@kolisachint/hoocode-agent 0.5.17 → 0.5.18

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/core/learn/cache.d.ts +58 -0
  3. package/dist/core/learn/cache.d.ts.map +1 -0
  4. package/dist/core/learn/cache.js +120 -0
  5. package/dist/core/learn/cache.js.map +1 -0
  6. package/dist/core/learn/coverage.d.ts +58 -0
  7. package/dist/core/learn/coverage.d.ts.map +1 -0
  8. package/dist/core/learn/coverage.js +142 -0
  9. package/dist/core/learn/coverage.js.map +1 -0
  10. package/dist/core/learn/digest.d.ts +1 -0
  11. package/dist/core/learn/digest.d.ts.map +1 -1
  12. package/dist/core/learn/digest.js +31 -4
  13. package/dist/core/learn/digest.js.map +1 -1
  14. package/dist/core/learn/extract.d.ts +71 -103
  15. package/dist/core/learn/extract.d.ts.map +1 -1
  16. package/dist/core/learn/extract.js +162 -437
  17. package/dist/core/learn/extract.js.map +1 -1
  18. package/dist/core/learn/mine.d.ts +123 -0
  19. package/dist/core/learn/mine.d.ts.map +1 -0
  20. package/dist/core/learn/mine.js +285 -0
  21. package/dist/core/learn/mine.js.map +1 -0
  22. package/dist/core/learn/reduce.d.ts +78 -0
  23. package/dist/core/learn/reduce.d.ts.map +1 -0
  24. package/dist/core/learn/reduce.js +123 -0
  25. package/dist/core/learn/reduce.js.map +1 -0
  26. package/dist/core/learn/state.d.ts +8 -0
  27. package/dist/core/learn/state.d.ts.map +1 -1
  28. package/dist/core/learn/state.js +18 -3
  29. package/dist/core/learn/state.js.map +1 -1
  30. package/dist/core/settings-manager.d.ts +2 -0
  31. package/dist/core/settings-manager.d.ts.map +1 -1
  32. package/dist/core/settings-manager.js +4 -0
  33. package/dist/core/settings-manager.js.map +1 -1
  34. package/dist/core/startup-progress.d.ts +12 -7
  35. package/dist/core/startup-progress.d.ts.map +1 -1
  36. package/dist/core/startup-progress.js +12 -7
  37. package/dist/core/startup-progress.js.map +1 -1
  38. package/dist/extensions/core/learn.d.ts +8 -4
  39. package/dist/extensions/core/learn.d.ts.map +1 -1
  40. package/dist/extensions/core/learn.js +208 -27
  41. package/dist/extensions/core/learn.js.map +1 -1
  42. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  43. package/dist/modes/interactive/components/footer.js +7 -25
  44. package/dist/modes/interactive/components/footer.js.map +1 -1
  45. package/dist/modes/interactive/components/progress-bar.d.ts +50 -0
  46. package/dist/modes/interactive/components/progress-bar.d.ts.map +1 -0
  47. package/dist/modes/interactive/components/progress-bar.js +77 -0
  48. package/dist/modes/interactive/components/progress-bar.js.map +1 -0
  49. package/dist/modes/interactive/voice/voice-panel.d.ts +6 -1
  50. package/dist/modes/interactive/voice/voice-panel.d.ts.map +1 -1
  51. package/dist/modes/interactive/voice/voice-panel.js +18 -14
  52. package/dist/modes/interactive/voice/voice-panel.js.map +1 -1
  53. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  54. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  55. package/examples/extensions/sandbox/package.json +1 -1
  56. package/examples/extensions/with-deps/package.json +1 -1
  57. package/package.json +4 -4
  58. package/dist/core/learn/normalize.d.ts +0 -65
  59. package/dist/core/learn/normalize.d.ts.map +0 -1
  60. package/dist/core/learn/normalize.js +0 -245
  61. package/dist/core/learn/normalize.js.map +0 -1
@@ -8,81 +8,54 @@
8
8
  * whether something is a durable rule or a one-off, and it is the one thing a
9
9
  * prompt reading its own context cannot see.
10
10
  *
