@kolisachint/hoocode-agent 0.5.17 → 0.5.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +247 -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 +58 -0
- package/dist/core/learn/cache.d.ts.map +1 -0
- package/dist/core/learn/cache.js +132 -0
- package/dist/core/learn/cache.js.map +1 -0
- 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 +58 -0
- package/dist/core/learn/coverage.d.ts.map +1 -0
- package/dist/core/learn/coverage.js +144 -0
- package/dist/core/learn/coverage.js.map +1 -0
- package/dist/core/learn/digest.d.ts +13 -0
- package/dist/core/learn/digest.d.ts.map +1 -1
- package/dist/core/learn/digest.js +113 -14
- package/dist/core/learn/digest.js.map +1 -1
- package/dist/core/learn/extract.d.ts +108 -105
- package/dist/core/learn/extract.d.ts.map +1 -1
- package/dist/core/learn/extract.js +308 -447
- package/dist/core/learn/extract.js.map +1 -1
- package/dist/core/learn/mine.d.ts +178 -0
- package/dist/core/learn/mine.d.ts.map +1 -0
- package/dist/core/learn/mine.js +390 -0
- package/dist/core/learn/mine.js.map +1 -0
- package/dist/core/learn/reduce.d.ts +89 -0
- package/dist/core/learn/reduce.d.ts.map +1 -0
- package/dist/core/learn/reduce.js +179 -0
- package/dist/core/learn/reduce.js.map +1 -0
- package/dist/core/learn/state.d.ts +19 -18
- package/dist/core/learn/state.d.ts.map +1 -1
- package/dist/core/learn/state.js +35 -31
- 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 +4 -2
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +5 -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/core/startup-progress.d.ts +12 -7
- package/dist/core/startup-progress.d.ts.map +1 -1
- package/dist/core/startup-progress.js +12 -7
- package/dist/core/startup-progress.js.map +1 -1
- package/dist/extensions/core/learn.d.ts +8 -4
- package/dist/extensions/core/learn.d.ts.map +1 -1
- package/dist/extensions/core/learn.js +292 -56
- package/dist/extensions/core/learn.js.map +1 -1
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +7 -25
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/interactive/components/progress-bar.d.ts +50 -0
- package/dist/modes/interactive/components/progress-bar.d.ts.map +1 -0
- package/dist/modes/interactive/components/progress-bar.js +77 -0
- package/dist/modes/interactive/components/progress-bar.js.map +1 -0
- 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/dist/modes/interactive/voice/voice-panel.d.ts +6 -1
- package/dist/modes/interactive/voice/voice-panel.d.ts.map +1 -1
- package/dist/modes/interactive/voice/voice-panel.js +18 -14
- package/dist/modes/interactive/voice/voice-panel.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
- package/dist/core/learn/normalize.d.ts +0 -65
- package/dist/core/learn/normalize.d.ts.map +0 -1
- package/dist/core/learn/normalize.js +0 -245
- package/dist/core/learn/normalize.js.map +0 -1
|
@@ -8,81 +8,70 @@
|
|
|
8
8
|
* whether something is a durable rule or a one-off, and it is the one thing a
|
|
9
9
|
* prompt reading its own context cannot see.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* This module is the orchestrator, and the split of labour inside it is
|
|
12
|
+
* deliberate:
|
|
13
|
+
*
|
|
14
|
+
* - **Gathering** is deterministic. Finding session files, resolving which cwd
|
|
15
|
+
* they belong to, walking the active branch of a forked session — all exact,
|
|
16
|
+
* all cheap, all here.
|
|
17
|
+
* - **Judgement** is the model's, in `mine.ts` and `coverage.ts`. What counts as
|
|
18
|
+
* a directive, what two phrasings have in common, whether a rule already
|
|
19
|
+
* covers something — none of that survives contact with a regex, and it used
|
|
20
|
+
* to be decided by one.
|
|
21
|
+
* - **Counting** is deterministic again, in `reduce.ts`. The number is the
|
|
22
|
+
* product, and a model asked to count over a long context will be
|
|
23
|
+
* approximately right.
|
|
24
|
+
*
|
|
25
|
+
* The expensive step is memoized per session file (`cache.ts`), so a session is
|
|
26
|
+
* read by the model exactly once in its life and the counts are still computed
|
|
27
|
+
* over every session in the window on every run.
|
|
16
28
|
*/
|
|
17
29
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
18
30
|
import { dirname, join, resolve, sep } from "node:path";
|
|
19
31
|
import { getUserAgentsDir } from "../../config.js";
|
|
20
32
|
import { getSessionDirPath } from "../session-manager.js";
|
|
21
33
|
import { loadSkills } from "../skills.js";
|
|
22
|
-
import {
|
|
34
|
+
import { hashSessionFile, pruneLearnCache, readCachedMining, writeCachedMining } from "./cache.js";
|
|
35
|
+
import { fallbackLabel } from "./cluster.js";
|
|
36
|
+
import { noCoverageJudge } from "./coverage.js";
|
|
37
|
+
import { reduceDirectives, reduceFixes, reduceRequests } from "./reduce.js";
|
|
23
38
|
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]";
|
|
39
|
+
export { LEARN_DIGEST_MARKER } from "./mine.js";
|
|
30
40
|
/** Sessions considered, newest first. */
|
|
31
41
|
const DEFAULT_MAX_SESSIONS = 20;
|
|
32
42
|
/** Sessions older than this are ignored — a pattern that stopped is not a rule. */
|
|
33
43
|
const DEFAULT_MAX_AGE_DAYS = 30;
|
|
34
44
|
/** Entries parsed per session file, as a guard against pathological transcripts. */
|
|
35
45
|
const MAX_ENTRIES_PER_SESSION = 8000;
|
|
36
|
-
/**
|
|
37
|
-
const
|
|
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;
|
|
46
|
+
/** Occurrences a directive needs before it is proposed. */
|
|
47
|
+
const DEFAULT_MIN_DIRECTIVE_COUNT = 2;
|
|
42
48
|
/**
|
|
43
|
-
*
|
|
49
|
+
* Sessions a request needs before it is worth proposing as a slash command.
|
|
44
50
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* higher bar and the truncation below keep "you already have a skill for this"
|
|
49
|
-
* from being said on a coincidence.
|
|
51
|
+
* Higher than the directive bar. A rule you stated twice is a rule; a job you
|
|
52
|
+
* asked for twice may just be a job that came up twice. Three separate sessions
|
|
53
|
+
* is the point at which typing it again is the expensive option.
|
|
50
54
|
*/
|
|
51
|
-
const
|
|
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;
|
|
55
|
+
const DEFAULT_MIN_REQUEST_COUNT = 3;
|
|
61
56
|
/** Cap on each list in the digest, so the model's budget goes to the top signals. */
|
|
62
57
|
const DEFAULT_MAX_PER_CATEGORY = 8;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
: "")
|
|
72
|
-
.join("\n")
|
|
73
|
-
.trim();
|
|
74
|
-
}
|
|
75
|
-
function isToolCall(block) {
|
|
76
|
-
return !!block && typeof block === "object" && block.type === "toolCall";
|
|
58
|
+
/** Newest entry timestamp on the branch, falling back to when the session opened. */
|
|
59
|
+
function lastActivityOf(entries, fallback) {
|
|
60
|
+
let latest = "";
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (typeof entry.timestamp === "string" && entry.timestamp > latest)
|
|
63
|
+
latest = entry.timestamp;
|
|
64
|
+
}
|
|
65
|
+
return latest || fallback;
|
|
77
66
|
}
|
|
78
67
|
/**
|
|
79
68
|
* Reduce a session's raw entries to the branch that was actually taken.
|
|
80
69
|
*
|
|
81
70
|
* Session files are trees — forks and clones append entries that were never
|
|
82
71
|
* part of the same conversation. Walking parent links back from the last entry
|
|
83
|
-
* keeps the
|
|
84
|
-
*
|
|
85
|
-
*
|
|
72
|
+
* keeps the miner from reading two turns that never happened in sequence as if
|
|
73
|
+
* they did. Sessions written before entry ids existed are flat, and for those
|
|
74
|
+
* file order *is* the branch.
|
|
86
75
|
*/
|
|
87
76
|
function activeBranch(entries) {
|
|
88
77
|
const withIds = entries.filter((e) => typeof e.id === "string");
|
|
@@ -169,11 +158,14 @@ function parseSessionFile(file, cwd, onSkip) {
|
|
|
169
158
|
onSkip("unreadable");
|
|
170
159
|
return undefined;
|
|
171
160
|
}
|
|
161
|
+
const branch = activeBranch(entries);
|
|
162
|
+
const opened = header?.timestamp ?? statSync(file).mtime.toISOString();
|
|
172
163
|
return {
|
|
173
164
|
file,
|
|
174
165
|
id: header?.id ?? file,
|
|
175
|
-
timestamp:
|
|
176
|
-
|
|
166
|
+
timestamp: opened,
|
|
167
|
+
lastActivity: lastActivityOf(branch, opened),
|
|
168
|
+
entries: branch,
|
|
177
169
|
};
|
|
178
170
|
}
|
|
179
171
|
/**
|
|
@@ -288,8 +280,9 @@ function listSessions(options) {
|
|
|
288
280
|
* signal off the list.
|
|
289
281
|
*/
|
|
290
282
|
function applySuppression(items, state, maxProposals, covered, onDeclined) {
|
|
291
|
-
if (!state)
|
|
292
|
-
return { kept: items.slice(0, maxProposals), suppressed: 0 };
|
|
283
|
+
if (!state) {
|
|
284
|
+
return { kept: items.slice(0, maxProposals), suppressed: 0, cut: Math.max(0, items.length - maxProposals) };
|
|
285
|
+
}
|
|
293
286
|
const kept = [];
|
|
294
287
|
let suppressed = 0;
|
|
295
288
|
for (const item of items) {
|
|
@@ -302,15 +295,98 @@ function applySuppression(items, state, maxProposals, covered, onDeclined) {
|
|
|
302
295
|
onDeclined?.(item);
|
|
303
296
|
kept.push(item);
|
|
304
297
|
}
|
|
305
|
-
|
|
298
|
+
// Anything past the cap cleared every bar and lost on rank alone. It is not
|
|
299
|
+
// suppressed and it is not bookmarked, so it will be back next run — but a
|
|
300
|
+
// digest that silently shows eight of twenty reads as "twenty is all there
|
|
301
|
+
// was", and the reader tunes the wrong knob.
|
|
302
|
+
return { kept: kept.slice(0, maxProposals), suppressed, cut: Math.max(0, kept.length - maxProposals) };
|
|
306
303
|
}
|
|
307
304
|
/**
|
|
308
305
|
* Where this cwd's sessions were found and what was passed over, without
|
|
309
|
-
*
|
|
306
|
+
* mining anything. `/learn settings` and `/learn stats` report on the window
|
|
307
|
+
* without paying for a model call.
|
|
310
308
|
*/
|
|
311
309
|
export function scanSessions(options) {
|
|
312
310
|
return listSessions(options).scan;
|
|
313
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* What a run would read, without reading it.
|
|
314
|
+
*
|
|
315
|
+
* Runs the real selection — the same age, cwd, cap and de-duplication rules
|
|
316
|
+
* `mineLearnDigest` applies — and then asks the cache about each survivor. It
|
|
317
|
+
* has to be the same selection: this number is what the confirmation prompt
|
|
318
|
+
* quotes, and a prompt that says twelve before reading three is worse than no
|
|
319
|
+
* prompt at all. Hashing the chosen files is cheap next to sending them to a
|
|
320
|
+
* model.
|
|
321
|
+
*/
|
|
322
|
+
export function planMining(options) {
|
|
323
|
+
const { sessions } = listSessions(options);
|
|
324
|
+
let cached = 0;
|
|
325
|
+
let pending = 0;
|
|
326
|
+
for (const session of sessions) {
|
|
327
|
+
const hash = hashSessionFile(session.file);
|
|
328
|
+
if (hash && readCachedMining(options.agentDir, hash))
|
|
329
|
+
cached++;
|
|
330
|
+
else
|
|
331
|
+
pending++;
|
|
332
|
+
}
|
|
333
|
+
return { total: sessions.length, cached, pending };
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Name every candidate in the window in one place.
|
|
337
|
+
*
|
|
338
|
+
* The pass runs over the whole window at once rather than per session, which is
|
|
339
|
+
* the entire point: "is this the same point as that" is unanswerable from
|
|
340
|
+
* inside one transcript. Labels already on record are offered as vocabulary so
|
|
341
|
+
* an item you decided on keeps the key it was bookmarked under — without that,
|
|
342
|
+
* a renamed cluster reads as brand new and suppression quietly stops working.
|
|
343
|
+
*
|
|
344
|
+
* A failed call falls back to naming each candidate after its own wording,
|
|
345
|
+
* which groups identical sentences and nothing else. That is the behaviour the
|
|
346
|
+
* pipeline had before this stage existed, so a clustering outage costs recall,
|
|
347
|
+
* not the run.
|
|
348
|
+
*/
|
|
349
|
+
async function labelSessions(raw, clusterer, knownLabels, signal) {
|
|
350
|
+
const inputs = [];
|
|
351
|
+
const origin = [];
|
|
352
|
+
for (const [sessionIndex, session] of raw.entries()) {
|
|
353
|
+
for (const [candidateIndex, candidate] of session.candidates.entries()) {
|
|
354
|
+
inputs.push({ id: inputs.length, kind: candidate.kind, text: candidate.text });
|
|
355
|
+
origin.push({ session: sessionIndex, candidate: candidateIndex });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
let labels = new Map();
|
|
359
|
+
if (clusterer && inputs.length > 0) {
|
|
360
|
+
try {
|
|
361
|
+
labels = await clusterer(inputs, knownLabels, signal);
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
// Fall through to per-text labels below.
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const labelled = raw.map((session) => ({
|
|
368
|
+
sessionId: session.sessionId,
|
|
369
|
+
lastActivity: session.lastActivity,
|
|
370
|
+
candidates: [],
|
|
371
|
+
}));
|
|
372
|
+
for (const [index, input] of inputs.entries()) {
|
|
373
|
+
const where = origin[index];
|
|
374
|
+
const candidate = where ? raw[where.session]?.candidates[where.candidate] : undefined;
|
|
375
|
+
if (!where || !candidate)
|
|
376
|
+
continue;
|
|
377
|
+
labelled[where.session]?.candidates.push({
|
|
378
|
+
...candidate,
|
|
379
|
+
label: labels.get(input.id) ?? fallbackLabel(input.text),
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
return labelled;
|
|
383
|
+
}
|
|
384
|
+
/** Labels already on record, so the naming pass can reuse rather than reinvent them. */
|
|
385
|
+
function knownLabelsFrom(state) {
|
|
386
|
+
if (!state)
|
|
387
|
+
return [];
|
|
388
|
+
return Object.keys(state.surfaced).map((key) => key.slice(key.indexOf(":") + 1));
|
|
389
|
+
}
|
|
314
390
|
/** Nearest AGENTS.md walking up from cwd, so proposals can be checked against it. */
|
|
315
391
|
function findAgentsFile(cwd) {
|
|
316
392
|
let dir = resolve(cwd);
|
|
@@ -326,386 +402,50 @@ function findAgentsFile(cwd) {
|
|
|
326
402
|
dir = parent;
|
|
327
403
|
}
|
|
328
404
|
}
|
|
329
|
-
/** Pair tool calls with their results along one branch, in call order. */
|
|
330
|
-
function toolEvents(entries) {
|
|
331
|
-
const byCallId = new Map();
|
|
332
|
-
const ordered = [];
|
|
333
|
-
for (const entry of entries) {
|
|
334
|
-
const message = entry.type === "message" ? entry.message : undefined;
|
|
335
|
-
if (!message)
|
|
336
|
-
continue;
|
|
337
|
-
if (message.role === "assistant") {
|
|
338
|
-
for (const block of (message.content ?? [])) {
|
|
339
|
-
if (!isToolCall(block))
|
|
340
|
-
continue;
|
|
341
|
-
const event = { name: block.name, args: block.arguments ?? {} };
|
|
342
|
-
byCallId.set(block.id, event);
|
|
343
|
-
ordered.push(event);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
else if (message.role === "toolResult") {
|
|
347
|
-
const event = byCallId.get(message.toolCallId);
|
|
348
|
-
if (!event)
|
|
349
|
-
continue;
|
|
350
|
-
event.isError = message.isError;
|
|
351
|
-
event.output = textOf(message.content);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
return ordered;
|
|
355
|
-
}
|
|
356
|
-
/** User turns worth mining, in order, with the digest's own output excluded. */
|
|
357
|
-
function userDirectives(entries) {
|
|
358
|
-
const out = [];
|
|
359
|
-
for (const entry of entries) {
|
|
360
|
-
const message = entry.type === "message" ? entry.message : undefined;
|
|
361
|
-
if (!message || message.role !== "user")
|
|
362
|
-
continue;
|
|
363
|
-
const text = textOf(message.content);
|
|
364
|
-
if (!text || text.startsWith(LEARN_DIGEST_MARKER))
|
|
365
|
-
continue;
|
|
366
|
-
if (!isRuleShapedDirective(text))
|
|
367
|
-
continue;
|
|
368
|
-
out.push(text.trim());
|
|
369
|
-
}
|
|
370
|
-
return out;
|
|
371
|
-
}
|
|
372
|
-
function clusterDirectives(perSession, coverage, minRepeats) {
|
|
373
|
-
const acc = new Map();
|
|
374
|
-
for (const { session, directives } of perSession) {
|
|
375
|
-
for (const text of directives) {
|
|
376
|
-
const normalized = normalizeDirective(text);
|
|
377
|
-
if (!normalized)
|
|
378
|
-
continue;
|
|
379
|
-
const existing = acc.get(normalized);
|
|
380
|
-
if (existing) {
|
|
381
|
-
existing.count++;
|
|
382
|
-
existing.sessions.add(session.id);
|
|
383
|
-
if (session.timestamp > existing.lastSeen)
|
|
384
|
-
existing.lastSeen = session.timestamp;
|
|
385
|
-
if (text.length > existing.text.length)
|
|
386
|
-
existing.text = text;
|
|
387
|
-
}
|
|
388
|
-
else {
|
|
389
|
-
acc.set(normalized, {
|
|
390
|
-
text,
|
|
391
|
-
normalized,
|
|
392
|
-
count: 1,
|
|
393
|
-
sessions: new Set([session.id]),
|
|
394
|
-
lastSeen: session.timestamp,
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
const clusters = [];
|
|
400
|
-
for (const entry of acc.values()) {
|
|
401
|
-
if (entry.count < minRepeats)
|
|
402
|
-
continue;
|
|
403
|
-
// Everything reaching here cleared the repeat threshold. Suppression handles
|
|
404
|
-
// the case that used to make these labels lie — a proposal accepted from a
|
|
405
|
-
// previous run coming back as "not working" when nothing had happened
|
|
406
|
-
// since. By the time an item survives that filter, a match genuinely means
|
|
407
|
-
// you repeated yourself after the rule or skill already existed.
|
|
408
|
-
const match = matchCoverage(entry.text, coverage);
|
|
409
|
-
clusters.push({
|
|
410
|
-
key: `directive:${entry.normalized}`,
|
|
411
|
-
text: entry.text,
|
|
412
|
-
normalized: entry.normalized,
|
|
413
|
-
count: entry.count,
|
|
414
|
-
sessions: entry.sessions.size,
|
|
415
|
-
lastSeen: entry.lastSeen,
|
|
416
|
-
status: match.rule ? "restated" : match.skill ? "has-skill" : "new",
|
|
417
|
-
existingRule: match.rule,
|
|
418
|
-
existingSkill: match.skill,
|
|
419
|
-
previouslyDeclined: false,
|
|
420
|
-
});
|
|
421
|
-
}
|
|
422
|
-
return clusters.sort((a, b) => b.sessions - a.sessions || b.count - a.count || a.text.localeCompare(b.text));
|
|
423
|
-
}
|
|
424
|
-
/** Files a mutating tool touched, for the resolution summary. */
|
|
425
|
-
function editedFile(event) {
|
|
426
|
-
if (!["edit", "write", "multi_edit", "apply_patch"].includes(event.name))
|
|
427
|
-
return undefined;
|
|
428
|
-
const path = event.args?.path ?? event.args?.file_path ?? event.args?.filePath;
|
|
429
|
-
return typeof path === "string" ? path : undefined;
|
|
430
|
-
}
|
|
431
|
-
function extractFixes(perSession) {
|
|
432
|
-
const acc = new Map();
|
|
433
|
-
for (const { session, events } of perSession) {
|
|
434
|
-
for (let i = 0; i < events.length; i++) {
|
|
435
|
-
const failure = events[i];
|
|
436
|
-
if (failure.name !== "bash" || !failure.isError)
|
|
437
|
-
continue;
|
|
438
|
-
const command = typeof failure.args?.command === "string" ? failure.args.command : "";
|
|
439
|
-
if (!command || isBenignFailure(command))
|
|
440
|
-
continue;
|
|
441
|
-
const normalized = normalizeCommand(command);
|
|
442
|
-
const interveningCommands = [];
|
|
443
|
-
const editedFiles = [];
|
|
444
|
-
let resolved = false;
|
|
445
|
-
for (let j = i + 1; j < Math.min(events.length, i + 1 + FIX_LOOKAHEAD); j++) {
|
|
446
|
-
const next = events[j];
|
|
447
|
-
const file = editedFile(next);
|
|
448
|
-
if (file)
|
|
449
|
-
editedFiles.push(file);
|
|
450
|
-
if (next.name !== "bash")
|
|
451
|
-
continue;
|
|
452
|
-
const nextCommand = typeof next.args?.command === "string" ? next.args.command : "";
|
|
453
|
-
if (!nextCommand)
|
|
454
|
-
continue;
|
|
455
|
-
// The same command later succeeding is the only evidence that the
|
|
456
|
-
// problem was actually fixed. A *different* command passing says
|
|
457
|
-
// nothing, and neither does the model moving on.
|
|
458
|
-
if (normalizeCommand(nextCommand) === normalized && !next.isError) {
|
|
459
|
-
resolved = true;
|
|
460
|
-
break;
|
|
461
|
-
}
|
|
462
|
-
interveningCommands.push(nextCommand.trim());
|
|
463
|
-
}
|
|
464
|
-
if (!resolved)
|
|
465
|
-
continue;
|
|
466
|
-
const output = failure.output ?? "";
|
|
467
|
-
// An abort is the user changing their mind, not a problem that was
|
|
468
|
-
// solved, and empty output carries nothing to sign or show.
|
|
469
|
-
if (isUninformativeFailure(output))
|
|
470
|
-
continue;
|
|
471
|
-
// Sign the error region, not the whole output: build tools lead with an
|
|
472
|
-
// identical banner, so signing everything makes unrelated failures of
|
|
473
|
-
// the same command collide on their shared preamble.
|
|
474
|
-
const errorRegion = extractErrorRegion(output);
|
|
475
|
-
const signature = normalizeErrorSignature(errorRegion);
|
|
476
|
-
if (!signature)
|
|
477
|
-
continue;
|
|
478
|
-
const key = `${normalized}${signature}`;
|
|
479
|
-
const existing = acc.get(key);
|
|
480
|
-
if (existing) {
|
|
481
|
-
existing.candidate.count++;
|
|
482
|
-
existing.sessions.add(session.id);
|
|
483
|
-
if (session.timestamp > existing.candidate.lastSeen)
|
|
484
|
-
existing.candidate.lastSeen = session.timestamp;
|
|
485
|
-
}
|
|
486
|
-
else {
|
|
487
|
-
acc.set(key, {
|
|
488
|
-
sessions: new Set([session.id]),
|
|
489
|
-
candidate: {
|
|
490
|
-
key: `fix:${key}`,
|
|
491
|
-
command: normalized,
|
|
492
|
-
signature,
|
|
493
|
-
errorExcerpt: errorRegion.replace(/\s+/g, " ").trim().slice(0, 240),
|
|
494
|
-
interveningCommands: [...new Set(interveningCommands)].slice(0, 5),
|
|
495
|
-
editedFiles: [...new Set(editedFiles)].slice(0, 5),
|
|
496
|
-
count: 1,
|
|
497
|
-
sessions: 1,
|
|
498
|
-
lastSeen: session.timestamp,
|
|
499
|
-
},
|
|
500
|
-
});
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
const out = [];
|
|
505
|
-
for (const { candidate, sessions } of acc.values()) {
|
|
506
|
-
candidate.sessions = sessions.size;
|
|
507
|
-
out.push(candidate);
|
|
508
|
-
}
|
|
509
|
-
return out.sort((a, b) => b.count - a.count || b.sessions - a.sessions || a.signature.localeCompare(b.signature));
|
|
510
|
-
}
|
|
511
|
-
/** A tool call reduced to a comparable step: the tool, plus what a bash call runs. */
|
|
512
|
-
function stepSignature(event) {
|
|
513
|
-
if (event.name === "bash") {
|
|
514
|
-
const command = typeof event.args?.command === "string" ? event.args.command : "";
|
|
515
|
-
const head = commandHead(command);
|
|
516
|
-
return head ? `bash:${head}` : "bash";
|
|
517
|
-
}
|
|
518
|
-
return event.name;
|
|
519
|
-
}
|
|
520
405
|
/**
|
|
521
|
-
*
|
|
522
|
-
* doing. A sequence built only from these plus file edits describes "coding",
|
|
523
|
-
* not a workflow, and no useful skill has ever come out of one.
|
|
524
|
-
*/
|
|
525
|
-
const PLUMBING_COMMANDS = new Set([
|
|
526
|
-
"cd",
|
|
527
|
-
"ls",
|
|
528
|
-
"pwd",
|
|
529
|
-
"cat",
|
|
530
|
-
"head",
|
|
531
|
-
"tail",
|
|
532
|
-
"wc",
|
|
533
|
-
"echo",
|
|
534
|
-
"which",
|
|
535
|
-
"find",
|
|
536
|
-
"fd",
|
|
537
|
-
"grep",
|
|
538
|
-
"rg",
|
|
539
|
-
"sed",
|
|
540
|
-
"awk",
|
|
541
|
-
"git status",
|
|
542
|
-
"git diff",
|
|
543
|
-
"git log",
|
|
544
|
-
"git show",
|
|
545
|
-
]);
|
|
546
|
-
/**
|
|
547
|
-
* Whether a sequence is a procedure rather than the rhythm of editing code.
|
|
406
|
+
* Turn one context file into rule lines the coverage judge can reason about.
|
|
548
407
|
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
* and
|
|
408
|
+
* Headings were dropped and the lines under them sent bare, which asks the
|
|
409
|
+
* model to decide whether a proposal is in scope using text with the scope
|
|
410
|
+
* removed — "stage only your own files" reads very differently under "Git Rules
|
|
411
|
+
* for Parallel Agents" than on its own. So each line carries its heading path,
|
|
412
|
+
* and the scope it came from, since the corpus spans a repo file and two user
|
|
413
|
+
* ones and a rule's home decides who it binds.
|
|
555
414
|
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
* drops the rhythm. The cost is real — a genuine one-command routine with setup
|
|
559
|
-
* is missed — and that is the intended trade, since a missed skill costs nothing
|
|
560
|
-
* while a digest full of noise costs the reader's attention every run.
|
|
415
|
+
* Fenced blocks go: a code sample illustrates a rule, it is not one, and on a
|
|
416
|
+
* real file it is a large share of the non-bullet text.
|
|
561
417
|
*/
|
|
562
|
-
function
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
418
|
+
function ruleLinesOf(content, scope) {
|
|
419
|
+
const lines = [];
|
|
420
|
+
const headings = [];
|
|
421
|
+
let inFence = false;
|
|
422
|
+
for (const raw of content.split("\n")) {
|
|
423
|
+
const line = raw.trim();
|
|
424
|
+
if (line.startsWith("```")) {
|
|
425
|
+
inFence = !inFence;
|
|
566
426
|
continue;
|
|
567
|
-
const head = step.slice("bash:".length);
|
|
568
|
-
if (PLUMBING_COMMANDS.has(head) || PLUMBING_COMMANDS.has(head.split(" ")[0] ?? ""))
|
|
569
|
-
continue;
|
|
570
|
-
commands.add(head);
|
|
571
|
-
}
|
|
572
|
-
return commands.size >= 2;
|
|
573
|
-
}
|
|
574
|
-
/** True when `needle` appears as a contiguous run inside `haystack`. */
|
|
575
|
-
function containsSequence(haystack, needle) {
|
|
576
|
-
if (needle.length > haystack.length)
|
|
577
|
-
return false;
|
|
578
|
-
for (let i = 0; i + needle.length <= haystack.length; i++) {
|
|
579
|
-
if (needle.every((step, offset) => haystack[i + offset] === step))
|
|
580
|
-
return true;
|
|
581
|
-
}
|
|
582
|
-
return false;
|
|
583
|
-
}
|
|
584
|
-
function extractWorkflows(perSession, minRepeats) {
|
|
585
|
-
const acc = new Map();
|
|
586
|
-
for (const { session, events } of perSession) {
|
|
587
|
-
const steps = events.slice(0, MAX_TOOL_CALLS_PER_SESSION).map(stepSignature);
|
|
588
|
-
for (let len = WORKFLOW_MIN_LEN; len <= WORKFLOW_MAX_LEN; len++) {
|
|
589
|
-
// Collect every position first, then count greedily without overlap.
|
|
590
|
-
// Counting each sliding position separately treats one long stretch of
|
|
591
|
-
// edit/read churn as dozens of repeats: an `edit > read > edit` run of
|
|
592
|
-
// length 12 scores 10 occurrences when it is really one stretch of work.
|
|
593
|
-
const positions = new Map();
|
|
594
|
-
for (let i = 0; i + len <= steps.length; i++) {
|
|
595
|
-
const window = steps.slice(i, i + len);
|
|
596
|
-
// A run of one repeated tool is a loop, not a workflow.
|
|
597
|
-
if (new Set(window).size < 2)
|
|
598
|
-
continue;
|
|
599
|
-
if (!isProcedure(window))
|
|
600
|
-
continue;
|
|
601
|
-
const key = window.join(" > ");
|
|
602
|
-
const list = positions.get(key);
|
|
603
|
-
if (list)
|
|
604
|
-
list.push(i);
|
|
605
|
-
else
|
|
606
|
-
positions.set(key, [i]);
|
|
607
|
-
}
|
|
608
|
-
for (const [key, occurrences] of positions) {
|
|
609
|
-
let count = 0;
|
|
610
|
-
let nextFree = -1;
|
|
611
|
-
for (const start of occurrences) {
|
|
612
|
-
if (start < nextFree)
|
|
613
|
-
continue;
|
|
614
|
-
count++;
|
|
615
|
-
nextFree = start + len;
|
|
616
|
-
}
|
|
617
|
-
const existing = acc.get(key);
|
|
618
|
-
if (existing) {
|
|
619
|
-
existing.count += count;
|
|
620
|
-
existing.sessions.add(session.id);
|
|
621
|
-
if (session.timestamp > existing.lastSeen)
|
|
622
|
-
existing.lastSeen = session.timestamp;
|
|
623
|
-
}
|
|
624
|
-
else {
|
|
625
|
-
acc.set(key, {
|
|
626
|
-
steps: key.split(" > "),
|
|
627
|
-
count,
|
|
628
|
-
sessions: new Set([session.id]),
|
|
629
|
-
lastSeen: session.timestamp,
|
|
630
|
-
});
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
427
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
sessions: entry.sessions.size,
|
|
642
|
-
lastSeen: entry.lastSeen,
|
|
643
|
-
}))
|
|
644
|
-
// Sessions first, matching directives: a sequence seen in three sessions is
|
|
645
|
-
// a workflow, while one repeated ten times in a single session is usually
|
|
646
|
-
// just the shape of that one task.
|
|
647
|
-
.sort((a, b) => b.sessions - a.sessions ||
|
|
648
|
-
b.count - a.count ||
|
|
649
|
-
b.steps.length - a.steps.length ||
|
|
650
|
-
a.steps.join().localeCompare(b.steps.join()));
|
|
651
|
-
// Every n-gram overlaps its own extensions and prefixes, so without this the
|
|
652
|
-
// list is one workflow described five slightly different ways. The test runs
|
|
653
|
-
// both directions on purpose: a shorter sequence always outranks the longer
|
|
654
|
-
// one containing it (it occurs at least as often), so checking only
|
|
655
|
-
// shorter-inside-kept would never fire. Keep the best-ranked member of each
|
|
656
|
-
// family and drop the rest.
|
|
657
|
-
const distinct = [];
|
|
658
|
-
for (const candidate of ranked) {
|
|
659
|
-
const overlapsKept = distinct.some((kept) => containsSequence(kept.steps, candidate.steps) || containsSequence(candidate.steps, kept.steps));
|
|
660
|
-
if (overlapsKept)
|
|
428
|
+
if (inFence || line.length === 0)
|
|
429
|
+
continue;
|
|
430
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
431
|
+
if (heading) {
|
|
432
|
+
const depth = heading[1]?.length ?? 1;
|
|
433
|
+
headings.length = Math.min(headings.length, depth - 1);
|
|
434
|
+
headings[depth - 1] = heading[2] ?? "";
|
|
661
435
|
continue;
|
|
662
|
-
distinct.push(candidate);
|
|
663
|
-
}
|
|
664
|
-
return distinct;
|
|
665
|
-
}
|
|
666
|
-
/**
|
|
667
|
-
* Where a piece of text is already written down, if anywhere.
|
|
668
|
-
*
|
|
669
|
-
* A rule wins over a skill when both match: it is the more specific answer, and
|
|
670
|
-
* "rewrite this line" is more actionable than "sharpen a description".
|
|
671
|
-
*/
|
|
672
|
-
export function matchCoverage(text, index) {
|
|
673
|
-
const words = contentWords(text);
|
|
674
|
-
let bestLine;
|
|
675
|
-
let bestOverlap = 0;
|
|
676
|
-
for (const line of index.ruleLines) {
|
|
677
|
-
const overlap = wordOverlap(words, line);
|
|
678
|
-
if (overlap > bestOverlap) {
|
|
679
|
-
bestOverlap = overlap;
|
|
680
|
-
bestLine = line;
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
if (bestOverlap >= COVERED_OVERLAP)
|
|
684
|
-
return { rule: bestLine };
|
|
685
|
-
let bestSkill;
|
|
686
|
-
let bestSkillOverlap = 0;
|
|
687
|
-
for (const skill of index.skills) {
|
|
688
|
-
const haystack = `${skill.name} ${skill.description.slice(0, SKILL_DESCRIPTION_CHARS)}`;
|
|
689
|
-
const overlap = wordOverlap(words, haystack);
|
|
690
|
-
if (overlap > bestSkillOverlap) {
|
|
691
|
-
bestSkillOverlap = overlap;
|
|
692
|
-
bestSkill = skill.name;
|
|
693
436
|
}
|
|
437
|
+
const path = headings.filter(Boolean).join(" > ");
|
|
438
|
+
lines.push(path ? `[${scope}] ${path} > ${line}` : `[${scope}] ${line}`);
|
|
694
439
|
}
|
|
695
|
-
|
|
696
|
-
return { skill: bestSkill };
|
|
697
|
-
return {};
|
|
440
|
+
return lines;
|
|
698
441
|
}
|
|
699
442
|
/** Assemble the coverage index for a directory. */
|
|
700
443
|
export function buildCoverageIndex(options) {
|
|
701
|
-
const
|
|
702
|
-
|
|
703
|
-
ruleLines
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
.filter((line) => line.length > 0 && !line.startsWith("#")),
|
|
707
|
-
skills: options.skills ?? loadSkillIndex(options.cwd, options.agentDir),
|
|
708
|
-
};
|
|
444
|
+
const ruleLines = [];
|
|
445
|
+
for (const file of coverageFiles(options.agentDir, findAgentsFile(options.cwd))) {
|
|
446
|
+
ruleLines.push(...ruleLinesOf(file.content, file.scope));
|
|
447
|
+
}
|
|
448
|
+
return { ruleLines, skills: options.skills ?? loadSkillIndex(options.cwd, options.agentDir) };
|
|
709
449
|
}
|
|
710
450
|
/**
|
|
711
451
|
* Text a proposal is checked against to decide whether it is already written
|
|
@@ -715,19 +455,24 @@ export function buildCoverageIndex(options) {
|
|
|
715
455
|
* user scope. Checking only the repo file would report a rule you accepted into
|
|
716
456
|
* `~/.agents/AGENTS.md` as declined.
|
|
717
457
|
*/
|
|
718
|
-
function
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
458
|
+
function coverageFiles(agentDir, repoFile) {
|
|
459
|
+
const files = [];
|
|
460
|
+
const candidates = [
|
|
461
|
+
{ scope: "repo", path: repoFile },
|
|
462
|
+
{ scope: "user", path: join(getUserAgentsDir(), "AGENTS.md") },
|
|
463
|
+
{ scope: "user", path: join(agentDir, "AGENTS.md") },
|
|
464
|
+
];
|
|
465
|
+
for (const candidate of candidates) {
|
|
466
|
+
if (!candidate.path || !existsSync(candidate.path))
|
|
722
467
|
continue;
|
|
723
468
|
try {
|
|
724
|
-
|
|
469
|
+
files.push({ scope: candidate.scope, content: readFileSync(candidate.path, "utf-8") });
|
|
725
470
|
}
|
|
726
471
|
catch {
|
|
727
472
|
// Unreadable context file: treat as absent rather than failing the run.
|
|
728
473
|
}
|
|
729
474
|
}
|
|
730
|
-
return
|
|
475
|
+
return files;
|
|
731
476
|
}
|
|
732
477
|
/**
|
|
733
478
|
* Skills a proposal could already have become.
|
|
@@ -750,8 +495,80 @@ function loadSkillIndex(cwd, agentDir) {
|
|
|
750
495
|
return [];
|
|
751
496
|
}
|
|
752
497
|
}
|
|
498
|
+
/**
|
|
499
|
+
* Run the miner over the window, reusing cached results wherever the file has
|
|
500
|
+
* not changed.
|
|
501
|
+
*
|
|
502
|
+
* A session that fails to mine is counted and skipped rather than aborting the
|
|
503
|
+
* run: one provider hiccup on one transcript should cost that transcript's
|
|
504
|
+
* signals, not the whole digest. The failure count is reported so the reader
|
|
505
|
+
* knows the numbers are short.
|
|
506
|
+
*
|
|
507
|
+
* Cancellation is different from failure and is reported separately. A run
|
|
508
|
+
* stopped half way has counted only some of the window, so its numbers are not
|
|
509
|
+
* merely short — they are wrong in a way that would poison the bookmark if the
|
|
510
|
+
* digest were treated as a completed run.
|
|
511
|
+
*/
|
|
512
|
+
async function mineSessions(sessions, options) {
|
|
513
|
+
const mined = [];
|
|
514
|
+
const report = { cached: 0, mined: 0, failed: 0 };
|
|
515
|
+
let done = 0;
|
|
516
|
+
for (const session of sessions) {
|
|
517
|
+
if (options.signal?.aborted)
|
|
518
|
+
return { mined, report, aborted: true };
|
|
519
|
+
const hash = hashSessionFile(session.file);
|
|
520
|
+
const cached = hash ? readCachedMining(options.agentDir, hash) : undefined;
|
|
521
|
+
if (cached) {
|
|
522
|
+
mined.push({ sessionId: session.id, lastActivity: session.lastActivity, candidates: cached.candidates });
|
|
523
|
+
report.cached++;
|
|
524
|
+
done++;
|
|
525
|
+
options.onProgress?.({ done, total: sessions.length, cached: report.cached });
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const minable = { id: session.id, timestamp: session.timestamp, entries: session.entries };
|
|
529
|
+
try {
|
|
530
|
+
const candidates = await options.miner(minable, options.signal);
|
|
531
|
+
mined.push({ sessionId: session.id, lastActivity: session.lastActivity, candidates });
|
|
532
|
+
report.mined++;
|
|
533
|
+
if (hash) {
|
|
534
|
+
writeCachedMining(options.agentDir, hash, {
|
|
535
|
+
sessionId: session.id,
|
|
536
|
+
timestamp: session.timestamp,
|
|
537
|
+
candidates,
|
|
538
|
+
minedAt: new Date().toISOString(),
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
// A cancelled request surfaces here as a rejection. That is not the
|
|
544
|
+
// provider failing on this transcript, so it must not be counted as one.
|
|
545
|
+
if (options.signal?.aborted)
|
|
546
|
+
return { mined, report, aborted: true };
|
|
547
|
+
report.failed++;
|
|
548
|
+
}
|
|
549
|
+
done++;
|
|
550
|
+
options.onProgress?.({ done, total: sessions.length, cached: report.cached });
|
|
551
|
+
}
|
|
552
|
+
return { mined, report, aborted: false };
|
|
553
|
+
}
|
|
554
|
+
/** Apply the coverage verdicts to the clusters they were asked about. */
|
|
555
|
+
function applyCoverage(directives, verdicts) {
|
|
556
|
+
for (const cluster of directives) {
|
|
557
|
+
const verdict = verdicts.get(cluster.label);
|
|
558
|
+
if (!verdict)
|
|
559
|
+
continue;
|
|
560
|
+
if (verdict.rule) {
|
|
561
|
+
cluster.status = "restated";
|
|
562
|
+
cluster.existingRule = verdict.rule;
|
|
563
|
+
}
|
|
564
|
+
else if (verdict.skill) {
|
|
565
|
+
cluster.status = "has-skill";
|
|
566
|
+
cluster.existingSkill = verdict.skill;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
753
570
|
/** Mine the recent sessions for this cwd and return the ranked digest. */
|
|
754
|
-
export function
|
|
571
|
+
export async function mineLearnDigest(options) {
|
|
755
572
|
const { sessions, skipped, scan } = listSessions(options);
|
|
756
573
|
const agentsFilePath = findAgentsFile(options.cwd);
|
|
757
574
|
let agentsContent;
|
|
@@ -763,39 +580,83 @@ export function extractLearnDigest(options) {
|
|
|
763
580
|
agentsContent = undefined;
|
|
764
581
|
}
|
|
765
582
|
}
|
|
583
|
+
const { mined, report, aborted } = await mineSessions(sessions, options);
|
|
584
|
+
pruneLearnCache(options.agentDir, options.now);
|
|
585
|
+
const state = options.ignoreState ? undefined : options.state;
|
|
586
|
+
// Named against the labels already on record — including in `all` mode, where
|
|
587
|
+
// suppression is off but the bookmark still has to line up next run.
|
|
588
|
+
const labelled = await labelSessions(mined, options.clusterer, knownLabelsFrom(options.state), options.signal);
|
|
589
|
+
const minRepeats = options.minRepeats ?? DEFAULT_MIN_DIRECTIVE_COUNT;
|
|
590
|
+
const maxProposals = options.maxProposals ?? DEFAULT_MAX_PER_CATEGORY;
|
|
591
|
+
const directives = reduceDirectives(labelled, minRepeats);
|
|
592
|
+
const fixes = reduceFixes(labelled, minRepeats);
|
|
593
|
+
const requests = reduceRequests(labelled, options.minRequestRepeats ?? DEFAULT_MIN_REQUEST_COUNT);
|
|
594
|
+
// Counted with the threshold at 1, which is the same reduce over the same
|
|
595
|
+
// input — so the difference is exactly what the threshold cost, rather than an
|
|
596
|
+
// estimate of it.
|
|
597
|
+
const everyPoint = reduceDirectives(labelled, 1).length + reduceFixes(labelled, 1).length + reduceRequests(labelled, 1).length;
|
|
598
|
+
const funnel = {
|
|
599
|
+
candidates: labelled.reduce((sum, session) => sum + session.candidates.length, 0),
|
|
600
|
+
points: everyPoint,
|
|
601
|
+
belowThreshold: everyPoint - directives.length - fixes.length - requests.length,
|
|
602
|
+
};
|
|
603
|
+
// Coverage is asked only about what survived the repeat threshold. Judging
|
|
604
|
+
// everything would mean sending the context file alongside a long tail of
|
|
605
|
+
// one-off observations that are never going to be proposed.
|
|
766
606
|
const coverage = buildCoverageIndex({ cwd: options.cwd, agentDir: options.agentDir, skills: options.skills });
|
|
767
|
-
const
|
|
768
|
-
|
|
607
|
+
const queries = directives.map((d) => ({ label: d.label, text: d.text }));
|
|
608
|
+
let coverageFailed = false;
|
|
609
|
+
try {
|
|
610
|
+
const verdicts = await (options.coverageJudge ?? noCoverageJudge)(queries, coverage, options.signal);
|
|
611
|
+
applyCoverage(directives, verdicts);
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
// A failed coverage call leaves everything `new`, which over-proposes
|
|
615
|
+
// slightly. That is the right way to fail: the reader can reject a
|
|
616
|
+
// duplicate, but cannot recover a proposal that was wrongly withheld. What
|
|
617
|
+
// must not happen is writing that guess down as if it were a reading.
|
|
618
|
+
coverageFailed = true;
|
|
619
|
+
}
|
|
769
620
|
const timestamps = sessions.map((s) => s.timestamp).sort();
|
|
770
|
-
const state = options.ignoreState ? undefined : options.state;
|
|
771
621
|
// Directives carry a real coverage signal — is this written down as a rule or
|
|
772
622
|
// a skill right now? — which is what separates an adopted proposal from a
|
|
773
|
-
// declined one. Fixes and
|
|
623
|
+
// declined one. Fixes and requests do not: a fix may have become a rule, a
|
|
774
624
|
// skill, or a habit, and which one is not recoverable here, so they get
|
|
775
625
|
// suppression only and are never labelled declined.
|
|
776
|
-
const
|
|
777
|
-
const directives = applySuppression(clusterDirectives(withDirectives, coverage, options.minRepeats ?? DEFAULT_MIN_DIRECTIVE_COUNT), state, maxProposals, (item) => item.status !== "new", (item) => {
|
|
626
|
+
const keptDirectives = applySuppression(directives, state, maxProposals, (item) => item.status !== "new", (item) => {
|
|
778
627
|
item.previouslyDeclined = true;
|
|
779
628
|
});
|
|
780
|
-
const
|
|
781
|
-
const
|
|
629
|
+
const keptFixes = applySuppression(fixes, state, maxProposals, () => false);
|
|
630
|
+
const keptRequests = applySuppression(requests, state, maxProposals, () => false);
|
|
782
631
|
const surfaced = [
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
...
|
|
632
|
+
// Directives carry their wording forward so a later `/learn stats` can ask
|
|
633
|
+
// about coverage using the sentence rather than the slug that names it.
|
|
634
|
+
...keptDirectives.kept.map((d) => ({
|
|
635
|
+
key: d.key,
|
|
636
|
+
lastSeen: d.lastSeen,
|
|
637
|
+
covered: d.status !== "new",
|
|
638
|
+
text: d.text,
|
|
639
|
+
})),
|
|
640
|
+
...keptFixes.kept.map((f) => ({ key: f.key, lastSeen: f.lastSeen, covered: false })),
|
|
641
|
+
...keptRequests.kept.map((r) => ({ key: r.key, lastSeen: r.lastSeen, covered: false })),
|
|
786
642
|
];
|
|
787
643
|
return {
|
|
788
644
|
scannedSessions: sessions.length,
|
|
789
645
|
skippedSessions: skipped,
|
|
790
646
|
scan,
|
|
647
|
+
mining: report,
|
|
648
|
+
aborted,
|
|
649
|
+
coverageFailed,
|
|
791
650
|
oldestSession: timestamps[0],
|
|
792
651
|
newestSession: timestamps[timestamps.length - 1],
|
|
793
652
|
agentsFilePath,
|
|
794
653
|
agentsFileTokens: agentsContent === undefined ? undefined : Math.round(Buffer.byteLength(agentsContent, "utf-8") / 4),
|
|
795
|
-
directives:
|
|
796
|
-
fixes:
|
|
797
|
-
|
|
798
|
-
suppressed:
|
|
654
|
+
directives: keptDirectives.kept,
|
|
655
|
+
fixes: keptFixes.kept,
|
|
656
|
+
requests: keptRequests.kept,
|
|
657
|
+
suppressed: keptDirectives.suppressed + keptFixes.suppressed + keptRequests.suppressed,
|
|
658
|
+
cut: keptDirectives.cut + keptFixes.cut + keptRequests.cut,
|
|
659
|
+
funnel,
|
|
799
660
|
surfaced,
|
|
800
661
|
};
|
|
801
662
|
}
|