@djolex999/vir-cli 0.23.3 → 0.24.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +3 -2
  2. package/dist/claude/updater.js +24 -4
  3. package/dist/claude/updater.js.map +1 -1
  4. package/dist/cli/audit.js +136 -0
  5. package/dist/cli/audit.js.map +1 -0
  6. package/dist/cli/compose.js +101 -0
  7. package/dist/cli/compose.js.map +1 -0
  8. package/dist/cli/costLabel.js +14 -0
  9. package/dist/cli/costLabel.js.map +1 -0
  10. package/dist/cli/dedupe.js +92 -0
  11. package/dist/cli/dedupe.js.map +1 -0
  12. package/dist/cli/embed.js +183 -0
  13. package/dist/cli/embed.js.map +1 -0
  14. package/dist/cli/guards.js +40 -0
  15. package/dist/cli/guards.js.map +1 -0
  16. package/dist/cli/init.js +361 -0
  17. package/dist/cli/init.js.map +1 -0
  18. package/dist/cli/lint.js +160 -0
  19. package/dist/cli/lint.js.map +1 -0
  20. package/dist/cli/reconcile.js +110 -62
  21. package/dist/cli/reconcile.js.map +1 -1
  22. package/dist/cli/review.js +26 -5
  23. package/dist/cli/review.js.map +1 -1
  24. package/dist/cli/summarize.js +148 -0
  25. package/dist/cli/summarize.js.map +1 -0
  26. package/dist/cli.js +36 -1100
  27. package/dist/cli.js.map +1 -1
  28. package/dist/config.js +12 -6
  29. package/dist/config.js.map +1 -1
  30. package/dist/cost/report.js +5 -2
  31. package/dist/cost/report.js.map +1 -1
  32. package/dist/diagnostics/doctor.js +23 -0
  33. package/dist/diagnostics/doctor.js.map +1 -1
  34. package/dist/mcp/server.js +40 -28
  35. package/dist/mcp/server.js.map +1 -1
  36. package/dist/pipeline/claudeCli.js +41 -5
  37. package/dist/pipeline/claudeCli.js.map +1 -1
  38. package/dist/pipeline/composer.js +1 -0
  39. package/dist/pipeline/composer.js.map +1 -1
  40. package/dist/pipeline/distillSession.js +96 -0
  41. package/dist/pipeline/distillSession.js.map +1 -0
  42. package/dist/pipeline/distiller.js +12 -5
  43. package/dist/pipeline/distiller.js.map +1 -1
  44. package/dist/pipeline/embeddingSweep.js +9 -5
  45. package/dist/pipeline/embeddingSweep.js.map +1 -1
  46. package/dist/pipeline/run.js +53 -73
  47. package/dist/pipeline/run.js.map +1 -1
  48. package/dist/pipeline/scrubber.js +4 -4
  49. package/dist/pipeline/scrubber.js.map +1 -1
  50. package/dist/pipeline/writer.js +83 -10
  51. package/dist/pipeline/writer.js.map +1 -1
  52. package/dist/state/db.js +8 -8
  53. package/dist/state/db.js.map +1 -1
  54. package/package.json +2 -1
package/dist/cli.js CHANGED
@@ -1,26 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import confirm from "@inquirer/confirm";
3
- import input from "@inquirer/input";
4
- import select from "@inquirer/select";
5
2
  import chalk from "chalk";
6
3
  import { Command } from "commander";
7
4
  import { createInterface } from "node:readline/promises";
8
5
  import { stdin, stdout, argv } from "node:process";
9
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
6
+ import { existsSync, readFileSync } from "node:fs";
10
7
  import { homedir } from "node:os";
11
8
  import { basename, dirname, join } from "node:path";
12
9
  import { fileURLToPath } from "node:url";
13
- import { CONFIG_PATH, maskSecret, ConfigSchema, configExists, ensureVirDir, expandHome, loadConfig, saveConfig, } from "./config.js";
10
+ import { configExists, loadConfig } from "./config.js";
14
11
  import { applyPlan, planUpdates } from "./claude/updater.js";
15
12
  import { pruneCommand } from "./cli/pruneAction.js";
16
13
  import { setupNotifications } from "./cli/notificationsSetup.js";
17
- import { detectDuplicates } from "./dedupe/detector.js";
18
- import { mergeNotes } from "./dedupe/merger.js";
19
- import { contradictionCheck, orphanCheck, stalenessCheck, } from "./lint/linter.js";
20
- import { legacyRelatedCheck, migrateLegacyRelated, } from "./lint/legacyRelated.js";
21
- import { demoteStrays, strayFileCheck } from "./lint/strayFiles.js";
22
14
  import { runPipeline } from "./pipeline/run.js";
23
- import { categorizeTranscriptHead, classifyTranscript, estimateSessionCost, groupByProject, projectNameFor, readTranscriptHead, } from "./pipeline/projects.js";
15
+ import { projectNameFor } from "./pipeline/projects.js";
24
16
  import { persistProjectDecisions, promptProjectDecisions, } from "./cli/projectSelect.js";
25
17
  import { gatherProjectsReport } from "./cli/projects.js";
26
18
  import { scanSessions } from "./pipeline/scanner.js";
@@ -28,15 +20,7 @@ import { parseSession } from "./pipeline/parser.js";
28
20
  import { scoreSession } from "./pipeline/filter.js";
29
21
  import { scrub } from "./pipeline/scrubber.js";
30
22
  import { filterToolCalls } from "./pipeline/toolCallFilter.js";
31
- import { Distiller, callLLM, maybeAnthropicClient, normalizeModelName, resolveModelShorthand, withRateLimitRetry, } from "./pipeline/distiller.js";
32
- import { applyAuditRejects, previewRejects } from "./audit/apply.js";
33
- import { batchByProject } from "./audit/batch.js";
34
- import { noteIsVerified, parseLimitOption, runAudit, selectAuditRows } from "./audit/run.js";
35
- import { composeFromSources, estimateComposeCostTokens, gatherSources, } from "./pipeline/composer.js";
36
- import { computeCost } from "./cost/pricing.js";
37
- import { countByCategory, summarizeAll, summarizeProject, } from "./pipeline/summarizer.js";
38
- import { buildPeriodPrompt, estimatePeriodCostTokens, periodLabel, periodRange, selectNotesInPeriod, summarizePeriod, } from "./pipeline/periodSummary.js";
39
- import { embedNoteWithProvider, resolveEmbeddingProvider, } from "./search/provider.js";
23
+ import { Distiller } from "./pipeline/distiller.js";
40
24
  import { acquireLock, LockHeldError, releaseLock } from "./pipeline/lock.js";
41
25
  import { loadIndex, searchWithOutcome, vaultRoot } from "./search/retriever.js";
42
26
  import { QUERY_LOG_PATH, readQueryLog, recordQueryEvent, } from "./search/queryLog.js";
@@ -47,9 +31,7 @@ import { runMcpServer } from "./mcp/server.js";
47
31
  import { runReview } from "./cli/review.js";
48
32
  import { runReviewJson } from "./cli/reviewJson.js";
49
33
  import { runAction } from "./cli/runAction.js";
50
- import { buildInitConfig } from "./cli/initConfig.js";
51
34
  import { runReconcile } from "./cli/reconcile.js";
52
- import { runEmbedSetup } from "./cli/embedSetup.js";
53
35
  import { installToClaudeCode, isClaudeAvailable, isInstalled, uninstallFromClaudeCode, } from "./mcp/install.js";
54
36
  import { install as installDaemon, status as daemonStatus, uninstall as uninstallDaemon, } from "./daemon/index.js";
55
37
  import { StateDb } from "./state/db.js";
@@ -57,7 +39,15 @@ import { parseDuration, readCostLog } from "./cost/log.js";
57
39
  import { buildReport } from "./cost/report.js";
58
40
  import * as ui from "./ui/display.js";
59
41
  import { VaultWriter } from "./pipeline/writer.js";
