@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.
@@ -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
- for (const found of discovered) {
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 = discovered.length - preflightNew;
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 = normalizeModelName(cfg.models.classify, cfg.provider);
277
- const distillModel = normalizeModelName(resolveModelShorthand(opts.forceDistillModel ?? cfg.models.distill), cfg.provider);
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
- for (const found of discovered) {
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
- ui.summary({
499
+ const dryStats = {
314
500
  sessions: { value: estimated, color: ui.info },
315
501
  "filtered out": { value: filteredOut, color: ui.dim },
316
- "est. total": { value: ui.formatUsd(totalCost), color: ui.warn },
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
- for (const found of discovered) {
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
- db.record({
423
- path: found.path,
424
- hash: found.hash,
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: {