mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* feat(search-lite): token budget + semantic query cache + intent weighting
Adds three additive features to the hybrid search pipeline. All
backward-compatible: existing callers see identical behavior unless they
opt in to the new options.
## 1. Token Budget Enforcement (src/core/search/token-budget.ts)
Cap the cumulative token cost of returned results so search payloads
fit downstream context windows. Greedy top-down walk; preserves caller
ordering; no re-rank. char/4 heuristic for token counting (no
tokenizer dependency \u2014 keeps the bun --compile bundle small).
SearchOpts.tokenBudget \u2014 numeric cap. Default undefined = no-op.
HybridSearchMeta.token_budget = { budget, used, kept, dropped }
HTTP query op: pass `token_budget` param.
## 2. Semantic Query Cache (src/core/search/query-cache.ts + migration v52)
Cache search results keyed by query embedding similarity. HNSW lookup:
`embedding <=> $1 < 0.08` (cosine similarity >= 0.92). Per-source
isolation so multi-source brains don\u2019t bleed. Per-row TTL (default 3600s).
Best-effort writes; all errors swallowed so the cache never breaks the
search hot path.
Migration v52 creates query_cache table with HALFVEC where pgvector >= 0.7;
falls back to VECTOR with the resolved config.embedding_dimensions dim.
New `gbrain cache` CLI: stats / clear --yes / prune.
Config keys: search.cache.enabled / similarity_threshold / ttl_seconds.
HybridSearchMeta.cache = { status, similarity?, age_seconds? }
Routed through new `hybridSearchCached(engine, query, opts)` wrapper;
the operations.ts query op now uses this wrapper so MCP/CLI calls
benefit automatically. Skipped for two-pass walks + non-default
embedding columns where cache semantics don\u2019t hold.
## 3. Zero-LLM Intent Weighting (src/core/search/intent-weights.ts)
Builds on the existing query-intent classifier (4 intents: entity /
temporal / event / general). New weight-adjustment layer applies subtle
per-intent nudges:
entity \u2192 boost keyword RRF + exact slug/title match
temporal \u2192 default recency=on when caller left it unset
event \u2192 boost keyword RRF (rare named entities) + soft recency
general \u2192 no-op (1.0 multipliers everywhere)
All adjustments are SUBTLE (max 1.25x). Caller-explicit options ALWAYS
win \u2014 intent weighting never silently overrides recency / salience.
Default ON; opt out via `opts.intentWeighting = false`. LLM query
expansion (expansion.ts) is still available and opt-in via
`opts.expansion = true` \u2014 it just isn\u2019t the default anymore.
HybridSearchMeta.intent now surfaces classifier output for debugging.
## Tests
test/token-budget.test.ts (10 tests, pure module)
test/intent-weights.test.ts (13 tests, pure module)
test/query-cache.test.ts (12 tests, PGLite)
test/hybrid-search-lite.serial.test.ts (9 tests, PGLite e2e)
Plus 105 pre-existing search tests still pass. `bun run verify` clean.
Co-authored-by: Wintermute <agents@garrytan.com>
* feat(search-mode): MODE_BUNDLES + resolveSearchMode wired into bare hybridSearch
Three named modes (conservative / balanced / tokenmax) that bundle the
search-lite knobs from PR #897 into a single config key. Mode resolution
lives in bare hybridSearch (NOT just the cached wrapper) so eval-replay
and eval-longmemeval — which call bare hybridSearch — test the same
mode-affected behavior as production. See [CDX-5+6] in the plan.
The mode bundle supplies DEFAULTS for intentWeighting, tokenBudget,
expansion, and searchLimit when the caller leaves those undefined.
Per-call SearchOpts and per-key config overrides still win (matches the
v0.31.12 model-tier resolution chain at model-config.ts:resolveModel).
knobsHash() exposes a stable SHA-256 of the resolved knob set; the cache
contamination hotfix (next commit) consumes it to prevent a tokenmax
write from being served to a conservative read.
Three new fields on HybridSearchMeta:
- mode (resolved mode name)
- existing token_budget meta now fires from bare hybridSearch too
Bare hybridSearch now applies tokenBudget at all three return paths
(no-embedding-provider, keyword-only-fallback, main). Previously only
hybridSearchCached enforced budget; eval commands missed it.
Tests: 37 unit cases pin the 3x7 bundle table cell-by-cell, the
resolution chain semantics, knobs hash determinism + cross-mode
separation, and the config-table parser. All 72 search-lite tests pass.
Bisect-friendly: this commit ONLY adds mode resolution. The cache-key
contamination hotfix [CDX-4] is a separate atomic commit (next).
* fix(query-cache): cross-mode contamination hotfix [CDX-4]
PR #897's query_cache keyed rows on sha256(source_id::query_text) only.
A tokenmax search (expansion=on, limit=50) populated a row that a
subsequent conservative call (no expansion, limit=10) read back, serving
the wrong-shape results. This is a real bug in PR #897 today, regardless
of the v0.32.3 mode picker work — Codex caught it in plan review.
Fix:
- Migration v56 adds query_cache.knobs_hash TEXT column + composite
(source_id, knobs_hash, created_at) index. Existing rows have NULL
knobs_hash and are excluded from lookups (silently re-populated with
the right hash on first hit — no orphan data, no destructive migration).
- cacheRowId(query, source, knobsHash) — knobsHash now part of the PK so
a tokenmax write and a conservative write for the same (query, source)
land in distinct rows.
- SemanticQueryCache.lookup({knobsHash}) filters WHERE knobs_hash = $.
- SemanticQueryCache.store({knobsHash}) writes the resolved hash.
- hybridSearchCached threads knobsHash from resolveSearchMode through
every cache call. Cache config (enabled/threshold/TTL) now reads from
the resolved mode bundle, not directly from the config table.
Tests (test/query-cache-knobs-hash.test.ts, 11 cases):
- cacheRowId bifurcates by knobsHash
- Tokenmax write does NOT contaminate conservative lookup
- Three modes coexist as distinct rows for same query
- Legacy NULL-knobs_hash rows are excluded from lookup
- Same-mode write updates in place (no duplicate rows)
All 58 cache + mode tests pass. Migration v56 applies cleanly on a fresh
PGLite brain.
Bisect-friendly: this commit is the cache-key hotfix alone. Mode
resolution wiring lives in the previous commit.
* feat(search-telemetry): in-process rollup writer + search_telemetry table
Migration v57 creates search_telemetry (date, mode, intent, count,
sum_results, sum_tokens, sum_budget_dropped, cache_hit, cache_miss,
first_seen, last_seen). PK (date, mode, intent) caps growth at ~4380
rows/year. Sums + counts only — averages derive at read time so
concurrent ON CONFLICT writes from multiple gbrain processes accumulate
correctly [CDX-17].
In-memory bucket flushed periodically (60s OR 100 calls) + on process
beforeExit/SIGINT/SIGTERM with a 2-second cap. The search hot path NEVER
waits on this write [D2, CDX-19].
Date-bucketed cache_hit / cache_miss columns make hit rate over --days N
derivable [CDX-18]. query_cache.hit_count is a lifetime counter and
can't be sliced by window.
Wired into bare hybridSearch via emitMeta: every search call sync-bumps
a bucket. flush() drains atomically by swapping the map before SQL writes
so a record() during flush lands in the new map.
readSearchStats(engine, {days}) returns the StatsWindow shape that
gbrain search stats consumes (next commit).
Tests: 16 unit cases pin record/flush/read semantics including
ON-CONFLICT-adds-raw-values, concurrent-flush coalescing, cache hit-rate
math, missing-table graceful degradation, and window clamping.
53 migrations apply on a fresh PGLite brain.
* feat(config): add unset + listConfigKeys + readLineSafe helper [CDX-7+8+9]
CDX-8: gbrain config has no unset path today. Required before
`gbrain search modes --reset` can clear search.* overrides.
- BrainEngine.unsetConfig(key) → returns rows deleted (0|1)
- BrainEngine.listConfigKeys(prefix) → exact-literal prefix match
with LIKE-escape on user-supplied % / _ / \ characters
- PGLiteEngine + PostgresEngine implementations
- `gbrain config unset <key>` and `gbrain config unset --pattern <prefix>`
sub-subcommands
CDX-9: readLine has no EOF detection or timeout. Mode-picker plan calls
out "TTY closes mid-prompt → defaults to balanced" but the raw helper
hangs forever. New readLineSafe(prompt, defaultValue, timeoutMs=60s):
- Returns defaultValue on stdin 'end' event
- Returns defaultValue on timeout
- Returns defaultValue on empty Enter
- Non-TTY stdin returns defaultValue immediately (e2e safe)
- Returns trimmed user input otherwise
Exported so install picker (next task) can use it.
Tests: 9 cases pin unset semantics + prefix matcher edge cases
(glob-wildcard escape, sort order, idempotent loop, search.* sweep).
All 53 migrations apply on a fresh PGLite brain.
* feat(init): install-time mode picker + upgrade banner
Install picker (src/commands/init-mode-picker.ts):
- Runs as a phase inside `gbrain init` AFTER engine.initSchema() so DB
config writes work [CDX-7].
- Idempotent: skipped on re-init if search.mode is already set.
- Smart auto-suggestion via recommendModeFor() reads
models.tier.subagent / models.default / OPENAI_API_KEY:
* Opus default/subagent → tokenmax (quality ceiling)
* Haiku subagent → conservative (4K budget keeps cost down)
* No OpenAI key → conservative (no LLM expansion possible)
* Sonnet / unknown → balanced (safe default)
- TTY shows menu via readLineSafe (60s timeout, defaults on EOF/empty).
- Non-TTY auto-selects + emits operator hint:
[gbrain] search mode: X (auto-selected — reason)
[gbrain] To change: gbrain config set search.mode <...>
- --json mode emits structured `{phase: 'search_mode_picker', ...}` event.
- Wired into both initPGLite and initPostgres flows.
Upgrade banner (src/commands/upgrade.ts):
- One-shot stderr banner in runPostUpgrade.
- State persisted via config key `search.mode_upgrade_notice_shown=true`
— fires at most once per install.
- Copy corrected per [CDX-1+2+3]: production query op STILL defaults
expand=true and limit=20. The banner reframes from "behavior is
regressing" to "named modes available + here's how to preserve
exact current shape."
Tests (test/init-mode-picker.test.ts, 16 cases):
- recommendModeFor heuristic for all 4 input shapes
- parseModeInput accepts numeric/named/case-insensitive, rejects garbage
- runModePicker non-TTY auto-selects + writes config
- Idempotent + --force re-prompt + JSON output
- Opus → tokenmax, Haiku → conservative real wiring through engine
* feat(cli): gbrain search modes/stats/tune command
Three sub-subcommands mirroring the gbrain models (v0.31.12) shape:
gbrain search modes [--json]
Read-only routing dashboard. Shows the three mode bundles, the active
mode, and the source of every resolved knob:
cache_enabled = true [override: search.cache.enabled]
tokenBudget = 4000 [mode: conservative]
Plus knob descriptions for legibility.
gbrain search modes --reset [--source <mode>]
Clears every search.* override (NOT search.mode itself). Preserves
the upgrade-notice state key. --source <mode> is a dry-run that
lists what --reset would change without writing — the paved path
[CDX-8] flagged as missing.
gbrain search stats [--days N] [--json]
Observability. Reads the search_telemetry rollup over the window
(clamps to [1, 365]). Prints cache hit rate, mode mix, intent mix,
budget drops, avg results/tokens. JSON output includes
_meta.metric_glossary block per [CDX-25].
gbrain search tune [--apply] [--json]
Recommendation engine. 5 rules cover the bug class:
- Insufficient data → "no_recommendations" status
- Conservative + high budget-drop rate → suggest balanced
- High cache hit rate (>85%) → suggest similarity threshold bump
- Tokenmax + Haiku subagent → suggest balanced (cost mismatch)
- Cache disabled but stats show usage → suggest re-enabling
--apply mutates config via setConfig / unsetConfig with a paste-ready
revert command printed at the end.
Registered in src/cli.ts dispatch table. 17 unit cases pin:
- Dashboard report shape + per-knob source attribution
- --reset preserves search.mode + notice key
- --source dry-run never writes
- stats reads telemetry rollup; --days clamps
- tune recommendation rules fire on real telemetry data
- --apply mutates config
- --help + unknown subcommand exit codes
* feat(eval): metric glossary module + auto-gen METRIC_GLOSSARY.md + CI guard
Single source of truth at src/core/eval/metric-glossary.ts. Every entry
carries 3 fields:
- industry_term (canonical IR/NLP literature name, preserved verbatim)
- eli10 (plain-English a 16-year-old can follow)
- range (numeric range + interpretation)
Covers 4 metric families:
- Retrieval: P@k, R@k, MRR, nDCG@k
- Stability: Jaccard@k, top-1 stability
- Statistical: p-value (paired bootstrap + Bonferroni), 95% CI
- Operational: cache hit rate, avg results/tokens, cost per query, p99 latency
Public surface:
- getMetricGloss(metric) → full entry or null
- eli10For(metric) → plain-English string or null
- buildMetricGlossaryMeta(metrics[]) → {metric → eli10} record for
JSON `_meta.metric_glossary` blocks per [CDX-25]. ONE block per
response, NOT sibling `_gloss` fields on every metric.
- renderMetricGlossaryMarkdown() → deterministic Markdown for the doc
Auto-generation:
scripts/generate-metric-glossary.ts emits docs/eval/METRIC_GLOSSARY.md.
Deterministic (same input → same bytes) so the CI guard can diff.
CI guard:
scripts/check-eval-glossary-fresh.sh regenerates into a temp file and
diffs against the committed doc. Out-of-date doc fails the build.
Wired into `bun run verify` (and therefore `bun run test:full`).
Tests (test/metric-glossary.test.ts, 18 cases):
- Every documented metric is present
- Every entry has all 3 required fields
- Accessors return null on unknown metrics (no throw)
- buildMetricGlossaryMeta silently drops unknown metrics
- renderer output is deterministic across calls
- Renderer groups metrics into 4 sections
docs/eval/METRIC_GLOSSARY.md: 5491 bytes, 124 lines, fresh.
* feat(doctor): search_mode + eval_drift checks + drift-watch module
src/core/eval/drift-watch.ts — curated retrieval watch-list [CDX-6].
Five patterns covering the surface that actually affects retrieval quality:
- src/core/search/ (search pipeline)
- src/core/embedding.ts (embedding shape)
- src/core/chunkers/ (chunk granularity)
- src/core/ai/recipes/anthropic.ts + openai.ts (expansion + embed routing)
- src/core/operations.ts (the query op definition)
Adding to the list is a deliberate act — requires a CHANGELOG line so
coverage grows on purpose, not by accident. Pure functions:
- matchesWatchPattern(path) — trailing-slash = prefix, bare = equality
- filesDriftedSince(repoRoot, sha?) — git diff --name-only wrapper
- watchedFilesDrifted(repoRoot, sha?) — composite
src/commands/doctor.ts — two new checks.
checkSearchMode [CDX-20]: status stays 'ok' (never warns, never docks
health score). Hint in message field. Three branches:
- unset → "search.mode is unset (using balanced fallback). Run
`gbrain search modes` to see what is running and pick a mode."
- mode + no overrides → "Mode: X (no per-key overrides — mode bundle
is canonical)."
- mode + overrides → "Mode: X with N per-key override(s) (k1, k2, …).
To consolidate to the pure mode bundle: gbrain search modes --reset"
Upgrade-notice state key (search.mode_upgrade_notice_shown) is excluded
from the override roster — it's not a knob.
checkEvalDrift [CDX-6]: surfaces uncommitted changes to retrieval-watched
files. Always 'ok'; operator-facing reminder. Names up to 3 drifted files
in the message + paste-ready re-eval command.
Both helpers exported (was: file-private) so tests can pin behavior
without walking the full runDoctor pipeline.
Tests: 12 drift-watch cases + 7 doctor-check cases. Pin watch-list shape,
prefix-vs-equality matcher semantics, missing-repo graceful failure, and
all three search_mode branches.
* feat(eval): --mode flag on longmemeval/replay + run-all + compare
Per-mode --mode flag plumbed into:
- gbrain eval longmemeval --mode <conservative|balanced|tokenmax>
Sets search.mode in the benchmark brain's config table; config is
in PRESERVE_TABLES so resetTables doesn't wipe it between questions.
Mode surfaces in the per-question NDJSON row.
- gbrain eval replay --mode <m> + --compare-limit N
--compare-limit forces a constant K across modes [CDX-13]; without
it, Jaccard@k against the captured baseline measures K-drift, not
quality. Mode is set once before the replay loop.
- NOT cross-modal per [CDX-11]: cross-modal scores OUTPUT against
TASK; it doesn't retrieve. Adding --mode there is theater.
New: gbrain eval run-all orchestrator (src/commands/eval-run-all.ts):
- Sweeps every requested mode × suite combination
- Sequential default per D9; --parallel N opt-in (clamped to mode count)
- Cost guard with split caps [CDX-15+16]:
--budget-usd-retrieval N (default $5)
--budget-usd-answer N (default $20)
Non-TTY refuses with exit 2 unless --yes AND explicit --budget-usd-*
flags pass. TTY refuses without --yes (defense against agent loops).
- estimateRunCost computes per-(suite,mode) breakdown including the
expansion-Haiku surcharge for tokenmax.
- Audit trail: appends to <repo>/.gbrain-evals/eval-results.jsonl
[CDX-23]. Personal brain (~/.gbrain) NEVER touched.
- v0.32.3 ships orchestrator + argv + guard + persist hook.
In-process per-suite invocation is a v0.32.4 follow-up (operator
runs the per-suite CLIs with the documented --mode flag for now;
each completion calls persistRunRecord to log).
New: gbrain eval compare report (src/commands/eval-compare.ts):
- Reads eval-results.jsonl, groups by (suite, mode), renders MD or JSON
- Most-recent (suite, mode, commit) wins when duplicates exist
- JSON output has schema_version=2 + _meta.metric_glossary block per
[CDX-25] (ONE block per response, not sibling _gloss fields)
- _meta.methodology field names the paired-bootstrap + Bonferroni
discipline per [CDX-14] so haters can reproduce
- Missing file → friendly hint pointing at `gbrain eval run-all`
Wired into eval dispatch table in src/commands/eval.ts.
Metric glossary fuzzy fallback: `recall@10` → `recall@k` lookup
(the glossary documents the family; report rows carry specific K
values). Routes through getMetricGloss for every call site.
Tests (42 cases total — all green):
- eval-run-all.test.ts (19): argv parser, cost estimate, guard
semantics for all 4 (over/under × tty/non-tty) shapes, persist hook
NDJSON shape.
- eval-compare.test.ts (5): JSON + MD output shapes, glossary
integration, missing-file graceful, mode filter, most-recent-wins.
- metric-glossary.test.ts (18): unchanged but updated assertions to
cover the fuzzy `@N` → `@k` fallback.
Pre-existing eval-replay / eval-longmemeval / eval-export / eval-prune
tests (42 cases) still pass — --mode + --compare-limit are additive.
* docs: methodology + CLAUDE.md/README/RESOLVER + skills/conventions
docs/eval/SEARCH_MODE_METHODOLOGY.md — haters-immune 8-section template.
Documents what the eval measures + does NOT measure, datasets + sizes
(LongMemEval n=500, Replay n=200, BrainBench n=1240 docs / 350 qrels),
random seed 42, run procedure verbatim, threats to validity (LongMemEval
English+technical skew, char/4 heuristic ~5-10% off, expansion ~97.6%
relative lift on this corpus), per-question raw outputs, pre-registered
expectations (tokenmax wins R@10 by 5-15pp, conservative wins cost by
5-15x, balanced lands within 3pp), re-run cadence anchored to the
src/core/eval/drift-watch.ts watch-list.
Statistical-significance section pins paired bootstrap with 10,000
resamples + Bonferroni correction across 3 modes × 4 metrics [CDX-14].
CLAUDE.md gets two new sections: ## Search Mode (3-mode table + resolution
chain + [CDX-4] cache contamination fix note + CLI commands) and ## Eval
discipline (single-source-of-truth glossary, methodology doc, eval_results
in repo NOT personal brain per [CDX-23]).
README.md Quick Start gets a paragraph naming the install picker, mode
heuristic, and the methodology link.
skills/conventions/search-modes.md NEW — convention file consumed by
brain-ops + query + signal-detector skills via the existing
`> **Convention:**` callout pattern. Routes "what mode" / "tune
retrieval" / "compare modes" queries to the right CLI surface.
skills/RESOLVER.md gets two new trigger rows pointing at
gbrain search * and gbrain eval compare.
* chore: regen llms.txt + llms-full.txt for v0.32.3 search-mode docs
bun run build:llms — picks up the new CLAUDE.md sections (Search Mode +
Eval discipline) and the docs/eval/SEARCH_MODE_METHODOLOGY.md addition.
build-llms.test.ts gate now passes.
* fix(doctor): wire search_mode + eval_drift checks into runDoctor main flow
The v0.32.3 search_mode + eval_drift helpers were inserted into the
DB-checks sub-helper at runDbChecks (line 345-355), but runDoctor itself
maintains its own check list and only calls the helpers' subset. Push
the two checks into the main runDoctor path (after the existing
sync_freshness check at line 2347) so they actually appear in
`gbrain doctor --json` output.
Both checks gated on engine !== null. Progress reporter heartbeat fires
for each. Both still return status 'ok' per [CDX-20] so health score is
preserved.
Verified end-to-end on a real Postgres brain: gbrain doctor --json now
includes 'search_mode' and 'eval_drift' in the checks array.
* fix: claw-test hang — DATABASE_URL leak + telemetry beforeExit deadlock
Two root causes for the hang, both fixed.
1. DATABASE_URL leak in claw-test scripted harness
The harness inherits the parent process's env via `...process.env`
for every phase child (init / import / query / extract / doctor).
When the e2e runner sets DATABASE_URL (for OTHER e2e tests), it
leaks into claw-test's children. `loadConfig` at src/core/config.ts:143
then flips inferredEngine to 'postgres' for every subsequent phase,
breaking the hermetic-PGLite-tempdir contract: phases race against
each other on a shared test Postgres while pointing at different
brain states.
Fix: strip DATABASE_URL + GBRAIN_DATABASE_URL from the child env
before forwarding. Re-apply GBRAIN_HOME / GBRAIN_FRICTION_RUN_ID
after the merge so a parent's override can't win. The harness is
PGLite-only by design.
2. Telemetry beforeExit deadlock
v0.32.3's recordSearchTelemetry installed a `process.on('beforeExit',
drainOnExit)` hook that wrapped the flush in `Promise.race([flush(),
setTimeout(2000)])`. beforeExit fires when the event loop empties,
but the hook enqueued NEW async work (the race's setTimeout +
pending flush), so the event loop never re-emptied. Short-lived
CLI invocations (`gbrain query "the"` finishing in ~100ms) ended
up waiting on the DB write indefinitely.
The claw-test harness spawns several short-lived gbrain queries.
Each one hung after its real work finished. The harness then waited
forever on its child subprocess's exit code.
Fix: drop the beforeExit + SIGINT + SIGTERM hooks. Per [CDX-19]'s
"stats are directional, not exact" contract, losing one unflushed
bucket on process exit is acceptable. The unref'd setInterval
handles long-running processes (HTTP MCP, autopilot, jobs work).
Short-lived CLI invocations exit immediately.
Verified:
- `gbrain query "the"` on a fresh PGLite brain exits in <1s (was
hanging forever).
- `bun test test/e2e/claw-test.test.ts` → 3 pass / 0 fail / 3.86s
(was hanging at the banner indefinitely).
- 85/85 e2e files / 574/574 tests pass including claw-test, with
DATABASE_URL set (the configuration that originally repro'd the
hang).
- 6235/6235 unit tests pass.
- Typecheck clean.
The two bugs interacted: the DATABASE_URL leak meant queries hit the
real Postgres (slow), making the beforeExit deadlock visible. Fixing
either alone would have masked the other. Both fixed in this commit.
* feat(install-picker): cost anchors in mode prompt + upgrade banner + docs
The install picker already asks explicitly (1/2/3 menu, default to the
recommendation on Enter). What was missing: a way to reason about the
cost tradeoff. Without numbers, "tokenmax" looks free and "conservative"
sounds restrictive; with numbers, the operator picks intentionally.
Cost anchors added everywhere the user encounters the mode choice:
- Install picker MENU_TEXT (gbrain init)
- Upgrade banner (gbrain upgrade post-upgrade)
- CLAUDE.md ## Search Mode section
- README.md Quick Start
- docs/eval/SEARCH_MODE_METHODOLOGY.md (with the math)
Anchors at Sonnet 4.6 downstream ($3/M input):
conservative ~$0.012/query ~$12/mo @ 1K ~$1,200/mo @ 100K
balanced ~$0.030/query ~$30/mo @ 1K ~$3,000/mo @ 100K
tokenmax ~$0.060/query ~$60/mo @ 1K ~$6,000/mo @ 100K
Plus tokenmax's Haiku expansion overhead: ~$1.50 per 1K queries on top.
Cache hits roughly halve these on a brain with repeat-query traffic.
The math is documented in SEARCH_MODE_METHODOLOGY.md so a reviewer can
audit each variable (T = ~400 tokens/chunk from the recursive chunker's
300-word target; N = `searchLimit` cap; R = downstream model rate from
src/core/anthropic-pricing.ts). Drift away from these numbers requires
updating CLAUDE.md + the picker + the methodology doc in lockstep — a
regression test pins the picker's anchor strings to enforce this.
The framing also names the cost rule honestly: the dominant cost isn't
gbrain (semantic cache is free; Haiku expansion is rounding-error). It's
the downstream agent reading retrieved chunks back into its context.
Operators who don't realize this pick badly.
Tests: 5 new regression cases in init-mode-picker.test.ts pin every
cost string in MENU_TEXT. Total 21/21 picker tests pass; 6240/6240
unit tests pass; verify gate green.
* docs: realistic-scale cost anchor for search modes
The per-query cost framing in the picker (~$0.012/$0.030/$0.060) is
honest but theoretical — it treats each search as an isolated billable
event. Real agent loops amortize a lot of context across turns via
Anthropic prompt caching, so the per-query 5x ratio doesn't translate
1:1 into total agent spend.
Added a "Realistic-scale anchor" section to SEARCH_MODE_METHODOLOGY.md
representing one heavy power-user agent loop running tokenmax:
- ~860 turns/mo (~29/day, one active agent)
- ~900K tokens/turn (system + tools + history + reasoning + search)
- ~$0.85/turn → ~$700/mo total agent spend at tokenmax
- ~88% Anthropic prompt-cache hit rate
Scaling balanced + conservative DOWN from that anchor:
- tokenmax → ~$700/mo, search ~22% of total spend
- balanced → ~$620/mo, search ~12% (saves ~$78/mo vs tokenmax)
- conservative → ~$575/mo, search ~5% (saves ~$124/mo vs tokenmax)
Honest takeaway: at realistic agent-loop scale WITH disciplined prompt
caching, mode choice saves 10-20% of total agent spend, not 5x. The
per-query math kicks back in for setups WITHOUT cache discipline (churn
the prompt prefix every turn → search payload becomes a larger fraction).
Both framings live in the doc.
CLAUDE.md ## Search Mode gets a forward-pointer paragraph naming the
"per-query math vs real-world spend" delta so agents reading the section
find the methodology footnote.
Numbers in the doc are anonymized + scaled away from any specific
deployment. No model names, no specific dollar figures from a real
production setup — just the per-turn / cache-hit-rate / search-count
shape ratios that a thoughtful operator can validate against their own
billing dashboard.
* feat(picker): mode × model cost matrix (25x corner-to-corner spread)
Previous version showed mode costs assuming Sonnet-only downstream.
That muted the spread to 5x and made mode choice look minor. Reality:
the downstream model tier is the BIGGER cost lever — pairing mode with
model is where the 25x spread lives.
New 3×3 matrix in the install picker, CLAUDE.md, methodology doc, README:
Haiku 4.5 Sonnet 4.6 Opus 4.7
($1/M input) ($3/M input) ($5/M input)
conservative $400/mo $1,200/mo $2,000/mo
balanced $1,000/mo $3,000/mo $5,000/mo
tokenmax $2,000/mo $6,000/mo $10,000/mo
(per-query cost @ 100K queries/mo, full search payload, no cache savings)
The methodology doc gets a new "Mode × Model matrix" section above the
realistic-scale anchor with concrete right-sizing guidance:
- tokenmax + Haiku: wrong direction. Haiku can't filter 50 chunks → noise
not signal. Pay Haiku rates, get sub-Haiku quality.
- conservative + Opus: wasted Opus. 200K context window starved on
retrieval depth. Pay Opus rates, get conservative-shape retrieval.
- Natural pairings span ~4x; the matrix corners span 25x. The natural
diagonal is where most users should land.
Realistic-scale anchor refreshed:
- tokenmax + Opus: ~$700/mo at 860 turns
- balanced + Sonnet: ~$430/mo
- conservative + Haiku: ~$170/mo
Plus a "mismatched pairings" section showing the math for tokenmax+Haiku
and conservative+Opus — both burn budget for no improvement.
Regression test updated: pins the 25x framing + the four anchor cells
(two corners + two diagonal mids) + the three downstream model rates.
22/22 picker tests pass. 6241/6241 unit tests pass. CI guards green.
* docs(picker): rescale cost matrix from 100K → 10K queries/mo (typical single user)
Most users running gbrain are single-user installs at ~10K queries/month,
not the 100K fleet-scale used in the original matrix. The picker numbers
($400 to $10,000/mo) looked alien to the actual audience. Rescaled to
10K with an explicit linear-scaling callout.
New matrix in picker, CLAUDE.md, README, methodology doc:
Haiku 4.5 Sonnet 4.6 Opus 4.7
($1/M) ($3/M) ($5/M)
conservative $40/mo $120/mo $200/mo
balanced $100/mo $300/mo $500/mo
tokenmax $200/mo $600/mo $1,000/mo
Still 25x corner-to-corner. Still 4x natural-diagonal spread. But now in
numbers a single user picks up and reasons about: "balanced + Sonnet at
$300/mo, that's fine" or "tokenmax + Opus at $1,000/mo, that's a
deliberate choice for max-quality high-stakes work."
Every surface updated:
- Install picker MENU_TEXT (with "scales linearly — multiply by 10
for 100K/mo" footnote so heavier users still see their number)
- CLAUDE.md ## Search Mode table + scaling prose
- README Quick Start
- methodology doc Mode × Model matrix section
- upgrade banner (post-upgrade notice)
Regression test updated: pins the 3 new anchor cells ($40, $300, $1,000)
+ the 10K/mo volume frame + the linear-scaling callout. 23/23 picker
tests pass, 6241/6241 unit tests pass, verify gate green.
Methodology doc's existing 1K/10K/100K Monthly cost breakdown tables
left intact (they already show the linear scaling explicitly).
* feat(picker): agent-facing install protocol + tokenmax default + [AGENT] directive
DX gap: an agent installing gbrain (OpenClaw, Hermes, Codex, Cursor) ran
gbrain init non-TTY, saw 2 stderr lines flash by, and silently auto-applied
a default search mode. The operator never saw the cost matrix or the choice.
At 25x corner-to-corner cost spread, that's surprise-spend territory.
Five surfaces fixed:
1. **Auto-suggest default flipped balanced → tokenmax.** The Sonnet/unknown
fallback now recommends tokenmax (preserves v0.31.x retrieval shape:
expand=on, generous result set). Haiku subagent → conservative still
wins (cost-sensitive signal). No-OpenAI-key → conservative still wins
(vector search not possible). Heuristic reordered: Haiku check now
fires BEFORE the Opus check, because a Haiku subagent loop signalling
cost sensitivity should win over a default-model heuristic.
2. **gbrain init non-TTY output rebuilt.** Previously: 2 stderr lines.
Now: the full 3×3 cost matrix + an explicit [AGENT] directive block
telling the agent to relay the matrix to its operator before
continuing. Includes a pointer to INSTALL_FOR_AGENTS.md Step 3.5 for
the full protocol.
3. **gbrain upgrade banner same treatment.** Existing v0.32.3 banner now
includes [AGENT] directive at the top so upgrading agents relay the
matrix to their operator instead of silently accepting v0.31.x →
v0.32.x default-applied behavior.
4. **INSTALL_FOR_AGENTS.md Step 3.5 NEW** with the matrix verbatim, the
exact paraphrasable ask-the-user wording, and the gbrain config set
commands to run after the operator picks. Plus a paragraph in the
Upgrade section pointing back at Step 3.5.
5. **AGENTS.md install checklist** gets a new Step 4 ("STOP — ask the
user about search mode") between init and the rest of the flow. The
agent's job description now explicitly says: silent acceptance is
the wrong default.
Tests (24/24 pass):
- Updated recommendModeFor heuristic order (Haiku floor > Opus default)
- New regression test: non-TTY output contains the matrix corners +
[AGENT] directive + INSTALL_FOR_AGENTS.md pointer
- withEnv() helper used for OPENAI_API_KEY mutation (test-isolation lint)
- Default-recommendation tests updated: Sonnet / unknown → tokenmax
Privacy + test-isolation gates clean. 6256/6256 unit tests pass.
---------
Co-authored-by: garrytan-agents <agents@garrytan.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
1555 lines
65 KiB
TypeScript
Executable File
1555 lines
65 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
|
|
import { installSigchldHandler } from './core/zombie-reap.ts';
|
|
installSigchldHandler();
|
|
|
|
import { readFileSync } from 'fs';
|
|
import { loadConfig, loadConfigWithEngine, toEngineConfig, isThinClient } from './core/config.ts';
|
|
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 type { Operation, OperationContext } from './core/operations.ts';
|
|
import { serializeMarkdown } from './core/markdown.ts';
|
|
import { parseGlobalFlags, setCliOptions, getCliOptions } from './core/cli-options.ts';
|
|
import type { CliOptions } from './core/cli-options.ts';
|
|
import { callRemoteTool, RemoteMcpError, unpackToolResult } from './core/mcp-client.ts';
|
|
import { maybePromptForUpgrade } from './core/thin-client-upgrade-prompt.ts';
|
|
import { VERSION } from './version.ts';
|
|
|
|
// Build CLI name -> operation lookup
|
|
const cliOps = new Map<string, Operation>();
|
|
for (const op of operations) {
|
|
const name = op.cliHints?.name;
|
|
if (name && !op.cliHints?.hidden) {
|
|
cliOps.set(name, op);
|
|
}
|
|
}
|
|
|
|
// CLI-only commands that bypass the operation layer
|
|
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', '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-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'cache']);
|
|
// 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.
|
|
const CLI_ONLY_SELF_HELP = new Set([
|
|
'upgrade', 'post-upgrade', 'check-update',
|
|
'embed', 'config',
|
|
'skillpack', 'skillpack-check',
|
|
'integrations', 'friction',
|
|
'frontmatter', 'check-resolvable',
|
|
'models',
|
|
'cache',
|
|
]);
|
|
|
|
async function main() {
|
|
// Parse global flags (--quiet / --progress-json / --progress-interval)
|
|
// BEFORE command dispatch, so `gbrain --progress-json doctor` works.
|
|
// The stripped argv is what the command sees.
|
|
const rawArgs = process.argv.slice(2);
|
|
const { cliOpts, rest: args } = parseGlobalFlags(rawArgs);
|
|
setCliOptions(cliOpts);
|
|
|
|
let command = args[0];
|
|
|
|
if (!command || command === '--help' || command === '-h') {
|
|
printHelp();
|
|
return;
|
|
}
|
|
|
|
if (command === '--version' || command === 'version') {
|
|
console.log(`gbrain ${VERSION}`);
|
|
return;
|
|
}
|
|
|
|
if (command === '--tools-json') {
|
|
const { printToolsJson } = await import('./commands/tools-json.ts');
|
|
printToolsJson();
|
|
return;
|
|
}
|
|
|
|
const subArgs = args.slice(1);
|
|
|
|
// DX alias: `ask` is a natural-language alias for `query`
|
|
if (command === 'ask') {
|
|
command = 'query';
|
|
}
|
|
|
|
// Per-command --help
|
|
if (hasHelpFlag(subArgs)) {
|
|
const op = cliOps.get(command);
|
|
if (op) {
|
|
printOpHelp(op);
|
|
return;
|
|
}
|
|
if (CLI_ONLY.has(command) && !CLI_ONLY_SELF_HELP.has(command)) {
|
|
printCliOnlyHelp(command);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// CLI-only commands
|
|
if (CLI_ONLY.has(command)) {
|
|
await handleCliOnly(command, subArgs);
|
|
return;
|
|
}
|
|
|
|
// Shared operations
|
|
const op = cliOps.get(command);
|
|
if (!op) {
|
|
console.error(`Unknown command: ${command}`);
|
|
console.error('Run gbrain --help for available commands.');
|
|
process.exit(1);
|
|
}
|
|
|
|
// v0.31.1 (Issue #734, CDX-1): parse CLI args BEFORE engine connect so
|
|
// the routing seam below can decide local-vs-remote without paying a
|
|
// PGLite migration replay on thin-client installs. The arg parser, image
|
|
// transform, and required-param check are all engine-free; refactoring
|
|
// them out of the engine try/catch is safe and unlocks routing.
|
|
const params = parseOpArgs(op, subArgs);
|
|
|
|
// v0.27.1 (`gbrain query --image <path>`): swap the `image` param from
|
|
// a filesystem path into base64 bytes + mime. The op accepts base64; the
|
|
// CLI accepts a path. Helper is exported so tests can exercise the
|
|
// transform without spawning a subprocess.
|
|
if (op.name === 'query' && typeof params.image === 'string' && params.image.length > 0) {
|
|
try {
|
|
const { path, base64, mime } = resolveQueryImage(
|
|
params.image as string,
|
|
(params.image_mime as string) || undefined,
|
|
);
|
|
params.image = base64;
|
|
params.image_mime = mime;
|
|
void path;
|
|
} catch (err) {
|
|
console.error(err instanceof Error ? err.message : String(err));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Validate required params before calling handler. v0.27.1: the
|
|
// `query` op's positional `query` is required only when --image is
|
|
// NOT supplied. The runtime altRequired check below overrides the
|
|
// generic required-flag check for that op.
|
|
const queryHasAlt = op.name === 'query' && typeof params.image === 'string' && params.image.length > 0;
|
|
for (const [key, def] of Object.entries(op.params)) {
|
|
if (def.required && params[key] === undefined) {
|
|
if (queryHasAlt && key === 'query') continue;
|
|
const cliName = op.cliHints?.name || op.name;
|
|
const positional = op.cliHints?.positional || [];
|
|
const usage = positional.map(p => `<${p}>`).join(' ');
|
|
console.error(`Usage: gbrain ${cliName} ${usage}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// v0.31.1 (Issue #734, CDX-1 routing seam): on thin-client installs,
|
|
// route every non-localOnly op through callRemoteTool instead of opening
|
|
// the empty local PGLite. localOnly ops can't run on a thin client at all
|
|
// (no local engine, server intentionally hides them) — refuse with hint.
|
|
// Fix for the silent-empty-results bug class that motivated this whole release.
|
|
const cfgPre = loadConfig();
|
|
if (isThinClient(cfgPre)) {
|
|
if (op.localOnly) {
|
|
refuseThinClient(command, cfgPre!.remote_mcp!.mcp_url);
|
|
}
|
|
await runThinClientRouted(op, params, cfgPre!, cliOpts);
|
|
return;
|
|
}
|
|
|
|
// Local engine path (unchanged behavior for local installs).
|
|
const engine = await connectEngine();
|
|
try {
|
|
const ctx = await makeContext(engine, params);
|
|
const rawResult = await op.handler(ctx, params);
|
|
// ENG-2 (renderer parity by data shape): JSON-round-trip the local-engine
|
|
// path's return value so renderers see the same shape they'd see on the
|
|
// routed path. Date → ISO string; bigint → string (postgres.js shape);
|
|
// Buffer → object. Microsecond-cost; eliminates a whole drift bug class.
|
|
const result = JSON.parse(JSON.stringify(rawResult));
|
|
const output = formatResult(op.name, result);
|
|
if (output) process.stdout.write(output);
|
|
} catch (e: unknown) {
|
|
if (e instanceof OperationError) {
|
|
console.error(`Error [${e.code}]: ${e.message}`);
|
|
if (e.suggestion) console.error(` Fix: ${e.suggestion}`);
|
|
process.exit(1);
|
|
}
|
|
console.error(e instanceof Error ? e.message : String(e));
|
|
process.exit(1);
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
}
|
|
|
|
function hasHelpFlag(args: string[]): boolean {
|
|
return args.includes('--help') || args.includes('-h');
|
|
}
|
|
|
|
function printCliOnlyHelp(command: string) {
|
|
console.log(`Usage: gbrain ${command}`);
|
|
console.log('');
|
|
console.log(`gbrain ${command} - run gbrain --help for the full command list.`);
|
|
}
|
|
|
|
/**
|
|
* v0.31.1 (Issue #734, CDX-1): route a shared op through the remote MCP
|
|
* server instead of running it locally. Called from main() when
|
|
* `isThinClient(cfg) && !op.localOnly`.
|
|
*
|
|
* Timeout policy (ENG-4): user override via --timeout=Ns wins; otherwise
|
|
* 180s for `think` (LLM calls), 30s for everything else.
|
|
*
|
|
* Error policy (CDX-4): callRemoteTool's hardening pass guarantees every
|
|
* thrown value reaches us as a RemoteMcpError. The switch below is
|
|
* exhaustively typed (TS `never` check); adding a new reason variant fails
|
|
* compilation until this dispatcher knows what to render.
|
|
*
|
|
* Renderer policy: the MCP tool result is unpacked via unpackToolResult
|
|
* (which JSON.parses the text content) and handed to the SAME formatResult
|
|
* the local-engine path uses. Renderer parity is enforced by data shape,
|
|
* not by per-command audit.
|
|
*/
|
|
async function runThinClientRouted(
|
|
op: Operation,
|
|
params: Record<string, unknown>,
|
|
cfg: GBrainConfig,
|
|
cliOpts: CliOptions,
|
|
): Promise<void> {
|
|
// ENG-4: per-op timeout default; user override wins.
|
|
const defaultTimeoutMs = op.name === 'think' ? 180_000 : 30_000;
|
|
const timeoutMs = cliOpts.timeoutMs ?? defaultTimeoutMs;
|
|
|
|
// SIGINT support: aborts in-flight HTTP cleanly (exit 130 is the standard
|
|
// SIGINT exit code; our error switch maps `network/aborted` to that).
|
|
const sigintController = new AbortController();
|
|
const onSigint = () => {
|
|
sigintController.abort(new Error('SIGINT'));
|
|
};
|
|
process.on('SIGINT', onSigint);
|
|
|
|
// v0.31.1 (Issue #734, cherry-pick B): print identity banner to stderr
|
|
// BEFORE the routed call. Banner failure suppresses the banner only —
|
|
// never the underlying command. Suppression honors --quiet, non-TTY,
|
|
// and GBRAIN_NO_BANNER=1.
|
|
await printIdentityBannerBestEffort(cfg, cliOpts, sigintController.signal);
|
|
|
|
try {
|
|
const raw = await callRemoteTool(cfg, op.name, params, {
|
|
timeoutMs,
|
|
signal: sigintController.signal,
|
|
});
|
|
const result = unpackToolResult(raw);
|
|
const output = formatResult(op.name, result);
|
|
if (output) process.stdout.write(output);
|
|
} catch (e: unknown) {
|
|
if (e instanceof RemoteMcpError) {
|
|
const url = cfg.remote_mcp!.mcp_url;
|
|
switch (e.reason) {
|
|
case 'config':
|
|
console.error(e.message);
|
|
break;
|
|
case 'discovery':
|
|
console.error(`OAuth discovery failed at ${cfg.remote_mcp!.issuer_url}.`);
|
|
console.error('Run `gbrain remote doctor` for details.');
|
|
break;
|
|
case 'auth':
|
|
console.error('OAuth auth failed.');
|
|
console.error('On the host, re-register your client:');
|
|
console.error(' gbrain auth register-client <name> --grant-types client_credentials --scopes read,write,admin');
|
|
break;
|
|
case 'auth_after_refresh':
|
|
console.error('OAuth auth failed after token refresh. Credentials may have been revoked.');
|
|
console.error('Run `gbrain remote doctor` to confirm.');
|
|
break;
|
|
case 'network':
|
|
if (e.detail?.kind === 'timeout') {
|
|
const hint = cliOpts.timeoutMs ? '' : ` (default ${defaultTimeoutMs}ms; pass --timeout=Ns to override)`;
|
|
console.error(`Request to ${url} timed out${hint}.`);
|
|
} else if (e.detail?.kind === 'aborted') {
|
|
console.error('Request aborted.');
|
|
process.off('SIGINT', onSigint);
|
|
process.exit(130);
|
|
} else {
|
|
console.error(`Cannot reach ${url}. Run \`gbrain remote doctor\` for details.`);
|
|
}
|
|
break;
|
|
case 'tool_error':
|
|
if (e.detail?.code === 'missing_scope') {
|
|
console.error('Missing OAuth scope on this client.');
|
|
console.error('On the host, re-register the client with broader scopes:');
|
|
console.error(' gbrain auth register-client <name> --grant-types client_credentials --scopes read,write,admin');
|
|
} else {
|
|
console.error(e.message);
|
|
console.error('Run `gbrain remote doctor` if this persists.');
|
|
}
|
|
break;
|
|
case 'parse':
|
|
console.error('Server response was malformed. Run `gbrain remote doctor`.');
|
|
break;
|
|
default: {
|
|
// Exhaustive switch sentinel (TS `never` — fails to build if a
|
|
// new RemoteMcpErrorReason variant is added without a case).
|
|
const _exhaustive: never = e.reason;
|
|
void _exhaustive;
|
|
console.error(`Unhandled remote error: ${e.message}`);
|
|
}
|
|
}
|
|
process.off('SIGINT', onSigint);
|
|
process.exit(1);
|
|
}
|
|
// Defense in depth: callRemoteTool's contract is that everything is
|
|
// RemoteMcpError. If a plain Error escapes, render it generically and
|
|
// exit 1 — but this should never happen post-CDX-4.
|
|
console.error(e instanceof Error ? e.message : String(e));
|
|
process.off('SIGINT', onSigint);
|
|
process.exit(1);
|
|
} finally {
|
|
process.off('SIGINT', onSigint);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// v0.31.1 (Issue #734, cherry-pick B): thin-client identity banner.
|
|
//
|
|
// Prints "[thin-client → <host> · brain: 102k pages, 265k chunks · vX.Y.Z]"
|
|
// to stderr before each routed command, so users (and agents) know they're
|
|
// talking to a real remote brain — not the empty local PGLite that motivated
|
|
// this whole release.
|
|
//
|
|
// Cache: 60s TTL, in-memory Map keyed by mcp_url. Cross-process file cache
|
|
// is deferred (marginal benefit; one mint per CLI process is fine).
|
|
// Suppression: --quiet, non-TTY, GBRAIN_NO_BANNER=1.
|
|
// Failure mode: any error in fetching identity → suppress banner; underlying
|
|
// command runs normally. Banner is observability, not load-bearing.
|
|
// ============================================================================
|
|
|
|
export interface BrainIdentity {
|
|
version: string;
|
|
engine: 'postgres' | 'pglite';
|
|
page_count: number;
|
|
chunk_count: number;
|
|
last_sync_iso: string | null;
|
|
}
|
|
|
|
interface CachedIdentity {
|
|
identity: BrainIdentity;
|
|
cached_at_ms: number;
|
|
}
|
|
|
|
const IDENTITY_TTL_MS = 60_000;
|
|
const identityCache = new Map<string, CachedIdentity>();
|
|
|
|
/** Test-only escape hatch — clears the in-memory cache between test runs. */
|
|
export function _clearIdentityCacheForTest(): void {
|
|
identityCache.clear();
|
|
}
|
|
|
|
export function bannerSuppressed(cliOpts: CliOptions): boolean {
|
|
if (cliOpts.quiet) return true;
|
|
if (process.env.GBRAIN_NO_BANNER === '1') return true;
|
|
// Non-TTY default is suppressed (clean pipes); explicit env-flag overrides.
|
|
if (!process.stderr.isTTY && process.env.GBRAIN_BANNER !== '1') return true;
|
|
return false;
|
|
}
|
|
|
|
function formatPageCount(n: number): string {
|
|
if (n >= 1000) {
|
|
const k = (n / 1000).toFixed(n >= 100_000 ? 0 : 1);
|
|
return `${k}k`;
|
|
}
|
|
return String(n);
|
|
}
|
|
|
|
function formatBanner(mcpUrl: string, id: BrainIdentity): string {
|
|
const host = mcpUrl.replace(/^https?:\/\//, '').split('/')[0];
|
|
const counts = `brain: ${formatPageCount(id.page_count)} pages, ${formatPageCount(id.chunk_count)} chunks`;
|
|
return `[thin-client → ${host} · ${counts} · v${id.version}]`;
|
|
}
|
|
|
|
async function fetchIdentity(
|
|
cfg: GBrainConfig,
|
|
signal: AbortSignal,
|
|
): Promise<BrainIdentity> {
|
|
// 2s timeout for the banner fetch — must not delay the underlying command.
|
|
const raw = await callRemoteTool(cfg, 'get_brain_identity', {}, {
|
|
timeoutMs: 2000,
|
|
signal,
|
|
});
|
|
const id = unpackToolResult<BrainIdentity>(raw);
|
|
return id;
|
|
}
|
|
|
|
async function printIdentityBannerBestEffort(
|
|
cfg: GBrainConfig,
|
|
cliOpts: CliOptions,
|
|
signal: AbortSignal,
|
|
): Promise<void> {
|
|
if (bannerSuppressed(cliOpts)) return;
|
|
const mcpUrl = cfg.remote_mcp?.mcp_url;
|
|
if (!mcpUrl) return;
|
|
|
|
// Cache lookup keyed by mcp_url so switching hosts via `gbrain init`
|
|
// invalidates cleanly even within a long-lived process.
|
|
const cached = identityCache.get(mcpUrl);
|
|
if (cached && Date.now() - cached.cached_at_ms < IDENTITY_TTL_MS) {
|
|
process.stderr.write(formatBanner(mcpUrl, cached.identity) + '\n');
|
|
// v0.31.11: detect remote-version drift, prompt user to upgrade.
|
|
// bannerIsSuppressed=false here — the early return above guaranteed it.
|
|
await maybePromptForUpgrade(cfg, cached.identity, cliOpts, false);
|
|
return;
|
|
}
|
|
|
|
// Cache miss — fetch. Failure is non-fatal: banner is observability,
|
|
// never load-bearing for the underlying command.
|
|
try {
|
|
const id = await fetchIdentity(cfg, signal);
|
|
identityCache.set(mcpUrl, { identity: id, cached_at_ms: Date.now() });
|
|
process.stderr.write(formatBanner(mcpUrl, id) + '\n');
|
|
// v0.31.11: detect remote-version drift, prompt user to upgrade.
|
|
await maybePromptForUpgrade(cfg, id, cliOpts, false);
|
|
} catch {
|
|
// Swallow. Banner suppressed; main command continues. The CDX-4
|
|
// hardened callRemoteTool will surface the same error class on the
|
|
// actual command call if the host is genuinely unreachable.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* v0.27.1: shared transform for `gbrain query --image <path>` (and any future
|
|
* CLI surface that takes an image path). Reads the file, base64-encodes,
|
|
* derives MIME from the extension, enforces the 20MB cap. Exported so tests
|
|
* can verify the transform without spawning a subprocess.
|
|
*
|
|
* Throws Error on any failure (file missing, oversized, etc.). Caller is
|
|
* responsible for routing to process.exit(1) with a user-facing message.
|
|
*/
|
|
export function resolveQueryImage(
|
|
imagePath: string,
|
|
explicitMime?: string,
|
|
): { path: string; base64: string; mime: string } {
|
|
const bytes = readFileSync(imagePath);
|
|
if (bytes.length > 20 * 1024 * 1024) {
|
|
throw new Error(`Error: image too large (${bytes.length} bytes, max 20MB).`);
|
|
}
|
|
const base64 = bytes.toString('base64');
|
|
let mime = explicitMime;
|
|
if (!mime) {
|
|
const lower = imagePath.toLowerCase();
|
|
const mimeFromExt: Record<string, string> = {
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.webp': 'image/webp',
|
|
'.heic': 'image/heic', '.heif': 'image/heif',
|
|
'.avif': 'image/avif',
|
|
};
|
|
const ext = Object.keys(mimeFromExt).find(e => lower.endsWith(e));
|
|
mime = ext ? mimeFromExt[ext] : 'image/jpeg';
|
|
}
|
|
return { path: imagePath, base64, mime };
|
|
}
|
|
|
|
function parseOpArgs(op: Operation, args: string[]): Record<string, unknown> {
|
|
const params: Record<string, unknown> = {};
|
|
const positional = op.cliHints?.positional || [];
|
|
let posIdx = 0;
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (arg.startsWith('--')) {
|
|
const key = arg.slice(2).replace(/-/g, '_');
|
|
const paramDef = op.params[key];
|
|
if (paramDef?.type === 'boolean') {
|
|
params[key] = true;
|
|
} else if (i + 1 < args.length) {
|
|
params[key] = args[++i];
|
|
if (paramDef?.type === 'number') params[key] = Number(params[key]);
|
|
}
|
|
} else if (posIdx < positional.length) {
|
|
const key = positional[posIdx++];
|
|
const paramDef = op.params[key];
|
|
params[key] = paramDef?.type === 'number' ? Number(arg) : arg;
|
|
}
|
|
}
|
|
|
|
// Read stdin for content params
|
|
if (op.cliHints?.stdin && !params[op.cliHints.stdin] && !process.stdin.isTTY) {
|
|
const stdinContent = readFileSync('/dev/stdin', 'utf-8');
|
|
const MAX_STDIN = 5_000_000; // 5MB
|
|
if (Buffer.byteLength(stdinContent, 'utf-8') > MAX_STDIN) {
|
|
console.error(`Error: stdin content exceeds ${MAX_STDIN} bytes. Split into smaller inputs.`);
|
|
process.exit(1);
|
|
}
|
|
params[op.cliHints.stdin] = stdinContent;
|
|
}
|
|
|
|
return params;
|
|
}
|
|
|
|
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;
|
|
try {
|
|
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;
|
|
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
|
|
// to the cross-source view (D16 back-compat path).
|
|
sourceId = undefined;
|
|
}
|
|
return {
|
|
engine,
|
|
config: loadConfig() || { engine: 'postgres' },
|
|
logger: { info: console.log, warn: console.warn, error: console.error },
|
|
dryRun: (params.dry_run as boolean) || false,
|
|
// Local CLI invocation — the user owns the machine; do not apply remote-caller
|
|
// confinement (e.g., cwd-locked file_upload).
|
|
remote: false,
|
|
cliOpts: getCliOptions(),
|
|
...(sourceId ? { sourceId } : {}),
|
|
};
|
|
}
|
|
|
|
function formatResult(opName: string, result: unknown): string {
|
|
switch (opName) {
|
|
case 'get_page': {
|
|
const r = result as any;
|
|
if (r.error === 'ambiguous_slug') {
|
|
return `Ambiguous slug. Did you mean:\n${r.candidates.map((c: string) => ` ${c}`).join('\n')}\n`;
|
|
}
|
|
return serializeMarkdown(r.frontmatter || {}, r.compiled_truth || '', r.timeline || '', {
|
|
type: r.type, title: r.title, tags: r.tags || [],
|
|
});
|
|
}
|
|
case 'list_pages': {
|
|
const pages = result as any[];
|
|
if (pages.length === 0) return 'No pages found.\n';
|
|
return pages.map(p =>
|
|
`${p.slug}\t${p.type}\t${p.updated_at?.toString().slice(0, 10) || '?'}\t${p.title}`,
|
|
).join('\n') + '\n';
|
|
}
|
|
case 'search':
|
|
case 'query': {
|
|
const results = result as any[];
|
|
if (results.length === 0) return 'No results.\n';
|
|
return results.map(r =>
|
|
`[${r.score?.toFixed(4) || '?'}] ${r.slug} -- ${r.chunk_text?.slice(0, 100) || ''}${r.stale ? ' (stale)' : ''}`,
|
|
).join('\n') + '\n';
|
|
}
|
|
case 'get_tags': {
|
|
const tags = result as string[];
|
|
return tags.length > 0 ? tags.join(', ') + '\n' : 'No tags.\n';
|
|
}
|
|
case 'get_stats': {
|
|
const s = result as any;
|
|
const lines = [
|
|
`Pages: ${s.page_count}`,
|
|
`Chunks: ${s.chunk_count}`,
|
|
`Embedded: ${s.embedded_count}`,
|
|
`Links: ${s.link_count}`,
|
|
`Tags: ${s.tag_count}`,
|
|
`Timeline: ${s.timeline_entry_count}`,
|
|
];
|
|
if (s.pages_by_type) {
|
|
lines.push('', 'By type:');
|
|
for (const [k, v] of Object.entries(s.pages_by_type)) {
|
|
lines.push(` ${k}: ${v}`);
|
|
}
|
|
}
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
case 'get_health': {
|
|
const h = result as any;
|
|
// Health score weights: missing_embeddings is the heaviest (2 pts), other
|
|
// graph quality issues are 1 pt each. link_coverage / timeline_coverage below
|
|
// 50% on entity pages indicates the graph needs population.
|
|
const score = Math.max(0, 10
|
|
- (h.missing_embeddings > 0 ? 2 : 0)
|
|
- (h.stale_pages > 0 ? 1 : 0)
|
|
- (h.orphan_pages > 0 ? 1 : 0)
|
|
- ((h.link_coverage ?? 1) < 0.5 ? 1 : 0)
|
|
- ((h.timeline_coverage ?? 1) < 0.5 ? 1 : 0));
|
|
const lines = [
|
|
`Health score: ${score}/10`,
|
|
`Embed coverage: ${(h.embed_coverage * 100).toFixed(1)}%`,
|
|
`Missing embeddings: ${h.missing_embeddings}`,
|
|
`Stale pages: ${h.stale_pages}`,
|
|
`Orphan pages: ${h.orphan_pages}`,
|
|
];
|
|
if (h.link_coverage !== undefined) {
|
|
lines.push(`Link coverage (entities): ${(h.link_coverage * 100).toFixed(1)}%`);
|
|
}
|
|
if (h.timeline_coverage !== undefined) {
|
|
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:');
|
|
for (const e of h.most_connected) {
|
|
lines.push(` ${e.slug}: ${e.link_count} links`);
|
|
}
|
|
}
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
case 'get_timeline': {
|
|
const entries = result as any[];
|
|
if (entries.length === 0) return 'No timeline entries.\n';
|
|
return entries.map(e =>
|
|
`${e.date} ${e.summary}${e.source ? ` [${e.source}]` : ''}`,
|
|
).join('\n') + '\n';
|
|
}
|
|
case 'get_versions': {
|
|
const versions = result as any[];
|
|
if (versions.length === 0) return 'No versions.\n';
|
|
return versions.map(v =>
|
|
`#${v.id} ${v.snapshot_at?.toString().slice(0, 19) || '?'} ${v.compiled_truth?.slice(0, 60) || ''}...`,
|
|
).join('\n') + '\n';
|
|
}
|
|
default:
|
|
return JSON.stringify(result, null, 2) + '\n';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Multi-topology v1: thin-client refusal set. These commands require a local
|
|
* engine; if `~/.gbrain/config.json` has `remote_mcp` set, the dispatch guard
|
|
* refuses them with a canonical error pointing at the remote host. The check
|
|
* runs before per-command dispatch so the error message is consistent.
|
|
*
|
|
* `serve` is in this set because `gbrain serve` (stdio or http) requires a
|
|
* local engine to expose. Thin clients don't have one to expose.
|
|
*
|
|
* `doctor` is intentionally NOT in this set — task 4 routes it to
|
|
* `runRemoteDoctor` for thin-client installs.
|
|
*/
|
|
const THIN_CLIENT_REFUSED_COMMANDS = new Set([
|
|
'sync', 'embed', 'extract', 'migrate', 'apply-migrations',
|
|
'repair-jsonb', 'orphans', 'integrity', 'serve',
|
|
// v0.31.1 (CDX-2 op coverage matrix): more local-only commands
|
|
'dream', 'transcripts', 'storage',
|
|
// v0.31.1 CDX-2 audit: takes/sources have multiple subcommands; some
|
|
// (takes_list/takes_search, sources_list/sources_status) have MCP
|
|
// equivalents and others are file-system bound (takes mutate commands
|
|
// edit local .md files). v0.31.1 refuses both at the top level with a
|
|
// hint pointing at the routable MCP tools; per-subcommand splits are
|
|
// a v0.31.x follow-up TODO.
|
|
'takes', 'sources',
|
|
// v0.32 thin-client routing audit (Codex round 2 findings #2, #4):
|
|
// - `pages` purge-deleted is admin+localOnly (operations.ts:856-864)
|
|
// - `files` list / file_url MCP ops are localOnly (operations.ts:1769-1879)
|
|
// - `eval` export/prune/replay have no MCP equivalents
|
|
// - `code-def`/`code-refs`/`code-callers`/`code-callees` have NO MCP ops
|
|
// in operations.ts:2630-2671; cannot be "fixed by routing" yet
|
|
'pages', 'files', 'eval', 'code-def', 'code-refs', 'code-callers', 'code-callees',
|
|
]);
|
|
|
|
/**
|
|
* v0.31.1 (Issue #734, CDX-5 + cherry-pick A): pinpoint refusal hints for
|
|
* local-only commands when running on a thin-client install. Each hint names
|
|
* the closest path (remote MCP call, host-side workflow) so users aren't
|
|
* stuck guessing what to do next.
|
|
*
|
|
* Source-of-truth lives here so adding a new local-only command means
|
|
* adding both the THIN_CLIENT_REFUSED_COMMANDS member AND the hint in one
|
|
* place during code review.
|
|
*/
|
|
const THIN_CLIENT_REFUSE_HINTS: Record<string, string> = {
|
|
sync: 'sync runs on the host. Trigger a remote cycle with `gbrain remote ping` (queues an autopilot-cycle job).',
|
|
embed: 'embed runs on the host as part of the autopilot cycle. `gbrain remote ping` triggers a full cycle including embed.',
|
|
extract: 'extract runs on the host. Use `gbrain remote ping` to trigger a cycle including extract.',
|
|
migrate: "migrate runs on the host's local engine. Run on the host machine.",
|
|
'apply-migrations': 'schema migrations run on the host. SSH and run there.',
|
|
'repair-jsonb': 'repair-jsonb operates on the local DB only.',
|
|
integrity: 'integrity scans local files. Run on the host machine.',
|
|
serve: 'serve starts a server. Run on the host, not the thin client.',
|
|
dream: 'dream runs the autopilot cycle on the host. `gbrain remote ping` queues one. (Native `gbrain dream` thin-client routing planned for v0.31.2.)',
|
|
orphans: "orphans needs the host's brain. Run on the host or use the `find_orphans` MCP tool from your agent.",
|
|
transcripts: 'transcripts is server-private (raw chat exports stay on the host). Read transcripts on the host machine.',
|
|
storage: 'storage operates on the local repo on disk. Run on the host.',
|
|
takes: 'takes mutate subcommands edit local .md files; routing the read subcommands lands in v0.31.x. For now: use `takes_list` and `takes_search` MCP tools from your agent, or run on the host.',
|
|
sources: 'sources commands manage local DB + config rows. Per-subcommand thin-client routing lands in v0.31.x. For now: use `sources_list` / `sources_status` MCP tools, or run on the host.',
|
|
// v0.32 audit additions
|
|
pages: '`pages purge-deleted` is admin+localOnly (hard-deletes from the local DB). Run on the host.',
|
|
files: '`files list` and `files url` MCP ops are localOnly (paths live on the host filesystem). Use `gbrain files` on the host machine.',
|
|
eval: '`eval` export/prune/replay touch the local engine and have no MCP equivalents. Run `gbrain eval` on the host.',
|
|
'code-def': '`code-def` needs symbol-aware lookup that has no MCP op yet. Run on the host or use `search` from your agent with a symbol-shaped query.',
|
|
'code-refs': '`code-refs` has no MCP op yet. Run on the host.',
|
|
'code-callers': '`code-callers` has no MCP op yet. Run on the host.',
|
|
'code-callees': '`code-callees` has no MCP op yet. Run on the host.',
|
|
};
|
|
|
|
/**
|
|
* v0.31.1: emit a pinpoint refusal hint for a thin-client-incompatible
|
|
* command and exit 1. Falls back to the canonical generic message when no
|
|
* specific hint is registered (defensive — every member of
|
|
* THIN_CLIENT_REFUSED_COMMANDS should have a hint).
|
|
*/
|
|
function refuseThinClient(command: string, mcpUrl: string): never {
|
|
const hint = THIN_CLIENT_REFUSE_HINTS[command];
|
|
if (hint) {
|
|
console.error(`\`gbrain ${command}\` is not routable. ${hint}`);
|
|
console.error(`(thin-client of ${mcpUrl})`);
|
|
} else {
|
|
console.error(
|
|
`\`gbrain ${command}\` requires a local engine. This install is a thin client of ${mcpUrl}.\n` +
|
|
`Run \`${command}\` on the remote host, or use the corresponding MCP tool from your agent.`,
|
|
);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
async function handleCliOnly(command: string, args: string[]) {
|
|
// Thin-client guard: refuse DB-bound commands cleanly with a pinpoint
|
|
// hint instead of letting them fail later inside connectEngine or
|
|
// mid-handler. v0.31.1 routes through `refuseThinClient` so every
|
|
// refusal carries an actionable next-step hint (CDX-5 cherry-pick A).
|
|
if (THIN_CLIENT_REFUSED_COMMANDS.has(command)) {
|
|
const cfg = loadConfig();
|
|
if (isThinClient(cfg)) {
|
|
refuseThinClient(command, cfg!.remote_mcp!.mcp_url);
|
|
}
|
|
}
|
|
|
|
// Commands that don't need a database connection
|
|
if (command === 'init') {
|
|
const { runInit } = await import('./commands/init.ts');
|
|
await runInit(args);
|
|
return;
|
|
}
|
|
if (command === 'auth') {
|
|
const { runAuth } = await import('./commands/auth.ts');
|
|
await runAuth(args);
|
|
return;
|
|
}
|
|
if (command === 'remote') {
|
|
// Multi-topology v1 (Tier B): thin-client-only convenience commands.
|
|
// `runRemote` self-checks for remote_mcp config and exits 1 if local-only.
|
|
const { runRemote } = await import('./commands/remote.ts');
|
|
await runRemote(args);
|
|
return;
|
|
}
|
|
if (command === 'upgrade') {
|
|
const { runUpgrade } = await import('./commands/upgrade.ts');
|
|
await runUpgrade(args);
|
|
return;
|
|
}
|
|
if (command === 'post-upgrade') {
|
|
const { runPostUpgrade } = await import('./commands/upgrade.ts');
|
|
await runPostUpgrade(args);
|
|
return;
|
|
}
|
|
if (command === 'check-update') {
|
|
const { runCheckUpdate } = await import('./commands/check-update.ts');
|
|
await runCheckUpdate(args);
|
|
return;
|
|
}
|
|
if (command === 'integrations') {
|
|
const { runIntegrations } = await import('./commands/integrations.ts');
|
|
await runIntegrations(args);
|
|
return;
|
|
}
|
|
if (command === 'providers') {
|
|
const { runProviders } = await import('./commands/providers.ts');
|
|
const [sub, ...rest] = args;
|
|
await runProviders(sub, rest);
|
|
return;
|
|
}
|
|
if (command === 'auth') {
|
|
const { runAuth } = await import('./commands/auth.ts');
|
|
await runAuth(args);
|
|
return;
|
|
}
|
|
if (command === 'resolvers') {
|
|
const { runResolvers } = await import('./commands/resolvers.ts');
|
|
await runResolvers(args);
|
|
return;
|
|
}
|
|
if (command === 'integrity') {
|
|
const { runIntegrity } = await import('./commands/integrity.ts');
|
|
await runIntegrity(args);
|
|
return;
|
|
}
|
|
if (command === 'publish') {
|
|
const { runPublish } = await import('./commands/publish.ts');
|
|
await runPublish(args);
|
|
return;
|
|
}
|
|
if (command === 'check-backlinks') {
|
|
const { runBacklinks } = await import('./commands/backlinks.ts');
|
|
await runBacklinks(args);
|
|
return;
|
|
}
|
|
if (command === 'frontmatter') {
|
|
const { runFrontmatter } = await import('./commands/frontmatter.ts');
|
|
await runFrontmatter(args);
|
|
return;
|
|
}
|
|
if (command === 'lint') {
|
|
const { runLint } = await import('./commands/lint.ts');
|
|
await runLint(args);
|
|
return;
|
|
}
|
|
if (command === 'check-resolvable') {
|
|
const { runCheckResolvable } = await import('./commands/check-resolvable.ts');
|
|
await runCheckResolvable(args);
|
|
return;
|
|
}
|
|
if (command === 'mounts') {
|
|
// No DB needed: mounts.json is a local config file. Registry will
|
|
// connect mount engines lazily on first use by op dispatch.
|
|
const { runMounts } = await import('./commands/mounts.ts');
|
|
await runMounts(args);
|
|
return;
|
|
}
|
|
if (command === 'cache') {
|
|
// v0.32.x search-lite: semantic query cache management. Dispatch the
|
|
// subcommand handler (stats / clear / prune); the handler opens its
|
|
// own engine connection.
|
|
const { runCache } = await import('./commands/cache.ts');
|
|
await runCache(args);
|
|
return;
|
|
}
|
|
if (command === 'routing-eval') {
|
|
const { runRoutingEvalCli } = await import('./commands/routing-eval.ts');
|
|
await runRoutingEvalCli(args);
|
|
return;
|
|
}
|
|
if (command === 'skillify') {
|
|
const { runSkillify } = await import('./commands/skillify.ts');
|
|
// `args` here is subArgs (command already stripped by caller), so
|
|
// args[0] is the subcommand (scaffold|check).
|
|
await runSkillify(args);
|
|
return;
|
|
}
|
|
if (command === 'skillpack') {
|
|
const { runSkillpack } = await import('./commands/skillpack.ts');
|
|
// subArgs already has `skillpack` stripped; args[0] is the subcommand.
|
|
await runSkillpack(args);
|
|
return;
|
|
}
|
|
if (command === 'friction') {
|
|
const { runFriction } = await import('./commands/friction.ts');
|
|
process.exit(runFriction(args));
|
|
}
|
|
if (command === 'claw-test') {
|
|
const { runClawTest } = await import('./commands/claw-test.ts');
|
|
process.exit(await runClawTest(args));
|
|
}
|
|
if (command === 'report') {
|
|
const { runReport } = await import('./commands/report.ts');
|
|
await runReport(args);
|
|
return;
|
|
}
|
|
if (command === 'apply-migrations') {
|
|
// Does not need connectEngine — each phase (schema, smoke, host-rewrite)
|
|
// manages its own subprocess or file-layer access directly. Avoids
|
|
// connecting a second time when the orchestrator shells out to
|
|
// `gbrain init --migrate-only` and `gbrain jobs smoke`.
|
|
const { runApplyMigrations } = await import('./commands/apply-migrations.ts');
|
|
await runApplyMigrations(args);
|
|
return;
|
|
}
|
|
if (command === 'repair-jsonb') {
|
|
const { runRepairJsonbCli } = await import('./commands/repair-jsonb.ts');
|
|
await runRepairJsonbCli(args);
|
|
return;
|
|
}
|
|
if (command === 'skillpack-check') {
|
|
// Agent-readable health report. Shells out to doctor + apply-migrations
|
|
// internally; does not need its own DB connection.
|
|
const { runSkillpackCheck } = await import('./commands/skillpack-check.ts');
|
|
await runSkillpackCheck(args);
|
|
return;
|
|
}
|
|
if (command === 'doctor') {
|
|
// Multi-topology v1: thin-client doctor. When `~/.gbrain/config.json`
|
|
// has remote_mcp set, every DB-bound check is irrelevant. Route to the
|
|
// outbound-HTTP probe set in `src/core/doctor-remote.ts` and return
|
|
// before any local-engine work.
|
|
const cfgForDoctor = loadConfig();
|
|
if (isThinClient(cfgForDoctor)) {
|
|
const { runRemoteDoctor } = await import('./core/doctor-remote.ts');
|
|
await runRemoteDoctor(cfgForDoctor!, args);
|
|
return;
|
|
}
|
|
|
|
// Doctor runs filesystem checks first (no DB needed), then DB checks.
|
|
// --fast skips DB checks entirely.
|
|
const { runDoctor } = await import('./commands/doctor.ts');
|
|
const { getDbUrlSource } = await import('./core/config.ts');
|
|
if (args.includes('--fast')) {
|
|
// Pass the DB URL source so doctor can tell "no config at all" from
|
|
// "user chose --fast while config is present".
|
|
await runDoctor(null, args, getDbUrlSource());
|
|
} else {
|
|
try {
|
|
const eng = await connectEngine();
|
|
await runDoctor(eng, args);
|
|
await eng.disconnect();
|
|
} catch {
|
|
// DB unavailable — still run filesystem checks
|
|
await runDoctor(null, args, getDbUrlSource());
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (command === 'smoke-test') {
|
|
// Run smoke tests — no DB connection needed, the script handles its own checks
|
|
const { execSync } = await import('child_process');
|
|
const { resolve, dirname } = await import('path');
|
|
const { fileURLToPath } = await import('url');
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
const scriptPath = resolve(scriptDir, '..', 'scripts', 'smoke-test.sh');
|
|
try {
|
|
execSync(`bash "${scriptPath}"`, { stdio: 'inherit', env: { ...process.env } });
|
|
} catch (e: any) {
|
|
// Non-zero exit = some tests failed (exit code = failure count)
|
|
process.exit(e.status ?? 1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (command === 'dream') {
|
|
// Dream mirrors doctor's pattern: filesystem phases run without a DB,
|
|
// so an engine connection failure is non-fatal. runCycle honestly
|
|
// reports DB phases as skipped when engine is null.
|
|
const { runDream } = await import('./commands/dream.ts');
|
|
let eng: BrainEngine | null = null;
|
|
try {
|
|
eng = await connectEngine();
|
|
} catch {
|
|
// DB unavailable — lint + backlinks still run against the brain dir.
|
|
}
|
|
try {
|
|
await runDream(eng, args);
|
|
} finally {
|
|
if (eng) await eng.disconnect();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// `eval cross-modal` is a pure API-call command — no DB, no brain. Bypass
|
|
// connectEngine entirely so first-run users (no `gbrain init` yet) can
|
|
// run the quality gate. Mirrors the dream/doctor no-DB pattern but
|
|
// doesn't even attempt the connect (T3=A in plans/radiant-napping-lerdorf.md).
|
|
// The handler self-configures the AI gateway from loadConfig() + process.env.
|
|
if (command === 'eval' && args[0] === 'cross-modal') {
|
|
const { runEvalCrossModal } = await import('./commands/eval-cross-modal.ts');
|
|
process.exit(await runEvalCrossModal(args.slice(1)));
|
|
}
|
|
|
|
// v0.32 EXP-5 (codex review #10): `eval takes-quality replay <receipt>`
|
|
// is the ONLY sub-subcommand that doesn't need a brain — it reads a
|
|
// receipt JSON file from disk and re-renders it. Bypass connectEngine
|
|
// here so users can replay a receipt on a machine without DATABASE_URL.
|
|
// run/trend/regress need the brain and fall through to the regular
|
|
// engine-required path below.
|
|
if (command === 'eval' && args[0] === 'takes-quality' && args[1] === 'replay') {
|
|
const { runReplayNoBrain } = await import('./commands/eval-takes-quality.ts');
|
|
process.exit(await runReplayNoBrain(args.slice(2)));
|
|
}
|
|
|
|
// v0.28.8: longmemeval brings its own in-memory PGLite. Bypassing
|
|
// connectEngine here keeps `gbrain eval longmemeval --help` and benchmark
|
|
// runs working on machines that have no `~/.gbrain/config.json` configured.
|
|
if (command === 'eval' && args[0] === 'longmemeval') {
|
|
const { runEvalLongMemEval } = await import('./commands/eval-longmemeval.ts');
|
|
await runEvalLongMemEval(args.slice(1));
|
|
return;
|
|
}
|
|
|
|
// v0.33.1.3: `gbrain eval whoknows` on thin-client installs bypasses
|
|
// connectEngine entirely — the eval routes per-query through the remote
|
|
// `find_experts` MCP op (the v0.31.1 routing seam). Local mode falls
|
|
// through to the engine-connected path below.
|
|
if (command === 'eval' && args[0] === 'whoknows') {
|
|
const cfgPre = loadConfig();
|
|
if (isThinClient(cfgPre)) {
|
|
const { runEvalWhoknows } = await import('./commands/eval-whoknows.ts');
|
|
process.exit(await runEvalWhoknows(null, args.slice(1)));
|
|
}
|
|
}
|
|
|
|
// All remaining CLI-only commands need a DB connection
|
|
const engine = await connectEngine();
|
|
try {
|
|
switch (command) {
|
|
case 'import': {
|
|
const { runImport } = await import('./commands/import.ts');
|
|
await runImport(engine, args);
|
|
break;
|
|
}
|
|
case 'export': {
|
|
const { runExport } = await import('./commands/export.ts');
|
|
await runExport(engine, args);
|
|
break;
|
|
}
|
|
case 'files': {
|
|
const { runFiles } = await import('./commands/files.ts');
|
|
await runFiles(engine, args);
|
|
break;
|
|
}
|
|
case 'embed': {
|
|
const { runEmbed } = await import('./commands/embed.ts');
|
|
await runEmbed(engine, args);
|
|
break;
|
|
}
|
|
case 'serve': {
|
|
const { runServe } = await import('./commands/serve.ts');
|
|
await runServe(engine, args);
|
|
return; // serve doesn't disconnect
|
|
}
|
|
case 'call': {
|
|
const { runCall } = await import('./commands/call.ts');
|
|
await runCall(engine, args);
|
|
break;
|
|
}
|
|
case 'config': {
|
|
const { runConfig } = await import('./commands/config.ts');
|
|
await runConfig(engine, args);
|
|
break;
|
|
}
|
|
// doctor is handled before connectEngine() above
|
|
case 'migrate': {
|
|
const { runMigrateEngine } = await import('./commands/migrate-engine.ts');
|
|
await runMigrateEngine(engine, args);
|
|
break;
|
|
}
|
|
case 'eval': {
|
|
// v0.32 EXP-5: `eval takes-quality {run,trend,regress}` requires a
|
|
// brain (samples takes from DB / reads runs table). `replay` was
|
|
// already routed through the no-DB bypass above and never reaches
|
|
// this case. Other `eval` subcommands (export/prune/replay-capture/
|
|
// longmemeval/cross-modal) go to the generic dispatcher.
|
|
if (args[0] === 'takes-quality') {
|
|
const { runEvalTakesQuality } = await import('./commands/eval-takes-quality.ts');
|
|
await runEvalTakesQuality(engine, args.slice(1));
|
|
break;
|
|
}
|
|
const { runEvalCommand } = await import('./commands/eval.ts');
|
|
await runEvalCommand(engine, args);
|
|
break;
|
|
}
|
|
case 'jobs': {
|
|
const { runJobs } = await import('./commands/jobs.ts');
|
|
await runJobs(engine, args);
|
|
break;
|
|
}
|
|
case 'agent': {
|
|
const { runAgent } = await import('./commands/agent.ts');
|
|
await runAgent(engine, args);
|
|
break;
|
|
}
|
|
case 'book-mirror': {
|
|
const { runBookMirrorCmd } = await import('./commands/book-mirror.ts');
|
|
await runBookMirrorCmd(engine, args);
|
|
break;
|
|
}
|
|
case 'sync': {
|
|
const { runSync } = await import('./commands/sync.ts');
|
|
await runSync(engine, args);
|
|
break;
|
|
}
|
|
case 'extract': {
|
|
const { runExtract } = await import('./commands/extract.ts');
|
|
await runExtract(engine, args);
|
|
break;
|
|
}
|
|
case 'features': {
|
|
const { runFeatures } = await import('./commands/features.ts');
|
|
await runFeatures(engine, args);
|
|
break;
|
|
}
|
|
case 'autopilot': {
|
|
const { runAutopilot } = await import('./commands/autopilot.ts');
|
|
await runAutopilot(engine, args);
|
|
return; // autopilot doesn't disconnect (long-running)
|
|
}
|
|
case 'graph-query': {
|
|
const { runGraphQuery } = await import('./commands/graph-query.ts');
|
|
await runGraphQuery(engine, args);
|
|
break;
|
|
}
|
|
case 'reconcile-links': {
|
|
// v0.20.0 Cathedral II Layer 8 D3: batch-recompute doc↔impl edges
|
|
// for any markdown page that cites code files. Idempotent; safe to
|
|
// re-run. Closes the v0.19.0 Layer 6 order-dependency bug where
|
|
// guides imported before their code never got their edges written.
|
|
const { runReconcileLinksCli } = await import('./commands/reconcile-links.ts');
|
|
await runReconcileLinksCli(engine, args);
|
|
break;
|
|
}
|
|
case 'orphans': {
|
|
const { runOrphans } = await import('./commands/orphans.ts');
|
|
await runOrphans(engine, args);
|
|
break;
|
|
}
|
|
// v0.32.7 CJK wave — post-upgrade markdown re-chunk sweep.
|
|
case 'reindex': {
|
|
const { runReindex } = await import('./commands/reindex.ts');
|
|
await runReindex(engine, args);
|
|
break;
|
|
}
|
|
// v0.29 — Salience + Anomaly Detection
|
|
case 'salience': {
|
|
const { runSalience } = await import('./commands/salience.ts');
|
|
await runSalience(engine, args);
|
|
break;
|
|
}
|
|
case 'anomalies': {
|
|
const { runAnomalies } = await import('./commands/anomalies.ts');
|
|
await runAnomalies(engine, args);
|
|
break;
|
|
}
|
|
case 'whoknows': {
|
|
// v0.33 (Issue #?): expertise + relationship-proximity routing.
|
|
// MCP op `find_experts` (read-scoped) backs the same code path; CLI
|
|
// dispatch here is the user-facing surface. Thin-client routing
|
|
// happens inside runWhoknows via isThinClient(cfg) (v0.31.1 pattern).
|
|
const { runWhoknows } = await import('./commands/whoknows.ts');
|
|
await runWhoknows(engine, args);
|
|
break;
|
|
}
|
|
case 'transcripts': {
|
|
const { runTranscripts } = await import('./commands/transcripts.ts');
|
|
await runTranscripts(engine, args);
|
|
break;
|
|
}
|
|
case 'models': {
|
|
const { runModels } = await import('./commands/models.ts');
|
|
await runModels(engine, args);
|
|
break;
|
|
}
|
|
case 'search': {
|
|
// v0.32.3 search-lite — `gbrain search modes/stats/tune`.
|
|
const { runSearch } = await import('./commands/search.ts');
|
|
await runSearch(engine, args);
|
|
break;
|
|
}
|
|
case 'takes': {
|
|
const { runTakes } = await import('./commands/takes.ts');
|
|
await runTakes(engine, args);
|
|
break;
|
|
}
|
|
case 'think': {
|
|
const { runThinkCli } = await import('./commands/think.ts');
|
|
await runThinkCli(engine, args);
|
|
break;
|
|
}
|
|
case 'recall': {
|
|
// v0.31: hot memory recall surface — `gbrain recall <entity>`,
|
|
// `--since DUR`, `--session ID`, `--today`, `--grep TEXT`,
|
|
// `--supersessions`, `--include-expired`, `--as-context`, `--json`.
|
|
const { runRecall } = await import('./commands/recall.ts');
|
|
await runRecall(engine, args);
|
|
break;
|
|
}
|
|
case 'forget': {
|
|
// v0.31: shorthand for expireFact. `gbrain forget <fact-id>`.
|
|
const { runForget } = await import('./commands/recall.ts');
|
|
await runForget(engine, args);
|
|
break;
|
|
}
|
|
case 'notability-eval': {
|
|
// v0.31.2: notability gate eval suite. Two subcommands:
|
|
// gbrain notability-eval mine — sample paragraphs, write candidates
|
|
// gbrain notability-eval review — TTY hand-confirm tiers
|
|
const { runNotabilityEval } = await import('./commands/notability-eval.ts');
|
|
const subcmd = args[0] || 'help';
|
|
const flags: Record<string, string | boolean> = {};
|
|
for (let i = 1; i < args.length; i++) {
|
|
const a = args[i];
|
|
if (a.startsWith('--')) {
|
|
const key = a.slice(2);
|
|
const next = args[i + 1];
|
|
if (next && !next.startsWith('--')) {
|
|
flags[key] = next;
|
|
i++;
|
|
} else {
|
|
flags[key] = true;
|
|
}
|
|
}
|
|
}
|
|
// sync.repo_path resolution (matches dream phase pattern).
|
|
let repoPath: string | undefined;
|
|
try {
|
|
repoPath = (flags.repo as string) || (await engine.getConfig('sync.repo_path')) || undefined;
|
|
} catch { /* engine may not be connected for help */ }
|
|
await runNotabilityEval({ cmd: subcmd, flags, engine, repoPath });
|
|
break;
|
|
}
|
|
case 'sources': {
|
|
const { runSources } = await import('./commands/sources.ts');
|
|
await runSources(engine, args);
|
|
break;
|
|
}
|
|
case 'pages': {
|
|
// v0.26.5: page-level operator commands (purge-deleted escape hatch).
|
|
const { runPages } = await import('./commands/pages.ts');
|
|
await runPages(engine, args);
|
|
break;
|
|
}
|
|
case 'storage': {
|
|
const { runStorage } = await import('./commands/storage.ts');
|
|
await runStorage(engine, args);
|
|
break;
|
|
}
|
|
case 'code-def': {
|
|
const { runCodeDef } = await import('./commands/code-def.ts');
|
|
await runCodeDef(engine, args);
|
|
break;
|
|
}
|
|
case 'code-refs': {
|
|
const { runCodeRefs } = await import('./commands/code-refs.ts');
|
|
await runCodeRefs(engine, args);
|
|
break;
|
|
}
|
|
case 'reindex-code': {
|
|
// v0.20.0 Cathedral II Layer 13 (E2): explicit code-page reindex
|
|
// for users upgrading from v0.19.0. Cost-preview gated; TTY prompt
|
|
// or ConfirmationRequired envelope for non-TTY/JSON callers.
|
|
const { runReindexCodeCli } = await import('./commands/reindex-code.ts');
|
|
await runReindexCodeCli(engine, args);
|
|
break;
|
|
}
|
|
case 'reindex-frontmatter': {
|
|
// v0.29.1: recovery / explicit-rebuild path for pages.effective_date.
|
|
// Mirror of reindex-code shape. Wraps the shared library function in
|
|
// src/core/backfill-effective-date.ts (same code path the v0.29.1
|
|
// migration orchestrator uses). The orchestrator runs once on
|
|
// upgrade; this command is for after-the-fact frontmatter edits.
|
|
//
|
|
// v0.30.1: still works; canonical entrypoint is now `gbrain backfill
|
|
// effective_date`. This command stays as a thin alias for back-compat.
|
|
const { reindexFrontmatterCli } = await import('./commands/reindex-frontmatter.ts');
|
|
await reindexFrontmatterCli(args);
|
|
return; // reindexFrontmatterCli handles its own engine lifecycle
|
|
}
|
|
case 'backfill': {
|
|
// v0.30.1: first-class generic backfill command. Subcommand dispatch
|
|
// is inside runBackfillCommand (kind | list | --help).
|
|
const { runBackfillCommand } = await import('./commands/backfill.ts');
|
|
await runBackfillCommand(args);
|
|
return;
|
|
}
|
|
case 'code-callers': {
|
|
// v0.20.0 Cathedral II Layer 10 (C4): "who calls <symbol>?"
|
|
const { runCodeCallers } = await import('./commands/code-callers.ts');
|
|
await runCodeCallers(engine, args);
|
|
break;
|
|
}
|
|
case 'code-callees': {
|
|
// v0.20.0 Cathedral II Layer 10 (C5): "what does <symbol> call?"
|
|
const { runCodeCallees } = await import('./commands/code-callees.ts');
|
|
await runCodeCallees(engine, args);
|
|
break;
|
|
}
|
|
case 'repos': {
|
|
// v0.19.0: `gbrain repos ...` is an alias into the v0.18.0 sources
|
|
// subsystem. The repos abstraction (Garry's OpenClaw baseline) was
|
|
// redundant with sources and carried per-user config state that
|
|
// couldn't participate in federation / RLS / multi-tenancy. We
|
|
// keep the alias so scripts like `gbrain repos add .` keep
|
|
// working, with a nudge toward the canonical command.
|
|
console.error('[gbrain] Note: "repos" is an alias for "sources" as of v0.19.0. Prefer `gbrain sources <subcommand>`.');
|
|
const { runSources } = await import('./commands/sources.ts');
|
|
await runSources(engine, args);
|
|
break;
|
|
}
|
|
}
|
|
} finally {
|
|
if (command !== 'serve') await engine.disconnect();
|
|
}
|
|
}
|
|
|
|
// Build the AIGatewayConfig payload from a GBrainConfig. File-local; not
|
|
// exported. Both configureGateway sites in connectEngine() pass through this
|
|
// helper so adding a new field touches one place. Adding a field to one site
|
|
// but not the other previously required remembering to mirror the change;
|
|
// the helper makes that structural.
|
|
function buildGatewayConfig(c: GBrainConfig): AIGatewayConfig {
|
|
// v0.32 (#121 reworked): when ~/.gbrain/config.json declares
|
|
// openai_api_key / anthropic_api_key, fold them into the gateway env so
|
|
// recipes that read OPENAI_API_KEY / ANTHROPIC_API_KEY find them. Process
|
|
// env still wins (it's loaded last) — this is a fallback for daemons /
|
|
// launchd-spawned subprocesses that don't propagate ~/.zshrc-sourced keys.
|
|
const envFromConfig: Record<string, string> = {};
|
|
if (c.openai_api_key) envFromConfig.OPENAI_API_KEY = c.openai_api_key;
|
|
if (c.anthropic_api_key) envFromConfig.ANTHROPIC_API_KEY = c.anthropic_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
|
|
// this, `LLAMA_SERVER_BASE_URL=http://localhost:9000` would let the probe
|
|
// succeed against :9000 but the actual embed call would still go to the
|
|
// recipe's base_url_default (localhost:8080). Same fix applies to
|
|
// OLLAMA_BASE_URL. Caller-provided cfg.provider_base_urls wins.
|
|
const envBaseUrls: Record<string, string> = {};
|
|
if (process.env.LLAMA_SERVER_BASE_URL) envBaseUrls['llama-server'] = process.env.LLAMA_SERVER_BASE_URL;
|
|
if (process.env.OLLAMA_BASE_URL) envBaseUrls['ollama'] = process.env.OLLAMA_BASE_URL;
|
|
if (process.env.LMSTUDIO_BASE_URL) envBaseUrls['lmstudio'] = process.env.LMSTUDIO_BASE_URL;
|
|
if (process.env.LITELLM_BASE_URL) envBaseUrls['litellm'] = process.env.LITELLM_BASE_URL;
|
|
|
|
return {
|
|
embedding_model: c.embedding_model,
|
|
embedding_dimensions: c.embedding_dimensions,
|
|
embedding_multimodal_model: c.embedding_multimodal_model,
|
|
expansion_model: c.expansion_model,
|
|
chat_model: c.chat_model,
|
|
chat_fallback_chain: c.chat_fallback_chain,
|
|
base_urls: { ...envBaseUrls, ...(c.provider_base_urls ?? {}) }, // config wins over env
|
|
env: { ...envFromConfig, ...process.env }, // process.env wins
|
|
};
|
|
}
|
|
|
|
async function connectEngine(opts?: { probeOnly?: boolean }): Promise<BrainEngine> {
|
|
const config = loadConfig();
|
|
if (!config) {
|
|
console.error('No brain configured. Run: gbrain init');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Configure the AI gateway BEFORE engine connect — initSchema needs embedding dims.
|
|
// Env is read once here; the gateway never reads process.env at call time (Codex C3).
|
|
const { configureGateway } = await import('./core/ai/gateway.ts');
|
|
configureGateway(buildGatewayConfig(config));
|
|
|
|
const { createEngine } = await import('./core/engine-factory.ts');
|
|
const engine = await createEngine(toEngineConfig(config));
|
|
const noRetry = process.argv.includes('--no-retry-connect') ||
|
|
process.env.GBRAIN_NO_RETRY_CONNECT === '1';
|
|
const { connectWithRetry } = await import('./core/db.ts');
|
|
await connectWithRetry(engine, toEngineConfig(config), { noRetry });
|
|
|
|
// v0.30.1 (Codex X1 / C2): probeOnly skips both hasPendingMigrations() probe
|
|
// AND initSchema(). Used by `get_health` MCP op + `gbrain upgrade --status`
|
|
// + doctor's migration_wedge check — these surfaces report wedge state and
|
|
// must NEVER themselves start or block on migrations.
|
|
if (opts?.probeOnly === true) {
|
|
return engine;
|
|
}
|
|
|
|
// Auto-apply pending schema migrations on connect (#651). Cheap probe
|
|
// first so already-migrated brains don't pay the bootstrap-probe +
|
|
// SCHEMA_SQL replay + ledger-check cost on every short-lived CLI call.
|
|
// This is the conditional version of #652 (oyi77's investigation):
|
|
// same correctness, no perf regression on the hot path.
|
|
try {
|
|
const { hasPendingMigrations } = await import('./core/migrate.ts');
|
|
if (await hasPendingMigrations(engine)) {
|
|
await engine.initSchema();
|
|
}
|
|
} catch (err) {
|
|
// Non-fatal: if probe or initSchema fails, surface a hint and continue
|
|
// with the connected engine. Subsequent operations will surface the
|
|
// real schema error in context.
|
|
console.warn(` Schema probe/migrate failed: ${(err as Error).message}`);
|
|
console.warn(' Try: gbrain init --migrate-only');
|
|
}
|
|
|
|
// v0.27.1 (F3 fix): re-merge DB-plane config now that the engine is up.
|
|
// Flags like `embedding_multimodal` are user-mutable via `gbrain config set`
|
|
// (DB plane) and need to flow into the gateway after connect. Schema-sizing
|
|
// fields (embedding_dimensions etc.) keep their pre-connect file/env values
|
|
// — those drove initSchema and the merged config respects file/env first.
|
|
try {
|
|
const merged = await loadConfigWithEngine(engine, config);
|
|
if (merged) {
|
|
// Stash gate flags on process.env for downstream readers (import-file.ts
|
|
// dispatches on GBRAIN_EMBEDDING_MULTIMODAL, OCR consumer reads
|
|
// GBRAIN_EMBEDDING_IMAGE_OCR_*). The gateway itself doesn't read these
|
|
// flags; this preserves the contract without changing the gateway shape.
|
|
if (merged.embedding_multimodal !== undefined) {
|
|
process.env.GBRAIN_EMBEDDING_MULTIMODAL = String(merged.embedding_multimodal);
|
|
}
|
|
if (merged.embedding_image_ocr !== undefined) {
|
|
process.env.GBRAIN_EMBEDDING_IMAGE_OCR = String(merged.embedding_image_ocr);
|
|
}
|
|
if (merged.embedding_image_ocr_model !== undefined) {
|
|
process.env.GBRAIN_EMBEDDING_IMAGE_OCR_MODEL = merged.embedding_image_ocr_model;
|
|
}
|
|
// Always re-configure with merged values when DB merge succeeded. The
|
|
// trigger used to be field-name-gated (only when embedding_multimodal_model
|
|
// was set); that coupled the gate to the field set and would silently
|
|
// miss future DB-mutable gateway fields. One extra cache+shrinkState
|
|
// clear per startup is microseconds, no hot path.
|
|
configureGateway(buildGatewayConfig(merged));
|
|
}
|
|
// v0.31.12: re-resolve gateway defaults through resolveModel so
|
|
// `models.tier.*` and `models.default` overrides apply to expansion +
|
|
// chat. Per Codex F3 — configureGateway is sync; this is the async
|
|
// re-stamp seam after engine.connect() makes config reads possible.
|
|
const { reconfigureGatewayWithEngine } = await import('./core/ai/gateway.ts');
|
|
await reconfigureGatewayWithEngine(engine);
|
|
} catch {
|
|
// Non-fatal. Pre-v39 brains may not have a usable config table yet.
|
|
}
|
|
|
|
return engine;
|
|
}
|
|
|
|
function printOpHelp(op: Operation) {
|
|
const positional = (op.cliHints?.positional || []).map(p => `<${p}>`).join(' ');
|
|
const name = op.cliHints?.name || op.name;
|
|
console.log(`Usage: gbrain ${name} ${positional} [options]\n`);
|
|
console.log(op.description + '\n');
|
|
const entries = Object.entries(op.params);
|
|
if (entries.length > 0) {
|
|
console.log('Options:');
|
|
for (const [key, def] of entries) {
|
|
const isPos = op.cliHints?.positional?.includes(key);
|
|
const req = def.required ? ' (required)' : '';
|
|
const prefix = isPos ? ` <${key}>` : ` --${key.replace(/_/g, '-')}`;
|
|
console.log(`${prefix.padEnd(28)} ${def.description || ''}${req}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function printHelp() {
|
|
// Gather shared operations grouped by category
|
|
const cliNames = Array.from(cliOps.entries())
|
|
.map(([name, op]) => ({ name, desc: op.description }));
|
|
|
|
console.log(`gbrain ${VERSION} -- personal knowledge brain
|
|
|
|
USAGE
|
|
gbrain <command> [options]
|
|
|
|
SETUP
|
|
init [--pglite|--supabase|--url] Create brain (PGLite default, no server)
|
|
migrate --to <supabase|pglite> Transfer brain between engines
|
|
upgrade Self-update
|
|
check-update [--json] Check for new versions
|
|
doctor [--json] [--fast] Health check (resolver, skills, pgvector, RLS, embeddings)
|
|
integrations [subcommand] Manage integration recipes (senses + reflexes)
|
|
|
|
PAGES
|
|
get <slug> Read a page
|
|
put <slug> [< file.md] Write/update a page
|
|
delete <slug> Delete a page
|
|
list [--type T] [--tag T] [-n N] List pages
|
|
|
|
SEARCH
|
|
search <query> Keyword search (tsvector)
|
|
query <question> [--no-expand] Hybrid search (RRF + expansion)
|
|
ask <question> [--no-expand] Alias for query
|
|
|
|
IMPORT/EXPORT
|
|
import <dir> [--no-embed] Import markdown directory
|
|
sync [--repo <path>] [flags] Git-to-brain incremental sync
|
|
sync --watch [--interval N] Continuous sync (loops until stopped)
|
|
sync --install-cron Install persistent sync daemon
|
|
export [--dir ./out/] Export to markdown
|
|
export --restore-only [--repo <p>] Restore missing supabase-only files
|
|
[--type T] [--slug-prefix S] With optional filters
|
|
|
|
FILES
|
|
files list [slug] List stored files
|
|
files upload <file> --page <slug> Upload file to storage
|
|
files upload-raw <file> --page <s> Smart upload (size routing + .redirect.yaml)
|
|
files signed-url <path> Generate signed URL (1-hour)
|
|
files sync <dir> Bulk upload directory
|
|
files verify Verify all uploads
|
|
|
|
EMBEDDINGS
|
|
embed [<slug>|--all|--stale] Generate/refresh embeddings
|
|
|
|
LINKS
|
|
link <from> <to> [--type T] Create typed link
|
|
unlink <from> <to> Remove link
|
|
backlinks <slug> Incoming links
|
|
graph <slug> [--depth N] Traverse link graph (returns nodes)
|
|
graph-query <slug> [--type T] Edge-based traversal with type/direction filters
|
|
[--depth N] [--direction in|out|both]
|
|
|
|
TAGS
|
|
tags <slug> List tags
|
|
tag <slug> <tag> Add tag
|
|
untag <slug> <tag> Remove tag
|
|
|
|
TIMELINE
|
|
timeline [<slug>] View timeline
|
|
timeline-add <slug> <date> <text> Add timeline entry
|
|
|
|
TOOLS
|
|
extract <links|timeline|all> Extract links/timeline (idempotent)
|
|
[--source fs|db] fs (default) walks .md files; db iterates engine pages
|
|
[--dir <brain>] brain dir for fs source
|
|
[--type T] [--since DATE] filters (db source)
|
|
[--dry-run] [--json]
|
|
publish <page.md> [--password] Shareable HTML (strips private data, optional AES-256)
|
|
check-backlinks <check|fix> [dir] Find/fix missing back-links across brain
|
|
lint <dir|file> [--fix] Catch LLM artifacts, placeholder dates, bad frontmatter
|
|
orphans [--json] [--count] Find pages with no inbound wikilinks
|
|
salience [--days N] [--kind P] v0.29: pages ranked by emotional + activity salience
|
|
anomalies [--since D] [--sigma N] v0.29: cohort-based statistical anomalies (tag, type)
|
|
transcripts recent [--days N] v0.29: recent raw .txt transcripts (local-only)
|
|
dream [--dry-run] [--json] Run the overnight maintenance cycle once (cron-friendly).
|
|
See also: autopilot --install (continuous daemon).
|
|
check-resolvable [--json] [--fix] Validate skill tree (reachability/MECE/DRY)
|
|
report --type <name> --content ... Save timestamped report to brain/reports/
|
|
|
|
SOURCES (multi-repo / multi-brain)
|
|
sources list Show registered sources
|
|
sources add <id> --path <p> Register a source (id = short name, e.g. 'wiki')
|
|
sources remove <id> Remove a source + its pages
|
|
sync --all Sync all sources with a local_path
|
|
sync --source <id> Sync one specific source
|
|
repos ... DEPRECATED alias for 'sources' (v0.19.0)
|
|
|
|
CODE INDEXING (v0.19.0 / v0.20.0 Cathedral II)
|
|
code-def <symbol> [--lang l] Find the definition of a symbol across code pages
|
|
code-refs <symbol> [--lang l] Find all references to a symbol (JSON-first)
|
|
code-callers <symbol> Who calls this symbol? (v0.20.0 A1)
|
|
code-callees <symbol> What does this symbol call? (v0.20.0 A1)
|
|
query <q> --lang <l> Filter hybrid search to one language (v0.20.0)
|
|
query <q> --symbol-kind <k> Filter to symbol type (function|class|method|...) (v0.20.0)
|
|
reconcile-links [--dry-run] Batch-recompute doc↔impl edges (v0.20.0)
|
|
reindex-code [--source id] [--yes] Explicit code-page reindex (v0.20.0)
|
|
sync --strategy code Sync code files into the brain
|
|
|
|
JOBS (Minions)
|
|
jobs submit <name> [--params JSON] Submit background job [--follow] [--dry-run]
|
|
jobs list [--status S] [--limit N] List jobs
|
|
jobs get <id> Job details + history
|
|
jobs cancel <id> Cancel job
|
|
jobs retry <id> Re-queue failed/dead job
|
|
jobs prune [--older-than 30d] Clean old jobs
|
|
jobs stats Job health dashboard
|
|
jobs work [--queue Q] Start worker daemon (Postgres only)
|
|
|
|
ADMIN
|
|
stats Brain statistics
|
|
health Brain health dashboard
|
|
history <slug> Page version history
|
|
revert <slug> <version-id> Revert to version
|
|
features [--json] [--auto-fix] Scan usage + recommend unused features
|
|
autopilot [--repo] [--interval N] Self-maintaining brain daemon
|
|
config [show|get|set] <key> [val] Brain config
|
|
storage status [--repo <path>] Storage tier status and health
|
|
[--json] (git-tracked vs supabase-only)
|
|
serve MCP server (stdio)
|
|
serve --http [--port N] HTTP MCP server with OAuth 2.1
|
|
--token-ttl N Access token TTL in seconds (default: 3600)
|
|
--enable-dcr Enable Dynamic Client Registration
|
|
--public-url URL Public issuer URL (required behind proxy/tunnel)
|
|
call <tool> '<json>' Raw tool invocation
|
|
version Version info
|
|
--tools-json Tool discovery (JSON)
|
|
|
|
Run gbrain <command> --help for command-specific help.
|
|
`);
|
|
}
|
|
|
|
main().catch(e => {
|
|
console.error(e.message || e);
|
|
process.exit(1);
|
|
});
|