42
+ import { withPipelineLock } from "./cli/guards.js";
60
43
  import { runDoctor, runDoctorJson } from "./diagnostics/doctor.js";
44
+ import { embedCommand } from "./cli/embed.js";
45
+ import { lintCommand } from "./cli/lint.js";
46
+ import { summarizeCommand } from "./cli/summarize.js";
47
+ import { auditCommand } from "./cli/audit.js";
48
+ import { composeCommand } from "./cli/compose.js";
49
+ import { dedupeCommand } from "./cli/dedupe.js";
50
+ import { cmdInit } from "./cli/init.js";
61
51
  // Read version at runtime from package.json (one dir up from dist/cli.js) so
62
52
  // `vir --version` never drifts from the published version. rootDir is ./src,
63
53
  // so package.json can't be imported — read it instead.
@@ -149,7 +139,7 @@ program
149
139
  : undefined,
150
140
  onConfirm: skipPrompt
151
141
  ? undefined
152
- : async (newCount) => confirmCostIfNeeded(cfg, newCount),
142
+ : async (newCount, estimatedUsd) => confirmCostIfNeeded(cfg, newCount, estimatedUsd),
153
143
  });
154
144
  }
155
145
  finally {
@@ -168,21 +158,18 @@ program
168
158
  process.exitCode = 1;
169
159
  }
170
160
  }));
