mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 17:02:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d941e9f918 | ||
|
|
26578a2478 | ||
|
|
7fdcd8bd2e | ||
|
|
6411150071 | ||
|
|
ac402f55f8 | ||
|
|
cf3527a40f | ||
|
|
e2b2819e63 | ||
|
|
2ae5d60b98 | ||
|
|
bd4c976a85 | ||
|
|
9b9bd8b241 | ||
|
|
fd9bb12b42 | ||
|
|
5bd2c51053 | ||
|
|
189bf856ee | ||
|
|
735dec83b7 | ||
|
|
fd0e371d5b | ||
|
|
136fc109c1 | ||
|
|
1243a7d3bb | ||
|
|
44eea64084 | ||
|
|
45bd04ff9f | ||
|
|
8a626999f0 | ||
|
|
033029f25d | ||
|
|
a729ca8a8c | ||
|
|
5087507de0 | ||
|
|
ed6e4e3219 | ||
|
|
f8b0ececcb | ||
|
|
ca260baaaa | ||
|
|
0c485415a5 | ||
|
|
0cfedd026d | ||
|
|
b92cc967df | ||
|
|
3eccd4ccd6 | ||
|
|
154814b095 | ||
|
|
9b720b04af | ||
|
|
e8785c1ab1 | ||
|
|
638dd0d247 | ||
|
|
697016f69d | ||
|
|
758a2d4293 | ||
|
|
52389dbe5b | ||
|
|
fb141969f5 | ||
|
|
fa5ec8399f | ||
|
|
3ce296e315 |
@@ -1,7 +1,7 @@
|
||||
name: Heavy Tests
|
||||
|
||||
# Heavy ops-shape tests under tests/heavy/. Cost minutes per run; NOT part
|
||||
# of default PR CI. Two triggers:
|
||||
# of default PR CI. Three triggers:
|
||||
# - Nightly schedule (catches regressions within 24h of merge to master).
|
||||
# - On-demand opt-in via PR label `heavy-tests` (slow loop kept off by default).
|
||||
# - Manual workflow_dispatch for triage.
|
||||
@@ -102,14 +102,15 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Real-agent door e2e: drives the ACTUAL `claude` + `codex` 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
|
||||
# both tests self-SKIP (describe.skipIf on binary/auth) and the job is a clean
|
||||
# no-op here. It exists so a self-hosted / manually-provisioned runner WITH
|
||||
# authed claude/codex (and ANTHROPIC/OPENAI creds) actually exercises the real
|
||||
# binaries. Heavy cadence only (nightly + `real-agent-e2e` label + dispatch);
|
||||
# NEVER the PR shard matrix.
|
||||
# Real-agent door e2e: drives the ACTUAL `claude` + `codex` + `hermes`
|
||||
# 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 /
|
||||
# manually-provisioned runner WITH authed claude/codex/hermes (and
|
||||
# ANTHROPIC/OPENAI creds) actually exercises the real binaries. Heavy cadence
|
||||
# only (nightly + `real-agent-e2e` label + dispatch); NEVER the PR shard
|
||||
# matrix.
|
||||
real-agent-e2e:
|
||||
name: Real-agent door e2e (skips without authed binaries)
|
||||
if: |
|
||||
@@ -118,6 +119,10 @@ jobs:
|
||||
contains(github.event.pull_request.labels.*.name, 'heavy-tests')
|
||||
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).
|
||||
GBRAIN_REAL_HERMES_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
@@ -125,15 +130,16 @@ jobs:
|
||||
bun-version: 1.3.13
|
||||
- run: bun install
|
||||
|
||||
# Reference both door tests; run only the ones present (the claude door
|
||||
# may land in a sibling PR). Missing binary/auth → the file self-skips, so
|
||||
# a stock runner reports a green no-op rather than failing.
|
||||
# Reference the door tests; run only the ones present (a door may land
|
||||
# in a sibling PR). Missing binary/auth → the file self-skips, so a
|
||||
# stock runner reports a green no-op rather than failing.
|
||||
- name: Run real-agent door tests
|
||||
run: |
|
||||
files=()
|
||||
for f in \
|
||||
test/e2e/bootstrap-real-claude.serial.test.ts \
|
||||
test/e2e/bootstrap-real-codex.serial.test.ts; do
|
||||
test/e2e/bootstrap-real-codex.serial.test.ts \
|
||||
test/e2e/install-real-hermes.serial.test.ts; do
|
||||
[ -f "$f" ] && files+=("$f")
|
||||
done
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
@@ -144,3 +150,187 @@ jobs:
|
||||
# --timeout: real-agent turns are slow (live claude/codex); the door
|
||||
# tests self-skip without authed binaries so this is a no-op elsewhere.
|
||||
bun test --timeout=600000 "${files[@]}"
|
||||
|
||||
# Hermes door e2e: unlike real-agent-e2e above (best-effort, self-skipping),
|
||||
# this job PROVISIONS the real hermes binary itself — pinned installer digest,
|
||||
# non-interactive auth + model pin — and then requires the door tests to
|
||||
# actually execute. It pays real API cost, so it stays label-gated at heavy
|
||||
# cadence (nightly + `real-agent-e2e`/`heavy-tests` label + dispatch); NEVER
|
||||
# the PR shard matrix. Loud-fail throughout: a runner that cannot install or
|
||||
# auth hermes fails this job rather than skipping.
|
||||
hermes-door:
|
||||
name: Hermes door e2e (real binary, loud-fail)
|
||||
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
|
||||
# Four serial door tests at 600s each plus the installer budget cannot
|
||||
# fit the sibling job's 20 minutes.
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
# Pin values documented in docs/mcp/HERMES-CLI-PIN.md — update them
|
||||
# together, deliberately, after reviewing upstream changes. The digest
|
||||
# pins the INSTALLER SCRIPT; the tag + commit pin the PAYLOAD it clones
|
||||
# (without them, the installer pulls upstream main into the runner that
|
||||
# later holds secrets). The commit is v2026.8.3's dereferenced SHA —
|
||||
# immutable even if the tag moves.
|
||||
HERMES_VERSION: "0.20.0"
|
||||
HERMES_GIT_TAG: "v2026.8.3"
|
||||
HERMES_GIT_COMMIT: "3c27eb6234bf91b8ceee9e9071591b31e9b148cb"
|
||||
HERMES_INSTALL_SHA256: "c118ff31618dc70339049ce71061b8f1351a1c70d9c2a236ed50d8a2550c550d"
|
||||
GBRAIN_REAL_HERMES_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
- run: bun install
|
||||
|
||||
# `runner.temp` is not an allowed context in job-level env, so the
|
||||
# evidence dir is derived here and exported for every later step (the
|
||||
# door test writes into it; the failure-path upload reads it — exporting
|
||||
# early keeps that upload working even when an install/precondition step
|
||||
# is what failed).
|
||||
- name: Prepare evidence dir
|
||||
run: |
|
||||
echo "GBRAIN_E2E_EVIDENCE_DIR=$RUNNER_TEMP/hermes-door-evidence" >> "$GITHUB_ENV"
|
||||
mkdir -p "$RUNNER_TEMP/hermes-door-evidence"
|
||||
|
||||
# NO secrets in this step's env: the installer is downloaded from the
|
||||
# network and executed, so it must never see credentials. The digest pin
|
||||
# turns an upstream installer change into a loud failure instead of
|
||||
# unreviewed code running in CI. Bound + retry the install run itself
|
||||
# (same posture as the e2e tier2 OpenClaw install): `timeout` kills a
|
||||
# hung attempt, 3 attempts ride out transient flakes, step cap backstops.
|
||||
- name: Install hermes (pinned installer digest)
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
curl -fsSL --retry 3 -o hermes-install.sh https://hermes-agent.nousresearch.com/install.sh
|
||||
if ! echo "$HERMES_INSTALL_SHA256 hermes-install.sh" | sha256sum -c -; then
|
||||
echo "::error::hermes installer digest drift — re-pin deliberately: update HERMES_INSTALL_SHA256 + HERMES_VERSION in this workflow and docs/mcp/HERMES-CLI-PIN.md after reviewing upstream changes" >&2
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
if timeout 600 bash hermes-install.sh --skip-setup --non-interactive --branch "$HERMES_GIT_TAG" --commit "$HERMES_GIT_COMMIT"; then
|
||||
# The branch/commit flags above are ASSERTED here, not trusted:
|
||||
# a shell installer that silently ignores unknown flags would
|
||||
# clone upstream main into a runner that later holds secrets.
|
||||
# Verify the actual checkout before anything else runs it.
|
||||
actual_commit=$(git -C "$HOME/.hermes/hermes-agent" rev-parse HEAD 2>/dev/null || echo "no-git-checkout")
|
||||
if [ "$actual_commit" != "$HERMES_GIT_COMMIT" ]; then
|
||||
echo "::error::hermes payload drift — installed checkout is $actual_commit, pinned $HERMES_GIT_COMMIT. Either the installer ignored its branch/commit flags or the layout moved from ~/.hermes/hermes-agent; re-pin deliberately (HERMES_GIT_TAG/HERMES_GIT_COMMIT + docs/mcp/HERMES-CLI-PIN.md) after reviewing upstream." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::hermes install attempt $attempt failed or timed out; retrying in 10s" >&2
|
||||
sleep 10
|
||||
done
|
||||
echo "::error::hermes install failed after 3 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Preconditions (binary, secret, version pin)
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
if ! command -v hermes >/dev/null 2>&1; then
|
||||
echo "::error::hermes did not resolve on PATH after install" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$ANTHROPIC_API_KEY" ]; then
|
||||
echo "::error::ANTHROPIC_API_KEY secret is empty — fork PRs get no secrets from GitHub, and this labeled job cannot run without them" >&2
|
||||
exit 1
|
||||
fi
|
||||
version_output=$(hermes --version)
|
||||
echo "$version_output"
|
||||
# Observed shape: `Hermes Agent v0.20.0 (2026.8.3)`.
|
||||
if ! printf '%s' "$version_output" | grep -qF "v$HERMES_VERSION"; then
|
||||
echo "::error::hermes version drift — expected v$HERMES_VERSION in: $version_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Configure hermes (auth + model pin)
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.hermes
|
||||
printf 'ANTHROPIC_API_KEY=%s\n' "$ANTHROPIC_API_KEY" > ~/.hermes/.env
|
||||
chmod 600 ~/.hermes/.env
|
||||
# `hermes model` is interactive-only; `config set` is the observed
|
||||
# non-interactive model pin.
|
||||
hermes config set model.default anthropic/claude-haiku-4.5
|
||||
# Global health check — informational only, never a gate here.
|
||||
hermes doctor || true
|
||||
|
||||
- name: Run hermes door tests
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
# Redirect to a file, then tail — never pipe bun through tail (the
|
||||
# pipe eats the real exit code and truncates failure details).
|
||||
# `|| EXIT=$?` keeps the default `-e` shell from bailing before the
|
||||
# tail runs.
|
||||
EXIT=0
|
||||
bun test --timeout=600000 test/e2e/install-real-hermes.serial.test.ts > door.txt 2>&1 || EXIT=$?
|
||||
tail -40 door.txt
|
||||
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
|
||||
# actually EXECUTE: a summary with zero passing tests means the
|
||||
# suite ran nothing or self-skipped everything — never let that
|
||||
# read as green.
|
||||
pass_count=$(grep -Eo '[0-9]+ pass' door.txt | tail -1 | grep -Eo '^[0-9]+' || true)
|
||||
if [ -z "$pass_count" ] || [ "$pass_count" -eq 0 ]; then
|
||||
echo "::error::hermes door summary shows no passing tests (nothing ran or everything skipped) — refusing to go green while testing nothing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The door test copies its evidence into GBRAIN_E2E_EVIDENCE_DIR; the
|
||||
# workflow only uploads it. The test already excludes credential files —
|
||||
# the scrub below is defensive belt-and-suspenders before upload. Both
|
||||
# steps also require the evidence-dir env (a failure before the prepare
|
||||
# step leaves it unset, and there is nothing to upload then anyway).
|
||||
- name: Scrub credentials from evidence (defensive)
|
||||
if: failure() && env.GBRAIN_E2E_EVIDENCE_DIR != ''
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
# Three layers, because the evidence dir carries files WRITTEN BY
|
||||
# THE THIRD-PARTY HERMES BINARY (logs/sessions) and upload-artifact
|
||||
# follows symlinks:
|
||||
# 1. filename scrub (env files),
|
||||
# 2. symlink delete (an agent-dropped link could dereference to a
|
||||
# real credential file at upload time),
|
||||
# 3. content scrub (any file that embeds the key — auth-error dumps
|
||||
# are most likely exactly on the failure path that uploads).
|
||||
find "$GBRAIN_E2E_EVIDENCE_DIR" -type f \( -name '.env' -o -name '*.env' \) -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 hermes door evidence
|
||||
if: failure() && env.GBRAIN_E2E_EVIDENCE_DIR != ''
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: hermes-door-evidence
|
||||
path: ${{ env.GBRAIN_E2E_EVIDENCE_DIR }}
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Hosted ubuntu-latest runners are ephemeral, but this must not depend
|
||||
# on that: if the job ever moves to a self-hosted runner (the sibling
|
||||
# real-agent-e2e job is designed for one), a key left in ~/.hermes/.env
|
||||
# would persist for every later workload on that runner.
|
||||
- name: Remove hermes credentials (unconditional)
|
||||
if: always()
|
||||
run: rm -f ~/.hermes/.env
|
||||
|
||||
+40
-11
@@ -1,4 +1,4 @@
|
||||
<!-- gbrain-runbook-stamp: 0.45.9.0 -->
|
||||
<!-- gbrain-runbook-stamp: 0.45.12.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. -->
|
||||
@@ -82,8 +82,11 @@ you needed; report the count at the end (it feeds the install-time measurement).
|
||||
`gh auth login -h github.com -p https -w` (you run it; they click Authorize).
|
||||
Then `gbrain bootstrap status` — it is idempotent and resume-aware; after any
|
||||
partial failure, re-run it and continue where it points.
|
||||
2. **Engine.** `gbrain init --pglite` (2 seconds, no server). Search mode defaults
|
||||
to balanced silently — do NOT ask; the human can change it any time with
|
||||
2. **Engine.** `gbrain init --pglite` (2 seconds, no server). Search mode is
|
||||
auto-selected silently (conservative when keyless, tokenmax with an
|
||||
expansion key) and printed with an `[AGENT]` cost matrix — surface that
|
||||
matrix to the human and confirm before running high-volume queries (see
|
||||
INSTALL_FOR_AGENTS.md Step 3.5); they can change it any time with
|
||||
`gbrain search modes`. The one thing to raise here is the OPTIONAL provider
|
||||
key — with no key you run keyless: keyword search plus memory you author
|
||||
yourself through the write tools; everything works, one key upgrades search to
|
||||
@@ -109,9 +112,15 @@ you needed; report the count at the end (it feeds the install-time measurement).
|
||||
4. **Render.** `gbrain bootstrap render` — identity files appear. Show the human
|
||||
SOUL.md. Existing files are never overwritten (re-runs are safe; `--force`
|
||||
backs up first).
|
||||
5. **Skills + brain wiring.** The CLI scaffolds the skill set and registers
|
||||
`brain/` as the workspace source. Nothing to judge here; relay the output.
|
||||
6. **Wire the harness.** `gbrain bootstrap hooks --harness <detected>`:
|
||||
5. **Skills.** `gbrain skillpack scaffold --all` — the CLI scaffolds the skill
|
||||
set. Nothing to judge here; relay the output.
|
||||
6. **Wire the harness + register the brain source.** `gbrain bootstrap hooks
|
||||
--harness <detected>` creates `<workspace>/brain` and prints the exact
|
||||
`gbrain sources add <source_id> --path <brain> --force` command for THIS
|
||||
workspace — run it verbatim (don't guess a different id; a guessed id
|
||||
only surfaces as an FK error at `verify` time, by which point a wrong
|
||||
guess also blocks the correct id with an `overlapping_path` error). It
|
||||
also:
|
||||
- Claude Code: installs per-turn hooks ON by default — do NOT ask; loading the
|
||||
brain every turn is the whole point of installing gbrain for your agent. Tell
|
||||
the human it is on and how to turn it off (`GBRAIN_HOOKS=0`, or re-run with
|
||||
@@ -141,7 +150,9 @@ you needed; report the count at the end (it feeds the install-time measurement).
|
||||
through the real write path, graph floor, token sweep, secret scan, repo
|
||||
privacy, hooks smoke, capability report (keyless or keyed). Exit 0 or it is not
|
||||
done. Paste the report. Then relay the first-run tour it prints (three prompts
|
||||
the human should try, starting with restarting the session).
|
||||
the human should try, starting with restarting the session) AND the hand-off
|
||||
block below it — the ownership line and the cold-start offer are the two
|
||||
things the human must actually understand, not fine print.
|
||||
|
||||
## Machine two
|
||||
|
||||
@@ -203,7 +214,25 @@ placeholder). Trust the CLI's detection over your own guesses.
|
||||
|
||||
## Hand off
|
||||
|
||||
Finish by telling the human: the private repo URL (or the local-only status), the
|
||||
capability mode (keyless vs keyed), the three commands they will actually reuse
|
||||
(`gbrain doctor`, `gbrain bootstrap verify`, `gbrain sources push`), and the
|
||||
first-run tour. Then delete nothing — this runbook was fetched, not installed.
|
||||
Two things the human must UNDERSTAND before you finish — say them plainly, in
|
||||
this order, and confirm they landed:
|
||||
|
||||
1. **They own the brain.** Every memory you keep is a markdown file in THEIR
|
||||
private GitHub repo — name the URL. Owning it means: they can read it any
|
||||
time, take it to a second machine (`gbrain bootstrap attach`), or delete the
|
||||
repo and the brain is gone. If they went local-only, say that instead, with
|
||||
`gbrain bootstrap repo` as the any-time upgrade.
|
||||
2. **The first skill to run is cold-start.** An empty brain is a database; a
|
||||
filled one is a memory — and every flagship skill (book-mirror, briefings,
|
||||
meeting prep) only becomes magical once the brain holds their real life.
|
||||
OFFER to run the cold-start skill now: it imports Gmail, calendar, and
|
||||
contacts through ClawVisor (clawvisor.com — an OAuth vault; you never hold
|
||||
raw tokens), or offline archives (Google Takeout, a notes folder) if they
|
||||
prefer no third-party gateway. Every phase is consent-gated and
|
||||
independently valuable — they can stop after any one. If they say "later",
|
||||
that is a complete install; they can say "fill my brain" any time.
|
||||
|
||||
Then the routine facts: the capability mode (keyless vs keyed), and the three
|
||||
commands they will actually reuse (`gbrain doctor`, `gbrain bootstrap verify`,
|
||||
`gbrain sources push`). Then delete nothing — this runbook was fetched, not
|
||||
installed.
|
||||
|
||||
+176
-1
@@ -2,6 +2,181 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.45.12.0] - 2026-08-13
|
||||
|
||||
**Hermes joins the tested-install club: a real-binary harness now proves gbrain works inside Hermes, and `gbrain friction diff` tells you whether an install problem is the agent's or ours.**
|
||||
|
||||
GBrain has long said "works with OpenClaw and Hermes." For OpenClaw that claim was
|
||||
backed by tests; for Hermes it was backed by hope. This release closes that gap with a
|
||||
full end-to-end harness: a Hermes runner for the claw-test friction lab, a real-binary
|
||||
"door" test that registers gbrain into an actual Hermes install over MCP and asks it to
|
||||
recall a seeded fact, and a CI job that installs a pinned Hermes release and runs the
|
||||
door on demand. Every Hermes CLI behavior the harness relies on was pinned by observing
|
||||
a real install — the flag-order traps, the interactive prompts, the exit-code quirks —
|
||||
and those observations ship as documentation so your own Hermes setup benefits too.
|
||||
|
||||
The live claw-test lane also got honest: it now stages the scenario workspace before the
|
||||
agent starts and verifies real outcomes after it finishes, so an agent that does nothing
|
||||
and exits cleanly finally FAILS the run instead of passing it. And with two runners in
|
||||
the registry, the new `gbrain friction diff --base openclaw --compare hermes` turns
|
||||
friction reports into a comparison instrument: pain unique to one agent is that agent's
|
||||
contract problem; pain common to both is ours.
|
||||
|
||||
## To take advantage of v0.45.12.0
|
||||
|
||||
`gbrain upgrade` is enough — no schema migration.
|
||||
|
||||
1. **Running Hermes?** Wire gbrain in with one command (full guide at
|
||||
`docs/mcp/HERMES.md`, including the non-obvious flag-order and prompt gotchas):
|
||||
```bash
|
||||
printf 'Y\n' | hermes mcp add gbrain --env GBRAIN_HOME=$HOME --connect-timeout 60 --command $(which gbrain) --args serve
|
||||
hermes mcp test gbrain
|
||||
```
|
||||
2. **Want the friction lab on your own agent?**
|
||||
```bash
|
||||
gbrain claw-test --live --agent hermes # or --agent openclaw
|
||||
gbrain friction diff --base openclaw --compare hermes
|
||||
```
|
||||
3. **If anything looks wrong,** file an issue at https://github.com/garrytan/gbrain/issues
|
||||
with `gbrain doctor` output.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
**Added**
|
||||
- Hermes runner for the claw-test harness: `gbrain claw-test --live --agent hermes` drives a real Hermes install headlessly (`$HERMES_BIN` override supported; `--list-agents` shows availability for both runners).
|
||||
- `gbrain friction diff --base <run-or-agent> --compare <run-or-agent>`: cross-agent friction comparison with unique-to-each and changed sections, count deltas, and a compatibility banner that warns when runs cover different scenarios or versions. `--json` for machines.
|
||||
- Real-binary Hermes install door e2e (`test/e2e/install-real-hermes.serial.test.ts`): registers this checkout's gbrain into a hermetic Hermes home via a live MCP handshake (110 tools discovered), verifies both the CLI and direct-config registration surfaces, and proves recall of a seeded fact in a paid one-shot turn. Triple-gated so it can never burn tokens by accident.
|
||||
- Label-gated `hermes-door` CI job: installs a digest-and-tag-pinned Hermes release, refuses to go green if nothing actually ran, and uploads scrubbed evidence on failure.
|
||||
- Per-client MCP docs for Hermes (`docs/mcp/HERMES.md`) and OpenClaw (`docs/mcp/OPENCLAW.md`), plus a dev-facing pin of every observed Hermes CLI behavior (`docs/mcp/HERMES-CLI-PIN.md`) and an install snippet in `INSTALL_FOR_AGENTS.md`.
|
||||
- Generic agent-workspace compatibility test pinning the documented "any repo with a workspace" install flow (detection, scaffold additivity, resolver health).
|
||||
|
||||
**Changed**
|
||||
- claw-test live mode now stages the scenario before the agent runs (fresh-install: brain pages + routing file + init; upgrade: seed replay) and verifies outcomes after it exits — doctor health, a scenario-declared query returning results, expected files existing, and for upgrades a non-mutating schema-version probe that a do-nothing agent cannot satisfy.
|
||||
- The brief handed to live agents now matches the current CLI exactly (extract argument shape, doctor status vocabulary), and bare `gbrain` inside a live run resolves to the harness's own binary via a per-run PATH shim.
|
||||
- Every claw-test run opens and closes with a machine-readable marker carrying the agent name and scenario, so friction analytics can resolve runs by agent; scripted runs are now labeled `scripted` instead of borrowing an agent's name.
|
||||
- Scenario oracle configuration is validated on load — misdeclared oracles fail loudly instead of silently not being enforced.
|
||||
|
||||
**Fixed**
|
||||
- claw-test now works out of the box when gbrain runs from source (`bun run src/cli.ts`): child invocations resolve to a real gbrain launcher instead of the bun runtime itself, which previously made the default harness unusable outside compiled builds.
|
||||
- Upgrade-scenario runs in BOTH modes fail loudly when the scenario ships no seed dump, instead of quietly initializing a current database and reporting an "upgrade" that never exercised a migration.
|
||||
- Every harness child process now runs under a wall-clock timeout, and live-agent timeouts kill the agent's whole process tree — a hung child no longer wedges a run (or a CI job) forever.
|
||||
- Agent-side friction entries now survive the run's tempdir cleanup: they merge into your friction log before the workspace is deleted, so `friction render` and `friction diff` finally see both halves of a live run.
|
||||
- `claw-test --list-agents` no longer races CLI teardown; output is complete and ordered.
|
||||
- Live runs keep the agent's gbrain children pointed at the run's own hermetic brain even when the surrounding shell exports a database-pointing environment variable — the harness's verification and the agent's work can no longer land in two different places.
|
||||
- The test real-name guard now correctly distinguishes the public Hermes platform (documented and tested) from private deployment names (still banned).
|
||||
## [0.45.11.0] - 2026-08-12
|
||||
|
||||
**The install now ends by telling you the two things that matter: you own the brain, and here's the first skill to run.** A working install used to finish on a health report and three tour prompts — technically complete, but a new user walked away without the two facts that make gbrain worth trusting and worth using. Now `gbrain bootstrap verify` ends with a hand-off: **what you own** (every memory is a markdown file in YOUR private GitHub repo — read it, take it to a second machine, delete it and the brain is gone; or the local-only variant with the one command that gives it a durable home) and **what to do next** (run the `cold-start` skill — say "fill my brain" and your agent imports your Gmail, calendar, and contacts through ClawVisor, an OAuth vault so the agent never holds raw tokens, or offline archives like Google Takeout, one consented phase at a time).
|
||||
|
||||
The structural fix underneath: `cold-start` — the skill designed exactly for "I just installed this, now what?" — was excluded from the downstream skill bundle, so the paste-in install audience it was written for could never scaffold it. It's now bundled, it's the #1 recommended skill (ahead of the book-mirror flagship, because every flagship skill only becomes magical once the brain holds your real life), and a new drift guard fails CI if any recommended skill ever becomes unscaffoldable again.
|
||||
|
||||
To take advantage of v0.45.11.0: existing installs can run `gbrain skillpack scaffold cold-start` and say "fill my brain"; fresh installs get the full hand-off automatically.
|
||||
|
||||
### Added
|
||||
- **The verify hand-off block.** On PASS, `gbrain bootstrap verify` prints (and returns in `--json` as `handoff`) the ownership statement — with the actual repo URL, or the local-only variant pointing at `gbrain bootstrap repo` — followed by the cold-start next action. The runbook's Hand off section now instructs the installing agent to make both land ("say them plainly, confirm they landed") and to OFFER running cold-start on the spot.
|
||||
- **`cold-start` ships in the downstream bundle** (61 skills) and leads the recommended set, so the post-install advisory, `gbrain advisor`, and `gbrain skillpack scaffold --all` all surface it. Its prior bundle exclusion ("host onboarding flow") predated the personal-agent bootstrap and was reversed deliberately.
|
||||
- **Recommended-set drift guard**: every recommended slug must be scaffoldable from the plugin bundle — recommended-but-unscaffoldable is a dead-end call-to-action and now fails the suite.
|
||||
|
||||
### Changed
|
||||
- README's Codex and Claude Code paths spell out the same two follow-ups after the click moment: ownership (markdown in a repo you own) and cold-start as the first skill, with ClawVisor named as the credential path and offline archives as the no-gateway alternative.
|
||||
|
||||
**Also in this release — the first-five-minutes DX wave** (re-versioned from an unpublished 0.45.9.0 after the release queue moved):
|
||||
|
||||
**The first five minutes stop making you think.** We built a real-terminal harness that drives the actual install the way a new user does — every picker, prompt, silence window, and line of copy — and then fixed what it surfaced. Keyless `gbrain init` used to dead-end at an error before it created anything; now it just works, keyless, and says so. A fresh brain used to scroll ~240 lines of internal migration names; now it prints one line. The success screen used to bury the one thing to do next under eight competing calls to action; now the copy-paste memory demo is the last, obvious thing on screen. And the "here's the magic" moment in the README now points at the trick that only a brain can do — tell it something, restart, ask for it back — instead of a question your identity files answer for free.
|
||||
|
||||
Under the hood: the upgrade nudge now compares the version you're actually running (a stale or foreign cache can't tell you to upgrade to something you already have), a broken settings file makes the installer stop and tell you rather than quietly replace it, and `gbrain init --supabase` fails loudly in a script instead of pretending it worked. Every fix landed with a test, and a two-model adversarial review pass (Claude + Codex) caught a cluster of follow-on issues in the fixes themselves — a keyless upgrade command that pointed at a rejected path, a compiled-binary detection that broke for renamed binaries — which are fixed here too.
|
||||
|
||||
To take advantage of v0.45.11.0: nothing to do — `gbrain self-upgrade` (or your next `gbrain` invocation's upgrade nudge) brings you current, and the improvements are all in the install/first-run path a new brain hits automatically.
|
||||
|
||||
### Added
|
||||
- **A real-PTY DX exploration harness** (`test/helpers/tty-harness.ts` + `scripts/dx-explore.ts`). It spawns any CLI — gbrain, `claude`, `codex` — under a true pseudo-terminal, timestamps every output burst, and turns silence windows into a measurable stall report, so "the user stared at a frozen screen for nine seconds" is an artifact, not a hunch. A `drive` mode lets an agent steer a live TUI across separate tool calls. Developer instrument only; transcripts are gitignored and nothing in the shipped product depends on it.
|
||||
|
||||
### Changed
|
||||
- **Keyless is now the default when you have no embedding key**, on both the interactive and scripted paths: `gbrain init` completes with a loud, honest "keyless mode — keyword search plus memory your agent writes; everything works" notice instead of exiting with an error. A near-miss key typo still fails loudly (so a fat-fingered `OPENAPI_API_KEY` isn't silently buried). Multiple keys auto-pick the canonical default rather than refusing.
|
||||
- **Fresh-brain init prints one schema-setup line** instead of the full migration replay; upgrades keep the per-migration detail where it has diagnostic value (`GBRAIN_MIGRATE_VERBOSE=1` restores it).
|
||||
- **The init success screen leads with one action** — the three-command memory demo, last on screen — with import/scale-up/health collapsed into a single terse footer and the recommended-skills advisory reduced to a human-voiced pointer.
|
||||
- **The provider picker offers "continue keyless" explicitly** and probe-gates a local Ollama daemon (a running daemon that hasn't pulled the model is annotated, not silently selected); a bare Enter never picks a broken local provider.
|
||||
- **The upgrade nudge tells the truth about your binary**: it compares the running version to the latest and prints the running version, so a stale or foreign-written cache can't nag about an upgrade you already have. The raw machine marker stays off an interactive human's screen (override with `GBRAIN_FORCE_UPGRADE_MARKER=1` for PTY-based agent harnesses that parse it).
|
||||
- **Copy honesty pass**: provider capabilities are attributed per provider (OpenAI unlocks semantic search + fact extraction; Voyage semantic search; Anthropic fact extraction — it has no embeddings API); the install-time estimate reads ~15 minutes for the personal-agent path (~30 for the always-on setup); the first-run tour says to restart first and frames the genuine cross-session round-trip.
|
||||
|
||||
### Fixed
|
||||
- **A parse-broken `.claude/settings.local.json` aborts the hooks write** with a fix-and-re-run message instead of being replaced — your permissions and allowlist are never silently dropped.
|
||||
- **`gbrain init --supabase` in a non-interactive shell fails loudly** (exit 1, names the `--url` escape hatch) instead of the old silent exit-0 that wrote no config.
|
||||
- **`gbrain bootstrap hooks` with a missing harness CLI** now still installs per-turn hooks and reports the phase as partial (so a resuming agent re-runs it once the CLI is on PATH) instead of leaving a false "wire complete".
|
||||
- **`gbrain bootstrap interview --set/--skip` after a confirmation** warns that it voided the read-back instead of failing silently later at render.
|
||||
- Review-pass self-fixes: the keyless upgrade hint now names the re-init command that actually works (not the schema-sizing field `config set` rejects); compiled-binary detection for the detached update refresh no longer breaks for a renamed/official-named binary; the DX harness scrubs copied credentials even on interrupt and reaps the child's whole process tree.
|
||||
## [0.45.10.0] - 2026-08-13
|
||||
|
||||
**21 more community and maintainer bug fixes. Search answers get more complete, sync gets safer, and doctor learns to warn you before a provider dies.**
|
||||
|
||||
This wave continues the v0.45.8.0 cleanup: no new product surface, just fixes. The
|
||||
standouts: pages created by the idea-extraction cycle were invisible to search (they
|
||||
were written without search chunks) and now show up like everything else, with a repair
|
||||
path for existing brains. Query caching now keys on your detail setting, so a compact
|
||||
answer is never served to a full-detail request. And doctor now warns you loudly if your
|
||||
brain is pinned to an embedding provider that has announced a shutdown, weeks before it
|
||||
happens instead of after.
|
||||
|
||||
Also riding: the rerank budget fix that landed directly this week. Contributed by @javieraldape.
|
||||
|
||||
## To take advantage of v0.45.10.0
|
||||
|
||||
`gbrain upgrade` is enough. No schema migration.
|
||||
|
||||
1. **Upgrade and check:**
|
||||
```bash
|
||||
gbrain upgrade
|
||||
gbrain doctor
|
||||
```
|
||||
2. **If doctor now warns about your embedding provider,** that is the new sunset check
|
||||
doing its job. It names the provider, the date, and the migration command.
|
||||
3. **Heal previously-invisible atom pages:**
|
||||
```bash
|
||||
gbrain embed --stale
|
||||
```
|
||||
4. **Things to watch:** the query cache key version moved, so the first re-ask of a
|
||||
cached question is a one-time cache miss. If anything else looks wrong, file an issue
|
||||
with `gbrain doctor` output: https://github.com/garrytan/gbrain/issues
|
||||
|
||||
### Itemized changes
|
||||
|
||||
**Search and recall**
|
||||
- Atom pages produced by the extraction cycle are chunked and embedded like every other page, so they appear in search results. Contributed by @awilhite.
|
||||
- `embed --stale` detects and heals pages that have content but no chunks. Contributed by @Masashi-Ono0611.
|
||||
- The query cache folds the detail knob into its key, so compact and full-detail answers never cross. Contributed by @time-attack.
|
||||
- Rerank budget failures are bucketed under their real cause instead of "unknown". Contributed by @javieraldape.
|
||||
|
||||
**Sync, import, and write-through**
|
||||
- Deferred link extraction above the size gate is consumed instead of dropped. Contributed by @time-attack.
|
||||
- Import error summaries name the failing table and constraint. Contributed by @bo-developing.
|
||||
- Write-through honors the page's recorded source path instead of recomputing it. Contributed by @JonMcCutchen.
|
||||
- The managed filing-rules block renders each repo's own taxonomy, not the bundled default. Contributed by @dovstern.
|
||||
- Timeline extraction no longer splits on bare hyphens inside link labels. Contributed by @time-attack.
|
||||
- Export scopes tag and raw-data sidecar reads to the page's source. Contributed by @alexey-metaengage.
|
||||
- Cross-source link targets survive an engine migration. Contributed by @RerankerGuo.
|
||||
|
||||
**Doctor and diagnostics**
|
||||
- A damaged PGLite store is reported as store damage, with runtime problems kept separate, and the verdict requires positive evidence. Contributed by @time-attack.
|
||||
- New check: brains pinned to an embedding provider with an announced shutdown get a loud warning with the migration path. Contributed by @time-attack.
|
||||
- Source listing distinguishes unset federation from explicit false. Contributed by @dovstern.
|
||||
- `put_page` reports push state honestly instead of implying success. Contributed by @dovstern.
|
||||
- Flow-style skill triggers parse correctly in skill health checks. Contributed by @RerankerGuo.
|
||||
- Sync-failure records auto-skipped as chronic stay visible to doctor until a human resolves them. Contributed by @RerankerGuo.
|
||||
|
||||
**Autopilot and agents**
|
||||
- The drain worker no longer self-deadlocks at concurrency=1, and its DB reconnect logic is shared with queue operations. Contributed by @time-attack.
|
||||
- Stale-lock reaping ignores foreign PIDs it did not create. Contributed by @javieraldape.
|
||||
- Agent jobs resolve their brain source at submit time, not execution time. Contributed by @Masashi-Ono0611.
|
||||
|
||||
**OAuth**
|
||||
- Dynamic client registration accepts `token_ttl_seconds`, clamped to admin policy, and an unset TTL cap now derives from `--token-ttl` instead of a permissive default. Contributed by @time-attack.
|
||||
|
||||
**Models**
|
||||
- The claude-cli recipe lists the Claude 5 family ids the CLI already serves, with pins. Contributed by @clement0909472.
|
||||
|
||||
**For contributors**
|
||||
- The CLI flag registry, one wave rider test, and the bootstrap version stamps were refreshed as part of assembly.
|
||||
|
||||
## [0.45.9.0] - 2026-08-12
|
||||
|
||||
**Your agent's memory keeps saving itself — even in a cloud sandbox, even on `/exit`, and it tells you the moment it can't.** The paste-in personal-agent install now works first-class in Claude Code's cloud environment, not just on a laptop. The persistence lane got three fixes that matter whether you're local or in the cloud: the workspace push now verifies repo privacy through a portable ladder that keeps working when the sandbox blocks the GitHub API, it runs after every turn (not only at session end, which the harness never fires on `/exit`), and a failed push surfaces on your next turn instead of failing in silence. Setup adapts to where it runs — no more scheduled-job errors on hosts without a scheduler, and no half-created repos in an environment that can't push them.
|
||||
@@ -341,7 +516,7 @@ answers. Ask before anything destructive. You are not done until
|
||||
`gbrain bootstrap verify` exits 0.
|
||||
```
|
||||
|
||||
The agent runs `gbrain bootstrap` — a new command family (`status`, `interview`, `render`, `repo`, `hooks`, `verify`, `attach`, `uninstall`) that drives the whole install. It works with **zero API keys**: your harness's model is the LLM, so the agent authors memory directly and search runs keyword-only; add one optional key (OpenAI, Anthropic, or Voyage) to unlock semantic search and automatic fact extraction. Everything is consent-gated — hooks, background push, MCP scope — and nothing runs while your harness is closed (the honest desktop contract; true 24/7 is what a hosted brain adds).
|
||||
The agent runs `gbrain bootstrap` — a new command family (`status`, `interview`, `render`, `repo`, `hooks`, `verify`, `attach`, `uninstall`) that drives the whole install. It works with **zero API keys**: your harness's model is the LLM, so the agent authors memory directly and search runs keyword-only; add one optional key to upgrade capabilities (OpenAI: semantic search + automatic fact extraction; Voyage: semantic search; Anthropic: fact extraction). Everything is consent-gated — hooks, background push, MCP scope — and nothing runs while your harness is closed (the honest desktop contract; true 24/7 is what a hosted brain adds).
|
||||
|
||||
### What you get
|
||||
|
||||
|
||||
@@ -506,7 +506,7 @@ four numeric segments are required first. Historical 3-segment versions
|
||||
| `CHANGELOG.md` | Top entry header `## [0.31.4.1] - YYYY-MM-DD` plus the "To take advantage of v0.31.4.1" block. | Standard Keep-a-Changelog header. |
|
||||
| `TODOS.md` | Any TODO entries that mention "follow-up from vX.Y.Z.W" use the version of the release that filed them. Update only when filing NEW follow-up TODOs. | Inline `vX.Y.Z.W` references in TODO bodies. |
|
||||
| `CLAUDE.md` | The Key Files section's per-file annotations carry `vX.Y.Z.W (#NNN)` tags noting which release introduced a behavior. Update whenever a wave's annotations get folded in. | Inline `vX.Y.Z.W (#NNN, contributed by @user)` references. |
|
||||
| `openclaw.plugin.json` | OpenClaw plugin manifest (v0.45.6.0, #4033). Hand-maintained; `test/openclaw-plugin-manifest.test.ts` fails the suite if it drifts from `package.json`. Merges from master auto-resolve it to master's version — re-bump it with the trio. | `"version": "0.45.8.0"` |
|
||||
| `openclaw.plugin.json` | OpenClaw plugin manifest (v0.45.6.0, #4033). Hand-maintained; `test/openclaw-plugin-manifest.test.ts` fails the suite if it drifts from `package.json`. Merges from master auto-resolve it to master's version — re-bump it with the trio. | `"version": "0.45.12.0"` |
|
||||
| `BOOTSTRAP_FOR_AGENTS.md` | Runbook stamp on line 1. `scripts/check-bootstrap-tag.sh` (in `bun run verify` + CI) fails when it drifts from `VERSION`; refresh it in the same commit as the bump. | `<!-- gbrain-runbook-stamp: X.Y.Z.W -->` |
|
||||
| `templates/bootstrap/template-repo/` | Vendored template tree with an embedded version stamp. Auto-derived, but NOT by `bun install`: run `bun run scripts/generate-template-repo.ts --out templates/bootstrap/template-repo` after the bump; `scripts/check-bootstrap-templates.sh` fails CI on drift. | `<!-- gbrain-template-stamp: X.Y.Z.W -->` in generated files. |
|
||||
|
||||
|
||||
+14
-3
@@ -90,9 +90,10 @@ NOT inside ~/gbrain.
|
||||
## Step 3.5: Confirm search mode with the user (DO NOT SKIP)
|
||||
|
||||
`gbrain init` auto-applied a default search mode (`tokenmax` unless your subagent
|
||||
tier is Haiku-class or no OpenAI key is configured). The init output included the
|
||||
cost matrix below preceded by `[AGENT]` markers. You must NOT silently accept the
|
||||
default. Stop and ask the operator.
|
||||
tier is Haiku-class or no expansion-capable API key — Anthropic, OpenAI, or
|
||||
Google — is configured). The init output included the cost matrix below preceded
|
||||
by `[AGENT]` markers. You must NOT silently accept the default. Stop and ask the
|
||||
operator.
|
||||
|
||||
**Present this matrix verbatim:**
|
||||
|
||||
@@ -216,6 +217,16 @@ diff against gbrain's bundle when you want upstream improvements. (The legacy
|
||||
`gbrain skillpack install` managed-block model was removed in v0.33 — run
|
||||
`gbrain skillpack migrate-fence` once if upgrading from an older release.)
|
||||
|
||||
**If you are Hermes:** register gbrain as your MCP server:
|
||||
|
||||
```bash
|
||||
printf 'Y\n' | hermes mcp add gbrain --env GBRAIN_HOME=$HOME --connect-timeout 60 --command $(which gbrain) --args serve
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
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
|
||||
|
||||
@@ -15,7 +15,7 @@ The point of building a 150K-page brain is to use it as a strategic moat. To nev
|
||||
|
||||
It's easier to ship a daemon that runs 24/7 to ingest, enrich, and consolidate than it is to keep an agent in chat working hard. GBrain is that daemon, generalized. Install in 30 minutes. Your agent does the work. As my personal agent gets smarter, so does yours.
|
||||
|
||||
> **~30 minutes to a fully working brain.** Database ready in 2 seconds (PGLite, no server). You just answer questions about API keys.
|
||||
> **~15 minutes to a working personal agent** on the recommended Codex / Claude Code path (mostly a short interview); ~30 minutes for the always-on OpenClaw / Hermes setup. Database ready in 2 seconds either way (PGLite, no server).
|
||||
|
||||
> **LLMs:** fetch [`llms.txt`](llms.txt) for the documentation map, or [`llms-full.txt`](llms-full.txt) for the same map with core docs inlined in one fetch. **Agents:** start with [`AGENTS.md`](AGENTS.md) (or [`CLAUDE.md`](CLAUDE.md) if you're Claude Code).
|
||||
|
||||
@@ -90,7 +90,9 @@ answers. Ask before anything destructive. You are not done until
|
||||
`gbrain bootstrap verify` exits 0.
|
||||
```
|
||||
|
||||
Codex will ask for command approvals during the install — approving them is the sandbox working as intended. What you get, in about 15 minutes: a short interview (6 required questions) → your agent's identity (SOUL.md, USER.md, MEMORY.md) rendered from your own answers, never invented → a local PGLite brain (2 seconds, no server, no Docker) → MCP wired so every session can search and write memory → a **private** GitHub repo, created and privacy-verified, as your agent's durable body. Works with **zero API keys** — keyword search plus memory your agent writes itself; one optional key (OpenAI, Anthropic, or Voyage) upgrades to semantic search and automatic fact extraction. Codex reads brain context through its tools each turn (pull-based).
|
||||
Codex will ask for command approvals during the install — approving them is the sandbox working as intended. What you get, in about 15 minutes: a short interview (6 required questions) → your agent's identity (SOUL.md, USER.md, MEMORY.md) rendered from your own answers, never invented → a local PGLite brain (2 seconds, no server, no Docker) → MCP wired so every session can search and write memory → a **private** GitHub repo, created and privacy-verified, as your agent's durable body. Works with **zero API keys** — keyword search plus memory your agent writes itself; one optional key upgrades capabilities (OpenAI: semantic search + automatic fact extraction; Voyage: semantic search; Anthropic: fact extraction). Codex reads brain context through its tools each turn (pull-based). The click moment: tell it one small thing to remember, restart Codex, then ask for it back — the answer comes from the brain, not from this chat's context (which the restart cleared). That cross-session round-trip is the whole product; "what's my name / my top jobs?" is answered from your identity files, which is nice but not the same trick.
|
||||
|
||||
Two things worth understanding once it's running: **you own the brain** — every memory is a markdown file in that private repo (read it, clone it to a second machine, delete it and the brain is gone) — and **the first skill to run is `cold-start`**: say "fill my brain" and your agent imports your Gmail, calendar, and contacts (via [ClawVisor](https://clawvisor.com), an OAuth vault so the agent never holds raw tokens) or offline archives like Google Takeout, one consented step at a time. An empty brain is a database; a filled one is a memory.
|
||||
|
||||
> **Prefer to make the repo yourself?** Create a new **empty** private repo **under your own GitHub account** (no README/.gitignore/license), clone it, open the clone in Codex, and paste the same block — bootstrap detects your empty repo and adopts it instead of creating one. The repo must be empty and personal-account-owned; org-owned repos are refused (create one under your account, or let bootstrap make it).
|
||||
|
||||
@@ -107,7 +109,7 @@ answers. Ask before anything destructive. You are not done until
|
||||
`gbrain bootstrap verify` exits 0.
|
||||
```
|
||||
|
||||
Everything from the Codex path applies — interview, identity from your own answers, local brain, private repo, keyless mode — plus Claude Code gets **per-turn context hooks**: your brain loads automatically into every prompt, and your work persists to your private repo on a per-turn cadence (debounced ~5 min locally, every turn in a cloud sandbox — this covers the `/exit` case the harness never fires a session-end hook on), with a notice on your next turn if a push ever fails. This works in a **Claude Code cloud session** too, not just on your laptop: verification falls back to pure git protocol when the sandbox blocks the GitHub API, and `gbrain bootstrap cloud-setup-script` prints the environment setup recipe. Restart the session after install and ask "what did I tell you my top jobs were?" — that's the moment it clicks. Full contract, security posture, cloud sandboxes, and uninstall: [docs/guides/bootstrap.md](docs/guides/bootstrap.md).
|
||||
Everything from the Codex path applies — interview, identity from your own answers, local brain, private repo, keyless mode — plus Claude Code gets **per-turn context hooks** (on by default, with an opt-out): your brain loads automatically into every prompt, and your work persists to your private repo on a per-turn cadence (debounced ~5 min locally, every turn in a cloud sandbox — this covers the `/exit` case the harness never fires a session-end hook on), with a notice on your next turn if a push ever fails. This works in a **Claude Code cloud session** too, not just on your laptop: verification falls back to pure git protocol when the sandbox blocks the GitHub API, and `gbrain bootstrap cloud-setup-script` prints the environment setup recipe. The click moment: tell it one small thing to remember, restart the session, then ask for it back — a fresh session has no chat context, so the answer can only come from the brain. That cross-session round-trip is the whole product ("what's my name?" is answered from your identity files — nice, but not the same trick). Same two follow-ups as the Codex path: you own the brain (markdown in your private repo), and `cold-start` is the first skill to run — "fill my brain" imports your email, calendar, and contacts (ClawVisor) or offline archives, one consented step at a time. Full contract, security posture, cloud sandboxes, and uninstall: [docs/guides/bootstrap.md](docs/guides/bootstrap.md).
|
||||
|
||||
> **Prefer to make the repo yourself?** Create a new **empty** private repo **under your own GitHub account** (no README/.gitignore/license), clone it, open the clone in Claude Code (CLI or the desktop app's open-a-repo flow), and paste the same block — bootstrap adopts your empty repo instead of creating one. The repo must be empty and personal-account-owned; org-owned repos are refused.
|
||||
|
||||
@@ -170,6 +172,8 @@ GBrain exposes nearly all of its 100+ operations as MCP tools (stdio and HTTP; a
|
||||
- **[Claude Code](docs/mcp/CLAUDE_CODE.md)** — local: one command, `claude mcp add gbrain -- gbrain serve` (zero server, zero tunnel). Remote with just a bearer token: `gbrain connect https://your-host/mcp --token gbrain_xxx` prints a paste-ready block (or `--install` wires it up and smoke-tests the token).
|
||||
- **[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).
|
||||
- **[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.
|
||||
- **[Perplexity Computer](docs/mcp/PERPLEXITY.md)** — `gbrain connect https://your-host/mcp --agent perplexity --oauth --register` mints a least-privilege OAuth client and prints the Issuer/Client ID/Secret to paste into Settings → Connectors (OAuth is the right path for a cloud connector; a bearer token also works for local use). Pro subscription required.
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# TODOS
|
||||
|
||||
## Onboarding DX follow-ups (filed v0.45.9.0)
|
||||
|
||||
- [ ] **Retire the `config set embedding_model` dead-end across ALL surfaces.** v0.45.9.0 fixed the keyless-init notice to point at `gbrain init --force --pglite --embedding-model <id>`, but `src/core/embed-preflight.ts` (lines ~73/83/90/115) and `src/core/embedding-dim-check.ts:78` still advertise `gbrain config set embedding_model <...>`, which `src/commands/config.ts:142` hard-refuses as a schema-sizing no-op. Same dead-end class, different surfaces. Sweep them to the re-init recipe. Priority: P2.
|
||||
- [ ] **`gbrain init --supabase` migrate-model dead-end doc.** The Postgres branch of config.ts points at `docs/embedding-migrations.md`; confirm that doc exists and describes a working switch, or write it. Priority: P3.
|
||||
- [ ] **DX harness binary cache keyed on nothing.** `scripts/dx-explore.ts` reuses `.context/dx-runs/bin/gbrain` unless `--rebuild` is passed, so a second run after code changes can produce transcripts from a stale binary. Key the cache by a source hash (or rebuild when any `src/` file is newer). Dev instrument only. Priority: P3.
|
||||
- [ ] **`verify` has no MCP-registration check.** v0.45.9.0 made `bootstrap status` report the wire phase `partial` when only hooks landed (host CLI missing), but `bootstrap verify` still exits 0 in that state. Add an MCP-registration probe to verify so the "done when verify exits 0" contract also covers MCP. Priority: P2.
|
||||
- [ ] **`hasExpansionKey` misses config-plane keys + init-before-key sequencing.** The mode picker reads `process.env` only; a key routed to the 0600 config by the interview (which runs AFTER init) never influences the auto-selected search mode, and the picker never re-fires. Resolve keys through the capability/gateway fold and consider re-running the recommendation when a key is first configured. Priority: P3.
|
||||
- [ ] **`findEnvKeyTypos` KEY_SHAPE misses no-underscore typos.** `OPENAI_APIKEY` (no `_` before `KEY`) escapes the near-miss net, so that typo class now completes keyless silently instead of failing loud. Widen the regex. Priority: P3.
|
||||
- [ ] **`init-nudge` stale "4 checks" comment + 6-probe accounting.** The header still says "4 onboard checks" but six probes now run; the partial-checks message counts the page-count probe. Cosmetic. Priority: P3.
|
||||
- [ ] **FIRST LIGHT (the real first-magical-moment feature).** The v0.45.9.0 tour rewrite is the ship-now slice; the full seed-phase → compendium → scout design is PR-A (seed phase + Mirror + baton) / PR-B (compendium + scout) with one-way-door decisions (new bootstrap phase, consent key, `skills/first-light/`, a one-time Gate-3 narration exemption). Priority: P2.
|
||||
|
||||
## Ambient recall follow-ups (filed v0.45.7.0, issue #1)
|
||||
|
||||
Deferred from the ambient-recall wave (`context_pack` + `delta` frozen verbs +
|
||||
@@ -3829,28 +3840,99 @@ After the sweep, both should be fixable and renameable back to plain `*.test.ts`
|
||||
|
||||
## claw-test E2E (v0.22.16 follow-ups)
|
||||
|
||||
### Hermes runner — `src/core/claw-test/runners/hermes.ts`
|
||||
**Priority:** P2
|
||||
|
||||
**What:** Add a Hermes implementation of the `AgentRunner` interface. v1 ships only OpenClaw; v1.1 lands hermes once we have real friction reports from openclaw to validate the contract against.
|
||||
|
||||
**Why:** Cross-agent diff (`gbrain friction diff --base openclaw --compare hermes`) is the highest-leverage next signal. Friction unique to one agent vs common-to-both separates "agent contract bug" from "gbrain bug" automatically.
|
||||
|
||||
**Effort:** S (CC ~30m). Depends on: v1 openclaw runner producing real friction reports first.
|
||||
### ~~Hermes runner — `src/core/claw-test/runners/hermes.ts`~~ DONE (hermes-harness wave)
|
||||
Shipped: `HermesRunner` (`hermes -z <brief>`, `$HERMES_BIN` > `which hermes`,
|
||||
`HERMES_HOME` env-allowlist delta) + the full hermes install door
|
||||
(`test/e2e/install-real-hermes.serial.test.ts`, opt-in-gated) + the label-gated
|
||||
`hermes-door` CI job in heavy-tests.yml. The cross-agent
|
||||
`gbrain friction diff --base openclaw --compare hermes` payoff shipped in the
|
||||
same wave (below). Observed-CLI pins live in `docs/mcp/HERMES-CLI-PIN.md` and
|
||||
`docs/mcp/HERMES.md`.
|
||||
|
||||
---
|
||||
|
||||
### Friction analytics suite — `diff` / `trend` / `migration-stub`
|
||||
### Friction analytics suite — `trend` / `migration-stub` (diff SHIPPED)
|
||||
**Priority:** P2
|
||||
|
||||
**What:** Three new `gbrain friction` subcommands deferred from v1:
|
||||
- `gbrain friction diff --base <run-or-agent> --compare <run-or-agent>` (cross-agent comparison; ~80 LOC)
|
||||
**What:** Two remaining `gbrain friction` subcommands deferred from v1
|
||||
(`diff` shipped in the hermes-harness wave — see `src/commands/friction.ts`):
|
||||
- `gbrain friction trend [--since <version-or-date>] [--phase <name>]` (time-series across runs; ~60 LOC)
|
||||
- `gbrain friction migration-stub [--threshold N]` (clusters friction by phase + tokens, emits `skills/migrations/v[N+1].md` stub; ~150 LOC)
|
||||
|
||||
**Why:** Turns point-in-time reports into a slope. Pairs with the v1.1 public scoreboard.
|
||||
|
||||
**Effort:** M (CC ~2h total).
|
||||
**Effort:** M (CC ~1.5h total).
|
||||
|
||||
---
|
||||
|
||||
### Promote hermes-door soft probes to hard assertions + build the REAL cron test
|
||||
**Priority:** P2
|
||||
|
||||
**What:** Two follow-ups now that the hermes CLI surface is pinned (v0.20.0,
|
||||
`docs/mcp/HERMES-CLI-PIN.md`): (1) promote the door's logged-evidence probes
|
||||
(`hermes mcp list` output shape; session-artifact tool-call traces under
|
||||
`<home>/.hermes/`) to hard assertions once a couple of CI runs confirm their
|
||||
stability across hermes releases; (2) build the real cron pairing test — the
|
||||
surface is fully non-interactive (`hermes cron create [--name N] [--no-agent]
|
||||
[--script PATH] <schedule> [prompt]` + `hermes cron tick` runs due jobs once
|
||||
and exits) — create a job that runs `gbrain sync --json`, tick, and assert the
|
||||
sync actually executed against the run's brain. (A self-skipping probe was
|
||||
deliberately CUT in review: a test that cannot fail is not coverage.)
|
||||
|
||||
**Why:** INSTALL_FOR_AGENTS.md's recurring-jobs step has zero coverage; the
|
||||
evidence sweep is the promotion signal the door already logs.
|
||||
|
||||
**Effort:** S-M (CC ~45m). Depends on: first labeled hermes-door CI runs.
|
||||
|
||||
---
|
||||
|
||||
### Wire the orphaned `voice-agent-install` ScenarioKind
|
||||
**Priority:** P2
|
||||
|
||||
**What:** `test/fixtures/claw-test-scenarios/voice-agent-install/` carries the
|
||||
richest install-assertion template in the repo (60-line expected.json:
|
||||
filesystem manifest, `.gbrain-source.json` sha256s, resolver rows, PII
|
||||
blocklist, health probe, tiered soft-fail) but `scenario.json` declares
|
||||
`kind: "voice-agent-install"`, which `ScenarioKind` rejects — the fixture
|
||||
cannot load. Extend `ScenarioKind` + `loadScenario` + a `postInstallHook`
|
||||
implementation so the scenario runs.
|
||||
|
||||
**Why:** Integrations-recipe install coverage (the `gbrain integrations
|
||||
install` path) has a fully-designed scenario sitting dead.
|
||||
|
||||
**Effort:** M (CC ~1h). Integrations-lane work, deliberately kept out of the
|
||||
hermes-harness wave.
|
||||
|
||||
---
|
||||
|
||||
### Cold-install container test — fill the `tests/docker/bootstrap-e2e.sh` placeholder
|
||||
**Priority:** P3
|
||||
|
||||
**What:** heavy-tests.yml carries a gated no-op step for
|
||||
`tests/docker/bootstrap-e2e.sh` (networkless cold-machine container install of
|
||||
gbrain itself: global install, PATH discovery, migrations). The file doesn't
|
||||
exist. Write it.
|
||||
|
||||
**Why:** The agent-platform door tests (claude/codex/hermes) all deliberately
|
||||
run gbrain from the dev tree / compiled binary — none of them proves gbrain's
|
||||
own cold install. That gap was re-flagged in the hermes-harness wave's outside
|
||||
review and scoped OUT of that wave on purpose.
|
||||
|
||||
**Effort:** M (CC ~1-2h, docker).
|
||||
|
||||
---
|
||||
|
||||
### BrainBench hermes adapter
|
||||
**Priority:** P3
|
||||
|
||||
**What:** ~50-100 lines in `src/eval/brainbench/adapters/hermes.ts` + an
|
||||
`ALL_HARNESSES` entry + baseline cells in `evals/brainbench/baselines/main.json`.
|
||||
|
||||
**Why:** Cross-harness memory-conformance coverage for the third platform.
|
||||
Eval seam (memory conformance), NOT install — kept out of the install wave on
|
||||
purpose; needs baseline-governance care per the BrainBench gate rules.
|
||||
|
||||
**Effort:** S-M (CC ~1h + baseline runs).
|
||||
|
||||
---
|
||||
|
||||
@@ -3870,7 +3952,7 @@ After the sweep, both should be fixable and renameable back to plain `*.test.ts`
|
||||
### Real v0.18 SQL dump for upgrade scenario
|
||||
**Priority:** P2
|
||||
|
||||
**What:** The `upgrade-from-v0.18` scenario ships scaffolded — `seed/dump.sql` is missing. The harness gracefully no-ops the seed phase when absent, so the scenario currently behaves like fresh-install. v1.1: generate a real v0.18-shape PGLite dump per the procedure documented in `test/fixtures/claw-test-scenarios/upgrade-from-v0.18/seed/README.md`.
|
||||
**What:** The `upgrade-from-v0.18` scenario ships scaffolded — `seed/dump.sql` is missing. Both scripted and live runs now FAIL LOUDLY on the missing dump (a silent skip used to init a current database and false-green the "upgrade"), so the shipped scenario is unrunnable until the dump lands. Generate a real v0.18-shape PGLite dump per the procedure documented in `test/fixtures/claw-test-scenarios/upgrade-from-v0.18/seed/README.md`.
|
||||
|
||||
**Why:** Without a real seed, the scenario doesn't actually exercise the migration chain forward-walk. That's the whole point of the upgrade scenario — proves issue #239/#243/#266/#357 class regressions stay fixed.
|
||||
|
||||
|
||||
+4
-2
@@ -13,7 +13,7 @@ Mix later if needed.
|
||||
|
||||
## 1. Run with an agent platform
|
||||
|
||||
Already running [OpenClaw](https://github.com/garrytan/openclaw) or [Hermes](https://github.com/garrytan/hermes)?
|
||||
Already running [OpenClaw](https://github.com/garrytan/openclaw) or [Hermes](https://github.com/NousResearch/hermes-agent)?
|
||||
|
||||
```bash
|
||||
bun install -g github:garrytan/gbrain#latest-stable
|
||||
@@ -71,7 +71,7 @@ claude mcp add gbrain -- gbrain serve --surface verbs # Claude Code
|
||||
codex mcp add gbrain -- gbrain serve --surface verbs # Codex
|
||||
```
|
||||
|
||||
The agent spawns `gbrain serve` as a stdio subprocess against your local brain. `--surface verbs` gives the agent the five-verb memory protocol (`recall`, `remember`, `entity`, `synthesize`, `forget` — [MEMORY_VERBS v1](protocol/MEMORY_VERBS_v1.md)) instead of the full tool catalog; drop the flag (default `full`) for every operation. Full walkthrough (both this local path and connecting to a remote brain), plus the brain-first protocol to paste into `CLAUDE.md` / `AGENTS.md`: **[Give your coding agent a memory](tutorials/connect-coding-agent.md)**.
|
||||
The agent spawns `gbrain serve` as a stdio subprocess against your local brain. `--surface verbs` gives the agent 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 tool catalog; drop the flag (default `full`) for every operation. Full walkthrough (both this local path and connecting to a remote brain), plus the brain-first protocol to paste into `CLAUDE.md` / `AGENTS.md`: **[Give your coding agent a memory](tutorials/connect-coding-agent.md)**.
|
||||
|
||||
## 3. MCP server (any MCP client)
|
||||
|
||||
@@ -98,6 +98,8 @@ Per-client setup guides live in [`docs/mcp/`](mcp/):
|
||||
- [`docs/mcp/CODEX.md`](mcp/CODEX.md)
|
||||
- [`docs/mcp/CLAUDE_DESKTOP.md`](mcp/CLAUDE_DESKTOP.md)
|
||||
- [`docs/mcp/CHATGPT.md`](mcp/CHATGPT.md)
|
||||
- [`docs/mcp/HERMES.md`](mcp/HERMES.md)
|
||||
- [`docs/mcp/OPENCLAW.md`](mcp/OPENCLAW.md)
|
||||
- [`docs/mcp/PERPLEXITY.md`](mcp/PERPLEXITY.md)
|
||||
- [`docs/mcp/DEPLOY.md`](mcp/DEPLOY.md) — production deploy patterns
|
||||
|
||||
|
||||
+5
-1
@@ -176,6 +176,7 @@ Unit tests and what they cover:
|
||||
- `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/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.
|
||||
- `test/cli-format-volunteer.test.ts` — `formatResult`'s `volunteer_context` human rendering: pointer lines with confidence/arm/rationale, the empty-result message, the approximate stats summary.
|
||||
- `test/config.test.ts` — config redaction.
|
||||
@@ -281,7 +282,9 @@ E2E tests live in `test/e2e/` and run against real Postgres+pgvector (require `D
|
||||
- `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` against a minimal AGENTS.md workspace fixture (`test/fixtures/openclaw-reference-minimal/`), regression guard for the OpenClaw deployment shape.
|
||||
- `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/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`.
|
||||
@@ -296,6 +299,7 @@ E2E tests live in `test/e2e/` and run against real Postgres+pgvector (require `D
|
||||
- `test/e2e/think-source-isolation-pglite.test.ts` — PGLite in-memory suite pinning the `think` gather stage's source scope: seeds three sources with cross-source links and embedded takes, then asserts `runGather` under a federated `sourceIds` grant (and under a scalar `sourceId`) keeps every stream — hybrid retrieval, takes keyword + vector (`searchTakes`/`searchTakesVector`), and the `traversePaths` graph walk — inside the grant while still reaching authorized neighboring sources. No `DATABASE_URL` needed.
|
||||
- `test/e2e/skill-brain-first.test.ts` — doctor reports `skill_brain_first` check with structured issues; `--fix --dry-run` previews insertion without writing; `--fix` applies the canonical Convention callout idempotently; `brain_first: exempt` frontmatter resolves the warn; `brain_first_typo` surfaces a paste-ready hint; audit JSONL records `detected` / `resolved` / `fixed` transitions; stable brain emits 0 audit lines/run.
|
||||
- Tier 2 (`test/e2e/skills.test.ts`) requires OpenClaw + API keys, runs nightly in CI.
|
||||
- `test/e2e/claw-test.test.ts` also covers live mode token-free via shim agents (`OPENCLAW_BIN=<sh script>`): the success-oracle break path (a do-nothing agent now FAILS), the E0 child-friction merge surviving tempdir cleanup, and the upgrade staging + schema-version-probe regression.
|
||||
- If `.env.testing` doesn't exist in this directory, check sibling worktrees: `find ../ -maxdepth 2 -name .env.testing -print -quit` and copy it here if found.
|
||||
- **Run E2E tests without asking permission.** When you want to verify behavior, there's a relevant E2E test, or you're shipping anything covered by an E2E suite — spin up the test DB, run the tests, tear down. Don't ask, don't propose it, don't defer. The lifecycle is short (~2-30s startup, sub-minute tests, instant teardown) and the gate value is high. Skipping with "DATABASE_URL unset" is silent regression, not caution.
|
||||
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# ZeroEntropy — zembed-1 + zerank-2
|
||||
|
||||
> **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
|
||||
> 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.
|
||||
>
|
||||
> The hosted setup below remains accurate until the shutdown date.
|
||||
|
||||
[ZeroEntropy](https://zeroentropy.dev) ships two specialized small models
|
||||
for retrieval pipelines:
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -100,9 +100,11 @@ With zero API keys, everything works: the agent authors memory explicitly throug
|
||||
the brain's write tools (`put_page`, timeline entries, `## Facts` fences — your
|
||||
harness's model is the LLM, already paid for), and search runs keyword-only
|
||||
(BM25). `bootstrap verify` prints the capability report honestly. One optional key
|
||||
(OpenAI, Anthropic, or Voyage) unlocks semantic search and automatic fact
|
||||
extraction; the key goes to the 0600 config file, never into the repo or the
|
||||
interview answers. API spend is metered separately from your subscription and is
|
||||
upgrades capabilities per provider — OpenAI unlocks semantic search and
|
||||
automatic fact extraction; Voyage unlocks semantic search; Anthropic unlocks
|
||||
fact extraction (Anthropic has no embeddings API, so it does not enable
|
||||
semantic search). The key goes to the 0600 config file, never into the repo or
|
||||
the interview answers. API spend is metered separately from your subscription and is
|
||||
zero in keyless mode; with a key, the standard spend gates apply
|
||||
([spend-controls](../operations/spend-controls.md)).
|
||||
|
||||
@@ -214,3 +216,32 @@ Run locally (where both are installed + authed):
|
||||
```bash
|
||||
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
|
||||
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).
|
||||
|
||||
`scripts/dx-explore.ts` drives it to capture the fresh-user funnel as timestamped
|
||||
transcripts under `.context/dx-runs/` (gitignored — nothing asserts, no CI):
|
||||
|
||||
```bash
|
||||
bun run scripts/dx-explore.ts help # comprehension surfaces (no keys)
|
||||
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 drive -- gbrain init # manual: steer a live TUI via a file channel
|
||||
```
|
||||
|
||||
`drive` mode is how an agent in a Conductor workspace explores a live TUI across
|
||||
separate tool calls: `cat <dir>/session/screen.txt` to watch, append
|
||||
`{"line":"..."}` / `{"key":"Down"}` / `{"stop":true}` to `<dir>/session/input.jsonl`
|
||||
to steer. Each run writes `meta.json`, `visible.txt`, `frames.jsonl`, and
|
||||
`stalls.md`. `--keyless` strips provider keys so the true no-key first-touch path
|
||||
is exercised (a Conductor session's ambient `ANTHROPIC_API_KEY` would otherwise
|
||||
leak in). Install scenarios pay real API cost — launch them as background tasks.
|
||||
|
||||
@@ -27,6 +27,37 @@ gbrain migrate embeddings --to voyage:voyage-3-large --yes
|
||||
declared width and is required for recipes that don't declare one (litellm,
|
||||
llama-server, and other bring-your-own-model providers).
|
||||
|
||||
**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.
|
||||
|
||||
## 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):
|
||||
|
||||
- **`gbrain doctor`** — the `provider_sunset` check warns on every run until
|
||||
the brain is off the provider. After the shutdown date it escalates to
|
||||
`fail` only when embedded vectors actually exist on the dead provider
|
||||
(retrieval is genuinely down); a zero-vector brain whose config merely
|
||||
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?
|
||||
`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.
|
||||
|
||||
Both 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
|
||||
|
||||
1. **Plan.** Counts every chunk not already in the target embedding space —
|
||||
@@ -88,6 +119,12 @@ continues where it stopped. An in-flight marker (`embedding_migration.state`
|
||||
in DB config) records the target; it is cleared only when the backlog drains
|
||||
to zero.
|
||||
|
||||
One caveat after a HARD kill (SIGKILL, crash, power loss — not Ctrl-C): the
|
||||
run's per-source single-flight embed lock is left behind, and an immediate
|
||||
re-run skips the re-embed and reports the migration as paused. The command
|
||||
says so explicitly (`lock_skipped` in `--json`); the lock expires on its own
|
||||
after at most 60 minutes, then the same re-run resumes normally.
|
||||
|
||||
A page whose chunks straddle two stale batches is embedded correctly but not
|
||||
stamped by the embed loop (which only stamps all-or-nothing per batch), so the
|
||||
migration runs one reconcile pass after the drain that stamps every
|
||||
|
||||
@@ -20,7 +20,12 @@ schema. The user gets new capabilities automatically.
|
||||
|
||||
gbrain stays current the way gstack does: it rides invocation frequency. A
|
||||
throttled, cache-read-only check runs at the start of every `gbrain` invocation
|
||||
(CLI and MCP) and emits an `UPGRADE_AVAILABLE <old> <new>` marker on stderr. No
|
||||
(CLI and MCP) and emits an `UPGRADE_AVAILABLE <old> <new>` marker on stderr. The
|
||||
raw marker line is suppressed when stderr is an interactive TTY (a human sees
|
||||
only the plain `gbrain X -> Y available` sentence, not the machine token); set
|
||||
`GBRAIN_FORCE_UPGRADE_MARKER=1` if an agent harness parses the token but runs
|
||||
under a PTY. `<old>` is always the RUNNING binary's version, so a stale or
|
||||
foreign-written cache never nags about an upgrade this binary already has. No
|
||||
host cron required — every agent kind (Claude Code, Codex, OpenClaw, Hermes, the
|
||||
`gbrain serve` host behind a Perplexity thin client) converges to current by
|
||||
construction. The behavior is governed by one file-plane config key,
|
||||
|
||||
@@ -23,7 +23,7 @@ The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atom
|
||||
|
||||
| Provider | env vars | default dims | cost ($/1M tokens) | local? | multimodal? |
|
||||
|---|---|---|---|---|---|
|
||||
| `zeroentropyai` | `ZEROENTROPY_API_KEY` | 2560 (Matryoshka to 1280/640/320/...) | 0.05 | no | no |
|
||||
| `zeroentropyai` (hosted API **shuts down 2026-09-04** — see note below) | `ZEROENTROPY_API_KEY` | 2560 (Matryoshka to 1280/640/320/...) | 0.05 | no | no |
|
||||
| `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`) |
|
||||
@@ -42,6 +42,8 @@ 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.
|
||||
|
||||
## If first import fails
|
||||
|
||||
If `gbrain import` fails with `expected N dimensions, not M`, run `gbrain doctor`. The output will print the exact `gbrain config set ...` or `gbrain retrieval-upgrade` command to repair the mismatch. **You should not need to delete `~/.gbrain`.** The bug-class that historically forced `rm -rf` recoveries is closed as of v0.37.
|
||||
|
||||
@@ -20,8 +20,8 @@ claude mcp add gbrain -- gbrain serve --surface verbs
|
||||
That's it. Claude Code spawns `gbrain serve` as a stdio subprocess. No server, no
|
||||
tunnel, no token needed. Works with both PGLite and Supabase engines.
|
||||
|
||||
`--surface verbs` exposes the five-verb memory protocol (`recall`, `remember`,
|
||||
`entity`, `synthesize`, `forget` — [MEMORY_VERBS v1](../protocol/MEMORY_VERBS_v1.md)),
|
||||
`--surface verbs` exposes the seven-verb memory protocol (`recall`, `remember`,
|
||||
`entity`, `synthesize`, `forget`, `context_pack`, `delta` — [MEMORY_VERBS v1](../protocol/MEMORY_VERBS_v1.md)),
|
||||
the surface built for agents and quickstarts. Drop the flag for the full
|
||||
operation catalog (`get_page`, `put_page`, `search`, graph ops, …) — `full` is
|
||||
the default and what existing installs already run.
|
||||
|
||||
+18
-2
@@ -24,8 +24,8 @@ gbrain serve --surface verbs # just the 7 memory verbs (quickstart surface)
|
||||
|
||||
Works with Claude Code, Cursor, Windsurf, and any MCP client that supports stdio.
|
||||
No server, no tunnel, no token needed. Works on both PGLite and Postgres engines.
|
||||
`--surface verbs` exposes exactly the five-verb memory protocol (`recall`,
|
||||
`remember`, `entity`, `synthesize`, `forget` —
|
||||
`--surface verbs` exposes exactly 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 catalog;
|
||||
omit the flag (default `full`) for every operation.
|
||||
|
||||
@@ -163,6 +163,22 @@ await oauthProvider.registerClientManual(
|
||||
For self-service client registration (Dynamic Client Registration, RFC 7591),
|
||||
start the server with `--enable-dcr`. DCR is off by default.
|
||||
|
||||
DCR requests may include an optional `token_ttl_seconds` field (integer,
|
||||
seconds) to request a per-client access-token lifetime. The server clamps the
|
||||
request into an admin-configured window — never rejects over it — persists the
|
||||
effective value as the client's TTL override, and echoes it back as
|
||||
`token_ttl_seconds` in the registration response. Subsequent `/token` responses
|
||||
for that client carry the matching `expires_in`. Clients that omit the field
|
||||
keep the server default (`--token-ttl`). The window defaults fail-closed: min
|
||||
300 seconds, max bounded by your `--token-ttl` — a self-registering client
|
||||
cannot request a longer-lived token than the server default unless you
|
||||
explicitly widen the window:
|
||||
|
||||
```bash
|
||||
gbrain config set oauth.dcr_ttl_min_seconds 600
|
||||
gbrain config set oauth.dcr_ttl_max_seconds 86400
|
||||
```
|
||||
|
||||
### 3. Expose the server
|
||||
|
||||
**Bind explicitly.** `gbrain serve --http` defaults to `127.0.0.1`.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Hermes CLI pin — observed behavior notes (v0.20.0)
|
||||
|
||||
Dev-facing companion to [HERMES.md](HERMES.md): every fact below was OBSERVED
|
||||
against a real install (2026-08-12), not researched from docs. The claw-test
|
||||
HermesRunner, the install door e2e, and the heavy-tests hermes-door CI job
|
||||
assert exactly these shapes — when hermes releases change them, update this
|
||||
file, the workflow pins, and the affected assertions together.
|
||||
|
||||
## Pin
|
||||
- **Hermes Agent v0.20.0 (2026.8.3)**, observed against git checkout `3e09adb` at
|
||||
`~/.hermes/hermes-agent` (an upstream-main commit carrying the same v0.20.0/2026.8.3
|
||||
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`
|
||||
(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
|
||||
|
||||
## HERMES_HOME — HONORED (verified)
|
||||
Installer (`HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"`) AND runtime both honor it:
|
||||
`mcp add`/`mcp list`/`config set` under `HERMES_HOME=<tmp>` read+write `<tmp>/config.yaml`,
|
||||
populate `<tmp>/{SOUL.md,cron,logs,...}`, and do NOT touch `~/.hermes`. Belt-and-suspenders
|
||||
(HOME + HERMES_HOME both to tmp) stays in the door test anyway.
|
||||
|
||||
## One-shot (`-z`)
|
||||
- `hermes -z "<prompt>"` → **stdout = final text ONLY**; benign notices may appear on stderr
|
||||
("Shell cwd was reset to ..."). Verified reply fidelity ("B0-PROBE-OK").
|
||||
- Exit codes: 0 = success; **1 = no inference provider configured** (message: "agent failed:
|
||||
No inference provider configured. Run 'hermes model' ... or set an API key
|
||||
(OPENROUTER_API_KEY, OPENAI_API_KEY, etc.) in ~/.hermes/.env.")
|
||||
- `--usage-file PATH` exists; per-call `-m MODEL --provider PROVIDER` exist; also
|
||||
`--in DIR`, `--ignore-user-config`, `--safe-mode`, `-t TOOLSETS`, `--skills`.
|
||||
|
||||
## Auth + model pin (non-interactive)
|
||||
- `$HERMES_HOME/.env` with `ANTHROPIC_API_KEY=...` WORKS (verified end-to-end).
|
||||
- Model pin: `hermes config set model.default anthropic/claude-haiku-4.5` → exit 0,
|
||||
writes `model.default` into config.yaml. `hermes config get model.default` reads it back.
|
||||
(`hermes model` is INTERACTIVE-only — never use it in tests/CI.)
|
||||
- Valid model id format: `anthropic/claude-haiku-4.5` (hermes catalog naming, provider-prefixed).
|
||||
|
||||
## `hermes mcp add` — THE big observed facts
|
||||
- Shape: `hermes mcp add <name> [--env K=V K2=V2 ...] [--connect-timeout N] --command CMD --args ...`
|
||||
**`--args` MUST be the last option** — anything after it (incl. a misplaced `--env`) is
|
||||
swallowed into the server argv. (First rehearsal failed exactly this way.)
|
||||
**The env flag takes MULTIPLE KEY=VALUE values after ONE flag; REPEATING it REPLACES the
|
||||
first occurrence** (argparse nargs semantics) — a repeated-flag invocation silently drops
|
||||
the earlier vars, the handshake fails, and the piped Y then hits the save-anyway prompt →
|
||||
the entry is saved with `enabled: false`. (First real door run failed exactly this way.)
|
||||
- Add performs a REAL MCP handshake + tool discovery at add time. Against
|
||||
`--command bun --args run <abs>/src/cli.ts serve` with `--env GBRAIN_HOME=<tmp>`:
|
||||
connected, discovered **110 gbrain tools**.
|
||||
- On success it prompts `Enable all N tools? [Y/n/select]:` — **non-interactive: pipe
|
||||
`printf 'Y\n'`**. Piping Y saves: `✓ Saved 'gbrain' to <HERMES_HOME>/config.yaml (110/110
|
||||
tools enabled)`. EOF on the prompt = `Cancelled.`, nothing saved.
|
||||
- **EXIT CODE IS 0 EVEN ON CONNECTION FAILURE OR CANCEL.** Never assert on `mcp add`'s exit
|
||||
code. Hard assertions = (a) `config.yaml` contains `mcp_servers.<name>` after the add,
|
||||
(b) `hermes mcp test <name>` exits 0.
|
||||
|
||||
## Saved config schema (verbatim shape)
|
||||
```yaml
|
||||
_config_version: 34
|
||||
mcp_servers:
|
||||
gbrain:
|
||||
command: bun
|
||||
args:
|
||||
- run
|
||||
- /abs/path/src/cli.ts
|
||||
- serve
|
||||
env:
|
||||
GBRAIN_HOME: /tmp/gb-xxxx
|
||||
connect_timeout: 60.0
|
||||
enabled: true
|
||||
```
|
||||
(The generated file also contains commented template blocks — security, fallback_model.)
|
||||
|
||||
## Probes
|
||||
- `hermes mcp list` → table `Name / Transport / Tools / Status`, row shows `gbrain ... ✓ enabled`.
|
||||
- `hermes mcp test gbrain` → exit 0 + prints the tool list. THE targeted probe for Test 1b.
|
||||
- `hermes doctor` exists (global health; not a per-server assertion).
|
||||
|
||||
## Cron (for the post-pin F7 TODO — real test is buildable)
|
||||
`hermes cron create [--name NAME] [--deliver ...] [--repeat N] [--skill S] [--script PATH]
|
||||
[--no-agent] [--workdir DIR] [--model M] [--provider P] <schedule> [prompt]` — fully
|
||||
non-interactive. `hermes cron tick` = run due jobs once and exit. `hermes cron list` exists.
|
||||
|
||||
## CI pin values (heavy-tests.yml `hermes-door` job)
|
||||
- `HERMES_VERSION: "0.20.0"`
|
||||
- `HERMES_GIT_TAG: "v2026.8.3"` + `HERMES_GIT_COMMIT: "3c27eb6234bf91b8ceee9e9071591b31e9b148cb"` —
|
||||
the installer's `--branch`/`--commit` flags pin the cloned PAYLOAD (the sha256 below only
|
||||
pins the installer script; without the tag+commit the payload would be upstream main).
|
||||
The flags are asserted, not trusted: post-install the job runs
|
||||
`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"`
|
||||
- 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.
|
||||
|
||||
## Multi-provider 401 gotcha (door hermeticity)
|
||||
With `model.default` pinned to `anthropic/*` but a SECOND provider key visible (env or
|
||||
.env — e.g. `OPENAI_API_KEY`), hermes's provider-auto mis-routes the request and the turn
|
||||
returns `HTTP 401: Missing Authentication header` as final text with EXIT 0. The door
|
||||
suite therefore seeds exactly ONE key (anthropic) and scrubs all provider env vars from
|
||||
hermes children (`hermesChildEnv` in test/helpers/agent-harness.ts) — the seeded
|
||||
`$HERMES_HOME/.env` is the single auth source.
|
||||
|
||||
## mcp add save-anyway (correction to an earlier note)
|
||||
A piped `Y` saves the entry EVEN when the handshake failed — the save-anyway prompt
|
||||
writes it with `enabled: false`. The success discriminators are `enabled: true` in the
|
||||
saved YAML plus `hermes mcp test <name>` exit 0 — never the add's exit code, and not the
|
||||
mere presence of the config entry.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Connect GBrain to Hermes
|
||||
|
||||
> This page is the MCP-registration reference for Hermes (the NousResearch
|
||||
> `hermes-agent`). 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 Hermes over stdio MCP.
|
||||
|
||||
Hermes 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
|
||||
printf 'Y\n' | hermes mcp add gbrain --env GBRAIN_HOME=$HOME --connect-timeout 60 --command $(which gbrain) --args serve
|
||||
```
|
||||
|
||||
`hermes mcp add` performs a real MCP handshake and tool discovery at add time,
|
||||
then prompts `Enable all N tools? [Y/n/select]:`. Three gotchas, all observed:
|
||||
|
||||
- **`--args` must be the LAST option.** Everything after it — including a
|
||||
misplaced `--env` — is swallowed into the server argv. To pass several
|
||||
environment variables, list them all after ONE `--env` flag
|
||||
(`--env A=1 B=2`); repeating the flag replaces the earlier values and the
|
||||
server is saved disabled when its handshake then fails. Put `--env` and
|
||||
`--connect-timeout` before `--command`, exactly as above.
|
||||
- **Pipe the `Y` in non-interactive contexts.** EOF on the enable-tools prompt
|
||||
prints `Cancelled.` and saves nothing. The piped `Y` saves the server with
|
||||
all tools enabled.
|
||||
- **The exit code is 0 even on connection failure or cancel.** Never assert on
|
||||
`mcp add`'s exit status — verify with `hermes mcp list` and
|
||||
`hermes mcp test gbrain` (below).
|
||||
|
||||
## Direct config (equally supported)
|
||||
|
||||
The add command writes an `mcp_servers` block into `$HERMES_HOME/config.yaml`
|
||||
(default `~/.hermes/config.yaml`). You can write it yourself instead:
|
||||
|
||||
```yaml
|
||||
mcp_servers:
|
||||
gbrain:
|
||||
command: gbrain
|
||||
args:
|
||||
- serve
|
||||
env:
|
||||
GBRAIN_HOME: /home/alice-example
|
||||
connect_timeout: 60.0
|
||||
enabled: true
|
||||
```
|
||||
|
||||
To remove gbrain, delete this block (or set `enabled: false` to disable
|
||||
without losing the config).
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
hermes mcp list # table row: gbrain ... ✓ enabled
|
||||
hermes mcp test gbrain # exits 0 and prints the discovered tool list
|
||||
```
|
||||
|
||||
Then one real round-trip:
|
||||
|
||||
```bash
|
||||
hermes -z "ask my gbrain brain: what did I import most recently?"
|
||||
```
|
||||
|
||||
`hermes -z` prints the final answer on stdout (benign notices may appear on
|
||||
stderr). Inside Hermes, gbrain's tools appear namespaced as
|
||||
`mcp_gbrain_<tool>` (e.g. `mcp_gbrain_search`).
|
||||
|
||||
## Headless auth + model pin
|
||||
|
||||
For cron jobs, CI, or any non-TTY run, Hermes needs a provider key and a
|
||||
default model configured without the interactive picker:
|
||||
|
||||
- Put the key in `$HERMES_HOME/.env`:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
# or OPENROUTER_API_KEY / OPENAI_API_KEY
|
||||
```
|
||||
|
||||
- Pin the model non-interactively (`hermes model` is interactive-only — never
|
||||
use it in scripts or CI):
|
||||
|
||||
```bash
|
||||
hermes config set model.default anthropic/claude-haiku-4.5
|
||||
hermes config get model.default # reads it back
|
||||
```
|
||||
|
||||
## Pair with cron
|
||||
|
||||
Hermes cron is fully non-interactive, which makes it a natural scheduler for
|
||||
brain maintenance:
|
||||
|
||||
```bash
|
||||
hermes cron create --name gbrain-sync '0 */4 * * *' 'Run gbrain sync and report anything unusual'
|
||||
hermes cron tick # run due jobs once and exit — deterministic testing
|
||||
hermes cron list
|
||||
```
|
||||
|
||||
See [docs/guides/cron-schedule.md](../guides/cron-schedule.md) for the full
|
||||
brain maintenance protocol (sync, embed, dream cycle).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`hermes doctor`** — global health check (installation, config, providers).
|
||||
It's not a per-server assertion; use `hermes mcp test gbrain` for that.
|
||||
- **`agent failed: No inference provider configured`** (exit 1) — Hermes has
|
||||
no model key. Set one in `$HERMES_HOME/.env` and pin `model.default` as
|
||||
above.
|
||||
- **Relocating Hermes** — both the installer and the runtime honor
|
||||
`HERMES_HOME`. All state (`config.yaml`, `.env`, `SOUL.md`, cron, logs)
|
||||
lives under it; the default is `~/.hermes`. Export it consistently or the
|
||||
gbrain registration lands in a config file the runtime never reads.
|
||||
|
||||
---
|
||||
|
||||
Documented against **Hermes Agent v0.20.0 (2026.8.3)**. Dev-facing observed-behavior
|
||||
notes (exact flag semantics, exit-code caveats, CI pin values) live in
|
||||
[HERMES-CLI-PIN.md](HERMES-CLI-PIN.md).
|
||||
@@ -0,0 +1,62 @@
|
||||
# Connect GBrain to OpenClaw
|
||||
|
||||
> This page is the MCP-registration reference card. For the full brain install
|
||||
> — CLI, engine, skills, dream cycle — follow
|
||||
> [INSTALL_FOR_AGENTS.md](../../INSTALL_FOR_AGENTS.md); the README covers the
|
||||
> bootstrap and connect paths.
|
||||
|
||||
Two supported shapes, both stdio.
|
||||
|
||||
## Option 1: ClawHub bundle plugin
|
||||
|
||||
GBrain ships [`openclaw.plugin.json`](../../openclaw.plugin.json) at the repo
|
||||
root. Installing the bundle plugin registers the MCP server for you — the
|
||||
manifest carries an `mcpServers.gbrain` entry (`./bin/gbrain serve`) plus the
|
||||
bundled skills — and declares the `gbrain-context` context engine. To route
|
||||
OpenClaw's context-engine slot through gbrain, set:
|
||||
|
||||
```
|
||||
plugins.slots.contextEngine = gbrain-context
|
||||
```
|
||||
|
||||
## Option 2: Direct `~/.openclaw/config.json`
|
||||
|
||||
The same shape gbrain's own CI uses (see the "Configure OpenClaw MCP" step in
|
||||
`.github/workflows/e2e.yml`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gbrain": {
|
||||
"command": "gbrain",
|
||||
"args": ["serve"],
|
||||
"env": {
|
||||
"DATABASE_URL": "postgresql://...",
|
||||
"GBRAIN_HOME": "/home/alice-example"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `env` block is optional: a PGLite brain needs no `DATABASE_URL`, and
|
||||
`GBRAIN_HOME` only matters when the brain home isn't `~/.gbrain`. Append
|
||||
`"--surface", "verbs"` to `args` for the seven-verb memory protocol
|
||||
([MEMORY_VERBS v1](../protocol/MEMORY_VERBS_v1.md)) instead of the full
|
||||
operation catalog.
|
||||
|
||||
## Verify
|
||||
|
||||
Start an agent turn and ask it to use the brain:
|
||||
|
||||
```
|
||||
Call get_brain_identity, then search my brain for [topic].
|
||||
```
|
||||
|
||||
If the tools respond, the wiring works. `list_skills` shows everything the
|
||||
brain can do (gated by `mcp.publish_skills` on the host).
|
||||
|
||||
## Remove
|
||||
|
||||
Delete the `mcpServers.gbrain` block from `~/.openclaw/config.json`, or
|
||||
uninstall the bundle plugin.
|
||||
@@ -135,6 +135,10 @@ sync that calls import emits `sync.import.<file>`, not `import.<file>`.
|
||||
Stable phase names shipped in v0.15.2:
|
||||
|
||||
- `doctor.db_checks` (umbrella for all DB-side doctor checks)
|
||||
- `doctor.pglite_probe` (the #2674 scratch-store probe; only when PGLite init
|
||||
failed with an unexplained/damage-class disk state or `--probe-pglite` was
|
||||
passed — a cold start can take 5–20s, so the heartbeat is the only sign of
|
||||
life)
|
||||
- `orphans.scan`
|
||||
- `embed.pages`
|
||||
- `extract.links_fs`, `extract.timeline_fs`, `extract.links_db`, `extract.timeline_db`
|
||||
|
||||
@@ -156,8 +156,8 @@ codex mcp add gbrain -- gbrain serve --surface verbs
|
||||
That's the whole wire-up. No token, no URL, no tunnel. The agent spawns
|
||||
`gbrain serve` as a stdio subprocess and talks to your local brain directly.
|
||||
|
||||
`--surface verbs` exposes exactly the five-verb memory protocol
|
||||
(`recall`, `remember`, `entity`, `synthesize`, `forget` —
|
||||
`--surface verbs` exposes exactly the seven-verb memory protocol
|
||||
(`recall`, `remember`, `entity`, `synthesize`, `forget`, `context_pack`, `delta` —
|
||||
[MEMORY_VERBS v1](../protocol/MEMORY_VERBS_v1.md), frozen + additive-forever)
|
||||
instead of the full operation catalog, so the agent sees a tight, stable surface
|
||||
instead of a 110-tool wall. Drop the flag (or pass `--surface full`) for every
|
||||
@@ -192,12 +192,12 @@ about people, companies, decisions, projects, or past context:
|
||||
tokens → `search` (cheap hybrid, no expansion). Concept, landscape, or
|
||||
"all the X that do Y" questions → `query` FIRST — it recovers synonym
|
||||
phrasings `search` misses, and a populated `search` result set is not proof
|
||||
of coverage. On the five-verb surface the same split is `recall` (retrieve)
|
||||
of coverage. On the seven-verb surface the same split is `recall` (retrieve)
|
||||
vs `synthesize` (reasoned answer). Check the brain BEFORE answering from
|
||||
memory or asking me. Never ask "who is X?" or "what did we decide about Y?"
|
||||
before checking — the brain probably already knows.
|
||||
2. **Write back.** When I make a decision, mention a new person/company, or land
|
||||
on an idea worth keeping, write it to the brain: `remember` on the five-verb
|
||||
on an idea worth keeping, write it to the brain: `remember` on the seven-verb
|
||||
surface (one fact, with provenance), or `put_page` on the full surface
|
||||
(entity pages under people/, companies/; decisions under decisions/ or
|
||||
notes/). One insight, one page, linked.
|
||||
@@ -222,7 +222,7 @@ hundreds of linked pages and patterns you didn't know were there.
|
||||
**3. Briefing from your brain (not from the internet).** *"What do I need to know
|
||||
before my 2pm with the Acme team?"* pulls your meeting history, the people,
|
||||
what's still open, what the brain doesn't know yet. The agent does your prep
|
||||
because it read your context. (`query` — `synthesize` on the five-verb surface —
|
||||
because it read your context. (`query` — `synthesize` on the seven-verb surface —
|
||||
gives you the synthesized answer with citations; this is the example on the
|
||||
[README](../../README.md).)
|
||||
|
||||
|
||||
+40
-9
@@ -661,7 +661,7 @@ four numeric segments are required first. Historical 3-segment versions
|
||||
| `CHANGELOG.md` | Top entry header `## [0.31.4.1] - YYYY-MM-DD` plus the "To take advantage of v0.31.4.1" block. | Standard Keep-a-Changelog header. |
|
||||
| `TODOS.md` | Any TODO entries that mention "follow-up from vX.Y.Z.W" use the version of the release that filed them. Update only when filing NEW follow-up TODOs. | Inline `vX.Y.Z.W` references in TODO bodies. |
|
||||
| `CLAUDE.md` | The Key Files section's per-file annotations carry `vX.Y.Z.W (#NNN)` tags noting which release introduced a behavior. Update whenever a wave's annotations get folded in. | Inline `vX.Y.Z.W (#NNN, contributed by @user)` references. |
|
||||
| `openclaw.plugin.json` | OpenClaw plugin manifest (v0.45.6.0, #4033). Hand-maintained; `test/openclaw-plugin-manifest.test.ts` fails the suite if it drifts from `package.json`. Merges from master auto-resolve it to master's version — re-bump it with the trio. | `"version": "0.45.8.0"` |
|
||||
| `openclaw.plugin.json` | OpenClaw plugin manifest (v0.45.6.0, #4033). Hand-maintained; `test/openclaw-plugin-manifest.test.ts` fails the suite if it drifts from `package.json`. Merges from master auto-resolve it to master's version — re-bump it with the trio. | `"version": "0.45.12.0"` |
|
||||
| `BOOTSTRAP_FOR_AGENTS.md` | Runbook stamp on line 1. `scripts/check-bootstrap-tag.sh` (in `bun run verify` + CI) fails when it drifts from `VERSION`; refresh it in the same commit as the bump. | `<!-- gbrain-runbook-stamp: X.Y.Z.W -->` |
|
||||
| `templates/bootstrap/template-repo/` | Vendored template tree with an embedded version stamp. Auto-derived, but NOT by `bun install`: run `bun run scripts/generate-template-repo.ts --out templates/bootstrap/template-repo` after the bump; `scripts/check-bootstrap-templates.sh` fails CI on drift. | `<!-- gbrain-template-stamp: X.Y.Z.W -->` in generated files. |
|
||||
|
||||
@@ -1106,9 +1106,10 @@ NOT inside ~/gbrain.
|
||||
## Step 3.5: Confirm search mode with the user (DO NOT SKIP)
|
||||
|
||||
`gbrain init` auto-applied a default search mode (`tokenmax` unless your subagent
|
||||
tier is Haiku-class or no OpenAI key is configured). The init output included the
|
||||
cost matrix below preceded by `[AGENT]` markers. You must NOT silently accept the
|
||||
default. Stop and ask the operator.
|
||||
tier is Haiku-class or no expansion-capable API key — Anthropic, OpenAI, or
|
||||
Google — is configured). The init output included the cost matrix below preceded
|
||||
by `[AGENT]` markers. You must NOT silently accept the default. Stop and ask the
|
||||
operator.
|
||||
|
||||
**Present this matrix verbatim:**
|
||||
|
||||
@@ -1232,6 +1233,16 @@ diff against gbrain's bundle when you want upstream improvements. (The legacy
|
||||
`gbrain skillpack install` managed-block model was removed in v0.33 — run
|
||||
`gbrain skillpack migrate-fence` once if upgrading from an older release.)
|
||||
|
||||
**If you are Hermes:** register gbrain as your MCP server:
|
||||
|
||||
```bash
|
||||
printf 'Y\n' | hermes mcp add gbrain --env GBRAIN_HOME=$HOME --connect-timeout 60 --command $(which gbrain) --args serve
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
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
|
||||
@@ -1596,7 +1607,7 @@ The point of building a 150K-page brain is to use it as a strategic moat. To nev
|
||||
|
||||
It's easier to ship a daemon that runs 24/7 to ingest, enrich, and consolidate than it is to keep an agent in chat working hard. GBrain is that daemon, generalized. Install in 30 minutes. Your agent does the work. As my personal agent gets smarter, so does yours.
|
||||
|
||||
> **~30 minutes to a fully working brain.** Database ready in 2 seconds (PGLite, no server). You just answer questions about API keys.
|
||||
> **~15 minutes to a working personal agent** on the recommended Codex / Claude Code path (mostly a short interview); ~30 minutes for the always-on OpenClaw / Hermes setup. Database ready in 2 seconds either way (PGLite, no server).
|
||||
|
||||
> **LLMs:** fetch [`llms.txt`](llms.txt) for the documentation map, or [`llms-full.txt`](llms-full.txt) for the same map with core docs inlined in one fetch. **Agents:** start with [`AGENTS.md`](AGENTS.md) (or [`CLAUDE.md`](CLAUDE.md) if you're Claude Code).
|
||||
|
||||
@@ -1671,7 +1682,9 @@ answers. Ask before anything destructive. You are not done until
|
||||
`gbrain bootstrap verify` exits 0.
|
||||
```
|
||||
|
||||
Codex will ask for command approvals during the install — approving them is the sandbox working as intended. What you get, in about 15 minutes: a short interview (6 required questions) → your agent's identity (SOUL.md, USER.md, MEMORY.md) rendered from your own answers, never invented → a local PGLite brain (2 seconds, no server, no Docker) → MCP wired so every session can search and write memory → a **private** GitHub repo, created and privacy-verified, as your agent's durable body. Works with **zero API keys** — keyword search plus memory your agent writes itself; one optional key (OpenAI, Anthropic, or Voyage) upgrades to semantic search and automatic fact extraction. Codex reads brain context through its tools each turn (pull-based).
|
||||
Codex will ask for command approvals during the install — approving them is the sandbox working as intended. What you get, in about 15 minutes: a short interview (6 required questions) → your agent's identity (SOUL.md, USER.md, MEMORY.md) rendered from your own answers, never invented → a local PGLite brain (2 seconds, no server, no Docker) → MCP wired so every session can search and write memory → a **private** GitHub repo, created and privacy-verified, as your agent's durable body. Works with **zero API keys** — keyword search plus memory your agent writes itself; one optional key upgrades capabilities (OpenAI: semantic search + automatic fact extraction; Voyage: semantic search; Anthropic: fact extraction). Codex reads brain context through its tools each turn (pull-based). The click moment: tell it one small thing to remember, restart Codex, then ask for it back — the answer comes from the brain, not from this chat's context (which the restart cleared). That cross-session round-trip is the whole product; "what's my name / my top jobs?" is answered from your identity files, which is nice but not the same trick.
|
||||
|
||||
Two things worth understanding once it's running: **you own the brain** — every memory is a markdown file in that private repo (read it, clone it to a second machine, delete it and the brain is gone) — and **the first skill to run is `cold-start`**: say "fill my brain" and your agent imports your Gmail, calendar, and contacts (via [ClawVisor](https://clawvisor.com), an OAuth vault so the agent never holds raw tokens) or offline archives like Google Takeout, one consented step at a time. An empty brain is a database; a filled one is a memory.
|
||||
|
||||
> **Prefer to make the repo yourself?** Create a new **empty** private repo **under your own GitHub account** (no README/.gitignore/license), clone it, open the clone in Codex, and paste the same block — bootstrap detects your empty repo and adopts it instead of creating one. The repo must be empty and personal-account-owned; org-owned repos are refused (create one under your account, or let bootstrap make it).
|
||||
|
||||
@@ -1688,7 +1701,7 @@ answers. Ask before anything destructive. You are not done until
|
||||
`gbrain bootstrap verify` exits 0.
|
||||
```
|
||||
|
||||
Everything from the Codex path applies — interview, identity from your own answers, local brain, private repo, keyless mode — plus Claude Code gets **per-turn context hooks**: your brain loads automatically into every prompt, and your work persists to your private repo on a per-turn cadence (debounced ~5 min locally, every turn in a cloud sandbox — this covers the `/exit` case the harness never fires a session-end hook on), with a notice on your next turn if a push ever fails. This works in a **Claude Code cloud session** too, not just on your laptop: verification falls back to pure git protocol when the sandbox blocks the GitHub API, and `gbrain bootstrap cloud-setup-script` prints the environment setup recipe. Restart the session after install and ask "what did I tell you my top jobs were?" — that's the moment it clicks. Full contract, security posture, cloud sandboxes, and uninstall: [docs/guides/bootstrap.md](docs/guides/bootstrap.md).
|
||||
Everything from the Codex path applies — interview, identity from your own answers, local brain, private repo, keyless mode — plus Claude Code gets **per-turn context hooks** (on by default, with an opt-out): your brain loads automatically into every prompt, and your work persists to your private repo on a per-turn cadence (debounced ~5 min locally, every turn in a cloud sandbox — this covers the `/exit` case the harness never fires a session-end hook on), with a notice on your next turn if a push ever fails. This works in a **Claude Code cloud session** too, not just on your laptop: verification falls back to pure git protocol when the sandbox blocks the GitHub API, and `gbrain bootstrap cloud-setup-script` prints the environment setup recipe. The click moment: tell it one small thing to remember, restart the session, then ask for it back — a fresh session has no chat context, so the answer can only come from the brain. That cross-session round-trip is the whole product ("what's my name?" is answered from your identity files — nice, but not the same trick). Same two follow-ups as the Codex path: you own the brain (markdown in your private repo), and `cold-start` is the first skill to run — "fill my brain" imports your email, calendar, and contacts (ClawVisor) or offline archives, one consented step at a time. Full contract, security posture, cloud sandboxes, and uninstall: [docs/guides/bootstrap.md](docs/guides/bootstrap.md).
|
||||
|
||||
> **Prefer to make the repo yourself?** Create a new **empty** private repo **under your own GitHub account** (no README/.gitignore/license), clone it, open the clone in Claude Code (CLI or the desktop app's open-a-repo flow), and paste the same block — bootstrap adopts your empty repo instead of creating one. The repo must be empty and personal-account-owned; org-owned repos are refused.
|
||||
|
||||
@@ -1751,6 +1764,8 @@ GBrain exposes nearly all of its 100+ operations as MCP tools (stdio and HTTP; a
|
||||
- **[Claude Code](docs/mcp/CLAUDE_CODE.md)** — local: one command, `claude mcp add gbrain -- gbrain serve` (zero server, zero tunnel). Remote with just a bearer token: `gbrain connect https://your-host/mcp --token gbrain_xxx` prints a paste-ready block (or `--install` wires it up and smoke-tests the token).
|
||||
- **[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).
|
||||
- **[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.
|
||||
- **[Perplexity Computer](docs/mcp/PERPLEXITY.md)** — `gbrain connect https://your-host/mcp --agent perplexity --oauth --register` mints a least-privilege OAuth client and prints the Issuer/Client ID/Secret to paste into Settings → Connectors (OAuth is the right path for a cloud connector; a bearer token also works for local use). Pro subscription required.
|
||||
@@ -3986,8 +4001,8 @@ gbrain serve --surface verbs # just the 7 memory verbs (quickstart surface)
|
||||
|
||||
Works with Claude Code, Cursor, Windsurf, and any MCP client that supports stdio.
|
||||
No server, no tunnel, no token needed. Works on both PGLite and Postgres engines.
|
||||
`--surface verbs` exposes exactly the five-verb memory protocol (`recall`,
|
||||
`remember`, `entity`, `synthesize`, `forget` —
|
||||
`--surface verbs` exposes exactly 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 catalog;
|
||||
omit the flag (default `full`) for every operation.
|
||||
|
||||
@@ -4125,6 +4140,22 @@ await oauthProvider.registerClientManual(
|
||||
For self-service client registration (Dynamic Client Registration, RFC 7591),
|
||||
start the server with `--enable-dcr`. DCR is off by default.
|
||||
|
||||
DCR requests may include an optional `token_ttl_seconds` field (integer,
|
||||
seconds) to request a per-client access-token lifetime. The server clamps the
|
||||
request into an admin-configured window — never rejects over it — persists the
|
||||
effective value as the client's TTL override, and echoes it back as
|
||||
`token_ttl_seconds` in the registration response. Subsequent `/token` responses
|
||||
for that client carry the matching `expires_in`. Clients that omit the field
|
||||
keep the server default (`--token-ttl`). The window defaults fail-closed: min
|
||||
300 seconds, max bounded by your `--token-ttl` — a self-registering client
|
||||
cannot request a longer-lived token than the server default unless you
|
||||
explicitly widen the window:
|
||||
|
||||
```bash
|
||||
gbrain config set oauth.dcr_ttl_min_seconds 600
|
||||
gbrain config set oauth.dcr_ttl_max_seconds 86400
|
||||
```
|
||||
|
||||
### 3. Expose the server
|
||||
|
||||
**Bind explicitly.** `gbrain serve --http` defaults to `127.0.0.1`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "gbrain-context-engine",
|
||||
"name": "gbrain",
|
||||
"version": "0.45.9.0",
|
||||
"version": "0.45.12.0",
|
||||
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
|
||||
"family": "bundle-plugin",
|
||||
"configSchema": {
|
||||
@@ -47,6 +47,7 @@
|
||||
"skills/capture",
|
||||
"skills/citation-fixer",
|
||||
"skills/citation-graph-ingest",
|
||||
"skills/cold-start",
|
||||
"skills/company-brainify",
|
||||
"skills/concept-synthesis",
|
||||
"skills/context-audit",
|
||||
|
||||
+1
-1
@@ -154,7 +154,7 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.45.9.0",
|
||||
"version": "0.45.12.0",
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.5",
|
||||
|
||||
@@ -32,7 +32,12 @@ cd "$ROOT"
|
||||
BANNED_NAMES=(
|
||||
'Diana' # Diana Hu, named in CLAUDE.md privacy example
|
||||
'Wintermute' # private OpenClaw fork name (CLAUDE.md rule)
|
||||
'Hermes' # downstream agent fork name
|
||||
# 'Hermes' removed (hermes-harness wave): here it names NousResearch/hermes-agent,
|
||||
# a PUBLIC platform gbrain documents (README hero, INSTALL_FOR_AGENTS.md) and now
|
||||
# tests against (claw-test hermes runner, install door e2e).
|
||||
# test/readme-hero-anchors.test.ts REQUIRES the README to mention it. The original
|
||||
# scrub targeted conflating the public agent with PRIVATE deployment names — those
|
||||
# (Wintermute, and any future private fork names) remain banned above/below.
|
||||
'Technium' # real GP handle
|
||||
'McGrew' # ex-OpenAI exec
|
||||
'YC Labs' # internal team name
|
||||
@@ -57,9 +62,6 @@ ALLOWLIST=(
|
||||
"test/recency-decay.test.ts:Wintermute" # regression-prevention test asserting wintermute is absent (structural)
|
||||
"test/scripts/check-proposal-pii.test.ts:Wintermute" # privacy-guard test asserting docs/proposals/ rejects wintermute (structural; same meta-rule exception as check-privacy.sh)
|
||||
"test/scripts/check-proposal-pii.test.ts:WINTERMUTE" # case-insensitive sentinel literal for the same privacy-guard test
|
||||
"test/serve-stdio-lifecycle.test.ts:Hermes" # comment naming a downstream-agent scenario — pre-existing, low signal
|
||||
"test/extract.test.ts:Hermes" # markdown-link extraction test fixture — pre-existing, ambiguous (Greek god vs fork)
|
||||
"test/readme-hero-anchors.test.ts:Hermes" # v0.36.0.0 D9 anchor test — asserts README mentions Hermes as a credit
|
||||
"test/readme-hero-anchors.test.ts:OpenClaw" # v0.36.0.0 D9 anchor test — asserts README mentions OpenClaw as a credit
|
||||
# v0.36.0.0: skillpack-harvest privacy linter tests structurally
|
||||
# require the literal "Wintermute" to verify the linter catches it.
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
/**
|
||||
* dx-explore — drive the REAL fresh-user experience under a PTY and record it.
|
||||
*
|
||||
* The e2e door tests (test/e2e/bootstrap-real-{claude,codex}.serial.test.ts)
|
||||
* prove the install WORKS headlessly. This script captures what installing
|
||||
* FEELS like: every picker, prompt, spinner, silence window, and line of copy
|
||||
* a fresh user sees, as timestamped transcripts ready for a
|
||||
* Don't-Make-Me-Think DX audit. It is a developer instrument, not a test —
|
||||
* transcripts land in .context/dx-runs/ (gitignored) and nothing asserts.
|
||||
*
|
||||
* Scenarios (all hermetic — temp HOME/GBRAIN_HOME/CLAUDE_CONFIG_DIR/CODEX_HOME;
|
||||
* the operator's real config is never WRITTEN. Two narrow reads exist for
|
||||
* auth: codex-install copies ~/.codex/auth.json into the temp CODEX_HOME, and
|
||||
* the claude seed records the API key's last 20 chars — both copies are
|
||||
* scrubbed at cleanup even under --keep, so no credential material outlives
|
||||
* the run):
|
||||
*
|
||||
* help First-touch comprehension surfaces: bare `gbrain`,
|
||||
* `gbrain --help`, `gbrain init --help`, `gbrain bootstrap
|
||||
* --help`, `gbrain bootstrap` bare. Cheap, no keys.
|
||||
* init Interactive `gbrain init` (keyless) with a naive-user
|
||||
* autopilot: wait for each screen to settle, snapshot it,
|
||||
* press Enter (accept the default), repeat. What a user who
|
||||
* "just hits Enter" experiences, with stall timing.
|
||||
* claude-install REAL interactive `claude` in a fresh empty workspace,
|
||||
* driven by the README paste block pointed at THIS repo's
|
||||
* BOOTSTRAP_FOR_AGENTS.md, with a scripted persona appendix
|
||||
* 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).
|
||||
* 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
|
||||
* type: echo '{"line":"hello"}' >> <dir>/session/input.jsonl
|
||||
* keys: echo '{"key":"Down"}' >> <dir>/session/input.jsonl
|
||||
* note: echo '{"note":"picker confuses me"}' >> ...
|
||||
* stop: echo '{"stop":true}' >> ...
|
||||
* {"line": ...} sends text + Enter; {"send": ...} sends raw
|
||||
* bytes (mind that zsh `echo` mangles \r — prefer "line").
|
||||
* Launch as a background task; this is how an agent in
|
||||
* Conductor explores a live TUI across tool calls.
|
||||
*
|
||||
* Usage:
|
||||
* bun run scripts/dx-explore.ts help
|
||||
* 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 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)
|
||||
* --rebuild force recompile of the cached binary
|
||||
* --keep keep hermetic temp homes for forensics
|
||||
*
|
||||
* Output bundle per scenario dir: meta.json, raw.txt, visible.txt,
|
||||
* frames.jsonl, stalls.md, events.jsonl (inputs/notes timeline), steps.md
|
||||
* (autopilot screen-by-screen), session/ (live: screen.txt, status.json).
|
||||
*
|
||||
* Progress prints to stderr; the transcript dir path is the only stdout line
|
||||
* (pipe-friendly), matching the repo's progress discipline.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import {
|
||||
launchTty,
|
||||
saveTranscript,
|
||||
seedClaudeTuiConfig,
|
||||
parseDriveCommand,
|
||||
type TtySession,
|
||||
} from '../test/helpers/tty-harness.ts';
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
/** Screen patterns that mean the paste-in install reached a passing verify —
|
||||
* ONE list shared by the claude-install and codex-install scenarios so the
|
||||
* two can't drift when the bootstrap's success copy changes. */
|
||||
const VERIFY_SUCCESS_PATTERNS: Array<RegExp | string> = [
|
||||
/bootstrap verify.*exit(?:ed|s)? 0/i,
|
||||
/verify\b.*\b(passed|0\b)/i,
|
||||
/All checks passed/i,
|
||||
];
|
||||
|
||||
// Same synthetic persona the door tests use — the interview can complete
|
||||
// unattended and nothing real about the operator ever enters a transcript.
|
||||
const PERSONA = {
|
||||
AGENT_NAME: 'Lighthouse',
|
||||
PRINCIPAL_NAME: 'Pat Example',
|
||||
AGENT_PURPOSE: 'Maintain the research corpus and draft the weekly memo without re-briefing.',
|
||||
AGENT_TOP_JOBS: 'corpus upkeep; weekly memo; meeting prep',
|
||||
PRINCIPAL_CONTEXT: 'Runs a small research group; builds internal tooling; values signal over noise.',
|
||||
VOICE_REGISTER: 'Direct: three options, the second one wins.',
|
||||
};
|
||||
|
||||
function log(msg: string): void {
|
||||
process.stderr.write(`[dx-explore] ${msg}\n`);
|
||||
}
|
||||
|
||||
function nowStamp(): string {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '-').slice(0, 19);
|
||||
}
|
||||
|
||||
// ── arg parsing ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface CliArgs {
|
||||
scenario: string;
|
||||
dir?: string;
|
||||
gbrainBin?: string;
|
||||
rebuild: boolean;
|
||||
keep: boolean;
|
||||
/** Strip provider API keys from the child env — the TRUE keyless posture.
|
||||
* Without this, a Conductor session's ANTHROPIC_API_KEY leaks into the
|
||||
* hermetic run and the keyless first-touch path is never exercised. */
|
||||
keyless: boolean;
|
||||
hermeticHome: boolean;
|
||||
driveArgv: string[];
|
||||
}
|
||||
|
||||
/** Provider keys the hermetic base allows through; --keyless drops them. */
|
||||
const PROVIDER_KEY_NAMES = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'OPENAI_API_KEY',
|
||||
'GSTACK_ANTHROPIC_API_KEY',
|
||||
'GSTACK_OPENAI_API_KEY',
|
||||
];
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const out: CliArgs = {
|
||||
scenario: '',
|
||||
rebuild: false,
|
||||
keep: false,
|
||||
keyless: false,
|
||||
hermeticHome: true,
|
||||
driveArgv: [],
|
||||
};
|
||||
let i = 0;
|
||||
const sep = argv.indexOf('--');
|
||||
const own = sep >= 0 ? argv.slice(0, sep) : argv;
|
||||
out.driveArgv = sep >= 0 ? argv.slice(sep + 1) : [];
|
||||
while (i < own.length) {
|
||||
const a = own[i]!;
|
||||
if (a === '--dir') out.dir = own[++i];
|
||||
else if (a === '--gbrain') out.gbrainBin = own[++i];
|
||||
else if (a === '--rebuild') out.rebuild = true;
|
||||
else if (a === '--keep') out.keep = true;
|
||||
else if (a === '--keyless') out.keyless = true;
|
||||
else if (a === '--no-hermetic-home') out.hermeticHome = false;
|
||||
else if (!out.scenario && !a.startsWith('--')) out.scenario = a;
|
||||
else {
|
||||
log(`unknown argument: ${a}`);
|
||||
process.exit(2);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── compiled gbrain binary (what a real user runs) ───────────────────────────
|
||||
|
||||
/** Compile (or reuse) a standalone gbrain binary. `bun run src/cli.ts` adds a
|
||||
* multi-second transpile stall to EVERY invocation that a real install never
|
||||
* has — a compiled binary keeps the timing honest. Cached under
|
||||
* .context/dx-runs/bin/ keyed on nothing (use --rebuild after code changes). */
|
||||
function ensureGbrainBinary(explicit: string | undefined, rebuild: boolean): string {
|
||||
if (explicit) {
|
||||
fs.accessSync(explicit, fs.constants.X_OK);
|
||||
return path.resolve(explicit);
|
||||
}
|
||||
const binDir = path.join(REPO_ROOT, '.context', 'dx-runs', 'bin');
|
||||
const binPath = path.join(binDir, 'gbrain');
|
||||
if (!rebuild && fs.existsSync(binPath)) {
|
||||
log(`reusing compiled gbrain at ${binPath} (--rebuild to refresh)`);
|
||||
return binPath;
|
||||
}
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
log('compiling gbrain (bun build --compile)…');
|
||||
const res = spawnSync('bun', ['build', '--compile', '--outfile', binPath, 'src/cli.ts'], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
timeout: 300_000,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (res.status !== 0 || !fs.existsSync(binPath)) {
|
||||
throw new Error(`bun build --compile failed (exit ${res.status}):\n${(res.stderr ?? '').slice(-2000)}`);
|
||||
}
|
||||
log(`compiled ${binPath}`);
|
||||
return binPath;
|
||||
}
|
||||
|
||||
// ── scenario plumbing ────────────────────────────────────────────────────────
|
||||
|
||||
interface ScenarioCtx {
|
||||
outDir: string;
|
||||
gbrainBin: string;
|
||||
keep: boolean;
|
||||
/** temp dirs to remove on completion unless --keep */
|
||||
cleanups: string[];
|
||||
/** Files carrying credential material (copied auth.json, seeded key
|
||||
* suffixes). ALWAYS deleted at cleanup — --keep keeps transcripts and
|
||||
* hermetic dirs for forensics, never credentials. */
|
||||
secretPaths: string[];
|
||||
events: Array<{ tMs: number; kind: 'input' | 'note' | 'screen'; data: string }>;
|
||||
t0: number;
|
||||
}
|
||||
|
||||
function newCtx(args: CliArgs, needsGbrain: boolean): ScenarioCtx {
|
||||
const outDir = path.resolve(
|
||||
args.dir ?? path.join(REPO_ROOT, '.context', 'dx-runs', `${args.scenario}-${nowStamp()}`),
|
||||
);
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const ctx: ScenarioCtx = {
|
||||
outDir,
|
||||
gbrainBin: needsGbrain ? ensureGbrainBinary(args.gbrainBin, args.rebuild) : '',
|
||||
keep: args.keep,
|
||||
cleanups: [],
|
||||
secretPaths: [],
|
||||
events: [],
|
||||
t0: Date.now(),
|
||||
};
|
||||
installSignalScrub(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function tmp(ctx: ScenarioCtx, prefix: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
ctx.cleanups.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function event(ctx: ScenarioCtx, kind: 'input' | 'note' | 'screen', data: string): void {
|
||||
ctx.events.push({ tMs: Date.now() - ctx.t0, kind, data });
|
||||
}
|
||||
|
||||
/** Delete every credential copy. Idempotent; safe to call from a signal
|
||||
* handler AND from finishCtx (a second call is a no-op). This is the
|
||||
* "no credential outlives the run" guarantee — it must run even when a
|
||||
* 10-25min install is Ctrl-C'd (finally does NOT run on SIGINT default). */
|
||||
function scrubSecrets(ctx: ScenarioCtx): void {
|
||||
for (const p of ctx.secretPaths) {
|
||||
try {
|
||||
fs.rmSync(p, { force: true });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire SIGINT/SIGTERM so an interrupted run still scrubs credentials before
|
||||
* the process dies. Registered once per scenario ctx. */
|
||||
function installSignalScrub(ctx: ScenarioCtx): void {
|
||||
const handler = (sig: NodeJS.Signals) => {
|
||||
scrubSecrets(ctx);
|
||||
process.stderr.write(`\n[dx-explore] ${sig}: scrubbed credential copies, exiting.\n`);
|
||||
process.exit(130);
|
||||
};
|
||||
process.once('SIGINT', handler);
|
||||
process.once('SIGTERM', handler);
|
||||
}
|
||||
|
||||
function finishCtx(ctx: ScenarioCtx): void {
|
||||
// Scrub credentials FIRST — before any other I/O that could throw (an
|
||||
// events.jsonl write failure must not strand auth files).
|
||||
scrubSecrets(ctx);
|
||||
fs.writeFileSync(
|
||||
path.join(ctx.outDir, 'events.jsonl'),
|
||||
ctx.events.map((e) => JSON.stringify(e)).join('\n') + (ctx.events.length ? '\n' : ''),
|
||||
);
|
||||
if (ctx.keep && ctx.secretPaths.length > 0) {
|
||||
log(`--keep: retained hermetic dirs, but scrubbed ${ctx.secretPaths.length} credential file(s)`);
|
||||
}
|
||||
if (!ctx.keep) {
|
||||
for (const d of ctx.cleanups) {
|
||||
try {
|
||||
fs.rmSync(d, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fs.writeFileSync(
|
||||
path.join(ctx.outDir, 'hermetic-dirs.json'),
|
||||
JSON.stringify(ctx.cleanups, null, 2),
|
||||
);
|
||||
}
|
||||
// The one stdout line: where the transcript landed.
|
||||
console.log(ctx.outDir);
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
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, 'status.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
running: !session.exited(),
|
||||
exitCode: session.exitCode(),
|
||||
elapsedMs: Date.now() - session.startedAtMs,
|
||||
frames: session.frames().length,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}, 500);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
function saveSession(ctx: ScenarioCtx, name: string, session: TtySession, extraMeta: Record<string, unknown> = {}): void {
|
||||
const dir = name ? path.join(ctx.outDir, name) : ctx.outDir;
|
||||
saveTranscript(dir, {
|
||||
frames: session.frames(),
|
||||
raw: session.raw(),
|
||||
meta: {
|
||||
scenario: name || path.basename(ctx.outDir),
|
||||
argv: session.argv,
|
||||
startedAtIso: new Date(session.startedAtMs).toISOString(),
|
||||
exitCode: session.exitCode(),
|
||||
durationMs: Date.now() - session.startedAtMs,
|
||||
...extraMeta,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── scenario: help ───────────────────────────────────────────────────────────
|
||||
|
||||
async function scenarioHelp(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
|
||||
const home = tmp(ctx, 'gb-dx-home-');
|
||||
const ws = tmp(ctx, 'gb-dx-ws-');
|
||||
const dropEnv = args.keyless ? PROVIDER_KEY_NAMES : undefined;
|
||||
const surfaces: Array<{ name: string; argv: string[] }> = [
|
||||
{ name: 'step-01-bare', argv: [ctx.gbrainBin] },
|
||||
{ name: 'step-02-help', argv: [ctx.gbrainBin, '--help'] },
|
||||
{ name: 'step-03-init-help', argv: [ctx.gbrainBin, 'init', '--help'] },
|
||||
{ name: 'step-04-bootstrap-help', argv: [ctx.gbrainBin, 'bootstrap', '--help'] },
|
||||
{ name: 'step-05-bootstrap-bare', argv: [ctx.gbrainBin, 'bootstrap'] },
|
||||
{ name: 'step-06-status-fresh', argv: [ctx.gbrainBin, 'status'] },
|
||||
];
|
||||
for (const s of surfaces) {
|
||||
log(`running ${s.name}: ${s.argv.join(' ')}`);
|
||||
const session = launchTty(s.argv, {
|
||||
cwd: ws,
|
||||
env: { HOME: home, GBRAIN_HOME: home },
|
||||
dropEnv,
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
await session.waitForExit(110_000);
|
||||
await session.close();
|
||||
saveSession(ctx, s.name, session);
|
||||
}
|
||||
}
|
||||
|
||||
// ── scenario: init (naive-user autopilot) ────────────────────────────────────
|
||||
|
||||
async function scenarioInit(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
|
||||
const home = tmp(ctx, 'gb-dx-home-');
|
||||
const ws = tmp(ctx, 'gb-dx-ws-');
|
||||
log(
|
||||
`interactive \`gbrain init\` (${args.keyless ? 'TRUE keyless — provider keys stripped' : 'ambient keys allowed'}), ` +
|
||||
'naive-user autopilot: Enter accepts every default',
|
||||
);
|
||||
const session = launchTty([ctx.gbrainBin, 'init'], {
|
||||
cwd: ws,
|
||||
env: { HOME: home, GBRAIN_HOME: home },
|
||||
dropEnv: args.keyless ? PROVIDER_KEY_NAMES : undefined,
|
||||
timeoutMs: 600_000,
|
||||
});
|
||||
const stopMirror = mirrorSession(ctx.outDir, session);
|
||||
|
||||
const steps: string[] = [];
|
||||
let lastMarkPos = 0;
|
||||
const MAX_STEPS = 15;
|
||||
try {
|
||||
for (let step = 1; step <= MAX_STEPS && !session.exited(); step++) {
|
||||
const settled = await session.waitForQuiet({ quietMs: 2000, timeoutMs: 180_000 });
|
||||
const shot = session.visibleSince(lastMarkPos);
|
||||
lastMarkPos = session.mark();
|
||||
const tSec = ((Date.now() - session.startedAtMs) / 1000).toFixed(1);
|
||||
steps.push(
|
||||
`## Step ${step} (t+${tSec}s${settled ? '' : ', NEVER SETTLED within 180s'})\n\n` +
|
||||
'```\n' + shot.trim().slice(-3000) + '\n```\n',
|
||||
);
|
||||
event(ctx, 'screen', shot.slice(-2000));
|
||||
if (session.exited()) break;
|
||||
log(`step ${step}: screen settled at t+${tSec}s — pressing Enter (default)`);
|
||||
event(ctx, 'input', 'Enter');
|
||||
session.sendKey('Enter');
|
||||
await Bun.sleep(300);
|
||||
}
|
||||
await session.waitForExit(60_000);
|
||||
} finally {
|
||||
stopMirror();
|
||||
await session.close();
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(ctx.outDir, 'steps.md'),
|
||||
`# gbrain init — naive-user autopilot (Enter through every prompt)\n\n${steps.join('\n')}`,
|
||||
);
|
||||
saveSession(ctx, '', session, { autopilot: 'enter-through-defaults', keyless: args.keyless });
|
||||
}
|
||||
|
||||
// ── scenarios: claude-install / codex-install ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle the harness's own first-run chrome dialogs (Claude Code: workspace
|
||||
* trust, bypass-permissions warning) so an unattended run reaches the input
|
||||
* prompt. Each handled dialog is recorded as a note — the dialogs ARE part of
|
||||
* the real first-run friction, just not gbrain's copy. Returns once the
|
||||
* screen has been quiet with no dialog visible, or at the deadline.
|
||||
*/
|
||||
async function settlePastBootDialogs(
|
||||
ctx: ScenarioCtx,
|
||||
session: TtySession,
|
||||
opts: { deadlineMs?: number } = {},
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + (opts.deadlineMs ?? 90_000);
|
||||
const handled = new Set<string>();
|
||||
while (Date.now() < deadline) {
|
||||
await session.waitForQuiet({ quietMs: 2000, timeoutMs: 30_000 });
|
||||
if (session.exited()) return;
|
||||
const tail = session.visible().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)');
|
||||
session.send('1');
|
||||
await Bun.sleep(300);
|
||||
session.sendKey('Enter');
|
||||
continue;
|
||||
}
|
||||
if (!handled.has('bypass') && /Bypass ?Permissions ?mode/i.test(tail.replace(/\s+/g, ''))) {
|
||||
handled.add('bypass');
|
||||
event(ctx, 'note', 'boot dialog: bypass-permissions warning — accepted (option 2)');
|
||||
session.send('2');
|
||||
await Bun.sleep(300);
|
||||
session.sendKey('Enter');
|
||||
continue;
|
||||
}
|
||||
// Codex directory-trust dialog: "Do you trust the contents of this
|
||||
// directory? › 1. Yes, continue 2. No, quit".
|
||||
if (!handled.has('codex-trust') && /trust ?the ?contents ?of ?this ?directory/i.test(tail.replace(/\s+/g, ''))) {
|
||||
handled.add('codex-trust');
|
||||
event(ctx, 'note', 'boot dialog: codex directory trust — accepted (option 1)');
|
||||
session.send('1');
|
||||
await Bun.sleep(300);
|
||||
session.sendKey('Enter');
|
||||
continue;
|
||||
}
|
||||
return; // quiet + no dialog = at the input prompt
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
function installPrompt(): string {
|
||||
const runbook = path.join(REPO_ROOT, 'BOOTSTRAP_FOR_AGENTS.md');
|
||||
return (
|
||||
`Read and follow every step of: ${runbook}\n` +
|
||||
`Goal: set yourself up as my persistent personal agent in this folder, with gbrain ` +
|
||||
`as your memory. Interview me before writing any identity file — never invent ` +
|
||||
`answers. Ask before anything destructive. You are not done until ` +
|
||||
`\`gbrain bootstrap verify\` exits 0.\n\n` +
|
||||
`[Unattended-run appendix — I am stepping away; use these interview answers instead ` +
|
||||
`of asking me, and do not wait for my input: ` +
|
||||
`agent name: ${PERSONA.AGENT_NAME}; my name: ${PERSONA.PRINCIPAL_NAME}; ` +
|
||||
`purpose: ${PERSONA.AGENT_PURPOSE}; top jobs: ${PERSONA.AGENT_TOP_JOBS}; ` +
|
||||
`about me: ${PERSONA.PRINCIPAL_CONTEXT}; voice: ${PERSONA.VOICE_REGISTER}. ` +
|
||||
`gbrain is already installed and on PATH. If a step needs GitHub auth or an API key ` +
|
||||
`that is unavailable, take the documented keyless/local fallback and continue.]`
|
||||
);
|
||||
}
|
||||
|
||||
async function scenarioClaudeInstall(ctx: ScenarioCtx): 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);
|
||||
|
||||
seedClaudeTuiConfig(cfg, {
|
||||
apiKey: process.env.ANTHROPIC_API_KEY ?? process.env.GSTACK_ANTHROPIC_API_KEY,
|
||||
// realpath: macOS tmpdirs live under /var → /private/var; claude compares
|
||||
// against the resolved path, so an unresolved seed misses.
|
||||
trustedDirs: [ws, fs.realpathSync(ws)],
|
||||
});
|
||||
// The seed records the key's last 20 chars — credential-adjacent, so it is
|
||||
// scrubbed at cleanup even with --keep.
|
||||
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,
|
||||
},
|
||||
);
|
||||
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> {
|
||||
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);
|
||||
|
||||
// Hermetic ~/.codex with ONLY the operator's auth (same posture as the
|
||||
// codex door test). codex refuses untrusted cwds — a git repo satisfies it.
|
||||
const codexHome = path.join(home, '.codex');
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
const realAuth = path.join(os.homedir(), '.codex', 'auth.json');
|
||||
if (fs.existsSync(realAuth)) {
|
||||
const authCopy = path.join(codexHome, 'auth.json');
|
||||
fs.copyFileSync(realAuth, authCopy);
|
||||
fs.chmodSync(authCopy, 0o600); // copyFileSync doesn't preserve source mode
|
||||
ctx.secretPaths.push(authCopy); // scrubbed at cleanup, even with --keep
|
||||
}
|
||||
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 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,
|
||||
},
|
||||
);
|
||||
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)',
|
||||
});
|
||||
}
|
||||
|
||||
// ── scenario: drive (manual control channel) ─────────────────────────────────
|
||||
|
||||
async function scenarioDrive(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
|
||||
if (args.driveArgv.length === 0) {
|
||||
log('drive mode needs a command: dx-explore.ts drive -- gbrain init');
|
||||
process.exit(2);
|
||||
}
|
||||
// `gbrain` as argv[0] resolves to the compiled binary.
|
||||
const argv = [...args.driveArgv];
|
||||
if (argv[0] === 'gbrain') argv[0] = ctx.gbrainBin;
|
||||
|
||||
const sessDir = path.join(ctx.outDir, 'session');
|
||||
fs.mkdirSync(sessDir, { recursive: true });
|
||||
const inputPath = path.join(sessDir, 'input.jsonl');
|
||||
fs.writeFileSync(inputPath, '');
|
||||
|
||||
const env: Record<string, string | undefined> = {};
|
||||
if (args.hermeticHome) {
|
||||
const home = tmp(ctx, 'gb-dx-home-');
|
||||
env.HOME = home;
|
||||
env.GBRAIN_HOME = home;
|
||||
}
|
||||
|
||||
log(`driving: ${argv.join(' ')}`);
|
||||
log(`watch: cat ${path.join(sessDir, 'screen.txt')}`);
|
||||
log(`input: echo '{"line":"some text"}' >> ${inputPath} (sends text + Enter)`);
|
||||
log(` echo '{"key":"Down"}' >> ${inputPath}`);
|
||||
log(`stop: echo '{"stop":true}' >> ${inputPath}`);
|
||||
|
||||
const session = launchTty(argv, {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
timeoutMs: 3_600_000,
|
||||
});
|
||||
const stopMirror = mirrorSession(ctx.outDir, session);
|
||||
|
||||
let offset = 0;
|
||||
let stopping = false;
|
||||
try {
|
||||
while (!session.exited() && !stopping) {
|
||||
await Bun.sleep(200);
|
||||
let content = '';
|
||||
try {
|
||||
content = fs.readFileSync(inputPath, 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (content.length <= offset) continue;
|
||||
const fresh = content.slice(offset);
|
||||
offset = content.length;
|
||||
for (const line of fresh.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
const cmd = parseDriveCommand(line);
|
||||
if (!cmd) {
|
||||
log(`skipping malformed drive command: ${line.slice(0, 120)}`);
|
||||
continue;
|
||||
}
|
||||
if (cmd.kind === 'send') {
|
||||
event(ctx, 'input', cmd.data);
|
||||
session.send(cmd.data);
|
||||
} else if (cmd.kind === 'key') {
|
||||
event(ctx, 'input', `<${cmd.key}>`);
|
||||
session.sendKey(cmd.key);
|
||||
} else if (cmd.kind === 'note') {
|
||||
event(ctx, 'note', cmd.text);
|
||||
} else if (cmd.kind === 'stop') {
|
||||
stopping = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
stopMirror();
|
||||
await session.close();
|
||||
}
|
||||
saveSession(ctx, '', session, { mode: 'drive', command: argv.join(' ') });
|
||||
}
|
||||
|
||||
// ── main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SCENARIOS: Record<string, { needsGbrain: boolean; run: (ctx: ScenarioCtx, args: CliArgs) => Promise<void> }> = {
|
||||
help: { needsGbrain: true, run: scenarioHelp },
|
||||
init: { needsGbrain: true, run: scenarioInit },
|
||||
'claude-install': { needsGbrain: true, run: scenarioClaudeInstall },
|
||||
'codex-install': { needsGbrain: true, run: scenarioCodexInstall },
|
||||
drive: { needsGbrain: true, run: scenarioDrive },
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const scenario = SCENARIOS[args.scenario];
|
||||
if (!scenario) {
|
||||
log(`usage: bun run scripts/dx-explore.ts <${Object.keys(SCENARIOS).join('|')}> [options] [-- cmd...]`);
|
||||
process.exit(2);
|
||||
}
|
||||
const ctx = newCtx(args, scenario.needsGbrain);
|
||||
log(`transcripts → ${ctx.outDir}`);
|
||||
try {
|
||||
await scenario.run(ctx, args);
|
||||
} finally {
|
||||
finishCtx(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -34,6 +34,12 @@ export const E2E_TEST_MAP: Record<string, string[]> = {
|
||||
"test/e2e/openclaw-context-engine-plugin.test.ts",
|
||||
"test/e2e/openclaw-plugin-load-real.test.ts",
|
||||
],
|
||||
// claw-test harness (command + core: runners, scenarios, seeding, friction
|
||||
// merge) feeds the scripted + shim-live E2E. The hermes door
|
||||
// (install-real-hermes.serial.test.ts) is deliberately NOT mapped — it is
|
||||
// opt-in-gated (GBRAIN_REAL_HERMES_E2E) and self-skips in run-all anyway.
|
||||
"src/commands/claw-test.ts": ["test/e2e/claw-test.test.ts"],
|
||||
"src/core/claw-test/**": ["test/e2e/claw-test.test.ts"],
|
||||
// dream.ts is a thin alias over runCycle in cycle.ts.
|
||||
"src/core/cycle.ts": ["test/e2e/cycle.test.ts", "test/e2e/dream.test.ts"],
|
||||
// Multi-source sync writes share the per-source bookmark anchor.
|
||||
|
||||
+13
-10
@@ -68,16 +68,19 @@ mkdir -p "$E2E_TMP_HOME/.gbrain"
|
||||
|
||||
# --- Hermetic env scrub: operator/agent context must not bleed into E2E ---
|
||||
# A dev shell or a Conductor workspace exports CONDUCTOR_*, MCP_*, OPENCLAW_*,
|
||||
# and GBRAIN_* config overrides (e.g. a stray GBRAIN_BRAIN_ID, GBRAIN_SOURCE,
|
||||
# GBRAIN_*_THRESHOLD, GBRAIN_SUPERVISOR_PID_FILE) that would silently change
|
||||
# test behavior — making "hermetic" E2E non-hermetic and its failures
|
||||
# unreproducible across machines. Drop them before bun starts. This is a
|
||||
# DENYLIST of operator-context prefixes (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. Adapts GStack's
|
||||
# buildHermeticEnv() allowlist to gbrain's shell E2E runner.
|
||||
for _e2e_var in $(env | grep -oE '^(CONDUCTOR_|MCP_|OPENCLAW_|GBRAIN_)[A-Za-z0-9_]*' | sort -u); do
|
||||
# HERMES_*, and GBRAIN_* config overrides (e.g. a stray GBRAIN_BRAIN_ID,
|
||||
# GBRAIN_SOURCE, GBRAIN_*_THRESHOLD, GBRAIN_SUPERVISOR_PID_FILE, an operator's
|
||||
# HERMES_BIN/HERMES_HOME) that would silently change test behavior — making
|
||||
# "hermetic" E2E non-hermetic and its failures unreproducible across machines.
|
||||
# Drop them before bun starts. This is a DENYLIST of operator-context prefixes
|
||||
# (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
|
||||
case "$_e2e_var" in
|
||||
GBRAIN_HOME) ;; # required for HOME isolation (set above) — keep
|
||||
*) unset "$_e2e_var" || true ;;
|
||||
|
||||
@@ -55,6 +55,7 @@ gbrain friction list # recent runs with counts
|
||||
gbrain friction render --run-id <id> # markdown report (default)
|
||||
gbrain friction render --run-id <id> --json
|
||||
gbrain friction summary --run-id <id> # friction + delight side-by-side
|
||||
gbrain friction diff --base <run-or-agent> --compare <run-or-agent> # cross-run/cross-agent comparison
|
||||
```
|
||||
|
||||
`render` defaults to `--redact` for markdown (strips `$HOME`/`$CWD` to `<HOME>`/`<CWD>` placeholders) so reports paste safely into PRs and issues.
|
||||
|
||||
@@ -159,7 +159,7 @@ mismatch, typo'd `--type`) before reporting anything.
|
||||
|
||||
```bash
|
||||
gbrain link-sources # citation-graph should appear with the expected count
|
||||
gbrain check-backlinks # confirm no orphaned references
|
||||
gbrain check-backlinks check # confirm no orphaned references
|
||||
```
|
||||
|
||||
## Run it (worked example, synthetic fixture)
|
||||
|
||||
@@ -139,13 +139,17 @@ cd "$BRAIN"
|
||||
gbrain recall --grep "salary"
|
||||
```
|
||||
|
||||
Collect every returned slug into the scope list.
|
||||
Resolve every returned slug to its repo-relative file path and write the
|
||||
paths into `/tmp/brainify-scope.txt` (one per line). This file is the
|
||||
scope list; the structural pass below APPENDS to it — nothing later in
|
||||
the procedure may truncate it, or the retrieval-discovered pages
|
||||
silently drop out of scope.
|
||||
|
||||
2. Structural discovery — people files that belong to the company, plus
|
||||
keyword hits across the wider scan scope:
|
||||
|
||||
```bash
|
||||
grep -rli 'company: *"acme-example"' people/ --include="*.md" | sort > /tmp/brainify-scope.txt
|
||||
grep -rli 'company: *"acme-example"' people/ --include="*.md" | sort >> /tmp/brainify-scope.txt
|
||||
grep -rli -E 'salary|equity|carry|retention|underperform|performance review|hard conversation' \
|
||||
meetings/ daily/ companies/ projects/ analysis/ --include="*.md" 2>/dev/null >> /tmp/brainify-scope.txt
|
||||
sort -u -o /tmp/brainify-scope.txt /tmp/brainify-scope.txt
|
||||
@@ -254,16 +258,22 @@ For sanitization, sensitive fact rows must be ACTUALLY REMOVED: find them
|
||||
(`gbrain recall --grep`), then delete the row from the page's Facts fence
|
||||
(step 5), exactly like a sensitive take. On an in-place shared brain, the
|
||||
page edit must then be re-synced (`gbrain sync` re-imports the edited page)
|
||||
so the shared database no longer serves the row — an edited page over an
|
||||
un-synced DB still leaks through retrieval. `forget` alone can never certify
|
||||
a brain clean.
|
||||
AND the facts index reconciled — sync's convergence contract covers page
|
||||
import only; downstream fact extraction is explicitly decoupled
|
||||
(`src/commands/sync.ts`, "CONVERGENCE CONTRACT"), so the DB keeps serving
|
||||
the deleted row until the extract-facts reconcile runs. Trigger it
|
||||
(`gbrain sweep`, or wait for the serve-resident sweep), then confirm with
|
||||
`gbrain recall --grep` that the row is actually gone. An edited page over
|
||||
an un-reconciled facts index still leaks through retrieval. `forget` alone
|
||||
can never certify a brain clean.
|
||||
|
||||
After edits: on the **staging-copy** path the fact rows are removed by editing
|
||||
the copied markdown directly (there is no live DB to re-sync yet — the team DB
|
||||
is built fresh when Phase 5 Step 0 turns the export into a source). On the
|
||||
**in-place shared-brain** path, `gbrain sync` re-imports the changed pages so
|
||||
the DB matches the markdown. Either way, run `gbrain check-backlinks check` to
|
||||
catch pages still pointing at removed content.
|
||||
**in-place shared-brain** path, run `gbrain sync` so the page content matches
|
||||
the markdown, then reconcile and verify the facts index as above. Either way,
|
||||
run `gbrain check-backlinks check` to catch pages still pointing at removed
|
||||
content.
|
||||
|
||||
### Phase 4: Verify
|
||||
|
||||
@@ -502,7 +512,10 @@ recovery line.
|
||||
mirror-clone backup in `~/.gbrain/backups/` for a retention window
|
||||
(~30 days is a sane default), then delete it — it contains the
|
||||
pre-sanitization history and should not accumulate indefinitely:
|
||||
`rm -rf ~/.gbrain/backups/brain-history-backup-<date>.git`
|
||||
`rm -rf ~/.gbrain/backups/shared-brain-history-backup-<date>.git`
|
||||
(the glob must match the `shared-brain-history-backup-*` name the backup
|
||||
step created — a mismatched pattern deletes nothing and silently retains
|
||||
the pre-sanitization history forever)
|
||||
- If the repo carries push hooks or auto-hardening wiring, re-verify remotes
|
||||
and hooks survived the rewrite before handing the repo to the team
|
||||
|
||||
@@ -592,9 +605,11 @@ This skill guarantees:
|
||||
covered by the sanitization scan; everything else is excluded by default,
|
||||
and the Phase 4 verification greps run against the exported tree before
|
||||
the first push.
|
||||
- Sensitive fact rows are deleted from the page's Facts fence and re-synced,
|
||||
never merely expired — `gbrain forget` retains the row (struck through,
|
||||
served via `--include-expired`) and can never certify clean.
|
||||
- Sensitive fact rows are deleted from the page's Facts fence, re-synced,
|
||||
and the facts index reconciled (extract-facts sweep) with the removal
|
||||
verified via `gbrain recall --grep`, never merely expired — `gbrain
|
||||
forget` retains the row (struck through, served via `--include-expired`)
|
||||
and can never certify clean.
|
||||
- The history-purge filter list and its restore manifest both derive from
|
||||
the COMPLETE set of sanitized paths, never a subset.
|
||||
- Every strip decision is a per-file model judgment grounded in a full read;
|
||||
@@ -623,7 +638,7 @@ Three artifacts:
|
||||
|
||||
- Scope: [N files scanned across people/, meetings/, daily/, ...]
|
||||
- Flagged: [M files with hits] (triage list attached)
|
||||
- Edited: [K files sanitized; T takes removed; F fact rows removed + re-synced]
|
||||
- Edited: [K files sanitized; T takes removed; F fact rows removed + re-synced + facts index reconciled]
|
||||
- Verification: [grep residuals: 0 confirmed-sensitive; retrieval checks: clean]
|
||||
- History: [not purged | fresh-export | purged after confirmed gate — backup at <path>]
|
||||
- Next re-audit: [date / cron slot]
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"schema-author": "schema-pack authoring is a brain-owner activity, not a client activity",
|
||||
"smoke-test": "host-runtime health checks (container/daemon assumptions)",
|
||||
"gbrain-upgrade": "host binary upgrade flow",
|
||||
"cold-start": "host onboarding flow",
|
||||
"schema-unify": "schema unification is a brain-owner migration activity",
|
||||
"skill-optimizer": "requires host-side skillopt engine access and benchmark files"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"_AGENT_README.md": "62613f7f1e061576b6c1b18844f59bd35f2df96ca5c45c8c41fae0772b9ce4d3",
|
||||
"_brain-filing-rules.json": "cf850df6a7425464c6d63b3ace71991cc93497fa0cc8cd21acd31883e17939c6",
|
||||
"_brain-filing-rules.md": "2d2d75b7c76081c56f41b2c0a5a978c355ce957300f9b0a5575dc4079ef1f877",
|
||||
"_friction-protocol.md": "1b6e7cfa58725a6a5dc2dc787242141bc33f5fde524540d85b14ec22266140f7",
|
||||
"_friction-protocol.md": "51353207240142024ff1facc25f225712275ecdb4a034ffffdd83740c8d328e3",
|
||||
"_output-rules.md": "0722ec2ecea7f9fa2f065cf12dfe1347956a9709d29898bf9fe95e875c64b800",
|
||||
"academic-verify/SKILL.md": "1c19e27e75249d869da428ce8d060075feef8fbbfe146af58b305d11a260ebbc",
|
||||
"academic-verify/routing-eval.jsonl": "90d894a9829d9936e6ac7a6507e4de67ad26e46a1fe13b7a34e7dec1c0d887dd",
|
||||
@@ -33,10 +33,10 @@
|
||||
"capture/SKILL.md": "98568ac96331f57397ea072749641d9748b1ce31e8b09d512b8db25c8fcda65f",
|
||||
"citation-fixer/SKILL.md": "abdadbf0740a529b9c4f86f05bba416417624503fdcbc6054402d5546afd08b4",
|
||||
"citation-fixer/routing-eval.jsonl": "52b23b71e66fdc18aee67d0576099b0c83997d648cf4ecf8fe7753b91b6c9c53",
|
||||
"citation-graph-ingest/SKILL.md": "6510856cc14a653dcade510702890343bc0527de14bc2f1ed0d2524f248c798c",
|
||||
"citation-graph-ingest/SKILL.md": "849b0cdc64b7ff14d0e6771bde15f0edc3c2fc29af08be015753a5f88a03205f",
|
||||
"citation-graph-ingest/routing-eval.jsonl": "a1ba605d35e736b741b9e8aac1e7d50b61a7cbcada893d67099b55bf5a0d2635",
|
||||
"cold-start/SKILL.md": "20be3d1b637621fd9fbd268072f6647533a23f596e30cb593523b051708aaddd",
|
||||
"company-brainify/SKILL.md": "2c058b39f5364b8ceb5c53b4525cce8645734f16cc3c229a490b005d58a78311",
|
||||
"company-brainify/SKILL.md": "ae48372512645f532820e43faaf18a8fa768a691b2144973dfc89465f84d84c6",
|
||||
"company-brainify/routing-eval.jsonl": "6f27f835eda9ae77a2b694534c78a043a871349820e8c638c3d8bbba6d3aa17b",
|
||||
"concept-synthesis/SKILL.md": "ed02d2e385143b16a1e69ee5934288fb4d0b755f68c4312faff663e6b2d7c4ed",
|
||||
"concept-synthesis/routing-eval.jsonl": "96dbd7d9c1b606e9e06262d0c06282399741e2bccb8eeb7b9ca88c20f44cda0e",
|
||||
@@ -137,7 +137,7 @@
|
||||
"minion-orchestrator/routing-eval.jsonl": "501ed2e19cb16847ff8425219d246b7a774de1accd42cb28fd44edbb64204992",
|
||||
"perplexity-research/SKILL.md": "c25f5c471cbe3c6e0f975d8397e8382b00a85f8aa75302231d53c52855369e97",
|
||||
"perplexity-research/routing-eval.jsonl": "f1a40d87e710d5d2acd602a372d83f46c95da022b6e635228fffeaacb3bb2b27",
|
||||
"plugin-exclusions.json": "585486aaaf9a87ec4b13bea5d03f5e9af34a9ac64283c878c234ba094126f793",
|
||||
"plugin-exclusions.json": "e8070da59bb4444304eb354c7421f0998383454d96d5e66e38484aae792c4775",
|
||||
"publish/SKILL.md": "e06b609db780a3cc93a1755a87b30ff08ffdc0fdbc834c1422b2ad2489b57497",
|
||||
"query/SKILL.md": "b12aae4e86b893038b1d9e97a977bd6a7939db9f5c57dde12c11d8d7451e0762",
|
||||
"query/routing-eval.jsonl": "74f5a91e52fabc54e0e9403fa17db87ee26bb7ebb8ae8005148c51142abc62fe",
|
||||
|
||||
+38
-10
@@ -14,6 +14,7 @@ import { spawn } from 'child_process';
|
||||
import {
|
||||
readUpdateCache,
|
||||
isCacheFresh,
|
||||
pendingUpgradeVersion,
|
||||
readSnooze,
|
||||
isSnoozeActive,
|
||||
resolveSelfUpgradeMode,
|
||||
@@ -259,12 +260,26 @@ function maybeEmitUpdateMarker(command: string): void {
|
||||
const now = Date.now();
|
||||
const entry = readUpdateCache();
|
||||
if (entry && isCacheFresh(entry, now)) {
|
||||
if (entry.marker.kind === 'upgrade_available' && entry.marker.latest) {
|
||||
// Shared stale/foreign-cache guard (pendingUpgradeVersion): only nag when
|
||||
// the cached latest is strictly newer than the RUNNING binary, and print
|
||||
// the running version — the cache records whatever binary WROTE it.
|
||||
const latest = pendingUpgradeVersion(VERSION, now);
|
||||
if (latest) {
|
||||
// notify mode honors a per-version snooze; auto mode ignores it.
|
||||
if (mode === 'notify' && isSnoozeActive(readSnooze(), entry.marker.latest, now)) return;
|
||||
process.stderr.write(`UPGRADE_AVAILABLE ${entry.marker.current} ${entry.marker.latest}\n`);
|
||||
if (mode === 'notify' && isSnoozeActive(readSnooze(), latest, now)) return;
|
||||
// The raw `UPGRADE_AVAILABLE <cur> <latest>` line is a MACHINE marker
|
||||
// (parsed by the self-upgrade skill / MCP via parseMarker). A human at
|
||||
// an interactive terminal should never see the token as the literal
|
||||
// first line of output — so emit it only when stderr is NOT a TTY
|
||||
// (agent harnesses capture stderr non-interactively and still get it).
|
||||
// GBRAIN_FORCE_UPGRADE_MARKER=1 forces it for the rarer agent harness
|
||||
// that allocates a PTY yet still parses the token. The human sentence
|
||||
// prints on both.
|
||||
if (!process.stderr.isTTY || process.env.GBRAIN_FORCE_UPGRADE_MARKER === '1') {
|
||||
process.stderr.write(`UPGRADE_AVAILABLE ${VERSION} ${latest}\n`);
|
||||
}
|
||||
process.stderr.write(
|
||||
`gbrain ${entry.marker.current} -> ${entry.marker.latest} available. Run: gbrain self-upgrade\n`,
|
||||
`gbrain ${VERSION} -> ${latest} available. Run: gbrain self-upgrade\n`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -273,19 +288,32 @@ function maybeEmitUpdateMarker(command: string): void {
|
||||
// Stale/missing cache → kick a detached, single-flighted refresh. The child
|
||||
// (`check-update --refresh-cache`) single-flights via the refresh lock and
|
||||
// writes the cache for the NEXT invocation. We never wait on it.
|
||||
// Spawn OURSELVES (hook.ts spawnDetachedPush pattern), not `gbrain` from
|
||||
// PATH — a different (older) binary on PATH would write ITS version into
|
||||
// the cache and make the marker lie about what is installed here.
|
||||
try {
|
||||
const child = spawn('gbrain', ['check-update', '--refresh-cache'], {
|
||||
const exec = process.execPath ?? '';
|
||||
const refreshArgs = ['check-update', '--refresh-cache'];
|
||||
// Detect compiled-vs-dev by the RUNTIME's basename, not our own — a
|
||||
// published binary keeps its official name (`gbrain-darwin-arm64`, a
|
||||
// `gb` shim), so matching `/gbrain$/` on execPath would misfire and
|
||||
// prepend the `/$bunfs/root/...` virtual entrypoint (process.argv[1] in
|
||||
// a compiled Bun binary), producing an unknown-command child that never
|
||||
// refreshes. Dev mode runs under `bun`/`node`; anything else IS the
|
||||
// compiled binary and re-execs itself directly.
|
||||
const isDevRuntime = /[/\\](bun|node)(\.exe)?$/.test(exec);
|
||||
const argv = isDevRuntime ? [process.argv[1], ...refreshArgs] : refreshArgs;
|
||||
const child = spawn(exec, argv, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env, GBRAIN_SKIP_STARTUP_HOOKS: '1' },
|
||||
});
|
||||
// ChildProcess is an EventEmitter — an unhandled 'error' (e.g. ENOENT when
|
||||
// gbrain isn't on PATH) would throw uncaught. Swallow it; the refresh is
|
||||
// best-effort.
|
||||
// ChildProcess is an EventEmitter — an unhandled 'error' would throw
|
||||
// uncaught. Swallow it; the refresh is best-effort.
|
||||
child.on('error', () => {});
|
||||
child.unref();
|
||||
} catch {
|
||||
/* gbrain not on PATH / spawn failed — fail-open, no refresh this run */
|
||||
/* spawn failed — fail-open, no refresh this run */
|
||||
}
|
||||
} catch {
|
||||
/* the update marker must never break a command */
|
||||
@@ -3028,7 +3056,7 @@ SETUP
|
||||
migrate embeddings --to <p:model> Re-embed onto another embedding provider
|
||||
upgrade Self-update
|
||||
check-update [--json] Check for new versions
|
||||
doctor [--json] [--fast] Health check (resolver, skills, pgvector, RLS, embeddings)
|
||||
doctor [--json] [--fast] [--probe-pglite] Health check (resolver, skills, pgvector, RLS, embeddings; --probe-pglite runs the scratch-store probe)
|
||||
integrations [subcommand] Manage integration recipes (senses + reflexes)
|
||||
|
||||
PAGES
|
||||
|
||||
+82
-3
@@ -18,6 +18,8 @@ import type { BrainEngine } from '../core/engine.ts';
|
||||
import { MinionQueue } from '../core/minions/queue.ts';
|
||||
import { waitForCompletion, TimeoutError } from '../core/minions/wait-for-completion.ts';
|
||||
import type { MinionJobInput, SubagentHandlerData, AggregatorHandlerData } from '../core/minions/types.ts';
|
||||
import { resolveSourceId, ALL_SOURCES } from '../core/source-resolver.ts';
|
||||
import { fetchSource } from '../core/sources-load.ts';
|
||||
import { runAgentLogs } from './agent-logs.ts';
|
||||
|
||||
// ── arg parsing helpers ────────────────────────────────────
|
||||
@@ -72,6 +74,10 @@ SUBMITTING
|
||||
--max-turns <n> Max assistant turns (default 20)
|
||||
--tools a,b,c Subset of registered tool names (comma list)
|
||||
--timeout-ms <n> Per-job wall-clock timeout
|
||||
--source <id> Brain source the subagent's writes are scoped to.
|
||||
Default: the standard resolution chain (GBRAIN_SOURCE,
|
||||
.gbrain-source, sources.default, ...) — see
|
||||
\`gbrain sources current\`
|
||||
--fanout-manifest <path> JSON array of {prompt, input_vars?} — one child each
|
||||
--follow Tail status until terminal (default on TTY)
|
||||
--detach Submit + print job id, exit immediately
|
||||
@@ -116,6 +122,7 @@ interface RunFlags {
|
||||
maxTurns?: number;
|
||||
tools?: string[];
|
||||
timeoutMs?: number;
|
||||
source?: string;
|
||||
fanoutManifest?: string;
|
||||
follow: boolean;
|
||||
detach: boolean;
|
||||
@@ -181,6 +188,7 @@ function parseRunFlags(args: string[]): { flags: RunFlags; rest: string[] } {
|
||||
case '--max-turns': flags.maxTurns = parseIntFlagValue(requireFlagValue(args, ++i, a), a); break;
|
||||
case '--tools': flags.tools = requireFlagValue(args, ++i, a).split(',').map(s => s.trim()).filter(Boolean); break;
|
||||
case '--timeout-ms': flags.timeoutMs = parseIntFlagValue(requireFlagValue(args, ++i, a), a); break;
|
||||
case '--source': flags.source = requireFlagValue(args, ++i, a); break;
|
||||
case '--fanout-manifest': flags.fanoutManifest = requireFlagValue(args, ++i, a); break;
|
||||
case '--follow': flags.follow = true; break;
|
||||
case '--no-follow': flags.follow = false; break;
|
||||
@@ -203,17 +211,86 @@ function parseRunFlags(args: string[]): { flags: RunFlags; rest: string[] } {
|
||||
return { flags, rest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate: is this error one of the source resolver's user-facing throws
|
||||
* we want to surface as a clean stderr line + exit 1? Mirrors
|
||||
* dream.ts:isResolverUserError — anything else (connection failures,
|
||||
* genuine bugs) propagates with a stack trace.
|
||||
*/
|
||||
function isResolverUserError(e: unknown): boolean {
|
||||
if (!(e instanceof Error)) return false;
|
||||
const m = e.message;
|
||||
return (m.startsWith('Source "') && m.includes(' not found.'))
|
||||
|| m.startsWith('Invalid --source value')
|
||||
|| m.startsWith('Invalid GBRAIN_SOURCE value');
|
||||
}
|
||||
|
||||
/**
|
||||
* #2922: resolve the brain source for a subagent submission via the
|
||||
* canonical chain (explicit --source → GBRAIN_SOURCE → .gbrain-source →
|
||||
* local_path match → sources.default → sole non-default → 'default').
|
||||
* Pre-fix, `gbrain agent run` never resolved a source, so every page an
|
||||
* agent job wrote landed in the seed 'default' source even on brains with
|
||||
* `gbrain sources default <id>` configured.
|
||||
*
|
||||
* The `__all__` sentinel is rejected here: subagent writes must target
|
||||
* exactly one source (and `validateSourceId` at tool-registry build time
|
||||
* would reject it anyway — better to fail at submit than at claim).
|
||||
*/
|
||||
async function resolveAgentSource(engine: BrainEngine, explicit: string | undefined): Promise<string> {
|
||||
// An empty `--source ""` must fail loudly, not silently degrade to the
|
||||
// env/dotfile/default tiers (resolveSourceId's `if (explicit)` treats a
|
||||
// falsy value as omitted — explicit-but-empty would slip through).
|
||||
if (explicit !== undefined && explicit.trim() === '') {
|
||||
console.error('gbrain agent run: --source requires a non-empty value. Run `gbrain agent run --help`.');
|
||||
process.exit(2);
|
||||
}
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = await resolveSourceId(engine, explicit ?? null);
|
||||
} catch (e) {
|
||||
if (isResolverUserError(e)) {
|
||||
console.error(`gbrain agent run: ${(e as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (resolved === ALL_SOURCES) {
|
||||
console.error(
|
||||
`gbrain agent run: --source ${ALL_SOURCES} is not supported — ` +
|
||||
`subagent writes must target exactly one source. Pass a concrete --source <id>.`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
// Archived-source guard, mirroring dream.ts: writing subagent pages into
|
||||
// an archived (normally invisible) source would mask them until restore.
|
||||
const src = await fetchSource(engine, resolved);
|
||||
if (src?.archived === true) {
|
||||
console.error(
|
||||
`gbrain agent run: source ${resolved} is archived; restore with ` +
|
||||
`\`gbrain sources restore ${resolved}\` before submitting agent jobs`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function runAgentRun(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const { flags, rest } = parseRunFlags(args);
|
||||
const queue = new MinionQueue(engine);
|
||||
|
||||
// #2922: resolve once at submit time; both the single-job and fan-out
|
||||
// paths stamp it on SubagentHandlerData.source_id so buildOpContext
|
||||
// scopes every tool call to it instead of the legacy 'default'.
|
||||
const sourceId = await resolveAgentSource(engine, flags.source);
|
||||
|
||||
// Fan-out path: --fanout-manifest supplies explicit child inputs. The
|
||||
// aggregator submits first (so its id is available as parent for each
|
||||
// child); children submit with on_child_fail='continue' so mixed
|
||||
// outcomes don't cascade; aggregator waits in waiting-children until
|
||||
// Lane 1B's terminal-set check unblocks it.
|
||||
if (flags.fanoutManifest) {
|
||||
await runFanout(engine, queue, flags, rest.join(' '));
|
||||
await runFanout(engine, queue, flags, rest.join(' '), sourceId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,7 +300,7 @@ export async function runAgentRun(engine: BrainEngine, args: string[]): Promise<
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const data: SubagentHandlerData = { prompt };
|
||||
const data: SubagentHandlerData = { prompt, source_id: sourceId };
|
||||
if (flags.subagentDef) data.subagent_def = flags.subagentDef;
|
||||
if (flags.model) data.model = flags.model;
|
||||
if (flags.maxTurns) data.max_turns = flags.maxTurns;
|
||||
@@ -248,7 +325,7 @@ export async function runAgentRun(engine: BrainEngine, args: string[]): Promise<
|
||||
|
||||
// ── fan-out ───────────────────────────────────────────────
|
||||
|
||||
async function runFanout(engine: BrainEngine, queue: MinionQueue, flags: RunFlags, promptTemplate: string): Promise<void> {
|
||||
async function runFanout(engine: BrainEngine, queue: MinionQueue, flags: RunFlags, promptTemplate: string, sourceId: string): Promise<void> {
|
||||
const manifestPath = flags.fanoutManifest!;
|
||||
let manifest: Array<{ prompt?: string; input_vars?: Record<string, unknown> }>;
|
||||
try {
|
||||
@@ -272,6 +349,7 @@ async function runFanout(engine: BrainEngine, queue: MinionQueue, flags: RunFlag
|
||||
const entry = manifest[0]!;
|
||||
const data: SubagentHandlerData = {
|
||||
prompt: entry.prompt ?? promptTemplate,
|
||||
source_id: sourceId,
|
||||
...(entry.input_vars ? { input_vars: entry.input_vars } : {}),
|
||||
...(flags.subagentDef ? { subagent_def: flags.subagentDef } : {}),
|
||||
...(flags.model ? { model: flags.model } : {}),
|
||||
@@ -303,6 +381,7 @@ async function runFanout(engine: BrainEngine, queue: MinionQueue, flags: RunFlag
|
||||
for (const entry of manifest) {
|
||||
const data: SubagentHandlerData = {
|
||||
prompt: entry.prompt ?? promptTemplate,
|
||||
source_id: sourceId,
|
||||
...(entry.input_vars ? { input_vars: entry.input_vars } : {}),
|
||||
...(flags.subagentDef ? { subagent_def: flags.subagentDef } : {}),
|
||||
...(flags.model ? { model: flags.model } : {}),
|
||||
|
||||
@@ -34,8 +34,7 @@ import type { BrainEngine, SourceRow } from '../core/engine.ts';
|
||||
import type { MinionQueue } from '../core/minions/queue.ts';
|
||||
import { NON_GLOBAL_PHASES, GLOBAL_PHASES, LAST_GLOBAL_AT_KEY } from '../core/cycle.ts';
|
||||
import { sourceConfigHasRemoteUrl } from '../core/sources-load.ts';
|
||||
|
||||
const FULL_CYCLE_FLOOR_MIN = 60;
|
||||
import { AUTOPILOT_FULL_CYCLE_FLOOR_MINUTES } from './autopilot-remediation-policy.ts';
|
||||
|
||||
// #2194 fix #2: failure cooldown. A source whose autopilot-cycle keeps
|
||||
// failing/timing-out re-dispatches every tick today (only SUCCESS gates
|
||||
@@ -81,6 +80,8 @@ export interface FanoutResult {
|
||||
/** True when this tick fell back to the legacy single-job path
|
||||
* (no sources rows / engine empty). */
|
||||
legacy_fallback: boolean;
|
||||
/** True when every enumerated source is inside the freshness window. */
|
||||
all_sources_fresh: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,7 +181,11 @@ export function readLastFullCycleAt(src: SourceRow): Date | null {
|
||||
* a brain may have fresh sync but stale extract/embed. The 60-min floor on
|
||||
* full-cycle is the canonical freshness signal for autopilot dispatch.
|
||||
*/
|
||||
export function isSourceStale(src: SourceRow, now = Date.now(), floorMin = FULL_CYCLE_FLOOR_MIN): boolean {
|
||||
export function isSourceStale(
|
||||
src: SourceRow,
|
||||
now = Date.now(),
|
||||
floorMin = AUTOPILOT_FULL_CYCLE_FLOOR_MINUTES,
|
||||
): boolean {
|
||||
const last = readLastFullCycleAt(src);
|
||||
if (last === null) return true;
|
||||
const ageMin = (now - last.getTime()) / 60_000;
|
||||
@@ -328,7 +333,7 @@ export function selectSourcesForDispatch(
|
||||
sources: SourceRow[],
|
||||
fanoutMax: number,
|
||||
now = Date.now(),
|
||||
floorMin = FULL_CYCLE_FLOOR_MIN,
|
||||
floorMin = AUTOPILOT_FULL_CYCLE_FLOOR_MINUTES,
|
||||
recentFailures: Map<string, SourceFailure> = new Map(),
|
||||
cooldownOpts: CooldownOpts = { baseMin: FAILURE_COOLDOWN_BASE_MIN, capMin: FAILURE_COOLDOWN_CAP_MIN },
|
||||
): { dispatch: SourceRow[]; skippedFresh: SourceRow[]; skippedCap: SourceRow[]; skippedCooldown: SourceRow[] } {
|
||||
@@ -406,7 +411,14 @@ export async function dispatchPerSource(
|
||||
} else {
|
||||
log(`[dispatch] job #${job.id} autopilot-cycle (legacy single-source)`);
|
||||
}
|
||||
return { dispatched: [], skipped_fresh: [], skipped_cap: [], skipped_cooldown: [], legacy_fallback: true };
|
||||
return {
|
||||
dispatched: [],
|
||||
skipped_fresh: [],
|
||||
skipped_cap: [],
|
||||
skipped_cooldown: [],
|
||||
legacy_fallback: true,
|
||||
all_sources_fresh: false,
|
||||
};
|
||||
}
|
||||
|
||||
// #2194 fix #2: load recent per-source failures + cooldown knobs so a
|
||||
@@ -426,7 +438,14 @@ export async function dispatchPerSource(
|
||||
}
|
||||
|
||||
const { dispatch, skippedFresh, skippedCap, skippedCooldown } =
|
||||
selectSourcesForDispatch(sources, opts.fanoutMax, Date.now(), FULL_CYCLE_FLOOR_MIN, recentFailures, cooldownOpts);
|
||||
selectSourcesForDispatch(
|
||||
sources,
|
||||
opts.fanoutMax,
|
||||
Date.now(),
|
||||
AUTOPILOT_FULL_CYCLE_FLOOR_MINUTES,
|
||||
recentFailures,
|
||||
cooldownOpts,
|
||||
);
|
||||
|
||||
const dispatched: string[] = [];
|
||||
for (const src of dispatch) {
|
||||
@@ -509,6 +528,7 @@ export async function dispatchPerSource(
|
||||
skipped_cap: skippedCap.map(s => s.id),
|
||||
skipped_cooldown: skippedCooldown.map(s => s.id),
|
||||
legacy_fallback: false,
|
||||
all_sources_fresh: skippedFresh.length === sources.length,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export const AUTOPILOT_FULL_CYCLE_FLOOR_MINUTES = 60;
|
||||
|
||||
export interface AutopilotRemediationPlanShape {
|
||||
score: number;
|
||||
planLength: number;
|
||||
estimatedSeconds: number;
|
||||
minutesSinceLastFull: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep recommendation keys stable for doctor/remediate checkpoints while
|
||||
* giving Autopilot a fresh single-flight slot on every dispatch interval.
|
||||
*/
|
||||
export function autopilotRemediationIdempotencyKey(
|
||||
recommendationKey: string,
|
||||
dispatchSlot: string,
|
||||
): string {
|
||||
return `${recommendationKey}:autopilot:${dispatchSlot}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A full cycle is a freshness invariant, independent of the current score or
|
||||
* targeted plan. Large/slow/severely degraded plans retain the existing
|
||||
* hammer behavior before the freshness floor is reached.
|
||||
*/
|
||||
export function shouldRunAutopilotFullCycle({
|
||||
score,
|
||||
planLength,
|
||||
estimatedSeconds,
|
||||
minutesSinceLastFull,
|
||||
}: AutopilotRemediationPlanShape): boolean {
|
||||
return minutesSinceLastFull >= AUTOPILOT_FULL_CYCLE_FLOOR_MINUTES
|
||||
|| planLength > 3
|
||||
|| estimatedSeconds >= 300
|
||||
|| score < 70;
|
||||
}
|
||||
|
||||
export function shouldSleepHealthyAutopilot(
|
||||
score: number,
|
||||
planLength: number,
|
||||
minutesSinceLastFull: number,
|
||||
): boolean {
|
||||
return score >= 95
|
||||
&& planLength === 0
|
||||
&& minutesSinceLastFull < AUTOPILOT_FULL_CYCLE_FLOOR_MINUTES;
|
||||
}
|
||||
+53
-23
@@ -25,6 +25,11 @@ import { execSync } from 'child_process';
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { loadPreferences } from '../core/preferences.ts';
|
||||
import { loadConfig, loadConfigFileOnly, saveConfig, gbrainPath as gbrainHomePath } from '../core/config.ts';
|
||||
import {
|
||||
classifyAutopilotLockHolder,
|
||||
type AutopilotLockProbeDeps,
|
||||
isPidAlive,
|
||||
} from '../core/autopilot-lock.ts';
|
||||
import { ChildWorkerSupervisor } from '../core/minions/child-worker-supervisor.ts';
|
||||
import { VERSION } from '../version.ts';
|
||||
import {
|
||||
@@ -41,6 +46,11 @@ import { evaluateQuietHours } from '../core/minions/quiet-hours.ts';
|
||||
import { inspectLock } from '../core/db-lock.ts';
|
||||
import { registerCleanup } from '../core/process-cleanup.ts';
|
||||
import { resolveAutopilotDispatchTimeoutMs } from './autopilot-timeout.ts';
|
||||
import {
|
||||
autopilotRemediationIdempotencyKey,
|
||||
shouldRunAutopilotFullCycle,
|
||||
shouldSleepHealthyAutopilot,
|
||||
} from './autopilot-remediation-policy.ts';
|
||||
// Path helpers live in a LEAF core module so other commands (gbrain migrate)
|
||||
// can read the daemon's state files without importing this one — a dynamic
|
||||
// import of a command module drags its whole flag surface into the importer's
|
||||
@@ -244,19 +254,22 @@ export function shouldSpawnAutopilotWorker(args: string[]): boolean {
|
||||
return !args.includes('--no-worker');
|
||||
}
|
||||
|
||||
export function isPidAlive(pid: number): boolean {
|
||||
if (!Number.isFinite(pid) || pid <= 0) return false;
|
||||
export { isPidAlive };
|
||||
|
||||
export const AUTOPILOT_FOREIGN_PID_TAKEOVER_GRACE_MS = 10 * 60 * 1000;
|
||||
|
||||
function autopilotLockAgeMs(lockPath: string): number | null {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
||||
return Date.now() - statSync(lockPath).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function decideLockAcquisition(
|
||||
lockPath: string,
|
||||
currentPid: number,
|
||||
deps: AutopilotLockProbeDeps = {},
|
||||
): { action: 'acquire' } | { action: 'exit'; holderPid: number } | { action: 'takeover'; reason: string } {
|
||||
if (!existsSync(lockPath)) return { action: 'acquire' };
|
||||
|
||||
@@ -268,10 +281,21 @@ export function decideLockAcquisition(
|
||||
}
|
||||
|
||||
const holderPid = Number.parseInt(raw, 10);
|
||||
const sameProcess = Number.isFinite(holderPid) && holderPid === currentPid;
|
||||
const alive = !sameProcess && isPidAlive(holderPid);
|
||||
const holder = classifyAutopilotLockHolder(holderPid, currentPid, deps);
|
||||
|
||||
if (alive) return { action: 'exit', holderPid };
|
||||
if (holder.state === 'alive-autopilot' || holder.state === 'alive-unknown') {
|
||||
return { action: 'exit', holderPid };
|
||||
}
|
||||
if (holder.state === 'alive-foreign') {
|
||||
const lockAgeMs = autopilotLockAgeMs(lockPath);
|
||||
if (lockAgeMs !== null && lockAgeMs >= AUTOPILOT_FOREIGN_PID_TAKEOVER_GRACE_MS) {
|
||||
return { action: 'takeover', reason: `foreign pid ${raw || '<empty>'} with stale lock` };
|
||||
}
|
||||
return { action: 'exit', holderPid };
|
||||
}
|
||||
if (holder.state === 'self') {
|
||||
return { action: 'takeover', reason: `own pid ${raw || '<empty>'}` };
|
||||
}
|
||||
return { action: 'takeover', reason: `dead pid ${raw || '<empty>'}` };
|
||||
}
|
||||
|
||||
@@ -875,8 +899,8 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
//
|
||||
// New logic: compute the remediation plan (cheap; no full doctor
|
||||
// walk), then route to the right level of intervention:
|
||||
// - Score >= 95 + empty plan: full cycle every 60min (phase-
|
||||
// coupling exercise), otherwise sleep.
|
||||
// - Full cycle every 60min regardless of score/plan (phase-
|
||||
// coupling + freshness invariant); healthy brains sleep before it.
|
||||
// - Small plan (<=3 steps, <5min): submit individual handlers.
|
||||
// - Large plan or low score: full autopilot-cycle (the hammer).
|
||||
//
|
||||
@@ -1121,16 +1145,16 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
const estTotal = plan.reduce((s, r) => s + r.est_seconds, 0);
|
||||
|
||||
// Track time since last full cycle for the 60-min floor.
|
||||
const FULL_CYCLE_FLOOR_MIN = 60;
|
||||
const minutesSinceLastFull = (Date.now() - lastFullCycleAt) / 60000;
|
||||
|
||||
const shouldFullCycle =
|
||||
(score >= 95 && plan.length === 0 && minutesSinceLastFull >= FULL_CYCLE_FLOOR_MIN) ||
|
||||
plan.length > 3 ||
|
||||
estTotal >= 300 ||
|
||||
score < 70;
|
||||
const shouldFullCycle = shouldRunAutopilotFullCycle({
|
||||
score,
|
||||
planLength: plan.length,
|
||||
estimatedSeconds: estTotal,
|
||||
minutesSinceLastFull,
|
||||
});
|
||||
|
||||
const shouldSleep = score >= 95 && plan.length === 0 && minutesSinceLastFull < FULL_CYCLE_FLOOR_MIN;
|
||||
const shouldSleep = shouldSleepHealthyAutopilot(score, plan.length, minutesSinceLastFull);
|
||||
|
||||
if (shouldSleep) {
|
||||
if (jsonMode) {
|
||||
@@ -1181,7 +1205,11 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
if (jsonMode) process.stderr.write(JSON.stringify({ event: 'global_maintenance_dispatch_failed', error: e instanceof Error ? e.message : String(e) }) + '\n');
|
||||
}
|
||||
}
|
||||
if (result.dispatched.length > 0 || result.legacy_fallback) {
|
||||
// On restart the process-local clock starts overdue. If persisted
|
||||
// source timestamps say every source is fresh, advance the local
|
||||
// clock too; otherwise a non-empty targeted plan would be skipped
|
||||
// on every tick until the persisted 60-minute window elapsed.
|
||||
if (result.dispatched.length > 0 || result.legacy_fallback || result.all_sources_fresh) {
|
||||
lastFullCycleAt = Date.now();
|
||||
}
|
||||
if (jsonMode) {
|
||||
@@ -1205,15 +1233,17 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
}
|
||||
} else {
|
||||
// Small targeted plan — submit individual handlers per step.
|
||||
// D9 content-hash idempotency keys (from computeRecommendations).
|
||||
// maxWaiting:1 per submit per codex #17 (closes the backpressure
|
||||
// gap the prior implementation had for targeted submits).
|
||||
// Recommendation keys stay stable for doctor/remediate checkpoints;
|
||||
// Autopilot adds the dispatch interval so completed rows cannot hold
|
||||
// the remediation slot forever (#4046).
|
||||
// maxWaiting:1 per submit per codex #17 bounds the cross-window
|
||||
// backlog if a targeted handler runs longer than one interval.
|
||||
for (const step of plan) {
|
||||
try {
|
||||
const isProtected = !!step.protected;
|
||||
const submitOpts = {
|
||||
queue: 'default',
|
||||
idempotency_key: step.idempotency_key,
|
||||
idempotency_key: autopilotRemediationIdempotencyKey(step.idempotency_key, slot),
|
||||
max_attempts: 2,
|
||||
timeout_ms: timeoutMs,
|
||||
maxWaiting: 1,
|
||||
|
||||
+196
-18
@@ -74,7 +74,7 @@ import {
|
||||
statusReport,
|
||||
type StatusReport,
|
||||
} from '../core/bootstrap/status.ts';
|
||||
import { verifyWorkspace } from '../core/bootstrap/verify.ts';
|
||||
import { verifyWorkspace, deriveWorkspaceSourceId } from '../core/bootstrap/verify.ts';
|
||||
|
||||
export const BOOTSTRAP_HELP = `gbrain bootstrap — paste-in agent install (Claude Code / Codex)
|
||||
|
||||
@@ -116,6 +116,60 @@ Env: GBRAIN_BOOTSTRAP_ABORT_AFTER=<phase> (test seam — abort after that phase'
|
||||
const SUPPORT_HINT =
|
||||
'If you are stuck: run `gbrain bootstrap status --json` and relay the "support" block verbatim.';
|
||||
|
||||
/**
|
||||
* Per-subcommand `--help`/`-h`/`help` usage text for the subcommands that
|
||||
* MUTATE state (create a repo, register MCP/hooks, run the verify contract,
|
||||
* adopt a workspace, remove receipt-tracked paths, record an interview
|
||||
* answer). `runBootstrap`'s dispatch checks `args[0]` for top-level help
|
||||
* (`--help`/`-h`/`help`/no args), but a help token AFTER the subcommand name
|
||||
* (e.g. `gbrain bootstrap repo --help`, `gbrain bootstrap uninstall help`)
|
||||
* previously fell straight into the subcommand's own arg parsing, which had
|
||||
* no help handling of its own — so it ran the real mutation instead of
|
||||
* printing help. `status`/`cloud-setup-script` are pure reads, so they don't
|
||||
* need a guard.
|
||||
*/
|
||||
const SUBCOMMAND_HELP: Record<string, string> = {
|
||||
render:
|
||||
'gbrain bootstrap render [--force] [--only F] [--minimal]\n' +
|
||||
' Render identity files from the confirmed interview answers. Never clobbers; --force backs up first.',
|
||||
repo:
|
||||
'gbrain bootstrap repo\n' +
|
||||
' 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' +
|
||||
' Register MCP (+ per-turn hooks on Claude Code, ON by default; --no-hooks opts out).',
|
||||
verify:
|
||||
'gbrain bootstrap verify [--json]\n' +
|
||||
' The whole install contract (round-trip, graph floor, magic moment, scans, hooks smoke). Exit 0 or not done.',
|
||||
attach:
|
||||
'gbrain bootstrap attach [--harness H]\n' +
|
||||
' Machine two: adopt a cloned agent workspace.',
|
||||
uninstall:
|
||||
'gbrain bootstrap uninstall [--delete-brain] [--home <dir>] [--yes]\n' +
|
||||
' Receipt-keyed removal. The repo stays yours.',
|
||||
interview:
|
||||
'gbrain bootstrap interview --init | --set KEY "value" | --skip KEY | --status | --show | --confirm <hash>\n' +
|
||||
' Create/record/read interview state. See `gbrain bootstrap --help` for the per-flag description.',
|
||||
};
|
||||
|
||||
/**
|
||||
* `--help`/`-h` are always recognized. The bare word `help` (no dashes) is
|
||||
* ALSO recognized for every subcommand above EXCEPT `interview` — mirroring
|
||||
* the top-level `sub === 'help'` handling for a user who tries the same
|
||||
* spelling after a subcommand name. `interview` is excluded from the
|
||||
* bare-word form because its `--set KEY "value"` free-text answers could
|
||||
* legitimately BE the literal word "help" (e.g. a one-word answer); none of
|
||||
* the other subcommands' flags take arbitrary prose, only booleans, enums,
|
||||
* or paths, so the bare-word collision risk there is negligible (matches the
|
||||
* already-accepted low-impact risk of `-h` colliding with a literal path
|
||||
* value like `--home -h`).
|
||||
*/
|
||||
function hasHelpToken(args: string[], allowBareWord: boolean): boolean {
|
||||
if (args.includes('--help') || args.includes('-h')) return true;
|
||||
return allowBareWord && args.includes('help');
|
||||
}
|
||||
|
||||
/** Thrown by the A7 abort seam; mapped to exit 130 (simulated kill). */
|
||||
export class BootstrapAbortInjected extends Error {
|
||||
constructor(phase: string) {
|
||||
@@ -154,6 +208,19 @@ function resolveWorkspace(args: string[]): string {
|
||||
return ws ? resolve(ws) : process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* POSIX single-quote anything not already shell-safe, for commands printed
|
||||
* as copy/paste guidance (mirror of the private `shellQuote` in
|
||||
* core/bootstrap/hooks.ts, core/sources-ops.ts, and commands/connect.ts —
|
||||
* same contract: `$()`/backticks in a value are inert literals once quoted).
|
||||
* A workspace path containing a space or shell metacharacter must not turn
|
||||
* "the exact command to run" into a broken (or, pasted blind, dangerous) one.
|
||||
*/
|
||||
function shellQuoteForDisplay(arg: string): string {
|
||||
if (/^[A-Za-z0-9_.:/@=-]+$/.test(arg)) return arg;
|
||||
return `'${arg.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
// ── Shared plumbing ─────────────────────────────────────────────────────────
|
||||
|
||||
type Harness = 'claude-code' | 'codex';
|
||||
@@ -362,7 +429,7 @@ async function runStatus(ws: string, rest: string[], home: string): Promise<numb
|
||||
if (report.next) {
|
||||
console.log(`\nNext: ${report.next}`);
|
||||
} else {
|
||||
console.log('\nAll phases done. Weekly self-check: `gbrain bootstrap verify`.');
|
||||
console.log('\nAll phases done. Weekly self-check: `gbrain bootstrap verify` (close agent sessions first — PGLite is single-writer).');
|
||||
}
|
||||
if (report.runbookSkew) {
|
||||
console.log(
|
||||
@@ -389,6 +456,15 @@ async function runStatus(ws: string, rest: string[], home: string): Promise<numb
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** One copy of the A8 invalidation warning — shared by --set and --skip so
|
||||
* the operator-facing instructions cannot drift between the two branches. */
|
||||
function warnInvalidatedConfirmation(): void {
|
||||
console.error(
|
||||
'note: this change voided the prior confirmation — read the full answer set back ' +
|
||||
'to the human, then `gbrain bootstrap interview --confirm <hash>` again before render.',
|
||||
);
|
||||
}
|
||||
|
||||
async function runInterview(ws: string, rest: string[]): Promise<number> {
|
||||
if (rest.includes('--init')) {
|
||||
const r = initState(ws);
|
||||
@@ -430,6 +506,7 @@ async function runInterview(ws: string, rest: string[]): Promise<number> {
|
||||
console.log(`${key}: routed to the 0600 config file (${routed.configKey}). Not recorded in interview state.`);
|
||||
return 0;
|
||||
}
|
||||
if (r.invalidatedConfirmation) warnInvalidatedConfirmation();
|
||||
console.log(`${key} recorded.`);
|
||||
return 0;
|
||||
}
|
||||
@@ -445,6 +522,7 @@ async function runInterview(ws: string, rest: string[]): Promise<number> {
|
||||
console.error(r.message);
|
||||
return 1;
|
||||
}
|
||||
if (r.invalidatedConfirmation) warnInvalidatedConfirmation();
|
||||
console.log(`${key} skipped.`);
|
||||
return 0;
|
||||
}
|
||||
@@ -735,8 +813,48 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
const gbrainHome = process.env.GBRAIN_HOME?.trim() || undefined;
|
||||
|
||||
return withLock(ws, async () => {
|
||||
// 0. source_id visibility seam: `hooks` is the last ENGINE-FREE phase
|
||||
// before `verify` (which alone can detect a source_id collision — the
|
||||
// sources registry lives only in the DB). Without this, a human who
|
||||
// hand-registers a source before verify has no way to know the exact id
|
||||
// the workspace expects, guesses an "intuitive" name instead, and only
|
||||
// discovers the mismatch via a `verify` roundtrip FK error — then, after
|
||||
// switching to the manifest's id, an `overlapping_path` error from the
|
||||
// earlier guess still claiming the same brain/ dir. Printing the current
|
||||
// id (and the collision-fallback id verify would derive, a pure path
|
||||
// hash that needs no engine) up front — plus creating brain/ so
|
||||
// registration can happen immediately — collapses that multi-round-trip
|
||||
// loop to one command.
|
||||
const brainDir = join(ws, 'brain');
|
||||
mkdirSync(brainDir, { recursive: true });
|
||||
// --force: brain/ was just created empty — `sources add` fail-fasts on a
|
||||
// --path that exists but isn't a git repo with committed, tracked
|
||||
// content (#2707), and gbrain deliberately never auto-git-inits a --path
|
||||
// source itself (a --path source is the user's own directory — the
|
||||
// consent boundary #2967 established for sync-time self-heal applies
|
||||
// here too). --force is the sanctioned opt-in for exactly this "register
|
||||
// before git-init exists" case (see sources-ops.ts's own not_a_git_repo
|
||||
// message), and it is safe here because brainDir is not an arbitrary
|
||||
// user path — it is the fixed `<workspace>/brain` subdir this phase just
|
||||
// created. Without --force, the printed command below would itself throw
|
||||
// not_a_git_repo the instant it's pasted.
|
||||
const quoted = shellQuoteForDisplay(brainDir);
|
||||
console.log(
|
||||
`brain source: register this workspace's brain/ now if you haven't — ` +
|
||||
`\`gbrain sources add ${sourceId} --path ${quoted} --force\` (brain/ is freshly created and empty; ` +
|
||||
`--force is the documented opt-in for registering before git-init exists). If '${sourceId}' is ` +
|
||||
`already claimed by a different checkout on this brain, \`gbrain bootstrap verify\` will detect the ` +
|
||||
`collision and switch this workspace to '${deriveWorkspaceSourceId(ws)}' — re-run the same command ` +
|
||||
`with that id instead.`,
|
||||
);
|
||||
|
||||
// 1. MCP registration — argv built by the host-format module, executed
|
||||
// through the runner seam, recorded on the receipt.
|
||||
// A missing host binary (exit 127) skips MCP registration but NOT the
|
||||
// hooks below — hooks only write .claude/settings.local.json and need no
|
||||
// binary. The old early-return silently dropped hooks while the copy said
|
||||
// only "MCP registration skipped".
|
||||
let mcpSkipped = false;
|
||||
const argvs =
|
||||
harness === 'claude-code'
|
||||
? registerClaudeMcp({ gbrainBin, scope: mcpScope, sourceId, ...(gbrainHome ? { gbrainHome } : {}) })
|
||||
@@ -746,10 +864,12 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
const res = await runner(argv);
|
||||
if (res.code === 127) {
|
||||
console.error(
|
||||
`\`${argv[0]}\` is not on PATH — is ${harness} installed? MCP registration skipped; ` +
|
||||
`re-run \`gbrain bootstrap hooks --harness ${harness}\` once it is.`,
|
||||
`\`${argv[0]}\` is not on PATH — is ${harness} installed? MCP registration skipped ` +
|
||||
`(per-turn hooks still install below); re-run ` +
|
||||
`\`gbrain bootstrap hooks --harness ${harness}\` once it is.`,
|
||||
);
|
||||
return 2;
|
||||
mcpSkipped = true;
|
||||
break;
|
||||
}
|
||||
if (res.code !== 0) {
|
||||
const already = /already exists|already registered/i.test(res.stderr + res.stdout);
|
||||
@@ -767,12 +887,45 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
console.error(
|
||||
`existing '${mcpName}' MCP registration targets a DIFFERENT workspace/binary — replacing it.`,
|
||||
);
|
||||
await runner([argv[0], 'mcp', 'remove', mcpName]);
|
||||
// The add above failed "already exists" in the CURRENT scope, so the
|
||||
// blocker lives there — target the remove at that scope on Claude
|
||||
// Code (a scope-less remove can resolve to a different scope's
|
||||
// registration and leave the blocker in place). Codex has no scope
|
||||
// flag. Fail loud if the remove doesn't land: the silent no-op loop
|
||||
// used to re-fail the add and report nothing actionable.
|
||||
const rmArgv =
|
||||
harness === 'claude-code'
|
||||
? [argv[0], 'mcp', 'remove', mcpName, '--scope', mcpScope]
|
||||
: [argv[0], 'mcp', 'remove', mcpName];
|
||||
const rm = await runner(rmArgv);
|
||||
if (rm.code !== 0) {
|
||||
console.error(
|
||||
`\`${rmArgv.join(' ')}\` failed (${rm.stderr.trim() || `exit ${rm.code}`}) — remove the stale ` +
|
||||
`registration by hand (\`${argv[0]} mcp get ${mcpName}\` shows where it lives), then re-run ` +
|
||||
`\`gbrain bootstrap hooks --harness ${harness} --repair\`.`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const re = await runner(argv);
|
||||
if (re.code !== 0 && !/already exists|already registered/i.test(re.stderr + re.stdout)) {
|
||||
console.error(`MCP re-registration failed (${argv.join(' ')}): ${re.stderr.trim() || `exit ${re.code}`}`);
|
||||
return 1;
|
||||
}
|
||||
// Re-add can itself return "already exists" if a racing writer
|
||||
// re-claimed the name between our remove and add — that registration
|
||||
// is NOT ours. Re-verify and abort rather than bless a foreign
|
||||
// endpoint that would intercept memory ops. (Only the recorded
|
||||
// warn-then-continue step-2 smoke did this before; here it's fatal.)
|
||||
const post = await verifyMcpTargetsWorkspace(runner, harness, mcpName, gbrainBin, sourceId);
|
||||
if (post === 'mismatch') {
|
||||
console.error(
|
||||
`after replacing '${mcpName}', it STILL targets a different workspace/binary — ` +
|
||||
`refusing to continue (a racing registration may have re-claimed the name). ` +
|
||||
`Inspect \`${argv[0]} mcp get ${mcpName}\`, remove it by hand, then re-run ` +
|
||||
`\`gbrain bootstrap hooks --harness ${harness} --repair\`.`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`MCP server '${mcpName}' already registered — could not confirm it targets this workspace ` +
|
||||
@@ -785,7 +938,7 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
// 2. Registration smoke [FIX7]: confirm the EXPECTED server (binary path +
|
||||
// GBRAIN_SOURCE), not merely a 'gbrain' substring in `mcp list`. Falls back
|
||||
// to the list probe only when the host has no `mcp get`.
|
||||
try {
|
||||
if (!mcpSkipped) try {
|
||||
const listBin = harness === 'claude-code' ? 'claude' : 'codex';
|
||||
const scopeLabel = harness === 'claude-code' ? mcpScope : 'user-global';
|
||||
const verdict = await verifyMcpTargetsWorkspace(runner, harness, 'gbrain', gbrainBin, sourceId);
|
||||
@@ -824,9 +977,21 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
// both files.
|
||||
const hookEnv = { GBRAIN_SOURCE: sourceId, ...(gbrainHome ? { GBRAIN_HOME: gbrainHome } : {}) };
|
||||
const cloudCarrier = detectExecutionEnvironment() === 'cloud-sandbox';
|
||||
const r = cloudCarrier
|
||||
? writeCommittedClaudeHooks(ws, { env: hookEnv })
|
||||
: writeClaudeHooks(ws, { gbrainBin, env: hookEnv });
|
||||
let r: ReturnType<typeof writeClaudeHooks> | ReturnType<typeof writeCommittedClaudeHooks>;
|
||||
try {
|
||||
r = cloudCarrier
|
||||
? writeCommittedClaudeHooks(ws, { env: hookEnv })
|
||||
: writeClaudeHooks(ws, { gbrainBin, env: hookEnv });
|
||||
} catch (e) {
|
||||
// Fail-closed on an unparseable settings file (either carrier): MCP
|
||||
// (step 1) still landed; record that, surface the fix, and exit
|
||||
// nonzero so the paste-in flow knows hooks are NOT installed.
|
||||
console.error((e as Error).message);
|
||||
if (!mcpSkipped) {
|
||||
appendReceiptRegistration(home, ws, { host: harness, scope: mcpScope, detail: 'mcp' });
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
hooksWritten = true;
|
||||
console.log(
|
||||
`hooks installed (${r.installed.length} event(s)) in ${r.settingsPath}${repair ? ' [repair]' : ''} — your brain now loads every turn. Turn off any time with GBRAIN_HOOKS=0, or re-run with --no-hooks.`,
|
||||
@@ -849,15 +1014,18 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
console.log('Codex has no hook system — per-turn context is the AGENTS.md pull protocol (stated plainly, not a bug).');
|
||||
}
|
||||
|
||||
// 4. Receipt registration record [CX2-12].
|
||||
appendReceiptRegistration(home, ws, {
|
||||
host: harness,
|
||||
scope: harness === 'claude-code' ? mcpScope : 'user',
|
||||
detail: hooksWritten ? 'mcp+hooks' : 'mcp',
|
||||
});
|
||||
// 4. Receipt registration record [CX2-12]. Detail records what actually
|
||||
// landed; nothing landed at all (127 + no hooks) → no receipt entry.
|
||||
if (!mcpSkipped || hooksWritten) {
|
||||
appendReceiptRegistration(home, ws, {
|
||||
host: harness,
|
||||
scope: harness === 'claude-code' ? mcpScope : 'user',
|
||||
detail: hooksWritten ? (mcpSkipped ? 'hooks' : 'mcp+hooks') : 'mcp',
|
||||
});
|
||||
}
|
||||
|
||||
abortIfInjected('wire');
|
||||
return 0;
|
||||
return mcpSkipped ? 2 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -879,7 +1047,7 @@ async function runVerify(ws: string, rest: string[], home: string): Promise<numb
|
||||
const sourceId = state.state === 'initialized' ? state.manifest.source_id : 'workspace';
|
||||
const result = await verifyWorkspace(engine, ws, { sourceId, gbrainHomeDir: home });
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify({ ok: result.ok, checks: result.checks, capability: result.capability, tour: result.tour }, null, 2));
|
||||
console.log(JSON.stringify({ ok: result.ok, checks: result.checks, capability: result.capability, tour: result.tour, handoff: result.handoff }, null, 2));
|
||||
} else {
|
||||
console.log(result.report);
|
||||
}
|
||||
@@ -1060,6 +1228,16 @@ export async function runBootstrap(args: string[], opts: RunBootstrapOpts = {}):
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Subcommand-level help: BEFORE any subcommand body runs, so a help token
|
||||
// after a mutating subcommand (repo/hooks/verify/attach/uninstall/render/
|
||||
// interview) never falls through into the real operation, regardless of
|
||||
// what other flags/values precede it in `rest`. No install-log entry
|
||||
// either — this isn't a phase run.
|
||||
if (SUBCOMMAND_HELP[sub] && hasHelpToken(rest, sub !== 'interview')) {
|
||||
console.log(SUBCOMMAND_HELP[sub]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The install log records the PHASE name, and the hooks subcommand is the
|
||||
// 'wire' phase (status.ts phase list) — one mapping, used at every log site.
|
||||
const logPhaseName = sub === 'hooks' ? 'wire' : sub;
|
||||
|
||||
+608
-69
@@ -17,19 +17,22 @@
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync, readFileSync, appendFileSync, chmodSync, cpSync, lstatSync } from 'fs';
|
||||
import { join, resolve, basename, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { tmpdir } from 'os';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { logFriction, frictionDir } from '../core/friction.ts';
|
||||
import { logFriction, frictionDir, frictionFile } from '../core/friction.ts';
|
||||
import { loadScenario, listScenarios, readBrief, type ScenarioConfig } from '../core/claw-test/scenarios.ts';
|
||||
import { parseProgressEvents, verifyExpectedPhases } from '../core/claw-test/progress-tail.ts';
|
||||
import { resolveAgentRunner, listRegisteredAgents, registerAgentRunner } from '../core/claw-test/agent-runner.ts';
|
||||
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 { createTranscriptSink } from '../core/claw-test/transcript-capture.ts';
|
||||
|
||||
// Ensure built-in runners are registered.
|
||||
registerAgentRunner('openclaw', () => new OpenClawRunner());
|
||||
registerAgentRunner('hermes', () => new HermesRunner());
|
||||
|
||||
interface HarnessOpts {
|
||||
scenario: string;
|
||||
@@ -38,8 +41,11 @@ interface HarnessOpts {
|
||||
keepTempdir: boolean;
|
||||
listAgents: boolean;
|
||||
help: boolean;
|
||||
/** Path to the gbrain binary used to invoke child commands. Defaults to argv[0]. */
|
||||
gbrainBin?: string;
|
||||
/** Path to the gbrain binary used to invoke child commands (always set by
|
||||
* parseArgs: GBRAIN_BIN_OVERRIDE when valid; else the compiled gbrain
|
||||
* binary, or a synthesized launcher when running under the bun runtime —
|
||||
* see resolveGbrainBin). */
|
||||
gbrainBin: string;
|
||||
}
|
||||
|
||||
interface PhaseOutcome {
|
||||
@@ -49,10 +55,24 @@ interface PhaseOutcome {
|
||||
stderrEvents: number;
|
||||
stdoutTail: string;
|
||||
stderrTail: string;
|
||||
/** Full stdout, only populated when invokeGbrain is asked to capture it. */
|
||||
stdoutFull?: string;
|
||||
}
|
||||
|
||||
const TAIL_BYTES = 4_096;
|
||||
const SUBPROCESS_TIMEOUT_MS = 5 * 60_000; // 5 minutes per phase
|
||||
/** Per-phase cap for the harness's own gbrain children (staging, scripted
|
||||
* phases, oracle probes). Env override is a test/incident escape hatch. */
|
||||
const SUBPROCESS_TIMEOUT_MS = envTimeoutMs('GBRAIN_CLAW_PHASE_TIMEOUT_MS', 5 * 60_000);
|
||||
/** Wall clock for the live agent turn — real fresh-install turns run long
|
||||
* (help text promises "5 to 10 min"), so the agent gets double the phase cap. */
|
||||
const LIVE_AGENT_TIMEOUT_MS = envTimeoutMs('GBRAIN_CLAW_AGENT_TIMEOUT_MS', 10 * 60_000);
|
||||
|
||||
function envTimeoutMs(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return fallback;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
export async function runClawTest(args: string[]): Promise<number> {
|
||||
const opts = parseArgs(args);
|
||||
@@ -66,6 +86,18 @@ export async function runClawTest(args: string[]): Promise<number> {
|
||||
return cmdListAgents();
|
||||
}
|
||||
|
||||
// Charset guard: both values flow into filesystem paths (scenario → the
|
||||
// fixtures root join, agent → the run-id → the tempdir template), so a
|
||||
// traversal-shaped value would either escape the fixtures root or crash
|
||||
// sanitizeRunId mid-run. Usage error, exit 2.
|
||||
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
for (const [flag, value] of [['scenario', opts.scenario], ['agent', opts.agent]] as const) {
|
||||
if (!NAME_RE.test(value)) {
|
||||
console.error(`invalid --${flag} value ${JSON.stringify(value)}: letters, digits, dot, dash, underscore only`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
let scenario: ScenarioConfig;
|
||||
try {
|
||||
scenario = loadScenario(opts.scenario);
|
||||
@@ -83,6 +115,26 @@ export async function runClawTest(args: string[]): Promise<number> {
|
||||
console.log(`run-id: ${runId}`);
|
||||
console.log(`tempdir: ${runRoot}`);
|
||||
|
||||
// Run-start meta record. Agent-name resolution in `gbrain friction diff`
|
||||
// depends on this: a fully clean run otherwise writes zero agent-stamped
|
||||
// entries and could never be resolved by agent name. Uses the existing
|
||||
// phase-marker kind (no new FrictionKind) + additive scenario/harness_schema
|
||||
// fields.
|
||||
const agentLabel = opts.live ? opts.agent : 'scripted';
|
||||
try {
|
||||
logFriction({
|
||||
runId,
|
||||
phase: 'harness',
|
||||
kind: 'phase-marker',
|
||||
marker: 'start',
|
||||
message: `run start: scenario=${scenario.name} agent=${agentLabel}`,
|
||||
source: 'harness',
|
||||
agent: agentLabel,
|
||||
scenario: scenario.name,
|
||||
harnessSchema: 1,
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
|
||||
// SIGINT/SIGTERM finalization (D11)
|
||||
let interrupted = false;
|
||||
const onSignal = () => {
|
||||
@@ -94,7 +146,7 @@ export async function runClawTest(args: string[]): Promise<number> {
|
||||
message: 'run interrupted by signal',
|
||||
kind: 'interrupted',
|
||||
source: 'harness',
|
||||
agent: opts.agent,
|
||||
agent: agentLabel,
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
};
|
||||
@@ -108,9 +160,48 @@ export async function runClawTest(args: string[]): Promise<number> {
|
||||
} else {
|
||||
exitCode = await runScripted(opts, scenario, { runId, runRoot, gbrainHome });
|
||||
}
|
||||
} catch (e) {
|
||||
// Without this, a thrown run (spawn failure, runner detect race) would
|
||||
// reach the finally block with exitCode still 0 and stamp a
|
||||
// `run complete … exit=0` meta record — the friction log (diff/render's
|
||||
// input) silently recording success for a crashed run.
|
||||
exitCode = 1;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`claw-test: run crashed: ${msg}`);
|
||||
if (e instanceof Error && e.stack) console.error(e.stack);
|
||||
try {
|
||||
logFriction({
|
||||
runId,
|
||||
phase: 'harness',
|
||||
message: `harness crashed: ${msg}`,
|
||||
severity: 'blocker',
|
||||
source: 'harness',
|
||||
agent: agentLabel,
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
} finally {
|
||||
process.off('SIGINT', onSignal);
|
||||
process.off('SIGTERM', onSignal);
|
||||
// Run-completion meta record (pairs with the start marker above).
|
||||
try {
|
||||
logFriction({
|
||||
runId,
|
||||
phase: 'harness',
|
||||
kind: 'phase-marker',
|
||||
marker: 'end',
|
||||
message: `run complete: scenario=${scenario.name} agent=${agentLabel} exit=${exitCode}`,
|
||||
source: 'harness',
|
||||
agent: agentLabel,
|
||||
scenario: scenario.name,
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
// Persist agent/child-side friction BEFORE the tempdir is deleted. The
|
||||
// children run with GBRAIN_HOME=<runRoot>, so their friction lands under
|
||||
// <runRoot>/.gbrain/friction/<runId>.jsonl — rmSync below would silently
|
||||
// destroy it on every run, leaving `friction render`/`diff` with only the
|
||||
// harness's half of the story. Merge into the parent's friction file
|
||||
// (same runId; the two sides write disjoint entries).
|
||||
mergeChildFriction(runRoot, runId);
|
||||
if (!opts.keepTempdir && !interrupted) {
|
||||
try { rmSync(runRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
} else {
|
||||
@@ -148,6 +239,36 @@ export async function runClawTest(args: string[]): Promise<number> {
|
||||
*/
|
||||
const POSTGRES_POLLUTION_ENV_VARS = ['DATABASE_URL', 'GBRAIN_DATABASE_URL'];
|
||||
|
||||
/**
|
||||
* Child env for gbrain invocations (scripted phases AND live-mode staging /
|
||||
* oracle probes): parent env minus Postgres-pointing vars AND minus every
|
||||
* other GBRAIN_* routing/tuning var — a stray operator GBRAIN_BRAIN_ID /
|
||||
* GBRAIN_SOURCE / threshold override would misroute the staging and oracle
|
||||
* probes and produce false verify verdicts (the same class scripts/run-e2e.sh
|
||||
* scrubs for e2e hermeticity). The two vars the harness owns are re-applied
|
||||
* last so a parent override can't win.
|
||||
*/
|
||||
function buildChildEnv(ctx: { runId: string; gbrainHome: string }): Record<string, string> {
|
||||
const parentEnv = process.env as Record<string, string | undefined>;
|
||||
const childEnv: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(parentEnv)) {
|
||||
if (v === undefined) continue;
|
||||
if (POSTGRES_POLLUTION_ENV_VARS.includes(k)) continue;
|
||||
if (k.startsWith('GBRAIN_')) continue;
|
||||
childEnv[k] = v;
|
||||
}
|
||||
childEnv.GBRAIN_HOME = ctx.gbrainHome;
|
||||
childEnv.GBRAIN_FRICTION_RUN_ID = ctx.runId;
|
||||
return childEnv;
|
||||
}
|
||||
|
||||
/** The hermetic run's PGLite path (configDir appends '.gbrain'). One spelling
|
||||
* for all probe/seed sites — a drifted copy would silently probe a
|
||||
* nonexistent db and fail the upgrade oracle as "unreadable". */
|
||||
function pgliteDbPath(gbrainHome: string): string {
|
||||
return join(gbrainHome, '.gbrain', 'brain.pglite');
|
||||
}
|
||||
|
||||
async function runScripted(
|
||||
opts: HarnessOpts,
|
||||
scenario: ScenarioConfig,
|
||||
@@ -157,17 +278,7 @@ async function runScripted(
|
||||
// The harness is PGLite-only by design; an inherited DATABASE_URL
|
||||
// would force loadConfig() to flip the engine to 'postgres' at the
|
||||
// next phase boundary and break the hermetic-tempdir contract.
|
||||
const parentEnv = process.env as Record<string, string | undefined>;
|
||||
const childEnv: Record<string, string> = { GBRAIN_HOME: ctx.gbrainHome, GBRAIN_FRICTION_RUN_ID: ctx.runId };
|
||||
for (const [k, v] of Object.entries(parentEnv)) {
|
||||
if (v === undefined) continue;
|
||||
if (POSTGRES_POLLUTION_ENV_VARS.includes(k)) continue;
|
||||
childEnv[k] = v;
|
||||
}
|
||||
// Re-apply the explicit overrides so a parent GBRAIN_HOME / GBRAIN_FRICTION_RUN_ID
|
||||
// can't accidentally win the merge.
|
||||
childEnv.GBRAIN_HOME = ctx.gbrainHome;
|
||||
childEnv.GBRAIN_FRICTION_RUN_ID = ctx.runId;
|
||||
const childEnv = buildChildEnv(ctx);
|
||||
|
||||
const phases: { name: string; argv: string[] }[] = [];
|
||||
// Phase 2: install_brain. `--no-embedding` defers embedding setup so the
|
||||
@@ -200,35 +311,51 @@ async function runScripted(
|
||||
// Phase 6: verify
|
||||
phases.push({ name: 'verify', argv: ['doctor', '--json', '--progress-json'] });
|
||||
|
||||
// Pre-phase: upgrade scenario seeds the database
|
||||
if (scenario.kind === 'upgrade' && scenario.seedRelative) {
|
||||
const seedSql = join(scenario.dir, scenario.seedRelative, 'dump.sql');
|
||||
if (existsSync(seedSql)) {
|
||||
const dbPath = join(ctx.gbrainHome, '.gbrain', 'brain.pglite');
|
||||
mkdirSync(join(ctx.gbrainHome, '.gbrain'), { recursive: true });
|
||||
const { seedPgliteFromFile } = await import('../core/claw-test/seed-pglite.ts');
|
||||
try {
|
||||
await seedPgliteFromFile({ dbPath, sqlPath: seedSql });
|
||||
console.log(`[seed] replayed ${seedSql} → ${dbPath}`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logFriction({
|
||||
runId: ctx.runId,
|
||||
phase: 'seed',
|
||||
message: `seed replay failed: ${msg}`,
|
||||
severity: 'blocker',
|
||||
source: 'harness',
|
||||
agent: opts.agent,
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
// Pre-phase: upgrade scenario seeds the database. A missing dump is a LOUD
|
||||
// failure, not a skip: skipping would init a current-version database and
|
||||
// report a false-green "upgrade" that never exercised a migration.
|
||||
if (scenario.kind === 'upgrade') {
|
||||
const seedSql = scenario.seedRelative ? join(scenario.dir, scenario.seedRelative, 'dump.sql') : null;
|
||||
if (!seedSql || !existsSync(seedSql)) {
|
||||
const msg = seedSql
|
||||
? `upgrade scenario has no seed dump at ${seedSql}`
|
||||
: 'upgrade scenario declares no seed dir';
|
||||
logFriction({
|
||||
runId: ctx.runId,
|
||||
phase: 'seed',
|
||||
message: msg,
|
||||
severity: 'blocker',
|
||||
hint: 'upgrade runs need a real dump.sql to measure the migration (see the TODOS entry for the v0.18 seed dump)',
|
||||
source: 'harness',
|
||||
agent: 'scripted',
|
||||
});
|
||||
console.error(`[seed] ${msg}`);
|
||||
return 1;
|
||||
}
|
||||
const dbPath = pgliteDbPath(ctx.gbrainHome);
|
||||
mkdirSync(join(ctx.gbrainHome, '.gbrain'), { recursive: true });
|
||||
const { seedPgliteFromFile } = await import('../core/claw-test/seed-pglite.ts');
|
||||
try {
|
||||
await seedPgliteFromFile({ dbPath, sqlPath: seedSql });
|
||||
console.log(`[seed] replayed ${seedSql} → ${dbPath}`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logFriction({
|
||||
runId: ctx.runId,
|
||||
phase: 'seed',
|
||||
message: `seed replay failed: ${msg}`,
|
||||
severity: 'blocker',
|
||||
source: 'harness',
|
||||
agent: 'scripted',
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const allStderr: string[] = [];
|
||||
const outcomes: PhaseOutcome[] = [];
|
||||
for (const phase of phases) {
|
||||
const outcome = await invokeGbrain(opts.gbrainBin ?? 'gbrain', phase.argv, ctx.runRoot, childEnv);
|
||||
const outcome = await invokeGbrain(opts.gbrainBin, phase.argv, ctx.runRoot, childEnv);
|
||||
outcome.phase = phase.name;
|
||||
outcomes.push(outcome);
|
||||
allStderr.push(outcome.stderrTail);
|
||||
@@ -240,7 +367,7 @@ async function runScripted(
|
||||
severity: 'error',
|
||||
hint: outcome.stderrTail.trim().slice(0, 500),
|
||||
source: 'harness',
|
||||
agent: opts.agent,
|
||||
agent: 'scripted',
|
||||
});
|
||||
return 1;
|
||||
} else {
|
||||
@@ -251,7 +378,7 @@ async function runScripted(
|
||||
kind: 'phase-marker',
|
||||
marker: 'end',
|
||||
source: 'harness',
|
||||
agent: opts.agent,
|
||||
agent: 'scripted',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -268,7 +395,7 @@ async function runScripted(
|
||||
severity: 'blocker',
|
||||
hint: 'either the command did not run or it did not emit progress events; check phase log above',
|
||||
source: 'harness',
|
||||
agent: opts.agent,
|
||||
agent: 'scripted',
|
||||
});
|
||||
}
|
||||
return 1;
|
||||
@@ -281,6 +408,19 @@ async function runScripted(
|
||||
// Live mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Per-agent install hints for the agent_detect blocker. */
|
||||
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',
|
||||
};
|
||||
|
||||
/**
|
||||
* Live mode. Hermeticity posture (deliberate, documented): live mode runs the
|
||||
* OPERATOR's configured agent — the real agent home (~/.openclaw, ~/.hermes,
|
||||
* model settings, skills) is inherited — against a HERMETIC BRAIN
|
||||
* (GBRAIN_HOME=tempdir). The fully hermetic lane is the door e2e
|
||||
* (install-real-hermes.serial.test.ts), which isolates the agent home too.
|
||||
*/
|
||||
async function runLive(
|
||||
opts: HarnessOpts,
|
||||
scenario: ScenarioConfig,
|
||||
@@ -302,17 +442,49 @@ async function runLive(
|
||||
phase: 'agent_detect',
|
||||
message: `agent ${opts.agent} not available: ${detected.reason ?? 'unknown'}`,
|
||||
severity: 'blocker',
|
||||
hint: opts.agent === 'openclaw' ? 'install openclaw or set OPENCLAW_BIN' : undefined,
|
||||
hint: AGENT_INSTALL_HINTS[opts.agent],
|
||||
source: 'harness',
|
||||
agent: opts.agent,
|
||||
});
|
||||
return 2;
|
||||
}
|
||||
|
||||
// ---- Stage the scenario (scenario-driven; mirrors the scripted branch) ----
|
||||
// The BRIEF's preconditions must actually exist before the agent reads it:
|
||||
// fresh-install promises "workspace already has an AGENTS.md", "3 small
|
||||
// markdown pages already there" (./brain), and "user just ran gbrain init".
|
||||
// Without staging, every live run starts in an empty tempdir and the run
|
||||
// measures recovery-from-broken-fixture, not gbrain friction.
|
||||
const childEnv = buildChildEnv(ctx);
|
||||
const gbrainBin = opts.gbrainBin;
|
||||
const stageFailed = await stageLiveScenario(opts, scenario, ctx, childEnv, gbrainBin);
|
||||
if (stageFailed !== 0) return stageFailed;
|
||||
|
||||
// Upgrade oracle needs the pre-turn schema version (non-mutating probe —
|
||||
// any gbrain CLI connect would auto-apply migrations and do the agent's
|
||||
// work for it).
|
||||
const dbPath = pgliteDbPath(ctx.gbrainHome);
|
||||
let preVersion: number | null = null;
|
||||
if (scenario.kind === 'upgrade') {
|
||||
const { readPgliteSchemaVersion } = await import('../core/claw-test/seed-pglite.ts');
|
||||
preVersion = await readPgliteSchemaVersion(dbPath);
|
||||
}
|
||||
|
||||
// ---- PATH shim: the BRIEF says `gbrain …`; make bare `gbrain` resolve to
|
||||
// THIS harness's binary (operator PATH may have none, or a stale global). ----
|
||||
const shimDir = join(ctx.runRoot, '.harness-bin');
|
||||
mkdirSync(shimDir, { recursive: true });
|
||||
const shimPath = join(shimDir, 'gbrain');
|
||||
// Single-quoted: validateBinPathEnv rejects quote/metacharacter values, so
|
||||
// the interpolation cannot break out of the quoting.
|
||||
writeFileSync(shimPath, `#!/bin/sh\nexec '${gbrainBin}' "$@"\n`, 'utf-8');
|
||||
chmodSync(shimPath, 0o755);
|
||||
|
||||
const sink = createTranscriptSink(ctx.transcriptPath);
|
||||
const env: Record<string, string> = {
|
||||
GBRAIN_HOME: ctx.gbrainHome,
|
||||
GBRAIN_FRICTION_RUN_ID: ctx.runId,
|
||||
PATH: `${shimDir}:${process.env.PATH ?? ''}`,
|
||||
};
|
||||
|
||||
const brief = readBrief(scenario);
|
||||
@@ -322,7 +494,7 @@ async function runLive(
|
||||
cwd: ctx.runRoot,
|
||||
brief,
|
||||
env,
|
||||
timeoutMs: SUBPROCESS_TIMEOUT_MS,
|
||||
timeoutMs: LIVE_AGENT_TIMEOUT_MS,
|
||||
transcriptSink: sink,
|
||||
});
|
||||
} finally {
|
||||
@@ -340,9 +512,270 @@ async function runLive(
|
||||
});
|
||||
return result.exitCode;
|
||||
}
|
||||
|
||||
// ---- Success oracle: exit code alone passes an agent that did nothing. ----
|
||||
return verifyLiveOutcome(opts, scenario, ctx, childEnv, gbrainBin, preVersion);
|
||||
}
|
||||
|
||||
/** Stage the workspace per scenario.kind before the agent turn. Returns 0 or a failing exit code. */
|
||||
async function stageLiveScenario(
|
||||
opts: HarnessOpts,
|
||||
scenario: ScenarioConfig,
|
||||
ctx: { runId: string; runRoot: string; gbrainHome: string },
|
||||
childEnv: Record<string, string>,
|
||||
gbrainBin: string,
|
||||
): Promise<number> {
|
||||
const failStage = (message: string, hint?: string): number => {
|
||||
logFriction({
|
||||
runId: ctx.runId,
|
||||
phase: 'stage',
|
||||
message,
|
||||
severity: 'blocker',
|
||||
hint,
|
||||
source: 'harness',
|
||||
agent: opts.agent,
|
||||
});
|
||||
console.error(`[stage] ${message}`);
|
||||
return 1;
|
||||
};
|
||||
|
||||
if (scenario.kind === 'upgrade') {
|
||||
// Seed ONLY — running init here would walk the migration chain forward
|
||||
// and do the very upgrade the agent turn is supposed to perform (any
|
||||
// gbrain connect auto-migrates). Same seed-first order as scripted mode.
|
||||
if (scenario.seedRelative) {
|
||||
const seedSql = join(scenario.dir, scenario.seedRelative, 'dump.sql');
|
||||
if (existsSync(seedSql)) {
|
||||
const dbPath = pgliteDbPath(ctx.gbrainHome);
|
||||
mkdirSync(join(ctx.gbrainHome, '.gbrain'), { recursive: true });
|
||||
const { seedPgliteFromFile } = await import('../core/claw-test/seed-pglite.ts');
|
||||
try {
|
||||
await seedPgliteFromFile({ dbPath, sqlPath: seedSql });
|
||||
console.log(`[stage] replayed ${seedSql} → ${dbPath}`);
|
||||
} catch (e) {
|
||||
return failStage(`seed replay failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
} else {
|
||||
return failStage(`upgrade scenario has no seed dump at ${seedSql}`, 'upgrade runs need a real dump.sql to measure the migration (see the TODOS entry for the v0.18 seed dump)');
|
||||
}
|
||||
} else {
|
||||
return failStage('upgrade scenario declares no seed dir');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// fresh-install: copy the scenario's brain pages + an AGENTS.md stub, then
|
||||
// init the brain (the BRIEF says the user "just ran gbrain init").
|
||||
if (scenario.brainRelative) {
|
||||
const src = join(scenario.dir, scenario.brainRelative);
|
||||
if (!existsSync(src)) {
|
||||
// Fail loudly, matching the upgrade branch's missing-seed blocker: a
|
||||
// silent skip here would fail the query oracle later with the
|
||||
// misleading hint "the agent likely skipped the import step" when the
|
||||
// real cause is a broken fixture.
|
||||
return failStage(`fresh-install scenario declares brain dir ${scenario.brainRelative} but it does not exist at ${src}`);
|
||||
}
|
||||
cpSync(src, join(ctx.runRoot, 'brain'), { recursive: true });
|
||||
}
|
||||
const agentsMd = join(ctx.runRoot, 'AGENTS.md');
|
||||
if (!existsSync(agentsMd)) {
|
||||
// Deliberately references NO skill files: staging creates none, and a row
|
||||
// pointing at a missing SKILL.md flips doctor's resolver_health to fail
|
||||
// (caught in rehearsal). Post-v0.33 scaffolded skills route via their own
|
||||
// frontmatter triggers, so a prose stub satisfies the BRIEF's
|
||||
// "workspace already has an AGENTS.md routing file" precondition.
|
||||
writeFileSync(
|
||||
agentsMd,
|
||||
'# Workspace routing\n\nSkills scaffolded under `skills/` route via their frontmatter `triggers:`.\n',
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
const init = await invokeGbrain(gbrainBin, ['init', '--pglite', '--no-embedding'], ctx.runRoot, childEnv);
|
||||
if (init.exitCode !== 0) {
|
||||
return failStage(`gbrain init failed during staging (exit ${init.exitCode})`, init.stderrTail.trim().slice(0, 500));
|
||||
}
|
||||
console.log('[stage] fresh-install workspace staged (brain pages + AGENTS.md + init)');
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Post-turn verification. Logs friction (phase 'verify') and returns 1 on any failure. */
|
||||
async function verifyLiveOutcome(
|
||||
opts: HarnessOpts,
|
||||
scenario: ScenarioConfig,
|
||||
ctx: { runId: string; runRoot: string; gbrainHome: string },
|
||||
childEnv: Record<string, string>,
|
||||
gbrainBin: string,
|
||||
preVersion: number | null,
|
||||
): Promise<number> {
|
||||
const failures: { message: string; hint?: string }[] = [];
|
||||
|
||||
if (scenario.kind === 'upgrade') {
|
||||
// The upgrade oracle is the schema version reaching LATEST during the
|
||||
// agent turn, read via the non-mutating direct-PGLite probe (doctor/any
|
||||
// CLI connect would apply the migrations itself and mask a do-nothing
|
||||
// agent). MUST run before any declared query oracle below — the query's
|
||||
// own CLI connect migrates, which would corrupt a later version read.
|
||||
const { readPgliteSchemaVersion } = await import('../core/claw-test/seed-pglite.ts');
|
||||
const { LATEST_VERSION } = await import('../core/migrate.ts');
|
||||
const dbPath = pgliteDbPath(ctx.gbrainHome);
|
||||
const postVersion = await readPgliteSchemaVersion(dbPath);
|
||||
if (preVersion === null || postVersion === null) {
|
||||
failures.push({ message: `upgrade oracle: schema version unreadable (pre=${preVersion} post=${postVersion})` });
|
||||
} else if (postVersion <= preVersion) {
|
||||
failures.push({
|
||||
message: `upgrade oracle: schema version did not advance during the agent turn (pre=${preVersion} post=${postVersion})`,
|
||||
hint: 'the agent never ran a gbrain command that walks the migration chain',
|
||||
});
|
||||
} else if (postVersion < LATEST_VERSION) {
|
||||
// Advancing one step is not an upgrade: any gbrain connect migrates to
|
||||
// latest, so a partial version means the agent's run died mid-chain.
|
||||
failures.push({
|
||||
message: `upgrade oracle: schema version advanced but stopped short of latest (pre=${preVersion} post=${postVersion} latest=${LATEST_VERSION})`,
|
||||
hint: 'a gbrain command started the migration chain but did not complete it',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// doctor: fresh --no-embedding brains report status "warnings" (observed
|
||||
// — embedding setup is deferred by design), so healthy AND warnings both
|
||||
// pass. Anything else — including output the harness cannot parse — is a
|
||||
// failure: an oracle that shrugs at unparsable output fails open.
|
||||
const doc = await invokeGbrain(gbrainBin, ['doctor', '--json'], ctx.runRoot, childEnv, { captureFullStdout: true });
|
||||
if (doc.exitCode !== 0) {
|
||||
failures.push({ message: `verify: doctor exited ${doc.exitCode}`, hint: doc.stderrTail.trim().slice(0, 300) });
|
||||
} else {
|
||||
const report = parseLastJson(doc.stdoutFull ?? doc.stdoutTail);
|
||||
const status = report && typeof report === 'object' ? (report as Record<string, unknown>).status : undefined;
|
||||
if (report === null || typeof status !== 'string') {
|
||||
failures.push({ message: 'verify: doctor exited 0 but its JSON output was unparsable' });
|
||||
} else if (status !== 'healthy' && status !== 'warnings') {
|
||||
failures.push({ message: `verify: doctor reports status ${JSON.stringify(status)}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A declared oracle is enforced for EVERY kind (loadScenario validates it
|
||||
// for every kind — accepting config it never enforces would be a silent
|
||||
// no-op; an upgrade agent that migrates the schema but loses the seeded
|
||||
// data must still fail a declared query oracle).
|
||||
const oracle = scenario.oracle;
|
||||
if (oracle?.query) {
|
||||
const q = await invokeGbrain(gbrainBin, ['query', oracle.query, '--json'], ctx.runRoot, childEnv, { captureFullStdout: true });
|
||||
const parsed = q.exitCode === 0 ? parseLastJson(q.stdoutFull ?? q.stdoutTail) : null;
|
||||
const min = oracle.minResults ?? 1;
|
||||
// query --json emits a bare array; anything else on a zero exit means the
|
||||
// command's contract broke — fail even when min_results is 0, because
|
||||
// "0 results required" never licenses unparsable output.
|
||||
if (q.exitCode === 0 && !Array.isArray(parsed)) {
|
||||
failures.push({
|
||||
message: `verify: query ${JSON.stringify(oracle.query)} exited 0 but its JSON output was unparsable`,
|
||||
});
|
||||
} else {
|
||||
const count = Array.isArray(parsed) ? parsed.length : 0;
|
||||
if (q.exitCode !== 0 || count < min) {
|
||||
failures.push({
|
||||
message: `verify: query ${JSON.stringify(oracle.query)} returned ${count} result(s), expected >= ${min} (exit ${q.exitCode})`,
|
||||
hint: 'the agent likely skipped the import step from the brief',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const rel of oracle?.filesExist ?? []) {
|
||||
if (!existsSync(join(ctx.runRoot, rel))) {
|
||||
failures.push({
|
||||
message: `verify: expected file missing after run: ${rel}`,
|
||||
hint: 'the agent likely skipped a brief step that produces this file',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const f of failures) {
|
||||
logFriction({
|
||||
runId: ctx.runId,
|
||||
phase: 'verify',
|
||||
message: f.message,
|
||||
severity: 'error',
|
||||
hint: f.hint,
|
||||
source: 'harness',
|
||||
agent: opts.agent,
|
||||
});
|
||||
console.error(`[verify] ${f.message}`);
|
||||
}
|
||||
return failures.length ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the trailing JSON document from CLI stdout (defensive: banners or
|
||||
* notices may precede the payload).
|
||||
*/
|
||||
function parseLastJson(stdout: string): unknown {
|
||||
const text = stdout.trim();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch { /* fall through */ }
|
||||
const starts = ['{', '['];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (starts.includes(text[i])) {
|
||||
try {
|
||||
return JSON.parse(text.slice(i));
|
||||
} catch { /* keep scanning */ }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Refuse to import child friction files larger than this — the file lives in
|
||||
* a workspace the AGENT writes to, so its size is untrusted. */
|
||||
const CHILD_FRICTION_MAX_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* E0: merge the child-side friction file (written under the run's hermetic
|
||||
* GBRAIN_HOME) into the parent process's friction dir so it survives tempdir
|
||||
* cleanup. Best-effort — a merge failure never fails the run.
|
||||
*
|
||||
* The child file is UNTRUSTED input (in live mode the agent can write
|
||||
* arbitrary bytes at that path), and the destination is the operator's
|
||||
* permanent friction log: require a regular file (no symlink — an agent-
|
||||
* dropped link could import any readable file on the box), cap the size, and
|
||||
* append only lines that parse as JSON objects so the log stays valid JSONL.
|
||||
* Exported for tests.
|
||||
*/
|
||||
export function mergeChildFriction(runRoot: string, runId: string): void {
|
||||
try {
|
||||
const childFile = join(runRoot, '.gbrain', 'friction', `${runId}.jsonl`);
|
||||
const st = lstatSync(childFile, { throwIfNoEntry: false });
|
||||
if (!st) return;
|
||||
if (!st.isFile()) {
|
||||
console.error(`[friction] skipping child friction merge: ${childFile} is not a regular file`);
|
||||
return;
|
||||
}
|
||||
if (st.size > CHILD_FRICTION_MAX_BYTES) {
|
||||
console.error(`[friction] skipping child friction merge: ${childFile} is ${st.size} bytes (cap ${CHILD_FRICTION_MAX_BYTES})`);
|
||||
return;
|
||||
}
|
||||
const parentFile = frictionFile(runId);
|
||||
if (resolve(childFile) === resolve(parentFile)) return;
|
||||
const raw = readFileSync(childFile, 'utf-8');
|
||||
if (!raw.trim()) return;
|
||||
const kept: string[] = [];
|
||||
let skipped = 0;
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) kept.push(line);
|
||||
else skipped++;
|
||||
} catch {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
if (skipped) console.error(`[friction] child friction merge skipped ${skipped} non-JSONL line(s)`);
|
||||
if (!kept.length) return;
|
||||
const dir = frictionDir();
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(parentFile, kept.join('\n') + '\n', 'utf-8');
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subprocess helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -352,17 +785,60 @@ function invokeGbrain(
|
||||
argv: string[],
|
||||
cwd: string,
|
||||
env: Record<string, string>,
|
||||
invokeOpts?: { captureFullStdout?: boolean },
|
||||
): Promise<PhaseOutcome> {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolvePromise) => {
|
||||
const start = Date.now();
|
||||
const child = spawn(bin, argv, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], shell: false });
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
child.stdout?.on('data', (b: Buffer) => stdout.push(b));
|
||||
child.stderr?.on('data', (b: Buffer) => stderr.push(b));
|
||||
|
||||
// A hung child (e.g. a leaked PGLite lock holder from the agent turn)
|
||||
// must not wedge the harness/CI job forever: SIGTERM at the phase cap,
|
||||
// SIGKILL if it lingers.
|
||||
let timedOut = false;
|
||||
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const wallClockTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
||||
killTimer = setTimeout(() => {
|
||||
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
}, 10_000);
|
||||
}, SUBPROCESS_TIMEOUT_MS);
|
||||
const clearTimers = () => {
|
||||
clearTimeout(wallClockTimer);
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
};
|
||||
|
||||
let settled = false;
|
||||
const settle = (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimers();
|
||||
let stderrText = Buffer.concat(stderr).toString('utf-8');
|
||||
if (timedOut) stderrText += `\nharness: killed after ${SUBPROCESS_TIMEOUT_MS}ms phase timeout`;
|
||||
const stdoutText = Buffer.concat(stdout).toString('utf-8');
|
||||
resolvePromise({
|
||||
phase: '',
|
||||
exitCode: typeof code === 'number' ? code : (timedOut ? 124 : 1),
|
||||
durationMs: Date.now() - start,
|
||||
stderrEvents: parseProgressEvents(stderrText).length,
|
||||
stdoutTail: tailOf(stdoutText),
|
||||
stderrTail: stderrText,
|
||||
// The 4KB tail is fine for logging but NOT for parsing JSON payloads
|
||||
// (doctor --json exceeds it and would lose its opening brace).
|
||||
...(invokeOpts?.captureFullStdout ? { stdoutFull: stdoutText } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimers();
|
||||
const stderrJoined = Buffer.concat(stderr).toString('utf-8') + '\nspawn error: ' + err.message;
|
||||
resolve({
|
||||
resolvePromise({
|
||||
phase: '',
|
||||
exitCode: 127,
|
||||
durationMs: Date.now() - start,
|
||||
@@ -371,16 +847,13 @@ function invokeGbrain(
|
||||
stderrTail: tailOf(stderrJoined),
|
||||
});
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
const stderrText = Buffer.concat(stderr).toString('utf-8');
|
||||
resolve({
|
||||
phase: '',
|
||||
exitCode: typeof code === 'number' ? code : 1,
|
||||
durationMs: Date.now() - start,
|
||||
stderrEvents: parseProgressEvents(stderrText).length,
|
||||
stdoutTail: tailOf(Buffer.concat(stdout).toString('utf-8')),
|
||||
stderrTail: stderrText,
|
||||
});
|
||||
// 'close' (pipes drained) is the clean path; 'exit' + grace covers a
|
||||
// grandchild that inherits the pipes and outlives the kill — without it a
|
||||
// timed-out phase whose child leaked a subprocess would wedge forever.
|
||||
child.on('close', (code) => settle(code));
|
||||
child.on('exit', (code) => {
|
||||
const t = setTimeout(() => settle(code), 2_000);
|
||||
t.unref?.();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -402,7 +875,7 @@ function parseArgs(args: string[]): HarnessOpts {
|
||||
keepTempdir: false,
|
||||
listAgents: false,
|
||||
help: args.includes('--help') || args.includes('-h'),
|
||||
gbrainBin: process.env.GBRAIN_BIN_OVERRIDE || process.execPath,
|
||||
gbrainBin: resolveGbrainBin(),
|
||||
};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
@@ -415,6 +888,60 @@ function parseArgs(args: string[]): HarnessOpts {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the gbrain binary child invocations use. GBRAIN_BIN_OVERRIDE goes
|
||||
* through the same absolute/no-dotdot/no-metacharacter validation as the
|
||||
* *_BIN runner overrides — the value is interpolated into a generated
|
||||
* PATH-shim script in live mode, so a relative value would re-resolve through
|
||||
* the shimmed PATH and self-exec forever, and quoting-hostile characters
|
||||
* would become code. Invalid overrides are rejected loudly (stderr) and the
|
||||
* harness falls back to the current executable.
|
||||
*
|
||||
* The fallback is NOT bare process.execPath: under `bun run src/cli.ts` (the
|
||||
* canonical source install) or a bun-global launcher, execPath is the Bun
|
||||
* RUNTIME, and children would run `bun init` / `bun import` instead of gbrain
|
||||
* — scaffolding a Bun project in the hermetic workspace and failing the rest
|
||||
* of the run. When execPath looks like bun, synthesize a launcher shim that
|
||||
* re-enters this checkout's cli.ts; only a compiled gbrain binary returns
|
||||
* execPath directly.
|
||||
*/
|
||||
let cachedGbrainBin: string | null = null;
|
||||
|
||||
function resolveGbrainBin(): string {
|
||||
if (cachedGbrainBin) return cachedGbrainBin;
|
||||
cachedGbrainBin = resolveGbrainBinUncached();
|
||||
return cachedGbrainBin;
|
||||
}
|
||||
|
||||
function resolveGbrainBinUncached(): string {
|
||||
const override = process.env.GBRAIN_BIN_OVERRIDE?.trim();
|
||||
if (override) {
|
||||
const invalid = validateBinPathEnv('GBRAIN_BIN_OVERRIDE', override);
|
||||
if (!invalid) return override;
|
||||
console.error(`ignoring ${invalid}; falling back to the current executable`);
|
||||
}
|
||||
const exe = process.execPath;
|
||||
if (/^bun(-profile)?(\.exe)?$/i.test(basename(exe))) {
|
||||
// src/commands/claw-test.ts → ../cli.ts. Under a compiled binary this
|
||||
// branch never fires (execPath is the gbrain binary itself); under bun
|
||||
// (dev checkout or bun-global install) import.meta resolves to the real
|
||||
// source file next to cli.ts.
|
||||
const cliTs = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'cli.ts');
|
||||
if (existsSync(cliTs) && !/['\n\r]/.test(exe) && !/['\n\r]/.test(cliTs)) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-launcher-'));
|
||||
const launcher = join(dir, 'gbrain');
|
||||
writeFileSync(launcher, `#!/bin/sh\nexec '${exe}' '${cliTs}' "$@"\n`, 'utf-8');
|
||||
chmodSync(launcher, 0o755);
|
||||
return launcher;
|
||||
}
|
||||
console.error(
|
||||
'claw-test: running under the bun runtime but the gbrain CLI entrypoint could not be located — ' +
|
||||
'child gbrain invocations would run bun itself. Set GBRAIN_BIN_OVERRIDE to a gbrain binary.',
|
||||
);
|
||||
}
|
||||
return exe;
|
||||
}
|
||||
|
||||
function newRunId(agent: string): string {
|
||||
const now = new Date();
|
||||
const ts = now.toISOString().replace(/[-:]/g, '').replace(/\..*/, '').replace('T', '-');
|
||||
@@ -422,23 +949,30 @@ function newRunId(agent: string): string {
|
||||
return `claw-test-${ts}-${agent}-${suf}`;
|
||||
}
|
||||
|
||||
function cmdListAgents(): number {
|
||||
async function cmdListAgents(): Promise<number> {
|
||||
const names = listRegisteredAgents();
|
||||
if (!names.length) {
|
||||
console.log('no agents registered');
|
||||
return 0;
|
||||
}
|
||||
for (const name of names) {
|
||||
// Detect concurrently but AWAIT all of them, then print in
|
||||
// listRegisteredAgents() order (the accessor sorts alphabetically).
|
||||
// The prior fire-and-forget .then() version returned before any detection
|
||||
// resolved, so output could vanish in CLI teardown.
|
||||
const lines = await Promise.all(names.map(async (name) => {
|
||||
try {
|
||||
const runner = resolveAgentRunner(name);
|
||||
runner.detect().then((d) => {
|
||||
const status = d.available ? `available at ${d.binPath}` : `unavailable: ${d.reason}`;
|
||||
console.log(`${name}: ${status}`);
|
||||
}).catch(() => { /* best effort */ });
|
||||
try {
|
||||
const d = await runner.detect();
|
||||
return `${name}: ${d.available ? `available at ${d.binPath}` : `unavailable: ${d.reason}`}`;
|
||||
} catch {
|
||||
return `${name}: (detect error)`;
|
||||
}
|
||||
} catch {
|
||||
console.log(`${name}: (factory error)`);
|
||||
return `${name}: (factory error)`;
|
||||
}
|
||||
}
|
||||
}));
|
||||
for (const line of lines) console.log(line);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -456,8 +990,13 @@ Defaults:
|
||||
Scripted mode runs canonical commands without an LLM (CI gate).
|
||||
Live mode spawns a real agent and lets it drive (~5–10 min, costs tokens).
|
||||
|
||||
Live mode runs YOUR configured agent (it may read/write your real agent home,
|
||||
e.g. ~/.openclaw or ~/.hermes) against a hermetic brain. The door e2e suite is
|
||||
the fully hermetic lane.
|
||||
|
||||
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 openclaw
|
||||
gbrain claw-test --live --agent hermes`);
|
||||
}
|
||||
|
||||
+342
-19
@@ -782,6 +782,124 @@ export async function checkSourceConfigShape(engine: BrainEngine): Promise<Check
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #2674 — pglite_scratch_probe: distinguish a damaged PGLite store from a
|
||||
* broken WASM runtime.
|
||||
*
|
||||
* PGLite reports only `Aborted()` to JS (the PANIC goes to its own stderr),
|
||||
* so when init fails, the error string cannot say WHICH of the two it is.
|
||||
* The probe initializes a throwaway store in a temp dir, round-trips a row,
|
||||
* and reads the outcome:
|
||||
*
|
||||
* - scratch works, real init failed → the runtime is fine; the failure is
|
||||
* specific to YOUR store. The store-damage verdict is only ASSERTED when
|
||||
* the caller supplies positive evidence (`storeDamageEvidence`: a
|
||||
* damage-class disk diagnosis from `inspectPgliteDataDir`, or a
|
||||
* wasm-abort/corrupt classification of the real init error). engine=null
|
||||
* alone also covers locks and config refusals — blaming the store for
|
||||
* those was the original false-positive defect; without evidence the
|
||||
* message hedges and points at the `pglite_data_dir` diagnosis instead.
|
||||
* - scratch fails too → the runtime cannot start on this machine; report
|
||||
* OS + Bun versions on #223.
|
||||
*
|
||||
* COST GATE: a PGLite cold start is 5–20s on loaded machines, so this never
|
||||
* runs on a routine `gbrain doctor`. It runs only when (a) the real PGLite
|
||||
* engine actually failed to open (engine=null, not --fast, configured engine
|
||||
* is pglite) AND the disk diagnosis didn't already fully explain the failure
|
||||
* (a live lock / missing dir needs no runtime probe), or (b) the operator
|
||||
* asks with `--probe-pglite`.
|
||||
*
|
||||
* `probeFn` is a test seam so message routing can be pinned without paying
|
||||
* real cold starts.
|
||||
*/
|
||||
export async function checkPgliteScratchProbe(opts: {
|
||||
realInitFailed: boolean;
|
||||
/**
|
||||
* Positive evidence the REAL store is damaged: `inspectPgliteDataDir`
|
||||
* verdict wal-corruption-likely/unsupported-layout (buildChecks path) or a
|
||||
* wasm-abort/corrupt classification of the actual connect error (remote
|
||||
* path). Without it the scratch-ok arm hedges instead of asserting damage.
|
||||
*/
|
||||
storeDamageEvidence?: boolean;
|
||||
realStorePath?: string;
|
||||
probeFn?: () => Promise<import('../core/pglite-engine.ts').PgliteScratchProbeResult>;
|
||||
}): Promise<Check> {
|
||||
const name = 'pglite_scratch_probe';
|
||||
try {
|
||||
const probe =
|
||||
opts.probeFn ??
|
||||
(async () => {
|
||||
const { probePgliteScratchStore } = await import('../core/pglite-engine.ts');
|
||||
return probePgliteScratchStore(opts.realStorePath);
|
||||
});
|
||||
const r = await probe();
|
||||
const secs = (r.duration_ms / 1000).toFixed(1);
|
||||
if (r.ok) {
|
||||
if (opts.realInitFailed && opts.storeDamageEvidence) {
|
||||
return {
|
||||
name,
|
||||
status: 'fail',
|
||||
message:
|
||||
`A scratch PGLite store initialized, wrote and read back fine on this machine (${secs}s), ` +
|
||||
`so the runtime is healthy and YOUR STORE is damaged — not the WASM runtime. ` +
|
||||
`Your markdown is unaffected: the DB holds derived data (chunks, embeddings, links, facts) that a re-sync rebuilds. ` +
|
||||
`Recover: \`gbrain pglite-repair --dry-run\` to diagnose, \`gbrain pglite-repair --yes\` for in-place WAL repair (data preserved); ` +
|
||||
`if that can't fix it, restore a backup of the store directory or run \`gbrain reinit-pglite\` (wipes + re-inits + re-syncs; ` +
|
||||
`defaults embedding flags from your config file).`,
|
||||
details: { scratch_ok: true, duration_ms: r.duration_ms },
|
||||
};
|
||||
}
|
||||
if (opts.realInitFailed) {
|
||||
// Runtime proven healthy, but no independent evidence of store DAMAGE
|
||||
// — engine=null also covers locks, config refusals, and transient
|
||||
// failures. Hedge rather than convict the store (#2674 review).
|
||||
return {
|
||||
name,
|
||||
status: 'warn',
|
||||
message:
|
||||
`A scratch PGLite store initialized, wrote and read back fine on this machine (${secs}s), ` +
|
||||
`so the WASM runtime is healthy — the failure opening your brain is specific to your store, ` +
|
||||
`its lock, or its configuration. See the \`pglite_data_dir\` check for the on-disk diagnosis; ` +
|
||||
`\`gbrain pglite-repair --dry-run\` diagnoses without mutating anything.`,
|
||||
details: { scratch_ok: true, duration_ms: r.duration_ms },
|
||||
};
|
||||
}
|
||||
return {
|
||||
name,
|
||||
status: 'ok',
|
||||
message: `PGLite runtime healthy: scratch store round-trip in ${secs}s.`,
|
||||
details: { scratch_ok: true, duration_ms: r.duration_ms },
|
||||
};
|
||||
}
|
||||
const errLine = (r.error ?? 'unknown error').split('\n')[0];
|
||||
if (opts.realInitFailed) {
|
||||
return {
|
||||
name,
|
||||
status: 'fail',
|
||||
message:
|
||||
`A fresh scratch PGLite store ALSO failed to start (${secs}s), so the WASM runtime cannot run ` +
|
||||
`on this machine — your store is not necessarily damaged. Report your OS and Bun versions on ` +
|
||||
`https://github.com/garrytan/gbrain/issues/223. Scratch error: ${errLine}`,
|
||||
details: { scratch_ok: false, duration_ms: r.duration_ms, error: r.error, verdict: r.verdict },
|
||||
};
|
||||
}
|
||||
return {
|
||||
name,
|
||||
status: 'warn',
|
||||
message:
|
||||
`Your real store opened, but a fresh scratch PGLite store failed to initialize (${secs}s) — ` +
|
||||
`new stores can't be created on this machine. Report your OS and Bun versions on ` +
|
||||
`https://github.com/garrytan/gbrain/issues/223. Scratch error: ${errLine}`,
|
||||
details: { scratch_ok: false, duration_ms: r.duration_ms, error: r.error, verdict: r.verdict },
|
||||
};
|
||||
} catch (e) {
|
||||
// Includes the never-touch-the-real-store guard refusal. The probe not
|
||||
// running is a diagnostic gap, not a diagnosis — warn, don't fail.
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { name, status: 'warn', message: `scratch probe could not run: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function doctorReportRemote(
|
||||
engine: BrainEngine,
|
||||
opts: { sourceIds?: string[] } = {},
|
||||
@@ -804,6 +922,23 @@ export async function doctorReportRemote(
|
||||
status: 'fail',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
// #2674: on PGLite, a dead connection is exactly the ambiguous case the
|
||||
// scratch probe exists for — pay its cold start only on this failure path.
|
||||
// Unlike buildChecks (where the connect error was swallowed upstream), the
|
||||
// real error IS in hand here: classify it, and only let the probe assert
|
||||
// store damage on a damage-class verdict (wasm-abort/corrupt) — a lock or
|
||||
// config refusal classifies 'unknown' and gets the hedged message.
|
||||
if (engine.kind === 'pglite') {
|
||||
let realStorePath: string | undefined;
|
||||
try { realStorePath = loadConfig()?.database_path; } catch { /* no config */ }
|
||||
let storeDamageEvidence = false;
|
||||
try {
|
||||
const { classifyPgliteInitError, stringifyPgliteInitError } = await import('../core/pglite-engine.ts');
|
||||
const verdict = classifyPgliteInitError(stringifyPgliteInitError(e));
|
||||
storeDamageEvidence = verdict === 'wasm-abort' || verdict === 'corrupt';
|
||||
} catch { /* classifier unavailable — stay hedged (fail-closed) */ }
|
||||
checks.push(await checkPgliteScratchProbe({ realInitFailed: true, storeDamageEvidence, realStorePath }));
|
||||
}
|
||||
// Without a connection, every other check is meaningless — short-circuit.
|
||||
return computeDoctorReport(checks);
|
||||
}
|
||||
@@ -1150,8 +1285,7 @@ export function checkSelfUpgradeHealth(): Check {
|
||||
const { loadConfig } = require('../core/config.ts');
|
||||
const {
|
||||
resolveSelfUpgradeMode,
|
||||
readUpdateCache,
|
||||
isCacheFresh,
|
||||
pendingUpgradeVersion,
|
||||
} = require('../core/self-upgrade.ts');
|
||||
const { readRecentSelfUpgrades } = require('../core/audit/self-upgrade-audit.ts');
|
||||
|
||||
@@ -1166,9 +1300,11 @@ export function checkSelfUpgradeHealth(): Check {
|
||||
}
|
||||
|
||||
const parts: string[] = [`mode=${mode}`];
|
||||
const entry = readUpdateCache();
|
||||
if (entry && isCacheFresh(entry, Date.now()) && entry.marker.kind === 'upgrade_available') {
|
||||
parts.push(`update available: ${entry.marker.current} -> ${entry.marker.latest} (run: gbrain self-upgrade)`);
|
||||
// Shared stale/foreign-cache guard: only report an upgrade strictly newer
|
||||
// than the RUNNING binary (pendingUpgradeVersion owns the rule).
|
||||
const pendingLatest = pendingUpgradeVersion(GBRAIN_BINARY_VERSION, Date.now());
|
||||
if (pendingLatest) {
|
||||
parts.push(`update available: ${GBRAIN_BINARY_VERSION} -> ${pendingLatest} (run: gbrain self-upgrade)`);
|
||||
}
|
||||
const failedVersions: string[] = cfg?.self_upgrade?.failed_versions ?? [];
|
||||
if (failedVersions.length > 0) {
|
||||
@@ -1746,6 +1882,8 @@ export async function checkVoiceGateHealth(engine: BrainEngine): Promise<Check>
|
||||
* Below that they're noise; reranker fails open anyway.
|
||||
* 5) Payload-too-large failures: warn at >=1 (indicates a workload
|
||||
* mismatch that the operator should know about).
|
||||
* 6) Budget/pricing failures: warn at >=1 with the rerank pricing surface
|
||||
* and --max-cost escape hatch.
|
||||
*
|
||||
* Engine-agnostic (file-based + one config-key read).
|
||||
*/
|
||||
@@ -1784,6 +1922,15 @@ export async function checkRerankerHealth(engine: BrainEngine): Promise<Check> {
|
||||
};
|
||||
}
|
||||
|
||||
const budgetFails = failures.filter((f) => f.reason === 'budget');
|
||||
if (budgetFails.length > 0) {
|
||||
return {
|
||||
name: 'reranker_health',
|
||||
status: 'warn',
|
||||
message: `${budgetFails.length} reranker budget/pricing failure(s) in last 7 days. Fix: add rerank pricing to src/core/embedding-pricing.ts or drop --max-cost.`,
|
||||
};
|
||||
}
|
||||
|
||||
const transientFails = failures.filter(
|
||||
(f) => f.reason === 'network' || f.reason === 'timeout' || f.reason === 'rate_limit',
|
||||
);
|
||||
@@ -2501,6 +2648,131 @@ export async function checkZeEmbeddingHealth(engine: BrainEngine): Promise<Check
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* provider_sunset doctor check (#3390 follow-up).
|
||||
*
|
||||
* Detects a brain whose EFFECTIVE embedding model (gateway-resolved, which is
|
||||
* how default-config brains land on the shipped default) is on a provider
|
||||
* with an announced hosted-API shutdown, and prints a paste-ready migration
|
||||
* command with the brain's ACTUAL `content_chunks.embedding` column width
|
||||
* filled in — not the config value, which can drift. Keeping the current
|
||||
* width avoids a needless dimension transition + index rebuild when the
|
||||
* target supports it.
|
||||
*
|
||||
* Unlike the one-shot upgrade banner (`ze_sunset_notice_shown`), this fires
|
||||
* on every `gbrain doctor` run until the brain is off the provider —
|
||||
* warn before the shutdown date; fail after it ONLY when the brain is
|
||||
* actually exposed (embedded vectors exist in the affected column, so
|
||||
* retrieval is genuinely down). A zero-vector brain whose config merely
|
||||
* RESOLVES to the dead default stays warn — otherwise every stock fresh
|
||||
* install (and every doctor-as-CI-gate) starts exiting 1 on the date with
|
||||
* no code change. Suppress entirely (accepted-risk installs) via
|
||||
* `gbrain config set doctor.suppress_provider_sunset true`.
|
||||
* No network call; one catalog query for the column width.
|
||||
*
|
||||
* `now` is injectable so tests can pin BOTH sides of the date without
|
||||
* waiting for the calendar (the date itself is a compile-time constant).
|
||||
*/
|
||||
export async function checkProviderSunset(engine: BrainEngine, now: number = Date.now()): Promise<Check> {
|
||||
const name = 'provider_sunset';
|
||||
try {
|
||||
const suppressed = await engine.getConfig('doctor.suppress_provider_sunset').catch(() => null);
|
||||
if (suppressed === 'true' || suppressed === '1') {
|
||||
return {
|
||||
name,
|
||||
status: 'ok',
|
||||
message: 'Check suppressed via doctor.suppress_provider_sunset (unset it to re-enable).',
|
||||
};
|
||||
}
|
||||
const { DEFAULT_EMBEDDING_MODEL, ZEROENTROPY_SUNSET_DATE } = await import('../core/ai/defaults.ts');
|
||||
// Effective model: gateway when configured (file/env plane, the runtime
|
||||
// truth); the shipped default otherwise — an unset-config brain resolves
|
||||
// to the default at runtime, so it is just as affected.
|
||||
let model = DEFAULT_EMBEDDING_MODEL;
|
||||
try {
|
||||
const { getEmbeddingModel } = await import('../core/ai/gateway.ts');
|
||||
model = getEmbeddingModel();
|
||||
} catch {
|
||||
// Gateway unconfigured — runtime resolves the shipped default.
|
||||
}
|
||||
// Effective reranker: resolve through the SAME plane search actually
|
||||
// reranks with — resolveSearchMode (mode bundle + search.reranker.*
|
||||
// config overrides; hybrid.ts passes `resolvedMode.reranker_model`).
|
||||
// The gateway plane is unset by default while balanced/tokenmax rerank
|
||||
// with the bundle's zeroentropyai model — reading the gateway here
|
||||
// would false-ok the exact brains this check exists to protect.
|
||||
let reranker: string | undefined;
|
||||
try {
|
||||
const { loadSearchModeConfig, resolveSearchMode } = await import('../core/search/mode.ts');
|
||||
const knobs = resolveSearchMode(await loadSearchModeConfig(engine));
|
||||
if (knobs.reranker_enabled) reranker = knobs.reranker_model;
|
||||
} catch {
|
||||
// Mode resolution failed — make no reranker-exposure claim.
|
||||
}
|
||||
const onSunsetEmbedding = model.startsWith('zeroentropyai:');
|
||||
const onSunsetReranker = !!reranker?.startsWith('zeroentropyai:');
|
||||
if (!onSunsetEmbedding && !onSunsetReranker) {
|
||||
return {
|
||||
name,
|
||||
status: 'ok',
|
||||
message: `No configured provider has an announced shutdown (embedding: ${model}).`,
|
||||
};
|
||||
}
|
||||
const past = now >= Date.parse(`${ZEROENTROPY_SUNSET_DATE}T00:00:00Z`);
|
||||
const parts: string[] = [];
|
||||
let hasVectors = false;
|
||||
if (onSunsetEmbedding) {
|
||||
let dims: number | null = null;
|
||||
try {
|
||||
const { readContentChunksEmbeddingDim } = await import('../core/embedding-dim-check.ts');
|
||||
dims = (await readContentChunksEmbeddingDim(engine)).dims;
|
||||
} catch {
|
||||
// Column probe failed (fresh/odd brain) — omit --dim from the hint.
|
||||
}
|
||||
try {
|
||||
const rows = await engine.executeRaw(
|
||||
`SELECT 1 AS one FROM content_chunks WHERE embedding IS NOT NULL LIMIT 1`,
|
||||
);
|
||||
hasVectors = rows.length > 0;
|
||||
} catch {
|
||||
// Probe failed (fresh/odd brain) — no exposure claim, warn-only.
|
||||
}
|
||||
const dimFlag = dims ? ` --dim ${dims}` : '';
|
||||
parts.push(
|
||||
past
|
||||
? hasVectors
|
||||
? `embedding_model="${model}": the hosted API shut down on ${ZEROENTROPY_SUNSET_DATE} — semantic retrieval is offline (queries can no longer be embedded against your existing vectors).`
|
||||
: `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.`,
|
||||
);
|
||||
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.` : ''),
|
||||
);
|
||||
}
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
if (onSunsetEmbedding || onSunsetReranker) {
|
||||
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
|
||||
// exist on the dead provider). Reranker-only exposure stays warn — search
|
||||
// fails open to unreranked ordering (degraded, not down).
|
||||
const failNow = past && onSunsetEmbedding && hasVectors;
|
||||
return { name, status: failNow ? 'fail' : 'warn', message: parts.join(' ') };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { name, status: 'warn', message: `Could not check provider sunset status: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.36.0.0 (A5): embedding_width_consistency doctor check.
|
||||
*
|
||||
@@ -5605,7 +5877,15 @@ export async function buildChecks(
|
||||
if (lastStarted && engine) {
|
||||
const queue = typeof lastStarted.queue === 'string' ? lastStarted.queue : 'default';
|
||||
const effectiveMaxRss = typeof lastStarted.max_rss_mb === 'number' ? lastStarted.max_rss_mb : null;
|
||||
const localPid = readSupervisorPid(DEFAULT_PID_FILE).pid;
|
||||
// The 'started' event already records the pid-file path actually in use
|
||||
// (this.opts.pidFile, which reflects a custom --pid-file). Prefer that
|
||||
// over re-deriving DEFAULT_PID_FILE locally so a custom --pid-file
|
||||
// deployment doesn't false-positive a singleton mismatch against itself.
|
||||
// Falls back to DEFAULT_PID_FILE when the event carries no usable value.
|
||||
const pidFilePath = typeof lastStarted.pid_file === 'string' && lastStarted.pid_file.length > 0
|
||||
? lastStarted.pid_file
|
||||
: DEFAULT_PID_FILE;
|
||||
const localPid = readSupervisorPid(pidFilePath).pid;
|
||||
const localHost = hostname();
|
||||
|
||||
// Read the DB singleton lock holder for this queue.
|
||||
@@ -6313,24 +6593,62 @@ export async function buildChecks(
|
||||
// Filesystem read failure is non-fatal.
|
||||
}
|
||||
|
||||
// 3d. PGLite data-dir diagnosis (WAL-repair wave). Only meaningful when the
|
||||
// connect already FAILED on a PGLite brain (engine === null): the connect
|
||||
// error was swallowed by the fs-only fallback, so this check re-derives the
|
||||
// dir state from disk and names the repair ladder. Skipped under --fast
|
||||
// (connect wasn't attempted, so "engine === null" proves nothing there).
|
||||
if (!fastMode && !engine) {
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
if (cfg?.engine === 'pglite') {
|
||||
// 3d. PGLite data-dir diagnosis (WAL-repair wave) + scratch-store probe
|
||||
// (#2674). The data-dir check re-derives the failure state from DISK (the
|
||||
// connect error was swallowed by the fs-only fallback); the probe adds the
|
||||
// RUNTIME dimension (a throwaway store that opens fine proves the WASM
|
||||
// runtime is healthy). Both only fire when the connect already FAILED on a
|
||||
// PGLite brain (engine === null, not --fast — under --fast connect wasn't
|
||||
// attempted, so "engine === null" proves nothing there).
|
||||
//
|
||||
// Probe cost gate (a PGLite cold start is 5–20s): auto-runs ONLY when init
|
||||
// failed AND the disk diagnosis didn't already fully explain it — a live
|
||||
// lock or a missing dir needs no runtime probe (and 'locked' was exactly
|
||||
// the reviewed false-positive: blaming the store while `gbrain serve` held
|
||||
// it). Explicit --probe-pglite always runs it. A routine healthy
|
||||
// `gbrain doctor` never pays it.
|
||||
{
|
||||
const probeRequested = args.includes('--probe-pglite');
|
||||
let cfgForProbe: ReturnType<typeof loadConfig> = null;
|
||||
try { cfgForProbe = loadConfig(); } catch { /* no config — nothing to diagnose */ }
|
||||
const pgliteInitFailed = !engine && !fastMode && cfgForProbe?.engine === 'pglite';
|
||||
|
||||
let dirVerdict: import('../core/pglite-repair.ts').PgliteDirDiagnosis['verdict'] | undefined;
|
||||
if (pgliteInitFailed) {
|
||||
try {
|
||||
const { inspectPgliteDataDir } = await import('../core/pglite-repair.ts');
|
||||
const { resolve } = await import('node:path');
|
||||
// Absolutize: a RELATIVE database_path would make the sidecar/backup
|
||||
// lookups resolve against doctor's cwd instead of the engine's.
|
||||
const pgliteDataDir = resolve(cfg.database_path || gbrainPath('brain.pglite'));
|
||||
checks.push(computePgliteDataDirCheck(pgliteDataDir, inspectPgliteDataDir(pgliteDataDir)));
|
||||
const pgliteDataDir = resolve(cfgForProbe!.database_path || gbrainPath('brain.pglite'));
|
||||
const diagnosis = inspectPgliteDataDir(pgliteDataDir);
|
||||
dirVerdict = diagnosis.verdict;
|
||||
checks.push(computePgliteDataDirCheck(pgliteDataDir, diagnosis));
|
||||
} catch {
|
||||
// Best-effort: an unreadable config or fs failure must not stop doctor.
|
||||
}
|
||||
}
|
||||
|
||||
const dirExplainsFailure = dirVerdict === 'locked' || dirVerdict === 'missing';
|
||||
if (probeRequested || (pgliteInitFailed && !dirExplainsFailure)) {
|
||||
progress.start('doctor.pglite_probe');
|
||||
const stopHb = startHeartbeat(progress, 'pglite scratch-store probe (cold start, can take 5–20s)…');
|
||||
try {
|
||||
checks.push(
|
||||
await checkPgliteScratchProbe({
|
||||
// A lock/missing dir explains the failure without the store being
|
||||
// damaged — an explicit --probe-pglite there still reports on the
|
||||
// runtime, but must not treat the store as the convicted party.
|
||||
realInitFailed: pgliteInitFailed && !dirExplainsFailure,
|
||||
storeDamageEvidence:
|
||||
dirVerdict === 'wal-corruption-likely' || dirVerdict === 'unsupported-layout',
|
||||
realStorePath: cfgForProbe?.database_path,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
stopHb();
|
||||
progress.finish();
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: an unreadable config or fs failure must not stop doctor.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8336,6 +8654,11 @@ export async function buildChecks(
|
||||
// v0.36.0.0 (A5): ZE embedding key health + schema/config width consistency.
|
||||
progress.heartbeat('ze_embedding_health');
|
||||
checks.push(await checkZeEmbeddingHealth(engine));
|
||||
// provider_sunset — brain pinned to a provider with an announced
|
||||
// hosted-API shutdown; paste-ready migration hint with the actual
|
||||
// column width. Warn before the date, fail after.
|
||||
progress.heartbeat('provider_sunset');
|
||||
checks.push(await checkProviderSunset(engine));
|
||||
progress.heartbeat('embedding_width_consistency');
|
||||
checks.push(await checkEmbeddingWidthConsistency(engine));
|
||||
// v0.41.15.0 (T6, codex #19/#20) — facts.embedding column drift
|
||||
|
||||
+265
-4
@@ -198,6 +198,22 @@ export interface EmbedResult {
|
||||
failure_samples: string[];
|
||||
/** True if this run was a dry-run. */
|
||||
dryRun: boolean;
|
||||
/**
|
||||
* Chunkless-page safety net (`--stale` only): pages with non-empty
|
||||
* content but zero `content_chunks` rows that this run chunked (or, in
|
||||
* dryRun, would chunk) so their new NULL-embedding chunks fold into the
|
||||
* SAME pass. 0 on a healthy brain. Additive field — see
|
||||
* `ChunklessPageRow` for the detection rationale.
|
||||
*/
|
||||
chunkless_pages_healed: number;
|
||||
/**
|
||||
* Set when a single-flight run did NO work because another backfill holds
|
||||
* the per-source embed lock. A hard-killed (SIGKILL/crash) run leaves its
|
||||
* lock behind for up to EMBED_BACKFILL_LOCK_TTL_MIN — callers that promise
|
||||
* "re-run to resume" (migrate embeddings) use this to say so instead of
|
||||
* misreporting embed failures.
|
||||
*/
|
||||
lock_skipped?: boolean;
|
||||
/**
|
||||
* E1 (paced-backfill): end-of-run pacing telemetry. Present ONLY when pacing
|
||||
* was active (enabled bundle). The number the operator could not get from an
|
||||
@@ -317,6 +333,7 @@ export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promis
|
||||
failures: 0,
|
||||
failure_samples: [],
|
||||
dryRun: !!opts.dryRun,
|
||||
chunkless_pages_healed: 0,
|
||||
};
|
||||
|
||||
if (opts.slugs && opts.slugs.length > 0) {
|
||||
@@ -375,6 +392,7 @@ export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promis
|
||||
try { await h.release(); } catch { /* best-effort */ }
|
||||
}
|
||||
serr(` [embed] another backfill is already running for source "${sid}"; skipping (single-flight).`);
|
||||
result.lock_skipped = true;
|
||||
return result;
|
||||
}
|
||||
sfLocks.push(lock);
|
||||
@@ -529,6 +547,7 @@ export async function runEmbed(engine: BrainEngine, args: string[]): Promise<Emb
|
||||
return {
|
||||
embedded: 0, skipped: 0, would_embed: 0, total_chunks: 0,
|
||||
pages_processed: 0, failures: 0, failure_samples: [], dryRun: false,
|
||||
chunkless_pages_healed: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -988,6 +1007,201 @@ async function embedAll(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunkless-page safety net for `embed --stale`. `listStaleChunks` /
|
||||
* `countStaleChunks` only ever look at `content_chunks` rows where
|
||||
* `embedding IS NULL` — a page written directly via `putPage` that never
|
||||
* went through chunking (e.g. an enrichment-generated entity stub) has NO
|
||||
* chunk row at all, so it is invisible to that scan forever, even after
|
||||
* unlimited `embed --stale` runs.
|
||||
*
|
||||
* This sweep finds pages with non-empty content (`compiled_truth` and/or
|
||||
* `timeline` — both are chunked independently, mirroring `embedPage`'s
|
||||
* chunkless branch) and zero `content_chunks` rows
|
||||
* (`engine.listChunklessPagesWithContent`, which already excludes
|
||||
* quarantined + embed_skip pages — both intentionally chunkless). The new
|
||||
* chunk rows land with `embedding = NULL`, so they flow into the SAME
|
||||
* `embed --stale` pass via the existing cursor below — no separate embed
|
||||
* step needed here.
|
||||
*
|
||||
* dryRun chunks locally (a pure, in-memory operation) to report an
|
||||
* accurate count without writing anything, matching embedPage's dry-run
|
||||
* contract (including `pages_processed`, which embedPage's own dry-run
|
||||
* branch increments for exactly this "examined, didn't write" case).
|
||||
*
|
||||
* Race note (review catch, three rounds — ACCEPTED RESIDUAL RISK, not
|
||||
* fully closed): between listing a page and writing its chunks, a
|
||||
* concurrent writer (sync, another `put_page`) could change or chunk the
|
||||
* SAME page. Two mitigations, both bounded — full atomicity (a
|
||||
* transaction/version-guarded conditional write inside `upsertChunks`)
|
||||
* would need a new engine primitive shared by every `upsertChunks` caller,
|
||||
* which is out of scope for a chunkless-page safety net:
|
||||
* 1. Immediately before writing, re-fetch the LIVE page via `getPage`
|
||||
* and build `inputs` from ITS CURRENT content, not the batch-list
|
||||
* snapshot — closes the "content changed but still chunkless"
|
||||
* sub-case, not just the "chunks appeared" one.
|
||||
* 2. Re-check `getChunks` right after that same fetch — skip (don't
|
||||
* overwrite) if chunks now exist AT THE TIME OF THE CHECK.
|
||||
* What this does NOT close: a writer that inserts chunks in the gap
|
||||
* BETWEEN step 2's check and the `upsertChunks` call immediately below it
|
||||
* (no intervening `await` other than that one call, but `upsertChunks`
|
||||
* itself is not conditioned on the check — this is still check-then-write,
|
||||
* not compare-and-swap) can still have its chunks overwritten — HONESTLY:
|
||||
* `upsertChunks` treats its input as the full desired chunk set for that
|
||||
* page and deletes any existing chunk_index absent from it, so a
|
||||
* concurrent writer's chunks landing in that exact gap CAN be replaced
|
||||
* with this sweep's stale-content chunks (embedding NULL). This is the
|
||||
* SAME check-then-write window `embedPage`'s existing single-page
|
||||
* chunkless branch already ships with today (that branch doesn't even
|
||||
* have step 2's re-check) — no new race CLASS is introduced, and the
|
||||
* window here is a single sequential getPage+getChunks+upsertChunks
|
||||
* instead of spanning a whole batch. The blast radius is bounded: the
|
||||
* page is NOT deleted or corrupted, just re-chunked from a stale
|
||||
* snapshot, and the NEXT write to that page (sync, another edit) that
|
||||
* actually chunks it restores correct content — this sweep's own
|
||||
* predicate is idempotent and doesn't compound the drift. Closing this
|
||||
* fully (true atomicity) is tracked as a follow-up, not blocking this
|
||||
* safety net.
|
||||
*
|
||||
* Per-page failure isolation (review catch): one malformed/oversized
|
||||
* chunkless page must not abort the sweep and, with it, the entire
|
||||
* `--stale` run before the normal NULL-embedding pass even starts — that
|
||||
* would make the safety net WORSE than the bug it fixes. Each page's
|
||||
* work is try/caught; a failure is recorded (`EmbedResult.failures` +
|
||||
* `failure_samples`, same convention as every other embed failure path)
|
||||
* and the sweep moves on.
|
||||
*
|
||||
* Bounded, keyset-paginated (like listStalePagesForExtraction) — a safety
|
||||
* net for a rare drift case, not the primary bulk-chunking path. `BATCH_SIZE`
|
||||
* is deliberately small (unlike the 2000-chunk-row default elsewhere in
|
||||
* this file): each row here carries a FULL page body (`compiled_truth` +
|
||||
* `timeline`), so a large batch of large pages is a real memory/latency
|
||||
* concern the metadata-only `listStaleChunks` rows never had (review
|
||||
* catch). It still respects the caller's pacer (no-op when pacing is off)
|
||||
* and a soft wall-clock cap (`GBRAIN_EMBED_TIME_BUDGET_MS`) so a
|
||||
* pathologically large damaged brain can't run this sweep unbounded — it
|
||||
* heals what it can and reports the rest for the next `embed --stale` run
|
||||
* (the SQL predicate is idempotent; nothing here requires finishing in one
|
||||
* pass). `startedAt` is shared with the caller's overall run clock (review
|
||||
* catch) — healing and the main stale loop draw from ONE combined budget
|
||||
* window, not two independent 30-minute ones. `catchUp` mirrors the main
|
||||
* loop's own `--catch-up` handling: removes the cap entirely (the keyset
|
||||
* cursor still terminates on its own; `signal` remains the abort path).
|
||||
*/
|
||||
async function healChunklessPages(
|
||||
engine: BrainEngine,
|
||||
sourceId: string | undefined,
|
||||
dryRun: boolean,
|
||||
result: EmbedResult,
|
||||
quiet: boolean | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
pacer: DbPacer | undefined,
|
||||
startedAt: number,
|
||||
catchUp: boolean,
|
||||
): Promise<void> {
|
||||
const BATCH_SIZE = 50;
|
||||
const BUDGET_MS: number | null = catchUp
|
||||
? null
|
||||
: parseInt(process.env.GBRAIN_EMBED_TIME_BUDGET_MS || `${30 * 60 * 1000}`, 10);
|
||||
const activePacer = pacer ?? createNoopPacer();
|
||||
let afterPageId: number | undefined;
|
||||
let pagesHealed = 0;
|
||||
let budgetExceeded = false;
|
||||
|
||||
const buildInputs = (compiledTruth: string, timeline: string): ChunkInput[] => {
|
||||
const inputs: ChunkInput[] = [];
|
||||
if (compiledTruth.trim()) {
|
||||
for (const c of chunkText(compiledTruth)) {
|
||||
inputs.push({ chunk_index: inputs.length, chunk_text: c.text, chunk_source: 'compiled_truth' });
|
||||
}
|
||||
}
|
||||
if (timeline.trim()) {
|
||||
for (const c of chunkText(timeline)) {
|
||||
inputs.push({ chunk_index: inputs.length, chunk_text: c.text, chunk_source: 'timeline' });
|
||||
}
|
||||
}
|
||||
return inputs;
|
||||
};
|
||||
// BUDGET_MS === null means catch-up: no wall-clock cap on this sweep,
|
||||
// mirroring the main stale loop's own --catch-up handling below.
|
||||
const overBudget = (): boolean => BUDGET_MS != null && Date.now() - startedAt > BUDGET_MS;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
if (isAborted(signal)) break;
|
||||
if (overBudget()) { budgetExceeded = true; break; }
|
||||
const batch = await observed(activePacer, () => engine.listChunklessPagesWithContent({
|
||||
batchSize: BATCH_SIZE,
|
||||
...(afterPageId != null && { afterPageId }),
|
||||
...(sourceId && { sourceId }),
|
||||
}));
|
||||
if (batch.length === 0) break;
|
||||
afterPageId = batch[batch.length - 1].id;
|
||||
|
||||
for (const page of batch) {
|
||||
if (isAborted(signal)) break;
|
||||
if (overBudget()) { budgetExceeded = true; break; }
|
||||
|
||||
try {
|
||||
if (dryRun) {
|
||||
// dryRun never writes, so there's no live-refetch race to close —
|
||||
// chunk the listed snapshot directly (matches embedPage's own
|
||||
// dry-run, which chunks whatever getPage returned at call time).
|
||||
const inputs = buildInputs(page.compiled_truth, page.timeline);
|
||||
// Whitespace-only content (SQL prefilter is `<> ''`, not
|
||||
// trim-aware) chunks to nothing — matches embedPage's contract.
|
||||
if (inputs.length === 0) continue;
|
||||
result.total_chunks += inputs.length;
|
||||
result.would_embed += inputs.length;
|
||||
result.pages_processed++;
|
||||
pagesHealed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-fetch the LIVE page + re-check chunks immediately before
|
||||
// writing (see race note above): chunk CURRENT content, and skip
|
||||
// rather than clobber if a concurrent writer already chunked this
|
||||
// page since we listed it.
|
||||
const [livePage, stillChunkless] = await Promise.all([
|
||||
observed(activePacer, () => engine.getPage(page.slug, { sourceId: page.source_id })),
|
||||
observed(activePacer, () => engine.getChunks(page.slug, { sourceId: page.source_id })),
|
||||
]);
|
||||
if (!livePage || stillChunkless.length > 0) continue;
|
||||
const inputs = buildInputs(livePage.compiled_truth, livePage.timeline);
|
||||
if (inputs.length === 0) continue;
|
||||
|
||||
await observed(activePacer, () =>
|
||||
engine.upsertChunks(page.slug, inputs, { sourceId: page.source_id }),
|
||||
);
|
||||
pagesHealed++;
|
||||
try {
|
||||
await activePacer.pace(signal);
|
||||
} catch (e) {
|
||||
if (!(e instanceof AbortError)) throw e;
|
||||
}
|
||||
} catch (e) {
|
||||
if (isAborted(signal)) break;
|
||||
recordFailure(result, 1, page.slug, e);
|
||||
serr(`\n [embed] chunkless-page heal failed for ${page.slug}: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (budgetExceeded || batch.length < BATCH_SIZE) break;
|
||||
}
|
||||
|
||||
result.chunkless_pages_healed = pagesHealed;
|
||||
if (pagesHealed > 0 && !quiet) {
|
||||
if (dryRun) {
|
||||
serr(`[embed] [dry-run] would chunk ${pagesHealed} page(s) with non-empty content but zero content_chunks rows`);
|
||||
} else {
|
||||
serr(`[embed] chunked ${pagesHealed} page(s) that had non-empty content but zero content_chunks rows (embedding them in this pass)`);
|
||||
}
|
||||
}
|
||||
if (budgetExceeded && !quiet) {
|
||||
serr(`[embed] chunkless-page sweep hit its time budget (${BUDGET_MS}ms) with more pages left; re-run embed --stale to continue healing them`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL-side stale path: replaces the listPages + per-page getChunks
|
||||
* walk with a count + slug-grouped SELECT. Preserves the existing
|
||||
@@ -1028,11 +1242,38 @@ async function embedAllStale(
|
||||
signature?: string,
|
||||
externalSignal?: AbortSignal,
|
||||
) {
|
||||
// Shared wall-clock anchor (review catch): the healing sweep below and the
|
||||
// main stale loop's own budget timer (further down) both measure against
|
||||
// this SAME start time, so a run's total wall-clock spend stays capped at
|
||||
// ONE `GBRAIN_EMBED_TIME_BUDGET_MS` window instead of summing two
|
||||
// independent 30-minute budgets.
|
||||
const overallStartedAt = Date.now();
|
||||
|
||||
// D7: thread sourceId so source-scoped runs only count + visit
|
||||
// that source's NULL embeddings.
|
||||
const sourceOpt = sourceId ? { sourceId } : undefined;
|
||||
const includeNullSig = !!staleOpts?.includeNullSignature;
|
||||
|
||||
// Chunkless-page safety net: pre-flight count mirrors the countStaleChunks
|
||||
// short-circuit just below — a healthy brain pays one extra SELECT
|
||||
// count(*) and does no further work. Only when pages are actually found
|
||||
// do we pay for the keyset-paginated chunk sweep. Chunking here (before
|
||||
// countStaleChunks) means any newly-written NULL-embedding chunks flow
|
||||
// into the SAME pass via the existing cursor.
|
||||
const chunklessCount = await engine.countChunklessPagesWithContent(sourceOpt);
|
||||
if (chunklessCount > 0) {
|
||||
await healChunklessPages(
|
||||
engine, sourceId, dryRun, result, staleOpts?.quiet, externalSignal, staleOpts?.pacer,
|
||||
overallStartedAt, !!staleOpts?.catchUp,
|
||||
);
|
||||
}
|
||||
// Review catch: an abort during healing must stop the run HERE, before
|
||||
// falling through into invalidateStaleSignatureEmbeddings below (which —
|
||||
// pre-existing, unchanged by this PR — does not itself check
|
||||
// externalSignal). Without this, a caller-cancelled run could still NULL
|
||||
// out signature-drifted embeddings and exit, leaving retrieval degraded.
|
||||
if (isAborted(externalSignal)) return;
|
||||
|
||||
// v0.41.31: re-embed pages whose embedding_signature drifted (model/dims
|
||||
// swap). dry-run must NOT mutate, so it counts signature-stale via the
|
||||
// widened predicate; a live run NULLs them first so the existing
|
||||
@@ -1085,7 +1326,15 @@ async function embedAllStale(
|
||||
if (staleCount === 0) {
|
||||
if (!staleOpts?.quiet) {
|
||||
if (dryRun) {
|
||||
slog('[dry-run] Would embed 0 chunks (0 stale found)');
|
||||
// dryRun never writes, so a healed-but-hypothetical chunkless page's
|
||||
// chunks never land in content_chunks and staleCount can't see them
|
||||
// — report result.would_embed (already includes them) instead of a
|
||||
// bare "0 chunks" that would contradict the returned EmbedResult.
|
||||
if (result.would_embed > 0) {
|
||||
slog(`[dry-run] Would embed ${result.would_embed} chunks (0 stale found; ${result.chunkless_pages_healed} chunkless page(s) would be chunked)`);
|
||||
} else {
|
||||
slog('[dry-run] Would embed 0 chunks (0 stale found)');
|
||||
}
|
||||
} else {
|
||||
slog('Embedded 0 chunks (0 stale found)');
|
||||
}
|
||||
@@ -1101,7 +1350,16 @@ async function embedAllStale(
|
||||
// made `embed.pages` claim total:1 next to a summary naming a much larger
|
||||
// stale count. docs/progress-events.md allows omitting `total` when it is
|
||||
// not known up front; it does not allow asserting a wrong one.
|
||||
if (!staleOpts?.quiet) slog(`[dry-run] Would embed ${staleCount} stale chunks`);
|
||||
//
|
||||
// Log result.would_embed (staleCount + any chunkless-page-healing
|
||||
// contribution from above), not the bare staleCount — otherwise this
|
||||
// line understates the total whenever chunkless pages were also found.
|
||||
if (!staleOpts?.quiet) {
|
||||
const chunklessNote = result.chunkless_pages_healed > 0
|
||||
? `, including ${result.chunkless_pages_healed} chunkless page(s)`
|
||||
: '';
|
||||
slog(`[dry-run] Would embed ${result.would_embed} stale chunks${chunklessNote}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1132,9 +1390,12 @@ async function embedAllStale(
|
||||
? null
|
||||
: parseInt(process.env.GBRAIN_EMBED_TIME_BUDGET_MS || `${30 * 60 * 1000}`, 10);
|
||||
const budgetController = new AbortController();
|
||||
const budgetStart = Date.now();
|
||||
// Shares overallStartedAt with the chunkless-page healing sweep above
|
||||
// (review catch) so the two phases draw from ONE combined budget window
|
||||
// instead of each getting a fresh 30 minutes.
|
||||
const budgetStart = overallStartedAt;
|
||||
let budgetTimer = BUDGET_MS != null
|
||||
? setTimeout(() => budgetController.abort(), BUDGET_MS)
|
||||
? setTimeout(() => budgetController.abort(), Math.max(0, budgetStart + BUDGET_MS - Date.now()))
|
||||
: undefined;
|
||||
// E-4 (paced-backfill): the budget measures WORK, not waiting. After each
|
||||
// batch, re-arm the timer to fire at start + BUDGET + total-paced-sleep, so a
|
||||
|
||||
+12
-3
@@ -108,7 +108,11 @@ export async function runExport(engine: BrainEngine, args: string[]) {
|
||||
let exported = 0;
|
||||
|
||||
for (const page of pages) {
|
||||
const tags = await engine.getTags(page.slug);
|
||||
// Slugs are unique per source, not brain-wide, so both sidecar reads are
|
||||
// pinned to the page's own source. Unscoped, `getTags` falls back to
|
||||
// `source_id = 'default'` and stamps the default source's tags onto a
|
||||
// same-slug page from another source (dropping its real ones).
|
||||
const tags = await engine.getTags(page.slug, { sourceId: page.source_id });
|
||||
const md = serializeMarkdown(
|
||||
page.frontmatter,
|
||||
page.compiled_truth,
|
||||
@@ -120,8 +124,13 @@ export async function runExport(engine: BrainEngine, args: string[]) {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, md);
|
||||
|
||||
// Export raw data as sidecar JSON
|
||||
const rawData = await engine.getRawData(page.slug);
|
||||
// Export raw data as sidecar JSON. Unscoped, this matches the slug in
|
||||
// EVERY source and the loop below merges the rows into one sidecar keyed
|
||||
// by `rd.source`, so another source's raw data silently overwrites this
|
||||
// page's own on a key collision.
|
||||
const rawData = await engine.getRawData(page.slug, undefined, {
|
||||
sourceId: page.source_id,
|
||||
});
|
||||
if (rawData.length > 0) {
|
||||
const slugParts = page.slug.split('/');
|
||||
const rawDir = join(outDir, ...slugParts.slice(0, -1), '.raw');
|
||||
|
||||
+44
-4
@@ -81,8 +81,10 @@ const BATCH_SIZE = 100;
|
||||
const STALE_BATCH_SIZE = Math.max(1, Number(process.env.GBRAIN_EXTRACT_STALE_BATCH) || 25);
|
||||
// v0.42.7: wall-clock budget for one `extract --stale` invocation (default
|
||||
// 30 min). `--catch-up` removes the cap (loops until 0 stale). Mirrors
|
||||
// embedAllStale's time-budget shape.
|
||||
const STALE_TIME_BUDGET_MS = Math.max(1000, Number(process.env.GBRAIN_EXTRACT_TIME_BUDGET_MS) || 30 * 60 * 1000);
|
||||
// embedAllStale's time-budget shape. Exported so the #2849 deferred-sweep
|
||||
// submitters (sync's size-gate defer branch + the jobs continuation chain)
|
||||
// derive their job timeout_ms from the SAME budget instead of hardcoding.
|
||||
export const STALE_TIME_BUDGET_MS = Math.max(1000, Number(process.env.GBRAIN_EXTRACT_TIME_BUDGET_MS) || 30 * 60 * 1000);
|
||||
|
||||
/**
|
||||
* v0.42.7 (#1696): best-effort extraction stamp for the source-correct write
|
||||
@@ -488,15 +490,53 @@ export async function extractLinksFromFile(
|
||||
|
||||
// --- Timeline extraction ---
|
||||
|
||||
/**
|
||||
* Index of the first dash (—, –, -) that can serve as the Source — Summary
|
||||
* delimiter: it must have whitespace on both sides and sit outside every
|
||||
* markdown-link span. Hyphens inside link targets
|
||||
* (`../people/alice-example.md`) and dashes inside link labels
|
||||
* (`[Deals — Q1 Review](...)`) are content, not delimiters — splitting on
|
||||
* them shatters one entry into two fragments whose halves re-insert on
|
||||
* every sync (the (page_id, date, summary, source) uniqueness sees each
|
||||
* fragment shape as a new row). Returns -1 when the line has no delimiter.
|
||||
*/
|
||||
function findDelimiterOutsideLinks(text: string): number {
|
||||
let depth = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (c === '[' || c === '(') depth++;
|
||||
else if (c === ']' || c === ')') { if (depth > 0) depth--; }
|
||||
else if (
|
||||
depth === 0 &&
|
||||
(c === '—' || c === '–' || c === '-') &&
|
||||
i > 0 && /\s/.test(text[i - 1]) &&
|
||||
i + 1 < text.length && /\s/.test(text[i + 1])
|
||||
) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Extract timeline entries from markdown content */
|
||||
export function extractTimelineFromContent(content: string, slug: string): ExtractedTimelineEntry[] {
|
||||
const entries: ExtractedTimelineEntry[] = [];
|
||||
|
||||
// Format 1: Bullet — - **YYYY-MM-DD** | Source — Summary
|
||||
const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+?)\s*[—–-]\s*(.+)$/gm;
|
||||
// The delimiter search is link-aware (see findDelimiterOutsideLinks); a
|
||||
// bullet with no delimiter (e.g. an auto-generated backlink line
|
||||
// `- **date** | Referenced in [X](y.md)`) is kept whole as the summary
|
||||
// rather than dropped or fragmented.
|
||||
const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+)$/gm;
|
||||
let match;
|
||||
while ((match = bulletPattern.exec(content)) !== null) {
|
||||
entries.push({ slug, date: match[1], source: match[2].trim(), summary: match[3].trim() });
|
||||
const rest = match[2].trim();
|
||||
const at = findDelimiterOutsideLinks(rest);
|
||||
if (at >= 0) {
|
||||
entries.push({ slug, date: match[1], source: rest.slice(0, at).trim(), summary: rest.slice(at + 1).trim() });
|
||||
} else {
|
||||
entries.push({ slug, date: match[1], source: 'markdown', summary: rest });
|
||||
}
|
||||
}
|
||||
|
||||
// Format 2: Header — ### YYYY-MM-DD — Title
|
||||
|
||||
+324
-5
@@ -1,21 +1,24 @@
|
||||
/**
|
||||
* gbrain friction — friction reporter CLI.
|
||||
*
|
||||
* Four subcommands in v1 (analytical/clustering ones move to v1.1):
|
||||
* Five subcommands (remaining analytical ones — trend, migration-stub — stay v1.1):
|
||||
* gbrain friction log Append a friction or delight entry
|
||||
* gbrain friction render Render a run as markdown or JSON
|
||||
* gbrain friction list List recent runs with counts
|
||||
* gbrain friction summary Side-by-side friction + delight summary
|
||||
* gbrain friction diff Compare two runs (or agents): unique-to-each + shared-but-changed
|
||||
*
|
||||
* Subcommands stay thin (≤ ~30 LOC each). Core logic lives in src/core/friction.ts.
|
||||
* Subcommands stay thin (≤ ~30 LOC each). Reader/writer/redaction logic lives
|
||||
* in src/core/friction.ts; the diff computation lives here (it is CLI-only).
|
||||
*
|
||||
* The CLI is dispatched from src/cli.ts. See `gbrain friction --help`.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import {
|
||||
logFriction, readFriction, listRuns, renderReport, renderSummary,
|
||||
activeRunId, frictionFile,
|
||||
type FrictionKind, type FrictionSeverity,
|
||||
activeRunId, frictionFile, frictionDir, redactEntry,
|
||||
type FrictionKind, type FrictionSeverity, type FrictionEntry, type ReadResult,
|
||||
} from '../core/friction.ts';
|
||||
|
||||
const VALID_KINDS = new Set<FrictionKind>(['friction', 'delight', 'phase-marker', 'interrupted']);
|
||||
@@ -28,6 +31,7 @@ export function runFriction(args: string[]): number {
|
||||
case 'render': return cmdRender(rest);
|
||||
case 'list': return cmdList(rest);
|
||||
case 'summary': return cmdSummary(rest);
|
||||
case 'diff': return cmdDiff(rest);
|
||||
case undefined:
|
||||
case '--help':
|
||||
case '-h':
|
||||
@@ -150,6 +154,317 @@ function cmdSummary(args: string[]): number {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// diff
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Entry identity for diffing: (kind, phase, normalized message prefix) —
|
||||
* lowercase, whitespace-collapsed, digit runs collapsed (durations, counts,
|
||||
* and tempdir suffixes would otherwise make the same friction land in
|
||||
* "unique to each" across runs), first 80 chars, with redaction applied
|
||||
* FIRST (the caller redacts via redactEntry). Kind IS identity: a delight
|
||||
* and a friction with the same text are different findings, and a
|
||||
* delight→friction flip must surface, never compare equal. Severity is
|
||||
* deliberately EXCLUDED from identity — it is the compared attribute, as a
|
||||
* PER-SEVERITY MULTISET (two errors + one nit vs one error + two nits is a
|
||||
* reported difference even though the severity sets and totals match).
|
||||
*/
|
||||
const IDENTITY_PREFIX_CHARS = 80;
|
||||
|
||||
interface DiffIdentityRecord {
|
||||
kind: string;
|
||||
phase: string;
|
||||
/** Redacted message of the first occurrence (display sample). */
|
||||
message: string;
|
||||
count: number;
|
||||
/** Unique severities, sorted (display); counts live in severity_counts. */
|
||||
severities: string[];
|
||||
severity_counts: Record<string, number>;
|
||||
}
|
||||
|
||||
interface DiffChangedRecord {
|
||||
kind: string;
|
||||
phase: string;
|
||||
message: string;
|
||||
base_count: number;
|
||||
compare_count: number;
|
||||
base_severities: string[];
|
||||
compare_severities: string[];
|
||||
base_severity_counts: Record<string, number>;
|
||||
compare_severity_counts: Record<string, number>;
|
||||
count_changed: boolean;
|
||||
severity_changed: boolean;
|
||||
}
|
||||
|
||||
interface DiffRunBanner {
|
||||
run_id: string;
|
||||
agent?: string;
|
||||
scenario?: string;
|
||||
gbrain_version?: string;
|
||||
interrupted: boolean;
|
||||
/** Malformed JSONL lines skipped by the reader (surfaced, never hidden). */
|
||||
malformed: number;
|
||||
}
|
||||
|
||||
export interface FrictionDiffResult {
|
||||
base: string;
|
||||
compare: string;
|
||||
banner: { base: DiffRunBanner; compare: DiffRunBanner; warnings: string[] };
|
||||
unique_to_base: DiffIdentityRecord[];
|
||||
unique_to_compare: DiffIdentityRecord[];
|
||||
changed: DiffChangedRecord[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a run spec: an exact run-id wins; otherwise treat the spec as an
|
||||
* agent name and pick the LATEST run (listRuns is mtime-sorted, newest first)
|
||||
* whose entries carry that agent — the run-start marker stamps agent on clean
|
||||
* runs, and any agent-stamped entry also counts. Returns undefined if nothing
|
||||
* matches.
|
||||
*/
|
||||
export function resolveRunSpec(spec: string): string | undefined {
|
||||
try {
|
||||
if (existsSync(frictionFile(spec))) return spec;
|
||||
} catch { /* spec has characters a run-id can't; fall through to agent-name resolution */ }
|
||||
for (const run of listRuns()) {
|
||||
try {
|
||||
const { entries } = readFriction(run.runId);
|
||||
if (entries.some(e => e.agent === spec)) return run.runId;
|
||||
} catch { /* unreadable file; skip */ }
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeForIdentity(message: string): string {
|
||||
return message.toLowerCase().replace(/\d+/g, '#').replace(/\s+/g, ' ').trim().slice(0, IDENTITY_PREFIX_CHARS);
|
||||
}
|
||||
|
||||
interface IdentityAccum { kind: string; phase: string; message: string; count: number; severities: Map<string, number> }
|
||||
|
||||
/** Sorted-key plain object from a severity count map (deterministic JSON). */
|
||||
function severityCounts(m: Map<string, number>): Record<string, number> {
|
||||
return Object.fromEntries([...m.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
||||
}
|
||||
|
||||
/** Diff operates ONLY on kind friction|delight; markers/interruptions feed the banner. */
|
||||
function collectIdentities(entries: FrictionEntry[]): Map<string, IdentityAccum> {
|
||||
const map = new Map<string, IdentityAccum>();
|
||||
for (const raw of entries) {
|
||||
// Entries from older writers may omit kind; they are friction by contract.
|
||||
const kind = raw.kind ?? 'friction';
|
||||
if (kind !== 'friction' && kind !== 'delight') continue;
|
||||
const e = redactEntry(raw);
|
||||
const key = `${kind}\u0000${e.phase}\u0000${normalizeForIdentity(e.message)}`;
|
||||
let acc = map.get(key);
|
||||
if (!acc) {
|
||||
acc = { kind, phase: e.phase, message: e.message, count: 0, severities: new Map() };
|
||||
map.set(key, acc);
|
||||
}
|
||||
acc.count++;
|
||||
if (e.severity) acc.severities.set(e.severity, (acc.severities.get(e.severity) ?? 0) + 1);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function toIdentityRecord(acc: IdentityAccum): DiffIdentityRecord {
|
||||
return {
|
||||
kind: acc.kind,
|
||||
phase: acc.phase,
|
||||
message: acc.message,
|
||||
count: acc.count,
|
||||
severities: [...acc.severities.keys()].sort(),
|
||||
severity_counts: severityCounts(acc.severities),
|
||||
};
|
||||
}
|
||||
|
||||
function bannerFor(runId: string, read: ReadResult): DiffRunBanner {
|
||||
const start = read.entries.find(e => e.kind === 'phase-marker' && e.marker === 'start');
|
||||
const agent = start?.agent ?? read.entries.find(e => e.agent)?.agent;
|
||||
return {
|
||||
run_id: runId,
|
||||
agent,
|
||||
scenario: start?.scenario,
|
||||
gbrain_version: start?.gbrain_version ?? read.entries[0]?.gbrain_version,
|
||||
interrupted: read.entries.some(e => e.kind === 'interrupted'),
|
||||
malformed: read.malformed,
|
||||
};
|
||||
}
|
||||
|
||||
/** Compute the diff between two resolved run-ids. Throws on read errors. */
|
||||
export function computeFrictionDiff(baseRunId: string, compareRunId: string): FrictionDiffResult {
|
||||
const baseRead = readFriction(baseRunId);
|
||||
const compareRead = readFriction(compareRunId);
|
||||
const baseBanner = bannerFor(baseRunId, baseRead);
|
||||
const compareBanner = bannerFor(compareRunId, compareRead);
|
||||
|
||||
const warnings: string[] = [];
|
||||
if ((baseBanner.scenario ?? '') !== (compareBanner.scenario ?? '')) {
|
||||
warnings.push(`scenario differs: ${baseRunId} ran ${baseBanner.scenario ?? '(unknown)'}, ${compareRunId} ran ${compareBanner.scenario ?? '(unknown)'} — entries may not be comparable`);
|
||||
}
|
||||
if ((baseBanner.gbrain_version ?? '') !== (compareBanner.gbrain_version ?? '')) {
|
||||
warnings.push(`gbrain version differs: ${baseRunId} ran ${baseBanner.gbrain_version ?? '(unknown)'}, ${compareRunId} ran ${compareBanner.gbrain_version ?? '(unknown)'}`);
|
||||
}
|
||||
|
||||
const baseIds = collectIdentities(baseRead.entries);
|
||||
const compareIds = collectIdentities(compareRead.entries);
|
||||
const uniqueToBase: DiffIdentityRecord[] = [];
|
||||
const uniqueToCompare: DiffIdentityRecord[] = [];
|
||||
const changed: DiffChangedRecord[] = [];
|
||||
for (const [key, b] of baseIds) {
|
||||
const c = compareIds.get(key);
|
||||
if (!c) { uniqueToBase.push(toIdentityRecord(b)); continue; }
|
||||
const bCounts = severityCounts(b.severities);
|
||||
const cCounts = severityCounts(c.severities);
|
||||
const countChanged = b.count !== c.count;
|
||||
// severity_changed = the per-severity DISTRIBUTION SHAPE changed — a new
|
||||
// severity appeared/disappeared or the mix redistributed (2×error+1×nit →
|
||||
// 1×error+2×nit, which the unique-severity set hides). Uniform scaling
|
||||
// (1×error → 10×error) is purely a count change and count_changed already
|
||||
// reports it. Integer cross-multiplication keeps the proportion test
|
||||
// exact.
|
||||
const bTotal = Object.values(bCounts).reduce((a, n) => a + n, 0);
|
||||
const cTotal = Object.values(cCounts).reduce((a, n) => a + n, 0);
|
||||
let severityChanged = (bTotal === 0) !== (cTotal === 0);
|
||||
if (!severityChanged) {
|
||||
for (const s of new Set([...Object.keys(bCounts), ...Object.keys(cCounts)])) {
|
||||
if ((bCounts[s] ?? 0) * cTotal !== (cCounts[s] ?? 0) * bTotal) { severityChanged = true; break; }
|
||||
}
|
||||
}
|
||||
if (countChanged || severityChanged) {
|
||||
changed.push({
|
||||
kind: b.kind, phase: b.phase, message: b.message,
|
||||
base_count: b.count, compare_count: c.count,
|
||||
base_severities: Object.keys(bCounts), compare_severities: Object.keys(cCounts),
|
||||
base_severity_counts: bCounts, compare_severity_counts: cCounts,
|
||||
count_changed: countChanged, severity_changed: severityChanged,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [key, c] of compareIds) {
|
||||
if (!baseIds.has(key)) uniqueToCompare.push(toIdentityRecord(c));
|
||||
}
|
||||
const byPhaseThenMessage = (a: { phase: string; message: string }, b: { phase: string; message: string }) =>
|
||||
a.phase.localeCompare(b.phase) || a.message.localeCompare(b.message);
|
||||
uniqueToBase.sort(byPhaseThenMessage);
|
||||
uniqueToCompare.sort(byPhaseThenMessage);
|
||||
changed.sort(byPhaseThenMessage);
|
||||
|
||||
return {
|
||||
base: baseRunId,
|
||||
compare: compareRunId,
|
||||
banner: { base: baseBanner, compare: compareBanner, warnings },
|
||||
unique_to_base: uniqueToBase,
|
||||
unique_to_compare: uniqueToCompare,
|
||||
changed,
|
||||
};
|
||||
}
|
||||
|
||||
function groupByPhase<T extends { phase: string }>(records: T[]): Map<string, T[]> {
|
||||
const map = new Map<string, T[]>();
|
||||
for (const r of records) {
|
||||
if (!map.has(r.phase)) map.set(r.phase, []);
|
||||
map.get(r.phase)!.push(r);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function fmtSeverityCounts(counts: Record<string, number>): string {
|
||||
const parts = Object.entries(counts).map(([s, n]) => (n > 1 ? `${s}×${n}` : s));
|
||||
return parts.length > 0 ? parts.join('/') : '(none)';
|
||||
}
|
||||
|
||||
/** Render the diff as markdown. Sections are labeled by run-id — the diff is
|
||||
* an instrument, not a judge; it never attributes blame to either side. */
|
||||
function renderDiff(diff: FrictionDiffResult): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`# Friction diff — base \`${diff.base}\` vs compare \`${diff.compare}\``);
|
||||
lines.push('');
|
||||
for (const side of [diff.banner.base, diff.banner.compare]) {
|
||||
const bits = [
|
||||
`agent=${side.agent ?? '(unknown)'}`,
|
||||
`scenario=${side.scenario ?? '(unknown)'}`,
|
||||
`gbrain=${side.gbrain_version ?? '(unknown)'}`,
|
||||
];
|
||||
if (side.interrupted) bits.push('interrupted');
|
||||
if (side.malformed > 0) bits.push(`${side.malformed} malformed line(s) skipped`);
|
||||
lines.push(`- \`${side.run_id}\`: ${bits.join(' · ')}`);
|
||||
}
|
||||
lines.push('');
|
||||
for (const w of diff.banner.warnings) lines.push(`> ⚠ WARN: ${w}`);
|
||||
if (diff.banner.warnings.length > 0) lines.push('');
|
||||
|
||||
if (diff.unique_to_base.length === 0 && diff.unique_to_compare.length === 0 && diff.changed.length === 0) {
|
||||
lines.push('No differences.');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const renderIdentitySection = (title: string, records: DiffIdentityRecord[]) => {
|
||||
lines.push(`## ${title} (${records.length})`);
|
||||
lines.push('');
|
||||
if (records.length === 0) { lines.push('(none)'); lines.push(''); return; }
|
||||
for (const [phase, rs] of groupByPhase(records)) {
|
||||
lines.push(`### \`${phase}\``);
|
||||
lines.push('');
|
||||
for (const r of rs) {
|
||||
const sev = r.severities.length > 0 ? `[${r.severities.join('/')}] ` : '';
|
||||
const kind = r.kind === 'delight' ? '[delight] ' : '';
|
||||
const count = r.count > 1 ? ` ×${r.count}` : '';
|
||||
lines.push(`- ${kind}${sev}${r.message}${count}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
};
|
||||
renderIdentitySection(`Unique to \`${diff.compare}\``, diff.unique_to_compare);
|
||||
renderIdentitySection(`Unique to \`${diff.base}\``, diff.unique_to_base);
|
||||
|
||||
lines.push(`## Shared but changed (${diff.changed.length})`);
|
||||
lines.push('');
|
||||
if (diff.changed.length === 0) { lines.push('(none)'); lines.push(''); }
|
||||
for (const [phase, rs] of groupByPhase(diff.changed)) {
|
||||
lines.push(`### \`${phase}\``);
|
||||
lines.push('');
|
||||
for (const r of rs) {
|
||||
const deltas: string[] = [];
|
||||
if (r.severity_changed) deltas.push(`severity ${fmtSeverityCounts(r.base_severity_counts)} → ${fmtSeverityCounts(r.compare_severity_counts)}`);
|
||||
if (r.count_changed) deltas.push(`count ${r.base_count} → ${r.compare_count}`);
|
||||
const kind = r.kind === 'delight' ? '[delight] ' : '';
|
||||
lines.push(`- ${kind}${r.message} — ${deltas.join('; ')}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function cmdDiff(args: string[]): number {
|
||||
const flags = parseFlags(args);
|
||||
const baseSpec = flags.string('--base');
|
||||
const compareSpec = flags.string('--compare');
|
||||
if (!baseSpec || !compareSpec) {
|
||||
console.error('usage: gbrain friction diff --base <run-or-agent> --compare <run-or-agent> [--json]');
|
||||
return 2;
|
||||
}
|
||||
const baseRun = resolveRunSpec(baseSpec);
|
||||
const compareRun = resolveRunSpec(compareSpec);
|
||||
if (!baseRun || !compareRun) {
|
||||
const unresolved = [!baseRun ? baseSpec : undefined, !compareRun ? compareSpec : undefined]
|
||||
.filter((s): s is string => s !== undefined)
|
||||
.map(s => JSON.stringify(s)).join(', ');
|
||||
const runs = listRuns();
|
||||
const available = runs.length > 0 ? runs.map(r => ` ${r.runId}`).join('\n') : ' (none)';
|
||||
console.error(`friction diff failed: ${unresolved} matched no run-id or agent\navailable runs under ${frictionDir()}:\n${available}`);
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const diff = computeFrictionDiff(baseRun, compareRun);
|
||||
process.stdout.write((flags.bool('--json') ? JSON.stringify(diff, null, 2) : renderDiff(diff)) + '\n');
|
||||
return 0;
|
||||
} catch (e) {
|
||||
console.error(`friction diff failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -174,12 +489,16 @@ Subcommands:
|
||||
render Render a run's entries as markdown (default) or JSON
|
||||
list List recent runs with friction/delight counts
|
||||
summary Two-column summary of friction + delight for a run
|
||||
diff Compare two runs: unique-to-each + shared-but-changed entries
|
||||
|
||||
Examples:
|
||||
gbrain friction log --severity confused --phase install --message "init didn't say which engine"
|
||||
gbrain friction render --run-id claw-test-20260428-... --transcripts
|
||||
gbrain friction list --json
|
||||
gbrain friction summary
|
||||
gbrain friction diff --base openclaw --compare hermes --json
|
||||
|
||||
Run-id resolution: --run-id > $GBRAIN_FRICTION_RUN_ID > 'standalone'.`);
|
||||
Run-id resolution: --run-id > $GBRAIN_FRICTION_RUN_ID > 'standalone'.
|
||||
Diff run resolution: an exact run-id wins; otherwise the value is treated as
|
||||
an agent name and resolves to that agent's latest run.`);
|
||||
}
|
||||
|
||||
+35
-7
@@ -25,6 +25,34 @@ import {
|
||||
resumeFilter,
|
||||
} from '../core/import-checkpoint.ts';
|
||||
|
||||
/**
|
||||
* Records one failed file against the run's error-grouping state and
|
||||
* returns the running count for its group plus an unredacted sample
|
||||
* message for display.
|
||||
*
|
||||
* `key` groups structurally-identical errors (e.g. the same failure
|
||||
* across many files) so a single noisy failure mode doesn't produce
|
||||
* thousands of near-duplicate warning lines — quoted substrings (typically
|
||||
* a per-file slug or path) are blanked for the GROUPING key only. The
|
||||
* printed `sample` is always a real, unredacted occurrence of the error
|
||||
* (the first one seen for that key), so identifying details that are
|
||||
* constant across the whole group — a Postgres table or constraint name,
|
||||
* for instance — survive into what actually gets shown to the user.
|
||||
* Pre-fix, the redacted key itself was printed, so e.g. a `pages_source_id_fkey`
|
||||
* foreign-key violation surfaced as `table "" violates foreign key constraint ""`.
|
||||
*/
|
||||
export function recordImportFailure(
|
||||
errorCounts: Record<string, number>,
|
||||
errorSamples: Record<string, string>,
|
||||
msg: string,
|
||||
): { key: string; count: number; sample: string } {
|
||||
const key = msg.replace(/"[^"]*"/g, '""');
|
||||
const count = (errorCounts[key] ?? 0) + 1;
|
||||
errorCounts[key] = count;
|
||||
if (!(key in errorSamples)) errorSamples[key] = msg;
|
||||
return { key, count, sample: errorSamples[key] };
|
||||
}
|
||||
|
||||
function defaultWorkers(): number {
|
||||
const cpuCount = cpus().length;
|
||||
const memGB = totalmem() / (1024 ** 3);
|
||||
@@ -288,6 +316,7 @@ export async function runImport(
|
||||
let chunksCreated = 0;
|
||||
const importedSlugs: string[] = [];
|
||||
const errorCounts: Record<string, number> = {};
|
||||
const errorSamples: Record<string, string> = {};
|
||||
const failures: Array<{ path: string; error: string }> = []; // Bug 9
|
||||
// #3839: paths that succeeded (imported OR unchanged) this run, keyed the
|
||||
// same way as `failures` above (importRelPath) so a path that failed on a
|
||||
@@ -351,12 +380,11 @@ export async function runImport(
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
const errorKey = msg.replace(/"[^"]*"/g, '""');
|
||||
errorCounts[errorKey] = (errorCounts[errorKey] || 0) + 1;
|
||||
if (errorCounts[errorKey] <= 5) {
|
||||
const { count, sample } = recordImportFailure(errorCounts, errorSamples, msg);
|
||||
if (count <= 5) {
|
||||
console.error(` Warning: skipped ${relativePath}: ${msg}`);
|
||||
} else if (errorCounts[errorKey] === 6) {
|
||||
console.error(` (suppressing further "${errorKey.slice(0, 60)}..." errors)`);
|
||||
} else if (count === 6) {
|
||||
console.error(` (suppressing further "${sample.slice(0, 60)}..." errors)`);
|
||||
}
|
||||
errors++;
|
||||
skipped++;
|
||||
@@ -457,9 +485,9 @@ export async function runImport(
|
||||
progress.finish();
|
||||
|
||||
// Error summary
|
||||
for (const [err, count] of Object.entries(errorCounts)) {
|
||||
for (const [key, count] of Object.entries(errorCounts)) {
|
||||
if (count > 5) {
|
||||
console.error(` ${count} files failed: ${err.slice(0, 100)}`);
|
||||
console.error(` ${count} files failed: ${errorSamples[key].slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,17 @@
|
||||
* config writes work [CDX-7]. Idempotent: if `search.mode` is already set
|
||||
* (re-init / second run), the picker is skipped entirely.
|
||||
*
|
||||
* TTY flow shows the menu. Non-TTY (CI, scripted init, --mcp-only) writes
|
||||
* `balanced` and prints the one-line hint pointing at `gbrain config set
|
||||
* search.mode`. The mode picker NEVER blocks an init run — readLineSafe
|
||||
* caps at 60s and falls back to `balanced` on timeout / EOF.
|
||||
* TTY flow shows the menu. Non-TTY (CI, scripted init, --mcp-only) applies
|
||||
* the auto-recommendation, prints the cost matrix + an [AGENT] directive to
|
||||
* confirm with the operator, and points at `gbrain config set search.mode`.
|
||||
* The mode picker NEVER blocks an init run — readLineSafe caps at 60s and
|
||||
* falls back to the recommendation on timeout / EOF.
|
||||
*
|
||||
* Smart auto-suggestion: reads models.tier.subagent / models.default /
|
||||
* OPENAI_API_KEY presence + brain size hint to RECOMMEND a mode. The
|
||||
* recommendation is informational only — the user picks. This is the
|
||||
* "agents perfectly tune for user needs" piece at install time.
|
||||
* expansion-capable key presence (Anthropic/OpenAI/Google) + brain size hint
|
||||
* to RECOMMEND a mode. The recommendation is informational only — the user
|
||||
* picks. This is the "agents perfectly tune for user needs" piece at
|
||||
* install time.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
@@ -36,8 +38,11 @@ export interface ModePickerInputs {
|
||||
subagentModel?: string | null;
|
||||
/** Configured default model id. */
|
||||
defaultModel?: string | null;
|
||||
/** True iff an OpenAI API key is configured. */
|
||||
hasOpenAIKey?: boolean;
|
||||
/** True iff an expansion-capable API key (Anthropic / OpenAI / Google) is
|
||||
* configured. LLM query expansion routes through the gateway's chat lane,
|
||||
* not the embedding lane — an OpenAI-only gate wrongly told Anthropic-keyed
|
||||
* installs "no LLM expansion possible". */
|
||||
hasExpansionKey?: boolean;
|
||||
/** Approximate page count of the brain (after initSchema, before bulk import). */
|
||||
pageCount?: number;
|
||||
}
|
||||
@@ -50,8 +55,8 @@ export interface ModePickerInputs {
|
||||
* shape per the v0.32.3 install-picker directive):
|
||||
* - Opus / Frontier model OR Sonnet / unknown → tokenmax (max-quality default)
|
||||
* - Haiku subagent → conservative (cost-sensitive setups)
|
||||
* - No OpenAI key configured → conservative (LLM expansion not possible
|
||||
* anyway, so tight budget makes more sense)
|
||||
* - No expansion-capable key (Anthropic/OpenAI/Google) → conservative
|
||||
* (LLM expansion cannot run anyway, so tight budget makes more sense)
|
||||
*
|
||||
* Rationale: the previous "default to balanced unless Opus detected" logic
|
||||
* silently downgraded users who were running Sonnet-tier work and expected
|
||||
@@ -67,10 +72,10 @@ export function recommendModeFor(inputs: ModePickerInputs): { mode: SearchMode;
|
||||
reason: 'Haiku subagent tier detected — tight 4K budget keeps per-call cost down.',
|
||||
};
|
||||
}
|
||||
if (inputs.hasOpenAIKey === false) {
|
||||
if (inputs.hasExpansionKey === false) {
|
||||
return {
|
||||
mode: 'conservative',
|
||||
reason: 'No OpenAI key configured — semantic cache still works, but no LLM expansion possible.',
|
||||
reason: 'No expansion-capable API key (Anthropic/OpenAI/Google) — semantic cache still works, but LLM query expansion cannot run.',
|
||||
};
|
||||
}
|
||||
const opus = /opus/i.test(inputs.defaultModel ?? '') || /opus/i.test(inputs.subagentModel ?? '');
|
||||
@@ -109,7 +114,12 @@ async function resolveInputs(engine: BrainEngine): Promise<ModePickerInputs> {
|
||||
return {
|
||||
subagentModel,
|
||||
defaultModel,
|
||||
hasOpenAIKey: Boolean(process.env.OPENAI_API_KEY),
|
||||
hasExpansionKey: Boolean(
|
||||
process.env.ANTHROPIC_API_KEY ||
|
||||
process.env.OPENAI_API_KEY ||
|
||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY ||
|
||||
process.env.GEMINI_API_KEY, // gateway accepts GEMINI_API_KEY as a first-class alias
|
||||
),
|
||||
pageCount,
|
||||
};
|
||||
}
|
||||
@@ -214,7 +224,9 @@ export async function runModePicker(
|
||||
// 2 stderr lines that agents typically miss / don't relay to the human.
|
||||
// Now: the agent sees the same matrix the human-TTY picker shows, plus
|
||||
// a directive saying "show this to your operator and confirm before
|
||||
// moving on." Default-applied mode is tokenmax (preserves v0.31.x shape).
|
||||
// moving on." The applied default is the recommendation above — the
|
||||
// banner interpolates it (a static "preserves the v0.31.x shape" line
|
||||
// used to claim tokenmax semantics even when conservative was applied).
|
||||
if (!process.stdin.isTTY) {
|
||||
try { await engine.setConfig(SEARCH_MODE_KEY, rec.mode); } catch { /* swallow */ }
|
||||
console.log('');
|
||||
@@ -224,10 +236,10 @@ export async function runModePicker(
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('');
|
||||
console.log('[AGENT] Before continuing, SHOW this matrix to your operator and');
|
||||
console.log('[AGENT] ask which mode they want. The default above preserves the');
|
||||
console.log('[AGENT] v0.31.x retrieval shape. Run `gbrain config set search.mode');
|
||||
console.log('[AGENT] <conservative|balanced|tokenmax>` if the operator picks');
|
||||
console.log('[AGENT] differently. See INSTALL_FOR_AGENTS.md Step 3.5 for the');
|
||||
console.log(`[AGENT] ask which mode they want. The default above (${rec.mode}) was`);
|
||||
console.log('[AGENT] auto-selected from the Reason line. Run `gbrain config set');
|
||||
console.log('[AGENT] search.mode <conservative|balanced|tokenmax>` if the operator');
|
||||
console.log('[AGENT] picks differently. See INSTALL_FOR_AGENTS.md Step 3.5 for the');
|
||||
console.log('[AGENT] full protocol.');
|
||||
console.log('');
|
||||
console.log('Per-query cost @ 10K queries/mo (search payload only, no cache savings):');
|
||||
|
||||
@@ -8,13 +8,22 @@
|
||||
* picker's UI and `gbrain providers list` can't drift.
|
||||
*
|
||||
* Trust contract:
|
||||
* - TTY-only. Callers must not invoke this in non-TTY contexts; D3 says
|
||||
* non-TTY with zero keys exits 1 from `resolveAIOptions` before we
|
||||
* reach here. A defensive guard returns null if no TTY anyway.
|
||||
* - Filters candidates to env-ready recipes (codex finding #3). The
|
||||
* picker is for choosing among providers the user CAN run, not for
|
||||
* walking them through key setup.
|
||||
* - On Ctrl-D / EOF / timeout: returns null, caller treats as exit 1.
|
||||
* - TTY-only. Callers must not invoke this in non-TTY contexts (non-TTY
|
||||
* zero-key resolves keyless in `resolveEmbeddingByEnv` before reaching
|
||||
* here). A defensive guard returns null if no TTY anyway.
|
||||
* - Filters candidates to env-ready recipes (codex finding #3), and
|
||||
* probe-gates LOCAL daemons (ollama): daemon-up ≠ model-pulled, so an
|
||||
* unreachable daemon is dropped and a missing model is annotated with
|
||||
* its `ollama pull` fix inline.
|
||||
* - Embedding pickers always offer `0) none — continue keyless`. When no
|
||||
* KEYED provider is ready, keyless is the DEFAULT (bare Enter / 60s
|
||||
* timeout / EOF all resolve to 0 → null), so a local daemon is never
|
||||
* auto-selected. When a keyed provider IS ready the default is `1`, so an
|
||||
* unattended timeout picks that first keyed provider — NOT null; explicit
|
||||
* `0` is still keyless.
|
||||
* - Returns null on the keyless choice (and on invalid input); the embedding
|
||||
* caller continues keyless with a loud notice on BOTH the zero-key and the
|
||||
* multi-key paths (other touchpoints treat null as no-pick).
|
||||
* - When the user picks a non-Anthropic chat-capable recipe AND
|
||||
* `ANTHROPIC_API_KEY` is missing, prints the subagent caveat from D7
|
||||
* BEFORE returning the choice so the user sees the implication.
|
||||
@@ -23,6 +32,7 @@
|
||||
import { listRecipes } from '../core/ai/recipes/index.ts';
|
||||
import { envReady, formatRecipeTable } from './providers.ts';
|
||||
import { readLineSafe } from './init.ts';
|
||||
import { probeOllama, type ProbeResult } from '../core/ai/probes.ts';
|
||||
import type { Recipe } from '../core/ai/types.ts';
|
||||
|
||||
export interface PickedProvider {
|
||||
@@ -46,6 +56,10 @@ export interface PickProviderOpts {
|
||||
isTTY?: boolean;
|
||||
/** Stderr override for tests (capturing prompts). Defaults to process.stderr.write. */
|
||||
writeStderr?: (s: string) => void;
|
||||
/** Local-daemon probe seam (injected for tests; defaults to probeOllama).
|
||||
* Keeps the unit suite off the network — and off any REAL ollama that
|
||||
* happens to be running on the test machine. */
|
||||
probeLocal?: () => Promise<ProbeResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,19 +121,57 @@ export async function pickProvider(opts: PickProviderOpts): Promise<PickedProvid
|
||||
}
|
||||
|
||||
const all = listRecipes();
|
||||
const ready = readyRecipesForTouchpoint(all, opts.touchpoint, env);
|
||||
let ready = readyRecipesForTouchpoint(all, opts.touchpoint, env);
|
||||
|
||||
if (ready.length === 0) {
|
||||
// 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.
|
||||
// Drop ollama when its daemon doesn't answer; annotate it when the daemon
|
||||
// answers but hasn't pulled the recipe's model. Scoped to ollama — other
|
||||
// local recipes (claude-cli) have no daemon to probe.
|
||||
const localHints = new Map<string, string>();
|
||||
const localRecipes = ready.filter((r) => r.id === 'ollama');
|
||||
if (localRecipes.length > 0) {
|
||||
const probe = opts.probeLocal ?? probeOllama;
|
||||
let probeResult: ProbeResult;
|
||||
try {
|
||||
probeResult = await probe();
|
||||
} catch {
|
||||
probeResult = { reachable: false };
|
||||
}
|
||||
if (!probeResult.models_endpoint_valid) {
|
||||
ready = ready.filter((r) => r.id !== 'ollama');
|
||||
} else {
|
||||
for (const r of localRecipes) {
|
||||
const tp = r.touchpoints[opts.touchpoint];
|
||||
const wanted = tp && 'models' in tp && Array.isArray(tp.models) ? tp.models[0] : undefined;
|
||||
const served = probeResult.models ?? [];
|
||||
if (wanted && !served.some((m) => m === wanted || m.startsWith(`${wanted}:`))) {
|
||||
localHints.set(r.id, `model not pulled — run: ollama pull ${wanted}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyless is always a valid embedding choice — the brain works with
|
||||
// keyword search + agent-authored memory. Offer it explicitly instead of
|
||||
// forcing a keypress through a provider menu.
|
||||
const keylessOption = opts.touchpoint === 'embedding';
|
||||
|
||||
if (ready.length === 0 && !keylessOption) {
|
||||
writeStderr(`\nNo ${opts.touchpoint}-capable providers are env-ready.\n`);
|
||||
writeStderr('Set one of the env vars below and re-run init:\n\n');
|
||||
writeStderr(formatRecipeTable(all, env) + '\n\n');
|
||||
return null;
|
||||
}
|
||||
|
||||
writeStderr(`\nPick a ${opts.touchpoint} provider (env-ready providers shown):\n\n`);
|
||||
writeStderr(formatRecipeTable(ready, env) + '\n\n');
|
||||
// Article-aware: touchpoint is 'embedding' | 'expansion' | 'chat' — a
|
||||
// hardcoded article renders "an chat provider".
|
||||
const article = /^[aeiou]/i.test(opts.touchpoint) ? 'an' : 'a';
|
||||
writeStderr(`\nPick ${article} ${opts.touchpoint} provider (env-ready providers shown):\n\n`);
|
||||
if (ready.length > 0) writeStderr(formatRecipeTable(ready, env) + '\n\n');
|
||||
|
||||
// Build numbered options
|
||||
// Build numbered options (0 = keyless skip for embedding).
|
||||
const lines = ready.map((r, i) => {
|
||||
const tp = r.touchpoints[opts.touchpoint];
|
||||
let label = ` ${i + 1}) ${r.id}`;
|
||||
@@ -129,21 +181,35 @@ export async function pickProvider(opts: PickProviderOpts): Promise<PickedProvid
|
||||
if (tp && 'models' in tp && Array.isArray(tp.models) && tp.models.length > 0) {
|
||||
label += ` ${tp.models[0]}`;
|
||||
}
|
||||
const hint = localHints.get(r.id);
|
||||
if (hint) label += ` [${hint}]`;
|
||||
return label;
|
||||
});
|
||||
if (keylessOption) {
|
||||
lines.unshift(' 0) none — continue keyless (keyword search; add a key later)');
|
||||
}
|
||||
writeStderr(lines.join('\n') + '\n\n');
|
||||
|
||||
// Default: keyless when no remote (keyed) provider is ready — a bare Enter
|
||||
// must never select a local daemon the user didn't ask for.
|
||||
const hasKeyedReady = ready.some((r) => (r.auth_env?.required ?? []).length > 0);
|
||||
const defaultChoice = keylessOption && !hasKeyedReady ? '0' : '1';
|
||||
const low = keylessOption ? 0 : 1;
|
||||
|
||||
const answer = await readLineSafe(
|
||||
`Choice [1-${ready.length}, default 1]: `,
|
||||
'1',
|
||||
`Choice [${low}-${ready.length}, default ${defaultChoice}]: `,
|
||||
defaultChoice,
|
||||
/* timeoutMs */ 60_000,
|
||||
);
|
||||
|
||||
const choice = parseInt(answer.trim(), 10);
|
||||
if (!Number.isFinite(choice) || choice < 1 || choice > ready.length) {
|
||||
writeStderr(`\nInvalid choice "${answer}". Aborting.\n`);
|
||||
if (!Number.isFinite(choice) || choice < low || choice > ready.length) {
|
||||
writeStderr(`\nInvalid choice "${answer}".\n`);
|
||||
return null;
|
||||
}
|
||||
if (keylessOption && choice === 0) {
|
||||
return null; // caller continues keyless with its own notice
|
||||
}
|
||||
|
||||
const picked = ready[choice - 1];
|
||||
const tp = picked.touchpoints[opts.touchpoint];
|
||||
|
||||
+126
-51
@@ -491,9 +491,16 @@ export async function findEnvKeyTypos(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Emit the fail-loud "no embedding provider" message + paste-ready setup. */
|
||||
/** Emit the "no embedding provider" message + paste-ready setup. Keyless
|
||||
* continue leads (it always works); key setup follows for the upgrade. */
|
||||
function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested: string }>): void {
|
||||
console.error('\nNo embedding provider configured. Set one of:');
|
||||
console.error('\nNo embedding provider configured.');
|
||||
console.error('Continue without one (keyless — keyword search + memory your agent writes):');
|
||||
console.error(' gbrain init --pglite --no-embedding');
|
||||
console.error(' (enable semantic search later by re-running with a key:');
|
||||
console.error(' gbrain init --force --pglite --embedding-model <id>)');
|
||||
console.error('');
|
||||
console.error('Or set a key for semantic search:');
|
||||
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)');
|
||||
@@ -501,9 +508,6 @@ function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested:
|
||||
console.error('');
|
||||
console.error('Or pick explicitly:');
|
||||
console.error(' gbrain init --pglite --embedding-model openai:text-embedding-3-large');
|
||||
console.error('');
|
||||
console.error('Or defer setup: gbrain init --pglite --no-embedding');
|
||||
console.error(' (you can configure later with `gbrain config set embedding_model <id>`)');
|
||||
// D13: surface near-miss env vars (e.g. OPENAPI_API_KEY → OPENAI_API_KEY).
|
||||
if (typos.length > 0) {
|
||||
console.error('');
|
||||
@@ -513,6 +517,19 @@ function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested:
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
* (it would be a silent no-op), so pointing users there is a dead end. */
|
||||
function printKeylessContinueNotice(): void {
|
||||
console.error(
|
||||
'No embedding provider keys detected — continuing in keyless mode:\n' +
|
||||
' keyword search + memory your agent writes down itself. Everything works.\n' +
|
||||
' One optional key upgrades search to semantic — set the key, then re-run\n' +
|
||||
' `gbrain init --force --pglite --embedding-model <id>` (re-imports via `gbrain sync`).',
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boolean): Promise<void> {
|
||||
const ready = await groupReadyByProvider('embedding');
|
||||
const isTTY = !nonInteractive && !!process.stdin.isTTY;
|
||||
@@ -548,28 +565,56 @@ async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boo
|
||||
}
|
||||
}
|
||||
|
||||
// Zero or multi — pick or fail loud.
|
||||
// Zero keys — keyless is a first-class posture (the whole paste-in
|
||||
// bootstrap runs on it), so the DEFAULT is to continue keyless with a loud
|
||||
// notice, not exit 1. Fail-loud survives in exactly one zero-key case: a
|
||||
// near-miss env var (OPENAPI_API_KEY → OPENAI_API_KEY) signals the user
|
||||
// MEANT to configure a key — completing keyless there would silently bury
|
||||
// their typo.
|
||||
if (ready.length === 0) {
|
||||
if (!isTTY) {
|
||||
const typos = await findEnvKeyTypos();
|
||||
const typos = await findEnvKeyTypos();
|
||||
if (typos.length > 0) {
|
||||
printNoEmbeddingProviderHint(typos);
|
||||
process.exit(1);
|
||||
}
|
||||
// TTY → picker; on null (user aborted) still fail loud.
|
||||
if (!isTTY) {
|
||||
printKeylessContinueNotice();
|
||||
out.noEmbedding = true;
|
||||
return;
|
||||
}
|
||||
// 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 });
|
||||
if (!picked) {
|
||||
const typos = await findEnvKeyTypos();
|
||||
printNoEmbeddingProviderHint(typos);
|
||||
process.exit(1);
|
||||
printKeylessContinueNotice();
|
||||
out.noEmbedding = true;
|
||||
return;
|
||||
}
|
||||
out.embedding_model = picked.fullModel;
|
||||
out.embedding_dimensions = picked.dim;
|
||||
return;
|
||||
}
|
||||
|
||||
// ready.length > 1 — picker (TTY) or fail-loud (non-TTY) per D2/D3.
|
||||
// ready.length > 1 — picker (TTY); non-TTY auto-picks the canonical default
|
||||
// when its key is present (the most common agent/dev setup is 2+ provider
|
||||
// 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 } =
|
||||
await import('../core/ai/defaults.ts');
|
||||
const canonicalProvider = 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;
|
||||
console.error(
|
||||
`Multiple embedding providers env-ready (${ready.map(p => p.recipeId).join(', ')}). ` +
|
||||
`Using the default ${DEFAULT_EMBEDDING_MODEL} (${DEFAULT_EMBEDDING_DIMENSIONS}d). ` +
|
||||
`Override with --embedding-model.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(`Multiple embedding providers env-ready: ${ready.map(p => p.recipeId).join(', ')}.`);
|
||||
console.error(`Disambiguate by passing --embedding-model <provider>:<model>, or unset extra env vars.`);
|
||||
process.exit(1);
|
||||
@@ -577,8 +622,13 @@ async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boo
|
||||
const { pickProvider } = await import('./init-provider-picker.ts');
|
||||
const picked = await pickProvider({ touchpoint: 'embedding', env: process.env, isTTY: true });
|
||||
if (!picked) {
|
||||
console.error('Init aborted: no embedding provider picked.');
|
||||
process.exit(1);
|
||||
// The embedding picker offers an explicit "0) none — continue keyless"
|
||||
// option (and returns null on it). Honor that instead of aborting: a user
|
||||
// with multiple keys who deliberately chose keyless gets keyless, matching
|
||||
// the zero-key path. (Ctrl-D / EOF / invalid also land here → keyless.)
|
||||
printKeylessContinueNotice();
|
||||
out.noEmbedding = true;
|
||||
return;
|
||||
}
|
||||
out.embedding_model = picked.fullModel;
|
||||
out.embedding_dimensions = picked.dim;
|
||||
@@ -1080,18 +1130,16 @@ async function initPGLite(opts: {
|
||||
} else {
|
||||
console.log(`\nBrain ready at ${dbPath}`);
|
||||
console.log(`${stats.page_count} pages. Engine: PGLite (local Postgres).`);
|
||||
// Reference/status blocks print FIRST and terse; the ONE primary action
|
||||
// (the memory demo) prints LAST so it is the final, unmistakable thing on
|
||||
// screen. Krug: one obvious next action, everything else subordinate.
|
||||
if (stats.page_count > 0) {
|
||||
console.log('');
|
||||
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
|
||||
console.log('Existing brain detected. Wire up the knowledge graph:');
|
||||
console.log(' gbrain extract links --source db (typed link backfill)');
|
||||
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
|
||||
console.log(' gbrain stats (verify links > 0)');
|
||||
} else {
|
||||
console.log('Next: gbrain import <dir>');
|
||||
}
|
||||
printMemoryVerbsQuickstart();
|
||||
console.log('');
|
||||
console.log('When you outgrow local: gbrain migrate --to supabase');
|
||||
reportModStatus();
|
||||
const { printAdvisoryIfRecommended } = await import('../core/skillpack/post-install-advisory.ts');
|
||||
const { VERSION } = await import('../version.ts');
|
||||
@@ -1101,6 +1149,9 @@ async function initPGLite(opts: {
|
||||
// Fail-open; 3s wallclock cap. Skipped silently in non-TTY contexts.
|
||||
const { runInitNudge } = await import('../core/onboard/init-nudge.ts');
|
||||
await runInitNudge(engine);
|
||||
|
||||
// The single primary action, last-on-screen.
|
||||
printMemoryVerbsQuickstart({ emptyBrain: stats.page_count === 0, onPglite: true });
|
||||
}
|
||||
} finally {
|
||||
try { await engine.disconnect(); } catch { /* best-effort */ }
|
||||
@@ -1108,23 +1159,33 @@ async function initPGLite(opts: {
|
||||
}
|
||||
|
||||
/**
|
||||
* MEMORY_VERBS v1 quickstart funnel (E3 + D4B + T1 consent). Printed at the
|
||||
* end of both init epilogues. The copy-next block is EXACTLY three commands
|
||||
* (codex DX 9): wire the harness, write a memory, prove the resurrection.
|
||||
* The demo uses the facts arm only, so it works with NO embedding key [F-B].
|
||||
* MEMORY_VERBS v1 quickstart funnel (E3 + D4B + T1 consent). Printed LAST in
|
||||
* both init epilogues as the ONE primary action. The copy-next block is
|
||||
* EXACTLY three commands (codex DX 9): wire the harness, write a memory, prove
|
||||
* the resurrection. The demo uses the facts arm only, so it works with NO
|
||||
* embedding key [F-B]. Secondary paths (import, migrate) ride a single terse
|
||||
* "More:" footer so they never compete with the primary action.
|
||||
*/
|
||||
function printMemoryVerbsQuickstart(): void {
|
||||
function printMemoryVerbsQuickstart(opts: { emptyBrain?: boolean; onPglite?: boolean } = {}): void {
|
||||
console.log('');
|
||||
console.log('Give your agent memory (copy these three commands):');
|
||||
console.log('→ Do this next — give your agent memory (copy these three commands):');
|
||||
console.log(' claude mcp add gbrain -- gbrain serve --surface verbs');
|
||||
console.log(' gbrain remember "I prefer dark mode in every editor" --provenance demo --entity people/me');
|
||||
console.log(' gbrain recall --entity people/me');
|
||||
console.log('Now ask your agent in a NEW session — it remembers.');
|
||||
console.log('Then ask your agent in a NEW session — it remembers.');
|
||||
console.log('');
|
||||
console.log('Note: memories agents save are readable by every agent connected to');
|
||||
console.log('this brain; use visibility:"private" for local-only facts.');
|
||||
console.log('Other harnesses (Codex, OpenClaw): docs/protocol/MEMORY_VERBS_v1.md');
|
||||
console.log('If `claude` is not found: install Claude Code first, or use the per-harness blocks in that doc.');
|
||||
// Secondary paths, one line, clearly subordinate to the action above.
|
||||
console.log('');
|
||||
console.log(
|
||||
'More: ' +
|
||||
(opts.emptyBrain ? 'bulk-load notes `gbrain import <dir>` · ' : '') +
|
||||
(opts.onPglite ? 'scale up `gbrain migrate --to supabase` · ' : '') +
|
||||
'health `gbrain doctor`',
|
||||
);
|
||||
}
|
||||
|
||||
async function initPostgres(opts: {
|
||||
@@ -1350,14 +1411,11 @@ async function initPostgres(opts: {
|
||||
console.log(`\nBrain ready. ${stats.page_count} pages. Engine: Postgres (Supabase).`);
|
||||
if (stats.page_count > 0) {
|
||||
console.log('');
|
||||
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
|
||||
console.log('Existing brain detected. Wire up the knowledge graph:');
|
||||
console.log(' gbrain extract links --source db (typed link backfill)');
|
||||
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
|
||||
console.log(' gbrain stats (verify links > 0)');
|
||||
} else {
|
||||
console.log('Next: gbrain import <dir>');
|
||||
}
|
||||
printMemoryVerbsQuickstart();
|
||||
reportModStatus();
|
||||
const { printAdvisoryIfRecommended } = await import('../core/skillpack/post-install-advisory.ts');
|
||||
const { VERSION } = await import('../version.ts');
|
||||
@@ -1367,6 +1425,9 @@ async function initPostgres(opts: {
|
||||
// Fail-open; 3s wallclock cap. Skipped silently in non-TTY contexts.
|
||||
const { runInitNudge } = await import('../core/onboard/init-nudge.ts');
|
||||
await runInitNudge(engine);
|
||||
|
||||
// The single primary action, last-on-screen.
|
||||
printMemoryVerbsQuickstart({ emptyBrain: stats.page_count === 0 });
|
||||
}
|
||||
} finally {
|
||||
try { await engine.disconnect(); } catch { /* best-effort */ }
|
||||
@@ -1402,6 +1463,15 @@ function countMarkdownFiles(dir: string, maxScan = 1500): number {
|
||||
}
|
||||
|
||||
async function supabaseWizard(): Promise<string> {
|
||||
// Non-TTY guard: without a terminal the URL prompt below can never be
|
||||
// answered — the legacy behavior was a silent exit-0 no-op (stdin closed →
|
||||
// readLine never resolved data → process ended with NO config written), the
|
||||
// worst failure shape for a scripted/agent caller. Fail loud with the fix.
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error('gbrain init --supabase needs an interactive terminal to prompt for the connection URL.');
|
||||
console.error('Non-interactive: pass --url <connection_string>, or set GBRAIN_DATABASE_URL and use --non-interactive.');
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
execSync('bunx supabase --version', { stdio: 'pipe' });
|
||||
console.log('Supabase CLI detected.');
|
||||
@@ -1427,12 +1497,21 @@ function readLine(prompt: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
process.stdout.write(prompt);
|
||||
let data = '';
|
||||
let settled = false;
|
||||
const settle = (value: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
process.stdin.pause();
|
||||
resolve(value);
|
||||
};
|
||||
process.stdin.setEncoding('utf-8');
|
||||
process.stdin.once('data', (chunk) => {
|
||||
data = chunk.toString().trim();
|
||||
process.stdin.pause();
|
||||
resolve(data);
|
||||
settle(data);
|
||||
});
|
||||
// EOF (Ctrl-D mid-prompt) resolves empty instead of hanging — the caller's
|
||||
// "No URL provided." guard then fails loud.
|
||||
process.stdin.once('end', () => settle(''));
|
||||
process.stdin.resume();
|
||||
});
|
||||
}
|
||||
@@ -1578,23 +1657,18 @@ export function reportModStatus(): void {
|
||||
skillCount = manifest.skills?.length || 0;
|
||||
} catch { /* manifest not found */ }
|
||||
|
||||
// One line per fact, one pointer per optional extra — this block sits on
|
||||
// the init success screen, where every extra call-to-action competes with
|
||||
// the memory-verbs funnel (the one action that matters). Krug: one screen,
|
||||
// one primary action.
|
||||
console.log('');
|
||||
console.log('--- GBrain Mod Status ---');
|
||||
console.log(`Skills: ${skillCount} loaded`);
|
||||
console.log(`GStack: ${gstack.found ? `found (${gstack.host})` : 'not found'}`);
|
||||
if (!gstack.found) {
|
||||
console.log(' Install GStack for coding skills:');
|
||||
console.log(' git clone https://github.com/garrytan/gstack.git ~/.claude/skills/gstack');
|
||||
console.log(' cd ~/.claude/skills/gstack && ./setup');
|
||||
}
|
||||
console.log('Resolver: skills/RESOLVER.md');
|
||||
console.log('Soul audit: ask your agent to "run a soul audit" to customize its identity (see skills/soul-audit)');
|
||||
console.log(`Skills: ${skillCount} loaded (router: skills/RESOLVER.md)`);
|
||||
console.log(`GStack: ${gstack.found ? `found (${gstack.host})` : 'not found (coding skills — see github.com/garrytan/gstack)'}`);
|
||||
// Retrieval Reflex (#1981): the deterministic pointer layer is ON by default
|
||||
// (no action needed). The policy skill is installed into the HOST repo on
|
||||
// request — we PRINT the command rather than silently mutating the host repo.
|
||||
console.log('Retrieval reflex: on by default (entity pointers injected per turn)');
|
||||
console.log(' Install the policy skill into your agent repo:');
|
||||
console.log(' gbrain integrations install retrieval-reflex --target <host-repo>');
|
||||
// request — we PRINT the pointer rather than silently mutating the host repo.
|
||||
console.log('Retrieval reflex: on by default. More: `gbrain integrations` (policy skill), skills/soul-audit (identity).');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
@@ -1606,7 +1680,7 @@ USAGE
|
||||
gbrain init [flags]
|
||||
|
||||
ENGINE SELECTION (mutually exclusive)
|
||||
--pglite Use embedded PGLite (zero-config, default for <1000 .md files)
|
||||
--pglite Use embedded PGLite (zero-config, the default)
|
||||
--supabase Use Supabase Postgres (recommended for 1000+ files)
|
||||
--url <URL> Use a manual Postgres connection string
|
||||
--mcp-only Thin-client mode: connect to a remote gbrain MCP, no local engine
|
||||
@@ -1639,9 +1713,10 @@ EXAMPLES
|
||||
gbrain init --mcp-only --url https://... # Thin-client mode
|
||||
|
||||
NOTES
|
||||
- Bare \`gbrain init\` in a directory with 1000+ .md files defaults to Supabase
|
||||
interactive setup. With <1000 files (or with --pglite explicitly), defaults
|
||||
to PGLite at ~/.gbrain/brain.pglite.
|
||||
- Bare \`gbrain init\` always defaults to PGLite at ~/.gbrain/brain.pglite.
|
||||
In a directory with 1000+ .md files it prints a suggestion to use
|
||||
\`gbrain init --supabase\` (faster search at scale) but still proceeds
|
||||
with PGLite.
|
||||
- Existing config is preserved unless --force is passed.
|
||||
`.trim());
|
||||
}
|
||||
|
||||
+38
-1
@@ -1708,7 +1708,44 @@ export async function registerBuiltinHandlers(
|
||||
});
|
||||
|
||||
worker.register('extract', async (job) => {
|
||||
const { runExtractCore } = await import('./extract.ts');
|
||||
const { runExtractCore, extractStaleFromDB, STALE_TIME_BUDGET_MS } = await import('./extract.ts');
|
||||
// #2849: stale mode — the durable follow-up for extraction deferred by
|
||||
// performSync's size gate (totalChanges > 100). Runs the same DB-source
|
||||
// watermark sweep as `gbrain extract --stale`, scoped to the source the
|
||||
// sync that deferred it was scoped to (job.data.sourceId; absent =
|
||||
// unscoped, matching what the CLI hint tells a default-brain operator
|
||||
// to run). The sweep is checkout-less + idempotent, so retries and
|
||||
// overlapping submissions converge.
|
||||
if (job.data.stale === true) {
|
||||
const sourceIdFilter = typeof job.data.sourceId === 'string' ? job.data.sourceId : undefined;
|
||||
const r = await extractStaleFromDB(engine, {
|
||||
dryRun: !!job.data.dryRun,
|
||||
jsonMode: false,
|
||||
includeFrontmatter: false,
|
||||
sourceIdFilter,
|
||||
catchUp: false,
|
||||
});
|
||||
// Internal 30-min budget hit with work remaining → chain a
|
||||
// continuation job so a very large deferred backlog converges without
|
||||
// waiting for the next sync. Forward-progress guard (pagesProcessed >
|
||||
// 0) prevents an infinite chain if the sweep can't advance.
|
||||
if (!job.data.dryRun && r.staleRemaining > 0 && r.pagesProcessed > 0) {
|
||||
try {
|
||||
const queue = new MinionQueue(engine);
|
||||
// NO maxWaiting: with an unscoped (NULL-sourceId) payload the
|
||||
// coalesce filter matches ANY waiting 'extract' job and would
|
||||
// swallow the continuation. Each completed sweep chains at most
|
||||
// one continuation and the sweep is an idempotent watermark scan,
|
||||
// so there is no pile-up to guard against.
|
||||
await queue.add(
|
||||
'extract',
|
||||
{ ...job.data, continuation_of: job.id },
|
||||
{ timeout_ms: STALE_TIME_BUDGET_MS + 5 * 60 * 1000 },
|
||||
);
|
||||
} catch { /* best-effort: next sync/manual sweep picks up the rest */ }
|
||||
}
|
||||
return { stale: true, source_id: sourceIdFilter ?? null, ...r };
|
||||
}
|
||||
const mode = (typeof job.data.mode === 'string' && ['links', 'timeline', 'all'].includes(job.data.mode))
|
||||
? (job.data.mode as 'links' | 'timeline' | 'all')
|
||||
: 'all';
|
||||
|
||||
@@ -389,7 +389,20 @@ export async function runMigrateEmbeddings(
|
||||
exit(0);
|
||||
} else {
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ status: 'incomplete', plan, embedded: embedResult.embedded, remaining }, null, 2));
|
||||
console.log(JSON.stringify({
|
||||
status: 'incomplete', plan, embedded: embedResult.embedded, remaining,
|
||||
...(embedResult.lock_skipped && { lock_skipped: true }),
|
||||
}, null, 2));
|
||||
} else if (embedResult.lock_skipped) {
|
||||
// E2E-observed failure mode: a hard-killed (SIGKILL/crash) migration
|
||||
// leaves its single-flight embed lock behind, and every immediate
|
||||
// re-run "resumes" without embedding anything. Say so — "re-run to
|
||||
// resume" would be a lie until the lock expires.
|
||||
const { EMBED_BACKFILL_LOCK_TTL_MIN } = await import('../core/embed-backfill-lock.ts');
|
||||
serr(`Migration paused: ${remaining} chunk(s) still stale, and the re-embed was SKIPPED because`);
|
||||
serr('another embed backfill holds the per-source lock. If that is a live run (check');
|
||||
serr('`gbrain jobs list`), let it finish. If a previous migration was killed hard, its lock');
|
||||
serr(`expires after at most ${EMBED_BACKFILL_LOCK_TTL_MIN} minutes — re-run the same command then.`);
|
||||
} else {
|
||||
serr(`Migration incomplete: ${remaining} chunk(s) still stale (embed failures or an interrupted run).`);
|
||||
serr('Re-run the same command to resume — completed chunks are never re-embedded.');
|
||||
|
||||
@@ -80,6 +80,10 @@ export function manifestMatchesTarget(manifest: MigrateManifest, targetId: strin
|
||||
return manifest.schema_version === 2 && manifest.target_id === targetId;
|
||||
}
|
||||
|
||||
function makeManifestKey(sourceId: string, slug: string): string {
|
||||
return sourceId === 'default' ? slug : `${sourceId}::${slug}`;
|
||||
}
|
||||
|
||||
function loadManifest(): MigrateManifest | null {
|
||||
const path = getManifestPath();
|
||||
if (!existsSync(path)) return null;
|
||||
@@ -151,6 +155,25 @@ export async function copyMigrationSources(source: BrainEngine, target: BrainEng
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyPageLinksToTarget(
|
||||
source: BrainEngine,
|
||||
target: BrainEngine,
|
||||
page: Page,
|
||||
failedKeys: ReadonlySet<string> = new Set(),
|
||||
): Promise<void> {
|
||||
const links = await source.getLinks(page.slug, { sourceId: page.source_id });
|
||||
for (const link of links) {
|
||||
const toSourceId = link.to_source_id ?? page.source_id;
|
||||
if (failedKeys.has(makeManifestKey(toSourceId, link.to_slug))) continue;
|
||||
await target.addLink(
|
||||
link.from_slug, link.to_slug,
|
||||
link.context, link.link_type,
|
||||
undefined, undefined, undefined,
|
||||
{ fromSourceId: page.source_id, toSourceId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* postgres.js's UNDEFINED_VALUE guard rejects any bound parameter that is JS
|
||||
* `undefined` — unlike PGLite, it will not silently treat it as SQL NULL.
|
||||
@@ -564,8 +587,6 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
|
||||
// entries were bare slugs; we keep treating those as default-source for
|
||||
// back-compat resume.
|
||||
const completedSet = new Set(manifest?.completed_slugs || []);
|
||||
const makeManifestKey = (sourceId: string, slug: string): string =>
|
||||
sourceId === 'default' ? slug : `${sourceId}::${slug}`;
|
||||
if (!manifest) {
|
||||
manifest = {
|
||||
completed_slugs: [],
|
||||
@@ -680,17 +701,7 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
|
||||
progress.tick(1);
|
||||
continue;
|
||||
}
|
||||
const sourceOpts = { sourceId: page.source_id };
|
||||
const links = await sourceEngine.getLinks(page.slug, sourceOpts);
|
||||
for (const link of links) {
|
||||
if (failedKeys.has(makeManifestKey(page.source_id, link.to_slug))) continue;
|
||||
await targetEngine.addLink(
|
||||
link.from_slug, link.to_slug,
|
||||
link.context, link.link_type,
|
||||
undefined, undefined, undefined,
|
||||
{ fromSourceId: page.source_id, toSourceId: page.source_id },
|
||||
);
|
||||
}
|
||||
await copyPageLinksToTarget(sourceEngine, targetEngine, page, failedKeys);
|
||||
progress.tick(1);
|
||||
}
|
||||
progress.finish();
|
||||
|
||||
+12
-3
@@ -38,7 +38,7 @@ import {
|
||||
type SearchMode,
|
||||
type ModeBundle,
|
||||
} from '../core/search/mode.ts';
|
||||
import { readSearchStats } from '../core/search/telemetry.ts';
|
||||
import { readSearchStats, telemetryCoverage, TELEMETRY_COVERAGE_CAVEAT } from '../core/search/telemetry.ts';
|
||||
|
||||
const KNOB_DESCRIPTIONS: Record<keyof ModeBundle, string> = {
|
||||
cache_enabled: 'Semantic query cache on/off',
|
||||
@@ -225,6 +225,7 @@ async function runStatsSubcommand(engine: BrainEngine, args: string[]): Promise<
|
||||
console.log(JSON.stringify({
|
||||
schema_version: 2,
|
||||
...stats,
|
||||
coverage: telemetryCoverage(),
|
||||
graph_signals: gsSection,
|
||||
_meta: {
|
||||
metric_glossary: {
|
||||
@@ -241,11 +242,15 @@ async function runStatsSubcommand(engine: BrainEngine, args: string[]): Promise<
|
||||
}
|
||||
|
||||
console.log(`Search stats over the last ${stats.window_days} days:`);
|
||||
console.log(` Coverage note: ${TELEMETRY_COVERAGE_CAVEAT}`);
|
||||
console.log('');
|
||||
console.log(` Total searches: ${stats.total_calls}`);
|
||||
if (stats.total_calls === 0) {
|
||||
console.log('');
|
||||
console.log('No telemetry recorded yet. Run a few `gbrain query` calls and re-check.');
|
||||
console.log('No telemetry recorded in this window. This can mean no search activity, or');
|
||||
console.log('it can reflect the coverage gap above — a lone short-lived CLI call is often');
|
||||
console.log('not enough to trigger a flush. `gbrain serve` / an MCP session is more likely');
|
||||
console.log('to record counts over time (telemetry stays best-effort either way).');
|
||||
// Still print the graph-signals section since failures are tracked
|
||||
// independently of the search_telemetry table.
|
||||
if (gsSection.enabled || gsSection.failures_count > 0) {
|
||||
@@ -382,6 +387,7 @@ async function runTuneSubcommand(engine: BrainEngine, args: string[]): Promise<v
|
||||
schema_version: 2,
|
||||
status: 'insufficient_data',
|
||||
total_calls: stats.total_calls,
|
||||
coverage: telemetryCoverage(),
|
||||
recommendations: [],
|
||||
message: 'Not enough search activity in the last 7 days to tune. Run `gbrain search stats` after some real usage.',
|
||||
}, null, 2));
|
||||
@@ -389,7 +395,8 @@ async function runTuneSubcommand(engine: BrainEngine, args: string[]): Promise<v
|
||||
}
|
||||
console.log('Not enough search activity in the last 7 days to tune.');
|
||||
console.log(`Total searches: ${stats.total_calls} (need >= 20 for confident recommendations).`);
|
||||
console.log('Run a few `gbrain query` calls, then re-run `gbrain search tune`.');
|
||||
console.log(`(${TELEMETRY_COVERAGE_CAVEAT} Low counts can reflect this gap, not just low usage.)`);
|
||||
console.log('Use `gbrain serve` or an MCP session for a while, then re-run `gbrain search tune`.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -448,6 +455,7 @@ async function runTuneSubcommand(engine: BrainEngine, args: string[]): Promise<v
|
||||
total_calls: stats.total_calls,
|
||||
cache_hit_rate: stats.cache_hit_rate,
|
||||
active_mode: resolved.resolved_mode,
|
||||
coverage: telemetryCoverage(),
|
||||
recommendations: recs,
|
||||
applied: apply ? recs.map(r => r.apply_command) : [],
|
||||
_meta: {
|
||||
@@ -466,6 +474,7 @@ async function runTuneSubcommand(engine: BrainEngine, args: string[]): Promise<v
|
||||
}
|
||||
|
||||
console.log(`Search tune (last 7 days, active mode: ${resolved.resolved_mode}):`);
|
||||
console.log(`(${TELEMETRY_COVERAGE_CAVEAT})`);
|
||||
console.log('');
|
||||
|
||||
if (recs.length === 0) {
|
||||
|
||||
@@ -27,7 +27,12 @@ import { OAuthTokenRevocationRequestSchema } from '@modelcontextprotocol/sdk/sha
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { operations, OperationError } from '../core/operations.ts';
|
||||
import type { OperationContext, AuthInfo } from '../core/operations.ts';
|
||||
import { GBrainOAuthProvider, validateTokenEndpointAuthMethod } from '../core/oauth-provider.ts';
|
||||
import {
|
||||
GBrainOAuthProvider,
|
||||
validateTokenEndpointAuthMethod,
|
||||
dcrRegistrationContext,
|
||||
DEFAULT_DCR_TTL_MIN_SECONDS,
|
||||
} from '../core/oauth-provider.ts';
|
||||
import type { SqlQuery } from '../core/oauth-provider.ts';
|
||||
import { hasScope, ALLOWED_SCOPES_LIST, normalizeScopesInput } from '../core/scope.ts';
|
||||
import { normalizeSourceInput, normalizeFederatedReadInput } from '../core/source-id.ts';
|
||||
@@ -702,11 +707,41 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
// constructor option instead of monkey-patching `_clientsStore` after
|
||||
// construction. Same outcome (no /register endpoint when --enable-dcr
|
||||
// is not passed); cleaner shape for tests and future maintainers.
|
||||
// #2179: admin-configured clamp window for DCR-requested token TTLs.
|
||||
// DB-plane config keys (`gbrain config set oauth.dcr_ttl_min_seconds ...`).
|
||||
// FAIL-CLOSED defaults: an unset/invalid max is bounded by the operator's
|
||||
// own --token-ttl (never a fixed permissive ceiling), and an inverted
|
||||
// window collapses to the min bound — the same direction clampDcrTokenTtl
|
||||
// itself resolves. A bad config narrows the window; it never widens it.
|
||||
const parseDcrTtlBound = (raw: unknown, fallback: number): number => {
|
||||
const n = Number(raw);
|
||||
return raw != null && Number.isFinite(n) && n >= 1 ? Math.floor(n) : fallback;
|
||||
};
|
||||
let dcrTtlMinSeconds = DEFAULT_DCR_TTL_MIN_SECONDS;
|
||||
let dcrTtlMaxSeconds = Math.max(tokenTtl, dcrTtlMinSeconds);
|
||||
try {
|
||||
dcrTtlMinSeconds = parseDcrTtlBound(await engine.getConfig('oauth.dcr_ttl_min_seconds'), DEFAULT_DCR_TTL_MIN_SECONDS);
|
||||
dcrTtlMaxSeconds = parseDcrTtlBound(await engine.getConfig('oauth.dcr_ttl_max_seconds'), Math.max(tokenTtl, dcrTtlMinSeconds));
|
||||
} catch {
|
||||
// Config read is best-effort; the fail-closed defaults stand.
|
||||
dcrTtlMaxSeconds = Math.max(tokenTtl, dcrTtlMinSeconds);
|
||||
}
|
||||
if (dcrTtlMinSeconds > dcrTtlMaxSeconds) {
|
||||
console.error(
|
||||
`[serve-http] WARNING: oauth.dcr_ttl_min_seconds (${dcrTtlMinSeconds}) exceeds ` +
|
||||
`oauth.dcr_ttl_max_seconds (${dcrTtlMaxSeconds}); collapsing the window to ` +
|
||||
`the min bound (${dcrTtlMinSeconds}).`,
|
||||
);
|
||||
dcrTtlMaxSeconds = dcrTtlMinSeconds;
|
||||
}
|
||||
|
||||
const oauthProvider = new GBrainOAuthProvider({
|
||||
sql,
|
||||
tokenTtl,
|
||||
dcrDisabled: !enableDcr,
|
||||
allowClientCredentialsDcr: enableDcrInsecure === true,
|
||||
dcrTtlMinSeconds,
|
||||
dcrTtlMaxSeconds,
|
||||
});
|
||||
|
||||
// #1353: loud stderr security WARN when DCR is enabled. DCR is an
|
||||
@@ -820,6 +855,20 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
app.use('/register', cors(corsOAuthOptions));
|
||||
app.use('/revoke', cors(corsOAuthOptions));
|
||||
|
||||
// #2179: capture the optional `token_ttl_seconds` DCR extension field
|
||||
// BEFORE the SDK's /register handler runs — its request schema strips
|
||||
// unknown body members, so the value would never reach registerClient.
|
||||
// The rest of the chain runs inside dcrRegistrationContext; the clients
|
||||
// store clamps + persists it. Malformed values are ignored (fail-safe:
|
||||
// absent → server default; out-of-range → clamped downstream; a TTL hint
|
||||
// never rejects a registration). express.json() here is idempotent with
|
||||
// the SDK router's own body parser.
|
||||
app.use('/register', express.json(), (req: Request, _res: Response, next: NextFunction) => {
|
||||
const raw = (req.body as Record<string, unknown> | null | undefined)?.token_ttl_seconds;
|
||||
const tokenTtlSeconds = typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined;
|
||||
dcrRegistrationContext.run({ tokenTtlSeconds }, next);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom client_credentials handler (before mcpAuthRouter)
|
||||
// SDK's token handler only supports authorization_code and refresh_token
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
parseSourceConfig,
|
||||
normalizeSourceConfig,
|
||||
isSourceFederated,
|
||||
sourceFederationState,
|
||||
type SourceRow as LoadedSourceRow,
|
||||
} from '../core/sources-load.ts';
|
||||
|
||||
@@ -470,8 +471,14 @@ async function runList(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
// Human-readable table.
|
||||
console.log('SOURCES');
|
||||
console.log('───────');
|
||||
for (const e of entries) {
|
||||
const fedMark = e.federated ? 'federated' : (e as any).archived ? '⚠ archived' : 'isolated';
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const e = entries[i];
|
||||
// Explicit `federated: false` (`sources unfederate`) fully isolates a
|
||||
// source's reads in both directions; an absent key ('unset') only keeps
|
||||
// it out of OTHER anchors' reads — its own unqualified reads still widen
|
||||
// outward (see sourceFederationState). Collapsing both to "isolated"
|
||||
// overstates what an unset flag does.
|
||||
const fedMark = (e as any).archived ? '⚠ archived' : sourceFederationState(rows[i].config);
|
||||
const pathStr = e.local_path ?? '(no local path)';
|
||||
const sync = e.last_sync_at ? `last sync ${e.last_sync_at}` : 'never synced';
|
||||
console.log(` ${e.id.padEnd(20)} ${fedMark.padEnd(12)} ${String(e.page_count).padStart(6)} pages ${sync}`);
|
||||
|
||||
+12
-14
@@ -15,7 +15,7 @@
|
||||
* - Workers — supervisor health from the audit JSONL
|
||||
* - Queue — live minion_jobs counts BY status (NO time window —
|
||||
* old stuck jobs are exactly what status surfaces)
|
||||
* - Autopilot — daemon PID liveness via kill -0 probe
|
||||
* - Autopilot — daemon PID liveness plus gbrain-autopilot identity probe
|
||||
*
|
||||
* Exit codes (kubectl-style):
|
||||
* 0 snapshot produced successfully (even if it carries warnings)
|
||||
@@ -40,6 +40,10 @@ import { existsSync, readFileSync } from 'node:fs';
|
||||
import { gbrainPath, loadConfig, isThinClient } from '../core/config.ts';
|
||||
import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts';
|
||||
import { VERSION } from '../version.ts';
|
||||
import {
|
||||
classifyAutopilotLockHolder,
|
||||
type AutopilotLockProbeDeps,
|
||||
} from '../core/autopilot-lock.ts';
|
||||
import {
|
||||
buildSyncStatusReport,
|
||||
type SyncStatusReport,
|
||||
@@ -297,8 +301,10 @@ function buildWorkerSummary(): WorkerSummary {
|
||||
return { crashes_24h, clean_exits_24h, by_cause, last_event_ts };
|
||||
}
|
||||
|
||||
function buildAutopilotStatus(): AutopilotStatus {
|
||||
const lockPath = gbrainPath('autopilot.lock');
|
||||
export function buildAutopilotStatus(
|
||||
lockPath: string = gbrainPath('autopilot.lock'),
|
||||
deps: AutopilotLockProbeDeps = {},
|
||||
): AutopilotStatus {
|
||||
const lockfile_present = existsSync(lockPath);
|
||||
let pid: number | null = null;
|
||||
let running = false;
|
||||
@@ -308,16 +314,8 @@ function buildAutopilotStatus(): AutopilotStatus {
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
pid = parsed;
|
||||
try {
|
||||
// kill -0 probes liveness without sending a real signal. Throws ESRCH
|
||||
// if the PID is gone, EPERM if alive but owned by another user (which
|
||||
// still tells us "something with that PID exists").
|
||||
process.kill(parsed, 0);
|
||||
running = true;
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
running = code === 'EPERM';
|
||||
}
|
||||
const holder = classifyAutopilotLockHolder(parsed, process.pid, deps);
|
||||
running = holder.state === 'alive-autopilot' || holder.state === 'alive-unknown';
|
||||
}
|
||||
} catch {
|
||||
/* unreadable lockfile, leave pid=null/running=false */
|
||||
@@ -592,7 +590,7 @@ function renderHuman(report: StatusReport): string {
|
||||
if (a.running) {
|
||||
lines.push(` running (PID ${a.pid})`);
|
||||
} else if (a.lockfile_present) {
|
||||
lines.push(` stale lockfile (PID ${a.pid ?? '?'} not alive). Run \`gbrain autopilot --install\` to restart.`);
|
||||
lines.push(` stale lockfile (PID ${a.pid ?? '?'} is not a live autopilot process). Run \`gbrain autopilot --install\` to restart.`);
|
||||
} else {
|
||||
lines.push(' not running. Install with `gbrain autopilot --install`.');
|
||||
}
|
||||
|
||||
+61
-3
@@ -3647,10 +3647,68 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// covered regardless.
|
||||
const extractOpts = opts.sourceId ? { sourceId: opts.sourceId } : undefined;
|
||||
if (!opts.noExtract && totalChanges > 100 && pagesAffected.length > 0) {
|
||||
// #2849: above the size gate the deferred extraction must be DURABLY
|
||||
// QUEUED, not just hinted. The autopilot cycle's extract phase is
|
||||
// slug-scoped (an up_to_date follow-up sync hands it an empty
|
||||
// pagesAffected), so a webhook-driven large sync left
|
||||
// `links_extracted_at` unstamped FOREVER unless an operator ran
|
||||
// `gbrain extract --stale` by hand. Submit a source-scoped stale-sweep
|
||||
// job bound to the consumed commit (idempotency key) so repeated
|
||||
// webhook deliveries / sync retries of the same commit coalesce onto
|
||||
// one job. The sweep itself is the watermark scan — it picks up the
|
||||
// pages this run imported AND any banked across resumed runs.
|
||||
// Best-effort: queue submission failure falls back to the hint-only
|
||||
// behavior (the pages stay stale + visible to doctor, never mis-stamped).
|
||||
let queuedJobId: number | string | null = null;
|
||||
try {
|
||||
const { MinionQueue } = await import('../core/minions/queue.ts');
|
||||
const { STALE_TIME_BUDGET_MS } = await import('./extract.ts');
|
||||
const queue = new MinionQueue(engine);
|
||||
const payload = {
|
||||
stale: true,
|
||||
...(opts.sourceId ? { sourceId: opts.sourceId } : {}),
|
||||
reason: 'sync_size_gate',
|
||||
// Bound to the PIN this run drained to (== headCommit unless resuming
|
||||
// a stored target), not live HEAD — the sweep covers what we imported.
|
||||
deferred_commit: pin,
|
||||
};
|
||||
// The stale sweep has its own internal wall-clock budget
|
||||
// (GBRAIN_EXTRACT_TIME_BUDGET_MS-derived); without an explicit
|
||||
// timeout_ms the job would inherit the tight null-default and get
|
||||
// wall-clock-killed mid-sweep (#1737 class). 5-min headroom.
|
||||
const timeoutMs = STALE_TIME_BUDGET_MS + 5 * 60 * 1000;
|
||||
// NO maxWaiting here: with an unscoped (NULL-sourceId) payload the
|
||||
// queue's coalesce filter matches ANY waiting 'extract' job (e.g. a
|
||||
// remediation-submitted {mode:'links'} row) and returns THAT job —
|
||||
// silently dropping the sweep while we log "queued". The idempotency
|
||||
// key alone is the dedup for repeat submissions toward the same pin.
|
||||
const key = `extract-stale:${opts.sourceId ?? 'default'}:${pin}`;
|
||||
const isLiveSweep = (j: { status: string; data: Record<string, unknown> }): boolean =>
|
||||
j.data?.stale === true && ['waiting', 'delayed', 'active'].includes(j.status);
|
||||
let job = await queue.add('extract', payload, { idempotency_key: key, timeout_ms: timeoutMs });
|
||||
if (!isLiveSweep(job)) {
|
||||
// The key slot holds a FINISHED row: a prior sweep toward this pin
|
||||
// that completed BEFORE this run's pages landed (checkpoint-resume /
|
||||
// blocked-advance re-sync of the same target). Those pages went
|
||||
// stale after that sweep's watermark pass, so coalescing onto the
|
||||
// finished row would strand them — queue a fresh sweep under a
|
||||
// run-unique key. (An 'active' sweep is safe to coalesce onto: its
|
||||
// end-of-run staleRemaining re-count chains a continuation.)
|
||||
job = await queue.add('extract', payload, {
|
||||
idempotency_key: `${key}:${Date.now()}`,
|
||||
timeout_ms: timeoutMs,
|
||||
});
|
||||
}
|
||||
// Only claim "queued" once we verified the returned row IS a live
|
||||
// stale sweep — never trust queue.add's row blind.
|
||||
if (isLiveSweep(job)) queuedJobId = job.id;
|
||||
} catch { /* best-effort — hint below still tells the operator */ }
|
||||
slog(
|
||||
` Large sync: deferring link/timeline extraction. ` +
|
||||
`Run 'gbrain extract --stale${opts.sourceId ? ` --source-id ${opts.sourceId}` : ''}' ` +
|
||||
`(or let the autopilot cycle's extract phase sweep it).`,
|
||||
` Large sync: deferring link/timeline extraction` +
|
||||
(queuedJobId != null
|
||||
? ` — queued stale-sweep job #${queuedJobId} (source: ${opts.sourceId ?? 'default'}); a running jobs worker will consume it.`
|
||||
: `.`) +
|
||||
` Run 'gbrain extract --stale${opts.sourceId ? ` --source-id ${opts.sourceId}` : ''}' to extract now.`,
|
||||
);
|
||||
}
|
||||
if (!opts.noExtract && totalChanges <= 100 && pagesAffected.length > 0) {
|
||||
|
||||
+11
-2
@@ -624,12 +624,13 @@ async function cmdExtract(engine: BrainEngine, rest: string[]): Promise<void> {
|
||||
const sub = rest[0];
|
||||
if (sub !== '--from-pages') {
|
||||
process.stderr.write(
|
||||
'Usage: gbrain takes extract --from-pages [--yes] [--dry-run] [--source-id <id>] [--max-pages N (clamped to 1000)] [--include-covered] [--holder <name>]\n' +
|
||||
'Usage: gbrain takes extract --from-pages [--yes] [--dry-run] [--json] [--source-id <id>] [--max-pages N (clamped to 1000)] [--include-covered] [--holder <name>]\n' +
|
||||
'Runs progress: pages that already hold takes are skipped, so repeat runs sweep a large corpus in slices. --include-covered rescans everything (refresh).\n',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const dryRun = rest.includes('--dry-run');
|
||||
const json = rest.includes('--json');
|
||||
const skipConfirm = rest.includes('--yes');
|
||||
const sourceIdx = rest.indexOf('--source-id');
|
||||
const sourceIdFilter = sourceIdx >= 0 ? rest[sourceIdx + 1] : undefined;
|
||||
@@ -667,9 +668,17 @@ async function cmdExtract(engine: BrainEngine, rest: string[]): Promise<void> {
|
||||
holder,
|
||||
});
|
||||
if (result.llm_unavailable) {
|
||||
process.stderr.write(`[takes extract] chat gateway unavailable (no API key configured).\n`);
|
||||
if (json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
} else {
|
||||
process.stderr.write(`[takes extract] chat gateway unavailable (no API key configured).\n`);
|
||||
}
|
||||
process.exit(2);
|
||||
}
|
||||
if (json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(
|
||||
`takes extract --from-pages: ${result.claims_extracted} claim(s) from ${result.pages_scanned} page(s)` +
|
||||
(dryRun ? ' (dry-run)' : '') + '\n',
|
||||
|
||||
+42
-11
@@ -473,19 +473,39 @@ export async function runPostUpgrade(args: string[] = []): Promise<void> {
|
||||
// `ze_sunset_notice_shown` (same pattern as the search-mode banner).
|
||||
try {
|
||||
const shown = await engine.getConfig('ze_sunset_notice_shown');
|
||||
const { DEFAULT_EMBEDDING_MODEL } = await import('../core/ai/defaults.ts');
|
||||
const { DEFAULT_EMBEDDING_MODEL, ZEROENTROPY_SUNSET_DATE } = await import('../core/ai/defaults.ts');
|
||||
const effectiveModel = cfgSchema.embedding_model ?? DEFAULT_EMBEDDING_MODEL;
|
||||
const rerankerModel = await engine.getConfig('search.reranker.model');
|
||||
// Effective reranker via the plane search actually reranks with
|
||||
// (mode bundle + search.reranker.* overrides) — the bare config
|
||||
// key is unset by default while balanced/tokenmax rerank with the
|
||||
// bundle's zeroentropyai model. Same resolution as the
|
||||
// provider_sunset doctor check.
|
||||
let rerankerModel: string | undefined;
|
||||
try {
|
||||
const { loadSearchModeConfig, resolveSearchMode } = await import('../core/search/mode.ts');
|
||||
const knobs = resolveSearchMode(await loadSearchModeConfig(engine));
|
||||
if (knobs.reranker_enabled) rerankerModel = knobs.reranker_model;
|
||||
} catch { /* no reranker-exposure claim */ }
|
||||
const onZeEmbedding = effectiveModel.startsWith('zeroentropyai:');
|
||||
const onZeReranker = !!rerankerModel?.startsWith('zeroentropyai:');
|
||||
if (shown !== 'true' && (onZeEmbedding || onZeReranker)) {
|
||||
// Paste-ready --dim from the ACTUAL column width (config can
|
||||
// drift): keeping the current width avoids a needless dimension
|
||||
// transition + index rebuild when the target supports it.
|
||||
let colDims: number | null = null;
|
||||
try {
|
||||
const { readContentChunksEmbeddingDim } = await import('../core/embedding-dim-check.ts');
|
||||
colDims = (await readContentChunksEmbeddingDim(engine)).dims;
|
||||
} catch { /* fresh brain — omit --dim */ }
|
||||
const dimFlag = colDims ? ` --dim ${colDims}` : '';
|
||||
console.log('');
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('[gbrain] ACTION REQUIRED: ZeroEntropy hosted API sunsets 2026-09-04.');
|
||||
console.log(`[gbrain] ACTION REQUIRED: ZeroEntropy hosted API sunsets ${ZEROENTROPY_SUNSET_DATE}.`);
|
||||
if (onZeEmbedding) {
|
||||
console.log(`[gbrain] This brain embeds with ${effectiveModel}. After the sunset,`);
|
||||
console.log('[gbrain] semantic retrieval STOPS WORKING (queries can no longer be');
|
||||
console.log('[gbrain] embedded against your existing vectors).');
|
||||
console.log('[gbrain] semantic retrieval STOPS WORKING entirely — your EXISTING');
|
||||
console.log('[gbrain] vectors become unqueryable (queries embed through the same');
|
||||
console.log('[gbrain] endpoint), not just new content.');
|
||||
}
|
||||
if (onZeReranker) {
|
||||
console.log(`[gbrain] The reranker (${rerankerModel}) also sunsets; search falls`);
|
||||
@@ -493,17 +513,28 @@ export async function runPostUpgrade(args: string[] = []): Promise<void> {
|
||||
}
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('');
|
||||
console.log('Migrate before the sunset (resumable; preview cost first):');
|
||||
console.log(' gbrain migrate embeddings --to <provider:model> --dry-run');
|
||||
console.log(' gbrain migrate embeddings --to <provider:model>');
|
||||
console.log('Two fixes, either works:');
|
||||
console.log('');
|
||||
console.log('Self-hosting zembed-1 (weights are Apache-2.0) via llama-server /');
|
||||
console.log('ollama also works and preserves your existing vectors — point');
|
||||
console.log('embedding at the local endpoint instead of migrating.');
|
||||
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('');
|
||||
console.log('[2] Migrate to another provider (resumable; preview cost first):');
|
||||
console.log(` gbrain migrate embeddings --to <provider:model>${dimFlag} --dry-run`);
|
||||
console.log(` gbrain migrate embeddings --to <provider:model>${dimFlag}`);
|
||||
if (colDims) {
|
||||
console.log(` (--dim ${colDims} is this brain's current index width — keep it to`);
|
||||
console.log(' avoid a needless schema rebuild when the target supports it.)');
|
||||
}
|
||||
if (onZeReranker) {
|
||||
console.log('');
|
||||
console.log('Reranker: gbrain config set search.reranker.enabled false (or pick another).');
|
||||
}
|
||||
console.log('');
|
||||
console.log(`\`gbrain doctor\` will keep flagging this until the brain is off the`);
|
||||
console.log('provider (check name: provider_sunset).');
|
||||
console.log('');
|
||||
await engine.setConfig('ze_sunset_notice_shown', 'true');
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -6,21 +6,16 @@
|
||||
* the self-upgrade refresh path.
|
||||
*/
|
||||
|
||||
import { readUpdateCache } from '../self-upgrade.ts';
|
||||
import { pendingUpgradeVersion } from '../self-upgrade.ts';
|
||||
import type { AdvisorCollector } from './types.ts';
|
||||
|
||||
export const collectVersion: AdvisorCollector = {
|
||||
id: 'version',
|
||||
collect: async (ctx) => {
|
||||
let latest: string | undefined;
|
||||
try {
|
||||
const entry = readUpdateCache();
|
||||
if (entry && entry.marker.kind === 'upgrade_available' && entry.marker.latest) {
|
||||
latest = entry.marker.latest;
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
// Shared stale/foreign-cache guard: fresh cache only, and only an upgrade
|
||||
// strictly newer than the RUNNING version (pendingUpgradeVersion owns the
|
||||
// rule; never throws).
|
||||
const latest = pendingUpgradeVersion(ctx.version, Date.now());
|
||||
if (!latest) return [];
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -16,6 +16,14 @@ export interface RecommendedSkill {
|
||||
}
|
||||
|
||||
export const RECOMMENDED: RecommendedSkill[] = [
|
||||
{
|
||||
// First on purpose: the day-one "now what?" answer. Every other skill in
|
||||
// this list gets dramatically better once the brain holds the user's real
|
||||
// life — cold-start is what fills it.
|
||||
slug: 'cold-start',
|
||||
description:
|
||||
'START HERE. Day-one brain filling: imports your Gmail, calendar, and contacts (via ClawVisor — an OAuth vault, the agent never holds raw tokens) or offline archives, one consented phase at a time. Run it right after install.',
|
||||
},
|
||||
{
|
||||
slug: 'book-mirror',
|
||||
description:
|
||||
|
||||
@@ -19,3 +19,14 @@
|
||||
// steps: {2560, 1280, 640, 320, 160, 80, 40} — see ai/dims.ts.
|
||||
export const DEFAULT_EMBEDDING_MODEL = 'zeroentropyai:zembed-1';
|
||||
export const DEFAULT_EMBEDDING_DIMENSIONS = 1280;
|
||||
|
||||
/**
|
||||
* ZeroEntropy announced (2026-07-24) that its hosted API — including
|
||||
* /models/embed and /models/rerank — shuts down on this date. Query
|
||||
* embedding uses the same endpoint as ingestion, so a brain still on a
|
||||
* `zeroentropyai:*` embedding model loses semantic retrieval ENTIRELY on
|
||||
* that date (existing vectors become unqueryable, not just new content).
|
||||
* Single source of truth for the upgrade banner + the `provider_sunset`
|
||||
* doctor check. Self-hosting the Apache-2.0 zembed-1 weights is unaffected.
|
||||
*/
|
||||
export const ZEROENTROPY_SUNSET_DATE = '2026-09-04';
|
||||
|
||||
+18
-3
@@ -6,6 +6,10 @@
|
||||
export interface ProbeResult {
|
||||
reachable: boolean;
|
||||
models_endpoint_valid?: boolean;
|
||||
/** Model ids the endpoint reports as served/pulled (when the models
|
||||
* endpoint is valid). Lets callers check "is the recipe's model actually
|
||||
* available" instead of treating daemon-up as model-ready. */
|
||||
models?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -22,14 +26,25 @@ export async function probeOpenAICompat(baseUrl: string, timeoutMs: number = 100
|
||||
signal: controller.signal,
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) return { reachable: true, models_endpoint_valid: false, error: `HTTP ${res.status}` };
|
||||
if (!res.ok) {
|
||||
clearTimeout(timer);
|
||||
return { reachable: true, models_endpoint_valid: false, error: `HTTP ${res.status}` };
|
||||
}
|
||||
// Keep the abort timer live through the BODY read — a daemon that accepts,
|
||||
// returns headers, then stalls the body would otherwise hang past the
|
||||
// advertised timeout (the probe sits on init's interactive critical path).
|
||||
const body = await res.json().catch(() => null);
|
||||
clearTimeout(timer);
|
||||
if (!body || typeof body !== 'object') {
|
||||
return { reachable: true, models_endpoint_valid: false, error: 'non-JSON response' };
|
||||
}
|
||||
const isList = (body as any).object === 'list' && Array.isArray((body as any).data);
|
||||
return { reachable: true, models_endpoint_valid: isList };
|
||||
const models = isList
|
||||
? ((body as any).data as Array<{ id?: unknown }>)
|
||||
.map((m) => (typeof m?.id === 'string' ? m.id : ''))
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
return { reachable: true, models_endpoint_valid: isList, models };
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
return { reachable: false, error: e instanceof Error ? e.message : String(e) };
|
||||
|
||||
@@ -35,7 +35,11 @@ export const claudeCli: Recipe = {
|
||||
// No embedding or expansion touchpoints — chat-only.
|
||||
chat: {
|
||||
models: [
|
||||
'claude-fable-5',
|
||||
'claude-opus-5',
|
||||
'claude-opus-4-8',
|
||||
'claude-opus-4-7',
|
||||
'claude-sonnet-5',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-haiku-4-5-20251001',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
export type AutopilotLockHolder =
|
||||
| { state: 'dead' }
|
||||
| { state: 'self' }
|
||||
| { state: 'alive-autopilot' }
|
||||
| { state: 'alive-foreign' }
|
||||
| { state: 'alive-unknown' };
|
||||
|
||||
export interface AutopilotLockProbeDeps {
|
||||
isPidAlive?: (pid: number) => boolean;
|
||||
readProcessCommand?: (pid: number) => string | null;
|
||||
}
|
||||
|
||||
export function isPidAlive(pid: number): boolean {
|
||||
if (!Number.isFinite(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
export function readProcessCommand(pid: number): string | null {
|
||||
if (!Number.isFinite(pid) || pid <= 0) return null;
|
||||
try {
|
||||
const out = execFileSync('ps', ['-p', String(pid), '-o', 'args='], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 1000,
|
||||
}).trim();
|
||||
return out.length > 0 ? out : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function looksLikeGbrainAutopilotCommand(command: string): boolean {
|
||||
const normalized = command.replace(/\\/g, '/').trim();
|
||||
if (!/(^|\s)autopilot(\s|$)/i.test(normalized)) return false;
|
||||
if (/(^|[\/\s])gbrain(?:\.exe)?(\s|$)/i.test(normalized)) return true;
|
||||
return /(^|\s)(?:\S+\/)?(?:\.{1,2}\/)?(?:src\/)?cli\.(?:ts|js|mjs)(\s|$)/i.test(normalized)
|
||||
|| /(^|\s)\S*\/src\/cli\.(?:ts|js|mjs)(\s|$)/i.test(normalized);
|
||||
}
|
||||
|
||||
export function classifyAutopilotLockHolder(
|
||||
pid: number,
|
||||
currentPid: number = process.pid,
|
||||
deps: AutopilotLockProbeDeps = {},
|
||||
): AutopilotLockHolder {
|
||||
if (!Number.isFinite(pid) || pid <= 0) return { state: 'dead' };
|
||||
if (pid === currentPid) return { state: 'self' };
|
||||
|
||||
const probeAlive = deps.isPidAlive ?? isPidAlive;
|
||||
if (!probeAlive(pid)) return { state: 'dead' };
|
||||
|
||||
const probeCommand = deps.readProcessCommand ?? readProcessCommand;
|
||||
const command = probeCommand(pid);
|
||||
if (command === null) return { state: 'alive-unknown' };
|
||||
return looksLikeGbrainAutopilotCommand(command)
|
||||
? { state: 'alive-autopilot' }
|
||||
: { state: 'alive-foreign' };
|
||||
}
|
||||
+19
-15
@@ -10,8 +10,9 @@
|
||||
* and dedupe match on the marker (surviving reordering and command-string
|
||||
* drift), and foreign hooks / permissions / every other settings key are
|
||||
* never touched. Writes are atomic (tmp + rename) with a `.bak` of the
|
||||
* previous file; a parse-broken existing file is backed up aside and the
|
||||
* write starts clean with a loud note in the result [G5].
|
||||
* previous file; a parse-broken existing file ABORTS the write with
|
||||
* fix-and-re-run instructions (fail-closed, matching removal's stance — a
|
||||
* rewrite could drop permissions/allowlist entries gbrain cannot parse) [G5].
|
||||
*
|
||||
* MCP registration helpers BUILD ARGV ONLY — the bootstrap dispatcher execs
|
||||
* them (and records the registration in the install receipt). Precedent:
|
||||
@@ -70,7 +71,9 @@ export interface WriteClaudeHooksResult {
|
||||
removedPrior: number;
|
||||
/** `.bak` of the pre-write file (null when no file existed). */
|
||||
backupPath: string | null;
|
||||
/** Where a parse-broken original was moved (null when parse succeeded). */
|
||||
/** Always null since the fail-closed change (a parse-broken file now
|
||||
* aborts the write instead of being moved aside). Kept for result-shape
|
||||
* stability. */
|
||||
brokenBackupPath: string | null;
|
||||
notes: string[];
|
||||
}
|
||||
@@ -247,10 +250,13 @@ interface LoadedSettings {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the existing settings file. Absent/empty → `{}`. Parse error → the
|
||||
* broken file is MOVED to a timestamped `.broken-*` backup and the caller
|
||||
* starts clean, with a loud note (the user's broken-by-hand file is never
|
||||
* silently destroyed, and never silently half-merged) [G5].
|
||||
* Parse the existing settings file. Absent/empty → `{}`. Parse error →
|
||||
* THROW, fail-closed [G5]: the file may carry permissions/allowlist entries
|
||||
* gbrain cannot see, so replacing it with a fresh file (the old behavior —
|
||||
* backup + start clean) silently dropped the user's live settings. Removal
|
||||
* (`removeClaudeHooks`) already refuses to touch what it cannot parse; the
|
||||
* write path now matches that stance. The user fixes the JSON, re-runs, and
|
||||
* the structural merge preserves everything.
|
||||
*/
|
||||
function loadSettings(path: string): LoadedSettings {
|
||||
const notes: string[] = [];
|
||||
@@ -273,14 +279,12 @@ function loadSettings(path: string): LoadedSettings {
|
||||
}
|
||||
return { settings: parsed as SettingsObject, existed: true, brokenBackupPath: null, notes };
|
||||
} catch (e) {
|
||||
const broken = `${path}.broken-${Date.now()}`;
|
||||
copyFileSync(path, broken);
|
||||
notes.push(
|
||||
`WARNING: ${path} was not valid JSON (${(e as Error).message}); ` +
|
||||
`the original was backed up to ${broken} and hooks were written to a fresh file. ` +
|
||||
`Restore any hand-made settings from the backup.`,
|
||||
throw new Error(
|
||||
`${path} is not valid JSON (${(e as Error).message}) — refusing to rewrite a settings file ` +
|
||||
`gbrain cannot parse (it may carry your permissions/allowlist entries). Fix the JSON by ` +
|
||||
`hand, then re-run \`gbrain bootstrap hooks --harness claude-code --repair\` ` +
|
||||
`(the structural merge preserves your settings).`,
|
||||
);
|
||||
return { settings: {}, existed: true, brokenBackupPath: broken, notes };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +370,7 @@ export function writeClaudeHooks(
|
||||
settings.hooks = hooks;
|
||||
|
||||
let backupPath: string | null = null;
|
||||
if (existed && brokenBackupPath === null) {
|
||||
if (existed) {
|
||||
backupPath = `${settingsPath}.bak`;
|
||||
copyFileSync(settingsPath, backupPath);
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ function requiredKeys(bank: QuestionBank): string[] {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SetAnswerResult =
|
||||
| { ok: true; sink: 'state'; key: string; value: string }
|
||||
| { ok: true; sink: 'state'; key: string; value: string; invalidatedConfirmation?: boolean }
|
||||
/** Config-sink keys [CX2-13]: nothing persisted here; the caller routes the
|
||||
* value via `routeProviderKeyToConfig`. The value is deliberately NOT
|
||||
* echoed back in this result. */
|
||||
@@ -305,20 +305,23 @@ export function setAnswer(workspaceDir: string, key: string, rawValue: string):
|
||||
if (!read.ok) return read;
|
||||
const state = read.state;
|
||||
state.answers[key] = { value: stored, set_at: new Date().toISOString() };
|
||||
// Any change invalidates a prior read-back confirmation [A8].
|
||||
// Any change invalidates a prior read-back confirmation [A8]. Surfaced to
|
||||
// the caller so the CLI can WARN — silently voiding the confirmation used
|
||||
// to fail much later, at render, with no pointer back to this --set.
|
||||
const invalidatedConfirmation = state.confirmed !== undefined;
|
||||
delete state.confirmed;
|
||||
try {
|
||||
writeInterviewState(workspaceDir, state);
|
||||
} catch (e) {
|
||||
return { ok: false, code: 'io_error', message: `could not write interview state: ${(e as Error).message}` };
|
||||
}
|
||||
return { ok: true, sink: 'state', key, value: stored };
|
||||
return { ok: true, sink: 'state', key, value: stored, invalidatedConfirmation };
|
||||
}
|
||||
|
||||
export function skipAnswer(
|
||||
workspaceDir: string,
|
||||
key: string
|
||||
): { ok: true; sink: 'state' | 'config'; key: string; skipped: true } | InterviewError {
|
||||
): { ok: true; sink: 'state' | 'config'; key: string; skipped: true; invalidatedConfirmation?: boolean } | InterviewError {
|
||||
const bank = loadQuestionBank();
|
||||
const spec = bankSpec(bank, key);
|
||||
if (!spec) {
|
||||
@@ -339,13 +342,15 @@ export function skipAnswer(
|
||||
if (!read.ok) return read;
|
||||
const state = read.state;
|
||||
state.answers[key] = { value: '', set_at: new Date().toISOString(), skipped: true };
|
||||
// Same [A8] invalidation-surfacing as setAnswer.
|
||||
const invalidatedConfirmation = state.confirmed !== undefined;
|
||||
delete state.confirmed;
|
||||
try {
|
||||
writeInterviewState(workspaceDir, state);
|
||||
} catch (e) {
|
||||
return { ok: false, code: 'io_error', message: `could not write interview state: ${(e as Error).message}` };
|
||||
}
|
||||
return { ok: true, sink: 'state', key, skipped: true };
|
||||
return { ok: true, sink: 'state', key, skipped: true, invalidatedConfirmation };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -133,6 +133,28 @@ function isRateLimitOr5xx(stderr: string): boolean {
|
||||
return /HTTP 5\d\d|HTTP 429|rate limit/i.test(stderr);
|
||||
}
|
||||
|
||||
/** `gh auth status`'s `--active` flag (added in cli/cli v2.57.0, 2024-09-11)
|
||||
* scopes the check to only the active account instead of aggregating every
|
||||
* registered account. On an older `gh`, passing an unrecognized flag makes
|
||||
* the WHOLE command fail — so Gate 2 must detect support before using it. */
|
||||
const GH_ACTIVE_FLAG_MIN_VERSION = [2, 57, 0] as const;
|
||||
|
||||
/** Parses the `X.Y.Z` out of `gh --version`'s first line (`gh version X.Y.Z (DATE)`).
|
||||
* Returns null on any unrecognized format — callers treat that as "unknown,
|
||||
* don't assume support". */
|
||||
function parseGhVersion(versionOutput: string): readonly [number, number, number] | null {
|
||||
const m = /\bgh version (\d+)\.(\d+)\.(\d+)/.exec(versionOutput);
|
||||
if (!m) return null;
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
}
|
||||
|
||||
function ghVersionAtLeast(v: readonly [number, number, number], min: readonly [number, number, number]): boolean {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (v[i] !== min[i]) return v[i]! > min[i]!;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The authenticated gh login, or null when it cannot be read/parsed. */
|
||||
async function fetchAuthedLogin(runner: ExecRunner): Promise<string | null> {
|
||||
const res = await runner(['gh', 'api', 'user']);
|
||||
@@ -594,7 +616,22 @@ export async function createPrivateRepo(
|
||||
}
|
||||
|
||||
// Gate 2: authenticated. Exit-code-2 — the human runs `gh auth login`.
|
||||
const ghAuth = await runner(['gh', 'auth', 'status']);
|
||||
// `--hostname github.com` scopes the check to the host this flow actually
|
||||
// targets (every downstream call — parseGithubOwnerRepo, the repo-create
|
||||
// URL fallback, etc. — is github.com-only), so an unrelated broken account
|
||||
// on some other configured host (e.g. a GitHub Enterprise instance) can't
|
||||
// false-block it either. `--active` (only when the installed `gh` supports
|
||||
// it) further restricts that host's check to the active account. Bare
|
||||
// `gh auth status` aggregates EVERY registered account on EVERY host and
|
||||
// exits 1 if any one of them is invalid — even an unused, long-expired
|
||||
// account — which false-blocks this gate while the active account (what
|
||||
// `gh`/`git` actually use) is perfectly healthy.
|
||||
const ghVersionTuple = parseGhVersion(ghVersion.stdout);
|
||||
const ghSupportsActiveFlag = ghVersionTuple !== null && ghVersionAtLeast(ghVersionTuple, GH_ACTIVE_FLAG_MIN_VERSION);
|
||||
const ghAuthArgv = ghSupportsActiveFlag
|
||||
? ['gh', 'auth', 'status', '--active', '--hostname', 'github.com']
|
||||
: ['gh', 'auth', 'status', '--hostname', 'github.com'];
|
||||
const ghAuth = await runner(ghAuthArgv);
|
||||
if (ghAuth.code !== 0) {
|
||||
throw new BootstrapError(
|
||||
'GH_AUTH',
|
||||
|
||||
@@ -270,7 +270,20 @@ export const PHASES: PhaseSpec[] = [
|
||||
detect: (ws, ctx) => {
|
||||
const regs = ctx.receipt?.registrations ?? [];
|
||||
if (regs.length > 0) {
|
||||
return { state: 'done', detail: regs.map((r) => `${r.host} (${r.scope})`).join(', ') };
|
||||
// A registration whose detail carries 'mcp' ('mcp' or 'mcp+hooks')
|
||||
// means MCP actually registered. A 'hooks'-only detail means the host
|
||||
// binary was missing at wire time (hooks landed, MCP did not) — the
|
||||
// phase is PARTIAL, not done, so a resuming agent re-runs it once the
|
||||
// CLI is on PATH instead of trusting a false "done".
|
||||
const mcpRegistered = regs.some((r) => (r.detail ?? '').includes('mcp'));
|
||||
if (mcpRegistered) {
|
||||
return { state: 'done', detail: regs.map((r) => `${r.host} (${r.scope})`).join(', ') };
|
||||
}
|
||||
return {
|
||||
state: 'partial',
|
||||
detail: 'hooks installed but MCP not registered (the harness CLI was not on PATH) — ' +
|
||||
're-run `gbrain bootstrap hooks --harness <claude-code|codex>` once it is',
|
||||
};
|
||||
}
|
||||
if (hooksInstalled(ws)) return { state: 'done', detail: 'hooks present in .claude/settings.local.json' };
|
||||
return { state: 'pending' };
|
||||
|
||||
@@ -75,6 +75,9 @@ export interface VerifyReport {
|
||||
capability: CapabilityReport;
|
||||
/** The three scripted first-run prompts [D3.6/A4]. */
|
||||
tour: string[];
|
||||
/** The OOBE hand-off lines (ownership + the cold-start next action) —
|
||||
* unconditional in the shape like `tour`; printed in the report on PASS. */
|
||||
handoff: string[];
|
||||
}
|
||||
|
||||
export interface VerifyOpts {
|
||||
@@ -97,11 +100,15 @@ export const VERIFY_PROBE_ENTITY_SLUG = 'wiki/bootstrap-verify-probe-entity';
|
||||
/** Deterministic magic-moment token the fence fact carries [CX-P0.5]. */
|
||||
export const VERIFY_MAGIC_TOKEN = 'verify-lighthouse-passphrase';
|
||||
|
||||
/** The three scripted first-run prompts [D3.6] — pinned by the A4 snapshot test. */
|
||||
/** The three scripted first-run prompts [D3.6] — pinned by the A4 snapshot test.
|
||||
* Exactly three (the count is copy-pinned here, in BOOTSTRAP_FOR_AGENTS.md,
|
||||
* and the A4 plan). Prompt 3 must be TRUE on day one — the brain is empty at
|
||||
* install, so "everything ingested so far" would be an anticlimax; the
|
||||
* round-trip fact from prompt 2 is the honest day-one payoff. */
|
||||
export const FIRST_RUN_TOUR: readonly string[] = [
|
||||
'"Who am I to you?" — identity from SOUL.md/USER.md, no lookup needed.',
|
||||
'"Remember that <one small true fact>." Then restart the session and ask me about it — that round-trip is the whole product.',
|
||||
'"What do you know about this project?" — brain recall over everything ingested so far.',
|
||||
'"Remember that <one small true fact>." — it lands in the brain, not this chat.',
|
||||
'"What do you remember about me?" — asked in the NEW session: on day one that is the fact from prompt 2, recalled from the brain. Every session after this adds more. That round-trip is the whole product.',
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -477,16 +484,33 @@ function checkMcpSurface(): VerifyCheck {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure, engine-free derivation of the collision-fallback source_id for a
|
||||
* workspace — a deterministic hash of the workspace's real path, no DB
|
||||
* lookup involved. `resolveSourceIdCollision` (below) is the only thing that
|
||||
* decides WHETHER this id is actually needed (that half requires the engine,
|
||||
* since the sources registry lives only in the DB) — but the id itself is
|
||||
* safe to preview from an engine-free phase. `bootstrap hooks` does exactly
|
||||
* that, so a human has the fallback id in hand before they ever hand-register
|
||||
* a source, instead of discovering it only after an FK error + a corrective
|
||||
* `verify` run.
|
||||
*/
|
||||
export function deriveWorkspaceSourceId(ws: string): string {
|
||||
const hash = createHash('sha256').update(realpathOrResolve(ws)).digest('hex').slice(0, 8);
|
||||
return `workspace-${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* source_id collision resolution [engine seam]. Render is ENGINE-FREE and the
|
||||
* sources registry lives ONLY in the DB (no registry file exists), so verify —
|
||||
* the one bootstrap subcommand holding an engine — is where a manifest
|
||||
* source_id already registered to a DIFFERENT checkout is detected. On
|
||||
* collision it derives a stable `workspace-<8char-path-hash>` id, persists it
|
||||
* to agent.json (render preserves it on re-render), and names the re-register
|
||||
* steps; every consumer (hooks GBRAIN_SOURCE env, verify, status hints,
|
||||
* attach, repo persistence) reads manifest.source_id, so the derived id
|
||||
* propagates. Returns a sourceId ONLY when it derived one.
|
||||
* collision it derives a stable `workspace-<8char-path-hash>` id (via
|
||||
* `deriveWorkspaceSourceId`), persists it to agent.json (render preserves it
|
||||
* on re-render), and names the re-register steps; every consumer (hooks
|
||||
* GBRAIN_SOURCE env, verify, status hints, attach, repo persistence) reads
|
||||
* manifest.source_id, so the derived id propagates. Returns a sourceId ONLY
|
||||
* when it derived one.
|
||||
*/
|
||||
async function resolveSourceIdCollision(
|
||||
engine: BrainEngine,
|
||||
@@ -506,8 +530,7 @@ async function resolveSourceIdCollision(
|
||||
if (realpathOrResolve(registered) === realpathOrResolve(brainDir)) {
|
||||
return { sourceId: null, check: null }; // same checkout — no collision
|
||||
}
|
||||
const hash = createHash('sha256').update(realpathOrResolve(ws)).digest('hex').slice(0, 8);
|
||||
const derived = `workspace-${hash}`;
|
||||
const derived = deriveWorkspaceSourceId(ws);
|
||||
writeManifest(ws, { ...state.manifest, source_id: derived });
|
||||
return {
|
||||
sourceId: derived,
|
||||
@@ -932,9 +955,11 @@ export async function verifyWorkspace(
|
||||
|
||||
checks.push(checkPushProbe(ws));
|
||||
checks.push(checkInertSkills(ws, caps));
|
||||
checks.push({ id: 'first_run_tour', ok: true, detail: 'three scripted prompts appended to the report [D3.6]' });
|
||||
const tourCheck = { id: 'first_run_tour', ok: true, detail: 'three scripted prompts appended to the report' };
|
||||
checks.push(tourCheck);
|
||||
|
||||
const ok = checks.every((c) => c.ok || c.warn === true);
|
||||
if (!ok) tourCheck.detail = 'tour withheld — prints on PASS';
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
persistVerifyRun(gbrainHomeDir, { ts, ok, checks });
|
||||
@@ -948,8 +973,57 @@ export async function verifyWorkspace(
|
||||
lines.push('');
|
||||
lines.push(renderCapabilityReport(caps));
|
||||
lines.push('');
|
||||
lines.push('First-run tour — hand these three prompts to your human, in order:');
|
||||
FIRST_RUN_TOUR.forEach((p, i) => lines.push(` ${i + 1}. ${p}`));
|
||||
// The tour celebrates a WORKING install — under a FAIL banner it reads as
|
||||
// a mixed signal ("broken, but go enjoy it"). Gate the report lines on ok;
|
||||
// the returned `tour` array (and --json field) stays unconditional so
|
||||
// machine consumers keep a stable shape.
|
||||
const handoff = buildHandoff(ws);
|
||||
if (ok) {
|
||||
lines.push('First-run tour — have your human RESTART the session first');
|
||||
lines.push('(a fresh session proves the files and the brain, not this chat),');
|
||||
lines.push('then try these three prompts in order:');
|
||||
FIRST_RUN_TOUR.forEach((p, i) => lines.push(` ${i + 1}. ${p}`));
|
||||
lines.push('');
|
||||
for (const h of handoff) lines.push(h);
|
||||
} else {
|
||||
lines.push('Fix the FAIL checks above and re-run — the first-run tour prints on PASS.');
|
||||
}
|
||||
|
||||
return { ok, checks, report: lines.join('\n'), capability: caps, tour: [...FIRST_RUN_TOUR] };
|
||||
return { ok, checks, report: lines.join('\n'), capability: caps, tour: [...FIRST_RUN_TOUR], handoff };
|
||||
}
|
||||
|
||||
/**
|
||||
* The post-tour hand-off block [OOBE]: the two things a fresh user must walk
|
||||
* away UNDERSTANDING, in priority order —
|
||||
* 1. OWNERSHIP: the brain is markdown in a repo THEY own (or local-only,
|
||||
* with the one command that gives it a durable home). Ownership is the
|
||||
* trust story; say the URL, say what owning it means.
|
||||
* 2. THE ONE NEXT ACTION: run the cold-start skill. An empty brain is a
|
||||
* database; every flagship skill (book-mirror, briefings, meeting prep)
|
||||
* only becomes magical once the brain holds the user's real life —
|
||||
* cold-start is the designed filler (Gmail/calendar/contacts via
|
||||
* ClawVisor, or offline archives), one consented phase at a time.
|
||||
* Returned unconditionally in the machine shape (like `tour`); printed in
|
||||
* the report only on PASS. Relay it to the human verbatim.
|
||||
*/
|
||||
export function buildHandoff(ws: string): string[] {
|
||||
const origin = gitOriginUrl(ws);
|
||||
const ownership = origin
|
||||
? [
|
||||
`What you own: every memory your agent keeps is a markdown file in YOUR private repo — ${origin}.`,
|
||||
'Read it any time, take it to a second machine (`gbrain bootstrap attach`), or delete it and the brain is gone. It is yours.',
|
||||
]
|
||||
: [
|
||||
'What you own: your agent\'s memory is markdown on this machine only (no remote yet).',
|
||||
'Run `gbrain bootstrap repo` any time to give it a private GitHub home you own — readable, portable, deletable.',
|
||||
];
|
||||
return [
|
||||
...ownership,
|
||||
'',
|
||||
'Fill it next: an empty brain is a database; a filled one is a memory.',
|
||||
'Ask your agent to run the cold-start skill — it imports your real life',
|
||||
'(Gmail, calendar, contacts via ClawVisor, an OAuth vault so the agent never',
|
||||
'holds raw tokens; or offline archives like Google Takeout), one consented',
|
||||
'phase at a time. Each phase is independently valuable — stop whenever.',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -40,8 +40,9 @@ import { findResolverFile, RESOLVER_FILENAMES } from './resolver-filenames.ts';
|
||||
import { redactSecretsInText } from './minions/handlers/shell-redact.ts';
|
||||
import { ensureGbrainHome, resolveGbrainHome } from './gbrain-home.ts';
|
||||
import { binaryOnPath } from './execution-env.ts';
|
||||
// Static import → bundled into the --compile binary so the taxonomy never drifts
|
||||
// and needs no runtime skills/ directory.
|
||||
import { loadFilingRules, type FilingRulesDoc } from './filing-audit.ts';
|
||||
// Bundled into the --compile binary as the fallback taxonomy for repos that
|
||||
// don't ship their own — see resolveFilingRules().
|
||||
import filingRulesDoc from '../../skills/_brain-filing-rules.json';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
@@ -245,10 +246,26 @@ exit 4
|
||||
|
||||
// ── Managed AGENTS/RESOLVER block (taxonomy from filing rules; no drift) ─────
|
||||
|
||||
function renderTaxonomyLines(): string {
|
||||
/**
|
||||
* Resolve the filing-rules taxonomy for `repoPath`: prefer the repo's own
|
||||
* `skills/_brain-filing-rules.json`, then `_brain-filing-rules.json` at the
|
||||
* repo root, else the bundled default. Fails open — a missing or malformed
|
||||
* repo file must never break `sources harden`.
|
||||
*/
|
||||
function resolveFilingRules(repoPath: string): FilingRulesDoc {
|
||||
for (const dir of [join(repoPath, 'skills'), repoPath]) {
|
||||
try {
|
||||
const rules = loadFilingRules(dir);
|
||||
if (rules) return rules;
|
||||
} catch { /* malformed — fall through to the bundled default */ }
|
||||
}
|
||||
return filingRulesDoc as FilingRulesDoc;
|
||||
}
|
||||
|
||||
function renderTaxonomyLines(rules: FilingRulesDoc): string {
|
||||
const seen = new Set<string>();
|
||||
const lines: string[] = [];
|
||||
for (const r of (filingRulesDoc as any).rules ?? []) {
|
||||
for (const r of rules.rules ?? []) {
|
||||
const dir = String(r.directory || '').trim();
|
||||
if (!dir || seen.has(dir)) continue;
|
||||
seen.add(dir);
|
||||
@@ -257,7 +274,8 @@ function renderTaxonomyLines(): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderManagedBlock(): string {
|
||||
function renderManagedBlock(repoPath: string): string {
|
||||
const rules = resolveFilingRules(repoPath);
|
||||
return `${AGENTS_BEGIN}
|
||||
<!-- gbrain durability rules. This block is regenerated by \`gbrain sources harden\`.
|
||||
Do not index as user knowledge; do not edit between the markers. -->
|
||||
@@ -265,7 +283,7 @@ function renderManagedBlock(): string {
|
||||
|
||||
1. **Deterministic filing — never use /tmp as storage.** Every persistent output
|
||||
goes to its taxonomy path (canonical, from \`skills/_brain-filing-rules.json\`):
|
||||
${renderTaxonomyLines()}
|
||||
${renderTaxonomyLines(rules)}
|
||||
Writing to /tmp, scratch dirs, or outside the repo is forbidden for anything
|
||||
meant to persist.
|
||||
|
||||
@@ -285,7 +303,7 @@ ${AGENTS_END}`;
|
||||
function patchResolverFile(repoPath: string, dryRun: boolean): { status: StepStatus; detail: string } {
|
||||
const existing = findResolverFile(repoPath);
|
||||
const target = existing ?? join(repoPath, RESOLVER_FILENAMES[1]); // default AGENTS.md
|
||||
const block = renderManagedBlock();
|
||||
const block = renderManagedBlock(repoPath);
|
||||
const name = relative(repoPath, target) || target;
|
||||
|
||||
let current = '';
|
||||
@@ -432,6 +450,58 @@ export function commitWriteThroughFile(repoPath: string, absPath: string, slug:
|
||||
}
|
||||
}
|
||||
|
||||
// ── Push-state query (D14) ───────────────────────────────────────────────────
|
||||
|
||||
export type PushLogStatus = 'ok' | 'needs_attention' | 'unknown';
|
||||
|
||||
export interface PushLogOutcome {
|
||||
status: PushLogStatus;
|
||||
detail: string;
|
||||
/** UTC timestamp parsed from the log line, when found. */
|
||||
at?: string;
|
||||
}
|
||||
|
||||
const PUSH_LOG_OK = /^(\S+) \[push\] (?:ok|ok-after-rebase) (\S+)\b/;
|
||||
const PUSH_LOG_LOCAL_ONLY = /^(\S+) \[push\] LOCAL-ONLY, NEEDS ATTENTION: (\S+) /;
|
||||
const PUSH_LOG_LOCK_TIMEOUT = /^(\S+) \[push\] lock-timeout (\S+)\b/;
|
||||
|
||||
/**
|
||||
* Best-effort read of the most recently logged push outcome for `branch`,
|
||||
* from the shared hook log ($GBRAIN_HOME/brain-push.log). The push itself
|
||||
* runs detached in the background (see `renderPostCommitHook`), so nothing
|
||||
* synchronous ever learns whether a given commit's own push landed — this is
|
||||
* the queryable substitute: "as of the last thing the hook logged for this
|
||||
* branch, were pushes landing?"
|
||||
*
|
||||
* The log is host-wide and keyed only by branch name, not repo path, so two
|
||||
* different hardened repos sharing a branch name (e.g. both on `main`) share
|
||||
* this signal. That's an acceptable approximation for a liveness check, not
|
||||
* a per-repo guarantee.
|
||||
*/
|
||||
export function getLastPushOutcome(branch: string): PushLogOutcome {
|
||||
const log = pushLogPath();
|
||||
if (!existsSync(log)) return { status: 'unknown', detail: 'no push attempts logged yet' };
|
||||
|
||||
let lines: string[];
|
||||
try {
|
||||
lines = readFileSync(log, 'utf-8').split('\n');
|
||||
} catch (e) {
|
||||
return { status: 'unknown', detail: `push log unreadable: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
if (!line) continue;
|
||||
let m = line.match(PUSH_LOG_OK);
|
||||
if (m && m[2] === branch) return { status: 'ok', detail: line.trim(), at: m[1] };
|
||||
m = line.match(PUSH_LOG_LOCAL_ONLY);
|
||||
if (m && m[2] === branch) return { status: 'needs_attention', detail: line.trim(), at: m[1] };
|
||||
m = line.match(PUSH_LOG_LOCK_TIMEOUT);
|
||||
if (m && m[2] === branch) return { status: 'needs_attention', detail: line.trim(), at: m[1] };
|
||||
}
|
||||
return { status: 'unknown', detail: `no push attempt logged yet for branch '${branch}'` };
|
||||
}
|
||||
|
||||
// ── Committed helper ────────────────────────────────────────────────────────
|
||||
|
||||
function installHelper(repoPath: string, dryRun: boolean): { status: StepStatus; detail: string } {
|
||||
@@ -766,7 +836,7 @@ function resolveRepoRoot(path: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function currentBranch(repoPath: string): string {
|
||||
export function currentBranch(repoPath: string): string {
|
||||
try {
|
||||
return execFileSync('git', ['-C', repoPath, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, env: { ...process.env, ...GIT_ENV },
|
||||
|
||||
@@ -332,7 +332,7 @@ export class BudgetTracker {
|
||||
// pricing we can't enforce the cap, and silently ignoring it would
|
||||
// void the contract.
|
||||
const msg = `${this.opts.label}: no pricing entry for model "${estimate.modelId}" (kind=${estimate.kind}). ` +
|
||||
`Add it to src/core/${estimate.kind === 'embed' ? 'embedding-pricing.ts' : 'anthropic-pricing.ts'} or drop --max-cost.`;
|
||||
`Add it to src/core/${estimate.kind === 'embed' || estimate.kind === 'rerank' ? 'embedding-pricing.ts' : 'anthropic-pricing.ts'} or drop --max-cost.`;
|
||||
this.fireExhausted();
|
||||
throw new BudgetExhausted(msg, {
|
||||
reason: 'no_pricing',
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
findPrimaryResolverPath,
|
||||
loadSkillTriggerIndex,
|
||||
} from './skill-trigger-index.ts';
|
||||
import { parseSkillFrontmatter } from './skill-frontmatter.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -218,26 +219,13 @@ export function parseResolverEntries(resolverContent: string): ResolverEntry[] {
|
||||
// needed for AGENTS.md-only OpenClaw deployments. See D-CX-12 / F-ENG-1.
|
||||
|
||||
/**
|
||||
* Simple YAML frontmatter parser — extracts triggers array if present.
|
||||
* Extract the triggers array through the shared SKILL.md frontmatter parser.
|
||||
*
|
||||
* Normalizes CRLF → LF before parsing so Windows checkouts (where
|
||||
* `core.autocrlf=true` is the default) parse correctly. Without this,
|
||||
* the `^---\n` and `^triggers:\s*\n` regexes never match because the
|
||||
* file content is `---\r\n` / `triggers:\r\n`, and every skill on
|
||||
* Windows is reported as `mece_gap` regardless of its actual content.
|
||||
* CI runs on Ubuntu-only so the bug only surfaces in user environments.
|
||||
* Keeping this compatibility export routed through `parseSkillFrontmatter`
|
||||
* prevents doctor gap detection from drifting from the trigger index.
|
||||
*/
|
||||
export function extractTriggers(skillContent: string): string[] {
|
||||
const content = skillContent.replace(/\r\n/g, '\n');
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!fmMatch) return [];
|
||||
const fm = fmMatch[1];
|
||||
const triggersMatch = fm.match(/^triggers:\s*\n((?:\s+-\s+.+\n?)*)/m);
|
||||
if (!triggersMatch) return [];
|
||||
return triggersMatch[1]
|
||||
.split('\n')
|
||||
.map(l => l.replace(/^\s+-\s+/, '').replace(/^["']|["']$/g, '').trim())
|
||||
.filter(Boolean);
|
||||
return parseSkillFrontmatter(skillContent)?.triggers ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* AgentRunner — pluggable contract for invoking external agents (openclaw,
|
||||
* hermes, codex, …) inside the claw-test harness. v1 ships a single
|
||||
* implementation (openclaw); the interface stays narrow and concrete so
|
||||
* adding a second runner in v1.1 is a ~50-line file.
|
||||
* hermes, codex, …) inside the claw-test harness. Two implementations ship
|
||||
* (openclaw, hermes); the interface stays narrow and concrete so adding
|
||||
* another runner is a ~100-line file.
|
||||
*
|
||||
* The harness wraps spawn/timeout/transcript-capture; runners only have to
|
||||
* answer "where's your binary?" and "how do I invoke it with this prompt?".
|
||||
@@ -82,6 +82,50 @@ export interface TranscriptEvent {
|
||||
bytes: Buffer;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared runner helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Env keys every runner forwards to its agent subprocess. Runners compose
|
||||
* `[...BASE_ENV_ALLOWLIST, ...delta]` instead of duplicating the list — the
|
||||
* allowlist (not a denylist) is the leak barrier: anything not named here
|
||||
* never reaches the agent.
|
||||
*
|
||||
* GBRAIN_DATABASE_URL is deliberately ABSENT (removed in the hermes-harness
|
||||
* wave's adversarial review): live mode's staging + success oracle operate on
|
||||
* the hermetic PGLite under GBRAIN_HOME=tempdir, and an inherited
|
||||
* GBRAIN_DATABASE_URL would flip only the AGENT's gbrain children to the
|
||||
* operator's real Postgres — polluting the real brain while the oracle probes
|
||||
* the untouched PGLite and fails with a misleading verdict.
|
||||
*/
|
||||
export const BASE_ENV_ALLOWLIST = [
|
||||
'PATH', 'HOME', 'USER', 'LANG', 'TZ', 'NODE_ENV',
|
||||
'ANTHROPIC_API_KEY', 'OPENAI_API_KEY',
|
||||
'GBRAIN_HOME', 'GBRAIN_FRICTION_RUN_ID',
|
||||
// Proxy plumbing (both spellings — Node reads upper, Python/curl read
|
||||
// lower): an operator behind a corporate proxy runs their agent through
|
||||
// these, and dropping them turns live mode into a misleading network
|
||||
// failure blamed on the agent.
|
||||
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY',
|
||||
'http_proxy', 'https_proxy', 'no_proxy',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Validate a *_BIN env override: must be absolute, free of `..` segments, and
|
||||
* free of shell-active characters. The value is interpolated into generated
|
||||
* sh shim scripts (single-quoted), so quotes/backslashes/dollar/backtick or a
|
||||
* newline would break out of the quoting and become code — reject them
|
||||
* outright rather than trying to escape. Spaces are fine (quoted).
|
||||
* Returns an error string (naming the env var) or null when valid.
|
||||
*/
|
||||
export function validateBinPathEnv(envName: string, p: string): string | null {
|
||||
if (!p.startsWith('/')) return `${envName} must be absolute; got ${p}`;
|
||||
if (p.split('/').includes('..')) return `${envName} must not contain '..' segments; got ${p}`;
|
||||
if (/['"`$\\\n\r]/.test(p)) return `${envName} must not contain quotes, backslashes, dollar signs, backticks, or newlines; got ${p}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Hermes runner — invokes the real `hermes` binary (NousResearch
|
||||
* hermes-agent, the public platform) in a tempdir with a BRIEF.md prompt.
|
||||
* Live mode only.
|
||||
*
|
||||
* Invocation pattern (verified against a pinned local install, v0.20.0 —
|
||||
* see docs/mcp/HERMES-CLI-PIN.md):
|
||||
* hermes -z "<brief>"
|
||||
*
|
||||
* The z flag is Hermes's headless one-shot: single prompt in, final response
|
||||
* text on stdout, nothing else. We deliberately do NOT pass Hermes's
|
||||
* working-directory flag (spelled "in") — `spawnWithCapture` already sets
|
||||
* `cwd`. `opts.agentName` is unused: the one-shot mode has no sub-agent
|
||||
* selector.
|
||||
*
|
||||
* Hermeticity posture (deliberate): live mode runs the OPERATOR's configured
|
||||
* Hermes — the real ~/.hermes (model settings, skills, sessions) is inherited
|
||||
* unless HERMES_HOME points elsewhere — against a hermetic BRAIN. The fully
|
||||
* hermetic lane is the door e2e (install-real-hermes.serial.test.ts).
|
||||
*
|
||||
* Binary resolution: $HERMES_BIN > `which hermes` > unavailable.
|
||||
* Path validation: must be absolute, must be executable, no '..' segments.
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { statSync } from 'fs';
|
||||
import {
|
||||
BASE_ENV_ALLOWLIST,
|
||||
validateBinPathEnv,
|
||||
type AgentRunner,
|
||||
type DetectResult,
|
||||
type InvokeOpts,
|
||||
type InvokeResult,
|
||||
} from '../agent-runner.ts';
|
||||
import { spawnWithCapture } from '../transcript-capture.ts';
|
||||
|
||||
/**
|
||||
* Allow-list for env propagation when spawning hermes. Delta from the shared
|
||||
* base: HERMES_HOME, so callers (door e2e, CI) can point Hermes at an
|
||||
* isolated home instead of the operator's real ~/.hermes; and
|
||||
* OPENROUTER_API_KEY, because OpenRouter is a Hermes-documented auth path
|
||||
* (docs/mcp/HERMES.md) — an operator whose hermes auths only via that env var
|
||||
* would otherwise see "no inference provider" and the harness would blame the
|
||||
* agent. Caveat (observed, docs/mcp/HERMES-CLI-PIN.md): live mode forwards
|
||||
* whatever provider keys the operator's shell exports, mirroring a direct
|
||||
* hermes run — with MULTIPLE keys visible and no model pinned in config,
|
||||
* Hermes's provider auto-routing can mis-route and fail with an HTTP 401 in
|
||||
* the final text. The operator's own config.yaml model pin is what prevents
|
||||
* that, same as it does outside the harness.
|
||||
*/
|
||||
const ENV_ALLOWLIST = [...BASE_ENV_ALLOWLIST, 'HERMES_HOME', 'OPENROUTER_API_KEY'];
|
||||
|
||||
export class HermesRunner implements AgentRunner {
|
||||
readonly name = 'hermes';
|
||||
|
||||
async detect(): Promise<DetectResult> {
|
||||
const fromEnv = process.env.HERMES_BIN?.trim();
|
||||
let binPath: string | undefined;
|
||||
|
||||
if (fromEnv) {
|
||||
const validation = validateBinPathEnv('HERMES_BIN', fromEnv);
|
||||
if (validation) return { available: false, reason: validation };
|
||||
binPath = fromEnv;
|
||||
} else {
|
||||
try {
|
||||
const out = execSync('which hermes', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
const found = out.trim();
|
||||
if (!found || !found.startsWith('/')) {
|
||||
return { available: false, reason: 'hermes not on PATH' };
|
||||
}
|
||||
binPath = found;
|
||||
} catch {
|
||||
return { available: false, reason: 'hermes not on PATH' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!binPath) return { available: false, reason: 'no binary resolved' };
|
||||
|
||||
try {
|
||||
const s = statSync(binPath);
|
||||
if (!s.isFile()) return { available: false, reason: `not a regular file: ${binPath}` };
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if (!(s.mode & 0o111)) return { available: false, reason: `not executable: ${binPath}` };
|
||||
} catch (e) {
|
||||
return { available: false, reason: `stat failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
|
||||
return { available: true, binPath };
|
||||
}
|
||||
|
||||
async invoke(opts: InvokeOpts): Promise<InvokeResult> {
|
||||
const detected = await this.detect();
|
||||
if (!detected.available || !detected.binPath) {
|
||||
throw new Error(`hermes runner unavailable: ${detected.reason ?? 'unknown'}`);
|
||||
}
|
||||
const args = ['-z', opts.brief];
|
||||
|
||||
// Filter env to allow-list, then merge caller overrides.
|
||||
const baseEnv: Record<string, string> = {};
|
||||
for (const key of ENV_ALLOWLIST) {
|
||||
const v = process.env[key];
|
||||
if (typeof v === 'string') baseEnv[key] = v;
|
||||
}
|
||||
const env: Record<string, string> = { ...baseEnv, ...opts.env };
|
||||
|
||||
const result = await spawnWithCapture(detected.binPath, args, {
|
||||
cwd: opts.cwd,
|
||||
env,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
transcriptSink: opts.transcriptSink,
|
||||
});
|
||||
|
||||
return { exitCode: result.exitCode, durationMs: result.durationMs };
|
||||
}
|
||||
}
|
||||
@@ -15,16 +15,19 @@
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { statSync } from 'fs';
|
||||
import type { AgentRunner, DetectResult, InvokeOpts, InvokeResult } from '../agent-runner.ts';
|
||||
import {
|
||||
BASE_ENV_ALLOWLIST,
|
||||
validateBinPathEnv,
|
||||
type AgentRunner,
|
||||
type DetectResult,
|
||||
type InvokeOpts,
|
||||
type InvokeResult,
|
||||
} from '../agent-runner.ts';
|
||||
import { spawnWithCapture } from '../transcript-capture.ts';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'default';
|
||||
/** Allow-list for env propagation when spawning openclaw. */
|
||||
const ENV_ALLOWLIST = [
|
||||
'PATH', 'HOME', 'USER', 'LANG', 'TZ', 'NODE_ENV',
|
||||
'ANTHROPIC_API_KEY', 'OPENAI_API_KEY',
|
||||
'GBRAIN_HOME', 'GBRAIN_FRICTION_RUN_ID', 'GBRAIN_DATABASE_URL',
|
||||
];
|
||||
/** Allow-list for env propagation when spawning openclaw (no delta from base). */
|
||||
const ENV_ALLOWLIST = [...BASE_ENV_ALLOWLIST];
|
||||
|
||||
export class OpenClawRunner implements AgentRunner {
|
||||
readonly name = 'openclaw';
|
||||
@@ -34,7 +37,7 @@ export class OpenClawRunner implements AgentRunner {
|
||||
let binPath: string | undefined;
|
||||
|
||||
if (fromEnv) {
|
||||
const validation = validateAbsolutePath(fromEnv);
|
||||
const validation = validateBinPathEnv('OPENCLAW_BIN', fromEnv);
|
||||
if (validation) return { available: false, reason: validation };
|
||||
binPath = fromEnv;
|
||||
} else {
|
||||
@@ -91,8 +94,3 @@ export class OpenClawRunner implements AgentRunner {
|
||||
}
|
||||
}
|
||||
|
||||
function validateAbsolutePath(p: string): string | null {
|
||||
if (!p.startsWith('/')) return `OPENCLAW_BIN must be absolute; got ${p}`;
|
||||
if (p.split('/').includes('..')) return `OPENCLAW_BIN must not contain '..' segments; got ${p}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,22 @@ import { fileURLToPath } from 'url';
|
||||
|
||||
export type ScenarioKind = 'fresh-install' | 'upgrade';
|
||||
|
||||
/**
|
||||
* Live-mode success oracle, declared per scenario. The harness verifies these
|
||||
* AFTER the agent exits 0 — exit code alone would pass an agent that did
|
||||
* nothing. All fields optional; a scenario with no oracle gets the kind's
|
||||
* default verification (fresh-install: doctor only; upgrade: schema-version
|
||||
* probe must ADVANCE during the agent turn).
|
||||
*/
|
||||
export interface ScenarioOracle {
|
||||
/** Query the staged brain must answer post-run (live mode). */
|
||||
query?: string;
|
||||
/** Minimum result count for `query` (default 1 when query is set). */
|
||||
minResults?: number;
|
||||
/** Workspace-relative paths that must exist post-run (proves brief steps ran). */
|
||||
filesExist?: string[];
|
||||
}
|
||||
|
||||
export interface ScenarioConfig {
|
||||
/** Directory the scenario was loaded from. Always absolute. */
|
||||
dir: string;
|
||||
@@ -33,6 +49,8 @@ export interface ScenarioConfig {
|
||||
brainRelative?: string;
|
||||
/** Path to seed dir for upgrade scenarios. */
|
||||
seedRelative?: string;
|
||||
/** Live-mode success oracle (see ScenarioOracle). */
|
||||
oracle?: ScenarioOracle;
|
||||
}
|
||||
|
||||
/** Default fixtures root, override via $GBRAIN_CLAW_SCENARIOS_DIR for tests. */
|
||||
@@ -61,10 +79,30 @@ export function listScenarios(root?: string): string[] {
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Confine a scenario-declared relative path inside the scenario dir. The
|
||||
* scenario pack is a trust boundary (loadable from arbitrary dirs via the
|
||||
* scenarios-dir env override, and its content flows to an external agent):
|
||||
* a traversal-shaped brief/brain/seed would let a pack read — and, via the
|
||||
* BRIEF, exfiltrate to the agent — arbitrary operator files.
|
||||
*/
|
||||
function confineToScenarioDir(name: string, dir: string, rel: string, field: string): void {
|
||||
const abs = resolve(dir, rel);
|
||||
if (abs !== dir && !abs.startsWith(dir + '/')) {
|
||||
throw new Error(`scenario ${JSON.stringify(name)}: ${field} must stay inside the scenario dir; got ${JSON.stringify(rel)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Load and validate one scenario by name. */
|
||||
export function loadScenario(name: string, root?: string): ScenarioConfig {
|
||||
// The name is a directory segment — never a path. A traversal-shaped name
|
||||
// would resolve scenario.json (and everything the scenario references)
|
||||
// outside the fixtures root.
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || name.includes('..')) {
|
||||
throw new Error(`scenario name ${JSON.stringify(name)} not found (names are letters, digits, dot, dash, underscore)`);
|
||||
}
|
||||
const r = root ?? defaultFixturesRoot();
|
||||
const dir = join(r, name);
|
||||
const dir = resolve(join(r, name));
|
||||
const cfgPath = join(dir, 'scenario.json');
|
||||
if (!existsSync(cfgPath)) {
|
||||
throw new Error(`scenario ${JSON.stringify(name)} not found at ${cfgPath}`);
|
||||
@@ -86,6 +124,7 @@ export function loadScenario(name: string, root?: string): ScenarioConfig {
|
||||
throw new Error(`scenario ${JSON.stringify(name)}: expected_phases must be a string[]`);
|
||||
}
|
||||
const briefRel = typeof cfg.brief === 'string' ? cfg.brief : 'BRIEF.md';
|
||||
confineToScenarioDir(name, dir, briefRel, 'brief');
|
||||
if (!existsSync(join(dir, briefRel))) {
|
||||
throw new Error(`scenario ${JSON.stringify(name)}: BRIEF.md missing at ${briefRel}`);
|
||||
}
|
||||
@@ -98,8 +137,60 @@ export function loadScenario(name: string, root?: string): ScenarioConfig {
|
||||
};
|
||||
if (typeof cfg.from_version === 'string') out.fromVersion = cfg.from_version;
|
||||
if (typeof cfg.description === 'string') out.description = cfg.description;
|
||||
if (typeof cfg.brain === 'string') out.brainRelative = cfg.brain;
|
||||
if (typeof cfg.seed === 'string') out.seedRelative = cfg.seed;
|
||||
if (typeof cfg.brain === 'string') {
|
||||
confineToScenarioDir(name, dir, cfg.brain, 'brain');
|
||||
out.brainRelative = cfg.brain;
|
||||
}
|
||||
if (typeof cfg.seed === 'string') {
|
||||
confineToScenarioDir(name, dir, cfg.seed, 'seed');
|
||||
out.seedRelative = cfg.seed;
|
||||
}
|
||||
if (cfg.oracle !== undefined) {
|
||||
if (!cfg.oracle || typeof cfg.oracle !== 'object' || Array.isArray(cfg.oracle)) {
|
||||
throw new Error(`scenario ${JSON.stringify(name)}: oracle must be a JSON object`);
|
||||
}
|
||||
const o = cfg.oracle as Record<string, unknown>;
|
||||
const oracle: ScenarioOracle = {};
|
||||
if (o.query !== undefined) {
|
||||
if (typeof o.query !== 'string' || !o.query.trim()) {
|
||||
throw new Error(`scenario ${JSON.stringify(name)}: oracle.query must be a non-empty string`);
|
||||
}
|
||||
// The query lands in gbrain argv — a leading dash would parse as a CLI
|
||||
// flag instead of a query (scenario packs load from arbitrary dirs via
|
||||
// the scenarios-dir env override, so treat this as a trust boundary).
|
||||
if (o.query.trim().startsWith('-')) {
|
||||
throw new Error(`scenario ${JSON.stringify(name)}: oracle.query must not start with a dash`);
|
||||
}
|
||||
oracle.query = o.query;
|
||||
oracle.minResults = 1;
|
||||
}
|
||||
if (o.min_results !== undefined) {
|
||||
// Rejecting min_results without query keeps the config honest: the
|
||||
// harness only reads minResults when a query is declared, and accepting
|
||||
// config it never enforces would be a silent no-op.
|
||||
if (o.query === undefined) {
|
||||
throw new Error(`scenario ${JSON.stringify(name)}: oracle.min_results requires oracle.query`);
|
||||
}
|
||||
if (typeof o.min_results !== 'number' || !Number.isFinite(o.min_results) || o.min_results < 0) {
|
||||
throw new Error(`scenario ${JSON.stringify(name)}: oracle.min_results must be a number >= 0`);
|
||||
}
|
||||
oracle.minResults = o.min_results;
|
||||
}
|
||||
if (o.files_exist !== undefined) {
|
||||
if (!Array.isArray(o.files_exist) || !o.files_exist.every(x => typeof x === 'string' && x.trim())) {
|
||||
throw new Error(`scenario ${JSON.stringify(name)}: oracle.files_exist must be a string[]`);
|
||||
}
|
||||
// Paths are resolved relative to the run's workspace; confine them so a
|
||||
// scenario pack can't probe arbitrary operator paths.
|
||||
for (const p of o.files_exist as string[]) {
|
||||
if (p.startsWith('/') || p.split('/').includes('..')) {
|
||||
throw new Error(`scenario ${JSON.stringify(name)}: oracle.files_exist entries must be workspace-relative (no absolute paths, no '..'): ${JSON.stringify(p)}`);
|
||||
}
|
||||
}
|
||||
oracle.filesExist = o.files_exist as string[];
|
||||
}
|
||||
out.oracle = oracle;
|
||||
}
|
||||
// Default brain path conventions
|
||||
if (!out.brainRelative && existsSync(join(dir, 'brain'))) out.brainRelative = 'brain';
|
||||
if (!out.seedRelative && out.kind === 'upgrade' && existsSync(join(dir, 'seed'))) {
|
||||
|
||||
@@ -66,6 +66,34 @@ export async function seedPgliteFromFile(opts: { dbPath: string; sqlPath: string
|
||||
return seedPglite({ dbPath: opts.dbPath, sql });
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-mutating schema-version probe for the live-mode upgrade oracle.
|
||||
*
|
||||
* The migration runner records its position via setConfig('version', …), i.e.
|
||||
* `config.key = 'version'`. Reading it through the normal CLI is NOT valid as
|
||||
* an upgrade oracle: every CLI connect routes through connectEngine → initSchema,
|
||||
* which auto-applies pending migrations — the verifier would perform the very
|
||||
* upgrade a do-nothing agent skipped. This probe opens PGLite directly (same
|
||||
* path as the seeder) and reads the row without touching the migration chain.
|
||||
*
|
||||
* Returns the recorded version, or null when the db/table/row doesn't exist.
|
||||
*/
|
||||
export async function readPgliteSchemaVersion(dbPath: string): Promise<number | null> {
|
||||
if (!existsSync(dbPath)) return null;
|
||||
const engine = new PGLiteEngine();
|
||||
try {
|
||||
await engine.connect({ engine: 'pglite', database_path: dbPath });
|
||||
const rows = await (engine as any).db.query(`SELECT value FROM config WHERE key = 'version'`);
|
||||
const value = rows?.rows?.[0]?.value;
|
||||
const n = typeof value === 'string' ? Number.parseInt(value, 10) : typeof value === 'number' ? value : NaN;
|
||||
return Number.isFinite(n) ? n : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
try { await engine.disconnect(); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a SQL dump into individual statements. Naïve `;` split that respects
|
||||
* single-quoted strings and `--` line comments. Sufficient for canonical
|
||||
|
||||
@@ -105,6 +105,8 @@ export interface SpawnResult {
|
||||
}
|
||||
|
||||
const SIGTERM_GRACE_MS = 5_000;
|
||||
/** After 'exit', how long to wait for 'close' (pipe drain) before resolving anyway. */
|
||||
const STREAM_DRAIN_GRACE_MS = 2_000;
|
||||
|
||||
export async function spawnWithCapture(bin: string, args: string[], opts: SpawnOpts): Promise<SpawnResult> {
|
||||
const start = Date.now();
|
||||
@@ -116,19 +118,33 @@ export async function spawnWithCapture(bin: string, args: string[], opts: SpawnO
|
||||
env: opts.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: false,
|
||||
// Own process group so timeout kills reach grandchildren too — agents
|
||||
// spawn MCP servers and gbrain children; signalling only the direct
|
||||
// PID leaves those orphaned holding the stdout/stderr pipes open.
|
||||
detached: true,
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Signal the whole process group (negative pid); fall back to the direct
|
||||
// child if the group is already gone or grouping failed.
|
||||
const killTree = (sig: NodeJS.Signals) => {
|
||||
const pid = child.pid;
|
||||
if (pid) {
|
||||
try { process.kill(-pid, sig); return; } catch { /* group gone or not a leader */ }
|
||||
}
|
||||
try { child.kill(sig); } catch { /* already gone */ }
|
||||
};
|
||||
|
||||
let timedOut = false;
|
||||
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const wallClockTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
||||
killTree('SIGTERM');
|
||||
killTimer = setTimeout(() => {
|
||||
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
killTree('SIGKILL');
|
||||
}, SIGTERM_GRACE_MS);
|
||||
}, opts.timeoutMs);
|
||||
|
||||
@@ -151,15 +167,17 @@ export async function spawnWithCapture(bin: string, args: string[], opts: SpawnO
|
||||
reject(e);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// No payload: close stdin anyway. An agent build that waits for stdin
|
||||
// EOF would otherwise block silently until the wall-clock kill — a paid
|
||||
// live turn burned as a timeout.
|
||||
try { child.stdin?.end(); } catch { /* stream already gone */ }
|
||||
}
|
||||
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(wallClockTimer);
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
let settled = false;
|
||||
const settle = (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(wallClockTimer);
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
resolve({
|
||||
@@ -167,6 +185,25 @@ export async function spawnWithCapture(bin: string, args: string[], opts: SpawnO
|
||||
durationMs: Date.now() - start,
|
||||
timedOut,
|
||||
});
|
||||
};
|
||||
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(wallClockTimer);
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
// 'close' (all pipes drained) is the clean path. But a grandchild that
|
||||
// survives the group kill can hold the pipes open forever, so 'exit' arms
|
||||
// a short drain grace and then settles regardless — the harness must not
|
||||
// hang AFTER its own timeout already fired.
|
||||
child.on('close', (code) => settle(code));
|
||||
child.on('exit', (code) => {
|
||||
const t = setTimeout(() => settle(code), STREAM_DRAIN_GRACE_MS);
|
||||
t.unref?.();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,102 +8,102 @@
|
||||
// (help-text mentions count): accepting an ignored flag is the pre-#2185
|
||||
// status quo; missing a real one breaks working invocations.
|
||||
export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'advisor': ['--aliases', '--all', '--apply', '--brain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--skills-dir', '--source', '--stale', '--supersessions', '--surface', '--thin', '--verbose', '--workspace', '--yes'],
|
||||
'agent': ['--aliases', '--all', '--brain', '--detach', '--fanout-manifest', '--flag', '--flags', '--follow', '--help', '--include-null-signature', '--json', '--max-turns', '--model', '--no-extract', '--no-follow', '--note', '--pattern', '--pending', '--reset', '--resolve', '--since', '--source', '--stale', '--subagent-def', '--supersessions', '--thin', '--timeout-ms', '--tools', '--word'],
|
||||
'anomalies': ['--aliases', '--all', '--brain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lookback-days', '--mcp-only', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--sigma', '--since', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout'],
|
||||
'apply-migrations': ['--ab', '--all', '--auto-update', '--brain', '--break-lock', '--build-index', '--by-mention', '--compile', '--days', '--dry-run', '--exclusive', '--fast', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--from-meetings', '--from-pages', '--help', '--history', '--host-dir', '--http', '--json', '--lang', '--list', '--locks', '--markdown', '--max-age', '--migrate-only', '--migration', '--mode', '--multimodal', '--no-autopilot-install', '--no-embedding', '--no-extract', '--non-interactive', '--phase', '--priority', '--refresh-unqualified', '--remediate', '--rollback', '--skip-verify', '--source', '--stale', '--surface', '--undo-wave', '--use-captured-snapshot', '--with-calibration', '--yes'],
|
||||
'auth': ['--aliases', '--all', '--bound-brain', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--enable-dcr', '--enable-dcr-insecure', '--fast', '--federated-read', '--force', '--from-pages', '--grant-types', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--pattern', '--pending', '--redirect-uri', '--reset', '--resolve', '--scopes', '--source', '--stale', '--supersessions', '--surface', '--takes-holders', '--thin', '--token', '--token-endpoint-auth-method', '--yes'],
|
||||
'autopilot': ['--aliases', '--all', '--auto-fix', '--batch', '--brain', '--break-lock', '--by-type', '--check', '--dimensions', '--dir', '--drain', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--ff-only', '--fix', '--force', '--force-break-lock', '--force-retry', '--from-pages', '--help', '--http', '--include-null-signature', '--inject-bootstrap', '--inline', '--input', '--install', '--interval', '--json', '--markdown', '--max-age', '--max-rss', '--max-usd', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-inject', '--no-mutate', '--no-worker', '--non-interactive', '--now', '--once', '--output', '--path', '--pattern', '--pending', '--phase', '--refresh-cache', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--source', '--stale', '--status', '--supersessions', '--surface', '--swap-only', '--target', '--target-score', '--thin', '--timeout', '--to', '--uninstall', '--unsafe-bypass-dream-guard', '--user', '--version', '--yes'],
|
||||
'advisor': ['--aliases', '--all', '--apply', '--brain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--skills-dir', '--source', '--stale', '--supersessions', '--surface', '--thin', '--token-ttl', '--verbose', '--workspace', '--yes'],
|
||||
'agent': ['--aliases', '--all', '--brain', '--detach', '--fanout-manifest', '--federated', '--flag', '--flags', '--follow', '--help', '--include-null-signature', '--json', '--max-turns', '--model', '--no-extract', '--no-federated', '--no-follow', '--note', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--since', '--source', '--stale', '--subagent-def', '--supersessions', '--thin', '--timeout-ms', '--tools', '--word'],
|
||||
'anomalies': ['--aliases', '--all', '--brain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lookback-days', '--mcp-only', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--sigma', '--since', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl'],
|
||||
'apply-migrations': ['--ab', '--all', '--auto-update', '--brain', '--break-lock', '--build-index', '--by-mention', '--compile', '--days', '--dry-run', '--exclusive', '--fast', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--from-meetings', '--from-pages', '--help', '--history', '--host-dir', '--http', '--json', '--lang', '--list', '--locks', '--markdown', '--max-age', '--migrate-only', '--migration', '--mode', '--multimodal', '--no-autopilot-install', '--no-embedding', '--no-extract', '--non-interactive', '--phase', '--priority', '--refresh-unqualified', '--remediate', '--rollback', '--skip-verify', '--source', '--stale', '--surface', '--token-ttl', '--undo-wave', '--use-captured-snapshot', '--with-calibration', '--yes'],
|
||||
'auth': ['--aliases', '--all', '--bound-brain', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--enable-dcr', '--enable-dcr-insecure', '--fast', '--federated-read', '--force', '--from-pages', '--grant-types', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--pattern', '--pending', '--redirect-uri', '--reset', '--resolve', '--scopes', '--source', '--stale', '--supersessions', '--surface', '--takes-holders', '--thin', '--token', '--token-endpoint-auth-method', '--token-ttl', '--yes'],
|
||||
'autopilot': ['--aliases', '--all', '--auto-fix', '--batch', '--brain', '--break-lock', '--by-type', '--check', '--dim', '--dimensions', '--dir', '--drain', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--ff-only', '--fix', '--force', '--force-break-lock', '--force-retry', '--from-pages', '--help', '--http', '--include-null-signature', '--inject-bootstrap', '--inline', '--input', '--install', '--interval', '--json', '--markdown', '--max-age', '--max-rss', '--max-usd', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-inject', '--no-mutate', '--no-worker', '--non-interactive', '--now', '--once', '--output', '--path', '--pattern', '--pending', '--phase', '--refresh-cache', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--source', '--stale', '--status', '--supersessions', '--surface', '--swap-only', '--target', '--target-score', '--thin', '--timeout', '--to', '--token-ttl', '--uninstall', '--unsafe-bypass-dream-guard', '--user', '--version', '--yes'],
|
||||
'backfill': ['--aliases', '--all', '--batch-size', '--brain', '--concurrency', '--dry-run', '--fresh', '--help', '--include-null-signature', '--json', '--keep-index', '--list', '--max-errors', '--max-rows', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--source', '--stale', '--supersessions', '--thin'],
|
||||
'bench': ['--baseline', '--brain', '--explain', '--force', '--from', '--help', '--json', '--label', '--lang', '--limit', '--markdown', '--multimodal', '--near-symbol', '--restore-only', '--source', '--stale', '--symbol-kind', '--thin', '--threshold-jaccard', '--threshold-latency-multiplier', '--threshold-top1', '--to', '--tool'],
|
||||
'book-mirror': ['--aliases', '--all', '--allow-empty', '--apply', '--asof', '--author', '--auto', '--background', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--budget-usd-per-day', '--by-mention', '--chapters-dir', '--content', '--context-file', '--date', '--days', '--dry-run', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--kind', '--limit', '--max-turns', '--max-usd', '--mode', '--model', '--multimodal', '--no-confirm', '--no-embedding', '--no-extract', '--no-follow', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--timeout-ms', '--title', '--trusted-extraction', '--url', '--with-db', '--yes'],
|
||||
'bootstrap': ['--abbrev-ref', '--abort', '--accept-visibility-change-consequences', '--all', '--allow-unverified-remote', '--brain', '--branch', '--cached', '--compile', '--confirm', '--count', '--delete-brain', '--diff-filter', '--env', '--error-unmatch', '--exclude-standard', '--fast', '--file', '--flag', '--force', '--from-pages', '--full', '--gbrain-bin', '--get', '--git-dir', '--git-path', '--harness', '--heads', '--help', '--home', '--http', '--init', '--is-inside-work-tree', '--isolated', '--jq', '--json', '--local', '--minimal', '--name-only', '--no-cron', '--no-embedding', '--no-hooks', '--no-verify', '--once', '--only', '--others', '--pat-file', '--path', '--pglite', '--porcelain', '--private', '--push', '--push-only', '--quiet', '--rebase', '--repair', '--scope', '--set', '--short', '--show', '--show-toplevel', '--skip', '--source', '--status', '--surface', '--unset-all', '--verify', '--version', '--visibility', '--workspace', '--yes'],
|
||||
'brainstorm': ['--aliases', '--all', '--brain', '--chunker-debug', '--code', '--compile', '--fast', '--fix', '--force', '--force-rechunk', '--force-resume', '--from-pages', '--full', '--help', '--http', '--include-null-signature', '--json', '--judge-model', '--lang', '--limit', '--list-runs', '--markdown', '--max-cost', '--max-far-set', '--max-ideas-per-judge-call', '--model', '--no-embed', '--no-embedding', '--no-extract', '--no-save', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--retry-failed', '--retry-judge', '--save', '--source', '--stale', '--strict-budget', '--supersessions', '--surface', '--thin', '--timeout', '--yes'],
|
||||
'cache': ['--brain', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--source', '--surface', '--yes'],
|
||||
'calibration': ['--ab', '--aliases', '--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--dry-run', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--holder', '--http', '--image', '--include-null-signature', '--json', '--key-prefix', '--kind', '--lang', '--limit', '--markdown', '--max-usd', '--mode', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--phase', '--progress-interval', '--progress-json', '--quiet', '--regenerate', '--repo', '--reset', '--resolve', '--restore-only', '--save', '--scrub-gstack', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--symbol-kind', '--thin', '--trusted-extraction', '--undo-wave', '--url', '--with-calibration', '--with-db', '--yes'],
|
||||
'call': ['--aliases', '--all', '--all-sources', '--as-context', '--auto-fix', '--background', '--brain', '--by-mention', '--catch-up', '--concurrency', '--confirm-destructive', '--content', '--cost-estimate', '--count', '--days', '--depth', '--dim', '--dir', '--direction', '--enable-dcr', '--enable-dcr-insecure', '--explain', '--fast', '--federated', '--file', '--fix', '--follow', '--force', '--from', '--from-meetings', '--grant-types', '--grep', '--hard-deadline', '--help', '--http', '--image', '--include-expired', '--include-frontmatter', '--include-null-signature', '--infer-dates', '--install', '--interval', '--json', '--key', '--kind', '--lang', '--limit', '--link-source', '--link-type', '--llm', '--migrate-only', '--missing-path', '--multimodal', '--ner', '--no-embed', '--no-expand', '--no-extract', '--no-federated', '--no-hard-deadline', '--no-retry-connect', '--no-save', '--older-than', '--page', '--param', '--params', '--password', '--path', '--pattern', '--pending', '--pglite', '--port', '--progress-interval', '--progress-json', '--public-url', '--queue', '--quiet', '--reenrich-after', '--refresh-cache', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--restore-only', '--save', '--scopes', '--session', '--sigma', '--since', '--slug', '--slug-prefix', '--source', '--source-id', '--stale', '--status', '--stdin', '--strategy', '--suites', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--synthesize', '--tag', '--thin', '--timeout', '--to', '--today', '--token', '--token-ttl', '--tools-json', '--type', '--uninstall', '--url', '--version', '--watch', '--with-calibration', '--workers', '--yes'],
|
||||
'capture': ['--aliases', '--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--depth', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--kind', '--limit', '--max-usd', '--mcp-only', '--mode', '--multimodal', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--repo', '--reset', '--resolve', '--restore-only', '--save', '--scopes', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--stdin', '--supersessions', '--surface', '--thin', '--timeout', '--trusted-extraction', '--type', '--url', '--what', '--where', '--who', '--with-db', '--yes'],
|
||||
'book-mirror': ['--aliases', '--all', '--allow-empty', '--apply', '--asof', '--author', '--auto', '--background', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--budget-usd-per-day', '--by-mention', '--chapters-dir', '--content', '--context-file', '--date', '--days', '--dry-run', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--kind', '--limit', '--max-turns', '--max-usd', '--mode', '--model', '--multimodal', '--no-confirm', '--no-embedding', '--no-extract', '--no-follow', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--timeout-ms', '--title', '--token-ttl', '--trusted-extraction', '--url', '--with-db', '--yes'],
|
||||
'bootstrap': ['--abbrev-ref', '--abort', '--accept-visibility-change-consequences', '--active', '--all', '--allow-unverified-remote', '--brain', '--branch', '--cached', '--compile', '--confirm', '--count', '--delete-brain', '--diff-filter', '--env', '--error-unmatch', '--exclude-standard', '--fast', '--file', '--flag', '--force', '--from-pages', '--full', '--gbrain-bin', '--get', '--git-dir', '--git-path', '--harness', '--heads', '--help', '--home', '--hostname', '--http', '--init', '--is-inside-work-tree', '--isolated', '--jq', '--json', '--local', '--minimal', '--name-only', '--no-cron', '--no-embedding', '--no-hooks', '--no-verify', '--once', '--only', '--others', '--pat-file', '--path', '--pglite', '--porcelain', '--private', '--push', '--push-only', '--quiet', '--rebase', '--repair', '--scope', '--set', '--short', '--show', '--show-toplevel', '--skip', '--source', '--status', '--surface', '--token-ttl', '--unset-all', '--verify', '--version', '--visibility', '--workspace', '--yes'],
|
||||
'brainstorm': ['--aliases', '--all', '--brain', '--chunker-debug', '--code', '--compile', '--fast', '--file', '--fix', '--force', '--force-rechunk', '--force-resume', '--from-pages', '--full', '--help', '--http', '--include-null-signature', '--json', '--judge-model', '--lang', '--limit', '--list-runs', '--markdown', '--max-cost', '--max-far-set', '--max-ideas-per-judge-call', '--model', '--no-embed', '--no-embedding', '--no-extract', '--no-save', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--retry-failed', '--retry-judge', '--save', '--source', '--stale', '--strict-budget', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--yes'],
|
||||
'cache': ['--brain', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--source', '--surface', '--token-ttl', '--yes'],
|
||||
'calibration': ['--ab', '--aliases', '--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--dry-run', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--holder', '--http', '--image', '--include-null-signature', '--json', '--key-prefix', '--kind', '--lang', '--limit', '--markdown', '--max-usd', '--mode', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--phase', '--progress-interval', '--progress-json', '--quiet', '--regenerate', '--repo', '--reset', '--resolve', '--restore-only', '--save', '--scrub-gstack', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--symbol-kind', '--thin', '--token-ttl', '--trusted-extraction', '--undo-wave', '--url', '--with-calibration', '--with-db', '--yes'],
|
||||
'call': ['--aliases', '--all', '--all-sources', '--as-context', '--auto-fix', '--background', '--brain', '--by-mention', '--catch-up', '--concurrency', '--confirm-destructive', '--content', '--cost-estimate', '--count', '--days', '--depth', '--dim', '--dir', '--direction', '--enable-dcr', '--enable-dcr-insecure', '--explain', '--fast', '--federated', '--file', '--fix', '--follow', '--force', '--from', '--from-meetings', '--grant-types', '--grep', '--hard-deadline', '--help', '--http', '--image', '--include-expired', '--include-frontmatter', '--include-null-signature', '--infer-dates', '--install', '--interval', '--json', '--key', '--kind', '--lang', '--limit', '--link-source', '--link-type', '--llm', '--migrate-only', '--missing-path', '--multimodal', '--ner', '--no-embed', '--no-expand', '--no-extract', '--no-federated', '--no-hard-deadline', '--no-retry-connect', '--no-save', '--older-than', '--page', '--param', '--params', '--password', '--path', '--pattern', '--pending', '--pglite', '--port', '--probe-pglite', '--progress-interval', '--progress-json', '--public-url', '--queue', '--quiet', '--reenrich-after', '--refresh-cache', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--restore-only', '--save', '--scopes', '--session', '--sigma', '--since', '--slug', '--slug-prefix', '--source', '--source-id', '--stale', '--status', '--stdin', '--strategy', '--suites', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--synthesize', '--tag', '--thin', '--timeout', '--to', '--today', '--token', '--token-ttl', '--tools-json', '--type', '--uninstall', '--url', '--version', '--watch', '--with-calibration', '--workers', '--yes'],
|
||||
'capture': ['--aliases', '--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--depth', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--kind', '--limit', '--max-usd', '--mcp-only', '--mode', '--multimodal', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--repo', '--reset', '--resolve', '--restore-only', '--save', '--scopes', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--stdin', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--trusted-extraction', '--type', '--url', '--what', '--where', '--who', '--with-db', '--yes'],
|
||||
'check-backlinks': ['--background', '--brain', '--brain-wide-max-cost-usd', '--dir', '--dry-run', '--explain', '--follow', '--help', '--include-frontmatter', '--json', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--source', '--stale', '--timeout', '--type'],
|
||||
'check-resolvable': ['--brain', '--dry-run', '--fix', '--help', '--json', '--skills-dir', '--source', '--strict', '--verbose'],
|
||||
'check-update': ['--all', '--brain', '--check', '--ff-only', '--help', '--json', '--markdown', '--migrate-only', '--non-interactive', '--refresh-cache', '--source', '--swap-only', '--to', '--version', '--yes'],
|
||||
'claw-test': ['--agent', '--brain', '--dir', '--help', '--json', '--keep-tempdir', '--list-agents', '--live', '--local', '--message', '--no-embed', '--no-embedding', '--path', '--pglite', '--progress-json', '--prompt-file', '--run-id', '--scenario', '--source', '--transcripts'],
|
||||
'check-update': ['--all', '--brain', '--check', '--dim', '--ff-only', '--help', '--json', '--markdown', '--migrate-only', '--non-interactive', '--refresh-cache', '--source', '--swap-only', '--to', '--version', '--yes'],
|
||||
'claw-test': ['--ab', '--agent', '--all', '--auto-update', '--brain', '--break-lock', '--build-index', '--by-mention', '--compile', '--days', '--dir', '--exclusive', '--force-retry', '--force-schema', '--from-meetings', '--help', '--history', '--http', '--json', '--keep-tempdir', '--lang', '--list-agents', '--live', '--local', '--locks', '--markdown', '--max-age', '--message', '--multimodal', '--no-embed', '--no-embedding', '--no-extract', '--path', '--pglite', '--phase', '--priority', '--progress-json', '--prompt-file', '--refresh-unqualified', '--remediate', '--rollback', '--run-id', '--scenario', '--skip-verify', '--source', '--stale', '--transcripts', '--undo-wave', '--use-captured-snapshot', '--with-calibration', '--yes'],
|
||||
'code-callees': ['--aliases', '--all', '--all-sources', '--brain', '--chunker-debug', '--clone-dir', '--confirm-destructive', '--federated', '--force', '--help', '--include-null-signature', '--json', '--limit', '--no-extract', '--no-federated', '--no-json', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--url', '--url-managed', '--yes'],
|
||||
'code-callers': ['--aliases', '--all', '--all-sources', '--brain', '--chunker-debug', '--clone-dir', '--confirm-destructive', '--federated', '--force', '--help', '--include-null-signature', '--json', '--limit', '--no-extract', '--no-federated', '--no-json', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--url', '--url-managed', '--yes'],
|
||||
'code-def': ['--aliases', '--all', '--brain', '--chunker-debug', '--help', '--include-null-signature', '--json', '--lang', '--limit', '--no-extract', '--no-json', '--pattern', '--pending', '--pretty', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--yes'],
|
||||
'code-refs': ['--aliases', '--all', '--brain', '--chunker-debug', '--help', '--include-null-signature', '--json', '--lang', '--limit', '--no-extract', '--no-json', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--yes'],
|
||||
'config': ['--aliases', '--all', '--brain', '--column', '--coverage-override', '--embedding-dimensions', '--embedding-model', '--fast', '--federated-read', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--markdown', '--model', '--multimodal', '--no-embedding', '--no-extract', '--pattern', '--pending', '--pglite', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--surface', '--thin', '--yes'],
|
||||
'config': ['--aliases', '--all', '--brain', '--column', '--coverage-override', '--detail', '--embedding-dimensions', '--embedding-model', '--fast', '--federated-read', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--markdown', '--model', '--multimodal', '--no-embedding', '--no-extract', '--no-federated', '--pattern', '--pending', '--pglite', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--surface', '--thin', '--token-ttl', '--yes'],
|
||||
'connect': ['--agent', '--bearer-token-env-var', '--bind', '--brain', '--client-id', '--client-secret', '--force', '--grant-types', '--help', '--http', '--install', '--json', '--name', '--oauth', '--public-url', '--register', '--scopes', '--show-token', '--source', '--timeout-ms', '--token', '--token-endpoint-auth-method', '--url', '--version', '--yes'],
|
||||
'conversation-parser': ['--aliases', '--all', '--brain', '--help', '--include-null-signature', '--json', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin'],
|
||||
'doctor': ['--ab', '--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--allow-shell-jobs', '--allow-unverified-remote', '--auto', '--auto-fix', '--auto-update', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--build-index', '--by-mention', '--by-type', '--cached', '--check', '--column', '--compile', '--concurrency', '--confidence', '--confirm', '--content-audit', '--count', '--days', '--delete-brain', '--detach', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--exclude-standard', '--exclusive', '--explain', '--fast', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--grant-types', '--harness', '--health-interval', '--help', '--history', '--home', '--http', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--init', '--input', '--is-inside-work-tree', '--jq', '--json', '--lang', '--limit', '--local', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-crashes', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-cron', '--no-embed', '--no-embedding', '--no-extract', '--no-mutate', '--no-verify', '--oauth-client-secret', '--older-than', '--once', '--others', '--overwrite', '--parallel', '--params', '--pat-file', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--priority', '--progress-interval', '--progress-json', '--push-only', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--restore-only', '--resume', '--review-lower', '--rollback', '--scope', '--scopes', '--set', '--short', '--show-current', '--show-toplevel', '--since', '--skills-dir', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--skip-verify', '--slugs', '--source', '--source-id', '--stale', '--stats', '--status', '--strategy', '--strict', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--timeout', '--to', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--unset-all', '--untracked-files', '--url', '--use-captured-snapshot', '--verbose', '--verify', '--version', '--window', '--with-calibration', '--workers', '--yes'],
|
||||
'dream': ['--against', '--aliases', '--all', '--allow-regression', '--anchor', '--asof', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd', '--budget-usd-answer', '--budget-usd-retrieval', '--by-type', '--by-type-floor', '--code', '--committed-baseline', '--compare', '--compile', '--concurrent', '--ctx-size', '--cycles', '--date', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--expansion', '--explain', '--fast', '--federated', '--fix', '--fixtures', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--format', '--from', '--from-db', '--from-pages', '--gold', '--harness', '--help', '--http', '--include-holdout', '--include-null-signature', '--input', '--install', '--json', '--judge-model', '--justification', '--keyword-only', '--lang', '--limit', '--llm', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--max-tokens', '--max-usd', '--mcp-only', '--min-recall', '--mode', '--model', '--models', '--modes', '--multimodal', '--name', '--name-only', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-llm', '--no-mutate', '--no-trajectory', '--once', '--out', '--output', '--output-dir', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--phase', '--priority', '--progress-interval', '--progress-json', '--pull', '--quiet', '--receipt-dir', '--remediate', '--repo', '--reranking', '--reset', '--resolve', '--restore-only', '--resume-from', '--retrieval-only', '--rounds', '--rubric-version', '--save', '--seed', '--short', '--show-toplevel', '--since', '--skip-replay', '--slot-a-model', '--slot-b-model', '--slot-c-model', '--slug', '--slug-prefix', '--source', '--source-id', '--stale', '--suite', '--suites', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--take', '--task', '--thin', '--threshold', '--timeout', '--to', '--top-k', '--undo', '--unsafe-bypass-dream-guard', '--update-baseline', '--verify', '--version', '--window', '--yes'],
|
||||
'doctor': ['--ab', '--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--allow-shell-jobs', '--allow-unverified-remote', '--auto', '--auto-fix', '--auto-update', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--build-index', '--by-mention', '--by-type', '--cached', '--check', '--column', '--compile', '--concurrency', '--confidence', '--confirm', '--content-audit', '--count', '--days', '--delete-brain', '--detach', '--detail', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--exclude-standard', '--exclusive', '--explain', '--fast', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--grant-types', '--harness', '--health-interval', '--help', '--history', '--home', '--http', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--init', '--input', '--is-inside-work-tree', '--jq', '--json', '--lang', '--limit', '--local', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-crashes', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-cron', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-mutate', '--no-verify', '--oauth-client-secret', '--older-than', '--once', '--others', '--overwrite', '--parallel', '--params', '--pat-file', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--priority', '--probe-pglite', '--progress-interval', '--progress-json', '--push-only', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--restore-only', '--resume', '--review-lower', '--rollback', '--scope', '--scopes', '--set', '--short', '--show-current', '--show-toplevel', '--since', '--skills-dir', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--skip-verify', '--slugs', '--source', '--source-id', '--stale', '--stats', '--status', '--strategy', '--strict', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--timeout', '--to', '--token-ttl', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--unset-all', '--untracked-files', '--url', '--use-captured-snapshot', '--verbose', '--verify', '--version', '--window', '--with-calibration', '--workers', '--yes'],
|
||||
'dream': ['--against', '--aliases', '--all', '--allow-regression', '--anchor', '--asof', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd', '--budget-usd-answer', '--budget-usd-retrieval', '--by-type', '--by-type-floor', '--code', '--committed-baseline', '--compare', '--compile', '--concurrent', '--ctx-size', '--cycles', '--date', '--detail', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--expansion', '--explain', '--fast', '--federated', '--fix', '--fixtures', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--format', '--from', '--from-db', '--from-pages', '--gold', '--harness', '--help', '--http', '--include-holdout', '--include-null-signature', '--input', '--install', '--json', '--judge-model', '--justification', '--keyword-only', '--lang', '--limit', '--llm', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--max-tokens', '--max-usd', '--mcp-only', '--min-recall', '--mode', '--model', '--models', '--modes', '--multimodal', '--name', '--name-only', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-llm', '--no-mutate', '--no-trajectory', '--once', '--out', '--output', '--output-dir', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--phase', '--priority', '--progress-interval', '--progress-json', '--pull', '--quiet', '--receipt-dir', '--remediate', '--repo', '--reranking', '--reset', '--resolve', '--restore-only', '--resume-from', '--retrieval-only', '--rounds', '--rubric-version', '--save', '--seed', '--short', '--show-toplevel', '--since', '--skip-replay', '--slot-a-model', '--slot-b-model', '--slot-c-model', '--slug', '--slug-prefix', '--source', '--source-id', '--stale', '--suite', '--suites', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--take', '--task', '--thin', '--threshold', '--timeout', '--to', '--token-ttl', '--top-k', '--undo', '--unsafe-bypass-dream-guard', '--update-baseline', '--verify', '--version', '--window', '--yes'],
|
||||
'edges-backfill': ['--aliases', '--all', '--all-sources', '--brain', '--concurrency', '--federated', '--help', '--include-null-signature', '--json', '--max-age', '--max-chunks', '--max-cost-usd', '--no-extract', '--no-federated', '--older-than', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--timeout', '--workers'],
|
||||
'embed': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--catch-up', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--max-cost-usd', '--model', '--multimodal', '--name', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--pace', '--pace-max-concurrency', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--restore-only', '--serial', '--slugs', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--version'],
|
||||
'enrich': ['--aliases', '--all', '--all-sources', '--allow-empty', '--apply', '--asof', '--auto', '--background', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd-per-day', '--by-mention', '--clone-dir', '--code', '--concurrency', '--confirm-destructive', '--content', '--date', '--days', '--dry-run', '--embedding-dimensions', '--embedding-model', '--entities', '--explain', '--fast', '--federated', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--judge-model', '--kind', '--lang', '--limit', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--max-usd', '--min-context', '--mode', '--model', '--multimodal', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--offset', '--older-than', '--order', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--reenrich-after', '--remediate', '--reset', '--resolve', '--restore-only', '--resume', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--source-id', '--stale', '--stats', '--supersessions', '--surface', '--symbol-kind', '--thin', '--thin-threshold', '--timeout', '--trusted-extraction', '--types', '--url', '--url-managed', '--version', '--with-db', '--workers', '--yes'],
|
||||
'eval': ['--ab-relational', '--against', '--aliases', '--all', '--allow-regression', '--background', '--baseline', '--batch', '--brain', '--brain-wide-max-cost-usd', '--budget-usd', '--budget-usd-answer', '--budget-usd-retrieval', '--committed-baseline', '--compare', '--compare-limit', '--concurrent', '--config-a', '--config-b', '--corpus', '--cycles', '--days', '--dedup-cosine', '--dedup-max-per-page', '--dedup-type-ratio', '--dimensions', '--distance-min', '--embedding-dimensions', '--embedding-model', '--expand', '--explain', '--fast', '--fixtures', '--follow', '--force', '--from-capture', '--from-db', '--from-pages', '--gold', '--grounding-min', '--harness', '--help', '--http', '--include-holdout', '--include-null-signature', '--input', '--json', '--judge', '--justification', '--k', '--limit', '--llm', '--max-pair-chars', '--max-tokens', '--max-usd', '--md', '--metric', '--min-recall', '--mode', '--model', '--models', '--modes', '--multimodal', '--name', '--no', '--no-cache', '--no-embed', '--no-embedding', '--no-expand', '--no-extract', '--no-llm', '--older-than', '--out', '--output', '--output-dir', '--parallel', '--pattern', '--pending', '--progress-interval', '--progress-json', '--qrels', '--queries-file', '--query', '--questions', '--quiet', '--receipt-dir', '--refresh-cache', '--remediate', '--reset', '--resolve', '--rrf-k', '--rubric-version', '--runs', '--sampling', '--save', '--seed', '--severity', '--short', '--show-toplevel', '--since', '--skip-replay', '--slot-a-model', '--slot-b-model', '--slot-c-model', '--slug', '--slug-prefix', '--source', '--stale', '--strategy', '--strict', '--suite', '--suites', '--supersessions', '--surface', '--task', '--thin', '--threshold', '--threshold-expected-top1', '--threshold-first-relevant-hit', '--threshold-jaccard', '--threshold-latency-multiplier', '--threshold-recall-at-k', '--threshold-top1', '--timeout', '--tool', '--top-k', '--top-regressions', '--until', '--update-baseline', '--usefulness-min', '--verbose', '--version', '--with-code-intel', '--yes'],
|
||||
'embed': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--catch-up', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--max-cost-usd', '--model', '--multimodal', '--name', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--pace', '--pace-max-concurrency', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--restore-only', '--serial', '--slugs', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--token-ttl', '--version'],
|
||||
'enrich': ['--aliases', '--all', '--all-sources', '--allow-empty', '--apply', '--asof', '--auto', '--background', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd-per-day', '--by-mention', '--clone-dir', '--code', '--concurrency', '--confirm-destructive', '--content', '--date', '--days', '--detail', '--dry-run', '--embedding-dimensions', '--embedding-model', '--entities', '--explain', '--fast', '--federated', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--judge-model', '--kind', '--lang', '--limit', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--max-usd', '--min-context', '--mode', '--model', '--multimodal', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--offset', '--older-than', '--order', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--reenrich-after', '--remediate', '--reset', '--resolve', '--restore-only', '--resume', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--source-id', '--stale', '--stats', '--supersessions', '--surface', '--symbol-kind', '--thin', '--thin-threshold', '--timeout', '--token-ttl', '--trusted-extraction', '--types', '--url', '--url-managed', '--version', '--with-db', '--workers', '--yes'],
|
||||
'eval': ['--ab-relational', '--against', '--aliases', '--all', '--allow-regression', '--background', '--baseline', '--batch', '--brain', '--brain-wide-max-cost-usd', '--budget-usd', '--budget-usd-answer', '--budget-usd-retrieval', '--committed-baseline', '--compare', '--compare-limit', '--concurrent', '--config-a', '--config-b', '--corpus', '--cycles', '--days', '--dedup-cosine', '--dedup-max-per-page', '--dedup-type-ratio', '--dimensions', '--distance-min', '--embedding-dimensions', '--embedding-model', '--expand', '--explain', '--fast', '--fixtures', '--follow', '--force', '--from-capture', '--from-db', '--from-pages', '--gold', '--grounding-min', '--harness', '--help', '--http', '--include-holdout', '--include-null-signature', '--input', '--json', '--judge', '--justification', '--k', '--limit', '--llm', '--max-pair-chars', '--max-tokens', '--max-usd', '--md', '--metric', '--min-recall', '--mode', '--model', '--models', '--modes', '--multimodal', '--name', '--no', '--no-cache', '--no-embed', '--no-embedding', '--no-expand', '--no-extract', '--no-llm', '--older-than', '--out', '--output', '--output-dir', '--parallel', '--pattern', '--pending', '--progress-interval', '--progress-json', '--qrels', '--queries-file', '--query', '--questions', '--quiet', '--receipt-dir', '--refresh-cache', '--remediate', '--reset', '--resolve', '--rrf-k', '--rubric-version', '--runs', '--sampling', '--save', '--seed', '--severity', '--short', '--show-toplevel', '--since', '--skip-replay', '--slot-a-model', '--slot-b-model', '--slot-c-model', '--slug', '--slug-prefix', '--source', '--stale', '--strategy', '--strict', '--suite', '--suites', '--supersessions', '--surface', '--task', '--thin', '--threshold', '--threshold-expected-top1', '--threshold-first-relevant-hit', '--threshold-jaccard', '--threshold-latency-multiplier', '--threshold-recall-at-k', '--threshold-top1', '--timeout', '--token-ttl', '--tool', '--top-k', '--top-regressions', '--until', '--update-baseline', '--usefulness-min', '--verbose', '--version', '--with-code-intel', '--yes'],
|
||||
'export': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--dir', '--explain', '--federated', '--fix', '--follow', '--help', '--include-null-signature', '--json', '--lang', '--markdown', '--multimodal', '--near-symbol', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--restore-only', '--slug-prefix', '--source', '--stale', '--supersessions', '--symbol-kind', '--thin', '--timeout', '--type'],
|
||||
'extract': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--by-mention', '--catch-up', '--code', '--concurrency', '--dir', '--dry-run', '--explain', '--federated', '--follow', '--from-meetings', '--help', '--include-frontmatter', '--include-null-signature', '--infer-dates', '--json', '--kind', '--lang', '--markdown', '--max-age', '--max-cost-usd', '--multimodal', '--name-status', '--near-symbol', '--ner', '--no-extract', '--no-federated', '--older-than', '--pack', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--restore-only', '--run-id', '--since', '--slug', '--source', '--source-id', '--stale', '--strategy', '--supersessions', '--symbol-kind', '--thin', '--timeout', '--type', '--verbose', '--workers', '--yes'],
|
||||
'extract-conversation-facts': ['--aliases', '--all', '--all-sources', '--background', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--by-mention', '--clone-dir', '--code', '--concurrency', '--confirm-destructive', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fix', '--follow', '--force', '--force-break-lock', '--help', '--include-null-signature', '--json', '--judge-model', '--lang', '--limit', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--model', '--multimodal', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--older-than', '--override-disabled', '--path', '--pattern', '--pending', '--pglite', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--restore-only', '--segment-limit', '--session', '--since', '--sleep', '--slug', '--source', '--source-id', '--stale', '--supabase', '--supersessions', '--symbol-kind', '--thin', '--timeout', '--types', '--url', '--url-managed', '--version', '--workers', '--yes'],
|
||||
'features': ['--aliases', '--all', '--auto-fix', '--background', '--batch-size', '--brain', '--by-mention', '--catch-up', '--concurrency', '--dir', '--explain', '--from-meetings', '--help', '--include-frontmatter', '--include-null-signature', '--infer-dates', '--json', '--kind', '--ner', '--no-extract', '--pace', '--pace-max-concurrency', '--pack', '--path', '--pattern', '--pending', '--priority', '--progress-json', '--quiet', '--repo', '--reset', '--resolve', '--run-id', '--since', '--slugs', '--source', '--source-id', '--stale', '--supersessions', '--thin', '--type', '--verbose', '--workers'],
|
||||
'files': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--explain', '--fast', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--no-pointer', '--page', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--retry-failed', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--type', '--yes'],
|
||||
'forget': ['--aliases', '--all', '--allow-empty', '--apply', '--as-context', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-tokens', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--grep', '--help', '--http', '--image', '--include-expired', '--include-null-signature', '--json', '--kind', '--limit', '--max-usd', '--mcp-only', '--mode', '--multimodal', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--query', '--quiet', '--reason', '--repo', '--reset', '--resolve', '--restore-only', '--rollup', '--save', '--session', '--session-id', '--since', '--since-last-run', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--today', '--trusted-extraction', '--url', '--watch', '--with-db', '--yes'],
|
||||
'founder': ['--aliases', '--all', '--brain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--mcp-only', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--since', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--until'],
|
||||
'friction': ['--agent', '--brain', '--help', '--hint', '--json', '--kind', '--message', '--no-redact', '--phase', '--redact', '--run-id', '--severity', '--source', '--transcript-path', '--transcripts'],
|
||||
'frontmatter': ['--aliases', '--all', '--allow-catch-all', '--brain', '--cached', '--diff-filter', '--dry-run', '--exclude-standard', '--fast', '--fix', '--force', '--from-pages', '--get', '--help', '--http', '--include-catch-all', '--include-null-signature', '--json', '--name-only', '--name-status', '--no-embedding', '--no-extract', '--no-verify', '--others', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--strategy', '--supersessions', '--surface', '--thin', '--timeout', '--uninstall', '--write-back'],
|
||||
'graph-query': ['--aliases', '--all', '--brain', '--depth', '--direction', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-foreign', '--include-null-signature', '--json', '--lang', '--markdown', '--mcp-only', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--type'],
|
||||
'hook': ['--aliases', '--all', '--allow-unverified-remote', '--batch-limit', '--brain', '--budget-ms', '--cached', '--count', '--delete-brain', '--detach', '--diff-filter', '--end-of-options', '--env', '--exclude-standard', '--fast', '--force', '--from-pages', '--get', '--harness', '--help', '--http', '--include-null-signature', '--jq', '--json', '--name-only', '--no-embedding', '--no-extract', '--once', '--others', '--path', '--pattern', '--pending', '--porcelain', '--quiet', '--reset', '--resolve', '--show-current', '--show-toplevel', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout'],
|
||||
'import': ['--aliases', '--all', '--asof', '--background', '--brain', '--brain-wide-max-cost-usd', '--by-mention', '--cached', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--fix', '--follow', '--force', '--force-rechunk', '--fresh', '--from-pages', '--full', '--help', '--http', '--include-gitignored', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--multimodal', '--name-status', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--older-than', '--others', '--path', '--pattern', '--pending', '--pglite', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--since', '--skip-failed', '--source', '--source-id', '--stale', '--strategy', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--url', '--workers'],
|
||||
'init': ['--all', '--brain', '--chat-model', '--check', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--entity', '--expansion-model', '--fast', '--force', '--from-pages', '--grant-types', '--help', '--http', '--issuer-url', '--json', '--judge-model', '--key', '--mcp-only', '--mcp-url', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--path', '--pglite', '--provenance', '--schema-pack', '--scopes', '--skip-embed-check', '--source', '--stale', '--supabase', '--surface', '--target', '--to', '--touchpoint', '--url', '--version'],
|
||||
'integrations': ['--auto', '--brain', '--dry-run', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--overwrite', '--refresh', '--reranking', '--source', '--surface', '--target'],
|
||||
'integrity': ['--aliases', '--all', '--auto', '--backend', '--background', '--brain', '--brain-wide-max-cost-usd', '--check', '--confidence', '--cost', '--dry-run', '--explain', '--fast', '--follow', '--force', '--fresh', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--limit', '--no-embedding', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--review-lower', '--skip-bare-tweet', '--skip-urls', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--type', '--url'],
|
||||
'jobs': ['--abbrev-ref', '--aliases', '--all', '--allow-empty', '--allow-protected', '--allow-shell-jobs', '--apply', '--asof', '--auto', '--auto-fix', '--auto-with-prompt', '--background', '--backoff-delay', '--backoff-jitter', '--backoff-type', '--batch', '--batch-size', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--break-lock', '--budget-usd', '--budget-usd-per-day', '--by-mention', '--by-type', '--cached', '--catch-up', '--check', '--cli-path', '--cluster', '--cluster-errors', '--code', '--concurrency', '--confidence', '--confirm-destructive', '--content', '--date', '--days', '--delay', '--detach', '--diff-filter', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--entities', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--federated-read', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--hard-deadline', '--health-interval', '--held-out', '--help', '--http', '--idempotency-key', '--image', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--infer-dates', '--inject-bootstrap', '--inline', '--input', '--install', '--interval', '--is-ancestor', '--json', '--kind', '--lang', '--limit', '--lock', '--markdown', '--max-age', '--max-attempts', '--max-cost-usd', '--max-crashes', '--max-rss', '--max-runtime-min', '--max-sources', '--max-stalled', '--max-usd', '--max-waiting', '--mcp-only', '--migrate-only', '--min-context', '--missing-path', '--mode', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--ner', '--nice', '--no', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federate', '--no-gpg-sign', '--no-hard-deadline', '--no-inject', '--no-mutate', '--no-pull', '--no-renames', '--no-schema-pack', '--no-verify', '--no-worker', '--non-interactive', '--now', '--offset', '--older-than', '--once', '--order', '--orphan', '--others', '--output', '--override-disabled', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--phase', '--pid-file', '--priority', '--progress-interval', '--progress-json', '--queue', '--quiet', '--redact-secrets', '--reenrich-after', '--refresh-cache', '--refresh-ms', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--review-lower', '--run-id', '--save', '--segment-limit', '--serial', '--session', '--session-id', '--short', '--show-toplevel', '--sigkill-rescue', '--since', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--sleep', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--stats', '--status', '--strategy', '--supersessions', '--surface', '--swap-only', '--symbol-kind', '--target', '--target-score', '--thin', '--thin-threshold', '--timeout', '--timeout-ms', '--to', '--trusted-extraction', '--type', '--types', '--uninstall', '--unsafe-bypass-dream-guard', '--url', '--user', '--verbose', '--verify', '--version', '--watch', '--wedge-rescue', '--with-db', '--workers', '--yes'],
|
||||
'lint': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--exclude', '--explain', '--fast', '--fix', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout'],
|
||||
'files': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--explain', '--fast', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--no-pointer', '--page', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--retry-failed', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--type', '--yes'],
|
||||
'forget': ['--aliases', '--all', '--allow-empty', '--apply', '--as-context', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-tokens', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--grep', '--help', '--http', '--image', '--include-expired', '--include-null-signature', '--json', '--kind', '--limit', '--max-usd', '--mcp-only', '--mode', '--multimodal', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--query', '--quiet', '--reason', '--repo', '--reset', '--resolve', '--restore-only', '--rollup', '--save', '--session', '--session-id', '--since', '--since-last-run', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--today', '--token-ttl', '--trusted-extraction', '--url', '--watch', '--with-db', '--yes'],
|
||||
'founder': ['--aliases', '--all', '--brain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--mcp-only', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--since', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--until'],
|
||||
'friction': ['--agent', '--base', '--brain', '--compare', '--help', '--hint', '--json', '--kind', '--message', '--no-redact', '--phase', '--redact', '--run-id', '--severity', '--source', '--transcript-path', '--transcripts'],
|
||||
'frontmatter': ['--aliases', '--all', '--allow-catch-all', '--brain', '--cached', '--diff-filter', '--dry-run', '--exclude-standard', '--fast', '--fix', '--force', '--from-pages', '--get', '--help', '--http', '--include-catch-all', '--include-null-signature', '--json', '--name-only', '--name-status', '--no-embedding', '--no-extract', '--no-verify', '--others', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--strategy', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--uninstall', '--write-back'],
|
||||
'graph-query': ['--aliases', '--all', '--brain', '--depth', '--direction', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-foreign', '--include-null-signature', '--json', '--lang', '--markdown', '--mcp-only', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--token-ttl', '--type'],
|
||||
'hook': ['--aliases', '--all', '--allow-unverified-remote', '--batch-limit', '--brain', '--budget-ms', '--cached', '--count', '--delete-brain', '--detach', '--diff-filter', '--end-of-options', '--env', '--exclude-standard', '--fast', '--force', '--from-pages', '--get', '--harness', '--help', '--http', '--include-null-signature', '--jq', '--json', '--name-only', '--no-embedding', '--no-extract', '--once', '--others', '--path', '--pattern', '--pending', '--porcelain', '--quiet', '--reset', '--resolve', '--show-current', '--show-toplevel', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl'],
|
||||
'import': ['--aliases', '--all', '--asof', '--background', '--brain', '--brain-wide-max-cost-usd', '--by-mention', '--cached', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--fix', '--follow', '--force', '--force-rechunk', '--fresh', '--from-pages', '--full', '--help', '--http', '--include-gitignored', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--multimodal', '--name-status', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--older-than', '--others', '--path', '--pattern', '--pending', '--pglite', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--since', '--skip-failed', '--source', '--source-id', '--stale', '--strategy', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--url', '--workers'],
|
||||
'init': ['--all', '--brain', '--chat-model', '--check', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--entity', '--expansion-model', '--fast', '--flag', '--force', '--from-pages', '--grant-types', '--help', '--http', '--issuer-url', '--json', '--judge-model', '--key', '--mcp-only', '--mcp-url', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--path', '--pglite', '--provenance', '--schema-pack', '--scopes', '--skip-embed-check', '--source', '--stale', '--supabase', '--surface', '--to', '--token-ttl', '--touchpoint', '--url', '--version'],
|
||||
'integrations': ['--auto', '--brain', '--dry-run', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--overwrite', '--refresh', '--reranking', '--source', '--surface', '--target', '--token-ttl'],
|
||||
'integrity': ['--aliases', '--all', '--auto', '--backend', '--background', '--brain', '--brain-wide-max-cost-usd', '--check', '--confidence', '--cost', '--dry-run', '--explain', '--fast', '--follow', '--force', '--fresh', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--limit', '--no-embedding', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--review-lower', '--skip-bare-tweet', '--skip-urls', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--type', '--url'],
|
||||
'jobs': ['--abbrev-ref', '--aliases', '--all', '--allow-empty', '--allow-protected', '--allow-shell-jobs', '--apply', '--asof', '--auto', '--auto-fix', '--auto-with-prompt', '--background', '--backoff-delay', '--backoff-jitter', '--backoff-type', '--batch', '--batch-size', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--break-lock', '--budget-usd', '--budget-usd-per-day', '--by-mention', '--by-type', '--cached', '--catch-up', '--check', '--cli-path', '--cluster', '--cluster-errors', '--code', '--concurrency', '--confidence', '--confirm-destructive', '--content', '--date', '--days', '--delay', '--detach', '--diff-filter', '--dim', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--entities', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--federated-read', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--hard-deadline', '--health-interval', '--held-out', '--help', '--http', '--idempotency-key', '--image', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--infer-dates', '--inject-bootstrap', '--inline', '--input', '--install', '--interval', '--is-ancestor', '--json', '--kind', '--lang', '--limit', '--lock', '--markdown', '--max-age', '--max-attempts', '--max-cost-usd', '--max-crashes', '--max-rss', '--max-runtime-min', '--max-sources', '--max-stalled', '--max-usd', '--max-waiting', '--mcp-only', '--migrate-only', '--min-context', '--missing-path', '--mode', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--ner', '--nice', '--no', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-inject', '--no-mutate', '--no-pull', '--no-renames', '--no-schema-pack', '--no-verify', '--no-worker', '--non-interactive', '--now', '--offset', '--older-than', '--once', '--order', '--orphan', '--others', '--output', '--override-disabled', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--phase', '--pid-file', '--priority', '--progress-interval', '--progress-json', '--queue', '--quiet', '--redact-secrets', '--reenrich-after', '--refresh-cache', '--refresh-ms', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--review-lower', '--run-id', '--save', '--segment-limit', '--serial', '--session', '--session-id', '--short', '--show-toplevel', '--sigkill-rescue', '--since', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--sleep', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--stats', '--status', '--strategy', '--supersessions', '--surface', '--swap-only', '--symbol-kind', '--target', '--target-score', '--thin', '--thin-threshold', '--timeout', '--timeout-ms', '--to', '--token-ttl', '--trusted-extraction', '--type', '--types', '--uninstall', '--unsafe-bypass-dream-guard', '--url', '--user', '--verbose', '--verify', '--version', '--watch', '--wedge-rescue', '--with-db', '--workers', '--yes'],
|
||||
'lint': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--exclude', '--explain', '--fast', '--fix', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl'],
|
||||
'lsd': ['--brain', '--force-resume', '--help', '--json', '--judge-model', '--limit', '--list-runs', '--max-cost', '--max-far-set', '--max-ideas-per-judge-call', '--no-save', '--resume', '--retry-judge', '--save', '--source', '--strict-budget', '--yes'],
|
||||
'maintain': ['--aliases', '--all', '--background', '--brain', '--break-lock', '--by-mention', '--catch-up', '--column', '--concurrency', '--content-audit', '--count', '--detach', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--force', '--force-retry', '--force-schema', '--from-meetings', '--full', '--help', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--index-audit', '--infer-dates', '--input', '--json', '--kind', '--lang', '--locks', '--markdown', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-usd', '--migrate-only', '--multimodal', '--near-symbol', '--ner', '--nice', '--no-extract', '--no-mutate', '--older-than', '--once', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--progress-json', '--query', '--queue', '--quiet', '--rebuild-rollup', '--regenerate', '--remediate', '--remediation-plan', '--reset', '--resolve', '--restore-only', '--resume', '--run-id', '--safe', '--scope', '--since', '--skills-dir', '--skip-failed', '--slugs', '--source', '--source-id', '--stale', '--status', '--supabase', '--supersessions', '--symbol-kind', '--target', '--target-score', '--thin', '--to', '--top-k', '--type', '--unsafe-bypass-dream-guard', '--url', '--verbose', '--window', '--workers', '--yes'],
|
||||
'migrate': ['--ab', '--aliases', '--all', '--auto-update', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--build-index', '--by-mention', '--catch-up', '--compile', '--days', '--dim', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--exclusive', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--from-meetings', '--from-pages', '--help', '--history', '--http', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--lang', '--locks', '--markdown', '--max-age', '--model', '--multimodal', '--name', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--non-interactive', '--pace', '--pace-max-concurrency', '--parallel', '--path', '--pattern', '--pending', '--phase', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--refresh-unqualified', '--remediate', '--reranking', '--reset', '--resolve', '--restore-only', '--resume', '--rollback', '--skip-verify', '--slugs', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--to', '--undo', '--undo-wave', '--url', '--use-captured-snapshot', '--version', '--with-calibration', '--yes'],
|
||||
'models': ['--aliases', '--all', '--brain', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--embeddings', '--help', '--include-null-signature', '--json', '--judge-model', '--model', '--multimodal', '--no', '--no-embed', '--no-extract', '--pattern', '--pending', '--reranking', '--reset', '--resolve', '--skip', '--source', '--stale', '--supersessions', '--thin', '--undo', '--version'],
|
||||
'maintain': ['--aliases', '--all', '--background', '--brain', '--break-lock', '--by-mention', '--catch-up', '--column', '--concurrency', '--content-audit', '--count', '--detach', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--force', '--force-retry', '--force-schema', '--from-meetings', '--full', '--help', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--index-audit', '--infer-dates', '--input', '--json', '--kind', '--lang', '--locks', '--markdown', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-usd', '--migrate-only', '--multimodal', '--near-symbol', '--ner', '--nice', '--no-extract', '--no-mutate', '--older-than', '--once', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--probe-pglite', '--progress-json', '--query', '--queue', '--quiet', '--rebuild-rollup', '--regenerate', '--remediate', '--remediation-plan', '--reset', '--resolve', '--restore-only', '--resume', '--run-id', '--safe', '--scope', '--since', '--skills-dir', '--skip-failed', '--slugs', '--source', '--source-id', '--stale', '--status', '--supabase', '--supersessions', '--symbol-kind', '--target', '--target-score', '--thin', '--to', '--top-k', '--type', '--unsafe-bypass-dream-guard', '--url', '--verbose', '--window', '--workers', '--yes'],
|
||||
'migrate': ['--ab', '--aliases', '--all', '--auto-update', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--build-index', '--by-mention', '--catch-up', '--compile', '--days', '--dim', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--exclusive', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--from-meetings', '--from-pages', '--help', '--history', '--http', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--lang', '--locks', '--markdown', '--max-age', '--model', '--multimodal', '--name', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--non-interactive', '--pace', '--pace-max-concurrency', '--parallel', '--path', '--pattern', '--pending', '--phase', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--refresh-unqualified', '--remediate', '--reranking', '--reset', '--resolve', '--restore-only', '--resume', '--rollback', '--skip-verify', '--slugs', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--to', '--token-ttl', '--undo', '--undo-wave', '--url', '--use-captured-snapshot', '--version', '--with-calibration', '--yes'],
|
||||
'models': ['--aliases', '--all', '--brain', '--ctx-size', '--detail', '--embedding-dimensions', '--embedding-model', '--embeddings', '--help', '--include-null-signature', '--json', '--judge-model', '--model', '--multimodal', '--no', '--no-embed', '--no-extract', '--pattern', '--pending', '--reranking', '--reset', '--resolve', '--skip', '--source', '--stale', '--supersessions', '--thin', '--undo', '--version'],
|
||||
'mounts': ['--alias', '--brain', '--cache', '--database-path', '--database-url', '--db-path', '--db-url', '--engine', '--explain', '--help', '--id', '--json', '--lang', '--lock', '--markdown', '--mcp-url', '--multimodal', '--near-symbol', '--path', '--restore-only', '--skills-dir', '--source', '--stale', '--symbol-kind', '--thin', '--verbose'],
|
||||
'notability-eval': ['--aliases', '--all', '--brain', '--embedding-dimensions', '--embedding-model', '--help', '--in', '--include-null-signature', '--json', '--model', '--multimodal', '--no', '--no-embed', '--no-extract', '--out', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--skip-llm', '--source', '--stale', '--supersessions', '--target-high', '--target-low', '--target-medium', '--thin', '--version'],
|
||||
'onboard': ['--aliases', '--all', '--allow-empty', '--allow-protected', '--apply', '--asof', '--auto', '--auto-with-prompt', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--check', '--content', '--date', '--days', '--entities', '--explain', '--federated', '--file', '--follow', '--from-pages', '--help', '--history', '--http', '--image', '--include-null-signature', '--json', '--kind', '--limit', '--max-usd', '--mode', '--multimodal', '--no-extract', '--offset', '--params', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--remediation-plan', '--reset', '--resolve', '--resume', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--target-score', '--thin', '--trusted-extraction', '--url', '--with-db', '--yes'],
|
||||
'orphans': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--count', '--explain', '--follow', '--help', '--include-null-signature', '--include-pseudo', '--json', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout'],
|
||||
'pages': ['--aliases', '--all', '--brain', '--dry-run', '--help', '--include-null-signature', '--json', '--no-extract', '--older-than', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin'],
|
||||
'pglite-repair': ['--brain', '--break-lock', '--dry-rnu', '--dry-run', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--path', '--quiet', '--source', '--surface', '--yes'],
|
||||
'post-upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--quiet', '--repo', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--surface', '--swap-only', '--target', '--to', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'],
|
||||
'protocol': ['--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--json', '--kind', '--limit', '--max-usd', '--mode', '--multimodal', '--no-embedding', '--offset', '--path', '--progress-interval', '--progress-json', '--quiet', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stats', '--surface', '--synthesize', '--target', '--timeout', '--token', '--trusted-extraction', '--url', '--with-db', '--yes'],
|
||||
'providers': ['--brain', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--reranking', '--source', '--surface', '--touchpoint', '--version'],
|
||||
'pglite-repair': ['--brain', '--break-lock', '--dry-rnu', '--dry-run', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--path', '--quiet', '--source', '--surface', '--token-ttl', '--yes'],
|
||||
'post-upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--detail', '--dim', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--flag', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--path', '--pglite', '--quiet', '--repo', '--reset', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--supabase', '--surface', '--swap-only', '--target', '--to', '--token-ttl', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'],
|
||||
'protocol': ['--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--json', '--kind', '--limit', '--max-usd', '--mode', '--multimodal', '--no-embedding', '--offset', '--path', '--progress-interval', '--progress-json', '--quiet', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stats', '--surface', '--synthesize', '--target', '--timeout', '--token', '--token-ttl', '--trusted-extraction', '--url', '--with-db', '--yes'],
|
||||
'providers': ['--brain', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--reranking', '--source', '--surface', '--token-ttl', '--touchpoint', '--version'],
|
||||
'publish': ['--accent', '--bg', '--border', '--brain', '--card-bg', '--code-bg', '--error', '--fg', '--help', '--json', '--link', '--muted', '--out', '--password', '--source', '--title'],
|
||||
'quarantine': ['--aliases', '--all', '--apply', '--brain', '--code', '--compile', '--explain', '--fast', '--fix', '--force', '--force-rechunk', '--from-pages', '--help', '--http', '--include-flagged', '--include-null-signature', '--json', '--lang', '--limit', '--markdown', '--multimodal', '--near-symbol', '--no-embed', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin'],
|
||||
'recall': ['--aliases', '--all', '--allow-empty', '--apply', '--as-context', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-tokens', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--grep', '--help', '--http', '--image', '--include-expired', '--include-null-signature', '--json', '--kind', '--limit', '--max-usd', '--mcp-only', '--mode', '--multimodal', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--query', '--quiet', '--reason', '--repo', '--reset', '--resolve', '--restore-only', '--rollup', '--save', '--session', '--session-id', '--since', '--since-last-run', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--today', '--trusted-extraction', '--url', '--watch', '--with-db', '--yes'],
|
||||
'quarantine': ['--aliases', '--all', '--apply', '--brain', '--code', '--compile', '--explain', '--fast', '--fix', '--force', '--force-rechunk', '--from-pages', '--help', '--http', '--include-flagged', '--include-null-signature', '--json', '--lang', '--limit', '--markdown', '--multimodal', '--near-symbol', '--no-embed', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin', '--token-ttl'],
|
||||
'recall': ['--aliases', '--all', '--allow-empty', '--apply', '--as-context', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-tokens', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--grep', '--help', '--http', '--image', '--include-expired', '--include-null-signature', '--json', '--kind', '--limit', '--max-usd', '--mcp-only', '--mode', '--multimodal', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--query', '--quiet', '--reason', '--repo', '--reset', '--resolve', '--restore-only', '--rollup', '--save', '--session', '--session-id', '--since', '--since-last-run', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--today', '--token-ttl', '--trusted-extraction', '--url', '--watch', '--with-db', '--yes'],
|
||||
'reconcile-links': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--explain', '--follow', '--help', '--include-frontmatter', '--include-null-signature', '--json', '--name-status', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--source', '--stale', '--strategy', '--supersessions', '--thin', '--timeout', '--type'],
|
||||
'reindex': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--code', '--compile', '--concurrency', '--cost-estimate', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lang', '--limit', '--markdown', '--max-age', '--max-cost-usd', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--no-extract', '--older-than', '--path', '--pattern', '--pending', '--pglite', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--version', '--workers', '--yes'],
|
||||
'reindex': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--code', '--compile', '--concurrency', '--cost-estimate', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lang', '--limit', '--markdown', '--max-age', '--max-cost-usd', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--no-extract', '--older-than', '--path', '--pattern', '--pending', '--pglite', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--version', '--workers', '--yes'],
|
||||
'reindex-code': ['--abi', '--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--chunker-debug', '--code', '--compile', '--concurrency', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fix', '--follow', '--force', '--force-rechunk', '--help', '--include-null-signature', '--json', '--judge-model', '--lang', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--model', '--multimodal', '--no', '--no-embed', '--no-extract', '--older-than', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--serial', '--source', '--stale', '--supersessions', '--thin', '--timeout', '--version', '--workers', '--yes'],
|
||||
'reindex-frontmatter': ['--aliases', '--all', '--brain', '--concurrency', '--dry-run', '--force', '--help', '--include-null-signature', '--json', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--slug-prefix', '--source', '--stale', '--supersessions', '--thin', '--workers', '--yes'],
|
||||
'reindex-search-vector': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--explain', '--follow', '--help', '--include-null-signature', '--json', '--migrate-only', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout', '--yes'],
|
||||
'reinit-pglite': ['--abbrev-ref', '--all', '--allow-empty', '--brain', '--break-lock', '--chat-model', '--concurrency', '--confirm-destructive', '--diff-filter', '--dir', '--embedding-dimensions', '--embedding-model', '--empty', '--entity', '--exclude', '--exclude-standard', '--expansion-model', '--fast', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--grant-types', '--hard-deadline', '--help', '--http', '--include-gitignored', '--interval', '--is-ancestor', '--issuer-url', '--json', '--key', '--lock', '--max-age', '--max-sources', '--mcp-only', '--mcp-url', '--migrate-only', '--missing-path', '--model', '--name-only', '--name-status', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-renames', '--no-schema-pack', '--no-sync', '--no-verify', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--orphan', '--parallel', '--path', '--pglite', '--priority', '--provenance', '--quiet', '--repo', '--retry-failed', '--schema-pack', '--scopes', '--serial', '--short', '--show-toplevel', '--skip-embed-check', '--skip-failed', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--strategy', '--supabase', '--surface', '--target', '--timeout', '--to', '--url', '--verify', '--version', '--watch', '--workers', '--yes'],
|
||||
'remote': ['--all', '--background', '--brain', '--break-lock', '--by-mention', '--column', '--concurrency', '--content-audit', '--count', '--detach', '--dim', '--dir', '--drain', '--embedding-dimensions', '--embedding-model', '--fast', '--fix', '--force', '--force-retry', '--force-schema', '--from-pages', '--full', '--help', '--http', '--include-flagged', '--index-audit', '--json', '--locks', '--markdown', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--multimodal', '--nice', '--no-embedding', '--older-than', '--parallel', '--params', '--path', '--pglite', '--phase', '--pid-file', '--porcelain', '--progress-json', '--query', '--queue', '--rebuild-rollup', '--regenerate', '--remediate', '--remediation-plan', '--reset', '--resume', '--scope', '--scopes', '--since', '--skills-dir', '--skip-failed', '--slugs', '--source', '--source-id', '--stale', '--status', '--supabase', '--surface', '--target', '--target-score', '--timeout', '--to', '--top-k', '--url', '--window', '--workers', '--yes'],
|
||||
'repair-jsonb': ['--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--explain', '--fast', '--follow', '--force', '--from-pages', '--help', '--http', '--json', '--lang', '--markdown', '--multimodal', '--near-symbol', '--no-embedding', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--restore-only', '--source', '--stale', '--supabase', '--surface', '--symbol-kind', '--thin', '--timeout', '--url'],
|
||||
'reinit-pglite': ['--abbrev-ref', '--all', '--allow-empty', '--brain', '--break-lock', '--chat-model', '--concurrency', '--confirm-destructive', '--diff-filter', '--dir', '--embedding-dimensions', '--embedding-model', '--empty', '--entity', '--exclude', '--exclude-standard', '--expansion-model', '--fast', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--grant-types', '--hard-deadline', '--help', '--http', '--include-gitignored', '--interval', '--is-ancestor', '--issuer-url', '--json', '--key', '--lock', '--max-age', '--max-sources', '--mcp-only', '--mcp-url', '--migrate-only', '--missing-path', '--model', '--name-only', '--name-status', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-renames', '--no-schema-pack', '--no-sync', '--no-verify', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--orphan', '--parallel', '--path', '--pglite', '--priority', '--provenance', '--quiet', '--repo', '--retry-failed', '--schema-pack', '--scopes', '--serial', '--short', '--show-toplevel', '--skip-embed-check', '--skip-failed', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--strategy', '--supabase', '--surface', '--timeout', '--to', '--token-ttl', '--url', '--verify', '--version', '--watch', '--workers', '--yes'],
|
||||
'remote': ['--all', '--background', '--brain', '--break-lock', '--by-mention', '--column', '--concurrency', '--content-audit', '--count', '--detach', '--dim', '--dir', '--drain', '--embedding-dimensions', '--embedding-model', '--fast', '--fix', '--force', '--force-retry', '--force-schema', '--from-pages', '--full', '--help', '--http', '--include-flagged', '--index-audit', '--json', '--locks', '--markdown', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--multimodal', '--nice', '--no-embedding', '--older-than', '--parallel', '--params', '--path', '--pglite', '--phase', '--pid-file', '--porcelain', '--probe-pglite', '--progress-json', '--query', '--queue', '--rebuild-rollup', '--regenerate', '--remediate', '--remediation-plan', '--reset', '--resume', '--scope', '--scopes', '--since', '--skills-dir', '--skip-failed', '--slugs', '--source', '--source-id', '--stale', '--status', '--supabase', '--surface', '--target', '--target-score', '--timeout', '--to', '--token-ttl', '--top-k', '--url', '--window', '--workers', '--yes'],
|
||||
'repair-jsonb': ['--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--explain', '--fast', '--follow', '--force', '--from-pages', '--help', '--http', '--json', '--lang', '--markdown', '--multimodal', '--near-symbol', '--no-embedding', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--restore-only', '--source', '--stale', '--supabase', '--surface', '--symbol-kind', '--thin', '--timeout', '--token-ttl', '--url'],
|
||||
'report': ['--brain', '--content', '--dir', '--help', '--json', '--source', '--title', '--type'],
|
||||
'repos': ['--abbrev-ref', '--abort', '--aliases', '--all', '--all-sources', '--allow-unverified-remote', '--brain', '--branch', '--break-lock', '--cached', '--clone-dir', '--compile', '--confirm-destructive', '--count', '--detect', '--diff-filter', '--dry-run', '--exclude-standard', '--explain', '--fast', '--federated', '--file', '--fix', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--github-repo', '--help', '--http', '--include-null-signature', '--include-warns', '--is-inside-work-tree', '--json', '--keep-storage', '--lang', '--local', '--markdown', '--max-age', '--max-cost-usd', '--message', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--no-cron', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-harden', '--no-verify', '--others', '--params', '--pat-file', '--path', '--pattern', '--pending', '--porcelain', '--push-only', '--quiet', '--rebase', '--repo', '--reset', '--resolve', '--restore-only', '--secret', '--set', '--short', '--show-toplevel', '--source', '--source-id', '--stale', '--status', '--strategy', '--supersessions', '--surface', '--symbol-kind', '--thin', '--unset-all', '--url', '--url-managed', '--yes'],
|
||||
'repos': ['--abbrev-ref', '--abort', '--aliases', '--all', '--all-sources', '--allow-unverified-remote', '--brain', '--branch', '--break-lock', '--cached', '--clone-dir', '--compile', '--confirm-destructive', '--count', '--detect', '--diff-filter', '--dry-run', '--exclude-standard', '--explain', '--fast', '--federated', '--file', '--fix', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--github-repo', '--help', '--http', '--include-null-signature', '--include-warns', '--is-inside-work-tree', '--json', '--keep-storage', '--lang', '--local', '--markdown', '--max-age', '--max-cost-usd', '--message', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--no-cron', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-harden', '--no-verify', '--others', '--params', '--pat-file', '--path', '--pattern', '--pending', '--porcelain', '--push-only', '--quiet', '--rebase', '--repo', '--reset', '--resolve', '--restore-only', '--secret', '--set', '--short', '--show-toplevel', '--source', '--source-id', '--stale', '--status', '--strategy', '--supersessions', '--surface', '--symbol-kind', '--thin', '--token-ttl', '--unset-all', '--url', '--url-managed', '--yes'],
|
||||
'resolvers': ['--auto', '--backend', '--brain', '--cost', '--help', '--json', '--source'],
|
||||
'retrieval-upgrade': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--catch-up', '--dim', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--explain', '--fast', '--follow', '--force', '--from-pages', '--help', '--http', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--markdown', '--model', '--multimodal', '--name', '--no', '--no-embed', '--no-embedding', '--no-extract', '--non-interactive', '--pace', '--pace-max-concurrency', '--parallel', '--pattern', '--pending', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reranking', '--reset', '--resolve', '--resume', '--slugs', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--to', '--undo', '--version', '--yes'],
|
||||
'retrieval-upgrade': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--catch-up', '--dim', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--explain', '--fast', '--follow', '--force', '--from-pages', '--help', '--http', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--markdown', '--model', '--multimodal', '--name', '--no', '--no-embed', '--no-embedding', '--no-extract', '--non-interactive', '--pace', '--pace-max-concurrency', '--parallel', '--pattern', '--pending', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reranking', '--reset', '--resolve', '--resume', '--slugs', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--to', '--token-ttl', '--undo', '--version', '--yes'],
|
||||
'routing-eval': ['--brain', '--fix', '--help', '--json', '--llm', '--skills-dir', '--source', '--strict', '--verbose'],
|
||||
'salience': ['--aliases', '--all', '--brain', '--days', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--kind', '--limit', '--mcp-only', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--slug-prefix', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout'],
|
||||
'schema': ['--alias', '--aliases', '--all', '--apply', '--as-filing-rules', '--brain', '--dims', '--expert', '--expert-routing', '--extractable', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--inverse', '--json', '--kind', '--no-embedding', '--no-extract', '--pack', '--page-type', '--pattern', '--pending', '--prefix', '--primitive', '--reset', '--resolve', '--schema-pack', '--since', '--source', '--source-id', '--stale', '--supersessions', '--surface', '--target-type', '--thin', '--to', '--with-db'],
|
||||
'self-upgrade': ['--all', '--brain', '--check', '--check-only', '--ff-only', '--force', '--help', '--json', '--markdown', '--migrate-only', '--non-interactive', '--refresh-cache', '--source', '--swap-only', '--to', '--version', '--yes'],
|
||||
'salience': ['--aliases', '--all', '--brain', '--days', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--kind', '--limit', '--mcp-only', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--slug-prefix', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl'],
|
||||
'schema': ['--alias', '--aliases', '--all', '--apply', '--as-filing-rules', '--brain', '--dims', '--expert', '--expert-routing', '--extractable', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--inverse', '--json', '--kind', '--no-embedding', '--no-extract', '--pack', '--page-type', '--pattern', '--pending', '--prefix', '--primitive', '--reset', '--resolve', '--schema-pack', '--since', '--source', '--source-id', '--stale', '--supersessions', '--surface', '--target-type', '--thin', '--to', '--token-ttl', '--with-db'],
|
||||
'self-upgrade': ['--all', '--brain', '--check', '--check-only', '--dim', '--ff-only', '--force', '--help', '--json', '--markdown', '--migrate-only', '--non-interactive', '--refresh-cache', '--source', '--swap-only', '--to', '--version', '--yes'],
|
||||
'serve': ['--aliases', '--all', '--bind', '--bound-slug-prefixes', '--brain', '--enable-dcr', '--enable-dcr-insecure', '--fast', '--federated-read', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--log-full-params', '--name', '--no-embedding', '--no-extract', '--once', '--parallel', '--pattern', '--pending', '--port', '--prefix', '--print-admin-token', '--public-url', '--reset', '--resolve', '--source', '--stale', '--stdio-idle-timeout', '--supersessions', '--suppress', '--suppress-bootstrap-token', '--surface', '--thin', '--token-ttl', '--yes'],
|
||||
'skillify': ['--brain', '--description', '--dry-run', '--force', '--help', '--json', '--mutating', '--recent', '--skills-dir', '--source', '--strict', '--triggers', '--verbose', '--writes-pages', '--writes-to'],
|
||||
'skillopt': ['--aliases', '--all', '--allow-mutate-bundled', '--background', '--batch-size', '--benchmark', '--bootstrap-from-routing', '--bootstrap-from-skill', '--bootstrap-reviewed', '--bootstrap-tasks', '--brain', '--brain-wide-max-cost-usd', '--chunker-debug', '--dry-run', '--epochs', '--follow', '--force', '--held-out', '--help', '--include-null-signature', '--json', '--judge-model', '--lr', '--lr-schedule', '--max-cost-usd', '--max-runtime-min', '--model', '--no-extract', '--no-mutate', '--optimizer-model', '--patch', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--rewrite', '--skills-dir', '--source', '--split', '--stale', '--supersessions', '--target-model', '--target-models', '--thin', '--verbose', '--yes'],
|
||||
'skillpack': ['--all', '--apply-clean-hunks', '--author', '--brain', '--dry-run', '--exit-code', '--fast', '--fix', '--force', '--force-unlock', '--format', '--from', '--from-pages', '--frontmatter', '--full', '--help', '--homepage', '--http', '--json', '--license', '--list', '--minimal', '--name-only', '--no-cache', '--no-embedding', '--no-lint', '--note', '--out', '--overwrite-local', '--push', '--quick', '--quiet', '--refresh', '--repo', '--schema-pack', '--short', '--since', '--skills-dir', '--skip-doctor', '--source', '--strict', '--surface', '--target', '--tier', '--trust', '--url', '--verbose', '--verify', '--workspace', '--yes'],
|
||||
'skillpack': ['--all', '--apply-clean-hunks', '--author', '--brain', '--dry-run', '--exit-code', '--fast', '--fix', '--force', '--force-unlock', '--format', '--from', '--from-pages', '--frontmatter', '--full', '--help', '--homepage', '--http', '--json', '--license', '--list', '--minimal', '--name-only', '--no-cache', '--no-embedding', '--no-lint', '--note', '--out', '--overwrite-local', '--push', '--quick', '--quiet', '--refresh', '--repo', '--schema-pack', '--short', '--since', '--skills-dir', '--skip-doctor', '--source', '--strict', '--surface', '--target', '--tier', '--token-ttl', '--trust', '--url', '--verbose', '--verify', '--workspace', '--yes'],
|
||||
'skillpack-check': ['--background', '--brain', '--brain-wide-max-cost-usd', '--explain', '--fast', '--follow', '--help', '--json', '--list', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--source', '--stale', '--strict', '--timeout', '--yes'],
|
||||
'smoke-test': ['--brain', '--help', '--json', '--source'],
|
||||
'sources': ['--abbrev-ref', '--abort', '--aliases', '--all', '--all-sources', '--allow-unverified-remote', '--brain', '--branch', '--break-lock', '--cached', '--clone-dir', '--compile', '--confirm-destructive', '--count', '--detect', '--diff-filter', '--dry-run', '--exclude-standard', '--explain', '--fast', '--federated', '--file', '--fix', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--github-repo', '--help', '--http', '--include-null-signature', '--include-warns', '--is-inside-work-tree', '--json', '--keep-storage', '--lang', '--local', '--markdown', '--max-age', '--max-cost-usd', '--message', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--no-cron', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-harden', '--no-verify', '--others', '--params', '--pat-file', '--path', '--pattern', '--pending', '--porcelain', '--push-only', '--quiet', '--rebase', '--repo', '--reset', '--resolve', '--restore-only', '--secret', '--set', '--short', '--show-toplevel', '--source', '--source-id', '--stale', '--status', '--strategy', '--supersessions', '--surface', '--symbol-kind', '--thin', '--unset-all', '--url', '--url-managed', '--yes'],
|
||||
'status': ['--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--all-sources', '--allow-empty', '--apply', '--asof', '--auto', '--background', '--batch-size', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--budget-usd-per-day', '--by-mention', '--cached', '--catch-up', '--clone-dir', '--code', '--column', '--compile', '--concurrency', '--confirm-destructive', '--content', '--content-audit', '--count', '--date', '--days', '--deadline-ms', '--depth', '--detach', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--entities', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--git-path', '--hard-deadline', '--help', '--http', '--image', '--include-flagged', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--index-audit', '--infer-dates', '--install', '--interval', '--is-ancestor', '--json', '--judge-model', '--kind', '--lang', '--limit', '--lock', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-runtime', '--max-sources', '--max-usd', '--mcp-only', '--migrate-only', '--min-context', '--missing-path', '--mode', '--model', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--ner', '--nice', '--no', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-recurse-submodules', '--no-renames', '--no-schema-pack', '--no-verify', '--object-format', '--offset', '--older-than', '--order', '--orphan', '--others', '--overwrite', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--prefix', '--priority', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--reenrich-after', '--refresh', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--run-id', '--save', '--scope', '--scopes', '--section', '--serial', '--session', '--session-id', '--short', '--show-toplevel', '--since', '--skills-dir', '--skip-failed', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--stats', '--status', '--stdin', '--strategy', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--thin-threshold', '--timeout', '--to', '--top-k', '--trusted-extraction', '--type', '--types', '--url', '--url-managed', '--verbose', '--verify', '--version', '--watch', '--what', '--where', '--who', '--window', '--with-db', '--workers', '--yes'],
|
||||
'sources': ['--abbrev-ref', '--abort', '--aliases', '--all', '--all-sources', '--allow-unverified-remote', '--brain', '--branch', '--break-lock', '--cached', '--clone-dir', '--compile', '--confirm-destructive', '--count', '--detect', '--diff-filter', '--dry-run', '--exclude-standard', '--explain', '--fast', '--federated', '--file', '--fix', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--github-repo', '--help', '--http', '--include-null-signature', '--include-warns', '--is-inside-work-tree', '--json', '--keep-storage', '--lang', '--local', '--markdown', '--max-age', '--max-cost-usd', '--message', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--no-cron', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-harden', '--no-verify', '--others', '--params', '--pat-file', '--path', '--pattern', '--pending', '--porcelain', '--push-only', '--quiet', '--rebase', '--repo', '--reset', '--resolve', '--restore-only', '--secret', '--set', '--short', '--show-toplevel', '--source', '--source-id', '--stale', '--status', '--strategy', '--supersessions', '--surface', '--symbol-kind', '--thin', '--token-ttl', '--unset-all', '--url', '--url-managed', '--yes'],
|
||||
'status': ['--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--all-sources', '--allow-empty', '--apply', '--asof', '--auto', '--background', '--batch-size', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--budget-usd-per-day', '--by-mention', '--cached', '--catch-up', '--clone-dir', '--code', '--column', '--compile', '--concurrency', '--confirm-destructive', '--content', '--content-audit', '--count', '--date', '--days', '--deadline-ms', '--depth', '--detach', '--detail', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--entities', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--git-path', '--hard-deadline', '--help', '--http', '--image', '--include-flagged', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--index-audit', '--infer-dates', '--install', '--interval', '--is-ancestor', '--json', '--judge-model', '--kind', '--lang', '--limit', '--lock', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-runtime', '--max-sources', '--max-usd', '--mcp-only', '--migrate-only', '--min-context', '--missing-path', '--mode', '--model', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--ner', '--nice', '--no', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-recurse-submodules', '--no-renames', '--no-schema-pack', '--no-verify', '--object-format', '--offset', '--older-than', '--order', '--orphan', '--others', '--overwrite', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--prefix', '--priority', '--probe-pglite', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--reenrich-after', '--refresh', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--run-id', '--save', '--scope', '--scopes', '--section', '--serial', '--session', '--session-id', '--short', '--show-toplevel', '--since', '--skills-dir', '--skip-failed', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--stats', '--status', '--stdin', '--strategy', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--thin-threshold', '--timeout', '--to', '--token-ttl', '--top-k', '--trusted-extraction', '--type', '--types', '--url', '--url-managed', '--verbose', '--verify', '--version', '--watch', '--what', '--where', '--who', '--window', '--with-db', '--workers', '--yes'],
|
||||
'storage': ['--aliases', '--all', '--brain', '--federated', '--fix', '--help', '--include-null-signature', '--json', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--to'],
|
||||
'sweep': ['--aliases', '--all', '--batch-limit', '--brain', '--budget-ms', '--help', '--include-null-signature', '--json', '--no-extract', '--once', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout'],
|
||||
'sync': ['--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--all-sources', '--allow-empty', '--asof', '--auto', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--by-mention', '--cached', '--catch-up', '--clone-dir', '--code', '--column', '--compile', '--concurrency', '--confirm-destructive', '--content-audit', '--count', '--depth', '--detach', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--ff-only', '--fix', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--git-path', '--hard-deadline', '--help', '--http', '--include-flagged', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--index-audit', '--infer-dates', '--interval', '--is-ancestor', '--json', '--kind', '--lang', '--lock', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-sources', '--max-usd', '--migrate-only', '--missing-path', '--model', '--multimodal', '--name', '--name-only', '--name-status', '--ner', '--nice', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-recurse-submodules', '--no-renames', '--no-schema-pack', '--no-verify', '--object-format', '--older-than', '--orphan', '--others', '--overwrite', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--prefix', '--priority', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--run-id', '--save', '--scope', '--serial', '--short', '--show-toplevel', '--since', '--skills-dir', '--skip-failed', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--status', '--stdin', '--strategy', '--supabase', '--supersessions', '--surface', '--target', '--target-score', '--thin', '--timeout', '--to', '--top-k', '--type', '--url', '--url-managed', '--verbose', '--verify', '--watch', '--window', '--workers', '--yes'],
|
||||
'takes': ['--aliases', '--all', '--brain', '--bucket-size', '--by', '--claim', '--dir', '--domain', '--dry-run', '--evidence', '--expired', '--fast', '--federated', '--force', '--from-pages', '--help', '--holder', '--http', '--include-covered', '--include-null-signature', '--json', '--kind', '--limit', '--max-pages', '--no-embedding', '--no-extract', '--no-federated', '--outcome', '--path', '--pattern', '--pending', '--quality', '--refresh', '--repo', '--reset', '--resolve', '--restore-only', '--row', '--since', '--slugs', '--sort', '--source', '--source-id', '--stale', '--supersessions', '--surface', '--thin', '--unit', '--until', '--value', '--weight', '--who', '--yes'],
|
||||
'think': ['--aliases', '--all', '--anchor', '--brain', '--calibration-holder', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-usd', '--mcp-only', '--model', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--rounds', '--save', '--since', '--source', '--stale', '--supersessions', '--surface', '--take', '--thin', '--timeout', '--until', '--with-calibration'],
|
||||
'sync': ['--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--all-sources', '--allow-empty', '--asof', '--auto', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--by-mention', '--cached', '--catch-up', '--clone-dir', '--code', '--column', '--compile', '--concurrency', '--confirm-destructive', '--content-audit', '--count', '--depth', '--detach', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--git-path', '--hard-deadline', '--help', '--http', '--include-flagged', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--index-audit', '--infer-dates', '--interval', '--is-ancestor', '--json', '--kind', '--lang', '--lock', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-sources', '--max-usd', '--migrate-only', '--missing-path', '--model', '--multimodal', '--name', '--name-only', '--name-status', '--ner', '--nice', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-recurse-submodules', '--no-renames', '--no-schema-pack', '--no-verify', '--object-format', '--older-than', '--orphan', '--others', '--overwrite', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--prefix', '--priority', '--probe-pglite', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--run-id', '--save', '--scope', '--serial', '--short', '--show-toplevel', '--since', '--skills-dir', '--skip-failed', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--status', '--stdin', '--strategy', '--supabase', '--supersessions', '--surface', '--target', '--target-score', '--thin', '--timeout', '--to', '--token-ttl', '--top-k', '--type', '--url', '--url-managed', '--verbose', '--verify', '--watch', '--window', '--workers', '--yes'],
|
||||
'takes': ['--aliases', '--all', '--brain', '--bucket-size', '--by', '--claim', '--dir', '--domain', '--dry-run', '--evidence', '--expired', '--fast', '--federated', '--force', '--from-pages', '--help', '--holder', '--http', '--include-covered', '--include-null-signature', '--json', '--kind', '--limit', '--max-pages', '--no-embedding', '--no-extract', '--no-federated', '--outcome', '--path', '--pattern', '--pending', '--quality', '--refresh', '--repo', '--reset', '--resolve', '--restore-only', '--row', '--since', '--slugs', '--sort', '--source', '--source-id', '--stale', '--supersessions', '--surface', '--thin', '--token-ttl', '--unit', '--until', '--value', '--weight', '--who', '--yes'],
|
||||
'think': ['--aliases', '--all', '--anchor', '--brain', '--calibration-holder', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-usd', '--mcp-only', '--model', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--rounds', '--save', '--since', '--source', '--stale', '--supersessions', '--surface', '--take', '--thin', '--timeout', '--token-ttl', '--until', '--with-calibration'],
|
||||
'transcripts': ['--aliases', '--all', '--brain', '--days', '--full', '--help', '--include-null-signature', '--json', '--limit', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout'],
|
||||
'upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--quiet', '--repo', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--surface', '--swap-only', '--target', '--to', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'],
|
||||
'watch': ['--aliases', '--all', '--brain', '--fast', '--federated', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-pages', '--min-confidence', '--no-embedding', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--window-turns'],
|
||||
'upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--detail', '--dim', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--flag', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--path', '--pglite', '--quiet', '--repo', '--reset', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--supabase', '--surface', '--swap-only', '--target', '--to', '--token-ttl', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'],
|
||||
'watch': ['--aliases', '--all', '--brain', '--fast', '--federated', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-pages', '--min-confidence', '--no-embedding', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--token-ttl', '--window-turns'],
|
||||
'ze-switch': ['--aliases', '--all', '--brain', '--confirm-reembed', '--dry-run', '--force', '--help', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--markdown', '--no-extract', '--non-interactive', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--source', '--stale', '--supersessions', '--thin', '--undo', '--yes'],
|
||||
};
|
||||
|
||||
@@ -1123,6 +1123,11 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
|
||||
'orphans.exclude_slugs',
|
||||
'sync.cost_gate_min_usd',
|
||||
'sync.federated_v2',
|
||||
// #2179: clamp window for DCR-requested per-client token TTLs. Read by
|
||||
// `gbrain serve --http` at startup; unset min defaults to 300s, unset max
|
||||
// defaults fail-closed to max(--token-ttl, min).
|
||||
'oauth.dcr_ttl_min_seconds',
|
||||
'oauth.dcr_ttl_max_seconds',
|
||||
'embed.backfill_cooldown_min',
|
||||
'embed.backfill_max_usd_per_source_24h',
|
||||
'embed.backfill_max_usd',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* v0.41.16.0 — Built-in conversation parser pattern registry.
|
||||
*
|
||||
* Seventeen hand-vetted patterns covering the chat-export formats this
|
||||
* Eighteen hand-vetted patterns covering the chat-export formats this
|
||||
* codebase is most likely to encounter. Each pattern's regex was
|
||||
* derived from a public format reference (source_doc field) so future
|
||||
* maintainers can verify against the wild shape.
|
||||
@@ -50,7 +50,7 @@ export function cleanSpeaker(raw: string, override?: RegExp): string {
|
||||
return stripped || raw.trim();
|
||||
}
|
||||
|
||||
/** The 17 hand-vetted built-in patterns. */
|
||||
/** The 18 hand-vetted built-in patterns. */
|
||||
export const BUILTIN_PATTERNS: readonly PatternEntry[] = [
|
||||
// -------------------------------------------------------------------
|
||||
// INLINE-DATE patterns (date in every line; less ambiguous; tried first).
|
||||
@@ -670,6 +670,46 @@ export const BUILTIN_PATTERNS: readonly PatternEntry[] = [
|
||||
test_negative: ['<alice> classic irc, no time', '[18:37] @alice: matrix'],
|
||||
source_doc: 'weechat default logger.format `%H:%M %p\\t%m`',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'markdown-heading-turn',
|
||||
origin: 'builtin',
|
||||
// gbrain transcript-ingest shape: a heading-only line ('## User' /
|
||||
// '## Assistant' / '### Human') opens a turn; the message text is
|
||||
// the continuation lines below the heading (D5), not anything on
|
||||
// the heading line itself. No per-line timestamps — date comes
|
||||
// from frontmatter / effective_date. The speaker set is closed
|
||||
// (User/Assistant/Human/System only) so ordinary section headings
|
||||
// like '## Summary' never match, and a heading with trailing prose
|
||||
// ('## User said hello') is rejected rather than mis-captured.
|
||||
regex: /^#{2,3}\s+(User|Assistant|Human|System)\s*:?\s*()$/,
|
||||
captures: {
|
||||
speaker_group: 1,
|
||||
text_group: 2,
|
||||
},
|
||||
date_source: 'frontmatter',
|
||||
time_format: '24h',
|
||||
timezone_policy: 'utc_assumed_with_warn',
|
||||
multi_line: true,
|
||||
score_continuations_as_body: true,
|
||||
// Narrowed to a role-prefix superset (NOT bare `/^#{2,3}\s/`): a body
|
||||
// that pastes unrelated markdown headings (e.g. a document with many
|
||||
// '## Section' headings) would otherwise inflate the D18 scorer's
|
||||
// anchor-candidate denominator without inflating the anchored count,
|
||||
// starving the pattern's score toward 0 on otherwise-valid transcripts.
|
||||
// Still a strict superset of `regex` per validatePatternEntry's
|
||||
// invariant (every test_positive sample passes both).
|
||||
quick_reject: /^#{2,3}\s+(?:User|Assistant|Human|System)\b/,
|
||||
test_positive: ['## User', '## Assistant', '### Human', '## System', '## User:'],
|
||||
test_negative: [
|
||||
'## Summary',
|
||||
'#### User',
|
||||
'User: plain no heading',
|
||||
'## User said hello',
|
||||
],
|
||||
source_doc:
|
||||
'gbrain nightly transcript ingest: compiled_truth bodies use markdown headings per turn',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -392,7 +392,7 @@ function getNonBlankLines(body: string, headCap?: number): string[] {
|
||||
* window) and `scorePatternFull` (whole body) delegate here so the
|
||||
* quick_reject + regex loop lives in one place. Reused by
|
||||
* `parseConversation`'s fallback path which pre-splits ONCE and
|
||||
* passes the array to all 17 candidates (saves 16 redundant body
|
||||
* passes the array to all 18 candidates (saves 17 redundant body
|
||||
* splits per fallback pass).
|
||||
*/
|
||||
function scoreFromLines(
|
||||
|
||||
@@ -2063,6 +2063,13 @@ export async function runCycle(
|
||||
yieldDuringPhase: opts.yieldDuringPhase,
|
||||
once: opts.onceForPhase === 'patterns',
|
||||
deadlineAtMs: opts.deadlineAtMs ?? null,
|
||||
// #1586: scope pattern writes to the cycle's resolved source, same as
|
||||
// synthesize above. Without it the child's put_page rows land in
|
||||
// 'default' while the reverse-write drops the file into the named
|
||||
// source's checkout — the row and the file disagree about which
|
||||
// source owns the page, which is what doctor reports as
|
||||
// multi_source_drift.
|
||||
sourceId: cycleSourceId,
|
||||
}));
|
||||
result.duration_ms = duration_ms;
|
||||
phaseResults.push(result);
|
||||
|
||||
@@ -6,14 +6,16 @@
|
||||
// pages already extracted by content hash — see "Idempotency" below).
|
||||
// 2. Dedup by content_hash; transcripts win on collision.
|
||||
// 3. Per work-item, ask Haiku for 1-3 atoms.
|
||||
// 4. Write each atom via engine.putPage(slug, page, {sourceId})
|
||||
// with sourceId threaded so federated brains route correctly.
|
||||
// 4. Write each atom via importFromContent(slug, markdown, {sourceId})
|
||||
// with sourceId threaded so federated brains route correctly. The
|
||||
// canonical import path (not engine.putPage) is what chunks and embeds
|
||||
// the page — see the write site below and #2163.
|
||||
//
|
||||
// Idempotency (per-atom, via deterministic slug):
|
||||
// Each atom's slug is `atoms/<source-date>/<stem>-<title-hash>` — built from
|
||||
// the SOURCE date (the transcript's own date / the page slug), NOT the run
|
||||
// date, plus a 6-char hash of the title. Re-extracting the same atom resolves
|
||||
// to the SAME slug, so engine.putPage upserts in place instead of minting a
|
||||
// to the SAME slug, so the import upserts in place instead of minting a
|
||||
// duplicate. This closes three bugs in one scheme:
|
||||
// - PR #1414's page-side re-extraction.
|
||||
// - The cross-day transcript duplicate: append-only transcripts grow daily,
|
||||
@@ -51,7 +53,9 @@ import type { BrainEngine } from '../engine.ts';
|
||||
import type { PhaseResult } from '../cycle.ts';
|
||||
import type { GBrainConfig } from '../config.ts';
|
||||
import type { ProgressReporter } from '../progress.ts';
|
||||
import { chat as gatewayChat, withBudgetTracker } from '../ai/gateway.ts';
|
||||
import { chat as gatewayChat, withBudgetTracker, isAvailable } from '../ai/gateway.ts';
|
||||
import { importFromContent } from '../import-file.ts';
|
||||
import { serializeMarkdown } from '../markdown.ts';
|
||||
import { BudgetExhausted, BudgetTracker, isModelPriceable } from '../budget/budget-tracker.ts';
|
||||
import { writeReceipt } from '../extract/receipt-writer.ts';
|
||||
import { upsertExtractRollup } from '../extract/rollup-writer.ts';
|
||||
@@ -681,32 +685,38 @@ export async function runPhaseExtractAtoms(
|
||||
item.kind === 'transcript'
|
||||
? { source_path: item.filePath }
|
||||
: { source_slug: item.slug };
|
||||
// v0.41.2.1 D9 #1 — thread sourceId through every putPage so
|
||||
// atoms land in the source we discovered them from. Pre-fix
|
||||
// the third arg was missing and atoms always wrote to 'default'.
|
||||
await engine.putPage(
|
||||
slug,
|
||||
// Serialize to markdown and import via the canonical pipeline so
|
||||
// the atom is chunked (+ embedded when a provider is configured).
|
||||
// engine.putPage is a bare page-row upsert that never chunks, so
|
||||
// atoms written through it never reached content_chunks and were
|
||||
// invisible to search — the same defect #2163 fixed for concept
|
||||
// pages in synthesize-concepts.ts, which was never applied here.
|
||||
//
|
||||
// `type: 'atom'` rides in frontmatter, which parseMarkdown honours
|
||||
// as an explicit override ahead of path inference, so the page type
|
||||
// survives the round-trip. sourceId stays threaded (v0.41.2.1 D9 #1)
|
||||
// so atoms still land in the source they were discovered from.
|
||||
const md = serializeMarkdown(
|
||||
{
|
||||
title: atom.title,
|
||||
type: 'atom',
|
||||
compiled_truth: atom.body,
|
||||
frontmatter: {
|
||||
type: 'atom',
|
||||
atom_type: atom.atom_type,
|
||||
...originFrontmatter,
|
||||
source_hash: item.contentHash.slice(0, 16),
|
||||
...(atom.source_quote && { source_quote: atom.source_quote }),
|
||||
...(atom.lesson && { lesson: atom.lesson }),
|
||||
...(atom.concepts && atom.concepts.length > 0 && { concepts: atom.concepts }),
|
||||
...(atom.virality_score !== undefined && { virality_score: atom.virality_score }),
|
||||
...(atom.emotional_register && { emotional_register: atom.emotional_register }),
|
||||
extracted_at: new Date().toISOString(),
|
||||
extracted_by: 'extract_atoms-v0.41.2.1',
|
||||
},
|
||||
timeline: '',
|
||||
atom_type: atom.atom_type,
|
||||
...originFrontmatter,
|
||||
source_hash: item.contentHash.slice(0, 16),
|
||||
...(atom.source_quote && { source_quote: atom.source_quote }),
|
||||
...(atom.lesson && { lesson: atom.lesson }),
|
||||
...(atom.concepts && atom.concepts.length > 0 && { concepts: atom.concepts }),
|
||||
...(atom.virality_score !== undefined && { virality_score: atom.virality_score }),
|
||||
...(atom.emotional_register && { emotional_register: atom.emotional_register }),
|
||||
extracted_at: new Date().toISOString(),
|
||||
extracted_by: 'extract_atoms-v0.41.2.1',
|
||||
},
|
||||
{ sourceId },
|
||||
atom.body,
|
||||
'',
|
||||
{ type: 'atom', title: atom.title, tags: [] },
|
||||
);
|
||||
await importFromContent(engine, slug, md, {
|
||||
sourceId,
|
||||
noEmbed: !isAvailable('embedding'),
|
||||
});
|
||||
totalAtomsExtracted++;
|
||||
}
|
||||
} else {
|
||||
|
||||
+58
-36
@@ -25,19 +25,18 @@ import type { BrainEngine } from '../engine.ts';
|
||||
import type { PhaseResult, PhaseError } from '../cycle.ts';
|
||||
import { MinionQueue } from '../minions/queue.ts';
|
||||
import { waitForCompletion, TimeoutError } from '../minions/wait-for-completion.ts';
|
||||
import type { MinionJobInput, SubagentHandlerData } from '../minions/types.ts';
|
||||
import type { MinionJobInput, MinionJobStatus, SubagentHandlerData } from '../minions/types.ts';
|
||||
import { serializeMarkdown } from '../markdown.ts';
|
||||
import type { Page, PageType } from '../types.ts';
|
||||
// #2415: allow-list + output-root resolution shared with the synthesize
|
||||
// phase — both phases must agree on the configured namespace.
|
||||
// runPgliteSubagentsInline is shared too: PGLite has no separate Minions
|
||||
// worker process (the embedded data-dir holds an exclusive file lock), so a
|
||||
// job submitted via queue.add() sits in 'waiting' forever unless something
|
||||
// drives the claim -> run -> complete loop inline. synthesize.ts already
|
||||
// does this for its own children; patterns.ts previously submitted and
|
||||
// waited without ever draining, so every real (non-dry-run) invocation on a
|
||||
// PGLite brain hung until subagentWaitTimeoutMs (default 35 min).
|
||||
import { loadAllowedSlugPrefixes, loadOutputRoot, runPgliteSubagentsInline } from './synthesize.ts';
|
||||
// runSubagentsInline is shared too: a job submitted via queue.add() sits in
|
||||
// 'waiting' forever unless something drives the claim -> run -> complete
|
||||
// loop — on PGLite because no separate worker can open the embedded
|
||||
// data-dir, on Postgres because the parent phase itself occupies a worker
|
||||
// slot and can deadlock a fully-occupied worker (#2050). synthesize.ts
|
||||
// drains its own children the same way.
|
||||
import { loadAllowedSlugPrefixes, loadOutputRoot, runSubagentsInline } from './synthesize.ts';
|
||||
import { probeChatModel } from '../ai/gateway.ts';
|
||||
import { normalizeModelId } from '../model-id.ts';
|
||||
|
||||
@@ -60,6 +59,13 @@ export interface PatternsPhaseOpts {
|
||||
* mid-phase and starves every tail phase (#2781).
|
||||
*/
|
||||
deadlineAtMs?: number | null;
|
||||
/**
|
||||
* #1586: the cycle's resolved source. Stamped onto every subagent child as
|
||||
* `source_id` so put_page writes land in this source's rows, and passed to
|
||||
* reverseWriteRefs so getPage/getTags read the correct (source_id, slug)
|
||||
* row. Unset → legacy 'default'. Mirrors synthesize.ts's `sourceId`.
|
||||
*/
|
||||
sourceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,18 +191,20 @@ export async function runPhasePatterns(
|
||||
}
|
||||
|
||||
const queue = new MinionQueue(engine);
|
||||
// PGLite children drain inline (no separate worker can open the embedded
|
||||
// data-dir), so give this job a private per-run queue: the inline drain
|
||||
// must never claim unrelated 'default'-queue jobs a Postgres worker owns.
|
||||
// Mirrors synthesize.ts's childQueueName derivation exactly.
|
||||
const childQueueName = engine.kind === 'pglite'
|
||||
? `dream-inline-${Date.now()}-${randomUUID().slice(0, 8)}`
|
||||
: 'default';
|
||||
// #2050: children drain inline on BOTH engines (see runSubagentsInline),
|
||||
// so give this job a private per-run queue: the inline drain must never
|
||||
// claim unrelated 'default'-queue jobs, and a 'default'-queue worker must
|
||||
// never claim a child this parent is about to run itself. Mirrors
|
||||
// synthesize.ts's childQueueName derivation exactly.
|
||||
const childQueueName = `dream-inline-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const data: SubagentHandlerData = {
|
||||
prompt: buildPatternsPrompt(reflections, config.minEvidence, config.sourceSlugPrefix, config.outputSlugPrefix),
|
||||
model: config.model,
|
||||
max_turns: 30,
|
||||
allowed_slug_prefixes: allowedSlugPrefixes,
|
||||
// #1586: scope every child tool call to the cycle's resolved source so
|
||||
// put_page writes land there instead of the hardcoded 'default'.
|
||||
...(opts.sourceId ? { source_id: opts.sourceId } : {}),
|
||||
};
|
||||
const submitOpts: Partial<MinionJobInput> = {
|
||||
max_stalled: 3,
|
||||
@@ -207,14 +215,13 @@ export async function runPhasePatterns(
|
||||
allowProtectedSubmit: true,
|
||||
});
|
||||
|
||||
// PGLite cannot run a separate Minions worker because the embedded DB
|
||||
// holds an exclusive file lock. Drain this phase's private child queue
|
||||
// inline so the parent observes the terminal state instead of polling
|
||||
// waitForCompletion until subagentWaitTimeoutMs expires. No-op on
|
||||
// Postgres (a real worker process claims the job there).
|
||||
await runPgliteSubagentsInline(engine, queue, childQueueName, opts.yieldDuringPhase);
|
||||
// Drain this phase's private child queue inline so the parent observes
|
||||
// the terminal state instead of polling waitForCompletion until
|
||||
// subagentWaitTimeoutMs expires. Runs on BOTH engines — on Postgres the
|
||||
// parent job otherwise deadlocks a fully-occupied worker (#2050).
|
||||
await runSubagentsInline(engine, queue, childQueueName, opts.yieldDuringPhase);
|
||||
|
||||
let outcome: string;
|
||||
let outcome: MinionJobStatus | 'timeout';
|
||||
try {
|
||||
const final = await waitForCompletion(queue, job.id, {
|
||||
timeoutMs: budgets.waitTimeoutMs,
|
||||
@@ -243,10 +250,14 @@ export async function runPhasePatterns(
|
||||
// Collect refs the subagent wrote (codex finding #2 — query tool exec rows).
|
||||
// v0.32.8: refs carry source_id so reverseWriteRefs targets the right
|
||||
// (source, slug) row instead of the first DB match.
|
||||
const writtenRefs = await collectChildPutPageSlugs(engine, [job.id]);
|
||||
// #1586: refs carry the cycle's resolved source (children wrote there via
|
||||
// SubagentHandlerData.source_id), so getPage/getTags read the same row the
|
||||
// child wrote, and the reverse-write treats it as the native source.
|
||||
const cycleSourceId = opts.sourceId ?? 'default';
|
||||
const writtenRefs = await collectChildPutPageSlugs(engine, [job.id], cycleSourceId);
|
||||
|
||||
// Reverse-write to fs.
|
||||
const reverseWriteCount = await reverseWriteRefs(engine, opts.brainDir, writtenRefs);
|
||||
const reverseWriteCount = await reverseWriteRefs(engine, opts.brainDir, writtenRefs, cycleSourceId);
|
||||
|
||||
const details = {
|
||||
reflections_considered: reflections.length,
|
||||
@@ -260,7 +271,7 @@ export async function runPhasePatterns(
|
||||
// returned status:ok even when the subagent timed out (e.g. no
|
||||
// subagent-capable worker slot free for the whole wait window) and zero
|
||||
// pattern pages were written — a silent no-op for days.
|
||||
if (outcome !== 'complete') {
|
||||
if (outcome !== 'completed') {
|
||||
if (writtenRefs.length === 0) {
|
||||
return {
|
||||
phase: 'patterns',
|
||||
@@ -454,13 +465,14 @@ When done, briefly list the pattern slugs you wrote/updated in your final messag
|
||||
async function collectChildPutPageSlugs(
|
||||
engine: BrainEngine,
|
||||
childIds: number[],
|
||||
sourceId = 'default',
|
||||
): Promise<Array<{ slug: string; source_id: string }>> {
|
||||
if (childIds.length === 0) return [];
|
||||
// v0.32.8: subagent put_page tool schema doesn't expose source_id (subagents
|
||||
// are scoped to a single source). Default to 'default' here; multi-source
|
||||
// dream cycles are a v0.33 follow-up. The point of threading source_id is
|
||||
// so reverseWriteRefs can pass it through getPage and pick the correct
|
||||
// (source_id, slug) row instead of whatever the DB happens to return.
|
||||
// are scoped to a single source). #1586: stamp the cycle's resolved source —
|
||||
// children write there via SubagentHandlerData.source_id — so reverseWriteRefs
|
||||
// can pass it through getPage and pick the correct (source_id, slug) row
|
||||
// instead of whatever the DB happens to return. Unset → legacy 'default'.
|
||||
const rows = await engine.executeRaw<{ slug: string }>(
|
||||
`SELECT DISTINCT
|
||||
COALESCE(input->>'slug', (input #>> '{}')::jsonb->>'slug') AS slug
|
||||
@@ -474,7 +486,7 @@ async function collectChildPutPageSlugs(
|
||||
return rows
|
||||
.map(r => r.slug)
|
||||
.filter((s): s is string => typeof s === 'string' && s.length > 0)
|
||||
.map(slug => ({ slug, source_id: 'default' }));
|
||||
.map(slug => ({ slug, source_id: sourceId }));
|
||||
}
|
||||
|
||||
// ── Reverse-write ────────────────────────────────────────────────────
|
||||
@@ -485,6 +497,7 @@ async function reverseWriteRefs(
|
||||
engine: BrainEngine,
|
||||
brainDir: string,
|
||||
refs: Array<{ slug: string; source_id: string }>,
|
||||
nativeSourceId = 'default',
|
||||
): Promise<number> {
|
||||
let count = 0;
|
||||
for (const { slug, source_id } of refs) {
|
||||
@@ -496,11 +509,12 @@ async function reverseWriteRefs(
|
||||
const tags = await engine.getTags(slug, { sourceId: source_id });
|
||||
try {
|
||||
const md = renderPageToMarkdown(page, tags);
|
||||
// v0.32.8 F6: non-default sources land under brainDir/.sources/<id>/<slug>.md
|
||||
// so same-slug-different-source pages don't collide on disk. Default-source
|
||||
// pages stay at brainDir/<slug>.md so single-source brains see no change.
|
||||
// `.sources/` is a reserved prefix; walkBrainRepo skips dot-dirs.
|
||||
const filePath = source_id === 'default'
|
||||
// v0.32.8 F6: foreign-source pages land under brainDir/.sources/<id>/<slug>.md
|
||||
// so same-slug-different-source pages don't collide on disk. Pages belonging
|
||||
// to the cycle's own source (#1586: brainDir IS that source's checkout —
|
||||
// legacy 'default' when unscoped) stay at brainDir/<slug>.md so single-source
|
||||
// brains see no change. `.sources/` is a reserved prefix; walkBrainRepo skips dot-dirs.
|
||||
const filePath = source_id === nativeSourceId
|
||||
? join(brainDir, `${slug}.md`)
|
||||
: join(brainDir, '.sources', source_id, `${slug}.md`);
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
@@ -558,3 +572,11 @@ function failed(error: PhaseError): PhaseResult {
|
||||
function makeError(cls: string, code: string, message: string, hint?: string): PhaseError {
|
||||
return hint ? { class: cls, code, message, hint } : { class: cls, code, message };
|
||||
}
|
||||
|
||||
// `__testing` re-exports otherwise-private helpers so unit tests can pin the
|
||||
// source-scoping contract (#1586) without driving a whole dream cycle.
|
||||
// Mirrors synthesize.ts's `__testing` block.
|
||||
export const __testing = {
|
||||
collectChildPutPageSlugs,
|
||||
reverseWriteRefs,
|
||||
};
|
||||
|
||||
+148
-37
@@ -37,6 +37,8 @@ import { basename, join, dirname, isAbsolute, resolve } from 'node:path';
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import type { PhaseResult, PhaseError } from '../cycle.ts';
|
||||
import { MinionQueue } from '../minions/queue.ts';
|
||||
import { reconnectAfterConnectionError } from '../minions/reconnect.ts';
|
||||
import { isRetryableConnError } from '../retry-matcher.ts';
|
||||
import { waitForCompletion, TimeoutError } from '../minions/wait-for-completion.ts';
|
||||
import { makeSubagentHandler } from '../minions/handlers/subagent.ts';
|
||||
import type { MinionJobInput, MinionJobContext, MinionHandler, SubagentHandlerData } from '../minions/types.ts';
|
||||
@@ -276,39 +278,101 @@ export interface SynthesizePhaseOpts {
|
||||
once?: boolean;
|
||||
}
|
||||
|
||||
const INLINE_PGLITE_LOCK_MS = 30_000;
|
||||
const INLINE_LOCK_MS = 30_000;
|
||||
|
||||
/**
|
||||
* PGLite cannot be served by a separate Minions worker process: the embedded
|
||||
* data-dir holds an exclusive file lock, so subagent children enqueued by the
|
||||
* synth parent would sit in 'waiting' until waitForCompletion times out.
|
||||
* Drive the same claim → run → complete/fail loop a worker would perform,
|
||||
* inline, against this phase's private child queue.
|
||||
* Drain this phase's private child queue inline: drive the same claim → run →
|
||||
* complete/fail loop a worker would perform, from the parent's own slot.
|
||||
*
|
||||
* Why inline on BOTH engines:
|
||||
* - PGLite: no separate Minions worker can run at all (the embedded
|
||||
* data-dir holds an exclusive file lock), so children would sit in
|
||||
* 'waiting' until waitForCompletion times out.
|
||||
* - Postgres (#2050): the parent phase itself runs as a job inside a
|
||||
* `jobs work` process. A worker whose slots are all occupied by such
|
||||
* parents (autopilot spawns its drain worker at the default
|
||||
* concurrency=1) can never claim the child the parent is blocking on —
|
||||
* a structural self-deadlock. Running children inline means a child
|
||||
* never needs a worker slot, so the deadlock is impossible at ANY
|
||||
* concurrency, and no extra DB-pool pressure is added: the child's work
|
||||
* replaces the parent's idle waitForCompletion polling in the slot the
|
||||
* parent already holds.
|
||||
*
|
||||
* `yieldDuringPhase` is ticked on a 60s interval while a child runs so the
|
||||
* 5-min cycle lock TTL keeps refreshing during long (up to 30-min) children.
|
||||
* The child's own claim lock is heartbeated at lockMs/3 (worker cadence
|
||||
* parity) — on Postgres a concurrent worker sweeps handleStalled() across
|
||||
* ALL queues, so without renewal any child running longer than lockMs would
|
||||
* be requeued mid-run and stall-churned to dead.
|
||||
*/
|
||||
export async function runPgliteSubagentsInline(
|
||||
export async function runSubagentsInline(
|
||||
engine: BrainEngine,
|
||||
queue: MinionQueue,
|
||||
queueName: string,
|
||||
yieldDuringPhase?: () => Promise<void>,
|
||||
handler: MinionHandler = makeSubagentHandler({ engine }),
|
||||
lockMs: number = INLINE_LOCK_MS,
|
||||
): Promise<void> {
|
||||
if (engine.kind !== 'pglite') return;
|
||||
// #3555 interaction: the drain's queue ops used to be bare awaits, so a
|
||||
// transient pooler reap mid-drain threw out of the loop and stranded the
|
||||
// remaining children in this per-run private queue — which no worker will
|
||||
// ever claim. Mirror the worker's recovery: on a retryable connection
|
||||
// error, rebuild the pool (shared reconnectAfterConnectionError) and retry
|
||||
// the loop; non-retryable errors still propagate (real bug → phase fails).
|
||||
const MAX_CONN_ERROR_STREAK = 5;
|
||||
let connErrorStreak = 0;
|
||||
let sawConnError = false;
|
||||
const recoverOrThrow = async (site: string, e: unknown): Promise<void> => {
|
||||
if (!isRetryableConnError(e) || ++connErrorStreak > MAX_CONN_ERROR_STREAK) throw e;
|
||||
sawConnError = true;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`[dream] inline drain ${site} hit a connection error; reconnecting and retrying: ${msg}\n`);
|
||||
await reconnectAfterConnectionError(engine, `inline-${site}`, e);
|
||||
// Small cooperative backoff (setTimeout keeps the cycle-lock keepalive
|
||||
// and any concurrent timers firing) before the loop retries.
|
||||
await new Promise((r) => setTimeout(r, Math.min(1000, Math.max(50, Math.floor(lockMs / 3)))));
|
||||
};
|
||||
|
||||
while (true) {
|
||||
// Housekeeping a worker would normally perform, so child rows can reach
|
||||
// terminal states (delayed retries promoted, timeouts dead-lettered)
|
||||
// before the synth parent enters waitForCompletion polling.
|
||||
await queue.promoteDelayed();
|
||||
await queue.handleStalled();
|
||||
await queue.handleTimeouts();
|
||||
await queue.handleWallClockTimeouts(INLINE_PGLITE_LOCK_MS);
|
||||
|
||||
const lockToken = randomUUID();
|
||||
const job = await queue.claim(lockToken, INLINE_PGLITE_LOCK_MS, queueName, ['subagent']);
|
||||
if (!job) return;
|
||||
let job: Awaited<ReturnType<MinionQueue['claim']>>;
|
||||
try {
|
||||
// Housekeeping a worker would normally perform, so child rows can reach
|
||||
// terminal states (delayed retries promoted, timeouts dead-lettered)
|
||||
// before the synth parent enters waitForCompletion polling.
|
||||
await queue.promoteDelayed();
|
||||
await queue.handleStalled();
|
||||
await queue.handleTimeouts();
|
||||
await queue.handleWallClockTimeouts(lockMs);
|
||||
|
||||
job = await queue.claim(lockToken, lockMs, queueName, ['subagent']);
|
||||
} catch (e) {
|
||||
await recoverOrThrow('queue-ops', e);
|
||||
continue;
|
||||
}
|
||||
connErrorStreak = 0;
|
||||
if (!job) {
|
||||
if (!sawConnError) return;
|
||||
// A connection-error window may have left a child 'active' under a
|
||||
// lock nobody renews (a claim that committed but whose row never
|
||||
// reached us, or a lost outcome write below). handleStalled() at the
|
||||
// loop top requeues it once the lock expires (≤ lockMs), so only exit
|
||||
// once the queue is actually quiet.
|
||||
let active = 0;
|
||||
try {
|
||||
const rows = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM minion_jobs WHERE queue = $1 AND status = 'active'`,
|
||||
[queueName],
|
||||
);
|
||||
active = rows[0]?.n ?? 0;
|
||||
} catch (e) {
|
||||
await recoverOrThrow('active-check', e);
|
||||
continue;
|
||||
}
|
||||
if (active === 0) return;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
continue;
|
||||
}
|
||||
|
||||
const abort = new AbortController();
|
||||
const shutdown = new AbortController();
|
||||
@@ -364,18 +428,45 @@ export async function runPgliteSubagentsInline(
|
||||
const keepalive = yieldDuringPhase
|
||||
? setInterval(() => { yieldDuringPhase().catch(() => { /* best-effort */ }); }, 60_000)
|
||||
: null;
|
||||
// #2050: heartbeat the child's claim lock while the handler runs so a
|
||||
// concurrent Postgres worker's handleStalled() sweep (all queues, not
|
||||
// just its own) can't requeue a live child. A false return means the row
|
||||
// was cancelled or reclaimed — abort the handler. Errors are swallowed
|
||||
// (best-effort; the next tick retries), never an unhandledRejection.
|
||||
const renewTimer = setInterval(() => {
|
||||
queue.renewLock(job.id, lockToken, lockMs)
|
||||
.then((ok) => {
|
||||
if (!ok && !abort.signal.aborted) abort.abort(new Error('lock-renewal-failed'));
|
||||
})
|
||||
.catch(() => { /* best-effort; next tick retries */ });
|
||||
}, Math.max(50, Math.floor(lockMs / 3)));
|
||||
// Run, then record — separated so a completeJob connection error can't
|
||||
// masquerade as a handler failure, and a failJob connection error can't
|
||||
// escape the drain and strand the remaining children (worker.ts #1720
|
||||
// parity: reconnect + retry the recording once; if it still fails, leave
|
||||
// the row for the loop's own handleStalled to requeue after lock expiry).
|
||||
let result: unknown;
|
||||
let handlerErr: unknown;
|
||||
let handlerRan = false;
|
||||
try {
|
||||
const result = await handler(context);
|
||||
await queue.completeJob(
|
||||
job.id,
|
||||
lockToken,
|
||||
result != null ? (typeof result === 'object' ? result as Record<string, unknown> : { value: result }) : undefined,
|
||||
);
|
||||
result = await handler(context);
|
||||
handlerRan = true;
|
||||
} catch (e) {
|
||||
handlerErr = e;
|
||||
}
|
||||
const record = async (): Promise<void> => {
|
||||
if (handlerRan) {
|
||||
await queue.completeJob(
|
||||
job.id,
|
||||
lockToken,
|
||||
result != null ? (typeof result === 'object' ? result as Record<string, unknown> : { value: result }) : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Timeout is terminal (handleTimeouts parity: stall → retry,
|
||||
// timeout → dead), never a delayed retry.
|
||||
const timedOut = abort.signal.aborted;
|
||||
const errorText = timedOut ? 'timeout exceeded' : (e instanceof Error ? e.message : String(e));
|
||||
const errorText = timedOut ? 'timeout exceeded' : (handlerErr instanceof Error ? handlerErr.message : String(handlerErr));
|
||||
const attemptsExhausted = job.attempts_made + 1 >= job.max_attempts;
|
||||
await queue.failJob(
|
||||
job.id,
|
||||
@@ -384,9 +475,30 @@ export async function runPgliteSubagentsInline(
|
||||
timedOut || attemptsExhausted ? 'dead' : 'delayed',
|
||||
0,
|
||||
);
|
||||
};
|
||||
try {
|
||||
try {
|
||||
await record();
|
||||
} catch (recordErr) {
|
||||
if (!isRetryableConnError(recordErr)) throw recordErr;
|
||||
sawConnError = true;
|
||||
const msg = recordErr instanceof Error ? recordErr.message : String(recordErr);
|
||||
process.stderr.write(`[dream] inline drain: recording job ${job.id} outcome hit a connection error; reconnecting and retrying once: ${msg}\n`);
|
||||
await reconnectAfterConnectionError(engine, 'inline-record', recordErr);
|
||||
try {
|
||||
await record();
|
||||
} catch (retryErr) {
|
||||
// Leave the row to the loop's own handleStalled: the claim lock
|
||||
// stops renewing (finally clears renewTimer), expires within
|
||||
// lockMs, and the next iteration requeues it on a live pool.
|
||||
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||
process.stderr.write(`[dream] inline drain: outcome recording retry for job ${job.id} also failed (${retryMsg}); leaving the row for stall requeue\n`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
if (keepalive) clearInterval(keepalive);
|
||||
clearInterval(renewTimer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -572,12 +684,11 @@ export async function runPhaseSynthesize(
|
||||
}
|
||||
|
||||
const queue = new MinionQueue(engine);
|
||||
// PGLite children drain inline (no separate worker can open the embedded
|
||||
// data-dir), so give them a private per-run queue: the inline drain must
|
||||
// never claim unrelated 'default'-queue jobs a Postgres worker owns.
|
||||
const childQueueName = engine.kind === 'pglite'
|
||||
? `dream-inline-${Date.now()}-${randomUUID().slice(0, 8)}`
|
||||
: 'default';
|
||||
// #2050: children drain inline on BOTH engines (see runSubagentsInline),
|
||||
// so give them a private per-run queue: the inline drain must never claim
|
||||
// unrelated 'default'-queue jobs, and a 'default'-queue worker must never
|
||||
// claim a child this parent is about to run itself.
|
||||
const childQueueName = `dream-inline-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const childIds: number[] = [];
|
||||
/** Map child job_id → chunk metadata for D6 orchestrator-side slug rewrite. */
|
||||
const chunkInfo = new Map<number, { idx: number; hash6: string }>();
|
||||
@@ -685,11 +796,11 @@ export async function runPhaseSynthesize(
|
||||
}
|
||||
}
|
||||
|
||||
// PGLite cannot run a separate Minions worker because the embedded DB
|
||||
// holds an exclusive file lock. Drain this phase's private child queue
|
||||
// inline so the parent observes terminal child states instead of polling
|
||||
// waiters until subagentWaitTimeoutMs expires. No-op on Postgres.
|
||||
await runPgliteSubagentsInline(engine, queue, childQueueName, opts.yieldDuringPhase);
|
||||
// Drain this phase's private child queue inline so the parent observes
|
||||
// terminal child states instead of polling waiters until
|
||||
// subagentWaitTimeoutMs expires. Runs on BOTH engines — on Postgres the
|
||||
// parent job otherwise deadlocks a fully-occupied worker (#2050).
|
||||
await runSubagentsInline(engine, queue, childQueueName, opts.yieldDuringPhase);
|
||||
|
||||
// Wait for every child to reach a terminal state. Tick yieldDuringPhase
|
||||
// every 5 min so the cycle lock TTL refreshes.
|
||||
@@ -1709,6 +1820,6 @@ export const __testing = {
|
||||
buildSynthesisPrompt,
|
||||
stampDreamProvenance,
|
||||
reverseWriteRefs,
|
||||
runPgliteSubagentsInline,
|
||||
runSubagentsInline,
|
||||
loadSynthConfig,
|
||||
};
|
||||
|
||||
@@ -99,6 +99,7 @@ export const BRAIN_CHECK_NAMES: ReadonlySet<string> = new Set([
|
||||
'ocr_health',
|
||||
'orphan_ratio',
|
||||
'oversized_pages',
|
||||
'pglite_scratch_probe',
|
||||
'quarantined_pages',
|
||||
'raw_provenance',
|
||||
'flagged_pages',
|
||||
@@ -171,6 +172,7 @@ export const OPS_CHECK_NAMES: ReadonlySet<string> = new Set([
|
||||
'pgvector',
|
||||
'pool_budget',
|
||||
'progressive_batch_audit_health',
|
||||
'provider_sunset',
|
||||
'queue_health',
|
||||
'reranker_health',
|
||||
'rls',
|
||||
|
||||
+30
-1
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
Page, PageInput, PageFilters, GetPageOpts,
|
||||
Chunk, ChunkInput, StaleChunkRow, StalePageRow,
|
||||
Chunk, ChunkInput, StaleChunkRow, StalePageRow, ChunklessPageRow,
|
||||
SearchResult, SearchOpts,
|
||||
Link, GraphNode, GraphPath, RelationalFanoutRow, RelationalFanoutOpts,
|
||||
TimelineEntry, TimelineInput, TimelineOpts,
|
||||
@@ -1087,6 +1087,35 @@ export interface BrainEngine {
|
||||
// common denominator on the wire).
|
||||
afterUpdatedAt?: string | null;
|
||||
}): Promise<StaleChunkRow[]>;
|
||||
/**
|
||||
* Pre-flight count for the chunkless-page safety net: pages with
|
||||
* non-empty `compiled_truth` AND/OR non-empty `timeline` — both are
|
||||
* chunked independently by the healer — and ZERO `content_chunks` rows.
|
||||
* `embed --stale` only scans `content_chunks` (embedding IS NULL) — a
|
||||
* page written directly via `putPage` that never got chunked has no
|
||||
* chunk row to find, so it stays invisible to that scan forever.
|
||||
* `opts.sourceId` scopes the count to a single source, matching
|
||||
* `countStaleChunks`. Quarantined and `embed_skip` pages are excluded —
|
||||
* both are intentionally chunkless by design, not drift needing repair.
|
||||
* See `ChunklessPageRow` for the full rationale.
|
||||
*/
|
||||
countChunklessPagesWithContent(opts?: { sourceId?: string }): Promise<number>;
|
||||
/**
|
||||
* List pages with non-empty `compiled_truth` and/or `timeline` and zero
|
||||
* `content_chunks` rows (sibling of `countChunklessPagesWithContent`;
|
||||
* same predicate). Keyset-paginated on `id` (mirrors
|
||||
* `listStalePagesForExtraction`) — pass the last row's `id` as
|
||||
* `afterPageId` for the next page. Default `batchSize` 50 — deliberately
|
||||
* small (unlike the 2000-row default on chunk-metadata-only cursors
|
||||
* elsewhere): each row here carries a FULL page body, so a large batch
|
||||
* of large pages is a real memory concern this is a safety-net sweep for
|
||||
* a rare drift case, not the primary bulk-import chunking path.
|
||||
*/
|
||||
listChunklessPagesWithContent(opts?: {
|
||||
batchSize?: number;
|
||||
afterPageId?: number;
|
||||
sourceId?: string;
|
||||
}): Promise<ChunklessPageRow[]>;
|
||||
/**
|
||||
* Delete every chunk for a page. Internal page-id lookup is sourceId-scoped
|
||||
* when `opts.sourceId` is given; otherwise the bare-slug subquery returns
|
||||
|
||||
+14
-4
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* Friction reporter — JSONL-backed signal capture for the claw-test feedback loop.
|
||||
*
|
||||
* The friction CLI (`gbrain friction log/render/list/summary`) writes here.
|
||||
* The claw-test harness reads here. The agent calls `gbrain friction log`
|
||||
* directly when it hits something confusing, missing, or wrong.
|
||||
* The friction CLI (`gbrain friction log/render/list/summary/diff`) writes
|
||||
* and reads here. The claw-test harness reads here. The agent calls
|
||||
* `gbrain friction log` directly when it hits something confusing, missing,
|
||||
* or wrong; `friction diff` compares two runs cross-agent.
|
||||
*
|
||||
* Storage shape: append-only JSONL files under `$GBRAIN_HOME/friction/`.
|
||||
* Storage shape: append-only JSONL files under `$GBRAIN_HOME/.gbrain/friction/`
|
||||
* (configDir() appends the '.gbrain' segment).
|
||||
* - `<run-id>.jsonl` for each harness run (run-id from $GBRAIN_FRICTION_RUN_ID)
|
||||
* - `standalone.jsonl` for entries logged outside a harness run
|
||||
*
|
||||
@@ -61,6 +63,10 @@ export interface FrictionEntry {
|
||||
transcript_offset?: number;
|
||||
/** For phase-marker entries only. */
|
||||
marker?: PhaseMarker;
|
||||
/** Scenario name the run executed (stamped on the run-start marker). */
|
||||
scenario?: string;
|
||||
/** Harness meta-record schema version (stamped on the run-start marker). */
|
||||
harness_schema?: number;
|
||||
}
|
||||
|
||||
export interface FrictionLogInput {
|
||||
@@ -74,6 +80,8 @@ export interface FrictionLogInput {
|
||||
agent?: string;
|
||||
transcriptOffset?: number;
|
||||
marker?: PhaseMarker;
|
||||
scenario?: string;
|
||||
harnessSchema?: number;
|
||||
/** When the writer is called from the harness wrapping a child error. */
|
||||
errorClass?: string;
|
||||
errorCode?: string;
|
||||
@@ -141,6 +149,8 @@ export function logFriction(input: FrictionLogInput): void {
|
||||
if (input.agent) entry.agent = input.agent;
|
||||
if (input.transcriptOffset !== undefined) entry.transcript_offset = input.transcriptOffset;
|
||||
if (input.marker) entry.marker = input.marker;
|
||||
if (input.scenario) entry.scenario = input.scenario;
|
||||
if (input.harnessSchema !== undefined) entry.harness_schema = input.harnessSchema;
|
||||
|
||||
const line = JSON.stringify(entry) + '\n';
|
||||
appendFileSync(frictionFile(runId), line, 'utf-8');
|
||||
|
||||
+76
-16
@@ -10,6 +10,26 @@ import {
|
||||
} from './retry-matcher.ts';
|
||||
import { repairTimelineDedupIndex } from './timeline-dedup-repair.ts';
|
||||
|
||||
/**
|
||||
* When true, per-migration explanatory notices (e.g. the v123/v124 "here is
|
||||
* what this migration changed" lines that specific handlers write to stderr)
|
||||
* are suppressed. Set by runMigrations for a FRESH-install full replay — those
|
||||
* notices are useful diagnostics on an UPGRADE but pure noise as a new user's
|
||||
* first-run output. Module-level (not threaded through the Migration type)
|
||||
* because only a couple of handlers emit them. Guarded via `migrationNotice`.
|
||||
* Known limitation: concurrent runMigrations calls in one process (two engines
|
||||
* migrating simultaneously) share this flag — worst case is a suppressed or
|
||||
* extra stderr NOTICE line; migration execution/stamping is unaffected.
|
||||
*/
|
||||
let quietMigrationNotices = false;
|
||||
|
||||
/** Write a per-migration explanatory notice unless fresh-install quiet mode is
|
||||
* on. Handlers should route their "what changed" lines through this. */
|
||||
function migrationNotice(line: string): void {
|
||||
if (quietMigrationNotices) return;
|
||||
process.stderr.write(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema migrations — run automatically on initSchema().
|
||||
*
|
||||
@@ -5511,7 +5531,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
// stderr, NOT stdout: migrations run lazily inside any command's
|
||||
// first DB connect — a console.log here polluted `doctor --json`
|
||||
// stdout and broke jq consumers (heavy-tests fm_wallclock).
|
||||
process.stderr.write(` v123: trigger functions recreated with language='english' (default — no backfill needed)\n`);
|
||||
migrationNotice(` v123: trigger functions recreated with language='english' (default — no backfill needed)\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5532,7 +5552,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
WHERE search_vector IS NOT NULL;
|
||||
`);
|
||||
|
||||
process.stderr.write(` v123: trigger functions recreated with language='${lang}' + backfilled existing rows\n`);
|
||||
migrationNotice(` v123: trigger functions recreated with language='${lang}' + backfilled existing rows\n`);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -5600,7 +5620,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
END;
|
||||
$fn$ LANGUAGE plpgsql;
|
||||
`);
|
||||
process.stderr.write(` v124: update_page_search_vector() no longer indexes compiled_truth (was overflowing tsvector on large pages, #2704)
|
||||
migrationNotice(` v124: update_page_search_vector() no longer indexes compiled_truth (was overflowing tsvector on large pages, #2704)
|
||||
`);
|
||||
},
|
||||
},
|
||||
@@ -6012,17 +6032,64 @@ export async function runMigrations(engine: BrainEngine): Promise<{ applied: num
|
||||
return { applied: 0, current };
|
||||
}
|
||||
|
||||
// Fresh install vs upgrade: a never-migrated brain (schema blob seeds
|
||||
// version='1'; every migration is >= 2) replays the FULL history — printing
|
||||
// ~240 lines of internal migration names as the user's first-run experience.
|
||||
// That wall makes a 2-second init read as complex and fragile ("1 → 125"
|
||||
// implies the brand-new install was 124 versions stale). Fresh installs get
|
||||
// one summary line; EXISTING brains keep the full per-migration detail
|
||||
// (upgrades are where the names carry diagnostic value).
|
||||
// GBRAIN_MIGRATE_VERBOSE=1 is the incident escape hatch (env-first, matching
|
||||
// the GBRAIN_SYNC_*/GBRAIN_PACE_* pattern).
|
||||
const freshInstall = current <= 1 && pending.length === sorted.length;
|
||||
const quietReplay = freshInstall && process.env.GBRAIN_MIGRATE_VERBOSE !== '1';
|
||||
// Suppress per-migration explanatory notices during a fresh-install replay
|
||||
// (they are upgrade diagnostics, noise on a new user's first run). Restored
|
||||
// in the finally so an in-process upgrade after a fresh init still narrates.
|
||||
quietMigrationNotices = quietReplay;
|
||||
|
||||
// Progress messages route to stderr so callers parsing stdout (e.g.
|
||||
// `gbrain jobs submit --json | jq`) aren't polluted by migration noise.
|
||||
process.stderr.write(` Schema version ${current} → ${LATEST_VERSION} (${pending.length} migration(s) pending)\n`);
|
||||
|
||||
// Pre-flight: warn about connections that might block DDL
|
||||
await checkForBlockingConnections(engine);
|
||||
if (quietReplay) {
|
||||
process.stderr.write(` Setting up brain schema (v${LATEST_VERSION})...\n`);
|
||||
} else {
|
||||
process.stderr.write(` Schema version ${current} → ${LATEST_VERSION} (${pending.length} migration(s) pending)\n`);
|
||||
}
|
||||
|
||||
let applied = 0;
|
||||
for (const m of pending) {
|
||||
process.stderr.write(` [${m.version}] ${m.name}...\n`);
|
||||
try {
|
||||
// Pre-flight: warn about connections that might block DDL
|
||||
await checkForBlockingConnections(engine);
|
||||
|
||||
for (const m of pending) {
|
||||
if (!quietReplay) process.stderr.write(` [${m.version}] ${m.name}...\n`);
|
||||
try {
|
||||
await applyOneMigration(engine, m);
|
||||
// Update version after both SQL and handler succeed. Inside the same
|
||||
// catch so a stamp-write failure is also NAMED in quiet mode.
|
||||
await engine.setConfig('version', String(m.version));
|
||||
} catch (err) {
|
||||
// Quiet fresh-install replay: name the failing migration — without the
|
||||
// per-step lines, the error would otherwise be anonymous.
|
||||
if (quietReplay) process.stderr.write(` [${m.version}] ${m.name} failed\n`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!quietReplay) process.stderr.write(` [${m.version}] ✓ ${m.name}\n`);
|
||||
applied++;
|
||||
}
|
||||
} finally {
|
||||
// Never leak the fresh-install quiet flag into a later in-process run —
|
||||
// covers every exit path from here on (incl. the pre-flight probe).
|
||||
quietMigrationNotices = false;
|
||||
}
|
||||
|
||||
return { applied, current: LATEST_VERSION };
|
||||
}
|
||||
|
||||
/** One migration's full body (SQL + handler + verify), extracted so the
|
||||
* runMigrations loop can name the failing migration in quiet-replay mode. */
|
||||
async function applyOneMigration(engine: BrainEngine, m: Migration): Promise<void> {
|
||||
// Pick SQL: engine-specific `sqlFor` wins over engine-agnostic `sql`.
|
||||
const sql = m.sqlFor?.[engine.kind] ?? m.sql;
|
||||
|
||||
@@ -6093,11 +6160,4 @@ export async function runMigrations(engine: BrainEngine): Promise<{ applied: num
|
||||
}
|
||||
}
|
||||
|
||||
// Update version after both SQL and handler succeed
|
||||
await engine.setConfig('version', String(m.version));
|
||||
process.stderr.write(` [${m.version}] ✓ ${m.name}\n`);
|
||||
applied++;
|
||||
}
|
||||
|
||||
return { applied, current: LATEST_VERSION };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Shared "rebuild the DB pool after a retryable connection failure" helper.
|
||||
*
|
||||
* PostgresEngine exposes reconnect(); PGLite and test doubles may not. Absence
|
||||
* is a no-op so non-Postgres callers preserve their legacy behavior.
|
||||
*
|
||||
* Extracted from MinionWorker's private method (#1491/#3555) so the inline
|
||||
* child drain (#2050, cycle/synthesize.ts runSubagentsInline) recovers from
|
||||
* the same transient pooler reaps instead of throwing out of the drain and
|
||||
* stranding children in a per-run queue no worker will ever claim.
|
||||
*/
|
||||
export async function reconnectAfterConnectionError(
|
||||
engine: unknown,
|
||||
site: string,
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
const reconnect = (engine as { reconnect?: (ctx?: { error?: unknown }) => Promise<void> }).reconnect;
|
||||
if (!reconnect) return;
|
||||
try {
|
||||
await reconnect.call(engine, { error });
|
||||
} catch (re) {
|
||||
console.error(`[minions] reconnect after ${site} error failed: ${re instanceof Error ? re.message : String(re)}`);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
unlinkSync,
|
||||
writeSync,
|
||||
} from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { dirname, resolve } from 'path';
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import { tryAcquireDbLock, type DbLockHandle } from '../db-lock.ts';
|
||||
import { currentBrainId } from './worker-registry.ts';
|
||||
@@ -574,7 +574,13 @@ export class MinionSupervisor {
|
||||
// 5. Announce start.
|
||||
this.emit('started', {
|
||||
supervisor_pid: process.pid,
|
||||
pid_file: this.opts.pidFile,
|
||||
// Resolved to absolute at emit time (relative to THIS process's cwd,
|
||||
// the only context in which a relative --pid-file was meaningful) so a
|
||||
// later reader (e.g. `gbrain doctor`, possibly running from a
|
||||
// different cwd) doesn't misresolve it. `this.opts.pidFile` itself
|
||||
// stays as-given for this process's own reads/writes below, which are
|
||||
// already correctly relative to this same cwd.
|
||||
pid_file: resolve(this.opts.pidFile),
|
||||
concurrency: this.opts.concurrency,
|
||||
queue: this.opts.queue,
|
||||
max_crashes: this.opts.maxCrashes,
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from './lock-renewal-tick.ts';
|
||||
import { lockRenewalAudit } from '../audit/lock-renewal-audit.ts';
|
||||
import { isRetryableConnError } from '../retry-matcher.ts';
|
||||
import { reconnectAfterConnectionError as reconnectEngineAfterConnError } from './reconnect.ts';
|
||||
|
||||
/**
|
||||
* Abort reasons that signal infrastructure failure (PgBouncer outage,
|
||||
@@ -735,18 +736,10 @@ export class MinionWorker extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Rebuild the worker-owned DB pool after a retryable connection failure.
|
||||
*
|
||||
* PostgresEngine exposes reconnect(); PGLite and test doubles may not. Absence
|
||||
* is a no-op so non-Postgres workers preserve their legacy behavior.
|
||||
* Shared with the inline child drain (#2050) via minions/reconnect.ts.
|
||||
*/
|
||||
private async reconnectAfterConnectionError(site: string, error: unknown): Promise<void> {
|
||||
const reconnect = (this.engine as { reconnect?: (ctx?: { error?: unknown }) => Promise<void> }).reconnect;
|
||||
if (!reconnect) return;
|
||||
try {
|
||||
await reconnect.call(this.engine, { error });
|
||||
} catch (re) {
|
||||
console.error(`[worker] reconnect after ${site} error failed: ${re instanceof Error ? re.message : String(re)}`);
|
||||
}
|
||||
await reconnectEngineAfterConnError(this.engine, site, error);
|
||||
}
|
||||
|
||||
/** RSS watchdog. Called from the per-job finally and the periodic timer.
|
||||
|
||||
+123
-17
@@ -13,6 +13,7 @@
|
||||
* - Legacy access_tokens fallback for backward compat
|
||||
*/
|
||||
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import type { Response } from 'express';
|
||||
import type {
|
||||
OAuthClientInformationFull,
|
||||
@@ -235,14 +236,75 @@ interface GBrainOAuthProviderOptions {
|
||||
* (operator-trusted, registers grants directly).
|
||||
*/
|
||||
allowClientCredentialsDcr?: boolean;
|
||||
/**
|
||||
* #2179: lower bound (seconds) for DCR-requested per-client token TTLs.
|
||||
* Requests below it clamp up. Default DEFAULT_DCR_TTL_MIN_SECONDS (300).
|
||||
*/
|
||||
dcrTtlMinSeconds?: number;
|
||||
/**
|
||||
* #2179: upper bound (seconds) for DCR-requested per-client token TTLs.
|
||||
* Requests above it clamp down. Unset defaults FAIL-CLOSED to
|
||||
* max(tokenTtl, dcrTtlMinSeconds): an anonymous DCR registrant can never
|
||||
* elect a longer-lived token than the operator's own --token-ttl unless
|
||||
* the admin explicitly widened the window.
|
||||
*/
|
||||
dcrTtlMaxSeconds?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DCR token TTL (#2179)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Default lower clamp bound for DCR-requested token TTLs (#2179). Admins
|
||||
* override via the `oauth.dcr_ttl_min_seconds` / `oauth.dcr_ttl_max_seconds`
|
||||
* config keys, read once by `gbrain serve --http` at startup. There is
|
||||
* deliberately NO fixed default max: an unset max derives fail-closed from
|
||||
* the operator's --token-ttl (`max(tokenTtl, min)`), so a self-registering
|
||||
* client can never out-live the server default without explicit admin opt-in.
|
||||
*/
|
||||
export const DEFAULT_DCR_TTL_MIN_SECONDS = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* Clamp a DCR-requested token TTL into the admin-configured [min, max]
|
||||
* window. Bounds are REQUIRED — callers resolve them (fail-closed) first.
|
||||
* Never rejects (#2179): out-of-range values clamp to the nearest bound.
|
||||
* Non-integer requests floor; an inverted window collapses to the min bound.
|
||||
*/
|
||||
export function clampDcrTokenTtl(
|
||||
requested: number,
|
||||
min: number,
|
||||
max: number,
|
||||
): number {
|
||||
const lo = Math.max(1, Math.floor(min));
|
||||
const hi = Math.max(lo, Math.floor(max));
|
||||
return Math.min(hi, Math.max(lo, Math.floor(requested)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Request-scoped carrier for the `token_ttl_seconds` DCR extension field
|
||||
* (#2179). The MCP SDK's /register handler validates the request body against
|
||||
* a strict schema and STRIPS unknown members before they reach
|
||||
* `clientsStore.registerClient`, so serve-http's /register middleware parses
|
||||
* the raw body and runs the SDK chain inside this AsyncLocalStorage context;
|
||||
* the store reads it back out at registration time. No context (CLI, admin
|
||||
* API, programmatic registration) means "no TTL request" — default behavior.
|
||||
*/
|
||||
export const dcrRegistrationContext = new AsyncLocalStorage<{ tokenTtlSeconds?: number }>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clients Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class GBrainClientsStore implements OAuthRegisteredClientsStore {
|
||||
constructor(private sql: SqlQuery, private allowClientCredentialsDcr = false) {}
|
||||
// #2179: DCR TTL bounds are required — the provider resolves fail-closed
|
||||
// defaults (max bounded by tokenTtl); no permissive fallback lives here.
|
||||
constructor(
|
||||
private sql: SqlQuery,
|
||||
private allowClientCredentialsDcr: boolean,
|
||||
private dcrTtlMin: number,
|
||||
private dcrTtlMax: number,
|
||||
) {}
|
||||
|
||||
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||
const rows = await this.sql`
|
||||
@@ -392,6 +454,27 @@ class GBrainClientsStore implements OAuthRegisteredClientsStore {
|
||||
}
|
||||
}
|
||||
|
||||
// #2179: optional `token_ttl_seconds` hint from the DCR request body,
|
||||
// carried via dcrRegistrationContext (the SDK strips unknown body
|
||||
// members). Fail-safe posture: absent or malformed → server default TTL;
|
||||
// out-of-range → clamped into [dcrTtlMin, dcrTtlMax]; never rejected.
|
||||
// Persist into oauth_clients.token_ttl (the same per-client override the
|
||||
// admin API writes) and echo the EFFECTIVE value in the registration
|
||||
// response so the caller can show the user what it actually got.
|
||||
let effectiveTtl: number | undefined;
|
||||
const requestedTtl = dcrRegistrationContext.getStore()?.tokenTtlSeconds;
|
||||
if (typeof requestedTtl === 'number' && Number.isFinite(requestedTtl)) {
|
||||
const clamped = clampDcrTokenTtl(requestedTtl, this.dcrTtlMin, this.dcrTtlMax);
|
||||
try {
|
||||
await this.sql`UPDATE oauth_clients SET token_ttl = ${clamped} WHERE client_id = ${clientId}`;
|
||||
effectiveTtl = clamped;
|
||||
} catch (e) {
|
||||
// Pre-migration schema without the token_ttl column: keep the
|
||||
// registration, but do NOT echo a TTL that wasn't persisted.
|
||||
if (!isUndefinedColumnError(e, 'token_ttl')) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Public clients: omit `client_secret` entirely from the response so
|
||||
// the wire payload matches RFC 7591 §3.2.1 ("if the client is a
|
||||
// public client, the authorization server MUST NOT issue a client
|
||||
@@ -403,6 +486,9 @@ class GBrainClientsStore implements OAuthRegisteredClientsStore {
|
||||
client_id_issued_at: now,
|
||||
};
|
||||
if (clientSecret) response.client_secret = clientSecret;
|
||||
if (effectiveTtl !== undefined) {
|
||||
(response as Record<string, unknown>).token_ttl_seconds = effectiveTtl;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -420,10 +506,21 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
|
||||
constructor(options: GBrainOAuthProviderOptions) {
|
||||
this.sql = options.sql;
|
||||
this._clientsStore = new GBrainClientsStore(this.sql, options.allowClientCredentialsDcr === true);
|
||||
this.dcrDisabled = options.dcrDisabled === true;
|
||||
this.tokenTtl = options.tokenTtl || 3600;
|
||||
this.refreshTtl = options.refreshTtl || 30 * 24 * 3600;
|
||||
// #2179 fail-closed: an unset DCR max is bounded by the operator's own
|
||||
// token TTL — never a fixed permissive ceiling — so a self-registering
|
||||
// client cannot elect a longer-lived token than the server default
|
||||
// unless the admin explicitly configured a wider window.
|
||||
const dcrTtlMin = options.dcrTtlMinSeconds ?? DEFAULT_DCR_TTL_MIN_SECONDS;
|
||||
const dcrTtlMax = options.dcrTtlMaxSeconds ?? Math.max(this.tokenTtl, dcrTtlMin);
|
||||
this._clientsStore = new GBrainClientsStore(
|
||||
this.sql,
|
||||
options.allowClientCredentialsDcr === true,
|
||||
dcrTtlMin,
|
||||
dcrTtlMax,
|
||||
);
|
||||
}
|
||||
|
||||
get clientsStore(): OAuthRegisteredClientsStore {
|
||||
@@ -931,20 +1028,10 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
const requestedScopes = requestedScope ? parseScopeString(requestedScope) : allowedScopes;
|
||||
const grantedScopes = requestedScopes.filter(s => hasScope(allowedScopes, s));
|
||||
|
||||
// Per-client TTL override (stored in oauth_clients.token_ttl)
|
||||
// Column may not exist on PGLite/older schemas — graceful fallback
|
||||
let clientTtl: number | undefined;
|
||||
try {
|
||||
const ttlRows = await this.sql`SELECT token_ttl FROM oauth_clients WHERE client_id = ${clientId}`;
|
||||
if (ttlRows.length > 0 && ttlRows[0].token_ttl) clientTtl = Number(ttlRows[0].token_ttl);
|
||||
} catch (e) {
|
||||
// F5 hardening: same posture as the deleted_at probe above. Only the
|
||||
// "column doesn't exist" path is a non-fatal fall-through.
|
||||
if (!isUndefinedColumnError(e, 'token_ttl')) throw e;
|
||||
}
|
||||
|
||||
// Client credentials: access token only, NO refresh token (RFC 6749 4.4.3)
|
||||
return this.issueTokens(clientId, grantedScopes, undefined, false, clientTtl);
|
||||
// Per-client TTL (oauth_clients.token_ttl) is applied inside issueTokens
|
||||
// so all three grant paths honor it (#2179).
|
||||
return this.issueTokens(clientId, grantedScopes, undefined, false);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -1204,17 +1291,36 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
// Internal: Issue access + optional refresh tokens
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-client TTL override lookup (oauth_clients.token_ttl). Set by the
|
||||
* admin API, the CLI, or a DCR `token_ttl_seconds` request (#2179).
|
||||
* Column may not exist on older schemas — graceful fallback to undefined.
|
||||
*/
|
||||
private async lookupClientTokenTtl(clientId: string): Promise<number | undefined> {
|
||||
try {
|
||||
const ttlRows = await this.sql`SELECT token_ttl FROM oauth_clients WHERE client_id = ${clientId}`;
|
||||
if (ttlRows.length > 0 && ttlRows[0].token_ttl) return Number(ttlRows[0].token_ttl);
|
||||
} catch (e) {
|
||||
// F5 hardening posture: only the "column doesn't exist" path is a
|
||||
// non-fatal fall-through.
|
||||
if (!isUndefinedColumnError(e, 'token_ttl')) throw e;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async issueTokens(
|
||||
clientId: string,
|
||||
scopes: string[],
|
||||
resource: URL | undefined,
|
||||
includeRefresh: boolean,
|
||||
ttlOverride?: number,
|
||||
): Promise<OAuthTokens> {
|
||||
const accessToken = generateToken('gbrain_at_');
|
||||
const accessHash = hashToken(accessToken);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const effectiveTtl = ttlOverride || this.tokenTtl;
|
||||
// #2179: the per-client override lives here (not in individual grant
|
||||
// handlers) so client_credentials, authorization_code AND refresh
|
||||
// issuance all honor oauth_clients.token_ttl consistently.
|
||||
const effectiveTtl = (await this.lookupClientTokenTtl(clientId)) || this.tokenTtl;
|
||||
const accessExpiry = now + effectiveTtl;
|
||||
|
||||
await this.sql`
|
||||
|
||||
@@ -43,6 +43,9 @@ export async function runInitNudge(engine: BrainEngine): Promise<void> {
|
||||
let linkedCount = 0;
|
||||
let timelineCount = 0;
|
||||
let takesCount = 0;
|
||||
// -1 = the page-count probe failed: fail-open sentinel, treat as non-empty
|
||||
// so current behavior is preserved when the count is unknown.
|
||||
let totalPages = -1;
|
||||
let checksRan = 0;
|
||||
let checksAttempted = 0;
|
||||
let partial = false;
|
||||
@@ -82,6 +85,11 @@ export async function runInitNudge(engine: BrainEngine): Promise<void> {
|
||||
[],
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
engine.executeRaw<{ count: string | number }>(
|
||||
`SELECT COUNT(*) AS count FROM pages WHERE deleted_at IS NULL`,
|
||||
[],
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
|
||||
@@ -99,8 +107,16 @@ export async function runInitNudge(engine: BrainEngine): Promise<void> {
|
||||
else if (i === 2) linkedCount = n;
|
||||
else if (i === 3) timelineCount = n;
|
||||
else if (i === 4) takesCount = n;
|
||||
else if (i === 5) totalPages = n;
|
||||
}
|
||||
|
||||
// A brand-new EMPTY brain has no "opportunities" — telling a fresh user
|
||||
// "0 takes" at the end of their first init is jargon-noise on the
|
||||
// activation surface. Suppress the ENTIRE nudge on empty (including the
|
||||
// partial-checks notice below).
|
||||
const brainEmpty = totalPages === 0;
|
||||
if (brainEmpty) return;
|
||||
|
||||
// Aggregate: any non-zero metric triggers the nudge.
|
||||
const linkCoverage = totalEntities > 0 ? linkedCount / totalEntities : 1;
|
||||
const timelineCoverage = totalEntities > 0 ? timelineCount / totalEntities : 1;
|
||||
@@ -109,7 +125,6 @@ export async function runInitNudge(engine: BrainEngine): Promise<void> {
|
||||
|| (totalEntities > 0 && linkCoverage < 0.7)
|
||||
|| (totalEntities > 0 && timelineCoverage < 0.9)
|
||||
|| takesCount === 0;
|
||||
|
||||
if (!hasRecommendations && !partial) return;
|
||||
|
||||
// Emit one-line nudge. Be terse — init is the activation surface.
|
||||
@@ -123,6 +138,13 @@ export async function runInitNudge(engine: BrainEngine): Promise<void> {
|
||||
}
|
||||
if (takesCount === 0) parts.push('0 takes');
|
||||
|
||||
if (parts.length === 0 && partial) {
|
||||
process.stderr.write(
|
||||
`\n[onboard] Init checks incomplete (${checksRan}/${checksAttempted}) — run 'gbrain onboard --check' for full recommendations.\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stderr.write(
|
||||
`\n[onboard] Brain has opportunities: ${parts.join(', ')}.\n` +
|
||||
`[onboard] Run 'gbrain onboard --check' to see the plan.` +
|
||||
|
||||
+10
-5
@@ -10,7 +10,7 @@ import { clampSearchLimit } from './engine.ts';
|
||||
import type { GBrainConfig } from './config.ts';
|
||||
import type { PageType } from './types.ts';
|
||||
import { importFromContent } from './import-file.ts';
|
||||
import { writePageThrough } from './write-through.ts';
|
||||
import { writePageThrough, type WriteThroughResult } from './write-through.ts';
|
||||
import { hybridSearch, hybridSearchCached, stampContentFlags, stampUnverifiedExtractions } from './search/hybrid.ts';
|
||||
import { expandQuery } from './search/expansion.ts';
|
||||
import { dedupResults } from './search/dedup.ts';
|
||||
@@ -1323,7 +1323,10 @@ const put_page: Operation = {
|
||||
// Trust gating:
|
||||
// - Subagent sandbox (viaSubagent without allowedSlugPrefixes) → DB-only.
|
||||
// - All other writes → write-through.
|
||||
let writeThrough: { written: boolean; path?: string; skipped?: string; error?: string } | undefined;
|
||||
// put_page's own trust-gating produces two skip reasons ('subagent_sandbox',
|
||||
// 'dry_run') that never come out of writePageThrough itself — widen the
|
||||
// field rather than losing the commit/pushed/lastPushStatus typing.
|
||||
let writeThrough: (Omit<WriteThroughResult, 'skipped'> & { skipped?: WriteThroughResult['skipped'] | 'subagent_sandbox' | 'dry_run' }) | undefined;
|
||||
const isSandboxSubagent = ctx.viaSubagent === true
|
||||
&& !(Array.isArray(ctx.allowedSlugPrefixes) && ctx.allowedSlugPrefixes.length > 0);
|
||||
if (!ctx.dryRun && result.status !== 'error' && !isSandboxSubagent) {
|
||||
@@ -2773,10 +2776,12 @@ const get_brain_identity: Operation = {
|
||||
let latest_version: string | null = null;
|
||||
try {
|
||||
const su = await import('./self-upgrade.ts');
|
||||
const entry = su.readUpdateCache();
|
||||
if (entry && su.isCacheFresh(entry, Date.now()) && entry.marker.kind === 'upgrade_available') {
|
||||
// Shared stale/foreign-cache guard (pendingUpgradeVersion): only an
|
||||
// upgrade strictly newer than the RUNNING version counts.
|
||||
const latest = su.pendingUpgradeVersion(VERSION, Date.now());
|
||||
if (latest) {
|
||||
update_available = true;
|
||||
latest_version = entry.marker.latest ?? null;
|
||||
latest_version = latest;
|
||||
}
|
||||
} catch {
|
||||
/* never let the banner break the op */
|
||||
|
||||
+164
-1
@@ -1,5 +1,10 @@
|
||||
import { PGlite } from '@electric-sql/pglite';
|
||||
import type { Transaction } from '@electric-sql/pglite';
|
||||
// Engine-live path: static top-level imports (scratch probe, #2674) — the
|
||||
// engine-dynamic-import guard forbids lazy `import()` here.
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join as joinPath, resolve as resolvePath, sep as pathSep } from 'node:path';
|
||||
// Engine-live path: static top-level import (no lazy `import()`). Supplies
|
||||
// PGLite's WASM/fsBundle/extension assets embedded via `with { type: 'file' }`
|
||||
// so a `bun build --compile` binary can serve a PGLite brain (Bun vfs #1340).
|
||||
@@ -55,7 +60,7 @@ import { attemptWalRepairAndRetry, closeRepairEpisodeIfOpen, type WalRepairRecei
|
||||
import { getFtsLanguage } from './fts-language.ts';
|
||||
import type {
|
||||
Page, PageInput, PageFilters, PageType,
|
||||
Chunk, ChunkInput, StaleChunkRow, StalePageRow,
|
||||
Chunk, ChunkInput, StaleChunkRow, StalePageRow, ChunklessPageRow,
|
||||
SearchResult, SearchOpts,
|
||||
Link, GraphNode, GraphPath,
|
||||
TimelineEntry, TimelineInput, TimelineOpts,
|
||||
@@ -87,6 +92,8 @@ import { buildSourceFactorCase, buildHardExcludeClause, buildVisibilityClause, b
|
||||
import { unverifiedExtractionFragment } from './extraction-review.ts';
|
||||
import { shouldExcludeFromOrphanReporting, loadOrphanPolicyOverrides } from './orphan-policy.ts';
|
||||
import { LINK_EXTRACTOR_VERSION_TS } from './link-extraction.ts';
|
||||
import { EMBED_SKIP_FILTER_FRAGMENT } from './embed-skip.ts';
|
||||
import { QUARANTINE_FILTER_FRAGMENT } from './quarantine.ts';
|
||||
import {
|
||||
normalizeEngineColumn,
|
||||
buildVectorCastFragment,
|
||||
@@ -420,6 +427,95 @@ async function preservingProcessExitCode<T>(fn: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #2674 — the scratch-store probe, the diagnostic half of the issue.
|
||||
*
|
||||
* PGLite reports only `Aborted()` to JS and prints the real PANIC (e.g.
|
||||
* `could not locate a valid checkpoint record`) to its own stderr, so from
|
||||
* the JS-visible error alone a damaged store is indistinguishable from a
|
||||
* broken WASM runtime. The one thing that CAN tell them apart is opening a
|
||||
* throwaway store on the same machine:
|
||||
*
|
||||
* - scratch store works → the runtime is healthy; the REAL store is damaged.
|
||||
* - scratch store fails too → the runtime cannot start here at all.
|
||||
*
|
||||
* Stderr capture: PGLite 0.4.3 exposes no print/printErr hook on
|
||||
* `PGliteOptions` (checked: only `debug`, which still writes to the
|
||||
* process's own stderr), so we deliberately do NOT try to intercept the
|
||||
* PANIC text — monkey-patching process.stderr.write around an async WASM
|
||||
* init is exactly the hack the classifier comments warn against. The
|
||||
* probe's ok/fail outcome carries the diagnosis instead; `verdict` is
|
||||
* populated from the JS-visible error for callers that want it.
|
||||
*
|
||||
* Runs the SAME code path as the real engine (PGlite.create with the
|
||||
* embedded WASM/extension assets) but deliberately NOT PGLiteEngine.connect():
|
||||
* connect wraps failures in buildPgliteInitErrorMessage, whose hint text
|
||||
* would then pollute re-classification of the probe error.
|
||||
*
|
||||
* Safety: the scratch dir comes from mkdtemp under os.tmpdir() and is
|
||||
* additionally checked against `realStorePath` (refuses any overlap in
|
||||
* either direction) — a bug here must never touch the brain being
|
||||
* diagnosed. The dir is removed in a finally, success or failure.
|
||||
*/
|
||||
export interface PgliteScratchProbeResult {
|
||||
ok: boolean;
|
||||
duration_ms: number;
|
||||
/** JS-visible error when ok=false (the PANIC itself lands on stderr, not here). */
|
||||
error?: string;
|
||||
verdict?: PgliteInitFailure;
|
||||
}
|
||||
|
||||
export async function probePgliteScratchStore(
|
||||
realStorePath?: string,
|
||||
): Promise<PgliteScratchProbeResult> {
|
||||
const scratchDir = await mkdtemp(joinPath(tmpdir(), 'gbrain-pglite-probe-'));
|
||||
if (realStorePath) {
|
||||
const real = resolvePath(realStorePath);
|
||||
const scratch = resolvePath(scratchDir);
|
||||
if (scratch === real || scratch.startsWith(real + pathSep) || real.startsWith(scratch + pathSep)) {
|
||||
await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
|
||||
throw new Error(
|
||||
`refusing to probe: scratch dir ${scratch} overlaps the real store ${real}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const started = Date.now();
|
||||
let db: PGlite | null = null;
|
||||
try {
|
||||
// Same assets as the real engine's connect(): the embedded WASM/fsBundle/
|
||||
// extension options (Bun vfs #1340) — a compiled binary's probe must
|
||||
// exercise the same runtime path the real store open uses.
|
||||
const embedded = await getEmbeddedPgliteOptions();
|
||||
db = await preservingProcessExitCode(() =>
|
||||
PGlite.create({
|
||||
dataDir: joinPath(scratchDir, 'store'),
|
||||
...embedded,
|
||||
}),
|
||||
);
|
||||
await db.query(`CREATE TABLE scratch_probe (id int PRIMARY KEY, note text)`);
|
||||
await db.query(`INSERT INTO scratch_probe VALUES (1, 'ok')`);
|
||||
const res = await db.query<{ note: string }>(`SELECT note FROM scratch_probe WHERE id = 1`);
|
||||
if (res.rows[0]?.note !== 'ok') {
|
||||
throw new Error(`scratch store read-back mismatch: ${JSON.stringify(res.rows)}`);
|
||||
}
|
||||
return { ok: true, duration_ms: Date.now() - started };
|
||||
} catch (err) {
|
||||
const message = stringifyPgliteInitError(err);
|
||||
return {
|
||||
ok: false,
|
||||
duration_ms: Date.now() - started,
|
||||
error: message,
|
||||
verdict: classifyPgliteInitError(message),
|
||||
};
|
||||
} finally {
|
||||
if (db) {
|
||||
try { await db.close(); } catch { /* probe store — nothing to save */ }
|
||||
}
|
||||
await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export class PGLiteEngine implements BrainEngine {
|
||||
readonly kind = 'pglite' as const;
|
||||
private _db: PGLiteDB | null = null;
|
||||
@@ -2945,6 +3041,73 @@ export class PGLiteEngine implements BrainEngine {
|
||||
return rows as unknown as StaleChunkRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared chunkless-page-with-content predicate (mirrors PostgresEngine).
|
||||
* Excludes quarantined + embed_skip pages — both are intentionally
|
||||
* chunkless by design, not drift the safety net should repair.
|
||||
*/
|
||||
private buildChunklessPagesWhere(opts?: { sourceId?: string }): { where: string; params: unknown[] } {
|
||||
const conds: string[] = [
|
||||
'p.deleted_at IS NULL',
|
||||
// healChunklessPages chunks BOTH compiled_truth and timeline (mirrors
|
||||
// embedPage) — a timeline-only page (rare but schema-legal) has
|
||||
// something to heal even with compiled_truth = ''.
|
||||
`(p.compiled_truth <> '' OR p.timeline <> '')`,
|
||||
EMBED_SKIP_FILTER_FRAGMENT,
|
||||
QUARANTINE_FILTER_FRAGMENT,
|
||||
'NOT EXISTS (SELECT 1 FROM content_chunks cc WHERE cc.page_id = p.id)',
|
||||
];
|
||||
const params: unknown[] = [];
|
||||
if (opts?.sourceId) {
|
||||
params.push(opts.sourceId);
|
||||
conds.push(`p.source_id = $${params.length}`);
|
||||
}
|
||||
return { where: conds.join(' AND '), params };
|
||||
}
|
||||
|
||||
async countChunklessPagesWithContent(opts?: { sourceId?: string }): Promise<number> {
|
||||
const { where, params } = this.buildChunklessPagesWhere(opts);
|
||||
const { rows } = await this.db.query(
|
||||
`SELECT count(*)::int AS count FROM pages p WHERE ${where}`,
|
||||
params,
|
||||
);
|
||||
const count = (rows[0] as { count: number } | undefined)?.count ?? 0;
|
||||
return Number(count);
|
||||
}
|
||||
|
||||
async listChunklessPagesWithContent(opts?: {
|
||||
batchSize?: number;
|
||||
afterPageId?: number;
|
||||
sourceId?: string;
|
||||
}): Promise<ChunklessPageRow[]> {
|
||||
const { where, params } = this.buildChunklessPagesWhere(opts);
|
||||
let afterClause = '';
|
||||
if (opts?.afterPageId != null) {
|
||||
params.push(opts.afterPageId);
|
||||
afterClause = ` AND p.id > $${params.length}`;
|
||||
}
|
||||
// Small default (unlike the 2000-row chunk-metadata cursors elsewhere):
|
||||
// each row here carries a FULL page body. See engine.ts docstring.
|
||||
const limit = opts?.batchSize ?? 50;
|
||||
params.push(limit);
|
||||
const limitIdx = params.length;
|
||||
const { rows } = await this.db.query(
|
||||
`SELECT p.id, p.slug, p.source_id, p.compiled_truth, p.timeline
|
||||
FROM pages p
|
||||
WHERE ${where}${afterClause}
|
||||
ORDER BY p.id
|
||||
LIMIT $${limitIdx}`,
|
||||
params,
|
||||
);
|
||||
return (rows as Record<string, unknown>[]).map(r => ({
|
||||
id: r.id as number,
|
||||
slug: r.slug as string,
|
||||
source_id: (r.source_id as string | undefined) ?? 'default',
|
||||
compiled_truth: (r.compiled_truth as string | null) ?? '',
|
||||
timeline: (r.timeline as string | null) ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
async deleteChunks(slug: string, opts?: { sourceId?: string }): Promise<void> {
|
||||
const sourceId = opts?.sourceId ?? 'default';
|
||||
// Source-qualify the page-id subquery; slugs are only unique per source.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user