@kolisachint/hoocode-agent 0.5.16 → 0.5.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/dist/core/learn/cache.d.ts +58 -0
  3. package/dist/core/learn/cache.d.ts.map +1 -0
  4. package/dist/core/learn/cache.js +120 -0
  5. package/dist/core/learn/cache.js.map +1 -0
  6. package/dist/core/learn/coverage.d.ts +58 -0
  7. package/dist/core/learn/coverage.d.ts.map +1 -0
  8. package/dist/core/learn/coverage.js +142 -0
  9. package/dist/core/learn/coverage.js.map +1 -0
  10. package/dist/core/learn/digest.d.ts +1 -0
  11. package/dist/core/learn/digest.d.ts.map +1 -1
  12. package/dist/core/learn/digest.js +31 -4
  13. package/dist/core/learn/digest.js.map +1 -1
  14. package/dist/core/learn/extract.d.ts +71 -103
  15. package/dist/core/learn/extract.d.ts.map +1 -1
  16. package/dist/core/learn/extract.js +162 -437
  17. package/dist/core/learn/extract.js.map +1 -1
  18. package/dist/core/learn/mine.d.ts +123 -0
  19. package/dist/core/learn/mine.d.ts.map +1 -0
  20. package/dist/core/learn/mine.js +285 -0
  21. package/dist/core/learn/mine.js.map +1 -0
  22. package/dist/core/learn/reduce.d.ts +78 -0
  23. package/dist/core/learn/reduce.d.ts.map +1 -0
  24. package/dist/core/learn/reduce.js +123 -0
  25. package/dist/core/learn/reduce.js.map +1 -0
  26. package/dist/core/learn/state.d.ts +8 -0
  27. package/dist/core/learn/state.d.ts.map +1 -1
  28. package/dist/core/learn/state.js +18 -3
  29. package/dist/core/learn/state.js.map +1 -1
  30. package/dist/core/settings-manager.d.ts +2 -0
  31. package/dist/core/settings-manager.d.ts.map +1 -1
  32. package/dist/core/settings-manager.js +4 -0
  33. package/dist/core/settings-manager.js.map +1 -1
  34. package/dist/core/startup-progress.d.ts +12 -7
  35. package/dist/core/startup-progress.d.ts.map +1 -1
  36. package/dist/core/startup-progress.js +12 -7
  37. package/dist/core/startup-progress.js.map +1 -1
  38. package/dist/extensions/core/learn.d.ts +8 -4
  39. package/dist/extensions/core/learn.d.ts.map +1 -1
  40. package/dist/extensions/core/learn.js +208 -27
  41. package/dist/extensions/core/learn.js.map +1 -1
  42. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  43. package/dist/modes/interactive/components/footer.js +7 -25
  44. package/dist/modes/interactive/components/footer.js.map +1 -1
  45. package/dist/modes/interactive/components/progress-bar.d.ts +50 -0
  46. package/dist/modes/interactive/components/progress-bar.d.ts.map +1 -0
  47. package/dist/modes/interactive/components/progress-bar.js +77 -0
  48. package/dist/modes/interactive/components/progress-bar.js.map +1 -0
  49. package/dist/modes/interactive/voice/voice-panel.d.ts +6 -1
  50. package/dist/modes/interactive/voice/voice-panel.d.ts.map +1 -1
  51. package/dist/modes/interactive/voice/voice-panel.js +18 -14
  52. package/dist/modes/interactive/voice/voice-panel.js.map +1 -1
  53. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  54. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  55. package/examples/extensions/sandbox/package.json +1 -1
  56. package/examples/extensions/with-deps/package.json +1 -1
  57. package/package.json +4 -4
  58. package/dist/core/learn/normalize.d.ts +0 -65
  59. package/dist/core/learn/normalize.d.ts.map +0 -1
  60. package/dist/core/learn/normalize.js +0 -245
  61. package/dist/core/learn/normalize.js.map +0 -1