171
- // Dollar-estimate label for the ACTIVE provider. On the subscription path the
172
- // honest label is quota, never "$0.00" — a zero would read as "free API call"
173
- // and hide that the run consumes the user's Claude Code limits.
174
- function estCostLabel(cfg, model, inputTokens, outputTokens) {
175
- if (cfg.provider === "claude-cli")
176
- return "subscription quota (no $)";
177
- return ui.formatUsd(computeCost(cfg.provider, model, inputTokens, outputTokens, cfg.pricing, cfg.kieTopUpTier));
178
- }
179
- async function confirmCostIfNeeded(cfg, newCount) {
161
+ async function confirmCostIfNeeded(cfg, newCount, estimatedUsd) {
180
162
  if (newCount <= 20)
181
163
  return true;
164
+ // Upper bound from transcript sizes; sessions the filter drops cost nothing.
165
+ const estimate = cfg.provider === "claude-cli"
166
+ ? "subscription quota (no $)"
167
+ : estimatedUsd === null
168
+ ? "unknown (no price for the configured models)"
169
+ : `up to ${ui.formatUsd(estimatedUsd)}`;
182
170
  ui.box([
183
171
  `${ui.text(String(newCount))} ${ui.dim("new sessions to process")}`,
184
- `${ui.dim("estimated:")} ${ui.warn("$1–5")} ${ui.dim("depending on session")}`,
185
- `${ui.dim("depth (deep code reviews cost more)")}`,
172
+ `${ui.dim("estimated:")} ${ui.warn(estimate)} ${ui.dim("(filtered sessions cost nothing)")}`,
186
173
  `${ui.dim("provider:")} ${ui.accent(cfg.provider)}`,
187
174
  ], { title: "cost estimate" });
188
175
  const rl = createInterface({ input: stdin, output: stdout });
@@ -223,6 +210,9 @@ program
223
210
  if (report.subscriptionCalls > 0) {
224
211
  ui.stat("subscription", `${report.subscriptionCalls} claude-cli calls (quota, no $ — excluded from totals)`);
225
212
  }
213
+ if (report.unpricedCalls > 0) {
214
+ ui.stat("unpriced", `${report.unpricedCalls} calls to a model with no price (real spend, not in the total — add it under pricing in config.json)`, ui.warn);
215
+ }
226
216
  ui.stat("sessions", report.sessionCount);
227
217
  ui.stat("total", ui.formatUsd(report.total), ui.warn);
228
218
  ui.stat("median/session", ui.formatUsd(report.median));
@@ -408,6 +398,9 @@ program
408
398
  continue;
409
399
  }
410
400
  const result = applyPlan(p);
401
+ // A file that could not be updated is a failed sync, not a warning.
402
+ if (!result.ok)
403
+ process.exitCode = 1;
411
404
  ui.row(result.ok ? ui.success(ui.CHECK) : ui.errorColor(ui.CROSS), ui.text(result.ok || result.reason === undefined
412
405
  ? collapseHome(p.target)
413
406
  : `${collapseHome(p.target)} — ${result.reason}`));
@@ -420,73 +413,8 @@ program
420
413
  program
421
414
  .command("dedupe")
422
415
  .description("Interactive duplicate detection + merge")
423
- .action(runAction(async () => {
424
- const cfg = loadConfig();
425
- const db = new StateDb();
426
- try {
427
- console.log("scanning for duplicate candidates...");
428
- const result = await detectDuplicates(cfg, db);
429
- console.log(`${result.checked} candidate pairs checked, ${result.duplicates.length} flagged as duplicates`);
430
- if (result.duplicates.length === 0) {
431
- return;
432
- }
433
- const rl = createInterface({ input: stdin, output: stdout });
434
- let merged = 0;
435
- let skipped = 0;
436
- for (const dup of result.duplicates) {
437
- console.log("\nDuplicate found:");
438
- console.log(`A: ${noteRefOf(dup.a)} (conf: ${dup.a.confidence.toFixed(2)}, ${dup.a.startedAt?.slice(0, 10) ?? "?"})`);
439
- console.log(` "${preview(dup.a.content)}"`);
440
- console.log(`B: ${noteRefOf(dup.b)} (conf: ${dup.b.confidence.toFixed(2)}, ${dup.b.startedAt?.slice(0, 10) ?? "?"})`);
441
- console.log(` "${preview(dup.b.content)}"`);
442
- console.log(`Reason: ${dup.reason}`);
443
- const suggestion = dup.keepWhich === "merge"
444
- ? "merge both"
445
- : `keep ${dup.keepWhich}`;
446
- console.log(`Suggested: ${suggestion}`);
447
- const ans = (await rl.question("[k]eep suggestion / [s]wap / [m]erge / [x] skip: "))
448
- .trim()
449
- .toLowerCase();
450
- let action = null;
451
- if (ans === "k" || ans === "") {
452
- action = dup.keepWhich;
453
- }
454
- else if (ans === "s") {
455
- action =
456
- dup.keepWhich === "A"
457
- ? "B"
458
- : dup.keepWhich === "B"
459
- ? "A"
460
- : "merge";
461
- }
462
- else if (ans === "m") {
463
- action = "merge";
464
- }
465
- else if (ans === "x") {
466
- skipped += 1;
467
- continue;
468
- }
469
- else {
470
- console.log(chalk.yellow("unknown input — skipping"));
471
- skipped += 1;
472
- continue;
473
- }
474
- try {
475
- const outcome = await mergeNotes(cfg, db, dup.a, dup.b, action);
476
- merged += 1;
477
- console.log(chalk.green(`merged (${outcome.action}): winner=${outcome.winnerPath} archived=${outcome.archivedPath}`));
478
- }
479
- catch (err) {
480
- console.error(chalk.red(`merge failed: ${err.message}`));
481
- }
482
- }
483
- rl.close();
484
- console.log(`\n${result.duplicates.length} pairs reviewed, ${merged} merged, ${skipped} skipped.`);
485
- }
486
- finally {
487
- db.close();
488
- }
489
- }));
416
+ .option("--yes", "Skip the cost confirmation prompt")
417
+ .action(runAction(dedupeCommand));
490
418
  program
491
419
  .command("lint")
492
420
  .description("Run orphan, stray-file, legacy-Related, staleness, and contradiction checks on the vault")
@@ -496,156 +424,8 @@ program
496
424
  .option("--fix", "Move retitle-duplicate strays into archived/ (other strays are left alone), and move legacy Related content into ## Details")
497
425
  .option("--stale", "Run only the staleness check (free)")
498
426
  .option("--contradictions", "Run only the contradiction check (Haiku tokens)")
499
- .action(runAction(async (opts) => {
500
- const cfg = loadConfig();
501
- const db = new StateDb();
502
- try {
503
- const runAll = !opts.orphans &&
504
- !opts.strays &&
505
- !opts.legacyRelated &&
506
- !opts.stale &&
507
- !opts.contradictions;
508
- const checks = [];
509
- if (runAll || opts.orphans)
510
- checks.push("orphans");
511
- if (runAll || opts.strays)
512
- checks.push("strays");
513
- if (runAll || opts.legacyRelated)
514
- checks.push("legacy-related");
515
- if (runAll || opts.stale)
516
- checks.push("stale");
517
- if (runAll || opts.contradictions)
518
- checks.push("contradictions");
519
- ui.header("lint");
520
- ui.blank();
521
- let orphanCount = 0;
522
- let strayCount = 0;
523
- let legacyCount = 0;
524
- let staleCount = 0;
525
- let contradictionCount = 0;
526
- let issues = 0;
527
- if (runAll || opts.orphans) {
528
- const sp = ui.spinner("checking orphans").start();
529
- const r = orphanCheck(cfg);
530
- sp.stop();
531
- orphanCount = r.orphans.length;
532
- issues += orphanCount;
533
- if (orphanCount === 0) {
534
- ui.row(ui.success(ui.CHECK), `${ui.text("orphans")} ${ui.dim("none")}`);
535
- }
536
- else {
537
- ui.row(ui.errorColor(ui.CROSS), `${ui.text("orphans")} ${ui.dim("(" + orphanCount + ")")}`);
538
- for (const o of r.orphans) {
539
- console.log(` ${ui.dim(ui.BULLET)} ${ui.text(ui.shortNotePath(o))}`);
540
- }
541
- }
542
- }
543
- if (runAll || opts.strays) {
544
- const sp = ui.spinner("checking stray files").start();
545
- const r = strayFileCheck(cfg, db);
546
- sp.stop();
547
- strayCount = r.strays.length;
548
- issues += strayCount;
549
- if (strayCount === 0) {
550
- ui.row(ui.success(ui.CHECK), `${ui.text("strays")} ${ui.dim("none")}`);
551
- }
552
- else {
553
- ui.row(ui.errorColor(ui.CROSS), `${ui.text("strays")} ${ui.dim("(" + strayCount + " of " + r.scanned + " files)")}`);
554
- for (const st of r.strays) {
555
- // The sibling is the whole point: it is what makes a stray safe
556
- // to demote. An `unknown` stray has none, and gets no advice.
557
- const note = st.liveSibling !== null
558
- ? ui.dim(`${ui.ARROW} live: ${st.liveSibling}`)
559
- : ui.warn("only copy — inspect before removing");
560
- console.log(` ${ui.dim(ui.BULLET)} ${ui.text(ui.shortNotePath(st.relPath))} ${ui.muted(st.kind)} ${note}`);
561
- }
562
- if (opts.fix) {
563
- const fixed = demoteStrays(cfg, r);
564
- strayCount -= fixed.moved;
565
- issues -= fixed.moved;
566
- ui.row(ui.success(ui.CHECK), `${ui.text("strays")} ${ui.dim(`moved ${fixed.moved} to archived/, left ${fixed.left} untouched`)}`);
567
- }
568
- }
569
- }
570
- if (runAll || opts.legacyRelated) {
571
- const sp = ui.spinner("checking legacy Related sections").start();
572
- const r = legacyRelatedCheck(db);
573
- sp.stop();
574
- const withContent = r.rows.filter((x) => x.kept > 0);
575
- legacyCount = r.rows.length;
576
- issues += legacyCount;
577
- if (legacyCount === 0) {
578
- ui.row(ui.success(ui.CHECK), `${ui.text("legacy-related")} ${ui.dim("none")}`);
579
- }
580
- else {
581
- const bullets = withContent.reduce((n, x) => n + x.kept, 0);
582
- ui.row(ui.errorColor(ui.CROSS), `${ui.text("legacy-related")} ${ui.dim(`(${legacyCount} of ${r.scanned} stored notes; ${withContent.length} hold ${bullets} content bullets a rewrite drops)`)}`);
583
- if (opts.fix) {
584
- const fixed = await migrateLegacyRelated(db, new VaultWriter(cfg, db));
585
- legacyCount -= fixed.migrated;
586
- issues -= fixed.migrated;
587
- ui.row(ui.success(ui.CHECK), `${ui.text("legacy-related")} ${ui.dim(`moved content into ## Details on ${fixed.migrated} rows, re-rendered ${fixed.rewritten} notes`)}`);
588
- for (const e of fixed.errors) {
589
- console.log(` ${ui.dim(ui.BULLET)} ${ui.errorColor(e.path)} ${ui.muted(e.message)}`);
590
- }
591
- }
592
- else {
593
- console.log(` ${ui.dim(ui.ARROW)} ${ui.muted("vir lint --legacy-related --fix")}`);
594
- }
595
- }
596
- }
597
- if (runAll || opts.stale) {
598
- const sp = ui.spinner("checking staleness").start();
599
- const stale = stalenessCheck(cfg, db);
600
- sp.stop();
601
- staleCount = stale.length;
602
- issues += staleCount;
603
- if (staleCount === 0) {
604
- ui.row(ui.success(ui.CHECK), `${ui.text("stale")} ${ui.dim("none")}`);
605
- }
606
- else {
607
- ui.row(ui.errorColor(ui.CROSS), `${ui.text("stale")} ${ui.dim("(" + staleCount + ")")}`);
608
- for (const s of stale) {
609
- console.log(` ${ui.dim(ui.BULLET)} ${ui.text(ui.shortNotePath(s.relPath))} ${ui.muted(`${s.ageDays}d`)} ${ui.dim(`${s.newerSameProjectCount} newer ${s.project} sessions`)}`);
610
- }
611
- }
612
- }
613
- if (runAll || opts.contradictions) {
614
- const sp = ui.spinner("checking contradictions (haiku)").start();
615
- const c = await contradictionCheck(cfg, db);
616
- sp.stop();
617
- contradictionCount = c.contradictions.length;
618
- issues += contradictionCount;
619
- if (contradictionCount === 0) {
620
- ui.row(ui.success(ui.CHECK), `${ui.text("contradictions")} ${ui.dim(`none found in ${c.checked} pairs`)}`);
621
- }
622
- else {
623
- ui.row(ui.errorColor(ui.CROSS), `${ui.text("contradictions")} ${ui.dim("(" + contradictionCount + ")")}`);
624
- for (const x of c.contradictions) {
625
- console.log(` ${ui.dim(ui.BULLET)} ${ui.text(ui.shortNotePath(x.a))} ${ui.dim("vs")} ${ui.text(ui.shortNotePath(x.b))}`);
626
- console.log(` ${ui.muted(x.reason)}`);
627
- }
628
- }
629
- }
630
- ui.blank();
631
- ui.divider();
632
- ui.summary({
633
- issues: {
634
- value: issues,
635
- color: issues > 0 ? ui.errorColor : ui.success,
636
- },
637
- orphans: { value: orphanCount, color: ui.muted },
638
- strays: { value: strayCount, color: ui.muted },
639
- "legacy-related": { value: legacyCount, color: ui.muted },
640
- stale: { value: staleCount, color: ui.muted },
641
- contradictions: { value: contradictionCount, color: ui.muted },
642
- });
643
- ui.divider();
644
- }
645
- finally {
646
- db.close();
647
- }
648
- }));
427
+ .option("--yes", "Skip the cost confirmation for the contradiction check")
428
+ .action(runAction(lintCommand));
649
429
  program
650
430
  .command("summarize [project]")
651
431
  .description("Generate a project, --all, or period (--week/--month) knowledge summary")
@@ -655,304 +435,14 @@ program
655
435
  .option("--model <model>", "Synthesis model: haiku | sonnet (period only)")
656
436
  .option("--dry-run", "Show note count + estimated cost, exit before LLM")
657
437
  .option("--yes", "Skip the cost confirmation prompt (period only)")
658
- .action(runAction(async (project, opts) => {
659
- const cfg = loadConfig();
660
- const wantWeek = opts.week !== undefined;
661
- const wantMonth = opts.month !== undefined;
662
- if (wantWeek && wantMonth) {
663
- console.error(chalk.red("use --week or --month, not both"));
664
- process.exitCode = 1;
665
- return;
666
- }
667
- // ── period summary path (--week / --month) ──────────────────────────
668
- if (wantWeek || wantMonth) {
669
- if (project || opts.all) {
670
- console.error(chalk.red("--week/--month cannot be combined with a project or --all"));
671
- process.exitCode = 1;
672
- return;
673
- }
674
- if (opts.model && !["haiku", "sonnet"].includes(opts.model)) {
675
- console.error(chalk.red(`--model must be 'haiku' or 'sonnet', got '${opts.model}'`));
676
- process.exitCode = 1;
677
- return;
678
- }
679
- const kind = wantWeek ? "week" : "month";
680
- const raw = wantWeek ? opts.week : opts.month;
681
- // commander yields `true` for a bare flag, or the string value for `--week 2`
682
- const offset = typeof raw === "string" ? Number.parseInt(raw, 10) : 0;
683
- if (!Number.isInteger(offset) || offset < 0) {
684
- console.error(chalk.red(`--${kind} offset must be a non-negative integer`));
685
- process.exitCode = 1;
686
- return;
687
- }
688
- const period = { kind, offset };
689
- const now = new Date();
690
- const db = new StateDb();
691
- try {
692
- ui.header("summarize");
693
- ui.divider();
694
- const range = periodRange(period, now);
695
- const label = periodLabel(period, range);
696
- console.log(ui.text(label));
697
- ui.divider();
698
- ui.blank();
699
- const notes = selectNotesInPeriod(db.listDistilled(), period, now);
700
- if (notes.length === 0) {
701
- ui.row(ui.warn(ui.WARN_GLYPH), ui.text(`no notes distilled in ${label} — nothing to summarize`));
702
- ui.line(ui.dim(" run `vir run` to distill more sessions first"));
703
- return;
704
- }
705
- const model = normalizeModelName(resolveModelShorthand(opts.model ?? cfg.models.distill), cfg.provider);
706
- const counts = countByCategory(notes);
707
- const prompt = buildPeriodPrompt(label, notes, counts);
708
- const { inputTokens, outputTokens } = estimatePeriodCostTokens(prompt);
709
- const estCost = estCostLabel(cfg, model, inputTokens, outputTokens);
710
- ui.summary({
711
- notes: { value: notes.length, color: ui.info },
712
- model: { value: model, color: ui.accent },
713
- "est. cost": { value: estCost, color: ui.warn },
714
- });
715
- ui.divider();
716
- if (opts.dryRun === true) {
717
- ui.line(ui.dim(" dry run — no synthesis performed; actuals may vary ±30%"));
718
- return;
719
- }
720
- if (opts.yes !== true) {
721
- const proceed = await confirm({
722
- message: `synthesize with ${model} (~${estCost})?`,
723
- default: true,
724
- });
725
- if (!proceed) {
726
- ui.line(ui.dim("aborted"));
727
- return;
728
- }
729
- }
730
- const sp = ui.spinner("synthesizing period summary").start();
731
- let result;
732
- try {
733
- result = await summarizePeriod(cfg, db, period, {
734
- now,
735
- model: opts.model,
736
- });
737
- sp.stop();
738
- }
739
- catch (err) {
740
- sp.fail(ui.errorColor(err.message));
741
- process.exitCode = 1;
742
- return;
743
- }
744
- if (!result) {
745
- ui.row(ui.warn(ui.WARN_GLYPH), ui.text("nothing to summarize"));
746
- return;
747
- }
748
- ui.row(ui.success(ui.CHECK), ui.text(`wrote ${result.relPath} (${result.noteCount} notes)`));
749
- ui.blank();
750
- }
751
- finally {
752
- db.close();
753
- }
754
- return;
755
- }
756
- // ── project / --all path (unchanged) ────────────────────────────────
757
- const db = new StateDb();
758
- try {
759
- if (opts.all) {
760
- const results = await summarizeAll(cfg, db);
761
- if (results.length === 0) {
762
- console.log(chalk.yellow("no projects with notes"));
763
- return;
764
- }
765
- for (const r of results) {
766
- console.log(chalk.green(`summarized project/${r.slug}`) +
767
- ` (${r.counts.total} sessions)`);
768
- }
769
- return;
770
- }
771
- if (!project) {
772
- console.error(chalk.red("usage: vir summarize <project> | --all | --week [n] | --month [n]"));
773
- process.exitCode = 1;
774
- return;
775
- }
776
- const res = await summarizeProject(cfg, project, db);
777
- if (!res) {
778
- console.log(chalk.yellow(`no distilled notes for project '${project}'`));
779
- return;
780
- }
781
- console.log(chalk.green(`summarized project/${res.slug}`) +
782
- ` (${res.counts.total} sessions) → ${res.path}`);
783
- }
784
- finally {
785
- db.close();
786
- }
787
- }));
438
+ .action(runAction(summarizeCommand));
788
439
  program
789
440
  .command("embed")
790
441
  .description("Generate embeddings for distilled notes")
791
442
  .option("--force", "Regenerate even if embedding already exists")
792
443
  .option("--setup", "Install the local embedding provider (fastembed + bge-small) on demand")
793
444
  .option("--yes", "Skip the --setup disk-cost confirmation")
794
- .action(runAction(async (opts) => {
795
- if (opts.setup) {
796
- await runEmbedSetup(opts.yes === true);
797
- return;
798
- }
799
- const cfg = loadConfig();
800
- ui.header("embed");
801
- ui.blank();
802
- const provider = await resolveEmbeddingProvider(cfg.embeddingProvider);
803
- if (!provider) {
804
- ui.row(ui.errorColor(ui.CROSS), ui.text("no embedding provider available"));
805
- ui.line(ui.dim(" vir embed --setup # local provider, no Ollama needed (~233 MB)"));
806
- ui.line(ui.dim(" or: brew install ollama && ollama pull nomic-embed-text && ollama serve"));
807
- process.exitCode = 1;
808
- return;
809
- }
810
- const provenance = provider.provenance();
811
- const db = new StateDb();
812
- try {
813
- const rows = db.listDistilled();
814
- const root = join(cfg.vaultPath, cfg.outputDir);
815
- const embeddedRows = [
816
- ...db.getEmbeddings(root),
817
- ...db.getArticleEmbeddings(),
818
- ...db.getTopicEmbeddings(root, cfg.topicsDir),
819
- ...db.getPdfEmbeddings(),
820
- ];
821
- // Crossing a model boundary is an index invalidation, not a gap fill —
822
- // it needs --force AND explicit consent, with the cost stated first.
823
- // Without --force, mismatched rows stay excluded from vector search
824
- // (counted, reported) and only NULL-embedding gaps are filled.
825
- const mismatched = embeddedRows.filter((r) => r.embeddingModel !== provider.modelName).length;
826
- if (mismatched > 0 && !opts.force) {
827
- ui.row(ui.warn(ui.WARN_GLYPH), ui.text(`${mismatched} note(s) are embedded under a different model than the active one (${provider.modelName})`));
828
- ui.line(ui.dim("they are excluded from vector search until re-embedded"));
829
- ui.line(ui.dim("run `vir embed --force` to re-embed the whole index"));
830
- }
831
- if (mismatched > 0 && opts.force) {
832
- const estSecs = Math.max(1, Math.round(rows.length * 0.15));
833
- ui.row(ui.warn(ui.WARN_GLYPH), ui.text(`model boundary: re-embedding ${rows.length} note(s) under ${provider.modelName} (~${estSecs}s), replacing vectors from other models`));
834
- if (!opts.yes) {
835
- const rl = createInterface({ input: stdin, output: stdout });
836
- const answer = (await rl.question("Proceed? [y/N] ")).trim().toLowerCase();
837
- rl.close();
838
- if (answer !== "y" && answer !== "yes") {
839
- ui.line(ui.dim("aborted — index unchanged"));
840
- return;
841
- }
842
- }
843
- }
844
- const existing = new Set(db
845
- .getEmbeddings(root)
846
- .filter((r) => r.embeddingModel === provider.modelName)
847
- .map((r) => r.sessionId));
848
- const target = opts.force
849
- ? rows
850
- : rows.filter((r) => !existing.has(r.sessionId));
851
- // Topics live in their own table, so backfill them here too — a compose
852
- // while Ollama was down heals on a manual `vir embed`, not only the next
853
- // `vir run` sweep. --force re-embeds all topics; otherwise just NULL ones.
854
- const topicTargets = opts.force
855
- ? db.listTopics().map((t) => ({ id: t.id, content: t.content }))
856
- : db.listTopicEmbeddingTargets();
857
- // Articles live in their own table too — back-fill them here so a clip
858
- // distilled while Ollama was down heals on a manual `vir embed`, not only
859
- // the next `vir run` sweep. --force re-embeds all embeddable articles
860
- // (keyed by source path); otherwise just the NULL-embedding ones.
861
- const articleTargets = opts.force
862
- ? db.listArticles().map((a) => ({ path: a.path, content: a.content }))
863
- : db.listArticleEmbeddingTargets();
864
- // PDFs live in their own table too — same back-fill rationale as articles.
865
- const pdfTargets = opts.force
866
- ? db.listPdfs().map((p) => ({ path: p.path, content: p.content }))
867
- : db.listPdfEmbeddingTargets();
868
- const total = target.length +
869
- topicTargets.length +
870
- articleTargets.length +
871
- pdfTargets.length;
872
- if (total === 0) {
873
- ui.row(ui.success(ui.CHECK), ui.text("all notes already embedded"));
874
- return;
875
- }
876
- const sp = ui.spinner(`embedding notes (0/${total})`).start();
877
- let embedded = 0;
878
- let skipped = 0;
879
- let errors = 0;
880
- for (let i = 0; i < target.length; i += 1) {
881
- const r = target[i];
882
- if (!r)
883
- continue;
884
- if (r.content.trim().length === 0) {
885
- skipped += 1;
886
- continue;
887
- }
888
- const vec = await embedNoteWithProvider(provider, r.content);
889
- if (!vec) {
890
- errors += 1;
891
- continue;
892
- }
893
- db.storeEmbedding(r.sessionId, vec, provenance);
894
- embedded += 1;
895
- sp.text = ui.dim(`embedding notes (${embedded}/${total})`);
896
- }
897
- for (const t of topicTargets) {
898
- if (!t.content || t.content.trim().length === 0) {
899
- skipped += 1;
900
- continue;
901
- }
902
- const vec = await embedNoteWithProvider(provider, t.content);
903
- if (!vec) {
904
- errors += 1;
905
- continue;
906
- }
907
- db.storeTopicEmbedding(t.id, vec, provenance);
908
- embedded += 1;
909
- sp.text = ui.dim(`embedding notes (${embedded}/${total})`);
910
- }
911
- for (const a of articleTargets) {
912
- if (!a.content || a.content.trim().length === 0) {
913
- skipped += 1;
914
- continue;
915
- }
916
- const vec = await embedNoteWithProvider(provider, a.content);
917
- if (!vec) {
918
- errors += 1;
919
- continue;
920
- }
921
- db.storeArticleEmbedding(a.path, vec, provenance);
922
- embedded += 1;
923
- sp.text = ui.dim(`embedding notes (${embedded}/${total})`);
924
- }
925
- for (const p of pdfTargets) {
926
- if (!p.content || p.content.trim().length === 0) {
927
- skipped += 1;
928
- continue;
929
- }
930
- const vec = await embedNoteWithProvider(provider, p.content);
931
- if (!vec) {
932
- errors += 1;
933
- continue;
934
- }
935
- db.storePdfEmbedding(p.path, vec, provenance);
936
- embedded += 1;
937
- sp.text = ui.dim(`embedding notes (${embedded}/${total})`);
938
- }
939
- sp.succeed(ui.text(`embedded ${embedded} notes`));
940
- ui.blank();
941
- ui.divider();
942
- ui.summary({
943
- embedded: { value: embedded, color: ui.success },
944
- skipped: { value: skipped, color: ui.muted },
945
- errors: {
946
- value: errors,
947
- color: errors > 0 ? ui.errorColor : ui.dim,
948
- },
949
- });
950
- ui.divider();
951
- }
952
- finally {
953
- db.close();
954
- }
955
- }));
445
+ .action(runAction(embedCommand));
956
446
  // JSON path for `vir query --json`: stdout gets a single JSON array on success
957
447
  // (`[]` when nothing matched), exit 0. On failure stdout stays EMPTY so the
958
448
  // plugin can `JSON.parse(stdout)` unguarded — the error goes to stderr as a
@@ -1094,88 +584,7 @@ program
1094
584
  .option("--model <model>", "Synthesis model: haiku | sonnet")
1095
585
  .option("--dry-run", "Show top sources + estimated cost, exit before LLM")
1096
586
  .option("--yes", "Skip the cost confirmation prompt")
1097
- .action(runAction(async (topic, opts) => {
1098
- const cfg = loadConfig();
1099
- if (opts.model && !["haiku", "sonnet"].includes(opts.model)) {
1100
- console.error(chalk.red(`--model must be 'haiku' or 'sonnet', got '${opts.model}'`));
1101
- process.exitCode = 1;
1102
- return;
1103
- }
1104
- const limit = Math.min(50, Math.max(1, Number.parseInt(opts.limit ?? "20", 10) || 20));
1105
- const db = new StateDb();
1106
- try {
1107
- ui.header("compose");
1108
- ui.divider();
1109
- console.log(ui.text(topic));
1110
- ui.divider();
1111
- ui.blank();
1112
- const sp = ui.spinner("searching vault for related notes").start();
1113
- const sources = await gatherSources(cfg, db, topic, limit);
1114
- sp.stop();
1115
- if (sources.length === 0) {
1116
- ui.row(ui.warn(ui.WARN_GLYPH), ui.text("no related notes found — nothing to synthesize"));
1117
- ui.line(ui.dim(" run `vir run` to distill more sessions first"));
1118
- return;
1119
- }
1120
- for (const s of sources.slice(0, 10))
1121
- ui.sourceRow(s.title, s.score);
1122
- ui.divider();
1123
- const model = normalizeModelName(resolveModelShorthand(opts.model ?? cfg.models.distill), cfg.provider);
1124
- const { inputTokens, outputTokens } = estimateComposeCostTokens(topic, sources);
1125
- const estCost = estCostLabel(cfg, model, inputTokens, outputTokens);
1126
- ui.summary({
1127
- sources: { value: sources.length, color: ui.info },
1128
- model: { value: model, color: ui.accent },
1129
- "est. cost": { value: estCost, color: ui.warn },
1130
- });
1131
- ui.divider();
1132
- if (opts.dryRun) {
1133
- ui.line(ui.dim(" dry run — no synthesis performed; actuals may vary ±30%"));
1134
- return;
1135
- }
1136
- if (opts.yes !== true) {
1137
- const proceed = await confirm({
1138
- message: `synthesize with ${model} (~${estCost})?`,
1139
- default: true,
1140
- });
1141
- if (!proceed) {
1142
- ui.line(ui.dim("aborted"));
1143
- return;
1144
- }
1145
- }
1146
- const writer = new VaultWriter(cfg, db);
1147
- const sp2 = ui.spinner("synthesizing topic page").start();
1148
- let result;
1149
- try {
1150
- result = await composeFromSources(cfg, db, topic, sources, writer, {
1151
- forceModel: opts.model,
1152
- });
1153
- sp2.stop();
1154
- }
1155
- catch (err) {
1156
- sp2.fail(ui.errorColor(err.message));
1157
- process.exitCode = 1;
1158
- return;
1159
- }
1160
- ui.row(ui.success(ui.CHECK), ui.text(`wrote ${result.relPath}`));
1161
- ui.blank();
1162
- // Actual cost from the record callLLM just appended for this compose.
1163
- const rec = [...readCostLog()]
1164
- .reverse()
1165
- .find((r) => r.stage === "compose" && r.session === result.slug);
1166
- ui.summary({
1167
- title: { value: result.title, color: ui.text },
1168
- sources: { value: result.sourceCount, color: ui.info },
1169
- confidence: { value: result.confidence.toFixed(2), color: ui.info },
1170
- ...(rec && rec.estimated_cost_usd !== null
1171
- ? { cost: { value: ui.formatUsd(rec.estimated_cost_usd), color: ui.warn } }
1172
- : {}),
1173
- });
1174
- }
1175
- finally {
1176
- db.close();
1177
- }
1178
- }));
587
+ .action(runAction(composeCommand));
1179
588
  const projectsCmd = program
1180
589
  .command("projects")
1181
590
  .description("Per-project distillation decisions: table + include/exclude");
@@ -1299,7 +708,7 @@ program
1299
708
  if (opts.approve !== undefined || opts.reject !== undefined) {
1300
709
  throw new Error("--approve and --reject need --json (the terminal loop asks per note)");
1301
710
  }
1302
- await runReview(opts);
711
+ await withPipelineLock(() => runReview(opts));
1303
712
  }));
