@djolex999/vir-cli 0.13.0 → 0.14.1

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";
@@ -26,6 +39,19 @@ export function estimatePerDocDistillCost(cfg, classifyModel, distillModel) {
26
39
  return (computeCost(cfg.provider, classifyModel, Math.ceil(3000 / CPT), 200, cfg.pricing, cfg.kieTopUpTier) +
27
40
  computeCost(cfg.provider, distillModel, Math.ceil(24_000 / CPT), 1500, cfg.pricing, cfg.kieTopUpTier));
28
41
  }
42
+ // The run log is the audit trail for spend, and spend happens in every mode —
43
+ // so this is UNGATED: interactive runs log exactly like daemon runs. (It was
44
+ // once gated behind a daemon-only flag; an interactive run that distilled 6
45
+ // sessions left no trace and produced two wrong diagnoses.) Best-effort:
46
+ // logging must never fail a run.
47
+ export function appendRunLog(msg, logPath = DAEMON_LOG_PATH) {
48
+ try {
49
+ appendFileSync(logPath, `[${new Date().toISOString()}] ${msg}\n`);
50
+ }
51
+ catch {
52
+ // ignore log errors
53
+ }
54
+ }
29
55
  export async function runPipeline(cfg, opts = {}) {
30
56
  ensureVirDir();
31
57
  const db = new StateDb();
@@ -37,6 +63,12 @@ export async function runPipeline(cfg, opts = {}) {
37
63
  distilled: 0,
38
64
  lowConfidence: 0,
39
65
  errored: 0,
66
+ projectExcluded: 0,
67
+ projectPending: 0,
68
+ flagSkipped: 0,
69
+ workflowSkipped: 0,
70
+ sidechainSkipped: 0,
71
+ agentSkipped: 0,
40
72
  rewritten: 0,
41
73
  notesWritten: [],
42
74
  articlesScanned: 0,
@@ -49,17 +81,7 @@ export async function runPipeline(cfg, opts = {}) {
49
81
  pdfsErrored: 0,
50
82
  };
51
83
  const interactive = !opts.quiet;
52
- // File-only logging — used for the daemon run.log regardless of UI mode.
53
- const fileLog = (msg) => {
54
- if (!opts.logToFile)
55
- return;
56
- try {
57
- appendFileSync(DAEMON_LOG_PATH, `[${new Date().toISOString()}] ${msg}\n`);
58
- }
59
- catch {
60
- // ignore log errors
61
- }
62
- };
84
+ const fileLog = (msg) => appendRunLog(msg);
63
85
  if (interactive) {
64
86
  ui.header(opts.dryRun
65
87
  ? "run --dry-run"
@@ -248,16 +270,145 @@ export async function runPipeline(cfg, opts = {}) {
248
270
  fileLog(`scanned ${discovered.length} jsonl files`);
249
271
  if (interactive)
250
272
  ui.blank();
273
+ // ── transcript-category filter (SCAN phase, upstream of projects) ────────
274
+ // Nested workflow/sidechain transcripts are agent-internal execution, not
275
+ // user knowledge — with the default "exclude" they're gated out before
276
+ // grouping, so they never count as project sessions, pending spend, or
277
+ // paid work. Rows are recorded (never silent) except under --dry-run,
278
+ // which must stay side-effect free.
279
+ let sessionsInScope = discovered;
280
+ if (cfg.workflowTranscripts !== "include") {
281
+ sessionsInScope = [];
282
+ for (const s of discovered) {
283
+ const cat = classifyTranscript(s.path, cfg.claudeProjectsDir);
284
+ if (cat === "session") {
285
+ sessionsInScope.push(s);
286
+ continue;
287
+ }
288
+ const reason = cat === "workflow" ? "workflow-transcript" : "sidechain-transcript";
289
+ if (cat === "workflow")
290
+ summary.workflowSkipped += 1;
291
+ else
292
+ summary.sidechainSkipped += 1;
293
+ if (!opts.dryRun) {
294
+ const existing = db.getByPath(s.path);
295
+ if (!isDistilledRow(existing) &&
296
+ (existing?.hash !== s.hash || existing?.skip_reason !== reason)) {
297
+ db.record({
298
+ path: s.path,
299
+ hash: s.hash,
300
+ skipped: true,
301
+ notePaths: [],
302
+ skipReason: reason,
303
+ });
304
+ }
305
+ }
306
+ }
307
+ const filtered = summary.workflowSkipped + summary.sidechainSkipped;
308
+ if (filtered > 0) {
309
+ const msg = `${filtered} agent-internal transcript(s) excluded (${summary.workflowSkipped} workflow, ${summary.sidechainSkipped} sidechain) — workflowTranscripts: exclude`;
310
+ if (interactive)
311
+ ui.line(ui.dim(` ${msg}`));
312
+ fileLog(msg);
313
+ }
314
+ }
315
+ // ── agent-transcript filter (SCAN phase, its own knob) ───────────────────
316
+ // Top-level SDK-launched harness agents (review/verify) — detected by the
317
+ // first user line's entrypoint starting with "sdk". Head-read only; a
318
+ // truncated or unreadable head falls through to the parser backstop below,
319
+ // still before any paid call.
320
+ if (cfg.agentTranscripts !== "include") {
321
+ const stillHuman = [];
322
+ for (const s of sessionsInScope) {
323
+ const entrypoint = sniffAgentEntrypoint(readTranscriptHead(s.path));
324
+ if (entrypoint === null) {
325
+ stillHuman.push(s);
326
+ continue;
327
+ }
328
+ summary.agentSkipped += 1;
329
+ if (!opts.dryRun) {
330
+ const existing = db.getByPath(s.path);
331
+ if (!isDistilledRow(existing) &&
332
+ (existing?.hash !== s.hash ||
333
+ existing?.skip_reason !== "agent-transcript")) {
334
+ db.record({
335
+ path: s.path,
336
+ hash: s.hash,
337
+ skipped: true,
338
+ notePaths: [],
339
+ skipReason: "agent-transcript",
340
+ entrypoint,
341
+ });
342
+ }
343
+ }
344
+ }
345
+ sessionsInScope = stillHuman;
346
+ if (summary.agentSkipped > 0) {
347
+ const msg = `${summary.agentSkipped} SDK-launched agent transcript(s) excluded — agentTranscripts: exclude`;
348
+ if (interactive)
349
+ ui.line(ui.dim(` ${msg}`));
350
+ fileLog(msg);
351
+ }
352
+ }
353
+ // ── project filter (SCAN phase — before any paid call) ───────────────────
354
+ // Decisions live in cfg.projects; absent = undecided, a real visible state.
355
+ // Filtering here is load-bearing: classify is a paid Haiku call per new
356
+ // session, so excluded/pending sessions must never reach the distiller.
357
+ const projectFlags = {
358
+ only: opts.onlyProjects,
359
+ excludeProject: opts.excludeProjects,
360
+ };
361
+ const projectGroups = groupByProject(sessionsInScope, cfg.claudeProjectsDir);
362
+ const projectOf = new Map();
363
+ for (const group of projectGroups.values()) {
364
+ for (const s of group.sessions)
365
+ projectOf.set(s.path, group.name);
366
+ }
367
+ let projectDecisions = {
368
+ ...cfg.projects,
369
+ };
370
+ const decisionFor = (path) => decideProject(projectOf.get(path) ?? "", projectDecisions, projectFlags);
371
+ // Undecided projects holding NEW sessions are a spend decision with a
372
+ // deadline (Claude Code prunes transcripts at ~30 days). Interactive
373
+ // callers inject onUndecidedProjects and get asked once; the daemon path
374
+ // records pending rows and notifies instead — it must never prompt.
375
+ const classifyModelId = normalizeModelName(cfg.models.classify, cfg.provider);
376
+ const distillModelId = normalizeModelName(resolveModelShorthand(opts.forceDistillModel ?? cfg.models.distill), cfg.provider);
377
+ const pendingProjects = [];
378
+ for (const group of projectGroups.values()) {
379
+ if (decideProject(group.name, projectDecisions, projectFlags) !== "pending")
380
+ continue;
381
+ const fresh = group.sessions.filter((s) => opts.full === true || !db.isProcessed(s.path, s.hash));
382
+ if (fresh.length === 0)
383
+ continue;
384
+ const totalBytes = fresh.reduce((sum, s) => sum + s.size, 0);
385
+ pendingProjects.push({
386
+ name: group.name,
387
+ sessionCount: fresh.length,
388
+ totalBytes,
389
+ estCost: fresh.reduce((sum, s) => sum +
390
+ estimateSessionCost(cfg.provider, classifyModelId, distillModelId, s.size, cfg.pricing, cfg.kieTopUpTier), 0),
391
+ });
392
+ }
393
+ if (pendingProjects.length > 0 && opts.onUndecidedProjects && !opts.dryRun) {
394
+ const answers = await opts.onUndecidedProjects(pendingProjects.sort((a, b) => b.estCost - a.estCost));
395
+ projectDecisions = { ...projectDecisions, ...answers };
396
+ }
251
397
  // Precompute how many sessions actually need LLM work so the CLI can show
252
398
  // an accurate cost confirmation before we hit the API. Also surfaces the
253
399
  // found/cached/new breakdown so a fresh DB never silently looks like a
254
400
  // stale-cache no-op (the symptom of the state.db → vir.db rename bug).
255
401
  let preflightNew = 0;
256
- for (const found of discovered) {
402
+ let preflightFiltered = 0;
403
+ for (const found of sessionsInScope) {
404
+ if (decisionFor(found.path) !== "include") {
405
+ preflightFiltered += 1;
406
+ continue;
407
+ }
257
408
  if (opts.full || !db.isProcessed(found.path, found.hash))
258
409
  preflightNew += 1;
259
410
  }
260
- const cached = discovered.length - preflightNew;
411
+ const cached = sessionsInScope.length - preflightNew - preflightFiltered;
261
412
  // Notes distilled but never embedded (write-time Ollama outage) — surfaced so
262
413
  // a retrieval blind spot is visible, not silent. Counts all three embeddable
263
414
  // layers (sessions + topics + articles) so the preflight matches exactly what
@@ -268,12 +419,23 @@ export async function runPipeline(cfg, opts = {}) {
268
419
  db.listPdfEmbeddingTargets().length;
269
420
  if (interactive) {
270
421
  ui.line(ui.dim(` ${discovered.length} files found · ${cached} cached · ${preflightNew} new` +
422
+ (preflightFiltered > 0
423
+ ? ` · ${preflightFiltered} project-filtered`
424
+ : "") +
271
425
  (pendingEmbedding > 0
272
426
  ? ` · ${pendingEmbedding} pending embedding`
273
427
  : "")));
274
428
  ui.blank();
275
429
  }
276
- fileLog(`preflight: found=${discovered.length} cached=${cached} new=${preflightNew} pendingEmbedding=${pendingEmbedding}`);
430
+ fileLog(`preflight: found=${discovered.length} cached=${cached} new=${preflightNew} projectFiltered=${preflightFiltered} pendingEmbedding=${pendingEmbedding}`);
431
+ // 0.14.0 changed the default provider to anthropic (claude-sonnet-5). A kie
432
+ // config keeps working exactly as configured — someone's config is not ours
433
+ // to change — but surface the default change once per interactive run so
434
+ // users who only ever accepted the old init default know the ground moved.
435
+ if (interactive && cfg.provider === "kie") {
436
+ ui.line(ui.dim(" Note: the default provider is now anthropic (claude-sonnet-5). Your 'kie' setting is unchanged — run vir init to switch."));
437
+ ui.blank();
438
+ }
277
439
  // Nudge session-only installs toward hybrid routing. interactive is already
278
440
  // false under --quiet/--daemon, so this never prints on the daemon path.
279
441
  if (interactive && !cfg.models.distillFast) {
@@ -287,15 +449,28 @@ export async function runPipeline(cfg, opts = {}) {
287
449
  // the estimate lands in the right ballpark instead of ~5x low. Still rough —
288
450
  // deep sessions vary, and low-confidence drops aren't knowable without the LLM.
289
451
  if (opts.dryRun) {
290
- const classifyModel = normalizeModelName(cfg.models.classify, cfg.provider);
291
- const distillModel = normalizeModelName(resolveModelShorthand(opts.forceDistillModel ?? cfg.models.distill), cfg.provider);
452
+ const classifyModel = classifyModelId;
453
+ const distillModel = distillModelId;
292
454
  const CLASSIFY_OUTPUT_TOKENS = 350;
293
455
  const DISTILL_OUTPUT_TOKENS = 4500;
294
456
  const CHARS_PER_TOKEN = 3;
295
457
  let totalCost = 0;
296
458
  let estimated = 0;
297
459
  let filteredOut = 0;
298
- for (const found of discovered) {
460
+ let dryExcluded = 0;
461
+ let dryPending = 0;
462
+ for (const found of sessionsInScope) {
463
+ // Project-filtered sessions are outside the estimate: they will not be
464
+ // distilled as things stand. Counted separately so they're never a
465
+ // silent omission from the preview.
466
+ const decision = decisionFor(found.path);
467
+ if (decision !== "include") {
468
+ if (decision === "exclude")
469
+ dryExcluded += 1;
470
+ else if (decision === "pending")
471
+ dryPending += 1;
472
+ continue;
473
+ }
299
474
  if (!opts.full && db.isProcessed(found.path, found.hash))
300
475
  continue;
301
476
  let parsed;
@@ -324,12 +499,31 @@ export async function runPipeline(cfg, opts = {}) {
324
499
  if (interactive) {
325
500
  ui.blank();
326
501
  ui.divider();
327
- ui.summary({
502
+ const dryStats = {
328
503
  sessions: { value: estimated, color: ui.info },
329
504
  "filtered out": { value: filteredOut, color: ui.dim },
330
- "est. total": { value: ui.formatUsd(totalCost), color: ui.warn },
331
- });
505
+ };
506
+ if (dryExcluded > 0) {
507
+ dryStats.excluded = { value: dryExcluded, color: ui.dim };
508
+ }
509
+ if (dryPending > 0) {
510
+ dryStats.undecided = { value: dryPending, color: ui.warn };
511
+ }
512
+ if (summary.workflowSkipped + summary.sidechainSkipped > 0) {
513
+ dryStats.workflow = {
514
+ value: summary.workflowSkipped + summary.sidechainSkipped,
515
+ color: ui.dim,
516
+ };
517
+ }
518
+ if (summary.agentSkipped > 0) {
519
+ dryStats.agent = { value: summary.agentSkipped, color: ui.dim };
520
+ }
521
+ dryStats["est. total"] = { value: ui.formatUsd(totalCost), color: ui.warn };
522
+ ui.summary(dryStats);
332
523
  ui.divider();
524
+ if (dryPending > 0) {
525
+ ui.line(ui.dim(` ${dryPending} session(s) in undecided projects — run vir projects to include/exclude them`));
526
+ }
333
527
  ui.line(ui.dim(" estimates assume typical output sizes; actuals may vary ±30%"));
334
528
  }
335
529
  // PDFs are estimated separately: papers exceed the 24k-char distill cap, so
@@ -345,7 +539,7 @@ export async function runPipeline(cfg, opts = {}) {
345
539
  fileLog(`dry-run: newPdfs=${newPdfs} perPdf=${ui.formatUsd(perPdf)} estTotal=${ui.formatUsd(perPdf * newPdfs)}`);
346
540
  }
347
541
  }
348
- fileLog(`dry-run: sessions=${estimated} filtered=${filteredOut} estTotal=${ui.formatUsd(totalCost)}`);
542
+ fileLog(`dry-run: sessions=${estimated} filtered=${filteredOut} excluded=${dryExcluded} pending=${dryPending} estTotal=${ui.formatUsd(totalCost)}`);
349
543
  db.close();
350
544
  return summary;
351
545
  }
@@ -357,8 +551,52 @@ export async function runPipeline(cfg, opts = {}) {
357
551
  return summary;
358
552
  }
359
553
  }
360
- for (const found of discovered) {
554
+ // One cheap probe before the loop: a provider-wide outage should be ONE
555
+ // clear failure, not N sessions each burning full retry chains and
556
+ // attempt-counter increments. Skipped when there is nothing to distill.
557
+ if (preflightNew > 0) {
361
558
  try {
559
+ await probeProvider(cfg, maybeAnthropicClient(cfg));
560
+ }
561
+ catch (err) {
562
+ const msg = err.message ?? String(err);
563
+ fileLog(`provider preflight failed: ${msg}`);
564
+ if (interactive) {
565
+ ui.row(ui.errorColor(ui.CROSS), ui.text(`provider ${cfg.provider} unreachable — ${msg}`));
566
+ }
567
+ db.close();
568
+ throw new Error(`provider ${cfg.provider} unreachable (preflight probe failed): ${msg}`);
569
+ }
570
+ }
571
+ for (const found of sessionsInScope) {
572
+ try {
573
+ // Project filter FIRST — before the cache check and long before any
574
+ // paid call. Every config-driven skip gets its DB row and reason;
575
+ // one-off flag skips record nothing (they say nothing about state).
576
+ const decision = decisionFor(found.path);
577
+ if (decision === "flag-skip") {
578
+ summary.flagSkipped += 1;
579
+ continue;
580
+ }
581
+ if (decision === "exclude" || decision === "pending") {
582
+ const reason = decision === "exclude" ? "project-excluded" : "project-pending";
583
+ if (decision === "exclude")
584
+ summary.projectExcluded += 1;
585
+ else
586
+ summary.projectPending += 1;
587
+ const existing = db.getByPath(found.path);
588
+ if (!isDistilledRow(existing) &&
589
+ (existing?.hash !== found.hash || existing?.skip_reason !== reason)) {
590
+ db.record({
591
+ path: found.path,
592
+ hash: found.hash,
593
+ skipped: true,
594
+ notePaths: [],
595
+ skipReason: reason,
596
+ });
597
+ }
598
+ continue;
599
+ }
362
600
  if (!opts.full && db.isProcessed(found.path, found.hash)) {
363
601
  summary.alreadyProcessed += 1;
364
602
  continue;
@@ -373,6 +611,45 @@ export async function runPipeline(cfg, opts = {}) {
373
611
  continue;
374
612
  }
375
613
  const parsed = parseSession(found.path, found.hash);
614
+ // Parser backstop for the transcript-category filter: a sidechain by
615
+ // CONTENT (isSidechain in the JSONL) that structural detection missed
616
+ // — e.g. a future layout change — still stops before any paid call.
617
+ if (cfg.workflowTranscripts !== "include" && parsed.isSidechain) {
618
+ summary.sidechainSkipped += 1;
619
+ // Same no-overwrite rule as the structural gate: a changed transcript
620
+ // whose row already holds a distilled note keeps that note visible.
621
+ if (!isDistilledRow(db.getByPath(found.path))) {
622
+ db.record({
623
+ path: found.path,
624
+ hash: found.hash,
625
+ skipped: true,
626
+ notePaths: [],
627
+ skipReason: "sidechain-transcript",
628
+ });
629
+ }
630
+ fileLog(`sidechain by content, skipping: ${found.path}`);
631
+ continue;
632
+ }
633
+ // Agent-transcript backstop: the SDK launch signature seen by the full
634
+ // parse when the scan-time head sniff missed it (truncated head, field
635
+ // moved). Still pre-classify, so still free.
636
+ if (cfg.agentTranscripts !== "include" &&
637
+ typeof parsed.entrypoint === "string" &&
638
+ parsed.entrypoint.startsWith("sdk")) {
639
+ summary.agentSkipped += 1;
640
+ if (!isDistilledRow(db.getByPath(found.path))) {
641
+ db.record({
642
+ path: found.path,
643
+ hash: found.hash,
644
+ skipped: true,
645
+ notePaths: [],
646
+ skipReason: "agent-transcript",
647
+ entrypoint: parsed.entrypoint,
648
+ });
649
+ }
650
+ fileLog(`sdk agent by content, skipping: ${found.path}`);
651
+ continue;
652
+ }
376
653
  const filter = scoreSession(parsed, cfg.filterThreshold);
377
654
  if (!filter.passes) {
378
655
  summary.skippedByFilter += 1;
@@ -421,12 +698,13 @@ export async function runPipeline(cfg, opts = {}) {
421
698
  project: note.classification.project,
422
699
  confidence: note.classification.confidence,
423
700
  startedAt: parsed.startedAt,
701
+ entrypoint: parsed.entrypoint,
424
702
  });
425
703
  if (interactive) {
426
704
  ui.categoryRow(note.classification.category, note.classification.topic);
427
705
  }
428
706
  fileLog(`distilled ${parsed.sessionId.slice(0, 8)} → ${note.classification.category}/${note.classification.topic}`);
429
- if (note.classification.confidence >= 0.8) {
707
+ if (note.classification.confidence >= 0.8 && cfg.notifications !== false) {
430
708
  notify(`Vir — new ${note.classification.category}`, `${note.classification.topic} · ${note.classification.project}`);
431
709
  }
432
710
  const slug = kebab(note.classification.project);
@@ -469,6 +747,24 @@ export async function runPipeline(cfg, opts = {}) {
469
747
  fileLog(`summary failed for project/${slug}: ${msg}`);
470
748
  }
471
749
  }
750
+ // Surface undecided projects — one log line and (config-gated) one macOS
751
+ // notification. Never a prompt: this is the daemon-safe path. Recomputed
752
+ // post-loop so interactively answered projects don't re-notify.
753
+ if (summary.projectPending > 0) {
754
+ const stillPending = new Set();
755
+ for (const group of projectGroups.values()) {
756
+ if (decideProject(group.name, projectDecisions, projectFlags) === "pending") {
757
+ stillPending.add(group.name);
758
+ }
759
+ }
760
+ const msg = `${summary.projectPending} session(s) across ${stillPending.size} project(s) awaiting include/exclude decision — run vir projects`;
761
+ fileLog(msg);
762
+ if (interactive)
763
+ ui.line(ui.dim(` ${msg}`));
764
+ if (cfg.notifications !== false && process.platform === "darwin") {
765
+ notify("vir — projects awaiting decision", `${stillPending.size} project(s), ${summary.projectPending} session(s) pending — run vir projects`);
766
+ }
767
+ }
472
768
  // Second input source: web articles. Gated on config; a session-only install
473
769
  // (no articlesDir) skips this entirely and behaves exactly as before.
474
770
  if (cfg.articlesDir && cfg.distillArticles) {
@@ -483,7 +779,7 @@ export async function runPipeline(cfg, opts = {}) {
483
779
  // (Ollama down during distill). Without this a transient outage is permanent
484
780
  // — the note never enters the embedding-search candidate set.
485
781
  await runEmbeddingSweep(db, writer, fileLog, interactive);
486
- 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}`);
782
+ 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}`);
487
783
  if (interactive) {
488
784
  ui.blank();
489
785
  ui.divider();
@@ -500,6 +796,21 @@ export async function runPipeline(cfg, opts = {}) {
500
796
  color: summary.errored > 0 ? ui.errorColor : ui.dim,
501
797
  },
502
798
  };
799
+ if (summary.projectExcluded > 0) {
800
+ stats.excluded = { value: summary.projectExcluded, color: ui.dim };
801
+ }
802
+ if (summary.projectPending > 0) {
803
+ stats.undecided = { value: summary.projectPending, color: ui.warn };
804
+ }
805
+ if (summary.workflowSkipped + summary.sidechainSkipped > 0) {
806
+ stats.workflow = {
807
+ value: summary.workflowSkipped + summary.sidechainSkipped,
808
+ color: ui.dim,
809
+ };
810
+ }
811
+ if (summary.agentSkipped > 0) {
812
+ stats.agent = { value: summary.agentSkipped, color: ui.dim };
813
+ }
503
814
  if (cfg.articlesDir && cfg.distillArticles) {
504
815
  stats.articles = { value: summary.articlesDistilled, color: ui.success };
505
816
  }
@@ -630,7 +941,8 @@ async function runArticlePhase(cfg, db, writer, summary, fileLog, interactive) {
630
941
  ui.categoryRow(distilled.classification.category, article.title);
631
942
  }
632
943
  fileLog(`distilled article → ${distilled.classification.category}/${article.title}`);
633
- if (distilled.classification.confidence >= 0.8) {
944
+ if (distilled.classification.confidence >= 0.8 &&
945
+ cfg.notifications !== false) {
634
946
  notify(`Vir — new ${distilled.classification.category}`, article.title);
635
947
  }
636
948
  await new Promise((r) => setTimeout(r, 2000));
@@ -715,7 +1027,8 @@ async function runPdfPhase(cfg, db, writer, summary, fileLog, interactive) {
715
1027
  ui.categoryRow(distilled.classification.category, parsed.title);
716
1028
  }
717
1029
  fileLog(`distilled pdf → ${distilled.classification.category}/${parsed.title}`);
718
- if (distilled.classification.confidence >= 0.8) {
1030
+ if (distilled.classification.confidence >= 0.8 &&
1031
+ cfg.notifications !== false) {
719
1032
  notify(`Vir — new ${distilled.classification.category}`, parsed.title);
720
1033
  }
721
1034
  await new Promise((r) => setTimeout(r, 2000));
@@ -758,6 +1071,8 @@ async function rewriteOne(writer, row) {
758
1071
  userText: "",
759
1072
  rawSummary: "",
760
1073
  transcriptText: "",
1074
+ isSidechain: false,
1075
+ entrypoint: null,
761
1076
  };
762
1077
  const note = {
763
1078
  classification: {