@@ -0,0 +1,285 @@
1
+ /**
2
+ * The map half of `/learn`: a model reads one session transcript and says what
3
+ * it saw.
4
+ *
5
+ * This replaces the regex gate that used to decide which user turns were worth
6
+ * looking at. That gate was a whitelist of imperative words, so a directive
7
+ * phrased any other way — "we're on bun now", "that's not how our error
8
+ * handling works" — was not ranked low, it was invisible. Recall was traded for
9
+ * a token budget, silently and unrecoverably.
10
+ *
11
+ * The trade here is explicit instead. Every user turn goes to the model
12
+ * verbatim; the budget is enforced by chunking and by a session cap the reader
13
+ * can see, not by a filter they cannot.
14
+ *
15
+ * What the model does *not* do is count. It reports occurrences one session at
16
+ * a time, and each one carries a `label` — its own normalization of what was
17
+ * meant. Counting identical labels across sessions is arithmetic, and it stays
18
+ * in code (see `reduce.ts`), for two reasons: models do not count reliably over
19
+ * long contexts, and a session mined in isolation cannot see recurrence anyway.
20
+ * Semantic grouping is the model's job; the number is not.
21
+ */
22
+ import { completeSimple } from "@kolisachint/hoocode-ai";
23
+ /**
24
+ * Chunking exists to fit a session into a context window, so it is sized from
25
+ * the window rather than from a fixed guess.
26
+ *
27
+ * The guess was costing calls. Rendering already strips assistant prose and
28
+ * truncates tool output, which compresses the two real transcripts in this repo
29
+ * from 0.93 MB and 2.26 MB down to 183 KB and 266 KB — about 47k and 68k
30
+ * tokens. A fixed 120k-character chunk cut those into two and three pieces for
31
+ * no reason: on any model with a 200k window each is comfortably one call.
32
+ *
33
+ * One call per session is also better than a cheaper-looking alternative. A
34
+ * chunk boundary is a blind spot — a failure and the fix that resolved it can
35
+ * land on opposite sides of one — so the fewer boundaries inside a session, the
36
+ * more the model can actually see.
37
+ */
38
+ const CHUNK_CONTEXT_FRACTION = 0.6;
39
+ /** Rough bytes per token. Deliberately conservative; a wrong guess here costs a wasted call. */
40
+ const CHARS_PER_TOKEN = 4;
41
+ /** Used when a model does not report a usable window. */
42
+ const FALLBACK_CHUNK_CHARS = 120_000;
43
+ /** Never chunk below this, or a small window would shred a transcript into noise. */
44
+ const MIN_CHUNK_CHARS = 40_000;
45
+ /**
46
+ * How much rendered transcript to send per call, given the reading model.
47
+ *
48
+ * Only a fraction of the window is used: the instructions, the response, and
49
+ * tokenizer variance all have to fit alongside, and overshooting costs a
50
+ * context-overflow error rather than a slightly worse answer.
51
+ */
52
+ export function chunkCharsForModel(model) {
53
+ const window = model.contextWindow;
54
+ if (!Number.isFinite(window) || window <= 0)
55
+ return FALLBACK_CHUNK_CHARS;
56
+ const budgetTokens = window * CHUNK_CONTEXT_FRACTION - MAX_RESPONSE_TOKENS;
57
+ return Math.max(MIN_CHUNK_CHARS, Math.floor(budgetTokens * CHARS_PER_TOKEN));
58
+ }
59
+ /** Tool output kept per call. Errors carry the signal; success output is mostly noise. */
60
+ const TOOL_OUTPUT_CHARS = 600;
61
+ const TOOL_ERROR_CHARS = 1_500;
62
+ /** Response ceiling per chunk. A chunk yielding more than this is noise, not signal. */
63
+ const MAX_RESPONSE_TOKENS = 4_000;
64
+ /** Candidates accepted from a single chunk, as a guard against a runaway response. */
65
+ const MAX_CANDIDATES_PER_CHUNK = 40;
66
+ /**
67
+ * Prefix on the message `/learn` injects. Its own digest is persisted like any
68
+ * other user turn, so without this the next run would mine its own output and
69
+ * every proposal would compound its own count.
70
+ */
71
+ export const LEARN_DIGEST_MARKER = "[learn-digest]";
72
+ function textOf(content) {
73
+ if (typeof content === "string")
74
+ return content;
75
+ if (!Array.isArray(content))
76
+ return "";
77
+ return content
78
+ .map((block) => block && typeof block === "object" && block.type === "text"
79
+ ? (block.text ?? "")
80
+ : "")
81
+ .join("\n")
82
+ .trim();
83
+ }
84
+ function isToolCall(block) {
85
+ return !!block && typeof block === "object" && block.type === "toolCall";
86
+ }
87
+ /** Compact one tool call's arguments — enough to recognise it, not enough to flood the window. */
88
+ function renderArgs(args) {
89
+ if (!args)
90
+ return "";
91
+ const parts = [];
92
+ for (const [key, value] of Object.entries(args)) {
93
+ if (typeof value === "string") {
94
+ parts.push(`${key}=${value.length > 200 ? `${value.slice(0, 200)}…` : value}`);
95
+ }
96
+ else if (typeof value === "number" || typeof value === "boolean") {
97
+ parts.push(`${key}=${value}`);
98
+ }
99
+ // Objects and arrays are structural detail the miner does not need.
100
+ }
101
+ return parts.join(" ");
102
+ }
103
+ /**
104
+ * Render a session as plain text for the model.
105
+ *
106
+ * User turns go in whole and unfiltered — that is the entire point of this
107
+ * rewrite, and any truncation here would quietly reintroduce the recall problem
108
+ * the regex gate had. Assistant prose is dropped: it is the bulk of a
109
+ * transcript and almost none of it is evidence about what the *user* wants.
110
+ * Tool calls are kept because a repeated sequence is a workflow and a
111
+ * failure-then-pass is a fix, and both are things worth proposing.
112
+ */
113
+ export function renderTranscript(session) {
114
+ const lines = [];
115
+ for (const entry of session.entries) {
116
+ const message = entry.type === "message" ? entry.message : undefined;
117
+ if (!message)
118
+ continue;
119
+ if (message.role === "user") {
120
+ const text = textOf(message.content);
121
+ // Skip the command's own past output, or proposals compound their counts.
122
+ if (!text || text.startsWith(LEARN_DIGEST_MARKER))
123
+ continue;
124
+ lines.push(`USER: ${text}`);
125
+ continue;
126
+ }
127
+ if (message.role === "assistant") {
128
+ for (const block of (message.content ?? [])) {
129
+ if (!isToolCall(block))
130
+ continue;
131
+ lines.push(`TOOL: ${block.name}(${renderArgs(block.arguments)})`);
132
+ }
133
+ continue;
134
+ }
135
+ if (message.role === "toolResult") {
136
+ const output = textOf(message.content);
137
+ if (!output)
138
+ continue;
139
+ const limit = message.isError ? TOOL_ERROR_CHARS : TOOL_OUTPUT_CHARS;
140
+ const label = message.isError ? "ERROR" : "RESULT";
141
+ lines.push(`${label}: ${output.length > limit ? `${output.slice(0, limit)}…` : output}`);
142
+ }
143
+ }
144
+ return lines.join("\n");
145
+ }
146
+ /**
147
+ * Split rendered text on line boundaries, so a chunk never cuts a user turn in
148
+ * half. A single turn longer than the budget gets its own oversized chunk
149
+ * rather than being split — losing the second half of a long directive is
150
+ * exactly the failure this rewrite exists to remove.
151
+ */
152
+ export function chunkTranscript(text, chunkChars = FALLBACK_CHUNK_CHARS) {
153
+ if (text.length <= chunkChars)
154
+ return text.length > 0 ? [text] : [];
155
+ const chunks = [];
156
+ let current = [];
157
+ let size = 0;
158
+ for (const line of text.split("\n")) {
159
+ if (size > 0 && size + line.length + 1 > chunkChars) {
160
+ chunks.push(current.join("\n"));
161
+ current = [];
162
+ size = 0;
163
+ }
164
+ current.push(line);
165
+ size += line.length + 1;
166
+ }
167
+ if (current.length > 0)
168
+ chunks.push(current.join("\n"));
169
+ return chunks;
170
+ }
171
+ const MINER_SYSTEM_PROMPT = `You read one coding-session transcript and report durable signals in it.
172
+
173
+ You are the recall stage of a two-stage pipeline. A later stage counts how often each signal recurs ACROSS sessions and decides what is worth writing down. Your job is to notice and name, not to judge importance and not to count — you are seeing one session and cannot know what repeats.
174
+
175
+ Report three kinds of thing.
176
+
177
+ **directive** — the user stating a preference, correction, constraint, or fact about how they want work done. Include these regardless of phrasing. All of these are directives:
178
+ - imperative: "always run the tests before pushing"
179
+ - corrective: "no, that's not how our error handling works"
180
+ - declarative: "we're on bun now", "the API returns snake_case"
181
+ - preference stated once, in passing: "I'd rather see this as a table"
182
+ Do NOT report task requests ("add a button to the header", "fix the login bug"). A task is what to do now; a directive is how things should be done in general. When a message contains both, report only the directive part.
183
+
184
+ **fix** — a command that failed and later succeeded, where something in between was the cause. Report the failing command, a short error excerpt, and what changed in between.
185
+
186
+ **workflow** — a sequence of three or more tool calls that recurs within this session, or that clearly represents a routine procedure (scaffold a file, then register it, then test it).
187
+
188
+ For every item, produce a "label": a short kebab-case slug naming what was MEANT, not what was said. The label is how occurrences are grouped across sessions, so two different phrasings of the same underlying point MUST get the same label.
189
+ - "we're on bun now" → use-bun-not-npm
190
+ - "stop using npm install" → use-bun-not-npm
191
+ - "pnpm isn't what we use here" → use-bun-not-npm
192
+ Keep labels general enough to collide when they mean the same thing, specific enough not to collide when they do not. Prefer 2-5 words.
193
+
194
+ Output STRICT JSON, no markdown fence, no prose:
195
+ {"candidates":[{"kind":"directive","label":"use-bun-not-npm","text":"<verbatim quote>","rationale":"<one clause on why it is durable>"}]}
196
+
197
+ For fix items add: "command", "errorExcerpt", "interveningCommands" (array), "editedFiles" (array).
198
+ For workflow items add: "steps" (array of tool names in order).
199
+
200
+ Report nothing rather than padding. An empty list is a correct answer for a session that taught nothing: {"candidates":[]}`;
201
+ /**
202
+ * Pull the JSON object out of a model response.
203
+ *
204
+ * Models fence JSON even when told not to, and occasionally prepend a sentence.
205
+ * Scanning for the outermost braces is more forgiving than trusting the format
206
+ * and cheaper than a repair pass — and a chunk whose response cannot be parsed
207
+ * is skipped, never fatal, because one bad chunk should not lose a whole run.
208
+ */
209
+ export function parseCandidates(response) {
210
+ const start = response.indexOf("{");
211
+ const end = response.lastIndexOf("}");
212
+ if (start < 0 || end <= start)
213
+ return [];
214
+ let parsed;
215
+ try {
216
+ parsed = JSON.parse(response.slice(start, end + 1));
217
+ }
218
+ catch {
219
+ return [];
220
+ }
221
+ const raw = parsed?.candidates;
222
+ if (!Array.isArray(raw))
223
+ return [];
224
+ const out = [];
225
+ for (const item of raw.slice(0, MAX_CANDIDATES_PER_CHUNK)) {
226
+ if (!item || typeof item !== "object")
227
+ continue;
228
+ const candidate = item;
229
+ const kind = candidate.kind;
230
+ if (kind !== "directive" && kind !== "fix" && kind !== "workflow")
231
+ continue;
232
+ const label = typeof candidate.label === "string" ? candidate.label.trim().toLowerCase() : "";
233
+ const text = typeof candidate.text === "string" ? candidate.text.trim() : "";
234
+ // A candidate with no label cannot be grouped, and one with no text cannot
235
+ // be quoted back — either way there is nothing to show the reader.
236
+ if (!label || !text)
237
+ continue;
238
+ const strings = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string").slice(0, 12) : undefined;
239
+ out.push({
240
+ kind,
241
+ label,
242
+ text,
243
+ rationale: typeof candidate.rationale === "string" ? candidate.rationale.trim() : undefined,
244
+ command: typeof candidate.command === "string" ? candidate.command : undefined,
245
+ errorExcerpt: typeof candidate.errorExcerpt === "string" ? candidate.errorExcerpt.slice(0, 400) : undefined,
246
+ interveningCommands: strings(candidate.interveningCommands),
247
+ editedFiles: strings(candidate.editedFiles),
248
+ steps: strings(candidate.steps),
249
+ });
250
+ }
251
+ return out;
252
+ }
253
+ /**
254
+ * Build the real miner: one model call per chunk of one session.
255
+ *
256
+ * Chunks are mined sequentially rather than in parallel. A cold-cache run is
257
+ * already the expensive path, and firing every chunk of every session at once
258
+ * is how you trip a provider rate limit on exactly the run that has the most to
259
+ * do.
260
+ */
261
+ export function createLlmMiner(deps) {
262
+ const chunkChars = chunkCharsForModel(deps.model);
263
+ return async (session, signal) => {
264
+ const chunks = chunkTranscript(renderTranscript(session), chunkChars);
265
+ const candidates = [];
266
+ for (const chunk of chunks) {
267
+ if (signal?.aborted)
268
+ break;
269
+ const response = await completeSimple(deps.model, {
270
+ systemPrompt: MINER_SYSTEM_PROMPT,
271
+ messages: [{ role: "user", content: [{ type: "text", text: chunk }], timestamp: Date.now() }],
272
+ }, { maxTokens: MAX_RESPONSE_TOKENS, signal, apiKey: deps.apiKey, headers: deps.headers });
273
+ if (response.stopReason === "error") {
274
+ throw new Error(response.errorMessage || "miner call failed");
275
+ }
276
+ const text = response.content
277
+ .filter((c) => c.type === "text")
278
+ .map((c) => c.text)
279
+ .join("\n");
280
+ candidates.push(...parseCandidates(text));
281
+ }
282
+ return candidates;
283
+ };
284
+ }
285
+ //# sourceMappingURL=mine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mine.js","sourceRoot":"","sources":["../../../src/core/learn/mine.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The map half of `/learn`: a model reads one session transcript and says what\n * it saw.\n *\n * This replaces the regex gate that used to decide which user turns were worth\n * looking at. That gate was a whitelist of imperative words, so a directive\n * phrased any other way — \"we're on bun now\", \"that's not how our error\n * handling works\" — was not ranked low, it was invisible. Recall was traded for\n * a token budget, silently and unrecoverably.\n *\n * The trade here is explicit instead. Every user turn goes to the model\n * verbatim; the budget is enforced by chunking and by a session cap the reader\n * can see, not by a filter they cannot.\n *\n * What the model does *not* do is count. It reports occurrences one session at\n * a time, and each one carries a `label` — its own normalization of what was\n * meant. Counting identical labels across sessions is arithmetic, and it stays\n * in code (see `reduce.ts`), for two reasons: models do not count reliably over\n * long contexts, and a session mined in isolation cannot see recurrence anyway.\n * Semantic grouping is the model's job; the number is not.\n */\n\nimport type { AgentMessage } from \"@kolisachint/hoocode-agent-core\";\nimport type { Model, TextContent, ToolCall } from \"@kolisachint/hoocode-ai\";\nimport { completeSimple } from \"@kolisachint/hoocode-ai\";\n\n/** What kind of thing the model noticed. */\nexport type CandidateKind = \"directive\" | \"fix\" | \"workflow\";\n\n/**\n * One occurrence, as reported by the model reading a single session.\n *\n * `label` is the load-bearing field. It is the model's canonical name for what\n * was meant — \"use-bun-not-npm\" for all of \"we're on bun now\", \"stop using\n * npm\", and \"pnpm isn't what we use\" — and it is what the reduce step groups\n * on. Getting a stable label out of the model is what buys semantic clustering\n * that `normalizeDirective`'s lowercase-and-strip-punctuation could never do.\n */\nexport interface MinedCandidate {\n\tkind: CandidateKind;\n\t/** Canonical slug for what was meant. The clustering key. */\n\tlabel: string;\n\t/** Verbatim text from the transcript, so the digest can quote rather than paraphrase. */\n\ttext: string;\n\t/** Why this is durable, in the model's words. Shown when a proposal is borderline. */\n\trationale?: string;\n\t/** The failing command, for `fix` candidates. */\n\tcommand?: string;\n\t/** Short error excerpt, for `fix` candidates. */\n\terrorExcerpt?: string;\n\t/** What was done in between, for `fix` candidates. */\n\tinterveningCommands?: string[];\n\t/** Files changed as part of the fix. */\n\teditedFiles?: string[];\n\t/** Tool sequence, for `workflow` candidates. */\n\tsteps?: string[];\n}\n\n/** A session reduced to what the miner needs: identity, time, and rendered text. */\nexport interface MinableSession {\n\tid: string;\n\ttimestamp: string;\n\tentries: Array<{ type: string; message?: AgentMessage }>;\n}\n\n/**\n * Mines one session. Injectable so the reduce path can be tested without a\n * model, and so a cached result can stand in for a live call.\n */\nexport type Miner = (session: MinableSession, signal?: AbortSignal) => Promise<MinedCandidate[]>;\n\n/**\n * Chunking exists to fit a session into a context window, so it is sized from\n * the window rather than from a fixed guess.\n *\n * The guess was costing calls. Rendering already strips assistant prose and\n * truncates tool output, which compresses the two real transcripts in this repo\n * from 0.93 MB and 2.26 MB down to 183 KB and 266 KB — about 47k and 68k\n * tokens. A fixed 120k-character chunk cut those into two and three pieces for\n * no reason: on any model with a 200k window each is comfortably one call.\n *\n * One call per session is also better than a cheaper-looking alternative. A\n * chunk boundary is a blind spot — a failure and the fix that resolved it can\n * land on opposite sides of one — so the fewer boundaries inside a session, the\n * more the model can actually see.\n */\nconst CHUNK_CONTEXT_FRACTION = 0.6;\n\n/** Rough bytes per token. Deliberately conservative; a wrong guess here costs a wasted call. */\nconst CHARS_PER_TOKEN = 4;\n\n/** Used when a model does not report a usable window. */\nconst FALLBACK_CHUNK_CHARS = 120_000;\n\n/** Never chunk below this, or a small window would shred a transcript into noise. */\nconst MIN_CHUNK_CHARS = 40_000;\n\n/**\n * How much rendered transcript to send per call, given the reading model.\n *\n * Only a fraction of the window is used: the instructions, the response, and\n * tokenizer variance all have to fit alongside, and overshooting costs a\n * context-overflow error rather than a slightly worse answer.\n */\nexport function chunkCharsForModel(model: Pick<Model<any>, \"contextWindow\">): number {\n\tconst window = model.contextWindow;\n\tif (!Number.isFinite(window) || window <= 0) return FALLBACK_CHUNK_CHARS;\n\tconst budgetTokens = window * CHUNK_CONTEXT_FRACTION - MAX_RESPONSE_TOKENS;\n\treturn Math.max(MIN_CHUNK_CHARS, Math.floor(budgetTokens * CHARS_PER_TOKEN));\n}\n\n/** Tool output kept per call. Errors carry the signal; success output is mostly noise. */\nconst TOOL_OUTPUT_CHARS = 600;\nconst TOOL_ERROR_CHARS = 1_500;\n\n/** Response ceiling per chunk. A chunk yielding more than this is noise, not signal. */\nconst MAX_RESPONSE_TOKENS = 4_000;\n\n/** Candidates accepted from a single chunk, as a guard against a runaway response. */\nconst MAX_CANDIDATES_PER_CHUNK = 40;\n\n/**\n * Prefix on the message `/learn` injects. Its own digest is persisted like any\n * other user turn, so without this the next run would mine its own output and\n * every proposal would compound its own count.\n */\nexport const LEARN_DIGEST_MARKER = \"[learn-digest]\";\n\nfunction textOf(content: unknown): string {\n\tif (typeof content === \"string\") return content;\n\tif (!Array.isArray(content)) return \"\";\n\treturn content\n\t\t.map((block) =>\n\t\t\tblock && typeof block === \"object\" && (block as TextContent).type === \"text\"\n\t\t\t\t? ((block as TextContent).text ?? \"\")\n\t\t\t\t: \"\",\n\t\t)\n\t\t.join(\"\\n\")\n\t\t.trim();\n}\n\nfunction isToolCall(block: unknown): block is ToolCall {\n\treturn !!block && typeof block === \"object\" && (block as ToolCall).type === \"toolCall\";\n}\n\n/** Compact one tool call's arguments — enough to recognise it, not enough to flood the window. */\nfunction renderArgs(args: Record<string, unknown> | undefined): string {\n\tif (!args) return \"\";\n\tconst parts: string[] = [];\n\tfor (const [key, value] of Object.entries(args)) {\n\t\tif (typeof value === \"string\") {\n\t\t\tparts.push(`${key}=${value.length > 200 ? `${value.slice(0, 200)}…` : value}`);\n\t\t} else if (typeof value === \"number\" || typeof value === \"boolean\") {\n\t\t\tparts.push(`${key}=${value}`);\n\t\t}\n\t\t// Objects and arrays are structural detail the miner does not need.\n\t}\n\treturn parts.join(\" \");\n}\n\n/**\n * Render a session as plain text for the model.\n *\n * User turns go in whole and unfiltered — that is the entire point of this\n * rewrite, and any truncation here would quietly reintroduce the recall problem\n * the regex gate had. Assistant prose is dropped: it is the bulk of a\n * transcript and almost none of it is evidence about what the *user* wants.\n * Tool calls are kept because a repeated sequence is a workflow and a\n * failure-then-pass is a fix, and both are things worth proposing.\n */\nexport function renderTranscript(session: MinableSession): string {\n\tconst lines: string[] = [];\n\n\tfor (const entry of session.entries) {\n\t\tconst message = entry.type === \"message\" ? entry.message : undefined;\n\t\tif (!message) continue;\n\n\t\tif (message.role === \"user\") {\n\t\t\tconst text = textOf(message.content);\n\t\t\t// Skip the command's own past output, or proposals compound their counts.\n\t\t\tif (!text || text.startsWith(LEARN_DIGEST_MARKER)) continue;\n\t\t\tlines.push(`USER: ${text}`);\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (message.role === \"assistant\") {\n\t\t\tfor (const block of (message.content ?? []) as unknown[]) {\n\t\t\t\tif (!isToolCall(block)) continue;\n\t\t\t\tlines.push(`TOOL: ${block.name}(${renderArgs(block.arguments as Record<string, unknown>)})`);\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (message.role === \"toolResult\") {\n\t\t\tconst output = textOf(message.content);\n\t\t\tif (!output) continue;\n\t\t\tconst limit = message.isError ? TOOL_ERROR_CHARS : TOOL_OUTPUT_CHARS;\n\t\t\tconst label = message.isError ? \"ERROR\" : \"RESULT\";\n\t\t\tlines.push(`${label}: ${output.length > limit ? `${output.slice(0, limit)}…` : output}`);\n\t\t}\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Split rendered text on line boundaries, so a chunk never cuts a user turn in\n * half. A single turn longer than the budget gets its own oversized chunk\n * rather than being split — losing the second half of a long directive is\n * exactly the failure this rewrite exists to remove.\n */\nexport function chunkTranscript(text: string, chunkChars = FALLBACK_CHUNK_CHARS): string[] {\n\tif (text.length <= chunkChars) return text.length > 0 ? [text] : [];\n\n\tconst chunks: string[] = [];\n\tlet current: string[] = [];\n\tlet size = 0;\n\tfor (const line of text.split(\"\\n\")) {\n\t\tif (size > 0 && size + line.length + 1 > chunkChars) {\n\t\t\tchunks.push(current.join(\"\\n\"));\n\t\t\tcurrent = [];\n\t\t\tsize = 0;\n\t\t}\n\t\tcurrent.push(line);\n\t\tsize += line.length + 1;\n\t}\n\tif (current.length > 0) chunks.push(current.join(\"\\n\"));\n\treturn chunks;\n}\n\nconst MINER_SYSTEM_PROMPT = `You read one coding-session transcript and report durable signals in it.\n\nYou are the recall stage of a two-stage pipeline. A later stage counts how often each signal recurs ACROSS sessions and decides what is worth writing down. Your job is to notice and name, not to judge importance and not to count — you are seeing one session and cannot know what repeats.\n\nReport three kinds of thing.\n\n**directive** — the user stating a preference, correction, constraint, or fact about how they want work done. Include these regardless of phrasing. All of these are directives:\n- imperative: \"always run the tests before pushing\"\n- corrective: \"no, that's not how our error handling works\"\n- declarative: \"we're on bun now\", \"the API returns snake_case\"\n- preference stated once, in passing: \"I'd rather see this as a table\"\nDo NOT report task requests (\"add a button to the header\", \"fix the login bug\"). A task is what to do now; a directive is how things should be done in general. When a message contains both, report only the directive part.\n\n**fix** — a command that failed and later succeeded, where something in between was the cause. Report the failing command, a short error excerpt, and what changed in between.\n\n**workflow** — a sequence of three or more tool calls that recurs within this session, or that clearly represents a routine procedure (scaffold a file, then register it, then test it).\n\nFor every item, produce a \"label\": a short kebab-case slug naming what was MEANT, not what was said. The label is how occurrences are grouped across sessions, so two different phrasings of the same underlying point MUST get the same label.\n- \"we're on bun now\" → use-bun-not-npm\n- \"stop using npm install\" → use-bun-not-npm\n- \"pnpm isn't what we use here\" → use-bun-not-npm\nKeep labels general enough to collide when they mean the same thing, specific enough not to collide when they do not. Prefer 2-5 words.\n\nOutput STRICT JSON, no markdown fence, no prose:\n{\"candidates\":[{\"kind\":\"directive\",\"label\":\"use-bun-not-npm\",\"text\":\"<verbatim quote>\",\"rationale\":\"<one clause on why it is durable>\"}]}\n\nFor fix items add: \"command\", \"errorExcerpt\", \"interveningCommands\" (array), \"editedFiles\" (array).\nFor workflow items add: \"steps\" (array of tool names in order).\n\nReport nothing rather than padding. An empty list is a correct answer for a session that taught nothing: {\"candidates\":[]}`;\n\n/**\n * Pull the JSON object out of a model response.\n *\n * Models fence JSON even when told not to, and occasionally prepend a sentence.\n * Scanning for the outermost braces is more forgiving than trusting the format\n * and cheaper than a repair pass — and a chunk whose response cannot be parsed\n * is skipped, never fatal, because one bad chunk should not lose a whole run.\n */\nexport function parseCandidates(response: string): MinedCandidate[] {\n\tconst start = response.indexOf(\"{\");\n\tconst end = response.lastIndexOf(\"}\");\n\tif (start < 0 || end <= start) return [];\n\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(response.slice(start, end + 1));\n\t} catch {\n\t\treturn [];\n\t}\n\n\tconst raw = (parsed as { candidates?: unknown })?.candidates;\n\tif (!Array.isArray(raw)) return [];\n\n\tconst out: MinedCandidate[] = [];\n\tfor (const item of raw.slice(0, MAX_CANDIDATES_PER_CHUNK)) {\n\t\tif (!item || typeof item !== \"object\") continue;\n\t\tconst candidate = item as Record<string, unknown>;\n\t\tconst kind = candidate.kind;\n\t\tif (kind !== \"directive\" && kind !== \"fix\" && kind !== \"workflow\") continue;\n\n\t\tconst label = typeof candidate.label === \"string\" ? candidate.label.trim().toLowerCase() : \"\";\n\t\tconst text = typeof candidate.text === \"string\" ? candidate.text.trim() : \"\";\n\t\t// A candidate with no label cannot be grouped, and one with no text cannot\n\t\t// be quoted back — either way there is nothing to show the reader.\n\t\tif (!label || !text) continue;\n\n\t\tconst strings = (value: unknown): string[] | undefined =>\n\t\t\tArray.isArray(value) ? value.filter((v): v is string => typeof v === \"string\").slice(0, 12) : undefined;\n\n\t\tout.push({\n\t\t\tkind,\n\t\t\tlabel,\n\t\t\ttext,\n\t\t\trationale: typeof candidate.rationale === \"string\" ? candidate.rationale.trim() : undefined,\n\t\t\tcommand: typeof candidate.command === \"string\" ? candidate.command : undefined,\n\t\t\terrorExcerpt: typeof candidate.errorExcerpt === \"string\" ? candidate.errorExcerpt.slice(0, 400) : undefined,\n\t\t\tinterveningCommands: strings(candidate.interveningCommands),\n\t\t\teditedFiles: strings(candidate.editedFiles),\n\t\t\tsteps: strings(candidate.steps),\n\t\t});\n\t}\n\treturn out;\n}\n\nexport interface MinerDeps {\n\tmodel: Model<any>;\n\tapiKey?: string;\n\theaders?: Record<string, string>;\n}\n\n/**\n * Build the real miner: one model call per chunk of one session.\n *\n * Chunks are mined sequentially rather than in parallel. A cold-cache run is\n * already the expensive path, and firing every chunk of every session at once\n * is how you trip a provider rate limit on exactly the run that has the most to\n * do.\n */\nexport function createLlmMiner(deps: MinerDeps): Miner {\n\tconst chunkChars = chunkCharsForModel(deps.model);\n\treturn async (session, signal) => {\n\t\tconst chunks = chunkTranscript(renderTranscript(session), chunkChars);\n\t\tconst candidates: MinedCandidate[] = [];\n\n\t\tfor (const chunk of chunks) {\n\t\t\tif (signal?.aborted) break;\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: MINER_SYSTEM_PROMPT,\n\t\t\t\t\tmessages: [{ role: \"user\", content: [{ type: \"text\", text: chunk }], timestamp: Date.now() }],\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 || \"miner call failed\");\n\t\t\t}\n\n\t\t\tconst text = response.content\n\t\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t\t.map((c) => c.text)\n\t\t\t\t.join(\"\\n\");\n\t\t\tcandidates.push(...parseCandidates(text));\n\t\t}\n\n\t\treturn candidates;\n\t};\n}\n"]}
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The reduce half of `/learn`: turn per-session observations into counts.
3
+ *
4
+ * The split with `mine.ts` is the whole design. Deciding that "we're on bun
5
+ * now" and "stop using npm" mean the same thing is semantics, and the model is
6
+ * better at it than any normalizer — so the model does it, by emitting a shared
7
+ * `label`. Deciding that the shared label occurred nine times across five
8
+ * sessions is arithmetic, and arithmetic stays here, because a model asked to
9
+ * count over a long context will be approximately right, and an approximately
10
+ * right number is worse than none when the number is the thing the reader acts
11
+ * on.
12
+ *
13
+ * Nothing in this file filters on content. The only gate is the repeat
14
+ * threshold, which is a dial the user sets and the digest reports, not a
15
+ * whitelist they cannot see.
16
+ */
17
+ import type { MinedCandidate } from "./mine.js";
18
+ /** Where a repeated directive already lives, if anywhere. */
19
+ export type DirectiveStatus = "new" | "restated" | "has-skill";
20
+ /** Fields every proposable item shares, so suppression can be applied uniformly. */
21
+ export interface Proposable {
22
+ /** Stable identity across runs — what the state file remembers. */
23
+ key: string;
24
+ /** Newest occurrence in the window, ISO. */
25
+ lastSeen: string;
26
+ }
27
+ export interface DirectiveCluster extends Proposable {
28
+ /** The model's canonical name for what was meant. The clustering key. */
29
+ label: string;
30
+ /** Representative verbatim quote, the longest seen in the cluster. */
31
+ text: string;
32
+ /** Why it is durable, in the model's words. */
33
+ rationale?: string;
34
+ /** Total times said. */
35
+ count: number;
36
+ /** Distinct sessions it was said in — the stronger of the two counts. */
37
+ sessions: number;
38
+ status: DirectiveStatus;
39
+ /** The existing rule line matched, when status is `restated`. */
40
+ existingRule?: string;
41
+ /** The skill that already covers this, when status is `has-skill`. */
42
+ existingSkill?: string;
43
+ /**
44
+ * Shown before and still not written down anywhere — neither as a rule nor as
45
+ * a skill — so the reader saw this proposal and passed on it.
46
+ */
47
+ previouslyDeclined: boolean;
48
+ }
49
+ export interface FixCandidate extends Proposable {
50
+ label: string;
51
+ /** The failing command. */
52
+ command: string;
53
+ /** Short excerpt of the real error text. */
54
+ errorExcerpt: string;
55
+ /** Commands run between the failure and the pass. */
56
+ interveningCommands: string[];
57
+ /** Files edited between the failure and the pass. */
58
+ editedFiles: string[];
59
+ count: number;
60
+ sessions: number;
61
+ }
62
+ export interface WorkflowCandidate extends Proposable {
63
+ label: string;
64
+ /** Tool names in order. */
65
+ steps: string[];
66
+ count: number;
67
+ sessions: number;
68
+ }
69
+ /** One session's mining output, tagged with the identity the counts need. */
70
+ export interface MinedSession {
71
+ sessionId: string;
72
+ timestamp: string;
73
+ candidates: MinedCandidate[];
74
+ }
75
+ export declare function reduceDirectives(sessions: MinedSession[], minRepeats: number): DirectiveCluster[];
76
+ export declare function reduceFixes(sessions: MinedSession[], minRepeats: number): FixCandidate[];
77
+ export declare function reduceWorkflows(sessions: MinedSession[], minRepeats: number): WorkflowCandidate[];
78
+ //# sourceMappingURL=reduce.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reduce.d.ts","sourceRoot":"","sources":["../../../src/core/learn/reduce.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEhD,6DAA6D;AAC7D,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,UAAU,GAAG,WAAW,CAAC;AAE/D,oFAAoF;AACpF,MAAM,WAAW,UAAU;IAC1B,qEAAmE;IACnE,GAAG,EAAE,MAAM,CAAC;IACZ,4CAA4C;IAC5C,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,gBAAiB,SAAQ,UAAU;IACnD,yEAAyE;IACzE,KAAK,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wBAAwB;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,2EAAyE;IACzE,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,eAAe,CAAC;IACxB,iEAAiE;IACjE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,sEAAsE;IACtE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,kBAAkB,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC/C,KAAK,EAAE,MAAM,CAAC;IACd,2BAA2B;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,4CAA4C;IAC5C,YAAY,EAAE,MAAM,CAAC;IACrB,qDAAqD;IACrD,mBAAmB,EAAE,MAAM,EAAE,CAAC;IAC9B,qDAAqD;IACrD,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAkB,SAAQ,UAAU;IACpD,KAAK,EAAE,MAAM,CAAC;IACd,2BAA2B;IAC3B,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,6EAA6E;AAC7E,MAAM,WAAW,YAAY;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC7B;AAuED,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAiBjG;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,YAAY,EAAE,CAexF;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAYjG","sourcesContent":["/**\n * The reduce half of `/learn`: turn per-session observations into counts.\n *\n * The split with `mine.ts` is the whole design. Deciding that \"we're on bun\n * now\" and \"stop using npm\" mean the same thing is semantics, and the model is\n * better at it than any normalizer — so the model does it, by emitting a shared\n * `label`. Deciding that the shared label occurred nine times across five\n * sessions is arithmetic, and arithmetic stays here, because a model asked to\n * count over a long context will be approximately right, and an approximately\n * right number is worse than none when the number is the thing the reader acts\n * on.\n *\n * Nothing in this file filters on content. The only gate is the repeat\n * threshold, which is a dial the user sets and the digest reports, not a\n * whitelist they cannot see.\n */\n\nimport type { MinedCandidate } from \"./mine.js\";\n\n/** Where a repeated directive already lives, if anywhere. */\nexport type DirectiveStatus = \"new\" | \"restated\" | \"has-skill\";\n\n/** Fields every proposable item shares, so suppression can be applied uniformly. */\nexport interface Proposable {\n\t/** Stable identity across runs — what the state file remembers. */\n\tkey: string;\n\t/** Newest occurrence in the window, ISO. */\n\tlastSeen: string;\n}\n\nexport interface DirectiveCluster extends Proposable {\n\t/** The model's canonical name for what was meant. The clustering key. */\n\tlabel: string;\n\t/** Representative verbatim quote, the longest seen in the cluster. */\n\ttext: string;\n\t/** Why it is durable, in the model's words. */\n\trationale?: string;\n\t/** Total times said. */\n\tcount: number;\n\t/** Distinct sessions it was said in — the stronger of the two counts. */\n\tsessions: number;\n\tstatus: DirectiveStatus;\n\t/** The existing rule line matched, when status is `restated`. */\n\texistingRule?: string;\n\t/** The skill that already covers this, when status is `has-skill`. */\n\texistingSkill?: string;\n\t/**\n\t * Shown before and still not written down anywhere — neither as a rule nor as\n\t * a skill — so the reader saw this proposal and passed on it.\n\t */\n\tpreviouslyDeclined: boolean;\n}\n\nexport interface FixCandidate extends Proposable {\n\tlabel: string;\n\t/** The failing command. */\n\tcommand: string;\n\t/** Short excerpt of the real error text. */\n\terrorExcerpt: string;\n\t/** Commands run between the failure and the pass. */\n\tinterveningCommands: string[];\n\t/** Files edited between the failure and the pass. */\n\teditedFiles: string[];\n\tcount: number;\n\tsessions: number;\n}\n\nexport interface WorkflowCandidate extends Proposable {\n\tlabel: string;\n\t/** Tool names in order. */\n\tsteps: string[];\n\tcount: number;\n\tsessions: number;\n}\n\n/** One session's mining output, tagged with the identity the counts need. */\nexport interface MinedSession {\n\tsessionId: string;\n\ttimestamp: string;\n\tcandidates: MinedCandidate[];\n}\n\ninterface Acc {\n\tlabel: string;\n\ttext: string;\n\trationale?: string;\n\tcount: number;\n\tsessions: Set<string>;\n\tlastSeen: string;\n\tsamples: MinedCandidate[];\n}\n\n/**\n * Group every candidate of one kind by its label.\n *\n * `lastSeen` takes the session timestamp rather than anything the model\n * reports: the model is reading a transcript and has no reliable clock, and\n * `lastSeen` drives suppression, where a wrong value silently hides a live\n * signal or resurfaces a dead one.\n */\nfunction groupByLabel(sessions: MinedSession[], kind: MinedCandidate[\"kind\"]): Acc[] {\n\tconst acc = new Map<string, Acc>();\n\n\tfor (const session of sessions) {\n\t\tfor (const candidate of session.candidates) {\n\t\t\tif (candidate.kind !== kind) continue;\n\t\t\tconst existing = acc.get(candidate.label);\n\t\t\tif (existing) {\n\t\t\t\texisting.count++;\n\t\t\t\texisting.sessions.add(session.sessionId);\n\t\t\t\tif (session.timestamp > existing.lastSeen) existing.lastSeen = session.timestamp;\n\t\t\t\t// Keep the fullest quote: a longer one carries more of the reasoning.\n\t\t\t\tif (candidate.text.length > existing.text.length) existing.text = candidate.text;\n\t\t\t\texisting.rationale ??= candidate.rationale;\n\t\t\t\texisting.samples.push(candidate);\n\t\t\t} else {\n\t\t\t\tacc.set(candidate.label, {\n\t\t\t\t\tlabel: candidate.label,\n\t\t\t\t\ttext: candidate.text,\n\t\t\t\t\trationale: candidate.rationale,\n\t\t\t\t\tcount: 1,\n\t\t\t\t\tsessions: new Set([session.sessionId]),\n\t\t\t\t\tlastSeen: session.timestamp,\n\t\t\t\t\tsamples: [candidate],\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\treturn [...acc.values()];\n}\n\n/** Distinct sessions first, then raw count: five sessions beats nine times in one. */\nfunction byEvidence(a: { sessions: number; count: number; label: string }, b: typeof a): number {\n\treturn b.sessions - a.sessions || b.count - a.count || a.label.localeCompare(b.label);\n}\n\n/** Merge repeated string fields across a cluster's samples, preserving order and dropping dupes. */\nfunction mergeStrings(samples: MinedCandidate[], pick: (c: MinedCandidate) => string[] | undefined): string[] {\n\tconst out: string[] = [];\n\tconst seen = new Set<string>();\n\tfor (const sample of samples) {\n\t\tfor (const value of pick(sample) ?? []) {\n\t\t\tif (seen.has(value)) continue;\n\t\t\tseen.add(value);\n\t\t\tout.push(value);\n\t\t}\n\t}\n\treturn out.slice(0, 12);\n}\n\nexport function reduceDirectives(sessions: MinedSession[], minRepeats: number): DirectiveCluster[] {\n\treturn groupByLabel(sessions, \"directive\")\n\t\t.filter((entry) => entry.count >= minRepeats)\n\t\t.map((entry) => ({\n\t\t\tkey: `directive:${entry.label}`,\n\t\t\tlabel: entry.label,\n\t\t\ttext: entry.text,\n\t\t\trationale: entry.rationale,\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t\t// Coverage is decided later, by a model that can tell a paraphrase from a\n\t\t\t// coincidence. Everything starts `new` and is corrected in place.\n\t\t\tstatus: \"new\" as DirectiveStatus,\n\t\t\tpreviouslyDeclined: false,\n\t\t}))\n\t\t.sort(byEvidence);\n}\n\nexport function reduceFixes(sessions: MinedSession[], minRepeats: number): FixCandidate[] {\n\treturn groupByLabel(sessions, \"fix\")\n\t\t.filter((entry) => entry.count >= minRepeats)\n\t\t.map((entry) => ({\n\t\t\tkey: `fix:${entry.label}`,\n\t\t\tlabel: entry.label,\n\t\t\tcommand: entry.samples.find((s) => s.command)?.command ?? entry.text,\n\t\t\terrorExcerpt: entry.samples.find((s) => s.errorExcerpt)?.errorExcerpt ?? \"\",\n\t\t\tinterveningCommands: mergeStrings(entry.samples, (s) => s.interveningCommands),\n\t\t\teditedFiles: mergeStrings(entry.samples, (s) => s.editedFiles),\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t}))\n\t\t.sort(byEvidence);\n}\n\nexport function reduceWorkflows(sessions: MinedSession[], minRepeats: number): WorkflowCandidate[] {\n\treturn groupByLabel(sessions, \"workflow\")\n\t\t.filter((entry) => entry.count >= minRepeats)\n\t\t.map((entry) => ({\n\t\t\tkey: `workflow:${entry.label}`,\n\t\t\tlabel: entry.label,\n\t\t\tsteps: entry.samples.find((s) => s.steps && s.steps.length > 0)?.steps ?? [],\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t}))\n\t\t.sort(byEvidence);\n}\n"]}
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The reduce half of `/learn`: turn per-session observations into counts.
3
+ *
4
+ * The split with `mine.ts` is the whole design. Deciding that "we're on bun
5
+ * now" and "stop using npm" mean the same thing is semantics, and the model is
6
+ * better at it than any normalizer — so the model does it, by emitting a shared
7
+ * `label`. Deciding that the shared label occurred nine times across five
8
+ * sessions is arithmetic, and arithmetic stays here, because a model asked to
9
+ * count over a long context will be approximately right, and an approximately
10
+ * right number is worse than none when the number is the thing the reader acts
11
+ * on.
12
+ *
13
+ * Nothing in this file filters on content. The only gate is the repeat
14
+ * threshold, which is a dial the user sets and the digest reports, not a
15
+ * whitelist they cannot see.
16
+ */
17
+ /**
18
+ * Group every candidate of one kind by its label.
19
+ *
20
+ * `lastSeen` takes the session timestamp rather than anything the model
21
+ * reports: the model is reading a transcript and has no reliable clock, and
22
+ * `lastSeen` drives suppression, where a wrong value silently hides a live
23
+ * signal or resurfaces a dead one.
24
+ */
25
+ function groupByLabel(sessions, kind) {
26
+ const acc = new Map();
27
+ for (const session of sessions) {
28
+ for (const candidate of session.candidates) {
29
+ if (candidate.kind !== kind)
30
+ continue;
31
+ const existing = acc.get(candidate.label);
32
+ if (existing) {
33
+ existing.count++;
34
+ existing.sessions.add(session.sessionId);
35
+ if (session.timestamp > existing.lastSeen)
36
+ existing.lastSeen = session.timestamp;
37
+ // Keep the fullest quote: a longer one carries more of the reasoning.
38
+ if (candidate.text.length > existing.text.length)
39
+ existing.text = candidate.text;
40
+ existing.rationale ??= candidate.rationale;
41
+ existing.samples.push(candidate);
42
+ }
43
+ else {
44
+ acc.set(candidate.label, {
45
+ label: candidate.label,
46
+ text: candidate.text,
47
+ rationale: candidate.rationale,
48
+ count: 1,
49
+ sessions: new Set([session.sessionId]),
50
+ lastSeen: session.timestamp,
51
+ samples: [candidate],
52
+ });
53
+ }
54
+ }
55
+ }
56
+ return [...acc.values()];
57
+ }
58
+ /** Distinct sessions first, then raw count: five sessions beats nine times in one. */
59
+ function byEvidence(a, b) {
60
+ return b.sessions - a.sessions || b.count - a.count || a.label.localeCompare(b.label);
61
+ }
62
+ /** Merge repeated string fields across a cluster's samples, preserving order and dropping dupes. */
63
+ function mergeStrings(samples, pick) {
64
+ const out = [];
65
+ const seen = new Set();
66
+ for (const sample of samples) {
67
+ for (const value of pick(sample) ?? []) {
68
+ if (seen.has(value))
69
+ continue;
70
+ seen.add(value);
71
+ out.push(value);
72
+ }
73
+ }
74
+ return out.slice(0, 12);
75
+ }
76
+ export function reduceDirectives(sessions, minRepeats) {
77
+ return groupByLabel(sessions, "directive")
78
+ .filter((entry) => entry.count >= minRepeats)
79
+ .map((entry) => ({
80
+ key: `directive:${entry.label}`,
81
+ label: entry.label,
82
+ text: entry.text,
83
+ rationale: entry.rationale,
84
+ count: entry.count,
85
+ sessions: entry.sessions.size,
86
+ lastSeen: entry.lastSeen,
87
+ // Coverage is decided later, by a model that can tell a paraphrase from a
88
+ // coincidence. Everything starts `new` and is corrected in place.
89
+ status: "new",
90
+ previouslyDeclined: false,
91
+ }))
92
+ .sort(byEvidence);
93
+ }
94
+ export function reduceFixes(sessions, minRepeats) {
95
+ return groupByLabel(sessions, "fix")
96
+ .filter((entry) => entry.count >= minRepeats)
97
+ .map((entry) => ({
98
+ key: `fix:${entry.label}`,
99
+ label: entry.label,
100
+ command: entry.samples.find((s) => s.command)?.command ?? entry.text,
101
+ errorExcerpt: entry.samples.find((s) => s.errorExcerpt)?.errorExcerpt ?? "",
102
+ interveningCommands: mergeStrings(entry.samples, (s) => s.interveningCommands),
103
+ editedFiles: mergeStrings(entry.samples, (s) => s.editedFiles),
104
+ count: entry.count,
105
+ sessions: entry.sessions.size,
106
+ lastSeen: entry.lastSeen,
107
+ }))
108
+ .sort(byEvidence);
109
+ }
110
+ export function reduceWorkflows(sessions, minRepeats) {
111
+ return groupByLabel(sessions, "workflow")
112
+ .filter((entry) => entry.count >= minRepeats)
113
+ .map((entry) => ({
114
+ key: `workflow:${entry.label}`,
115
+ label: entry.label,
116
+ steps: entry.samples.find((s) => s.steps && s.steps.length > 0)?.steps ?? [],
117
+ count: entry.count,
118
+ sessions: entry.sessions.size,
119
+ lastSeen: entry.lastSeen,
120
+ }))
121
+ .sort(byEvidence);
122
+ }
123
+ //# sourceMappingURL=reduce.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reduce.js","sourceRoot":"","sources":["../../../src/core/learn/reduce.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The reduce half of `/learn`: turn per-session observations into counts.\n *\n * The split with `mine.ts` is the whole design. Deciding that \"we're on bun\n * now\" and \"stop using npm\" mean the same thing is semantics, and the model is\n * better at it than any normalizer — so the model does it, by emitting a shared\n * `label`. Deciding that the shared label occurred nine times across five\n * sessions is arithmetic, and arithmetic stays here, because a model asked to\n * count over a long context will be approximately right, and an approximately\n * right number is worse than none when the number is the thing the reader acts\n * on.\n *\n * Nothing in this file filters on content. The only gate is the repeat\n * threshold, which is a dial the user sets and the digest reports, not a\n * whitelist they cannot see.\n */\n\nimport type { MinedCandidate } from \"./mine.js\";\n\n/** Where a repeated directive already lives, if anywhere. */\nexport type DirectiveStatus = \"new\" | \"restated\" | \"has-skill\";\n\n/** Fields every proposable item shares, so suppression can be applied uniformly. */\nexport interface Proposable {\n\t/** Stable identity across runs — what the state file remembers. */\n\tkey: string;\n\t/** Newest occurrence in the window, ISO. */\n\tlastSeen: string;\n}\n\nexport interface DirectiveCluster extends Proposable {\n\t/** The model's canonical name for what was meant. The clustering key. */\n\tlabel: string;\n\t/** Representative verbatim quote, the longest seen in the cluster. */\n\ttext: string;\n\t/** Why it is durable, in the model's words. */\n\trationale?: string;\n\t/** Total times said. */\n\tcount: number;\n\t/** Distinct sessions it was said in — the stronger of the two counts. */\n\tsessions: number;\n\tstatus: DirectiveStatus;\n\t/** The existing rule line matched, when status is `restated`. */\n\texistingRule?: string;\n\t/** The skill that already covers this, when status is `has-skill`. */\n\texistingSkill?: string;\n\t/**\n\t * Shown before and still not written down anywhere — neither as a rule nor as\n\t * a skill — so the reader saw this proposal and passed on it.\n\t */\n\tpreviouslyDeclined: boolean;\n}\n\nexport interface FixCandidate extends Proposable {\n\tlabel: string;\n\t/** The failing command. */\n\tcommand: string;\n\t/** Short excerpt of the real error text. */\n\terrorExcerpt: string;\n\t/** Commands run between the failure and the pass. */\n\tinterveningCommands: string[];\n\t/** Files edited between the failure and the pass. */\n\teditedFiles: string[];\n\tcount: number;\n\tsessions: number;\n}\n\nexport interface WorkflowCandidate extends Proposable {\n\tlabel: string;\n\t/** Tool names in order. */\n\tsteps: string[];\n\tcount: number;\n\tsessions: number;\n}\n\n/** One session's mining output, tagged with the identity the counts need. */\nexport interface MinedSession {\n\tsessionId: string;\n\ttimestamp: string;\n\tcandidates: MinedCandidate[];\n}\n\ninterface Acc {\n\tlabel: string;\n\ttext: string;\n\trationale?: string;\n\tcount: number;\n\tsessions: Set<string>;\n\tlastSeen: string;\n\tsamples: MinedCandidate[];\n}\n\n/**\n * Group every candidate of one kind by its label.\n *\n * `lastSeen` takes the session timestamp rather than anything the model\n * reports: the model is reading a transcript and has no reliable clock, and\n * `lastSeen` drives suppression, where a wrong value silently hides a live\n * signal or resurfaces a dead one.\n */\nfunction groupByLabel(sessions: MinedSession[], kind: MinedCandidate[\"kind\"]): Acc[] {\n\tconst acc = new Map<string, Acc>();\n\n\tfor (const session of sessions) {\n\t\tfor (const candidate of session.candidates) {\n\t\t\tif (candidate.kind !== kind) continue;\n\t\t\tconst existing = acc.get(candidate.label);\n\t\t\tif (existing) {\n\t\t\t\texisting.count++;\n\t\t\t\texisting.sessions.add(session.sessionId);\n\t\t\t\tif (session.timestamp > existing.lastSeen) existing.lastSeen = session.timestamp;\n\t\t\t\t// Keep the fullest quote: a longer one carries more of the reasoning.\n\t\t\t\tif (candidate.text.length > existing.text.length) existing.text = candidate.text;\n\t\t\t\texisting.rationale ??= candidate.rationale;\n\t\t\t\texisting.samples.push(candidate);\n\t\t\t} else {\n\t\t\t\tacc.set(candidate.label, {\n\t\t\t\t\tlabel: candidate.label,\n\t\t\t\t\ttext: candidate.text,\n\t\t\t\t\trationale: candidate.rationale,\n\t\t\t\t\tcount: 1,\n\t\t\t\t\tsessions: new Set([session.sessionId]),\n\t\t\t\t\tlastSeen: session.timestamp,\n\t\t\t\t\tsamples: [candidate],\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\treturn [...acc.values()];\n}\n\n/** Distinct sessions first, then raw count: five sessions beats nine times in one. */\nfunction byEvidence(a: { sessions: number; count: number; label: string }, b: typeof a): number {\n\treturn b.sessions - a.sessions || b.count - a.count || a.label.localeCompare(b.label);\n}\n\n/** Merge repeated string fields across a cluster's samples, preserving order and dropping dupes. */\nfunction mergeStrings(samples: MinedCandidate[], pick: (c: MinedCandidate) => string[] | undefined): string[] {\n\tconst out: string[] = [];\n\tconst seen = new Set<string>();\n\tfor (const sample of samples) {\n\t\tfor (const value of pick(sample) ?? []) {\n\t\t\tif (seen.has(value)) continue;\n\t\t\tseen.add(value);\n\t\t\tout.push(value);\n\t\t}\n\t}\n\treturn out.slice(0, 12);\n}\n\nexport function reduceDirectives(sessions: MinedSession[], minRepeats: number): DirectiveCluster[] {\n\treturn groupByLabel(sessions, \"directive\")\n\t\t.filter((entry) => entry.count >= minRepeats)\n\t\t.map((entry) => ({\n\t\t\tkey: `directive:${entry.label}`,\n\t\t\tlabel: entry.label,\n\t\t\ttext: entry.text,\n\t\t\trationale: entry.rationale,\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t\t// Coverage is decided later, by a model that can tell a paraphrase from a\n\t\t\t// coincidence. Everything starts `new` and is corrected in place.\n\t\t\tstatus: \"new\" as DirectiveStatus,\n\t\t\tpreviouslyDeclined: false,\n\t\t}))\n\t\t.sort(byEvidence);\n}\n\nexport function reduceFixes(sessions: MinedSession[], minRepeats: number): FixCandidate[] {\n\treturn groupByLabel(sessions, \"fix\")\n\t\t.filter((entry) => entry.count >= minRepeats)\n\t\t.map((entry) => ({\n\t\t\tkey: `fix:${entry.label}`,\n\t\t\tlabel: entry.label,\n\t\t\tcommand: entry.samples.find((s) => s.command)?.command ?? entry.text,\n\t\t\terrorExcerpt: entry.samples.find((s) => s.errorExcerpt)?.errorExcerpt ?? \"\",\n\t\t\tinterveningCommands: mergeStrings(entry.samples, (s) => s.interveningCommands),\n\t\t\teditedFiles: mergeStrings(entry.samples, (s) => s.editedFiles),\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t}))\n\t\t.sort(byEvidence);\n}\n\nexport function reduceWorkflows(sessions: MinedSession[], minRepeats: number): WorkflowCandidate[] {\n\treturn groupByLabel(sessions, \"workflow\")\n\t\t.filter((entry) => entry.count >= minRepeats)\n\t\t.map((entry) => ({\n\t\t\tkey: `workflow:${entry.label}`,\n\t\t\tlabel: entry.label,\n\t\t\tsteps: entry.samples.find((s) => s.steps && s.steps.length > 0)?.steps ?? [],\n\t\t\tcount: entry.count,\n\t\t\tsessions: entry.sessions.size,\n\t\t\tlastSeen: entry.lastSeen,\n\t\t}))\n\t\t.sort(byEvidence);\n}\n"]}
@@ -31,6 +31,13 @@ export interface SurfacedItem {
31
31
  * told from a declined one, without asking.
32
32
  */
33
33
  coveredWhenSurfaced: boolean;
34
+ /**
35
+ * Representative wording, kept so `/learn stats` can ask about coverage using
36
+ * what was actually said. The key alone is a slug, and judging "is this
37
+ * written down?" from a slug is a much weaker question than judging it from
38
+ * the sentence the slug stands for.
39
+ */
40
+ text?: string;
34
41
  }
35
42
  export interface LearnState {
36
43
  version: number;
@@ -114,5 +121,6 @@ export declare function recordSurfaced(state: LearnState, items: Array<{
114
121
  key: string;
115
122
  lastSeen: string;
116
123
  covered: boolean;
124
+ text?: string;
117
125
  }>, now?: Date): LearnState;
118
126
  //# sourceMappingURL=state.d.ts.map