@kolisachint/hoocode-agent 0.5.13 → 0.5.14
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 +71 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +16 -0
- package/dist/config.js.map +1 -1
- package/dist/core/context-files.d.ts +16 -3
- package/dist/core/context-files.d.ts.map +1 -1
- package/dist/core/context-files.js +77 -6
- package/dist/core/context-files.js.map +1 -1
- package/dist/core/learn/digest.d.ts +17 -0
- package/dist/core/learn/digest.d.ts.map +1 -0
- package/dist/core/learn/digest.js +131 -0
- package/dist/core/learn/digest.js.map +1 -0
- package/dist/core/learn/extract.d.ts +178 -0
- package/dist/core/learn/extract.d.ts.map +1 -0
- package/dist/core/learn/extract.js +705 -0
- package/dist/core/learn/extract.js.map +1 -0
- package/dist/core/learn/normalize.d.ts +65 -0
- package/dist/core/learn/normalize.d.ts.map +1 -0
- package/dist/core/learn/normalize.js +245 -0
- package/dist/core/learn/normalize.js.map +1 -0
- package/dist/core/learn/state.d.ts +115 -0
- package/dist/core/learn/state.d.ts.map +1 -0
- package/dist/core/learn/state.js +151 -0
- package/dist/core/learn/state.js.map +1 -0
- package/dist/core/settings-defaults.d.ts +5 -0
- package/dist/core/settings-defaults.d.ts.map +1 -1
- package/dist/core/settings-defaults.js +5 -0
- package/dist/core/settings-defaults.js.map +1 -1
- package/dist/core/settings-manager.d.ts +14 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +17 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/settings-types.d.ts +5 -0
- package/dist/core/settings-types.d.ts.map +1 -1
- package/dist/core/settings-types.js.map +1 -1
- package/dist/extensions/core/hoo-core.d.ts.map +1 -1
- package/dist/extensions/core/hoo-core.js +2 -0
- package/dist/extensions/core/hoo-core.js.map +1 -1
- package/dist/extensions/core/learn.d.ts +22 -0
- package/dist/extensions/core/learn.d.ts.map +1 -0
- package/dist/extensions/core/learn.js +167 -0
- package/dist/extensions/core/learn.js.map +1 -0
- package/dist/modes/interactive/resource-display.d.ts.map +1 -1
- package/dist/modes/interactive/resource-display.js +6 -1
- package/dist/modes/interactive/resource-display.js.map +1 -1
- package/docs/settings.md +31 -0
- package/docs/usage.md +64 -2
- 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,705 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session mining for `/learn`.
|
|
3
|
+
*
|
|
4
|
+
* Reads session `.jsonl` files straight off disk rather than the live context.
|
|
5
|
+
* That is the whole point: the on-disk transcript is complete even when the
|
|
6
|
+
* in-context one has been compacted away, and it spans every past session
|
|
7
|
+
* instead of only this one. Cross-session repetition is the signal that decides
|
|
8
|
+
* whether something is a durable rule or a one-off, and it is the one thing a
|
|
9
|
+
* prompt reading its own context cannot see.
|
|
10
|
+
*
|
|
11
|
+
* The split of labour is deliberate. This module is entirely deterministic: it
|
|
12
|
+
* parses, filters, normalizes, counts and ranks. Judgement — is this a rule, how
|
|
13
|
+
* should it be phrased, which scope owns it — belongs to the model reading the
|
|
14
|
+
* digest, which is why the output carries evidence (counts, sessions, dates)
|
|
15
|
+
* rather than conclusions.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
|
+
import { getUserAgentsDir } from "../../config.js";
|
|
20
|
+
import { getDefaultSessionDir } from "../session-manager.js";
|
|
21
|
+
import { loadSkills } from "../skills.js";
|
|
22
|
+
import { commandHead, contentWords, extractErrorRegion, isBenignFailure, isRuleShapedDirective, isUninformativeFailure, normalizeCommand, normalizeDirective, normalizeErrorSignature, wordOverlap, } from "./normalize.js";
|
|
23
|
+
import { judge } from "./state.js";
|
|
24
|
+
/**
|
|
25
|
+
* Prefix on the message `/learn` injects. The digest is persisted like any user
|
|
26
|
+
* turn, so without this marker the next `/learn` would mine its own output and
|
|
27
|
+
* every proposal would compound its own count.
|
|
28
|
+
*/
|
|
29
|
+
export const LEARN_DIGEST_MARKER = "[learn-digest]";
|
|
30
|
+
/** Sessions considered, newest first. */
|
|
31
|
+
const DEFAULT_MAX_SESSIONS = 20;
|
|
32
|
+
/** Sessions older than this are ignored — a pattern that stopped is not a rule. */
|
|
33
|
+
const DEFAULT_MAX_AGE_DAYS = 30;
|
|
34
|
+
/** Entries parsed per session file, as a guard against pathological transcripts. */
|
|
35
|
+
const MAX_ENTRIES_PER_SESSION = 8000;
|
|
36
|
+
/** Tool calls per session fed to the workflow detector. */
|
|
37
|
+
const MAX_TOOL_CALLS_PER_SESSION = 400;
|
|
38
|
+
/** How far forward the fix extractor looks for the same command succeeding. */
|
|
39
|
+
const FIX_LOOKAHEAD = 40;
|
|
40
|
+
/** Word overlap against an existing rule above which a directive counts as covered. */
|
|
41
|
+
const COVERED_OVERLAP = 0.6;
|
|
42
|
+
/**
|
|
43
|
+
* The same bar for skills, set higher on purpose.
|
|
44
|
+
*
|
|
45
|
+
* A rule is one line, so overlap against it is a sharp signal. A skill is a name
|
|
46
|
+
* plus a description written to attract matches, which is a far larger haystack
|
|
47
|
+
* — a short directive's words turn up in it by chance much more readily. The
|
|
48
|
+
* higher bar and the truncation below keep "you already have a skill for this"
|
|
49
|
+
* from being said on a coincidence.
|
|
50
|
+
*/
|
|
51
|
+
const SKILL_COVERED_OVERLAP = 0.75;
|
|
52
|
+
/** Description characters considered. The opening says what a skill does; the rest is trigger bait. */
|
|
53
|
+
const SKILL_DESCRIPTION_CHARS = 300;
|
|
54
|
+
/** Directives must reach this many occurrences to be reported at all. */
|
|
55
|
+
const DEFAULT_MIN_DIRECTIVE_COUNT = 2;
|
|
56
|
+
/** Tool sequence lengths considered as workflow candidates. */
|
|
57
|
+
const WORKFLOW_MIN_LEN = 3;
|
|
58
|
+
const WORKFLOW_MAX_LEN = 5;
|
|
59
|
+
/** Repeats before a tool sequence is worth proposing as a skill. */
|
|
60
|
+
const DEFAULT_MIN_WORKFLOW_COUNT = 3;
|
|
61
|
+
/** Cap on each list in the digest, so the model's budget goes to the top signals. */
|
|
62
|
+
const DEFAULT_MAX_PER_CATEGORY = 8;
|
|
63
|
+
function textOf(content) {
|
|
64
|
+
if (typeof content === "string")
|
|
65
|
+
return content;
|
|
66
|
+
if (!Array.isArray(content))
|
|
67
|
+
return "";
|
|
68
|
+
return content
|
|
69
|
+
.map((block) => block && typeof block === "object" && block.type === "text"
|
|
70
|
+
? (block.text ?? "")
|
|
71
|
+
: "")
|
|
72
|
+
.join("\n")
|
|
73
|
+
.trim();
|
|
74
|
+
}
|
|
75
|
+
function isToolCall(block) {
|
|
76
|
+
return !!block && typeof block === "object" && block.type === "toolCall";
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Reduce a session's raw entries to the branch that was actually taken.
|
|
80
|
+
*
|
|
81
|
+
* Session files are trees — forks and clones append entries that were never
|
|
82
|
+
* part of the same conversation. Walking parent links back from the last entry
|
|
83
|
+
* keeps the extractor from stitching a "fix" out of two turns that never
|
|
84
|
+
* happened in sequence. Sessions written before entry ids existed are flat, and
|
|
85
|
+
* for those file order *is* the branch.
|
|
86
|
+
*/
|
|
87
|
+
function activeBranch(entries) {
|
|
88
|
+
const withIds = entries.filter((e) => typeof e.id === "string");
|
|
89
|
+
if (withIds.length === 0)
|
|
90
|
+
return entries;
|
|
91
|
+
const byId = new Map();
|
|
92
|
+
for (const entry of withIds)
|
|
93
|
+
byId.set(entry.id, entry);
|
|
94
|
+
const branch = [];
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
let cursor = withIds[withIds.length - 1];
|
|
97
|
+
while (cursor?.id && !seen.has(cursor.id)) {
|
|
98
|
+
seen.add(cursor.id);
|
|
99
|
+
branch.push(cursor);
|
|
100
|
+
cursor = cursor.parentId ? byId.get(cursor.parentId) : undefined;
|
|
101
|
+
}
|
|
102
|
+
return branch.reverse();
|
|
103
|
+
}
|
|
104
|
+
function parseSessionFile(file, cwd) {
|
|
105
|
+
let raw;
|
|
106
|
+
try {
|
|
107
|
+
raw = readFileSync(file, "utf-8");
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const lines = raw.split("\n");
|
|
113
|
+
let header;
|
|
114
|
+
const entries = [];
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
if (!line.trim())
|
|
117
|
+
continue;
|
|
118
|
+
if (entries.length >= MAX_ENTRIES_PER_SESSION)
|
|
119
|
+
break;
|
|
120
|
+
let parsed;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(line);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// A partially-flushed final line is normal for a live session.
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (parsed.type === "session") {
|
|
129
|
+
header ??= parsed;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
entries.push(parsed);
|
|
133
|
+
}
|
|
134
|
+
// An explicit `--session` path can put a session for another directory in
|
|
135
|
+
// this directory, so trust the header over the file's location.
|
|
136
|
+
if (header?.cwd && resolve(header.cwd) !== resolve(cwd))
|
|
137
|
+
return undefined;
|
|
138
|
+
if (entries.length === 0)
|
|
139
|
+
return undefined;
|
|
140
|
+
return {
|
|
141
|
+
file,
|
|
142
|
+
id: header?.id ?? file,
|
|
143
|
+
timestamp: header?.timestamp ?? statSync(file).mtime.toISOString(),
|
|
144
|
+
entries: activeBranch(entries),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function listSessions(options) {
|
|
148
|
+
const dir = options.sessionDir ?? getDefaultSessionDir(options.cwd, options.agentDir);
|
|
149
|
+
if (!existsSync(dir))
|
|
150
|
+
return { sessions: [], skipped: 0 };
|
|
151
|
+
const maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
152
|
+
const maxAgeDays = options.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;
|
|
153
|
+
const now = options.now ?? new Date();
|
|
154
|
+
const cutoff = now.getTime() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
155
|
+
let files;
|
|
156
|
+
try {
|
|
157
|
+
files = readdirSync(dir)
|
|
158
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
159
|
+
.map((f) => join(dir, f));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return { sessions: [], skipped: 0 };
|
|
163
|
+
}
|
|
164
|
+
const dated = files
|
|
165
|
+
.map((file) => {
|
|
166
|
+
try {
|
|
167
|
+
return { file, mtime: statSync(file).mtime.getTime() };
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
.filter((f) => !!f)
|
|
174
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
175
|
+
const sessions = [];
|
|
176
|
+
let skipped = 0;
|
|
177
|
+
for (const { file, mtime } of dated) {
|
|
178
|
+
if (sessions.length >= maxSessions) {
|
|
179
|
+
skipped++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (mtime < cutoff) {
|
|
183
|
+
skipped++;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const parsed = parseSessionFile(file, options.cwd);
|
|
187
|
+
if (parsed)
|
|
188
|
+
sessions.push(parsed);
|
|
189
|
+
else
|
|
190
|
+
skipped++;
|
|
191
|
+
}
|
|
192
|
+
return { sessions, skipped };
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Hold back items already shown that have not recurred since, then cap the rest.
|
|
196
|
+
*
|
|
197
|
+
* Order matters: suppression runs *before* the cap, or an item you already
|
|
198
|
+
* decided on would occupy one of the few slots the digest has and push a live
|
|
199
|
+
* signal off the list.
|
|
200
|
+
*/
|
|
201
|
+
function applySuppression(items, state, maxProposals, covered, onDeclined) {
|
|
202
|
+
if (!state)
|
|
203
|
+
return { kept: items.slice(0, maxProposals), suppressed: 0 };
|
|
204
|
+
const kept = [];
|
|
205
|
+
let suppressed = 0;
|
|
206
|
+
for (const item of items) {
|
|
207
|
+
const verdict = judge(state, { key: item.key, lastSeen: item.lastSeen, covered: covered(item) });
|
|
208
|
+
if (verdict.suppressed) {
|
|
209
|
+
suppressed++;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (verdict.previouslyDeclined)
|
|
213
|
+
onDeclined?.(item);
|
|
214
|
+
kept.push(item);
|
|
215
|
+
}
|
|
216
|
+
return { kept: kept.slice(0, maxProposals), suppressed };
|
|
217
|
+
}
|
|
218
|
+
/** Nearest AGENTS.md walking up from cwd, so proposals can be checked against it. */
|
|
219
|
+
function findAgentsFile(cwd) {
|
|
220
|
+
let dir = resolve(cwd);
|
|
221
|
+
while (true) {
|
|
222
|
+
for (const name of ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]) {
|
|
223
|
+
const candidate = join(dir, name);
|
|
224
|
+
if (existsSync(candidate))
|
|
225
|
+
return candidate;
|
|
226
|
+
}
|
|
227
|
+
const parent = dirname(dir);
|
|
228
|
+
if (parent === dir)
|
|
229
|
+
return undefined;
|
|
230
|
+
dir = parent;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** Pair tool calls with their results along one branch, in call order. */
|
|
234
|
+
function toolEvents(entries) {
|
|
235
|
+
const byCallId = new Map();
|
|
236
|
+
const ordered = [];
|
|
237
|
+
for (const entry of entries) {
|
|
238
|
+
const message = entry.type === "message" ? entry.message : undefined;
|
|
239
|
+
if (!message)
|
|
240
|
+
continue;
|
|
241
|
+
if (message.role === "assistant") {
|
|
242
|
+
for (const block of (message.content ?? [])) {
|
|
243
|
+
if (!isToolCall(block))
|
|
244
|
+
continue;
|
|
245
|
+
const event = { name: block.name, args: block.arguments ?? {} };
|
|
246
|
+
byCallId.set(block.id, event);
|
|
247
|
+
ordered.push(event);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
else if (message.role === "toolResult") {
|
|
251
|
+
const event = byCallId.get(message.toolCallId);
|
|
252
|
+
if (!event)
|
|
253
|
+
continue;
|
|
254
|
+
event.isError = message.isError;
|
|
255
|
+
event.output = textOf(message.content);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return ordered;
|
|
259
|
+
}
|
|
260
|
+
/** User turns worth mining, in order, with the digest's own output excluded. */
|
|
261
|
+
function userDirectives(entries) {
|
|
262
|
+
const out = [];
|
|
263
|
+
for (const entry of entries) {
|
|
264
|
+
const message = entry.type === "message" ? entry.message : undefined;
|
|
265
|
+
if (!message || message.role !== "user")
|
|
266
|
+
continue;
|
|
267
|
+
const text = textOf(message.content);
|
|
268
|
+
if (!text || text.startsWith(LEARN_DIGEST_MARKER))
|
|
269
|
+
continue;
|
|
270
|
+
if (!isRuleShapedDirective(text))
|
|
271
|
+
continue;
|
|
272
|
+
out.push(text.trim());
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
function clusterDirectives(perSession, coverage, minRepeats) {
|
|
277
|
+
const acc = new Map();
|
|
278
|
+
for (const { session, directives } of perSession) {
|
|
279
|
+
for (const text of directives) {
|
|
280
|
+
const normalized = normalizeDirective(text);
|
|
281
|
+
if (!normalized)
|
|
282
|
+
continue;
|
|
283
|
+
const existing = acc.get(normalized);
|
|
284
|
+
if (existing) {
|
|
285
|
+
existing.count++;
|
|
286
|
+
existing.sessions.add(session.id);
|
|
287
|
+
if (session.timestamp > existing.lastSeen)
|
|
288
|
+
existing.lastSeen = session.timestamp;
|
|
289
|
+
if (text.length > existing.text.length)
|
|
290
|
+
existing.text = text;
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
acc.set(normalized, {
|
|
294
|
+
text,
|
|
295
|
+
normalized,
|
|
296
|
+
count: 1,
|
|
297
|
+
sessions: new Set([session.id]),
|
|
298
|
+
lastSeen: session.timestamp,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const clusters = [];
|
|
304
|
+
for (const entry of acc.values()) {
|
|
305
|
+
if (entry.count < minRepeats)
|
|
306
|
+
continue;
|
|
307
|
+
// Everything reaching here cleared the repeat threshold. Suppression handles
|
|
308
|
+
// the case that used to make these labels lie — a proposal accepted from a
|
|
309
|
+
// previous run coming back as "not working" when nothing had happened
|
|
310
|
+
// since. By the time an item survives that filter, a match genuinely means
|
|
311
|
+
// you repeated yourself after the rule or skill already existed.
|
|
312
|
+
const match = matchCoverage(entry.text, coverage);
|
|
313
|
+
clusters.push({
|
|
314
|
+
key: `directive:${entry.normalized}`,
|
|
315
|
+
text: entry.text,
|
|
316
|
+
normalized: entry.normalized,
|
|
317
|
+
count: entry.count,
|
|
318
|
+
sessions: entry.sessions.size,
|
|
319
|
+
lastSeen: entry.lastSeen,
|
|
320
|
+
status: match.rule ? "restated" : match.skill ? "has-skill" : "new",
|
|
321
|
+
existingRule: match.rule,
|
|
322
|
+
existingSkill: match.skill,
|
|
323
|
+
previouslyDeclined: false,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return clusters.sort((a, b) => b.sessions - a.sessions || b.count - a.count || a.text.localeCompare(b.text));
|
|
327
|
+
}
|
|
328
|
+
/** Files a mutating tool touched, for the resolution summary. */
|
|
329
|
+
function editedFile(event) {
|
|
330
|
+
if (!["edit", "write", "multi_edit", "apply_patch"].includes(event.name))
|
|
331
|
+
return undefined;
|
|
332
|
+
const path = event.args?.path ?? event.args?.file_path ?? event.args?.filePath;
|
|
333
|
+
return typeof path === "string" ? path : undefined;
|
|
334
|
+
}
|
|
335
|
+
function extractFixes(perSession) {
|
|
336
|
+
const acc = new Map();
|
|
337
|
+
for (const { session, events } of perSession) {
|
|
338
|
+
for (let i = 0; i < events.length; i++) {
|
|
339
|
+
const failure = events[i];
|
|
340
|
+
if (failure.name !== "bash" || !failure.isError)
|
|
341
|
+
continue;
|
|
342
|
+
const command = typeof failure.args?.command === "string" ? failure.args.command : "";
|
|
343
|
+
if (!command || isBenignFailure(command))
|
|
344
|
+
continue;
|
|
345
|
+
const normalized = normalizeCommand(command);
|
|
346
|
+
const interveningCommands = [];
|
|
347
|
+
const editedFiles = [];
|
|
348
|
+
let resolved = false;
|
|
349
|
+
for (let j = i + 1; j < Math.min(events.length, i + 1 + FIX_LOOKAHEAD); j++) {
|
|
350
|
+
const next = events[j];
|
|
351
|
+
const file = editedFile(next);
|
|
352
|
+
if (file)
|
|
353
|
+
editedFiles.push(file);
|
|
354
|
+
if (next.name !== "bash")
|
|
355
|
+
continue;
|
|
356
|
+
const nextCommand = typeof next.args?.command === "string" ? next.args.command : "";
|
|
357
|
+
if (!nextCommand)
|
|
358
|
+
continue;
|
|
359
|
+
// The same command later succeeding is the only evidence that the
|
|
360
|
+
// problem was actually fixed. A *different* command passing says
|
|
361
|
+
// nothing, and neither does the model moving on.
|
|
362
|
+
if (normalizeCommand(nextCommand) === normalized && !next.isError) {
|
|
363
|
+
resolved = true;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
interveningCommands.push(nextCommand.trim());
|
|
367
|
+
}
|
|
368
|
+
if (!resolved)
|
|
369
|
+
continue;
|
|
370
|
+
const output = failure.output ?? "";
|
|
371
|
+
// An abort is the user changing their mind, not a problem that was
|
|
372
|
+
// solved, and empty output carries nothing to sign or show.
|
|
373
|
+
if (isUninformativeFailure(output))
|
|
374
|
+
continue;
|
|
375
|
+
// Sign the error region, not the whole output: build tools lead with an
|
|
376
|
+
// identical banner, so signing everything makes unrelated failures of
|
|
377
|
+
// the same command collide on their shared preamble.
|
|
378
|
+
const errorRegion = extractErrorRegion(output);
|
|
379
|
+
const signature = normalizeErrorSignature(errorRegion);
|
|
380
|
+
if (!signature)
|
|
381
|
+
continue;
|
|
382
|
+
const key = `${normalized}${signature}`;
|
|
383
|
+
const existing = acc.get(key);
|
|
384
|
+
if (existing) {
|
|
385
|
+
existing.candidate.count++;
|
|
386
|
+
existing.sessions.add(session.id);
|
|
387
|
+
if (session.timestamp > existing.candidate.lastSeen)
|
|
388
|
+
existing.candidate.lastSeen = session.timestamp;
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
acc.set(key, {
|
|
392
|
+
sessions: new Set([session.id]),
|
|
393
|
+
candidate: {
|
|
394
|
+
key: `fix:${key}`,
|
|
395
|
+
command: normalized,
|
|
396
|
+
signature,
|
|
397
|
+
errorExcerpt: errorRegion.replace(/\s+/g, " ").trim().slice(0, 240),
|
|
398
|
+
interveningCommands: [...new Set(interveningCommands)].slice(0, 5),
|
|
399
|
+
editedFiles: [...new Set(editedFiles)].slice(0, 5),
|
|
400
|
+
count: 1,
|
|
401
|
+
sessions: 1,
|
|
402
|
+
lastSeen: session.timestamp,
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const out = [];
|
|
409
|
+
for (const { candidate, sessions } of acc.values()) {
|
|
410
|
+
candidate.sessions = sessions.size;
|
|
411
|
+
out.push(candidate);
|
|
412
|
+
}
|
|
413
|
+
return out.sort((a, b) => b.count - a.count || b.sessions - a.sessions || a.signature.localeCompare(b.signature));
|
|
414
|
+
}
|
|
415
|
+
/** A tool call reduced to a comparable step: the tool, plus what a bash call runs. */
|
|
416
|
+
function stepSignature(event) {
|
|
417
|
+
if (event.name === "bash") {
|
|
418
|
+
const command = typeof event.args?.command === "string" ? event.args.command : "";
|
|
419
|
+
const head = commandHead(command);
|
|
420
|
+
return head ? `bash:${head}` : "bash";
|
|
421
|
+
}
|
|
422
|
+
return event.name;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Commands that are how an agent looks around rather than what the user was
|
|
426
|
+
* doing. A sequence built only from these plus file edits describes "coding",
|
|
427
|
+
* not a workflow, and no useful skill has ever come out of one.
|
|
428
|
+
*/
|
|
429
|
+
const PLUMBING_COMMANDS = new Set([
|
|
430
|
+
"cd",
|
|
431
|
+
"ls",
|
|
432
|
+
"pwd",
|
|
433
|
+
"cat",
|
|
434
|
+
"head",
|
|
435
|
+
"tail",
|
|
436
|
+
"wc",
|
|
437
|
+
"echo",
|
|
438
|
+
"which",
|
|
439
|
+
"find",
|
|
440
|
+
"fd",
|
|
441
|
+
"grep",
|
|
442
|
+
"rg",
|
|
443
|
+
"sed",
|
|
444
|
+
"awk",
|
|
445
|
+
"git status",
|
|
446
|
+
"git diff",
|
|
447
|
+
"git log",
|
|
448
|
+
"git show",
|
|
449
|
+
]);
|
|
450
|
+
/**
|
|
451
|
+
* Whether a sequence is a procedure rather than the rhythm of editing code.
|
|
452
|
+
*
|
|
453
|
+
* Two distinct doing-commands is the bar, and it was set by looking at real
|
|
454
|
+
* transcripts. One command is not enough: the edit/test loop
|
|
455
|
+
* (`edit → edit → bash:npm run`) satisfies it, and because a sliding window
|
|
456
|
+
* over a long alternating run produces every rotation of that cycle, it alone
|
|
457
|
+
* filled all eight slots with `edit → npm run → edit`, `npm run → edit → edit`
|
|
458
|
+
* and so on — one habit described eight ways.
|
|
459
|
+
*
|
|
460
|
+
* A procedure worth a skill chains *different* actions: test then commit then
|
|
461
|
+
* push, build then tag then publish. Requiring two distinct ones keeps those and
|
|
462
|
+
* drops the rhythm. The cost is real — a genuine one-command routine with setup
|
|
463
|
+
* is missed — and that is the intended trade, since a missed skill costs nothing
|
|
464
|
+
* while a digest full of noise costs the reader's attention every run.
|
|
465
|
+
*/
|
|
466
|
+
function isProcedure(steps) {
|
|
467
|
+
const commands = new Set();
|
|
468
|
+
for (const step of steps) {
|
|
469
|
+
if (!step.startsWith("bash:"))
|
|
470
|
+
continue;
|
|
471
|
+
const head = step.slice("bash:".length);
|
|
472
|
+
if (PLUMBING_COMMANDS.has(head) || PLUMBING_COMMANDS.has(head.split(" ")[0] ?? ""))
|
|
473
|
+
continue;
|
|
474
|
+
commands.add(head);
|
|
475
|
+
}
|
|
476
|
+
return commands.size >= 2;
|
|
477
|
+
}
|
|
478
|
+
/** True when `needle` appears as a contiguous run inside `haystack`. */
|
|
479
|
+
function containsSequence(haystack, needle) {
|
|
480
|
+
if (needle.length > haystack.length)
|
|
481
|
+
return false;
|
|
482
|
+
for (let i = 0; i + needle.length <= haystack.length; i++) {
|
|
483
|
+
if (needle.every((step, offset) => haystack[i + offset] === step))
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
function extractWorkflows(perSession, minRepeats) {
|
|
489
|
+
const acc = new Map();
|
|
490
|
+
for (const { session, events } of perSession) {
|
|
491
|
+
const steps = events.slice(0, MAX_TOOL_CALLS_PER_SESSION).map(stepSignature);
|
|
492
|
+
for (let len = WORKFLOW_MIN_LEN; len <= WORKFLOW_MAX_LEN; len++) {
|
|
493
|
+
// Collect every position first, then count greedily without overlap.
|
|
494
|
+
// Counting each sliding position separately treats one long stretch of
|
|
495
|
+
// edit/read churn as dozens of repeats: an `edit > read > edit` run of
|
|
496
|
+
// length 12 scores 10 occurrences when it is really one stretch of work.
|
|
497
|
+
const positions = new Map();
|
|
498
|
+
for (let i = 0; i + len <= steps.length; i++) {
|
|
499
|
+
const window = steps.slice(i, i + len);
|
|
500
|
+
// A run of one repeated tool is a loop, not a workflow.
|
|
501
|
+
if (new Set(window).size < 2)
|
|
502
|
+
continue;
|
|
503
|
+
if (!isProcedure(window))
|
|
504
|
+
continue;
|
|
505
|
+
const key = window.join(" > ");
|
|
506
|
+
const list = positions.get(key);
|
|
507
|
+
if (list)
|
|
508
|
+
list.push(i);
|
|
509
|
+
else
|
|
510
|
+
positions.set(key, [i]);
|
|
511
|
+
}
|
|
512
|
+
for (const [key, occurrences] of positions) {
|
|
513
|
+
let count = 0;
|
|
514
|
+
let nextFree = -1;
|
|
515
|
+
for (const start of occurrences) {
|
|
516
|
+
if (start < nextFree)
|
|
517
|
+
continue;
|
|
518
|
+
count++;
|
|
519
|
+
nextFree = start + len;
|
|
520
|
+
}
|
|
521
|
+
const existing = acc.get(key);
|
|
522
|
+
if (existing) {
|
|
523
|
+
existing.count += count;
|
|
524
|
+
existing.sessions.add(session.id);
|
|
525
|
+
if (session.timestamp > existing.lastSeen)
|
|
526
|
+
existing.lastSeen = session.timestamp;
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
acc.set(key, {
|
|
530
|
+
steps: key.split(" > "),
|
|
531
|
+
count,
|
|
532
|
+
sessions: new Set([session.id]),
|
|
533
|
+
lastSeen: session.timestamp,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const ranked = [...acc.values()]
|
|
540
|
+
.filter((entry) => entry.count >= minRepeats)
|
|
541
|
+
.map((entry) => ({
|
|
542
|
+
key: `workflow:${entry.steps.join(" > ")}`,
|
|
543
|
+
steps: entry.steps,
|
|
544
|
+
count: entry.count,
|
|
545
|
+
sessions: entry.sessions.size,
|
|
546
|
+
lastSeen: entry.lastSeen,
|
|
547
|
+
}))
|
|
548
|
+
// Sessions first, matching directives: a sequence seen in three sessions is
|
|
549
|
+
// a workflow, while one repeated ten times in a single session is usually
|
|
550
|
+
// just the shape of that one task.
|
|
551
|
+
.sort((a, b) => b.sessions - a.sessions ||
|
|
552
|
+
b.count - a.count ||
|
|
553
|
+
b.steps.length - a.steps.length ||
|
|
554
|
+
a.steps.join().localeCompare(b.steps.join()));
|
|
555
|
+
// Every n-gram overlaps its own extensions and prefixes, so without this the
|
|
556
|
+
// list is one workflow described five slightly different ways. The test runs
|
|
557
|
+
// both directions on purpose: a shorter sequence always outranks the longer
|
|
558
|
+
// one containing it (it occurs at least as often), so checking only
|
|
559
|
+
// shorter-inside-kept would never fire. Keep the best-ranked member of each
|
|
560
|
+
// family and drop the rest.
|
|
561
|
+
const distinct = [];
|
|
562
|
+
for (const candidate of ranked) {
|
|
563
|
+
const overlapsKept = distinct.some((kept) => containsSequence(kept.steps, candidate.steps) || containsSequence(candidate.steps, kept.steps));
|
|
564
|
+
if (overlapsKept)
|
|
565
|
+
continue;
|
|
566
|
+
distinct.push(candidate);
|
|
567
|
+
}
|
|
568
|
+
return distinct;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Where a piece of text is already written down, if anywhere.
|
|
572
|
+
*
|
|
573
|
+
* A rule wins over a skill when both match: it is the more specific answer, and
|
|
574
|
+
* "rewrite this line" is more actionable than "sharpen a description".
|
|
575
|
+
*/
|
|
576
|
+
export function matchCoverage(text, index) {
|
|
577
|
+
const words = contentWords(text);
|
|
578
|
+
let bestLine;
|
|
579
|
+
let bestOverlap = 0;
|
|
580
|
+
for (const line of index.ruleLines) {
|
|
581
|
+
const overlap = wordOverlap(words, line);
|
|
582
|
+
if (overlap > bestOverlap) {
|
|
583
|
+
bestOverlap = overlap;
|
|
584
|
+
bestLine = line;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (bestOverlap >= COVERED_OVERLAP)
|
|
588
|
+
return { rule: bestLine };
|
|
589
|
+
let bestSkill;
|
|
590
|
+
let bestSkillOverlap = 0;
|
|
591
|
+
for (const skill of index.skills) {
|
|
592
|
+
const haystack = `${skill.name} ${skill.description.slice(0, SKILL_DESCRIPTION_CHARS)}`;
|
|
593
|
+
const overlap = wordOverlap(words, haystack);
|
|
594
|
+
if (overlap > bestSkillOverlap) {
|
|
595
|
+
bestSkillOverlap = overlap;
|
|
596
|
+
bestSkill = skill.name;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (bestSkillOverlap >= SKILL_COVERED_OVERLAP)
|
|
600
|
+
return { skill: bestSkill };
|
|
601
|
+
return {};
|
|
602
|
+
}
|
|
603
|
+
/** Assemble the coverage index for a directory. */
|
|
604
|
+
export function buildCoverageIndex(options) {
|
|
605
|
+
const corpus = coverageCorpus(options.agentDir, findAgentsFile(options.cwd));
|
|
606
|
+
return {
|
|
607
|
+
ruleLines: corpus
|
|
608
|
+
.split("\n")
|
|
609
|
+
.map((line) => line.trim())
|
|
610
|
+
.filter((line) => line.length > 0 && !line.startsWith("#")),
|
|
611
|
+
skills: options.skills ?? loadSkillIndex(options.cwd, options.agentDir),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Text a proposal is checked against to decide whether it is already written
|
|
616
|
+
* down — the nearest repo context file plus both user scopes.
|
|
617
|
+
*
|
|
618
|
+
* All three matter for suppression, because `/learn` can route a rule to the
|
|
619
|
+
* user scope. Checking only the repo file would report a rule you accepted into
|
|
620
|
+
* `~/.agents/AGENTS.md` as declined.
|
|
621
|
+
*/
|
|
622
|
+
function coverageCorpus(agentDir, repoFile) {
|
|
623
|
+
const parts = [];
|
|
624
|
+
for (const candidate of [repoFile, join(getUserAgentsDir(), "AGENTS.md"), join(agentDir, "AGENTS.md")]) {
|
|
625
|
+
if (!candidate || !existsSync(candidate))
|
|
626
|
+
continue;
|
|
627
|
+
try {
|
|
628
|
+
parts.push(readFileSync(candidate, "utf-8"));
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
// Unreadable context file: treat as absent rather than failing the run.
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return parts.join("\n");
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Skills a proposal could already have become.
|
|
638
|
+
*
|
|
639
|
+
* `/learn` routes long or conditional guidance to a skill rather than a rule, so
|
|
640
|
+
* without this a proposal you adopted *as a skill* would read as declined —
|
|
641
|
+
* looking only at context files sees an unchanged `AGENTS.md` and concludes you
|
|
642
|
+
* passed. Reuses the real loader rather than a second SKILL.md scanner so the
|
|
643
|
+
* set of locations cannot drift from what the session actually loads.
|
|
644
|
+
*/
|
|
645
|
+
function loadSkillIndex(cwd, agentDir) {
|
|
646
|
+
try {
|
|
647
|
+
return loadSkills({ cwd, agentDir, skillPaths: [], includeDefaults: true }).skills.map((skill) => ({
|
|
648
|
+
name: skill.name,
|
|
649
|
+
description: skill.description ?? "",
|
|
650
|
+
}));
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
// Skills are an enrichment here, not the point of the command.
|
|
654
|
+
return [];
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/** Mine the recent sessions for this cwd and return the ranked digest. */
|
|
658
|
+
export function extractLearnDigest(options) {
|
|
659
|
+
const { sessions, skipped } = listSessions(options);
|
|
660
|
+
const agentsFilePath = findAgentsFile(options.cwd);
|
|
661
|
+
let agentsContent;
|
|
662
|
+
if (agentsFilePath) {
|
|
663
|
+
try {
|
|
664
|
+
agentsContent = readFileSync(agentsFilePath, "utf-8");
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
agentsContent = undefined;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const coverage = buildCoverageIndex({ cwd: options.cwd, agentDir: options.agentDir, skills: options.skills });
|
|
671
|
+
const withDirectives = sessions.map((session) => ({ session, directives: userDirectives(session.entries) }));
|
|
672
|
+
const withEvents = sessions.map((session) => ({ session, events: toolEvents(session.entries) }));
|
|
673
|
+
const timestamps = sessions.map((s) => s.timestamp).sort();
|
|
674
|
+
const state = options.ignoreState ? undefined : options.state;
|
|
675
|
+
// Directives carry a real coverage signal — is this written down as a rule or
|
|
676
|
+
// a skill right now? — which is what separates an adopted proposal from a
|
|
677
|
+
// declined one. Fixes and workflows do not: a fix may have become a rule, a
|
|
678
|
+
// skill, or a habit, and which one is not recoverable here, so they get
|
|
679
|
+
// suppression only and are never labelled declined.
|
|
680
|
+
const maxProposals = options.maxProposals ?? DEFAULT_MAX_PER_CATEGORY;
|
|
681
|
+
const directives = applySuppression(clusterDirectives(withDirectives, coverage, options.minRepeats ?? DEFAULT_MIN_DIRECTIVE_COUNT), state, maxProposals, (item) => item.status !== "new", (item) => {
|
|
682
|
+
item.previouslyDeclined = true;
|
|
683
|
+
});
|
|
684
|
+
const fixes = applySuppression(extractFixes(withEvents), state, maxProposals, () => false);
|
|
685
|
+
const workflows = applySuppression(extractWorkflows(withEvents, options.minWorkflowRepeats ?? DEFAULT_MIN_WORKFLOW_COUNT), state, maxProposals, () => false);
|
|
686
|
+
const surfaced = [
|
|
687
|
+
...directives.kept.map((d) => ({ key: d.key, lastSeen: d.lastSeen, covered: d.status !== "new" })),
|
|
688
|
+
...fixes.kept.map((f) => ({ key: f.key, lastSeen: f.lastSeen, covered: false })),
|
|
689
|
+
...workflows.kept.map((w) => ({ key: w.key, lastSeen: w.lastSeen, covered: false })),
|
|
690
|
+
];
|
|
691
|
+
return {
|
|
692
|
+
scannedSessions: sessions.length,
|
|
693
|
+
skippedSessions: skipped,
|
|
694
|
+
oldestSession: timestamps[0],
|
|
695
|
+
newestSession: timestamps[timestamps.length - 1],
|
|
696
|
+
agentsFilePath,
|
|
697
|
+
agentsFileTokens: agentsContent === undefined ? undefined : Math.round(Buffer.byteLength(agentsContent, "utf-8") / 4),
|
|
698
|
+
directives: directives.kept,
|
|
699
|
+
fixes: fixes.kept,
|
|
700
|
+
workflows: workflows.kept,
|
|
701
|
+
suppressed: directives.suppressed + fixes.suppressed + workflows.suppressed,
|
|
702
|
+
surfaced,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
//# sourceMappingURL=extract.js.map
|