{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../../src/core/learn/cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAMH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AA0BhD,MAAM,WAAW,YAAY;IAC5B,sFAAsF;IACtF,SAAS,EAAE,MAAM,CAAC;IAClB,+BAA+B;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,wCAAwC;IACxC,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMhE;AAMD,gFAAgF;AAChF,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAezF;AAED,mFAAmF;AACnF,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,GAAG,IAAI,CAO3F;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,GAAE,IAAiB,GAAG,IAAI,CAgB9E;AAED;;;;;GAKG","sourcesContent":["/**\n * Per-session memo of the miner's output, keyed on file content.\n *\n * This is what makes an LLM-read-everything pipeline affordable. A closed\n * session transcript never changes again, so the model needs to read it exactly\n * once in its life. Hash the bytes, keep the candidates, and a routine `/learn`\n * pays for the one or two sessions written since the last run while the other\n * eighteen come back for free.\n *\n * It also happens to be the right answer to \"incremental vs. full history\",\n * which an earlier design tried to solve with an mtime cursor. A cursor breaks\n * the counting: if a run only *reads* sessions newer than the cursor, a\n * directive said once today has a count of one, because the four earlier\n * occurrences were never in the scan. Caching moves the skipping to the\n * expensive step only — the reduce step still runs over every cached session\n * every time, so the cross-session counts stay exact no matter how little was\n * mined this run.\n *\n * The cache is disposable. Deleting it costs one re-mine and nothing else, so\n * every failure path here degrades to \"mine it again\" rather than to an error.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { writeFileAtomicSync } from \"../../utils/atomic-file.js\";\nimport type { MinedCandidate } from \"./mine.js\";\n\n/**\n * Bump when the miner prompt, the candidate shape, or what the miner is shown\n * changes.\n *\n * The version is part of the cache key, not a field inside the entry, so a bump\n * invalidates every entry at once without a migration or a sweep — old files\n * simply stop being looked up, and the pruner reclaims them on age.\n *\n * v2: transcripts no longer carry successful tool output or replayed\n * slash-command bodies, and candidates are dropped when their quote cannot be\n * found in what the user said. Entries mined before that were read from a\n * different transcript than the one the pipeline now produces, so keeping them\n * would mean counting evidence the current rules would have rejected.\n *\n * v3: candidates no longer carry a label. Naming moved to a global pass that\n * sees the whole window, which is also what makes this file model-independent:\n * a cached label was frozen at mining time, so changing the `fast` tier forked\n * the vocabulary permanently and split every count across the seam.\n */\nconst CACHE_VERSION = 3;\n\n/** Entries untouched for this long are reclaimed. */\nconst CACHE_RETENTION_DAYS = 180;\n\nexport interface CachedMining {\n\t/** Session identity, carried so a cache hit does not need the transcript reparsed. */\n\tsessionId: string;\n\t/** Session start time, ISO. */\n\ttimestamp: string;\n\tcandidates: MinedCandidate[];\n\t/** When this entry was written, ISO. */\n\tminedAt: string;\n}\n\nexport function getLearnCacheDir(agentDir: string): string {\n\treturn join(agentDir, \"learn\", \"cache\");\n}\n\n/**\n * Content hash of a session file.\n *\n * Content, not mtime: a resumed session gets a fresh mtime with identical\n * bytes, and a file copied between machines gets a new mtime too. Both would\n * force a needless re-mine. Content also makes the reverse mistake impossible —\n * a file whose bytes changed always misses the cache, which matters because the\n * live session is appended to between runs.\n */\nexport function hashSessionFile(file: string): string | undefined {\n\ttry {\n\t\treturn createHash(\"sha256\").update(readFileSync(file)).digest(\"hex\").slice(0, 32);\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nfunction entryPath(agentDir: string, hash: string): string {\n\treturn join(getLearnCacheDir(agentDir), `v${CACHE_VERSION}-${hash}.json`);\n}\n\n/** Look up a previously mined session. Any unreadable entry reads as a miss. */\nexport function readCachedMining(agentDir: string, hash: string): CachedMining | undefined {\n\tconst path = entryPath(agentDir, hash);\n\ttry {\n\t\tif (!existsSync(path)) return undefined;\n\t\tconst parsed = JSON.parse(readFileSync(path, \"utf-8\")) as Partial<CachedMining>;\n\t\tif (!Array.isArray(parsed.candidates) || typeof parsed.sessionId !== \"string\") return undefined;\n\t\treturn {\n\t\t\tsessionId: parsed.sessionId,\n\t\t\ttimestamp: typeof parsed.timestamp === \"string\" ? parsed.timestamp : new Date(0).toISOString(),\n\t\t\tcandidates: parsed.candidates as MinedCandidate[],\n\t\t\tminedAt: typeof parsed.minedAt === \"string\" ? parsed.minedAt : new Date(0).toISOString(),\n\t\t};\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/** Store a mined session. Failing to cache is never worth failing the run over. */\nexport function writeCachedMining(agentDir: string, hash: string, entry: CachedMining): void {\n\ttry {\n\t\tmkdirSync(getLearnCacheDir(agentDir), { recursive: true });\n\t\twriteFileAtomicSync(entryPath(agentDir, hash), `${JSON.stringify(entry, null, 2)}\\n`);\n\t} catch {\n\t\t// The cost is re-mining this session next run.\n\t}\n}\n\n/**\n * Drop entries nothing has referenced in a long time, so a machine that has\n * been running this for a year does not keep every session it ever saw.\n */\nexport function pruneLearnCache(agentDir: string, now: Date = new Date()): void {\n\tconst dir = getLearnCacheDir(agentDir);\n\tif (!existsSync(dir)) return;\n\tconst cutoff = now.getTime() - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000;\n\ttry {\n\t\tfor (const name of readdirSync(dir)) {\n\t\t\tconst path = join(dir, name);\n\t\t\ttry {\n\t\t\t\tif (statSync(path).mtime.getTime() < cutoff) rmSync(path, { force: true });\n\t\t\t} catch {\n\t\t\t\t// Concurrent run reclaimed it first.\n\t\t\t}\n\t\t}\n\t} catch {\n\t\t// An unreadable cache directory is not an error worth surfacing.\n\t}\n}\n\n/**\n * Counting what a run still owes the model lives in `extract.ts:planMining`,\n * not here: the answer depends on which sessions the window actually selects,\n * and duplicating that selection is how the confirmation prompt ends up\n * quoting a number the run does not honour.\n */\n"]}