@kolisachint/hoocode-agent 0.5.18 → 0.5.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/dist/core/learn/audit.d.ts +136 -0
  3. package/dist/core/learn/audit.d.ts.map +1 -0
  4. package/dist/core/learn/audit.js +316 -0
  5. package/dist/core/learn/audit.js.map +1 -0
  6. package/dist/core/learn/cache.d.ts.map +1 -1
  7. package/dist/core/learn/cache.js +14 -2
  8. package/dist/core/learn/cache.js.map +1 -1
  9. package/dist/core/learn/cluster.d.ts +78 -0
  10. package/dist/core/learn/cluster.d.ts.map +1 -0
  11. package/dist/core/learn/cluster.js +184 -0
  12. package/dist/core/learn/cluster.js.map +1 -0
  13. package/dist/core/learn/coverage.d.ts.map +1 -1
  14. package/dist/core/learn/coverage.js +2 -0
  15. package/dist/core/learn/coverage.js.map +1 -1
  16. package/dist/core/learn/digest.d.ts +12 -0
  17. package/dist/core/learn/digest.d.ts.map +1 -1
  18. package/dist/core/learn/digest.js +86 -14
  19. package/dist/core/learn/digest.js.map +1 -1
  20. package/dist/core/learn/extract.d.ts +39 -4
  21. package/dist/core/learn/extract.d.ts.map +1 -1
  22. package/dist/core/learn/extract.js +170 -34
  23. package/dist/core/learn/extract.js.map +1 -1
  24. package/dist/core/learn/mine.d.ts +78 -23
  25. package/dist/core/learn/mine.d.ts.map +1 -1
  26. package/dist/core/learn/mine.js +142 -37
  27. package/dist/core/learn/mine.js.map +1 -1
  28. package/dist/core/learn/reduce.d.ts +19 -8
  29. package/dist/core/learn/reduce.d.ts.map +1 -1
  30. package/dist/core/learn/reduce.js +69 -13
  31. package/dist/core/learn/reduce.js.map +1 -1
  32. package/dist/core/learn/state.d.ts +11 -18
  33. package/dist/core/learn/state.d.ts.map +1 -1
  34. package/dist/core/learn/state.js +23 -34
  35. package/dist/core/learn/state.js.map +1 -1
  36. package/dist/core/settings-defaults.d.ts +1 -1
  37. package/dist/core/settings-defaults.d.ts.map +1 -1
  38. package/dist/core/settings-defaults.js +1 -1
  39. package/dist/core/settings-defaults.js.map +1 -1
  40. package/dist/core/settings-manager.d.ts +2 -2
  41. package/dist/core/settings-manager.d.ts.map +1 -1
  42. package/dist/core/settings-manager.js +1 -1
  43. package/dist/core/settings-manager.js.map +1 -1
  44. package/dist/core/settings-types.d.ts +1 -1
  45. package/dist/core/settings-types.d.ts.map +1 -1
  46. package/dist/core/settings-types.js.map +1 -1
  47. package/dist/extensions/core/learn.d.ts.map +1 -1
  48. package/dist/extensions/core/learn.js +128 -73
  49. package/dist/extensions/core/learn.js.map +1 -1
  50. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  51. package/dist/modes/interactive/components/settings-selector.js +1 -1
  52. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  53. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  54. package/dist/modes/interactive/interactive-mode.js +1 -1
  55. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  56. package/docs/settings.md +9 -6
  57. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  58. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  59. package/examples/extensions/sandbox/package.json +1 -1
  60. package/examples/extensions/with-deps/package.json +1 -1
  61. package/package.json +4 -4
