mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-16 18:02:30 +00:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5765171d3 | ||
|
|
cc3e284316 | ||
|
|
9e678f467d | ||
|
|
0c78213d92 | ||
|
|
e38e4a845b | ||
|
|
526595ce01 | ||
|
|
2d8c772171 | ||
|
|
bdef2dada5 | ||
|
|
cc902834d5 | ||
|
|
eaca6a94d8 | ||
|
|
7df02405bc | ||
|
|
bf0e8795f8 | ||
|
|
a534240d2c | ||
|
|
6db8dc8a34 | ||
|
|
750275533f | ||
|
|
17e36adb57 | ||
|
|
519c3748da | ||
|
|
fcbf547841 | ||
|
|
422438bfe7 | ||
|
|
ccc9f171c0 | ||
|
|
a90547e6fe | ||
|
|
56a272f18d | ||
|
|
40c5774fa8 | ||
|
|
71fb120514 | ||
|
|
3577432456 | ||
|
|
39687cb7fe | ||
|
|
6d25dd0711 | ||
|
|
4922905fb9 | ||
|
|
3ebda1fc87 | ||
|
|
1a06fc100a | ||
|
|
180c648ba0 | ||
|
|
181fc8c1a2 | ||
|
|
4deee227be |
@@ -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.
|
||||
|
||||
@@ -18,6 +18,11 @@ on:
|
||||
# label so we don't fan out on unrelated label changes.
|
||||
types: [labeled, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_grok_door:
|
||||
description: 'Run the grok-door job (pre-secret posture: label or this input only)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -104,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 /
|
||||
@@ -122,11 +128,23 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
# Open the hermes opt-in door here so binary/auth absence — not the
|
||||
# opt-in var — is what skips (same posture as the claude/codex doors).
|
||||
# Open the hermes/grok opt-in doors here so binary/auth absence — not
|
||||
# the opt-in var — is what skips (same posture as the claude/codex
|
||||
# doors). The grok door's keyless tier additionally self-skips without
|
||||
# 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:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
@@ -141,7 +159,9 @@ jobs:
|
||||
for f in \
|
||||
test/e2e/bootstrap-real-claude.serial.test.ts \
|
||||
test/e2e/bootstrap-real-codex.serial.test.ts \
|
||||
test/e2e/install-real-hermes.serial.test.ts; do
|
||||
test/e2e/install-real-hermes.serial.test.ts \
|
||||
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
|
||||
@@ -180,10 +200,12 @@ 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
@@ -277,11 +299,13 @@ jobs:
|
||||
EXIT=0
|
||||
bun test --timeout=600000 test/e2e/install-real-hermes.serial.test.ts > door.txt 2>&1 || EXIT=$?
|
||||
tail -40 door.txt
|
||||
# Preserve the FULL bun output UNCONDITIONALLY (upload stays
|
||||
# failure-gated): the zero-pass failure class below exits with the
|
||||
# summary as its only trace, and bun prints failure details before
|
||||
# the summary, so the 40-line tail can drop exactly the lines a
|
||||
# paid-CI triage needs.
|
||||
cp door.txt "$GBRAIN_E2E_EVIDENCE_DIR/" 2>/dev/null || true
|
||||
if [ "$EXIT" -ne 0 ]; then
|
||||
# Preserve the FULL bun output for the failure artifact — bun
|
||||
# prints failure details before the summary, so the 40-line tail
|
||||
# above can drop exactly the lines a paid-CI triage needs.
|
||||
cp door.txt "$GBRAIN_E2E_EVIDENCE_DIR/" 2>/dev/null || true
|
||||
exit "$EXIT"
|
||||
fi
|
||||
# This job provisions the binary + auth above, so the door must
|
||||
@@ -336,3 +360,508 @@ jobs:
|
||||
- name: Remove hermes credentials (unconditional)
|
||||
if: always()
|
||||
run: rm -f ~/.hermes/.env
|
||||
|
||||
# Grok door e2e (xAI Grok Build): PROVISIONS the real grok binary via the
|
||||
# pinned npm package (registry integrity verified — stronger than the
|
||||
# curl-installer path; both pins live in docs/mcp/GROK-CLI-PIN.md, enforced
|
||||
# against this file by scripts/check-grok-pin.sh in `bun run verify`).
|
||||
# KEYLESS-FIRST ordering (deliberate divergence from hermes-door): grok's
|
||||
# mcp add/list/doctor run keyless, so the compat tier runs and banks its
|
||||
# coverage BEFORE the secret precondition — a missing XAI_API_KEY still
|
||||
# fails this job loudly, but only after the free tier proved the install
|
||||
# surface, so pre-secret runs are diagnostic instead of pure red.
|
||||
#
|
||||
# PRE-SECRET GATING POSTURE: `real-agent-e2e` label or the run_grok_door
|
||||
# dispatch input ONLY — deliberately NOT `schedule` and NOT the generic
|
||||
# `heavy-tests` label, so an absent XAI_API_KEY secret cannot paint nightly
|
||||
# heavy runs (or unrelated heavy-labeled PRs) red. The commit that lands
|
||||
# AFTER an admin creates the XAI_API_KEY secret (an external prerequisite,
|
||||
# not a code change) re-adds: the schedule leg, the heavy-tests label leg,
|
||||
# a default-on dispatch, and a latest-version canary matrix leg
|
||||
# (continue-on-error, schedule-scoped, own timeout) so the pinned lane
|
||||
# stays deterministic while the canary tracks what users actually run.
|
||||
grok-door:
|
||||
name: Grok door e2e (real binary, keyless-first)
|
||||
if: |
|
||||
(github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'real-agent-e2e')) ||
|
||||
(github.event_name == 'workflow_dispatch' &&
|
||||
inputs.run_grok_door == true)
|
||||
runs-on: ubuntu-latest
|
||||
# Measured local door wall-time: keyless tier ~29s + one-time compiled
|
||||
# gbrain build (~2-4 min) + npm install (~10s); paid SMOKE turn budget
|
||||
# 2 x 240s. 20 min = measured + >50% headroom (GROK-CLI-PIN.md).
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
# Pin values documented in docs/mcp/GROK-CLI-PIN.md — update them
|
||||
# together, deliberately, after reviewing upstream changes
|
||||
# (scripts/check-grok-pin.sh fails `bun run verify` on drift).
|
||||
GROK_VERSION: "1.0.4"
|
||||
GROK_NPM_PACKAGE: "@xai-official/grok"
|
||||
GROK_NPM_INTEGRITY: "sha512-Nu3SFXTqwvCQr/LQFwrQYgngJhUQwX2h9ZSgzW4HowidjbPBWtMVO0xI88d2z6/zlDSNaT5YP/uk+2DthKQMsg=="
|
||||
# Per-platform payload pins: the wrapper's integrity covers only the
|
||||
# wrapper tarball; the binary that EXECUTES is the platform sub-package.
|
||||
GROK_NPM_LINUX_X64_INTEGRITY: "sha512-Dan2LfKcFBiabuDGHaGgMT8Ndzibo2ljvSjh4MlpV5117JL+S/0KMbdyYpk+13d7t+4znniW1cm+rRwUGSAvtw=="
|
||||
GROK_NPM_LINUX_ARM64_INTEGRITY: "sha512-zGK42Eq3ZmIa7cSVnl6CiJ4cxTCMsNLQCmCoLJhy5eZXfAvZ1DA3K3HXmKCj4OScX8SalYlp7mx8HWl9Y6gytw=="
|
||||
GBRAIN_REAL_GROK_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/grok-door-evidence" >> "$GITHUB_ENV"
|
||||
mkdir -p "$RUNNER_TEMP/grok-door-evidence"
|
||||
|
||||
# Compile gbrain ONCE for both bun test invocations below —
|
||||
# ensureCompiledGbrain's cache is per-process, so without this the
|
||||
# keyless and paid runs each pay the 2-4 min compile.
|
||||
- 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: the npm registry verifies the per-platform
|
||||
# payload against its integrity metadata; the pre-check pins that the
|
||||
# registry still serves the SAME integrity we observed (a re-published
|
||||
# 1.0.4 becomes a loud re-pin decision, not silently different code
|
||||
# running next to secrets in later steps). Version assert lives here
|
||||
# too — before any secret-bearing step.
|
||||
- name: Install grok (pinned npm package)
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
served=$(npm view "$GROK_NPM_PACKAGE@$GROK_VERSION" dist.integrity 2>/dev/null || true)
|
||||
if [ "$served" != "$GROK_NPM_INTEGRITY" ]; then
|
||||
echo "::error::grok npm integrity drift for $GROK_NPM_PACKAGE@$GROK_VERSION — registry serves '$served', pinned '$GROK_NPM_INTEGRITY'. Re-pin deliberately: update the stamps in docs/mcp/GROK-CLI-PIN.md + this workflow after reviewing upstream (see the pin doc's re-observation checklist)." >&2
|
||||
exit 1
|
||||
fi
|
||||
# The platform sub-package is the binary that actually runs — pin it
|
||||
# too (per-arch; ubuntu-latest is x64 today, arm64 pinned for a
|
||||
# future runner switch).
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64) plat_pkg="$GROK_NPM_PACKAGE-linux-x64"; plat_pin="$GROK_NPM_LINUX_X64_INTEGRITY" ;;
|
||||
aarch64|arm64) plat_pkg="$GROK_NPM_PACKAGE-linux-arm64"; plat_pin="$GROK_NPM_LINUX_ARM64_INTEGRITY" ;;
|
||||
*) echo "::error::unsupported runner arch for the grok payload pin: $arch" >&2; exit 1 ;;
|
||||
esac
|
||||
plat_served=$(npm view "$plat_pkg@$GROK_VERSION" dist.integrity 2>/dev/null || true)
|
||||
if [ "$plat_served" != "$plat_pin" ]; then
|
||||
echo "::error::grok platform payload integrity drift for $plat_pkg@$GROK_VERSION — registry serves '$plat_served', pinned '$plat_pin'. Re-pin deliberately (GROK-CLI-PIN.md stamps + this workflow)." >&2
|
||||
exit 1
|
||||
fi
|
||||
npm install -g "$GROK_NPM_PACKAGE@$GROK_VERSION"
|
||||
if ! command -v grok >/dev/null 2>&1; then
|
||||
echo "::error::grok did not resolve on PATH after npm install" >&2
|
||||
exit 1
|
||||
fi
|
||||
version_output=$(grok --version)
|
||||
echo "$version_output"
|
||||
# Observed shape: `grok 1.0.4 (buildhash)` (GROK-CLI-PIN.md).
|
||||
if ! printf '%s' "$version_output" | grep -qF "grok $GROK_VERSION"; then
|
||||
echo "::error::grok version drift — expected 'grok $GROK_VERSION' in: $version_output (see docs/mcp/GROK-CLI-PIN.md triage table)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# KEYLESS TIER FIRST — banks the free compat coverage (documented-shape
|
||||
# registration, TOML schema, doctor handshake proving the seven-verb
|
||||
# surface) before anything needs the secret. XAI_API_KEY is absent from
|
||||
# this step by construction, so the paid describe self-skips.
|
||||
- name: Run grok door tests (keyless tier)
|
||||
run: |
|
||||
EXIT=0
|
||||
bun test --timeout=600000 test/e2e/install-real-grok.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: 4 keyless tests pass, the
|
||||
# 1 paid test skips. Zero-pass or 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 4 ]; then
|
||||
echo "::error::grok door keyless tier expected 4 passing tests, summary shows '${pass_count:-none}' — refusing to go green (see docs/mcp/GROK-CLI-PIN.md triage table)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Preconditions (secret present)
|
||||
env:
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
run: |
|
||||
if [ -z "$XAI_API_KEY" ]; then
|
||||
echo "::error::XAI_API_KEY secret is empty — the keyless tier above already ran (its coverage is banked); the paid SMOKE needs the secret. Admin: create the XAI_API_KEY repo/environment secret (console.x.ai), then re-run. Fork PRs get no secrets from GitHub." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Named bad-key preflight: key-rot fails HERE, at a step named for it,
|
||||
# instead of surfacing as a confusing SMOKE failure (GROK-CLI-PIN.md
|
||||
# triage table). One minimal paid probe.
|
||||
- name: Auth preflight (bad-key tripwire)
|
||||
env:
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
run: |
|
||||
export GROK_HOME="$RUNNER_TEMP/grok-preflight-home"
|
||||
mkdir -p "$GROK_HOME"
|
||||
# Verbatim kill-switch homes (update together): seedGrokConfig in
|
||||
# test/helpers/agent-harness.ts and scenarioGrokInstall in
|
||||
# scripts/dx-explore.ts.
|
||||
printf '[cli]\nauto_update = false\n' > "$GROK_HOME/config.toml"
|
||||
# This step runs the third-party agent binary directly: never hand
|
||||
# it the WRITABLE step-metadata files (appending to GITHUB_ENV/PATH
|
||||
# poisons the later secret-bearing steps — the same channel
|
||||
# grokChildEnv scrubs for test-spawned children), and kill its web
|
||||
# tools like the door SMOKE does.
|
||||
out=$(env -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_OUTPUT -u GITHUB_STATE -u GITHUB_STEP_SUMMARY \
|
||||
grok -p "reply with exactly: PREFLIGHT-OK" --output-format plain --disable-web-search 2>&1) || {
|
||||
echo "::error::grok auth preflight failed — the XAI_API_KEY secret is present but rejected (rotate it at console.x.ai; see GROK-CLI-PIN.md triage table). Output: ${out:0:300}" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "auth preflight ok"
|
||||
|
||||
- name: Run grok door tests (full — paid SMOKE included)
|
||||
env:
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
run: |
|
||||
EXIT=0
|
||||
bun test --timeout=600000 test/e2e/install-real-grok.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-grok.serial.test.ts (change together).
|
||||
if grep -q 'SKIP paid tier' door.txt; then
|
||||
echo "::error::grok door paid tier skipped despite a present XAI_API_KEY — hasGrokAuth() 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 5 ]; then
|
||||
echo "::error::grok door full run expected 5 passing tests (6 once the JSON tool-call test lands), summary shows '${pass_count:-none}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Auto-update tripwire: the seeded [cli] auto_update=false is the whole
|
||||
# kill-switch (no env form observed) — 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 grok >/dev/null 2>&1; then
|
||||
version_output=$(grok --version || true)
|
||||
if ! printf '%s' "$version_output" | grep -qF "grok $GROK_VERSION"; then
|
||||
echo "::error::grok version moved mid-job — auto-update kill-switch failed (expected 'grok $GROK_VERSION', got: $version_output)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Scrub credentials from evidence (defensive)
|
||||
if: failure() && env.GBRAIN_E2E_EVIDENCE_DIR != ''
|
||||
env:
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
run: |
|
||||
# Same triple as hermes-door: filenames, symlinks, content. Auth is
|
||||
# env-only here (the job never writes the key to disk — stronger
|
||||
# than the hermes .env posture), so the content grep is the layer
|
||||
# that matters for grok-written logs on the failure path.
|
||||
find "$GBRAIN_E2E_EVIDENCE_DIR" -type f \( -name '.env' -o -name '*.env' -o -name 'mcp_credentials.json' \) -exec rm -f {} + 2>/dev/null || true
|
||||
find "$GBRAIN_E2E_EVIDENCE_DIR" -type l -delete 2>/dev/null || true
|
||||
if [ -n "$XAI_API_KEY" ]; then
|
||||
grep -rlF "$XAI_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 grok door evidence
|
||||
if: failure() && env.GBRAIN_E2E_EVIDENCE_DIR != ''
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: grok-door-evidence
|
||||
path: ${{ env.GBRAIN_E2E_EVIDENCE_DIR }}
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Auth travels env-only, but grok MAY persist derived credentials after
|
||||
# an authed turn (the inventory is pending — GROK-CLI-PIN.md); remove
|
||||
# the known candidate unconditionally so nothing outlives the job even
|
||||
# on a future self-hosted runner.
|
||||
- name: Remove grok credentials (unconditional)
|
||||
if: always()
|
||||
run: |
|
||||
rm -f ~/.grok/mcp_credentials.json
|
||||
rm -rf "$RUNNER_TEMP/grok-preflight-home"
|
||||
# Cancellation/timeout bypasses the suite's afterAll cleanup — the
|
||||
# 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.17.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
|
||||
|
||||
+368
@@ -2,6 +2,374 @@
|
||||
|
||||
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.
|
||||
|
||||
### Added
|
||||
|
||||
- **Grok Build install support.** `grok mcp add gbrain -- gbrain serve --surface verbs` wires the seven-verb memory surface into xAI's coding agent; [docs/mcp/GROK.md](docs/mcp/GROK.md) carries the full guide — registration, direct TOML config, the trust-gated vendor-config fallback (an existing Claude Code registration may already work), verification via `grok mcp doctor` (the honest probe: the add itself is lazy and always exits 0), headless auth, model pinning, auto-update pinning for reproducible environments, cron pairing, and troubleshooting (including the colliding community `grok` binary and where Grok actually discovers skills). `INSTALL_FOR_AGENTS.md` gains the matching "If you are Grok Build" block; the guide is honest that this is the brain-only install — the `gbrain bootstrap` personal-agent path doesn't support Grok yet.
|
||||
- **`gbrain claw-test --live --agent grok`.** Grok is the third registered agent runner, so guide-following friction runs and `gbrain friction diff --base <hermes-run> --compare <grok-run>` work out of the box. The runner records a version preamble in every transcript (a mis-bound community binary is diagnosable after the fact) and warns loudly when the operator's `~/.claude.json` registers gbrain — Grok reads vendor MCP configs for trusted folders, a contamination channel no other supported agent has.
|
||||
- **A real-binary "door" e2e for Grok** (`test/e2e/install-real-grok.serial.test.ts`), split-gated so the free tier needs no API key: version pin, the documented registration shape end-to-end, the seven-verb handshake proven keyless, a vendor-fallback provenance guard, and the direct-TOML surface all run with just the binary; the paid recall smoke (a per-run nonce fact, web search disabled) additionally needs `XAI_API_KEY`. A label-gated `grok-door` CI job provisions the pinned npm package (wrapper AND per-platform payload integrities pre-checked against the pin doc), banks the keyless coverage before any secret is required, and refuses green on a silently-skipped paid tier.
|
||||
- **`docs/mcp/GROK-CLI-PIN.md`** — the observed-behavior pin (config schema verbatim, lazy-add semantics, honest doctor discriminator, keyless TUI sign-in behavior, volatile-path inventory, supported-version policy) with machine-readable stamps enforced against the CI workflow by a new `check-grok-pin` verify guard, which fails closed if the pin doc ever disappears out from under the door job.
|
||||
- **A `grok-install` DX scenario** (`scripts/dx-explore.ts`) drives the REAL interactive Grok TUI through the brain-only install under a PTY, with a sign-in-wall early-stop so keyless runs record the friction in seconds instead of pasting into a login screen for the full wall clock.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Agent runners share their detection/env plumbing.** The byte-identical binary-resolution and env-filtering bodies moved out of the per-agent runners into `agent-runner.ts` (`detectBinary`/`filterAllowlistEnv`), shrinking every future runner; binary resolution never passes through a shell anymore. The live-lane Grok runner forwards only Grok's own credentials — the operator's Anthropic/OpenAI keys never reach a third-party binary.
|
||||
- **PTY transcripts are structurally redacted at every write site.** Provider-key values are replaced in every artifact — including the live screen mirror that outlives interrupted runs — with a hard-failing independent check behind the redaction; a secret split across output bursts can no longer be reassembled from the frame log. `--keyless` now genuinely drops provider keys in all install scenarios, the leak check never touches files that predate the run, and the PTY hot loops strip bounded windows instead of the whole buffer (repaint-heavy TUIs were making every poll quadratic).
|
||||
- **The hermes-door CI job got the same hardening sweep:** checkout token persistence off, and the door's full test output is preserved as evidence even for the failure class that previously left no trace.
|
||||
|
||||
To take advantage of v0.45.20.0: nothing changes for existing installs — this release adds a client, it doesn't modify brain behavior. Grok Build users: follow [docs/mcp/GROK.md](docs/mcp/GROK.md) (two commands: register, then `grok mcp doctor gbrain` to verify the seven-verb handshake). Maintainers enabling the paid CI lane: create the `XAI_API_KEY` repo secret, then land the follow-up that adds the schedule and canary legs.
|
||||
## [0.45.19.0] - 2026-08-15
|
||||
|
||||
**The interactive `gbrain init` pickers are now tested on a real terminal — and the repo carries one PTY layer instead of two.** The first thing every new user touches (the embedding provider picker and the search-mode picker) previously had no true-TTY coverage; the comments that claimed otherwise pointed at a harness nothing ever called.
|
||||
|
||||
### Added
|
||||
- A real-PTY test for the interactive init flow (`test/init-picker-pty.serial.test.ts`). It drives both pickers under a true pseudo-terminal, proves typed input actually lands (non-default selections plus bounded response times, so dead input can never pass through the pickers' silent defaults), covers the Ctrl-D/EOF fallback, and runs in the required serial CI lane. Fully hermetic: temp-root home, no provider keys visible, child process reaped even on failed assertions.
|
||||
|
||||
### Removed
|
||||
- The unused second PTY harness (`test/helpers/cli-pty-runner.ts` plus its self-test, ~657 lines). Its spawn path never gained a caller across ~20 minor versions. The real-terminal layer is consolidated on `test/helpers/tty-harness.ts` (Bun's built-in `terminal:` spawn — no native modules).
|
||||
|
||||
### Fixed
|
||||
- Three test-file comments that claimed interactive-picker coverage existed where it didn't — two deferred to each other in a circle, one described a piped-stdin test as PTY-based. All three now point at the real coverage.
|
||||
|
||||
### Changed
|
||||
- `docs/TESTING.md` gains a four-tier decision table for TTY and interactive-CLI testing (injected `isTTY` → piped stdin → real-PTY serial test → DX-exploration instrument), including the rule that CI-required interactive tests live in the serial lane. `docs/architecture/KEY_FILES.md` now describes the surviving harness accurately, and related project docs and the stale shard-weight entry were trued up to match.
|
||||
|
||||
## [0.45.18.0] - 2026-08-15
|
||||
|
||||
**Today's agent spend now reads correctly at every hour, in every timezone.** The admin spend endpoint computed "today" against a naive timestamp that each database session reinterpreted in its own timezone — on any non-UTC session (a PGLite brain following the host clock, a timezone-configured Postgres role), the day boundary shifted by the offset and every evening's spend silently underreported as 0. The boundary is now a UTC instant, independent of session timezone, pinned by a regression test that exercises sessions 12 hours either side of UTC at any wall-clock hour.
|
||||
|
||||
The same class also made the new test-suite snapshot fixture time-of-day flaky: the snapshot bakes the build machine's timezone into the restored cluster, so snapshot-restored engines ran sessions in the builder's zone while cold-init engines followed the running process. Restored engines now re-pin their session to the runtime zone (existing tarballs heal without a rebuild), the snapshot builder pins UTC so tarballs are deterministic across hosts, and a parity test asserts cold and snapshot engines agree on their UTC offset.
|
||||
|
||||
### Fixed
|
||||
- `/admin/api/agents/spend`: `spent_cents_today` no longer underreports on non-UTC sessions (UTC-instant day boundary).
|
||||
- Snapshot-restored PGLite engines behave identically to cold-init engines regardless of the machine that built the tarball.
|
||||
|
||||
## [0.45.17.0] - 2026-08-15
|
||||
|
||||
**A test run can no longer silently touch a real brain.** `gbrain init` writes your
|
||||
|
||||
+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)
|
||||
|
||||
+39
-8
@@ -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
|
||||
|
||||
@@ -227,6 +230,34 @@ Keep `--args` last (everything after it becomes server argv) and verify with
|
||||
`hermes mcp test gbrain` — the add exits 0 even on failure. Full reference:
|
||||
[docs/mcp/HERMES.md](docs/mcp/HERMES.md).
|
||||
|
||||
**If you are Grok Build** (xAI's `grok` CLI): register gbrain as your MCP server:
|
||||
|
||||
```bash
|
||||
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, 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
|
||||
|
||||
@@ -173,6 +173,8 @@ GBrain exposes nearly all of its 100+ operations as MCP tools (stdio and HTTP; a
|
||||
- **[Codex](docs/mcp/CODEX.md)** — `gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex` (or `--install`). Codex reads the bearer from `$GBRAIN_REMOTE_TOKEN` at runtime, so the token never lands in Codex config.
|
||||
- **[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.
|
||||
@@ -234,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).
|
||||
@@ -291,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:
|
||||
|
||||
@@ -329,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.
|
||||
|
||||
@@ -348,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
|
||||
@@ -494,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`.
|
||||
@@ -4267,7 +4404,7 @@ purpose; needs baseline-governance care per the BrainBench gate rules.
|
||||
### PTY-mode transcript capture
|
||||
**Priority:** P3
|
||||
|
||||
**What:** `transcript-capture.ts` currently uses plain `child_process.spawn` pipes. Some agents only emit ANSI colors / progress UI on a TTY. v1.1 adds a PTY mode (likely via `node-pty`) so live-mode transcripts capture the full agent UX.
|
||||
**What:** `transcript-capture.ts` currently uses plain `child_process.spawn` pipes. Some agents only emit ANSI colors / progress UI on a TTY. v1.1 adds a PTY mode so live-mode transcripts capture the full agent UX. Do NOT add node-pty for this: Bun's built-in `terminal:` spawn option (Bun 1.3.10+, already pinned in engines) is the dependency-free path, proven by `test/helpers/tty-harness.ts` — reuse `launchTty` or its spawn shape.
|
||||
|
||||
**Why:** Faithful transcripts make the friction → reasoning link more useful. v1 accepts that some agent UI is lost.
|
||||
|
||||
@@ -4275,6 +4412,28 @@ purpose; needs baseline-governance care per the BrainBench gate rules.
|
||||
|
||||
---
|
||||
|
||||
### Non-tier-1 e2e files run in no required CI lane
|
||||
**Priority:** P2
|
||||
|
||||
**What:** Unit shards exclude `test/e2e/*` (`scripts/test-shard.sh`), and `.github/workflows/e2e.yml` runs only explicitly named files (a handful across its jobs — e.g. `test/e2e/mechanical.test.ts`, `test/e2e/mcp.test.ts`, the jsonb-parity pair); there is no glob. Every other `test/e2e/*.test.ts` — including PGLite-only files that need no `DATABASE_URL`, like `init-fresh-pglite.test.ts` — executes only when someone runs `bun run test:e2e` by hand. Decide per file: wire into a required workflow, re-home PGLite-only files to the serial lane (the pattern `test/init-picker-pty.serial.test.ts` uses), or explicitly document them as manual-only.
|
||||
|
||||
**Why:** Tests that never run in required CI are silent coverage loss — they rot without failing. Surfaced by the TTY-harness cleanup review when the new PTY picker test almost landed in the same dead lane.
|
||||
|
||||
**Effort:** S-M (CC ~30-60m for the audit + re-homing; workflow wiring adds CI-minutes cost per file).
|
||||
|
||||
---
|
||||
|
||||
### Ctrl-D during `gbrain init` stalls 60s at the next prompt (readLineSafe does not latch EOF)
|
||||
**Priority:** P2
|
||||
|
||||
**What:** Pressing Ctrl-D at the interactive provider picker is detected immediately (keyless fallback in ~200ms), but Bun's stdin never yields another line after EOF while `isTTY` stays true — so the SUBSEQUENT search-mode picker sits its full 60s `readLineSafe` fallback before init completes (probed under a real PTY: keyless notice at 0.2s, mode prompt rendered at 1.2s, exit at 61.1s). Fix: `readLineSafe` (src/commands/init.ts) should latch EOF — once stdin has ended, later calls return their default immediately instead of waiting out the timer. Regression test: extend the EOF case in `test/init-picker-pty.serial.test.ts` to run init to completion and assert exit well under the fallback window (the case currently closes early on purpose to keep the 60s stall out of required CI — see the comment there).
|
||||
|
||||
**Why:** A user who hits Ctrl-D at the first prompt stares at a frozen screen for a full minute before init finishes. Cross-model adversarial review finding (Codex), confirmed by a real-PTY probe.
|
||||
|
||||
**Effort:** S (CC ~20m: EOF latch + regression-test extension).
|
||||
|
||||
---
|
||||
|
||||
### Read-side host-isolation (`$GBRAIN_HOST_HOME`)
|
||||
**Priority:** P3
|
||||
|
||||
@@ -5830,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
|
||||
@@ -5892,3 +6055,253 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts`
|
||||
subsystem that deserves its own eng + CEO review, not a rider. The currency work
|
||||
(`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
|
||||
creates the `XAI_API_KEY` repo/environment secret (console.x.ai; prefer a
|
||||
protected GitHub Environment scoped to door jobs), then one commit re-adds
|
||||
to `grok-door` in heavy-tests.yml: the `schedule` leg, the `heavy-tests`
|
||||
label leg, default-on dispatch, and a latest-version CANARY matrix leg
|
||||
(`continue-on-error`, schedule-scoped, own timeout) so the pinned lane stays
|
||||
deterministic while the canary tracks what users run. Same session: run the
|
||||
pending-auth Phase-0 observations (paid one-shot smoke, authed model list +
|
||||
measured per-turn cost pins, credential-file inventory after login → door
|
||||
evidence exclusions + TTY secretPaths, authed first-run TUI copy) into
|
||||
`docs/mcp/GROK-CLI-PIN.md`, and pin `parseGrokJson` + the separate
|
||||
non-retried JSON toolCall door test (one extra paid turn) once the
|
||||
streaming-json event shape is observed. Effort: S (CC ~30min + admin).
|
||||
- [ ] **P2 — `gbrain connect --agent grok`.** One-command install UX:
|
||||
`AgentId`/`AGENT_SPECS`/`AGENT_IDS` in `src/commands/connect.ts`, a
|
||||
`buildGrokMcpAddArgv` in `src/core/mcp-registration.ts` (shape already
|
||||
pinned in GROK-CLI-PIN.md), connect tests ("all four agents" pin moves to
|
||||
five), KEY_FILES entry. Deferred from the grok wave to avoid a second
|
||||
observation pass; the pin doc now exists, so this is mechanical. Effort: S.
|
||||
- [ ] **P2 — HERMES.md surface refresh.** The hermes register command predates
|
||||
the truthful-surface wave and wires the full 100+-op catalog;
|
||||
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.
|
||||
- [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.
|
||||
- [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
|
||||
repo's first gbrain-driving PTY assertion test — keep it an instrument
|
||||
until the copy proves stable. Effort: M.
|
||||
- [ ] **P3 — Nightly cross-agent friction-diff artifact.** After door runs,
|
||||
`gbrain friction diff --base <hermes-run> --compare <grok-run>` rendered
|
||||
into a CI artifact so guide-following friction regressions surface without
|
||||
a dev-box session. Effort: S/M.
|
||||
- [ ] **P3 — `xai:` provider block in model-pricing.ts.** Grok models
|
||||
(grok-4.6/4.5 observed) for cost views once xAI pricing is sourced;
|
||||
separate concern from the harness wave (CANONICAL_PRICING discipline).
|
||||
Effort: S.
|
||||
- [ ] **P3 — BrainBench grok adapter.** `src/eval/brainbench/adapters/` +
|
||||
`ALL_HARNESSES` entry — same seam as the already-filed hermes adapter
|
||||
(TODOS "BrainBench hermes adapter"); build both together. Effort: M.
|
||||
- [ ] **P3 — Client-registry unification (Approach C).** The repo carries 7
|
||||
hardcoded client lists (connect AGENT_SPECS, bootstrap Harness,
|
||||
HarnessSelector, host-specs TARGETS, claw-test registry, brainbench
|
||||
ALL_HARNESSES, volunteer HARNESS_CHANNELS); grok proved the claw-test
|
||||
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.
|
||||
- [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=="],
|
||||
|
||||
+12
-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
|
||||
@@ -99,6 +101,11 @@ Per-client setup guides live in [`docs/mcp/`](mcp/):
|
||||
- [`docs/mcp/CLAUDE_DESKTOP.md`](mcp/CLAUDE_DESKTOP.md)
|
||||
- [`docs/mcp/CHATGPT.md`](mcp/CHATGPT.md)
|
||||
- [`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
|
||||
|
||||
The HTTP server ships with an admin SPA at `/admin`, an SSE activity feed at `/admin/events`, DCR-style client registration, scope-gated `read`/`write`/`admin` access, and rate limiting.
|
||||
|
||||
@@ -136,6 +136,28 @@ Triage rule: a `warn-pass` EXIT-HANG line in `.context/test-summary.txt` is NOT
|
||||
- `tests/heavy/*.sh` → ops-shape shell scripts. Cost minutes per run; NOT in default `bun test`. Run via `bun run test:heavy` or scheduled nightly via `.github/workflows/heavy-tests.yml`. Examples: pg_upgrade matrix (boot legacy brain → walk to head), RSS budget gate (measure peak worker RSS vs committed baseline), read-latency-under-sync (p50/p95/p99 under concurrent writer load), sync lock regression (N concurrent syncs assert 1 winner + N-1 lock-busy + zero leaked `gbrain_cycle_locks` rows). See `tests/heavy/README.md` for when to add a script here vs `*.slow.test.ts`. Files prefixed with `_` (e.g. `tests/heavy/_build_legacy_fixtures.sh`) are helpers/libs invoked by sibling tests — the runner skips them.
|
||||
- `test/fuzz/*.test.ts` → property-based fuzz harness. Pure-validator targets in `pure-validators.test.ts` are guarded by `scripts/check-fuzz-purity.sh` (in `bun run verify`), which `bun build --target=bun` bundles each target and greps the resulting bundle for banned transitive imports (`node:fs`, `node:child_process`, engine modules). Anything that fails the guard moves to `mixed-validators.test.ts` (still property-tested, but no purity guarantee) or `filesystem-validators.test.ts` (fs-backed, uses temp dirs). Fuzz tests run in the default `bun test` loop because they're fast (~3s for ~12 properties × 1000 runs each).
|
||||
|
||||
### TTY and interactive-CLI testing
|
||||
|
||||
Four escalating tools; reach for the cheapest one that answers the question:
|
||||
|
||||
| Question | Tool | Example |
|
||||
|---|---|---|
|
||||
| Does the TTY/non-TTY branch logic pick right? | Inject `isTTY` into the pure function — no subprocess | `test/init-provider-picker.test.ts`, `test/jobs-watch-mode.test.ts` |
|
||||
| Does the real CLI behave right when stdin is NOT a terminal? | Spawn the CLI with piped/ignored stdio | `test/cli-stdin-hang.test.ts` (fast loop); `test/e2e/init-fresh-pglite.test.ts` (manual `test:e2e` lane — see the TODOS e2e CI-lane entry) |
|
||||
| Does the real CLI render menus and read typed input under a REAL terminal? | `launchTty` from `test/helpers/tty-harness.ts` in a `*.serial.test.ts` file | `test/init-picker-pty.serial.test.ts` |
|
||||
| How does the install FEEL (stalls, copy, silence windows)? | `scripts/dx-explore.ts` — instrument, not a test; nothing asserts | transcripts under `.context/dx-runs/` (see `docs/guides/bootstrap.md`) |
|
||||
|
||||
Real-PTY test rules: put the file in the serial lane (`*.serial.test.ts` — that
|
||||
lane runs in required CI; a new `test/e2e/*` file does NOT, since unit shards
|
||||
exclude the directory and the e2e workflow runs only explicitly named files,
|
||||
no glob);
|
||||
assert NON-default picker values (bare Enter and each prompt's 60s
|
||||
`readLineSafe` timeout both resolve to the default, so a defaults-asserting
|
||||
test passes with dead input); always `await session.close()` in a `finally`
|
||||
(only `close()` clears the harness wall timer); and point `HOME` plus
|
||||
`GBRAIN_HOME` at a temp root with pass-through auth keys stripped via
|
||||
`dropEnv` so picker state is machine-independent.
|
||||
|
||||
### Skills-manifest freshness guard
|
||||
|
||||
`skills/skills.lock.json` is a committed sha256 inventory of every bundled file under
|
||||
@@ -263,6 +285,8 @@ Unit tests and what they cover:
|
||||
- `test/volunteer-context.test.ts` — push-based context core (#2095), hermetic in-memory PGLite: `parseWindow` lenient `user:`/`assistant:` parsing, multi-turn window extraction, confidence-gated volunteering (arm confidences, multi-turn/newest-turn boosts, `min_confidence` gate, max-pages cap), slug-only suppression, privacy (rationales are deterministic templates; synopses pass the takes/facts fence), and the approximate usage-stats join.
|
||||
- `test/watch-command.test.ts` — `gbrain watch` push transport (#2095): streaming loop, rolling window, session dedupe, `--json` JSONL shape, `channel: 'watch'` event logging, clean EOF return. Hermetic PGLite + injected line/write deps (no subprocess, no real stdin).
|
||||
- `test/watch-sigint.serial.test.ts` — `gbrain watch` SIGINT lifecycle against a real spawned CLI subprocess with a tmpdir brain. SERIAL: parallel unit shards flake on concurrent subprocess spawns (same rationale as `apply-migrations-pglite-spawn.serial.test.ts`).
|
||||
- `test/init-picker-pty.serial.test.ts` — the interactive `gbrain init` pickers (embedding-provider + search-mode) driven under a REAL pseudo-terminal via `launchTty`: typed input lands (a NON-default mode choice verified by a follow-up non-TTY config read — bare Enter and the `readLineSafe` timeout both resolve to defaults, so a defaults-asserting test would pass with dead input), prompt-to-acknowledgement gaps bounded well under the fallback window, plus the Ctrl-D/EOF keyless fallback. On CI, missing PTY support fails loud instead of skipping. Hermetic: HOME + GBRAIN_HOME at a temp root, pass-through auth keys stripped via `dropEnv`; `session.close()` in `finally`. Serial: PTY spawn + full PGLite bootstrap, and the serial lane is what runs in required CI.
|
||||
- `test/tty-harness.test.ts` — the real-PTY harness's pure helpers (`stripAnsi`, `computeStalls`, `renderStallsReport`, `parseDriveCommand`, `buildClaudeTuiSeed`) with zero subprocesses; the file's live-PTY smokes are `describe.skipIf(!ptySupported())`-gated.
|
||||
- `test/autopilot-launchd-lifecycle.serial.test.ts` — autopilot lifecycle behavior, not generated-string assertions: the full install → self-disable → status → reinstall → uninstall arc with `launchctl` replaced by an argv recorder and the generated wrapper executed by a REAL bash against a genuinely deleted repo (every platform), plus a darwin-only fail-SKIP describe against the real launchd under a per-run unique label (`GBRAIN_AUTOPILOT_LABEL`) so it can never collide with — or tear down — a real install on the host. Serial: spawns subprocesses and pins HOME/GBRAIN_HOME for the whole file.
|
||||
- `test/autopilot-fanout.test.ts` — Autopilot fan-out and #4046 policy regression: targeted idempotency keys reopen per dispatch interval while stable doctor/remediate keys remain unchanged; the 60-minute full-cycle floor wins with a remaining small plan, and an all-fresh restart check advances the process-local clock without masking failed stale-source submissions.
|
||||
- `test/agent-scheduler-contract.serial.test.ts` — the documented external agent-scheduler shell chain (`gbrain sync --repo X && gbrain embed --stale`, live-sync.md / INSTALL_FOR_AGENTS.md Step 7) driven end-to-end through a real `/bin/sh` against a keyless PGLite brain: the `&&` short-circuit IS the contract (argv arrays can't exercise it), the keyless bare stale embed exits 0, and the pull-failure case that must break the chain does. Anti-vacuity: the fixture commits a real page and every read-back asserts pages >= 1. Serial: real spawned CLI + tmpdir HOME.
|
||||
@@ -310,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.
|
||||
@@ -376,12 +412,18 @@ 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.
|
||||
- `test/e2e/openclaw-reference-compat.test.ts` — `check-resolvable` + skillpack install-model against a minimal AGENTS.md workspace fixture (`test/fixtures/openclaw-reference-minimal/`), regression guard for the OpenClaw deployment shape.
|
||||
- `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.
|
||||
- `test/e2e/engine-parity.test.ts` — Postgres ↔ PGLite top-result and result-set parity for `searchKeyword` + `searchVector` (Postgres ranks pages then picks best chunk while PGLite returns chunks directly, so the source-boost behavior needs parity coverage). Skips without `DATABASE_URL`.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# claude-cli — the `claude-cli` recipe (routes chat/toolLoop through the local `claude` CLI)
|
||||
|
||||
This page documents the `claude-cli` recipe as it already ships (added in
|
||||
v0.42.66.0, PR #3310 — see `CHANGELOG.md`) — it is not proposing new
|
||||
behavior. The implementation lives at `src/core/ai/recipes/claude-cli.ts`
|
||||
and `src/core/ai/providers/claude-cli-language-model.ts`; neither
|
||||
`README.md` nor `docs/` mentioned this recipe before this page, so the only
|
||||
description of how it behaves lived in those source comments.
|
||||
|
||||
`claude-cli` routes `gateway.chat()` and `gateway.toolLoop()` through the
|
||||
`claude` CLI binary as a subprocess (`claude --print ...`) instead of through
|
||||
the Anthropic SDK. It sits alongside the existing `anthropic` recipe as a
|
||||
second `Recipe` entry with the same touchpoint shape; which one a given
|
||||
model string resolves to is a per-call choice: `anthropic:claude-sonnet-5`
|
||||
resolves to the `native-anthropic` implementation (SDK + `ANTHROPIC_API_KEY`),
|
||||
`claude-cli:claude-sonnet-5` resolves to `ClaudeCliLanguageModel` (subprocess,
|
||||
CLI-managed auth).
|
||||
|
||||
**Chat-only — no embedding.** `gateway.embed()` throws immediately for
|
||||
`claude-cli` models (`claude-cli has no embedding model. Use openai or google
|
||||
for embeddings.`). Claude has no first-party embedding model regardless of
|
||||
transport; pair this recipe with `openai`, `google`, or `voyage` for
|
||||
embeddings the same way the `anthropic` recipe's docs already recommend.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install Claude Code (the `claude` CLI) and run `claude` once to log in.
|
||||
If the binary is not on `PATH`, point the gateway at it explicitly:
|
||||
|
||||
```bash
|
||||
export GBRAIN_CLAUDE_CLI_BIN=/path/to/claude
|
||||
```
|
||||
|
||||
2. Point a model tier (or any per-call model string) at `claude-cli:`:
|
||||
|
||||
```bash
|
||||
gbrain config set models.tier.subagent claude-cli:claude-sonnet-5
|
||||
```
|
||||
|
||||
Any of the models the recipe declares work the same way:
|
||||
`claude-cli:claude-opus-5`, `claude-cli:claude-haiku-4-5-20251001`, etc.
|
||||
Short aliases (`claude-cli:sonnet`, `claude-cli:haiku`, `claude-cli:opus`)
|
||||
resolve the same way the `anthropic` recipe's aliases do.
|
||||
|
||||
The recipe declares `auth_env: { required: [] }`, and neither the recipe
|
||||
nor the adapter code reads or passes any API-key-shaped config value to the
|
||||
subprocess — whatever the `claude` binary does for its own auth (see below)
|
||||
is between it and its own login state, not something gbrain's config layer
|
||||
participates in. There is also no `provider_base_urls` entry for this
|
||||
recipe — it has no base URL, only a subprocess binary path
|
||||
(`GBRAIN_CLAUDE_CLI_BIN`).
|
||||
|
||||
## What actually happens on a call
|
||||
|
||||
Each `doGenerate` call spawns `claude --print --output-format json --model
|
||||
<id> --disable-slash-commands --tools '' --strict-mcp-config` as a
|
||||
subprocess, with `cwd` set to a per-process directory under the OS tmpdir
|
||||
(`join(tmpdir(), 'gbrain-claude-cli-cwd-' + process.pid)`, created via
|
||||
`mkdirSync(..., { recursive: true })` if missing — code doesn't otherwise
|
||||
touch or inspect its contents), and pipes the rendered prompt to it on
|
||||
stdin:
|
||||
|
||||
- `--tools ''` disables every built-in tool (Bash/Read/WebSearch/…) — the
|
||||
subprocess must behave like a raw LLM, not a full agent.
|
||||
- `--strict-mcp-config` skips loading the user's MCP servers. Without it,
|
||||
every call would boot the user's configured MCP servers — including
|
||||
gbrain's own MCP, which would recurse and contend for the PGLite
|
||||
single-writer lock.
|
||||
- The subprocess env is a copy of gbrain's own process env with exactly
|
||||
three keys deleted before spawn: `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`,
|
||||
`ANTHROPIC_BASE_URL`. Everything else in gbrain's environment is inherited
|
||||
as-is. The recipe's source comment states the intent (stop an
|
||||
`ANTHROPIC_API_KEY` present in gbrain's own env from being picked up by the
|
||||
subprocess), scoped to those three variables specifically — the doc does
|
||||
not claim this rules out every other way `claude` could end up billing
|
||||
through a non-subscription path (e.g. other env-based auth switches the CLI
|
||||
itself may support); that is between the installed `claude` binary and its
|
||||
own configuration, not something this recipe's code inspects.
|
||||
- Beyond that env-scrub, auth resolution is entirely up to the installed
|
||||
`claude` binary — the recipe does not manage or forward credentials
|
||||
itself. Whatever `claude` is already logged in / authenticated with on
|
||||
this machine is what it authenticates with here too (see the `claude` CLI's
|
||||
own docs for how it stores and resolves that).
|
||||
|
||||
`--bare` (which would skip loading the user-level `~/.claude/CLAUDE.md`
|
||||
entirely) is not among the flags passed, because it also forces
|
||||
`ANTHROPIC_API_KEY` auth (per the recipe's source comment). One effect of
|
||||
not passing it: the user-level `~/.claude/CLAUDE.md` still loads and gets
|
||||
cached tokens on every call.
|
||||
|
||||
The adapter does not use `claude`'s own agentic tool-calling — it injects a
|
||||
fenced instruction block into the system prompt teaching the model a
|
||||
`<use_tools>[{id,name,input}, ...]</use_tools>` JSON emission format
|
||||
(`buildToolUseInstructions`), then parses that block back out of the plain
|
||||
text response into ai-sdk tool-call parts (`extractToolCalls`). This
|
||||
protocol-over-text approach is what lets `supports_subagent_loop: true`
|
||||
work through the `--print`, no-built-in-tools subprocess shape described
|
||||
above.
|
||||
|
||||
## Constraints
|
||||
|
||||
| Area | Behavior |
|
||||
|---|---|
|
||||
| Embedding | Not supported — `gateway.embed()` throws for `claude-cli` models. Pair with another provider for embeddings. |
|
||||
| Streaming | Not implemented. `doStream()` throws. `gateway.toolLoop()` (the main caller) is non-streaming already, so this is not a practical limitation for subagent dispatch, but any caller that expects a streaming chat surface cannot use `claude-cli`. |
|
||||
| Tool use | JSON emission via a system-prompt-injected protocol, not the CLI's native tool-call mechanism. Parallel tool calls in one turn round-trip correctly. |
|
||||
| Multimodal | Not supported over the subprocess path. File/image message parts are rendered as a `[file <mediaType>]` text stub, not sent as actual content. |
|
||||
| Prompt caching | The recipe declares `supports_prompt_cache: false`. The CLI manages its own caching internally but does not expose it through gbrain's `cache_control` control plane, so from the gateway's point of view this model does not support prompt caching. |
|
||||
| Usage / token counts | Reported `usage.input_tokens` / `usage.output_tokens` are read straight from the CLI's `--output-format json` envelope (`result.usage?.input_tokens` / `output_tokens`); gbrain does not independently count tokens for this path. |
|
||||
| Cost figures | The recipe declares `cost_per_1m_input_usd: 3.0` / `cost_per_1m_output_usd: 15.0` — the same Sonnet-class figures the `anthropic` recipe declares (`price_last_verified: 2026-06-17`) — purely so gbrain's budget ledger has a number to attribute per call. Neither the recipe nor the adapter code checks what you're actually billed; treat these as the ledger's nominal per-call number, not a verified charge. |
|
||||
| User-level CLAUDE.md | `~/.claude/CLAUDE.md` still loads on every call (see above) — only the working directory changes (see "What actually happens on a call" for exactly what that directory is and isn't). |
|
||||
|
||||
## Known doctor caveat: cold-start subprocess vs the fixed 5s probe timeout
|
||||
|
||||
`gbrain models doctor`'s chat reachability probe (`probeModel` in
|
||||
`src/commands/models.ts`) wraps every chat call in a fixed 5-second
|
||||
`AbortController` timeout, independent of any per-recipe timeout the recipe
|
||||
itself declares (`claude-cli` does not declare a `default_timeout_ms`).
|
||||
Spawning the `claude` binary and letting it start up is generally fast, but
|
||||
is not instantaneous — a slow first invocation (cold process cache, slow
|
||||
disk, contended machine) can outrun that 5-second window.
|
||||
|
||||
When that happens, the probe's `AbortController` fires, the subprocess is
|
||||
killed (`child.kill('SIGTERM')`), and the adapter's abort handler rejects
|
||||
with a fixed message (`claude-cli adapter aborted`). `classifyError` in
|
||||
`src/commands/models.ts` only maps a message to `status: network` if it
|
||||
matches `/timeout|network|econn|fetch failed|enotfound/`; `claude-cli
|
||||
adapter aborted` matches none of those, so it falls through to
|
||||
`status: unknown` — the classifier's catch-all — instead of `status:
|
||||
network`, which is what a plain slow/unreachable HTTP provider would map
|
||||
to on the same probe timeout. So a `status: unknown` result on a
|
||||
`claude-cli:` model is not necessarily a broken configuration on its own;
|
||||
a cold subprocess start outrunning the fixed 5s window is one thing that
|
||||
can produce it (the same class of first-call cold-start the embedding
|
||||
reachability probe's own code comment already calls out for local
|
||||
embedders), and re-running the probe is a reasonable first thing to try.
|
||||
`status: unknown` on its own doesn't distinguish that from any other
|
||||
unclassified failure, so if a re-run keeps producing it, treat it as an
|
||||
unclassified error worth investigating rather than assuming cold-start.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Where it comes from | Try |
|
||||
|---|---|---|
|
||||
| `claude-cli spawn failed: ...` / stdin write failure | `spawn()`'s `error` event or a failed `stdin.write` — commonly means the `claude` binary was not found on `PATH` | Install Claude Code, or set `GBRAIN_CLAUDE_CLI_BIN` to the binary's path |
|
||||
| `claude-cli exited <code>: ...` | Non-zero exit from the `claude` subprocess itself; the message is whatever the CLI wrote to stderr/stdout | Run `claude` interactively with the same model to see the underlying CLI error directly (e.g. not logged in, model unavailable) |
|
||||
| `claude-cli output not JSON: ...` | `JSON.parse(stdout)` threw (stdout wasn't valid JSON at all) | Confirm the installed `claude` CLI version still supports `--print --output-format json`; this adapter's JSON handling was verified against CLI 2.1.145 |
|
||||
| `claude-cli JSON event array had no "result" event` | stdout parsed as a JSON array (the `"verbose": true` event-stream shape in `~/.claude/settings.json`) but none of the events had `type: "result"` | Check `~/.claude/settings.json` for `"verbose": true`; the adapter tolerates the array shape but still needs a `result` event in it |
|
||||
| `gbrain models doctor` reports `chat` as `status: unknown` for a `claude-cli:` model | See "Known doctor caveat" above — `classifyError` falls through to `unknown` for the adapter's abort message | Re-run the probe; if it persists, treat it as an unclassified failure and investigate directly (e.g. run the same model via `gbrain models doctor --json` or call `claude` by hand) |
|
||||
| A call bills through the Anthropic API instead of the local session | The adapter deletes `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL` from the subprocess env — this covers gbrain's own env leaking into the call. It does not inspect any other auth/billing switch the installed `claude` CLI itself may support | If billing looks wrong, check the `claude` CLI's own auth/billing configuration on this machine, not just gbrain's env |
|
||||
@@ -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,12 +309,16 @@ 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`) 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
|
||||
`agent-harness.ts`; pure helpers are unit-tested in `test/tty-harness.test.ts`
|
||||
(zero subprocesses, PTY smokes self-skip where `terminal:` is unavailable).
|
||||
The harness itself also backs one required-CI test: `test/init-picker-pty.serial.test.ts`
|
||||
asserts the interactive `gbrain init` pickers under a real PTY (see the
|
||||
TTY decision table in `docs/TESTING.md`). The DX-exploration layer below stays
|
||||
an instrument — nothing in it asserts.
|
||||
|
||||
`scripts/dx-explore.ts` drives it to capture the fresh-user funnel as timestamped
|
||||
transcripts under `.context/dx-runs/` (gitignored — nothing asserts, no CI):
|
||||
@@ -309,6 +328,8 @@ 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:
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# Grok Build CLI pin — observed behavior notes (v1.0.4)
|
||||
|
||||
Dev-facing companion to [GROK.md](GROK.md): every fact below was OBSERVED against a
|
||||
real install (2026-08-14), not researched from docs. The claw-test GrokRunner, the
|
||||
install door e2e, and the heavy-tests grok-door CI job assert exactly these shapes —
|
||||
when Grok Build releases change them, update this file, the workflow pins, and the
|
||||
affected assertions together (`scripts/check-grok-pin.sh` in `bun run verify` enforces
|
||||
the workflow-side match).
|
||||
|
||||
Naming note: **grok** (xAI Grok Build CLI, `XAI_API_KEY`) is not **groq** (Groq Inc.
|
||||
inference, `src/core/ai/recipes/groq.ts`, `GROQ_API_KEY`) and not **ngrok** (tunnels).
|
||||
|
||||
<!-- grok-pin: distribution_kind=npm -->
|
||||
<!-- grok-pin: npm_package=@xai-official/grok -->
|
||||
<!-- grok-pin: npm_version=1.0.4 -->
|
||||
<!-- grok-pin: npm_integrity=sha512-Nu3SFXTqwvCQr/LQFwrQYgngJhUQwX2h9ZSgzW4HowidjbPBWtMVO0xI88d2z6/zlDSNaT5YP/uk+2DthKQMsg== -->
|
||||
<!-- grok-pin: npm_linux_x64_integrity=sha512-Dan2LfKcFBiabuDGHaGgMT8Ndzibo2ljvSjh4MlpV5117JL+S/0KMbdyYpk+13d7t+4znniW1cm+rRwUGSAvtw== -->
|
||||
<!-- grok-pin: npm_linux_arm64_integrity=sha512-zGK42Eq3ZmIa7cSVnl6CiJ4cxTCMsNLQCmCoLJhy5eZXfAvZ1DA3K3HXmKCj4OScX8SalYlp7mx8HWl9Y6gytw== -->
|
||||
<!-- grok-pin: grok_version=1.0.4 -->
|
||||
<!-- grok-pin: installer_sha256=43d0943123edade1383a476a4f778674877acee7c1f98a00f094c4a0f7349321 -->
|
||||
<!-- grok-pin: observed_date=2026-08-14 -->
|
||||
|
||||
## Pin
|
||||
- **Grok Build v1.0.4**, `grok --version` output shape: `grok 1.0.4 (d846eb93d94d)`
|
||||
(version + build hash; the door's shape assert is `/^grok \d+\.\d+\.\d+ \([0-9a-f]+\)$/`).
|
||||
- **Provisioning (CI + local): pinned npm install** — `@xai-official/grok@1.0.4`,
|
||||
registry integrity `sha512-Nu3SFX…`. The package fans out to
|
||||
`@xai-official/grok-{darwin,linux,win32}-{arm64,x64}` optional deps at the same
|
||||
version; the CI job pins the LINUX payload integrities too (stamps above) because
|
||||
the wrapper's integrity covers only the wrapper tarball — the platform sub-package
|
||||
is the binary that executes. Load-bearing assumption, stated explicitly: npm
|
||||
version-immutability (a published version cannot be replaced on npmjs; only a new
|
||||
version or an unpublish, both of which fail the pinned install loudly).
|
||||
- Installer path (fallback only): `https://x.ai/cli/install.sh`, sha256
|
||||
`43d0943123edade1383a476a4f778674877acee7c1f98a00f094c4a0f7349321` (17,686 bytes).
|
||||
It SUPPORTS version pinning (`bash -s <X.Y.Z>`) and downloads versioned artifacts
|
||||
`grok-<version>-<os>-<arch>` from `https://x.ai/cli` (fallback GCS bucket
|
||||
`grok-build-public-artifacts`), self-checks `--version` post-download. Platform
|
||||
string from `uname -s`/`uname -m` with a Rosetta correction on Apple Silicon.
|
||||
- Verified against macOS arm64; npm `os`/`cpu` matrix covers linux x64/arm64 for CI.
|
||||
|
||||
## GROK_HOME — HONORED (verified)
|
||||
`GROK_HOME=<tmp> HOME=<tmp> grok mcp list|add|doctor` read+write `<tmp>/config.toml`
|
||||
and do NOT touch `~/.grok`. Belt-and-suspenders (HOME + GROK_HOME both to tmp) stays
|
||||
in the door anyway. NOTE what grok writes into `$GROK_HOME` on EVERY run (tripwire
|
||||
exclusions — these are VOLATILE): `active_sessions.lock`, `active_sessions.json`,
|
||||
`bin/grok-<version>` (it copies its own binary in), `logs/unified.jsonl`,
|
||||
`docs/user-guide/*.md` (it ships its user guide into the home), `leader.sock` (a
|
||||
leader daemon socket; `--leader-socket <PATH>` overrides). The tripwire hashes ONLY
|
||||
`config.toml` + credential-class files, never the volatile set.
|
||||
|
||||
## One-shot (`-p`)
|
||||
- `grok -p "<prompt>"` (`-p, --single`) prints the response to stdout and exits.
|
||||
- `--output-format plain|json|streaming-json|streaming-messages-json` (default plain;
|
||||
`streaming-json` = NDJSON of native ACP session updates; `streaming-messages-json` =
|
||||
Anthropic Messages wire format; `--include-partial-messages` adds deltas).
|
||||
- **Keyless one-shot: exit 1**, message (verbatim, both stdout and stderr):
|
||||
`Not signed in. To authenticate without a browser, run:\n grok login --device-code\n\nAlternatively, set the XAI_API_KEY environment variable or run `grok login` on a machine with a browser.`
|
||||
→ `hasGrokAuth()` = non-empty `XAI_API_KEY`; the TTY scenario's keyless early-stop
|
||||
matcher is `Not signed in`.
|
||||
- Cost/toolset flags that EXIST (observed in --help): `--always-approve`,
|
||||
`--permission-mode default|acceptEdits|auto|dontAsk|bypassPermissions|plan`,
|
||||
`--tools <LIST>`, `--disallowed-tools <LIST>`, `--allow/--deny <RULE>`,
|
||||
`--disable-web-search` (dedicated kill for web search + fetch — the door SMOKE uses
|
||||
THIS, not a tools list), `--max-turns <N>`, `-m/--model`, `--reasoning-effort`
|
||||
(alias `--effort`), `--cwd <PATH>`, `--rules`, `--prompt-file`, `--prompt-json`,
|
||||
`--json-schema` (implies json output), `--verbatim`, `--sandbox <PROFILE>`
|
||||
(env `GROK_SANDBOX`), `--no-memory`, `--no-plan`, `--no-subagents`.
|
||||
- There is NO auto-update CLI flag. Auto-update is config: `[cli] auto_update = true`
|
||||
is the DEFAULT — hermetic homes MUST seed `[cli] auto_update = false`. Manual
|
||||
updater: `grok update [--check --json --version <V> --force-reinstall --alpha]`.
|
||||
- `.envrc` gotcha: `load_envrc = true` by default — grok loads `.envrc` from the
|
||||
working directory. Door/live spawns pin `cwd` to tmp workspaces partly for this.
|
||||
|
||||
## Auth + model pin (non-interactive)
|
||||
- Keyless error pinned above; `grok login --device-code` exists for headless
|
||||
interactive auth; `XAI_API_KEY` env is the documented headless path (its end-to-end
|
||||
smoke is **pending auth** — no key was available at observation time; the door's
|
||||
paid tier stays skip-gated until then, per plan D0).
|
||||
- `grok models` works KEYLESS (exit 0): prints `You are not authenticated.`, then
|
||||
`Default model: grok-4.6` and the visible list (`grok-4.6 (default)`, `grok-4.5`).
|
||||
Authenticated list may be larger; per-turn cost pins are **pending auth**.
|
||||
- Model pin mechanism: per-call `-m <model>` (authoritative in tests — immune to
|
||||
config rewrites) and `[models] default = "<model>"` in config.toml.
|
||||
|
||||
## `grok mcp add` — THE big observed facts
|
||||
- Shape: `grok mcp add <name> [-e KEY=value]... [-s user|project] [-t stdio|http|sse] -- <command> [args...]`
|
||||
— everything after `--` is the server argv. **`-e/--env` is REPEATABLE, one
|
||||
KEY=value per flag** (their docs pin this as a breaking change from earlier
|
||||
releases: `use -e A=1 -e B=2, not --env A=1 B=2` — the hermes replace-bug class is
|
||||
fixed upstream). Server names: letters, numbers, hyphens, underscores only.
|
||||
- **Add is LAZY: exit 0 always, NO handshake at add time, no interactive prompt**
|
||||
(`Added stdio MCP server 'gbrain' … to user config` / `File modified:
|
||||
$GROK_HOME/config.toml`). Adding a NONEXISTENT command also exits 0. Never assert
|
||||
add's exit code; never treat `enabled = true` in the saved TOML as a handshake
|
||||
proof (it is written unconditionally).
|
||||
- Scope: `-s user` (default) → `~/.grok/config.toml`; `-s project` →
|
||||
`./.grok/config.toml` (committable; reference secrets as `${VAR}`).
|
||||
- **Bare command names resolve via the CALLER'S PATH** (verified): registering
|
||||
`-- gbrain serve --surface verbs` with a PATH-prefixed bin dir works — doctor
|
||||
resolved bare `gbrain` to the staged wrapper and completed the handshake. The
|
||||
bun-run wrapper shim (`#!/bin/sh\nexec bun run <abs>/src/cli.ts "$@"`) works as the
|
||||
staged binary (the fallback lane when a compiled binary is unavailable).
|
||||
- Startup timeout: per-server `startup_timeout_sec` (default 30) or global env
|
||||
`GROK_MCP_STARTUP_TIMEOUT_SECS` (seconds) / `MCP_TIMEOUT` (ms, Claude-compatible).
|
||||
The bun-run wrapper cold-transpiles slowly — the door sets 60+.
|
||||
|
||||
## Saved config schema (verbatim, from a real add)
|
||||
```toml
|
||||
[mcp_servers.gbrain]
|
||||
command = "/tmp/<staged-bin>/gbrain"
|
||||
args = [
|
||||
"serve",
|
||||
"--surface",
|
||||
"verbs",
|
||||
]
|
||||
enabled = true
|
||||
|
||||
[mcp_servers.gbrain.env]
|
||||
GBRAIN_SOURCE = "workspace"
|
||||
GBRAIN_HOME = "/tmp/<brain-home>"
|
||||
```
|
||||
Full schema keys (from grok's own shipped user guide, `$GROK_HOME/docs/user-guide/`):
|
||||
`command`, `args`, `env`, `enabled` (default true), `startup_timeout_sec` (default
|
||||
30), `tool_timeout_sec` (default 6000), `tool_timeouts`.
|
||||
|
||||
## Probes — the HONEST discriminator exists
|
||||
- **`grok mcp doctor <name> --json`**: SPAWNS the server for real. Good server →
|
||||
**exit 0** with checks `command found` / `server started` / `handshake OK`
|
||||
(`"detail": "protocol 2025-11-25"`) / **`7 tools discovered`** (the verbs surface's
|
||||
seven verbs, proven keyless end-to-end). Broken server (nonexistent command) →
|
||||
**exit 1**, check `command not found`, `passed: false`, plus a `hint`. THE door's
|
||||
hard discriminator; the T4 doctor pre-flight gates the paid loop (plan M6 resolves
|
||||
to the honest branch).
|
||||
- Doctor `--json` also enumerates config **sources** with per-source status —
|
||||
`~/.grok/config.toml`, `~/.claude.json`, `.mcp.json` — and each server carries a
|
||||
`"source"` field (`"config"`, `".mcp.json"`, …): the T2b provenance assertion reads
|
||||
this directly.
|
||||
- `grok mcp list --json` → exit 0, array of `{command, args, env, enabled, name,
|
||||
scope}`.
|
||||
- `grok inspect` (keyless, exit 0) shows version, CWD, `Project trusted: yes/no`,
|
||||
instructions, permissions, skills, agents — the config-discovery audit surface.
|
||||
|
||||
## Vendor-config fallback — TRUST-GATED (verified)
|
||||
A project `.mcp.json` in the cwd is SEEN by doctor (source `found`, server listed
|
||||
with `source: ".mcp.json"`) but the server check reports **`folder untrusted`** and
|
||||
`mcp list` shows nothing until the folder is trusted (first-run trust flow). So:
|
||||
fresh tmp HOME + fresh cwd ⇒ vendor entries structurally cannot activate (door
|
||||
provenance guarantee), and on an operator's machine the fallback only engages for
|
||||
folders they already trusted — the live-lane warning (operator `~/.claude.json`
|
||||
carrying `mcpServers.gbrain`) still applies for trusted folders.
|
||||
|
||||
## 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` stamps here, re-run the re-observation checklist below, update workflow env pins (check-grok-pin.sh enforces the pair) |
|
||||
| installer digest drift (fallback path) | `sha256sum -c` fails on install.sh | Diff the new installer, re-pin `installer_sha256` after review |
|
||||
| version drift mid-run | `grok --version` re-check ≠ pinned | Auto-update engaged — verify `[cli] auto_update = false` seeding; re-pin if a deliberate bump |
|
||||
| blank XAI_API_KEY secret | named precondition/paid-sentinel failure | Admin adds/rotates the repo Actions secret (console.x.ai origin); keyless tier still ran |
|
||||
| invalid/expired key | bad-key preflight fails (pin its message after first authed run) | Rotate the secret; no code change |
|
||||
| tripwire fired | manifest mismatch on config/credential files only | True isolation breach — stop, inspect which file changed; volatile-path drift alone must NOT fire (bug in exclusions if it does) |
|
||||
| real door regression | doctor checks or recall assert fail with pins intact | Bisect against the pinned version; file upstream if grok-side |
|
||||
|
||||
Re-observation checklist on a version bump: re-run the npm/installer pin captures
|
||||
(§Pin), the help-surface diff (`--help`, `mcp --help`, `mcp add --help`), and the
|
||||
mcp add → saved-TOML → doctor sequence (§add/§probes). The one-shot/auth/model
|
||||
sections only need re-observation if their assertions start failing.
|
||||
|
||||
## Keyless TUI behavior (observed via the dx-explore PTY instrument)
|
||||
Under a real PTY with no credentials, interactive `grok` plays a Braille-
|
||||
pattern intro animation (U+2800-range glyphs) for a few seconds, then settles
|
||||
(~6s) onto a SIGN-IN screen: "Approve in your browser to finish signing in"
|
||||
plus a device code (and a ctrl+c hint). There is no unattended path past it.
|
||||
Two hazards for PTY automation, both observed: the animation frames carry
|
||||
zero word-like text (3+-letter runs) — a text-presence heuristic must count
|
||||
letter runs, not enumerate glyphs; and pasting into the sign-in screen leaves
|
||||
a persistent full-screen spinner redrawing at ~5 frames/sec, which starves
|
||||
quiet-based settling and makes full-buffer ANSI stripping the hot loop
|
||||
(strip bounded raw tails instead). Headless keyless is the clean
|
||||
`Not signed in` error above. The `grok-install` dx scenario early-stops at
|
||||
the sign-in copy (or a persistently textless screen) with the friction
|
||||
recorded — that IS the keyless measurement.
|
||||
|
||||
## Supported-version policy
|
||||
gbrain's grok integration is verified against **Grok Build v1.0.4** (this pin). The
|
||||
canary CI leg (enabled with the secret) tracks latest and is continue-on-error; the
|
||||
pinned lane is the deterministic gate. **Pending auth** (requires `XAI_API_KEY`):
|
||||
paid one-shot smoke, authed model list + per-turn cost pins, credential-file
|
||||
inventory after login (feeds evidence exclusions + TTY secretPaths), AUTHED
|
||||
first-run TUI dialog copy (the keyless TUI + headless copies are pinned above).
|
||||
@@ -0,0 +1,155 @@
|
||||
# Connect GBrain to Grok Build
|
||||
|
||||
> This page is the MCP-registration reference for **Grok Build** — xAI's
|
||||
> official `grok` CLI (early beta, subscriber-gated; not the community
|
||||
> `superagent-ai/grok-cli`, which ships a colliding `grok` binary — 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 Grok Build over stdio MCP.
|
||||
> The `gbrain bootstrap` persistent-personal-agent path is **not yet
|
||||
> supported for Grok** (Claude Code and Codex only today) — brain-only
|
||||
> install is what this page delivers.
|
||||
|
||||
Grok Build spawns `gbrain serve` as a local stdio subprocess. No server, no
|
||||
tunnel, no token needed. Works with both PGLite and Supabase engines.
|
||||
|
||||
## Register (recommended)
|
||||
|
||||
```bash
|
||||
grok mcp add gbrain -e "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 `grok mcp add`, all observed:
|
||||
|
||||
- **The env flag is repeatable, one `KEY=value` per flag** (`-e A=1 -e B=2`).
|
||||
Server argv goes after `--`.
|
||||
- **Registration is lazy.** The add writes config and exits 0 without
|
||||
connecting — even for a nonexistent command. Verify with `grok mcp doctor`
|
||||
(below), never with the add's exit code.
|
||||
- **Scope:** the default writes to `~/.grok/config.toml`; add `-s project`
|
||||
to write a committable `./.grok/config.toml` instead (reference secrets as
|
||||
`${VAR}` in project scope — values are stored verbatim).
|
||||
|
||||
## Direct config (equally supported)
|
||||
|
||||
The add command writes an `[mcp_servers.gbrain]` block into
|
||||
`~/.grok/config.toml` (or `./.grok/config.toml` with project scope; the
|
||||
`GROK_HOME` env var relocates the user config dir). You can write it
|
||||
yourself instead:
|
||||
|
||||
```toml
|
||||
[mcp_servers.gbrain]
|
||||
command = "gbrain"
|
||||
args = ["serve", "--surface", "verbs"]
|
||||
startup_timeout_sec = 60
|
||||
enabled = true
|
||||
|
||||
[mcp_servers.gbrain.env]
|
||||
GBRAIN_HOME = "/home/alice-example"
|
||||
```
|
||||
|
||||
`startup_timeout_sec` defaults to 30; raise it (or export
|
||||
`GROK_MCP_STARTUP_TIMEOUT_SECS`) if gbrain runs from source via `bun run`,
|
||||
which cold-transpiles on first spawn. To remove gbrain, delete the block (or
|
||||
set `enabled = false` to disable without losing the config).
|
||||
|
||||
## Zero-config vendor fallback
|
||||
|
||||
Grok Build also reads MCP registrations from `~/.claude.json`, `.cursor/mcp.json`,
|
||||
and a project `.mcp.json` — at lower priority than its own config, and **only
|
||||
for folders you have trusted** in Grok (fresh folders report
|
||||
`folder untrusted` until you accept the trust prompt). If you already
|
||||
registered gbrain for Claude Code, Grok may pick it up with zero
|
||||
configuration. `grok mcp doctor --json` reports every source it consulted
|
||||
and which one each server came from — check the `source` field to see which
|
||||
config won before assuming the native one did.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
grok mcp list --json # entry: {"name":"gbrain","enabled":true,...}
|
||||
grok mcp doctor gbrain # THE real probe: spawns the server
|
||||
```
|
||||
|
||||
`grok mcp doctor gbrain` performs the actual handshake — expect the checks
|
||||
`command found`, `server started`, `handshake OK`, and `7 tools discovered`
|
||||
(the seven verbs), exit 0. A broken registration exits 1 with a failing
|
||||
check and a hint. Then one real round-trip:
|
||||
|
||||
```bash
|
||||
grok -p "use the gbrain recall tool to answer: what did I import most recently?"
|
||||
```
|
||||
|
||||
`grok -p` (single-turn headless) prints the final answer on stdout.
|
||||
|
||||
## Headless auth + model pin
|
||||
|
||||
For cron jobs, CI, or any non-TTY run:
|
||||
|
||||
- **Auth:** export `XAI_API_KEY` (from console.x.ai). Keyless headless runs
|
||||
exit 1 with `Not signed in`; `grok login --device-code` is the
|
||||
interactive-terminal alternative, `grok login` the browser one.
|
||||
- **Model pin:** pass `-m <model>` per call, or set it in config:
|
||||
|
||||
```toml
|
||||
[models]
|
||||
default = "grok-4.5"
|
||||
```
|
||||
|
||||
- **Updates:** Grok self-updates by default. For pinned/reproducible
|
||||
environments, seed:
|
||||
|
||||
```toml
|
||||
[cli]
|
||||
auto_update = false
|
||||
```
|
||||
|
||||
## Pair with cron
|
||||
|
||||
Grok Build has no built-in cron; schedule headless one-shots with your
|
||||
system scheduler:
|
||||
|
||||
```bash
|
||||
# crontab: brain maintenance every 4 hours
|
||||
0 */4 * * * XAI_API_KEY=... grok -p "Run gbrain sync and report anything unusual" --output-format plain
|
||||
```
|
||||
|
||||
See [docs/guides/cron-schedule.md](../guides/cron-schedule.md) for the full
|
||||
brain maintenance protocol (sync, embed, dream cycle).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Wrong `grok` on PATH** — the community `superagent-ai/grok-cli` also
|
||||
installs a `grok` binary. The official CLI answers `grok --version` with
|
||||
`grok X.Y.Z (buildhash)`; anything else is the other tool. Install the
|
||||
official one via `npm install -g @xai-official/grok` or
|
||||
`curl -fsSL https://x.ai/cli/install.sh | bash`.
|
||||
- **grok ≠ groq ≠ ngrok** — Grok Build (xAI, `XAI_API_KEY`) is not Groq
|
||||
(the inference provider, `GROQ_API_KEY`) and not ngrok (tunnels). A
|
||||
mis-set key produces auth errors against the wrong service.
|
||||
- **`Not signed in` (exit 1)** — no auth in a headless run. Export
|
||||
`XAI_API_KEY` or run `grok login --device-code`.
|
||||
- **Doctor says `folder untrusted`** — the registration came from a vendor
|
||||
config (`.mcp.json` / `~/.claude.json`) in a folder Grok hasn't been told
|
||||
to trust. Trust the folder in an interactive session, or register
|
||||
natively with `grok mcp add`.
|
||||
- **Doctor times out on `server started`** — raise `startup_timeout_sec`
|
||||
(or `GROK_MCP_STARTUP_TIMEOUT_SECS=90`) if gbrain runs via `bun run`.
|
||||
- **Skills note:** `gbrain skillpack scaffold` writes `skills/<name>/SKILL.md`
|
||||
into your workspace, which Grok does **not** auto-discover as Grok skills
|
||||
(it reads `.grok/skills`, `~/.grok/skills`, `~/.agents/skills`, plugins).
|
||||
gbrain's skills still work as reference documents the agent reads;
|
||||
`grok inspect` shows what Grok actually discovered.
|
||||
- **`grok inspect`** — the config-discovery audit: version, cwd trust,
|
||||
instructions, permissions, skills, agents, MCP sources.
|
||||
|
||||
---
|
||||
|
||||
Verified against **Grok Build v1.0.4** (early beta — expect churn; the pin
|
||||
is enforced in CI). Dev-facing observed-behavior notes (exact flag
|
||||
semantics, exit-code caveats, config schema, CI pin values) live in
|
||||
[GROK-CLI-PIN.md](GROK-CLI-PIN.md).
|
||||
@@ -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()`:
|
||||
|
||||
|
||||
@@ -64,6 +64,16 @@ If `claude` is not found: install Claude Code first, or use a block below.
|
||||
codex mcp add gbrain -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
**Grok Build** (verify with `grok mcp doctor gbrain` — the add is lazy)
|
||||
```bash
|
||||
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** |
|
||||
|
||||
|
||||
+118
-17
@@ -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
|
||||
|
||||
@@ -1247,6 +1250,34 @@ Keep `--args` last (everything after it becomes server argv) and verify with
|
||||
`hermes mcp test gbrain` — the add exits 0 even on failure. Full reference:
|
||||
[docs/mcp/HERMES.md](docs/mcp/HERMES.md).
|
||||
|
||||
**If you are Grok Build** (xAI's `grok` CLI): register gbrain as your MCP server:
|
||||
|
||||
```bash
|
||||
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, 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
|
||||
@@ -1530,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` |
|
||||
@@ -1770,6 +1801,8 @@ GBrain exposes nearly all of its 100+ operations as MCP tools (stdio and HTTP; a
|
||||
- **[Codex](docs/mcp/CODEX.md)** — `gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex` (or `--install`). Codex reads the bearer from `$GBRAIN_REMOTE_TOKEN` at runtime, so the token never lands in Codex config.
|
||||
- **[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.
|
||||
@@ -1831,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).
|
||||
@@ -1888,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:
|
||||
|
||||
@@ -1926,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.
|
||||
|
||||
@@ -1945,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
|
||||
@@ -2091,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -3205,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
|
||||
|
||||
```
|
||||
@@ -3871,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
|
||||
|
||||
@@ -3933,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
|
||||
@@ -4420,6 +4511,16 @@ If `claude` is not found: install Claude Code first, or use a block below.
|
||||
codex mcp add gbrain -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
**Grok Build** (verify with `grok mcp doctor gbrain` — the add is lazy)
|
||||
```bash
|
||||
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.17.0",
|
||||
"version": "0.46.4.0",
|
||||
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
|
||||
"family": "bundle-plugin",
|
||||
"configSchema": {
|
||||
|
||||
+5
-1
@@ -50,6 +50,9 @@
|
||||
"check:admin-scope-drift": "bash scripts/check-admin-scope-drift.sh",
|
||||
"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",
|
||||
@@ -131,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",
|
||||
@@ -156,7 +160,7 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.45.17.0",
|
||||
"version": "0.46.4.0",
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.5",
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
#!/usr/bin/env bun
|
||||
// scripts/build-pglite-snapshot.ts
|
||||
//
|
||||
// TZ pinned to UTC BEFORE any PGLite work: dumpDataDir bakes this process's
|
||||
// TimeZone into the tar's cluster defaults. Building under the host zone made
|
||||
// restored engines run sessions in the build machine's zone (the engine also
|
||||
// re-pins at restore — this is the belt to that suspender, and it keeps any
|
||||
// OTHER zone-derived state baked into the tar deterministic across hosts).
|
||||
process.env.TZ = 'UTC';
|
||||
//
|
||||
// Tier 3 fast-restore: boot a fresh PGLite, run the full initSchema (forward
|
||||
// bootstrap + PGLITE_SCHEMA_SQL + every migration), dump the post-init state
|
||||
// to a tar fixture. Test files that read GBRAIN_PGLITE_SNAPSHOT can skip the
|
||||
|
||||
@@ -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
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/check-grok-pin.sh — grok pin consistency guard.
|
||||
#
|
||||
# GROK-CLI-PIN.md is the single observed-behavior source for the grok
|
||||
# integration; its pins fan out to the heavy-tests grok-door job env, the
|
||||
# GrokRunner 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/GROK-CLI-PIN.md carries a machine-stable stamp block
|
||||
# (`<!-- grok-pin: key=value -->`, one per line) including
|
||||
# distribution_kind (npm | installer).
|
||||
# 2. The grok-door job env in .github/workflows/heavy-tests.yml must carry
|
||||
# EXACTLY the pin set for the chosen distribution_kind:
|
||||
# npm: GROK_VERSION==grok_version, GROK_NPM_PACKAGE==npm_package,
|
||||
# GROK_NPM_INTEGRITY==npm_integrity; no GROK_INSTALL_SHA256.
|
||||
# installer: GROK_VERSION==grok_version,
|
||||
# GROK_INSTALL_SHA256==installer_sha256; no GROK_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 grok-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 grok-door job yet →
|
||||
# SKIP (exit 0), matching scripts/check-bootstrap-tag.sh. Test override:
|
||||
# GBRAIN_GROK_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_GROK_PIN_GUARD_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
PIN_FILE="$ROOT/docs/mcp/GROK-CLI-PIN.md"
|
||||
WORKFLOW="$ROOT/.github/workflows/heavy-tests.yml"
|
||||
|
||||
if [ ! -f "$WORKFLOW" ]; then
|
||||
echo "check-grok-pin: SKIP (no $WORKFLOW)"
|
||||
exit 0
|
||||
fi
|
||||
if ! grep -q '^ grok-door:' "$WORKFLOW"; then
|
||||
echo "check-grok-pin: SKIP (no grok-door job in heavy-tests.yml yet)"
|
||||
exit 0
|
||||
fi
|
||||
# Once the grok-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-grok-pin: FAIL — grok-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-grok-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 "^<!-- grok-pin: $1=" "$PIN_FILE" || true; } | head -1 \
|
||||
| sed -e 's/^<!-- grok-pin: [a-z0-9_]*=//' -e 's/ -->$//'
|
||||
}
|
||||
|
||||
# Duplicate stamps are drift bait (two values, which one is real?).
|
||||
dupes=$({ grep -E '^<!-- grok-pin: ' "$PIN_FILE" || true; } | sed -e 's/^<!-- grok-pin: //' -e 's/=.*$//' | sort | uniq -d)
|
||||
[ -n "$dupes" ] && fail "duplicate grok-pin stamp(s) in GROK-CLI-PIN.md: $dupes"
|
||||
|
||||
DIST_KIND=$(stamp distribution_kind)
|
||||
GROK_VERSION_PIN=$(stamp grok_version)
|
||||
[ -n "$DIST_KIND" ] || fail "GROK-CLI-PIN.md is missing the distribution_kind stamp"
|
||||
[ -n "$GROK_VERSION_PIN" ] || fail "GROK-CLI-PIN.md is missing the grok_version stamp"
|
||||
case "$DIST_KIND" in
|
||||
npm|installer) ;;
|
||||
*) fail "distribution_kind stamp must be npm or installer; got '$DIST_KIND'" ;;
|
||||
esac
|
||||
|
||||
# --- 2. Extract the grok-door job block --------------------------------------
|
||||
# Jobs sit at 2-space indent; the block ends at the next 2-space-indented key.
|
||||
job_block=$(awk '
|
||||
/^ grok-door:/ { f = 1; print; next }
|
||||
f && /^ [A-Za-z0-9_-]+:/ { exit }
|
||||
f { print }
|
||||
' "$WORKFLOW")
|
||||
[ -n "$job_block" ] || fail "could not extract the grok-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 GROK_VERSION)
|
||||
WF_NPM_PACKAGE=$(wf_env GROK_NPM_PACKAGE)
|
||||
WF_NPM_INTEGRITY=$(wf_env GROK_NPM_INTEGRITY)
|
||||
WF_INSTALL_SHA=$(wf_env GROK_INSTALL_SHA256)
|
||||
|
||||
[ -n "$WF_VERSION" ] || fail "grok-door job env is missing GROK_VERSION"
|
||||
[ "$WF_VERSION" = "$GROK_VERSION_PIN" ] || fail "GROK_VERSION drift — workflow '$WF_VERSION' vs pin-doc stamp '$GROK_VERSION_PIN' (update together; see the pin doc's re-observation checklist)"
|
||||
|
||||
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 GROK-CLI-PIN.md is missing the npm_package stamp"
|
||||
[ -n "$NPM_INTEGRITY_PIN" ] || fail "distribution_kind=npm but GROK-CLI-PIN.md is missing the npm_integrity stamp"
|
||||
[ -n "$WF_NPM_PACKAGE" ] || fail "distribution_kind=npm but the grok-door job env is missing GROK_NPM_PACKAGE"
|
||||
[ -n "$WF_NPM_INTEGRITY" ] || fail "distribution_kind=npm but the grok-door job env is missing GROK_NPM_INTEGRITY"
|
||||
[ "$WF_NPM_PACKAGE" = "$NPM_PACKAGE_PIN" ] || fail "GROK_NPM_PACKAGE drift — workflow '$WF_NPM_PACKAGE' vs stamp '$NPM_PACKAGE_PIN'"
|
||||
[ "$WF_NPM_INTEGRITY" = "$NPM_INTEGRITY_PIN" ] || fail "GROK_NPM_INTEGRITY drift — workflow vs stamp mismatch"
|
||||
# npm_version is a documented near-duplicate of grok_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" != "$GROK_VERSION_PIN" ]; then
|
||||
fail "npm_version stamp ($NPM_VERSION_PIN) disagrees with grok_version stamp ($GROK_VERSION_PIN) — update together"
|
||||
fi
|
||||
[ -z "$WF_INSTALL_SHA" ] || fail "distribution_kind=npm but the grok-door job also pins GROK_INSTALL_SHA256 — one provisioning mode only (mode exclusivity)"
|
||||
else
|
||||
INSTALL_SHA_PIN=$(stamp installer_sha256)
|
||||
[ -n "$INSTALL_SHA_PIN" ] || fail "distribution_kind=installer but GROK-CLI-PIN.md is missing the installer_sha256 stamp"
|
||||
[ -n "$WF_INSTALL_SHA" ] || fail "distribution_kind=installer but the grok-door job env is missing GROK_INSTALL_SHA256"
|
||||
[ "$WF_INSTALL_SHA" = "$INSTALL_SHA_PIN" ] || fail "GROK_INSTALL_SHA256 drift — workflow vs stamp mismatch"
|
||||
[ -z "$WF_NPM_INTEGRITY" ] || fail "distribution_kind=installer but the grok-door job also pins GROK_NPM_INTEGRITY — one provisioning mode only (mode exclusivity)"
|
||||
fi
|
||||
|
||||
echo "check-grok-pin: ok ($DIST_KIND mode, grok $GROK_VERSION_PIN)"
|
||||
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
|
||||
|
||||
+377
-98
@@ -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)
|
||||
@@ -69,6 +72,10 @@ import {
|
||||
saveTranscript,
|
||||
seedClaudeTuiConfig,
|
||||
parseDriveCommand,
|
||||
ptySupported,
|
||||
redactSecrets,
|
||||
stripAnsi,
|
||||
MIN_REDACT_SECRET_LEN,
|
||||
type TtySession,
|
||||
} from '../test/helpers/tty-harness.ts';
|
||||
|
||||
@@ -118,11 +125,14 @@ interface CliArgs {
|
||||
driveArgv: string[];
|
||||
}
|
||||
|
||||
/** Provider keys the hermetic base allows through; --keyless drops them. */
|
||||
/** Provider keys the hermetic base allows through; --keyless drops them.
|
||||
* Also the redaction-map source: every non-empty value here is scrubbed
|
||||
* from every written artifact. */
|
||||
const PROVIDER_KEY_NAMES = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'OPENAI_API_KEY',
|
||||
'XAI_API_KEY',
|
||||
'GSTACK_ANTHROPIC_API_KEY',
|
||||
'GSTACK_OPENAI_API_KEY',
|
||||
];
|
||||
@@ -202,10 +212,25 @@ interface ScenarioCtx {
|
||||
* suffixes). ALWAYS deleted at cleanup — --keep keeps transcripts and
|
||||
* hermetic dirs for forensics, never credentials. */
|
||||
secretPaths: string[];
|
||||
/** Secret VALUES (name → value) redacted from every written artifact —
|
||||
* transcripts, the live screen mirror, events.jsonl. Structural, not
|
||||
* checklist: writes go through redactSecrets at the write site. */
|
||||
redact: Record<string, string>;
|
||||
events: Array<{ tMs: number; kind: 'input' | 'note' | 'screen'; data: string }>;
|
||||
t0: number;
|
||||
}
|
||||
|
||||
/** Non-empty provider-key VALUES currently in the environment — the redaction
|
||||
* map for every artifact write. */
|
||||
function buildRedactMap(): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const name of PROVIDER_KEY_NAMES) {
|
||||
const v = process.env[name];
|
||||
if (v && v.trim().length >= MIN_REDACT_SECRET_LEN) out[name] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function newCtx(args: CliArgs, needsGbrain: boolean): ScenarioCtx {
|
||||
const outDir = path.resolve(
|
||||
args.dir ?? path.join(REPO_ROOT, '.context', 'dx-runs', `${args.scenario}-${nowStamp()}`),
|
||||
@@ -217,6 +242,7 @@ function newCtx(args: CliArgs, needsGbrain: boolean): ScenarioCtx {
|
||||
keep: args.keep,
|
||||
cleanups: [],
|
||||
secretPaths: [],
|
||||
redact: buildRedactMap(),
|
||||
events: [],
|
||||
t0: Date.now(),
|
||||
};
|
||||
@@ -266,7 +292,10 @@ function finishCtx(ctx: ScenarioCtx): void {
|
||||
scrubSecrets(ctx);
|
||||
fs.writeFileSync(
|
||||
path.join(ctx.outDir, 'events.jsonl'),
|
||||
ctx.events.map((e) => JSON.stringify(e)).join('\n') + (ctx.events.length ? '\n' : ''),
|
||||
redactSecrets(
|
||||
ctx.events.map((e) => JSON.stringify(e)).join('\n') + (ctx.events.length ? '\n' : ''),
|
||||
ctx.redact,
|
||||
),
|
||||
);
|
||||
if (ctx.keep && ctx.secretPaths.length > 0) {
|
||||
log(`--keep: retained hermetic dirs, but scrubbed ${ctx.secretPaths.length} credential file(s)`);
|
||||
@@ -290,13 +319,20 @@ function finishCtx(ctx: ScenarioCtx): void {
|
||||
}
|
||||
|
||||
/** Live session mirror so a watcher (or a Conductor agent) can follow along:
|
||||
* session/screen.txt (latest visible tail) + session/status.json. */
|
||||
function mirrorSession(dir: string, session: TtySession): () => void {
|
||||
* session/screen.txt (latest visible tail) + session/status.json. Each tick
|
||||
* strips only a bounded RAW tail (a full-buffer stripAnsi every 500ms is
|
||||
* quadratic on a 25-minute session) and redacts before writing — the mirror
|
||||
* is a live artifact that outlives an interrupted run, so it must never
|
||||
* carry a raw key even transiently. */
|
||||
function mirrorSession(dir: string, session: TtySession, redact?: Record<string, string>): () => void {
|
||||
const sessDir = path.join(dir, 'session');
|
||||
fs.mkdirSync(sessDir, { recursive: true });
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
fs.writeFileSync(path.join(sessDir, 'screen.txt'), session.visible().slice(-8000));
|
||||
fs.writeFileSync(
|
||||
path.join(sessDir, 'screen.txt'),
|
||||
redactSecrets(stripAnsi(session.raw().slice(-131_072)).slice(-8000), redact),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(sessDir, 'status.json'),
|
||||
JSON.stringify(
|
||||
@@ -322,6 +358,7 @@ function saveSession(ctx: ScenarioCtx, name: string, session: TtySession, extraM
|
||||
saveTranscript(dir, {
|
||||
frames: session.frames(),
|
||||
raw: session.raw(),
|
||||
redact: ctx.redact,
|
||||
meta: {
|
||||
scenario: name || path.basename(ctx.outDir),
|
||||
argv: session.argv,
|
||||
@@ -331,6 +368,53 @@ function saveSession(ctx: ScenarioCtx, name: string, session: TtySession, extraM
|
||||
...extraMeta,
|
||||
},
|
||||
});
|
||||
assertNoSecrets(dir, ctx.redact, ctx.t0);
|
||||
}
|
||||
|
||||
/** Independent double-check of the structural redaction above: grep every
|
||||
* written artifact for each raw secret value and HARD-FAIL on a hit (delete
|
||||
* the leaking file, mark the run failed). A leak here means redactSecrets
|
||||
* missed a rendering (e.g. ANSI-interleaved) — that is a bug to fix, never
|
||||
* a warning to scroll past. */
|
||||
function assertNoSecrets(dir: string, redact: Record<string, string>, sinceMs: number): void {
|
||||
const values = Object.entries(redact).filter(([, v]) => v && v.length >= MIN_REDACT_SECRET_LEN);
|
||||
if (values.length === 0) return;
|
||||
const walk = (d: string): string[] =>
|
||||
fs.readdirSync(d, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = path.join(d, e.name);
|
||||
return e.isDirectory() ? walk(p) : e.isFile() ? [p] : [];
|
||||
});
|
||||
let leaked = false;
|
||||
for (const file of walk(dir)) {
|
||||
// NEVER touch files that predate this run: --dir can point anywhere
|
||||
// (repo root, even $HOME) and deleting a pre-existing .env that happens
|
||||
// to contain the key would be data loss, not leak containment.
|
||||
try { if (fs.statSync(file).mtimeMs < sinceMs - 1000) continue; } catch { continue; }
|
||||
let body: string;
|
||||
try { body = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
||||
// Joined-data pass for frame records: a secret split across JSONL
|
||||
// records never appears contiguously in the file body.
|
||||
let joinedData = '';
|
||||
if (file.endsWith('.jsonl')) {
|
||||
for (const line of body.split('\n')) {
|
||||
try { joinedData += String(JSON.parse(line)?.data ?? ''); } catch { /* not a data record */ }
|
||||
}
|
||||
}
|
||||
for (const [name, value] of values) {
|
||||
const hit =
|
||||
body.includes(value) ||
|
||||
stripAnsi(body).includes(value) || // ANSI-interleaved rendering
|
||||
(joinedData !== '' && (joinedData.includes(value) || stripAnsi(joinedData).includes(value)));
|
||||
if (hit) {
|
||||
leaked = true;
|
||||
fs.rmSync(file, { force: true });
|
||||
log(`SECRET LEAK: ${file} contained ${name} despite structural redaction — file deleted; fix redactSecrets coverage before trusting transcripts`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (leaked) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── scenario: help ───────────────────────────────────────────────────────────
|
||||
@@ -376,7 +460,7 @@ async function scenarioInit(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
|
||||
dropEnv: args.keyless ? PROVIDER_KEY_NAMES : undefined,
|
||||
timeoutMs: 600_000,
|
||||
});
|
||||
const stopMirror = mirrorSession(ctx.outDir, session);
|
||||
const stopMirror = mirrorSession(ctx.outDir, session, ctx.redact);
|
||||
|
||||
const steps: string[] = [];
|
||||
let lastMarkPos = 0;
|
||||
@@ -429,7 +513,10 @@ async function settlePastBootDialogs(
|
||||
while (Date.now() < deadline) {
|
||||
await session.waitForQuiet({ quietMs: 2000, timeoutMs: 30_000 });
|
||||
if (session.exited()) return;
|
||||
const tail = session.visible().slice(-2500);
|
||||
// Bounded strip: a repaint-heavy TUI (observed: grok's splash animation)
|
||||
// grows the raw buffer by MBs — stripping the FULL buffer every
|
||||
// iteration is the quadratic hot loop; strip a raw tail instead.
|
||||
const tail = stripAnsi(session.raw().slice(-131_072)).slice(-2500);
|
||||
if (!handled.has('trust') && /trust this ?folder/i.test(tail.replace(/\s+/g, ' '))) {
|
||||
handled.add('trust');
|
||||
event(ctx, 'note', 'boot dialog: workspace trust — accepted (option 1)');
|
||||
@@ -456,10 +543,120 @@ async function settlePastBootDialogs(
|
||||
session.sendKey('Enter');
|
||||
continue;
|
||||
}
|
||||
// Grok Build sign-in screen (observed keyless copy: "Not signed in").
|
||||
// No unattended path exists past it — record the friction and stop
|
||||
// settling; the caller's wall clock must not burn on a login dialog.
|
||||
if (/not signed in/i.test(tail)) {
|
||||
event(ctx, 'note', 'boot dialog: grok sign-in required — no unattended path; stopping settle');
|
||||
return;
|
||||
}
|
||||
// Quiet with no KNOWN dialog: if the tail still LOOKS like a prompt
|
||||
// (numbered options / y-n / picker glyph), note it — a silently
|
||||
// mis-settled boot is otherwise invisible in the audit trail.
|
||||
if (/(?:^|\n)\s*(?:\d+\.\s|[❯›]\s)|\((?:y\/n|Y\/n)\)/m.test(tail.slice(-400))) {
|
||||
event(ctx, 'note', 'settle: quiet with an unmatched dialog-shaped tail — proceeding (note-only; check the transcript if the paste lands oddly)');
|
||||
}
|
||||
return; // quiet + no dialog = at the input prompt
|
||||
}
|
||||
}
|
||||
|
||||
/** Stage the compiled gbrain into a fresh bin dir (PATH-prepend target) so
|
||||
* the agent's bare `gbrain` runs this checkout. */
|
||||
function stageBinDir(ctx: ScenarioCtx): string {
|
||||
const binDir = tmp(ctx, 'gb-dx-bin-');
|
||||
fs.copyFileSync(ctx.gbrainBin, path.join(binDir, 'gbrain'));
|
||||
fs.chmodSync(path.join(binDir, 'gbrain'), 0o755);
|
||||
return binDir;
|
||||
}
|
||||
|
||||
interface InstallSessionOpts {
|
||||
argv: string[];
|
||||
cwd: string;
|
||||
env: Record<string, string | undefined>;
|
||||
extraAllow?: string[];
|
||||
dropEnv?: string[];
|
||||
/** The pasted prompt. Per-agent: the bootstrap runbook block for
|
||||
* claude/codex, a brain-only GROK.md-driven block for grok. */
|
||||
prompt: string;
|
||||
/** Success copy to race against (default: the bootstrap verify patterns).
|
||||
* Brain-only scenarios pass their own — grok's is the doctor banner. */
|
||||
successPatterns?: Array<RegExp | string>;
|
||||
timeoutMs?: number;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The shared install-session tail the claude/codex/grok scenarios all run:
|
||||
* launch → mirror → settle boot dialogs → paste the prompt → race
|
||||
* verify-success copy vs exit → let trailing output land → save. Per-agent
|
||||
* PREPARATION (TUI seeds, auth copies, git init) deliberately stays in each
|
||||
* scenario — the loop is what was duplicated, the prep genuinely differs. */
|
||||
async function runInstallSession(ctx: ScenarioCtx, opts: InstallSessionOpts): Promise<void> {
|
||||
const timeoutMs = opts.timeoutMs ?? 1_800_000;
|
||||
let earlyTerminal: string | undefined;
|
||||
log(`watch live: cat ${path.join(ctx.outDir, 'session', 'screen.txt')}`);
|
||||
const session = launchTty(opts.argv, {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
extraAllow: opts.extraAllow,
|
||||
dropEnv: opts.dropEnv,
|
||||
timeoutMs,
|
||||
});
|
||||
const stopMirror = mirrorSession(ctx.outDir, session, ctx.redact);
|
||||
try {
|
||||
await settlePastBootDialogs(ctx, session);
|
||||
// Sign-in wall / textless splash: no unattended path exists past either.
|
||||
// Observed (grok v1.0.4 keyless): headless prints "Not signed in"; the
|
||||
// TUI loops a full-screen glyph animation with NO textual prompt for 8+
|
||||
// minutes. Try one Enter (the common skip-splash gesture), then if the
|
||||
// screen still carries no meaningful text — or shows the sign-in copy —
|
||||
// record the friction (that IS the keyless measurement) and end early
|
||||
// instead of pasting into a wall for the full wall clock.
|
||||
const tailText = () => stripAnsi(session.raw().slice(-131_072)).slice(-2500);
|
||||
// Sign-in copy, both surfaces observed (GROK-CLI-PIN.md): headless prints
|
||||
// "Not signed in"; the TUI settles (~6s, after an intro animation) onto
|
||||
// "Approve in your browser to finish signing in" + a device code.
|
||||
const signInWall = () =>
|
||||
/not signed in|approve in your browser|finish signing in|sign in with/i.test(tailText());
|
||||
// Word-like text only: splash/spinner screens render Braille-pattern
|
||||
// glyphs (U+2800 range, observed) with scattered digits/SGR residue but
|
||||
// ZERO 3+-letter runs, while any real prompt/sign-in screen carries
|
||||
// words. Counting letter runs beats enumerating glyph exceptions.
|
||||
const meaningfulLen = (s: string) => (s.match(/[A-Za-z]{3,}/g) ?? []).join('').length;
|
||||
if (!session.exited() && (signInWall() || meaningfulLen(tailText()) < 40)) {
|
||||
event(ctx, 'note', 'sign-in wall or textless splash — sending one Enter (skip-splash attempt)');
|
||||
session.sendKey('Enter');
|
||||
await Bun.sleep(3000);
|
||||
if (!session.exited() && (signInWall() || meaningfulLen(tailText()) < 40)) {
|
||||
event(ctx, 'note', 'sign-in wall / no textual prompt persists — ending session early (keyless friction recorded)');
|
||||
earlyTerminal = 'sign-in-wall-or-splash';
|
||||
return; // finally owns cleanup; save happens after it
|
||||
}
|
||||
}
|
||||
event(ctx, 'input', 'paste install prompt');
|
||||
// Scope the verify match to output AFTER the paste: the pasted prompt
|
||||
// itself contains verify-adjacent copy, and matching the full buffer
|
||||
// re-scans a growing transcript every poll.
|
||||
const pasteMark = session.mark();
|
||||
session.send(opts.prompt);
|
||||
await Bun.sleep(1500);
|
||||
session.sendKey('Enter');
|
||||
const raceBudget = Math.max(timeoutMs - 300_000, Math.floor(timeoutMs * 0.8));
|
||||
const done = await Promise.race([
|
||||
session
|
||||
.waitForAny(opts.successPatterns ?? VERIFY_SUCCESS_PATTERNS, { timeoutMs: raceBudget, since: pasteMark })
|
||||
.then(() => 'verify-signal')
|
||||
.catch(() => 'no-signal'),
|
||||
session.waitForExit(raceBudget).then(() => 'exited'),
|
||||
]);
|
||||
event(ctx, 'note', `terminal condition: ${done}`);
|
||||
await session.waitForQuiet({ quietMs: 5000, timeoutMs: 60_000 });
|
||||
} finally {
|
||||
stopMirror();
|
||||
await session.close();
|
||||
}
|
||||
saveSession(ctx, '', session, earlyTerminal ? { ...opts.meta, terminal: earlyTerminal } : opts.meta);
|
||||
}
|
||||
|
||||
/** The README paste block, pointed at THIS repo's runbook, plus a persona
|
||||
* appendix so the interview completes unattended. The appendix is the ONLY
|
||||
* deviation from the shipped block — flagged in meta so the audit discounts it. */
|
||||
@@ -481,14 +678,12 @@ function installPrompt(): string {
|
||||
);
|
||||
}
|
||||
|
||||
async function scenarioClaudeInstall(ctx: ScenarioCtx): Promise<void> {
|
||||
async function scenarioClaudeInstall(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
|
||||
const home = tmp(ctx, 'gb-dx-home-');
|
||||
const cfg = tmp(ctx, 'gb-dx-ccfg-');
|
||||
const gbHome = tmp(ctx, 'gb-dx-gbhome-');
|
||||
const ws = tmp(ctx, 'gb-dx-ws-');
|
||||
const binDir = tmp(ctx, 'gb-dx-bin-');
|
||||
fs.copyFileSync(ctx.gbrainBin, path.join(binDir, 'gbrain'));
|
||||
fs.chmodSync(path.join(binDir, 'gbrain'), 0o755);
|
||||
const binDir = stageBinDir(ctx);
|
||||
|
||||
seedClaudeTuiConfig(cfg, {
|
||||
apiKey: process.env.ANTHROPIC_API_KEY ?? process.env.GSTACK_ANTHROPIC_API_KEY,
|
||||
@@ -501,61 +696,33 @@ async function scenarioClaudeInstall(ctx: ScenarioCtx): Promise<void> {
|
||||
ctx.secretPaths.push(path.join(cfg, '.claude.json'));
|
||||
|
||||
log('REAL interactive claude running the paste-in bootstrap (10-25 min, real API cost)');
|
||||
log(`watch live: cat ${path.join(ctx.outDir, 'session', 'screen.txt')}`);
|
||||
const session = launchTty(
|
||||
// --dangerously-skip-permissions: v1 measures flow + copy + stalls without
|
||||
// permission-dialog babysitting. Permission-prompt COUNT is a separate
|
||||
// drive-mode pass (the dialogs are Claude Code's chrome, not gbrain copy).
|
||||
['claude', '--dangerously-skip-permissions'],
|
||||
{
|
||||
cwd: ws,
|
||||
env: {
|
||||
HOME: home,
|
||||
CLAUDE_CONFIG_DIR: cfg,
|
||||
GBRAIN_HOME: gbHome,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
},
|
||||
timeoutMs: 1_800_000,
|
||||
await runInstallSession(ctx, {
|
||||
// dangerously-skip-permissions (spelled dash-free here): v1 measures flow
|
||||
// + copy + stalls without permission-dialog babysitting. Permission-prompt
|
||||
// COUNT is a separate drive-mode pass (the dialogs are Claude Code's
|
||||
// chrome, not gbrain copy).
|
||||
argv: ['claude', '--dangerously-skip-permissions'],
|
||||
cwd: ws,
|
||||
env: {
|
||||
HOME: home,
|
||||
CLAUDE_CONFIG_DIR: cfg,
|
||||
GBRAIN_HOME: gbHome,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
},
|
||||
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)',
|
||||
},
|
||||
);
|
||||
const stopMirror = mirrorSession(ctx.outDir, session);
|
||||
try {
|
||||
// Get past first-run chrome (trust dialog, bypass warning), then paste.
|
||||
await settlePastBootDialogs(ctx, session);
|
||||
event(ctx, 'input', 'paste install prompt');
|
||||
session.send(installPrompt());
|
||||
await Bun.sleep(1500);
|
||||
session.sendKey('Enter');
|
||||
// Run until verify-success copy or exit or wall clock.
|
||||
const done = await Promise.race([
|
||||
session
|
||||
.waitForAny(VERIFY_SUCCESS_PATTERNS, {
|
||||
timeoutMs: 1_500_000,
|
||||
})
|
||||
.then(() => 'verify-signal')
|
||||
.catch(() => 'no-signal'),
|
||||
session.waitForExit(1_500_000).then(() => 'exited'),
|
||||
]);
|
||||
event(ctx, 'note', `terminal condition: ${done}`);
|
||||
// Let trailing output land.
|
||||
await session.waitForQuiet({ quietMs: 5000, timeoutMs: 60_000 });
|
||||
} finally {
|
||||
stopMirror();
|
||||
await session.close();
|
||||
}
|
||||
saveSession(ctx, '', session, {
|
||||
promptDeviation: 'unattended persona appendix + local runbook path + preinstalled binary',
|
||||
runbook: 'BOOTSTRAP_FOR_AGENTS.md (local)',
|
||||
});
|
||||
}
|
||||
|
||||
async function scenarioCodexInstall(ctx: ScenarioCtx): Promise<void> {
|
||||
async function scenarioCodexInstall(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 = tmp(ctx, 'gb-dx-bin-');
|
||||
fs.copyFileSync(ctx.gbrainBin, path.join(binDir, 'gbrain'));
|
||||
fs.chmodSync(path.join(binDir, 'gbrain'), 0o755);
|
||||
const binDir = stageBinDir(ctx);
|
||||
|
||||
// Hermetic ~/.codex with ONLY the operator's auth (same posture as the
|
||||
// codex door test). codex refuses untrusted cwds — a git repo satisfies it.
|
||||
@@ -573,46 +740,152 @@ async function scenarioCodexInstall(ctx: ScenarioCtx): Promise<void> {
|
||||
spawnSync('git', ['-C', ws, 'config', 'user.name', 'DX Explore']);
|
||||
|
||||
log('REAL interactive codex running the paste-in bootstrap (10-25 min, real API cost)');
|
||||
log(`watch live: cat ${path.join(ctx.outDir, 'session', 'screen.txt')}`);
|
||||
const session = launchTty(
|
||||
['codex', '--sandbox', 'workspace-write', '--ask-for-approval', 'never'],
|
||||
{
|
||||
cwd: ws,
|
||||
env: {
|
||||
HOME: home,
|
||||
CODEX_HOME: codexHome,
|
||||
GBRAIN_HOME: gbHome,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
},
|
||||
extraAllow: ['OPENAI_API_KEY', 'CODEX_*'],
|
||||
timeoutMs: 1_800_000,
|
||||
await runInstallSession(ctx, {
|
||||
argv: ['codex', '--sandbox', 'workspace-write', '--ask-for-approval', 'never'],
|
||||
cwd: ws,
|
||||
env: {
|
||||
HOME: home,
|
||||
CODEX_HOME: codexHome,
|
||||
GBRAIN_HOME: gbHome,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
},
|
||||
extraAllow: ['OPENAI_API_KEY', 'CODEX_*'],
|
||||
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)',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── scenario: grok-install ───────────────────────────────────────────────────
|
||||
|
||||
/** Brain-only success copy for the grok scenario: the doctor banner proves
|
||||
* the registration handshook (observed, docs/mcp/GROK-CLI-PIN.md) — grok has
|
||||
* NO bootstrap path, so the bootstrap verify patterns must not be its bar. */
|
||||
const GROK_INSTALL_SUCCESS_PATTERNS: Array<RegExp | string> = [
|
||||
/7 tools discovered/i,
|
||||
/handshake OK/i,
|
||||
];
|
||||
|
||||
/** GROK.md-driven brain-only prompt — deliberately NOT the bootstrap paste
|
||||
* block: the docs classify grok as brain-only install (no `gbrain bootstrap`
|
||||
* support), so the scenario must not test an unsupported flow. */
|
||||
function grokInstallPrompt(): string {
|
||||
const guide = path.join(REPO_ROOT, 'docs', 'mcp', 'GROK.md');
|
||||
return (
|
||||
`Read and follow: ${guide}\n` +
|
||||
`Goal: wire the gbrain memory brain into you (Grok Build) over stdio MCP — ` +
|
||||
`brain-only install, no bootstrap. Steps: ` +
|
||||
`1) run \`gbrain init --pglite --no-embedding --non-interactive\`; ` +
|
||||
`2) register gbrain exactly as the guide's Register section shows; ` +
|
||||
`3) verify with \`grok mcp doctor gbrain\` — you are not done until it reports ` +
|
||||
`the tools-discovered check passing; ` +
|
||||
`4) one recall round-trip: use the gbrain remember tool to store ` +
|
||||
`"${PERSONA.AGENT_NAME} prefers ${PERSONA.VOICE_REGISTER}" and then recall it.\n\n` +
|
||||
`[Unattended-run appendix — I am stepping away; do not wait for my input. ` +
|
||||
`gbrain is already installed and on PATH. If a step needs auth that is ` +
|
||||
`unavailable, note the exact error and stop.]`
|
||||
);
|
||||
const stopMirror = mirrorSession(ctx.outDir, session);
|
||||
try {
|
||||
await settlePastBootDialogs(ctx, session);
|
||||
event(ctx, 'input', 'paste install prompt');
|
||||
session.send(installPrompt());
|
||||
await Bun.sleep(1500);
|
||||
session.sendKey('Enter');
|
||||
const done = await Promise.race([
|
||||
session
|
||||
.waitForAny(VERIFY_SUCCESS_PATTERNS, {
|
||||
timeoutMs: 1_500_000,
|
||||
})
|
||||
.then(() => 'verify-signal')
|
||||
.catch(() => 'no-signal'),
|
||||
session.waitForExit(1_500_000).then(() => 'exited'),
|
||||
]);
|
||||
event(ctx, 'note', `terminal condition: ${done}`);
|
||||
await session.waitForQuiet({ quietMs: 5000, timeoutMs: 60_000 });
|
||||
} finally {
|
||||
stopMirror();
|
||||
await session.close();
|
||||
}
|
||||
saveSession(ctx, '', session, {
|
||||
promptDeviation: 'unattended persona appendix + local runbook path + preinstalled binary',
|
||||
runbook: 'BOOTSTRAP_FOR_AGENTS.md (local)',
|
||||
}
|
||||
|
||||
async function scenarioGrokInstall(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 ~/.grok seeded with the auto-update kill-switch (config-only
|
||||
// mechanism, default ON — observed v1.0.4). Auth travels via XAI_API_KEY
|
||||
// env only; grok MAY persist derived credentials after an authed session,
|
||||
// so the known candidate is pre-registered for the scrub (rm of a file
|
||||
// that never appears is a no-op).
|
||||
const grokHome = path.join(home, '.grok');
|
||||
fs.mkdirSync(grokHome, { recursive: true });
|
||||
// Verbatim kill-switch homes (update together): seedGrokConfig in
|
||||
// test/helpers/agent-harness.ts and the grok-door auth-preflight printf
|
||||
// in .github/workflows/heavy-tests.yml.
|
||||
fs.writeFileSync(path.join(grokHome, 'config.toml'), '[cli]\nauto_update = false\n');
|
||||
ctx.secretPaths.push(path.join(grokHome, 'mcp_credentials.json'));
|
||||
|
||||
log('REAL interactive grok running the GROK.md brain-only install (real API cost when authed)');
|
||||
log('keyless runs stop at the observed sign-in screen — that friction IS the measurement');
|
||||
await runInstallSession(ctx, {
|
||||
argv: ['grok'],
|
||||
cwd: ws,
|
||||
env: {
|
||||
HOME: home,
|
||||
GROK_HOME: grokHome,
|
||||
GBRAIN_HOME: gbHome,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
// Never let a keyless first-run bounce the OPERATOR's browser for
|
||||
// sign-in; the settle loop stops at the observed "Not signed in" copy.
|
||||
BROWSER: '/usr/bin/false',
|
||||
},
|
||||
extraAllow: ['XAI_API_KEY'],
|
||||
// --keyless drops provider keys AFTER extraAllow re-admission — a keyless
|
||||
// grok-install must measure the sign-in wall, not silently run authed.
|
||||
dropEnv: args.keyless ? PROVIDER_KEY_NAMES : undefined,
|
||||
prompt: grokInstallPrompt(),
|
||||
successPatterns: GROK_INSTALL_SUCCESS_PATTERNS,
|
||||
meta: {
|
||||
promptDeviation: 'unattended appendix + local GROK.md path + preinstalled binary',
|
||||
runbook: 'docs/mcp/GROK.md (local, brain-only — grok has no bootstrap path)',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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)',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -650,7 +923,7 @@ async function scenarioDrive(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
|
||||
env,
|
||||
timeoutMs: 3_600_000,
|
||||
});
|
||||
const stopMirror = mirrorSession(ctx.outDir, session);
|
||||
const stopMirror = mirrorSession(ctx.outDir, session, ctx.redact);
|
||||
|
||||
let offset = 0;
|
||||
let stopping = false;
|
||||
@@ -701,10 +974,16 @@ const SCENARIOS: Record<string, { needsGbrain: boolean; run: (ctx: ScenarioCtx,
|
||||
init: { needsGbrain: true, run: scenarioInit },
|
||||
'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 },
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!ptySupported()) {
|
||||
log('this Bun lacks PTY (terminal:) support — upgrade Bun (engines.bun in package.json) before running dx scenarios');
|
||||
process.exit(2);
|
||||
}
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const scenario = SCENARIOS[args.scenario];
|
||||
if (!scenario) {
|
||||
|
||||
@@ -156,6 +156,12 @@ export function renderRegistryModule(registry: Record<string, string[]>): string
|
||||
// Regenerate: bun run build:flag-registry
|
||||
// Freshness + drift pinned by test/cli-flag-validation.test.ts (#2185).
|
||||
//
|
||||
// Merge conflict here? Do not hand-merge it. This file is regenerated on most
|
||||
// upstream waves, so a branch that also regenerates it conflicts on the whole
|
||||
// body. Take the base branch's copy wholesale, then re-run the command above —
|
||||
// the freshness test named above fails loudly if that regeneration was done
|
||||
// against the wrong base.
|
||||
//
|
||||
// Per-command legal flags for CLI_ONLY commands, derived from each command's
|
||||
// source (case block + imported modules + one level of relative imports +
|
||||
// scripts/generate-flag-registry.ts EXTRA_FLAGS). Deliberately over-inclusive
|
||||
|
||||
@@ -60,3 +60,6 @@ check-wasm-embedded.sh buildfresh exempt binary embed check
|
||||
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).
|
||||
|
||||
+8
-5
@@ -86,11 +86,14 @@ mkdir -p "$E2E_TMP_HOME/.gbrain"
|
||||
# (not an allowlist rebuild), so PATH, HOME, TMPDIR, CI, DATABASE_URL, and bun
|
||||
# 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, so the paid hermes
|
||||
# door suite structurally cannot fire under this runner (its venue is
|
||||
# heavy-tests.yml's direct bun test). Adapts GStack's buildHermeticEnv()
|
||||
# allowlist to gbrain's shell E2E runner.
|
||||
for _e2e_var in $(env | grep -oE '^(CONDUCTOR_|MCP_|OPENCLAW_|HERMES_|GBRAIN_)[A-Za-z0-9_]*' | sort -u); do
|
||||
# not inherit — which also scrubs GBRAIN_REAL_HERMES_E2E and
|
||||
# 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; 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_|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
|
||||
|
||||
@@ -68,6 +68,9 @@ CHECKS=(
|
||||
"check:no-double-retry"
|
||||
"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,
|
||||
@@ -103,7 +105,6 @@
|
||||
"test/cli-help-discoverability.test.ts": 1670,
|
||||
"test/cli-multimodal-integration.test.ts": 26677,
|
||||
"test/cli-options.test.ts": 1658,
|
||||
"test/cli-pty-runner.test.ts": 16,
|
||||
"test/cli-query-image.test.ts": 56,
|
||||
"test/cli.test.ts": 2633,
|
||||
"test/code-callers-cli.test.ts": 2,
|
||||
@@ -190,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,
|
||||
@@ -398,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,
|
||||
@@ -709,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);
|
||||
|
||||
@@ -28,11 +28,15 @@ import { parseProgressEvents, verifyExpectedPhases } from '../core/claw-test/pro
|
||||
import { resolveAgentRunner, listRegisteredAgents, registerAgentRunner, validateBinPathEnv } from '../core/claw-test/agent-runner.ts';
|
||||
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;
|
||||
@@ -412,6 +416,12 @@ async function runScripted(
|
||||
const AGENT_INSTALL_HINTS: Record<string, string> = {
|
||||
openclaw: 'install openclaw or set OPENCLAW_BIN',
|
||||
hermes: 'install hermes (https://hermes-agent.nousresearch.com) or set HERMES_BIN',
|
||||
// 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',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -998,5 +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 hermes`);
|
||||
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)',
|
||||
|
||||
@@ -551,7 +551,13 @@ export async function queryAgentClientSpend(engine: BrainEngine): Promise<AgentC
|
||||
SELECT SUM(spend_cents)::text
|
||||
FROM mcp_spend_log
|
||||
WHERE client_id = c.client_id
|
||||
AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC')
|
||||
-- Double AT TIME ZONE: the inner one yields NAIVE UTC-midnight;
|
||||
-- the outer one converts it back to a timestamptz INSTANT. Without
|
||||
-- it, the naive value is reinterpreted in the SESSION timezone, so
|
||||
-- any non-UTC session (host-tz PGLite, a tz-configured Postgres
|
||||
-- role) shifts the day boundary by the offset and today's spend
|
||||
-- underreports every evening.
|
||||
AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'
|
||||
), '0') AS spent_cents_today,
|
||||
COALESCE((
|
||||
SELECT SUM(estimated_cents)::text
|
||||
|
||||
+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. */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user