@djolex999/vir-cli 0.1.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.
Files changed (69) hide show
  1. package/CLAUDE.md +149 -0
  2. package/LICENSE +21 -0
  3. package/README.md +155 -0
  4. package/dist/claude/updater.js +230 -0
  5. package/dist/claude/updater.js.map +1 -0
  6. package/dist/cli.js +779 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/config.js +82 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/daemon/launchd.js +93 -0
  11. package/dist/daemon/launchd.js.map +1 -0
  12. package/dist/dedupe/detector.js +159 -0
  13. package/dist/dedupe/detector.js.map +1 -0
  14. package/dist/dedupe/merger.js +116 -0
  15. package/dist/dedupe/merger.js.map +1 -0
  16. package/dist/lint/linter.js +224 -0
  17. package/dist/lint/linter.js.map +1 -0
  18. package/dist/pipeline/distiller.js +208 -0
  19. package/dist/pipeline/distiller.js.map +1 -0
  20. package/dist/pipeline/filter.js +28 -0
  21. package/dist/pipeline/filter.js.map +1 -0
  22. package/dist/pipeline/parser.js +109 -0
  23. package/dist/pipeline/parser.js.map +1 -0
  24. package/dist/pipeline/run.js +312 -0
  25. package/dist/pipeline/run.js.map +1 -0
  26. package/dist/pipeline/scanner.js +47 -0
  27. package/dist/pipeline/scanner.js.map +1 -0
  28. package/dist/pipeline/scrubber.js +51 -0
  29. package/dist/pipeline/scrubber.js.map +1 -0
  30. package/dist/pipeline/summarizer.js +162 -0
  31. package/dist/pipeline/summarizer.js.map +1 -0
  32. package/dist/pipeline/types.js +2 -0
  33. package/dist/pipeline/types.js.map +1 -0
  34. package/dist/pipeline/writer.js +195 -0
  35. package/dist/pipeline/writer.js.map +1 -0
  36. package/dist/search/embedder.js +93 -0
  37. package/dist/search/embedder.js.map +1 -0
  38. package/dist/search/retriever.js +212 -0
  39. package/dist/search/retriever.js.map +1 -0
  40. package/dist/search/synthesizer.js +26 -0
  41. package/dist/search/synthesizer.js.map +1 -0
  42. package/dist/state/db.js +309 -0
  43. package/dist/state/db.js.map +1 -0
  44. package/dist/ui/display.js +148 -0
  45. package/dist/ui/display.js.map +1 -0
  46. package/package.json +50 -0
  47. package/src/claude/updater.ts +273 -0
  48. package/src/cli.ts +953 -0
  49. package/src/config.ts +89 -0
  50. package/src/daemon/launchd.ts +115 -0
  51. package/src/dedupe/detector.ts +197 -0
  52. package/src/dedupe/merger.ts +172 -0
  53. package/src/lint/linter.ts +286 -0
  54. package/src/pipeline/distiller.ts +280 -0
  55. package/src/pipeline/filter.ts +43 -0
  56. package/src/pipeline/parser.ts +118 -0
  57. package/src/pipeline/run.ts +378 -0
  58. package/src/pipeline/scanner.ts +51 -0
  59. package/src/pipeline/scrubber.ts +55 -0
  60. package/src/pipeline/summarizer.ts +204 -0
  61. package/src/pipeline/types.ts +41 -0
  62. package/src/pipeline/writer.ts +242 -0
  63. package/src/search/embedder.ts +88 -0
  64. package/src/search/retriever.ts +255 -0
  65. package/src/search/synthesizer.ts +45 -0
  66. package/src/state/db.ts +451 -0
  67. package/src/ui/display.ts +184 -0
  68. package/tsconfig.json +23 -0
  69. package/vir-flow.html +708 -0
