@kolisachint/hoocode-agent 0.5.18 → 0.5.20

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 +179 -0
  2. package/dist/core/learn/audit.d.ts +136 -0
  3. package/dist/core/learn/audit.d.ts.map +1 -0
  4. package/dist/core/learn/audit.js +316 -0
  5. package/dist/core/learn/audit.js.map +1 -0
  6. package/dist/core/learn/cache.d.ts.map +1 -1
  7. package/dist/core/learn/cache.js +14 -2
  8. package/dist/core/learn/cache.js.map +1 -1
  9. package/dist/core/learn/cluster.d.ts +78 -0
  10. package/dist/core/learn/cluster.d.ts.map +1 -0
  11. package/dist/core/learn/cluster.js +184 -0
  12. package/dist/core/learn/cluster.js.map +1 -0
  13. package/dist/core/learn/coverage.d.ts.map +1 -1
  14. package/dist/core/learn/coverage.js +2 -0
  15. package/dist/core/learn/coverage.js.map +1 -1
  16. package/dist/core/learn/digest.d.ts +12 -0
  17. package/dist/core/learn/digest.d.ts.map +1 -1
  18. package/dist/core/learn/digest.js +86 -14
  19. package/dist/core/learn/digest.js.map +1 -1
  20. package/dist/core/learn/extract.d.ts +39 -4
  21. package/dist/core/learn/extract.d.ts.map +1 -1
  22. package/dist/core/learn/extract.js +170 -34
  23. package/dist/core/learn/extract.js.map +1 -1
  24. package/dist/core/learn/mine.d.ts +78 -23
  25. package/dist/core/learn/mine.d.ts.map +1 -1
  26. package/dist/core/learn/mine.js +142 -37
  27. package/dist/core/learn/mine.js.map +1 -1
  28. package/dist/core/learn/reduce.d.ts +19 -8
  29. package/dist/core/learn/reduce.d.ts.map +1 -1
  30. package/dist/core/learn/reduce.js +69 -13
  31. package/dist/core/learn/reduce.js.map +1 -1
  32. package/dist/core/learn/state.d.ts +11 -18
  33. package/dist/core/learn/state.d.ts.map +1 -1
  34. package/dist/core/learn/state.js +23 -34
  35. package/dist/core/learn/state.js.map +1 -1
  36. package/dist/core/settings-defaults.d.ts +1 -1
  37. package/dist/core/settings-defaults.d.ts.map +1 -1
  38. package/dist/core/settings-defaults.js +1 -1
  39. package/dist/core/settings-defaults.js.map +1 -1
  40. package/dist/core/settings-manager.d.ts +2 -2
  41. package/dist/core/settings-manager.d.ts.map +1 -1
  42. package/dist/core/settings-manager.js +1 -1
  43. package/dist/core/settings-manager.js.map +1 -1
  44. package/dist/core/settings-types.d.ts +1 -1
  45. package/dist/core/settings-types.d.ts.map +1 -1
  46. package/dist/core/settings-types.js.map +1 -1
  47. package/dist/extensions/core/learn.d.ts.map +1 -1
  48. package/dist/extensions/core/learn.js +128 -73
  49. package/dist/extensions/core/learn.js.map +1 -1
  50. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  51. package/dist/modes/interactive/components/settings-selector.js +1 -1
  52. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  53. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  54. package/dist/modes/interactive/interactive-mode.js +1 -1
  55. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  56. package/docs/settings.md +9 -6
  57. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  58. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  59. package/examples/extensions/sandbox/package.json +1 -1
  60. package/examples/extensions/with-deps/package.json +1 -1
  61. package/package.json +4 -4
