Compare commits

..
Author SHA1 Message Date
ddb000b0e2 fix(dream): keep dream --dry-run --json stdout clean of embed summaries (#394)
The cycle's embed phase called runEmbedCore with no output suppression, so
the '[dry-run] Would embed ...' / 'Embedded N chunks ...' slog summaries
landed on stdout ahead of the JSON CycleReport, breaking the documented
stdout-clean-for-JSON contract (docs/progress-events.md).

Adds EmbedOpts.quiet gating the human stdout summary slog sites in
embed.ts (embedPage, embedAll, embedAllStale); the cycle's runPhaseEmbed
sets quiet: true since it reports counts via its own PhaseResult. Errors
and warnings still go to stderr regardless.

Takeover of #854 (same approach, reimplemented on current master — the
original patch predates the slog migration and the widened
embedAll/embedAllStale signatures). Regression test ported from #854.

Co-authored-by: Kage18 <Kage18@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:31:13 -07:00
331 changed files with 1628 additions and 18061 deletions
+1 -1
View File
@@ -28,5 +28,5 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11
+3 -3
View File
@@ -45,7 +45,7 @@ jobs:
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
@@ -82,7 +82,7 @@ jobs:
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
@@ -116,7 +116,7 @@ jobs:
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
id-token: write # for attest-build-provenance (Sigstore OIDC)
attestations: write # for attest-build-provenance
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
@@ -49,7 +49,7 @@ jobs:
with:
path: artifacts
- name: Create release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
with:
files: |
artifacts/gbrain-darwin-arm64/gbrain-darwin-arm64
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
container:
image: semgrep/semgrep:1.170.0@sha256:c98f8829eea377274ee4b10656458b078b88232469b2ff913f091c2317347c9d
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
# Non-blocking initially (continue-on-error): the first runs establish a
# baseline without failing unrelated PRs. Graduation path: once the
# baseline findings are triaged (fixed or `# nosemgrep`'d), remove
+8 -12
View File
@@ -43,7 +43,7 @@ jobs:
hit: ${{ steps.lookup.outputs.cache-hit }}
hash: ${{ steps.compute.outputs.hash }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Compute content hash
id: compute
run: |
@@ -84,7 +84,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@dcedce43c6f43de0b836d1fe38946645c9c638dc # v2
@@ -103,7 +103,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 12
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
@@ -124,7 +124,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
@@ -149,7 +149,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 12
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
@@ -172,7 +172,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 12
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
@@ -206,17 +206,13 @@ jobs:
needs: cache-check
if: needs.cache-check.outputs.hit != 'true'
runs-on: ubuntu-latest
# 22, not 15: under parallel PR load the PGLite WASM cold-starts stretch a
# shard past 15 min while every test is still passing — the timeout then
# cancels the job and the test-status gate reads it as a failure. 13 runs
# died this way on 2026-07-21/22 alone.
timeout-minutes: 22
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
-5
View File
@@ -35,11 +35,6 @@ export/
# .context/test-shards/. Workspace-local by design — never committed.
.context/
# Local agent instruction overrides (CLAUDE.local.md / AGENTS.local.md) — personal,
# per-clone, loaded after the committed CLAUDE.md/AGENTS.md. Never committed.
CLAUDE.local.md
AGENTS.local.md
# Tier 3 PGLite snapshot fixture (built on demand by build:pglite-snapshot)
test/fixtures/pglite-snapshot.tar
test/fixtures/pglite-snapshot.version
-138
View File
@@ -2,144 +2,6 @@
All notable changes to GBrain will be documented in this file.
## [0.42.65.0] - 2026-07-23
**A large maintenance release: 93 verified fixes and small features merged since v0.42.64.0, most of them community contributions.**
If you use gbrain day to day, this release makes the boring parts trustworthy. Importing and syncing notes is safer: a failed pull no longer pretends everything is up to date, imported pages are read back after writing to confirm they landed, and a page with real content can no longer be silently overwritten by an empty one. Search answers get better inputs: the think command now picks excerpts that actually match your question, and results respect your federated source settings. Background enrichment (the "dream" cycle) wastes less money and retries properly when an AI provider is down. Spending caps now fail closed, so a billing hiccup can never turn into an uncapped spend. And `gbrain doctor` is quieter, with several false alarms removed and real problems (like an embedding backlog with no worker running) now flagged.
More AI providers work out of the box, including OpenRouter prompt caching, MiniMax and Zhipu GLM recipes, Ollama Matryoshka embedding dimensions, and llama-server batch limits.
## To take advantage of v0.42.65.0
`gbrain upgrade` should do this automatically. No new schema migrations ship in this release.
1. **Upgrade and verify:**
```bash
gbrain upgrade
gbrain doctor
gbrain stats
```
2. **If `gbrain doctor` reports new findings after upgrading,** that is the quieter, more accurate check set working as intended. Each finding names its fix.
3. **If any step fails,** please file an issue at https://github.com/garrytan/gbrain/issues with the output of `gbrain doctor` and `~/.gbrain/upgrade-errors.jsonl` if it exists.
### Itemized changes
#### Security
- MCP source scoping for remote callers got a hardening pass, so agent-facing connections stay confined to the sources they were granted. (#2881, contributed by @spinsirr)
- Paid MCP spend accounting is now atomic and fails closed, and resolver spend is recorded before a cap error is raised, so caps cannot be raced past or undercounted. (#3203, #3204, contributed by @caterpillarC15)
- The OAuth token endpoint rate limit on the HTTP server is now configurable via env for deployments behind shared IPs. (#3114, contributed by @time-attack)
- `WWW-Authenticate` responses now carry `resource_metadata` per the MCP spec and RFC 9728, so conforming clients can discover the auth server. (#1410, contributed by @rayers)
#### Search, retrieval, and think
- `think` selects query-relevant excerpts instead of generic ones. (#3197, contributed by @Y0lan)
- Unqualified local CLI `search`/`query` now honors `sources.config.federated` read visibility. (#2561, #3141, contributed by @time-attack)
- Email citation metadata is projected into search results. (#2873, contributed by @amtagrwl)
- The `think` Gaps section renders once instead of twice. (#1662, contributed by @howwohmm)
- Fuzzy entity lookup threads the caller's source scope and skips soft-deleted entities. (#1508, contributed by @tim404x)
- `code-def` surfaces method, constructor, field, and struct definitions, not just top-level symbols. (#1628, contributed by @rayers)
- Briefing pages are excluded from their own Brain Pulse salience. (#1202, contributed by @rwbaker)
- Reranker calls with missing auth are classified as configuration errors before falling back. (#2059, #3139, contributed by @time-attack)
#### Import, sync, and ingestion
- A failed git pull with zero imports reports `partial (pull_failed)` instead of `up_to_date`. (#3068, #3253, contributed by @Masashi-Ono0611)
- Imports run a post-write read-back verification with a durable ingest-log record. (#2869, contributed by @Andredsouza1984)
- `put` refuses to overwrite a non-empty page with empty content. (#2708, contributed by @symmetric-matthew)
- `putPage` restores soft-deleted rows instead of colliding with them. (#2779, contributed by @RerankerGuo)
- Mixed-case slugs are normalized before chunk upsert, ending duplicate-chunk churn. (#430, #3143, contributed by @time-attack)
- Imports fall back to the body H1 for the title when frontmatter lacks `title:`. (#2446, #3072, contributed by @time-attack)
- YAML comments inside the frontmatter fence are no longer treated as markdown headings. (#3225, #3247, contributed by @Masashi-Ono0611)
- Write-through guards case-insensitive filesystem collisions before the atomic write. (#2831, #3119, contributed by @time-attack)
- Path-qualified wikilinks outside the known directory pattern resolve on the DB/put_page path. (#2866, contributed by @paul-0320)
- CJK slugs are supported in the slug registry and dream-cycle summary slugs. (#782, #738, #3083, contributed by @time-attack)
- Three ingest/sync/serve singleton fixes: page-type round-trip, deleted-slug embed noise, and a stateless width guard. (#3140, contributed by @time-attack)
- Sync honors the `embedding_disabled` sentinel as an implicit `--no-embed`. (#2879, contributed by @gawievanblerk)
- Verified sync head sentinels are cleared correctly. (#2734, contributed by @symmetric-matthew)
- Resumed syncs report the pinned commit they actually landed on. (#3202, contributed by @caterpillarC15)
- The expected `discover_git_root` probe failure stays off stderr. (#3232, contributed by @Masashi-Ono0611)
- `extract --stale` runs the real resolver so basename resolution reaches stale pages, and clears pre-version-bump pages. (#2576, #2717, contributed by @paul-0320; #1791, contributed by @Nazim22)
- Oversized code chunks are capped so they stay embeddable, and code-chunk metadata survives re-embeds. (#1675, contributed by @lubosxyz; #769, #1232, contributed by @rayers)
#### Background cycle, dream, and facts
- Path-derived dream sources are stamped, and the engine closes cleanly on autopilot shutdown. (#3178, contributed by @time-attack)
- All-provider-failed atom drains propagate so durable jobs retry instead of silently dropping work. (#3218, #3248, contributed by @Masashi-Ono0611)
- Atom extraction raises `maxTokens` and case-normalizes `atom_type` for Gemini models. (#3211, contributed by @alexey-metaengage)
- The conversation extractor gates anonymous-speaker self-attribution instead of guessing. (#3228, contributed by @asenkovskiy)
- Incremental dream extraction stamps its watermark so re-runs stop reprocessing. (#2636, #3115, contributed by @time-attack)
- `dream --dry-run --json` keeps stdout clean of embed summaries. (#394, #3109, contributed by @time-attack)
- Synthesized dream pages require a self-contained opening summary. (#2770, contributed by @Masashi-Ono0611)
- PGLite inline synth subagent drains complete, and `lint` gains `--exclude`. (#2699, #2649, #3162, contributed by @time-attack)
- Live context reads the documented "P1 Today" heading form with plain checkbox tasks, matching the daily-task-manager skill's output format. (#2186, #3124, contributed by @time-attack)
- Queued AI jobs refresh gateway config at execution time instead of using a stale snapshot. (#2125, contributed by @maxpetrusenkoagent)
- `brainstorm`/`propose_takes` honor configured models: cost preview uses the configured model, the judge reads its config key, provider probes are skipped when unneeded, and page projection is narrowed. (#3120, contributed by @time-attack)
- Backlog hardening wave: x-to-brain health check, propose_takes deadlines, capture title truncation, extract_atoms backlog handling, and pooler direct-URL routing. (#3165, contributed by @time-attack)
- `skillopt` emits `proposed.md` in no-mutate mode. (#2635, #3182, contributed by @time-attack)
- Nightly quality probe enable path and conversation-parser probe are wired up. (#2629, #2630, #3094, contributed by @time-attack)
#### Doctor, health, and maintenance
- New safe maintenance automation with a shared orphan-exclusion policy, so routine cleanup runs without risking linked content. (#3015, #3023, contributed by @time-attack)
- `orphan_ratio` excludes the chronicle volume under `life/events/`. (#2264, #3214, contributed by @asenkovskiy)
- `brain_score` orphan/timeline components use the orphans-audit linkable scope. (#3155, contributed by @time-attack)
- Entity timeline coverage is measured separately from whole-brain density. (#2761, contributed by @TurgutKural)
- Doctor flags embed backfills queued with no worker running. (#2696, contributed by @javieraldape)
- Two doctor false-positive/timeout fixes: the drift walk skips `node_modules`, and the bare-tweet check skips inline code and cited lines. (#1772, contributed by @sonlndv)
- A dead `llm_fallback_enabled` recommendation is dropped from conversation format coverage. (#1903, contributed by @ElliotDrel)
- Skill triggers with CRLF line endings parse on Windows. (#1149, contributed by @samporter-31)
- Onboard check names are registered in doctor categories, ending unknown-check warnings, and onboard-check remediations survive the `--apply --auto` path. (#3075, #3097, contributed by @time-attack)
- Dead slug prefixes are counted by slug. (#2697, contributed by @RerankerGuo)
- The backlinks worker defaults to check, not fix, and `check-backlinks` honors its positional directory argument. (#1853, contributed by @choomz; #3076, contributed by @time-attack)
- Calibration resolves the owner holder via config, defaulting to `self`. (#3077, contributed by @time-attack)
- Memory throttling on Linux reads `/proc/meminfo` MemAvailable. (#556, contributed by @chengzehsu)
#### AI providers and gateway
- OpenRouter gets family-scoped prompt caching, and query expansion works on chat-capable openai-compat recipes. (#3152, contributed by @time-attack)
- MiniMax recipe: embedding wire-shape compat fetch plus a chat touchpoint. (#1977, #3089, contributed by @time-attack)
- The Zhipu recipe gains a chat touchpoint so GLM subagents work. (#1157, #3084, contributed by @time-attack)
- Tier-configured models reach the recipe allowlist, Anthropic model lists are refreshed, tier resolutions are registered, and probe labels are honest. (#2800, contributed by @p3ob7o)
- Provider base URL config merges from the DB. (#1676, contributed by @TheLordArgus)
- The gateway falls back to the pooler when the derived direct host is unreachable. (#1641, #3088, contributed by @time-attack)
- Config-plane `voyage_api_key` folds into `VOYAGE_API_KEY` like the other hosted keys. (#3236, contributed by @Masashi-Ono0611)
- The `zeroentropyai:zerank-2` reranker has a pricing entry so the budget tracker can meter it. (#3223, #3233, contributed by @Masashi-Ono0611)
- llama-server embedding batches are capped at its 32-input request limit. (#1281, contributed by @mmekkaoui)
- Matryoshka dimensions thread through for Qwen3-Embedding on Ollama. (#1072, contributed by @mgandal)
- `init` seeds AI options from env on cold install, and `whoami` reports the stdio transport. (#3091, contributed by @time-attack)
- The `models` dispatch subcommand reads its first argument correctly. (#1428, contributed by @BenjaminDSmithy)
- Synopsis generation tail-truncates document text for small-model chat handlers. (#1427, contributed by @BenjaminDSmithy)
- The contradiction judge token cap is raised for thinking models. (#3210, contributed by @alexey-metaengage)
#### Schema, migrations, and storage engines
- Engine migration counts and surfaces per-page copy failures instead of silently advancing. (#3241, contributed by @Masashi-Ono0611)
- Invalid `CONCURRENTLY`-build index remnants are dropped without a DO block. (#3191, contributed by @Masashi-Ono0611)
- Unsupported large-dimension HNSW indexes are skipped instead of failing schema setup. (#1734, #3080, contributed by @time-attack)
- The v0.32.2 migration dirty-check scopes to targeted sources and surfaces failed phase detail. (#3093, contributed by @time-attack)
- Schema packs merge the full `extends` chain and `borrow_from` into the resolved manifest. (#1749, #3181, contributed by @time-attack)
- The schema-pack stats catch-all is narrowed so masked errors surface instead of fake zero-page counts. (#2466, #3133, contributed by @time-attack)
- Bundled schema-pack inspection reports the pack actually shipped in the binary, and minion subagent auth resolves through config. (#3110, contributed by @time-attack)
- PGLite `putPage` guards against zero-row RETURNING. (#1649, contributed by @alexhawkins)
#### MCP server and CLI surface
- `list_pages` rows include `source_id`. (#3209, contributed by @alexey-metaengage)
- Running CLI commands while `gbrain serve` (MCP) holds the brain now notifies about the conflict instead of failing confusingly. (#3243, contributed by @fdefitte)
- The OpenClaw plugin manifest entry is declared so the plugin loads. (#2551, #3185, contributed by @time-attack)
#### For contributors
- CI scanner roots are normalized on macOS. (#3198, contributed by @caterpillarC15)
- CI shard timeout raised to 22 minutes plus a delta-assert reporter leak test. (#3231, contributed by @time-attack)
- E2E suite hardening: flaky tests, no-op assertions, and cross-test coupling removed. (#1704, contributed by @auroracapital)
- `mechanical.test.ts` isolates `$HOME` so the E2E suite stops clobbering user config. (#434, contributed by @lloydarmbrust)
- The lint code-fence-wrap detector and fixer regex now agree. (#1597, contributed by @chungty)
- README project links for OpenClaw and Hermes are corrected. (#1961, #3179, contributed by @time-attack)
- A completed TODOS entry is dropped. (#3229, contributed by @Masashi-Ono0611)
## [0.42.64.0] - 2026-07-20
### Fixed
+2 -2
View File
@@ -71,8 +71,8 @@ GBrain is designed to be installed and operated by an AI agent. The fastest path
If you don't already have an AI agent platform running, start with one of these. Both are designed to read GBrain's install protocol and execute it:
- **[OpenClaw](https://github.com/openclaw/openclaw)** — deploy [AlphaClaw on Render](https://render.com/deploy?repo=https://github.com/chrysb/alphaclaw) (one click, 8GB+ RAM)
- **[Hermes](https://github.com/NousResearch/hermes-agent)** — deploy on [Railway](https://github.com/praveen-ks-2001/hermes-agent-template) (one click)
- **[OpenClaw](https://github.com/openclawagents/openclaw)** — deploy [AlphaClaw on Render](https://render.com/deploy?repo=https://github.com/chrysb/alphaclaw) (one click, 8GB+ RAM)
- **[Hermes](https://github.com/openclawagents/hermes)** — deploy on [Railway](https://github.com/praveen-ks-2001/hermes-agent-template) (one click)
Then paste this into your agent:
-12
View File
@@ -135,18 +135,6 @@ the PGLite schema. Local agents continue to use stdio (`gbrain serve`).
Running `--http` against a PGLite-backed install fails fast with a clear
error message at startup.
### Docker network isolation (self-hosted Postgres)
OAuth and source scoping enforce isolation on the `serve --http` path only.
Raw Postgres reachability bypasses both: a container that shares Docker's
default `bridge` network with the brain's Postgres can open a direct DB
session without any token and read every source. Put the brain's Postgres on
a user-defined Docker network with nothing untrusted on it, publish its port
loopback-only (if at all), and never put `DATABASE_URL` or a Postgres
password in untrusted agent containers — those should reach the brain
exclusively via OAuth against `serve --http`. Full operator checklist:
[docs/mcp/DEPLOY.md — Co-located Docker workloads](docs/mcp/DEPLOY.md#co-located-docker-workloads-self-hosted-postgres).
### CORS
Default-deny: no `Access-Control-Allow-Origin` header is sent unless an
+12 -19
View File
@@ -2,6 +2,14 @@
## community fix-wave follow-ups (filed v0.42.60.0)
- [ ] **P1 — take-writes source scoping fails open when source resolution errors (#2684 residual).**
`resolveTakesSourceId` (src/commands/takes.ts) swallows resolution errors and returns
`undefined`, which falls back to the unscoped slug-only page lookup — so an invalid
`GBRAIN_SOURCE` (or a broken dotfile chain) silently restores the pre-#2698 cross-source
write behavior on multi-source brains. Decide fail-closed semantics: error out when a
source was explicitly requested but doesn't resolve; keep the unscoped fallback only for
brains with no source configuration at all. Add a regression test for the invalid-source
path. Found by cross-model adversarial review during the v0.42.60.0 release ship.
- [ ] **P2 — cherry-pick #2112's uncovered doctor.ts hunk.** Fix-wave A (#2820) superseded
most of #2112 but not its `checkSubagentCapability` fix (check explicit `models.subagent`
before `models.tier.subagent`). Refile or cherry-pick; the rest of that PR is covered.
@@ -2279,25 +2287,10 @@ at plan time and got carved out:
via `buildPerSourceBindings`. Document workaround: register
source-scoped OAuth clients.
- [x] **v0.41+: T20 — extends-chain merging in registry.ts.** DONE (#1749).
`resolvePack` now merges parent → child (child-wins) for the six
ingest/query-shaping fields (`page_types`, `link_types`,
`frontmatter_links`, `enrichable_types`, `filing_rules`, `takes_kinds`)
plus `borrow_from` materialization, in `src/core/schema-pack/merge.ts`.
The cascade was transparent (consumers already read `resolved.manifest`),
not per-consumer. `phases`/`calibration_domains` deliberately excluded —
see the P3 follow-up below.
- [ ] **P3: explicit opt-in to inherit `phases` / `calibration_domains`.**
T20 excludes these two from the child-wins merge because they gate real
cycle execution (`cycle.ts` `packDeclaresPhase`) and the manifest
contract says each pack declares its own participation explicitly —
auto-inheriting would silently make a child run cycle phases it never
requested. Multi-level lens packs (`gbrain-everything`) therefore still
re-declare them by hand. If that redeclaration becomes painful, add an
explicit manifest flag (e.g. `inherit_phases: true`) so a pack author
opts in consciously. Depends on: T20 (landed). Start in
`src/core/schema-pack/merge.ts` (`mergeInheritedManifest`).
- [ ] **v0.41+: T20 — extends-chain merging in registry.ts.**
`registry.ts:167` documents the gap. Implementing full child-wins
merge cascades through every consumer of `manifest.page_types`. ~1
day CC.
- [ ] **v0.41+: T21 — comment-preserving YAML emitter.**
v0.40.7.0 emitter does NOT preserve comments. Authors who care
+1 -1
View File
@@ -1 +1 @@
0.42.65.0
0.42.64.0
+5 -10
View File
@@ -51,9 +51,8 @@
"@electric-sql/pglite",
],
"overrides": {
"@hono/node-server": "^2.0.5",
"body-parser": "^2.3.0",
"fast-uri": "^3.1.4",
"@hono/node-server": "^1.19.13",
"fast-uri": "^3.1.2",
"fast-xml-builder": "^1.1.7",
"fast-xml-parser": "^5.7.0",
"form-data": "^4.0.6",
@@ -163,7 +162,7 @@
"@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="],
"@hono/node-server": ["@hono/node-server@2.0.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA=="],
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@jsquash/avif": ["@jsquash/avif@2.1.1", "", { "dependencies": { "wasm-feature-detect": "^1.2.11" } }, "sha512-LMRxd0fMgfCLtobDh0/sFYJMMiRJTNYSEEWvRDKXlAeZ08t3gI5V+1thIT0XjXJ+SVG7Zug9B0XPyx0Ti5VRNA=="],
@@ -327,7 +326,7 @@
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
@@ -401,7 +400,7 @@
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
"fast-uri": ["fast-uri@3.1.3", "", {}, "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg=="],
"fast-xml-builder": ["fast-xml-builder@1.3.0", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ=="],
@@ -615,10 +614,6 @@
"@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"body-parser/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
"es-set-tostringtag/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"eventsource/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
-1
View File
@@ -189,7 +189,6 @@ Unit tests and what they cover:
- `test/orphans.test.ts` — orphans command: detection, pseudo filtering, text/json/count outputs, MCP op.
- `test/postgres-engine.test.ts``statement_timeout` scoping: `sql.begin` + `SET LOCAL` shape, source-level grep guardrail against a reintroduced bare `SET statement_timeout`.
- `test/sync.test.ts` — sync logic + regression guard asserting top-level `engine.transaction` is not called.
- `test/sync-pull-failed-anchor.serial.test.ts`#3068 regression: a failed internal `git pull` (local-path origin vs `protocol.file.allow=never`) with zero imports returns `partial`/`pull_failed` (not `up_to_date`), freezes `last_commit` + `last_sync_at`, recovers after a manual pull; fall-through import of local commits preserved. Serial: pins `GBRAIN_HOME` to a temp dir for the whole file.
- `test/sync-concurrency.test.ts``autoConcurrency()` thresholds + PGLite-forces-serial + explicit-override clamping; `shouldRunParallel()` explicit-bypasses-floor contract; `parseWorkers()` validation rejecting `'0'`/`'-3'`/`'foo'`/`'1.5'`/trailing chars.
- `test/sync-parallel.test.ts` — PGLite-routed coverage of the bookmark gate under concurrency, head-drift gate, vanished-file failure capture, PGLite-stays-serial, and the `gbrain-sync` writer-lock contract.
- `test/sync-failures.test.ts``classifyErrorCode` regex coverage for all 12 codes against literal production message strings from `markdown.ts` and `import-file.ts`; `summarizeFailuresByCode` sort + pre-classified-honor; `recordSyncFailures` code-field persistence; `acknowledgeSyncFailures` `AcknowledgeResult` shape + backfill on legacy entries.
File diff suppressed because one or more lines are too long
-9
View File
@@ -75,15 +75,6 @@ Meta-pack stacking creator + investor + engineer via the v0.38
preserved — this IS the active pack; the registry walks extends +
borrow to materialize the merged view.
**Merge contract (T20 / #1749).** `resolvePack` merges parent → child
(child-wins) for the six ingest/query-shaping fields: `page_types`,
`link_types`, `frontmatter_links`, `enrichable_types`, `filing_rules`,
and `takes_kinds` (unioned — a child cannot narrow it). `phases` and
`calibration_domains` are **NOT** inherited: they gate cycle execution,
so each pack must declare its own participation explicitly. That is why
`gbrain-everything` re-declares all its phases and all 7
`calibration_domains` — inheritance does not carry them.
Activate via `gbrain config set schema_pack gbrain-everything` and
calibration_profile produces all 7 domain scorecards in one JSONB.
+1 -29
View File
@@ -145,7 +145,7 @@ api_version: gbrain-schema-pack-v1
name: my-pack
version: 0.0.1
gbrain_min_version: 0.39.0
extends: gbrain-base # inherits base's TYPES (see Merge contract below); add overrides
extends: gbrain-base # inherits everything from base; add overrides below
description: |
My personal pack.
@@ -170,34 +170,6 @@ enrichable_types: []
filing_rules: []
```
## Merge contract (`extends` + `borrow_from`)
`resolvePack` composes a pack against its `extends` chain (and any
`borrow_from` targets) into the `resolved.manifest` every consumer reads
(T20 / #1749). The rules:
- **Six fields inherit, child-wins:** `page_types`, `link_types`,
`frontmatter_links`, `enrichable_types`, `filing_rules`, and `takes_kinds`.
A child value with the same key (type name, link name, etc.) overrides the
parent's; keys the child doesn't declare come through from the parent.
- **`page_types` ordering:** overrides of a base type keep the base's declared
position (base's `inferType` prefix priority is authoritative); a genuinely
new type — from the child, a `borrow_from`, or a middle pack in the chain —
is prepended nearest-first, so a more-derived type's `path_prefix` wins
regardless of how deep the chain is.
- **`takes_kinds` is UNION, not replace** — it carries a Zod default, so an
omitted field is indistinguishable from an explicit one. A child can ADD
kinds but **cannot narrow** `takes_kinds` below base parent. If you need a
smaller set, don't `extends` a pack that declares the larger one.
- **`phases` and `calibration_domains` are NOT inherited** (child-only). They
gate real cycle execution, so each pack must declare its own participation
explicitly — inheriting them would silently make a child run phases it never
requested. This is why `gbrain-everything` re-declares all its phases and
calibration domains by hand. See `lens-packs.md` for the worked example.
- **`borrow_from` is selective + non-transitive + fail-closed:** it pulls only
the named `types`/`link_types` from the target's OWN declarations (omitting a
category borrows none of it); a missing target throws `UnknownPackError`.
## Recovery + revert
The single-PR cathedral is hard to revert atomically. Per codex finding
+5 -8
View File
@@ -21,17 +21,14 @@ GBrain is tuned for the Supabase **Transaction pooler** (port 6543): it
auto-disables prepared statements there and routes `engine.transaction()`
(migrations, DDL, sync imports) to a derived **direct** connection
(`db.<ref>.supabase.co:5432`). That direct host is IPv6-only, so on an
IPv4-only host it is unreachable. When that happens gbrain now falls back to
the pooler automatically (one stderr warning, then single-pool mode for the
rest of the process) — but the pooler's ~2-min statement timeout can truncate
very long migrations or bulk imports.
IPv4-only host, reads work but sync **silently skips most pages**. This is the
number one cause of "sync ran but nothing happened."
Fix: make the direct connection reachable over IPv4. Either set
`GBRAIN_DIRECT_DATABASE_URL` to the **Session pooler** string (port 5432 on the
`pooler.supabase.com` host, IPv4), or enable Supabase's IPv4 add-on.
`GBRAIN_DISABLE_DIRECT_POOL=1` skips the direct pool (and the fallback warning)
entirely. Verify by running `gbrain sync` and checking that the page count in
`gbrain stats` matches the syncable file count in the repo.
`pooler.supabase.com` host, IPv4), or enable Supabase's IPv4 add-on. Verify by
running `gbrain sync` and checking that the page count in `gbrain stats` matches
the syncable file count in the repo.
### The Primitives
+1 -3
View File
@@ -131,9 +131,7 @@ into gbrain so other clients can scaffold it. Default behavior:
`~/.gbrain/harvest-private-patterns.txt` plus built-in defaults
(canonical private fork name, common email regex, Slack channel pattern). Any
match → rollback (delete the harvested files) and exit non-zero.
- `openclaw.plugin.json` updated with the new slug, sorted. Harvest must preserve
the top-level OpenClaw-native plugin fields (`id`, `configSchema`, `contracts`)
because OpenClaw validates those before it can install the package.
- `openclaw.plugin.json` updated with the new slug, sorted.
- `--no-lint` bypasses the linter (after a manual editorial scrub).
Use the `skillpack-harvest` skill (its companion editorial workflow)
-37
View File
@@ -258,43 +258,6 @@ the user owns the machine.
See [ALTERNATIVES.md](ALTERNATIVES.md) for a comparison of ngrok, Tailscale
Funnel, and cloud hosts (Fly.io, Railway).
### Co-located Docker workloads (self-hosted Postgres)
OAuth scopes and source scoping guard the `gbrain serve --http` path. They do
NOT guard raw Postgres. If the brain's Postgres runs as a container on the same
Docker host as other workloads (agent runtimes, n8n, staging fixtures), any
container sharing Docker's default `bridge` network can open a direct DB
session — no OAuth token required — and read every source. That silently
recreates a privileged path underneath the isolation you configured at the MCP
layer.
Network-zone the host so untrusted containers can never reach Postgres:
```
Docker host
├── gbrain-net ← ONLY the brain's Postgres (+ gbrain serve, if containerized)
├── agent-<id>-net ← each untrusted agent runtime, isolated
└── default bridge ← no secret-bearing databases
```
Operator checklist:
```text
[ ] Postgres is on a user-defined Docker network, not the default bridge
(or nothing else runs on that bridge)
[ ] If Postgres publishes a host port at all, it binds loopback only
(`-p 127.0.0.1:5432:5432`, never `0.0.0.0`)
[ ] Untrusted agent containers have no DATABASE_URL or Postgres password
[ ] Untrusted agents reach the brain via OAuth/Bearer against serve --http only
(host loopback via host.docker.internal / host gateway — never gbrain-net)
[ ] OAuth clients are least-privilege: scoped --source / --federated-read,
pre-minted short-lived tokens preferred over long-lived client secrets
[ ] Isolation verified: a team-scoped client cannot read internal-only sources
```
Optional defense-in-depth: a dedicated Postgres role (or RLS) limited to the
allowed `source_id`s, so even a leaked connection string can't read everything.
## Troubleshooting
**"missing_auth" error**
-15
View File
@@ -91,18 +91,3 @@ First full takes extraction run on a ~100K-page brain:
4. **Self-reported ≠ verified.** "Reports 7 figures" → holder=person, weight=0.75, NOT world/1.0
5. **No false precision.** Use 0.05 increments (0.35, 0.55, 0.75), not 0.74 or 0.82
6. **"So what" test.** Skip Twitter handles, follower counts, obvious metadata
## Owner-holder canonicalization
"The brain owner" is, by convention, the holder string **`self`** — the value the
dream `consolidate` phase stamps when it promotes the owner's hot facts into cold
takes. Calibration, `think`, and the `doctor` calibration check resolve the owner
holder through `resolveOwnerHolder` (`src/core/owner-holder.ts`): explicit override
> `emotional_weight.user_holder` config > `self`.
Known limitation (tracked in garrytan/gbrain#2465): the owner can also
appear under `brain` (a take the owner asserts, via `propose_takes`) and
`people/<owner>` (extraction that names the owner). The resolver selects the
*default* canonical owner string for reads; it does not merge those other
strings. Per-take attribution for other people (e.g. `people/george`) is
unaffected and correct.
-4
View File
@@ -484,10 +484,6 @@ Returns a per-source dashboard: when each source last synced, how many pages, ho
The admin dashboard at `https://brain.acme-co.com/admin` shows live request volume, registered OAuth clients, recent activity, and brain stats. Use the admin bootstrap token from Part 4 to log in the first time, then register additional admin users from inside the dashboard.
### If agents run as containers on the same Docker host
OAuth source scoping only guards the HTTP MCP path. If the brain's Postgres and your teammates' agent runtimes are containers on the same Docker host, make sure the agents can't reach Postgres directly over Docker's default bridge network — a direct DB session skips OAuth entirely. Put Postgres on its own user-defined network, publish it loopback-only if at all, and never hand agent containers a `DATABASE_URL`. The copy-paste operator checklist lives in [docs/mcp/DEPLOY.md — Co-located Docker workloads](../mcp/DEPLOY.md#co-located-docker-workloads-self-hosted-postgres).
---
## Part 13: Cost and speed expectations
@@ -233,14 +233,13 @@ keep it or `git checkout` to throw it away. Nothing is committed for you.
**For a skill that ships with gbrain** (anything under the gbrain repo's own
`skills/`): SkillOpt refuses to overwrite it by default and writes the winner to
`skills/<name>/skillopt/proposed.md` instead (while keeping `best.md` as the
optimizer's current-best pointer), so an optimization pass can never silently
mutate a skill other people depend on. Two ways to handle that:
`skills/<name>/skillopt/best.md` instead, so an optimization pass can never
silently mutate a skill other people depend on. Two ways to handle that:
```bash
# See the proposed improvement without touching SKILL.md (works for ANY skill):
gbrain skillopt meeting-prep --split 1:1:1 --no-mutate
# → writes skills/meeting-prep/skillopt/proposed.md, updates best.md, and prints the proposal path.
# → writes skills/meeting-prep/skillopt/best.md (the proposed rewrite), prints its path. Copy what you want.
# Actually rewrite a bundled skill (explicit opt-in + an independent held-out set):
gbrain skillopt brain-ops --split 1:1:1 --allow-mutate-bundled \
+7 -47
View File
@@ -1565,8 +1565,8 @@ GBrain is designed to be installed and operated by an AI agent. The fastest path
If you don't already have an AI agent platform running, start with one of these. Both are designed to read GBrain's install protocol and execute it:
- **[OpenClaw](https://github.com/openclaw/openclaw)** — deploy [AlphaClaw on Render](https://render.com/deploy?repo=https://github.com/chrysb/alphaclaw) (one click, 8GB+ RAM)
- **[Hermes](https://github.com/NousResearch/hermes-agent)** — deploy on [Railway](https://github.com/praveen-ks-2001/hermes-agent-template) (one click)
- **[OpenClaw](https://github.com/openclawagents/openclaw)** — deploy [AlphaClaw on Render](https://render.com/deploy?repo=https://github.com/chrysb/alphaclaw) (one click, 8GB+ RAM)
- **[Hermes](https://github.com/openclawagents/hermes)** — deploy on [Railway](https://github.com/praveen-ks-2001/hermes-agent-template) (one click)
Then paste this into your agent:
@@ -2720,17 +2720,14 @@ GBrain is tuned for the Supabase **Transaction pooler** (port 6543): it
auto-disables prepared statements there and routes `engine.transaction()`
(migrations, DDL, sync imports) to a derived **direct** connection
(`db.<ref>.supabase.co:5432`). That direct host is IPv6-only, so on an
IPv4-only host it is unreachable. When that happens gbrain now falls back to
the pooler automatically (one stderr warning, then single-pool mode for the
rest of the process) — but the pooler's ~2-min statement timeout can truncate
very long migrations or bulk imports.
IPv4-only host, reads work but sync **silently skips most pages**. This is the
number one cause of "sync ran but nothing happened."
Fix: make the direct connection reachable over IPv4. Either set
`GBRAIN_DIRECT_DATABASE_URL` to the **Session pooler** string (port 5432 on the
`pooler.supabase.com` host, IPv4), or enable Supabase's IPv4 add-on.
`GBRAIN_DISABLE_DIRECT_POOL=1` skips the direct pool (and the fallback warning)
entirely. Verify by running `gbrain sync` and checking that the page count in
`gbrain stats` matches the syncable file count in the repo.
`pooler.supabase.com` host, IPv4), or enable Supabase's IPv4 add-on. Verify by
running `gbrain sync` and checking that the page count in `gbrain stats` matches
the syncable file count in the repo.
### The Primitives
@@ -3905,43 +3902,6 @@ the user owns the machine.
See [ALTERNATIVES.md](ALTERNATIVES.md) for a comparison of ngrok, Tailscale
Funnel, and cloud hosts (Fly.io, Railway).
### Co-located Docker workloads (self-hosted Postgres)
OAuth scopes and source scoping guard the `gbrain serve --http` path. They do
NOT guard raw Postgres. If the brain's Postgres runs as a container on the same
Docker host as other workloads (agent runtimes, n8n, staging fixtures), any
container sharing Docker's default `bridge` network can open a direct DB
session — no OAuth token required — and read every source. That silently
recreates a privileged path underneath the isolation you configured at the MCP
layer.
Network-zone the host so untrusted containers can never reach Postgres:
```
Docker host
├── gbrain-net ← ONLY the brain's Postgres (+ gbrain serve, if containerized)
├── agent-<id>-net ← each untrusted agent runtime, isolated
└── default bridge ← no secret-bearing databases
```
Operator checklist:
```text
[ ] Postgres is on a user-defined Docker network, not the default bridge
(or nothing else runs on that bridge)
[ ] If Postgres publishes a host port at all, it binds loopback only
(`-p 127.0.0.1:5432:5432`, never `0.0.0.0`)
[ ] Untrusted agent containers have no DATABASE_URL or Postgres password
[ ] Untrusted agents reach the brain via OAuth/Bearer against serve --http only
(host loopback via host.docker.internal / host gateway — never gbrain-net)
[ ] OAuth clients are least-privilege: scoped --source / --federated-read,
pre-minted short-lived tokens preferred over long-lived client secrets
[ ] Isolation verified: a team-scoped client cannot read internal-only sources
```
Optional defense-in-depth: a dedicated Postgres role (or RLS) limited to the
allowed `source_id`s, so even a leaked connection string can't read everything.
## Troubleshooting
**"missing_auth" error**
-1
View File
@@ -1,5 +1,4 @@
{
"id": "gbrain-context-engine",
"name": "gbrain",
"version": "0.32.3.0",
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
+3 -4
View File
@@ -144,11 +144,10 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.42.65.0",
"version": "0.42.64.0",
"overrides": {
"@hono/node-server": "^2.0.5",
"fast-uri": "^3.1.4",
"body-parser": "^2.3.0",
"@hono/node-server": "^1.19.13",
"fast-uri": "^3.1.2",
"fast-xml-builder": "^1.1.7",
"fast-xml-parser": "^5.7.0",
"form-data": "^4.0.6",
+8 -13
View File
@@ -1,7 +1,7 @@
---
id: x-to-brain
name: X-to-Brain
version: 0.8.2
version: 0.8.1
description: Twitter timeline, mentions, and keyword monitoring flow into brain pages. Tracks deletions, engagement velocity, OCR on images, and real-time alerts.
category: sense
requires: []
@@ -9,12 +9,9 @@ secrets:
- name: X_BEARER_TOKEN
description: X API v2 Bearer token (Basic tier minimum, $200/mo for full archive search)
where: https://developer.x.com/en/portal/dashboard — create a project + app, copy the Bearer Token from "Keys and tokens"
- name: X_HANDLE
description: Your X username without the @ (used for the app-only health check — /users/me requires user-context OAuth, which app-only bearer tokens don't have)
where: Your X profile — the handle in your profile URL, e.g. x.com/yourhandle → yourhandle
health_checks:
- type: http
url: "https://api.x.com/2/users/by/username/$X_HANDLE"
url: "https://api.x.com/2/users/me"
auth: bearer
auth_token: "$X_BEARER_TOKEN"
label: "X API"
@@ -113,17 +110,15 @@ Tell the user:
4. Inside the project, create a new App
5. Go to the app's 'Keys and tokens' tab
6. Under 'Bearer Token', click 'Generate' (or 'Regenerate')
7. Copy the Bearer Token and paste it to me, along with your X handle (without the @)
7. Copy the Bearer Token and paste it to me
Note: Free tier gives read-only access with low limits. Basic tier ($200/mo)
gives search/recent endpoint and higher limits. Pro tier gets full archive search."
Set both `X_BEARER_TOKEN` and `X_HANDLE` in the environment. Validate immediately
(app-only bearer tokens cannot call `/users/me` — that endpoint requires
user-context OAuth — so validation uses the by-username lookup):
Validate immediately:
```bash
curl -sf -H "Authorization: Bearer $X_BEARER_TOKEN" \
"https://api.x.com/2/users/by/username/$X_HANDLE" \
"https://api.x.com/2/users/me" \
&& echo "PASS: X API connected" \
|| echo "FAIL: X API token invalid"
```
@@ -139,10 +134,10 @@ starting with 'AAA...', (3) if you just created the app, the token is valid imme
```bash
# Look up the user's X user ID from their handle
curl -sf -H "Authorization: Bearer $X_BEARER_TOKEN" \
"https://api.x.com/2/users/by/username/$X_HANDLE" | grep -o '"id":"[^"]*"'
"https://api.x.com/2/users/by/username/USERNAME" | grep -o '"id":"[^"]*"'
```
Look up the user ID from the handle collected in Step 1.
Ask the user for their X handle (e.g., @yourhandle). Look up their user ID.
Save it — the collector needs the numeric ID, not the handle.
### Step 3: Configure the Collector
@@ -210,7 +205,7 @@ The agent should review collected data 2-3x daily and run enrichment.
```bash
mkdir -p ~/.gbrain/integrations/x-to-brain
echo '{"ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","event":"setup_complete","source_version":"0.8.2","status":"ok","details":{"user_id":"X_USER_ID"}}' >> ~/.gbrain/integrations/x-to-brain/heartbeat.jsonl
echo '{"ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","event":"setup_complete","source_version":"0.8.1","status":"ok","details":{"user_id":"X_USER_ID"}}' >> ~/.gbrain/integrations/x-to-brain/heartbeat.jsonl
```
## Production Patterns (v0.8.1)
+1 -1
View File
@@ -70,7 +70,7 @@ PATTERN='import[[:space:]]+(\*[[:space:]]+as[[:space:]]+[a-zA-Z_$][a-zA-Z0-9_$]*
FOUND_FILES=""
while IFS= read -r f; do
[ -n "$f" ] && FOUND_FILES="$FOUND_FILES$f"$'\n'
done < <(grep -rlE --include='*.ts' "$PATTERN" src 2>/dev/null | sort -u || true)
done < <(grep -rlE --include='*.ts' "$PATTERN" src/ 2>/dev/null | sort -u || true)
FAIL=0
+2 -2
View File
@@ -100,9 +100,9 @@ IFS='|' eval 'PATTERN="${PATTERN_PARTS[*]}"'
# Find tool.
if command -v rg >/dev/null 2>&1; then
matches="$(rg -niH --no-heading -t ts "$PATTERN" test 2>/dev/null || true)"
matches="$(rg -niH --no-heading -t ts "$PATTERN" test/ 2>/dev/null || true)"
elif command -v grep >/dev/null 2>&1; then
matches="$(grep -rniE --include='*.test.ts' "$PATTERN" test 2>/dev/null || true)"
matches="$(grep -rniE --include='*.test.ts' "$PATTERN" test/ 2>/dev/null || true)"
else
echo "check-test-real-names: ERROR: neither rg nor grep available." >&2
exit 2
+1 -12
View File
@@ -133,7 +133,6 @@ for i in $(seq 1 "$N"); do
env SHARD="$i/$N" \
bash scripts/run-unit-shard.sh --max-concurrency="$INTRA_CONC" \
> "$SHARD_LOG" 2>&1
rc=$?
else
env SHARD="$i/$N" \
bash scripts/run-unit-shard.sh --max-concurrency="$INTRA_CONC" \
@@ -143,20 +142,10 @@ for i in $(seq 1 "$N"); do
sleep 5 && kill -KILL "$pid" 2>/dev/null ) &
cap_pid=$!
wait "$pid" 2>/dev/null
# Capture the shard's exit code from ITS `wait`, before any watchdog
# teardown runs. The teardown commands below overwrite $? — the killed
# watchdog reports 143 — which used to get stamped into every shard's
# sentinel on machines with no gtimeout/timeout: every run "failed"
# with rc=143 summaries even when all tests passed.
rc=$?
# Reap the watchdog's `sleep` child too (pkill -P), then the watchdog.
# Killing only the subshell leaves the sleep orphaned until
# $SHARD_TIMEOUT elapses — same quirk the heartbeat cleanup below works
# around; CI's orphan-process sweep flags those.
pkill -P "$cap_pid" 2>/dev/null
kill "$cap_pid" 2>/dev/null
wait "$cap_pid" 2>/dev/null
fi
rc=$?
echo "$rc" > "$LOG_DIR/shard-$i.exit"
[ "$rc" = "124" ] && echo "WEDGED" > "$LOG_DIR/shard-$i.wedged"
) &
+1 -12
View File
@@ -126,7 +126,6 @@ for c in "${CHECKS[@]}"; do
(
if [ -n "$TIMEOUT_BIN" ]; then
"$TIMEOUT_BIN" "${TIMEOUT}s" bun run "$c" > "$LOG_FILE" 2>&1
rc=$?
else
bun run "$c" > "$LOG_FILE" 2>&1 &
pid=$!
@@ -134,20 +133,10 @@ for c in "${CHECKS[@]}"; do
sleep 5 && kill -KILL "$pid" 2>/dev/null ) &
cap_pid=$!
wait "$pid" 2>/dev/null
# Capture the check's exit code from ITS `wait`, before any watchdog
# teardown runs. The teardown commands below overwrite $? — the killed
# watchdog reports 143 — which used to get stamped into every sentinel
# on machines with no gtimeout/timeout: verify reported pass=0
# fail=<all> while every per-check log said OK.
rc=$?
# Reap the watchdog's `sleep` child too (pkill -P), then the watchdog.
# Killing only the subshell leaves the sleep orphaned until $TIMEOUT
# elapses — same quirk the heartbeat cleanup in run-unit-parallel.sh
# works around; CI's orphan-process sweep flags those.
pkill -P "$cap_pid" 2>/dev/null
kill "$cap_pid" 2>/dev/null
wait "$cap_pid" 2>/dev/null
fi
rc=$?
echo "$rc" > "$EXIT_FILE"
) &
PIDS+=($!)
+1 -1
View File
@@ -62,7 +62,7 @@ gbrain capture "..." --json # structured output for agents
- **Slug:** `inbox/YYYY-MM-DD-<hash8>` (stable for same content; the daemon's 24h dedup catches re-captures).
- **Type:** `note` (override with `--type idea` etc.).
- **Frontmatter stamps:** `captured_via: capture-cli`, `captured_at: <ISO>`.
- **Title:** first non-empty line of the body, capped at 80 chars (truncation appends `…`).
- **Title:** first non-empty line of the body, capped at 80 chars.
## Output Format
+1 -2
View File
@@ -266,5 +266,4 @@ editorial pass.
(e.g. `src/commands/<slug>.ts` if the host SKILL.md declares it
in frontmatter)
- gbrain's `openclaw.plugin.json` — adds the slug to `skills:`
array, sorted alphabetically, without removing OpenClaw-native plugin fields
like `id`, `configSchema`, or `contracts`
array, sorted alphabetically
+1 -3
View File
@@ -57,8 +57,6 @@ This mode guarantees:
- `skills/manifest.json` lists every skill directory
- `skills/RESOLVER.md` references every skill in the manifest
- `openclaw.plugin.json` `skills[]` round-trips with both
- `openclaw.plugin.json` keeps OpenClaw install-required native plugin fields
(`id`, object `configSchema`, and `contracts.contextEngines` when applicable)
- No MECE violations (duplicate triggers across skills)
### Phases
@@ -74,7 +72,7 @@ This mode guarantees:
### Automation
```bash
bun test test/skills-conformance.test.ts test/resolver.test.ts test/openclaw-plugin-manifest.test.ts
bun test test/skills-conformance.test.ts test/resolver.test.ts
```
The CI-gated check is the package.json `test` script.
+4 -87
View File
@@ -24,7 +24,6 @@ import type { GBrainConfig } from './core/config.ts';
import type { AIGatewayConfig } from './core/ai/types.ts';
import type { BrainEngine } from './core/engine.ts';
import { operations, OperationError } from './core/operations.ts';
import { resolveSourceIdEngineFree } from './core/source-resolver.ts';
import { formatVolunteeredPage } from './core/context/volunteer.ts';
import type { Operation, OperationContext } from './core/operations.ts';
import { shouldForceExitAfterMain, finishCliTeardown, flushThenExit, currentExitCode, setCliExitVerdict } from './core/cli-force-exit.ts';
@@ -55,7 +54,7 @@ export function bigintToStringReplacer(_key: string, value: unknown): unknown {
}
// CLI-only commands that bypass the operation layer
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'maintain', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector']);
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector']);
// CLI-only commands whose handlers print their own --help text. These are
// excluded from the generic short-circuit so detailed per-command and
// per-subcommand usage stays reachable.
@@ -79,8 +78,6 @@ const CLI_ONLY_SELF_HELP = new Set([
'capture',
// v0.42 self-upgrade ships its own usage (flags + the agent-skill story).
'self-upgrade',
// maintain (#3015) prints its own usage block (modes + not-auto-applied list).
'maintain',
// v0.43 (#2095): watch ships WATCH_HELP (flags + the stdin-turn protocol).
'watch',
// v0.37 fix wave (Lane D.4 + CDX2-12): sync's --no-embed flag was
@@ -385,15 +382,6 @@ async function main() {
if (op.localOnly) {
refuseThinClient(command, cfgPre!.remote_mcp!.mcp_url);
}
// #2098: the local path resolves --source / GBRAIN_SOURCE / .gbrain-source
// inside makeContext (ctx.sourceId), which this route never reaches — so
// scope must be mapped onto the op's source_id wire param before the call.
try {
applyThinClientSourceScope(op, params);
} catch (e: unknown) {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
}
await runThinClientRouted(op, params, cfgPre!, cliOpts);
return;
}
@@ -814,80 +802,18 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
return params;
}
/**
* #2098: thin-client source scoping. Locally, --source / GBRAIN_SOURCE /
* .gbrain-source resolve to ctx.sourceId in makeContext; the thin-client
* route short-circuits before that, so `gbrain query --source X` against a
* remote brain silently searched unscoped. This runs the engine-free tiers
* (flag env dotfile; the DB-backed tiers can't run without an engine
* the server's grant scoping covers the rest) and maps the result onto the
* op's `source_id` wire param.
*
* Ops that declare their OWN `source` param (facts add, etc.) are left
* untouched their --source is an op param, not scope. An explicit --source
* on an op with no source_id wire param throws (loud beats silent drop);
* ambient env/dotfile scope with nowhere to send it is ignored, matching the
* pre-fix behavior for non-scopeable ops. Exported for tests.
*/
// Ops whose `source_id` wire param is NOT read-scope semantics: get_skill's
// source_id flips the lookup from host catalog to brain-resident-pack
// (getResidentSkillDetail). Ambient env/dotfile scope must never leak into
// these; an explicit --source-id still passes through untouched above.
const NON_SCOPE_SOURCE_ID_OPS = new Set(['get_skill']);
export function applyThinClientSourceScope(
op: Operation,
params: Record<string, unknown>,
cwd?: string,
): void {
if ('source' in op.params) return; // the op owns --source; not a scope flag
const explicit = typeof params.source === 'string' && params.source.length > 0
? (params.source as string)
: null;
delete params.source; // never a wire param on these ops — don't leak it
// Explicit per-call scope already on the wire wins over ambient tiers.
if (params.source_id !== undefined || params.all_sources === true) {
if (explicit) {
throw new Error('Pass either --source or --source-id/--all-sources, not both.');
}
return;
}
const resolved = resolveSourceIdEngineFree(explicit, cwd);
if (!resolved) return;
if (!('source_id' in op.params) || NON_SCOPE_SOURCE_ID_OPS.has(op.name)) {
if (explicit) {
const hint = NON_SCOPE_SOURCE_ID_OPS.has(op.name)
? `(its source_id parameter is not a scope filter; pass --source-id explicitly if you mean it)`
: `(the remote op has no source_id parameter; the server scopes it to your grant)`;
throw new Error(
`gbrain ${op.cliHints?.name || op.name} does not accept --source on a thin-client install ${hint}.`,
);
}
return; // ambient env/dotfile scope with nowhere to send it
}
params.source_id = resolved;
}
async function makeContext(engine: BrainEngine, params: Record<string, unknown>): Promise<OperationContext> {
// v0.31.8 (D11): resolve sourceId via the canonical 6-tier chain. Honors
// --source / GBRAIN_SOURCE / .gbrain-source / path-match / brain default /
// 'default'. Wrapped in try/catch so a doctor / single-source brain that
// never set up sources still returns 'default' silently.
let sourceId: string | undefined;
// #2561: when the source resolved via a NON-explicit tier (path-match /
// brain default / sole-non-default / seed default), unqualified search-shaped
// reads span every `config.federated = true` source. Computed here (the
// trusted local boundary) and consumed by federatedSearchScope in
// operations.ts, which additionally gates on ctx.remote === false.
let localFederated: string[] | undefined;
try {
const { resolveSourceWithTier, localFederatedSourceIds } = await import('./core/source-resolver.ts');
const { resolveSourceId } = await import('./core/source-resolver.ts');
// params.source is set when a CLI flag was parsed for the op (rare; most
// CLI ops don't take --source). Falls through to env/dotfile/path-match.
const explicit = (params.source as string | undefined) ?? null;
const resolved = await resolveSourceWithTier(engine, explicit);
sourceId = resolved.source_id;
localFederated = await localFederatedSourceIds(engine, resolved.source_id, resolved.tier);
sourceId = await resolveSourceId(engine, explicit);
} catch {
// Source resolution failed (e.g. sources table doesn't exist on a fresh
// pre-init brain). Leave sourceId unset; engine read methods fall through
@@ -908,7 +834,6 @@ async function makeContext(engine: BrainEngine, params: Record<string, unknown>)
// table). Matches dispatch.ts's auto-fill so the contract holds across
// every transport.
sourceId: sourceId ?? 'default',
...(localFederated ? { localFederatedSourceIds: localFederated } : {}),
};
}
@@ -1010,10 +935,7 @@ export function formatResult(opName: string, result: unknown): string {
lines.push(`Link coverage (entities): ${(h.link_coverage * 100).toFixed(1)}%`);
}
if (h.timeline_coverage !== undefined) {
lines.push(`Timeline coverage (entity pages): ${(h.timeline_coverage * 100).toFixed(1)}%`);
}
if (h.timeline_coverage_score !== undefined) {
lines.push(`Timeline density (all pages): ${h.timeline_coverage_score}/15 (whole-brain brain-score component)`);
lines.push(`Timeline coverage (entities): ${(h.timeline_coverage * 100).toFixed(1)}%`);
}
if (Array.isArray(h.most_connected) && h.most_connected.length > 0) {
lines.push('Most connected entities:');
@@ -1835,11 +1757,6 @@ async function handleCliOnly(command: string, args: string[]) {
await runOrphans(engine, args);
break;
}
case 'maintain': {
const { runMaintain } = await import('./commands/maintain.ts');
await runMaintain(engine, args);
break;
}
// v0.32.7 CJK wave — post-upgrade markdown re-chunk sweep.
// v0.36 Phase 3 wave — `gbrain reindex --multimodal` re-embeds content_chunks
// into the unified Voyage multimodal-3 column.
+6 -14
View File
@@ -133,15 +133,14 @@ function indexCompleted(entries: CompletedMigrationEntry[]): CompletedIndex {
* Returns the resolved status for a migration based on its entries.
*
* Semantics (Bug 3 keep "complete wins" safety):
* - If the latest entry is `retry`, the version is pending. This is the
* explicit escape hatch written by `--force-retry`, and it overrides an
* earlier `complete` entry without hand-editing the ledger.
* - Otherwise, if any entry is `complete`, the version is complete.
* - If any entry is `complete`, the version is complete. Terminal state.
* - Otherwise, if the latest entry is `retry`, the version is pending
* (user requested a fresh attempt).
* - Otherwise, if any entry is `partial`, the version is partial.
* - Otherwise, pending.
*
* `complete` never regresses accidentally. A later `partial` append cannot
* undo a completed migration; only a trailing, explicit `retry` marker can.
* `complete` never regresses. A later accidental `partial` append cannot
* undo a completed migration.
*/
function statusForVersion(
version: string,
@@ -149,9 +148,9 @@ function statusForVersion(
): 'complete' | 'partial' | 'pending' | 'wedged' {
const entries = idx.byVersion.get(version) ?? [];
if (entries.length === 0) return 'pending';
if (entries.some(e => e.status === 'complete')) return 'complete';
const latest = entries[entries.length - 1];
if (latest.status === 'retry') return 'pending';
if (entries.some(e => e.status === 'complete')) return 'complete';
// Bug 3 attempt cap — count consecutive partials from the end (stopping
// at any 'retry' or 'complete'). If we hit MAX_CONSECUTIVE_PARTIALS,
// the migration is wedged and needs explicit --force-retry to try again.
@@ -439,13 +438,6 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
const result = await m.orchestrator(orchestratorOptsFrom(cli));
if (result.status === 'failed') {
console.error(`Migration v${m.version} reported status=failed.`);
// Surface each failed phase's detail — the ledger records it, but
// the operator needs it on stderr to act (#921).
for (const p of result.phases) {
if (p.status === 'failed') {
console.error(` phase ${p.name}: ${p.detail ?? '(no detail)'}`);
}
}
// Record the attempt as 'partial' (not 'complete') so the cap counts
// it. Don't let a failed orchestrator look like it never ran.
try {
-60
View File
@@ -515,60 +515,6 @@ async function registerClient(name: string, args: string[]) {
}
}
/**
* v0.42.x (#1914): rescope an existing OAuth client's write source and/or
* federated read scope. This is the operator surface the DCR registration
* comment promised ("rescope via the CLI later") DCR clients land with
* source_id='default' / federated_read=['default'] and must not self-widen,
* so widening happens here (trusted local CLI) or via the requireAdmin
* /admin/api/rescope-client endpoint.
*/
async function rescopeClient(clientId: string, args: string[]) {
const usage = 'Usage: auth rescope-client <client_id> [--source SOURCE] [--federated-read SRC1,SRC2,...]';
if (!clientId) {
console.error(usage);
process.exit(1);
}
let sourceId: string | undefined;
let federatedRead: string[] | undefined;
for (let i = 0; i < args.length; i += 2) {
const flag = args[i];
const value = args[i + 1];
if (value === undefined || value.startsWith('--')) {
console.error(`Error: ${flag} requires a value`);
console.error(usage);
process.exit(1);
}
if (flag === '--source') sourceId = value;
else if (flag === '--federated-read') {
federatedRead = value.split(',').map(s => s.trim()).filter(Boolean);
} else {
console.error(`Error: Unknown flag: ${flag}`);
console.error(usage);
process.exit(1);
}
}
if (sourceId === undefined && federatedRead === undefined) {
console.error('Error: pass --source and/or --federated-read');
console.error(usage);
process.exit(1);
}
try {
await withConfiguredSql(async (sql) => {
const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts');
const provider = new GBrainOAuthProvider({ sql });
const result = await provider.rescopeClient(clientId, { sourceId, federatedRead });
console.log(`OAuth client rescoped: "${result.clientName}" (${result.clientId})\n`);
console.log(` Write source: ${result.sourceId}`);
console.log(` Federated reads: ${result.federatedRead.join(', ') || '<none>'}`);
console.log('\nTakes effect on the client\'s next request (existing tokens included).');
});
} catch (e: any) {
console.error('Error:', e.message);
process.exit(1);
}
}
/**
* Entry point for the `gbrain auth` CLI subcommand. Also reused by the
* direct-script path (see bottom of file) so `bun run src/commands/auth.ts`
@@ -610,7 +556,6 @@ export async function runAuth(args: string[]): Promise<void> {
return;
}
case 'register-client': await registerClient(rest[0], rest.slice(1)); return;
case 'rescope-client': await rescopeClient(rest[0], rest.slice(1)); return;
case 'revoke-client': await revokeClient(rest[0]); return;
case 'test': {
const tokenIdx = rest.indexOf('--token');
@@ -648,11 +593,6 @@ Usage:
--bound-slug-prefixes <prefix1,prefix2> Bind submit_agent writes to slug prefixes
--bound-max-concurrent <n> Bound submit_agent concurrency (default: 1)
--budget-usd-per-day <usd> Bound submit_agent daily spend cap
gbrain auth rescope-client <client_id> [options] Change an existing client's source scope (e.g. a DCR
client stuck on the 'default' source). Only the flags
you pass change; the other axis is left as-is.
--source <id> New write source
--federated-read <id1,id2,...> New read-scope source list
gbrain auth revoke-client <client_id> Hard-delete an OAuth 2.1 client (cascades to tokens + codes)
gbrain auth test <url> --token <token> Smoke-test a remote MCP server
`);
+8 -140
View File
@@ -38,7 +38,6 @@ import { logSelfUpgrade } from '../core/audit/self-upgrade-audit.ts';
import { detectInstallMethod } from './upgrade.ts';
import { evaluateQuietHours } from '../core/minions/quiet-hours.ts';
import { inspectLock } from '../core/db-lock.ts';
import { registerCleanup } from '../core/process-cleanup.ts';
/**
* v0.37.7.0 #1162 classify autopilot reconnect-loop errors.
@@ -434,37 +433,6 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
let stopping = false;
let childSupervisor: ChildWorkerSupervisor | null = null;
// #1872: graceful engine shutdown. On PGLite the cycle steps run INLINE in
// this process, so a hard `process.exit` mid-write (systemctl stop →
// SIGTERM) kills WASM Postgres with the WAL dirty and can corrupt the
// brain. Two exit paths must both close the engine:
// - autopilot's own shutdown() below (owns SIGINT + internal stops like
// max_crashes / cycle-failure-cap), and
// - process-cleanup's SIGTERM handler (installed at cli.ts module load;
// it runs the cleanup registry with a 3s deadline and then exits) —
// which is why closeEngine is ALSO registered there.
// closeEngine aborts the in-flight inline cycle (runCycle checks the
// signal between phases and threads it into phase sub-work), gives it a
// short bounded window to wind down, then disconnects. PGLite's
// disconnect() drains the pending query and checkpoints before closing;
// a second call is a no-op (disconnect snapshots + nulls the handle), so
// both paths firing is safe.
const shutdownAbort = new AbortController();
let inflightInlineCycle: Promise<unknown> | null = null;
const closeEngine = async () => {
shutdownAbort.abort(new Error('autopilot shutdown'));
if (inflightInlineCycle) {
// ponytail: 2s cap keeps us inside process-cleanup's 3s deadline; a
// between-phase abort resolves instantly, a mid-phase one may not.
await Promise.race([
inflightInlineCycle.catch(() => { /* cycle errors already logged by the loop */ }),
new Promise((r) => setTimeout(r, 2_000)),
]);
}
try { await engine.disconnect(); } catch { /* best-effort */ }
};
const deregisterEngineClose = registerCleanup('autopilot-engine-close', closeEngine);
if (spawnManagedWorker) {
const cliPath = resolveGbrainCliPath();
// Cgroup-aware auto-sized RSS watchdog cap (issue #1678). The old flat
@@ -552,10 +520,6 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
childSupervisor.killChild('SIGKILL');
}
}
// #1872: abort the in-flight inline cycle and close the engine BEFORE
// process.exit — a hard exit mid-write corrupts PGLite's WASM Postgres.
await closeEngine();
deregisterEngineClose();
try { unlinkSync(lockPath); } catch { /* already gone */ }
process.exit(0);
};
@@ -563,9 +527,6 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
process.on('SIGINT', () => { void shutdown('SIGINT'); });
let consecutiveErrors = 0;
// Parser-probe fixture warning is once-per-process, not once-per-cycle
// (compiled-binary installs have no source tree; don't spam the log).
let parserProbeFixtureWarned = false;
// v0.37.7.0 #1162 — counter for consecutive reconnect failures.
// Reset on every successful health probe or reconnect. Threshold
// controlled by GBRAIN_AUTOPILOT_MAX_RECONNECT_FAILS env (default 30).
@@ -864,10 +825,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
{
queue: 'default',
idempotency_key: idemKey,
// issue #3218: the handler now throws on an
// all-provider-failed batch, so give the queue's
// backoff a chance (was 1 — dead-lettered instantly).
max_attempts: 3,
max_attempts: 1,
timeout_ms: timeoutMs,
},
{ allowProtectedSubmit: true },
@@ -907,19 +865,9 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
} catch {
embeddingModel = (await engine.getConfig('embedding_model')) ?? undefined;
}
// #2662 (codex round-3): HOSTED_EMBED_KEY_CONFIG entries are keys
// buildGatewayConfig folds from the FILE plane only — `gbrain config
// set <key> X` writes the DB plane, which never reaches the gateway
// for these fields. Reading via engine.getConfig() here (DB plane)
// would report a provider "configured" from a DB-only key that the
// gateway can never actually use, dispatching a doomed embed job.
// Read the same file-plane source context.ts (doctor) reads instead,
// so autopilot and doctor agree with what the gateway can see.
const { loadConfigFileOnly } = await import('../core/config.ts');
const fileCfg = loadConfigFileOnly() as Record<string, unknown> | null;
const embedKeyCfg: Record<string, unknown> = {};
const embedKeyCfg: Record<string, string | null> = {};
for (const field of Object.values(HOSTED_EMBED_KEY_CONFIG)) {
embedKeyCfg[field] = fileCfg?.[field];
embedKeyCfg[field] = await engine.getConfig(field);
}
const ctx = {
repoPath,
@@ -1060,21 +1008,16 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
// path's phase set). Now both converge on the same primitive.
try {
const { runCycle } = await import('../core/cycle.ts');
// #1872: track the promise so closeEngine can drain it on shutdown,
// and pass the abort signal so the cycle winds down between phases.
const cyclePromise = runCycle(engine, {
const report = await runCycle(engine, {
brainDir: repoPath,
// Autopilot daemon path: pulls by default (matches
// pre-v0.17 autopilot behavior). CLI dream defaults false
// for cron safety; that choice is scoped to dream only.
pull: true,
signal: shutdownAbort.signal,
yieldBetweenPhases: async () => {
await new Promise(r => setImmediate(r));
},
});
inflightInlineCycle = cyclePromise;
const report = await cyclePromise.finally(() => { inflightInlineCycle = null; });
// Only 'failed' (every attempted phase failed) trips the autopilot
// circuit breaker. 'partial' means at least one phase warned or
// failed while others ran — that's a soft signal, not a fatal
@@ -1130,36 +1073,17 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
// loop. Probe runs even when cycleOk=false (probe may surface signal
// explaining why the cycle is failing).
try {
const { resolveProbeEnabled, resolveProbeMaxUsd, runNightlyQualityProbe } = await import('../core/cycle/nightly-quality-probe.ts');
// Dual-plane read: `gbrain config set` (what the doctor enable hint
// prints) writes the DB plane; ~/.gbrain/config.json is the fallback.
let dbEnabled: string | null = null;
let dbMaxUsd: string | null = null;
try {
dbEnabled = await engine.getConfig('autopilot.nightly_quality_probe.enabled');
dbMaxUsd = await engine.getConfig('autopilot.nightly_quality_probe.max_usd');
} catch { /* DB unavailable → file plane only */ }
const probeEnabled = resolveProbeEnabled(dbEnabled, cfg?.autopilot?.nightly_quality_probe?.enabled);
const probeEnabled = cfg?.autopilot?.nightly_quality_probe?.enabled === true;
if (probeEnabled) {
const { runNightlyQualityProbe } = await import('../core/cycle/nightly-quality-probe.ts');
const { runLongMemEvalForProbe, runCrossModalBatchForProbe } = await import('../core/cycle/nightly-probe-adapters.ts');
const { isAvailable } = await import('../core/ai/gateway.ts');
const { existsSync } = await import('node:fs');
const { fileURLToPath } = await import('node:url');
const { join } = await import('node:path');
const maxUsd = resolveProbeMaxUsd(dbMaxUsd, cfg?.autopilot?.nightly_quality_probe?.max_usd);
// The committed fixture (test/fixtures/longmemeval-nightly.jsonl)
// lives in the gbrain PACKAGE, not the brain repo — repoPath is
// sync.repo_path (the user's brain), where the fixture never
// exists, so the probe error'd on every real install. Resolve the
// package root from the module location; keep repoPath as the
// fallback for setups that vendor the fixture into the brain repo.
const pkgRoot = fileURLToPath(new URL('../..', import.meta.url));
const fixtureAtPkgRoot = existsSync(join(pkgRoot, 'test', 'fixtures', 'longmemeval-nightly.jsonl'));
const maxUsd = Number(cfg?.autopilot?.nightly_quality_probe?.max_usd ?? 5);
await runNightlyQualityProbe({
isEnabled: () => true, // already gated above; phase re-checks for defense-in-depth
hasEmbeddingProvider: () => isAvailable('embedding'),
resolveMaxUsd: () => maxUsd,
resolveRepoRoot: () => (fixtureAtPkgRoot ? pkgRoot : repoPath ?? gbrainHomePath('.')),
resolveRepoRoot: () => repoPath ?? gbrainHomePath('.'),
runLongMemEval: runLongMemEvalForProbe,
runCrossModalBatch: runCrossModalBatchForProbe,
now: () => new Date(),
@@ -1171,62 +1095,6 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
// informational; autopilot loop continues.
}
// 4.6 — Nightly conversation-parser probe (v0.41.16.0 phase module;
// the scheduler wire-up was deferred at ship and is added here). Same
// posture as 4.5: the phase owns its gates (enabled/mode-gate, LLM
// key), the wiring owns invocation + the audit row, and a probe
// failure NEVER crashes the autopilot loop. Per D10 the probe is
// default-ON for search.mode=tokenmax, opt-in otherwise.
try {
const { runConversationParserNightlyProbe } = await import('../core/conversation-parser/nightly-probe.ts');
const { logParserProbeEvent, parserProbeRanWithin } = await import('../core/audit-parser-probe.ts');
const { isAvailable } = await import('../core/ai/gateway.ts');
const { existsSync } = await import('node:fs');
const { fileURLToPath } = await import('node:url');
const { join } = await import('node:path');
// Flag reads dual-plane: the DB row (`gbrain config set …`) wins,
// ~/.gbrain/config.json is the fallback. search.mode lives on the
// DB plane only (mode.ts owns it).
let parserDbEnabled: string | null = null;
let dbSearchMode: string | null = null;
try {
parserDbEnabled = await engine.getConfig('autopilot.conversation_parser_probe.enabled');
dbSearchMode = await engine.getConfig('search.mode');
} catch { /* DB unavailable → file plane only */ }
const parserEnabled = parserDbEnabled != null
? parserDbEnabled === 'true'
: cfg?.autopilot?.conversation_parser_probe?.enabled === true;
const searchMode = dbSearchMode ?? '';
// Fixtures are committed in the gbrain package (test/fixtures/…),
// NOT the brain repo — resolve from the module location. Compiled
// binaries carry no source tree: skip quietly instead of writing
// failure rows that would flip doctor to WARN on every binary install.
const pkgRoot = fileURLToPath(new URL('../..', import.meta.url));
const fixturePath = join(pkgRoot, 'test', 'fixtures', 'conversation-formats', 'all.jsonl');
const adversarialPath = join(pkgRoot, 'test', 'fixtures', 'conversation-formats', 'adversarial.jsonl');
const shouldInvoke = parserEnabled || searchMode === 'tokenmax';
if (shouldInvoke && existsSync(fixturePath) && existsSync(adversarialPath)) {
const result = await runConversationParserNightlyProbe({
isEnabled: () => parserEnabled,
searchMode: () => searchMode,
hasLlmKey: () => isAvailable('chat'),
resolveFixturePath: () => fixturePath,
resolveAdversarialPath: () => adversarialPath,
now: () => new Date(),
shouldSkipForRateLimit: () => parserProbeRanWithin(24 * 60 * 60 * 1000),
});
// rate_limited is a non-run: the loop ticks every few minutes, so
// logging every skip would flood the audit file with no-signal rows.
if (result.outcome !== 'rate_limited') logParserProbeEvent(result);
} else if (shouldInvoke && !parserProbeFixtureWarned) {
parserProbeFixtureWarned = true;
console.error(`[parser-probe] fixtures not found under ${pkgRoot}; skipping (probe needs a source-checkout install)`);
}
} catch (e) {
logError('autopilot.parser_probe', e);
// Informational, like 4.5: do NOT bump consecutiveErrors.
}
// Wait for next cycle
await new Promise(r => setTimeout(r, interval * 1000));
}
+8 -40
View File
@@ -5,8 +5,8 @@
* checks if back-links exist, and optionally creates them.
*
* Usage:
* gbrain check-backlinks check [dir] [--dir <brain-dir>] # report missing back-links
* gbrain check-backlinks fix [dir] [--dir <brain-dir>] # create missing back-links
* gbrain check-backlinks check [--dir <brain-dir>] # report missing back-links
* gbrain check-backlinks fix [--dir <brain-dir>] # create missing back-links
* gbrain check-backlinks fix --dry-run # preview fixes
*/
@@ -201,40 +201,6 @@ export interface BacklinksResult {
dryRun: boolean;
}
export interface ParsedBacklinksArgs {
subcommand: string | undefined;
brainDir: string;
dryRun: boolean;
}
export function parseBacklinksArgs(args: string[]): ParsedBacklinksArgs {
const subcommand = args[0];
const dryRun = args.includes('--dry-run');
const dirIdx = args.indexOf('--dir');
const flagDir = dirIdx >= 0 && args[dirIdx + 1] && !args[dirIdx + 1].startsWith('--')
? args[dirIdx + 1]
: undefined;
let positionalDir: string | undefined;
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (arg === '--dir') {
i++;
continue;
}
if (arg === '--dry-run') continue;
if (arg.startsWith('--')) continue;
positionalDir = arg;
break;
}
return {
subcommand,
brainDir: flagDir ?? positionalDir ?? '.',
dryRun,
};
}
/**
* Library-level backlinks check/fix. Throws on validation errors; returns a
* structured result so Minions handlers + autopilot-cycle can surface counts.
@@ -270,14 +236,16 @@ export async function runBacklinksCore(opts: BacklinksOpts): Promise<BacklinksRe
}
export async function runBacklinks(args: string[]) {
const { subcommand, brainDir, dryRun } = parseBacklinksArgs(args);
const subcommand = args[0];
const dirIdx = args.indexOf('--dir');
const brainDir = dirIdx >= 0 ? args[dirIdx + 1] : '.';
const dryRun = args.includes('--dry-run');
if (!subcommand || !['check', 'fix'].includes(subcommand)) {
console.error('Usage: gbrain check-backlinks <check|fix> [dir] [--dir <brain-dir>] [--dry-run]');
console.error('Usage: gbrain check-backlinks <check|fix> [--dir <brain-dir>] [--dry-run]');
console.error(' check Report missing back-links');
console.error(' fix Create missing back-links (appends to Timeline)');
console.error(' dir Brain directory (default: current directory)');
console.error(' --dir Brain directory override');
console.error(' --dir Brain directory (default: current directory)');
console.error(' --dry-run Preview fixes without writing');
process.exit(1);
}
+3 -10
View File
@@ -23,7 +23,6 @@ import { runPhaseCalibrationProfile } from '../core/cycle/calibration-profile.ts
import { sourceScopeOpts, type OperationContext } from '../core/operations.ts';
import type { GBrainConfig } from '../core/config.ts';
import { GBrainError } from '../core/types.ts';
import { resolveOwnerHolder } from '../core/owner-holder.ts';
export interface CalibrationProfileRow {
/** BIGSERIAL string (postgres.js int8 wire shape; never Number() int8
@@ -168,10 +167,7 @@ export async function runCalibration(
config: GBrainConfig,
): Promise<void> {
const { opts } = parseArgs(args);
const holder = resolveOwnerHolder({
override: opts.holder,
configValue: await engine.getConfig('emotional_weight.user_holder'),
});
const holder = opts.holder ?? 'garry';
// Resolve --source / GBRAIN_SOURCE / .gbrain-source so the (now reachable, #2035)
// calibration command targets the right source in a multi-source brain instead
// of always reading `default`. No signal → 'default' (prior behavior).
@@ -257,15 +253,12 @@ export async function getCalibrationProfileOp(
ctx: OperationContext,
params: { holder?: string },
): Promise<CalibrationProfileRow | null> {
const holder = resolveOwnerHolder({
override: params.holder,
configValue: await ctx.engine.getConfig('emotional_weight.user_holder'),
});
const holder = params.holder ?? 'garry';
if (typeof holder !== 'string' || holder.length === 0) {
throw new GBrainError(
'INVALID_HOLDER',
'get_calibration_profile.holder must be a non-empty string',
'pass holder="<slug>" or omit to default to the owner holder (config emotional_weight.user_holder, else "self")',
'pass holder="<slug>" or omit to default to "garry"',
);
}
const scope = sourceScopeOpts(ctx);
+2 -6
View File
@@ -233,18 +233,14 @@ export function maybeRewriteSourceFkError(err: unknown, sourceId: string | undef
/**
* Derive a title from the first non-empty, non-`---` line of the body,
* stripping leading markdown heading marks, capped at 80 chars. Truncation
* is codepoint-aware (never splits an astral surrogate pair) and appends an
* ellipsis so a cut title is visibly cut.
* stripping leading markdown heading marks, capped at 80 chars.
* Falls back to 'Capture' when no usable line exists.
*/
function deriveTitle(rawBody: string): string {
const firstLine = rawBody
.split('\n')
.find((l) => l.trim().length > 0 && l.trim() !== '---') ?? '';
const stripped = firstLine.replace(/^#+\s*/, '');
const cps = [...stripped];
return (cps.length > 80 ? cps.slice(0, 79).join('') + '…' : stripped) || 'Capture';
return firstLine.replace(/^#+\s*/, '').slice(0, 80) || 'Capture';
}
/**
-10
View File
@@ -37,19 +37,9 @@ export async function findCodeDef(
// trigger) are first-class definitions in the SQL sense. The chunker's
// normalizeSymbolType maps create_table → 'table' etc, so adding the SQL
// kinds here is what makes `gbrain code-def users` work against SQL.
// Method-level + member definitions. normalizeSymbolType only canonicalizes
// some node types; the rest fall through `type.replace(/_/g, ' ')`, so
// tree-sitter's method_declaration → 'method declaration', struct_specifier →
// 'struct specifier', protocol_declaration → 'protocol declaration', etc.
// Without these, code-def is blind to every method, constructor, field, C
// struct, and Swift protocol — which is most of an OO codebase. The plain
// 'struct' entry above never matched for the same reason (C emits the
// 'struct specifier' fallback form).
const DEF_TYPES = [
'function', 'class', 'interface', 'type', 'enum', 'struct', 'trait', 'module', 'contract',
'table', 'view', 'index', 'procedure', 'schema', 'database', 'trigger',
'method declaration', 'method definition', 'constructor declaration',
'field declaration', 'field definition', 'struct specifier', 'protocol declaration',
];
const params: unknown[] = [symbol, limit];
let whereLang = '';
+206 -396
View File
@@ -28,7 +28,6 @@ import type { DbUrlSource } from '../core/config.ts';
import { gbrainPath, loadConfig } from '../core/config.ts';
import { reflexEnabled } from '../core/context/reflex.ts';
import { resolveSocketPath } from '../core/context/resolve-ipc.ts';
import { resolveOwnerHolder } from '../core/owner-holder.ts';
import { homedir } from 'os';
import { dirname, isAbsolute, join, resolve as resolvePath } from 'path';
import { fileURLToPath } from 'url';
@@ -529,61 +528,6 @@ export async function childTableOrphansCheck(engine: BrainEngine): Promise<Check
};
}
/**
* Raw-source persistence guarantee (#1978, warn-only v1).
*
* Invariant: every synthesized/derived page (dream_generated:true frontmatter
* or type:synthesis) must either carry a raw trace or declare an explicit
* exemption. Accepted traces:
* - frontmatter key `raw_trace` / `raw_source` / `source_uri`
* - an attached `raw_data` row
* - `synthesis_evidence` rows (think-op citations)
* - explicit `raw_trace_exempt: true` (reason in `raw_trace_exempt_reason`)
*
* v1 is deliberately warn-only no write path is blocked. Escalation to
* fail-closed enforcement in the synthesis/import write paths is the v2
* follow-up once real brains run clean.
*
* Pure helper (engine.executeRaw only) for parity with
* childTableOrphansCheck so tests can target it directly.
*/
export async function rawProvenanceCheck(engine: BrainEngine): Promise<Check> {
const where = `
p.deleted_at IS NULL
AND (COALESCE(p.frontmatter->>'dream_generated', '') = 'true' OR p.type = 'synthesis')
AND NOT (COALESCE(p.frontmatter, '{}'::jsonb) ?| ARRAY['raw_trace', 'raw_source', 'source_uri', 'raw_trace_exempt'])
AND NOT EXISTS (SELECT 1 FROM raw_data rd WHERE rd.page_id = p.id)
AND NOT EXISTS (SELECT 1 FROM synthesis_evidence se WHERE se.synthesis_page_id = p.id)`;
try {
const rows = await engine.executeRaw<{ n: string | number }>(
`SELECT COUNT(*)::int AS n FROM pages p WHERE ${where}`,
);
const n = Number(rows[0]?.n ?? 0);
if (n === 0) {
return {
name: 'raw_provenance',
status: 'ok',
message: 'All synthesized pages carry a raw trace or explicit exemption',
};
}
const sample = await engine.executeRaw<{ slug: string }>(
`SELECT p.slug FROM pages p WHERE ${where} ORDER BY p.slug LIMIT 5`,
);
const slugs = sample.map(r => r.slug).join(', ');
return {
name: 'raw_provenance',
status: 'warn',
message:
`${n} synthesized page(s) lack a raw trace (no raw_trace/raw_source/source_uri frontmatter, ` +
`raw_data row, or synthesis evidence) and carry no raw_trace_exempt marker. e.g. ${slugs}. ` +
`Fix: stamp raw_source (path/URI of the source material) or raw_trace_exempt: true + ` +
`raw_trace_exempt_reason in frontmatter. Warn-only (#1978).`,
};
} catch {
return { name: 'raw_provenance', status: 'warn', message: 'Could not check raw provenance (older schema?)' };
}
}
export async function doctorReportRemote(engine: BrainEngine): Promise<DoctorReport> {
const checks: Check[] = [];
@@ -813,7 +757,31 @@ export async function doctorReportRemote(engine: BrainEngine): Promise<DoctorRep
// 5. Queue health (Postgres-only). PGLite has no minion_jobs in the same
// shape; skip the check there with an informational message.
checks.push(await computeQueueHealthCheck(engine));
if (engine.kind === 'postgres') {
try {
// issue #1801: column is `status`, not `state` (schema.sql:780). The
// pre-fix query errored every run and the catch silently returned "No
// queue activity," so this remote/thin-client check was a no-op.
const rows = await engine.executeRaw<{ stalled: string | number }>(
`SELECT COUNT(*) AS stalled FROM minion_jobs
WHERE status = 'active'
AND started_at IS NOT NULL
AND started_at < NOW() - INTERVAL '1 hour'`,
);
const stalled = Number(rows[0]?.stalled ?? 0);
checks.push({
name: 'queue_health',
status: stalled === 0 ? 'ok' : 'warn',
message: stalled === 0
? 'No stalled active jobs'
: `${stalled} active job(s) stalled > 1h — \`gbrain jobs cancel <id>\` or \`gbrain jobs retry <id>\` on the host`,
});
} catch {
checks.push({ name: 'queue_health', status: 'ok', message: 'No queue activity' });
}
} else {
checks.push({ name: 'queue_health', status: 'ok', message: 'PGLite — no queue to check' });
}
// issue #1801 — wedged_queue (cross-surface parity with buildChecks).
checks.push(await computeWedgedQueueCheck(engine));
@@ -1319,19 +1287,14 @@ export async function checkAbandonedThreads(engine: BrainEngine): Promise<Check>
/**
* calibration_freshness: warns when the active calibration profile is
* older than 7 days (configurable). Default holder resolves via resolveOwnerHolder
* (config emotional_weight.user_holder, else 'self'). Multi-source
* older than 7 days (configurable). Default holder 'garry'. Multi-source
* brains see one row per source; this check uses the most recent across
* all sources.
*/
export async function checkCalibrationFreshness(engine: BrainEngine): Promise<Check> {
try {
const ownerHolder = resolveOwnerHolder({
configValue: await engine.getConfig('emotional_weight.user_holder'),
});
const rows = await engine.executeRaw<{ generated_at: Date | null }>(
`SELECT MAX(generated_at) AS generated_at FROM calibration_profiles WHERE holder = $1`,
[ownerHolder],
`SELECT MAX(generated_at) AS generated_at FROM calibration_profiles WHERE holder = 'garry'`,
);
const generated = rows[0]?.generated_at;
if (!generated) {
@@ -1588,24 +1551,6 @@ export async function checkRerankerHealth(engine: BrainEngine): Promise<Check> {
};
}
// Historical #2059 rows were logged as `unknown` before missing reranker
// auth was classified at the gateway. Surface repeated unknowns instead of
// reporting "ok" while every rerank fails open.
const unknownFails = failures.filter((f) => f.reason === 'unknown');
if (unknownFails.length >= 3) {
const setupHint = unknownFails.some((f) => {
const summary = String(f.error_summary ?? '');
return summary.includes('ZEROENTROPY_API_KEY') || summary.toLowerCase().includes('api key');
})
? ' Fix: verify ZEROENTROPY_API_KEY and run `gbrain models doctor`.'
: '';
return {
name: 'reranker_health',
status: 'warn',
message: `${unknownFails.length} unknown reranker failure(s) in last 7 days.${setupHint}`,
};
}
return {
name: 'reranker_health',
status: 'ok',
@@ -1640,174 +1585,6 @@ export async function checkRerankerHealth(engine: BrainEngine): Promise<Check> {
* Also surfaces (codex M-10): runs resolveBulkRetryOpts(process.env) at
* startup so bad GBRAIN_BULK_* config fails at doctor time, not first-retry.
*/
/**
* queue_health: Postgres Minion queue diagnostics.
*
* Includes the original stalled/depth/memory/prompt checks plus the #2557
* no-worker signal: old `embed-backfill` jobs waiting on a queue with no live
* registered worker for that queue. That catches the default deployment shape
* where `sync` enqueues deferred embedding work but the operator never started
* `gbrain jobs work` or a supervisor.
*/
export async function computeQueueHealthCheck(
engine: BrainEngine,
opts: {
waitingDepthThreshold?: number;
oldWaitingHours?: number;
readWorkers?: () => Array<{ queue: string }>;
} = {},
): Promise<Check> {
if (engine.kind === 'pglite') {
return {
name: 'queue_health',
status: 'ok',
message: 'Skipped (PGLite — no multi-process worker surface)',
};
}
try {
// issue #1801: column is `status`, not `state` (schema.sql:780).
const stalledRows: Array<{ id: number; name: string; started_at: string }> =
await engine.executeRaw(
`SELECT id, name, started_at::text AS started_at
FROM minion_jobs
WHERE status = 'active'
AND started_at IS NOT NULL
AND started_at < now() - interval '1 hour'
ORDER BY started_at ASC
LIMIT 5`,
);
const threshold = opts.waitingDepthThreshold
?? _resolveEnvNumber('GBRAIN_QUEUE_WAITING_THRESHOLD', 10);
const depthRows: Array<{ name: string; queue: string; depth: number }> =
await engine.executeRaw(
`SELECT name, queue, count(*)::int AS depth
FROM minion_jobs
WHERE status = 'waiting'
GROUP BY name, queue
HAVING count(*) > $1
ORDER BY depth DESC
LIMIT 5`,
[threshold],
);
const rssKillRows: Array<{ cnt: number }> = await engine.executeRaw(
`SELECT count(*)::int AS cnt
FROM minion_jobs
WHERE status IN ('dead', 'failed')
AND finished_at > now() - interval '24 hours'
AND error_text = 'aborted: watchdog'`,
);
const rssKillCount = Number(rssKillRows[0]?.cnt ?? 0);
const promptTooLongRows: Array<{ cnt: number }> = await engine.executeRaw(
`SELECT count(*)::int AS cnt
FROM minion_jobs
WHERE name = 'subagent'
AND status = 'dead'
AND finished_at > now() - interval '24 hours'
AND error_text LIKE 'prompt_too_long:%'`,
);
const promptTooLongCount = Number(promptTooLongRows[0]?.cnt ?? 0);
const oldWaitingHours = opts.oldWaitingHours
?? _resolveEnvNumber('GBRAIN_QUEUE_NO_WORKER_WARN_HOURS', 1);
const oldWaitingRows: Array<{
name: string;
queue: string;
depth: number;
oldest_age_seconds: number;
}> = await engine.executeRaw(
`SELECT name,
queue,
count(*)::int AS depth,
EXTRACT(EPOCH FROM (now() - min(created_at)))::int AS oldest_age_seconds
FROM minion_jobs
WHERE status = 'waiting'
AND name = 'embed-backfill'
GROUP BY name, queue
HAVING min(created_at) < now() - ($1::text::interval)
ORDER BY oldest_age_seconds DESC
LIMIT 5`,
[`${oldWaitingHours} hours`],
);
let liveWorkerQueues = new Set<string>();
if (oldWaitingRows.length > 0) {
const workers = opts.readWorkers
? opts.readWorkers()
: (await import('../core/minions/worker-registry.ts')).readWorkers();
liveWorkerQueues = new Set(workers.map((w) => w.queue));
}
const problems: string[] = [];
if (stalledRows.length > 0) {
const sample = stalledRows
.map(r => `#${r.id}(${r.name})`)
.join(', ');
problems.push(
`${stalledRows.length} stalled-forever job(s): ${sample}. ` +
`Fix: gbrain jobs get <id> to inspect; gbrain jobs cancel <id> to force-kill.`
);
}
if (depthRows.length > 0) {
const sample = depthRows
.map(r => `${r.name}@${r.queue}=${r.depth}`)
.join(', ');
problems.push(
`waiting-queue depth exceeds ${threshold} for: ${sample}. ` +
`Fix: set maxWaiting on the submitter (or raise GBRAIN_QUEUE_WAITING_THRESHOLD).`
);
}
for (const row of oldWaitingRows) {
if (liveWorkerQueues.has(row.queue)) continue;
const hours = Math.max(1, Math.round(Number(row.oldest_age_seconds ?? 0) / 3600));
problems.push(
`${row.depth} ${row.name} job(s) have waited on queue '${row.queue}' for up to ${hours}h ` +
`and no live worker is registered for that queue. ` +
`Start one with \`gbrain jobs work --queue ${row.queue}\` or ` +
`\`gbrain jobs supervisor start --queue ${row.queue}\`.`
);
}
if (rssKillCount > 0) {
problems.push(
`${rssKillCount} job(s) dead-lettered for RSS-watchdog memory-limit kills in last 24h. ` +
`Fix: raise the limit (e.g. \`gbrain jobs work --max-rss 4096\`) or opt out (\`--max-rss 0\`). ` +
`→ see worker_oom_loop for the cap + fix (the authoritative OOM-loop signal).`
);
}
if (promptTooLongCount > 0) {
problems.push(
`${promptTooLongCount} subagent job(s) dead-lettered with prompt_too_long in last 24h. ` +
`Dream/synthesize transcripts exceeded the model's input context. ` +
`Fix: \`gbrain dream --phase synthesize --dry-run --json\` to identify fat transcripts; ` +
`set \`dream.synthesize.max_prompt_tokens\` to bound the per-chunk budget, or use a ` +
`larger-context model (Opus 4.7 = 1M tokens vs Sonnet 4.6 = 200K).`
);
}
if (problems.length === 0) {
return {
name: 'queue_health',
status: 'ok',
message: `No stalled-forever jobs; no queue over depth ${threshold}; no old embed-backfill jobs without a worker.`,
};
}
return {
name: 'queue_health',
status: 'warn',
message: problems.join(' '),
};
} catch (e) {
return {
name: 'queue_health',
status: 'warn',
message: `queue_health scan skipped: ${e instanceof Error ? e.message : String(e)}`,
};
}
}
/**
* issue #1801 `wedged_queue` check. Surfaces the alive-but-wedged-worker
* signature (a queue with claimable work waiting, zero live-lock active jobs,
@@ -3183,54 +2960,6 @@ function _resolveSyncFreshnessHours(varName: string, fallback: number): number {
* branch (disabled / enabled-no-events / enabled-all-pass / enabled-with-failures)
* without spinning up the audit JSONL or a real config file.
*/
/**
* Pure function form of the conversation_parser_probe_health check.
* Mirrors computeNightlyQualityProbeHealthCheck: skip-with-hint when the
* probe is off and silent, surface the last 7 days of audit events when
* it has run, WARN on any non-pass outcome.
*
* `effectiveEnabled` folds the D10 mode-gate in: explicitly enabled OR
* search.mode=tokenmax (where the probe is default-on).
*/
export function computeConversationParserProbeHealthCheck(
effectiveEnabled: boolean,
events: ReadonlyArray<{ outcome: string; ts: string; reason?: string }>,
): Check {
const name = 'conversation_parser_probe_health';
if (!effectiveEnabled && events.length === 0) {
return {
name,
status: 'ok',
message:
'disabled (opt-in; default-on only for search.mode=tokenmax). Enable with: ' +
'`gbrain config set autopilot.conversation_parser_probe.enabled true`',
};
}
if (events.length === 0) {
return {
name,
status: 'ok',
message: 'enabled but no probe events in the last 7 days (next run by autopilot; fixtures require a source-checkout install).',
};
}
const bad = events.filter(e => e.outcome !== 'pass');
const latest = events[events.length - 1]!;
if (bad.length > 0) {
return {
name,
status: 'warn',
message:
`${bad.length}/${events.length} probe run(s) in the last 7 days did not pass; ` +
`latest: ${latest.outcome}${latest.reason ? ` (${latest.reason})` : ''}`,
};
}
return {
name,
status: 'ok',
message: `${events.length} probe run(s) in the last 7 days, all pass (latest ${latest.ts}).`,
};
}
export function computeNightlyQualityProbeHealthCheck(
probeEnabled: boolean,
events: ReadonlyArray<{ outcome: string; ts: string; detail?: string }>,
@@ -5114,17 +4843,10 @@ export async function buildChecks(
try {
const { readRecentQualityProbeEvents } = await import('../core/audit-quality-probe.ts');
const { loadConfig } = await import('../core/config.ts');
const { resolveProbeEnabled } = await import('../core/cycle/nightly-quality-probe.ts');
let probeEnabled = false;
try {
// Dual-plane read, matching the autopilot gate: the DB row (what the
// enable hint's `gbrain config set` writes) wins; file plane fallback.
let dbVal: string | null = null;
try {
dbVal = engine ? await engine.getConfig('autopilot.nightly_quality_probe.enabled') : null;
} catch { /* DB unavailable → file plane only */ }
const cfg = loadConfig();
probeEnabled = resolveProbeEnabled(dbVal, (cfg as any)?.autopilot?.nightly_quality_probe?.enabled);
probeEnabled = Boolean((cfg as any)?.autopilot?.nightly_quality_probe?.enabled);
} catch { /* config unavailable → treat as disabled */ }
const events = readRecentQualityProbeEvents(7);
const check = computeNightlyQualityProbeHealthCheck(probeEnabled, events);
@@ -5241,7 +4963,8 @@ export async function buildChecks(
message:
`${unmatched}/${sample.length} conversation pages (${unmatchedPct.toFixed(1)}%) match NO built-in pattern. ` +
`Breakdown: ${breakdown}. ` +
`Investigate: gbrain conversation-parser scan <slug>`,
`Investigate: gbrain conversation-parser scan <slug> | ` +
`Enable LLM fallback (opt-in): gbrain config set conversation_parser.llm_fallback_enabled true`,
});
} else {
checks.push({
@@ -5307,29 +5030,19 @@ export async function buildChecks(
// 3d.5 v0.41.13.0 — conversation_parser_probe_health. Mode-gated
// per D10: ON when search.mode=tokenmax, opt-in for other modes.
// Surfaces the last 7 days of nightly-probe audit events; warn on any
// non-pass outcome (fail / budget_exceeded / adversarial_false_positive).
// (Until the autopilot wire-up this was a hardcoded "Skipped" stub.)
try {
const { readRecentParserProbeEvents } = await import('../core/audit-parser-probe.ts');
let parserProbeEnabled = false;
try {
let dbVal: string | null = null;
let dbMode: string | null = null;
try {
dbVal = engine ? await engine.getConfig('autopilot.conversation_parser_probe.enabled') : null;
dbMode = engine ? await engine.getConfig('search.mode') : null;
} catch { /* DB unavailable → file plane only */ }
const { loadConfig } = await import('../core/config.ts');
const fileVal = (loadConfig() as any)?.autopilot?.conversation_parser_probe?.enabled;
const flagOn = dbVal != null ? dbVal === 'true' : fileVal === true;
parserProbeEnabled = flagOn || dbMode === 'tokenmax';
} catch { /* config unavailable → treat as disabled */ }
const parserEvents = readRecentParserProbeEvents(7);
checks.push(computeConversationParserProbeHealthCheck(parserProbeEnabled, parserEvents));
} catch {
// Best-effort; audit-log read failure shouldn't stop doctor.
}
// Surface the last 7 days of nightly-probe events; warn on FAIL /
// BUDGET_EXCEEDED / adversarial_false_positive.
//
// v0.41.13.0 ships the probe as opt-in (autopilot wiring deferred
// to T7 in the cathedral plan); this check skips with an enable
// hint until the probe has at least one audit event written.
checks.push({
name: 'conversation_parser_probe_health',
status: 'ok',
message:
'Skipped (nightly probe is opt-in; enable with ' +
'`gbrain config set autopilot.conversation_parser_probe.enabled true`)',
});
// 3e. home_dir_in_worktree (v0.35.8.0). Walks up from `gbrainPath()`
// looking for a `.git` directory OR file. If found, warns: `~/.gbrain/`
@@ -6155,12 +5868,12 @@ export async function buildChecks(
message: `Only code/test fixture entity pages found (${entityCount}); graph_coverage not applicable`,
});
} else if (linkCoverage >= 0.5 && timelineCoverage >= 0.5) {
checks.push({ name: 'graph_coverage', status: 'ok', message: `Entity link coverage ${linkPct}%, entity timeline coverage ${timelinePct}%` });
checks.push({ name: 'graph_coverage', status: 'ok', message: `Entity link coverage ${linkPct}%, timeline ${timelinePct}%` });
} else {
checks.push({
name: 'graph_coverage',
status: 'warn',
message: `Entity link coverage ${linkPct}%, entity timeline coverage ${timelinePct}% (${eligibleEntityCount} entity pages). Run: gbrain extract all`,
message: `Entity link coverage ${linkPct}%, timeline ${timelinePct}% (${eligibleEntityCount} entity pages). Run: gbrain extract all`,
});
}
@@ -6172,7 +5885,7 @@ export async function buildChecks(
const parts = [
`embed ${health.embed_coverage_score}/35`,
`links ${health.link_density_score}/25`,
`timeline density (all pages) ${health.timeline_coverage_score}/15`,
`timeline ${health.timeline_coverage_score}/15`,
`orphans ${health.no_orphans_score}/15`,
`dead-links ${health.no_dead_links_score}/10`,
];
@@ -6345,12 +6058,6 @@ export async function buildChecks(
progress.heartbeat('child_table_orphans');
checks.push(await childTableOrphansCheck(engine));
// 10d. Raw-source persistence guarantee (#1978, warn-only v1).
// Every synthesized/derived page must carry a raw trace or an explicit
// exemption. Warn-only in v1 — surfaces violations, blocks nothing.
progress.heartbeat('raw_provenance');
checks.push(await rawProvenanceCheck(engine));
// v0.33: whoknows_health — fixture presence + row count. The eval
// gate itself runs via `gbrain eval whoknows`; this check is the
// "did you do the assignment?" signal.
@@ -7209,12 +6916,159 @@ export async function buildChecks(
}
}
// 11b. Queue health (v0.19.1 queue-resilience wave).
// Postgres-only because PGLite has no multi-process worker surface. Two
// subchecks, both cheap (single SELECT each, status-index-covered):
//
// 1. stalled-forever: any active job whose started_at is > 1h old. The
// incident that motivated this release ran 90+ min before surfacing.
// Surface the ID so the operator can `gbrain jobs get <id>` to inspect
// or `gbrain jobs cancel <id>` to force-kill.
//
// 2. backpressure-missed: per-name waiting depth exceeds the threshold
// (default 10, override via GBRAIN_QUEUE_WAITING_THRESHOLD env). Signal
// that a submitter probably needs maxWaiting set. Bounded by per-name
// aggregation so a single name's pile shows up clearly instead of
// getting lost in the total.
//
// Not included in v0.19.1 (tracked as B7 follow-up): worker-heartbeat
// staleness. It needs a minion_workers table; the lock_until-on-active-jobs
// proxy can't distinguish "no worker" from "worker idle," and a check that
// cries wolf erodes trust in every other doctor check.
progress.heartbeat('queue_health');
const queueHealthHb = startHeartbeat(progress, 'scanning queue health…');
try {
checks.push(await computeQueueHealthCheck(engine));
} finally {
queueHealthHb();
if (engine.kind === 'pglite') {
checks.push({
name: 'queue_health',
status: 'ok',
message: 'Skipped (PGLite — no multi-process worker surface)',
});
} else {
const queueHealthHb = startHeartbeat(progress, 'scanning queue health…');
try {
const sql = db.getConnection();
// Subcheck 1: stalled-forever active jobs (>1h wall-clock).
const stalledRows: Array<{ id: number; name: string; started_at: string }> = await sql`
SELECT id, name, started_at::text AS started_at
FROM minion_jobs
WHERE status = 'active'
AND started_at IS NOT NULL
AND started_at < now() - interval '1 hour'
ORDER BY started_at ASC
LIMIT 5
`;
// Subcheck 2: per-name waiting depth exceeds threshold.
const rawThreshold = process.env.GBRAIN_QUEUE_WAITING_THRESHOLD;
const parsedThreshold = rawThreshold ? parseInt(rawThreshold, 10) : 10;
const threshold = Number.isFinite(parsedThreshold) && parsedThreshold >= 1
? parsedThreshold
: 10;
const depthRows: Array<{ name: string; queue: string; depth: number }> = await sql`
SELECT name, queue, count(*)::int AS depth
FROM minion_jobs
WHERE status = 'waiting'
GROUP BY name, queue
HAVING count(*) > ${threshold}
ORDER BY depth DESC
LIMIT 5
`;
// Subcheck 3 (v0.22.14): RSS-watchdog kills in the last 24h. Bare workers
// newly default to --max-rss 2048 (was 0); operators who run large embed
// or import jobs may see kills that didn't happen pre-v0.22.14. We surface
// a hint when this signature appears so the upgrade path is obvious.
// Signature: when the watchdog trips, gracefulShutdown('watchdog') aborts
// in-flight jobs with `new Error('watchdog')`. The worker's failJob path
// (worker.ts:660-664) writes `error_text = 'aborted: watchdog'` for any
// job in-flight at the moment of the kill.
//
// We deliberately DO NOT do a loose `ILIKE '%watchdog%'`:
// 1. Parent jobs that inherit `on_child_fail='fail_parent'` get
// `"child job N failed: aborted: watchdog"` — counting that
// double-counts (child + parent) for one watchdog event.
// 2. Any user error_text containing the word "watchdog" matches.
// Match the exact prefix `'aborted: watchdog'` to scope this purely to
// the worker's own kill signature.
const rssKillRows: Array<{ cnt: number }> = await sql`
SELECT count(*)::int AS cnt
FROM minion_jobs
WHERE status IN ('dead', 'failed')
AND finished_at > now() - interval '24 hours'
AND error_text = 'aborted: watchdog'
`;
const rssKillCount = rssKillRows[0]?.cnt ?? 0;
// Subcheck 4 (v0.30.2): prompt_too_long terminal failures on subagent
// jobs in the last 24h. The dream/synthesize phase classifies Anthropic
// 400 "prompt is too long" responses as UnrecoverableError so they
// dead-letter on first attempt instead of clogging the queue with
// max_stalled retries. Surface count + fix hint when present.
const promptTooLongRows: Array<{ cnt: number }> = await sql`
SELECT count(*)::int AS cnt
FROM minion_jobs
WHERE name = 'subagent'
AND status = 'dead'
AND finished_at > now() - interval '24 hours'
AND error_text LIKE 'prompt_too_long:%'
`;
const promptTooLongCount = promptTooLongRows[0]?.cnt ?? 0;
const problems: string[] = [];
if (stalledRows.length > 0) {
const sample = stalledRows
.map(r => `#${r.id}(${r.name})`)
.join(', ');
problems.push(
`${stalledRows.length} stalled-forever job(s): ${sample}. ` +
`Fix: gbrain jobs get <id> to inspect; gbrain jobs cancel <id> to force-kill.`
);
}
if (depthRows.length > 0) {
const sample = depthRows
.map(r => `${r.name}@${r.queue}=${r.depth}`)
.join(', ');
problems.push(
`waiting-queue depth exceeds ${threshold} for: ${sample}. ` +
`Fix: set maxWaiting on the submitter (or raise GBRAIN_QUEUE_WAITING_THRESHOLD).`
);
}
if (rssKillCount > 0) {
problems.push(
`${rssKillCount} job(s) dead-lettered for RSS-watchdog memory-limit kills in last 24h. ` +
`Fix: raise the limit (e.g. \`gbrain jobs work --max-rss 4096\`) or opt out (\`--max-rss 0\`). ` +
`→ see worker_oom_loop for the cap + fix (the authoritative OOM-loop signal).`
);
}
if (promptTooLongCount > 0) {
problems.push(
`${promptTooLongCount} subagent job(s) dead-lettered with prompt_too_long in last 24h. ` +
`Dream/synthesize transcripts exceeded the model's input context. ` +
`Fix: \`gbrain dream --phase synthesize --dry-run --json\` to identify fat transcripts; ` +
`set \`dream.synthesize.max_prompt_tokens\` to bound the per-chunk budget, or use a ` +
`larger-context model (Opus 4.7 = 1M tokens vs Sonnet 4.6 = 200K).`
);
}
if (problems.length === 0) {
checks.push({
name: 'queue_health',
status: 'ok',
message: `No stalled-forever jobs; no queue over depth ${threshold}.`,
});
} else {
checks.push({
name: 'queue_health',
status: 'warn',
message: problems.join(' '),
});
}
} catch (e) {
checks.push({
name: 'queue_health',
status: 'warn',
message: `queue_health scan skipped: ${e instanceof Error ? e.message : String(e)}`,
});
} finally {
queueHealthHb();
}
}
// 11.4 subagent_capability (v0.38 — D7; was subagent_provider in v0.31.12). Surfaces a
@@ -7924,71 +7778,27 @@ export async function runRemediationPlan(
return;
}
for (const line of renderRemediationPlanLines(plan, targetScore)) {
console.log(line);
}
}
/**
* Human-render the remediation plan into a sequence of console lines.
* Exported for unit-test access `runRemediationPlan` consumes it
* verbatim and only adds the JSON-mode short-circuit.
*
* Gating the "at target" line on `brain_score_current >= targetScore`
* is load-bearing: when the plan is empty AND the target is unreachable,
* the prior shape printed both "Target unreachable: …" and "Brain is at
* target" back-to-back, which contradicted itself and hid the real next
* step (manual prereq config to lift `max_reachable_score`).
*/
export function renderRemediationPlanLines(
plan: RemediationPlanShape,
targetScore: number,
): string[] {
const lines: string[] = [];
lines.push(`Brain score: ${plan.brain_score_current}/100 → target ${targetScore}`);
// Human output
console.log(`Brain score: ${plan.brain_score_current}/100 → target ${targetScore}`);
if (plan.target_unreachable) {
lines.push(`Target unreachable: max with autonomous remediation is ${plan.max_reachable_score}/100.`);
console.log(`Target unreachable: max with autonomous remediation is ${plan.max_reachable_score}/100.`);
}
if (plan.plan.length === 0) {
if (plan.brain_score_current >= targetScore) {
lines.push('No remediations needed. Brain is at target.');
}
// When brain_score < targetScore and plan is empty, the unreachable
// line (if applicable) is the user-facing explanation; the blocked-
// checks block below surfaces the manual gap. Don't follow with a
// misleading "at target" claim.
console.log('No remediations needed. Brain is at target.');
} else {
lines.push(`Plan: ${plan.plan.length} step(s), est ${plan.est_total_seconds}s, est $${plan.est_total_usd_cost.toFixed(2)}`);
console.log(`Plan: ${plan.plan.length} step(s), est ${plan.est_total_seconds}s, est $${plan.est_total_usd_cost.toFixed(2)}`);
for (const step of plan.plan) {
const protectedMark = step.protected ? ' [PROTECTED]' : '';
const costMark = step.est_usd_cost ? ` ($${step.est_usd_cost.toFixed(2)})` : '';
lines.push(` ${step.step}. [${step.severity}] ${step.job}${protectedMark}${step.rationale}${costMark}`);
console.log(` ${step.step}. [${step.severity}] ${step.job}${protectedMark}${step.rationale}${costMark}`);
}
}
if (plan.blocked.length > 0) {
lines.push(`\nBlocked checks (prereq missing):`);
console.log(`\nBlocked checks (prereq missing):`);
for (const b of plan.blocked) {
lines.push(` - ${b.check}: ${b.reason}`);
console.log(` - ${b.check}: ${b.reason}`);
}
}
return lines;
}
interface RemediationPlanShape {
brain_score_current: number;
target_unreachable: boolean;
max_reachable_score: number;
plan: Array<{
step: number;
severity: string;
job: string;
protected?: boolean;
est_usd_cost?: number;
rationale: string;
}>;
est_total_seconds: number;
est_total_usd_cost: number;
blocked: Array<{ check: string; reason: string }>;
}
/**
+5 -25
View File
@@ -26,7 +26,6 @@
import type { BrainEngine } from '../core/engine.ts';
import {
runCycle,
resolveSourceForDir,
ALL_PHASES,
type CyclePhase,
type CycleReport,
@@ -86,7 +85,7 @@ interface DreamArgs {
* `--phase <name>`; bare `--once` is a usage error (there'd be no single
* phase to target). Applies only to phases with a config `.enabled` gate
* (patterns, synthesize, conversation_facts_backfill, enrich_thin,
* skillopt, drift) a no-op for phases that always run when named directly.
* skillopt) a no-op for phases that always run when named directly.
*/
once: boolean;
}
@@ -367,7 +366,7 @@ Options:
unlike toggling the flag on/off around the run, a
crash mid-invocation can't leave it stuck. Applies to
patterns, synthesize, conversation_facts_backfill,
enrich_thin, skillopt, drift; no-op on phases with no such
enrich_thin, skillopt; no-op on phases with no such
gate. Requires an EXPLICIT --phase <name> a phase
implied by --input or --drain does not count (bare
--once, or --once with --input/--drain and no
@@ -381,9 +380,9 @@ Options:
--source <id> Scope the cycle to one source so doctor's
cycle_freshness check sees a fresh stamp on
completion. When omitted, gbrain derives the
source from --dir / the configured checkout
when it matches a source's local_path (#1869).
completion. Without this, gbrain dream's
timestamp never lands and federated brains
see "stale cycle" forever.
--source-id <id> Alias for --source. Matches the v0.37.7.0+
naming used by import/extract/graph-query.
@@ -635,25 +634,6 @@ export async function runDream(engine: BrainEngine | null, args: string[]): Prom
);
process.exit(1);
}
// #1869: a path-scoped run (--dir, or the configured sync.repo_path) whose
// directory matches a registered source's local_path IS that source's cycle
// — derive the source id so runCycle writes last_source_cycle_at /
// last_full_cycle_at on success and doctor's cycle_freshness check stops
// reading perpetually stale. Explicit --source still wins (resolved above).
// Fixed here at the command level, NOT in runCycle's stamp gate, so legacy
// global callers (autopilot-global-maintenance runs GLOBAL_PHASES with a
// brainDir and no sourceId) can't falsely stamp per-source freshness.
// A derived match on an archived source is skipped silently (falls back to
// legacy unscoped behavior) — stamping it would mask staleness on restore,
// mirroring the explicit --source archived guard above.
if (resolvedSourceId === undefined && engine !== null && brainDir !== null) {
const derived = await resolveSourceForDir(engine, brainDir);
if (derived !== undefined) {
const src = await fetchSource(engine, derived);
if (src?.archived !== true) resolvedSourceId = derived;
}
}
// ─── issue #1678: bounded single-hold extract_atoms drain ──────────
if (opts.drain) {
if (engine === null) {
+4 -34
View File
@@ -591,7 +591,7 @@ async function embedPage(
for (let j = 0; j < toEmbed.length; j++) {
embeddingMap.set(toEmbed[j].chunk_index, embeddings[j]);
}
const updated: ChunkInput[] = chunks.map(c => preserveCodeMetadata(c, {
const updated: ChunkInput[] = chunks.map(c => ({
chunk_index: c.chunk_index,
chunk_text: c.chunk_text,
chunk_source: c.chunk_source,
@@ -615,31 +615,6 @@ async function embedPage(
if (!quiet) slog(`${slug}: embedded ${toEmbed.length} chunks`);
}
/**
* Carry code-chunk metadata (language, symbol_name, symbol_type, line range,
* parent scope, doc comment, qualified name) from a loaded Chunk back into a
* ChunkInput destined for upsertChunks.
*
* Issue #769: every re-embed used to strip these fields, and upsertChunks
* overwrites (does not COALESCE) the metadata columns from EXCLUDED, so
* each pass clobbered code-def's primary index to NULL. Pulling the
* preservation into one helper keeps the three re-embed call sites
* (embedPage, embedAll non-stale, embedAllStale) in lock-step.
*/
function preserveCodeMetadata(loaded: any, base: ChunkInput): ChunkInput {
return {
...base,
language: loaded.language ?? undefined,
symbol_name: loaded.symbol_name ?? undefined,
symbol_type: loaded.symbol_type ?? undefined,
start_line: loaded.start_line ?? undefined,
end_line: loaded.end_line ?? undefined,
parent_symbol_path: loaded.parent_symbol_path ?? undefined,
doc_comment: loaded.doc_comment ?? undefined,
symbol_name_qualified: loaded.symbol_name_qualified ?? undefined,
};
}
async function embedAll(
engine: BrainEngine,
staleOnly: boolean,
@@ -754,10 +729,8 @@ async function embedAll(
for (let j = 0; j < toEmbed.length; j++) {
embeddingMap.set(toEmbed[j].chunk_index, embeddings[j]);
}
// Preserve ALL chunks, only update embeddings for stale ones.
// preserveCodeMetadata threads code-chunk metadata (#769) so re-embed
// doesn't clobber language/symbol_name/symbol_type to NULL.
const updated: ChunkInput[] = chunks.map(c => preserveCodeMetadata(c, {
// Preserve ALL chunks, only update embeddings for stale ones
const updated: ChunkInput[] = chunks.map(c => ({
chunk_index: c.chunk_index,
chunk_text: c.chunk_text,
chunk_source: c.chunk_source,
@@ -1057,10 +1030,7 @@ async function embedAllStale(
for (let j = 0; j < stale.length; j++) {
staleIdxToEmbedding.set(stale[j].chunk_index, embeddings[j]);
}
// preserveCodeMetadata threads code-chunk metadata (#769) so the
// autopilot --stale path doesn't clobber language/symbol_name/etc
// to NULL on every cycle.
const merged: ChunkInput[] = existing.map(c => preserveCodeMetadata(c, {
const merged: ChunkInput[] = existing.map(c => ({
chunk_index: c.chunk_index,
chunk_text: c.chunk_text,
chunk_source: c.chunk_source,
+2 -15
View File
@@ -76,7 +76,7 @@ FLAGS:
dimensions (goal, depth, sourcing, specificity, useful).
--cycles N 1-3. Default: 3 in TTY, 1 in non-TTY (T11). Each
cycle is 3 model calls; verdict aggregates over them.
--slot-a-model <id> Override default 'openai:gpt-5.2'.
--slot-a-model <id> Override default 'openai:gpt-4o'.
--slot-b-model <id> Override default 'anthropic:claude-opus-4-7'.
--slot-c-model <id> Override default 'google:gemini-1.5-pro'.
--receipt-dir <path> Default: gbrainPath('eval-receipts').
@@ -468,14 +468,6 @@ interface BatchRow {
question_id: string;
question: string;
hypothesis: string;
/**
* Gold answer from the benchmark dataset, when the upstream eval emits
* it (eval-longmemeval does). Folded into the judge task so CORRECTNESS
* is verifiable without it a judge panel that sees only
* {question, hypothesis} cannot validate a terse factual answer against
* a haystack it never saw.
*/
answer?: string;
}
/**
@@ -589,7 +581,6 @@ function readBatchRows(path: string): BatchReadResult {
question_id: typeof obj.question_id === 'string' ? obj.question_id : `line-${lineNo}`,
question: obj.question,
hypothesis: obj.hypothesis,
...(typeof obj.answer === 'string' && obj.answer.length > 0 ? { answer: obj.answer } : {}),
});
}
if (summarySkipped > 0) {
@@ -706,11 +697,7 @@ async function runBatchMode(parsed: ParsedArgs, opts: RunCrossModalOpts): Promis
fn: async (row, idx) => {
process.stderr.write(`[eval cross-modal batch] ${idx + 1}/${rows.length} ${row.question_id} starting...\n`);
return await runEvalFn({
// With a gold answer the judges can actually verify correctness;
// without one they see only {question, hypothesis} and cannot.
task: row.answer
? `${row.question}\n\nExpected answer (gold label from the benchmark dataset): ${row.answer}`
: row.question,
task: row.question,
output: row.hypothesis,
slug: row.question_id,
dimensions,
+3 -17
View File
@@ -33,7 +33,6 @@ import {
type AliasMap,
} from '../eval/longmemeval/extract.ts';
import { extractCandidateEntities } from '../core/think/entity-extract.ts';
import { splitProviderModelId } from '../core/model-id.ts';
import { resolveEntitySlugWithSource, type ResolutionSource } from '../core/entities/resolve.ts';
import { formatTrajectoryBlock } from '../core/trajectory-format.ts';
@@ -470,22 +469,14 @@ export async function runEvalLongMemEval(args: string[], runOpts: RunOpts = {}):
});
// Wrap Anthropic SDK so its `.messages.create` shape matches ThinkLLMClient.
// Same pattern as src/core/think/index.ts:247-249 — EXCEPT think's default
// client routes through the gateway, which parses `provider:model` recipe
// ids. This eval's client is a raw SDK by design (hermetic, no gateway
// dependency), and resolveModel returns RECIPE ids (`anthropic:claude-…`);
// passing one through unstripped 404s every answer/extractor call, which
// surfaces downstream as all-upstream_error batches in the nightly probe.
const toSdkModel = (m: string): string => splitProviderModelId(m).model || m;
// Same pattern as src/core/think/index.ts:247-249.
const realClient = new Anthropic();
const client: ThinkLLMClient = runOpts.client ?? {
create: (params, callOpts) =>
realClient.messages.create({ ...params, model: toSdkModel(params.model) }, callOpts),
create: (params, callOpts) => realClient.messages.create(params, callOpts),
};
// v0.40.2.0 — separate extractor client (defaults to same SDK).
const extractorClient: ThinkLLMClient = runOpts.extractorClient ?? {
create: (params, callOpts) =>
realClient.messages.create({ ...params, model: toSdkModel(params.model) }, callOpts),
create: (params, callOpts) => realClient.messages.create(params, callOpts),
};
const trajectoryEnabled = !opts.noTrajectory;
const extractorModel = trajectoryEnabled
@@ -760,11 +751,6 @@ async function runOneQuestion(
// v0.40.1.0 (Track D / T2) — copy question_type into the row so the
// by_type_summary can be rebuilt from the file on resume runs.
question_type: q.question_type,
// Gold answer for downstream consumers that verify correctness (the
// cross-modal --batch judge folds it into the task; evaluate_qa.py
// ignores unknown fields). Without it a judge can't validate a terse
// factual hypothesis against a haystack it never saw.
...(q.answer !== undefined ? { answer: q.answer } : {}),
hypothesis,
retrieved_session_ids: retrievedSessionIds,
...(recallHit !== undefined ? { recall_hit: recallHit } : {}),
+9 -60
View File
@@ -433,10 +433,7 @@ export async function extractLinksFromFile(
async resolve(name: string, dirHint?: string | string[]): Promise<string | null> {
if (!name) return null;
const trimmed = name.trim();
// Same broadened slug-shape as makeResolver step 1: accepts
// digit-leading folders (`90-people/nicolai`) and nested paths.
// Exact Set membership guards it — no false positives.
if (/\//.test(trimmed) && /^[a-z0-9][a-z0-9/_-]*$/.test(trimmed) && allSlugs.has(trimmed)) {
if (/^[a-z][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(trimmed) && allSlugs.has(trimmed)) {
return trimmed;
}
const hints = Array.isArray(dirHint) ? dirHint : (dirHint ? [dirHint] : []);
@@ -585,17 +582,6 @@ export interface ExtractOpts {
* before (single-'default'-source brains unaffected).
*/
sourceId?: string;
/**
* v0.42 also extract frontmatter links on the incremental (slugs) path.
* `extractForSlugs` extracts BODY links only by default; set this true to also
* parse each changed page's frontmatter so `sources:`/`related:` edges stay fresh
* when YAML is edited externally and synced in. Applied PER changed page, so the
* incremental walk stays bounded (no switch to a full DB scan). Only honored on
* the incremental path (`slugs` defined); the full-walk path already covers
* frontmatter via its own dispatch. Gated upstream by the config key
* `autopilot.incremental_extract_include_frontmatter` (default off).
*/
includeFrontmatter?: boolean;
}
/**
@@ -634,7 +620,7 @@ export async function runExtractCore(engine: BrainEngine, opts: ExtractOpts): Pr
// Nothing changed — skip entirely.
return result;
}
const r = await extractForSlugs(engine, opts.dir, opts.slugs, opts.mode, dryRun, jsonMode, workers, opts.signal, opts.sourceId, opts.includeFrontmatter);
const r = await extractForSlugs(engine, opts.dir, opts.slugs, opts.mode, dryRun, jsonMode, workers, opts.signal, opts.sourceId);
result.links_created = r.links_created;
result.timeline_entries_created = r.timeline_created;
result.pages_processed = r.pages;
@@ -1025,11 +1011,6 @@ async function extractForSlugs(
signal?: AbortSignal,
// #1747/#1503: stamp resolved brain source id on batch rows (see ExtractOpts.sourceId).
sourceId?: string,
// v0.42: when true, also extract frontmatter links per changed page so
// externally-edited YAML (`sources:`/`related:`) stays fresh on the cycle.
// Default false preserves the body-only incremental behavior. Gated upstream
// by `autopilot.incremental_extract_include_frontmatter`.
includeFrontmatter: boolean = false,
): Promise<{ links_created: number; timeline_created: number; pages: number }> {
// Build the full slug set for link resolution (fast: just readdir, no file reads)
const allFiles = walkMarkdownFiles(brainDir);
@@ -1044,10 +1025,6 @@ async function extractForSlugs(
let linksCreated = 0;
let timelineCreated = 0;
let pagesProcessed = 0;
// #2636: successfully processed pages get their extraction watermark
// stamped after the final flush (mode 'all' only — a partial-mode run
// hasn't done the full extraction the watermark asserts).
const processedRefs: Array<{ slug: string; source_id: string }> = [];
// Issue #972: read the basename flag once per extract run.
const globalBasename = await isGlobalBasenameEnabled(engine);
@@ -1108,7 +1085,7 @@ async function extractForSlugs(
const content = readFileSync(fullPath, 'utf-8');
if (doLinks) {
const links = await extractLinksFromFile(content, relPath, allSlugs, { globalBasename, includeFrontmatter });
const links = await extractLinksFromFile(content, relPath, allSlugs, { globalBasename });
for (const link of links) {
if (dryRun) {
if (!jsonMode) console.log(` ${link.from_slug}${link.to_slug} (${link.link_type})`);
@@ -1136,7 +1113,6 @@ async function extractForSlugs(
}
pagesProcessed++;
if (!dryRun) processedRefs.push({ slug, source_id: sourceId ?? 'default' });
} catch { /* skip unreadable */ }
progress.tick(1);
},
@@ -1144,13 +1120,6 @@ async function extractForSlugs(
await flushLinks();
await flushTimeline();
// #2636: the Dream cycle disables sync's inline extraction and routes
// changed slugs through this incremental path — without a stamp here,
// those pages never get links_extracted_at and stay permanently visible
// to `extract --stale` / doctor. Stamp only after BOTH batches flushed.
if (!dryRun && mode === 'all') {
await stampExtracted(engine, processedRefs);
}
progress.finish();
if (!jsonMode) {
@@ -1682,7 +1651,7 @@ async function extractTimelineFromDB(
* make re-extraction idempotent). EVERY processed page is stamped, including
* zero-link pages they WERE processed.
*/
export async function extractStaleFromDB(
async function extractStaleFromDB(
engine: BrainEngine,
opts: {
dryRun: boolean;
@@ -1715,17 +1684,9 @@ export async function extractStaleFromDB(
// Batch mode = pg_trgm + exact only, NO per-name search fallback. The
// resolution map sees ALL sources so qualified cross-source wikilinks resolve
// even when --source-id scopes the stale SCAN.
//
// #2576 bug 1: ALWAYS the real resolver — extractPageLinks's opts gate which
// pass runs (`skipFrontmatter` for the frontmatter pass, `globalBasename` for
// the issue-#972 bare-wikilink pass). The former `includeFrontmatter ?
// resolver : nullResolver` ternary predates #972; the synthetic resolver has
// no `resolveBasenameMatches`, so the --stale sweep silently skipped basename
// resolution even with `link_resolution.global_basename` enabled, stamping
// pages as extracted with their bare wikilinks dropped. Mirrors
// extractLinksFromDB (including the codex-[P1] `sourceId` scoping).
const resolver = makeResolver(engine, { mode: 'batch', sourceId: sourceIdFilter });
const globalBasename = await isGlobalBasenameEnabled(engine);
const resolver = makeResolver(engine, { mode: 'batch' });
const nullResolver = { resolve: async () => null as string | null };
const activeResolver = includeFrontmatter ? resolver : nullResolver;
const allRefs = await engine.listAllPageRefs();
const allSlugs = new Set<string>();
const slugToSources = new Map<string, string[]>();
@@ -1757,8 +1718,7 @@ export async function extractStaleFromDB(
for (const page of rows) {
const fullContent = page.compiled_truth + '\n' + page.timeline;
const extracted = await extractPageLinks(
page.slug, fullContent, page.frontmatter, page.type, resolver,
{ skipFrontmatter: !includeFrontmatter, globalBasename },
page.slug, fullContent, page.frontmatter, page.type, activeResolver,
);
for (const c of extracted.candidates) {
const r = resolveCandidateSources(c, page.slug, page.source_id, allSlugs, slugToSources);
@@ -1783,18 +1743,7 @@ export async function extractStaleFromDB(
// `page.updated_at.toISOString()` — the JS Date is ms-truncated, so the
// µs-precision DB updated_at stayed strictly greater and the page never
// cleared on Postgres. Stamping the exact value makes them equal.
//
// BUT the stamp must also clear the version-staleness clause
// (`links_extracted_at < versionTs`). A page whose updated_at predates
// versionTs would otherwise be stamped below the threshold and read as
// stale forever — a permanent re-extract loop that never clears the lag.
// GREATEST(updated_at, versionTs) preserves the race semantics (a real
// future edit advances updated_at > versionTs >= stamp → re-extracts)
// while lifting old pages to the threshold so they clear.
const stampIso = page.updated_at.getTime() >= Date.parse(versionTs)
? page.updated_at_iso
: versionTs;
processedRefs.push({ slug: page.slug, source_id: page.source_id, extractedAt: stampIso });
processedRefs.push({ slug: page.slug, source_id: page.source_id, extractedAt: page.updated_at_iso });
}
// Flush NON-swallowing (CDX-4): a throw here propagates out of the sweep so
+13 -110
View File
@@ -26,8 +26,6 @@ export async function runInit(args: string[]) {
return;
}
validateInitFlags(args);
const isSupabase = args.includes('--supabase');
const isPGLite = args.includes('--pglite');
const isMcpOnly = args.includes('--mcp-only');
@@ -153,65 +151,6 @@ export async function runInit(args: string[]) {
return initPostgres({ databaseUrl, jsonOutput, apiKey, aiOpts, schemaPack, skipEmbedCheck });
}
const INIT_BOOLEAN_FLAGS = new Set([
'--pglite',
'--supabase',
'--mcp-only',
'--force',
'--non-interactive',
'--migrate-only',
'--json',
'--no-embedding',
'--skip-embed-check',
]);
const INIT_VALUE_FLAGS = new Set([
'--url',
'--key',
'--path',
'--schema-pack',
'--embedding-model',
'--model',
'--embedding-dimensions',
'--expansion-model',
'--chat-model',
'--mcp-url',
'--issuer-url',
'--oauth-client-id',
'--oauth-client-secret',
]);
function validateInitFlags(args: string[]) {
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!arg.startsWith('-')) continue;
if (INIT_BOOLEAN_FLAGS.has(arg)) continue;
if (INIT_VALUE_FLAGS.has(arg)) {
if (i + 1 >= args.length || args[i + 1].startsWith('-')) {
failInitFlag(`gbrain init: ${arg} requires a value`, args.includes('--json'));
}
i += 1;
continue;
}
if (arg.startsWith('--')) {
failInitFlag(`gbrain init: unknown flag ${arg}`, args.includes('--json'));
}
}
}
function failInitFlag(message: string, jsonOutput: boolean): never {
if (jsonOutput) {
console.log(JSON.stringify({ status: 'error', reason: 'invalid_flag', message }));
} else {
console.error(message);
console.error('Run `gbrain init --help` for supported flags.');
}
process.exit(1);
}
interface ResolveAIOptionsArgs {
verbose: string | null; // --embedding-model
shorthand: string | null; // --model
@@ -222,7 +161,7 @@ interface ResolveAIOptionsArgs {
nonInteractive: boolean; // --non-interactive (forces D3 fail-loud, no picker)
}
export interface ResolvedAIOptions {
interface ResolvedAIOptions {
embedding_model?: string;
embedding_dimensions?: number;
expansion_model?: string;
@@ -231,41 +170,6 @@ export interface ResolvedAIOptions {
noEmbedding?: boolean;
}
/**
* Seed init's AI options from persisted config, falling back to the raw env
* vars when loadConfig() returned null (#1058). On a cold install (no
* config.json AND no DATABASE_URL) loadConfig short-circuits BEFORE its env
* merge, so GBRAIN_EMBEDDING_MODEL / GBRAIN_EMBEDDING_DIMENSIONS /
* GBRAIN_EXPANSION_MODEL / GBRAIN_CHAT_MODEL were silently ignored by init
* and Tier-3 detection auto-picked by API key instead. Exported for unit
* tests (env injectable).
*/
export function seedAIOptionsFromConfig(
cfg: GBrainConfig | null,
env: NodeJS.ProcessEnv = process.env,
): ResolvedAIOptions {
const envDims = env.GBRAIN_EMBEDDING_DIMENSIONS
? parseInt(env.GBRAIN_EMBEDDING_DIMENSIONS, 10)
: NaN;
const seed = cfg ?? {
embedding_disabled: undefined,
embedding_model: env.GBRAIN_EMBEDDING_MODEL,
embedding_dimensions: Number.isFinite(envDims) ? envDims : undefined,
expansion_model: env.GBRAIN_EXPANSION_MODEL,
chat_model: env.GBRAIN_CHAT_MODEL,
};
const out: ResolvedAIOptions = {};
if (seed.embedding_disabled) {
out.noEmbedding = true;
} else if (seed.embedding_model) {
out.embedding_model = seed.embedding_model;
if (seed.embedding_dimensions) out.embedding_dimensions = seed.embedding_dimensions;
}
if (seed.expansion_model) out.expansion_model = seed.expansion_model;
if (seed.chat_model) out.chat_model = seed.chat_model;
return out;
}
/**
* Resolve AI provider options for `gbrain init`.
*
@@ -299,13 +203,18 @@ async function resolveAIOptions(opts: ResolveAIOptionsArgs): Promise<ResolvedAIO
// user already opted into deferred mode.
try {
const { loadConfig } = await import('../core/config.ts');
// #1058: loadConfig() returns null on a cold install (no config.json AND
// no DATABASE_URL) — before it ever reaches its env merge. The seed helper
// falls back to the same GBRAIN_* env vars directly in that case.
Object.assign(out, seedAIOptionsFromConfig(loadConfig()));
const cfg = loadConfig();
if (cfg?.embedding_disabled) {
out.noEmbedding = true;
} else if (cfg?.embedding_model) {
out.embedding_model = cfg.embedding_model;
if (cfg.embedding_dimensions) out.embedding_dimensions = cfg.embedding_dimensions;
}
if (cfg?.expansion_model) out.expansion_model = cfg.expansion_model;
if (cfg?.chat_model) out.chat_model = cfg.chat_model;
} catch {
// loadConfig threw — treat as first-time install, fall through to env
// detection.
// loadConfig throws when no brain configured — first-time install, fall
// through to env detection.
}
// --- Tier 1+2: explicit flags ---------------------------------------------
@@ -1169,9 +1078,6 @@ async function initPostgres(opts: {
console.warn(' Direct connections are IPv6 only and fail in many environments.');
console.warn(' Use the Transaction pooler connection string instead (port 6543):');
console.warn(' Supabase Dashboard > Connect (top bar) > Connection String > Transaction pooler');
console.warn(' (With a pooler URL, gbrain derives a direct connection for DDL and falls back');
console.warn(' to the pooler automatically if that host is unreachable. Power users:');
console.warn(' GBRAIN_DIRECT_DATABASE_URL overrides the derived URL; GBRAIN_DISABLE_DIRECT_POOL=1 disables it.)');
console.warn('');
}
@@ -1185,9 +1091,6 @@ async function initPostgres(opts: {
if (databaseUrl.includes('supabase.co') && (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT'))) {
console.error('Connection failed. Supabase direct connections (db.*.supabase.co:5432) are IPv6 only.');
console.error('Use the Transaction pooler connection string instead (port 6543).');
console.error('(gbrain derives its own direct connection from pooler URLs for DDL; if that host is');
console.error('unreachable it falls back to the pooler. GBRAIN_DIRECT_DATABASE_URL overrides the');
console.error('derived URL; GBRAIN_DISABLE_DIRECT_POOL=1 disables the direct pool entirely.)');
}
throw e;
}
@@ -1552,7 +1455,7 @@ export function reportModStatus(): void {
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('Soul audit: run `gbrain soul-audit` to customize agent identity');
// 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.
+1 -10
View File
@@ -98,17 +98,8 @@ export function findBareTweetHits(compiledTruth: string, slug: string): BareTwee
}
// If the line already contains a tweet URL, it's cited — skip
if (URL_NEARBY_RE.test(line)) continue;
// If the line carries an explicit source citation (e.g.
// "[Source: X, @handle, 2026-05-28]"), it's already attributed — skip.
// Catches instructional/example lines in recipe docs that demonstrate
// the CORRECT citation format. (v0.42.x)
if (/\[\s*source:/i.test(line)) continue;
// Strip inline-code spans (`...`) before matching: phrases shown as
// inline-code templates in docs are examples, not bare claims. The
// fenced-code skip above only covers ``` blocks, not inline backticks.
const lineForMatch = line.replace(/`[^`]*`/g, '');
for (const re of BARE_TWEET_PHRASES) {
const m = lineForMatch.match(re);
const m = line.match(re);
if (m) {
hits.push({ slug, line: i + 1, rawLine: line.trim(), phrase: m[0] });
break; // one finding per line is enough
+2 -24
View File
@@ -1664,13 +1664,7 @@ export async function registerBuiltinHandlers(
worker.register('backlinks', async (job) => {
const { runBacklinksCore } = await import('./backlinks.ts');
// Default to 'check', not 'fix': backlinks jobs submitted with an empty
// payload (e.g. the sync→embed→backlinks chains enqueued after ingestion)
// must never rewrite tracked brain pages with generated "Referenced in"
// timeline bullets. Mirrors the documented intent in src/core/cycle.ts
// (runPhaseBacklinks). The filesystem fixer stays available explicitly
// via '{"action":"fix"}' or `gbrain check-backlinks fix`.
const action: 'check' | 'fix' = job.data.action === 'fix' ? 'fix' : 'check';
const action: 'check' | 'fix' = job.data.action === 'check' ? 'check' : 'fix';
const dir = typeof job.data.dir === 'string'
? job.data.dir
: (await engine.getConfig('sync.repo_path')) ?? '.';
@@ -1885,7 +1879,6 @@ export async function registerBuiltinHandlers(
signal: job.signal,
deadlineAtMs: job.deadlineAtMs, // #2781: phases budget sub-work from remaining time
phases,
forceGlobalOrphans: true,
yieldBetweenPhases: async () => { await new Promise<void>((r) => setImmediate(r)); },
});
@@ -2062,26 +2055,11 @@ export async function registerBuiltinHandlers(
? job.data.repoPath
: ((await engine.getConfig('sync.repo_path')) ?? undefined);
try {
const result = await runExtractAtomsDrainForSource(engine, {
return await runExtractAtomsDrainForSource(engine, {
sourceId,
windowSeconds,
brainDir: repoPath,
});
// issue #3218: every item the drain attempted failed (0 succeeded, >=1
// provider error) — completing this job normally would mark the
// durable job done while the backlog sits untouched, and no retry
// policy would ever fire on it again. Throw so the worker's ordinary
// failJob path (attempt+backoff, or dead-letter once exhausted) takes
// over instead — matching the existing behavior for every other
// handler failure. Partial success (>=1 item extracted) keeps
// completing normally, unchanged.
if (result.status === 'provider_failure') {
throw new Error(
`extract-atoms-drain: all provider calls failed this batch ` +
`(batches=${result.batches}, remaining=${result.remaining ?? '?'}) — retrying`,
);
}
return result;
} catch (e) {
if (e instanceof LockUnavailableError) {
return { phase: 'extract_atoms', status: 'skipped', deferred: true, reason: 'cycle_already_running' };
+9 -49
View File
@@ -127,12 +127,7 @@ export function lintContent(content: string, filePath: string, opts: LintContent
}
// Rule: Wrapping code fences (```markdown ... ```)
// Detector intentionally has NO /m flag so ^/$ match start/end of the whole
// file, not inner lines. Keeps detector in sync with fixContent() below,
// which also has no /m flag. Without this, lint reports "fixable" false
// positives on any page that simply contains a ```markdown code block, but
// fixContent can never strip them (its regex only matches whole-file wrappers).
if (content.match(/^```(?:markdown|md)\s*\n/) && content.match(/\n```\s*$/)) {
if (content.match(/^```(?:markdown|md)\s*\n/m) && content.match(/\n```\s*$/m)) {
issues.push({
file: filePath, line: 1, rule: 'code-fence-wrap',
message: 'Page wrapped in ```markdown code fences (LLM artifact)',
@@ -383,30 +378,15 @@ async function resolveLintContentSanity(
};
}
/**
* Directories never containing knowledge pages, skipped by default.
* Deliberately tiny: only vendored dependency trees qualify. Anything
* more opinionated (README.md, CHANGELOG.md, test/) is repo policy
* callers opt in via `--exclude` / `LintOpts.exclude`. Dot- and
* underscore-prefixed entries are already skipped by the walk.
*/
const DEFAULT_LINT_EXCLUDE_DIRS = new Set(['node_modules']);
/** Collect markdown files from a directory */
function collectPages(dir: string, extraExcludes: string[] = []): string[] {
const extra = new Set(extraExcludes);
function collectPages(dir: string): string[] {
const pages: string[] = [];
function walk(d: string) {
for (const entry of readdirSync(d)) {
if (entry.startsWith('.') || entry.startsWith('_')) continue;
const full = join(d, entry);
if (lstatSync(full).isDirectory()) {
if (DEFAULT_LINT_EXCLUDE_DIRS.has(entry) || extra.has(entry)) continue;
walk(full);
} else if (entry.endsWith('.md')) {
if (extra.has(entry)) continue;
pages.push(full);
}
if (lstatSync(full).isDirectory()) walk(full);
else if (entry.endsWith('.md')) pages.push(full);
}
}
walk(dir);
@@ -434,13 +414,6 @@ export interface LintOpts {
* yields + checks this every 200 pages.
*/
signal?: AbortSignal;
/**
* #2649: extra dir/file basenames to skip while collecting pages, in
* addition to node_modules and dot/underscore entries. For mixed-content
* repos (knowledge pages alongside software trees). Ignored for
* single-file targets.
*/
exclude?: string[];
}
export interface LintResult {
@@ -467,7 +440,7 @@ export async function runLintCore(opts: LintOpts): Promise<LintResult> {
}
const isSingleFile = statSync(opts.target).isFile();
const pages = isSingleFile ? [opts.target] : collectPages(opts.target, opts.exclude ?? []);
const pages = isSingleFile ? [opts.target] : collectPages(opts.target);
// Resolve content-sanity config once for this lint run (D1: lift DB
// config when reachable). Caller can pre-pass via opts.contentSanity
@@ -518,27 +491,14 @@ export async function runLintCore(opts: LintOpts): Promise<LintResult> {
}
export async function runLint(args: string[]) {
// #2649: --exclude=a,b or --exclude a,b — extra basenames to skip.
const extraExcludes: string[] = [];
const skipIdx = new Set<number>();
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a.startsWith('--exclude=')) {
extraExcludes.push(...a.slice('--exclude='.length).split(',').map(s => s.trim()).filter(Boolean));
} else if (a === '--exclude' && i + 1 < args.length) {
extraExcludes.push(...args[i + 1].split(',').map(s => s.trim()).filter(Boolean));
skipIdx.add(i + 1);
}
}
const target = args.find((a, i) => !a.startsWith('--') && !skipIdx.has(i));
const target = args.find(a => !a.startsWith('--'));
const doFix = args.includes('--fix');
const dryRun = args.includes('--dry-run');
if (!target) {
console.error('Usage: gbrain lint <dir|file.md> [--fix] [--dry-run] [--exclude a,b]');
console.error('Usage: gbrain lint <dir|file.md> [--fix] [--dry-run]');
console.error(' --fix Auto-fix fixable issues (LLM preambles, code fences)');
console.error(' --dry-run Preview fixes without writing');
console.error(' --exclude Comma-separated dir/file basenames to skip (in addition to node_modules)');
process.exit(1);
}
@@ -550,7 +510,7 @@ export async function runLint(args: string[]) {
// Single file or directory — print human detail as we go, then rely on
// Core for the aggregate numbers at the end.
const isSingleFile = statSync(target).isFile();
const pages = isSingleFile ? [target] : collectPages(target, extraExcludes);
const pages = isSingleFile ? [target] : collectPages(target);
// Progress on stderr. Stdout keeps the per-issue human output it always had.
const { createProgress } = await import('../core/progress.ts');
@@ -597,7 +557,7 @@ export async function runLint(args: string[]) {
// produces canonical numbers for the summary line).
// Pass contentSanity through so runLintCore skips its own resolve
// (we already resolved once for the human-detail loop above).
const result = await runLintCore({ target, fix: doFix, dryRun, contentSanity, exclude: extraExcludes });
const result = await runLintCore({ target, fix: doFix, dryRun, contentSanity });
console.log(`\n${result.pages_scanned} pages scanned. ${result.total_issues} issue(s) in ${result.pages_with_issues} page(s).`);
if (doFix) {
console.log(`${dryRun ? '(dry run) ' : ''}${result.total_fixed} auto-fixed.`);
-224
View File
@@ -1,224 +0,0 @@
/**
* gbrain maintain conservative self-healing maintenance.
*
* This command automates the safe parts of the operator runbook:
* - stale link/timeline extraction
* - stale per-source dream cycles when doctor reports cycle_freshness
*
* It deliberately does NOT mutate source files, apply schema-pack upgrades, or
* invent semantic hub links. Those need review or a separate command with an
* auditable proposal surface.
*/
import { existsSync } from 'fs';
import type { BrainEngine } from '../core/engine.ts';
import type { BrainHealth } from '../core/types.ts';
import { buildChecks, computeDoctorReport, type DoctorReport, type Check } from './doctor.ts';
import { extractStaleFromDB } from './extract.ts';
import { runCycle, type CycleReport } from '../core/cycle.ts';
type ActionStatus = 'ok' | 'would_apply' | 'applied' | 'blocked' | 'skipped';
export interface MaintenanceAction {
name: string;
status: ActionStatus;
message: string;
details?: Record<string, unknown>;
}
export interface MaintainOptions {
json: boolean;
safe: boolean;
dryRun: boolean;
help: boolean;
}
export interface MaintainReport {
mode: 'dry-run' | 'safe';
before: {
health: BrainHealth;
doctor: DoctorReport;
};
actions: MaintenanceAction[];
after: {
health: BrainHealth;
doctor: DoctorReport;
};
}
export function parseMaintainArgs(args: string[]): MaintainOptions {
const safe = args.includes('--safe');
return {
json: args.includes('--json'),
safe,
dryRun: args.includes('--dry-run') || !safe,
help: args.includes('--help') || args.includes('-h'),
};
}
export function extractCycleFreshnessSourceIds(checks: Check[]): string[] {
const ids = new Set<string>();
for (const check of checks) {
if (check.name !== 'cycle_freshness' || check.status === 'ok') continue;
const re = /Source '([^']+)' last cycled/g;
for (const match of check.message.matchAll(re)) {
const id = match[1]?.trim();
if (id) ids.add(id);
}
}
return [...ids].sort();
}
async function buildDoctorReport(engine: BrainEngine): Promise<DoctorReport> {
const checks = await buildChecks(engine, ['--json', '--scope=brain']);
return computeDoctorReport(checks);
}
async function runStaleExtraction(
engine: BrainEngine,
beforeHealth: BrainHealth,
dryRun: boolean,
): Promise<MaintenanceAction> {
if (beforeHealth.stale_pages <= 0) {
return { name: 'extract_stale', status: 'ok', message: 'No stale pages.' };
}
if (dryRun) {
return {
name: 'extract_stale',
status: 'would_apply',
message: `Would run DB-backed stale extraction for ${beforeHealth.stale_pages} page(s).`,
details: { stale_pages: beforeHealth.stale_pages },
};
}
const result = await extractStaleFromDB(engine, {
dryRun: false,
jsonMode: false,
includeFrontmatter: false,
catchUp: false,
});
return {
name: 'extract_stale',
status: 'applied',
message: `Processed ${result.pagesProcessed} stale page(s); ${result.staleRemaining} remain.`,
details: {
links_created: result.linksCreated,
timeline_created: result.timelineCreated,
pages_processed: result.pagesProcessed,
stale_remaining: result.staleRemaining,
},
};
}
async function runCycleFreshnessMaintenance(
engine: BrainEngine,
beforeDoctor: DoctorReport,
dryRun: boolean,
): Promise<MaintenanceAction[]> {
const sourceIds = extractCycleFreshnessSourceIds(beforeDoctor.checks);
if (sourceIds.length === 0) {
return [{ name: 'cycle_freshness', status: 'ok', message: 'All sources cycled recently.' }];
}
if (dryRun) {
return sourceIds.map((sourceId) => ({
name: 'cycle_freshness',
status: 'would_apply',
message: `Would run source-scoped dream cycle for ${sourceId}.`,
details: { source_id: sourceId },
}));
}
const sources = await engine.listAllSources();
const actions: MaintenanceAction[] = [];
for (const sourceId of sourceIds) {
const source = sources.find((s) => s.id === sourceId);
const localPath = source?.local_path ?? null;
const brainDir = localPath && existsSync(localPath) ? localPath : null;
const report: CycleReport = await runCycle(engine, {
brainDir,
dryRun: false,
pull: false,
sourceId,
});
actions.push({
name: 'cycle_freshness',
status: report.status === 'failed' ? 'blocked' : 'applied',
message: `Ran source-scoped dream cycle for ${sourceId}: ${report.status}.`,
details: {
source_id: sourceId,
brain_dir: brainDir,
cycle_status: report.status,
phases: report.phases.map((p) => ({ phase: p.phase, status: p.status })),
},
});
}
return actions;
}
export async function runMaintain(engine: BrainEngine, args: string[]): Promise<MaintainReport | void> {
const opts = parseMaintainArgs(args);
if (opts.help) {
console.log(`Usage: gbrain maintain [--safe] [--dry-run] [--json]
Conservative self-healing maintenance.
Modes:
--dry-run Preview safe actions without writes. Default when --safe is absent.
--safe Apply safe actions: stale extraction and source cycle freshness.
--json Emit a structured before/action/after report.
Not auto-applied:
source-file frontmatter fixes, schema-pack upgrades, atom-pack changes,
semantic hub-link guesses, and destructive cleanup.
`);
return;
}
const beforeHealth = await engine.getHealth();
const beforeDoctor = await buildDoctorReport(engine);
const actions: MaintenanceAction[] = [];
actions.push(await runStaleExtraction(engine, beforeHealth, opts.dryRun));
actions.push(...await runCycleFreshnessMaintenance(engine, beforeDoctor, opts.dryRun));
const afterHealth = await engine.getHealth();
const afterDoctor = await buildDoctorReport(engine);
const report: MaintainReport = {
mode: opts.dryRun ? 'dry-run' : 'safe',
before: { health: beforeHealth, doctor: beforeDoctor },
actions,
after: { health: afterHealth, doctor: afterDoctor },
};
if (opts.json) {
console.log(JSON.stringify(report, null, 2));
} else {
printMaintainReport(report);
}
return report;
}
function printMaintainReport(report: MaintainReport): void {
console.log(`GBrain maintain (${report.mode})`);
console.log(
`Before: brain_score=${Math.round(report.before.health.brain_score)}/100 ` +
`stale=${report.before.health.stale_pages} islands=${report.before.health.orphan_pages} ` +
`doctor=${report.before.doctor.status}`,
);
for (const action of report.actions) {
console.log(` ${action.status}: ${action.name}${action.message}`);
}
console.log(
`After: brain_score=${Math.round(report.after.health.brain_score)}/100 ` +
`stale=${report.after.health.stale_pages} islands=${report.after.health.orphan_pages} ` +
`doctor=${report.after.doctor.status}`,
);
if (report.mode === 'dry-run') {
console.log('Run `gbrain maintain --safe` to apply safe actions.');
}
}
+98 -216
View File
@@ -10,13 +10,12 @@
import { createEngine } from '../core/engine-factory.ts';
import { loadConfig, saveConfig, toEngineConfig, gbrainPath, effectiveEnvDatabaseUrl, type GBrainConfig } from '../core/config.ts';
import type { BrainEngine } from '../core/engine.ts';
import type { EngineConfig, Page } from '../core/types.ts';
import type { EngineConfig } from '../core/types.ts';
import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'fs';
import { createHash } from 'crypto';
import { resolve } from 'path';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
interface MigrateOpts {
targetEngine: 'postgres' | 'pglite';
@@ -144,99 +143,6 @@ export async function copyMigrationSources(source: BrainEngine, target: BrainEng
}
}
/**
* 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.
* A page read back from a PGLite source can carry `undefined` for a column
* that is legitimately empty/NULL (a read-side driver-shape difference, not
* a data problem), and passing that value straight into a Postgres
* `putPage` throws mid-insert (#3194). Normalizing at this migrate-only
* boundary rather than inside `putPage` itself, which many non-migrate
* callers also use turns that driver-shape difference into an explicit
* SQL NULL, so only a genuine NOT-NULL constraint violation (an actual data
* problem) still surfaces as a page-copy failure.
*/
function nullifyUndefinedColumns<T extends Record<string, unknown>>(row: T): T {
const normalized = { ...row };
for (const key of Object.keys(normalized) as (keyof T)[]) {
if (normalized[key] === undefined) normalized[key] = null as T[typeof key];
}
return normalized;
}
/**
* Copy one page's full row (page body, chunks, tags, timeline, raw data)
* from source to target. Throws on any failure the caller (the per-page
* loop in runMigrateEngine) decides how to account for that: track it as a
* failed page and keep going, rather than letting one bad row silently
* disappear from the progress count (#3194). Exported so unit tests can
* inject fake engines and exercise the failure path without a live
* DATABASE_URL.
*/
export async function copyPageToTarget(
source: BrainEngine,
target: BrainEngine,
page: Page,
): Promise<void> {
const sourceOpts = { sourceId: page.source_id };
// Copy page (preserve source_id). v0.32.8 F8: thread source_id end-to-end
// so multi-source pages migrate intact.
await target.putPage(page.slug, nullifyUndefinedColumns({
type: page.type,
title: page.title,
compiled_truth: page.compiled_truth,
timeline: page.timeline,
frontmatter: page.frontmatter,
content_hash: page.content_hash,
}), sourceOpts);
// Copy chunks with embeddings.
const chunks = await source.getChunksWithEmbeddings(page.slug, sourceOpts);
if (chunks.length > 0) {
await target.upsertChunks(page.slug, chunks.map(c => ({
chunk_index: c.chunk_index,
chunk_text: c.chunk_text,
chunk_source: c.chunk_source,
embedding: c.embedding || undefined,
model: c.model,
token_count: c.token_count || undefined,
})), sourceOpts);
}
// Copy tags
const tags = await source.getTags(page.slug, sourceOpts);
for (const tag of tags) {
await target.addTag(page.slug, tag, sourceOpts);
}
// Copy timeline
const timeline = await source.getTimeline(page.slug, sourceOpts);
for (const entry of timeline) {
await target.addTimelineEntry(page.slug, {
date: entry.date,
source: entry.source,
summary: entry.summary,
detail: entry.detail,
}, sourceOpts);
}
// Copy raw data
const rawData = await source.getRawData(page.slug, undefined, sourceOpts);
for (const rd of rawData) {
await target.putRawData(page.slug, rd.source, rd.data, sourceOpts);
}
}
/** A page that failed to copy during migrate tracked so the run's final
* summary reports it honestly instead of letting the "N copied" counter
* imply every page landed (#3194). */
export interface MigratePageFailure {
source_id: string;
slug: string;
reason: string;
}
export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]): Promise<void> {
const opts = parseArgs(args);
const config = loadConfig();
@@ -271,47 +177,32 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
await targetEngine.connect(targetConfig);
await targetEngine.initSchema();
// Load or create manifest for resume. Checked BEFORE the non-empty-target
// guard below: a manifest matching this exact target means the target's
// existing rows came from OUR OWN in-progress migration (#3194's per-page
// failures now leave the target non-empty by design instead of crashing),
// so a resume must not be treated as "attempting to migrate into a
// foreign non-empty brain".
// Check if target has data
const targetStats = await targetEngine.getStats();
if (targetStats.page_count > 0 && !opts.force) {
console.error(`Target brain is not empty (${targetStats.page_count} pages).`);
console.error('Run with --force to overwrite, or migrate to an empty brain.');
await targetEngine.disconnect();
process.exit(1);
}
if (targetStats.page_count > 0 && opts.force) {
console.log('--force: wiping target brain...');
// v0.18.0+ multi-source: deletePage(slug) is now source-scoped (defaults
// to 'default'), so per-page iteration would skip non-default-source
// rows. migrate-engine --force is a destructive wipe across the entire
// brain — all sources, all pages — so we issue a raw DELETE that matches
// the original semantic. Cascades through content_chunks / page_links /
// tags / timeline_entries / page_versions via existing FKs.
await targetEngine.executeRaw('DELETE FROM pages');
}
// Load or create manifest for resume
let manifest = loadManifest();
if (manifest && !manifestMatchesTarget(manifest, targetId)) {
console.log('Previous migration was to a different target. Starting fresh.');
manifest = null;
}
const resumingMatchingManifest = manifest !== null;
// Check if target has data
const targetStats = await targetEngine.getStats();
if (opts.force) {
if (targetStats.page_count > 0) {
console.log('--force: wiping target brain...');
// v0.18.0+ multi-source: deletePage(slug) is now source-scoped (defaults
// to 'default'), so per-page iteration would skip non-default-source
// rows. migrate-engine --force is a destructive wipe across the entire
// brain — all sources, all pages — so we issue a raw DELETE that matches
// the original semantic. Cascades through content_chunks / page_links /
// tags / timeline_entries / page_versions via existing FKs.
await targetEngine.executeRaw('DELETE FROM pages');
}
// --force always starts this exact migration fresh against this target:
// a manifest tracking a previous attempt must not be trusted to skip
// pages, regardless of whether the target LOOKED non-empty just now
// (e.g. the target DB file was recreated out-of-band but
// ~/.gbrain/migrate-manifest.json survived) — round 2 of #3194.
manifest = null;
} else if (targetStats.page_count > 0 && !resumingMatchingManifest) {
console.error(`Target brain is not empty (${targetStats.page_count} pages).`);
console.error('Run with --force to overwrite, or migrate to an empty brain.');
await targetEngine.disconnect();
process.exit(1);
} else if (targetStats.page_count > 0 && resumingMatchingManifest) {
console.log(`Resuming previous migration: ${manifest!.completed_slugs.length} page(s) already copied.`);
}
// v0.32.8 F8: manifest keys are now `${source_id}::${slug}` so multi-source
// migrations don't collide on same-slug-different-source pages. Pre-v0.32.8
// entries were bare slugs; we keep treating those as default-source for
@@ -328,13 +219,6 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
started_at: new Date().toISOString(),
};
}
// Persist immediately, before any page copy runs. Otherwise a run where
// EVERY page fails after its putPage lands (but before completed_slugs
// ever gets a successful entry) leaves the target non-empty with no
// manifest file on disk at all — the next invocation can't tell this
// was a resumable in-progress migration and hits the non-empty guard
// above requiring --force (round 2 of #3194).
saveManifest(manifest);
// Pages.source_id is a foreign key. Copy the complete source catalog first,
// including archived rows and sync/routing metadata, so every page write has
@@ -351,68 +235,82 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('migrate.copy_pages', pagesToMigrate.length);
// v0.32.8 F8: thread source_id end-to-end so multi-source pages migrate
// intact. Pre-fix: putPage / getTags / getTimeline / getRawData / getLinks
// all silently defaulted to source_id='default', so non-default-source
// tags / timeline / raw / links were either dropped or attached to the
// wrong row.
let migrated = 0;
const failures: MigratePageFailure[] = [];
for (const page of pagesToMigrate) {
try {
await copyPageToTarget(sourceEngine, targetEngine, page);
// Track progress with composite key so multi-source resume is correct.
manifest!.completed_slugs.push(makeManifestKey(page.source_id, page.slug));
saveManifest(manifest!);
migrated++;
} catch (e) {
// #3194: a per-page write failure must never be swallowed into the
// success count. Leave it OUT of completed_slugs (a resume retries
// it — putPage/upsertChunks/etc. are all upserts, so re-running the
// whole page copy is safe) and surface it in the final summary below
// instead of letting "N pages copied" imply everything landed.
failures.push({
source_id: page.source_id,
slug: page.slug,
reason: e instanceof Error ? e.message : String(e),
});
// v0.32.8 F8: thread source_id end-to-end so multi-source pages migrate
// intact. Pre-fix: putPage / getTags / getTimeline / getRawData / getLinks
// all silently defaulted to source_id='default', so non-default-source
// tags / timeline / raw / links were either dropped or attached to the
// wrong row.
const sourceOpts = { sourceId: page.source_id };
// Copy page (preserve source_id)
await targetEngine.putPage(page.slug, {
type: page.type,
title: page.title,
compiled_truth: page.compiled_truth,
timeline: page.timeline,
frontmatter: page.frontmatter,
content_hash: page.content_hash,
}, sourceOpts);
// Copy chunks with embeddings.
const chunks = await sourceEngine.getChunksWithEmbeddings(page.slug, sourceOpts);
if (chunks.length > 0) {
await targetEngine.upsertChunks(page.slug, chunks.map(c => ({
chunk_index: c.chunk_index,
chunk_text: c.chunk_text,
chunk_source: c.chunk_source,
embedding: c.embedding || undefined,
model: c.model,
token_count: c.token_count || undefined,
})), sourceOpts);
}
// Copy tags
const tags = await sourceEngine.getTags(page.slug, sourceOpts);
for (const tag of tags) {
await targetEngine.addTag(page.slug, tag, sourceOpts);
}
// Copy timeline
const timeline = await sourceEngine.getTimeline(page.slug, sourceOpts);
for (const entry of timeline) {
await targetEngine.addTimelineEntry(page.slug, {
date: entry.date,
source: entry.source,
summary: entry.summary,
detail: entry.detail,
}, sourceOpts);
}
// Copy raw data
const rawData = await sourceEngine.getRawData(page.slug, undefined, sourceOpts);
for (const rd of rawData) {
await targetEngine.putRawData(page.slug, rd.source, rd.data, sourceOpts);
}
// Copy versions
const versions = await sourceEngine.getVersions(page.slug, sourceOpts);
// Versions are snapshots, we recreate them on the target
// (createVersion takes a snapshot of current state, which we just set)
// Track progress with composite key so multi-source resume is correct.
manifest!.completed_slugs.push(makeManifestKey(page.source_id, page.slug));
saveManifest(manifest!);
migrated++;
progress.tick(1, page.slug);
}
progress.finish();
if (failures.length > 0) {
console.error(`\n${failures.length} of ${pagesToMigrate.length} page(s) FAILED to copy and were NOT migrated:`);
for (const f of failures) {
const key = f.source_id === 'default' ? f.slug : `${f.source_id}::${f.slug}`;
console.error(` - ${key}: ${f.reason}`);
}
console.error('Re-run `gbrain migrate` to retry the failed pages (already-copied pages resume via the manifest).');
// Non-fatal so the run still copies links + config for everything that
// DID land, but the process must exit non-zero — a partial migration
// must never look identical to a clean one.
setCliExitVerdict(1);
}
// Copy links (after all pages exist in target).
// v0.32.8 F8: thread source_id so cross-source links migrate correctly.
// #3194: a page that failed to copy above does NOT exist on the target,
// so any link touching it would violate the target's FK and abort this
// whole phase (the exact "addLink failed: page ... not found" crash from
// the original report). Skip links on either end of a known-failed page —
// a retry that successfully copies the page also re-copies its links.
const failedKeys = new Set(failures.map(f => makeManifestKey(f.source_id, f.slug)));
console.log('Copying links...');
progress.start('migrate.copy_links', allPages.length);
for (const page of allPages) {
if (failedKeys.has(makeManifestKey(page.source_id, page.slug))) {
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,
@@ -444,38 +342,22 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
// Update local config. v0.37 fix wave: preserve existing file-plane
// embedding/expansion/chat config across the engine migration; only
// the engine + connection target should change.
//
// #3194: only flip the ACTIVE config when the migration is fully clean.
// A partial migration leaves the target's data incomplete; auto-switching
// every subsequent `gbrain` invocation onto that incomplete target would
// (a) make the failure invisible behind otherwise-normal usage and (b)
// break the natural retry — `gbrain migrate --to X` again would hit the
// "Already using X engine" guard even though the migration never actually
// finished. Leaving the file-plane config untouched keeps the source the
// active engine, so a retry (which resumes via the still-intact manifest)
// is a same-shaped command, not a special case.
if (failures.length === 0) {
const existingFile = (await import('../core/config.ts')).loadConfigFileOnly() ?? ({} as GBrainConfig);
const newConfig: GBrainConfig = {
...existingFile,
engine: opts.targetEngine,
...(opts.targetEngine === 'postgres'
? { database_url: targetConfig.database_url, database_path: undefined }
: { database_path: targetConfig.database_path, database_url: undefined }),
};
saveConfig(newConfig);
// Clean up the resume manifest — only safe once nothing is left pending.
clearManifest();
}
const existingFile = (await import('../core/config.ts')).loadConfigFileOnly() ?? ({} as GBrainConfig);
const newConfig: GBrainConfig = {
...existingFile,
engine: opts.targetEngine,
...(opts.targetEngine === 'postgres'
? { database_url: targetConfig.database_url, database_path: undefined }
: { database_path: targetConfig.database_path, database_url: undefined }),
};
saveConfig(newConfig);
if (failures.length > 0) {
console.log(`\nMigration completed with errors. ${migrated} of ${pagesToMigrate.length} pages copied, ${failures.length} failed (${completedSet.size} already done from a prior run). See failure list above.`);
console.log(`Config NOT switched — still using engine: ${config.engine}. Re-run \`gbrain migrate --to ${opts.targetEngine}\` to retry; already-copied pages resume via the manifest.`);
} else {
console.log(`\nMigration complete. ${migrated} pages transferred.`);
console.log(`Config updated to engine: ${opts.targetEngine}`);
}
if (failures.length === 0 && config.engine === 'pglite' && config.database_path) {
// Clean up
clearManifest();
console.log(`\nMigration complete. ${migrated} pages transferred.`);
console.log(`Config updated to engine: ${opts.targetEngine}`);
if (config.engine === 'pglite' && config.database_path) {
console.log(`Original PGLite brain preserved at ${config.database_path} (backup).`);
}
+11 -15
View File
@@ -186,6 +186,17 @@ async function phaseBFenceFacts(
const localPathById = new Map<string, string | null>();
for (const s of sources) localPathById.set(s.id, s.local_path);
// Dirty-tree refusal: check every source's local_path before writing.
for (const [id, localPath] of localPathById) {
if (localPath && isLocalPathDirty(localPath)) {
return {
name: 'fence_facts',
status: 'failed',
detail: `source "${id}" has uncommitted changes in ${localPath}. Commit or stash, then re-run.`,
};
}
}
// Walk legacy rows in (source_id, entity_slug) groups for per-page
// atomic writes.
const legacy = await engine.executeRaw<LegacyFactRow>(
@@ -224,21 +235,6 @@ async function phaseBFenceFacts(
groups.set(key, list);
}
// Dirty-tree refusal: check ONLY the sources we are about to write
// into. A dirty tree in an unrelated source (or zero fenceable rows
// at all) must not block a no-op or a targeted backfill (#927).
const targetSourceIds = new Set([...groups.keys()].map(k => k.split('\0')[0]));
for (const id of targetSourceIds) {
const localPath = localPathById.get(id);
if (localPath && isLocalPathDirty(localPath)) {
return {
name: 'fence_facts',
status: 'failed',
detail: `source "${id}" has uncommitted changes in ${localPath}. Commit or stash, then re-run.`,
};
}
}
for (const [key, group] of groups) {
const [sourceId, entitySlug] = key.split('\0');
const localPath = localPathById.get(sourceId)!;
+1 -14
View File
@@ -536,20 +536,7 @@ function shouldSkipProvider(modelStr: string, skip: string[]): boolean {
export async function runModels(engine: BrainEngine, args: string[]): Promise<void> {
const json = args.includes('--json');
// args is `subArgs` from cli.ts `handleCliOnly` — the leading 'models'
// token has already been stripped. The subcommand is at args[0], NOT
// args[1]. Pre-fix this check was `args[1]`, so `gbrain models doctor`
// silently fell through to the read view. The doctor probe path was
// unreachable from the CLI.
//
// --help honored FIRST so `gbrain models doctor --help` shows usage
// instead of running network probes (which would spend tokens or
// exit nonzero when the user only asked for help). Pre-fix the
// args[1] ternary happened to dodge this by always falling through
// to the args.includes('--help') branch; the args[0] rewrite needs
// explicit ordering to preserve that behavior.
const hasHelp = args.includes('--help') || args.includes('-h') || args[0] === 'help';
const sub = hasHelp ? 'help' : args[0] === 'doctor' ? 'doctor' : 'read';
const sub = args[1] === 'doctor' ? 'doctor' : args[1] === 'help' || args.includes('--help') || args.includes('-h') ? 'help' : 'read';
if (sub === 'help') {
process.stdout.write(
+1 -5
View File
@@ -142,16 +142,12 @@ export async function runOnboard(engine: BrainEngine, args: string[]): Promise<v
// --auto path: runs through the T2 library orchestrator. Hooks emit CLI
// progress to stderr; the final result lands as JSON on stdout (or human
// summary). extraRemediations (gathered above from runAllOnboardChecks)
// is threaded into the runner so the onboard-check remediations
// (extract-ner, extract-timeline-from-meetings, etc.) reach the planner
// — the same wiring the --check path uses above.
// summary).
const result = await runRemediation(
engine,
{
targetScore,
maxUsd,
extraRemediations,
// --auto --yes opts into the prompt_required tier too; library
// doesn't distinguish auto_apply vs prompt_required, it just runs
// every remediation in the plan. The plan-building side (T12 render)
+55 -10
View File
@@ -15,11 +15,6 @@
import type { BrainEngine } from '../core/engine.ts';
import { createProgress, startHeartbeat } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import {
shouldExcludeFromOrphanReporting,
loadOrphanPolicyOverrides,
type OrphanPolicyOverrides,
} from '../core/orphan-policy.ts';
// --- Types ---
@@ -37,14 +32,65 @@ export interface OrphanResult {
excluded: number;
}
// --- Filter constants ---
/** Slug suffixes that are always auto-generated root files */
const AUTO_SUFFIX_PATTERNS = ['/_index', '/log'];
/** Page slugs that are pseudo-pages by convention */
const PSEUDO_SLUGS = new Set(['_atlas', '_index', '_stats', '_orphans', '_scratch', 'claude']);
/** Slug segment that marks raw sources */
const RAW_SEGMENT = '/raw/';
/** Slug prefixes where no inbound links is expected */
const DENY_PREFIXES = [
'output/',
'dashboards/',
'scripts/',
'templates/',
'openclaw/config/',
];
/** First slug segments where no inbound links is expected */
const FIRST_SEGMENT_EXCLUSIONS = new Set([
'scratch',
'thoughts',
'catalog',
'entities',
'raw',
'atoms',
'skills',
]);
// --- Filter logic ---
/**
* Returns true if a slug should be excluded from orphan reporting by default.
* These are pages where having no inbound links is expected / not a content problem.
*/
export function shouldExclude(slug: string, overrides?: OrphanPolicyOverrides): boolean {
return shouldExcludeFromOrphanReporting(slug, overrides);
export function shouldExclude(slug: string): boolean {
// Pseudo-pages (exact match)
if (PSEUDO_SLUGS.has(slug)) return true;
// Auto-generated suffix patterns
for (const suffix of AUTO_SUFFIX_PATTERNS) {
if (slug.endsWith(suffix)) return true;
}
// Raw source slugs
if (slug.includes(RAW_SEGMENT)) return true;
// Deny-prefix slugs
for (const prefix of DENY_PREFIXES) {
if (slug.startsWith(prefix)) return true;
}
// First-segment exclusions
const firstSegment = slug.split('/')[0];
if (FIRST_SEGMENT_EXCLUSIONS.has(firstSegment)) return true;
return false;
}
/**
@@ -110,7 +156,6 @@ export async function findOrphans(
let allOrphans: { slug: string; title: string; domain: string | null }[];
let total: number;
let excludedAll: number;
const overrides = includePseudo ? undefined : await loadOrphanPolicyOverrides(engine);
try {
allOrphans = await engine.findOrphanPages(
sourceIds ? { sourceIds } : sourceId ? { sourceId } : undefined,
@@ -139,7 +184,7 @@ export async function findOrphans(
total = liveRows.length;
excludedAll = includePseudo
? 0
: liveRows.reduce((n, r) => n + (shouldExclude(r.slug, overrides) ? 1 : 0), 0);
: liveRows.reduce((n, r) => n + (shouldExclude(r.slug) ? 1 : 0), 0);
} finally {
stopHb();
progress.finish();
@@ -147,7 +192,7 @@ export async function findOrphans(
const filtered = includePseudo
? allOrphans
: allOrphans.filter(row => !shouldExclude(row.slug, overrides));
: allOrphans.filter(row => !shouldExclude(row.slug));
const orphans: OrphanPage[] = filtered.map(row => ({
slug: row.slug,
+10 -116
View File
@@ -45,7 +45,6 @@ import {
type IngestionContentType,
type IngestionEvent,
} from '../core/ingestion/types.ts';
import { resolveOwnerHolder } from '../core/owner-holder.ts';
/**
* /health endpoint timeout. 3s rather than 5s: Fly.io's default
@@ -113,24 +112,6 @@ export function shouldSuppressBootstrapPrint(opts: {
return !opts.isTty;
}
export type OAuthTokenRateLimitConfig = {
windowMs: number;
max: number;
};
function parsePositiveIntEnv(value: string | undefined, fallback: number): number {
if (value === undefined) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export function resolveOAuthTokenRateLimit(env: NodeJS.ProcessEnv = process.env): OAuthTokenRateLimitConfig {
return {
windowMs: parsePositiveIntEnv(env.GBRAIN_OAUTH_TOKEN_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000),
max: parsePositiveIntEnv(env.GBRAIN_OAUTH_TOKEN_RATE_LIMIT_MAX, 50),
};
}
export type ProbeHealthResult =
| { ok: true; status: 200; body: { status: 'ok'; version: string; engine: string; [k: string]: unknown } }
| { ok: false; status: 503; body: { error: 'service_unavailable'; error_description: string } };
@@ -449,34 +430,6 @@ export function skillPublishStatus(publishSkills: boolean): { bannerValue: strin
};
}
/**
* #1196: startup embedding-width guard for stateless host deployments.
*
* `embedding_model` / `embedding_dimensions` are file/env-plane only, so a
* container booted WITHOUT a config.json (stateless host) resolves the
* compiled-in default embedding width. Against an existing brain whose
* `content_chunks.embedding` is a different `vector(N)`, every write then
* fails with an opaque dim mismatch. Run doctor's existing
* embedding_width_consistency check at serve startup and return a loud
* banner (with the paste-ready recipe) when it isn't ok. Fail-open: a check
* error never blocks serving read traffic.
*/
export async function embeddingWidthStartupWarning(engine: BrainEngine): Promise<string | null> {
try {
const { checkEmbeddingWidthConsistency } = await import('./doctor.ts');
const check = await checkEmbeddingWidthConsistency(engine);
if (check.status === 'ok') return null;
return (
`[serve-http] WARNING: embedding width check failed — writes that embed will fail until fixed.\n` +
`${check.message}\n` +
`Stateless hosts: embedding_model/embedding_dimensions resolve from env/config.json only — ` +
`set GBRAIN_EMBEDDING_MODEL / GBRAIN_EMBEDDING_DIMENSIONS (or mount config.json) to match the brain's schema.`
);
} catch {
return null;
}
}
export async function runServeHttp(engine: BrainEngine, options: ServeHttpOptions) {
const { port, tokenTtl, enableDcr, enableDcrInsecure, publicUrl, logFullParams } = options;
// v0.34.1 (#864, D11): default bind flipped from 0.0.0.0 to 127.0.0.1.
@@ -501,14 +454,6 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
);
}
// #1196: fail-loud at startup when the resolved embedding width diverges
// from the brain's actual vector(N) column (stateless containers falling
// through to the compiled-in default). Non-fatal: reads still work.
{
const widthWarn = await embeddingWidthStartupWarning(engine);
if (widthWarn) console.error(widthWarn);
}
// Skill-publishing status for the banner + nudge. Mirrors readMcpPublishSkills
// (skill-catalog.ts): the DB plane (`gbrain config set`) wins over the file
// plane. When OFF, a connected coding agent can't see the host's skill
@@ -687,13 +632,12 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// Custom client_credentials handler (before mcpAuthRouter)
// SDK's token handler only supports authorization_code and refresh_token
// ---------------------------------------------------------------------------
const oauthTokenRateLimit = resolveOAuthTokenRateLimit();
const ccRateLimiter = rateLimit({
windowMs: oauthTokenRateLimit.windowMs,
max: oauthTokenRateLimit.max,
windowMs: 15 * 60 * 1000,
max: 50,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too_many_requests', error_description: 'Rate limit exceeded. Try again later.' },
message: { error: 'too_many_requests', error_description: 'Rate limit exceeded. Try again in 15 minutes.' },
});
// Magic-link rate limiter: 10 requests/min/IP. The bootstrap token is
@@ -899,21 +843,6 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// reverse proxies / tunnels; default to localhost for dev.
const issuerUrl = new URL(publicUrl || `http://localhost:${port}`);
// MCP authorization spec (2025-06-18 draft §5.1) and RFC 9728 require the
// protected resource server to return its discovery metadata URL in the
// WWW-Authenticate header on 401 responses:
//
// WWW-Authenticate: Bearer resource_metadata="<URL>"
//
// Clients (claude.ai, Cursor, every other MCP-aware OAuth client) use that
// URL to find the authorization-server discovery doc + token endpoint
// without the user having to paste those URLs manually. Pre-fix the header
// shipped `Bearer error="invalid_token", ...` with no resource_metadata
// parameter, so MCP clients couldn't begin the OAuth flow from a fresh
// 401 — they would silently fail to connect with a generic "couldn't
// reach the MCP server" error.
const resourceMetadataUrl = `${issuerUrl.toString().replace(/\/$/, '')}/.well-known/oauth-protected-resource`;
// F9: cookie `secure` flag honors both the request's TLS state (req.secure
// is set when express trust-proxy lands an X-Forwarded-Proto: https) AND
// the operator's declared issuer protocol (so a Cloudflare-tunnel deploy
@@ -1261,7 +1190,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
app.get('/admin/api/calibration/pattern/:id', requireAdmin, async (req: Request, res: Response) => {
try {
const { getLatestProfile } = await import('./calibration.ts');
const holder = resolveOwnerHolder({ override: (req.query.holder as string) || undefined, configValue: await engine.getConfig('emotional_weight.user_holder') });
const holder = (req.query.holder as string) || 'garry';
const profile = await getLatestProfile(engine, { holder });
if (!profile) {
res.status(404).json({ error: 'no_profile' });
@@ -1311,7 +1240,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
app.get('/admin/api/calibration/profile', requireAdmin, async (req: Request, res: Response) => {
try {
const { getLatestProfile } = await import('./calibration.ts');
const holder = resolveOwnerHolder({ override: (req.query.holder as string) || undefined, configValue: await engine.getConfig('emotional_weight.user_holder') });
const holder = (req.query.holder as string) || 'garry';
const profile = await getLatestProfile(engine, { holder });
res.json(profile);
} catch (err) {
@@ -1328,7 +1257,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
renderAbandonedThreadsCard,
renderPatternStatementsCard,
} = await import('../core/calibration/svg-renderer.ts');
const holder = resolveOwnerHolder({ override: (req.query.holder as string) || undefined, configValue: await engine.getConfig('emotional_weight.user_holder') });
const holder = (req.query.holder as string) || 'garry';
const type = req.params.type;
const profile = await getLatestProfile(engine, { holder });
@@ -1567,38 +1496,6 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
}
});
// v0.42.x (#1914): rescope an OAuth client's write source / federated read
// scope. Admin-gated on purpose — DCR clients must never self-widen their
// scope (fail-closed trust); only the operator rescopes, here or via
// `gbrain auth rescope-client`. Source ids are validated by the canonical
// validator inside rescopeClient.
app.post('/admin/api/rescope-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
try {
const { clientId, sourceId, federatedRead } = req.body ?? {};
if (!clientId || typeof clientId !== 'string') {
res.status(400).json({ error: 'clientId required' });
return;
}
if (federatedRead !== undefined &&
!(Array.isArray(federatedRead) && federatedRead.every((s: unknown) => typeof s === 'string'))) {
res.status(400).json({ error: 'federatedRead must be an array of source id strings' });
return;
}
if (sourceId !== undefined && typeof sourceId !== 'string') {
res.status(400).json({ error: 'sourceId must be a string' });
return;
}
const result = await oauthProvider.rescopeClient(clientId, { sourceId, federatedRead });
res.json(result);
} catch (e) {
const message = e instanceof Error ? e.message : 'Rescope failed';
const status = /No OAuth client found/.test(message) ? 404
: /Invalid source_id|requires --source|cannot be empty|does not exist/.test(message) ? 400
: 500;
res.status(status).json({ error: message });
}
});
// Revoke OAuth client
app.post('/admin/api/revoke-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
try {
@@ -1704,7 +1601,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
res.status(405).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed' }, id: null });
});
app.post('/mcp', requireBearerAuth({ verifier: oauthProvider, resourceMetadataUrl }), async (req: Request, res: Response) => {
app.post('/mcp', requireBearerAuth({ verifier: oauthProvider }), async (req: Request, res: Response) => {
const startTime = Date.now();
const authInfo = (req as any).auth as AuthInfo;
@@ -2047,7 +1944,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
app.post(
'/ingest',
ingestRateLimiter,
requireBearerAuth({ verifier: oauthProvider, requiredScopes: ['write'], resourceMetadataUrl }),
requireBearerAuth({ verifier: oauthProvider, requiredScopes: ['write'] }),
express.raw({ type: '*/*', limit: ingestMaxBytes }),
async (req: Request, res: Response) => {
const startTime = Date.now();
@@ -2249,10 +2146,8 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// Other event types (ping, pull_request, etc.) return 202 'ignored'
// so GitHub doesn't retry.
// D15.5: HMAC compare uses the shared safeHexEqual helper.
// D18: submits 'sync' job with extraction + auto_embed_backfill enabled and
// priority -10 (above autopilot's 0). This opts normal incremental pushes
// into sync's inline extraction while pagesAffected still identifies the
// changed pages. The sync core can still defer large (>100) changes.
// D18: submits 'sync' job with auto_embed_backfill=true and priority -10
// (above autopilot's 0).
// ---------------------------------------------------------------------------
const githubWebhookLimiter = rateLimit({
windowMs: 60_000,
@@ -2372,7 +2267,6 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
'sync',
{
sourceId: source.id,
noExtract: false,
auto_embed_backfill: true,
embed_reason: 'webhook',
},
+1 -68
View File
@@ -9,17 +9,6 @@ import { startMcpServer } from '../mcp/server.ts';
// the dir, sees a dead PID, and removes it).
const CLEANUP_DEADLINE_MS = 5_000;
// Boot-readiness deadline (#3273). A serve process that wedges mid-boot
// (e.g. an MCP boot step that never completes because a configured
// upstream is unreachable) holds the PGLite write lock indefinitely: the
// post-#2348 lock discipline never steals from a live holder, so every
// CLI consumer times out until someone hunts down and kills the PID. If
// startMcpServer hasn't finished connecting the transport within this
// window, we release the engine (dropping the lock) and exit non-zero so
// a supervisor can restart with backoff. Env-tunable via
// GBRAIN_SERVE_BOOT_TIMEOUT_SECONDS; 0 disables.
const DEFAULT_BOOT_TIMEOUT_SECONDS = 60;
// How often the parent-process watchdog polls the live kernel parent PID
// (via `readLiveParentPid`, NOT the cached `process.ppid` — see that
// helper's comment). We don't receive a signal when our parent dies (the
@@ -78,10 +67,6 @@ export interface ServeOptions {
// transport.onclose still cover legitimate shutdown.
// Defaults to `process.env.MCP_STDIO === '1'` when omitted.
mcpStdio?: boolean;
// Test seam for the boot-readiness deadline (#3273). Milliseconds.
// Defaults to GBRAIN_SERVE_BOOT_TIMEOUT_SECONDS (seconds; 60 when
// unset, 0 disables) when omitted.
bootTimeoutMs?: number;
}
export async function runServe(
@@ -157,43 +142,7 @@ export async function runServe(
installStdioLifecycle(engine, args, opts);
const start = opts.startMcpServer ?? startMcpServer;
// Boot-readiness deadline (#3273): never sit on the PGLite write lock
// forever with a boot that never completes. On expiry: log, release the
// engine (drops the lock), exit non-zero so supervisors restart with
// backoff. The disconnect itself is raced against CLEANUP_DEADLINE_MS,
// same as the graceful-shutdown path, so a wedged WASM close can't trap
// us either.
const bootTimeoutMs = opts.bootTimeoutMs ?? resolveBootTimeoutMs();
let bootDeadline: ReturnType<typeof setTimeout> | null = null;
if (bootTimeoutMs > 0) {
const log = opts.log ?? ((msg: string) => console.error(msg));
const exit = opts.exit ?? ((code?: number) => { process.exit(code); });
bootDeadline = setTimeout(() => {
log(
`GBrain MCP server: boot did not complete within ${bootTimeoutMs}ms — releasing DB lock and exiting so other consumers unblock (check configured provider endpoints; tune via GBRAIN_SERVE_BOOT_TIMEOUT_SECONDS, 0 disables)`,
);
const cleanup = setTimeout(() => { exit(1); }, CLEANUP_DEADLINE_MS);
cleanup.unref?.();
Promise.resolve()
.then(() => engine.disconnect())
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
log(`GBrain MCP server: boot-deadline cleanup error: ${msg}`);
})
.finally(() => {
clearTimeout(cleanup);
exit(1);
});
}, bootTimeoutMs);
bootDeadline.unref?.();
}
try {
await start(engine);
} finally {
if (bootDeadline) clearTimeout(bootDeadline);
}
await start(engine);
// startMcpServer's `await server.connect(transport)` resolves once the
// SDK has wired up its stdin 'data' listener; that listener keeps the
// event loop alive. We deliberately do NOT add `await new Promise(() =>
@@ -201,22 +150,6 @@ export async function runServe(
// hooks from being able to call process.exit() cleanly.
}
// Env resolution for the boot deadline. Lenient (warn + default) rather
// than throw: this is an incident-time escape hatch, and a typo'd env var
// must not turn a boot-safety net into a boot failure of its own.
function resolveBootTimeoutMs(): number {
const raw = process.env.GBRAIN_SERVE_BOOT_TIMEOUT_SECONDS;
if (raw === undefined || raw.trim() === '') return DEFAULT_BOOT_TIMEOUT_SECONDS * 1000;
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) {
console.error(
`[gbrain serve] ignoring invalid GBRAIN_SERVE_BOOT_TIMEOUT_SECONDS=${JSON.stringify(raw)} — using default ${DEFAULT_BOOT_TIMEOUT_SECONDS}s`,
);
return DEFAULT_BOOT_TIMEOUT_SECONDS * 1000;
}
return n * 1000;
}
interface StdioLifecycleDeps {
stdin: NodeJS.ReadableStream & { isTTY?: boolean };
signals: Pick<NodeJS.Process, 'on'>;
+11 -170
View File
@@ -213,7 +213,7 @@ export interface SyncResult {
* cron operators can disambiguate timeout vs pull-timeout in monitoring.
*/
filesImported?: number;
reason?: 'timeout' | 'pull_timeout' | 'pull_failed' | 'stall_timeout' | 'checkpoint_unavailable';
reason?: 'timeout' | 'pull_timeout' | 'stall_timeout' | 'checkpoint_unavailable';
/**
* v0.42.x (#1794): cumulative file paths durably banked to the checkpoint
* across THIS run + prior resumed runs. Surfaced on every partial/blocked
@@ -909,25 +909,6 @@ export function buildAutoEmbedArgs(slugs: string[], sourceId?: string): string[]
return sourceId ? ['--source', sourceId, '--slugs', ...slugs] : ['--slugs', ...slugs];
}
/**
* Resolve sync's effective no-embed mode from CLI args + config.
*
* The deferred-setup sentinel (`embedding_disabled: true`, written by
* `gbrain init --no-embedding`) is an implicit `--no-embed`: without this,
* the embed credential preflight demands provider credentials the user
* deliberately deferred at init, and every `gbrain sync` on a keyless
* brain exits 1. See embed-preflight.ts's skip protocol the sentinel is
* meant to be honored before the credential check ever runs.
*
* Exported for `test/sync-no-embed-sentinel.test.ts`.
*/
export function resolveNoEmbed(
args: string[],
cfg: { embedding_disabled?: boolean } | null,
): boolean {
return args.includes('--no-embed') || cfg?.embedding_disabled === true;
}
/**
* Shell out to git with a generous maxBuffer.
*
@@ -937,28 +918,12 @@ export function resolveNoEmbed(
*
* 100 MiB is generous but still bounded a 100K-file diff with long
* paths tops out around 1020 MiB in practice.
*
* `silenceStderr`: Node's `execFileSync` writes the child's stderr straight
* through to the parent's real stderr by default (in addition to attaching
* it to the thrown error's `.stderr`) *unless* an explicit `stdio` array is
* given. Callers that treat a failure as an expected, self-handled outcome
* (rather than a crash to surface) pass `silenceStderr: true` so git's raw
* `fatal: ...` line never reaches the process's own stderr only the
* caller's own (usually friendlier) handling of the caught error does.
* Default `false` preserves today's passthrough for every other call site.
*/
function git(
repoPath: string,
args: string[],
configs: string[] = [],
timeoutMs = 30000,
{ silenceStderr = false }: { silenceStderr?: boolean } = {},
): string {
function git(repoPath: string, args: string[], configs: string[] = [], timeoutMs = 30000): string {
return execFileSync('git', buildGitInvocation(repoPath, args, configs), {
encoding: 'utf-8',
timeout: timeoutMs,
maxBuffer: 100 * 1024 * 1024,
...(silenceStderr ? { stdio: ['ignore', 'pipe', 'pipe'] as const } : {}),
}).trim();
}
@@ -967,19 +932,10 @@ function git(
* `git -C <path> rev-parse --show-toplevel`. Handles worktrees and submodules
* natively (git itself resolves them). Throws a user-friendly error when no
* git repo is found.
*
* The probe's failure is expected and routine (a non-git-yet brain dir, a
* scratch dir, a caller checking "is this a repo?") `sync.ts` self-heals
* it (git-init) or surfaces the message below, never the raw git stderr.
* `silenceStderr: true` keeps git's own `fatal: not a git repository ...`
* off the process's real stderr so operator log-scanning for `fatal:` as a
* crash signature doesn't false-alarm on every routine probe miss (#2964
* auto-recovery made the *outcome* self-healing; this keeps the *log* quiet
* about the expected miss that triggered it).
*/
export function discoverGitRoot(inputPath: string): string {
try {
return git(inputPath, ['rev-parse', '--show-toplevel'], [], 30000, { silenceStderr: true });
return git(inputPath, ['rev-parse', '--show-toplevel']);
} catch {
throw new Error(
`Not inside a git repository: ${inputPath}. GBrain sync requires a git-initialized repo (or a subdirectory of one).`,
@@ -1423,7 +1379,6 @@ See also:
{
sourceId: sourceIdArg,
repoPath: source.local_path,
noExtract: false,
auto_embed_backfill: true,
embed_reason: 'sync_trigger',
},
@@ -1762,7 +1717,7 @@ function buildPartialResult(opts: {
modified: number;
deleted: number;
renamed: number;
reason: 'timeout' | 'pull_timeout' | 'pull_failed' | 'stall_timeout' | 'checkpoint_unavailable';
reason: 'timeout' | 'pull_timeout' | 'stall_timeout' | 'checkpoint_unavailable';
bankedFiles?: number;
}): SyncResult {
return {
@@ -1993,15 +1948,6 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
});
}
// #3068: remember a warn-and-continue pull failure. The fall-through-to-
// working-tree design stays (local commits still import when the remote is
// unreachable), but a ZERO-import sync after a failed pull must not report
// `up_to_date` / bump the freshness heartbeat — that is what made a
// permanently-failing pull (e.g. a local-path origin rejected by
// protocol.file.allow=never, #1315) invisible forever: every nightly run
// exited 0 with "Already up to date" and doctor's sync_freshness never
// fired because last_sync_at kept advancing.
let pullFailed = false;
if (!opts.noPull && !detachedHead && originRemotePresent) {
const _t0 = Date.now();
serr(`[gbrain phase] sync.git_pull start`);
@@ -2044,7 +1990,6 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
reason: 'pull_timeout',
});
}
pullFailed = true;
if (msg.includes('non-fast-forward') || msg.includes('diverged')) {
serr(`Warning: git pull failed (remote diverged). Syncing from local state.`);
} else {
@@ -2219,29 +2164,6 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
detachedWorkingTreeManifest.renamed.length > 0);
if (lastCommit === headCommit && !versionMismatch && !versionNeverSet && !hasDetachedWorkingTreeChanges) {
// #3068: the pull failed and nothing local advanced — this run imported
// NOTHING and the remote may hold commits we could not fetch. Reporting
// `up_to_date` here (and bumping the heartbeat below) is exactly the
// silent-wedge from the issue: every scheduled sync exits 0 forever while
// the source is stale. Return `partial` instead (not a clean status, and
// last_sync_at stays frozen so doctor/sources-status staleness fires).
// The anchor is untouched; the next sync retries the pull from the same
// bookmark.
if (pullFailed) {
serr(
`[sync] git pull failed and no local changes imported — reporting partial ` +
`(not up_to_date); sync anchor unchanged at ${lastCommit.slice(0, 8)}.`,
);
return buildPartialResult({
fromCommit: lastCommit,
toCommit: lastCommit,
filesImported: 0,
pagesAffected: [],
chunksCreated: 0,
added: 0, modified: 0, deleted: 0, renamed: 0,
reason: 'pull_failed',
});
}
// v0.42.52.0 (PR #22xx): bump last_sync_at as a heartbeat on every successful
// 0-changes sync. D4 invariant ("never advance last_commit on partial") is
// preserved: last_sync_at is a monitoring signal (doctor sync_freshness
@@ -2426,27 +2348,6 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
}
if (totalChanges === 0) {
// #3068: same guard as the git-HEAD-equality gate above — a failed pull
// plus zero imports must not produce a clean `up_to_date` (and must not
// advance the anchor past commits this run never looked at remotely).
// Reached when local-only commits landed with no syncable content while
// the pull kept failing. Nothing is written; the next sync re-diffs the
// same trivial range and retries the pull.
if (pullFailed) {
serr(
`[sync] git pull failed and no syncable changes imported — reporting partial ` +
`(not up_to_date); sync anchor unchanged at ${lastCommit.slice(0, 8)}.`,
);
return buildPartialResult({
fromCommit: lastCommit,
toCommit: lastCommit,
filesImported: 0,
pagesAffected: [],
chunksCreated: 0,
added: 0, modified: 0, deleted: 0, renamed: 0,
reason: 'pull_failed',
});
}
// Update sync state even with no syncable changes (git advanced). v0.42.x
// (#1794): advance to the PINNED target, and clear any checkpoint (a resume
// whose remaining range turned out to have no syncable changes still
@@ -2555,13 +2456,6 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
};
const pagesAffected: string[] = [];
// #1284: slugs deleted this run (delete loop, or renamed-away old slugs are
// NOT pushed — only confirmed deletes land here). pagesAffected stays the
// full manifest for extract/report paths, but the auto-embed at the end
// must NOT be handed deleted slugs: embedPage throws 'Page not found' for
// each one and serr-logs noise. A slug re-imported later in the same run
// (delete + re-add) is removed from this set at its push site.
const deletedSlugs = new Set<string>();
// issue #1939: file paths that imported cleanly this run. The failure-ledger
// gate clears these so a previously-failing file's `attempts` streak resets
// on success (consecutive-failure semantics for the auto-skip valve).
@@ -2722,7 +2616,6 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// slugs (paths in filtered.deleted but with no DB row) so
// downstream extract/embed don't waste lookups.
pagesAffected.push(...deleted);
for (const s of deleted) deletedSlugs.add(s);
// v0.42.x (#1794): the whole batch is handled (deleted or already
// gone); checkpoint every path so a resume skips it.
for (const p of batch) await markCompleted(p);
@@ -2735,7 +2628,6 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
try {
await engine.deletePage(slugs[j], deleteScopedOpts);
pagesAffected.push(slugs[j]);
deletedSlugs.add(slugs[j]);
await markCompleted(batch[j]);
} catch (perSlugErr) {
failedFiles.push({
@@ -2763,7 +2655,6 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
try {
await engine.deletePage(slug, deleteOpts);
pagesAffected.push(slug);
deletedSlugs.add(slug);
await markCompleted(path);
} catch (err) {
failedFiles.push({
@@ -2864,7 +2755,6 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
}
}
pagesAffected.push(newSlug);
deletedSlugs.delete(newSlug); // #1284: rename landed on a previously-deleted slug → embeddable again
await markCompleted(to);
progress.tick(1, newSlug);
}
@@ -3065,7 +2955,6 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
if (result.status === 'imported') {
chunksCreated += result.chunks;
pagesAffected.push(result.slug);
deletedSlugs.delete(result.slug); // #1284: deleted-then-re-added in the same run → embeddable again
// issue #1939: record the file path (not slug) so the gate clears any
// prior failure-ledger row — success resets the auto-skip attempt streak.
succeededPaths.push(path);
@@ -3240,7 +3129,6 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// pin..HEAD diff. Advance to pin.
// - pin NOT an ancestor of HEAD (history REWRITE / reset / force-push) →
// the tree we imported against is gone. Block; do not advance.
let headVerificationSucceeded = false;
try {
const currentHead = git(gitContextRoot, ['rev-parse', 'HEAD']);
if (currentHead !== pin) {
@@ -3256,12 +3144,8 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
path: '<head>',
error: `git history rewritten during sync: pinned target ${pin.slice(0, 8)} is no longer an ancestor of HEAD ${currentHead.slice(0, 8)}`,
});
} else {
headVerificationSucceeded = true;
}
// else: forward progress (enrich committed on top) — safe, advance to pin.
} else {
headVerificationSucceeded = true;
}
} catch (e) {
// rev-parse failure is itself a drift signal (worktree disappeared).
@@ -3307,10 +3191,6 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
...succeededPaths,
...filtered.deleted,
...filtered.renamed.map(r => r.from),
// A prior transient rev-parse timeout records a hard-blocking sentinel that
// operators cannot acknowledge manually. Once pin ancestry is verified on
// a later run, clear that stale sentinel through the ordinary success path.
...(headVerificationSucceeded ? ['<head>'] : []),
];
const gate = await applySyncFailureGate({
@@ -3385,9 +3265,6 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// Log ingest
await engine.logIngest({
// #3242 (attribution sub-bug): credit the sync to the source it wrote
// to, not the shared 'default' bucket.
...(opts.sourceId ? { source_id: opts.sourceId } : {}),
source_type: 'git_sync',
source_ref: `${repoPath} @ ${headCommit.slice(0, 8)}`,
pages_updated: pagesAffected,
@@ -3491,19 +3368,14 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// sync. Non-mismatch errors stay best-effort (rate limits, transient
// network) — those shouldn't break sync.
let embedded = 0;
// #1284: never hand deleted slugs to the embedder — embedPage throws
// 'Page not found' per deleted slug and logs one error line each. Filter
// against this run's confirmed-deleted set (slugs re-imported later in the
// run were removed from it at their push sites).
const embedSlugs = pagesAffected.filter((s) => !deletedSlugs.has(s));
if (!noEmbed && embedSlugs.length > 0 && pagesAffected.length <= 100) {
if (!noEmbed && pagesAffected.length > 0 && pagesAffected.length <= 100) {
try {
const { runEmbedCore } = await import('./embed.ts');
const embedOpts = opts.sourceId
? { slugs: embedSlugs, sourceId: opts.sourceId }
: { slugs: embedSlugs };
? { slugs: pagesAffected, sourceId: opts.sourceId }
: { slugs: pagesAffected };
await runEmbedCore(engine, embedOpts);
embedded = embedSlugs.length;
embedded = pagesAffected.length;
} catch (e: unknown) {
const { EmbeddingDimMismatchError } = await import('./embed.ts');
if (e instanceof EmbeddingDimMismatchError) {
@@ -3520,7 +3392,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
return {
status: 'synced',
fromCommit: lastCommit,
toCommit: pin,
toCommit: headCommit,
added: filtered.added.length,
modified: filtered.modified.length,
deleted: filtered.deleted.length,
@@ -4142,7 +4014,7 @@ See also:
const dryRun = args.includes('--dry-run');
const full = args.includes('--full');
const noPull = args.includes('--no-pull');
const noEmbed = resolveNoEmbed(args, loadConfig());
const noEmbed = args.includes('--no-embed');
const noExtract = args.includes('--no-extract'); // v0.42.7 #1696
const skipFailed = args.includes('--skip-failed');
const retryFailed = args.includes('--retry-failed');
@@ -4674,9 +4546,6 @@ See also:
status: r.status,
...(r.result ? {
sync_status: r.result.status,
// #3068: surface the partial reason (e.g. pull_failed) so JSON
// consumers can distinguish a self-healing timeout from a wedge.
...(r.result.reason ? { reason: r.result.reason } : {}),
added: r.result.added,
modified: r.result.modified,
deleted: r.result.deleted,
@@ -4698,14 +4567,7 @@ See also:
// Best-effort, stderr-only; skipped on dry-run.
if (!dryRun) await maybeExtractionNudge(engine);
// #3068: any source wedged on a failed pull (partial/pull_failed) makes
// the whole --all run non-zero — it will not self-heal on retry, so a
// green exit would hide it from cron/monitoring. Timeout-class partials
// keep the pre-existing exit-0 behavior (they converge on retry).
const pullFailedCount = perSourceResults.filter(
(r) => r.status === 'ok' && r.result?.status === 'partial' && r.result.reason === 'pull_failed',
).length;
if (errCount > 0 || pullFailedCount > 0) process.exit(1);
if (errCount > 0) process.exit(1);
return;
}
@@ -4793,16 +4655,6 @@ See also:
process.off('SIGINT', onSingleSourceSigint);
}
printSyncResult(result);
// #3068: a pull_failed partial is NOT a success — unlike timeout-class
// partials (which converge on retry), a failing pull will not self-heal.
// Exit non-zero so cron/monitoring sees the wedge instead of a green run.
// Routed through the owned verdict channel (NOT bare `process.exitCode`,
// which PGLite's Emscripten runtime clobbers mid-run — see
// src/core/cli-force-exit.ts).
if (result.status === 'partial' && result.reason === 'pull_failed') {
const { setCliExitVerdict } = await import('../core/cli-force-exit.ts');
setCliExitVerdict(1);
}
// v0.42.7 (#1696, D5): extraction-lag nudge after a completed single-source
// sync. Fire on every non-error completion (synced | first_sync | up_to_date)
// — NOT just 'synced'; a fresh/--full import (`first_sync`) is the biggest
@@ -5508,17 +5360,6 @@ function printSyncResult(result: SyncResult, sink: NodeJS.WriteStream = process.
write(` Fix the files then re-run 'gbrain sync', or 'gbrain sync --skip-failed' to move on.`);
break;
case 'partial':
// #3068: a failed (non-timeout) pull with zero imports gets its own
// message — "imported 0 of 0" reads like success, but the local
// checkout may be behind a remote we could not fetch.
if (result.reason === 'pull_failed') {
write(
`Sync INCOMPLETE at ${result.fromCommit?.slice(0, 8) ?? '<initial>'}: ` +
`git pull failed — the local checkout may be behind its remote.`,
);
write(` Fix the pull (see the warning above), then re-run 'gbrain sync' (last_commit unchanged; safe to retry).`);
break;
}
// v0.41.13.0 (T7 / D-V3-5): --timeout fired before the bookmark write
// so last_commit is UNCHANGED. The next sync re-walks the same diff
// and content_hash short-circuits already-imported files at ~10ms each.
+2 -3
View File
@@ -29,7 +29,6 @@ import {
} from '../core/takes-fence.ts';
import { withPageLock } from '../core/page-lock.ts';
import { resolveSourceId } from '../core/source-resolver.ts';
import { resolveOwnerHolder } from '../core/owner-holder.ts';
// --- Helpers ---
@@ -292,7 +291,7 @@ async function cmdSupersede(engine: BrainEngine, args: string[], sourceId?: stri
const pageId = await getPageId(engine, slug, sourceId);
// Read existing row to inherit kind/holder unless overridden
const existing = await engine.listTakes({ page_id: pageId, active: true, limit: 500 });
const existing = await engine.listTakes({ page_id: pageId, active: false, limit: 500 });
const target = existing.find(t => t.row_num === rowNum);
if (!target) {
console.error(`Row #${rowNum} not found on ${slug}.`);
@@ -365,7 +364,7 @@ async function cmdResolve(engine: BrainEngine, args: string[], sourceId?: string
// --evidence is the v0.30.0 alias for --source on the resolve subcommand
// (semantic clarity: "what evidence resolved this bet?").
const source = flagValue(args, '--evidence') ?? flagValue(args, '--source');
const resolvedBy = flagValue(args, '--by') ?? resolveOwnerHolder({ configValue: await engine.getConfig('emotional_weight.user_holder') });
const resolvedBy = flagValue(args, '--by') ?? 'garry';
const dirArg = flagValue(args, '--dir');
const pageId = await getPageId(engine, slug, sourceId);
+2 -2
View File
@@ -6,7 +6,7 @@
* degrades to gather-only output with a warning if missing.
*/
import type { BrainEngine } from '../core/engine.ts';
import { runThink, persistSynthesis, stripGapsSection } from '../core/think/index.ts';
import { runThink, persistSynthesis } from '../core/think/index.ts';
import { loadConfig, isThinClient } from '../core/config.ts';
import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts';
@@ -157,7 +157,7 @@ prints what would have been the input (exit 0).
// Human-readable output
console.log(`# ${question}\n`);
console.log(stripGapsSection(result.answer));
console.log(result.answer);
console.log('');
if (result.gaps.length > 0) {
console.log('## Gaps');
+3 -13
View File
@@ -18,22 +18,12 @@
import { loadConfig } from '../config.ts';
export function hasAnthropicKey(): boolean {
return resolveAnthropicKey() !== undefined;
}
/**
* Resolve the actual key value: env first, then the gbrain config file.
* Callers constructing an Anthropic client directly (e.g. the legacy
* subagent path) must pass this as `apiKey` a bare `new Anthropic()`
* only sees env, so launchd/MCP workers with config-stored keys fail.
*/
export function resolveAnthropicKey(): string | undefined {
if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY;
if (process.env.ANTHROPIC_API_KEY) return true;
try {
const cfg = loadConfig();
if (cfg?.anthropic_api_key) return cfg.anthropic_api_key;
if (cfg?.anthropic_api_key) return true;
} catch {
// loadConfig may throw on first-run installs; treat as no key available.
}
return undefined;
return false;
}
+1 -7
View File
@@ -9,7 +9,7 @@
* import it from `../../src/cli.ts`.
*
* The single ownership site for: (a) folding file-plane API keys
* (openai/anthropic/zeroentropy/openrouter/voyage) into the gateway env, and (b) threading
* (openai/anthropic/zeroentropy) into the gateway env, and (b) threading
* local-server `*_BASE_URL` env vars into base_urls. Both matter for the
* init-time embedding-key probe without (a) it would false-warn on
* config.json-keyed users, and without (b) a live probe could hit the wrong
@@ -38,12 +38,6 @@ export function buildGatewayConfig(c: GBrainConfig): AIGatewayConfig {
// config.json) must reach the openrouter recipe's OPENROUTER_API_KEY.
// process.env still wins via the later spread.
if (c.openrouter_api_key) envFromConfig.OPENROUTER_API_KEY = c.openrouter_api_key;
// #2662: same seam for Voyage. Before this, config.json's voyage_api_key
// was accepted at the file plane but never threaded into the gateway env,
// so launchd/daemon/MCP contexts (no process-env export) silently failed
// multimodal/image embeds despite config.json looking complete. process.env
// still wins via the later spread.
if (c.voyage_api_key) envFromConfig.VOYAGE_API_KEY = c.voyage_api_key;
// v0.32 codex finding #4+#5 fix: thread local-server _BASE_URL env vars
// into base_urls so the gateway hits the user's configured port. Without
+7 -10
View File
@@ -22,7 +22,6 @@
*/
import { resolveRecipe } from './model-resolver.ts';
import { listRecipes } from './recipes/index.ts';
import { AIConfigError } from './errors.ts';
export interface ProviderCapabilities {
@@ -78,10 +77,7 @@ export function getProviderCapabilities(modelString: string): ProviderCapabiliti
if (!chat) {
throw new AIConfigError(
`Provider "${recipe.id}" does not offer a chat touchpoint.`,
// Computed from the registry so the hint can't drift into listing
// chat-less providers (the pre-fix list falsely included embedding-only
// recipes, sending users in circles — #1157).
`Known providers with chat: ${listRecipes().filter(r => r.touchpoints.chat).map(r => r.id).join(', ')}. Pick one for models.tier.subagent.`,
`Known providers with chat: openai, anthropic, google, openrouter, litellm-proxy, deepseek, groq, together, azure-openai, dashscope, minimax, zhipu, ollama, llama-server. Pick one for models.tier.subagent.`,
);
}
@@ -92,13 +88,9 @@ export function getProviderCapabilities(modelString: string): ProviderCapabiliti
// boundary; this function returns capabilities for whatever the user asked
// for, on the assumption it'll be validated elsewhere.
const promptCache = chat.supports_prompt_cache;
return {
supportsToolCalling: chat.supports_tools === true,
supportsPromptCaching: typeof promptCache === 'function'
? promptCache(parsed.modelId)
: promptCache === true,
supportsPromptCaching: chat.supports_prompt_cache === true,
// No recipe exposes parallel-tools-specifically yet; gate on supports_tools.
// Subsequent waves can split this into its own recipe field if a provider
// ever supports tools without parallel dispatch.
@@ -109,6 +101,11 @@ export function getProviderCapabilities(modelString: string): ProviderCapabiliti
supportsThinking: false,
maxContext: chat.max_context_tokens ?? 128_000,
};
// The `parsed` binding is intentionally unused — `resolveRecipe` is called
// here for its validation side-effects (throws on unknown provider). Keeping
// the destructure makes future per-model capability overrides cheap.
void parsed;
}
/**
+3 -54
View File
@@ -90,30 +90,6 @@ export function isValidOpenAITextEmbedding3Dim(modelId: string, dims: number): b
return Number.isInteger(dims) && dims >= 1 && dims <= max;
}
// Perplexity hosted embeddings (#1046): Matryoshka-style flexible dims,
// any integer from 128 up to the model's native size. `dimensions` is the
// native wire field (no translation needed); output encoding divergence
// (base64 int8) is handled by perplexityCompatFetch in gateway.ts.
const PERPLEXITY_EMBEDDING_MAX_DIMS: Record<string, number> = {
'pplx-embed-v1-0.6b': 1024,
'pplx-embed-v1-4b': 2560,
};
export const PERPLEXITY_MIN_DIMS = 128;
export function isPerplexityEmbeddingModel(modelId: string): boolean {
return modelId in PERPLEXITY_EMBEDDING_MAX_DIMS;
}
export function maxPerplexityEmbeddingDim(modelId: string): number | undefined {
return PERPLEXITY_EMBEDDING_MAX_DIMS[modelId];
}
export function isValidPerplexityDim(modelId: string, dims: number): boolean {
const max = PERPLEXITY_EMBEDDING_MAX_DIMS[modelId];
if (max === undefined) return false;
return Number.isInteger(dims) && dims >= PERPLEXITY_MIN_DIMS && dims <= max;
}
// NVIDIA NIM hosted embedding models use asymmetric input_type values. Most
// emit fixed natural dimensions, but llama-nemotron-embed-1b-v2 accepts
// Matryoshka-style dimension overrides (e.g. matching an existing 1280d
@@ -250,23 +226,6 @@ export function dimsProviderOptions(
},
};
}
// Perplexity pplx-embed-v1-* — flexible dims via the native
// `dimensions` field. Fail-loud when the configured dim is outside
// the model's range (same rationale as the Voyage/ZE guards: the
// upstream HTTP 400 misroutes as a transient network error).
// Symmetric retrieval — inputType is never emitted.
if (isPerplexityEmbeddingModel(modelId)) {
if (!isValidPerplexityDim(modelId, dims)) {
const max = maxPerplexityEmbeddingDim(modelId)!;
throw new AIConfigError(
`Perplexity model "${modelId}" supports embedding_dimensions in ` +
`${PERPLEXITY_MIN_DIMS}..${max}, got ${dims}.`,
`Set \`embedding_dimensions\` to a value between ${PERPLEXITY_MIN_DIMS} and ${max} ` +
`in your gbrain config.`,
);
}
return { openaiCompatible: { dimensions: dims } };
}
// NVIDIA NIM hosted embeddings are OpenAI-compatible but require
// asymmetric input_type. Use passage for indexing/document-side vectors
// and query for search-side vectors. Only llama-nemotron-embed-1b-v2
@@ -285,10 +244,9 @@ export function dimsProviderOptions(
// configured for a smaller width (e.g. 1536) hard-fail at first embed.
// Azure/OpenAI-compat embeddings are symmetric — inputType ignored.
// v0.36.0.0 (D13): same range validation as native-openai path.
const bareModelId = modelId.includes('/') ? modelId.split('/').pop()! : modelId;
if (bareModelId.startsWith('text-embedding-3')) {
if (isOpenAITextEmbedding3Model(bareModelId) && !isValidOpenAITextEmbedding3Dim(bareModelId, dims)) {
const max = maxOpenAITextEmbedding3Dim(bareModelId)!;
if (modelId.startsWith('text-embedding-3')) {
if (isOpenAITextEmbedding3Model(modelId) && !isValidOpenAITextEmbedding3Dim(modelId, dims)) {
const max = maxOpenAITextEmbedding3Dim(modelId)!;
throw new AIConfigError(
`OpenAI model "${modelId}" supports embedding_dimensions in 1..${max}, got ${dims}.`,
`Set \`embedding_dimensions\` to a value between 1 and ${max} ` +
@@ -305,15 +263,6 @@ export function dimsProviderOptions(
if (modelId === 'text-embedding-v3' || modelId === 'embedding-3') {
return { openaiCompatible: { dimensions: dims } };
}
// Qwen3-Embedding family on Ollama (and any other openai-compatible
// provider serving it) supports Matryoshka truncation via `dimensions`.
// Native sizes: 0.6B=1024, 4B=2560, 8B=4096. Without `dimensions`,
// Ollama returns the native size and brains configured for narrower
// widths hard-fail with a dim-mismatch error. Pattern match the bare
// model name + any `:tag` (e.g. `qwen3-embedding:4b`, `qwen3-embedding:0.6b`).
if (modelId === 'qwen3-embedding' || modelId.startsWith('qwen3-embedding:')) {
return { openaiCompatible: { dimensions: dims } };
}
// MiniMax embo-01 takes a `type: 'db' | 'query'` field for asymmetric
// retrieval. Today still hardcoded to 'db' for back-compat — opting
// into the new inputType seam is a follow-up (see plan's deferred
+4 -200
View File
@@ -47,10 +47,6 @@ import type {
TouchpointKind,
} from './types.ts';
import { resolveRecipe, assertTouchpoint, parseModelId } from './model-resolver.ts';
import {
OPENROUTER_CACHE_HEADER,
openrouterRequiresExplicitPromptCache,
} from './recipes/openrouter.ts';
import { resolveModel, TIER_DEFAULTS } from '../model-config.ts';
import type { BrainEngine } from '../engine.ts';
import { dimsProviderOptions } from './dims.ts';
@@ -267,18 +263,6 @@ export class ZeroEntropyResponseTooLargeError extends Error {
}
}
/** Perplexity twin of the Voyage/ZE OOM caps (#1046). Int8 components are
* 1 byte each, so a real response (512 texts × 2560 dims) is ~1.3 MB
* anything near this cap is unambiguously not legitimate. */
const MAX_PERPLEXITY_RESPONSE_BYTES = 256 * 1024 * 1024;
export class PerplexityResponseTooLargeError extends Error {
constructor(message: string) {
super(message);
this.name = 'PerplexityResponseTooLargeError';
}
}
// ---- Unified auth resolution (D12=A) ----
//
// Pre-v0.32, openai-compatible auth was duplicated across instantiateEmbedding,
@@ -615,8 +599,6 @@ function warnRecipesMissingBatchTokens(): void {
// LiteLLM proxy, llama-server) — they ship without a static cap because
// the cap depends on a user-launched server. Warning is noise for them.
if (embedding.no_batch_cap === true) continue;
// A declared item-count cap is a real batch cap — no warning needed.
if (embedding.max_batch_items !== undefined) continue;
if (_warnedRecipes.has(recipe.id)) continue;
_warnedRecipes.add(recipe.id);
// eslint-disable-next-line no-console
@@ -1310,103 +1292,6 @@ const openAICompatAsymmetricFetch = (async (input: RequestInfo | URL, init?: Req
return fetch(typeof input === 'string' ? input : input.toString(), baseInit);
}) as unknown as typeof fetch;
/**
* Perplexity compatibility shim (#1046). Perplexity's `/v1/embeddings`
* endpoint is OpenAI-shaped but diverges on two points that break the AI
* SDK's openai-compatible adapter:
* - `encoding_format` only accepts 'base64_int8' (default) or
* 'base64_binary'; the SDK sends 'float', which Perplexity rejects.
* Force 'base64_int8' on the wire.
* - The response `embedding` is a base64 string encoding SIGNED INT8
* components (natively quantized output). The SDK schema expects
* `number[]` decode Int8Array number[] here. Cosine similarity is
* scale-invariant, so the raw int8 components rank correctly.
* `dimensions` is Perplexity's native field name no translation needed
* (dims.ts emits it directly). Layer 1/Layer 2 OOM caps mirror the Voyage
* pattern.
*
* Exported for tests (behavioral coverage of the int8 decode); not part of
* the public gateway API.
*/
export const perplexityCompatFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
// OUTBOUND: force the encoding Perplexity actually accepts.
if (init?.body && typeof init.body === 'string') {
try {
const parsed = JSON.parse(init.body);
if (parsed && typeof parsed === 'object' && parsed.encoding_format !== 'base64_int8') {
parsed.encoding_format = 'base64_int8';
// Drop Content-Length so fetch recomputes from the new body.
const headers = new Headers(init.headers ?? {});
headers.delete('content-length');
init = { ...init, body: JSON.stringify(parsed), headers };
}
} catch {
// Body wasn't JSON — pass through untouched.
}
}
const resp = await fetch(input as any, init);
if (!resp.ok) return resp;
const ct = resp.headers.get('content-type') ?? '';
if (!ct.toLowerCase().includes('application/json')) return resp;
// Layer 1: Content-Length pre-check BEFORE the body is parsed.
const contentLengthHeader = resp.headers.get('content-length');
if (contentLengthHeader) {
const len = parseInt(contentLengthHeader, 10);
if (Number.isFinite(len) && len > MAX_PERPLEXITY_RESPONSE_BYTES) {
throw new PerplexityResponseTooLargeError(
`Perplexity response Content-Length=${len} exceeds ${MAX_PERPLEXITY_RESPONSE_BYTES} bytes — ` +
`likely compromised endpoint or misconfiguration`,
);
}
}
// INBOUND: decode base64 int8 embeddings to number[] so the SDK's Zod
// schema validates.
try {
const json: any = await resp.clone().json();
if (!json || typeof json !== 'object') return resp;
let modified = false;
if (Array.isArray(json.data)) {
for (const item of json.data) {
if (item && typeof item.embedding === 'string') {
// Layer 2: per-embedding cap for chunked responses that skipped
// Layer 1. base64 → bytes is the canonical 0.75 ratio.
const estDecoded = Math.ceil(item.embedding.length * 0.75);
if (estDecoded > MAX_PERPLEXITY_RESPONSE_BYTES) {
throw new PerplexityResponseTooLargeError(
`Perplexity embedding base64 exceeds ${MAX_PERPLEXITY_RESPONSE_BYTES} bytes ` +
`(estimated ${estDecoded} bytes from ${item.embedding.length} base64 chars)`,
);
}
// base64_int8: one signed int8 per component.
const bytes = Buffer.from(item.embedding, 'base64');
item.embedding = Array.from(new Int8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength));
modified = true;
}
}
}
if (json.usage && typeof json.usage === 'object' && json.usage.prompt_tokens === undefined) {
json.usage.prompt_tokens = typeof json.usage.total_tokens === 'number'
? json.usage.total_tokens
: 0;
modified = true;
}
if (!modified) return resp;
return new Response(JSON.stringify(json), {
status: resp.status,
statusText: resp.statusText,
headers: resp.headers,
});
} catch (err) {
// OOM-cap throws MUST propagate; anything else falls back to the
// original response (same contract as voyageCompatFetch).
if (err instanceof PerplexityResponseTooLargeError) throw err;
return resp;
}
}) as unknown as typeof fetch;
async function resolveEmbeddingProvider(modelStr: string): Promise<{ model: any; recipe: Recipe; modelId: string }> {
const { parsed, recipe } = resolveRecipe(modelStr);
assertTouchpoint(recipe, 'embedding', parsed.modelId, getExtendedModelsForProvider(parsed.providerId));
@@ -1451,10 +1336,6 @@ function instantiateEmbedding(recipe: Recipe, modelId: string, cfg: AIGatewayCon
throw new AIConfigError(
`Anthropic has no embedding model. Use openai or google for embeddings.`,
);
case 'claude-cli':
throw new AIConfigError(
`claude-cli has no embedding model. Use openai or google for embeddings.`,
);
case 'openai-compatible': {
// D12=A: unified auth via Recipe.resolveAuth (or default).
const auth = applyResolveAuth(recipe, cfg, 'embedding');
@@ -1479,8 +1360,6 @@ function instantiateEmbedding(recipe: Recipe, modelId: string, cfg: AIGatewayCon
? zeroEntropyCompatFetch
: recipe.id === 'nvidia'
? nvidiaCompatFetch
: recipe.id === 'perplexity'
? perplexityCompatFetch
: openAICompatAsymmetricFetch);
const client = createOpenAICompatible({
name: recipe.id,
@@ -1638,17 +1517,10 @@ export async function embed(texts: string[], opts?: EmbedOpts): Promise<Float32A
// Pre-split is gated on max_batch_tokens. Recipes without it (e.g. OpenAI)
// ride the fast path: one embedMany call, no recursion safety net.
const tokenBatches = maxBatchTokens
const batches = maxBatchTokens
? splitByTokenBudget(truncated, Math.floor(maxBatchTokens * effectiveSafetyFactor(recipe)), charsPerToken)
: [truncated];
// Hard COUNT cap (e.g. llama-server's "maximum allowed batch size 32").
// Token budget can't bound item count, so re-split any oversized batch.
const maxBatchItems = embedding?.max_batch_items;
const batches = maxBatchItems
? tokenBatches.flatMap(b => capBatchItems(b, maxBatchItems))
: tokenBatches;
const allEmbeddings: Float32Array[] = [];
let _embedThrew = false;
try {
@@ -1724,23 +1596,6 @@ export function splitByTokenBudget(
return batches;
}
/**
* Split a batch into sub-batches of at most `maxItems` inputs. Enforces a
* hard COUNT cap that the token-budget split can't (many tiny inputs fit
* under any token budget). Used for endpoints like llama.cpp's llama-server
* that reject requests exceeding their launch batch size.
*
* @internal exported for tests; not part of the public gateway API.
*/
export function capBatchItems(texts: string[], maxItems: number): string[][] {
if (maxItems <= 0 || texts.length <= maxItems) return [texts];
const batches: string[][] = [];
for (let i = 0; i < texts.length; i += maxItems) {
batches.push(texts.slice(i, i + maxItems));
}
return batches;
}
/**
* Returns true if the error looks like a provider batch-token-limit error.
*
@@ -2399,15 +2254,6 @@ function instantiateExpansion(recipe: Recipe, modelId: string, cfg: AIGatewayCon
const baseURL = resolveNativeBaseUrl('anthropic', cfg);
return createAnthropic({ apiKey, ...(baseURL ? { baseURL } : {}) }).languageModel(modelId);
}
case 'claude-cli': {
// The CLI handles its own auth (OAuth session); spawn the subprocess
// directly via the same LanguageModelV2 implementation chat uses. There
// is no separate expansion path because claude-cli does not declare a
// separate expansion touchpoint — but routing here keeps the switch
// exhaustive and lets a future expansion touchpoint use the same code.
const { ClaudeCliLanguageModel } = require('./providers/claude-cli-language-model.ts');
return new ClaudeCliLanguageModel(modelId);
}
case 'openai-compatible': {
// D12=A: unified auth via Recipe.resolveAuth (or default).
const auth = applyResolveAuth(recipe, cfg, 'expansion');
@@ -2863,17 +2709,6 @@ export function probeChatModel(modelStr: string): ChatModelProbe {
return { ok: true };
}
/**
* Per-model prompt-cache capability: `supports_prompt_cache` may be a static
* boolean (native providers) or a per-model-id predicate (OpenRouter's
* family-scoped caching).
*/
function chatSupportsPromptCache(recipe: Recipe, modelId: string): boolean {
const support = recipe.touchpoints.chat?.supports_prompt_cache;
if (typeof support === 'function') return support(modelId);
return support === true;
}
async function resolveChatProvider(modelStr: string): Promise<{ model: any; recipe: Recipe; modelId: string }> {
const { parsed, recipe } = resolveRecipe(modelStr);
assertTouchpoint(recipe, 'chat', parsed.modelId, getExtendedModelsForProvider(parsed.providerId));
@@ -2907,15 +2742,6 @@ function instantiateChat(recipe: Recipe, modelId: string, cfg: AIGatewayConfig):
const baseURL = resolveNativeBaseUrl('anthropic', cfg);
return createAnthropic({ apiKey, ...(baseURL ? { baseURL } : {}) }).languageModel(modelId);
}
case 'claude-cli': {
// The CLI handles its own auth (OAuth session managed by `claude`
// login). Subprocess-based LanguageModelV2 dispatches via the recipe
// path so per-call routing works: `claude-cli:claude-sonnet-4-6` lands
// here, while sibling `litellm:gpt-5.4` continues through the
// openai-compatible path below. No env-var switch, no global flag.
const { ClaudeCliLanguageModel } = require('./providers/claude-cli-language-model.ts');
return new ClaudeCliLanguageModel(modelId);
}
case 'openai-compatible': {
// D12=A: unified auth via Recipe.resolveAuth (or default).
const auth = applyResolveAuth(recipe, cfg, 'chat');
@@ -3204,19 +3030,9 @@ export async function chat(opts: ChatOpts): Promise<ChatResult> {
const { model, recipe, modelId } = await resolveChatProvider(modelStr);
const cfg = requireConfig();
const supportsCache = chatSupportsPromptCache(recipe, modelId);
const supportsCache = recipe.touchpoints.chat?.supports_prompt_cache === true;
const useCache = !!opts.cacheSystem && supportsCache;
// OpenRouter Claude routes need an explicit `cache_control` on the system
// content block, but the openai-compatible adapter drops anthropic-namespace
// providerOptions before building the wire body. Signal intent via a private
// header; the recipe's compat fetch shim rewrites the body and strips the
// header before the request leaves the process. OpenAI routes through
// OpenRouter cache automatically — no marker needed.
const requestHeaders = useCache && recipe.id === 'openrouter' && openrouterRequiresExplicitPromptCache(modelId)
? { [OPENROUTER_CACHE_HEADER]: '1' }
: undefined;
const tools = toAISDKTools(opts.tools);
const providerOptions: Record<string, any> = {};
@@ -3328,7 +3144,6 @@ export async function chat(opts: ChatOpts): Promise<ChatResult> {
// shorter wins). Covers native-anthropic (the default provider + facts Haiku).
abortSignal: withDefaultTimeout(opts.abortSignal, AI_CHAT_TIMEOUT_MS),
providerOptions: Object.keys(providerOptions).length > 0 ? providerOptions : undefined,
...(requestHeaders ? { headers: requestHeaders } : {}),
});
// Normalize blocks. Vercel SDK gives us `result.content` (an array of typed
@@ -3377,10 +3192,7 @@ export async function chat(opts: ChatOpts): Promise<ChatResult> {
usage: {
input_tokens: inTok,
output_tokens: outTok,
// `usage.cachedInputTokens` is the AI SDK's provider-neutral cache-read
// count — it's how OpenAI-compatible routes (OpenRouter's
// prompt_tokens_details.cached_tokens) surface cache hits.
cache_read_tokens: Number(anthropicCache.cacheReadInputTokens ?? anthropicCache.cache_read_input_tokens ?? usage.cachedInputTokens ?? 0),
cache_read_tokens: Number(anthropicCache.cacheReadInputTokens ?? anthropicCache.cache_read_input_tokens ?? 0),
cache_creation_tokens: Number(anthropicCache.cacheCreationInputTokens ?? anthropicCache.cache_creation_input_tokens ?? 0),
},
model: `${recipe.id}:${modelId}`,
@@ -3844,15 +3656,7 @@ export async function rerank(input: RerankInput): Promise<RerankResult[]> {
// whose request/response shape differs from ZE/llama.cpp (e.g. Voyage with
// `top_k` / `data[]`) needs separate adapter hooks in a follow-up plan.
const url = `${compat.baseURL.replace(/\/$/, '')}${tp.path ?? '/models/rerank'}`;
let auth: { apiKey?: string; headers?: Record<string, string> };
try {
auth = applyResolveAuth(recipe, cfg, 'reranker');
} catch (err) {
if (err instanceof AIConfigError) {
throw new RerankError(err.message, 'auth');
}
throw err;
}
const auth = applyResolveAuth(recipe, cfg, 'reranker');
// applyResolveAuth returns { apiKey } for Bearer-style auth (SDK's native
// path) or { headers } for custom-header providers (Azure). v0.37.6.0:
// recipes can ALSO declare default_headers (attribution etc.) which flow
@@ -1,444 +0,0 @@
/**
* ai-sdk LanguageModelV2 implementation that dispatches via the `claude --print`
* CLI subprocess. Used by the `claude-cli` recipe to route gateway.toolLoop /
* gateway.chat calls through Claude Code's OAuth session instead of the
* Anthropic SDK + ANTHROPIC_API_KEY.
*
* Per-call routing is the contract: the gateway resolves the model string
* to this recipe based on the `claude-cli:` prefix, instantiates one of
* these objects per modelId, and dispatches doGenerate. Sibling subagent
* jobs with `litellm:gpt-5.4` continue routing through litellm-proxy in
* the same worker; no env-var switch, no global state.
*
* Tool use is supported via system-prompt-instructed JSON emission:
* The recipe injects a fenced instruction block into the system prompt
* that teaches the model the `<use_tools>[{id,name,input}, ...]</use_tools>`
* emission format. The adapter parses those blocks back into ai-sdk
* `tool-call` content parts. Parallel tool calls (multiple entries in
* the JSON array) round-trip cleanly this is the case that breaks
* on the codex-proxy / litellm GPT-5.x bridge today.
*
* Context isolation:
* The subprocess is spawned from a dedicated tmpdir so claude-cli's
* CLAUDE.md auto-discovery has no local files to find. `--system-prompt`
* replaces the default system prompt; `--disable-slash-commands` skips
* skill resolution. User-level ~/.claude/CLAUDE.md still loads because
* the only way to skip it is `--bare`, which forces ANTHROPIC_API_KEY
* auth and defeats the whole point of this provider. The ~42k cached
* tokens from user-level instructions are accepted as a cost-trivial
* trade-off on the subscription path.
*
* doStream is not yet implemented; the model declares no streaming. Callers
* (gateway.toolLoop primarily) use doGenerate.
*/
import { spawn } from 'node:child_process';
import { mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type {
LanguageModelV2,
LanguageModelV2CallOptions,
LanguageModelV2Content,
LanguageModelV2FunctionTool,
LanguageModelV2Prompt,
LanguageModelV2Message,
LanguageModelV2ProviderDefinedTool,
} from '@ai-sdk/provider';
function claudeBin(): string {
return process.env.GBRAIN_CLAUDE_CLI_BIN ?? 'claude';
}
const CLAUDE_CWD = join(tmpdir(), `gbrain-claude-cli-cwd-${process.pid}`);
let cwdEnsured = false;
function ensureCleanCwd(): string {
if (!cwdEnsured) {
mkdirSync(CLAUDE_CWD, { recursive: true });
cwdEnsured = true;
}
return CLAUDE_CWD;
}
/** Parsed shape of `claude --print --output-format json`. */
interface ClaudeJsonResult {
type: 'result';
subtype: 'success' | string;
is_error: boolean;
result: string;
stop_reason: string | null;
session_id: string;
num_turns: number;
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
}
/**
* Build the system-prompt addendum that teaches the model the
* `<use_tools>...</use_tools>` emission format. Returns the empty string
* when no tools are registered for this turn so the model gets a normal
* text-completion prompt without protocol noise.
*/
function buildToolUseInstructions(
tools: ReadonlyArray<LanguageModelV2FunctionTool | LanguageModelV2ProviderDefinedTool> | undefined,
): string {
if (!tools || tools.length === 0) return '';
const functionTools = tools.filter((t): t is LanguageModelV2FunctionTool => t.type === 'function');
if (functionTools.length === 0) return '';
const toolSpecs = functionTools.map(t => ({
name: t.name,
description: t.description ?? '',
input_schema: t.inputSchema ?? { type: 'object', properties: {} },
}));
return [
'',
'## Tool Use Protocol',
'',
'You have access to these tools:',
'',
'```json',
JSON.stringify(toolSpecs, null, 2),
'```',
'',
'To call one or more tools in this turn, emit EXACTLY ONE block of this form, ' +
'with no other text outside the block on its own lines:',
'',
'<use_tools>',
'[',
' {"id": "<unique tool call id, like toolu_01ABC>", "name": "<tool name>", "input": <input object matching the tool\'s input_schema>}',
']',
'</use_tools>',
'',
'Multiple tool calls go in the array. Tool results are returned to you on the ' +
'next turn as [tool_result <text>] entries. You may then call more tools or emit a final response.',
'',
'When you are ready to give a final answer instead of calling tools, respond with prose text only — ' +
'do not include a <use_tools> block in that case.',
'',
].join('\n');
}
/**
* Render the ai-sdk message array into a single text prompt for `claude --print`
* stdin. System messages are extracted up-front and concatenated into the
* `--system-prompt` flag value. Tool calls and tool results are rendered as
* placeholders so the model sees the conversation in a coherent shape even
* though the adapter does not natively round-trip tool calls through claude-cli.
*/
function renderPrompt(prompt: LanguageModelV2Prompt): { systemText: string; userPrompt: string } {
const systemParts: string[] = [];
const convo: string[] = [];
for (const msg of prompt as ReadonlyArray<LanguageModelV2Message>) {
if (msg.role === 'system') {
systemParts.push(msg.content);
continue;
}
if (msg.role === 'user') {
const text = msg.content
.map(p => {
if (p.type === 'text') return p.text;
// File parts get a stub — multimodal is not supported via subprocess yet.
if (p.type === 'file') return `[file ${p.mediaType ?? 'unknown'}]`;
return '';
})
.filter(s => s.length > 0)
.join('\n');
if (text) convo.push(`User: ${text}`);
continue;
}
if (msg.role === 'assistant') {
const rendered = msg.content
.map(p => {
if (p.type === 'text') return p.text;
if (p.type === 'reasoning') return ''; // dropped on replay
if (p.type === 'tool-call') {
return `[tool_use ${p.toolName}(${p.input})]`;
}
if (p.type === 'tool-result') {
const out = typeof p.output === 'string' ? p.output : JSON.stringify(p.output);
return `[tool_result ${out}]`;
}
return '';
})
.filter(s => s.length > 0)
.join('\n');
if (rendered) convo.push(`Assistant: ${rendered}`);
continue;
}
if (msg.role === 'tool') {
const rendered = msg.content
.map(p => {
const out = typeof p.output === 'string' ? p.output : JSON.stringify(p.output);
return `[tool_result ${out}]`;
})
.join('\n');
if (rendered) convo.push(`User: ${rendered}`);
continue;
}
}
return { systemText: systemParts.join('\n'), userPrompt: convo.join('\n\n') };
}
/**
* Spawn `claude --print` with the contamination-suppression flags and return
* the parsed `--output-format json` envelope. Aborts propagate to SIGTERM on
* the child.
*/
function runClaude(
systemPrompt: string,
userPrompt: string,
model: string,
signal?: AbortSignal,
): Promise<ClaudeJsonResult> {
return new Promise((resolve, reject) => {
const args = [
'--print',
'--output-format', 'json',
'--model', model,
'--disable-slash-commands',
// Agent isolation: this subprocess must behave like a raw LLM, not a
// full Claude Code agent. `--tools ""` disables every built-in tool
// (Bash/Read/WebSearch/...); `--strict-mcp-config` ignores all user-level
// MCP servers (without it, each call would boot the user's MCP servers —
// including gbrain's own MCP → recursion + PGLite single-writer lock
// contention). Verified against claude CLI 2.1.145 --help.
'--tools', '',
'--strict-mcp-config',
];
if (systemPrompt) {
args.push('--system-prompt', systemPrompt);
}
// Env scrub: guarantee the CLI authenticates via its own OAuth session
// (subscription), never via an inherited API key. Without this, an
// ANTHROPIC_API_KEY in gbrain's env (the exact setup this recipe is meant
// to replace) silently flips billing to per-token API usage.
const env = { ...process.env };
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_AUTH_TOKEN;
delete env.ANTHROPIC_BASE_URL;
const child = spawn(claudeBin(), args, {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: ensureCleanCwd(),
env,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', chunk => { stdout += String(chunk); });
child.stderr.on('data', chunk => { stderr += String(chunk); });
const onAbort = () => {
child.kill('SIGTERM');
reject(new Error('claude-cli adapter aborted'));
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
child.on('error', err => {
if (signal) signal.removeEventListener('abort', onAbort);
reject(new Error(`claude-cli spawn failed: ${err instanceof Error ? err.message : String(err)}`));
});
child.on('close', code => {
if (signal) signal.removeEventListener('abort', onAbort);
if (code !== 0) {
reject(new Error(`claude-cli exited ${code}: ${stderr.trim() || stdout.trim()}`));
return;
}
try {
let parsed = JSON.parse(stdout) as unknown;
// Compat: when the user has `"verbose": true` in ~/.claude/settings.json,
// `--print --output-format json` emits an ARRAY of events
// ([{type:"system",subtype:"init",...}, ..., {type:"result",...}])
// instead of the bare result object. There is no CLI flag to force it
// off (no --no-verbose; --settings '{}' merges, does not replace), so
// tolerate both shapes and pick the result event. Verified on CLI 2.1.145.
if (Array.isArray(parsed)) {
const resultEvent = parsed.find(
(ev): ev is ClaudeJsonResult =>
!!ev && typeof ev === 'object' && (ev as { type?: unknown }).type === 'result',
);
if (!resultEvent) {
reject(new Error(`claude-cli JSON event array had no "result" event\n--- raw ---\n${stdout.slice(0, 500)}`));
return;
}
parsed = resultEvent;
}
const envelope = parsed as ClaudeJsonResult;
if (envelope.is_error) {
reject(new Error(`claude-cli reported error: ${envelope.result || envelope.subtype}`));
return;
}
resolve(envelope);
} catch (e) {
reject(new Error(`claude-cli output not JSON: ${e instanceof Error ? e.message : String(e)}\n--- raw ---\n${stdout.slice(0, 500)}`));
}
});
// stdin error handler: if the binary does not exist (ENOENT) or the child
// dies before draining stdin, write/end can emit an unhandled 'error'
// (EPIPE) that would crash the worker. The spawn-level 'error' / non-zero
// 'close' handlers above already surface the real failure, so the stdin
// error itself is safe to swallow.
child.stdin.on('error', () => { /* surfaced via child 'error'/'close' */ });
try {
child.stdin.write(userPrompt);
child.stdin.end();
} catch (e) {
if (signal) signal.removeEventListener('abort', onAbort);
reject(new Error(`claude-cli stdin write failed (is the claude binary installed?): ${e instanceof Error ? e.message : String(e)}`));
}
});
}
interface ParsedToolCall {
id: string;
name: string;
/** Stringified JSON, matching the ai-sdk LanguageModelV2ToolCall.input contract. */
input: string;
}
/**
* Locate and parse the `<use_tools>...</use_tools>` block in the assistant's
* raw text response. Returns the parsed tool calls plus whatever prose
* surrounded the block. Returns an empty `toolCalls` array when no block is
* present, malformed, or unterminated the caller then treats the full
* raw text as a final text response.
*/
function extractToolCalls(raw: string): {
toolCalls: ParsedToolCall[];
beforeText: string;
afterText: string;
} {
const openTag = '<use_tools>';
const closeTag = '</use_tools>';
const openIdx = raw.indexOf(openTag);
if (openIdx === -1) {
return { toolCalls: [], beforeText: raw.trim(), afterText: '' };
}
const closeIdx = raw.indexOf(closeTag, openIdx + openTag.length);
if (closeIdx === -1) {
// Unterminated block — recover gracefully.
return { toolCalls: [], beforeText: raw.trim(), afterText: '' };
}
const beforeText = raw.slice(0, openIdx).trim();
const afterText = raw.slice(closeIdx + closeTag.length).trim();
let inner = raw.slice(openIdx + openTag.length, closeIdx).trim();
if (inner.startsWith('```')) {
inner = inner.replace(/^```(?:json|JSON)?\s*\n?/, '').replace(/\n?```$/, '').trim();
}
let parsed: unknown;
try {
parsed = JSON.parse(inner);
} catch {
return { toolCalls: [], beforeText: raw.trim(), afterText: '' };
}
if (!Array.isArray(parsed)) {
return { toolCalls: [], beforeText: raw.trim(), afterText: '' };
}
const toolCalls: ParsedToolCall[] = [];
for (const entry of parsed) {
if (!entry || typeof entry !== 'object') continue;
const e = entry as Record<string, unknown>;
const name = typeof e.name === 'string' ? e.name : null;
if (!name) continue;
const id = typeof e.id === 'string' && e.id.length > 0
? e.id
: `toolu_claude_cli_${Math.random().toString(36).slice(2, 12)}`;
const inputJson = JSON.stringify(e.input ?? {});
toolCalls.push({ id, name, input: inputJson });
}
return { toolCalls, beforeText, afterText };
}
/**
* Strip provider prefixes (`anthropic:`, `litellm:`, `claude-cli:`) that the
* underlying CLI does not understand. The gateway hands us a bare model id
* via `recipe.aliases` resolution, but defensive normalization here keeps
* direct LanguageModelV2 construction (in tests, for example) ergonomic.
*/
function normalizeModel(model: string): string {
const idx = model.indexOf(':');
return idx >= 0 ? model.slice(idx + 1) : model;
}
export class ClaudeCliLanguageModel implements LanguageModelV2 {
readonly specificationVersion = 'v2' as const;
readonly provider = 'claude-cli';
readonly modelId: string;
readonly supportedUrls = {};
constructor(modelId: string) {
this.modelId = normalizeModel(modelId);
}
async doGenerate(options: LanguageModelV2CallOptions): Promise<{
content: LanguageModelV2Content[];
finishReason: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown';
usage: { inputTokens: number | undefined; outputTokens: number | undefined; totalTokens: number | undefined };
warnings: never[];
}> {
const { systemText, userPrompt } = renderPrompt(options.prompt);
const toolInstructions = buildToolUseInstructions(options.tools);
const systemPrompt = [systemText, toolInstructions].filter(s => s.length > 0).join('\n');
const result = await runClaude(systemPrompt, userPrompt, this.modelId, options.abortSignal);
const { toolCalls, beforeText, afterText } = extractToolCalls(result.result);
const content: LanguageModelV2Content[] = [];
if (beforeText) content.push({ type: 'text', text: beforeText });
for (const call of toolCalls) {
content.push({
type: 'tool-call',
toolCallId: call.id,
toolName: call.name,
input: call.input,
});
}
if (afterText) content.push({ type: 'text', text: afterText });
if (content.length === 0) {
// Empty response — still hand the caller a well-formed content array.
content.push({ type: 'text', text: result.result ?? '' });
}
const finishReason = toolCalls.length > 0 ? 'tool-calls' as const : 'stop' as const;
const inputTokens = result.usage?.input_tokens;
const outputTokens = result.usage?.output_tokens;
const totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
return {
content,
finishReason,
usage: {
inputTokens,
outputTokens,
totalTokens: inputTokens !== undefined && outputTokens !== undefined ? totalTokens : undefined,
},
warnings: [],
};
}
async doStream(): Promise<never> {
throw new Error(
'claude-cli LanguageModel does not support streaming. Use doGenerate or set ' +
'the model on a non-streaming chat surface (gateway.toolLoop is non-streaming).',
);
}
}
-71
View File
@@ -1,71 +0,0 @@
import type { Recipe } from '../types.ts';
/**
* Claude via the local `claude` CLI binary, using its built-in OAuth session
* (Claude Code / Claude Max subscription). No ANTHROPIC_API_KEY needed the
* CLI manages its own auth state and the gateway dispatches via subprocess.
*
* Solves the #334 case where Max subscribers want Minions subagent dispatch
* to run against their existing subscription instead of paying per-token API
* charges. The recipe sits alongside the existing `anthropic` recipe so users
* pick per call: `anthropic:claude-sonnet-4-6` (API key + per-token billing)
* vs `claude-cli:claude-sonnet-4-6` (OAuth subscription, no API key).
*
* Chat-only. Claude has no first-party embedding model; users wanting an
* Anthropic chat path with embeddings still combine this with openai/google/
* voyage for embedding the way the existing `anthropic` recipe documents.
*
* Auth: `auth_env.required: []` because the CLI handles auth itself. The
* `claude` binary on PATH (or `GBRAIN_CLAUDE_CLI_BIN`) IS the auth surface;
* there is nothing for the gateway to forward.
*
* Setup expectation: `claude` CLI installed and logged in (Claude Code
* onboarding does this), or `GBRAIN_CLAUDE_CLI_BIN` pointing at the binary.
*/
export const claudeCli: Recipe = {
id: 'claude-cli',
name: 'Claude (via CLI)',
tier: 'native',
implementation: 'claude-cli',
// The CLI owns auth; no env vars are required from the gateway side.
auth_env: {
required: [],
},
touchpoints: {
// No embedding or expansion touchpoints — chat-only.
chat: {
models: [
'claude-opus-4-7',
'claude-sonnet-4-6',
'claude-haiku-4-5-20251001',
],
supports_tools: true,
supports_subagent_loop: true,
// The CLI handles caching internally and does not surface it via the
// standard cache_control control plane. From the gateway's POV the
// model does not support prompt caching.
supports_prompt_cache: false,
max_context_tokens: 200000,
// Cost figures match the underlying Claude API tier, but the actual
// bill is borne by the subscription. We report them for the budget
// ledger's per-call accounting; operators on flat-rate subscriptions
// can treat the numbers as nominal.
cost_per_1m_input_usd: 3.0,
cost_per_1m_output_usd: 15.0,
price_last_verified: '2026-06-17',
},
},
// Friendly aliases mirror the `anthropic` recipe so config strings stay
// portable: switching `anthropic:claude-sonnet-4-6` to `claude-cli:claude-sonnet-4-6`
// is a one-token edit. Reverse aliases rewrite legacy IDs back to canonical.
aliases: {
'claude-haiku-4-5': 'claude-haiku-4-5-20251001',
'claude-sonnet-4-6-20250929': 'claude-sonnet-4-6',
'sonnet': 'claude-sonnet-4-6',
'haiku': 'claude-haiku-4-5-20251001',
'opus': 'claude-opus-4-7',
},
setup_hint:
'Install Claude Code (`claude` CLI) and run `claude` once to log in. ' +
'Set GBRAIN_CLAUDE_CLI_BIN if the binary is not on PATH.',
};
-61
View File
@@ -1,61 +0,0 @@
import type { Recipe } from '../types.ts';
/**
* Alibaba DashScope () reranker. DashScope's OpenAI-compatible surface
* splits by capability: embeddings live under `/compatible-mode/v1` (see the
* sibling `dashscope` recipe) while rerank lives under `/compatible-api/v1`
* with a PLURAL leaf `POST {base}/reranks`. Wire shape matches ZeroEntropy:
* request `{model, query, documents, top_n?}`, response
* `{results: [{index, relevance_score}]}` so it rides gateway.rerank()'s
* native path with only the recipe-pluggable `path` override (v0.40.6.1).
*
* This is a SEPARATE recipe rather than a reranker touchpoint on `dashscope`
* because the two capabilities need different base URLs (`compatible-mode`
* vs `compatible-api`) and `provider_base_urls` is keyed by recipe id one
* recipe can't point embeddings and rerank at different prefixes. Same
* topology precedent as llama-server vs llama-server-reranker.
*
* Live-verified against the China endpoint (2026-07): `/reranks` with
* `qwen3-rerank` 200 `results[].relevance_score`; `/rerank` (singular)
* 404; `gte-rerank-v2` 404 "Unsupported model for OpenAI compatibility
* mode" (native-API only, so it is deliberately NOT listed here).
*
* Note: the international endpoint requires a region-aware DASHSCOPE_API_KEY.
* China-region users point at https://dashscope.aliyuncs.com/compatible-api/v1
* via `provider_base_urls['dashscope-rerank']`, mirroring the embedding
* recipe's convention.
*/
export const dashscopeRerank: Recipe = {
id: 'dashscope-rerank',
name: 'Alibaba DashScope (灵积, reranker)',
tier: 'openai-compat',
implementation: 'openai-compatible',
base_url_default: 'https://dashscope-intl.aliyuncs.com/compatible-api/v1',
auth_env: {
required: ['DASHSCOPE_API_KEY'],
setup_url: 'https://help.aliyun.com/zh/model-studio/getting-started/',
},
touchpoints: {
reranker: {
// Only the model verified live on the OpenAI-compat /reranks surface.
// gte-rerank-v2 exists on DashScope's native API but the compat path
// rejects it ("Unsupported model for OpenAI compatibility mode").
models: ['qwen3-rerank'],
default_model: 'qwen3-rerank',
// Mirror ZE's defensive per-request ceiling; gateway.rerank()
// pre-flights body size and fails open.
max_payload_bytes: 5_000_000,
// PLURAL leaf under compatible-api — the whole reason this recipe
// exists. `${base_url}${path}` → `…/compatible-api/v1/reranks`.
path: '/reranks',
// Hosted API: no local warmup, but cross-region latency can exceed
// the 5s gateway default (same rationale as llama-server-reranker).
default_timeout_ms: 30_000,
},
},
setup_hint:
'Get an API key at https://help.aliyun.com/zh/model-studio/getting-started/, then ' +
'`export DASHSCOPE_API_KEY=...` and `gbrain config set search.reranker.model ' +
'dashscope-rerank:qwen3-rerank`. China-region accounts: `gbrain config set ' +
'provider_base_urls.dashscope-rerank https://dashscope.aliyuncs.com/compatible-api/v1`.',
};
-9
View File
@@ -76,15 +76,6 @@ export const deepseek: Recipe = {
setup_url: 'https://platform.deepseek.com/api_keys',
},
touchpoints: {
// Query expansion reuses the same OpenAI-compatible chat endpoint (the
// gateway's expansion path is a plain languageModel call). Without this
// declaration an explicit `expansion_model: deepseek:...` silently
// yields no expansion (#1135).
expansion: {
models: ['deepseek-chat'],
cost_per_1m_tokens_usd: 0.14,
price_last_verified: '2026-04-20',
},
chat: {
models: ['deepseek-chat', 'deepseek-reasoner'],
supports_tools: true,
-8
View File
@@ -16,14 +16,6 @@ export const groq: Recipe = {
setup_url: 'https://console.groq.com/keys',
},
touchpoints: {
// Same OpenAI-compatible endpoint as chat; declared so an explicit
// `expansion_model: groq:...` resolves instead of silently dropping
// expansion (#1135). 8b-instant is the natural expansion pick (cheap,
// fast, no tool-calling needed for multi-query rewrites).
expansion: {
models: ['llama-3.1-8b-instant', 'llama-3.3-70b-versatile'],
price_last_verified: '2026-04-20',
},
chat: {
models: [
'llama-3.3-70b-versatile',
-6
View File
@@ -9,7 +9,6 @@ import type { Recipe } from '../types.ts';
import { openai } from './openai.ts';
import { google } from './google.ts';
import { anthropic } from './anthropic.ts';
import { claudeCli } from './claude-cli.ts';
import { ollama } from './ollama.ts';
import { openrouter } from './openrouter.ts';
import { voyage } from './voyage.ts';
@@ -20,7 +19,6 @@ import { together } from './together.ts';
import { llamaServer } from './llama-server.ts';
import { minimax } from './minimax.ts';
import { dashscope } from './dashscope.ts';
import { dashscopeRerank } from './dashscope-rerank.ts';
import { zhipu } from './zhipu.ts';
import { azureOpenAI } from './azure-openai.ts';
import { zeroentropyai } from './zeroentropyai.ts';
@@ -28,13 +26,11 @@ import { llamaServerReranker } from './llama-server-reranker.ts';
import { moonshot } from './moonshot.ts';
import { mistral } from './mistral.ts';
import { nvidia } from './nvidia.ts';
import { perplexity } from './perplexity.ts';
const ALL: Recipe[] = [
openai,
google,
anthropic,
claudeCli,
ollama,
openrouter,
voyage,
@@ -46,14 +42,12 @@ const ALL: Recipe[] = [
llamaServerReranker,
minimax,
dashscope,
dashscopeRerank,
zhipu,
azureOpenAI,
zeroentropyai,
moonshot,
mistral,
nvidia,
perplexity,
];
/** Map from `provider:id` key to recipe. */
+3 -6
View File
@@ -35,12 +35,9 @@ export const llamaServer: Recipe = {
trust_custom_dims: true, // #2271: user knows the launched model's native dim
cost_per_1m_tokens_usd: 0,
price_last_verified: '2026-05-10',
// llama-server enforces a hard request-COUNT cap equal to its launch
// batch size (`--batch-size`, default 32): it rejects requests with
// more inputs with `batch size N > maximum allowed batch size 32`.
// The token-budget split can't bound item count, so cap it here. A
// server launched with a larger `-b` can raise this. v0.32 (#779).
max_batch_items: 32,
// llama-server's batch capacity is set by `--ctx-size` at launch
// time; no static cap to declare. v0.32 (#779).
no_batch_cap: true,
},
},
/**
+2 -112
View File
@@ -1,100 +1,8 @@
import type { Recipe } from '../types.ts';
/**
* MiniMax transport shim (#1977). MiniMax's `/v1/embeddings` endpoint is NOT
* OpenAI-compatible at the wire level despite the recipe's
* `implementation: 'openai-compatible'`:
* - Request: requires `texts` (the AI SDK sends `input`) plus an optional
* `type: 'db' | 'query'` asymmetric-retrieval field, and rejects OpenAI's
* `encoding_format`.
* - Response: returns `{vectors: number[][], total_tokens}` where the AI
* SDK's Zod schema expects `{data: [{embedding, index}], usage}`.
*
* Chat (`/chat/completions`) IS OpenAI-compatible, and this same fetch is
* applied to every openai-compatible touchpoint by `applyOpenAICompatConfig`,
* so everything outside the embeddings path passes through untouched and
* the response rewrite parses via `resp.clone()` only (never consume the
* body of a response we return as-is; the DeepSeek shim rule). Fail-open:
* any rewrite error returns the original request/response.
*
* @internal exported for tests.
*/
// Cast through `unknown` because Bun's `typeof fetch` carries a `preconnect`
// member the arrow function does not implement (matches deepseek.ts).
export const minimaxCompatFetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const isEmbeddings = url.includes('/embeddings');
// OUTBOUND (embeddings only): `input` → `texts`, default `type: 'db'`
// (the recipe's documented symmetric default — the AI SDK adapter strips
// the `type` threaded via providerOptions before it reaches the wire,
// same class as #1400), and drop `encoding_format` (not a MiniMax param).
if (isEmbeddings && init?.body && typeof init.body === 'string') {
try {
const parsed = JSON.parse(init.body);
if (
parsed && typeof parsed === 'object' &&
parsed.input !== undefined && parsed.texts === undefined
) {
parsed.texts = Array.isArray(parsed.input) ? parsed.input : [parsed.input];
delete parsed.input;
delete parsed.encoding_format;
if (parsed.type === undefined) parsed.type = 'db';
// Drop Content-Length so fetch recomputes from the new body.
const headers = new Headers(init.headers ?? {});
headers.delete('content-length');
init = { ...init, body: JSON.stringify(parsed), headers };
}
} catch {
// Body wasn't JSON — pass through untouched.
}
}
const res = await fetch(input as any, init as any);
// INBOUND (embeddings only): `{vectors: [[...]]}` → `{data: [{embedding}]}`.
// Anything else (chat completions, MiniMax base_resp errors, non-JSON)
// returns the ORIGINAL response with its body unread.
if (!isEmbeddings || !res.ok) return res;
const ctype = res.headers.get('content-type') ?? '';
if (!ctype.toLowerCase().includes('application/json')) return res;
try {
const json = await res.clone().json();
if (!json || typeof json !== 'object' || !Array.isArray(json.vectors)) return res;
const totalTokens = typeof json.total_tokens === 'number' ? json.total_tokens : 0;
const rewritten = {
object: 'list',
data: (json.vectors as number[][]).map((embedding, index) => ({
object: 'embedding',
embedding,
index,
})),
model: typeof json.model === 'string' ? json.model : 'embo-01',
usage: { prompt_tokens: totalTokens, total_tokens: totalTokens },
};
// Fresh header set: the body changed, so upstream content-length /
// content-encoding would now be wrong.
const headers = new Headers(res.headers);
headers.delete('content-length');
headers.delete('content-encoding');
return new Response(JSON.stringify(rewritten), {
status: res.status,
statusText: res.statusText,
headers,
});
} catch {
return res;
}
}) as unknown as typeof fetch;
/**
* MiniMax (AI). `/embeddings` endpoint at api.minimaxi.com (wire shape
* normalized by `minimaxCompatFetch` above); OpenAI-compatible
* `/chat/completions`. The flagship embedding model is `embo-01` (1536 dims).
* MiniMax (AI). OpenAI-compatible /embeddings endpoint at
* api.minimax.chat. The flagship embedding model is `embo-01` (1536 dims).
*
* MiniMax's API takes an extra `type: 'db' | 'query'` field for asymmetric
* retrieval. gbrain currently has no notion of "this is a document vs a
@@ -130,25 +38,7 @@ export const minimax: Recipe = {
// halving in the gateway catches token-limit errors at runtime.
max_batch_tokens: 4096,
},
chat: {
// Model list from MiniMax's /v1/models (#1977). Chat is genuinely
// OpenAI-compatible — no wire rewrite needed (minimaxCompatFetch
// passes non-embedding requests through untouched).
models: [
'MiniMax-M3',
'MiniMax-M2.7',
'MiniMax-M2.7-highspeed',
'MiniMax-M2.5',
'MiniMax-M2.5-highspeed',
'MiniMax-M2.1',
'MiniMax-M2.1-highspeed',
'MiniMax-M2',
],
supports_tools: false,
supports_subagent_loop: false,
},
},
setup_hint:
'Get an API key at https://www.minimaxi.com, then `export MINIMAX_API_KEY=...`',
compat: { fetch: minimaxCompatFetch },
};
+1 -100
View File
@@ -1,101 +1,5 @@
import type { Recipe } from '../types.ts';
/**
* Private in-process marker header. `gateway.chat()` sets it when the caller
* asked for prompt caching (`cacheSystem`) on an OpenRouter route that needs
* an explicit `cache_control` (Anthropic Claude). The compat fetch shim below
* strips it and rewrites the body; the header NEVER leaves the process.
*
* Why a header and not providerOptions: the AI SDK's openai-compatible
* adapter validates providerOptions against a fixed schema and silently
* drops anthropic-namespace fields before building the wire body (same class
* of problem as the embedding `input_type` ALS in gateway.ts). Headers pass
* through untouched.
*/
export const OPENROUTER_CACHE_HEADER = 'x-gbrain-anthropic-prompt-cache';
/**
* Family-scoped prompt-cache capability (per OpenRouter docs):
* - OpenAI chat routes cache automatically (no request mutation needed).
* - Anthropic Claude routes cache when the request carries `cache_control`
* on a content block (applied by the fetch shim below).
* Everything else is not marked cacheable deliberately narrow rather than
* blessing every routed model family forever.
*/
export function openrouterSupportsPromptCache(modelId: string): boolean {
const normalized = modelId.trim().toLowerCase();
if (normalized.startsWith('openai/gpt-') || /^openai\/o\d/.test(normalized)) return true;
if (normalized.startsWith('anthropic/claude-')) return true;
return false;
}
/** Only Anthropic Claude routes need an explicit cache_control block. */
export function openrouterRequiresExplicitPromptCache(modelId: string): boolean {
return modelId.trim().toLowerCase().startsWith('anthropic/claude-');
}
/**
* Rewrite the last system message's string content into OpenRouter's
* documented Anthropic caching shape: a content-part array carrying
* `cache_control: { type: 'ephemeral' }` on the text block. (A top-level
* body `cache_control` is NOT the OpenRouter format OR forwards per-block
* markers only.) Returns the input unchanged when it doesn't apply.
*/
function withSystemCacheControl(body: unknown): unknown {
if (!body || typeof body !== 'object' || Array.isArray(body)) return body;
const record = body as Record<string, unknown>;
const model = typeof record.model === 'string' ? record.model : '';
if (!openrouterRequiresExplicitPromptCache(model)) return body;
const messages = Array.isArray(record.messages) ? record.messages : undefined;
if (!messages) return body;
let idx = -1;
for (let i = 0; i < messages.length; i++) {
const m = messages[i];
if (m && typeof m === 'object' && (m as Record<string, unknown>).role === 'system') idx = i;
}
if (idx === -1) return body;
const sys = messages[idx] as Record<string, unknown>;
if (typeof sys.content !== 'string' || sys.content.length === 0) return body;
const next = messages.slice();
next[idx] = {
...sys,
content: [{ type: 'text', text: sys.content, cache_control: { type: 'ephemeral' } }],
};
return { ...record, messages: next };
}
/**
* Compat fetch: honors the OPENROUTER_CACHE_HEADER marker by splicing an
* Anthropic cache_control breakpoint onto the system block, then strips the
* marker. Fail-open: any parse problem sends the original body unchanged.
*
* @internal exported for tests. Cast through `unknown` because TS's
* `typeof fetch` includes a `preconnect` member (matches azure-openai.ts).
*/
export const openrouterCompatFetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
if (!init?.headers) return fetch(input as any, init as any);
const headers = new Headers(init.headers as any);
if (!headers.has(OPENROUTER_CACHE_HEADER)) return fetch(input as any, init as any);
headers.delete(OPENROUTER_CACHE_HEADER);
let body = init.body;
if (typeof body === 'string') {
try {
const parsed = JSON.parse(body);
const rewritten = withSystemCacheControl(parsed);
if (rewritten !== parsed) {
body = JSON.stringify(rewritten);
headers.delete('content-length');
}
} catch {
// Non-JSON body: let the provider surface the original problem.
}
}
return fetch(input as any, { ...init, headers, body } as any);
}) as unknown as typeof fetch;
/**
* OpenRouter single-key fan-out to OpenAI, Anthropic, Google, DeepSeek, and
* dozens of other providers via a single OpenAI-compatible endpoint at
@@ -189,9 +93,7 @@ export const openrouter: Recipe = {
supports_tools: true,
// Informational only — real gate is isAnthropicProvider() upstream.
supports_subagent_loop: false,
// Family-scoped: OpenAI routes cache automatically; Anthropic routes
// cache via the compat fetch shim's cache_control rewrite.
supports_prompt_cache: openrouterSupportsPromptCache,
supports_prompt_cache: false,
// No max_context_tokens: catalog spans 128K to 1M+; a single recipe-wide
// value is either unsafe for smaller models or wasteful for larger ones.
// Let upstream errors surface per-model.
@@ -200,5 +102,4 @@ export const openrouter: Recipe = {
},
setup_hint:
'Get an API key at https://openrouter.ai/settings/keys, then `export OPENROUTER_API_KEY=...` and use `openrouter:<provider>/<model>`. Optional overrides: OPENROUTER_BASE_URL (proxy), OPENROUTER_REFERER (attribution URL), OPENROUTER_TITLE (attribution name).',
compat: { fetch: openrouterCompatFetch },
};
-54
View File
@@ -1,54 +0,0 @@
import type { Recipe } from '../types.ts';
/**
* Perplexity's hosted embeddings API (#1046). OpenAI-shaped at
* `POST {base}/embeddings` but diverges on the wire:
* - `encoding_format` only accepts 'base64_int8' (default) or
* 'base64_binary' the AI SDK's 'float' default is rejected.
* - The response `embedding` is a base64 string encoding SIGNED INT8
* components (natively quantized output), not a float array.
* Both divergences are handled by perplexityCompatFetch in gateway.ts
* (force 'base64_int8' outbound; decode Int8Array number[] inbound).
* Cosine similarity is scale-invariant, so the raw int8 components store
* and rank correctly as floats.
*
* Models (per docs.perplexity.ai/api-reference/embeddings-post, 2026-07):
* - pplx-embed-v1-0.6b: dims 128..1024 (default 1024)
* - pplx-embed-v1-4b: dims 128..2560 (default 2560)
* The flexible-dim range validation lives in src/core/ai/dims.ts
* (PERPLEXITY_EMBEDDING_MAX_DIMS). default_dims is pinned at 1024 so both
* models work out of the box on a plain vector(N) column; users who want
* the 4b model's full 2560 width set `embedding_dimensions: 2560` and the
* existing halfvec path (dims > 2000) covers storage + ANN.
*
* Auth is PERPLEXITY_API_KEY only deliberately NO OPENAI_API_KEY
* fallback (a Perplexity brain must never silently bill/route through
* OpenAI). If your key lives in PPLX_API_KEY, re-export it.
*/
export const perplexity: Recipe = {
id: 'perplexity',
name: 'Perplexity',
tier: 'openai-compat',
implementation: 'openai-compatible',
base_url_default: 'https://api.perplexity.ai/v1',
auth_env: {
required: ['PERPLEXITY_API_KEY'],
setup_url: 'https://www.perplexity.ai/settings/api',
},
touchpoints: {
embedding: {
models: ['pplx-embed-v1-0.6b', 'pplx-embed-v1-4b'],
default_dims: 1024,
cost_per_1m_tokens_usd: 0.03, // pplx-embed-v1-4b; 0.6b is $0.004/M
price_last_verified: '2026-07-21',
// Perplexity enforces 120K combined tokens (and 512 texts) per
// request. Same pre-split posture as Voyage: assume a dense
// tokenizer (1 char ≈ 1 token) at 0.5 utilization; the gateway's
// recursive halving is the runtime safety net.
max_batch_tokens: 120_000,
chars_per_token: 1,
safety_factor: 0.5,
},
},
setup_hint: 'Get an API key at https://www.perplexity.ai/settings/api, then `export PERPLEXITY_API_KEY=...` (re-export PPLX_API_KEY if that is where your key lives).',
};
-7
View File
@@ -16,13 +16,6 @@ export const together: Recipe = {
setup_url: 'https://api.together.ai/settings/api-keys',
},
touchpoints: {
// Same OpenAI-compatible endpoint as chat; declared so an explicit
// `expansion_model: together:...` resolves instead of silently dropping
// expansion (#1135).
expansion: {
models: ['meta-llama/Llama-3.3-70B-Instruct-Turbo', 'Qwen/Qwen2.5-72B-Instruct-Turbo'],
price_last_verified: '2026-04-20',
},
chat: {
models: [
'Qwen/Qwen2.5-72B-Instruct-Turbo',
+4 -19
View File
@@ -1,10 +1,9 @@
import type { Recipe } from '../types.ts';
/**
* Zhipu AI (AI) BigModel Open Platform. OpenAI-compatible /embeddings and
* /chat/completions endpoints at open.bigmodel.cn. Hosts embedding-2 (1024d),
* embedding-3 (Matryoshka up to 2048d), and the GLM chat family (glm-5.1 etc.)
* with native tool calling usable for models.tier.subagent (#1157).
* Zhipu AI (AI) BigModel Open Platform. OpenAI-compatible /embeddings
* endpoint at open.bigmodel.cn. Hosts embedding-2 (1024d) and embedding-3
* (Matryoshka up to 2048d).
*
* embedding-3 at 2048 dims exceeds pgvector's HNSW cap of 2000 those
* brains fall back to exact vector scans (see
@@ -26,20 +25,6 @@ export const zhipu: Recipe = {
setup_url: 'https://open.bigmodel.cn/',
},
touchpoints: {
chat: {
// Informational list (openai-compat tier: assertTouchpoint doesn't
// enforce it), so newer GLM ids pass without a recipe edit.
models: ['glm-5.1', 'glm-4.6', 'glm-4.5'],
supports_tools: true,
// gbrain-side stable tool ids (v0.38 D11) decoupled the loop from
// Anthropic response formats; GLM tool calling is stable through the
// OpenAI-compat path, same as deepseek/groq.
supports_subagent_loop: true,
// Anthropic-style cache_control markers are not honored on the
// OpenAI-compat path — the loop runs hot (degraded:no_caching warn).
supports_prompt_cache: false,
max_context_tokens: 128000,
},
embedding: {
models: ['embedding-3', 'embedding-2'],
default_dims: 1024,
@@ -51,5 +36,5 @@ export const zhipu: Recipe = {
},
},
setup_hint:
'Get an API key at https://open.bigmodel.cn/, then `export ZHIPUAI_API_KEY=...`. Chat/subagent: use `zhipu:glm-5.1`.',
'Get an API key at https://open.bigmodel.cn/, then `export ZHIPUAI_API_KEY=...`',
};
+3 -19
View File
@@ -22,8 +22,7 @@ export type Implementation =
| 'native-openai'
| 'native-google'
| 'native-anthropic'
| 'openai-compatible'
| 'claude-cli';
| 'openai-compatible';
export interface EmbeddingTouchpoint {
models: string[];
@@ -55,16 +54,6 @@ export interface EmbeddingTouchpoint {
* `max_batch_tokens` is also set.
*/
safety_factor?: number;
/**
* Maximum number of inputs per embedding request. Some endpoints enforce a
* hard COUNT cap independent of token budget notably llama.cpp's
* `llama-server`, which rejects requests with more inputs than its launch
* batch size (e.g. `batch size 100 > maximum allowed batch size 32`). The
* token-budget pre-split cannot bound item count (many tiny chunks fit under
* any token budget), so this is enforced as a separate hard re-split after
* the token split. When unset, no count cap is applied.
*/
max_batch_items?: number;
/**
* v0.27.1: when true, at least one model in this recipe accepts image
* inputs via a multimodal embedding endpoint (e.g. Voyage's
@@ -233,13 +222,8 @@ export interface ChatTouchpoint {
* Strictly stronger than supports_tools.
*/
supports_subagent_loop: boolean;
/**
* Prompt caching honored for this chat touchpoint. Static booleans cover
* native providers; openai-compatible aggregators may decide per model id
* (e.g. OpenRouter caches OpenAI and Anthropic routes but not every routed
* model family).
*/
supports_prompt_cache?: boolean | ((modelId: string) => boolean);
/** Anthropic-style ephemeral prompt cache markers honored. */
supports_prompt_cache?: boolean;
max_context_tokens?: number;
cost_per_1m_input_usd?: number;
cost_per_1m_output_usd?: number;
-63
View File
@@ -1,63 +0,0 @@
/**
* Nightly conversation-parser probe audit trail.
*
* One event per REAL probe run lands in
* `~/.gbrain/audit/parser-probe-YYYY-Www.jsonl` (ISO-week rotation via the
* shared audit-writer primitive; honors `GBRAIN_AUDIT_DIR`).
* Scheduler-cadence skips (`rate_limited`) are NOT logged the autopilot
* loop ticks every few minutes, so logging every skip would flood the
* audit file with rows that carry no signal.
*
* Read by `gbrain doctor`'s `conversation_parser_probe_health` check and
* by the autopilot wiring's 24h rate-limit gate (`parserProbeRanWithin`).
*/
import { createAuditWriter } from './audit/audit-writer.ts';
import type { NightlyProbeResult } from './conversation-parser/nightly-probe.ts';
export type ParserProbeAuditEvent = NightlyProbeResult;
const writer = createAuditWriter<ParserProbeAuditEvent>({
featureName: 'parser-probe',
errorLabel: 'gbrain',
errorMessagePrefix: 'parser-probe audit ',
errorTrailer: '; probe continues',
});
/** Append one parser-probe event. Best-effort; never throws. */
export function logParserProbeEvent(event: ParserProbeAuditEvent): void {
writer.log(event);
}
/**
* Read recent parser-probe events (current + previous ISO week, filtered
* to the window). Missing files and corrupt rows are skipped silently.
*/
export function readRecentParserProbeEvents(
days = 7,
now: Date = new Date(),
): ParserProbeAuditEvent[] {
return writer.readRecent(days, now);
}
/** Exposed for tests pinning the rotation edge cases. */
export function computeParserProbeAuditFilename(now: Date = new Date()): string {
return writer.computeFilename(now);
}
/**
* 24h rate-limit gate for the autopilot wiring: true when any audited run
* happened within `windowMs` of `now`. Only REAL outcomes are audited (see
* module header), so a pass/fail today blocks re-runs until tomorrow while
* scheduler-cadence skips never extend the window.
*/
export function parserProbeRanWithin(
windowMs: number,
now: Date = new Date(),
): boolean {
const cutoff = now.getTime() - windowMs;
return readRecentParserProbeEvents(2, now).some((ev) => {
const ts = Date.parse(ev.ts);
return Number.isFinite(ts) && ts >= cutoff;
});
}
-14
View File
@@ -81,20 +81,6 @@ function getLoad(): number {
/** Get memory usage fraction (0-1) */
function getMemoryUsage(): number {
// Prefer /proc/meminfo MemAvailable on Linux — os.freemem() returns
// MemFree which excludes page cache, falsely reading "high pressure"
// in any container where the kernel caches files.
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs');
const meminfo: string = fs.readFileSync('/proc/meminfo', 'utf8');
const totalKb = Number(meminfo.match(/MemTotal:\s+(\d+)/)?.[1]);
const availKb = Number(meminfo.match(/MemAvailable:\s+(\d+)/)?.[1]);
if (totalKb > 0 && availKb >= 0) return 1 - availKb / totalKb;
} catch {
/* fall through to os.freemem() (non-Linux or /proc unavailable) */
}
// Non-Linux fallback (macOS, Windows, or any host without /proc/meminfo)
const total = totalmem();
if (total === 0) return 0;
return 1 - (freemem() / total);
+11 -25
View File
@@ -11,35 +11,21 @@ import { parseModelId } from './ai/model-resolver.ts';
* RecommendationContext (doctor + autopilot) use this to build a sync
* `resolveKey` closure without re-parsing recipes.
*
* Only keys that `buildGatewayConfig` (src/core/ai/build-gateway-config.ts)
* actually folds from config into the gateway env may appear here.
* GOOGLE_GENERATIVE_AI_API_KEY is deliberately absent: its config field is NOT
* threaded to the gateway today, so the producer closures fall through to
* checking `process.env` ONLY for it. That matches what the gateway can
* actually use (the recipe reads that key from env). Counting a config-plane
* google_api_key here would be a false positive: doctor/autopilot would call
* the provider "configured" and dispatch an embed.stale job that then fails
* auth at the gateway. When a future change threads google_api_key into
* buildGatewayConfig, re-add the matching entry here in the same change.
*
* VOYAGE_API_KEY voyage_api_key was the same kind of gap (#2662) until
* buildGatewayConfig started folding it now safe to list here too.
*
* Caveat inherited from the existing OPENAI_API_KEY/ZEROENTROPY_API_KEY
* entries (unchanged by #2662, noted here for anyone extending this map):
* autopilot's resolveKey resolves these fields via `engine.getConfig()`
* (DB plane), while `buildGatewayConfig` only folds the FILE-plane
* (config.json) value. A `gbrain config set voyage_api_key X` with no
* matching config.json entry can therefore still read "configured" here
* while the gateway has no key a pre-existing false-positive class, not
* introduced or fixed by this change. Closing it requires threading
* `*_api_key` DB values through `loadConfigWithEngine()` before
* `buildGatewayConfig`, which is a separate, larger change.
* Only OPENAI_API_KEY and ZEROENTROPY_API_KEY appear here because those are the
* only embedding keys `buildGatewayConfig` (src/cli.ts) folds from config into
* the gateway env. VOYAGE_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY are deliberately
* absent: their config fields are NOT threaded to the gateway today, so the
* producer closures fall through to checking `process.env` ONLY for them. That
* matches what the gateway can actually use (the recipes read those keys from
* env). Counting a config-plane voyage_api_key/google_api_key here would be a
* false positive: doctor/autopilot would call the provider "configured" and
* dispatch an embed.stale job that then fails auth at the gateway. When a future
* change threads voyage_api_key/google_api_key into buildGatewayConfig (the open
* voyage-config-mapping work), re-add the matching entry here in the same change.
*/
export const HOSTED_EMBED_KEY_CONFIG: Record<string, string> = {
OPENAI_API_KEY: 'openai_api_key',
ZEROENTROPY_API_KEY: 'zeroentropy_api_key',
VOYAGE_API_KEY: 'voyage_api_key',
};
/**
+18 -34
View File
@@ -139,21 +139,8 @@ export function autoFixFrontmatter(
fixes.push({ code: 'NULL_BYTES', description: 'Stripped null bytes' });
}
// 2. MISSING_CLOSE — if there's an opener but no closer at all, insert
// `---` immediately before the first heading-shaped line (best-effort
// guess at where the frontmatter was meant to end).
//
// Find the closer FIRST, scanning the full zone — do not stop at the
// first `#`-prefixed line. A `#` line between the opening and closing
// `---` is a YAML comment (comments are valid anywhere in a YAML
// document), not a markdown heading; only the genuine absence of a
// closing `---` counts as MISSING_CLOSE. Mirrors the fix applied to
// the parseMarkdown validator in #2153 — this is the sibling
// reimplementation in the auto-fixer and had the same bug (it broke
// out of the scan on the first heading-shaped line, so a `#` comment
// appearing before a real closing fence was misdetected as
// MISSING_CLOSE and the fix inserted a spurious `---` that split
// valid frontmatter in two, pushing the real keys into the body).
// 2. MISSING_CLOSE — if there's an opener but no closer before a heading,
// insert `---` immediately before the heading. Walk lines once.
{
const lines = working.split('\n');
let firstNonEmpty = -1;
@@ -162,27 +149,24 @@ export function autoFixFrontmatter(
}
if (firstNonEmpty >= 0 && lines[firstNonEmpty].trim() === '---') {
let closeIdx = -1;
let headingIdx = -1;
for (let i = firstNonEmpty + 1; i < lines.length; i++) {
if (lines[i].trim() === '---') { closeIdx = i; break; }
const t = lines[i].trim();
if (t === '---') { closeIdx = i; break; }
if (/^#{1,6}\s/.test(t)) { headingIdx = i; break; }
}
if (closeIdx === -1) {
let headingIdx = -1;
for (let i = firstNonEmpty + 1; i < lines.length; i++) {
if (/^#{1,6}\s/.test(lines[i].trim())) { headingIdx = i; break; }
}
if (headingIdx >= 0) {
const fixed = [
...lines.slice(0, headingIdx),
'---',
'',
...lines.slice(headingIdx),
];
working = fixed.join('\n');
fixes.push({
code: 'MISSING_CLOSE',
description: `Inserted closing --- before heading at line ${headingIdx + 1}`,
});
}
if (closeIdx === -1 && headingIdx >= 0) {
const fixed = [
...lines.slice(0, headingIdx),
'---',
'',
...lines.slice(headingIdx),
];
working = fixed.join('\n');
fixes.push({
code: 'MISSING_CLOSE',
description: `Inserted closing --- before heading at line ${headingIdx + 1}`,
});
}
}
}
+7 -43
View File
@@ -68,7 +68,6 @@ import {
type BrainstormCheckpoint,
type CheckpointCross,
} from './checkpoint.ts';
import { resolveOwnerHolder } from '../owner-holder.ts';
export { BudgetExhausted };
@@ -140,7 +139,7 @@ export interface BrainstormOptions {
modelOverride?: string;
/** Skip the cost-preview TTY grace window. Required for non-interactive callers. */
skipCostPreview?: boolean;
/** When set, force the user holder for calibration profile lookup. Falls back to config (`emotional_weight.user_holder`) then `'self'`. */
/** When set, force the user holder for calibration profile lookup. Falls back to config (`emotional_weight.user_holder`) then `'garry'`. */
holderOverride?: string;
/** Source scope. */
sourceId?: string;
@@ -486,44 +485,9 @@ const DEFAULT_PARALLELISM = 4;
* src/core/errors.ts (the v0.19.0 envelope every new agent-facing
* surface uses) rather than introducing a new BrainstormError class.
*/
/** File-config slice the orchestrator reads (see loadConfig in core/config.ts). */
export interface BrainstormRunConfig {
embedding_model?: string;
chat_model?: string;
emotional_weight?: { user_holder?: string };
}
/**
* Model used for the cost preview + hard cost ceiling. Mirrors what the
* gateway will actually run: explicit --model override, else the configured
* chat_model (gateway default), else the hardcoded gateway fallback. Before
* this resolved through config, a non-Sonnet chat_model got its preview
* priced against the wrong model. (Takeover of PR #1855 by @starm2010.)
*/
export function resolveBrainstormChatModel(
config: { chat_model?: string },
modelOverride?: string,
): string {
return modelOverride ?? config.chat_model ?? 'anthropic:claude-sonnet-4-6';
}
/**
* Judge-phase model precedence: --judge-model flag, else the
* `models.brainstorm.judge` config key, else undefined (falls back to
* `modelOverride` then the gateway default at the runJudge callsite).
*/
export async function resolveBrainstormJudgeModel(
engine: BrainEngine,
judgeModelFlag?: string,
): Promise<string | undefined> {
if (judgeModelFlag) return judgeModelFlag;
const configured = await engine.getConfig('models.brainstorm.judge');
return configured ?? undefined;
}
export async function runBrainstorm(
engine: BrainEngine,
config: BrainstormRunConfig,
config: { embedding_model?: string; emotional_weight?: { user_holder?: string } },
opts: BrainstormOptions
): Promise<BrainstormResult> {
// v0.39.3.0 (Phase 5, CV11+T4): outer try/catch around the orchestrator
@@ -545,7 +509,7 @@ export async function runBrainstorm(
async function runBrainstormImpl(
engine: BrainEngine,
config: BrainstormRunConfig,
config: { embedding_model?: string; emotional_weight?: { user_holder?: string } },
opts: BrainstormOptions,
): Promise<BrainstormResult> {
// v0.39.0.0 T10: install a gateway-layer BudgetTracker scope around the
@@ -565,7 +529,7 @@ async function runBrainstormImpl(
async function _runBrainstormInner(
engine: BrainEngine,
config: BrainstormRunConfig,
config: { embedding_model?: string; emotional_weight?: { user_holder?: string } },
opts: BrainstormOptions,
): Promise<BrainstormResult> {
const profile = opts.profile ?? BRAINSTORM_PROFILE;
@@ -574,7 +538,7 @@ async function _runBrainstormInner(
const embedFn = opts.embedQueryFn ?? embedQuery;
// ---- Phase 0: cost preview + TTY grace ----
const modelStr = resolveBrainstormChatModel(config, opts.modelOverride);
const modelStr = opts.modelOverride ?? 'anthropic:claude-sonnet-4-6';
const { aborted, estimate } = await previewCostAndWait({
profile,
model: modelStr,
@@ -659,7 +623,7 @@ async function _runBrainstormInner(
}
// ---- Phase 3: calibration context (cold-start fallback) ----
const holder = resolveOwnerHolder({ override: opts.holderOverride, configValue: config.emotional_weight?.user_holder });
const holder = opts.holderOverride ?? config.emotional_weight?.user_holder ?? 'garry';
const calibContext = await loadCalibrationContext(engine, {
holder,
sourceId: opts.sourceId,
@@ -883,7 +847,7 @@ async function _runBrainstormInner(
far_slug: i.far_slug,
}));
const judgeResult = await runJudge(profile.judge_config, judgeInput, {
modelOverride: (await resolveBrainstormJudgeModel(engine, opts.judgeModel)) ?? opts.modelOverride,
modelOverride: opts.judgeModel ?? opts.modelOverride,
chatFn: opts.chatFn,
activeBiasTags: activeBiasTags ?? undefined,
abortSignal: opts.abortSignal,
+3 -22
View File
@@ -32,7 +32,6 @@ import { mkdirSync, appendFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { gbrainPath } from '../config.ts';
import { ANTHROPIC_PRICING, type ModelPricing } from '../anthropic-pricing.ts';
import { canonicalLookup } from '../model-pricing.ts';
import { EMBEDDING_PRICING, lookupEmbeddingPrice } from '../embedding-pricing.ts';
import { splitProviderModelId } from '../model-id.ts';
import { isoWeekFilename, resolveAuditDir } from '../audit-week-file.ts';
@@ -167,13 +166,9 @@ const FREE_LOCAL_EMBED_PROVIDERS: ReadonlySet<string> = new Set([
* local-inference providers (FREE_LOCAL_EMBED_PROVIDERS) price at $0 so
* `--max-cost` callers don't hard-fail.
* - Rerank: try ANTHROPIC_PRICING (legacy path for any Claude-priced
* rerank); else try lookupEmbeddingPrice paid rerank providers (e.g.
* ZeroEntropy's zerank-2) share the same provider:model-keyed,
* $/1M-token table as their embedding siblings, so it's reused here
* rather than duplicated into a third table; else if the provider half
* is in FREE_LOCAL_RERANK_PROVIDERS, return zero pricing so `--max-cost`
* callers don't TX2 hard-fail on local inference recipes (electricity,
* not tokens); else unknown.
* rerank); else if the provider half is in FREE_LOCAL_RERANK_PROVIDERS,
* return zero pricing so `--max-cost` callers don't TX2 hard-fail on
* local inference recipes (electricity, not tokens); else unknown.
*/
function lookupPricing(modelId: string, kind: BudgetKind): ModelPricing | null {
if (kind === 'embed') {
@@ -199,14 +194,6 @@ function lookupPricing(modelId: string, kind: BudgetKind): ModelPricing | null {
const tailHit = ANTHROPIC_PRICING[modelTail];
if (tailHit) return tailHit;
}
// Paid rerank providers (e.g. ZeroEntropy's zerank-2) aren't Claude-priced,
// so they miss the ANTHROPIC_PRICING checks above. Reuse the embedding
// pricing table (issue #3223) — same provider:model key shape, same
// $/1M-token unit — instead of hand-copying a third pricing surface.
if (kind === 'rerank') {
const hit = lookupEmbeddingPrice(modelId);
if (hit.kind === 'known') return { input: hit.pricePerMTok, output: 0 };
}
// v0.40.6.1: zero-price local-inference rerank providers so the budget
// tracker's TX2 hard-fail doesn't trip on `llama-server-reranker:<model>`
// under `--max-cost`. Only the rerank kind — chat/embed already have
@@ -214,12 +201,6 @@ function lookupPricing(modelId: string, kind: BudgetKind): ModelPricing | null {
if (kind === 'rerank' && providerId && FREE_LOCAL_RERANK_PROVIDERS.has(providerId)) {
return { input: 0, output: 0 };
}
// Fall back to the full canonical pricing table so non-Anthropic chat
// models with a known price (openai:*, google:*, deepseek:*) resolve under
// --max-cost instead of TX2 no_pricing hard-failing at $0. ANTHROPIC_PRICING
// above is only the bare-keyed Claude view.
const canon = canonicalLookup(modelId);
if (canon) return canon;
return null;
}
+1 -1
View File
@@ -25,7 +25,7 @@ import type { BrainEngine } from '../engine.ts';
export interface ABRunInput {
question: string;
/** Holder context for calibration. Resolves via resolveOwnerHolder (config emotional_weight.user_holder, else 'self'). */
/** Holder context for calibration. Default 'garry'. */
holder?: string;
/** Engine for DB write. */
engine: BrainEngine;
+3 -13
View File
@@ -217,19 +217,9 @@ export function parseResolverEntries(resolverContent: string): ResolverEntry[] {
// `skillsDir/*/SKILL.md` when manifest.json is missing — the scenario
// needed for AGENTS.md-only OpenClaw deployments. See D-CX-12 / F-ENG-1.
/**
* Simple YAML frontmatter parser extracts triggers array if present.
*
* 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.
*/
export function extractTriggers(skillContent: string): string[] {
const content = skillContent.replace(/\r\n/g, '\n');
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
/** Simple YAML frontmatter parser — extracts triggers array if present. */
function extractTriggers(skillContent: string): string[] {
const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) return [];
const fm = fmMatch[1];
const triggersMatch = fm.match(/^triggers:\s*\n((?:\s+-\s+.+\n?)*)/m);
+3 -98
View File
@@ -168,15 +168,6 @@ export interface CodeChunkOptions {
largeChunkThresholdTokens?: number;
fallbackChunkSizeWords?: number;
fallbackOverlapWords?: number;
/**
* Hard upper bound (estimated tokens) on any single emitted chunk. A node
* the AST splitter can't break up (a giant object/array literal, a single
* huge assignment, a massive template literal) would otherwise be emitted
* whole and rejected by the embedder ("input exceeds context length").
* Chunks over this budget are recursively re-split. Default 2000 fits the
* smallest common embedder context (e.g. nomic-embed-text, 2048).
*/
maxChunkTokens?: number;
}
/**
@@ -558,7 +549,6 @@ export function parseWithTimeout(
}
const DEFAULT_CHUNKER_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_CHUNK_TOKENS = 2000;
function resolveChunkerTimeoutMs(): number {
const raw = process.env.GBRAIN_CHUNKER_TIMEOUT_MS;
@@ -716,9 +706,9 @@ export async function chunkCodeTextFull(
}
if (chunks.length === 0) {
return { chunks: capOversizedChunks(fallbackChunks(source, filePath, language, opts), filePath, language, opts), edges: rawEdges };
return { chunks: fallbackChunks(source, filePath, language, opts), edges: rawEdges };
}
return { chunks: capOversizedChunks(mergeSmallSiblings(chunks, chunkTarget), filePath, language, opts), edges: rawEdges };
return { chunks: mergeSmallSiblings(chunks, chunkTarget), edges: rawEdges };
} catch {
return { chunks: fallbackChunks(source, filePath, language, opts), edges: [] };
} finally {
@@ -824,73 +814,6 @@ function buildMergedChunk(group: CodeChunk[], index: number): CodeChunk {
};
}
/**
* Final safety net: guarantee no emitted chunk exceeds the embedder's context
* budget. tree-sitter splitting (splitLargeNode) can only break up a node that
* exposes a `body` with >= 2 named children. A node without one a giant
* object/array literal, a single huge assignment, a massive template literal
* is emitted whole, producing a chunk far larger than the embedder accepts.
* The embedder then rejects it ("input exceeds context length") and the chunk
* is never embedded. Recursively re-split any over-budget chunk; fall back to a
* hard character split for pathological no-whitespace content (e.g. a minified
* one-liner) where word/line splitting can't get under budget.
*/
function capOversizedChunks(
chunks: CodeChunk[],
filePath: string,
language: SupportedCodeLanguage,
opts: CodeChunkOptions,
): CodeChunk[] {
const cap = opts.maxChunkTokens ?? DEFAULT_MAX_CHUNK_TOKENS;
if (!chunks.some((c) => estimateTokens(c.text) > cap)) return chunks;
const out: CodeChunk[] = [];
for (const c of chunks) {
if (estimateTokens(c.text) <= cap) {
out.push({ ...c, index: out.length });
continue;
}
// Strip the structured header ("[Lang] path:N-M symbol\n\n") so the splitter
// works on the raw body; buildChunk re-adds a header to each piece.
const body = c.text.replace(/^\[[^\]]+\] [^\n]+\n\n/, '');
for (const piece of splitToTokenBudget(body, cap, opts)) {
if (!piece.trim()) continue;
out.push(buildChunk({
body: piece,
filePath,
language,
symbolName: c.metadata.symbolName,
symbolType: c.metadata.symbolType,
startLine: c.metadata.startLine,
endLine: c.metadata.endLine,
index: out.length,
parentSymbolPath: c.metadata.parentSymbolPath,
}));
}
}
return out;
}
/** Split `text` into pieces each estimated <= cap tokens. Word/line-aware
* (recursiveChunk) first; a hard character split is the last resort for
* content with no whitespace to break on. */
function splitToTokenBudget(text: string, cap: number, opts: CodeChunkOptions): string[] {
const out: string[] = [];
const pieces = recursiveChunk(text, {
chunkSize: opts.fallbackChunkSizeWords ?? 300,
chunkOverlap: opts.fallbackOverlapWords ?? 50,
}).map((p) => p.text);
for (const piece of pieces) {
if (estimateTokens(piece) <= cap) {
out.push(piece);
continue;
}
// ~3.5 chars/token is a conservative cl100k estimate for source text.
const charBudget = Math.max(1, Math.floor(cap * 3.5));
for (let i = 0; i < piece.length; i += charBudget) out.push(piece.slice(i, i + charBudget));
}
return out;
}
// ---------- Internals ----------
function fallbackChunks(
@@ -1235,25 +1158,7 @@ export function estimateTokens(text: string): number {
tiktokenInitialized = true;
}
if (tiktokenEncoder) {
try {
return tiktokenEncoder.encode(text).length;
} catch {
// Code legitimately contains tiktoken special-token strings (e.g. CLIP/GPT
// tokenizers embed the literal "<|endoftext|>"). The default encode() uses
// disallowed_special='all' and THROWS on those, crashing reindex-code on
// valid source files. For a token COUNT we don't need special-token
// semantics: re-encode treating them as ordinary text (never throws),
// heuristic only if even that fails.
try {
return (
tiktokenEncoder as unknown as {
encode: (s: string, allowed: string[], disallowed: string[]) => Uint32Array;
}
).encode(text, [], []).length;
} catch {
return Math.max(1, Math.ceil(text.length / 4));
}
}
return tiktokenEncoder.encode(text).length;
}
return Math.max(1, Math.ceil(text.length / 4));
}
-8
View File
@@ -20,14 +20,6 @@ export const CJK_SLUG_CHARS = '一-鿿぀-ゟ゠-ヿ가-힯';
export const CJK_RANGES_REGEX = new RegExp(`[${CJK_SLUG_CHARS}]`);
/**
* Page-slug segment grammar (no anchors): alnum-or-CJK lead char, then
* alnum/CJK/hyphen continuation. Single source for validatePageSlug
* (operations.ts), SlugRegistry's SLUG_RE, and the dream-cycle
* SUMMARY_SLUG_RE so every slug validator shares one grammar (#738).
*/
export const PAGE_SLUG_SEG = `[a-z0-9${CJK_SLUG_CHARS}][a-z0-9${CJK_SLUG_CHARS}\\-]*`;
export const CJK_SENTENCE_DELIMITERS = ['。', '', '']; // 。!?
export const CJK_CLAUSE_DELIMITERS = ['', '', '', '、']; // ;:,、
+1 -72
View File
@@ -48,21 +48,6 @@ export interface GBrainConfig {
* reads OPENROUTER_API_KEY.
*/
openrouter_api_key?: string;
/**
* Voyage AI API key (#2662). File-plane slot so `~/.gbrain/config.json`'s
* `voyage_api_key` reaches the voyage recipe the same way
* zeroentropy_api_key/openrouter_api_key do: file plane
* buildGatewayConfig env dict recipe reads VOYAGE_API_KEY. Before this,
* launchd/daemon/MCP contexts without a process-env export silently
* failed multimodal embeds despite config.json looking complete.
*
* NOTE (scoped to what this fix covers): `gbrain config set
* voyage_api_key X` writes the DB plane, which `loadConfigWithEngine()`
* does NOT merge for any `*_api_key` field (zeroentropy_api_key /
* openrouter_api_key have the same pre-existing gap) only the
* config.json file-plane route is wired through today.
*/
voyage_api_key?: string;
/** AI gateway config (v0.14+). v0.36+ default: "zeroentropyai:zembed-1" / 1280 / "anthropic:claude-haiku-4-5-20251001". */
embedding_model?: string;
embedding_dimensions?: number;
@@ -120,16 +105,6 @@ export interface GBrainConfig {
*/
max_usd?: number;
};
/**
* v0.41.16.0 nightly conversation-parser probe. Per D10: default ON
* for `search.mode=tokenmax` brains, opt-in for conservative/balanced.
* ~$0.05/night with the committed fixtures × Haiku polish. Gated
* INSIDE the autopilot tick body, like nightly_quality_probe.
*/
conversation_parser_probe?: {
/** Enable for non-tokenmax modes. Defaults to false. */
enabled?: boolean;
};
/**
* v0.42.x (#1685 GAP D) extract_atoms backlog auto-drain. Default ON so a
* pack-gated silent backlog never piles up unseen; daily-spend-capped so the
@@ -146,18 +121,6 @@ export interface GBrainConfig {
/** Daily spend cap (USD); bounds drains/day = floor(cap / ~$0.30). Default 2.0. */
max_usd_per_day?: number;
};
/**
* v0.42 keep frontmatter links fresh on the incremental cycle. The cycle's
* extract phase re-extracts only the slugs a sync changed, but `extractForSlugs`
* extracts BODY links only frontmatter (`sources:`/`related:` etc.) link edges
* silently drift stale when a page's YAML is edited externally and synced in.
* Set true to also extract frontmatter links per changed page each cycle, keeping
* externally-edited YAML edges fresh without a full rescan. Default false
* (preserves current behavior). Read via the file/env/DB plane in the cycle's
* extract dispatch. Disable/enable with
* `gbrain config set autopilot.incremental_extract_include_frontmatter <bool>`.
*/
incremental_extract_include_frontmatter?: boolean;
};
eval?: {
/** false disables capture entirely. Defaults to true. */
@@ -657,10 +620,7 @@ export function loadConfig(): GBrainConfig | null {
* size the schema and must be stable across engine connect.
*/
export async function loadConfigWithEngine(
engine: {
getConfig(key: string): Promise<string | null | undefined>;
listConfigKeys?(prefix: string): Promise<string[]>;
},
engine: { getConfig(key: string): Promise<string | null | undefined> },
base?: GBrainConfig | null,
): Promise<GBrainConfig | null> {
// Codex /ship finding #3: when there's no file config AND no env DB URL,
@@ -697,31 +657,11 @@ export async function loadConfigWithEngine(
return undefined;
}
}
async function dbPrefixMap(prefix: string): Promise<Record<string, string> | undefined> {
if (typeof engine.listConfigKeys !== 'function') return undefined;
let keys: string[];
try {
keys = await engine.listConfigKeys(prefix);
} catch {
return undefined;
}
const out: Record<string, string> = {};
for (const key of keys.sort()) {
if (!key.startsWith(prefix)) continue;
const leaf = key.slice(prefix.length);
if (!leaf) continue;
const value = await dbStr(key);
if (value !== undefined) out[leaf] = value;
}
return Object.keys(out).length > 0 ? out : undefined;
}
const dbMultimodal = await dbBool('embedding_multimodal');
const dbMultimodalModel = await dbStr('embedding_multimodal_model');
const dbOcr = await dbBool('embedding_image_ocr');
const dbOcrModel = await dbStr('embedding_image_ocr_model');
const dbProviderBaseUrls = await dbPrefixMap('provider_base_urls.');
// v0.36 (D7) — embedding-column registry merge. Stored as JSON string in
// the config table. Parse + shape-check here; full registry validation
// (regex on keys, type/dim/provider field shapes) runs in the resolver at
@@ -745,15 +685,6 @@ export async function loadConfigWithEngine(
if (merged.embedding_image_ocr_model === undefined && dbOcrModel !== undefined) {
merged.embedding_image_ocr_model = dbOcrModel;
}
if (dbProviderBaseUrls !== undefined) {
const next = { ...(merged.provider_base_urls ?? {}) };
for (const [providerId, baseUrl] of Object.entries(dbProviderBaseUrls)) {
if (next[providerId] === undefined) next[providerId] = baseUrl;
}
if (Object.keys(next).length > 0) {
merged.provider_base_urls = next;
}
}
if (merged.embedding_columns === undefined && dbEmbeddingColumns !== undefined) {
try {
const parsed = JSON.parse(dbEmbeddingColumns);
@@ -912,7 +843,6 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
'anthropic_api_key',
'zeroentropy_api_key',
'openrouter_api_key',
'voyage_api_key',
'embedding_model',
'embedding_dimensions',
'embedding_disabled',
@@ -974,7 +904,6 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
'models.subagent',
'models.expansion',
'models.chat',
'models.brainstorm.judge',
'models.eval.longmemeval',
'facts.extraction_model',
// #2113: output-token cap for the per-turn facts extractor (default 4000).
+4 -78
View File
@@ -167,52 +167,6 @@ export function deriveDirectUrl(url: string): string | null {
}
}
/** True when the URL targets Supavisor transaction mode (port 6543). */
function isTransactionPoolerUrl(url: string): boolean {
try {
return new URL(url.replace(/^postgres(ql)?:\/\//, 'http://')).port === '6543';
} catch {
return false;
}
}
/**
* Resolve the direct-pool URL from an explicit/env override + the primary URL.
*
* A direct override still pointing at the TRANSACTION-mode pooler (port 6543,
* usually a copy-paste of the primary URL) is a misconfiguration: the manager
* would believe DDL/bulk work runs on a long-timeout direct connection while
* still routing through Supavisor transaction mode (short timeouts, no
* prepared statements). Normalize it via deriveDirectUrl. Session-mode pooler
* URLs (pooler host, port 5432) pass through they are a legitimate
* direct-ish target when the db.<ref> host is unreachable.
*/
export function normalizeDirectUrl(primaryUrl: string, override?: string | null): string | null {
const candidate = override ?? deriveDirectUrl(primaryUrl);
if (!candidate) return null;
if (!isTransactionPoolerUrl(candidate)) return candidate;
return deriveDirectUrl(candidate) ?? deriveDirectUrl(primaryUrl);
}
/**
* Error codes that mean "the direct host is unreachable from this network"
* (#1641). The auto-derived db.<ref>.supabase.co host is IPv6-only without
* the paid IPv4 add-on, so ENOTFOUND/ECONNREFUSED here is expected on
* IPv4-only networks we fall back to the pooler instead of failing init.
*/
const NETWORK_UNREACHABLE_CODES = [
'ENOTFOUND', 'ECONNREFUSED', 'ENETUNREACH', 'EHOSTUNREACH',
'ETIMEDOUT', 'CONNECT_TIMEOUT',
];
/** True when err looks like a network-unreachable failure (not auth/SQL). */
export function isNetworkUnreachableError(err: unknown): boolean {
const code = (err as { code?: unknown } | null)?.code;
if (typeof code === 'string' && NETWORK_UNREACHABLE_CODES.includes(code)) return true;
const msg = err instanceof Error ? err.message : String(err);
return NETWORK_UNREACHABLE_CODES.some(c => msg.includes(c));
}
/**
* Read kill-switch state from env. Subordinate to parent manager's state
* when present (A2 inheritance).
@@ -259,10 +213,9 @@ export class ConnectionManager {
} else {
this._killSwitch = readKillSwitchEnv();
this._isSupabase = isSupabasePoolerUrl(opts.url);
// Direct URL: explicit override > env > derive > null. Pooler-shaped
// overrides are normalized to a real direct host (or dropped).
// Direct URL: explicit override > env > derive > null
const envOverride = process.env.GBRAIN_DIRECT_DATABASE_URL;
this._directUrl = normalizeDirectUrl(opts.url, opts.directUrl ?? envOverride);
this._directUrl = opts.directUrl ?? envOverride ?? deriveDirectUrl(opts.url);
}
}
@@ -366,30 +319,7 @@ export class ConnectionManager {
throw err;
});
}
let pool: Sql | null;
try {
pool = await this._directInit;
} catch (err) {
// #1641: the derived direct host (db.<ref>.supabase.co) is IPv6-only
// without Supabase's IPv4 add-on. On IPv4-only networks the direct
// pool can never connect — permanently fall back to the read pool
// (self-activating kill-switch) instead of failing init/migrations.
// Non-network errors (auth, SQL) still throw: they mean misconfig,
// not unreachability.
if (isNetworkUnreachableError(err)) {
const alreadyWarned = this._killSwitch;
this._killSwitch = true;
const msg = err instanceof Error ? err.message : String(err);
if (!alreadyWarned) console.error(
`gbrain: direct connection to ${this._directUrl ? this.hostOnly(this._directUrl) : 'unknown host'} unreachable (${msg}); ` +
'falling back to the pooler for DDL/bulk (long migrations may hit the pooler statement timeout). ' +
'Set GBRAIN_DIRECT_DATABASE_URL to a reachable direct URL (e.g. the Session pooler, port 5432) or enable the Supabase IPv4 add-on; ' +
'GBRAIN_DISABLE_DIRECT_POOL=1 silences this.',
);
return this.getReadPool();
}
throw err;
}
const pool = await this._directInit;
if (!pool) {
// Defensive — initDirectPool should have thrown.
throw new Error('connection-manager: direct pool init returned null');
@@ -420,9 +350,8 @@ export class ConnectionManager {
},
};
const t0 = Date.now();
let pool: Sql | null = null;
try {
pool = postgres(this._directUrl, opts);
const pool = postgres(this._directUrl, opts);
// Probe to validate connectivity early.
await pool`SELECT 1`;
logConnectionEvent({
@@ -433,9 +362,6 @@ export class ConnectionManager {
});
return pool;
} catch (err) {
// Don't leak the failed pool's sockets/timers (#1641 fallback keeps
// the process running afterward).
if (pool) await endPoolBounded(pool);
logConnectionEvent({
pool: 'ddl',
op: 'error',
+4 -15
View File
@@ -453,14 +453,7 @@ function resolveActivity(
* every `assemble()` call. 1 MB is generous for a human-edited task list. */
const MAX_TASKS_MD_BYTES = 1_000_000;
/** Extract open tasks from ops/tasks.md Today section.
*
* The daily-task-manager skill's documented Output Format uses priority
* headings (`## P1 — Today`) with plain `- [ ] task` lines; older fixtures
* used a bare `## Today` heading with bold task names. Accept both so the
* live-context reader matches the documented writer contract instead of
* silently surfacing no tasks (#2186).
*/
/** Extract open tasks from ops/tasks.md "## Today" section. */
function resolveTodayTasks(workspaceDir: string): string[] {
try {
const path = join(workspaceDir, 'ops', 'tasks.md');
@@ -468,18 +461,14 @@ function resolveTodayTasks(workspaceDir: string): string[] {
// statSync throws if the file doesn't exist; that lands in the outer catch.
if (statSync(path).size > MAX_TASKS_MD_BYTES) return [];
const raw = readFileSync(path, 'utf8');
const todayMatch = raw.match(/^##\s+(?:P\d\s*[—–-]\s*)?Today\b[\s\S]*?(?=\n##\s|$(?![\s\S]))/m);
const todayMatch = raw.match(/## Today[\s\S]*?(?=\n## |$)/);
if (!todayMatch) return [];
const lines = todayMatch[0].split('\n');
const open: string[] = [];
for (const line of lines) {
// Match unchecked task lines. Legacy bold form first (extracts just
// the task name, dropping trailing metadata), then the documented
// plain form (whole line body is the task).
const m =
line.match(/^\s*-\s*\[ \]\s*\*\*(.+?)\*\*/) ??
line.match(/^\s*-\s*\[ \]\s*(.+?)\s*$/);
// Match unchecked task lines: - [ ] **task name** ...
const m = line.match(/^\s*-\s*\[ \]\s*\*\*(.+?)\*\*/);
if (m) open.push(sanitizeForPrompt(m[1].trim()));
}
return open.slice(0, 5); // cap at 5 to keep prompt lean
+85 -187
View File
@@ -45,6 +45,7 @@ import { embedBatch } from './embedding.ts';
import { resolveContextualRetrievalMode } from './contextual-retrieval-resolver.ts';
import {
buildContextualPrefix,
extractFirstTwoSentences,
modeRequiresHaiku,
modeRequiresWrapper,
sanitizeTitle,
@@ -53,11 +54,12 @@ import {
import {
generatePerChunkSynopsis,
SYNOPSIS_PROMPT_VERSION,
SYNOPSIS_DOC_MAX_CHARS,
type GeneratePerChunkSynopsisResult,
} from './page-summary.ts';
import type { SynopsisFailureKind } from './audit-synopsis.ts';
import { runSlidingPool } from './worker-pool.ts';
import {
logSynopsisFailure,
type SynopsisFailureKind,
} from './audit-synopsis.ts';
import type { BrainEngine } from './engine.ts';
import type { ChunkInput, CRMode, Page } from './types.ts';
import type { SourceRow } from './sources-ops.ts';
@@ -70,24 +72,6 @@ import type { SourceRow } from './sources-ops.ts';
* corpus_generation hash.
*/
export const TITLE_WRAPPER_VERSION = 1;
const DEFAULT_HAIKU_MODEL = 'anthropic:claude-haiku-4-5-20251001';
export const DEFAULT_CONTEXTUAL_CHUNK_CONCURRENCY = 4;
export const MAX_CONTEXTUAL_CHUNK_CONCURRENCY = 16;
export function resolveContextualChunkConcurrency(
env: Record<string, string | undefined> = process.env,
): number {
const raw = env.GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY;
if (raw === undefined || raw.trim() === '') return DEFAULT_CONTEXTUAL_CHUNK_CONCURRENCY;
const n = Number(raw);
if (!Number.isFinite(n)) return DEFAULT_CONTEXTUAL_CHUNK_CONCURRENCY;
return clampContextualChunkConcurrency(n);
}
function clampContextualChunkConcurrency(n: number): number {
if (!Number.isFinite(n)) return DEFAULT_CONTEXTUAL_CHUNK_CONCURRENCY;
return Math.max(1, Math.min(MAX_CONTEXTUAL_CHUNK_CONCURRENCY, Math.trunc(n)));
}
/**
* Embedding model placeholder. The actual model name lands here from
@@ -119,17 +103,8 @@ function getEmbeddingModelTag(): string {
export function computeCorpusGeneration(args: {
crMode: CRMode;
haikuModel: string;
/**
* Resolved `SYNOPSIS_DOC_MAX_CHARS` for per_chunk_synopsis runs. When
* present, folded into the hash so changes to
* `GBRAIN_SYNOPSIS_DOC_MAX_CHARS` invalidate the prior cache cleanly.
* Omit for `crMode !== 'per_chunk_synopsis'` title / none modes
* don't consult the cap and the field stays out of the hash for
* back-compat with pre-cap embeddings.
*/
synopsisDocMaxChars?: number;
}): string {
const h = createHash('sha256')
return createHash('sha256')
.update(args.crMode)
.update('|')
.update(String(SYNOPSIS_PROMPT_VERSION))
@@ -138,11 +113,9 @@ export function computeCorpusGeneration(args: {
.update('|')
.update(String(TITLE_WRAPPER_VERSION))
.update('|')
.update(getEmbeddingModelTag());
if (args.synopsisDocMaxChars !== undefined) {
h.update('|doc_cap=').update(String(args.synopsisDocMaxChars));
}
return h.digest('hex').slice(0, 16);
.update(getEmbeddingModelTag())
.digest('hex')
.slice(0, 16);
}
/**
@@ -223,16 +196,12 @@ export interface ReembedPageArgs {
* src/core/minions/rate-leases.ts here; inline callers (import-file,
* reindex command) pass undefined and rely on gateway-level retry.
*/
acquireSynopsisLease?: () => Promise<unknown>;
releaseSynopsisLease?: (lease?: unknown) => Promise<void>;
/**
* Intra-page per-chunk synopsis concurrency. 1 preserves the legacy
* sequential loop exactly; higher values only parallelize Haiku synopsis
* calls. Embedding remains one batch after all synopses succeed.
*/
chunkConcurrency?: number;
acquireSynopsisLease?: () => Promise<void>;
releaseSynopsisLease?: () => Promise<void>;
}
const DEFAULT_HAIKU_MODEL = 'anthropic:claude-haiku-4-5-20251001';
/**
* Re-embed one page through the active CR mode. Implements the D26 P0-2
* two-phase build pattern.
@@ -284,11 +253,7 @@ export async function reembedPageWithContextualRetrieval(
args.pageSlug,
args.sourceId,
resolution.mode,
computeCorpusGeneration({
crMode: resolution.mode,
haikuModel: args.haikuModel ?? DEFAULT_HAIKU_MODEL,
synopsisDocMaxChars: resolution.mode === 'per_chunk_synopsis' ? SYNOPSIS_DOC_MAX_CHARS : undefined,
}),
computeCorpusGeneration({ crMode: resolution.mode, haikuModel: args.haikuModel ?? DEFAULT_HAIKU_MODEL }),
);
return { kind: 'skipped', reason: 'no_chunks' };
}
@@ -317,7 +282,6 @@ export async function reembedPageWithContextualRetrieval(
const corpus_generation = computeCorpusGeneration({
crMode: attemptMode,
haikuModel,
synopsisDocMaxChars: attemptMode === 'per_chunk_synopsis' ? SYNOPSIS_DOC_MAX_CHARS : undefined,
});
// ── PHASE 2: single DB transaction ───────────────────────────
@@ -451,41 +415,82 @@ async function tryBuildPhase1(opts: {
}
// per_chunk_synopsis path. Read source text via fallback chain,
// generate synopsis per chunk through a bounded sliding pool, then
// generate synopsis per chunk sequentially within this page (D10),
// batch embed at the end (D27 P2-2).
const sourceText = readSourceTextWithFallback(page, chunks);
const wrappedTexts: string[] = new Array(chunks.length);
const chunkConcurrency = clampContextualChunkConcurrency(
args.chunkConcurrency ?? resolveContextualChunkConcurrency(),
);
const wrappedTexts: string[] = [];
const poolResult = await runSlidingPool({
items: chunks,
workers: chunkConcurrency,
signal: args.abortSignal,
onError: 'abort',
failureLabel: (c) => String(c.chunk_index),
onItem: async (c, i) => {
wrappedTexts[i] = await buildWrappedChunkText({
chunk: c,
sourceText,
safeTitle,
page,
args,
haikuModel,
});
},
});
for (let i = 0; i < chunks.length; i++) {
const c = chunks[i];
if (poolResult.failures.length > 0) {
const failure = [...poolResult.failures].sort((a, b) => a.idx - b.idx)[0].error;
if (failure instanceof ChunkSynopsisPhase1Error) {
return failure.result;
// Code chunks always bypass the wrapper (D20-T4) — pass through.
if (c.chunk_source === 'fenced_code') {
wrappedTexts.push(c.chunk_text);
continue;
}
throw failure;
}
if (poolResult.aborted || args.abortSignal?.aborted) {
return { kind: 'transient', cause: 'timeout', detail: 'aborted' };
// Acquire rate-lease per chunk (D26 P0-3). Inline callers pass no
// hooks; only the Minion handler wires through rate-leases.ts.
if (args.acquireSynopsisLease) {
await args.acquireSynopsisLease();
}
let synopsisResult: GeneratePerChunkSynopsisResult;
try {
synopsisResult = await generatePerChunkSynopsis({
documentText: sourceText,
chunkText: c.chunk_text,
pageTitle: page.title,
pageSlug: args.pageSlug,
sourceId: args.sourceId,
chunkIndex: c.chunk_index,
model: haikuModel,
abortSignal: args.abortSignal,
});
} finally {
if (args.releaseSynopsisLease) {
try {
await args.releaseSynopsisLease();
} catch {
// Lease release failure shouldn't abort the page; surfacing it
// would race with the synopsis result. Audit-only.
}
}
}
if (synopsisResult.kind === 'success') {
const prefix = buildContextualPrefix(safeTitle, synopsisResult.synopsis);
wrappedTexts.push(
wrapChunkForEmbedding(c.chunk_text, prefix, c.chunk_source),
);
continue;
}
// Failure classification per D27 P1-2:
// refusal | empty | malformed → page-level fall-back to title-only
// auth_failure → permanent (won't fix with retry)
// rate_limit | timeout | network | provider_5xx → transient
// source_missing → walked into fallback already; would be 'malformed'
// from generatePerChunkSynopsis if we ever propagated it here
if (
synopsisResult.kind === 'refusal' ||
synopsisResult.kind === 'empty' ||
synopsisResult.kind === 'malformed'
) {
return { kind: 'page_level_fallback_requested', cause: synopsisResult.kind };
}
if (synopsisResult.kind === 'auth_failure') {
return {
kind: 'permanent',
cause: synopsisResult.kind,
detail: synopsisResult.detail ?? 'auth failure',
};
}
return {
kind: 'transient',
cause: synopsisResult.kind,
detail: synopsisResult.detail ?? 'transient',
};
}
// All chunks synthesized successfully. Single batch embed (D27 P2-2).
@@ -506,113 +511,6 @@ async function tryBuildPhase1(opts: {
}
}
class ChunkSynopsisPhase1Error extends Error {
constructor(readonly result: Exclude<Phase1Result, Phase1Success>) {
super(`chunk synopsis failed: ${result.kind}`);
this.name = 'ChunkSynopsisPhase1Error';
}
}
async function buildWrappedChunkText(opts: {
chunk: ChunkInput;
sourceText: string;
safeTitle: string;
page: Page;
args: ReembedPageArgs;
haikuModel: string;
}): Promise<string> {
const { chunk: c, sourceText, safeTitle, page, args, haikuModel } = opts;
// Code chunks always bypass the wrapper (D20-T4) — pass through.
if (c.chunk_source === 'fenced_code') {
return c.chunk_text;
}
// Acquire rate-lease per chunk (D26 P0-3). Inline callers pass no
// hooks; only the Minion handler wires through rate-leases.ts.
let lease: unknown;
let leaseAcquired = false;
let synopsisResult: GeneratePerChunkSynopsisResult;
try {
if (args.acquireSynopsisLease) {
try {
lease = await args.acquireSynopsisLease();
} catch (err) {
if (args.abortSignal?.aborted || isAbortError(err)) {
throw new ChunkSynopsisPhase1Error({
kind: 'transient',
cause: 'timeout',
detail: 'aborted',
});
}
throw err;
}
leaseAcquired = true;
}
synopsisResult = await generatePerChunkSynopsis({
documentText: sourceText,
chunkText: c.chunk_text,
pageTitle: page.title,
pageSlug: args.pageSlug,
sourceId: args.sourceId,
chunkIndex: c.chunk_index,
model: haikuModel,
abortSignal: args.abortSignal,
});
} finally {
if (leaseAcquired && args.releaseSynopsisLease) {
try {
await args.releaseSynopsisLease(lease);
} catch {
// Lease release failure shouldn't abort the page; surfacing it
// would race with the synopsis result. Audit-only.
}
}
}
if (synopsisResult.kind === 'success') {
const prefix = buildContextualPrefix(safeTitle, synopsisResult.synopsis);
return wrapChunkForEmbedding(c.chunk_text, prefix, c.chunk_source);
}
// Failure classification per D27 P1-2:
// refusal | empty | malformed → page-level fall-back to title-only
// auth_failure → permanent (won't fix with retry)
// rate_limit | timeout | network | provider_5xx → transient
// source_missing → walked into fallback already; would be 'malformed'
// from generatePerChunkSynopsis if we ever propagated it here
if (
synopsisResult.kind === 'refusal' ||
synopsisResult.kind === 'empty' ||
synopsisResult.kind === 'malformed'
) {
throw new ChunkSynopsisPhase1Error({
kind: 'page_level_fallback_requested',
cause: synopsisResult.kind,
});
}
if (synopsisResult.kind === 'auth_failure') {
throw new ChunkSynopsisPhase1Error({
kind: 'permanent',
cause: synopsisResult.kind,
detail: synopsisResult.detail ?? 'auth failure',
});
}
throw new ChunkSynopsisPhase1Error({
kind: 'transient',
cause: synopsisResult.kind,
detail: synopsisResult.detail ?? 'transient',
});
}
function isAbortError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
(err as { name?: unknown }).name === 'AbortError'
);
}
/**
* Source-text fallback chain per D11:
* 1. read page.source_path from disk (truest "document")
@@ -17,11 +17,11 @@
* Cost: ~$0.05/night with default fixtures × Haiku polish. Bounded
* by the active BudgetTracker the autopilot loop creates per-tick.
*
* Wired into the autopilot loop (step 4.6 in autopilot.ts), following
* the same shape as `src/core/cycle/nightly-quality-probe.ts`
* (v0.40.1.0 Track D / T6): the wiring resolves fixtures from the
* gbrain package root, writes real outcomes to the parser-probe audit
* trail (`audit-parser-probe.ts`), and never crashes the loop.
* **Wiring into the autopilot loop is deferred to a follow-up**
* (filed in TODOS.md). v0.41.16.0 ships the phase as a callable
* module so doctor + future cron drivers can invoke it; the
* scheduler wire-up follows the same shape as
* `src/core/cycle/nightly-quality-probe.ts` (v0.40.1.0 Track D / T6).
*
* Test seam: all dependencies are injected via NightlyProbeDeps so
* unit tests don't touch real LLMs or real fixtures.
+1 -6
View File
@@ -44,12 +44,7 @@ export const DEFAULT_DIMENSIONS: string[] = [
* `--slot-a-model`, `--slot-b-model`, `--slot-c-model` on the CLI.
*/
export const DEFAULT_SLOTS: SlotConfig[] = [
// Every default MUST be listed in its recipe's chat touchpoint (pinned by
// test/cross-modal-default-slots.test.ts) — `openai:gpt-4o` sat here after
// the OpenAI recipe dropped it, so slot A errored "not listed for OpenAI
// chat" on every install and the 3-slot panel could never reach its
// 2-model quorum without a Google key (verdict: permanently inconclusive).
{ id: 'A', model: 'openai:gpt-5.2' },
{ id: 'A', model: 'openai:gpt-4o' },
{ id: 'B', model: 'anthropic:claude-opus-4-7' },
{ id: 'C', model: 'google:gemini-1.5-pro' },
];
+14 -116
View File
@@ -66,10 +66,6 @@ export type CyclePhase =
// - calibration_profile: aggregates the resolved subset into 2-4
// narrative pattern statements + active bias tags. Voice-gated.
| 'propose_takes' | 'grade_takes' | 'calibration_profile'
// #2653 — drift detection (default OFF; dream.drift.enabled). LLM-judges
// soft-band takes against recent timeline evidence; report-only in v1
// (writes reports/drift-<date>; auto_update mutates nothing).
| 'drift'
| 'embed' | 'orphans' | 'purge'
// v0.39 T12: schema-suggest passive trigger (D3 + D4 plan-eng-review).
// Wraps runSuggest() — same library the CLI verb + EIIRP call.
@@ -155,10 +151,6 @@ export const ALL_PHASES: CyclePhase[] = [
'propose_takes',
'grade_takes',
'calibration_profile',
// #2653 — drift detection. Default OFF (dream.drift.enabled). Runs AFTER
// the calibration trio (fresh take resolutions) and BEFORE embed so the
// drift report page gets embedded same-cycle. Report-only in v1.
'drift',
// v0.41.11.0 — opt-in conversation-facts backfill. Default OFF; reads
// cycle.conversation_facts_backfill.enabled gate inside the wrapper.
// Ordered AFTER calibration_profile (matches the runCycle dispatch
@@ -203,10 +195,8 @@ export const ALL_PHASES: CyclePhase[] = [
* - `source`: safe to parallelize per source. Sync reads/writes the
* one source's rows; extract walks changed slugs.
* - `global`: must serialize across the brain. Embed walks all stale
* chunks; purge sweeps brain-wide; orphans can report a single
* resolved source but still belongs in the serialized global lane;
* grade_takes + calibration aggregate across sources;
* resolve_symbol_edges walks every chunk.
* chunks; orphans/purge sweep brain-wide; grade_takes + calibration
* aggregate across sources; resolve_symbol_edges walks every chunk.
* - `mixed`: per-phase decomposition needed before parallelizing.
* Synthesize reads the brain-global transcripts dir but writes to
* per-source slugs (via subagent allowlist). Patterns reads
@@ -231,9 +221,6 @@ export const PHASE_SCOPE: Record<CyclePhase, PhaseScope> = {
propose_takes: 'source',
grade_takes: 'global',
calibration_profile: 'global',
// #2653 — drift walks takes brain-wide (same posture as grade_takes) and
// writes one brain-global report page.
drift: 'global',
embed: 'global',
orphans: 'global',
purge: 'global',
@@ -302,8 +289,6 @@ const NEEDS_LOCK_PHASES: ReadonlySet<CyclePhase> = new Set([
'propose_takes',
'grade_takes',
'calibration_profile',
// #2653 — writes the reports/drift-<date> page.
'drift',
// v0.41 T9 — extract_atoms writes atom-typed pages via put_page;
// synthesize_concepts writes concept-typed pages + tier updates. Both
// mutate DB state and need the lock.
@@ -468,12 +453,6 @@ export interface CycleOpts {
* loop bug (codex finding #3).
*/
synthBypassDreamGuard?: boolean;
/**
* Force the orphans phase to scan brain-wide even when `brainDir` resolves to
* a source. Used by autopilot global maintenance, whose phase set is
* intentionally brain-wide.
*/
forceGlobalOrphans?: boolean;
/**
* AbortSignal from the Minions worker (v0.22.1, #403). When aborted
* (timeout, cancel, lock-loss), runCycle bails between phases and
@@ -490,14 +469,12 @@ export interface CycleOpts {
* + every existing caller).
*
* **Note for follow-up waves:** this only scopes the LOCK. Several
* cycle phases (`embed`, `purge`, `resolve_symbol_edges`, `grade_takes`,
* `calibration_profile`) still operate brain-wide regardless of sourceId
* see the `PHASE_SCOPE` taxonomy. `orphans` uses the resolved source
* for its candidate set when one exists, but it remains in the serialized
* global lane for autopilot scheduling. Per-source cycle locks let two
* cycles RUN, but the global-scoped phases inside each will still touch
* the same rows. Genuine per-source fan-out requires the deferred TODOs
* in the plan.
* cycle phases (`embed`, `orphans`, `purge`, `resolve_symbol_edges`,
* `grade_takes`, `calibration_profile`) still operate brain-wide
* regardless of sourceId see the `PHASE_SCOPE` taxonomy. Per-source
* cycle locks let two cycles RUN, but the global-scoped phases
* inside each will still touch the same rows. Genuine per-source
* fan-out requires the deferred TODOs in the plan.
*
* Validated via `assertValidSourceId` in `cycleLockIdFor` (defense-in-depth).
*/
@@ -878,16 +855,8 @@ interface SyncPhaseResult extends PhaseResult {
* Resolve the source id for a brain directory by looking up the sources
* table. Returns undefined when no registered source matches (falls back
* to pre-v0.18 global config.sync.* keys).
*
* Exported for dream.ts (#1869): a `gbrain dream --dir <path>` run whose
* path matches a registered source's local_path is a per-source cycle in
* everything but name, so dream derives the source id up front and passes
* it as opts.sourceId landing the freshness stamp without changing
* runCycle's stamp/lock semantics for legacy global callers (the
* autopilot-global-maintenance handler runs GLOBAL_PHASES with a brainDir
* and MUST NOT stamp per-source freshness; see rejected PR #2549).
*/
export async function resolveSourceForDir(
async function resolveSourceForDir(
engine: BrainEngine,
brainDir: string | null,
): Promise<string | undefined> {
@@ -966,21 +935,13 @@ async function runPhaseSync(
// sync's inline extract still runs to preserve prior behavior.
});
const syncedCount = result.added + result.modified;
// #3068: a pull_failed partial means the internal git pull failed and the
// run imported nothing — the source may be silently behind its remote and
// will not self-heal. Surface it as 'warn' (not 'ok') so a scheduled cycle
// doesn't report a clean run over a wedged source. Timeout-class partials
// keep the pre-existing 'ok' mapping (they converge on retry by design).
const pullFailedPartial = result.status === 'partial' && result.reason === 'pull_failed';
return {
phase: 'sync',
status: result.status === 'blocked_by_failures' || pullFailedPartial ? 'warn' : 'ok',
status: result.status === 'blocked_by_failures' ? 'warn' : 'ok',
duration_ms: 0,
summary: dryRun
? `${syncedCount} page(s) would sync, ${result.deleted} would delete`
: pullFailedPartial
? `git pull failed, nothing imported — source may be behind its remote (sync anchor unchanged)`
: `+${result.added} added, ~${result.modified} modified, -${result.deleted} deleted`,
: `+${result.added} added, ~${result.modified} modified, -${result.deleted} deleted`,
details: {
added: result.added,
modified: result.modified,
@@ -989,7 +950,6 @@ async function runPhaseSync(
chunksCreated: result.chunksCreated,
failedFiles: result.failedFiles ?? 0,
syncStatus: result.status,
...(result.reason ? { syncReason: result.reason } : {}),
dryRun,
},
pagesAffected: result.pagesAffected,
@@ -1036,21 +996,6 @@ async function runPhaseExtract(
): Promise<PhaseResult> {
try {
const { runExtractCore } = await import('../commands/extract.ts');
const { loadConfig } = await import('./config.ts');
// Default off: the incremental cycle extracts body links only unless the
// operator opts in to keeping externally-edited frontmatter links fresh too.
// Both planes, file wins (env > file > DB precedence, per loadConfigWithEngine):
// `gbrain config set autopilot.incremental_extract_include_frontmatter true`
// writes the DB plane (engine.setConfig), so a file-plane-only read here
// would make the documented enable command a silent no-op (#2120 class).
const fileVal = loadConfig()?.autopilot?.incremental_extract_include_frontmatter;
let includeFrontmatter = fileVal === true;
if (fileVal === undefined) {
try {
includeFrontmatter =
(await engine.getConfig('autopilot.incremental_extract_include_frontmatter')) === 'true';
} catch { /* config table unreadable → default off */ }
}
// Extract is read-mostly against the filesystem + write to links table.
// Honor dryRun by skipping with a 'skipped' entry: extract doesn't have
// a clean dry-run mode today and runCycle should be honest about it.
@@ -1071,7 +1016,6 @@ async function runPhaseExtract(
slugs: changedSlugs, // undefined = full walk (first run / manual)
signal,
sourceId,
includeFrontmatter, // honored on the incremental (slugs) path only
});
const linksCreated = result?.links_created ?? 0;
const timelineCreated = result?.timeline_entries_created ?? 0;
@@ -1449,10 +1393,10 @@ async function runPhasePurge(engine: BrainEngine, dryRun: boolean): Promise<Phas
* to avoid a static import (purge phase is only loaded in the autopilot path). */
const SOFT_DELETE_TTL_HOURS_FOR_PURGE = 72;
async function runPhaseOrphans(engine: BrainEngine, sourceId?: string): Promise<PhaseResult> {
async function runPhaseOrphans(engine: BrainEngine): Promise<PhaseResult> {
try {
const { findOrphans } = await import('../commands/orphans.ts');
const result = await findOrphans(engine, sourceId !== undefined ? { sourceId } : {});
const result = await findOrphans(engine);
const count = result.total_orphans;
// Orphans are a code-smell signal, not a fatal condition. The
// original `count > 20` cutoff was tuned for small dev brains; on
@@ -1471,10 +1415,8 @@ async function runPhaseOrphans(engine: BrainEngine, sourceId?: string): Promise<
summary: `${count} orphan page(s) out of ${result.total_pages} total`,
details: {
total_orphans: count,
total_linkable: result.total_linkable,
total_pages: result.total_pages,
excluded: result.excluded,
...(sourceId !== undefined ? { source_id: sourceId } : {}),
},
};
} catch (e) {
@@ -1538,7 +1480,6 @@ export async function runCycle(
const cycleSourceId: string | undefined = engine
? (opts.sourceId ?? (await resolveSourceForDir(engine, brainDir)))
: opts.sourceId;
const orphansSourceId = opts.forceGlobalOrphans ? undefined : cycleSourceId;
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
@@ -2177,49 +2118,6 @@ export async function runCycle(
}
}
// ── #2653: drift detection ──────────────────────────────────
// Default OFF (dream.drift.enabled). LLM-judges soft-band takes
// against recent timeline evidence; report-only in v1 — writes one
// reports/drift-<date> page, mutates no takes regardless of
// dream.drift.auto_update.
if (phases.includes('drift')) {
checkAborted(opts.signal);
if (!engine) {
phaseResults.push({
phase: 'drift',
status: 'skipped',
duration_ms: 0,
summary: 'no database connected',
details: { reason: 'no_database' },
});
} else {
progress.start('cycle.drift');
const { runPhaseDrift } = await import('./cycle/drift.ts');
const { result, duration_ms } = await timePhase(async (): Promise<PhaseResult> => {
const r = await runPhaseDrift(engine, {
dryRun,
brainDir: brainDir ?? undefined,
forceEnabled: opts.onceForPhase === 'drift',
});
const status: PhaseStatus =
r.status === 'complete' ? 'ok' :
r.status === 'partial' ? 'warn' :
r.status === 'failed' ? 'fail' : 'skipped';
return {
phase: 'drift',
status,
duration_ms: 0,
summary: r.detail,
details: { ...(r.totals ?? {}) },
};
});
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
}
await safeYield(opts.yieldBetweenPhases);
}
// ── v0.41.11.0: conversation_facts_backfill ─────────────────
// Opt-in (default OFF). Walks long-form conversation/meeting/slack/
// email pages, segments by 30-min gap, runs facts extractor with a
@@ -2353,7 +2251,7 @@ export async function runCycle(
});
} else {
progress.start('cycle.orphans');
const { result, duration_ms } = await timePhase(() => runPhaseOrphans(engine, orphansSourceId));
const { result, duration_ms } = await timePhase(() => runPhaseOrphans(engine));
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
+2 -6
View File
@@ -26,7 +26,6 @@
*/
import { BaseCyclePhase, type ScopedReadOpts, type BasePhaseOpts } from './base-phase.ts';
import { resolveOwnerHolder } from '../owner-holder.ts';
import { chat as gatewayChat } from '../ai/gateway.ts';
import { TIER_DEFAULTS } from '../model-config.ts';
import { gateVoice, type VoiceGateGenerator, type VoiceGateJudge } from '../calibration/voice-gate.ts';
@@ -97,7 +96,7 @@ export type PatternStatementsGenerator = (input: {
export type BiasTagsGenerator = (patterns: string[]) => Promise<string[]>;
export interface CalibrationProfileOpts extends BasePhaseOpts {
/** Holder to generate the profile for. Default resolves via resolveOwnerHolder (config emotional_weight.user_holder, else 'self'). */
/** Holder to generate the profile for. Default 'garry'. */
holder?: string;
/** Inject the patterns generator (tests). */
patternsGenerator?: PatternStatementsGenerator;
@@ -228,10 +227,7 @@ class CalibrationProfilePhase extends BaseCyclePhase {
_ctx: OperationContext,
opts: CalibrationProfileOpts,
): Promise<{ summary: string; details: Record<string, unknown>; status?: PhaseStatus }> {
const holder = resolveOwnerHolder({
override: opts.holder,
configValue: await engine.getConfig('emotional_weight.user_holder'),
});
const holder = opts.holder ?? 'garry';
const promptVersion = opts.promptVersion ?? CALIBRATION_PROFILE_PROMPT_VERSION;
const modelId = opts.model ?? TIER_DEFAULTS.reasoning;
const gradeCompletion = opts.gradeCompletion ?? 1.0;
+35 -250
View File
@@ -1,25 +1,18 @@
/**
* Drift dream phase (#2653 wired v0.42.x; scaffold shipped v0.28).
* v0.28: drift dream phase.
*
* Detects takes whose underlying evidence has shifted since the take was
* made. Two stages:
* 1. Cheap SQL heuristic (findDriftCandidates): soft-band takes
* (weight 0.30.85, active, unresolved) on pages with fresh
* timeline_entries evidence inside the lookback window.
* 2. LLM judge: each candidate's claim is compared against the recent
* timeline evidence; the judge returns {drifted, confidence,
* reasoning, suggested_weight}. BudgetMeter-gated.
* Detects takes where the underlying evidence has shifted since the take
* was made. v0.28 ships the SCAFFOLD: the phase iterates active takes,
* runs a lightweight check against recent timeline entries on the same
* page, and writes a drift-report-<date>.md if any takes look stale.
*
* Output is REPORT-ONLY (v1 conservative posture): judged candidates land
* on a `reports/drift-<date>` page. `dream.drift.auto_update` mutates
* NOTHING in v1 it is recorded in the report so operators can see the
* flag state, and a future wave may wire weight adjustment behind it.
* The full LLM-driven drift detection (compare each take's claim to recent
* page evidence and propose a weight adjustment) is the v0.29 follow-up.
* v0.28 lays the phase orchestration so the contract is stable.
*
* Default-disabled. Operator opts in:
* gbrain config set dream.drift.enabled true
* gbrain config set dream.drift.lookback_days 30
* gbrain config set dream.drift.max_per_cycle 20
* gbrain config set dream.drift.budget 1.0
*/
import type { BrainEngine } from '../engine.ts';
@@ -32,10 +25,6 @@ export interface DriftPhaseOpts {
dryRun: boolean;
/** Override the audit ledger path (tests). */
auditPath?: string;
/** issue #2860 --once: bypass the dream.drift.enabled gate for this run only. */
forceEnabled?: boolean;
/** Inject the judge model call (tests). Defaults to gateway chat. */
judge?: DriftJudgeFn;
}
export interface DriftConfig {
@@ -43,7 +32,6 @@ export interface DriftConfig {
lookbackDays: number;
budgetUsd: number;
autoUpdate: boolean;
maxPerCycle: number;
}
async function loadDriftConfig(engine: BrainEngine): Promise<DriftConfig> {
@@ -51,19 +39,16 @@ async function loadDriftConfig(engine: BrainEngine): Promise<DriftConfig> {
const lookbackStr = await engine.getConfig('dream.drift.lookback_days');
const budgetStr = await engine.getConfig('dream.drift.budget');
const autoStr = await engine.getConfig('dream.drift.auto_update');
const maxPerStr = await engine.getConfig('dream.drift.max_per_cycle');
return {
enabled: enabledStr === 'true',
lookbackDays: lookbackStr ? Math.max(1, parseInt(lookbackStr, 10) || 30) : 30,
budgetUsd: budgetStr ? Math.max(0, parseFloat(budgetStr) || 1.0) : 1.0,
autoUpdate: autoStr === 'true',
maxPerCycle: maxPerStr ? Math.max(1, parseInt(maxPerStr, 10) || 20) : 20,
};
}
export interface DriftCandidate {
interface DriftCandidate {
takeId: number;
pageId: number;
pageSlug: string;
rowNum: number;
claim: string;
@@ -72,121 +57,24 @@ export interface DriftCandidate {
recentEvidenceCount: number;
}
/** Judge verdict: has the claim drifted relative to the evidence? */
export interface DriftVerdict {
drifted: boolean;
confidence: number;
reasoning: string;
/** Judge's suggested new weight (advisory only — v1 never applies it). */
suggested_weight?: number;
}
/** Judge function signature — injected for tests. */
export type DriftJudgeFn = (input: {
candidate: DriftCandidate;
evidence: string;
modelHint?: string;
}) => Promise<DriftVerdict>;
export const DRIFT_JUDGE_PROMPT = `You are auditing a knowledge-base "take" (a weighted claim) for drift:
has newer evidence shifted the ground under this claim since it was made?
Output ONLY one JSON object with these fields:
- drifted (boolean) true when the evidence meaningfully contradicts,
supersedes, or reframes the claim; false when it is
consistent or merely adjacent.
- confidence (number in [0,1]) your confidence in the drifted verdict.
- reasoning (string, <=300 chars) what in the evidence drove the verdict.
- suggested_weight (number in [0,1], optional) where the take's weight should
move if drifted. Advisory only.
If the evidence is sparse or unrelated to the claim, return drifted=false with
low confidence.
TAKE:
Claim: {CLAIM}
Weight: {WEIGHT}
Page: {PAGE}
RECENT EVIDENCE (timeline entries on the same page):
{EVIDENCE_BLOCK}
`;
/**
* Parse the judge model's JSON output. Tolerant of fence wrapping and
* leading prose; returns null on unrecoverable parse failure.
*/
export function parseDriftOutput(raw: string): DriftVerdict | null {
if (!raw || raw.trim().length === 0) return null;
let text = raw.trim();
const fenced = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
if (fenced) text = (fenced[1] ?? '').trim();
const firstObj = text.indexOf('{');
if (firstObj === -1) return null;
let parsed: unknown;
try {
parsed = JSON.parse(text.slice(firstObj));
} catch {
return null;
}
if (typeof parsed !== 'object' || parsed === null) return null;
const r = parsed as Record<string, unknown>;
if (typeof r.drifted !== 'boolean') return null;
const confRaw = typeof r.confidence === 'number' ? r.confidence : Number.parseFloat(String(r.confidence ?? ''));
if (!Number.isFinite(confRaw)) return null;
const verdict: DriftVerdict = {
drifted: r.drifted,
confidence: Math.max(0, Math.min(1, confRaw)),
reasoning: typeof r.reasoning === 'string' ? r.reasoning.slice(0, 300) : '',
};
const sw = typeof r.suggested_weight === 'number' ? r.suggested_weight : NaN;
if (Number.isFinite(sw)) verdict.suggested_weight = Math.max(0, Math.min(1, sw));
return verdict;
}
/** Production judge — calls gateway.chat with the DRIFT_JUDGE_PROMPT. */
export async function defaultDriftJudge(input: {
candidate: DriftCandidate;
evidence: string;
modelHint?: string;
}): Promise<DriftVerdict> {
const { chat } = await import('../ai/gateway.ts');
const prompt = DRIFT_JUDGE_PROMPT
.replace('{CLAIM}', input.candidate.claim)
.replace('{WEIGHT}', String(input.candidate.weight))
.replace('{PAGE}', input.candidate.pageSlug)
.replace('{EVIDENCE_BLOCK}', input.evidence);
const result = await chat({
messages: [{ role: 'user', content: prompt }],
...(input.modelHint ? { model: input.modelHint } : {}),
maxTokens: 400,
});
const parsed = parseDriftOutput(result.text);
if (!parsed) {
// Failed parse — conservative no-drift at zero confidence so the row
// still surfaces in the report instead of disappearing silently.
return { drifted: false, confidence: 0, reasoning: 'judge_output_parse_failed' };
}
return parsed;
}
/**
* Cheap pre-LLM heuristic: takes that have substantial recent timeline
* evidence on the same page MAY have drifted. Surface them; the LLM judge
* decides.
* evidence on the same page MAY have drifted. Surface them; the v0.29
* LLM judge will decide if the weight should move.
*/
async function findDriftCandidates(
engine: BrainEngine,
lookbackDays: number,
): Promise<DriftCandidate[]> {
const cutoffIso = lookbackCutoffIso(lookbackDays);
const cutoffMs = Date.now() - lookbackDays * 86_400_000;
const cutoffIso = new Date(cutoffMs).toISOString().slice(0, 10);
// Only consider takes with weight in the "soft" middle band (0.3..0.85)
// — facts (1.0) don't drift, very-low hunches (<0.3) aren't actionable yet.
const rows = await engine.executeRaw<{
take_id: number; page_id: number; page_slug: string; row_num: number;
take_id: number; page_slug: string; row_num: number;
claim: string; weight: number; recent_evidence: number;
}>(`
SELECT t.id AS take_id, p.id AS page_id, p.slug AS page_slug, t.row_num,
SELECT t.id AS take_id, p.slug AS page_slug, t.row_num,
t.claim, t.weight,
(SELECT count(*)::int FROM timeline_entries te
WHERE te.page_id = p.id
@@ -204,7 +92,6 @@ async function findDriftCandidates(
.filter(r => Number(r.recent_evidence) >= 1)
.map(r => ({
takeId: Number(r.take_id),
pageId: Number(r.page_id),
pageSlug: String(r.page_slug),
rowNum: Number(r.row_num),
claim: String(r.claim),
@@ -213,58 +100,6 @@ async function findDriftCandidates(
}));
}
function lookbackCutoffIso(lookbackDays: number): string {
return new Date(Date.now() - lookbackDays * 86_400_000).toISOString().slice(0, 10);
}
/** Format the candidate page's recent timeline entries as judge evidence. */
async function loadEvidence(
engine: BrainEngine,
pageId: number,
cutoffIso: string,
): Promise<string> {
const rows = await engine.executeRaw<{ date: string; source: string; summary: string }>(
`SELECT date::text AS date, source, summary FROM timeline_entries
WHERE page_id = $1 AND date >= $2::date
ORDER BY date DESC LIMIT 12`,
[pageId, cutoffIso],
);
if (rows.length === 0) return '(no timeline entries in window)';
return rows.map(r => `- ${r.date} [${r.source}] ${r.summary}`).join('\n');
}
interface JudgedCandidate {
candidate: DriftCandidate;
verdict: DriftVerdict;
}
function buildReportBody(
judged: JudgedCandidate[],
cfg: DriftConfig,
modelId: string,
): string {
const drifted = judged.filter(j => j.verdict.drifted);
const lines: string[] = [
`Drift check over active soft-band takes (weight 0.30.85) with timeline`,
`evidence from the last ${cfg.lookbackDays} day(s). Judge model: ${modelId}.`,
``,
`**Report-only:** no takes were modified. \`dream.drift.auto_update\` is`,
`${cfg.autoUpdate ? 'set but ignored in v1 (report-only posture)' : 'off'}; review and adjust weights manually.`,
``,
`${drifted.length} of ${judged.length} judged take(s) look drifted.`,
``,
];
for (const { candidate: c, verdict: v } of judged) {
lines.push(`## ${v.drifted ? 'DRIFTED' : 'stable'}${c.pageSlug} (take #${c.rowNum})`);
lines.push(`- Claim: ${c.claim}`);
lines.push(`- Weight: ${c.weight}${v.suggested_weight !== undefined ? ` → suggested ${v.suggested_weight}` : ''}`);
lines.push(`- Confidence: ${v.confidence.toFixed(2)}`);
if (v.reasoning) lines.push(`- Reasoning: ${v.reasoning}`);
lines.push('');
}
return lines.join('\n');
}
function skipped(_reason: string, detail: string): DreamPhaseResult {
return { name: 'drift', status: 'skipped', detail, duration_ms: 0 };
}
@@ -275,7 +110,7 @@ export async function runPhaseDrift(
): Promise<DreamPhaseResult> {
const start = Date.now();
const config = await loadDriftConfig(engine);
if (!config.enabled && !opts.forceEnabled) {
if (!config.enabled) {
return skipped('not_configured', 'dream.drift.enabled is false');
}
@@ -290,16 +125,9 @@ export async function runPhaseDrift(
};
}
if (opts.dryRun) {
return {
name: 'drift',
status: 'skipped',
detail: `dry-run: ${Math.min(candidates.length, config.maxPerCycle)} of ${candidates.length} candidates would be evaluated`,
totals: { candidates: candidates.length },
duration_ms: Date.now() - start,
};
}
// Resolve model for the (future v0.29) LLM judge. For v0.28 we just
// surface the candidates — the meter call is a no-op when we don't actually
// submit, but resolveModel sets the right pricing key when v0.29 ships.
const modelId = await resolveModel(engine, {
configKey: 'models.drift',
deprecatedConfigKey: 'dream.drift.model',
@@ -311,70 +139,27 @@ export async function runPhaseDrift(
phase: 'drift',
auditPath: opts.auditPath,
});
const judge = opts.judge ?? defaultDriftJudge;
const cutoffIso = lookbackCutoffIso(config.lookbackDays);
const judged: JudgedCandidate[] = [];
let budgetExhausted = false;
let failed = 0;
for (const candidate of candidates.slice(0, config.maxPerCycle)) {
const check = meter.check({
modelId,
estimatedInputTokens: 1500,
maxOutputTokens: 400,
label: `drift:${candidate.pageSlug}#${candidate.rowNum}`,
});
if (!check.allowed) {
budgetExhausted = true;
break;
}
const evidence = await loadEvidence(engine, candidate.pageId, cutoffIso);
try {
const verdict = await judge({ candidate, evidence, modelHint: modelId });
judged.push({ candidate, verdict });
} catch (e) {
failed += 1;
process.stderr.write(`[drift] judge failed on take ${candidate.takeId}: ${(e as Error).message}\n`);
}
// v0.28 scaffold: write a candidate report. v0.29 wires LLM-driven weight
// adjustment through autoUpdate. modelId + meter are wired now so the
// ledger captures the gate state even when we don't submit.
void modelId; void meter;
if (opts.dryRun) {
return {
name: 'drift',
status: 'skipped',
detail: `dry-run: ${candidates.length} candidates would be evaluated`,
totals: { candidates: candidates.length },
duration_ms: Date.now() - start,
};
}
const driftedCount = judged.filter(j => j.verdict.drifted).length;
let reportSlug: string | undefined;
if (judged.length > 0) {
const date = new Date().toISOString().slice(0, 10);
reportSlug = `reports/drift-${date}`;
// Report-only v1: the report page is the ONLY write this phase makes.
// Lands in the default source (brain-global artifact, same-day re-runs
// upsert the same slug).
await engine.putPage(reportSlug, {
type: 'report',
title: `Drift report ${date}`,
compiled_truth: buildReportBody(judged, config, modelId),
});
}
const detail =
`judged ${judged.length}/${candidates.length} candidates: ${driftedCount} drifted` +
(reportSlug ? `${reportSlug}` : '') +
(budgetExhausted ? ' (budget exhausted)' : '') +
(failed > 0 ? ` (${failed} judge failure(s))` : '') +
`. Cumulative cost: $${meter.totalSpent.toFixed(4)} / $${config.budgetUsd.toFixed(2)}` +
`. Report-only: auto_update=${config.autoUpdate} mutates nothing in v1.`;
return {
name: 'drift',
status: judged.length > 0
? (budgetExhausted || failed > 0 ? 'partial' : 'complete')
// Zero judged: budget capped before any judge ran → partial (capped,
// not broken); otherwise every judge call failed → failed.
: (budgetExhausted ? 'partial' : 'failed'),
detail,
totals: {
candidates: candidates.length,
judged: judged.length,
drifted: driftedCount,
failed,
},
status: 'complete',
detail: `surfaced ${candidates.length} drift candidates (LLM judge: v0.29 follow-up). autoUpdate=${config.autoUpdate}`,
totals: { candidates: candidates.length },
duration_ms: Date.now() - start,
};
}

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