Files
gbrain/src/commands/sources.ts
T
Garry TanandClaude Opus 4.7 fa2c7a6990 v0.40.10.0 feat: content sanity defense — junk-pattern throw + oversize-skip-embed (#1351)
* feat: add content-sanity assessor + embed-skip helper + audit JSONL primitives

Four new core modules (pure, no engine I/O):

- src/core/content-sanity.ts — assessor with 6 hand-vetted junk patterns
  (Cloudflare attention-required, just-a-moment, ray-id; access-denied;
  captcha-required; bare error-page titles). Bytes measured against
  compiled_truth + timeline (parseMarkdown body split, not file bytes).
  ContentSanityBlockError tagged with PAGE_JUNK_PATTERN code so
  classifyErrorCode hits via regex without a new ImportResult field.

- src/core/content-sanity-literals.ts — operator literal-substring loader
  for ~/.gbrain/junk-substrings.txt. Comment directives for name +
  applies_to. ENOENT returns empty list (fail-soft); no regex parsing so
  no ReDoS surface.

- src/core/embed-skip.ts — single source of truth for the embed-skip
  predicate. JS isEmbedSkipped() + filterOutEmbedSkipped() for in-memory
  callers; EMBED_SKIP_FILTER_FRAGMENT raw SQL string for engine-layer
  filters. buildEmbedSkipMarker() emits the canonical frontmatter shape.
  Both Postgres and PGLite use the same JSONB '?' existence operator.

- src/core/audit/content-sanity-audit.ts — ISO-week JSONL at
  ~/.gbrain/audit/content-sanity-YYYY-Www.jsonl. Built on v0.40.4.0
  audit-writer primitive. One stream for hard-block + soft-block + warn
  events with event_type discriminator. summarizeContentSanityEvents
  rolls up by type + source + pattern hits for doctor consumption.

99 unit tests across 4 new test files (207 assertions) covering
boundaries, every built-in pattern, bytes-parity assertion, operator
literals (regex meta-chars stay literal), audit JSONL round-trip + reader.

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

* feat(embed): apply embed-skip filter at all 5 stale-chunk sites

Embed sweep must skip pages with frontmatter.embed_skip set so soft-blocked
pages don't get re-embedded. Five wiring sites all use the shared helper:

  1. src/commands/embed.ts — --stale CLI path (delegates to embedAllStale)
  2. src/commands/embed.ts — --all CLI path (JS-side filterOutEmbedSkipped
     on the listPages result; Codex r2 #11 caught this previously-missed
     surface that re-embedded soft-blocked pages on every model swap)
  3. src/core/embed-stale.ts:90 — Minion helper (inherits via engine)
  4. src/core/postgres-engine.ts — listStaleChunks + countStaleChunks
     gain 'NOT (COALESCE(p.frontmatter, ''{}''::jsonb) ? ''embed_skip'')'
     filter at the SQL layer. Always JOINs pages now (pre-fix bare path
     skipped the JOIN; D4 + D8 require it for the filter).
  5. src/core/pglite-engine.ts — mirror of postgres-engine; PGLite is
     Postgres 17.5 in WASM so the same JSONB '?' operator works.

Cross-site invariant pinned by test/embed-skip.test.ts (20 cases on the
JS predicate + SQL fragment semantics). When v0.41+ promotes embed_skip
to a schema column, all 5 sites get updated in one helper file.

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

* feat(ingest): wire content-sanity gate into importFromContent narrow waist

Hard-block via thrown ContentSanityBlockError; soft-block via frontmatter
marker + chunk deletion on transition (D9 invariant). Single throw point
means every wrapper site (CLI, MCP put_page, sync) inherits correct
exit/error semantics through existing exception flow — no per-wrapper
status-vocabulary changes (Codex r2 #2).

import-file.ts:
- Gate runs AFTER parseMarkdown so assessor sees compiled_truth + timeline
  + title + frontmatter (Codex r2 #5+#7).
- Kill-switch (GBRAIN_NO_SANITY=1) checked via direct process.env AS WELL
  AS effective config — loadConfig() returns null on bare installs (no
  ~/.gbrain/config.json, no DATABASE_URL) so the config-only path missed
  the kill-switch. Caught by test/import-file-content-sanity.test.ts.
- Hard-block: throws ContentSanityBlockError. Existing import.ts catch
  increments errors; sync.ts:929 catch records failure with classified code.
- Soft-block: sets parsed.frontmatter.embed_skip via buildEmbedSkipMarker
  before hash compute (so hash differs from prior version → real write).
  Chunking block guards on isEmbedSkipped → chunks stays empty → existing
  tx.deleteChunks fires (D9 transition invariant).
- Audit JSONL records every assessment (hard / soft / warn + bypass-mode).

sync.ts:
- classifyErrorCode gains /PAGE_JUNK_PATTERN/ → 'PAGE_JUNK_PATTERN' regex.
  No PAGE_OVERSIZED code because oversize is now a soft state — page lands.

config.ts:
- New content_sanity.* field on GBrainConfig (4 keys: bytes_warn,
  bytes_block, junk_patterns_enabled, disabled).
- loadConfig() reads GBRAIN_PAGE_WARN_BYTES, GBRAIN_PAGE_BLOCK_BYTES,
  GBRAIN_NO_JUNK_PATTERNS, GBRAIN_NO_SANITY env vars sparse-merged.
- loadConfigWithEngine merges DB-plane content_sanity.* keys per-key
  sparse-merge so 'gbrain config set content_sanity.bytes_block N' takes
  effect uniformly (Codex r2 #6 D1 acceptance).
- KNOWN_CONFIG_KEYS + KNOWN_CONFIG_KEY_PREFIXES include the new keys.

cli.ts:
- runImport now honors result.errors > 0 for non-zero exit. Pre-fix the
  CLI awaited runImport but discarded the result, so hard-blocked imports
  exited 0 silently (Codex r2 #3).

9 PGLite-backed unit tests pin: hard-block throws, error message contains
PAGE_JUNK_PATTERN, blocked page does NOT land in DB, soft-block writes
page with embed_skip set, soft-block deletes pre-existing chunks (D9
transition), kill-switch bypass works.

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

* feat: lint rules + doctor checks + 'gbrain sources audit' CLI

Three operator surfaces backed by the shared content-sanity assessor:

lint.ts (2 new rules):
- huge-page: bytes (compiled_truth + timeline post-parse) exceeds warn or
  block threshold. Message names the actual byte count.
- scraper-junk: built-in junk pattern OR operator literal matched.
- Lint runs parseMarkdown to extract body for bytes-parity with doctor
  (D2 — both surfaces measure body-only, not file-with-frontmatter).
- runLintCore resolves effective config once per run: file/env (sync via
  loadConfig) + DB-lift when ~/.gbrain/ is reachable (D1). CI without
  ~/.gbrain/ falls through immediately. Engine probe wrapped in try/catch
  so lint never blocks on engine state.
- Operator literals loaded once per lint run; passed through to every
  page's lintContent call.

doctor.ts (3 new checks + 1 flag):
- oversized_pages: indexed-free table scan via
  octet_length(compiled_truth) + octet_length(COALESCE(timeline, ''))
  (Codex r2 #13: octet_length is bytes, length is chars). Status warn
  on 1+ rows; oversize is now a soft state so no 'fail'.
- scraper_junk_pages: capped 1000 most-recent default + --content-audit
  opt-in for full scan (D10 mirrors --index-audit precedent from v0.14.3).
  Applies assessor per-page on title + 2KB body slice + frontmatter.
- content_sanity_audit_recent: reads ~/.gbrain/audit/content-sanity-*.jsonl
  for last 7 days, aggregates by event_type + source. Warn at 10+ events,
  fail at 100+. Doctor message names the multi-host limitation explicitly
  (Codex r1 #14): 'audit reflects events on this host only; multi-host
  operators should share GBRAIN_AUDIT_DIR'.

sources.ts (new audit subcommand):
- gbrain sources audit <id> [--json] [--include-warns]
- Reads sources.local_path, walks disk (via pruneDir for node_modules /
  .git / dotfiles), runs assessContentSanity per .md file.
- Reports size distribution (p50, p99, max) + would-hard-block count +
  would-soft-block count + junk-pattern hit map.
- Read-only: NO DB writes, NO file mutations. Operator runs this BEFORE
  a sync to catch junk early, or AFTER landing v0.40.9.0 to audit
  historical inventory.

13 unit tests on lint rules; D1 config-lift behavior pinned by lift
in runLintCore + manual override via opts.contentSanity for tests.

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

* chore: bump version and changelog (v0.40.9.0)

v0.40.9.0 — content sanity defense: junk-pattern throw + oversize-skip-embed.

Plus TODOS.md entries for the 9 deferred v0.41+ follow-ups:
- chunk-level embed-quarantine (Codex r1 #3 — page-level granularity wrong)
- source-repo remediation CLI (gbrain sources prune-junk)
- threshold validation post-deploy on real corpora
- brain-score no_junk_pages_score component
- pages soft-delete --where CLI (paired with prune-junk)
- post-v0.45 operator-regex extensibility (needs real ReDoS story)
- post-v0.45 HTML-density rule (needs fenced-code handling)
- bytes-parity E2E across lint + doctor
- 5-path narrow-waist E2E pin tests + doctor integration tests

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

* docs: update CLAUDE.md for v0.40.9.0 content-sanity wave

Add v0.40.9.0 Key Files entries for the content-sanity defense modules:
content-sanity.ts (assessor), content-sanity-literals.ts (operator loader),
embed-skip.ts (5-site shared predicate), audit/content-sanity-audit.ts
(JSONL writer). Extend doctor.ts, lint.ts, embed.ts, import-file.ts, and
sources.ts entries with the v0.40.9.0 surfaces (3 new doctor checks,
2 new lint rules, embed-skip filter at 5 sites, importFromContent gate,
sources audit subcommand).

Regenerate llms-full.txt per the CLAUDE.md edit rule.

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

* chore: rebump v0.40.9.0 → v0.40.10.0 (queue collision with #1350)

PR #1350 also claimed v0.40.9.0. Advancing this PR to v0.40.10.0 so CI's
version-gate doesn't reject on overlap. No functional change — same shipped
content, just a different version slot.

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

* fix(brain-writer): +1ms overshoot on COUNT-race timer to defeat CI boundary flake

PR #1351 ship CI hit a single test failure (one in 2552):
  (fail) scanBrainSources partial-scan state > hanging COUNT does not
  exceed deadline — Promise.race timeout fires [579.01ms]

Run: https://github.com/garrytan/gbrain/actions/runs/77611667786

Cause: heavily-loaded CI runners (8 parallel shards × 4 concurrent test
files = ~32 concurrent bun processes) occasionally let the setTimeout
race callback resolve a microsecond BEFORE the wall-clock boundary,
leaving Date.now() one tick below deadline. The post-await deadline
check at brain-writer.ts:512 uses Date.now() >= deadline; on that tick
the check evaluated false and scanOneSource ran src-a anyway. Test then
asserted firstSource.status === 'skipped' and got 'scanned'.

Fix: add 1ms overshoot to the race-timer schedule:
  setTimeout(..., remainingMs + 1)

Guarantees the timer fires past the deadline by at least one millisecond
regardless of runner timer drift. Cost: 1ms additional wall-clock
latency on hung COUNT queries — operationally negligible.

Verified: stress-tested 5/5 passing locally. The bug class is identical
to the one the existing test comment block (lines 180-187) documents
(`>=` not `>` at line 512); this +1ms is the belt to that suspenders.

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-05-24 10:49:56 -07:00

1153 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* gbrain sources — manage multi-source brain configuration (v0.18.0).
*
* A source is a logical brain-within-the-DB: wiki, gstack, yc-media, etc.
* Every page/file/ingest_log row is scoped to a sources(id) row. Slugs
* are unique per source. See docs/guides/multi-source-brains.md for the
* full story.
*
* Subcommands:
* gbrain sources add <id> --path <path> [--name <display>] [--federated|--no-federated]
* gbrain sources list [--json]
* gbrain sources remove <id> [--yes] [--dry-run] [--keep-storage]
* gbrain sources rename <id> <new-name>
* gbrain sources default <id>
* gbrain sources attach <id> — write .gbrain-source in CWD
* gbrain sources detach — remove .gbrain-source from CWD
* gbrain sources federate <id> — sources.config.federated = true
* gbrain sources unfederate <id> — sources.config.federated = false
*
* NOT in scope for Step 6 (deferred per plan):
* - import-from-github (needs SSRF + clone integration)
* - prune (retention/TTL deferred to v0.18)
* - MCP tool-def regen for full source-scoping of all ops (part of Step 2+5)
*/
import { writeFileSync, unlinkSync, existsSync } from 'fs';
import { join } from 'path';
import type { BrainEngine } from '../core/engine.ts';
import {
assessDestructiveImpact,
checkDestructiveConfirmation,
softDeleteSource,
restoreSource,
listArchivedSources,
purgeExpiredSources,
formatImpact,
formatSoftDelete,
SOFT_DELETE_TTL_HOURS,
} from '../core/destructive-guard.ts';
import {
addSource as opsAddSource,
recloneIfMissing,
SourceOpError,
type SourceRow as OpsSourceRow,
} from '../core/sources-ops.ts';
import {
resolveSourceWithTier,
SOURCE_TIER_NAMES,
} from '../core/source-resolver.ts';
import {
loadAllSources,
parseSourceConfig,
isSourceFederated,
type SourceRow as LoadedSourceRow,
} from '../core/sources-load.ts';
// ── Validation ──────────────────────────────────────────────
// Shared with source-resolver.ts — canonical shape.
const SOURCE_ID_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
function validateSourceId(id: string): void {
if (!SOURCE_ID_RE.test(id)) {
throw new Error(
`Invalid source id "${id}". Must be 1-32 lowercase alnum chars with optional interior hyphens (e.g. "wiki", "yc-media").`,
);
}
}
// ── Types ───────────────────────────────────────────────────
interface SourceRow {
id: string;
name: string;
local_path: string | null;
last_commit: string | null;
last_sync_at: Date | null;
config: Record<string, unknown> | string;
created_at: Date;
}
interface SourceListEntry {
id: string;
name: string;
local_path: string | null;
federated: boolean;
page_count: number;
last_sync_at: string | null;
}
// ── Helpers ─────────────────────────────────────────────────
// v0.40 (D7): shared helpers — re-exported as local names for back-compat
// with existing call sites that import `parseConfig`/`isFederated` by intent.
const parseConfig = parseSourceConfig;
const isFederated = isSourceFederated;
async function fetchSource(engine: BrainEngine, id: string): Promise<SourceRow | null> {
const rows = await engine.executeRaw<SourceRow>(
`SELECT id, name, local_path, last_commit, last_sync_at, config, created_at
FROM sources WHERE id = $1`,
[id],
);
return rows[0] ?? null;
}
async function countPages(engine: BrainEngine, sourceId: string): Promise<number> {
const rows = await engine.executeRaw<{ n: number }>(
`SELECT COUNT(*)::int AS n FROM pages WHERE source_id = $1`,
[sourceId],
);
return rows[0]?.n ?? 0;
}
// ── Subcommand: add ─────────────────────────────────────────
async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
const id = args[0];
if (!id) {
console.error(
'Usage: gbrain sources add <id> [--path <path> | --url <https-url>] ' +
'[--name <display>] [--federated|--no-federated] [--clone-dir <path>]',
);
process.exit(2);
}
let localPath: string | null = null;
let remoteUrl: string | undefined;
let displayName: string | undefined;
let federated: boolean | null = null;
let cloneDir: string | undefined;
for (let i = 1; i < args.length; i++) {
const a = args[i];
if (a === '--path') { localPath = args[++i]; continue; }
if (a === '--url') { remoteUrl = args[++i]; continue; }
if (a === '--name') { displayName = args[++i]; continue; }
if (a === '--federated') { federated = true; continue; }
if (a === '--no-federated') { federated = false; continue; }
if (a === '--clone-dir') { cloneDir = args[++i]; continue; }
console.error(`Unknown flag: ${a}`);
process.exit(2);
}
if (remoteUrl && localPath) {
console.error('Error: --url and --path are mutually exclusive (--url manages its own clone path).');
process.exit(2);
}
// Throw on SourceOpError; cli.ts wraps every command in a try/catch that
// turns Error into the right exit code. Tests assert throw shape, so we
// intentionally propagate rather than process.exit here.
const created: OpsSourceRow = await opsAddSource(engine, {
id,
name: displayName,
localPath,
remoteUrl,
federated,
cloneDir,
});
const fed = isFederated(created.config);
const finalRemoteUrl = (created.config as Record<string, unknown>).remote_url as string | undefined;
const tail = finalRemoteUrl
? ` ← cloned from ${finalRemoteUrl}`
: created.local_path
? ` → ${created.local_path}`
: '';
console.log(
`Created source "${id}"${displayName && displayName !== id ? ` (name: ${displayName})` : ''}${tail}`,
);
if (finalRemoteUrl) {
console.log(` clone path: ${created.local_path}`);
}
console.log(
` federated: ${fed}${fed ? ' — appears in cross-source default search' : ' — only searched when explicitly named via --source'}`,
);
}
// ── Subcommand: list ────────────────────────────────────────
async function runList(engine: BrainEngine, args: string[]): Promise<void> {
const json = args.includes('--json');
// v0.40 (D7): loadAllSources is the single source of truth for source enum.
// Pass includeArchived=true to preserve the legacy `runList` behavior of
// surfacing archived rows (they get the ⚠ marker below).
const rows: LoadedSourceRow[] = await loadAllSources(engine, { includeArchived: true });
const entries: SourceListEntry[] = [];
for (const r of rows) {
const pageCount = await countPages(engine, r.id);
entries.push({
id: r.id,
name: r.name,
local_path: r.local_path,
federated: isFederated(r.config),
page_count: pageCount,
last_sync_at: r.last_sync_at ? new Date(r.last_sync_at).toISOString() : null,
});
}
if (json) {
console.log(JSON.stringify({ sources: entries }, null, 2));
return;
}
// Human-readable table.
console.log('SOURCES');
console.log('───────');
for (const e of entries) {
const fedMark = e.federated ? 'federated' : (e as any).archived ? '⚠ archived' : 'isolated';
const pathStr = e.local_path ?? '(no local path)';
const sync = e.last_sync_at ? `last sync ${e.last_sync_at}` : 'never synced';
console.log(` ${e.id.padEnd(20)} ${fedMark.padEnd(12)} ${String(e.page_count).padStart(6)} pages ${sync}`);
if (e.local_path) console.log(` ${' '.repeat(22)}${pathStr}`);
}
if (entries.length === 0) console.log(' (no sources registered)');
}
// ── Subcommand: remove ──────────────────────────────────────
async function runRemove(engine: BrainEngine, args: string[]): Promise<void> {
const id = args[0];
if (!id) {
console.error('Usage: gbrain sources remove <id> [--yes] [--confirm-destructive] [--dry-run] [--keep-storage]');
process.exit(2);
}
const yes = args.includes('--yes');
const dryRun = args.includes('--dry-run');
const confirmDestructive = args.includes('--confirm-destructive');
const _keepStorage = args.includes('--keep-storage');
void _keepStorage;
if (id === 'default') {
console.error('Error: cannot remove the "default" source (it backs the pre-v0.17 brain).');
process.exit(3);
}
const src = await fetchSource(engine, id);
if (!src) {
console.error(`Source "${id}" not found.`);
process.exit(4);
}
// v0.26.5: Impact preview + destructive guard
const impact = await assessDestructiveImpact(engine, id);
if (impact) {
console.log(formatImpact(impact));
if (dryRun) {
console.log('(dry-run; no side effects)');
return;
}
const blockMsg = checkDestructiveConfirmation(impact, { yes, confirmDestructive, dryRun });
if (blockMsg) {
console.error(blockMsg);
process.exit(5);
}
} else {
if (dryRun) { console.log('(dry-run; source not found)'); return; }
if (!yes && !confirmDestructive) {
console.error('Refusing to remove without --yes or --confirm-destructive.');
process.exit(5);
}
}
await engine.executeRaw(`DELETE FROM sources WHERE id = $1`, [id]);
const pageCount = impact?.pageCount ?? 0;
console.log(`Removed source "${id}" (${pageCount} pages + dependent rows cascaded).`);
}
// ── Subcommand: archive (soft-delete) ───────────────────────
// ── Subcommand: set-cr-mode (v0.40.3.0 — D5) ────────────────
//
// `gbrain sources set-cr-mode <id> <none|title|per_chunk_synopsis>`
// writes sources.contextual_retrieval_mode for the per-source override
// resolver. Empty value / "unset" / "default" clears the column (NULL
// falls through to the global mode).
//
// Loud rejection on:
// - missing id
// - missing mode
// - invalid mode (lists valid options)
// - non-existent source id (lists registered sources via paste-ready hint)
//
// D5 picked the narrow verb over a generic `sources set <key> <value>`
// because per-field validation actually matters (CRMode validation must
// run; future fields may need bespoke prompts). The generic mutator is
// filed as a v0.41+ TODO when 3+ writable fields exist.
async function runSetCrMode(engine: BrainEngine, args: string[]): Promise<void> {
const { isCRMode, CR_MODES } = await import('../core/types.ts');
const id = args[0];
const mode = args[1];
if (!id || !mode) {
console.error('Usage: gbrain sources set-cr-mode <id> <none|title|per_chunk_synopsis>');
console.error(' Pass "unset" or "default" to clear the override (NULL falls through).');
process.exit(2);
}
// Clear path: empty / "unset" / "default" → NULL.
const clearing = mode === 'unset' || mode === 'default' || mode === '';
if (!clearing && !isCRMode(mode)) {
console.error(`Error: invalid CR mode "${mode}".`);
console.error(`Valid options: ${CR_MODES.join(' | ')}`);
console.error(` Or pass "unset" / "default" to clear the override.`);
process.exit(2);
}
// Loud-rejection on missing source. Closes the idempotent-pebble Failure
// Modes "critical gap": pre-v0.40.3.0 there was no surface that wrote to
// sources.contextual_retrieval_mode, so the silent-no-op via SQL UPDATE
// matching 0 rows was the failure mode the gap warning called out.
const exists = await engine.executeRaw<{ id: string }>(
`SELECT id FROM sources WHERE id = $1 LIMIT 1`,
[id],
);
if (exists.length === 0) {
console.error(`Error: source "${id}" not found.`);
console.error(` Run 'gbrain sources list' to see registered sources.`);
process.exit(4);
}
const newValue = clearing ? null : mode;
await engine.executeRaw(
`UPDATE sources SET contextual_retrieval_mode = $1 WHERE id = $2`,
[newValue, id],
);
if (clearing) {
console.log(`Cleared contextual_retrieval_mode for source "${id}" (NULL falls through to global mode).`);
} else {
console.log(`Set source "${id}" contextual_retrieval_mode = ${mode}.`);
}
}
async function runArchive(engine: BrainEngine, args: string[]): Promise<void> {
const id = args[0];
if (!id) {
console.error('Usage: gbrain sources archive <id>');
process.exit(2);
}
if (id === 'default') {
console.error('Error: cannot archive the "default" source.');
process.exit(3);
}
// Show impact preview
const impact = await assessDestructiveImpact(engine, id);
if (!impact) {
console.error(`Source "${id}" not found.`);
process.exit(4);
}
const result = await softDeleteSource(engine, id);
if (!result) {
console.error(`Failed to archive source "${id}".`);
process.exit(4);
}
console.log(formatSoftDelete(result));
}
// ── Subcommand: restore ─────────────────────────────────────
async function runRestore(engine: BrainEngine, args: string[]): Promise<void> {
const id = args[0];
const noFederate = args.includes('--no-federate');
if (!id) {
console.error('Usage: gbrain sources restore <id> [--no-federate]');
process.exit(2);
}
const restored = await restoreSource(engine, id, !noFederate);
if (!restored) {
console.error(`Source "${id}" not found or not archived.`);
process.exit(4);
}
console.log(`Source "${id}" restored. ${noFederate ? 'Not re-federated.' : 'Re-federated.'}`);
console.log(`All pages, chunks, and embeddings are intact.`);
// T4 (eng-review): if the source has a remote_url AND its clone dir was
// autopurged (e.g. operator rm -rf'd $GBRAIN_HOME/clones/), re-clone
// before declaring restore success. Without this, restore returns green
// but the source is unsyncable until a later sync path discovers the gap.
try {
const recloned = await recloneIfMissing(engine, id);
if (recloned) {
console.log(` re-cloned from remote_url (clone dir was missing).`);
}
} catch (e) {
if (e instanceof SourceOpError) {
console.error(` WARN: could not re-clone: ${e.message}`);
console.error(` The DB row is restored but the on-disk clone is missing.`);
console.error(` Try \`gbrain sync --source ${id}\` to recover, or remove + re-add.`);
} else {
throw e;
}
}
}
// ── Subcommand: purge ───────────────────────────────────────
async function runPurge(engine: BrainEngine, args: string[]): Promise<void> {
const id = args[0];
const confirmDestructive = args.includes('--confirm-destructive');
if (id) {
// Purge a specific source (must be archived)
const impact = await assessDestructiveImpact(engine, id);
if (!impact) {
console.error(`Source "${id}" not found.`);
process.exit(4);
}
console.log(formatImpact(impact));
if (!confirmDestructive) {
console.error(`Pass --confirm-destructive to permanently delete source "${id}".`);
process.exit(5);
}
await engine.executeRaw(`DELETE FROM sources WHERE id = $1`, [id]);
console.log(`Permanently deleted source "${id}" (${impact.pageCount} pages cascaded).`);
return;
}
// No id: purge all expired archives
const purged = await purgeExpiredSources(engine);
if (purged.length === 0) {
console.log('No expired archives to purge.');
} else {
console.log(`Purged ${purged.length} expired archive(s): ${purged.join(', ')}`);
}
}
// ── Subcommand: archived ────────────────────────────────────
async function runListArchived(engine: BrainEngine, args: string[]): Promise<void> {
const json = args.includes('--json');
const archived = await listArchivedSources(engine);
if (json) {
console.log(JSON.stringify({ archived }, null, 2));
return;
}
if (archived.length === 0) {
console.log('No archived sources.');
return;
}
console.log('ARCHIVED SOURCES (soft-deleted)');
console.log('───────────────────────────────');
for (const a of archived) {
const hours = Math.max(0, Math.round((a.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60)));
console.log(` ${a.id.padEnd(20)} ${String(a.pageCount).padStart(6)} pages expires in ${hours}h (restore: gbrain sources restore ${a.id})`);
}
}
// ── Subcommand: rename ──────────────────────────────────────
async function runRename(engine: BrainEngine, args: string[]): Promise<void> {
const id = args[0];
const newName = args[1];
if (!id || !newName) {
console.error('Usage: gbrain sources rename <id> <new-display-name>');
process.exit(2);
}
const src = await fetchSource(engine, id);
if (!src) {
console.error(`Source "${id}" not found.`);
process.exit(4);
}
await engine.executeRaw(`UPDATE sources SET name = $1 WHERE id = $2`, [newName, id]);
console.log(`Renamed source "${id}" display: ${src.name}${newName} (id is immutable).`);
}
// ── Subcommand: default ─────────────────────────────────────
async function runDefault(engine: BrainEngine, args: string[]): Promise<void> {
const id = args[0];
if (!id) {
console.error('Usage: gbrain sources default <id>');
process.exit(2);
}
const src = await fetchSource(engine, id);
if (!src) {
console.error(`Source "${id}" not found.`);
process.exit(4);
}
// Stored in the config table (not sources.config, because it's a brain-
// level preference not a per-source setting).
await engine.setConfig('sources.default', id);
console.log(`Default source set to "${id}".`);
}
// ── Subcommand: attach / detach (CWD dotfile) ──────────────
function runAttach(args: string[]): void {
const id = args[0];
if (!id) {
console.error('Usage: gbrain sources attach <id>');
process.exit(2);
}
validateSourceId(id);
const dotfile = join(process.cwd(), '.gbrain-source');
writeFileSync(dotfile, id + '\n', 'utf8');
console.log(`Attached ${process.cwd()} to source "${id}" via .gbrain-source.`);
console.log(`Commands run from this directory (or any subdirectory) will default to this source.`);
}
function runDetach(): void {
const dotfile = join(process.cwd(), '.gbrain-source');
if (!existsSync(dotfile)) {
console.log(`No .gbrain-source file in ${process.cwd()}.`);
return;
}
unlinkSync(dotfile);
console.log(`Detached ${process.cwd()} (removed .gbrain-source).`);
}
// ── Subcommand: federate / unfederate ───────────────────────
async function runFederate(engine: BrainEngine, args: string[], value: boolean): Promise<void> {
const id = args[0];
if (!id) {
console.error(`Usage: gbrain sources ${value ? 'federate' : 'unfederate'} <id>`);
process.exit(2);
}
const src = await fetchSource(engine, id);
if (!src) {
console.error(`Source "${id}" not found.`);
process.exit(4);
}
const config = parseConfig(src.config);
config.federated = value;
await engine.executeRaw(
`UPDATE sources SET config = $1::jsonb WHERE id = $2`,
[JSON.stringify(config), id],
);
console.log(`Source "${id}" is now ${value ? 'federated (appears in cross-source default search)' : 'isolated (only searched when explicitly named)'}.`);
// v0.40 D19: auto-submit embed-backfill when coverage < 100%. Federation
// flip is a moment when the user explicitly opted into seeing this source
// in default search; un-embedded chunks would hide content from the very
// moment they wanted visibility. Best-effort — submission failure does NOT
// fail the flip.
try {
const { isFederatedV2Enabled } = await import('../core/feature-flags.ts');
if (!(await isFederatedV2Enabled(engine))) return;
const { loadAllSources } = await import('../core/sources-load.ts');
const { computeAllSourceMetrics } = await import('../core/source-health.ts');
const sources = await loadAllSources(engine, { includeArchived: false });
const metrics = await computeAllSourceMetrics(engine, sources);
const m = metrics.find((x) => x.source_id === id);
if (!m || m.total_chunks === 0 || m.embed_coverage_pct >= 100) return;
const { submitEmbedBackfill } = await import('../core/embed-backfill-submit.ts');
const sub = await submitEmbedBackfill(engine, id, { reason: 'federation_flip' });
if (sub.status === 'submitted') {
const missing = m.total_chunks - m.embedded_chunks;
console.log(` → embed-backfill job ${sub.jobId} queued for missing ${missing} chunks.`);
} else if (sub.status === 'cooldown') {
console.log(` → embed-backfill skipped (cooldown). Manually trigger with: gbrain jobs submit embed-backfill --params '{"sourceId":"${id}"}'`);
} else if (sub.status === 'spend_capped') {
console.log(` → embed-backfill skipped (24h spend cap $${sub.spendCapUsd} reached for this source).`);
}
} catch (err) {
// Federation flip already succeeded; embed-backfill is a follow-up nicety.
console.error(` → embed-backfill submission failed (flip succeeded): ${err instanceof Error ? err.message : String(err)}`);
}
}
// ── v0.40 sources status (D12) ──────────────────────────────
async function runStatus(engine: BrainEngine, args: string[]): Promise<void> {
const json = args.includes('--json');
const { loadAllSources } = await import('../core/sources-load.ts');
const { computeAllSourceMetrics } = await import('../core/source-health.ts');
const sources = await loadAllSources(engine, { includeArchived: false });
if (sources.length === 0) {
if (json) {
console.log(JSON.stringify({ schema_version: 1, sources: [] }, null, 2));
} else {
console.log('No sources registered. Use `gbrain sources add <id> --path <path>` first.');
}
return;
}
const metrics = await computeAllSourceMetrics(engine, sources);
if (json) {
console.log(JSON.stringify({ schema_version: 1, sources: metrics }, null, 2));
return;
}
// Human-readable table: SOURCE | LAG | EMBED | FAILS | QUEUE | PAGES | LAST SYNC
console.log('SOURCES — health');
console.log('────────────────');
console.log(
` ${'SOURCE'.padEnd(20)} ${'LAG'.padEnd(8)} ${'EMBED'.padEnd(7)} ${'FAILS'.padEnd(6)} ${'QUEUE'.padEnd(6)} ${'PAGES'.padStart(8)} LAST SYNC`,
);
for (const m of metrics) {
const lag = m.lag_seconds === null
? 'never'
: formatLag(m.lag_seconds);
const embed = `${m.embed_coverage_pct.toFixed(0)}%`;
const fails = String(m.failed_jobs_24h);
const queue = String(m.queue_depth);
const pages = m.total_pages.toLocaleString();
const sync = m.last_sync_at ? new Date(m.last_sync_at).toISOString().slice(0, 19).replace('T', ' ') : 'never';
console.log(` ${m.source_id.padEnd(20)} ${lag.padEnd(8)} ${embed.padEnd(7)} ${fails.padEnd(6)} ${queue.padEnd(6)} ${pages.padStart(8)} ${sync}`);
}
console.log('');
for (const m of metrics) {
const warns: string[] = [];
if (!m.local_path) warns.push('no local_path');
if (m.lag_seconds === null) warns.push(`never synced — run \`gbrain sync --source ${m.source_id}\``);
if (m.embed_coverage_pct < 95 && m.total_chunks > 100) {
warns.push(`${(100 - m.embed_coverage_pct).toFixed(1)}% un-embedded — run \`gbrain embed --stale --source ${m.source_id}\``);
}
if (m.failed_jobs_24h >= 3) {
warns.push(`${m.failed_jobs_24h} failures in 24h — check \`gbrain jobs list --status failed\``);
}
if (warns.length > 0) {
console.log(` ⚠ ${m.source_id}: ${warns.join('; ')}`);
}
}
}
function formatLag(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}d`;
}
// ── v0.40 sources webhook (D8) ──────────────────────────────
async function runWebhook(engine: BrainEngine, args: string[]): Promise<void> {
const sub = args[0];
const rest = args.slice(1);
switch (sub) {
case 'set': return runWebhookSet(engine, rest);
case 'show': return runWebhookShow(engine, rest);
case 'rotate': return runWebhookRotate(engine, rest);
case 'clear': return runWebhookClear(engine, rest);
case undefined:
case '--help':
case '-h':
console.log(`Usage: gbrain sources webhook <subcommand> <source-id> [options]
Subcommands:
set <id> [--secret VAL] [--github-repo owner/name] One-time reveal
show <id> Metadata only
rotate <id> New secret, reveal
clear <id> Remove webhook config`);
return;
default:
console.error(`Unknown webhook subcommand: ${sub}`);
process.exit(2);
}
}
async function runWebhookSet(engine: BrainEngine, args: string[]): Promise<void> {
const id = args[0];
if (!id) {
console.error('Usage: gbrain sources webhook set <id> [--secret VAL] [--github-repo owner/name]');
process.exit(2);
}
const src = await fetchSource(engine, id);
if (!src) {
console.error(`Source "${id}" not found.`);
process.exit(1);
}
const explicitSecret = args.find((a, i) => args[i - 1] === '--secret');
const githubRepo = args.find((a, i) => args[i - 1] === '--github-repo');
if (!githubRepo) {
console.error('--github-repo owner/name is required (e.g. "Garry-s-List/zion-brain")');
process.exit(2);
}
if (!/^[\w.-]+\/[\w.-]+$/.test(githubRepo)) {
console.error(`Invalid --github-repo format: "${githubRepo}". Expected "owner/name".`);
process.exit(2);
}
const { randomBytes } = await import('node:crypto');
const secret = explicitSecret ?? randomBytes(32).toString('hex');
const cfg = parseConfig(src.config);
cfg.webhook_secret = secret;
cfg.github_repo = githubRepo;
await engine.executeRaw(
`UPDATE sources SET config = $1::jsonb WHERE id = $2`,
[JSON.stringify(cfg), id],
);
console.log(`Webhook configured for source "${id}":`);
console.log(` github_repo: ${githubRepo}`);
console.log(` webhook_secret: ${secret}`);
console.log('');
console.log('--- Paste this into GitHub repo settings → Webhooks → Add webhook ---');
console.log(' Payload URL: <your gbrain serve --http URL>/webhooks/github');
console.log(' Content type: application/json');
console.log(` Secret: ${secret}`);
console.log(' Events: Just the push event');
console.log(' Active: checked');
console.log('');
console.log('⚠ This secret is shown ONCE. Save it now; subsequent `gbrain sources webhook show` will NOT display it.');
}
async function runWebhookShow(engine: BrainEngine, args: string[]): Promise<void> {
const id = args[0];
if (!id) {
console.error('Usage: gbrain sources webhook show <id>');
process.exit(2);
}
const src = await fetchSource(engine, id);
if (!src) {
console.error(`Source "${id}" not found.`);
process.exit(1);
}
const cfg = parseConfig(src.config);
const githubRepo = typeof cfg.github_repo === 'string' ? cfg.github_repo : '(not set)';
const secretSet = typeof cfg.webhook_secret === 'string' && cfg.webhook_secret.length > 0;
const trackedBranch = typeof cfg.tracked_branch === 'string' ? cfg.tracked_branch : '(auto-detected on next sync, default main)';
console.log(`Webhook configuration for source "${id}":`);
console.log(` github_repo: ${githubRepo}`);
console.log(` webhook_secret: ${secretSet ? '<set — use `webhook rotate` to reveal a new one>' : '(not set)'}`);
console.log(` tracked_branch: ${trackedBranch}`);
}
async function runWebhookRotate(engine: BrainEngine, args: string[]): Promise<void> {
const id = args[0];
if (!id) {
console.error('Usage: gbrain sources webhook rotate <id>');
process.exit(2);
}
const src = await fetchSource(engine, id);
if (!src) {
console.error(`Source "${id}" not found.`);
process.exit(1);
}
const { randomBytes } = await import('node:crypto');
const secret = randomBytes(32).toString('hex');
const cfg = parseConfig(src.config);
cfg.webhook_secret = secret;
await engine.executeRaw(
`UPDATE sources SET config = $1::jsonb WHERE id = $2`,
[JSON.stringify(cfg), id],
);
console.log(`New webhook secret for source "${id}":`);
console.log(` ${secret}`);
console.log('');
console.log('⚠ Update the GitHub webhook config to use this new secret. The old one is invalidated immediately.');
}
async function runWebhookClear(engine: BrainEngine, args: string[]): Promise<void> {
const id = args[0];
if (!id) {
console.error('Usage: gbrain sources webhook clear <id>');
process.exit(2);
}
const src = await fetchSource(engine, id);
if (!src) {
console.error(`Source "${id}" not found.`);
process.exit(1);
}
const cfg = parseConfig(src.config);
delete cfg.webhook_secret;
delete cfg.github_repo;
await engine.executeRaw(
`UPDATE sources SET config = $1::jsonb WHERE id = $2`,
[JSON.stringify(cfg), id],
);
console.log(`Webhook configuration cleared for source "${id}".`);
}
// ── v0.40 sources tracked-branch (D20) ──────────────────────
async function runTrackedBranch(engine: BrainEngine, args: string[]): Promise<void> {
const id = args[0];
if (!id) {
console.error('Usage: gbrain sources tracked-branch <id> [--set <branch>] [--detect]');
process.exit(2);
}
const src = await fetchSource(engine, id);
if (!src) {
console.error(`Source "${id}" not found.`);
process.exit(1);
}
const setArg = args.find((a, i) => args[i - 1] === '--set');
const detect = args.includes('--detect');
const cfg = parseConfig(src.config);
if (setArg) {
cfg.tracked_branch = setArg;
await engine.executeRaw(
`UPDATE sources SET config = $1::jsonb WHERE id = $2`,
[JSON.stringify(cfg), id],
);
console.log(`Tracked branch for source "${id}" set to "${setArg}".`);
return;
}
if (detect) {
if (!src.local_path) {
console.error(`Source "${id}" has no local_path; cannot auto-detect branch.`);
process.exit(1);
}
try {
const { execFileSync } = await import('node:child_process');
const branch = execFileSync('git', ['-C', src.local_path, 'rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' }).trim();
cfg.tracked_branch = branch;
await engine.executeRaw(
`UPDATE sources SET config = $1::jsonb WHERE id = $2`,
[JSON.stringify(cfg), id],
);
console.log(`Detected branch "${branch}" for source "${id}"; persisted to config.tracked_branch.`);
} catch (e) {
console.error(`git rev-parse failed: ${e instanceof Error ? e.message : String(e)}`);
process.exit(1);
}
return;
}
// Read mode: just print
const tracked = typeof cfg.tracked_branch === 'string' ? cfg.tracked_branch : '(unset — defaults to main)';
console.log(`Source "${id}" tracked_branch: ${tracked}`);
}
// ── `sources current` (v0.37.7.0) ──────────────────────────
//
// Verify which source the CLI would target before running a
// destructive op. Walks the same 6-tier chain as `resolveSourceId()`
// and reports both the winning source id AND the tier label
// ("flag" / "env" / "dotfile" / "local_path" / "brain_default" /
// "seed_default"). Optional `--source <id>` shows what an explicit
// flag WOULD resolve to without actually running anything.
async function runCurrent(engine: BrainEngine, args: string[]): Promise<void> {
const json = args.includes('--json');
let explicit: string | null = null;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--source' && i + 1 < args.length) {
explicit = args[++i] || null;
}
}
let result: Awaited<ReturnType<typeof resolveSourceWithTier>>;
try {
result = await resolveSourceWithTier(engine, explicit, process.cwd());
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (json) {
console.log(JSON.stringify({ error: msg }, null, 2));
} else {
console.error(`Error resolving source: ${msg}`);
}
process.exit(1);
}
if (json) {
console.log(JSON.stringify({
source_id: result.source_id,
tier: result.tier,
detail: result.detail ?? null,
resolver_chain: SOURCE_TIER_NAMES,
}, null, 2));
return;
}
console.log(`source: ${result.source_id}`);
console.log(` tier: ${result.tier}${result.detail ? ` (${result.detail})` : ''}`);
}
/**
* v0.41 — `gbrain sources audit <id>` dry-run scan.
*
* Walks the source's `local_path` on disk, runs `assessContentSanity`
* per `.md` file, and reports:
* - file count + size distribution (p50 / p99 / max)
* - would-hard-blocks (junk-pattern matches; new ingests would refuse)
* - would-soft-blocks (oversize-only; new ingests would set embed_skip)
* - junk-pattern hit counts grouped by pattern name
*
* Read-only: NO DB writes, NO file mutations. Intended for operators to
* inspect a source repo BEFORE syncing (catches junk early) or AFTER
* the new gate ships (audit existing inventory against the new rules
* without touching state).
*
* Uses `pruneDir` from sync.ts so node_modules / .git / .obsidian are
* skipped at descent — same walker semantics as the actual sync path.
*/
async function runAudit(engine: BrainEngine, args: string[]): Promise<void> {
const sourceId = args.find((a) => !a.startsWith('--'));
const json = args.includes('--json');
const includeWarns = args.includes('--include-warns');
if (!sourceId) {
console.error('Usage: gbrain sources audit <source-id> [--json] [--include-warns]');
process.exit(2);
}
const { fetchSource } = await import('../core/sources-load.ts');
const src = await fetchSource(engine, sourceId);
if (!src) {
console.error(`Source not found: ${sourceId} (run \`gbrain sources list\` to see registered sources)`);
process.exit(1);
}
if (!src.local_path) {
console.error(`Source ${sourceId} has no local_path — cannot audit on disk`);
process.exit(1);
}
// Lazy-load FS + walker bits so the command stays import-cheap when
// not invoked (every subcommand pays the import cost on dispatch).
const { readFileSync, readdirSync, lstatSync, existsSync: _exists } =
await import('fs');
const { join: pathJoin } = await import('path');
const { pruneDir } = await import('../core/sync.ts');
const { assessContentSanity } = await import('../core/content-sanity.ts');
const { loadOperatorLiterals } = await import('../core/content-sanity-literals.ts');
const { parseMarkdown } = await import('../core/markdown.ts');
if (!_exists(src.local_path)) {
console.error(`local_path does not exist on disk: ${src.local_path}`);
process.exit(1);
}
// Walk recursively. Mirror gbrain sync's descent rules so the file set
// we audit matches the file set that would actually be ingested.
const files: string[] = [];
function walk(dir: string): void {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return; // permission denied; skip silently
}
for (const entry of entries) {
const full = pathJoin(dir, entry);
let stat;
try {
stat = lstatSync(full);
} catch {
continue;
}
if (stat.isDirectory()) {
if (pruneDir(entry, dir)) continue;
walk(full);
} else if (entry.endsWith('.md')) {
files.push(full);
}
}
}
walk(src.local_path);
const literals = loadOperatorLiterals();
const sizes: number[] = [];
const wouldHardBlock: Array<{ file: string; matched: string[]; bytes: number }> = [];
const wouldSoftBlock: Array<{ file: string; bytes: number }> = [];
const wouldWarn: Array<{ file: string; bytes: number }> = [];
const patternHits: Record<string, number> = {};
for (const file of files) {
let content: string;
try {
content = readFileSync(file, 'utf-8');
} catch {
continue;
}
let parsed;
try {
parsed = parseMarkdown(content, file);
} catch {
continue; // malformed page; not our concern in audit
}
const sanity = assessContentSanity({
compiled_truth: parsed.compiled_truth,
timeline: parsed.timeline ?? '',
title: parsed.title,
extra_literals: literals,
});
sizes.push(sanity.bytes);
if (sanity.shouldHardBlock) {
const matched = [...sanity.junk_pattern_matches, ...sanity.literal_substring_matches];
for (const name of matched) {
patternHits[name] = (patternHits[name] ?? 0) + 1;
}
wouldHardBlock.push({ file, matched, bytes: sanity.bytes });
} else if (sanity.shouldSkipEmbed) {
wouldSoftBlock.push({ file, bytes: sanity.bytes });
} else if (sanity.reasons.includes('oversize_warn')) {
wouldWarn.push({ file, bytes: sanity.bytes });
}
}
// Size distribution stats.
sizes.sort((a, b) => a - b);
const p = (q: number) =>
sizes.length === 0 ? 0 : sizes[Math.min(sizes.length - 1, Math.floor(q * sizes.length))];
if (json) {
console.log(JSON.stringify({
schema_version: 1,
source_id: sourceId,
local_path: src.local_path,
total_files: files.length,
distribution: { p50: p(0.5), p99: p(0.99), max: sizes[sizes.length - 1] ?? 0 },
hard_block_count: wouldHardBlock.length,
soft_block_count: wouldSoftBlock.length,
warn_count: wouldWarn.length,
pattern_hits: patternHits,
hard_blocks: wouldHardBlock.slice(0, 20),
soft_blocks: wouldSoftBlock.slice(0, 20),
...(includeWarns ? { warns: wouldWarn.slice(0, 20) } : {}),
}, null, 2));
return;
}
console.log(`Source: ${sourceId} (${src.local_path})`);
console.log(`Files scanned: ${files.length} markdown files`);
if (sizes.length > 0) {
console.log(`Size distribution: p50=${p(0.5)} bytes, p99=${p(0.99)} bytes, max=${sizes[sizes.length - 1]} bytes`);
}
console.log(`Would-hard-block: ${wouldHardBlock.length}`);
console.log(`Would-soft-block: ${wouldSoftBlock.length}`);
if (includeWarns) {
console.log(`Would-warn: ${wouldWarn.length}`);
}
if (Object.keys(patternHits).length > 0) {
const sorted = Object.entries(patternHits).sort((a, b) => b[1] - a[1]);
console.log(`Junk-pattern hits: ${sorted.map(([n, c]) => `${n} ×${c}`).join(', ')}`);
}
if (wouldHardBlock.length > 0) {
console.log('\nTop hard-blocks:');
for (const h of wouldHardBlock.slice(0, 10)) {
console.log(` ${h.file} [${h.matched.join(', ')}] (${h.bytes}b)`);
}
}
if (wouldSoftBlock.length > 0) {
console.log('\nTop soft-blocks (would write but skip embedding):');
for (const s of wouldSoftBlock.slice(0, 10)) {
console.log(` ${s.file} (${s.bytes}b)`);
}
}
}
// ── Dispatcher ──────────────────────────────────────────────
// v0.40.6.0: my duplicate `runStatus` (line ~895 pre-resolution) was
// removed during the v0.40.5 merge. Master's source-health.ts-backed
// runStatus at line ~582 is a strict superset (adds lag / embed coverage
// / failed-job count / queue depth columns). The `buildSyncStatusReport`
// + `printSyncStatusReport` exports from src/commands/sync.ts remain
// available as a library API for callers who want the v0.40.6.0-specific
// shape (used by test/e2e/sync-status-pglite.test.ts as the IRON RULE
// regression).
export async function runSources(engine: BrainEngine, args: string[]): Promise<void> {
const sub = args[0];
const rest = args.slice(1);
switch (sub) {
case 'add': return runAdd(engine, rest);
case 'list': return runList(engine, rest);
case 'remove': return runRemove(engine, rest);
case 'rename': return runRename(engine, rest);
case 'default': return runDefault(engine, rest);
case 'attach': runAttach(rest); return;
case 'detach': runDetach(); return;
case 'federate': return runFederate(engine, rest, true);
case 'unfederate': return runFederate(engine, rest, false);
case 'archive': return runArchive(engine, rest);
case 'restore': return runRestore(engine, rest);
case 'purge': return runPurge(engine, rest);
case 'archived': return runListArchived(engine, rest);
case 'current': return runCurrent(engine, rest);
// v0.40.5.0 Federated Sync v2 (master) + v0.40.6.0 status dashboard
// The status function lives at the line-582 declaration (master's
// source-health.ts-backed version). My duplicate runStatus (line ~895
// in the post-merge file, the buildSyncStatusReport-backed one) is
// removed below since master's federation_health metrics dashboard is
// a superset.
case 'status': return runStatus(engine, rest);
case 'webhook': return runWebhook(engine, rest);
case 'tracked-branch': return runTrackedBranch(engine, rest);
// v0.40.3.0 contextual retrieval (from master)
case 'set-cr-mode': return runSetCrMode(engine, rest);
case 'audit': return runAudit(engine, rest);
case undefined:
case '--help':
case '-h':
printHelp();
return;
default:
console.error(`Unknown sources subcommand: ${sub}`);
printHelp();
process.exit(2);
}
}
function printHelp(): void {
console.log(`gbrain sources — manage multi-source brain configuration (v0.26.5)
Subcommands:
add <id> --path <p> [--name <n>] [--federated|--no-federated]
Register a new source.
list [--json] List registered sources with page counts.
remove <id> [--confirm-destructive] [--dry-run]
Permanently delete a source and all its data.
Shows impact preview. Requires --confirm-destructive
when the source has data (pages/chunks/embeddings).
archive <id> Soft-delete: hide from search, preserve data for ${SOFT_DELETE_TTL_HOURS}h.
restore <id> [--no-federate] Un-archive a soft-deleted source.
status [--json] v0.40.3.0 — read-only per-source dashboard:
last sync, staleness, page count,
embedding coverage, unacked failures.
--json emits {schema_version:1, ...} on
stdout for monitoring pipelines.
archived [--json] List soft-deleted sources and their expiry.
purge [<id>] [--confirm-destructive]
Permanently delete archived sources.
Without <id>: purge all expired archives.
With <id>: force-purge (requires --confirm-destructive).
rename <id> <new-name> Rename display name (id is immutable).
default <id> Set the brain-level default source.
attach <id> Write .gbrain-source in CWD (like kubectl context).
detach Remove .gbrain-source from CWD.
current [--source <id>] [--json] Echo the resolved source id + which tier
won (flag/env/dotfile/local_path/
brain_default/seed_default). Run this
before destructive ops to verify you're
targeting the brain you think you are.
federate <id> Make source appear in cross-source default search.
unfederate <id> Isolate source from default search.
set-cr-mode <id> <none|title|per_chunk_synopsis>
Per-source contextual retrieval mode
override (v0.40.3.0). Pass "unset" or
"default" to clear (NULL falls through
to the global search.mode bundle).
Source id: [a-z0-9-]{1,32}. Immutable citation key.
Destructive operations (remove, purge) show an impact preview before acting.
Pass --dry-run to preview without side effects.
Use 'archive' instead of 'remove' for a safe ${SOFT_DELETE_TTL_HOURS}h grace period.
`);
}