11
- * The split of labour is deliberate. This module is entirely deterministic: it
12
- * parses, filters, normalizes, counts and ranks. Judgement — is this a rule, how
13
- * should it be phrased, which scope owns it — belongs to the model reading the
14
- * digest, which is why the output carries evidence (counts, sessions, dates)
15
- * rather than conclusions.
11
+ * This module is the orchestrator, and the split of labour inside it is
12
+ * deliberate:
13
+ *
14
+ * - **Gathering** is deterministic. Finding session files, resolving which cwd
15
+ * they belong to, walking the active branch of a forked session — all exact,
16
+ * all cheap, all here.
17
+ * - **Judgement** is the model's, in `mine.ts` and `coverage.ts`. What counts as
18
+ * a directive, what two phrasings have in common, whether a rule already
19
+ * covers something — none of that survives contact with a regex, and it used
20
+ * to be decided by one.
21
+ * - **Counting** is deterministic again, in `reduce.ts`. The number is the
22
+ * product, and a model asked to count over a long context will be
23
+ * approximately right.
24
+ *
25
+ * The expensive step is memoized per session file (`cache.ts`), so a session is
26
+ * read by the model exactly once in its life and the counts are still computed
27
+ * over every session in the window on every run.
16
28
  */
17
29
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
18
30
  import { dirname, join, resolve, sep } from "node:path";
19
31
  import { getUserAgentsDir } from "../../config.js";
20
32
  import { getSessionDirPath } from "../session-manager.js";
21
33
  import { loadSkills } from "../skills.js";
22
- import { commandHead, contentWords, extractErrorRegion, isBenignFailure, isRuleShapedDirective, isUninformativeFailure, normalizeCommand, normalizeDirective, normalizeErrorSignature, wordOverlap, } from "./normalize.js";
34
+ import { hashSessionFile, pruneLearnCache, readCachedMining, writeCachedMining } from "./cache.js";
35
+ import { noCoverageJudge } from "./coverage.js";
36
+ import { reduceDirectives, reduceFixes, reduceWorkflows } from "./reduce.js";
23
37
  import { judge } from "./state.js";
24
- /**
25
- * Prefix on the message `/learn` injects. The digest is persisted like any user
26
- * turn, so without this marker the next `/learn` would mine its own output and
27
- * every proposal would compound its own count.
28
- */
29
- export const LEARN_DIGEST_MARKER = "[learn-digest]";
38
+ export { LEARN_DIGEST_MARKER } from "./mine.js";
30
39
  /** Sessions considered, newest first. */
31
40
  const DEFAULT_MAX_SESSIONS = 20;
32
41
  /** Sessions older than this are ignored — a pattern that stopped is not a rule. */
33
42
  const DEFAULT_MAX_AGE_DAYS = 30;
34
43
  /** Entries parsed per session file, as a guard against pathological transcripts. */
35
44
  const MAX_ENTRIES_PER_SESSION = 8000;
36
- /** Tool calls per session fed to the workflow detector. */
37
- const MAX_TOOL_CALLS_PER_SESSION = 400;
38
- /** How far forward the fix extractor looks for the same command succeeding. */
39
- const FIX_LOOKAHEAD = 40;
40
- /** Word overlap against an existing rule above which a directive counts as covered. */
41
- const COVERED_OVERLAP = 0.6;
42
- /**
43
- * The same bar for skills, set higher on purpose.
44
- *
45
- * A rule is one line, so overlap against it is a sharp signal. A skill is a name
46
- * plus a description written to attract matches, which is a far larger haystack
47
- * — a short directive's words turn up in it by chance much more readily. The
48
- * higher bar and the truncation below keep "you already have a skill for this"
49
- * from being said on a coincidence.
50
- */
51
- const SKILL_COVERED_OVERLAP = 0.75;
52
- /** Description characters considered. The opening says what a skill does; the rest is trigger bait. */
53
- const SKILL_DESCRIPTION_CHARS = 300;
54
- /** Directives must reach this many occurrences to be reported at all. */
45
+ /** Occurrences a directive needs before it is proposed. */
55
46
  const DEFAULT_MIN_DIRECTIVE_COUNT = 2;
56
- /** Tool sequence lengths considered as workflow candidates. */
57
- const WORKFLOW_MIN_LEN = 3;
58
- const WORKFLOW_MAX_LEN = 5;
59
47
  /** Repeats before a tool sequence is worth proposing as a skill. */
60
48
  const DEFAULT_MIN_WORKFLOW_COUNT = 3;
61
49
  /** Cap on each list in the digest, so the model's budget goes to the top signals. */
62
50
  const DEFAULT_MAX_PER_CATEGORY = 8;