@@ -1 +1 @@
1
- {"version":3,"file":"digest.js","sourceRoot":"","sources":["../../../src/core/learn/digest.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Renders the extractor's output into the message `/learn` injects.\n *\n * The digest is evidence plus instructions, and the split matters: the numbers\n * come from {@link extractLearnDigest} and are not negotiable, while everything\n * the model does with them — phrasing, routing, deciding a pattern is not worth\n * a rule — is judgement it has to exercise. Counts are printed on every item\n * because \"said in 5 of your last 12 sessions\" is a decision the reader can\n * make in one keystroke, where \"extracted from your session\" is not.\n */\n\nimport type { LearnDigest } from \"./extract.js\";\nimport { LEARN_DIGEST_MARKER } from \"./extract.js\";\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\nfunction evidence(count: number, sessions: number, lastSeen: string): string {\n\tconst times = count === 1 ? \"once\" : `${count}x`;\n\tconst where = sessions === 1 ? \"1 session\" : `${sessions} sessions`;\n\treturn `${times} across ${where}, last ${shortDate(lastSeen)}`;\n}\n\n/** True when there is nothing worth asking the model to look at. */\nexport function isEmptyDigest(digest: LearnDigest): boolean {\n\treturn digest.directives.length === 0 && digest.fixes.length === 0 && digest.workflows.length === 0;\n}\n\nexport function renderLearnDigest(\n\tdigest: LearnDigest,\n\toptions: { userScopePath: string; mode?: \"incremental\" | \"all\" },\n): string {\n\tconst lines: string[] = [];\n\n\tlines.push(\n\t\t`${LEARN_DIGEST_MARKER} Mined ${digest.scannedSessions} session(s) in this directory` +\n\t\t\t(digest.skippedSessions > 0 ? ` (${digest.skippedSessions} skipped: out of window or unreadable)` : \"\") +\n\t\t\t(digest.oldestSession ? `, ${shortDate(digest.oldestSession)} to ${shortDate(digest.newestSession)}` : \"\") +\n\t\t\t(digest.suppressed > 0 ? `. ${digest.suppressed} item(s) held back — already shown and unchanged since` : \"\") +\n\t\t\t\".\",\n\t);\n\t// Naming the mode keeps two very different empty results from reading alike:\n\t// \"nothing new since last time\" and \"nothing here at all\" are not the same\n\t// answer, and the reader cannot tell them apart from the counts.\n\tif (options.mode === \"all\") {\n\t\tlines.push(\"Mode: all — suppression is off, so items you have already seen and decided on are included.\");\n\t}\n\t// The model reads every transcript in full, which costs real tokens. Saying\n\t// what was re-read versus reused keeps that price visible rather than hidden.\n\tlines.push(\n\t\t`Read by the model this run: ${digest.mining.mined}; reused from cache: ${digest.mining.cached}` +\n\t\t\t(digest.mining.failed > 0\n\t\t\t\t? `; failed: ${digest.mining.failed} (their signals are missing from the counts below)`\n\t\t\t\t: \"\") +\n\t\t\t\".\",\n\t);\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"The counts below are computed from session transcripts on disk, not from this conversation. \" +\n\t\t\t\"Treat them as evidence, not conclusions — your job is to decide what deserves to be written down, \" +\n\t\t\t\"phrase it, and put it in the right place.\",\n\t);\n\tlines.push(\"\");\n\n\t// ── Directives ───────────────────────────────────────────────────────────\n\tif (digest.directives.length > 0) {\n\t\tlines.push(\"## Directives you have repeated\");\n\t\tlines.push(\"\");\n\t\tfor (const cluster of digest.directives) {\n\t\t\tlines.push(`- **${cluster.status}** — \"${cluster.text.replace(/\\s+/g, \" \").trim()}\"`);\n\t\t\tlines.push(` - ${evidence(cluster.count, cluster.sessions, cluster.lastSeen)}`);\n\t\t\t// Occurrences were grouped by meaning, not by wording, so the quote above\n\t\t\t// is one phrasing of several. Naming the shared point keeps a count of 5\n\t\t\t// from looking like five copies of one sentence.\n\t\t\tlines.push(` - grouped as: ${cluster.label}`);\n\t\t\tif (cluster.rationale) {\n\t\t\t\tlines.push(` - why it may be durable: ${cluster.rationale}`);\n\t\t\t}\n\t\t\tif (cluster.existingRule) {\n\t\t\t\tlines.push(` - already covered by: \"${cluster.existingRule.slice(0, 160)}\"`);\n\t\t\t}\n\t\t\tif (cluster.existingSkill) {\n\t\t\t\tlines.push(` - already covered by the \\`${cluster.existingSkill}\\` skill`);\n\t\t\t}\n\t\t\tif (cluster.previouslyDeclined) {\n\t\t\t\tlines.push(\" - proposed before and not written down — you have already passed on this once\");\n\t\t\t}\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Fixes ────────────────────────────────────────────────────────────────\n\tif (digest.fixes.length > 0) {\n\t\tlines.push(\"## Failures you resolved\");\n\t\tlines.push(\"\");\n\t\tlines.push(\n\t\t\t\"Each is a command that failed and later succeeded, where something done in between was the fix. \" +\n\t\t\t\t\"Recurring ones are worth writing down; a one-off is not.\",\n\t\t);\n\t\tlines.push(\"\");\n\t\tfor (const fix of digest.fixes) {\n\t\t\tlines.push(`- \\`${fix.command}\\` — ${evidence(fix.count, fix.sessions, fix.lastSeen)}`);\n\t\t\tlines.push(` - grouped as: ${fix.label}`);\n\t\t\t// The excerpt comes from the model now, which may not have quoted one.\n\t\t\tif (fix.errorExcerpt) {\n\t\t\t\tlines.push(` - error: ${fix.errorExcerpt}`);\n\t\t\t}\n\t\t\tif (fix.interveningCommands.length > 0) {\n\t\t\t\tlines.push(` - commands in between: ${fix.interveningCommands.map((c) => `\\`${c}\\``).join(\", \")}`);\n\t\t\t}\n\t\t\tif (fix.editedFiles.length > 0) {\n\t\t\t\tlines.push(` - files edited: ${fix.editedFiles.join(\", \")}`);\n\t\t\t}\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Workflows ────────────────────────────────────────────────────────────\n\tif (digest.workflows.length > 0) {\n\t\tlines.push(\"## Repeated tool sequences\");\n\t\tlines.push(\"\");\n\t\tfor (const workflow of digest.workflows) {\n\t\t\t// A workflow the model named but did not enumerate still has a label\n\t\t\t// worth showing; rendering an empty backtick pair instead would not.\n\t\t\tconst steps = workflow.steps.length > 0 ? `\\`${workflow.steps.join(\" → \")}\\`` : workflow.label;\n\t\t\tlines.push(`- ${steps} — ${evidence(workflow.count, workflow.sessions, workflow.lastSeen)}`);\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Instructions ─────────────────────────────────────────────────────────\n\tlines.push(\"## What to do\");\n\tlines.push(\"\");\n\tlines.push(\"Work through the items above and propose concrete edits. For each one, decide:\");\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"1. **Is it durable?** A rule that will still be true next month belongs somewhere. A one-off preference \" +\n\t\t\t\"about the task you happened to be doing does not. When in doubt, drop it — a wrong rule costs more than \" +\n\t\t\t\"a missing one, because it is paid on every request forever.\",\n\t);\n\tlines.push(\n\t\t\"2. **Rule or skill?** This is the most important call. A context file is loaded on **every** turn; a skill \" +\n\t\t\t\"is loaded **on demand**. So: short, always-true, unconditional → a one-line rule. Long, procedural, \" +\n\t\t\t'or conditional (a sequence of steps, a runbook, anything starting \"when X, do Y\") → a skill, not a rule. ' +\n\t\t\t\"Repeated tool sequences are almost always skills.\",\n\t);\n\tlines.push(\n\t\t`3. **Which scope?** Project-specific (this repo's tests, build, architecture, conventions) → the repo ` +\n\t\t\t`\\`AGENTS.md\\`. Personal habits that travel with you across every repo (style preferences, how you like ` +\n\t\t\t`commits written) → \\`${options.userScopePath}\\`. If it names this repo's files or commands, it is not a ` +\n\t\t\t`user-scope rule.`,\n\t);\n\tlines.push(\n\t\t\"4. **Restated items are rewrites, not additions.** An item marked `restated` is already covered by a rule \" +\n\t\t\t\"that is not working — too vague, buried, or contradicted elsewhere. Rewrite the existing line or delete \" +\n\t\t\t\"it in favour of a sharper one. Do not add a second rule saying the same thing.\",\n\t);\n\tlines.push(\n\t\t\"5. **`has-skill` items are a triggering problem, not a missing rule.** A skill already covers it and you \" +\n\t\t\t\"asked by hand anyway, which usually means the skill's `description` frontmatter does not describe the \" +\n\t\t\t\"situation you were in. Sharpen that description so it matches, rather than adding a rule that duplicates \" +\n\t\t\t\"what the skill already does.\",\n\t);\n\tlines.push(\"\");\n\tlines.push(\"Then, while you have the file open, audit it:\");\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"- **Delete rules that no longer match the code.** Check a sample against the repo before trusting them.\",\n\t);\n\tlines.push(\"- **Delete rules that restate default behaviour.** Guidance the agent already follows is pure cost.\");\n\tlines.push(\n\t\t\"- **Collapse duplicates**, including any rule stated at both repo and user scope — that one is paid twice.\",\n\t);\n\tlines.push(\n\t\t\"- **One line per rule.** No rationale, no examples, no preamble, unless the example *is* the rule. Prose is \" +\n\t\t\t\"the single biggest source of context-file bloat.\",\n\t);\n\tlines.push(\"\");\n\n\tif (digest.agentsFilePath) {\n\t\tlines.push(\n\t\t\t`The repo context file is \\`${digest.agentsFilePath}\\`` +\n\t\t\t\t(digest.agentsFileTokens ? ` (~${digest.agentsFileTokens} tokens, re-sent every request)` : \"\") +\n\t\t\t\t\". Report the token delta of your proposed changes before applying them; a net reduction is a good outcome.\",\n\t\t);\n\t} else {\n\t\tlines.push(\n\t\t\t\"No repo context file exists yet. Create one only if at least one durable project rule survives step 1.\",\n\t\t);\n\t}\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"Show what you propose, then apply it with edits — do not ask a separate approval question first, the edit \" +\n\t\t\t\"prompt is the approval. If nothing here is worth writing down, say so plainly and change nothing.\",\n\t);\n\n\treturn lines.join(\"\\n\");\n}\n"]}
1
+ {"version":3,"file":"digest.js","sourceRoot":"","sources":["../../../src/core/learn/digest.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Renders the extractor's output into the message `/learn` injects.\n *\n * The digest is evidence plus instructions, and the split matters: the numbers\n * come from {@link extractLearnDigest} and are not negotiable, while everything\n * the model does with them — phrasing, routing, deciding a pattern is not worth\n * a rule — is judgement it has to exercise. Counts are printed on every item\n * because \"said in 5 of your last 12 sessions\" is a decision the reader can\n * make in one keystroke, where \"extracted from your session\" is not.\n */\n\nimport type { AuditReport } from \"./audit.js\";\nimport { staleTokens } from \"./audit.js\";\nimport type { LearnDigest } from \"./extract.js\";\nimport { LEARN_DIGEST_MARKER } from \"./extract.js\";\n\n/**\n * Quote lengths. A directive is a sentence; a request is a whole task message,\n * and can be a slash-command body running to thousands of characters.\n */\nconst DIRECTIVE_QUOTE_CHARS = 400;\nconst REQUEST_QUOTE_CHARS = 200;\n\n/** One line, bounded — a quote has to survive being rendered inside a list item. */\nfunction quote(text: string, limit: number): string {\n\tconst flat = text.replace(/\\s+/g, \" \").trim();\n\treturn flat.length > limit ? `${flat.slice(0, limit)}…` : flat;\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\nfunction evidence(count: number, sessions: number, lastSeen: string): string {\n\tconst times = count === 1 ? \"once\" : `${count}x`;\n\tconst where = sessions === 1 ? \"1 session\" : `${sessions} sessions`;\n\treturn `${times} across ${where}, last ${shortDate(lastSeen)}`;\n}\n\n/** True when there is nothing worth asking the model to look at. */\nexport function isEmptyDigest(digest: LearnDigest): boolean {\n\treturn digest.directives.length === 0 && digest.fixes.length === 0 && digest.requests.length === 0;\n}\n\n/**\n * Render the audit findings as a message the model can act on.\n *\n * Deliberately framed as questions rather than verdicts. The checker is\n * deterministic and therefore confident, but \"this path does not resolve\" is\n * not the same claim as \"this line is wrong\" — a context file may name a\n * location the tool reads at runtime, or one that belongs to another checkout.\n * Roughly a third of findings on a real file are of that kind, so the message\n * that carries them has to ask for verification, not authorise a sweep.\n */\nexport function renderAuditReport(report: AuditReport): string {\n\tconst lines: string[] = [];\n\tconst cost = staleTokens(report);\n\n\tlines.push(\n\t\t`${LEARN_DIGEST_MARKER} Audited ${report.files.length} context file(s) — ${report.checked} referent(s) checked ` +\n\t\t\t`against the filesystem, ${report.stale.length} did not resolve (~${cost} tokens of always-loaded context).`,\n\t);\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"This check is deterministic: it resolved every backticked path and `run` script named by the context files \" +\n\t\t\t\"below against this working tree and every package root in it. It costs no model calls and knows nothing \" +\n\t\t\t\"about intent.\",\n\t);\n\tlines.push(\"\");\n\n\tfor (const item of report.stale) {\n\t\tlines.push(`- \\`${item.referent}\\` — ${item.file}:${item.line}, ~${item.tokens} tokens`);\n\t\tlines.push(` - line: ${item.lineText.slice(0, 200)}`);\n\t}\n\tlines.push(\"\");\n\n\tlines.push(\"## What to do\");\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"Each entry is a candidate, not a verdict. For each one, check the repo before touching the line — \" +\n\t\t\t\"`git log` for a file that moved or was deleted is usually enough to tell which case you are in:\",\n\t);\n\tlines.push(\"\");\n\tlines.push(\"1. **The referent moved.** Fix the path in place. Do not delete the rule; it is still true.\");\n\tlines.push(\n\t\t\"2. **The referent is gone and the rule went with it.** Delete the line, and the surrounding section if \" +\n\t\t\t\"nothing in it survives. This is the case worth the most — it is always-loaded context describing \" +\n\t\t\t\"something that cannot happen.\",\n\t);\n\tlines.push(\n\t\t\"3. **The path is a runtime or optional location** the tool reads if it happens to exist, or a file in \" +\n\t\t\t\"another checkout. Nothing is wrong; leave it alone and say so.\",\n\t);\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"Report the token delta of what you remove. Do not add anything — this pass is subtractive, and it is the \" +\n\t\t\t\"only one that moves the per-request cost down.\",\n\t);\n\n\treturn lines.join(\"\\n\");\n}\n\nexport function renderLearnDigest(\n\tdigest: LearnDigest,\n\toptions: { userScopePath: string; mode?: \"incremental\" | \"all\" },\n): string {\n\tconst lines: string[] = [];\n\n\tlines.push(\n\t\t`${LEARN_DIGEST_MARKER} Mined ${digest.scannedSessions} session(s) in this directory` +\n\t\t\t(digest.skippedSessions > 0 ? ` (${digest.skippedSessions} skipped: out of window or unreadable)` : \"\") +\n\t\t\t(digest.oldestSession ? `, ${shortDate(digest.oldestSession)} to ${shortDate(digest.newestSession)}` : \"\") +\n\t\t\t(digest.suppressed > 0 ? `. ${digest.suppressed} item(s) held back — already shown and unchanged since` : \"\") +\n\t\t\t// A cut item cleared every bar and lost on rank. Saying so is the\n\t\t\t// difference between \"this is everything\" and \"this is the top of a list\".\n\t\t\t(digest.cut > 0 ? `. ${digest.cut} more cleared the bar but were cut to fit the per-run cap` : \"\") +\n\t\t\t\".\",\n\t);\n\tlines.push(\n\t\t`Read ${digest.funnel.candidates} occurrence(s), which named ${digest.funnel.points} distinct point(s); ` +\n\t\t\t`${digest.funnel.belowThreshold} did not recur in enough separate sessions to be proposed.`,\n\t);\n\t// Naming the mode keeps two very different empty results from reading alike:\n\t// \"nothing new since last time\" and \"nothing here at all\" are not the same\n\t// answer, and the reader cannot tell them apart from the counts.\n\tif (options.mode === \"all\") {\n\t\tlines.push(\"Mode: all — suppression is off, so items you have already seen and decided on are included.\");\n\t}\n\t// The model reads every transcript in full, which costs real tokens. Saying\n\t// what was re-read versus reused keeps that price visible rather than hidden.\n\tlines.push(\n\t\t`Read by the model this run: ${digest.mining.mined}; reused from cache: ${digest.mining.cached}` +\n\t\t\t(digest.mining.failed > 0\n\t\t\t\t? `; failed: ${digest.mining.failed} (their signals are missing from the counts below)`\n\t\t\t\t: \"\") +\n\t\t\t\".\",\n\t);\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"The counts below are computed from session transcripts on disk, not from this conversation. \" +\n\t\t\t\"Treat them as evidence, not conclusions — your job is to decide what deserves to be written down, \" +\n\t\t\t\"phrase it, and put it in the right place.\",\n\t);\n\tlines.push(\"\");\n\n\t// ── Directives ───────────────────────────────────────────────────────────\n\tif (digest.directives.length > 0) {\n\t\tlines.push(\"## Directives you have repeated\");\n\t\tlines.push(\"\");\n\t\tfor (const cluster of digest.directives) {\n\t\t\tlines.push(`- **${cluster.status}** — \"${quote(cluster.text, DIRECTIVE_QUOTE_CHARS)}\"`);\n\t\t\tlines.push(` - ${evidence(cluster.count, cluster.sessions, cluster.lastSeen)}`);\n\t\t\t// Occurrences were grouped by meaning, not by wording, so the quote above\n\t\t\t// is one phrasing of several. Naming the shared point keeps a count of 5\n\t\t\t// from looking like five copies of one sentence.\n\t\t\tlines.push(` - grouped as: ${cluster.label}`);\n\t\t\tif (cluster.rationale) {\n\t\t\t\tlines.push(` - why it may be durable: ${cluster.rationale}`);\n\t\t\t}\n\t\t\tif (cluster.existingRule) {\n\t\t\t\tlines.push(` - already covered by: \"${cluster.existingRule.slice(0, 160)}\"`);\n\t\t\t}\n\t\t\tif (cluster.existingSkill) {\n\t\t\t\tlines.push(` - already covered by the \\`${cluster.existingSkill}\\` skill`);\n\t\t\t}\n\t\t\tif (cluster.previouslyDeclined) {\n\t\t\t\tlines.push(\" - proposed before and not written down — you have already passed on this once\");\n\t\t\t}\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Fixes ────────────────────────────────────────────────────────────────\n\tif (digest.fixes.length > 0) {\n\t\tlines.push(\"## Failures you resolved\");\n\t\tlines.push(\"\");\n\t\tlines.push(\n\t\t\t\"Each is a command that failed and later succeeded, where something done in between was the fix. \" +\n\t\t\t\t\"Recurring ones are worth writing down; a one-off is not.\",\n\t\t);\n\t\tlines.push(\"\");\n\t\tfor (const fix of digest.fixes) {\n\t\t\tlines.push(`- \\`${fix.command}\\` — ${evidence(fix.count, fix.sessions, fix.lastSeen)}`);\n\t\t\tlines.push(` - grouped as: ${fix.label}`);\n\t\t\t// The excerpt comes from the model now, which may not have quoted one.\n\t\t\tif (fix.errorExcerpt) {\n\t\t\t\tlines.push(` - error: ${fix.errorExcerpt}`);\n\t\t\t}\n\t\t\tif (fix.interveningCommands.length > 0) {\n\t\t\t\tlines.push(` - commands in between: ${fix.interveningCommands.map((c) => `\\`${c}\\``).join(\", \")}`);\n\t\t\t}\n\t\t\tif (fix.editedFiles.length > 0) {\n\t\t\t\tlines.push(` - files edited: ${fix.editedFiles.join(\", \")}`);\n\t\t\t}\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Requests ────────────────────────────────────────────────────────────\n\tif (digest.requests.length > 0) {\n\t\tlines.push(\"## Work you keep asking for by name\");\n\t\tlines.push(\"\");\n\t\tfor (const request of digest.requests) {\n\t\t\t// Flattened and capped, unlike a directive quote. A request *is* a whole\n\t\t\t// task message — a slash-command body runs to thousands of characters — so\n\t\t\t// eight of them rendered raw would swamp the digest and a multi-line one\n\t\t\t// would break the list it sits in.\n\t\t\tlines.push(`- **${request.label}** — \"${quote(request.text, REQUEST_QUOTE_CHARS)}\"`);\n\t\t\tlines.push(` - ${evidence(request.count, request.sessions, request.lastSeen)}`);\n\t\t}\n\t\tlines.push(\"\");\n\t}\n\n\t// ── Instructions ─────────────────────────────────────────────────────────\n\tlines.push(\"## What to do\");\n\tlines.push(\"\");\n\tlines.push(\"Work through the items above and propose concrete edits. For each one, decide:\");\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"1. **Is it durable?** A rule that will still be true next month belongs somewhere. A one-off preference \" +\n\t\t\t\"about the task you happened to be doing does not. When in doubt, drop it — a wrong rule costs more than \" +\n\t\t\t\"a missing one, because it is paid on every request forever.\",\n\t);\n\tlines.push(\n\t\t\"2. **Rule, skill, or slash command?** This is the most important call, and it is a cost question. A \" +\n\t\t\t\"context file is loaded on **every** turn; a skill's description is always loaded but its body only on \" +\n\t\t\t\"demand; a slash command costs nothing until it is invoked.\",\n\t);\n\tlines.push(\n\t\t\" - **Rule** — short, always true, unconditional. One line in a context file. Highest bar, because it is \" +\n\t\t\t\"paid on every request forever whether or not it is relevant.\",\n\t);\n\tlines.push(\n\t\t' - **Skill** — long, procedural, or conditional; anything shaped \"when X, do Y\"; a runbook or a sequence ' +\n\t\t\t\"of steps. Write `.agents/skills/<name>/SKILL.md`, and spend the effort on the `description` \" +\n\t\t\t\"frontmatter: it is the only part always in context, and it decides whether the skill ever fires.\",\n\t);\n\tlines.push(\n\t\t\" - **Slash command** — a *job you keep asking for*, not a rule about how work is done. The items under \" +\n\t\t\t'\"Work you keep asking for by name\" are these. Write `.agents/commands/<name>.md`, with `$1`/`$ARGUMENTS` ' +\n\t\t\t\"where the request varies. The cheapest artifact there is: nothing is loaded until you type it.\",\n\t);\n\tlines.push(\n\t\t`3. **Which scope?** Project-specific (this repo's tests, build, architecture, conventions) → the repo ` +\n\t\t\t`\\`AGENTS.md\\`. Personal habits that travel with you across every repo (style preferences, how you like ` +\n\t\t\t`commits written) → \\`${options.userScopePath}\\`. If it names this repo's files or commands, it is not a ` +\n\t\t\t`user-scope rule.`,\n\t);\n\tlines.push(\n\t\t\"4. **Restated items are rewrites, not additions.** An item marked `restated` is already covered by a rule \" +\n\t\t\t\"that is not working — too vague, buried, or contradicted elsewhere. Rewrite the existing line or delete \" +\n\t\t\t\"it in favour of a sharper one. Do not add a second rule saying the same thing.\",\n\t);\n\tlines.push(\n\t\t\"5. **`has-skill` items are a triggering problem, not a missing rule.** A skill already covers it and you \" +\n\t\t\t\"asked by hand anyway, which usually means the skill's `description` frontmatter does not describe the \" +\n\t\t\t\"situation you were in. Sharpen that description so it matches, rather than adding a rule that duplicates \" +\n\t\t\t\"what the skill already does.\",\n\t);\n\tlines.push(\n\t\t\"6. **Write local files, not a plugin.** Skills and commands proposed from this evidence are local habits: \" +\n\t\t\t\"write them under `.agents/`. `ProposePlugin` packages something already proven useful into a portable, \" +\n\t\t\t\"publishable artifact — a later step for a skill that has earned it, not the way to create one. Never \" +\n\t\t\t\"propose a hook or an MCP server from this evidence: it records what was said and what failed, which is \" +\n\t\t\t\"far too weak a warrant for anything that executes.\",\n\t);\n\tlines.push(\"\");\n\tlines.push(\"Then, while you have the file open, audit it:\");\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"- **Delete rules that no longer match the code.** Check a sample against the repo before trusting them.\",\n\t);\n\tlines.push(\"- **Delete rules that restate default behaviour.** Guidance the agent already follows is pure cost.\");\n\tlines.push(\n\t\t\"- **Collapse duplicates**, including any rule stated at both repo and user scope — that one is paid twice.\",\n\t);\n\tlines.push(\n\t\t\"- **One line per rule.** No rationale, no examples, no preamble, unless the example *is* the rule. Prose is \" +\n\t\t\t\"the single biggest source of context-file bloat.\",\n\t);\n\tlines.push(\"\");\n\n\tif (digest.agentsFilePath) {\n\t\tlines.push(\n\t\t\t`The repo context file is \\`${digest.agentsFilePath}\\`` +\n\t\t\t\t(digest.agentsFileTokens ? ` (~${digest.agentsFileTokens} tokens, re-sent every request)` : \"\") +\n\t\t\t\t\". Report the token delta of your proposed changes before applying them; a net reduction is a good outcome.\",\n\t\t);\n\t} else {\n\t\tlines.push(\n\t\t\t\"No repo context file exists yet. Create one only if at least one durable project rule survives step 1.\",\n\t\t);\n\t}\n\tlines.push(\"\");\n\tlines.push(\n\t\t\"Show what you propose, then apply it with edits — do not ask a separate approval question first, the edit \" +\n\t\t\t\"prompt is the approval. If nothing here is worth writing down, say so plainly and change nothing.\",\n\t);\n\n\treturn lines.join(\"\\n\");\n}\n"]}
@@ -26,13 +26,14 @@
26
26
  * read by the model exactly once in its life and the counts are still computed
