{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../../src/core/learn/state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAuBH,MAAM,WAAW,YAAY;IAC5B,6DAA6D;IAC7D,UAAU,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,cAAc,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,mBAAmB,EAAE,OAAO,CAAC;IAC7B;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,UAAU;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;CACvC;AAMD;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAE9E;AAED,4EAA4E;AAC5E,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAavD;AAED,4EAA4E;AAC5E,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,GAAG,GAAE,IAAiB,GAAG,IAAI,CAe7F;AAED,8EAA8E;AAC9E,MAAM,WAAW,gBAAgB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,iEAAiE;IACjE,QAAQ,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,OAAO,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IAClC,qEAAqE;IACrE,UAAU,EAAE,OAAO,CAAC;IACpB;;;;OAIG;IACH,kBAAkB,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,gBAAgB,GAAG,kBAAkB,CAcpF;AAED,MAAM,WAAW,UAAU;IAC1B,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,UAAU,GAAG,UAAU,CAejE;AAED,iFAAiF;AACjF,wBAAgB,cAAc,CAC7B,KAAK,EAAE,UAAU,EACjB,KAAK,EAAE,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,EAChF,GAAG,GAAE,IAAiB,GACpB,UAAU,CAYZ","sourcesContent":["/**\n * Per-directory memory of what `/learn` has already shown you.\n *\n * The extractor computes a sliding-window snapshot — \"in your last N sessions\n * you said this M times\" — which is stateless by design and correct to\n * recompute from scratch every run. What it cannot know on its own is whether\n * you have already *seen* a given item and made a call on it. Without that, a\n * rule you accepted last week comes back forever (its occurrences are still\n * inside the window), and worse, it comes back flagged `restated` — accusing a\n * rule that is working of not working. An item you declined comes back too,\n * unchanged, every single run.\n *\n * So one bookmark per directory: what was surfaced, and when. An item is shown\n * again only once something new has happened — you said it again *after* it was\n * last put in front of you. That single rule fixes both cases, and it keeps the\n * `restated` label honest, because the only way to earn it is to repeat yourself\n * after the rule already existed.\n *\n * This is a bookmark, not an index. No cards, no retrieval, no embeddings — the\n * transcripts remain the source of truth and this file can be deleted at any\n * time with no loss beyond re-proposing things once.\n */\n\nimport { existsSync, mkdirSync, readFileSync } from \"node:fs\";\nimport { basename, join } from \"node:path\";\nimport { writeFileAtomicSync } from \"../../utils/atomic-file.js\";\n\n/**\n * Bump when the on-disk shape changes; an unknown version is discarded, not\n * migrated.\n *\n * v2: keys hold the miner's semantic label (`directive:use-bun-not-npm`) where\n * v1 held normalized directive text (`directive:always use bun not npm`).\n *\n * v3: `workflow:` keys are gone — the category was removed — and labels now come\n * from a global naming pass rather than per-session guesses, so the keys a v2\n * file holds were drawn from a vocabulary that no longer exists. Keeping them\n * would suppress nothing and count as proposals that were never revisited.\n */\nconst STATE_VERSION = 3;\n\n/** Surfaced keys older than this are forgotten, so the file cannot grow without bound. */\nconst STATE_RETENTION_DAYS = 180;\n\nexport interface SurfacedItem {\n\t/** When this item was last put in front of the user, ISO. */\n\tsurfacedAt: string;\n\t/** Newest occurrence the run knew about at that point, ISO. */\n\tlastOccurrence: string;\n\t/**\n\t * Whether the item was covered by a context file the last time it was\n\t * surfaced. Comparing that against coverage now is how an adopted proposal is\n\t * told from a declined one, without asking.\n\t */\n\tcoveredWhenSurfaced: boolean;\n\t/**\n\t * Representative wording, kept so `/learn stats` can ask about coverage using\n\t * what was actually said. The key alone is a slug, and judging \"is this\n\t * written down?\" from a slug is a much weaker question than judging it from\n\t * the sentence the slug stands for.\n\t */\n\ttext?: string;\n}\n\nexport interface LearnState {\n\tversion: number;\n\tlastRun?: string;\n\tsurfaced: Record<string, SurfacedItem>;\n}\n\nfunction emptyState(): LearnState {\n\treturn { version: STATE_VERSION, surfaced: {} };\n}\n\n/**\n * State file for a directory.\n *\n * Keyed off the *name* of the cwd's session directory, which encodes the cwd —\n * so the bookmark is per working directory, matching what the digest describes.\n * Callers pass the cwd-derived path rather than whatever directory the live\n * session happens to be reading: a session with no directory of its own would\n * otherwise land every project on one nameless state file, and projects sharing\n * a custom `sessionDir` would share one bookmark between them.\n */\nexport function getLearnStatePath(agentDir: string, sessionDir: string): string {\n\treturn join(agentDir, \"learn\", `${basename(sessionDir)}.json`);\n}\n\n/** Read state, treating any unreadable or unknown-version file as empty. */\nexport function readLearnState(path: string): LearnState {\n\ttry {\n\t\tif (!existsSync(path)) return emptyState();\n\t\tconst parsed = JSON.parse(readFileSync(path, \"utf-8\")) as Partial<LearnState>;\n\t\tif (parsed.version !== STATE_VERSION || typeof parsed.surfaced !== \"object\" || parsed.surfaced === null) {\n\t\t\treturn emptyState();\n\t\t}\n\t\treturn { version: STATE_VERSION, lastRun: parsed.lastRun, surfaced: parsed.surfaced };\n\t} catch {\n\t\t// A corrupt bookmark is not worth failing a command over; the cost of\n\t\t// starting fresh is proposing a few things a second time.\n\t\treturn emptyState();\n\t}\n}\n\n/** Persist state, pruning entries nothing has referenced in a long time. */\nexport function writeLearnState(path: string, state: LearnState, now: Date = new Date()): void {\n\tconst cutoff = now.getTime() - STATE_RETENTION_DAYS * 24 * 60 * 60 * 1000;\n\tconst surfaced: Record<string, SurfacedItem> = {};\n\tfor (const [key, item] of Object.entries(state.surfaced)) {\n\t\tconst at = Date.parse(item.surfacedAt);\n\t\tif (Number.isNaN(at) || at >= cutoff) surfaced[key] = item;\n\t}\n\n\ttry {\n\t\tmkdirSync(join(path, \"..\"), { recursive: true });\n\t\twriteFileAtomicSync(path, `${JSON.stringify({ ...state, surfaced }, null, 2)}\\n`);\n\t} catch {\n\t\t// Losing the bookmark degrades /learn to its previous behaviour (proposing\n\t\t// already-decided items again). Never worth failing the command.\n\t}\n}\n\n/** What the caller needs to decide whether an item is worth showing again. */\nexport interface SuppressionInput {\n\tkey: string;\n\t/** Newest occurrence of this item in the current window, ISO. */\n\tlastSeen: string;\n\t/** Whether a context file covers it right now. */\n\tcovered: boolean;\n}\n\nexport interface SuppressionVerdict {\n\t/** Drop it: already surfaced, and nothing new has happened since. */\n\tsuppressed: boolean;\n\t/**\n\t * Surfaced before, still not covered by any context file — you saw it and\n\t * chose not to write it down. Worth telling the model so it proposes again\n\t * tentatively instead of pressing the same case twice.\n\t */\n\tpreviouslyDeclined: boolean;\n}\n\n/**\n * Decide whether an item should be shown again.\n *\n * An item is suppressed when it was surfaced before and has not recurred since:\n * `lastSeen <= surfacedAt` means every occurrence backing it was already on\n * screen when you made your call. Say it again and `lastSeen` moves past\n * `surfacedAt`, and it returns — which is exactly when it is worth returning.\n */\nexport function judge(state: LearnState, input: SuppressionInput): SuppressionVerdict {\n\tconst previous = state.surfaced[input.key];\n\tif (!previous) return { suppressed: false, previouslyDeclined: false };\n\n\tconst surfacedAt = Date.parse(previous.surfacedAt);\n\tconst lastSeen = Date.parse(input.lastSeen);\n\tconst recurredSince = !Number.isNaN(surfacedAt) && !Number.isNaN(lastSeen) && lastSeen > surfacedAt;\n\n\treturn {\n\t\tsuppressed: !recurredSince,\n\t\t// Covered now but not when it was surfaced means the proposal was taken up.\n\t\t// Still uncovered means it was passed over.\n\t\tpreviouslyDeclined: !input.covered && !previous.coveredWhenSurfaced,\n\t};\n}\n\nexport interface LearnStats {\n\t/** Proposals on record, across all categories. */\n\ttotal: number;\n\tdirectives: number;\n\tfixes: number;\n\trequests: number;\n\tearliest?: string;\n\tlatest?: string;\n\tlastRun?: string;\n}\n\n/**\n * Count what has been proposed, and when.\n *\n * Deliberately no adoption rate. There used to be one, derived by re-judging\n * coverage and calling the delta \"adopted\", and it was wrong in both\n * directions: a failed judge at either end moved the number, and a proposal\n * correctly rejected as junk was indistinguishable from one ignored. It shipped\n * with two disclaimers explaining how not to misread it, which is the clearest\n * possible sign that it should not have shipped. What the reader actually wants\n * — is the always-loaded surface growing or shrinking — is measurable exactly,\n * from the context files themselves, and is reported instead.\n */\nexport function summarizeLearnState(state: LearnState): LearnStats {\n\tconst stats: LearnStats = { total: 0, directives: 0, fixes: 0, requests: 0 };\n\n\tfor (const [key, item] of Object.entries(state.surfaced)) {\n\t\tstats.total++;\n\t\tif (!stats.earliest || item.surfacedAt < stats.earliest) stats.earliest = item.surfacedAt;\n\t\tif (!stats.latest || item.surfacedAt > stats.latest) stats.latest = item.surfacedAt;\n\n\t\tif (key.startsWith(\"fix:\")) stats.fixes++;\n\t\telse if (key.startsWith(\"request:\")) stats.requests++;\n\t\telse if (key.startsWith(\"directive:\")) stats.directives++;\n\t}\n\n\tstats.lastRun = state.lastRun;\n\treturn stats;\n}\n\n/** Record everything this run put on screen, so the next run can suppress it. */\nexport function recordSurfaced(\n\tstate: LearnState,\n\titems: Array<{ key: string; lastSeen: string; covered: boolean; text?: string }>,\n\tnow: Date = new Date(),\n): LearnState {\n\tconst surfaced = { ...state.surfaced };\n\tconst at = now.toISOString();\n\tfor (const item of items) {\n\t\tsurfaced[item.key] = {\n\t\t\tsurfacedAt: at,\n\t\t\tlastOccurrence: item.lastSeen,\n\t\t\tcoveredWhenSurfaced: item.covered,\n\t\t\ttext: item.text,\n\t\t};\n\t}\n\treturn { version: STATE_VERSION, lastRun: at, surfaced };\n}\n"]}