Files
gbrain/src/cli.ts
T
55ca4984b2 feat: v0.17.0 — gbrain dream + runCycle primitive (one cycle, two CLIs) (#321)
* fix(sync): honor --dry-run in full-sync path + expose embedded count

Precondition for v0.17 brain maintenance cycle (runCycle primitive).

The full-sync path (performFullSync) previously called runImport() even
when opts.dryRun was true, silently writing to the DB and advancing
sync.last_commit. `gbrain sync --dry-run` on a fresh brain (or with
--full) would mutate state without warning.

Fix:
  - performFullSync now early-returns a `dry_run` SyncResult when
    opts.dryRun is set. Walks the repo via collectMarkdownFiles +
    isSyncable to count what WOULD be imported. No writes, no git
    state advance.
  - SyncResult gains an `embedded: number` field (required). Tracks
    pages re-embedded during the sync's auto-embed step. Existing
    return sites set 0; the synced + first_sync paths set real counts
    (best-estimate until commit 2 sharpens runEmbedCore's return type).
  - first_sync path now returns real added + chunksCreated counts
    from runImport instead of hardcoded zeros.
  - printSyncResult shows embedded count in human output.

Tests (test/sync.test.ts, new `performSync dry-run never writes`
block, PGLite + temp git repo, no DATABASE_URL required):
  - first-sync --dry-run: no pages, no sync.last_commit
  - incremental --dry-run after real sync: bookmark unchanged
  - --full --dry-run: no reimport, bookmark unchanged
  - SyncResult.embedded is a number

Codex outside-voice caught this. Would have shipped silent DB writes
on dry-run for anyone using `gbrain sync --dry-run --full`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(embed): add dry-run mode + return EmbedResult with counts

Precondition for v0.17 brain maintenance cycle (runCycle primitive).

runEmbedCore previously returned Promise<void> and had no dry-run mode.
That made it impossible for runCycle to (a) report accurate embedded
counts or (b) honor --dry-run without also skipping the entire embed
phase (which would have required runCycle to know embed's internal
semantics — a layering violation).

Changes:
  - EmbedOpts gains `dryRun?: boolean`. When set, embedPage and
    embedAll enumerate stale chunks (or would-be-created chunks for
    unchunked pages, via local chunkText without engine.upsertChunks)
    but never call embedBatch and never write to the engine.
  - runEmbedCore: Promise<void> -> Promise<EmbedResult>. Result shape:
    { embedded, skipped, would_embed, total_chunks, pages_processed,
      dryRun }.
    embedded = chunks newly embedded (0 in dryRun).
    would_embed = chunks that WOULD be embedded (0 in non-dryRun).
    skipped = chunks with pre-existing embeddings.
  - runEmbed CLI wrapper honors --dry-run flag and returns the result
    through. `gbrain embed --stale --dry-run` is now a safe preview.
  - Callers ignoring the return value (sync auto-embed, autopilot
    inline fallback, jobs.ts handlers, CLI) keep compiling — the new
    return type is additive for `await` callers.

Tests (test/embed.test.ts, new `runEmbedCore --dry-run` block, uses
the existing mock.module embedBatch pattern, no API key required):
  - dry-run --all: zero embedBatch calls, zero upsertChunks calls,
    would_embed matches stale chunk total
  - dry-run --stale correctly splits stale vs already-embedded counts
  - dry-run --slugs on a single page tallies per-chunk counts
  - non-dry-run regression guard: embedded count matches across
    concurrent workers

Codex outside-voice flagged the Promise<void> return as a blocker for
accurate CycleReport.totals.pages_embedded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(orphans): engine-injected queries, drop db.getConnection() global

Precondition for v0.17 brain maintenance cycle (runCycle primitive).

findOrphans + queryOrphanPages previously reached into the postgres-js
singleton via db.getConnection(), which (a) didn't compose with
runCycle's explicit-engine contract and (b) was wrong for PGLite test
fixtures and for any caller not using the default global connection.
Codex outside-voice flagged this as a blocker.

Changes:
  - BrainEngine interface gains findOrphanPages() — returns pages with
    no inbound links via the same NOT EXISTS anti-join. Implemented on
    both postgres-engine (sql tag) and pglite-engine (db.query).
  - findOrphans signature: findOrphans(engine, { includePseudo }).
    Engine is required. Uses engine.findOrphanPages() and
    engine.getStats().page_count instead of raw SQL + global counts.
  - queryOrphanPages signature: queryOrphanPages(engine). Delegates to
    engine.findOrphanPages().
  - src/commands/orphans.ts drops the `import * as db` — no more
    global-state coupling.
  - Callers updated: src/core/operations.ts find_orphans handler now
    passes ctx.engine through; runOrphans CLI entry uses its engine arg.
  - No signature change needed in cli.ts (it was already passing engine
    via CLI_ONLY dispatch).

Tests (test/orphans.test.ts, new `findOrphans (engine-injected)`
describe block, PGLite in-memory, no DATABASE_URL required):
  - links correctly scope orphans (alice links to bob -> bob not
    an orphan; alice is)
  - includePseudo:true surfaces _atlas-style pages
  - queryOrphanPages delegates to passed engine
  - empty brain returns {orphans: [], total_pages: 0} without crashing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cycle): add runCycle primitive in src/core/cycle.ts

The brain maintenance cycle as a single function. Six phases in
semantically-driven order (fix files → sync → extract → embed →
report orphans). Pure composition of existing library calls — no
execSync, no subprocess anti-patterns, no regex-parsed output.

    ┌───────────────────────────────────────────────────┐
    │ runCycle(engine, opts) → CycleReport              │
    │   Phase 1: lint --fix         (fs writes)         │
    │   Phase 2: backlinks --fix    (fs writes)         │
    │   Phase 3: sync               (DB picks up 1+2)   │
    │   Phase 4: extract            (DB picks up links) │
    │   Phase 5: embed --stale      (DB writes)         │
    │   Phase 6: orphans            (DB read, report)   │
    └───────────────────────────────────────────────────┘

Why the commit-4 primitive:

  - CEO + Eng + Codex reviews all converged on "extract one cycle
    function, wire both dream and autopilot through it." Two CLIs,
    one definition of what the brain does overnight.
  - Phase order was wrong in PR #309's original dream.ts (sync
    before lint+backlinks lost the "fix files, then index them"
    semantic).
  - This commit is the bisectable foundation; commit 5 (dream)
    and commit 6 (autopilot+jobs) just call into it.

