mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* chore(test): preload gateway to OpenAI/1536 so 1536-dim test fixtures keep working The v0.37 fix wave changes the canonical gateway defaults to zeroentropyai:zembed-1 / 1280 (matching what v0.36 already chose as the system default). 20+ test files have hardcoded new Float32Array(1536) fixtures that match the OLD schema default. Without this preload, those tests fail with a vector-dim-mismatch on insert. The preload is gateway-only — it doesn't change which model gbrain ships to production users. Tests that want the new ZE/1280 defaults call configureGateway() explicitly in their own beforeAll. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ai): canonical embedding defaults + sweep across schema/engines/registry Closes the v0.36 defaults drift bug class. The gateway shipped zeroentropyai:zembed-1 / 1280 as the system default in v0.36 but eight other places kept hardcoding 1536 / text-embedding-3-large. Fresh gbrain init --pglite sized the column to 1536, the embed pipeline used ZE/1280, and every page failed with dim mismatch. - New src/core/ai/defaults.ts leaf module is the canonical source for DEFAULT_EMBEDDING_MODEL / DEFAULT_EMBEDDING_DIMENSIONS. Schema and registry helpers import from this lean module instead of pulling the full gateway (which loads every provider SDK). - src/core/ai/gateway.ts re-exports the constants for back-compat. - src/core/pglite-schema.ts getPGLiteSchema() defaults track gateway. - src/core/postgres-engine.ts getPostgresSchema() default args track gateway (same drift on the Postgres path — codex round 1 CDX-1). - Both engine.initSchema() fallbacks track gateway constants (no more stale OpenAI/1536 catch-block defaults). - Schema seed stops stripping the provider prefix; full provider:model is stored in the DB config table (codex round 1 CDX-4). - Chunk-row INSERT defaults track gateway (codex round 2 CDX2-4 — pglite-engine:1611 + postgres-engine:1647 were production write sites previously hardcoded to text-embedding-3-large). - src/core/search/embedding-column.ts loadRegistry + isCacheSafe gain the cfg > gateway > DEFAULT resolution chain (codex round 2 CDX2-3). The gateway tier matters because callers that configure the gateway (init paths, tests, programmatic SDK) expect the registry to mirror that state when cfg doesn't have an explicit embedding_model. Tests: - schema-templating: default expectation flips to ZE/1280 (v0.37 truth). - embedding-dim-check: 3 new engine-kind branching cases + updated fresh-brain expectation (under legacy preload). - embedding-column: registry + isCacheSafe expectations match new chain. - v0_28_5-fix-wave E2E: engineKind required arg propagated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(init+config+cli): always-configure gateway, file-only loader, honest config-set, sync/reinit help Closes the "fresh init doesn't work + config-set silently lies" bug class end-to-end. Six related changes that ship together because the file-plane/DB-plane contract only holds when init paths, config-set, the gateway env mapping, and the recipe text all agree. Lane B (init paths): - initPGLite, initPostgres, initMigrateOnly always configureGateway() before engine.initSchema(). Pre-fix the call was gated on flags, so bare `gbrain init --pglite` left the gateway unconfigured and the engine fell through to stale OpenAI/1536 defaults instead of the ZE/1280 the gateway would have resolved. - New configureGatewayWithMergedPrecedence() helper applies the locked precedence chain `CLI > env > existing file > gateway internal`. - printResolvedAIChoice() shows the resolved model/dim at init time + surfaces a ZE setup hint inline when the API key is missing. - B.4: saveConfig merge uses loadConfigFileOnly() so transient env state (DATABASE_URL, etc.) never poisons ~/.gbrain/config.json (codex round 2 CDX-5). - B.5: extend the v0.28.5 dim-mismatch detector so it fires when the gateway-resolved dim differs from the existing column, not only when --embedding-dimensions is explicit (codex round 2 CDX-6). Lane C (config plane): - New `loadConfigFileOnly()` reads ~/.gbrain/config.json only — no env merge, no DATABASE_URL inference. Safe write-back source for init. - GBrainConfig gains `zeroentropy_api_key?: string`. loadConfig merges process.env.ZEROENTROPY_API_KEY. buildGatewayConfig at cli.ts:1401 maps it into env.ZEROENTROPY_API_KEY so ZE recipes finally see it (codex round 2 CDX2-5+6 — the v1 fix landed in the wrong file). - `gbrain config set embedding_model` and `... embedding_dimensions` refuse unconditionally and print a paste-ready wipe-and-reinit recipe. No --force escape (codex round 2 CDX2-13). - migrate-engine.ts adds a contract comment at the DB-plane write site documenting "DB stores schema-applied metadata; file plane is canonical for runtime gateway config" + preserves the existing file-plane config across engine migration. Lane D.1 (recipe text): - embeddingMismatchMessage() takes an `engineKind` arg. PGLite branch emits a wipe-and-reinit recipe using gbrainPath('brain.pglite') or the caller's databasePath override. Postgres branch keeps the SQL ALTER recipe. - The PGLite recipe recommends `gbrain reinit-pglite` (new sugar command below) as the one-line path before falling back to the by-hand mv + init + sync sequence. Lane D.4 (sync help dispatch): - `sync` and `reinit-pglite` added to CLI_ONLY_SELF_HELP so their own --help branches reach the user (pre-fix the generic short-circuit fired first and the dedicated usage was unreachable; codex round 2 CDX2-12). - `gbrain sync --help` short-circuits BEFORE engine bind so users on a fresh tmpdir (no config) can read the help without hitting no-such-config errors. Sugar: - New `gbrain reinit-pglite --embedding-model X --embedding-dimensions N` wraps the wipe + init + sync dance into one command. Backs up the brain to <path>.bak. TTY confirmation unless --yes. --no-sync to defer the resync. --json for scripts. Tests: - test/cli.test.ts sync-help test rewritten for the new per-command-usage output (lists --no-embed which is the v0.37 user-visible flag the wave wanted to surface). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(embed+sync): pre-flight dim-mismatch guard + sync hint at both catch sites embedding-pipeline error UX. Pre-fix, a fresh-install dim mismatch produced raw Postgres "expected N dimensions, not M" errors page after page, surfacing only after the worker pool drained the entire corpus. Sync swallowed embed errors at TWO catch sites and never surfaced the recovery recipe. embed.ts: - New `EmbeddingDimMismatchError` tagged class with the paste-ready recipe baked in. - `runEmbedCore` pre-flights via `readContentChunksEmbeddingDim` + gateway.getEmbeddingDimensions() before the worker pool spins up. On mismatch, throws the typed error which the CLI wrapper catches and prints. Dry-run skips the check (no embed risk). - Catches the headline fresh-install bug class at first call instead of letting it hammer N parallel API calls into dim-rejected inserts. sync.ts: - Both embed catches at sync.ts:990 (incremental) and sync.ts:1129 (first-sync) detect EmbeddingDimMismatchError and surface the recipe + a `--no-embed` tip on stderr (codex round 2 CDX2-8: incremental path was previously silent; only the first-sync path was flagged). - Non-mismatch embed failures still stay best-effort (rate limits, transient network) — those shouldn't break sync. - Sync calls runEmbedCore directly instead of runEmbed (which calls process.exit on error and bypasses sync's catch). - Sync gets a proper --help block listing every meaningful flag: --no-embed, --workers, --source, --skip-failed, --retry-failed, --watch, --interval, --no-pull, --all, --json, --yes, --dry-run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(doctor): read gateway for schema-sizing checks + provider-aware key lookup Doctor's embedding checks were reading the DB config table for embedding_model / embedding_dimensions / zeroentropy_api_key. Post v0.37 the file plane is canonical (the DB plane is schema-applied metadata, not runtime gateway config) so those reads produced stale verdicts on fresh installs whose DB row hadn't been written. - checkEmbeddingWidthConsistency reads gateway.getEmbeddingDimensions() and gateway.getEmbeddingModel() instead of engine.getConfig(...). Reuses readContentChunksEmbeddingDim from the same shared helper init + embed use. On mismatch, the fix hint threads engineKind + databasePath into the new branched recipe (codex round 1 CDX-8 + Lane E.1/E.2). - checkZeEmbeddingHealth reads gateway for the model + loadConfigFileOnly for the key. Fires when (a) resolved model starts with zeroentropyai: AND (b) ZEROENTROPY_API_KEY is unset in env AND (c) file plane has no zeroentropy_api_key (codex round 2 CDX2-10). - loadRecommendationContext reads gateway for both fields and recognizes the ZE key alongside OpenAI/Anthropic in the hasEmbeddingApiKey check, so brains on ZE no longer look "healthy" just because OPENAI_API_KEY happens to be set (codex round 2 CDX2-11). Tests rewritten for the gateway-source-of-truth contract via configureGateway() in beforeAll. Added a "gateway unconfigured: skips with ok" case so doctor doesn't false-warn on cold-boot brains. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test+docs(v0.37): fix-wave unit coverage + PGLite-first migration recipe + TODOS Lands the v0.37 PGLite fresh-install fix wave's structural tests and the user-facing migration recipe overhaul. test/v0_37_fix_wave.test.ts (new): 22 unit cases pinning the lanes: - Lane A: defaults module exports, getPGLiteSchema/getPostgresSchema default-args, registry + isCacheSafe under the `cfg > gateway > DEFAULT` chain (both gateway-set and gateway-reset branches). - Lane B: loadConfigFileOnly env isolation + DATABASE_URL inference refusal + null-on-missing. - Lane C.3: buildGatewayConfig maps zeroentropy_api_key + process.env wins over config (operator escape hatch contract). - Lane D.2: EmbeddingDimMismatchError shape + tag. - Lane D.4: structural assertion that `sync` is in CLI_ONLY_SELF_HELP. - Deferred-TODO ship: reinit-pglite is registered correctly + embeddingMismatchMessage PGLite branch recommends it. docs/embedding-migrations.md: PGLite section moved to top (the default install). The recommended path is `gbrain reinit-pglite` one-liner; the by-hand mv + init + sync sequence stays as the fallback recipe. Postgres SQL ALTER recipe preserved. New section on `gbrain config set` refusal explains the file-plane vs DB-plane contract so users don't follow stale documentation. TODOS.md: 4 deferred follow-ups filed with concrete file pointers: - gbrain embed --try-fallback (provider auto-switch with consent gate) - Full plane unification for non-schema-sizing fields - Worker-pool shared AbortController for mid-run dim drift - Cleanup of back-compat constants in src/core/embedding.ts Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(v0.37): fill behavior gaps + headline fresh-install E2E The structural fix-wave tests in test/v0_37_fix_wave.test.ts pin lane-level invariants (exports, registry chain, signature shapes). The audit found 10+ END-TO-END behaviors that the structural tests didn't actually reach. This file fills the highest-leverage gaps. Unit coverage (test/v0_37_gap_fill.test.ts, 12 cases): - Lane A.7: chunk-row INSERT default tracks DEFAULT_EMBEDDING_MODEL constant (pre-fix this was the literal 'text-embedding-3-large' at pglite-engine.ts:1611 + postgres-engine.ts:1647 — production write sites that were never directly tested; codex round 2 CDX2-4). - Lane A.8: schema seed stores full provider:model in DB config (pre-fix the .split(':') strip dropped the prefix; codex round 1 CDX-4). Asserts a fresh ZE init stores `zeroentropyai:zembed-1` in the config table, not bare `zembed-1`. - Lane B precedence: explicit CLI > env > existing file > default test (codex round 2 CDX2-7 contradiction guard). - Lane C.3 env merge: process.env.ZEROENTROPY_API_KEY threads through loadConfig → cfg.zeroentropy_api_key; loadConfigFileOnly does NOT. - Lane D.2 end-to-end: schema=1536 + gateway=1280 → EmbeddingDimMismatchError fires AND the embed transport is never called (the whole point of pre-flight). Plus dry-run skips the check. - Lane D.3 source-text grep: both sync.ts catch sites detect the typed error + the `--no-embed` tip is present (CDX2-8). - Lane E.4 source-text grep: loadRecommendationContext is provider-aware (reads gateway + branches on ZE/OpenAI key). - reinit-pglite contract: refuses on non-PGLite engines + refuses when required flags are missing. E2E (test/e2e/fresh-install-pglite.test.ts, 2 cases): - Bare `gbrain init --pglite` produces a `vector(1280)` schema, prints the resolved choice, persists defaults to config.json — the headline scenario that v0.37 ships to fix. - init → seed page → embed end-to-end: chunks have non-null embeddings; no dim mismatch despite the wave's defaults change. Both E2E cases are IN-PROCESS (per CDX2-12: CLI-subprocess E2E can't inherit `__setEmbedTransportForTests`). They run with stubbed transport returning synthetic 1280-dim vectors so we never hit real provider APIs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(v0.37): defensive gateway restore in reinit-pglite describe block Adds an afterAll that restores the gateway to OpenAI/1536 (matching the bunfig preload) at the end of the reinit-pglite describe. Belt-and- suspenders: earlier describe blocks in this file already restore, but if the reinit-pglite tests ever start mutating the gateway in the future, this protects downstream test files in the same bun-test shard from inheriting a non-default state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.37.10.0) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: scrub stale config-set recipes for embedding model (v0.37.10.0) README + topologies + embedding-providers were still pointing users at `gbrain config set embedding_model X` / `embedding_dimensions N`. As of v0.37.10.0 those writes are refused — the schema column has to resize alongside the config. Point at `gbrain reinit-pglite` (PGLite) and the SQL recipe in `docs/embedding-migrations.md` (Postgres) instead. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: bump version to v0.37.11.0 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test: quarantine v0.37 fix-wave tests to .serial.test.ts CI's `check:test-isolation` lint flagged R1 violations (direct `process.env.GBRAIN_HOME` mutation) in both new fix-wave test files. Per the documented quarantine pattern in CLAUDE.md, rename to `*.serial.test.ts` instead of refactoring through `withEnv()` — both files use beforeEach/afterEach env wiring that's already serial-safe. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1716 lines
73 KiB
TypeScript
Executable File
1716 lines
73 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', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', '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', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd']);
|
|
// 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',
|
|
'brainstorm', 'lsd',
|
|
// v0.37 fix wave (Lane D.4 + CDX2-12): sync's --no-embed flag was
|
|
// unreachable via help because the dispatcher's generic CLI-only
|
|
// short-circuit fired before runSync could print its own usage block.
|
|
// Adding `sync` here routes `gbrain sync --help` into runSync.
|
|
'sync',
|
|
// v0.37 fix wave (deferred TODO, shipped): reinit-pglite has its
|
|
// own --help in runReinitPglite. Routing through SELF_HELP avoids
|
|
// the generic short-circuit so the destructive-action warning text
|
|
// reaches the user.
|
|
'reinit-pglite',
|
|
]);
|
|
|
|
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);
|
|
if (op.name === 'query') {
|
|
const { awaitPendingSearchCacheWrites } = await import('./core/search/hybrid.ts');
|
|
await awaitPendingSearchCacheWrites();
|
|
}
|
|
} 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 };
|
|
}
|
|
|
|
export 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('--')) {
|
|
if (arg.startsWith('--no-')) {
|
|
const positiveKey = arg.slice(5).replace(/-/g, '_');
|
|
const positiveDef = op.params[positiveKey];
|
|
if (positiveDef?.type === 'boolean') {
|
|
params[positiveKey] = false;
|
|
continue;
|
|
}
|
|
}
|
|
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(),
|
|
// v0.34 D4: sourceId is REQUIRED at the type level. Fall back to 'default'
|
|
// when resolveSourceId returned undefined (fresh pre-init brain, no sources
|
|
// table). Matches dispatch.ts's auto-fill so the contract holds across
|
|
// every transport.
|
|
sourceId: sourceId ?? 'default',
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
// v0.37 fix wave (deferred TODO, shipped): one-command wipe-and-reinit.
|
|
// Spawns its own engine internally so no pre-bound engine needed.
|
|
if (command === 'reinit-pglite') {
|
|
const { runReinitPglite } = await import('./commands/reinit-pglite.ts');
|
|
await runReinitPglite(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;
|
|
}
|
|
|
|
// v0.36+ brain-health-100: --remediation-plan and --remediate go
|
|
// through dedicated functions that compute from engine.getHealth()
|
|
// (cheap path D7), NOT the full doctor walk.
|
|
if (args.includes('--remediation-plan')) {
|
|
const { runRemediationPlan } = await import('./commands/doctor.ts');
|
|
const eng = await connectEngine();
|
|
try { await runRemediationPlan(eng, args); } finally { await eng.disconnect(); }
|
|
return;
|
|
}
|
|
if (args.includes('--remediate')) {
|
|
const { runRemediate } = await import('./commands/doctor.ts');
|
|
const eng = await connectEngine();
|
|
try { await runRemediate(eng, args); } finally { await eng.disconnect(); }
|
|
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 === 'ze-switch') {
|
|
// v0.36.0.0 — manual ZE-default switch lever. Owns its own engine lifecycle
|
|
// to mirror the doctor pattern.
|
|
const { runZeSwitch } = await import('./commands/ze-switch.ts');
|
|
const eng = await connectEngine();
|
|
try {
|
|
await runZeSwitch(args, eng);
|
|
} finally {
|
|
await eng.disconnect();
|
|
}
|
|
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.
|
|
//
|
|
// v0.35.1.1: still need to configureGateway() so the in-memory brain's
|
|
// import + hybridSearch can embed via the configured provider. Reads
|
|
// ~/.gbrain/config.json when present; falls back to env vars otherwise
|
|
// (GBRAIN_EMBEDDING_MODEL / GBRAIN_EMBEDDING_DIMENSIONS).
|
|
if (command === 'eval' && args[0] === 'longmemeval') {
|
|
const { runEvalLongMemEval } = await import('./commands/eval-longmemeval.ts');
|
|
if (!(args.length > 1 && (args[1] === '--help' || args[1] === '-h'))) {
|
|
const config = loadConfig() ?? ({
|
|
embedding_model: process.env.GBRAIN_EMBEDDING_MODEL,
|
|
embedding_dimensions: process.env.GBRAIN_EMBEDDING_DIMENSIONS
|
|
? Number(process.env.GBRAIN_EMBEDDING_DIMENSIONS) : undefined,
|
|
} as GBrainConfig);
|
|
const { configureGateway } = await import('./core/ai/gateway.ts');
|
|
configureGateway(buildGatewayConfig(config));
|
|
}
|
|
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)));
|
|
}
|
|
}
|
|
|
|
// v0.37 fix wave (Lane D.4 + CDX2-12): short-circuit `gbrain sync --help`
|
|
// BEFORE the engine bind. runSync has its own --help branch but can't
|
|
// reach it without an engine — which means a user running `--help` from
|
|
// a fresh tmpdir with no config gets a no-such-config error instead of
|
|
// help text. Importing runSync without the engine + passing null works
|
|
// because runSync's --help path doesn't touch the engine argument.
|
|
if (command === 'sync' && (args.includes('--help') || args.includes('-h'))) {
|
|
const { runSync } = await import('./commands/sync.ts');
|
|
await runSync(null as any, args);
|
|
return;
|
|
}
|
|
|
|
// 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.
|
|
// v0.36 Phase 3 wave — `gbrain reindex --multimodal` re-embeds content_chunks
|
|
// into the unified Voyage multimodal-3 column.
|
|
case 'reindex': {
|
|
if (args.includes('--multimodal')) {
|
|
const { runReindexMultimodal } = await import('./commands/reindex-multimodal.ts');
|
|
const limitIdx = args.indexOf('--limit');
|
|
const limitVal = limitIdx >= 0 && limitIdx + 1 < args.length ? parseInt(args[limitIdx + 1], 10) : undefined;
|
|
const result = await runReindexMultimodal(engine, {
|
|
limit: Number.isFinite(limitVal as number) ? (limitVal as number) : undefined,
|
|
dryRun: args.includes('--dry-run'),
|
|
costEstimate: args.includes('--cost-estimate'),
|
|
noEmbed: args.includes('--no-embed'),
|
|
json: args.includes('--json'),
|
|
yes: args.includes('--yes'),
|
|
});
|
|
if (args.includes('--json')) {
|
|
console.log(JSON.stringify(result, null, 2));
|
|
} else {
|
|
console.log(`reindex --multimodal: ${result.reembedded} re-embedded, ${result.failed} failed, ${result.pending_after} pending. est. cost: $${result.cost_usd_estimate.toFixed(2)}`);
|
|
}
|
|
break;
|
|
}
|
|
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 'edges-backfill': {
|
|
// v0.34 W6 — operator escape hatch for the symbol-resolution backfill.
|
|
// Resumable via the edges_backfilled_at watermark; per-batch transactions
|
|
// commit so Ctrl-C leaves a clean resumable state.
|
|
const { runEdgesBackfill } = await import('./commands/edges-backfill.ts');
|
|
await runEdgesBackfill(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 'brainstorm': {
|
|
// v0.37.0 (Open Collider wave): bisociation idea generator grounded
|
|
// in the user's own brain. Prefix-stratified domain-bank (D14) +
|
|
// shared judges + citation transparency (D6). LSD MCP exposure
|
|
// deferred to D7; this is CLI-only.
|
|
const { runBrainstormCommand } = await import('./commands/brainstorm.ts');
|
|
await runBrainstormCommand(engine, args);
|
|
break;
|
|
}
|
|
case 'lsd': {
|
|
// v0.37.0 — Lateral Synaptic Drift. Inverted-judge / stale-bias
|
|
// variant of brainstorm. Shares the orchestrator + judges via
|
|
// LSD_PROFILE config. Local-only by design (cost + weirdness gate).
|
|
const { runLsdCommand } = await import('./commands/lsd.ts');
|
|
await runLsdCommand(engine, args);
|
|
break;
|
|
}
|
|
case 'calibration': {
|
|
// v0.36.1.0 (T7): print/regenerate the active calibration profile.
|
|
// MCP op `get_calibration_profile` (read-scoped) backs the same data path.
|
|
const { runCalibration } = await import('./commands/calibration.ts');
|
|
const calibrationConfig = loadConfig() ?? ({} as never);
|
|
await runCalibration(engine, args, calibrationConfig);
|
|
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 'founder': {
|
|
// v0.35.4 (T7) — founder scorecard. `gbrain founder scorecard <slug>`
|
|
// rolls up Phase 2's typed-claim substrate into the four scorecard
|
|
// metrics (claim accuracy, consistency, growth trajectory, red flags).
|
|
// Thin-client routing handled inside the command file.
|
|
const { runFounder } = await import('./commands/founder-scorecard.ts');
|
|
await runFounder(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. 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.
|
|
// v0.37.6.0: exported so `test/ai/build-gateway-config.test.ts` can pin the
|
|
// env-baseURL passthrough contract for every `_BASE_URL` env var the CLI
|
|
// reads (LLAMA_SERVER, OLLAMA, LMSTUDIO, LITELLM, OPENROUTER).
|
|
export 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.37 fix wave (CDX2-5+6): ZE became the default provider in v0.36 but
|
|
// the env-mapping at this seam never picked it up. `gbrain config set
|
|
// zeroentropy_api_key X` wrote DB plane (ignored by gateway). The file-
|
|
// plane field now exists (GBrainConfig type) and gets mapped here, so
|
|
// setting it via `~/.gbrain/config.json` propagates into the gateway.
|
|
if (c.zeroentropy_api_key) envFromConfig.ZEROENTROPY_API_KEY = c.zeroentropy_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;
|
|
if (process.env.OPENROUTER_BASE_URL) envBaseUrls['openrouter'] = process.env.OPENROUTER_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);
|
|
});
|