@djolex999/vir-cli 0.12.0 → 0.14.0

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.
package/dist/cli.js CHANGED
@@ -10,12 +10,15 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { basename, dirname, join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
- import { CONFIG_PATH, ConfigSchema, configExists, ensureVirDir, expandHome, loadConfig, saveConfig, } from "./config.js";
13
+ import { CONFIG_PATH, maskSecret, ConfigSchema, configExists, ensureVirDir, expandHome, loadConfig, saveConfig, } from "./config.js";
14
14
  import { applyPlan, planUpdates } from "./claude/updater.js";
15
15
  import { detectDuplicates } from "./dedupe/detector.js";
16
16
  import { mergeNotes } from "./dedupe/merger.js";
17
17
  import { contradictionCheck, orphanCheck, stalenessCheck, } from "./lint/linter.js";
18
18
  import { runPipeline } from "./pipeline/run.js";
19
+ import { categorizeTranscriptHead, classifyTranscript, estimateSessionCost, groupByProject, readTranscriptHead, } from "./pipeline/projects.js";
20
+ import { persistProjectDecisions, promptProjectDecisions, } from "./cli/projectSelect.js";
21
+ import { gatherProjectsReport } from "./cli/projects.js";
19
22
  import { scanSessions } from "./pipeline/scanner.js";
20
23
  import { parseSession } from "./pipeline/parser.js";
21
24
  import { scoreSession } from "./pipeline/filter.js";
@@ -27,7 +30,8 @@ import { computeCost } from "./cost/pricing.js";
27
30
  import { countByCategory, summarizeAll, summarizeProject, } from "./pipeline/summarizer.js";
28
31
  import { buildPeriodPrompt, estimatePeriodCostTokens, periodLabel, periodRange, selectNotesInPeriod, summarizePeriod, } from "./pipeline/periodSummary.js";
29
32
  import { embeddingForNote, isOllamaAvailable, } from "./search/embedder.js";
30
- import { search, vaultRoot } from "./search/retriever.js";
33
+ import { acquireLock, LockHeldError, releaseLock } from "./pipeline/lock.js";
34
+ import { search, searchWithOutcome, vaultRoot } from "./search/retriever.js";
31
35
  import { buildQueryResults, errorPayload } from "./output/json.js";
32
36
  import { synthesize } from "./search/synthesizer.js";
33
37
  import { runMcpServer } from "./mcp/server.js";
@@ -69,6 +73,8 @@ program
69
73
  .option("--yes", "Skip the cost confirmation prompt")
70
74
  .option("--force-model <model>", "Override the distill model for this run only: haiku | sonnet")
71
75
  .option("--dry-run", "Estimate per-session cost after filtering, then exit before any LLM call")
76
+ .option("--only <project>", "Restrict this run to a project (repeatable, never persisted)", (v, acc) => acc.concat(v), [])
77
+ .option("--exclude-project <project>", "Skip a project for this run only (repeatable, never persisted)", (v, acc) => acc.concat(v), [])
72
78
  .action(runAction(async (opts) => {
73
79
  const cfg = loadConfig();
74
80
  const daemon = opts.daemon === true;
@@ -87,19 +93,59 @@ program
87
93
  articlesOnly ||
88
94
  pdfsOnly ||
89
95
  dryRun;
90
- const summary = await runPipeline(cfg, {
91
- full: opts.full,
92
- quiet: daemon,
93
- logToFile: daemon,
94
- rewriteOnly,
95
- articlesOnly,
96
- pdfsOnly,
97
- forceDistillModel: opts.forceModel,
98
- dryRun,
99
- onConfirm: skipPrompt
100
- ? undefined
101
- : async (newCount) => confirmCostIfNeeded(cfg, newCount),
102
- });
96
+ // Dry-run and rewrite-only never call the distiller — no lock needed,
97
+ // and they must not be blocked by (or block) a running pipeline.
98
+ const needsLock = !dryRun && !rewriteOnly;
99
+ if (needsLock) {
100
+ try {
101
+ acquireLock();
102
+ }
103
+ catch (err) {
104
+ if (err instanceof LockHeldError) {
105
+ console.error(chalk.yellow(err.message));
106
+ process.exitCode = 1;
107
+ return;
108
+ }
109
+ throw err;
110
+ }
111
+ }
112
+ // Interactivity for the undecided-projects triage is a TTY question,
113
+ // not a --yes question: --yes skips the COST prompt but a human at a
114
+ // terminal can still answer include/exclude. launchd/cron have no
115
+ // TTY, so the daemon path can never reach the prompt.
116
+ const canPromptProjects = process.stdin.isTTY === true && !daemon && !dryRun;
117
+ let summary;
118
+ try {
119
+ summary = await runPipeline(cfg, {
120
+ full: opts.full,
121
+ quiet: daemon,
122
+ logToFile: daemon,
123
+ rewriteOnly,
124
+ articlesOnly,
125
+ pdfsOnly,
126
+ forceDistillModel: opts.forceModel,
127
+ dryRun,
128
+ onlyProjects: opts.only?.length ? opts.only : undefined,
129
+ excludeProjects: opts.excludeProject?.length
130
+ ? opts.excludeProject
131
+ : undefined,
132
+ onUndecidedProjects: canPromptProjects
133
+ ? async (pending) => {
134
+ const answers = await promptProjectDecisions(pending, cfg.projects, `${pending.length} project(s) have sessions awaiting a decision — select the ones vir should track`);
135
+ persistProjectDecisions(answers);
136
+ ui.row(ui.success(ui.CHECK), ui.text(`saved ${Object.keys(answers).length} project decision(s)`));
137
+ return answers;
138
+ }
139
+ : undefined,
140
+ onConfirm: skipPrompt
141
+ ? undefined
142
+ : async (newCount) => confirmCostIfNeeded(cfg, newCount),
143
+ });
144
+ }
145
+ finally {
146
+ if (needsLock)
147
+ releaseLock();
148
+ }
103
149
  // Surface per-item distill failures via a non-zero exit so external
104
150
  // callers (and the user) don't get false "success" — the silent-success
105
151
  // bug that hid Kie's 200-with-error responses pre-0.7.2.
@@ -222,10 +268,11 @@ const schedule = program
222
268
  .description("Manage the background daemon (launchd / systemd / cron)");
223
269
  schedule
224
270
  .command("install")
225
- .description("Install + start the scheduled daemon")
226
- .action(runAction(async () => {
271
+ .description("Install the scheduled daemon (first run at the next interval)")
272
+ .option("--run-now", "Also kick off one run immediately after installing")
273
+ .action(runAction(async (opts) => {
227
274
  const cfg = loadConfig();
228
- await installDaemon(cfg);
275
+ await installDaemon(cfg, { runNow: opts.runNow });
229
276
  const ds = await daemonStatus();
230
277
  console.log(chalk.green(`installed via ${ds.method}: ${ds.configPath ?? "(no path)"} (active=${ds.active})`));
231
278
  }));
@@ -777,19 +824,24 @@ program
777
824
  console.log(ui.text(question));
778
825
  ui.divider();
779
826
  ui.blank();
780
- const ollamaUp = await isOllamaAvailable();
781
- const sp = ui
782
- .spinner(`searching vault (${ollamaUp ? "embeddings" : "tfidf"})`)
783
- .start();
784
- let hits;
827
+ // The label must reflect what actually served the results, so it can
828
+ // only be printed AFTER the search — the old up-front isOllamaAvailable
829
+ // label claimed "embeddings" even when the embed call failed and TF-IDF
830
+ // served the hits.
831
+ const sp = ui.spinner("searching vault").start();
832
+ let outcome;
785
833
  try {
786
- hits = await search(cfg, db, question, 8);
834
+ outcome = await searchWithOutcome(cfg, db, question, 8);
787
835
  sp.stop();
788
836
  }
789
837
  catch (err) {
790
838
  sp.fail(ui.errorColor(err.message));
791
839
  return;
792
840
  }
841
+ const hits = outcome.hits;
842
+ if (outcome.degraded) {
843
+ ui.row(ui.warn(ui.WARN_GLYPH), ui.text(`embedding search failed — results are TF-IDF fallback (${outcome.embedError})`));
844
+ }
793
845
  if (hits.length === 0) {
794
846
  ui.row(ui.warn(ui.WARN_GLYPH), ui.text("no documents matched"));
795
847
  return;
@@ -798,7 +850,7 @@ program
798
850
  ui.blank();
799
851
  console.log(ui.text(ui.wrap(answer.trim(), 60)));
800
852
  ui.blank();
801
- const method = hits[0]?.method ?? "tfidf";
853
+ const method = outcome.method;
802
854
  const relevant = hits.filter((h) => h.score > 0).slice(0, 3);
803
855
  ui.divider();
804
856
  for (const h of relevant)
@@ -809,7 +861,10 @@ program
809
861
  : new VaultWriter(cfg).noteCount();
810
862
  ui.summary({
811
863
  sources: { value: relevant.length, color: ui.info },
812
- via: { value: method, color: ui.accent },
864
+ via: {
865
+ value: outcome.degraded ? "tfidf (embeddings failed)" : method,
866
+ color: outcome.degraded ? ui.warn : ui.accent,
867
+ },
813
868
  searched: { value: totalNotes, color: ui.muted },
814
869
  });
815
870
  }
@@ -906,6 +961,70 @@ program
906
961
  db.close();
907
962
  }
908
963
  }));
964
+ const projectsCmd = program
965
+ .command("projects")
966
+ .description("Per-project distillation decisions: table + include/exclude");
967
+ projectsCmd
968
+ .option("--json", "Machine-readable output")
969
+ .action(runAction(async (opts) => {
970
+ const cfg = loadConfig();
971
+ const rows = gatherProjectsReport(cfg);
972
+ if (opts.json === true) {
973
+ process.stdout.write(JSON.stringify(rows.map(({ pendingPaths: _pendingPaths, ...r }) => r), null, 2) + "\n");
974
+ return;
975
+ }
976
+ ui.header("projects");
977
+ ui.blank();
978
+ if (rows.length === 0) {
979
+ ui.line(ui.dim(" no projects found under the Claude projects dir"));
980
+ return;
981
+ }
982
+ const nameW = Math.min(Math.max(...rows.map((r) => r.name.length), 7), 44);
983
+ ui.line(ui.dim(` ${"project".padEnd(nameW)} decision sessions distilled pending excluded est. pending`));
984
+ for (const r of rows) {
985
+ const decision = r.decision === "include"
986
+ ? ui.success("include ")
987
+ : r.decision === "exclude"
988
+ ? ui.dim("exclude ")
989
+ : ui.warn("undecided");
990
+ const nestedParts = [
991
+ ...(r.workflowSessions > 0 ? [`+${r.workflowSessions} workflow`] : []),
992
+ ...(r.sidechainSessions > 0
993
+ ? [`+${r.sidechainSessions} sidechain`]
994
+ : []),
995
+ ...(r.agentSessions > 0 ? [`+${r.agentSessions} agent`] : []),
996
+ ];
997
+ const nestedNote = nestedParts.length > 0 ? ui.dim(` (${nestedParts.join(", ")})`) : "";
998
+ ui.line(` ${ui.text(r.name.padEnd(nameW))} ${decision} ${String(r.sessions).padStart(8)} ${String(r.distilled).padStart(9)} ${String(r.pending).padStart(7)} ${String(r.excluded).padStart(8)} ${r.pending > 0 ? ui.warn(ui.formatUsd(r.estPendingCost).padStart(12)) : ui.dim("—".padStart(12))}${nestedNote}`);
999
+ }
1000
+ const pending = rows.filter((r) => r.decision === "undecided" && r.pending > 0);
1001
+ if (pending.length > 0) {
1002
+ ui.blank();
1003
+ ui.line(ui.dim(` ${pending.length} project(s) undecided — vir projects include|exclude <name>, or answer the prompt on the next interactive vir run`));
1004
+ }
1005
+ }));
1006
+ async function cmdProjectDecision(name, decision) {
1007
+ const cfg = loadConfig();
1008
+ const rows = gatherProjectsReport(cfg);
1009
+ const known = rows.find((r) => r.name === name);
1010
+ if (!known) {
1011
+ console.error(chalk.red(`unknown project '${name}' — seen projects: ${rows.map((r) => r.name).join(", ") || "(none)"}`));
1012
+ process.exitCode = 1;
1013
+ return;
1014
+ }
1015
+ persistProjectDecisions({ [name]: decision });
1016
+ ui.row(ui.success(ui.CHECK), ui.text(decision === "include"
1017
+ ? `${name} will be distilled from the next run (${known.pending} session(s) pending)`
1018
+ : `${name} excluded — forward-looking only; already-distilled notes stay in the vault`));
1019
+ }
1020
+ projectsCmd
1021
+ .command("include <name>")
1022
+ .description("Track a project's sessions from now on")
1023
+ .action(runAction(async (name) => cmdProjectDecision(name, "include")));
1024
+ projectsCmd
1025
+ .command("exclude <name>")
1026
+ .description("Stop tracking a project (existing notes are untouched)")
1027
+ .action(runAction(async (name) => cmdProjectDecision(name, "exclude")));
909
1028
  program
910
1029
  .command("status")
911
1030
  .description("Show processing status + knowledge base breakdown")
@@ -937,9 +1056,14 @@ program
937
1056
  .description("Retry sessions that silently failed pre-0.7.2 (null/empty content despite skipped=0)")
938
1057
  .option("--dry-run", "Report recoverable count + estimated cost + false-cost collateral; exit before any LLM call")
939
1058
  .option("--yes", "Skip the cost confirmation prompt")
1059
+ .option("--force", "Also retry retry-exhausted sessions (3+ consecutive failed distills)")
940
1060
  .action(runAction(async (opts) => {
941
1061
  const cfg = loadConfig();
942
- await runReconcile(cfg, { dryRun: opts.dryRun, yes: opts.yes });
1062
+ await runReconcile(cfg, {
1063
+ dryRun: opts.dryRun,
1064
+ yes: opts.yes,
1065
+ force: opts.force,
1066
+ });
943
1067
  }));
944
1068
  program
945
1069
  .command("review")
@@ -1227,30 +1351,36 @@ async function cmdInit() {
1227
1351
  default: existing?.provider ?? "anthropic",
1228
1352
  choices: [
1229
1353
  {
1230
- name: "Anthropic (direct, official pricing)",
1354
+ name: "Anthropic (direct + reliable, official pricing — recommended)",
1231
1355
  value: "anthropic",
1232
1356
  },
1233
1357
  {
1234
- name: "Kie.ai (same Claude models, ~72% cheaper)",
1358
+ name: "Kie.ai (~72% cheaper, third-party proxy — you trade reliability for cost)",
1235
1359
  value: "kie",
1236
1360
  },
1237
1361
  ],
1238
1362
  }));
1363
+ // Secret prompt discipline: mask while typing, echo only a masked
1364
+ // confirmation (maskSecret), and NEVER render the existing key as the
1365
+ // visible inquirer default — that prints the full secret to the terminal
1366
+ // before a single keystroke. Empty input keeps the existing/env key.
1367
+ const promptSecret = async (message, keep, validate) => {
1368
+ const value = await input({
1369
+ message: keep
1370
+ ? `${message} (Enter keeps ${maskSecret(keep)})`
1371
+ : message,
1372
+ transformer: (v, { isFinal }) => isFinal ? maskSecret(v || keep || "") : "•".repeat(v.length),
1373
+ validate: (v) => (v === "" && keep ? true : validate(v)),
1374
+ });
1375
+ return value === "" && keep ? keep : value;
1376
+ };
1239
1377
  let anthropicApiKey;
1240
1378
  let kieApiKey;
1241
1379
  if (provider === "anthropic") {
1242
- anthropicApiKey = await input({
1243
- message: "Anthropic API key",
1244
- default: existing?.anthropicApiKey ?? process.env.ANTHROPIC_API_KEY ?? "",
1245
- validate: (v) => v.startsWith("sk-ant-") ? true : "key should start with sk-ant-",
1246
- });
1380
+ 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-"));
1247
1381
  }
1248
1382
  else {
1249
- kieApiKey = await input({
1250
- message: "Kie.ai API key",
1251
- default: existing?.kieApiKey ?? process.env.KIE_API_KEY ?? "",
1252
- validate: (v) => v.length > 10 ? true : "enter a valid Kie.ai API key",
1253
- });
1383
+ 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"));
1254
1384
  }
1255
1385
  // ── model pickers (provider-aware) ──────────────────────────────────────
1256
1386
  const classifyChoices = provider === "anthropic"
@@ -1275,9 +1405,10 @@ async function cmdInit() {
1275
1405
  const distillChoices = provider === "anthropic"
1276
1406
  ? [
1277
1407
  {
1278
- name: "claude-sonnet-4-6 (recommended)",
1279
- value: "claude-sonnet-4-6",
1408
+ name: "claude-sonnet-5 (recommended)",
1409
+ value: "claude-sonnet-5",
1280
1410
  },
1411
+ { name: "claude-sonnet-4-6", value: "claude-sonnet-4-6" },
1281
1412
  {
1282
1413
  name: "claude-haiku-4-5-20251001 (faster, cheaper)",
1283
1414
  value: "claude-haiku-4-5-20251001",
@@ -1307,6 +1438,67 @@ async function cmdInit() {
1307
1438
  : "must be between 0 and 1";
1308
1439
  },
1309
1440
  }));
1441
+ // ── project triage (the primary decision point) ─────────────────────────
1442
+ // Every project found under the Claude projects dir is shown once with its
1443
+ // session count and rough cost; selected = include, unselected = exclude.
1444
+ // Projects that appear later start undecided and trigger the run prompt.
1445
+ let projectDecisions = {};
1446
+ let agentTranscriptsAnswer;
1447
+ try {
1448
+ const projectsDirX = expandHome(claudeProjectsDir);
1449
+ const allFound = scanSessions(projectsDirX);
1450
+ // Triage counts + the agent-transcript question. Only top-level
1451
+ // transcripts count (nested workflow/sidechain have their own filter),
1452
+ // and agent transcripts are excluded from the per-project multi-select
1453
+ // numbers so the costs shown reflect what would actually distill.
1454
+ const topLevel = allFound.filter((s) => classifyTranscript(s.path, projectsDirX) === "session");
1455
+ const counts = { interactive: 0, agent: 0, stub: 0 };
1456
+ const agentPaths = new Set();
1457
+ for (const s of topLevel) {
1458
+ const cat = categorizeTranscriptHead(readTranscriptHead(s.path), s.size);
1459
+ counts[cat] += 1;
1460
+ if (cat === "agent")
1461
+ agentPaths.add(s.path);
1462
+ }
1463
+ if (counts.agent > 0) {
1464
+ ui.blank();
1465
+ ui.line(ui.dim(` ${counts.interactive} interactive · ${counts.agent} SDK-launched · ${counts.stub} stubs`));
1466
+ agentTranscriptsAnswer = (await select({
1467
+ message: "SDK-launched agent transcripts (review/verify harness output) — distill them?",
1468
+ default: existing?.agentTranscripts ?? "exclude",
1469
+ choices: [
1470
+ {
1471
+ name: "Exclude (recommended — agent-internal execution, not your decisions)",
1472
+ value: "exclude",
1473
+ },
1474
+ { name: "Include (distill them like any session)", value: "include" },
1475
+ ],
1476
+ }));
1477
+ }
1478
+ const found = agentTranscriptsAnswer !== "include"
1479
+ ? topLevel.filter((s) => !agentPaths.has(s.path))
1480
+ : topLevel;
1481
+ const groups = [...groupByProject(found, projectsDirX).values()];
1482
+ if (groups.length > 0) {
1483
+ const classifyId = normalizeModelName(classifyModel, provider);
1484
+ const distillId = normalizeModelName(distillModel, provider);
1485
+ const infos = groups
1486
+ .map((g) => ({
1487
+ name: g.name,
1488
+ sessionCount: g.sessions.length,
1489
+ totalBytes: g.totalBytes,
1490
+ estCost: g.sessions.reduce((sum, s) => sum +
1491
+ estimateSessionCost(provider, classifyId, distillId, s.size), 0),
1492
+ }))
1493
+ .sort((a, b) => b.estCost - a.estCost);
1494
+ ui.blank();
1495
+ ui.line(ui.dim(" Which projects should vir distill? Costs are rough upper bounds."));
1496
+ projectDecisions = await promptProjectDecisions(infos, existing?.projects ?? {}, "Projects to track");
1497
+ }
1498
+ }
1499
+ catch (err) {
1500
+ console.warn(chalk.yellow(`project scan failed (${err.message}) — you can decide later with vir projects`));
1501
+ }
1310
1502
  const parsed = ConfigSchema.safeParse(buildInitConfig(existing, {
1311
1503
  vaultPath,
1312
1504
  outputDir,
@@ -1320,6 +1512,8 @@ async function cmdInit() {
1320
1512
  pdfsDir,
1321
1513
  classifyModel,
1322
1514
  distillModel,
1515
+ projects: projectDecisions,
1516
+ agentTranscripts: agentTranscriptsAnswer,
1323
1517
  }));
1324
1518
  if (!parsed.success) {
1325
1519
  console.error(chalk.red("invalid config:"));