{"version":3,"file":"digest.d.ts","sourceRoot":"","sources":["../../../src/core/learn/digest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AA4BhD,oEAAoE;AACpE,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAE1D;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CA8C7D;AAED,wBAAgB,iBAAiB,CAChC,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE;IAAE,aAAa,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,aAAa,GAAG,KAAK,CAAA;CAAE,GAC9D,MAAM,CAmMR","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"]}