27
27
  * over every session in the window on every run.
28
28
  */
29
+ import type { Clusterer } from "./cluster.js";
29
30
  import type { CoverageIndex, CoverageJudge } from "./coverage.js";
30
31
  import type { Miner } from "./mine.js";
31
- import type { DirectiveCluster, FixCandidate, WorkflowCandidate } from "./reduce.js";
32
+ import type { DirectiveCluster, FixCandidate, RequestCandidate } from "./reduce.js";
32
33
  import { type LearnState } from "./state.js";
33
34
  export type { CoverageIndex, CoverageMatch } from "./coverage.js";
34
35
  export { LEARN_DIGEST_MARKER } from "./mine.js";
35
- export type { DirectiveCluster, DirectiveStatus, FixCandidate, WorkflowCandidate } from "./reduce.js";
36
+ export type { DirectiveCluster, DirectiveStatus, FixCandidate, RequestCandidate } from "./reduce.js";
36
37
  /**
37
38
  * Why a session file on disk did not make it into the digest.
38
39
  *
@@ -80,15 +81,43 @@ export interface LearnDigest {
80
81
  * hide the item on the next run, when the evidence is complete.
81
82
  */
82
83
  aborted: boolean;
84
+ /**
85
+ * The coverage judge failed, so every directive reads `new` whether or not it
86
+ * is written down. Callers must not record these as surfaced either: the
87
+ * bookmark stores whether an item was covered when shown, and a wrong `false`
88
+ * there tells a later run you passed over a proposal you were never given.
89
+ */
90
+ coverageFailed: boolean;
83
91
  oldestSession?: string;
84
92
  newestSession?: string;
85
93
  agentsFilePath?: string;
86
94
  agentsFileTokens?: number;
87
95
  directives: DirectiveCluster[];
88
96
  fixes: FixCandidate[];
89
- workflows: WorkflowCandidate[];
97
+ requests: RequestCandidate[];
90
98
  /** Items held back because nothing new has happened since they were last shown. */
91
99
  suppressed: number;
100
+ /** Items that cleared every threshold but lost the ranking to `maxProposals`. */
101
+ cut: number;
102
+ /**
103
+ * What the window contained before the thresholds, so an empty digest can be
104
+ * read.
105
+ *
106
+ * The pipeline filters hard — replayed slash-command bodies, tool output,
107
+ * quotes that cannot be found in the transcript, then a distinct-session bar
108
+ * — and every one of those is silent. Without these numbers "nothing to
109
+ * propose" is unreadable: it could mean the sessions taught nothing, or that
110
+ * the bar is one session too high, and the reader has no way to tell which
111
+ * knob to reach for.
112
+ */
113
+ funnel: {
114
+ /** Occurrences the miner reported and the quote check accepted. */
115
+ candidates: number;
116
+ /** Distinct points after naming — how much the clustering pass actually merged. */
117
+ points: number;
118
+ /** Points that were named and counted but did not clear the repeat threshold. */
119
+ belowThreshold: number;
120
+ };
92
121
  /** Everything this run put on screen, for the caller to persist. */