package/src/cli.ts ADDED
@@ -0,0 +1,953 @@
1
+ #!/usr/bin/env node
2
+ import confirm from "@inquirer/confirm";
3
+ import input from "@inquirer/input";
4
+ import select from "@inquirer/select";
5
+ import chalk from "chalk";
6
+ import { Command } from "commander";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { stdin, stdout, argv, exit } from "node:process";
9
+ import { existsSync, mkdirSync, realpathSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import {
13
+ CONFIG_PATH,
14
+ ConfigSchema,
15
+ configExists,
16
+ ensureVirDir,
17
+ expandHome,
18
+ loadConfig,
19
+ saveConfig,
20
+ type Config,
21
+ } from "./config.js";
22
+ import { applyPlan, planUpdates, type PlanItem } from "./claude/updater.js";
23
+ import { detectDuplicates } from "./dedupe/detector.js";
24
+ import { mergeNotes } from "./dedupe/merger.js";
25
+ import {
26
+ contradictionCheck,
27
+ orphanCheck,
28
+ stalenessCheck,
29
+ } from "./lint/linter.js";
30
+ import { runPipeline } from "./pipeline/run.js";
31
+ import { summarizeAll, summarizeProject } from "./pipeline/summarizer.js";
32
+ import {
33
+ embeddingForNote,
34
+ isOllamaAvailable,
35
+ } from "./search/embedder.js";
36
+ import { search } from "./search/retriever.js";
37
+ import { synthesize } from "./search/synthesizer.js";
38
+ import {
39
+ daemonStatus,
40
+ installPlist,
41
+ plistPath,
42
+ uninstallPlist,
43
+ } from "./daemon/launchd.js";
44
+ import { StateDb, type KnowledgeStats } from "./state/db.js";
45
+ import * as ui from "./ui/display.js";
46
+ import { VaultWriter } from "./pipeline/writer.js";
47
+
48
+ const program = new Command();
49
+ program
50
+ .name("vir")
51
+ .description("Distill Claude Code sessions into an Obsidian vault")
52
+ .version("0.1.0");
53
+
54
+ program
55
+ .command("init")
56
+ .description("Interactive setup")
57
+ .action(async () => {
58
+ await cmdInit();
59
+ });
60
+
61
+ program
62
+ .command("run")
63
+ .description("Run pipeline once")
64
+ .option("--full", "Re-process all sessions, ignoring state cache")
65
+ .option("--daemon", "Quiet output, write to daemon log file")
66
+ .option(
67
+ "--rewrite-only",
68
+ "Skip scan/filter/LLM; re-render stored notes from SQLite",
69
+ )
70
+ .option("--yes", "Skip the cost confirmation prompt")
71
+ .action(
72
+ async (opts: {
73
+ full?: boolean;
74
+ daemon?: boolean;
75
+ rewriteOnly?: boolean;
76
+ yes?: boolean;
77
+ }) => {
78
+ const cfg = loadConfig();
79
+ const daemon = opts.daemon === true;
80
+ const rewriteOnly = opts.rewriteOnly === true;
81
+ const skipPrompt = opts.yes === true || daemon || rewriteOnly;
82
+ await runPipeline(cfg, {
83
+ full: opts.full,
84
+ quiet: daemon,
85
+ logToFile: daemon,
86
+ rewriteOnly,
87
+ onConfirm: skipPrompt
88
+ ? undefined
89
+ : async (newCount) => confirmCostIfNeeded(cfg, newCount),
90
+ });
91
+ },
92
+ );
93
+
94
+ async function confirmCostIfNeeded(
95
+ cfg: Config,
96
+ newCount: number,
97
+ ): Promise<boolean> {
98
+ if (newCount <= 20) return true;
99
+ ui.box(
100
+ [
101
+ `${ui.text(String(newCount))} ${ui.dim("new sessions to process")}`,
102
+ `${ui.dim("estimated:")} ${ui.warn("$1–5")} ${ui.dim("depending on session")}`,
103
+ `${ui.dim("depth (deep code reviews cost more)")}`,
104
+ `${ui.dim("provider:")} ${ui.accent(cfg.provider)}`,
105
+ ],
106
+ { title: "cost estimate" },
107
+ );
108
+ const rl = createInterface({ input: stdin, output: stdout });
109
+ const ans = (await rl.question(ui.muted("continue? (y/n) ")))
110
+ .trim()
111
+ .toLowerCase();
112
+ rl.close();
113
+ return ans === "y" || ans === "yes";
114
+ }
115
+
116
+ const schedule = program.command("schedule").description("Manage launchd daemon");
117
+ schedule
118
+ .command("install")
119
+ .description("Install + load launchd plist")
120
+ .action(() => {
121
+ const cfg = loadConfig();
122
+ const nodePath = process.execPath;
123
+ const cliPath = realpathSync(argv[1] ?? "");
124
+ const res = installPlist({
125
+ nodePath,
126
+ cliPath,
127
+ cadenceHours: cfg.cadenceHours,
128
+ });
129
+ console.log(
130
+ chalk.green(`installed: ${res.plistPath} (loaded=${res.loaded})`),
131
+ );
132
+ });
133
+ schedule
134
+ .command("uninstall")
135
+ .description("Unload + remove launchd plist")
136
+ .action(() => {
137
+ const res = uninstallPlist();
138
+ if (res.removed) console.log(chalk.green(`removed ${plistPath()}`));
139
+ else console.log(chalk.yellow(`no plist found at ${plistPath()}`));
140
+ });
141
+
142
+ program
143
+ .command("sync-claude [project]")
144
+ .description("Update Vir blocks in CLAUDE.md files (global + per-project)")
145
+ .option("--dry-run", "Show diff only, never write")
146
+ .option("--force", "Apply without confirmation")
147
+ .option("--global", "Only update ~/.claude/CLAUDE.md")
148
+ .action(
149
+ async (
150
+ projectArg: string | undefined,
151
+ opts: { dryRun?: boolean; force?: boolean; global?: boolean },
152
+ ) => {
153
+ const cfg = loadConfig();
154
+ const db = new StateDb();
155
+ try {
156
+ const plans = planUpdates(cfg, db, {
157
+ project: projectArg,
158
+ globalOnly: opts.global === true,
159
+ });
160
+ ui.header(
161
+ `sync-claude${opts.dryRun ? " --dry-run" : opts.force ? " --force" : ""}`,
162
+ );
163
+ ui.blank();
164
+ if (plans.length === 0) {
165
+ ui.row(ui.warn(ui.WARN_GLYPH), ui.text("nothing to plan"));
166
+ return;
167
+ }
168
+
169
+ for (const p of plans) {
170
+ renderPlan(p);
171
+ ui.blank();
172
+ }
173
+
174
+ if (opts.dryRun) {
175
+ ui.line(ui.dim("run without --dry-run to apply"));
176
+ return;
177
+ }
178
+
179
+ let proceed = opts.force === true;
180
+ if (!proceed) {
181
+ const rl = createInterface({ input: stdin, output: stdout });
182
+ const ans = (await rl.question(ui.dim("apply these changes? (y/n) ")))
183
+ .trim()
184
+ .toLowerCase();
185
+ rl.close();
186
+ proceed = ans === "y" || ans === "yes";
187
+ }
188
+ if (!proceed) {
189
+ ui.line(ui.dim("aborted"));
190
+ return;
191
+ }
192
+
193
+ for (const p of plans) {
194
+ if (!p.exists) {
195
+ ui.row(ui.warn(ui.WARN_GLYPH), ui.text(`skipped ${collapseHome(p.target)}`));
196
+ continue;
197
+ }
198
+ const ok = applyPlan(p);
199
+ ui.row(
200
+ ok ? ui.success(ui.CHECK) : ui.errorColor(ui.CROSS),
201
+ ui.text(collapseHome(p.target)),
202
+ );
203
+ }
204
+ } finally {
205
+ db.close();
206
+ }
207
+ },
208
+ );
209
+
210
+ program
211
+ .command("dedupe")
212
+ .description("Interactive duplicate detection + merge")
213
+ .action(async () => {
214
+ const cfg = loadConfig();
215
+ const db = new StateDb();
216
+ try {
217
+ console.log("scanning for duplicate candidates...");
218
+ const result = await detectDuplicates(cfg, db);
219
+ console.log(
220
+ `${result.checked} candidate pairs checked, ${result.duplicates.length} flagged as duplicates`,
221
+ );
222
+ if (result.duplicates.length === 0) {
223
+ return;
224
+ }
225
+
226
+ const rl = createInterface({ input: stdin, output: stdout });
227
+ let merged = 0;
228
+ let skipped = 0;
229
+ for (const dup of result.duplicates) {
230
+ console.log("\nDuplicate found:");
231
+ console.log(
232
+ `A: ${noteRefOf(dup.a)} (conf: ${dup.a.confidence.toFixed(2)}, ${dup.a.startedAt?.slice(0, 10) ?? "?"})`,
233
+ );
234
+ console.log(` "${preview(dup.a.content)}"`);
235
+ console.log(
236
+ `B: ${noteRefOf(dup.b)} (conf: ${dup.b.confidence.toFixed(2)}, ${dup.b.startedAt?.slice(0, 10) ?? "?"})`,
237
+ );
238
+ console.log(` "${preview(dup.b.content)}"`);
239
+ console.log(`Reason: ${dup.reason}`);
240
+ const suggestion =
241
+ dup.keepWhich === "merge"
242
+ ? "merge both"
243
+ : `keep ${dup.keepWhich}`;
244
+ console.log(`Suggested: ${suggestion}`);
245
+
246
+ const ans = (
247
+ await rl.question(
248
+ "[k]eep suggestion / [s]wap / [m]erge / [x] skip: ",
249
+ )
250
+ )
251
+ .trim()
252
+ .toLowerCase();
253
+
254
+ let action: "A" | "B" | "merge" | null = null;
255
+ if (ans === "k" || ans === "") {
256
+ action = dup.keepWhich;
257
+ } else if (ans === "s") {
258
+ action =
259
+ dup.keepWhich === "A"
260
+ ? "B"
261
+ : dup.keepWhich === "B"
262
+ ? "A"
263
+ : "merge";
264
+ } else if (ans === "m") {
265
+ action = "merge";
266
+ } else if (ans === "x") {
267
+ skipped += 1;
268
+ continue;
269
+ } else {
270
+ console.log(chalk.yellow("unknown input — skipping"));
271
+ skipped += 1;
272
+ continue;
273
+ }
274
+
275
+ try {
276
+ const outcome = await mergeNotes(cfg, db, dup.a, dup.b, action);
277
+ merged += 1;
278
+ console.log(
279
+ chalk.green(
280
+ `merged (${outcome.action}): winner=${outcome.winnerPath} archived=${outcome.archivedPath}`,
281
+ ),
282
+ );
283
+ } catch (err) {
284
+ console.error(
285
+ chalk.red(`merge failed: ${(err as Error).message}`),
286
+ );
287
+ }
288
+ }
289
+ rl.close();
290
+ console.log(
291
+ `\n${result.duplicates.length} pairs reviewed, ${merged} merged, ${skipped} skipped.`,
292
+ );
293
+ } finally {
294
+ db.close();
295
+ }
296
+ });
297
+
298
+ program
299
+ .command("lint")
300
+ .description("Run orphan, staleness, and contradiction checks on the vault")
301
+ .option("--orphans", "Run only the orphan check (free)")
302
+ .option("--stale", "Run only the staleness check (free)")
303
+ .option("--contradictions", "Run only the contradiction check (Haiku tokens)")
304
+ .action(
305
+ async (opts: {
306
+ orphans?: boolean;
307
+ stale?: boolean;
308
+ contradictions?: boolean;
309
+ }) => {
310
+ const cfg = loadConfig();
311
+ const db = new StateDb();
312
+ try {
313
+ const runAll = !opts.orphans && !opts.stale && !opts.contradictions;
314
+ const checks: string[] = [];
315
+ if (runAll || opts.orphans) checks.push("orphans");
316
+ if (runAll || opts.stale) checks.push("stale");
317
+ if (runAll || opts.contradictions) checks.push("contradictions");
318
+
319
+ ui.header("lint");
320
+ ui.blank();
321
+
322
+ let orphanCount = 0;
323
+ let staleCount = 0;
324
+ let contradictionCount = 0;
325
+ let issues = 0;
326
+
327
+ if (runAll || opts.orphans) {
328
+ const sp = ui.spinner("checking orphans").start();
329
+ const r = orphanCheck(cfg);
330
+ sp.stop();
331
+ orphanCount = r.orphans.length;
332
+ issues += orphanCount;
333
+ if (orphanCount === 0) {
334
+ ui.row(ui.success(ui.CHECK), `${ui.text("orphans")} ${ui.dim("none")}`);
335
+ } else {
336
+ ui.row(ui.errorColor(ui.CROSS), `${ui.text("orphans")} ${ui.dim("(" + orphanCount + ")")}`);
337
+ for (const o of r.orphans) {
338
+ console.log(` ${ui.dim(ui.BULLET)} ${ui.text(ui.shortNotePath(o))}`);
339
+ }
340
+ }
341
+ }
342
+
343
+ if (runAll || opts.stale) {
344
+ const sp = ui.spinner("checking staleness").start();
345
+ const stale = stalenessCheck(cfg, db);
346
+ sp.stop();
347
+ staleCount = stale.length;
348
+ issues += staleCount;
349
+ if (staleCount === 0) {
350
+ ui.row(ui.success(ui.CHECK), `${ui.text("stale")} ${ui.dim("none")}`);
351
+ } else {
352
+ ui.row(ui.errorColor(ui.CROSS), `${ui.text("stale")} ${ui.dim("(" + staleCount + ")")}`);
353
+ for (const s of stale) {
354
+ console.log(
355
+ ` ${ui.dim(ui.BULLET)} ${ui.text(ui.shortNotePath(s.relPath))} ${ui.muted(`${s.ageDays}d`)} ${ui.dim(`${s.newerSameProjectCount} newer ${s.project} sessions`)}`,
356
+ );
357
+ }
358
+ }
359
+ }
360
+
361
+ if (runAll || opts.contradictions) {
362
+ const sp = ui.spinner("checking contradictions (haiku)").start();
363
+ const c = await contradictionCheck(cfg, db);
364
+ sp.stop();
365
+ contradictionCount = c.contradictions.length;
366
+ issues += contradictionCount;
367
+ if (contradictionCount === 0) {
368
+ ui.row(
369
+ ui.success(ui.CHECK),
370
+ `${ui.text("contradictions")} ${ui.dim(`none found in ${c.checked} pairs`)}`,
371
+ );
372
+ } else {
373
+ ui.row(
374
+ ui.errorColor(ui.CROSS),
375
+ `${ui.text("contradictions")} ${ui.dim("(" + contradictionCount + ")")}`,
376
+ );
377
+ for (const x of c.contradictions) {
378
+ console.log(
379
+ ` ${ui.dim(ui.BULLET)} ${ui.text(ui.shortNotePath(x.a))} ${ui.dim("vs")} ${ui.text(ui.shortNotePath(x.b))}`,
380
+ );
381
+ console.log(` ${ui.muted(x.reason)}`);
382
+ }
383
+ }
384
+ }
385
+
386
+ ui.blank();
387
+ ui.divider();
388
+ ui.summary({
389
+ issues: {
390
+ value: issues,
391
+ color: issues > 0 ? ui.errorColor : ui.success,
392
+ },
393
+ orphans: { value: orphanCount, color: ui.muted },
394
+ stale: { value: staleCount, color: ui.muted },
395
+ contradictions: { value: contradictionCount, color: ui.muted },
396
+ });
397
+ ui.divider();
398
+ } finally {
399
+ db.close();
400
+ }
401
+ },
402
+ );
403
+
404
+ program
405
+ .command("summarize [project]")
406
+ .description("Generate or regenerate a project knowledge summary")
407
+ .option("--all", "Regenerate summaries for every project with notes")
408
+ .action(async (project: string | undefined, opts: { all?: boolean }) => {
409
+ const cfg = loadConfig();
410
+ const db = new StateDb();
411
+ try {
412
+ if (opts.all) {
413
+ const results = await summarizeAll(cfg, db);
414
+ if (results.length === 0) {
415
+ console.log(chalk.yellow("no projects with notes"));
416
+ return;
417
+ }
418
+ for (const r of results) {
419
+ console.log(
420
+ chalk.green(`summarized project/${r.slug}`) +
421
+ ` (${r.counts.total} sessions)`,
422
+ );
423
+ }
424
+ return;
425
+ }
426
+ if (!project) {
427
+ console.error(chalk.red("usage: vir summarize <project> | --all"));
428
+ exit(1);
429
+ }
430
+ const res = await summarizeProject(cfg, project, db);
431
+ if (!res) {
432
+ console.log(chalk.yellow(`no distilled notes for project '${project}'`));
433
+ return;
434
+ }
435
+ console.log(
436
+ chalk.green(`summarized project/${res.slug}`) +
437
+ ` (${res.counts.total} sessions) → ${res.path}`,
438
+ );
439
+ } finally {
440
+ db.close();
441
+ }
442
+ });
443
+
444
+ program
445
+ .command("embed")
446
+ .description("Generate Ollama embeddings for distilled notes")
447
+ .option("--force", "Regenerate even if embedding already exists")
448
+ .action(async (opts: { force?: boolean }) => {
449
+ const cfg = loadConfig();
450
+ ui.header("embed");
451
+ ui.blank();
452
+ if (!(await isOllamaAvailable())) {
453
+ ui.row(ui.errorColor(ui.CROSS), ui.text("Ollama not running"));
454
+ ui.line(ui.dim(" brew install ollama"));
455
+ ui.line(ui.dim(" ollama pull nomic-embed-text"));
456
+ ui.line(ui.dim(" ollama serve"));
457
+ exit(1);
458
+ }
459
+ const db = new StateDb();
460
+ try {
461
+ const rows = db.listDistilled();
462
+ const root = join(cfg.vaultPath, cfg.outputDir);
463
+ const existing = new Set(
464
+ db.getEmbeddings(root).map((r) => r.sessionId),
465
+ );
466
+ const target = opts.force
467
+ ? rows
468
+ : rows.filter((r) => !existing.has(r.sessionId));
469
+
470
+ if (target.length === 0) {
471
+ ui.row(ui.success(ui.CHECK), ui.text("all notes already embedded"));
472
+ return;
473
+ }
474
+
475
+ const sp = ui.spinner(`embedding notes (0/${target.length})`).start();
476
+ let embedded = 0;
477
+ let skipped = 0;
478
+ let errors = 0;
479
+ for (let i = 0; i < target.length; i += 1) {
480
+ const r = target[i];
481
+ if (!r) continue;
482
+ if (r.content.trim().length === 0) {
483
+ skipped += 1;
484
+ continue;
485
+ }
486
+ const vec = await embeddingForNote(r.content);
487
+ if (!vec) {
488
+ errors += 1;
489
+ continue;
490
+ }
491
+ db.storeEmbedding(r.sessionId, vec);
492
+ embedded += 1;
493
+ sp.text = ui.dim(`embedding notes (${embedded}/${target.length})`);
494
+ }
495
+ sp.succeed(ui.text(`embedded ${embedded} notes`));
496
+ ui.blank();
497
+ ui.divider();
498
+ ui.summary({
499
+ embedded: { value: embedded, color: ui.success },
500
+ skipped: { value: skipped, color: ui.muted },
501
+ errors: {
502
+ value: errors,
503
+ color: errors > 0 ? ui.errorColor : ui.dim,
504
+ },
505
+ });
506
+ ui.divider();
507
+ } finally {
508
+ db.close();
509
+ }
510
+ });
511
+
512
+ program
513
+ .command("query <question>")
514
+ .description("Search the vault: embedding/TF-IDF retrieval + Claude synthesis")
515
+ .action(async (question: string) => {
516
+ const cfg = loadConfig();
517
+ const db = new StateDb();
518
+ try {
519
+ ui.header("query");
520
+ ui.divider();
521
+ console.log(ui.text(question));
522
+ ui.divider();
523
+ ui.blank();
524
+
525
+ const ollamaUp = await isOllamaAvailable();
526
+ const sp = ui
527
+ .spinner(`searching vault (${ollamaUp ? "embeddings" : "tfidf"})`)
528
+ .start();
529
+ let hits;
530
+ try {
531
+ hits = await search(cfg, db, question, 8);
532
+ sp.stop();
533
+ } catch (err) {
534
+ sp.fail(ui.errorColor((err as Error).message));
535
+ return;
536
+ }
537
+
538
+ if (hits.length === 0) {
539
+ ui.row(ui.warn(ui.WARN_GLYPH), ui.text("no documents matched"));
540
+ return;
541
+ }
542
+
543
+ const answer = await synthesize(cfg, question, hits);
544
+ ui.blank();
545
+ console.log(ui.text(ui.wrap(answer.trim(), 60)));
546
+ ui.blank();
547
+
548
+ const method = hits[0]?.method ?? "tfidf";
549
+ const relevant = hits.filter((h) => h.score > 0).slice(0, 3);
550
+ ui.divider();
551
+ for (const h of relevant) ui.sourceRow(h.title, h.score);
552
+ ui.divider();
553
+ const totalNotes =
554
+ method === "embedding"
555
+ ? db.getEmbeddings(join(cfg.vaultPath, cfg.outputDir)).length
556
+ : new VaultWriter(cfg).noteCount();
557
+ ui.summary({
558
+ sources: { value: relevant.length, color: ui.info },
559
+ via: { value: method, color: ui.accent },
560
+ searched: { value: totalNotes, color: ui.muted },
561
+ });
562
+ } finally {
563
+ db.close();
564
+ }
565
+ });
566
+
567
+ program
568
+ .command("status")
569
+ .description("Show processing status + knowledge base breakdown")
570
+ .action(() => {
571
+ const cfg = configExists() ? loadConfig() : null;
572
+ if (!cfg) {
573
+ ui.header("status");
574
+ ui.row(ui.warn(ui.WARN_GLYPH), ui.text("not configured — run `vir init`"));
575
+ return;
576
+ }
577
+ const db = new StateDb();
578
+ const knowledge = db.getStats();
579
+ db.close();
580
+ const ds = daemonStatus();
581
+
582
+ ui.header("status");
583
+ ui.blank();
584
+ renderKnowledge(knowledge);
585
+ ui.blank();
586
+ renderDaemon(ds, cfg.cadenceHours);
587
+ });
588
+
589
+ function renderKnowledge(k: KnowledgeStats): void {
590
+ if (k.total === 0) {
591
+ ui.box(
592
+ [
593
+ ui.text("no distilled notes yet"),
594
+ ui.dim("run `vir run --full` to populate"),
595
+ ],
596
+ { title: "knowledge" },
597
+ );
598
+ return;
599
+ }
600
+
601
+ const lines: string[] = [];
602
+ lines.push(
603
+ `${ui.dim("notes")} ${ui.text(String(k.total).padStart(3))} ${ui.dim("avg conf")} ${ui.info(k.avgConfidence.toFixed(2))}`,
604
+ );
605
+ lines.push(
606
+ `${ui.dim("high signal")} ${ui.success(String(k.highConf).padStart(2))} ${ui.dim("low signal")} ${ui.errorColor(String(k.lowConf).padStart(2))}`,
607
+ );
608
+ const oldest = (k.oldestNote || "?").slice(0, 10);
609
+ const newest = (k.newestNote || "?").slice(0, 10);
610
+ lines.push(
611
+ `${ui.muted(oldest)} ${ui.dim(ui.ARROW)} ${ui.muted(newest)}`,
612
+ );
613
+ ui.box(lines, { title: "knowledge" });
614
+
615
+ ui.blank();
616
+ const entries: Array<[string, number]> = [
617
+ ["pattern", k.byCategory.pattern ?? 0],
618
+ ["decision", k.byCategory.decision ?? 0],
619
+ ["gotcha", k.byCategory.gotcha ?? 0],
620
+ ["tool", k.byCategory.tool ?? 0],
621
+ ];
622
+ const maxCount = Math.max(1, ...entries.map(([, c]) => c));
623
+ for (const [label, count] of entries) {
624
+ const w = 16;
625
+ const filled = Math.round((count / maxCount) * w);
626
+ const bar = "█".repeat(filled) + "░".repeat(w - filled);
627
+ const pct = k.total > 0 ? Math.round((count / k.total) * 100) : 0;
628
+ const color = ui.colorForCategory[label] ?? ui.text;
629
+ console.log(
630
+ `${color(label.padEnd(9))} ${color(bar)} ${ui.text(String(count).padStart(3))} ${ui.dim(String(pct).padStart(3) + "%")}`,
631
+ );
632
+ }
633
+
634
+ ui.blank();
635
+ const projectLines: string[] = [];
636
+ const projects = Object.entries(k.byProject).sort(
637
+ (a, b) => b[1].total - a[1].total,
638
+ );
639
+ for (const [name, p] of projects) {
640
+ const last = p.lastSeen ? p.lastSeen.slice(0, 10) : "—";
641
+ const conf = p.avgConfidence.toFixed(2);
642
+ projectLines.push(
643
+ `${ui.text(name.padEnd(10).slice(0, 10))} ${ui.info(String(p.total).padStart(3))} ` +
644
+ `${ui.dim("P")}${ui.text(String(p.patterns).padStart(2))} ` +
645
+ `${ui.dim("G")}${ui.text(String(p.gotchas).padStart(2))} ` +
646
+ `${ui.dim("D")}${ui.text(String(p.decisions).padStart(2))} ` +
647
+ `${ui.dim("T")}${ui.text(String(p.tools).padStart(2))} ` +
648
+ `${ui.info(conf)} ${ui.muted(last)}`,
649
+ );
650
+ }
651
+ ui.box(projectLines, { title: "projects", width: 52 });
652
+
653
+ ui.blank();
654
+ for (const [name, p] of projects) {
655
+ if (p.total === 0) continue;
656
+ if (p.gotchas === 0) {
657
+ ui.row(
658
+ ui.warn(ui.WARN_GLYPH),
659
+ ui.text(`${name} — no gotchas recorded`),
660
+ );
661
+ }
662
+ if (p.decisions === 0) {
663
+ ui.row(
664
+ ui.warn(ui.WARN_GLYPH),
665
+ ui.text(`${name} — no architecture decisions`),
666
+ );
667
+ }
668
+ if (p.avgConfidence < 0.65) {
669
+ ui.row(
670
+ ui.warn(ui.WARN_GLYPH),
671
+ ui.text(
672
+ `${name} — low avg confidence (${p.avgConfidence.toFixed(2)})`,
673
+ ),
674
+ );
675
+ }
676
+ }
677
+ }
678
+
679
+ function renderDaemon(
680
+ ds: ReturnType<typeof daemonStatus>,
681
+ cadenceHours: number,
682
+ ): void {
683
+ const status = ds.loaded ? "running" : ds.installed ? "loaded" : "off";
684
+ const statusColor = ds.loaded
685
+ ? ui.success
686
+ : ds.installed
687
+ ? ui.warn
688
+ : ui.dim;
689
+ ui.box(
690
+ [
691
+ `${ui.dim("status")} ${statusColor(status)}`,
692
+ `${ui.dim("cadence")} ${ui.text(`every ${cadenceHours}h`)}`,
693
+ `${ui.dim("plist")} ${ui.muted(collapseHome(ds.plistPath))}`,
694
+ ],
695
+ { title: "daemon", width: 52 },
696
+ );
697
+ }
698
+
699
+ async function cmdInit(): Promise<void> {
700
+ ensureVirDir();
701
+ const existing = configExists() ? safeLoad() : null;
702
+
703
+ ui.header("init");
704
+ ui.blank();
705
+
706
+ // ── vault path ──────────────────────────────────────────────────────────
707
+ let vaultPath = "";
708
+ for (;;) {
709
+ vaultPath = await input({
710
+ message: "Obsidian vault path",
711
+ default:
712
+ existing?.vaultPath ??
713
+ join(homedir(), "Documents", "Obsidian", "MyVault"),
714
+ });
715
+ const expanded = expandHome(vaultPath);
716
+ if (existsSync(expanded)) break;
717
+ const create = await confirm({
718
+ message: `Vault path does not exist (${expanded}). Create it?`,
719
+ default: true,
720
+ });
721
+ if (create) {
722
+ try {
723
+ mkdirSync(expanded, { recursive: true });
724
+ break;
725
+ } catch (err) {
726
+ console.error(
727
+ chalk.red(`failed to create: ${(err as Error).message}`),
728
+ );
729
+ }
730
+ }
731
+ }
732
+
733
+ const outputDir = await input({
734
+ message: "Output subdir inside vault",
735
+ default: existing?.outputDir ?? "vir",
736
+ });
737
+
738
+ // ── claude projects dir ─────────────────────────────────────────────────
739
+ let claudeProjectsDir = "";
740
+ for (;;) {
741
+ claudeProjectsDir = await input({
742
+ message: "Claude Code projects dir",
743
+ default:
744
+ existing?.claudeProjectsDir ?? join(homedir(), ".claude", "projects"),
745
+ });
746
+ const expanded = expandHome(claudeProjectsDir);
747
+ if (existsSync(expanded)) break;
748
+ console.warn(
749
+ chalk.yellow(
750
+ "directory not found — Claude Code sessions may not exist yet",
751
+ ),
752
+ );
753
+ const cont = await confirm({
754
+ message: "continue anyway?",
755
+ default: false,
756
+ });
757
+ if (cont) break;
758
+ }
759
+
760
+ const cadenceHours = Number(
761
+ await input({
762
+ message: "Cadence (hours)",
763
+ default: String(existing?.cadenceHours ?? 4),
764
+ validate: (v: string) => {
765
+ const n = Number(v);
766
+ return Number.isFinite(n) && n > 0 ? true : "must be a positive number";
767
+ },
768
+ }),
769
+ );
770
+
771
+ // ── provider picker ─────────────────────────────────────────────────────
772
+ const provider = (await select({
773
+ message: "Provider",
774
+ default: existing?.provider ?? "anthropic",
775
+ choices: [
776
+ {
777
+ name: "Anthropic (direct, official pricing)",
778
+ value: "anthropic" as const,
779
+ },
780
+ {
781
+ name: "Kie.ai (same Claude models, ~72% cheaper)",
782
+ value: "kie" as const,
783
+ },
784
+ ],
785
+ })) as "anthropic" | "kie";
786
+
787
+ let anthropicApiKey: string | undefined;
788
+ let kieApiKey: string | undefined;
789
+ if (provider === "anthropic") {
790
+ anthropicApiKey = await input({
791
+ message: "Anthropic API key",
792
+ default: existing?.anthropicApiKey ?? process.env.ANTHROPIC_API_KEY ?? "",
793
+ validate: (v: string) =>
794
+ v.startsWith("sk-ant-") ? true : "key should start with sk-ant-",
795
+ });
796
+ } else {
797
+ kieApiKey = await input({
798
+ message: "Kie.ai API key",
799
+ default: existing?.kieApiKey ?? process.env.KIE_API_KEY ?? "",
800
+ validate: (v: string) =>
801
+ v.length > 10 ? true : "enter a valid Kie.ai API key",
802
+ });
803
+ }
804
+
805
+ // ── model pickers (provider-aware) ──────────────────────────────────────
806
+ const classifyChoices =
807
+ provider === "anthropic"
808
+ ? [
809
+ {
810
+ name: "claude-haiku-4-5-20251001 (recommended)",
811
+ value: "claude-haiku-4-5-20251001",
812
+ },
813
+ { name: "claude-sonnet-4-6", value: "claude-sonnet-4-6" },
814
+ ]
815
+ : [
816
+ {
817
+ name: "claude-haiku-4-5 (recommended)",
818
+ value: "claude-haiku-4-5",
819
+ },
820
+ { name: "claude-sonnet-4-6", value: "claude-sonnet-4-6" },
821
+ ];
822
+ const classifyModel = await select({
823
+ message: "Classify model (fast pass)",
824
+ choices: classifyChoices,
825
+ });
826
+
827
+ const distillChoices =
828
+ provider === "anthropic"
829
+ ? [
830
+ {
831
+ name: "claude-sonnet-4-6 (recommended)",
832
+ value: "claude-sonnet-4-6",
833
+ },
834
+ {
835
+ name: "claude-haiku-4-5-20251001 (faster, cheaper)",
836
+ value: "claude-haiku-4-5-20251001",
837
+ },
838
+ ]
839
+ : [
840
+ {
841
+ name: "claude-sonnet-4-6 (recommended)",
842
+ value: "claude-sonnet-4-6",
843
+ },
844
+ {
845
+ name: "claude-haiku-4-5 (faster, cheaper)",
846
+ value: "claude-haiku-4-5",
847
+ },
848
+ ];
849
+ const distillModel = await select({
850
+ message: "Distill model (deep extraction)",
851
+ choices: distillChoices,
852
+ });
853
+
854
+ const filterThreshold = Number(
855
+ await input({
856
+ message: "Filter threshold (0..1)",
857
+ default: String(existing?.filterThreshold ?? 0.4),
858
+ validate: (v: string) => {
859
+ const n = Number(v);
860
+ return Number.isFinite(n) && n >= 0 && n <= 1
861
+ ? true
862
+ : "must be between 0 and 1";
863
+ },
864
+ }),
865
+ );
866
+
867
+ const parsed = ConfigSchema.safeParse({
868
+ vaultPath,
869
+ outputDir,
870
+ claudeProjectsDir,
871
+ cadenceHours,
872
+ provider,
873
+ anthropicApiKey,
874
+ kieApiKey,
875
+ filterThreshold,
876
+ models: { classify: classifyModel, distill: distillModel },
877
+ });
878
+
879
+ if (!parsed.success) {
880
+ console.error(chalk.red("invalid config:"));
881
+ for (const issue of parsed.error.issues) {
882
+ console.error(` - ${issue.path.join(".")}: ${issue.message}`);
883
+ }
884
+ exit(1);
885
+ }
886
+
887
+ saveConfig(parsed.data);
888
+ ui.blank();
889
+ ui.row(ui.success(ui.CHECK), ui.text(`saved ${CONFIG_PATH}`));
890
+ ui.line(ui.dim("next: `vir run` to test once, then `vir schedule install`"));
891
+ }
892
+
893
+ function renderPlan(p: PlanItem): void {
894
+ const title = collapseHome(p.target);
895
+ if (!p.exists) {
896
+ ui.box([ui.dim("no CLAUDE.md found — would be skipped")], { title });
897
+ return;
898
+ }
899
+ const lines: string[] = [];
900
+ for (const e of p.diff.added) {
901
+ lines.push(`${ui.success("+")} ${ui.text(e.slug)}`);
902
+ }
903
+ for (const u of p.diff.upgraded) {
904
+ lines.push(
905
+ `${ui.info(ui.UP_ARROW)} ${ui.text(u.slug)} ${ui.dim(`${u.oldConf.toFixed(2)}${ui.ARROW}${u.newConf.toFixed(2)}`)}`,
906
+ );
907
+ }
908
+ for (const r of p.diff.removed) {
909
+ lines.push(`${ui.warn("-")} ${ui.text(r.slug)}`);
910
+ }
911
+ if (p.diff.unchanged.length > 0) {
912
+ lines.push(
913
+ `${ui.dim("~")} ${ui.dim(`${p.diff.unchanged.length} entries unchanged`)}`,
914
+ );
915
+ }
916
+ if (lines.length === 0) lines.push(ui.dim("no changes"));
917
+ ui.box(lines, { title });
918
+ }
919
+
920
+ function collapseHome(p: string): string {
921
+ const h = homedir();
922
+ return p.startsWith(h) ? "~" + p.slice(h.length) : p;
923
+ }
924
+
925
+ function noteRefOf(r: {
926
+ category: string;
927
+ topic: string;
928
+ sessionId: string;
929
+ }): string {
930
+ const dir = `${r.category}s`;
931
+ const slug = r.topic
932
+ .toLowerCase()
933
+ .replace(/[^a-z0-9]+/g, "-")
934
+ .replace(/^-+|-+$/g, "");
935
+ return `${dir}/${slug}-${r.sessionId.slice(0, 8)}`;
936
+ }
937
+
938
+ function preview(s: string): string {
939
+ return s.replace(/\s+/g, " ").trim().slice(0, 80);
940
+ }
941
+
942
+ function safeLoad(): Config | null {
943
+ try {
944
+ return loadConfig();
945
+ } catch {
946
+ return null;
947
+ }
948
+ }
949
+
950
+ program.parseAsync(argv).catch((err: unknown) => {
951
+ console.error(chalk.red((err as Error).message ?? String(err)));
952
+ exit(1);
953
+ });