@@ -0,0 +1,316 @@
1
+ /**
2
+ * The subtractive half of `/learn`: which lines in a context file describe
3
+ * things that no longer exist?
4
+ *
5
+ * The mining pipeline can only ever propose additions. Nothing in it moves the
6
+ * always-loaded token surface down, so a context file accumulates: a rule
7
+ * naming a deleted workflow, a command that was removed, a file that moved two
8
+ * refactors ago. Those lines cost tokens on every request forever and are worse
9
+ * than useless, because the agent believes them.
10
+ *
11
+ * This is deliberately deterministic — no model call, no cache, no state. It
12
+ * reads the context files already in force, pulls out the referents they name
13
+ * in backticks, and asks the filesystem. That makes it instant and free, which
14
+ * is what lets it be the half you run most often.
15
+ *
16
+ * Precision is bought with exclusions rather than cleverness, because a noisy
17
+ * audit is one nobody reads. Three rules do most of the work:
18
+ *
19
+ * - **Only path-like referents with a separator.** A bare `auth.json` could be
20
+ * anywhere or nowhere; `docs/providers.md` is a claim about this repo.
21
+ * - **Resolve against every package root, not just the repo root.** A monorepo
22
+ * names `src/cli/args.ts` relative to the package being discussed, and
23
+ * checking only the repo root reports the entire contributing guide as stale.
24
+ * - **Skip lines that assert absence.** "these are all gone", "e.g.
25
+ * `bedrock-utils.ts`", "create `foo.ts`" legitimately name files that do not
26
+ * exist. Deciding this in general is a judgement call; a short vocabulary of
27
+ * assertive forms catches the cases that occur in practice.
28
+ *
29
+ * What is left is a short list where a wrong entry costs one glance and a right
30
+ * one costs a line of always-loaded context. That asymmetry is the reason the
31
+ * remaining false positives are acceptable and silent misses are not.
32
+ */
33
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
34
+ import { join, resolve, sep } from "node:path";
35
+ /** Directories never descended into when discovering package roots. */
36
+ const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", "coverage", "out", ".next", ".turbo"]);
37
+ /** How deep to look for package roots. Deep enough for a monorepo, shallow enough to stay instant. */
38
+ const MAX_ROOT_DEPTH = 3;
39
+ /** Extensions that make a token a file reference even without an obvious path shape. */
40
+ const FILE_EXTENSIONS = /\.(md|markdown|ts|tsx|js|jsx|mjs|cjs|json|jsonc|ya?ml|toml|sh|bash|zsh|py|rs|go|lock|txt|css|html)$/i;
41
+ /**
42
+ * Forms that legitimately name something absent.
43
+ *
44
+ * Every one of these was a false positive on a real context file before it was
45
+ * excluded. `removed`/`gone` describe a deletion; `e.g.`/`for example` name a
46
+ * pattern rather than a file; `create`/`scaffold` describe an artifact the
47
+ * reader is being told to write.
48
+ *
49
+ * The list is deliberately short. Broad prohibitions ("never edit X", "do not
50
+ * run Y") name things that exist and are exactly the claims worth checking, so
51
+ * matching on those words would trade the audit's whole purpose for a little
52
+ * precision.
53
+ */
54
+ const ABSENCE_MARKERS = [
55
+ "removed",
56
+ "deleted",
57
+ " gone",
58
+ "no longer",
59
+ "used to",
60
+ "if present",
61
+ "if it exists",
62
+ "optional",
63
+ "e.g.",
64
+ "for example",
65
+ "create ",
66
+ "scaffold",
67
+ ];
68
+ /** Referents that look like a git ref rather than a path. */
69
+ const GIT_REF_PREFIXES = ["origin/", "upstream/", "refs/", "HEAD"];
70
+ /** Rough token estimate, matching the convention used elsewhere for context files. */
71
+ function estimateTokens(text) {
72
+ return Math.round(Buffer.byteLength(text, "utf-8") / 4);
73
+ }
74
+ /**
75
+ * Directories a relative referent may be resolved against.
76
+ *
77
+ * The repo root alone is not enough: a monorepo's contributing notes name
78
+ * `src/cli/args.ts` meaning "inside the package under discussion", and resolving
79
+ * that only from the root reports every such line as stale. Every directory
80
+ * holding a `package.json` is therefore a root, nearest-shallowest first.
81
+ */
82
+ export function resolutionRoots(base) {
83
+ const roots = [resolve(base)];
84
+ const walk = (dir, depth) => {
85
+ if (depth > MAX_ROOT_DEPTH)
86
+ return;
87
+ let entries;
88
+ try {
89
+ entries = readdirSync(dir, { withFileTypes: true });
90
+ }
91
+ catch {
92
+ return;
93
+ }
94
+ for (const entry of entries) {
95
+ if (!entry.isDirectory() || IGNORED_DIRS.has(entry.name))
96
+ continue;
97
+ const child = join(dir, entry.name);
98
+ if (existsSync(join(child, "package.json")))
99
+ roots.push(child);
100
+ walk(child, depth + 1);
101
+ }
102
+ };
103
+ walk(resolve(base), 1);
104
+ return roots;
105
+ }
106
+ /** Every backticked span in a line, in order. */
107
+ function backtickedSpans(line) {
108
+ const out = [];
109
+ const pattern = /`([^`\n]+)`/g;
110
+ let match = pattern.exec(line);
111
+ while (match) {
112
+ if (match[1])
113
+ out.push(match[1]);
114
+ match = pattern.exec(line);
115
+ }
116
+ return out;
117
+ }
118
+ /** True when the line reads as a statement about something absent, optional, or yet to be written. */
119
+ function assertsAbsence(line) {
120
+ const lower = line.toLowerCase();
121
+ return ABSENCE_MARKERS.some((marker) => lower.includes(marker));
122
+ }
123
+ /** The script name a `bun run x` / `npm run x` reference names, if it is one. */
124
+ function scriptReference(token) {
125
+ const match = /^(?:bun|npm|pnpm|yarn)\s+run\s+([A-Za-z0-9:_-]+)$/.exec(token.trim());
126
+ return match?.[1];
127
+ }
128
+ /**
129
+ * Decide whether a backticked token is a checkable claim about this repo.
130
+ *
131
+ * Ordering matters: the skip reasons are reported as counts, and a token that
132
+ * matches several should be attributed to the most specific one, so a
133
+ * placeholder is a placeholder rather than "ambiguous".
134
+ */
135
+ export function classifyReferent(token) {
136
+ const value = token.trim();
137
+ const script = scriptReference(value);
138
+ if (script)
139
+ return { kind: "script", script };
140
+ if (/[<>*{}$|]/.test(value))
141
+ return { kind: "skip", reason: "placeholder" };
142
+ if (/^https?:\/\//i.test(value))
143
+ return { kind: "skip", reason: "external" };
144
+ if (GIT_REF_PREFIXES.some((prefix) => value.startsWith(prefix)))
145
+ return { kind: "skip", reason: "external" };
146
+ // A shell command, a prose fragment, or a flag list — not a path.
147
+ if (/\s/.test(value))
148
+ return { kind: "skip", reason: "ambiguous" };
149
+ if (value.startsWith("~") || value.startsWith(sep) || /^[A-Za-z]:[\\/]/.test(value)) {
150
+ return { kind: "skip", reason: "runtime" };
151
+ }
152
+ if (value === "." || value === "..")
153
+ return { kind: "skip", reason: "ambiguous" };
154
+ // The load-bearing precision rule: without a separator the token names a
155
+ // filename that could be anywhere, and "anywhere" is not a claim worth
156
+ // contradicting. A bare `stream.test.ts` in a monorepo is not stale, it is
157
+ // under-specified.
158
+ if (!value.includes("/"))
159
+ return { kind: "skip", reason: "ambiguous" };
160
+ // Past the separator rule, something that is neither a known file type nor an
161
+ // obvious directory is more likely prose than a path.
162
+ if (!FILE_EXTENSIONS.test(value) && !value.endsWith("/"))
163
+ return { kind: "skip", reason: "ambiguous" };
164
+ return { kind: "path" };
165
+ }
166
+ /** True when the referent resolves against any root. Directories count. */
167
+ function pathResolves(referent, roots) {
168
+ const relative = referent.replace(/\/+$/, "");
169
+ if (!relative)
170
+ return false;
171
+ return roots.some((root) => existsSync(join(root, relative)));
172
+ }
173
+ /** Script names declared by any `package.json` at any resolution root. */
174
+ function declaredScripts(roots) {
175
+ const names = new Set();
176
+ for (const root of roots) {
177
+ const manifest = join(root, "package.json");
178
+ if (!existsSync(manifest))
179
+ continue;
180
+ try {
181
+ const parsed = JSON.parse(readFileSync(manifest, "utf-8"));
182
+ for (const name of Object.keys(parsed.scripts ?? {}))
183
+ names.add(name);
184
+ }
185
+ catch {
186
+ // A malformed manifest is not this command's problem; treat it as
187
+ // declaring nothing rather than failing the audit.
188
+ }
189
+ }
190
+ return names;
191
+ }
192
+ /**
193
+ * The project a referent is resolved against: the nearest ancestor holding a
194
+ * `.git`, or `cwd` when there is none.
195
+ *
196
+ * Not `cwd` itself. Context files are collected by walking up from `cwd`, so in
197
+ * a monorepo the repo's `AGENTS.md` sits *above* the package you are working
198
+ * in — and running from a package root is the normal case, not the exception.
199
+ * Anchoring on `cwd` meant the file with all the claims in it was declared "not
200
+ * in this working tree" and skipped, so the audit passed by checking nothing.
201
+ */
202
+ export function findProjectRoot(cwd) {
203
+ let dir = resolve(cwd);
204
+ while (true) {
205
+ if (existsSync(join(dir, ".git")))
206
+ return dir;
207
+ const parent = resolve(dir, "..");
208
+ if (parent === dir)
209
+ return resolve(cwd);
210
+ dir = parent;
211
+ }
212
+ }
213
+ /** True when the path is inside the project, so its claims are about this repo. */
214
+ function insideTree(path, root) {
215
+ const base = resolve(root);
216
+ const target = resolve(path);
217
+ return target === base || target.startsWith(base + sep);
218
+ }
219
+ /**
220
+ * Check every referent named by the repo-scope context files.
221
+ *
222
+ * Files outside the project are listed but not audited: a rule in
223
+ * `~/.agents/AGENTS.md` naming `src/index.ts` is a claim about whichever repo
224
+ * it was written for, and resolving it here would report another project's
225
+ * rules as broken.
226
+ *
227
+ * File contents are re-read from disk rather than taken from the loader, which
228
+ * truncates oversized files for the prompt — auditing the truncation would
229
+ * silently stop checking exactly the files most likely to have gone stale.
230
+ */
231
+ export function auditContextFiles(options) {
232
+ // Rooted at the project, not at `cwd`: a path in the repo's context file is
233
+ // written relative to the repo, and is being read from wherever you happen to
234
+ // be working.
235
+ const projectRoot = findProjectRoot(options.cwd);
236
+ const roots = resolutionRoots(projectRoot);
237
+ const scripts = declaredScripts(roots);
238
+ const report = {
239
+ files: [],
240
+ skippedFiles: [],
241
+ checked: 0,
242
+ skipped: { placeholder: 0, runtime: 0, external: 0, ambiguous: 0, assertsAbsence: 0 },
243
+ stale: [],
244
+ roots,
245
+ };
246
+ for (const file of options.files) {
247
+ if (!insideTree(file.path, projectRoot)) {
248
+ report.skippedFiles.push(file.path);
249
+ continue;
250
+ }
251
+ let content;
252
+ try {
253
+ content = readFileSync(file.path, "utf-8");
254
+ }
255
+ catch {
256
+ report.skippedFiles.push(file.path);
257
+ continue;
258
+ }
259
+ report.files.push({ path: file.path, tokens: file.tokens ?? estimateTokens(content) });
260
+ const lines = content.split("\n");
261
+ let inFence = false;
262
+ for (const [index, raw] of lines.entries()) {
263
+ // A fenced block is example code, not a claim about the repo. Its
264
+ // contents are also where most of a context file's plausible-looking
265
+ // paths live, so auditing it is almost pure noise.
266
+ if (raw.trimStart().startsWith("```")) {
267
+ inFence = !inFence;
268
+ continue;
269
+ }
270
+ if (inFence)
271
+ continue;
272
+ const spans = backtickedSpans(raw);
273
+ if (spans.length === 0)
274
+ continue;
275
+ const absence = assertsAbsence(raw);
276
+ for (const span of spans) {
277
+ const classification = classifyReferent(span);
278
+ if (classification.kind === "skip") {
279
+ report.skipped[classification.reason]++;
280
+ continue;
281
+ }
282
+ if (absence) {
283
+ report.skipped.assertsAbsence++;
284
+ continue;
285
+ }
286
+ report.checked++;
287
+ const resolved = classification.kind === "script" ? scripts.has(classification.script) : pathResolves(span, roots);
288
+ if (resolved)
289
+ continue;
290
+ report.stale.push({
291
+ file: file.path,
292
+ line: index + 1,
293
+ lineText: raw.trim(),
294
+ referent: span,
295
+ kind: classification.kind,
296
+ tokens: estimateTokens(raw),
297
+ });
298
+ }
299
+ }
300
+ }
301
+ return report;
302
+ }
303
+ /** Total recurring cost of the lines the audit flagged. */
304
+ export function staleTokens(report) {
305
+ const seen = new Set();
306
+ let total = 0;
307
+ for (const item of report.stale) {
308
+ const key = `${item.file}:${item.line}`;
309
+ if (seen.has(key))
310
+ continue;
311
+ seen.add(key);
312
+ total += item.tokens;
313
+ }
314
+ return total;
315
+ }
316
+ //# sourceMappingURL=audit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit.js","sourceRoot":"","sources":["../../../src/core/learn/audit.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The subtractive half of `/learn`: which lines in a context file describe\n * things that no longer exist?\n *\n * The mining pipeline can only ever propose additions. Nothing in it moves the\n * always-loaded token surface down, so a context file accumulates: a rule\n * naming a deleted workflow, a command that was removed, a file that moved two\n * refactors ago. Those lines cost tokens on every request forever and are worse\n * than useless, because the agent believes them.\n *\n * This is deliberately deterministic — no model call, no cache, no state. It\n * reads the context files already in force, pulls out the referents they name\n * in backticks, and asks the filesystem. That makes it instant and free, which\n * is what lets it be the half you run most often.\n *\n * Precision is bought with exclusions rather than cleverness, because a noisy\n * audit is one nobody reads. Three rules do most of the work:\n *\n * - **Only path-like referents with a separator.** A bare `auth.json` could be\n * anywhere or nowhere; `docs/providers.md` is a claim about this repo.\n * - **Resolve against every package root, not just the repo root.** A monorepo\n * names `src/cli/args.ts` relative to the package being discussed, and\n * checking only the repo root reports the entire contributing guide as stale.\n * - **Skip lines that assert absence.** \"these are all gone\", \"e.g.\n * `bedrock-utils.ts`\", \"create `foo.ts`\" legitimately name files that do not\n * exist. Deciding this in general is a judgement call; a short vocabulary of\n * assertive forms catches the cases that occur in practice.\n *\n * What is left is a short list where a wrong entry costs one glance and a right\n * one costs a line of always-loaded context. That asymmetry is the reason the\n * remaining false positives are acceptable and silent misses are not.\n */\n\nimport { type Dirent, existsSync, readdirSync, readFileSync } from \"node:fs\";\nimport { join, resolve, sep } from \"node:path\";\n\n/** One referent that did not resolve, with the line that claimed it. */\nexport interface StaleReference {\n\t/** Context file the claim lives in. */\n\tfile: string;\n\t/** 1-based line number. */\n\tline: number;\n\t/** The line, trimmed — what the reader would delete or fix. */\n\tlineText: string;\n\t/** The referent that could not be found. */\n\treferent: string;\n\tkind: \"path\" | \"script\";\n\t/** Rough token cost of the line, so the value of deleting it is visible. */\n\ttokens: number;\n}\n\n/** Why a referent was not checked. Reported as counts so the audit's reach is visible. */\nexport interface AuditSkips {\n\t/** Contains a placeholder or a glob, e.g. an angle-bracket stand-in or a star. */\n\tplaceholder: number;\n\t/** Home-relative or absolute: a runtime location, not a repo artifact. */\n\truntime: number;\n\t/** A URL, or a git ref like `origin/main`. */\n\texternal: number;\n\t/** No path separator, so the claim is not about a specific location. */\n\tambiguous: number;\n\t/** The line asserts the referent is absent, optional, or to be created. */\n\tassertsAbsence: number;\n}\n\nexport interface AuditReport {\n\t/** Context files audited, with their recurring cost. */\n\tfiles: Array<{ path: string; tokens: number }>;\n\t/** Context files skipped because they live outside the working tree. */\n\tskippedFiles: string[];\n\t/** Referents actually resolved against the filesystem. */\n\tchecked: number;\n\tskipped: AuditSkips;\n\tstale: StaleReference[];\n\t/** Directories referents were resolved against, nearest first. */\n\troots: string[];\n}\n\n/** Directories never descended into when discovering package roots. */\nconst IGNORED_DIRS = new Set([\"node_modules\", \".git\", \"dist\", \"build\", \"coverage\", \"out\", \".next\", \".turbo\"]);\n\n/** How deep to look for package roots. Deep enough for a monorepo, shallow enough to stay instant. */\nconst MAX_ROOT_DEPTH = 3;\n\n/** Extensions that make a token a file reference even without an obvious path shape. */\nconst FILE_EXTENSIONS =\n\t/\\.(md|markdown|ts|tsx|js|jsx|mjs|cjs|json|jsonc|ya?ml|toml|sh|bash|zsh|py|rs|go|lock|txt|css|html)$/i;\n\n/**\n * Forms that legitimately name something absent.\n *\n * Every one of these was a false positive on a real context file before it was\n * excluded. `removed`/`gone` describe a deletion; `e.g.`/`for example` name a\n * pattern rather than a file; `create`/`scaffold` describe an artifact the\n * reader is being told to write.\n *\n * The list is deliberately short. Broad prohibitions (\"never edit X\", \"do not\n * run Y\") name things that exist and are exactly the claims worth checking, so\n * matching on those words would trade the audit's whole purpose for a little\n * precision.\n */\nconst ABSENCE_MARKERS = [\n\t\"removed\",\n\t\"deleted\",\n\t\" gone\",\n\t\"no longer\",\n\t\"used to\",\n\t\"if present\",\n\t\"if it exists\",\n\t\"optional\",\n\t\"e.g.\",\n\t\"for example\",\n\t\"create \",\n\t\"scaffold\",\n];\n\n/** Referents that look like a git ref rather than a path. */\nconst GIT_REF_PREFIXES = [\"origin/\", \"upstream/\", \"refs/\", \"HEAD\"];\n\n/** Rough token estimate, matching the convention used elsewhere for context files. */\nfunction estimateTokens(text: string): number {\n\treturn Math.round(Buffer.byteLength(text, \"utf-8\") / 4);\n}\n\n/**\n * Directories a relative referent may be resolved against.\n *\n * The repo root alone is not enough: a monorepo's contributing notes name\n * `src/cli/args.ts` meaning \"inside the package under discussion\", and resolving\n * that only from the root reports every such line as stale. Every directory\n * holding a `package.json` is therefore a root, nearest-shallowest first.\n */\nexport function resolutionRoots(base: string): string[] {\n\tconst roots: string[] = [resolve(base)];\n\n\tconst walk = (dir: string, depth: number): void => {\n\t\tif (depth > MAX_ROOT_DEPTH) return;\n\t\tlet entries: Dirent[];\n\t\ttry {\n\t\t\tentries = readdirSync(dir, { withFileTypes: true });\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\t\tfor (const entry of entries) {\n\t\t\tif (!entry.isDirectory() || IGNORED_DIRS.has(entry.name)) continue;\n\t\t\tconst child = join(dir, entry.name);\n\t\t\tif (existsSync(join(child, \"package.json\"))) roots.push(child);\n\t\t\twalk(child, depth + 1);\n\t\t}\n\t};\n\twalk(resolve(base), 1);\n\n\treturn roots;\n}\n\n/** Every backticked span in a line, in order. */\nfunction backtickedSpans(line: string): string[] {\n\tconst out: string[] = [];\n\tconst pattern = /`([^`\\n]+)`/g;\n\tlet match = pattern.exec(line);\n\twhile (match) {\n\t\tif (match[1]) out.push(match[1]);\n\t\tmatch = pattern.exec(line);\n\t}\n\treturn out;\n}\n\n/** True when the line reads as a statement about something absent, optional, or yet to be written. */\nfunction assertsAbsence(line: string): boolean {\n\tconst lower = line.toLowerCase();\n\treturn ABSENCE_MARKERS.some((marker) => lower.includes(marker));\n}\n\n/** The script name a `bun run x` / `npm run x` reference names, if it is one. */\nfunction scriptReference(token: string): string | undefined {\n\tconst match = /^(?:bun|npm|pnpm|yarn)\\s+run\\s+([A-Za-z0-9:_-]+)$/.exec(token.trim());\n\treturn match?.[1];\n}\n\ntype Classification =\n\t| { kind: \"path\" }\n\t| { kind: \"script\"; script: string }\n\t| { kind: \"skip\"; reason: keyof AuditSkips };\n\n/**\n * Decide whether a backticked token is a checkable claim about this repo.\n *\n * Ordering matters: the skip reasons are reported as counts, and a token that\n * matches several should be attributed to the most specific one, so a\n * placeholder is a placeholder rather than \"ambiguous\".\n */\nexport function classifyReferent(token: string): Classification {\n\tconst value = token.trim();\n\tconst script = scriptReference(value);\n\tif (script) return { kind: \"script\", script };\n\n\tif (/[<>*{}$|]/.test(value)) return { kind: \"skip\", reason: \"placeholder\" };\n\tif (/^https?:\\/\\//i.test(value)) return { kind: \"skip\", reason: \"external\" };\n\tif (GIT_REF_PREFIXES.some((prefix) => value.startsWith(prefix))) return { kind: \"skip\", reason: \"external\" };\n\t// A shell command, a prose fragment, or a flag list — not a path.\n\tif (/\\s/.test(value)) return { kind: \"skip\", reason: \"ambiguous\" };\n\tif (value.startsWith(\"~\") || value.startsWith(sep) || /^[A-Za-z]:[\\\\/]/.test(value)) {\n\t\treturn { kind: \"skip\", reason: \"runtime\" };\n\t}\n\tif (value === \".\" || value === \"..\") return { kind: \"skip\", reason: \"ambiguous\" };\n\t// The load-bearing precision rule: without a separator the token names a\n\t// filename that could be anywhere, and \"anywhere\" is not a claim worth\n\t// contradicting. A bare `stream.test.ts` in a monorepo is not stale, it is\n\t// under-specified.\n\tif (!value.includes(\"/\")) return { kind: \"skip\", reason: \"ambiguous\" };\n\t// Past the separator rule, something that is neither a known file type nor an\n\t// obvious directory is more likely prose than a path.\n\tif (!FILE_EXTENSIONS.test(value) && !value.endsWith(\"/\")) return { kind: \"skip\", reason: \"ambiguous\" };\n\n\treturn { kind: \"path\" };\n}\n\n/** True when the referent resolves against any root. Directories count. */\nfunction pathResolves(referent: string, roots: string[]): boolean {\n\tconst relative = referent.replace(/\\/+$/, \"\");\n\tif (!relative) return false;\n\treturn roots.some((root) => existsSync(join(root, relative)));\n}\n\n/** Script names declared by any `package.json` at any resolution root. */\nfunction declaredScripts(roots: string[]): Set<string> {\n\tconst names = new Set<string>();\n\tfor (const root of roots) {\n\t\tconst manifest = join(root, \"package.json\");\n\t\tif (!existsSync(manifest)) continue;\n\t\ttry {\n\t\t\tconst parsed = JSON.parse(readFileSync(manifest, \"utf-8\")) as { scripts?: Record<string, unknown> };\n\t\t\tfor (const name of Object.keys(parsed.scripts ?? {})) names.add(name);\n\t\t} catch {\n\t\t\t// A malformed manifest is not this command's problem; treat it as\n\t\t\t// declaring nothing rather than failing the audit.\n\t\t}\n\t}\n\treturn names;\n}\n\n/**\n * The project a referent is resolved against: the nearest ancestor holding a\n * `.git`, or `cwd` when there is none.\n *\n * Not `cwd` itself. Context files are collected by walking up from `cwd`, so in\n * a monorepo the repo's `AGENTS.md` sits *above* the package you are working\n * in — and running from a package root is the normal case, not the exception.\n * Anchoring on `cwd` meant the file with all the claims in it was declared \"not\n * in this working tree\" and skipped, so the audit passed by checking nothing.\n */\nexport function findProjectRoot(cwd: string): string {\n\tlet dir = resolve(cwd);\n\twhile (true) {\n\t\tif (existsSync(join(dir, \".git\"))) return dir;\n\t\tconst parent = resolve(dir, \"..\");\n\t\tif (parent === dir) return resolve(cwd);\n\t\tdir = parent;\n\t}\n}\n\n/** True when the path is inside the project, so its claims are about this repo. */\nfunction insideTree(path: string, root: string): boolean {\n\tconst base = resolve(root);\n\tconst target = resolve(path);\n\treturn target === base || target.startsWith(base + sep);\n}\n\nexport interface AuditOptions {\n\tcwd: string;\n\t/** Context files in force, as loaded for the system prompt. */\n\tfiles: Array<{ path: string; tokens?: number }>;\n}\n\n/**\n * Check every referent named by the repo-scope context files.\n *\n * Files outside the project are listed but not audited: a rule in\n * `~/.agents/AGENTS.md` naming `src/index.ts` is a claim about whichever repo\n * it was written for, and resolving it here would report another project's\n * rules as broken.\n *\n * File contents are re-read from disk rather than taken from the loader, which\n * truncates oversized files for the prompt — auditing the truncation would\n * silently stop checking exactly the files most likely to have gone stale.\n */\nexport function auditContextFiles(options: AuditOptions): AuditReport {\n\t// Rooted at the project, not at `cwd`: a path in the repo's context file is\n\t// written relative to the repo, and is being read from wherever you happen to\n\t// be working.\n\tconst projectRoot = findProjectRoot(options.cwd);\n\tconst roots = resolutionRoots(projectRoot);\n\tconst scripts = declaredScripts(roots);\n\n\tconst report: AuditReport = {\n\t\tfiles: [],\n\t\tskippedFiles: [],\n\t\tchecked: 0,\n\t\tskipped: { placeholder: 0, runtime: 0, external: 0, ambiguous: 0, assertsAbsence: 0 },\n\t\tstale: [],\n\t\troots,\n\t};\n\n\tfor (const file of options.files) {\n\t\tif (!insideTree(file.path, projectRoot)) {\n\t\t\treport.skippedFiles.push(file.path);\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet content: string;\n\t\ttry {\n\t\t\tcontent = readFileSync(file.path, \"utf-8\");\n\t\t} catch {\n\t\t\treport.skippedFiles.push(file.path);\n\t\t\tcontinue;\n\t\t}\n\t\treport.files.push({ path: file.path, tokens: file.tokens ?? estimateTokens(content) });\n\n\t\tconst lines = content.split(\"\\n\");\n\t\tlet inFence = false;\n\t\tfor (const [index, raw] of lines.entries()) {\n\t\t\t// A fenced block is example code, not a claim about the repo. Its\n\t\t\t// contents are also where most of a context file's plausible-looking\n\t\t\t// paths live, so auditing it is almost pure noise.\n\t\t\tif (raw.trimStart().startsWith(\"```\")) {\n\t\t\t\tinFence = !inFence;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (inFence) continue;\n\n\t\t\tconst spans = backtickedSpans(raw);\n\t\t\tif (spans.length === 0) continue;\n\n\t\t\tconst absence = assertsAbsence(raw);\n\t\t\tfor (const span of spans) {\n\t\t\t\tconst classification = classifyReferent(span);\n\t\t\t\tif (classification.kind === \"skip\") {\n\t\t\t\t\treport.skipped[classification.reason]++;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (absence) {\n\t\t\t\t\treport.skipped.assertsAbsence++;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\treport.checked++;\n\t\t\t\tconst resolved =\n\t\t\t\t\tclassification.kind === \"script\" ? scripts.has(classification.script) : pathResolves(span, roots);\n\t\t\t\tif (resolved) continue;\n\n\t\t\t\treport.stale.push({\n\t\t\t\t\tfile: file.path,\n\t\t\t\t\tline: index + 1,\n\t\t\t\t\tlineText: raw.trim(),\n\t\t\t\t\treferent: span,\n\t\t\t\t\tkind: classification.kind,\n\t\t\t\t\ttokens: estimateTokens(raw),\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\treturn report;\n}\n\n/** Total recurring cost of the lines the audit flagged. */\nexport function staleTokens(report: AuditReport): number {\n\tconst seen = new Set<string>();\n\tlet total = 0;\n\tfor (const item of report.stale) {\n\t\tconst key = `${item.file}:${item.line}`;\n\t\tif (seen.has(key)) continue;\n\t\tseen.add(key);\n\t\ttotal += item.tokens;\n\t}\n\treturn total;\n}\n"]}
@@ -1 +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"]}
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;AA0BhD,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, the candidate shape, or what the miner is shown\n * 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 *\n * v2: transcripts no longer carry successful tool output or replayed\n * slash-command bodies, and candidates are dropped when their quote cannot be\n * found in what the user said. Entries mined before that were read from a\n * different transcript than the one the pipeline now produces, so keeping them\n * would mean counting evidence the current rules would have rejected.\n *\n * v3: candidates no longer carry a label. Naming moved to a global pass that\n * sees the whole window, which is also what makes this file model-independent:\n * a cached label was frozen at mining time, so changing the `fast` tier forked\n * the vocabulary permanently and split every count across the seam.\n */\nconst CACHE_VERSION = 3;\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"]}
@@ -24,13 +24,25 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } fr
24
24
  import { join } from "node:path";