93
122
  surfaced: Array<{
94
123
  key: string;
@@ -111,7 +140,7 @@ export interface ExtractOptions {
111
140
  /** Occurrences a directive needs before it is proposed. The signal/noise dial. */
112
141
  minRepeats?: number;
113
142
  /** Repeats a tool sequence needs before it is proposed as a skill. */
114
- minWorkflowRepeats?: number;
143
+ minRequestRepeats?: number;
115
144
  /** Cap on each list in the digest. */
116
145
  maxProposals?: number;
117
146
  /**
@@ -136,6 +165,12 @@ export interface ExtractOptions {
136
165
  export interface MineOptions extends ExtractOptions {
137
166
  /** Reads one session and reports what it saw. */
138
167
  miner: Miner;
168
+ /**
169
+ * Names the whole window at once, deciding which occurrences are the same
170
+ * point. Without one, each candidate is named after its own wording, which
171
+ * groups identical sentences and nothing else.
172
+ */
173
+ clusterer?: Clusterer;
139
174
  /** Decides which proposals are already written down. Defaults to "none are". */
140
175
  coverageJudge?: CoverageJudge;
141
176
  /** Progress callback, so a cold-cache run is not a silent wait. */
@@ -1 +1 @@
1
- {"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../../../src/core/learn/extract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AASH,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAiB,MAAM,eAAe,CAAC;AAEjF,OAAO,KAAK,EAAkB,KAAK,EAAE,MAAM,WAAW,CAAC;AACvD,OAAO,KAAK,EAAE,gBAAgB,EAAE,YAAY,EAA4B,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAE/G,OAAO,EAAS,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAEpD,YAAY,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAClE,OAAO,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAChD,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAetG;;;;;;;GAOG;AACH,MAAM,WAAW,iBAAiB;IACjC,+CAA+C;IAC/C,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,6CAA6C;IAC7C,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,4DAA4D;IAC5D,KAAK,EAAE,MAAM,CAAC;IACd,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;IACf,gFAAgF;IAChF,QAAQ,EAAE,MAAM,CAAC;IACjB,8CAA8C;IAC9C,SAAS,EAAE,MAAM,CAAC;IAClB,2DAA2D;IAC3D,UAAU,EAAE,MAAM,CAAC;CACnB;AAED,6FAA6F;AAC7F,MAAM,WAAW,YAAY;IAC5B,uDAAuD;IACvD,MAAM,EAAE,MAAM,CAAC;IACf,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,MAAM,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,eAAe,EAAE,MAAM,CAAC;IACxB,8DAA8D;IAC9D,IAAI,EAAE,iBAAiB,CAAC;IACxB,gDAAgD;IAChD,MAAM,EAAE,YAAY,CAAC;IACrB;;;;;OAKG;IACH,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,EAAE,gBAAgB,EAAE,CAAC;IAC/B,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,SAAS,EAAE,iBAAiB,EAAE,CAAC;IAC/B,mFAAmF;IACnF,UAAU,EAAE,MAAM,CAAC;IACnB,oEAAoE;IACpE,QAAQ,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACpF;AAED,MAAM,WAAW,cAAc;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,sCAAsC;IACtC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,kFAAkF;IAClF,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;OAGG;IACH,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtD,mCAAmC;IACnC,GAAG,CAAC,EAAE,IAAI,CAAC;CACX;AAED,sEAAsE;AACtE,MAAM,WAAW,WAAY,SAAQ,cAAc;IAClD,iDAAiD;IACjD,KAAK,EAAE,KAAK,CAAC;IACb,gFAAgF;IAChF,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,mEAAmE;IACnE,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACjF,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB;AAkID;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,IAAI,CAAC,cAAc,EAAE,KAAK,GAAG,UAAU,GAAG,YAAY,CAAC,GAAG,MAAM,EAAE,CAW/G;AAqHD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,cAAc,GAAG,iBAAiB,CAEvE;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,cAAc,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAUtG;AAgBD,mDAAmD;AACnD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACtD,GAAG,aAAa,CAShB;AAwHD,0EAA0E;AAC1E,wBAAsB,eAAe,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC,CAsFhF","sourcesContent":["/**\n * Session mining for `/learn`.\n *\n * Reads session `.jsonl` files straight off disk rather than the live context.\n * That is the whole point: the on-disk transcript is complete even when the\n * in-context one has been compacted away, and it spans every past session\n * instead of only this one. Cross-session repetition is the signal that decides\n * whether something is a durable rule or a one-off, and it is the one thing a\n * prompt reading its own context cannot see.\n *\n * This module is the orchestrator, and the split of labour inside it is\n * deliberate:\n *\n * - **Gathering** is deterministic. Finding session files, resolving which cwd\n * they belong to, walking the active branch of a forked session — all exact,\n * all cheap, all here.\n * - **Judgement** is the model's, in `mine.ts` and `coverage.ts`. What counts as\n * a directive, what two phrasings have in common, whether a rule already\n * covers something — none of that survives contact with a regex, and it used\n * to be decided by one.\n * - **Counting** is deterministic again, in `reduce.ts`. The number is the\n * product, and a model asked to count over a long context will be\n * approximately right.\n *\n * The expensive step is memoized per session file (`cache.ts`), so a session is\n * read by the model exactly once in its life and the counts are still computed\n * over every session in the window on every run.\n */\n\nimport { existsSync, readdirSync, readFileSync, realpathSync, statSync } from \"node:fs\";\nimport { dirname, join, resolve, sep } from \"node:path\";\nimport type { AgentMessage } from \"@kolisachint/hoocode-agent-core\";\nimport { getUserAgentsDir } from \"../../config.js\";\nimport { getSessionDirPath } from \"../session-manager.js\";\nimport { loadSkills } from \"../skills.js\";\nimport { hashSessionFile, pruneLearnCache, readCachedMining, writeCachedMining } from \"./cache.js\";\nimport type { CoverageIndex, CoverageJudge, CoverageQuery } from \"./coverage.js\";\nimport { noCoverageJudge } from \"./coverage.js\";\nimport type { MinableSession, Miner } from \"./mine.js\";\nimport type { DirectiveCluster, FixCandidate, MinedSession, Proposable, WorkflowCandidate } from \"./reduce.js\";\nimport { reduceDirectives, reduceFixes, reduceWorkflows } from \"./reduce.js\";\nimport { judge, type LearnState } from \"./state.js\";\n\nexport type { CoverageIndex, CoverageMatch } from \"./coverage.js\";\nexport { LEARN_DIGEST_MARKER } from \"./mine.js\";\nexport type { DirectiveCluster, DirectiveStatus, FixCandidate, WorkflowCandidate } from \"./reduce.js\";\n\n/** Sessions considered, newest first. */\nconst DEFAULT_MAX_SESSIONS = 20;\n/** Sessions older than this are ignored — a pattern that stopped is not a rule. */\nconst DEFAULT_MAX_AGE_DAYS = 30;\n/** Entries parsed per session file, as a guard against pathological transcripts. */\nconst MAX_ENTRIES_PER_SESSION = 8000;\n/** Occurrences a directive needs before it is proposed. */\nconst DEFAULT_MIN_DIRECTIVE_COUNT = 2;\n/** Repeats before a tool sequence is worth proposing as a skill. */\nconst DEFAULT_MIN_WORKFLOW_COUNT = 3;\n/** Cap on each list in the digest, so the model's budget goes to the top signals. */\nconst DEFAULT_MAX_PER_CATEGORY = 8;\n\n/**\n * Why a session file on disk did not make it into the digest.\n *\n * \"No recent sessions\" is the one outcome a user cannot act on without this:\n * an empty session directory, a directory full of month-old sessions, and a\n * directory full of sessions belonging to another checkout all produce the same\n * sentence, and the fix differs in each case.\n */\nexport interface SessionScanReport {\n\t/** Directories actually searched, in order. */\n\tdirs: string[];\n\t/** Directories that do not exist on disk. */\n\tmissingDirs: string[];\n\t/** `.jsonl` files found across all searched directories. */\n\tfiles: number;\n\t/** Skipped for being older than the age window. */\n\ttooOld: number;\n\t/** Skipped because the session header records a different working directory. */\n\totherCwd: number;\n\t/** Skipped for being beyond `maxSessions`. */\n\toverLimit: number;\n\t/** Skipped for being unreadable, unparseable, or empty. */\n\tunreadable: number;\n}\n\n/** What the run cost, so the price of an LLM-read pipeline is visible rather than hidden. */\nexport interface MiningReport {\n\t/** Sessions whose candidates came from cache, free. */\n\tcached: number;\n\t/** Sessions sent to the model this run. */\n\tmined: number;\n\t/** Sessions the model failed on. Their signals are missing from the counts. */\n\tfailed: number;\n}\n\nexport interface LearnDigest {\n\tscannedSessions: number;\n\tskippedSessions: number;\n\t/** Where the sessions came from, and what was passed over. */\n\tscan: SessionScanReport;\n\t/** What was read by the model versus reused. */\n\tmining: MiningReport;\n\t/**\n\t * The run stopped before reading the whole window, so the counts below are\n\t * computed from part of it. Callers must not record these as surfaced: a\n\t * partial count can fall under the repeat threshold, and bookmarking it would\n\t * hide the item on the next run, when the evidence is complete.\n\t */\n\taborted: boolean;\n\toldestSession?: string;\n\tnewestSession?: string;\n\tagentsFilePath?: string;\n\tagentsFileTokens?: number;\n\tdirectives: DirectiveCluster[];\n\tfixes: FixCandidate[];\n\tworkflows: WorkflowCandidate[];\n\t/** Items held back because nothing new has happened since they were last shown. */\n\tsuppressed: number;\n\t/** Everything this run put on screen, for the caller to persist. */\n\tsurfaced: Array<{ key: string; lastSeen: string; covered: boolean; text?: string }>;\n}\n\nexport interface ExtractOptions {\n\tcwd: string;\n\tagentDir: string;\n\t/**\n\t * An extra directory to scan, normally the live session manager's. The\n\t * per-cwd default directory is always scanned as well, so a session manager\n\t * pointing somewhere unusual cannot hide this directory's history.\n\t */\n\tsessionDir?: string;\n\tmaxSessions?: number;\n\tmaxAgeDays?: number;\n\t/** Occurrences a directive needs before it is proposed. The signal/noise dial. */\n\tminRepeats?: number;\n\t/** Repeats a tool sequence needs before it is proposed as a skill. */\n\tminWorkflowRepeats?: number;\n\t/** Cap on each list in the digest. */\n\tmaxProposals?: number;\n\t/**\n\t * What previous runs already showed. Items with no new occurrences since are\n\t * held back. Omit (or pass `ignoreState`) to propose everything in the window.\n\t */\n\tstate?: LearnState;\n\t/** Re-propose everything, ignoring what previous runs surfaced (`/learn all`). */\n\tignoreState?: boolean;\n\t/**\n\t * Skills a directive can already be covered by. Defaults to the ones loaded\n\t * from disk; injectable so tests do not read the developer's real skills.\n\t */\n\tskills?: Array<{ name: string; description: string }>;\n\t/** Injectable clock, for tests. */\n\tnow?: Date;\n}\n\n/** Everything the async pipeline needs beyond the window settings. */\nexport interface MineOptions extends ExtractOptions {\n\t/** Reads one session and reports what it saw. */\n\tminer: Miner;\n\t/** Decides which proposals are already written down. Defaults to \"none are\". */\n\tcoverageJudge?: CoverageJudge;\n\t/** Progress callback, so a cold-cache run is not a silent wait. */\n\tonProgress?: (progress: { done: number; total: number; cached: number }) => void;\n\tsignal?: AbortSignal;\n}\n\ninterface SessionHeaderLike {\n\ttype: \"session\";\n\tid?: string;\n\ttimestamp?: string;\n\tcwd?: string;\n}\n\ninterface EntryLike {\n\ttype: string;\n\tid?: string;\n\tparentId?: string | null;\n\ttimestamp?: string;\n\tmessage?: AgentMessage;\n}\n\n/** One session, reduced to the branch that was actually taken. */\ninterface ParsedSession {\n\tfile: string;\n\tid: string;\n\ttimestamp: string;\n\tentries: EntryLike[];\n}\n\n/**\n * Reduce a session's raw entries to the branch that was actually taken.\n *\n * Session files are trees — forks and clones append entries that were never\n * part of the same conversation. Walking parent links back from the last entry\n * keeps the miner from reading two turns that never happened in sequence as if\n * they did. Sessions written before entry ids existed are flat, and for those\n * file order *is* the branch.\n */\nfunction activeBranch(entries: EntryLike[]): EntryLike[] {\n\tconst withIds = entries.filter((e) => typeof e.id === \"string\");\n\tif (withIds.length === 0) return entries;\n\n\tconst byId = new Map<string, EntryLike>();\n\tfor (const entry of withIds) byId.set(entry.id as string, entry);\n\n\tconst branch: EntryLike[] = [];\n\tconst seen = new Set<string>();\n\tlet cursor: EntryLike | undefined = withIds[withIds.length - 1];\n\twhile (cursor?.id && !seen.has(cursor.id)) {\n\t\tseen.add(cursor.id);\n\t\tbranch.push(cursor);\n\t\tcursor = cursor.parentId ? byId.get(cursor.parentId) : undefined;\n\t}\n\treturn branch.reverse();\n}\n\n/**\n * Compare two directory paths the way the filesystem does.\n *\n * A session header stores the cwd as it was typed, and the same directory can\n * be spelled several ways: through a symlink (`/tmp` is `/private/tmp` on\n * macOS), with a trailing separator, or in different case on the\n * case-insensitive filesystems that macOS and Windows ship by default. String\n * equality on `resolve()` alone rejects every one of those, and rejecting them\n * here means silently discarding the whole history the command exists to read.\n */\nfunction normalizeDirPath(path: string): string {\n\tlet resolved = resolve(path);\n\ttry {\n\t\tresolved = realpathSync.native(resolved);\n\t} catch {\n\t\t// Deleted or never-created directory: the textual form is all we have.\n\t}\n\t// `resolve` already drops a trailing separator except at a filesystem root,\n\t// where dropping it would turn \"/\" into \"\".\n\tif (resolved.length > 1 && resolved.endsWith(sep)) resolved = resolved.slice(0, -1);\n\treturn process.platform === \"win32\" || process.platform === \"darwin\" ? resolved.toLowerCase() : resolved;\n}\n\nfunction sameDirectory(a: string, b: string): boolean {\n\treturn normalizeDirPath(a) === normalizeDirPath(b);\n}\n\n/** Reason a candidate file produced no session, for the scan report. */\ntype SkipReason = \"otherCwd\" | \"unreadable\";\n\nfunction parseSessionFile(file: string, cwd: string, onSkip: (reason: SkipReason) => void): ParsedSession | undefined {\n\tlet raw: string;\n\ttry {\n\t\traw = readFileSync(file, \"utf-8\");\n\t} catch {\n\t\tonSkip(\"unreadable\");\n\t\treturn undefined;\n\t}\n\n\tconst lines = raw.split(\"\\n\");\n\tlet header: SessionHeaderLike | undefined;\n\tconst entries: EntryLike[] = [];\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\tif (entries.length >= MAX_ENTRIES_PER_SESSION) break;\n\t\tlet parsed: EntryLike | SessionHeaderLike;\n\t\ttry {\n\t\t\tparsed = JSON.parse(line);\n\t\t} catch {\n\t\t\t// A partially-flushed final line is normal for a live session.\n\t\t\tcontinue;\n\t\t}\n\t\tif (parsed.type === \"session\") {\n\t\t\theader ??= parsed as SessionHeaderLike;\n\t\t\tcontinue;\n\t\t}\n\t\tentries.push(parsed as EntryLike);\n\t}\n\n\t// An explicit `--session` path can put a session for another directory in\n\t// this directory, so trust the header over the file's location.\n\tif (header?.cwd && !sameDirectory(header.cwd, cwd)) {\n\t\tonSkip(\"otherCwd\");\n\t\treturn undefined;\n\t}\n\tif (entries.length === 0) {\n\t\tonSkip(\"unreadable\");\n\t\treturn undefined;\n\t}\n\n\treturn {\n\t\tfile,\n\t\tid: header?.id ?? file,\n\t\ttimestamp: header?.timestamp ?? statSync(file).mtime.toISOString(),\n\t\tentries: activeBranch(entries),\n\t};\n}\n\n/**\n * Every directory this cwd's sessions could be sitting in.\n *\n * The caller passes the live session manager's directory, which is the right\n * answer almost always — but not quite always, and each exception silently\n * emptied the digest. An in-memory session (`--no-session`) reports `\"\"`; an\n * explicit `--session <path>` reports wherever that file lives; a custom\n * `sessionDir` setting points at one shared directory. In every one of those\n * cases the per-cwd default directory still holds the history worth mining, so\n * search both and let the header check sort out what belongs to this cwd.\n */\nexport function candidateSessionDirs(options: Pick<ExtractOptions, \"cwd\" | \"agentDir\" | \"sessionDir\">): string[] {\n\tconst dirs: string[] = [];\n\tconst seen = new Set<string>();\n\tfor (const dir of [options.sessionDir, getSessionDirPath(options.cwd, options.agentDir)]) {\n\t\tif (!dir) continue;\n\t\tconst key = normalizeDirPath(dir);\n\t\tif (seen.has(key)) continue;\n\t\tseen.add(key);\n\t\tdirs.push(dir);\n\t}\n\treturn dirs;\n}\n\nfunction listSessions(options: ExtractOptions): {\n\tsessions: ParsedSession[];\n\tskipped: number;\n\tscan: SessionScanReport;\n} {\n\tconst dirs = candidateSessionDirs(options);\n\tconst scan: SessionScanReport = {\n\t\tdirs,\n\t\tmissingDirs: [],\n\t\tfiles: 0,\n\t\ttooOld: 0,\n\t\totherCwd: 0,\n\t\toverLimit: 0,\n\t\tunreadable: 0,\n\t};\n\n\tconst maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;\n\tconst maxAgeDays = options.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;\n\tconst now = options.now ?? new Date();\n\tconst cutoff = now.getTime() - maxAgeDays * 24 * 60 * 60 * 1000;\n\n\tconst files: string[] = [];\n\tfor (const dir of dirs) {\n\t\tif (!existsSync(dir)) {\n\t\t\tscan.missingDirs.push(dir);\n\t\t\tcontinue;\n\t\t}\n\t\ttry {\n\t\t\tfor (const name of readdirSync(dir)) {\n\t\t\t\tif (name.endsWith(\".jsonl\")) files.push(join(dir, name));\n\t\t\t}\n\t\t} catch {\n\t\t\tscan.missingDirs.push(dir);\n\t\t}\n\t}\n\tscan.files = files.length;\n\n\t// Newest first across all directories, so `maxSessions` keeps the most recent\n\t// history rather than whichever directory happened to be searched first.\n\tconst dated = files\n\t\t.map((file) => {\n\t\t\ttry {\n\t\t\t\treturn { file, mtime: statSync(file).mtime.getTime() };\n\t\t\t} catch {\n\t\t\t\tscan.unreadable++;\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t})\n\t\t.filter((f): f is { file: string; mtime: number } => !!f)\n\t\t.sort((a, b) => b.mtime - a.mtime);\n\n\tconst sessions: ParsedSession[] = [];\n\tconst seenIds = new Set<string>();\n\tlet skipped = 0;\n\tfor (const { file, mtime } of dated) {\n\t\tif (sessions.length >= maxSessions) {\n\t\t\tscan.overLimit++;\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\tif (mtime < cutoff) {\n\t\t\tscan.tooOld++;\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\tconst parsed = parseSessionFile(file, options.cwd, (reason) => {\n\t\t\tscan[reason]++;\n\t\t});\n\t\tif (!parsed) {\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\t// Searching two directories can turn up the same session twice (an explicit\n\t\t// `--session` path inside the default directory). Counting it twice would\n\t\t// inflate the cross-session repetition that decides what gets proposed.\n\t\tif (seenIds.has(parsed.id)) {\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\tseenIds.add(parsed.id);\n\t\tsessions.push(parsed);\n\t}\n\treturn { sessions, skipped, scan };\n}\n\n/**\n * Hold back items already shown that have not recurred since, then cap the rest.\n *\n * Order matters: suppression runs *before* the cap, or an item you already\n * decided on would occupy one of the few slots the digest has and push a live\n * signal off the list.\n */\nfunction applySuppression<T extends Proposable>(\n\titems: T[],\n\tstate: LearnState | undefined,\n\tmaxProposals: number,\n\tcovered: (item: T) => boolean,\n\tonDeclined?: (item: T) => void,\n): { kept: T[]; suppressed: number } {\n\tif (!state) return { kept: items.slice(0, maxProposals), suppressed: 0 };\n\n\tconst kept: T[] = [];\n\tlet suppressed = 0;\n\tfor (const item of items) {\n\t\tconst verdict = judge(state, { key: item.key, lastSeen: item.lastSeen, covered: covered(item) });\n\t\tif (verdict.suppressed) {\n\t\t\tsuppressed++;\n\t\t\tcontinue;\n\t\t}\n\t\tif (verdict.previouslyDeclined) onDeclined?.(item);\n\t\tkept.push(item);\n\t}\n\treturn { kept: kept.slice(0, maxProposals), suppressed };\n}\n\n/**\n * Where this cwd's sessions were found and what was passed over, without\n * mining anything. `/learn settings` and `/learn stats` report on the window\n * without paying for a model call.\n */\nexport function scanSessions(options: ExtractOptions): SessionScanReport {\n\treturn listSessions(options).scan;\n}\n\n/**\n * What a run would read, without reading it.\n *\n * Runs the real selection — the same age, cwd, cap and de-duplication rules\n * `mineLearnDigest` applies — and then asks the cache about each survivor. It\n * has to be the same selection: this number is what the confirmation prompt\n * quotes, and a prompt that says twelve before reading three is worse than no\n * prompt at all. Hashing the chosen files is cheap next to sending them to a\n * model.\n */\nexport function planMining(options: ExtractOptions): { total: number; cached: number; pending: number } {\n\tconst { sessions } = listSessions(options);\n\tlet cached = 0;\n\tlet pending = 0;\n\tfor (const session of sessions) {\n\t\tconst hash = hashSessionFile(session.file);\n\t\tif (hash && readCachedMining(options.agentDir, hash)) cached++;\n\t\telse pending++;\n\t}\n\treturn { total: sessions.length, cached, pending };\n}\n\n/** Nearest AGENTS.md walking up from cwd, so proposals can be checked against it. */\nfunction findAgentsFile(cwd: string): string | undefined {\n\tlet dir = resolve(cwd);\n\twhile (true) {\n\t\tfor (const name of [\"AGENTS.md\", \"AGENTS.MD\", \"CLAUDE.md\", \"CLAUDE.MD\"]) {\n\t\t\tconst candidate = join(dir, name);\n\t\t\tif (existsSync(candidate)) return candidate;\n\t\t}\n\t\tconst parent = dirname(dir);\n\t\tif (parent === dir) return undefined;\n\t\tdir = parent;\n\t}\n}\n\n/** Assemble the coverage index for a directory. */\nexport function buildCoverageIndex(options: {\n\tcwd: string;\n\tagentDir: string;\n\tskills?: Array<{ name: string; description: string }>;\n}): CoverageIndex {\n\tconst corpus = coverageCorpus(options.agentDir, findAgentsFile(options.cwd));\n\treturn {\n\t\truleLines: corpus\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => line.trim())\n\t\t\t.filter((line) => line.length > 0 && !line.startsWith(\"#\")),\n\t\tskills: options.skills ?? loadSkillIndex(options.cwd, options.agentDir),\n\t};\n}\n\n/**\n * Text a proposal is checked against to decide whether it is already written\n * down — the nearest repo context file plus both user scopes.\n *\n * All three matter for suppression, because `/learn` can route a rule to the\n * user scope. Checking only the repo file would report a rule you accepted into\n * `~/.agents/AGENTS.md` as declined.\n */\nfunction coverageCorpus(agentDir: string, repoFile: string | undefined): string {\n\tconst parts: string[] = [];\n\tfor (const candidate of [repoFile, join(getUserAgentsDir(), \"AGENTS.md\"), join(agentDir, \"AGENTS.md\")]) {\n\t\tif (!candidate || !existsSync(candidate)) continue;\n\t\ttry {\n\t\t\tparts.push(readFileSync(candidate, \"utf-8\"));\n\t\t} catch {\n\t\t\t// Unreadable context file: treat as absent rather than failing the run.\n\t\t}\n\t}\n\treturn parts.join(\"\\n\");\n}\n\n/**\n * Skills a proposal could already have become.\n *\n * `/learn` routes long or conditional guidance to a skill rather than a rule, so\n * without this a proposal you adopted *as a skill* would read as declined —\n * looking only at context files sees an unchanged `AGENTS.md` and concludes you\n * passed. Reuses the real loader rather than a second SKILL.md scanner so the\n * set of locations cannot drift from what the session actually loads.\n */\nfunction loadSkillIndex(cwd: string, agentDir: string): Array<{ name: string; description: string }> {\n\ttry {\n\t\treturn loadSkills({ cwd, agentDir, skillPaths: [], includeDefaults: true }).skills.map((skill) => ({\n\t\t\tname: skill.name,\n\t\t\tdescription: skill.description ?? \"\",\n\t\t}));\n\t} catch {\n\t\t// Skills are an enrichment here, not the point of the command.\n\t\treturn [];\n\t}\n}\n\n/**\n * Run the miner over the window, reusing cached results wherever the file has\n * not changed.\n *\n * A session that fails to mine is counted and skipped rather than aborting the\n * run: one provider hiccup on one transcript should cost that transcript's\n * signals, not the whole digest. The failure count is reported so the reader\n * knows the numbers are short.\n *\n * Cancellation is different from failure and is reported separately. A run\n * stopped half way has counted only some of the window, so its numbers are not\n * merely short — they are wrong in a way that would poison the bookmark if the\n * digest were treated as a completed run.\n */\nasync function mineSessions(\n\tsessions: ParsedSession[],\n\toptions: MineOptions,\n): Promise<{ mined: MinedSession[]; report: MiningReport; aborted: boolean }> {\n\tconst mined: MinedSession[] = [];\n\tconst report: MiningReport = { cached: 0, mined: 0, failed: 0 };\n\n\tlet done = 0;\n\tfor (const session of sessions) {\n\t\tif (options.signal?.aborted) return { mined, report, aborted: true };\n\n\t\tconst hash = hashSessionFile(session.file);\n\t\tconst cached = hash ? readCachedMining(options.agentDir, hash) : undefined;\n\t\tif (cached) {\n\t\t\tmined.push({ sessionId: session.id, timestamp: session.timestamp, candidates: cached.candidates });\n\t\t\treport.cached++;\n\t\t\tdone++;\n\t\t\toptions.onProgress?.({ done, total: sessions.length, cached: report.cached });\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst minable: MinableSession = { id: session.id, timestamp: session.timestamp, entries: session.entries };\n\t\ttry {\n\t\t\tconst candidates = await options.miner(minable, options.signal);\n\t\t\tmined.push({ sessionId: session.id, timestamp: session.timestamp, candidates });\n\t\t\treport.mined++;\n\t\t\tif (hash) {\n\t\t\t\twriteCachedMining(options.agentDir, hash, {\n\t\t\t\t\tsessionId: session.id,\n\t\t\t\t\ttimestamp: session.timestamp,\n\t\t\t\t\tcandidates,\n\t\t\t\t\tminedAt: new Date().toISOString(),\n\t\t\t\t});\n\t\t\t}\n\t\t} catch {\n\t\t\t// A cancelled request surfaces here as a rejection. That is not the\n\t\t\t// provider failing on this transcript, so it must not be counted as one.\n\t\t\tif (options.signal?.aborted) return { mined, report, aborted: true };\n\t\t\treport.failed++;\n\t\t}\n\t\tdone++;\n\t\toptions.onProgress?.({ done, total: sessions.length, cached: report.cached });\n\t}\n\n\treturn { mined, report, aborted: false };\n}\n\n/** Apply the coverage verdicts to the clusters they were asked about. */\nfunction applyCoverage(directives: DirectiveCluster[], verdicts: Map<string, { rule?: string; skill?: string }>): void {\n\tfor (const cluster of directives) {\n\t\tconst verdict = verdicts.get(cluster.label);\n\t\tif (!verdict) continue;\n\t\tif (verdict.rule) {\n\t\t\tcluster.status = \"restated\";\n\t\t\tcluster.existingRule = verdict.rule;\n\t\t} else if (verdict.skill) {\n\t\t\tcluster.status = \"has-skill\";\n\t\t\tcluster.existingSkill = verdict.skill;\n\t\t}\n\t}\n}\n\n/** Mine the recent sessions for this cwd and return the ranked digest. */\nexport async function mineLearnDigest(options: MineOptions): Promise<LearnDigest> {\n\tconst { sessions, skipped, scan } = listSessions(options);\n\n\tconst agentsFilePath = findAgentsFile(options.cwd);\n\tlet agentsContent: string | undefined;\n\tif (agentsFilePath) {\n\t\ttry {\n\t\t\tagentsContent = readFileSync(agentsFilePath, \"utf-8\");\n\t\t} catch {\n\t\t\tagentsContent = undefined;\n\t\t}\n\t}\n\n\tconst { mined, report, aborted } = await mineSessions(sessions, options);\n\tpruneLearnCache(options.agentDir, options.now);\n\n\tconst minRepeats = options.minRepeats ?? DEFAULT_MIN_DIRECTIVE_COUNT;\n\tconst maxProposals = options.maxProposals ?? DEFAULT_MAX_PER_CATEGORY;\n\tconst directives = reduceDirectives(mined, minRepeats);\n\tconst fixes = reduceFixes(mined, minRepeats);\n\tconst workflows = reduceWorkflows(mined, options.minWorkflowRepeats ?? DEFAULT_MIN_WORKFLOW_COUNT);\n\n\t// Coverage is asked only about what survived the repeat threshold. Judging\n\t// everything would mean sending the context file alongside a long tail of\n\t// one-off observations that are never going to be proposed.\n\tconst coverage = buildCoverageIndex({ cwd: options.cwd, agentDir: options.agentDir, skills: options.skills });\n\tconst queries: CoverageQuery[] = directives.map((d) => ({ label: d.label, text: d.text }));\n\ttry {\n\t\tconst verdicts = await (options.coverageJudge ?? noCoverageJudge)(queries, coverage, options.signal);\n\t\tapplyCoverage(directives, verdicts);\n\t} catch {\n\t\t// A failed coverage call leaves everything `new`, which over-proposes\n\t\t// slightly. That is the right way to fail: the reader can reject a\n\t\t// duplicate, but cannot recover a proposal that was wrongly withheld.\n\t}\n\n\tconst timestamps = sessions.map((s) => s.timestamp).sort();\n\tconst state = options.ignoreState ? undefined : options.state;\n\n\t// Directives carry a real coverage signal — is this written down as a rule or\n\t// a skill right now? — which is what separates an adopted proposal from a\n\t// declined one. Fixes and workflows do not: a fix may have become a rule, a\n\t// skill, or a habit, and which one is not recoverable here, so they get\n\t// suppression only and are never labelled declined.\n\tconst keptDirectives = applySuppression(\n\t\tdirectives,\n\t\tstate,\n\t\tmaxProposals,\n\t\t(item) => item.status !== \"new\",\n\t\t(item) => {\n\t\t\titem.previouslyDeclined = true;\n\t\t},\n\t);\n\tconst keptFixes = applySuppression(fixes, state, maxProposals, () => false);\n\tconst keptWorkflows = applySuppression(workflows, state, maxProposals, () => false);\n\n\tconst surfaced = [\n\t\t// Directives carry their wording forward so a later `/learn stats` can ask\n\t\t// about coverage using the sentence rather than the slug that names it.\n\t\t...keptDirectives.kept.map((d) => ({\n\t\t\tkey: d.key,\n\t\t\tlastSeen: d.lastSeen,\n\t\t\tcovered: d.status !== \"new\",\n\t\t\ttext: d.text,\n\t\t})),\n\t\t...keptFixes.kept.map((f) => ({ key: f.key, lastSeen: f.lastSeen, covered: false })),\n\t\t...keptWorkflows.kept.map((w) => ({ key: w.key, lastSeen: w.lastSeen, covered: false })),\n\t];\n\n\treturn {\n\t\tscannedSessions: sessions.length,\n\t\tskippedSessions: skipped,\n\t\tscan,\n\t\tmining: report,\n\t\taborted,\n\t\toldestSession: timestamps[0],\n\t\tnewestSession: timestamps[timestamps.length - 1],\n\t\tagentsFilePath,\n\t\tagentsFileTokens:\n\t\t\tagentsContent === undefined ? undefined : Math.round(Buffer.byteLength(agentsContent, \"utf-8\") / 4),\n\t\tdirectives: keptDirectives.kept,\n\t\tfixes: keptFixes.kept,\n\t\tworkflows: keptWorkflows.kept,\n\t\tsuppressed: keptDirectives.suppressed + keptFixes.suppressed + keptWorkflows.suppressed,\n\t\tsurfaced,\n\t};\n}\n"]}
1
+ {"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../../../src/core/learn/extract.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Session mining for `/learn`.\n *\n * Reads session `.jsonl` files straight off disk rather than the live context.\n * That is the whole point: the on-disk transcript is complete even when the\n * in-context one has been compacted away, and it spans every past session\n * instead of only this one. Cross-session repetition is the signal that decides\n * whether something is a durable rule or a one-off, and it is the one thing a\n * prompt reading its own context cannot see.\n *\n * This module is the orchestrator, and the split of labour inside it is\n * deliberate:\n *\n * - **Gathering** is deterministic. Finding session files, resolving which cwd\n * they belong to, walking the active branch of a forked session — all exact,\n * all cheap, all here.\n * - **Judgement** is the model's, in `mine.ts` and `coverage.ts`. What counts as\n * a directive, what two phrasings have in common, whether a rule already\n * covers something — none of that survives contact with a regex, and it used\n * to be decided by one.\n * - **Counting** is deterministic again, in `reduce.ts`. The number is the\n * product, and a model asked to count over a long context will be\n * approximately right.\n *\n * The expensive step is memoized per session file (`cache.ts`), so a session is\n * read by the model exactly once in its life and the counts are still computed\n * over every session in the window on every run.\n */\n\nimport { existsSync, readdirSync, readFileSync, realpathSync, statSync } from \"node:fs\";\nimport { dirname, join, resolve, sep } from \"node:path\";\nimport type { AgentMessage } from \"@kolisachint/hoocode-agent-core\";\nimport { getUserAgentsDir } from \"../../config.js\";\nimport { getSessionDirPath } from \"../session-manager.js\";\nimport { loadSkills } from \"../skills.js\";\nimport { hashSessionFile, pruneLearnCache, readCachedMining, writeCachedMining } from \"./cache.js\";\nimport type { Clusterer, ClusterInput } from \"./cluster.js\";\nimport { fallbackLabel } from \"./cluster.js\";\nimport type { CoverageIndex, CoverageJudge, CoverageQuery } from \"./coverage.js\";\nimport { noCoverageJudge } from \"./coverage.js\";\nimport type { MinableSession, MinedCandidate, Miner } from \"./mine.js\";\nimport type { DirectiveCluster, FixCandidate, MinedSession, Proposable, RequestCandidate } from \"./reduce.js\";\nimport { reduceDirectives, reduceFixes, reduceRequests } from \"./reduce.js\";\nimport { judge, type LearnState } from \"./state.js\";\n\nexport type { CoverageIndex, CoverageMatch } from \"./coverage.js\";\nexport { LEARN_DIGEST_MARKER } from \"./mine.js\";\nexport type { DirectiveCluster, DirectiveStatus, FixCandidate, RequestCandidate } from \"./reduce.js\";\n\n/** Sessions considered, newest first. */\nconst DEFAULT_MAX_SESSIONS = 20;\n/** Sessions older than this are ignored — a pattern that stopped is not a rule. */\nconst DEFAULT_MAX_AGE_DAYS = 30;\n/** Entries parsed per session file, as a guard against pathological transcripts. */\nconst MAX_ENTRIES_PER_SESSION = 8000;\n/** Occurrences a directive needs before it is proposed. */\nconst DEFAULT_MIN_DIRECTIVE_COUNT = 2;\n/**\n * Sessions a request needs before it is worth proposing as a slash command.\n *\n * Higher than the directive bar. A rule you stated twice is a rule; a job you\n * asked for twice may just be a job that came up twice. Three separate sessions\n * is the point at which typing it again is the expensive option.\n */\nconst DEFAULT_MIN_REQUEST_COUNT = 3;\n/** Cap on each list in the digest, so the model's budget goes to the top signals. */\nconst DEFAULT_MAX_PER_CATEGORY = 8;\n\n/**\n * Why a session file on disk did not make it into the digest.\n *\n * \"No recent sessions\" is the one outcome a user cannot act on without this:\n * an empty session directory, a directory full of month-old sessions, and a\n * directory full of sessions belonging to another checkout all produce the same\n * sentence, and the fix differs in each case.\n */\nexport interface SessionScanReport {\n\t/** Directories actually searched, in order. */\n\tdirs: string[];\n\t/** Directories that do not exist on disk. */\n\tmissingDirs: string[];\n\t/** `.jsonl` files found across all searched directories. */\n\tfiles: number;\n\t/** Skipped for being older than the age window. */\n\ttooOld: number;\n\t/** Skipped because the session header records a different working directory. */\n\totherCwd: number;\n\t/** Skipped for being beyond `maxSessions`. */\n\toverLimit: number;\n\t/** Skipped for being unreadable, unparseable, or empty. */\n\tunreadable: number;\n}\n\n/** What the run cost, so the price of an LLM-read pipeline is visible rather than hidden. */\nexport interface MiningReport {\n\t/** Sessions whose candidates came from cache, free. */\n\tcached: number;\n\t/** Sessions sent to the model this run. */\n\tmined: number;\n\t/** Sessions the model failed on. Their signals are missing from the counts. */\n\tfailed: number;\n}\n\nexport interface LearnDigest {\n\tscannedSessions: number;\n\tskippedSessions: number;\n\t/** Where the sessions came from, and what was passed over. */\n\tscan: SessionScanReport;\n\t/** What was read by the model versus reused. */\n\tmining: MiningReport;\n\t/**\n\t * The run stopped before reading the whole window, so the counts below are\n\t * computed from part of it. Callers must not record these as surfaced: a\n\t * partial count can fall under the repeat threshold, and bookmarking it would\n\t * hide the item on the next run, when the evidence is complete.\n\t */\n\taborted: boolean;\n\t/**\n\t * The coverage judge failed, so every directive reads `new` whether or not it\n\t * is written down. Callers must not record these as surfaced either: the\n\t * bookmark stores whether an item was covered when shown, and a wrong `false`\n\t * there tells a later run you passed over a proposal you were never given.\n\t */\n\tcoverageFailed: boolean;\n\toldestSession?: string;\n\tnewestSession?: string;\n\tagentsFilePath?: string;\n\tagentsFileTokens?: number;\n\tdirectives: DirectiveCluster[];\n\tfixes: FixCandidate[];\n\trequests: RequestCandidate[];\n\t/** Items held back because nothing new has happened since they were last shown. */\n\tsuppressed: number;\n\t/** Items that cleared every threshold but lost the ranking to `maxProposals`. */\n\tcut: number;\n\t/**\n\t * What the window contained before the thresholds, so an empty digest can be\n\t * read.\n\t *\n\t * The pipeline filters hard — replayed slash-command bodies, tool output,\n\t * quotes that cannot be found in the transcript, then a distinct-session bar\n\t * — and every one of those is silent. Without these numbers \"nothing to\n\t * propose\" is unreadable: it could mean the sessions taught nothing, or that\n\t * the bar is one session too high, and the reader has no way to tell which\n\t * knob to reach for.\n\t */\n\tfunnel: {\n\t\t/** Occurrences the miner reported and the quote check accepted. */\n\t\tcandidates: number;\n\t\t/** Distinct points after naming — how much the clustering pass actually merged. */\n\t\tpoints: number;\n\t\t/** Points that were named and counted but did not clear the repeat threshold. */\n\t\tbelowThreshold: number;\n\t};\n\t/** Everything this run put on screen, for the caller to persist. */\n\tsurfaced: Array<{ key: string; lastSeen: string; covered: boolean; text?: string }>;\n}\n\nexport interface ExtractOptions {\n\tcwd: string;\n\tagentDir: string;\n\t/**\n\t * An extra directory to scan, normally the live session manager's. The\n\t * per-cwd default directory is always scanned as well, so a session manager\n\t * pointing somewhere unusual cannot hide this directory's history.\n\t */\n\tsessionDir?: string;\n\tmaxSessions?: number;\n\tmaxAgeDays?: number;\n\t/** Occurrences a directive needs before it is proposed. The signal/noise dial. */\n\tminRepeats?: number;\n\t/** Repeats a tool sequence needs before it is proposed as a skill. */\n\tminRequestRepeats?: number;\n\t/** Cap on each list in the digest. */\n\tmaxProposals?: number;\n\t/**\n\t * What previous runs already showed. Items with no new occurrences since are\n\t * held back. Omit (or pass `ignoreState`) to propose everything in the window.\n\t */\n\tstate?: LearnState;\n\t/** Re-propose everything, ignoring what previous runs surfaced (`/learn all`). */\n\tignoreState?: boolean;\n\t/**\n\t * Skills a directive can already be covered by. Defaults to the ones loaded\n\t * from disk; injectable so tests do not read the developer's real skills.\n\t */\n\tskills?: Array<{ name: string; description: string }>;\n\t/** Injectable clock, for tests. */\n\tnow?: Date;\n}\n\n/** Everything the async pipeline needs beyond the window settings. */\nexport interface MineOptions extends ExtractOptions {\n\t/** Reads one session and reports what it saw. */\n\tminer: Miner;\n\t/**\n\t * Names the whole window at once, deciding which occurrences are the same\n\t * point. Without one, each candidate is named after its own wording, which\n\t * groups identical sentences and nothing else.\n\t */\n\tclusterer?: Clusterer;\n\t/** Decides which proposals are already written down. Defaults to \"none are\". */\n\tcoverageJudge?: CoverageJudge;\n\t/** Progress callback, so a cold-cache run is not a silent wait. */\n\tonProgress?: (progress: { done: number; total: number; cached: number }) => void;\n\tsignal?: AbortSignal;\n}\n\ninterface SessionHeaderLike {\n\ttype: \"session\";\n\tid?: string;\n\ttimestamp?: string;\n\tcwd?: string;\n}\n\ninterface EntryLike {\n\ttype: string;\n\tid?: string;\n\tparentId?: string | null;\n\ttimestamp?: string;\n\tmessage?: AgentMessage;\n}\n\n/** One session, reduced to the branch that was actually taken. */\ninterface ParsedSession {\n\tfile: string;\n\tid: string;\n\t/** When the session was opened. Describes the window, not what is in it. */\n\ttimestamp: string;\n\t/**\n\t * When the session was last written to.\n\t *\n\t * This is the clock suppression runs on, and it must not be the session's\n\t * start. A session opened yesterday and worked in today would date everything\n\t * said in it to yesterday, which can be older than the last `/learn` run — so\n\t * something said minutes ago reads as \"nothing new since you were last shown\n\t * this\" and is held back. Per-candidate timestamps would be finer, but the\n\t * miner sees an untimestamped blob and would have to invent them; the\n\t * session's last activity is deterministic, free, and errs toward showing an\n\t * item again rather than hiding it.\n\t */\n\tlastActivity: string;\n\tentries: EntryLike[];\n}\n\n/** Newest entry timestamp on the branch, falling back to when the session opened. */\nfunction lastActivityOf(entries: EntryLike[], fallback: string): string {\n\tlet latest = \"\";\n\tfor (const entry of entries) {\n\t\tif (typeof entry.timestamp === \"string\" && entry.timestamp > latest) latest = entry.timestamp;\n\t}\n\treturn latest || fallback;\n}\n\n/**\n * Reduce a session's raw entries to the branch that was actually taken.\n *\n * Session files are trees — forks and clones append entries that were never\n * part of the same conversation. Walking parent links back from the last entry\n * keeps the miner from reading two turns that never happened in sequence as if\n * they did. Sessions written before entry ids existed are flat, and for those\n * file order *is* the branch.\n */\nfunction activeBranch(entries: EntryLike[]): EntryLike[] {\n\tconst withIds = entries.filter((e) => typeof e.id === \"string\");\n\tif (withIds.length === 0) return entries;\n\n\tconst byId = new Map<string, EntryLike>();\n\tfor (const entry of withIds) byId.set(entry.id as string, entry);\n\n\tconst branch: EntryLike[] = [];\n\tconst seen = new Set<string>();\n\tlet cursor: EntryLike | undefined = withIds[withIds.length - 1];\n\twhile (cursor?.id && !seen.has(cursor.id)) {\n\t\tseen.add(cursor.id);\n\t\tbranch.push(cursor);\n\t\tcursor = cursor.parentId ? byId.get(cursor.parentId) : undefined;\n\t}\n\treturn branch.reverse();\n}\n\n/**\n * Compare two directory paths the way the filesystem does.\n *\n * A session header stores the cwd as it was typed, and the same directory can\n * be spelled several ways: through a symlink (`/tmp` is `/private/tmp` on\n * macOS), with a trailing separator, or in different case on the\n * case-insensitive filesystems that macOS and Windows ship by default. String\n * equality on `resolve()` alone rejects every one of those, and rejecting them\n * here means silently discarding the whole history the command exists to read.\n */\nfunction normalizeDirPath(path: string): string {\n\tlet resolved = resolve(path);\n\ttry {\n\t\tresolved = realpathSync.native(resolved);\n\t} catch {\n\t\t// Deleted or never-created directory: the textual form is all we have.\n\t}\n\t// `resolve` already drops a trailing separator except at a filesystem root,\n\t// where dropping it would turn \"/\" into \"\".\n\tif (resolved.length > 1 && resolved.endsWith(sep)) resolved = resolved.slice(0, -1);\n\treturn process.platform === \"win32\" || process.platform === \"darwin\" ? resolved.toLowerCase() : resolved;\n}\n\nfunction sameDirectory(a: string, b: string): boolean {\n\treturn normalizeDirPath(a) === normalizeDirPath(b);\n}\n\n/** Reason a candidate file produced no session, for the scan report. */\ntype SkipReason = \"otherCwd\" | \"unreadable\";\n\nfunction parseSessionFile(file: string, cwd: string, onSkip: (reason: SkipReason) => void): ParsedSession | undefined {\n\tlet raw: string;\n\ttry {\n\t\traw = readFileSync(file, \"utf-8\");\n\t} catch {\n\t\tonSkip(\"unreadable\");\n\t\treturn undefined;\n\t}\n\n\tconst lines = raw.split(\"\\n\");\n\tlet header: SessionHeaderLike | undefined;\n\tconst entries: EntryLike[] = [];\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\tif (entries.length >= MAX_ENTRIES_PER_SESSION) break;\n\t\tlet parsed: EntryLike | SessionHeaderLike;\n\t\ttry {\n\t\t\tparsed = JSON.parse(line);\n\t\t} catch {\n\t\t\t// A partially-flushed final line is normal for a live session.\n\t\t\tcontinue;\n\t\t}\n\t\tif (parsed.type === \"session\") {\n\t\t\theader ??= parsed as SessionHeaderLike;\n\t\t\tcontinue;\n\t\t}\n\t\tentries.push(parsed as EntryLike);\n\t}\n\n\t// An explicit `--session` path can put a session for another directory in\n\t// this directory, so trust the header over the file's location.\n\tif (header?.cwd && !sameDirectory(header.cwd, cwd)) {\n\t\tonSkip(\"otherCwd\");\n\t\treturn undefined;\n\t}\n\tif (entries.length === 0) {\n\t\tonSkip(\"unreadable\");\n\t\treturn undefined;\n\t}\n\n\tconst branch = activeBranch(entries);\n\tconst opened = header?.timestamp ?? statSync(file).mtime.toISOString();\n\treturn {\n\t\tfile,\n\t\tid: header?.id ?? file,\n\t\ttimestamp: opened,\n\t\tlastActivity: lastActivityOf(branch, opened),\n\t\tentries: branch,\n\t};\n}\n\n/**\n * Every directory this cwd's sessions could be sitting in.\n *\n * The caller passes the live session manager's directory, which is the right\n * answer almost always — but not quite always, and each exception silently\n * emptied the digest. An in-memory session (`--no-session`) reports `\"\"`; an\n * explicit `--session <path>` reports wherever that file lives; a custom\n * `sessionDir` setting points at one shared directory. In every one of those\n * cases the per-cwd default directory still holds the history worth mining, so\n * search both and let the header check sort out what belongs to this cwd.\n */\nexport function candidateSessionDirs(options: Pick<ExtractOptions, \"cwd\" | \"agentDir\" | \"sessionDir\">): string[] {\n\tconst dirs: string[] = [];\n\tconst seen = new Set<string>();\n\tfor (const dir of [options.sessionDir, getSessionDirPath(options.cwd, options.agentDir)]) {\n\t\tif (!dir) continue;\n\t\tconst key = normalizeDirPath(dir);\n\t\tif (seen.has(key)) continue;\n\t\tseen.add(key);\n\t\tdirs.push(dir);\n\t}\n\treturn dirs;\n}\n\nfunction listSessions(options: ExtractOptions): {\n\tsessions: ParsedSession[];\n\tskipped: number;\n\tscan: SessionScanReport;\n} {\n\tconst dirs = candidateSessionDirs(options);\n\tconst scan: SessionScanReport = {\n\t\tdirs,\n\t\tmissingDirs: [],\n\t\tfiles: 0,\n\t\ttooOld: 0,\n\t\totherCwd: 0,\n\t\toverLimit: 0,\n\t\tunreadable: 0,\n\t};\n\n\tconst maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;\n\tconst maxAgeDays = options.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;\n\tconst now = options.now ?? new Date();\n\tconst cutoff = now.getTime() - maxAgeDays * 24 * 60 * 60 * 1000;\n\n\tconst files: string[] = [];\n\tfor (const dir of dirs) {\n\t\tif (!existsSync(dir)) {\n\t\t\tscan.missingDirs.push(dir);\n\t\t\tcontinue;\n\t\t}\n\t\ttry {\n\t\t\tfor (const name of readdirSync(dir)) {\n\t\t\t\tif (name.endsWith(\".jsonl\")) files.push(join(dir, name));\n\t\t\t}\n\t\t} catch {\n\t\t\tscan.missingDirs.push(dir);\n\t\t}\n\t}\n\tscan.files = files.length;\n\n\t// Newest first across all directories, so `maxSessions` keeps the most recent\n\t// history rather than whichever directory happened to be searched first.\n\tconst dated = files\n\t\t.map((file) => {\n\t\t\ttry {\n\t\t\t\treturn { file, mtime: statSync(file).mtime.getTime() };\n\t\t\t} catch {\n\t\t\t\tscan.unreadable++;\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t})\n\t\t.filter((f): f is { file: string; mtime: number } => !!f)\n\t\t.sort((a, b) => b.mtime - a.mtime);\n\n\tconst sessions: ParsedSession[] = [];\n\tconst seenIds = new Set<string>();\n\tlet skipped = 0;\n\tfor (const { file, mtime } of dated) {\n\t\tif (sessions.length >= maxSessions) {\n\t\t\tscan.overLimit++;\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\tif (mtime < cutoff) {\n\t\t\tscan.tooOld++;\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\tconst parsed = parseSessionFile(file, options.cwd, (reason) => {\n\t\t\tscan[reason]++;\n\t\t});\n\t\tif (!parsed) {\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\t// Searching two directories can turn up the same session twice (an explicit\n\t\t// `--session` path inside the default directory). Counting it twice would\n\t\t// inflate the cross-session repetition that decides what gets proposed.\n\t\tif (seenIds.has(parsed.id)) {\n\t\t\tskipped++;\n\t\t\tcontinue;\n\t\t}\n\t\tseenIds.add(parsed.id);\n\t\tsessions.push(parsed);\n\t}\n\treturn { sessions, skipped, scan };\n}\n\n/**\n * Hold back items already shown that have not recurred since, then cap the rest.\n *\n * Order matters: suppression runs *before* the cap, or an item you already\n * decided on would occupy one of the few slots the digest has and push a live\n * signal off the list.\n */\nfunction applySuppression<T extends Proposable>(\n\titems: T[],\n\tstate: LearnState | undefined,\n\tmaxProposals: number,\n\tcovered: (item: T) => boolean,\n\tonDeclined?: (item: T) => void,\n): { kept: T[]; suppressed: number; cut: number } {\n\tif (!state) {\n\t\treturn { kept: items.slice(0, maxProposals), suppressed: 0, cut: Math.max(0, items.length - maxProposals) };\n\t}\n\n\tconst kept: T[] = [];\n\tlet suppressed = 0;\n\tfor (const item of items) {\n\t\tconst verdict = judge(state, { key: item.key, lastSeen: item.lastSeen, covered: covered(item) });\n\t\tif (verdict.suppressed) {\n\t\t\tsuppressed++;\n\t\t\tcontinue;\n\t\t}\n\t\tif (verdict.previouslyDeclined) onDeclined?.(item);\n\t\tkept.push(item);\n\t}\n\t// Anything past the cap cleared every bar and lost on rank alone. It is not\n\t// suppressed and it is not bookmarked, so it will be back next run — but a\n\t// digest that silently shows eight of twenty reads as \"twenty is all there\n\t// was\", and the reader tunes the wrong knob.\n\treturn { kept: kept.slice(0, maxProposals), suppressed, cut: Math.max(0, kept.length - maxProposals) };\n}\n\n/**\n * Where this cwd's sessions were found and what was passed over, without\n * mining anything. `/learn settings` and `/learn stats` report on the window\n * without paying for a model call.\n */\nexport function scanSessions(options: ExtractOptions): SessionScanReport {\n\treturn listSessions(options).scan;\n}\n\n/**\n * What a run would read, without reading it.\n *\n * Runs the real selection — the same age, cwd, cap and de-duplication rules\n * `mineLearnDigest` applies — and then asks the cache about each survivor. It\n * has to be the same selection: this number is what the confirmation prompt\n * quotes, and a prompt that says twelve before reading three is worse than no\n * prompt at all. Hashing the chosen files is cheap next to sending them to a\n * model.\n */\nexport function planMining(options: ExtractOptions): { total: number; cached: number; pending: number } {\n\tconst { sessions } = listSessions(options);\n\tlet cached = 0;\n\tlet pending = 0;\n\tfor (const session of sessions) {\n\t\tconst hash = hashSessionFile(session.file);\n\t\tif (hash && readCachedMining(options.agentDir, hash)) cached++;\n\t\telse pending++;\n\t}\n\treturn { total: sessions.length, cached, pending };\n}\n\n/** One session's candidates before the naming pass has seen them. */\ninterface RawMinedSession {\n\tsessionId: string;\n\tlastActivity: string;\n\tcandidates: MinedCandidate[];\n}\n\n/**\n * Name every candidate in the window in one place.\n *\n * The pass runs over the whole window at once rather than per session, which is\n * the entire point: \"is this the same point as that\" is unanswerable from\n * inside one transcript. Labels already on record are offered as vocabulary so\n * an item you decided on keeps the key it was bookmarked under — without that,\n * a renamed cluster reads as brand new and suppression quietly stops working.\n *\n * A failed call falls back to naming each candidate after its own wording,\n * which groups identical sentences and nothing else. That is the behaviour the\n * pipeline had before this stage existed, so a clustering outage costs recall,\n * not the run.\n */\nasync function labelSessions(\n\traw: RawMinedSession[],\n\tclusterer: Clusterer | undefined,\n\tknownLabels: string[],\n\tsignal?: AbortSignal,\n): Promise<MinedSession[]> {\n\tconst inputs: ClusterInput[] = [];\n\tconst origin: Array<{ session: number; candidate: number }> = [];\n\tfor (const [sessionIndex, session] of raw.entries()) {\n\t\tfor (const [candidateIndex, candidate] of session.candidates.entries()) {\n\t\t\tinputs.push({ id: inputs.length, kind: candidate.kind, text: candidate.text });\n\t\t\torigin.push({ session: sessionIndex, candidate: candidateIndex });\n\t\t}\n\t}\n\n\tlet labels = new Map<number, string>();\n\tif (clusterer && inputs.length > 0) {\n\t\ttry {\n\t\t\tlabels = await clusterer(inputs, knownLabels, signal);\n\t\t} catch {\n\t\t\t// Fall through to per-text labels below.\n\t\t}\n\t}\n\n\tconst labelled: MinedSession[] = raw.map((session) => ({\n\t\tsessionId: session.sessionId,\n\t\tlastActivity: session.lastActivity,\n\t\tcandidates: [],\n\t}));\n\tfor (const [index, input] of inputs.entries()) {\n\t\tconst where = origin[index];\n\t\tconst candidate = where ? raw[where.session]?.candidates[where.candidate] : undefined;\n\t\tif (!where || !candidate) continue;\n\t\tlabelled[where.session]?.candidates.push({\n\t\t\t...candidate,\n\t\t\tlabel: labels.get(input.id) ?? fallbackLabel(input.text),\n\t\t});\n\t}\n\treturn labelled;\n}\n\n/** Labels already on record, so the naming pass can reuse rather than reinvent them. */\nfunction knownLabelsFrom(state: LearnState | undefined): string[] {\n\tif (!state) return [];\n\treturn Object.keys(state.surfaced).map((key) => key.slice(key.indexOf(\":\") + 1));\n}\n\n/** Nearest AGENTS.md walking up from cwd, so proposals can be checked against it. */\nfunction findAgentsFile(cwd: string): string | undefined {\n\tlet dir = resolve(cwd);\n\twhile (true) {\n\t\tfor (const name of [\"AGENTS.md\", \"AGENTS.MD\", \"CLAUDE.md\", \"CLAUDE.MD\"]) {\n\t\t\tconst candidate = join(dir, name);\n\t\t\tif (existsSync(candidate)) return candidate;\n\t\t}\n\t\tconst parent = dirname(dir);\n\t\tif (parent === dir) return undefined;\n\t\tdir = parent;\n\t}\n}\n\n/**\n * Turn one context file into rule lines the coverage judge can reason about.\n *\n * Headings were dropped and the lines under them sent bare, which asks the\n * model to decide whether a proposal is in scope using text with the scope\n * removed — \"stage only your own files\" reads very differently under \"Git Rules\n * for Parallel Agents\" than on its own. So each line carries its heading path,\n * and the scope it came from, since the corpus spans a repo file and two user\n * ones and a rule's home decides who it binds.\n *\n * Fenced blocks go: a code sample illustrates a rule, it is not one, and on a\n * real file it is a large share of the non-bullet text.\n */\nfunction ruleLinesOf(content: string, scope: string): string[] {\n\tconst lines: string[] = [];\n\tconst headings: string[] = [];\n\tlet inFence = false;\n\n\tfor (const raw of content.split(\"\\n\")) {\n\t\tconst line = raw.trim();\n\t\tif (line.startsWith(\"```\")) {\n\t\t\tinFence = !inFence;\n\t\t\tcontinue;\n\t\t}\n\t\tif (inFence || line.length === 0) continue;\n\n\t\tconst heading = /^(#{1,6})\\s+(.*)$/.exec(line);\n\t\tif (heading) {\n\t\t\tconst depth = heading[1]?.length ?? 1;\n\t\t\theadings.length = Math.min(headings.length, depth - 1);\n\t\t\theadings[depth - 1] = heading[2] ?? \"\";\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst path = headings.filter(Boolean).join(\" > \");\n\t\tlines.push(path ? `[${scope}] ${path} > ${line}` : `[${scope}] ${line}`);\n\t}\n\treturn lines;\n}\n\n/** Assemble the coverage index for a directory. */\nexport function buildCoverageIndex(options: {\n\tcwd: string;\n\tagentDir: string;\n\tskills?: Array<{ name: string; description: string }>;\n}): CoverageIndex {\n\tconst ruleLines: string[] = [];\n\tfor (const file of coverageFiles(options.agentDir, findAgentsFile(options.cwd))) {\n\t\truleLines.push(...ruleLinesOf(file.content, file.scope));\n\t}\n\treturn { ruleLines, skills: options.skills ?? loadSkillIndex(options.cwd, options.agentDir) };\n}\n\n/**\n * Text a proposal is checked against to decide whether it is already written\n * down — the nearest repo context file plus both user scopes.\n *\n * All three matter for suppression, because `/learn` can route a rule to the\n * user scope. Checking only the repo file would report a rule you accepted into\n * `~/.agents/AGENTS.md` as declined.\n */\nfunction coverageFiles(agentDir: string, repoFile: string | undefined): Array<{ scope: string; content: string }> {\n\tconst files: Array<{ scope: string; content: string }> = [];\n\tconst candidates: Array<{ scope: string; path: string | undefined }> = [\n\t\t{ scope: \"repo\", path: repoFile },\n\t\t{ scope: \"user\", path: join(getUserAgentsDir(), \"AGENTS.md\") },\n\t\t{ scope: \"user\", path: join(agentDir, \"AGENTS.md\") },\n\t];\n\tfor (const candidate of candidates) {\n\t\tif (!candidate.path || !existsSync(candidate.path)) continue;\n\t\ttry {\n\t\t\tfiles.push({ scope: candidate.scope, content: readFileSync(candidate.path, \"utf-8\") });\n\t\t} catch {\n\t\t\t// Unreadable context file: treat as absent rather than failing the run.\n\t\t}\n\t}\n\treturn files;\n}\n\n/**\n * Skills a proposal could already have become.\n *\n * `/learn` routes long or conditional guidance to a skill rather than a rule, so\n * without this a proposal you adopted *as a skill* would read as declined —\n * looking only at context files sees an unchanged `AGENTS.md` and concludes you\n * passed. Reuses the real loader rather than a second SKILL.md scanner so the\n * set of locations cannot drift from what the session actually loads.\n */\nfunction loadSkillIndex(cwd: string, agentDir: string): Array<{ name: string; description: string }> {\n\ttry {\n\t\treturn loadSkills({ cwd, agentDir, skillPaths: [], includeDefaults: true }).skills.map((skill) => ({\n\t\t\tname: skill.name,\n\t\t\tdescription: skill.description ?? \"\",\n\t\t}));\n\t} catch {\n\t\t// Skills are an enrichment here, not the point of the command.\n\t\treturn [];\n\t}\n}\n\n/**\n * Run the miner over the window, reusing cached results wherever the file has\n * not changed.\n *\n * A session that fails to mine is counted and skipped rather than aborting the\n * run: one provider hiccup on one transcript should cost that transcript's\n * signals, not the whole digest. The failure count is reported so the reader\n * knows the numbers are short.\n *\n * Cancellation is different from failure and is reported separately. A run\n * stopped half way has counted only some of the window, so its numbers are not\n * merely short — they are wrong in a way that would poison the bookmark if the\n * digest were treated as a completed run.\n */\nasync function mineSessions(\n\tsessions: ParsedSession[],\n\toptions: MineOptions,\n): Promise<{ mined: RawMinedSession[]; report: MiningReport; aborted: boolean }> {\n\tconst mined: RawMinedSession[] = [];\n\tconst report: MiningReport = { cached: 0, mined: 0, failed: 0 };\n\n\tlet done = 0;\n\tfor (const session of sessions) {\n\t\tif (options.signal?.aborted) return { mined, report, aborted: true };\n\n\t\tconst hash = hashSessionFile(session.file);\n\t\tconst cached = hash ? readCachedMining(options.agentDir, hash) : undefined;\n\t\tif (cached) {\n\t\t\tmined.push({ sessionId: session.id, lastActivity: session.lastActivity, candidates: cached.candidates });\n\t\t\treport.cached++;\n\t\t\tdone++;\n\t\t\toptions.onProgress?.({ done, total: sessions.length, cached: report.cached });\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst minable: MinableSession = { id: session.id, timestamp: session.timestamp, entries: session.entries };\n\t\ttry {\n\t\t\tconst candidates = await options.miner(minable, options.signal);\n\t\t\tmined.push({ sessionId: session.id, lastActivity: session.lastActivity, candidates });\n\t\t\treport.mined++;\n\t\t\tif (hash) {\n\t\t\t\twriteCachedMining(options.agentDir, hash, {\n\t\t\t\t\tsessionId: session.id,\n\t\t\t\t\ttimestamp: session.timestamp,\n\t\t\t\t\tcandidates,\n\t\t\t\t\tminedAt: new Date().toISOString(),\n\t\t\t\t});\n\t\t\t}\n\t\t} catch {\n\t\t\t// A cancelled request surfaces here as a rejection. That is not the\n\t\t\t// provider failing on this transcript, so it must not be counted as one.\n\t\t\tif (options.signal?.aborted) return { mined, report, aborted: true };\n\t\t\treport.failed++;\n\t\t}\n\t\tdone++;\n\t\toptions.onProgress?.({ done, total: sessions.length, cached: report.cached });\n\t}\n\n\treturn { mined, report, aborted: false };\n}\n\n/** Apply the coverage verdicts to the clusters they were asked about. */\nfunction applyCoverage(directives: DirectiveCluster[], verdicts: Map<string, { rule?: string; skill?: string }>): void {\n\tfor (const cluster of directives) {\n\t\tconst verdict = verdicts.get(cluster.label);\n\t\tif (!verdict) continue;\n\t\tif (verdict.rule) {\n\t\t\tcluster.status = \"restated\";\n\t\t\tcluster.existingRule = verdict.rule;\n\t\t} else if (verdict.skill) {\n\t\t\tcluster.status = \"has-skill\";\n\t\t\tcluster.existingSkill = verdict.skill;\n\t\t}\n\t}\n}\n\n/** Mine the recent sessions for this cwd and return the ranked digest. */\nexport async function mineLearnDigest(options: MineOptions): Promise<LearnDigest> {\n\tconst { sessions, skipped, scan } = listSessions(options);\n\n\tconst agentsFilePath = findAgentsFile(options.cwd);\n\tlet agentsContent: string | undefined;\n\tif (agentsFilePath) {\n\t\ttry {\n\t\t\tagentsContent = readFileSync(agentsFilePath, \"utf-8\");\n\t\t} catch {\n\t\t\tagentsContent = undefined;\n\t\t}\n\t}\n\n\tconst { mined, report, aborted } = await mineSessions(sessions, options);\n\tpruneLearnCache(options.agentDir, options.now);\n\n\tconst state = options.ignoreState ? undefined : options.state;\n\t// Named against the labels already on record — including in `all` mode, where\n\t// suppression is off but the bookmark still has to line up next run.\n\tconst labelled = await labelSessions(mined, options.clusterer, knownLabelsFrom(options.state), options.signal);\n\n\tconst minRepeats = options.minRepeats ?? DEFAULT_MIN_DIRECTIVE_COUNT;\n\tconst maxProposals = options.maxProposals ?? DEFAULT_MAX_PER_CATEGORY;\n\tconst directives = reduceDirectives(labelled, minRepeats);\n\tconst fixes = reduceFixes(labelled, minRepeats);\n\tconst requests = reduceRequests(labelled, options.minRequestRepeats ?? DEFAULT_MIN_REQUEST_COUNT);\n\n\t// Counted with the threshold at 1, which is the same reduce over the same\n\t// input — so the difference is exactly what the threshold cost, rather than an\n\t// estimate of it.\n\tconst everyPoint =\n\t\treduceDirectives(labelled, 1).length + reduceFixes(labelled, 1).length + reduceRequests(labelled, 1).length;\n\tconst funnel = {\n\t\tcandidates: labelled.reduce((sum, session) => sum + session.candidates.length, 0),\n\t\tpoints: everyPoint,\n\t\tbelowThreshold: everyPoint - directives.length - fixes.length - requests.length,\n\t};\n\n\t// Coverage is asked only about what survived the repeat threshold. Judging\n\t// everything would mean sending the context file alongside a long tail of\n\t// one-off observations that are never going to be proposed.\n\tconst coverage = buildCoverageIndex({ cwd: options.cwd, agentDir: options.agentDir, skills: options.skills });\n\tconst queries: CoverageQuery[] = directives.map((d) => ({ label: d.label, text: d.text }));\n\tlet coverageFailed = false;\n\ttry {\n\t\tconst verdicts = await (options.coverageJudge ?? noCoverageJudge)(queries, coverage, options.signal);\n\t\tapplyCoverage(directives, verdicts);\n\t} catch {\n\t\t// A failed coverage call leaves everything `new`, which over-proposes\n\t\t// slightly. That is the right way to fail: the reader can reject a\n\t\t// duplicate, but cannot recover a proposal that was wrongly withheld. What\n\t\t// must not happen is writing that guess down as if it were a reading.\n\t\tcoverageFailed = true;\n\t}\n\n\tconst timestamps = sessions.map((s) => s.timestamp).sort();\n\t// Directives carry a real coverage signal — is this written down as a rule or\n\t// a skill right now? — which is what separates an adopted proposal from a\n\t// declined one. Fixes and requests do not: a fix may have become a rule, a\n\t// skill, or a habit, and which one is not recoverable here, so they get\n\t// suppression only and are never labelled declined.\n\tconst keptDirectives = applySuppression(\n\t\tdirectives,\n\t\tstate,\n\t\tmaxProposals,\n\t\t(item) => item.status !== \"new\",\n\t\t(item) => {\n\t\t\titem.previouslyDeclined = true;\n\t\t},\n\t);\n\tconst keptFixes = applySuppression(fixes, state, maxProposals, () => false);\n\tconst keptRequests = applySuppression(requests, state, maxProposals, () => false);\n\n\tconst surfaced = [\n\t\t// Directives carry their wording forward so a later `/learn stats` can ask\n\t\t// about coverage using the sentence rather than the slug that names it.\n\t\t...keptDirectives.kept.map((d) => ({\n\t\t\tkey: d.key,\n\t\t\tlastSeen: d.lastSeen,\n\t\t\tcovered: d.status !== \"new\",\n\t\t\ttext: d.text,\n\t\t})),\n\t\t...keptFixes.kept.map((f) => ({ key: f.key, lastSeen: f.lastSeen, covered: false })),\n\t\t...keptRequests.kept.map((r) => ({ key: r.key, lastSeen: r.lastSeen, covered: false })),\n\t];\n\n\treturn {\n\t\tscannedSessions: sessions.length,\n\t\tskippedSessions: skipped,\n\t\tscan,\n\t\tmining: report,\n\t\taborted,\n\t\tcoverageFailed,\n\t\toldestSession: timestamps[0],\n\t\tnewestSession: timestamps[timestamps.length - 1],\n\t\tagentsFilePath,\n\t\tagentsFileTokens:\n\t\t\tagentsContent === undefined ? undefined : Math.round(Buffer.byteLength(agentsContent, \"utf-8\") / 4),\n\t\tdirectives: keptDirectives.kept,\n\t\tfixes: keptFixes.kept,\n\t\trequests: keptRequests.kept,\n\t\tsuppressed: keptDirectives.suppressed + keptFixes.suppressed + keptRequests.suppressed,\n\t\tcut: keptDirectives.cut + keptFixes.cut + keptRequests.cut,\n\t\tfunnel,\n\t\tsurfaced,\n\t};\n}\n"]}