@kolisachint/hoocode-agent 0.5.61 → 0.5.62
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 +55 -0
- package/dist/extensions/core/learn.d.ts.map +1 -1
- package/dist/extensions/core/learn.js +202 -134
- package/dist/extensions/core/learn.js.map +1 -1
- package/dist/modes/interactive/components/branch-summary-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/branch-summary-message.js +3 -2
- package/dist/modes/interactive/components/branch-summary-message.js.map +1 -1
- package/dist/modes/interactive/components/compaction-summary-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/compaction-summary-message.js +3 -2
- package/dist/modes/interactive/components/compaction-summary-message.js.map +1 -1
- package/dist/modes/interactive/components/custom-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/custom-message.js +4 -4
- package/dist/modes/interactive/components/custom-message.js.map +1 -1
- package/dist/modes/interactive/components/skill-invocation-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/skill-invocation-message.js +3 -2
- package/dist/modes/interactive/components/skill-invocation-message.js.map +1 -1
- package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/user-message.js +3 -3
- package/dist/modes/interactive/components/user-message.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +3 -3
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/theme/theme.d.ts +25 -0
- package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
- package/dist/modes/interactive/theme/theme.js +18 -0
- package/dist/modes/interactive/theme/theme.js.map +1 -1
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.62] - 2026-09-09
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **`[skill]`, `[compaction]` and `[branch]` blocks are sheets like every other
|
|
8
|
+
message block.** All three were written to use "the same background colour as
|
|
9
|
+
custom messages for visual consistency" and all three stopped at the colour:
|
|
10
|
+
they got the fill and none of the edge, so under a cut-out theme they rendered
|
|
11
|
+
as flat full-width bands sitting beside the sheets they were copying — no
|
|
12
|
+
gutter, no cut edge, no shadow — in the same transcript. The fill and the
|
|
13
|
+
paper treatment were two independent decisions made at each call site, which
|
|
14
|
+
is why they drifted; they are now one call, `applyBlockFill`, and a test walks
|
|
15
|
+
every block component plus the source of every component file so a new block
|
|
16
|
+
cannot take the fill without the edge again.
|
|
17
|
+
- **A filled block no longer wraps past the right margin in a narrow terminal.**
|
|
18
|
+
Below a width where the gutter and two columns of padding still leave room for
|
|
19
|
+
content, the padding kept its columns and pushed the row wider than the band
|
|
20
|
+
it was meant to fill: the shadow's column went with it and the line wrapped.
|
|
21
|
+
Padding is now derived from the band, and where the band is too narrow to
|
|
22
|
+
carry its own bottom run the box gives the paper treatment up and draws the
|
|
23
|
+
plain full-width band a theme without paper draws, rather than degenerating
|
|
24
|
+
into a one-column band with a shadow beside it.
|
|
25
|
+
|
|
26
|
+
- **`/learn` no longer ends as an extension error when the session it was
|
|
27
|
+
started in is replaced mid-run.** Mining reads transcripts with a model and
|
|
28
|
+
can run for minutes, and nothing stops a `/new`, a `/resume`, a `/fork` or a
|
|
29
|
+
`/mode` landing in the middle of it. What replaces the session disposes the
|
|
30
|
+
old one, which invalidates the command ctx the run captured, so the first line
|
|
31
|
+
the finished run tried to print threw instead: the user got
|
|
32
|
+
`Extension "command:learn" error: This extension ctx is stale after session
|
|
33
|
+
replacement or reload` under the banner of the session that had just replaced
|
|
34
|
+
theirs, and the digest they had waited for was discarded. The run now learns
|
|
35
|
+
about the replacement from `session_shutdown` — emitted just before the
|
|
36
|
+
disposal, which is what makes it the last moment a run can still be told —
|
|
37
|
+
aborts its mining pass there, and reports nothing, because there is no longer
|
|
38
|
+
anywhere to report to. Only time is lost: every transcript already read is in
|
|
39
|
+
the on-disk cache, so `/learn` in the replacing session resumes from it.
|
|
40
|
+
- **A cut-out theme's shadow no longer pokes out past its own corner.** The
|
|
41
|
+
bottom run of a paper sheet's shadow is drawn with `▀`, which fills a cell
|
|
42
|
+
edge to edge, while the column down its right edge is `▌`, which fills half of
|
|
43
|
+
one. Ending the run on `▀` under that column overshot it by half a cell, and
|
|
44
|
+
what the eye got at the bottom-right of every filled block — user messages,
|
|
45
|
+
extension blocks, error and warning frames — was a stray tip coming out of the
|
|
46
|
+
shadow. The run now ends on `▘`, the same top half cut back to the column's
|
|
47
|
+
width, so the two edges close flush.
|
|
48
|
+
- **And no longer floats a column of page between a sheet and its shadow.** A
|
|
49
|
+
hand-cut right edge nicks one column out of roughly one row in five, and that
|
|
50
|
+
column used to be left as bare page in front of the shadow, which detached the
|
|
51
|
+
shadow from the sheet casting it at exactly the rows the cut was meant to make
|
|
52
|
+
look hand-made. A nick is a notch taken out of the sheet, not a hole punched in
|
|
53
|
+
the shadow behind it, so the column it gives back is now inked as shadow. The
|
|
54
|
+
shadow's own column stays in the one cell it always occupied — stepping it in
|
|
55
|
+
with the cut leaves no overlap between one row's half-cell mark and the next,
|
|
56
|
+
and reads as a dashed staircase.
|
|
57
|
+
|
|
3
58
|
## [0.5.61] - 2026-09-06
|
|
4
59
|
|
|
5
60
|
## [0.5.60] - 2026-09-05
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"learn.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/learn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAQH,OAAO,KAAK,EAAE,YAAY,EAA2B,MAAM,gCAAgC,CAAC;AA2Z5F,wBAAgB,UAAU,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAqMjD","sourcesContent":["/**\n * `/learn` — promote what recent sessions actually taught into durable rules\n * and skills.\n *\n * The command is a thin shell on purpose. It runs the mining pipeline over\n * session transcripts on disk, renders the ranked result, and injects it as a\n * follow-up message; every judgement after that belongs to the model, which can\n * read the repo and phrase a rule far better than a heuristic can.\n *\n * Reading transcripts from disk rather than the live context is what makes this\n * work: the on-disk history survives compaction, and it spans past sessions, so\n * \"you have said this in five separate sessions\" is available as a number\n * instead of a guess. That number is the whole reason the command exists.\n *\n * The pipeline reads every transcript with a model rather than pre-filtering\n * with regexes, which costs real tokens on a cold cache. That price is stated\n * before it is paid, never inferred: a run with sessions to read asks first.\n *\n * Follows /grill in modes.ts: no session switch, no mode change, no config\n * write — just a follow-up message. Writes to AGENTS.md happen through ordinary\n * edit tools, so the existing permission prompt is the approval step and no\n * separate picker is needed.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { Api, Model } from \"@kolisachint/hoocode-ai\";\nimport { CONFIG_DIR_NAME, getHooCodeDir } from \"../../config.js\";\nimport { loadProjectContextFiles } from \"../../core/context-files.js\";\nimport type { ExtensionAPI, ExtensionCommandContext } from \"../../core/extensions/types.js\";\nimport { auditContextFiles, staleTokens } from \"../../core/learn/audit.js\";\nimport type { Clusterer } from \"../../core/learn/cluster.js\";\nimport { createLlmClusterer } from \"../../core/learn/cluster.js\";\nimport type { CoverageJudge } from \"../../core/learn/coverage.js\";\nimport { createLlmCoverageJudge } from \"../../core/learn/coverage.js\";\nimport { isEmptyDigest, renderAuditReport, renderLearnDigest } from \"../../core/learn/digest.js\";\nimport {\n\ttype LearnDigest,\n\tmineLearnDigest,\n\tplanMining,\n\ttype SessionScanReport,\n\tscanSessions,\n} from \"../../core/learn/extract.js\";\nimport type { Miner } from \"../../core/learn/mine.js\";\nimport { chunkCharsForModel, createLlmMiner, replayFingerprints } from \"../../core/learn/mine.js\";\nimport {\n\tgetLearnStatePath,\n\treadLearnState,\n\trecordSurfaced,\n\tsummarizeLearnState,\n\twriteLearnState,\n} from \"../../core/learn/state.js\";\nimport { resolveModelCategory } from \"../../core/model-categories.js\";\n\nimport { getSessionDirPath } from \"../../core/session-manager.js\";\nimport { SettingsManager } from \"../../core/settings-manager.js\";\nimport { startupProgress } from \"../../core/startup-progress.js\";\n\n/** Guards against double-registration when default extensions load more than once. */\nconst REGISTERED = Symbol.for(\"hoocode.learn.registered\");\n\n/** User-scope destination offered for personal rules that travel across repos. */\nconst USER_SCOPE_PATH = join(homedir(), \".agents\", \"AGENTS.md\");\n\n/** Footer key for the mining progress bar. */\nconst PROGRESS_KEY = \"learn-mining\";\n\n/** Escape, the way a raw terminal delivers it. */\nconst ESCAPE = \"\\x1b\";\n\n/**\n * Sessions that can be read without asking first.\n *\n * A run that has one or two new transcripts to read is the normal daily case\n * and interrupting it to confirm a trivial cost is noise. Beyond this the run\n * is a backfill — onboarding to an existing repo, or a first run — and the\n * reader should get to decide before it starts.\n */\nconst CONFIRM_ABOVE_PENDING = 3;\n\n/** Render a home-relative path the way the user would type it. */\nfunction displayPath(path: string): string {\n\tconst home = homedir();\n\treturn path.startsWith(home) ? `~${path.slice(home.length)}` : path;\n}\n\nfunction shortDate(iso: string | undefined): string {\n\tif (!iso) return \"unknown\";\n\tconst date = new Date(iso);\n\treturn Number.isNaN(date.getTime()) ? \"unknown\" : date.toISOString().slice(0, 10);\n}\n\n/** The settings keys `/learn` reads, paired with the values in force right now. */\ntype LearnWindow = ReturnType<SettingsManager[\"getLearnSettings\"]>;\n\nconst SETTING_KEYS: Array<{ key: keyof LearnWindow; setting: string; note: string }> = [\n\t{ key: \"maxSessions\", setting: \"learnMaxSessions\", note: \"recent sessions scanned\" },\n\t{ key: \"maxAgeDays\", setting: \"learnMaxAgeDays\", note: \"ignore sessions older than this, in days\" },\n\t{ key: \"minRepeats\", setting: \"learnMinRepeats\", note: \"times a directive must recur to be proposed\" },\n\t{\n\t\tkey: \"minRequestRepeats\",\n\t\tsetting: \"learnMinRequestRepeats\",\n\t\tnote: \"repeats before a tool sequence is proposed\",\n\t},\n\t{ key: \"maxProposals\", setting: \"learnMaxProposals\", note: \"cap on each list in the digest\" },\n];\n\n/**\n * Where the knobs live, and what they are set to.\n *\n * `/learn` has its settings and no UI, so until this existed the only way to\n * find them was to already know they were in `settings.json`. Every message that\n * reports a disappointing result names a threshold, so every one of them ends\n * with these lines.\n */\nfunction settingsPathLines(ctx: ExtensionCommandContext, agentDir: string): string[] {\n\treturn [\n\t\t\"Settings — edit either file, no restart needed\",\n\t\t` user ${displayPath(join(agentDir, \"settings.json\"))}`,\n\t\t` project ${displayPath(join(ctx.cwd, CONFIG_DIR_NAME, \"settings.json\"))} (wins where both set a key)`,\n\t];\n}\n\nfunction settingsLines(ctx: ExtensionCommandContext, agentDir: string, window: LearnWindow): string[] {\n\tconst lines = settingsPathLines(ctx, agentDir);\n\tfor (const { key, setting, note } of SETTING_KEYS) {\n\t\tlines.push(` ${setting.padEnd(24)} ${String(window[key] ?? \"—\").padStart(3)} ${note}`);\n\t}\n\treturn lines;\n}\n\n/**\n * The directory whose name keys this cwd's bookmark.\n *\n * Derived from the cwd, never from the live session manager. An in-memory\n * session (`--no-session`) reports an empty session directory, which used to key\n * every such run to the same nameless state file, and a shared custom\n * `sessionDir` used to make two unrelated projects share one bookmark. The cwd\n * is what \"per directory\" means here, so the cwd is what it is keyed on.\n */\nfunction stateKeyDir(ctx: ExtensionCommandContext, agentDir: string): string {\n\treturn getSessionDirPath(ctx.cwd, agentDir);\n}\n\n/** Run the directory scan without mining anything, for the reports that only need counts. */\nfunction sessionScanPreview(ctx: ExtensionCommandContext, agentDir: string, window: LearnWindow): SessionScanReport {\n\treturn scanSessions({\n\t\tcwd: ctx.cwd,\n\t\tagentDir,\n\t\tsessionDir: ctx.sessionManager.getSessionDir(),\n\t\tmaxSessions: window.maxSessions,\n\t\tmaxAgeDays: window.maxAgeDays,\n\t});\n}\n\n/** Where sessions were looked for, and what was passed over — the \"why nothing?\" answer. */\nfunction scanLines(scan: SessionScanReport, window: LearnWindow): string[] {\n\tconst lines: string[] = [\"Looked in\"];\n\tfor (const dir of scan.dirs) {\n\t\tconst missing = scan.missingDirs.includes(dir) ? \" (does not exist)\" : \"\";\n\t\tlines.push(` ${displayPath(dir)}${missing}`);\n\t}\n\tlines.push(`Found ${scan.files} session file(s)`);\n\n\tconst skips: string[] = [];\n\tif (scan.tooOld > 0) skips.push(`${scan.tooOld} older than ${window.maxAgeDays} days (learnMaxAgeDays)`);\n\tif (scan.otherCwd > 0) skips.push(`${scan.otherCwd} recorded a different working directory`);\n\tif (scan.overLimit > 0) skips.push(`${scan.overLimit} beyond the newest ${window.maxSessions} (learnMaxSessions)`);\n\tif (scan.unreadable > 0) skips.push(`${scan.unreadable} empty or unreadable`);\n\tfor (const skip of skips) lines.push(` skipped: ${skip}`);\n\treturn lines;\n}\n\n/**\n * Explain an empty scan rather than asserting there is no history.\n *\n * The old single sentence was wrong as often as it was right: sessions existed,\n * they were simply all outside the window or recorded under another path. Naming\n * the directory searched and the reason each file was passed over turns a dead\n * end into something the reader can fix.\n */\nfunction reportNoSessions(ctx: ExtensionCommandContext, agentDir: string, digest: LearnDigest, window: LearnWindow) {\n\tconst lines: string[] = [];\n\tlines.push(\n\t\tdigest.scan.files === 0\n\t\t\t? \"/learn found no session transcripts for this directory.\"\n\t\t\t: \"/learn found session transcripts, but none inside the current window.\",\n\t);\n\tlines.push(\"\");\n\tlines.push(...scanLines(digest.scan, window));\n\tlines.push(\"\");\n\tlines.push(...settingsLines(ctx, agentDir, window));\n\tctx.ui.notify(lines.join(\"\\n\"), \"warning\");\n}\n\n/**\n * The model that reads transcripts.\n *\n * This is the one call in the pipeline that reads *everything*, so it wants the\n * cheapest capable model rather than the session's. That question already has an\n * answer in this codebase — the `fast` model category, which subagents use for\n * exactly this kind of bulk read — so it is reused rather than reinvented.\n * `settings.modelCategories.fast` wins when set; otherwise the tier is derived\n * from the user's available models, and nothing here is provider-specific.\n *\n * Falls back to the session model when the tier resolves to nothing or to a\n * model the registry cannot find, since a mis-set tier should not take the\n * command out entirely.\n */\nfunction resolveMinerModel(ctx: ExtensionCommandContext, settings: SettingsManager): Model<Api> | undefined {\n\tconst ref = resolveModelCategory(\n\t\t\"fast\",\n\t\t{\n\t\t\tmodelCategories: settings.getModelCategories(),\n\t\t\tdefaultProvider: settings.getDefaultProvider(),\n\t\t\tdefaultModel: settings.getDefaultModel(),\n\t\t},\n\t\tctx.modelRegistry.getAvailable(),\n\t);\n\tif (!ref) return ctx.model;\n\n\tconst slash = ref.indexOf(\"/\");\n\tconst found = slash > 0 ? ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1)) : undefined;\n\treturn found ?? ctx.model;\n}\n\n/**\n * Literal runs from the slash commands in force, so the miner can tell a\n * command body replaying itself from something the user typed.\n *\n * Read from the session's own command list rather than re-deriving the search\n * path: which directories are scanned, in which order, and which flags disable\n * them is a precedence list that lives in one place and would drift the moment\n * it lived in two.\n */\nfunction loadReplayFingerprints(pi: ExtensionAPI): string[] {\n\tconst bodies: Array<{ content: string }> = [];\n\tfor (const command of pi.getCommands()) {\n\t\tconst path = command.sourceInfo?.path;\n\t\t// A built-in has no file behind it, and nothing to replay.\n\t\tif (!path || !existsSync(path)) continue;\n\t\ttry {\n\t\t\tbodies.push({ content: readFileSync(path, \"utf-8\") });\n\t\t} catch {\n\t\t\t// Unreadable command file: one fewer fingerprint, not a failed run.\n\t\t}\n\t}\n\treturn replayFingerprints(bodies);\n}\n\n/** Build the two model-backed stages, or report why they cannot be built. */\nasync function buildPipeline(\n\tctx: ExtensionCommandContext,\n\tsettings: SettingsManager,\n\t/** Empty for callers that only need the coverage judge; mining wants the real set. */\n\tfingerprints: string[] = [],\n): Promise<\n\t{ miner: Miner; clusterer: Clusterer; coverageJudge: CoverageJudge; model: Model<Api> } | { error: string }\n> {\n\tconst model = resolveMinerModel(ctx, settings);\n\tif (!model) {\n\t\treturn {\n\t\t\terror: \"/learn reads session transcripts with a model, and no model is selected. Pick one with /model, then run /learn again.\",\n\t\t};\n\t}\n\n\tconst auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);\n\tif (!auth.ok) {\n\t\treturn { error: `/learn could not authenticate ${model.provider}/${model.id}: ${auth.error}` };\n\t}\n\n\tconst deps = {\n\t\tmodel,\n\t\tapiKey: auth.apiKey,\n\t\theaders: auth.headers,\n\t\treplayFingerprints: fingerprints,\n\t};\n\treturn {\n\t\tminer: createLlmMiner(deps),\n\t\tclusterer: createLlmClusterer(deps),\n\t\tcoverageJudge: createLlmCoverageJudge(deps),\n\t\tmodel,\n\t};\n}\n\n/**\n * What this run still owes the model.\n *\n * Delegated to `planMining` so the number quoted by the confirmation prompt\n * comes from the same session selection the run will use — same window, same\n * cwd check, same de-duplication.\n */\nfunction pendingWork(ctx: ExtensionCommandContext, agentDir: string, window: LearnWindow) {\n\treturn planMining({\n\t\tcwd: ctx.cwd,\n\t\tagentDir,\n\t\tsessionDir: ctx.sessionManager.getSessionDir(),\n\t\tmaxSessions: window.maxSessions,\n\t\tmaxAgeDays: window.maxAgeDays,\n\t});\n}\n\n/**\n * `/learn stats` — what has been proposed here, and what it costs.\n *\n * Reads the state file and the context files. No model call: this used to\n * re-judge coverage and report an \"adoption rate\", which was unreliable in both\n * directions and shipped with two disclaimers explaining how not to misread it.\n * The honest version of the question it was trying to answer — is the\n * always-loaded surface growing — is a number the filesystem can answer exactly.\n */\nfunction reportStats(ctx: ExtensionCommandContext): void {\n\tconst agentDir = getHooCodeDir();\n\tconst settings = SettingsManager.create(ctx.cwd, agentDir);\n\tconst window = settings.getLearnSettings();\n\tconst statePath = getLearnStatePath(agentDir, stateKeyDir(ctx, agentDir));\n\tconst state = readLearnState(statePath);\n\n\tif (Object.keys(state.surfaced).length === 0) {\n\t\t// Nothing on record means `/learn` has never proposed anything here — which\n\t\t// is as likely to be \"it never found any sessions\" as \"you never ran it\", so\n\t\t// point at both the sessions it can see and the knobs that gate them.\n\t\tconst lines = [\"No /learn history for this directory yet — nothing has been proposed here.\"];\n\t\tlines.push(` State file ${displayPath(statePath)} (not created yet)`);\n\t\tlines.push(\"\");\n\t\tlines.push(...scanLines(sessionScanPreview(ctx, agentDir, window), window));\n\t\tlines.push(\"\");\n\t\tlines.push(...settingsPathLines(ctx, agentDir));\n\t\tlines.push(\" Run /learn settings for the thresholds in force.\");\n\t\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n\t\treturn;\n\t}\n\n\tconst stats = summarizeLearnState(state);\n\n\tconst contextFiles = loadProjectContextFiles({ cwd: ctx.cwd, agentDir }).agentsFiles;\n\tconst contextTokens = contextFiles.reduce((sum, file) => sum + (file.tokens ?? 0), 0);\n\n\tconst lines: string[] = [];\n\tlines.push(`/learn history for this directory — ${shortDate(stats.earliest)} to ${shortDate(stats.latest)}`);\n\tlines.push(\n\t\t` Proposals shown ${stats.total} (${stats.directives} directive, ${stats.fixes} fix, ${stats.requests} request)`,\n\t);\n\tif (stats.lastRun) lines.push(` Last run ${shortDate(stats.lastRun)}`);\n\tlines.push(\"\");\n\n\t// The one number worth watching, and the only one here that is exact. Mining\n\t// can only push it up; `/learn stale` is what pushes it down.\n\tlines.push(`Always-loaded cost ~${contextTokens} tokens across ${contextFiles.length} context file(s)`);\n\tfor (const file of contextFiles) {\n\t\tlines.push(` ~${file.tokens ?? 0} ${displayPath(file.path)}`);\n\t}\n\tlines.push(\" Run /learn stale to find lines naming something that no longer exists.\");\n\tlines.push(\"\");\n\tlines.push(`State file ${displayPath(statePath)}`);\n\tlines.push(\"\");\n\tlines.push(...settingsPathLines(ctx, agentDir));\n\tlines.push(\" Run /learn settings for the thresholds in force.\");\n\n\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n}\n\n/**\n * `/learn stale` — which lines in the context files name something that is gone.\n *\n * The mining path can only propose additions, so this is the only half of the\n * command that moves the always-loaded token surface down. It is deterministic\n * and costs nothing, which is what makes it the half worth running often; the\n * findings go to the model only when there are some, so a clean audit is free.\n */\nfunction reportAudit(pi: ExtensionAPI, ctx: ExtensionCommandContext): void {\n\tconst agentDir = getHooCodeDir();\n\tconst { agentsFiles } = loadProjectContextFiles({ cwd: ctx.cwd, agentDir });\n\n\tif (agentsFiles.length === 0) {\n\t\tctx.ui.notify(\"/learn stale found no context files to check (no AGENTS.md or CLAUDE.md is in force).\", \"warning\");\n\t\treturn;\n\t}\n\n\tconst report = auditContextFiles({ cwd: ctx.cwd, files: agentsFiles });\n\n\tif (report.files.length === 0) {\n\t\tconst lines = [\"/learn stale checked nothing — every context file in force is outside this working tree.\"];\n\t\tfor (const path of report.skippedFiles) lines.push(` ${displayPath(path)}`);\n\t\tlines.push(\"A rule written in a user-scope file names paths in whatever repo it was written for, not this one.\");\n\t\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n\t\treturn;\n\t}\n\n\tconst totalTokens = report.files.reduce((sum, file) => sum + file.tokens, 0);\n\tif (report.stale.length === 0) {\n\t\tctx.ui.notify(\n\t\t\t`/learn stale — ${report.checked} referent(s) in ${report.files.length} context file(s) all resolve. ` +\n\t\t\t\t`~${totalTokens} tokens, re-sent every request.`,\n\t\t\t\"info\",\n\t\t);\n\t\treturn;\n\t}\n\n\tctx.ui.notify(\n\t\t`/learn stale — ${report.stale.length} of ${report.checked} referent(s) do not resolve ` +\n\t\t\t`(~${staleTokens(report)} of ~${totalTokens} always-loaded tokens).`,\n\t\t\"info\",\n\t);\n\tpi.sendUserMessage(renderAuditReport(report), { deliverAs: \"followUp\" });\n}\n\n/** `/learn settings` — the knobs, their current values, and the files to set them in. */\nfunction reportSettings(ctx: ExtensionCommandContext): void {\n\tconst agentDir = getHooCodeDir();\n\tconst settings = SettingsManager.create(ctx.cwd, agentDir);\n\tconst window = settings.getLearnSettings();\n\tconst lines = settingsLines(ctx, agentDir, window);\n\n\t// The reading model is not a `/learn` setting — it is the shared `fast` tier,\n\t// so name it here rather than leaving the reader to guess which model is\n\t// about to read their history, and point at the setting that changes it.\n\tconst model = resolveMinerModel(ctx, settings);\n\tlines.push(\n\t\t` reads transcripts with ${model ? `${model.provider}/${model.id}` : \"no model selected\"}` +\n\t\t\t` (the \\`fast\\` tier — set modelCategories.fast to change it)`,\n\t);\n\tif (model) {\n\t\tlines.push(\n\t\t\t` ${Math.round(chunkCharsForModel(model) / 1000)}k characters per call, from its ${model.contextWindow} token window`,\n\t\t);\n\t}\n\n\tlines.push(\"\");\n\tlines.push(...scanLines(sessionScanPreview(ctx, agentDir, window), window));\n\tlines.push(`State file ${displayPath(getLearnStatePath(agentDir, stateKeyDir(ctx, agentDir)))}`);\n\tconst { pending } = pendingWork(ctx, agentDir, window);\n\tlines.push(\n\t\tpending === 0\n\t\t\t? \"All sessions in the window are already mined; the next /learn costs one small coverage call.\"\n\t\t\t: `${pending} session(s) in the window still need reading, roughly one call each.`,\n\t);\n\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n}\n\nexport function setupLearn(pi: ExtensionAPI): void {\n\tconst guarded = pi as unknown as Record<symbol, boolean>;\n\tif (guarded[REGISTERED]) return;\n\tguarded[REGISTERED] = true;\n\n\tpi.registerCommand(\"learn\", {\n\t\tdescription: \"Mine recent sessions for durable rules and skills. Usage: /learn [all|stale|stats|settings]\",\n\t\tgetArgumentCompletions: (prefix: string) =>\n\t\t\t(\n\t\t\t\t[\n\t\t\t\t\t{ value: \"all\", label: \"re-propose everything\" },\n\t\t\t\t\t{ value: \"stale\", label: \"context-file lines naming something that is gone\" },\n\t\t\t\t\t{ value: \"stats\", label: \"what happened to past proposals\" },\n\t\t\t\t\t{ value: \"settings\", label: \"where sessions are read from, and the knobs\" },\n\t\t\t\t] as const\n\t\t\t)\n\t\t\t\t.filter((option) => option.value.startsWith(prefix))\n\t\t\t\t.map((option) => ({ value: option.value, label: option.label })),\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst argument = args.trim().toLowerCase();\n\t\t\tif (argument && ![\"all\", \"stale\", \"stats\", \"settings\"].includes(argument)) {\n\t\t\t\tctx.ui.notify(\"Usage: /learn [all|stale|stats|settings]\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (argument === \"stale\") {\n\t\t\t\treportAudit(pi, ctx);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (argument === \"stats\") {\n\t\t\t\treportStats(ctx);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (argument === \"settings\") {\n\t\t\t\treportSettings(ctx);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst ignoreState = argument === \"all\";\n\n\t\t\t// Read per-invocation so a settings edit takes effect without a reload,\n\t\t\t// and so a project settings.json can narrow the window for one repo.\n\t\t\tconst agentDir = getHooCodeDir();\n\t\t\tconst settings = SettingsManager.create(ctx.cwd, agentDir);\n\t\t\tconst window = settings.getLearnSettings();\n\t\t\tconst statePath = getLearnStatePath(agentDir, stateKeyDir(ctx, agentDir));\n\n\t\t\tconst pipeline = await buildPipeline(ctx, settings, loadReplayFingerprints(pi));\n\t\t\tif (\"error\" in pipeline) {\n\t\t\t\tctx.ui.notify(pipeline.error, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// State the price before charging it. A first run in a busy repo reads\n\t\t\t// every transcript in the window, which is the expensive path by design\n\t\t\t// — but it should never be a surprise, and the cache means it is paid\n\t\t\t// once rather than on every run.\n\t\t\tconst { pending } = pendingWork(ctx, agentDir, window);\n\t\t\tif (pending > CONFIRM_ABOVE_PENDING) {\n\t\t\t\tconst proceed = await ctx.ui.confirm(\n\t\t\t\t\t\"Read session transcripts?\",\n\t\t\t\t\t`${pending} session(s) have not been read yet. /learn reads each one with a model ` +\n\t\t\t\t\t\t`(${pipeline.model.provider}/${pipeline.model.id}) and caches the result, so this cost is paid once ` +\n\t\t\t\t\t\t`per session. Later runs reuse it.`,\n\t\t\t\t);\n\t\t\t\tif (!proceed) {\n\t\t\t\t\tctx.ui.notify(\"/learn cancelled — nothing was read.\", \"info\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// A backfill can run for minutes across dozens of transcripts, and the\n\t\t\t// agent is idle throughout — so `ctx.signal` is undefined and there is no\n\t\t\t// ambient way out. Escape gets one.\n\t\t\tconst controller = new AbortController();\n\t\t\tconst unsubscribe = ctx.ui.onTerminalInput((data) => {\n\t\t\t\tif (data !== ESCAPE) return undefined;\n\t\t\t\tcontroller.abort();\n\t\t\t\treturn { consume: true };\n\t\t\t});\n\n\t\t\tlet digest: LearnDigest;\n\t\t\ttry {\n\t\t\t\tdigest = await mineLearnDigest({\n\t\t\t\t\tcwd: ctx.cwd,\n\t\t\t\t\tagentDir,\n\t\t\t\t\t// Searched in addition to the per-cwd default directory, so a session\n\t\t\t\t\t// manager pointing elsewhere (`--session`, a custom `sessionDir`, or\n\t\t\t\t\t// an in-memory session reporting none at all) cannot hide the history.\n\t\t\t\t\tsessionDir: ctx.sessionManager.getSessionDir(),\n\t\t\t\t\tmaxSessions: window.maxSessions,\n\t\t\t\t\tmaxAgeDays: window.maxAgeDays,\n\t\t\t\t\tminRepeats: window.minRepeats,\n\t\t\t\t\tminRequestRepeats: window.minRequestRepeats,\n\t\t\t\t\tmaxProposals: window.maxProposals,\n\t\t\t\t\tstate: readLearnState(statePath),\n\t\t\t\t\tignoreState,\n\t\t\t\t\tminer: pipeline.miner,\n\t\t\t\t\tclusterer: pipeline.clusterer,\n\t\t\t\t\tcoverageJudge: pipeline.coverageJudge,\n\t\t\t\t\tsignal: controller.signal,\n\t\t\t\t\tonProgress: ({ done, total, cached }) => {\n\t\t\t\t\t\t// The same footer bar the semantic index uses. Cached sessions are\n\t\t\t\t\t\t// counted as done because they are: the bar measures progress\n\t\t\t\t\t\t// through the window, not money spent, and a run that is mostly\n\t\t\t\t\t\t// cache should look nearly finished from the start.\n\t\t\t\t\t\tstartupProgress.set({\n\t\t\t\t\t\t\tkey: PROGRESS_KEY,\n\t\t\t\t\t\t\tkind: \"work\",\n\t\t\t\t\t\t\tlabel:\n\t\t\t\t\t\t\t\tcached > 0\n\t\t\t\t\t\t\t\t\t? `Reading sessions (${cached} cached) — esc to stop`\n\t\t\t\t\t\t\t\t\t: \"Reading sessions — esc to stop\",\n\t\t\t\t\t\t\tdone,\n\t\t\t\t\t\t\ttotal,\n\t\t\t\t\t\t\tunit: \"sessions\",\n\t\t\t\t\t\t});\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tctx.ui.notify(`/learn could not read session history: ${error}`, \"error\");\n\t\t\t\treturn;\n\t\t\t} finally {\n\t\t\t\tunsubscribe();\n\t\t\t\tstartupProgress.remove(PROGRESS_KEY);\n\t\t\t}\n\n\t\t\t// A cancelled run counted only part of the window, so its numbers are not\n\t\t\t// merely incomplete — they are low. Showing them would be misleading and\n\t\t\t// bookmarking them would hide those items on the next, complete run.\n\t\t\t// Everything read so far is cached, so stopping costs nothing but time.\n\t\t\tif (digest.aborted) {\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t`/learn stopped — ${digest.mining.mined} session(s) were read and cached, so resuming picks up where this left off.`,\n\t\t\t\t\t\"info\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (digest.scannedSessions === 0) {\n\t\t\t\treportNoSessions(ctx, agentDir, digest, window);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (isEmptyDigest(digest)) {\n\t\t\t\tconst lines: string[] = [];\n\t\t\t\tlines.push(\n\t\t\t\t\tdigest.suppressed > 0\n\t\t\t\t\t\t? `Read ${digest.scannedSessions} session(s) — nothing new since last time (${digest.suppressed} already shown). Run /learn all to see them again.`\n\t\t\t\t\t\t: `Read ${digest.scannedSessions} session(s) — nothing repeated often enough to be worth a rule yet.`,\n\t\t\t\t);\n\t\t\t\tif (digest.suppressed === 0) {\n\t\t\t\t\t// Which of the two empty results this is. \"Nothing was said\" and \"a\n\t\t\t\t\t// lot was said and none of it repeated\" read identically otherwise,\n\t\t\t\t\t// and they point at completely different knobs.\n\t\t\t\t\tlines.push(\n\t\t\t\t\t\t` ${digest.funnel.candidates} occurrence(s) → ${digest.funnel.points} distinct point(s) → ` +\n\t\t\t\t\t\t\t`${digest.funnel.belowThreshold} below the repeat threshold`,\n\t\t\t\t\t);\n\t\t\t\t\tlines.push(\"\");\n\t\t\t\t\tlines.push(...settingsLines(ctx, agentDir, window));\n\t\t\t\t}\n\t\t\t\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst counts = [\n\t\t\t\tdigest.directives.length > 0 ? `${digest.directives.length} directive(s)` : undefined,\n\t\t\t\tdigest.fixes.length > 0 ? `${digest.fixes.length} fix(es)` : undefined,\n\t\t\t\tdigest.requests.length > 0 ? `${digest.requests.length} request(s)` : undefined,\n\t\t\t].filter((part): part is string => !!part);\n\t\t\tconst held = digest.suppressed > 0 ? `, ${digest.suppressed} held back` : \"\";\n\t\t\tconst cut = digest.cut > 0 ? `, ${digest.cut} cut to fit the cap` : \"\";\n\t\t\tctx.ui.notify(\n\t\t\t\t`Mined ${digest.scannedSessions} session(s) (${digest.mining.mined} read, ${digest.mining.cached} cached): ${counts.join(\", \")}${held}${cut}.`,\n\t\t\t\t\"info\",\n\t\t\t);\n\n\t\t\t// Record before delivering: what matters is that these were put in front\n\t\t\t// of the user, which is true whether or not they act on the digest.\n\t\t\t//\n\t\t\t// Unless coverage could not be read. The bookmark stores whether an item\n\t\t\t// was already written down when it was shown, and that is what later tells\n\t\t\t// an adopted proposal from one passed over. Recording a guess as a reading\n\t\t\t// would have a later run tell the user they passed on something they were\n\t\t\t// never shown. Skipping costs one round of re-proposing.\n\t\t\tif (!digest.coverageFailed) {\n\t\t\t\twriteLearnState(statePath, recordSurfaced(readLearnState(statePath), digest.surfaced));\n\t\t\t}\n\n\t\t\tpi.sendUserMessage(\n\t\t\t\trenderLearnDigest(digest, {\n\t\t\t\t\tuserScopePath: displayPath(USER_SCOPE_PATH),\n\t\t\t\t\tmode: ignoreState ? \"all\" : \"incremental\",\n\t\t\t\t}),\n\t\t\t\t{ deliverAs: \"followUp\" },\n\t\t\t);\n\t\t},\n\t});\n}\n"]}
|
|
1
|
+
{"version":3,"file":"learn.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/learn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAQH,OAAO,KAAK,EAAE,YAAY,EAA2B,MAAM,gCAAgC,CAAC;AA0nB5F,wBAAgB,UAAU,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CA+CjD","sourcesContent":["/**\n * `/learn` — promote what recent sessions actually taught into durable rules\n * and skills.\n *\n * The command is a thin shell on purpose. It runs the mining pipeline over\n * session transcripts on disk, renders the ranked result, and injects it as a\n * follow-up message; every judgement after that belongs to the model, which can\n * read the repo and phrase a rule far better than a heuristic can.\n *\n * Reading transcripts from disk rather than the live context is what makes this\n * work: the on-disk history survives compaction, and it spans past sessions, so\n * \"you have said this in five separate sessions\" is available as a number\n * instead of a guess. That number is the whole reason the command exists.\n *\n * The pipeline reads every transcript with a model rather than pre-filtering\n * with regexes, which costs real tokens on a cold cache. That price is stated\n * before it is paid, never inferred: a run with sessions to read asks first.\n *\n * Follows /grill in modes.ts: no session switch, no mode change, no config\n * write — just a follow-up message. Writes to AGENTS.md happen through ordinary\n * edit tools, so the existing permission prompt is the approval step and no\n * separate picker is needed.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { Api, Model } from \"@kolisachint/hoocode-ai\";\nimport { CONFIG_DIR_NAME, getHooCodeDir } from \"../../config.js\";\nimport { loadProjectContextFiles } from \"../../core/context-files.js\";\nimport type { ExtensionAPI, ExtensionCommandContext } from \"../../core/extensions/types.js\";\nimport { auditContextFiles, staleTokens } from \"../../core/learn/audit.js\";\nimport type { Clusterer } from \"../../core/learn/cluster.js\";\nimport { createLlmClusterer } from \"../../core/learn/cluster.js\";\nimport type { CoverageJudge } from \"../../core/learn/coverage.js\";\nimport { createLlmCoverageJudge } from \"../../core/learn/coverage.js\";\nimport { isEmptyDigest, renderAuditReport, renderLearnDigest } from \"../../core/learn/digest.js\";\nimport {\n\ttype LearnDigest,\n\tmineLearnDigest,\n\tplanMining,\n\ttype SessionScanReport,\n\tscanSessions,\n} from \"../../core/learn/extract.js\";\nimport type { Miner } from \"../../core/learn/mine.js\";\nimport { chunkCharsForModel, createLlmMiner, replayFingerprints } from \"../../core/learn/mine.js\";\nimport {\n\tgetLearnStatePath,\n\treadLearnState,\n\trecordSurfaced,\n\tsummarizeLearnState,\n\twriteLearnState,\n} from \"../../core/learn/state.js\";\nimport { resolveModelCategory } from \"../../core/model-categories.js\";\n\nimport { getSessionDirPath } from \"../../core/session-manager.js\";\nimport { SettingsManager } from \"../../core/settings-manager.js\";\nimport { startupProgress } from \"../../core/startup-progress.js\";\n\n/** Guards against double-registration when default extensions load more than once. */\nconst REGISTERED = Symbol.for(\"hoocode.learn.registered\");\n\n/** User-scope destination offered for personal rules that travel across repos. */\nconst USER_SCOPE_PATH = join(homedir(), \".agents\", \"AGENTS.md\");\n\n/** Footer key for the mining progress bar. */\nconst PROGRESS_KEY = \"learn-mining\";\n\n/** Escape, the way a raw terminal delivers it. */\nconst ESCAPE = \"\\x1b\";\n\n/**\n * One `/learn` run that is still reading transcripts, and whether the session\n * that started it is gone.\n */\ntype LearnRun = { controller: AbortController; stale: boolean };\n\n/**\n * The mining runs currently in flight.\n *\n * `/learn` is the one command here that runs for minutes on end, and the session\n * it was started in can be replaced while it does — a `/new`, a `/resume`, a\n * `/fork`, a `/mode` that swaps the session. Whatever replaces it disposes the\n * old session, and disposal invalidates the command ctx this run captured: the\n * next line it tries to print throws instead, and a run the user waited minutes\n * for surfaces as `Extension \"command:learn\" error: This extension ctx is\n * stale…` with its digest thrown away.\n *\n * `session_shutdown` is emitted before that disposal, which makes it the one\n * point where a run in flight can still find out. It aborts the mining pass and\n * marks the run stale; from there the run reports nothing at all, because there\n * is no longer anywhere to report to. Nothing is lost but time — every\n * transcript already read is in the on-disk cache, so the next `/learn`, in\n * whatever session replaced this one, resumes from it.\n */\nconst IN_FLIGHT = new Set<LearnRun>();\n\n/** Tell every run in flight that its session is going away. */\nfunction abortInFlightRuns(): void {\n\tfor (const run of IN_FLIGHT) {\n\t\trun.stale = true;\n\t\trun.controller.abort();\n\t}\n}\n\n/**\n * Sessions that can be read without asking first.\n *\n * A run that has one or two new transcripts to read is the normal daily case\n * and interrupting it to confirm a trivial cost is noise. Beyond this the run\n * is a backfill — onboarding to an existing repo, or a first run — and the\n * reader should get to decide before it starts.\n */\nconst CONFIRM_ABOVE_PENDING = 3;\n\n/** Render a home-relative path the way the user would type it. */\nfunction displayPath(path: string): string {\n\tconst home = homedir();\n\treturn path.startsWith(home) ? `~${path.slice(home.length)}` : path;\n}\n\nfunction shortDate(iso: string | undefined): string {\n\tif (!iso) return \"unknown\";\n\tconst date = new Date(iso);\n\treturn Number.isNaN(date.getTime()) ? \"unknown\" : date.toISOString().slice(0, 10);\n}\n\n/** The settings keys `/learn` reads, paired with the values in force right now. */\ntype LearnWindow = ReturnType<SettingsManager[\"getLearnSettings\"]>;\n\nconst SETTING_KEYS: Array<{ key: keyof LearnWindow; setting: string; note: string }> = [\n\t{ key: \"maxSessions\", setting: \"learnMaxSessions\", note: \"recent sessions scanned\" },\n\t{ key: \"maxAgeDays\", setting: \"learnMaxAgeDays\", note: \"ignore sessions older than this, in days\" },\n\t{ key: \"minRepeats\", setting: \"learnMinRepeats\", note: \"times a directive must recur to be proposed\" },\n\t{\n\t\tkey: \"minRequestRepeats\",\n\t\tsetting: \"learnMinRequestRepeats\",\n\t\tnote: \"repeats before a tool sequence is proposed\",\n\t},\n\t{ key: \"maxProposals\", setting: \"learnMaxProposals\", note: \"cap on each list in the digest\" },\n];\n\n/**\n * Where the knobs live, and what they are set to.\n *\n * `/learn` has its settings and no UI, so until this existed the only way to\n * find them was to already know they were in `settings.json`. Every message that\n * reports a disappointing result names a threshold, so every one of them ends\n * with these lines.\n */\nfunction settingsPathLines(ctx: ExtensionCommandContext, agentDir: string): string[] {\n\treturn [\n\t\t\"Settings — edit either file, no restart needed\",\n\t\t` user ${displayPath(join(agentDir, \"settings.json\"))}`,\n\t\t` project ${displayPath(join(ctx.cwd, CONFIG_DIR_NAME, \"settings.json\"))} (wins where both set a key)`,\n\t];\n}\n\nfunction settingsLines(ctx: ExtensionCommandContext, agentDir: string, window: LearnWindow): string[] {\n\tconst lines = settingsPathLines(ctx, agentDir);\n\tfor (const { key, setting, note } of SETTING_KEYS) {\n\t\tlines.push(` ${setting.padEnd(24)} ${String(window[key] ?? \"—\").padStart(3)} ${note}`);\n\t}\n\treturn lines;\n}\n\n/**\n * The directory whose name keys this cwd's bookmark.\n *\n * Derived from the cwd, never from the live session manager. An in-memory\n * session (`--no-session`) reports an empty session directory, which used to key\n * every such run to the same nameless state file, and a shared custom\n * `sessionDir` used to make two unrelated projects share one bookmark. The cwd\n * is what \"per directory\" means here, so the cwd is what it is keyed on.\n */\nfunction stateKeyDir(ctx: ExtensionCommandContext, agentDir: string): string {\n\treturn getSessionDirPath(ctx.cwd, agentDir);\n}\n\n/** Run the directory scan without mining anything, for the reports that only need counts. */\nfunction sessionScanPreview(ctx: ExtensionCommandContext, agentDir: string, window: LearnWindow): SessionScanReport {\n\treturn scanSessions({\n\t\tcwd: ctx.cwd,\n\t\tagentDir,\n\t\tsessionDir: ctx.sessionManager.getSessionDir(),\n\t\tmaxSessions: window.maxSessions,\n\t\tmaxAgeDays: window.maxAgeDays,\n\t});\n}\n\n/** Where sessions were looked for, and what was passed over — the \"why nothing?\" answer. */\nfunction scanLines(scan: SessionScanReport, window: LearnWindow): string[] {\n\tconst lines: string[] = [\"Looked in\"];\n\tfor (const dir of scan.dirs) {\n\t\tconst missing = scan.missingDirs.includes(dir) ? \" (does not exist)\" : \"\";\n\t\tlines.push(` ${displayPath(dir)}${missing}`);\n\t}\n\tlines.push(`Found ${scan.files} session file(s)`);\n\n\tconst skips: string[] = [];\n\tif (scan.tooOld > 0) skips.push(`${scan.tooOld} older than ${window.maxAgeDays} days (learnMaxAgeDays)`);\n\tif (scan.otherCwd > 0) skips.push(`${scan.otherCwd} recorded a different working directory`);\n\tif (scan.overLimit > 0) skips.push(`${scan.overLimit} beyond the newest ${window.maxSessions} (learnMaxSessions)`);\n\tif (scan.unreadable > 0) skips.push(`${scan.unreadable} empty or unreadable`);\n\tfor (const skip of skips) lines.push(` skipped: ${skip}`);\n\treturn lines;\n}\n\n/**\n * Explain an empty scan rather than asserting there is no history.\n *\n * The old single sentence was wrong as often as it was right: sessions existed,\n * they were simply all outside the window or recorded under another path. Naming\n * the directory searched and the reason each file was passed over turns a dead\n * end into something the reader can fix.\n */\nfunction reportNoSessions(ctx: ExtensionCommandContext, agentDir: string, digest: LearnDigest, window: LearnWindow) {\n\tconst lines: string[] = [];\n\tlines.push(\n\t\tdigest.scan.files === 0\n\t\t\t? \"/learn found no session transcripts for this directory.\"\n\t\t\t: \"/learn found session transcripts, but none inside the current window.\",\n\t);\n\tlines.push(\"\");\n\tlines.push(...scanLines(digest.scan, window));\n\tlines.push(\"\");\n\tlines.push(...settingsLines(ctx, agentDir, window));\n\tctx.ui.notify(lines.join(\"\\n\"), \"warning\");\n}\n\n/**\n * The model that reads transcripts.\n *\n * This is the one call in the pipeline that reads *everything*, so it wants the\n * cheapest capable model rather than the session's. That question already has an\n * answer in this codebase — the `fast` model category, which subagents use for\n * exactly this kind of bulk read — so it is reused rather than reinvented.\n * `settings.modelCategories.fast` wins when set; otherwise the tier is derived\n * from the user's available models, and nothing here is provider-specific.\n *\n * Falls back to the session model when the tier resolves to nothing or to a\n * model the registry cannot find, since a mis-set tier should not take the\n * command out entirely.\n */\nfunction resolveMinerModel(ctx: ExtensionCommandContext, settings: SettingsManager): Model<Api> | undefined {\n\tconst ref = resolveModelCategory(\n\t\t\"fast\",\n\t\t{\n\t\t\tmodelCategories: settings.getModelCategories(),\n\t\t\tdefaultProvider: settings.getDefaultProvider(),\n\t\t\tdefaultModel: settings.getDefaultModel(),\n\t\t},\n\t\tctx.modelRegistry.getAvailable(),\n\t);\n\tif (!ref) return ctx.model;\n\n\tconst slash = ref.indexOf(\"/\");\n\tconst found = slash > 0 ? ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1)) : undefined;\n\treturn found ?? ctx.model;\n}\n\n/**\n * Literal runs from the slash commands in force, so the miner can tell a\n * command body replaying itself from something the user typed.\n *\n * Read from the session's own command list rather than re-deriving the search\n * path: which directories are scanned, in which order, and which flags disable\n * them is a precedence list that lives in one place and would drift the moment\n * it lived in two.\n */\nfunction loadReplayFingerprints(pi: ExtensionAPI): string[] {\n\tconst bodies: Array<{ content: string }> = [];\n\tfor (const command of pi.getCommands()) {\n\t\tconst path = command.sourceInfo?.path;\n\t\t// A built-in has no file behind it, and nothing to replay.\n\t\tif (!path || !existsSync(path)) continue;\n\t\ttry {\n\t\t\tbodies.push({ content: readFileSync(path, \"utf-8\") });\n\t\t} catch {\n\t\t\t// Unreadable command file: one fewer fingerprint, not a failed run.\n\t\t}\n\t}\n\treturn replayFingerprints(bodies);\n}\n\n/** Build the two model-backed stages, or report why they cannot be built. */\nasync function buildPipeline(\n\tctx: ExtensionCommandContext,\n\tsettings: SettingsManager,\n\t/** Empty for callers that only need the coverage judge; mining wants the real set. */\n\tfingerprints: string[] = [],\n): Promise<\n\t{ miner: Miner; clusterer: Clusterer; coverageJudge: CoverageJudge; model: Model<Api> } | { error: string }\n> {\n\tconst model = resolveMinerModel(ctx, settings);\n\tif (!model) {\n\t\treturn {\n\t\t\terror: \"/learn reads session transcripts with a model, and no model is selected. Pick one with /model, then run /learn again.\",\n\t\t};\n\t}\n\n\tconst auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);\n\tif (!auth.ok) {\n\t\treturn { error: `/learn could not authenticate ${model.provider}/${model.id}: ${auth.error}` };\n\t}\n\n\tconst deps = {\n\t\tmodel,\n\t\tapiKey: auth.apiKey,\n\t\theaders: auth.headers,\n\t\treplayFingerprints: fingerprints,\n\t};\n\treturn {\n\t\tminer: createLlmMiner(deps),\n\t\tclusterer: createLlmClusterer(deps),\n\t\tcoverageJudge: createLlmCoverageJudge(deps),\n\t\tmodel,\n\t};\n}\n\n/**\n * What this run still owes the model.\n *\n * Delegated to `planMining` so the number quoted by the confirmation prompt\n * comes from the same session selection the run will use — same window, same\n * cwd check, same de-duplication.\n */\nfunction pendingWork(ctx: ExtensionCommandContext, agentDir: string, window: LearnWindow) {\n\treturn planMining({\n\t\tcwd: ctx.cwd,\n\t\tagentDir,\n\t\tsessionDir: ctx.sessionManager.getSessionDir(),\n\t\tmaxSessions: window.maxSessions,\n\t\tmaxAgeDays: window.maxAgeDays,\n\t});\n}\n\n/**\n * `/learn stats` — what has been proposed here, and what it costs.\n *\n * Reads the state file and the context files. No model call: this used to\n * re-judge coverage and report an \"adoption rate\", which was unreliable in both\n * directions and shipped with two disclaimers explaining how not to misread it.\n * The honest version of the question it was trying to answer — is the\n * always-loaded surface growing — is a number the filesystem can answer exactly.\n */\nfunction reportStats(ctx: ExtensionCommandContext): void {\n\tconst agentDir = getHooCodeDir();\n\tconst settings = SettingsManager.create(ctx.cwd, agentDir);\n\tconst window = settings.getLearnSettings();\n\tconst statePath = getLearnStatePath(agentDir, stateKeyDir(ctx, agentDir));\n\tconst state = readLearnState(statePath);\n\n\tif (Object.keys(state.surfaced).length === 0) {\n\t\t// Nothing on record means `/learn` has never proposed anything here — which\n\t\t// is as likely to be \"it never found any sessions\" as \"you never ran it\", so\n\t\t// point at both the sessions it can see and the knobs that gate them.\n\t\tconst lines = [\"No /learn history for this directory yet — nothing has been proposed here.\"];\n\t\tlines.push(` State file ${displayPath(statePath)} (not created yet)`);\n\t\tlines.push(\"\");\n\t\tlines.push(...scanLines(sessionScanPreview(ctx, agentDir, window), window));\n\t\tlines.push(\"\");\n\t\tlines.push(...settingsPathLines(ctx, agentDir));\n\t\tlines.push(\" Run /learn settings for the thresholds in force.\");\n\t\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n\t\treturn;\n\t}\n\n\tconst stats = summarizeLearnState(state);\n\n\tconst contextFiles = loadProjectContextFiles({ cwd: ctx.cwd, agentDir }).agentsFiles;\n\tconst contextTokens = contextFiles.reduce((sum, file) => sum + (file.tokens ?? 0), 0);\n\n\tconst lines: string[] = [];\n\tlines.push(`/learn history for this directory — ${shortDate(stats.earliest)} to ${shortDate(stats.latest)}`);\n\tlines.push(\n\t\t` Proposals shown ${stats.total} (${stats.directives} directive, ${stats.fixes} fix, ${stats.requests} request)`,\n\t);\n\tif (stats.lastRun) lines.push(` Last run ${shortDate(stats.lastRun)}`);\n\tlines.push(\"\");\n\n\t// The one number worth watching, and the only one here that is exact. Mining\n\t// can only push it up; `/learn stale` is what pushes it down.\n\tlines.push(`Always-loaded cost ~${contextTokens} tokens across ${contextFiles.length} context file(s)`);\n\tfor (const file of contextFiles) {\n\t\tlines.push(` ~${file.tokens ?? 0} ${displayPath(file.path)}`);\n\t}\n\tlines.push(\" Run /learn stale to find lines naming something that no longer exists.\");\n\tlines.push(\"\");\n\tlines.push(`State file ${displayPath(statePath)}`);\n\tlines.push(\"\");\n\tlines.push(...settingsPathLines(ctx, agentDir));\n\tlines.push(\" Run /learn settings for the thresholds in force.\");\n\n\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n}\n\n/**\n * `/learn stale` — which lines in the context files name something that is gone.\n *\n * The mining path can only propose additions, so this is the only half of the\n * command that moves the always-loaded token surface down. It is deterministic\n * and costs nothing, which is what makes it the half worth running often; the\n * findings go to the model only when there are some, so a clean audit is free.\n */\nfunction reportAudit(pi: ExtensionAPI, ctx: ExtensionCommandContext): void {\n\tconst agentDir = getHooCodeDir();\n\tconst { agentsFiles } = loadProjectContextFiles({ cwd: ctx.cwd, agentDir });\n\n\tif (agentsFiles.length === 0) {\n\t\tctx.ui.notify(\"/learn stale found no context files to check (no AGENTS.md or CLAUDE.md is in force).\", \"warning\");\n\t\treturn;\n\t}\n\n\tconst report = auditContextFiles({ cwd: ctx.cwd, files: agentsFiles });\n\n\tif (report.files.length === 0) {\n\t\tconst lines = [\"/learn stale checked nothing — every context file in force is outside this working tree.\"];\n\t\tfor (const path of report.skippedFiles) lines.push(` ${displayPath(path)}`);\n\t\tlines.push(\"A rule written in a user-scope file names paths in whatever repo it was written for, not this one.\");\n\t\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n\t\treturn;\n\t}\n\n\tconst totalTokens = report.files.reduce((sum, file) => sum + file.tokens, 0);\n\tif (report.stale.length === 0) {\n\t\tctx.ui.notify(\n\t\t\t`/learn stale — ${report.checked} referent(s) in ${report.files.length} context file(s) all resolve. ` +\n\t\t\t\t`~${totalTokens} tokens, re-sent every request.`,\n\t\t\t\"info\",\n\t\t);\n\t\treturn;\n\t}\n\n\tctx.ui.notify(\n\t\t`/learn stale — ${report.stale.length} of ${report.checked} referent(s) do not resolve ` +\n\t\t\t`(~${staleTokens(report)} of ~${totalTokens} always-loaded tokens).`,\n\t\t\"info\",\n\t);\n\tpi.sendUserMessage(renderAuditReport(report), { deliverAs: \"followUp\" });\n}\n\n/** `/learn settings` — the knobs, their current values, and the files to set them in. */\nfunction reportSettings(ctx: ExtensionCommandContext): void {\n\tconst agentDir = getHooCodeDir();\n\tconst settings = SettingsManager.create(ctx.cwd, agentDir);\n\tconst window = settings.getLearnSettings();\n\tconst lines = settingsLines(ctx, agentDir, window);\n\n\t// The reading model is not a `/learn` setting — it is the shared `fast` tier,\n\t// so name it here rather than leaving the reader to guess which model is\n\t// about to read their history, and point at the setting that changes it.\n\tconst model = resolveMinerModel(ctx, settings);\n\tlines.push(\n\t\t` reads transcripts with ${model ? `${model.provider}/${model.id}` : \"no model selected\"}` +\n\t\t\t` (the \\`fast\\` tier — set modelCategories.fast to change it)`,\n\t);\n\tif (model) {\n\t\tlines.push(\n\t\t\t` ${Math.round(chunkCharsForModel(model) / 1000)}k characters per call, from its ${model.contextWindow} token window`,\n\t\t);\n\t}\n\n\tlines.push(\"\");\n\tlines.push(...scanLines(sessionScanPreview(ctx, agentDir, window), window));\n\tlines.push(`State file ${displayPath(getLearnStatePath(agentDir, stateKeyDir(ctx, agentDir)))}`);\n\tconst { pending } = pendingWork(ctx, agentDir, window);\n\tlines.push(\n\t\tpending === 0\n\t\t\t? \"All sessions in the window are already mined; the next /learn costs one small coverage call.\"\n\t\t\t: `${pending} session(s) in the window still need reading, roughly one call each.`,\n\t);\n\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n}\n\n/**\n * `/learn` (and `/learn all`) — read the window, rank what recurred, hand it to\n * the model.\n *\n * Lives out here rather than inside the handler so the run it registers has one\n * `finally` covering every exit, and so the staleness checks below read as the\n * sequence of points at which the session can vanish: the auth round-trip, the\n * confirmation prompt, and the mining pass itself. Every one of them is an\n * `await` long enough for a `/new` or a `/mode` to land in the middle of it.\n */\nasync function runMining(pi: ExtensionAPI, ctx: ExtensionCommandContext, ignoreState: boolean): Promise<void> {\n\t// Read per-invocation so a settings edit takes effect without a reload,\n\t// and so a project settings.json can narrow the window for one repo.\n\tconst agentDir = getHooCodeDir();\n\tconst settings = SettingsManager.create(ctx.cwd, agentDir);\n\tconst window = settings.getLearnSettings();\n\tconst statePath = getLearnStatePath(agentDir, stateKeyDir(ctx, agentDir));\n\n\t// Registered before the first await: a run that is not in the set is a run\n\t// `session_shutdown` cannot reach.\n\tconst run: LearnRun = { controller: new AbortController(), stale: false };\n\tIN_FLIGHT.add(run);\n\ttry {\n\t\tconst pipeline = await buildPipeline(ctx, settings, loadReplayFingerprints(pi));\n\t\tif (run.stale) return;\n\t\tif (\"error\" in pipeline) {\n\t\t\tctx.ui.notify(pipeline.error, \"error\");\n\t\t\treturn;\n\t\t}\n\n\t\t// State the price before charging it. A first run in a busy repo reads\n\t\t// every transcript in the window, which is the expensive path by design\n\t\t// — but it should never be a surprise, and the cache means it is paid\n\t\t// once rather than on every run.\n\t\tconst { pending } = pendingWork(ctx, agentDir, window);\n\t\tif (pending > CONFIRM_ABOVE_PENDING) {\n\t\t\tconst proceed = await ctx.ui.confirm(\n\t\t\t\t\"Read session transcripts?\",\n\t\t\t\t`${pending} session(s) have not been read yet. /learn reads each one with a model ` +\n\t\t\t\t\t`(${pipeline.model.provider}/${pipeline.model.id}) and caches the result, so this cost is paid once ` +\n\t\t\t\t\t`per session. Later runs reuse it.`,\n\t\t\t);\n\t\t\tif (run.stale) return;\n\t\t\tif (!proceed) {\n\t\t\t\tctx.ui.notify(\"/learn cancelled — nothing was read.\", \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\t// A backfill can run for minutes across dozens of transcripts, and the\n\t\t// agent is idle throughout — so `ctx.signal` is undefined and there is no\n\t\t// ambient way out. Escape gets one, and so does a session replacement,\n\t\t// through the same controller.\n\t\tconst unsubscribe = ctx.ui.onTerminalInput((data) => {\n\t\t\tif (data !== ESCAPE) return undefined;\n\t\t\trun.controller.abort();\n\t\t\treturn { consume: true };\n\t\t});\n\n\t\tlet digest: LearnDigest;\n\t\ttry {\n\t\t\tdigest = await mineLearnDigest({\n\t\t\t\tcwd: ctx.cwd,\n\t\t\t\tagentDir,\n\t\t\t\t// Searched in addition to the per-cwd default directory, so a session\n\t\t\t\t// manager pointing elsewhere (`--session`, a custom `sessionDir`, or\n\t\t\t\t// an in-memory session reporting none at all) cannot hide the history.\n\t\t\t\tsessionDir: ctx.sessionManager.getSessionDir(),\n\t\t\t\tmaxSessions: window.maxSessions,\n\t\t\t\tmaxAgeDays: window.maxAgeDays,\n\t\t\t\tminRepeats: window.minRepeats,\n\t\t\t\tminRequestRepeats: window.minRequestRepeats,\n\t\t\t\tmaxProposals: window.maxProposals,\n\t\t\t\tstate: readLearnState(statePath),\n\t\t\t\tignoreState,\n\t\t\t\tminer: pipeline.miner,\n\t\t\t\tclusterer: pipeline.clusterer,\n\t\t\t\tcoverageJudge: pipeline.coverageJudge,\n\t\t\t\tsignal: run.controller.signal,\n\t\t\t\tonProgress: ({ done, total, cached }) => {\n\t\t\t\t\t// The same footer bar the semantic index uses. Cached sessions are\n\t\t\t\t\t// counted as done because they are: the bar measures progress\n\t\t\t\t\t// through the window, not money spent, and a run that is mostly\n\t\t\t\t\t// cache should look nearly finished from the start.\n\t\t\t\t\tstartupProgress.set({\n\t\t\t\t\t\tkey: PROGRESS_KEY,\n\t\t\t\t\t\tkind: \"work\",\n\t\t\t\t\t\tlabel:\n\t\t\t\t\t\t\tcached > 0\n\t\t\t\t\t\t\t\t? `Reading sessions (${cached} cached) — esc to stop`\n\t\t\t\t\t\t\t\t: \"Reading sessions — esc to stop\",\n\t\t\t\t\t\tdone,\n\t\t\t\t\t\ttotal,\n\t\t\t\t\t\tunit: \"sessions\",\n\t\t\t\t\t});\n\t\t\t\t},\n\t\t\t});\n\t\t} catch (error) {\n\t\t\t// A session that went away mid-read is not a failure to report: the\n\t\t\t// place it would be reported to is exactly what stopped existing.\n\t\t\tif (run.stale) return;\n\t\t\tctx.ui.notify(`/learn could not read session history: ${error}`, \"error\");\n\t\t\treturn;\n\t\t} finally {\n\t\t\tunsubscribe();\n\t\t\tstartupProgress.remove(PROGRESS_KEY);\n\t\t}\n\n\t\t// The session this run belongs to has been replaced. Say nothing and write\n\t\t// nothing: `ctx` throws on use from here, the bookmark would record\n\t\t// proposals nobody was shown, and every transcript read is already cached\n\t\t// for whichever session runs /learn next.\n\t\tif (run.stale) return;\n\n\t\t// A cancelled run counted only part of the window, so its numbers are not\n\t\t// merely incomplete — they are low. Showing them would be misleading and\n\t\t// bookmarking them would hide those items on the next, complete run.\n\t\t// Everything read so far is cached, so stopping costs nothing but time.\n\t\tif (digest.aborted) {\n\t\t\tctx.ui.notify(\n\t\t\t\t`/learn stopped — ${digest.mining.mined} session(s) were read and cached, so resuming picks up where this left off.`,\n\t\t\t\t\"info\",\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tif (digest.scannedSessions === 0) {\n\t\t\treportNoSessions(ctx, agentDir, digest, window);\n\t\t\treturn;\n\t\t}\n\n\t\tif (isEmptyDigest(digest)) {\n\t\t\tconst lines: string[] = [];\n\t\t\tlines.push(\n\t\t\t\tdigest.suppressed > 0\n\t\t\t\t\t? `Read ${digest.scannedSessions} session(s) — nothing new since last time (${digest.suppressed} already shown). Run /learn all to see them again.`\n\t\t\t\t\t: `Read ${digest.scannedSessions} session(s) — nothing repeated often enough to be worth a rule yet.`,\n\t\t\t);\n\t\t\tif (digest.suppressed === 0) {\n\t\t\t\t// Which of the two empty results this is. \"Nothing was said\" and \"a\n\t\t\t\t// lot was said and none of it repeated\" read identically otherwise,\n\t\t\t\t// and they point at completely different knobs.\n\t\t\t\tlines.push(\n\t\t\t\t\t` ${digest.funnel.candidates} occurrence(s) → ${digest.funnel.points} distinct point(s) → ` +\n\t\t\t\t\t\t`${digest.funnel.belowThreshold} below the repeat threshold`,\n\t\t\t\t);\n\t\t\t\tlines.push(\"\");\n\t\t\t\tlines.push(...settingsLines(ctx, agentDir, window));\n\t\t\t}\n\t\t\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst counts = [\n\t\t\tdigest.directives.length > 0 ? `${digest.directives.length} directive(s)` : undefined,\n\t\t\tdigest.fixes.length > 0 ? `${digest.fixes.length} fix(es)` : undefined,\n\t\t\tdigest.requests.length > 0 ? `${digest.requests.length} request(s)` : undefined,\n\t\t].filter((part): part is string => !!part);\n\t\tconst held = digest.suppressed > 0 ? `, ${digest.suppressed} held back` : \"\";\n\t\tconst cut = digest.cut > 0 ? `, ${digest.cut} cut to fit the cap` : \"\";\n\t\tctx.ui.notify(\n\t\t\t`Mined ${digest.scannedSessions} session(s) (${digest.mining.mined} read, ${digest.mining.cached} cached): ${counts.join(\", \")}${held}${cut}.`,\n\t\t\t\"info\",\n\t\t);\n\n\t\t// Record before delivering: what matters is that these were put in front\n\t\t// of the user, which is true whether or not they act on the digest.\n\t\t//\n\t\t// Unless coverage could not be read. The bookmark stores whether an item\n\t\t// was already written down when it was shown, and that is what later tells\n\t\t// an adopted proposal from one passed over. Recording a guess as a reading\n\t\t// would have a later run tell the user they passed on something they were\n\t\t// never shown. Skipping costs one round of re-proposing.\n\t\tif (!digest.coverageFailed) {\n\t\t\twriteLearnState(statePath, recordSurfaced(readLearnState(statePath), digest.surfaced));\n\t\t}\n\n\t\tpi.sendUserMessage(\n\t\t\trenderLearnDigest(digest, {\n\t\t\t\tuserScopePath: displayPath(USER_SCOPE_PATH),\n\t\t\t\tmode: ignoreState ? \"all\" : \"incremental\",\n\t\t\t}),\n\t\t\t{ deliverAs: \"followUp\" },\n\t\t);\n\t} finally {\n\t\tIN_FLIGHT.delete(run);\n\t}\n}\n\nexport function setupLearn(pi: ExtensionAPI): void {\n\tconst guarded = pi as unknown as Record<symbol, boolean>;\n\tif (guarded[REGISTERED]) return;\n\tguarded[REGISTERED] = true;\n\n\tpi.registerCommand(\"learn\", {\n\t\tdescription: \"Mine recent sessions for durable rules and skills. Usage: /learn [all|stale|stats|settings]\",\n\t\tgetArgumentCompletions: (prefix: string) =>\n\t\t\t(\n\t\t\t\t[\n\t\t\t\t\t{ value: \"all\", label: \"re-propose everything\" },\n\t\t\t\t\t{ value: \"stale\", label: \"context-file lines naming something that is gone\" },\n\t\t\t\t\t{ value: \"stats\", label: \"what happened to past proposals\" },\n\t\t\t\t\t{ value: \"settings\", label: \"where sessions are read from, and the knobs\" },\n\t\t\t\t] as const\n\t\t\t)\n\t\t\t\t.filter((option) => option.value.startsWith(prefix))\n\t\t\t\t.map((option) => ({ value: option.value, label: option.label })),\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst argument = args.trim().toLowerCase();\n\t\t\tif (argument && ![\"all\", \"stale\", \"stats\", \"settings\"].includes(argument)) {\n\t\t\t\tctx.ui.notify(\"Usage: /learn [all|stale|stats|settings]\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (argument === \"stale\") {\n\t\t\t\treportAudit(pi, ctx);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (argument === \"stats\") {\n\t\t\t\treportStats(ctx);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (argument === \"settings\") {\n\t\t\t\treportSettings(ctx);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait runMining(pi, ctx, argument === \"all\");\n\t\t},\n\t});\n\n\t// The one notice a run in flight gets that its session is being replaced.\n\t// Emitted before the session is disposed, which is what makes it usable: a\n\t// run told here still has a live ctx to stop cleanly with, where one told\n\t// afterwards has none.\n\tpi.on(\"session_shutdown\", () => {\n\t\tabortInFlightRuns();\n\t});\n}\n"]}
|
|
@@ -45,6 +45,32 @@ const USER_SCOPE_PATH = join(homedir(), ".agents", "AGENTS.md");
|
|
|
45
45
|
const PROGRESS_KEY = "learn-mining";
|
|
46
46
|
/** Escape, the way a raw terminal delivers it. */
|
|
47
47
|
const ESCAPE = "\x1b";
|
|
48
|
+
/**
|
|
49
|
+
* The mining runs currently in flight.
|
|
50
|
+
*
|
|
51
|
+
* `/learn` is the one command here that runs for minutes on end, and the session
|
|
52
|
+
* it was started in can be replaced while it does — a `/new`, a `/resume`, a
|
|
53
|
+
* `/fork`, a `/mode` that swaps the session. Whatever replaces it disposes the
|
|
54
|
+
* old session, and disposal invalidates the command ctx this run captured: the
|
|
55
|
+
* next line it tries to print throws instead, and a run the user waited minutes
|
|
56
|
+
* for surfaces as `Extension "command:learn" error: This extension ctx is
|
|
57
|
+
* stale…` with its digest thrown away.
|
|
58
|
+
*
|
|
59
|
+
* `session_shutdown` is emitted before that disposal, which makes it the one
|
|
60
|
+
* point where a run in flight can still find out. It aborts the mining pass and
|
|
61
|
+
* marks the run stale; from there the run reports nothing at all, because there
|
|
62
|
+
* is no longer anywhere to report to. Nothing is lost but time — every
|
|
63
|
+
* transcript already read is in the on-disk cache, so the next `/learn`, in
|
|
64
|
+
* whatever session replaced this one, resumes from it.
|
|
65
|
+
*/
|
|
66
|
+
const IN_FLIGHT = new Set();
|
|
67
|
+
/** Tell every run in flight that its session is going away. */
|
|
68
|
+
function abortInFlightRuns() {
|
|
69
|
+
for (const run of IN_FLIGHT) {
|
|
70
|
+
run.stale = true;
|
|
71
|
+
run.controller.abort();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
48
74
|
/**
|
|
49
75
|
* Sessions that can be read without asking first.
|
|
50
76
|
*
|
|
@@ -364,6 +390,174 @@ function reportSettings(ctx) {
|
|
|
364
390
|
: `${pending} session(s) in the window still need reading, roughly one call each.`);
|
|
365
391
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
366
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* `/learn` (and `/learn all`) — read the window, rank what recurred, hand it to
|
|
395
|
+
* the model.
|
|
396
|
+
*
|
|
397
|
+
* Lives out here rather than inside the handler so the run it registers has one
|
|
398
|
+
* `finally` covering every exit, and so the staleness checks below read as the
|
|
399
|
+
* sequence of points at which the session can vanish: the auth round-trip, the
|
|
400
|
+
* confirmation prompt, and the mining pass itself. Every one of them is an
|
|
401
|
+
* `await` long enough for a `/new` or a `/mode` to land in the middle of it.
|
|
402
|
+
*/
|
|
403
|
+
async function runMining(pi, ctx, ignoreState) {
|
|
404
|
+
// Read per-invocation so a settings edit takes effect without a reload,
|
|
405
|
+
// and so a project settings.json can narrow the window for one repo.
|
|
406
|
+
const agentDir = getHooCodeDir();
|
|
407
|
+
const settings = SettingsManager.create(ctx.cwd, agentDir);
|
|
408
|
+
const window = settings.getLearnSettings();
|
|
409
|
+
const statePath = getLearnStatePath(agentDir, stateKeyDir(ctx, agentDir));
|
|
410
|
+
// Registered before the first await: a run that is not in the set is a run
|
|
411
|
+
// `session_shutdown` cannot reach.
|
|
412
|
+
const run = { controller: new AbortController(), stale: false };
|
|
413
|
+
IN_FLIGHT.add(run);
|
|
414
|
+
try {
|
|
415
|
+
const pipeline = await buildPipeline(ctx, settings, loadReplayFingerprints(pi));
|
|
416
|
+
if (run.stale)
|
|
417
|
+
return;
|
|
418
|
+
if ("error" in pipeline) {
|
|
419
|
+
ctx.ui.notify(pipeline.error, "error");
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
// State the price before charging it. A first run in a busy repo reads
|
|
423
|
+
// every transcript in the window, which is the expensive path by design
|
|
424
|
+
// — but it should never be a surprise, and the cache means it is paid
|
|
425
|
+
// once rather than on every run.
|
|
426
|
+
const { pending } = pendingWork(ctx, agentDir, window);
|
|
427
|
+
if (pending > CONFIRM_ABOVE_PENDING) {
|
|
428
|
+
const proceed = await ctx.ui.confirm("Read session transcripts?", `${pending} session(s) have not been read yet. /learn reads each one with a model ` +
|
|
429
|
+
`(${pipeline.model.provider}/${pipeline.model.id}) and caches the result, so this cost is paid once ` +
|
|
430
|
+
`per session. Later runs reuse it.`);
|
|
431
|
+
if (run.stale)
|
|
432
|
+
return;
|
|
433
|
+
if (!proceed) {
|
|
434
|
+
ctx.ui.notify("/learn cancelled — nothing was read.", "info");
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// A backfill can run for minutes across dozens of transcripts, and the
|
|
439
|
+
// agent is idle throughout — so `ctx.signal` is undefined and there is no
|
|
440
|
+
// ambient way out. Escape gets one, and so does a session replacement,
|
|
441
|
+
// through the same controller.
|
|
442
|
+
const unsubscribe = ctx.ui.onTerminalInput((data) => {
|
|
443
|
+
if (data !== ESCAPE)
|
|
444
|
+
return undefined;
|
|
445
|
+
run.controller.abort();
|
|
446
|
+
return { consume: true };
|
|
447
|
+
});
|
|
448
|
+
let digest;
|
|
449
|
+
try {
|
|
450
|
+
digest = await mineLearnDigest({
|
|
451
|
+
cwd: ctx.cwd,
|
|
452
|
+
agentDir,
|
|
453
|
+
// Searched in addition to the per-cwd default directory, so a session
|
|
454
|
+
// manager pointing elsewhere (`--session`, a custom `sessionDir`, or
|
|
455
|
+
// an in-memory session reporting none at all) cannot hide the history.
|
|
456
|
+
sessionDir: ctx.sessionManager.getSessionDir(),
|
|
457
|
+
maxSessions: window.maxSessions,
|
|
458
|
+
maxAgeDays: window.maxAgeDays,
|
|
459
|
+
minRepeats: window.minRepeats,
|
|
460
|
+
minRequestRepeats: window.minRequestRepeats,
|
|
461
|
+
maxProposals: window.maxProposals,
|
|
462
|
+
state: readLearnState(statePath),
|
|
463
|
+
ignoreState,
|
|
464
|
+
miner: pipeline.miner,
|
|
465
|
+
clusterer: pipeline.clusterer,
|
|
466
|
+
coverageJudge: pipeline.coverageJudge,
|
|
467
|
+
signal: run.controller.signal,
|
|
468
|
+
onProgress: ({ done, total, cached }) => {
|
|
469
|
+
// The same footer bar the semantic index uses. Cached sessions are
|
|
470
|
+
// counted as done because they are: the bar measures progress
|
|
471
|
+
// through the window, not money spent, and a run that is mostly
|
|
472
|
+
// cache should look nearly finished from the start.
|
|
473
|
+
startupProgress.set({
|
|
474
|
+
key: PROGRESS_KEY,
|
|
475
|
+
kind: "work",
|
|
476
|
+
label: cached > 0
|
|
477
|
+
? `Reading sessions (${cached} cached) — esc to stop`
|
|
478
|
+
: "Reading sessions — esc to stop",
|
|
479
|
+
done,
|
|
480
|
+
total,
|
|
481
|
+
unit: "sessions",
|
|
482
|
+
});
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
// A session that went away mid-read is not a failure to report: the
|
|
488
|
+
// place it would be reported to is exactly what stopped existing.
|
|
489
|
+
if (run.stale)
|
|
490
|
+
return;
|
|
491
|
+
ctx.ui.notify(`/learn could not read session history: ${error}`, "error");
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
unsubscribe();
|
|
496
|
+
startupProgress.remove(PROGRESS_KEY);
|
|
497
|
+
}
|
|
498
|
+
// The session this run belongs to has been replaced. Say nothing and write
|
|
499
|
+
// nothing: `ctx` throws on use from here, the bookmark would record
|
|
500
|
+
// proposals nobody was shown, and every transcript read is already cached
|
|
501
|
+
// for whichever session runs /learn next.
|
|
502
|
+
if (run.stale)
|
|
503
|
+
return;
|
|
504
|
+
// A cancelled run counted only part of the window, so its numbers are not
|
|
505
|
+
// merely incomplete — they are low. Showing them would be misleading and
|
|
506
|
+
// bookmarking them would hide those items on the next, complete run.
|
|
507
|
+
// Everything read so far is cached, so stopping costs nothing but time.
|
|
508
|
+
if (digest.aborted) {
|
|
509
|
+
ctx.ui.notify(`/learn stopped — ${digest.mining.mined} session(s) were read and cached, so resuming picks up where this left off.`, "info");
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (digest.scannedSessions === 0) {
|
|
513
|
+
reportNoSessions(ctx, agentDir, digest, window);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (isEmptyDigest(digest)) {
|
|
517
|
+
const lines = [];
|
|
518
|
+
lines.push(digest.suppressed > 0
|
|
519
|
+
? `Read ${digest.scannedSessions} session(s) — nothing new since last time (${digest.suppressed} already shown). Run /learn all to see them again.`
|
|
520
|
+
: `Read ${digest.scannedSessions} session(s) — nothing repeated often enough to be worth a rule yet.`);
|
|
521
|
+
if (digest.suppressed === 0) {
|
|
522
|
+
// Which of the two empty results this is. "Nothing was said" and "a
|
|
523
|
+
// lot was said and none of it repeated" read identically otherwise,
|
|
524
|
+
// and they point at completely different knobs.
|
|
525
|
+
lines.push(` ${digest.funnel.candidates} occurrence(s) → ${digest.funnel.points} distinct point(s) → ` +
|
|
526
|
+
`${digest.funnel.belowThreshold} below the repeat threshold`);
|
|
527
|
+
lines.push("");
|
|
528
|
+
lines.push(...settingsLines(ctx, agentDir, window));
|
|
529
|
+
}
|
|
530
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const counts = [
|
|
534
|
+
digest.directives.length > 0 ? `${digest.directives.length} directive(s)` : undefined,
|
|
535
|
+
digest.fixes.length > 0 ? `${digest.fixes.length} fix(es)` : undefined,
|
|
536
|
+
digest.requests.length > 0 ? `${digest.requests.length} request(s)` : undefined,
|
|
537
|
+
].filter((part) => !!part);
|
|
538
|
+
const held = digest.suppressed > 0 ? `, ${digest.suppressed} held back` : "";
|
|
539
|
+
const cut = digest.cut > 0 ? `, ${digest.cut} cut to fit the cap` : "";
|
|
540
|
+
ctx.ui.notify(`Mined ${digest.scannedSessions} session(s) (${digest.mining.mined} read, ${digest.mining.cached} cached): ${counts.join(", ")}${held}${cut}.`, "info");
|
|
541
|
+
// Record before delivering: what matters is that these were put in front
|
|
542
|
+
// of the user, which is true whether or not they act on the digest.
|
|
543
|
+
//
|
|
544
|
+
// Unless coverage could not be read. The bookmark stores whether an item
|
|
545
|
+
// was already written down when it was shown, and that is what later tells
|
|
546
|
+
// an adopted proposal from one passed over. Recording a guess as a reading
|
|
547
|
+
// would have a later run tell the user they passed on something they were
|
|
548
|
+
// never shown. Skipping costs one round of re-proposing.
|
|
549
|
+
if (!digest.coverageFailed) {
|
|
550
|
+
writeLearnState(statePath, recordSurfaced(readLearnState(statePath), digest.surfaced));
|
|
551
|
+
}
|
|
552
|
+
pi.sendUserMessage(renderLearnDigest(digest, {
|
|
553
|
+
userScopePath: displayPath(USER_SCOPE_PATH),
|
|
554
|
+
mode: ignoreState ? "all" : "incremental",
|
|
555
|
+
}), { deliverAs: "followUp" });
|
|
556
|
+
}
|
|
557
|
+
finally {
|
|
558
|
+
IN_FLIGHT.delete(run);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
367
561
|
export function setupLearn(pi) {
|
|
368
562
|
const guarded = pi;
|
|
369
563
|
if (guarded[REGISTERED])
|
|
@@ -397,141 +591,15 @@ export function setupLearn(pi) {
|
|
|
397
591
|
reportSettings(ctx);
|
|
398
592
|
return;
|
|
399
593
|
}
|
|
400
|
-
|
|
401
|
-
// Read per-invocation so a settings edit takes effect without a reload,
|
|
402
|
-
// and so a project settings.json can narrow the window for one repo.
|
|
403
|
-
const agentDir = getHooCodeDir();
|
|
404
|
-
const settings = SettingsManager.create(ctx.cwd, agentDir);
|
|
405
|
-
const window = settings.getLearnSettings();
|
|
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
|
-
});
|
|
436
|
-
let digest;
|
|
437
|
-
try {
|
|
438
|
-
digest = await mineLearnDigest({
|
|
439
|
-
cwd: ctx.cwd,
|
|
440
|
-
agentDir,
|
|
441
|
-
// Searched in addition to the per-cwd default directory, so a session
|
|
442
|
-
// manager pointing elsewhere (`--session`, a custom `sessionDir`, or
|
|
443
|
-
// an in-memory session reporting none at all) cannot hide the history.
|
|
444
|
-
sessionDir: ctx.sessionManager.getSessionDir(),
|
|
445
|
-
maxSessions: window.maxSessions,
|
|
446
|
-
maxAgeDays: window.maxAgeDays,
|
|
447
|
-
minRepeats: window.minRepeats,
|
|
448
|
-
minRequestRepeats: window.minRequestRepeats,
|
|
449
|
-
maxProposals: window.maxProposals,
|
|
450
|
-
state: readLearnState(statePath),
|
|
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
|
-
},
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
catch (error) {
|
|
475
|
-
ctx.ui.notify(`/learn could not read session history: ${error}`, "error");
|
|
476
|
-
return;
|
|
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
|
-
}
|
|
490
|
-
if (digest.scannedSessions === 0) {
|
|
491
|
-
reportNoSessions(ctx, agentDir, digest, window);
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
if (isEmptyDigest(digest)) {
|
|
495
|
-
const lines = [];
|
|
496
|
-
lines.push(digest.suppressed > 0
|
|
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.`);
|
|
499
|
-
if (digest.suppressed === 0) {
|
|
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`);
|
|
505
|
-
lines.push("");
|
|
506
|
-
lines.push(...settingsLines(ctx, agentDir, window));
|
|
507
|
-
}
|
|
508
|
-
ctx.ui.notify(lines.join("\n"), "info");
|
|
509
|
-
return;
|
|
510
|
-
}
|
|
511
|
-
const counts = [
|
|
512
|
-
digest.directives.length > 0 ? `${digest.directives.length} directive(s)` : undefined,
|
|
513
|
-
digest.fixes.length > 0 ? `${digest.fixes.length} fix(es)` : undefined,
|
|
514
|
-
digest.requests.length > 0 ? `${digest.requests.length} request(s)` : undefined,
|
|
515
|
-
].filter((part) => !!part);
|
|
516
|
-
const held = digest.suppressed > 0 ? `, ${digest.suppressed} held back` : "";
|
|
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");
|
|
519
|
-
// Record before delivering: what matters is that these were put in front
|
|
520
|
-
// of the user, which is true whether or not they act on the digest.
|
|
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" });
|
|
594
|
+
await runMining(pi, ctx, argument === "all");
|
|
534
595
|
},
|
|
535
596
|
});
|
|
597
|
+
// The one notice a run in flight gets that its session is being replaced.
|
|
598
|
+
// Emitted before the session is disposed, which is what makes it usable: a
|
|
599
|
+
// run told here still has a live ctx to stop cleanly with, where one told
|
|
600
|
+
// afterwards has none.
|
|
601
|
+
pi.on("session_shutdown", () => {
|
|
602
|
+
abortInFlightRuns();
|
|
603
|
+
});
|
|
536
604
|
}
|
|
537
605
|
//# sourceMappingURL=learn.js.map
|