Coordination — the codex-flagged blocker:

Session-scoped pg_try_advisory_lock does not survive PgBouncer
transaction pooling (the v0.15.4 fix made pooled connections the
default). Replaced with a DB lock table (gbrain_cycle_locks) that
works through every pooler:

  - Acquire: INSERT ... ON CONFLICT DO UPDATE ... WHERE ttl < NOW()
  - Refresh: UPDATE ttl_expires_at between phases via hook
  - Release: DELETE in finally{}
  - TTL: 30 min; crashed holders auto-release

PGLite / engine=null path uses a file lock at ~/.gbrain/cycle.lock
with PID liveness check. kill(pid, 0) with EPERM treated as alive
(so init/launchd-pid holders aren't mis-classified as stale).

Lock-skip: only phases that mutate state (lint, backlinks, sync,
extract, embed) trigger lock acquisition. orphans is read-only.
Single-phase --phase orphans runs never block on a held lock.

Engine-null mode preserved: filesystem phases run, DB phases skip
with {status:'skipped', reason:'no_database'}. Matches current
dream's capability that would have been lost if runCycle required
a connected engine.

Contract details:

  - CycleReport has schema_version:"1" (stable, additive) so agents
    consuming --json can rely on the shape
  - status: 'ok' | 'clean' | 'partial' | 'skipped' | 'failed'.
    'clean' = ran successfully with zero activity; agents trivially
    detect a healthy brain.
  - PhaseResult.error: { class, code, message, hint?, docs_url? }
    (Stripe-API-tier structured failure info) when status='fail'
  - yieldBetweenPhases hook: awaited between EVERY phase and before
    return, runs even after phase failure, exceptions logged but
    non-fatal. Required so the Minions autopilot-cycle handler can
    renew its job lock between phases (prevents the v0.14 stall-death
    regression codex flagged).
  - git pull explicit: opts.pull defaults to false (cron-safe).
    Autopilot daemon callers opt in if user configured it.
  - extract phase doesn't have a dry-run mode in the underlying
    library function, so runCycle honestly skips extract when
    dryRun=true (status:'skipped', reason:'no_dry_run_support').

Schema migration v16: gbrain_cycle_locks table + idx_cycle_locks_ttl.
Also appended to src/schema.sql and src/core/pglite-schema.ts for
fresh installs. schema-embedded.ts regenerated via build:schema.

Tests (test/core/cycle.test.ts, PGLite in-memory + mocked library
functions, no DATABASE_URL required):

  - dryRun × phases matrix: dryRun:true reaches lint/backlinks/sync/
    embed; extract is honestly skipped
  - Phase selection: default runs all 6 in order; --phase lint runs
    only lint; --phase orphans runs only orphans
  - Lock semantics: acquire + release on mutating phases, skip
    entirely for read-only selections
  - cycle_already_running: seeded live-holder lock → status:skipped,
    zero phase runs; TTL-expired holder → auto-claimed
  - Engine null: filesystem phases run, DB phases skip
  - File lock (engine=null) blocks when PID 1 holds lock with fresh
    mtime — exercises the PID liveness branch including EPERM
  - Status derivation: 'ok' vs 'clean' vs 'partial' vs 'skipped'
  - yieldBetweenPhases called N times, hook exceptions non-fatal

Next: commit 5 rewrites dream.ts as a thin CLI alias over runCycle,
commit 6 migrates autopilot daemon + jobs.ts handler to delegate to
runCycle too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(dream): add gbrain dream CLI as a thin alias over runCycle

`gbrain dream` is the README brand-promise command: "the agent runs
while I sleep, the dream cycle ... I wake up and the brain is smarter."
Cron-friendly, JSON-reportable, phase-selectable. Same maintenance
cycle as `gbrain autopilot`, just scheduled differently — both
converge on runCycle (added in commit 4) so there's one source of
truth for what happens overnight.

Contract:
  gbrain dream                       # full 6-phase cycle
  gbrain dream --dry-run             # preview, no writes
  gbrain dream --json                # CycleReport JSON (agent-readable)
  gbrain dream --phase <name>        # single-phase run
  gbrain dream --pull                # git pull before syncing
  gbrain dream --dir /path/to/brain  # explicit brain location

Cron: 0 2 * * * gbrain dream --json >> /var/log/gbrain-dream.log

Behavior details:
  - Brain-dir resolution: requires explicit --dir OR sync.repo_path
    in engine config. No more walk-up-cwd-for-.git footgun that
    PR #309's original dream.ts had (would lint unrelated git repos).
  - engine=null mode preserved via cli.ts's try/catch around
    connectEngine — filesystem phases (lint, backlinks) still run
    without a DB, DB phases report skipped/no_database in the output.
  - status=clean prints "Brain is healthy. N phase(s) checked in Ns."
    status=skipped prints the reason (cycle_already_running, etc.).
    Partial/failed prints the phase-by-phase detail.
  - Exit code 1 when status=failed (cron spots real problems).
    'partial' is not a failure — warnings shouldn't page you.
  - --help text cross-references `autopilot --install` for users
    who want continuous maintenance as a daemon.

CLI registration (src/cli.ts):
  - 'dream' added to CLI_ONLY
  - handleCliOnly has a pre-engine branch mirroring doctor's pattern:
    try connectEngine() → ok path; catch → runDream(null, args) so
    filesystem phases still run when DB is down
  - Help text updated with one-line dream entry and autopilot cross-ref

Tests (test/dream.test.ts, real PGLite + real library calls, no mocks
to avoid `mock.module` leakage across test files):
  - brainDir resolution: explicit --dir wins, engine config fallback,
    missing + nonexistent errors
  - phase selection: --phase lint|orphans produces single-phase report
  - phase validation: --phase garbage exits 1
  - output: --json parses as CycleReport with schema_version:"1"
  - human output mentions "Brain is healthy" on clean status
  - dry-run: cycle runs but DB stays untouched
  - exit code: clean/ok/partial do not call process.exit

Also (test/core/cycle.test.ts): refactored to use beforeAll/afterAll
with one shared PGLite engine per describe + truncateCycleLocks
between tests. Cuts test time from ~11s to ~4s; avoids the 15-migration
penalty per test that was causing parallel-suite timeout flakes.

Co-Authored-By: Wintermute <wintermute@garrytan.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: v0.17.0 — autopilot + jobs delegate to runCycle (unifies the cycle)

Autopilot daemon (`--inline` path) and Minions `autopilot-cycle`
handler both now delegate to `runCycle` (introduced in commit 4).
Three callers, one cycle definition:

  1. `gbrain dream`                        — one-shot cron cycle
  2. `gbrain autopilot` daemon inline path — scheduled cycles
  3. `autopilot-cycle` Minions handler     — durable queue with retry

All three share:
  - Same 6 phases in same order (lint → backlinks → sync → extract →
    embed → orphans)
  - Same DB lock table coordination (`gbrain_cycle_locks`)
  - Same yieldBetweenPhases discipline (prevents v0.14 stall-death)
  - Same structured CycleReport output

Autopilot inline path gains lint + orphan sweep that the old path
skipped. Minions autopilot-cycle handler also gains lint + orphans.
Users who run `gbrain autopilot --install` see 6-phase reports in
`gbrain jobs get <id>` starting on next interval. No config change
required.

Changes:
  - `src/commands/autopilot.ts`: inline fallback path (~20 lines)
    replaces the ~22-line sync+extract+embed sequence with a single
    runCycle call. Uses pull:true (matches pre-v0.17 autopilot
    behavior). Uses setImmediate yield hook. Status/failure reporting
    derives from CycleReport.status. `--help` cross-references `gbrain
    dream` for one-shot use.
  - `src/commands/jobs.ts:579` (`autopilot-cycle` handler): replaces
    the 4-step try/catch sequence with a runCycle call. Returns
    `{ partial, status, report }` so `gbrain jobs get <id>` shows the
    full structured CycleReport. Preserves partial-failure semantic
    (one phase failing does NOT throw; next cycle still runs).
    yieldBetweenPhases yields the event loop between phases for the
    worker's lock-renewal timer.

Release scaffolding:
  - VERSION: 0.16.0 → 0.17.0
  - CHANGELOG.md: v0.17.0 entry in GStack voice — headline, numbers
    table, "what this means" paragraph, "To take advantage" block
    per CLAUDE.md post-ship rules. Itemized changes below the fold.
    Credit to @Wintermute for the original PR #309 thesis.
  - skills/migrations/v0.17.0.md: documents what changed for
    upgrading users. No mechanical action required — schema migration
    v16 (cycle locks table) + handler delegation both apply
    automatically. Includes opt-out paths for users who don't want
    their daemon modifying files (use `dream --phase orphans` in cron
    and skip autopilot-install, or other explicit configs).
  - CLAUDE.md: new entries for `src/core/cycle.ts` and
    `src/commands/dream.ts` with contract details.

Tests: no new test file needed for this commit — the cycle primitive
is extensively tested in test/core/cycle.test.ts (18 cases), dream
in test/dream.test.ts (11), and autopilot's delegation is mechanical
(calls runCycle with specific opts). The handler contract is covered
implicitly: if runCycle returns a CycleReport, the handler wraps it
in `{ partial, status, report }` — nothing else to assert.

Verified:
  - `bun test test/autopilot-install.test.ts test/autopilot-resolve-cli.test.ts test/core/cycle.test.ts test/dream.test.ts` → 37 pass, 0 fail

Completes the v0.17.0 feature: 6 bisectable commits on one branch
(garrytan/v0.17-dream-cycle), ready to push as one PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): add runCycle + dream E2E coverage against real Postgres

