mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-17 10:22:34 +00:00
Compare commits
28
Commits
@@ -96,7 +96,9 @@ jobs:
|
||||
bun-version: 1.3.13
|
||||
- run: bun install
|
||||
- name: Run Tier 1 E2E tests
|
||||
run: bun test --timeout=60000 test/e2e/mechanical.test.ts test/e2e/mcp.test.ts
|
||||
# job-isolation rides tier1 deliberately: e2e.yml runs only explicitly
|
||||
# NAMED files (no glob) — an unwired e2e file is silent coverage loss.
|
||||
run: bun test --timeout=60000 test/e2e/mechanical.test.ts test/e2e/mcp.test.ts test/e2e/job-isolation.test.ts
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
|
||||
# #3485 preload guard: this job intentionally tests against a DB.
|
||||
|
||||
@@ -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
@@ -1,16 +1,17 @@
|
||||
<!-- gbrain-runbook-stamp: 0.45.20.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
|
||||
|
||||
+323
@@ -2,6 +2,329 @@
|
||||
|
||||
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.**
|
||||
([#4152](https://github.com/garrytan/gbrain/issues/4152)) The synthesize
|
||||
phase used to point its most expensive model at every transcript that
|
||||
cleared a yes/no check — on a busy brain that meant an unbounded queue of
|
||||
long frontier-model jobs grinding through logistics and small talk. It is
|
||||
now a two-stage cascade: a cheap scored triage reads every file first, and
|
||||
only what scores above your threshold reaches the synthesis model, which
|
||||
starts from a map of the noteworthy passages instead of hunting through raw
|
||||
transcript.
|
||||
|
||||
### Added
|
||||
- **Scored triage gate.** Every transcript gets a 0–1 salience score,
|
||||
content type, candidate quotes, and entity candidates from the utility-tier
|
||||
model (one call per new file, cached in `dream_verdicts` with the judging
|
||||
model + prompt version — migration v129). The gate
|
||||
(`dream.triage.threshold`, default 0.5) is applied at read time: retune it
|
||||
any time and re-gating costs **zero** new LLM calls. Provider hiccups
|
||||
(truncation, refusal, unparseable output) are never cached as rejections —
|
||||
those files are re-judged next cycle, and an outage reports as "triage
|
||||
degraded", never as "everything scored low".
|
||||
- **`gbrain dream retriage`** — re-score the corpus and reconcile the queued
|
||||
synthesis backlog. `--dry-run` previews from cached scores with zero LLM
|
||||
calls; `--reconcile-queue` cancels queued jobs that score below the gate
|
||||
AND converts jobs stranded in dead per-run queues so the next cycle
|
||||
actually re-submits them; `--audit-rejects <n>` gets a frontier-model
|
||||
second opinion on a sample of rejects (the threshold-calibration loop).
|
||||
Every sweep prints an upfront cost estimate and asks before spending more
|
||||
than a few dollars (`--yes` to skip, `--max-usd` for an estimate-based
|
||||
budget stop that counts every paid call, including unreliable ones — it
|
||||
can overshoot by up to the configured triage concurrency). Guardrails: queues
|
||||
younger than an hour are treated as possibly-live and never touched;
|
||||
`--cancel-unmatched` refuses to run off a truncated or empty corpus scan.
|
||||
- **Triage map in the synthesis prompt.** Passing files hand the synthesis
|
||||
subagent their pre-extracted quotes and entities (verbatim-verified against
|
||||
the chunk text) so it works from signal instead of re-scanning sludge.
|
||||
- **Cost knobs.** `dream.synthesize.max_turns` (default now 16, was a
|
||||
hardcoded 30 — set it back via config if your written-page counts drop;
|
||||
`details.synthesis.avg_turns` shows cap pressure),
|
||||
`dream.triage.max_ms` (per-cycle triage time budget, default 5 min — a big
|
||||
cold corpus triages across a few cycles, with deferred files labeled "not
|
||||
yet triaged", never silently rejected), and an opt-in per-source daily
|
||||
synthesis cap (`dream.synthesize.max_submissions_per_source_per_day`,
|
||||
default off; 200/day is a sane value for busy deployments). The intended
|
||||
pairing is the shipped mid-tier synthesis default — frontier-model
|
||||
overrides are unnecessary with triage doing the reading.
|
||||
|
||||
### Fixed
|
||||
- A run whose submissions were all skipped (cap, already-synthesized) no
|
||||
longer starts the 12-hour cooldown, so the skipped files retry on the next
|
||||
cycle instead of waiting half a day.
|
||||
- Synthesis jobs stranded in a dead per-run queue by a killed cycle are
|
||||
self-healed on the next run (cancelled and re-submitted into the live
|
||||
queue) instead of stalling the phase for the full 35-minute wait.
|
||||
- `gbrain dream retriage --help` (and richer `gbrain dream --help`) now
|
||||
print real usage instead of the generic one-line stub, with no brain
|
||||
configured.
|
||||
|
||||
To take advantage of v0.46.2.0: upgrade and run `gbrain dream` as usual —
|
||||
existing verdicts are re-scored automatically on the next cycle (cheap,
|
||||
utility-tier). If you have a queued synthesis backlog, run
|
||||
`gbrain dream retriage --dry-run` to preview, then
|
||||
`gbrain dream retriage --reconcile-queue` to drain it for pennies. Tune
|
||||
`dream.triage.threshold` freely; re-gating is free.
|
||||
|
||||
## [0.46.1.0] - 2026-08-15
|
||||
|
||||
**A stuck job can no longer take down your whole worker.** Field reports from
|
||||
a production deployment ([#5](https://github.com/garrytan-agents/gbrain/issues/5),
|
||||
[#6](https://github.com/garrytan-agents/gbrain/issues/6)) showed two
|
||||
compounding failure modes: a handler that ignored its abort signal could only
|
||||
be "force-evicted" (abandoned but still running, still holding connections),
|
||||
and abandoned probe/renewal queries starved the connection pool until the
|
||||
worker killed itself with a misleading "DB unreachable" — while the database
|
||||
sat at a fraction of capacity. This release fixes the starvation class and
|
||||
adds real per-job blast-radius control.
|
||||
|
||||
### Added
|
||||
- **`gbrain jobs work --job-isolation process`** (also
|
||||
`gbrain jobs supervisor --job-isolation process`, env
|
||||
`GBRAIN_JOB_ISOLATION`): each claimed job runs in its own child process.
|
||||
A stuck handler is group-SIGKILLed for real instead of abandoned, a crash
|
||||
or memory blowup takes one job instead of all N, and the OS reclaims every
|
||||
leaked resource when the child dies. The worker keeps claiming, renewing,
|
||||
and recording; handler-error semantics (unrecoverable → dead, rate-lease →
|
||||
no attempt burned, backoff otherwise) are preserved across the boundary.
|
||||
Worker shutdown gives children the drain window to finish and report — a
|
||||
routine deploy never burns a job attempt. Recommended for long-running
|
||||
LLM-bound handlers; see the new section in `docs/guides/minions-deployment.md`.
|
||||
- **Health-probe verdicts that name the failing layer.** When the worker's DB
|
||||
probe fails, it now disambiguates via the direct session lane and says
|
||||
`pool_starved` ("server IS reachable; the fault is in the
|
||||
transaction-pooler path") or `server_unreachable` — instead of the blanket
|
||||
"DB unreachable" that historically sent operators debugging database
|
||||
capacity while the real fault was client-side. A startup warning also makes
|
||||
single-pool mode (direct-lane kill switch) loud instead of silent, and
|
||||
`docs/guides/queue-operations-runbook.md` gains a verdict-interpretation
|
||||
table.
|
||||
- `GBRAIN_POOL_MAX_LIFETIME_S`: explicit client-pool connection max-lifetime
|
||||
knob (0 disables; default stays the per-connection 30–60min jitter).
|
||||
|
||||
### Fixed
|
||||
- **Timed-out DB probes and lock renewals are now cancelled, not abandoned.**
|
||||
Every place that raced a query against a timer (health probe, minion lock
|
||||
renewal, cycle-drain renewal, submit-time queue probes, DB-lock refresh)
|
||||
previously let the losing query keep running on a checked-out connection —
|
||||
under pool exhaustion each abandoned racer held a slot and made the
|
||||
exhaustion worse, starving the lock heartbeat first. All five sites now
|
||||
abort the query via its cancellation signal so the slot is released.
|
||||
- Long-running maintenance holds (index rebuilds, non-transactional
|
||||
migrations, backfill write batches) now reserve from the direct session
|
||||
lane instead of pinning the worker's shared pool — capped so reserved
|
||||
holds always leave a direct-lane slot for the claim/renewal heartbeats,
|
||||
and falling back to the previous behavior when the direct lane is
|
||||
unavailable.
|
||||
|
||||
Full operational detail: `docs/guides/minions-deployment.md` (isolation
|
||||
sizing: connections, memory, spawn cost) and
|
||||
`docs/guides/queue-operations-runbook.md` (probe verdicts).
|
||||
|
||||
## [0.46.0.0] - 2026-08-14
|
||||
|
||||
**Your other agents' sessions become brain knowledge.** Until now only Claude
|
||||
Code sessions flowed into the brain automatically; every Codex rollout,
|
||||
OpenClaw session, and Hermes conversation on your disk — often years of
|
||||
decisions — was invisible. `gbrain transcripts ingest` imports them all as
|
||||
readable conversation pages with provenance back to the exact session file,
|
||||
and the facts pipeline makes them answer "what did I decide about X, in
|
||||
whichever agent I said it" as one query. Consumer chat exports (ChatGPT and
|
||||
Claude.ai `conversations.json`) import through the same door.
|
||||
|
||||
- **One command, six formats.** `gbrain transcripts ingest <path-or-glob>`
|
||||
auto-detects Claude Code JSONL, Codex rollouts, OpenClaw sessions, the
|
||||
Hermes SQLite store (read from a lock-safe copy), and extracted
|
||||
ChatGPT/Claude.ai exports. No arguments shows what it WOULD import across
|
||||
your harness directories; `--all` imports the discovered set;
|
||||
`gbrain transcripts status` shows the found-vs-imported gap per harness.
|
||||
- **Safe by default.** Secrets are redacted before anything is written
|
||||
(bodies, titles, speaker labels, and session metadata; plus your
|
||||
`harvest-private-patterns.txt` rules), message content that mimics
|
||||
conversation formatting cannot forge speakers or timestamps, and imports
|
||||
are a readable text-turn archive by design — tool payloads and thinking
|
||||
blocks never land in pages (one-line placeholders mark where they
|
||||
happened). Embedding is off by default for bulk backfills
|
||||
(opt in with the embed flag, or run the embed backfill later).
|
||||
- **Free to re-run.** Unchanged sessions skip on content hash; long sessions
|
||||
split into searchable parts that reconcile themselves when a session
|
||||
shrinks; interrupted runs converge on the next pass, healing any half-done
|
||||
writes. `--since last` resumes from the previous complete run and never
|
||||
advances past files it could not fully read.
|
||||
- **Facts on demand.** `--facts` extracts through the shipped
|
||||
conversation-facts pipeline under a budget cap; imported pages also flow
|
||||
into the existing scheduled backfill when that cycle phase is enabled.
|
||||
|
||||
### Added
|
||||
- `gbrain transcripts ingest` and `gbrain transcripts status` subcommands
|
||||
(engine-free `--help`), with discovery mode, `--all`, `--dry-run`,
|
||||
`--format`, `--limit`, `--since <iso|last>`, `--source-id`, `--facts`,
|
||||
`--max-cost-usd`, `--embed`, `--json`, `--quiet`.
|
||||
- Transcript-adapter seam at `src/core/transcripts/` (session-granular
|
||||
contract with per-file diagnostics and drift alarms; dated spec targets per
|
||||
host format) and adapters for Codex, OpenClaw, Hermes, ChatGPT export, and
|
||||
Claude.ai export; the shipped Claude Code parser gains an additive
|
||||
timestamp-preserving mode, regression-pinned for the hook lane.
|
||||
- Batch `slugs` selector on the conversation-facts extraction core (one
|
||||
invocation per import run; an empty list is a no-op, never a full-corpus
|
||||
walk).
|
||||
- Write-back fidelity e2e through the raw adapter path (gold-extractor
|
||||
seam), pinning cross-harness continuity in one source.
|
||||
|
||||
### Changed
|
||||
- `skills/conversation-archive` now routes the covered formats to the native
|
||||
importer and states the native-vs-manual privacy delta.
|
||||
- The fixture-privacy gate also scans the new transcript fixture corpus.
|
||||
|
||||
### Fixed
|
||||
- PGLite `putRawData` now detects a missing page like the Postgres engine
|
||||
(integrity failures abort instead of silently no-opping).
|
||||
|
||||
### To take advantage of v0.46.0.0
|
||||
Upgrade, then run `gbrain transcripts ingest` with no arguments to see every
|
||||
importable session log on the machine, and `gbrain transcripts ingest --all`
|
||||
to import them. Unzip consumer exports first and pass the extracted
|
||||
`conversations.json`. On PGLite, stop `gbrain serve` for the import (the
|
||||
single-writer lock error names the PID if you forget). Run
|
||||
`gbrain transcripts status` any time to see what's still waiting.
|
||||
## [0.45.20.0] - 2026-08-14
|
||||
|
||||
**Grok Build joins the supported-client roster.** xAI's `grok` CLI can now wire a gbrain brain in one command, and — like Hermes before it — the install path is proven against the real binary, not written from docs: every asserted flag, config shape, and exit-code quirk was observed against a pinned Grok Build install, recorded in a machine-checked pin document, and exercised by a real-binary e2e door that CI can run.
|
||||
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -235,6 +236,21 @@ curl -X POST https://your-brain/ingest \
|
||||
For mobile capture, the inbox folder source picks up anything dropped into
|
||||
`~/.gbrain/inbox/` from iOS Shortcuts / AirDrop / Drafts / Finder.
|
||||
|
||||
Your other agents' histories import in one command. `gbrain transcripts ingest`
|
||||
parses agent session logs (Claude Code, Codex, OpenClaw, Hermes) and extracted
|
||||
consumer chat exports (ChatGPT / Claude.ai `conversations.json`) into readable
|
||||
conversation pages with provenance back to the exact session file. Secrets are
|
||||
scrubbed from message bodies, titles, speakers, and session metadata before
|
||||
anything is written, embedding is off by default for bulk backfills, and
|
||||
re-runs are free — unchanged sessions skip on content hash:
|
||||
|
||||
```bash
|
||||
gbrain transcripts ingest # discover importable session logs
|
||||
gbrain transcripts ingest --all # import everything discovered
|
||||
gbrain transcripts ingest ~/Downloads/conversations.json # consumer export (unzip first)
|
||||
gbrain transcripts status # found vs imported, per harness
|
||||
```
|
||||
|
||||
Third-party skillpacks can ship custom ingestion sources (Granola, Linear,
|
||||
voice, OCR) against the versioned `IngestionSource` contract at
|
||||
`gbrain/ingestion`. See [`docs/skillpack-anatomy.md`](docs/skillpack-anatomy.md).
|
||||
@@ -292,11 +308,11 @@ 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).
|
||||
|
||||
**Job queue (Minions).** BullMQ-shaped, Postgres-native job queue. Durable subagents (LLM tool loops that survive crashes via two-phase pending→done persistence), shell jobs with audit, child jobs with cascading timeouts, rate leases for outbound providers, attachments via S3/Supabase storage. Replaces "spawn subagent as fire-and-forget Promise" with something that recovers from anything.
|
||||
**Job queue (Minions).** BullMQ-shaped, Postgres-native job queue. Durable subagents (LLM tool loops that survive crashes via two-phase pending→done persistence), shell jobs with audit, child jobs with cascading timeouts, rate leases for outbound providers, attachments via S3/Supabase storage. Opt-in per-job process isolation (`gbrain jobs work --job-isolation process`) runs each claimed job in its own SIGKILL-able child process, so a stuck handler dies for real and a crash takes one job instead of the whole worker; when the worker's DB health probe fails, it names the failing layer (`pool_starved` vs `server_unreachable`) instead of a blanket "DB unreachable". Sizing and rollout guidance in [`docs/guides/minions-deployment.md`](docs/guides/minions-deployment.md); probe-verdict triage in [`docs/guides/queue-operations-runbook.md`](docs/guides/queue-operations-runbook.md). Replaces "spawn subagent as fire-and-forget Promise" with something that recovers from anything.
|
||||
|
||||
**Non-English brains (FTS language config).** The Postgres full-text search tokenizer is configurable via `GBRAIN_FTS_LANGUAGE`. Defaults to `english`. Set it to any text-search configuration that exists in your Postgres instance:
|
||||
|
||||
@@ -330,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.
|
||||
|
||||
@@ -349,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
|
||||
@@ -495,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.
|
||||
|
||||
@@ -1,5 +1,149 @@
|
||||
# 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:**
|
||||
`transaction()` callers that invoke parent-engine methods (or module helpers
|
||||
taking `engine` not `tx`) take a SECOND read-pool slot while holding the tx
|
||||
slot — e.g. the `operations.ts` advisory-lock loop around `tx.addLink`. Under
|
||||
a saturated pool this is a client-side self-deadlock class. Audit call sites;
|
||||
add a dev-mode warning (e.g. a tx-depth counter consulted by `runUnsafe`).
|
||||
**Why:** the #6 incident's exact 240s-idle sessions were never reproduced
|
||||
under a debugger; this is the strongest remaining candidate — the shipped
|
||||
wave mitigates the starvation class but does not close this path. **Effort:**
|
||||
M. **Priority:** P1-companion.
|
||||
- [ ] **P2 — per-handler isolation policy.** **What:** a per-handler-name set
|
||||
(e.g. long-running LLM-bound handlers isolate, sub-second `lint`/`backlinks`
|
||||
stay inline) instead of the all-or-nothing `--job-isolation process`.
|
||||
**Why:** spawn cost (~0.3–1s) is noise for 644s subagent jobs, meaningful
|
||||
for sub-second handlers; one worker should be able to mix. **Context:**
|
||||
`worker.ts` executeJob's `isolated` gate is the seam. **Effort:** M.
|
||||
**Priority:** P2.
|
||||
- [ ] **P2 — per-child --max-rss caps.** **What:** RSS watchdog for isolation
|
||||
children (the worker-level watchdog covers the worker only in process mode;
|
||||
a startup note ships today). **Context:** child-job-runner.ts owns the child
|
||||
lifecycle; a poll of the child's RSS + group-kill on breach mirrors the
|
||||
worker watchdog. **Effort:** M. **Priority:** P2.
|
||||
- [ ] **P2 — jobs-side connection-budget clamp for isolated workers.** **What:**
|
||||
warn/clamp concurrency when `concurrency × (child pool + 1) + parent pools`
|
||||
exceeds a configured budget (GBRAIN_MAX_CONNECTIONS-style; precedent
|
||||
`sync-concurrency.ts:clampWorkersForConnectionBudget`). **Why:** isolation
|
||||
multiplies pooler CLIENT connections (~73 at concurrency 15); today the
|
||||
budget lives only in docs math. **Effort:** S. **Priority:** P2.
|
||||
- [ ] **P3 — --job-isolation pass-through for the autopilot's embedded
|
||||
supervisor.** **What:** `autopilot.ts` builds its own worker args; add the
|
||||
conditional flag there (jobs supervisor already passes through). **Effort:**
|
||||
S. **Priority:** P3.
|
||||
- [ ] **P3 — runLockRenewalTick adoption in the cycle drain.** **What:**
|
||||
`synthesize.ts` now uses the minimal `runDrainRenewalTick` (per-call signal +
|
||||
guard); adopting the full tick would add the audit channel + bounded
|
||||
reconnect. **Effort:** S. **Priority:** P3.
|
||||
- [ ] **P3 — streaming child progress.** **What:** isolation children report
|
||||
progress via their own token-fenced DB writes today (identical to inline);
|
||||
an IPC stream would only add parent-side visibility (e.g. lifecycle events
|
||||
in `jobs watch`). **Effort:** M. **Priority:** P3.
|
||||
- [ ] **P3 — connection-audit release events + plain-idle visibility.**
|
||||
**What:** `logConnectionEvent` never emits `release`, so the JSONL cannot
|
||||
answer "who holds a slot"; and `getIdleBlockers` filters
|
||||
`state='idle in transaction'` only — the #6 incident's plain-`idle` sessions
|
||||
were invisible to it. **Effort:** M. **Priority:** P3.
|
||||
- [ ] **P3 — doctor connection_routing check.** **What:** wire
|
||||
`ConnectionManager.describeMode()` + `healthCheck()` (both currently
|
||||
zero-caller outside tests) into a doctor check naming the routing mode,
|
||||
kill-switch state, and per-pool probe latency. Comments in four files
|
||||
already reference this check as if it existed. **Effort:** S.
|
||||
**Priority:** P3.
|
||||
- [ ] **P3 — isolation test-gap follow-ups (pre-landing review).** **What:**
|
||||
(a) spawned-CLI negative tests for `jobs run-child` bootstrap guards (PGLite
|
||||
→ exit 13; missing job-id/env → exit 13) and for `jobs work` with
|
||||
isolation on + an unresolvable child CLI (fail-fast exit 1) — both need a
|
||||
real engine bootstrap so they live in the e2e lane; (b) a behavioral (not
|
||||
structural) test driving `withRefreshingLock` with a hung injected
|
||||
`handle.refresh` (signal aborted at timeout, no overlapping ticks); (c) a
|
||||
force-evict-skip test for isolation mode (needs the 30s evict window made
|
||||
injectable); (d) operator-flow message tests (verdict-tailored FATAL text,
|
||||
single-pool startup banner). **Why:** the ship coverage audit scored the
|
||||
wave 82% — these are the surviving gaps. **Effort:** M. **Priority:** P3.
|
||||
- [ ] **P3 — raceWithAbortTimeout shared helper.** **What:** the
|
||||
"Promise.race a query vs a setTimeout that aborts an AbortController,
|
||||
clearTimeout in finally" pattern now exists at five sites (db-probe
|
||||
withDeadline, synthesize runDrainRenewalTick, lock-renewal-tick callAbort,
|
||||
db-lock tickAbort, supervisor probeAbort), each re-deriving the same
|
||||
invariants. Extract one helper and adopt it. **Effort:** S. **Priority:** P3.
|
||||
- [ ] **P3 — lazy handler resolution in run-child.** **What:** every isolation
|
||||
child runs full registerBuiltinHandlers (incl. plugin discovery) to resolve
|
||||
ONE handler; the job name is known from the row — a resolve-by-name path
|
||||
would skip discovery for builtins. Matters only if isolation is ever used
|
||||
for short jobs (documented as not the target). **Effort:** S. **Priority:** P3.
|
||||
- [ ] **P3 — full checkout instrumentation via a Sql proxy.** **What:** the
|
||||
CheckoutGauge covers raw/direct/reserved/tx seams only; tagged-template
|
||||
traffic (most engine load) is untracked. A proxy around the postgres.js Sql
|
||||
callable could count real checkouts — investigate cost/fragility before
|
||||
building. **Why:** would turn the probe's "tracked subset" caveat into full
|
||||
coverage. **Effort:** M. **Priority:** P3.
|
||||
|
||||
## Security-process follow-ups (filed with Wave −1 of the fix-wave campaign, 2026-08-14)
|
||||
|
||||
- [ ] **P2 — Vulnerability disclosure policy.** **What:** a written disclosure
|
||||
@@ -254,14 +398,6 @@ fix-now findings landed on the branch; these four are the review-deferred tail.
|
||||
`requestToolsPersistLimiter`; the surface_change audit rows already give
|
||||
a DB-side count to enforce against if needed. **Effort:** medium.
|
||||
**Priority:** P3.
|
||||
- [ ] **P3 — cancel (not just abandon) timed-out submit-time queue probes.**
|
||||
**What:** the WP5 wedge/pause probes time-bound via Promise.race, but the
|
||||
losing query keeps running on the pool after the race resolves. Wire
|
||||
AbortSignal / statement_timeout so a slow probe releases its slot. **Why:**
|
||||
under pool exhaustion (the exact regime the probes exist to detect) an
|
||||
abandoned probe query holds a pooler slot and makes the exhaustion worse.
|
||||
**Context:** `src/core/minion/supervisor.ts` queryWedgeSignals callers in
|
||||
`src/core/operations.ts` submit paths. **Effort:** small. **Priority:** P3.
|
||||
- [ ] **P3 — document the status --json snapshot union under schema_version.**
|
||||
**What:** a short protocol note (docs/progress-events.md sibling) pinning
|
||||
the `get_status_snapshot` v2 shape as a discriminated union on
|
||||
@@ -361,7 +497,7 @@ Deferred from the BrainBench wave (eng-reviewed; plan + GSTACK REVIEW REPORT at
|
||||
|
||||
- [ ] **`--live` agent-in-the-loop know-to-ask.** Replay fixtures with a real model deciding whether to issue retrieval calls; grade the agent, not just the deterministic reflex. Pre-registered in `docs/eval/BRAINBENCH.md` (the v1 metric grades the injection decision, which IS the shipped mechanism). Needs: seeded N-repeat methodology for model stochasticity + budget rails. Priority: P2.
|
||||
- [ ] **Intrusion-budget gating calibration.** `avg_injected_tokens` is reported, non-gating (decision 18) — a wrong threshold is worse than none. After a few weeks of scoreboard data across PRs, pick calibrated per-seam thresholds and promote it to a gated metric. Priority: P2.
|
||||
- [ ] **Flip contract adapters to production — claude-code half now unblocked.** `adapters/claude-code.ts` exports the UserPromptSubmit hook wire types; the real hook (`gbrain hook user-prompt`, shipped with the bootstrap lane and extended with cross-turn dedupe + the channel feedback loop in the cathedral-3 convergence) swaps the in-process transport for an exec of the hook script and flips `seam: 'contract'` → `'production'` with continuous bench numbers. Note the production hook also exercises transcript-based dedupe, which the memoryless contract row deliberately doesn't. Same for codex fragments when that integration lands. Priority: P1 (the claude-code integration has landed; this is now standalone-actionable).
|
||||
- [ ] **Flip contract adapters to production — claude-code half now unblocked.** `adapters/claude-code.ts` exports the UserPromptSubmit hook wire types; the real hook (`gbrain hook user-prompt`, shipped with the bootstrap lane and extended with cross-turn dedupe + the channel feedback loop in the cathedral-3 convergence) swaps the in-process transport for an exec of the hook script and flips `seam: 'contract'` → `'production'` with continuous bench numbers. Note the production hook also exercises transcript-based dedupe, which the memoryless contract row deliberately doesn't. For the codex half: the cathedral-4 transcripts lane shipped a verified codex rollout PARSER (`src/core/transcripts/codex.ts`, structural turn selection pinned against a live sample) — a codex contract adapter can now consume it instead of waiting for a hook integration. Priority: P1 (the claude-code integration has landed; codex parsing has landed; this is now standalone-actionable).
|
||||
- [ ] **Cathedral 1 conformance-kit fixture import.** The memory-verbs conformance scenarios convert to BrainBench fixtures via the published `evals/brainbench/schema/fixture.schema.json` once `garrytan/cathedral-1` merges ("conformance tests double as BrainBench seed fixtures", decision log 2026-06-12). Free corpus growth from already-reviewed scenarios. Blocked by: cathedral-1 on master. Priority: P2.
|
||||
- [ ] **Live-embeddings fidelity mode (`--embeddings`).** Hermetic CI grades the keyword/alias arms only (disclosed); an opt-in mode seeding real embeddings would grade write-back/continuity retrieval through the vector path. Same budget rails as `--llm`. Priority: P3.
|
||||
- [ ] **Community fixture intake + competitor adapters.** The TD1 remainder after the generated corpus absorbed in-PR growth: an `external-authors/`-style intake path for contributed fixtures (validator + privacy guard already gate them) and adapters for non-gbrain memory systems against the published schemas, enabling true head-to-head rows in the gbrain-evals scorecard. Priority: P3.
|
||||
@@ -669,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`.
|
||||
@@ -5852,10 +5989,14 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts`
|
||||
- [ ] **P2 — `gbrain ingest feed`: native feed adapter.** blog-ingest ships the
|
||||
agent-procedure layer; the durable path is a deterministic RSS/Atom adapter
|
||||
(discovery, pagination, canonical-URL dedup, 429 backoff) behind one command.
|
||||
- [ ] **P2 — Native AI-chat export importer.** conversation-archive converts
|
||||
ChatGPT/Claude/Perplexity exports via agent procedure; a native importer
|
||||
(export JSON → conversations/ pages) makes it deterministic. Pairs with the
|
||||
existing conversation-parser surface.
|
||||
- [x] **P2 — Native AI-chat export importer.** **Completed:** v0.46.0.0 (2026-08-14).
|
||||
`gbrain transcripts ingest` imports extracted ChatGPT and Claude.ai
|
||||
`conversations.json` exports natively (adapters at
|
||||
`src/core/transcripts/{chatgpt-export,claude-export}.ts`, rendering on the
|
||||
conversation-parser surface). Perplexity has no adapter yet — a candidate
|
||||
leaf module on the same `TranscriptAdapter` seam (the pattern the
|
||||
cathedral-4 "More harness adapters" follow-up below documents); the
|
||||
conversation-archive skill keeps the manual procedure for it meanwhile.
|
||||
- [ ] **P2 — Entity-guard as a native op.** phonetic-name-guard's own changelog
|
||||
proves prose-only failed: ASR-variant entity collisions need a native check
|
||||
(registry + alias table consulted at put/import time). The wave shipped the
|
||||
@@ -5915,6 +6056,54 @@ 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
|
||||
eng review — each carries a named design, none is a bug. Context: the import lane
|
||||
(adapters at `src/core/transcripts/`, session-atomic pipeline, embed-OFF default)
|
||||
covers DEAD logs; go-forward capture beyond Claude Code is deliberately absent.
|
||||
|
||||
- [ ] **OpenClaw go-forward capture.** Blocked upstream: the OpenClaw PluginApi exposes only `registerContextEngine` — no end-of-turn/agent-end capability. When the host grows one, the plugin (`src/openclaw-context-engine.ts`) subscribes and emits the session into the corpus lane (`~/.gbrain/transcripts/corpus` sidecar protocol) the way `gbrain hook session-end` does for Claude Code; the openclaw session PARSER already ships. Consent must ride a capture line like the bootstrap harness `--no-capture` model. Priority: P2.
|
||||
- [ ] **Codex go-forward capture (notify sweeper).** `docs/designs/AGENT_BOOTSTRAP_PLAN.md` FF2 names the design (notify sweeper over `~/.codex/sessions`); the rollout parser now ships in `src/core/transcripts/codex.ts`, so the sweeper is pure wiring: on codex notify, run `gbrain transcripts ingest <rollout> --quiet`. Needs the same consent posture as capture. Priority: P2.
|
||||
- [ ] **Scheduled re-import cycle phase.** `transcripts ingest --since last --all` as an opt-in cycle phase so dead-log import self-refreshes. REQUIRES its own consent-line design first: reading harness dirs on a schedule is capture-adjacent (the "Autonomous transcript watchers" decision above rules the spirit); the clean-scan watermark + status gap table already make manual re-runs cheap. Priority: P3.
|
||||
- [ ] **PII auto-detection redaction pass for imports.** The native lane redacts secrets (secret-scan) + user patterns (`harvest-private-patterns.txt`, emails included) and counts imperatives; broad PII detection (names, phones, addresses) is its own subsystem — the conversation-archive skill keeps the human scrub step for sensitive corpora meanwhile. Priority: P2.
|
||||
- [ ] **More harness adapters: Cursor / Gemini CLI / Copilot CLI.** Leaf modules on the `TranscriptAdapter` seam (~1h each with an agent): dated SPEC_TARGET + scrubbed fixture + drift alarm, per the shipped six. Formats unverified locally — verify a real sample first (the hermes gate pattern). Priority: P3.
|
||||
- [ ] **ChatGPT/Claude.ai export zip unwrapping.** v1 requires the EXTRACTED `conversations.json` ("unzip first" is documented + error-hinted). Add zip handling without a heavy dependency (Bun has no built-in zip; evaluate a minimal vendored inflate or shelling to `unzip` with confinement). Priority: P3.
|
||||
- [ ] **BrainBench raw-format fixture schema (sibling repo).** The in-repo pin (`test/e2e/transcripts-writeback-fidelity.test.ts`) grades raw files through the adapters with the gold extractor, but the BrainBench corpus schema (gbrain-evals) still rejects unknown keys and its corpus hash doesn't cover raw sidecars. Needs: versioned raw-fixture sidecar type + loader + hash coverage + baseline re-cut in gbrain-evals, then a `write_back_fidelity_raw` suite row here. Priority: P2.
|
||||
- [ ] **Hermes SPEC_TARGET verification against a populated store.** The schema came from the installed hermes-agent v0.20.0 source (`SCHEMA_SQL`), but no populated `state.db` existed on the dev machine — the fixture is synthetic-by-declaration. Verify against a real store after some Hermes sessions accrue, then flip `status: 'provisional'` → `'verified'` and pin the `active`/`compacted` semantics the adapter currently ignores. Priority: P3.
|
||||
## Grok Build wave follow-ups (filed at build time)
|
||||
|
||||
- [ ] **P1 — Enable the grok-door paid lane once XAI_API_KEY exists.** Admin
|
||||
@@ -5941,25 +6130,36 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts`
|
||||
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
|
||||
@@ -5983,7 +6183,125 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts`
|
||||
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
|
||||
fan-out.** What: restructure the fan-out to submit bounded batches and
|
||||
drain each before submitting more, stopping against the parent job's
|
||||
`deadlineAtMs`. Why: today the phase bulk-submits every accepted child
|
||||
then drains sequentially inside `autopilot-cycle`'s 30-min wall clock
|
||||
(`handler-timeouts.ts:44`); a timeout mid-drain strands the remainder in
|
||||
the run's private queue (the C1 self-heal + retriage conversion now
|
||||
recover them, but not creating strands beats recovering them). Blocked
|
||||
by: `runCycle` does not thread deadline/abort into phases (verified
|
||||
absent at the synthesize call site, cycle.ts ~2030). Context: outside
|
||||
voice C2 on the #4152 eng review; the triage `max_ms` budget bounds the
|
||||
cheap half, this bounds the expensive half. Effort: M/L.
|
||||
- [ ] **P2 — Scheduled reject sample-audit with spend-posture
|
||||
integration.** What: automate `dream retriage --audit-rejects N` on a
|
||||
cadence (weekly cron or post-cycle sampling) writing disagreement-rate
|
||||
telemetry, gated by `spend.posture`. Why: the threshold is an
|
||||
intuition-set 0.5 until real false-negative data exists; the cascade
|
||||
literature is unanimous that unaudited gates drift (eng-review search
|
||||
check). The manual flag ships with #4152; this files the loop that runs
|
||||
without an operator remembering. Depends on: a few weeks of production
|
||||
score distributions. Effort: M.
|
||||
- [ ] **P3 — Borderline-band routing (0.30–0.49 → mid-tier model or batch
|
||||
digest).** What: a second lane where near-threshold files get a cheaper
|
||||
treatment instead of the binary keep/drop. Why: the issue marked it
|
||||
optional; it adds a third model lane + a second threshold pair, which
|
||||
should be tuned from `details.triage` score distributions rather than
|
||||
guessed. Blocked by: production calibration data (see the audit TODO
|
||||
above). Effort: M.
|
||||
- [ ] **P3 — Source×corpus multiplier: per-source corpus mapping or
|
||||
explicit fan-out consent.** What: `dream.synthesize.session_corpus_dir`
|
||||
is GLOBAL config while synth idempotency keys are SOURCE-namespaced, so
|
||||
N registered sources each re-fan the same corpus (a live deployment saw
|
||||
3 × ~1,250 jobs/day of the same files). Triage verdicts are
|
||||
source-agnostic (judged once) and the cascade cuts each source's fanout
|
||||
by the pass rate, but total synthesis is still N× the corpus. Why
|
||||
deferred: pages land per-source, so per-source synthesis may be intended
|
||||
semantics for some operators — needs its own issue + design (per-source
|
||||
corpus config keys vs an explicit multi-source consent flag). Diagnostic:
|
||||
`dream retriage --reconcile-queue --json` reports `queue.by_source`.
|
||||
Context: outside voice C3 argued root-cause-first; scoped out twice
|
||||
during the #4152 review. Comment on #4152 after ship. Effort: M.
|
||||
- [ ] **P3 — Dream triage perf follow-ups (from the #4152 ship review).**
|
||||
What: (a) batch the per-file `getDreamVerdict` PK probes in `runTriagePass`
|
||||
into one prefetch (unnest join on (file_path, content_hash)) and reuse it
|
||||
for retriage's spend-estimate loop (currently 2×N sequential roundtrips on
|
||||
the operator sweep); (b) a partial index for `countRecentSynthSubmissions`
|
||||
(`(created_at) WHERE name='subagent' AND idempotency_key LIKE
|
||||
'dream:synth-v2:%'`) so the opt-in daily cap's count is index-served on
|
||||
busy brains; (c) a shared `seedTriageVerdict` test helper to collapse the
|
||||
five hand-rolled triage-v1 seed blocks. Why: all flagged by the ship
|
||||
review's performance/maintainability specialists; none block — cache
|
||||
probes are ~0.1% of adjacent LLM latency and the cap is default-off.
|
||||
Effort: M.
|
||||
- [ ] **P3 — Per-file single-flight for triage cache misses.** What:
|
||||
concurrent passes (retriage while a cycle runs) can double-judge the same
|
||||
uncached file (~1¢/file, last-write-wins converges — benign but untidy);
|
||||
a per-(file,hash) advisory claim would dedupe. Why deferred: real locks
|
||||
are heavy machinery for a benign-cost race; the retriage help documents
|
||||
the behavior. Context: outside-voice CX5 on the #4152 ship review.
|
||||
Effort: M.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -334,6 +334,18 @@ Unit tests and what they cover:
|
||||
- `test/enrichment-service.test.ts` — entity slugification, extraction, tier escalation.
|
||||
- `test/data-research.test.ts` — recipe validation, MRR/ARR extraction, dedup, tracker parsing, HTML stripping.
|
||||
- `test/minions.test.ts` — Minions job queue: CRUD, state machine, backoff, stall detection, dependencies, worker lifecycle, lock management, claim mechanics, depth/child-cap, timeouts, cascade kill, idempotency, `child_done` inbox, attachments, removeOnComplete/Fail, `max_stalled` clamp/default/plumbing coverage.
|
||||
- `test/minion-queue-renewlock-signal.test.ts` — `renewLock` forwards its optional AbortSignal to `executeRawDirect` (stub-engine capture); legacy 3-arg calls unchanged; token-fence miss returns false.
|
||||
- `test/cycle-drain-renewal.test.ts` — `runDrainRenewalTick` (cycle drain): per-call signal aborted on timeout (slot released), onLost once on a lost fence, throws swallowed, hung renewal resolves at the deadline.
|
||||
- `test/queue-probe-cancellation.test.ts` — `probeQueueState`/`queryWedgeSignals` signal threading: the 1500ms budget CANCELS the losing probe query; fast-path signals never abort; throw still collapses to `{probe_failed: true}`.
|
||||
- `test/db-pool-max-lifetime.test.ts` — `resolveMaxLifetimeSeconds`: env forms, 0-disables, 30–60min jitter bounds, warn-once on invalid, per-call jitter variance.
|
||||
- `test/pool-gauge.test.ts` — `CheckoutGauge` pure semantics + the PostgresEngine seams with fake pools: counted while in flight, released on resolve, on REJECTED queries, and on the SYNCHRONOUS pre-aborted-signal throw (leak guards); `getPoolDiagnostics` fail-open.
|
||||
- `test/db-probe.test.ts` — `runDbProbe` verdict matrix (pool_starved / server_unreachable / unknown), honest-disjunction + no-waiter-arithmetic wording pins, hung probes cancelled via their signals, diagnostics absent/throwing fail open.
|
||||
- `test/postgres-engine-reserved-routing.test.ts` — `withReservedConnection` routing: direct pool when dual-pool active, read pool when kill-switched/in-tx, semaphore cap (directPoolSize−1) with read-pool overflow, permit released on fn throw and reserve failure.
|
||||
- `test/job-isolation-protocol.test.ts` — outcome-file codec round-trip + every decode failure path (missing/malformed/oversize→UnrecoverableError; byte counts, never content), handler-error instanceof reconstruction, child-CLI invocation resolution, and REAL detached-process `killProcessGroup` tests incl. the grandchild-death guarantee (exercises the Bun negative-pid `/bin/kill` fallback for real under `bun test`).
|
||||
- `test/run-child-entry.test.ts` — `runChildJobEntry` on real in-memory PGLite with a REAL claim-minted token: success (fenced updateProgress lands), handler-failure outcome (exit 0), token-mismatch never runs the handler (exit 14), missing job/handler, parent-death watchdog aborts a live handler.
|
||||
- `test/child-job-runner.test.ts` — `runJobInChild` against real .mjs children: success + full env contract (incl. `GBRAIN_DIRECT_POOL_SIZE=1`), error/lease outcome reconstruction, crash, SIGTERM-ignorer → group SIGKILL at the injected grace, pre-aborted signal, spawn ENOENT → `ChildSpawnInfraError`, worker-shutdown drain (report-during-drain completes; non-reporting kill → `ChildWorkerShutdownError`).
|
||||
- `test/worker-job-isolation.test.ts` — full parent path on PGLite with the `fake-run-child.mjs` fixture: claim → child → fenced completeJob (real token over env), error outcome → failJob, crash burns the attempt, spawn failure RELEASES with zero attempts burned, and the codex-2 #8 serialization-parity pin (unreportable results fail in BOTH modes, never falsely complete).
|
||||
- `test/jobs-isolation-flag.test.ts` — `parseJobIsolationFlag`: space/= forms, env fallback + flag-wins, empty-env default, other flags untouched.
|
||||
- `test/extract.test.ts` — link extraction, timeline extraction, frontmatter parsing, directory type inference.
|
||||
- `test/extract-db.test.ts` — `gbrain extract --source db`: typed link inference, idempotency, `--type` filter, `--dry-run` JSON output.
|
||||
- `test/extract-fs.test.ts` — `gbrain extract --source fs`: first-run inserts + second-run reports zero, dry-run dedups candidates across files, second-run perf regression guard for the N+1 dedup bug.
|
||||
@@ -400,6 +412,7 @@ E2E tests live in `test/e2e/` and run against real Postgres+pgvector (require `D
|
||||
- `test/e2e/sync.test.ts` — `--skip-failed` failure-loop test alongside happy-path tests: broken file → `performSync` returns `blocked_by_failures` with grouped breakdown → `performSync({skipFailed: true})` advances bookmark and returns `AcknowledgeResult` with code summary → second broken file → second cycle. Saves and restores the user's real `~/.gbrain/sync-failures.jsonl` so the test is hermetic. Asserts bookmark gating, JSONL state, dedup across paths, summary aggregation, and the literal doctor-rendering string format.
|
||||
- `test/e2e/upgrade.test.ts` — check-update against real GitHub API (network required).
|
||||
- `test/e2e/minions-shell-pglite.test.ts` — PGLite `--follow` inline shell-job path (in-memory, no `DATABASE_URL` required) — the path the minion-orchestrator skill documents for dev use.
|
||||
- `test/e2e/job-isolation.test.ts` — process isolation on real Postgres (DATABASE_URL-gated, wired EXPLICITLY into `.github/workflows/e2e.yml` tier1 — the workflow runs only named files): a concurrency-3 isolated drain through real child processes (the `fake-run-child.mjs` fixture — real spawns, no child DB pools), and the REAL `jobs run-child` CLI entrypoint end-to-end (engine bootstrap incl. the child's own pools, quiet handler registry, token validation, outcome protocol).
|
||||
- `test/e2e/pglite-cli-exit.serial.test.ts` — real spawned-CLI exit behavior on PGLite (in-memory, no `DATABASE_URL`): read commands (`search`/`get`/`query`) exit 0 promptly; CLI_ONLY `capture` exits clean and frees the single-writer lock; the `#2084` describes pin every swept disconnect site — a failed op exits 1 with the error on stderr, and the dashboard, read-only-timeout, doctor, and `dream --dry-run` paths all exit with no force-exit banner.
|
||||
- `test/e2e/pgbouncer-teardown.test.ts` — PgBouncer TRANSACTION-mode teardown (#2084 / the #1972→#2015→#2084 class). Pins the bug CLASS, not timings: a CLI op against a txn-mode pooled URL exits 0 with intact stdout and does NOT ride the 10s hard-deadline backstop (the `engine.disconnect() did not return` banner is the smoking gun — pre-#2084 it printed on 100% of query-shaped ops). Gated by `GBRAIN_PGBOUNCER_URL` + `GBRAIN_PGBOUNCER_DIRECT_URL` (NOT `DATABASE_URL`) — set automatically by `bun run ci:local`'s `pgbouncer` compose service; skips gracefully elsewhere. Uses a DEDICATED `gbrain_pgbouncer` database so it never races the `gbrain_test` TRUNCATE fixtures.
|
||||
- `test/e2e/volunteer-context-postgres.test.ts` — `volunteer_context` on REAL Postgres (#2095; engine parity beyond the hermetic PGLite unit suite): resolution arms through the actual op handler, the fire-and-forget volunteer-event sink landing rows, the stats join, and the RLS pin that `context_volunteer_events` has ROW LEVEL SECURITY enabled (keeps the v35 auto-RLS event trigger honest for migration-created tables). `DATABASE_URL`-gated.
|
||||
@@ -407,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.025–0.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)
|
||||
|
||||
@@ -86,7 +86,7 @@ the repo. The architectural rule still holds — these aren't
|
||||
| `mcp_request_log` | Audit trail. Volatile by design. |
|
||||
| `minion_jobs` / `minion_inbox` / `minion_attachments` | Job queue. Restarts re-enqueue or drop. |
|
||||
| `eval_candidates` / `eval_capture_failures` | Contributor-mode dev loop; opt-in capture. |
|
||||
| `dream_verdicts` | Cheap verdict cache. Rebuildable by re-running Haiku. |
|
||||
| `dream_verdicts` | Scored triage cache (salience score, quotes, entities, judging model + prompt version). Rebuildable via `gbrain dream retriage --force`. |
|
||||
| `gbrain_cycle_locks` / migration ledger | Infrastructure. |
|
||||
| `op_checkpoint_paths` | Sync-resume checkpoint. Append-only progress banking; a completed sync makes it irrelevant. |
|
||||
| `config` (some keys) | Site-local routing config (e.g. `sync.repo_path`). |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 30s–2min 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
|
||||
```
|
||||
|
||||
@@ -115,6 +115,49 @@ it nightly and Phase 4 below (plus most of Phase 2's hygiene checks) is
|
||||
covered. The pseudocode that follows is the harness-side variant for agents
|
||||
that also do LLM-driven entity sweeps and memory consolidation on top.
|
||||
|
||||
### Synthesis cost control: the triage cascade
|
||||
|
||||
The synthesize phase is a two-stage cascade: a cheap scored triage
|
||||
(utility-tier model, one call per new transcript) gates the expensive
|
||||
per-transcript synthesis subagents. The dials:
|
||||
|
||||
- `dream.triage.threshold` (default 0.5) — the gate. Scores are cached, so
|
||||
retuning it re-gates instantly with **zero** new LLM calls. Raise it if too
|
||||
much routine content synthesizes; lower it if real signal is being skipped.
|
||||
- `models.dream.triage` — the triage model (default: utility tier / Haiku).
|
||||
- `dream.triage.max_chars` (default 24000, floor 1000) — per-transcript
|
||||
sample window (head/middle/tail) sent to the judge. Not part of cache
|
||||
validity — after changing it, `gbrain dream retriage --force` re-judges
|
||||
under the new sampling.
|
||||
- `dream.triage.max_tokens` (default 2048, floor 256) — judge output budget.
|
||||
- `dream.triage.concurrency` (default 4, clamped 1–16) — concurrent judge
|
||||
calls.
|
||||
- `dream.synthesize.max_turns` (default 16) — synthesis turn budget. The
|
||||
triage map hands the subagent pre-extracted segments, so the mid-tier
|
||||
default model (`models.dream.synthesize`, tier `reasoning`) with a 16-turn
|
||||
budget is the intended pairing — frontier-model overrides are unnecessary
|
||||
and slow the queue. Completeness comes from triage coverage (every file
|
||||
scored, minus files deferred under the `max_ms` budget below) plus
|
||||
segment-guided prompts, not model size. If written-page counts
|
||||
drop after upgrading, set it back to 30 and check
|
||||
`details.synthesis.avg_turns` for cap pressure.
|
||||
- `dream.triage.max_ms` (default 5 min) — per-cycle wall-clock budget for
|
||||
judging NEW files; a big cold corpus triages across a few cycles (cached
|
||||
files are free). Deferred files are labeled "not yet triaged", never
|
||||
silently rejected.
|
||||
- `dream.synthesize.max_submissions_per_source_per_day` (default 0 = off) —
|
||||
opt-in backstop cap on synthesis jobs per source; 200/day is a sane value
|
||||
for busy deployments.
|
||||
|
||||
Maintenance recipe — after changing the threshold, upgrading through a
|
||||
`TRIAGE_VERSION` bump, or to drain a queued synthesis backlog:
|
||||
|
||||
```bash
|
||||
gbrain dream retriage --dry-run # what would change (zero LLM calls)
|
||||
gbrain dream retriage --reconcile-queue # re-score + cancel below-threshold queued jobs
|
||||
gbrain dream retriage --audit-rejects 20 # synthesis-model second opinion on 20 rejects
|
||||
```
|
||||
|
||||
### What It Does
|
||||
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -87,6 +87,69 @@ check warns if what you asked for isn't what's actually running (e.g. a
|
||||
negative value denied without privilege, or an OS `RLIMIT_NICE` clamp). This
|
||||
is distinct from the concurrency / inflight cap and composes with it.
|
||||
|
||||
### Per-job process isolation (`--job-isolation process`)
|
||||
|
||||
By default all concurrency slots execute inside one worker process. A
|
||||
handler that ignores its abort signal can only be force-evicted — the
|
||||
promise is abandoned, still running, still holding connections and memory —
|
||||
and any worker exit destroys every in-flight job at once. With isolation on,
|
||||
each claimed job runs in its own child process: a stuck handler is
|
||||
group-SIGKILLed for real (group signaling under Bun falls back to POSIX
|
||||
`/bin/kill`; if that's unavailable the worker logs that isolation is
|
||||
degraded), a crash or OOM in a child takes that one job instead of all N,
|
||||
and the OS reclaims every leaked resource when the child dies:
|
||||
|
||||
```bash
|
||||
# Recommended for long-running LLM-bound handlers (subagent):
|
||||
gbrain jobs supervisor --concurrency 4 --job-isolation process
|
||||
|
||||
# Bare worker, or durably via env:
|
||||
GBRAIN_JOB_ISOLATION=process gbrain jobs work --concurrency 4
|
||||
```
|
||||
|
||||
How it works: the worker keeps claim, lock renewal, and all result
|
||||
recording; the child (an internal `run-child` entrypoint of the same gbrain
|
||||
binary) re-validates the claim, runs the handler with its own small engine
|
||||
pool, and reports one atomic outcome file. Handler-error semantics are
|
||||
preserved across the boundary (unrecoverable → dead, rate-lease → no attempt
|
||||
burned, everything else → normal backoff). On worker shutdown children get
|
||||
the drain window to finish and report; a child killed before reporting is
|
||||
released with no attempt burned. If the worker dies hard, the orphaned child
|
||||
self-terminates via a parent-liveness watchdog and the stall sweeper
|
||||
requeues the job after lock expiry — the lock token fences the orphan's
|
||||
queue writes (result recording, progress, state transitions) into no-ops.
|
||||
The handler's own side effects (page writes through its engine) can still
|
||||
land until the watchdog stops the child; that window is the watchdog's
|
||||
poll + grace, not unbounded.
|
||||
|
||||
Sizing notes:
|
||||
|
||||
- **Connections:** each child opens its own small pools (read 3 by default,
|
||||
override via `GBRAIN_JOB_CHILD_POOL_SIZE`; direct 1). Worked example at
|
||||
concurrency 15: 15×(3+1) + the worker's 10+3 ≈ **73 client connections**
|
||||
total — 55 ride the transaction-pooler lane (multiplexed, no extra server
|
||||
backends) and 18 are lazy direct session-lane connections, each holding a
|
||||
real server backend while open. Budget the pooler-lane count against your
|
||||
pooler's client limit and the session-lane count against
|
||||
`max_connections`.
|
||||
- **Memory:** `--max-rss` covers the WORKER process only in this mode
|
||||
(handler memory lives in the children; the worker prints a note when both
|
||||
are set). There is no per-child RSS cap yet — a runaway child is contained
|
||||
only by host/container limits. Size host memory for concurrency × handler
|
||||
footprint.
|
||||
- **Spawn cost:** ~0.3–1s per job (engine connect included) — noise for
|
||||
long-running handlers, meaningful for sub-second ones (`lint`,
|
||||
`backlinks`). Keep those inline or on a separate inline worker.
|
||||
- **Security note:** the child receives the job's lock token via env. It is
|
||||
a *fencing* token (split-brain protection), not a secret — same-user env
|
||||
already contains the database URL.
|
||||
- **Child CLI resolution:** the worker fail-fast validates the child CLI at
|
||||
startup (compiled `gbrain` binary, bun-dev fallback, or the
|
||||
`GBRAIN_JOB_CHILD_CLI` env override — the ops/test escape hatch). Three
|
||||
consecutive child spawn/bootstrap failures self-exit the worker as
|
||||
unhealthy (a deterministically broken child CLI) for process-manager
|
||||
restart instead of burning attempts across the queue.
|
||||
|
||||
### Which supervisor when?
|
||||
|
||||
The supervisor solves in-process crash recovery. Platform-level
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -71,7 +71,10 @@ gbrain jobs get <id>
|
||||
## Rescue actions (in order of escalation)
|
||||
|
||||
```bash
|
||||
# Force-kill a single stuck job:
|
||||
# Cancel a single stuck job (inline mode: cooperative — the handler must
|
||||
# observe its abort signal, and after 30s it is force-evicted from tracking
|
||||
# but the promise keeps running; with --job-isolation process the child is
|
||||
# actually SIGTERM→SIGKILLed once cancellation is detected):
|
||||
gbrain jobs cancel <id>
|
||||
|
||||
# Clear a specific job entirely (last resort):
|
||||
@@ -119,6 +122,29 @@ claiming. Start one:
|
||||
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs work --concurrency 4
|
||||
```
|
||||
|
||||
## Reading the DB-probe verdicts (pool starved vs server unreachable)
|
||||
|
||||
When the worker's health probe fails repeatedly, the terminal
|
||||
`[health] DB probe failed N consecutive times (verdict: ...)` line — and the
|
||||
`unhealthy` payload the supervisor sees — carries a verdict that names the
|
||||
failing LAYER (the intermediate `(N/3)` lines log only the failure detail).
|
||||
Read it before touching anything — the historical failure mode here was
|
||||
hours spent evaluating a database instance upgrade while the server sat at
|
||||
10% of max_connections.
|
||||
|
||||
| Verdict | What it means | What to do |
|
||||
|---|---|---|
|
||||
| `pool_starved` | The read-pool probe failed but the DIRECT-lane probe succeeded — the database server is reachable; the fault is in the transaction-pooler path (client pool exhaustion or a pooler-layer fault; the probe deliberately does not distinguish the two). | Look at client-side load: long-running handler queries holding slots, `GBRAIN_POOL_SIZE` too small for the workload, or a pooler-layer incident. Do NOT resize the database. The worker exit is correct recovery — it frees every client-held slot. |
|
||||
| `server_unreachable` | Both the pooler lane and the direct lane failed. | Check connectivity/capacity first: network, DNS, the database itself. Both-lanes-failed is the evidence — credential/config errors or a saturated direct lane can also land here, so glance at the probe detail text before concluding the server is down. |
|
||||
| `unknown` | The read probe failed and no direct lane exists to disambiguate (single-pool mode: non-Supabase, kill switch active, or no derivable direct URL). | Check the startup log for the single-pool warning; consider `GBRAIN_DIRECT_DATABASE_URL` so future incidents self-diagnose. |
|
||||
|
||||
The `gbrain-tracked in flight` counts in the message are a tracked SUBSET
|
||||
(raw/direct/reserved/transaction seams only) — most template-path queries are
|
||||
untracked, so `0 in flight` next to a `pool_starved` verdict means the
|
||||
saturation lives in that untracked traffic or at the pooler layer itself,
|
||||
not that the pool is idle. The verdict, not the counts, is the
|
||||
authoritative signal.
|
||||
|
||||
## Related
|
||||
|
||||
- [Minions worker deployment](minions-deployment.md) — supervisor lifecycle,
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ gbrain config set spend.posture gated # default — gates enforce
|
||||
| Value | Effect |
|
||||
|-------|--------|
|
||||
| `gated` (default) | Every cost gate enforces its limit as documented below. |
|
||||
| `tokenmax` | Every embedding-spend gate in the table below prints its estimate and **proceeds** — informational only. Spend is still recorded to the ledger; posture removes the *ceiling*, not the *accounting*. (Commands with their own LLM cost caps outside this doc's embedding scope — e.g. `extract-conversation-facts --max-cost-usd` — don't resolve posture; their per-call flags govern.) |
|
||||
| `tokenmax` | Every embedding-spend gate in the table below prints its estimate and **proceeds** — informational only. Spend is still recorded to the ledger; posture removes the *ceiling*, not the *accounting*. (Commands with their own LLM cost caps outside this doc's embedding scope — e.g. `extract-conversation-facts --max-cost-usd`, `dream retriage --max-usd` (an estimate-based soft stop) — don't resolve posture; their per-call flags govern.) |
|
||||
|
||||
`spend.posture` is deliberately separate from `search.mode=tokenmax` (which governs
|
||||
retrieval payload size, not embedding spend). When a gate fires and
|
||||
|
||||
@@ -28,6 +28,7 @@ Any of these commands stream events when `--progress-json` is set:
|
||||
- `gbrain eval`
|
||||
- `gbrain eval brainbench`
|
||||
- `gbrain apply-migrations` (the orchestrator + every child command)
|
||||
- `gbrain transcripts ingest` (per-file ticks + a per-session heartbeat over the import set)
|
||||
|
||||
Non-bulk commands (`stats`, `graph-query`, `get`, `put`, etc.) don't emit
|
||||
events — they return in under a second.
|
||||
@@ -158,6 +159,9 @@ Stable phase names shipped in v0.15.2:
|
||||
fixture count and a percentage would lie
|
||||
- `export.pages`
|
||||
- `files.sync`
|
||||
- `transcripts.ingest` (one tick per session-log file; sessions inside a
|
||||
multi-session file — the hermes store, consumer exports — don't get their
|
||||
own ticks, so total = file count; each session emits a heartbeat instead)
|
||||
|
||||
Sub-phases exposed via `child()`:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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** |
|
||||
|
||||
|
||||
+100
-18
@@ -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
|
||||
@@ -1543,7 +1561,7 @@ wins; fix the row.
|
||||
| "Migrate from Obsidian/Notion/Logseq" | `skills/migrate/SKILL.md` |
|
||||
| Brain health check, maintenance run | `skills/maintain/SKILL.md` |
|
||||
| "Extract links", "build link graph", "populate timeline" | `skills/maintain/SKILL.md` (extraction sections) |
|
||||
| "Run dream", "process today's session", "synthesize my conversations", "consolidate yesterday's conversations", "what patterns did you see", "did the dream cycle run" | `skills/maintain/SKILL.md` (dream cycle section) |
|
||||
| "Run dream", "process today's session", "synthesize my conversations", "consolidate yesterday's conversations", "what patterns did you see", "did the dream cycle run", "retriage the backlog", "re-score the triage" | `skills/maintain/SKILL.md` (dream cycle section) |
|
||||
| "Brain health", "what features am I missing", "brain score" | Run `gbrain features --json` |
|
||||
| "Set up autopilot", "run brain maintenance", "keep brain updated" | Run `gbrain autopilot --install --repo ~/brain` |
|
||||
| "Upgrade gbrain", "update gbrain", "gbrain update available", `UPGRADE_AVAILABLE`, "is gbrain up to date" | `skills/gbrain-upgrade/SKILL.md` |
|
||||
@@ -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.
|
||||
@@ -1845,6 +1864,21 @@ curl -X POST https://your-brain/ingest \
|
||||
For mobile capture, the inbox folder source picks up anything dropped into
|
||||
`~/.gbrain/inbox/` from iOS Shortcuts / AirDrop / Drafts / Finder.
|
||||
|
||||
Your other agents' histories import in one command. `gbrain transcripts ingest`
|
||||
parses agent session logs (Claude Code, Codex, OpenClaw, Hermes) and extracted
|
||||
consumer chat exports (ChatGPT / Claude.ai `conversations.json`) into readable
|
||||
conversation pages with provenance back to the exact session file. Secrets are
|
||||
scrubbed from message bodies, titles, speakers, and session metadata before
|
||||
anything is written, embedding is off by default for bulk backfills, and
|
||||
re-runs are free — unchanged sessions skip on content hash:
|
||||
|
||||
```bash
|
||||
gbrain transcripts ingest # discover importable session logs
|
||||
gbrain transcripts ingest --all # import everything discovered
|
||||
gbrain transcripts ingest ~/Downloads/conversations.json # consumer export (unzip first)
|
||||
gbrain transcripts status # found vs imported, per harness
|
||||
```
|
||||
|
||||
Third-party skillpacks can ship custom ingestion sources (Granola, Linear,
|
||||
voice, OCR) against the versioned `IngestionSource` contract at
|
||||
`gbrain/ingestion`. See [`docs/skillpack-anatomy.md`](docs/skillpack-anatomy.md).
|
||||
@@ -1902,11 +1936,11 @@ 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).
|
||||
|
||||
**Job queue (Minions).** BullMQ-shaped, Postgres-native job queue. Durable subagents (LLM tool loops that survive crashes via two-phase pending→done persistence), shell jobs with audit, child jobs with cascading timeouts, rate leases for outbound providers, attachments via S3/Supabase storage. Replaces "spawn subagent as fire-and-forget Promise" with something that recovers from anything.
|
||||
**Job queue (Minions).** BullMQ-shaped, Postgres-native job queue. Durable subagents (LLM tool loops that survive crashes via two-phase pending→done persistence), shell jobs with audit, child jobs with cascading timeouts, rate leases for outbound providers, attachments via S3/Supabase storage. Opt-in per-job process isolation (`gbrain jobs work --job-isolation process`) runs each claimed job in its own SIGKILL-able child process, so a stuck handler dies for real and a crash takes one job instead of the whole worker; when the worker's DB health probe fails, it names the failing layer (`pool_starved` vs `server_unreachable`) instead of a blanket "DB unreachable". Sizing and rollout guidance in [`docs/guides/minions-deployment.md`](docs/guides/minions-deployment.md); probe-verdict triage in [`docs/guides/queue-operations-runbook.md`](docs/guides/queue-operations-runbook.md). Replaces "spawn subagent as fire-and-forget Promise" with something that recovers from anything.
|
||||
|
||||
**Non-English brains (FTS language config).** The Postgres full-text search tokenizer is configurable via `GBRAIN_FTS_LANGUAGE`. Defaults to `english`. Set it to any text-search configuration that exists in your Postgres instance:
|
||||
|
||||
@@ -1940,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.
|
||||
|
||||
@@ -1959,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
|
||||
@@ -2105,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -3219,6 +3253,49 @@ it nightly and Phase 4 below (plus most of Phase 2's hygiene checks) is
|
||||
covered. The pseudocode that follows is the harness-side variant for agents
|
||||
that also do LLM-driven entity sweeps and memory consolidation on top.
|
||||
|
||||
### Synthesis cost control: the triage cascade
|
||||
|
||||
The synthesize phase is a two-stage cascade: a cheap scored triage
|
||||
(utility-tier model, one call per new transcript) gates the expensive
|
||||
per-transcript synthesis subagents. The dials:
|
||||
|
||||
- `dream.triage.threshold` (default 0.5) — the gate. Scores are cached, so
|
||||
retuning it re-gates instantly with **zero** new LLM calls. Raise it if too
|
||||
much routine content synthesizes; lower it if real signal is being skipped.
|
||||
- `models.dream.triage` — the triage model (default: utility tier / Haiku).
|
||||
- `dream.triage.max_chars` (default 24000, floor 1000) — per-transcript
|
||||
sample window (head/middle/tail) sent to the judge. Not part of cache
|
||||
validity — after changing it, `gbrain dream retriage --force` re-judges
|
||||
under the new sampling.
|
||||
- `dream.triage.max_tokens` (default 2048, floor 256) — judge output budget.
|
||||
- `dream.triage.concurrency` (default 4, clamped 1–16) — concurrent judge
|
||||
calls.
|
||||
- `dream.synthesize.max_turns` (default 16) — synthesis turn budget. The
|
||||
triage map hands the subagent pre-extracted segments, so the mid-tier
|
||||
default model (`models.dream.synthesize`, tier `reasoning`) with a 16-turn
|
||||
budget is the intended pairing — frontier-model overrides are unnecessary
|
||||
and slow the queue. Completeness comes from triage coverage (every file
|
||||
scored, minus files deferred under the `max_ms` budget below) plus
|
||||
segment-guided prompts, not model size. If written-page counts
|
||||
drop after upgrading, set it back to 30 and check
|
||||
`details.synthesis.avg_turns` for cap pressure.
|
||||
- `dream.triage.max_ms` (default 5 min) — per-cycle wall-clock budget for
|
||||
judging NEW files; a big cold corpus triages across a few cycles (cached
|
||||
files are free). Deferred files are labeled "not yet triaged", never
|
||||
silently rejected.
|
||||
- `dream.synthesize.max_submissions_per_source_per_day` (default 0 = off) —
|
||||
opt-in backstop cap on synthesis jobs per source; 200/day is a sane value
|
||||
for busy deployments.
|
||||
|
||||
Maintenance recipe — after changing the threshold, upgrading through a
|
||||
`TRIAGE_VERSION` bump, or to drain a queued synthesis backlog:
|
||||
|
||||
```bash
|
||||
gbrain dream retriage --dry-run # what would change (zero LLM calls)
|
||||
gbrain dream retriage --reconcile-queue # re-score + cancel below-threshold queued jobs
|
||||
gbrain dream retriage --audit-rejects 20 # synthesis-model second opinion on 20 rejects
|
||||
```
|
||||
|
||||
### What It Does
|
||||
|
||||
```
|
||||
@@ -3885,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
|
||||
|
||||
@@ -3947,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
|
||||
@@ -4439,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
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"id": "gbrain-context-engine",
|
||||
"name": "gbrain",
|
||||
"version": "0.45.20.0",
|
||||
"version": "0.46.4.0",
|
||||
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
|
||||
"family": "bundle-plugin",
|
||||
"configSchema": {
|
||||
|
||||
+4
-1
@@ -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.45.20.0",
|
||||
"version": "0.46.4.0",
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.5",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,10 +21,16 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
FIXTURE_DIR="test/fixtures/conversation-formats"
|
||||
# cathedral-4: the transcripts-import fixtures (raw harness/export shapes)
|
||||
# carry the same placeholder-names-only contract as conversation-formats.
|
||||
FIXTURE_DIRS=("test/fixtures/conversation-formats" "test/fixtures/transcripts")
|
||||
|
||||
if [ ! -d "$FIXTURE_DIR" ]; then
|
||||
echo "[check-fixture-privacy] $FIXTURE_DIR does not exist; nothing to check"
|
||||
EXISTING_DIRS=()
|
||||
for d in "${FIXTURE_DIRS[@]}"; do
|
||||
[ -d "$d" ] && EXISTING_DIRS+=("$d")
|
||||
done
|
||||
if [ ${#EXISTING_DIRS[@]} -eq 0 ]; then
|
||||
echo "[check-fixture-privacy] no fixture dirs exist; nothing to check"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -45,7 +51,7 @@ BANNED_TOKENS=(
|
||||
|
||||
errors=0
|
||||
for token in "${BANNED_TOKENS[@]}"; do
|
||||
matches=$(grep -ril "$token" "$FIXTURE_DIR" 2>/dev/null || true)
|
||||
matches=$(grep -ril "$token" "${EXISTING_DIRS[@]}" 2>/dev/null || true)
|
||||
if [ -n "$matches" ]; then
|
||||
echo "[check-fixture-privacy] BANNED token '$token' found in:"
|
||||
echo "$matches" | sed 's/^/ - /'
|
||||
@@ -61,4 +67,4 @@ if [ "$errors" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[check-fixture-privacy] OK: no banned tokens found in $FIXTURE_DIR"
|
||||
echo "[check-fixture-privacy] OK: no banned tokens found in ${EXISTING_DIRS[*]}"
|
||||
|
||||
Executable
+149
@@ -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)"
|
||||
Executable
+70
@@ -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
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ wins; fix the row.
|
||||
| "Migrate from Obsidian/Notion/Logseq" | `skills/migrate/SKILL.md` |
|
||||
| Brain health check, maintenance run | `skills/maintain/SKILL.md` |
|
||||
| "Extract links", "build link graph", "populate timeline" | `skills/maintain/SKILL.md` (extraction sections) |
|
||||
| "Run dream", "process today's session", "synthesize my conversations", "consolidate yesterday's conversations", "what patterns did you see", "did the dream cycle run" | `skills/maintain/SKILL.md` (dream cycle section) |
|
||||
| "Run dream", "process today's session", "synthesize my conversations", "consolidate yesterday's conversations", "what patterns did you see", "did the dream cycle run", "retriage the backlog", "re-score the triage" | `skills/maintain/SKILL.md` (dream cycle section) |
|
||||
| "Brain health", "what features am I missing", "brain score" | Run `gbrain features --json` |
|
||||
| "Set up autopilot", "run brain maintenance", "keep brain updated" | Run `gbrain autopilot --install --repo ~/brain` |
|
||||
| "Upgrade gbrain", "update gbrain", "gbrain update available", `UPGRADE_AVAILABLE`, "is gbrain up to date" | `skills/gbrain-upgrade/SKILL.md` |
|
||||
|
||||
@@ -12,7 +12,7 @@ Four tiers:
|
||||
|
||||
| Tier | Purpose | Default | Examples |
|
||||
|---|---|---|---|
|
||||
| `utility` | fast classification, expansion, verdict, dedup | `claude-haiku-4-5-20251001` | query expansion, facts contradiction classifier, dream synthesize verdict |
|
||||
| `utility` | fast classification, expansion, verdict, dedup | `claude-haiku-4-5-20251001` | query expansion, facts contradiction classifier, dream triage judge (prefers `models.dream.triage`) |
|
||||
| `reasoning` | default chat, synthesis, generation | `claude-sonnet-4-6` | gateway chat, dream synthesize, patterns, facts extraction |
|
||||
| `deep` | slow, expensive reasoning | `claude-opus-4-7` | `gbrain think`, auto-think, cross-modal eval slot B |
|
||||
| `subagent` | Anthropic-only multi-turn tool loop | `claude-sonnet-4-6` | `gbrain agent run` |
|
||||
@@ -28,6 +28,10 @@ Override priority (highest first):
|
||||
7. Tier default (the table above)
|
||||
8. Hardcoded caller fallback
|
||||
|
||||
One exception: the dream triage judge pre-reads `models.dream.triage` first —
|
||||
when that key is set, it wins over this entire chain (`gbrain models` reports
|
||||
it as the effective route).
|
||||
|
||||
Power-user recipes:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -49,9 +49,11 @@ upstream: conversation-history+transcript-save@fc834ee
|
||||
|
||||
Two halves of one loop:
|
||||
|
||||
1. **IMPORT** — raw export or session log → one dated markdown page per
|
||||
conversation under `conversations/` → `gbrain import`/`gbrain sync` →
|
||||
parser validation → fact extraction → gap check.
|
||||
1. **IMPORT** — raw export or session log → dated markdown pages under
|
||||
`conversations/` (the native importer writes them directly and splits
|
||||
long sessions into parts; the manual path converts one page per
|
||||
conversation, then `gbrain import`/`gbrain sync`) → parser validation →
|
||||
fact extraction → gap check.
|
||||
2. **RETRIEVE** — search the archive, pull threads, build timelines, and
|
||||
answer "when did I first discuss X".
|
||||
|
||||
@@ -59,11 +61,29 @@ Years of AI-assistant history is one of the largest personal corpora most
|
||||
users own. This skill makes it first-class brain content instead of a JSON
|
||||
blob in a downloads folder.
|
||||
|
||||
**No native raw-export importer exists.** `gbrain import <dir>` ingests
|
||||
markdown directories; nothing in the CLI parses a provider's raw
|
||||
`conversations.json` directly. The conversion step below is agent work.
|
||||
(A native `gbrain import --format chatgpt|claude` is a filed follow-up; until
|
||||
it lands, this procedure is the supported path.)
|
||||
**A native importer now exists: `gbrain transcripts ingest`.** It parses
|
||||
agent session logs (Claude Code, Codex, OpenClaw, Hermes) AND extracted
|
||||
consumer exports (ChatGPT `conversations.json`, Claude.ai export) directly:
|
||||
detection, secret redaction, imessage-slack rendering, long-session
|
||||
splitting, and idempotent re-runs are all native. Prefer it over the manual
|
||||
procedure whenever the source is one of those six formats:
|
||||
|
||||
```
|
||||
gbrain transcripts ingest ~/Downloads/conversations.json # unzip first
|
||||
gbrain transcripts ingest # discover harness logs
|
||||
gbrain transcripts status # found vs imported gaps
|
||||
```
|
||||
|
||||
Native-vs-manual delta to know: the native lane redacts SECRETS (key
|
||||
patterns) plus your `~/.gbrain/harvest-private-patterns.txt` regexes and
|
||||
counts agent-directed imperatives into frontmatter, but broad PII detection
|
||||
(names, phones, addresses) remains YOUR review pass — the manual procedure's
|
||||
human scrub step still applies to sensitive corpora. Two more deltas: the
|
||||
native lane caps each message at ~4K characters in the page body (readable
|
||||
archive, not verbatim — the session file named in `source_uri` stays the
|
||||
verbatim record), and tool/thinking traffic appears only as one-line
|
||||
placeholders. Providers without a native adapter (e.g. Perplexity) keep
|
||||
using the manual conversion below.
|
||||
|
||||
## Where Conversations Live
|
||||
|
||||
|
||||
+28
-12
@@ -18,6 +18,8 @@ triggers:
|
||||
- "populate links"
|
||||
- "backfill graph"
|
||||
- "extract timeline entries"
|
||||
- "retriage the backlog"
|
||||
- "re-score the triage"
|
||||
- "run dream"
|
||||
- "process today's session"
|
||||
- "process yesterday's transcripts"
|
||||
@@ -116,7 +118,8 @@ gbrain extract timeline --dir ~/brain
|
||||
|
||||
### Dream cycle (v0.23): synthesize + patterns
|
||||
|
||||
`gbrain dream` runs the full 8-phase maintenance cycle:
|
||||
`gbrain dream` runs the full maintenance cycle (core phases shown; opt-in
|
||||
phases like atoms/concepts/drift slot in between):
|
||||
|
||||
```
|
||||
lint -> backlinks -> sync -> synthesize -> extract -> patterns -> embed -> orphans
|
||||
@@ -124,14 +127,25 @@ lint -> backlinks -> sync -> synthesize -> extract -> patterns -> embed -> orpha
|
||||
|
||||
The two new phases consolidate yesterday's conversations into long-term memory:
|
||||
|
||||
**Synthesize phase:** reads transcripts from `dream.synthesize.session_corpus_dir`,
|
||||
runs a cheap Haiku verdict (cached in `dream_verdicts`) to filter routine
|
||||
ops sessions, then fans out one Sonnet subagent per worth-processing
|
||||
transcript. Each subagent writes reflections (`wiki/personal/reflections/...`),
|
||||
originals (`wiki/originals/ideas/...`), and people timeline entries. The
|
||||
orchestrator collects the slugs from `subagent_tool_executions` (NOT
|
||||
`pages.updated_at` — that would pick up unrelated writes) and reverse-renders
|
||||
each new page from DB → markdown on disk.
|
||||
**Synthesize phase (two-stage cascade):** reads transcripts from
|
||||
`dream.synthesize.session_corpus_dir`, then triages before it spends: a cheap
|
||||
utility-tier judge (`models.dream.triage`) scores every new file 0–1 for
|
||||
salience and pre-extracts candidate quotes + entities, cached in
|
||||
`dream_verdicts` with the judging model + prompt version (bounded per cycle
|
||||
by `dream.triage.max_ms`, default 5 min — deferred files retry next cycle,
|
||||
never silently rejected). Only files scoring
|
||||
at or above `dream.triage.threshold` (default 0.5 — applied at read time, so
|
||||
retuning the threshold re-gates with zero new LLM calls) fan out one synthesis
|
||||
subagent per transcript chunk, each primed with the triage map and capped at
|
||||
`dream.synthesize.max_turns` (default 16). Each subagent writes reflections
|
||||
(`wiki/personal/reflections/...`), originals (`wiki/originals/ideas/...`), and
|
||||
people timeline entries. The orchestrator collects the slugs from
|
||||
`subagent_tool_executions` (NOT `pages.updated_at` — that would pick up
|
||||
unrelated writes) and reverse-renders each new page from DB → markdown on
|
||||
disk. To re-apply the gate after retuning the threshold or drain a queued
|
||||
backlog, run `gbrain dream retriage --dry-run` (zero LLM calls, cached
|
||||
scores only) then `gbrain dream retriage --reconcile-queue`; `--force`
|
||||
re-judges everything from scratch.
|
||||
|
||||
**Patterns phase:** runs after `extract` (so the graph state is fresh).
|
||||
Reads recent reflections within `dream.patterns.lookback_days` (default 30),
|
||||
@@ -164,15 +178,17 @@ timestamp is stored in `dream.synthesize.last_completion_ts` and is written
|
||||
ONLY on successful runs (not on skipped/failed). Explicit `--input` /
|
||||
`--date` / `--from` / `--to` invocations bypass cooldown.
|
||||
|
||||
**`--dry-run` semantics:** runs the cheap Haiku significance filter (caches
|
||||
verdicts) but skips the Sonnet synthesis pass. NOT zero LLM calls.
|
||||
**`--dry-run` semantics:** runs the scored triage pass (judges + caches
|
||||
verdicts for new files) but skips the synthesis subagents. NOT zero LLM
|
||||
calls — for a zero-call preview from cached scores use
|
||||
`gbrain dream retriage --dry-run` instead.
|
||||
|
||||
**Configure synthesize on a fresh brain:**
|
||||
```bash
|
||||
gbrain config set dream.synthesize.session_corpus_dir /path/to/transcripts
|
||||
gbrain config set dream.synthesize.enabled true
|
||||
gbrain dream --phase synthesize --dry-run --json # preview
|
||||
gbrain dream # full 8-phase cycle
|
||||
gbrain dream # full cycle
|
||||
```
|
||||
|
||||
**Invocation patterns:**
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
{
|
||||
"RESOLVER.md": "8e54195c109c764d2954186ee92a30a62cd91e961233be53f828db5a25ebe710",
|
||||
"RESOLVER.md": "36b43c65a41e6fce894b9559db2bce0a53f06a99450410e498323c12e12e92bb",
|
||||
"_AGENT_README.md": "62613f7f1e061576b6c1b18844f59bd35f2df96ca5c45c8c41fae0772b9ce4d3",
|
||||
"_brain-filing-rules.json": "cf850df6a7425464c6d63b3ace71991cc93497fa0cc8cd21acd31883e17939c6",
|
||||
"_brain-filing-rules.md": "2d2d75b7c76081c56f41b2c0a5a978c355ce957300f9b0a5575dc4079ef1f877",
|
||||
@@ -48,7 +48,7 @@
|
||||
"conventions/cron-via-minions.md": "badb1cd6cd825d6f1ac0b6b28cc47e5d80facc783a3e59a14146ae901ee0f933",
|
||||
"conventions/cross-modal.yaml": "c012c3d72614a87b1ee698173dce2a0fb0d057a54df7aab87993c4b07fff6280",
|
||||
"conventions/exec-output.md": "2bf371ac3ec4987eff7cc13cd3ea8cc97c46bd43f588eec024ff27f3171bc58f",
|
||||
"conventions/model-routing.md": "fb7ae8746a578500d6789b68ff40049037aa4d337b65b42f7c1745ae7080c2db",
|
||||
"conventions/model-routing.md": "8b28aa706436e7b68493ec481e12be6309b0af1a553930e8b1029d675fa4b3ad",
|
||||
"conventions/path-discipline.md": "8af5415721bd115e6979c96688bcf542a32675809ada9aaedbb6706a40926954",
|
||||
"conventions/quality.md": "8aa681001114689d34268ccadaf0e2ff07b8f68aa5987c093a8c4a7a744f12a6",
|
||||
"conventions/regex-discipline.md": "d96a9baa6f27184e165889a9c655607366a739d851684d9f41cdec294f99edac",
|
||||
@@ -58,7 +58,7 @@
|
||||
"conventions/subagent-routing.md": "8b8830b815a9a8581a12b489f966c0b0a39eb9b5f66e905a691a03653eef348d",
|
||||
"conventions/test-before-bulk.md": "6b2c52cda9e2cd5f04c15152b3d92aeb7187ab193a15082be0f8a3991a6a5725",
|
||||
"conventions/untrusted-content.md": "259384d490892cd0e1e8e054decf752d7354f516c83aee57b332c1a96aac6a6e",
|
||||
"conversation-archive/SKILL.md": "867d3a202ce500027ed2ab85edd9d3359d677aa7a180105f2b7db12ad3492701",
|
||||
"conversation-archive/SKILL.md": "4e1dea00f5e1e16e749a42f295fdccf556199d4400a2ba1b891aa91839e37214",
|
||||
"conversation-archive/routing-eval.jsonl": "ae087a84b1fd5b108b7cdab8d035a09b3ccecd8aad53ba5f71e463059108cfca",
|
||||
"correction-pipeline/SKILL.md": "caf1264b7afec46569d30f6d92b07f37ae375e3f4e6aeddd58866aec327053de",
|
||||
"correction-pipeline/routing-eval.jsonl": "7f8d96606a8d7bed3d79fdcee6904764c8abb9fa0b506adb414b5c4805b69d0b",
|
||||
@@ -88,7 +88,7 @@
|
||||
"idea-lineage/routing-eval.jsonl": "ee2e00704b9accb7dd58bb8f126a3bc04a2c40be499180fa505dbf6d5061cd41",
|
||||
"ingest/SKILL.md": "dc40ecc0072806fb8c7bb6ab9cf1f103842e05653eb55d67632d7e3ffc4dd7d2",
|
||||
"install/SKILL.md": "881bd0a422f34c6df4642aae66c51e2a4cc18ad5ca6d0b52d44b4de93512a3c4",
|
||||
"maintain/SKILL.md": "59da3f0227a2b41ed9c3beb334322f1ef1dbd80af733a587c0a4687a707b9815",
|
||||
"maintain/SKILL.md": "33e48e31baf89b6b257ad863cdb9de444777bc1272f5ed8c2b28be3a54cbaa14",
|
||||
"manifest.json": "03471868cce05fa38af6f793da54e2fc11f77ef778271a596d75bc29f9ec4c73",
|
||||
"measure-before-you-fix/SKILL.md": "1fd3b40ab65cbd08f50dea16107701859165469be3c85c57d779c7b4bbf92db8",
|
||||
"measure-before-you-fix/routing-eval.jsonl": "0661df9974a9cfe31216d574b1db0ef341945c2eb844ebf4ab6920fcbbc90d6c",
|
||||
@@ -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",
|
||||
|
||||
+13
-1
@@ -154,11 +154,19 @@ const CLI_ONLY_SELF_HELP = new Set([
|
||||
// would leave that help dead code behind the generic stub (the init.ts:117
|
||||
// trap ENG-2 names).
|
||||
'bootstrap', 'hook', 'sweep',
|
||||
// cathedral-4: transcripts ships its own HELP (the ingest import lane +
|
||||
// the v0.29 recent reader). Without this the generic stub hides both.
|
||||
'transcripts',
|
||||
// jobs ships JOBS_HELP + a per-subcommand record (JOBS_SUBCOMMAND_HELP) in
|
||||
// jobs.ts, guarded BEFORE the thin-client refusal and the subcommand switch
|
||||
// so `jobs work --help` prints help instead of starting a worker daemon.
|
||||
// Without this entry the generic stub hid the worker entry point entirely.
|
||||
'jobs',
|
||||
// #4152: dream ships its own printHelp AND the `dream retriage --help`
|
||||
// subverb help (dispatched engine-free before parseArgs). The generic stub
|
||||
// would hide both — `gbrain dream retriage --help` printed the one-line
|
||||
// dream stub instead of the retriage contract (outside-voice CX9).
|
||||
'dream',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -177,9 +185,13 @@ const SELF_HELP_WITHOUT_ENGINE: Record<string, () => Promise<(engine: never, arg
|
||||
maintain: async () => (await import('./commands/maintain.ts')).runMaintain as never,
|
||||
'extract-conversation-facts': async () =>
|
||||
(await import('./commands/extract-conversation-facts.ts')).runExtractConversationFacts as never,
|
||||
transcripts: async () => (await import('./commands/transcripts.ts')).runTranscripts as never,
|
||||
// runJobs accepts BrainEngine | null and its help guard returns before any
|
||||
// engine (or subcommand body) is touched.
|
||||
jobs: async () => (await import('./commands/jobs.ts')).runJobs as never,
|
||||
// runDream accepts BrainEngine | null; --help (and `retriage --help`) is
|
||||
// answered before any engine-bearing work per the dream.ts IRON RULE.
|
||||
dream: async () => (await import('./commands/dream.ts')).runDream as never,
|
||||
};
|
||||
|
||||
/** Returns true when the command's own help was printed. */
|
||||
@@ -3195,7 +3207,7 @@ TOOLS
|
||||
orphans [--json] [--count] Find pages with no inbound wikilinks
|
||||
salience [--days N] [--kind P] v0.29: pages ranked by emotional + activity salience
|
||||
anomalies [--since D] [--sigma N] v0.29: cohort-based statistical anomalies (tag, type)
|
||||
transcripts recent [--days N] v0.29: recent raw .txt transcripts (local-only)
|
||||
transcripts <ingest|status|recent> v0.46: import agent session logs + chat exports (local-only)
|
||||
dream [--dry-run] [--json] Run the overnight maintenance cycle once (cron-friendly).
|
||||
See also: autopilot --install (continuous daemon).
|
||||
check-resolvable [--json] [--fix] Validate skill tree (reachability/MECE/DRY)
|
||||
|
||||
+467
-40
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Spend-gate constants for `gbrain dream retriage` (#4152, outside-voice C12).
|
||||
* Split from dream-retriage.ts so tests can pin them without importing the
|
||||
* command's engine-bearing module graph. The chars→tokens ratio lives in
|
||||
* synthesize.ts (`CHARS_PER_TOKEN`, exported) — the command imports it from
|
||||
* there so the two estimates can't drift.
|
||||
*/
|
||||
|
||||
/** Estimated sweeps above this ask for confirmation unless --yes. */
|
||||
export const SPEND_CONFIRM_USD = 5;
|
||||
|
||||
/** When the model has no CANONICAL_PRICING entry, gate on file count instead. */
|
||||
export const UNPRICED_CONFIRM_FILES = 500;
|
||||
@@ -0,0 +1,697 @@
|
||||
/**
|
||||
* `gbrain dream retriage` (#4152) — re-score the corpus and reconcile the
|
||||
* synth-v2 job backlog against the triage gate.
|
||||
*
|
||||
* Two halves, both optional:
|
||||
* 1. Re-judge: sweep the discovered corpus through runTriagePass
|
||||
* (`--force` ignores the cache; `--since` treats older verdicts as
|
||||
* stale; `--dry-run` performs ZERO judge calls and reads cached scores
|
||||
* only). Spend-gated: prints an upfront estimate and asks for
|
||||
* confirmation above ~$5 (`--yes` skips; `--max-usd` soft-stops).
|
||||
* 2. `--reconcile-queue` (opt-in — cancels queued work): parse every
|
||||
* waiting/delayed/paused `dream:synth-v2:*` job across ALL queues (the
|
||||
* live backlog largely sits in dead per-run `dream-inline-*` queues no
|
||||
* worker will ever drain), match (basename, hash16) to discovered
|
||||
* transcripts, then:
|
||||
* - matched below threshold → cancel (frontier job not worth it)
|
||||
* - matched above, stale queue → cancel as `converted_for_resubmit`
|
||||
* (cancelled rows release their idempotency slot, so the next cycle
|
||||
* re-adds them into ITS live private drain — this is what actually
|
||||
* migrates the backlog, outside-voice C1)
|
||||
* - matched above, live queue → keep
|
||||
* - matched but unscored → keep (never cancel on no data)
|
||||
* - unmatched → keep unless `--cancel-unmatched`
|
||||
* - key-source ≠ data.source_id → skip + count (C9 hardening)
|
||||
* Cancellation is best-effort: status is re-checked immediately before
|
||||
* each cancel and rows that turned `active` are skipped; the residual
|
||||
* claim-vs-cancel race matches cancelJob's own BullMQ-style contract.
|
||||
*
|
||||
* `--audit-rejects <n>` (C6): re-judges N stride-sampled (deterministic) below-threshold files with
|
||||
* the SYNTHESIS model and reports the disagreement rate — the operator-run
|
||||
* calibration loop for `dream.triage.threshold`.
|
||||
*
|
||||
* Exit codes: 0 success (even when nothing cancelled), 1 missing corpus
|
||||
* config / engine, 2 usage error.
|
||||
*/
|
||||
|
||||
import { basename } from 'node:path';
|
||||
import { createInterface } from 'node:readline';
|
||||
import type { BrainEngine, DreamVerdict } from '../core/engine.ts';
|
||||
import {
|
||||
loadSynthConfig,
|
||||
runTriagePass,
|
||||
parseSynthV2Key,
|
||||
makeJudgeClient,
|
||||
judgeSignificance,
|
||||
isTriageCacheValid,
|
||||
dreamInlineQueueAgeMs,
|
||||
DREAM_INLINE_LIVE_GRACE_MS,
|
||||
CHARS_PER_TOKEN,
|
||||
type TriageFileReport,
|
||||
} from '../core/cycle/synthesize.ts';
|
||||
import { discoverTranscripts } from '../core/cycle/transcript-discovery.ts';
|
||||
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
|
||||
import { MinionQueue } from '../core/minions/queue.ts';
|
||||
import { canonicalLookup } from '../core/model-pricing.ts';
|
||||
import { SPEND_CONFIRM_USD, UNPRICED_CONFIRM_FILES } from './dream-retriage-constants.ts';
|
||||
|
||||
interface RetriageArgs {
|
||||
help: boolean;
|
||||
threshold: number | null;
|
||||
since: Date | null;
|
||||
force: boolean;
|
||||
reconcileQueue: boolean;
|
||||
cancelUnmatched: boolean;
|
||||
dryRun: boolean;
|
||||
limit: number | null;
|
||||
source: string | null;
|
||||
json: boolean;
|
||||
yes: boolean;
|
||||
maxUsd: number | null;
|
||||
auditRejects: number | null;
|
||||
}
|
||||
|
||||
class UsageError extends Error {}
|
||||
|
||||
function parseRetriageArgs(args: string[]): RetriageArgs {
|
||||
const out: RetriageArgs = {
|
||||
help: false,
|
||||
threshold: null,
|
||||
since: null,
|
||||
force: false,
|
||||
reconcileQueue: false,
|
||||
cancelUnmatched: false,
|
||||
dryRun: false,
|
||||
limit: null,
|
||||
source: null,
|
||||
json: false,
|
||||
yes: false,
|
||||
maxUsd: null,
|
||||
auditRejects: null,
|
||||
};
|
||||
const takeValue = (flag: string, i: number): string => {
|
||||
const v = args[i + 1];
|
||||
if (v === undefined || v.startsWith('--')) throw new UsageError(`${flag} requires a value`);
|
||||
return v;
|
||||
};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
switch (a) {
|
||||
case '--help': case '-h': out.help = true; break;
|
||||
case '--force': out.force = true; break;
|
||||
case '--reconcile-queue': out.reconcileQueue = true; break;
|
||||
case '--cancel-unmatched': out.cancelUnmatched = true; break;
|
||||
case '--dry-run': out.dryRun = true; break;
|
||||
case '--json': out.json = true; break;
|
||||
case '--yes': out.yes = true; break;
|
||||
case '--threshold': {
|
||||
const v = Number(takeValue(a, i)); i++;
|
||||
if (!Number.isFinite(v) || v < 0 || v > 1) throw new UsageError('--threshold must be a number in [0,1]');
|
||||
out.threshold = v;
|
||||
break;
|
||||
}
|
||||
case '--since': {
|
||||
const raw = takeValue(a, i); i++;
|
||||
const ms = Date.parse(raw);
|
||||
if (Number.isNaN(ms)) throw new UsageError(`--since could not parse date: ${raw}`);
|
||||
out.since = new Date(ms);
|
||||
break;
|
||||
}
|
||||
case '--limit': {
|
||||
const v = parseInt(takeValue(a, i), 10); i++;
|
||||
if (!Number.isFinite(v) || v < 1) throw new UsageError('--limit must be a positive integer');
|
||||
out.limit = v;
|
||||
break;
|
||||
}
|
||||
case '--source': case '--source-id': {
|
||||
out.source = takeValue(a, i); i++;
|
||||
break;
|
||||
}
|
||||
case '--max-usd': {
|
||||
const v = Number(takeValue(a, i)); i++;
|
||||
if (!Number.isFinite(v) || v <= 0) throw new UsageError('--max-usd must be a positive number');
|
||||
out.maxUsd = v;
|
||||
break;
|
||||
}
|
||||
case '--audit-rejects': {
|
||||
const v = parseInt(takeValue(a, i), 10); i++;
|
||||
if (!Number.isFinite(v) || v < 1) throw new UsageError('--audit-rejects must be a positive integer');
|
||||
out.auditRejects = v;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new UsageError(`unknown flag for dream retriage: ${a}`);
|
||||
}
|
||||
}
|
||||
if (out.cancelUnmatched && !out.reconcileQueue) {
|
||||
throw new UsageError('--cancel-unmatched requires --reconcile-queue');
|
||||
}
|
||||
// CX2: a corpus scan truncated by --limit would misclassify every file outside
|
||||
// the slice as "unmatched" — combining it with --cancel-unmatched would
|
||||
// mass-cancel valid backlog.
|
||||
if (out.cancelUnmatched && out.limit !== null) {
|
||||
throw new UsageError('--cancel-unmatched cannot combine with --limit: a truncated scan misclassifies files as unmatched');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function printRetriageHelp(): void {
|
||||
console.log(`gbrain dream retriage — re-score the corpus, reconcile the synth backlog
|
||||
|
||||
USAGE
|
||||
gbrain dream retriage [flags]
|
||||
|
||||
FLAGS
|
||||
--threshold <0..1> Gate override for this sweep (default: dream.triage.threshold)
|
||||
--since <date> Treat verdicts judged before <date> as stale (re-judge)
|
||||
--force Re-judge every discovered file regardless of cache
|
||||
--limit <n> Only consider the first n discovered transcripts
|
||||
--dry-run Zero judge calls, zero cancels; report from cached scores
|
||||
--reconcile-queue Cancel waiting synth jobs per the gate (opt-in; see below)
|
||||
--cancel-unmatched With --reconcile-queue: also cancel jobs whose file no
|
||||
longer matches any discovered transcript
|
||||
--source <id> Scope queue reconciliation to one source's jobs
|
||||
--yes Skip the spend confirmation
|
||||
--max-usd <n> Soft-stop judging when the ESTIMATED spend crosses n
|
||||
(estimate-based; every judge attempt counts, including
|
||||
unreliable responses; may overshoot by up to the
|
||||
configured concurrency; requires a priced model; also
|
||||
bounds --audit-rejects)
|
||||
--audit-rejects <n> Re-judge n stride-sampled (deterministic) below-threshold
|
||||
files with the SYNTHESIS model; report the disagreement
|
||||
rate. Skipped under --dry-run. Counted in the spend gate.
|
||||
--json Machine-readable output
|
||||
--help This text
|
||||
|
||||
NOTES
|
||||
--cancel-unmatched cannot combine with --limit (a truncated corpus scan
|
||||
would misclassify everything outside the slice as unmatched), and refuses
|
||||
to run when discovery finds zero transcripts (a corpus-mount outage must
|
||||
not erase the queued retry frontier). dream-inline-* queues younger than
|
||||
1h are treated as possibly LIVE (a running cycle's drain) and are never
|
||||
cancelled — they count as kept_live_queue. Running retriage while a cycle
|
||||
is active may double-judge some cache misses (benign: last write wins).
|
||||
|
||||
RECONCILE SEMANTICS
|
||||
matched + score < threshold cancel
|
||||
matched + score >= threshold, dead dream-inline-* queue (older than 1h)
|
||||
cancel (converted_for_resubmit —
|
||||
next cycle re-adds it into a live drain)
|
||||
matched + score >= threshold, live queue keep
|
||||
matched, no reliable score keep
|
||||
unmatched / unparseable key keep (cancel with --cancel-unmatched)
|
||||
legacy dream:synth: (v1) keys never touched
|
||||
|
||||
Cancelled rows release their idempotency slot, so lowering the threshold
|
||||
later cleanly re-submits the work.`);
|
||||
}
|
||||
|
||||
interface QueueCandidate {
|
||||
id: number;
|
||||
queue: string;
|
||||
status: string;
|
||||
idempotency_key: string;
|
||||
source_id: string;
|
||||
}
|
||||
|
||||
interface ReconcileStats {
|
||||
candidates: number;
|
||||
cancelled: number;
|
||||
converted_for_resubmit: number;
|
||||
kept_above_threshold: number;
|
||||
kept_unscored: number;
|
||||
/** Rows in a dream-inline-* queue younger than the liveness grace — possibly a running cycle's; never cancelled (CX1). */
|
||||
kept_live_queue: number;
|
||||
unmatched: number;
|
||||
unmatched_cancelled: number;
|
||||
source_mismatch: number;
|
||||
other_source: number;
|
||||
already_terminal: number;
|
||||
by_source: Record<string, number>;
|
||||
by_queue_kind: { dream_inline: number; other: number };
|
||||
}
|
||||
|
||||
/** Per-file cost estimate in USD for one triage judge call; null when the model is unpriced. */
|
||||
function estimatePerFileUsd(model: string, maxChars: number, maxTokens: number): number | null {
|
||||
const pricing = canonicalLookup(model);
|
||||
if (!pricing) return null;
|
||||
const inputTokens = maxChars / CHARS_PER_TOKEN;
|
||||
return (inputTokens / 1_000_000) * pricing.input + (maxTokens / 1_000_000) * pricing.output;
|
||||
}
|
||||
|
||||
async function confirmOnTty(prompt: string): Promise<boolean> {
|
||||
if (!process.stdin.isTTY) return false;
|
||||
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
||||
const answer = await new Promise<string>(resolve => rl.question(`${prompt} [y/N] `, resolve));
|
||||
rl.close();
|
||||
return /^y(es)?$/i.test(answer.trim());
|
||||
}
|
||||
|
||||
export async function runDreamRetriage(engine: BrainEngine | null, args: string[]): Promise<void> {
|
||||
let parsed: RetriageArgs;
|
||||
try {
|
||||
parsed = parseRetriageArgs(args);
|
||||
} catch (e) {
|
||||
if (e instanceof UsageError) {
|
||||
console.error(`dream retriage: ${e.message} (see: gbrain dream retriage --help)`);
|
||||
setCliExitVerdict(2);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
// IRON RULE: --help short-circuits before any engine-bearing work.
|
||||
if (parsed.help) {
|
||||
printRetriageHelp();
|
||||
return;
|
||||
}
|
||||
if (engine === null) {
|
||||
console.error('gbrain dream retriage requires a connected brain; run `gbrain init` first');
|
||||
setCliExitVerdict(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await loadSynthConfig(engine);
|
||||
if (!config.corpusDir) {
|
||||
console.error('dream retriage: dream.synthesize.session_corpus_dir is unset — nothing to retriage');
|
||||
setCliExitVerdict(1);
|
||||
return;
|
||||
}
|
||||
const threshold = parsed.threshold ?? config.triage.threshold;
|
||||
|
||||
let transcripts = discoverTranscripts({
|
||||
corpusDir: config.corpusDir,
|
||||
meetingTranscriptsDir: config.meetingTranscriptsDir ?? undefined,
|
||||
minChars: config.minChars,
|
||||
excludePatterns: config.excludePatterns,
|
||||
});
|
||||
if (parsed.limit !== null) transcripts = transcripts.slice(0, parsed.limit);
|
||||
|
||||
// ── Half 1: score the corpus (cached reads in --dry-run; judged otherwise) ──
|
||||
let reports: TriageFileReport[];
|
||||
const byPath = new Map<string, DreamVerdict>();
|
||||
let passStats = { judged: 0, cacheHits: 0, unreliable: 0, deferred: 0 };
|
||||
// Estimated spend accumulated across the triage sweep AND the reject audit —
|
||||
// one budget spans both halves (CX3 + security review).
|
||||
let estimatedSpendUsd = 0;
|
||||
|
||||
if (parsed.dryRun) {
|
||||
// Zero judge calls: read cached verdicts only. Files without a valid
|
||||
// triage-v1 score report as needs_triage.
|
||||
reports = [];
|
||||
for (const t of transcripts) {
|
||||
const cached = await engine.getDreamVerdict(t.filePath, t.contentHash);
|
||||
const valid = cached !== null && !parsed.force
|
||||
&& isTriageCacheValid(cached, config.triage.model, parsed.since ?? undefined);
|
||||
if (cached && valid) {
|
||||
passStats.cacheHits++;
|
||||
byPath.set(t.filePath, cached);
|
||||
reports.push({
|
||||
filePath: t.filePath,
|
||||
worth: cached.score !== null && cached.score >= threshold,
|
||||
score: cached.score,
|
||||
content_type: cached.content_type,
|
||||
reasons: cached.reasons,
|
||||
cached: true,
|
||||
});
|
||||
} else {
|
||||
reports.push({
|
||||
filePath: t.filePath,
|
||||
worth: false,
|
||||
score: null,
|
||||
content_type: null,
|
||||
reasons: ['needs_triage (dry-run performs no judge calls)'],
|
||||
cached: false,
|
||||
deferred: true,
|
||||
});
|
||||
passStats.deferred++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Spend gate (outside-voice C12): estimate the miss count upfront and
|
||||
// confirm above SPEND_CONFIRM_USD unless --yes.
|
||||
let missCount = 0;
|
||||
for (const t of transcripts) {
|
||||
if (parsed.force) { missCount++; continue; }
|
||||
const cached = await engine.getDreamVerdict(t.filePath, t.contentHash);
|
||||
const valid = cached !== null
|
||||
&& isTriageCacheValid(cached, config.triage.model, parsed.since ?? undefined);
|
||||
if (!valid) missCount++;
|
||||
}
|
||||
const perFileUsd = estimatePerFileUsd(config.triage.model, config.triage.maxChars, config.triage.maxTokens);
|
||||
// CX3: --max-usd is estimate-based; an unpriced model would silently
|
||||
// disable the budget the operator explicitly asked for — refuse instead.
|
||||
if (parsed.maxUsd !== null && perFileUsd === null) {
|
||||
console.error(
|
||||
`dream retriage: --max-usd requires a priced model; "${config.triage.model}" has no CANONICAL_PRICING entry`,
|
||||
);
|
||||
setCliExitVerdict(2);
|
||||
return;
|
||||
}
|
||||
// The frontier audit spends too (security review): fold its worst case
|
||||
// into the gated estimate so --audit-rejects can't ride around the gate.
|
||||
const auditPerFileUsd = parsed.auditRejects !== null
|
||||
? estimatePerFileUsd(config.model, config.triage.maxChars, config.triage.maxTokens)
|
||||
: null;
|
||||
// Codex structured review P1: an unpriced SYNTHESIS model would zero out
|
||||
// the audit's share of the estimate AND silently disable --max-usd inside
|
||||
// the audit loop — refuse the budget flag, and always confirm when the
|
||||
// audit spend cannot be estimated.
|
||||
const auditUnpriced = parsed.auditRejects !== null && auditPerFileUsd === null;
|
||||
if (parsed.maxUsd !== null && auditUnpriced) {
|
||||
console.error(
|
||||
`dream retriage: --max-usd with --audit-rejects requires a priced synthesis model; ` +
|
||||
`"${config.model}" has no CANONICAL_PRICING entry`,
|
||||
);
|
||||
setCliExitVerdict(2);
|
||||
return;
|
||||
}
|
||||
const auditEstimateUsd = parsed.auditRejects !== null && auditPerFileUsd !== null
|
||||
? parsed.auditRejects * auditPerFileUsd
|
||||
: 0;
|
||||
// Structured-review round 2 P1: the KNOWN portion of the estimate gates
|
||||
// independently of whether the triage model is priced — an unpriced
|
||||
// triage model with a large PRICED audit must still confirm on the audit
|
||||
// dollars, not slide through the file-count gate on cached rejects.
|
||||
const knownEstimateUsd = (perFileUsd ?? 0) * missCount + auditEstimateUsd;
|
||||
const estimateUsd = perFileUsd === null ? null : knownEstimateUsd;
|
||||
const gateTriggered = knownEstimateUsd > SPEND_CONFIRM_USD
|
||||
|| (perFileUsd === null && missCount > UNPRICED_CONFIRM_FILES)
|
||||
|| auditUnpriced; // un-estimable audit spend always confirms
|
||||
const auditSuffix = auditUnpriced
|
||||
? ` (audit model "${config.model}" unpriced — audit spend cannot be estimated)`
|
||||
: auditEstimateUsd > 0 ? ` (incl. ≤ $${auditEstimateUsd.toFixed(2)} audit)` : '';
|
||||
const estimateLine = estimateUsd !== null
|
||||
? `[retriage] ${missCount} file(s) to judge with ${config.triage.model} — estimated ≤ $${estimateUsd.toFixed(2)}${auditSuffix}`
|
||||
: `[retriage] ${missCount} file(s) to judge with ${config.triage.model} — no pricing entry for this model (cannot estimate; the ${UNPRICED_CONFIRM_FILES}-file confirmation gate applies)${auditSuffix}`;
|
||||
process.stderr.write(estimateLine + '\n');
|
||||
if (gateTriggered && !parsed.yes) {
|
||||
if (parsed.json || !process.stdin.isTTY) {
|
||||
console.error('dream retriage: spend estimate exceeds the confirmation gate; re-run with --yes (non-interactive)');
|
||||
setCliExitVerdict(2);
|
||||
return;
|
||||
}
|
||||
const ok = await confirmOnTty(`Proceed with ~$${estimateUsd?.toFixed(2) ?? '?'} of triage spend?`);
|
||||
if (!ok) {
|
||||
console.error('dream retriage: aborted at spend confirmation');
|
||||
setCliExitVerdict(2);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --max-usd soft-stop: estimate-based (usage isn't threaded through the
|
||||
// judge seam); stops pulling new misses once attempts × per-file estimate
|
||||
// crosses the budget. runTriagePass ticks shouldStop on EVERY judge
|
||||
// attempt (CX3 — unreliable responses are paid calls too); may overshoot
|
||||
// by up to the configured concurrency. Remaining files report as deferred.
|
||||
const shouldStop = parsed.maxUsd !== null && perFileUsd !== null
|
||||
? (): boolean => {
|
||||
estimatedSpendUsd += perFileUsd;
|
||||
return estimatedSpendUsd >= parsed.maxUsd!;
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const pass = await runTriagePass(engine, transcripts, {
|
||||
model: config.triage.model,
|
||||
maxChars: config.triage.maxChars,
|
||||
maxTokens: config.triage.maxTokens,
|
||||
threshold,
|
||||
concurrency: config.triage.concurrency,
|
||||
maxMs: 0, // operator sweep runs to completion; --limit / --max-usd bound it
|
||||
force: parsed.force,
|
||||
staleBefore: parsed.since ?? undefined,
|
||||
shouldStop,
|
||||
});
|
||||
reports = pass.reports;
|
||||
for (const [k, v] of pass.byPath) byPath.set(k, v);
|
||||
passStats = { judged: pass.judged, cacheHits: pass.cacheHits, unreliable: pass.unreliable, deferred: pass.deferred };
|
||||
}
|
||||
|
||||
// ── Half 2: queue reconciliation (opt-in) ──
|
||||
let reconcile: ReconcileStats | null = null;
|
||||
if (parsed.reconcileQueue) {
|
||||
const queue = new MinionQueue(engine);
|
||||
const rows = await engine.executeRaw<QueueCandidate>(
|
||||
`SELECT id, queue, status, idempotency_key,
|
||||
COALESCE(NULLIF(data->>'source_id', ''), 'default') AS source_id
|
||||
FROM minion_jobs
|
||||
WHERE name = 'subagent'
|
||||
AND status IN ('waiting', 'delayed', 'paused')
|
||||
AND idempotency_key LIKE 'dream:synth-v2:%'`,
|
||||
);
|
||||
// Two lookups: membership in the discovered corpus (matched at all?) vs a
|
||||
// usable scored verdict. A discovered file with no reliable score is
|
||||
// "matched but unscored" — kept, never cancelled on missing data. The
|
||||
// '|' join is unambiguous even for basenames containing '|': hash16 is
|
||||
// fixed-width hex after the final separator.
|
||||
const discoveredKeys = new Set<string>();
|
||||
const verdictByKey = new Map<string, DreamVerdict>();
|
||||
for (const t of transcripts) {
|
||||
const k = `${basename(t.filePath)}|${t.contentHash.slice(0, 16)}`;
|
||||
discoveredKeys.add(k);
|
||||
const v = byPath.get(t.filePath);
|
||||
if (v) verdictByKey.set(k, v);
|
||||
}
|
||||
// CX2: an empty discovery result alongside a non-empty backlog means the
|
||||
// corpus is unreachable (mount outage, permissions, wrong dir) far more
|
||||
// often than it means every file was deleted. Refuse to cancel-unmatched
|
||||
// in that state — a transient outage must not erase the retry frontier.
|
||||
if (parsed.cancelUnmatched && transcripts.length === 0 && rows.length > 0) {
|
||||
console.error(
|
||||
`dream retriage: discovery found 0 transcripts but ${rows.length} queued job(s) exist; ` +
|
||||
'refusing --cancel-unmatched (corpus may be unreachable). Fix discovery or drop the flag.',
|
||||
);
|
||||
setCliExitVerdict(2);
|
||||
return;
|
||||
}
|
||||
reconcile = {
|
||||
candidates: rows.length,
|
||||
cancelled: 0,
|
||||
converted_for_resubmit: 0,
|
||||
kept_above_threshold: 0,
|
||||
kept_unscored: 0,
|
||||
kept_live_queue: 0,
|
||||
unmatched: 0,
|
||||
unmatched_cancelled: 0,
|
||||
source_mismatch: 0,
|
||||
other_source: 0,
|
||||
already_terminal: 0,
|
||||
by_source: {},
|
||||
by_queue_kind: { dream_inline: 0, other: 0 },
|
||||
};
|
||||
// Codex structured review P2: queue age alone is not liveness — a cycle
|
||||
// with several slow sequential children can legitimately exceed the 1h
|
||||
// grace. Consult the REAL signal: a live (unexpired) cycle lock means a
|
||||
// cycle is running right now, so no dream-inline queue is provably dead.
|
||||
const liveLocks = await engine.executeRaw<{ id: string }>(
|
||||
`SELECT id FROM gbrain_cycle_locks WHERE ttl_expires_at > NOW() AND id LIKE 'gbrain-cycle%'`,
|
||||
);
|
||||
// Structured-review round 2 P2: cycle locks are per-source
|
||||
// (`gbrain-cycle:<source>`) — a cycle running for source A must not
|
||||
// suppress conversions for source B indefinitely. Only the legacy bare
|
||||
// `gbrain-cycle` lock is global.
|
||||
const globalLockLive = liveLocks.some(l => l.id === 'gbrain-cycle');
|
||||
const liveLockSources = new Set(
|
||||
liveLocks
|
||||
.map(l => (l.id.startsWith('gbrain-cycle:') ? l.id.slice('gbrain-cycle:'.length) : null))
|
||||
.filter((s): s is string => s !== null),
|
||||
);
|
||||
if (liveLocks.length > 0) {
|
||||
process.stderr.write(
|
||||
`[retriage] live cycle lock(s) detected (${liveLocks.map(l => l.id).join(', ')}); ` +
|
||||
`dream-inline queues for those sources are treated as possibly-live — conversions skipped\n`,
|
||||
);
|
||||
}
|
||||
const cancelRow = async (id: number): Promise<'cancelled' | 'already_terminal'> => {
|
||||
// Pre-cancel status re-check (C9): a candidate claimed by a live worker
|
||||
// between the snapshot SELECT and now is skipped, not killed.
|
||||
const fresh = await engine.executeRaw<{ status: string }>(
|
||||
`SELECT status FROM minion_jobs WHERE id = $1`, [id],
|
||||
);
|
||||
const status = fresh[0]?.status;
|
||||
if (status !== 'waiting' && status !== 'delayed' && status !== 'paused') return 'already_terminal';
|
||||
const r = await queue.cancelJob(id);
|
||||
return r ? 'cancelled' : 'already_terminal';
|
||||
};
|
||||
for (const row of rows) {
|
||||
reconcile.by_source[row.source_id] = (reconcile.by_source[row.source_id] ?? 0) + 1;
|
||||
const inlineQueueAge = dreamInlineQueueAgeMs(row.queue);
|
||||
const isInlineQueue = inlineQueueAge !== null || row.queue.startsWith('dream-inline-');
|
||||
if (isInlineQueue) reconcile.by_queue_kind.dream_inline++;
|
||||
else reconcile.by_queue_kind.other++;
|
||||
// CX1: a dream-inline-* queue younger than the liveness grace may belong
|
||||
// to a cycle that is RUNNING right now — its inline drain will claim
|
||||
// these rows. Never cancel anything in a possibly-live private queue
|
||||
// (unparseable-timestamp names count as possibly-live, fail-safe), and
|
||||
// a live cycle lock FOR THIS ROW'S SOURCE marks its inline queues
|
||||
// possibly-live regardless of age (structured-review P2: slow
|
||||
// sequential children can outlive the grace; round 2: per-source, so a
|
||||
// busy source A never suppresses source B's cleanup indefinitely).
|
||||
const lockLiveForRow = globalLockLive || liveLockSources.has(row.source_id);
|
||||
const possiblyLiveQueue = isInlineQueue
|
||||
&& (lockLiveForRow || inlineQueueAge === null || inlineQueueAge <= DREAM_INLINE_LIVE_GRACE_MS);
|
||||
if (possiblyLiveQueue) {
|
||||
reconcile.kept_live_queue++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.source !== null && row.source_id !== parsed.source) {
|
||||
reconcile.other_source++;
|
||||
continue;
|
||||
}
|
||||
const key = parseSynthV2Key(row.idempotency_key);
|
||||
if (!key) {
|
||||
reconcile.unmatched++;
|
||||
continue;
|
||||
}
|
||||
// C9 hardening: the key's encoded source must agree with the payload's
|
||||
// source_id — never cancel on a disagreement.
|
||||
if ((key.source || 'default') !== row.source_id) {
|
||||
reconcile.source_mismatch++;
|
||||
continue;
|
||||
}
|
||||
const matchKey = `${key.basename}|${key.hash16}`;
|
||||
const verdict = verdictByKey.get(matchKey);
|
||||
if (!verdict || verdict.score === null) {
|
||||
// Unmatched file OR matched-but-unscored (deferred/degraded): only the
|
||||
// truly-unmatched are cancellable, and only behind --cancel-unmatched.
|
||||
const isMatchedUnscored = discoveredKeys.has(matchKey);
|
||||
if (isMatchedUnscored) {
|
||||
reconcile.kept_unscored++;
|
||||
} else if (parsed.cancelUnmatched) {
|
||||
// Structured-review P2: the dry-run preview must count would-cancel
|
||||
// unmatched rows the same way the below-threshold branch does — a
|
||||
// destructive preview that understates its impact is worse than none.
|
||||
if (parsed.dryRun) {
|
||||
reconcile.unmatched_cancelled++; // dry-run: would cancel
|
||||
} else {
|
||||
const outcome = await cancelRow(row.id);
|
||||
if (outcome === 'cancelled') reconcile.unmatched_cancelled++;
|
||||
else reconcile.already_terminal++;
|
||||
}
|
||||
} else {
|
||||
reconcile.unmatched++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (verdict.score < threshold) {
|
||||
if (!parsed.dryRun) {
|
||||
const outcome = await cancelRow(row.id);
|
||||
if (outcome === 'cancelled') reconcile.cancelled++;
|
||||
else reconcile.already_terminal++;
|
||||
} else {
|
||||
reconcile.cancelled++; // dry-run: would cancel
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Above threshold. C1: a row stranded in a provably-dead per-run
|
||||
// dream-inline-* queue (older than the liveness grace, no live cycle
|
||||
// lock — the possibly-live case was already kept above) will never be
|
||||
// claimed — cancel it so the next cycle's queue.add re-creates it in a
|
||||
// live drain (the cancelled row releases its idempotency slot).
|
||||
// `delayed` counts too (structured-review P2): a transient-failure
|
||||
// retry parked in a dead queue has no worker to promote or drain it.
|
||||
if (isInlineQueue && (row.status === 'waiting' || row.status === 'delayed')) {
|
||||
if (!parsed.dryRun) {
|
||||
const outcome = await cancelRow(row.id);
|
||||
if (outcome === 'cancelled') reconcile.converted_for_resubmit++;
|
||||
else reconcile.already_terminal++;
|
||||
} else {
|
||||
reconcile.converted_for_resubmit++; // dry-run: would convert
|
||||
}
|
||||
continue;
|
||||
}
|
||||
reconcile.kept_above_threshold++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── --audit-rejects (C6): frontier second opinion on N stride-sampled rejects ──
|
||||
let audit: { sampled: number; disagreements: number; disagreement_rate: number | null } | null = null;
|
||||
if (parsed.auditRejects !== null && parsed.dryRun) {
|
||||
// Loud no-op instead of a silently-null audit field (maintainability review).
|
||||
process.stderr.write('[retriage] --audit-rejects skipped under --dry-run (the audit spends frontier-model calls)\n');
|
||||
}
|
||||
if (parsed.auditRejects !== null && !parsed.dryRun) {
|
||||
const rejects = reports.filter(r => r.score !== null && r.score < threshold);
|
||||
// Deterministic stride-sample over the rejects in discovery order — no
|
||||
// randomness, so repeated audits compare like with like.
|
||||
const sample: TriageFileReport[] = [];
|
||||
const stride = Math.max(1, Math.floor(rejects.length / parsed.auditRejects));
|
||||
for (let i = 0; i < rejects.length && sample.length < parsed.auditRejects; i += stride) sample.push(rejects[i]);
|
||||
const frontier = makeJudgeClient(config.model);
|
||||
if (!frontier) {
|
||||
process.stderr.write(`[retriage] --audit-rejects: no reachable provider for ${config.model}; skipping audit\n`);
|
||||
} else {
|
||||
const byFilePath = new Map(transcripts.map(t => [t.filePath, t]));
|
||||
const auditPerFileUsd = estimatePerFileUsd(config.model, config.triage.maxChars, config.triage.maxTokens);
|
||||
let disagreements = 0;
|
||||
let judged = 0;
|
||||
for (const r of sample) {
|
||||
const t = byFilePath.get(r.filePath);
|
||||
if (!t) continue;
|
||||
// --max-usd spans the audit too (CX3): stop before the next frontier
|
||||
// call would cross the budget.
|
||||
if (parsed.maxUsd !== null && auditPerFileUsd !== null
|
||||
&& estimatedSpendUsd + auditPerFileUsd > parsed.maxUsd) {
|
||||
process.stderr.write(`[retriage] --audit-rejects stopped at --max-usd $${parsed.maxUsd.toFixed(2)} (audited ${judged})\n`);
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const second = await judgeSignificance(frontier, t, config.model, {
|
||||
maxChars: config.triage.maxChars,
|
||||
maxTokens: config.triage.maxTokens,
|
||||
});
|
||||
estimatedSpendUsd += auditPerFileUsd ?? 0;
|
||||
if (second.unreliable) continue;
|
||||
judged++;
|
||||
if (second.score >= threshold) disagreements++;
|
||||
} catch {
|
||||
// A failed call is still an attempt — count its estimated cost.
|
||||
estimatedSpendUsd += auditPerFileUsd ?? 0;
|
||||
// Audit is best-effort; a failed second opinion is skipped.
|
||||
}
|
||||
}
|
||||
audit = {
|
||||
sampled: judged,
|
||||
disagreements,
|
||||
disagreement_rate: judged > 0 ? Math.round((disagreements / judged) * 1000) / 1000 : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const passCount = reports.filter(r => r.worth).length;
|
||||
const summary = {
|
||||
discovered: transcripts.length,
|
||||
threshold,
|
||||
pass: passCount,
|
||||
below_threshold: reports.filter(r => r.score !== null && !r.worth).length,
|
||||
needs_triage: reports.filter(r => r.deferred).length,
|
||||
retriaged: passStats.judged,
|
||||
cache_hits: passStats.cacheHits,
|
||||
unreliable: passStats.unreliable,
|
||||
deferred: passStats.deferred,
|
||||
dry_run: parsed.dryRun,
|
||||
queue: reconcile,
|
||||
audit,
|
||||
};
|
||||
|
||||
if (parsed.json) {
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
} else {
|
||||
const would = parsed.dryRun ? ' (dry-run: no cancels performed)' : '';
|
||||
console.log(`[retriage] ${summary.discovered} discovered | ${summary.pass} pass @ threshold ${threshold} | ` +
|
||||
`${summary.below_threshold} below | ${summary.needs_triage} need triage | ` +
|
||||
`${summary.retriaged} judged, ${summary.cache_hits} cached, ${summary.unreliable} unreliable`);
|
||||
if (reconcile) {
|
||||
console.log(`[retriage] queue: ${reconcile.candidates} candidates | ${reconcile.cancelled} cancelled | ` +
|
||||
`${reconcile.converted_for_resubmit} converted for resubmit | ${reconcile.kept_above_threshold} kept | ` +
|
||||
`${reconcile.kept_unscored} unscored kept | ${reconcile.kept_live_queue} live-queue kept | ${reconcile.unmatched} unmatched | ` +
|
||||
`${reconcile.source_mismatch} source-mismatch skipped | ${reconcile.already_terminal} already terminal${would}`);
|
||||
const bySource = Object.entries(reconcile.by_source).map(([s, n]) => `${s}=${n}`).join(', ');
|
||||
if (bySource) console.log(`[retriage] queue by source: ${bySource} | stale dream-inline queues: ${reconcile.by_queue_kind.dream_inline}`);
|
||||
}
|
||||
if (audit) {
|
||||
console.log(`[retriage] reject audit: ${audit.sampled} re-judged by ${config.model}, ` +
|
||||
`${audit.disagreements} disagreements (rate ${audit.disagreement_rate ?? 'n/a'})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
-2
@@ -32,6 +32,7 @@ import {
|
||||
type CycleReport,
|
||||
} from '../core/cycle.ts';
|
||||
import { resolveSourceId } from '../core/source-resolver.ts';
|
||||
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
|
||||
import { fetchSource } from '../core/sources-load.ts';
|
||||
import { existsSync } from 'fs';
|
||||
import { resolve } from 'node:path';
|
||||
@@ -346,6 +347,7 @@ async function resolveBrainDir(
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: gbrain dream [options]
|
||||
gbrain dream retriage [flags] (see: gbrain dream retriage --help)
|
||||
|
||||
Run one brain maintenance cycle. Eight phases:
|
||||
lint -> backlinks -> sync -> synthesize -> extract -> patterns -> embed -> orphans
|
||||
@@ -354,10 +356,17 @@ The synthesize + patterns phases (v0.21) consolidate yesterday's
|
||||
conversation transcripts into reflections, originals, and cross-session
|
||||
pattern pages. Designed for cron (exits when done).
|
||||
|
||||
The synthesize phase (#4152) runs a two-stage cascade: a cheap scored triage
|
||||
(model: models.dream.triage, gate: dream.triage.threshold, default 0.5) gates
|
||||
the expensive per-transcript synthesis subagents (turn budget:
|
||||
dream.synthesize.max_turns, default 16). Retune the threshold any time —
|
||||
scores are cached, so re-gating costs zero new LLM calls. \`dream retriage\`
|
||||
re-scores the corpus and reconciles the queued synthesis backlog.
|
||||
|
||||
Options:
|
||||
--dry-run Preview all fixes without writing. Note: synthesize
|
||||
runs the cheap Haiku significance filter (caches
|
||||
verdicts), but skips the Sonnet synthesis pass.
|
||||
runs the cheap scored triage pass (caches verdicts),
|
||||
but skips the synthesis subagents.
|
||||
"--dry-run" does NOT mean "zero LLM calls."
|
||||
--json Emit the CycleReport as JSON (agent-readable)
|
||||
--phase <name> Run a single phase: ${ALL_PHASES.join(' | ')}
|
||||
@@ -569,6 +578,33 @@ async function runDrain(
|
||||
}
|
||||
|
||||
export async function runDream(engine: BrainEngine | null, args: string[]): Promise<CycleReport | void> {
|
||||
// ─── `dream retriage` subverb (#4152) — dispatched BEFORE parseArgs so its
|
||||
// flag set never collides with the cycle flags. `dream --help` never reaches
|
||||
// here (args[0] is '--help'); `dream retriage --help` prints subcommand help
|
||||
// inside runDreamRetriage without touching the engine (same IRON RULE).
|
||||
if (args[0] === 'retriage') {
|
||||
const { runDreamRetriage } = await import('./dream-retriage.ts');
|
||||
await runDreamRetriage(engine, args.slice(1));
|
||||
return;
|
||||
}
|
||||
// Fail-loud guard (structured-review r3 P1): the CLI flag registry unions
|
||||
// retriage's flags into `dream`, so the pre-dispatch validator accepts
|
||||
// `gbrain dream --reconcile-queue` — but without the `retriage` positional,
|
||||
// parseArgs would ignore the flag and silently run the full (paid, writing)
|
||||
// maintenance cycle instead of the reconciliation the user asked for.
|
||||
{
|
||||
const RETRIAGE_ONLY_FLAGS = ['--reconcile-queue', '--cancel-unmatched', '--audit-rejects'];
|
||||
const stray = args.find(a => RETRIAGE_ONLY_FLAGS.includes(a));
|
||||
if (stray) {
|
||||
console.error(
|
||||
`gbrain dream: ${stray} belongs to the 'retriage' subcommand — ` +
|
||||
`did you mean: gbrain dream retriage ${args.join(' ')}`,
|
||||
);
|
||||
setCliExitVerdict(2);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const opts = parseArgs(args);
|
||||
|
||||
// ─── IRON RULE: --help short-circuits BEFORE any engine-bearing work ─
|
||||
|
||||
@@ -249,9 +249,17 @@ export interface ExtractConversationFactsCoreOpts {
|
||||
types?: AllowedType[];
|
||||
/** Process a single page; otherwise iterate all matching pages in the source. */
|
||||
slug?: string;
|
||||
/**
|
||||
* cathedral-4 batch selector: process exactly these pages (serial, with
|
||||
* the same per-page advisory lock + durable-outcome gates as enumeration).
|
||||
* ONE core invocation per caller run — per-slug invocations multiply
|
||||
* config resolution, checkpoint IO, and receipt writes by page count.
|
||||
* Takes precedence over `slug`.
|
||||
*/
|
||||
slugs?: string[];
|
||||
/** Show would-do counts without writing facts or advancing checkpoint. */
|
||||
dryRun?: boolean;
|
||||
/** Cap pages processed in this invocation. */
|
||||
/** Cap pages processed in this invocation (enumeration path only; ignored when `slugs` is set). */
|
||||
limit?: number;
|
||||
/** ISO watermark; messages older than this are filtered out. */
|
||||
sinceIso?: string;
|
||||
@@ -1336,7 +1344,24 @@ export async function runExtractConversationFactsCore(
|
||||
// types are not silently skipped (see ALLOWED_TYPE_ALIASES).
|
||||
const concreteTypes = pageTypesForAllowed(types);
|
||||
|
||||
if (opts.slug) {
|
||||
if (opts.slugs !== undefined) {
|
||||
// Batch mode is selected by the PRESENCE of the selector: an empty
|
||||
// list means "process exactly these zero pages" (a no-op), never a
|
||||
// fall-through to full-corpus enumeration and its LLM spend.
|
||||
for (const slug of opts.slugs) {
|
||||
if (signal?.aborted) throw new Error('aborted');
|
||||
const page = await engine.getPage(slug, { sourceId });
|
||||
if (!page) {
|
||||
result.pages_skipped_disappeared++;
|
||||
continue;
|
||||
}
|
||||
if (!concreteTypes.includes(page.type)) {
|
||||
result.pages_skipped++;
|
||||
continue;
|
||||
}
|
||||
await processPageWithLock(page);
|
||||
}
|
||||
} else if (opts.slug) {
|
||||
const page = await engine.getPage(opts.slug, { sourceId });
|
||||
if (!page) {
|
||||
result.pages_skipped_disappeared++;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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 ?? {}) };
|
||||
|
||||
+200
-4
@@ -6,7 +6,12 @@
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { MinionQueue } from '../core/minions/queue.ts';
|
||||
import { MinionWorker } from '../core/minions/worker.ts';
|
||||
import { WORKER_EXIT_RSS_WATCHDOG } from '../core/minions/worker-exit-codes.ts';
|
||||
import {
|
||||
WORKER_EXIT_RSS_WATCHDOG,
|
||||
JOB_CHILD_EXIT_USAGE,
|
||||
} from '../core/minions/worker-exit-codes.ts';
|
||||
import { CHILD_ENV, resolveChildCliInvocation } from '../core/minions/job-isolation.ts';
|
||||
import { runChildJobEntry } from '../core/minions/run-child.ts';
|
||||
import type { MinionHandler, MinionJob, MinionJobStatus } from '../core/minions/types.ts';
|
||||
import type { PaceKeyOverrides } from '../core/pace-mode.ts';
|
||||
import { loadConfig, isThinClient } from '../core/config.ts';
|
||||
@@ -154,6 +159,32 @@ export function resolveWorkerConcurrency(args: string[], env: NodeJS.ProcessEnv
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export type JobIsolationMode = 'inline' | 'process';
|
||||
|
||||
/**
|
||||
* issue #5: `--job-isolation <inline|process>` (space or `=` form), env
|
||||
* fallback GBRAIN_JOB_ISOLATION, default inline. `process` runs each claimed
|
||||
* job in a SIGKILL-able child process — blast radius 1 job instead of N.
|
||||
* Env injected as a param so tests never mutate process.env (rule R1).
|
||||
* Invalid values fail fast (parseMaxRssFlag convention).
|
||||
*/
|
||||
export function parseJobIsolationFlag(
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): JobIsolationMode {
|
||||
let raw: string | undefined;
|
||||
const eqForm = args.find((a) => a.startsWith('--job-isolation='));
|
||||
if (eqForm !== undefined) raw = eqForm.slice('--job-isolation='.length);
|
||||
if (raw === undefined) raw = parseFlag(args, '--job-isolation');
|
||||
if (raw === undefined || raw === '') raw = env.GBRAIN_JOB_ISOLATION;
|
||||
if (raw === undefined || raw === '') return 'inline';
|
||||
if (raw === 'inline' || raw === 'process') return raw;
|
||||
console.error(
|
||||
`Error: invalid job isolation mode ${JSON.stringify(raw)}. Valid: inline, process.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* #3026: the thin-client `list`/`get` branches receive jobs as parsed JSON
|
||||
* off the MCP wire, where every timestamp is an ISO string — but formatJob /
|
||||
@@ -269,11 +300,13 @@ USAGE
|
||||
gbrain jobs watch [--json] [--follow] [--refresh-ms=N]
|
||||
gbrain jobs work [--queue Q] [--concurrency N] [--max-rss MB]
|
||||
[--health-interval MS] [--nice N]
|
||||
[--job-isolation inline|process]
|
||||
gbrain jobs supervisor [start] [--detach] [--json]
|
||||
[--concurrency N] [--queue Q] [--pid-file PATH]
|
||||
[--max-crashes N] [--health-interval N]
|
||||
[--allow-shell-jobs] [--cli-path PATH]
|
||||
[--max-rss MB] [--nice N]
|
||||
[--job-isolation inline|process]
|
||||
|
||||
--nice N OS scheduling priority, -20 (highest) to 19 (nicest). Lowers CPU
|
||||
priority without cutting concurrency — full throughput when the
|
||||
@@ -344,9 +377,18 @@ const JOBS_SUBCOMMAND_HELP: Record<string, string> = {
|
||||
USAGE
|
||||
gbrain jobs work [--queue Q] [--concurrency N] [--max-rss MB]
|
||||
[--health-interval MS] [--nice N]
|
||||
[--job-isolation inline|process]
|
||||
|
||||
OPTIONS
|
||||
--queue Q Queue to claim from (default: default)
|
||||
--job-isolation M inline (default): handlers run in the worker process.
|
||||
process: each claimed job runs in its own child
|
||||
process — a stuck handler is group-SIGKILLed instead
|
||||
of abandoned, and a crash takes one job, not all N.
|
||||
Env fallback: GBRAIN_JOB_ISOLATION. Recommended for
|
||||
long-running LLM-bound handlers (subagent). Note:
|
||||
--max-rss then covers the worker only, and each child
|
||||
adds ~4 pooler client connections.
|
||||
--concurrency N Max jobs in flight. Resolution: flag, then
|
||||
GBRAIN_WORKER_CONCURRENCY env, then 1. Values < 1
|
||||
are clamped to 1 with a loud stderr note.
|
||||
@@ -376,6 +418,7 @@ USAGE
|
||||
[--max-crashes N] [--health-interval N]
|
||||
[--allow-shell-jobs] [--cli-path PATH]
|
||||
[--max-rss MB] [--nice N]
|
||||
[--job-isolation inline|process]
|
||||
gbrain jobs supervisor status [--json] [--pid-file PATH]
|
||||
gbrain jobs supervisor stop [--json] [--pid-file PATH]
|
||||
|
||||
@@ -397,6 +440,7 @@ OPTIONS (start)
|
||||
--cli-path PATH Explicit gbrain binary for the worker child
|
||||
--max-rss MB RSS watchdog for the worker (same rules as jobs work)
|
||||
--nice N OS priority for supervisor + worker children
|
||||
--job-isolation M Passed through to the worker (see jobs work --help)
|
||||
|
||||
EXIT CODES (start)
|
||||
0 clean shutdown 1 max crashes exceeded
|
||||
@@ -1185,6 +1229,59 @@ export async function runJobs(engineOrNull: BrainEngine | null, args: string[]):
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
case 'run-child': {
|
||||
// INTERNAL (issue #5 process isolation): spawned by `jobs work` with
|
||||
// process isolation enabled. One job, one process: validate the claim,
|
||||
// run the handler with the child's own engine, write ONE outcome file,
|
||||
// exit. Deliberately absent from user-facing help. The CLI layer owns
|
||||
// engine.disconnect() + process.exit() (engine-ownership invariant).
|
||||
{
|
||||
const config = loadConfig();
|
||||
if (config?.engine === 'pglite') {
|
||||
console.error('[run-child] process isolation requires the Postgres engine.');
|
||||
await engine.disconnect();
|
||||
process.exit(JOB_CHILD_EXIT_USAGE);
|
||||
}
|
||||
const jobIdRaw = parseFlag(args, '--job-id');
|
||||
const jobId = jobIdRaw != null ? parseInt(jobIdRaw, 10) : NaN;
|
||||
const lockToken = process.env[CHILD_ENV.lockToken];
|
||||
const resultPath = process.env[CHILD_ENV.resultPath];
|
||||
const parentPidRaw = parseInt(process.env[CHILD_ENV.parentPid] ?? '0', 10);
|
||||
if (!Number.isInteger(jobId) || jobId <= 0 || !lockToken || !resultPath) {
|
||||
console.error(
|
||||
'[run-child] internal command spawned by the jobs worker; requires ' +
|
||||
`a numeric job id plus ${CHILD_ENV.lockToken} and ${CHILD_ENV.resultPath} in env.`,
|
||||
);
|
||||
await engine.disconnect();
|
||||
process.exit(JOB_CHILD_EXIT_USAGE);
|
||||
}
|
||||
|
||||
// Same handler surface as the worker: registerBuiltinHandlers also
|
||||
// performs plugin discovery, so plugin subagent jobs isolate too.
|
||||
const throwaway = new MinionWorker(engine, { queue: 'default', concurrency: 1 });
|
||||
await registerBuiltinHandlers(throwaway, engine, { quiet: true });
|
||||
|
||||
let code: number;
|
||||
try {
|
||||
code = await runChildJobEntry(
|
||||
engine,
|
||||
{
|
||||
jobId,
|
||||
lockToken,
|
||||
resultPath,
|
||||
parentPid: Number.isInteger(parentPidRaw) && parentPidRaw > 0 ? parentPidRaw : 0,
|
||||
},
|
||||
{ resolveHandler: (name) => throwaway.getHandler(name) },
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`[run-child] fatal: ${e instanceof Error ? e.message : String(e)}`);
|
||||
code = 1;
|
||||
}
|
||||
await engine.disconnect();
|
||||
process.exit(code);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-fallthrough -- unreachable: the case above always exits
|
||||
case 'work': {
|
||||
// Check if PGLite
|
||||
const config = (await import('../core/config.ts')).loadConfig();
|
||||
@@ -1245,11 +1342,78 @@ export async function runJobs(engineOrNull: BrainEngine | null, args: string[]):
|
||||
}
|
||||
}
|
||||
|
||||
// issue #5: per-job process isolation. Resolve + validate the child CLI
|
||||
// invocation ONCE at startup and refuse to start on failure — a bad
|
||||
// path discovered per-job would release every claim as infra failures
|
||||
// (never dead-lettering, but never progressing either).
|
||||
const jobIsolation = parseJobIsolationFlag(args);
|
||||
let childCliInvocation: { cmd: string; argsPrefix: string[] } | null = null;
|
||||
let childTiniPath = '';
|
||||
if (jobIsolation === 'process') {
|
||||
const { resolveGbrainCliPath } = await import('./autopilot.ts');
|
||||
const inv = resolveChildCliInvocation(
|
||||
process.env,
|
||||
process.execPath,
|
||||
process.argv[1],
|
||||
() => resolveGbrainCliPath(),
|
||||
);
|
||||
if (!inv) {
|
||||
console.error(
|
||||
'Error: process isolation needs a resolvable gbrain CLI for job children ' +
|
||||
'(compiled binary on PATH, or GBRAIN_JOB_CHILD_CLI override).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// Canonicalize BEFORE validating: existsSync on a relative name checks
|
||||
// cwd while spawn() resolves via PATH — the validated file and the
|
||||
// executed binary could differ (security review). Resolving to an
|
||||
// absolute path makes the fail-fast check and the spawn agree.
|
||||
const { existsSync: childCliExists } = await import('node:fs');
|
||||
const { resolve: resolveCliPath } = await import('node:path');
|
||||
inv.cmd = resolveCliPath(inv.cmd);
|
||||
if (!childCliExists(inv.cmd)) {
|
||||
console.error(
|
||||
`Error: resolved child CLI does not exist: ${inv.cmd} ` +
|
||||
'(set GBRAIN_JOB_CHILD_CLI to a valid gbrain binary).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
childCliInvocation = inv;
|
||||
const { detectTini } = await import('../core/minions/spawn-helpers.ts');
|
||||
childTiniPath = detectTini();
|
||||
if (maxRssMb > 0) {
|
||||
console.error(
|
||||
'[gbrain jobs] note: with process isolation on, the --max-rss watchdog covers the ' +
|
||||
'WORKER process only — handler memory now lives in job children. Per-child caps are ' +
|
||||
'a filed follow-up; size host memory for concurrency x handler footprint.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try { await queue.ensureSchema(); }
|
||||
catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exit(1); }
|
||||
|
||||
// issue #6: the direct-pool kill switch collapses lock renewal, health
|
||||
// probes, and handler workload onto ONE shared pool — silently. Make
|
||||
// the collapse loud at startup so a later 'pool_starved' incident has
|
||||
// an obvious prior warning instead of a mystery.
|
||||
{
|
||||
const { getConnectionRouting } = await import('../core/minions/db-probe.ts');
|
||||
const cm = getConnectionRouting(engine);
|
||||
if (cm?.isDualPoolActive && !cm.isDualPoolActive()) {
|
||||
const killSwitched = cm.describeMode?.().kill_switch_active === true;
|
||||
console.error(
|
||||
`[gbrain jobs] single-pool mode: lock renewal, health probes and handler workload share ` +
|
||||
`one connection pool${killSwitched ? ' (direct-lane kill switch is active)' : ''}. ` +
|
||||
`Under heavy handler load this pool can starve the lock heartbeat. For Supabase brains, ` +
|
||||
`ensure the direct (5432) host is reachable or set GBRAIN_DIRECT_DATABASE_URL.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const worker = new MinionWorker(engine, {
|
||||
queue: queueName, concurrency, maxRssMb, healthCheckInterval,
|
||||
jobIsolation, childCliInvocation, childTiniPath,
|
||||
});
|
||||
await registerBuiltinHandlers(worker, engine);
|
||||
|
||||
@@ -1259,9 +1423,37 @@ export async function runJobs(engineOrNull: BrainEngine | null, args: string[]):
|
||||
// the external PM (systemd, Docker, cron watchdog) restart cleanly.
|
||||
worker.on('unhealthy', (info) => {
|
||||
if (info.reason === 'db_dead') {
|
||||
// issue #6: name the failing LAYER, not just "DB unreachable" —
|
||||
// that message sent operators chasing database capacity while the
|
||||
// real fault was client-side pool exhaustion. Exiting is still
|
||||
// correct recovery either way (it frees every client-held slot).
|
||||
if (info.verdict === 'pool_starved') {
|
||||
console.error(
|
||||
`[health] FATAL: connection-pool path saturated after ${info.consecutiveFailures} probes — ` +
|
||||
`the database server itself is reachable. (${info.message}) ` +
|
||||
`Likely causes: long-running handler queries holding pool slots, or too-small GBRAIN_POOL_SIZE ` +
|
||||
`for this workload. Consider --job-isolation process for long-running handlers ` +
|
||||
`(handler connections then die with each job's child process). ` +
|
||||
`Exiting for process-manager restart (frees all client-held slots).`,
|
||||
);
|
||||
} else if (info.verdict === 'server_unreachable') {
|
||||
console.error(
|
||||
`[health] FATAL: database server unreachable after ${info.consecutiveFailures} probes ` +
|
||||
`(both pooler and direct lanes failed). (${info.message}) ` +
|
||||
`Exiting for process-manager restart.`,
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`[health] FATAL: DB probe failed ${info.consecutiveFailures} consecutive times (${info.message}). ` +
|
||||
`Exiting for process-manager restart.`,
|
||||
);
|
||||
}
|
||||
} else if (info.reason === 'child_spawn_failing') {
|
||||
console.error(
|
||||
`[health] FATAL: DB unreachable after ${info.consecutiveFailures} probes (${info.message}). ` +
|
||||
`Exiting for process-manager restart.`,
|
||||
`[health] FATAL: ${info.consecutiveFailures} consecutive job-child spawn/bootstrap ` +
|
||||
`failures (${info.message}). The child CLI is deterministically broken — fix the ` +
|
||||
`worker's child CLI configuration (or GBRAIN_JOB_CHILD_CLI). Exiting for ` +
|
||||
`process-manager restart.`,
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
@@ -1290,7 +1482,10 @@ export async function runJobs(engineOrNull: BrainEngine | null, args: string[]):
|
||||
: `, health-check: ${Math.round(healthCheckInterval / 1000)}s`)
|
||||
: '';
|
||||
const niceNote = niceResult ? `, nice: ${formatNice(niceResult.effective ?? niceVal!)}` : '';
|
||||
console.log(`Minion worker started (queue: ${queueName}, concurrency: ${concurrency}${watchdogNote}${healthNote}${niceNote})`);
|
||||
const isolationNote = jobIsolation === 'process'
|
||||
? `, isolation: process (child cli: ${childCliInvocation?.cmd}${childTiniPath ? ', tini' : ''})`
|
||||
: '';
|
||||
console.log(`Minion worker started (queue: ${queueName}, concurrency: ${concurrency}${watchdogNote}${healthNote}${niceNote}${isolationNote})`);
|
||||
console.log(`Registered handlers: ${worker.registeredNames.join(', ')}`);
|
||||
|
||||
// Register in the live worker registry (issue #1815) so jobs stats / doctor
|
||||
@@ -1609,6 +1804,7 @@ export async function runJobs(engineOrNull: BrainEngine | null, args: string[]):
|
||||
allowShellJobs,
|
||||
json: jsonMode,
|
||||
maxRssMb,
|
||||
jobIsolation: parseJobIsolationFlag(args),
|
||||
...(supNice !== undefined ? { nice_requested: supNice } : {}),
|
||||
...(supNiceResult?.effective != null ? { nice_effective: supNiceResult.effective } : {}),
|
||||
...(supNiceResult?.error ? { nice_error: supNiceResult.error } : {}),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
+24
-3
@@ -32,6 +32,7 @@ import {
|
||||
DEFAULT_ALIASES,
|
||||
TIER_DEFAULTS,
|
||||
resolveModel,
|
||||
resolveAlias,
|
||||
type ModelTier,
|
||||
} from '../core/model-config.ts';
|
||||
import { maybeAttachVersionSuffixHint } from '../core/ai/base-url-probe.ts';
|
||||
@@ -45,11 +46,23 @@ interface PerTaskModelRoute {
|
||||
description: string;
|
||||
deprecatedConfigKey?: string;
|
||||
envVar?: string;
|
||||
/**
|
||||
* #4152 (2A): an explicit pre-read key that wins over the whole
|
||||
* resolveModel chain when set — mirrors loadSynthConfig's triage-model
|
||||
* resolution so the dashboard reports the ACTUAL spending route.
|
||||
*/
|
||||
overrideKey?: string;
|
||||
}
|
||||
|
||||
const PER_TASK_KEYS: PerTaskModelRoute[] = [
|
||||
{ key: 'models.dream.synthesize', tier: 'reasoning', description: 'Dream synthesis (conversation → brain pages)' },
|
||||
{ key: 'models.dream.synthesize_verdict', tier: 'utility', description: 'Dream synthesis verdict (Haiku judge)' },
|
||||
{
|
||||
key: 'models.dream.synthesize_verdict',
|
||||
tier: 'utility',
|
||||
description: 'Dream triage judge (scored gate; models.dream.triage preferred)',
|
||||
deprecatedConfigKey: 'dream.synthesize.verdict_model',
|
||||
overrideKey: 'models.dream.triage',
|
||||
},
|
||||
{ key: 'models.dream.patterns', tier: 'reasoning', description: 'Pattern discovery (cross-take themes)' },
|
||||
{ key: 'models.drift', tier: 'reasoning', description: 'Drift LLM judge (v0.29 scaffold)' },
|
||||
{ key: 'models.auto_think', tier: 'deep', description: 'Auto-think question answering' },
|
||||
@@ -128,7 +141,15 @@ async function buildReport(engine: BrainEngine): Promise<ModelsReport> {
|
||||
|
||||
const per_task: ModelsReport['per_task'] = [];
|
||||
for (const route of PER_TASK_KEYS) {
|
||||
const { key, tier, description, deprecatedConfigKey, envVar } = route;
|
||||
const { key, tier, description, deprecatedConfigKey, envVar, overrideKey } = route;
|
||||
// Explicit pre-read override (loadSynthConfig 2A parity): when set, it IS
|
||||
// the effective spending route and must be reported as such.
|
||||
const overrideValue = overrideKey ? await engine.getConfig(overrideKey) : null;
|
||||
if (overrideKey && overrideValue?.trim()) {
|
||||
const resolved = await resolveAlias(engine, overrideValue.trim());
|
||||
per_task.push({ key, tier, resolved, source: `config: ${overrideKey}`, description });
|
||||
continue;
|
||||
}
|
||||
const resolved = await resolveModel(engine, {
|
||||
configKey: key,
|
||||
deprecatedConfigKey,
|
||||
@@ -430,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
@@ -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).',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 \\
|
||||
|
||||
@@ -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)',
|
||||
|
||||
+449
-25
@@ -1,30 +1,35 @@
|
||||
/**
|
||||
* gbrain transcripts — Recent raw conversation transcripts.
|
||||
* gbrain transcripts — session transcripts: recent corpus reads and the
|
||||
* cathedral-4 import lane.
|
||||
*
|
||||
* Local-only: this command reads `.txt` files from the dream-cycle corpus
|
||||
* directories. It exists as a CLI surface so humans can trigger the same
|
||||
* read path the v0.29 `get_recent_transcripts` MCP op uses (which is itself
|
||||
* gated on remote=false; subagents and MCP/HTTP callers cannot reach it).
|
||||
* gbrain transcripts recent — dream-corpus .txt reader (v0.29 surface).
|
||||
* gbrain transcripts ingest — import dead session logs (Claude Code,
|
||||
* Codex, OpenClaw, Hermes) and consumer chat
|
||||
* exports (ChatGPT, Claude.ai) into
|
||||
* conversation pages. Local-only, explicit
|
||||
* paths are trusted CLI input; embedding is
|
||||
* OFF by default (bulk imports defer to the
|
||||
* embed backfill lane).
|
||||
*
|
||||
* Usage:
|
||||
* gbrain transcripts recent # last 7 days, summaries
|
||||
* gbrain transcripts recent --days 14
|
||||
* gbrain transcripts recent --full # full content (capped at 100KB/file)
|
||||
* gbrain transcripts recent --json
|
||||
* PGLite note: like every engine-opening command, ingest cannot run while
|
||||
* `gbrain serve` holds the single-writer lock — the lock error names the PID.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
|
||||
import type { TranscriptFormat } from '../core/transcripts/types.ts';
|
||||
import { runTranscriptsIngest, type TranscriptsIngestResult } from '../core/transcripts/ingest.ts';
|
||||
import { isOpenclawCheckpointFile } from '../core/transcripts/openclaw.ts';
|
||||
|
||||
interface RunOpts {
|
||||
interface RecentOpts {
|
||||
days?: number;
|
||||
full?: boolean;
|
||||
limit?: number;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): RunOpts | { help: true } {
|
||||
const opts: RunOpts = {};
|
||||
function parseRecentArgs(args: string[]): RecentOpts | { help: true } {
|
||||
const opts: RecentOpts = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--help' || a === '-h') return { help: true };
|
||||
@@ -44,31 +49,450 @@ function parseArgs(args: string[]): RunOpts | { help: true } {
|
||||
return opts;
|
||||
}
|
||||
|
||||
const HELP = `Usage: gbrain transcripts recent [options]
|
||||
const FORMATS: readonly TranscriptFormat[] = [
|
||||
'claude-code',
|
||||
'codex',
|
||||
'openclaw',
|
||||
'hermes',
|
||||
'chatgpt',
|
||||
'claude-export',
|
||||
];
|
||||
|
||||
Recent raw conversation transcripts (NOT polished reflections). Reads from
|
||||
the dream-cycle corpus dirs (dream.synthesize.session_corpus_dir and
|
||||
dream.synthesize.meeting_transcripts_dir).
|
||||
interface IngestCliOpts {
|
||||
paths: string[];
|
||||
format?: TranscriptFormat;
|
||||
dryRun?: boolean;
|
||||
limit?: number;
|
||||
since?: string;
|
||||
source?: string;
|
||||
facts?: boolean;
|
||||
maxCostUsd?: number;
|
||||
embed?: boolean;
|
||||
all?: boolean;
|
||||
json?: boolean;
|
||||
quiet?: boolean;
|
||||
}
|
||||
|
||||
Options:
|
||||
--days N Window in days (default 7)
|
||||
--limit N Max transcripts (default 50)
|
||||
--full Return full content (default: ~300-char summary). Capped 100KB/file.
|
||||
--json JSON output for agents
|
||||
--help, -h Show this help
|
||||
function parseIngestArgs(args: string[]): IngestCliOpts | { help: true } | { error: string } {
|
||||
const opts: IngestCliOpts = { paths: [] };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--help' || a === '-h') return { help: true };
|
||||
if (a === '--json') { opts.json = true; continue; }
|
||||
if (a === '--quiet') { opts.quiet = true; continue; }
|
||||
if (a === '--dry-run') { opts.dryRun = true; continue; }
|
||||
if (a === '--embed') { opts.embed = true; continue; }
|
||||
if (a === '--facts') { opts.facts = true; continue; }
|
||||
if (a === '--all') { opts.all = true; continue; }
|
||||
if (a === '--format') {
|
||||
const v = args[++i] as TranscriptFormat | undefined;
|
||||
if (!v || !FORMATS.includes(v)) {
|
||||
return { error: `unknown format '${v ?? ''}' (expected one of: ${FORMATS.join(', ')})` };
|
||||
}
|
||||
opts.format = v;
|
||||
continue;
|
||||
}
|
||||
if (a === '--limit') {
|
||||
const n = parseInt(args[++i] ?? '', 10);
|
||||
if (!Number.isFinite(n) || n <= 0) return { error: 'limit must be a positive integer' };
|
||||
opts.limit = n;
|
||||
continue;
|
||||
}
|
||||
if (a === '--since') {
|
||||
const v = args[++i];
|
||||
if (!v) return { error: 'since needs an ISO timestamp or the word last' };
|
||||
if (v !== 'last') {
|
||||
// Validate + Z-normalize: the filter compares lexicographically
|
||||
// against Z-form ISO, so an offset-form or garbage value would
|
||||
// silently mis-filter (and a filtered-everything run would still
|
||||
// look clean).
|
||||
const d = new Date(v);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return { error: `since needs a parseable ISO timestamp or the word last (got '${v}')` };
|
||||
}
|
||||
opts.since = d.toISOString();
|
||||
continue;
|
||||
}
|
||||
opts.since = v;
|
||||
continue;
|
||||
}
|
||||
if (a === '--source-id' || a === '--source') {
|
||||
const v = args[++i];
|
||||
if (!v) return { error: 'source-id needs a value' };
|
||||
opts.source = v;
|
||||
continue;
|
||||
}
|
||||
if (a === '--max-cost-usd') {
|
||||
const n = parseFloat(args[++i] ?? '');
|
||||
if (!Number.isFinite(n) || n <= 0) return { error: 'max-cost-usd must be a positive number' };
|
||||
opts.maxCostUsd = n;
|
||||
continue;
|
||||
}
|
||||
if (a.startsWith('-')) return { error: `unknown flag ${a}` };
|
||||
opts.paths.push(a);
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
Note: dream-generated outputs (frontmatter dream_generated: true) are skipped.
|
||||
const HELP = `Usage:
|
||||
gbrain transcripts ingest <path-or-glob>... [options]
|
||||
gbrain transcripts ingest # discovery: show found session logs
|
||||
gbrain transcripts ingest --all # import everything discovered
|
||||
gbrain transcripts status # found vs imported gap table
|
||||
gbrain transcripts recent [options]
|
||||
|
||||
ingest — import dead session logs and chat exports as conversation pages
|
||||
(readable text-turn archive: user/assistant text only, secrets redacted,
|
||||
long sessions split into searchable parts). Re-runs are free (content-hash
|
||||
skip). Embedding is OFF by default; run the embed backfill later or opt in.
|
||||
|
||||
--all Import every session log discovered under the harness
|
||||
roots (claude/codex/openclaw projects + the hermes store)
|
||||
--format F claude-code | codex | openclaw | hermes | chatgpt |
|
||||
claude-export (auto-detected when omitted)
|
||||
--dry-run Parse + redact + report; writes nothing
|
||||
--limit N Max sessions this run
|
||||
--since T Only sessions newer than ISO time T; the word "last"
|
||||
resumes from the previous clean run
|
||||
--source-id S Target source (default: the canonical 6-tier resolution)
|
||||
--embed Embed pages at import (default: defer to embed backfill)
|
||||
--facts Extract facts from imported pages (budget-capped)
|
||||
--max-cost-usd F Facts budget cap (default 5)
|
||||
--json Machine-readable result
|
||||
--quiet Suppress the human summary
|
||||
|
||||
recent — read recent raw dream-corpus transcripts (.txt), newest first:
|
||||
--days N Window in days (default 7)
|
||||
--limit N Max transcripts (default 50)
|
||||
--full Full content, capped 100KB/file (default: short summary)
|
||||
--json JSON output for agents
|
||||
Dream-generated outputs (frontmatter dream_generated: true) are skipped.
|
||||
|
||||
Notes: consumer exports must be unzipped first (pass conversations.json).
|
||||
On PGLite, stop gbrain serve first (single-writer lock).
|
||||
`;
|
||||
|
||||
/** Extensions the importer understands; directory expansion filters to these. */
|
||||
const IMPORTABLE_EXTENSIONS = ['.jsonl', '.db', '.json'];
|
||||
|
||||
/**
|
||||
* Expand path-or-glob args. Directory specs filter to importable extensions —
|
||||
* without the filter, every stray file in a real directory (macOS Finder
|
||||
* metadata, editor backups, READMEs) becomes a permanent per-file error that
|
||||
* breaks cleanScan on every run, silently killing the since-last resume for
|
||||
* directory scopes. Checkpoint snapshots are never imported.
|
||||
*/
|
||||
async function expandPaths(specs: string[]): Promise<string[]> {
|
||||
const { statSync } = await import('node:fs');
|
||||
const out: string[] = [];
|
||||
for (const spec of specs) {
|
||||
let matched = false;
|
||||
try {
|
||||
if (statSync(spec).isFile()) {
|
||||
out.push(spec);
|
||||
continue;
|
||||
}
|
||||
if (statSync(spec).isDirectory()) {
|
||||
const glob = new Bun.Glob('**/*');
|
||||
for (const p of glob.scanSync({ cwd: spec, absolute: true, onlyFiles: true })) {
|
||||
if (IMPORTABLE_EXTENSIONS.some((ext) => p.endsWith(ext))) out.push(p);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Not a literal path — try as a glob below.
|
||||
}
|
||||
const glob = new Bun.Glob(spec);
|
||||
for (const p of glob.scanSync({ cwd: process.cwd(), absolute: true, onlyFiles: true })) {
|
||||
out.push(p);
|
||||
matched = true;
|
||||
}
|
||||
if (!matched && !out.includes(spec)) {
|
||||
// Keep the unmatched spec so the per-file error names it.
|
||||
out.push(spec);
|
||||
}
|
||||
}
|
||||
return [...new Set(out)].filter((p) => !isOpenclawCheckpointFile(p));
|
||||
}
|
||||
|
||||
function fmtSummary(r: TranscriptsIngestResult): string {
|
||||
const byHarness = new Map<string, number>();
|
||||
for (const f of r.files) {
|
||||
for (const s of f.sessions) {
|
||||
if (!s.error) byHarness.set(s.harness, (byHarness.get(s.harness) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const lines: string[] = [];
|
||||
const counts = [...byHarness.entries()].map(([h, n]) => `${h}: ${n}`).join(', ');
|
||||
lines.push(
|
||||
`sessions: ${r.sessionsImported} imported (${counts || 'none'}), ` +
|
||||
`${r.sessionsFiltered} filtered, ${r.sessionsErrored} errored, ${r.sessionsSeen} seen`,
|
||||
);
|
||||
lines.push(
|
||||
`pages: ${r.pages.imported} imported, ${r.pages.skipped} unchanged` +
|
||||
(r.pages.errored ? `, ${r.pages.errored} ERRORED` : '') +
|
||||
(r.pages.planned ? `, ${r.pages.planned} planned (dry run)` : '') +
|
||||
(r.partsDeleted ? `, ${r.partsDeleted} stale parts deleted` : ''),
|
||||
);
|
||||
if (r.redactions > 0) lines.push(`redactions: ${r.redactions} secrets/patterns redacted before write`);
|
||||
if (r.imperatives > 0) lines.push(`flagged: ${r.imperatives} agent-directed imperative(s) noted in frontmatter`);
|
||||
if (r.driftFiles > 0) {
|
||||
lines.push(
|
||||
`DRIFT WARNING: ${r.driftFiles} file(s) parsed to zero sessions — the host ` +
|
||||
`format may have changed; see the adapter SPEC_TARGET runbook`,
|
||||
);
|
||||
}
|
||||
for (const f of r.files) {
|
||||
if (f.error) lines.push(`error: ${f.path}: ${f.error}`);
|
||||
for (const s of f.sessions) {
|
||||
if (s.error) lines.push(`error: ${f.path} session ${s.sessionId}: ${s.error}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function runIngest(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const parsed = parseIngestArgs(args);
|
||||
if ('help' in parsed) {
|
||||
console.log(HELP);
|
||||
return;
|
||||
}
|
||||
if ('error' in parsed) {
|
||||
console.error(`gbrain transcripts ingest: ${parsed.error}`);
|
||||
setCliExitVerdict(2);
|
||||
return;
|
||||
}
|
||||
// The watermark fingerprint binds the USER-STATED spec, captured BEFORE
|
||||
// discovery expands it — binding expanded file lists would mint a new
|
||||
// fingerprint every time a harness writes a new session, so the all-lane
|
||||
// since-last would never resume. Specs are RESOLVED first: the same
|
||||
// relative spec from two different cwds names different scopes (must not
|
||||
// share a watermark), and equivalent spellings of one dir must not
|
||||
// fragment into separate watermarks.
|
||||
const { resolve } = await import('node:path');
|
||||
const { hostname } = await import('node:os');
|
||||
// The all-lane scope is THIS machine's harness roots, so the fingerprint
|
||||
// carries host + roots: checkpoints are DB-backed and shared across every
|
||||
// machine on the brain — a bare literal would let machine B inherit
|
||||
// machine A's watermark and silently skip local sessions it never scanned.
|
||||
const { harnessRoots } = await import('../core/transcripts/detect.ts');
|
||||
const checkpointSpec =
|
||||
parsed.paths.length === 0
|
||||
? ['--all-discovery', hostname(), ...harnessRoots().map((r) => r.root).sort()]
|
||||
: [...parsed.paths].map((p) => resolve(p)).sort();
|
||||
|
||||
// No paths: discovery. Without the all flag, show what WOULD be imported
|
||||
// and stop (a safe default for a command that can touch four harness
|
||||
// histories); with it, import the discovered set.
|
||||
if (parsed.paths.length === 0) {
|
||||
const { discoverTranscriptFiles } = await import('../core/transcripts/discover.ts');
|
||||
const discovered = discoverTranscriptFiles();
|
||||
if (discovered.length === 0) {
|
||||
console.log('discovery: no session logs found under the harness roots');
|
||||
return;
|
||||
}
|
||||
if (!parsed.all) {
|
||||
const byFormat = new Map<string, { n: number; bytes: number }>();
|
||||
for (const d of discovered) {
|
||||
const cur = byFormat.get(d.format) ?? { n: 0, bytes: 0 };
|
||||
cur.n++;
|
||||
cur.bytes += d.bytes;
|
||||
byFormat.set(d.format, cur);
|
||||
}
|
||||
console.log('discovery (nothing imported yet — add the all flag to import):');
|
||||
for (const [format, { n, bytes }] of byFormat) {
|
||||
console.log(` ${format.padEnd(12)} ${String(n).padStart(5)} file(s) ${(bytes / 1024 / 1024).toFixed(1)} MB`);
|
||||
}
|
||||
console.log(' tip: `gbrain transcripts status` shows found vs imported per harness');
|
||||
return;
|
||||
}
|
||||
parsed.paths = discovered.map((d) => d.path);
|
||||
}
|
||||
|
||||
// Source: the canonical 6-tier chain (capture.ts pattern) — one resolved
|
||||
// id threads import + raw-data + reconciliation + checkpoint fingerprint.
|
||||
let sourceId = 'default';
|
||||
try {
|
||||
const { resolveSourceWithTier } = await import('../core/source-resolver.ts');
|
||||
const r = await resolveSourceWithTier(engine, parsed.source ?? null);
|
||||
sourceId = r.source_id;
|
||||
} catch (e) {
|
||||
console.error(`gbrain transcripts ingest: ${e instanceof Error ? e.message : String(e)}`);
|
||||
setCliExitVerdict(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Active pack ONCE per command (never per file).
|
||||
let activePack: { page_types: ReadonlyArray<{ name: string; path_prefixes: ReadonlyArray<string> }> } | undefined;
|
||||
try {
|
||||
const { loadActivePack } = await import('../core/schema-pack/load-active.ts');
|
||||
const { loadConfig } = await import('../core/config.ts');
|
||||
const resolved = await loadActivePack({ cfg: loadConfig(), remote: false, sourceId });
|
||||
activePack = { page_types: resolved.manifest.page_types };
|
||||
} catch {
|
||||
activePack = undefined;
|
||||
}
|
||||
|
||||
const paths = await expandPaths(parsed.paths);
|
||||
if (paths.length === 0) {
|
||||
console.error('gbrain transcripts ingest: 0 files matched');
|
||||
return;
|
||||
}
|
||||
|
||||
// --since last → op-checkpoint watermark (speed convenience only; the
|
||||
// status gap table is the correctness surface). Fingerprint binds
|
||||
// source + pathspec + format + adapter version so a second source or a
|
||||
// different root never inherits this watermark.
|
||||
const { fingerprint, loadOpCheckpoint, recordCompleted } = await import('../core/op-checkpoint.ts');
|
||||
const { TRANSCRIPT_IMPORT_VERSION } = await import('../core/transcripts/render.ts');
|
||||
const checkpointKey = {
|
||||
op: 'transcripts-ingest',
|
||||
fingerprint: fingerprint({
|
||||
sourceId,
|
||||
pathspec: checkpointSpec,
|
||||
format: parsed.format ?? 'auto',
|
||||
version: TRANSCRIPT_IMPORT_VERSION,
|
||||
}),
|
||||
};
|
||||
let sinceIso = parsed.since;
|
||||
if (parsed.since === 'last') {
|
||||
sinceIso = undefined;
|
||||
const keys = await loadOpCheckpoint(engine, checkpointKey);
|
||||
for (const k of keys) {
|
||||
if (k.startsWith('since:')) {
|
||||
const v = k.slice('since:'.length);
|
||||
if (!sinceIso || v > sinceIso) sinceIso = v;
|
||||
}
|
||||
}
|
||||
if (!sinceIso && !parsed.quiet) {
|
||||
console.error('transcripts ingest: no previous clean run for this scope — full scan');
|
||||
}
|
||||
}
|
||||
|
||||
const { createProgress } = await import('../core/progress.ts');
|
||||
const { cliOptsToProgressOptions, getCliOptions } = await import('../core/cli-options.ts');
|
||||
const reporter = createProgress(cliOptsToProgressOptions(getCliOptions()));
|
||||
reporter.start('transcripts.ingest', paths.length);
|
||||
|
||||
let result: TranscriptsIngestResult;
|
||||
try {
|
||||
result = await runTranscriptsIngest(engine, {
|
||||
paths,
|
||||
format: parsed.format,
|
||||
dryRun: parsed.dryRun,
|
||||
limit: parsed.limit,
|
||||
sinceIso,
|
||||
sourceId,
|
||||
embed: parsed.embed,
|
||||
activePack,
|
||||
onFileDone: () => reporter.tick(),
|
||||
// Multi-session stores (one hermes state.db = thousands of sessions)
|
||||
// need liveness BETWEEN file ticks.
|
||||
onSession: (sessionId) => reporter.heartbeat(`session ${sessionId.slice(0, 12)}`),
|
||||
});
|
||||
} finally {
|
||||
reporter.finish();
|
||||
}
|
||||
|
||||
if (!parsed.embed && !parsed.dryRun && result.pages.imported > 0 && !parsed.quiet) {
|
||||
console.error(
|
||||
'note: pages imported without embeddings (default) — run the embed backfill ' +
|
||||
'or re-run with the embed flag to make them vector-searchable now',
|
||||
);
|
||||
}
|
||||
|
||||
// Watermark: advance ONLY on a clean, untruncated, non-dry scan — and only
|
||||
// when the run ATTESTED full coverage (no since bound, or since=last). An
|
||||
// explicit since run never scanned below its cutoff and must not vouch for
|
||||
// sessions there.
|
||||
const attestsCoverage = parsed.since === undefined || parsed.since === 'last';
|
||||
if (result.cleanScan && result.maxSessionTs && attestsCoverage) {
|
||||
await recordCompleted(engine, checkpointKey, [`since:${result.maxSessionTs}`]);
|
||||
}
|
||||
|
||||
// --facts: ONE extractor invocation over every touched slug (including
|
||||
// hash-skipped pages — the extractor's version-token gate dedupes work).
|
||||
let factsSummary: { pages: number; spentUsd?: number } | undefined;
|
||||
if (parsed.facts && !parsed.dryRun && result.slugsTouched.length > 0) {
|
||||
const { runIngestFacts } = await import('../core/transcripts/ingest-facts.ts');
|
||||
factsSummary = await runIngestFacts(engine, {
|
||||
sourceId,
|
||||
slugs: [...new Set(result.slugsTouched)],
|
||||
maxCostUsd: parsed.maxCostUsd,
|
||||
quiet: parsed.quiet,
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.json) {
|
||||
console.log(JSON.stringify({ ...result, facts: factsSummary ?? null, source_id: sourceId }, null, 2));
|
||||
} else if (!parsed.quiet) {
|
||||
console.log(fmtSummary(result));
|
||||
if (factsSummary) {
|
||||
console.log(
|
||||
`facts: extracted over ${factsSummary.pages} page(s)` +
|
||||
(factsSummary.spentUsd !== undefined ? `, ~$${factsSummary.spentUsd.toFixed(2)} spent` : ''),
|
||||
);
|
||||
}
|
||||
const firstImported = result.files.flatMap((f) => f.sessions).find((s) => !s.error && s.baseSlug);
|
||||
if (firstImported && !parsed.dryRun) {
|
||||
console.log(`try it: gbrain query "${firstImported.baseSlug.split('/').pop()}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const allFailed =
|
||||
result.files.length > 0 &&
|
||||
result.files.every((f) => f.error !== undefined || (f.drift && f.sessions.length === 0));
|
||||
if (allFailed) setCliExitVerdict(1);
|
||||
}
|
||||
|
||||
async function runStatus(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const json = args.includes('--json');
|
||||
let sourceId = 'default';
|
||||
try {
|
||||
const { resolveSourceWithTier } = await import('../core/source-resolver.ts');
|
||||
sourceId = (await resolveSourceWithTier(engine, null)).source_id;
|
||||
} catch {
|
||||
// Fall through with default — status is read-only.
|
||||
}
|
||||
const { buildStatusRows, discoverTranscriptFiles, indexImportedSessions } = await import(
|
||||
'../core/transcripts/discover.ts'
|
||||
);
|
||||
const rows = buildStatusRows(discoverTranscriptFiles(), await indexImportedSessions(engine, sourceId));
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ source_id: sourceId, rows }, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`transcripts status (source: ${sourceId})`);
|
||||
console.log(' harness found imported-sessions not-yet-imported');
|
||||
for (const r of rows) {
|
||||
const gap = r.gapFiles === null ? '(store-level; run ingest to see)' : String(r.gapFiles);
|
||||
console.log(
|
||||
` ${r.format.padEnd(12)} ${String(r.found).padStart(6)} ${String(r.importedSessions).padStart(12)} ${gap}`,
|
||||
);
|
||||
}
|
||||
const totalGap = rows.reduce((n, r) => n + (r.gapFiles ?? 0), 0);
|
||||
if (totalGap > 0) {
|
||||
console.log(` backfill: gbrain transcripts ingest --all (${totalGap} file(s) waiting)`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTranscripts(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const sub = args[0];
|
||||
if (sub === 'ingest') {
|
||||
await runIngest(engine, args.slice(1));
|
||||
return;
|
||||
}
|
||||
if (sub === 'status') {
|
||||
await runStatus(engine, args.slice(1));
|
||||
return;
|
||||
}
|
||||
if (sub !== 'recent') {
|
||||
console.log(HELP);
|
||||
if (sub && sub !== '--help' && sub !== '-h') setCliExitVerdict(2);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseArgs(args.slice(1));
|
||||
const parsed = parseRecentArgs(args.slice(1));
|
||||
if ('help' in parsed) {
|
||||
console.log(HELP);
|
||||
return;
|
||||
|
||||
+42
-4
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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=...`',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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-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', '--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', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd', '--budget-usd-answer', '--budget-usd-retrieval', '--by-type', '--by-type-floor', '--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', '--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'],
|
||||
'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', '--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'],
|
||||
'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'],
|
||||
@@ -108,8 +108,8 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'sync': ['--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--all-sources', '--allow-empty', '--asof', '--auto', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--by-mention', '--cached', '--catch-up', '--clone-dir', '--code', '--column', '--compile', '--concurrency', '--confirm-destructive', '--content-audit', '--count', '--depth', '--detach', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--git-path', '--hard-deadline', '--help', '--http', '--include-flagged', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--index-audit', '--infer-dates', '--interval', '--is-ancestor', '--json', '--kind', '--lang', '--lock', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-sources', '--max-usd', '--migrate-only', '--missing-path', '--model', '--multimodal', '--name', '--name-only', '--name-status', '--ner', '--nice', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-recurse-submodules', '--no-renames', '--no-schema-pack', '--no-verify', '--object-format', '--older-than', '--orphan', '--others', '--overwrite', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--prefix', '--priority', '--probe-pglite', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--run-id', '--save', '--scope', '--serial', '--short', '--show-toplevel', '--since', '--skills-dir', '--skip-failed', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--status', '--stdin', '--strategy', '--supabase', '--supersessions', '--surface', '--target', '--target-score', '--thin', '--timeout', '--to', '--token-ttl', '--top-k', '--type', '--url', '--url-managed', '--verbose', '--verify', '--watch', '--window', '--workers', '--yes'],
|
||||
'takes': ['--aliases', '--all', '--brain', '--bucket-size', '--by', '--claim', '--dir', '--domain', '--dry-run', '--evidence', '--expired', '--fast', '--federated', '--force', '--from-pages', '--help', '--holder', '--http', '--include-covered', '--include-null-signature', '--json', '--kind', '--limit', '--max-pages', '--no-embedding', '--no-extract', '--no-federated', '--outcome', '--path', '--pattern', '--pending', '--quality', '--refresh', '--repo', '--reset', '--resolve', '--restore-only', '--row', '--since', '--slugs', '--sort', '--source', '--source-id', '--stale', '--supersessions', '--surface', '--thin', '--token-ttl', '--unit', '--until', '--value', '--weight', '--who', '--yes'],
|
||||
'think': ['--aliases', '--all', '--anchor', '--brain', '--calibration-holder', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-usd', '--mcp-only', '--model', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--rounds', '--save', '--since', '--source', '--stale', '--supersessions', '--surface', '--take', '--thin', '--timeout', '--token-ttl', '--until', '--with-calibration'],
|
||||
'transcripts': ['--aliases', '--all', '--brain', '--days', '--full', '--help', '--include-null-signature', '--json', '--limit', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout'],
|
||||
'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'],
|
||||
};
|
||||
|
||||
@@ -1048,6 +1048,9 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
|
||||
'cycle.extract_atoms.budget_usd',
|
||||
'models.dream.patterns',
|
||||
'models.dream.synthesize_verdict',
|
||||
// #4152: preferred triage-model key (explicit pre-read in loadSynthConfig;
|
||||
// wins over models.dream.synthesize_verdict + dream.synthesize.verdict_model).
|
||||
'models.dream.triage',
|
||||
'models.drift',
|
||||
'models.auto_think',
|
||||
'models.think',
|
||||
@@ -1079,6 +1082,19 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
|
||||
'dream.synthesize.output_root',
|
||||
'dream.synthesize.subagent_timeout_ms',
|
||||
'dream.synthesize.subagent_wait_timeout_ms',
|
||||
// #4152 two-stage cascade: subagent turn budget (default 16) + opt-in
|
||||
// per-source daily submission cap (default 0 = disabled; 200 recommended
|
||||
// for busy deployments).
|
||||
'dream.synthesize.max_turns',
|
||||
'dream.synthesize.max_submissions_per_source_per_day',
|
||||
// #4152 triage knobs. The triage model's preferred key is
|
||||
// `models.dream.triage` (models.* prefix, registered via the models.dream.*
|
||||
// family); these tune the gate + sampling + pass budget.
|
||||
'dream.triage.threshold',
|
||||
'dream.triage.max_chars',
|
||||
'dream.triage.max_tokens',
|
||||
'dream.triage.max_ms',
|
||||
'dream.triage.concurrency',
|
||||
'dream.patterns.lookback_days',
|
||||
'dream.patterns.min_evidence',
|
||||
// #2782-family: patterns-phase subagent timeouts (mirror of the
|
||||
@@ -1140,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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user