@kolisachint/hoocode-agent 0.5.17 → 0.5.19

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 (83) hide show
  1. package/CHANGELOG.md +247 -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 +58 -0
  7. package/dist/core/learn/cache.d.ts.map +1 -0
  8. package/dist/core/learn/cache.js +132 -0
  9. package/dist/core/learn/cache.js.map +1 -0
  10. package/dist/core/learn/cluster.d.ts +78 -0
  11. package/dist/core/learn/cluster.d.ts.map +1 -0
  12. package/dist/core/learn/cluster.js +184 -0
  13. package/dist/core/learn/cluster.js.map +1 -0
  14. package/dist/core/learn/coverage.d.ts +58 -0
  15. package/dist/core/learn/coverage.d.ts.map +1 -0
  16. package/dist/core/learn/coverage.js +144 -0
  17. package/dist/core/learn/coverage.js.map +1 -0
  18. package/dist/core/learn/digest.d.ts +13 -0
  19. package/dist/core/learn/digest.d.ts.map +1 -1
  20. package/dist/core/learn/digest.js +113 -14
  21. package/dist/core/learn/digest.js.map +1 -1
  22. package/dist/core/learn/extract.d.ts +108 -105
  23. package/dist/core/learn/extract.d.ts.map +1 -1
  24. package/dist/core/learn/extract.js +308 -447
  25. package/dist/core/learn/extract.js.map +1 -1
  26. package/dist/core/learn/mine.d.ts +178 -0
  27. package/dist/core/learn/mine.d.ts.map +1 -0
  28. package/dist/core/learn/mine.js +390 -0
  29. package/dist/core/learn/mine.js.map +1 -0
  30. package/dist/core/learn/reduce.d.ts +89 -0
  31. package/dist/core/learn/reduce.d.ts.map +1 -0
  32. package/dist/core/learn/reduce.js +179 -0
  33. package/dist/core/learn/reduce.js.map +1 -0
  34. package/dist/core/learn/state.d.ts +19 -18
  35. package/dist/core/learn/state.d.ts.map +1 -1
  36. package/dist/core/learn/state.js +35 -31
  37. package/dist/core/learn/state.js.map +1 -1
  38. package/dist/core/settings-defaults.d.ts +1 -1
  39. package/dist/core/settings-defaults.d.ts.map +1 -1
  40. package/dist/core/settings-defaults.js +1 -1
  41. package/dist/core/settings-defaults.js.map +1 -1
  42. package/dist/core/settings-manager.d.ts +4 -2
  43. package/dist/core/settings-manager.d.ts.map +1 -1
  44. package/dist/core/settings-manager.js +5 -1
  45. package/dist/core/settings-manager.js.map +1 -1
  46. package/dist/core/settings-types.d.ts +1 -1
  47. package/dist/core/settings-types.d.ts.map +1 -1
  48. package/dist/core/settings-types.js.map +1 -1
  49. package/dist/core/startup-progress.d.ts +12 -7
  50. package/dist/core/startup-progress.d.ts.map +1 -1
  51. package/dist/core/startup-progress.js +12 -7
  52. package/dist/core/startup-progress.js.map +1 -1
  53. package/dist/extensions/core/learn.d.ts +8 -4
  54. package/dist/extensions/core/learn.d.ts.map +1 -1
  55. package/dist/extensions/core/learn.js +292 -56
  56. package/dist/extensions/core/learn.js.map +1 -1
  57. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  58. package/dist/modes/interactive/components/footer.js +7 -25
  59. package/dist/modes/interactive/components/footer.js.map +1 -1
  60. package/dist/modes/interactive/components/progress-bar.d.ts +50 -0
  61. package/dist/modes/interactive/components/progress-bar.d.ts.map +1 -0
  62. package/dist/modes/interactive/components/progress-bar.js +77 -0
  63. package/dist/modes/interactive/components/progress-bar.js.map +1 -0
  64. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  65. package/dist/modes/interactive/components/settings-selector.js +1 -1
  66. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  67. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  68. package/dist/modes/interactive/interactive-mode.js +1 -1
  69. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  70. package/dist/modes/interactive/voice/voice-panel.d.ts +6 -1
  71. package/dist/modes/interactive/voice/voice-panel.d.ts.map +1 -1
  72. package/dist/modes/interactive/voice/voice-panel.js +18 -14
  73. package/dist/modes/interactive/voice/voice-panel.js.map +1 -1
  74. package/docs/settings.md +9 -6
  75. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  76. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  77. package/examples/extensions/sandbox/package.json +1 -1
  78. package/examples/extensions/with-deps/package.json +1 -1
  79. package/package.json +4 -4
  80. package/dist/core/learn/normalize.d.ts +0 -65
  81. package/dist/core/learn/normalize.d.ts.map +0 -1
  82. package/dist/core/learn/normalize.js +0 -245
  83. package/dist/core/learn/normalize.js.map +0 -1