25
25
  import { writeFileAtomicSync } from "../../utils/atomic-file.js";
26
26
  /**
27
- * Bump when the miner prompt or the candidate shape changes.
27
+ * Bump when the miner prompt, the candidate shape, or what the miner is shown
28
+ * changes.
28
29
  *
29
30
  * The version is part of the cache key, not a field inside the entry, so a bump
30
31
  * invalidates every entry at once without a migration or a sweep — old files
31
32
  * simply stop being looked up, and the pruner reclaims them on age.
33
+ *
34
+ * v2: transcripts no longer carry successful tool output or replayed
35
+ * slash-command bodies, and candidates are dropped when their quote cannot be
36
+ * found in what the user said. Entries mined before that were read from a
37
+ * different transcript than the one the pipeline now produces, so keeping them
38
+ * would mean counting evidence the current rules would have rejected.
39
+ *
40
+ * v3: candidates no longer carry a label. Naming moved to a global pass that
41
+ * sees the whole window, which is also what makes this file model-independent:
42
+ * a cached label was frozen at mining time, so changing the `fast` tier forked
43
+ * the vocabulary permanently and split every count across the seam.
32
44
  */
33
- const CACHE_VERSION = 1;
45
+ const CACHE_VERSION = 3;
34
46
  /** Entries untouched for this long are reclaimed. */