1304
713
  program
1305
714
  .command("audit")
@@ -1311,125 +720,7 @@ program
1311
720
  .option("--dry-run", "Show notes, batches and est. cost, exit before any LLM call")
1312
721
  .option("--yes", "Skip the cost confirmation prompt")
1313
722
  .option("--apply-rejects", "Move notes with a fresh reject verdict to .rejected/ (no model call; undo with vir review --restore)")
1314
- .action(runAction(async (opts) => {
1315
- const limit = parseLimitOption(opts.limit);
1316
- if (limit === null) {
1317
- ui.header("audit");
1318
- ui.blank();
1319
- ui.row(ui.errorColor(ui.CROSS), ui.text(`--limit must be a positive integer, got "${opts.limit}"`));
1320
- process.exitCode = 1;
1321
- return;
1322
- }
1323
- const cfg = loadConfig();
1324
- const db = new StateDb();
1325
- try {
1326
- const root = vaultRoot(cfg);
1327
- if (opts.applyRejects) {
1328
- const { toMove: targets, verified } = previewRejects(db.listDistilled(), db.listAudits(), (r) => noteIsVerified(root, r), opts.project);
1329
- ui.header("audit");
1330
- ui.blank();
1331
- ui.summary({
1332
- rejects: { value: targets.length, color: ui.errorColor },
1333
- "approved, skipped": { value: verified.length, color: verified.length > 0 ? ui.warn : ui.muted },
1334
- });
1335
- for (const r of targets.slice(0, 20))
1336
- ui.line(` ${ui.dim(ui.BULLET)} ${ui.text(r.topic)} ${ui.dim(r.project)}`);
1337
- if (targets.length > 20)
1338
- ui.line(ui.dim(` … and ${targets.length - 20} more`));
1339
- ui.divider();
1340
- if (targets.length === 0 || opts.dryRun) {
1341
- if (opts.dryRun)
1342
- ui.line(ui.dim(" dry run — nothing moved"));
1343
- return;
1344
- }
1345
- if (opts.yes !== true) {
1346
- const proceed = await confirm({ message: `move ${targets.length} notes to .rejected/?`, default: false });
1347
- if (!proceed) {
1348
- ui.line(ui.dim("aborted"));
1349
- return;
1350
- }
1351
- }
1352
- const s = applyAuditRejects(db, root, { project: opts.project });
1353
- ui.summary({
1354
- moved: { value: s.moved, color: ui.info },
1355
- "missing file": { value: s.missingFile, color: ui.muted },
1356
- collision: { value: s.collision, color: s.collision > 0 ? ui.warn : ui.muted },
1357
- verified: { value: s.verified, color: s.verified > 0 ? ui.warn : ui.muted },
1358
- });
1359
- ui.line(ui.dim(" undo any of them with `vir review --restore <note>`"));
1360
- return;
1361
- }
1362
- const auditOpts = { project: opts.project, limit, all: opts.all };
1363
- const isVerified = (r) => noteIsVerified(root, r);
1364
- const model = normalizeModelName(resolveModelShorthand(opts.model ?? cfg.models.distill), cfg.provider);
1365
- const picked = selectAuditRows(db.listDistilled(), db.listAudits(), auditOpts, isVerified);
1366
- const batches = batchByProject(picked.rows);
1367
- const inputTokens = Math.ceil(picked.rows.reduce((n, r) => n + r.content.length, 0) / 4 + batches.length * 600);
1368
- const outputTokens = picked.rows.length * 60;
1369
- const estCost = estCostLabel(cfg, model, inputTokens, outputTokens);
1370
- ui.header("audit");
1371
- ui.blank();
1372
- ui.summary({
1373
- notes: { value: picked.rows.length, color: ui.info },
1374
- batches: { value: batches.length, color: ui.info },
1375
- "already audited": { value: picked.skippedFresh, color: ui.muted },
1376
- verified: { value: picked.skippedVerified, color: ui.muted },
1377
- model: { value: model, color: ui.accent },
1378
- "est. cost": { value: estCost, color: ui.warn },
1379
- });
1380
- ui.divider();
1381
- if (picked.rows.length === 0) {
1382
- ui.row(ui.success(ui.CHECK), ui.text("nothing to audit"));
1383
- return;
1384
- }
1385
- if (opts.dryRun) {
1386
- ui.line(ui.dim(" dry run — no model calls made"));
1387
- return;
1388
- }
1389
- if (opts.yes !== true) {
1390
- const proceed = await confirm({ message: `audit ${picked.rows.length} notes with ${model} (~${estCost})?`, default: true });
1391
- if (!proceed) {
1392
- ui.line(ui.dim("aborted"));
1393
- return;
1394
- }
1395
- }
1396
- const client = maybeAnthropicClient(cfg);
1397
- const sp = ui.spinner(`auditing ${batches.length} batches`).start();
1398
- let summary;
1399
- try {
1400
- summary = await runAudit(db, auditOpts, {
1401
- isVerified,
1402
- llm: (prompt) => withRateLimitRetry(() => callLLM(cfg, client, {
1403
- prompt,
1404
- model,
1405
- maxTokens: 4000,
1406
- cost: { stage: "audit" },
1407
- })),
1408
- });
1409
- sp.stop();
1410
- }
1411
- catch (err) {
1412
- sp.fail(ui.errorColor(err.message));
1413
- process.exitCode = 1;
1414
- return;
1415
- }
1416
- ui.summary({
1417
- audited: { value: summary.audited, color: ui.info },
1418
- keep: { value: summary.byVerdict.keep, color: ui.success },
1419
- verify: { value: summary.byVerdict.verify, color: ui.warn },
1420
- merge: { value: summary.byVerdict.merge, color: ui.warn },
1421
- reject: { value: summary.byVerdict.reject, color: ui.errorColor },
1422
- unanswered: { value: summary.unanswered, color: ui.muted },
1423
- "failed batches": { value: summary.failedBatches, color: summary.failedBatches > 0 ? ui.errorColor : ui.muted },
1424
- });
1425
- ui.line(ui.dim(" nothing was moved — walk the flagged notes with `vir review --audited`"));
1426
- if (summary.failedBatches > 0)
1427
- process.exitCode = 1;
1428
- }
1429
- finally {
1430
- db.close();
1431
- }
1432
- }));
723
+ .action(runAction(auditCommand));
1433
724
  program
