@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.
- 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.js +171 -16
- 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 +4 -2
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/launchd.js +57 -5
- package/dist/daemon/launchd.js.map +1 -1
- package/dist/diagnostics/doctor.js +155 -20
- package/dist/diagnostics/doctor.js.map +1 -1
- package/dist/pipeline/distiller.js +18 -0
- package/dist/pipeline/distiller.js.map +1 -1
- package/dist/pipeline/parser.js +11 -0
- package/dist/pipeline/parser.js.map +1 -1
- package/dist/pipeline/projects.js +226 -0
- package/dist/pipeline/projects.js.map +1 -0
- package/dist/pipeline/run.js +342 -27
- package/dist/pipeline/run.js.map +1 -1
- package/dist/state/db.js +76 -6
- 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";
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
291
|
-
const distillModel =
|
|
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
|
-
|
|
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
|
-
|
|
502
|
+
const dryStats = {
|
|
328
503
|
sessions: { value: estimated, color: ui.info },
|
|
329
504
|
"filtered out": { value: filteredOut, color: ui.dim },
|
|
330
|
-
|
|
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
|
-
|
|
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: {
|