@kolisachint/hoocode-agent 0.5.18 → 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.
- package/CHANGELOG.md +177 -0
- package/dist/core/learn/audit.d.ts +136 -0
- package/dist/core/learn/audit.d.ts.map +1 -0
- package/dist/core/learn/audit.js +316 -0
- package/dist/core/learn/audit.js.map +1 -0
- package/dist/core/learn/cache.d.ts.map +1 -1
- package/dist/core/learn/cache.js +14 -2
- package/dist/core/learn/cache.js.map +1 -1
- package/dist/core/learn/cluster.d.ts +78 -0
- package/dist/core/learn/cluster.d.ts.map +1 -0
- package/dist/core/learn/cluster.js +184 -0
- package/dist/core/learn/cluster.js.map +1 -0
- package/dist/core/learn/coverage.d.ts.map +1 -1
- package/dist/core/learn/coverage.js +2 -0
- package/dist/core/learn/coverage.js.map +1 -1
- package/dist/core/learn/digest.d.ts +12 -0
- package/dist/core/learn/digest.d.ts.map +1 -1
- package/dist/core/learn/digest.js +86 -14
- package/dist/core/learn/digest.js.map +1 -1
- package/dist/core/learn/extract.d.ts +39 -4
- package/dist/core/learn/extract.d.ts.map +1 -1
- package/dist/core/learn/extract.js +170 -34
- package/dist/core/learn/extract.js.map +1 -1
- package/dist/core/learn/mine.d.ts +78 -23
- package/dist/core/learn/mine.d.ts.map +1 -1
- package/dist/core/learn/mine.js +142 -37
- package/dist/core/learn/mine.js.map +1 -1
- package/dist/core/learn/reduce.d.ts +19 -8
- package/dist/core/learn/reduce.d.ts.map +1 -1
- package/dist/core/learn/reduce.js +69 -13
- package/dist/core/learn/reduce.js.map +1 -1
- package/dist/core/learn/state.d.ts +11 -18
- package/dist/core/learn/state.d.ts.map +1 -1
- package/dist/core/learn/state.js +23 -34
- package/dist/core/learn/state.js.map +1 -1
- package/dist/core/settings-defaults.d.ts +1 -1
- package/dist/core/settings-defaults.d.ts.map +1 -1
- package/dist/core/settings-defaults.js +1 -1
- package/dist/core/settings-defaults.js.map +1 -1
- package/dist/core/settings-manager.d.ts +2 -2
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +1 -1
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/settings-types.d.ts +1 -1
- package/dist/core/settings-types.d.ts.map +1 -1
- package/dist/core/settings-types.js.map +1 -1
- package/dist/extensions/core/learn.d.ts.map +1 -1
- package/dist/extensions/core/learn.js +128 -73
- package/dist/extensions/core/learn.js.map +1 -1
- package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/settings-selector.js +1 -1
- package/dist/modes/interactive/components/settings-selector.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +1 -1
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/docs/settings.md +9 -6
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
|
@@ -1 +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"]}
|
|
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 here is name or count. It used to emit a label\n * per occurrence — its own canonical name for what was meant — and the reduce\n * step grouped on exact label equality. That cannot work from inside one\n * session: the model is asked to hit a shared vocabulary it has never seen, and\n * on a real corpus it agreed with itself 3 times out of 188. Naming now happens\n * once, globally, in `cluster.ts`, where every candidate is visible at the same\n * time. Counting stays in `reduce.ts`, where it always belonged.\n *\n * Leaving labels out also makes the cache model-independent. A cached candidate\n * used to carry a label frozen at mining time, so changing the `fast` tier\n * forked the vocabulary permanently: old sessions and new ones named the same\n * thing differently, and neither side reached the repeat threshold.\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\" | \"request\";\n\n/**\n * One occurrence, as reported by the model reading a single session.\n *\n * Deliberately unnamed. What was *meant* is only decidable against everything\n * else that was said, and this stage sees one session, so it reports what it\n * saw and leaves grouping to `cluster.ts`. This is also the shape that goes in\n * the cache, which is why nothing model-specific may live on it.\n */\nexport interface MinedCandidate {\n\tkind: CandidateKind;\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}\n\n/** A candidate once the global naming pass has decided what to call it. */\nexport interface LabelledCandidate extends MinedCandidate {\n\t/** Canonical slug for what was meant. The clustering key. */\n\tlabel: 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/** Error output kept per call. Errors carry the signal; success output is dropped entirely. */\nconst TOOL_ERROR_CHARS = 1_500;\n\n/**\n * Shortest literal run of a slash-command body that identifies a replay.\n *\n * Long enough that a user cannot type it by accident, short enough to survive a\n * template whose placeholders are densely packed.\n */\nconst REPLAY_FINGERPRINT_CHARS = 40;\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\n/** Collapse whitespace so a quote survives the wrapping a markdown source imposes on it. */\nfunction normalizeForMatch(text: string): string {\n\treturn text.replace(/\\s+/g, \" \").trim().toLowerCase();\n}\n\n/**\n * Literal runs from slash-command bodies, used to recognise a replayed expansion.\n *\n * A `user`-type slash command is persisted as an ordinary user message holding\n * the whole template body, with nothing to mark it as machinery. Read back off\n * disk it is indistinguishable from something the user typed — and it is the\n * most repeated text in a real corpus, because running `/pr` thirty times\n * writes the same two thousand characters thirty times. Mining it produces\n * directives the user never stated, at counts that look exactly like organic\n * repetition.\n *\n * Detection is retroactive on purpose. A provenance flag written at turn time\n * would be exact, but it would only help sessions recorded after it shipped,\n * leaving the existing corpus contaminated for months. Matching against the\n * command bodies still on disk fixes the history that already exists. The gap\n * is a template that has since been deleted; that case wants the flag, and is\n * the reason to add one later.\n */\nexport function replayFingerprints(templates: Array<{ content: string }>): string[] {\n\tconst out: string[] = [];\n\tfor (const template of templates) {\n\t\t// Split on the placeholders that argument substitution rewrites, leaving the\n\t\t// literal text that survives every expansion.\n\t\tconst segments = template.content.split(/\\$(?:\\d+|ARGUMENTS|\\*)/);\n\t\tlet longest = \"\";\n\t\tfor (const segment of segments) {\n\t\t\tconst normalized = normalizeForMatch(segment);\n\t\t\tif (normalized.length > longest.length) longest = normalized;\n\t\t}\n\t\tif (longest.length >= REPLAY_FINGERPRINT_CHARS) out.push(longest);\n\t}\n\treturn out;\n}\n\n/** True when a user turn is the body of a slash command rather than something typed. */\nexport function isReplayedTurn(text: string, fingerprints: string[]): boolean {\n\tif (fingerprints.length === 0) return false;\n\tconst normalized = normalizeForMatch(text);\n\tif (normalized.length < REPLAY_FINGERPRINT_CHARS) return false;\n\treturn fingerprints.some((fingerprint) => normalized.includes(fingerprint));\n}\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 the user actually typed go in whole and unfiltered — any\n * truncation there would quietly reintroduce the recall problem the old regex\n * gate had. What does not go in is text the user's tooling replayed: its own\n * past digests, and slash-command bodies.\n *\n * Assistant prose is dropped: it is the bulk of a transcript and almost none of\n * it is evidence about what the *user* wants. Tool calls are kept, because a\n * failure-then-pass is a fix. Successful tool output is dropped: it is a file\n * or a command's stdout, not a statement by anyone, and feeding it to a miner\n * looking for directives yields lines lifted out of plan files and configs\n * attributed to the user.\n */\nexport function renderTranscript(session: MinableSession, fingerprints: string[] = []): 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\tif (isReplayedTurn(text, fingerprints)) 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\" && message.isError) {\n\t\t\tconst output = textOf(message.content);\n\t\t\tif (!output) continue;\n\t\t\tlines.push(`ERROR: ${output.length > TOOL_ERROR_CHARS ? `${output.slice(0, TOOL_ERROR_CHARS)}…` : output}`);\n\t\t}\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\n/** Everything the user actually said in a session, normalized, for checking quotes against. */\nexport function spokenText(session: MinableSession, fingerprints: string[] = []): string {\n\tconst parts: string[] = [];\n\tfor (const entry of session.entries) {\n\t\tconst message = entry.type === \"message\" ? entry.message : undefined;\n\t\tif (!message || message.role !== \"user\") continue;\n\t\tconst text = textOf(message.content);\n\t\tif (!text || text.startsWith(LEARN_DIGEST_MARKER)) continue;\n\t\tif (isReplayedTurn(text, fingerprints)) continue;\n\t\tparts.push(text);\n\t}\n\treturn normalizeForMatch(parts.join(\"\\n\"));\n}\n\n/**\n * Drop candidates whose quote cannot be found in what the user said.\n *\n * The miner is told to quote verbatim and the digest renders every quote inside\n * quotation marks, but on a real corpus a third of them appear nowhere in the\n * session: paraphrases, merged sentences, and lines lifted out of tool output.\n * A quote that cannot be located is evidence that cannot be shown, and a\n * proposal the reader cannot check is worse than one that was never made.\n *\n * Whitespace is normalized before comparing, because a directive written in a\n * markdown file arrives wrapped across lines and the model unwraps it.\n */\nexport function verifyCandidates(candidates: MinedCandidate[], spoken: string): MinedCandidate[] {\n\t// Normalized again rather than trusting the caller: the check is a substring\n\t// test, and one un-normalized argument would silently reject everything.\n\tconst haystack = normalizeForMatch(spoken);\n\tif (!haystack) return [];\n\treturn candidates.filter((candidate) => {\n\t\t// A fix is evidenced by commands and errors, not by something the user said.\n\t\tif (candidate.kind === \"fix\") return true;\n\t\treturn haystack.includes(normalizeForMatch(candidate.text));\n\t});\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\"\nA directive is how things should be done in general. What to do right now is a **request** — see below — not a directive. When a message contains both, report the directive part here.\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**request** — the user asking for a piece of work by name: \"open a release PR\", \"run the full check and fix what it finds\", \"give me a demo of X\". Report the request as they phrased it. A request repeated across sessions is a slash command waiting to be written, which is why it is worth reporting even though it is not a rule.\n\nA message can contain both a request and a directive — \"open a release PR, and remember to stage only your own files\" is one of each. Report both, separately.\n\nQuote \"text\" VERBATIM from the transcript. Do not paraphrase, merge two sentences, or tidy the wording: a quote that cannot be found in the session is discarded, because the reader is shown it in quotation marks and has to be able to check it.\n\nDo not name or group anything. A later stage sees every session at once and decides what counts as the same point; from inside one session you cannot know.\n\nOutput STRICT JSON, no markdown fence, no prose:\n{\"candidates\":[{\"kind\":\"directive\",\"text\":\"<verbatim quote>\",\"rationale\":\"<one clause on why it is durable>\"}]}\n\nFor fix items add: \"command\", \"errorExcerpt\", \"interveningCommands\" (array), \"editedFiles\" (array).\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 !== \"request\") continue;\n\n\t\tconst text = typeof candidate.text === \"string\" ? candidate.text.trim() : \"\";\n\t\t// Nothing to quote back means nothing to show the reader.\n\t\tif (!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\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});\n\t}\n\treturn out;\n}\n\nexport interface MinerDeps {\n\tmodel: Model<any>;\n\tapiKey?: string;\n\theaders?: Record<string, string>;\n\t/**\n\t * Literal runs from the slash-command bodies in force, from\n\t * `replayFingerprints`. User turns matching one are machinery replaying\n\t * itself, not the user speaking.\n\t */\n\treplayFingerprints?: 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\tconst fingerprints = deps.replayFingerprints ?? [];\n\treturn async (session, signal) => {\n\t\tconst chunks = chunkTranscript(renderTranscript(session, fingerprints), 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\t// Verify against the whole session rather than the chunk that produced the\n\t\t// candidate: a quote can legitimately straddle a chunk boundary, and a\n\t\t// dropped-for-being-unfindable verdict has to mean unfindable anywhere.\n\t\treturn verifyCandidates(candidates, spokenText(session, fingerprints));\n\t};\n}\n"]}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* threshold, which is a dial the user sets and the digest reports, not a
|
|
15
15
|
* whitelist they cannot see.
|
|
16
16
|
*/
|
|
17
|
-
import type {
|
|
17
|
+
import type { LabelledCandidate } from "./mine.js";
|
|
18
18
|
/** Where a repeated directive already lives, if anywhere. */
|
|
19
19
|
export type DirectiveStatus = "new" | "restated" | "has-skill";
|
|
20
20
|
/** Fields every proposable item shares, so suppression can be applied uniformly. */
|
|
@@ -59,20 +59,31 @@ export interface FixCandidate extends Proposable {
|
|
|
59
59
|
count: number;
|
|
60
60
|
sessions: number;
|
|
61
61
|
}
|
|
62
|
-
|
|
62
|
+
/**
|
|
63
|
+
* A piece of work the user keeps asking for by name.
|
|
64
|
+
*
|
|
65
|
+
* This is the slash-command signal, and it was being thrown away: the miner
|
|
66
|
+
* used to be told not to report task requests at all. But a request repeated
|
|
67
|
+
* across sessions is exactly what a slash command is for, and the evidence for
|
|
68
|
+
* it is stronger than for most rules — the same job, asked for again, in
|
|
69
|
+
* someone's own words.
|
|
70
|
+
*/
|
|
71
|
+
export interface RequestCandidate extends Proposable {
|
|
63
72
|
label: string;
|
|
64
|
-
/**
|
|
65
|
-
|
|
73
|
+
/** How the user phrased it, so the proposal can quote rather than invent. */
|
|
74
|
+
text: string;
|
|
66
75
|
count: number;
|
|
67
76
|
sessions: number;
|
|
68
77
|
}
|
|
69
|
-
/** One session's mining output, tagged with the identity the counts need. */
|
|
78
|
+
/** One session's mining output, named and tagged with the identity the counts need. */
|
|
70
79
|
export interface MinedSession {
|
|
71
80
|
sessionId: string;
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
/** When the session was last written to — the clock suppression runs on. */
|
|
82
|
+
lastActivity: string;
|
|
83
|
+
/** Named by the global clustering pass; grouping here is exact-label arithmetic. */
|
|
84
|
+
candidates: LabelledCandidate[];
|
|
74
85
|
}
|
|
75
86
|
export declare function reduceDirectives(sessions: MinedSession[], minRepeats: number): DirectiveCluster[];
|
|
76
87
|
export declare function reduceFixes(sessions: MinedSession[], minRepeats: number): FixCandidate[];
|
|
77
|
-
export declare function
|
|
88
|
+
export declare function reduceRequests(sessions: MinedSession[], minRepeats: number): RequestCandidate[];
|
|
78
89
|
//# sourceMappingURL=reduce.d.ts.map
|
|
@@ -1 +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"]}
|
|
1
|
+
{"version":3,"file":"reduce.d.ts","sourceRoot":"","sources":["../../../src/core/learn/reduce.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAkB,MAAM,WAAW,CAAC;AAEnE,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;;;;;;;;GAQG;AACH,MAAM,WAAW,gBAAiB,SAAQ,UAAU;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,6EAA6E;IAC7E,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,uFAAuF;AACvF,MAAM,WAAW,YAAY;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,8EAA4E;IAC5E,YAAY,EAAE,MAAM,CAAC;IACrB,oFAAoF;IACpF,UAAU,EAAE,iBAAiB,EAAE,CAAC;CAChC;AAgID,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,cAAc,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAY/F","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 { LabelledCandidate, 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\n/**\n * A piece of work the user keeps asking for by name.\n *\n * This is the slash-command signal, and it was being thrown away: the miner\n * used to be told not to report task requests at all. But a request repeated\n * across sessions is exactly what a slash command is for, and the evidence for\n * it is stronger than for most rules — the same job, asked for again, in\n * someone's own words.\n */\nexport interface RequestCandidate extends Proposable {\n\tlabel: string;\n\t/** How the user phrased it, so the proposal can quote rather than invent. */\n\ttext: string;\n\tcount: number;\n\tsessions: number;\n}\n\n/** One session's mining output, named and tagged with the identity the counts need. */\nexport interface MinedSession {\n\tsessionId: string;\n\t/** When the session was last written to — the clock suppression runs on. */\n\tlastActivity: string;\n\t/** Named by the global clustering pass; grouping here is exact-label arithmetic. */\n\tcandidates: LabelledCandidate[];\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's last activity 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. It is deliberately not the session's start\n * either — a long-running session would date today's words to the day it was\n * opened, and suppression would call them old news.\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.lastActivity > existing.lastSeen) existing.lastSeen = session.lastActivity;\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.lastActivity,\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/**\n * Merge accumulators whose representative quote is the same sentence.\n *\n * The model labels each session independently and has no way to see what it\n * called the same thing last time, so identical text routinely arrives under\n * two labels — and then reads as two separate proposals, the same words twice.\n * Matching on the quote costs nothing and is the one case where sameness is not\n * a judgement call.\n *\n * This runs before the repeat threshold on purpose: two occurrences split\n * across two labels are each below the bar, and merging them afterwards would\n * mean neither was ever considered.\n *\n * The surviving label is the lexicographically smallest of the merged set, not\n * the first one seen. First-seen follows session order, which changes whenever\n * a session is added — so the merged cluster would silently change its name\n * between runs, the state key with it, and every item the reader had already\n * decided on would come back as new. A merge has to be a pure function of what\n * was merged.\n */\nfunction mergeIdenticalText(entries: Acc[]): Acc[] {\n\tconst byText = new Map<string, Acc>();\n\tconst order: Acc[] = [];\n\n\tfor (const entry of entries) {\n\t\tconst key = entry.text.replace(/\\s+/g, \" \").trim().toLowerCase();\n\t\tconst existing = byText.get(key);\n\t\tif (!existing) {\n\t\t\tbyText.set(key, entry);\n\t\t\torder.push(entry);\n\t\t\tcontinue;\n\t\t}\n\t\texisting.count += entry.count;\n\t\tfor (const session of entry.sessions) existing.sessions.add(session);\n\t\tif (entry.lastSeen > existing.lastSeen) existing.lastSeen = entry.lastSeen;\n\t\texisting.rationale ??= entry.rationale;\n\t\texisting.samples.push(...entry.samples);\n\t\tif (entry.label < existing.label) existing.label = entry.label;\n\t}\n\n\treturn order;\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\n/**\n * The repeat threshold counts distinct sessions, not occurrences.\n *\n * Saying a thing twice inside one session is the commonest thing in a\n * transcript and usually means the opposite of durable: the agent ignored it\n * the first time, so it was restated. Two *sessions* is a claim about how you\n * work; two lines in one session is a claim about one afternoon.\n */\nfunction repeatedEnough(entry: Acc, minRepeats: number): boolean {\n\treturn entry.sessions.size >= minRepeats;\n}\n\nexport function reduceDirectives(sessions: MinedSession[], minRepeats: number): DirectiveCluster[] {\n\treturn mergeIdenticalText(groupByLabel(sessions, \"directive\"))\n\t\t.filter((entry) => repeatedEnough(entry, 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) => repeatedEnough(entry, 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 reduceRequests(sessions: MinedSession[], minRepeats: number): RequestCandidate[] {\n\treturn mergeIdenticalText(groupByLabel(sessions, \"request\"))\n\t\t.filter((entry) => repeatedEnough(entry, minRepeats))\n\t\t.map((entry) => ({\n\t\t\tkey: `request:${entry.label}`,\n\t\t\tlabel: entry.label,\n\t\t\ttext: entry.text,\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"]}
|
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
/**
|
|
18
18
|
* Group every candidate of one kind by its label.
|
|
19
19
|
*
|
|
20
|
-
* `lastSeen` takes the session
|
|
20
|
+
* `lastSeen` takes the session's last activity rather than anything the model
|
|
21
21
|
* reports: the model is reading a transcript and has no reliable clock, and
|
|
22
22
|
* `lastSeen` drives suppression, where a wrong value silently hides a live
|
|
23
|
-
* signal or resurfaces a dead one.
|
|
23
|
+
* signal or resurfaces a dead one. It is deliberately not the session's start
|
|
24
|
+
* either — a long-running session would date today's words to the day it was
|
|
25
|
+
* opened, and suppression would call them old news.
|
|
24
26
|
*/
|
|
25
27
|
function groupByLabel(sessions, kind) {
|
|
26
28
|
const acc = new Map();
|
|
@@ -32,8 +34,8 @@ function groupByLabel(sessions, kind) {
|
|
|
32
34
|
if (existing) {
|
|
33
35
|
existing.count++;
|
|
34
36
|
existing.sessions.add(session.sessionId);
|
|
35
|
-
if (session.
|
|
36
|
-
existing.lastSeen = session.
|
|
37
|
+
if (session.lastActivity > existing.lastSeen)
|
|
38
|
+
existing.lastSeen = session.lastActivity;
|
|
37
39
|
// Keep the fullest quote: a longer one carries more of the reasoning.
|
|
38
40
|
if (candidate.text.length > existing.text.length)
|
|
39
41
|
existing.text = candidate.text;
|
|
@@ -47,7 +49,7 @@ function groupByLabel(sessions, kind) {
|
|
|
47
49
|
rationale: candidate.rationale,
|
|
48
50
|
count: 1,
|
|
49
51
|
sessions: new Set([session.sessionId]),
|
|
50
|
-
lastSeen: session.
|
|
52
|
+
lastSeen: session.lastActivity,
|
|
51
53
|
samples: [candidate],
|
|
52
54
|
});
|
|
53
55
|
}
|
|
@@ -55,6 +57,49 @@ function groupByLabel(sessions, kind) {
|
|
|
55
57
|
}
|
|
56
58
|
return [...acc.values()];
|
|
57
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Merge accumulators whose representative quote is the same sentence.
|
|
62
|
+
*
|
|
63
|
+
* The model labels each session independently and has no way to see what it
|
|
64
|
+
* called the same thing last time, so identical text routinely arrives under
|
|
65
|
+
* two labels — and then reads as two separate proposals, the same words twice.
|
|
66
|
+
* Matching on the quote costs nothing and is the one case where sameness is not
|
|
67
|
+
* a judgement call.
|
|
68
|
+
*
|
|
69
|
+
* This runs before the repeat threshold on purpose: two occurrences split
|
|
70
|
+
* across two labels are each below the bar, and merging them afterwards would
|
|
71
|
+
* mean neither was ever considered.
|
|
72
|
+
*
|
|
73
|
+
* The surviving label is the lexicographically smallest of the merged set, not
|
|
74
|
+
* the first one seen. First-seen follows session order, which changes whenever
|
|
75
|
+
* a session is added — so the merged cluster would silently change its name
|
|
76
|
+
* between runs, the state key with it, and every item the reader had already
|
|
77
|
+
* decided on would come back as new. A merge has to be a pure function of what
|
|
78
|
+
* was merged.
|
|
79
|
+
*/
|
|
80
|
+
function mergeIdenticalText(entries) {
|
|
81
|
+
const byText = new Map();
|
|
82
|
+
const order = [];
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
const key = entry.text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
85
|
+
const existing = byText.get(key);
|
|
86
|
+
if (!existing) {
|
|
87
|
+
byText.set(key, entry);
|
|
88
|
+
order.push(entry);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
existing.count += entry.count;
|
|
92
|
+
for (const session of entry.sessions)
|
|
93
|
+
existing.sessions.add(session);
|
|
94
|
+
if (entry.lastSeen > existing.lastSeen)
|
|
95
|
+
existing.lastSeen = entry.lastSeen;
|
|
96
|
+
existing.rationale ??= entry.rationale;
|
|
97
|
+
existing.samples.push(...entry.samples);
|
|
98
|
+
if (entry.label < existing.label)
|
|
99
|
+
existing.label = entry.label;
|
|
100
|
+
}
|
|
101
|
+
return order;
|
|
102
|
+
}
|
|
58
103
|
/** Distinct sessions first, then raw count: five sessions beats nine times in one. */
|
|
59
104
|
function byEvidence(a, b) {
|
|
60
105
|
return b.sessions - a.sessions || b.count - a.count || a.label.localeCompare(b.label);
|
|
@@ -73,9 +118,20 @@ function mergeStrings(samples, pick) {
|
|
|
73
118
|
}
|
|
74
119
|
return out.slice(0, 12);
|
|
75
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* The repeat threshold counts distinct sessions, not occurrences.
|
|
123
|
+
*
|
|
124
|
+
* Saying a thing twice inside one session is the commonest thing in a
|
|
125
|
+
* transcript and usually means the opposite of durable: the agent ignored it
|
|
126
|
+
* the first time, so it was restated. Two *sessions* is a claim about how you
|
|
127
|
+
* work; two lines in one session is a claim about one afternoon.
|
|
128
|
+
*/
|
|
129
|
+
function repeatedEnough(entry, minRepeats) {
|
|
130
|
+
return entry.sessions.size >= minRepeats;
|
|
131
|
+
}
|
|
76
132
|
export function reduceDirectives(sessions, minRepeats) {
|
|
77
|
-
return groupByLabel(sessions, "directive")
|
|
78
|
-
.filter((entry) => entry
|
|
133
|
+
return mergeIdenticalText(groupByLabel(sessions, "directive"))
|
|
134
|
+
.filter((entry) => repeatedEnough(entry, minRepeats))
|
|
79
135
|
.map((entry) => ({
|
|
80
136
|
key: `directive:${entry.label}`,
|
|
81
137
|
label: entry.label,
|
|
@@ -93,7 +149,7 @@ export function reduceDirectives(sessions, minRepeats) {
|
|
|
93
149
|
}
|
|
94
150
|
export function reduceFixes(sessions, minRepeats) {
|
|
95
151
|
return groupByLabel(sessions, "fix")
|
|
96
|
-
.filter((entry) => entry
|
|
152
|
+
.filter((entry) => repeatedEnough(entry, minRepeats))
|
|
97
153
|
.map((entry) => ({
|
|
98
154
|
key: `fix:${entry.label}`,
|
|
99
155
|
label: entry.label,
|
|
@@ -107,13 +163,13 @@ export function reduceFixes(sessions, minRepeats) {
|
|
|
107
163
|
}))
|
|
108
164
|
.sort(byEvidence);
|
|
109
165
|
}
|
|
110
|
-
export function
|
|
111
|
-
return groupByLabel(sessions, "
|
|
112
|
-
.filter((entry) => entry
|
|
166
|
+
export function reduceRequests(sessions, minRepeats) {
|
|
167
|
+
return mergeIdenticalText(groupByLabel(sessions, "request"))
|
|
168
|
+
.filter((entry) => repeatedEnough(entry, minRepeats))
|
|
113
169
|
.map((entry) => ({
|
|
114
|
-
key: `
|
|
170
|
+
key: `request:${entry.label}`,
|
|
115
171
|
label: entry.label,
|
|
116
|
-
|
|
172
|
+
text: entry.text,
|
|
117
173
|
count: entry.count,
|
|
118
174
|
sessions: entry.sessions.size,
|
|
119
175
|
lastSeen: entry.lastSeen,
|
|
@@ -1 +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"]}
|
|
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 { LabelledCandidate, 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\n/**\n * A piece of work the user keeps asking for by name.\n *\n * This is the slash-command signal, and it was being thrown away: the miner\n * used to be told not to report task requests at all. But a request repeated\n * across sessions is exactly what a slash command is for, and the evidence for\n * it is stronger than for most rules — the same job, asked for again, in\n * someone's own words.\n */\nexport interface RequestCandidate extends Proposable {\n\tlabel: string;\n\t/** How the user phrased it, so the proposal can quote rather than invent. */\n\ttext: string;\n\tcount: number;\n\tsessions: number;\n}\n\n/** One session's mining output, named and tagged with the identity the counts need. */\nexport interface MinedSession {\n\tsessionId: string;\n\t/** When the session was last written to — the clock suppression runs on. */\n\tlastActivity: string;\n\t/** Named by the global clustering pass; grouping here is exact-label arithmetic. */\n\tcandidates: LabelledCandidate[];\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's last activity 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. It is deliberately not the session's start\n * either — a long-running session would date today's words to the day it was\n * opened, and suppression would call them old news.\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.lastActivity > existing.lastSeen) existing.lastSeen = session.lastActivity;\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.lastActivity,\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/**\n * Merge accumulators whose representative quote is the same sentence.\n *\n * The model labels each session independently and has no way to see what it\n * called the same thing last time, so identical text routinely arrives under\n * two labels — and then reads as two separate proposals, the same words twice.\n * Matching on the quote costs nothing and is the one case where sameness is not\n * a judgement call.\n *\n * This runs before the repeat threshold on purpose: two occurrences split\n * across two labels are each below the bar, and merging them afterwards would\n * mean neither was ever considered.\n *\n * The surviving label is the lexicographically smallest of the merged set, not\n * the first one seen. First-seen follows session order, which changes whenever\n * a session is added — so the merged cluster would silently change its name\n * between runs, the state key with it, and every item the reader had already\n * decided on would come back as new. A merge has to be a pure function of what\n * was merged.\n */\nfunction mergeIdenticalText(entries: Acc[]): Acc[] {\n\tconst byText = new Map<string, Acc>();\n\tconst order: Acc[] = [];\n\n\tfor (const entry of entries) {\n\t\tconst key = entry.text.replace(/\\s+/g, \" \").trim().toLowerCase();\n\t\tconst existing = byText.get(key);\n\t\tif (!existing) {\n\t\t\tbyText.set(key, entry);\n\t\t\torder.push(entry);\n\t\t\tcontinue;\n\t\t}\n\t\texisting.count += entry.count;\n\t\tfor (const session of entry.sessions) existing.sessions.add(session);\n\t\tif (entry.lastSeen > existing.lastSeen) existing.lastSeen = entry.lastSeen;\n\t\texisting.rationale ??= entry.rationale;\n\t\texisting.samples.push(...entry.samples);\n\t\tif (entry.label < existing.label) existing.label = entry.label;\n\t}\n\n\treturn order;\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\n/**\n * The repeat threshold counts distinct sessions, not occurrences.\n *\n * Saying a thing twice inside one session is the commonest thing in a\n * transcript and usually means the opposite of durable: the agent ignored it\n * the first time, so it was restated. Two *sessions* is a claim about how you\n * work; two lines in one session is a claim about one afternoon.\n */\nfunction repeatedEnough(entry: Acc, minRepeats: number): boolean {\n\treturn entry.sessions.size >= minRepeats;\n}\n\nexport function reduceDirectives(sessions: MinedSession[], minRepeats: number): DirectiveCluster[] {\n\treturn mergeIdenticalText(groupByLabel(sessions, \"directive\"))\n\t\t.filter((entry) => repeatedEnough(entry, 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) => repeatedEnough(entry, 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 reduceRequests(sessions: MinedSession[], minRepeats: number): RequestCandidate[] {\n\treturn mergeIdenticalText(groupByLabel(sessions, \"request\"))\n\t\t.filter((entry) => repeatedEnough(entry, minRepeats))\n\t\t.map((entry) => ({\n\t\t\tkey: `request:${entry.label}`,\n\t\t\tlabel: entry.label,\n\t\t\ttext: entry.text,\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"]}
|
|
@@ -91,31 +91,24 @@ export interface LearnStats {
|
|
|
91
91
|
total: number;
|
|
92
92
|
directives: number;
|
|
93
93
|
fixes: number;
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Directive proposals that were genuinely open questions — not already
|
|
97
|
-
* written down when they were shown. Only these can be adopted or passed
|
|
98
|
-
* over, so they are the denominator of the rate.
|
|
99
|
-
*/
|
|
100
|
-
open: number;
|
|
101
|
-
adopted: number;
|
|
102
|
-
declined: number;
|
|
94
|
+
requests: number;
|
|
103
95
|
earliest?: string;
|
|
104
96
|
latest?: string;
|
|
105
97
|
lastRun?: string;
|
|
106
98
|
}
|
|
107
99
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* Nothing extra is recorded to make this work: each entry already stores whether
|
|
111
|
-
* it was covered when it was shown, and coverage now is recomputable, so the
|
|
112
|
-
* pair is enough to tell an adopted proposal from one that was passed over.
|
|
100
|
+
* Count what has been proposed, and when.
|
|
113
101
|
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
102
|
+
* Deliberately no adoption rate. There used to be one, derived by re-judging
|
|
103
|
+
* coverage and calling the delta "adopted", and it was wrong in both
|
|
104
|
+
* directions: a failed judge at either end moved the number, and a proposal
|
|
105
|
+
* correctly rejected as junk was indistinguishable from one ignored. It shipped
|
|
106
|
+
* with two disclaimers explaining how not to misread it, which is the clearest
|
|
107
|
+
* possible sign that it should not have shipped. What the reader actually wants
|
|
108
|
+
* — is the always-loaded surface growing or shrinking — is measurable exactly,
|
|
109
|
+
* from the context files themselves, and is reported instead.
|
|
117
110
|
*/
|
|
118
|
-
export declare function summarizeLearnState(state: LearnState
|
|
111
|
+
export declare function summarizeLearnState(state: LearnState): LearnStats;
|
|
119
112
|
/** Record everything this run put on screen, so the next run can suppress it. */
|
|
120
113
|
export declare function recordSurfaced(state: LearnState, items: Array<{
|
|
121
114
|
key: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../../src/core/learn/state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAsBH,MAAM,WAAW,YAAY;IAC5B,6DAA6D;IAC7D,UAAU,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,cAAc,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,mBAAmB,EAAE,OAAO,CAAC;IAC7B;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,UAAU;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;CACvC;AAMD;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAE9E;AAED,4EAA4E;AAC5E,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAavD;AAED,4EAA4E;AAC5E,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,GAAG,GAAE,IAAiB,GAAG,IAAI,CAe7F;AAED,8EAA8E;AAC9E,MAAM,WAAW,gBAAgB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,iEAAiE;IACjE,QAAQ,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,OAAO,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IAClC,qEAAqE;IACrE,UAAU,EAAE,OAAO,CAAC;IACpB;;;;OAIG;IACH,kBAAkB,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,gBAAgB,GAAG,kBAAkB,CAcpF;AAED,MAAM,WAAW,UAAU;IAC1B,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,GAAG,UAAU,CA6BhH;AAED,iFAAiF;AACjF,wBAAgB,cAAc,CAC7B,KAAK,EAAE,UAAU,EACjB,KAAK,EAAE,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,EAChF,GAAG,GAAE,IAAiB,GACpB,UAAU,CAYZ","sourcesContent":["/**\n * Per-directory memory of what `/learn` has already shown you.\n *\n * The extractor computes a sliding-window snapshot — \"in your last N sessions\n * you said this M times\" — which is stateless by design and correct to\n * recompute from scratch every run. What it cannot know on its own is whether\n * you have already *seen* a given item and made a call on it. Without that, a\n * rule you accepted last week comes back forever (its occurrences are still\n * inside the window), and worse, it comes back flagged `restated` — accusing a\n * rule that is working of not working. An item you declined comes back too,\n * unchanged, every single run.\n *\n * So one bookmark per directory: what was surfaced, and when. An item is shown\n * again only once something new has happened — you said it again *after* it was\n * last put in front of you. That single rule fixes both cases, and it keeps the\n * `restated` label honest, because the only way to earn it is to repeat yourself\n * after the rule already existed.\n *\n * This is a bookmark, not an index. No cards, no retrieval, no embeddings — the\n * transcripts remain the source of truth and this file can be deleted at any\n * time with no loss beyond re-proposing things once.\n */\n\nimport { existsSync, mkdirSync, readFileSync } from \"node:fs\";\nimport { basename, join } from \"node:path\";\nimport { writeFileAtomicSync } from \"../../utils/atomic-file.js\";\n\n/**\n * Bump when the on-disk shape changes; an unknown version is discarded, not\n * migrated.\n *\n * v2: keys hold the miner's semantic label (`directive:use-bun-not-npm`) where\n * v1 held normalized directive text (`directive:always use bun not npm`). The\n * two never collide, so a v1 file left in place would not suppress anything —\n * harmless — but every entry in it would be counted by `/learn stats` as a\n * proposal that was never adopted, permanently depressing the rate. Discarding\n * costs one round of re-proposing; keeping costs a number that stays wrong.\n */\nconst STATE_VERSION = 2;\n\n/** Surfaced keys older than this are forgotten, so the file cannot grow without bound. */\nconst STATE_RETENTION_DAYS = 180;\n\nexport interface SurfacedItem {\n\t/** When this item was last put in front of the user, ISO. */\n\tsurfacedAt: string;\n\t/** Newest occurrence the run knew about at that point, ISO. */\n\tlastOccurrence: string;\n\t/**\n\t * Whether the item was covered by a context file the last time it was\n\t * surfaced. Comparing that against coverage now is how an adopted proposal is\n\t * told from a declined one, without asking.\n\t */\n\tcoveredWhenSurfaced: boolean;\n\t/**\n\t * Representative wording, kept so `/learn stats` can ask about coverage using\n\t * what was actually said. The key alone is a slug, and judging \"is this\n\t * written down?\" from a slug is a much weaker question than judging it from\n\t * the sentence the slug stands for.\n\t */\n\ttext?: string;\n}\n\nexport interface LearnState {\n\tversion: number;\n\tlastRun?: string;\n\tsurfaced: Record<string, SurfacedItem>;\n}\n\nfunction emptyState(): LearnState {\n\treturn { version: STATE_VERSION, surfaced: {} };\n}\n\n/**\n * State file for a directory.\n *\n * Keyed off the *name* of the cwd's session directory, which encodes the cwd —\n * so the bookmark is per working directory, matching what the digest describes.\n * Callers pass the cwd-derived path rather than whatever directory the live\n * session happens to be reading: a session with no directory of its own would\n * otherwise land every project on one nameless state file, and projects sharing\n * a custom `sessionDir` would share one bookmark between them.\n */\nexport function getLearnStatePath(agentDir: string, sessionDir: string): string {\n\treturn join(agentDir, \"learn\", `${basename(sessionDir)}.json`);\n}\n\n/** Read state, treating any unreadable or unknown-version file as empty. */\nexport function readLearnState(path: string): LearnState {\n\ttry {\n\t\tif (!existsSync(path)) return emptyState();\n\t\tconst parsed = JSON.parse(readFileSync(path, \"utf-8\")) as Partial<LearnState>;\n\t\tif (parsed.version !== STATE_VERSION || typeof parsed.surfaced !== \"object\" || parsed.surfaced === null) {\n\t\t\treturn emptyState();\n\t\t}\n\t\treturn { version: STATE_VERSION, lastRun: parsed.lastRun, surfaced: parsed.surfaced };\n\t} catch {\n\t\t// A corrupt bookmark is not worth failing a command over; the cost of\n\t\t// starting fresh is proposing a few things a second time.\n\t\treturn emptyState();\n\t}\n}\n\n/** Persist state, pruning entries nothing has referenced in a long time. */\nexport function writeLearnState(path: string, state: LearnState, now: Date = new Date()): void {\n\tconst cutoff = now.getTime() - STATE_RETENTION_DAYS * 24 * 60 * 60 * 1000;\n\tconst surfaced: Record<string, SurfacedItem> = {};\n\tfor (const [key, item] of Object.entries(state.surfaced)) {\n\t\tconst at = Date.parse(item.surfacedAt);\n\t\tif (Number.isNaN(at) || at >= cutoff) surfaced[key] = item;\n\t}\n\n\ttry {\n\t\tmkdirSync(join(path, \"..\"), { recursive: true });\n\t\twriteFileAtomicSync(path, `${JSON.stringify({ ...state, surfaced }, null, 2)}\\n`);\n\t} catch {\n\t\t// Losing the bookmark degrades /learn to its previous behaviour (proposing\n\t\t// already-decided items again). Never worth failing the command.\n\t}\n}\n\n/** What the caller needs to decide whether an item is worth showing again. */\nexport interface SuppressionInput {\n\tkey: string;\n\t/** Newest occurrence of this item in the current window, ISO. */\n\tlastSeen: string;\n\t/** Whether a context file covers it right now. */\n\tcovered: boolean;\n}\n\nexport interface SuppressionVerdict {\n\t/** Drop it: already surfaced, and nothing new has happened since. */\n\tsuppressed: boolean;\n\t/**\n\t * Surfaced before, still not covered by any context file — you saw it and\n\t * chose not to write it down. Worth telling the model so it proposes again\n\t * tentatively instead of pressing the same case twice.\n\t */\n\tpreviouslyDeclined: boolean;\n}\n\n/**\n * Decide whether an item should be shown again.\n *\n * An item is suppressed when it was surfaced before and has not recurred since:\n * `lastSeen <= surfacedAt` means every occurrence backing it was already on\n * screen when you made your call. Say it again and `lastSeen` moves past\n * `surfacedAt`, and it returns — which is exactly when it is worth returning.\n */\nexport function judge(state: LearnState, input: SuppressionInput): SuppressionVerdict {\n\tconst previous = state.surfaced[input.key];\n\tif (!previous) return { suppressed: false, previouslyDeclined: false };\n\n\tconst surfacedAt = Date.parse(previous.surfacedAt);\n\tconst lastSeen = Date.parse(input.lastSeen);\n\tconst recurredSince = !Number.isNaN(surfacedAt) && !Number.isNaN(lastSeen) && lastSeen > surfacedAt;\n\n\treturn {\n\t\tsuppressed: !recurredSince,\n\t\t// Covered now but not when it was surfaced means the proposal was taken up.\n\t\t// Still uncovered means it was passed over.\n\t\tpreviouslyDeclined: !input.covered && !previous.coveredWhenSurfaced,\n\t};\n}\n\nexport interface LearnStats {\n\t/** Proposals on record, across all categories. */\n\ttotal: number;\n\tdirectives: number;\n\tfixes: number;\n\tworkflows: number;\n\t/**\n\t * Directive proposals that were genuinely open questions — not already\n\t * written down when they were shown. Only these can be adopted or passed\n\t * over, so they are the denominator of the rate.\n\t */\n\topen: number;\n\tadopted: number;\n\tdeclined: number;\n\tearliest?: string;\n\tlatest?: string;\n\tlastRun?: string;\n}\n\n/**\n * Reconstruct what happened to past proposals.\n *\n * Nothing extra is recorded to make this work: each entry already stores whether\n * it was covered when it was shown, and coverage now is recomputable, so the\n * pair is enough to tell an adopted proposal from one that was passed over.\n *\n * Directives only. Fixes and workflows have no coverage signal — a fix may have\n * become a rule, a skill, or a habit, and which one is not recoverable — so\n * counting them here would mean inventing an outcome.\n */\nexport function summarizeLearnState(state: LearnState, isCoveredNow: (normalized: string) => boolean): LearnStats {\n\tconst stats: LearnStats = { total: 0, directives: 0, fixes: 0, workflows: 0, open: 0, adopted: 0, declined: 0 };\n\n\tfor (const [key, item] of Object.entries(state.surfaced)) {\n\t\tstats.total++;\n\t\tif (!stats.earliest || item.surfacedAt < stats.earliest) stats.earliest = item.surfacedAt;\n\t\tif (!stats.latest || item.surfacedAt > stats.latest) stats.latest = item.surfacedAt;\n\n\t\tif (key.startsWith(\"fix:\")) {\n\t\t\tstats.fixes++;\n\t\t\tcontinue;\n\t\t}\n\t\tif (key.startsWith(\"workflow:\")) {\n\t\t\tstats.workflows++;\n\t\t\tcontinue;\n\t\t}\n\t\tif (!key.startsWith(\"directive:\")) continue;\n\n\t\tstats.directives++;\n\t\t// Already written down when shown, so there was no decision to make.\n\t\tif (item.coveredWhenSurfaced) continue;\n\n\t\tstats.open++;\n\t\tif (isCoveredNow(key.slice(\"directive:\".length))) stats.adopted++;\n\t\telse stats.declined++;\n\t}\n\n\tstats.lastRun = state.lastRun;\n\treturn stats;\n}\n\n/** Record everything this run put on screen, so the next run can suppress it. */\nexport function recordSurfaced(\n\tstate: LearnState,\n\titems: Array<{ key: string; lastSeen: string; covered: boolean; text?: string }>,\n\tnow: Date = new Date(),\n): LearnState {\n\tconst surfaced = { ...state.surfaced };\n\tconst at = now.toISOString();\n\tfor (const item of items) {\n\t\tsurfaced[item.key] = {\n\t\t\tsurfacedAt: at,\n\t\t\tlastOccurrence: item.lastSeen,\n\t\t\tcoveredWhenSurfaced: item.covered,\n\t\t\ttext: item.text,\n\t\t};\n\t}\n\treturn { version: STATE_VERSION, lastRun: at, surfaced };\n}\n"]}
|
|
1
|
+
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../../src/core/learn/state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAuBH,MAAM,WAAW,YAAY;IAC5B,6DAA6D;IAC7D,UAAU,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,cAAc,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,mBAAmB,EAAE,OAAO,CAAC;IAC7B;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,UAAU;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;CACvC;AAMD;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAE9E;AAED,4EAA4E;AAC5E,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAavD;AAED,4EAA4E;AAC5E,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,GAAG,GAAE,IAAiB,GAAG,IAAI,CAe7F;AAED,8EAA8E;AAC9E,MAAM,WAAW,gBAAgB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,iEAAiE;IACjE,QAAQ,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,OAAO,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IAClC,qEAAqE;IACrE,UAAU,EAAE,OAAO,CAAC;IACpB;;;;OAIG;IACH,kBAAkB,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,gBAAgB,GAAG,kBAAkB,CAcpF;AAED,MAAM,WAAW,UAAU;IAC1B,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,UAAU,GAAG,UAAU,CAejE;AAED,iFAAiF;AACjF,wBAAgB,cAAc,CAC7B,KAAK,EAAE,UAAU,EACjB,KAAK,EAAE,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,EAChF,GAAG,GAAE,IAAiB,GACpB,UAAU,CAYZ","sourcesContent":["/**\n * Per-directory memory of what `/learn` has already shown you.\n *\n * The extractor computes a sliding-window snapshot — \"in your last N sessions\n * you said this M times\" — which is stateless by design and correct to\n * recompute from scratch every run. What it cannot know on its own is whether\n * you have already *seen* a given item and made a call on it. Without that, a\n * rule you accepted last week comes back forever (its occurrences are still\n * inside the window), and worse, it comes back flagged `restated` — accusing a\n * rule that is working of not working. An item you declined comes back too,\n * unchanged, every single run.\n *\n * So one bookmark per directory: what was surfaced, and when. An item is shown\n * again only once something new has happened — you said it again *after* it was\n * last put in front of you. That single rule fixes both cases, and it keeps the\n * `restated` label honest, because the only way to earn it is to repeat yourself\n * after the rule already existed.\n *\n * This is a bookmark, not an index. No cards, no retrieval, no embeddings — the\n * transcripts remain the source of truth and this file can be deleted at any\n * time with no loss beyond re-proposing things once.\n */\n\nimport { existsSync, mkdirSync, readFileSync } from \"node:fs\";\nimport { basename, join } from \"node:path\";\nimport { writeFileAtomicSync } from \"../../utils/atomic-file.js\";\n\n/**\n * Bump when the on-disk shape changes; an unknown version is discarded, not\n * migrated.\n *\n * v2: keys hold the miner's semantic label (`directive:use-bun-not-npm`) where\n * v1 held normalized directive text (`directive:always use bun not npm`).\n *\n * v3: `workflow:` keys are gone — the category was removed — and labels now come\n * from a global naming pass rather than per-session guesses, so the keys a v2\n * file holds were drawn from a vocabulary that no longer exists. Keeping them\n * would suppress nothing and count as proposals that were never revisited.\n */\nconst STATE_VERSION = 3;\n\n/** Surfaced keys older than this are forgotten, so the file cannot grow without bound. */\nconst STATE_RETENTION_DAYS = 180;\n\nexport interface SurfacedItem {\n\t/** When this item was last put in front of the user, ISO. */\n\tsurfacedAt: string;\n\t/** Newest occurrence the run knew about at that point, ISO. */\n\tlastOccurrence: string;\n\t/**\n\t * Whether the item was covered by a context file the last time it was\n\t * surfaced. Comparing that against coverage now is how an adopted proposal is\n\t * told from a declined one, without asking.\n\t */\n\tcoveredWhenSurfaced: boolean;\n\t/**\n\t * Representative wording, kept so `/learn stats` can ask about coverage using\n\t * what was actually said. The key alone is a slug, and judging \"is this\n\t * written down?\" from a slug is a much weaker question than judging it from\n\t * the sentence the slug stands for.\n\t */\n\ttext?: string;\n}\n\nexport interface LearnState {\n\tversion: number;\n\tlastRun?: string;\n\tsurfaced: Record<string, SurfacedItem>;\n}\n\nfunction emptyState(): LearnState {\n\treturn { version: STATE_VERSION, surfaced: {} };\n}\n\n/**\n * State file for a directory.\n *\n * Keyed off the *name* of the cwd's session directory, which encodes the cwd —\n * so the bookmark is per working directory, matching what the digest describes.\n * Callers pass the cwd-derived path rather than whatever directory the live\n * session happens to be reading: a session with no directory of its own would\n * otherwise land every project on one nameless state file, and projects sharing\n * a custom `sessionDir` would share one bookmark between them.\n */\nexport function getLearnStatePath(agentDir: string, sessionDir: string): string {\n\treturn join(agentDir, \"learn\", `${basename(sessionDir)}.json`);\n}\n\n/** Read state, treating any unreadable or unknown-version file as empty. */\nexport function readLearnState(path: string): LearnState {\n\ttry {\n\t\tif (!existsSync(path)) return emptyState();\n\t\tconst parsed = JSON.parse(readFileSync(path, \"utf-8\")) as Partial<LearnState>;\n\t\tif (parsed.version !== STATE_VERSION || typeof parsed.surfaced !== \"object\" || parsed.surfaced === null) {\n\t\t\treturn emptyState();\n\t\t}\n\t\treturn { version: STATE_VERSION, lastRun: parsed.lastRun, surfaced: parsed.surfaced };\n\t} catch {\n\t\t// A corrupt bookmark is not worth failing a command over; the cost of\n\t\t// starting fresh is proposing a few things a second time.\n\t\treturn emptyState();\n\t}\n}\n\n/** Persist state, pruning entries nothing has referenced in a long time. */\nexport function writeLearnState(path: string, state: LearnState, now: Date = new Date()): void {\n\tconst cutoff = now.getTime() - STATE_RETENTION_DAYS * 24 * 60 * 60 * 1000;\n\tconst surfaced: Record<string, SurfacedItem> = {};\n\tfor (const [key, item] of Object.entries(state.surfaced)) {\n\t\tconst at = Date.parse(item.surfacedAt);\n\t\tif (Number.isNaN(at) || at >= cutoff) surfaced[key] = item;\n\t}\n\n\ttry {\n\t\tmkdirSync(join(path, \"..\"), { recursive: true });\n\t\twriteFileAtomicSync(path, `${JSON.stringify({ ...state, surfaced }, null, 2)}\\n`);\n\t} catch {\n\t\t// Losing the bookmark degrades /learn to its previous behaviour (proposing\n\t\t// already-decided items again). Never worth failing the command.\n\t}\n}\n\n/** What the caller needs to decide whether an item is worth showing again. */\nexport interface SuppressionInput {\n\tkey: string;\n\t/** Newest occurrence of this item in the current window, ISO. */\n\tlastSeen: string;\n\t/** Whether a context file covers it right now. */\n\tcovered: boolean;\n}\n\nexport interface SuppressionVerdict {\n\t/** Drop it: already surfaced, and nothing new has happened since. */\n\tsuppressed: boolean;\n\t/**\n\t * Surfaced before, still not covered by any context file — you saw it and\n\t * chose not to write it down. Worth telling the model so it proposes again\n\t * tentatively instead of pressing the same case twice.\n\t */\n\tpreviouslyDeclined: boolean;\n}\n\n/**\n * Decide whether an item should be shown again.\n *\n * An item is suppressed when it was surfaced before and has not recurred since:\n * `lastSeen <= surfacedAt` means every occurrence backing it was already on\n * screen when you made your call. Say it again and `lastSeen` moves past\n * `surfacedAt`, and it returns — which is exactly when it is worth returning.\n */\nexport function judge(state: LearnState, input: SuppressionInput): SuppressionVerdict {\n\tconst previous = state.surfaced[input.key];\n\tif (!previous) return { suppressed: false, previouslyDeclined: false };\n\n\tconst surfacedAt = Date.parse(previous.surfacedAt);\n\tconst lastSeen = Date.parse(input.lastSeen);\n\tconst recurredSince = !Number.isNaN(surfacedAt) && !Number.isNaN(lastSeen) && lastSeen > surfacedAt;\n\n\treturn {\n\t\tsuppressed: !recurredSince,\n\t\t// Covered now but not when it was surfaced means the proposal was taken up.\n\t\t// Still uncovered means it was passed over.\n\t\tpreviouslyDeclined: !input.covered && !previous.coveredWhenSurfaced,\n\t};\n}\n\nexport interface LearnStats {\n\t/** Proposals on record, across all categories. */\n\ttotal: number;\n\tdirectives: number;\n\tfixes: number;\n\trequests: number;\n\tearliest?: string;\n\tlatest?: string;\n\tlastRun?: string;\n}\n\n/**\n * Count what has been proposed, and when.\n *\n * Deliberately no adoption rate. There used to be one, derived by re-judging\n * coverage and calling the delta \"adopted\", and it was wrong in both\n * directions: a failed judge at either end moved the number, and a proposal\n * correctly rejected as junk was indistinguishable from one ignored. It shipped\n * with two disclaimers explaining how not to misread it, which is the clearest\n * possible sign that it should not have shipped. What the reader actually wants\n * — is the always-loaded surface growing or shrinking — is measurable exactly,\n * from the context files themselves, and is reported instead.\n */\nexport function summarizeLearnState(state: LearnState): LearnStats {\n\tconst stats: LearnStats = { total: 0, directives: 0, fixes: 0, requests: 0 };\n\n\tfor (const [key, item] of Object.entries(state.surfaced)) {\n\t\tstats.total++;\n\t\tif (!stats.earliest || item.surfacedAt < stats.earliest) stats.earliest = item.surfacedAt;\n\t\tif (!stats.latest || item.surfacedAt > stats.latest) stats.latest = item.surfacedAt;\n\n\t\tif (key.startsWith(\"fix:\")) stats.fixes++;\n\t\telse if (key.startsWith(\"request:\")) stats.requests++;\n\t\telse if (key.startsWith(\"directive:\")) stats.directives++;\n\t}\n\n\tstats.lastRun = state.lastRun;\n\treturn stats;\n}\n\n/** Record everything this run put on screen, so the next run can suppress it. */\nexport function recordSurfaced(\n\tstate: LearnState,\n\titems: Array<{ key: string; lastSeen: string; covered: boolean; text?: string }>,\n\tnow: Date = new Date(),\n): LearnState {\n\tconst surfaced = { ...state.surfaced };\n\tconst at = now.toISOString();\n\tfor (const item of items) {\n\t\tsurfaced[item.key] = {\n\t\t\tsurfacedAt: at,\n\t\t\tlastOccurrence: item.lastSeen,\n\t\t\tcoveredWhenSurfaced: item.covered,\n\t\t\ttext: item.text,\n\t\t};\n\t}\n\treturn { version: STATE_VERSION, lastRun: at, surfaced };\n}\n"]}
|