Gap from the v0.17 commit series: PR #321 shipped unit-level tests
for runCycle (test/core/cycle.test.ts) and dream (test/dream.test.ts)
but no E2E coverage that exercises the real Postgres paths. Filling
that in before merge.

  test/e2e/cycle.test.ts (6 cases):
    - schema migration v16 created gbrain_cycle_locks + index
    - dry-run full cycle: zero DB writes + lock table empty after
    - live cycle: pages + chunks materialize, sync.last_commit set
    - concurrent cycle blocked by lock → status:'skipped'
    - TTL-expired lock auto-claimed (crashed-holder recovery)
    - --phase orphans skips lock entirely (read-only optimization)

  test/e2e/dream.test.ts (3 cases):
    - dream --dry-run --json emits valid CycleReport + DB stays empty
    - dream (no --dry-run) syncs pages into real DB
    - dream --phase orphans doesn't touch the cycle-lock table

Both files mock embedBatch via mock.module so the embed phase never
calls OpenAI even when the full 6-phase cycle runs (zero API cost,
zero flakiness from network calls).

Verified locally:
  - `docker run pgvector/pgvector:pg16` on port 5434
  - `DATABASE_URL=... bun test test/e2e/cycle.test.ts test/e2e/dream.test.ts` → 9 pass, 0 fail
  - Full E2E suite (`bun run test:e2e`): 16 files, 150 tests, 0 fail
  - Container torn down after: `docker stop + rm gbrain-test-pg`

