mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* feat(core): shared computeSyncDelta + spend-posture module (#2139) sync-delta.ts: ONE implementation of "what changed since last_commit", consumed by both the sync executor and the inline cost estimator so the gate's dollar figure can't drift from what the sync imports. spend-posture.ts: spend.posture config + parseUsdLimit/formatUsdLimit off-switch parsing (off/unlimited/none → Infinity; undefined at the budget boundary so ledger rows never serialize null). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): delta-aware cost estimator + non-TTY auto-defer + per-source failure acks (#2139) The inline-embed cost gate was a ~400x phantom: it priced the entire tree whenever the working tree was dirty (always, on an active brain), then blocked the daily cron with exit 2. Now: - performSyncInner + the estimator both route through computeSyncDelta, so the estimate mirrors execution (fetch-first delta; dirty-but-caught-up tree → $0). - shouldBlockSync is posture-aware; non-TTY above floor AUTO-DEFERS embeds to capped backfill jobs (exit 0) instead of wedging — single shared runInlineCostGate on both --all and single-source paths. - --full prices delta + stale backlog (full sync sweeps it inline). - off/unlimited on the cost knobs; tokenmax bypasses the backfill cap (still ledgered) but never the cooldown. - --skip-failed/--retry-failed scoped per source; the D15 parallel refusal is lifted (the #1939 ledger is per-source + lock-serialized). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(config): register spend-control keys + validate spend.posture (#2139) Adds spend.posture + the five previously --force-only spend knobs to KNOWN_CONFIG_KEYS so `config set` accepts them directly (removes the archaeology the issue complained about), and rejects invalid spend.posture values at set time with a paste-ready hint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reindex,enrich,onboard): spend.posture across the remaining cost gates (#2139) reindex-code: tokenmax makes the cost gate informational; --max-cost accepts off/unlimited. enrich + onboard --auto: tokenmax lifts the refuse-without-cap guardrail and runs UNCAPPED (spend still ledgered by BudgetTracker). Explicit --max-usd always wins over posture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: cost-gate, delta estimator, spend-posture, off-switch coverage (#2139) New sync-delta + sync-cost-estimate unit suites; rewritten cost-gate serial tests (auto-defer instead of exit 2, posture, off-switch, format split, single-source); parseUsdLimit/posture-aware shouldBlockSync; backfill cap-off + tokenmax-bypass + cooldown-still-refuses; config known-key acceptance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(spend-controls): single spend-control surface + ref-map + follow-up TODOs (#2139) New docs/operations/spend-controls.md (every gate, key, default, off switch, posture interaction); CLAUDE.md reference-map row; two P3 follow-up TODOs (measured chunk-count gating, per-source defer granularity). llms bundles regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(spend): SSRF-harden estimator fetch + complete off/uncapped across reindex/enrich/onboard (#2139) Ship-stage codex pre-landing review caught four P1s in the secondary cost gates: - The delta estimator's fetch-first ran `git fetch` through the plain git() helper, bypassing the GIT_SSRF_FLAGS + GIT_TERMINAL_PROMPT=0 hardening that real sync uses. Added `fetchRemote()` to git-remote.ts (same flags as pullRepo) and route the estimator through it — a cost preview / dry-run can no longer hit a remote through a less-protected path. - `reindex --max-cost off`, `enrich --max-usd off`, `onboard --auto --max-usd off` were parsed but didn't actually proceed/uncap. Now: explicit off (and spend.posture=tokenmax) proceed past the confirmation/missing-cap refusal AND run uncapped. enrich threads an Infinity sentinel mapped to "no BudgetTracker ceiling" (never raw Infinity → no null in audit rows); reindex/onboard use their native undefined=uncapped path. Spend still ledgered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.42.45.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(KEY_FILES): update sync/embedding/git-remote/reindex entries to post-#2139 truth document-release pass: the cost-gate entries described the pre-#2139 behavior (full-tree-ceiling estimator, --skip-failed-rejects-under-parallel, exit-2 confirmation gate). Updated to current truth — delta-aware estimator via the shared computeSyncDelta, per-source failure acks under parallel, non-TTY auto-defer (no exit 2), posture-aware shouldBlockSync. Added entries for the two new core modules (sync-delta.ts, spend-posture.ts) + fetchRemote on git-remote.ts + reindex --max-cost off. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
560 lines
21 KiB
TypeScript
560 lines
21 KiB
TypeScript
/**
|
|
* v0.21.0 Cathedral II Layer 13 (E2) — `gbrain reindex-code`.
|
|
*
|
|
* Explicit backfill for v0.19.0 → v0.21.0 brains. Layer 12's
|
|
* `sources.chunker_version` gate forces a re-walk next sync on any source
|
|
* whose working tree hasn't drifted, but users who want the benefits NOW
|
|
* (before the next sync) get this: walk every page where type='code', read
|
|
* compiled_truth + frontmatter.file, re-import via importCodeFile. Pages
|
|
* flow through the same code path as normal sync (chunker + embeddings +
|
|
* content_hash folding), so a reindex is bit-identical to a fresh sync.
|
|
*
|
|
* Flags:
|
|
* --source <id> Scope to one sources row. Omit = all code pages.
|
|
* --dry-run Preview cost + page count, exit 0.
|
|
* --yes Skip interactive [y/N]. Required for non-TTY + non-JSON.
|
|
* --json Machine-readable ConfirmationRequired / result envelope.
|
|
* --force Bypass importCodeFile's content_hash early-return. Use
|
|
* this for paranoid full reindex when content_hash equals
|
|
* but you still want a re-chunk + re-embed pass.
|
|
*
|
|
* Batched in chunks of 100 pages to avoid OOM on 47K-page brains (codex
|
|
* review Finding 4.4). Idempotent: re-running on already-reindexed pages
|
|
* is a no-op unless --force is passed.
|
|
*/
|
|
|
|
import type { BrainEngine } from '../core/engine.ts';
|
|
import { importCodeFile } from '../core/import-file.ts';
|
|
import { estimateTokens } from '../core/chunkers/code.ts';
|
|
import { getEmbeddingModelName, estimateEmbeddingCostUsd } from '../core/embedding.ts';
|
|
import { errorFor, serializeError } from '../core/errors.ts';
|
|
import { createInterface } from 'readline';
|
|
import { createProgress } from '../core/progress.ts';
|
|
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
|
|
import { BudgetTracker, BudgetExhausted } from '../core/budget/budget-tracker.ts';
|
|
import { withBudgetTracker } from '../core/ai/gateway.ts';
|
|
// v0.41.15.0 (T11, D9): per-batch parallel workers. BudgetExhausted
|
|
// auto-aborts via the worker-pool's D13 bypass.
|
|
import { runSlidingPool } from '../core/worker-pool.ts';
|
|
import { parseWorkers, resolveWorkersWithClamp } from '../core/sync-concurrency.ts';
|
|
|
|
export interface ReindexCodeOpts {
|
|
sourceId?: string;
|
|
dryRun?: boolean;
|
|
yes?: boolean;
|
|
json?: boolean;
|
|
force?: boolean;
|
|
noEmbed?: boolean;
|
|
/** Page batch size. Default 100 (codex Finding 4.4 OOM protection). */
|
|
batchSize?: number;
|
|
/**
|
|
* Cap embedding spend in USD. Default undefined = no cap (legacy behavior).
|
|
* When set, the reindex body runs inside a `withBudgetTracker` scope so
|
|
* every `gateway.embed()` call inside `importCodeFile` composes with the
|
|
* cap. Throws BudgetExhausted (reason='cost') when cumulative exceeds the
|
|
* cap; partial progress is preserved (already-imported pages stay
|
|
* imported, the throw aborts the remaining batch).
|
|
*/
|
|
maxCostUsd?: number;
|
|
/**
|
|
* v0.41.15.0 (T11, D9): per-batch parallel workers. Default 1.
|
|
* PGLite clamps to 1. Recommended 4-8 for large code corpora.
|
|
* BudgetExhausted from any worker aborts the pool via the worker-
|
|
* pool's D13 bypass — the budget cap stays load-bearing under
|
|
* concurrency.
|
|
*/
|
|
workers?: number;
|
|
}
|
|
|
|
export interface ReindexCodeResult {
|
|
status: 'ok' | 'dry_run' | 'cancelled' | 'source_id_required';
|
|
codePages: number;
|
|
reindexed: number;
|
|
skipped: number;
|
|
failed: number;
|
|
totalTokens: number;
|
|
costUsd: number;
|
|
model: string;
|
|
failures?: Array<{ slug: string; error: string }>;
|
|
}
|
|
|
|
/**
|
|
* Voyage publishes voyage-code-3, a code-specialized embedding model that
|
|
* outperforms their general flagships on code retrieval. Per-worktree code
|
|
* brains (Topology 3) are pure source-code, so the recommendation is clean.
|
|
* The nudge surfaces this from `gbrain reindex --code` on dry-run AND
|
|
* execute paths so an agent sees it before spending Anthropic/OpenAI tokens.
|
|
*
|
|
* Allowlist matches against the BARE model name (what getEmbeddingModelName()
|
|
* returns — the gateway strips the provider prefix). Lives in runReindexCode
|
|
* (not the CLI wrapper) because the CLI wrapper's dry-run branch returns
|
|
* before the gate block.
|
|
*/
|
|
const CODE_TUNED_BARE_MODELS = new Set(['voyage-code-3']);
|
|
|
|
export type NudgeDecision =
|
|
| { shouldNudge: false }
|
|
| { shouldNudge: true; currentModel: string; recommendedModel: 'voyage:voyage-code-3' };
|
|
|
|
export function shouldNudgeCodeModel(bareModelName: string | undefined | null): NudgeDecision {
|
|
if (!bareModelName || typeof bareModelName !== 'string') return { shouldNudge: false };
|
|
const trimmed = bareModelName.trim();
|
|
if (!trimmed) return { shouldNudge: false };
|
|
if (CODE_TUNED_BARE_MODELS.has(trimmed.toLowerCase())) return { shouldNudge: false };
|
|
return {
|
|
shouldNudge: true,
|
|
currentModel: trimmed,
|
|
recommendedModel: 'voyage:voyage-code-3',
|
|
};
|
|
}
|
|
|
|
/** Render the nudge to stderr. Pure-stderr by construction so --json stdout stays clean. */
|
|
function printCodeModelNudge(decision: Extract<NudgeDecision, { shouldNudge: true }>): void {
|
|
process.stderr.write(
|
|
`[reindex-code] Configured embedding model is \`${decision.currentModel}\`. For pure code retrieval, Voyage's code-tuned \`voyage-code-3\` typically outperforms general-purpose models. Switch:\n` +
|
|
` gbrain config set embedding_model ${decision.recommendedModel}\n` +
|
|
` gbrain config set embedding_dimensions 1024\n` +
|
|
`Suppress with GBRAIN_NO_CODE_MODEL_NUDGE=1.\n`,
|
|
);
|
|
}
|
|
|
|
interface CodePageRow {
|
|
slug: string;
|
|
compiled_truth: string;
|
|
frontmatter: Record<string, unknown> | null;
|
|
}
|
|
|
|
async function fetchCodePages(
|
|
engine: BrainEngine,
|
|
sourceId: string | undefined,
|
|
batchSize: number,
|
|
offset: number,
|
|
): Promise<CodePageRow[]> {
|
|
// Direct SQL: listPages doesn't expose source_id filtering, and we need
|
|
// compiled_truth + frontmatter anyway (not just the Page shape).
|
|
const sourceClause = sourceId ? `AND p.source_id = '${sourceId.replace(/'/g, "''")}'` : '';
|
|
const rows = await engine.executeRaw<CodePageRow>(
|
|
`SELECT p.slug, p.compiled_truth, p.frontmatter
|
|
FROM pages p
|
|
WHERE p.type = 'code' ${sourceClause}
|
|
ORDER BY p.slug
|
|
LIMIT ${batchSize} OFFSET ${offset}`,
|
|
);
|
|
return rows;
|
|
}
|
|
|
|
async function countCodePages(engine: BrainEngine, sourceId: string | undefined): Promise<number> {
|
|
const sourceClause = sourceId ? `AND p.source_id = '${sourceId.replace(/'/g, "''")}'` : '';
|
|
const rows = await engine.executeRaw<{ n: string | number }>(
|
|
`SELECT COUNT(*)::text AS n FROM pages p WHERE p.type = 'code' ${sourceClause}`,
|
|
);
|
|
if (rows.length === 0) return 0;
|
|
const raw = rows[0]!.n;
|
|
return typeof raw === 'string' ? parseInt(raw, 10) : raw;
|
|
}
|
|
|
|
/**
|
|
* Estimate total embedding cost for a reindex. Walks every code page's
|
|
* compiled_truth and sums tokens. Conservative: does not try to detect
|
|
* unchanged chunks (the incremental embedding cache in importCodeFile does
|
|
* that; this estimate is the ceiling, not the floor).
|
|
*/
|
|
async function estimateReindexCost(
|
|
engine: BrainEngine,
|
|
sourceId: string | undefined,
|
|
batchSize: number,
|
|
): Promise<{ totalTokens: number; totalPages: number }> {
|
|
let totalTokens = 0;
|
|
let totalPages = 0;
|
|
let offset = 0;
|
|
while (true) {
|
|
const batch = await fetchCodePages(engine, sourceId, batchSize, offset);
|
|
if (batch.length === 0) break;
|
|
for (const row of batch) {
|
|
if (row.compiled_truth) totalTokens += estimateTokens(row.compiled_truth);
|
|
totalPages++;
|
|
}
|
|
offset += batch.length;
|
|
if (batch.length < batchSize) break;
|
|
}
|
|
return { totalTokens, totalPages };
|
|
}
|
|
|
|
async function promptYesNo(question: string): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
rl.question(question, (answer) => {
|
|
rl.close();
|
|
const a = answer.trim().toLowerCase();
|
|
resolve(a === 'y' || a === 'yes');
|
|
});
|
|
rl.on('close', () => resolve(false));
|
|
});
|
|
}
|
|
|
|
export async function runReindexCode(
|
|
engine: BrainEngine,
|
|
opts: ReindexCodeOpts = {},
|
|
): Promise<ReindexCodeResult> {
|
|
const batchSize = opts.batchSize ?? 100;
|
|
|
|
const { totalTokens, totalPages } = await estimateReindexCost(engine, opts.sourceId, batchSize);
|
|
const costUsd = estimateEmbeddingCostUsd(totalTokens);
|
|
|
|
// Code-model nudge: fire when there's actual work, the operator hasn't opted
|
|
// out, and JSON-mode isn't active. Lives here (not in runReindexCodeCli) so
|
|
// dry-run paths surface it too — the CLI wrapper's dry-run branch returns
|
|
// before its gate block, which is where any UI placed there would be missed.
|
|
if (
|
|
totalPages > 0 &&
|
|
!opts.json &&
|
|
!opts.noEmbed &&
|
|
process.env.GBRAIN_NO_CODE_MODEL_NUDGE !== '1'
|
|
) {
|
|
const decision = shouldNudgeCodeModel(getEmbeddingModelName());
|
|
if (decision.shouldNudge) printCodeModelNudge(decision);
|
|
}
|
|
|
|
if (opts.dryRun) {
|
|
return {
|
|
status: 'dry_run',
|
|
codePages: totalPages,
|
|
reindexed: 0,
|
|
skipped: 0,
|
|
failed: 0,
|
|
totalTokens,
|
|
costUsd,
|
|
model: getEmbeddingModelName(),
|
|
};
|
|
}
|
|
|
|
if (totalPages === 0) {
|
|
return {
|
|
status: 'ok',
|
|
codePages: 0,
|
|
reindexed: 0,
|
|
skipped: 0,
|
|
failed: 0,
|
|
totalTokens: 0,
|
|
costUsd: 0,
|
|
model: getEmbeddingModelName(),
|
|
};
|
|
}
|
|
|
|
// Walk every code page, re-run importCodeFile with compiled_truth as
|
|
// the content source. relativePath comes from frontmatter.file (set by
|
|
// the original importCodeFile call). Progress via stderr reporter.
|
|
const reporter = createProgress(cliOptsToProgressOptions(getCliOptions()));
|
|
reporter.start('reindex_code.pages', totalPages);
|
|
|
|
let reindexed = 0;
|
|
let skipped = 0;
|
|
let failed = 0;
|
|
const failures: Array<{ slug: string; error: string }> = [];
|
|
let offset = 0;
|
|
let budgetExhausted: BudgetExhausted | null = null;
|
|
|
|
// F3: when --max-cost is set, run the body inside withBudgetTracker so
|
|
// every gateway.embed() call inside importCodeFile composes with the cap.
|
|
// On BudgetExhausted, we catch + persist what's been imported so far,
|
|
// then surface the throw as a partial-progress result the caller can
|
|
// re-run. importCodeFile is idempotent (content_hash short-circuit), so
|
|
// a re-run picks up where the cap fired.
|
|
const reindexBody = async (): Promise<void> => {
|
|
try {
|
|
while (true) {
|
|
const batch = await fetchCodePages(engine, opts.sourceId, batchSize, offset);
|
|
if (batch.length === 0) break;
|
|
|
|
// v0.41.15.0 (T11): per-batch sliding pool. BudgetExhausted
|
|
// from any worker propagates up via the helper's D13 bypass
|
|
// (not caught here) so the outer catch can record partial
|
|
// progress unchanged.
|
|
const writersResolved = resolveWorkersWithClamp(
|
|
engine,
|
|
opts.workers,
|
|
'reindex-code',
|
|
batch.length,
|
|
);
|
|
await runSlidingPool({
|
|
items: batch,
|
|
workers: writersResolved.workers,
|
|
failureLabel: (row) => row.slug,
|
|
onItem: async (row) => {
|
|
const fm = row.frontmatter ?? {};
|
|
const relPath = typeof fm.file === 'string' ? fm.file : null;
|
|
if (!relPath) {
|
|
failed++;
|
|
failures.push({ slug: row.slug, error: 'missing frontmatter.file' });
|
|
reporter.tick();
|
|
return;
|
|
}
|
|
if (!row.compiled_truth) {
|
|
failed++;
|
|
failures.push({ slug: row.slug, error: 'missing compiled_truth' });
|
|
reporter.tick();
|
|
return;
|
|
}
|
|
try {
|
|
const result = await importCodeFile(engine, relPath, row.compiled_truth, {
|
|
noEmbed: opts.noEmbed,
|
|
force: opts.force,
|
|
sourceId: opts.sourceId,
|
|
});
|
|
if (result.status === 'imported') reindexed++;
|
|
else if (result.status === 'skipped') skipped++;
|
|
else {
|
|
failed++;
|
|
failures.push({ slug: row.slug, error: result.error ?? result.status });
|
|
}
|
|
} catch (e: unknown) {
|
|
// BudgetExhausted bypasses the helper's onError and hard-
|
|
// aborts the pool (D13). All other errors are captured
|
|
// per-page so the rest of the batch completes.
|
|
if (e instanceof BudgetExhausted) throw e;
|
|
failed++;
|
|
failures.push({ slug: row.slug, error: e instanceof Error ? e.message : String(e) });
|
|
}
|
|
reporter.tick();
|
|
},
|
|
});
|
|
|
|
offset += batch.length;
|
|
if (batch.length < batchSize) break;
|
|
}
|
|
} finally {
|
|
reporter.finish();
|
|
}
|
|
};
|
|
|
|
try {
|
|
if (typeof opts.maxCostUsd === 'number' && opts.maxCostUsd > 0) {
|
|
const tracker = new BudgetTracker({ maxCostUsd: opts.maxCostUsd, label: 'reindex-code' });
|
|
await withBudgetTracker(tracker, reindexBody);
|
|
} else {
|
|
await reindexBody();
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof BudgetExhausted) {
|
|
budgetExhausted = e;
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
if (budgetExhausted) {
|
|
// Partial-progress result: surfaces what got reindexed before the cap
|
|
// fired. The CLI wrapper translates this into a clear user-facing
|
|
// message + non-zero exit; the library result lets agent callers see
|
|
// what happened without grep'ing stderr.
|
|
return {
|
|
status: 'ok',
|
|
codePages: totalPages,
|
|
reindexed,
|
|
skipped,
|
|
failed,
|
|
totalTokens,
|
|
costUsd: budgetExhausted.spent,
|
|
model: getEmbeddingModelName(),
|
|
failures: [
|
|
{ slug: '(budget)', error: budgetExhausted.message },
|
|
...(failures.length > 0 ? failures : []),
|
|
],
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: 'ok',
|
|
codePages: totalPages,
|
|
reindexed,
|
|
skipped,
|
|
failed,
|
|
totalTokens,
|
|
costUsd,
|
|
model: getEmbeddingModelName(),
|
|
failures: failures.length > 0 ? failures : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* v0.42.11.0 (#1784) — what to print when the cost gate refuses to spend
|
|
* non-interactively without `--yes`. The REFUSAL (exit 2, no spend) is the
|
|
* guardrail and is correct; the FORMAT is a separate axis. Pre-#1784 this path
|
|
* always emitted a JSON envelope even without `--json`, violating the repo's
|
|
* "human by default" convention. Now: JSON only when `--json` is explicit;
|
|
* otherwise a human refusal on stderr. Pure + exported so it's unit-testable
|
|
* without a brain or a real cost preview.
|
|
*/
|
|
export interface CostRefusal {
|
|
stdout?: string;
|
|
stderr?: string;
|
|
}
|
|
export function buildCostRefusal(opts: {
|
|
json: boolean;
|
|
previewMsg: string;
|
|
preview: unknown;
|
|
costUsd: number;
|
|
model: string;
|
|
}): CostRefusal {
|
|
if (opts.json) {
|
|
const envelope = serializeError(errorFor({
|
|
class: 'ConfirmationRequired',
|
|
code: 'cost_preview_requires_yes',
|
|
message: opts.previewMsg,
|
|
hint: 'Pass --yes to proceed, or --dry-run to see the preview and exit 0.',
|
|
}));
|
|
return {
|
|
stdout: JSON.stringify({ error: envelope, preview: opts.preview, costUsd: opts.costUsd, model: opts.model }),
|
|
};
|
|
}
|
|
return {
|
|
stderr:
|
|
`${opts.previewMsg}\n` +
|
|
'Refusing to re-embed non-interactively without confirmation. ' +
|
|
'Pass --yes to proceed, or --dry-run for the preview (exit 0).',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* CLI entrypoint. Parses argv, wires cost-preview gate + JSON/TTY branching,
|
|
* delegates to runReindexCode. Exit codes: 0 on success/dry-run, 2 on
|
|
* ConfirmationRequired (matches sync --all), 1 on runtime error.
|
|
*/
|
|
export async function runReindexCodeCli(engine: BrainEngine, args: string[]): Promise<void> {
|
|
const sourceIdx = args.indexOf('--source');
|
|
const sourceId = sourceIdx >= 0 ? args[sourceIdx + 1] : undefined;
|
|
const dryRun = args.includes('--dry-run');
|
|
const yes = args.includes('--yes') || args.includes('-y');
|
|
const json = args.includes('--json');
|
|
const force = args.includes('--force');
|
|
const noEmbed = args.includes('--no-embed');
|
|
|
|
// v0.41.15.0 (T11, D9): --workers N for per-batch parallelism.
|
|
let workers: number | undefined;
|
|
const workersIdx = args.indexOf('--workers');
|
|
const concurrencyIdx = args.indexOf('--concurrency');
|
|
const workersValIdx = workersIdx >= 0 ? workersIdx + 1 : (concurrencyIdx >= 0 ? concurrencyIdx + 1 : -1);
|
|
if (workersValIdx > 0 && workersValIdx < args.length) {
|
|
try {
|
|
workers = parseWorkers(args[workersValIdx]);
|
|
} catch (e) {
|
|
console.error((e as Error).message);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
// F3: --max-cost / --max-cost-usd both accepted for symmetry with brainstorm.
|
|
// v0.42.42.0 (#2139): `off`/`unlimited`/`none` → no runtime cap AND an explicit
|
|
// "cost isn't the constraint" decision that proceeds past the confirmation gate
|
|
// (like --yes). Numeric must be positive; `0`/garbage is rejected.
|
|
let maxCostUsd: number | undefined;
|
|
let maxCostOff = false;
|
|
for (const flag of ['--max-cost', '--max-cost-usd']) {
|
|
const idx = args.indexOf(flag);
|
|
if (idx >= 0) {
|
|
const v = args[idx + 1];
|
|
const t = (v ?? '').trim().toLowerCase();
|
|
if (['off', 'unlimited', 'none'].includes(t)) {
|
|
maxCostUsd = undefined; // no runtime cap (reindex skips the tracker when unset)
|
|
maxCostOff = true;
|
|
break;
|
|
}
|
|
const n = v ? parseFloat(v) : NaN;
|
|
if (!Number.isFinite(n) || n <= 0) {
|
|
console.error(`gbrain reindex --code: ${flag} requires a positive number in USD, or off/unlimited (got ${v ?? '(missing)'})`);
|
|
process.exit(2);
|
|
}
|
|
maxCostUsd = n;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (dryRun) {
|
|
const result = await runReindexCode(engine, { sourceId, dryRun: true, yes, json, force, noEmbed, maxCostUsd, workers });
|
|
if (json) {
|
|
console.log(JSON.stringify(result));
|
|
} else {
|
|
console.log(
|
|
`reindex-code preview: ${result.codePages} code page(s), ` +
|
|
`~${result.totalTokens.toLocaleString()} tokens, ` +
|
|
`est. $${result.costUsd.toFixed(2)} on ${result.model}.`,
|
|
);
|
|
console.log('--dry-run: exit without reindexing.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Cost preview + gate, before touching the DB.
|
|
if (!noEmbed) {
|
|
const preview = await estimateReindexCost(engine, sourceId, 100);
|
|
const costUsd = estimateEmbeddingCostUsd(preview.totalTokens);
|
|
const previewMsg =
|
|
`reindex-code: ${preview.totalPages} code page(s), ` +
|
|
`~${preview.totalTokens.toLocaleString()} tokens, ` +
|
|
`est. $${costUsd.toFixed(2)} on ${getEmbeddingModelName()}.`;
|
|
|
|
if (preview.totalPages === 0) {
|
|
if (json) {
|
|
console.log(JSON.stringify({ status: 'ok', codePages: 0, reindexed: 0, skipped: 0, failed: 0, totalTokens: 0, costUsd: 0, model: getEmbeddingModelName() }));
|
|
} else {
|
|
console.log('No code pages to reindex.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!yes) {
|
|
// v0.42.42.0 (#2139): spend.posture=tokenmax makes the gate informational
|
|
// — print the estimate and proceed (the operator declared cost isn't the
|
|
// constraint). The spend is still ledgered by the runtime BudgetTracker.
|
|
const { resolveSpendPosture } = await import('../core/spend-posture.ts');
|
|
const posture = await resolveSpendPosture(engine);
|
|
// An explicit `--max-cost off` is the same "cost isn't the constraint"
|
|
// signal as spend.posture=tokenmax — proceed past the confirmation gate.
|
|
if (posture === 'tokenmax' || maxCostOff) {
|
|
const gate = maxCostOff ? 'max_cost_off' : 'posture_tokenmax';
|
|
if (json) {
|
|
console.log(JSON.stringify({ status: 'proceeding', gate, codePages: preview.totalPages, totalTokens: preview.totalTokens, costUsd, model: getEmbeddingModelName() }));
|
|
} else {
|
|
console.log(`${previewMsg} ${maxCostOff ? '--max-cost off' : 'spend.posture=tokenmax'}: proceeding (informational). docs: docs/operations/spend-controls.md`);
|
|
}
|
|
} else {
|
|
const isTTY = Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY);
|
|
if (!isTTY || json) {
|
|
// Guardrail unchanged: refuse + exit 2, no spend. Only the FORMAT splits
|
|
// on --json now (human refusal on stderr otherwise) — #1784.
|
|
const refusal = buildCostRefusal({ json, previewMsg, preview, costUsd, model: getEmbeddingModelName() });
|
|
if (refusal.stdout) console.log(refusal.stdout);
|
|
if (refusal.stderr) console.error(refusal.stderr);
|
|
process.exit(2);
|
|
}
|
|
console.log(previewMsg);
|
|
const answer = await promptYesNo('Proceed? [y/N] ');
|
|
if (!answer) {
|
|
console.log('Cancelled.');
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const result = await runReindexCode(engine, { sourceId, yes, json, force, noEmbed, maxCostUsd, workers });
|
|
if (json) {
|
|
console.log(JSON.stringify(result));
|
|
} else {
|
|
console.log(
|
|
`reindex-code: ${result.reindexed} reindexed, ${result.skipped} skipped, ${result.failed} failed ` +
|
|
`(${result.codePages} total code pages, ~${result.totalTokens.toLocaleString()} tokens, ` +
|
|
`est. $${result.costUsd.toFixed(2)}).`,
|
|
);
|
|
if (result.failures && result.failures.length > 0) {
|
|
console.log(`\n${result.failures.length} failure(s):`);
|
|
for (const f of result.failures.slice(0, 10)) {
|
|
console.log(` ${f.slug}: ${f.error}`);
|
|
}
|
|
if (result.failures.length > 10) {
|
|
console.log(` ... and ${result.failures.length - 10} more`);
|
|
}
|
|
}
|
|
}
|
|
}
|