63
- function textOf(content) {
64
- if (typeof content === "string")
65
- return content;
66
- if (!Array.isArray(content))
67
- return "";
68
- return content
69
- .map((block) => block && typeof block === "object" && block.type === "text"
70
- ? (block.text ?? "")
71
- : "")
72
- .join("\n")
73
- .trim();
74
- }
75
- function isToolCall(block) {
76
- return !!block && typeof block === "object" && block.type === "toolCall";
77
- }
78
51
  /**
79
52
  * Reduce a session's raw entries to the branch that was actually taken.
80
53
  *
81
54
  * Session files are trees — forks and clones append entries that were never
82
55
  * part of the same conversation. Walking parent links back from the last entry
83
- * keeps the extractor from stitching a "fix" out of two turns that never
84
- * happened in sequence. Sessions written before entry ids existed are flat, and
85
- * for those file order *is* the branch.
56
+ * keeps the miner from reading two turns that never happened in sequence as if
57
+ * they did. Sessions written before entry ids existed are flat, and for those
58
+ * file order *is* the branch.
86
59
  */
87
60
  function activeBranch(entries) {
88
61
  const withIds = entries.filter((e) => typeof e.id === "string");
@@ -306,11 +279,35 @@ function applySuppression(items, state, maxProposals, covered, onDeclined) {
306
279
  }
307
280
  /**
308
281
  * Where this cwd's sessions were found and what was passed over, without
309
- * ranking anything. `/learn stats` reports on the window without re-mining it.
282
+ * mining anything. `/learn settings` and `/learn stats` report on the window
283
+ * without paying for a model call.
310
284
  */
311
285
  export function scanSessions(options) {
312
286
  return listSessions(options).scan;
313
287
  }
288
+ /**
289
+ * What a run would read, without reading it.
290
+ *
291
+ * Runs the real selection — the same age, cwd, cap and de-duplication rules
292
+ * `mineLearnDigest` applies — and then asks the cache about each survivor. It
293
+ * has to be the same selection: this number is what the confirmation prompt
294
+ * quotes, and a prompt that says twelve before reading three is worse than no
295
+ * prompt at all. Hashing the chosen files is cheap next to sending them to a
296
+ * model.
297
+ */
298
+ export function planMining(options) {
299
+ const { sessions } = listSessions(options);
300
+ let cached = 0;
301
+ let pending = 0;
302
+ for (const session of sessions) {
303
+ const hash = hashSessionFile(session.file);
304
+ if (hash && readCachedMining(options.agentDir, hash))
305
+ cached++;
306
+ else
307
+ pending++;
308
+ }
309
+ return { total: sessions.length, cached, pending };
310
+ }
314
311
  /** Nearest AGENTS.md walking up from cwd, so proposals can be checked against it. */
315
312
  function findAgentsFile(cwd) {
316
313
  let dir = resolve(cwd);
@@ -326,376 +323,6 @@ function findAgentsFile(cwd) {
326
323
  dir = parent;
327
324
  }
328
325
  }
329
- /** Pair tool calls with their results along one branch, in call order. */
330
- function toolEvents(entries) {
331
- const byCallId = new Map();
332
- const ordered = [];
333
- for (const entry of entries) {
334
- const message = entry.type === "message" ? entry.message : undefined;
335
- if (!message)
336
- continue;
337
- if (message.role === "assistant") {
338
- for (const block of (message.content ?? [])) {
339
- if (!isToolCall(block))
340
- continue;
341
- const event = { name: block.name, args: block.arguments ?? {} };
342
- byCallId.set(block.id, event);
343
- ordered.push(event);
344
- }
345
- }
346
- else if (message.role === "toolResult") {
347
- const event = byCallId.get(message.toolCallId);
348
- if (!event)
349
- continue;
350
- event.isError = message.isError;
351
- event.output = textOf(message.content);
352
- }
353
- }
354
- return ordered;
355
- }
356
- /** User turns worth mining, in order, with the digest's own output excluded. */
357
- function userDirectives(entries) {
358
- const out = [];
359
- for (const entry of entries) {
360
- const message = entry.type === "message" ? entry.message : undefined;
361
- if (!message || message.role !== "user")
362
- continue;
363
- const text = textOf(message.content);
364
- if (!text || text.startsWith(LEARN_DIGEST_MARKER))
365
- continue;
366
- if (!isRuleShapedDirective(text))
367
- continue;
368
- out.push(text.trim());
369
- }
370
- return out;
371
- }
372
- function clusterDirectives(perSession, coverage, minRepeats) {
373
- const acc = new Map();
374
- for (const { session, directives } of perSession) {
375
- for (const text of directives) {
376
- const normalized = normalizeDirective(text);
377
- if (!normalized)
378
- continue;
379
- const existing = acc.get(normalized);
380
- if (existing) {
381
- existing.count++;
382
- existing.sessions.add(session.id);
383
- if (session.timestamp > existing.lastSeen)
384
- existing.lastSeen = session.timestamp;
385
- if (text.length > existing.text.length)
386
- existing.text = text;
387
- }
388
- else {
389
- acc.set(normalized, {
390
- text,
391
- normalized,
392
- count: 1,
393
- sessions: new Set([session.id]),
394
- lastSeen: session.timestamp,
395
- });
396
- }
397
- }
398
- }
399
- const clusters = [];
400
- for (const entry of acc.values()) {
401
- if (entry.count < minRepeats)
402
- continue;
403
- // Everything reaching here cleared the repeat threshold. Suppression handles
404
- // the case that used to make these labels lie — a proposal accepted from a
405
- // previous run coming back as "not working" when nothing had happened
406
- // since. By the time an item survives that filter, a match genuinely means
407
- // you repeated yourself after the rule or skill already existed.
408
- const match = matchCoverage(entry.text, coverage);
409
- clusters.push({
410
- key: `directive:${entry.normalized}`,
411
- text: entry.text,
412
- normalized: entry.normalized,
413
- count: entry.count,
414
- sessions: entry.sessions.size,
415
- lastSeen: entry.lastSeen,
416
- status: match.rule ? "restated" : match.skill ? "has-skill" : "new",
417
- existingRule: match.rule,
418
- existingSkill: match.skill,
419
- previouslyDeclined: false,
420
- });
421
- }
422
- return clusters.sort((a, b) => b.sessions - a.sessions || b.count - a.count || a.text.localeCompare(b.text));
423
- }
424
- /** Files a mutating tool touched, for the resolution summary. */
425
- function editedFile(event) {
426
- if (!["edit", "write", "multi_edit", "apply_patch"].includes(event.name))
427
- return undefined;
428
- const path = event.args?.path ?? event.args?.file_path ?? event.args?.filePath;
429
- return typeof path === "string" ? path : undefined;
430
- }
431
- function extractFixes(perSession) {
432
- const acc = new Map();
433
- for (const { session, events } of perSession) {
434
- for (let i = 0; i < events.length; i++) {
435
- const failure = events[i];
436
- if (failure.name !== "bash" || !failure.isError)
437
- continue;
438
- const command = typeof failure.args?.command === "string" ? failure.args.command : "";
439
- if (!command || isBenignFailure(command))
440
- continue;
441
- const normalized = normalizeCommand(command);
442
- const interveningCommands = [];
443
- const editedFiles = [];
444
- let resolved = false;
445
- for (let j = i + 1; j < Math.min(events.length, i + 1 + FIX_LOOKAHEAD); j++) {
446
- const next = events[j];
447
- const file = editedFile(next);
448
- if (file)
449
- editedFiles.push(file);
450
- if (next.name !== "bash")
451
- continue;
452
- const nextCommand = typeof next.args?.command === "string" ? next.args.command : "";
453
- if (!nextCommand)
454
- continue;
455
- // The same command later succeeding is the only evidence that the
456
- // problem was actually fixed. A *different* command passing says
457
- // nothing, and neither does the model moving on.
458
- if (normalizeCommand(nextCommand) === normalized && !next.isError) {
459
- resolved = true;
460
- break;
461
- }
462
- interveningCommands.push(nextCommand.trim());
463
- }
464
- if (!resolved)
465
- continue;
466
- const output = failure.output ?? "";
467
- // An abort is the user changing their mind, not a problem that was
468
- // solved, and empty output carries nothing to sign or show.
469
- if (isUninformativeFailure(output))
470
- continue;
471
- // Sign the error region, not the whole output: build tools lead with an
472
- // identical banner, so signing everything makes unrelated failures of
473
- // the same command collide on their shared preamble.
474
- const errorRegion = extractErrorRegion(output);
475
- const signature = normalizeErrorSignature(errorRegion);
476
- if (!signature)
477
- continue;
478
- const key = `${normalized}${signature}`;
479
- const existing = acc.get(key);
480
- if (existing) {
481
- existing.candidate.count++;
482
- existing.sessions.add(session.id);
483
- if (session.timestamp > existing.candidate.lastSeen)
484
- existing.candidate.lastSeen = session.timestamp;
485
- }
486
- else {
487
- acc.set(key, {
488
- sessions: new Set([session.id]),
489
- candidate: {
490
- key: `fix:${key}`,
491
- command: normalized,
492
- signature,
493
- errorExcerpt: errorRegion.replace(/\s+/g, " ").trim().slice(0, 240),
494
- interveningCommands: [...new Set(interveningCommands)].slice(0, 5),
495
- editedFiles: [...new Set(editedFiles)].slice(0, 5),
496
- count: 1,
497
- sessions: 1,
498
- lastSeen: session.timestamp,
499
- },
500
- });
501
- }
502
- }
503
- }
504
- const out = [];
505
- for (const { candidate, sessions } of acc.values()) {
506
- candidate.sessions = sessions.size;
507
- out.push(candidate);
508
- }
509
- return out.sort((a, b) => b.count - a.count || b.sessions - a.sessions || a.signature.localeCompare(b.signature));
510
- }
511
- /** A tool call reduced to a comparable step: the tool, plus what a bash call runs. */
512
- function stepSignature(event) {
513
- if (event.name === "bash") {
514
- const command = typeof event.args?.command === "string" ? event.args.command : "";
515
- const head = commandHead(command);
516
- return head ? `bash:${head}` : "bash";
517
- }
518
- return event.name;
519
- }
520
- /**
521
- * Commands that are how an agent looks around rather than what the user was
522
- * doing. A sequence built only from these plus file edits describes "coding",
523
- * not a workflow, and no useful skill has ever come out of one.
524
- */
525
- const PLUMBING_COMMANDS = new Set([
526
- "cd",
527
- "ls",
528
- "pwd",
529
- "cat",
530
- "head",
531
- "tail",
532
- "wc",
533
- "echo",
534
- "which",
535
- "find",
536
- "fd",
537
- "grep",
538
- "rg",
539
- "sed",
540
- "awk",
541
- "git status",
542
- "git diff",
543
- "git log",
544
- "git show",
545
- ]);
546
- /**
547
- * Whether a sequence is a procedure rather than the rhythm of editing code.
548
- *
549
- * Two distinct doing-commands is the bar, and it was set by looking at real
550
- * transcripts. One command is not enough: the edit/test loop
551
- * (`edit → edit → bash:npm run`) satisfies it, and because a sliding window
552
- * over a long alternating run produces every rotation of that cycle, it alone
553
- * filled all eight slots with `edit → npm run → edit`, `npm run → edit → edit`
554
- * and so on — one habit described eight ways.
555
- *
556
- * A procedure worth a skill chains *different* actions: test then commit then
557
- * push, build then tag then publish. Requiring two distinct ones keeps those and
558
- * drops the rhythm. The cost is real — a genuine one-command routine with setup
559
- * is missed — and that is the intended trade, since a missed skill costs nothing
560
- * while a digest full of noise costs the reader's attention every run.
561
- */
562
- function isProcedure(steps) {
563
- const commands = new Set();
564
- for (const step of steps) {
565
- if (!step.startsWith("bash:"))
566
- continue;
567
- const head = step.slice("bash:".length);
568
- if (PLUMBING_COMMANDS.has(head) || PLUMBING_COMMANDS.has(head.split(" ")[0] ?? ""))
569
- continue;
570
- commands.add(head);
571
- }
572
- return commands.size >= 2;
573
- }
574
- /** True when `needle` appears as a contiguous run inside `haystack`. */
575
- function containsSequence(haystack, needle) {
576
- if (needle.length > haystack.length)
577
- return false;
578
- for (let i = 0; i + needle.length <= haystack.length; i++) {
579
- if (needle.every((step, offset) => haystack[i + offset] === step))
580
- return true;
581
- }
582
- return false;
583
- }
584
- function extractWorkflows(perSession, minRepeats) {
585
- const acc = new Map();
586
- for (const { session, events } of perSession) {
587
- const steps = events.slice(0, MAX_TOOL_CALLS_PER_SESSION).map(stepSignature);
588
- for (let len = WORKFLOW_MIN_LEN; len <= WORKFLOW_MAX_LEN; len++) {
589
- // Collect every position first, then count greedily without overlap.
590
- // Counting each sliding position separately treats one long stretch of
591
- // edit/read churn as dozens of repeats: an `edit > read > edit` run of
592
- // length 12 scores 10 occurrences when it is really one stretch of work.
593
- const positions = new Map();
594
- for (let i = 0; i + len <= steps.length; i++) {
595
- const window = steps.slice(i, i + len);
596
- // A run of one repeated tool is a loop, not a workflow.
597
- if (new Set(window).size < 2)
598
- continue;
599
- if (!isProcedure(window))
600
- continue;
601
- const key = window.join(" > ");
602
- const list = positions.get(key);
603
- if (list)
604
- list.push(i);
605
- else
606
- positions.set(key, [i]);
607
- }
608
- for (const [key, occurrences] of positions) {
609
- let count = 0;
610
- let nextFree = -1;
611
- for (const start of occurrences) {
612
- if (start < nextFree)
613
- continue;
614
- count++;
615
- nextFree = start + len;
616
- }
617
- const existing = acc.get(key);
618
- if (existing) {
619
- existing.count += count;
620
- existing.sessions.add(session.id);
621
- if (session.timestamp > existing.lastSeen)
622
- existing.lastSeen = session.timestamp;
623
- }
624
- else {
625
- acc.set(key, {
626
- steps: key.split(" > "),
627
- count,
628
- sessions: new Set([session.id]),
629
- lastSeen: session.timestamp,
630
- });
631
- }
632
- }
633
- }
634
- }
635
- const ranked = [...acc.values()]
636
- .filter((entry) => entry.count >= minRepeats)
637
- .map((entry) => ({
638
- key: `workflow:${entry.steps.join(" > ")}`,
639
- steps: entry.steps,
640
- count: entry.count,
641
- sessions: entry.sessions.size,
642
- lastSeen: entry.lastSeen,
643
- }))
644
- // Sessions first, matching directives: a sequence seen in three sessions is
645
- // a workflow, while one repeated ten times in a single session is usually
646
- // just the shape of that one task.
647
- .sort((a, b) => b.sessions - a.sessions ||
648
- b.count - a.count ||
649
- b.steps.length - a.steps.length ||
650
- a.steps.join().localeCompare(b.steps.join()));
651
- // Every n-gram overlaps its own extensions and prefixes, so without this the
652
- // list is one workflow described five slightly different ways. The test runs
653
- // both directions on purpose: a shorter sequence always outranks the longer
654
- // one containing it (it occurs at least as often), so checking only
655
- // shorter-inside-kept would never fire. Keep the best-ranked member of each
656
- // family and drop the rest.
657
- const distinct = [];
658
- for (const candidate of ranked) {
659
- const overlapsKept = distinct.some((kept) => containsSequence(kept.steps, candidate.steps) || containsSequence(candidate.steps, kept.steps));
660
- if (overlapsKept)
661
- continue;
662
- distinct.push(candidate);
663
- }
664
- return distinct;
665
- }
666
- /**
667
- * Where a piece of text is already written down, if anywhere.
668
- *
669
- * A rule wins over a skill when both match: it is the more specific answer, and
670
- * "rewrite this line" is more actionable than "sharpen a description".
671
- */
672
- export function matchCoverage(text, index) {
673
- const words = contentWords(text);
674
- let bestLine;
675
- let bestOverlap = 0;
676
- for (const line of index.ruleLines) {
677
- const overlap = wordOverlap(words, line);
678
- if (overlap > bestOverlap) {
679
- bestOverlap = overlap;
680
- bestLine = line;
681
- }
682
- }
683
- if (bestOverlap >= COVERED_OVERLAP)
684
- return { rule: bestLine };
685
- let bestSkill;
686
- let bestSkillOverlap = 0;
687
- for (const skill of index.skills) {
688
- const haystack = `${skill.name} ${skill.description.slice(0, SKILL_DESCRIPTION_CHARS)}`;
689
- const overlap = wordOverlap(words, haystack);
690
- if (overlap > bestSkillOverlap) {
691
- bestSkillOverlap = overlap;
692
- bestSkill = skill.name;
693
- }
694
- }
695
- if (bestSkillOverlap >= SKILL_COVERED_OVERLAP)
696
- return { skill: bestSkill };
697
- return {};
698
- }
699
326
  /** Assemble the coverage index for a directory. */
700
327
  export function buildCoverageIndex(options) {
701
328
  const corpus = coverageCorpus(options.agentDir, findAgentsFile(options.cwd));
@@ -750,8 +377,80 @@ function loadSkillIndex(cwd, agentDir) {
750
377
  return [];
751
378
  }
752
379
  }
380
+ /**
381
+ * Run the miner over the window, reusing cached results wherever the file has
382
+ * not changed.
383
+ *
384
+ * A session that fails to mine is counted and skipped rather than aborting the
385
+ * run: one provider hiccup on one transcript should cost that transcript's
386
+ * signals, not the whole digest. The failure count is reported so the reader
387
+ * knows the numbers are short.
388
+ *
389
+ * Cancellation is different from failure and is reported separately. A run
390
+ * stopped half way has counted only some of the window, so its numbers are not
391
+ * merely short — they are wrong in a way that would poison the bookmark if the
392
+ * digest were treated as a completed run.
393
+ */
394
+ async function mineSessions(sessions, options) {
395
+ const mined = [];
396
+ const report = { cached: 0, mined: 0, failed: 0 };
397
+ let done = 0;
398
+ for (const session of sessions) {
399
+ if (options.signal?.aborted)
400
+ return { mined, report, aborted: true };
401
+ const hash = hashSessionFile(session.file);
402
+ const cached = hash ? readCachedMining(options.agentDir, hash) : undefined;
403
+ if (cached) {
404
+ mined.push({ sessionId: session.id, timestamp: session.timestamp, candidates: cached.candidates });
405
+ report.cached++;
406
+ done++;
407
+ options.onProgress?.({ done, total: sessions.length, cached: report.cached });
408
+ continue;
409
+ }
410
+ const minable = { id: session.id, timestamp: session.timestamp, entries: session.entries };
411
+ try {
412
+ const candidates = await options.miner(minable, options.signal);
413
+ mined.push({ sessionId: session.id, timestamp: session.timestamp, candidates });
414
+ report.mined++;
415
+ if (hash) {
416
+ writeCachedMining(options.agentDir, hash, {
417
+ sessionId: session.id,
418
+ timestamp: session.timestamp,
419
+ candidates,
420
+ minedAt: new Date().toISOString(),
421
+ });
422
+ }
423
+ }
424
+ catch {
425
+ // A cancelled request surfaces here as a rejection. That is not the
426
+ // provider failing on this transcript, so it must not be counted as one.
427
+ if (options.signal?.aborted)
428
+ return { mined, report, aborted: true };
429
+ report.failed++;
430
+ }
431
+ done++;
432
+ options.onProgress?.({ done, total: sessions.length, cached: report.cached });
433
+ }
434
+ return { mined, report, aborted: false };
435
+ }
436
+ /** Apply the coverage verdicts to the clusters they were asked about. */
437
+ function applyCoverage(directives, verdicts) {
438
+ for (const cluster of directives) {
439
+ const verdict = verdicts.get(cluster.label);
440
+ if (!verdict)
441
+ continue;
442
+ if (verdict.rule) {
443
+ cluster.status = "restated";
444
+ cluster.existingRule = verdict.rule;
445
+ }
446
+ else if (verdict.skill) {
447
+ cluster.status = "has-skill";
448
+ cluster.existingSkill = verdict.skill;
449
+ }
450
+ }
451
+ }
753
452
  /** Mine the recent sessions for this cwd and return the ranked digest. */
754
- export function extractLearnDigest(options) {
453
+ export async function mineLearnDigest(options) {
755
454
  const { sessions, skipped, scan } = listSessions(options);
756
455
  const agentsFilePath = findAgentsFile(options.cwd);
757
456
  let agentsContent;
@@ -763,9 +462,27 @@ export function extractLearnDigest(options) {
763
462
  agentsContent = undefined;
764
463
  }
765
464
  }
465
+ const { mined, report, aborted } = await mineSessions(sessions, options);
466
+ pruneLearnCache(options.agentDir, options.now);
467
+ const minRepeats = options.minRepeats ?? DEFAULT_MIN_DIRECTIVE_COUNT;
468
+ const maxProposals = options.maxProposals ?? DEFAULT_MAX_PER_CATEGORY;
469
+ const directives = reduceDirectives(mined, minRepeats);
470
+ const fixes = reduceFixes(mined, minRepeats);
471
+ const workflows = reduceWorkflows(mined, options.minWorkflowRepeats ?? DEFAULT_MIN_WORKFLOW_COUNT);
472
+ // Coverage is asked only about what survived the repeat threshold. Judging
473
+ // everything would mean sending the context file alongside a long tail of
474
+ // one-off observations that are never going to be proposed.
766
475
  const coverage = buildCoverageIndex({ cwd: options.cwd, agentDir: options.agentDir, skills: options.skills });
767
- const withDirectives = sessions.map((session) => ({ session, directives: userDirectives(session.entries) }));
768
- const withEvents = sessions.map((session) => ({ session, events: toolEvents(session.entries) }));
476
+ const queries = directives.map((d) => ({ label: d.label, text: d.text }));
477
+ try {
478
+ const verdicts = await (options.coverageJudge ?? noCoverageJudge)(queries, coverage, options.signal);
479
+ applyCoverage(directives, verdicts);
480
+ }
481
+ catch {
482
+ // A failed coverage call leaves everything `new`, which over-proposes
483
+ // slightly. That is the right way to fail: the reader can reject a
484
+ // duplicate, but cannot recover a proposal that was wrongly withheld.
485
+ }
769
486
  const timestamps = sessions.map((s) => s.timestamp).sort();
770
487
  const state = options.ignoreState ? undefined : options.state;
771
488
  // Directives carry a real coverage signal — is this written down as a rule or
@@ -773,29 +490,37 @@ export function extractLearnDigest(options) {
773
490
  // declined one. Fixes and workflows do not: a fix may have become a rule, a
774
491
  // skill, or a habit, and which one is not recoverable here, so they get
775
492
  // suppression only and are never labelled declined.
776
- const maxProposals = options.maxProposals ?? DEFAULT_MAX_PER_CATEGORY;
777
- const directives = applySuppression(clusterDirectives(withDirectives, coverage, options.minRepeats ?? DEFAULT_MIN_DIRECTIVE_COUNT), state, maxProposals, (item) => item.status !== "new", (item) => {
493
+ const keptDirectives = applySuppression(directives, state, maxProposals, (item) => item.status !== "new", (item) => {
778
494
  item.previouslyDeclined = true;
779
495
  });
780
- const fixes = applySuppression(extractFixes(withEvents), state, maxProposals, () => false);
781
- const workflows = applySuppression(extractWorkflows(withEvents, options.minWorkflowRepeats ?? DEFAULT_MIN_WORKFLOW_COUNT), state, maxProposals, () => false);
496
+ const keptFixes = applySuppression(fixes, state, maxProposals, () => false);
497
+ const keptWorkflows = applySuppression(workflows, state, maxProposals, () => false);
782
498
  const surfaced = [
783
- ...directives.kept.map((d) => ({ key: d.key, lastSeen: d.lastSeen, covered: d.status !== "new" })),
784
- ...fixes.kept.map((f) => ({ key: f.key, lastSeen: f.lastSeen, covered: false })),
785
- ...workflows.kept.map((w) => ({ key: w.key, lastSeen: w.lastSeen, covered: false })),
499
+ // Directives carry their wording forward so a later `/learn stats` can ask
500
+ // about coverage using the sentence rather than the slug that names it.
501
+ ...keptDirectives.kept.map((d) => ({
502
+ key: d.key,
503
+ lastSeen: d.lastSeen,
504
+ covered: d.status !== "new",
505
+ text: d.text,
506
+ })),
507
+ ...keptFixes.kept.map((f) => ({ key: f.key, lastSeen: f.lastSeen, covered: false })),
508
+ ...keptWorkflows.kept.map((w) => ({ key: w.key, lastSeen: w.lastSeen, covered: false })),
786
509
  ];
787
510
  return {
788
511
  scannedSessions: sessions.length,
789
512
  skippedSessions: skipped,
790
513
  scan,
514
+ mining: report,
515
+ aborted,
791
516
  oldestSession: timestamps[0],
792
517
  newestSession: timestamps[timestamps.length - 1],
793
518
  agentsFilePath,
794
519
  agentsFileTokens: agentsContent === undefined ? undefined : Math.round(Buffer.byteLength(agentsContent, "utf-8") / 4),
795
- directives: directives.kept,
796
- fixes: fixes.kept,
797
- workflows: workflows.kept,
798
- suppressed: directives.suppressed + fixes.suppressed + workflows.suppressed,
520
+ directives: keptDirectives.kept,
521
+ fixes: keptFixes.kept,
522
+ workflows: keptWorkflows.kept,
523
+ suppressed: keptDirectives.suppressed + keptFixes.suppressed + keptWorkflows.suppressed,
799
524
  surfaced,
800
525
  };
801
526
  }