@@ -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"]}
@@ -0,0 +1,184 @@
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 { completeSimple } from "@kolisachint/hoocode-ai";
30
+ /**
31
+ * Candidates named per call.
32
+ *
33
+ * Sized so a typical window is one call: 200 quotes at ~120 characters is well
34
+ * inside a small model's window with room for the reply. Past that, clustering
35
+ * quality would degrade anyway — a list nobody can hold in mind is one nobody
36
+ * names consistently.
37
+ */
38
+ const MAX_CANDIDATES_PER_CALL = 200;
39
+ /** Known labels offered as vocabulary. Enough to cover a real state file, short of flooding the prompt. */
40
+ const MAX_KNOWN_LABELS = 150;
41
+ /**
42
+ * Trim the vocabulary to what fits, keeping both ends.
43
+ *
44
+ * The list is ordered: labels already on record first, then names invented
45
+ * earlier in this run. Those are two different anchors — the first keeps the
46
+ * bookmark matching across runs, the second keeps a split window from starting
47
+ * rival names for one point — and taking a plain prefix silently drops the
48
+ * second exactly when batching makes it necessary.
49
+ */
50
+ export function trimVocabulary(labels, max = MAX_KNOWN_LABELS) {
51
+ if (labels.length <= max)
52
+ return labels;
53
+ const head = Math.ceil(max / 2);
54
+ return [...labels.slice(0, head), ...labels.slice(-(max - head))];
55
+ }
56
+ /** Quote characters sent per candidate. A directive is identifiable long before this. */
57
+ const QUOTE_CHARS = 240;
58
+ const MAX_RESPONSE_TOKENS = 4_000;
59
+ const CLUSTER_SYSTEM_PROMPT = `You group occurrences from coding sessions by what they MEAN, and give each group a name.
60
+
61
+ You 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.
62
+
63
+ Rules, in order of importance:
64
+
65
+ 1. 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.
66
+ 2. Items meaning the same thing MUST get the same label, even when the wording shares no words.
67
+ - "we're on bun now" / "stop using npm install" / "pnpm isn't what we use here" → use-bun-not-npm
68
+ - "never force push" / "don't rewrite shared history" → never-force-push
69
+ 3. 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.
70
+ 4. A label is a short kebab-case slug naming the point, 2-5 words. Name the point, not the session it came from.
71
+ 5. 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.
72
+
73
+ Output STRICT JSON, no markdown fence, no prose. One entry per item, using the item's number:
74
+ {"labels":[{"id":1,"label":"use-bun-not-npm"},{"id":2,"label":"use-bun-not-npm"}]}
75
+
76
+ Every 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.`;
77
+ /** Render the numbered list the prompt describes. */
78
+ export function renderClusterRequest(inputs, knownLabels) {
79
+ const lines = [];
80
+ if (knownLabels.length > 0) {
81
+ lines.push("KNOWN LABELS (reuse exactly when one fits):");
82
+ for (const label of trimVocabulary(knownLabels))
83
+ lines.push(`- ${label}`);
84
+ lines.push("");
85
+ }
86
+ lines.push("ITEMS:");
87
+ for (const input of inputs) {
88
+ const quote = input.text.length > QUOTE_CHARS ? `${input.text.slice(0, QUOTE_CHARS)}…` : input.text;
89
+ lines.push(`${input.id}. [${input.kind}] ${quote.replace(/\s+/g, " ")}`);
90
+ }
91
+ return lines.join("\n");
92
+ }
93
+ /**
94
+ * Read the label assignments out of a model response.
95
+ *
96
+ * Same forgiving parse as the miner: models fence JSON they were told not to
97
+ * fence, and one unparseable response should cost the run its grouping, not its
98
+ * life. An id the caller never asked about is dropped rather than trusted.
99
+ */
100
+ export function parseClusterLabels(response, known) {
101
+ const out = new Map();
102
+ const start = response.indexOf("{");
103
+ const end = response.lastIndexOf("}");
104
+ if (start < 0 || end <= start)
105
+ return out;
106
+ let parsed;
107
+ try {
108
+ parsed = JSON.parse(response.slice(start, end + 1));
109
+ }
110
+ catch {
111
+ return out;
112
+ }
113
+ const raw = parsed?.labels;
114
+ if (!Array.isArray(raw))
115
+ return out;
116
+ for (const item of raw) {
117
+ if (!item || typeof item !== "object")
118
+ continue;
119
+ const entry = item;
120
+ const id = typeof entry.id === "number" ? entry.id : Number.NaN;
121
+ const label = typeof entry.label === "string" ? entry.label.trim().toLowerCase() : "";
122
+ if (!Number.isInteger(id) || !known.has(id) || !label)
123
+ continue;
124
+ // Normalized to the slug shape the state file keys on, so a model that
125
+ // answers "Use Bun Not Npm" does not fork the vocabulary on punctuation.
126
+ out.set(id, label
127
+ .replace(/[^a-z0-9]+/g, "-")
128
+ .replace(/^-|-$/g, "")
129
+ .slice(0, 60));
130
+ }
131
+ return out;
132
+ }
133
+ export function createLlmClusterer(deps) {
134
+ return async (inputs, knownLabels, signal) => {
135
+ const assigned = new Map();
136
+ // Labels invented in an earlier batch join the vocabulary for the next, so
137
+ // a split window still converges on one name per point.
138
+ const vocabulary = [...knownLabels];
139
+ for (let offset = 0; offset < inputs.length; offset += MAX_CANDIDATES_PER_CALL) {
140
+ if (signal?.aborted)
141
+ break;
142
+ const batch = inputs.slice(offset, offset + MAX_CANDIDATES_PER_CALL);
143
+ const response = await completeSimple(deps.model, {
144
+ systemPrompt: CLUSTER_SYSTEM_PROMPT,
145
+ messages: [
146
+ {
147
+ role: "user",
148
+ content: [{ type: "text", text: renderClusterRequest(batch, vocabulary) }],
149
+ timestamp: Date.now(),
150
+ },
151
+ ],
152
+ }, { maxTokens: MAX_RESPONSE_TOKENS, signal, apiKey: deps.apiKey, headers: deps.headers });
153
+ if (response.stopReason === "error") {
154
+ throw new Error(response.errorMessage || "clustering call failed");
155
+ }
156
+ const text = response.content
157
+ .filter((block) => block.type === "text")
158
+ .map((block) => block.text)
159
+ .join("");
160
+ for (const [id, label] of parseClusterLabels(text, new Set(batch.map((item) => item.id)))) {
161
+ assigned.set(id, label);
162
+ if (!vocabulary.includes(label))
163
+ vocabulary.push(label);
164
+ }
165
+ }
166
+ return assigned;
167
+ };
168
+ }
169
+ /**
170
+ * Fallback naming for a candidate the clusterer did not label.
171
+ *
172
+ * A run whose clustering call failed should still propose something, so an
173
+ * unlabelled candidate falls back to a slug of its own text. That groups
174
+ * identical wording and nothing else — the behaviour the pipeline had before
175
+ * clustering existed, which is the right floor to fail to.
176
+ */
177
+ export function fallbackLabel(text) {
178
+ return (text
179
+ .toLowerCase()
180
+ .replace(/[^a-z0-9]+/g, "-")
181
+ .replace(/^-|-$/g, "")
182
+ .slice(0, 60) || "unlabelled");
183
+ }
184
+ //# sourceMappingURL=cluster.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cluster.js","sourceRoot":"","sources":["../../../src/core/learn/cluster.ts"],"names":[],"mappings":"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","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"]}
@@ -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;AA0DzC,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\nEach rule reads \\`[scope] Heading > Subheading > line\\`. The scope is \\`repo\\` (binds work in this project) or \\`user\\` (binds everywhere). The heading path is the section the line lives under, and it is what tells you the line's subject when the line alone is ambiguous.\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,144 @@
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
+ Each rule reads \`[scope] Heading > Subheading > line\`. The scope is \`repo\` (binds work in this project) or \`user\` (binds everywhere). The heading path is the section the line lives under, and it is what tells you the line's subject when the line alone is ambiguous.
35
+
36
+ For each proposal, decide:
37
+ - "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.
38
+ - "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.
39
+ - "new" — nothing covers it.
40
+
41
+ Judge by MEANING, not by shared words. Different vocabulary for the same instruction is covered. Shared vocabulary about different things is NOT covered:
42
+ - proposal "always use bun, never npm" vs rule "install dependencies with bun" → covered (rule)
43
+ - proposal "run tests before pushing" vs rule "CI runs the test suite on every PR" → NOT covered, these are different instructions to different actors
44
+ - proposal "prefer table output" vs rule "use tables in documentation" → NOT covered unless the scope matches
45
+
46
+ 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.
47
+
48
+ Rules win over skills when both match: rewriting a line is more actionable than sharpening a description.
49
+
50
+ Output STRICT JSON, no markdown fence, no prose. Use the proposal's exact label:
51
+ {"verdicts":[{"label":"use-bun-not-npm","verdict":"rule","ruleIndex":3},{"label":"scaffold-route","verdict":"skill","skillIndex":1},{"label":"prefer-tables","verdict":"new"}]}`;
52
+ function buildPrompt(queries, index) {
53
+ const lines = [];
54
+ lines.push("RULES:");
55
+ const rules = index.ruleLines.slice(0, MAX_RULE_LINES);
56
+ if (rules.length === 0)
57
+ lines.push("(none)");
58
+ for (const [i, rule] of rules.entries()) {
59
+ lines.push(`${i}. ${rule}`);
60
+ }
61
+ lines.push("", "SKILLS:");
62
+ const skills = index.skills.slice(0, MAX_SKILLS);
63
+ if (skills.length === 0)
64
+ lines.push("(none)");
65
+ for (const [i, skill] of skills.entries()) {
66
+ lines.push(`${i}. ${skill.name} — ${skill.description.slice(0, SKILL_DESCRIPTION_CHARS)}`);
67
+ }
68
+ lines.push("", "PROPOSALS:");
69
+ for (const query of queries) {
70
+ lines.push(`- label: ${query.label}\n text: ${query.text}`);
71
+ }
72
+ return lines.join("\n");
73
+ }
74
+ /** Read the verdict list back, ignoring anything malformed rather than failing the run. */
75
+ export function parseVerdicts(response, queries, index) {
76
+ const out = new Map();
77
+ const start = response.indexOf("{");
78
+ const end = response.lastIndexOf("}");
79
+ if (start < 0 || end <= start)
80
+ return out;
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(response.slice(start, end + 1));
84
+ }
85
+ catch {
86
+ return out;
87
+ }
88
+ const raw = parsed?.verdicts;
89
+ if (!Array.isArray(raw))
90
+ return out;
91
+ const known = new Set(queries.map((q) => q.label));
92
+ for (const item of raw) {
93
+ if (!item || typeof item !== "object")
94
+ continue;
95
+ const verdict = item;
96
+ const label = typeof verdict.label === "string" ? verdict.label.trim().toLowerCase() : "";
97
+ // A label the batch did not ask about is a hallucinated row; dropping it is
98
+ // safer than letting it mark some other proposal covered.
99
+ if (!label || !known.has(label))
100
+ continue;
101
+ if (verdict.verdict === "rule") {
102
+ const at = typeof verdict.ruleIndex === "number" ? index.ruleLines[verdict.ruleIndex] : undefined;
103
+ // An out-of-range index means the model decided "covered" but cannot show
104
+ // which line. Treat that as `new`: the reader cannot act on an unnamed rule.
105
+ if (at)
106
+ out.set(label, { rule: at });
107
+ continue;
108
+ }
109
+ if (verdict.verdict === "skill") {
110
+ const at = typeof verdict.skillIndex === "number" ? index.skills[verdict.skillIndex] : undefined;
111
+ if (at)
112
+ out.set(label, { skill: at.name });
113
+ continue;
114
+ }
115
+ out.set(label, {});
116
+ }
117
+ return out;
118
+ }
119
+ export function createLlmCoverageJudge(deps) {
120
+ return async (queries, index, signal) => {
121
+ // Nothing to match against means nothing can be covered, and the call would
122
+ // be pure cost.
123
+ if (queries.length === 0 || (index.ruleLines.length === 0 && index.skills.length === 0)) {
124
+ return new Map();
125
+ }
126
+ const response = await completeSimple(deps.model, {
127
+ systemPrompt: COVERAGE_SYSTEM_PROMPT,
128
+ messages: [
129
+ { role: "user", content: [{ type: "text", text: buildPrompt(queries, index) }], timestamp: Date.now() },
130
+ ],
131
+ }, { maxTokens: MAX_RESPONSE_TOKENS, signal, apiKey: deps.apiKey, headers: deps.headers });
132
+ if (response.stopReason === "error") {
133
+ throw new Error(response.errorMessage || "coverage call failed");
134
+ }
135
+ const text = response.content
136
+ .filter((c) => c.type === "text")
137
+ .map((c) => c.text)
138
+ .join("\n");
139
+ return parseVerdicts(text, queries, index);
140
+ };
141
+ }
142
+ /** Everything is new. Used when no model is available, so the run still produces a digest. */
143
+ export const noCoverageJudge = async () => new Map();
144
+ //# 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\nEach rule reads \\`[scope] Heading > Subheading > line\\`. The scope is \\`repo\\` (binds work in this project) or \\`user\\` (binds everywhere). The heading path is the section the line lives under, and it is what tells you the line's subject when the line alone is ambiguous.\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"]}
@@ -8,10 +8,23 @@
8
8
  * because "said in 5 of your last 12 sessions" is a decision the reader can
9
9
  * make in one keystroke, where "extracted from your session" is not.
10
10
  */
11
+ import type { AuditReport } from "./audit.js";
11
12
  import type { LearnDigest } from "./extract.js";
12
13
  /** True when there is nothing worth asking the model to look at. */
13
14
  export declare function isEmptyDigest(digest: LearnDigest): boolean;
15
+ /**
16
+ * Render the audit findings as a message the model can act on.
17
+ *
18
+ * Deliberately framed as questions rather than verdicts. The checker is
19
+ * deterministic and therefore confident, but "this path does not resolve" is
20
+ * not the same claim as "this line is wrong" — a context file may name a
21
+ * location the tool reads at runtime, or one that belongs to another checkout.
22
+ * Roughly a third of findings on a real file are of that kind, so the message
23
+ * that carries them has to ask for verification, not authorise a sweep.
24
+ */
25
+ export declare function renderAuditReport(report: AuditReport): string;
14
26
  export declare function renderLearnDigest(digest: LearnDigest, options: {
15
27
  userScopePath: string;
28
+ mode?: "incremental" | "all";
16
29
  }): string;
17
30
  //# sourceMappingURL=digest.d.ts.map