@djolex999/vir-cli 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/initConfig.js +4 -0
- package/dist/cli/initConfig.js.map +1 -1
- package/dist/cli/projectSelect.js +51 -0
- package/dist/cli/projectSelect.js.map +1 -0
- package/dist/cli/projects.js +119 -0
- package/dist/cli/projects.js.map +1 -0
- package/dist/cli/reconcile.js +44 -4
- package/dist/cli/reconcile.js.map +1 -1
- package/dist/cli.js +235 -41
- package/dist/cli.js.map +1 -1
- package/dist/config.js +40 -2
- package/dist/config.js.map +1 -1
- package/dist/cost/pricing.js +5 -0
- package/dist/cost/pricing.js.map +1 -1
- package/dist/daemon/index.js +10 -3
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/launchd.js +65 -6
- package/dist/daemon/launchd.js.map +1 -1
- package/dist/diagnostics/doctor.js +151 -14
- package/dist/diagnostics/doctor.js.map +1 -1
- package/dist/pipeline/distiller.js +29 -0
- package/dist/pipeline/distiller.js.map +1 -1
- package/dist/pipeline/lock.js +57 -0
- package/dist/pipeline/lock.js.map +1 -0
- package/dist/pipeline/parser.js +11 -0
- package/dist/pipeline/parser.js.map +1 -1
- package/dist/pipeline/projects.js +189 -0
- package/dist/pipeline/projects.js.map +1 -0
- package/dist/pipeline/run.js +354 -23
- package/dist/pipeline/run.js.map +1 -1
- package/dist/search/embedder.js +13 -0
- package/dist/search/embedder.js.map +1 -1
- package/dist/search/retriever.js +35 -12
- package/dist/search/retriever.js.map +1 -1
- package/dist/state/db.js +128 -13
- package/dist/state/db.js.map +1 -1
- package/package.json +2 -1
package/dist/pipeline/run.js
CHANGED
|
@@ -3,7 +3,7 @@ import { appendFileSync } from "node:fs";
|
|
|
3
3
|
import { DAEMON_LOG_PATH, ensureVirDir } from "../config.js";
|
|
4
4
|
import { StateDb } from "../state/db.js";
|
|
5
5
|
import * as ui from "../ui/display.js";
|
|
6
|
-
import { Distiller, normalizeModelName, resolveModelShorthand, } from "./distiller.js";
|
|
6
|
+
import { Distiller, maybeAnthropicClient, normalizeModelName, probeProvider, resolveModelShorthand, } from "./distiller.js";
|
|
7
7
|
import { computeCost } from "../cost/pricing.js";
|
|
8
8
|
import { scoreSession } from "./filter.js";
|
|
9
9
|
import { parseSession } from "./parser.js";
|
|
@@ -12,6 +12,19 @@ import { scanArticles } from "./articleReader.js";
|
|
|
12
12
|
import { distillArticle } from "./articleDistiller.js";
|
|
13
13
|
import { parsePdf, scanPdfs } from "./pdfReader.js";
|
|
14
14
|
import { distillPdf } from "./pdfDistiller.js";
|
|
15
|
+
import { classifyTranscript, decideProject, groupByProject, estimateSessionCost, readTranscriptHead, sniffAgentEntrypoint, } from "./projects.js";
|
|
16
|
+
// A row that holds a successfully distilled note. Neither filter may
|
|
17
|
+
// overwrite one: flipping it to skipped=1 would silently hide the note from
|
|
18
|
+
// listDistilled/embeddings/rewrite — a semi-prune, and both filters are
|
|
19
|
+
// forward-looking only. (Excluding stops FUTURE distills; it never touches
|
|
20
|
+
// what already exists.)
|
|
21
|
+
function isDistilledRow(row) {
|
|
22
|
+
return (row !== undefined &&
|
|
23
|
+
row.skipped === 0 &&
|
|
24
|
+
row.error === null &&
|
|
25
|
+
row.content !== null &&
|
|
26
|
+
row.content !== "");
|
|
27
|
+
}
|
|
15
28
|
import { scrub } from "./scrubber.js";
|
|
16
29
|
import { summarizeProject } from "./summarizer.js";
|
|
17
30
|
import { filterToolCalls } from "./toolCallFilter.js";
|
|
@@ -37,6 +50,12 @@ export async function runPipeline(cfg, opts = {}) {
|
|
|
37
50
|
distilled: 0,
|
|
38
51
|
lowConfidence: 0,
|
|
39
52
|
errored: 0,
|
|
53
|
+
projectExcluded: 0,
|
|
54
|
+
projectPending: 0,
|
|
55
|
+
flagSkipped: 0,
|
|
56
|
+
workflowSkipped: 0,
|
|
57
|
+
sidechainSkipped: 0,
|
|
58
|
+
agentSkipped: 0,
|
|
40
59
|
rewritten: 0,
|
|
41
60
|
notesWritten: [],
|
|
42
61
|
articlesScanned: 0,
|
|
@@ -78,6 +97,20 @@ export async function runPipeline(cfg, opts = {}) {
|
|
|
78
97
|
if (opts.rewriteOnly) {
|
|
79
98
|
const rows = db.listDistilled();
|
|
80
99
|
fileLog(`rewrite-only: ${rows.length} distilled sessions in db`);
|
|
100
|
+
if (opts.dryRun) {
|
|
101
|
+
if (interactive) {
|
|
102
|
+
ui.blank();
|
|
103
|
+
ui.divider();
|
|
104
|
+
ui.summary({
|
|
105
|
+
"would rewrite": { value: rows.length, color: ui.info },
|
|
106
|
+
});
|
|
107
|
+
ui.divider();
|
|
108
|
+
ui.line(ui.dim(" dry run — no notes rewritten, no index regenerated"));
|
|
109
|
+
}
|
|
110
|
+
fileLog(`dry-run rewrite-only: would rewrite ${rows.length} notes`);
|
|
111
|
+
db.close();
|
|
112
|
+
return summary;
|
|
113
|
+
}
|
|
81
114
|
if (interactive) {
|
|
82
115
|
const sp = ui.spinner(`rewriting ${rows.length} notes`).start();
|
|
83
116
|
try {
|
|
@@ -234,16 +267,145 @@ export async function runPipeline(cfg, opts = {}) {
|
|
|
234
267
|
fileLog(`scanned ${discovered.length} jsonl files`);
|
|
235
268
|
if (interactive)
|
|
236
269
|
ui.blank();
|
|
270
|
+
// ── transcript-category filter (SCAN phase, upstream of projects) ────────
|
|
271
|
+
// Nested workflow/sidechain transcripts are agent-internal execution, not
|
|
272
|
+
// user knowledge — with the default "exclude" they're gated out before
|
|
273
|
+
// grouping, so they never count as project sessions, pending spend, or
|
|
274
|
+
// paid work. Rows are recorded (never silent) except under --dry-run,
|
|
275
|
+
// which must stay side-effect free.
|
|
276
|
+
let sessionsInScope = discovered;
|
|
277
|
+
if (cfg.workflowTranscripts !== "include") {
|
|
278
|
+
sessionsInScope = [];
|
|
279
|
+
for (const s of discovered) {
|
|
280
|
+
const cat = classifyTranscript(s.path, cfg.claudeProjectsDir);
|
|
281
|
+
if (cat === "session") {
|
|
282
|
+
sessionsInScope.push(s);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const reason = cat === "workflow" ? "workflow-transcript" : "sidechain-transcript";
|
|
286
|
+
if (cat === "workflow")
|
|
287
|
+
summary.workflowSkipped += 1;
|
|
288
|
+
else
|
|
289
|
+
summary.sidechainSkipped += 1;
|
|
290
|
+
if (!opts.dryRun) {
|
|
291
|
+
const existing = db.getByPath(s.path);
|
|
292
|
+
if (!isDistilledRow(existing) &&
|
|
293
|
+
(existing?.hash !== s.hash || existing?.skip_reason !== reason)) {
|
|
294
|
+
db.record({
|
|
295
|
+
path: s.path,
|
|
296
|
+
hash: s.hash,
|
|
297
|
+
skipped: true,
|
|
298
|
+
notePaths: [],
|
|
299
|
+
skipReason: reason,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const filtered = summary.workflowSkipped + summary.sidechainSkipped;
|
|
305
|
+
if (filtered > 0) {
|
|
306
|
+
const msg = `${filtered} agent-internal transcript(s) excluded (${summary.workflowSkipped} workflow, ${summary.sidechainSkipped} sidechain) — workflowTranscripts: exclude`;
|
|
307
|
+
if (interactive)
|
|
308
|
+
ui.line(ui.dim(` ${msg}`));
|
|
309
|
+
fileLog(msg);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// ── agent-transcript filter (SCAN phase, its own knob) ───────────────────
|
|
313
|
+
// Top-level SDK-launched harness agents (review/verify) — detected by the
|
|
314
|
+
// first user line's entrypoint starting with "sdk". Head-read only; a
|
|
315
|
+
// truncated or unreadable head falls through to the parser backstop below,
|
|
316
|
+
// still before any paid call.
|
|
317
|
+
if (cfg.agentTranscripts !== "include") {
|
|
318
|
+
const stillHuman = [];
|
|
319
|
+
for (const s of sessionsInScope) {
|
|
320
|
+
const entrypoint = sniffAgentEntrypoint(readTranscriptHead(s.path));
|
|
321
|
+
if (entrypoint === null) {
|
|
322
|
+
stillHuman.push(s);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
summary.agentSkipped += 1;
|
|
326
|
+
if (!opts.dryRun) {
|
|
327
|
+
const existing = db.getByPath(s.path);
|
|
328
|
+
if (!isDistilledRow(existing) &&
|
|
329
|
+
(existing?.hash !== s.hash ||
|
|
330
|
+
existing?.skip_reason !== "agent-transcript")) {
|
|
331
|
+
db.record({
|
|
332
|
+
path: s.path,
|
|
333
|
+
hash: s.hash,
|
|
334
|
+
skipped: true,
|
|
335
|
+
notePaths: [],
|
|
336
|
+
skipReason: "agent-transcript",
|
|
337
|
+
entrypoint,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
sessionsInScope = stillHuman;
|
|
343
|
+
if (summary.agentSkipped > 0) {
|
|
344
|
+
const msg = `${summary.agentSkipped} SDK-launched agent transcript(s) excluded — agentTranscripts: exclude`;
|
|
345
|
+
if (interactive)
|
|
346
|
+
ui.line(ui.dim(` ${msg}`));
|
|
347
|
+
fileLog(msg);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// ── project filter (SCAN phase — before any paid call) ───────────────────
|
|
351
|
+
// Decisions live in cfg.projects; absent = undecided, a real visible state.
|
|
352
|
+
// Filtering here is load-bearing: classify is a paid Haiku call per new
|
|
353
|
+
// session, so excluded/pending sessions must never reach the distiller.
|
|
354
|
+
const projectFlags = {
|
|
355
|
+
only: opts.onlyProjects,
|
|
356
|
+
excludeProject: opts.excludeProjects,
|
|
357
|
+
};
|
|
358
|
+
const projectGroups = groupByProject(sessionsInScope, cfg.claudeProjectsDir);
|
|
359
|
+
const projectOf = new Map();
|
|
360
|
+
for (const group of projectGroups.values()) {
|
|
361
|
+
for (const s of group.sessions)
|
|
362
|
+
projectOf.set(s.path, group.name);
|
|
363
|
+
}
|
|
364
|
+
let projectDecisions = {
|
|
365
|
+
...cfg.projects,
|
|
366
|
+
};
|
|
367
|
+
const decisionFor = (path) => decideProject(projectOf.get(path) ?? "", projectDecisions, projectFlags);
|
|
368
|
+
// Undecided projects holding NEW sessions are a spend decision with a
|
|
369
|
+
// deadline (Claude Code prunes transcripts at ~30 days). Interactive
|
|
370
|
+
// callers inject onUndecidedProjects and get asked once; the daemon path
|
|
371
|
+
// records pending rows and notifies instead — it must never prompt.
|
|
372
|
+
const classifyModelId = normalizeModelName(cfg.models.classify, cfg.provider);
|
|
373
|
+
const distillModelId = normalizeModelName(resolveModelShorthand(opts.forceDistillModel ?? cfg.models.distill), cfg.provider);
|
|
374
|
+
const pendingProjects = [];
|
|
375
|
+
for (const group of projectGroups.values()) {
|
|
376
|
+
if (decideProject(group.name, projectDecisions, projectFlags) !== "pending")
|
|
377
|
+
continue;
|
|
378
|
+
const fresh = group.sessions.filter((s) => opts.full === true || !db.isProcessed(s.path, s.hash));
|
|
379
|
+
if (fresh.length === 0)
|
|
380
|
+
continue;
|
|
381
|
+
const totalBytes = fresh.reduce((sum, s) => sum + s.size, 0);
|
|
382
|
+
pendingProjects.push({
|
|
383
|
+
name: group.name,
|
|
384
|
+
sessionCount: fresh.length,
|
|
385
|
+
totalBytes,
|
|
386
|
+
estCost: fresh.reduce((sum, s) => sum +
|
|
387
|
+
estimateSessionCost(cfg.provider, classifyModelId, distillModelId, s.size, cfg.pricing, cfg.kieTopUpTier), 0),
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
if (pendingProjects.length > 0 && opts.onUndecidedProjects && !opts.dryRun) {
|
|
391
|
+
const answers = await opts.onUndecidedProjects(pendingProjects.sort((a, b) => b.estCost - a.estCost));
|
|
392
|
+
projectDecisions = { ...projectDecisions, ...answers };
|
|
393
|
+
}
|
|
237
394
|
// Precompute how many sessions actually need LLM work so the CLI can show
|
|
238
395
|
// an accurate cost confirmation before we hit the API. Also surfaces the
|
|
239
396
|
// found/cached/new breakdown so a fresh DB never silently looks like a
|
|
240
397
|
// stale-cache no-op (the symptom of the state.db → vir.db rename bug).
|
|
241
398
|
let preflightNew = 0;
|
|
242
|
-
|
|
399
|
+
let preflightFiltered = 0;
|
|
400
|
+
for (const found of sessionsInScope) {
|
|
401
|
+
if (decisionFor(found.path) !== "include") {
|
|
402
|
+
preflightFiltered += 1;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
243
405
|
if (opts.full || !db.isProcessed(found.path, found.hash))
|
|
244
406
|
preflightNew += 1;
|
|
245
407
|
}
|
|
246
|
-
const cached =
|
|
408
|
+
const cached = sessionsInScope.length - preflightNew - preflightFiltered;
|
|
247
409
|
// Notes distilled but never embedded (write-time Ollama outage) — surfaced so
|
|
248
410
|
// a retrieval blind spot is visible, not silent. Counts all three embeddable
|
|
249
411
|
// layers (sessions + topics + articles) so the preflight matches exactly what
|
|
@@ -254,12 +416,23 @@ export async function runPipeline(cfg, opts = {}) {
|
|
|
254
416
|
db.listPdfEmbeddingTargets().length;
|
|
255
417
|
if (interactive) {
|
|
256
418
|
ui.line(ui.dim(` ${discovered.length} files found · ${cached} cached · ${preflightNew} new` +
|
|
419
|
+
(preflightFiltered > 0
|
|
420
|
+
? ` · ${preflightFiltered} project-filtered`
|
|
421
|
+
: "") +
|
|
257
422
|
(pendingEmbedding > 0
|
|
258
423
|
? ` · ${pendingEmbedding} pending embedding`
|
|
259
424
|
: "")));
|
|
260
425
|
ui.blank();
|
|
261
426
|
}
|
|
262
|
-
fileLog(`preflight: found=${discovered.length} cached=${cached} new=${preflightNew} pendingEmbedding=${pendingEmbedding}`);
|
|
427
|
+
fileLog(`preflight: found=${discovered.length} cached=${cached} new=${preflightNew} projectFiltered=${preflightFiltered} pendingEmbedding=${pendingEmbedding}`);
|
|
428
|
+
// 0.14.0 changed the default provider to anthropic (claude-sonnet-5). A kie
|
|
429
|
+
// config keeps working exactly as configured — someone's config is not ours
|
|
430
|
+
// to change — but surface the default change once per interactive run so
|
|
431
|
+
// users who only ever accepted the old init default know the ground moved.
|
|
432
|
+
if (interactive && cfg.provider === "kie") {
|
|
433
|
+
ui.line(ui.dim(" Note: the default provider is now anthropic (claude-sonnet-5). Your 'kie' setting is unchanged — run vir init to switch."));
|
|
434
|
+
ui.blank();
|
|
435
|
+
}
|
|
263
436
|
// Nudge session-only installs toward hybrid routing. interactive is already
|
|
264
437
|
// false under --quiet/--daemon, so this never prints on the daemon path.
|
|
265
438
|
if (interactive && !cfg.models.distillFast) {
|
|
@@ -273,15 +446,28 @@ export async function runPipeline(cfg, opts = {}) {
|
|
|
273
446
|
// the estimate lands in the right ballpark instead of ~5x low. Still rough —
|
|
274
447
|
// deep sessions vary, and low-confidence drops aren't knowable without the LLM.
|
|
275
448
|
if (opts.dryRun) {
|
|
276
|
-
const classifyModel =
|
|
277
|
-
const distillModel =
|
|
449
|
+
const classifyModel = classifyModelId;
|
|
450
|
+
const distillModel = distillModelId;
|
|
278
451
|
const CLASSIFY_OUTPUT_TOKENS = 350;
|
|
279
452
|
const DISTILL_OUTPUT_TOKENS = 4500;
|
|
280
453
|
const CHARS_PER_TOKEN = 3;
|
|
281
454
|
let totalCost = 0;
|
|
282
455
|
let estimated = 0;
|
|
283
456
|
let filteredOut = 0;
|
|
284
|
-
|
|
457
|
+
let dryExcluded = 0;
|
|
458
|
+
let dryPending = 0;
|
|
459
|
+
for (const found of sessionsInScope) {
|
|
460
|
+
// Project-filtered sessions are outside the estimate: they will not be
|
|
461
|
+
// distilled as things stand. Counted separately so they're never a
|
|
462
|
+
// silent omission from the preview.
|
|
463
|
+
const decision = decisionFor(found.path);
|
|
464
|
+
if (decision !== "include") {
|
|
465
|
+
if (decision === "exclude")
|
|
466
|
+
dryExcluded += 1;
|
|
467
|
+
else if (decision === "pending")
|
|
468
|
+
dryPending += 1;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
285
471
|
if (!opts.full && db.isProcessed(found.path, found.hash))
|
|
286
472
|
continue;
|
|
287
473
|
let parsed;
|
|
@@ -310,12 +496,31 @@ export async function runPipeline(cfg, opts = {}) {
|
|
|
310
496
|
if (interactive) {
|
|
311
497
|
ui.blank();
|
|
312
498
|
ui.divider();
|
|
313
|
-
|
|
499
|
+
const dryStats = {
|
|
314
500
|
sessions: { value: estimated, color: ui.info },
|
|
315
501
|
"filtered out": { value: filteredOut, color: ui.dim },
|
|
316
|
-
|
|
317
|
-
|
|
502
|
+
};
|
|
503
|
+
if (dryExcluded > 0) {
|
|
504
|
+
dryStats.excluded = { value: dryExcluded, color: ui.dim };
|
|
505
|
+
}
|
|
506
|
+
if (dryPending > 0) {
|
|
507
|
+
dryStats.undecided = { value: dryPending, color: ui.warn };
|
|
508
|
+
}
|
|
509
|
+
if (summary.workflowSkipped + summary.sidechainSkipped > 0) {
|
|
510
|
+
dryStats.workflow = {
|
|
511
|
+
value: summary.workflowSkipped + summary.sidechainSkipped,
|
|
512
|
+
color: ui.dim,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
if (summary.agentSkipped > 0) {
|
|
516
|
+
dryStats.agent = { value: summary.agentSkipped, color: ui.dim };
|
|
517
|
+
}
|
|
518
|
+
dryStats["est. total"] = { value: ui.formatUsd(totalCost), color: ui.warn };
|
|
519
|
+
ui.summary(dryStats);
|
|
318
520
|
ui.divider();
|
|
521
|
+
if (dryPending > 0) {
|
|
522
|
+
ui.line(ui.dim(` ${dryPending} session(s) in undecided projects — run vir projects to include/exclude them`));
|
|
523
|
+
}
|
|
319
524
|
ui.line(ui.dim(" estimates assume typical output sizes; actuals may vary ±30%"));
|
|
320
525
|
}
|
|
321
526
|
// PDFs are estimated separately: papers exceed the 24k-char distill cap, so
|
|
@@ -331,7 +536,7 @@ export async function runPipeline(cfg, opts = {}) {
|
|
|
331
536
|
fileLog(`dry-run: newPdfs=${newPdfs} perPdf=${ui.formatUsd(perPdf)} estTotal=${ui.formatUsd(perPdf * newPdfs)}`);
|
|
332
537
|
}
|
|
333
538
|
}
|
|
334
|
-
fileLog(`dry-run: sessions=${estimated} filtered=${filteredOut} estTotal=${ui.formatUsd(totalCost)}`);
|
|
539
|
+
fileLog(`dry-run: sessions=${estimated} filtered=${filteredOut} excluded=${dryExcluded} pending=${dryPending} estTotal=${ui.formatUsd(totalCost)}`);
|
|
335
540
|
db.close();
|
|
336
541
|
return summary;
|
|
337
542
|
}
|
|
@@ -343,13 +548,105 @@ export async function runPipeline(cfg, opts = {}) {
|
|
|
343
548
|
return summary;
|
|
344
549
|
}
|
|
345
550
|
}
|
|
346
|
-
|
|
551
|
+
// One cheap probe before the loop: a provider-wide outage should be ONE
|
|
552
|
+
// clear failure, not N sessions each burning full retry chains and
|
|
553
|
+
// attempt-counter increments. Skipped when there is nothing to distill.
|
|
554
|
+
if (preflightNew > 0) {
|
|
347
555
|
try {
|
|
556
|
+
await probeProvider(cfg, maybeAnthropicClient(cfg));
|
|
557
|
+
}
|
|
558
|
+
catch (err) {
|
|
559
|
+
const msg = err.message ?? String(err);
|
|
560
|
+
fileLog(`provider preflight failed: ${msg}`);
|
|
561
|
+
if (interactive) {
|
|
562
|
+
ui.row(ui.errorColor(ui.CROSS), ui.text(`provider ${cfg.provider} unreachable — ${msg}`));
|
|
563
|
+
}
|
|
564
|
+
db.close();
|
|
565
|
+
throw new Error(`provider ${cfg.provider} unreachable (preflight probe failed): ${msg}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
for (const found of sessionsInScope) {
|
|
569
|
+
try {
|
|
570
|
+
// Project filter FIRST — before the cache check and long before any
|
|
571
|
+
// paid call. Every config-driven skip gets its DB row and reason;
|
|
572
|
+
// one-off flag skips record nothing (they say nothing about state).
|
|
573
|
+
const decision = decisionFor(found.path);
|
|
574
|
+
if (decision === "flag-skip") {
|
|
575
|
+
summary.flagSkipped += 1;
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (decision === "exclude" || decision === "pending") {
|
|
579
|
+
const reason = decision === "exclude" ? "project-excluded" : "project-pending";
|
|
580
|
+
if (decision === "exclude")
|
|
581
|
+
summary.projectExcluded += 1;
|
|
582
|
+
else
|
|
583
|
+
summary.projectPending += 1;
|
|
584
|
+
const existing = db.getByPath(found.path);
|
|
585
|
+
if (!isDistilledRow(existing) &&
|
|
586
|
+
(existing?.hash !== found.hash || existing?.skip_reason !== reason)) {
|
|
587
|
+
db.record({
|
|
588
|
+
path: found.path,
|
|
589
|
+
hash: found.hash,
|
|
590
|
+
skipped: true,
|
|
591
|
+
notePaths: [],
|
|
592
|
+
skipReason: reason,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
348
597
|
if (!opts.full && db.isProcessed(found.path, found.hash)) {
|
|
349
598
|
summary.alreadyProcessed += 1;
|
|
350
599
|
continue;
|
|
351
600
|
}
|
|
601
|
+
// Retry bound: MAX_DISTILL_ATTEMPTS consecutive failures park the
|
|
602
|
+
// session — even under --full — so the daemon can't burn money on a
|
|
603
|
+
// persistently failing transcript. `vir reconcile --force` is the only
|
|
604
|
+
// way back in (it resets the counter on success).
|
|
605
|
+
if (db.retryExhausted(found.path)) {
|
|
606
|
+
summary.alreadyProcessed += 1;
|
|
607
|
+
fileLog(`retry-exhausted, skipping: ${found.path}`);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
352
610
|
const parsed = parseSession(found.path, found.hash);
|
|
611
|
+
// Parser backstop for the transcript-category filter: a sidechain by
|
|
612
|
+
// CONTENT (isSidechain in the JSONL) that structural detection missed
|
|
613
|
+
// — e.g. a future layout change — still stops before any paid call.
|
|
614
|
+
if (cfg.workflowTranscripts !== "include" && parsed.isSidechain) {
|
|
615
|
+
summary.sidechainSkipped += 1;
|
|
616
|
+
// Same no-overwrite rule as the structural gate: a changed transcript
|
|
617
|
+
// whose row already holds a distilled note keeps that note visible.
|
|
618
|
+
if (!isDistilledRow(db.getByPath(found.path))) {
|
|
619
|
+
db.record({
|
|
620
|
+
path: found.path,
|
|
621
|
+
hash: found.hash,
|
|
622
|
+
skipped: true,
|
|
623
|
+
notePaths: [],
|
|
624
|
+
skipReason: "sidechain-transcript",
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
fileLog(`sidechain by content, skipping: ${found.path}`);
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
// Agent-transcript backstop: the SDK launch signature seen by the full
|
|
631
|
+
// parse when the scan-time head sniff missed it (truncated head, field
|
|
632
|
+
// moved). Still pre-classify, so still free.
|
|
633
|
+
if (cfg.agentTranscripts !== "include" &&
|
|
634
|
+
typeof parsed.entrypoint === "string" &&
|
|
635
|
+
parsed.entrypoint.startsWith("sdk")) {
|
|
636
|
+
summary.agentSkipped += 1;
|
|
637
|
+
if (!isDistilledRow(db.getByPath(found.path))) {
|
|
638
|
+
db.record({
|
|
639
|
+
path: found.path,
|
|
640
|
+
hash: found.hash,
|
|
641
|
+
skipped: true,
|
|
642
|
+
notePaths: [],
|
|
643
|
+
skipReason: "agent-transcript",
|
|
644
|
+
entrypoint: parsed.entrypoint,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
fileLog(`sdk agent by content, skipping: ${found.path}`);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
353
650
|
const filter = scoreSession(parsed, cfg.filterThreshold);
|
|
354
651
|
if (!filter.passes) {
|
|
355
652
|
summary.skippedByFilter += 1;
|
|
@@ -398,12 +695,13 @@ export async function runPipeline(cfg, opts = {}) {
|
|
|
398
695
|
project: note.classification.project,
|
|
399
696
|
confidence: note.classification.confidence,
|
|
400
697
|
startedAt: parsed.startedAt,
|
|
698
|
+
entrypoint: parsed.entrypoint,
|
|
401
699
|
});
|
|
402
700
|
if (interactive) {
|
|
403
701
|
ui.categoryRow(note.classification.category, note.classification.topic);
|
|
404
702
|
}
|
|
405
703
|
fileLog(`distilled ${parsed.sessionId.slice(0, 8)} → ${note.classification.category}/${note.classification.topic}`);
|
|
406
|
-
if (note.classification.confidence >= 0.8) {
|
|
704
|
+
if (note.classification.confidence >= 0.8 && cfg.notifications !== false) {
|
|
407
705
|
notify(`Vir — new ${note.classification.category}`, `${note.classification.topic} · ${note.classification.project}`);
|
|
408
706
|
}
|
|
409
707
|
const slug = kebab(note.classification.project);
|
|
@@ -419,13 +717,9 @@ export async function runPipeline(cfg, opts = {}) {
|
|
|
419
717
|
ui.row(ui.errorColor(ui.CROSS), ui.text(`error: ${msg}`));
|
|
420
718
|
fileLog(`error on ${found.path}: ${msg}`);
|
|
421
719
|
try {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
skipped: false,
|
|
426
|
-
notePaths: [],
|
|
427
|
-
error: msg,
|
|
428
|
-
});
|
|
720
|
+
// recordError, not record: writing the hash on a failed attempt would
|
|
721
|
+
// mark the transcript processed and hide it from every future run.
|
|
722
|
+
db.recordError(found.path, found.hash, msg);
|
|
429
723
|
}
|
|
430
724
|
catch {
|
|
431
725
|
// ignore record errors
|
|
@@ -450,6 +744,24 @@ export async function runPipeline(cfg, opts = {}) {
|
|
|
450
744
|
fileLog(`summary failed for project/${slug}: ${msg}`);
|
|
451
745
|
}
|
|
452
746
|
}
|
|
747
|
+
// Surface undecided projects — one log line and (config-gated) one macOS
|
|
748
|
+
// notification. Never a prompt: this is the daemon-safe path. Recomputed
|
|
749
|
+
// post-loop so interactively answered projects don't re-notify.
|
|
750
|
+
if (summary.projectPending > 0) {
|
|
751
|
+
const stillPending = new Set();
|
|
752
|
+
for (const group of projectGroups.values()) {
|
|
753
|
+
if (decideProject(group.name, projectDecisions, projectFlags) === "pending") {
|
|
754
|
+
stillPending.add(group.name);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
const msg = `${summary.projectPending} session(s) across ${stillPending.size} project(s) awaiting include/exclude decision — run vir projects`;
|
|
758
|
+
fileLog(msg);
|
|
759
|
+
if (interactive)
|
|
760
|
+
ui.line(ui.dim(` ${msg}`));
|
|
761
|
+
if (cfg.notifications !== false && process.platform === "darwin") {
|
|
762
|
+
notify("vir — projects awaiting decision", `${stillPending.size} project(s), ${summary.projectPending} session(s) pending — run vir projects`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
453
765
|
// Second input source: web articles. Gated on config; a session-only install
|
|
454
766
|
// (no articlesDir) skips this entirely and behaves exactly as before.
|
|
455
767
|
if (cfg.articlesDir && cfg.distillArticles) {
|
|
@@ -464,7 +776,7 @@ export async function runPipeline(cfg, opts = {}) {
|
|
|
464
776
|
// (Ollama down during distill). Without this a transient outage is permanent
|
|
465
777
|
// — the note never enters the embedding-search candidate set.
|
|
466
778
|
await runEmbeddingSweep(db, writer, fileLog, interactive);
|
|
467
|
-
fileLog(`vir run done — scanned=${summary.scanned} new=${summary.scanned - summary.alreadyProcessed} distilled=${summary.distilled} skipped=${summary.skippedByFilter} lowConf=${summary.lowConfidence} errored=${summary.errored} articles=${summary.articlesDistilled} pdfs=${summary.pdfsDistilled}`);
|
|
779
|
+
fileLog(`vir run done — scanned=${summary.scanned} new=${summary.scanned - summary.alreadyProcessed} distilled=${summary.distilled} skipped=${summary.skippedByFilter} lowConf=${summary.lowConfidence} errored=${summary.errored} projectExcluded=${summary.projectExcluded} projectPending=${summary.projectPending} flagSkipped=${summary.flagSkipped} workflowSkipped=${summary.workflowSkipped} sidechainSkipped=${summary.sidechainSkipped} agentSkipped=${summary.agentSkipped} articles=${summary.articlesDistilled} pdfs=${summary.pdfsDistilled}`);
|
|
468
780
|
if (interactive) {
|
|
469
781
|
ui.blank();
|
|
470
782
|
ui.divider();
|
|
@@ -481,6 +793,21 @@ export async function runPipeline(cfg, opts = {}) {
|
|
|
481
793
|
color: summary.errored > 0 ? ui.errorColor : ui.dim,
|
|
482
794
|
},
|
|
483
795
|
};
|
|
796
|
+
if (summary.projectExcluded > 0) {
|
|
797
|
+
stats.excluded = { value: summary.projectExcluded, color: ui.dim };
|
|
798
|
+
}
|
|
799
|
+
if (summary.projectPending > 0) {
|
|
800
|
+
stats.undecided = { value: summary.projectPending, color: ui.warn };
|
|
801
|
+
}
|
|
802
|
+
if (summary.workflowSkipped + summary.sidechainSkipped > 0) {
|
|
803
|
+
stats.workflow = {
|
|
804
|
+
value: summary.workflowSkipped + summary.sidechainSkipped,
|
|
805
|
+
color: ui.dim,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
if (summary.agentSkipped > 0) {
|
|
809
|
+
stats.agent = { value: summary.agentSkipped, color: ui.dim };
|
|
810
|
+
}
|
|
484
811
|
if (cfg.articlesDir && cfg.distillArticles) {
|
|
485
812
|
stats.articles = { value: summary.articlesDistilled, color: ui.success };
|
|
486
813
|
}
|
|
@@ -611,7 +938,8 @@ async function runArticlePhase(cfg, db, writer, summary, fileLog, interactive) {
|
|
|
611
938
|
ui.categoryRow(distilled.classification.category, article.title);
|
|
612
939
|
}
|
|
613
940
|
fileLog(`distilled article → ${distilled.classification.category}/${article.title}`);
|
|
614
|
-
if (distilled.classification.confidence >= 0.8
|
|
941
|
+
if (distilled.classification.confidence >= 0.8 &&
|
|
942
|
+
cfg.notifications !== false) {
|
|
615
943
|
notify(`Vir — new ${distilled.classification.category}`, article.title);
|
|
616
944
|
}
|
|
617
945
|
await new Promise((r) => setTimeout(r, 2000));
|
|
@@ -696,7 +1024,8 @@ async function runPdfPhase(cfg, db, writer, summary, fileLog, interactive) {
|
|
|
696
1024
|
ui.categoryRow(distilled.classification.category, parsed.title);
|
|
697
1025
|
}
|
|
698
1026
|
fileLog(`distilled pdf → ${distilled.classification.category}/${parsed.title}`);
|
|
699
|
-
if (distilled.classification.confidence >= 0.8
|
|
1027
|
+
if (distilled.classification.confidence >= 0.8 &&
|
|
1028
|
+
cfg.notifications !== false) {
|
|
700
1029
|
notify(`Vir — new ${distilled.classification.category}`, parsed.title);
|
|
701
1030
|
}
|
|
702
1031
|
await new Promise((r) => setTimeout(r, 2000));
|
|
@@ -739,6 +1068,8 @@ async function rewriteOne(writer, row) {
|
|
|
739
1068
|
userText: "",
|
|
740
1069
|
rawSummary: "",
|
|
741
1070
|
transcriptText: "",
|
|
1071
|
+
isSidechain: false,
|
|
1072
|
+
entrypoint: null,
|
|
742
1073
|
};
|
|
743
1074
|
const note = {
|
|
744
1075
|
classification: {
|