@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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The naming pass: decide which occurrences are the same point.
|
|
3
|
+
*
|
|
4
|
+
* This is the stage the pipeline was missing. Mining is a map over sessions and
|
|
5
|
+
* counting is a reduce over labels, but nothing sat in between to agree on what
|
|
6
|
+
* the labels *are*. The miner was asked to produce them from inside a single
|
|
7
|
+
* session — to hit a shared vocabulary it had never seen — and on a real corpus
|
|
8
|
+
* it agreed with itself 3 times in 188 candidates. `use-bun-not-npm` and
|
|
9
|
+
* `prefer-bun-over-npm` are the same rule and never met.
|
|
10
|
+
*
|
|
11
|
+
* So naming happens once, with everything visible at the same time. That is a
|
|
12
|
+
* different question than the miner was being asked: not "what is a good name
|
|
13
|
+
* for this sentence" but "which of these sentences are the same point", which
|
|
14
|
+
* is only answerable in the presence of the others.
|
|
15
|
+
*
|
|
16
|
+
* Two properties matter more than elegance here:
|
|
17
|
+
*
|
|
18
|
+
* - **Stability across runs.** State keys are `directive:<label>`, so a label
|
|
19
|
+
* that drifts between runs silently breaks suppression — every proposal you
|
|
20
|
+
* already decided on comes back forever. The labels already on record are
|
|
21
|
+
* therefore sent as a preferred vocabulary, and reusing one is the first
|
|
22
|
+
* instruction the model gets.
|
|
23
|
+
* - **Degrading in order.** A window too large for one call is processed in
|
|
24
|
+
* sequence, with the names assigned so far carried into the next call. That is
|
|
25
|
+
* worse than seeing everything at once, but it is worse in a predictable
|
|
26
|
+
* direction: later candidates join earlier clusters rather than starting
|
|
27
|
+
* rival ones.
|
|
28
|
+
*/
|
|
29
|
+
import { completeSimple } from "@kolisachint/hoocode-ai";
|
|
30
|
+
/**
|
|
31
|
+
* Candidates named per call.
|
|
32
|
+
*
|
|
33
|
+
* Sized so a typical window is one call: 200 quotes at ~120 characters is well
|
|
34
|
+
* inside a small model's window with room for the reply. Past that, clustering
|
|
35
|
+
* quality would degrade anyway — a list nobody can hold in mind is one nobody
|
|
36
|
+
* names consistently.
|
|
37
|
+
*/
|
|
38
|
+
const MAX_CANDIDATES_PER_CALL = 200;
|
|
39
|
+
/** Known labels offered as vocabulary. Enough to cover a real state file, short of flooding the prompt. */
|
|
40
|
+
const MAX_KNOWN_LABELS = 150;
|
|
41
|
+
/**
|
|
42
|
+
* Trim the vocabulary to what fits, keeping both ends.
|
|
43
|
+
*
|
|
44
|
+
* The list is ordered: labels already on record first, then names invented
|
|
45
|
+
* earlier in this run. Those are two different anchors — the first keeps the
|
|
46
|
+
* bookmark matching across runs, the second keeps a split window from starting
|
|
47
|
+
* rival names for one point — and taking a plain prefix silently drops the
|
|
48
|
+
* second exactly when batching makes it necessary.
|
|
49
|
+
*/
|
|
50
|
+
export function trimVocabulary(labels, max = MAX_KNOWN_LABELS) {
|
|
51
|
+
if (labels.length <= max)
|
|
52
|
+
return labels;
|
|
53
|
+
const head = Math.ceil(max / 2);
|
|
54
|
+
return [...labels.slice(0, head), ...labels.slice(-(max - head))];
|
|
55
|
+
}
|
|
56
|
+
/** Quote characters sent per candidate. A directive is identifiable long before this. */
|
|
57
|
+
const QUOTE_CHARS = 240;
|
|
58
|
+
const MAX_RESPONSE_TOKENS = 4_000;
|
|
59
|
+
const CLUSTER_SYSTEM_PROMPT = `You group occurrences from coding sessions by what they MEAN, and give each group a name.
|
|
60
|
+
|
|
61
|
+
You are given numbered ITEMS. Each is something a user said, or something that happened, across many sessions. Different sessions phrase the same point differently — your job is to recognise that and name the point once.
|
|
62
|
+
|
|
63
|
+
Rules, in order of importance:
|
|
64
|
+
|
|
65
|
+
1. If a label in KNOWN LABELS already names the point, reuse it EXACTLY. These are names already on record; reusing one is how a proposal the reader already decided on stays decided. Do not invent a synonym for a label that exists.
|
|
66
|
+
2. Items meaning the same thing MUST get the same label, even when the wording shares no words.
|
|
67
|
+
- "we're on bun now" / "stop using npm install" / "pnpm isn't what we use here" → use-bun-not-npm
|
|
68
|
+
- "never force push" / "don't rewrite shared history" → never-force-push
|
|
69
|
+
3. Items meaning different things MUST NOT share a label, even when the wording is similar. "doc tools off by default" and "network tools off by default" are the same shape and different rules.
|
|
70
|
+
4. A label is a short kebab-case slug naming the point, 2-5 words. Name the point, not the session it came from.
|
|
71
|
+
5. Never group across kinds. A directive ("how work should be done") and a request ("do this piece of work") are never the same item, even when they are about the same subject.
|
|
72
|
+
|
|
73
|
+
Output STRICT JSON, no markdown fence, no prose. One entry per item, using the item's number:
|
|
74
|
+
{"labels":[{"id":1,"label":"use-bun-not-npm"},{"id":2,"label":"use-bun-not-npm"}]}
|
|
75
|
+
|
|
76
|
+
Every item gets exactly one label. An item that means something no other item means still gets its own label — a group of one is a normal answer.`;
|
|
77
|
+
/** Render the numbered list the prompt describes. */
|
|
78
|
+
export function renderClusterRequest(inputs, knownLabels) {
|
|
79
|
+
const lines = [];
|
|
80
|
+
if (knownLabels.length > 0) {
|
|
81
|
+
lines.push("KNOWN LABELS (reuse exactly when one fits):");
|
|
82
|
+
for (const label of trimVocabulary(knownLabels))
|
|
83
|
+
lines.push(`- ${label}`);
|
|
84
|
+
lines.push("");
|
|
85
|
+
}
|
|
86
|
+
lines.push("ITEMS:");
|
|
87
|
+
for (const input of inputs) {
|
|
88
|
+
const quote = input.text.length > QUOTE_CHARS ? `${input.text.slice(0, QUOTE_CHARS)}…` : input.text;
|
|
89
|
+
lines.push(`${input.id}. [${input.kind}] ${quote.replace(/\s+/g, " ")}`);
|
|
90
|
+
}
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Read the label assignments out of a model response.
|
|
95
|
+
*
|
|
96
|
+
* Same forgiving parse as the miner: models fence JSON they were told not to
|
|
97
|
+
* fence, and one unparseable response should cost the run its grouping, not its
|
|
98
|
+
* life. An id the caller never asked about is dropped rather than trusted.
|
|
99
|
+
*/
|
|
100
|
+
export function parseClusterLabels(response, known) {
|
|
101
|
+
const out = new Map();
|
|
102
|
+
const start = response.indexOf("{");
|
|
103
|
+
const end = response.lastIndexOf("}");
|
|
104
|
+
if (start < 0 || end <= start)
|
|
105
|
+
return out;
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(response.slice(start, end + 1));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
const raw = parsed?.labels;
|
|
114
|
+
if (!Array.isArray(raw))
|
|
115
|
+
return out;
|
|
116
|
+
for (const item of raw) {
|
|
117
|
+
if (!item || typeof item !== "object")
|
|
118
|
+
continue;
|
|
119
|
+
const entry = item;
|
|
120
|
+
const id = typeof entry.id === "number" ? entry.id : Number.NaN;
|
|
121
|
+
const label = typeof entry.label === "string" ? entry.label.trim().toLowerCase() : "";
|
|
122
|
+
if (!Number.isInteger(id) || !known.has(id) || !label)
|
|
123
|
+
continue;
|
|
124
|
+
// Normalized to the slug shape the state file keys on, so a model that
|
|
125
|
+
// answers "Use Bun Not Npm" does not fork the vocabulary on punctuation.
|
|
126
|
+
out.set(id, label
|
|
127
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
128
|
+
.replace(/^-|-$/g, "")
|
|
129
|
+
.slice(0, 60));
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
export function createLlmClusterer(deps) {
|
|
134
|
+
return async (inputs, knownLabels, signal) => {
|
|
135
|
+
const assigned = new Map();
|
|
136
|
+
// Labels invented in an earlier batch join the vocabulary for the next, so
|
|
137
|
+
// a split window still converges on one name per point.
|
|
138
|
+
const vocabulary = [...knownLabels];
|
|
139
|
+
for (let offset = 0; offset < inputs.length; offset += MAX_CANDIDATES_PER_CALL) {
|
|
140
|
+
if (signal?.aborted)
|
|
141
|
+
break;
|
|
142
|
+
const batch = inputs.slice(offset, offset + MAX_CANDIDATES_PER_CALL);
|
|
143
|
+
const response = await completeSimple(deps.model, {
|
|
144
|
+
systemPrompt: CLUSTER_SYSTEM_PROMPT,
|
|
145
|
+
messages: [
|
|
146
|
+
{
|
|
147
|
+
role: "user",
|
|
148
|
+
content: [{ type: "text", text: renderClusterRequest(batch, vocabulary) }],
|
|
149
|
+
timestamp: Date.now(),
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
}, { maxTokens: MAX_RESPONSE_TOKENS, signal, apiKey: deps.apiKey, headers: deps.headers });
|
|
153
|
+
if (response.stopReason === "error") {
|
|
154
|
+
throw new Error(response.errorMessage || "clustering call failed");
|
|
155
|
+
}
|
|
156
|
+
const text = response.content
|
|
157
|
+
.filter((block) => block.type === "text")
|
|
158
|
+
.map((block) => block.text)
|
|
159
|
+
.join("");
|
|
160
|
+
for (const [id, label] of parseClusterLabels(text, new Set(batch.map((item) => item.id)))) {
|
|
161
|
+
assigned.set(id, label);
|
|
162
|
+
if (!vocabulary.includes(label))
|
|
163
|
+
vocabulary.push(label);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return assigned;
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Fallback naming for a candidate the clusterer did not label.
|
|
171
|
+
*
|
|
172
|
+
* A run whose clustering call failed should still propose something, so an
|
|
173
|
+
* unlabelled candidate falls back to a slug of its own text. That groups
|
|
174
|
+
* identical wording and nothing else — the behaviour the pipeline had before
|
|
175
|
+
* clustering existed, which is the right floor to fail to.
|
|
176
|
+
*/
|
|
177
|
+
export function fallbackLabel(text) {
|
|
178
|
+
return (text
|
|
179
|
+
.toLowerCase()
|
|
180
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
181
|
+
.replace(/^-|-$/g, "")
|
|
182
|
+
.slice(0, 60) || "unlabelled");
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=cluster.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cluster.js","sourceRoot":"","sources":["../../../src/core/learn/cluster.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The naming pass: decide which occurrences are the same point.\n *\n * This is the stage the pipeline was missing. Mining is a map over sessions and\n * counting is a reduce over labels, but nothing sat in between to agree on what\n * the labels *are*. The miner was asked to produce them from inside a single\n * session — to hit a shared vocabulary it had never seen — and on a real corpus\n * it agreed with itself 3 times in 188 candidates. `use-bun-not-npm` and\n * `prefer-bun-over-npm` are the same rule and never met.\n *\n * So naming happens once, with everything visible at the same time. That is a\n * different question than the miner was being asked: not \"what is a good name\n * for this sentence\" but \"which of these sentences are the same point\", which\n * is only answerable in the presence of the others.\n *\n * Two properties matter more than elegance here:\n *\n * - **Stability across runs.** State keys are `directive:<label>`, so a label\n * that drifts between runs silently breaks suppression — every proposal you\n * already decided on comes back forever. The labels already on record are\n * therefore sent as a preferred vocabulary, and reusing one is the first\n * instruction the model gets.\n * - **Degrading in order.** A window too large for one call is processed in\n * sequence, with the names assigned so far carried into the next call. That is\n * worse than seeing everything at once, but it is worse in a predictable\n * direction: later candidates join earlier clusters rather than starting\n * rival ones.\n */\n\nimport { completeSimple, type Model } from \"@kolisachint/hoocode-ai\";\nimport type { MinedCandidate } from \"./mine.js\";\n\n/**\n * Candidates named per call.\n *\n * Sized so a typical window is one call: 200 quotes at ~120 characters is well\n * inside a small model's window with room for the reply. Past that, clustering\n * quality would degrade anyway — a list nobody can hold in mind is one nobody\n * names consistently.\n */\nconst MAX_CANDIDATES_PER_CALL = 200;\n\n/** Known labels offered as vocabulary. Enough to cover a real state file, short of flooding the prompt. */\nconst MAX_KNOWN_LABELS = 150;\n\n/**\n * Trim the vocabulary to what fits, keeping both ends.\n *\n * The list is ordered: labels already on record first, then names invented\n * earlier in this run. Those are two different anchors — the first keeps the\n * bookmark matching across runs, the second keeps a split window from starting\n * rival names for one point — and taking a plain prefix silently drops the\n * second exactly when batching makes it necessary.\n */\nexport function trimVocabulary(labels: string[], max = MAX_KNOWN_LABELS): string[] {\n\tif (labels.length <= max) return labels;\n\tconst head = Math.ceil(max / 2);\n\treturn [...labels.slice(0, head), ...labels.slice(-(max - head))];\n}\n\n/** Quote characters sent per candidate. A directive is identifiable long before this. */\nconst QUOTE_CHARS = 240;\n\nconst MAX_RESPONSE_TOKENS = 4_000;\n\n/** One candidate to be named, with the identity the caller needs to put the label back. */\nexport interface ClusterInput {\n\t/** Caller's handle for this candidate; returned untouched. */\n\tid: number;\n\tkind: MinedCandidate[\"kind\"];\n\ttext: string;\n}\n\n/**\n * Assign a label to each input. Missing entries are left for the caller to\n * handle; a clusterer may legitimately decline to name something.\n */\nexport type Clusterer = (\n\tinputs: ClusterInput[],\n\tknownLabels: string[],\n\tsignal?: AbortSignal,\n) => Promise<Map<number, string>>;\n\nconst CLUSTER_SYSTEM_PROMPT = `You group occurrences from coding sessions by what they MEAN, and give each group a name.\n\nYou are given numbered ITEMS. Each is something a user said, or something that happened, across many sessions. Different sessions phrase the same point differently — your job is to recognise that and name the point once.\n\nRules, in order of importance:\n\n1. If a label in KNOWN LABELS already names the point, reuse it EXACTLY. These are names already on record; reusing one is how a proposal the reader already decided on stays decided. Do not invent a synonym for a label that exists.\n2. Items meaning the same thing MUST get the same label, even when the wording shares no words.\n - \"we're on bun now\" / \"stop using npm install\" / \"pnpm isn't what we use here\" → use-bun-not-npm\n - \"never force push\" / \"don't rewrite shared history\" → never-force-push\n3. Items meaning different things MUST NOT share a label, even when the wording is similar. \"doc tools off by default\" and \"network tools off by default\" are the same shape and different rules.\n4. A label is a short kebab-case slug naming the point, 2-5 words. Name the point, not the session it came from.\n5. Never group across kinds. A directive (\"how work should be done\") and a request (\"do this piece of work\") are never the same item, even when they are about the same subject.\n\nOutput STRICT JSON, no markdown fence, no prose. One entry per item, using the item's number:\n{\"labels\":[{\"id\":1,\"label\":\"use-bun-not-npm\"},{\"id\":2,\"label\":\"use-bun-not-npm\"}]}\n\nEvery item gets exactly one label. An item that means something no other item means still gets its own label — a group of one is a normal answer.`;\n\n/** Render the numbered list the prompt describes. */\nexport function renderClusterRequest(inputs: ClusterInput[], knownLabels: string[]): string {\n\tconst lines: string[] = [];\n\n\tif (knownLabels.length > 0) {\n\t\tlines.push(\"KNOWN LABELS (reuse exactly when one fits):\");\n\t\tfor (const label of trimVocabulary(knownLabels)) lines.push(`- ${label}`);\n\t\tlines.push(\"\");\n\t}\n\n\tlines.push(\"ITEMS:\");\n\tfor (const input of inputs) {\n\t\tconst quote = input.text.length > QUOTE_CHARS ? `${input.text.slice(0, QUOTE_CHARS)}…` : input.text;\n\t\tlines.push(`${input.id}. [${input.kind}] ${quote.replace(/\\s+/g, \" \")}`);\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Read the label assignments out of a model response.\n *\n * Same forgiving parse as the miner: models fence JSON they were told not to\n * fence, and one unparseable response should cost the run its grouping, not its\n * life. An id the caller never asked about is dropped rather than trusted.\n */\nexport function parseClusterLabels(response: string, known: Set<number>): Map<number, string> {\n\tconst out = new Map<number, string>();\n\tconst start = response.indexOf(\"{\");\n\tconst end = response.lastIndexOf(\"}\");\n\tif (start < 0 || end <= start) return out;\n\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(response.slice(start, end + 1));\n\t} catch {\n\t\treturn out;\n\t}\n\n\tconst raw = (parsed as { labels?: unknown })?.labels;\n\tif (!Array.isArray(raw)) return out;\n\n\tfor (const item of raw) {\n\t\tif (!item || typeof item !== \"object\") continue;\n\t\tconst entry = item as Record<string, unknown>;\n\t\tconst id = typeof entry.id === \"number\" ? entry.id : Number.NaN;\n\t\tconst label = typeof entry.label === \"string\" ? entry.label.trim().toLowerCase() : \"\";\n\t\tif (!Number.isInteger(id) || !known.has(id) || !label) continue;\n\t\t// Normalized to the slug shape the state file keys on, so a model that\n\t\t// answers \"Use Bun Not Npm\" does not fork the vocabulary on punctuation.\n\t\tout.set(\n\t\t\tid,\n\t\t\tlabel\n\t\t\t\t.replace(/[^a-z0-9]+/g, \"-\")\n\t\t\t\t.replace(/^-|-$/g, \"\")\n\t\t\t\t.slice(0, 60),\n\t\t);\n\t}\n\treturn out;\n}\n\nexport interface ClustererDeps {\n\tmodel: Model<any>;\n\tapiKey?: string;\n\theaders?: Record<string, string>;\n}\n\nexport function createLlmClusterer(deps: ClustererDeps): Clusterer {\n\treturn async (inputs, knownLabels, signal) => {\n\t\tconst assigned = new Map<number, string>();\n\t\t// Labels invented in an earlier batch join the vocabulary for the next, so\n\t\t// a split window still converges on one name per point.\n\t\tconst vocabulary = [...knownLabels];\n\n\t\tfor (let offset = 0; offset < inputs.length; offset += MAX_CANDIDATES_PER_CALL) {\n\t\t\tif (signal?.aborted) break;\n\t\t\tconst batch = inputs.slice(offset, offset + MAX_CANDIDATES_PER_CALL);\n\n\t\t\tconst response = await completeSimple(\n\t\t\t\tdeps.model,\n\t\t\t\t{\n\t\t\t\t\tsystemPrompt: CLUSTER_SYSTEM_PROMPT,\n\t\t\t\t\tmessages: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\trole: \"user\",\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: renderClusterRequest(batch, vocabulary) }],\n\t\t\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t\t{ maxTokens: MAX_RESPONSE_TOKENS, signal, apiKey: deps.apiKey, headers: deps.headers },\n\t\t\t);\n\n\t\t\tif (response.stopReason === \"error\") {\n\t\t\t\tthrow new Error(response.errorMessage || \"clustering call failed\");\n\t\t\t}\n\n\t\t\tconst text = response.content\n\t\t\t\t.filter((block): block is { type: \"text\"; text: string } => block.type === \"text\")\n\t\t\t\t.map((block) => block.text)\n\t\t\t\t.join(\"\");\n\n\t\t\tfor (const [id, label] of parseClusterLabels(text, new Set(batch.map((item) => item.id)))) {\n\t\t\t\tassigned.set(id, label);\n\t\t\t\tif (!vocabulary.includes(label)) vocabulary.push(label);\n\t\t\t}\n\t\t}\n\n\t\treturn assigned;\n\t};\n}\n\n/**\n * Fallback naming for a candidate the clusterer did not label.\n *\n * A run whose clustering call failed should still propose something, so an\n * unlabelled candidate falls back to a slug of its own text. That groups\n * identical wording and nothing else — the behaviour the pipeline had before\n * clustering existed, which is the right floor to fail to.\n */\nexport function fallbackLabel(text: string): string {\n\treturn (\n\t\ttext\n\t\t\t.toLowerCase()\n\t\t\t.replace(/[^a-z0-9]+/g, \"-\")\n\t\t\t.replace(/^-|-$/g, \"\")\n\t\t\t.slice(0, 60) || \"unlabelled\"\n\t);\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"coverage.d.ts","sourceRoot":"","sources":["../../../src/core/learn/coverage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAC;AAGrD,MAAM,WAAW,aAAa;IAC7B,4EAA4E;IAC5E,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACrD;AAED,MAAM,WAAW,aAAa;IAC7B,sDAAsD;IACtD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,gFAAgF;AAChF,MAAM,WAAW,aAAa;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACb;AAED;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,CAC3B,OAAO,EAAE,aAAa,EAAE,EACxB,KAAK,EAAE,aAAa,EACpB,MAAM,CAAC,EAAE,WAAW,KAChB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"coverage.d.ts","sourceRoot":"","sources":["../../../src/core/learn/coverage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAC;AAGrD,MAAM,WAAW,aAAa;IAC7B,4EAA4E;IAC5E,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACrD;AAED,MAAM,WAAW,aAAa;IAC7B,sDAAsD;IACtD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,gFAAgF;AAChF,MAAM,WAAW,aAAa;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACb;AAED;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,CAC3B,OAAO,EAAE,aAAa,EAAE,EACxB,KAAK,EAAE,aAAa,EACpB,MAAM,CAAC,EAAE,WAAW,KAChB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC;AA0DzC,2FAA2F;AAC3F,wBAAgB,aAAa,CAC5B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,aAAa,EAAE,EACxB,KAAK,EAAE,aAAa,GAClB,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAwC5B;AAED,MAAM,WAAW,YAAY;IAC5B,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,YAAY,GAAG,aAAa,CA6BxE;AAED,8FAA8F;AAC9F,eAAO,MAAM,eAAe,EAAE,aAAqC,CAAC","sourcesContent":["/**\n * Is this already written down?\n *\n * The answer decides the most useful distinction the digest makes — `new` vs\n * `restated` vs `has-skill` — and it used to be decided by bag-of-words\n * overlap: count how many content words of the proposal appear anywhere in a\n * rule line, call it covered above 0.6. That is wrong in both directions and\n * for the same reason, namely that it does not read. It calls \"always run tests\n * before pushing\" covered by a line about \"running the test suite in CI\", and\n * it misses a real paraphrase that happens to pick different vocabulary.\n *\n * Both mistakes are expensive. A false `restated` accuses a rule that is\n * working of not working, and tells the reader to rewrite something fine. A\n * false `new` proposes a rule they already have, which is how a context file\n * grows duplicates.\n *\n * So a model reads the rules and the proposals together and matches them. One\n * call for the whole batch, because the question is small and the corpus is the\n * same for every item — the context file is a few thousand tokens and does not\n * want re-sending once per proposal.\n */\n\nimport type { Model } from \"@kolisachint/hoocode-ai\";\nimport { completeSimple } from \"@kolisachint/hoocode-ai\";\n\nexport interface CoverageIndex {\n\t/** Candidate rule lines from the repo context file and both user scopes. */\n\truleLines: string[];\n\tskills: Array<{ name: string; description: string }>;\n}\n\nexport interface CoverageMatch {\n\t/** The context-file line that covers this, if any. */\n\trule?: string;\n\t/** The skill that covers this, if any. Only set when no rule matched. */\n\tskill?: string;\n}\n\n/** One thing to look up, identified by the label the reduce step grouped on. */\nexport interface CoverageQuery {\n\tlabel: string;\n\ttext: string;\n}\n\n/**\n * Decide coverage for a batch. Injectable so the pipeline can be tested without\n * a model, and so a run with no model configured can degrade to \"everything is\n * new\" rather than failing.\n */\nexport type CoverageJudge = (\n\tqueries: CoverageQuery[],\n\tindex: CoverageIndex,\n\tsignal?: AbortSignal,\n) => Promise<Map<string, CoverageMatch>>;\n\n/** Rule lines sent per call. A context file longer than this is already the problem. */\nconst MAX_RULE_LINES = 400;\n/** Skills sent per call. */\nconst MAX_SKILLS = 120;\n/** Description characters per skill — the opening says what it does; the rest is trigger bait. */\nconst SKILL_DESCRIPTION_CHARS = 300;\nconst MAX_RESPONSE_TOKENS = 2_000;\n\nconst COVERAGE_SYSTEM_PROMPT = `You decide whether each proposed rule is ALREADY covered by existing project rules or skills.\n\nYou are given numbered RULES (lines from context files), numbered SKILLS (name and description), and numbered PROPOSALS.\n\nEach rule reads \\`[scope] Heading > Subheading > line\\`. The scope is \\`repo\\` (binds work in this project) or \\`user\\` (binds everywhere). The heading path is the section the line lives under, and it is what tells you the line's subject when the line alone is ambiguous.\n\nFor each proposal, decide:\n- \"rule\" — an existing rule already says this. The reader repeating it means that rule is not working, so it should be rewritten rather than duplicated.\n- \"skill\" — an existing skill already does this, and the reader asked by hand anyway. Usually the skill's description does not describe the situation they were in.\n- \"new\" — nothing covers it.\n\nJudge by MEANING, not by shared words. Different vocabulary for the same instruction is covered. Shared vocabulary about different things is NOT covered:\n- proposal \"always use bun, never npm\" vs rule \"install dependencies with bun\" → covered (rule)\n- proposal \"run tests before pushing\" vs rule \"CI runs the test suite on every PR\" → NOT covered, these are different instructions to different actors\n- proposal \"prefer table output\" vs rule \"use tables in documentation\" → NOT covered unless the scope matches\n\nPrefer \"new\" when genuinely unsure. A false \"covered\" tells the reader to rewrite a rule that is fine; a false \"new\" merely proposes something they can reject.\n\nRules win over skills when both match: rewriting a line is more actionable than sharpening a description.\n\nOutput STRICT JSON, no markdown fence, no prose. Use the proposal's exact label:\n{\"verdicts\":[{\"label\":\"use-bun-not-npm\",\"verdict\":\"rule\",\"ruleIndex\":3},{\"label\":\"scaffold-route\",\"verdict\":\"skill\",\"skillIndex\":1},{\"label\":\"prefer-tables\",\"verdict\":\"new\"}]}`;\n\nfunction buildPrompt(queries: CoverageQuery[], index: CoverageIndex): string {\n\tconst lines: string[] = [];\n\n\tlines.push(\"RULES:\");\n\tconst rules = index.ruleLines.slice(0, MAX_RULE_LINES);\n\tif (rules.length === 0) lines.push(\"(none)\");\n\tfor (const [i, rule] of rules.entries()) {\n\t\tlines.push(`${i}. ${rule}`);\n\t}\n\n\tlines.push(\"\", \"SKILLS:\");\n\tconst skills = index.skills.slice(0, MAX_SKILLS);\n\tif (skills.length === 0) lines.push(\"(none)\");\n\tfor (const [i, skill] of skills.entries()) {\n\t\tlines.push(`${i}. ${skill.name} — ${skill.description.slice(0, SKILL_DESCRIPTION_CHARS)}`);\n\t}\n\n\tlines.push(\"\", \"PROPOSALS:\");\n\tfor (const query of queries) {\n\t\tlines.push(`- label: ${query.label}\\n text: ${query.text}`);\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\n/** Read the verdict list back, ignoring anything malformed rather than failing the run. */\nexport function parseVerdicts(\n\tresponse: string,\n\tqueries: CoverageQuery[],\n\tindex: CoverageIndex,\n): Map<string, CoverageMatch> {\n\tconst out = new Map<string, CoverageMatch>();\n\tconst start = response.indexOf(\"{\");\n\tconst end = response.lastIndexOf(\"}\");\n\tif (start < 0 || end <= start) return out;\n\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(response.slice(start, end + 1));\n\t} catch {\n\t\treturn out;\n\t}\n\n\tconst raw = (parsed as { verdicts?: unknown })?.verdicts;\n\tif (!Array.isArray(raw)) return out;\n\n\tconst known = new Set(queries.map((q) => q.label));\n\tfor (const item of raw) {\n\t\tif (!item || typeof item !== \"object\") continue;\n\t\tconst verdict = item as Record<string, unknown>;\n\t\tconst label = typeof verdict.label === \"string\" ? verdict.label.trim().toLowerCase() : \"\";\n\t\t// A label the batch did not ask about is a hallucinated row; dropping it is\n\t\t// safer than letting it mark some other proposal covered.\n\t\tif (!label || !known.has(label)) continue;\n\n\t\tif (verdict.verdict === \"rule\") {\n\t\t\tconst at = typeof verdict.ruleIndex === \"number\" ? index.ruleLines[verdict.ruleIndex] : undefined;\n\t\t\t// An out-of-range index means the model decided \"covered\" but cannot show\n\t\t\t// which line. Treat that as `new`: the reader cannot act on an unnamed rule.\n\t\t\tif (at) out.set(label, { rule: at });\n\t\t\tcontinue;\n\t\t}\n\t\tif (verdict.verdict === \"skill\") {\n\t\t\tconst at = typeof verdict.skillIndex === \"number\" ? index.skills[verdict.skillIndex] : undefined;\n\t\t\tif (at) out.set(label, { skill: at.name });\n\t\t\tcontinue;\n\t\t}\n\t\tout.set(label, {});\n\t}\n\treturn out;\n}\n\nexport interface CoverageDeps {\n\tmodel: Model<any>;\n\tapiKey?: string;\n\theaders?: Record<string, string>;\n}\n\nexport function createLlmCoverageJudge(deps: CoverageDeps): CoverageJudge {\n\treturn async (queries, index, signal) => {\n\t\t// Nothing to match against means nothing can be covered, and the call would\n\t\t// be pure cost.\n\t\tif (queries.length === 0 || (index.ruleLines.length === 0 && index.skills.length === 0)) {\n\t\t\treturn new Map();\n\t\t}\n\n\t\tconst response = await completeSimple(\n\t\t\tdeps.model,\n\t\t\t{\n\t\t\t\tsystemPrompt: COVERAGE_SYSTEM_PROMPT,\n\t\t\t\tmessages: [\n\t\t\t\t\t{ role: \"user\", content: [{ type: \"text\", text: buildPrompt(queries, index) }], timestamp: Date.now() },\n\t\t\t\t],\n\t\t\t},\n\t\t\t{ maxTokens: MAX_RESPONSE_TOKENS, signal, apiKey: deps.apiKey, headers: deps.headers },\n\t\t);\n\n\t\tif (response.stopReason === \"error\") {\n\t\t\tthrow new Error(response.errorMessage || \"coverage call failed\");\n\t\t}\n\n\t\tconst text = response.content\n\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t.map((c) => c.text)\n\t\t\t.join(\"\\n\");\n\t\treturn parseVerdicts(text, queries, index);\n\t};\n}\n\n/** Everything is new. Used when no model is available, so the run still produces a digest. */\nexport const noCoverageJudge: CoverageJudge = async () => new Map();\n"]}
|
|
@@ -31,6 +31,8 @@ const COVERAGE_SYSTEM_PROMPT = `You decide whether each proposed rule is ALREADY
|
|
|
31
31
|
|
|
32
32
|
You are given numbered RULES (lines from context files), numbered SKILLS (name and description), and numbered PROPOSALS.
|
|
33
33
|
|
|
34
|
+
Each rule reads \`[scope] Heading > Subheading > line\`. The scope is \`repo\` (binds work in this project) or \`user\` (binds everywhere). The heading path is the section the line lives under, and it is what tells you the line's subject when the line alone is ambiguous.
|
|
35
|
+
|
|
34
36
|
For each proposal, decide:
|
|
35
37
|
- "rule" — an existing rule already says this. The reader repeating it means that rule is not working, so it should be rewritten rather than duplicated.
|
|
36
38
|
- "skill" — an existing skill already does this, and the reader asked by hand anyway. Usually the skill's description does not describe the situation they were in.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"coverage.js","sourceRoot":"","sources":["../../../src/core/learn/coverage.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Is this already written down?\n *\n * The answer decides the most useful distinction the digest makes — `new` vs\n * `restated` vs `has-skill` — and it used to be decided by bag-of-words\n * overlap: count how many content words of the proposal appear anywhere in a\n * rule line, call it covered above 0.6. That is wrong in both directions and\n * for the same reason, namely that it does not read. It calls \"always run tests\n * before pushing\" covered by a line about \"running the test suite in CI\", and\n * it misses a real paraphrase that happens to pick different vocabulary.\n *\n * Both mistakes are expensive. A false `restated` accuses a rule that is\n * working of not working, and tells the reader to rewrite something fine. A\n * false `new` proposes a rule they already have, which is how a context file\n * grows duplicates.\n *\n * So a model reads the rules and the proposals together and matches them. One\n * call for the whole batch, because the question is small and the corpus is the\n * same for every item — the context file is a few thousand tokens and does not\n * want re-sending once per proposal.\n */\n\nimport type { Model } from \"@kolisachint/hoocode-ai\";\nimport { completeSimple } from \"@kolisachint/hoocode-ai\";\n\nexport interface CoverageIndex {\n\t/** Candidate rule lines from the repo context file and both user scopes. */\n\truleLines: string[];\n\tskills: Array<{ name: string; description: string }>;\n}\n\nexport interface CoverageMatch {\n\t/** The context-file line that covers this, if any. */\n\trule?: string;\n\t/** The skill that covers this, if any. Only set when no rule matched. */\n\tskill?: string;\n}\n\n/** One thing to look up, identified by the label the reduce step grouped on. */\nexport interface CoverageQuery {\n\tlabel: string;\n\ttext: string;\n}\n\n/**\n * Decide coverage for a batch. Injectable so the pipeline can be tested without\n * a model, and so a run with no model configured can degrade to \"everything is\n * new\" rather than failing.\n */\nexport type CoverageJudge = (\n\tqueries: CoverageQuery[],\n\tindex: CoverageIndex,\n\tsignal?: AbortSignal,\n) => Promise<Map<string, CoverageMatch>>;\n\n/** Rule lines sent per call. A context file longer than this is already the problem. */\nconst MAX_RULE_LINES = 400;\n/** Skills sent per call. */\nconst MAX_SKILLS = 120;\n/** Description characters per skill — the opening says what it does; the rest is trigger bait. */\nconst SKILL_DESCRIPTION_CHARS = 300;\nconst MAX_RESPONSE_TOKENS = 2_000;\n\nconst COVERAGE_SYSTEM_PROMPT = `You decide whether each proposed rule is ALREADY covered by existing project rules or skills.\n\nYou are given numbered RULES (lines from context files), numbered SKILLS (name and description), and numbered PROPOSALS.\n\nFor each proposal, decide:\n- \"rule\" — an existing rule already says this. The reader repeating it means that rule is not working, so it should be rewritten rather than duplicated.\n- \"skill\" — an existing skill already does this, and the reader asked by hand anyway. Usually the skill's description does not describe the situation they were in.\n- \"new\" — nothing covers it.\n\nJudge by MEANING, not by shared words. Different vocabulary for the same instruction is covered. Shared vocabulary about different things is NOT covered:\n- proposal \"always use bun, never npm\" vs rule \"install dependencies with bun\" → covered (rule)\n- proposal \"run tests before pushing\" vs rule \"CI runs the test suite on every PR\" → NOT covered, these are different instructions to different actors\n- proposal \"prefer table output\" vs rule \"use tables in documentation\" → NOT covered unless the scope matches\n\nPrefer \"new\" when genuinely unsure. A false \"covered\" tells the reader to rewrite a rule that is fine; a false \"new\" merely proposes something they can reject.\n\nRules win over skills when both match: rewriting a line is more actionable than sharpening a description.\n\nOutput STRICT JSON, no markdown fence, no prose. Use the proposal's exact label:\n{\"verdicts\":[{\"label\":\"use-bun-not-npm\",\"verdict\":\"rule\",\"ruleIndex\":3},{\"label\":\"scaffold-route\",\"verdict\":\"skill\",\"skillIndex\":1},{\"label\":\"prefer-tables\",\"verdict\":\"new\"}]}`;\n\nfunction buildPrompt(queries: CoverageQuery[], index: CoverageIndex): string {\n\tconst lines: string[] = [];\n\n\tlines.push(\"RULES:\");\n\tconst rules = index.ruleLines.slice(0, MAX_RULE_LINES);\n\tif (rules.length === 0) lines.push(\"(none)\");\n\tfor (const [i, rule] of rules.entries()) {\n\t\tlines.push(`${i}. ${rule}`);\n\t}\n\n\tlines.push(\"\", \"SKILLS:\");\n\tconst skills = index.skills.slice(0, MAX_SKILLS);\n\tif (skills.length === 0) lines.push(\"(none)\");\n\tfor (const [i, skill] of skills.entries()) {\n\t\tlines.push(`${i}. ${skill.name} — ${skill.description.slice(0, SKILL_DESCRIPTION_CHARS)}`);\n\t}\n\n\tlines.push(\"\", \"PROPOSALS:\");\n\tfor (const query of queries) {\n\t\tlines.push(`- label: ${query.label}\\n text: ${query.text}`);\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\n/** Read the verdict list back, ignoring anything malformed rather than failing the run. */\nexport function parseVerdicts(\n\tresponse: string,\n\tqueries: CoverageQuery[],\n\tindex: CoverageIndex,\n): Map<string, CoverageMatch> {\n\tconst out = new Map<string, CoverageMatch>();\n\tconst start = response.indexOf(\"{\");\n\tconst end = response.lastIndexOf(\"}\");\n\tif (start < 0 || end <= start) return out;\n\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(response.slice(start, end + 1));\n\t} catch {\n\t\treturn out;\n\t}\n\n\tconst raw = (parsed as { verdicts?: unknown })?.verdicts;\n\tif (!Array.isArray(raw)) return out;\n\n\tconst known = new Set(queries.map((q) => q.label));\n\tfor (const item of raw) {\n\t\tif (!item || typeof item !== \"object\") continue;\n\t\tconst verdict = item as Record<string, unknown>;\n\t\tconst label = typeof verdict.label === \"string\" ? verdict.label.trim().toLowerCase() : \"\";\n\t\t// A label the batch did not ask about is a hallucinated row; dropping it is\n\t\t// safer than letting it mark some other proposal covered.\n\t\tif (!label || !known.has(label)) continue;\n\n\t\tif (verdict.verdict === \"rule\") {\n\t\t\tconst at = typeof verdict.ruleIndex === \"number\" ? index.ruleLines[verdict.ruleIndex] : undefined;\n\t\t\t// An out-of-range index means the model decided \"covered\" but cannot show\n\t\t\t// which line. Treat that as `new`: the reader cannot act on an unnamed rule.\n\t\t\tif (at) out.set(label, { rule: at });\n\t\t\tcontinue;\n\t\t}\n\t\tif (verdict.verdict === \"skill\") {\n\t\t\tconst at = typeof verdict.skillIndex === \"number\" ? index.skills[verdict.skillIndex] : undefined;\n\t\t\tif (at) out.set(label, { skill: at.name });\n\t\t\tcontinue;\n\t\t}\n\t\tout.set(label, {});\n\t}\n\treturn out;\n}\n\nexport interface CoverageDeps {\n\tmodel: Model<any>;\n\tapiKey?: string;\n\theaders?: Record<string, string>;\n}\n\nexport function createLlmCoverageJudge(deps: CoverageDeps): CoverageJudge {\n\treturn async (queries, index, signal) => {\n\t\t// Nothing to match against means nothing can be covered, and the call would\n\t\t// be pure cost.\n\t\tif (queries.length === 0 || (index.ruleLines.length === 0 && index.skills.length === 0)) {\n\t\t\treturn new Map();\n\t\t}\n\n\t\tconst response = await completeSimple(\n\t\t\tdeps.model,\n\t\t\t{\n\t\t\t\tsystemPrompt: COVERAGE_SYSTEM_PROMPT,\n\t\t\t\tmessages: [\n\t\t\t\t\t{ role: \"user\", content: [{ type: \"text\", text: buildPrompt(queries, index) }], timestamp: Date.now() },\n\t\t\t\t],\n\t\t\t},\n\t\t\t{ maxTokens: MAX_RESPONSE_TOKENS, signal, apiKey: deps.apiKey, headers: deps.headers },\n\t\t);\n\n\t\tif (response.stopReason === \"error\") {\n\t\t\tthrow new Error(response.errorMessage || \"coverage call failed\");\n\t\t}\n\n\t\tconst text = response.content\n\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t.map((c) => c.text)\n\t\t\t.join(\"\\n\");\n\t\treturn parseVerdicts(text, queries, index);\n\t};\n}\n\n/** Everything is new. Used when no model is available, so the run still produces a digest. */\nexport const noCoverageJudge: CoverageJudge = async () => new Map();\n"]}
|
|
1
|
+
{"version":3,"file":"coverage.js","sourceRoot":"","sources":["../../../src/core/learn/coverage.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Is this already written down?\n *\n * The answer decides the most useful distinction the digest makes — `new` vs\n * `restated` vs `has-skill` — and it used to be decided by bag-of-words\n * overlap: count how many content words of the proposal appear anywhere in a\n * rule line, call it covered above 0.6. That is wrong in both directions and\n * for the same reason, namely that it does not read. It calls \"always run tests\n * before pushing\" covered by a line about \"running the test suite in CI\", and\n * it misses a real paraphrase that happens to pick different vocabulary.\n *\n * Both mistakes are expensive. A false `restated` accuses a rule that is\n * working of not working, and tells the reader to rewrite something fine. A\n * false `new` proposes a rule they already have, which is how a context file\n * grows duplicates.\n *\n * So a model reads the rules and the proposals together and matches them. One\n * call for the whole batch, because the question is small and the corpus is the\n * same for every item — the context file is a few thousand tokens and does not\n * want re-sending once per proposal.\n */\n\nimport type { Model } from \"@kolisachint/hoocode-ai\";\nimport { completeSimple } from \"@kolisachint/hoocode-ai\";\n\nexport interface CoverageIndex {\n\t/** Candidate rule lines from the repo context file and both user scopes. */\n\truleLines: string[];\n\tskills: Array<{ name: string; description: string }>;\n}\n\nexport interface CoverageMatch {\n\t/** The context-file line that covers this, if any. */\n\trule?: string;\n\t/** The skill that covers this, if any. Only set when no rule matched. */\n\tskill?: string;\n}\n\n/** One thing to look up, identified by the label the reduce step grouped on. */\nexport interface CoverageQuery {\n\tlabel: string;\n\ttext: string;\n}\n\n/**\n * Decide coverage for a batch. Injectable so the pipeline can be tested without\n * a model, and so a run with no model configured can degrade to \"everything is\n * new\" rather than failing.\n */\nexport type CoverageJudge = (\n\tqueries: CoverageQuery[],\n\tindex: CoverageIndex,\n\tsignal?: AbortSignal,\n) => Promise<Map<string, CoverageMatch>>;\n\n/** Rule lines sent per call. A context file longer than this is already the problem. */\nconst MAX_RULE_LINES = 400;\n/** Skills sent per call. */\nconst MAX_SKILLS = 120;\n/** Description characters per skill — the opening says what it does; the rest is trigger bait. */\nconst SKILL_DESCRIPTION_CHARS = 300;\nconst MAX_RESPONSE_TOKENS = 2_000;\n\nconst COVERAGE_SYSTEM_PROMPT = `You decide whether each proposed rule is ALREADY covered by existing project rules or skills.\n\nYou are given numbered RULES (lines from context files), numbered SKILLS (name and description), and numbered PROPOSALS.\n\nEach rule reads \\`[scope] Heading > Subheading > line\\`. The scope is \\`repo\\` (binds work in this project) or \\`user\\` (binds everywhere). The heading path is the section the line lives under, and it is what tells you the line's subject when the line alone is ambiguous.\n\nFor each proposal, decide:\n- \"rule\" — an existing rule already says this. The reader repeating it means that rule is not working, so it should be rewritten rather than duplicated.\n- \"skill\" — an existing skill already does this, and the reader asked by hand anyway. Usually the skill's description does not describe the situation they were in.\n- \"new\" — nothing covers it.\n\nJudge by MEANING, not by shared words. Different vocabulary for the same instruction is covered. Shared vocabulary about different things is NOT covered:\n- proposal \"always use bun, never npm\" vs rule \"install dependencies with bun\" → covered (rule)\n- proposal \"run tests before pushing\" vs rule \"CI runs the test suite on every PR\" → NOT covered, these are different instructions to different actors\n- proposal \"prefer table output\" vs rule \"use tables in documentation\" → NOT covered unless the scope matches\n\nPrefer \"new\" when genuinely unsure. A false \"covered\" tells the reader to rewrite a rule that is fine; a false \"new\" merely proposes something they can reject.\n\nRules win over skills when both match: rewriting a line is more actionable than sharpening a description.\n\nOutput STRICT JSON, no markdown fence, no prose. Use the proposal's exact label:\n{\"verdicts\":[{\"label\":\"use-bun-not-npm\",\"verdict\":\"rule\",\"ruleIndex\":3},{\"label\":\"scaffold-route\",\"verdict\":\"skill\",\"skillIndex\":1},{\"label\":\"prefer-tables\",\"verdict\":\"new\"}]}`;\n\nfunction buildPrompt(queries: CoverageQuery[], index: CoverageIndex): string {\n\tconst lines: string[] = [];\n\n\tlines.push(\"RULES:\");\n\tconst rules = index.ruleLines.slice(0, MAX_RULE_LINES);\n\tif (rules.length === 0) lines.push(\"(none)\");\n\tfor (const [i, rule] of rules.entries()) {\n\t\tlines.push(`${i}. ${rule}`);\n\t}\n\n\tlines.push(\"\", \"SKILLS:\");\n\tconst skills = index.skills.slice(0, MAX_SKILLS);\n\tif (skills.length === 0) lines.push(\"(none)\");\n\tfor (const [i, skill] of skills.entries()) {\n\t\tlines.push(`${i}. ${skill.name} — ${skill.description.slice(0, SKILL_DESCRIPTION_CHARS)}`);\n\t}\n\n\tlines.push(\"\", \"PROPOSALS:\");\n\tfor (const query of queries) {\n\t\tlines.push(`- label: ${query.label}\\n text: ${query.text}`);\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\n/** Read the verdict list back, ignoring anything malformed rather than failing the run. */\nexport function parseVerdicts(\n\tresponse: string,\n\tqueries: CoverageQuery[],\n\tindex: CoverageIndex,\n): Map<string, CoverageMatch> {\n\tconst out = new Map<string, CoverageMatch>();\n\tconst start = response.indexOf(\"{\");\n\tconst end = response.lastIndexOf(\"}\");\n\tif (start < 0 || end <= start) return out;\n\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(response.slice(start, end + 1));\n\t} catch {\n\t\treturn out;\n\t}\n\n\tconst raw = (parsed as { verdicts?: unknown })?.verdicts;\n\tif (!Array.isArray(raw)) return out;\n\n\tconst known = new Set(queries.map((q) => q.label));\n\tfor (const item of raw) {\n\t\tif (!item || typeof item !== \"object\") continue;\n\t\tconst verdict = item as Record<string, unknown>;\n\t\tconst label = typeof verdict.label === \"string\" ? verdict.label.trim().toLowerCase() : \"\";\n\t\t// A label the batch did not ask about is a hallucinated row; dropping it is\n\t\t// safer than letting it mark some other proposal covered.\n\t\tif (!label || !known.has(label)) continue;\n\n\t\tif (verdict.verdict === \"rule\") {\n\t\t\tconst at = typeof verdict.ruleIndex === \"number\" ? index.ruleLines[verdict.ruleIndex] : undefined;\n\t\t\t// An out-of-range index means the model decided \"covered\" but cannot show\n\t\t\t// which line. Treat that as `new`: the reader cannot act on an unnamed rule.\n\t\t\tif (at) out.set(label, { rule: at });\n\t\t\tcontinue;\n\t\t}\n\t\tif (verdict.verdict === \"skill\") {\n\t\t\tconst at = typeof verdict.skillIndex === \"number\" ? index.skills[verdict.skillIndex] : undefined;\n\t\t\tif (at) out.set(label, { skill: at.name });\n\t\t\tcontinue;\n\t\t}\n\t\tout.set(label, {});\n\t}\n\treturn out;\n}\n\nexport interface CoverageDeps {\n\tmodel: Model<any>;\n\tapiKey?: string;\n\theaders?: Record<string, string>;\n}\n\nexport function createLlmCoverageJudge(deps: CoverageDeps): CoverageJudge {\n\treturn async (queries, index, signal) => {\n\t\t// Nothing to match against means nothing can be covered, and the call would\n\t\t// be pure cost.\n\t\tif (queries.length === 0 || (index.ruleLines.length === 0 && index.skills.length === 0)) {\n\t\t\treturn new Map();\n\t\t}\n\n\t\tconst response = await completeSimple(\n\t\t\tdeps.model,\n\t\t\t{\n\t\t\t\tsystemPrompt: COVERAGE_SYSTEM_PROMPT,\n\t\t\t\tmessages: [\n\t\t\t\t\t{ role: \"user\", content: [{ type: \"text\", text: buildPrompt(queries, index) }], timestamp: Date.now() },\n\t\t\t\t],\n\t\t\t},\n\t\t\t{ maxTokens: MAX_RESPONSE_TOKENS, signal, apiKey: deps.apiKey, headers: deps.headers },\n\t\t);\n\n\t\tif (response.stopReason === \"error\") {\n\t\t\tthrow new Error(response.errorMessage || \"coverage call failed\");\n\t\t}\n\n\t\tconst text = response.content\n\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t.map((c) => c.text)\n\t\t\t.join(\"\\n\");\n\t\treturn parseVerdicts(text, queries, index);\n\t};\n}\n\n/** Everything is new. Used when no model is available, so the run still produces a digest. */\nexport const noCoverageJudge: CoverageJudge = async () => new Map();\n"]}
|
|
@@ -8,9 +8,21 @@
|
|
|
8
8
|
* because "said in 5 of your last 12 sessions" is a decision the reader can
|
|
9
9
|
* make in one keystroke, where "extracted from your session" is not.
|
|
10
10
|
*/
|
|
11
|
+
import type { AuditReport } from "./audit.js";
|
|
11
12
|
import type { LearnDigest } from "./extract.js";
|
|
12
13
|
/** True when there is nothing worth asking the model to look at. */
|
|
13
14
|
export declare function isEmptyDigest(digest: LearnDigest): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Render the audit findings as a message the model can act on.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately framed as questions rather than verdicts. The checker is
|
|
19
|
+
* deterministic and therefore confident, but "this path does not resolve" is
|
|
20
|
+
* not the same claim as "this line is wrong" — a context file may name a
|
|
21
|
+
* location the tool reads at runtime, or one that belongs to another checkout.
|
|
22
|
+
* Roughly a third of findings on a real file are of that kind, so the message
|
|
23
|
+
* that carries them has to ask for verification, not authorise a sweep.
|
|
24
|
+
*/
|
|
25
|
+
export declare function renderAuditReport(report: AuditReport): string;
|
|
14
26
|
export declare function renderLearnDigest(digest: LearnDigest, options: {
|
|
15
27
|
userScopePath: string;
|
|
16
28
|
mode?: "incremental" | "all";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"digest.d.ts","sourceRoot":"","sources":["../../../src/core/learn/digest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAehD,oEAAoE;AACpE,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAE1D;AAED,wBAAgB,iBAAiB,CAChC,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE;IAAE,aAAa,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,aAAa,GAAG,KAAK,CAAA;CAAE,GAC9D,MAAM,CAsKR","sourcesContent":["/**\n * Renders the extractor's output into the message `/learn` injects.\n *\n * The digest is evidence plus instructions, and the split matters: the numbers\n * come from {@link extractLearnDigest} and are not negotiable, while everything\n * the model does with them — phrasing, routing, deciding a pattern is not worth\n * a rule — is judgement it has to exercise. Counts are printed on every item\n * because \"said in 5 of your last 12 sessions\" is a decision the reader can\n * make in one keystroke, where \"extracted from your session\" is not.\n */\n\nimport type { LearnDigest } from \"./extract.js\";\nimport { LEARN_DIGEST_MARKER } from \"./extract.js\";\n\nfunction shortDate(iso: string | undefined): string {\n\tif (!iso) return \"unknown\";\n\tconst date = new Date(iso);\n\treturn Number.isNaN(date.getTime()) ? \"unknown\" : date.toISOString().slice(0, 10);\n}\n\nfunction evidence(count: number, sessions: number, lastSeen: string): string {\n\tconst times = count === 1 ? \"once\" : `${count}x`;\n\tconst where = sessions === 1 ? \"1 session\" : `${sessions} sessions`;\n\treturn `${times} across ${where}, last ${shortDate(lastSeen)}`;\n}\n\n/** True when there is nothing worth asking the model to look at. */\nexport function isEmptyDigest(digest: LearnDigest): boolean {\n\treturn digest.directives.length === 0 && digest.fixes.length === 0 && digest.workflows.length === 0;\n}\n\nexport function renderLearnDigest(\n\tdigest: LearnDigest,\n\toptions: { userScopePath: string; mode?: \"incremental\" | \"all\" },\n): string {\n\tconst lines: string[] = [];\n\n\tlines.push(\n\t\t`${LEARN_DIGEST_MARKER} Mined ${digest.scannedSessions} session(s) in this directory` +\n\t\t\t(digest.skippedSessions > 0 ? ` (${digest.skippedSessions} skipped: out of window or unreadable)` : \"\") +\n\t\t\t(digest.oldestSession ? `, ${shortDate(digest.oldestSession)} to ${shortDate(digest.newestSession)}` : \"\") +\n\t\t\t(digest.suppressed > 0 ? `. ${digest.suppressed} item(s) held back — already shown and unchanged since` : \"\") +\n\t\t\t\".\",\n\t);\n\t// Naming the mode keeps two very different empty results from reading alike:\n\t// \"nothing new since last time\" and \"nothing here at all\" are not the same\n\t// answer, and the reader cannot tell them apart from the counts.\n\tif (options.mode === \"all\") {\n\t\tlines.push(\"Mode: all — suppression is off, so items you have already seen and decided on are included.\");\n\t}\n\t// The model reads every transcript in full, which costs real tokens. Saying\n\t// what was re-read versus reused keeps that price visible rather than hidden.\n\tlines.push(\n\t\t`Read by the model this run: ${digest.mining.mined}; reused from cache: ${digest.mining.cached}` +\n\t\t\t(digest.mining.failed > 0\n\t\t\t\t? `; failed: ${digest.mining.failed} (their signals are missing from the counts below)`\n\t\t\t\t: \"\") +\n\t\t\t\".\",\n\t);\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"The counts below are computed from session transcripts on disk, not from this conversation. \" +\n\t\t\t\"Treat them as evidence, not conclusions — your job is to decide what deserves to be written down, \" +\n\t\t\t\"phrase it, and put it in the right place.\",\n\t);\n\tlines.push(\"\");\n\n\t// ── Directives ───────────────────────────────────────────────────────────\n\tif (digest.directives.length > 0) {\n\t\tlines.push(\"## Directives you have repeated\");\n\t\tlines.push(\"\");\n\t\tfor (const cluster of digest.directives) {\n\t\t\tlines.push(`- **${cluster.status}** — \"${cluster.text.replace(/\\s+/g, \" \").trim()}\"`);\n\t\t\tlines.push(` - ${evidence(cluster.count, cluster.sessions, cluster.lastSeen)}`);\n\t\t\t// Occurrences were grouped by meaning, not by wording, so the quote above\n\t\t\t// is one phrasing of several. Naming the shared point keeps a count of 5\n\t\t\t// from looking like five copies of one sentence.\n\t\t\tlines.push(` - grouped as: ${cluster.label}`);\n\t\t\tif (cluster.rationale) {\n\t\t\t\tlines.push(` - why it may be durable: ${cluster.rationale}`);\n\t\t\t}\n\t\t\tif (cluster.existingRule) {\n\t\t\t\tlines.push(` - already covered by: \"${cluster.existingRule.slice(0, 160)}\"`);\n\t\t\t}\n\t\t\tif (cluster.existingSkill) {\n\t\t\t\tlines.push(` - already covered by the \\`${cluster.existingSkill}\\` skill`);\n\t\t\t}\n\t\t\tif (cluster.previouslyDeclined) {\n\t\t\t\tlines.push(\" - proposed before and not written down — you have already passed on this once\");\n\t\t\t}\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Fixes ────────────────────────────────────────────────────────────────\n\tif (digest.fixes.length > 0) {\n\t\tlines.push(\"## Failures you resolved\");\n\t\tlines.push(\"\");\n\t\tlines.push(\n\t\t\t\"Each is a command that failed and later succeeded, where something done in between was the fix. \" +\n\t\t\t\t\"Recurring ones are worth writing down; a one-off is not.\",\n\t\t);\n\t\tlines.push(\"\");\n\t\tfor (const fix of digest.fixes) {\n\t\t\tlines.push(`- \\`${fix.command}\\` — ${evidence(fix.count, fix.sessions, fix.lastSeen)}`);\n\t\t\tlines.push(` - grouped as: ${fix.label}`);\n\t\t\t// The excerpt comes from the model now, which may not have quoted one.\n\t\t\tif (fix.errorExcerpt) {\n\t\t\t\tlines.push(` - error: ${fix.errorExcerpt}`);\n\t\t\t}\n\t\t\tif (fix.interveningCommands.length > 0) {\n\t\t\t\tlines.push(` - commands in between: ${fix.interveningCommands.map((c) => `\\`${c}\\``).join(\", \")}`);\n\t\t\t}\n\t\t\tif (fix.editedFiles.length > 0) {\n\t\t\t\tlines.push(` - files edited: ${fix.editedFiles.join(\", \")}`);\n\t\t\t}\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Workflows ────────────────────────────────────────────────────────────\n\tif (digest.workflows.length > 0) {\n\t\tlines.push(\"## Repeated tool sequences\");\n\t\tlines.push(\"\");\n\t\tfor (const workflow of digest.workflows) {\n\t\t\t// A workflow the model named but did not enumerate still has a label\n\t\t\t// worth showing; rendering an empty backtick pair instead would not.\n\t\t\tconst steps = workflow.steps.length > 0 ? `\\`${workflow.steps.join(\" → \")}\\`` : workflow.label;\n\t\t\tlines.push(`- ${steps} — ${evidence(workflow.count, workflow.sessions, workflow.lastSeen)}`);\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Instructions ─────────────────────────────────────────────────────────\n\tlines.push(\"## What to do\");\n\tlines.push(\"\");\n\tlines.push(\"Work through the items above and propose concrete edits. For each one, decide:\");\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"1. **Is it durable?** A rule that will still be true next month belongs somewhere. A one-off preference \" +\n\t\t\t\"about the task you happened to be doing does not. When in doubt, drop it — a wrong rule costs more than \" +\n\t\t\t\"a missing one, because it is paid on every request forever.\",\n\t);\n\tlines.push(\n\t\t\"2. **Rule or skill?** This is the most important call. A context file is loaded on **every** turn; a skill \" +\n\t\t\t\"is loaded **on demand**. So: short, always-true, unconditional → a one-line rule. Long, procedural, \" +\n\t\t\t'or conditional (a sequence of steps, a runbook, anything starting \"when X, do Y\") → a skill, not a rule. ' +\n\t\t\t\"Repeated tool sequences are almost always skills.\",\n\t);\n\tlines.push(\n\t\t`3. **Which scope?** Project-specific (this repo's tests, build, architecture, conventions) → the repo ` +\n\t\t\t`\\`AGENTS.md\\`. Personal habits that travel with you across every repo (style preferences, how you like ` +\n\t\t\t`commits written) → \\`${options.userScopePath}\\`. If it names this repo's files or commands, it is not a ` +\n\t\t\t`user-scope rule.`,\n\t);\n\tlines.push(\n\t\t\"4. **Restated items are rewrites, not additions.** An item marked `restated` is already covered by a rule \" +\n\t\t\t\"that is not working — too vague, buried, or contradicted elsewhere. Rewrite the existing line or delete \" +\n\t\t\t\"it in favour of a sharper one. Do not add a second rule saying the same thing.\",\n\t);\n\tlines.push(\n\t\t\"5. **`has-skill` items are a triggering problem, not a missing rule.** A skill already covers it and you \" +\n\t\t\t\"asked by hand anyway, which usually means the skill's `description` frontmatter does not describe the \" +\n\t\t\t\"situation you were in. Sharpen that description so it matches, rather than adding a rule that duplicates \" +\n\t\t\t\"what the skill already does.\",\n\t);\n\tlines.push(\"\");\n\tlines.push(\"Then, while you have the file open, audit it:\");\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"- **Delete rules that no longer match the code.** Check a sample against the repo before trusting them.\",\n\t);\n\tlines.push(\"- **Delete rules that restate default behaviour.** Guidance the agent already follows is pure cost.\");\n\tlines.push(\n\t\t\"- **Collapse duplicates**, including any rule stated at both repo and user scope — that one is paid twice.\",\n\t);\n\tlines.push(\n\t\t\"- **One line per rule.** No rationale, no examples, no preamble, unless the example *is* the rule. Prose is \" +\n\t\t\t\"the single biggest source of context-file bloat.\",\n\t);\n\tlines.push(\"\");\n\n\tif (digest.agentsFilePath) {\n\t\tlines.push(\n\t\t\t`The repo context file is \\`${digest.agentsFilePath}\\`` +\n\t\t\t\t(digest.agentsFileTokens ? ` (~${digest.agentsFileTokens} tokens, re-sent every request)` : \"\") +\n\t\t\t\t\". Report the token delta of your proposed changes before applying them; a net reduction is a good outcome.\",\n\t\t);\n\t} else {\n\t\tlines.push(\n\t\t\t\"No repo context file exists yet. Create one only if at least one durable project rule survives step 1.\",\n\t\t);\n\t}\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"Show what you propose, then apply it with edits — do not ask a separate approval question first, the edit \" +\n\t\t\t\"prompt is the approval. If nothing here is worth writing down, say so plainly and change nothing.\",\n\t);\n\n\treturn lines.join(\"\\n\");\n}\n"]}
|
|
1
|
+
{"version":3,"file":"digest.d.ts","sourceRoot":"","sources":["../../../src/core/learn/digest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AA4BhD,oEAAoE;AACpE,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAE1D;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CA8C7D;AAED,wBAAgB,iBAAiB,CAChC,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE;IAAE,aAAa,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,aAAa,GAAG,KAAK,CAAA;CAAE,GAC9D,MAAM,CAmMR","sourcesContent":["/**\n * Renders the extractor's output into the message `/learn` injects.\n *\n * The digest is evidence plus instructions, and the split matters: the numbers\n * come from {@link extractLearnDigest} and are not negotiable, while everything\n * the model does with them — phrasing, routing, deciding a pattern is not worth\n * a rule — is judgement it has to exercise. Counts are printed on every item\n * because \"said in 5 of your last 12 sessions\" is a decision the reader can\n * make in one keystroke, where \"extracted from your session\" is not.\n */\n\nimport type { AuditReport } from \"./audit.js\";\nimport { staleTokens } from \"./audit.js\";\nimport type { LearnDigest } from \"./extract.js\";\nimport { LEARN_DIGEST_MARKER } from \"./extract.js\";\n\n/**\n * Quote lengths. A directive is a sentence; a request is a whole task message,\n * and can be a slash-command body running to thousands of characters.\n */\nconst DIRECTIVE_QUOTE_CHARS = 400;\nconst REQUEST_QUOTE_CHARS = 200;\n\n/** One line, bounded — a quote has to survive being rendered inside a list item. */\nfunction quote(text: string, limit: number): string {\n\tconst flat = text.replace(/\\s+/g, \" \").trim();\n\treturn flat.length > limit ? `${flat.slice(0, limit)}…` : flat;\n}\n\nfunction shortDate(iso: string | undefined): string {\n\tif (!iso) return \"unknown\";\n\tconst date = new Date(iso);\n\treturn Number.isNaN(date.getTime()) ? \"unknown\" : date.toISOString().slice(0, 10);\n}\n\nfunction evidence(count: number, sessions: number, lastSeen: string): string {\n\tconst times = count === 1 ? \"once\" : `${count}x`;\n\tconst where = sessions === 1 ? \"1 session\" : `${sessions} sessions`;\n\treturn `${times} across ${where}, last ${shortDate(lastSeen)}`;\n}\n\n/** True when there is nothing worth asking the model to look at. */\nexport function isEmptyDigest(digest: LearnDigest): boolean {\n\treturn digest.directives.length === 0 && digest.fixes.length === 0 && digest.requests.length === 0;\n}\n\n/**\n * Render the audit findings as a message the model can act on.\n *\n * Deliberately framed as questions rather than verdicts. The checker is\n * deterministic and therefore confident, but \"this path does not resolve\" is\n * not the same claim as \"this line is wrong\" — a context file may name a\n * location the tool reads at runtime, or one that belongs to another checkout.\n * Roughly a third of findings on a real file are of that kind, so the message\n * that carries them has to ask for verification, not authorise a sweep.\n */\nexport function renderAuditReport(report: AuditReport): string {\n\tconst lines: string[] = [];\n\tconst cost = staleTokens(report);\n\n\tlines.push(\n\t\t`${LEARN_DIGEST_MARKER} Audited ${report.files.length} context file(s) — ${report.checked} referent(s) checked ` +\n\t\t\t`against the filesystem, ${report.stale.length} did not resolve (~${cost} tokens of always-loaded context).`,\n\t);\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"This check is deterministic: it resolved every backticked path and `run` script named by the context files \" +\n\t\t\t\"below against this working tree and every package root in it. It costs no model calls and knows nothing \" +\n\t\t\t\"about intent.\",\n\t);\n\tlines.push(\"\");\n\n\tfor (const item of report.stale) {\n\t\tlines.push(`- \\`${item.referent}\\` — ${item.file}:${item.line}, ~${item.tokens} tokens`);\n\t\tlines.push(` - line: ${item.lineText.slice(0, 200)}`);\n\t}\n\tlines.push(\"\");\n\n\tlines.push(\"## What to do\");\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"Each entry is a candidate, not a verdict. For each one, check the repo before touching the line — \" +\n\t\t\t\"`git log` for a file that moved or was deleted is usually enough to tell which case you are in:\",\n\t);\n\tlines.push(\"\");\n\tlines.push(\"1. **The referent moved.** Fix the path in place. Do not delete the rule; it is still true.\");\n\tlines.push(\n\t\t\"2. **The referent is gone and the rule went with it.** Delete the line, and the surrounding section if \" +\n\t\t\t\"nothing in it survives. This is the case worth the most — it is always-loaded context describing \" +\n\t\t\t\"something that cannot happen.\",\n\t);\n\tlines.push(\n\t\t\"3. **The path is a runtime or optional location** the tool reads if it happens to exist, or a file in \" +\n\t\t\t\"another checkout. Nothing is wrong; leave it alone and say so.\",\n\t);\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"Report the token delta of what you remove. Do not add anything — this pass is subtractive, and it is the \" +\n\t\t\t\"only one that moves the per-request cost down.\",\n\t);\n\n\treturn lines.join(\"\\n\");\n}\n\nexport function renderLearnDigest(\n\tdigest: LearnDigest,\n\toptions: { userScopePath: string; mode?: \"incremental\" | \"all\" },\n): string {\n\tconst lines: string[] = [];\n\n\tlines.push(\n\t\t`${LEARN_DIGEST_MARKER} Mined ${digest.scannedSessions} session(s) in this directory` +\n\t\t\t(digest.skippedSessions > 0 ? ` (${digest.skippedSessions} skipped: out of window or unreadable)` : \"\") +\n\t\t\t(digest.oldestSession ? `, ${shortDate(digest.oldestSession)} to ${shortDate(digest.newestSession)}` : \"\") +\n\t\t\t(digest.suppressed > 0 ? `. ${digest.suppressed} item(s) held back — already shown and unchanged since` : \"\") +\n\t\t\t// A cut item cleared every bar and lost on rank. Saying so is the\n\t\t\t// difference between \"this is everything\" and \"this is the top of a list\".\n\t\t\t(digest.cut > 0 ? `. ${digest.cut} more cleared the bar but were cut to fit the per-run cap` : \"\") +\n\t\t\t\".\",\n\t);\n\tlines.push(\n\t\t`Read ${digest.funnel.candidates} occurrence(s), which named ${digest.funnel.points} distinct point(s); ` +\n\t\t\t`${digest.funnel.belowThreshold} did not recur in enough separate sessions to be proposed.`,\n\t);\n\t// Naming the mode keeps two very different empty results from reading alike:\n\t// \"nothing new since last time\" and \"nothing here at all\" are not the same\n\t// answer, and the reader cannot tell them apart from the counts.\n\tif (options.mode === \"all\") {\n\t\tlines.push(\"Mode: all — suppression is off, so items you have already seen and decided on are included.\");\n\t}\n\t// The model reads every transcript in full, which costs real tokens. Saying\n\t// what was re-read versus reused keeps that price visible rather than hidden.\n\tlines.push(\n\t\t`Read by the model this run: ${digest.mining.mined}; reused from cache: ${digest.mining.cached}` +\n\t\t\t(digest.mining.failed > 0\n\t\t\t\t? `; failed: ${digest.mining.failed} (their signals are missing from the counts below)`\n\t\t\t\t: \"\") +\n\t\t\t\".\",\n\t);\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"The counts below are computed from session transcripts on disk, not from this conversation. \" +\n\t\t\t\"Treat them as evidence, not conclusions — your job is to decide what deserves to be written down, \" +\n\t\t\t\"phrase it, and put it in the right place.\",\n\t);\n\tlines.push(\"\");\n\n\t// ── Directives ───────────────────────────────────────────────────────────\n\tif (digest.directives.length > 0) {\n\t\tlines.push(\"## Directives you have repeated\");\n\t\tlines.push(\"\");\n\t\tfor (const cluster of digest.directives) {\n\t\t\tlines.push(`- **${cluster.status}** — \"${quote(cluster.text, DIRECTIVE_QUOTE_CHARS)}\"`);\n\t\t\tlines.push(` - ${evidence(cluster.count, cluster.sessions, cluster.lastSeen)}`);\n\t\t\t// Occurrences were grouped by meaning, not by wording, so the quote above\n\t\t\t// is one phrasing of several. Naming the shared point keeps a count of 5\n\t\t\t// from looking like five copies of one sentence.\n\t\t\tlines.push(` - grouped as: ${cluster.label}`);\n\t\t\tif (cluster.rationale) {\n\t\t\t\tlines.push(` - why it may be durable: ${cluster.rationale}`);\n\t\t\t}\n\t\t\tif (cluster.existingRule) {\n\t\t\t\tlines.push(` - already covered by: \"${cluster.existingRule.slice(0, 160)}\"`);\n\t\t\t}\n\t\t\tif (cluster.existingSkill) {\n\t\t\t\tlines.push(` - already covered by the \\`${cluster.existingSkill}\\` skill`);\n\t\t\t}\n\t\t\tif (cluster.previouslyDeclined) {\n\t\t\t\tlines.push(\" - proposed before and not written down — you have already passed on this once\");\n\t\t\t}\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Fixes ────────────────────────────────────────────────────────────────\n\tif (digest.fixes.length > 0) {\n\t\tlines.push(\"## Failures you resolved\");\n\t\tlines.push(\"\");\n\t\tlines.push(\n\t\t\t\"Each is a command that failed and later succeeded, where something done in between was the fix. \" +\n\t\t\t\t\"Recurring ones are worth writing down; a one-off is not.\",\n\t\t);\n\t\tlines.push(\"\");\n\t\tfor (const fix of digest.fixes) {\n\t\t\tlines.push(`- \\`${fix.command}\\` — ${evidence(fix.count, fix.sessions, fix.lastSeen)}`);\n\t\t\tlines.push(` - grouped as: ${fix.label}`);\n\t\t\t// The excerpt comes from the model now, which may not have quoted one.\n\t\t\tif (fix.errorExcerpt) {\n\t\t\t\tlines.push(` - error: ${fix.errorExcerpt}`);\n\t\t\t}\n\t\t\tif (fix.interveningCommands.length > 0) {\n\t\t\t\tlines.push(` - commands in between: ${fix.interveningCommands.map((c) => `\\`${c}\\``).join(\", \")}`);\n\t\t\t}\n\t\t\tif (fix.editedFiles.length > 0) {\n\t\t\t\tlines.push(` - files edited: ${fix.editedFiles.join(\", \")}`);\n\t\t\t}\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Requests ────────────────────────────────────────────────────────────\n\tif (digest.requests.length > 0) {\n\t\tlines.push(\"## Work you keep asking for by name\");\n\t\tlines.push(\"\");\n\t\tfor (const request of digest.requests) {\n\t\t\t// Flattened and capped, unlike a directive quote. A request *is* a whole\n\t\t\t// task message — a slash-command body runs to thousands of characters — so\n\t\t\t// eight of them rendered raw would swamp the digest and a multi-line one\n\t\t\t// would break the list it sits in.\n\t\t\tlines.push(`- **${request.label}** — \"${quote(request.text, REQUEST_QUOTE_CHARS)}\"`);\n\t\t\tlines.push(` - ${evidence(request.count, request.sessions, request.lastSeen)}`);\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Instructions ─────────────────────────────────────────────────────────\n\tlines.push(\"## What to do\");\n\tlines.push(\"\");\n\tlines.push(\"Work through the items above and propose concrete edits. For each one, decide:\");\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"1. **Is it durable?** A rule that will still be true next month belongs somewhere. A one-off preference \" +\n\t\t\t\"about the task you happened to be doing does not. When in doubt, drop it — a wrong rule costs more than \" +\n\t\t\t\"a missing one, because it is paid on every request forever.\",\n\t);\n\tlines.push(\n\t\t\"2. **Rule, skill, or slash command?** This is the most important call, and it is a cost question. A \" +\n\t\t\t\"context file is loaded on **every** turn; a skill's description is always loaded but its body only on \" +\n\t\t\t\"demand; a slash command costs nothing until it is invoked.\",\n\t);\n\tlines.push(\n\t\t\" - **Rule** — short, always true, unconditional. One line in a context file. Highest bar, because it is \" +\n\t\t\t\"paid on every request forever whether or not it is relevant.\",\n\t);\n\tlines.push(\n\t\t' - **Skill** — long, procedural, or conditional; anything shaped \"when X, do Y\"; a runbook or a sequence ' +\n\t\t\t\"of steps. Write `.agents/skills/<name>/SKILL.md`, and spend the effort on the `description` \" +\n\t\t\t\"frontmatter: it is the only part always in context, and it decides whether the skill ever fires.\",\n\t);\n\tlines.push(\n\t\t\" - **Slash command** — a *job you keep asking for*, not a rule about how work is done. The items under \" +\n\t\t\t'\"Work you keep asking for by name\" are these. Write `.agents/commands/<name>.md`, with `$1`/`$ARGUMENTS` ' +\n\t\t\t\"where the request varies. The cheapest artifact there is: nothing is loaded until you type it.\",\n\t);\n\tlines.push(\n\t\t`3. **Which scope?** Project-specific (this repo's tests, build, architecture, conventions) → the repo ` +\n\t\t\t`\\`AGENTS.md\\`. Personal habits that travel with you across every repo (style preferences, how you like ` +\n\t\t\t`commits written) → \\`${options.userScopePath}\\`. If it names this repo's files or commands, it is not a ` +\n\t\t\t`user-scope rule.`,\n\t);\n\tlines.push(\n\t\t\"4. **Restated items are rewrites, not additions.** An item marked `restated` is already covered by a rule \" +\n\t\t\t\"that is not working — too vague, buried, or contradicted elsewhere. Rewrite the existing line or delete \" +\n\t\t\t\"it in favour of a sharper one. Do not add a second rule saying the same thing.\",\n\t);\n\tlines.push(\n\t\t\"5. **`has-skill` items are a triggering problem, not a missing rule.** A skill already covers it and you \" +\n\t\t\t\"asked by hand anyway, which usually means the skill's `description` frontmatter does not describe the \" +\n\t\t\t\"situation you were in. Sharpen that description so it matches, rather than adding a rule that duplicates \" +\n\t\t\t\"what the skill already does.\",\n\t);\n\tlines.push(\n\t\t\"6. **Write local files, not a plugin.** Skills and commands proposed from this evidence are local habits: \" +\n\t\t\t\"write them under `.agents/`. `ProposePlugin` packages something already proven useful into a portable, \" +\n\t\t\t\"publishable artifact — a later step for a skill that has earned it, not the way to create one. Never \" +\n\t\t\t\"propose a hook or an MCP server from this evidence: it records what was said and what failed, which is \" +\n\t\t\t\"far too weak a warrant for anything that executes.\",\n\t);\n\tlines.push(\"\");\n\tlines.push(\"Then, while you have the file open, audit it:\");\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"- **Delete rules that no longer match the code.** Check a sample against the repo before trusting them.\",\n\t);\n\tlines.push(\"- **Delete rules that restate default behaviour.** Guidance the agent already follows is pure cost.\");\n\tlines.push(\n\t\t\"- **Collapse duplicates**, including any rule stated at both repo and user scope — that one is paid twice.\",\n\t);\n\tlines.push(\n\t\t\"- **One line per rule.** No rationale, no examples, no preamble, unless the example *is* the rule. Prose is \" +\n\t\t\t\"the single biggest source of context-file bloat.\",\n\t);\n\tlines.push(\"\");\n\n\tif (digest.agentsFilePath) {\n\t\tlines.push(\n\t\t\t`The repo context file is \\`${digest.agentsFilePath}\\`` +\n\t\t\t\t(digest.agentsFileTokens ? ` (~${digest.agentsFileTokens} tokens, re-sent every request)` : \"\") +\n\t\t\t\t\". Report the token delta of your proposed changes before applying them; a net reduction is a good outcome.\",\n\t\t);\n\t} else {\n\t\tlines.push(\n\t\t\t\"No repo context file exists yet. Create one only if at least one durable project rule survives step 1.\",\n\t\t);\n\t}\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"Show what you propose, then apply it with edits — do not ask a separate approval question first, the edit \" +\n\t\t\t\"prompt is the approval. If nothing here is worth writing down, say so plainly and change nothing.\",\n\t);\n\n\treturn lines.join(\"\\n\");\n}\n"]}
|
|
@@ -8,7 +8,19 @@
|
|
|
8
8
|
* because "said in 5 of your last 12 sessions" is a decision the reader can
|
|
9
9
|
* make in one keystroke, where "extracted from your session" is not.
|
|
10
10
|
*/
|
|
11
|
+
import { staleTokens } from "./audit.js";
|
|
11
12
|
import { LEARN_DIGEST_MARKER } from "./extract.js";
|
|
13
|
+
/**
|
|
14
|
+
* Quote lengths. A directive is a sentence; a request is a whole task message,
|
|
15
|
+
* and can be a slash-command body running to thousands of characters.
|
|
16
|
+
*/
|
|
17
|
+
const DIRECTIVE_QUOTE_CHARS = 400;
|
|
18
|
+
const REQUEST_QUOTE_CHARS = 200;
|
|
19
|
+
/** One line, bounded — a quote has to survive being rendered inside a list item. */
|
|
20
|
+
function quote(text, limit) {
|
|
21
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
22
|
+
return flat.length > limit ? `${flat.slice(0, limit)}…` : flat;
|
|
23
|
+
}
|
|
12
24
|
function shortDate(iso) {
|
|
13
25
|
if (!iso)
|
|
14
26
|
return "unknown";
|
|
@@ -22,7 +34,48 @@ function evidence(count, sessions, lastSeen) {
|
|
|
22
34
|
}
|
|
23
35
|
/** True when there is nothing worth asking the model to look at. */
|
|
24
36
|
export function isEmptyDigest(digest) {
|
|
25
|
-
return digest.directives.length === 0 && digest.fixes.length === 0 && digest.
|
|
37
|
+
return digest.directives.length === 0 && digest.fixes.length === 0 && digest.requests.length === 0;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Render the audit findings as a message the model can act on.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately framed as questions rather than verdicts. The checker is
|
|
43
|
+
* deterministic and therefore confident, but "this path does not resolve" is
|
|
44
|
+
* not the same claim as "this line is wrong" — a context file may name a
|
|
45
|
+
* location the tool reads at runtime, or one that belongs to another checkout.
|
|
46
|
+
* Roughly a third of findings on a real file are of that kind, so the message
|
|
47
|
+
* that carries them has to ask for verification, not authorise a sweep.
|
|
48
|
+
*/
|
|
49
|
+
export function renderAuditReport(report) {
|
|
50
|
+
const lines = [];
|
|
51
|
+
const cost = staleTokens(report);
|
|
52
|
+
lines.push(`${LEARN_DIGEST_MARKER} Audited ${report.files.length} context file(s) — ${report.checked} referent(s) checked ` +
|
|
53
|
+
`against the filesystem, ${report.stale.length} did not resolve (~${cost} tokens of always-loaded context).`);
|
|
54
|
+
lines.push("");
|
|
55
|
+
lines.push("This check is deterministic: it resolved every backticked path and `run` script named by the context files " +
|
|
56
|
+
"below against this working tree and every package root in it. It costs no model calls and knows nothing " +
|
|
57
|
+
"about intent.");
|
|
58
|
+
lines.push("");
|
|
59
|
+
for (const item of report.stale) {
|
|
60
|
+
lines.push(`- \`${item.referent}\` — ${item.file}:${item.line}, ~${item.tokens} tokens`);
|
|
61
|
+
lines.push(` - line: ${item.lineText.slice(0, 200)}`);
|
|
62
|
+
}
|
|
63
|
+
lines.push("");
|
|
64
|
+
lines.push("## What to do");
|
|
65
|
+
lines.push("");
|
|
66
|
+
lines.push("Each entry is a candidate, not a verdict. For each one, check the repo before touching the line — " +
|
|
67
|
+
"`git log` for a file that moved or was deleted is usually enough to tell which case you are in:");
|
|
68
|
+
lines.push("");
|
|
69
|
+
lines.push("1. **The referent moved.** Fix the path in place. Do not delete the rule; it is still true.");
|
|
70
|
+
lines.push("2. **The referent is gone and the rule went with it.** Delete the line, and the surrounding section if " +
|
|
71
|
+
"nothing in it survives. This is the case worth the most — it is always-loaded context describing " +
|
|
72
|
+
"something that cannot happen.");
|
|
73
|
+
lines.push("3. **The path is a runtime or optional location** the tool reads if it happens to exist, or a file in " +
|
|
74
|
+
"another checkout. Nothing is wrong; leave it alone and say so.");
|
|
75
|
+
lines.push("");
|
|
76
|
+
lines.push("Report the token delta of what you remove. Do not add anything — this pass is subtractive, and it is the " +
|
|
77
|
+
"only one that moves the per-request cost down.");
|
|
78
|
+
return lines.join("\n");
|
|
26
79
|
}
|
|
27
80
|
export function renderLearnDigest(digest, options) {
|
|
28
81
|
const lines = [];
|
|
@@ -30,7 +83,12 @@ export function renderLearnDigest(digest, options) {
|
|
|
30
83
|
(digest.skippedSessions > 0 ? ` (${digest.skippedSessions} skipped: out of window or unreadable)` : "") +
|
|
31
84
|
(digest.oldestSession ? `, ${shortDate(digest.oldestSession)} to ${shortDate(digest.newestSession)}` : "") +
|
|
32
85
|
(digest.suppressed > 0 ? `. ${digest.suppressed} item(s) held back — already shown and unchanged since` : "") +
|
|
86
|
+
// A cut item cleared every bar and lost on rank. Saying so is the
|
|
87
|
+
// difference between "this is everything" and "this is the top of a list".
|
|
88
|
+
(digest.cut > 0 ? `. ${digest.cut} more cleared the bar but were cut to fit the per-run cap` : "") +
|
|
33
89
|
".");
|
|
90
|
+
lines.push(`Read ${digest.funnel.candidates} occurrence(s), which named ${digest.funnel.points} distinct point(s); ` +
|
|
91
|
+
`${digest.funnel.belowThreshold} did not recur in enough separate sessions to be proposed.`);
|
|
34
92
|
// Naming the mode keeps two very different empty results from reading alike:
|
|
35
93
|
// "nothing new since last time" and "nothing here at all" are not the same
|
|
36
94
|
// answer, and the reader cannot tell them apart from the counts.
|
|
@@ -54,7 +112,7 @@ export function renderLearnDigest(digest, options) {
|
|
|
54
112
|
lines.push("## Directives you have repeated");
|
|
55
113
|
lines.push("");
|
|
56
114
|
for (const cluster of digest.directives) {
|
|
57
|
-
lines.push(`- **${cluster.status}** — "${cluster.text
|
|
115
|
+
lines.push(`- **${cluster.status}** — "${quote(cluster.text, DIRECTIVE_QUOTE_CHARS)}"`);
|
|
58
116
|
lines.push(` - ${evidence(cluster.count, cluster.sessions, cluster.lastSeen)}`);
|
|
59
117
|
// Occurrences were grouped by meaning, not by wording, so the quote above
|
|
60
118
|
// is one phrasing of several. Naming the shared point keeps a count of 5
|
|
@@ -98,15 +156,17 @@ export function renderLearnDigest(digest, options) {
|
|
|
98
156
|
}
|
|
99
157
|
lines.push("");
|
|
100
158
|
}
|
|
101
|
-
// ──
|
|
102
|
-
if (digest.
|
|
103
|
-
lines.push("##
|
|
159
|
+
// ── Requests ────────────────────────────────────────────────────────────
|
|
160
|
+
if (digest.requests.length > 0) {
|
|
161
|
+
lines.push("## Work you keep asking for by name");
|
|
104
162
|
lines.push("");
|
|
105
|
-
for (const
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
163
|
+
for (const request of digest.requests) {
|
|
164
|
+
// Flattened and capped, unlike a directive quote. A request *is* a whole
|
|
165
|
+
// task message — a slash-command body runs to thousands of characters — so
|
|
166
|
+
// eight of them rendered raw would swamp the digest and a multi-line one
|
|
167
|
+
// would break the list it sits in.
|
|
168
|
+
lines.push(`- **${request.label}** — "${quote(request.text, REQUEST_QUOTE_CHARS)}"`);
|
|
169
|
+
lines.push(` - ${evidence(request.count, request.sessions, request.lastSeen)}`);
|
|
110
170
|
}
|
|
111
171
|
lines.push("");
|
|
112
172
|
}
|
|
@@ -118,10 +178,17 @@ export function renderLearnDigest(digest, options) {
|
|
|
118
178
|
lines.push("1. **Is it durable?** A rule that will still be true next month belongs somewhere. A one-off preference " +
|
|
119
179
|
"about the task you happened to be doing does not. When in doubt, drop it — a wrong rule costs more than " +
|
|
120
180
|
"a missing one, because it is paid on every request forever.");
|
|
121
|
-
lines.push("2. **Rule or
|
|
122
|
-
"is loaded
|
|
123
|
-
|
|
124
|
-
|
|
181
|
+
lines.push("2. **Rule, skill, or slash command?** This is the most important call, and it is a cost question. A " +
|
|
182
|
+
"context file is loaded on **every** turn; a skill's description is always loaded but its body only on " +
|
|
183
|
+
"demand; a slash command costs nothing until it is invoked.");
|
|
184
|
+
lines.push(" - **Rule** — short, always true, unconditional. One line in a context file. Highest bar, because it is " +
|
|
185
|
+
"paid on every request forever whether or not it is relevant.");
|
|
186
|
+
lines.push(' - **Skill** — long, procedural, or conditional; anything shaped "when X, do Y"; a runbook or a sequence ' +
|
|
187
|
+
"of steps. Write `.agents/skills/<name>/SKILL.md`, and spend the effort on the `description` " +
|
|
188
|
+
"frontmatter: it is the only part always in context, and it decides whether the skill ever fires.");
|
|
189
|
+
lines.push(" - **Slash command** — a *job you keep asking for*, not a rule about how work is done. The items under " +
|
|
190
|
+
'"Work you keep asking for by name" are these. Write `.agents/commands/<name>.md`, with `$1`/`$ARGUMENTS` ' +
|
|
191
|
+
"where the request varies. The cheapest artifact there is: nothing is loaded until you type it.");
|
|
125
192
|
lines.push(`3. **Which scope?** Project-specific (this repo's tests, build, architecture, conventions) → the repo ` +
|
|
126
193
|
`\`AGENTS.md\`. Personal habits that travel with you across every repo (style preferences, how you like ` +
|
|
127
194
|
`commits written) → \`${options.userScopePath}\`. If it names this repo's files or commands, it is not a ` +
|
|
@@ -133,6 +200,11 @@ export function renderLearnDigest(digest, options) {
|
|
|
133
200
|
"asked by hand anyway, which usually means the skill's `description` frontmatter does not describe the " +
|
|
134
201
|
"situation you were in. Sharpen that description so it matches, rather than adding a rule that duplicates " +
|
|
135
202
|
"what the skill already does.");
|
|
203
|
+
lines.push("6. **Write local files, not a plugin.** Skills and commands proposed from this evidence are local habits: " +
|
|
204
|
+
"write them under `.agents/`. `ProposePlugin` packages something already proven useful into a portable, " +
|
|
205
|
+
"publishable artifact — a later step for a skill that has earned it, not the way to create one. Never " +
|
|
206
|
+
"propose a hook or an MCP server from this evidence: it records what was said and what failed, which is " +
|
|
207
|
+
"far too weak a warrant for anything that executes.");
|
|
136
208
|
lines.push("");
|
|
137
209
|
lines.push("Then, while you have the file open, audit it:");
|
|
138
210
|
lines.push("");
|