@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,75 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.18] - 2026-08-15
4
+
5
+ ### Changed
6
+
7
+ - Every surface that shows measurable progress now uses one progress bar. The footer (tool
8
+ downloads, the semantic index, `/learn`) and the voice panel each had their
9
+ own, written months apart, and they had drifted three ways: one drew `▰▱` and
10
+ the other `·` over `·`, one said `2.0 MB` and the other `2 MB`, and each
11
+ carried its own copy of the percent-and-detail layout. Bar width is still per
12
+ surface — a footer line shares its row, a panel has room to be finer — but
13
+ nothing else is.
14
+ - Progress bars distinguish filled from empty by shape (`▰▱`) rather than by
15
+ colour alone, matching the context gauge beside them. The previous `·`-over-`·`
16
+ fill put the entire reading in the colour, so at a glance, on a low-contrast
17
+ theme, or anywhere styling is dropped, 10% and 90% looked identical. A started
18
+ bar also keeps one filled cell instead of rounding down to empty, so the first
19
+ of forty files reads differently from none of them.
20
+ - `/learn` now reads session transcripts with a model instead of pre-filtering
21
+ them with regexes. The old extractor only considered user turns matching a
22
+ whitelist of imperative words, so anything phrased another way — "we're on bun
23
+ now", "that's not how our error handling works", a constraint stated once in
24
+ passing — was not ranked low, it was invisible. Length and line-count caps
25
+ dropped long explanations too, which is where the reasoning behind a rule
26
+ usually is. Every user turn now goes to the model whole.
27
+ - Grouping is semantic rather than lexical. The model labels each occurrence
28
+ with what was *meant*, so "we're on bun now" and "stop using npm" count as one
29
+ recurring point instead of two unrelated ones. Counting still happens in code:
30
+ a model asked to count across a long context is approximately right, and the
31
+ count is what the digest is for.
32
+ - Coverage — whether a proposal is already written down — is a model judgement
33
+ instead of word overlap. The old 0.6-overlap test both called unrelated rules a
34
+ match (marking a working rule `restated`) and missed real paraphrases that
35
+ picked different vocabulary (proposing a duplicate).
36
+ - Mining results are cached per session file, keyed on content hash, so each
37
+ transcript is read once in its life. Counts are still recomputed over every
38
+ session in the window on every run, so caching the expensive step never costs
39
+ the cross-session evidence. A run reports what it read versus reused, and asks
40
+ before reading more than a few new sessions.
41
+ - The digest names the mode it ran in, so "nothing new since last time" and
42
+ "nothing here at all" no longer read alike.
43
+ - `/learn`'s per-directory memory is discarded once on upgrade. Its keys used to
44
+ be normalized directive text and are now the miner's semantic label, so old
45
+ entries could never match a new proposal — harmless for suppression, but every
46
+ one of them would have counted in `/learn stats` as a proposal that was never
47
+ adopted, holding the rate down permanently. The cost of discarding is one round
48
+ of re-proposing.
49
+
50
+ ### Added
51
+
52
+ - `/learn` shows a progress bar in the footer while it reads transcripts — the
53
+ same one the semantic index uses — with the count of sessions done, how many
54
+ came from cache, and the reminder that escape stops it. Cached sessions count
55
+ as done: the bar measures progress through the window, so a mostly-cached run
56
+ looks nearly finished from the start, which it is.
57
+ - A long backfill can be stopped with escape. Everything read up to that point is
58
+ already cached, so resuming picks up where it left off. A stopped run neither
59
+ shows nor records its proposals: it counted only part of the window, so its
60
+ numbers are low, and bookmarking them would hide those items on the next
61
+ complete run.
62
+ - `/learn` reads transcripts with the `fast` model category — the same tier
63
+ subagents already use for bulk reads — rather than the session's model. Set
64
+ `modelCategories.fast` to change it; left unset it is derived from the models
65
+ you have, so nothing here is provider-specific. `/learn settings` names the
66
+ model it resolved and how much it sends per call.
67
+ - Transcripts are chunked to fit the reading model's context window instead of a
68
+ fixed 120k characters. Rendering already compresses the two real transcripts in
69
+ this repo from 0.93 MB and 2.26 MB to roughly 47k and 68k tokens, so on a
70
+ 200k-token model each is now a single call rather than two and three. Fewer
71
+ boundaries also means fewer blind spots: a failure and the fix that resolved it
72
+ can otherwise land on opposite sides of one.
3
73
  ## [0.5.17] - 2026-08-14
4
74
 
