Files
gbrain/src/core/operations.ts
T
a4df40fe5c feat: v0.15.2 - bulk-action progress streaming (stderr reporter, agent-visible heartbeats) (#293)
* feat(progress): step 1 - shared ProgressReporter + CliOptions

Adds the foundation for v0.14.2's bulk-action progress streaming work:

- src/core/progress.ts: dependency-free reporter with auto/human/json/quiet
  modes, TTY-aware rendering, time+item rate gating, heartbeat helper for
  slow single queries, dot-composed child phases, EPIPE defense (both sync
  throw and async 'error' event), and a singleton module-level signal
  coordinator so SIGINT/SIGTERM emits abort events for all live phases
  without leaking per-instance listeners.

- src/core/cli-options.ts: parseGlobalFlags() for --quiet /
  --progress-json / --progress-interval=<ms> (both space and = forms),
  plus cliOptsToProgressOptions() that resolves to the right mode. Non-TTY
  default is human-plain one-line-per-event; JSON is explicit opt-in so
  shell pipelines don't suddenly see structured noise.

- test/progress.test.ts (17 cases): mode resolution, rate gating, no-fake-
  totals on heartbeat paths, EPIPE paths, SIGINT singleton, child phase
  composition.

- test/cli-options.test.ts (14 cases): flag parsing, invalid values,
  interleaved flags, mode resolution.

Follow-ups wire doctor/embed/files/export/extract/import/sync/migrate/
repair-jsonb/backlinks/orphans/lint/integrity/eval/autopilot/jobs plus
the apply-migrations orchestrators through this reporter, and route
Minion handlers to job.updateProgress instead of stderr. See the plan
in ~/.claude/plans/.

1682 unit tests pass.

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

* feat(progress): step 2 - wire global flags into cli.ts

Parse --quiet / --progress-json / --progress-interval from argv BEFORE
command dispatch, strip them, stash resolved CliOptions on a module-level
singleton (same pattern as Commander's program.opts()) and on every
OperationContext created for shared-op dispatch.

- src/cli.ts: parseGlobalFlags(rawArgs) at the top of main(); setCliOptions
  once; dispatch sees only the stripped argv. Fixes the "gbrain
  --progress-json doctor" unknown-command case that Codex flagged.
- src/core/cli-options.ts: expose setCliOptions/getCliOptions/
  _resetCliOptionsForTest singleton. Commands that want progress call
  getCliOptions() to construct their reporter.
- src/core/operations.ts: OperationContext gains optional cliOpts field
  so shared-op handlers (and MCP-invoked ops that need a reporter) can
  read the same settings. MCP callers leave it undefined and consumers
  default to quiet.
- test/cli-options.test.ts: +4 cases covering singleton round-trip and
  an integration smoke spawning `bun src/cli.ts --progress-json --version`
  to prove the global flag survives dispatch.

45 relevant unit tests pass (progress + cli-options + cli.test.ts).

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

* feat(progress): step 3a - doctor + orphans heartbeat streaming

Doctor on a 52K-page brain used to sit silent for 10+ minutes while the
DB checks ran, then get killed by an agent timeout. Wired through the
new reporter so agents see which check is running and the slow ones
heartbeat every second.

doctor.ts:
- Start a single `doctor.db_checks` phase around the DB section, with a
  per-check heartbeat before each step (connection, pgvector, rls,
  schema_version, embeddings, graph_coverage, integrity, jsonb_integrity,
  markdown_body_completeness).
- jsonb_integrity now scans 5 targets, not 4: added page_versions.
  frontmatter so the check surface matches `repair-jsonb` (per Codex
  review of the plan — the old 4-target scan missed a known repair site).
  Per-target heartbeat so 50K-row scans show incremental progress.
- markdown_body_completeness: wrap the existing query in a 1s heartbeat
  timer. The regex scan over rd.data ->> 'content' can't be paginated
  usefully; this just lets agents see life during the sequential scan.
  No fake totals — the LIMIT 100 query has no meaningful total count.
- integrity sample: same heartbeat pattern around the 500-page scan.

orphans.ts:
- findOrphans() wraps the NOT EXISTS anti-join in a 1s heartbeat.
  Keyset pagination was considered and rejected: without an index on
  links.to_page_id it's no faster than the full scan, and may re-plan
  the anti-join per batch. A schema migration adding that index is the
  right fix and is queued for v0.14.3.

Follow-ups:
- Step 3b: wire embed/files/export (the \r-only stdout offenders).
- Step 5: end-to-end progress test spawning `gbrain doctor --progress-json`
  against a fixture brain, asserting stderr events and clean stdout.

All existing unit tests continue to pass (76/76 in doctor + orphans +
progress + cli-options).

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

* feat(progress): step 3b - embed + files + export stderr progress

Replaces the \r-on-stdout progress pattern in the three worst offenders
(embed, files sync, export) with the shared reporter on stderr. Stdout
now carries only final summaries, so scripts and tests that grep for
counts ("Embedded N chunks", "Files sync complete", "Exported N pages")
still work when output is piped.

- embed.ts: runEmbedCore accepts an optional onProgress callback. The
  CLI wrapper builds a reporter and passes reporter.tick(); Minion
  handlers will pass job.updateProgress in Step 4. Worker-pool is
  single-threaded JS so no rate-gate race (per Codex review #18).
- files.ts syncFiles(): tick per file; summary preserved on stdout.
- export.ts: tick per page; summary preserved on stdout.

Also fixes a --quiet flag collision. `skillpack-check` has its own
--quiet mode (suppress all stdout). parseGlobalFlags strips --quiet
globally now, and skillpack-check reads the resolved CliOptions
singleton via getCliOptions() instead of re-parsing argv. Test updated
to match the stripping behavior.

1686 unit tests pass.

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

* feat(progress): step 3c - extract + import + sync reporter streaming

Extract, import, and sync now stream per-file progress to stderr through
the shared reporter. All three kept their stdout summaries + JSON
action-events intact so existing tests + agent scripts are unaffected.

- extract.ts (4 paths: links/timeline × fs/db): replaced the ad-hoc
  `process.stderr.write({event:"progress"...})` lines with reporter
  ticks. Same channel (stderr), canonical schema now, visible in both
  text and --json modes. Stdout action-events (`add_link` /
  `add_timeline`) untouched — tests grep them.
- import.ts: the logProgress() function that printed every 100 files to
  stdout is now a progress.tick() call per file. Rate-gated by the
  reporter. Stdout still gets the final "Import complete (Xs)" summary
  and the --json payload.
- sync.ts: three new phases (`sync.deletes`, `sync.renames`,
  `sync.imports`) tick per file, so big syncs show each step rather than
  a single end-of-run summary. Phase hierarchy ready to be child()-chained
  into runImport / runEmbed later, per Codex review #26.

Updated the #132 nested-transaction regression test in test/sync.test.ts
to also accept the new hoisted-loop shape — the guarantee (this loop is
not wrapped in engine.transaction) still holds.

1686 unit tests pass.

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

* feat(progress): step 3d - migrate/repair/backlinks/lint/integrity/eval

Wires the remaining bulk commands through the reporter:

- migrate-engine: phase starts (migrate.copy_pages, migrate.copy_links),
  per-page tick. Old \"Progress: N/total\" stdout logs replaced by
  stderr ticks; final stdout summary preserved.
- repair-jsonb: per-column start + a heartbeat timer while each UPDATE
  runs (minutes on 50K-row tables). CRITICAL: stdout stays clean so
  migrations/v0_12_2.ts's JSON.parse(child.stdout) still works. Per
  Codex review #12.
- backlinks: 1s heartbeat around findBacklinkGaps() (sync double-walk
  of the brain dir).
- lint: tick per page; per-issue stdout output preserved.
- integrity auto: tick per page in the main resolver loop. The separate
  ~/.gbrain/integrity-progress.jsonl resume marker is untouched (its
  role shifts from live progress reporting to resume-only).
- eval: add an onProgress option to core's runEval(), CLI wraps with a
  reporter. Phases: eval.single / eval.ab. Tick per query.

core/search/eval.ts gains a RunEvalOptions type so future callers (MCP
eval op, Minion handlers) can also hook in without the reporter.

1686 unit tests pass.

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

* feat(progress): step 3e - onProgress callbacks on core libs

- src/core/embedding.ts: embedBatch() gains an optional
  EmbedBatchOptions.onBatchComplete callback, fired after each 100-item
  sub-batch. CLI wrappers pass reporter.tick; Minion handlers can pass
  job.updateProgress.
- src/core/enrichment-service.ts: enrichEntities() config gains
  onProgress(done, total, name) fired after each entity. Same split:
  CLI -> reporter, Minion -> DB-backed progress.

No CLI behavior change on its own. Wiring these callbacks into the
Minion handlers is Step 4.

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

* feat(progress): step 4 - orchestrators + upgrade + minion handlers

- cli-options.ts: childGlobalFlags() returns the flag suffix to append
  to child gbrain subprocesses. Empty string by default, " --quiet
  --progress-json" when the parent has them set, so child behavior
  inherits the parent's progress-mode without scattering string-concat
  logic across every execSync site.

- migrations/v0_12_2.ts: each execSync inherits the parent's global
  flags. Phase C (repair-jsonb --dry-run --json) pins explicit stdio to
  ['ignore','pipe','inherit'] so child stderr streams straight through
  while stdout stays captured for JSON.parse. Per Codex review #12.
- migrations/v0_12_0.ts + v0_11_0.ts: same childGlobalFlags wiring at
  each gbrain-subcommand execSync.

- upgrade.ts: post-upgrade timeout bumped 300s → 30min (1_800_000 ms)
  with GBRAIN_POST_UPGRADE_TIMEOUT_MS override. The old 300s cap killed
  v0.12.0 graph-backfill migrations on 50K+ brains; the heartbeat
  wiring added in v0.14.2 makes long waits observable, so a generous
  ceiling no longer means users stare at a silent terminal.

- jobs.ts: the embed Minion handler passes job.updateProgress as the
  onProgress callback, so per-job progress is durable in minion_jobs
  and readable via `gbrain jobs get <id>`. Primary Minion progress
  channel is DB-backed — stderr from `jobs work` stays coarse for
  daemon liveness only. Per Codex review #20.

1686 unit tests pass.

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

* feat(progress): step 5 - E2E doctor-progress test + CI guard

scripts/check-progress-to-stdout.sh greps src/ for the banned
`process.stdout.write('\r…')` pattern that v0.14.2 removed from the
bulk-action codepaths. Wired into the `bun run test` script so any
future regression that puts progress back on stdout fails fast. An
empty allowlist documents the position: every known call site was
migrated; new exceptions need a rationale in the allowlist.

test/e2e/doctor-progress.test.ts (Tier 1, needs Postgres + pgvector):
- `gbrain --progress-json doctor --json`: stderr carries JSONL progress
  events with the canonical {event, phase, ts} shape, starts + finishes
  for `doctor.db_checks`. Stdout stays parseable JSON — no progress
  pollution.
- `gbrain doctor` (no flag): human-plain progress goes to stderr only,
  stdout stays free of `[doctor.db_checks]`.
- `gbrain --quiet doctor`: reporter emits nothing; doctor still runs to
  completion.

test/cli-options.test.ts: +2 spawning integration tests. One verifies
`gbrain --progress-json --version` keeps stdout clean of progress events
(single-shot commands that don't use a reporter aren't affected). One
guards the skillpack-check --quiet regression — --quiet suppresses
stdout by reading the resolved CliOptions singleton, not re-parsing argv.

Full test matrix:
  bun run test           -> 1726 pass / 184 skipped (no DB) / 0 fail
  bun run test:e2e       -> 136 pass / 13 skipped / 0 fail

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

* feat(progress): step 6 - docs + v0.14.2 release bump

- VERSION + package.json bumped to 0.14.2.
- docs/progress-events.md (new): canonical JSON event schema reference.
  Stable from v0.14.2, additive only. Lists every phase name shipped
  in this release, the five event types (start/tick/heartbeat/finish/
  abort), the TTY/non-TTY rendering rules, subprocess inheritance
  semantics, and the Minion DB-backed progress model.
- CLAUDE.md: "Bulk-action progress reporting" section under the build
  instructions; Key files entries for src/core/progress.ts,
  src/core/cli-options.ts, scripts/check-progress-to-stdout.sh, and
  docs/progress-events.md; doctor.ts entry updated to note the v0.14.2
  5-target jsonb_integrity scan + heartbeat wiring.
- CHANGELOG.md v0.14.2: full release summary per project voice rules.
  The "numbers that matter" table, per-command before/after grid,
  backward-compat warnings for stdout→stderr moves, and an itemized
  changes section covering reporter/CLI plumbing/schema/Minion
  handlers/doctor fixes/upgrade timeout/CI guard/tests. No em dashes.
  Real file paths, real commands, real numbers.
- skills/migrations/v0.14.2.md (new): agent migration note. Mechanical
  step is "nothing" since v0.14.2 is purely additive. Walks agents
  through the three new global flags, the 14 wired commands, the event
  schema cheat sheet, Minion progress via job.updateProgress, and
  scripts/verification commands.

Full test matrix:
  bun run test (unit + guards) -> 1726 pass / 184 skipped / 0 fail
  bun run test:e2e (Postgres)  -> 141 pass / 8 skipped / 0 fail

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

* chore: bump version to 0.15.2, restore master's [0.14.2] CHANGELOG entry

Master sits at 0.14.2 (reliability wave). This PR lands on top as 0.15.2
(progress streaming wave). Splits the merge-time combined CHANGELOG entry
back into two discrete release sections so history stays honest:

- [0.15.2] = progress reporter, CliOptions, 14 wired commands, Minion
  embed handler, doctor jsonb_integrity 5-target fix, upgrade timeout bump,
  CI guard, progress unit+E2E tests.
- [0.14.2] = master's eight root-cause bug fixes, restored verbatim from
  origin/master.

Touched files:
- VERSION + package.json: 0.14.2 -> 0.15.2 (next patch off master).
- skills/migrations/v0.14.2.md -> skills/migrations/v0.15.2.md (rename
  + rewrite frontmatter + body to v0.15.2).
- CHANGELOG.md: split into two entries; progress-wave refs renamed
  v0.14.2 -> v0.15.2; reliability-wave entry restored from master.
- src/core/progress.ts, src/commands/doctor.ts, src/commands/sync.ts,
  src/commands/upgrade.ts, docs/progress-events.md, test/sync.test.ts:
  progress-wave v0.14.2 references -> v0.15.2. The remaining v0.14.2
  references in test/e2e/migration-flow.test.ts (Bug 3 context) and
  CLAUDE.md (reliability-wave key commands, Bug 3 ledger move) correctly
  point at master's 0.14.2 release.

Test matrix after version bump:
  bun run test -> 1780 pass / 179 skipped / 0 fail

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:54:13 -07:00

1301 lines
48 KiB
TypeScript

/**
* Contract-first operation definitions. Single source of truth for CLI, MCP, and tools-json.
* Each operation defines its schema, handler, and optional CLI hints.
*/
import { lstatSync, realpathSync } from 'fs';
import { resolve, relative, sep } from 'path';
import type { BrainEngine } from './engine.ts';
import { clampSearchLimit } from './engine.ts';
import type { GBrainConfig } from './config.ts';
import type { PageType } from './types.ts';
import { importFromContent } from './import-file.ts';
import { hybridSearch } from './search/hybrid.ts';
import { expandQuery } from './search/expansion.ts';
import { dedupResults } from './search/dedup.ts';
import { extractPageLinks, isAutoLinkEnabled, isAutoTimelineEnabled, parseTimelineEntries, makeResolver, type UnresolvedFrontmatterRef } from './link-extraction.ts';
import * as db from './db.ts';
// --- Types ---
export type ErrorCode =
| 'page_not_found'
| 'invalid_params'
| 'embedding_failed'
| 'storage_error'
| 'bucket_not_found'
| 'database_error';
export class OperationError extends Error {
constructor(
public code: ErrorCode,
message: string,
public suggestion?: string,
public docs?: string,
) {
super(message);
this.name = 'OperationError';
}
toJSON() {
return {
error: this.code,
message: this.message,
suggestion: this.suggestion,
docs: this.docs,
};
}
}
// --- Upload validators (Fix 1 / B5 / H5 / M4) ---
/**
* Validate an upload path. Two modes:
* - strict (remote=true): confines the resolved path to `root` and rejects symlinks.
* Used when the caller is untrusted (MCP over stdio/HTTP, agent-facing).
* - loose (remote=false): only verifies the file exists and is not a symlink whose
* target escapes the filesystem (no path traversal protection). Used for local CLI
* where the user owns the filesystem.
*
* Either way: symlinks in the final component are always rejected (prevents
* transparent redirection to a different file than the user typed).
*
* @param filePath caller-supplied path
* @param root confinement root (only used when strict=true)
* @param strict true → enforce cwd confinement (B5 + H1). false → allow any accessible path.
* @throws OperationError(invalid_params) on symlink escape, traversal, or missing file
*/
export function validateUploadPath(filePath: string, root: string, strict = true): string {
let real: string;
try {
real = realpathSync(resolve(filePath));
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('ENOENT')) {
throw new OperationError('invalid_params', `File not found: ${filePath}`);
}
throw new OperationError('invalid_params', `Cannot resolve path: ${filePath}`);
}
// Always reject final-component symlinks (basic safety for both modes).
try {
if (lstatSync(resolve(filePath)).isSymbolicLink()) {
throw new OperationError('invalid_params', `Symlinks are not allowed for upload: ${filePath}`);
}
} catch (e) {
if (e instanceof OperationError) throw e;
// lstat race with unlink — pass if realpath already succeeded.
}
if (!strict) return real;
// Strict mode: confine to root via realpath + path.relative (catches parent-dir symlinks per B5).
let realRoot: string;
try {
realRoot = realpathSync(root);
} catch {
throw new OperationError('invalid_params', `Confinement root not accessible: ${root}`);
}
const rel = relative(realRoot, real);
if (rel === '' || rel.startsWith('..') || rel.startsWith(`..${sep}`) || resolve(realRoot, rel) !== real) {
throw new OperationError('invalid_params', `Upload path must be within the working directory: ${filePath}`);
}
return real;
}
/**
* Allowlist validator for page slugs. Rejects URL-encoded traversal, backslashes,
* control chars, RTL overrides, Unicode lookalikes — anything outside the allowlist.
* Format: lowercase alphanumeric + hyphen segments separated by single forward slashes.
*/
export function validatePageSlug(slug: string): void {
if (typeof slug !== 'string' || slug.length === 0) {
throw new OperationError('invalid_params', 'page_slug must be a non-empty string');
}
if (slug.length > 255) {
throw new OperationError('invalid_params', 'page_slug exceeds 255 characters');
}
if (!/^[a-z0-9][a-z0-9\-]*(\/[a-z0-9][a-z0-9\-]*)*$/i.test(slug)) {
throw new OperationError('invalid_params', `Invalid page_slug: ${slug} (allowed: alphanumeric, hyphens, forward-slash separated segments)`);
}
}
/**
* Allowlist validator for uploaded file basenames. Rejects control chars, backslashes,
* RTL overrides (\u202E), leading dot (hidden files) and leading dash (CLI flag confusion).
* Allows extension dots and underscores. Max 255 chars.
*/
export function validateFilename(name: string): void {
if (typeof name !== 'string' || name.length === 0) {
throw new OperationError('invalid_params', 'Filename must be a non-empty string');
}
if (name.length > 255) {
throw new OperationError('invalid_params', 'Filename exceeds 255 characters');
}
if (!/^[a-zA-Z0-9][a-zA-Z0-9._\-]*$/.test(name)) {
throw new OperationError('invalid_params', `Invalid filename: ${name} (allowed: alphanumeric, dot, underscore, hyphen — no leading dot/dash, no control chars or backslash)`);
}
}
export interface ParamDef {
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
required?: boolean;
description?: string;
default?: unknown;
enum?: string[];
items?: ParamDef;
}
export interface Logger {
info(msg: string): void;
warn(msg: string): void;
error(msg: string): void;
}
export interface OperationContext {
engine: BrainEngine;
config: GBrainConfig;
logger: Logger;
dryRun: boolean;
/**
* True when the caller is remote/untrusted (MCP over stdio/HTTP, or any agent-facing entry point).
* False for local CLI invocations by the owner of the machine.
*
* Security-sensitive operations (e.g., file_upload) tighten their filesystem
* confinement when remote=true and allow unrestricted local-filesystem access
* when remote=false.
*
* When unset, operations MUST default to the stricter (remote=true) behavior.
*/
remote?: boolean;
/**
* Resolved global CLI options (--quiet / --progress-json / --progress-interval).
* CLI callers populate this from `getCliOptions()`. MCP / library callers
* may leave it undefined — consumers default to quiet/no-progress for
* background work.
*/
cliOpts?: { quiet: boolean; progressJson: boolean; progressInterval: number };
}
export interface Operation {
name: string;
description: string;
params: Record<string, ParamDef>;
handler: (ctx: OperationContext, params: Record<string, unknown>) => Promise<unknown>;
mutating?: boolean;
cliHints?: {
name?: string;
positional?: string[];
stdin?: string;
hidden?: boolean;
};
}
// --- Page CRUD ---
const get_page: Operation = {
name: 'get_page',
description: 'Read a page by slug (supports optional fuzzy matching)',
params: {
slug: { type: 'string', required: true, description: 'Page slug' },
fuzzy: { type: 'boolean', description: 'Enable fuzzy slug resolution (default: false)' },
},
handler: async (ctx, p) => {
const slug = p.slug as string;
const fuzzy = (p.fuzzy as boolean) || false;
let page = await ctx.engine.getPage(slug);
let resolved_slug: string | undefined;
if (!page && fuzzy) {
const candidates = await ctx.engine.resolveSlugs(slug);
if (candidates.length === 1) {
page = await ctx.engine.getPage(candidates[0]);
resolved_slug = candidates[0];
} else if (candidates.length > 1) {
return { error: 'ambiguous_slug', candidates };
}
}
if (!page) {
throw new OperationError('page_not_found', `Page not found: ${slug}`, 'Check the slug or use fuzzy: true');
}
const tags = await ctx.engine.getTags(page.slug);
return { ...page, tags, ...(resolved_slug ? { resolved_slug } : {}) };
},
cliHints: { name: 'get', positional: ['slug'] },
};
const put_page: Operation = {
name: 'put_page',
description: 'Write/update a page (markdown with frontmatter). Chunks, embeds, reconciles tags, and (when auto_link/auto_timeline are enabled) extracts + reconciles graph links and timeline entries.',
params: {
slug: { type: 'string', required: true, description: 'Page slug' },
content: { type: 'string', required: true, description: 'Full markdown content with YAML frontmatter' },
},
mutating: true,
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'put_page', slug: p.slug };
const slug = p.slug as string;
// Skip embedding when no OpenAI key is configured. importFromContent's existing
// try/catch around embed only catches; without a key the OpenAI client would
// attempt 5 retries with exponential backoff (up to ~2 minutes total) before
// giving up. Detect early.
const noEmbed = !process.env.OPENAI_API_KEY;
const result = await importFromContent(ctx.engine, slug, p.content as string, { noEmbed });
// Auto-link post-hook: runs AFTER importFromContent (which is its own
// transaction). Runs even on status='skipped' so reconciliation catches drift
// between the page text and the links table. Failures are non-blocking.
//
// SECURITY: skipped for remote (MCP) callers. Auto-link's bare-slug regex
// matches `people/X` etc. anywhere in page text, including code fences,
// quoted strings, and prompt-injected content. An untrusted page can plant
// arbitrary outbound links by including `see meetings/board-q1` in its body.
// Combined with the backlink boost in hybridSearch, attacker-placed targets
// would surface higher in search. Local CLI users (ctx.remote=false) opt
// into this behavior; MCP/remote writes do not.
let autoLinks:
| { created: number; removed: number; errors: number; unresolved: UnresolvedFrontmatterRef[] }
| { error: string }
| { skipped: 'remote' }
| undefined;
let autoTimeline: { created: number } | { error: string } | { skipped: 'remote' } | undefined;
if (ctx.remote === true) {
autoLinks = { skipped: 'remote' };
autoTimeline = { skipped: 'remote' };
} else if (result.parsedPage) {
try {
const enabled = await isAutoLinkEnabled(ctx.engine);
if (enabled) {
autoLinks = await runAutoLink(ctx.engine, slug, result.parsedPage);
}
} catch (e) {
autoLinks = { error: e instanceof Error ? e.message : String(e) };
}
// Timeline extraction mirrors auto-link: runs post-write, best-effort,
// never blocks the write. ON CONFLICT DO NOTHING in
// addTimelineEntriesBatch keeps it idempotent across re-writes, so a
// page that's edited and re-written won't duplicate its own timeline.
try {
const enabled = await isAutoTimelineEnabled(ctx.engine);
if (enabled) {
const fullContent = result.parsedPage.compiled_truth + '\n' + result.parsedPage.timeline;
const entries = parseTimelineEntries(fullContent);
if (entries.length > 0) {
const batch = entries.map(e => ({
slug,
date: e.date,
summary: e.summary,
detail: e.detail || '',
}));
const created = await ctx.engine.addTimelineEntriesBatch(batch);
autoTimeline = { created };
} else {
autoTimeline = { created: 0 };
}
}
} catch (e) {
autoTimeline = { error: e instanceof Error ? e.message : String(e) };
}
}
// Post-write validator lint (PR 2.5): feature-flag-gated, non-blocking.
// When `writer.lint_on_put_page` is enabled, runs the BrainWriter's
// validators on the freshly-written page and logs findings to
// ingest_log + ~/.gbrain/validator-lint.jsonl. Does NOT reject the
// write — that's the deferred strict-mode flip after the 7-day soak.
let writerLint: { error_count: number; warning_count: number } | { skipped: string } | undefined;
try {
const { runPostWriteLint } = await import('./output/post-write.ts');
const lint = await runPostWriteLint(ctx.engine, result.slug);
if (lint.ran) {
writerLint = {
error_count: lint.findings.filter(f => f.severity === 'error').length,
warning_count: lint.findings.filter(f => f.severity === 'warning').length,
};
} else if (lint.skippedReason) {
writerLint = { skipped: lint.skippedReason };
}
} catch {
// Non-fatal; never blocks put_page.
}
return {
slug: result.slug,
status: result.status === 'imported' ? 'created_or_updated' : result.status,
chunks: result.chunks,
...(autoLinks ? { auto_links: autoLinks } : {}),
...(autoTimeline ? { auto_timeline: autoTimeline } : {}),
...(writerLint ? { writer_lint: writerLint } : {}),
};
},
cliHints: { name: 'put', positional: ['slug'], stdin: 'content' },
};
/**
* Extract entity refs from a freshly-written page, sync the links table to match.
* Creates new links via addLink, removes stale ones (links present in DB but no
* longer referenced in content) via removeLink. Returns counts.
*
* Runs OUTSIDE importFromContent's transaction so it doesn't block the page write
* or get rolled back if a single link operation fails. Per-link failures are
* counted; the overall function never throws (catch in put_page handler covers
* extraction errors).
*/
async function runAutoLink(
engine: BrainEngine,
slug: string,
parsed: { type: PageType; compiled_truth: string; timeline: string; frontmatter: Record<string, unknown> },
): Promise<{ created: number; removed: number; errors: number; unresolved: UnresolvedFrontmatterRef[] }> {
const fullContent = parsed.compiled_truth + '\n' + parsed.timeline;
// Live-mode resolver: per-put throwaway cache, pg_trgm + optional search.
const resolver = makeResolver(engine, { mode: 'live' });
const { candidates, unresolved } = await extractPageLinks(
slug, fullContent, parsed.frontmatter, parsed.type, resolver,
);
// Resolve which targets exist (skip refs to non-existent pages to avoid FK
// violation churn in addLink). One getAllSlugs call upfront, O(1) lookup.
const allSlugs = await engine.getAllSlugs();
const valid = candidates.filter(c =>
allSlugs.has(c.targetSlug) && (!c.fromSlug || allSlugs.has(c.fromSlug))
);
// Split candidates by direction. Outgoing (fromSlug === slug or unset) are
// this page's own edges, reconciled against getLinks(slug). Incoming
// (fromSlug !== slug — frontmatter with `direction: incoming`) are edges
// where this page is the TO side; reconciled against getBacklinks(slug)
// but SCOPED to the frontmatter edges this page authored via
// (link_source='frontmatter' AND origin_slug = slug). We never touch
// frontmatter edges authored by OTHER pages.
const out = valid.filter(c => !c.fromSlug || c.fromSlug === slug);
const inc = valid.filter(c => c.fromSlug && c.fromSlug !== slug);
// Run getLinks + addLink/removeLink loops inside a single transaction so that
// concurrent put_page calls on the same slug can't race the reconciliation:
// without this, two simultaneous writes both read stale `existingKeys` and
// re-create links the other side just removed (lost-update).
//
// Row-level locks alone aren't enough: both writers can read the same
// `existingKeys` set BEFORE either mutates a row, so the union-of-writes
// race survives. A transaction-scoped advisory lock keyed on the slug
// hash serializes the entire reconciliation across processes. Falls
// through on engines that don't support pg_advisory_xact_lock (PGLite is
// single-process so there's no cross-process concern there anyway).
const result = await engine.transaction(async (tx) => {
try {
await tx.executeRaw(`SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, [`auto_link:${slug}`]);
} catch {
// engine doesn't support advisory locks — fall through
}
const existingOut = await tx.getLinks(slug);
// Incoming: we only look at frontmatter edges WE authored (origin_slug=slug).
// Non-frontmatter and other-page frontmatter edges survive untouched.
const existingInRaw = await tx.getBacklinks(slug);
const existingIn = existingInRaw.filter(
l => l.link_source === 'frontmatter' && l.origin_slug === slug,
);
// Reconcilable outgoing edges: markdown + our own frontmatter edges.
// Manual edges (link_source='manual') are NEVER touched by reconciliation.
const reconcilableOut = existingOut.filter(
l => l.link_source === 'markdown' || l.link_source == null ||
(l.link_source === 'frontmatter' && l.origin_slug === slug),
);
const outKeys = new Set(out.map(c =>
`${c.targetSlug}\u0000${c.linkType}\u0000${c.linkSource ?? 'markdown'}`
));
const incKeys = new Set(inc.map(c =>
`${c.fromSlug}\u0000${c.linkType}`
));
let created = 0, removed = 0, errors = 0;
// Add outgoing edges.
for (const c of out) {
try {
await tx.addLink(
slug, c.targetSlug, c.context, c.linkType,
c.linkSource, c.originSlug, c.originField,
);
const existKey = `${c.targetSlug}\u0000${c.linkType}\u0000${c.linkSource ?? 'markdown'}`;
const exists = reconcilableOut.some(l =>
`${l.to_slug}\u0000${l.link_type}\u0000${l.link_source ?? 'markdown'}` === existKey
);
if (!exists) created++;
} catch {
errors++;
}
}
// Add incoming edges (other page → slug).
for (const c of inc) {
try {
await tx.addLink(
c.fromSlug!, c.targetSlug, c.context, c.linkType,
'frontmatter', c.originSlug, c.originField,
);
const existKey = `${c.fromSlug}\u0000${c.linkType}`;
const exists = existingIn.some(l =>
`${l.from_slug}\u0000${l.link_type}` === existKey
);
if (!exists) created++;
} catch {
errors++;
}
}
// Remove stale outgoing (markdown or our-frontmatter, not in desired set).
for (const l of reconcilableOut) {
const key = `${l.to_slug}\u0000${l.link_type}\u0000${l.link_source ?? 'markdown'}`;
if (!outKeys.has(key)) {
try {
await tx.removeLink(slug, l.to_slug, l.link_type, l.link_source ?? undefined);
removed++;
} catch {
errors++;
}
}
}
// Remove stale incoming (our frontmatter → slug, not in desired set).
for (const l of existingIn) {
const key = `${l.from_slug}\u0000${l.link_type}`;
if (!incKeys.has(key)) {
try {
await tx.removeLink(l.from_slug, slug, l.link_type, 'frontmatter');
removed++;
} catch {
errors++;
}
}
}
return { created, removed, errors };
});
return { ...result, unresolved };
}
const delete_page: Operation = {
name: 'delete_page',
description: 'Delete a page',
params: {
slug: { type: 'string', required: true },
},
mutating: true,
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'delete_page', slug: p.slug };
await ctx.engine.deletePage(p.slug as string);
return { status: 'deleted' };
},
cliHints: { name: 'delete', positional: ['slug'] },
};
const list_pages: Operation = {
name: 'list_pages',
description: 'List pages with optional filters',
params: {
type: { type: 'string', description: 'Filter by page type' },
tag: { type: 'string', description: 'Filter by tag' },
limit: { type: 'number', description: 'Max results (default 50)' },
},
handler: async (ctx, p) => {
const pages = await ctx.engine.listPages({
type: p.type as any,
tag: p.tag as string,
limit: clampSearchLimit(p.limit as number | undefined, 50, 100),
});
return pages.map(pg => ({
slug: pg.slug,
type: pg.type,
title: pg.title,
updated_at: pg.updated_at,
}));
},
cliHints: { name: 'list' },
};
// --- Search ---
const search: Operation = {
name: 'search',
description: 'Keyword search using full-text search',
params: {
query: { type: 'string', required: true },
limit: { type: 'number', description: 'Max results (default 20)' },
offset: { type: 'number', description: 'Skip first N results (for pagination)' },
},
handler: async (ctx, p) => {
const results = await ctx.engine.searchKeyword(p.query as string, {
limit: (p.limit as number) || 20,
offset: (p.offset as number) || 0,
});
return dedupResults(results);
},
cliHints: { name: 'search', positional: ['query'] },
};
const query: Operation = {
name: 'query',
description: 'Hybrid search with vector + keyword + multi-query expansion',
params: {
query: { type: 'string', required: true },
limit: { type: 'number', description: 'Max results (default 20)' },
offset: { type: 'number', description: 'Skip first N results (for pagination)' },
expand: { type: 'boolean', description: 'Enable multi-query expansion (default: true)' },
detail: { type: 'string', description: 'Result detail level: low (compiled truth only), medium (default, all with dedup), high (all chunks)' },
},
handler: async (ctx, p) => {
const expand = p.expand !== false;
const detail = (p.detail as 'low' | 'medium' | 'high') || undefined;
return hybridSearch(ctx.engine, p.query as string, {
limit: (p.limit as number) || 20,
offset: (p.offset as number) || 0,
expansion: expand,
expandFn: expand ? expandQuery : undefined,
detail,
});
},
cliHints: { name: 'query', positional: ['query'] },
};
// --- Tags ---
const add_tag: Operation = {
name: 'add_tag',
description: 'Add tag to page',
params: {
slug: { type: 'string', required: true },
tag: { type: 'string', required: true },
},
mutating: true,
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'add_tag', slug: p.slug, tag: p.tag };
await ctx.engine.addTag(p.slug as string, p.tag as string);
return { status: 'ok' };
},
cliHints: { name: 'tag', positional: ['slug', 'tag'] },
};
const remove_tag: Operation = {
name: 'remove_tag',
description: 'Remove tag from page',
params: {
slug: { type: 'string', required: true },
tag: { type: 'string', required: true },
},
mutating: true,
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'remove_tag', slug: p.slug, tag: p.tag };
await ctx.engine.removeTag(p.slug as string, p.tag as string);
return { status: 'ok' };
},
cliHints: { name: 'untag', positional: ['slug', 'tag'] },
};
const get_tags: Operation = {
name: 'get_tags',
description: 'List tags for a page',
params: {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
return ctx.engine.getTags(p.slug as string);
},
cliHints: { name: 'tags', positional: ['slug'] },
};
// --- Links ---
const add_link: Operation = {
name: 'add_link',
description: 'Create link between pages',
params: {
from: { type: 'string', required: true },
to: { type: 'string', required: true },
link_type: { type: 'string', description: 'Link type (e.g., invested_in, works_at)' },
context: { type: 'string', description: 'Context for the link' },
},
mutating: true,
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'add_link', from: p.from, to: p.to };
await ctx.engine.addLink(
p.from as string, p.to as string,
(p.context as string) || '', (p.link_type as string) || '',
);
return { status: 'ok' };
},
cliHints: { name: 'link', positional: ['from', 'to'] },
};
const remove_link: Operation = {
name: 'remove_link',
description: 'Remove link between pages',
params: {
from: { type: 'string', required: true },
to: { type: 'string', required: true },
},
mutating: true,
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'remove_link', from: p.from, to: p.to };
await ctx.engine.removeLink(p.from as string, p.to as string);
return { status: 'ok' };
},
cliHints: { name: 'unlink', positional: ['from', 'to'] },
};
const get_links: Operation = {
name: 'get_links',
description: 'List outgoing links from a page',
params: {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
return ctx.engine.getLinks(p.slug as string);
},
};
const get_backlinks: Operation = {
name: 'get_backlinks',
description: 'List incoming links to a page',
params: {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
return ctx.engine.getBacklinks(p.slug as string);
},
cliHints: { name: 'backlinks', positional: ['slug'] },
};
/**
* Hard cap on traverse_graph depth from MCP callers. Each recursive CTE iteration
* grows a `visited` array per path; in `direction=both` the join is `OR`-based and
* fans out exponentially. Without a cap, a remote MCP caller can pass depth=1e6
* and burn memory/CPU on the database. 10 hops is well beyond any realistic
* relationship query (Wintermute's "people who attended meetings with Alice"
* is 2 hops; the deepest meaningful chain in our test data is 4).
*/
const TRAVERSE_DEPTH_CAP = 10;
const traverse_graph: Operation = {
name: 'traverse_graph',
description: 'Traverse link graph from a page. With link_type/direction, returns edges (GraphPath[]) instead of nodes.',
params: {
slug: { type: 'string', required: true },
depth: { type: 'number', description: `Max traversal depth (default 5, capped at ${TRAVERSE_DEPTH_CAP})` },
link_type: { type: 'string', description: 'Filter to one link type (per-edge filter, traversal only follows matching edges)' },
direction: { type: 'string', enum: ['in', 'out', 'both'], description: 'Traversal direction (default out)' },
},
handler: async (ctx, p) => {
const slug = p.slug as string;
const requestedDepth = (p.depth as number) || 5;
if (requestedDepth > TRAVERSE_DEPTH_CAP) {
ctx.logger.warn(`[gbrain] traverse_graph depth clamped from ${requestedDepth} to ${TRAVERSE_DEPTH_CAP}`);
}
const depth = Math.max(1, Math.min(requestedDepth, TRAVERSE_DEPTH_CAP));
const linkType = p.link_type as string | undefined;
const direction = p.direction as 'in' | 'out' | 'both' | undefined;
// Backward compat: when neither link_type nor direction is provided, return
// the legacy GraphNode[] shape. Once either is set, switch to GraphPath[].
if (linkType === undefined && direction === undefined) {
return ctx.engine.traverseGraph(slug, depth);
}
return ctx.engine.traversePaths(slug, { depth, linkType, direction });
},
cliHints: { name: 'graph', positional: ['slug'] },
};
// --- Timeline ---
const add_timeline_entry: Operation = {
name: 'add_timeline_entry',
description: 'Add timeline entry to a page',
params: {
slug: { type: 'string', required: true },
date: { type: 'string', required: true },
summary: { type: 'string', required: true },
detail: { type: 'string' },
source: { type: 'string' },
},
mutating: true,
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'add_timeline_entry', slug: p.slug };
const date = p.date as string;
// Reject anything that isn't a strict YYYY-MM-DD with year 1900-2199 and
// a real calendar day. PG DATE accepts year 5874897 silently — that's a
// semantic bug nobody actually wants.
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new Error(`Invalid date format "${date}" (expected YYYY-MM-DD)`);
}
const [y, m, d] = date.split('-').map(Number);
if (y < 1900 || y > 2199 || m < 1 || m > 12 || d < 1 || d > 31) {
throw new Error(`Invalid date "${date}" (year 1900-2199, month 1-12, day 1-31)`);
}
// Round-trip through Date to catch e.g. Feb 30.
const parsed = new Date(date);
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date) {
throw new Error(`Invalid calendar date "${date}"`);
}
await ctx.engine.addTimelineEntry(p.slug as string, {
date,
source: (p.source as string) || '',
summary: p.summary as string,
detail: (p.detail as string) || '',
});
return { status: 'ok' };
},
cliHints: { name: 'timeline-add', positional: ['slug', 'date', 'summary'] },
};
const get_timeline: Operation = {
name: 'get_timeline',
description: 'Get timeline entries for a page',
params: {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
return ctx.engine.getTimeline(p.slug as string);
},
cliHints: { name: 'timeline', positional: ['slug'] },
};
// --- Admin ---
const get_stats: Operation = {
name: 'get_stats',
description: 'Brain statistics (page count, chunk count, etc.)',
params: {},
handler: async (ctx) => {
return ctx.engine.getStats();
},
cliHints: { name: 'stats' },
};
const get_health: Operation = {
name: 'get_health',
description: 'Brain health dashboard (embed coverage, stale pages, orphans)',
params: {},
handler: async (ctx) => {
return ctx.engine.getHealth();
},
cliHints: { name: 'health' },
};
const get_versions: Operation = {
name: 'get_versions',
description: 'Page version history',
params: {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
return ctx.engine.getVersions(p.slug as string);
},
cliHints: { name: 'history', positional: ['slug'] },
};
const revert_version: Operation = {
name: 'revert_version',
description: 'Revert page to a previous version',
params: {
slug: { type: 'string', required: true },
version_id: { type: 'number', required: true },
},
mutating: true,
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'revert_version', slug: p.slug, version_id: p.version_id };
await ctx.engine.createVersion(p.slug as string);
await ctx.engine.revertToVersion(p.slug as string, p.version_id as number);
return { status: 'reverted' };
},
cliHints: { name: 'revert', positional: ['slug', 'version_id'] },
};
// --- Sync ---
const sync_brain: Operation = {
name: 'sync_brain',
description: 'Sync git repo to brain (incremental)',
params: {
repo: { type: 'string', description: 'Path to git repo (optional if configured)' },
dry_run: { type: 'boolean', description: 'Preview changes without applying' },
full: { type: 'boolean', description: 'Full re-sync (ignore checkpoint)' },
no_pull: { type: 'boolean', description: 'Skip git pull' },
no_embed: { type: 'boolean', description: 'Skip embedding generation' },
},
mutating: true,
handler: async (ctx, p) => {
const { performSync } = await import('../commands/sync.ts');
return performSync(ctx.engine, {
repoPath: p.repo as string | undefined,
dryRun: ctx.dryRun || (p.dry_run as boolean) || false,
noEmbed: (p.no_embed as boolean) || false,
noPull: (p.no_pull as boolean) || false,
full: (p.full as boolean) || false,
});
},
cliHints: { name: 'sync', hidden: true },
};
// --- Raw Data ---
const put_raw_data: Operation = {
name: 'put_raw_data',
description: 'Store raw API response data for a page',
params: {
slug: { type: 'string', required: true },
source: { type: 'string', required: true, description: 'Data source (e.g., crustdata, happenstance)' },
data: { type: 'object', required: true, description: 'Raw data object' },
},
mutating: true,
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'put_raw_data', slug: p.slug, source: p.source };
await ctx.engine.putRawData(p.slug as string, p.source as string, p.data as object);
return { status: 'ok' };
},
};
const get_raw_data: Operation = {
name: 'get_raw_data',
description: 'Retrieve raw data for a page',
params: {
slug: { type: 'string', required: true },
source: { type: 'string', description: 'Filter by source' },
},
handler: async (ctx, p) => {
return ctx.engine.getRawData(p.slug as string, p.source as string | undefined);
},
};
// --- Resolution & Chunks ---
const resolve_slugs: Operation = {
name: 'resolve_slugs',
description: 'Fuzzy-resolve a partial slug to matching page slugs',
params: {
partial: { type: 'string', required: true },
},
handler: async (ctx, p) => {
return ctx.engine.resolveSlugs(p.partial as string);
},
};
const get_chunks: Operation = {
name: 'get_chunks',
description: 'Get content chunks for a page',
params: {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
return ctx.engine.getChunks(p.slug as string);
},
};
// --- Ingest Log ---
const log_ingest: Operation = {
name: 'log_ingest',
description: 'Log an ingestion event',
params: {
source_type: { type: 'string', required: true },
source_ref: { type: 'string', required: true },
pages_updated: { type: 'array', required: true, items: { type: 'string' } },
summary: { type: 'string', required: true },
},
mutating: true,
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'log_ingest' };
await ctx.engine.logIngest({
source_type: p.source_type as string,
source_ref: p.source_ref as string,
pages_updated: p.pages_updated as string[],
summary: p.summary as string,
});
return { status: 'ok' };
},
};
const get_ingest_log: Operation = {
name: 'get_ingest_log',
description: 'Get recent ingestion log entries',
params: {
limit: { type: 'number', description: 'Max entries (default 20)' },
},
handler: async (ctx, p) => {
return ctx.engine.getIngestLog({ limit: clampSearchLimit(p.limit as number | undefined, 20, 50) });
},
};
// --- File Operations ---
// Both branches need a LIMIT. Without one, the slug-filtered branch materializes
// every file for that slug — an MCP caller can force unbounded memory consumption
// by targeting a page with many attachments.
const FILE_LIST_LIMIT = 100;
const file_list: Operation = {
name: 'file_list',
description: 'List stored files',
params: {
slug: { type: 'string', description: 'Filter by page slug' },
},
handler: async (_ctx, p) => {
const sql = db.getConnection();
const slug = p.slug as string | undefined;
if (slug) {
return sql`SELECT id, page_slug, filename, storage_path, mime_type, size_bytes, content_hash, created_at FROM files WHERE page_slug = ${slug} ORDER BY filename LIMIT ${FILE_LIST_LIMIT}`;
}
return sql`SELECT id, page_slug, filename, storage_path, mime_type, size_bytes, content_hash, created_at FROM files ORDER BY page_slug, filename LIMIT ${FILE_LIST_LIMIT}`;
},
};
const file_upload: Operation = {
name: 'file_upload',
description: 'Upload a file to storage',
params: {
path: { type: 'string', required: true, description: 'Local file path' },
page_slug: { type: 'string', description: 'Associate with page' },
},
mutating: true,
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'file_upload', path: p.path };
const { readFileSync, statSync } = await import('fs');
const { basename, extname } = await import('path');
const { createHash } = await import('crypto');
const filePath = p.path as string;
const pageSlug = (p.page_slug as string) || null;
// Fix 1 / B5 / H5 / M4: validate path, slug, filename before any filesystem read.
// Remote callers (MCP, agent) are confined to cwd (strict). Local CLI callers
// can upload from anywhere on the filesystem (loose) — the user owns the machine.
// Default is strict when ctx.remote is undefined (defense-in-depth).
const strict = ctx.remote !== false;
validateUploadPath(filePath, process.cwd(), strict);
if (pageSlug) validatePageSlug(pageSlug);
const filename = basename(filePath);
validateFilename(filename);
const stat = statSync(filePath);
const content = readFileSync(filePath);
const hash = createHash('sha256').update(content).digest('hex');
const storagePath = pageSlug ? `${pageSlug}/${filename}` : `unsorted/${hash.slice(0, 8)}-${filename}`;
const MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
'.pdf': 'application/pdf', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg',
};
const mimeType = MIME_TYPES[extname(filePath).toLowerCase()] || null;
const sql = db.getConnection();
const existing = await sql`SELECT id FROM files WHERE content_hash = ${hash} AND storage_path = ${storagePath}`;
if (existing.length > 0) {
return { status: 'already_exists', storage_path: storagePath };
}
// Upload to storage backend if configured
if (ctx.config.storage) {
const { createStorage } = await import('./storage.ts');
const storage = await createStorage(ctx.config.storage as any);
try {
await storage.upload(storagePath, content, mimeType || undefined);
} catch (uploadErr) {
throw new OperationError('storage_error', `Upload failed: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`);
}
}
try {
await sql`
INSERT INTO files (page_slug, filename, storage_path, mime_type, size_bytes, content_hash, metadata)
VALUES (${pageSlug}, ${filename}, ${storagePath}, ${mimeType}, ${stat.size}, ${hash}, ${'{}'}::jsonb)
ON CONFLICT (storage_path) DO UPDATE SET
content_hash = EXCLUDED.content_hash,
size_bytes = EXCLUDED.size_bytes,
mime_type = EXCLUDED.mime_type
`;
} catch (dbErr) {
// Rollback: clean up storage if DB write failed
if (ctx.config.storage) {
try {
const { createStorage } = await import('./storage.ts');
const storage = await createStorage(ctx.config.storage as any);
await storage.delete(storagePath);
} catch { /* best effort cleanup */ }
}
throw dbErr;
}
return { status: 'uploaded', storage_path: storagePath, size_bytes: stat.size };
},
};
const file_url: Operation = {
name: 'file_url',
description: 'Get a URL for a stored file',
params: {
storage_path: { type: 'string', required: true },
},
handler: async (_ctx, p) => {
const sql = db.getConnection();
const rows = await sql`SELECT storage_path, mime_type, size_bytes FROM files WHERE storage_path = ${p.storage_path as string}`;
if (rows.length === 0) {
throw new OperationError('storage_error', `File not found: ${p.storage_path}`);
}
// TODO: generate signed URL from Supabase Storage
return { storage_path: rows[0].storage_path, url: `gbrain:files/${rows[0].storage_path}` };
},
};
// --- Jobs (Minions) ---
const submit_job: Operation = {
name: 'submit_job',
description: 'Submit a background job to the Minions queue. Built-in types: sync, embed, lint, import, extract, backlinks, autopilot-cycle. The `shell` type is CLI-only and rejected over MCP.',
params: {
name: { type: 'string', required: true, description: 'Job type (sync, embed, lint, import, extract, backlinks, autopilot-cycle; shell is CLI-only)' },
data: { type: 'object', description: 'Job payload (JSON)' },
queue: { type: 'string', description: 'Queue name (default: "default")' },
priority: { type: 'number', description: 'Priority (0 = highest, default: 0)' },
max_attempts: { type: 'number', description: 'Max retry attempts (default: 3)' },
delay: { type: 'number', description: 'Delay in ms before eligible' },
timeout_ms: { type: 'number', description: 'Per-job wall-clock timeout in ms; aborted job goes to dead' },
},
mutating: true,
handler: async (ctx, p) => {
const name = typeof p.name === 'string' ? p.name.trim() : '';
if (ctx.dryRun) return { dry_run: true, action: 'submit_job', name };
// Submit-side MCP guard: reject protected job names from untrusted callers
// BEFORE we touch the DB. This is the first of the two security layers
// (the second is MinionQueue.add's check). Independent of the worker-side
// GBRAIN_ALLOW_SHELL_JOBS env flag — even if that flag is on, MCP callers
// cannot submit protected-type jobs.
const { isProtectedJobName } = await import('./minions/protected-names.ts');
if (ctx.remote && isProtectedJobName(name)) {
throw new OperationError('permission_denied', `'${name}' jobs cannot be submitted over MCP (CLI-only for security)`);
}
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
// Trusted flag set only when this is a local (non-remote) submission. When
// remote=true, the guard above has already thrown for protected names, so
// passing undefined here is safe for any non-protected name that slips by.
const trusted = !ctx.remote && isProtectedJobName(name) ? { allowProtectedSubmit: true } : undefined;
return queue.add(name, (p.data as Record<string, unknown>) || {}, {
queue: (p.queue as string) || 'default',
priority: (p.priority as number) || 0,
max_attempts: (p.max_attempts as number) || 3,
delay: (p.delay as number) || undefined,
timeout_ms: (p.timeout_ms as number) || undefined,
}, trusted);
},
};
const get_job: Operation = {
name: 'get_job',
description: 'Get job status and details by ID',
params: {
id: { type: 'number', required: true, description: 'Job ID' },
},
handler: async (ctx, p) => {
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const job = await queue.getJob(p.id as number);
if (!job) throw new OperationError('invalid_params', `Job not found: ${p.id}`);
return job;
},
};
const list_jobs: Operation = {
name: 'list_jobs',
description: 'List jobs with optional filters',
params: {
status: { type: 'string', description: 'Filter by status (waiting, active, completed, failed, delayed, dead, cancelled)' },
queue: { type: 'string', description: 'Filter by queue name' },
name: { type: 'string', description: 'Filter by job type' },
limit: { type: 'number', description: 'Max results (default: 50)' },
},
handler: async (ctx, p) => {
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
return queue.getJobs({
status: p.status as string | undefined,
queue: p.queue as string | undefined,
name: p.name as string | undefined,
limit: (p.limit as number) || 50,
} as Parameters<typeof queue.getJobs>[0]);
},
};
const cancel_job: Operation = {
name: 'cancel_job',
description: 'Cancel a waiting, active, or delayed job',
params: {
id: { type: 'number', required: true, description: 'Job ID' },
},
mutating: true,
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'cancel_job', id: p.id };
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const cancelled = await queue.cancelJob(p.id as number);
if (!cancelled) throw new OperationError('invalid_params', `Cannot cancel job ${p.id} (may already be in terminal status)`);
return cancelled;
},
};
const retry_job: Operation = {
name: 'retry_job',
description: 'Re-queue a failed or dead job for retry',
params: {
id: { type: 'number', required: true, description: 'Job ID' },
},
mutating: true,
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'retry_job', id: p.id };
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const retried = await queue.retryJob(p.id as number);
if (!retried) throw new OperationError('invalid_params', `Cannot retry job ${p.id} (must be failed or dead)`);
return retried;
},
};
const get_job_progress: Operation = {
name: 'get_job_progress',
description: 'Get structured progress for a running job',
params: {
id: { type: 'number', required: true, description: 'Job ID' },
},
handler: async (ctx, p) => {
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const job = await queue.getJob(p.id as number);
if (!job) throw new OperationError('invalid_params', `Job not found: ${p.id}`);
return { id: job.id, name: job.name, status: job.status, progress: job.progress };
},
};
const pause_job: Operation = {
name: 'pause_job',
description: 'Pause a waiting, active, or delayed job',
params: {
id: { type: 'number', required: true, description: 'Job ID' },
},
handler: async (ctx, p) => {
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const job = await queue.pauseJob(p.id as number);
if (!job) throw new OperationError('invalid_params', `Job not found or not pausable: ${p.id}`);
return { id: job.id, status: job.status };
},
};
const resume_job: Operation = {
name: 'resume_job',
description: 'Resume a paused job back to waiting',
params: {
id: { type: 'number', required: true, description: 'Job ID' },
},
handler: async (ctx, p) => {
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const job = await queue.resumeJob(p.id as number);
if (!job) throw new OperationError('invalid_params', `Job not found or not paused: ${p.id}`);
return { id: job.id, status: job.status };
},
};
const replay_job: Operation = {
name: 'replay_job',
description: 'Replay a completed/failed/dead job, optionally with modified data',
params: {
id: { type: 'number', required: true, description: 'Source job ID to replay' },
data_overrides: { type: 'object', required: false, description: 'Data fields to override (merged with original)' },
},
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'replay_job', id: p.id };
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const job = await queue.replayJob(p.id as number, p.data_overrides as Record<string, unknown> | undefined);
if (!job) throw new OperationError('invalid_params', `Job not found or not in terminal state: ${p.id}`);
return { id: job.id, name: job.name, status: job.status, source_id: p.id };
},
};
const send_job_message: Operation = {
name: 'send_job_message',
description: 'Send a sidechannel message to a running job\'s inbox',
params: {
id: { type: 'number', required: true, description: 'Job ID to message' },
payload: { type: 'object', required: true, description: 'Message payload (arbitrary JSON)' },
sender: { type: 'string', required: false, description: 'Sender identity (default: admin)' },
},
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'send_job_message', id: p.id };
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const msg = await queue.sendMessage(p.id as number, p.payload, (p.sender as string) ?? 'admin');
if (!msg) throw new OperationError('invalid_params', `Job not found, not messageable, or sender unauthorized: ${p.id}`);
return { sent: true, message_id: msg.id, job_id: p.id };
},
};
// --- Orphans ---
const find_orphans: Operation = {
name: 'find_orphans',
description: 'Find pages with no inbound wikilinks. Essential for content enrichment cycles.',
params: {
include_pseudo: {
type: 'boolean',
description: 'Include auto-generated and pseudo pages (default: false)',
},
},
handler: async (_ctx, p) => {
const { findOrphans } = await import('../commands/orphans.ts');
return findOrphans((p.include_pseudo as boolean) || false);
},
cliHints: { name: 'orphans', hidden: true },
};
// --- Exports ---
export const operations: Operation[] = [
// Page CRUD
get_page, put_page, delete_page, list_pages,
// Search
search, query,
// Tags
add_tag, remove_tag, get_tags,
// Links
add_link, remove_link, get_links, get_backlinks, traverse_graph,
// Timeline
add_timeline_entry, get_timeline,
// Admin
get_stats, get_health, get_versions, revert_version,
// Sync
sync_brain,
// Raw data
put_raw_data, get_raw_data,
// Resolution & chunks
resolve_slugs, get_chunks,
// Ingest log
log_ingest, get_ingest_log,
// Files
file_list, file_upload, file_url,
// Jobs (Minions)
submit_job, get_job, list_jobs, cancel_job, retry_job, get_job_progress,
pause_job, resume_job, replay_job, send_job_message,
// Orphans
find_orphans,
];
export const operationsByName = Object.fromEntries(
operations.map(op => [op.name, op]),
) as Record<string, Operation>;