@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
|
@@ -2,34 +2,58 @@
|
|
|
2
2
|
* `/learn` — promote what recent sessions actually taught into durable rules
|
|
3
3
|
* and skills.
|
|
4
4
|
*
|
|
5
|
-
* The command is a thin shell on purpose. It runs the
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* The command is a thin shell on purpose. It runs the mining pipeline over
|
|
6
|
+
* session transcripts on disk, renders the ranked result, and injects it as a
|
|
7
|
+
* follow-up message; every judgement after that belongs to the model, which can
|
|
8
|
+
* read the repo and phrase a rule far better than a heuristic can.
|
|
9
9
|
*
|
|
10
10
|
* Reading transcripts from disk rather than the live context is what makes this
|
|
11
11
|
* work: the on-disk history survives compaction, and it spans past sessions, so
|
|
12
12
|
* "you have said this in five separate sessions" is available as a number
|
|
13
13
|
* instead of a guess. That number is the whole reason the command exists.
|
|
14
14
|
*
|
|
15
|
+
* The pipeline reads every transcript with a model rather than pre-filtering
|
|
16
|
+
* with regexes, which costs real tokens on a cold cache. That price is stated
|
|
17
|
+
* before it is paid, never inferred: a run with sessions to read asks first.
|
|
18
|
+
*
|
|
15
19
|
* Follows /grill in modes.ts: no session switch, no mode change, no config
|
|
16
20
|
* write — just a follow-up message. Writes to AGENTS.md happen through ordinary
|
|
17
21
|
* edit tools, so the existing permission prompt is the approval step and no
|
|
18
22
|
* separate picker is needed.
|
|
19
23
|
*/
|
|
24
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
25
|
import { homedir } from "node:os";
|
|
21
26
|
import { join } from "node:path";
|
|
22
27
|
import { CONFIG_DIR_NAME, getHooCodeDir } from "../../config.js";
|
|
23
28
|
import { loadProjectContextFiles } from "../../core/context-files.js";
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
29
|
+
import { auditContextFiles, staleTokens } from "../../core/learn/audit.js";
|
|
30
|
+
import { createLlmClusterer } from "../../core/learn/cluster.js";
|
|
31
|
+
import { createLlmCoverageJudge } from "../../core/learn/coverage.js";
|
|
32
|
+
import { isEmptyDigest, renderAuditReport, renderLearnDigest } from "../../core/learn/digest.js";
|
|
33
|
+
import { mineLearnDigest, planMining, scanSessions, } from "../../core/learn/extract.js";
|
|
34
|
+
import { chunkCharsForModel, createLlmMiner, replayFingerprints } from "../../core/learn/mine.js";
|
|
26
35
|
import { getLearnStatePath, readLearnState, recordSurfaced, summarizeLearnState, writeLearnState, } from "../../core/learn/state.js";
|
|
36
|
+
import { resolveModelCategory } from "../../core/model-categories.js";
|
|
27
37
|
import { getSessionDirPath } from "../../core/session-manager.js";
|
|
28
38
|
import { SettingsManager } from "../../core/settings-manager.js";
|
|
39
|
+
import { startupProgress } from "../../core/startup-progress.js";
|
|
29
40
|
/** Guards against double-registration when default extensions load more than once. */
|
|
30
41
|
const REGISTERED = Symbol.for("hoocode.learn.registered");
|
|
31
42
|
/** User-scope destination offered for personal rules that travel across repos. */
|
|
32
43
|
const USER_SCOPE_PATH = join(homedir(), ".agents", "AGENTS.md");
|
|
44
|
+
/** Footer key for the mining progress bar. */
|
|
45
|
+
const PROGRESS_KEY = "learn-mining";
|
|
46
|
+
/** Escape, the way a raw terminal delivers it. */
|
|
47
|
+
const ESCAPE = "\x1b";
|
|
48
|
+
/**
|
|
49
|
+
* Sessions that can be read without asking first.
|
|
50
|
+
*
|
|
51
|
+
* A run that has one or two new transcripts to read is the normal daily case
|
|
52
|
+
* and interrupting it to confirm a trivial cost is noise. Beyond this the run
|
|
53
|
+
* is a backfill — onboarding to an existing repo, or a first run — and the
|
|
54
|
+
* reader should get to decide before it starts.
|
|
55
|
+
*/
|
|
56
|
+
const CONFIRM_ABOVE_PENDING = 3;
|
|
33
57
|
/** Render a home-relative path the way the user would type it. */
|
|
34
58
|
function displayPath(path) {
|
|
35
59
|
const home = homedir();
|
|
@@ -46,8 +70,8 @@ const SETTING_KEYS = [
|
|
|
46
70
|
{ key: "maxAgeDays", setting: "learnMaxAgeDays", note: "ignore sessions older than this, in days" },
|
|
47
71
|
{ key: "minRepeats", setting: "learnMinRepeats", note: "times a directive must recur to be proposed" },
|
|
48
72
|
{
|
|
49
|
-
key: "
|
|
50
|
-
setting: "
|
|
73
|
+
key: "minRequestRepeats",
|
|
74
|
+
setting: "learnMinRequestRepeats",
|
|
51
75
|
note: "repeats before a tool sequence is proposed",
|
|
52
76
|
},
|
|
53
77
|
{ key: "maxProposals", setting: "learnMaxProposals", note: "cap on each list in the digest" },
|
|
@@ -55,7 +79,7 @@ const SETTING_KEYS = [
|
|
|
55
79
|
/**
|
|
56
80
|
* Where the knobs live, and what they are set to.
|
|
57
81
|
*
|
|
58
|
-
* `/learn` has
|
|
82
|
+
* `/learn` has its settings and no UI, so until this existed the only way to
|
|
59
83
|
* find them was to already know they were in `settings.json`. Every message that
|
|
60
84
|
* reports a disappointing result names a threshold, so every one of them ends
|
|
61
85
|
* with these lines.
|
|
@@ -70,7 +94,7 @@ function settingsPathLines(ctx, agentDir) {
|
|
|
70
94
|
function settingsLines(ctx, agentDir, window) {
|
|
71
95
|
const lines = settingsPathLines(ctx, agentDir);
|
|
72
96
|
for (const { key, setting, note } of SETTING_KEYS) {
|
|
73
|
-
lines.push(` ${setting.padEnd(24)} ${String(window[key]).padStart(3)} ${note}`);
|
|
97
|
+
lines.push(` ${setting.padEnd(24)} ${String(window[key] ?? "—").padStart(3)} ${note}`);
|
|
74
98
|
}
|
|
75
99
|
return lines;
|
|
76
100
|
}
|
|
@@ -86,7 +110,7 @@ function settingsLines(ctx, agentDir, window) {
|
|
|
86
110
|
function stateKeyDir(ctx, agentDir) {
|
|
87
111
|
return getSessionDirPath(ctx.cwd, agentDir);
|
|
88
112
|
}
|
|
89
|
-
/** Run the directory scan without
|
|
113
|
+
/** Run the directory scan without mining anything, for the reports that only need counts. */
|
|
90
114
|
function sessionScanPreview(ctx, agentDir, window) {
|
|
91
115
|
return scanSessions({
|
|
92
116
|
cwd: ctx.cwd,
|
|
@@ -137,15 +161,112 @@ function reportNoSessions(ctx, agentDir, digest, window) {
|
|
|
137
161
|
ctx.ui.notify(lines.join("\n"), "warning");
|
|
138
162
|
}
|
|
139
163
|
/**
|
|
140
|
-
*
|
|
164
|
+
* The model that reads transcripts.
|
|
165
|
+
*
|
|
166
|
+
* This is the one call in the pipeline that reads *everything*, so it wants the
|
|
167
|
+
* cheapest capable model rather than the session's. That question already has an
|
|
168
|
+
* answer in this codebase — the `fast` model category, which subagents use for
|
|
169
|
+
* exactly this kind of bulk read — so it is reused rather than reinvented.
|
|
170
|
+
* `settings.modelCategories.fast` wins when set; otherwise the tier is derived
|
|
171
|
+
* from the user's available models, and nothing here is provider-specific.
|
|
172
|
+
*
|
|
173
|
+
* Falls back to the session model when the tier resolves to nothing or to a
|
|
174
|
+
* model the registry cannot find, since a mis-set tier should not take the
|
|
175
|
+
* command out entirely.
|
|
176
|
+
*/
|
|
177
|
+
function resolveMinerModel(ctx, settings) {
|
|
178
|
+
const ref = resolveModelCategory("fast", {
|
|
179
|
+
modelCategories: settings.getModelCategories(),
|
|
180
|
+
defaultProvider: settings.getDefaultProvider(),
|
|
181
|
+
defaultModel: settings.getDefaultModel(),
|
|
182
|
+
}, ctx.modelRegistry.getAvailable());
|
|
183
|
+
if (!ref)
|
|
184
|
+
return ctx.model;
|
|
185
|
+
const slash = ref.indexOf("/");
|
|
186
|
+
const found = slash > 0 ? ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1)) : undefined;
|
|
187
|
+
return found ?? ctx.model;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Literal runs from the slash commands in force, so the miner can tell a
|
|
191
|
+
* command body replaying itself from something the user typed.
|
|
192
|
+
*
|
|
193
|
+
* Read from the session's own command list rather than re-deriving the search
|
|
194
|
+
* path: which directories are scanned, in which order, and which flags disable
|
|
195
|
+
* them is a precedence list that lives in one place and would drift the moment
|
|
196
|
+
* it lived in two.
|
|
197
|
+
*/
|
|
198
|
+
function loadReplayFingerprints(pi) {
|
|
199
|
+
const bodies = [];
|
|
200
|
+
for (const command of pi.getCommands()) {
|
|
201
|
+
const path = command.sourceInfo?.path;
|
|
202
|
+
// A built-in has no file behind it, and nothing to replay.
|
|
203
|
+
if (!path || !existsSync(path))
|
|
204
|
+
continue;
|
|
205
|
+
try {
|
|
206
|
+
bodies.push({ content: readFileSync(path, "utf-8") });
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// Unreadable command file: one fewer fingerprint, not a failed run.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return replayFingerprints(bodies);
|
|
213
|
+
}
|
|
214
|
+
/** Build the two model-backed stages, or report why they cannot be built. */
|
|
215
|
+
async function buildPipeline(ctx, settings,
|
|
216
|
+
/** Empty for callers that only need the coverage judge; mining wants the real set. */
|
|
217
|
+
fingerprints = []) {
|
|
218
|
+
const model = resolveMinerModel(ctx, settings);
|
|
219
|
+
if (!model) {
|
|
220
|
+
return {
|
|
221
|
+
error: "/learn reads session transcripts with a model, and no model is selected. Pick one with /model, then run /learn again.",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
225
|
+
if (!auth.ok) {
|
|
226
|
+
return { error: `/learn could not authenticate ${model.provider}/${model.id}: ${auth.error}` };
|
|
227
|
+
}
|
|
228
|
+
const deps = {
|
|
229
|
+
model,
|
|
230
|
+
apiKey: auth.apiKey,
|
|
231
|
+
headers: auth.headers,
|
|
232
|
+
replayFingerprints: fingerprints,
|
|
233
|
+
};
|
|
234
|
+
return {
|
|
235
|
+
miner: createLlmMiner(deps),
|
|
236
|
+
clusterer: createLlmClusterer(deps),
|
|
237
|
+
coverageJudge: createLlmCoverageJudge(deps),
|
|
238
|
+
model,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* What this run still owes the model.
|
|
243
|
+
*
|
|
244
|
+
* Delegated to `planMining` so the number quoted by the confirmation prompt
|
|
245
|
+
* comes from the same session selection the run will use — same window, same
|
|
246
|
+
* cwd check, same de-duplication.
|
|
247
|
+
*/
|
|
248
|
+
function pendingWork(ctx, agentDir, window) {
|
|
249
|
+
return planMining({
|
|
250
|
+
cwd: ctx.cwd,
|
|
251
|
+
agentDir,
|
|
252
|
+
sessionDir: ctx.sessionManager.getSessionDir(),
|
|
253
|
+
maxSessions: window.maxSessions,
|
|
254
|
+
maxAgeDays: window.maxAgeDays,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* `/learn stats` — what has been proposed here, and what it costs.
|
|
141
259
|
*
|
|
142
|
-
* Reads the state file and
|
|
143
|
-
*
|
|
144
|
-
*
|
|
260
|
+
* Reads the state file and the context files. No model call: this used to
|
|
261
|
+
* re-judge coverage and report an "adoption rate", which was unreliable in both
|
|
262
|
+
* directions and shipped with two disclaimers explaining how not to misread it.
|
|
263
|
+
* The honest version of the question it was trying to answer — is the
|
|
264
|
+
* always-loaded surface growing — is a number the filesystem can answer exactly.
|
|
145
265
|
*/
|
|
146
266
|
function reportStats(ctx) {
|
|
147
267
|
const agentDir = getHooCodeDir();
|
|
148
|
-
const
|
|
268
|
+
const settings = SettingsManager.create(ctx.cwd, agentDir);
|
|
269
|
+
const window = settings.getLearnSettings();
|
|
149
270
|
const statePath = getLearnStatePath(agentDir, stateKeyDir(ctx, agentDir));
|
|
150
271
|
const state = readLearnState(statePath);
|
|
151
272
|
if (Object.keys(state.surfaced).length === 0) {
|
|
@@ -162,50 +283,85 @@ function reportStats(ctx) {
|
|
|
162
283
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
163
284
|
return;
|
|
164
285
|
}
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
return !!(match.rule || match.skill);
|
|
169
|
-
});
|
|
170
|
-
const contextTokens = loadProjectContextFiles({ cwd: ctx.cwd, agentDir }).agentsFiles.reduce((sum, file) => sum + (file.tokens ?? 0), 0);
|
|
286
|
+
const stats = summarizeLearnState(state);
|
|
287
|
+
const contextFiles = loadProjectContextFiles({ cwd: ctx.cwd, agentDir }).agentsFiles;
|
|
288
|
+
const contextTokens = contextFiles.reduce((sum, file) => sum + (file.tokens ?? 0), 0);
|
|
171
289
|
const lines = [];
|
|
172
290
|
lines.push(`/learn history for this directory — ${shortDate(stats.earliest)} to ${shortDate(stats.latest)}`);
|
|
173
|
-
lines.push(` Proposals shown ${stats.total} (${stats.directives} directive, ${stats.fixes} fix, ${stats.
|
|
291
|
+
lines.push(` Proposals shown ${stats.total} (${stats.directives} directive, ${stats.fixes} fix, ${stats.requests} request)`);
|
|
174
292
|
if (stats.lastRun)
|
|
175
293
|
lines.push(` Last run ${shortDate(stats.lastRun)}`);
|
|
176
294
|
lines.push("");
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
lines.push("Directive adoption — the only category with a coverage signal");
|
|
183
|
-
lines.push(` Written down ${stats.adopted} of ${stats.open} (${rate}%)`);
|
|
184
|
-
lines.push(` Passed over ${stats.declined}`);
|
|
185
|
-
lines.push("");
|
|
186
|
-
// Without this the number invites the wrong conclusion. Adoption is a proxy
|
|
187
|
-
// for usefulness, and a proposal correctly rejected as not durable counts
|
|
188
|
-
// against it exactly like a junk one — so near-100% means the bar is too
|
|
189
|
-
// low, not that the extractor is perfect.
|
|
190
|
-
lines.push(" A very high rate means the bar is too low, not that every proposal was good.");
|
|
191
|
-
lines.push(" Near zero means the extractor is proposing the wrong things.");
|
|
295
|
+
// The one number worth watching, and the only one here that is exact. Mining
|
|
296
|
+
// can only push it up; `/learn stale` is what pushes it down.
|
|
297
|
+
lines.push(`Always-loaded cost ~${contextTokens} tokens across ${contextFiles.length} context file(s)`);
|
|
298
|
+
for (const file of contextFiles) {
|
|
299
|
+
lines.push(` ~${file.tokens ?? 0} ${displayPath(file.path)}`);
|
|
192
300
|
}
|
|
301
|
+
lines.push(" Run /learn stale to find lines naming something that no longer exists.");
|
|
193
302
|
lines.push("");
|
|
194
|
-
lines.push(`Context files ~${contextTokens} tokens, re-sent every request`);
|
|
195
303
|
lines.push(`State file ${displayPath(statePath)}`);
|
|
196
304
|
lines.push("");
|
|
197
305
|
lines.push(...settingsPathLines(ctx, agentDir));
|
|
198
306
|
lines.push(" Run /learn settings for the thresholds in force.");
|
|
199
307
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
200
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* `/learn stale` — which lines in the context files name something that is gone.
|
|
311
|
+
*
|
|
312
|
+
* The mining path can only propose additions, so this is the only half of the
|
|
313
|
+
* command that moves the always-loaded token surface down. It is deterministic
|
|
314
|
+
* and costs nothing, which is what makes it the half worth running often; the
|
|
315
|
+
* findings go to the model only when there are some, so a clean audit is free.
|
|
316
|
+
*/
|
|
317
|
+
function reportAudit(pi, ctx) {
|
|
318
|
+
const agentDir = getHooCodeDir();
|
|
319
|
+
const { agentsFiles } = loadProjectContextFiles({ cwd: ctx.cwd, agentDir });
|
|
320
|
+
if (agentsFiles.length === 0) {
|
|
321
|
+
ctx.ui.notify("/learn stale found no context files to check (no AGENTS.md or CLAUDE.md is in force).", "warning");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const report = auditContextFiles({ cwd: ctx.cwd, files: agentsFiles });
|
|
325
|
+
if (report.files.length === 0) {
|
|
326
|
+
const lines = ["/learn stale checked nothing — every context file in force is outside this working tree."];
|
|
327
|
+
for (const path of report.skippedFiles)
|
|
328
|
+
lines.push(` ${displayPath(path)}`);
|
|
329
|
+
lines.push("A rule written in a user-scope file names paths in whatever repo it was written for, not this one.");
|
|
330
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const totalTokens = report.files.reduce((sum, file) => sum + file.tokens, 0);
|
|
334
|
+
if (report.stale.length === 0) {
|
|
335
|
+
ctx.ui.notify(`/learn stale — ${report.checked} referent(s) in ${report.files.length} context file(s) all resolve. ` +
|
|
336
|
+
`~${totalTokens} tokens, re-sent every request.`, "info");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
ctx.ui.notify(`/learn stale — ${report.stale.length} of ${report.checked} referent(s) do not resolve ` +
|
|
340
|
+
`(~${staleTokens(report)} of ~${totalTokens} always-loaded tokens).`, "info");
|
|
341
|
+
pi.sendUserMessage(renderAuditReport(report), { deliverAs: "followUp" });
|
|
342
|
+
}
|
|
201
343
|
/** `/learn settings` — the knobs, their current values, and the files to set them in. */
|
|
202
344
|
function reportSettings(ctx) {
|
|
203
345
|
const agentDir = getHooCodeDir();
|
|
204
|
-
const
|
|
346
|
+
const settings = SettingsManager.create(ctx.cwd, agentDir);
|
|
347
|
+
const window = settings.getLearnSettings();
|
|
205
348
|
const lines = settingsLines(ctx, agentDir, window);
|
|
349
|
+
// The reading model is not a `/learn` setting — it is the shared `fast` tier,
|
|
350
|
+
// so name it here rather than leaving the reader to guess which model is
|
|
351
|
+
// about to read their history, and point at the setting that changes it.
|
|
352
|
+
const model = resolveMinerModel(ctx, settings);
|
|
353
|
+
lines.push(` reads transcripts with ${model ? `${model.provider}/${model.id}` : "no model selected"}` +
|
|
354
|
+
` (the \`fast\` tier — set modelCategories.fast to change it)`);
|
|
355
|
+
if (model) {
|
|
356
|
+
lines.push(` ${Math.round(chunkCharsForModel(model) / 1000)}k characters per call, from its ${model.contextWindow} token window`);
|
|
357
|
+
}
|
|
206
358
|
lines.push("");
|
|
207
359
|
lines.push(...scanLines(sessionScanPreview(ctx, agentDir, window), window));
|
|
208
360
|
lines.push(`State file ${displayPath(getLearnStatePath(agentDir, stateKeyDir(ctx, agentDir)))}`);
|
|
361
|
+
const { pending } = pendingWork(ctx, agentDir, window);
|
|
362
|
+
lines.push(pending === 0
|
|
363
|
+
? "All sessions in the window are already mined; the next /learn costs one small coverage call."
|
|
364
|
+
: `${pending} session(s) in the window still need reading, roughly one call each.`);
|
|
209
365
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
210
366
|
}
|
|
211
367
|
export function setupLearn(pi) {
|
|
@@ -214,9 +370,10 @@ export function setupLearn(pi) {
|
|
|
214
370
|
return;
|
|
215
371
|
guarded[REGISTERED] = true;
|
|
216
372
|
pi.registerCommand("learn", {
|
|
217
|
-
description: "Mine recent sessions for durable rules and skills. Usage: /learn [all|stats|settings]",
|
|
373
|
+
description: "Mine recent sessions for durable rules and skills. Usage: /learn [all|stale|stats|settings]",
|
|
218
374
|
getArgumentCompletions: (prefix) => [
|
|
219
375
|
{ value: "all", label: "re-propose everything" },
|
|
376
|
+
{ value: "stale", label: "context-file lines naming something that is gone" },
|
|
220
377
|
{ value: "stats", label: "what happened to past proposals" },
|
|
221
378
|
{ value: "settings", label: "where sessions are read from, and the knobs" },
|
|
222
379
|
]
|
|
@@ -224,8 +381,12 @@ export function setupLearn(pi) {
|
|
|
224
381
|
.map((option) => ({ value: option.value, label: option.label })),
|
|
225
382
|
handler: async (args, ctx) => {
|
|
226
383
|
const argument = args.trim().toLowerCase();
|
|
227
|
-
if (argument &&
|
|
228
|
-
ctx.ui.notify("Usage: /learn [all|stats|settings]", "warning");
|
|
384
|
+
if (argument && !["all", "stale", "stats", "settings"].includes(argument)) {
|
|
385
|
+
ctx.ui.notify("Usage: /learn [all|stale|stats|settings]", "warning");
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (argument === "stale") {
|
|
389
|
+
reportAudit(pi, ctx);
|
|
229
390
|
return;
|
|
230
391
|
}
|
|
231
392
|
if (argument === "stats") {
|
|
@@ -240,11 +401,41 @@ export function setupLearn(pi) {
|
|
|
240
401
|
// Read per-invocation so a settings edit takes effect without a reload,
|
|
241
402
|
// and so a project settings.json can narrow the window for one repo.
|
|
242
403
|
const agentDir = getHooCodeDir();
|
|
243
|
-
const
|
|
404
|
+
const settings = SettingsManager.create(ctx.cwd, agentDir);
|
|
405
|
+
const window = settings.getLearnSettings();
|
|
244
406
|
const statePath = getLearnStatePath(agentDir, stateKeyDir(ctx, agentDir));
|
|
407
|
+
const pipeline = await buildPipeline(ctx, settings, loadReplayFingerprints(pi));
|
|
408
|
+
if ("error" in pipeline) {
|
|
409
|
+
ctx.ui.notify(pipeline.error, "error");
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
// State the price before charging it. A first run in a busy repo reads
|
|
413
|
+
// every transcript in the window, which is the expensive path by design
|
|
414
|
+
// — but it should never be a surprise, and the cache means it is paid
|
|
415
|
+
// once rather than on every run.
|
|
416
|
+
const { pending } = pendingWork(ctx, agentDir, window);
|
|
417
|
+
if (pending > CONFIRM_ABOVE_PENDING) {
|
|
418
|
+
const proceed = await ctx.ui.confirm("Read session transcripts?", `${pending} session(s) have not been read yet. /learn reads each one with a model ` +
|
|
419
|
+
`(${pipeline.model.provider}/${pipeline.model.id}) and caches the result, so this cost is paid once ` +
|
|
420
|
+
`per session. Later runs reuse it.`);
|
|
421
|
+
if (!proceed) {
|
|
422
|
+
ctx.ui.notify("/learn cancelled — nothing was read.", "info");
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// A backfill can run for minutes across dozens of transcripts, and the
|
|
427
|
+
// agent is idle throughout — so `ctx.signal` is undefined and there is no
|
|
428
|
+
// ambient way out. Escape gets one.
|
|
429
|
+
const controller = new AbortController();
|
|
430
|
+
const unsubscribe = ctx.ui.onTerminalInput((data) => {
|
|
431
|
+
if (data !== ESCAPE)
|
|
432
|
+
return undefined;
|
|
433
|
+
controller.abort();
|
|
434
|
+
return { consume: true };
|
|
435
|
+
});
|
|
245
436
|
let digest;
|
|
246
437
|
try {
|
|
247
|
-
digest =
|
|
438
|
+
digest = await mineLearnDigest({
|
|
248
439
|
cwd: ctx.cwd,
|
|
249
440
|
agentDir,
|
|
250
441
|
// Searched in addition to the per-cwd default directory, so a session
|
|
@@ -254,16 +445,48 @@ export function setupLearn(pi) {
|
|
|
254
445
|
maxSessions: window.maxSessions,
|
|
255
446
|
maxAgeDays: window.maxAgeDays,
|
|
256
447
|
minRepeats: window.minRepeats,
|
|
257
|
-
|
|
448
|
+
minRequestRepeats: window.minRequestRepeats,
|
|
258
449
|
maxProposals: window.maxProposals,
|
|
259
450
|
state: readLearnState(statePath),
|
|
260
451
|
ignoreState,
|
|
452
|
+
miner: pipeline.miner,
|
|
453
|
+
clusterer: pipeline.clusterer,
|
|
454
|
+
coverageJudge: pipeline.coverageJudge,
|
|
455
|
+
signal: controller.signal,
|
|
456
|
+
onProgress: ({ done, total, cached }) => {
|
|
457
|
+
// The same footer bar the semantic index uses. Cached sessions are
|
|
458
|
+
// counted as done because they are: the bar measures progress
|
|
459
|
+
// through the window, not money spent, and a run that is mostly
|
|
460
|
+
// cache should look nearly finished from the start.
|
|
461
|
+
startupProgress.set({
|
|
462
|
+
key: PROGRESS_KEY,
|
|
463
|
+
kind: "work",
|
|
464
|
+
label: cached > 0
|
|
465
|
+
? `Reading sessions (${cached} cached) — esc to stop`
|
|
466
|
+
: "Reading sessions — esc to stop",
|
|
467
|
+
done,
|
|
468
|
+
total,
|
|
469
|
+
unit: "sessions",
|
|
470
|
+
});
|
|
471
|
+
},
|
|
261
472
|
});
|
|
262
473
|
}
|
|
263
474
|
catch (error) {
|
|
264
475
|
ctx.ui.notify(`/learn could not read session history: ${error}`, "error");
|
|
265
476
|
return;
|
|
266
477
|
}
|
|
478
|
+
finally {
|
|
479
|
+
unsubscribe();
|
|
480
|
+
startupProgress.remove(PROGRESS_KEY);
|
|
481
|
+
}
|
|
482
|
+
// A cancelled run counted only part of the window, so its numbers are not
|
|
483
|
+
// merely incomplete — they are low. Showing them would be misleading and
|
|
484
|
+
// bookmarking them would hide those items on the next, complete run.
|
|
485
|
+
// Everything read so far is cached, so stopping costs nothing but time.
|
|
486
|
+
if (digest.aborted) {
|
|
487
|
+
ctx.ui.notify(`/learn stopped — ${digest.mining.mined} session(s) were read and cached, so resuming picks up where this left off.`, "info");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
267
490
|
if (digest.scannedSessions === 0) {
|
|
268
491
|
reportNoSessions(ctx, agentDir, digest, window);
|
|
269
492
|
return;
|
|
@@ -271,11 +494,14 @@ export function setupLearn(pi) {
|
|
|
271
494
|
if (isEmptyDigest(digest)) {
|
|
272
495
|
const lines = [];
|
|
273
496
|
lines.push(digest.suppressed > 0
|
|
274
|
-
? `
|
|
275
|
-
: `
|
|
497
|
+
? `Read ${digest.scannedSessions} session(s) — nothing new since last time (${digest.suppressed} already shown). Run /learn all to see them again.`
|
|
498
|
+
: `Read ${digest.scannedSessions} session(s) — nothing repeated often enough to be worth a rule yet.`);
|
|
276
499
|
if (digest.suppressed === 0) {
|
|
277
|
-
//
|
|
278
|
-
//
|
|
500
|
+
// Which of the two empty results this is. "Nothing was said" and "a
|
|
501
|
+
// lot was said and none of it repeated" read identically otherwise,
|
|
502
|
+
// and they point at completely different knobs.
|
|
503
|
+
lines.push(` ${digest.funnel.candidates} occurrence(s) → ${digest.funnel.points} distinct point(s) → ` +
|
|
504
|
+
`${digest.funnel.belowThreshold} below the repeat threshold`);
|
|
279
505
|
lines.push("");
|
|
280
506
|
lines.push(...settingsLines(ctx, agentDir, window));
|
|
281
507
|
}
|
|
@@ -285,16 +511,26 @@ export function setupLearn(pi) {
|
|
|
285
511
|
const counts = [
|
|
286
512
|
digest.directives.length > 0 ? `${digest.directives.length} directive(s)` : undefined,
|
|
287
513
|
digest.fixes.length > 0 ? `${digest.fixes.length} fix(es)` : undefined,
|
|
288
|
-
digest.
|
|
514
|
+
digest.requests.length > 0 ? `${digest.requests.length} request(s)` : undefined,
|
|
289
515
|
].filter((part) => !!part);
|
|
290
516
|
const held = digest.suppressed > 0 ? `, ${digest.suppressed} held back` : "";
|
|
291
|
-
|
|
517
|
+
const cut = digest.cut > 0 ? `, ${digest.cut} cut to fit the cap` : "";
|
|
518
|
+
ctx.ui.notify(`Mined ${digest.scannedSessions} session(s) (${digest.mining.mined} read, ${digest.mining.cached} cached): ${counts.join(", ")}${held}${cut}.`, "info");
|
|
292
519
|
// Record before delivering: what matters is that these were put in front
|
|
293
520
|
// of the user, which is true whether or not they act on the digest.
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
521
|
+
//
|
|
522
|
+
// Unless coverage could not be read. The bookmark stores whether an item
|
|
523
|
+
// was already written down when it was shown, and that is what later tells
|
|
524
|
+
// an adopted proposal from one passed over. Recording a guess as a reading
|
|
525
|
+
// would have a later run tell the user they passed on something they were
|
|
526
|
+
// never shown. Skipping costs one round of re-proposing.
|
|
527
|
+
if (!digest.coverageFailed) {
|
|
528
|
+
writeLearnState(statePath, recordSurfaced(readLearnState(statePath), digest.surfaced));
|
|
529
|
+
}
|
|
530
|
+
pi.sendUserMessage(renderLearnDigest(digest, {
|
|
531
|
+
userScopePath: displayPath(USER_SCOPE_PATH),
|
|
532
|
+
mode: ignoreState ? "all" : "incremental",
|
|
533
|
+
}), { deliverAs: "followUp" });
|
|
298
534
|
},
|
|
299
535
|
});
|
|
300
536
|
}
|