5
75
  ### Fixed
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Per-session memo of the miner's output, keyed on file content.
3
+ *
4
+ * This is what makes an LLM-read-everything pipeline affordable. A closed
5
+ * session transcript never changes again, so the model needs to read it exactly
6
+ * once in its life. Hash the bytes, keep the candidates, and a routine `/learn`
7
+ * pays for the one or two sessions written since the last run while the other
8
+ * eighteen come back for free.
9
+ *
10
+ * It also happens to be the right answer to "incremental vs. full history",
11
+ * which an earlier design tried to solve with an mtime cursor. A cursor breaks
12
+ * the counting: if a run only *reads* sessions newer than the cursor, a
13
+ * directive said once today has a count of one, because the four earlier
14
+ * occurrences were never in the scan. Caching moves the skipping to the
15
+ * expensive step only — the reduce step still runs over every cached session
16
+ * every time, so the cross-session counts stay exact no matter how little was
17
+ * mined this run.
18
+ *
19
+ * The cache is disposable. Deleting it costs one re-mine and nothing else, so
20
+ * every failure path here degrades to "mine it again" rather than to an error.
21
+ */
22
+ import type { MinedCandidate } from "./mine.js";
23
+ export interface CachedMining {
24
+ /** Session identity, carried so a cache hit does not need the transcript reparsed. */
25
+ sessionId: string;
26
+ /** Session start time, ISO. */
27
+ timestamp: string;
28
+ candidates: MinedCandidate[];
29
+ /** When this entry was written, ISO. */
30
+ minedAt: string;
31
+ }
32
+ export declare function getLearnCacheDir(agentDir: string): string;
33
+ /**
34
+ * Content hash of a session file.
35
+ *
36
+ * Content, not mtime: a resumed session gets a fresh mtime with identical
37
+ * bytes, and a file copied between machines gets a new mtime too. Both would
38
+ * force a needless re-mine. Content also makes the reverse mistake impossible —
39
+ * a file whose bytes changed always misses the cache, which matters because the
40
+ * live session is appended to between runs.
41
+ */
42
+ export declare function hashSessionFile(file: string): string | undefined;
43
+ /** Look up a previously mined session. Any unreadable entry reads as a miss. */
44
+ export declare function readCachedMining(agentDir: string, hash: string): CachedMining | undefined;
45
+ /** Store a mined session. Failing to cache is never worth failing the run over. */
46
+ export declare function writeCachedMining(agentDir: string, hash: string, entry: CachedMining): void;
47
+ /**
48
+ * Drop entries nothing has referenced in a long time, so a machine that has
49
+ * been running this for a year does not keep every session it ever saw.
50
+ */
51
+ export declare function pruneLearnCache(agentDir: string, now?: Date): void;
52
+ /**
53
+ * Counting what a run still owes the model lives in `extract.ts:planMining`,
54
+ * not here: the answer depends on which sessions the window actually selects,
55
+ * and duplicating that selection is how the confirmation prompt ends up
56
+ * quoting a number the run does not honour.
57
+ */
58
+ //# sourceMappingURL=cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../../src/core/learn/cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAMH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAchD,MAAM,WAAW,YAAY;IAC5B,sFAAsF;IACtF,SAAS,EAAE,MAAM,CAAC;IAClB,+BAA+B;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,wCAAwC;IACxC,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMhE;AAMD,gFAAgF;AAChF,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAezF;AAED,mFAAmF;AACnF,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,GAAG,IAAI,CAO3F;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,GAAE,IAAiB,GAAG,IAAI,CAgB9E;AAED;;;;;GAKG","sourcesContent":["/**\n * Per-session memo of the miner's output, keyed on file content.\n *\n * This is what makes an LLM-read-everything pipeline affordable. A closed\n * session transcript never changes again, so the model needs to read it exactly\n * once in its life. Hash the bytes, keep the candidates, and a routine `/learn`\n * pays for the one or two sessions written since the last run while the other\n * eighteen come back for free.\n *\n * It also happens to be the right answer to \"incremental vs. full history\",\n * which an earlier design tried to solve with an mtime cursor. A cursor breaks\n * the counting: if a run only *reads* sessions newer than the cursor, a\n * directive said once today has a count of one, because the four earlier\n * occurrences were never in the scan. Caching moves the skipping to the\n * expensive step only — the reduce step still runs over every cached session\n * every time, so the cross-session counts stay exact no matter how little was\n * mined this run.\n *\n * The cache is disposable. Deleting it costs one re-mine and nothing else, so\n * every failure path here degrades to \"mine it again\" rather than to an error.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { writeFileAtomicSync } from \"../../utils/atomic-file.js\";\nimport type { MinedCandidate } from \"./mine.js\";\n\n/**\n * Bump when the miner prompt or the candidate shape changes.\n *\n * The version is part of the cache key, not a field inside the entry, so a bump\n * invalidates every entry at once without a migration or a sweep — old files\n * simply stop being looked up, and the pruner reclaims them on age.\n */\nconst CACHE_VERSION = 1;\n\n/** Entries untouched for this long are reclaimed. */\nconst CACHE_RETENTION_DAYS = 180;\n\nexport interface CachedMining {\n\t/** Session identity, carried so a cache hit does not need the transcript reparsed. */\n\tsessionId: string;\n\t/** Session start time, ISO. */\n\ttimestamp: string;\n\tcandidates: MinedCandidate[];\n\t/** When this entry was written, ISO. */\n\tminedAt: string;\n}\n\nexport function getLearnCacheDir(agentDir: string): string {\n\treturn join(agentDir, \"learn\", \"cache\");\n}\n\n/**\n * Content hash of a session file.\n *\n * Content, not mtime: a resumed session gets a fresh mtime with identical\n * bytes, and a file copied between machines gets a new mtime too. Both would\n * force a needless re-mine. Content also makes the reverse mistake impossible —\n * a file whose bytes changed always misses the cache, which matters because the\n * live session is appended to between runs.\n */\nexport function hashSessionFile(file: string): string | undefined {\n\ttry {\n\t\treturn createHash(\"sha256\").update(readFileSync(file)).digest(\"hex\").slice(0, 32);\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nfunction entryPath(agentDir: string, hash: string): string {\n\treturn join(getLearnCacheDir(agentDir), `v${CACHE_VERSION}-${hash}.json`);\n}\n\n/** Look up a previously mined session. Any unreadable entry reads as a miss. */\nexport function readCachedMining(agentDir: string, hash: string): CachedMining | undefined {\n\tconst path = entryPath(agentDir, hash);\n\ttry {\n\t\tif (!existsSync(path)) return undefined;\n\t\tconst parsed = JSON.parse(readFileSync(path, \"utf-8\")) as Partial<CachedMining>;\n\t\tif (!Array.isArray(parsed.candidates) || typeof parsed.sessionId !== \"string\") return undefined;\n\t\treturn {\n\t\t\tsessionId: parsed.sessionId,\n\t\t\ttimestamp: typeof parsed.timestamp === \"string\" ? parsed.timestamp : new Date(0).toISOString(),\n\t\t\tcandidates: parsed.candidates as MinedCandidate[],\n\t\t\tminedAt: typeof parsed.minedAt === \"string\" ? parsed.minedAt : new Date(0).toISOString(),\n\t\t};\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/** Store a mined session. Failing to cache is never worth failing the run over. */\nexport function writeCachedMining(agentDir: string, hash: string, entry: CachedMining): void {\n\ttry {\n\t\tmkdirSync(getLearnCacheDir(agentDir), { recursive: true });\n\t\twriteFileAtomicSync(entryPath(agentDir, hash), `${JSON.stringify(entry, null, 2)}\\n`);\n\t} catch {\n\t\t// The cost is re-mining this session next run.\n\t}\n}\n\n/**\n * Drop entries nothing has referenced in a long time, so a machine that has\n * been running this for a year does not keep every session it ever saw.\n */\nexport function pruneLearnCache(agentDir: string, now: Date = new Date()): void {\n\tconst dir = getLearnCacheDir(agentDir);\n\tif (!existsSync(dir)) return;\n\tconst cutoff = now.getTime() - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000;\n\ttry {\n\t\tfor (const name of readdirSync(dir)) {\n\t\t\tconst path = join(dir, name);\n\t\t\ttry {\n\t\t\t\tif (statSync(path).mtime.getTime() < cutoff) rmSync(path, { force: true });\n\t\t\t} catch {\n\t\t\t\t// Concurrent run reclaimed it first.\n\t\t\t}\n\t\t}\n\t} catch {\n\t\t// An unreadable cache directory is not an error worth surfacing.\n\t}\n}\n\n/**\n * Counting what a run still owes the model lives in `extract.ts:planMining`,\n * not here: the answer depends on which sessions the window actually selects,\n * and duplicating that selection is how the confirmation prompt ends up\n * quoting a number the run does not honour.\n */\n"]}
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Per-session memo of the miner's output, keyed on file content.
3
+ *
4
+ * This is what makes an LLM-read-everything pipeline affordable. A closed
5
+ * session transcript never changes again, so the model needs to read it exactly
6
+ * once in its life. Hash the bytes, keep the candidates, and a routine `/learn`
7
+ * pays for the one or two sessions written since the last run while the other
8
+ * eighteen come back for free.
9
+ *
10
+ * It also happens to be the right answer to "incremental vs. full history",
11
+ * which an earlier design tried to solve with an mtime cursor. A cursor breaks
12
+ * the counting: if a run only *reads* sessions newer than the cursor, a
13
+ * directive said once today has a count of one, because the four earlier
14
+ * occurrences were never in the scan. Caching moves the skipping to the
15
+ * expensive step only — the reduce step still runs over every cached session
16
+ * every time, so the cross-session counts stay exact no matter how little was
17
+ * mined this run.
18
+ *
19
+ * The cache is disposable. Deleting it costs one re-mine and nothing else, so
20
+ * every failure path here degrades to "mine it again" rather than to an error.
21
+ */
22
+ import { createHash } from "node:crypto";
23
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { writeFileAtomicSync } from "../../utils/atomic-file.js";
26
+ /**
27
+ * Bump when the miner prompt or the candidate shape changes.
28
+ *
29
+ * The version is part of the cache key, not a field inside the entry, so a bump
30
+ * invalidates every entry at once without a migration or a sweep — old files
31
+ * simply stop being looked up, and the pruner reclaims them on age.
32
+ */
33
+ const CACHE_VERSION = 1;
34
+ /** Entries untouched for this long are reclaimed. */
35
+ const CACHE_RETENTION_DAYS = 180;
36
+ export function getLearnCacheDir(agentDir) {
37
+ return join(agentDir, "learn", "cache");
38
+ }
39
+ /**
40
+ * Content hash of a session file.
41
+ *
42
+ * Content, not mtime: a resumed session gets a fresh mtime with identical
43
+ * bytes, and a file copied between machines gets a new mtime too. Both would
44
+ * force a needless re-mine. Content also makes the reverse mistake impossible —
45
+ * a file whose bytes changed always misses the cache, which matters because the
46
+ * live session is appended to between runs.
47
+ */
48
+ export function hashSessionFile(file) {
49
+ try {
50
+ return createHash("sha256").update(readFileSync(file)).digest("hex").slice(0, 32);
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ }
56
+ function entryPath(agentDir, hash) {
57
+ return join(getLearnCacheDir(agentDir), `v${CACHE_VERSION}-${hash}.json`);
58
+ }
59
+ /** Look up a previously mined session. Any unreadable entry reads as a miss. */
60
+ export function readCachedMining(agentDir, hash) {
61
+ const path = entryPath(agentDir, hash);
62
+ try {
63
+ if (!existsSync(path))
64
+ return undefined;
65
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
66
+ if (!Array.isArray(parsed.candidates) || typeof parsed.sessionId !== "string")
67
+ return undefined;
68
+ return {
69
+ sessionId: parsed.sessionId,
70
+ timestamp: typeof parsed.timestamp === "string" ? parsed.timestamp : new Date(0).toISOString(),
71
+ candidates: parsed.candidates,
72
+ minedAt: typeof parsed.minedAt === "string" ? parsed.minedAt : new Date(0).toISOString(),
73
+ };
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ }
79
+ /** Store a mined session. Failing to cache is never worth failing the run over. */
80
+ export function writeCachedMining(agentDir, hash, entry) {
81
+ try {
82
+ mkdirSync(getLearnCacheDir(agentDir), { recursive: true });
83
+ writeFileAtomicSync(entryPath(agentDir, hash), `${JSON.stringify(entry, null, 2)}\n`);
84
+ }
85
+ catch {
86
+ // The cost is re-mining this session next run.
87
+ }
88
+ }
89
+ /**
90
+ * Drop entries nothing has referenced in a long time, so a machine that has
91
+ * been running this for a year does not keep every session it ever saw.
92
+ */
93
+ export function pruneLearnCache(agentDir, now = new Date()) {
94
+ const dir = getLearnCacheDir(agentDir);
95
+ if (!existsSync(dir))
96
+ return;
97
+ const cutoff = now.getTime() - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000;
98
+ try {
99
+ for (const name of readdirSync(dir)) {
100
+ const path = join(dir, name);
101
+ try {
102
+ if (statSync(path).mtime.getTime() < cutoff)
103
+ rmSync(path, { force: true });
104
+ }
105
+ catch {
106
+ // Concurrent run reclaimed it first.
107
+ }
108
+ }
109
+ }
110
+ catch {
111
+ // An unreadable cache directory is not an error worth surfacing.
112
+ }
113
+ }
114
+ /**
115
+ * Counting what a run still owes the model lives in `extract.ts:planMining`,
116
+ * not here: the answer depends on which sessions the window actually selects,
117
+ * and duplicating that selection is how the confirmation prompt ends up
118
+ * quoting a number the run does not honour.
119
+ */
120
+ //# sourceMappingURL=cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.js","sourceRoot":"","sources":["../../../src/core/learn/cache.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Per-session memo of the miner's output, keyed on file content.\n *\n * This is what makes an LLM-read-everything pipeline affordable. A closed\n * session transcript never changes again, so the model needs to read it exactly\n * once in its life. Hash the bytes, keep the candidates, and a routine `/learn`\n * pays for the one or two sessions written since the last run while the other\n * eighteen come back for free.\n *\n * It also happens to be the right answer to \"incremental vs. full history\",\n * which an earlier design tried to solve with an mtime cursor. A cursor breaks\n * the counting: if a run only *reads* sessions newer than the cursor, a\n * directive said once today has a count of one, because the four earlier\n * occurrences were never in the scan. Caching moves the skipping to the\n * expensive step only — the reduce step still runs over every cached session\n * every time, so the cross-session counts stay exact no matter how little was\n * mined this run.\n *\n * The cache is disposable. Deleting it costs one re-mine and nothing else, so\n * every failure path here degrades to \"mine it again\" rather than to an error.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { writeFileAtomicSync } from \"../../utils/atomic-file.js\";\nimport type { MinedCandidate } from \"./mine.js\";\n\n/**\n * Bump when the miner prompt or the candidate shape changes.\n *\n * The version is part of the cache key, not a field inside the entry, so a bump\n * invalidates every entry at once without a migration or a sweep — old files\n * simply stop being looked up, and the pruner reclaims them on age.\n */\nconst CACHE_VERSION = 1;\n\n/** Entries untouched for this long are reclaimed. */\nconst CACHE_RETENTION_DAYS = 180;\n\nexport interface CachedMining {\n\t/** Session identity, carried so a cache hit does not need the transcript reparsed. */\n\tsessionId: string;\n\t/** Session start time, ISO. */\n\ttimestamp: string;\n\tcandidates: MinedCandidate[];\n\t/** When this entry was written, ISO. */\n\tminedAt: string;\n}\n\nexport function getLearnCacheDir(agentDir: string): string {\n\treturn join(agentDir, \"learn\", \"cache\");\n}\n\n/**\n * Content hash of a session file.\n *\n * Content, not mtime: a resumed session gets a fresh mtime with identical\n * bytes, and a file copied between machines gets a new mtime too. Both would\n * force a needless re-mine. Content also makes the reverse mistake impossible —\n * a file whose bytes changed always misses the cache, which matters because the\n * live session is appended to between runs.\n */\nexport function hashSessionFile(file: string): string | undefined {\n\ttry {\n\t\treturn createHash(\"sha256\").update(readFileSync(file)).digest(\"hex\").slice(0, 32);\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nfunction entryPath(agentDir: string, hash: string): string {\n\treturn join(getLearnCacheDir(agentDir), `v${CACHE_VERSION}-${hash}.json`);\n}\n\n/** Look up a previously mined session. Any unreadable entry reads as a miss. */\nexport function readCachedMining(agentDir: string, hash: string): CachedMining | undefined {\n\tconst path = entryPath(agentDir, hash);\n\ttry {\n\t\tif (!existsSync(path)) return undefined;\n\t\tconst parsed = JSON.parse(readFileSync(path, \"utf-8\")) as Partial<CachedMining>;\n\t\tif (!Array.isArray(parsed.candidates) || typeof parsed.sessionId !== \"string\") return undefined;\n\t\treturn {\n\t\t\tsessionId: parsed.sessionId,\n\t\t\ttimestamp: typeof parsed.timestamp === \"string\" ? parsed.timestamp : new Date(0).toISOString(),\n\t\t\tcandidates: parsed.candidates as MinedCandidate[],\n\t\t\tminedAt: typeof parsed.minedAt === \"string\" ? parsed.minedAt : new Date(0).toISOString(),\n\t\t};\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/** Store a mined session. Failing to cache is never worth failing the run over. */\nexport function writeCachedMining(agentDir: string, hash: string, entry: CachedMining): void {\n\ttry {\n\t\tmkdirSync(getLearnCacheDir(agentDir), { recursive: true });\n\t\twriteFileAtomicSync(entryPath(agentDir, hash), `${JSON.stringify(entry, null, 2)}\\n`);\n\t} catch {\n\t\t// The cost is re-mining this session next run.\n\t}\n}\n\n/**\n * Drop entries nothing has referenced in a long time, so a machine that has\n * been running this for a year does not keep every session it ever saw.\n */\nexport function pruneLearnCache(agentDir: string, now: Date = new Date()): void {\n\tconst dir = getLearnCacheDir(agentDir);\n\tif (!existsSync(dir)) return;\n\tconst cutoff = now.getTime() - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000;\n\ttry {\n\t\tfor (const name of readdirSync(dir)) {\n\t\t\tconst path = join(dir, name);\n\t\t\ttry {\n\t\t\t\tif (statSync(path).mtime.getTime() < cutoff) rmSync(path, { force: true });\n\t\t\t} catch {\n\t\t\t\t// Concurrent run reclaimed it first.\n\t\t\t}\n\t\t}\n\t} catch {\n\t\t// An unreadable cache directory is not an error worth surfacing.\n\t}\n}\n\n/**\n * Counting what a run still owes the model lives in `extract.ts:planMining`,\n * not here: the answer depends on which sessions the window actually selects,\n * and duplicating that selection is how the confirmation prompt ends up\n * quoting a number the run does not honour.\n */\n"]}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Is this already written down?
3
+ *
4
+ * The answer decides the most useful distinction the digest makes — `new` vs
5
+ * `restated` vs `has-skill` — and it used to be decided by bag-of-words
6
+ * overlap: count how many content words of the proposal appear anywhere in a
7
+ * rule line, call it covered above 0.6. That is wrong in both directions and
8
+ * for the same reason, namely that it does not read. It calls "always run tests
9
+ * before pushing" covered by a line about "running the test suite in CI", and
10
+ * it misses a real paraphrase that happens to pick different vocabulary.
11
+ *
12
+ * Both mistakes are expensive. A false `restated` accuses a rule that is
13
+ * working of not working, and tells the reader to rewrite something fine. A
14
+ * false `new` proposes a rule they already have, which is how a context file
15
+ * grows duplicates.
16
+ *
17
+ * So a model reads the rules and the proposals together and matches them. One
18
+ * call for the whole batch, because the question is small and the corpus is the
19
+ * same for every item — the context file is a few thousand tokens and does not
20
+ * want re-sending once per proposal.
21
+ */
22
+ import type { Model } from "@kolisachint/hoocode-ai";
23
+ export interface CoverageIndex {
24
+ /** Candidate rule lines from the repo context file and both user scopes. */
25
+ ruleLines: string[];
26
+ skills: Array<{
27
+ name: string;
28
+ description: string;
29
+ }>;
30
+ }
31
+ export interface CoverageMatch {
32
+ /** The context-file line that covers this, if any. */
33
+ rule?: string;
34
+ /** The skill that covers this, if any. Only set when no rule matched. */
35
+ skill?: string;
36
+ }
37
+ /** One thing to look up, identified by the label the reduce step grouped on. */
38
+ export interface CoverageQuery {
39
+ label: string;
40
+ text: string;
41
+ }
42
+ /**
43
+ * Decide coverage for a batch. Injectable so the pipeline can be tested without
44
+ * a model, and so a run with no model configured can degrade to "everything is
45
+ * new" rather than failing.
46
+ */
47
+ export type CoverageJudge = (queries: CoverageQuery[], index: CoverageIndex, signal?: AbortSignal) => Promise<Map<string, CoverageMatch>>;
48
+ /** Read the verdict list back, ignoring anything malformed rather than failing the run. */
49
+ export declare function parseVerdicts(response: string, queries: CoverageQuery[], index: CoverageIndex): Map<string, CoverageMatch>;
50
+ export interface CoverageDeps {
51
+ model: Model<any>;
52
+ apiKey?: string;
53
+ headers?: Record<string, string>;
54
+ }
55
+ export declare function createLlmCoverageJudge(deps: CoverageDeps): CoverageJudge;
56
+ /** Everything is new. Used when no model is available, so the run still produces a digest. */
57
+ export declare const noCoverageJudge: CoverageJudge;
58
+ //# sourceMappingURL=coverage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coverage.d.ts","sourceRoot":"","sources":["../../../src/core/learn/coverage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAC;AAGrD,MAAM,WAAW,aAAa;IAC7B,4EAA4E;IAC5E,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACrD;AAED,MAAM,WAAW,aAAa;IAC7B,sDAAsD;IACtD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,gFAAgF;AAChF,MAAM,WAAW,aAAa;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACb;AAED;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,CAC3B,OAAO,EAAE,aAAa,EAAE,EACxB,KAAK,EAAE,aAAa,EACpB,MAAM,CAAC,EAAE,WAAW,KAChB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC;AAwDzC,2FAA2F;AAC3F,wBAAgB,aAAa,CAC5B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,aAAa,EAAE,EACxB,KAAK,EAAE,aAAa,GAClB,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAwC5B;AAED,MAAM,WAAW,YAAY;IAC5B,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,YAAY,GAAG,aAAa,CA6BxE;AAED,8FAA8F;AAC9F,eAAO,MAAM,eAAe,EAAE,aAAqC,CAAC","sourcesContent":["/**\n * Is this already written down?\n *\n * The answer decides the most useful distinction the digest makes — `new` vs\n * `restated` vs `has-skill` — and it used to be decided by bag-of-words\n * overlap: count how many content words of the proposal appear anywhere in a\n * rule line, call it covered above 0.6. That is wrong in both directions and\n * for the same reason, namely that it does not read. It calls \"always run tests\n * before pushing\" covered by a line about \"running the test suite in CI\", and\n * it misses a real paraphrase that happens to pick different vocabulary.\n *\n * Both mistakes are expensive. A false `restated` accuses a rule that is\n * working of not working, and tells the reader to rewrite something fine. A\n * false `new` proposes a rule they already have, which is how a context file\n * grows duplicates.\n *\n * So a model reads the rules and the proposals together and matches them. One\n * call for the whole batch, because the question is small and the corpus is the\n * same for every item — the context file is a few thousand tokens and does not\n * want re-sending once per proposal.\n */\n\nimport type { Model } from \"@kolisachint/hoocode-ai\";\nimport { completeSimple } from \"@kolisachint/hoocode-ai\";\n\nexport interface CoverageIndex {\n\t/** Candidate rule lines from the repo context file and both user scopes. */\n\truleLines: string[];\n\tskills: Array<{ name: string; description: string }>;\n}\n\nexport interface CoverageMatch {\n\t/** The context-file line that covers this, if any. */\n\trule?: string;\n\t/** The skill that covers this, if any. Only set when no rule matched. */\n\tskill?: string;\n}\n\n/** One thing to look up, identified by the label the reduce step grouped on. */\nexport interface CoverageQuery {\n\tlabel: string;\n\ttext: string;\n}\n\n/**\n * Decide coverage for a batch. Injectable so the pipeline can be tested without\n * a model, and so a run with no model configured can degrade to \"everything is\n * new\" rather than failing.\n */\nexport type CoverageJudge = (\n\tqueries: CoverageQuery[],\n\tindex: CoverageIndex,\n\tsignal?: AbortSignal,\n) => Promise<Map<string, CoverageMatch>>;\n\n/** Rule lines sent per call. A context file longer than this is already the problem. */\nconst MAX_RULE_LINES = 400;\n/** Skills sent per call. */\nconst MAX_SKILLS = 120;\n/** Description characters per skill — the opening says what it does; the rest is trigger bait. */\nconst SKILL_DESCRIPTION_CHARS = 300;\nconst MAX_RESPONSE_TOKENS = 2_000;\n\nconst COVERAGE_SYSTEM_PROMPT = `You decide whether each proposed rule is ALREADY covered by existing project rules or skills.\n\nYou are given numbered RULES (lines from context files), numbered SKILLS (name and description), and numbered PROPOSALS.\n\nFor each proposal, decide:\n- \"rule\" — an existing rule already says this. The reader repeating it means that rule is not working, so it should be rewritten rather than duplicated.\n- \"skill\" — an existing skill already does this, and the reader asked by hand anyway. Usually the skill's description does not describe the situation they were in.\n- \"new\" — nothing covers it.\n\nJudge by MEANING, not by shared words. Different vocabulary for the same instruction is covered. Shared vocabulary about different things is NOT covered:\n- proposal \"always use bun, never npm\" vs rule \"install dependencies with bun\" → covered (rule)\n- proposal \"run tests before pushing\" vs rule \"CI runs the test suite on every PR\" → NOT covered, these are different instructions to different actors\n- proposal \"prefer table output\" vs rule \"use tables in documentation\" → NOT covered unless the scope matches\n\nPrefer \"new\" when genuinely unsure. A false \"covered\" tells the reader to rewrite a rule that is fine; a false \"new\" merely proposes something they can reject.\n\nRules win over skills when both match: rewriting a line is more actionable than sharpening a description.\n\nOutput STRICT JSON, no markdown fence, no prose. Use the proposal's exact label:\n{\"verdicts\":[{\"label\":\"use-bun-not-npm\",\"verdict\":\"rule\",\"ruleIndex\":3},{\"label\":\"scaffold-route\",\"verdict\":\"skill\",\"skillIndex\":1},{\"label\":\"prefer-tables\",\"verdict\":\"new\"}]}`;\n\nfunction buildPrompt(queries: CoverageQuery[], index: CoverageIndex): string {\n\tconst lines: string[] = [];\n\n\tlines.push(\"RULES:\");\n\tconst rules = index.ruleLines.slice(0, MAX_RULE_LINES);\n\tif (rules.length === 0) lines.push(\"(none)\");\n\tfor (const [i, rule] of rules.entries()) {\n\t\tlines.push(`${i}. ${rule}`);\n\t}\n\n\tlines.push(\"\", \"SKILLS:\");\n\tconst skills = index.skills.slice(0, MAX_SKILLS);\n\tif (skills.length === 0) lines.push(\"(none)\");\n\tfor (const [i, skill] of skills.entries()) {\n\t\tlines.push(`${i}. ${skill.name} — ${skill.description.slice(0, SKILL_DESCRIPTION_CHARS)}`);\n\t}\n\n\tlines.push(\"\", \"PROPOSALS:\");\n\tfor (const query of queries) {\n\t\tlines.push(`- label: ${query.label}\\n text: ${query.text}`);\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\n/** Read the verdict list back, ignoring anything malformed rather than failing the run. */\nexport function parseVerdicts(\n\tresponse: string,\n\tqueries: CoverageQuery[],\n\tindex: CoverageIndex,\n): Map<string, CoverageMatch> {\n\tconst out = new Map<string, CoverageMatch>();\n\tconst start = response.indexOf(\"{\");\n\tconst end = response.lastIndexOf(\"}\");\n\tif (start < 0 || end <= start) return out;\n\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(response.slice(start, end + 1));\n\t} catch {\n\t\treturn out;\n\t}\n\n\tconst raw = (parsed as { verdicts?: unknown })?.verdicts;\n\tif (!Array.isArray(raw)) return out;\n\n\tconst known = new Set(queries.map((q) => q.label));\n\tfor (const item of raw) {\n\t\tif (!item || typeof item !== \"object\") continue;\n\t\tconst verdict = item as Record<string, unknown>;\n\t\tconst label = typeof verdict.label === \"string\" ? verdict.label.trim().toLowerCase() : \"\";\n\t\t// A label the batch did not ask about is a hallucinated row; dropping it is\n\t\t// safer than letting it mark some other proposal covered.\n\t\tif (!label || !known.has(label)) continue;\n\n\t\tif (verdict.verdict === \"rule\") {\n\t\t\tconst at = typeof verdict.ruleIndex === \"number\" ? index.ruleLines[verdict.ruleIndex] : undefined;\n\t\t\t// An out-of-range index means the model decided \"covered\" but cannot show\n\t\t\t// which line. Treat that as `new`: the reader cannot act on an unnamed rule.\n\t\t\tif (at) out.set(label, { rule: at });\n\t\t\tcontinue;\n\t\t}\n\t\tif (verdict.verdict === \"skill\") {\n\t\t\tconst at = typeof verdict.skillIndex === \"number\" ? index.skills[verdict.skillIndex] : undefined;\n\t\t\tif (at) out.set(label, { skill: at.name });\n\t\t\tcontinue;\n\t\t}\n\t\tout.set(label, {});\n\t}\n\treturn out;\n}\n\nexport interface CoverageDeps {\n\tmodel: Model<any>;\n\tapiKey?: string;\n\theaders?: Record<string, string>;\n}\n\nexport function createLlmCoverageJudge(deps: CoverageDeps): CoverageJudge {\n\treturn async (queries, index, signal) => {\n\t\t// Nothing to match against means nothing can be covered, and the call would\n\t\t// be pure cost.\n\t\tif (queries.length === 0 || (index.ruleLines.length === 0 && index.skills.length === 0)) {\n\t\t\treturn new Map();\n\t\t}\n\n\t\tconst response = await completeSimple(\n\t\t\tdeps.model,\n\t\t\t{\n\t\t\t\tsystemPrompt: COVERAGE_SYSTEM_PROMPT,\n\t\t\t\tmessages: [\n\t\t\t\t\t{ role: \"user\", content: [{ type: \"text\", text: buildPrompt(queries, index) }], timestamp: Date.now() },\n\t\t\t\t],\n\t\t\t},\n\t\t\t{ maxTokens: MAX_RESPONSE_TOKENS, signal, apiKey: deps.apiKey, headers: deps.headers },\n\t\t);\n\n\t\tif (response.stopReason === \"error\") {\n\t\t\tthrow new Error(response.errorMessage || \"coverage call failed\");\n\t\t}\n\n\t\tconst text = response.content\n\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t.map((c) => c.text)\n\t\t\t.join(\"\\n\");\n\t\treturn parseVerdicts(text, queries, index);\n\t};\n}\n\n/** Everything is new. Used when no model is available, so the run still produces a digest. */\nexport const noCoverageJudge: CoverageJudge = async () => new Map();\n"]}
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Is this already written down?
3
+ *
4
+ * The answer decides the most useful distinction the digest makes — `new` vs
5
+ * `restated` vs `has-skill` — and it used to be decided by bag-of-words
6
+ * overlap: count how many content words of the proposal appear anywhere in a
7
+ * rule line, call it covered above 0.6. That is wrong in both directions and
8
+ * for the same reason, namely that it does not read. It calls "always run tests
9
+ * before pushing" covered by a line about "running the test suite in CI", and
10
+ * it misses a real paraphrase that happens to pick different vocabulary.
11
+ *
12
+ * Both mistakes are expensive. A false `restated` accuses a rule that is
13
+ * working of not working, and tells the reader to rewrite something fine. A
14
+ * false `new` proposes a rule they already have, which is how a context file
15
+ * grows duplicates.
16
+ *
17
+ * So a model reads the rules and the proposals together and matches them. One
18
+ * call for the whole batch, because the question is small and the corpus is the
19
+ * same for every item — the context file is a few thousand tokens and does not
20
+ * want re-sending once per proposal.
21
+ */
22
+ import { completeSimple } from "@kolisachint/hoocode-ai";
23
+ /** Rule lines sent per call. A context file longer than this is already the problem. */
24
+ const MAX_RULE_LINES = 400;
25
+ /** Skills sent per call. */
26
+ const MAX_SKILLS = 120;
27
+ /** Description characters per skill — the opening says what it does; the rest is trigger bait. */
28
+ const SKILL_DESCRIPTION_CHARS = 300;
29
+ const MAX_RESPONSE_TOKENS = 2_000;
30
+ const COVERAGE_SYSTEM_PROMPT = `You decide whether each proposed rule is ALREADY covered by existing project rules or skills.
31
+
32
+ You are given numbered RULES (lines from context files), numbered SKILLS (name and description), and numbered PROPOSALS.
33
+
34
+ For each proposal, decide:
35
+ - "rule" — an existing rule already says this. The reader repeating it means that rule is not working, so it should be rewritten rather than duplicated.
36
+ - "skill" — an existing skill already does this, and the reader asked by hand anyway. Usually the skill's description does not describe the situation they were in.
37
+ - "new" — nothing covers it.
38
+
39
+ Judge by MEANING, not by shared words. Different vocabulary for the same instruction is covered. Shared vocabulary about different things is NOT covered:
40
+ - proposal "always use bun, never npm" vs rule "install dependencies with bun" → covered (rule)
41
+ - proposal "run tests before pushing" vs rule "CI runs the test suite on every PR" → NOT covered, these are different instructions to different actors
42
+ - proposal "prefer table output" vs rule "use tables in documentation" → NOT covered unless the scope matches
43
+
44
+ Prefer "new" when genuinely unsure. A false "covered" tells the reader to rewrite a rule that is fine; a false "new" merely proposes something they can reject.
45
+
46
+ Rules win over skills when both match: rewriting a line is more actionable than sharpening a description.
47
+
48
+ Output STRICT JSON, no markdown fence, no prose. Use the proposal's exact label:
49
+ {"verdicts":[{"label":"use-bun-not-npm","verdict":"rule","ruleIndex":3},{"label":"scaffold-route","verdict":"skill","skillIndex":1},{"label":"prefer-tables","verdict":"new"}]}`;
50
+ function buildPrompt(queries, index) {
51
+ const lines = [];
52
+ lines.push("RULES:");
53
+ const rules = index.ruleLines.slice(0, MAX_RULE_LINES);
54
+ if (rules.length === 0)
55
+ lines.push("(none)");
56
+ for (const [i, rule] of rules.entries()) {
57
+ lines.push(`${i}. ${rule}`);
58
+ }
59
+ lines.push("", "SKILLS:");
60
+ const skills = index.skills.slice(0, MAX_SKILLS);
61
+ if (skills.length === 0)
62
+ lines.push("(none)");
63
+ for (const [i, skill] of skills.entries()) {
64
+ lines.push(`${i}. ${skill.name} — ${skill.description.slice(0, SKILL_DESCRIPTION_CHARS)}`);
65
+ }
66
+ lines.push("", "PROPOSALS:");
67
+ for (const query of queries) {
68
+ lines.push(`- label: ${query.label}\n text: ${query.text}`);
69
+ }
70
+ return lines.join("\n");
71
+ }
72
+ /** Read the verdict list back, ignoring anything malformed rather than failing the run. */
73
+ export function parseVerdicts(response, queries, index) {
74
+ const out = new Map();
75
+ const start = response.indexOf("{");
76
+ const end = response.lastIndexOf("}");
77
+ if (start < 0 || end <= start)
78
+ return out;
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(response.slice(start, end + 1));
82
+ }
83
+ catch {
84
+ return out;
85
+ }
86
+ const raw = parsed?.verdicts;
87
+ if (!Array.isArray(raw))
88
+ return out;
89
+ const known = new Set(queries.map((q) => q.label));
90
+ for (const item of raw) {
91
+ if (!item || typeof item !== "object")
92
+ continue;
93
+ const verdict = item;
94
+ const label = typeof verdict.label === "string" ? verdict.label.trim().toLowerCase() : "";
95
+ // A label the batch did not ask about is a hallucinated row; dropping it is
96
+ // safer than letting it mark some other proposal covered.
97
+ if (!label || !known.has(label))
98
+ continue;
99
+ if (verdict.verdict === "rule") {
100
+ const at = typeof verdict.ruleIndex === "number" ? index.ruleLines[verdict.ruleIndex] : undefined;
101
+ // An out-of-range index means the model decided "covered" but cannot show
102
+ // which line. Treat that as `new`: the reader cannot act on an unnamed rule.
103
+ if (at)
104
+ out.set(label, { rule: at });
105
+ continue;
106
+ }
107
+ if (verdict.verdict === "skill") {
108
+ const at = typeof verdict.skillIndex === "number" ? index.skills[verdict.skillIndex] : undefined;
109
+ if (at)
110
+ out.set(label, { skill: at.name });
111
+ continue;
112
+ }
113
+ out.set(label, {});
114
+ }
115
+ return out;
116
+ }
117
+ export function createLlmCoverageJudge(deps) {
118
+ return async (queries, index, signal) => {
119
+ // Nothing to match against means nothing can be covered, and the call would
120
+ // be pure cost.
121
+ if (queries.length === 0 || (index.ruleLines.length === 0 && index.skills.length === 0)) {
122
+ return new Map();
123
+ }
124
+ const response = await completeSimple(deps.model, {
125
+ systemPrompt: COVERAGE_SYSTEM_PROMPT,
126
+ messages: [
127
+ { role: "user", content: [{ type: "text", text: buildPrompt(queries, index) }], timestamp: Date.now() },
128
+ ],
129
+ }, { maxTokens: MAX_RESPONSE_TOKENS, signal, apiKey: deps.apiKey, headers: deps.headers });
130
+ if (response.stopReason === "error") {
131
+ throw new Error(response.errorMessage || "coverage call failed");
132
+ }
133
+ const text = response.content
134
+ .filter((c) => c.type === "text")
135
+ .map((c) => c.text)
136
+ .join("\n");
137
+ return parseVerdicts(text, queries, index);
138
+ };
139
+ }
140
+ /** Everything is new. Used when no model is available, so the run still produces a digest. */
141
+ export const noCoverageJudge = async () => new Map();
142
+ //# sourceMappingURL=coverage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coverage.js","sourceRoot":"","sources":["../../../src/core/learn/coverage.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Is this already written down?\n *\n * The answer decides the most useful distinction the digest makes — `new` vs\n * `restated` vs `has-skill` — and it used to be decided by bag-of-words\n * overlap: count how many content words of the proposal appear anywhere in a\n * rule line, call it covered above 0.6. That is wrong in both directions and\n * for the same reason, namely that it does not read. It calls \"always run tests\n * before pushing\" covered by a line about \"running the test suite in CI\", and\n * it misses a real paraphrase that happens to pick different vocabulary.\n *\n * Both mistakes are expensive. A false `restated` accuses a rule that is\n * working of not working, and tells the reader to rewrite something fine. A\n * false `new` proposes a rule they already have, which is how a context file\n * grows duplicates.\n *\n * So a model reads the rules and the proposals together and matches them. One\n * call for the whole batch, because the question is small and the corpus is the\n * same for every item — the context file is a few thousand tokens and does not\n * want re-sending once per proposal.\n */\n\nimport type { Model } from \"@kolisachint/hoocode-ai\";\nimport { completeSimple } from \"@kolisachint/hoocode-ai\";\n\nexport interface CoverageIndex {\n\t/** Candidate rule lines from the repo context file and both user scopes. */\n\truleLines: string[];\n\tskills: Array<{ name: string; description: string }>;\n}\n\nexport interface CoverageMatch {\n\t/** The context-file line that covers this, if any. */\n\trule?: string;\n\t/** The skill that covers this, if any. Only set when no rule matched. */\n\tskill?: string;\n}\n\n/** One thing to look up, identified by the label the reduce step grouped on. */\nexport interface CoverageQuery {\n\tlabel: string;\n\ttext: string;\n}\n\n/**\n * Decide coverage for a batch. Injectable so the pipeline can be tested without\n * a model, and so a run with no model configured can degrade to \"everything is\n * new\" rather than failing.\n */\nexport type CoverageJudge = (\n\tqueries: CoverageQuery[],\n\tindex: CoverageIndex,\n\tsignal?: AbortSignal,\n) => Promise<Map<string, CoverageMatch>>;\n\n/** Rule lines sent per call. A context file longer than this is already the problem. */\nconst MAX_RULE_LINES = 400;\n/** Skills sent per call. */\nconst MAX_SKILLS = 120;\n/** Description characters per skill — the opening says what it does; the rest is trigger bait. */\nconst SKILL_DESCRIPTION_CHARS = 300;\nconst MAX_RESPONSE_TOKENS = 2_000;\n\nconst COVERAGE_SYSTEM_PROMPT = `You decide whether each proposed rule is ALREADY covered by existing project rules or skills.\n\nYou are given numbered RULES (lines from context files), numbered SKILLS (name and description), and numbered PROPOSALS.\n\nFor each proposal, decide:\n- \"rule\" — an existing rule already says this. The reader repeating it means that rule is not working, so it should be rewritten rather than duplicated.\n- \"skill\" — an existing skill already does this, and the reader asked by hand anyway. Usually the skill's description does not describe the situation they were in.\n- \"new\" — nothing covers it.\n\nJudge by MEANING, not by shared words. Different vocabulary for the same instruction is covered. Shared vocabulary about different things is NOT covered:\n- proposal \"always use bun, never npm\" vs rule \"install dependencies with bun\" → covered (rule)\n- proposal \"run tests before pushing\" vs rule \"CI runs the test suite on every PR\" → NOT covered, these are different instructions to different actors\n- proposal \"prefer table output\" vs rule \"use tables in documentation\" → NOT covered unless the scope matches\n\nPrefer \"new\" when genuinely unsure. A false \"covered\" tells the reader to rewrite a rule that is fine; a false \"new\" merely proposes something they can reject.\n\nRules win over skills when both match: rewriting a line is more actionable than sharpening a description.\n\nOutput STRICT JSON, no markdown fence, no prose. Use the proposal's exact label:\n{\"verdicts\":[{\"label\":\"use-bun-not-npm\",\"verdict\":\"rule\",\"ruleIndex\":3},{\"label\":\"scaffold-route\",\"verdict\":\"skill\",\"skillIndex\":1},{\"label\":\"prefer-tables\",\"verdict\":\"new\"}]}`;\n\nfunction buildPrompt(queries: CoverageQuery[], index: CoverageIndex): string {\n\tconst lines: string[] = [];\n\n\tlines.push(\"RULES:\");\n\tconst rules = index.ruleLines.slice(0, MAX_RULE_LINES);\n\tif (rules.length === 0) lines.push(\"(none)\");\n\tfor (const [i, rule] of rules.entries()) {\n\t\tlines.push(`${i}. ${rule}`);\n\t}\n\n\tlines.push(\"\", \"SKILLS:\");\n\tconst skills = index.skills.slice(0, MAX_SKILLS);\n\tif (skills.length === 0) lines.push(\"(none)\");\n\tfor (const [i, skill] of skills.entries()) {\n\t\tlines.push(`${i}. ${skill.name} — ${skill.description.slice(0, SKILL_DESCRIPTION_CHARS)}`);\n\t}\n\n\tlines.push(\"\", \"PROPOSALS:\");\n\tfor (const query of queries) {\n\t\tlines.push(`- label: ${query.label}\\n text: ${query.text}`);\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\n/** Read the verdict list back, ignoring anything malformed rather than failing the run. */\nexport function parseVerdicts(\n\tresponse: string,\n\tqueries: CoverageQuery[],\n\tindex: CoverageIndex,\n): Map<string, CoverageMatch> {\n\tconst out = new Map<string, CoverageMatch>();\n\tconst start = response.indexOf(\"{\");\n\tconst end = response.lastIndexOf(\"}\");\n\tif (start < 0 || end <= start) return out;\n\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(response.slice(start, end + 1));\n\t} catch {\n\t\treturn out;\n\t}\n\n\tconst raw = (parsed as { verdicts?: unknown })?.verdicts;\n\tif (!Array.isArray(raw)) return out;\n\n\tconst known = new Set(queries.map((q) => q.label));\n\tfor (const item of raw) {\n\t\tif (!item || typeof item !== \"object\") continue;\n\t\tconst verdict = item as Record<string, unknown>;\n\t\tconst label = typeof verdict.label === \"string\" ? verdict.label.trim().toLowerCase() : \"\";\n\t\t// A label the batch did not ask about is a hallucinated row; dropping it is\n\t\t// safer than letting it mark some other proposal covered.\n\t\tif (!label || !known.has(label)) continue;\n\n\t\tif (verdict.verdict === \"rule\") {\n\t\t\tconst at = typeof verdict.ruleIndex === \"number\" ? index.ruleLines[verdict.ruleIndex] : undefined;\n\t\t\t// An out-of-range index means the model decided \"covered\" but cannot show\n\t\t\t// which line. Treat that as `new`: the reader cannot act on an unnamed rule.\n\t\t\tif (at) out.set(label, { rule: at });\n\t\t\tcontinue;\n\t\t}\n\t\tif (verdict.verdict === \"skill\") {\n\t\t\tconst at = typeof verdict.skillIndex === \"number\" ? index.skills[verdict.skillIndex] : undefined;\n\t\t\tif (at) out.set(label, { skill: at.name });\n\t\t\tcontinue;\n\t\t}\n\t\tout.set(label, {});\n\t}\n\treturn out;\n}\n\nexport interface CoverageDeps {\n\tmodel: Model<any>;\n\tapiKey?: string;\n\theaders?: Record<string, string>;\n}\n\nexport function createLlmCoverageJudge(deps: CoverageDeps): CoverageJudge {\n\treturn async (queries, index, signal) => {\n\t\t// Nothing to match against means nothing can be covered, and the call would\n\t\t// be pure cost.\n\t\tif (queries.length === 0 || (index.ruleLines.length === 0 && index.skills.length === 0)) {\n\t\t\treturn new Map();\n\t\t}\n\n\t\tconst response = await completeSimple(\n\t\t\tdeps.model,\n\t\t\t{\n\t\t\t\tsystemPrompt: COVERAGE_SYSTEM_PROMPT,\n\t\t\t\tmessages: [\n\t\t\t\t\t{ role: \"user\", content: [{ type: \"text\", text: buildPrompt(queries, index) }], timestamp: Date.now() },\n\t\t\t\t],\n\t\t\t},\n\t\t\t{ maxTokens: MAX_RESPONSE_TOKENS, signal, apiKey: deps.apiKey, headers: deps.headers },\n\t\t);\n\n\t\tif (response.stopReason === \"error\") {\n\t\t\tthrow new Error(response.errorMessage || \"coverage call failed\");\n\t\t}\n\n\t\tconst text = response.content\n\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t.map((c) => c.text)\n\t\t\t.join(\"\\n\");\n\t\treturn parseVerdicts(text, queries, index);\n\t};\n}\n\n/** Everything is new. Used when no model is available, so the run still produces a digest. */\nexport const noCoverageJudge: CoverageJudge = async () => new Map();\n"]}
@@ -13,5 +13,6 @@ import type { LearnDigest } from "./extract.js";
13
13
  export declare function isEmptyDigest(digest: LearnDigest): boolean;
14
14
  export declare function renderLearnDigest(digest: LearnDigest, options: {
15
15
  userScopePath: string;
16
+ mode?: "incremental" | "all";
16
17
  }): string;
17
18
  //# sourceMappingURL=digest.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"digest.d.ts","sourceRoot":"","sources":["../../../src/core/learn/digest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAehD,oEAAoE;AACpE,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAE1D;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE;IAAE,aAAa,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CA2IjG","sourcesContent":["/**\n * Renders the extractor's output into the message `/learn` injects.\n *\n * The digest is evidence plus instructions, and the split matters: the numbers\n * come from {@link extractLearnDigest} and are not negotiable, while everything\n * the model does with them — phrasing, routing, deciding a pattern is not worth\n * a rule — is judgement it has to exercise. Counts are printed on every item\n * because \"said in 5 of your last 12 sessions\" is a decision the reader can\n * make in one keystroke, where \"extracted from your session\" is not.\n */\n\nimport type { LearnDigest } from \"./extract.js\";\nimport { LEARN_DIGEST_MARKER } from \"./extract.js\";\n\nfunction shortDate(iso: string | undefined): string {\n\tif (!iso) return \"unknown\";\n\tconst date = new Date(iso);\n\treturn Number.isNaN(date.getTime()) ? \"unknown\" : date.toISOString().slice(0, 10);\n}\n\nfunction evidence(count: number, sessions: number, lastSeen: string): string {\n\tconst times = count === 1 ? \"once\" : `${count}x`;\n\tconst where = sessions === 1 ? \"1 session\" : `${sessions} sessions`;\n\treturn `${times} across ${where}, last ${shortDate(lastSeen)}`;\n}\n\n/** True when there is nothing worth asking the model to look at. */\nexport function isEmptyDigest(digest: LearnDigest): boolean {\n\treturn digest.directives.length === 0 && digest.fixes.length === 0 && digest.workflows.length === 0;\n}\n\nexport function renderLearnDigest(digest: LearnDigest, options: { userScopePath: string }): string {\n\tconst lines: string[] = [];\n\n\tlines.push(\n\t\t`${LEARN_DIGEST_MARKER} Mined ${digest.scannedSessions} session(s) in this directory` +\n\t\t\t(digest.skippedSessions > 0 ? ` (${digest.skippedSessions} skipped: out of window or unreadable)` : \"\") +\n\t\t\t(digest.oldestSession ? `, ${shortDate(digest.oldestSession)} to ${shortDate(digest.newestSession)}` : \"\") +\n\t\t\t(digest.suppressed > 0 ? `. ${digest.suppressed} item(s) held back — already shown and unchanged since` : \"\") +\n\t\t\t\".\",\n\t);\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"The counts below are computed from session transcripts on disk, not from this conversation. \" +\n\t\t\t\"Treat them as evidence, not conclusions — your job is to decide what deserves to be written down, \" +\n\t\t\t\"phrase it, and put it in the right place.\",\n\t);\n\tlines.push(\"\");\n\n\t// ── Directives ───────────────────────────────────────────────────────────\n\tif (digest.directives.length > 0) {\n\t\tlines.push(\"## Directives you have repeated\");\n\t\tlines.push(\"\");\n\t\tfor (const cluster of digest.directives) {\n\t\t\tlines.push(`- **${cluster.status}** — \"${cluster.text.replace(/\\s+/g, \" \").trim()}\"`);\n\t\t\tlines.push(` - ${evidence(cluster.count, cluster.sessions, cluster.lastSeen)}`);\n\t\t\tif (cluster.existingRule) {\n\t\t\t\tlines.push(` - already covered by: \"${cluster.existingRule.slice(0, 160)}\"`);\n\t\t\t}\n\t\t\tif (cluster.existingSkill) {\n\t\t\t\tlines.push(` - already covered by the \\`${cluster.existingSkill}\\` skill`);\n\t\t\t}\n\t\t\tif (cluster.previouslyDeclined) {\n\t\t\t\tlines.push(\" - proposed before and not written down — you have already passed on this once\");\n\t\t\t}\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Fixes ────────────────────────────────────────────────────────────────\n\tif (digest.fixes.length > 0) {\n\t\tlines.push(\"## Failures you resolved\");\n\t\tlines.push(\"\");\n\t\tlines.push(\n\t\t\t\"Each is a command that failed, then later succeeded unchanged after intervening work — \" +\n\t\t\t\t\"so something in between was the fix.\",\n\t\t);\n\t\tlines.push(\"\");\n\t\tfor (const fix of digest.fixes) {\n\t\t\tlines.push(`- \\`${fix.command}\\` — ${evidence(fix.count, fix.sessions, fix.lastSeen)}`);\n\t\t\tlines.push(` - error: ${fix.errorExcerpt}`);\n\t\t\tif (fix.interveningCommands.length > 0) {\n\t\t\t\tlines.push(` - commands in between: ${fix.interveningCommands.map((c) => `\\`${c}\\``).join(\", \")}`);\n\t\t\t}\n\t\t\tif (fix.editedFiles.length > 0) {\n\t\t\t\tlines.push(` - files edited: ${fix.editedFiles.join(\", \")}`);\n\t\t\t}\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Workflows ────────────────────────────────────────────────────────────\n\tif (digest.workflows.length > 0) {\n\t\tlines.push(\"## Repeated tool sequences\");\n\t\tlines.push(\"\");\n\t\tfor (const workflow of digest.workflows) {\n\t\t\tlines.push(\n\t\t\t\t`- \\`${workflow.steps.join(\" → \")}\\` — ${evidence(workflow.count, workflow.sessions, workflow.lastSeen)}`,\n\t\t\t);\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Instructions ─────────────────────────────────────────────────────────\n\tlines.push(\"## What to do\");\n\tlines.push(\"\");\n\tlines.push(\"Work through the items above and propose concrete edits. For each one, decide:\");\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"1. **Is it durable?** A rule that will still be true next month belongs somewhere. A one-off preference \" +\n\t\t\t\"about the task you happened to be doing does not. When in doubt, drop it — a wrong rule costs more than \" +\n\t\t\t\"a missing one, because it is paid on every request forever.\",\n\t);\n\tlines.push(\n\t\t\"2. **Rule or skill?** This is the most important call. A context file is loaded on **every** turn; a skill \" +\n\t\t\t\"is loaded **on demand**. So: short, always-true, unconditional → a one-line rule. Long, procedural, \" +\n\t\t\t'or conditional (a sequence of steps, a runbook, anything starting \"when X, do Y\") → a skill, not a rule. ' +\n\t\t\t\"Repeated tool sequences are almost always skills.\",\n\t);\n\tlines.push(\n\t\t`3. **Which scope?** Project-specific (this repo's tests, build, architecture, conventions) → the repo ` +\n\t\t\t`\\`AGENTS.md\\`. Personal habits that travel with you across every repo (style preferences, how you like ` +\n\t\t\t`commits written) → \\`${options.userScopePath}\\`. If it names this repo's files or commands, it is not a ` +\n\t\t\t`user-scope rule.`,\n\t);\n\tlines.push(\n\t\t\"4. **Restated items are rewrites, not additions.** An item marked `restated` is already covered by a rule \" +\n\t\t\t\"that is not working — too vague, buried, or contradicted elsewhere. Rewrite the existing line or delete \" +\n\t\t\t\"it in favour of a sharper one. Do not add a second rule saying the same thing.\",\n\t);\n\tlines.push(\n\t\t\"5. **`has-skill` items are a triggering problem, not a missing rule.** A skill already covers it and you \" +\n\t\t\t\"asked by hand anyway, which usually means the skill's `description` frontmatter does not describe the \" +\n\t\t\t\"situation you were in. Sharpen that description so it matches, rather than adding a rule that duplicates \" +\n\t\t\t\"what the skill already does.\",\n\t);\n\tlines.push(\"\");\n\tlines.push(\"Then, while you have the file open, audit it:\");\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"- **Delete rules that no longer match the code.** Check a sample against the repo before trusting them.\",\n\t);\n\tlines.push(\"- **Delete rules that restate default behaviour.** Guidance the agent already follows is pure cost.\");\n\tlines.push(\n\t\t\"- **Collapse duplicates**, including any rule stated at both repo and user scope — that one is paid twice.\",\n\t);\n\tlines.push(\n\t\t\"- **One line per rule.** No rationale, no examples, no preamble, unless the example *is* the rule. Prose is \" +\n\t\t\t\"the single biggest source of context-file bloat.\",\n\t);\n\tlines.push(\"\");\n\n\tif (digest.agentsFilePath) {\n\t\tlines.push(\n\t\t\t`The repo context file is \\`${digest.agentsFilePath}\\`` +\n\t\t\t\t(digest.agentsFileTokens ? ` (~${digest.agentsFileTokens} tokens, re-sent every request)` : \"\") +\n\t\t\t\t\". Report the token delta of your proposed changes before applying them; a net reduction is a good outcome.\",\n\t\t);\n\t} else {\n\t\tlines.push(\n\t\t\t\"No repo context file exists yet. Create one only if at least one durable project rule survives step 1.\",\n\t\t);\n\t}\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"Show what you propose, then apply it with edits — do not ask a separate approval question first, the edit \" +\n\t\t\t\"prompt is the approval. If nothing here is worth writing down, say so plainly and change nothing.\",\n\t);\n\n\treturn lines.join(\"\\n\");\n}\n"]}
1
+ {"version":3,"file":"digest.d.ts","sourceRoot":"","sources":["../../../src/core/learn/digest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAehD,oEAAoE;AACpE,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAE1D;AAED,wBAAgB,iBAAiB,CAChC,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE;IAAE,aAAa,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,aAAa,GAAG,KAAK,CAAA;CAAE,GAC9D,MAAM,CAsKR","sourcesContent":["/**\n * Renders the extractor's output into the message `/learn` injects.\n *\n * The digest is evidence plus instructions, and the split matters: the numbers\n * come from {@link extractLearnDigest} and are not negotiable, while everything\n * the model does with them — phrasing, routing, deciding a pattern is not worth\n * a rule — is judgement it has to exercise. Counts are printed on every item\n * because \"said in 5 of your last 12 sessions\" is a decision the reader can\n * make in one keystroke, where \"extracted from your session\" is not.\n */\n\nimport type { LearnDigest } from \"./extract.js\";\nimport { LEARN_DIGEST_MARKER } from \"./extract.js\";\n\nfunction shortDate(iso: string | undefined): string {\n\tif (!iso) return \"unknown\";\n\tconst date = new Date(iso);\n\treturn Number.isNaN(date.getTime()) ? \"unknown\" : date.toISOString().slice(0, 10);\n}\n\nfunction evidence(count: number, sessions: number, lastSeen: string): string {\n\tconst times = count === 1 ? \"once\" : `${count}x`;\n\tconst where = sessions === 1 ? \"1 session\" : `${sessions} sessions`;\n\treturn `${times} across ${where}, last ${shortDate(lastSeen)}`;\n}\n\n/** True when there is nothing worth asking the model to look at. */\nexport function isEmptyDigest(digest: LearnDigest): boolean {\n\treturn digest.directives.length === 0 && digest.fixes.length === 0 && digest.workflows.length === 0;\n}\n\nexport function renderLearnDigest(\n\tdigest: LearnDigest,\n\toptions: { userScopePath: string; mode?: \"incremental\" | \"all\" },\n): string {\n\tconst lines: string[] = [];\n\n\tlines.push(\n\t\t`${LEARN_DIGEST_MARKER} Mined ${digest.scannedSessions} session(s) in this directory` +\n\t\t\t(digest.skippedSessions > 0 ? ` (${digest.skippedSessions} skipped: out of window or unreadable)` : \"\") +\n\t\t\t(digest.oldestSession ? `, ${shortDate(digest.oldestSession)} to ${shortDate(digest.newestSession)}` : \"\") +\n\t\t\t(digest.suppressed > 0 ? `. ${digest.suppressed} item(s) held back — already shown and unchanged since` : \"\") +\n\t\t\t\".\",\n\t);\n\t// Naming the mode keeps two very different empty results from reading alike:\n\t// \"nothing new since last time\" and \"nothing here at all\" are not the same\n\t// answer, and the reader cannot tell them apart from the counts.\n\tif (options.mode === \"all\") {\n\t\tlines.push(\"Mode: all — suppression is off, so items you have already seen and decided on are included.\");\n\t}\n\t// The model reads every transcript in full, which costs real tokens. Saying\n\t// what was re-read versus reused keeps that price visible rather than hidden.\n\tlines.push(\n\t\t`Read by the model this run: ${digest.mining.mined}; reused from cache: ${digest.mining.cached}` +\n\t\t\t(digest.mining.failed > 0\n\t\t\t\t? `; failed: ${digest.mining.failed} (their signals are missing from the counts below)`\n\t\t\t\t: \"\") +\n\t\t\t\".\",\n\t);\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"The counts below are computed from session transcripts on disk, not from this conversation. \" +\n\t\t\t\"Treat them as evidence, not conclusions — your job is to decide what deserves to be written down, \" +\n\t\t\t\"phrase it, and put it in the right place.\",\n\t);\n\tlines.push(\"\");\n\n\t// ── Directives ───────────────────────────────────────────────────────────\n\tif (digest.directives.length > 0) {\n\t\tlines.push(\"## Directives you have repeated\");\n\t\tlines.push(\"\");\n\t\tfor (const cluster of digest.directives) {\n\t\t\tlines.push(`- **${cluster.status}** — \"${cluster.text.replace(/\\s+/g, \" \").trim()}\"`);\n\t\t\tlines.push(` - ${evidence(cluster.count, cluster.sessions, cluster.lastSeen)}`);\n\t\t\t// Occurrences were grouped by meaning, not by wording, so the quote above\n\t\t\t// is one phrasing of several. Naming the shared point keeps a count of 5\n\t\t\t// from looking like five copies of one sentence.\n\t\t\tlines.push(` - grouped as: ${cluster.label}`);\n\t\t\tif (cluster.rationale) {\n\t\t\t\tlines.push(` - why it may be durable: ${cluster.rationale}`);\n\t\t\t}\n\t\t\tif (cluster.existingRule) {\n\t\t\t\tlines.push(` - already covered by: \"${cluster.existingRule.slice(0, 160)}\"`);\n\t\t\t}\n\t\t\tif (cluster.existingSkill) {\n\t\t\t\tlines.push(` - already covered by the \\`${cluster.existingSkill}\\` skill`);\n\t\t\t}\n\t\t\tif (cluster.previouslyDeclined) {\n\t\t\t\tlines.push(\" - proposed before and not written down — you have already passed on this once\");\n\t\t\t}\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Fixes ────────────────────────────────────────────────────────────────\n\tif (digest.fixes.length > 0) {\n\t\tlines.push(\"## Failures you resolved\");\n\t\tlines.push(\"\");\n\t\tlines.push(\n\t\t\t\"Each is a command that failed and later succeeded, where something done in between was the fix. \" +\n\t\t\t\t\"Recurring ones are worth writing down; a one-off is not.\",\n\t\t);\n\t\tlines.push(\"\");\n\t\tfor (const fix of digest.fixes) {\n\t\t\tlines.push(`- \\`${fix.command}\\` — ${evidence(fix.count, fix.sessions, fix.lastSeen)}`);\n\t\t\tlines.push(` - grouped as: ${fix.label}`);\n\t\t\t// The excerpt comes from the model now, which may not have quoted one.\n\t\t\tif (fix.errorExcerpt) {\n\t\t\t\tlines.push(` - error: ${fix.errorExcerpt}`);\n\t\t\t}\n\t\t\tif (fix.interveningCommands.length > 0) {\n\t\t\t\tlines.push(` - commands in between: ${fix.interveningCommands.map((c) => `\\`${c}\\``).join(\", \")}`);\n\t\t\t}\n\t\t\tif (fix.editedFiles.length > 0) {\n\t\t\t\tlines.push(` - files edited: ${fix.editedFiles.join(\", \")}`);\n\t\t\t}\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Workflows ────────────────────────────────────────────────────────────\n\tif (digest.workflows.length > 0) {\n\t\tlines.push(\"## Repeated tool sequences\");\n\t\tlines.push(\"\");\n\t\tfor (const workflow of digest.workflows) {\n\t\t\t// A workflow the model named but did not enumerate still has a label\n\t\t\t// worth showing; rendering an empty backtick pair instead would not.\n\t\t\tconst steps = workflow.steps.length > 0 ? `\\`${workflow.steps.join(\" → \")}\\`` : workflow.label;\n\t\t\tlines.push(`- ${steps} — ${evidence(workflow.count, workflow.sessions, workflow.lastSeen)}`);\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Instructions ─────────────────────────────────────────────────────────\n\tlines.push(\"## What to do\");\n\tlines.push(\"\");\n\tlines.push(\"Work through the items above and propose concrete edits. For each one, decide:\");\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"1. **Is it durable?** A rule that will still be true next month belongs somewhere. A one-off preference \" +\n\t\t\t\"about the task you happened to be doing does not. When in doubt, drop it — a wrong rule costs more than \" +\n\t\t\t\"a missing one, because it is paid on every request forever.\",\n\t);\n\tlines.push(\n\t\t\"2. **Rule or skill?** This is the most important call. A context file is loaded on **every** turn; a skill \" +\n\t\t\t\"is loaded **on demand**. So: short, always-true, unconditional → a one-line rule. Long, procedural, \" +\n\t\t\t'or conditional (a sequence of steps, a runbook, anything starting \"when X, do Y\") → a skill, not a rule. ' +\n\t\t\t\"Repeated tool sequences are almost always skills.\",\n\t);\n\tlines.push(\n\t\t`3. **Which scope?** Project-specific (this repo's tests, build, architecture, conventions) → the repo ` +\n\t\t\t`\\`AGENTS.md\\`. Personal habits that travel with you across every repo (style preferences, how you like ` +\n\t\t\t`commits written) → \\`${options.userScopePath}\\`. If it names this repo's files or commands, it is not a ` +\n\t\t\t`user-scope rule.`,\n\t);\n\tlines.push(\n\t\t\"4. **Restated items are rewrites, not additions.** An item marked `restated` is already covered by a rule \" +\n\t\t\t\"that is not working — too vague, buried, or contradicted elsewhere. Rewrite the existing line or delete \" +\n\t\t\t\"it in favour of a sharper one. Do not add a second rule saying the same thing.\",\n\t);\n\tlines.push(\n\t\t\"5. **`has-skill` items are a triggering problem, not a missing rule.** A skill already covers it and you \" +\n\t\t\t\"asked by hand anyway, which usually means the skill's `description` frontmatter does not describe the \" +\n\t\t\t\"situation you were in. Sharpen that description so it matches, rather than adding a rule that duplicates \" +\n\t\t\t\"what the skill already does.\",\n\t);\n\tlines.push(\"\");\n\tlines.push(\"Then, while you have the file open, audit it:\");\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"- **Delete rules that no longer match the code.** Check a sample against the repo before trusting them.\",\n\t);\n\tlines.push(\"- **Delete rules that restate default behaviour.** Guidance the agent already follows is pure cost.\");\n\tlines.push(\n\t\t\"- **Collapse duplicates**, including any rule stated at both repo and user scope — that one is paid twice.\",\n\t);\n\tlines.push(\n\t\t\"- **One line per rule.** No rationale, no examples, no preamble, unless the example *is* the rule. Prose is \" +\n\t\t\t\"the single biggest source of context-file bloat.\",\n\t);\n\tlines.push(\"\");\n\n\tif (digest.agentsFilePath) {\n\t\tlines.push(\n\t\t\t`The repo context file is \\`${digest.agentsFilePath}\\`` +\n\t\t\t\t(digest.agentsFileTokens ? ` (~${digest.agentsFileTokens} tokens, re-sent every request)` : \"\") +\n\t\t\t\t\". Report the token delta of your proposed changes before applying them; a net reduction is a good outcome.\",\n\t\t);\n\t} else {\n\t\tlines.push(\n\t\t\t\"No repo context file exists yet. Create one only if at least one durable project rule survives step 1.\",\n\t\t);\n\t}\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"Show what you propose, then apply it with edits — do not ask a separate approval question first, the edit \" +\n\t\t\t\"prompt is the approval. If nothing here is worth writing down, say so plainly and change nothing.\",\n\t);\n\n\treturn lines.join(\"\\n\");\n}\n"]}