Per CLAUDE.md E2E test DB lifecycle. These tests skip gracefully when
DATABASE_URL isn't set (via hasDatabase() helper + describe.skip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Wintermute <wintermute@garrytan.com>
2026-04-22 08:23:24 -07:00

620 lines
22 KiB
TypeScript

#!/usr/bin/env bun
import { readFileSync } from 'fs';
import { loadConfig, toEngineConfig } from './core/config.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 { 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', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'dream', 'check-resolvable']);
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 (subArgs.includes('--help') || subArgs.includes('-h')) {
const op = cliOps.get(command);
if (op) {
printOpHelp(op);
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);
}
const engine = await connectEngine();
try {
const params = parseOpArgs(op, subArgs);
// Validate required params before calling handler
for (const [key, def] of Object.entries(op.params)) {
if (def.required && params[key] === undefined) {
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);
}
}
const ctx = makeContext(engine, params);
const result = await op.handler(ctx, params);
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 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;
}
function makeContext(engine: BrainEngine, params: Record<string, unknown>): OperationContext {
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(),
};
}
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';
}
}
async function handleCliOnly(command: string, args: string[]) {
// Commands that don't need a database connection
if (command === 'init') {
const { runInit } = await import('./commands/init.ts');
await runInit(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 === '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 === '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 === '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') {
// 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 === '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;
}
// 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);
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': {
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 '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 'orphans': {
const { runOrphans } = await import('./commands/orphans.ts');
await runOrphans(engine, args);
break;
}
}
} finally {
if (command !== 'serve') await engine.disconnect();
}
}
async function connectEngine(): Promise<BrainEngine> {
const config = loadConfig();
if (!config) {
console.error('No brain configured. Run: gbrain init');
process.exit(1);
}
const { createEngine } = await import('./core/engine-factory.ts');
const engine = await createEngine(toEngineConfig(config));
await engine.connect(toEngineConfig(config));
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
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
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/
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
serve MCP server (stdio)
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);
});