35
47
  const CACHE_RETENTION_DAYS = 180;
36
48
  export function getLearnCacheDir(agentDir) {
@@ -1 +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"]}
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, the candidate shape, or what the miner is shown\n * 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 *\n * v2: transcripts no longer carry successful tool output or replayed\n * slash-command bodies, and candidates are dropped when their quote cannot be\n * found in what the user said. Entries mined before that were read from a\n * different transcript than the one the pipeline now produces, so keeping them\n * would mean counting evidence the current rules would have rejected.\n *\n * v3: candidates no longer carry a label. Naming moved to a global pass that\n * sees the whole window, which is also what makes this file model-independent:\n * a cached label was frozen at mining time, so changing the `fast` tier forked\n * the vocabulary permanently and split every count across the seam.\n */\nconst CACHE_VERSION = 3;\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,78 @@
1
+ /**
2
+ * The naming pass: decide which occurrences are the same point.
3
+ *
4
+ * This is the stage the pipeline was missing. Mining is a map over sessions and
5
+ * counting is a reduce over labels, but nothing sat in between to agree on what
6
+ * the labels *are*. The miner was asked to produce them from inside a single
7
+ * session — to hit a shared vocabulary it had never seen — and on a real corpus
8
+ * it agreed with itself 3 times in 188 candidates. `use-bun-not-npm` and
9
+ * `prefer-bun-over-npm` are the same rule and never met.
10
+ *
11
+ * So naming happens once, with everything visible at the same time. That is a
12
+ * different question than the miner was being asked: not "what is a good name
13
+ * for this sentence" but "which of these sentences are the same point", which
14
+ * is only answerable in the presence of the others.
15
+ *
16
+ * Two properties matter more than elegance here:
17
+ *
18
+ * - **Stability across runs.** State keys are `directive:<label>`, so a label
19
+ * that drifts between runs silently breaks suppression — every proposal you
20
+ * already decided on comes back forever. The labels already on record are
21
+ * therefore sent as a preferred vocabulary, and reusing one is the first
22
+ * instruction the model gets.
23
+ * - **Degrading in order.** A window too large for one call is processed in
24
+ * sequence, with the names assigned so far carried into the next call. That is
25
+ * worse than seeing everything at once, but it is worse in a predictable
26
+ * direction: later candidates join earlier clusters rather than starting
27
+ * rival ones.
28
+ */
29
+ import { type Model } from "@kolisachint/hoocode-ai";
30
+ import type { MinedCandidate } from "./mine.js";
31
+ /**
32
+ * Trim the vocabulary to what fits, keeping both ends.
33
+ *
34
+ * The list is ordered: labels already on record first, then names invented
35
+ * earlier in this run. Those are two different anchors — the first keeps the
36
+ * bookmark matching across runs, the second keeps a split window from starting
37
+ * rival names for one point — and taking a plain prefix silently drops the
38
+ * second exactly when batching makes it necessary.
39
+ */
40
+ export declare function trimVocabulary(labels: string[], max?: number): string[];
41
+ /** One candidate to be named, with the identity the caller needs to put the label back. */
42
+ export interface ClusterInput {
43
+ /** Caller's handle for this candidate; returned untouched. */
44
+ id: number;
45
+ kind: MinedCandidate["kind"];
46
+ text: string;
47
+ }
48
+ /**
49
+ * Assign a label to each input. Missing entries are left for the caller to
50
+ * handle; a clusterer may legitimately decline to name something.
51
+ */
52
+ export type Clusterer = (inputs: ClusterInput[], knownLabels: string[], signal?: AbortSignal) => Promise<Map<number, string>>;
53
+ /** Render the numbered list the prompt describes. */
54
+ export declare function renderClusterRequest(inputs: ClusterInput[], knownLabels: string[]): string;
55
+ /**
56
+ * Read the label assignments out of a model response.
57
+ *
58
+ * Same forgiving parse as the miner: models fence JSON they were told not to
59
+ * fence, and one unparseable response should cost the run its grouping, not its
60
+ * life. An id the caller never asked about is dropped rather than trusted.
61
+ */
62
+ export declare function parseClusterLabels(response: string, known: Set<number>): Map<number, string>;
63
+ export interface ClustererDeps {
64
+ model: Model<any>;
65
+ apiKey?: string;
66
+ headers?: Record<string, string>;
67
+ }
68
+ export declare function createLlmClusterer(deps: ClustererDeps): Clusterer;
69
+ /**
70
+ * Fallback naming for a candidate the clusterer did not label.
71
+ *
72
+ * A run whose clustering call failed should still propose something, so an
73
+ * unlabelled candidate falls back to a slug of its own text. That groups
74
+ * identical wording and nothing else — the behaviour the pipeline had before
75
+ * clustering existed, which is the right floor to fail to.
76
+ */
77
+ export declare function fallbackLabel(text: string): string;
78
+ //# sourceMappingURL=cluster.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cluster.d.ts","sourceRoot":"","sources":["../../../src/core/learn/cluster.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,EAAkB,KAAK,KAAK,EAAE,MAAM,yBAAyB,CAAC;AACrE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAehD;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,GAAG,SAAmB,GAAG,MAAM,EAAE,CAIjF;AAOD,2FAA2F;AAC3F,MAAM,WAAW,YAAY;IAC5B,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IAC7B,IAAI,EAAE,MAAM,CAAC;CACb;AAED;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,CACvB,MAAM,EAAE,YAAY,EAAE,EACtB,WAAW,EAAE,MAAM,EAAE,EACrB,MAAM,CAAC,EAAE,WAAW,KAChB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AAqBlC,qDAAqD;AACrD,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,YAAY,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,MAAM,CAgB1F;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAiC5F;AAED,MAAM,WAAW,aAAa;IAC7B,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,kBAAkB,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,CA2CjE;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQlD","sourcesContent":["/**\n * The naming pass: decide which occurrences are the same point.\n *\n * This is the stage the pipeline was missing. Mining is a map over sessions and\n * counting is a reduce over labels, but nothing sat in between to agree on what\n * the labels *are*. The miner was asked to produce them from inside a single\n * session — to hit a shared vocabulary it had never seen — and on a real corpus\n * it agreed with itself 3 times in 188 candidates. `use-bun-not-npm` and\n * `prefer-bun-over-npm` are the same rule and never met.\n *\n * So naming happens once, with everything visible at the same time. That is a\n * different question than the miner was being asked: not \"what is a good name\n * for this sentence\" but \"which of these sentences are the same point\", which\n * is only answerable in the presence of the others.\n *\n * Two properties matter more than elegance here:\n *\n * - **Stability across runs.** State keys are `directive:<label>`, so a label\n * that drifts between runs silently breaks suppression — every proposal you\n * already decided on comes back forever. The labels already on record are\n * therefore sent as a preferred vocabulary, and reusing one is the first\n * instruction the model gets.\n * - **Degrading in order.** A window too large for one call is processed in\n * sequence, with the names assigned so far carried into the next call. That is\n * worse than seeing everything at once, but it is worse in a predictable\n * direction: later candidates join earlier clusters rather than starting\n * rival ones.\n */\n\nimport { completeSimple, type Model } from \"@kolisachint/hoocode-ai\";\nimport type { MinedCandidate } from \"./mine.js\";\n\n/**\n * Candidates named per call.\n *\n * Sized so a typical window is one call: 200 quotes at ~120 characters is well\n * inside a small model's window with room for the reply. Past that, clustering\n * quality would degrade anyway — a list nobody can hold in mind is one nobody\n * names consistently.\n */\nconst MAX_CANDIDATES_PER_CALL = 200;\n\n/** Known labels offered as vocabulary. Enough to cover a real state file, short of flooding the prompt. */\nconst MAX_KNOWN_LABELS = 150;\n\n/**\n * Trim the vocabulary to what fits, keeping both ends.\n *\n * The list is ordered: labels already on record first, then names invented\n * earlier in this run. Those are two different anchors — the first keeps the\n * bookmark matching across runs, the second keeps a split window from starting\n * rival names for one point — and taking a plain prefix silently drops the\n * second exactly when batching makes it necessary.\n */\nexport function trimVocabulary(labels: string[], max = MAX_KNOWN_LABELS): string[] {\n\tif (labels.length <= max) return labels;\n\tconst head = Math.ceil(max / 2);\n\treturn [...labels.slice(0, head), ...labels.slice(-(max - head))];\n}\n\n/** Quote characters sent per candidate. A directive is identifiable long before this. */\nconst QUOTE_CHARS = 240;\n\nconst MAX_RESPONSE_TOKENS = 4_000;\n\n/** One candidate to be named, with the identity the caller needs to put the label back. */\nexport interface ClusterInput {\n\t/** Caller's handle for this candidate; returned untouched. */\n\tid: number;\n\tkind: MinedCandidate[\"kind\"];\n\ttext: string;\n}\n\n/**\n * Assign a label to each input. Missing entries are left for the caller to\n * handle; a clusterer may legitimately decline to name something.\n */\nexport type Clusterer = (\n\tinputs: ClusterInput[],\n\tknownLabels: string[],\n\tsignal?: AbortSignal,\n) => Promise<Map<number, string>>;\n\nconst CLUSTER_SYSTEM_PROMPT = `You group occurrences from coding sessions by what they MEAN, and give each group a name.\n\nYou are given numbered ITEMS. Each is something a user said, or something that happened, across many sessions. Different sessions phrase the same point differently — your job is to recognise that and name the point once.\n\nRules, in order of importance:\n\n1. If a label in KNOWN LABELS already names the point, reuse it EXACTLY. These are names already on record; reusing one is how a proposal the reader already decided on stays decided. Do not invent a synonym for a label that exists.\n2. Items meaning the same thing MUST get the same label, even when the wording shares no words.\n - \"we're on bun now\" / \"stop using npm install\" / \"pnpm isn't what we use here\" → use-bun-not-npm\n - \"never force push\" / \"don't rewrite shared history\" → never-force-push\n3. Items meaning different things MUST NOT share a label, even when the wording is similar. \"doc tools off by default\" and \"network tools off by default\" are the same shape and different rules.\n4. A label is a short kebab-case slug naming the point, 2-5 words. Name the point, not the session it came from.\n5. Never group across kinds. A directive (\"how work should be done\") and a request (\"do this piece of work\") are never the same item, even when they are about the same subject.\n\nOutput STRICT JSON, no markdown fence, no prose. One entry per item, using the item's number:\n{\"labels\":[{\"id\":1,\"label\":\"use-bun-not-npm\"},{\"id\":2,\"label\":\"use-bun-not-npm\"}]}\n\nEvery item gets exactly one label. An item that means something no other item means still gets its own label — a group of one is a normal answer.`;\n\n/** Render the numbered list the prompt describes. */\nexport function renderClusterRequest(inputs: ClusterInput[], knownLabels: string[]): string {\n\tconst lines: string[] = [];\n\n\tif (knownLabels.length > 0) {\n\t\tlines.push(\"KNOWN LABELS (reuse exactly when one fits):\");\n\t\tfor (const label of trimVocabulary(knownLabels)) lines.push(`- ${label}`);\n\t\tlines.push(\"\");\n\t}\n\n\tlines.push(\"ITEMS:\");\n\tfor (const input of inputs) {\n\t\tconst quote = input.text.length > QUOTE_CHARS ? `${input.text.slice(0, QUOTE_CHARS)}…` : input.text;\n\t\tlines.push(`${input.id}. [${input.kind}] ${quote.replace(/\\s+/g, \" \")}`);\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Read the label assignments out of a model response.\n *\n * Same forgiving parse as the miner: models fence JSON they were told not to\n * fence, and one unparseable response should cost the run its grouping, not its\n * life. An id the caller never asked about is dropped rather than trusted.\n */\nexport function parseClusterLabels(response: string, known: Set<number>): Map<number, string> {\n\tconst out = new Map<number, string>();\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 { labels?: unknown })?.labels;\n\tif (!Array.isArray(raw)) return out;\n\n\tfor (const item of raw) {\n\t\tif (!item || typeof item !== \"object\") continue;\n\t\tconst entry = item as Record<string, unknown>;\n\t\tconst id = typeof entry.id === \"number\" ? entry.id : Number.NaN;\n\t\tconst label = typeof entry.label === \"string\" ? entry.label.trim().toLowerCase() : \"\";\n\t\tif (!Number.isInteger(id) || !known.has(id) || !label) continue;\n\t\t// Normalized to the slug shape the state file keys on, so a model that\n\t\t// answers \"Use Bun Not Npm\" does not fork the vocabulary on punctuation.\n\t\tout.set(\n\t\t\tid,\n\t\t\tlabel\n\t\t\t\t.replace(/[^a-z0-9]+/g, \"-\")\n\t\t\t\t.replace(/^-|-$/g, \"\")\n\t\t\t\t.slice(0, 60),\n\t\t);\n\t}\n\treturn out;\n}\n\nexport interface ClustererDeps {\n\tmodel: Model<any>;\n\tapiKey?: string;\n\theaders?: Record<string, string>;\n}\n\nexport function createLlmClusterer(deps: ClustererDeps): Clusterer {\n\treturn async (inputs, knownLabels, signal) => {\n\t\tconst assigned = new Map<number, string>();\n\t\t// Labels invented in an earlier batch join the vocabulary for the next, so\n\t\t// a split window still converges on one name per point.\n\t\tconst vocabulary = [...knownLabels];\n\n\t\tfor (let offset = 0; offset < inputs.length; offset += MAX_CANDIDATES_PER_CALL) {\n\t\t\tif (signal?.aborted) break;\n\t\t\tconst batch = inputs.slice(offset, offset + MAX_CANDIDATES_PER_CALL);\n\n\t\t\tconst response = await completeSimple(\n\t\t\t\tdeps.model,\n\t\t\t\t{\n\t\t\t\t\tsystemPrompt: CLUSTER_SYSTEM_PROMPT,\n\t\t\t\t\tmessages: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\trole: \"user\",\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: renderClusterRequest(batch, vocabulary) }],\n\t\t\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t\t{ maxTokens: MAX_RESPONSE_TOKENS, signal, apiKey: deps.apiKey, headers: deps.headers },\n\t\t\t);\n\n\t\t\tif (response.stopReason === \"error\") {\n\t\t\t\tthrow new Error(response.errorMessage || \"clustering call failed\");\n\t\t\t}\n\n\t\t\tconst text = response.content\n\t\t\t\t.filter((block): block is { type: \"text\"; text: string } => block.type === \"text\")\n\t\t\t\t.map((block) => block.text)\n\t\t\t\t.join(\"\");\n\n\t\t\tfor (const [id, label] of parseClusterLabels(text, new Set(batch.map((item) => item.id)))) {\n\t\t\t\tassigned.set(id, label);\n\t\t\t\tif (!vocabulary.includes(label)) vocabulary.push(label);\n\t\t\t}\n\t\t}\n\n\t\treturn assigned;\n\t};\n}\n\n/**\n * Fallback naming for a candidate the clusterer did not label.\n *\n * A run whose clustering call failed should still propose something, so an\n * unlabelled candidate falls back to a slug of its own text. That groups\n * identical wording and nothing else — the behaviour the pipeline had before\n * clustering existed, which is the right floor to fail to.\n */\nexport function fallbackLabel(text: string): string {\n\treturn (\n\t\ttext\n\t\t\t.toLowerCase()\n\t\t\t.replace(/[^a-z0-9]+/g, \"-\")\n\t\t\t.replace(/^-|-$/g, \"\")\n\t\t\t.slice(0, 60) || \"unlabelled\"\n\t);\n}\n"]}