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