{"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,GAAG,EAAE,YAAY,GAAG,IAAI,CA+ClD","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(hoo: ExtensionAPI): string[] {\n\tconst bodies: Array<{ content: string }> = [];\n\tfor (const command of hoo.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(hoo: 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\thoo.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(hoo: 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(hoo));\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\thoo.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(hoo: ExtensionAPI): void {\n\tconst guarded = hoo as unknown as Record<symbol, boolean>;\n\tif (guarded[REGISTERED]) return;\n\tguarded[REGISTERED] = true;\n\n\thoo.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(hoo, 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(hoo, 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\thoo.on(\"session_shutdown\", () => {\n\t\tabortInFlightRuns();\n\t});\n}\n"]}