Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Fable 5 c5765171d3 Merge origin/master (v0.46.3.0) into garrytan/opencode-support
Trio kept at 0.46.4.0 (wave version, highest); CHANGELOG carries both
entries with 0.46.4.0 topmost; template-repo + flag registry regenerated;
llms rebuilt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 21:29:17 -07:00
Garry TanandClaude Fable 5 cc3e284316 v0.46.3.0 feat(providers): ZeroEntropy deprecation — Voyage new-install default, sunset migration + playbook, September removal staged (#4160)
* feat(ai): Voyage reranker touchpoint + recipe sunset metadata + split-default constants

- Recipe.sunset (per-touchpoint replacement) on zeroentropyai; once-per-process
  warn-on-use in the gateway for embedding + rerank, with a test reset seam
- Voyage reranker touchpoint: rerank-2.5/-lite at /rerank, request key via
  top_param ('top_k'); response parser accepts both results[] (ZE/llama-server)
  and data[] (Voyage REST, live-wire verified)
- EmbeddingTouchpoint.default_model (voyage-4 — array order is quality-sorted,
  not recommendation-sorted); voyage-code-4 added incl. dims.ts membership
- Split-default: NEW_INSTALL_DEFAULT_* constants (voyage-4 @1024 + rerank-2.5);
  the legacy configless runtime fallback stays zembed-1 @1280 until the
  September removal so no existing brain changes behavior
- Voyage reranker + voyage-code-4 pricing rows (verified 2026-08-15)

* feat(init): new installs land on Voyage; sunset providers hidden from pick surfaces

- Auto-pick, multi-key canonical tiebreak, --embedding-model shorthand, and the
  interactive picker (selection + displayed row) all resolve default_model
- Recipes with sunset metadata are excluded from auto-pick and the picker;
  explicit --embedding-model still works with a loud warning
- Voyage-picked installs write search.reranker.model voyage:rerank-2.5 as
  explicit per-brain config (never clobbers an existing choice)
- Keyless fresh installs size the embedding column at the new-install width
  (1024) via an explicit init param; schema generators keep the legacy import
- gbrain providers flags sunsetting providers as DEPRECATED with the
  replacement, regardless of key readiness

* feat(migrations): v0.47.0 detect-and-notify migration + shared ZE exposure detection

- src/core/ze-exposure.ts: exposure = effective-model resolution (env → file →
  legacy fallback), never vector evidence; resolved-reranker + custom-column
  axes; tri-state status (probe failure → unknown, never silently clear);
  LIMIT-capped blast radius with a re-embed cost estimate; shared
  renderZeActionRequired for the migration notice and the upgrade banner
- v0.47.0 registry migration: notice-only — no config writes, never invokes
  migrate embeddings; unknown exposure completes with a loud advisory +
  pending-host-work entry instead of wedging the migration chain
- skills/migrations/v0.47.0.0.md: agent playbook (key-detection ladder,
  target-aware dims, reranker fix, custom-column honesty, wire-accurate
  self-host path)

* feat(sweep): stop steering anyone at ZeroEntropy; stage-2 sunset banner; doctor fixes

- ze-switch forward + --resume refuse with the migrate-embeddings escape route
  (--undo and --dry-run still work until the September removal)
- provider_sunset doctor fix-hint is target-aware on dimensions (voyage at its
  valid 1024 step; OpenAI keep-width only when valid); reranker hints name the
  provider's key generically
- Stage-2 upgrade banner (ze_sunset_notice_v2_shown) via detectZeExposure —
  fires on exposed, on unknown (fail-safe), and on a ZE-resolved reranker
  without a Voyage key; stage-1 banner's self-host copy made wire-accurate
- doctor.suppress_provider_sunset registered so the documented suppression
  command works; advisor/queue/probe/preflight/ops copy stops recommending ZE
  or refused commands; minion provider list derives from the recipe registry

* test(e2e): Voyage live wire suite + date guard on the ZeroEntropy live suite

- voyage-rerank-live: skip-gated real-key confirmation of the rerank wire
  (top_k request, data[] response, ordered results) + a voyage-4 1024d embed
- zeroentropy-live auto-skips on/after 2026-09-04 so a slipped removal wave
  cannot leave CI permanently red against a dead API

* docs: Voyage-first provider story; ZeroEntropy marked deprecated everywhere

Current-state sweep across README, install guides, provider matrix, migration
guide, retrieval architecture, KEY_FILES, and tutorials; September-removal
staged-deletion inventory filed in TODOS.md; llms bundles + CLI flag registry
regenerated.

* fix(ci): ci-local smoke count mirrors run-e2e's +1 phantom-redirect entry

run-e2e.sh deliberately appends test/phantom-redirect-engine-parity.test.ts
(Postgres arm needs the DATABASE_URL lane, #3485) to the test/e2e glob, but
the ci-local smoke check counted only the glob — the check fails on any tree
once the two counts drift. Pre-existing on master; surfaced here by adding a
new e2e file.

* fix: pre-landing review fixes

- providers explain matrix honors default_model + flags sunset providers as
  deprecated (and never recommends one); no-key recommendation → the voyage
  default
- ze-exposure: registry keys validated before rendering into the agent-directed
  banner (text-injection hardening); width-honest OpenAI copy (a static 1280
  was wrong for 640d/2560d brains — doctor prints the exact command);
  dims interpolated from the NEW_INSTALL constant; cappedCount marked private
  with a no-dynamic-SQL contract
- upgrade: drop the unreachable rerankerGate disjunct (reranker exposure is
  already status=exposed) and correct the comment
- init: both --no-embedding hints stop recommending the refused
  `config set embedding_model`; duplicated voyage-reranker-override blocks
  extracted into one shared helper
- ze-switch refusal message interpolates the sunset/default constants
- v0.47.0 migration: version/skill literals hoisted to constants; redundant
  dynamic import removed; pending-host-work append gains a torn-write newline
  guard; a FAILED host-work write now returns partial (retries next run)
  instead of complete

* test: coverage for the v0.47 surfaces the review flagged

- fresh-install e2e: voyage init writes the rerank-2.5 override; re-init never
  clobbers an explicit reranker choice; explicit sunset-provider init proceeds
  WITH the loud warning (D3 allow-explicit); keyless installs size at 1024
- picker units: sunset providers not offered; voyage pick resolves the
  canonical voyage-4 (selection AND displayed row)
- sunset warn-on-use: embedding-touchpoint call site + migrate copy
- ze-switch: --dry-run --json planned envelope restored
- voyage live e2e header/test names corrected to the data[] wire truth

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

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

* fix: adversarial review fixes — sunset-target gate, keyless recovery, file-plane keys

- migrate embeddings refuses a sunset TARGET (agent replaying an old runbook
  would strand the brain onto the dying provider days before the shutdown);
  --force-sunset-target is the loud self-hoster escape hatch, threaded through
  planEmbeddingMigration but deliberately NOT exposed on the MCP op.
- The documented keyless recovery command (`init --force --embedding-model ...`)
  now actually works: an explicit flag clears the seeded embedding_disabled
  sentinel AND the persist block stops re-writing the stale sentinel inherited
  via the ...existingFile spread (both engine paths).
- init provider readiness folds FILE-PLANE keys (buildGatewayConfig env) so a
  config.json-keyed non-interactive install stops silently landing keyless;
  fresh installs with only a sunset key get told which key was ignored and why.
- New-install reranker default generalized: voyage-keyed installs (any
  embedding provider) get the voyage override; keyed non-voyage installs get
  an explicit disable instead of inheriting the doomed legacy bundle default;
  keyless installs get NO write so the recovery re-init still lands its
  override; never-clobber checks both reranker config keys.
- doctor provider_sunset grows a custom-column arm (shared
  detectZeCustomColumns helper) — no more ok while ZE-backed columns are live.
- ze-exposure: capped blast radius renders a cost FLOOR ("at least ~$X");
  base-URL override surfaces a self-host note instead of claiming certain
  death; gateway warn-on-use skips base-URL-overridden providers entirely.
- v0.47.0 migration: unreachable-brain fake engine now THROWS from getConfig
  (returning null fabricated verified claims); detection crash still prints a
  self-contained fallback ACTION REQUIRED banner.
- providers explain prices the CANONICAL model via lookupEmbeddingPrice
  (recipe-wide hint tracks the flagship, wrong for voyage-4);
  embed-preflight + advisor recommendation copy swept to effective-model-aware
  non-refused commands.

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

* test: pin the adversarial-review fixes

- resolveMigrationTarget: sunset-target refusal + allowSunsetTarget escape.
- file-plane voyage_api_key → buildGatewayConfig fold → provider ready
  (ambient-env-scrubbed).
- e2e keyless → keyed recovery round trip: embedding_disabled cleared, model
  persisted, reranker override lands (keyless init left reranker config
  virgin).
- e2e keyed non-voyage install: reranker explicitly disabled, no model
  override written.

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

* docs: document --force-sunset-target in the migration guide + playbook

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

* test: credential-free unreachable-brain fixture (secret-scan guardrail false positive)

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

* docs: document-release audit — align docs with final behavior; unbreak compat-fetch proximity pin

Post-ship /document-release pass over the final diff. Reranker-default copy
corrected everywhere to the generalized truth (voyage-KEYED installs get the
override on any provider; keyed non-voyage installs get an explicit disable;
keyless installs get no write); --force-sunset-target + doctor's target-aware
commands and custom-column arm documented; headless-install pattern replaced a
hard-refused `config set embedding_model` ENTRYPOINT with the sanctioned
re-init; README/integration guides updated to keyless-continue + file-plane
key detection truth. zeroentropy-compat-fetch's source-text proximity window
widened (the sunset warn-on-use hook landed between the pinned cast and
resolveEmbeddingProvider). llms bundles regenerated.

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

* test: withEnv() for the file-plane fold test (test-isolation R1 gate)

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

* v0.46.3.0 chore: re-version the wave 0.47.0.0 → 0.46.3.0 (natural next off master)

Master sits at 0.46.1.x; this wave takes the natural next slot instead of
leapfrogging. The rename cascades through the migration identity — a registry
migration numbered above the binary version would be skipped as future —
so v0_47_0 → v0_46_3 (MIGRATION_VERSION 0.46.3), the agent playbook moves to
skills/migrations/v0.46.3.0.md, and every code/doc/test reference follows.
The September removal target label shifts v0.48 → v0.47 to keep the
deprecation→removal pairing consecutive. Lockfile, templates, skills
manifest, and llms bundles regenerated; typecheck + full verify (44/44) +
migration/version-coupled suites (288 tests) green.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 21:25:30 -07:00
Garry TanandClaude Fable 5 9e678f467d Merge origin/master (v0.46.2.0) into garrytan/opencode-support
Trio kept at 0.46.4.0 (wave version, highest); CHANGELOG carries both
entries with 0.46.4.0 topmost; TODOS union-resolved (master's re-filed
pin-privacy candidate dropped — this branch ships it as DONE);
cli-flag-registry + template-repo regenerated; llms rebuilt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 15:41:54 -07:00
Garry TanandClaude Fable 5 e38e4a845b v0.46.4.0 chore(release): re-bump 0.46.2.0 → 0.46.4.0 (version slots claimed by in-flight PRs)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 15:20:13 -07:00
Garry TanandClaude Fable 5 526595ce01 docs: cross-model doc-review fixes — opencode roster + probe/remote accuracy
Findings from the standard post-ship Codex doc review, verified against
the shipped code: the bootstrap guide's intro, install table, and door
inventory still described a two-client product (opencode added to all
three); INSTALL_FOR_AGENTS' grok section said the personal-agent path is
Claude Code/Codex only; OPENCODE.md called its recipe the bootstrap
"manual equivalent" (bootstrap additionally pins GBRAIN_SOURCE + full
surface), lacked the mcp-list trust caution the pin doc carries, and
never documented the connect --install / --force remote lane; the pin
doc's provisioning bullet now names the pack-verify-install posture the
CI job actually runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 13:22:41 -07:00
Garry TanandClaude Fable 5 2d8c772171 docs: fold the post-review opencode fix-wave behaviors into KEY_FILES
Three current-state completions the fix agents didn't carry into the
per-file index: connect --agent opencode --install's --force semantics
(maps to the writer's allowReplaceOtherSource — ours-at-old-url
replaceable, foreign still refuses), removeOpencodeMcpEntry's
skipOtherSource option, and bootstrap uninstall's expectation-keyed
opencode sweep (both merged global filenames under the config-dir lock
plus the project file; other-workspace entries skipped with a note).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 13:14:33 -07:00
Garry TanandClaude Fable 5 bdef2dada5 v0.46.2.0 feat(opencode): full-parity client support — bootstrap, harness, connect, claw-test, e2e door
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 12:54:44 -07:00
Garry TanandClaude Fable 5 cc902834d5 chore: regen cli-flag-registry after review-fix waves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 12:52:40 -07:00
Garry TanandClaude Fable 5 eaca6a94d8 fix: adversarial-review fix wave — cross-model (Claude + Codex) findings
P0: the registration probe no longer executes from the invoking cwd
(mkdtemp cwd for user scope; project scope skips the live probe —
parse-back is authoritative), so a cloned repo's committed opencode.json
can't gain code execution during bootstrap. Probe timeouts now kill the
child (SIGTERM→SIGKILL, bounded drain) instead of abandoning it over the
PGLite lock. Global writes reconcile mcp.<name> across BOTH merged
global filenames. Stale-target cleanup takes the target config-dir lock.
Backups are unique per operation; rollback is content-guarded and
remove-path backups tighten to 0600 when token-bearing. connect --force
now works on the opencode lane with url-appropriate refusal copy.
atomic-write cleans tmp litter and survives the exists/realpath race.
Bun-lane fingerprint is fail-closed on gbrain-less args. Scope answers
trim. bounded() clears its drain-cap timer. CI installs opencode from
byte-verified tarballs. Consent-semantics + hermetic-live-runner
follow-ups filed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 12:51:29 -07:00
Garry TanandClaude Fable 5 7df02405bc fix: pre-landing review fixes — review-army + red-team wave
Security: opencode error-path snippets render <paste-token-here> instead
of the live bearer; the harness opencode catch redacts like the claude
lane; test-harness child envs unconditionally drop GITHUB_TOKEN/ACTIONS_*.
Correctness: bootstrap-lock coverage for every shared-config writer
(hooks/connect/uninstall); uninstall + step-aside gate sweep BOTH global
opencode filenames (merged namespace); uninstall passes a sourceId
expectation and skips other-workspace entries; cross-kind fingerprint
matches classify ours-other-source instead of silently replacing;
dangling-symlink writes preserve the link; failed-smoke rollback restores
atomically; registration probe pins OPENCODE_DISABLE_AUTOUPDATE, a 20s
cap, ANSI-stripped exact-name matching. Guard: check-opencode-pin now
cross-checks per-platform integrities + every OPENCODE_VERSION copy.
Plus deny-path/uninstall/rollback/truth-table/symlink test coverage,
help-prose cosmetics, downgrade doc note, 3 P3 TODOs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 12:02:50 -07:00
Garry TanandClaude Fable 5 bf0e8795f8 fix(bootstrap): close two plan-audit gaps — ACCESS_POLICY opencode scope paragraph + doctor host:opencode pin
Plan-completion audit (ship Step 8) flagged both as PARTIAL: the
ACCESS_POLICY template's MCP-scope section didn't state opencode's
inverted default (user-global; project spawns with NO trust prompt),
and bootstrap_harness_health had no named pin proving an opencode
receipt flows through the host-generic filter. Template-repo regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 11:13:26 -07:00
Garry TanandClaude Fable 5 a534240d2c test: coverage pins for the opencode channel widenings
Ship-audit additions: hook.ts --harness opencode flag-parse attribution
end-to-end, and 'opencode' membership in VOLUNTEER_CHANNELS +
isHarnessChannel (a regression here silently rebadges opencode deliveries
as claude-code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 10:59:54 -07:00
Garry Tan 6db8dc8a34 Merge remote-tracking branch 'origin/master' into garrytan/opencode-support
# Conflicts:
#	TODOS.md
2026-08-15 10:45:27 -07:00
Garry TanandClaude Fable 5 750275533f chore(todos): file opencode-wave follow-ups + retire fired triggers by title
Door-adapter extraction (test-side) and cadence policy: DONE — the 4th-door
trigger fired. CI-tail composite action re-filed with the sharpened trigger
(first GREEN grok-door AND opencode-door dispatches). hermesChildEnv
GITHUB_* backport: DONE via the shared factory. PIN-doc privacy guard:
DONE (check-pin-doc-privacy.sh in verify). New follow-ups: first-dispatch
watch, plugin/event-system wiring (ambient-recall lane), BrainBench
adapter (with hermes+grok), connect --oauth authorization-code lane,
OPENCODE_CONFIG* re-observation on bumps, opencode-install PTY promotion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 10:26:44 -07:00
Garry TanandClaude Fable 5 17e36adb57 dx(explore): opencode-install TTY scenario — bootstrap paste block under the real interactive TUI
Unlike grok's brain-only prompt, opencode gets the FULL bootstrap paste
block (it is a bootstrap-supported harness) under a hermetic HOME + both
XDG dirs, the double autoupdate kill (config seed + env), BROWSER=false so
a first-run can never bounce the operator's browser, and auth.json
pre-registered for the secret scrub. Keyless posture INVERTS the grok
scenario: the anonymous free tier means a --keyless run should COMPLETE
the flow — a sign-in wall here is itself a pin-refresh signal, and the
generic early-stop in runInstallSession records it as friction if it ever
appears.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 10:25:52 -07:00
Garry TanandClaude Fable 5 519c3748da docs: opencode across the install/testing/architecture surface
README client roster + remote-connect bullet (with the not-OpenClaw
disambiguation and the bootstrap-supported banner), INSTALL_FOR_AGENTS
'If you are opencode' block (routes bootstrap-capable readers to the
runbook; brain-only registration otherwise), docs/INSTALL per-client list,
MEMORY_VERBS register snippet, bootstrap guide (degradation-matrix row
naming the INVERTED scope default + rationale; harness-mode opencode
bullet; dx-explore scenario line), ambient-recall/push-context harness
mentions, and KEY_FILES current-state entries (opencode-json.ts,
atomic-write.ts, connect/harness/hooks/claw-test entry refreshes).
llms bundles regenerated (build:llms chaser).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 10:24:35 -07:00
Garry TanandClaude Fable 5 fcbf547841 ci(heavy): opencode-door job (day-one full posture, keyless SMOKE) + canary leg + pin guards + hermes installer re-pin
opencode-door takes hermes-door's triggers (nightly + labels + dispatch;
cadence policy: nightly for the NEWEST door agent) with grok-door's
internals — keyless-first ordering, secretless npm provisioning with
wrapper AND per-platform integrity pre-checks, pass-count + paid sentinels,
mid-job version-drift tripwire, evidence scrub RE-KEYED to
ANTHROPIC_API_KEY + auth.json (not XAI/mcp_credentials), unconditional
credential removal. No dedicated dispatch input (any workflow_dispatch
already passes the non-PR arm — an input would be dead yaml). The keyless
tier includes the nonce SMOKE (free tier), so the core door needs NO
secret; the paid anthropic leg rides the secret hermes-door already
consumes. opencode-door-canary lands IN-WAVE (schedule-only,
continue-on-error, unpinned latest): opencode ships near-continuously — a
red canary is a pin-refresh signal, never a gate. real-agent-e2e adds the
opencode door file + env pins.

Guards: check-opencode-pin.sh (stamp↔workflow parity, job-block anchored so
the UNPINNED canary leg cannot satisfy it; fail-closed when the door exists
without the pin doc) and check-pin-doc-privacy.sh (placeholder discipline
for ALL docs/mcp/*-CLI-PIN.md — no operator home paths, no key-shaped
material outside sha512 pins, no non-example emails), both in bun run
verify + guards-manifest, both with fixture-tree bun tests.

Maintenance: hermes-door installer pin refreshed (upstream install.sh
drifted past the prior digest — last two nightlies red; reviewed: the
--commit payload-pin path is intact and the payload pins are unchanged).
docs/TESTING.md gains the opencode door entry + the door cadence policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 10:21:12 -07:00
Garry TanandClaude Fable 5 422438bfe7 test(e2e): opencode door — split-gated real-binary e2e on the extracted family (keyless SMOKE included)
The door goes a step beyond grok's split gating: opencode's anonymous free
tier drives MCP tool calls with zero credentials (observed, load-bearing),
so even the nonce SMOKE runs keyless. Tiers: T1 bare-semver version pin
(the SST-vs-claimant discriminator), T2 INSTALL via the documented
'mcp add … -- gbrain serve --surface verbs' shape + the honest 'mcp list'
discriminator (spawns servers; ✓/✗ text asserted — exit code is 0 even on
failure), T2b spawn-gate CANARY (a project-config decoy is spawn-attempted
with no trust prompt — if this ever gates, the bootstrap user-global
default rationale changed: re-observe), T3 writer parity (gbrain's
opencode-json.ts output handshakes through the real binary; cross-tool
preservation both ways incl. the autoupdate seed), T4 keyless SMOKE
(per-run nonce + STRUCTURAL gbrain_* tool_use proof via parseOpencodeJsonl,
list preflight before any turn, 2 attempts), and the paid T5 anthropic leg
(hasOpencodeAuth-gated; self-validating models-gate pins the model id
BEFORE any spend). Hermeticity: HOME + both XDG dirs per child, tmp cwds,
config/credential tripwire over the operator's real opencode state,
checkout guard, --pure on every probe (mcp list autoloads plugins), --pure
placed BEFORE the '--' separator (a trailing append lands inside the server
command — caught live). run-e2e.sh scrubs the OPENCODE_ prefix.

Verified live: 6/6 pass in 35.8s (keyless tier + paid anthropic leg)
against the hermetically pinned opencode-ai@1.18.18.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 10:13:21 -07:00
Garry TanandClaude Fable 5 ccc9f171c0 refactor(test): door-family extraction in agent-harness — shared resolver/childEnv/spawn core; grok+hermes ported; opencode first consumer
The 'P3 — Door-adapter extraction + CI-tail composite action' TODO armed
this at 'the NEXT door agent (4th)' — opencode is the 4th. Test-side only
(the composite CI-tail action stays deferred until the first green
grok-door AND opencode-door dispatches; workflow yaml can't be proven
locally).

- makeBinaryResolver: one shape (fail-closed $*_BIN > which > landing
  spots + nvm/PATH sweeps); claude/codex/hermes/grok resolvers become
  factory products with identical candidate lists.
- makeAgentChildEnv + GITHUB_STEP_META_KEYS: hermeticChildEnv + per-agent
  overrides + key deletion + the step-metadata scrub + binDir prepend.
  hermesChildEnv GAINS the GITHUB_* deletion via the factory (the filed P2
  backport; truth-table extended).
- runOneShotSpawn: shared timeout/kill/kill-9-escalation/bounded-drain core;
  hermesOneShotTurn gains the escalation + bounded drain (strictly safer,
  nothing pinned the old unbounded wait); grokOneShotTurn is now a thin argv
  builder over it.
- 5a-opencode family (first consumer): resolveOpencodeBinary (fail-closed
  OPENCODE_BIN), hasOpencodeAuth (PAID-leg-only gate — the keyless free
  tier carries the core SMOKE), opencodeChildEnv (HOME + BOTH XDG dirs,
  anthropic re-admission, other-provider + OPENCODE_CONFIG* shadow-trio
  deletes), seedOpencodeConfig (config half of the double autoupdate kill),
  opencodeOneShotTurn, and parseOpencodeJsonl (event shapes pinned from the
  live v1.18.18 observation — {type,part} with part.text / part.tool).

EV1 gate (local keyless grok-door): 4 pass / paid-skip in 20.5s BEFORE and
AFTER the port, against a hermetically npm-pinned @xai-official/grok@1.0.4.
The hermes door self-skips without a binary — its port is pinned by the
unit truth-tables (stated honestly, per the plan).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 10:08:18 -07:00
Garry TanandClaude Fable 5 56a272f18d feat(claw-test): opencode runner — detection, pinned one-shot invoke, multi-provider env allowlist
OpencodeRunner (4th AgentRunner): detectBinary('OPENCODE_BIN','opencode');
argv pinned to 'run <brief> --format default' (explicit format so an
upstream default flip cannot silently change the transcript shape; NO
--auto — MCP tool calls fire in run mode without it, verified). Env
allowlist = BASE + an EXPLICIT multi-provider delta (XAI / Google / Gemini
/ OpenRouter keys — BASE carries only Anthropic+OpenAI, and a live-lane
operator on other providers would otherwise see a misleading auth failure)
+ XDG dirs + OPENCODE_CONFIG(_DIR) + OPENCODE_DISABLE_AUTOUPDATE;
OPENCODE_CONFIG_CONTENT (the inline config-shadow channel) deliberately
absent. Bare-semver version preamble (the SST-vs-claimant discriminator)
+ global-config mcp.gbrain contamination tripwire (JSONC-tolerant, checks
BOTH merged filenames). --list-agents pin moves to all four runners with
the openclaw<opencode ordering note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 10:01:21 -07:00
Garry TanandClaude Fable 5 40c5774fa8 feat(connect): --agent opencode — env-interpolated bearer, direct-writer --install
buildOpencodeMcpAddArgv pins the validated one-liner: the --header value
carries opencode's {env:GBRAIN_REMOTE_TOKEN} interpolation LITERALLY, so the
token never enters argv, the config file, or --json output. The print block
mirrors codexBlock (export line + one-liner + restart note). --install goes
through a new ConnectDeps.writeOpencodeRemoteEntry member (the existing
injectable seam, connect.ts:ConnectDeps) wrapping the JSONC writer in env
token mode — no opencode binary required, idempotent re-runs, foreign
same-name entries refuse with the writer's message (token-redacted), and
the D4 probe smoke-tests the credential end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:58:16 -07:00
Garry TanandClaude Fable 5 71fb120514 feat(bootstrap): opencode harness-mode target — managed remote entry with inline bearer, remove/status/rollback
HarnessSelector gains 'opencode' (forced-wire like codex: the JSONC writer
needs no opencode CLI). Wiring is one managed mcp.<name> remote entry with
the inline Authorization bearer in the user-global opencode config, 0600,
under the [X11] lock ordering (config-dir → opencode-dir). Ownership [C8]:
idempotent re-runs match on the serve url; rotation across a url change
recognizes the old entry via the PRIOR receipt's url; anything else under
the name refuses inside the writer. Failed-smoke rollback restores the .bak
or removes a fresh entry, and the fresh mint is revoked (impostor-guard
economics hold). --remove classifies against the receipt url and skips
not-ours entries with a note; --status recovers the bearer from the entry
(url-matched — a foreign entry's credential is never transmitted). Consent
copy: per-host numbered item, joined-list reach statement (a fourth harness
can no longer silently mislabel the ternary tree), opencode off-ramp.

Fixture hygiene: the harness serial fixture now injects opencodeConfig +
detectOpencode — the default path resolution reaches the operator's REAL
~/.config/opencode (the claudeUserSettingsPath lesson, caught live when the
registrar-mode test wrote a fixture token there; cleaned up).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:53:45 -07:00
Garry TanandClaude Fable 5 3577432456 feat(bootstrap): opencode workspace lane — hooks --harness opencode, scope-aware direct-writer registration, channels, templates
The Harness union widening is typecheck-SILENT at every existing
'claude-code ? A : B' ternary, so the hot sites now dispatch exhaustively
(HARNESSES satisfies anchor; exec-lane bin map returns null for opencode —
its registrations go through the JSONC writer whose fingerprint IS the
[FIX7] check, never through <host> mcp get).

Scope INVERSION for opencode: default user-global (opencode spawns
project-config-defined MCP servers with NO trust prompt — verified; a
committed project entry would auto-execute on every collaborator machine).
MCP_SCOPE=project is an explicit opt-in that writes the workspace
opencode.json with a PATH-resolved command (committed-candidate file: no
absolute machine paths, no fail-open analog exists) and prints the sharing
warning + enabled:false opt-out. detectHarness probes OPENCODE/OPENCODE_PID
(observed 1.18.18). Ownership: a remote-type mcp.gbrain in the global
config makes the stdio lane step aside (codexBlockOwnsName analog); foreign
entries refuse. Verification: writer post-render parse-back is
authoritative; best-effort 'opencode mcp list --pure' probe (skipped on
plugin-bearing configs — mcp list is a code-execution surface).

Atomic with this commit (each-commit-green): questions.json MCP_SCOPE +
SURFACE_PRIMARY copy, AGENTS/GITHUB template pull-protocol generalization,
BOOTSTRAP_FOR_AGENTS.md scope guidance + opencode wiring bullet,
status.ts interview/wire resume hints, check-bootstrap-templates.sh §(e)
pins (now 'Claude Code and opencode' + the 'NO trust prompt' spawn-gate
rationale pin), guard-test fixtures, the status-test hint pin, the vendored
template-repo regen, the offline docker opencode leg, and uninstall's
receipt-keyed opencode removal. Channels: 'opencode' joins
VOLUNTEER_CHANNELS + HARNESS_CHANNELS (reserved attribution slot, codex
precedent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:45:48 -07:00
Garry TanandClaude Fable 5 39687cb7fe feat(bootstrap): opencode-json managed config writer + opencode-2026-08 host spec
- src/core/bootstrap/opencode-json.ts: comment-preserving JSONC writer
  (jsonc-parser surgical edits — opencode's own mcp add preserves comments,
  the writer matches that bar). Ownership is a 4-state structural
  fingerprint (ours-same-source | ours-other-source | foreign | absent)
  keyed on GBRAIN_SOURCE EQUALITY ([FIX7] parity), never a marker key.
  Distinct read-failure classes (ENOENT create / empty-as-{} / unreadable
  refuse); foreign refusal on write AND remove; post-render validation
  (our entry round-trips, every other key survives) keeps the original on
  failure; 0600 + .bak-0600 only for inline-bearer entries; bearer
  recovery helper for harness --status.
- host-specs.ts: TARGETS['opencode-2026-08'] (verified 2026-08-15 against
  a hermetic opencode-ai@1.18.18) + opencodeConfigDir/GlobalConfigPath/
  ProjectConfigPath (XDG-only — OPENCODE_CONFIG* observed INERT in
  1.18.18, honoring them would be a silent no-op install) +
  OPENCODE_HAS_HOOKS=false.
- atomic-write.ts: rule-of-three extraction of the symlink-resolving,
  mode-inheriting atomic writer; codex-toml.ts + hooks.ts ported onto it
  (behavior pinned by their existing suites).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:31:45 -07:00
Garry TanandClaude Fable 5 6d25dd0711 docs(mcp): pin observed opencode CLI behavior (OPENCODE-CLI-PIN.md) + registration guide
Phase-0 hermetic observation of opencode-ai@1.18.18 (npm wrapper + platform
payload integrities pinned). Load-bearing observations: keyless anonymous
free tier answers headless runs AND drives MCP tool calls without --auto
(nonce SMOKE proven end-to-end against a real gbrain serve --surface verbs);
mcp list is the honest discriminator (spawns servers, exit 0 regardless —
parse the text); mcp add takes '-- command' (undocumented in --help) but
always writes user-global opencode.jsonc; project-defined local servers
spawn with NO trust gate (drives the user-global bootstrap default); JSONC
parses in .json-named files and both filenames merge; OPENCODE_CONFIG* env
vars observed inert (docs-contradiction, called out); OPENCODE=1 set in bash
children (detectHarness probe); AGENTS.md loads, CLAUDE.md not double-loaded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:25:00 -07:00
136 changed files with 9558 additions and 780 deletions
+274 -4
View File
@@ -109,8 +109,9 @@ jobs:
retention-days: 14
if-no-files-found: ignore
# Real-agent door e2e: drives the ACTUAL `claude` + `codex` + `hermes`
# binaries (no PATH shims) against a real gbrain over MCP. These pay real API
# Real-agent door e2e: drives the ACTUAL `claude` + `codex` + `hermes` +
# `grok` + `opencode` binaries (no PATH shims) against a real gbrain over
# MCP. These pay real API
# cost and need the binaries installed + authed, which a stock GitHub runner
# does NOT have — so the tests self-SKIP (describe.skipIf on binary/auth) and
# the job is a clean no-op here. It exists so a self-hosted /
@@ -133,10 +134,13 @@ jobs:
# a grok binary, which a stock runner does not have.
GBRAIN_REAL_HERMES_E2E: '1'
GBRAIN_REAL_GROK_E2E: '1'
GBRAIN_REAL_OPENCODE_E2E: '1'
# Pin so a provisioned runner's grok version-shape test asserts against
# the supported version (and a colliding community `grok` binary fails
# loud instead of running the keyless tier confusingly).
GROK_VERSION: "1.0.4"
# Same posture for opencode: a provisioned runner's version pin.
OPENCODE_VERSION: "1.18.18"
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
@@ -156,7 +160,8 @@ jobs:
test/e2e/bootstrap-real-claude.serial.test.ts \
test/e2e/bootstrap-real-codex.serial.test.ts \
test/e2e/install-real-hermes.serial.test.ts \
test/e2e/install-real-grok.serial.test.ts; do
test/e2e/install-real-grok.serial.test.ts \
test/e2e/install-real-opencode.serial.test.ts; do
[ -f "$f" ] && files+=("$f")
done
if [ "${#files[@]}" -eq 0 ]; then
@@ -195,7 +200,7 @@ jobs:
HERMES_VERSION: "0.20.0"
HERMES_GIT_TAG: "v2026.8.3"
HERMES_GIT_COMMIT: "3c27eb6234bf91b8ceee9e9071591b31e9b148cb"
HERMES_INSTALL_SHA256: "c118ff31618dc70339049ce71061b8f1351a1c70d9c2a236ed50d8a2550c550d"
HERMES_INSTALL_SHA256: "868ed3a91e0fabbff6d7418b3ede82bf4833652ec4e77196a42852fb35a9e5b9"
GBRAIN_REAL_HERMES_E2E: '1'
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
@@ -595,3 +600,268 @@ jobs:
# hermetic homes carry no key file (env-only auth) but may hold
# grok-derived credentials once the authed inventory lands.
rm -rf /tmp/gb-grok-* 2>/dev/null || true
# opencode door e2e (SST opencode): PROVISIONS the real opencode binary via
# the pinned npm package (wrapper + per-platform payload integrities
# verified — both pins live in docs/mcp/OPENCODE-CLI-PIN.md, enforced
# against this file by scripts/check-opencode-pin.sh in `bun run verify`).
#
# DAY-ONE FULL POSTURE (a step past grok's pre-secret gating, deliberate):
# opencode's anonymous free tier drives MCP tool calls keyless (observed,
# load-bearing — OPENCODE-CLI-PIN.md §One-shot), so the ENTIRE core door —
# including the nonce SMOKE — runs with no secret; and the paid anthropic
# leg rides the ANTHROPIC_API_KEY secret that already exists (hermes-door
# consumes it). So this job takes the hermes-door triggers (nightly +
# labels + dispatch, cadence policy: nightly for the NEWEST door agent)
# with grok-door's internals (keyless-first ordering, secretless pinned
# provisioning, sentinels, scrub triple, unconditional credential removal).
# No dedicated dispatch input: any workflow_dispatch already passes the
# non-PR arm, so an input would be dead yaml.
opencode-door:
name: opencode door e2e (real binary, keyless SMOKE)
if: |
github.event_name != 'pull_request' ||
contains(github.event.pull_request.labels.*.name, 'real-agent-e2e') ||
contains(github.event.pull_request.labels.*.name, 'heavy-tests')
runs-on: ubuntu-latest
# Measured local door wall-time: full 6-test run 35.8s + one-time
# compiled gbrain build (~2-4 min) + npm install (~15s); free-tier +
# paid turn budgets 2 x 240s each. 20 min = measured + >50% headroom.
timeout-minutes: 20
env:
# Pin values documented in docs/mcp/OPENCODE-CLI-PIN.md — update them
# together, deliberately, after reviewing upstream changes
# (scripts/check-opencode-pin.sh fails `bun run verify` on drift).
OPENCODE_VERSION: "1.18.18"
OPENCODE_NPM_PACKAGE: "opencode-ai"
OPENCODE_NPM_INTEGRITY: "sha512-J+5HFq8tf+wPBBpBpMPSNjSytF2/EkNWYfFZh4si1d9auFbQriqDyqZv+vFUsLWERfdMU32Eajwuiq3rKBvZLQ=="
# Per-platform payload pins: the wrapper's integrity covers only the
# wrapper tarball; the binary that EXECUTES is the platform sub-package.
OPENCODE_NPM_LINUX_X64_INTEGRITY: "sha512-WmeUnhljYJ252wywKTiW4bNDzsas2njpjPUEh0jM6HKNI4vFxJtREtzaWViY4AKEAcOkLWT8Ll17ixvcHz3AnA=="
OPENCODE_NPM_LINUX_ARM64_INTEGRITY: "sha512-e8D3g0qJEIzawEg2+ygW3vkZjAYL2ssyAx4GbihjwXwZFvlZZy5zRWWzdz5KLBoHSTl0FB73vNtnNeXONyHpVQ=="
GBRAIN_REAL_OPENCODE_E2E: '1'
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
- run: bun install
- name: Prepare evidence dir
run: |
echo "GBRAIN_E2E_EVIDENCE_DIR=$RUNNER_TEMP/opencode-door-evidence" >> "$GITHUB_ENV"
mkdir -p "$RUNNER_TEMP/opencode-door-evidence"
# Compile gbrain ONCE for both bun test invocations below.
- name: Build gbrain (compile once for both door runs)
run: |
bun build --compile --outfile "$RUNNER_TEMP/gbrain-door-bin" src/cli.ts
echo "GBRAIN_COMPILED_BIN=$RUNNER_TEMP/gbrain-door-bin" >> "$GITHUB_ENV"
# SECRETLESS provisioning, pack-verify-install: `npm pack` DOWNLOADS
# each artifact and reports the integrity of the BYTES it wrote, so the
# asserts below cover the tarballs actually held — closing the
# view-then-install TOCTOU (two registry round-trips a payload-swapping
# registry could split). The wrapper then installs FROM the verified
# local tarball, not a fresh registry resolve of the name. Payload
# resolution, honestly: that install still fetches the platform
# sub-package (opencode-linux-*) over the network; after the pack step
# byte-confirms the registry's payload artifact matches its pin, npm
# validates the install-time fetch against the same packument
# integrity. No --ignore-scripts: opencode-ai's postinstall places the
# platform binary (verified locally — with the flag the CLI refuses to
# run). Version assert lives here too — before any secret-bearing step.
- name: Install opencode (pinned npm package, pack-verify-install)
timeout-minutes: 10
run: |
packdir=$(mktemp -d)
read_integrity() {
node -e 'let d;try{d=JSON.parse(require("fs").readFileSync(0,"utf8"))}catch{d=[]}process.stdout.write((Array.isArray(d)&&d[0]&&d[0].integrity)||"")'
}
pushd "$packdir" >/dev/null
served=$(npm pack "$OPENCODE_NPM_PACKAGE@$OPENCODE_VERSION" --json 2>/dev/null | read_integrity || true)
if [ "$served" != "$OPENCODE_NPM_INTEGRITY" ]; then
echo "::error::opencode npm integrity drift for $OPENCODE_NPM_PACKAGE@$OPENCODE_VERSION — packed tarball integrity '$served', pinned '$OPENCODE_NPM_INTEGRITY'. Re-pin deliberately: update the stamps in docs/mcp/OPENCODE-CLI-PIN.md + this workflow after reviewing upstream (see the pin doc's re-observation checklist)." >&2
exit 1
fi
arch=$(uname -m)
case "$arch" in
x86_64) plat_pkg="opencode-linux-x64"; plat_pin="$OPENCODE_NPM_LINUX_X64_INTEGRITY" ;;
aarch64|arm64) plat_pkg="opencode-linux-arm64"; plat_pin="$OPENCODE_NPM_LINUX_ARM64_INTEGRITY" ;;
*) echo "::error::unsupported runner arch for the opencode payload pin: $arch" >&2; exit 1 ;;
esac
plat_served=$(npm pack "$plat_pkg@$OPENCODE_VERSION" --json 2>/dev/null | read_integrity || true)
if [ "$plat_served" != "$plat_pin" ]; then
echo "::error::opencode platform payload integrity drift for $plat_pkg@$OPENCODE_VERSION — packed tarball integrity '$plat_served', pinned '$plat_pin'. Re-pin deliberately (OPENCODE-CLI-PIN.md stamps + this workflow)." >&2
exit 1
fi
npm install -g ./opencode-ai-*.tgz
popd >/dev/null
rm -rf "$packdir"
if ! command -v opencode >/dev/null 2>&1; then
echo "::error::opencode did not resolve on PATH after npm install" >&2
exit 1
fi
version_output=$(opencode --version)
echo "$version_output"
# Observed shape: BARE semver (`1.18.18` — no name, no hash); the
# SST-vs-claimant discriminator (OPENCODE-CLI-PIN.md §Pin).
if [ "$(printf '%s' "$version_output" | tr -d '[:space:]')" != "$OPENCODE_VERSION" ]; then
echo "::error::opencode version drift — expected bare '$OPENCODE_VERSION', got: $version_output (see docs/mcp/OPENCODE-CLI-PIN.md triage table)" >&2
exit 1
fi
# KEYLESS TIER FIRST — and on opencode that includes the nonce SMOKE
# (free tier). ANTHROPIC_API_KEY is absent from this step by
# construction, so the paid describe self-skips.
- name: Run opencode door tests (keyless tier — SMOKE included)
run: |
EXIT=0
bun test --timeout=600000 test/e2e/install-real-opencode.serial.test.ts > door-keyless.txt 2>&1 || EXIT=$?
tail -40 door-keyless.txt
cp door-keyless.txt "$GBRAIN_E2E_EVIDENCE_DIR/" 2>/dev/null || true
if [ "$EXIT" -ne 0 ]; then
exit "$EXIT"
fi
# Exact expected shape for this tier: 5 keyless tests pass (T1, T2,
# T2b, T3, T4-SMOKE), the 1 paid test skips. Zero/partial-pass
# refuses green.
pass_count=$(grep -Eo '[0-9]+ pass' door-keyless.txt | tail -1 | grep -Eo '^[0-9]+' || true)
if [ -z "$pass_count" ] || [ "$pass_count" -lt 5 ]; then
echo "::error::opencode door keyless tier expected 5 passing tests, summary shows '${pass_count:-none}' — refusing to go green (see docs/mcp/OPENCODE-CLI-PIN.md triage table)" >&2
exit 1
fi
- name: Preconditions (secret present)
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
if [ -z "$ANTHROPIC_API_KEY" ]; then
echo "::error::ANTHROPIC_API_KEY secret is empty — the keyless tier above already ran (its coverage, including the SMOKE, is banked); the paid anthropic leg needs the secret hermes-door already consumes. Fork PRs get no secrets from GitHub." >&2
exit 1
fi
# Full run (paid anthropic leg included). The T5 models-gate inside the
# suite is the named bad-pin tripwire: it validates the pinned model id
# against the AUTHED `opencode models` list BEFORE any spend.
- name: Run opencode door tests (full — paid anthropic leg included)
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
EXIT=0
bun test --timeout=600000 test/e2e/install-real-opencode.serial.test.ts > door.txt 2>&1 || EXIT=$?
tail -40 door.txt
cp door.txt "$GBRAIN_E2E_EVIDENCE_DIR/" 2>/dev/null || true
if [ "$EXIT" -ne 0 ]; then
exit "$EXIT"
fi
# PAID-SENTINEL: with the key present, a skipping paid tier must
# never read as green (the split-gating false-green class). The
# grep target is the suite's literal skip log — mirrored in
# test/e2e/install-real-opencode.serial.test.ts (change together).
if grep -q 'SKIP paid tier' door.txt; then
echo "::error::opencode door paid tier skipped despite a present ANTHROPIC_API_KEY — hasOpencodeAuth() gate drift; refusing to go green" >&2
exit 1
fi
pass_count=$(grep -Eo '[0-9]+ pass' door.txt | tail -1 | grep -Eo '^[0-9]+' || true)
if [ -z "$pass_count" ] || [ "$pass_count" -lt 6 ]; then
echo "::error::opencode door full run expected 6 passing tests, summary shows '${pass_count:-none}'" >&2
exit 1
fi
# Auto-update tripwire: the DOUBLE kill (config seed + env var) is the
# whole defense — a version that MOVED mid-job means it failed and the
# pins above are no longer what just ran.
- name: Version re-check (mid-job drift tripwire)
if: always()
run: |
if command -v opencode >/dev/null 2>&1; then
version_output=$(opencode --version || true)
if [ "$(printf '%s' "$version_output" | tr -d '[:space:]')" != "$OPENCODE_VERSION" ]; then
echo "::error::opencode version moved mid-job — auto-update kill failed (expected '$OPENCODE_VERSION', got: $version_output)" >&2
exit 1
fi
fi
- name: Scrub credentials from evidence (defensive)
if: failure() && env.GBRAIN_E2E_EVIDENCE_DIR != ''
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
# Same triple as the sibling doors, RE-KEYED for this lane: the
# credential file candidate is opencode's auth.json and the content
# grep sweeps ANTHROPIC_API_KEY (not XAI). Auth is env-only here —
# the content grep is the layer that matters for opencode-written
# logs on the failure path.
find "$GBRAIN_E2E_EVIDENCE_DIR" -type f \( -name '.env' -o -name '*.env' -o -name 'auth.json' \) -exec rm -f {} + 2>/dev/null || true
find "$GBRAIN_E2E_EVIDENCE_DIR" -type l -delete 2>/dev/null || true
if [ -n "$ANTHROPIC_API_KEY" ]; then
grep -rlF "$ANTHROPIC_API_KEY" "$GBRAIN_E2E_EVIDENCE_DIR" 2>/dev/null | while IFS= read -r f; do
echo "::warning::removing evidence file containing the API key: ${f#"$GBRAIN_E2E_EVIDENCE_DIR"/}" >&2
rm -f "$f"
done
fi
- name: Upload opencode door evidence
if: failure() && env.GBRAIN_E2E_EVIDENCE_DIR != ''
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: opencode-door-evidence
path: ${{ env.GBRAIN_E2E_EVIDENCE_DIR }}
retention-days: 14
if-no-files-found: ignore
# Auth travels env-only, but a future login flow would persist
# auth.json — remove the known candidate unconditionally so nothing
# outlives the job even on a future self-hosted runner.
- name: Remove opencode credentials (unconditional)
if: always()
run: |
rm -f ~/.local/share/opencode/auth.json
rm -rf /tmp/gb-opencode-* 2>/dev/null || true
# opencode canary: latest-version leg (schedule-scoped, continue-on-error,
# own timeout — landed IN-WAVE, reversing the grok-style deferral, because
# opencode ships near-continuously and a frozen pin goes stale in weeks;
# the pinned lane above stays the deterministic gate while this tracks
# what users actually run). Keyless tier only (incl. the free-tier SMOKE);
# no secret ever reaches this job. A red here is a PIN-REFRESH SIGNAL
# (OPENCODE-CLI-PIN.md §Pin-refresh cadence), never a gate.
opencode-door-canary:
name: opencode door canary (latest, keyless, non-gating)
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
timeout-minutes: 20
continue-on-error: true
env:
GBRAIN_REAL_OPENCODE_E2E: '1'
# Deliberately NO OPENCODE_VERSION pin: T1 asserts the bare-semver
# SHAPE only, and the suite runs against whatever `latest` is today.
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
- run: bun install
- name: Build gbrain
run: |
bun build --compile --outfile "$RUNNER_TEMP/gbrain-door-bin" src/cli.ts
echo "GBRAIN_COMPILED_BIN=$RUNNER_TEMP/gbrain-door-bin" >> "$GITHUB_ENV"
- name: Install opencode@latest (unpinned — the whole point)
timeout-minutes: 10
run: |
npm install -g opencode-ai@latest
command -v opencode >/dev/null 2>&1
echo "canary version: $(opencode --version)"
- name: Run opencode door tests (keyless tier against latest)
run: |
EXIT=0
bun test --timeout=600000 test/e2e/install-real-opencode.serial.test.ts > door-canary.txt 2>&1 || EXIT=$?
tail -40 door-canary.txt
if [ "$EXIT" -ne 0 ]; then
echo "::warning::opencode canary red against latest — pin-refresh signal (OPENCODE-CLI-PIN.md §Pin-refresh cadence); the pinned lane is the gate."
exit "$EXIT"
fi
+25 -12
View File
@@ -1,16 +1,17 @@
<!-- gbrain-runbook-stamp: 0.46.2.0 -->
<!-- gbrain-runbook-stamp: 0.46.4.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. -->
# BOOTSTRAP_FOR_AGENTS.md — become your human's persistent agent
You are an AI agent (Claude Code or Codex) reading this because a human pasted a
block asking you to set yourself up as their persistent personal agent, with gbrain
as your memory. This runbook is the judgment layer; the deterministic work lives in
`gbrain bootstrap` subcommands with exit codes. Follow it top to bottom.
You are an AI agent (Claude Code, Codex, or opencode) reading this because a human
pasted a block asking you to set yourself up as their persistent personal agent, with
gbrain as your memory. This runbook is the judgment layer; the deterministic work lives
in `gbrain bootstrap` subcommands with exit codes. Follow it top to bottom.
**Scope note:** this path is for Claude Code and Codex (desktop apps or CLIs).
**Scope note:** this path is for Claude Code, Codex, and opencode (desktop apps or
CLIs; opencode = the SST terminal agent, opencode.ai — not OpenClaw).
Running OpenClaw or Hermes? Use `INSTALL_FOR_AGENTS.md` instead.
**End state:** this folder is your workspace — identity files rendered from your
@@ -96,12 +97,16 @@ you needed; report the count at the end (it feeds the install-time measurement).
3. **Interview.** `gbrain bootstrap interview --init`, then ask the questions from
the bank (the CLI prints them) in three batches, recording each answer verbatim
with `--set KEY "value"`. Push once on vague answers to the required questions.
Claude Code only: with the final batch, also ask the ONE operational consent —
MCP scope. It is not one of the 12 interview questions; consents ride alongside
the bank. The choice: project (recommended — any other repo you open cannot
read your brain) vs user (your agent everywhere, but any repo you open can
reach it — read and write — and two open sessions contend for the database).
Record it with
Claude Code and opencode: with the final batch, also ask the ONE operational
consent — MCP scope. It is not one of the 12 interview questions; consents ride
alongside the bank. On Claude Code the choice: project (recommended — any other
repo you open cannot read your brain) vs user (your agent everywhere, but any
repo you open can reach it — read and write — and two open sessions contend for
the database). On opencode the recommendation INVERTS: user-global is the
default and the sharing-safe choice (opencode spawns project-config-defined
servers with NO trust prompt, so a committed project entry executes on every
collaborator's machine) — offer project only as a deliberate opt-in and state
that consequence. Record it with
`gbrain bootstrap interview --set MCP_SCOPE <project|user>` BEFORE the
read-back, so the confirmation covers it. On Codex, skip this question
entirely — the wiring step states the Codex reality instead.
@@ -133,6 +138,14 @@ you needed; report the count at the end (it feeds the install-time measurement).
on this machine can reach the brain (read and write) through its MCP
tools; the off-ramps are `codex mcp remove gbrain` (registration only) or
`gbrain bootstrap uninstall` (full teardown).
- opencode: writes the MCP entry directly into opencode's JSONC config (no
CLI exec needed) and relies on the AGENTS.md protocol, which opencode loads
natively — say plainly that opencode gets pull-based context, not per-turn
push. Scope follows the recorded MCP_SCOPE answer (user-global default; a
project answer writes the committed-candidate `opencode.json` and the CLI
prints the sharing warning). Restart opencode after wiring — it reads config
at session start. Off-ramps: the entry's `"enabled": false`, or
`gbrain bootstrap uninstall`.
7. **Private repo.** `gbrain bootstrap repo` — creates a PRIVATE GitHub repo from
the workspace, verifies the privacy bit through the API, pushes. If the human
started from a repo they created themselves (create-repo-first: an EMPTY private
+136
View File
@@ -2,6 +2,142 @@
All notable changes to GBrain will be documented in this file.
## [0.46.4.0] - 2026-08-15
**opencode joins the supported-client roster — at full parity from day one.**
(opencode is opencode.ai, SST's terminal agent — not OpenClaw.) Unlike earlier
clients that started with a manual recipe, opencode lands with every install
lane gbrain has: the paste-in workspace bootstrap, machine-level harness
wiring, `gbrain connect`, a claw-test runner, and a real-binary e2e door in
CI. Every asserted flag, config shape, and quirk was observed against a
pinned install (opencode 1.18.18), recorded in a machine-checked pin
document, and exercised against the real binary — including the part that
makes opencode special: its keyless anonymous free tier drives MCP tool
calls, so the end-to-end proof needs zero secrets.
### Added
- **`gbrain bootstrap hooks --harness opencode`** — workspace-lane MCP
registration via direct, comment-preserving JSONC writes (never a CLI
exec, works offline). MCP scope is honored with a deliberately INVERTED
default: user-global, because opencode spawns project-config servers with
no trust prompt; project scope is an explicit opt-in that prints a sharing
warning. A structural ownership fingerprint refuses to touch entries
gbrain didn't write.
- **`gbrain bootstrap harness --harness opencode`** — machine-level remote
MCP wiring with an inline bearer written 0600, token rotation across URL
changes, content-guarded rollback on failed smoke, `--status` and
`--remove`.
- **`gbrain connect --agent opencode [--install]`** — env-interpolated
bearer (`{env:GBRAIN_REMOTE_TOKEN}`): the token never enters the config
file. `--force` replaces a registration whose endpoint moved.
- **`gbrain claw-test --agent opencode`** and a split-gated real-binary e2e
door in CI: keyless tier (version pin, install + `mcp list` handshake,
spawn-gate canary, writer parity, MCP SMOKE on the free tier) plus a paid
Anthropic leg that model-gates before spending; npm supply-chain
provisioning verifies the actual downloaded tarball bytes against pinned
integrities; a schedule-only canary tracks the latest upstream release.
- **Docs:** `docs/mcp/OPENCODE.md` install guide,
`docs/mcp/OPENCODE-CLI-PIN.md` observation pin (with a verify-time drift
guard and a pin-refresh cadence), roster updates across README / INSTALL /
bootstrap guides. opencode reads the rendered AGENTS.md pull-protocol
contract natively.
### Changed
- The bootstrap config writers (Claude hooks JSON, Codex TOML, opencode
JSONC) now share one atomic-write helper; symlinked configs — including
dangling dotfile-manager links — survive writes as links.
- The door-test family (binary resolution, hermetic child envs, one-shot
spawns) extracted into shared factories; the hermes and grok runners were
ported onto them, hermes child envs gained the GitHub step-metadata scrub,
and the hermes installer pin was refreshed (its nightly door had gone red
on upstream installer drift).
- A new pin-doc privacy guard asserts every agent pin document ships with
placeholder paths and no key material.
### Fixed
- Security and robustness hardening from the pre-landing cross-model review
pass: registration verification probes run isolated and time-bounded, and
a hung probe is killed instead of abandoned; global config writes
reconcile both opencode global filenames under the bootstrap lock; config
backups are unique per operation with content-guarded restore; error
paths never echo credentials; test-harness child processes drop CI
credentials before spawning third-party binaries.
### To take advantage of v0.46.4.0
opencode users: run `gbrain bootstrap hooks --harness opencode` in your
brain workspace (or paste the standard bootstrap block into an opencode
session). The keyless free tier is enough to verify the wiring end to end —
`opencode mcp list` should show `✓ gbrain connected`. Existing installs:
nothing changes; this release adds a client, it doesn't modify brain
behavior.
## [0.46.3.0] - 2026-08-15
**ZeroEntropy is shutting down on 2026-09-04 — gbrain now gets you off it
before that date costs you retrieval.** The provider that shipped as the
default embedding + reranking stack from v0.36 through v0.46 is winding down
its hosted API. This release makes the transition a guided, one-command move
instead of a surprise outage:
- **New default: Voyage.** Fresh installs land on `voyage:voyage-4` (1024d) —
the strongest hosted retrieval embedder on current benchmarks — and the same
`VOYAGE_API_KEY` powers the new `voyage:rerank-2.5` reranker (written as
explicit config whenever a Voyage key is present at init; keyed installs
without one get reranking explicitly disabled instead of inheriting a
fallback they can't use) and the multimodal model. One key, all three
touchpoints. The v4 family shares one embedding space, so you can later
point queries at `voyage-4-large` or `-lite` without re-indexing.
- **Nothing changes out from under existing brains.** A brain configured for
ZeroEntropy (or riding the old default) keeps working exactly as before until
the shutdown date — this release only detects, warns, and hands you the
playbook. Every surface that used to steer you toward the dying provider
(init auto-pick, the interactive picker, `gbrain providers`, setup hints)
now steers you to the replacement instead, and using it prints a
once-per-process heads-up.
- **The switch is one command:** `gbrain migrate embeddings --to voyage:voyage-4
--dim 1024 --dry-run` (cost preview), then `--yes`. Prefer OpenAI? It can
keep your existing column width: `--to openai:text-embedding-3-small
--dim 1280`. Reranker: `gbrain config set search.reranker.model
voyage:rerank-2.5`. Self-hosting the Apache-2.0 weights remains documented in
docs/guides/embedding-migration.md.
- **Your agent gets told too.** A v0.46.3 migration checks whether your brain
still resolves to the sunsetting provider (embedding, reranker, or custom
columns), prints an ACTION REQUIRED notice with the blast radius and cost
estimate, and files a durable action item pointing at the agent playbook
(`skills/migrations/v0.46.3.0.md`). `gbrain doctor`'s provider check now
prints width-aware paste-ready commands, and its documented suppression
switch (`gbrain config set doctor.suppress_provider_sunset true`) actually
works now.
- `gbrain ze-switch` no longer switches brains onto the sunsetting provider
(`--undo` still moves brains off it), and `gbrain migrate embeddings` refuses
a re-embed onto a provider with an announced shutdown — self-hosters with a
wire-compatible endpoint pass `--force-sunset-target`. Voyage's
`voyage-code-4` is available for code-heavy brains.
- Setup fixes that ride along: init now detects provider keys stored in
`~/.gbrain/config.json` (file plane), not just env vars; a keyless brain
upgrades in place via `gbrain init --force --embedding-model voyage:voyage-4`
(the deferred-setup sentinel is cleared); and keyless fresh installs size the
embedding column at the new default width.
### To take advantage of v0.46.3.0
`gbrain upgrade` is enough — no schema migration.
1. **Upgrade and check:**
```bash
gbrain upgrade
gbrain doctor
```
2. **If the upgrade printed ACTION REQUIRED** (or doctor flags
`provider_sunset`): run the printed migrate command before 2026-09-04, or
hand your agent `skills/migrations/v0.46.3.0.md` — it walks the whole
switch, including the reranker and edge cases.
3. **Things to watch:** existing brains see zero behavior change from this
release itself; fresh installs default to `voyage:voyage-4` at 1024
dimensions; reranking configured on the sunsetting provider dies with it on
2026-09-04 unless you set the voyage reranker (or disable reranking). If
anything looks wrong, file an issue with `gbrain doctor` output:
https://github.com/garrytan/gbrain/issues
## [0.46.2.0] - 2026-08-15
**Dream synthesis now triages before it spends.**
+1 -1
View File
@@ -65,7 +65,7 @@ src/
yaml-lite.ts Lightweight YAML parser
chunkers/ 3-tier chunking (recursive, semantic, llm)
search/ Hybrid search (vector, keyword, hybrid, expansion, dedup)
embedding.ts Embedding service (provider-routed; ZeroEntropy default)
embedding.ts Embedding service (provider-routed; Voyage default)
mcp/
server.ts MCP stdio server (generated from operations)
http-transport.ts HTTP MCP transport (OAuth, body caps)
+27 -9
View File
@@ -55,19 +55,22 @@ restart the shell or add the PATH export to the shell profile.
## Step 2: API Keys
Ask the user for these. gbrain defaults to the ZeroEntropy embedding + reranker stack
(as of v0.36.2.0); OpenAI/Voyage are still supported as fallbacks via `gbrain config
set embedding_model <provider:model>`.
Ask the user for these. gbrain defaults to the Voyage embedding + reranker stack
(`voyage:voyage-4` @ 1024d + `voyage:rerank-2.5` — one key covers both); OpenAI is the
main alternative, chosen at init via `--embedding-model <provider:model>`. ZeroEntropy
is deprecated (its hosted API shuts down 2026-09-04): init auto-pick and the picker
exclude it, and every ZE embed/rerank prints a deprecation warning.
```bash
export ZEROENTROPY_API_KEY=ze-... # default embedding + reranker (v0.36.2.0+)
export OPENAI_API_KEY=sk-... # fallback for vector search; also used for chat models
export VOYAGE_API_KEY=pa-... # default embedding + reranker (one key covers both)
export OPENAI_API_KEY=sk-... # alternative for vector search; also used for chat models
export ANTHROPIC_API_KEY=sk-ant-... # optional, improves search quality via query expansion
```
Save to shell profile or `.env`. Keys are picked up by `gbrain config set` automatically
or can be stored in `~/.gbrain/config.json` (file plane). Without any embedding provider,
keyword search still works. Without Anthropic, search works but skips query expansion.
Save to shell profile or `.env`, or store in `~/.gbrain/config.json` (file plane). Do
NOT use `gbrain config set` for API keys — it writes the DB plane, which the embedding
pipeline never reads. Without any embedding provider, keyword search still works.
Without Anthropic, search works but skips query expansion.
## Step 3: Create the Brain
@@ -236,10 +239,25 @@ grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
The add is lazy (exit 0 without connecting) — verify with
`grok mcp doctor gbrain`, which spawns the server and must report
`7 tools discovered`. This is the brain-only install; the `gbrain bootstrap`
personal-agent path does not support Grok yet (Claude Code/Codex only).
personal-agent path does not support Grok yet (Claude Code, Codex, and opencode only).
Verified against Grok Build v1.0.4. Full reference:
[docs/mcp/GROK.md](docs/mcp/GROK.md).
**If you are opencode** (the SST terminal agent, opencode.ai — not OpenClaw):
you are a bootstrap-supported harness — for the full persistent-personal-agent
install, follow `BOOTSTRAP_FOR_AGENTS.md` instead of this page. For the
brain-only MCP registration:
```bash
opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs
```
The add is lazy (exit 0 without connecting) — verify with `opencode mcp list`,
which spawns the server and must show `✓ gbrain connected` (the exit code is 0
even on failure; read the output). Restart opencode afterwards — it reads
config at session start. Verified against opencode v1.18.18. Full reference:
[docs/mcp/OPENCODE.md](docs/mcp/OPENCODE.md).
Whether you scaffolded or not, read `skills/RESOLVER.md` (in your workspace, or the
bundled copy at `~/gbrain/skills/RESOLVER.md` when running from the cloned repo). It's
the skill dispatcher — tells you which skill to read for any task. Save this to your
+6 -5
View File
@@ -174,6 +174,7 @@ GBrain exposes nearly all of its 100+ operations as MCP tools (stdio and HTTP; a
- **[Cursor / Windsurf / any stdio MCP client](docs/mcp/CLAUDE_CODE.md)** — same shape, add `{"command": "gbrain", "args": ["serve"]}` to your MCP config.
- **[Hermes](docs/mcp/HERMES.md)** — `printf 'Y\n' | hermes mcp add gbrain --env GBRAIN_HOME=$HOME --connect-timeout 60 --command $(which gbrain) --args serve`. Keep `--args` last, and verify with `hermes mcp test gbrain` (the add exits 0 even on failure).
- **[Grok Build](docs/mcp/GROK.md)** — `grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs`. The add is lazy (exit 0 without connecting) — verify with `grok mcp doctor gbrain`, which spawns the server and reports `7 tools discovered`. Verified against Grok Build v1.0.4.
- **[opencode](docs/mcp/OPENCODE.md)** (opencode.ai / SST — not OpenClaw) — `opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs`, or let `gbrain bootstrap hooks --harness opencode` write the config for you (opencode is a bootstrap-supported harness — it reads AGENTS.md natively). The add is lazy — verify with `opencode mcp list`, which spawns the server (`✓ gbrain connected`). Remote: `gbrain connect https://your-host/mcp --token gbrain_xxx --agent opencode [--install]` — the config stores only the `{env:GBRAIN_REMOTE_TOKEN}` interpolation. Verified against opencode v1.18.18.
- **[OpenClaw](docs/mcp/OPENCLAW.md)** — the ClawHub bundle plugin registers gbrain automatically (`openclaw.plugin.json` ships in this repo), or add `{"command": "gbrain", "args": ["serve"]}` to `~/.openclaw/config.json`'s `mcpServers`.
- **[Claude Desktop (Cowork)](docs/mcp/CLAUDE_DESKTOP.md)** — Settings → Integrations → add the URL of your HTTP server. Remote only; the local `claude_desktop_config.json` does not work for remote servers.
- **[Claude Cowork (team plan)](docs/mcp/CLAUDE_COWORK.md)** — org Owner adds the connector under Organization Settings → Connectors.
@@ -307,7 +308,7 @@ The whole loop is described in [`docs/architecture/topologies.md`](docs/architec
## Capabilities
**Hybrid search.** Vector (HNSW on pgvector) + BM25 keyword + reciprocal-rank fusion + source-tier boost + intent-aware query rewriting. Three named search modes (`conservative`, `balanced`, `tokenmax`) bundle the cost/quality knobs into a single config key. Live cost/recall comparisons in [`docs/eval/SEARCH_MODE_METHODOLOGY.md`](docs/eval/SEARCH_MODE_METHODOLOGY.md). The install picker default-applies `tokenmax` (it recommends `conservative` for Haiku-class subagent tiers or keyless setups); a brain with `search.mode` unset resolves to `balanced` at query time. The ZeroEntropy reranker is on in `balanced` and `tokenmax`, off in `conservative`. Per-query graph signals notice when a top result is a hub for THAT query (adjacency boost), is corroborated across team brains (cross-source boost), or is being crowded out by weak chunks from a chatty session (session demote). Run `gbrain search "<query>" --explain` to see per-stage attribution: base score, every boost that fired, what it multiplied. `gbrain doctor` ships a `graph_signals_coverage` check; `gbrain search stats` shows fire counts and failure breakdowns. Vector retrieval pools the best chunk per page, so a page surfaces on its strongest evidence instead of losing to a neighbor on one weak chunk. Queries that match a page's title phrase or a declared free-text alias (`gbrain reindex --aliases` backfills existing pages) get boosted to the page they name. Every result carries an `evidence` tag (why it matched) and a `create_safety` hint (`exists` / `probable` / `unknown`) so an agent decides whether a page already exists instead of guessing from a raw score. `gbrain search diagnose "<query>" --target <slug>` traces which retrieval layer surfaces (or misses) a page.
**Hybrid search.** Vector (HNSW on pgvector) + BM25 keyword + reciprocal-rank fusion + source-tier boost + intent-aware query rewriting. Three named search modes (`conservative`, `balanced`, `tokenmax`) bundle the cost/quality knobs into a single config key. Live cost/recall comparisons in [`docs/eval/SEARCH_MODE_METHODOLOGY.md`](docs/eval/SEARCH_MODE_METHODOLOGY.md). The install picker default-applies `tokenmax` (it recommends `conservative` for Haiku-class subagent tiers or keyless setups); a brain with `search.mode` unset resolves to `balanced` at query time. The cross-encoder reranker is on in `balanced` and `tokenmax`, off in `conservative` — new installs get Voyage `rerank-2.5`; brains that never set `search.reranker.model` still fall back to the deprecated ZeroEntropy `zerank-2` (hosted API ends 2026-09-04) until the September cutover. Per-query graph signals notice when a top result is a hub for THAT query (adjacency boost), is corroborated across team brains (cross-source boost), or is being crowded out by weak chunks from a chatty session (session demote). Run `gbrain search "<query>" --explain` to see per-stage attribution: base score, every boost that fired, what it multiplied. `gbrain doctor` ships a `graph_signals_coverage` check; `gbrain search stats` shows fire counts and failure breakdowns. Vector retrieval pools the best chunk per page, so a page surfaces on its strongest evidence instead of losing to a neighbor on one weak chunk. Queries that match a page's title phrase or a declared free-text alias (`gbrain reindex --aliases` backfills existing pages) get boosted to the page they name. Every result carries an `evidence` tag (why it matched) and a `create_safety` hint (`exists` / `probable` / `unknown`) so an agent decides whether a page already exists instead of guessing from a raw score. `gbrain search diagnose "<query>" --target <slug>` traces which retrieval layer surfaces (or misses) a page.
**Self-wiring knowledge graph.** Every `put_page` extracts entity refs from markdown/wikilinks/typed-link syntax and writes edges with zero LLM calls. Typed edges (`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, …). Multi-hop traversal via `gbrain graph-query`. The graph is what produces the +31.4 P@5 lift over vector-only RAG. **Obsidian-style vaults:** bare `[[note-name]]` wikilinks that point across folders — you wrote `[[struktura]]` but the page lives at `projects/struktura.md` — resolve by basename once you opt in with `gbrain config set link_resolution.global_basename true`. Off by default; `gbrain doctor` tells you how many edges you'd gain before you flip it. See [migrating an Obsidian vault](INSTALL_FOR_AGENTS.md#step-45-wire-the-knowledge-graph).
@@ -345,8 +346,8 @@ Data flowing into the brain. Each integration is a recipe — markdown + setup h
- **Voice**: Phone calls create brain pages via Twilio + OpenAI Realtime (or DIY STT+LLM+TTS). Setup recipe: [`recipes/twilio-voice-brain.md`](recipes/twilio-voice-brain.md).
- **Email + calendar**: webhook handlers that route to brain signals. [`docs/integrations/meeting-webhooks.md`](docs/integrations/meeting-webhooks.md).
- **Embedding providers**: a dozen providers covered — OpenAI (default fallback), OpenRouter, Voyage, ZeroEntropy (default), Google Gemini, Azure OpenAI, MiniMax, Alibaba DashScope, Zhipu, Ollama (local), llama.cpp llama-server (local), LiteLLM proxy. Pricing matrix + decision tree in [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md).
- **Rerankers**: ZeroEntropy `zerank-2` hosted (the default; on in `balanced` and `tokenmax` modes) plus the `llama-server-reranker` recipe for fully-local cross-encoder rerank via llama.cpp — runs Qwen3-Reranker or self-hosted ZeroEntropy weights against the same `gateway.rerank()` seam. Setup walkthrough in [`docs/ai-providers/llama-server-reranker.md`](docs/ai-providers/llama-server-reranker.md).
- **Embedding providers**: a dozen providers covered — Voyage (default: `voyage-4` @ 1024d), OpenAI, OpenRouter, Google Gemini, Azure OpenAI, MiniMax, Alibaba DashScope, Zhipu, Ollama (local), llama.cpp llama-server (local), LiteLLM proxy, plus ZeroEntropy (deprecated — hosted API ends 2026-09-04). Pricing matrix + decision tree in [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md).
- **Rerankers**: Voyage `rerank-2.5` hosted (the new-install default; reranking is on in `balanced` and `tokenmax` modes, same `VOYAGE_API_KEY` as embeddings), ZeroEntropy `zerank-2` (deprecated — hosted API ends 2026-09-04; still the fallback for brains that never set `search.reranker.model`), plus the `llama-server-reranker` recipe for fully-local cross-encoder rerank via llama.cpp — runs Qwen3-Reranker or self-hosted zerank weights against the same `gateway.rerank()` seam. Setup walkthrough in [`docs/ai-providers/llama-server-reranker.md`](docs/ai-providers/llama-server-reranker.md).
- **Credential gateway**: vault-aware secret distribution. [`docs/integrations/credential-gateway.md`](docs/integrations/credential-gateway.md).
- **MCP clients**: every major MCP client is supported. [`docs/mcp/`](docs/mcp/) per-client setup.
@@ -364,7 +365,7 @@ Data flowing into the brain. Each integration is a recipe — markdown + setup h
**PGLite crashes at startup with `RuntimeError: Aborted()` (often right after a macOS upgrade)?** Not a macOS incompatibility — the OS-upgrade reboot killed gbrain mid-write and tore the data dir's WAL. gbrain now repairs this automatically on the next command (data preserved, backup kept); if auto-repair is disabled or skipped, run `gbrain pglite-repair --dry-run` to diagnose and `gbrain pglite-repair --yes` to repair in place. Full recovery ladder (repair → rebuild → engine switch) in [`docs/ENGINES.md` — Troubleshooting: startup abort](docs/ENGINES.md#troubleshooting-startup-abort-runtimeerror-aborted) and [`docs/INSTALL.md`](docs/INSTALL.md#pglite-crashes-on-macos-26x-tahoe).
**`gbrain import` fails with `expected N dimensions, not M`?** Run `gbrain doctor`. It will print the exact `gbrain config set ...` or `gbrain retrieval-upgrade` command to repair the mismatch. You should not need to delete `~/.gbrain`. Fresh `gbrain init --pglite` auto-detects your embedding provider from API keys in your environment: set `OPENAI_API_KEY` (or `ZEROENTROPY_API_KEY` / `VOYAGE_API_KEY`) before running init, or pass `--embedding-model <provider>:<model>` explicitly. With multiple keys set, init fires an interactive picker. In non-TTY contexts (CI, Docker) with no keys, init exits 1 with a paste-ready setup hint; pass `--no-embedding` to defer setup until runtime. See [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md) for the full provider matrix and [`docs/operations/headless-install.md`](docs/operations/headless-install.md) for Docker/CI sequencing.
**`gbrain import` fails with `expected N dimensions, not M`?** Run `gbrain doctor`. It will print the exact `gbrain config set ...` or `gbrain retrieval-upgrade` command to repair the mismatch. You should not need to delete `~/.gbrain`. Fresh `gbrain init --pglite` auto-detects your embedding provider from API keys: set `VOYAGE_API_KEY` (or `OPENAI_API_KEY` / another provider key) in the environment — or in `~/.gbrain/config.json`, which init also reads — before running init, or pass `--embedding-model <provider>:<model>` explicitly. With multiple keys set, init fires an interactive picker (non-TTY auto-picks the Voyage default when its key is present). With no keys at all, init continues keyless (keyword-only search) with a loud notice; add a key later and re-run `gbrain init --force --embedding-model voyage:voyage-4` to enable embeddings, or pass `--no-embedding` up front to make keyless explicit. See [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md) for the full provider matrix and [`docs/operations/headless-install.md`](docs/operations/headless-install.md) for Docker/CI sequencing.
**Hourly cron sync keeps timing out on a federated brain?** Switch your
cron to a per-source loop with shell `timeout(1)` doing the OS-level kill
@@ -510,4 +511,4 @@ MIT. I built GBrain to run my OpenClaw and Hermes deployments — the production
Origin story: [`docs/ethos/ORIGIN.md`](docs/ethos/ORIGIN.md).
Community PR contributors are credited in `CHANGELOG.md` per release. ZeroEntropy ([@zeroentropy](https://zeroentropy.dev)) for the embedding + reranker stack that ships as the default. Voyage AI for the asymmetric-encoding recipe template. Ramp Labs for the search quality improvements lineage.
Community PR contributors are credited in `CHANGELOG.md` per release. ZeroEntropy ([@zeroentropy](https://zeroentropy.dev)) for the embedding + reranker stack that shipped as the default from v0.36 through v0.46. Voyage AI for the asymmetric-encoding recipe template. Ramp Labs for the search quality improvements lineage.
+178 -16
View File
@@ -1,5 +1,67 @@
# TODOS
## v0.47 SEPTEMBER REMOVAL — ZeroEntropy (filed v0.46.3.0; TARGET: ship 2026-09-04..2026-09-08)
ZeroEntropy's hosted API dies 2026-09-04. v0.46.3.0 deprecated it (split-default:
new installs → voyage; legacy runtime fallbacks stay ZE; detect-and-notify
migration). The removal wave deletes the provider and performs the hard cutover.
Staged-deletion discipline (ship replacements → migrate call sites → update tests
→ THEN delete; see the skills/_brain-filing-rules precedent below):
- [ ] **P1 — HARD CUTOVER: retire the legacy configless runtime fallbacks.**
`DEFAULT_EMBEDDING_MODEL`/`DEFAULT_EMBEDDING_DIMENSIONS` (src/core/ai/defaults.ts)
stop resolving to `zeroentropyai:*`; unmigrated configless brains get a HARD,
actionable error naming `gbrain migrate embeddings --to voyage:voyage-4 --dim 1024`.
Also flip `DEFAULT_RERANKER_MODEL` (gateway.ts) + the three mode-bundle
`reranker_model` values (mode.ts:298,348,403) to `voyage:rerank-2.5` — one-time
knobs-hash query-cache miss for ALL modes incl. conservative (reranker_model is
hashed unconditionally; document in that release's CHANGELOG). Verify the schema
generators' legacy-constant consumers (pglite-schema, postgres-engine,
embedding-column.ts registry fallback) get a deliberate post-ZE story.
- [ ] **P1 — PREREQ before recipe deletion: move gateway.ts's `'/models/rerank'`
default path onto explicit per-recipe `path` fields.** llama-server-reranker and
dashscope-rerank may ride the implicit ZE-shaped fallback — audit + pin with tests
FIRST or their rerank calls 404 the day the fallback goes.
- [ ] **P1 — Delete the provider surface.** `src/core/ai/recipes/zeroentropyai.ts` +
registry entries (recipes/index.ts); `zeroEntropyCompatFetch`,
`MAX_ZEROENTROPY_RESPONSE_BYTES`, `ZeroEntropyResponseTooLargeError` + the
fetch-ternary arm (gateway.ts); ZE sets in dims.ts; `ze-switch.ts` +
`retrieval-upgrade-planner.ts` + `retrieval-upgrade-prompt.ts` (~1200 lines) +
cli.ts dispatch/CLI_ONLY/flag-registry rows; `checkZeEmbeddingHealth` in doctor
(`provider_sunset` STAYS and goes generic — read `recipe.sunset` instead of the
hardcoded ZE constants); pricing rows LAST (budget-tracker rerank metering reads
them for historical audit rows). NOTE: test/ai/zeroentropy-compat-fetch.test.ts
greps gateway.ts SOURCE TEXT — delete the test with the code, in the same commit.
- [ ] **P1 — Self-host continuity decision.** The v0.46.3 playbook's zero-re-embed
path keeps the `zeroentropyai:zembed-1` id behind a base-URL override to a
ZE-wire-compatible endpoint. Recipe deletion breaks it. Decide: keep a minimal
local-only recipe shell (no picker/auto-pick, no hosted default URL), ship a
signature-migration tool (rewrite pages.embedding_signature provider ids without
re-embedding), or explicitly end the promise with a loud migration note. The
playbook (skills/migrations/v0.46.3.0.md) links here — honor it.
- [ ] **P2 — Tests + CI.** Delete the 8 ZE-dedicated test files
(zeroentropy-recipe, zeroentropy-compat-fetch, dims-zeroentropy,
e2e/zeroentropy-live, ze-switch-cli, ze-switch-env-override, doctor-ze-checks,
provider-sunset-doctor.serial gets REWRITTEN generic not deleted) + update ~40
coupled files; drop the zeroentropy-live job + ZEROENTROPY_API_KEY secret from
.github/workflows/e2e.yml:168,179 (already date-skip-gated since v0.46.3);
scripts/test-weights.json rows.
- [ ] **P2 — Config + docs.** `zeroentropy_api_key` config key: keep
parseable-but-warned (removing it would make old config.json files fail to
load); delete docs/ai-providers/zeroentropy.md + its scripts/llms-config.ts
entry (+ `bun run build:llms`); v0.46.3 migration stays registered and must
degrade gracefully once the recipe is gone (notice-only — verify its copy).
- [ ] **P2 — Custom-column off-ramp (not removal-gated, but September makes it
urgent for affected users).** Write-side custom-column migration
(`gbrain embed --column X --model Y`, embedding-column.ts:60-62 v2 deferral) so
ZE-backed `embedding_columns` entries get an executable migration instead of
drop-and-re-embed guidance.
- [ ] **P3 — Optional `cohere-rerank` recipe.** Cohere rerank-4.0-pro is the
strongest surviving hosted reranker (Agentset ELO 1629, behind only the dying
zerank-2) for users who want max rerank quality on a dedicated key. Wire shape
differs from the ZE/voyage dialect — needs its own `top_param`/response mapping
audit. Filed from the v0.46.3 CEO review (deferred cherry-pick).
## Issues #5+#6 follow-ups (pool starvation + process isolation; plan: ~/.claude/plans/system-instruction-you-are-working-witty-moore.md)
- [ ] **P1-companion — nested-checkout audit + dev-mode detection.** **What:**
@@ -743,9 +805,10 @@ The eng-review + Codex outside-voice narrowed the wave to these deferrals:
covers anthropic + openai; Google was deferred because Gemini's native suffix is unproven
(its OpenAI-compat route is `/v1beta/openai`). Verify the correct `@ai-sdk/google` suffix,
then add `google` to the helper. Where: `src/core/ai/gateway.ts:resolveNativeBaseUrl`.
- [ ] **P3 — Fold Voyage/Google/LiteLLM/OpenRouter API keys into `buildGatewayConfig`.**
It folds only OPENAI/ANTHROPIC/ZEROENTROPY file-plane keys today, so `config.json`-set keys
for other providers only work if also in `process.env`. Extend the mapping. Where:
- [ ] **P3 — Fold Google/LiteLLM/OpenRouter API keys into `buildGatewayConfig`.**
Voyage + Dashscope + Google were folded by #2662 (`build-gateway-config.ts:33-60`);
remaining gaps are the aggregator keys (litellm, openrouter) whose `config.json`-set
keys only work if also in `process.env`. Extend the mapping. Where:
`src/core/ai/build-gateway-config.ts`.
- [ ] **P3 — OpenRouter per-model custom-dim handling.** OpenRouter declares recipe-wide
`dims_options` and mixes fixed-dim + arbitrary models, so it's excluded from `trust_custom_dims`.
@@ -5993,6 +6056,39 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts`
(`skillpack status`/`sync`, doctor `skill_currency`) already keeps the brain's skill
set current on upgrade; this item is purely about semantic retrieval of skills.
## opencode wave follow-ups (filed at build time)
- [ ] **P2 — Watch the first opencode-door + canary dispatches.** The job is
day-one full posture (nightly + labels; keyless SMOKE + paid anthropic leg
on the existing secret) — after the wave merges, confirm the first nightly
run goes green end-to-end and the canary leg's latest-version result, then
update OPENCODE-CLI-PIN.md §Pending auth with anything the authed CI run
observes (exact `opencode models` output, per-turn cost note). Effort: S.
- [ ] **P3 — Wire opencode's plugin/event system** (the ambient-recall lane).
opencode ships a JS plugin system with lifecycle events; `OPENCODE_HAS_HOOKS
= false` in host-specs.ts marks the gap. Needs its own observation pass
(plugin API shapes, event timing, context-injection surface) before design —
would upgrade opencode from pull-protocol to per-turn push, above codex.
Effort: M/L.
- [ ] **P3 — BrainBench opencode adapter.** `src/eval/brainbench/adapters/` +
`ALL_HARNESSES` entry — build together with the already-filed hermes + grok
adapters (three pending; one eval wave). Effort: M.
- [ ] **P3 — connect `--agent opencode --oauth`.** opencode's `mcp auth` is an
authorization-code OAuth flow (not client-credentials) — a connect lane for
it needs the interactive-grant plumbing the current `--oauth`
(perplexity/generic client-credentials) path does not model. Effort: M.
- [ ] **P3 — Re-observe the OPENCODE_CONFIG* env trio on version bumps.**
Observed INERT in 1.18.18 (docs-contradiction pinned in OPENCODE-CLI-PIN.md
§Path seams); host-specs resolves via XDG only. If a future release
activates them, `opencodeConfigDir()` and the hermetic child-env deletes
must move together. The pin doc's re-observation checklist carries the
probe. Effort: S.
- [ ] **P3 — opencode-install PTY promotion.** Same criterion as grok-install:
2 consecutive stable dx-scenario runs ≥1 month apart with unchanged
boot/first-run copy → promote to a PTY assertion test. opencode's keyless
free tier means the scenario should COMPLETE the bootstrap, making it a
stronger promotion candidate than grok's sign-in-wall early-stop. Effort: M.
## Transcripts-import follow-ups (filed from cathedral-4, `gbrain transcripts ingest`)
Scoped OUT of the cathedral-4 PR by the CEO review's cherry-pick ceremony and the
@@ -6034,25 +6130,36 @@ covers DEAD logs; go-forward capture beyond Claude Code is deliberately absent.
CLAUDE_CODE.md + GROK.md now recommend `--surface verbs`. Update the
register one-liner + Direct config block (+ INSTALL_FOR_AGENTS hermes
block) and re-verify against the pinned hermes. Effort: S.
- [ ] **P2 — Backport the GITHUB_ENV/GITHUB_PATH/GITHUB_OUTPUT/GITHUB_STATE
- [x] **P2 — Backport the GITHUB_ENV/GITHUB_PATH/GITHUB_OUTPUT/GITHUB_STATE
deletion from `grokChildEnv` to `hermesChildEnv`** (and consider narrowing
the `GITHUB_` ALLOW_PREFIX to the read-only metadata names) — the prefix
rule forwards writable CI step-metadata files to untrusted agent children.
Unit truth-table exists for the grok side to clone. Effort: S.
DONE (opencode-support wave): `hermesChildEnv` now rides `makeAgentChildEnv`,
which scrubs the GITHUB_* step-metadata files for every door agent; truth-table
extended in `test/helpers/agent-harness.unit.test.ts`.
- [ ] **P3 — Grok bootstrap-harness target.** `gbrain bootstrap` personal-agent
support for Grok Build: `HarnessSelector` + `parseHarnessArgs`, a dated
`TARGETS` spec in `host-specs.ts`, a `wireGrok` branch + TOML writer (grok
config schema pinned; `codex-toml.ts` is the precedent), receipt/rollback/
status handling, and the INSTALL_FOR_AGENTS honest-classification flip.
Docs currently state "bootstrap does not support Grok yet". Effort: M.
- [ ] **P3 — Door-adapter extraction + CI-tail composite action.** Trigger: the
NEXT door agent (4th). Extract the agent-harness door family shape
(resolve/auth/seed/childEnv/pin/turn) and hoist the shared workflow tail
(evidence prep / scrub triple / upload / zero-pass grep / cred cleanup)
into a composite action; port grok-door as first consumer. Until then the
hermes-door/grok-door scrub blocks carry cross-reference comments. Also
adopt a door CADENCE policy: nightly for the newest/most-churning agent,
label-only after 2 stable monthly cycles per agent. Effort: M.
- [x] **P3 — Door-adapter extraction (test-side) + door cadence policy.**
Trigger FIRED at the 4th door agent (opencode, the opencode-support wave):
`makeBinaryResolver`/`makeAgentChildEnv`/`runOneShotSpawn` extracted in
`test/helpers/agent-harness.ts`, grok+hermes ported (hermes gained the
GITHUB_* scrub + bounded drain), opencode landed as first consumer; the
cadence policy is adopted in `docs/TESTING.md` (nightly for the newest
agent, label-only after 2 stable monthly cycles).
- [ ] **P3 — Door CI-tail composite action.** Trigger: the FIRST GREEN
grok-door AND opencode-door dispatches (workflow yaml cannot be proven
locally, and refactoring never-run jobs compounds risk — grok-door has
never dispatched: its XAI_API_KEY secret does not exist yet). Hoist the
shared workflow tail (evidence prep / scrub triple / upload / pass-count +
paid sentinels / version re-check / cred cleanup) from
hermes-door/grok-door/opencode-door into a composite action; port
opencode-door as first consumer (it is the freshest copy). Until then the
three doors' scrub blocks carry cross-reference comments. Effort: M.
- [ ] **P3 — Promote grok-install to a PTY assertion test.** Criterion: 2
consecutive stable runs ≥1 month apart of the dx scenario (pre-ship ritual
on grok-touching waves) with unchanged boot/sign-in copy. Would be the
@@ -6076,11 +6183,66 @@ covers DEAD logs; go-forward capture beyond Claude Code is deliberately absent.
registry shape generalizes. Unify into one data-driven table AFTER the
door-adapter extraction lands (earn it — don't freeze hermes-isms in).
Effort: L.
- [ ] **P3 — PIN-doc privacy-guard candidate.** GROK-CLI-PIN/HERMES-CLI-PIN
carry verbatim observation transcripts; consider extending check-privacy.sh
(or a dedicated check) to assert pin docs use `<tmp>`/placeholder paths and
never carry key material or account ids. Effort: S.
- [x] **P3 — PIN-doc privacy guard.** DONE (opencode-support wave):
`scripts/check-pin-doc-privacy.sh` (in `bun run verify` + guards-manifest,
fixture-tested) asserts every `docs/mcp/*-CLI-PIN.md` uses placeholder paths
and carries no key-shaped material or non-example emails.
- [x] **P3 — opencode-door npm view-vs-install TOCTOU.** DONE (adversarial-review
fix wave): the door job's install step is now pack-verify-install — `npm pack
<pkg>@<ver> --json` downloads the artifact and reports the integrity of the
BYTES written; both the wrapper and the platform payload are asserted against
their pins before `npm install -g ./opencode-ai-*.tgz` installs from the
verified local tarball (no fresh registry resolve of the name; the payload's
install-time fetch is npm-validated against the same byte-confirmed packument).
Verified locally on darwin-arm64 (wrapper integrity == pin; `--ignore-scripts`
breaks opencode's postinstall binary placement, so it is deliberately absent).
- [x] **P3 — `opencode mcp list` probe spawns project-config servers.** DONE
(adversarial-review fix wave): the user-scope probe spawns from a fresh EMPTY
mkdtemp cwd (no project config can load), project scope SKIPS the live probe
entirely with a printed note (parse-back is authoritative), and the probe now
holds the real process handle so the 20s timeout actually kills the child
(SIGTERM → SIGKILL) instead of abandoning it.
- [ ] **P3 — dedupe the opencode read→parse→classify dance.** The
read-config → parseOpencodeConfig → opencodeEntryKind sequence is spelled
three times (bootstrap.ts runHooks pre-check, harness.ts apply expectUrl
fallback, harness.ts remove ownership check); extract a
`classifyOpencodeEntryAt(path, name, expect)` helper and drop the
double-printed other-source warning (the caller AND the writer note it).
Effort: S.
## opencode adversarial-review fix-wave follow-ups (filed at fix time)
- [ ] **P2 — per-harness MCP-scope consent key.** An interview MCP_SCOPE answer
recorded for Claude Code (where 'project' is the privacy-SAFE default)
currently authorizes opencode's INVERTED-risk scopes without fresh
confirmation ('project' on opencode = committed file that auto-spawns on
every collaborator machine, no trust gate), and an ABSENT answer defaults
opencode to user-global exposure (any repo on the machine reaches the
brain). Design a harness-specific consent confirm — either per-harness
answer keys (MCP_SCOPE_OPENCODE) or a one-time "your recorded scope means
something riskier here — confirm" gate on the opencode lane. Relates to the
agent-bootstrap A8 consent-semantics TODO. Effort: M.
- [ ] **P3 — opencodeEntryKind remote ownership: normalize the url compare.**
Ownership uses exact string equality on the entry url vs the receipt/expect
url — trailing-slash and host-case variants misclassify in BOTH directions
(ours read as foreign → orphaned entry; a variant-url foreign endpoint
never matches, fine, but the asymmetry is accidental). Consider URL
normalization (scheme/host case-fold, trailing-slash) plus an
Authorization-shape check before comparing. Effort: S.
- [ ] **P2 — claw-test --live runners inherit real HOME/XDG.** The grok /
hermes / opencode --live runners run against the operator's real
HOME/XDG config surface and only WARN on a pre-existing global gbrain
entry; a scripted run can mutate or exercise the operator's live wiring.
Consider a fail-closed flag (refuse when a global gbrain registration
exists unless --allow-live-config) or hermetic-by-default across the
runner family. Effort: M.
- [ ] **P3 — fixed-name `.bak` parity: codex-toml.ts + hooks.ts writers.**
opencode-json.ts now takes UNIQUE `.bak-<hex>` backups per operation
(overlapping runs can't clobber each other's snapshot; harness restores
from the returned path and unlinks on success). The codex TOML writer and
the hooks settings writers still use fixed-name backups with the same
theoretical overlap window — port the unique-backup pattern (and the
restore-guard compare) for parity. Effort: S/M.
## Dream triage cascade follow-ups (#4152, filed at implementation)
- [ ] **P2 — Incremental submit-drain + deadline threading in synthesize
+1 -1
View File
@@ -1 +1 @@
0.46.2.0
0.46.4.0
+3
View File
@@ -27,6 +27,7 @@
"gray-matter": "^4.0.3",
"heic-decode": "^2.1.0",
"js-yaml": "^3.15.1",
"jsonc-parser": "^3.3.1",
"marked": "^18.0.2",
"openai": "^4.0.0",
"pgvector": "^0.2.0",
@@ -469,6 +470,8 @@
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"libheif-js": ["libheif-js@1.19.8", "", {}, "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ=="],
+8 -5
View File
@@ -26,7 +26,7 @@ Your agent now reads `skills/RESOLVER.md` once per request, routes intent to the
Scaffolded skills are first-class files in your agent repo — edit freely. To pull upstream gbrain improvements later, `gbrain skillpack reference <name>` diffs your local copy vs the bundle. The legacy `skillpack install` managed-block model was retired in v0.36.0.0; if you're upgrading from an older release, run `gbrain skillpack migrate-fence` once to strip the legacy fence and keep your existing skill rows.
To upgrade later: `gbrain upgrade` runs schema migrations + post-upgrade prompts (chunker bumps, the v0.36.2.0 ZeroEntropy switch). Always TTY-only; non-TTY upgrades skip prompts with informational stderr lines.
To upgrade later: `gbrain upgrade` runs schema migrations + post-upgrade prompts (chunker bumps, provider-sunset notices). Always TTY-only; non-TTY upgrades skip prompts with informational stderr lines.
## 2. CLI standalone
@@ -48,14 +48,16 @@ gbrain migrate --to pglite # Postgres → PGLite (rare)
For shared / large / multi-machine deployments (a team or company brain with multiple users hitting one server over HTTP MCP with OAuth scoping per user), follow the dedicated walkthrough: **[Tutorial: set up GBrain as your company brain](tutorials/company-brain.md)**.
API keys live in `~/.gbrain/config.json` (file plane) or env vars (`OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `ZEROENTROPY_API_KEY`, `VOYAGE_API_KEY`, `ANTHROPIC_API_KEY`). Set via CLI:
API keys live in `~/.gbrain/config.json` (file plane) or env vars (`VOYAGE_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`). Set them via env or by editing `~/.gbrain/config.json` directly — do NOT use `gbrain config set` for API keys (that writes the DB plane, which the embedding pipeline never reads):
```bash
gbrain config set zeroentropy_api_key sk-...
gbrain config set openrouter_api_key sk-or-...
gbrain config set anthropic_api_key sk-ant-...
export VOYAGE_API_KEY=pa-... # default embedding (voyage-4) + reranker (rerank-2.5) — one key
export OPENAI_API_KEY=sk-... # alternative embeddings; also used for chat models
export ANTHROPIC_API_KEY=sk-ant-... # optional, improves search via query expansion
```
`ZEROENTROPY_API_KEY` is still honored but deprecated — the ZeroEntropy hosted API shuts down 2026-09-04 (see [`docs/ai-providers/zeroentropy.md`](ai-providers/zeroentropy.md) for the off-ramp).
Common follow-ups:
```bash
@@ -101,6 +103,7 @@ Per-client setup guides live in [`docs/mcp/`](mcp/):
- [`docs/mcp/PERPLEXITY.md`](mcp/PERPLEXITY.md)
- [`docs/mcp/HERMES.md`](mcp/HERMES.md) — Hermes (Nous Research CLI)
- [`docs/mcp/GROK.md`](mcp/GROK.md) — Grok Build (xAI CLI)
- [`docs/mcp/OPENCODE.md`](mcp/OPENCODE.md) — opencode (opencode.ai / SST terminal agent)
- [`docs/mcp/OPENCLAW.md`](mcp/OPENCLAW.md) — OpenClaw (bundle plugin or stdio)
- [`docs/mcp/CLAUDE_COWORK.md`](mcp/CLAUDE_COWORK.md) — Claude Cowork (team plan)
- [`docs/mcp/DEPLOY.md`](mcp/DEPLOY.md) — production deploy patterns
+3
View File
@@ -420,6 +420,9 @@ E2E tests live in `test/e2e/` and run against real Postgres+pgvector (require `D
- `test/e2e/workspace-generic-compat.test.ts` — always-on (PGLite, no binary): pins the INSTALL_FOR_AGENTS.md "any repo with a workspace" contract against `test/fixtures/generic-agents-workspace/` (Hermes is the motivating consumer): `cwd_walk_up` detection, the `GBRAIN_SKILLS_DIR` override, `check-resolvable` on a root AGENTS.md, and scaffold additivity + refuse-overwrite. The real Hermes-behavior proof is the door suite below.
- `test/e2e/install-real-hermes.serial.test.ts` — the hermes "door": real `hermes` binary + real `hermes mcp add` handshake (full-catalog tool discovery; the count tracks the op catalog, so the test asserts discovery happened, not a number) + a paid `hermes -z` recall turn against a seeded brain. Triple-gated: `GBRAIN_REAL_HERMES_E2E=1` (explicit opt-in — run-e2e.sh scrubs GBRAIN_*, so it can never fire under `bun run test:e2e`) + resolvable binary + non-empty ANTHROPIC key (anthropic-pinned on purpose: a second provider key flips hermes provider-auto into a mis-routed 401). Hermetic HOME + HERMES_HOME with a tripwire on the operator's real config; evidence copies to `GBRAIN_E2E_EVIDENCE_DIR` for CI upload. Venue: heavy-tests.yml (`real-agent-e2e` + `hermes-door` jobs).
- `test/e2e/install-real-grok.serial.test.ts` — the grok "door" (xAI Grok Build; every asserted shape observed against the pin in `docs/mcp/GROK-CLI-PIN.md`). SPLIT-GATED, a deliberate divergence from the hermes door: grok's `mcp add/list/doctor` run keyless, so the compat tier (version-shape pin, documented-shape `grok mcp add gbrain -- gbrain serve --surface verbs` via a PATH-staged bin dir, saved-TOML asserts via `Bun.TOML.parse`, `mcp doctor` handshake proving the seven-verb surface, vendor-fallback provenance guard, direct-TOML surface) needs only `GBRAIN_REAL_GROK_E2E=1` + a resolvable binary; the paid SMOKE additionally needs a non-empty `XAI_API_KEY` and asserts a PER-RUN NONCE fact (grok has fs/shell tools — the committed fact is greppable, so recall of it proves nothing) with web search disabled. `mcp add` is lazy (exit 0 always) — `mcp doctor <name> --json` is the honest discriminator (exit 0/1 observed). Hermetic HOME + GROK_HOME + tmp cwd on every spawn (grok reads vendor MCP configs for trusted folders and loads `.envrc` from cwd); bounded tripwire over the operator's real `~/.grok` config/credential files (volatile paths excluded — grok rewrites logs/sessions/bin/docs every run) + a checkout guard that no `.grok/`/`.mcp.json` appeared in the repo root. Venue: heavy-tests.yml (`real-agent-e2e` + `grok-door` jobs); run directly via `GBRAIN_REAL_GROK_E2E=1 bun test test/e2e/install-real-grok.serial.test.ts`.
- `test/e2e/install-real-opencode.serial.test.ts` — the opencode "door" (SST opencode; every asserted shape observed against the pin in `docs/mcp/OPENCODE-CLI-PIN.md`). SPLIT-GATED a step past the grok door: opencode's anonymous FREE TIER drives MCP tool calls keyless, so even the nonce SMOKE runs in the keyless tier — T1 bare-semver version pin (the SST-vs-claimant discriminator), T2 documented-shape `opencode mcp add gbrain --env … -- gbrain serve --surface verbs` + the honest `opencode mcp list` discriminator (it SPAWNS every server; `✓/✗` text is the assertion surface — exit code is 0 even on failure, and `mcp debug` is OAuth-only), T2b spawn-gate CANARY (a project-config decoy is spawn-attempted with NO trust prompt — if this ever gates, the bootstrap user-global scope default's rationale changed: re-observe), T3 writer parity (gbrain's `opencode-json.ts` output handshakes through the real binary; cross-tool preservation both ways), T4 keyless SMOKE (per-run nonce + STRUCTURAL `gbrain_*` tool_use proof via `parseOpencodeJsonl`, `--format json`). The paid T5 anthropic leg additionally needs a non-empty `ANTHROPIC_API_KEY` and self-validates the pinned model id against the authed `opencode models` list BEFORE any spend. Hermetic HOME + both XDG dirs + tmp cwd on every spawn; `--pure` on every probe (`mcp list` autoloads plugins — a code-execution surface); bounded tripwire over the operator's real opencode configs/auth.json + a repo-root checkout guard. Venue: heavy-tests.yml (`real-agent-e2e` + `opencode-door` jobs, plus the schedule-only `opencode-door-canary` latest-version leg — continue-on-error, a pin-refresh signal, never a gate); run directly via `GBRAIN_REAL_OPENCODE_E2E=1 bun test test/e2e/install-real-opencode.serial.test.ts`.
**Door cadence policy** (adopted with the 4th door agent): the NEWEST door agent runs at nightly/schedule cadence (currently opencode, whose canary leg also tracks `latest`); a door drops to label-only (`real-agent-e2e`) after 2 stable monthly cycles with unchanged pins. Rationale: churn concentrates in the newest integration; steady-state doors pay for themselves on demand, not nightly.
- `test/helpers/tty-harness.ts` + `test/tty-harness.test.ts` — the DX real-PTY harness (`Bun.spawn({terminal:})`): pure text/timing helpers unit-tested with zero subprocesses, plus three live PTY smokes against `sh` guarded by `describe.skipIf(!ptySupported())`. The harness itself is a dev instrument surface — its consumer `scripts/dx-explore.ts` never runs in CI (transcripts land in gitignored `.context/dx-runs/`); see `docs/guides/bootstrap.md` for the scenario runbook.
- `test/e2e/search-swamp.test.ts` — reproduces the source-swamp case. Seeds a curated `originals/talks/article-outline-fat-code` page against two `<fork>/chat/` pages stuffed with the same multi-word phrase. Asserts the article wins keyword AND vector ranking, that `detail=high` lets the chat swamp re-surface, and that `source_id` passes through the two-stage CTE intact. PGLite in-memory.
- `test/e2e/search-exclude.test.ts``test/` + `archive/` pages hidden by default, `include_slug_prefixes` opts back in, caller-supplied `exclude_slug_prefixes` adds to defaults. Both keyword and vector search paths.
+7 -3
View File
@@ -22,9 +22,13 @@ Two flavors of "local" this recipe covers:
This recipe is the path override + recipe shape. Any provider whose
request/response wire matches ZE/llama.cpp can use it by just pointing
at a different base URL. Providers whose wire shape differs (Voyage uses
`top_k` not `top_n`, returns `data[]` not `results[]`) need a separate
recipe with adapter hooks — that lands in a follow-up plan.
at a different base URL. A provider whose request differs only in the
top-N key declares it via the recipe's `top_param` — that's how the
hosted Voyage reranker recipe (`voyage:rerank-2.5`, the new-install
default, `top_k`) works. On the response side the gateway parser accepts
both known array keys (`results[]` for ZE/llama.cpp, `data[]` for
Voyage's REST — the shared item shape is `{index, relevance_score}`);
a genuinely different item shape needs its own recipe with adapter hooks.
## Setup
+51 -22
View File
@@ -1,20 +1,44 @@
# ZeroEntropy — zembed-1 + zerank-2
# ZeroEntropy — zembed-1 + zerank-2 (DEPRECATED)
> **Hosted API shutdown: 2026-09-04.** ZeroEntropy announced (2026-07-24)
> that its hosted endpoints — `/models/embed` and `/models/rerank` — shut
> down on that date. A brain still embedding through the hosted API loses
> semantic retrieval entirely on that date: query embedding uses the same
> **DEPRECATED — hosted API shutdown: 2026-09-04.** ZeroEntropy announced
> (2026-07-24) that its hosted endpoints — `/models/embed` and
> `/models/rerank` — shut down on that date, and gbrain has deprecated the
> recipe: `gbrain init` auto-pick and the interactive picker exclude it
> (explicit `--embedding-model zeroentropyai:*` still works, with a loud
> warning), every ZE embed/rerank call prints a once-per-process
> deprecation warning, `gbrain providers` annotates it DEPRECATED, and
> `gbrain ze-switch` refuses to switch a brain ONTO ZeroEntropy (`--undo`
> and `--dry-run` still work). The September release removes the recipe
> entirely. A brain still embedding through the hosted API loses semantic
> retrieval entirely on the shutdown date: query embedding uses the same
> endpoint, so **existing vectors become unqueryable**, not just new
> content. Two fixes, either works:
>
> 1. **Self-host the same model** — zembed-1 weights are Apache-2.0. Serve
> them via `llama-server` or Ollama and point the config at the local
> endpoint. Keeps every existing vector; no re-embed at all.
> 2. **Migrate to another provider** — `gbrain migrate embeddings --to
> <provider:model> --dim <N> --dry-run` (resumable; see
> [the migration guide](../guides/embedding-migration.md)). `gbrain
> doctor` (check `provider_sunset`) prints this command with your
> brain's actual `--dim` filled in.
> 1. **Migrate to Voyage (recommended)** — `gbrain migrate embeddings
> --to voyage:voyage-4 --dim 1024 --dry-run` (cost preview), then
> `--yes`. 1280 is not a valid Voyage width (valid: 256/512/1024/2048),
> so a 1280d brain gets a one-time schema/HNSW rebuild to 1024 — the
> command handles it, resumable if killed. The OpenAI alternative keeps
> the width (flexible dims): `--to openai:text-embedding-3-small --dim
> 1280`. Reranker: `gbrain config set search.reranker.model
> voyage:rerank-2.5` (needs `VOYAGE_API_KEY`) or `gbrain config set
> search.reranker.enabled false`. See
> [the migration guide](../guides/embedding-migration.md); `gbrain
> doctor` (check `provider_sunset`) prints both commands target-aware
> (Voyage at 1024; OpenAI keep-width when your brain's actual width is
> valid there).
> 2. **Self-host the same model (zero re-embed, advanced)** — zembed-1
> weights are Apache-2.0. Keep the `zeroentropyai:zembed-1` model id
> (the embedding signature must not change) and point its base URL at
> your own endpoint: `gbrain config set
> provider_base_urls.zeroentropyai <url>`. The endpoint must speak
> **ZeroEntropy's wire dialect** (`/models/embed`, `{results: [...]}`
> responses — the id routes through a ZE-specific compat fetch), so a
> generic OpenAI-compatible llama-server or Ollama endpoint will NOT
> work without a compat proxy in front. Switching the provider id
> instead changes `pages.embedding_signature` and triggers a full
> re-embed. This path survives only until the September removal release
> deletes the recipe.
>
> The hosted setup below remains accurate until the shutdown date.
@@ -98,15 +122,20 @@ The reranker is the bigger story: gbrain had no cross-encoder reranker
stage before v0.35.0.0. It slots between RRF dedup and token-budget
enforcement in hybrid search.
### Default-on with `tokenmax` mode
### Default-on with `balanced` and `tokenmax` modes
`tokenmax` mode now defaults `search.reranker.enabled = true` with
`zerank-2`. If you already use `tokenmax` AND have `ZEROENTROPY_API_KEY`
set, reranker fires automatically. Without the key, every rerank call
fails-open (audit-logged) and search returns RRF order — same UX as
before, just with an observable failure surfaced via `gbrain doctor`.
The `balanced` and `tokenmax` mode bundles default
`search.reranker.enabled = true`. Brains that never set
`search.reranker.model` still fall back to `zerank-2` (the legacy bundle
default until the September cutover — new installs write explicit reranker
config instead: `voyage:rerank-2.5` when a Voyage key is present, otherwise
`search.reranker.enabled false`). With
`ZEROENTROPY_API_KEY` set, the ZE reranker fires automatically. Without
the key, every rerank call fails-open (audit-logged) and search returns
RRF order — same UX as before, just with an observable failure surfaced
via `gbrain doctor`.
### Opt-in on `conservative` or `balanced` mode
### Opt-in on `conservative` mode
```bash
gbrain config set search.reranker.enabled true
@@ -136,8 +165,8 @@ Two probes run for reranker:
| Config key | Default | Notes |
|---|---|---|
| `search.reranker.enabled` | `true` for tokenmax, `false` for others | One-flip opt-in/out |
| `search.reranker.model` | `zeroentropyai:zerank-2` | Try `zerank-1` (older SOTA) or `zerank-1-small` (Apache-2.0 open) |
| `search.reranker.enabled` | `true` for balanced/tokenmax, `false` for conservative | One-flip opt-in/out |
| `search.reranker.model` | `zeroentropyai:zerank-2` (legacy fallback; new installs write `voyage:rerank-2.5`) | The recommended replacement is `voyage:rerank-2.5` |
| `search.reranker.top_n_in` | `30` | Candidates sent to reranker (caps API spend) |
| `search.reranker.top_n_out` | `null` (no truncate) | Truncate reranked output to this many; `null` preserves full length |
| `search.reranker.timeout_ms` | `5000` | HTTP timeout; long stalls degrade UX worse than RRF fallback |
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -44,13 +44,13 @@ Three regexes, zero LLM tokens, single SQL `addLinksBatch` call with `INSERT ...
Heuristic link-type inference (`attended`, `works_at`, `invested_in`, `founded`, `advises`) fires from surrounding sentence context — also LLM-free. Power users who want richer types add them via the typed-link blockquote convention.
## ZeroEntropy as reranker: 60% top-1 reshuffle
## Cross-encoder reranker: 60% top-1 reshuffle
ZeroEntropy's `zerank-2` is the default reranker (on for the `balanced` and `tokenmax` mode bundles, off for `conservative`). On a real-corpus benchmark across 20 queries, zerank-2 reshuffles **60% of top-1 results** after the hybrid + RRF + graph stack. That's the headline number.
The reranker is on for the `balanced` and `tokenmax` mode bundles, off for `conservative`. New installs with a Voyage key get `rerank-2.5` written as explicit `search.reranker.model` config (the recommended reranker; same `VOYAGE_API_KEY` as embeddings — keyed installs without one get reranking explicitly disabled instead); brains that never set the key still fall back to the legacy ZeroEntropy `zerank-2` mode-bundle default, which is deprecated (the hosted API ends 2026-09-04 — switch with `gbrain config set search.reranker.model voyage:rerank-2.5`) and remains the fallback only until the September cutover. On a real-corpus benchmark across 20 queries, zerank-2 reshuffles **60% of top-1 results** after the hybrid + RRF + graph stack. That's the headline number.
The mechanical reason: hybrid ranking is locally optimal per strategy but globally suboptimal. A cross-encoder reranker reads the query + each candidate document jointly, with full attention. It catches the cases where the vector + keyword + graph signals all agreed on a document that's semantically related but topically wrong.
The cost: +150ms p50 latency, ~$0.025/M tokens. Disabled with `gbrain config set search.reranker.enabled false`. For agent loops that do downstream LLM work after retrieval, the latency is invisible.
The cost: +150ms p50 latency, ~$0.0250.05/M tokens depending on the reranker. Disabled with `gbrain config set search.reranker.enabled false`. For agent loops that do downstream LLM work after retrieval, the latency is invisible.
## Source-aware ranking
@@ -150,7 +150,7 @@ graph augment (optional two-pass structural expansion — walkDepth > 0)
deduplication (4-layer: per-page cap, Jaccard, type diversity)
reranker (zerank-2 cross-encoder — balanced/tokenmax; fail-open)
reranker (cross-encoder — balanced/tokenmax; fail-open)
alias hop (exact alias match injects/boosts the canonical page)
+2
View File
@@ -1,5 +1,7 @@
# Embedder Shootout — May 2026 Eval Plan
> **Historical note:** this plan predates the ZeroEntropy hosted-API sunset (2026-09-04); the ZeroEntropy cells below are historical.
**Status:** approved, ready to execute
**Owner:** Garry
**Plan source:** `~/.claude/plans/system-instruction-you-are-working-linear-origami.md` (review log)
+7 -7
View File
@@ -2,9 +2,9 @@
GBrain stores embeddings in a fixed-dimension `vector(N)` column on
`content_chunks`. If you switch to a model with a different dimension
(e.g. `openai:text-embedding-3-large` 1536 → `zeroentropyai:zembed-1`
1280, or `voyage:voyage-4-large` 2048), the on-disk column type doesn't
change automatically.
(e.g. `openai:text-embedding-3-large` 1536 → `voyage:voyage-4` 1024, or
`voyage:voyage-4-large` 2048), the on-disk column type doesn't change
automatically.
`gbrain init`, `gbrain doctor`, and `gbrain embed --stale` all detect
this mismatch and refuse to silently proceed. This doc is the recipe
@@ -63,8 +63,8 @@ single-command wrapper:
```bash
gbrain reinit-pglite \
--embedding-model zeroentropyai:zembed-1 \
--embedding-dimensions 1280
--embedding-model voyage:voyage-4 \
--embedding-dimensions 1024
```
This backs up the existing brain to `<path>.bak`, runs `gbrain init`
@@ -84,8 +84,8 @@ mv ~/.gbrain/brain.pglite ~/.gbrain/brain.pglite.bak
# every other field in ~/.gbrain/config.json (chat model,
# expansion model, API keys).
gbrain init --pglite \
--embedding-model zeroentropyai:zembed-1 \
--embedding-dimensions 1280
--embedding-model voyage:voyage-4 \
--embedding-dimensions 1024
# 3. Re-import your brain repo. `gbrain sync` reads the brain repo
# from disk and re-creates the page rows.
+1 -1
View File
@@ -32,7 +32,7 @@ pure win. See the per-verb latency table in
calls `context_pack` / `delta` over MCP (they are on `--surface verbs`) or the
CLI (`gbrain context-pack`, `gbrain delta`) at the boundary and injects the
returned `text` (or renders the structured arms). This is the portable path —
no hooks required. It is the primary path for Codex (which has no hooks) and
no hooks required. It is the primary path for Codex and opencode (no wired hooks) and
for Postgres brains (which have no local IPC socket).
- **Push (PGLite + Claude Code):** the bundled hook framework fires
automatically at `SessionStart` (injects a warm pack — including the
+20 -4
View File
@@ -1,7 +1,7 @@
# GBrain Bootstrap — your harness as your agent
`gbrain bootstrap` turns a Claude Code or Codex session into a persistent personal
agent: identity files rendered from your own answers, a local PGLite brain,
`gbrain bootstrap` turns a Claude Code, Codex, or opencode session into a
persistent personal agent: identity files rendered from your own answers, a local PGLite brain,
per-turn context, session-triggered schedules, and a private GitHub repo as the
agent's durable, portable body. This guide is the full contract — what gets
installed, what runs when, what it can and cannot do, and how to undo all of it.
@@ -19,7 +19,7 @@ follows is `BOOTSTRAP_FOR_AGENTS.md` at the repo root, fetched at the
| Identity files (SOUL/USER/MEMORY/AGENTS/CLAUDE/HEARTBEAT/ACCESS_POLICY/GITHUB) | your workspace folder | loaded at session start |
| `agent.json` manifest + `brain/`, `memory/`, `skills/`, `state/` | workspace | — |
| Local brain (PGLite) | `~/.gbrain/` (never in the repo) | while a session's MCP serve is open |
| MCP registration (`gbrain serve`) | Claude Code: project scope by default; Codex: user-global (no scope flag) | spawned by your harness per session |
| MCP registration (`gbrain serve`) | Claude Code: project scope by default; Codex: user-global (no scope flag); opencode: user-global by default (project scope is an explicit opt-in — see the degradation matrix) | spawned by your harness per session |
| Hooks (Claude Code, ON by default) | local installs: `.claude/settings.local.json` (gitignored); cloud sandboxes: the COMMITTED `.claude/settings.json` (PATH-resolved, fail-open commands) | each prompt; fail-open; `--no-hooks` opts out at install, `GBRAIN_HOOKS=0` disables at runtime |
| Per-turn persistence | Stop hook → debounced, detached scan-gated push (per workspace; 5 min default, every turn in cloud sandboxes) | after each assistant turn; `GBRAIN_STOP_PUSH=0` disables; `GBRAIN_STOP_PUSH_DEBOUNCE_MIN` / config `hooks.stop_push_debounce_min` tune it |
| Session persistence | SessionEnd hook → scan-gated commit+push | at session end (note: the harness never fires SessionEnd on `/exit` — the per-turn push is what covers that) |
@@ -155,6 +155,7 @@ you'd apply to any journal: write what you'd be comfortable persisting.
| GitHub / `gh` | full local agent | off-machine durability (repo re-runnable later) |
| Hooks (Claude Code) | pull protocol via AGENTS.md gates | automatic per-turn context + session-end persistence |
| Codex (no wired hooks, no MCP scope flag) | pull protocol + MCP tools | per-turn push (stated plainly; not oversold — codex 0.147+ ships a hook system, but gbrain does not wire it yet) + the ability to confine MCP reach to one folder (`codex mcp add` is always user-global) |
| opencode (no wired hooks; scope INVERTED: user-global by default) | pull protocol (opencode reads AGENTS.md natively) + MCP tools; project scope available as an explicit opt-in | per-turn push (opencode ships a plugin/event system, but gbrain does not wire it yet). The project-scope default is deliberately NOT offered: opencode spawns project-config servers with no trust prompt, so a committed entry would auto-execute on every collaborator machine |
| Second simultaneous session | first session unaffected | second session's brain tools fail politely (one live serve per brain — v1 contract) |
| Postgres brain (incl. harness mode) | MCP tools every session + pull protocol | per-turn hook injection (`no_pglite_path`: the hook IPC socket is PGLite-only today; hooks stay pre-wired and light up when the engine-uniform listener lands) |
@@ -188,6 +189,15 @@ mode wires them in one command, with no `agent.json` and no interview:
INLINE in the codex config (0600) — framework-spawned codex inherits no
shell profile, so the env-var lane the `connect` path uses would never
reach it.
- opencode: one managed `mcp.gbrain` remote entry with the bearer header
INLINE in the user-global JSONC config (0600), written by the same
comment-preserving editor the workspace lane uses — the `{env:…}`
interpolation the `connect` path prefers would resolve empty under a
framework-spawned opencode for the same no-shell-profile reason.
Note: downgrading gbrain below the release that introduced opencode support
after wiring it leaves the opencode entry in place for manual removal —
edit the opencode config by hand, or re-upgrade and run
`gbrain bootstrap harness --remove`.
- Honesty on Postgres brains: per-turn injection is degraded (the matrix row
above); MCP is the active seam and the summary says so.
- `--status [--json]` probes the live truth (serve health, token validity via
@@ -274,6 +284,11 @@ that changed shape, a harness that stopped calling our MCP server):
a seeded, brain-only fact (falling back to a shell `gbrain query` if headless
stdio-MCP is unavailable).
opencode's real-binary door lives in
`test/e2e/install-real-opencode.serial.test.ts` (its writer-parity leg
handshakes gbrain's direct JSONC registration through the actual binary);
`docs/TESTING.md` carries the full door inventory and cadence policy.
These pay real API cost and take 30s2min per turn, so they are NOT in the PR
shard. Everything is hermetic (temp `HOME` / `CODEX_HOME` / `CLAUDE_CONFIG_DIR` /
`GBRAIN_HOME` per test — the operator's real `~/.claude`, `~/.gbrain`, `~/.codex`
@@ -294,7 +309,7 @@ bun test test/e2e/bootstrap-real-codex.serial.test.ts
## DX exploration harness (developer instrument, not a test)
The door tests prove the install WORKS; they say nothing about how it FEELS.
`test/helpers/tty-harness.ts` spawns any CLI (gbrain, `claude`, `codex`, `grok`) under a
`test/helpers/tty-harness.ts` spawns any CLI (gbrain, `claude`, `codex`, `grok`, `opencode`) under a
real pseudo-terminal (Bun's `terminal:` spawn option) and records every output
burst with a millisecond timestamp, so unnecessary pauses become a measurable
artifact (`computeStalls``stalls.md`) instead of a vibe. Same hermetic env as
@@ -313,6 +328,7 @@ bun run scripts/dx-explore.ts help # comprehension surfaces (no key
bun run scripts/dx-explore.ts init [--keyless] # interactive init, naive-user autopilot
bun run scripts/dx-explore.ts claude-install # REAL claude running the paste-in bootstrap
bun run scripts/dx-explore.ts codex-install # REAL codex, same
bun run scripts/dx-explore.ts opencode-install # REAL opencode running the paste-in bootstrap
bun run scripts/dx-explore.ts grok-install # REAL grok, brain-only GROK.md install (no bootstrap path)
bun run scripts/dx-explore.ts drive -- gbrain init # manual: steer a live TUI via a file channel
```
+78 -24
View File
@@ -3,9 +3,9 @@
`gbrain migrate embeddings` re-embeds an entire brain onto a different
embedding provider/model, safely and resumably. It is the forward path off a
sunsetting provider (for example ZeroEntropy's hosted API, which shuts down
2026-09-04 and is the shipped default for brains that never picked a model) —
but it is provider-agnostic: any configured `provider:model` works as a
target.
2026-09-04 and remains the configless runtime fallback for existing brains
that never picked a model — new installs default to `voyage:voyage-4`) — but
it is provider-agnostic: any configured `provider:model` works as a target.
Also reachable as `gbrain retrieval-upgrade` — the alias that `gbrain doctor`
repair hints and the README point at.
@@ -14,32 +14,58 @@ repair hints and the README point at.
```bash
# Preview the work + cost. Changes nothing.
gbrain migrate embeddings --to openai:text-embedding-3-small --dry-run
gbrain migrate embeddings --to voyage:voyage-4 --dim 1024 --dry-run
# Run it (interactive confirm shows chunk count + $ estimate first).
gbrain migrate embeddings --to openai:text-embedding-3-small
gbrain migrate embeddings --to voyage:voyage-4 --dim 1024
# Non-interactive (cron / scripts): --yes is required, else exit 2.
gbrain migrate embeddings --to voyage:voyage-3-large --yes
gbrain migrate embeddings --to voyage:voyage-4 --dim 1024 --yes
```
`--dim <N>` overrides the target width; it defaults to the provider recipe's
declared width and is required for recipes that don't declare one (litellm,
llama-server, and other bring-your-own-model providers).
Targets on a provider with an announced shutdown are refused (a paid re-embed
onto a dying API would strand the brain). Self-hosting a wire-compatible
endpoint behind a `provider_base_urls` override? `--force-sunset-target` is
the explicit escape hatch.
## Recommended targets
- **`voyage:voyage-4 --dim 1024`** (the new-install default). One
`VOYAGE_API_KEY` covers embedding, the `rerank-2.5` reranker, and the
multimodal model; the voyage-4 family shares one embedding space, so you
can later point the query model at `voyage-4-large` or `voyage-4-lite`
without reindexing. Note: **1280 is not a valid Voyage width** (valid:
256/512/1024/2048), so a legacy 1280d brain gets a one-time schema/HNSW
index rebuild to 1024 — the command handles it, and it is resumable if
killed.
- **`openai:text-embedding-3-small --dim 1280`** — the keep-your-width
alternative: OpenAI's text-embedding-3 models support flexible dims, so a
1280d brain keeps its column (no schema rebuild). No reranker coverage on
the OpenAI key.
Set the target's API key via `export VOYAGE_API_KEY=...` (or edit
`~/.gbrain/config.json` directly) — do NOT use `gbrain config set
voyage_api_key`: that writes the DB plane, which the embedding pipeline never
reads.
**Pick `--dim` = your brain's current column width when the target supports
it.** A different width triggers the destructive schema transition (column +
index rebuild across all three dim-pinned tables); the same width skips it
entirely. `gbrain doctor` (check `provider_sunset`, for providers with an
announced shutdown) prints the paste-ready command with your actual width
already filled in — it reads the real `vector(N)` column, not the config
value, which can drift.
announced shutdown) prints target-aware paste-ready commands — the Voyage
command at its valid 1024 width, plus an OpenAI keep-width alternative with
your actual width filled in when that width is valid there — reading the real
`vector(N)` column, not the config value, which can drift.
## How affected brains find out (provider sunsets)
Two surfaces flag a brain whose embedding model (or reranker) is on a
provider with an announced hosted-API shutdown, such as ZeroEntropy
(2026-09-04):
Three surfaces flag a brain whose embedding model, reranker, or custom
embedding columns are on a provider with an announced hosted-API shutdown,
such as ZeroEntropy (2026-09-04):
- **`gbrain doctor`** — the `provider_sunset` check warns on every run until
the brain is off the provider. After the shutdown date it escalates to
@@ -48,15 +74,25 @@ provider with an announced hosted-API shutdown, such as ZeroEntropy
resolves to the dead default stays `warn`, so doctor-as-CI-gate setups
don't start exiting 1 on the date. The reranker side resolves through the
same plane search actually reranks with (the mode bundle +
`search.reranker.*` overrides). The message carries the paste-ready
migration command with the brain's actual `--dim`. Accepted the risk?
`search.reranker.*` overrides), and ZE-backed custom `embedding_columns`
entries are flagged too. The message carries target-aware paste-ready
migration commands (Voyage at 1024; OpenAI keep-width when your width is
valid there). Accepted the risk?
`gbrain config set doctor.suppress_provider_sunset true` silences it.
- **`gbrain upgrade`** — a one-shot banner (gated by
`ze_sunset_notice_shown`) with the same two fixes.
`ze_sunset_notice_shown`) with the same two fixes, plus a stage-2 banner
per brain.
- **The v0.46.3 version migration** (runs via `gbrain upgrade` /
`gbrain apply-migrations`) — detect-and-notify only: it checks the host
brain's exposure (embedding, reranker, custom columns), prints the ACTION
REQUIRED block, and files an agent action item pointing at
`skills/migrations/v0.46.3.0.md` in
`~/.gbrain/migrations/pending-host-work.jsonl`. It never changes config or
spends money on your behalf.
Both state the full consequence: after the shutdown, **existing vectors
become unqueryable** — query embedding uses the same endpoint as ingestion —
not just new content.
All of them state the full consequence: after the shutdown, **existing
vectors become unqueryable** — query embedding uses the same endpoint as
ingestion — not just new content.
## What it does, in order
@@ -162,14 +198,32 @@ vector spaces in one index, degrading retrieval with nothing in the logs.
## Reranker
Migrating embeddings does not touch the reranker. If
`search.reranker.model` points at the outgoing provider, the plan prints a
warning; disable it (`gbrain config set search.reranker.enabled false`) or
point it at another provider.
`search.reranker.model` (or the mode-bundle fallback) resolves to the
outgoing provider, the plan prints a warning; point it at the recommended
replacement — `gbrain config set search.reranker.model voyage:rerank-2.5`
(needs `VOYAGE_API_KEY`) — or disable it
(`gbrain config set search.reranker.enabled false`).
## Custom embedding columns
There is **no automated off-ramp for custom `embedding_columns` entries**:
`migrate embeddings` covers the primary column only. Re-declare each custom
column's config on the new provider and re-embed its content, or drop the
column config.
## Self-hosting instead of migrating
If the outgoing model's weights are available (zembed-1's are Apache-2.0),
serving them locally via `llama-server` / `ollama` / a LiteLLM proxy
preserves your existing vectors — no re-embed at all. Point
`embedding_model` at the local recipe and keep the same dimensions. The
self-hosting preserves your existing vectors — no re-embed at all — but only
when the embedding signature doesn't change: keep the SAME model id
(`zeroentropyai:zembed-1`) and point its base URL at your endpoint with
`gbrain config set provider_base_urls.zeroentropyai <url>`. The endpoint
must speak ZeroEntropy's wire dialect (`/models/embed`,
`{results: [...]}` responses) — the model id routes through a ZE-specific
compat fetch, so a generic OpenAI-compatible `llama-server` or Ollama
endpoint will NOT work without a compat proxy in front. Switching the
provider id instead (e.g. `llama-server:zembed-1`) changes
`pages.embedding_signature`, and the next stale-embed pass re-embeds
everything — a full re-embed, not a zero-cost move. This path survives only
until the September removal release deletes the `zeroentropyai` recipe. The
migration command is for when you'd rather move to a hosted provider.
+2 -2
View File
@@ -12,7 +12,7 @@ The push channels share one zero-LLM core (`src/core/context/volunteer.ts`):
| `reflex` | automatic, inside the context engine | default-on for plugin hosts; nothing to call |
| `op` | `gbrain volunteer-context` / MCP `volunteer_context` | agents without the plugin; one call per turn |
| `watch` | `gbrain watch` | stream a transcript in, volunteered pages stream out |
| `claude-code` / `codex` | `gbrain hook user-prompt` (registered by `gbrain bootstrap`) | per-prompt injection inside a harness; see "Harness hooks" below |
| `claude-code` / `codex` / `opencode` | `gbrain hook user-prompt` (registered by `gbrain bootstrap`) | per-prompt injection inside a harness; see "Harness hooks" below |
## How it decides
@@ -74,7 +74,7 @@ this channel production-grade rather than spammy-and-invisible:
- **The feedback loop.** The serve logs each DELIVERED block's volunteered
pages and pointers to `context_volunteer_events` under the hook's channel
(`claude-code` by default; a codex hook registration passes
`--harness codex`). `gbrain volunteer-context --stats` then shows
`--harness codex` / `--harness opencode`). `gbrain volunteer-context --stats` then shows
per-harness precision, and `gbrain doctor`'s `volunteer_channels` check
shows which channels actually fire, with guidance for the two quiet cases:
"hook installed but never registered (restart the session)" and "registered
+13 -11
View File
@@ -1,6 +1,6 @@
# Embedding providers
GBrain ships with 16 embedding-provider recipes covering OpenAI, ZeroEntropy, Voyage, OpenRouter (single key, many hosted models), the major hosted alternatives, three local options, and a universal escape hatch (LiteLLM proxy). Run `gbrain providers list` to see the live registry; `gbrain providers explain --json` emits a machine-readable matrix for agents.
GBrain ships with 16 embedding-provider recipes covering Voyage (the default), OpenAI, OpenRouter (single key, many hosted models), the major hosted alternatives, three local options, a universal escape hatch (LiteLLM proxy), and the deprecated ZeroEntropy recipe (hosted API shuts down 2026-09-04). Run `gbrain providers list` to see the live registry; `gbrain providers explain --json` emits a machine-readable matrix for agents.
This page is the human-readable counterpart: capability per provider, env-var setup, dimensions, cost, and known constraints.
@@ -13,9 +13,9 @@ gbrain providers test --model openai:text-embedding-3-large # smoke-test
gbrain init --pglite --model voyage # use a non-default provider
```
## Init resolves your provider from env keys
## Init resolves your provider from your keys
As of v0.37, `gbrain init --pglite` auto-detects which provider to use from your env vars. With `OPENAI_API_KEY` set, you get OpenAI. With `ZEROENTROPY_API_KEY` set, you get ZeroEntropy. If multiple provider keys are set, init fires an interactive picker. If no provider keys are set in a non-TTY context (CI, Docker build), init exits 1 with a paste-ready setup hint. Explicit flags (`--embedding-model`, `--no-embedding`) always win over env detection.
`gbrain init --pglite` auto-detects which provider to use from your provider keys — env vars or the file plane (`~/.gbrain/config.json` fields like `voyage_api_key`; env wins when both are set). With `VOYAGE_API_KEY` set, you get Voyage (`voyage:voyage-4` @ 1024d). With `OPENAI_API_KEY` set, you get OpenAI. Whenever a Voyage key is present — even if a different embedding provider is picked — init also writes `search.reranker.model voyage:rerank-2.5` as explicit config (one key covers both); keyed installs without a Voyage key get `search.reranker.enabled false` written instead. If multiple provider keys are set, init fires an interactive picker (non-TTY auto-picks the Voyage default when its key is present). ZeroEntropy is deprecated and excluded from both auto-pick and the picker — explicit `--embedding-model zeroentropyai:*` still works, with a loud warning. With no provider keys at all, init continues keyless (keyword-only search) with a loud notice; recover later with `gbrain init --force --embedding-model voyage:voyage-4`. Explicit flags (`--embedding-model`, `--no-embedding`) always win over key detection.
The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atomically, so subsequent runs are deterministic across releases.
@@ -23,10 +23,10 @@ The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atom
| Provider | env vars | default dims | cost ($/1M tokens) | local? | multimodal? |
|---|---|---|---|---|---|
| `zeroentropyai` (hosted API **shuts down 2026-09-04** — see note below) | `ZEROENTROPY_API_KEY` | 2560 (Matryoshka to 1280/640/320/...) | 0.05 | no | no |
| `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 |
| `voyage` | `VOYAGE_API_KEY` | 1024 | 0.18 | no | yes (`voyage-multimodal-3`) |
| `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 |
| `minimax` | `MINIMAX_API_KEY` | 1536 | 0.07 | no | no |
@@ -42,7 +42,7 @@ The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atom
**Note on local providers.** Ollama and llama-server have no required API key, so they don't show up in env-detection auto-pick. Pick them explicitly with `--embedding-model ollama:<model>` to avoid silently routing to a daemon that may not be running.
**Note on the ZeroEntropy hosted API.** ZeroEntropy announced (2026-07-24) that its hosted endpoints shut down on **2026-09-04**. A brain still embedding through the hosted API loses semantic retrieval entirely on that date — query embedding uses the same endpoint, so existing vectors become unqueryable, not just new content. Either self-host the Apache-2.0 zembed-1 weights via llama-server/Ollama (keeps every existing vector, no re-embed), or migrate with `gbrain migrate embeddings` — see [the migration guide](../guides/embedding-migration.md). `gbrain doctor` (check `provider_sunset`) flags affected brains and prints the paste-ready command with the brain's actual `--dim` filled in.
**Note on the ZeroEntropy hosted API.** ZeroEntropy announced (2026-07-24) that its hosted endpoints shut down on **2026-09-04**, and the recipe is deprecated: init auto-pick and the interactive picker exclude it (explicit `--embedding-model zeroentropyai:*` still works, with a loud warning), every ZE embed/rerank call prints a once-per-process deprecation warning, and `gbrain providers` annotates it DEPRECATED. A brain still embedding through the hosted API loses semantic retrieval entirely on that date — query embedding uses the same endpoint, so existing vectors become unqueryable, not just new content. The off-ramp: `gbrain migrate embeddings --to voyage:voyage-4 --dim 1024 --dry-run` (cost preview), then `--yes`. 1280 is not a valid Voyage width (valid: 256/512/1024/2048), so a 1280d brain gets a one-time schema/HNSW rebuild to 1024; the OpenAI alternative keeps the width (flexible dims): `--to openai:text-embedding-3-small --dim 1280`. See [the migration guide](../guides/embedding-migration.md). Self-hosting the Apache-2.0 zembed-1 weights keeps every existing vector with zero re-embed, but the endpoint must speak ZeroEntropy's wire dialect — a generic OpenAI-compatible llama-server/Ollama will NOT work without a compat proxy (details in [`docs/ai-providers/zeroentropy.md`](../ai-providers/zeroentropy.md)). `gbrain doctor` (check `provider_sunset`) flags affected brains — including ZE-backed custom embedding columns — and prints target-aware paste-ready commands (Voyage at 1024; OpenAI keep-width when the brain's actual width is valid there); accepted the risk? `gbrain config set doctor.suppress_provider_sunset true` silences it.
## If first import fails
@@ -64,8 +64,8 @@ The doctor distinguishes two repair paths:
- **Cost-sensitive, English-only**: Ollama (free, local) or Voyage (paid, best quality per dollar).
- **Quality-first**: Voyage `voyage-4-large` (1024-2048 dims, ~3-4× more dense tokens than OpenAI tiktoken).
- **Code-heavy brain (gstack per-worktree, source repos)**: Voyage `voyage-code-3` (1024 default; supports 256/512/1024/2048). Tuned on programming languages. Voyage publishes head-to-head numbers showing it outperforms their general flagships on code retrieval ([voyageai.com/blog](https://voyageai.com/blog)). For gstack's per-worktree pglite-backed code brain, this is the right default — see Topology 3 in `docs/architecture/topologies.md`.
- **Reranking pair**: ZeroEntropy `zerank-2` is the hosted default in `tokenmax` mode (see [`docs/ai-providers/zeroentropy.md`](../ai-providers/zeroentropy.md)). Voyage `rerank-2.5` pairs cleanly with Voyage embeddings.
- **Code-heavy brain (gstack per-worktree, source repos)**: Voyage `voyage-code-3` (1024 default; supports 256/512/1024/2048), or the newer `voyage-code-4` (hosted, flexible dims, $0.12/M). Tuned on programming languages. Voyage publishes head-to-head numbers showing it outperforms their general flagships on code retrieval ([voyageai.com/blog](https://voyageai.com/blog)). For gstack's per-worktree pglite-backed code brain, this is the right default — see Topology 3 in `docs/architecture/topologies.md`.
- **Reranking pair**: Voyage `rerank-2.5` ($0.05/M; `rerank-2.5-lite` at $0.02/M for cost-sensitive setups) is the new-install default and rides the same `VOYAGE_API_KEY` as embeddings. ZeroEntropy `zerank-2` remains the fallback only for brains that never set `search.reranker.model` — deprecated, hosted API ends 2026-09-04 (see [`docs/ai-providers/zeroentropy.md`](../ai-providers/zeroentropy.md)).
- **Local reranking (no API spend)**: `llama-server-reranker` recipe (v0.40.6.1) — point gbrain at your own `llama-server --reranking` instance running Qwen3-Reranker or self-hosted ZeroEntropy weights. Same `gateway.rerank()` seam, $0 per call. Walkthrough in [`docs/ai-providers/llama-server-reranker.md`](../ai-providers/llama-server-reranker.md).
- **One key for many hosted models**: OpenRouter. Set `OPENROUTER_API_KEY` and use `openrouter:<provider>/<model>` for chat against GPT-5.2, Claude 4.x, Gemini 3, DeepSeek, and dozens more without juggling per-provider keys. Embedding catalog includes OpenAI, Google, Qwen, BGE-M3.
- **Enterprise compliance**: Azure OpenAI (data residency + private endpoints) or self-hosted via llama-server / Ollama.
@@ -77,15 +77,17 @@ The doctor distinguishes two repair paths:
### OpenAI
Default. Set `OPENAI_API_KEY`. Models: `text-embedding-3-large` (3072 max, 1536 default), `text-embedding-3-small` (1536). Matryoshka via the `dimensions` field — gbrain pins it from `embedding_dimensions` config so existing 1536-dim brains stay aligned across SDK upgrades.
The main alternative to the Voyage default (its flexible-dim `text-embedding-3` models can keep an existing column width during a provider migration). Set `OPENAI_API_KEY`. Models: `text-embedding-3-large` (3072 max, 1536 default), `text-embedding-3-small` (1536). Matryoshka via the `dimensions` field — gbrain pins it from `embedding_dimensions` config so existing 1536-dim brains stay aligned across SDK upgrades.
Optional `OPENAI_BASE_URL` — point the native OpenAI provider at an OpenAI-compatible gateway. A bare host is normalized to carry the `/v1` suffix automatically (so `https://gw.example.com` and `https://gw.example.com/v1` both work); when unset, the SDK's default endpoint is untouched. `ANTHROPIC_BASE_URL` gets the same normalization for Anthropic chat/expansion calls.
### Voyage AI
Best-in-class quality on the Voyage 4 family (Jan 2026 release). Set `VOYAGE_API_KEY`. Models: `voyage-4-large`, `voyage-4`, `voyage-4-lite`, `voyage-4-nano`, `voyage-3.5`, `voyage-code-3` (code-tuned), `voyage-finance-2`, `voyage-law-2`, `voyage-multimodal-3` (text + image).
**The default provider** — new installs get `voyage-4` @ 1024d ($0.06/M) plus the `rerank-2.5` reranker on the same key. Best-in-class quality on the Voyage 4 family (Jan 2026 release). Set `VOYAGE_API_KEY`. Models: `voyage-4-large`, `voyage-4`, `voyage-4-lite`, `voyage-4-nano`, `voyage-code-4` (code-tuned, hosted, flexible dims, $0.12/M), `voyage-3.5`, `voyage-code-3`, `voyage-finance-2`, `voyage-law-2`, `voyage-multimodal-3` (text + image).
Voyage 4 family shares an embedding space across all variants, so you can index with `voyage-4-large` and query with `voyage-4-lite` without reindexing. Dims: 256, 512, 1024, 2048. **2048 exceeds pgvector's HNSW cap of 2000** — those brains fall back to exact vector scans (still correct, just slower).
Voyage 4 family shares an embedding space across all variants, so you can index with `voyage-4` and later point the query model at `voyage-4-large` or `voyage-4-lite` without reindexing. Dims: 256, 512, 1024, 2048. **2048 exceeds pgvector's HNSW cap of 2000** — those brains fall back to exact vector scans (still correct, just slower).
Voyage also serves the hosted rerankers `rerank-2.5` ($0.05/M) and `rerank-2.5-lite` ($0.02/M) at `POST /v1/rerank` (prices verified 2026-08-15) — the new-install reranker default, configured via `gbrain config set search.reranker.model voyage:rerank-2.5`.
**For brains that index source code** (gstack's per-worktree pglite-backed code brain — see Topology 3 in `docs/architecture/topologies.md`), prefer `voyage-code-3` over `voyage-4-large`. Voyage tunes it on programming languages and publishes head-to-head numbers vs their general flagships on code retrieval. Configure at install time:
+5 -2
View File
@@ -12,7 +12,10 @@ file, the workflow pins, and the affected assertions together.
version stamp; CI installs the RELEASE TAG `v2026.8.3` = commit `3c27eb62` — the two
differ by post-release main commits, same declared version. If a CI door run ever
diverges from these notes, re-observe against the tag checkout.)
- Installer sha256: `c118ff31618dc70339049ce71061b8f1351a1c70d9c2a236ed50d8a2550c550d`
- Installer sha256: `868ed3a91e0fabbff6d7418b3ede82bf4833652ec4e77196a42852fb35a9e5b9`
(refreshed 2026-08-15: upstream installer drifted past the prior pin —
reviewed; the `--commit` payload-pin path the door depends on is intact,
and the payload pins (tag+commit) are unchanged)
(download https://hermes-agent.nousresearch.com/install.sh to a file first; verify; then run)
- Installer flags used: `--skip-setup --non-interactive`; binary lands at `~/.local/bin/hermes`
- Python 3.11.15 via uv
@@ -93,7 +96,7 @@ non-interactive. `hermes cron tick` = run due jobs once and exit. `hermes cron l
`git -C ~/.hermes/hermes-agent rev-parse HEAD` and loud-fails on any mismatch, so an
installer that silently ignores unknown flags (or a moved checkout layout) can never
run unpinned upstream code on a runner that later holds secrets.
- `HERMES_INSTALL_SHA256: "c118ff31618dc70339049ce71061b8f1351a1c70d9c2a236ed50d8a2550c550d"`
- `HERMES_INSTALL_SHA256: "868ed3a91e0fabbff6d7418b3ede82bf4833652ec4e77196a42852fb35a9e5b9"`
- Door test asserts `hermes --version` output contains `v$HERMES_VERSION` when the env var is set.
- `hermes --version` output shape: `Hermes Agent v0.20.0 (2026.8.3)` + install dir + python lines.
+252
View File
@@ -0,0 +1,252 @@
# opencode CLI pin — observed behavior notes (v1.18.18)
Dev-facing companion to [OPENCODE.md](OPENCODE.md): every fact below was OBSERVED
against a real hermetic install (2026-08-15, macOS arm64), not researched from docs.
The claw-test OpencodeRunner, the install door e2e, and the heavy-tests
opencode-door CI job assert exactly these shapes — when opencode releases change
them, update this file, the workflow pins, and the affected assertions together
(`scripts/check-opencode-pin.sh` in `bun run verify` enforces the workflow-side
match). Where an observation CONTRADICTS opencode's docs, the observation wins and
the contradiction is called out inline.
Naming note: **opencode** (SST, opencode.ai, npm `opencode-ai`) is not **OpenClaw**
(the agent platform gbrain ships a runner for) and not the original `opencode` CLI
that was renamed Crush — see Troubleshooting in OPENCODE.md for the binary-name
collision.
<!-- opencode-pin: distribution_kind=npm -->
<!-- opencode-pin: npm_package=opencode-ai -->
<!-- opencode-pin: npm_version=1.18.18 -->
<!-- opencode-pin: npm_integrity=sha512-J+5HFq8tf+wPBBpBpMPSNjSytF2/EkNWYfFZh4si1d9auFbQriqDyqZv+vFUsLWERfdMU32Eajwuiq3rKBvZLQ== -->
<!-- opencode-pin: npm_linux_x64_integrity=sha512-WmeUnhljYJ252wywKTiW4bNDzsas2njpjPUEh0jM6HKNI4vFxJtREtzaWViY4AKEAcOkLWT8Ll17ixvcHz3AnA== -->
<!-- opencode-pin: npm_linux_arm64_integrity=sha512-e8D3g0qJEIzawEg2+ygW3vkZjAYL2ssyAx4GbihjwXwZFvlZZy5zRWWzdz5KLBoHSTl0FB73vNtnNeXONyHpVQ== -->
<!-- opencode-pin: opencode_version=1.18.18 -->
<!-- opencode-pin: observed_date=2026-08-15 -->
## Pin
- **opencode v1.18.18**, `opencode --version` output shape: bare `1.18.18`
version only, NO binary-name prefix, NO build hash (unlike grok's
`grok 1.0.4 (hash)`). The door's T1 shape assert is `/^\d+\.\d+\.\d+$/` on the
trimmed output; SST identity is discriminated by the `mcp`+`debug` subcommands
existing (`opencode debug paths` exits 0 and prints the path table below —
the renamed-to-Crush ancestor and other claimants have neither).
- **Provisioning (CI + local): pinned npm, pack-verify-install**
`opencode-ai@1.18.18`, registry integrity `sha512-J+5HFq…`. The CI job
`npm pack`s the wrapper AND the runner's platform payload first (pack
reports the integrity of the bytes it actually downloaded — closing the
view-then-install TOCTOU), asserts both against the stamps above, then
installs FROM the verified local wrapper tarball; the install-time platform
sub-package fetch is validated by npm against the same packument integrity
the pack step just byte-confirmed. The wrapper fans out to per-platform
payloads (`opencode-{darwin,linux,windows}-{arm64,x64}[-baseline|-musl]`) as
optionalDependencies at the same version; the LINUX payload integrities are
pinned separately because the wrapper's integrity covers only the wrapper
tarball. Darwin arm64 payload observed at
`sha512-VkG+bz8u8Xqg9NzPK+2/71nEd4DKKlo2NLZurQ1eLAzDnmb1CMYZif/o6Shl8YFuTuYU/30k6yufl4Zr0Ij64g==`
(informational — the CI runners are linux). Same npm version-immutability
assumption as the grok pin, stated explicitly.
- A curl installer (`https://opencode.ai/install`) exists but is NOT the pinned
lane; npm is.
## Pin-refresh cadence (this CLI ships near-continuously)
opencode releases far faster than grok (patch releases near-daily). The pinned
lane is the deterministic gate; the **canary leg** in `opencode-door` (schedule-
scoped, `continue-on-error`, installs `opencode-ai@latest`) exists to surface
drift BEFORE it strands the pin. Policy: when the canary leg reds or the pin is
>6 weeks old, run the re-observation checklist (bottom) against latest, bump the
stamps + workflow env pins together, and note behavior deltas in this file.
Do not chase every patch release; refresh on canary signal or the 6-week clock.
## Path seams — XDG honored; OPENCODE_CONFIG* env vars are INERT (verified)
`opencode debug paths` is the authoritative dump. Observed under
`HOME=<tmp> XDG_CONFIG_HOME=<tmp>/.config XDG_DATA_HOME=<tmp>/.local/share`:
```
config <XDG_CONFIG_HOME>/opencode (opencode.json + opencode.jsonc)
data <XDG_DATA_HOME>/opencode (auth.json, opencode.db*, log/, repos/)
state <tmp>/.local/state/opencode (locks/)
cache <tmp>/.cache/opencode (bin/)
tmp /tmp/opencode
```
- **HOME + XDG_CONFIG_HOME/XDG_DATA_HOME redirection works fully on macOS**
(nothing was written outside the hermetic home across the whole observation
run). The door uses HOME + both XDG vars, belt-and-suspenders.
- **DOCS-CONTRADICTION: `OPENCODE_CONFIG`, `OPENCODE_CONFIG_DIR`, and
`OPENCODE_CONFIG_CONTENT` had NO observable effect on config resolution in
1.18.18** — probes registered via each were absent from `mcp list`, while the
XDG-resolved global config was still read. gbrain's path helpers therefore
resolve via XDG only and deliberately do NOT honor `OPENCODE_CONFIG*`;
re-observe on version bump (if a future release activates them, the helpers
and this section change together). Hermetic child envs still DELETE all three
(defense against a future release activating them).
- Volatile paths (tripwire exclusions): `opencode.db`, `opencode.db-shm`,
`opencode.db-wal`, `log/`, `repos/` under data; `locks/` under state; `bin/`
under cache. The tripwire hashes only `opencode.json(c)` + `auth.json`.
- Vendor quirk: opencode writes a `.gitignore` (node_modules, package.json, …)
into the CONFIG dir on first touch.
## Config format — JSONC everywhere, both filenames merge (verified)
- `~/.config/opencode/opencode.jsonc` AND `~/.config/opencode/opencode.json`
are BOTH read when both exist (servers from each appeared simultaneously in
`mcp list`) — merge, not first-wins. opencode's own `mcp add` writes the
`.jsonc` name.
- **Comments parse in `.json`-named files too** (a `// comment` inside project
`opencode.json` did not break resolution). JSONC is the effective grammar for
every config file regardless of extension → gbrain's writer treats all
opencode configs as JSONC (jsonc-parser surgical edits; comments survive).
- Project config: `opencode.json` in the project root is read (lookup traverses
up); a project-scope entry appears alongside global entries.
- Unknown keys inside an `mcp.<name>` entry are TOLERATED in 1.18.18 (an
`_gbrain` probe key neither errored nor hid the server). gbrain still does
NOT write marker keys — ownership is judged by structural fingerprint — so a
future strict-schema flip cannot brick a user's opencode.
- `opencode debug config` prints the resolved merge (rendering has a doubled-
line quirk; treat it as a debug view, not a parse surface).
## `opencode mcp add` — observed facts
- Shape: `opencode mcp add <name> [--env KEY=VALUE]... -- <command> [args...]`
(local) or `opencode mcp add <name> --url <URL> [--header KEY=VALUE]...`
(remote). The `-- command` form is real but UNDOCUMENTED in `--help` (the
help lists only `--url/--env/--header`; the error copy for a bare add says
`Provide either --url <url> or a command after --`).
- **Always writes the GLOBAL `opencode.jsonc`** — even when a project
`opencode.json` with an `mcp` table exists in the cwd. There is NO scope
flag. Project-scope registration requires writing the file directly (gbrain's
writer does).
- **Add is lazy**: exit 0, no spawn, no prompt — for unreachable URLs and
nonexistent commands alike. Never treat add's exit code as a handshake.
- **Rewrites preserve comments and foreign keys** (a seeded `// comment` and a
`theme` key survived a subsequent add) — opencode uses a JSONC-preserving
editor internally; gbrain's writer matches that bar.
- `--header` values are stored verbatim, including `{env:VAR}` interpolation
syntax (`Authorization=Bearer {env:GBRAIN_REMOTE_TOKEN}` round-trips).
## Saved config schema (verbatim, from real adds)
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"gbrain": {
"type": "local",
"command": ["gbrain", "serve", "--surface", "verbs"],
"environment": { "GBRAIN_SOURCE": "workspace", "GBRAIN_HOME": "/tmp/<brain-home>" }
},
"gbrain-remote": {
"type": "remote",
"url": "https://brain.example/mcp",
"headers": { "Authorization": "Bearer {env:GBRAIN_REMOTE_TOKEN}" }
}
}
}
```
`enabled` is optional (absent = enabled). `oauth` was not written by the CLI and
is omitted by gbrain's writer (no OAuth interference with bearer headers was
observed). Local commands: an absolute `command[0]` works; PATH-resolved bare
`gbrain` resolves via the SPAWNING process's PATH (the door verifies the staged
bin-dir prepend).
## Probes — `mcp list` is the honest discriminator; `mcp debug` is NOT
- **`opencode mcp list` SPAWNS every configured local server and connects every
remote one**, then prints per-server status: `✓ <name> connected` or
`✗ <name> failed` with a reason line (`Executable not found in $PATH:
"gbrain"`, `SSE error: …`). THE door's keyless handshake proof. Caveats:
**exit code is 0 even when servers fail** (parse the text, assert
`✓ gbrain connected`), output is clack-style UI with ANSI codes, and there is
no `--json`.
- **`mcp list` is also a code-execution surface**: it spawned a PROJECT-defined
`type:local` command from a fresh checkout with NO prompt and NO trust gate
(verified with a touch-file probe). Two consequences: (1) gbrain's
bootstrap default scope for opencode is USER-GLOBAL — a committed project
entry would auto-spawn on every collaborator's machine; (2) any gbrain-run
probe uses `--pure` (kills external plugin autoload) + `OPENCODE_DISABLE_AUTOUPDATE=1`.
- `opencode mcp debug <name>` is OAUTH debugging only — on a local server it
prints `MCP server <name> is not a remote server` and exits 0. Not a
discriminator.
- No tool-count line exists in `mcp list` (grok's `7 tools discovered` has no
analog); tool discovery is proven by the SMOKE turn's `tool_use` events
instead.
## One-shot (`opencode run`) — KEYLESS WORKS (anonymous free tier)
- `opencode run "<msg>"` prints the ANSWER TEXT ALONE on stdout; the session
banner (`> build · <model>`) and UI go to stderr. Exit 0 on success; exit 1
with a structured JSON error (`"ref": "err_…"`) on failure (e.g. bogus
model).
- **Keyless runs WORK**: with zero credentials and no auth.json, `run` answers
via opencode's anonymous free tier (default model observed:
`opencode/big-pickle`; `opencode models` lists 8 keyless `opencode/*` models,
most `-free` suffixed; `opencode stats` reports $0.00). There is no
`Not signed in` wall in headless run mode.
- **MCP tools fire in keyless run mode WITHOUT `--auto`** (verified: the free
model called `gbrain_recall` and returned a seeded per-run nonce with
`--auto` absent). `--auto` exists (`auto-approve permissions that are not
explicitly denied (dangerous!)`) but the door does not need or use it.
- MCP tool naming: `<server>_<tool>` (observed `gbrain_recall`).
- `--format json` emits NDJSON events, every event
`{type, timestamp, sessionID, part}`; types observed: `step_start`,
`tool_use`, `text`, `step_finish`. Tool events carry
`part: {type:"tool", tool:"gbrain_recall", callID, state:{status:"completed",
input:{…}, output:"<stringified JSON>"}}` — `parseOpencodeJsonl` pins this.
- Model flag: `-m/--model <provider/model>` (`opencode/big-pickle` confirmed;
paid ids follow models.dev convention — see Pending auth).
- Keyless SMOKE end-to-end (proven 2026-08-15): pinned opencode + free model +
real `gbrain serve --surface verbs` (7 verbs banner) recalled a per-run nonce
through MCP with zero credentials, keyless PGLite brain.
## Environment — detectHarness + child-env facts (verified)
- Inside `run`'s bash tool, opencode sets **`OPENCODE=1`** and `OPENCODE_PID`
in child processes → `gbrain bootstrap`'s `detectHarness()` probes
`OPENCODE`.
- Auto-update kill: `OPENCODE_DISABLE_AUTOUPDATE=1` env + `"autoupdate": false`
config — the door seeds BOTH; version stayed pinned across every observed
run. `opencode upgrade` is the manual updater.
- Rules files: project `AGENTS.md` is loaded; a sibling `CLAUDE.md` is NOT
double-loaded (nonce test: only the AGENTS.md nonce surfaced) — AGENTS.md
wins per level, exactly as documented. gbrain's rendered pull-protocol
contract works unchanged.
- `.well-known/opencode` remote config: never observed to fire in any CLI run
(docs list it atop the lookup order). No kill needed today; re-observe on
version bump.
## Auth (only needed for PAID providers)
- Anonymous free tier needs nothing on disk; `auth.json` is only created by
`opencode auth login` at `<XDG_DATA_HOME>/opencode/auth.json`
(`opencode providers`, alias `auth`, prints the path).
- The optional paid door leg gates on `ANTHROPIC_API_KEY` (env-only) and
self-validates the model id against the authed `opencode models` output
before spending.
## When the door goes red (triage)
| Failure class | Signature | Remediation |
|---|---|---|
| npm pin drift | install step: version/integrity mismatch | Re-pin deliberately: bump `npm_version`+`npm_integrity` (+ platform stamps), run the re-observation checklist, update workflow env pins (check-opencode-pin.sh enforces the pair) |
| canary leg red, pinned leg green | latest-version leg fails install/asserts | Upstream changed shape — schedule a pin refresh; pinned lane still gates |
| version drift mid-run | `opencode --version` re-check ≠ pinned | Auto-update engaged — verify BOTH kills (env + config seed); re-pin if deliberate |
| `✗ gbrain failed` in `mcp list` | `Executable not found in $PATH` / spawn error | Staged bin dir missing from PATH, or abs path wrong — registration bug, not opencode drift |
| free-tier drift | keyless SMOKE stops answering / new auth wall | Re-observe keyless posture; if the free tier is gated, flip the SMOKE to the ANTHROPIC leg and re-pin this section |
| paid leg: model id unknown | models-gate assert fails before any spend | Update the pinned anthropic model id from the authed `opencode models` output |
| tripwire fired | manifest mismatch on `opencode.json(c)`/`auth.json` only | True isolation breach — stop and inspect; volatile-path drift alone must NOT fire |
| real door regression | handshake or nonce assert fails, pins intact | Bisect against the pinned version; file upstream if opencode-side |
Re-observation checklist on a version bump: npm pin captures (§Pin), help-surface
diff (`--help`, `run --help`, `mcp --help`, `mcp add --help`), the
add → saved-config → `mcp list` sequence (§add/§Probes), the keyless `run`
posture (§One-shot — free tier presence, stdout purity, MCP-without---auto),
`debug paths`, and the `OPENCODE_CONFIG*` inertness probe (§Path seams). The
spawn-gate probe (§Probes) re-runs whenever release notes mention MCP trust or
permissions.
## Pending auth (requires ANTHROPIC_API_KEY; the core door does NOT)
Authed `opencode models` list + exact `anthropic/<model>` id confirmation,
one paid one-shot smoke + per-turn cost note, `auth.json` verbatim shape after
`opencode auth login` (feeds evidence exclusions + TTY secretPaths), and
whether the authed TUI first-run differs from the keyless one pinned in the
dx scenario. The opencode-door paid leg self-validates the model id before
spending, so these pins harden the door but do not block it.
## Supported-version policy
gbrain's opencode integration is verified against **opencode v1.18.18** (this
pin). The canary CI leg tracks latest (continue-on-error); the pinned lane is
the deterministic gate. Keyless free-tier behavior is a LOAD-BEARING
observation (the SMOKE rides it) — treat free-tier changes as pin-refresh
triggers, not flakes.
+175
View File
@@ -0,0 +1,175 @@
# Connect GBrain to opencode
> This page is the MCP-registration reference for **opencode** — the SST
> terminal coding agent (opencode.ai, npm `opencode-ai`; not OpenClaw, and not
> the original `opencode` CLI that was renamed Crush — see Troubleshooting).
> For the full brain install — CLI, engine, skills, dream cycle — follow
> [INSTALL_FOR_AGENTS.md](../../INSTALL_FOR_AGENTS.md) first; this page wires
> the finished brain into opencode over stdio MCP. opencode is a
> **bootstrap-supported harness**: `gbrain bootstrap hooks --harness opencode`
> registers the brain for you (and `gbrain connect --agent opencode` handles
> remote brains — see below) — the commands on this page are the standalone
> manual recipe. Bootstrap's own registration additionally pins the workspace
> source (`GBRAIN_SOURCE`) and the full op surface, so the two are not
> byte-identical.
opencode spawns `gbrain serve` as a local stdio subprocess. No server, no
tunnel, no token needed. Works with both PGLite and Supabase engines — and
because opencode natively reads `AGENTS.md`, a gbrain workspace's rendered
brain contract loads with zero extra configuration.
## Register (recommended)
```bash
opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs
```
`--surface verbs` exposes the seven-verb memory protocol (`recall`,
`remember`, `entity`, `synthesize`, `forget`, `context_pack`, `delta`
[MEMORY_VERBS v1](../protocol/MEMORY_VERBS_v1.md)) instead of the full
100+-op catalog — the recommended starting surface for coding agents.
Three facts about `opencode mcp add`, all observed:
- **The local-command form is `-- <command> [args...]` after the flags**
it's real but missing from `--help` (which shows only `--url/--env/--header`).
`--env` is repeatable, one `KEY=VALUE` per flag.
- **Registration is lazy.** The add writes config and exits 0 without
connecting — even for a nonexistent command. Verify with `opencode mcp list`
(below), never with the add's exit code.
- **It always writes the USER-GLOBAL config**
(`~/.config/opencode/opencode.jsonc`) — there is no scope flag. For a
project-scoped entry, write the project `opencode.json` directly (next
section) — but read the sharing warning first.
## Direct config (equally supported)
Global (`~/.config/opencode/opencode.jsonc`) or project (`opencode.json` in
the repo root — opencode's lookup traverses up to the git root):
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"gbrain": {
"type": "local",
"command": ["gbrain", "serve", "--surface", "verbs"],
"environment": { "GBRAIN_HOME": "/home/alice-example" },
"enabled": true
}
}
}
```
Comments are fine — opencode parses JSONC in both `.json` and `.jsonc` files,
and both filenames are read (merged) when both exist. To remove gbrain,
delete the entry, or set `"enabled": false` to disable without losing it.
**Sharing warning for project config:** opencode spawns project-defined local
MCP servers with **no trust prompt** — a committed `opencode.json` carrying a
gbrain entry executes on every collaborator's machine. Teammates without
gbrain get a failing spawn each session; teammates WITH gbrain attach their
own `host` brain to your repo's context. Prefer the user-global config (the
gbrain bootstrap default); if you do commit a project entry, use the
PATH-resolved `"gbrain"` command form (never an absolute path) and tell
collaborators `"enabled": false` is the opt-out.
## Verify
```bash
opencode mcp list # the real probe: SPAWNS the server
```
`opencode mcp list` performs the actual spawn + handshake for every
configured server — expect `✓ gbrain connected`. A broken registration shows
`✗ gbrain failed` with the reason (e.g. `Executable not found in $PATH`).
Because it spawns everything — including any project `opencode.json` entries
in your cwd, with no trust prompt — run it from a directory you trust
(gbrain's own bootstrap verification probe runs from an empty temp directory
for exactly this reason, and skips the live probe entirely for project-scoped
registrations).
Two caveats: the exit code is 0 even when servers fail (read the output, not
`$?`), and `opencode mcp debug` is OAuth-only diagnostics — it is NOT a
handshake probe for local servers. Then one real round-trip:
```bash
opencode run "use the gbrain recall tool to answer: what did I import most recently?"
```
`opencode run` (headless one-shot) prints the final answer alone on stdout
(UI goes to stderr). MCP tools work in run mode without any permission flags.
## Remote brains (`gbrain connect`)
For a brain served over HTTP on another machine:
```bash
gbrain connect https://your-host/mcp --token gbrain_xxx --agent opencode [--install]
```
Without `--install` it prints the config block to add; with `--install` it
writes the entry directly into the user-global config (no opencode binary
required — the JSONC write IS the registration) and smoke-tests the token.
Either way the config stores only the `{env:GBRAIN_REMOTE_TOKEN}`
interpolation — opencode resolves the env var at read time, so the token
never lands in the file. Export `GBRAIN_REMOTE_TOKEN` in your shell profile.
`--force` replaces a gbrain-managed entry whose endpoint moved (a rotated
serve); an entry gbrain didn't write is never replaced — pick another
`--name`. (Framework-spawned opencode inherits no shell profile;
`gbrain bootstrap harness --harness opencode` covers that case with an
inline-bearer entry written 0600.)
## Auth + model pin
- **Keyless works.** opencode ships an anonymous free tier (default model
`opencode/big-pickle` at observation time) — headless runs and MCP tool
calls work with zero credentials. For paid providers, export the provider
key (e.g. `ANTHROPIC_API_KEY`) or run `opencode auth login` (credentials
land in `~/.local/share/opencode/auth.json`).
- **Model pin:** pass `-m <provider/model>` per call, or set `"model"` in the
config. `opencode models` lists what your credentials can reach.
- **Updates:** opencode self-updates by default. For pinned/reproducible
environments, set BOTH `"autoupdate": false` in config AND
`OPENCODE_DISABLE_AUTOUPDATE=1` in the environment.
## Pair with cron
opencode has no built-in cron; schedule headless one-shots with your system
scheduler:
```bash
# crontab: brain maintenance every 4 hours
0 */4 * * * opencode run "Run gbrain sync and report anything unusual"
```
See [docs/guides/cron-schedule.md](../guides/cron-schedule.md) for the full
brain maintenance protocol (sync, embed, dream cycle).
## Troubleshooting
- **Wrong `opencode` on PATH** — the name has prior claimants (the original
`opencode` project was renamed Crush). The SST CLI answers
`opencode --version` with a bare semver (`1.18.18`) and has `opencode mcp`
+ `opencode debug paths` subcommands. Install it via
`npm install -g opencode-ai` or `curl -fsSL https://opencode.ai/install | bash`.
- **opencode ≠ OpenClaw** — opencode (opencode.ai / SST) is the terminal
agent this page covers; OpenClaw is the agent platform with its own gbrain
runner and docs ([OPENCLAW.md](OPENCLAW.md)).
- **`✗ gbrain failed — Executable not found in $PATH`** — the registered
command was the bare `"gbrain"` name and opencode's PATH doesn't carry it.
Use the absolute binary path in the user-global config, or fix PATH.
- **Registered but nothing changed mid-session** — opencode reads config at
session start; restart opencode (or start a new session) after registering.
- **`OPENCODE_CONFIG` seems ignored** — observed inert in v1.18.18: only
`HOME`/`XDG_CONFIG_HOME` move the config location. Don't rely on it.
- **Which config won?**`opencode debug config` prints the resolved merge;
`opencode debug paths` prints every directory opencode uses.
- **Rules files** — opencode loads the project `AGENTS.md` (a sibling
`CLAUDE.md` is NOT double-loaded; AGENTS.md wins). gbrain's rendered
workspace contract rides this natively.
---
Verified against **opencode v1.18.18** (fast-moving project — the pin is
enforced in CI, with a latest-version canary leg watching for drift).
Dev-facing observed-behavior notes (exact flag semantics, exit-code caveats,
config schema, CI pin values) live in [OPENCODE-CLI-PIN.md](OPENCODE-CLI-PIN.md).
+20 -16
View File
@@ -1,6 +1,6 @@
# Headless install: Docker, CI, postinstall
`gbrain init --pglite` in a non-TTY context (Docker `RUN`, CI step, postinstall hook) exits 1 when no embedding-provider API key is present in the environment. This is a deliberate fail-loud — the alternative is a silent-broken state where init succeeds with a default that doesn't match any real key.
`gbrain init --pglite` in a non-TTY context (Docker `RUN`, CI step, postinstall hook) with no embedding-provider API key continues **keyless** (keyword-only search) with a loud notice — a first-class supported end state, not an error (Pattern 3 below). Init reads keys from the environment or from `~/.gbrain/config.json` (env wins). Two fail-louds remain: a near-miss env var name (e.g. `OPENAPI_API_KEY`) exits 1 with the corrected spelling instead of being silently ignored, and multiple keys with no canonical candidate exit 1 asking for an explicit `--embedding-model`.
Three patterns work for headless installs. Pick whichever fits your image lifecycle.
@@ -13,23 +13,25 @@ If your CI / Docker pipeline can inject the API key as a build-time env var, set
FROM oven/bun:1 AS builder
# Inject key at build via --build-arg or `--env` from CI.
ARG OPENAI_API_KEY
ENV OPENAI_API_KEY=$OPENAI_API_KEY
ARG VOYAGE_API_KEY
ENV VOYAGE_API_KEY=$VOYAGE_API_KEY
RUN bun install -g github:garrytan/gbrain#latest-stable
RUN gbrain init --pglite # auto-picks OpenAI, persists config
RUN gbrain init --pglite # auto-picks the Voyage default (voyage-4 @ 1024d), persists config
```
```yaml
# GitHub Actions equivalent
- name: Initialize gbrain
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
VOYAGE_API_KEY: ${{ secrets.VOYAGE_API_KEY }}
run: |
bun install -g github:garrytan/gbrain#latest-stable
gbrain init --pglite
```
Any provider key works the same way (`OPENAI_API_KEY` → OpenAI, etc.); see [the provider matrix](../integrations/embedding-providers.md).
Init writes `~/.gbrain/config.json` with the resolved `embedding_model` + `embedding_dimensions`. Subsequent runs (in the same image / runner) read from that config and don't re-resolve.
## Pattern 2: Provider key only at runtime (deferred-setup)
@@ -44,19 +46,20 @@ RUN bun install -g github:garrytan/gbrain#latest-stable
# width, but no embed callsite will actually run until runtime config.
RUN gbrain init --pglite --no-embedding
# At container start (entrypoint), provide the real provider:
# At container start (entrypoint), the runtime env now carries the key —
# re-init resolves the provider from it (or pin one explicitly with
# --embedding-model <provider>:<model>):
ENTRYPOINT ["/bin/sh", "-c", "\
gbrain config set embedding_model openai:text-embedding-3-large \
&& gbrain init --force --pglite \
gbrain init --force --pglite \
&& exec gbrain serve"]
```
The `gbrain init --no-embedding` opt-in writes `embedding_disabled: true` to config. Every embed callsite (`gbrain import`, `gbrain embed`, the `runEmbedCore` library entry point) checks this and refuses cleanly with a `gbrain config set embedding_model <id>` hint rather than proceeding with a silent default.
The `gbrain init --no-embedding` opt-in writes `embedding_disabled: true` to config. Every embed callsite (`gbrain import`, `gbrain embed`, the `runEmbedCore` library entry point) checks this and refuses cleanly with a re-init hint (`gbrain init --force --embedding-model voyage:voyage-4`) rather than proceeding with a silent default. (`gbrain config set embedding_model` is refused by design — it's a file-plane schema-sizing field the DB-plane command can't affect.)
The runtime `gbrain init --force` re-runs the init flow against the now-populated env, which:
- Removes `embedding_disabled` from config.
- Resolves the provider via env detection.
- Removes `embedding_disabled` from config (an explicit `--embedding-model` flag also clears it).
- Resolves the provider via key detection (env vars or `~/.gbrain/config.json`).
- Re-templates the PGLite schema if dim differs from the build-time default.
## Pattern 3: No key, ever (keyless mode)
@@ -73,15 +76,16 @@ RUN gbrain init --pglite --no-embedding # keyless install — done; no runtime
Since every embedding cost gate is structurally moot with no key, none of `docs/operations/spend-controls.md` applies until you add one.
## What WON'T work
## What changed from older releases
```dockerfile
# Don't do this — silent default leaves you with vector(1280) ZE column
# and 1536d OpenAI provider at runtime, mismatched.
RUN gbrain init --pglite
# On older gbrain releases this persisted a silent provider default that
# mismatched the runtime key (a legacy-width column with a different-width
# provider at runtime).
RUN gbrain init --pglite # no keys in the build env
```
If an older image used this pattern, `gbrain doctor` will surface the mismatch on first run after upgrade and print a paste-ready repair command — `gbrain init --force --pglite --embedding-model <model> --embedding-dimensions <dims>` for brains with no embeddings yet, `gbrain migrate embeddings --to <model> --dim <dims>` for non-empty brains.
It now continues keyless — the same end state as Pattern 3 — and recovery is Pattern 2's runtime `gbrain init --force`. If an older image shipped the mismatched shape, `gbrain doctor` will surface the mismatch on first run after upgrade and print a paste-ready repair command — `gbrain init --force --pglite --embedding-model <model> --embedding-dimensions <dims>` for brains with no embeddings yet, `gbrain migrate embeddings --to <model> --dim <dims>` for non-empty brains.
## Verifying a headless install
+5
View File
@@ -69,6 +69,11 @@ codex mcp add gbrain -- gbrain serve --surface verbs
grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
```
**opencode** (verify with `opencode mcp list` — the add is lazy, and list SPAWNS the server)
```bash
opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs
```
**OpenClaw / any stdio MCP host** — register the server command
`gbrain serve --surface verbs`. Remote brains: `gbrain serve --http` on the
host, then `gbrain connect https://host/mcp --token gbrain_xxx --install` on
+4 -4
View File
@@ -493,9 +493,9 @@ OAuth source scoping only guards the HTTP MCP path. If the brain's Postgres and
## Part 13: Cost and speed expectations
Real numbers from the published benchmark, running the default stack (GBrain with ZeroEntropy for embedding + reranker):
Real numbers from the published benchmark. The benchmark ran the then-default ZeroEntropy stack (now deprecated — its hosted API ends 2026-09-04); the current default is Voyage `voyage-4` + `rerank-2.5`, in the same price and latency class:
- **Embedding cost:** $0.05 per million tokens. For comparison, GBrain configured with OpenAI is $0.13 (2.6× more expensive), Voyage is $0.18 (3.6× more).
- **Embedding cost:** the current default (`voyage:voyage-4`) is $0.06 per million tokens; the benchmark's ZeroEntropy stack was $0.05. For comparison, GBrain configured with OpenAI is $0.13.
- **Ingest speed:** about 22 seconds for a small test corpus of 164 pages on the host machine. For a 10K-page corpus, expect about 20 minutes the first time, then most syncs are incremental and finish in seconds.
- **Query latency:** about 122 ms median for a `gbrain search`. For comparison, the same query through GBrain with OpenAI takes about 282 ms.
- **Synthesized-answer latency:** a few seconds, dominated by the Anthropic API.
@@ -503,7 +503,7 @@ Real numbers from the published benchmark, running the default stack (GBrain wit
Full methodology and per-run receipt JSONs live in [the gbrain-evals repo](https://github.com/garrytan/gbrain-evals/blob/main/docs/benchmarks/2026-05-23-v0.40.6.0-snapshot.md).
For a 25-person company at sustained use, expect about $35 a month in embeddings (ZeroEntropy at $0.05/million tokens), $50 a month in Anthropic calls for the synthesized-answer queries, plus your hosting bill. Under $100 a month for the AI side at most companies your size.
For a 25-person company at sustained use, expect about $40 a month in embeddings (the default `voyage-4` at $0.06/million tokens), $50 a month in Anthropic calls for the synthesized-answer queries, plus your hosting bill. Under $100 a month for the AI side at most companies your size.
---
@@ -515,7 +515,7 @@ Check `gbrain auth list` on the host and confirm their client has `--source` set
### "Sync is slow and feels stuck"
The first sync embeds every page, which takes time. Check `gbrain sources status` for the live page count. If it's climbing you're not stuck, you're just embedding. If you've got a 10K-page corpus and ZeroEntropy is being throttled, the per-source parallel sync looks like progress on three sources at once rather than one source moving fast.
The first sync embeds every page, which takes time. Check `gbrain sources status` for the live page count. If it's climbing you're not stuck, you're just embedding. If you've got a 10K-page corpus and your embedding provider is throttling you, the per-source parallel sync looks like progress on three sources at once rather than one source moving fast.
### "I see a page I shouldn't see"
+2 -3
View File
@@ -106,8 +106,7 @@ In the AlphaClaw UI (Providers tab):
- **OpenAI API Key.** Required for embeddings if you use the OpenAI provider.
- **Anthropic API Key.** Required for Claude (the main model the agent talks through).
- **Perplexity API Key.** Optional, for web search.
- **Voyage API Key.** Optional, alternative to OpenAI for embeddings.
- **ZeroEntropy API Key.** Recommended. GBrain ships with ZeroEntropy as the default embedder + reranker because it's about 2× faster than OpenAI and about 2.6× cheaper.
- **Voyage API Key.** Recommended. GBrain ships with Voyage as the default embedder + reranker (`voyage-4` + `rerank-2.5`) — one key covers both, at about half OpenAI's embedding price.
You can use the same keys across multiple agents.
@@ -236,7 +235,7 @@ Brains share through git. My main agent can populate another agent's brain by pu
|-----------|-------------|
| Render Pro (minimum viable) | about $85 |
| Supabase (small) | free to $25 |
| OpenAI API (embeddings) | $5 to $20 (much less if you use ZeroEntropy as the default) |
| OpenAI API (embeddings) | $5 to $20 (about half that on the default Voyage stack) |
| Anthropic API (Claude) | $50 to $500 (usage dependent) |
| **Total minimum** | **about $100 to $150 a month** |
+40 -16
View File
@@ -1075,19 +1075,22 @@ restart the shell or add the PATH export to the shell profile.
## Step 2: API Keys
Ask the user for these. gbrain defaults to the ZeroEntropy embedding + reranker stack
(as of v0.36.2.0); OpenAI/Voyage are still supported as fallbacks via `gbrain config
set embedding_model <provider:model>`.
Ask the user for these. gbrain defaults to the Voyage embedding + reranker stack
(`voyage:voyage-4` @ 1024d + `voyage:rerank-2.5` — one key covers both); OpenAI is the
main alternative, chosen at init via `--embedding-model <provider:model>`. ZeroEntropy
is deprecated (its hosted API shuts down 2026-09-04): init auto-pick and the picker
exclude it, and every ZE embed/rerank prints a deprecation warning.
```bash
export ZEROENTROPY_API_KEY=ze-... # default embedding + reranker (v0.36.2.0+)
export OPENAI_API_KEY=sk-... # fallback for vector search; also used for chat models
export VOYAGE_API_KEY=pa-... # default embedding + reranker (one key covers both)
export OPENAI_API_KEY=sk-... # alternative for vector search; also used for chat models
export ANTHROPIC_API_KEY=sk-ant-... # optional, improves search quality via query expansion
```
Save to shell profile or `.env`. Keys are picked up by `gbrain config set` automatically
or can be stored in `~/.gbrain/config.json` (file plane). Without any embedding provider,
keyword search still works. Without Anthropic, search works but skips query expansion.
Save to shell profile or `.env`, or store in `~/.gbrain/config.json` (file plane). Do
NOT use `gbrain config set` for API keys — it writes the DB plane, which the embedding
pipeline never reads. Without any embedding provider, keyword search still works.
Without Anthropic, search works but skips query expansion.
## Step 3: Create the Brain
@@ -1256,10 +1259,25 @@ grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
The add is lazy (exit 0 without connecting) — verify with
`grok mcp doctor gbrain`, which spawns the server and must report
`7 tools discovered`. This is the brain-only install; the `gbrain bootstrap`
personal-agent path does not support Grok yet (Claude Code/Codex only).
personal-agent path does not support Grok yet (Claude Code, Codex, and opencode only).
Verified against Grok Build v1.0.4. Full reference:
[docs/mcp/GROK.md](docs/mcp/GROK.md).
**If you are opencode** (the SST terminal agent, opencode.ai — not OpenClaw):
you are a bootstrap-supported harness — for the full persistent-personal-agent
install, follow `BOOTSTRAP_FOR_AGENTS.md` instead of this page. For the
brain-only MCP registration:
```bash
opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs
```
The add is lazy (exit 0 without connecting) — verify with `opencode mcp list`,
which spawns the server and must show `✓ gbrain connected` (the exit code is 0
even on failure; read the output). Restart opencode afterwards — it reads
config at session start. Verified against opencode v1.18.18. Full reference:
[docs/mcp/OPENCODE.md](docs/mcp/OPENCODE.md).
Whether you scaffolded or not, read `skills/RESOLVER.md` (in your workspace, or the
bundled copy at `~/gbrain/skills/RESOLVER.md` when running from the cloned repo). It's
the skill dispatcher — tells you which skill to read for any task. Save this to your
@@ -1784,6 +1802,7 @@ GBrain exposes nearly all of its 100+ operations as MCP tools (stdio and HTTP; a
- **[Cursor / Windsurf / any stdio MCP client](docs/mcp/CLAUDE_CODE.md)** — same shape, add `{"command": "gbrain", "args": ["serve"]}` to your MCP config.
- **[Hermes](docs/mcp/HERMES.md)** — `printf 'Y\n' | hermes mcp add gbrain --env GBRAIN_HOME=$HOME --connect-timeout 60 --command $(which gbrain) --args serve`. Keep `--args` last, and verify with `hermes mcp test gbrain` (the add exits 0 even on failure).
- **[Grok Build](docs/mcp/GROK.md)** — `grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs`. The add is lazy (exit 0 without connecting) — verify with `grok mcp doctor gbrain`, which spawns the server and reports `7 tools discovered`. Verified against Grok Build v1.0.4.
- **[opencode](docs/mcp/OPENCODE.md)** (opencode.ai / SST — not OpenClaw) — `opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs`, or let `gbrain bootstrap hooks --harness opencode` write the config for you (opencode is a bootstrap-supported harness — it reads AGENTS.md natively). The add is lazy — verify with `opencode mcp list`, which spawns the server (`✓ gbrain connected`). Remote: `gbrain connect https://your-host/mcp --token gbrain_xxx --agent opencode [--install]` — the config stores only the `{env:GBRAIN_REMOTE_TOKEN}` interpolation. Verified against opencode v1.18.18.
- **[OpenClaw](docs/mcp/OPENCLAW.md)** — the ClawHub bundle plugin registers gbrain automatically (`openclaw.plugin.json` ships in this repo), or add `{"command": "gbrain", "args": ["serve"]}` to `~/.openclaw/config.json`'s `mcpServers`.
- **[Claude Desktop (Cowork)](docs/mcp/CLAUDE_DESKTOP.md)** — Settings → Integrations → add the URL of your HTTP server. Remote only; the local `claude_desktop_config.json` does not work for remote servers.
- **[Claude Cowork (team plan)](docs/mcp/CLAUDE_COWORK.md)** — org Owner adds the connector under Organization Settings → Connectors.
@@ -1917,7 +1936,7 @@ The whole loop is described in [`docs/architecture/topologies.md`](docs/architec
## Capabilities
**Hybrid search.** Vector (HNSW on pgvector) + BM25 keyword + reciprocal-rank fusion + source-tier boost + intent-aware query rewriting. Three named search modes (`conservative`, `balanced`, `tokenmax`) bundle the cost/quality knobs into a single config key. Live cost/recall comparisons in [`docs/eval/SEARCH_MODE_METHODOLOGY.md`](docs/eval/SEARCH_MODE_METHODOLOGY.md). The install picker default-applies `tokenmax` (it recommends `conservative` for Haiku-class subagent tiers or keyless setups); a brain with `search.mode` unset resolves to `balanced` at query time. The ZeroEntropy reranker is on in `balanced` and `tokenmax`, off in `conservative`. Per-query graph signals notice when a top result is a hub for THAT query (adjacency boost), is corroborated across team brains (cross-source boost), or is being crowded out by weak chunks from a chatty session (session demote). Run `gbrain search "<query>" --explain` to see per-stage attribution: base score, every boost that fired, what it multiplied. `gbrain doctor` ships a `graph_signals_coverage` check; `gbrain search stats` shows fire counts and failure breakdowns. Vector retrieval pools the best chunk per page, so a page surfaces on its strongest evidence instead of losing to a neighbor on one weak chunk. Queries that match a page's title phrase or a declared free-text alias (`gbrain reindex --aliases` backfills existing pages) get boosted to the page they name. Every result carries an `evidence` tag (why it matched) and a `create_safety` hint (`exists` / `probable` / `unknown`) so an agent decides whether a page already exists instead of guessing from a raw score. `gbrain search diagnose "<query>" --target <slug>` traces which retrieval layer surfaces (or misses) a page.
**Hybrid search.** Vector (HNSW on pgvector) + BM25 keyword + reciprocal-rank fusion + source-tier boost + intent-aware query rewriting. Three named search modes (`conservative`, `balanced`, `tokenmax`) bundle the cost/quality knobs into a single config key. Live cost/recall comparisons in [`docs/eval/SEARCH_MODE_METHODOLOGY.md`](docs/eval/SEARCH_MODE_METHODOLOGY.md). The install picker default-applies `tokenmax` (it recommends `conservative` for Haiku-class subagent tiers or keyless setups); a brain with `search.mode` unset resolves to `balanced` at query time. The cross-encoder reranker is on in `balanced` and `tokenmax`, off in `conservative` — new installs get Voyage `rerank-2.5`; brains that never set `search.reranker.model` still fall back to the deprecated ZeroEntropy `zerank-2` (hosted API ends 2026-09-04) until the September cutover. Per-query graph signals notice when a top result is a hub for THAT query (adjacency boost), is corroborated across team brains (cross-source boost), or is being crowded out by weak chunks from a chatty session (session demote). Run `gbrain search "<query>" --explain` to see per-stage attribution: base score, every boost that fired, what it multiplied. `gbrain doctor` ships a `graph_signals_coverage` check; `gbrain search stats` shows fire counts and failure breakdowns. Vector retrieval pools the best chunk per page, so a page surfaces on its strongest evidence instead of losing to a neighbor on one weak chunk. Queries that match a page's title phrase or a declared free-text alias (`gbrain reindex --aliases` backfills existing pages) get boosted to the page they name. Every result carries an `evidence` tag (why it matched) and a `create_safety` hint (`exists` / `probable` / `unknown`) so an agent decides whether a page already exists instead of guessing from a raw score. `gbrain search diagnose "<query>" --target <slug>` traces which retrieval layer surfaces (or misses) a page.
**Self-wiring knowledge graph.** Every `put_page` extracts entity refs from markdown/wikilinks/typed-link syntax and writes edges with zero LLM calls. Typed edges (`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, …). Multi-hop traversal via `gbrain graph-query`. The graph is what produces the +31.4 P@5 lift over vector-only RAG. **Obsidian-style vaults:** bare `[[note-name]]` wikilinks that point across folders — you wrote `[[struktura]]` but the page lives at `projects/struktura.md` — resolve by basename once you opt in with `gbrain config set link_resolution.global_basename true`. Off by default; `gbrain doctor` tells you how many edges you'd gain before you flip it. See [migrating an Obsidian vault](INSTALL_FOR_AGENTS.md#step-45-wire-the-knowledge-graph).
@@ -1955,8 +1974,8 @@ Data flowing into the brain. Each integration is a recipe — markdown + setup h
- **Voice**: Phone calls create brain pages via Twilio + OpenAI Realtime (or DIY STT+LLM+TTS). Setup recipe: [`recipes/twilio-voice-brain.md`](recipes/twilio-voice-brain.md).
- **Email + calendar**: webhook handlers that route to brain signals. [`docs/integrations/meeting-webhooks.md`](docs/integrations/meeting-webhooks.md).
- **Embedding providers**: a dozen providers covered — OpenAI (default fallback), OpenRouter, Voyage, ZeroEntropy (default), Google Gemini, Azure OpenAI, MiniMax, Alibaba DashScope, Zhipu, Ollama (local), llama.cpp llama-server (local), LiteLLM proxy. Pricing matrix + decision tree in [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md).
- **Rerankers**: ZeroEntropy `zerank-2` hosted (the default; on in `balanced` and `tokenmax` modes) plus the `llama-server-reranker` recipe for fully-local cross-encoder rerank via llama.cpp — runs Qwen3-Reranker or self-hosted ZeroEntropy weights against the same `gateway.rerank()` seam. Setup walkthrough in [`docs/ai-providers/llama-server-reranker.md`](docs/ai-providers/llama-server-reranker.md).
- **Embedding providers**: a dozen providers covered — Voyage (default: `voyage-4` @ 1024d), OpenAI, OpenRouter, Google Gemini, Azure OpenAI, MiniMax, Alibaba DashScope, Zhipu, Ollama (local), llama.cpp llama-server (local), LiteLLM proxy, plus ZeroEntropy (deprecated — hosted API ends 2026-09-04). Pricing matrix + decision tree in [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md).
- **Rerankers**: Voyage `rerank-2.5` hosted (the new-install default; reranking is on in `balanced` and `tokenmax` modes, same `VOYAGE_API_KEY` as embeddings), ZeroEntropy `zerank-2` (deprecated — hosted API ends 2026-09-04; still the fallback for brains that never set `search.reranker.model`), plus the `llama-server-reranker` recipe for fully-local cross-encoder rerank via llama.cpp — runs Qwen3-Reranker or self-hosted zerank weights against the same `gateway.rerank()` seam. Setup walkthrough in [`docs/ai-providers/llama-server-reranker.md`](docs/ai-providers/llama-server-reranker.md).
- **Credential gateway**: vault-aware secret distribution. [`docs/integrations/credential-gateway.md`](docs/integrations/credential-gateway.md).
- **MCP clients**: every major MCP client is supported. [`docs/mcp/`](docs/mcp/) per-client setup.
@@ -1974,7 +1993,7 @@ Data flowing into the brain. Each integration is a recipe — markdown + setup h
**PGLite crashes at startup with `RuntimeError: Aborted()` (often right after a macOS upgrade)?** Not a macOS incompatibility — the OS-upgrade reboot killed gbrain mid-write and tore the data dir's WAL. gbrain now repairs this automatically on the next command (data preserved, backup kept); if auto-repair is disabled or skipped, run `gbrain pglite-repair --dry-run` to diagnose and `gbrain pglite-repair --yes` to repair in place. Full recovery ladder (repair → rebuild → engine switch) in [`docs/ENGINES.md` — Troubleshooting: startup abort](docs/ENGINES.md#troubleshooting-startup-abort-runtimeerror-aborted) and [`docs/INSTALL.md`](docs/INSTALL.md#pglite-crashes-on-macos-26x-tahoe).
**`gbrain import` fails with `expected N dimensions, not M`?** Run `gbrain doctor`. It will print the exact `gbrain config set ...` or `gbrain retrieval-upgrade` command to repair the mismatch. You should not need to delete `~/.gbrain`. Fresh `gbrain init --pglite` auto-detects your embedding provider from API keys in your environment: set `OPENAI_API_KEY` (or `ZEROENTROPY_API_KEY` / `VOYAGE_API_KEY`) before running init, or pass `--embedding-model <provider>:<model>` explicitly. With multiple keys set, init fires an interactive picker. In non-TTY contexts (CI, Docker) with no keys, init exits 1 with a paste-ready setup hint; pass `--no-embedding` to defer setup until runtime. See [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md) for the full provider matrix and [`docs/operations/headless-install.md`](docs/operations/headless-install.md) for Docker/CI sequencing.
**`gbrain import` fails with `expected N dimensions, not M`?** Run `gbrain doctor`. It will print the exact `gbrain config set ...` or `gbrain retrieval-upgrade` command to repair the mismatch. You should not need to delete `~/.gbrain`. Fresh `gbrain init --pglite` auto-detects your embedding provider from API keys: set `VOYAGE_API_KEY` (or `OPENAI_API_KEY` / another provider key) in the environment — or in `~/.gbrain/config.json`, which init also reads — before running init, or pass `--embedding-model <provider>:<model>` explicitly. With multiple keys set, init fires an interactive picker (non-TTY auto-picks the Voyage default when its key is present). With no keys at all, init continues keyless (keyword-only search) with a loud notice; add a key later and re-run `gbrain init --force --embedding-model voyage:voyage-4` to enable embeddings, or pass `--no-embedding` up front to make keyless explicit. See [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md) for the full provider matrix and [`docs/operations/headless-install.md`](docs/operations/headless-install.md) for Docker/CI sequencing.
**Hourly cron sync keeps timing out on a federated brain?** Switch your
cron to a per-source loop with shell `timeout(1)` doing the OS-level kill
@@ -2120,7 +2139,7 @@ MIT. I built GBrain to run my OpenClaw and Hermes deployments — the production
Origin story: [`docs/ethos/ORIGIN.md`](docs/ethos/ORIGIN.md).
Community PR contributors are credited in `CHANGELOG.md` per release. ZeroEntropy ([@zeroentropy](https://zeroentropy.dev)) for the embedding + reranker stack that ships as the default. Voyage AI for the asymmetric-encoding recipe template. Ramp Labs for the search quality improvements lineage.
Community PR contributors are credited in `CHANGELOG.md` per release. ZeroEntropy ([@zeroentropy](https://zeroentropy.dev)) for the embedding + reranker stack that shipped as the default from v0.36 through v0.46. Voyage AI for the asymmetric-encoding recipe template. Ramp Labs for the search quality improvements lineage.
---
@@ -3943,7 +3962,7 @@ The push channels share one zero-LLM core (`src/core/context/volunteer.ts`):
| `reflex` | automatic, inside the context engine | default-on for plugin hosts; nothing to call |
| `op` | `gbrain volunteer-context` / MCP `volunteer_context` | agents without the plugin; one call per turn |
| `watch` | `gbrain watch` | stream a transcript in, volunteered pages stream out |
| `claude-code` / `codex` | `gbrain hook user-prompt` (registered by `gbrain bootstrap`) | per-prompt injection inside a harness; see "Harness hooks" below |
| `claude-code` / `codex` / `opencode` | `gbrain hook user-prompt` (registered by `gbrain bootstrap`) | per-prompt injection inside a harness; see "Harness hooks" below |
## How it decides
@@ -4005,7 +4024,7 @@ this channel production-grade rather than spammy-and-invisible:
- **The feedback loop.** The serve logs each DELIVERED block's volunteered
pages and pointers to `context_volunteer_events` under the hook's channel
(`claude-code` by default; a codex hook registration passes
`--harness codex`). `gbrain volunteer-context --stats` then shows
`--harness codex` / `--harness opencode`). `gbrain volunteer-context --stats` then shows
per-harness precision, and `gbrain doctor`'s `volunteer_channels` check
shows which channels actually fire, with guidance for the two quiet cases:
"hook installed but never registered (restart the session)" and "registered
@@ -4497,6 +4516,11 @@ codex mcp add gbrain -- gbrain serve --surface verbs
grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
```
**opencode** (verify with `opencode mcp list` — the add is lazy, and list SPAWNS the server)
```bash
opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs
```
**OpenClaw / any stdio MCP host** — register the server command
`gbrain serve --surface verbs`. Remote brains: `gbrain serve --http` on the
host, then `gbrain connect https://host/mcp --token gbrain_xxx --install` on
+1 -1
View File
@@ -31,7 +31,7 @@ Repo: https://github.com/garrytan/gbrain
## AI providers
- [docs/ai-providers/zeroentropy.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/ai-providers/zeroentropy.md): ZeroEntropy zembed-1 embedding + zerank-2 reranker (hosted): API key, embedding switch, reranker config.
- [docs/ai-providers/zeroentropy.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/ai-providers/zeroentropy.md): ZeroEntropy zembed-1 embedding + zerank-2 reranker (hosted): API key, embedding switch, reranker config. (deprecated; hosted sunset 2026-09-04)
- [docs/ai-providers/llama-server-reranker.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/ai-providers/llama-server-reranker.md): Local reranker via llama.cpp --reranking: Qwen3-Reranker or self-hosted ZE weights, --alias setup, gbrain config keys, cold-start timeout, budget-cap interaction.
## Debugging
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "gbrain-context-engine",
"name": "gbrain",
"version": "0.46.2.0",
"version": "0.46.4.0",
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
"family": "bundle-plugin",
"configSchema": {
+4 -1
View File
@@ -51,6 +51,8 @@
"check:cli-exec": "bash scripts/check-cli-executable.sh",
"check:engine-dynamic-import": "bash scripts/check-engine-dynamic-import.sh",
"check:grok-pin": "bash scripts/check-grok-pin.sh",
"check:opencode-pin": "bash scripts/check-opencode-pin.sh",
"check:pin-doc-privacy": "bash scripts/check-pin-doc-privacy.sh",
"check:gateway-routed": "bash scripts/check-gateway-routed-no-direct-anthropic.sh",
"check:worker-pool-atomicity": "bash scripts/check-worker-pool-atomicity.sh",
"check:doc-history": "bash scripts/check-key-files-current-state.sh",
@@ -132,6 +134,7 @@
"gray-matter": "^4.0.3",
"heic-decode": "^2.1.0",
"js-yaml": "^3.15.1",
"jsonc-parser": "^3.3.1",
"marked": "^18.0.2",
"openai": "^4.0.0",
"pgvector": "^0.2.0",
@@ -157,7 +160,7 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.46.2.0",
"version": "0.46.4.0",
"overrides": {
"@hono/node-server": "^2.0.5",
"fast-uri": "^3.1.5",
+26 -15
View File
@@ -25,14 +25,16 @@
# (d) Phase-list check [D5]: every `Phase: <name>` in BOOTSTRAP_FOR_AGENTS.md
# must appear in src/core/bootstrap/status.ts (the TS phase list is the
# single source; the runbook defers to it). Skips while either is absent.
# (e) Harness-scoping counter-signal pins: the MCP-scope consent is Claude
# Code only (Codex has no scope flag — `codex mcp add` is user-global).
# Tripwires against accidental deletion of the load-bearing prose, not
# proofs of placement: the runbook must carry the Codex bullet's
# "Do NOT offer an MCP scope choice" and the phase-3 "Claude Code only"
# scoping; questions.json's MCP_SCOPE.question must START WITH
# "(Claude Code only". Intentional rewording updates these pins in the
# same commit. Skips while the runbook/bank are absent.
# (e) Harness-scoping counter-signal pins: the MCP-scope consent applies on
# Claude Code and opencode (Codex has no scope flag — `codex mcp add` is
# user-global; opencode DEFAULTS to user-global — no trust gate on
# project-config servers). Tripwires against accidental deletion of the
# load-bearing prose, not proofs of placement: the runbook must carry the
# Codex bullet's "Do NOT offer an MCP scope choice" and the phase-3
# "Claude Code and opencode" scoping; questions.json's MCP_SCOPE.question
# must START WITH "(Claude Code and opencode". Intentional rewording
# updates these pins in the same commit. Skips while the runbook/bank are
# absent.
#
# BSD/GNU grep portable (no \t escapes). Uses `bun` for JSON parsing — the
# check runs via `bun run verify`, so bun is always present.
@@ -195,7 +197,7 @@ else
echo "SKIP: phase-list check (runbook or src/core/bootstrap/status.ts absent)"
fi
# ── (e) harness-scoping counter-signal pins (MCP scope is Claude Code only) ─
# ── (e) harness-scoping counter-signal pins (scope = Claude Code + opencode) ─
if [ -f "$RUNBOOK" ]; then
if ! grep -qF 'Do NOT offer an MCP scope choice' "$RUNBOOK"; then
fail=1
@@ -204,11 +206,20 @@ if [ -f "$RUNBOOK" ]; then
echo " without this line, Codex-door agents re-ask a dead question." >&2
echo " Rewording intentionally? Update this pin in the same commit." >&2
fi
if ! grep -qF 'Claude Code only' "$RUNBOOK"; then
if ! grep -qF 'Claude Code and opencode' "$RUNBOOK"; then
fail=1
echo "FAIL: BOOTSTRAP_FOR_AGENTS.md lost the 'Claude Code only' scoping on the" >&2
echo " MCP-scope consent (phase 3). Without it the consent reads as" >&2
echo " harness-blind and Codex-door agents ask it." >&2
echo "FAIL: BOOTSTRAP_FOR_AGENTS.md lost the 'Claude Code and opencode' scoping" >&2
echo " on the MCP-scope consent (phase 3). Without it the consent reads as" >&2
echo " harness-blind: Codex-door agents ask a dead question and opencode" >&2
echo " agents miss the inverted (user-global) default." >&2
echo " Rewording intentionally? Update this pin in the same commit." >&2
fi
if ! grep -qF 'NO trust prompt' "$RUNBOOK"; then
fail=1
echo "FAIL: BOOTSTRAP_FOR_AGENTS.md lost the opencode spawn-gate rationale" >&2
echo " ('NO trust prompt'). Without it agents recommend the Claude-style" >&2
echo " project default on opencode — where a committed project entry" >&2
echo " auto-executes on every collaborator machine." >&2
echo " Rewording intentionally? Update this pin in the same commit." >&2
fi
else
@@ -216,9 +227,9 @@ else
fi
if [ -f "$QUESTIONS" ] && command -v bun >/dev/null 2>&1; then
if ! GBRAIN_QJSON="$QUESTIONS" bun -e \
'const fs=require("fs");let b;try{b=JSON.parse(fs.readFileSync(process.env.GBRAIN_QJSON,"utf8"));}catch(e){process.exit(1);}if(!b.questions){process.exit(1);}const e=b.questions.MCP_SCOPE;const q=(e&&e.question)||"";process.exit(q.startsWith("(Claude Code only")&&e.phase==="interview"?0:1);'; then
'const fs=require("fs");let b;try{b=JSON.parse(fs.readFileSync(process.env.GBRAIN_QJSON,"utf8"));}catch(e){process.exit(1);}if(!b.questions){process.exit(1);}const e=b.questions.MCP_SCOPE;const q=(e&&e.question)||"";process.exit(q.startsWith("(Claude Code and opencode")&&e.phase==="interview"?0:1);'; then
fail=1
echo "FAIL: questions.json MCP_SCOPE.question must start with '(Claude Code only'" >&2
echo "FAIL: questions.json MCP_SCOPE.question must start with '(Claude Code and opencode'" >&2
echo " AND MCP_SCOPE.phase must be 'interview' (the consent is recorded" >&2
echo " pre-confirm during the interview; a 'wire' phase re-creates the" >&2
echo " bank-vs-runbook contradiction). Also fails when the questions" >&2
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# scripts/check-opencode-pin.sh — opencode pin consistency guard.
#
# OPENCODE-CLI-PIN.md is the single observed-behavior source for the opencode
# integration; its pins fan out to the heavy-tests opencode-door job env, the
# OpencodeRunner argv, and the door e2e assertions. The prose rule is "update
# together" — this guard turns the workflow half of that rule into CI:
#
# 1. docs/mcp/OPENCODE-CLI-PIN.md carries a machine-stable stamp block
# (`<!-- opencode-pin: key=value -->`, one per line) including
# distribution_kind (npm | installer).
# 2. The opencode-door job env in .github/workflows/heavy-tests.yml must carry
# EXACTLY the pin set for the chosen distribution_kind:
# npm: OPENCODE_VERSION==opencode_version, OPENCODE_NPM_PACKAGE==npm_package,
# OPENCODE_NPM_INTEGRITY==npm_integrity; no OPENCODE_INSTALL_SHA256.
# installer: OPENCODE_VERSION==opencode_version,
# OPENCODE_INSTALL_SHA256==installer_sha256; no OPENCODE_NPM_INTEGRITY.
# (The pin DOC may document both — the fallback path stays written down;
# exclusivity is about which pins the WORKFLOW actually enforces.)
#
# Greps are anchored to the opencode-door job block so a future canary matrix leg
# (or a second door job) cannot satisfy the check by accident.
#
# SKIP-GRACEFUL: missing pin doc, missing workflow, or no opencode-door job yet →
# SKIP (exit 0), matching scripts/check-bootstrap-tag.sh. Test override:
# GBRAIN_OPENCODE_PIN_GUARD_ROOT points file resolution at a fixture tree.
# BSD/GNU portable (no \t escapes, no GNU-only flags).
set -euo pipefail
ROOT="${GBRAIN_OPENCODE_PIN_GUARD_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
PIN_FILE="$ROOT/docs/mcp/OPENCODE-CLI-PIN.md"
WORKFLOW="$ROOT/.github/workflows/heavy-tests.yml"
if [ ! -f "$WORKFLOW" ]; then
echo "check-opencode-pin: SKIP (no $WORKFLOW)"
exit 0
fi
if ! grep -q '^ opencode-door:' "$WORKFLOW"; then
echo "check-opencode-pin: SKIP (no opencode-door job in heavy-tests.yml yet)"
exit 0
fi
# Once the opencode-door job EXISTS, a missing pin doc is a FAILURE, not a skip —
# deleting/renaming the doc must not silently disable the supply-chain gate.
if [ ! -f "$PIN_FILE" ]; then
echo "check-opencode-pin: FAIL — opencode-door job exists but $PIN_FILE is missing (the pin doc is the gate's source of truth)" >&2
exit 1
fi
fail() {
echo "check-opencode-pin: FAIL — $1" >&2
exit 1
}
# --- 1. Parse the stamp block ------------------------------------------------
stamp() {
# First occurrence wins; a missing stamp yields the empty string (callers
# decide whether that is a failure) — the `|| true` keeps set -e/pipefail
# from treating grep's no-match exit as a script error.
{ grep -E "^<!-- opencode-pin: $1=" "$PIN_FILE" || true; } | head -1 \
| sed -e 's/^<!-- opencode-pin: [a-z0-9_]*=//' -e 's/ -->$//'
}
# Duplicate stamps are drift bait (two values, which one is real?).
dupes=$({ grep -E '^<!-- opencode-pin: ' "$PIN_FILE" || true; } | sed -e 's/^<!-- opencode-pin: //' -e 's/=.*$//' | sort | uniq -d)
[ -n "$dupes" ] && fail "duplicate opencode-pin stamp(s) in OPENCODE-CLI-PIN.md: $dupes"
DIST_KIND=$(stamp distribution_kind)
OPENCODE_VERSION_PIN=$(stamp opencode_version)
[ -n "$DIST_KIND" ] || fail "OPENCODE-CLI-PIN.md is missing the distribution_kind stamp"
[ -n "$OPENCODE_VERSION_PIN" ] || fail "OPENCODE-CLI-PIN.md is missing the opencode_version stamp"
case "$DIST_KIND" in
npm|installer) ;;
*) fail "distribution_kind stamp must be npm or installer; got '$DIST_KIND'" ;;
esac
# --- 2. Extract the opencode-door job block --------------------------------------
# Jobs sit at 2-space indent; the block ends at the next 2-space-indented key.
job_block=$(awk '
/^ opencode-door:/ { f = 1; print; next }
f && /^ [A-Za-z0-9_-]+:/ { exit }
f { print }
' "$WORKFLOW")
[ -n "$job_block" ] || fail "could not extract the opencode-door job block"
wf_env() {
# Strip either quote style: a YAML-formatter pass flipping double to single
# quotes must not read as pin drift.
{ printf '%s\n' "$job_block" | grep -E "^ $1:" || true; } | head -1 \
| sed -e "s/^ $1:[[:space:]]*//" -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'\$//"
}
WF_VERSION=$(wf_env OPENCODE_VERSION)
WF_NPM_PACKAGE=$(wf_env OPENCODE_NPM_PACKAGE)
WF_NPM_INTEGRITY=$(wf_env OPENCODE_NPM_INTEGRITY)
WF_INSTALL_SHA=$(wf_env OPENCODE_INSTALL_SHA256)
[ -n "$WF_VERSION" ] || fail "opencode-door job env is missing OPENCODE_VERSION"
[ "$WF_VERSION" = "$OPENCODE_VERSION_PIN" ] || fail "OPENCODE_VERSION drift — workflow '$WF_VERSION' vs pin-doc stamp '$OPENCODE_VERSION_PIN' (update together; see the pin doc's re-observation checklist)"
# EVERY OPENCODE_VERSION: env line in the WHOLE workflow (the real-agent-e2e
# door job carries a second copy) must equal the stamp — bumping the door job
# alone must never pass green. Env keys sit at line start after indentation,
# so comments mentioning the name never match.
all_wf_versions=$({ grep -E '^[[:space:]]*OPENCODE_VERSION:' "$WORKFLOW" || true; } \
| sed -e 's/^[[:space:]]*OPENCODE_VERSION:[[:space:]]*//' -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'\$//")
for v in $all_wf_versions; do
[ "$v" = "$OPENCODE_VERSION_PIN" ] || fail "an OPENCODE_VERSION occurrence elsewhere in heavy-tests.yml ('$v') disagrees with the pin-doc stamp '$OPENCODE_VERSION_PIN' — every copy in the workflow moves with the stamp"
done
if [ "$DIST_KIND" = "npm" ]; then
NPM_PACKAGE_PIN=$(stamp npm_package)
NPM_INTEGRITY_PIN=$(stamp npm_integrity)
[ -n "$NPM_PACKAGE_PIN" ] || fail "distribution_kind=npm but OPENCODE-CLI-PIN.md is missing the npm_package stamp"
[ -n "$NPM_INTEGRITY_PIN" ] || fail "distribution_kind=npm but OPENCODE-CLI-PIN.md is missing the npm_integrity stamp"
[ -n "$WF_NPM_PACKAGE" ] || fail "distribution_kind=npm but the opencode-door job env is missing OPENCODE_NPM_PACKAGE"
[ -n "$WF_NPM_INTEGRITY" ] || fail "distribution_kind=npm but the opencode-door job env is missing OPENCODE_NPM_INTEGRITY"
[ "$WF_NPM_PACKAGE" = "$NPM_PACKAGE_PIN" ] || fail "OPENCODE_NPM_PACKAGE drift — workflow '$WF_NPM_PACKAGE' vs stamp '$NPM_PACKAGE_PIN'"
[ "$WF_NPM_INTEGRITY" = "$NPM_INTEGRITY_PIN" ] || fail "OPENCODE_NPM_INTEGRITY drift — workflow vs stamp mismatch"
# npm_version is a documented near-duplicate of opencode_version — assert they
# agree so bumping one alone can never pass green.
NPM_VERSION_PIN=$(stamp npm_version)
if [ -n "$NPM_VERSION_PIN" ] && [ "$NPM_VERSION_PIN" != "$OPENCODE_VERSION_PIN" ]; then
fail "npm_version stamp ($NPM_VERSION_PIN) disagrees with opencode_version stamp ($OPENCODE_VERSION_PIN) — update together"
fi
# Platform-payload integrity stamps (the door job byte-pins the linux
# sub-packages too): when the pin doc carries them, the job env must match.
X64_PIN=$(stamp npm_linux_x64_integrity)
if [ -n "$X64_PIN" ]; then
WF_X64=$(wf_env OPENCODE_NPM_LINUX_X64_INTEGRITY)
[ -n "$WF_X64" ] || fail "pin doc stamps npm_linux_x64_integrity but the opencode-door job env is missing OPENCODE_NPM_LINUX_X64_INTEGRITY"
[ "$WF_X64" = "$X64_PIN" ] || fail "OPENCODE_NPM_LINUX_X64_INTEGRITY drift — workflow vs stamp mismatch"
fi
ARM64_PIN=$(stamp npm_linux_arm64_integrity)
if [ -n "$ARM64_PIN" ]; then
WF_ARM64=$(wf_env OPENCODE_NPM_LINUX_ARM64_INTEGRITY)
[ -n "$WF_ARM64" ] || fail "pin doc stamps npm_linux_arm64_integrity but the opencode-door job env is missing OPENCODE_NPM_LINUX_ARM64_INTEGRITY"
[ "$WF_ARM64" = "$ARM64_PIN" ] || fail "OPENCODE_NPM_LINUX_ARM64_INTEGRITY drift — workflow vs stamp mismatch"
fi
[ -z "$WF_INSTALL_SHA" ] || fail "distribution_kind=npm but the opencode-door job also pins OPENCODE_INSTALL_SHA256 — one provisioning mode only (mode exclusivity)"
else
INSTALL_SHA_PIN=$(stamp installer_sha256)
[ -n "$INSTALL_SHA_PIN" ] || fail "distribution_kind=installer but OPENCODE-CLI-PIN.md is missing the installer_sha256 stamp"
[ -n "$WF_INSTALL_SHA" ] || fail "distribution_kind=installer but the opencode-door job env is missing OPENCODE_INSTALL_SHA256"
[ "$WF_INSTALL_SHA" = "$INSTALL_SHA_PIN" ] || fail "OPENCODE_INSTALL_SHA256 drift — workflow vs stamp mismatch"
[ -z "$WF_NPM_INTEGRITY" ] || fail "distribution_kind=installer but the opencode-door job also pins OPENCODE_NPM_INTEGRITY — one provisioning mode only (mode exclusivity)"
fi
echo "check-opencode-pin: ok ($DIST_KIND mode, opencode $OPENCODE_VERSION_PIN)"
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# scripts/check-pin-doc-privacy.sh — PIN-doc privacy guard.
#
# The docs/mcp/*-CLI-PIN.md files carry VERBATIM observation transcripts from
# real installs (help output, saved configs, error copy). That verbatim
# discipline is the point — but it is also exactly how an operator path
# (/Users/<name>/…), a key fragment, or an account id ends up committed and
# shipped with every release. This guard asserts the placeholder discipline:
#
# 1. No operator home paths: /Users/<name>/ or /home/<name>/ must appear as
# placeholders (<tmp>, $HOME, ~/) — never as a real username path.
# Bare `~/.grok`-style spellings are fine (that IS the placeholder).
# 2. No key material: long high-entropy tokens with known prefixes
# (sk-…, xai-…, gbrain_<64+hex-ish>, ANTHROPIC/OPENAI/XAI key shapes).
# npm `sha512-…` integrity pins are EXPECTED content — excluded.
# 3. No obvious account ids: emails outside example.com/invalid domains.
#
# SKIP-GRACEFUL: no pin docs yet → SKIP (exit 0). Test override:
# GBRAIN_PIN_PRIVACY_GUARD_ROOT points file resolution at a fixture tree.
# BSD/GNU portable.
set -uo pipefail
ROOT="${GBRAIN_PIN_PRIVACY_GUARD_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
shopt -s nullglob
PIN_DOCS=("$ROOT"/docs/mcp/*-CLI-PIN.md)
shopt -u nullglob
if [ "${#PIN_DOCS[@]}" -eq 0 ]; then
echo "check-pin-doc-privacy: SKIP (no docs/mcp/*-CLI-PIN.md yet)"
exit 0
fi
fail=0
for doc in "${PIN_DOCS[@]}"; do
rel="${doc#"$ROOT"/}"
# 1. Operator home paths (a real username after /Users/ or /home/).
hits=$(grep -nE '(/Users|/home)/[A-Za-z][A-Za-z0-9._-]+/' "$doc" || true)
if [ -n "$hits" ]; then
fail=1
echo "FAIL: $rel carries operator home path(s) — replace with <tmp>/\$HOME/~ placeholders:" >&2
printf '%s\n' "$hits" | sed 's/^/ /' >&2
fi
# 2. Key material. sha512- npm integrity pins are expected; exclude lines
# carrying them before scanning for long secret-shaped runs.
hits=$(grep -v 'sha512-' "$doc" | grep -nE '(sk-[A-Za-z0-9_-]{20,}|xai-[A-Za-z0-9_-]{20,}|gbrain_[A-Za-z0-9]{32,}|AKIA[0-9A-Z]{16})' || true)
if [ -n "$hits" ]; then
fail=1
echo "FAIL: $rel carries key-shaped material — redact before committing:" >&2
printf '%s\n' "$hits" | sed 's/^/ /' >&2
fi
# 3. Emails outside the documentation-safe domains.
hits=$(grep -nE '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}' "$doc" \
| grep -vE '@(example\.(com|org|net)|[A-Za-z0-9.-]*invalid)' || true)
if [ -n "$hits" ]; then
fail=1
echo "FAIL: $rel carries a non-placeholder email address:" >&2
printf '%s\n' "$hits" | sed 's/^/ /' >&2
fi
done
if [ "$fail" -ne 0 ]; then
echo "check-pin-doc-privacy: FAIL (pin docs ship with every release — placeholder discipline is the privacy IRON RULE)" >&2
exit 1
fi
echo "check-pin-doc-privacy: ok (${#PIN_DOCS[@]} pin doc(s))"
+5 -1
View File
@@ -157,7 +157,11 @@ done
# Step 3: smoke-test run-e2e.sh argv + shard handling.
echo "[ci-local] Smoke: run-e2e.sh argv + shard..."
SMOKE_NO_ARGS=$(bash scripts/run-e2e.sh --dry-run-list | wc -l | tr -d ' ')
EXPECTED_ALL=$(ls test/e2e/*.test.ts | wc -l | tr -d ' ')
# run-e2e.sh's no-arg list is the test/e2e glob PLUS phantom-redirect-engine-
# parity (lives in test/; its Postgres arm is only reachable through this
# DATABASE_URL-bearing lane — see the comment in run-e2e.sh). Mirror that +1
# here or the smoke check fails on every tree where the counts drift.
EXPECTED_ALL=$(( $(ls test/e2e/*.test.ts | wc -l | tr -d ' ') + 1 ))
if [ "$SMOKE_NO_ARGS" != "$EXPECTED_ALL" ]; then
echo "[ci-local] ERROR: --dry-run-list (no args) printed $SMOKE_NO_ARGS, expected $EXPECTED_ALL" >&2
exit 1
+57
View File
@@ -28,6 +28,8 @@
* so the interview completes unattended. Pays real API cost;
* takes 10-25 min. Run in background and watch session/screen.txt.
* codex-install Same for REAL `codex` (interactive TUI).
* opencode-install Same for REAL `opencode` (bootstrap-supported; the keyless
* run rides the anonymous free tier and should COMPLETE).
* drive -- <cmd> Manual mode: spawn ANY command under the PTY and steer it
* across separate shell calls via a file control channel:
* watch: cat <dir>/session/screen.txt
@@ -45,6 +47,7 @@
* bun run scripts/dx-explore.ts init
* bun run scripts/dx-explore.ts claude-install
* bun run scripts/dx-explore.ts codex-install
* bun run scripts/dx-explore.ts opencode-install [--keyless]
* bun run scripts/dx-explore.ts drive [--no-hermetic-home] -- gbrain init
* Options: --dir <out> transcript dir (default .context/dx-runs/<scenario>-<ts>)
* --gbrain <bin> use an existing gbrain binary (default: compile+cache)
@@ -833,6 +836,59 @@ async function scenarioGrokInstall(ctx: ScenarioCtx, args: CliArgs): Promise<voi
});
}
// ── scenario: opencode-install ───────────────────────────────────────────────
async function scenarioOpencodeInstall(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
const home = tmp(ctx, 'gb-dx-home-');
const gbHome = tmp(ctx, 'gb-dx-gbhome-');
const ws = tmp(ctx, 'gb-dx-ws-');
const binDir = stageBinDir(ctx);
// Hermetic HOME + BOTH XDG dirs (config/auth/data all move — observed
// v1.18.18, OPENCODE-CLI-PIN.md §Path seams), seeded with the config half
// of the double autoupdate kill; the env half rides the session env below.
const xdgConfig = path.join(home, '.config');
const ocCfgDir = path.join(xdgConfig, 'opencode');
fs.mkdirSync(ocCfgDir, { recursive: true });
fs.writeFileSync(
path.join(ocCfgDir, 'opencode.json'),
JSON.stringify({ $schema: 'https://opencode.ai/config.json', autoupdate: false }, null, 2) + '\n',
);
// Auth travels env-only for the anthropic leg; a login flow would persist
// auth.json — pre-register the known candidate for the scrub (rm of a file
// that never appears is a no-op).
ctx.secretPaths.push(path.join(home, '.local', 'share', 'opencode', 'auth.json'));
spawnSync('git', ['init', '-q', ws]);
spawnSync('git', ['-C', ws, 'config', 'user.email', 'dx@example.com']);
spawnSync('git', ['-C', ws, 'config', 'user.name', 'DX Explore']);
log('REAL interactive opencode running the paste-in bootstrap (opencode is a bootstrap-supported harness)');
log('keyless runs ride the anonymous free tier (observed) — the flow should COMPLETE keyless; a sign-in wall here is itself a pin-refresh signal');
await runInstallSession(ctx, {
argv: ['opencode'],
cwd: ws,
env: {
HOME: home,
XDG_CONFIG_HOME: xdgConfig,
XDG_DATA_HOME: path.join(home, '.local', 'share'),
OPENCODE_DISABLE_AUTOUPDATE: '1',
GBRAIN_HOME: gbHome,
PATH: `${binDir}:${process.env.PATH ?? ''}`,
// Never let a first-run bounce the OPERATOR's browser for sign-in.
BROWSER: '/usr/bin/false',
},
extraAllow: ['ANTHROPIC_API_KEY'],
// --keyless drops provider keys AFTER extraAllow re-admission — on
// opencode that measures the FREE-TIER path, not a wall (observed).
dropEnv: args.keyless ? PROVIDER_KEY_NAMES : undefined,
prompt: installPrompt(),
meta: {
promptDeviation: 'unattended persona appendix + local runbook path + preinstalled binary',
runbook: 'BOOTSTRAP_FOR_AGENTS.md (local — opencode is bootstrap-supported)',
},
});
}
// ── scenario: drive (manual control channel) ─────────────────────────────────
async function scenarioDrive(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
@@ -919,6 +975,7 @@ const SCENARIOS: Record<string, { needsGbrain: boolean; run: (ctx: ScenarioCtx,
'claude-install': { needsGbrain: true, run: scenarioClaudeInstall },
'codex-install': { needsGbrain: true, run: scenarioCodexInstall },
'grok-install': { needsGbrain: true, run: scenarioGrokInstall },
'opencode-install': { needsGbrain: true, run: scenarioOpencodeInstall },
drive: { needsGbrain: true, run: scenarioDrive },
};
+2
View File
@@ -61,3 +61,5 @@ check-bootstrap-tag.sh repostate exempt VERSION stamp drift check
check-cli-executable.sh repostate exempt file-mode check
check-no-tracked-symlinks.sh repostate exempt git index state check
check-grok-pin.sh repostate exempt pin-stamp drift check (GROK-CLI-PIN.md stamps vs heavy-tests grok-door env); own bun guard tests in test/check-bootstrap-guards.test.ts
check-opencode-pin.sh repostate exempt pin-stamp drift check (OPENCODE-CLI-PIN.md stamps vs heavy-tests opencode-door env); own bun guard tests in test/check-bootstrap-guards.test.ts
check-pin-doc-privacy.sh repostate exempt PIN-doc placeholder discipline (no operator paths/key material/emails in docs/mcp/*-CLI-PIN.md); own bun guard tests in test/check-bootstrap-guards.test.ts
1 # CI guard registry (W0 fix-wave, Tier-1 #11 / D5.14).
61 check-cli-executable.sh
62 check-no-tracked-symlinks.sh
63 check-grok-pin.sh
64 check-opencode-pin.sh
65 check-pin-doc-privacy.sh
+1 -1
View File
@@ -176,7 +176,7 @@ export const SECTIONS: DocSection[] = [
{
title: "docs/ai-providers/zeroentropy.md",
description:
"ZeroEntropy zembed-1 embedding + zerank-2 reranker (hosted): API key, embedding switch, reranker config.",
"ZeroEntropy zembed-1 embedding + zerank-2 reranker (hosted): API key, embedding switch, reranker config. (deprecated; hosted sunset 2026-09-04)",
path: "docs/ai-providers/zeroentropy.md",
// Setup walkthrough — discoverable in the index, not inlined in the
// single-fetch bundle (keeps llms-full.txt under FULL_SIZE_BUDGET).
+5 -3
View File
@@ -87,11 +87,13 @@ mkdir -p "$E2E_TMP_HOME/.gbrain"
# internals survive untouched. We keep GBRAIN_HOME (just set above for HOME
# isolation); everything else GBRAIN_* is an operator override the suite must
# not inherit — which also scrubs GBRAIN_REAL_HERMES_E2E and
# GBRAIN_REAL_GROK_E2E, so the paid hermes/grok door suites structurally
# GBRAIN_REAL_GROK_E2E / GBRAIN_REAL_OPENCODE_E2E, so the real-agent door
# suites structurally
# cannot fire under this runner (their venue is heavy-tests.yml's direct bun
# test). GROK_ also drops an operator's GROK_BIN/GROK_HOME. Adapts GStack's
# test). GROK_ also drops an operator's GROK_BIN/GROK_HOME; OPENCODE_ drops
# OPENCODE_BIN and the OPENCODE_CONFIG* trio. Adapts GStack's
# buildHermeticEnv() allowlist to gbrain's shell E2E runner.
for _e2e_var in $(env | grep -oE '^(CONDUCTOR_|MCP_|OPENCLAW_|HERMES_|GROK_|GBRAIN_)[A-Za-z0-9_]*' | sort -u); do
for _e2e_var in $(env | grep -oE '^(CONDUCTOR_|MCP_|OPENCLAW_|HERMES_|GROK_|OPENCODE_|GBRAIN_)[A-Za-z0-9_]*' | sort -u); do
case "$_e2e_var" in
GBRAIN_HOME) ;; # required for HOME isolation (set above) — keep
GBRAIN_TEST_ALLOW_DATABASE_URL) ;; # #3485 preload opt-in (set above) — keep
+2
View File
@@ -69,6 +69,8 @@ CHECKS=(
"check:batch-audit-site"
"check:engine-dynamic-import"
"check:grok-pin"
"check:opencode-pin"
"check:pin-doc-privacy"
"check:worker-lock-renewal-shape"
"check:bootstrap-tag"
"check:bootstrap-templates"
+5
View File
@@ -26,7 +26,9 @@
"test/ai/rerank.test.ts": 16,
"test/ai/schema-templating.test.ts": 4,
"test/ai/silent-drop-regression.test.ts": 9,
"test/ai/sunset-warn.test.ts": 10,
"test/ai/voyage-code-3-recipe.test.ts": 3,
"test/ai/voyage-reranker-recipe.test.ts": 3,
"test/ai/zeroentropy-compat-fetch.test.ts": 16,
"test/ai/zeroentropy-recipe.test.ts": 1,
"test/anomalies.test.ts": 1,
@@ -189,6 +191,7 @@
"test/dream.test.ts": 26718,
"test/drift-watch.test.ts": 255,
"test/dry-fix.test.ts": 442,
"test/e2e/voyage-rerank-live.test.ts": 1,
"test/edge-extractor.test.ts": 252,
"test/effective-date.test.ts": 6,
"test/embed-backfill-submit.test.ts": 3099,
@@ -397,6 +400,7 @@
"test/migrate.test.ts": 102260,
"test/migration-orchestrator-v0_21_0.test.ts": 7,
"test/migration-orchestrator-v0_31_0.test.ts": 12960,
"test/migration-orchestrator-v0_46_3.serial.test.ts": 8000,
"test/migration-resume.test.ts": 9,
"test/migration-v0-29-1.serial.test.ts": 3273,
"test/migrations-cjk-wave.test.ts": 3280,
@@ -708,6 +712,7 @@
"test/worker-shutdown-disconnect.test.ts": 3260,
"test/writer.test.ts": 37365,
"test/yaml-lite.test.ts": 9,
"test/ze-exposure.test.ts": 9000,
"test/ze-switch-cli.test.ts": 4227,
"test/zombie-reap.test.ts": 3
}
+140
View File
@@ -0,0 +1,140 @@
---
version: 0.46.3
feature_pitch:
headline: "ZeroEntropy shuts down 2026-09-04 — switch embedding + reranker before then. New default: Voyage."
one_liner: "One command migrates the brain: gbrain migrate embeddings --to voyage:voyage-4 --dim 1024"
user_action_required: true
---
# v0.46.3 migration — ZeroEntropy shutdown (ACTION REQUIRED before 2026-09-04)
ZeroEntropy's hosted API — the former default for BOTH embedding (`zembed-1`)
and reranking (`zerank-2`) — stops working on **2026-09-04**. A brain still
resolving to `zeroentropyai:*` loses semantic retrieval ENTIRELY on that date
(existing vectors become unqueryable, not just new content).
**Nothing was changed automatically.** v0.46.3 only detects exposure and files
this action item. The brain keeps working until the date. The switch itself
costs money (a one-time re-embed) and needs a target API key, so it is the
user's/agent's explicit call.
## Agent action — when to run this migration
Run the steps below when the user says anything like:
- "ZeroEntropy" / "zembed" / "zerank" anything
- "migrate my embeddings" / "switch embedding provider"
- "search stopped working" / "embedding provider error" (after 2026-09-04)
- "doctor says provider_sunset" / "gbrain is warning about a provider"
- after any `gbrain upgrade` that printed the ACTION REQUIRED sunset block
Also proactively surface it when `~/.gbrain/migrations/pending-host-work.jsonl`
contains a `"migration": "0.46.3"` entry.
## Step 1 — confirm exposure
```bash
gbrain doctor --json | jq '.checks[] | select(.name=="provider_sunset")'
```
`warn`/`fail` mentioning zeroentropyai = exposed. `ok` = already migrated
(nothing to do; remove the pending-host-work entry).
## Step 2 — pick the target by which key exists
**Preferred — Voyage** (`VOYAGE_API_KEY` in env, or `voyage_api_key` in
`~/.gbrain/config.json`). One key covers embedding + reranking + the
multimodal model, and voyage-4 is the current hosted retrieval-quality
leader. To set the key: `export VOYAGE_API_KEY=...` or edit
`~/.gbrain/config.json` directly — do NOT use `gbrain config set
voyage_api_key` (that writes the DB plane, which the embedding pipeline never
reads).
```bash
gbrain migrate embeddings --to voyage:voyage-4 --dim 1024 --dry-run # cost preview — show the user
gbrain migrate embeddings --to voyage:voyage-4 --dim 1024 --yes
```
Note: **1280 is not a valid Voyage width** (valid: 256/512/1024/2048), so this
includes a one-time schema/HNSW index rebuild to 1024. The command handles it;
it is resumable if killed.
**Alternative — OpenAI** (`OPENAI_API_KEY`). No reranker coverage, but OpenAI
text-embedding-3 supports flexible widths, so a 1280d brain can keep its
column (no schema rebuild):
```bash
gbrain migrate embeddings --to openai:text-embedding-3-small --dim 1280 --dry-run
gbrain migrate embeddings --to openai:text-embedding-3-small --dim 1280 --yes
# (or --dim 1536 for the model's native width — costs a schema rebuild)
```
**Neither key** — get one of the two (Voyage: https://dash.voyageai.com/api-keys),
or self-host (below).
## Step 3 — reranker
If the doctor/notice flagged the reranker (balanced/tokenmax modes rerank with
ZE zerank-2 by default until the removal release):
```bash
gbrain config set search.reranker.model voyage:rerank-2.5 # needs VOYAGE_API_KEY
# or turn reranking off:
gbrain config set search.reranker.enabled false
```
Without either, reranking silently fails open (no rerank, autocut off) after
the shutdown date — search still works, ordering quality drops.
## Step 4 — custom embedding columns (rare)
If the notice listed ZE-backed `embedding_columns` entries: there is **no
automated off-ramp** for custom columns yet (`migrate embeddings` covers the
primary column only). Re-declare the column config on the new provider and
re-embed its content, or drop the column config. A write-side custom-column
migration is a filed follow-up (TODOS.md).
## Step 5 — verify
```bash
gbrain doctor --json | jq '.checks[] | select(.name=="provider_sunset") | .status' # → "ok"
gbrain search "anything you know is in the brain" # sanity check
```
Then remove/mark the `0.46.3` entry in
`~/.gbrain/migrations/pending-host-work.jsonl` as done.
## Self-hosting (zero re-embed, advanced)
zembed-1's weights are Apache-2.0. Keeping vectors WITHOUT re-embedding
requires keeping the `zeroentropyai:zembed-1` model id (the embedding
signature must not change) and pointing its base URL at your own endpoint:
```bash
gbrain config set provider_base_urls.zeroentropyai http://your-host:PORT/v1
```
Two honest caveats:
1. The endpoint must speak **ZeroEntropy's wire dialect** (`/models/embed`,
`{results: [...]}` responses) — a generic OpenAI-compatible llama-server or
Ollama endpoint will NOT work; you need a ZE-wire-compatible proxy in front.
2. This works only until the September removal release deletes the
`zeroentropyai` recipe. A self-host continuity decision (possibly a compat
mode) is tracked in TODOS.md — if you rely on this path, follow that item.
Switching the provider id instead (e.g. `llama-server:zembed-1`) changes
`pages.embedding_signature`, and the next stale-embed pass re-embeds
everything — that path is a full re-embed, not a zero-cost move.
Note: `gbrain migrate embeddings` refuses `--to zeroentropyai:*` by default
(it protects everyone else from re-embedding onto the dying hosted API). With
your base-URL override in place, pass `--force-sunset-target` to proceed.
## When NOT to run this migration
- The brain is keyless (`embedding_disabled: true`) with no ZE reranker or
custom columns — nothing to migrate.
- `provider_sunset` already reports `ok` — done; just clear the pending entry.
- You only mounted someone else's brain: the migration is host-scoped; the
brain's owner migrates it (their upgrade banner + doctor nag them).
+1
View File
@@ -127,6 +127,7 @@
"migrations/v0.40.3.0.md": "5f500f8c543c2b6f41778b0bd3beedada68f7284f7933ad8b769322b433a8fe9",
"migrations/v0.40.5.md": "b9837d52a030517698dfb31c439f562cde60a1015ae488dab09be2c16ff182e5",
"migrations/v0.41.11.0.md": "5c6873ab969d14def4a450d792f070f1259d08b3aca43bc7825d0a9114b2b36b",
"migrations/v0.46.3.0.md": "7762212509ea3f954b31ae4ebb9ee8fc1e497ac021c633fa95631f03a2eaaecc",
"migrations/v0.5.0.md": "5e0dabc451595295c4d971e19bcb33c258a127223d25859d8321cb7e1ce60711",
"migrations/v0.7.0.md": "97c2740445a10b1c5c7123c17dbd625fa27a94095b85d27c2b278da756c4c59a",
"migrations/v0.8.0.md": "1919ff8b8f3680612ff888e7cfcc0d86ece5d5304ae19af4497bdf40b050561a",
+467 -40
View File
@@ -27,8 +27,9 @@
* B5 relay instruction), never a stack trace.
*/
import { mkdirSync, readdirSync } from 'node:fs';
import { basename, isAbsolute, join, resolve } from 'node:path';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
import { VERSION } from '../version.ts';
import { loadConfig, loadConfigFileOnly, toEngineConfig } from '../core/config.ts';
@@ -77,7 +78,16 @@ import {
statusHarness,
type HarnessDeps,
} from '../core/bootstrap/harness.ts';
import { codexConfigPath } from '../core/bootstrap/host-specs.ts';
import { codexConfigPath, opencodeConfigDir, opencodeGlobalConfigPath, opencodeProjectConfigPath } from '../core/bootstrap/host-specs.ts';
import {
opencodeEntryKind,
opencodeEntrySnippet,
opencodeRemoteEntryExists,
parseOpencodeConfig,
reconcileOpencodeSiblingGlobal,
removeOpencodeMcpEntry,
writeOpencodeMcpEntry,
} from '../core/bootstrap/opencode-json.ts';
import { promptLine } from '../core/cli-util.ts';
import {
appendInstallLog,
@@ -88,7 +98,7 @@ import {
} from '../core/bootstrap/status.ts';
import { verifyWorkspace, deriveWorkspaceSourceId } from '../core/bootstrap/verify.ts';
export const BOOTSTRAP_HELP = `gbrain bootstrap — paste-in agent install (Claude Code / Codex)
export const BOOTSTRAP_HELP = `gbrain bootstrap — paste-in agent install (Claude Code / Codex / opencode)
Usage: gbrain bootstrap <subcommand> [flags]
@@ -104,25 +114,29 @@ Subcommands (run \`gbrain bootstrap status\` first — it is the resume entrypoi
render [--force] [--only F] [--minimal]
Render identity files from the confirmed answers.
Never clobbers; --force backs up first.
hooks [--harness claude-code|codex] [--repair] [--no-hooks] [--gbrain-bin <path>]
hooks [--harness claude-code|codex|opencode] [--repair] [--no-hooks] [--gbrain-bin <path>]
Register MCP (+ per-turn hooks on Claude Code,
ON by default; --no-hooks opts out, GBRAIN_HOOKS=0
disables at runtime).
disables at runtime). opencode registrations are
written directly into its JSONC config (user-global
by default; MCP_SCOPE=project is an explicit opt-in
with a sharing warning).
repo Create the dedicated PRIVATE GitHub repo (or adopt
an EMPTY private repo you created under your own
account), verify the privacy bit via the API, push.
verify [--json] The whole install contract (round-trip, graph floor,
magic moment, scans, hooks smoke). Exit 0 or not done.
attach [--harness H] Machine two: adopt a cloned agent workspace.
harness [--harness claude-code|codex|all] [--url U | --port N] [--source ID]
harness [--harness claude-code|codex|opencode|all] [--url U | --port N] [--source ID]
[--token-name NAME | --token TOK] [--name MCPNAME] [--project DIR]...
[--no-hooks] [--no-capture] [--force] [--status] [--remove] [--yes] [--json]
Wire framework-spawned Claude Code / Codex sessions to a
RUNNING \`gbrain serve --http\` on this box (#4043): scoped
bearer token, user-scope MCP + headless pre-approval,
lifecycle hooks (user scope, or per --project dir), codex
config block. No agent.json needed. Idempotent; --remove
tears it down. (--local is an accepted no-op alias.)
Wire framework-spawned Claude Code / Codex / opencode
sessions to a RUNNING \`gbrain serve --http\` on this box
(#4043): scoped bearer token, user-scope MCP + headless
pre-approval, lifecycle hooks (user scope, or per --project
dir), codex config block, opencode config entry. No
agent.json needed. Idempotent; --remove tears it down.
(--local is an accepted no-op alias.)
cloud-setup-script Print the paste-ready cloud environment setup
script (installs the gbrain binary into the
environment snapshot; npm-based bun fetching
@@ -158,7 +172,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
' Create the dedicated PRIVATE GitHub repo (or adopt an EMPTY private repo you created\n' +
' under your own account), verify the privacy bit via the API, push.',
hooks:
'gbrain bootstrap hooks [--harness claude-code|codex] [--repair] [--no-hooks] [--gbrain-bin <path>]\n' +
'gbrain bootstrap hooks [--harness claude-code|codex|opencode] [--repair] [--no-hooks] [--gbrain-bin <path>]\n' +
' Register MCP (+ per-turn hooks on Claude Code, ON by default; --no-hooks opts out).',
verify:
'gbrain bootstrap verify [--json]\n' +
@@ -244,12 +258,26 @@ function shellQuoteForDisplay(arg: string): string {
// ── Shared plumbing ─────────────────────────────────────────────────────────
type Harness = 'claude-code' | 'codex';
type Harness = 'claude-code' | 'codex' | 'opencode';
/** Best-effort harness auto-detect; the --harness flag always wins. */
/** Every workspace-lane harness exhaustive-switch anchors key off this so
* a future member is a COMPILE error at each dispatch site, not a silent
* fall-through into another harness's branch (the union-widening trap: a
* `harness === 'claude-code' ? A : B` ternary routes every new member down
* B). */
const HARNESSES = ['claude-code', 'codex', 'opencode'] as const satisfies readonly Harness[];
function isHarness(v: string | undefined): v is Harness {
return (HARNESSES as readonly string[]).includes(v ?? '');
}
/** Best-effort harness auto-detect; the --harness flag always wins.
* opencode sets OPENCODE=1 (+OPENCODE_PID) in its bash-tool children
* verified against opencode 1.18.18 (OPENCODE-CLI-PIN.md §Environment). */
export function detectHarness(env: Record<string, string | undefined> = process.env): Harness | null {
if (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT) return 'claude-code';
if (env.CODEX_HOME || env.CODEX_SANDBOX || env.CODEX_CI) return 'codex';
if (env.OPENCODE || env.OPENCODE_PID) return 'opencode';
return null;
}
@@ -285,7 +313,16 @@ async function verifyMcpTargetsWorkspace(
gbrainBin: string,
sourceId: string,
): Promise<'match' | 'mismatch' | 'unknown'> {
const bin = harness === 'claude-code' ? 'claude' : 'codex';
// Exec-lane harnesses only. opencode registrations go through the direct
// JSONC writer whose 4-state fingerprint IS the [FIX7] check (structural,
// no exec) — it never routes here; 'unknown' keeps a stray call honest.
const EXEC_HARNESS_BIN = {
'claude-code': 'claude',
codex: 'codex',
opencode: null,
} as const satisfies Record<Harness, string | null>;
const bin = EXEC_HARNESS_BIN[harness];
if (bin === null) return 'unknown';
let res;
try {
res = await runner([bin, 'mcp', 'get', name]);
@@ -300,6 +337,121 @@ async function verifyMcpTargetsWorkspace(
return hasBin && hasSource ? 'match' : 'mismatch';
}
/** Wall-clock cap on the best-effort `opencode mcp list` probe: `mcp list`
* SPAWNS every configured server, and a hung spawn must not hang the install
* on timeout the probe child is actually TERMINATED (SIGTERM, then SIGKILL
* ~2s later) and the result degrades to the could-not-confirm branch (code
* 124, repo-visibility's raced-runner convention). */
const OPENCODE_PROBE_TIMEOUT_MS = 20_000;
/** Injectable probe-spawn seam (the door serial tests capture argv + cwd +
* env and fake the child). The default holds the REAL process handle via
* Bun.spawn a Promise.race that merely abandons a hung `opencode mcp list`
* leaves its spawned MCP servers running (including the just-registered
* `gbrain serve`, which then squats the PGLite single-writer lock) and keeps
* the CLI's event loop alive past flushThenExit. */
export interface OpencodeProbeHandle {
exited: Promise<number>;
kill(force?: boolean): void;
stdout: Promise<string>;
stderr: Promise<string>;
/** Detach the child + its pipes from the event loop (called when the probe
* gives up on a hung child/grandchild so the CLI can still exit). */
unref?: () => void;
}
export type OpencodeProbeSpawn = (
argv: string[],
opts: { cwd: string; env: Record<string, string | undefined> },
) => OpencodeProbeHandle;
function defaultOpencodeProbeSpawn(
argv: string[],
opts: { cwd: string; env: Record<string, string | undefined> },
): OpencodeProbeHandle {
const proc = Bun.spawn(argv, {
cwd: opts.cwd,
env: opts.env as Record<string, string>,
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
});
return {
exited: proc.exited,
kill: (force?: boolean) => {
try {
proc.kill(force ? 9 : undefined);
} catch {
/* already dead */
}
},
stdout: new Response(proc.stdout).text().catch(() => ''),
stderr: new Response(proc.stderr).text().catch(() => ''),
unref: () => {
try {
proc.unref();
} catch {
/* best-effort */
}
},
};
}
/** Run the opencode registration probe with OPENCODE_DISABLE_AUTOUPDATE=1 on
* the spawn env (OPENCODE-CLI-PIN.md §Probes: the auto-updater must never
* fire mid-probe) from an explicit `cwd` callers pass a fresh EMPTY temp
* dir, never the invoking cwd, because opencode merges a project
* opencode.json from cwd and spawns its local servers with NO trust prompt
* (a cloned malicious repo must not get code execution out of an install
* probe). On timeout the child is killed (SIGTERM SIGKILL) and the pipes
* are drained BOUNDED (a spawned MCP-server grandchild can inherit the pipe
* fds and hold them open past the direct child's death). Exported for the
* timeout-kill unit test. */
export async function runOpencodeProbe(
argv: string[],
opts: { cwd: string; spawn?: OpencodeProbeSpawn; timeoutMs?: number },
): Promise<{ code: number; stdout: string; stderr: string }> {
const spawnFn = opts.spawn ?? defaultOpencodeProbeSpawn;
const timeoutMs = opts.timeoutMs ?? OPENCODE_PROBE_TIMEOUT_MS;
const env: Record<string, string | undefined> = { ...process.env, OPENCODE_DISABLE_AUTOUPDATE: '1' };
let handle: OpencodeProbeHandle;
try {
handle = spawnFn(argv, { cwd: opts.cwd, env });
} catch (e) {
// Bun.spawn throws synchronously when the binary is absent — map to the
// shell's 127 convention so the caller's not-on-PATH branch fires.
return { code: 127, stdout: '', stderr: e instanceof Error ? e.message : String(e) };
}
// Bounded race helper that never leaves a live timer holding the loop.
const raceMs = async <T>(p: Promise<T>, ms: number, fallback: T): Promise<T> => {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([p, new Promise<T>((res) => { timer = setTimeout(() => res(fallback), ms); })]);
} finally {
clearTimeout(timer);
}
};
let code = await raceMs<number | null>(handle.exited, timeoutMs, null);
const timedOut = code === null;
if (code === null) {
handle.kill(); // graceful first — opencode tears its servers down on TERM
code = await raceMs<number | null>(handle.exited, 2_000, null);
if (code === null) {
handle.kill(true); // SIGKILL is not refusable; the wait below is paranoia-bounded
code = await raceMs<number | null>(handle.exited, 2_000, null);
}
}
const drainCap = timedOut ? 2_000 : 5_000;
const [stdout, stderr] = await Promise.all([
raceMs(handle.stdout, drainCap, ''),
raceMs(handle.stderr, drainCap, ''),
]);
if (timedOut || code === null) {
handle.unref?.(); // a grandchild may still hold the pipes — never hold the CLI's exit
return { code: 124, stdout, stderr: stderr || `timeout after ${timeoutMs}ms` };
}
return { code, stdout, stderr };
}
async function withLock<T>(ws: string, fn: () => Promise<T>): Promise<T> {
const handle = await acquireBootstrapLock(ws);
try {
@@ -768,11 +920,21 @@ async function runRepo(ws: string, rest: string[], home: string, runner: ExecRun
});
}
async function runHooks(ws: string, rest: string[], home: string, runner: ExecRunner): Promise<number> {
const harnessFlag = flagValue(rest, '--harness') as Harness | undefined;
const harness = harnessFlag ?? detectHarness();
if (!harness || (harness !== 'claude-code' && harness !== 'codex')) {
console.error('cannot auto-detect the harness — pass --harness claude-code or --harness codex');
async function runHooks(
ws: string,
rest: string[],
home: string,
runner: ExecRunner,
probeSpawn?: OpencodeProbeSpawn,
): Promise<number> {
const harnessFlag = flagValue(rest, '--harness');
const harness = isHarness(harnessFlag) ? harnessFlag : harnessFlag ? null : detectHarness();
if (!harness) {
console.error(
harnessFlag
? `unknown --harness '${harnessFlag}' — pass --harness claude-code, codex, or opencode`
: 'cannot auto-detect the harness — pass --harness claude-code, codex, or opencode',
);
return 2;
}
// --repair is an idempotent-run alias: the same registration/write path as a
@@ -804,18 +966,44 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
return 2;
}
const mcpScope = ((consentAnswer(ws, 'MCP_SCOPE') ?? 'project').toLowerCase() === 'user' ? 'user' : 'project') as 'project' | 'user';
// Raw (unbanked) MCP_SCOPE answer — several harness branches need to know
// whether a human EXPLICITLY chose a scope vs the bank default filling in.
// typeof guard: readInterviewState validates `answers` is an object but not
// per-answer shapes — a hand-edited value of 3 must not throw.
const rawScopeAnswer = (() => {
const read = readInterviewState(ws);
const raw = read.ok ? read.state.answers['MCP_SCOPE'] : undefined;
// .trim(): a hand-edited or sloppily-recorded ' project' must not
// silently resolve to the user-global default (scope answers are
// security-relevant on opencode).
return raw?.skipped !== true && typeof raw?.value === 'string' ? raw.value.trim().toLowerCase() : undefined;
})();
// Scope resolution is per-harness (exhaustive switch — see HARNESSES):
// - claude-code: consent answer, bank default 'project' (the privacy-safe
// default: any other repo you open cannot read the brain).
// - codex: no scope flag exists; the value is ignored (note below).
// - opencode: default 'user' — OPPOSITE of claude-code, because opencode
// spawns project-config-defined servers with NO trust gate (verified,
// OPENCODE-CLI-PIN.md §Probes): a committed project entry would auto-spawn
// on every collaborator's machine. 'project' only via an EXPLICIT answer
// (the sharing warning prints at write time).
const mcpScope = ((): 'project' | 'user' => {
switch (harness) {
case 'claude-code':
return (consentAnswer(ws, 'MCP_SCOPE') ?? 'project').toLowerCase() === 'user' ? 'user' : 'project';
case 'codex':
return 'project'; // ignored — codex registrations are user-global (no scope flag)
case 'opencode':
return rawScopeAnswer === 'project' ? 'project' : 'user';
}
})();
// A persisted 'project' answer is meaningless on Codex (`codex mcp add` has no
// scope flag) — reachable via attach from a Claude Code machine or a pre-fix
// install. Fires on each hooks/repair run while the stale answer persists.
// Raw read, NOT consentAnswer: the bank default is 'project', so the resolved
// value would fire this note on every Codex install where no one was asked.
if (harness === 'codex') {
const read = readInterviewState(ws);
const raw = read.ok ? read.state.answers['MCP_SCOPE'] : undefined;
// typeof guard: readInterviewState validates `answers` is an object but not
// per-answer shapes — a hand-edited value of 3 must not throw.
if (raw?.skipped !== true && typeof raw?.value === 'string' && raw.value.toLowerCase() === 'project') {
if (rawScopeAnswer === 'project') {
console.error(
"note: the recorded MCP_SCOPE answer 'project' has no effect on Codex — " +
'`codex mcp add` has no scope flag; the registration is user-global (any repo ' +
@@ -845,6 +1033,27 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
);
return 0;
}
// Same ownership rule, opencode spelling: a REMOTE-type mcp.gbrain in the
// user-global config is either the harness lane's (inline bearer) or
// foreign — the stdio lane must not fight it in either case. BOTH global
// filenames are checked: opencode merges opencode.json AND opencode.jsonc
// when both exist, so a remote entry in EITHER file owns the name even
// when the path resolver would pick the other for writing.
if (
harness === 'opencode' &&
mcpScope === 'user' &&
[join(opencodeConfigDir(), 'opencode.jsonc'), join(opencodeConfigDir(), 'opencode.json')].some((p) =>
opencodeRemoteEntryExists(p, 'gbrain'),
)
) {
console.log(
"the 'gbrain' opencode MCP entry in the user-global config is a remote server (managed by " +
'`gbrain bootstrap harness`, or foreign) — skipping the stdio registration. Run ' +
'`gbrain bootstrap harness --remove` first (or remove the entry) if you want this ' +
'workspace-lane stdio registration instead.',
);
return 0;
}
return withLock(ws, async () => {
// 0. source_id visibility seam: `hooks` is the last ENGINE-FREE phase
@@ -889,6 +1098,157 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
// binary. The old early-return silently dropped hooks while the copy said
// only "MCP registration skipped".
let mcpSkipped = false;
if (harness === 'opencode') {
// Direct-writer lane (no exec): registrations land via the JSONC
// writer whose 4-state fingerprint is the [FIX7] check. Scope resolves
// to a FILE here — user → global config (absolute binary path),
// project → committed-candidate opencode.json (PATH-resolved command;
// no absolute machine paths in a file that travels, and no fail-open
// analog exists — the sharing warning below is the mitigation).
const configPath = mcpScope === 'project' ? opencodeProjectConfigPath(ws) : opencodeGlobalConfigPath();
const command =
mcpScope === 'project'
? ['gbrain', 'serve', '--surface', 'full']
: [gbrainBin, 'serve', '--surface', 'full'];
const entry = {
kind: 'local' as const,
name: 'gbrain',
command,
environment: { GBRAIN_SOURCE: sourceId, ...(gbrainHome ? { GBRAIN_HOME: gbrainHome } : {}) },
};
try {
// [X11] config-dir lock parity with the harness lane: the user-global
// config is shared across workspaces AND homes, so gbrain writers
// serialize on ITS directory. The project-scope file lives in the
// workspace root, which withLock(ws) already holds — the lock is
// non-reentrant, so the same-dir case skips the nested acquire.
const ocCfgDir = dirname(configPath);
let ocLock: { release(): void } | null = null;
if (resolve(ocCfgDir) !== resolve(ws)) {
mkdirSync(ocCfgDir, { recursive: true }); // the lock needs the dir; the writer mkdirs later anyway
ocLock = await acquireBootstrapLock(ocCfgDir);
}
let w: ReturnType<typeof writeOpencodeMcpEntry>;
try {
// [FIX7] parity: an existing entry pointing at a DIFFERENT workspace
// is warned about and replaced (same behavior as the exec lanes'
// mismatch path); a FOREIGN entry refuses inside the writer. The
// pre-check parse carries the same paste-by-hand snippet the writer
// uses so a corrupt config never strands the user.
const existingText = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
const existingKind = opencodeEntryKind(
parseOpencodeConfig(existingText, configPath, opencodeEntrySnippet(entry)),
'gbrain',
{ sourceId },
);
if (existingKind === 'ours-other-source') {
console.error(`existing 'gbrain' opencode entry targets a DIFFERENT workspace — replacing it.`);
}
// Two-filename merge blind spot: opencode merges BOTH user-global
// filenames, so a same-name gbrain entry in the SIBLING file would
// survive this write as a shadow registration. Reconcile it under
// the same config-dir lock (ours → removed with a note; foreign →
// refuse loudly naming both files). User scope only — the project
// file has no observed sibling semantics.
if (mcpScope === 'user') {
const sib = reconcileOpencodeSiblingGlobal(configPath, 'gbrain', { sourceId });
for (const note of sib.notes) console.error(note);
}
w = writeOpencodeMcpEntry(configPath, entry, {
expect: { sourceId },
allowReplaceOtherSource: true,
});
} finally {
ocLock?.release();
}
console.log(
`MCP registered with opencode (scope: ${mcpScope === 'project' ? 'project (explicit opt-in)' : 'user-global'}) — ` +
`wrote ${w.configPath}${w.replacedPrior ? ' (replaced prior gbrain entry)' : ''}; ` +
'restart opencode (config is read at session start).',
);
for (const note of w.notes) console.error(note);
if (mcpScope === 'project') {
console.error(
'SHARING WARNING: opencode spawns project-config-defined MCP servers with NO trust prompt — ' +
'if this opencode.json is committed, every collaborator machine will spawn gbrain (teammates ' +
'without gbrain see a failing spawn each session; teammates WITH gbrain attach THEIR host ' +
'brain to this repo). The command is PATH-resolved ("gbrain" — requires gbrain on PATH); ' +
'the teammate opt-out is `"enabled": false` on the entry. The user-global default avoids all of this.' +
(gbrainHome
? ` Also: the entry embeds this machine's GBRAIN_HOME path (${gbrainHome}) — it won't be portable to other machines.`
: ''),
);
} else if (rawScopeAnswer === undefined) {
console.log(
"scope defaulted to user-global — opencode spawns project-defined servers with no trust gate, " +
'so the committed-file scope is explicit-opt-in only (record MCP_SCOPE=project to choose it).',
);
}
} catch (e) {
console.error((e as Error).message);
return 1;
}
// Registration smoke: the writer's post-render validation already
// proved the config parses and carries exactly our entry (that is the
// authoritative check). Best-effort live probe when the binary is on
// PATH: `opencode mcp list` SPAWNS servers (the honest discriminator)
// — run it with --pure (no external plugin autoload; `mcp list` is a
// code-execution surface otherwise) and skip it entirely when a
// plugin-bearing config is present (OPENCODE-CLI-PIN.md §Probes).
try {
const parsedCfg = parseOpencodeConfig(
existsSync(configPath) ? readFileSync(configPath, 'utf8') : '',
configPath,
);
if (mcpScope === 'project') {
// SECURITY: opencode merges the project opencode.json from the
// probe's cwd and spawns its local servers with NO trust prompt —
// running `mcp list` inside this workspace would execute whatever
// the (possibly just-cloned) repo's config names. Parse-back stays
// the authoritative check; the human runs the live probe.
console.log(
'live `opencode mcp list` probe skipped for project scope — config parse-back is authoritative; ' +
'run `opencode mcp list` yourself in this workspace to confirm.',
);
} else if (parsedCfg.plugin !== undefined) {
console.log('live `opencode mcp list` probe skipped (plugin-bearing config) — config parse-back is the verification.');
} else {
// SECURITY: the probe spawns from a fresh EMPTY temp dir, never the
// invoking cwd — no project opencode.json can load there (the same
// no-trust-prompt spawn surface as the project-scope skip above).
const probeCwd = mkdtempSync(join(tmpdir(), 'gbrain-opencode-probe-'));
let probe: { code: number; stdout: string; stderr: string };
try {
probe = await runOpencodeProbe(['opencode', 'mcp', 'list', '--pure'], {
cwd: probeCwd,
...(probeSpawn ? { spawn: probeSpawn } : {}),
});
} finally {
rmSync(probeCwd, { recursive: true, force: true });
}
// `mcp list` colorizes when a TTY-ish env leaks through — strip ANSI
// escapes before matching, and anchor the name on whitespace/EOL so
// a `gbrain-remote` entry can never satisfy a bare \bgbrain\b (\b
// matches before the hyphen).
const plain = probe.stdout.replace(/\u001b\[[0-9;]*m/g, '');
if (probe.code === 127) {
console.log('`opencode` is not on PATH — registration written; the config activates when opencode next starts here.');
} else if (probe.code === 0 && /✓\s+gbrain(\s|$)/.test(plain)) {
console.log('`opencode mcp list` handshake: ✓ gbrain connected.');
} else if (probe.code === 0 && /✗\s+gbrain(\s|$)/.test(plain)) {
console.error(
'WARNING: `opencode mcp list` reports ✗ gbrain failed — the spawn did not handshake ' +
'(is the gbrain binary path valid on this machine?). The exit code of `mcp list` is 0 even ' +
'on failure; this warning is from parsing its output.',
);
} else {
console.log('MCP registration written; could not confirm via `opencode mcp list` (best-effort probe).');
}
}
} catch {
/* smoke is best-effort */
}
} else {
const argvs =
harness === 'claude-code'
? registerClaudeMcp({ gbrainBin, scope: mcpScope, sourceId, ...(gbrainHome ? { gbrainHome } : {}) })
@@ -997,6 +1357,7 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
} catch {
/* smoke is best-effort */
}
} // end exec-lane registration (claude-code / codex)
// 3. Hooks (Claude Code only, consent-gated).
let hooksWritten = false;
@@ -1044,16 +1405,31 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
: 'hooks declined (HOOKS_CONSENT set to no) — the AGENTS.md pull protocol covers per-turn context instead; re-enable with `gbrain bootstrap hooks --harness claude-code`.',
);
}
} else {
} else if (harness === 'codex') {
console.log('gbrain does not wire Codex hooks yet — per-turn context is the AGENTS.md pull protocol (stated plainly; the codex hook lane is a filed follow-up).');
} else {
console.log(
'gbrain does not wire opencode\'s plugin/event system yet — per-turn context is the AGENTS.md ' +
'pull protocol, which opencode loads natively (the opencode plugin lane is a filed follow-up).',
);
}
// 4. Receipt registration record [CX2-12]. Detail records what actually
// landed; nothing landed at all (127 + no hooks) → no receipt entry.
if (!mcpSkipped || hooksWritten) {
const receiptScope = ((): string => {
switch (harness) {
case 'claude-code':
return mcpScope;
case 'codex':
return 'user'; // codex registrations are always user-global
case 'opencode':
return mcpScope; // user default; project only via explicit opt-in
}
})();
appendReceiptRegistration(home, ws, {
host: harness,
scope: harness === 'claude-code' ? mcpScope : 'user',
scope: receiptScope,
detail: hooksWritten ? (mcpSkipped ? 'hooks' : 'mcp+hooks') : 'mcp',
});
}
@@ -1252,15 +1628,63 @@ async function runUninstall(ws: string, rest: string[], home: string, runner: Ex
// Execute the structured host-registration removals the module returned.
for (const reg of result.registration_removals) {
if (reg.host === 'claude-code') {
const r = removeClaudeHooks(ws);
if (r.removed > 0) console.log(`removed ${r.removed} gbrain hook entr${r.removed === 1 ? 'y' : 'ies'} from ${r.settingsPath}`);
for (const note of r.notes) console.error(note);
const rm = await runner(['claude', 'mcp', 'remove', 'gbrain']);
if (rm.code !== 0) console.error('note: `claude mcp remove gbrain` did not succeed — remove it by hand if it lingers.');
} else {
const rm = await runner(['codex', 'mcp', 'remove', 'gbrain']);
if (rm.code !== 0) console.error('note: `codex mcp remove gbrain` did not succeed — remove it by hand if it lingers.');
switch (reg.host) {
case 'claude-code': {
const r = removeClaudeHooks(ws);
if (r.removed > 0) console.log(`removed ${r.removed} gbrain hook entr${r.removed === 1 ? 'y' : 'ies'} from ${r.settingsPath}`);
for (const note of r.notes) console.error(note);
const rm = await runner(['claude', 'mcp', 'remove', 'gbrain']);
if (rm.code !== 0) console.error('note: `claude mcp remove gbrain` did not succeed — remove it by hand if it lingers.');
break;
}
case 'codex': {
const rm = await runner(['codex', 'mcp', 'remove', 'gbrain']);
if (rm.code !== 0) console.error('note: `codex mcp remove gbrain` did not succeed — remove it by hand if it lingers.');
break;
}
case 'opencode': {
// Direct-writer removal (fingerprint-keyed; foreign entries refuse
// inside the module). Every candidate file best-effort — the
// receipt's scope names where the registration landed, but a stale
// entry in another file costs nothing to sweep. BOTH global
// filenames are swept: opencode merges opencode.json AND
// opencode.jsonc when both exist, so sweeping only the resolver's
// pick would strand a gbrain entry in the other file. The removal
// is expectation-keyed on THIS workspace's source id — a gbrain
// entry from a DIFFERENT workspace is skipped with a note, never
// silently deleted (it is not this uninstall's to remove).
const sweep = (p: string): void => {
try {
const r = removeOpencodeMcpEntry(p, 'gbrain', { sourceId: durabilitySourceId }, { skipOtherSource: true });
if (r.removed) console.log(`removed the gbrain opencode MCP entry from ${p}`);
for (const note of r.notes) console.error(note);
} catch (e) {
console.error(`note: could not remove the gbrain opencode entry from ${p}: ${(e as Error).message}`);
}
};
// Global files run under the config-dir bootstrap lock (the writer
// contract; harness.ts [X11] parity). Only when the dir exists — no
// dir means no config, and uninstall must not create one just to
// lock it.
const ocDir = opencodeConfigDir();
const globals = [join(ocDir, 'opencode.jsonc'), join(ocDir, 'opencode.json')].filter((p) => existsSync(p));
if (globals.length > 0) {
try {
const ocLock = await acquireBootstrapLock(ocDir);
try {
for (const p of globals) sweep(p);
} finally {
ocLock.release();
}
} catch (e) {
console.error(`note: could not lock the opencode config dir (${(e as Error).message}) — entries left for a re-run.`);
}
}
// The project file's dir IS the workspace, which withLock(ws)
// already holds — the lock is non-reentrant, so no nested acquire.
sweep(opencodeProjectConfigPath(ws));
break;
}
}
}
@@ -1321,6 +1745,9 @@ async function runUninstall(ws: string, rest: string[], home: string, runner: Ex
export interface RunBootstrapOpts {
/** Exec seam for gh/claude/codex subprocesses (tests inject a recorder). */
runner?: ExecRunner;
/** Spawn seam for the opencode `mcp list` probe (tests capture argv, cwd,
* and env; the default holds a real Bun.spawn handle so timeouts kill). */
probeSpawn?: OpencodeProbeSpawn;
}
/** Dispatch. Returns the process exit code (cli.ts passes it to setCliExitVerdict). */
@@ -1386,7 +1813,7 @@ export async function runBootstrap(args: string[], opts: RunBootstrapOpts = {}):
code = await runRepo(ws, rest, home, runner);
break;
case 'hooks':
code = await runHooks(ws, rest, home, runner);
code = await runHooks(ws, rest, home, runner, opts.probeSpawn);
break;
case 'verify':
code = await runVerify(ws, rest, home);
+6
View File
@@ -29,12 +29,14 @@ import { resolveAgentRunner, listRegisteredAgents, registerAgentRunner, validate
import { OpenClawRunner } from '../core/claw-test/runners/openclaw.ts';
import { HermesRunner } from '../core/claw-test/runners/hermes.ts';
import { GrokRunner } from '../core/claw-test/runners/grok.ts';
import { OpencodeRunner } from '../core/claw-test/runners/opencode.ts';
import { createTranscriptSink } from '../core/claw-test/transcript-capture.ts';
// Ensure built-in runners are registered.
registerAgentRunner('openclaw', () => new OpenClawRunner());
registerAgentRunner('hermes', () => new HermesRunner());
registerAgentRunner('grok', () => new GrokRunner());
registerAgentRunner('opencode', () => new OpencodeRunner());
interface HarnessOpts {
scenario: string;
@@ -417,6 +419,9 @@ const AGENT_INSTALL_HINTS: Record<string, string> = {
// Official xAI CLI only — the community superagent-ai grok-cli ships a
// colliding `grok` binary (docs/mcp/GROK-CLI-PIN.md).
grok: 'install grok (npm: @xai-official/grok, or https://x.ai/cli/install.sh) or set GROK_BIN',
// SST terminal agent — not OpenClaw, and not the renamed-to-Crush ancestor
// that shares the binary name (docs/mcp/OPENCODE-CLI-PIN.md).
opencode: 'install opencode (npm: opencode-ai, or https://opencode.ai/install) or set OPENCODE_BIN',
};
/**
@@ -1003,6 +1008,7 @@ Examples:
gbrain claw-test --scenario fresh-install
gbrain claw-test --scenario upgrade-from-v0.18 --keep-tempdir
gbrain claw-test --live --agent openclaw
gbrain claw-test --live --agent opencode
gbrain claw-test --live --agent hermes
gbrain claw-test --live --agent grok`);
}
+142 -9
View File
@@ -9,7 +9,7 @@
* needed for the connection.
*
* gbrain connect <mcp-url> [--token <bearer>] [--name gbrain]
* [--agent claude-code|codex|perplexity|generic]
* [--agent claude-code|codex|opencode|perplexity|generic]
* [--oauth [--register | --client-id ID --client-secret SECRET] [--scopes "read write"]]
* [--install] [--yes] [--json] [--show-token] [--force]
* [--timeout-ms N]
@@ -27,20 +27,34 @@
* only; --install runs it).
* - codex: `codex mcp add <name> --url <url> --bearer-token-env-var
* GBRAIN_REMOTE_TOKEN` (bearer via env var; --install runs it).
* - opencode: `opencode mcp add <name> --url <url> --header
* "Authorization=Bearer {env:GBRAIN_REMOTE_TOKEN}"` (the interpolation is
* stored literally; --install writes the entry directly via
* opencode-json.ts no binary needed).
* - perplexity: GUI connector (Settings Connectors). Supports bearer or
* OAuth; no --install.
* - generic: prints the connector fields for any other MCP client.
*/
import { execFileSync } from 'child_process';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import type { ConnectProbeResult } from '../core/connect-probe.ts';
import { probeBrainIdentity, DEFAULT_PROBE_TIMEOUT_MS } from '../core/connect-probe.ts';
import { opencodeGlobalConfigPath } from '../core/bootstrap/host-specs.ts';
import { acquireBootstrapLock } from '../core/bootstrap/lock.ts';
import {
GBRAIN_REMOTE_TOKEN_ENV,
reconcileOpencodeSiblingGlobal,
writeOpencodeMcpEntry,
} from '../core/bootstrap/opencode-json.ts';
import { promptLine } from '../core/cli-util.ts';
import {
NAME_RE,
REDACTED,
buildClaudeMcpAddArgv,
buildCodexMcpAddArgv,
buildOpencodeMcpAddArgv,
cmdString,
isValidName,
issuerFromMcpUrl,
@@ -58,6 +72,7 @@ export {
REDACTED,
buildClaudeMcpAddArgv,
buildCodexMcpAddArgv,
buildOpencodeMcpAddArgv,
cmdString,
isLinkLocalOrMetadata,
issuerFromMcpUrl,
@@ -69,7 +84,9 @@ export {
type UrlResult,
} from '../core/mcp-registration.ts';
export const ENV_VAR = 'GBRAIN_REMOTE_TOKEN';
// Defined from the writer's exported constant so the printed interpolation and
// the ownership fingerprint literal ({env:GBRAIN_REMOTE_TOKEN}) cannot drift.
export const ENV_VAR = GBRAIN_REMOTE_TOKEN_ENV;
export const PLACEHOLDER_TOKEN = '<paste-your-token>';
export const PLACEHOLDER_SECRET = '<paste-your-client-secret>';
export const DEFAULT_NAME = 'gbrain';
@@ -77,12 +94,12 @@ export const DEFAULT_SCOPES = 'read write';
// Single source of truth shared with the probe (was a duplicated 15_000 literal).
const DEFAULT_TIMEOUT_MS = DEFAULT_PROBE_TIMEOUT_MS;
export type AgentId = 'claude-code' | 'codex' | 'perplexity' | 'generic';
export type AgentId = 'claude-code' | 'codex' | 'opencode' | 'perplexity' | 'generic';
interface AgentSpec {
id: AgentId;
label: string; // human label for messages
binary?: string; // CLI binary backing --install ('claude' | 'codex')
binary?: string; // CLI binary backing --install ('claude' | 'codex'; opencode installs via the direct JSONC writer)
installable: boolean;
supportsOAuth: boolean; // accepts OAuth client-credentials connector fields
}
@@ -90,11 +107,14 @@ interface AgentSpec {
export const AGENT_SPECS: Record<AgentId, AgentSpec> = {
'claude-code': { id: 'claude-code', label: 'Claude Code', binary: 'claude', installable: true, supportsOAuth: false },
codex: { id: 'codex', label: 'Codex', binary: 'codex', installable: true, supportsOAuth: false },
// No `binary`: the opencode --install lane never execs a CLI (direct JSONC
// write), and it branches before the exec lane's `spec.binary` read.
opencode: { id: 'opencode', label: 'opencode', installable: true, supportsOAuth: false },
perplexity: { id: 'perplexity', label: 'Perplexity Computer', installable: false, supportsOAuth: true },
generic: { id: 'generic', label: 'your agent', installable: false, supportsOAuth: true },
};
export const AGENT_IDS: AgentId[] = ['claude-code', 'codex', 'perplexity', 'generic'];
export const AGENT_IDS: AgentId[] = ['claude-code', 'codex', 'opencode', 'perplexity', 'generic'];
// The named tools MUST be real MCP-exposed ops (verified by the round-trip
// E2E). `capture` is intentionally absent: it's a CLI-only convenience wrapper,
@@ -127,7 +147,7 @@ Usage:
gbrain connect <mcp-url> [--token <bearer>] [flags]
Prints a copy-paste setup block for your agent, or wires it up directly with
--install (claude-code + codex only). The MCP URL is your remote
--install (claude-code, codex + opencode). The MCP URL is your remote
'gbrain serve --http' endpoint; a bare host is rejected pass an explicit
https:// URL.
@@ -140,14 +160,15 @@ Auth:
Flags:
--token <bearer> Bearer token (else $${ENV_VAR}; from 'gbrain auth create')
--name <id> MCP server name in the agent (default: ${DEFAULT_NAME})
--agent <kind> claude-code (default) | codex | perplexity | generic
--agent <kind> claude-code (default) | codex | opencode | perplexity | generic
--oauth Use OAuth client credentials instead of a bearer token
--register With --oauth: mint a client on the host (gbrain auth register-client)
--client-id <id> With --oauth: use an existing OAuth client id
--client-secret <s> With --oauth: use an existing OAuth client secret
--scopes "<s>" With --oauth --register: client scopes (default: "${DEFAULT_SCOPES}")
--install Run the agent's MCP-add command, then smoke-test the token
(claude-code + codex only)
(claude-code + codex + opencode; opencode installs via a direct
config write no binary needed, token stays out of the file)
--yes Skip the install confirmation prompt
--force On --install, replace an existing server of the same name
--json Emit machine-readable JSON (secret redacted)
@@ -158,6 +179,7 @@ Examples:
gbrain connect https://brain.example.com/mcp --token gbrain_xxx
gbrain connect https://brain.example.com:3131 --install --yes
gbrain connect https://brain.example.com/mcp --token gbrain_xxx --agent codex
gbrain connect https://brain.example.com/mcp --token gbrain_xxx --agent opencode --install
gbrain connect https://brain.example.com/mcp --agent perplexity --oauth --register
gbrain connect https://brain.example.com/mcp --agent perplexity --oauth \\
--client-id gbrain_cl_xxx --client-secret gbrain_cs_xxx
@@ -225,6 +247,31 @@ function codexBlock(p: { name: string; url: string; token: string | null }): str
return lines.join('\n');
}
function opencodeBlock(p: { name: string; url: string; token: string | null }): string {
const tokenValue = p.token ?? PLACEHOLDER_TOKEN;
const cmd = cmdString('opencode', buildOpencodeMcpAddArgv({ name: p.name, url: p.url, envVar: ENV_VAR }));
const lines = [
'# Paste into opencode:',
'',
'Connect my knowledge brain, then learn what it can do:',
'',
` export ${ENV_VAR}=${shellQuote(tokenValue)}`,
` ${cmd}`,
'',
];
if (!p.token) lines.push(`Replace ${PLACEHOLDER_TOKEN} with a token from \`gbrain auth create "opencode"\` on the host.`, '');
lines.push(
`The config stores the literal \`{env:${ENV_VAR}}\` interpolation — opencode resolves it at read time, ` +
`so keep that variable exported in your shell profile; the token never lands in the config file. ` +
`Restart opencode after registering (config is read at session start).`,
'',
LEARN_INSTRUCTION,
'',
SECRET_NOTE,
);
return lines.join('\n');
}
function perplexityBearerBlock(p: { url: string; token: string | null }): string {
const tokenValue = p.token ?? PLACEHOLDER_TOKEN;
return [
@@ -296,6 +343,7 @@ export function buildConnectBlock(p: { agent: AgentId; name: string; url: string
switch (p.agent) {
case 'claude-code': return claudeBlock(p);
case 'codex': return codexBlock(p);
case 'opencode': return opencodeBlock(p);
case 'perplexity': return perplexityBearerBlock(p);
case 'generic': return genericBearerBlock(p);
}
@@ -330,6 +378,10 @@ export function buildJson(p: { url: string; name: string; agent: AgentId; token:
// Codex command carries no token (env-var name only), so it's safe verbatim.
command_argv = buildCodexMcpAddArgv({ name: p.name, url: p.url, envVar: ENV_VAR });
command = cmdString('codex', command_argv);
} else if (p.agent === 'opencode') {
// The literal {env:VAR} interpolation, not a token — safe verbatim.
command_argv = buildOpencodeMcpAddArgv({ name: p.name, url: p.url, envVar: ENV_VAR });
command = cmdString('opencode', command_argv);
}
return {
schema_version: 1,
@@ -363,6 +415,18 @@ export interface ConnectDeps {
probe(url: string, token: string, timeoutMs: number): Promise<ConnectProbeResult>;
env(name: string): string | undefined;
registerOAuthClient(name: string, scopes: string): RegisterResult;
/** opencode --install lane: direct JSONC write of a remote entry carrying
* the literal `{env:GBRAIN_REMOTE_TOKEN}` interpolation (no binary execed,
* no token on disk). Throws on a foreign same-name entry; an OURS entry at
* a different url refuses unless `allowReplaceOtherSource` (connect maps
* --force onto it). May be async: the default impl serializes on the
* config-dir bootstrap lock (the writer contract); sync test fakes remain
* assignable. */
writeOpencodeRemoteEntry(
name: string,
url: string,
opts?: { allowReplaceOtherSource?: boolean },
): { configPath: string; replacedPrior: boolean } | Promise<{ configPath: string; replacedPrior: boolean }>;
}
async function defaultPromptYesNo(question: string): Promise<boolean> {
@@ -420,6 +484,31 @@ const defaultDeps: ConnectDeps = {
probe: (url, token, timeoutMs) => probeBrainIdentity(url, token, { timeoutMs }),
env: (name) => process.env[name],
registerOAuthClient: defaultRegisterOAuthClient,
writeOpencodeRemoteEntry: async (name, url, opts) => {
// The writer's contract: callers hold acquireBootstrapLock on the config
// dir (harness.ts [X11] parity) — the user-global file is shared across
// workspaces and homes, so concurrent gbrain writers serialize here.
const configPath = opencodeGlobalConfigPath();
const cfgDir = dirname(configPath);
mkdirSync(cfgDir, { recursive: true }); // the lock needs the dir; the writer mkdirs later anyway
const lock = await acquireBootstrapLock(cfgDir);
try {
// Two-filename merge blind spot: opencode merges BOTH user-global
// filenames, so a same-name gbrain entry in the SIBLING file would
// survive this write as a shadow registration (ours → removed with a
// note; foreign → refuse loudly naming both files).
const sib = reconcileOpencodeSiblingGlobal(configPath, name, { url });
for (const note of sib.notes) console.error(note);
const r = writeOpencodeMcpEntry(
configPath,
{ kind: 'remote', name, url, tokenMode: 'env' },
{ expect: { url }, ...(opts?.allowReplaceOtherSource ? { allowReplaceOtherSource: true } : {}) },
);
return { configPath: r.configPath, replacedPrior: r.replacedPrior };
} finally {
lock.release();
}
},
};
// ---------------------------------------------------------------------------
@@ -595,8 +684,52 @@ export async function runConnect(args: string[], deps: ConnectDeps = defaultDeps
// --install path. token is guaranteed literal here (install mode resolveToken).
const realToken = token as string;
if (!spec.installable) {
fail(`--install supports claude-code and codex. ${spec.label} is set up through its own UI — drop --install to print the setup steps.`);
fail(`--install supports claude-code, codex, and opencode. ${spec.label} is set up through its own UI — drop --install to print the setup steps.`);
}
if (f.agent === 'opencode') {
// Direct-writer lane: no opencode binary required (the JSONC write IS the
// registration), and the config carries only the {env:VAR} interpolation
// — the writer's fingerprint handles idempotent re-runs and refuses a
// foreign same-name entry (--force cannot override THAT; pick --name).
// --force maps to the writer's allowReplaceOtherSource so an OURS entry
// at an old url (a rotated serve) is replaceable, mirroring the exec
// lanes' documented --force semantics.
if (!f.yes) {
if (!deps.isTTY()) {
fail('--install in a non-interactive shell requires --yes (refusing to register a credential-bearing MCP server without confirmation).');
}
const ok = await deps.promptYesNo(`Add MCP entry '${f.name}' -> ${url} to the opencode user-global config?`);
if (!ok) fail('Aborted.');
}
let w: { configPath: string; replacedPrior: boolean };
try {
w = await deps.writeOpencodeRemoteEntry(f.name, url, { allowReplaceOtherSource: f.force });
} catch (e) {
fail(redactToken((e as Error).message, realToken));
}
console.error(
`Added MCP entry '${f.name}' -> ${url} in ${w.configPath}` +
`${w.replacedPrior ? ' (replaced the prior gbrain entry)' : ''}. Restart opencode (config is read at session start).`,
);
if (deps.env(ENV_VAR) !== realToken) {
console.error(`opencode resolves {env:${ENV_VAR}} at read time. Add this to your shell profile so sessions can reach the brain:`);
console.error(` export ${ENV_VAR}=<your-token>`);
}
const ocProbe = await deps.probe(url, realToken, f.timeoutMs);
if (ocProbe.ok) {
console.error(`Verified: ${ocProbe.identity || 'brain reachable'}`);
console.error('');
console.error(LEARN_INSTRUCTION);
return;
}
console.error(
`Warning: registered '${f.name}', but the smoke-test did not verify (${ocProbe.reason}): ${redactToken(ocProbe.message, realToken)}`,
);
console.error('The agent will likely hit 401/errors until the token or URL is fixed.');
process.exit(1);
}
const binary = spec.binary as string; // 'claude' | 'codex'
if (!deps.hasBinary(binary)) {
fail(`${spec.label} CLI ('${binary}') not found on PATH. Install ${spec.label}, or drop --install to print the command to run manually.`);
+45 -15
View File
@@ -1909,7 +1909,7 @@ export async function checkRerankerHealth(engine: BrainEngine): Promise<Check> {
return {
name: 'reranker_health',
status: 'warn',
message: `${authFails.length} reranker auth failure(s) in last 7 days. Fix: verify ZEROENTROPY_API_KEY and run \`gbrain models doctor\`.`,
message: `${authFails.length} reranker auth failure(s) in last 7 days. Fix: verify the reranker provider's API key (e.g. VOYAGE_API_KEY) and run \`gbrain models doctor\`.`,
};
}
@@ -1949,9 +1949,13 @@ export async function checkRerankerHealth(engine: BrainEngine): Promise<Check> {
if (unknownFails.length >= 3) {
const setupHint = unknownFails.some((f) => {
const summary = String(f.error_summary ?? '');
return summary.includes('ZEROENTROPY_API_KEY') || summary.toLowerCase().includes('api key');
return (
summary.includes('ZEROENTROPY_API_KEY') ||
summary.includes('VOYAGE_API_KEY') ||
summary.toLowerCase().includes('api key')
);
})
? ' Fix: verify ZEROENTROPY_API_KEY and run `gbrain models doctor`.'
? " Fix: verify the reranker provider's API key (e.g. VOYAGE_API_KEY) and run `gbrain models doctor`."
: '';
return {
name: 'reranker_health',
@@ -2748,7 +2752,18 @@ export async function checkProviderSunset(engine: BrainEngine, now: number = Dat
}
const onSunsetEmbedding = model.startsWith('zeroentropyai:');
const onSunsetReranker = !!reranker?.startsWith('zeroentropyai:');
if (!onSunsetEmbedding && !onSunsetReranker) {
// Custom embedding columns can route queries through a ZE-backed model
// even when the primary embedding + reranker are clear — without this arm
// the check reports ok while those columns die on the date.
let zeColumns: string[] = [];
try {
const { detectZeCustomColumns } = await import('../core/ze-exposure.ts');
zeColumns = (await detectZeCustomColumns(engine)).columns;
} catch {
// Probe failed — make no custom-column claim.
}
const onSunsetColumns = zeColumns.length > 0;
if (!onSunsetEmbedding && !onSunsetReranker && !onSunsetColumns) {
return {
name,
status: 'ok',
@@ -2774,7 +2789,6 @@ export async function checkProviderSunset(engine: BrainEngine, now: number = Dat
} catch {
// Probe failed (fresh/odd brain) — no exposure claim, warn-only.
}
const dimFlag = dims ? ` --dim ${dims}` : '';
parts.push(
past
? hasVectors
@@ -2782,21 +2796,37 @@ export async function checkProviderSunset(engine: BrainEngine, now: number = Dat
: `embedding_model="${model}": the hosted API shut down on ${ZEROENTROPY_SUNSET_DATE}. No embedded vectors exist yet, so retrieval is not impacted — but embedding will fail until the config points elsewhere.`
: `embedding_model="${model}": the hosted API shuts down on ${ZEROENTROPY_SUNSET_DATE}. On that date semantic retrieval stops entirely — existing vectors become unqueryable (query embedding uses the same endpoint), not just new content.`,
);
// v0.46.3: the paste-ready fix is TARGET-AWARE on dimensions. Voyage's
// valid widths are {256, 512, 1024, 2048} — blindly preserving this
// brain's actual width (usually 1280) would emit a command Voyage
// rejects. OpenAI text-3 supports flexible widths up to its native
// size, so the keep-width form is offered only when valid there.
const openaiDimFlag = dims && dims <= 1536 ? ` --dim ${dims}` : ' --dim 1536';
const openaiKeepsWidth = !!(dims && dims <= 1536);
parts.push(
`Two fixes, either works: ` +
`[1] self-host the same model — zembed-1 weights are Apache-2.0; serve them via llama-server or Ollama and point the config at the local endpoint. Keeps every existing vector, no re-embed (docs/guides/embedding-migration.md, "Self-hosting instead of migrating"). ` +
`[2] migrate to another provider (resumable; preview cost first): ` +
`gbrain migrate embeddings --to <provider:model>${dimFlag} --dry-run` +
(dims ? ` — keep --dim ${dims} (this brain's actual index width) to avoid a needless schema rebuild when the target supports it.` : ''),
`[1] self-host the same model — zembed-1 weights are Apache-2.0; keep the zeroentropyai:zembed-1 id and point provider_base_urls.zeroentropyai at a ZE-wire-compatible endpoint (NOT a generic OpenAI-compatible server — the id speaks ZE's /models/embed dialect). Keeps every existing vector, no re-embed (docs/guides/embedding-migration.md). ` +
`[2] migrate (resumable; preview cost first): ` +
`gbrain migrate embeddings --to voyage:voyage-4 --dim 1024 --dry-run` +
(dims && dims !== 1024 ? ` (${dims} is not a valid Voyage width — the migration rebuilds the index at 1024)` : '') +
`; OpenAI alternative${openaiKeepsWidth ? ` keeps this brain's ${dims}d width` : ''}: ` +
`gbrain migrate embeddings --to openai:text-embedding-3-small${openaiDimFlag} --dry-run.`,
);
}
if (onSunsetReranker) {
parts.push(
`The reranker (${reranker}) is on the same provider; after the shutdown search falls back to unreranked ordering. ` +
`Fix: gbrain config set search.reranker.enabled false, or point search.reranker.model at another provider.`,
`Fix: gbrain config set search.reranker.model voyage:rerank-2.5 (needs VOYAGE_API_KEY), or disable: gbrain config set search.reranker.enabled false.`,
);
}
if (onSunsetEmbedding || onSunsetReranker) {
if (onSunsetColumns) {
parts.push(
`Custom embedding column(s) backed by the shutting-down provider: ${zeColumns.join(', ')}. ` +
`No automated off-ramp exists for custom columns yet (migrate embeddings covers the primary column only) — ` +
`re-declare them on a new provider and re-embed (skills/migrations/v0.46.3.0.md).`,
);
}
if (onSunsetEmbedding || onSunsetReranker || onSunsetColumns) {
parts.push('Accepted the risk? Silence this check: gbrain config set doctor.suppress_provider_sunset true');
}
// fail = retrieval is ACTUALLY down (past the date AND embedded vectors
@@ -2814,10 +2844,10 @@ export async function checkProviderSunset(engine: BrainEngine, now: number = Dat
* v0.36.0.0 (A5): embedding_width_consistency doctor check.
*
* Cross-checks that `config.embedding_dimensions` matches the actual
* `vector(N)` width on `content_chunks.embedding`. Drift here means the
* ze-switch was interrupted mid-flight (schema changed but config write
* crashed, or vice versa). Surfaces a paste-ready `gbrain ze-switch
* --resume` hint.
* `vector(N)` width on `content_chunks.embedding`. Drift here means an
* embedding migration was interrupted mid-flight (schema changed but the
* config write crashed, or vice versa). Recovery path: `gbrain migrate
* embeddings` (resumable).
*/
export async function checkEmbeddingWidthConsistency(engine: BrainEngine): Promise<Check> {
try {
+9 -9
View File
@@ -159,11 +159,11 @@ export interface HookIo {
/** TEST SEAM: user-prompt deadline override (wall-clock flake control). */
userPromptDeadlineMs?: number;
/**
* Feedback-loop attribution channel (`--harness <claude-code|codex>`).
* Feedback-loop attribution channel (`--harness <claude-code|codex|opencode>`).
* Default 'claude-code' the only harness bootstrap registers hooks for
* today; a codex hook registration passes the flag explicitly.
* today; a codex/opencode hook registration passes the flag explicitly.
*/
harness?: 'claude-code' | 'codex';
harness?: 'claude-code' | 'codex' | 'opencode';
}
// ── Entry point ─────────────────────────────────────────────────────────────
@@ -175,8 +175,8 @@ Events (wired into .claude/settings.local.json by gbrain bootstrap):
push status, hook health) to stdout
user-prompt read hook JSON on stdin, request per-turn context from a
running 'gbrain serve' over IPC, print additionalContext JSON
(--harness <claude-code|codex> sets the feedback-loop channel;
default claude-code, unknown values fall back to the default)
(--harness <claude-code|codex|opencode> sets the feedback-loop
channel; default claude-code, unknown values fall back to the default)
stop append to the per-session live buffer
session-end ingest the session transcript into the dream corpus
(secret-scanned), prune old corpus files, push the workspace
@@ -195,13 +195,13 @@ export async function runHook(args: string[], io: HookIo = {}): Promise<number>
write(io, USAGE + '\n');
return 0;
}
// `--harness <claude-code|codex>` — feedback-loop channel attribution for
// user-prompt. Unknown values fall back to the default (fail-open: a bad
// registration must never break the hook contract).
// `--harness <claude-code|codex|opencode>` — feedback-loop channel
// attribution for user-prompt. Unknown values fall back to the default
// (fail-open: a bad registration must never break the hook contract).
const harnessIdx = args.indexOf('--harness');
if (harnessIdx >= 0 && !io.harness) {
const v = args[harnessIdx + 1];
if (v === 'claude-code' || v === 'codex') io = { ...io, harness: v };
if (v === 'claude-code' || v === 'codex' || v === 'opencode') io = { ...io, harness: v };
}
if (!event || !['session-start', 'user-prompt', 'stop', 'session-end', 'compact'].includes(event)) {
process.stderr.write(USAGE + '\n');
+12 -3
View File
@@ -123,6 +123,12 @@ export async function pickProvider(opts: PickProviderOpts): Promise<PickedProvid
const all = listRecipes();
let ready = readyRecipesForTouchpoint(all, opts.touchpoint, env);
// v0.46.3: never OFFER a provider whose hosted API has an announced shutdown
// (recipe.sunset) — a fresh install must not be steered onto a dying
// provider. Explicit --embedding-model still works (with a loud warning)
// until the removal release.
ready = ready.filter((r) => !r.sunset);
// Probe-gate the ollama daemon: `envReady` treats no-key-required as
// ready, but daemon-up ≠ model-pulled — the exact trap that let a keyless
// Enter "choose" a broken ollama config and continue silently degraded.
@@ -179,7 +185,9 @@ export async function pickProvider(opts: PickProviderOpts): Promise<PickedProvid
label += ` (${tp.default_dims}d)`;
}
if (tp && 'models' in tp && Array.isArray(tp.models) && tp.models.length > 0) {
label += ` ${tp.models[0]}`;
// v0.46.3: show the canonical model (default_model), not array position —
// the displayed row must match what a pick actually selects.
label += ` ${('default_model' in tp && tp.default_model) || tp.models[0]}`;
}
const hint = localHints.get(r.id);
if (hint) label += ` [${hint}]`;
@@ -215,9 +223,10 @@ export async function pickProvider(opts: PickProviderOpts): Promise<PickedProvid
const tp = picked.touchpoints[opts.touchpoint];
if (!tp) return null;
// Pick first model in the recipe's list (callers can override via flag).
// v0.46.3: pick the recipe's canonical model (default_model), falling back to
// array position (callers can override via flag).
const modelId = ('models' in tp && Array.isArray(tp.models) && tp.models.length > 0)
? tp.models[0]
? (('default_model' in tp && tp.default_model) || tp.models[0])
: '';
if (!modelId) {
writeStderr(`\nRecipe "${picked.id}" declares no models for ${opts.touchpoint}. Aborting.\n`);
+233 -32
View File
@@ -312,6 +312,13 @@ async function resolveAIOptions(opts: ResolveAIOptionsArgs): Promise<ResolvedAIO
if (verbose) {
out.embedding_model = verbose;
// v0.46.3: an EXPLICIT --embedding-model wins over a seeded deferred-setup
// sentinel — without this, `gbrain init --force --embedding-model
// voyage:voyage-4` on a keyless brain (embedding_disabled persisted) would
// silently re-persist embedding_disabled and the documented recovery
// command would be a no-op. (--no-embedding is parsed later and still
// wins when both flags are passed.)
delete out.noEmbedding;
} else if (shorthand) {
const { getRecipe } = await import('../core/ai/recipes/index.ts');
const recipe = getRecipe(shorthand);
@@ -331,15 +338,22 @@ async function resolveAIOptions(opts: ResolveAIOptionsArgs): Promise<ResolvedAIO
);
process.exit(1);
}
const firstModel = recipe.touchpoints.embedding?.models[0];
if (!firstModel) {
// v0.46.3: the shorthand resolves the recipe's canonical model, not array
// position — Voyage lists voyage-4-large first but its canonical default
// is voyage-4 (see EmbeddingTouchpoint.default_model).
const canonicalModel =
recipe.touchpoints.embedding?.default_model ?? recipe.touchpoints.embedding?.models[0];
if (!canonicalModel) {
console.error(`Provider ${shorthand} has no embedding models listed. Use --embedding-model provider:model.`);
process.exit(1);
}
out.embedding_model = `${shorthand}:${firstModel}`;
out.embedding_model = `${shorthand}:${canonicalModel}`;
// v0.46.3: explicit flag wins over a seeded deferred-setup sentinel (see the
// verbose branch above).
delete out.noEmbedding;
// #2051: width follows the model actually chosen, not the recipe default.
const { embeddingDimsForModel } = await import('../core/ai/model-resolver.ts');
out.embedding_dimensions = embeddingDimsForModel(recipe, firstModel);
out.embedding_dimensions = embeddingDimsForModel(recipe, canonicalModel);
}
if (dimsArg !== null && !Number.isNaN(dimsArg) && dimsArg > 0) {
@@ -373,6 +387,22 @@ async function resolveAIOptions(opts: ResolveAIOptionsArgs): Promise<ResolvedAIO
}
}
// v0.46.3: an explicitly-requested sunset provider (verbose or shorthand form)
// is allowed until the removal release, but never silently — warn loudly and
// proceed (D3: hide + warn, allow explicit).
if (out.embedding_model) {
const { getRecipe } = await import('../core/ai/recipes/index.ts');
const sunsetRecipe = getRecipe(out.embedding_model.split(':')[0]);
if (sunsetRecipe?.sunset) {
const rep = sunsetRecipe.sunset.replacement?.embedding;
console.error(
`WARNING: ${sunsetRecipe.name} stops working on ${sunsetRecipe.sunset.date}. ` +
`Proceeding because you asked explicitly${rep ? `, but the recommended provider is ${rep}` : ''}. ` +
`Migrate before that date: gbrain migrate embeddings --to ${rep ?? '<provider:model>'} --dry-run`,
);
}
}
if (expansion) out.expansion_model = expansion;
if (chat) out.chat_model = chat;
@@ -453,6 +483,10 @@ export async function groupReadyByProvider(
// still picker-selectable explicitly, but silent auto-pick is wrong UX.
const required = r.auth_env?.required ?? [];
if (required.length === 0) continue;
// v0.46.3: never auto-pick a provider whose hosted API has an announced
// shutdown (recipe.sunset). Explicit --embedding-model still works
// (with a loud warning) until the removal release.
if (r.sunset) continue;
if (envReady(r, env)) {
ready.push({ recipeId: r.id, recipe: r });
seen.add(r.id);
@@ -501,13 +535,12 @@ function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested:
console.error(' gbrain init --force --pglite --embedding-model <id>)');
console.error('');
console.error('Or set a key for semantic search:');
console.error(' export VOYAGE_API_KEY=pa-… # voyage:voyage-4 (1024d) — default');
console.error(' export OPENAI_API_KEY=sk-… # openai:text-embedding-3-large (1536d)');
console.error(' export ZEROENTROPY_API_KEY=ze-… # zeroentropyai:zembed-1 (2560d, Matryoshka)');
console.error(' export VOYAGE_API_KEY=pa-… # voyage:voyage-3-large (1024d)');
console.error('Then re-run: gbrain init --pglite');
console.error('');
console.error('Or pick explicitly:');
console.error(' gbrain init --pglite --embedding-model openai:text-embedding-3-large');
console.error(' gbrain init --pglite --embedding-model voyage:voyage-4');
// D13: surface near-miss env vars (e.g. OPENAPI_API_KEY → OPENAI_API_KEY).
if (typos.length > 0) {
console.error('');
@@ -517,6 +550,62 @@ function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested:
}
}
/**
* v0.46.3: voyage-keyed installs (any picked embedding provider) get the
* recommended reranker written as EXPLICIT per-brain config the mode-bundle
* reranker default stays on the sunsetting legacy provider until the
* September removal (split-default), so without this write a fresh voyage
* brain would resolve a reranker whose key it doesn't have; keyed non-voyage
* installs get explicit `search.reranker.enabled false` instead, and keyless
* installs get no write. Never clobbers an existing explicit choice (re-init
* preserves user config). Best-effort: reranking is fail-open, a missed
* override degrades to no-rerank, never breaks init. Shared by the PGLite and
* Postgres init paths (one edit site for the September bundle flip).
*/
async function writeNewInstallRerankerDefault(
engine: { getConfig(key: string): Promise<string | null>; setConfig(key: string, value: string): Promise<void> },
resolvedModel: string | undefined,
): Promise<void> {
// Deliberate legacy setups keep the legacy bundle reranker (works until the
// provider's shutdown; warn-on-use covers it).
if (resolvedModel?.startsWith('zeroentropyai:')) return;
try {
// Never-clobber: an existing explicit reranker model OR enabled override
// means the user already decided — leave both keys alone.
const [existingModel, existingEnabled] = await Promise.all([
engine.getConfig('search.reranker.model'),
engine.getConfig('search.reranker.enabled'),
]);
if (existingModel || existingEnabled != null) return;
// Voyage key on either plane (env or ~/.gbrain/config.json) → point the
// reranker at it. Otherwise the bundle default still resolves the legacy
// sunset reranker, which this install has no key for and which dies on
// 2026-09-04 — disable it explicitly so fresh installs don't inherit a
// doomed fail-open (per-search timeout penalty after the shutdown).
const hasVoyageKey =
!!process.env.VOYAGE_API_KEY || !!loadConfigFileOnly()?.voyage_api_key;
if (resolvedModel?.startsWith('voyage:') || hasVoyageKey) {
const { NEW_INSTALL_DEFAULT_RERANKER_MODEL } = await import('../core/ai/defaults.ts');
await engine.setConfig('search.reranker.model', NEW_INSTALL_DEFAULT_RERANKER_MODEL);
console.log(` Reranker: ${NEW_INSTALL_DEFAULT_RERANKER_MODEL} (same VOYAGE_API_KEY)`);
} else if (resolvedModel) {
// Keyed non-voyage install (e.g. openai): make the no-reranker state
// explicit instead of inheriting the legacy sunset bundle default this
// brain has no key for. KEYLESS installs deliberately get NO write —
// the documented recovery re-init must find virgin reranker config so
// its voyage override still lands (never-clobber would block it).
await engine.setConfig('search.reranker.enabled', 'false');
console.log(
' Reranker: disabled (no VOYAGE_API_KEY — enable later: ' +
'gbrain config set search.reranker.enabled true && ' +
'gbrain config set search.reranker.model voyage:rerank-2.5)',
);
}
} catch {
// Cosmetic; never block init.
}
}
/** Loud keyless-continue notice for the no-keys default path. The upgrade
* command is `init --force` re-init, NOT `config set embedding_model` that
* key is a schema-sizing file-plane field that `gbrain config set` refuses
@@ -531,28 +620,43 @@ function printKeylessContinueNotice(): void {
}
async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boolean): Promise<void> {
const ready = await groupReadyByProvider('embedding');
// v0.46.3: provider readiness folds FILE-PLANE keys too (docs explicitly
// permit `voyage_api_key` etc. in ~/.gbrain/config.json) — env still wins
// via buildGatewayConfig's spread order. Without this, a non-interactive
// fresh install keyed only via config.json reported zero providers and
// silently persisted keyless mode.
const fileCfgForKeys = loadConfigFileOnly();
let effectiveEnv: NodeJS.ProcessEnv = process.env;
if (fileCfgForKeys) {
try {
const { buildGatewayConfig } = await import('../core/ai/build-gateway-config.ts');
effectiveEnv = buildGatewayConfig(fileCfgForKeys).env as NodeJS.ProcessEnv;
} catch {
// Fold failure → env-only readiness (pre-v0.46.3 behavior).
}
}
const ready = await groupReadyByProvider('embedding', effectiveEnv);
const isTTY = !nonInteractive && !!process.stdin.isTTY;
if (ready.length === 1) {
const r = ready[0].recipe;
const tp = r.touchpoints.embedding!;
if (Array.isArray(tp.models) && tp.models.length > 0) {
const model = tp.models[0];
// v0.46.3: recipes carry a canonical default_model — array order is
// quality-sorted, not recommendation-sorted (Voyage lists voyage-4-large
// first; the canonical pick is voyage-4).
const model = tp.default_model ?? tp.models[0];
const fullModel = `${r.id}:${model}`;
// When the resolved provider matches the canonical default model
// (DEFAULT_EMBEDDING_MODEL), use the gateway's
// DEFAULT_EMBEDDING_DIMENSIONS instead of the recipe's `default_dims`
// (which is the recipe's "largest sensible" tier). This keeps
// fresh-install schema width aligned with the v0.37.11.0 system
// default — for ZE that means 1280 (the Matryoshka step closest to
// legacy OpenAI 1536), not the recipe's 2560.
const { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } =
// When the resolved provider matches the NEW-INSTALL canonical default
// model, use NEW_INSTALL_DEFAULT_EMBEDDING_DIMENSIONS instead of the
// recipe's `default_dims` so fresh-install schema width stays aligned
// with the system default (1024 for voyage-4).
const { NEW_INSTALL_DEFAULT_EMBEDDING_MODEL, NEW_INSTALL_DEFAULT_EMBEDDING_DIMENSIONS } =
await import('../core/ai/defaults.ts');
const { embeddingDimsForModel } = await import('../core/ai/model-resolver.ts');
// #2051: non-canonical models resolve per-model, not recipe-wide.
const dims = fullModel === DEFAULT_EMBEDDING_MODEL
? DEFAULT_EMBEDDING_DIMENSIONS
const dims = fullModel === NEW_INSTALL_DEFAULT_EMBEDDING_MODEL
? NEW_INSTALL_DEFAULT_EMBEDDING_DIMENSIONS
: embeddingDimsForModel(r, model);
out.embedding_model = fullModel;
out.embedding_dimensions = dims;
@@ -572,6 +676,66 @@ async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boo
// MEANT to configure a key — completing keyless there would silently bury
// their typo.
if (ready.length === 0) {
// v0.46.3: the sunset exclusion must NOT convert a working legacy brain to
// keyless. A configless EXISTING brain (config.json has a database but no
// embedding_model — it rides the legacy runtime fallback) being re-inited
// with only a sunset-provider key would otherwise land in the zero-ready
// path and get `embedding_disabled: true` written — disabling semantic
// search BEFORE the provider's shutdown. When the brain already depends
// on the sunsetting provider and its key is present, keep it (with the
// loud warning); FRESH installs still never get steered onto it.
const { listRecipes } = await import('../core/ai/recipes/index.ts');
const { envReady } = await import('./providers.ts');
const sunsetReady = listRecipes().filter(
(r) =>
r.sunset &&
(r.auth_env?.required ?? []).length > 0 &&
envReady(r, effectiveEnv) &&
(r.touchpoints.embedding?.models?.length ?? 0) > 0,
);
if (sunsetReady.length > 0) {
const fileCfg = fileCfgForKeys;
const existingConfiglessBrain =
!!fileCfg &&
!!(fileCfg.database_path || fileCfg.database_url) &&
!fileCfg.embedding_model &&
fileCfg.embedding_disabled !== true;
if (!existingConfiglessBrain) {
// FRESH install with only a sunset-provider key: keyless-continue is
// right, but "no keys detected" would be false — name the key we
// deliberately ignored and the way out (D3: hide + WARN, not hide
// silently).
const r = sunsetReady[0];
console.error(
`NOTE: ${r.auth_env?.required?.[0] ?? r.id} is set, but ${r.name} shuts down on ` +
`${r.sunset!.date} — not auto-selecting it for a new brain. ` +
`Set VOYAGE_API_KEY (recommended) or force it explicitly: ` +
`gbrain init --pglite --embedding-model ${r.id}:${r.touchpoints.embedding!.default_model ?? r.touchpoints.embedding!.models[0]} (not recommended).`,
);
}
if (existingConfiglessBrain) {
const r = sunsetReady[0];
const tp = r.touchpoints.embedding!;
const model = tp.default_model ?? tp.models[0];
const fullModel = `${r.id}:${model}`;
const { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } =
await import('../core/ai/defaults.ts');
const { embeddingDimsForModel } = await import('../core/ai/model-resolver.ts');
// Legacy brains ride the legacy width (their stored vectors live there).
const dims = fullModel === DEFAULT_EMBEDDING_MODEL
? DEFAULT_EMBEDDING_DIMENSIONS
: embeddingDimsForModel(r, model);
out.embedding_model = fullModel;
out.embedding_dimensions = dims;
console.error(
`WARNING: this brain currently embeds via ${r.name}, which stops working on ` +
`${r.sunset!.date}. Keeping ${fullModel} (${dims}d) so nothing breaks today — ` +
`migrate before that date: gbrain migrate embeddings --to ` +
`${r.sunset!.replacement?.embedding ?? '<provider:model>'} --dry-run`,
);
return;
}
}
const typos = await findEnvKeyTypos();
if (typos.length > 0) {
printNoEmbeddingProviderHint(typos);
@@ -585,7 +749,7 @@ async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boo
// TTY → picker (local providers like ollama may be selectable); a null
// pick (nothing offered, user skipped, or EOF) continues keyless.
const { pickProvider } = await import('./init-provider-picker.ts');
const picked = await pickProvider({ touchpoint: 'embedding', env: process.env, isTTY: true });
const picked = await pickProvider({ touchpoint: 'embedding', env: effectiveEnv, isTTY: true });
if (!picked) {
printKeylessContinueNotice();
out.noEmbedding = true;
@@ -601,16 +765,16 @@ async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boo
// keys — failing there blocked scripted installs), else fail-loud per D2/D3
// (a genuinely ambiguous set with no canonical candidate stays explicit).
if (!isTTY) {
const { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } =
const { NEW_INSTALL_DEFAULT_EMBEDDING_MODEL, NEW_INSTALL_DEFAULT_EMBEDDING_DIMENSIONS } =
await import('../core/ai/defaults.ts');
const canonicalProvider = DEFAULT_EMBEDDING_MODEL.split(':')[0];
const canonicalProvider = NEW_INSTALL_DEFAULT_EMBEDDING_MODEL.split(':')[0];
const canonical = ready.find((p) => p.recipeId === canonicalProvider);
if (canonical) {
out.embedding_model = DEFAULT_EMBEDDING_MODEL;
out.embedding_dimensions = DEFAULT_EMBEDDING_DIMENSIONS;
out.embedding_model = NEW_INSTALL_DEFAULT_EMBEDDING_MODEL;
out.embedding_dimensions = NEW_INSTALL_DEFAULT_EMBEDDING_DIMENSIONS;
console.error(
`Multiple embedding providers env-ready (${ready.map(p => p.recipeId).join(', ')}). ` +
`Using the default ${DEFAULT_EMBEDDING_MODEL} (${DEFAULT_EMBEDDING_DIMENSIONS}d). ` +
`Using the default ${NEW_INSTALL_DEFAULT_EMBEDDING_MODEL} (${NEW_INSTALL_DEFAULT_EMBEDDING_DIMENSIONS}d). ` +
`Override with --embedding-model.`,
);
return;
@@ -620,7 +784,7 @@ async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boo
process.exit(1);
}
const { pickProvider } = await import('./init-provider-picker.ts');
const picked = await pickProvider({ touchpoint: 'embedding', env: process.env, isTTY: true });
const picked = await pickProvider({ touchpoint: 'embedding', env: effectiveEnv, isTTY: true });
if (!picked) {
// The embedding picker offers an explicit "0) none — continue keyless"
// option (and returns null on it). Honor that instead of aborting: a user
@@ -924,8 +1088,8 @@ function printResolvedAIChoice(
console.warn(' export ZEROENTROPY_API_KEY=...');
console.warn(' Or add to ~/.gbrain/config.json:');
console.warn(' "zeroentropy_api_key": "..."');
console.warn(' Or pick a different provider:');
console.warn(' gbrain init --pglite --embedding-model openai:text-embedding-3-large --embedding-dimensions 1536');
console.warn(' NOTE: ZeroEntropy shuts down 2026-09-04 — prefer the default instead:');
console.warn(' gbrain init --pglite --embedding-model voyage:voyage-4');
}
}
}
@@ -953,7 +1117,7 @@ async function initPGLite(opts: {
let resolvedModel: string | undefined;
if (opts.aiOpts?.noEmbedding) {
// D9 deferred-setup mode: skip preflight, no model/dim resolved.
console.log(` --no-embedding: deferred setup — configure with \`gbrain config set embedding_model <id>\` before import`);
console.log(` --no-embedding: deferred setup — enable later with \`gbrain init --force --embedding-model voyage:voyage-4\` (\`config set embedding_model\` is refused by design)`);
} else if (opts.aiOpts?.embedding_model) {
const { resolveSchemaEmbeddingDim } = await import('../core/embedding-dim-check.ts');
const pre = resolveSchemaEmbeddingDim({
@@ -981,9 +1145,20 @@ async function initPGLite(opts: {
// resolveAIOptions above: CLI flags > env vars > existing file > gateway
// defaults.
const { configureGateway } = await import('../core/ai/gateway.ts');
// v0.46.3: keyless fresh installs size the embedding column at the NEW-INSTALL
// width (1024), not the legacy configless fallback (1280) — the sizing is an
// explicit param here, NOT a rewire of the schema generators' legacy import
// (those also run on existing-brain reconnects, where legacy must stay
// legacy). Existing keyless brains are unaffected: initSchema never resizes
// an existing column, and the Lane B.5 mismatch guard stays off for keyless
// (resolvedDim is undefined).
const { NEW_INSTALL_DEFAULT_EMBEDDING_DIMENSIONS: newInstallDims } =
await import('../core/ai/defaults.ts');
configureGateway({
embedding_model: resolvedModel ?? opts.aiOpts?.embedding_model,
embedding_dimensions: resolvedDim ?? opts.aiOpts?.embedding_dimensions,
embedding_dimensions:
resolvedDim ?? opts.aiOpts?.embedding_dimensions ??
(opts.aiOpts?.noEmbedding ? newInstallDims : undefined),
expansion_model: opts.aiOpts?.expansion_model,
chat_model: opts.aiOpts?.chat_model,
env: { ...process.env },
@@ -1066,6 +1241,8 @@ async function initPGLite(opts: {
}
}
await writeNewInstallRerankerDefault(engine, resolvedModel);
// v0.37.10.0 T7 (D9) + v0.37.11.0 Lane B.4: atomic embedding-config
// persistence on top of the existing file-plane config (preserves
// user-set fields like zeroentropy_api_key, chat_model, expansion_model).
@@ -1092,6 +1269,14 @@ async function initPGLite(opts: {
// unless explicitly overridden by --schema-pack on re-init.
...(opts.schemaPack ? { schema_pack: opts.schemaPack } : {}),
};
// v0.46.3: leaving deferred-setup mode — a resolved (model, dims) tuple must
// also CLEAR a stale embedding_disabled sentinel inherited via the
// ...existingFile spread, or the documented recovery command
// (`init --force --embedding-model ...`) persists a config that still
// disables embedding at runtime.
if (!opts.aiOpts?.noEmbedding && resolvedModel && resolvedDim) {
delete config.embedding_disabled;
}
// PR1: new installs publish their skill catalog over MCP by default
// (existing config wins on re-init, so a prior opt-out is preserved).
config.mcp = { publish_skills: true, ...(config.mcp ?? {}) };
@@ -1211,7 +1396,7 @@ async function initPostgres(opts: {
let resolvedDim: number | undefined;
let resolvedModel: string | undefined;
if (opts.aiOpts?.noEmbedding) {
console.log(` --no-embedding: deferred setup — configure with \`gbrain config set embedding_model <id>\` before import`);
console.log(` --no-embedding: deferred setup — enable later with \`gbrain init --force --embedding-model voyage:voyage-4\` (\`config set embedding_model\` is refused by design)`);
} else if (opts.aiOpts?.embedding_model) {
const { resolveSchemaEmbeddingDim } = await import('../core/embedding-dim-check.ts');
const pre = resolveSchemaEmbeddingDim({
@@ -1231,9 +1416,15 @@ async function initPostgres(opts: {
// T6: unconditional configureGateway BEFORE initSchema.
const { configureGateway } = await import('../core/ai/gateway.ts');
// v0.46.3: keyless fresh installs size at the NEW-INSTALL width (see the
// PGLite path's comment — same explicit-param rationale).
const { NEW_INSTALL_DEFAULT_EMBEDDING_DIMENSIONS: newInstallDims } =
await import('../core/ai/defaults.ts');
configureGateway({
embedding_model: resolvedModel ?? opts.aiOpts?.embedding_model,
embedding_dimensions: resolvedDim ?? opts.aiOpts?.embedding_dimensions,
embedding_dimensions:
resolvedDim ?? opts.aiOpts?.embedding_dimensions ??
(opts.aiOpts?.noEmbedding ? newInstallDims : undefined),
expansion_model: opts.aiOpts?.expansion_model,
chat_model: opts.aiOpts?.chat_model,
env: { ...process.env },
@@ -1355,6 +1546,8 @@ async function initPostgres(opts: {
}
}
await writeNewInstallRerankerDefault(engine, resolvedModel);
// v0.37.10.0 T7 (D9) + v0.37.11.0 Lane B.4 (Postgres mirror): atomic
// embedding-config persistence on top of the existing file-plane config.
// Same precedence + same merge contract as the PGLite path above.
@@ -1375,6 +1568,14 @@ async function initPostgres(opts: {
// v0.42 (T17): same schema_pack default as PGLite path.
...(opts.schemaPack ? { schema_pack: opts.schemaPack } : {}),
};
// v0.46.3: leaving deferred-setup mode — a resolved (model, dims) tuple must
// also CLEAR a stale embedding_disabled sentinel inherited via the
// ...existingFile spread, or the documented recovery command
// (`init --force --embedding-model ...`) persists a config that still
// disables embedding at runtime.
if (!opts.aiOpts?.noEmbedding && resolvedModel && resolvedDim) {
delete config.embedding_disabled;
}
// PR1: new installs publish their skill catalog over MCP by default
// (existing config wins on re-init, so a prior opt-out is preserved).
config.mcp = { publish_skills: true, ...(config.mcp ?? {}) };
+6
View File
@@ -43,6 +43,7 @@ export interface MigrateEmbeddingsFlags {
json: boolean;
noEmbed: boolean;
ignoreEnvOverride: boolean;
forceSunsetTarget: boolean;
batchSize?: number;
pace?: ReturnType<typeof parsePaceArgs>;
}
@@ -62,6 +63,7 @@ export function parseMigrateEmbeddingsFlags(args: string[]): MigrateEmbeddingsFl
json: args.includes('--json'),
noEmbed: args.includes('--no-embed'),
ignoreEnvOverride: args.includes('--ignore-env-override'),
forceSunsetTarget: args.includes('--force-sunset-target'),
...(batchSize !== undefined && { batchSize }),
pace: parsePaceArgs(args),
};
@@ -89,6 +91,9 @@ Flags:
--pace[=mode] DB-contention pacing for the re-embed (off|gentle|balanced|aggressive).
--ignore-env-override Proceed even when GBRAIN_EMBEDDING_* env vars would
override the target at runtime (you know why).
--force-sunset-target Allow migrating ONTO a provider with an announced
shutdown (e.g. a self-hosted wire-compatible endpoint
behind a provider_base_urls override).
--help Show this help.
A killed run is resumable: re-run the same command. Already-migrated chunks
@@ -253,6 +258,7 @@ export async function runMigrateEmbeddings(
...(flags.dim !== undefined && { dim: flags.dim }),
...(fromModel !== undefined && { fromModel }),
...(fromDims !== undefined && { fromDims }),
...(flags.forceSunsetTarget && { allowSunsetTarget: true }),
});
} catch (e) {
serr(e instanceof Error ? e.message : String(e));
+2
View File
@@ -27,6 +27,7 @@ import { v0_29_1 } from './v0_29_1.ts';
import { v0_31_0 } from './v0_31_0.ts';
import { v0_32_2 } from './v0_32_2.ts';
import { v0_43_0 } from './v0_43_0.ts';
import { v0_46_3 } from './v0_46_3.ts';
export const migrations: Migration[] = [
v0_11_0,
@@ -45,6 +46,7 @@ export const migrations: Migration[] = [
v0_31_0,
v0_32_2,
v0_43_0,
v0_46_3,
];
/** Look up a migration by exact version string. */
+260
View File
@@ -0,0 +1,260 @@
/**
* v0.46.3 migration ZeroEntropy sunset notice (detect-and-notify ONLY).
*
* ZeroEntropy's hosted API shuts down on ZEROENTROPY_SUNSET_DATE (see
* src/core/ai/defaults.ts). This orchestrator:
*
* A. Detects the HOST brain's exposure via src/core/ze-exposure.ts
* (effective-model resolution env file legacy fallback; plus the
* resolved reranker and ZE-backed custom columns). Read-only.
* B. When exposed (or exposure is UNKNOWN fail-safe): prints the ACTION
* REQUIRED block and appends one idempotent pending-host-work entry
* pointing the host agent at skills/migrations/v0.46.3.0.md.
*
* It performs NO config writes, NO pinning, and NEVER invokes
* `migrate embeddings` (the migration costs money and needs a target key the
* user may not have that decision belongs to the user/agent via the
* playbook). The v0.46.3 split-default keeps existing brains fully working
* until the date; this migration is purely the loud, durable notification.
*
* UNKNOWN handling: returns `complete` with detail `exposure_unknown` NOT
* `partial`. Three consecutive partials would wedge the whole migration chain
* behind `--force-retry` (apply-migrations.ts); the stage-2 upgrade banner and
* `gbrain doctor` carry the ongoing nag instead.
*
* Host-scoped: apply-migrations runs once per host (global completed.jsonl).
* Mounted/team brains are covered by the per-brain stage-2 banner + doctor.
*
* NOTE: `apply-migrations --dry-run` exits before invoking orchestrators
* the `opts.dryRun` path here is exercised by tests, not by that CLI flag.
*/
import { existsSync, readFileSync, mkdirSync, appendFileSync } from 'fs';
import { join } from 'path';
import type {
Migration,
OrchestratorOpts,
OrchestratorResult,
OrchestratorPhaseResult,
} from './types.ts';
import { loadConfig, loadConfigFileOnly, toEngineConfig, gbrainPath } from '../../core/config.ts';
import { ZEROENTROPY_SUNSET_DATE } from '../../core/ai/defaults.ts';
import { createEngine } from '../../core/engine-factory.ts';
import type { BrainEngine } from '../../core/engine.ts';
const MIGRATION_VERSION = '0.46.3';
const PLAYBOOK_SKILL = 'skills/migrations/v0.46.3.0.md';
function pendingHostWorkDir(): string { return gbrainPath('migrations'); }
function pendingHostWorkPath(): string { return join(pendingHostWorkDir(), 'pending-host-work.jsonl'); }
interface PendingHostWorkEntry {
migration: string;
ts: string;
skill: string;
reason: string;
}
function existingEntryForVersion(version: string): boolean {
const p = pendingHostWorkPath();
if (!existsSync(p)) return false;
try {
const raw = readFileSync(p, 'utf-8');
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed) as PendingHostWorkEntry;
if (obj.migration === version) return true;
} catch { /* skip malformed */ }
}
} catch { /* read error */ }
return false;
}
function emitHostWork(reason: string): OrchestratorPhaseResult {
try {
if (existingEntryForVersion(MIGRATION_VERSION)) {
return { name: 'host-work', status: 'skipped', detail: 'already recorded' };
}
mkdirSync(pendingHostWorkDir(), { recursive: true });
const entry: PendingHostWorkEntry = {
migration: MIGRATION_VERSION,
ts: new Date().toISOString(),
skill: PLAYBOOK_SKILL,
reason,
};
// Torn-write guard: a prior crashed writer can leave the file without a
// trailing newline; appending directly would concatenate onto the torn
// line, producing one unparseable line that existingEntryForVersion skips
// forever — losing THE deliverable of this migration. Ensure separation.
let prefix = '';
if (existsSync(pendingHostWorkPath())) {
const raw = readFileSync(pendingHostWorkPath(), 'utf-8');
if (raw.length > 0 && !raw.endsWith('\n')) prefix = '\n';
}
appendFileSync(pendingHostWorkPath(), prefix + JSON.stringify(entry) + '\n');
return { name: 'host-work', status: 'complete', detail: pendingHostWorkPath() };
} catch (e) {
return {
name: 'host-work',
status: 'failed',
detail: e instanceof Error ? e.message : String(e),
};
}
}
async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult> {
const phases: OrchestratorPhaseResult[] = [];
const config = loadConfig();
const fileCfg = loadConfigFileOnly();
if (!config && !fileCfg) {
// No gbrain install on this host — nothing to notify about.
phases.push({ name: 'detect', status: 'skipped', detail: 'no brain configured' });
return { version: MIGRATION_VERSION, status: 'complete', phases };
}
let engine: BrainEngine | null = null;
let exposure: import('../../core/ze-exposure.ts').ZeExposure | null = null;
try {
const { detectZeExposure } = await import('../../core/ze-exposure.ts');
if (config) {
try {
engine = await createEngine(toEngineConfig(config));
await engine.connect(toEngineConfig(config));
} catch {
engine = null; // DB probes will fail → tri-state handles it below.
}
}
if (engine) {
exposure = await detectZeExposure(engine, fileCfg);
phases.push({
name: 'detect',
status: 'complete',
detail:
exposure.status === 'unknown'
? `exposure_unknown (failed probes: ${exposure.unknownProbes.join(', ')})`
: exposure.status,
});
} else {
// Config exists but the brain is unreachable: resolution-based exposure
// still works from the file plane alone; DB-backed probes are unknown.
// getConfig THROWS (not null): returning null would fabricate verified
// claims from a DB we never reached — "no ZE custom columns" and "no
// reranker override" would read as checked when they weren't. Throwing
// routes those probes into unknownProbes (honest tri-state).
const fakeEngine = {
getConfig: async () => {
throw new Error('brain unreachable');
},
executeRaw: async () => {
throw new Error('brain unreachable');
},
} as unknown as BrainEngine;
exposure = await detectZeExposure(fakeEngine, fileCfg);
// Force fail-safe posture: probes could not run.
if (exposure.status === 'clear') {
exposure = { ...exposure, status: 'unknown', unknownProbes: ['engine_connect'] };
}
phases.push({
name: 'detect',
status: 'complete',
detail: `brain unreachable — ${exposure.status}`,
});
}
} catch (e) {
phases.push({
name: 'detect',
status: 'complete',
detail: `exposure_unknown (${e instanceof Error ? e.message : String(e)})`,
});
exposure = null;
} finally {
try { await engine?.disconnect(); } catch { /* best-effort */ }
}
const status = exposure?.status ?? 'unknown';
if (status === 'clear') {
// Unexposed brains complete as a no-op — no nag, no host work.
return { version: MIGRATION_VERSION, status: 'complete', phases };
}
// Exposed OR unknown → print the notice + emit host work (fail-safe: when
// we can't prove the brain is clear, nag rather than stay silent).
if (exposure) {
const { renderZeActionRequired } = await import('../../core/ze-exposure.ts');
const banner = [
'',
'='.repeat(74),
'ACTION REQUIRED — ZeroEntropy shutdown (v0.46.3 migration notice)',
'='.repeat(74),
renderZeActionRequired(exposure),
'='.repeat(74),
'',
].join('\n');
// eslint-disable-next-line no-console
console.error(banner);
} else {
// Detection itself crashed (exposure === null): still print a loud,
// self-contained advisory — the host-work entry alone is silent until an
// agent reads it, and the whole point of this migration is the notice.
// eslint-disable-next-line no-console
console.error(
[
'',
'='.repeat(74),
'ACTION REQUIRED — ZeroEntropy shutdown (v0.46.3 migration notice)',
'='.repeat(74),
'Exposure detection failed on this host, so this brain is treated as',
`affected until proven otherwise. ZeroEntropy stops working on ${ZEROENTROPY_SUNSET_DATE}.`,
'Run `gbrain doctor` (provider_sunset) and see the agent playbook:',
` ${PLAYBOOK_SKILL}`,
'='.repeat(74),
'',
].join('\n'),
);
}
if (opts.dryRun) {
phases.push({ name: 'host-work', status: 'skipped', detail: 'dry-run' });
return { version: MIGRATION_VERSION, status: 'complete', phases };
}
const hostWork = emitHostWork(
`ZeroEntropy hosted API sunset — embedding/reranker migration required (status: ${status})`,
);
phases.push(hostWork);
// A FAILED host-work write (unwritable ~/.gbrain, full disk) is a different
// class from `exposure_unknown`: the durable action item — this migration's
// entire deliverable — was not recorded. Return `partial` so apply-migrations
// retries on the next run and converges once writable; a persistently
// unwritable dir earns the loud three-partial WEDGED warning. (`skipped` =
// already recorded = fine.)
const failedWrite = hostWork.status === 'failed';
return {
version: MIGRATION_VERSION,
status: failedWrite ? 'partial' : 'complete',
phases,
pending_host_work: hostWork.status === 'complete' ? 1 : 0,
};
}
export const v0_46_3: Migration = {
version: MIGRATION_VERSION,
featurePitch: {
headline:
'ZeroEntropy is shutting down 2026-09-04 — brains embedding or reranking with it must switch. New default: Voyage (voyage-4 + rerank-2.5, one key).',
description:
'Nothing changes automatically: existing brains keep working until the shutdown date. ' +
'This migration detects whether your brain still resolves to ZeroEntropy (embedding, reranker, or custom columns) ' +
'and, if so, records an action item for your agent at skills/migrations/v0.46.3.0.md. ' +
'The one-command fix: `gbrain migrate embeddings --to voyage:voyage-4 --dim 1024 --dry-run` (cost preview), then `--yes`. ' +
'OpenAI alternative can keep column widths up to 1536 (e.g. a 1280d brain: `--to openai:text-embedding-3-small --dim 1280`); `gbrain doctor` prints this brain\'s exact width-aware command. ' +
'Reranker: `gbrain config set search.reranker.model voyage:rerank-2.5`.',
},
orchestrator,
};
+1 -1
View File
@@ -451,7 +451,7 @@ async function probeRerankerConfig(engine: BrainEngine): Promise<ProbeResult> {
touchpoint: 'reranker_config',
status: 'config',
message: `Provider "${recipe.id}" does not declare a reranker touchpoint.`,
fix: 'Switch to a provider that does (e.g. zeroentropyai:zerank-2).',
fix: 'Switch to a provider that does (e.g. voyage:rerank-2.5).',
elapsed_ms: Date.now() - start,
};
}
+46 -15
View File
@@ -11,6 +11,7 @@ import { buildGatewayConfig } from '../core/ai/build-gateway-config.ts';
import { probeOllama, probeLMStudio } from '../core/ai/probes.ts';
import { loadConfig } from '../core/config.ts';
import { AIConfigError, AITransientError } from '../core/ai/errors.ts';
import { lookupEmbeddingPrice } from '../core/embedding-pricing.ts';
import type { Recipe } from '../core/ai/types.ts';
const SCHEMA_VERSION = 1;
@@ -30,6 +31,9 @@ interface ProviderOption {
tier: 'native' | 'openai-compat';
pros: string[];
cons: string[];
/** v0.46.3: set when the provider's hosted API has an announced shutdown
* (recipe.sunset) agent-facing consumers must not steer users here. */
deprecated?: { date: string; replacement?: string };
}
function configureFromEnv(): void {
@@ -80,7 +84,14 @@ export function formatRecipeTable(recipes: Recipe[], env: NodeJS.ProcessEnv = pr
const hasExpand = !!r.touchpoints.expansion;
const hasChat = !!r.touchpoints.chat && r.touchpoints.chat.models.length > 0;
const ready = envReady(r, env);
const status = ready ? '✓ ready' : `✗ missing ${r.auth_env?.required?.[0] ?? 'setup'}`;
// v0.46.3: a sunsetting provider is flagged in the listing regardless of
// key readiness — "ready" on a dying API is not a state to advertise.
const status = r.sunset
? `⚠ DEPRECATED — hosted API ends ${r.sunset.date}` +
(r.sunset.replacement?.embedding ? `; use ${r.sunset.replacement.embedding}` : '')
: ready
? '✓ ready'
: `✗ missing ${r.auth_env?.required?.[0] ?? 'setup'}`;
rows.push(
r.id.padEnd(idCol) +
r.tier.padEnd(18) +
@@ -325,17 +336,35 @@ async function runExplain(args: string[]): Promise<void> {
for (const r of recipes) {
if (r.touchpoints.embedding && r.touchpoints.embedding.models.length > 0) {
const m = r.touchpoints.embedding;
// v0.46.3: canonical model, not array position (Voyage lists voyage-4-large
// first; its canonical default is voyage-4).
const canonicalModel = m.default_model ?? m.models[0];
// Price the CANONICAL model, not the recipe-wide touchpoint hint — the
// touchpoint cost tracks models[0], which can differ from the canonical
// pick (voyage-4 is $0.06/M; the recipe-wide hint reflects the flagship).
const modelPrice = lookupEmbeddingPrice(`${r.id}:${canonicalModel}`);
options.push({
id: `${r.id}:${m.models[0]}`,
id: `${r.id}:${canonicalModel}`,
touchpoint: 'embedding',
model: m.models[0],
model: canonicalModel,
dims: m.default_dims,
cost_per_1m_tokens_usd: m.cost_per_1m_tokens_usd,
cost_per_1m_tokens_usd:
modelPrice.kind === 'known' ? modelPrice.pricePerMTok : m.cost_per_1m_tokens_usd,
price_last_verified: m.price_last_verified,
env_ready: envReady(r) || (r.id === 'ollama' && ollama.models_endpoint_valid === true),
tier: r.tier,
pros: prosFor(r, 'embedding'),
cons: consFor(r),
cons: r.sunset
? [...consFor(r), `DEPRECATED — hosted API ends ${r.sunset.date}`]
: consFor(r),
...(r.sunset
? {
deprecated: {
date: r.sunset.date,
replacement: r.sunset.replacement?.embedding,
},
}
: {}),
});
}
if (r.touchpoints.expansion) {
@@ -452,11 +481,17 @@ function consFor(r: Recipe): string[] {
}
function pickRecommended(options: ProviderOption[], env: Record<string, boolean>, ollamaReady: boolean): { id: string; reason: string } {
// Embedding recommendation: prefer env-ready native providers in this order.
const embOpts = options.filter(o => o.touchpoint === 'embedding');
// Embedding recommendation: prefer env-ready providers in canonical order
// Voyage first (the v0.46.3 new-install default: one key covers embedding +
// rerank-2.5 + multimodal). Never recommend a sunsetting provider.
const embOpts = options.filter(o => o.touchpoint === 'embedding' && !o.deprecated);
if (env.VOYAGE_API_KEY) {
const voyage = embOpts.find(o => o.id.startsWith('voyage:'));
if (voyage) return { id: voyage.id, reason: 'VOYAGE_API_KEY set — the default: voyage-4 at 1024 dims; the same key powers the rerank-2.5 reranker and the multimodal model.' };
}
if (env.OPENAI_API_KEY) {
const openai = embOpts.find(o => o.id.startsWith('openai:'));
if (openai) return { id: openai.id, reason: 'OPENAI_API_KEY set — OpenAI default is high-quality and preserves existing 1536-dim schema.' };
if (openai) return { id: openai.id, reason: 'OPENAI_API_KEY set — high-quality and preserves an existing 1536-dim schema.' };
}
if (ollamaReady) {
const ollama = embOpts.find(o => o.id.startsWith('ollama:'));
@@ -466,13 +501,9 @@ function pickRecommended(options: ProviderOption[], env: Record<string, boolean>
const google = embOpts.find(o => o.id.startsWith('google:'));
if (google) return { id: google.id, reason: 'GOOGLE_GENERATIVE_AI_API_KEY set — Gemini embedding at 768 dims.' };
}
if (env.VOYAGE_API_KEY) {
const voyage = embOpts.find(o => o.id.startsWith('voyage:'));
if (voyage) return { id: voyage.id, reason: 'VOYAGE_API_KEY set — Voyage at 1024 dims.' };
}
// Nothing ready. Recommend OpenAI as the lowest-friction path.
// Nothing ready. Recommend the canonical default as the setup path.
return {
id: 'openai:text-embedding-3-large',
reason: 'No provider env detected. OpenAI is the fastest setup — get a key at https://platform.openai.com/api-keys.',
id: 'voyage:voyage-4',
reason: 'No provider env detected. Voyage is the default — get a key at https://dash.voyageai.com/api-keys (one key also powers reranking + multimodal).',
};
}
+2 -2
View File
@@ -303,8 +303,8 @@ Optional:
--json Emit structured JSON output on stdout.
Examples:
# Switch from OpenAI/1536 to ZeroEntropy/1280:
gbrain reinit-pglite --embedding-model zeroentropyai:zembed-1 --embedding-dimensions 1280
# Switch from OpenAI/1536 to Voyage/1024:
gbrain reinit-pglite --embedding-model voyage:voyage-4 --embedding-dimensions 1024
# Skip the sync step (do it later):
gbrain reinit-pglite --embedding-model openai:text-embedding-3-large \\
+1 -1
View File
@@ -48,7 +48,7 @@ const KNOB_DESCRIPTIONS: Record<keyof ModeBundle, string> = {
tokenBudget: 'Per-call token-budget cap (undefined = no cap)',
expansion: 'LLM multi-query expansion (Haiku call per search)',
searchLimit: 'Default `limit` for the operation layer',
reranker_enabled: 'Cross-encoder reranker (ZE zerank-2) on/off',
reranker_enabled: 'Cross-encoder reranker on/off',
reranker_model: 'Provider:model for the reranker',
reranker_top_n_in: 'Candidates sent to reranker per call',
reranker_top_n_out: 'Cap on reranked output (null = no truncate)',
+42 -4
View File
@@ -515,10 +515,11 @@ export async function runPostUpgrade(args: string[] = []): Promise<void> {
console.log('');
console.log('Two fixes, either works:');
console.log('');
console.log('[1] Self-host the same model — zembed-1 weights are Apache-2.0. Serve');
console.log(' them via llama-server or Ollama and point the config at the local');
console.log(' endpoint. Keeps every existing vector; NO re-embed at all. See');
console.log(' docs/guides/embedding-migration.md ("Self-hosting instead of migrating").');
console.log('[1] Self-host the same model — zembed-1 weights are Apache-2.0. Keep the');
console.log(' zeroentropyai:zembed-1 id and point provider_base_urls.zeroentropyai');
console.log(' at a ZE-wire-compatible endpoint (NOT a generic OpenAI-compatible');
console.log(' server — the id speaks ZE\'s /models/embed dialect). Keeps every');
console.log(' vector; NO re-embed. See docs/guides/embedding-migration.md.');
console.log('');
console.log('[2] Migrate to another provider (resumable; preview cost first):');
console.log(` gbrain migrate embeddings --to <provider:model>${dimFlag} --dry-run`);
@@ -541,6 +542,43 @@ export async function runPostUpgrade(args: string[] = []): Promise<void> {
// Banner is cosmetic; never block the upgrade.
}
// v0.46.3: stage-2 sunset notice, gated by `ze_sunset_notice_v2_shown`.
// Fires even for brains that saw stage 1 — this release ships the
// migration playbook + the Voyage default, and the exposure story
// widened (custom columns, env overrides, reranker-without-key).
// The gate opens on `exposed` and on `unknown` (fail-safe: nag rather
// than stay silent). Reranker-only exposure is COVERED by `exposed`:
// detectZeExposure sets status='exposed' whenever the resolved
// reranker is ZE (including the legacy bundle default), so an
// OpenAI/Voyage-embedding brain whose reranking rides the ZE bundle
// still opens the gate, and renderZeActionRequired includes the
// reranker paragraph.
try {
const shownV2 = await engine.getConfig('ze_sunset_notice_v2_shown');
if (shownV2 !== 'true') {
const { detectZeExposure, renderZeActionRequired } =
await import('../core/ze-exposure.ts');
const exposure = await detectZeExposure(engine);
if (exposure.status === 'exposed' || exposure.status === 'unknown') {
console.log('');
console.log('═══════════════════════════════════════════════════════════════');
console.log('[gbrain] ACTION REQUIRED — ZeroEntropy shutdown (v0.46.3 notice)');
console.log('═══════════════════════════════════════════════════════════════');
for (const line of renderZeActionRequired(exposure).split('\n')) {
console.log(`[gbrain] ${line}`);
}
console.log('[AGENT] Relay this to your operator: this brain needs an embedding/');
console.log('[AGENT] reranker migration before the shutdown date. Playbook:');
console.log('[AGENT] skills/migrations/v0.46.3.0.md');
console.log('═══════════════════════════════════════════════════════════════');
console.log('');
await engine.setConfig('ze_sunset_notice_v2_shown', 'true');
}
}
} catch {
// Banner is cosmetic; never block the upgrade.
}
// PR1: skill-catalog publish consent. New installs default ON at
// `gbrain init`; EXISTING installs stay OFF (default-OFF runtime = no
// silent capability grant on upgrade) until the owner opts in HERE.
+23
View File
@@ -110,6 +110,29 @@ export async function runZeSwitch(args: string[], engine: BrainEngine): Promise<
const flags = parseFlags(args);
// v0.46.3: ZeroEntropy is shutting down. Switching a brain ONTO it — including
// resuming a half-applied forward switch — is disabled; only --undo (which
// moves a brain OFF it) and --dry-run (read-only plan) still run. The whole
// command is deleted in the September removal release.
if (!flags.undo && !flags.dryRun) {
const {
ZEROENTROPY_SUNSET_DATE,
NEW_INSTALL_DEFAULT_EMBEDDING_MODEL,
NEW_INSTALL_DEFAULT_EMBEDDING_DIMENSIONS,
} = await import('../core/ai/defaults.ts');
const msg =
`ze-switch is disabled: ZeroEntropy shuts down its hosted API on ${ZEROENTROPY_SUNSET_DATE}.\n` +
'Switching onto it (or resuming a half-applied switch) would strand this brain.\n' +
`To LEAVE ZeroEntropy: gbrain migrate embeddings --to ${NEW_INSTALL_DEFAULT_EMBEDDING_MODEL} --dim ${NEW_INSTALL_DEFAULT_EMBEDDING_DIMENSIONS} --dry-run\n` +
'To undo a prior switch: gbrain ze-switch --undo';
if (flags.json) {
console.log(JSON.stringify({ status: 'refused', reason: 'provider_sunset', message: msg }));
} else {
console.error(msg);
}
process.exit(1);
}
try {
// --dry-run: just plan, never apply.
if (flags.dryRun) {
+44 -14
View File
@@ -25,28 +25,58 @@ export const collectSetupSmells: AdvisorCollector = {
const findings: AdvisorFinding[] = [];
const cfg = ctx.config ?? ({} as typeof ctx.config);
// Embeddings disabled — deferred setup never completed.
// Embeddings disabled — deferred setup never completed. No command_argv:
// `config set embedding_model` is hard-refused (schema-sizing file-plane
// key); the sanctioned path is a re-init.
if (cfg.embedding_disabled === true) {
findings.push({
id: 'embeddings_disabled',
severity: 'warn',
title: 'Embeddings are disabled — semantic search and dedup are off.',
detail: 'Set an embedding model to turn on vector search.',
fix: { command_argv: ['gbrain', 'config', 'set', 'embedding_model', '<model-id>'] },
collector: 'setup-smells',
ask_user: true,
});
} else if (!cfg.embedding_model && !cfg.zeroentropy_api_key && !process.env.ZEROENTROPY_API_KEY) {
// Default provider needs a key; none present anywhere → embeds will fail.
findings.push({
id: 'embedding_key_missing',
severity: 'warn',
title: 'No embedding provider key is set — embedding will fail at write time.',
detail: 'Set zeroentropy_api_key (or choose another provider via embedding_model).',
fix: { command_argv: ['gbrain', 'config', 'set', 'zeroentropy_api_key', '<key>'] },
detail:
'Enable with `gbrain init --force --embedding-model voyage:voyage-4` ' +
'(set VOYAGE_API_KEY first).',
fix: { command_argv: null },
collector: 'setup-smells',
ask_user: true,
});
} else {
// v0.46.3: key the "will embeds work" claim on the EFFECTIVE model —
// configless brains resolve the legacy runtime fallback until the
// September cutover, so the credential that matters is that provider's,
// not the recommended default's. Both key planes are checked (env +
// file config; the DB plane is never read by the embed pipeline).
const { DEFAULT_EMBEDDING_MODEL } = await import('../ai/defaults.ts');
const { getRecipe } = await import('../ai/recipes/index.ts');
const effectiveModel = cfg.embedding_model ?? DEFAULT_EMBEDDING_MODEL;
const provider = effectiveModel.split(':')[0];
const recipe = getRecipe(provider);
const keyName = recipe?.auth_env?.required?.[0];
const fileKeys: Record<string, string | undefined> = {
OPENAI_API_KEY: cfg.openai_api_key,
VOYAGE_API_KEY: cfg.voyage_api_key,
ZEROENTROPY_API_KEY: cfg.zeroentropy_api_key,
};
const keyMissing = !!keyName && !process.env[keyName] && !fileKeys[keyName];
if (keyMissing) {
const sunsetNote = recipe?.sunset
? ` NOTE: ${recipe.name} shuts down ${recipe.sunset.date} — migrate instead of ` +
`setting its key: \`gbrain migrate embeddings --to ` +
`${recipe.sunset.replacement?.embedding ?? 'voyage:voyage-4'} --dry-run\`.`
: '';
findings.push({
id: 'embedding_key_missing',
severity: 'warn',
title: `Embedding resolves to ${effectiveModel} but ${keyName} is not set — embedding will fail at write time.`,
detail:
`Set ${keyName} in the environment (or add it to ~/.gbrain/config.json).` +
sunsetNote +
' To switch providers: `gbrain init --force --embedding-model voyage:voyage-4` with VOYAGE_API_KEY set.',
fix: { command_argv: null },
collector: 'setup-smells',
ask_user: true,
});
}
}
// Remote-MCP brain serving agents but skill publishing is off → agents hit
+44 -5
View File
@@ -12,14 +12,53 @@
* install AND every doctor consistency check.
*/
// v0.36.0 chose ZeroEntropy as the system default after evals showed
// 11/20 wins vs OpenAI (6) and Voyage (4) on real-corpus benchmarks.
// 1280 is the closest analog to legacy OpenAI 1536d while staying on
// the high-recall section of ZE's Matryoshka curve. Valid ZE Matryoshka
// steps: {2560, 1280, 640, 320, 160, 80, 40} — see ai/dims.ts.
// LEGACY CONFIGLESS RUNTIME FALLBACK — not the new-install default anymore.
//
// v0.36.0 chose ZeroEntropy as the system default (11/20 eval wins vs OpenAI
// and Voyage). ZeroEntropy's hosted API shuts down on ZEROENTROPY_SUNSET_DATE,
// so v0.46.3 split the default: these two constants now serve ONLY brains with
// no `embedding_model` in file config (old/hand-rolled installs whose stored
// vectors live in ZE's 1280d space — flipping this under them would break
// retrieval BEFORE the provider itself dies). Every new-install surface reads
// NEW_INSTALL_DEFAULT_* below. The September removal release deletes this
// fallback and hard-errors unmigrated configless brains with the migrate
// command. Do NOT point anything new at these.
export const DEFAULT_EMBEDDING_MODEL = 'zeroentropyai:zembed-1';
export const DEFAULT_EMBEDDING_DIMENSIONS = 1280;
// NEW-INSTALL DEFAULT (v0.46.3): voyage-4 @ 1024d.
//
// Why Voyage: ZeroEntropy covered BOTH gbrain touchpoints (embedding +
// reranking); Voyage is the only replacement that covers both on one key
// (rerank-2.5 rides VOYAGE_API_KEY; OpenAI has no reranker API), the
// multimodal model is already voyage:voyage-multimodal-3, and the voyage-4
// family is the current hosted retrieval SOTA. Why voyage-4 (not -large/-lite):
// $0.06/M ≈ zembed-1's $0.05/M, and the v4 trio SHARES ONE EMBEDDING SPACE —
// a brain indexed with voyage-4 can later point its query model at
// voyage-4-large or -lite with no reindex, so the within-family choice is
// reversible. 1024 is a valid Voyage Matryoshka step {256, 512, 1024, 2048}
// and matches the embedding_image/embedding_multimodal widths.
//
// Consumers (new-install surfaces ONLY): init auto-pick canonical tiebreak,
// the interactive picker default, the no-keys hint, keyless fresh-install
// schema sizing (passed as an explicit init param — the schema generators keep
// importing the legacy constants for existing-brain reconnects), and all
// recommendation copy (playbook, banners, doctor fix-hints, advisor).
export const NEW_INSTALL_DEFAULT_EMBEDDING_MODEL = 'voyage:voyage-4';
export const NEW_INSTALL_DEFAULT_EMBEDDING_DIMENSIONS = 1024;
/**
* Recommended reranker replacement (v0.46.3). The runtime reranker defaults
* (gateway DEFAULT_RERANKER_MODEL + the mode-bundle reranker_model values)
* stay on zerank-2 until the September removal so existing ZE-keyed brains
* keep their working reranker until the API actually dies; init writes this
* as an explicit `search.reranker.model` config for voyage-keyed installs
* (any picked embedding provider; keyed non-voyage installs get explicit
* `search.reranker.enabled false` instead), and the migration playbook sets
* it for migrating users.
*/
export const NEW_INSTALL_DEFAULT_RERANKER_MODEL = 'voyage:rerank-2.5';
/**
* ZeroEntropy announced (2026-07-24) that its hosted API including
* /models/embed and /models/rerank shuts down on this date. Query
+3
View File
@@ -24,6 +24,9 @@ const VOYAGE_OUTPUT_DIMENSION_MODELS = new Set([
'voyage-4-large',
'voyage-4',
'voyage-4-lite',
// voyage-code-4: hosted, flexible dims 256/512/1024/2048 per Voyage's
// embeddings docs (verified 2026-08-15).
'voyage-code-4',
'voyage-3-large',
'voyage-3.5',
'voyage-3.5-lite',
+75 -19
View File
@@ -98,18 +98,12 @@ function withDefaultTimeout(caller: AbortSignal | undefined, timeoutMs: number):
}
const MAX_CHARS = 8000;
// v0.36.0.0 (D3 + D4): ZeroEntropy zembed-1 at 1280d via Matryoshka is the
// new default for embedding. Real-corpus benchmark across 20 queries:
// - ZE wins 11/20 (OpenAI 6, Voyage 4)
// - 442ms avg vs OpenAI 973ms (2.2x faster)
// - $0.05/M tokens vs OpenAI $0.13/M (2.6x cheaper at regular pricing)
// ZE valid Matryoshka steps are {2560, 1280, 640, 320, 160, 80, 40}; 1280 is
// the closest analog to current OpenAI 1536d (smaller -> smaller HNSW index
// -> faster queries) while staying in the high-recall zone of the Matryoshka
// curve. 1024 (Voyage's step) is NOT a valid ZE dim — see
// src/core/ai/dims.ts:ZEROENTROPY_VALID_DIMS.
// New installs without ZEROENTROPY_API_KEY size for 1280d anyway — the
// AIConfigError surfaces at first embed with a paste-ready setup hint.
// v0.46.3 SPLIT-DEFAULT: DEFAULT_EMBEDDING_MODEL / DEFAULT_EMBEDDING_DIMENSIONS
// are now the LEGACY CONFIGLESS RUNTIME FALLBACK only (brains with no
// `embedding_model` in file config, whose stored vectors live in ZE's 1280d
// space). ZeroEntropy's hosted API shuts down on ZEROENTROPY_SUNSET_DATE; the
// September removal release deletes this fallback. Every NEW-INSTALL surface
// reads NEW_INSTALL_DEFAULT_* instead — full rationale in ./defaults.ts.
// Re-exported from the leaf `defaults.ts` so heavy schema/registry modules
// don't transitively load every provider SDK just to read the defaults.
export { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } from './defaults.ts';
@@ -119,6 +113,11 @@ const DEFAULT_CHAT_MODEL = 'anthropic:claude-sonnet-4-6';
// v0.35.0.0+: reranker default. Used only when search.reranker.enabled is set
// AND no explicit reranker_model is configured. Mode bundles' per-mode
// `reranker_model` default to this same value but can be overridden.
// v0.46.3: stays on the LEGACY zerank-2 until the September removal (reranker
// split-default: existing ZE-keyed brains keep their working reranker until
// the API dies; voyage-keyed NEW installs get an explicit
// `search.reranker.model voyage:rerank-2.5` override written at init, and
// keyed non-voyage installs get explicit `search.reranker.enabled false`).
const DEFAULT_RERANKER_MODEL = 'zeroentropyai:zerank-2';
let _config: AIGatewayConfig | null = null;
@@ -1417,9 +1416,54 @@ export const perplexityCompatFetch = (async (input: RequestInfo | URL, init?: Re
}
}) as unknown as typeof fetch;
/**
* v0.46.3 once-per-(recipe,touchpoint) sunset warning. Fires when a recipe with
* `sunset` metadata is actually USED (embedding resolution / rerank call), so
* brains still riding a dying provider hear about it on every process, not
* only at upgrade time. Module-level memoization (same pattern as
* storage-config.ts's deprecation warn); `_resetSunsetWarningsForTest()` is
* the test seam. Never throws a warning must not take down an embed.
*/
const _sunsetWarned = new Set<string>();
export function _resetSunsetWarningsForTest(): void {
_sunsetWarned.clear();
}
function warnSunsetOnce(recipe: Recipe, touchpoint: 'embedding' | 'reranker'): void {
try {
const sunset = recipe.sunset;
if (!sunset) return;
// A base-URL override routes this provider id to a user-supplied endpoint
// (typically a self-hosted wire-compatible server) — the HOSTED shutdown
// doesn't apply, so a per-call deprecation warning would be a false
// positive. The removal-release continuity story is carried by the
// migration notice/banner instead.
if (_config?.base_urls?.[recipe.id]) return;
const key = `${recipe.id}:${touchpoint}`;
if (_sunsetWarned.has(key)) return;
_sunsetWarned.add(key);
const replacement =
touchpoint === 'embedding' ? sunset.replacement?.embedding : sunset.replacement?.reranker;
const fix =
touchpoint === 'embedding'
? replacement
? ` Migrate: \`gbrain migrate embeddings --to ${replacement} --dry-run\``
: ''
: replacement
? ` Switch: \`gbrain config set search.reranker.model ${replacement}\``
: '';
process.stderr.write(
`[gbrain] DEPRECATED: ${recipe.name} ${touchpoint} stops working on ` +
`${sunset.date}.${sunset.message ? ` ${sunset.message}` : ''}${fix}\n`,
);
} catch {
// Cosmetic; never block the call path.
}
}
async function resolveEmbeddingProvider(modelStr: string): Promise<{ model: any; recipe: Recipe; modelId: string }> {
const { parsed, recipe } = resolveRecipe(modelStr);
assertTouchpoint(recipe, 'embedding', parsed.modelId);
warnSunsetOnce(recipe, 'embedding');
const cfg = requireConfig();
const cacheKey = `emb:${recipe.id}:${parsed.modelId}:${cfg.base_urls?.[recipe.id] ?? ''}`;
@@ -3907,15 +3951,17 @@ export async function rerank(input: RerankInput): Promise<RerankResult[]> {
'unknown',
);
}
warnSunsetOnce(recipe, 'reranker');
// Resolve base URL + auth from the recipe (same path Voyage/ZE embeddings use).
const cfg = requireConfig();
const compat = applyOpenAICompatConfig(recipe, cfg);
// v0.40.6.1: rerank URL path is recipe-pluggable. Defaults to ZeroEntropy's
// legacy `/models/rerank`; openai-style providers like llama.cpp's
// llama-server set `/v1/rerank`. Wire shape is unchanged — any provider
// whose request/response shape differs from ZE/llama.cpp (e.g. Voyage with
// `top_k` / `data[]`) needs separate adapter hooks in a follow-up plan.
// llama-server set `/v1/rerank`; Voyage sets `/rerank`. Response shape is
// shared across all current dialects ({results: [{index, relevance_score}]});
// the only request-side difference is the top-N key, declared per recipe via
// `top_param` (v0.46.3).
const url = `${compat.baseURL.replace(/\/$/, '')}${tp.path ?? '/models/rerank'}`;
let auth: { apiKey?: string; headers?: Record<string, string> };
try {
@@ -3937,7 +3983,7 @@ export async function rerank(input: RerankInput): Promise<RerankResult[]> {
model: parsed.modelId,
query: input.query,
documents: input.documents,
...(input.topN !== undefined ? { top_n: input.topN } : {}),
...(input.topN !== undefined ? { [tp.top_param ?? 'top_n']: input.topN } : {}),
});
// Pre-flight payload size guard (CDX1-F17 / plan Phase 3 cost guard). The
@@ -4009,10 +4055,20 @@ export async function rerank(input: RerankInput): Promise<RerankResult[]> {
throw new RerankError(msg, reason, resp.status);
}
const json: any = await resp.json();
if (!json || !Array.isArray(json.results)) {
throw new RerankError('rerank: malformed response (no results array)', 'unknown');
// v0.46.3: two response dialects share the item shape {index,
// relevance_score} but differ in the array key — ZE/llama-server return
// `results[]`, Voyage's REST returns `data[]` ({object: "list", data:
// [...]}, live-wire verified 2026-08-15; Voyage's Python SDK renames it
// `results`, which is why docs-level checks get this wrong). Accept both.
const items: any[] | null = Array.isArray(json?.results)
? json.results
: Array.isArray(json?.data)
? json.data
: null;
if (!items) {
throw new RerankError('rerank: malformed response (no results/data array)', 'unknown');
}
const mapped = json.results.map((r: any) => ({
const mapped = items.map((r: any) => ({
index: typeof r.index === 'number' ? r.index : 0,
relevanceScore: typeof r.relevance_score === 'number' ? r.relevance_score : 0,
}));
+27
View File
@@ -32,10 +32,16 @@ export const voyage: Recipe = {
embedding: {
models: [
'voyage-4-large', 'voyage-4', 'voyage-4-lite', 'voyage-4-nano',
'voyage-code-4',
'voyage-3.5', 'voyage-3-large', 'voyage-3', 'voyage-3-lite',
'voyage-code-3', 'voyage-finance-2', 'voyage-law-2',
'voyage-multimodal-3',
],
// v0.46.3: canonical pick for every "choose a model for the user" surface.
// models[0] is voyage-4-large (quality order); the new-install default is
// voyage-4 (price/quality balance, shared v4 embedding space) — see
// NEW_INSTALL_DEFAULT_EMBEDDING_MODEL in ai/defaults.ts.
default_model: 'voyage-4',
default_dims: 1024,
// Display hint for `gbrain providers` only (billing math goes through
// src/core/embedding-pricing.ts). Rate for the default voyage-4-large.
@@ -59,6 +65,27 @@ export const voyage: Recipe = {
// 4xx).
multimodal_models: ['voyage-multimodal-3'],
},
// v0.46.3: Voyage reranking (the recommended zerank-2 replacement — same
// VOYAGE_API_KEY as embeddings). gateway.rerank() posts to
// `${base_url_default}/rerank` (base already ends in /v1). Wire dialect:
// request takes `top_k` (declared via top_param); response is
// {object: "list", data: [{index, relevance_score}]} — live-wire verified
// 2026-08-15; the gateway's parser accepts both data[] and results[].
reranker: {
models: ['rerank-2.5', 'rerank-2.5-lite'],
default_model: 'rerank-2.5',
path: '/rerank',
top_param: 'top_k',
// https://docs.voyageai.com/docs/pricing (verified 2026-08-15):
// rerank-2.5 $0.05/M, rerank-2.5-lite $0.02/M.
cost_per_1m_tokens_usd: 0.05,
price_last_verified: '2026-08-15',
// Voyage enforces token-based caps (32K per query+document pair,
// ≤1000 documents/request) rather than a byte cap; 5MB is a
// conservative byte-level proxy matching the ZE-era pre-flight so
// oversized bodies still fail open before the wire.
max_payload_bytes: 5_000_000,
},
},
setup_hint: 'Get an API key at https://dash.voyageai.com/api-keys, then `export VOYAGE_API_KEY=...`',
};
+17
View File
@@ -1,4 +1,9 @@
import type { Recipe } from '../types.ts';
import {
ZEROENTROPY_SUNSET_DATE,
NEW_INSTALL_DEFAULT_EMBEDDING_MODEL,
NEW_INSTALL_DEFAULT_RERANKER_MODEL,
} from '../defaults.ts';
/**
* ZeroEntropy ships two specialized small models that target the two weakest
@@ -64,4 +69,16 @@ export const zeroentropyai: Recipe = {
},
setup_hint:
'Get an API key at https://dashboard.zeroentropy.dev, then `export ZEROENTROPY_API_KEY=...`',
// ZeroEntropy is winding down; the hosted API dies on the sunset date.
// This drives picker/auto-pick exclusion, warn-on-use, and the
// `gbrain providers` DEPRECATED annotation. The recipe itself is deleted
// in the September removal release (see TODOS.md).
sunset: {
date: ZEROENTROPY_SUNSET_DATE,
message: 'ZeroEntropy is shutting down its hosted API.',
replacement: {
embedding: NEW_INSTALL_DEFAULT_EMBEDDING_MODEL,
reranker: NEW_INSTALL_DEFAULT_RERANKER_MODEL,
},
},
};
+40
View File
@@ -28,6 +28,16 @@ export type Implementation =
export interface EmbeddingTouchpoint {
models: string[];
default_dims: number;
/**
* v0.46.3: canonical model for this recipe's embedding touchpoint. Every
* "pick a model for the user" surface resolves `default_model ?? models[0]`:
* init auto-pick (single-key and multi-key canonical tiebreak), the
* `--embedding-model <provider>` shorthand expansion, and the interactive
* picker's selection + displayed row. Exists because array order is a bad
* carrier for "recommended": Voyage lists voyage-4-large first (quality
* order) but the canonical default is voyage-4 (price/quality balance).
*/
default_model?: string;
/**
* Per-model native dimensions, keyed by bare model id (no `provider:`
* prefix). Consulted before `default_dims` when resolving schema width
@@ -229,6 +239,15 @@ export interface RerankerTouchpoint {
* '/v1/rerank').
*/
path?: string;
/**
* v0.46.3: request-body key for the "return top N" parameter. Named by wire
* shape, not provider. Defaults to 'top_n' (ZeroEntropy/llama-server/jina
* dialect); Voyage's /v1/rerank takes 'top_k'. Response parsing accepts
* both array keys (`results[]` for ZE/llama-server, `data[]` for Voyage's
* REST live-wire verified) since the item shape
* `{index, relevance_score}` is shared.
*/
top_param?: 'top_n' | 'top_k';
/**
* Recipe-level timeout fallback for `gateway.rerank()` and search-mode
* resolution. Caller's `input.timeoutMs` and `search.reranker.timeout_ms`
@@ -307,6 +326,27 @@ export interface Recipe {
aliases?: Record<string, string>;
/** One-line description of setup (shown in wizard + env subcommand). */
setup_hint?: string;
/**
* v0.46.3: the provider announced a hosted-API shutdown. Drives, from one
* source: init picker/auto-pick exclusion, the once-per-process warn-on-use
* in the gateway, and the `gbrain providers` DEPRECATED annotation.
* (`provider_sunset` in doctor stays provider-specific until the removal
* release this field does not make the doctor generic yet.)
* `replacement` is per-touchpoint: one provider can be replaced by different
* targets for embedding vs reranking.
*/
sunset?: {
/** ISO date the hosted API stops working. */
date: string;
/** Optional extra context appended to warnings. */
message?: string;
replacement?: {
/** Recommended `provider:model` replacement for the embedding touchpoint. */
embedding?: string;
/** Recommended `provider:model` replacement for the reranker touchpoint. */
reranker?: string;
};
};
/**
* v0.32 (D12=A): unified auth resolver across embed / expansion / chat
* touchpoints. Returns the header name (`Authorization`, `api-key`, etc.)
+103
View File
@@ -0,0 +1,103 @@
/**
* atomic-write.ts the ONE atomic config-file writer for bootstrap host
* surfaces (rule-of-three extraction: hooks.ts settings JSON, codex-toml.ts
* TOML text, opencode-json.ts JSONC text all swap through here).
*
* Semantics, hardened for shared user-scope targets [C10 / X11]:
* - The SYMLINK TARGET is resolved first so a dotfile-manager-linked config
* survives as a link (a bare rename would replace the link with a regular
* file). DANGLING links are resolved too (readlink, hop by hop): the write
* creates the missing target and the link survives.
* - tmp file uses a random suffix and inherits the EXISTING file's mode; a
* fresh file takes `freshMode` (caller's convention secret-bearing
* targets pass 0o600). `forceMode` overrides both (codex-toml forces 0600
* because the file carries a bearer token regardless of its prior mode).
* - chmod after write because writeFileSync's mode applies only on create.
*
* EOL and serialization stay caller-side: hooks.ts stringifies JSON,
* codex-toml converts to CRLF when the original was CRLF, opencode-json
* preserves EOLs naturally via jsonc-parser text splicing.
*/
import { randomBytes } from 'node:crypto';
import {
chmodSync,
existsSync,
lstatSync,
mkdirSync,
readlinkSync,
realpathSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { dirname, isAbsolute, resolve } from 'node:path';
/** Resolve the write target through symlinks, INCLUDING dangling ones.
* existsSync follows symlinks, so a DANGLING link reads "absent" and a bare
* rename would replace the link itself with a regular file instead the
* link text is resolved hop by hop (relative to each link's dir, bounded
* against loops) and the write lands at the final target, preserving the
* link the same way the live-symlink realpath branch does. */
function resolveWriteTarget(path: string): string {
if (existsSync(path)) {
// TOCTOU guard: the file can vanish between existsSync and realpathSync
// (a concurrent unlink), which would throw a raw ENOENT out of a writer
// that is perfectly able to proceed — fall through and treat the path as
// fresh/dangling instead.
try {
return realpathSync(path); // live file / live symlink chain
} catch {
/* raced away — resolve below */
}
}
let target = path;
for (let hops = 0; hops < 40; hops++) {
let st;
try {
st = lstatSync(target);
} catch {
return target; // truly absent — fresh-file target
}
if (!st.isSymbolicLink()) return target;
const linkText = readlinkSync(target);
target = isAbsolute(linkText) ? linkText : resolve(dirname(target), linkText);
}
return target; // pathological loop — bounded, last hop wins
}
export function atomicWriteTextFile(
path: string,
text: string,
opts?: { freshMode?: number; forceMode?: number },
): void {
const target = resolveWriteTarget(path);
mkdirSync(dirname(target), { recursive: true });
let mode: number | undefined;
if (opts?.forceMode !== undefined) {
mode = opts.forceMode;
} else {
try {
mode = statSync(target).mode & 0o777;
} catch {
mode = opts?.freshMode;
}
}
const tmp = `${target}.tmp-${randomBytes(6).toString('hex')}`;
// Failure hygiene: a throwing write/chmod/rename (ENOSPC, EACCES, target
// turned into a directory, …) must not leak the tmp file next to the
// user's config — unlink it best-effort and rethrow the original error.
try {
writeFileSync(tmp, text, { encoding: 'utf8', ...(mode !== undefined ? { mode } : {}) });
if (mode !== undefined) chmodSync(tmp, mode);
renameSync(tmp, target);
} catch (e) {
try {
unlinkSync(tmp);
} catch {
/* best-effort — the original error is the one that matters */
}
throw e;
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ export interface AttachWorkspaceOptions {
/** The gbrain home receiving the install receipt (default: configDir()). */
gbrainHomeDir?: string;
/** Target harness for the hooks/MCP steps' descriptions. */
harness?: 'claude-code' | 'codex';
harness?: 'claude-code' | 'codex' | 'opencode';
/** Recorded as the receipt's created_by (the attaching binary's version). */
createdBy?: string;
}
+5 -20
View File
@@ -32,19 +32,8 @@
* brick).
*/
import { randomBytes } from 'node:crypto';
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
realpathSync,
renameSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname } from 'node:path';
import { chmodSync, copyFileSync, existsSync, readFileSync, statSync } from 'node:fs';
import { atomicWriteTextFile } from './atomic-write.ts';
import { CODEX_TOML_BLOCK_BEGIN, CODEX_TOML_BLOCK_END } from './host-specs.ts';
export interface CodexHttpServerBlock {
@@ -158,15 +147,11 @@ function renderBlock(block: CodexHttpServerBlock): string[] {
];
}
/** Atomic 0600 write preserving symlinks and the file's dominant EOL. */
/** 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 {
const target = existsSync(configPath) ? realpathSync(configPath) : configPath;
mkdirSync(dirname(target), { recursive: true });
const tmp = `${target}.tmp-${randomBytes(6).toString('hex')}`;
const out = crlf ? unixText.replace(/\n/g, '\r\n') : unixText;
writeFileSync(tmp, out, { encoding: 'utf8', mode: 0o600 });
chmodSync(tmp, 0o600);
renameSync(tmp, target);
atomicWriteTextFile(configPath, out, { forceMode: 0o600 });
}
/**
+2 -2
View File
@@ -130,7 +130,7 @@ export interface InstallReceipt {
* corpus dir, ). Uninstall removes exactly these, nothing else. */
created_paths: string[];
/** Host registrations bootstrap performed (for marker-keyed removal). */
registrations: Array<{ host: 'claude-code' | 'codex'; scope: string; detail?: string }>;
registrations: Array<{ host: 'claude-code' | 'codex' | 'opencode'; scope: string; detail?: string }>;
}
export function receiptPath(gbrainHomeDir: string): string {
@@ -223,7 +223,7 @@ export type HarnessTargetKind = 'mcp' | 'permission' | 'hooks';
export type HarnessTargetState = 'pending' | 'confirmed' | 'failed';
export interface HarnessTarget {
host: 'claude-code' | 'codex';
host: 'claude-code' | 'codex' | 'opencode';
kind: HarnessTargetKind;
state: HarnessTargetState;
/** user scope or a --project dir (hooks); user for mcp/permission. */
+274 -19
View File
@@ -1,8 +1,8 @@
/**
* harness.ts `gbrain bootstrap harness` (#4043): default brain wiring for
* agent-framework-driven coding (a downstream framework spawning Claude Code
* `claude -p` / codex exec on a box that already hosts a brain + a running
* `gbrain serve --http`).
* `claude -p` / codex exec / opencode run on a box that already hosts a brain
* + a running `gbrain serve --http`).
*
* What it wires, per harness:
* - Claude Code: user-scope HTTP MCP registration (`claude mcp add --scope
@@ -12,6 +12,10 @@
* required anywhere.
* - Codex: one managed `[mcp_servers.<name>]` TOML block with the inline
* bearer token (codex-toml.ts `codex mcp add` cannot express it).
* - opencode: one managed `mcp.<name>` remote entry with the inline bearer
* header in the user-global JSONC config (opencode-json.ts
* framework-spawned opencode inherits no shell env, so the `{env:…}`
* interpolation the connect lane uses would resolve empty here).
*
* Contracts folded from the CEO review + outside voice (letters reference the
* plan file):
@@ -36,7 +40,7 @@
* revoke defers with a typed message under a live PGLite serve.
*/
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { VERSION } from '../../version.ts';
@@ -66,6 +70,7 @@ import {
type HarnessReceipt,
type HarnessTarget,
} from './format.ts';
import { atomicWriteTextFile } from './atomic-write.ts';
import {
removeCodexHttpServerBlock,
writeCodexHttpServerBlock,
@@ -87,12 +92,22 @@ import {
claudeUserSettingsPath,
codexConfigPath,
mcpPermissionEntry,
opencodeConfigDir,
opencodeGlobalConfigPath,
type ClaudeHookEvent,
} from './host-specs.ts';
import {
opencodeEntryKind,
parseOpencodeConfig,
parseOpencodeEntryBearer,
reconcileOpencodeSiblingGlobal,
removeOpencodeMcpEntry,
writeOpencodeMcpEntry,
} from './opencode-json.ts';
// ── Flags ───────────────────────────────────────────────────────────────────
export type HarnessSelector = 'claude-code' | 'codex' | 'all';
export type HarnessSelector = 'claude-code' | 'codex' | 'opencode' | 'all';
export interface HarnessFlags {
harness: HarnessSelector;
@@ -143,8 +158,8 @@ export function parseHarnessArgs(rest: string[]): HarnessFlags {
};
const h = value('--harness');
if (h !== undefined) {
if (h !== 'claude-code' && h !== 'codex' && h !== 'all') {
out.error = `unknown --harness '${h}' — pass claude-code, codex, or all`;
if (h !== 'claude-code' && h !== 'codex' && h !== 'opencode' && h !== 'all') {
out.error = `unknown --harness '${h}' — pass claude-code, codex, opencode, or all`;
return out;
}
out.harness = h;
@@ -230,6 +245,8 @@ export interface HarnessDeps {
userSettingsPath?: string;
/** Resolved codex config path (tests point at a temp CODEX_HOME). */
codexConfig?: string;
/** Resolved opencode config path (tests point at a temp XDG_CONFIG_HOME). */
opencodeConfig?: string;
/** Engine-backed mint; tests inject a fake. */
mint?: (opts: {
name: string;
@@ -241,6 +258,7 @@ export interface HarnessDeps {
pgliteLiveServe?: () => boolean;
detectClaude?: () => boolean;
detectCodex?: () => boolean;
detectOpencode?: () => boolean;
gbrainBin?: string | null;
log?: (line: string) => void;
logError?: (line: string) => void;
@@ -256,6 +274,7 @@ function resolveDeps(deps: HarnessDeps): Required<Omit<HarnessDeps, 'gbrainBin'>
probeIdentity: deps.probeIdentity ?? ((url, token) => probeBrainIdentity(url, token)),
userSettingsPath: deps.userSettingsPath ?? claudeUserSettingsPath(),
codexConfig: deps.codexConfig ?? codexConfigPath(),
opencodeConfig: deps.opencodeConfig ?? opencodeGlobalConfigPath(),
mint: deps.mint ?? defaultMint,
revokeById: deps.revokeById ?? defaultRevokeById,
pgliteLiveServe: deps.pgliteLiveServe ?? defaultPgliteLiveServe,
@@ -263,6 +282,9 @@ function resolveDeps(deps: HarnessDeps): Required<Omit<HarnessDeps, 'gbrainBin'>
detectCodex:
deps.detectCodex ??
(() => whichSafe('codex') !== null || existsSync(deps.codexConfig ?? codexConfigPath())),
detectOpencode:
deps.detectOpencode ??
(() => whichSafe('opencode') !== null || existsSync(opencodeConfigDir())),
gbrainBin: deps.gbrainBin !== undefined ? deps.gbrainBin : null,
log: deps.log ?? ((l) => console.log(l)),
logError: deps.logError ?? ((l) => console.error(l)),
@@ -357,12 +379,14 @@ export function buildConsentBlock(p: {
url: string;
wireClaude: boolean;
wireCodex: boolean;
wireOpencode: boolean;
hooks: boolean;
capture: boolean;
hookScope: string;
name: string;
userSettingsPath: string;
codexConfig: string;
opencodeConfig: string;
}): string {
const lines: string[] = [
'gbrain bootstrap harness — wire framework-spawned coding sessions to this brain',
@@ -402,14 +426,23 @@ export function buildConsentBlock(p: {
`${p.codexConfig} (0600) — framework-spawned codex inherits no shell env, so an env-var token would not reach it.`,
);
}
if (p.wireOpencode) {
lines.push(
` ${n++}. opencode (user-global): write the mcp.${p.name} remote entry with the bearer token INLINE into ` +
`${p.opencodeConfig} (0600) — framework-spawned opencode inherits no shell env, so the {env:…} ` +
`interpolation would resolve empty.`,
);
}
// [X7] The reach statement matches what is ACTUALLY being wired — it must
// never claim a host or a hook lane this invocation does not touch.
const hosts =
p.wireClaude && p.wireCodex
? 'EVERY Claude Code and Codex session'
: p.wireClaude
? 'EVERY Claude Code session'
: 'EVERY Codex session';
// never claim a host or a hook lane this invocation does not touch. Joined
// list, not a ternary tree: a fourth harness must be a compile-time nudge
// here, not a silent mislabel.
const hostNames = [
...(p.wireClaude ? ['Claude Code'] : []),
...(p.wireCodex ? ['Codex'] : []),
...(p.wireOpencode ? ['opencode'] : []),
];
const hosts = `EVERY ${hostNames.join(' and ')} session`;
const hookLine = !p.wireClaude || !p.hooks
? 'No hooks are wired by this invocation.'
: p.capture
@@ -421,6 +454,7 @@ export function buildConsentBlock(p: {
'`gbrain auth revoke --id <id>` (see auth list)',
...(p.wireClaude ? [`\`claude mcp remove ${p.name} --scope user\``] : []),
...(p.wireCodex ? ['edit the codex config'] : []),
...(p.wireOpencode ? ['edit the opencode config'] : []),
];
lines.push(
'',
@@ -498,8 +532,42 @@ async function cleanupStalePriorTargets(
);
}
} else if (pt.host === 'codex' && pt.kind === 'mcp') {
const r = removeCodexHttpServerBlock(pt.path ?? d.codexConfig, pt.name ?? 'gbrain');
if (r.removed) d.log(`stale codex managed block removed from ${pt.path ?? d.codexConfig} (no longer planned).`);
// [X11] The caller holds only the claude config-dir lock; the codex
// config is a DIFFERENT shared file, and its read-modify-write must
// serialize on ITS directory's bootstrap lock too — a concurrent
// codex-dir-locked writer interleaving here would have one rename
// discard the other. Ordering matches apply/remove (claude/config
// dir first, then codex dir), so no lock-order inversion; the lock
// is non-reentrant, so the same-dir case skips the nested acquire.
const codexPath = pt.path ?? d.codexConfig;
const codexDir = dirname(codexPath);
mkdirSync(codexDir, { recursive: true });
const heldDir = resolve(dirname(d.userSettingsPath));
const lk = resolve(codexDir) === heldDir ? null : await acquireBootstrapLock(codexDir);
let r: ReturnType<typeof removeCodexHttpServerBlock>;
try {
r = removeCodexHttpServerBlock(codexPath, pt.name ?? 'gbrain');
} finally {
lk?.release();
}
if (r.removed) d.log(`stale codex managed block removed from ${codexPath} (no longer planned).`);
} else if (pt.host === 'opencode' && pt.kind === 'mcp') {
// Fingerprint-keyed against the PRIOR receipt's url — a foreign or
// rotated-away entry refuses inside the module (never guess). Same
// [X11] nested-lock discipline as the codex branch above (claude/
// config dir held by the caller, then the opencode dir here).
const ocPath = pt.path ?? d.opencodeConfig;
const ocDir = dirname(ocPath);
mkdirSync(ocDir, { recursive: true });
const heldDir = resolve(dirname(d.userSettingsPath));
const lk = resolve(ocDir) === heldDir ? null : await acquireBootstrapLock(ocDir);
let r: ReturnType<typeof removeOpencodeMcpEntry>;
try {
r = removeOpencodeMcpEntry(ocPath, pt.name ?? 'gbrain', { url: prior.url });
} finally {
lk?.release();
}
if (r.removed) d.log(`stale opencode entry removed from ${ocPath} (no longer planned).`);
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -539,14 +607,17 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
// explicit (we exec `claude mcp add`; it owns ~/.claude.json).
const wireClaude = (flags.harness === 'all' || flags.harness === 'claude-code') && d.detectClaude();
const wireCodex = flags.harness === 'codex' || (flags.harness === 'all' && d.detectCodex());
// opencode mirrors codex: an explicit --harness opencode FORCES wiring (the
// JSONC writer needs no opencode CLI and creates the config file itself).
const wireOpencode = flags.harness === 'opencode' || (flags.harness === 'all' && d.detectOpencode());
if (flags.harness === 'claude-code' && !wireClaude) {
d.logError('claude CLI not found on PATH — the user-scope MCP registration needs it (it owns ~/.claude.json).');
return 2;
}
if (!wireClaude && !wireCodex) {
if (!wireClaude && !wireCodex && !wireOpencode) {
d.logError(
'no harness detected on this box (claude CLI not on PATH; no codex install) — ' +
'pass --harness claude-code|codex explicitly if detection is wrong.',
'no harness detected on this box (claude CLI not on PATH; no codex install; no opencode install) — ' +
'pass --harness claude-code|codex|opencode explicitly if detection is wrong.',
);
return 2;
}
@@ -597,12 +668,14 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
url,
wireClaude,
wireCodex,
wireOpencode,
hooks: wireHooks,
capture: !flags.noCapture,
hookScope,
name: flags.name,
userSettingsPath: d.userSettingsPath,
codexConfig: d.codexConfig,
opencodeConfig: d.opencodeConfig,
});
d.log(consent);
if (!flags.yes) {
@@ -719,6 +792,17 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
mechanism: 'toml-block',
});
}
if (wireOpencode) {
targets.push({
host: 'opencode',
kind: 'mcp',
state: 'pending',
scope: 'user',
path: d.opencodeConfig,
name: flags.name,
mechanism: 'jsonc-entry',
});
}
// [X4] EVERY unrevoked prior minted id is carried — on the --token lane
// too. A failed rotation must never forget the token before last.
const carriedPreviousIds = [
@@ -805,6 +889,15 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
let oldClaudeReg: { url: string; token: string } | null = null;
let claudeReplaced = false;
let codexRollback: { path: string; backupPath: string | null; replacedPrior: boolean } | null = null;
let opencodeRollback: {
path: string;
backupPath: string | null;
replacedPrior: boolean;
/** Exact text this run's writer landed the rollback compares the LIVE
* file against it before restoring (the lock is released before the
* smoke, so a newer registration may have landed since). */
writtenText: string;
} | null = null;
let cfgLock: Awaited<ReturnType<typeof acquireBootstrapLock>> | null = null;
if (wireClaude) {
const cfgDir = dirname(d.userSettingsPath);
@@ -957,6 +1050,68 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
}
}
// 7b. opencode wiring — the managed JSONC entry is the single write
// mechanism (opencode-json.ts; fingerprint-owned, comment-preserving).
// Same [X11] lock discipline; same forced-wire posture as codex.
for (const t of targets) {
if (t.host !== 'opencode') continue;
try {
const ocDir = dirname(t.path!);
mkdirSync(ocDir, { recursive: true });
const ocLock = await acquireBootstrapLock(ocDir);
let r: ReturnType<typeof writeOpencodeMcpEntry>;
try {
// Ownership [C8]: an entry at OUR new url (idempotent re-run) or at
// the PRIOR receipt's url (rotation across a port change) is ours;
// anything else under the name refuses inside the writer. The prior
// url is offered as the expectation only when the current one does
// not classify the entry as ours.
let expectUrl = url;
if (prior && prior.url !== url && existsSync(t.path!)) {
try {
const parsedExisting = parseOpencodeConfig(readFileSync(t.path!, 'utf8'), t.path!);
if (
opencodeEntryKind(parsedExisting, flags.name, { url }) === 'foreign' &&
opencodeEntryKind(parsedExisting, flags.name, { url: prior.url }) === 'ours-same-source'
) {
expectUrl = prior.url;
}
} catch {
/* the writer's own read path raises the real error below */
}
}
// Two-filename merge blind spot: opencode merges BOTH user-global
// filenames, so a same-name gbrain entry in the SIBLING file would
// survive this write as a shadow registration. Reconcile under the
// same config-dir lock (ours → removed with a note; foreign →
// refuse loudly naming both files).
const sib = reconcileOpencodeSiblingGlobal(t.path!, flags.name, { url: expectUrl });
for (const note of sib.notes) d.log(note);
r = writeOpencodeMcpEntry(
t.path!,
{ kind: 'remote', name: flags.name, url, tokenMode: 'inline', bearerToken: token },
{ expect: { url: expectUrl }, allowReplaceOtherSource: true },
);
} finally {
ocLock.release();
}
for (const note of r.notes) d.logError(note);
opencodeRollback = { path: t.path!, backupPath: r.backupPath, replacedPrior: r.replacedPrior, writtenText: r.writtenText };
confirm(t);
d.log(
`opencode wired: mcp.${flags.name} remote entry with inline bearer header in ${t.path} (0600). ` +
'opencode ships a plugin/event system, but gbrain does not wire it yet — per-turn context on ' +
'opencode is MCP tools + the pull protocol (AGENTS.md loads natively). Restart opencode: it ' +
'reads config at session start.',
);
} catch (e) {
// Redaction parity with the claude lane: the writer's refusal messages
// can embed a paste-by-hand snippet, and the receipt + stderr must
// never carry the live bearer under any error shape.
failTarget(t, redactToken(e instanceof Error ? e.message : String(e), token));
}
}
// 8. Smoke [C3-enriched message; X10 verbs-surface honesty]. An
// unknown-tool tool_error means initialize + auth ALREADY succeeded — a
// serve running a narrowed --surface (e.g. verbs) is verified, not broken.
@@ -1010,7 +1165,9 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
const rbLock = await acquireBootstrapLock(dirname(codexRollback.path)); // [X11] parity
try {
if (codexRollback.backupPath && existsSync(codexRollback.backupPath)) {
copyFileSync(codexRollback.backupPath, codexRollback.path);
// Atomic restore: a crash mid-copy must never leave a torn config
// (the backup carries the previous bearer — 0600 stays forced).
atomicWriteTextFile(codexRollback.path, readFileSync(codexRollback.backupPath, 'utf8'), { forceMode: 0o600 });
} else if (!codexRollback.replacedPrior) {
removeCodexHttpServerBlock(codexRollback.path, flags.name);
}
@@ -1023,6 +1180,42 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
d.logError(`codex rollback failed: ${e instanceof Error ? e.message : String(e)} — re-run to converge.`);
}
}
if (opencodeRollback) {
try {
let failNote = 'rolled back to the previous opencode config after the failed smoke';
const rbLock = await acquireBootstrapLock(dirname(opencodeRollback.path)); // [X11] parity
try {
// Restore-guard: the config-dir lock was released before the smoke,
// so a NEWER registration (another run's) may have replaced ours —
// restoring this run's snapshot over it would clobber that newer
// wiring. Only restore when the live file still carries the EXACT
// text this run wrote; either way the fresh mint is revoked below.
const current = existsSync(opencodeRollback.path)
? readFileSync(opencodeRollback.path, 'utf8')
: '';
if (current !== opencodeRollback.writtenText) {
failNote =
'smoke failed; opencode rollback SKIPPED — the config changed after this run wrote it ' +
'(a newer registration exists); this run\'s fresh mint is still revoked';
d.log(failNote + '.');
} else if (opencodeRollback.backupPath && existsSync(opencodeRollback.backupPath)) {
// Atomic restore (codex-lane parity): never a torn config mid-crash.
atomicWriteTextFile(opencodeRollback.path, readFileSync(opencodeRollback.backupPath, 'utf8'), { forceMode: 0o600 });
// Consumed — the unique backup carries the previous bearer and
// has no consumer once restored.
try { rmSync(opencodeRollback.backupPath, { force: true }); } catch { /* best-effort */ }
} else if (!opencodeRollback.replacedPrior) {
removeOpencodeMcpEntry(opencodeRollback.path, flags.name, { url });
}
} finally {
rbLock.release();
}
const ot = targets.find((t) => t.host === 'opencode' && t.kind === 'mcp');
if (ot) failTarget(ot, failNote);
} catch (e) {
d.logError(`opencode rollback failed: ${e instanceof Error ? e.message : String(e)} — re-run to converge.`);
}
}
const mt = targets.find((t) => t.host === 'claude-code' && t.kind === 'mcp');
if (claudeReplaced && oldClaudeReg) {
await d.runner(['claude', 'mcp', 'remove', flags.name, '--scope', 'user']);
@@ -1080,6 +1273,18 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
}
}
// The unique opencode backup carries the PREVIOUS bearer; once the new
// wiring is verified it has no consumer — unlink it so re-runs never
// accumulate token-bearing snapshots (failed runs consume it via the
// restore above; skipped restores leave it 0600 for manual recovery).
if (smokeOk && opencodeRollback?.backupPath) {
try {
rmSync(opencodeRollback.backupPath, { force: true });
} catch {
/* best-effort — it is 0600 either way */
}
}
// 9. [X3] Convergence cleanup — AFTER the smoke, so prior working wiring is
// never unwired on a run that failed to establish its replacement. Cleanup
// failures append as failed targets (blocking the rotation gate below) so
@@ -1286,6 +1491,47 @@ export async function removeHarness(flags: HarnessFlags, rawDeps: HarnessDeps):
? `Codex managed block removed from ${codexPath}.`
: `no managed block in ${codexPath} — counted as removed.`,
);
} else if (t.host === 'opencode') {
const ocPath = t.path ?? d.opencodeConfig;
// [C8] Ownership before removal: an entry now at a DIFFERENT url is
// another install's — skip with a note, cleared from the receipt
// (mirror of the claude not-ours branch; the module's foreign check
// would THROW, which reads as a failure rather than a skip).
let kind: ReturnType<typeof opencodeEntryKind> = 'absent';
if (existsSync(ocPath)) {
try {
kind = opencodeEntryKind(
parseOpencodeConfig(readFileSync(ocPath, 'utf8'), ocPath),
t.name ?? 'gbrain',
{ url: receipt.url },
);
} catch (e) {
throw new Error(`opencode config unreadable: ${e instanceof Error ? e.message : String(e)}`);
}
}
if (kind === 'absent') {
d.log(`opencode entry '${t.name}' already gone — counted as removed.`); // [F2]
} else if (kind !== 'ours-same-source') {
d.log(
`opencode entry '${t.name}' does not match this receipt's url (${receipt.url}) — owned by another ` +
'install; skipping, cleared from the receipt.',
);
} else {
const ocDir = dirname(ocPath);
mkdirSync(ocDir, { recursive: true });
const ocLock = resolve(ocDir) === resolve(rmCfgDir) ? null : await acquireBootstrapLock(ocDir);
let r: ReturnType<typeof removeOpencodeMcpEntry>;
try {
r = removeOpencodeMcpEntry(ocPath, t.name ?? 'gbrain', { url: receipt.url });
} finally {
ocLock?.release();
}
d.log(
r.removed
? `opencode managed entry removed from ${ocPath}.`
: `no managed entry in ${ocPath} — counted as removed.`,
);
}
}
} catch (e) {
anyFailed = true;
@@ -1443,6 +1689,15 @@ export async function statusHarness(flags: HarnessFlags, rawDeps: HarnessDeps):
}
}
}
if (!token) {
const ocMcp = receipt.targets.find((t) => t.host === 'opencode' && t.kind === 'mcp');
if (ocMcp?.path) {
// [C8] url-matched inside the helper: a foreign/rotated entry's bearer
// is never recovered (it was not issued for receipt.url).
token = parseOpencodeEntryBearer(ocMcp.path, ocMcp.name ?? 'gbrain', receipt.url);
if (token) tokenSource = 'opencode config entry';
}
}
let tokenLine: string;
let tokenVerified: boolean | 'unavailable' = 'unavailable';
+6 -25
View File
@@ -24,19 +24,9 @@
* (and GBRAIN_HOME when isolated) ride the registration itself.
*/
import { randomBytes } from 'node:crypto';
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
realpathSync,
renameSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, isAbsolute, join } from 'node:path';
import { copyFileSync, existsSync, readFileSync } from 'node:fs';
import { isAbsolute, join } from 'node:path';
import { atomicWriteTextFile } from './atomic-write.ts';
import {
CLAUDE_COMMITTED_SETTINGS_FILE_RELPATH,
CLAUDE_HOOK_DEFAULT_TIMEOUT_SECS,
@@ -297,18 +287,9 @@ function stripOurEntries(groups: unknown[], marker: string = GBRAIN_HOOK_MARKER_
* but not for user-global config).
*/
function atomicWriteJson(path: string, value: unknown, freshMode?: number): void {
const target = existsSync(path) ? realpathSync(path) : path;
mkdirSync(dirname(target), { recursive: true });
let mode: number | undefined;
try {
mode = statSync(target).mode & 0o777;
} catch {
mode = freshMode; // fresh file: caller's convention (user-scope → 0600) [X11]
}
const tmp = `${target}.tmp-${randomBytes(6).toString('hex')}`;
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', ...(mode !== undefined ? { mode } : {}) });
if (mode !== undefined) chmodSync(tmp, mode); // writeFileSync mode applies only on create
renameSync(tmp, target);
// Shared bootstrap atomic writer (symlink-resolving, mode-inheriting) —
// fresh files take the caller's convention (user-scope → 0600) [X11].
atomicWriteTextFile(path, `${JSON.stringify(value, null, 2)}\n`, { freshMode });
}
/** Pre-write backup path per strategy; timestamped avoids the shared-slot loss. */
+111 -1
View File
@@ -19,8 +19,9 @@
* gbrain code.
*/
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { basename, dirname, join } from 'node:path';
// ── Spec-target registry [ENG-7] ────────────────────────────────────────────
@@ -38,6 +39,7 @@ export interface HostSpecTarget {
export const CLAUDE_CODE_SPEC_ID = 'claude-code-2026-08';
export const CODEX_SPEC_ID = 'codex-2026-08';
export const OPENCODE_SPEC_ID = 'opencode-2026-08';
export const TARGETS: Record<string, HostSpecTarget> = {
[CLAUDE_CODE_SPEC_ID]: {
@@ -103,6 +105,43 @@ export const TARGETS: Record<string, HostSpecTarget> = {
'has no hooks". Some codex builds gate HTTP MCP servers behind ' +
'`experimental_use_rmcp_client = true` — probe at wiring time.',
},
[OPENCODE_SPEC_ID]: {
id: OPENCODE_SPEC_ID,
status: 'verified',
verifiedAt: '2026-08-15',
references: [
'docs/mcp/OPENCODE-CLI-PIN.md',
'https://opencode.ai/docs/mcp-servers/',
'opencode-ai 1.18.18 (hermetic observation run, macOS arm64, 2026-08-15)',
],
note:
'opencode (SST, opencode.ai — not OpenClaw). Config is JSONC everywhere: ' +
'comments parse in .json-named files, and global opencode.json AND ' +
'opencode.jsonc are BOTH read (merged) when both exist; `opencode mcp ' +
'add` writes the user-global opencode.jsonc via a comment-preserving ' +
'editor, so gbrain writes match that bar (jsonc-parser surgical edits, ' +
'opencode-json.ts). MCP entries: {type:"local", command[], environment, ' +
'enabled?} / {type:"remote", url, headers} — header values keep ' +
'`{env:VAR}` interpolation verbatim; unknown keys tolerated in 1.18.18 ' +
'but gbrain writes NO marker key (ownership is a structural ' +
'fingerprint — a future strict-schema flip must not brick the host). ' +
'`mcp add` has no scope flag (always user-global); project opencode.json ' +
'is read but a project-defined LOCAL server spawns with NO trust gate ' +
'(verified) — so gbrain defaults registration to USER scope and treats ' +
'project scope as explicit opt-in with a sharing warning. `mcp list` is ' +
'the honest discriminator (spawns servers; ✓/✗ text; exit 0 regardless); ' +
'`mcp debug` is OAuth-only. Keyless anonymous free tier answers headless ' +
'`run` AND drives MCP tool calls without --auto (load-bearing for the ' +
'door SMOKE). DOCS-CONTRADICTION pinned: OPENCODE_CONFIG / _CONFIG_DIR / ' +
'_CONFIG_CONTENT observed INERT in 1.18.18 — only HOME/XDG_CONFIG_HOME ' +
'move the config; path helpers resolve via XDG only. opencode sets ' +
'OPENCODE=1 (+OPENCODE_PID) in bash-tool children — detectHarness ' +
'probes OPENCODE. AGENTS.md loads natively; CLAUDE.md is NOT ' +
'double-loaded. opencode ships a JS plugin/event system — ' +
'OPENCODE_HAS_HOOKS=false means "gbrain does not wire it yet" (follow-up ' +
'filed), NOT "opencode has no hooks"; probes run with --pure + ' +
'OPENCODE_DISABLE_AUTOUPDATE=1 because mcp list autoloads plugins.',
},
};
// ── Claude Code shapes the writers consume ──────────────────────────────────
@@ -240,3 +279,74 @@ export const CODEX_HAS_HOOKS = false;
export const CODEX_TOML_BLOCK_BEGIN =
`# gbrain:${GBRAIN_HARNESS_MARKER_VALUE} begin - managed by \`gbrain bootstrap harness\`; do not edit inside`;
export const CODEX_TOML_BLOCK_END = `# gbrain:${GBRAIN_HARNESS_MARKER_VALUE} end`;
// ── opencode shapes ─────────────────────────────────────────────────────────
/**
* opencode config DIRECTORY (user-global). Resolution mirrors what the real
* binary was OBSERVED to do (OPENCODE-CLI-PIN.md §Path seams): XDG_CONFIG_HOME
* else $HOME/.config, then /opencode. The OPENCODE_CONFIG / OPENCODE_CONFIG_DIR
* / OPENCODE_CONFIG_CONTENT env vars are deliberately NOT honored here
* observed INERT in opencode 1.18.18 (probes registered through each were
* invisible to `mcp list` while the XDG-resolved config was still read), so
* honoring them would write registrations into a file opencode never reads (a
* silent no-op install). HOME is read from the env explicitly because Bun's
* homedir() reads the password database, not the HOME env var (the
* claudeUserSettingsPath lesson).
*/
export function opencodeConfigDir(): string {
const xdg = process.env.XDG_CONFIG_HOME?.trim();
if (xdg) return join(xdg, 'opencode');
const home = process.env.HOME?.trim();
return join(home || homedir(), '.config', 'opencode');
}
/**
* User-global opencode config FILE. Both `opencode.json` and `opencode.jsonc`
* are read (merged) by the host when both exist; gbrain edits the file that
* already carries content, preferring `.jsonc` (the name `opencode mcp add`
* itself writes) when both or neither exist one-owner-per-file keeps the
* merge unambiguous for `mcp.gbrain`.
*/
export function opencodeGlobalConfigPath(): string {
const dir = opencodeConfigDir();
const jsonc = join(dir, 'opencode.jsonc');
const json = join(dir, 'opencode.json');
if (existsSync(jsonc)) return jsonc;
if (existsSync(json)) return json;
return jsonc;
}
/**
* The OTHER member of the global filename pair for a given config path
* (`opencode.json` `opencode.jsonc` in the same dir), or null when the
* basename is not a pair member. opencode MERGES both files when both exist,
* so global WRITERS must reconcile `mcp.<name>` across the pair a same-name
* entry left in the sibling survives as a shadow registration whose merge
* winner is ambiguous (and a later removal of the primary "reveals" it).
* Callers apply this to the USER-GLOBAL pair only; project-scope sibling
* semantics are unobserved.
*/
export function opencodeGlobalSiblingPath(configPath: string): string | null {
const dir = dirname(configPath);
const base = basename(configPath);
if (base === 'opencode.json') return join(dir, 'opencode.jsonc');
if (base === 'opencode.jsonc') return join(dir, 'opencode.json');
return null;
}
/** Project-scope opencode config (docs-canonical name; opencode's lookup
* traverses up to the git root). Committed-file candidate the writer's
* PATH-resolved command + sharing-warning rules apply (OPENCODE.md). */
export function opencodeProjectConfigPath(workspaceDir: string): string {
return join(workspaceDir, 'opencode.json');
}
/**
* Whether gbrain WIRES opencode's hook/plugin system. False = not yet:
* opencode ships a JS plugin/event system (and `--pure` to suppress it), but
* gbrain's opencode plugin lane is a filed follow-up; per-turn context on
* opencode rides the pull-protocol AGENTS.md gates, which opencode loads
* natively (verified and CLAUDE.md is NOT double-loaded alongside it).
*/
export const OPENCODE_HAS_HOOKS = false;
+572
View File
@@ -0,0 +1,572 @@
/**
* opencode-json.ts managed `mcp.<name>` entry writer for opencode's JSONC
* configs (see TARGETS['opencode-2026-08'] in host-specs.ts and
* docs/mcp/OPENCODE-CLI-PIN.md for the verified format assumptions).
*
* Why a direct writer exists: `opencode mcp add` always targets the
* user-global opencode.jsonc (no scope flag), cannot set file modes (the
* harness lane's inline bearer needs 0600), and requires the binary on the
* box the writer covers project scope, secret hygiene, and offline/
* pre-install registration with one code path.
*
* Safety invariants (codex-toml.ts analog, adapted for JSONC):
* - ALL edits go through jsonc-parser `modify`/`applyEdits` text splicing
* that preserves comments, formatting, and EOLs byte-for-byte outside the
* edited range. opencode's own `mcp add` preserves comments (observed);
* gbrain matches that bar. JSON.parse is never used on config text.
* - Ownership is a STRUCTURAL FINGERPRINT, not a marker key (unknown keys
* are tolerated by opencode 1.18.18, but a future strict-schema flip must
* not brick the user's opencode): a local entry is ours when command[0] is
* gbrain-shaped AND environment.GBRAIN_SOURCE exists; source EQUALITY
* (not mere presence) splits `ours-same-source` from `ours-other-source`
* ([FIX7] parity with verifyMcpTargetsWorkspace) callers warn before
* overwriting another workspace's registration. A remote entry is ours
* when its url matches the caller's receipt, or when its Authorization
* header carries the `{env:GBRAIN_REMOTE_TOKEN}` interpolation (only the
* connect lane writes that). Anything else under our name is FOREIGN
* refuse, never guess.
* - Read-failure classes are distinct: ENOENT fresh file; empty/whitespace
* treated as `{}`; unreadable (EACCES etc.) refuse loudly (never
* clobber what cannot be read). A file that fails even JSONC parsing
* refuse with a paste-by-hand snippet.
* - Post-render validation before rename: the rendered text is re-parsed,
* our entry deep-asserted, and every OTHER top-level key asserted to
* survive; on any failure the original file is untouched.
* - Secrets hygiene: when the entry carries an inline bearer the target is
* forced 0600. Backups are UNIQUE per operation (`<config>.bak-<hex>`,
* returned in the result) so two overlapping runs can never clobber each
* other's snapshot, and a backup is chmod'd 0600 whenever the COPIED
* content carries an inline bearer (write AND remove paths on re-runs
* the backup carries the PREVIOUS token). Token-free entries inherit the
* file's existing mode.
* - Concurrency: callers hold acquireBootstrapLock (config-dir
* opencode-dir ordering, mirroring the codex lanes in harness.ts) the
* writer itself is lock-free like codex-toml.ts.
*/
import { randomBytes } from 'node:crypto';
import { chmodSync, copyFileSync, existsSync, readFileSync } from 'node:fs';
import { applyEdits, modify, parse as parseJsonc, printParseErrorCode, type ParseError } from 'jsonc-parser';
import { atomicWriteTextFile } from './atomic-write.ts';
import { opencodeGlobalSiblingPath } from './host-specs.ts';
export const GBRAIN_REMOTE_TOKEN_ENV = 'GBRAIN_REMOTE_TOKEN';
const ENV_INTERPOLATION = `{env:${GBRAIN_REMOTE_TOKEN_ENV}}`;
// ── Entry shapes ────────────────────────────────────────────────────────────
export interface OpencodeLocalEntry {
kind: 'local';
name: string;
/** argv command[0] is PATH-resolved "gbrain" (project scope, committed-
* file candidate) or an absolute binary path (user scope). */
command: string[];
environment: Record<string, string>;
}
export interface OpencodeRemoteEntry {
kind: 'remote';
name: string;
url: string;
/** 'inline' writes `Bearer <token>` (harness lane framework-spawned
* opencode inherits no shell profile; file forced 0600). 'env' writes the
* `{env:GBRAIN_REMOTE_TOKEN}` interpolation (connect lane token never
* enters the file). */
tokenMode: 'inline' | 'env';
bearerToken?: string;
}
export type OpencodeMcpEntry = OpencodeLocalEntry | OpencodeRemoteEntry;
export type OpencodeEntryKind =
| 'absent'
| 'ours-same-source'
| 'ours-other-source'
| 'foreign';
export interface OpencodeEntryExpectation {
/** GBRAIN_SOURCE the caller is registering (local entries). */
sourceId?: string;
/** Serve url from the caller's receipt (remote entries). */
url?: string;
}
export interface WriteOpencodeEntryResult {
configPath: string;
/** True when a prior gbrain-owned entry was replaced (idempotent re-run). */
replacedPrior: boolean;
/** Kind of the pre-existing entry (what was there before this write). */
priorKind: OpencodeEntryKind;
/** Unique per-write backup (`<config>.bak-<hex>`) of the prior file, or
* null on a fresh file. Callers that roll back restore from THIS path. */
backupPath: string | null;
/** The EXACT text this write landed rollback callers compare the current
* file content against it before restoring (a mismatch means a newer
* registration exists and a restore would clobber it). */
writtenText: string;
notes: string[];
}
export interface RemoveOpencodeEntryResult {
configPath: string;
removed: boolean;
backupPath: string | null;
notes: string[];
}
// ── Read + parse (failure classes are distinct) ─────────────────────────────
interface RawConfig {
text: string;
existed: boolean;
}
function readConfigRaw(configPath: string): RawConfig {
if (!existsSync(configPath)) return { text: '', existed: false };
let text: string;
try {
text = readFileSync(configPath, 'utf8');
} catch (e) {
throw new Error(
`${configPath} exists but cannot be read (${(e as Error).message}) — ` +
`refusing to touch a config that cannot be read back. Fix permissions and re-run.`,
);
}
return { text, existed: true };
}
/**
* Parse config text as JSONC (opencode's effective grammar for BOTH .json
* and .jsonc files OPENCODE-CLI-PIN.md §Config format). Empty/whitespace
* text parses as `{}`. Text that fails even JSONC parsing throws with a
* paste-by-hand snippet so the user is never stranded.
*/
export function parseOpencodeConfig(text: string, configPath: string, snippet?: string): Record<string, unknown> {
if (text.trim() === '') return {};
const errors: ParseError[] = [];
const parsed = parseJsonc(text, errors, { allowTrailingComma: true }) as unknown;
if (errors.length > 0) {
const first = errors[0];
throw new Error(
`${configPath} does not parse as JSONC (${printParseErrorCode(first.error)} at offset ${first.offset}) — ` +
`opencode itself cannot read it either. Fix the file, or add the entry by hand:\n${snippet ?? ''}`,
);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`${configPath} is valid JSONC but not an object — fix the file and re-run.`);
}
return parsed as Record<string, unknown>;
}
// ── Ownership fingerprint ───────────────────────────────────────────────────
function isGbrainShapedCommand(command: unknown): boolean {
if (!Array.isArray(command) || command.length === 0) return false;
const head = command[0];
if (typeof head !== 'string') return false;
if (head === 'gbrain') return true; // PATH-resolved (project scope)
if (/[\\/]gbrain$/.test(head)) return true; // absolute binary path
// bun-run wrapper shim lane: `bun run <...>/gbrain/src/cli.ts` etc. The
// arg match is ANCHORED like the head-path lane: some arg must carry an
// exact `gbrain` path segment (or a hyphen-suffixed `gbrain-*` one) — a
// loose substring scan classified `bun run /opt/gbrainy-fork/src/cli.ts`
// as ours (both the `gbrain` substring and a bare `src/cli.ts$` matched).
// A gbrain-less `bun run /repo/src/cli.ts` is now NOT ours (fail-closed:
// gbrain refuses to touch what it cannot prove it owns).
if (head === 'bun' || head.endsWith('/bun')) {
return command.some((a) => typeof a === 'string' && /(?:^|[\\/])gbrain(?:[\\/-]|$)/.test(a));
}
// staged shim named gbrain-<suffix> (e.g. gbrain-shim from stageBinDir) —
// hyphen-anchored so a foreign /opt/bin/gbrainy is NOT ours.
return /[\\/]gbrain-[^\\/]*$/.test(head);
}
/**
* Classify the `mcp.<name>` entry in parsed config. The arbiter every lane
* consults before writing or removing (codexBlockOwnsName analog).
*/
export function opencodeEntryKind(
parsed: Record<string, unknown>,
name: string,
expect: OpencodeEntryExpectation = {},
): OpencodeEntryKind {
const mcp = parsed.mcp;
if (typeof mcp !== 'object' || mcp === null) return 'absent';
const entry = (mcp as Record<string, unknown>)[name];
if (entry === undefined) return 'absent';
if (typeof entry !== 'object' || entry === null) return 'foreign';
const e = entry as Record<string, unknown>;
if (e.type === 'local') {
if (!isGbrainShapedCommand(e.command)) return 'foreign';
const env = e.environment;
const src =
typeof env === 'object' && env !== null
? (env as Record<string, unknown>).GBRAIN_SOURCE
: undefined;
if (typeof src !== 'string' || src === '') return 'foreign';
// Kind mismatch (red-team): a caller expecting a REMOTE entry (harness /
// connect lanes pass expect.url) that finds a LOCAL gbrain entry is
// looking at ANOTHER lane's registration (the workspace stdio lane's) —
// never `ours-same-source`, or a silent replace (and a later --remove)
// would eat it. `ours-other-source` fires the refuse/confirm machinery.
if (expect.url !== undefined) return 'ours-other-source';
if (expect.sourceId === undefined) return 'ours-same-source';
return src === expect.sourceId ? 'ours-same-source' : 'ours-other-source';
}
if (e.type === 'remote') {
if (expect.url !== undefined && e.url === expect.url) return 'ours-same-source';
const headers = e.headers;
const auth =
typeof headers === 'object' && headers !== null
? (headers as Record<string, unknown>).Authorization
: undefined;
if (typeof auth === 'string' && auth.includes(ENV_INTERPOLATION)) {
// Only the gbrain connect lane writes the {env:GBRAIN_REMOTE_TOKEN}
// interpolation — unambiguously ours even without a receipt url. But a
// url mismatch (another serve) OR a LOCAL expectation (expect.sourceId
// — the workspace stdio lane; the kind-mismatch mirror of the local
// branch above) is another lane's wiring: ours-other-source.
return expect.url === undefined && expect.sourceId === undefined
? 'ours-same-source'
: 'ours-other-source';
}
return 'foreign';
}
return 'foreign';
}
// ── Rendering ───────────────────────────────────────────────────────────────
function entryValue(entry: OpencodeMcpEntry): Record<string, unknown> {
if (entry.kind === 'local') {
return {
type: 'local',
command: entry.command,
environment: entry.environment,
enabled: true,
};
}
const token =
entry.tokenMode === 'inline'
? `Bearer ${entry.bearerToken ?? ''}`
: `Bearer ${ENV_INTERPOLATION}`;
return {
type: 'remote',
url: entry.url,
headers: { Authorization: token },
enabled: true,
};
}
/** Copy-pasteable snippet for the refusal paths (the user is never stranded).
* SECURITY: an inline bearer is substituted with a literal placeholder the
* snippet rides thrown error messages (parse refusal, foreign refusal), and an
* error path must never embed the real secret in text that lands in logs,
* receipts, or stderr. Only the human-facing snippet changes; the write path
* still renders the real token. */
export function opencodeEntrySnippet(entry: OpencodeMcpEntry): string {
const safe: OpencodeMcpEntry =
entry.kind === 'remote' && entry.tokenMode === 'inline'
? { ...entry, bearerToken: '<paste-token-here>' }
: entry;
return JSON.stringify({ mcp: { [safe.name]: entryValue(safe) } }, null, 2);
}
function assertEntryName(name: string): void {
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
throw new Error(
`MCP server name "${name}" is not a simple key ([A-Za-z0-9_-]+) — pick a simpler --name`,
);
}
}
function entryCarriesSecret(entry: OpencodeMcpEntry): boolean {
return entry.kind === 'remote' && entry.tokenMode === 'inline';
}
/** True when config text carries an INLINE bearer credential (any
* `Bearer <value>` that is not the `{env:…}` interpolation) the rule that
* decides whether a backup copy must be tightened to 0600. */
export function textCarriesInlineBearer(text: string): boolean {
return /Bearer\s+(?!\{env:)\S/.test(text);
}
/** Unique-suffix backup (`<config>.bak-<hex>`): two overlapping runs can
* never clobber each other's snapshot. The config-dir lock covers the WRITE,
* but a backup must survive until the caller's post-write verification (the
* harness network smoke) which runs AFTER the lock is released, so a fixed
* `.bak` name would let run B's writer overwrite run A's snapshot and a
* failed run A would then restore (and revoke against) run B's state.
* chmod 0600 whenever the copied content carries an inline bearer
* (copyFileSync onto a fresh path takes the source mode, but a hand-loosened
* source must not propagate a loose mode to a token-bearing backup). */
function createUniqueBackup(configPath: string, priorText: string): string {
const backupPath = `${configPath}.bak-${randomBytes(6).toString('hex')}`;
copyFileSync(configPath, backupPath);
if (textCarriesInlineBearer(priorText)) chmodSync(backupPath, 0o600);
return backupPath;
}
// No explicit eol: jsonc-parser detects and preserves the file's own EOLs
// (verified: a CRLF config keeps CRLF through modify/applyEdits).
const FORMATTING = { formattingOptions: { insertSpaces: true, tabSize: 2 } };
// ── Write ───────────────────────────────────────────────────────────────────
/**
* Idempotently write the managed `mcp.<name>` entry via a comment-preserving
* surgical edit. Refuses foreign entries; replaces ours-same-source silently;
* replaces ours-other-source only when `allowReplaceOtherSource` (callers
* warn first). Validates the render before the atomic swap.
*/
export function writeOpencodeMcpEntry(
configPath: string,
entry: OpencodeMcpEntry,
opts: { expect?: OpencodeEntryExpectation; allowReplaceOtherSource?: boolean } = {},
): WriteOpencodeEntryResult {
assertEntryName(entry.name);
if (entry.kind === 'remote' && entry.tokenMode === 'inline' && !entry.bearerToken) {
throw new Error('inline token mode requires a bearerToken');
}
const notes: string[] = [];
const snippet = opencodeEntrySnippet(entry);
const { text, existed } = readConfigRaw(configPath);
const parsed = parseOpencodeConfig(text, configPath, snippet);
const priorKind = opencodeEntryKind(parsed, entry.name, opts.expect);
if (priorKind === 'foreign') {
throw new Error(
`mcp.${entry.name} in ${configPath} is not a gbrain-managed entry — refusing to overwrite it. ` +
`Remove it (or pick another --name) and re-run.`,
);
}
if (priorKind === 'ours-other-source' && !opts.allowReplaceOtherSource) {
// Caller-appropriate refusal text: on the REMOTE path (expect.url — the
// harness/connect lanes) no GBRAIN_SOURCE is involved, and connect's
// documented escape hatch is --force; the GBRAIN_SOURCE wording belongs
// to the local/workspace lane only.
throw new Error(
opts.expect?.url !== undefined
? `mcp.${entry.name} in ${configPath} is a gbrain registration that does not match this endpoint ` +
`(${opts.expect.url}) — another install or lane owns it; pass --force to replace it, or pick another --name.`
: `mcp.${entry.name} in ${configPath} belongs to a DIFFERENT gbrain workspace ` +
`(GBRAIN_SOURCE mismatch) — re-run with the overwrite confirmation to reroute it, or pick another --name.`,
);
}
if (priorKind === 'ours-other-source') {
notes.push(
opts.expect?.url !== undefined
? `replaced a gbrain registration that did not match this endpoint (url/lane mismatch).`
: `replaced a gbrain registration that pointed at a different workspace (source mismatch).`,
);
}
const baseText = text.trim() === '' ? '{\n "$schema": "https://opencode.ai/config.json"\n}\n' : text;
const edits = modify(baseText, ['mcp', entry.name], entryValue(entry), FORMATTING);
const nextText = applyEdits(baseText, edits);
// Post-render validation: parse + deep-assert our entry + assert every
// OTHER top-level key survives. Any failure leaves the original untouched.
const rendered = parseOpencodeConfig(nextText, configPath, snippet);
const renderedMcp = rendered.mcp as Record<string, unknown> | undefined;
const ours = renderedMcp?.[entry.name];
if (JSON.stringify(ours) !== JSON.stringify(entryValue(entry))) {
throw new Error(
`post-render validation failed: mcp.${entry.name} did not round-trip — original file left untouched.`,
);
}
for (const key of Object.keys(parsed)) {
if (key === 'mcp') continue;
if (JSON.stringify(rendered[key]) !== JSON.stringify(parsed[key])) {
throw new Error(
`post-render validation failed: top-level key "${key}" changed — original file left untouched.`,
);
}
}
if (typeof parsed.mcp === 'object' && parsed.mcp !== null) {
for (const key of Object.keys(parsed.mcp as Record<string, unknown>)) {
if (key === entry.name) continue;
const before = (parsed.mcp as Record<string, unknown>)[key];
const after = renderedMcp?.[key];
if (JSON.stringify(after) !== JSON.stringify(before)) {
throw new Error(
`post-render validation failed: mcp.${key} (not ours) changed — original file left untouched.`,
);
}
}
}
const secret = entryCarriesSecret(entry);
let backupPath: string | null = null;
if (existed) {
backupPath = createUniqueBackup(configPath, text);
if (secret) chmodSync(backupPath, 0o600); // re-runs: the backup carries the previous token
}
atomicWriteTextFile(configPath, nextText, secret ? { forceMode: 0o600 } : { freshMode: 0o644 });
if (secret && existed) {
notes.push(`${configPath} tightened to 0600 — it now carries a bearer token.`);
}
return {
configPath,
replacedPrior: priorKind !== 'absent',
priorKind,
backupPath,
writtenText: nextText,
notes,
};
}
// ── Remove ──────────────────────────────────────────────────────────────────
/**
* Remove the managed entry (fingerprint-keyed; everything else survives
* byte-for-byte). Absent file / absent entry are calm no-ops. Foreign
* entries refuse removal never deletes what gbrain does not own.
* `skipOtherSource` turns an `ours-other-source` match into a calm skip-with-
* note instead of a removal (the uninstall sweep passes it: a gbrain entry
* from a DIFFERENT workspace is not this uninstall's to delete).
*/
export function removeOpencodeMcpEntry(
configPath: string,
name: string,
expect: OpencodeEntryExpectation = {},
opts: { skipOtherSource?: boolean } = {},
): RemoveOpencodeEntryResult {
assertEntryName(name);
const notes: string[] = [];
if (!existsSync(configPath)) {
return { configPath, removed: false, backupPath: null, notes: ['no opencode config — nothing to remove'] };
}
const { text } = readConfigRaw(configPath);
const parsed = parseOpencodeConfig(text, configPath);
const kind = opencodeEntryKind(parsed, name, expect);
if (kind === 'absent') {
return { configPath, removed: false, backupPath: null, notes: ['no gbrain-managed entry — nothing to remove'] };
}
if (kind === 'foreign') {
throw new Error(
`mcp.${name} in ${configPath} is not a gbrain-managed entry — refusing to remove it.`,
);
}
if (kind === 'ours-other-source' && opts.skipOtherSource) {
return {
configPath,
removed: false,
backupPath: null,
notes: [
`mcp.${name} in ${configPath} belongs to a DIFFERENT gbrain workspace (source mismatch) — left in place.`,
],
};
}
if (kind === 'ours-other-source') {
notes.push('removed a gbrain registration that pointed at a different workspace (source mismatch).');
}
const edits = modify(text, ['mcp', name], undefined, FORMATTING);
const nextText = applyEdits(text, edits);
parseOpencodeConfig(nextText, configPath); // never leave opencode unreadable
// Unique backup, 0600 when the copied content carries an inline bearer —
// the removed entry may BE the token-bearing one, and a fixed-name copy
// onto a pre-existing loose-mode backup would keep the loose mode.
const backupPath = createUniqueBackup(configPath, text);
atomicWriteTextFile(configPath, nextText);
return { configPath, removed: true, backupPath, notes };
}
// ── Sibling-global reconcile (the two-filename merge blind spot) ────────────
/**
* opencode merges the user-global `opencode.json` AND `opencode.jsonc` when
* both exist. Before writing `mcp.<name>` into one of them, reconcile the
* SIBLING file: an ours-classified entry there is removed (one owner per
* name left in place it survives as a shadow registration whose merge
* winner is ambiguous, and a later removal of the primary "reveals" it); a
* FOREIGN entry refuses loudly naming BOTH files (same refusal posture as
* the primary-file foreign case the merge winner is not ours to fight
* over). No-op when the path is not a global-pair member or the sibling is
* absent/entry-less. Callers hold the opencode config-dir bootstrap lock
* (both files share the dir one lock covers both) and call this ONLY for
* user-global writes (project-scope sibling semantics are unobserved).
*/
export function reconcileOpencodeSiblingGlobal(
configPath: string,
name: string,
expect: OpencodeEntryExpectation = {},
): { siblingPath: string | null; removed: boolean; notes: string[] } {
const siblingPath = opencodeGlobalSiblingPath(configPath);
if (!siblingPath || !existsSync(siblingPath)) return { siblingPath, removed: false, notes: [] };
const { text } = readConfigRaw(siblingPath);
const parsed = parseOpencodeConfig(text, siblingPath);
const kind = opencodeEntryKind(parsed, name, expect);
if (kind === 'absent') return { siblingPath, removed: false, notes: [] };
if (kind === 'foreign') {
throw new Error(
`mcp.${name} in ${siblingPath} is not a gbrain-managed entry — opencode merges ${siblingPath} AND ` +
`${configPath} when both exist, so writing mcp.${name} into ${configPath} would fight it with an ` +
`ambiguous merge winner. Remove it (or pick another --name) and re-run.`,
);
}
const r = removeOpencodeMcpEntry(siblingPath, name, expect);
const notes = [
`removed the gbrain mcp.${name} entry from ${siblingPath} — opencode merges both global filenames, and the ` +
`registration being written lands in ${configPath} (one owner per name).`,
...r.notes,
];
return { siblingPath, removed: r.removed, notes };
}
/**
* True when `mcp.<name>` exists as a REMOTE-type entry (regardless of
* ownership). The workspace stdio lane consults this before writing a local
* entry into the user-global config: a remote entry under our name is either
* the harness lane's (bootstrap harness) or foreign either way the stdio
* lane must not fight it (the codexBlockOwnsName analog, #4043 ownership
* rule). Best-effort: unreadable/unparseable configs return false (the write
* path re-checks with full refusal semantics).
*/
export function opencodeRemoteEntryExists(configPath: string, name: string): boolean {
try {
const { text, existed } = readConfigRaw(configPath);
if (!existed) return false;
const parsed = parseOpencodeConfig(text, configPath);
const mcp = parsed.mcp;
if (typeof mcp !== 'object' || mcp === null) return false;
const entry = (mcp as Record<string, unknown>)[name];
return typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).type === 'remote';
} catch {
return false;
}
}
// ── Status/recovery helpers ─────────────────────────────────────────────────
/**
* Recover the inline bearer from OUR remote entry (harness `--status` token
* liveness the receipt never stores the token). Returns null when the file
* or entry is absent, foreign, env-mode, or unreadable as JSONC.
*/
export function parseOpencodeEntryBearer(configPath: string, name: string, expectUrl?: string): string | null {
try {
const { text, existed } = readConfigRaw(configPath);
if (!existed) return null;
const parsed = parseOpencodeConfig(text, configPath);
const kind = opencodeEntryKind(parsed, name, { url: expectUrl });
if (kind !== 'ours-same-source') return null;
const entry = (parsed.mcp as Record<string, unknown>)[name] as Record<string, unknown>;
if (entry.type !== 'remote') return null;
const auth = (entry.headers as Record<string, unknown> | undefined)?.Authorization;
if (typeof auth !== 'string' || !auth.startsWith('Bearer ')) return null;
const token = auth.slice('Bearer '.length);
if (token.includes('{env:')) return null; // env-interpolated — no inline token to recover
return token || null;
} catch {
return null;
}
}
+5 -3
View File
@@ -209,7 +209,7 @@ export const PHASES: PhaseSpec[] = [
title: 'Identity interview (confirmed read-back)',
resume_hint:
'gbrain bootstrap interview --init, then --set each answer, then --confirm <hash>. ' +
'Claude Code only: also record the MCP scope consent (--set MCP_SCOPE <project|user>) BEFORE --confirm',
'Claude Code and opencode: also record the MCP scope consent (--set MCP_SCOPE <project|user>) BEFORE --confirm',
detect: (ws) => {
const exists = existsSync(interviewStatePath(ws));
const st = interviewStatus(ws);
@@ -265,8 +265,10 @@ export const PHASES: PhaseSpec[] = [
// outside the harness being wired). Advisory prose; the grep pins in
// scripts/check-bootstrap-templates.sh §(e) are the enforcement.
resume_hint:
'gbrain bootstrap hooks --harness <claude-code|codex> — MCP scope consent is ' +
'Claude Code only (recorded during the interview, pre-confirm); Codex registrations are always user-global (no scope flag)',
'gbrain bootstrap hooks --harness <claude-code|codex|opencode> — MCP scope consent applies on ' +
'Claude Code and opencode (recorded during the interview, pre-confirm; opencode defaults to ' +
'user-global — the sharing-safe choice, since it spawns project-config servers with no trust gate); ' +
'Codex registrations are always user-global (no scope flag)',
detect: (ws, ctx) => {
const regs = ctx.receipt?.registrations ?? [];
if (regs.length > 0) {
+1 -1
View File
@@ -98,7 +98,7 @@ export function resolveBrainDataDir(gbrainHomeDir: string): string {
// ---------------------------------------------------------------------------
export interface RegistrationRemovalRequest {
host: 'claude-code' | 'codex';
host: 'claude-code' | 'codex' | 'opencode';
scope: string;
detail?: string;
}
+162
View File
@@ -0,0 +1,162 @@
/**
* opencode runner invokes the real `opencode` binary (SST terminal agent,
* opencode.ai) in a tempdir with a BRIEF.md prompt. Live mode only.
*
* Invocation pattern (verified against a pinned hermetic install, v1.18.18
* see docs/mcp/OPENCODE-CLI-PIN.md):
* opencode run "<brief>" --format default
*
* `run` is opencode's headless one-shot: prompt in, final answer text ALONE
* on stdout (banner/UI on stderr), exit 0. `--format default` is passed
* explicitly so an upstream default flip cannot silently change the
* transcript shape. NO `--auto`: MCP tool calls fire in run mode without any
* permission flag (verified the keyless SMOKE recalled a nonce through
* gbrain_recall with the flag absent). NO `-m`: live mode runs the
* OPERATOR's configured opencode, whose model pin (or the anonymous free
* tier) is the point of the measurement.
*
* Naming: opencode (SST, opencode.ai, npm `opencode-ai`) is not OpenClaw
* (the platform with its own runner) and not the original `opencode` CLI
* that became Crush the version preamble below makes a mis-bound claimant
* diagnosable (the SST CLI answers `--version` with a BARE semver).
*
* Hermeticity posture (deliberate): live mode runs the OPERATOR's configured
* opencode the real XDG config/data dirs are inherited unless
* XDG_CONFIG_HOME/XDG_DATA_HOME point elsewhere against a hermetic BRAIN.
* opencode-specific contamination channel (observed): the user-global
* opencode config is read for EVERY run, and a project opencode.json in the
* cwd spawns its local MCP servers with NO trust gate. Live-mode workspaces
* are harness-created tempdirs (no project config in reach), but a global
* mcp.gbrain entry would bind the operator's REAL brain while the oracle
* probes the hermetic one `invoke()` logs a loud warning for that case.
* The fully hermetic lane is the door e2e
* (install-real-opencode.serial.test.ts): fresh HOME + XDG dirs.
* OPENCODE_CONFIG_CONTENT (inline whole-config env) is deliberately NOT
* forwarded it is a config-shadowing channel, and it was observed inert in
* 1.18.18 anyway (OPENCODE-CLI-PIN.md §Path seams).
*
* Binary resolution: $OPENCODE_BIN > `which opencode` > unavailable.
*/
import { execFileSync } from 'child_process';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import {
BASE_ENV_ALLOWLIST,
detectBinary,
filterAllowlistEnv,
type AgentRunner,
type DetectResult,
type InvokeOpts,
type InvokeResult,
} from '../agent-runner.ts';
import { spawnWithCapture } from '../transcript-capture.ts';
/**
* Allow-list for env propagation when spawning opencode. The base list
* carries ONLY Anthropic + OpenAI provider keys opencode's headline
* feature is multi-provider, so the delta NAMES the additional provider
* keys a live-lane operator may be running on (xAI, Google, OpenRouter);
* anything not named here silently strips and reads as a misleading
* agent-auth failure. Plus the opencode seams: XDG dirs (config/auth
* relocation for hermetic callers), OPENCODE_CONFIG(_DIR) (observed inert
* in 1.18.18 but forwarded so a future release that activates them behaves
* the way the caller intended), and OPENCODE_DISABLE_AUTOUPDATE (the env
* half of the double autoupdate kill).
*/
const ENV_ALLOWLIST = [
...BASE_ENV_ALLOWLIST,
'XAI_API_KEY',
'GOOGLE_GENERATIVE_AI_API_KEY',
'GEMINI_API_KEY',
'OPENROUTER_API_KEY',
'XDG_CONFIG_HOME',
'XDG_DATA_HOME',
'OPENCODE_CONFIG',
'OPENCODE_CONFIG_DIR',
'OPENCODE_DISABLE_AUTOUPDATE',
];
export class OpencodeRunner implements AgentRunner {
readonly name = 'opencode';
async detect(): Promise<DetectResult> {
return detectBinary('OPENCODE_BIN', 'opencode');
}
async invoke(opts: InvokeOpts): Promise<InvokeResult> {
const detected = await this.detect();
if (!detected.available || !detected.binPath) {
throw new Error(`opencode runner unavailable: ${detected.reason ?? 'unknown'}`);
}
const args = ['run', opts.brief, '--format', 'default'];
const env = filterAllowlistEnv(ENV_ALLOWLIST, opts.env);
this.warnOnGlobalGbrainEntry(env);
// Version preamble: recorded as a plain stdout transcript event. The SST
// CLI answers with a BARE semver (`1.18.18` — no name, no build hash);
// any other shape means a colliding `opencode` claimant is bound.
// execFileSync (no shell) with the SAME filtered env as the turn itself.
try {
const version = execFileSync(detected.binPath, ['--version'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 15_000,
env: env as NodeJS.ProcessEnv,
cwd: opts.cwd,
}).trim();
opts.transcriptSink.write({
ts: Date.now(),
channel: 'stdout',
bytes: Buffer.from(`[opencode-runner preamble] version: ${version}\n`, 'utf-8'),
});
} catch {
// Preamble is diagnostic only — never fail the run for it.
}
const result = await spawnWithCapture(detected.binPath, args, {
cwd: opts.cwd,
env,
timeoutMs: opts.timeoutMs,
transcriptSink: opts.transcriptSink,
});
return { exitCode: result.exitCode, durationMs: result.durationMs };
}
/**
* Loud tripwire for the global-config contamination channel: when the
* config dir opencode will resolve carries an mcp.gbrain entry, a live
* turn routes gbrain tool calls at the operator's REAL brain while the
* oracle probes the hermetic one. Warning only (live mode deliberately
* runs the operator's agent); the hermetic lane is the door e2e. Checks
* BOTH filenames opencode merges opencode.json AND opencode.jsonc.
*/
private warnOnGlobalGbrainEntry(env: Record<string, string>): void {
try {
const xdg = env.XDG_CONFIG_HOME ?? process.env.XDG_CONFIG_HOME;
const home = env.HOME ?? process.env.HOME;
const cfgDir = xdg ? join(xdg, 'opencode') : home ? join(home, '.config', 'opencode') : null;
if (!cfgDir) return;
for (const file of ['opencode.jsonc', 'opencode.json']) {
const p = join(cfgDir, file);
if (!existsSync(p)) continue;
// Loose containment probe, not a parse: the global config is JSONC
// (comments legal), and a substring hit is enough for a warning.
const text = readFileSync(p, 'utf-8');
if (/"gbrain"\s*:/.test(text) && /"mcp"\s*:/.test(text)) {
console.warn(
`[opencode-runner] WARNING: ${p} carries an mcp.gbrain entry. opencode reads the ` +
"user-global config on every run, so this live turn may bind the OPERATOR'S REAL " +
'brain instead of the hermetic one. Use a scratch HOME/XDG_CONFIG_HOME for clean ' +
'measurements (docs/mcp/OPENCODE-CLI-PIN.md).',
);
return;
}
}
} catch {
// Best-effort tripwire — unreadable/invalid config is not an error here.
}
}
}
+17 -17
View File
@@ -23,7 +23,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'backfill': ['--aliases', '--all', '--batch-size', '--brain', '--concurrency', '--dry-run', '--fresh', '--help', '--include-null-signature', '--json', '--keep-index', '--list', '--max-errors', '--max-rows', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--source', '--stale', '--supersessions', '--thin'],
'bench': ['--baseline', '--brain', '--explain', '--force', '--from', '--help', '--json', '--label', '--lang', '--limit', '--markdown', '--multimodal', '--near-symbol', '--restore-only', '--source', '--stale', '--symbol-kind', '--thin', '--threshold-jaccard', '--threshold-latency-multiplier', '--threshold-top1', '--to', '--tool'],
'book-mirror': ['--aliases', '--all', '--allow-empty', '--apply', '--asof', '--author', '--auto', '--background', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--budget-usd-per-day', '--by-mention', '--chapters-dir', '--content', '--context-file', '--date', '--days', '--dry-run', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--kind', '--limit', '--max-turns', '--max-usd', '--mode', '--model', '--multimodal', '--no-confirm', '--no-embedding', '--no-extract', '--no-follow', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--timeout-ms', '--title', '--token-ttl', '--trusted-extraction', '--url', '--with-db', '--yes'],
'bootstrap': ['--abbrev-ref', '--abort', '--accept-visibility-change-consequences', '--active', '--all', '--allow-unverified-remote', '--brain', '--branch', '--cached', '--compile', '--confirm', '--count', '--delete-brain', '--diff-filter', '--env', '--error-unmatch', '--exclude-standard', '--fast', '--file', '--flag', '--force', '--from-pages', '--full', '--gbrain-bin', '--get', '--git-dir', '--git-path', '--harness', '--heads', '--help', '--home', '--hostname', '--http', '--id', '--init', '--install', '--is-inside-work-tree', '--isolated', '--jq', '--json', '--local', '--minimal', '--name', '--name-only', '--no-capture', '--no-cron', '--no-embedding', '--no-hooks', '--no-verify', '--once', '--only', '--others', '--pat-file', '--path', '--pglite', '--porcelain', '--port', '--private', '--project', '--push', '--push-only', '--quiet', '--rebase', '--remove', '--repair', '--scope', '--scopes', '--set', '--short', '--show', '--show-toplevel', '--skip', '--source', '--status', '--surface', '--token', '--token-name', '--token-ttl', '--unset-all', '--url', '--user-hooks', '--verify', '--version', '--visibility', '--workspace', '--yes'],
'bootstrap': ['--abbrev-ref', '--abort', '--accept-visibility-change-consequences', '--active', '--all', '--allow-unverified-remote', '--auto', '--brain', '--branch', '--cached', '--compile', '--confirm', '--count', '--delete-brain', '--diff-filter', '--env', '--error-unmatch', '--exclude-standard', '--fast', '--file', '--flag', '--force', '--from-pages', '--full', '--gbrain-bin', '--get', '--git-dir', '--git-path', '--harness', '--heads', '--help', '--home', '--hostname', '--http', '--id', '--init', '--install', '--is-inside-work-tree', '--isolated', '--jq', '--json', '--local', '--minimal', '--name', '--name-only', '--no-capture', '--no-cron', '--no-embedding', '--no-hooks', '--no-verify', '--once', '--only', '--others', '--pat-file', '--path', '--pglite', '--porcelain', '--port', '--private', '--project', '--pure', '--push', '--push-only', '--quiet', '--rebase', '--remove', '--repair', '--scope', '--scopes', '--set', '--short', '--show', '--show-toplevel', '--skip', '--source', '--status', '--surface', '--token', '--token-name', '--token-ttl', '--unset-all', '--url', '--user-hooks', '--verify', '--version', '--visibility', '--workspace', '--yes'],
'brainstorm': ['--aliases', '--all', '--brain', '--chunker-debug', '--code', '--compile', '--fast', '--file', '--fix', '--force', '--force-rechunk', '--force-resume', '--from-pages', '--full', '--help', '--http', '--include-null-signature', '--json', '--judge-model', '--lang', '--limit', '--list-runs', '--markdown', '--max-cost', '--max-far-set', '--max-ideas-per-judge-call', '--model', '--no-embed', '--no-embedding', '--no-extract', '--no-save', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--retry-failed', '--retry-judge', '--save', '--source', '--stale', '--strict-budget', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--yes'],
'cache': ['--brain', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--source', '--surface', '--token-ttl', '--yes'],
'calibration': ['--ab', '--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', '--dry-run', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--holder', '--http', '--image', '--include-null-signature', '--json', '--key-prefix', '--kind', '--lang', '--limit', '--markdown', '--max-usd', '--mode', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--phase', '--progress-interval', '--progress-json', '--quiet', '--regenerate', '--repo', '--reset', '--resolve', '--restore-only', '--save', '--scrub-gstack', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--symbol-kind', '--thin', '--token-ttl', '--trusted-extraction', '--undo-wave', '--url', '--with-calibration', '--with-db', '--yes'],
@@ -32,23 +32,23 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'check-backlinks': ['--background', '--brain', '--brain-wide-max-cost-usd', '--dir', '--dry-run', '--explain', '--follow', '--help', '--include-frontmatter', '--json', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--source', '--stale', '--timeout', '--type'],
'check-resolvable': ['--brain', '--dry-run', '--fix', '--help', '--json', '--skills-dir', '--source', '--strict', '--verbose'],
'check-update': ['--all', '--brain', '--check', '--dim', '--ff-only', '--help', '--json', '--markdown', '--migrate-only', '--non-interactive', '--refresh-cache', '--source', '--swap-only', '--to', '--version', '--yes'],
'claw-test': ['--ab', '--agent', '--all', '--auto-update', '--brain', '--break-lock', '--build-index', '--by-mention', '--compile', '--days', '--dir', '--exclusive', '--force', '--force-retry', '--force-schema', '--from-meetings', '--help', '--history', '--http', '--json', '--keep-tempdir', '--lang', '--list-agents', '--live', '--local', '--locks', '--markdown', '--max-age', '--message', '--multimodal', '--no-embed', '--no-embedding', '--no-extract', '--output-format', '--path', '--pglite', '--phase', '--priority', '--progress-json', '--refresh-unqualified', '--remediate', '--rollback', '--run-id', '--scenario', '--skip-verify', '--source', '--stale', '--surface', '--transcripts', '--undo-wave', '--use-captured-snapshot', '--version', '--with-calibration', '--yes'],
'claw-test': ['--ab', '--agent', '--all', '--auto', '--auto-update', '--brain', '--break-lock', '--build-index', '--by-mention', '--compile', '--days', '--dir', '--exclusive', '--force', '--force-retry', '--force-schema', '--format', '--from-meetings', '--help', '--history', '--http', '--json', '--keep-tempdir', '--lang', '--list-agents', '--live', '--local', '--locks', '--markdown', '--max-age', '--message', '--multimodal', '--no-embed', '--no-embedding', '--no-extract', '--output-format', '--path', '--pglite', '--phase', '--priority', '--progress-json', '--refresh-unqualified', '--remediate', '--rollback', '--run-id', '--scenario', '--skip-verify', '--source', '--stale', '--surface', '--transcripts', '--undo-wave', '--use-captured-snapshot', '--version', '--with-calibration', '--yes'],
'code-callees': ['--aliases', '--all', '--all-sources', '--brain', '--chunker-debug', '--clone-dir', '--confirm-destructive', '--federated', '--force', '--help', '--include-null-signature', '--json', '--limit', '--no-extract', '--no-federated', '--no-json', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--url', '--url-managed', '--yes'],
'code-callers': ['--aliases', '--all', '--all-sources', '--brain', '--chunker-debug', '--clone-dir', '--confirm-destructive', '--federated', '--force', '--help', '--include-null-signature', '--json', '--limit', '--no-extract', '--no-federated', '--no-json', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--url', '--url-managed', '--yes'],
'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', '--bearer-token-env-var', '--bind', '--brain', '--client-id', '--client-secret', '--force', '--grant-types', '--help', '--http', '--install', '--json', '--name', '--oauth', '--public-url', '--register', '--scope', '--scopes', '--show-token', '--source', '--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', '--json', '--name', '--oauth', '--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', '--exclude-standard', '--exclusive', '--explain', '--fast', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--grant-types', '--harness', '--health-interval', '--help', '--history', '--home', '--http', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--init', '--input', '--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-only', '--migrate-only', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-cron', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-mutate', '--no-verify', '--oauth-client-secret', '--older-than', '--once', '--others', '--overwrite', '--parallel', '--params', '--pat-file', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--priority', '--probe-pglite', '--progress-interval', '--progress-json', '--project', '--push-only', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--remediate', '--remediation-plan', '--remove', '--repo', '--reset', '--resolve', '--restore-only', '--resume', '--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-ttl', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--unset-all', '--untracked-files', '--url', '--use-captured-snapshot', '--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', '--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-id', '--stale', '--suite', '--suites', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--take', '--task', '--thin', '--threshold', '--timeout', '--to', '--token-ttl', '--top-k', '--undo', '--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', '--stale', '--supersessions', '--thin', '--timeout', '--workers'],
'embed': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--catch-up', '--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', '--token-ttl', '--version'],
'enrich': ['--aliases', '--all', '--all-sources', '--allow-empty', '--apply', '--asof', '--auto', '--background', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd-per-day', '--by-mention', '--clone-dir', '--code', '--concurrency', '--confirm-destructive', '--content', '--date', '--days', '--detail', '--dry-run', '--embedding-dimensions', '--embedding-model', '--entities', '--explain', '--fast', '--federated', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--judge-model', '--kind', '--lang', '--limit', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--max-usd', '--min-context', '--mode', '--model', '--multimodal', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--offset', '--older-than', '--order', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--reenrich-after', '--remediate', '--reset', '--resolve', '--restore-only', '--resume', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--source-id', '--stale', '--stats', '--supersessions', '--surface', '--symbol-kind', '--thin', '--thin-threshold', '--timeout', '--token-ttl', '--trusted-extraction', '--types', '--url', '--url-managed', '--version', '--with-db', '--workers', '--yes'],
'eval': ['--ab-relational', '--against', '--aliases', '--all', '--allow-regression', '--background', '--baseline', '--batch', '--brain', '--brain-wide-max-cost-usd', '--budget-usd', '--budget-usd-answer', '--budget-usd-retrieval', '--committed-baseline', '--compare', '--compare-limit', '--concurrent', '--config-a', '--config-b', '--corpus', '--cycles', '--days', '--dedup-cosine', '--dedup-max-per-page', '--dedup-type-ratio', '--dimensions', '--distance-min', '--embedding-dimensions', '--embedding-model', '--expand', '--explain', '--fast', '--fixtures', '--follow', '--force', '--from-capture', '--from-db', '--from-pages', '--gold', '--grounding-min', '--harness', '--help', '--http', '--include-holdout', '--include-null-signature', '--input', '--json', '--judge', '--justification', '--k', '--limit', '--llm', '--max-pair-chars', '--max-tokens', '--max-usd', '--md', '--metric', '--min-recall', '--mode', '--model', '--models', '--modes', '--multimodal', '--name', '--no', '--no-cache', '--no-embed', '--no-embedding', '--no-expand', '--no-extract', '--no-llm', '--older-than', '--out', '--output', '--output-dir', '--parallel', '--pattern', '--pending', '--progress-interval', '--progress-json', '--qrels', '--queries-file', '--query', '--questions', '--quiet', '--receipt-dir', '--refresh-cache', '--remediate', '--reset', '--resolve', '--rrf-k', '--rubric-version', '--runs', '--sampling', '--save', '--seed', '--severity', '--short', '--show-toplevel', '--since', '--skip-replay', '--slot-a-model', '--slot-b-model', '--slot-c-model', '--slug', '--slug-prefix', '--source', '--stale', '--strategy', '--strict', '--suite', '--suites', '--supersessions', '--surface', '--task', '--thin', '--threshold', '--threshold-expected-top1', '--threshold-first-relevant-hit', '--threshold-jaccard', '--threshold-latency-multiplier', '--threshold-recall-at-k', '--threshold-top1', '--timeout', '--token-ttl', '--tool', '--top-k', '--top-regressions', '--until', '--update-baseline', '--usefulness-min', '--verbose', '--version', '--with-code-intel', '--yes'],
'embed': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--catch-up', '--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'],
'enrich': ['--aliases', '--all', '--all-sources', '--allow-empty', '--apply', '--asof', '--auto', '--background', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd-per-day', '--by-mention', '--clone-dir', '--code', '--concurrency', '--confirm-destructive', '--content', '--date', '--days', '--detail', '--dry-run', '--embedding-dimensions', '--embedding-model', '--entities', '--explain', '--fast', '--federated', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--judge-model', '--kind', '--lang', '--limit', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--max-usd', '--min-context', '--mode', '--model', '--multimodal', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--offset', '--older-than', '--order', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--reenrich-after', '--remediate', '--reset', '--resolve', '--restore-only', '--resume', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--source-id', '--stale', '--stats', '--supersessions', '--surface', '--symbol-kind', '--thin', '--thin-threshold', '--timeout', '--to', '--token-ttl', '--trusted-extraction', '--types', '--url', '--url-managed', '--version', '--with-db', '--workers', '--yes'],
'eval': ['--ab-relational', '--against', '--aliases', '--all', '--allow-regression', '--background', '--baseline', '--batch', '--brain', '--brain-wide-max-cost-usd', '--budget-usd', '--budget-usd-answer', '--budget-usd-retrieval', '--committed-baseline', '--compare', '--compare-limit', '--concurrent', '--config-a', '--config-b', '--corpus', '--cycles', '--days', '--dedup-cosine', '--dedup-max-per-page', '--dedup-type-ratio', '--dimensions', '--distance-min', '--embedding-dimensions', '--embedding-model', '--expand', '--explain', '--fast', '--fixtures', '--follow', '--force', '--from-capture', '--from-db', '--from-pages', '--gold', '--grounding-min', '--harness', '--help', '--http', '--include-holdout', '--include-null-signature', '--input', '--json', '--judge', '--justification', '--k', '--limit', '--llm', '--max-pair-chars', '--max-tokens', '--max-usd', '--md', '--metric', '--min-recall', '--mode', '--model', '--models', '--modes', '--multimodal', '--name', '--no', '--no-cache', '--no-embed', '--no-embedding', '--no-expand', '--no-extract', '--no-llm', '--older-than', '--out', '--output', '--output-dir', '--parallel', '--pattern', '--pending', '--progress-interval', '--progress-json', '--qrels', '--queries-file', '--query', '--questions', '--quiet', '--receipt-dir', '--refresh-cache', '--remediate', '--reset', '--resolve', '--rrf-k', '--rubric-version', '--runs', '--sampling', '--save', '--seed', '--severity', '--short', '--show-toplevel', '--since', '--skip-replay', '--slot-a-model', '--slot-b-model', '--slot-c-model', '--slug', '--slug-prefix', '--source', '--stale', '--strategy', '--strict', '--suite', '--suites', '--supersessions', '--surface', '--task', '--thin', '--threshold', '--threshold-expected-top1', '--threshold-first-relevant-hit', '--threshold-jaccard', '--threshold-latency-multiplier', '--threshold-recall-at-k', '--threshold-top1', '--timeout', '--to', '--token-ttl', '--tool', '--top-k', '--top-regressions', '--until', '--update-baseline', '--usefulness-min', '--verbose', '--version', '--with-code-intel', '--yes'],
'export': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--dir', '--explain', '--federated', '--fix', '--follow', '--help', '--include-null-signature', '--json', '--lang', '--markdown', '--multimodal', '--near-symbol', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--restore-only', '--slug-prefix', '--source', '--stale', '--supersessions', '--symbol-kind', '--thin', '--timeout', '--type'],
'extract': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--by-mention', '--catch-up', '--code', '--concurrency', '--dir', '--dry-run', '--explain', '--federated', '--follow', '--from-meetings', '--help', '--include-frontmatter', '--include-null-signature', '--infer-dates', '--json', '--kind', '--lang', '--markdown', '--max-age', '--max-cost-usd', '--multimodal', '--name-status', '--near-symbol', '--ner', '--no-extract', '--no-federated', '--older-than', '--pack', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--restore-only', '--run-id', '--since', '--slug', '--source', '--source-id', '--stale', '--strategy', '--supersessions', '--symbol-kind', '--thin', '--timeout', '--type', '--verbose', '--workers', '--yes'],
'extract-conversation-facts': ['--aliases', '--all', '--all-sources', '--background', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--by-mention', '--clone-dir', '--code', '--concurrency', '--confirm-destructive', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fix', '--follow', '--force', '--force-break-lock', '--help', '--include-null-signature', '--json', '--judge-model', '--lang', '--limit', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--model', '--multimodal', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--older-than', '--override-disabled', '--path', '--pattern', '--pending', '--pglite', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--restore-only', '--segment-limit', '--session', '--since', '--sleep', '--slug', '--source', '--source-id', '--stale', '--supabase', '--supersessions', '--symbol-kind', '--thin', '--timeout', '--types', '--url', '--url-managed', '--version', '--workers', '--yes'],
'extract-conversation-facts': ['--aliases', '--all', '--all-sources', '--background', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--by-mention', '--clone-dir', '--code', '--concurrency', '--confirm-destructive', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fix', '--follow', '--force', '--force-break-lock', '--help', '--include-null-signature', '--json', '--judge-model', '--lang', '--limit', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--model', '--multimodal', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--older-than', '--override-disabled', '--path', '--pattern', '--pending', '--pglite', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--restore-only', '--segment-limit', '--session', '--since', '--sleep', '--slug', '--source', '--source-id', '--stale', '--supabase', '--supersessions', '--symbol-kind', '--thin', '--timeout', '--to', '--types', '--url', '--url-managed', '--version', '--workers', '--yes'],
'features': ['--aliases', '--all', '--auto-fix', '--background', '--batch-size', '--brain', '--by-mention', '--catch-up', '--concurrency', '--dir', '--explain', '--from-meetings', '--help', '--include-frontmatter', '--include-null-signature', '--infer-dates', '--json', '--kind', '--ner', '--no-extract', '--pace', '--pace-max-concurrency', '--pack', '--path', '--pattern', '--pending', '--priority', '--progress-json', '--quiet', '--repo', '--reset', '--resolve', '--run-id', '--since', '--slugs', '--source', '--source-id', '--stale', '--supersessions', '--thin', '--type', '--verbose', '--workers'],
'files': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--explain', '--fast', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--no-pointer', '--page', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--retry-failed', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--type', '--yes'],
'forget': ['--aliases', '--all', '--allow-empty', '--apply', '--as-context', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-tokens', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--grep', '--help', '--http', '--image', '--include-expired', '--include-null-signature', '--json', '--kind', '--limit', '--max-usd', '--mcp-only', '--mode', '--multimodal', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--query', '--quiet', '--reason', '--repo', '--reset', '--resolve', '--restore-only', '--rollup', '--save', '--session', '--session-id', '--since', '--since-last-run', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--today', '--token-ttl', '--trusted-extraction', '--url', '--watch', '--with-db', '--yes'],
@@ -56,32 +56,32 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'friction': ['--agent', '--base', '--brain', '--compare', '--help', '--hint', '--json', '--kind', '--message', '--no-redact', '--phase', '--redact', '--run-id', '--severity', '--source', '--transcript-path', '--transcripts'],
'frontmatter': ['--aliases', '--all', '--allow-catch-all', '--brain', '--cached', '--diff-filter', '--dry-run', '--exclude-standard', '--fast', '--fix', '--force', '--from-pages', '--get', '--help', '--http', '--include-catch-all', '--include-null-signature', '--json', '--name-only', '--name-status', '--no-embedding', '--no-extract', '--no-verify', '--others', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--strategy', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--uninstall', '--write-back'],
'graph-query': ['--aliases', '--all', '--brain', '--depth', '--direction', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-foreign', '--include-null-signature', '--json', '--lang', '--markdown', '--mcp-only', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--token-ttl', '--type'],
'hook': ['--aliases', '--all', '--allow-unverified-remote', '--batch-limit', '--brain', '--budget-ms', '--cached', '--count', '--delete-brain', '--detach', '--diff-filter', '--end-of-options', '--env', '--exclude-standard', '--fast', '--force', '--from-pages', '--get', '--harness', '--help', '--http', '--include-null-signature', '--jq', '--json', '--name-only', '--no-embedding', '--no-extract', '--once', '--others', '--path', '--pattern', '--pending', '--porcelain', '--project', '--quiet', '--remove', '--reset', '--resolve', '--show-current', '--show-toplevel', '--source', '--stale', '--stats', '--status', '--supersessions', '--surface', '--thin', '--timeout', '--token', '--token-ttl'],
'hook': ['--aliases', '--all', '--allow-unverified-remote', '--auto', '--batch-limit', '--brain', '--budget-ms', '--cached', '--count', '--delete-brain', '--detach', '--diff-filter', '--end-of-options', '--env', '--exclude-standard', '--fast', '--force', '--from-pages', '--get', '--harness', '--help', '--http', '--include-null-signature', '--jq', '--json', '--name-only', '--no-embedding', '--no-extract', '--once', '--others', '--path', '--pattern', '--pending', '--porcelain', '--project', '--pure', '--quiet', '--remove', '--reset', '--resolve', '--show-current', '--show-toplevel', '--source', '--stale', '--stats', '--status', '--supersessions', '--surface', '--thin', '--timeout', '--token', '--token-ttl'],
'import': ['--aliases', '--all', '--asof', '--background', '--brain', '--brain-wide-max-cost-usd', '--by-mention', '--cached', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--fix', '--follow', '--force', '--force-rechunk', '--fresh', '--from-pages', '--full', '--help', '--http', '--include-gitignored', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--multimodal', '--name-status', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--older-than', '--others', '--path', '--pattern', '--pending', '--pglite', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--since', '--skip-failed', '--source', '--source-id', '--stale', '--strategy', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--url', '--workers'],
'init': ['--all', '--brain', '--chat-model', '--check', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--entity', '--expansion-model', '--fast', '--flag', '--force', '--from-pages', '--grant-types', '--help', '--http', '--issuer-url', '--json', '--judge-model', '--key', '--mcp-only', '--mcp-url', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--path', '--pglite', '--provenance', '--schema-pack', '--scopes', '--skip-embed-check', '--source', '--stale', '--supabase', '--surface', '--to', '--token-ttl', '--touchpoint', '--url', '--version'],
'init': ['--all', '--brain', '--chat-model', '--check', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--embeddings', '--entity', '--expansion-model', '--fast', '--flag', '--force', '--from-pages', '--grant-types', '--help', '--http', '--issuer-url', '--json', '--judge-model', '--key', '--mcp-only', '--mcp-url', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--path', '--pglite', '--provenance', '--reranking', '--schema-pack', '--scopes', '--skip-embed-check', '--source', '--stale', '--supabase', '--surface', '--to', '--token-ttl', '--touchpoint', '--url', '--version'],
'integrations': ['--auto', '--brain', '--dry-run', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--overwrite', '--refresh', '--reranking', '--source', '--surface', '--target', '--token-ttl'],
'integrity': ['--aliases', '--all', '--auto', '--backend', '--background', '--brain', '--brain-wide-max-cost-usd', '--check', '--confidence', '--cost', '--dry-run', '--explain', '--fast', '--follow', '--force', '--fresh', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--limit', '--no-embedding', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--review-lower', '--skip-bare-tweet', '--skip-urls', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--type', '--url'],
'jobs': ['--abbrev-ref', '--aliases', '--all', '--allow-empty', '--allow-protected', '--allow-shell-jobs', '--apply', '--asof', '--auto', '--auto-fix', '--auto-with-prompt', '--background', '--backoff-delay', '--backoff-jitter', '--backoff-type', '--batch', '--batch-size', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--break-lock', '--budget-usd', '--budget-usd-per-day', '--by-mention', '--by-type', '--cached', '--catch-up', '--check', '--cli-path', '--cluster', '--cluster-errors', '--code', '--concurrency', '--confidence', '--confirm-destructive', '--content', '--date', '--days', '--delay', '--detach', '--diff-filter', '--dim', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--entities', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--federated-read', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--hard-deadline', '--health-interval', '--held-out', '--help', '--http', '--idempotency-key', '--image', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--infer-dates', '--inject-bootstrap', '--inline', '--input', '--install', '--interval', '--is-ancestor', '--job-id', '--job-isolation', '--json', '--kind', '--lang', '--limit', '--lock', '--markdown', '--max-age', '--max-attempts', '--max-cost-usd', '--max-crashes', '--max-rss', '--max-runtime-min', '--max-sources', '--max-stalled', '--max-usd', '--max-waiting', '--mcp-only', '--migrate-only', '--min-context', '--missing-path', '--mode', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--ner', '--nice', '--no', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-inject', '--no-mutate', '--no-pull', '--no-renames', '--no-schema-pack', '--no-verify', '--no-worker', '--non-interactive', '--now', '--offset', '--older-than', '--once', '--order', '--orphan', '--others', '--output', '--override-disabled', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--phase', '--pid-file', '--priority', '--progress-interval', '--progress-json', '--queue', '--quiet', '--redact-secrets', '--reenrich-after', '--refresh-cache', '--refresh-ms', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--review-lower', '--run-id', '--save', '--segment-limit', '--serial', '--session', '--session-id', '--short', '--show-toplevel', '--sigkill-rescue', '--since', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--sleep', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--stats', '--status', '--strategy', '--supersessions', '--surface', '--swap-only', '--symbol-kind', '--target', '--target-score', '--thin', '--thin-threshold', '--timeout', '--timeout-ms', '--to', '--token-ttl', '--trusted-extraction', '--type', '--types', '--uninstall', '--unsafe-bypass-dream-guard', '--url', '--user', '--verbose', '--verify', '--version', '--watch', '--wedge-rescue', '--with-db', '--workers', '--yes'],
'lint': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--exclude', '--explain', '--fast', '--fix', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl'],
'lsd': ['--brain', '--force-resume', '--help', '--json', '--judge-model', '--limit', '--list-runs', '--max-cost', '--max-far-set', '--max-ideas-per-judge-call', '--no-save', '--resume', '--retry-judge', '--save', '--source', '--strict-budget', '--yes'],
'maintain': ['--aliases', '--all', '--background', '--brain', '--break-lock', '--by-mention', '--catch-up', '--column', '--concurrency', '--content-audit', '--count', '--detach', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--force', '--force-retry', '--force-schema', '--from-meetings', '--full', '--help', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--index-audit', '--infer-dates', '--input', '--json', '--kind', '--lang', '--locks', '--markdown', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-usd', '--migrate-only', '--multimodal', '--near-symbol', '--ner', '--nice', '--no-extract', '--no-mutate', '--older-than', '--once', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--probe-pglite', '--progress-json', '--query', '--queue', '--quiet', '--rebuild-rollup', '--regenerate', '--remediate', '--remediation-plan', '--reset', '--resolve', '--restore-only', '--resume', '--run-id', '--safe', '--scope', '--since', '--skills-dir', '--skip-failed', '--slugs', '--source', '--source-id', '--stale', '--status', '--supabase', '--supersessions', '--symbol-kind', '--target', '--target-score', '--thin', '--to', '--top-k', '--type', '--unsafe-bypass-dream-guard', '--url', '--verbose', '--window', '--workers', '--yes'],
'migrate': ['--ab', '--aliases', '--all', '--auto-update', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--build-index', '--by-mention', '--catch-up', '--compile', '--days', '--dim', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--exclusive', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--from-meetings', '--from-pages', '--help', '--history', '--http', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--lang', '--locks', '--markdown', '--max-age', '--model', '--multimodal', '--name', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--non-interactive', '--pace', '--pace-max-concurrency', '--parallel', '--path', '--pattern', '--pending', '--phase', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--refresh-unqualified', '--remediate', '--reranking', '--reset', '--resolve', '--restore-only', '--resume', '--rollback', '--skip-verify', '--slugs', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--to', '--token-ttl', '--undo', '--undo-wave', '--url', '--use-captured-snapshot', '--version', '--with-calibration', '--yes'],
'models': ['--aliases', '--all', '--brain', '--ctx-size', '--detail', '--embedding-dimensions', '--embedding-model', '--embeddings', '--help', '--include-null-signature', '--json', '--judge-model', '--model', '--multimodal', '--no', '--no-embed', '--no-extract', '--pattern', '--pending', '--reranking', '--reset', '--resolve', '--skip', '--source', '--stale', '--supersessions', '--thin', '--undo', '--version'],
'migrate': ['--ab', '--aliases', '--all', '--auto-update', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--build-index', '--by-mention', '--catch-up', '--compile', '--days', '--dim', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--exclusive', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--force-sunset-target', '--from-meetings', '--from-pages', '--help', '--history', '--http', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--lang', '--locks', '--markdown', '--max-age', '--model', '--multimodal', '--name', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--non-interactive', '--pace', '--pace-max-concurrency', '--parallel', '--path', '--pattern', '--pending', '--phase', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--refresh-unqualified', '--remediate', '--reranking', '--reset', '--resolve', '--restore-only', '--resume', '--rollback', '--skip-verify', '--slugs', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--to', '--token-ttl', '--undo', '--undo-wave', '--url', '--use-captured-snapshot', '--version', '--with-calibration', '--yes'],
'models': ['--aliases', '--all', '--brain', '--ctx-size', '--detail', '--embedding-dimensions', '--embedding-model', '--embeddings', '--help', '--include-null-signature', '--json', '--judge-model', '--model', '--multimodal', '--no', '--no-embed', '--no-extract', '--pattern', '--pending', '--reranking', '--reset', '--resolve', '--skip', '--source', '--stale', '--supersessions', '--thin', '--to', '--undo', '--version'],
'mounts': ['--alias', '--brain', '--cache', '--database-path', '--database-url', '--db-path', '--db-url', '--engine', '--explain', '--help', '--id', '--json', '--lang', '--lock', '--markdown', '--mcp-url', '--multimodal', '--near-symbol', '--path', '--restore-only', '--skills-dir', '--source', '--stale', '--symbol-kind', '--thin', '--verbose'],
'notability-eval': ['--aliases', '--all', '--brain', '--embedding-dimensions', '--embedding-model', '--help', '--in', '--include-null-signature', '--json', '--model', '--multimodal', '--no', '--no-embed', '--no-extract', '--out', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--skip-llm', '--source', '--stale', '--supersessions', '--target-high', '--target-low', '--target-medium', '--thin', '--version'],
'notability-eval': ['--aliases', '--all', '--brain', '--embedding-dimensions', '--embedding-model', '--help', '--in', '--include-null-signature', '--json', '--model', '--multimodal', '--no', '--no-embed', '--no-extract', '--out', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--skip-llm', '--source', '--stale', '--supersessions', '--target-high', '--target-low', '--target-medium', '--thin', '--to', '--version'],
'onboard': ['--aliases', '--all', '--allow-empty', '--allow-protected', '--apply', '--asof', '--auto', '--auto-with-prompt', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--check', '--content', '--date', '--days', '--entities', '--explain', '--federated', '--file', '--follow', '--from-pages', '--help', '--history', '--http', '--image', '--include-null-signature', '--json', '--kind', '--limit', '--max-usd', '--mode', '--multimodal', '--no-extract', '--offset', '--params', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--remediation-plan', '--reset', '--resolve', '--resume', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--target-score', '--thin', '--trusted-extraction', '--url', '--with-db', '--yes'],
'orphans': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--count', '--explain', '--follow', '--help', '--include-null-signature', '--include-pseudo', '--json', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout'],
'pages': ['--aliases', '--all', '--brain', '--dry-run', '--help', '--include-null-signature', '--json', '--no-extract', '--older-than', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin'],
'pglite-repair': ['--brain', '--break-lock', '--dry-rnu', '--dry-run', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--path', '--quiet', '--source', '--surface', '--token-ttl', '--yes'],
'post-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'],
'protocol': ['--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', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--json', '--kind', '--limit', '--max-usd', '--mode', '--multimodal', '--no-embedding', '--offset', '--path', '--progress-interval', '--progress-json', '--quiet', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stats', '--surface', '--synthesize', '--target', '--timeout', '--token', '--token-ttl', '--trusted-extraction', '--url', '--with-db', '--yes'],
'providers': ['--brain', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--reranking', '--source', '--surface', '--token-ttl', '--touchpoint', '--version'],
'providers': ['--brain', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--reranking', '--source', '--surface', '--to', '--token-ttl', '--touchpoint', '--version'],
'publish': ['--accent', '--bg', '--border', '--brain', '--card-bg', '--code-bg', '--error', '--fg', '--help', '--json', '--link', '--muted', '--out', '--password', '--source', '--title'],
'quarantine': ['--aliases', '--all', '--apply', '--brain', '--code', '--compile', '--explain', '--fast', '--fix', '--force', '--force-rechunk', '--from-pages', '--help', '--http', '--include-flagged', '--include-null-signature', '--json', '--lang', '--limit', '--markdown', '--multimodal', '--near-symbol', '--no-embed', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin', '--token-ttl'],
'recall': ['--aliases', '--all', '--allow-empty', '--apply', '--as-context', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-tokens', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--grep', '--help', '--http', '--image', '--include-expired', '--include-null-signature', '--json', '--kind', '--limit', '--max-usd', '--mcp-only', '--mode', '--multimodal', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--query', '--quiet', '--reason', '--repo', '--reset', '--resolve', '--restore-only', '--rollup', '--save', '--session', '--session-id', '--since', '--since-last-run', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--today', '--token-ttl', '--trusted-extraction', '--url', '--watch', '--with-db', '--yes'],
'reconcile-links': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--explain', '--follow', '--help', '--include-frontmatter', '--include-null-signature', '--json', '--name-status', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--source', '--stale', '--strategy', '--supersessions', '--thin', '--timeout', '--type'],
'reindex': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--code', '--compile', '--concurrency', '--cost-estimate', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lang', '--limit', '--markdown', '--max-age', '--max-cost-usd', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--no-extract', '--older-than', '--path', '--pattern', '--pending', '--pglite', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--version', '--workers', '--yes'],
'reindex-code': ['--abi', '--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--chunker-debug', '--code', '--compile', '--concurrency', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fix', '--follow', '--force', '--force-rechunk', '--help', '--include-null-signature', '--json', '--judge-model', '--lang', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--model', '--multimodal', '--no', '--no-embed', '--no-extract', '--older-than', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--serial', '--source', '--stale', '--supersessions', '--thin', '--timeout', '--version', '--workers', '--yes'],
'reindex': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--code', '--compile', '--concurrency', '--cost-estimate', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lang', '--limit', '--markdown', '--max-age', '--max-cost-usd', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--no-extract', '--older-than', '--path', '--pattern', '--pending', '--pglite', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--to', '--token-ttl', '--version', '--workers', '--yes'],
'reindex-code': ['--abi', '--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--chunker-debug', '--code', '--compile', '--concurrency', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fix', '--follow', '--force', '--force-rechunk', '--help', '--include-null-signature', '--json', '--judge-model', '--lang', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--model', '--multimodal', '--no', '--no-embed', '--no-extract', '--older-than', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--serial', '--source', '--stale', '--supersessions', '--thin', '--timeout', '--to', '--version', '--workers', '--yes'],
'reindex-frontmatter': ['--aliases', '--all', '--brain', '--concurrency', '--dry-run', '--force', '--help', '--include-null-signature', '--json', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--slug-prefix', '--source', '--stale', '--supersessions', '--thin', '--workers', '--yes'],
'reindex-search-vector': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--explain', '--follow', '--help', '--include-null-signature', '--json', '--migrate-only', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout', '--yes'],
'reinit-pglite': ['--abbrev-ref', '--all', '--allow-empty', '--brain', '--break-lock', '--chat-model', '--concurrency', '--confirm-destructive', '--diff-filter', '--dir', '--embedding-dimensions', '--embedding-model', '--empty', '--entity', '--exclude', '--exclude-standard', '--expansion-model', '--fast', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--grant-types', '--hard-deadline', '--help', '--http', '--include-gitignored', '--interval', '--is-ancestor', '--issuer-url', '--json', '--key', '--lock', '--max-age', '--max-sources', '--mcp-only', '--mcp-url', '--migrate-only', '--missing-path', '--model', '--name-only', '--name-status', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-renames', '--no-schema-pack', '--no-sync', '--no-verify', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--orphan', '--parallel', '--path', '--pglite', '--priority', '--provenance', '--quiet', '--repo', '--retry-failed', '--schema-pack', '--scopes', '--serial', '--short', '--show-toplevel', '--skip-embed-check', '--skip-failed', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--strategy', '--supabase', '--surface', '--timeout', '--to', '--token-ttl', '--url', '--verify', '--version', '--watch', '--workers', '--yes'],
@@ -90,7 +90,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'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-id', '--stale', '--status', '--strategy', '--supersessions', '--surface', '--symbol-kind', '--thin', '--token-ttl', '--unset-all', '--url', '--url-managed', '--yes'],
'resolvers': ['--auto', '--backend', '--brain', '--cost', '--help', '--json', '--source'],
'retrieval-upgrade': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--catch-up', '--dim', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--explain', '--fast', '--follow', '--force', '--from-pages', '--help', '--http', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--markdown', '--model', '--multimodal', '--name', '--no', '--no-embed', '--no-embedding', '--no-extract', '--non-interactive', '--pace', '--pace-max-concurrency', '--parallel', '--pattern', '--pending', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reranking', '--reset', '--resolve', '--resume', '--slugs', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--to', '--token-ttl', '--undo', '--version', '--yes'],
'retrieval-upgrade': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--catch-up', '--dim', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--explain', '--fast', '--follow', '--force', '--force-sunset-target', '--from-pages', '--help', '--http', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--markdown', '--model', '--multimodal', '--name', '--no', '--no-embed', '--no-embedding', '--no-extract', '--non-interactive', '--pace', '--pace-max-concurrency', '--parallel', '--pattern', '--pending', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reranking', '--reset', '--resolve', '--resume', '--slugs', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--to', '--token-ttl', '--undo', '--version', '--yes'],
'routing-eval': ['--brain', '--fix', '--help', '--json', '--llm', '--skills-dir', '--source', '--strict', '--verbose'],
'salience': ['--aliases', '--all', '--brain', '--days', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--kind', '--limit', '--mcp-only', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--slug-prefix', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl'],
'schema': ['--alias', '--aliases', '--all', '--apply', '--as-filing-rules', '--brain', '--dims', '--expert', '--expert-routing', '--extractable', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--inverse', '--json', '--kind', '--no-embedding', '--no-extract', '--pack', '--page-type', '--pattern', '--pending', '--prefix', '--primitive', '--reset', '--resolve', '--schema-pack', '--since', '--source', '--source-id', '--stale', '--supersessions', '--surface', '--target-type', '--thin', '--to', '--token-ttl', '--with-db'],
@@ -111,5 +111,5 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'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-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', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--token-ttl', '--window-turns'],
'ze-switch': ['--aliases', '--all', '--brain', '--confirm-reembed', '--dry-run', '--force', '--help', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--markdown', '--no-extract', '--non-interactive', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--source', '--stale', '--supersessions', '--thin', '--undo', '--yes'],
'ze-switch': ['--aliases', '--all', '--brain', '--confirm-reembed', '--dim', '--dry-run', '--force', '--help', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--markdown', '--no-extract', '--non-interactive', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--source', '--stale', '--supersessions', '--thin', '--to', '--undo', '--yes'],
};
+7
View File
@@ -1156,6 +1156,13 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
// reconcile-links, and sweep. The documented off-switch is `gbrain config
// set auto_link false` — same unregistered-key class as auto_chronicle.
'auto_link',
// v0.46.3: the provider_sunset doctor check's own suppression escape hatch
// (doctor.ts) and docs/guides/embedding-migration.md both document
// `gbrain config set doctor.suppress_provider_sunset true`, but the key was
// never registered — the documented command exited 1 with "Unknown config
// key". Same class as auto_chronicle above. Deliberately an exact key, not
// a blanket 'doctor.' prefix (unbounded namespaces defeat the typo gate).
'doctor.suppress_provider_sunset',
// #2606: chronicle judge output-token cap (default 4000). Event-dense
// pages overflowed the old hardcoded 1500 and were misrecorded as
// no_events; the cap is now configurable and truncation is surfaced.
+2 -2
View File
@@ -25,11 +25,11 @@ import { reflexPointerRationale } from './retrieval-reflex.ts';
export const VOLUNTEER_EVENTS_TTL_DAYS = 90;
/** Single source of truth for channel values — type + guards derive from it. */
export const VOLUNTEER_CHANNELS = ['op', 'reflex', 'watch', 'claude-code', 'codex'] as const;
export const VOLUNTEER_CHANNELS = ['op', 'reflex', 'watch', 'claude-code', 'codex', 'opencode'] as const;
export type VolunteerChannel = (typeof VOLUNTEER_CHANNELS)[number];
/** The harness subset — the ONLY channels a wire caller may claim. */
export const HARNESS_CHANNELS = ['claude-code', 'codex'] as const;
export const HARNESS_CHANNELS = ['claude-code', 'codex', 'opencode'] as const;
export type HarnessChannel = (typeof HARNESS_CHANNELS)[number];
/** Wire fallback: the only harness bootstrap registers hooks for today. */
+1 -1
View File
@@ -156,7 +156,7 @@ export async function runNightlyQualityProbe(deps: NightlyProbeDeps): Promise<Ni
if (!hasEmbed) {
process.stderr.write(
`[nightly-quality-probe] no embedding provider configured; skipping. ` +
`Configure OPENAI_API_KEY / VOYAGE_API_KEY / ZEROENTROPY_API_KEY and re-enable.\n`,
`Configure VOYAGE_API_KEY / OPENAI_API_KEY and re-enable.\n`,
);
logQualityProbeEvent({
outcome: 'no_embedding_key',
+8 -5
View File
@@ -70,7 +70,10 @@ export function formatEmbeddingCredsError(d: EmbeddingDiagnosis): string {
return [
'No embedding model is configured for this brain.',
'',
' Set one: gbrain config set embedding_model openai:text-embedding-3-small',
// NOT `gbrain config set embedding_model` — that key is file-plane
// schema-sizing config the command refuses; init --force is the
// sanctioned re-configuration path.
' Set one: gbrain init --force --embedding-model voyage:voyage-4',
' Or skip embedding now: re-run with --no-embed',
].join('\n');
@@ -80,14 +83,14 @@ export function formatEmbeddingCredsError(d: EmbeddingDiagnosis): string {
'',
` ${d.message}`,
'',
' Pick a known provider: gbrain config set embedding_model openai:text-embedding-3-small',
' Pick a known provider: gbrain init --force --embedding-model voyage:voyage-4',
].join('\n');
case 'no_touchpoint':
return [
`Provider "${d.provider}" does not offer an embedding touchpoint.`,
'',
' Switch providers: gbrain config set embedding_model openai:text-embedding-3-small',
' Switch providers: gbrain init --force --embedding-model voyage:voyage-4',
' Or run with --no-embed to import-only and embed later.',
].join('\n');
@@ -112,9 +115,9 @@ export function formatEmbeddingCredsError(d: EmbeddingDiagnosis): string {
// Only offer a provider-switch hint when the current provider isn't openai
// (otherwise we'd be suggesting they switch to the thing they already have).
if (d.provider !== 'openai') {
lines.push(` • Switch providers: gbrain config set embedding_model openai:text-embedding-3-small`);
lines.push(` • Switch providers: gbrain init --force --embedding-model openai:text-embedding-3-small`);
} else {
lines.push(` • Switch providers: gbrain config set embedding_model voyage:voyage-3-large`);
lines.push(` • Switch providers: gbrain init --force --embedding-model voyage:voyage-4`);
}
lines.push('');
lines.push(`Example shell setup:`);
+23 -3
View File
@@ -93,7 +93,11 @@ export function migrationSignature(toModel: string, toDims: number): string {
* Throws with a paste-ready message on an unknown provider or when the
* recipe declares no default dims and the caller passed none.
*/
export function resolveMigrationTarget(to: string, dimFlag?: number): { toModel: string; toDims: number } {
export function resolveMigrationTarget(
to: string,
dimFlag?: number,
opts?: { allowSunsetTarget?: boolean },
): { toModel: string; toDims: number } {
if (!to.includes(':')) {
throw new Error(
`--to must be provider:model (e.g. openai:text-embedding-3-small). Got: ${to}`,
@@ -104,6 +108,20 @@ export function resolveMigrationTarget(to: string, dimFlag?: number): { toModel:
if (!recipe.touchpoints.embedding) {
throw new Error(`Provider ${recipe.id} has no embedding support. Pick an embedding-capable provider:model.`);
}
// v0.46.3: refuse a PAID full re-embed onto a provider with an announced
// shutdown — the live probe passes while the hosted API is still up, so
// without this gate an agent replaying an old runbook could strand the
// brain days before the shutdown. Same posture as ze-switch's forward
// refusal; `--force-sunset-target` (self-hosters with a compatible
// endpoint) is the loud escape hatch.
if (recipe.sunset && !opts?.allowSunsetTarget) {
throw new Error(
`Refusing to migrate ONTO ${recipe.name}: its hosted API stops working on ` +
`${recipe.sunset.date}, so this paid re-embed would strand the brain. ` +
`Recommended target: ${recipe.sunset.replacement?.embedding ?? 'voyage:voyage-4'}. ` +
`Self-hosting a compatible endpoint? Re-run with --force-sunset-target.`,
);
}
const toDims = dimFlag ?? embeddingDimsForModel(recipe, to);
if (!toDims || toDims <= 0) {
throw new Error(
@@ -120,9 +138,11 @@ export function resolveMigrationTarget(to: string, dimFlag?: number): { toModel:
*/
export async function planEmbeddingMigration(
engine: BrainEngine,
opts: { to: string; dim?: number; fromModel?: string; fromDims?: number },
opts: { to: string; dim?: number; fromModel?: string; fromDims?: number; allowSunsetTarget?: boolean },
): Promise<EmbeddingMigrationPlan> {
const { toModel, toDims } = resolveMigrationTarget(opts.to, opts.dim);
const { toModel, toDims } = resolveMigrationTarget(opts.to, opts.dim, {
allowSunsetTarget: opts.allowSunsetTarget,
});
// From-state: caller (CLI) passes the gateway-resolved values; fall back
// to the shipped defaults for gateway-less contexts (unit tests, op probe).
+6 -1
View File
@@ -34,10 +34,15 @@ export const EMBEDDING_PRICING: Record<string, EmbeddingPricing> = {
'openai:text-embedding-3-small': { pricePerMTok: 0.02 },
// Legacy OpenAI ada (still common in older brains)
'openai:text-embedding-ada-002': { pricePerMTok: 0.10 },
// Voyage (https://docs.voyageai.com/docs/pricing, verified 2026-07-28)
// Voyage (https://docs.voyageai.com/docs/pricing, verified 2026-08-15)
'voyage:voyage-4-large': { pricePerMTok: 0.12 },
'voyage:voyage-4': { pricePerMTok: 0.06 },
'voyage:voyage-4-lite': { pricePerMTok: 0.02 },
'voyage:voyage-code-4': { pricePerMTok: 0.12 },
// Voyage rerankers (same pricing page, verified 2026-08-15). Same
// budget-tracker rerank-kind fallback rationale as the zerank-2 row below.
'voyage:rerank-2.5': { pricePerMTok: 0.05 },
'voyage:rerank-2.5-lite': { pricePerMTok: 0.02 },
// voyage-4-nano is deliberately absent: it's the open-weight variant (see
// src/core/ai/recipes/voyage.ts) and Voyage's pricing page lists no hosted
// rate for it. A 0 entry would under-estimate anyone paying for it via the
+4 -3
View File
@@ -15,9 +15,10 @@
* warn only, never blocks.
*
* Both run against the EFFECTIVE gateway config process.env overlaid with
* file-plane keys (openai/anthropic/zeroentropy from config.json) and
* `opts.apiKey`, plus provider base URLs built via the same
* `buildGatewayConfig` runtime uses. Without that, the config-only check would
* every file-plane provider key config.json carries (openai / anthropic /
* voyage / zeroentropy / dashscope / google whatever buildGatewayConfig
* folds, #2662) and `opts.apiKey`, plus provider base URLs built via the
* same `buildGatewayConfig` runtime uses. Without that, the config-only check would
* false-warn on config.json-keyed users, and the live probe could hit the
* wrong endpoint (custom OpenAI base URL, llama-server, etc.).
*
+10
View File
@@ -141,6 +141,16 @@ export function buildCodexMcpAddArgv(p: { name: string; url: string; envVar: str
return ['mcp', 'add', p.name, '--url', p.url, '--bearer-token-env-var', p.envVar];
}
/**
* `opencode mcp add` argv for a remote HTTP server. The header value carries
* opencode's `{env:VAR}` interpolation LITERALLY opencode resolves it at
* read time, so the token never enters argv or the config file (verified
* against opencode 1.18.18; OPENCODE-CLI-PIN.md §mcp add).
*/
export function buildOpencodeMcpAddArgv(p: { name: string; url: string; envVar: string }): string[] {
return ['mcp', 'add', p.name, '--url', p.url, '--header', `Authorization=Bearer {env:${p.envVar}}`];
}
/**
* POSIX single-quote any arg that isn't already shell-safe, so `$()`, backticks,
* etc. in a token are inert literals when the block is pasted into a shell
+6 -2
View File
@@ -138,11 +138,15 @@ export class MinionQueue {
);
}
if (verdict === 'unknown') {
// v0.46.3: derive the provider list from the recipe registry instead
// of a hardcoded string (which drifted silently as recipes came and
// went — and would have needed editing again at the ZE removal).
const { listRecipes } = await import('../ai/recipes/index.ts');
const known = listRecipes().map((r) => r.id).join(', ');
throw new Error(
`subagent job rejected: data.model "${submittedModel}" references an unknown provider. ` +
`Use format provider:model where provider matches a recipe in src/core/ai/recipes/. ` +
`Known providers: anthropic, openai, google, openrouter, litellm-proxy, ollama, llama-server, ` +
`together, azure-openai, deepseek, groq, dashscope, minimax, zhipu, voyage, zeroentropyai.`,
`Known providers: ${known}.`,
);
}
// 'degraded:no_caching' and 'degraded:no_parallel' pass through — the
+1 -1
View File
@@ -2228,7 +2228,7 @@ const query: Operation = {
embedding_column: {
type: 'string',
description:
"v0.36: route vector search through a non-default embedding column. Defaults to 'embedding' (OpenAI 1536d) unless `search_embedding_column` config sets a different default. Per-call override for A/B benchmarking across providers (e.g. 'embedding_voyage', 'embedding_zeroentropy'). Column MUST be declared in the `embedding_columns` config registry — unknown names throw with a paste-ready hint listing valid columns.",
"v0.36: route vector search through a non-default embedding column. Defaults to 'embedding' (the brain's primary column) unless `search_embedding_column` config sets a different default. Per-call override for A/B benchmarking across providers (e.g. 'embedding_voyage', 'embedding_openai'). Column MUST be declared in the `embedding_columns` config registry — unknown names throw with a paste-ready hint listing valid columns.",
},
adaptive_return: {
type: 'boolean',
+9 -4
View File
@@ -104,10 +104,15 @@ export interface ModeBundle {
*/
reranker_enabled: boolean;
/**
* Provider:model for the reranker. Default `'zeroentropyai:zerank-2'`.
* Other ZE rerankers (`zerank-1`, `zerank-1-small`) work via the same
* recipe; future Cohere/Voyage rerankers drop in as new recipes
* declaring `touchpoints.reranker`.
* Provider:model for the reranker. Bundle default `'zeroentropyai:zerank-2'`
* LEGACY until the September removal (v0.46.3 split-default: existing
* ZE-keyed brains keep a working reranker until the hosted API dies on
* 2026-09-04; voyage-keyed new installs get an explicit
* `search.reranker.model voyage:rerank-2.5` override written at init
* (keyed non-voyage installs get explicit `search.reranker.enabled false`),
* and the September release flips this bundle value to voyage). Voyage
* rerankers (`rerank-2.5`, `rerank-2.5-lite`) are live recipes today via
* `touchpoints.reranker`.
*/
reranker_model: string;
/** Candidates to send upstream (default 30). The full result list always
+342
View File
@@ -0,0 +1,342 @@
/**
* ZeroEntropy sunset exposure detection (v0.46.3).
*
* One shared answer to "is this brain still depending on ZeroEntropy?"
* consumed by the v0.46.3 version migration (detect-and-notify) and the
* stage-2 upgrade banner. Standalone module by design: the ze-switch /
* retrieval-upgrade subsystem is deleted in the September removal release and
* this file must survive it.
*
* SCOPE: the brain the given engine is connected to + the HOST file plane
* (~/.gbrain/config.json). apply-migrations is host-scoped (one global
* completed.jsonl, no --brain flag), so the version migration covers the HOST
* brain only; mounted/team brains are covered by the per-brain stage-2 banner
* + doctor gates (their `ze_sunset_notice_v2_shown` lives in each brain's own
* DB config).
*
* EXPOSURE = EFFECTIVE-MODEL RESOLUTION, NOT VECTOR EVIDENCE. Precedence:
* env (GBRAIN_EMBEDDING_MODEL) file `embedding_model` the legacy
* configless runtime fallback (DEFAULT_EMBEDDING_MODEL, still ZE until the
* September cutover). A configless brain resolves to the ZE fallback
* DETERMINISTICALLY it is exposed even with zero vectors. Signatures and
* column width feed only the blast radius: 1280d is NOT ZE-proof (OpenAI
* text-3 supports arbitrary Matryoshka widths including 1280).
*
* TRI-STATE: a failed DB probe downgrades `status` to 'unknown', never to
* 'clear'. Callers must nag on 'unknown' (fail-safe), and the migration
* returns `complete` with an `exposure_unknown` detail rather than `partial`
* three consecutive partials would wedge the whole migration chain behind
* `--force-retry` (apply-migrations.ts).
*/
import type { BrainEngine } from './engine.ts';
import type { GBrainConfig } from './config.ts';
import { loadConfigFileOnly } from './config.ts';
import { DEFAULT_EMBEDDING_MODEL, ZEROENTROPY_SUNSET_DATE } from './ai/defaults.ts';
import {
NEW_INSTALL_DEFAULT_EMBEDDING_MODEL,
NEW_INSTALL_DEFAULT_EMBEDDING_DIMENSIONS,
NEW_INSTALL_DEFAULT_RERANKER_MODEL,
} from './ai/defaults.ts';
import { lookupEmbeddingPrice } from './embedding-pricing.ts';
const ZE_PREFIX = 'zeroentropyai:';
/** Strict column-name shape (mirrors the registry's validateColumnKey
* contract). Registry keys read here come from BOTH config planes the DB
* plane can be written by another party on mounted/team brains, and these
* names are rendered verbatim into the agent-directed ACTION REQUIRED banner,
* so nonconforming names are dropped rather than echoed (text-injection
* hardening; sanctioned write paths already enforce this shape). */
const SAFE_COLUMN_KEY = /^[a-z_][a-z0-9_]{0,62}$/;
/** Blast-radius counts are LIMIT-capped so a million-chunk brain can't stall
* apply-migrations on a full-table COUNT. */
export const BLAST_RADIUS_CAP = 100_000;
export type ZeExposureStatus = 'exposed' | 'clear' | 'unknown';
export interface ZeBlastRadius {
/** Pages whose embedding_signature is ZE-stamped (capped; null = probe failed). */
zeSignedPages: number | null;
zeSignedPagesCapped: boolean;
/** Non-NULL embedded chunks (capped; null = probe failed / not ZE-exposed). */
embeddedChunks: number | null;
embeddedChunksCapped: boolean;
/** Rough re-embed cost at the recommended target (~400 tokens/chunk). */
estReembedUsd: number | null;
}
export interface ZeExposure {
status: ZeExposureStatus;
/** Effective embedding model resolves to zeroentropyai:* (and not disabled). */
zeEmbedding: boolean;
/** ...because file config names it explicitly. */
zeExplicit: boolean;
/** ...because nothing is configured and the legacy runtime fallback applies. */
bareDefaultZe: boolean;
/** Env override (GBRAIN_EMBEDDING_MODEL) names ZE env wins over file, so
* the notice must tell the operator the env var itself has to change. */
envOverride: boolean;
/** The RESOLVED reranker (explicit config or still-legacy bundle default,
* when reranking is enabled for the active mode) is zeroentropyai:*. */
zeReranker: boolean;
/** Custom embedding_columns registry entries backed by a ZE model. */
zeCustomColumns: string[];
/** VOYAGE_API_KEY in env OR voyage_api_key in file config. */
hasVoyageKey: boolean;
/** provider_base_urls.zeroentropyai is overridden (file or DB plane) the
* operator may be self-hosting a ZE-wire endpoint, so the HOSTED shutdown
* may not break them. Exposure stays exposed (fail-safe: we can't verify
* the endpoint), but the notice says so instead of claiming certain death. */
selfHostBaseUrl: boolean;
/** Names of probes that failed (drove status to 'unknown'). */
unknownProbes: string[];
blastRadius: ZeBlastRadius;
}
/** PRIVATE both call sites pass hardcoded literal SQL; never pass dynamic
* or config-derived strings into `innerSql` (it is string-composed). If a
* future probe needs a parameter, add a fixed named query instead. */
async function cappedCount(engine: BrainEngine, innerSql: string): Promise<number> {
const rows = await engine.executeRaw<{ n: number }>(
`SELECT COUNT(*)::int AS n FROM (${innerSql} LIMIT ${BLAST_RADIUS_CAP + 1}) t`,
);
return Number(rows?.[0]?.n ?? 0);
}
function zeColumnsFromRegistry(registry: unknown, out: Set<string>): void {
if (!registry || typeof registry !== 'object') return;
for (const [name, entry] of Object.entries(registry as Record<string, unknown>)) {
if (!SAFE_COLUMN_KEY.test(name)) continue; // never echo nonconforming names
const provider = (entry as { provider?: unknown } | null)?.provider;
if (typeof provider === 'string' && provider.startsWith(ZE_PREFIX)) out.add(name);
}
}
/**
* ZE-backed custom-column names for this brain (file + DB registry planes).
* Shared by detectZeExposure and doctor's provider_sunset check the doctor
* must not report ok while a ZE-backed custom column is still live.
*/
export async function detectZeCustomColumns(
engine: BrainEngine,
fileCfgArg?: GBrainConfig | null,
): Promise<{ columns: string[]; probeFailed: boolean }> {
const fileCfg = fileCfgArg !== undefined ? fileCfgArg : loadConfigFileOnly();
const zeCols = new Set<string>();
zeColumnsFromRegistry(fileCfg?.embedding_columns, zeCols);
let probeFailed = false;
try {
const raw = await engine.getConfig('embedding_columns');
if (raw) zeColumnsFromRegistry(JSON.parse(raw), zeCols);
} catch {
probeFailed = true;
}
return { columns: [...zeCols].sort(), probeFailed };
}
/**
* Detect this brain's ZeroEntropy exposure. Every DB probe is individually
* try/caught; a failure marks the probe unknown instead of throwing (a broken
* schema must not turn detection into a crash or into a false 'clear').
*/
export async function detectZeExposure(
engine: BrainEngine,
fileCfgArg?: GBrainConfig | null,
env: NodeJS.ProcessEnv = process.env,
): Promise<ZeExposure> {
const fileCfg = fileCfgArg !== undefined ? fileCfgArg : loadConfigFileOnly();
const unknownProbes: string[] = [];
// --- Embedding axis: pure config resolution, cannot fail -----------------
const disabled = fileCfg?.embedding_disabled === true;
const envModel = env.GBRAIN_EMBEDDING_MODEL?.trim() || undefined;
const fileModel = fileCfg?.embedding_model;
const effective = envModel ?? fileModel ?? DEFAULT_EMBEDDING_MODEL;
const zeEmbedding = !disabled && effective.startsWith(ZE_PREFIX);
const envOverride = !disabled && !!envModel?.startsWith(ZE_PREFIX);
const zeExplicit = !disabled && !envModel && !!fileModel?.startsWith(ZE_PREFIX);
const bareDefaultZe = !disabled && !envModel && !fileModel;
// --- Reranker axis: resolve through the SAME plane search reranks with ---
// (mode bundle + search.reranker.* config; env excluded). Detectable
// precisely because the bundle default stays legacy-ZE until September.
let zeReranker = false;
try {
const { loadSearchModeConfig, resolveSearchMode } = await import('./search/mode.ts');
const knobs = resolveSearchMode(await loadSearchModeConfig(engine));
zeReranker = knobs.reranker_enabled && knobs.reranker_model.startsWith(ZE_PREFIX);
} catch {
unknownProbes.push('reranker_resolution');
}
// --- Custom-column axis: both planes (file + DB registry) ----------------
const colProbe = await detectZeCustomColumns(engine, fileCfg);
if (colProbe.probeFailed) unknownProbes.push('embedding_columns_db');
const zeCustomColumns = colProbe.columns;
const hasVoyageKey = !!(env.VOYAGE_API_KEY || fileCfg?.voyage_api_key);
// --- Self-host signal (informational): base-URL override on either plane --
let selfHostBaseUrl = !!fileCfg?.provider_base_urls?.zeroentropyai;
if (!selfHostBaseUrl) {
try {
selfHostBaseUrl = !!(await engine.getConfig('provider_base_urls.zeroentropyai'));
} catch {
// Informational only — a failed probe never flips status here.
}
}
// --- Blast radius (informational; failures do NOT flip status) -----------
const blastRadius: ZeBlastRadius = {
zeSignedPages: null,
zeSignedPagesCapped: false,
embeddedChunks: null,
embeddedChunksCapped: false,
estReembedUsd: null,
};
if (zeEmbedding) {
try {
const n = await cappedCount(
engine,
`SELECT 1 FROM pages WHERE embedding_signature LIKE '${ZE_PREFIX}%'`,
);
blastRadius.zeSignedPages = Math.min(n, BLAST_RADIUS_CAP);
blastRadius.zeSignedPagesCapped = n > BLAST_RADIUS_CAP;
} catch {
// Pre-v108 schemas have no embedding_signature column — informational.
}
try {
const n = await cappedCount(
engine,
`SELECT 1 FROM content_chunks WHERE embedding IS NOT NULL`,
);
blastRadius.embeddedChunks = Math.min(n, BLAST_RADIUS_CAP);
blastRadius.embeddedChunksCapped = n > BLAST_RADIUS_CAP;
const price = lookupEmbeddingPrice(NEW_INSTALL_DEFAULT_EMBEDDING_MODEL);
if (price.kind === 'known' && blastRadius.embeddedChunks !== null) {
// ~400 tokens/chunk repo-wide average (CLAUDE.md cost anchors).
const tokens = blastRadius.embeddedChunks * 400;
blastRadius.estReembedUsd = (tokens / 1_000_000) * price.pricePerMTok;
}
} catch {
// Fresh/odd brains: no chunk count, no cost estimate. Informational.
}
}
const exposed = zeEmbedding || zeReranker || zeCustomColumns.length > 0;
const status: ZeExposureStatus = exposed
? 'exposed'
: unknownProbes.length > 0
? 'unknown'
: 'clear';
return {
status,
zeEmbedding,
zeExplicit,
bareDefaultZe,
envOverride,
zeReranker,
zeCustomColumns,
hasVoyageKey,
selfHostBaseUrl,
unknownProbes,
blastRadius,
};
}
function fmtCount(n: number | null, capped: boolean): string {
if (n === null) return 'unknown';
return capped ? `${n.toLocaleString()}+` : n.toLocaleString();
}
/**
* Render the shared ACTION REQUIRED body. Both the v0.46.3 migration notice
* and the stage-2 upgrade banner wrap this text (DRY: the exposure story is
* written once). Plain ASCII, <= 78 cols per repo convention.
*/
export function renderZeActionRequired(exposure: ZeExposure): string {
const lines: string[] = [];
lines.push(`ZeroEntropy stops working on ${ZEROENTROPY_SUNSET_DATE}.`);
if (exposure.status === 'unknown' && !exposure.zeEmbedding && !exposure.zeReranker) {
lines.push(
`This brain's exposure could not be fully determined ` +
`(failed probes: ${exposure.unknownProbes.join(', ')}). Treat it as affected ` +
`until \`gbrain doctor\` reports provider_sunset ok.`,
);
}
if (exposure.zeEmbedding) {
const via = exposure.envOverride
? 'via the GBRAIN_EMBEDDING_MODEL env var'
: exposure.zeExplicit
? 'via embedding_model in ~/.gbrain/config.json'
: 'via the configless runtime fallback (nothing is configured)';
lines.push(`This brain EMBEDS with ZeroEntropy (${via}).`);
lines.push(
`Blast radius: ${fmtCount(exposure.blastRadius.zeSignedPages, exposure.blastRadius.zeSignedPagesCapped)} ` +
`ZE-stamped pages / ${fmtCount(exposure.blastRadius.embeddedChunks, exposure.blastRadius.embeddedChunksCapped)} ` +
`embedded chunks` +
(exposure.blastRadius.estReembedUsd !== null
? ` (${exposure.blastRadius.embeddedChunksCapped ? 'at least ' : ''}~$${exposure.blastRadius.estReembedUsd.toFixed(2)} to re-embed with ${NEW_INSTALL_DEFAULT_EMBEDDING_MODEL})`
: ''),
);
lines.push(
`Fix: gbrain migrate embeddings --to ${NEW_INSTALL_DEFAULT_EMBEDDING_MODEL} ` +
`--dim ${NEW_INSTALL_DEFAULT_EMBEDDING_DIMENSIONS} --dry-run`,
);
lines.push(
` (Voyage's valid widths are 256/512/1024/2048 — legacy default brains are ` +
`1280d and get a one-time rebuild to ${NEW_INSTALL_DEFAULT_EMBEDDING_DIMENSIONS}.`,
);
lines.push(
` OpenAI can keep widths up to 1536: --to openai:text-embedding-3-small --dim <width>.`,
);
lines.push(
` \`gbrain doctor\` prints this brain's exact width-aware command.)`,
);
if (exposure.envOverride) {
lines.push(
`NOTE: GBRAIN_EMBEDDING_MODEL currently forces the ZE model — the env var itself`,
);
lines.push(`must change; config edits alone cannot fix this brain.`);
}
}
if (exposure.zeReranker) {
lines.push(
`Reranking currently resolves to ZE zerank-2 and dies the same day. Fix:`,
);
lines.push(
` gbrain config set search.reranker.model ${NEW_INSTALL_DEFAULT_RERANKER_MODEL}` +
(exposure.hasVoyageKey ? '' : ' (needs VOYAGE_API_KEY)'),
);
lines.push(` (or disable: gbrain config set search.reranker.enabled false)`);
}
if (exposure.zeCustomColumns.length > 0) {
lines.push(
`Custom embedding column(s) backed by ZeroEntropy: ${exposure.zeCustomColumns.join(', ')}.`,
);
lines.push(
`No automated off-ramp exists for custom columns yet (migrate embeddings covers`,
);
lines.push(
`the primary column only) — re-declare them on the new provider and re-embed.`,
);
}
if (exposure.selfHostBaseUrl) {
lines.push(
`NOTE: provider_base_urls.zeroentropyai is overridden. If that endpoint is a`,
);
lines.push(
`self-hosted ZE-wire-compatible server, the hosted shutdown does not break it —`,
);
lines.push(
`but the zeroentropyai provider id is still slated for removal (see the`,
);
lines.push(`self-host continuity TODO in the playbook) — plan a migration anyway.`);
}
lines.push(`Nothing has been changed automatically; this brain keeps working until the date.`);
lines.push(`Agent playbook: skills/migrations/v0.46.3.0.md`);
return lines.join('\n');
}
@@ -48,6 +48,14 @@ registration is always user-global and the tradeoff above is the standing
state. Off-ramps: `codex mcp remove gbrain` removes just the registration;
`gbrain bootstrap uninstall` is the full teardown.
opencode: the scope logic is INVERTED from Claude Code. opencode spawns
servers from a project `opencode.json` with NO trust prompt, so a
project-scoped registration in a repo you share means anyone who checks the
repo out gets the entry executed on open. gbrain therefore defaults to a
user-global registration; project scope is an explicit opt-in that prints a
sharing warning. Off-ramps: `gbrain bootstrap uninstall` removes the entry
from both scope files; deleting the `mcp.gbrain` key by hand also works.
## The transcript corpus
Session transcripts are retained locally (outside this repo, mode 0700, pruned
+1 -1
View File
@@ -72,7 +72,7 @@ during long work reads as broken.
**Gate 2 — Recover missed context.** Scan the conversation for earlier messages that
never got processed. Before sending the final reply, rescan for anything that
arrived mid-turn. On a harness WITHOUT hooks (Codex — pull protocol), also run
arrived mid-turn. On a harness WITHOUT hooks (Codex / opencode — pull protocol), also run
`gbrain bootstrap status` once at the start of a conversation: it surfaces a
failing or stale workspace push that hook-carrying harnesses would have shown
automatically. If it reports the push FAILING, tell {{PRINCIPAL_NAME}} plainly —
+2 -2
View File
@@ -10,8 +10,8 @@ remain private.
anything matching the deny list in `.gitignore`.
- **How it syncs:** `gbrain sources push` — a secret-scan-gated commit + push that
refuses public remotes. On Claude Code it runs automatically per turn
(debounced) and at session end via hooks; on Codex (no hook system) run it at
natural stopping points (the AGENTS.md gate reminds you). If background
(debounced) and at session end via hooks; on Codex or opencode (no wired hook
system) run it at natural stopping points (the AGENTS.md gate reminds you). If background
persistence is enabled, a git post-commit hook auto-pushes each commit and a
30-minute pull job keeps multi-machine checkouts fresh. Run it by hand after
meaningful changes on any harness.
+2 -2
View File
@@ -118,7 +118,7 @@
"MCP_SCOPE": {
"consent": true,
"phase": "interview",
"question": "(Claude Code only. Codex has no scope flag — its registrations are always user-global; on Codex, state that plainly instead of asking.) Register the brain for THIS folder only (recommended — any other repo you open cannot read it), or for every session on this machine (your agent everywhere, but any repo you open can query your brain, and two open sessions will contend for the local database)?",
"question": "(Claude Code and opencode. Codex has no scope flag — its registrations are always user-global; on Codex, state that plainly instead of asking. On opencode the DEFAULT is user-global — the sharing-safe choice, because opencode spawns project-config-defined servers with no trust gate; offer 'project' only as a deliberate opt-in and state the committed-file consequence.) Register the brain for THIS folder only (recommended on Claude Code — any other repo you open cannot read it), or for every session on this machine (your agent everywhere, but any repo you open can query your brain, and two open sessions will contend for the local database)?",
"default": "project",
"allowed": ["project", "user"],
"maxLength": 8
@@ -163,7 +163,7 @@
"VOICE_BANNED": { "default": "- Opening with filler (\"Great question\", \"I'd be happy to help\", \"Absolutely\")\n- Hedging when a take exists\n- \"It's not X, it's Y\" constructions\n- Narrating the writing process inside a document", "maxLength": 1024, "shape": "list" },
"SAFETY_RED_LINES": { "default": "- Never send money or make purchases without explicit per-instance approval\n- Never message third parties as the principal without sign-off on the exact text\n- Never delete data that cannot be restored\n- Never share the principal's private information with anyone but the principal", "maxLength": 2048, "shape": "list" },
"QUIET_HOURS": { "default": "23:00-08:00 local", "maxLength": 64 },
"SURFACE_PRIMARY": { "default": "this workspace (Claude Code / Codex)", "maxLength": 128 },
"SURFACE_PRIMARY": { "default": "this workspace (Claude Code / Codex / opencode)", "maxLength": 128 },
"SURFACE_MULTIUSER": { "default": "single-principal", "allowed": ["single-principal", "shared"], "maxLength": 32 },
"MEMORY_WHAT_MATTERS": { "default": "- Corrections the principal makes (these become standing rules)\n- Commitments made in either direction, with dates\n- Preferences stated once that should never need restating\n- Facts about people and projects the principal works with", "maxLength": 2048, "shape": "list" },
"PRINCIPAL_PROJECTS": { "default": "*(none recorded yet — add as they come up)*", "maxLength": 4096, "shape": "list" },
@@ -48,6 +48,14 @@ registration is always user-global and the tradeoff above is the standing
state. Off-ramps: `codex mcp remove gbrain` removes just the registration;
`gbrain bootstrap uninstall` is the full teardown.
opencode: the scope logic is INVERTED from Claude Code. opencode spawns
servers from a project `opencode.json` with NO trust prompt, so a
project-scoped registration in a repo you share means anyone who checks the
repo out gets the entry executed on open. gbrain therefore defaults to a
user-global registration; project scope is an explicit opt-in that prints a
sharing warning. Off-ramps: `gbrain bootstrap uninstall` removes the entry
from both scope files; deleting the `mcp.gbrain` key by hand also works.
## The transcript corpus
Session transcripts are retained locally (outside this repo, mode 0700, pruned
+1 -1
View File
@@ -76,7 +76,7 @@ during long work reads as broken.
**Gate 2 — Recover missed context.** Scan the conversation for earlier messages that
never got processed. Before sending the final reply, rescan for anything that
arrived mid-turn. On a harness WITHOUT hooks (Codex — pull protocol), also run
arrived mid-turn. On a harness WITHOUT hooks (Codex / opencode — pull protocol), also run
`gbrain bootstrap status` once at the start of a conversation: it surfaces a
failing or stale workspace push that hook-carrying harnesses would have shown
automatically. If it reports the push FAILING, tell {{PRINCIPAL_NAME}} plainly —
+2 -2
View File
@@ -10,8 +10,8 @@ remain private.
anything matching the deny list in `.gitignore`.
- **How it syncs:** `gbrain sources push` — a secret-scan-gated commit + push that
refuses public remotes. On Claude Code it runs automatically per turn
(debounced) and at session end via hooks; on Codex (no hook system) run it at
natural stopping points (the AGENTS.md gate reminds you). If background
(debounced) and at session end via hooks; on Codex or opencode (no wired hook
system) run it at natural stopping points (the AGENTS.md gate reminds you). If background
persistence is enabled, a git post-commit hook auto-pushes each commit and a
30-minute pull job keeps multi-machine checkouts fresh. Run it by hand after
meaningful changes on any harness.
+1 -1
View File
@@ -1,6 +1,6 @@
# gbrain agent workspace — template
<!-- gbrain-template-stamp: 0.46.2.0 -->
<!-- gbrain-template-stamp: 0.46.4.0 -->
This repository is the **"Use this template"** distribution artifact for a
[gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same

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