@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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname } from "node:path";
|
|
3
|
+
import type { ParsedSession, TranscriptLine } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const FILE_TOOLS = new Set([
|
|
6
|
+
"Read",
|
|
7
|
+
"Edit",
|
|
8
|
+
"Write",
|
|
9
|
+
"NotebookEdit",
|
|
10
|
+
"MultiEdit",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export function parseSession(path: string, hash: string): ParsedSession {
|
|
14
|
+
const raw = readFileSync(path, "utf8");
|
|
15
|
+
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
|
|
16
|
+
|
|
17
|
+
let startedAt: string | null = null;
|
|
18
|
+
let endedAt: string | null = null;
|
|
19
|
+
let toolCallCount = 0;
|
|
20
|
+
const filesTouched = new Set<string>();
|
|
21
|
+
const assistantBlocks: string[] = [];
|
|
22
|
+
const userBlocks: string[] = [];
|
|
23
|
+
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
let evt: TranscriptLine;
|
|
26
|
+
try {
|
|
27
|
+
evt = JSON.parse(line) as TranscriptLine;
|
|
28
|
+
} catch {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ts = typeof evt.timestamp === "string" ? evt.timestamp : null;
|
|
33
|
+
if (ts) {
|
|
34
|
+
if (!startedAt) startedAt = ts;
|
|
35
|
+
endedAt = ts;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const msg = evt.message ?? evt;
|
|
39
|
+
const role = typeof msg.role === "string" ? msg.role : evt.role;
|
|
40
|
+
const content = msg.content ?? evt.content;
|
|
41
|
+
|
|
42
|
+
if (Array.isArray(content)) {
|
|
43
|
+
for (const block of content) {
|
|
44
|
+
if (!block || typeof block !== "object") continue;
|
|
45
|
+
const b = block as Record<string, unknown>;
|
|
46
|
+
const blockType = typeof b.type === "string" ? b.type : null;
|
|
47
|
+
|
|
48
|
+
if (blockType === "text" && typeof b.text === "string") {
|
|
49
|
+
if (role === "assistant") assistantBlocks.push(b.text);
|
|
50
|
+
else if (role === "user") userBlocks.push(b.text);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (blockType === "tool_use") {
|
|
54
|
+
toolCallCount += 1;
|
|
55
|
+
const toolName = typeof b.name === "string" ? b.name : "";
|
|
56
|
+
const input = (b.input as Record<string, unknown> | undefined) ?? {};
|
|
57
|
+
if (FILE_TOOLS.has(toolName)) {
|
|
58
|
+
const fp =
|
|
59
|
+
typeof input.file_path === "string"
|
|
60
|
+
? input.file_path
|
|
61
|
+
: typeof input.path === "string"
|
|
62
|
+
? input.path
|
|
63
|
+
: null;
|
|
64
|
+
if (fp) filesTouched.add(fp);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} else if (typeof content === "string") {
|
|
69
|
+
if (role === "assistant") assistantBlocks.push(content);
|
|
70
|
+
else if (role === "user") userBlocks.push(content);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const assistantText = assistantBlocks.join("\n\n");
|
|
75
|
+
const userText = userBlocks.join("\n\n");
|
|
76
|
+
const rawSummary = buildRawSummary({
|
|
77
|
+
userText,
|
|
78
|
+
assistantText,
|
|
79
|
+
toolCallCount,
|
|
80
|
+
filesTouched: [...filesTouched],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
path,
|
|
85
|
+
hash,
|
|
86
|
+
sessionId: basename(path, ".jsonl"),
|
|
87
|
+
projectSlug: basename(dirname(path)),
|
|
88
|
+
startedAt,
|
|
89
|
+
endedAt,
|
|
90
|
+
lineCount: lines.length,
|
|
91
|
+
toolCallCount,
|
|
92
|
+
filesTouched: [...filesTouched],
|
|
93
|
+
assistantText,
|
|
94
|
+
userText,
|
|
95
|
+
rawSummary,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildRawSummary(opts: {
|
|
100
|
+
userText: string;
|
|
101
|
+
assistantText: string;
|
|
102
|
+
toolCallCount: number;
|
|
103
|
+
filesTouched: string[];
|
|
104
|
+
}): string {
|
|
105
|
+
const userPreview = truncate(opts.userText, 4000);
|
|
106
|
+
const assistantPreview = truncate(opts.assistantText, 8000);
|
|
107
|
+
return [
|
|
108
|
+
`# User messages\n${userPreview}`,
|
|
109
|
+
`# Assistant messages\n${assistantPreview}`,
|
|
110
|
+
`# Tool calls: ${opts.toolCallCount}`,
|
|
111
|
+
`# Files touched (${opts.filesTouched.length}):\n${opts.filesTouched.slice(0, 50).join("\n")}`,
|
|
112
|
+
].join("\n\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function truncate(s: string, n: number): string {
|
|
116
|
+
if (s.length <= n) return s;
|
|
117
|
+
return s.slice(0, n) + `\n…[truncated ${s.length - n} chars]`;
|
|
118
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { appendFileSync } from "node:fs";
|
|
3
|
+
import { DAEMON_LOG_PATH, ensureVirDir, type Config } from "../config.js";
|
|
4
|
+
import { StateDb } from "../state/db.js";
|
|
5
|
+
import * as ui from "../ui/display.js";
|
|
6
|
+
import { Distiller } from "./distiller.js";
|
|
7
|
+
import { scoreSession } from "./filter.js";
|
|
8
|
+
import { parseSession } from "./parser.js";
|
|
9
|
+
import { scanSessions } from "./scanner.js";
|
|
10
|
+
import { scrub } from "./scrubber.js";
|
|
11
|
+
import { summarizeProject } from "./summarizer.js";
|
|
12
|
+
import type { DistilledNote, ParsedSession } from "./types.js";
|
|
13
|
+
import { kebab, VaultWriter } from "./writer.js";
|
|
14
|
+
|
|
15
|
+
export interface RunOptions {
|
|
16
|
+
full?: boolean;
|
|
17
|
+
quiet?: boolean;
|
|
18
|
+
logToFile?: boolean;
|
|
19
|
+
rewriteOnly?: boolean;
|
|
20
|
+
// Called after the scan with the count of sessions that will be distilled.
|
|
21
|
+
// Return false to abort cleanly. If omitted, the run always proceeds —
|
|
22
|
+
// daemon callers rely on this default.
|
|
23
|
+
onConfirm?: (newCount: number) => Promise<boolean>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RunSummary {
|
|
27
|
+
scanned: number;
|
|
28
|
+
alreadyProcessed: number;
|
|
29
|
+
skippedByFilter: number;
|
|
30
|
+
distilled: number;
|
|
31
|
+
lowConfidence: number;
|
|
32
|
+
errored: number;
|
|
33
|
+
rewritten: number;
|
|
34
|
+
notesWritten: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function runPipeline(
|
|
38
|
+
cfg: Config,
|
|
39
|
+
opts: RunOptions = {},
|
|
40
|
+
): Promise<RunSummary> {
|
|
41
|
+
ensureVirDir();
|
|
42
|
+
const db = new StateDb();
|
|
43
|
+
const writer = new VaultWriter(cfg, db);
|
|
44
|
+
|
|
45
|
+
const summary: RunSummary = {
|
|
46
|
+
scanned: 0,
|
|
47
|
+
alreadyProcessed: 0,
|
|
48
|
+
skippedByFilter: 0,
|
|
49
|
+
distilled: 0,
|
|
50
|
+
lowConfidence: 0,
|
|
51
|
+
errored: 0,
|
|
52
|
+
rewritten: 0,
|
|
53
|
+
notesWritten: [],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const interactive = !opts.quiet;
|
|
57
|
+
|
|
58
|
+
// File-only logging — used for the daemon run.log regardless of UI mode.
|
|
59
|
+
const fileLog = (msg: string): void => {
|
|
60
|
+
if (!opts.logToFile) return;
|
|
61
|
+
try {
|
|
62
|
+
appendFileSync(
|
|
63
|
+
DAEMON_LOG_PATH,
|
|
64
|
+
`[${new Date().toISOString()}] ${msg}\n`,
|
|
65
|
+
);
|
|
66
|
+
} catch {
|
|
67
|
+
// ignore log errors
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
if (interactive) {
|
|
72
|
+
ui.header(
|
|
73
|
+
opts.rewriteOnly
|
|
74
|
+
? "run --rewrite-only"
|
|
75
|
+
: opts.full
|
|
76
|
+
? "run --full"
|
|
77
|
+
: "run",
|
|
78
|
+
);
|
|
79
|
+
ui.blank();
|
|
80
|
+
}
|
|
81
|
+
fileLog(
|
|
82
|
+
`vir run start (full=${opts.full ? "true" : "false"} rewriteOnly=${opts.rewriteOnly ? "true" : "false"})`,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (opts.rewriteOnly) {
|
|
86
|
+
const rows = db.listDistilled();
|
|
87
|
+
fileLog(`rewrite-only: ${rows.length} distilled sessions in db`);
|
|
88
|
+
if (interactive) {
|
|
89
|
+
const sp = ui.spinner(`rewriting ${rows.length} notes`).start();
|
|
90
|
+
try {
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
try {
|
|
93
|
+
const written = await rewriteOne(writer, row);
|
|
94
|
+
summary.rewritten += 1;
|
|
95
|
+
summary.notesWritten.push(...written);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
summary.errored += 1;
|
|
98
|
+
fileLog(`error on ${row.path}: ${(err as Error).message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
sp.succeed(ui.text(`rewrote ${summary.rewritten} notes`));
|
|
102
|
+
} catch (err) {
|
|
103
|
+
sp.fail(ui.errorColor((err as Error).message));
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
for (const row of rows) {
|
|
108
|
+
try {
|
|
109
|
+
const written = await rewriteOne(writer, row);
|
|
110
|
+
summary.rewritten += 1;
|
|
111
|
+
summary.notesWritten.push(...written);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
summary.errored += 1;
|
|
114
|
+
fileLog(`error on ${row.path}: ${(err as Error).message}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
fileLog(
|
|
119
|
+
`vir run done — rewriteOnly rewritten=${summary.rewritten} errored=${summary.errored}`,
|
|
120
|
+
);
|
|
121
|
+
if (interactive) {
|
|
122
|
+
ui.blank();
|
|
123
|
+
ui.divider();
|
|
124
|
+
ui.summary({
|
|
125
|
+
rewritten: { value: summary.rewritten, color: ui.success },
|
|
126
|
+
errored: {
|
|
127
|
+
value: summary.errored,
|
|
128
|
+
color: summary.errored > 0 ? ui.errorColor : ui.dim,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
ui.divider();
|
|
132
|
+
}
|
|
133
|
+
db.close();
|
|
134
|
+
return summary;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const distiller = new Distiller(cfg);
|
|
138
|
+
const newPerProject = new Map<string, number>();
|
|
139
|
+
|
|
140
|
+
const scanSpinner = interactive
|
|
141
|
+
? ui.spinner("scanning ~/.claude/projects").start()
|
|
142
|
+
: null;
|
|
143
|
+
let discovered;
|
|
144
|
+
try {
|
|
145
|
+
discovered = scanSessions(cfg.claudeProjectsDir);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (scanSpinner) scanSpinner.fail(ui.errorColor("scan failed"));
|
|
148
|
+
fileLog(`scanner failed: ${(err as Error).message}`);
|
|
149
|
+
db.close();
|
|
150
|
+
return summary;
|
|
151
|
+
}
|
|
152
|
+
summary.scanned = discovered.length;
|
|
153
|
+
if (scanSpinner) {
|
|
154
|
+
scanSpinner.succeed(
|
|
155
|
+
ui.text(`scanned ${ui.info(String(discovered.length))} ${ui.dim("jsonl files")}`),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
fileLog(`scanned ${discovered.length} jsonl files`);
|
|
159
|
+
if (interactive) ui.blank();
|
|
160
|
+
|
|
161
|
+
// Precompute how many sessions actually need LLM work so the CLI can show
|
|
162
|
+
// an accurate cost confirmation before we hit the API. Also surfaces the
|
|
163
|
+
// found/cached/new breakdown so a fresh DB never silently looks like a
|
|
164
|
+
// stale-cache no-op (the symptom of the state.db → vir.db rename bug).
|
|
165
|
+
let preflightNew = 0;
|
|
166
|
+
for (const found of discovered) {
|
|
167
|
+
if (opts.full || !db.isProcessed(found.path, found.hash)) preflightNew += 1;
|
|
168
|
+
}
|
|
169
|
+
const cached = discovered.length - preflightNew;
|
|
170
|
+
if (interactive) {
|
|
171
|
+
ui.line(
|
|
172
|
+
ui.dim(
|
|
173
|
+
` ${discovered.length} files found · ${cached} cached · ${preflightNew} new`,
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
ui.blank();
|
|
177
|
+
}
|
|
178
|
+
fileLog(
|
|
179
|
+
`preflight: found=${discovered.length} cached=${cached} new=${preflightNew}`,
|
|
180
|
+
);
|
|
181
|
+
if (opts.onConfirm) {
|
|
182
|
+
const proceed = await opts.onConfirm(preflightNew);
|
|
183
|
+
if (!proceed) {
|
|
184
|
+
fileLog("aborted by user at cost prompt");
|
|
185
|
+
db.close();
|
|
186
|
+
return summary;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const found of discovered) {
|
|
191
|
+
try {
|
|
192
|
+
if (!opts.full && db.isProcessed(found.path, found.hash)) {
|
|
193
|
+
summary.alreadyProcessed += 1;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const parsed = parseSession(found.path, found.hash);
|
|
198
|
+
const filter = scoreSession(parsed, cfg.filterThreshold);
|
|
199
|
+
|
|
200
|
+
if (!filter.passes) {
|
|
201
|
+
summary.skippedByFilter += 1;
|
|
202
|
+
db.record({
|
|
203
|
+
path: found.path,
|
|
204
|
+
hash: found.hash,
|
|
205
|
+
skipped: true,
|
|
206
|
+
notePaths: [],
|
|
207
|
+
});
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const scrubbedSummary = scrub(parsed.rawSummary);
|
|
212
|
+
const scrubbedContent = scrub(
|
|
213
|
+
parsed.assistantText + "\n\n" + parsed.userText,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const note = await distiller.run(parsed, scrubbedSummary, scrubbedContent);
|
|
217
|
+
if (!note) {
|
|
218
|
+
summary.lowConfidence += 1;
|
|
219
|
+
db.record({
|
|
220
|
+
path: found.path,
|
|
221
|
+
hash: found.hash,
|
|
222
|
+
skipped: true,
|
|
223
|
+
notePaths: [],
|
|
224
|
+
});
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const written = await writer.write(parsed, note);
|
|
229
|
+
summary.distilled += 1;
|
|
230
|
+
summary.notesWritten.push(...written);
|
|
231
|
+
db.record({
|
|
232
|
+
path: found.path,
|
|
233
|
+
hash: found.hash,
|
|
234
|
+
skipped: false,
|
|
235
|
+
notePaths: written,
|
|
236
|
+
content: note.markdown,
|
|
237
|
+
category: note.classification.category,
|
|
238
|
+
topic: note.classification.topic,
|
|
239
|
+
project: note.classification.project,
|
|
240
|
+
confidence: note.classification.confidence,
|
|
241
|
+
startedAt: parsed.startedAt,
|
|
242
|
+
});
|
|
243
|
+
if (interactive) {
|
|
244
|
+
ui.categoryRow(note.classification.category, note.classification.topic);
|
|
245
|
+
}
|
|
246
|
+
fileLog(
|
|
247
|
+
`distilled ${parsed.sessionId.slice(0, 8)} → ${note.classification.category}/${note.classification.topic}`,
|
|
248
|
+
);
|
|
249
|
+
if (note.classification.confidence >= 0.8) {
|
|
250
|
+
notify(
|
|
251
|
+
`Vir — new ${note.classification.category}`,
|
|
252
|
+
`${note.classification.topic} · ${note.classification.project}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const slug = kebab(note.classification.project);
|
|
256
|
+
if (slug.length > 0) {
|
|
257
|
+
newPerProject.set(slug, (newPerProject.get(slug) ?? 0) + 1);
|
|
258
|
+
}
|
|
259
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
260
|
+
} catch (err) {
|
|
261
|
+
summary.errored += 1;
|
|
262
|
+
const msg = (err as Error).message ?? String(err);
|
|
263
|
+
if (interactive) ui.row(ui.errorColor(ui.CROSS), ui.text(`error: ${msg}`));
|
|
264
|
+
fileLog(`error on ${found.path}: ${msg}`);
|
|
265
|
+
try {
|
|
266
|
+
db.record({
|
|
267
|
+
path: found.path,
|
|
268
|
+
hash: found.hash,
|
|
269
|
+
skipped: false,
|
|
270
|
+
notePaths: [],
|
|
271
|
+
error: msg,
|
|
272
|
+
});
|
|
273
|
+
} catch {
|
|
274
|
+
// ignore record errors
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (const [slug, count] of newPerProject) {
|
|
280
|
+
if (count < 3) continue;
|
|
281
|
+
try {
|
|
282
|
+
const res = await summarizeProject(cfg, slug, db);
|
|
283
|
+
if (res) {
|
|
284
|
+
if (interactive)
|
|
285
|
+
ui.row(ui.success(ui.CHECK), ui.text(`summarized project/${slug}`));
|
|
286
|
+
fileLog(`summarized project/${slug}`);
|
|
287
|
+
}
|
|
288
|
+
} catch (err) {
|
|
289
|
+
const msg = (err as Error).message;
|
|
290
|
+
if (interactive)
|
|
291
|
+
ui.row(
|
|
292
|
+
ui.errorColor(ui.CROSS),
|
|
293
|
+
ui.text(`summary failed for project/${slug}: ${msg}`),
|
|
294
|
+
);
|
|
295
|
+
fileLog(`summary failed for project/${slug}: ${msg}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
fileLog(
|
|
300
|
+
`vir run done — scanned=${summary.scanned} new=${summary.scanned - summary.alreadyProcessed} distilled=${summary.distilled} skipped=${summary.skippedByFilter} lowConf=${summary.lowConfidence} errored=${summary.errored}`,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
if (interactive) {
|
|
304
|
+
ui.blank();
|
|
305
|
+
ui.divider();
|
|
306
|
+
ui.summary({
|
|
307
|
+
scanned: { value: summary.scanned, color: ui.info },
|
|
308
|
+
new: {
|
|
309
|
+
value: summary.scanned - summary.alreadyProcessed,
|
|
310
|
+
color: ui.info,
|
|
311
|
+
},
|
|
312
|
+
distilled: { value: summary.distilled, color: ui.success },
|
|
313
|
+
skipped: { value: summary.skippedByFilter, color: ui.warn },
|
|
314
|
+
errored: {
|
|
315
|
+
value: summary.errored,
|
|
316
|
+
color: summary.errored > 0 ? ui.errorColor : ui.dim,
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
ui.divider();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
db.close();
|
|
323
|
+
return summary;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function rewriteOne(
|
|
327
|
+
writer: VaultWriter,
|
|
328
|
+
row: import("../state/db.js").DistilledRow,
|
|
329
|
+
): Promise<string[]> {
|
|
330
|
+
const parsed: ParsedSession = {
|
|
331
|
+
path: row.path,
|
|
332
|
+
hash: "",
|
|
333
|
+
sessionId: row.sessionId,
|
|
334
|
+
projectSlug: row.project,
|
|
335
|
+
startedAt: row.startedAt,
|
|
336
|
+
endedAt: null,
|
|
337
|
+
lineCount: 0,
|
|
338
|
+
toolCallCount: 0,
|
|
339
|
+
filesTouched: [],
|
|
340
|
+
assistantText: "",
|
|
341
|
+
userText: "",
|
|
342
|
+
rawSummary: "",
|
|
343
|
+
};
|
|
344
|
+
const note: DistilledNote = {
|
|
345
|
+
classification: {
|
|
346
|
+
category: row.category,
|
|
347
|
+
topic: row.topic,
|
|
348
|
+
project: row.project,
|
|
349
|
+
confidence: row.confidence,
|
|
350
|
+
},
|
|
351
|
+
markdown: row.content,
|
|
352
|
+
};
|
|
353
|
+
return writer.write(parsed, note);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// macOS notification via osascript. Uses spawnSync (no shell, no injection).
|
|
357
|
+
// Embedded values are escaped for AppleScript's string literal rules.
|
|
358
|
+
function notify(title: string, message: string): void {
|
|
359
|
+
if (process.platform !== "darwin") return;
|
|
360
|
+
try {
|
|
361
|
+
const safeTitle = escapeAppleScript(title);
|
|
362
|
+
const safeMessage = escapeAppleScript(message);
|
|
363
|
+
spawnSync(
|
|
364
|
+
"osascript",
|
|
365
|
+
[
|
|
366
|
+
"-e",
|
|
367
|
+
`display notification "${safeMessage}" with title "${safeTitle}" sound name "Glass"`,
|
|
368
|
+
],
|
|
369
|
+
{ stdio: "ignore" },
|
|
370
|
+
);
|
|
371
|
+
} catch {
|
|
372
|
+
// notification failure must never crash the pipeline
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function escapeAppleScript(s: string): string {
|
|
377
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
378
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface DiscoveredSession {
|
|
6
|
+
path: string;
|
|
7
|
+
hash: string;
|
|
8
|
+
size: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function hashFile(path: string): string {
|
|
12
|
+
const data = readFileSync(path);
|
|
13
|
+
return createHash("sha256").update(data).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function scanSessions(projectsDir: string): DiscoveredSession[] {
|
|
17
|
+
const out: DiscoveredSession[] = [];
|
|
18
|
+
walk(projectsDir, out);
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function walk(dir: string, acc: DiscoveredSession[]): void {
|
|
23
|
+
let entries: string[];
|
|
24
|
+
try {
|
|
25
|
+
entries = readdirSync(dir);
|
|
26
|
+
} catch {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
for (const name of entries) {
|
|
30
|
+
const full = join(dir, name);
|
|
31
|
+
let st;
|
|
32
|
+
try {
|
|
33
|
+
st = statSync(full);
|
|
34
|
+
} catch {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (st.isDirectory()) {
|
|
38
|
+
walk(full, acc);
|
|
39
|
+
} else if (st.isFile() && name.endsWith(".jsonl")) {
|
|
40
|
+
try {
|
|
41
|
+
acc.push({
|
|
42
|
+
path: full,
|
|
43
|
+
hash: hashFile(full),
|
|
44
|
+
size: st.size,
|
|
45
|
+
});
|
|
46
|
+
} catch {
|
|
47
|
+
// unreadable file - skip
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
|
|
4
|
+
const HOME = homedir();
|
|
5
|
+
|
|
6
|
+
const PATTERNS: Array<{ re: RegExp; replace: string | ((m: string) => string) }> = [
|
|
7
|
+
// Anthropic API keys
|
|
8
|
+
{ re: /sk-ant-[A-Za-z0-9_-]{20,}/g, replace: "[REDACTED_ANTHROPIC_KEY]" },
|
|
9
|
+
// OpenAI API keys
|
|
10
|
+
{ re: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g, replace: "[REDACTED_OPENAI_KEY]" },
|
|
11
|
+
// Generic bearer tokens
|
|
12
|
+
{
|
|
13
|
+
re: /\bBearer\s+[A-Za-z0-9_\-.=]+/gi,
|
|
14
|
+
replace: "Bearer [REDACTED_TOKEN]",
|
|
15
|
+
},
|
|
16
|
+
// GitHub PATs
|
|
17
|
+
{ re: /\bghp_[A-Za-z0-9]{30,}\b/g, replace: "[REDACTED_GH_TOKEN]" },
|
|
18
|
+
{ re: /\bgho_[A-Za-z0-9]{30,}\b/g, replace: "[REDACTED_GH_TOKEN]" },
|
|
19
|
+
// AWS access keys
|
|
20
|
+
{ re: /\bAKIA[0-9A-Z]{16}\b/g, replace: "[REDACTED_AWS_KEY]" },
|
|
21
|
+
// Email addresses
|
|
22
|
+
{
|
|
23
|
+
re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
|
|
24
|
+
replace: "[REDACTED_EMAIL]",
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export function scrub(input: string): string {
|
|
29
|
+
let out = input;
|
|
30
|
+
for (const { re, replace } of PATTERNS) {
|
|
31
|
+
if (typeof replace === "string") {
|
|
32
|
+
out = out.replace(re, replace);
|
|
33
|
+
} else {
|
|
34
|
+
out = out.replace(re, replace);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
out = normalizePaths(out);
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizePaths(input: string): string {
|
|
42
|
+
// Replace user home prefix
|
|
43
|
+
const homeRe = new RegExp(escapeRegex(HOME), "g");
|
|
44
|
+
let out = input.replace(homeRe, "~");
|
|
45
|
+
|
|
46
|
+
// Replace remaining absolute paths with ~/<basename>
|
|
47
|
+
// Match paths like /Users/.../file, /var/..., /tmp/..., /etc/...
|
|
48
|
+
// Conservative: only collapse deeply-nested user-ish paths to avoid mangling /usr/bin/node etc.
|
|
49
|
+
out = out.replace(/\/Users\/[^\s"'`)\]]+/g, (m) => `~/${basename(m)}`);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function escapeRegex(s: string): string {
|
|
54
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
55
|
+
}
|