1434
725
  .command("prune")
1435
726
  .description("Demote agent-derived notes to .rejected/ (dry run by default)")
@@ -1585,342 +876,6 @@ function renderDaemon(ds, cadenceHours) {
1585
876
  `${ui.dim("config")} ${ui.muted(ds.configPath ? collapseHome(ds.configPath) : "—")}`,
1586
877
  ], { title: "daemon", width: 52 });
1587
878
  }
1588
- async function cmdInit() {
1589
- ensureVirDir();
1590
- const existing = configExists() ? safeLoad() : null;
1591
- ui.header("init");
1592
- ui.blank();
1593
- // ── vault path ──────────────────────────────────────────────────────────
1594
- let vaultPath = "";
1595
- for (;;) {
1596
- vaultPath = await input({
1597
- message: "Obsidian vault path",
1598
- default: existing?.vaultPath ??
1599
- join(homedir(), "Documents", "Obsidian", "MyVault"),
1600
- });
1601
- const expanded = expandHome(vaultPath);
1602
- if (existsSync(expanded))
1603
- break;
1604
- const create = await confirm({
1605
- message: `Vault path does not exist (${expanded}). Create it?`,
1606
- default: true,
1607
- });
1608
- if (create) {
1609
- try {
1610
- mkdirSync(expanded, { recursive: true });
1611
- break;
1612
- }
1613
- catch (err) {
1614
- console.error(chalk.red(`failed to create: ${err.message}`));
1615
- }
1616
- }
1617
- }
1618
- const outputDir = await input({
1619
- message: "Output subdir inside vault",
1620
- default: existing?.outputDir ?? "vir",
1621
- });
1622
- // ── claude projects dir ─────────────────────────────────────────────────
1623
- let claudeProjectsDir = "";
1624
- for (;;) {
1625
- claudeProjectsDir = await input({
1626
- message: "Claude Code projects dir",
1627
- default: existing?.claudeProjectsDir ?? join(homedir(), ".claude", "projects"),
1628
- });
1629
- const expanded = expandHome(claudeProjectsDir);
1630
- if (existsSync(expanded))
1631
- break;
1632
- console.warn(chalk.yellow("directory not found — Claude Code sessions may not exist yet"));
1633
- const cont = await confirm({
1634
- message: "continue anyway?",
1635
- default: false,
1636
- });
1637
- if (cont)
1638
- break;
1639
- }
1640
- // ── web articles (optional second input source) ─────────────────────────
1641
- let articlesDir = existing?.articlesDir;
1642
- const wantsArticles = await confirm({
1643
- message: "Do you save web articles to a folder (e.g. Obsidian Web Clipper)?",
1644
- default: existing?.articlesDir !== undefined,
1645
- });
1646
- if (wantsArticles) {
1647
- for (;;) {
1648
- articlesDir = await input({
1649
- message: "Articles (raw/) directory",
1650
- default: existing?.articlesDir ??
1651
- join(homedir(), "Documents", "Obsidian", "raw"),
1652
- });
1653
- const expanded = expandHome(articlesDir);
1654
- if (existsSync(expanded))
1655
- break;
1656
- const create = await confirm({
1657
- message: `Path does not exist (${expanded}). Create it?`,
1658
- default: true,
1659
- });
1660
- if (create) {
1661
- try {
1662
- mkdirSync(expanded, { recursive: true });
1663
- break;
1664
- }
1665
- catch (err) {
1666
- console.error(chalk.red(`failed to create: ${err.message}`));
1667
- }
1668
- }
1669
- else {
1670
- break;
1671
- }
1672
- }
1673
- }
1674
- else {
1675
- articlesDir = undefined;
1676
- }
1677
- // ── PDFs / papers (optional third input source) ──────────────────────────
1678
- let pdfsDir = existing?.pdfsDir;
1679
- const wantsPdfs = await confirm({
1680
- message: "Do you keep PDFs / papers in a folder to ingest?",
1681
- default: existing?.pdfsDir !== undefined,
1682
- });
1683
- if (wantsPdfs) {
1684
- for (;;) {
1685
- pdfsDir = await input({
1686
- message: "PDFs directory",
1687
- default: existing?.pdfsDir ?? join(homedir(), "Documents", "papers"),
1688
- });
1689
- const expanded = expandHome(pdfsDir);
1690
- if (existsSync(expanded))
1691
- break;
1692
- const create = await confirm({
1693
- message: `Path does not exist (${expanded}). Create it?`,
1694
- default: true,
1695
- });
1696
- if (create) {
1697
- try {
1698
- mkdirSync(expanded, { recursive: true });
1699
- break;
1700
- }
1701
- catch (err) {
1702
- console.error(chalk.red(`failed to create: ${err.message}`));
1703
- }
1704
- }
1705
- else {
1706
- break;
1707
- }
1708
- }
1709
- }
1710
- else {
1711
- pdfsDir = undefined;
1712
- }
1713
- const cadenceHours = Number(await input({
1714
- message: "Cadence (hours)",
1715
- default: String(existing?.cadenceHours ?? 3),
1716
- validate: (v) => {
1717
- const n = Number(v);
1718
- return Number.isFinite(n) && n > 0 ? true : "must be a positive number";
1719
- },
1720
- }));
1721
- // ── provider picker ─────────────────────────────────────────────────────
1722
- const provider = (await select({
1723
- message: "Provider",
1724
- default: existing?.provider ?? "anthropic",
1725
- choices: [
1726
- {
1727
- name: "Anthropic (API key — predictable per-session cost, no effect on your Claude Code limits)",
1728
- value: "anthropic",
1729
- },
1730
- {
1731
- name: "Claude Code (subscription — free and keyless; distills consume your Claude Code usage limits)",
1732
- value: "claude-cli",
1733
- },
1734
- {
1735
- name: "Kie.ai (~72% cheaper API, third-party proxy — you trade reliability for cost)",
1736
- value: "kie",
1737
- },
1738
- ],
1739
- }));
1740
- // Secret prompt discipline: mask while typing, echo only a masked
1741
- // confirmation (maskSecret), and NEVER render the existing key as the
1742
- // visible inquirer default — that prints the full secret to the terminal
1743
- // before a single keystroke. Empty input keeps the existing/env key.
1744
- const promptSecret = async (message, keep, validate) => {
1745
- const value = await input({
1746
- message: keep
1747
- ? `${message} (Enter keeps ${maskSecret(keep)})`
1748
- : message,
1749
- transformer: (v, { isFinal }) => isFinal ? maskSecret(v || keep || "") : "•".repeat(v.length),
1750
- validate: (v) => (v === "" && keep ? true : validate(v)),
1751
- });
1752
- return value === "" && keep ? keep : value;
1753
- };
1754
- let anthropicApiKey;
1755
- let kieApiKey;
1756
- if (provider === "anthropic") {
1757
- anthropicApiKey = await promptSecret("Anthropic API key", existing?.anthropicApiKey ?? process.env.ANTHROPIC_API_KEY ?? undefined, (v) => (v.startsWith("sk-ant-") ? true : "key should start with sk-ant-"));
1758
- }
1759
- else if (provider === "kie") {
1760
- kieApiKey = await promptSecret("Kie.ai API key", existing?.kieApiKey ?? process.env.KIE_API_KEY ?? undefined, (v) => (v.length > 10 ? true : "enter a valid Kie.ai API key"));
1761
- }
1762
- // claude-cli: no credential of any kind — it uses the `claude` login.
1763
- // ── model pickers (provider-aware) ──────────────────────────────────────
1764
- // claude-cli accepts the same full Anthropic model ids (and pins them per
1765
- // invocation via --model), so it shares the anthropic id set.
1766
- const classifyChoices = provider !== "kie"
1767
- ? [
1768
- {
1769
- name: "claude-haiku-4-5-20251001 (recommended)",
1770
- value: "claude-haiku-4-5-20251001",
1771
- },
1772
- { name: "claude-sonnet-4-6", value: "claude-sonnet-4-6" },
1773
- ]
1774
- : [
1775
- {
1776
- name: "claude-haiku-4-5 (recommended)",
1777
- value: "claude-haiku-4-5",
1778
- },
1779
- { name: "claude-sonnet-4-6", value: "claude-sonnet-4-6" },
1780
- ];
1781
- const classifyModel = await select({
1782
- message: "Classify model (fast pass)",
1783
- choices: classifyChoices,
1784
- });
1785
- const distillChoices = provider !== "kie"
1786
- ? [
1787
- {
1788
- name: "claude-sonnet-5 (recommended)",
1789
- value: "claude-sonnet-5",
1790
- },
1791
- { name: "claude-sonnet-4-6", value: "claude-sonnet-4-6" },
1792
- {
1793
- name: "claude-haiku-4-5-20251001 (faster, cheaper)",
1794
- value: "claude-haiku-4-5-20251001",
1795
- },
1796
- ]
1797
- : [
1798
- {
1799
- name: "claude-sonnet-4-6 (recommended)",
1800
- value: "claude-sonnet-4-6",
1801
- },
1802
- {
1803
- name: "claude-haiku-4-5 (faster, cheaper)",
1804
- value: "claude-haiku-4-5",
1805
- },
1806
- ];
1807
- const distillModel = await select({
1808
- message: "Distill model (deep extraction)",
1809
- choices: distillChoices,
1810
- });
1811
- const filterThreshold = Number(await input({
1812
- message: "Filter threshold (0..1)",
1813
- default: String(existing?.filterThreshold ?? 0.4),
1814
- validate: (v) => {
1815
- const n = Number(v);
1816
- return Number.isFinite(n) && n >= 0 && n <= 1
1817
- ? true
1818
- : "must be between 0 and 1";
1819
- },
1820
- }));
1821
- // ── project triage (the primary decision point) ─────────────────────────
1822
- // Every project found under the Claude projects dir is shown once with its
1823
- // session count and rough cost; selected = include, unselected = exclude.
1824
- // Projects that appear later start undecided and trigger the run prompt.
1825
- let projectDecisions = {};
1826
- let agentTranscriptsAnswer;
1827
- try {
1828
- const projectsDirX = expandHome(claudeProjectsDir);
1829
- const allFound = scanSessions(projectsDirX);
1830
- // Triage counts + the agent-transcript question. Only top-level
1831
- // transcripts count (nested workflow/sidechain have their own filter),
1832
- // and agent transcripts are excluded from the per-project multi-select
1833
- // numbers so the costs shown reflect what would actually distill.
1834
- const topLevel = allFound.filter((s) => classifyTranscript(s.path, projectsDirX) === "session");
1835
- const counts = { interactive: 0, agent: 0, stub: 0 };
1836
- const agentPaths = new Set();
1837
- for (const s of topLevel) {
1838
- const cat = categorizeTranscriptHead(readTranscriptHead(s.path), s.size);
1839
- counts[cat] += 1;
1840
- if (cat === "agent")
1841
- agentPaths.add(s.path);
1842
- }
1843
- if (counts.agent > 0) {
1844
- ui.blank();
1845
- ui.line(ui.dim(` ${counts.interactive} interactive · ${counts.agent} SDK-launched · ${counts.stub} stubs`));
1846
- agentTranscriptsAnswer = (await select({
1847
- message: "SDK-launched agent transcripts (review/verify harness output) — distill them?",
1848
- default: existing?.agentTranscripts ?? "exclude",
1849
- choices: [
1850
- {
1851
- name: "Exclude (recommended — agent-internal execution, not your decisions)",
1852
- value: "exclude",
1853
- },
1854
- { name: "Include (distill them like any session)", value: "include" },
1855
- ],
1856
- }));
1857
- }
1858
- const found = agentTranscriptsAnswer !== "include"
1859
- ? topLevel.filter((s) => !agentPaths.has(s.path))
1860
- : topLevel;
1861
- const groups = [...groupByProject(found, projectsDirX).values()];
1862
- if (groups.length > 0) {
1863
- const classifyId = normalizeModelName(classifyModel, provider);
1864
- const distillId = normalizeModelName(distillModel, provider);
1865
- const infos = groups
1866
- .map((g) => ({
1867
- name: g.name,
1868
- sessionCount: g.sessions.length,
1869
- totalBytes: g.totalBytes,
1870
- estCost: g.sessions.reduce((sum, s) => sum +
1871
- estimateSessionCost(provider, classifyId, distillId, s.size), 0),
1872
- }))
1873
- .sort((a, b) => b.estCost - a.estCost);
1874
- ui.blank();
1875
- ui.line(ui.dim(" Which projects should vir distill? Costs are rough upper bounds."));
1876
- projectDecisions = await promptProjectDecisions(infos, existing?.projects ?? {}, "Projects to track");
1877
- }
1878
- }
1879
- catch (err) {
1880
- console.warn(chalk.yellow(`project scan failed (${err.message}) — you can decide later with vir projects`));
1881
- }
1882
- const parsed = ConfigSchema.safeParse(buildInitConfig(existing, {
1883
- vaultPath,
1884
- outputDir,
1885
- claudeProjectsDir,
1886
- cadenceHours,
1887
- provider,
1888
- anthropicApiKey,
1889
- kieApiKey,
1890
- filterThreshold,
1891
- articlesDir,
1892
- pdfsDir,
1893
- classifyModel,
1894
- distillModel,
1895
- projects: projectDecisions,
1896
- agentTranscripts: agentTranscriptsAnswer,
1897
- }));
1898
- if (!parsed.success) {
1899
- console.error(chalk.red("invalid config:"));
1900
- for (const issue of parsed.error.issues) {
1901
- console.error(` - ${issue.path.join(".")}: ${issue.message}`);
1902
- }
1903
- process.exitCode = 1;
1904
- return;
1905
- }
1906
- saveConfig(parsed.data);
1907
- ui.blank();
1908
- ui.row(ui.success(ui.CHECK), ui.text(`saved ${CONFIG_PATH}`));
1909
- ui.blank();
1910
- const wantsMcp = await confirm({
1911
- message: "Register Vir with Claude Code now? (recommended)",
1912
- default: true,
1913
- });
1914
- if (wantsMcp) {
1915
- await installToClaudeCode("user");
1916
- }
1917
- if (process.platform === "darwin" && parsed.data.notifications) {
1918
- ui.blank();
1919
- setupNotifications({ test: false });
1920
- }
1921
- ui.blank();
1922
- ui.line(ui.dim("next: `vir run` to test once, then `vir schedule install`"));
1923
- }
1924
879
  function renderPlan(p) {
1925
880
  const title = collapseHome(p.target);
1926
881
  if (!p.exists) {
@@ -1948,25 +903,6 @@ function collapseHome(p) {
1948
903
  const h = homedir();
1949
904
  return p.startsWith(h) ? "~" + p.slice(h.length) : p;
1950
905
  }
1951
- function noteRefOf(r) {
1952
- const dir = `${r.category}s`;
1953
- const slug = r.topic
1954
- .toLowerCase()
1955
- .replace(/[^a-z0-9]+/g, "-")
1956
- .replace(/^-+|-+$/g, "");
1957
- return `${dir}/${slug}-${r.sessionId.slice(0, 8)}`;
1958
- }
1959
- function preview(s) {
1960
- return s.replace(/\s+/g, " ").trim().slice(0, 80);
1961
- }
1962
- function safeLoad() {
1963
- try {
1964
- return loadConfig();
1965
- }
1966
- catch {
1967
- return null;
1968
- }
1969
- }
1970
906
  // Safety net for anything that escapes the per-action `runAction` wrapper (e.g.
1971
907
  // commander-internal rejections before the action handler is reached). Set
1972
908
  // `process.exitCode` instead of calling `process.exit` so buffered stdout/stderr