@kolisachint/hoocode-agent 0.5.17 → 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.
- package/CHANGELOG.md +247 -0
- package/dist/core/learn/audit.d.ts +136 -0
- package/dist/core/learn/audit.d.ts.map +1 -0
- package/dist/core/learn/audit.js +316 -0
- package/dist/core/learn/audit.js.map +1 -0
- package/dist/core/learn/cache.d.ts +58 -0
- package/dist/core/learn/cache.d.ts.map +1 -0
- package/dist/core/learn/cache.js +132 -0
- package/dist/core/learn/cache.js.map +1 -0
- package/dist/core/learn/cluster.d.ts +78 -0
- package/dist/core/learn/cluster.d.ts.map +1 -0
- package/dist/core/learn/cluster.js +184 -0
- package/dist/core/learn/cluster.js.map +1 -0
- package/dist/core/learn/coverage.d.ts +58 -0
- package/dist/core/learn/coverage.d.ts.map +1 -0
- package/dist/core/learn/coverage.js +144 -0
- package/dist/core/learn/coverage.js.map +1 -0
- package/dist/core/learn/digest.d.ts +13 -0
- package/dist/core/learn/digest.d.ts.map +1 -1
- package/dist/core/learn/digest.js +113 -14
- package/dist/core/learn/digest.js.map +1 -1
- package/dist/core/learn/extract.d.ts +108 -105
- package/dist/core/learn/extract.d.ts.map +1 -1
- package/dist/core/learn/extract.js +308 -447
- package/dist/core/learn/extract.js.map +1 -1
- package/dist/core/learn/mine.d.ts +178 -0
- package/dist/core/learn/mine.d.ts.map +1 -0
- package/dist/core/learn/mine.js +390 -0
- package/dist/core/learn/mine.js.map +1 -0
- package/dist/core/learn/reduce.d.ts +89 -0
- package/dist/core/learn/reduce.d.ts.map +1 -0
- package/dist/core/learn/reduce.js +179 -0
- package/dist/core/learn/reduce.js.map +1 -0
- package/dist/core/learn/state.d.ts +19 -18
- package/dist/core/learn/state.d.ts.map +1 -1
- package/dist/core/learn/state.js +35 -31
- package/dist/core/learn/state.js.map +1 -1
- package/dist/core/settings-defaults.d.ts +1 -1
- package/dist/core/settings-defaults.d.ts.map +1 -1
- package/dist/core/settings-defaults.js +1 -1
- package/dist/core/settings-defaults.js.map +1 -1
- package/dist/core/settings-manager.d.ts +4 -2
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +5 -1
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/settings-types.d.ts +1 -1
- package/dist/core/settings-types.d.ts.map +1 -1
- package/dist/core/settings-types.js.map +1 -1
- package/dist/core/startup-progress.d.ts +12 -7
- package/dist/core/startup-progress.d.ts.map +1 -1
- package/dist/core/startup-progress.js +12 -7
- package/dist/core/startup-progress.js.map +1 -1
- package/dist/extensions/core/learn.d.ts +8 -4
- package/dist/extensions/core/learn.d.ts.map +1 -1
- package/dist/extensions/core/learn.js +292 -56
- package/dist/extensions/core/learn.js.map +1 -1
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +7 -25
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/interactive/components/progress-bar.d.ts +50 -0
- package/dist/modes/interactive/components/progress-bar.d.ts.map +1 -0
- package/dist/modes/interactive/components/progress-bar.js +77 -0
- package/dist/modes/interactive/components/progress-bar.js.map +1 -0
- package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/settings-selector.js +1 -1
- package/dist/modes/interactive/components/settings-selector.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +1 -1
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/voice/voice-panel.d.ts +6 -1
- package/dist/modes/interactive/voice/voice-panel.d.ts.map +1 -1
- package/dist/modes/interactive/voice/voice-panel.js +18 -14
- package/dist/modes/interactive/voice/voice-panel.js.map +1 -1
- package/docs/settings.md +9 -6
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
- package/dist/core/learn/normalize.d.ts +0 -65
- package/dist/core/learn/normalize.d.ts.map +0 -1
- package/dist/core/learn/normalize.js +0 -245
- package/dist/core/learn/normalize.js.map +0 -1
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subtractive half of `/learn`: which lines in a context file describe
|
|
3
|
+
* things that no longer exist?
|
|
4
|
+
*
|
|
5
|
+
* The mining pipeline can only ever propose additions. Nothing in it moves the
|
|
6
|
+
* always-loaded token surface down, so a context file accumulates: a rule
|
|
7
|
+
* naming a deleted workflow, a command that was removed, a file that moved two
|
|
8
|
+
* refactors ago. Those lines cost tokens on every request forever and are worse
|
|
9
|
+
* than useless, because the agent believes them.
|
|
10
|
+
*
|
|
11
|
+
* This is deliberately deterministic — no model call, no cache, no state. It
|
|
12
|
+
* reads the context files already in force, pulls out the referents they name
|
|
13
|
+
* in backticks, and asks the filesystem. That makes it instant and free, which
|
|
14
|
+
* is what lets it be the half you run most often.
|
|
15
|
+
*
|
|
16
|
+
* Precision is bought with exclusions rather than cleverness, because a noisy
|
|
17
|
+
* audit is one nobody reads. Three rules do most of the work:
|
|
18
|
+
*
|
|
19
|
+
* - **Only path-like referents with a separator.** A bare `auth.json` could be
|
|
20
|
+
* anywhere or nowhere; `docs/providers.md` is a claim about this repo.
|
|
21
|
+
* - **Resolve against every package root, not just the repo root.** A monorepo
|
|
22
|
+
* names `src/cli/args.ts` relative to the package being discussed, and
|
|
23
|
+
* checking only the repo root reports the entire contributing guide as stale.
|
|
24
|
+
* - **Skip lines that assert absence.** "these are all gone", "e.g.
|
|
25
|
+
* `bedrock-utils.ts`", "create `foo.ts`" legitimately name files that do not
|
|
26
|
+
* exist. Deciding this in general is a judgement call; a short vocabulary of
|
|
27
|
+
* assertive forms catches the cases that occur in practice.
|
|
28
|
+
*
|
|
29
|
+
* What is left is a short list where a wrong entry costs one glance and a right
|
|
30
|
+
* one costs a line of always-loaded context. That asymmetry is the reason the
|
|
31
|
+
* remaining false positives are acceptable and silent misses are not.
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
34
|
+
import { join, resolve, sep } from "node:path";
|
|
35
|
+
/** Directories never descended into when discovering package roots. */
|
|
36
|
+
const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", "coverage", "out", ".next", ".turbo"]);
|
|
37
|
+
/** How deep to look for package roots. Deep enough for a monorepo, shallow enough to stay instant. */
|
|
38
|
+
const MAX_ROOT_DEPTH = 3;
|
|
39
|
+
/** Extensions that make a token a file reference even without an obvious path shape. */
|
|
40
|
+
const FILE_EXTENSIONS = /\.(md|markdown|ts|tsx|js|jsx|mjs|cjs|json|jsonc|ya?ml|toml|sh|bash|zsh|py|rs|go|lock|txt|css|html)$/i;
|
|
41
|
+
/**
|
|
42
|
+
* Forms that legitimately name something absent.
|
|
43
|
+
*
|
|
44
|
+
* Every one of these was a false positive on a real context file before it was
|
|
45
|
+
* excluded. `removed`/`gone` describe a deletion; `e.g.`/`for example` name a
|
|
46
|
+
* pattern rather than a file; `create`/`scaffold` describe an artifact the
|
|
47
|
+
* reader is being told to write.
|
|
48
|
+
*
|
|
49
|
+
* The list is deliberately short. Broad prohibitions ("never edit X", "do not
|
|
50
|
+
* run Y") name things that exist and are exactly the claims worth checking, so
|
|
51
|
+
* matching on those words would trade the audit's whole purpose for a little
|
|
52
|
+
* precision.
|
|
53
|
+
*/
|
|
54
|
+
const ABSENCE_MARKERS = [
|
|
55
|
+
"removed",
|
|
56
|
+
"deleted",
|
|
57
|
+
" gone",
|
|
58
|
+
"no longer",
|
|
59
|
+
"used to",
|
|
60
|
+
"if present",
|
|
61
|
+
"if it exists",
|
|
62
|
+
"optional",
|
|
63
|
+
"e.g.",
|
|
64
|
+
"for example",
|
|
65
|
+
"create ",
|
|
66
|
+
"scaffold",
|
|
67
|
+
];
|
|
68
|
+
/** Referents that look like a git ref rather than a path. */
|
|
69
|
+
const GIT_REF_PREFIXES = ["origin/", "upstream/", "refs/", "HEAD"];
|
|
70
|
+
/** Rough token estimate, matching the convention used elsewhere for context files. */
|
|
71
|
+
function estimateTokens(text) {
|
|
72
|
+
return Math.round(Buffer.byteLength(text, "utf-8") / 4);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Directories a relative referent may be resolved against.
|
|
76
|
+
*
|
|
77
|
+
* The repo root alone is not enough: a monorepo's contributing notes name
|
|
78
|
+
* `src/cli/args.ts` meaning "inside the package under discussion", and resolving
|
|
79
|
+
* that only from the root reports every such line as stale. Every directory
|
|
80
|
+
* holding a `package.json` is therefore a root, nearest-shallowest first.
|
|
81
|
+
*/
|
|
82
|
+
export function resolutionRoots(base) {
|
|
83
|
+
const roots = [resolve(base)];
|
|
84
|
+
const walk = (dir, depth) => {
|
|
85
|
+
if (depth > MAX_ROOT_DEPTH)
|
|
86
|
+
return;
|
|
87
|
+
let entries;
|
|
88
|
+
try {
|
|
89
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (!entry.isDirectory() || IGNORED_DIRS.has(entry.name))
|
|
96
|
+
continue;
|
|
97
|
+
const child = join(dir, entry.name);
|
|
98
|
+
if (existsSync(join(child, "package.json")))
|
|
99
|
+
roots.push(child);
|
|
100
|
+
walk(child, depth + 1);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
walk(resolve(base), 1);
|
|
104
|
+
return roots;
|
|
105
|
+
}
|
|
106
|
+
/** Every backticked span in a line, in order. */
|
|
107
|
+
function backtickedSpans(line) {
|
|
108
|
+
const out = [];
|
|
109
|
+
const pattern = /`([^`\n]+)`/g;
|
|
110
|
+
let match = pattern.exec(line);
|
|
111
|
+
while (match) {
|
|
112
|
+
if (match[1])
|
|
113
|
+
out.push(match[1]);
|
|
114
|
+
match = pattern.exec(line);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/** True when the line reads as a statement about something absent, optional, or yet to be written. */
|
|
119
|
+
function assertsAbsence(line) {
|
|
120
|
+
const lower = line.toLowerCase();
|
|
121
|
+
return ABSENCE_MARKERS.some((marker) => lower.includes(marker));
|
|
122
|
+
}
|
|
123
|
+
/** The script name a `bun run x` / `npm run x` reference names, if it is one. */
|
|
124
|
+
function scriptReference(token) {
|
|
125
|
+
const match = /^(?:bun|npm|pnpm|yarn)\s+run\s+([A-Za-z0-9:_-]+)$/.exec(token.trim());
|
|
126
|
+
return match?.[1];
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Decide whether a backticked token is a checkable claim about this repo.
|
|
130
|
+
*
|
|
131
|
+
* Ordering matters: the skip reasons are reported as counts, and a token that
|
|
132
|
+
* matches several should be attributed to the most specific one, so a
|
|
133
|
+
* placeholder is a placeholder rather than "ambiguous".
|
|
134
|
+
*/
|
|
135
|
+
export function classifyReferent(token) {
|
|
136
|
+
const value = token.trim();
|
|
137
|
+
const script = scriptReference(value);
|
|
138
|
+
if (script)
|
|
139
|
+
return { kind: "script", script };
|
|
140
|
+
if (/[<>*{}$|]/.test(value))
|
|
141
|
+
return { kind: "skip", reason: "placeholder" };
|
|
142
|
+
if (/^https?:\/\//i.test(value))
|
|
143
|
+
return { kind: "skip", reason: "external" };
|
|
144
|
+
if (GIT_REF_PREFIXES.some((prefix) => value.startsWith(prefix)))
|
|
145
|
+
return { kind: "skip", reason: "external" };
|
|
146
|
+
// A shell command, a prose fragment, or a flag list — not a path.
|
|
147
|
+
if (/\s/.test(value))
|
|
148
|
+
return { kind: "skip", reason: "ambiguous" };
|
|
149
|
+
if (value.startsWith("~") || value.startsWith(sep) || /^[A-Za-z]:[\\/]/.test(value)) {
|
|
150
|
+
return { kind: "skip", reason: "runtime" };
|
|
151
|
+
}
|
|
152
|
+
if (value === "." || value === "..")
|
|
153
|
+
return { kind: "skip", reason: "ambiguous" };
|
|
154
|
+
// The load-bearing precision rule: without a separator the token names a
|
|
155
|
+
// filename that could be anywhere, and "anywhere" is not a claim worth
|
|
156
|
+
// contradicting. A bare `stream.test.ts` in a monorepo is not stale, it is
|
|
157
|
+
// under-specified.
|
|
158
|
+
if (!value.includes("/"))
|
|
159
|
+
return { kind: "skip", reason: "ambiguous" };
|
|
160
|
+
// Past the separator rule, something that is neither a known file type nor an
|
|
161
|
+
// obvious directory is more likely prose than a path.
|
|
162
|
+
if (!FILE_EXTENSIONS.test(value) && !value.endsWith("/"))
|
|
163
|
+
return { kind: "skip", reason: "ambiguous" };
|
|
164
|
+
return { kind: "path" };
|
|
165
|
+
}
|
|
166
|
+
/** True when the referent resolves against any root. Directories count. */
|
|
167
|
+
function pathResolves(referent, roots) {
|
|
168
|
+
const relative = referent.replace(/\/+$/, "");
|
|
169
|
+
if (!relative)
|
|
170
|
+
return false;
|
|
171
|
+
return roots.some((root) => existsSync(join(root, relative)));
|
|
172
|
+
}
|
|
173
|
+
/** Script names declared by any `package.json` at any resolution root. */
|
|
174
|
+
function declaredScripts(roots) {
|
|
175
|
+
const names = new Set();
|
|
176
|
+
for (const root of roots) {
|
|
177
|
+
const manifest = join(root, "package.json");
|
|
178
|
+
if (!existsSync(manifest))
|
|
179
|
+
continue;
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(readFileSync(manifest, "utf-8"));
|
|
182
|
+
for (const name of Object.keys(parsed.scripts ?? {}))
|
|
183
|
+
names.add(name);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// A malformed manifest is not this command's problem; treat it as
|
|
187
|
+
// declaring nothing rather than failing the audit.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return names;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The project a referent is resolved against: the nearest ancestor holding a
|
|
194
|
+
* `.git`, or `cwd` when there is none.
|
|
195
|
+
*
|
|
196
|
+
* Not `cwd` itself. Context files are collected by walking up from `cwd`, so in
|
|
197
|
+
* a monorepo the repo's `AGENTS.md` sits *above* the package you are working
|
|
198
|
+
* in — and running from a package root is the normal case, not the exception.
|
|
199
|
+
* Anchoring on `cwd` meant the file with all the claims in it was declared "not
|
|
200
|
+
* in this working tree" and skipped, so the audit passed by checking nothing.
|
|
201
|
+
*/
|
|
202
|
+
export function findProjectRoot(cwd) {
|
|
203
|
+
let dir = resolve(cwd);
|
|
204
|
+
while (true) {
|
|
205
|
+
if (existsSync(join(dir, ".git")))
|
|
206
|
+
return dir;
|
|
207
|
+
const parent = resolve(dir, "..");
|
|
208
|
+
if (parent === dir)
|
|
209
|
+
return resolve(cwd);
|
|
210
|
+
dir = parent;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** True when the path is inside the project, so its claims are about this repo. */
|
|
214
|
+
function insideTree(path, root) {
|
|
215
|
+
const base = resolve(root);
|
|
216
|
+
const target = resolve(path);
|
|
217
|
+
return target === base || target.startsWith(base + sep);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Check every referent named by the repo-scope context files.
|
|
221
|
+
*
|
|
222
|
+
* Files outside the project are listed but not audited: a rule in
|
|
223
|
+
* `~/.agents/AGENTS.md` naming `src/index.ts` is a claim about whichever repo
|
|
224
|
+
* it was written for, and resolving it here would report another project's
|
|
225
|
+
* rules as broken.
|
|
226
|
+
*
|
|
227
|
+
* File contents are re-read from disk rather than taken from the loader, which
|
|
228
|
+
* truncates oversized files for the prompt — auditing the truncation would
|
|
229
|
+
* silently stop checking exactly the files most likely to have gone stale.
|
|
230
|
+
*/
|
|
231
|
+
export function auditContextFiles(options) {
|
|
232
|
+
// Rooted at the project, not at `cwd`: a path in the repo's context file is
|
|
233
|
+
// written relative to the repo, and is being read from wherever you happen to
|
|
234
|
+
// be working.
|
|
235
|
+
const projectRoot = findProjectRoot(options.cwd);
|
|
236
|
+
const roots = resolutionRoots(projectRoot);
|
|
237
|
+
const scripts = declaredScripts(roots);
|
|
238
|
+
const report = {
|
|
239
|
+
files: [],
|
|
240
|
+
skippedFiles: [],
|
|
241
|
+
checked: 0,
|
|
242
|
+
skipped: { placeholder: 0, runtime: 0, external: 0, ambiguous: 0, assertsAbsence: 0 },
|
|
243
|
+
stale: [],
|
|
244
|
+
roots,
|
|
245
|
+
};
|
|
246
|
+
for (const file of options.files) {
|
|
247
|
+
if (!insideTree(file.path, projectRoot)) {
|
|
248
|
+
report.skippedFiles.push(file.path);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
let content;
|
|
252
|
+
try {
|
|
253
|
+
content = readFileSync(file.path, "utf-8");
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
report.skippedFiles.push(file.path);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
report.files.push({ path: file.path, tokens: file.tokens ?? estimateTokens(content) });
|
|
260
|
+
const lines = content.split("\n");
|
|
261
|
+
let inFence = false;
|
|
262
|
+
for (const [index, raw] of lines.entries()) {
|
|
263
|
+
// A fenced block is example code, not a claim about the repo. Its
|
|
264
|
+
// contents are also where most of a context file's plausible-looking
|
|
265
|
+
// paths live, so auditing it is almost pure noise.
|
|
266
|
+
if (raw.trimStart().startsWith("```")) {
|
|
267
|
+
inFence = !inFence;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (inFence)
|
|
271
|
+
continue;
|
|
272
|
+
const spans = backtickedSpans(raw);
|
|
273
|
+
if (spans.length === 0)
|
|
274
|
+
continue;
|
|
275
|
+
const absence = assertsAbsence(raw);
|
|
276
|
+
for (const span of spans) {
|
|
277
|
+
const classification = classifyReferent(span);
|
|
278
|
+
if (classification.kind === "skip") {
|
|
279
|
+
report.skipped[classification.reason]++;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (absence) {
|
|
283
|
+
report.skipped.assertsAbsence++;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
report.checked++;
|
|
287
|
+
const resolved = classification.kind === "script" ? scripts.has(classification.script) : pathResolves(span, roots);
|
|
288
|
+
if (resolved)
|
|
289
|
+
continue;
|
|
290
|
+
report.stale.push({
|
|
291
|
+
file: file.path,
|
|
292
|
+
line: index + 1,
|
|
293
|
+
lineText: raw.trim(),
|
|
294
|
+
referent: span,
|
|
295
|
+
kind: classification.kind,
|
|
296
|
+
tokens: estimateTokens(raw),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return report;
|
|
302
|
+
}
|
|
303
|
+
/** Total recurring cost of the lines the audit flagged. */
|
|
304
|
+
export function staleTokens(report) {
|
|
305
|
+
const seen = new Set();
|
|
306
|
+
let total = 0;
|
|
307
|
+
for (const item of report.stale) {
|
|
308
|
+
const key = `${item.file}:${item.line}`;
|
|
309
|
+
if (seen.has(key))
|
|
310
|
+
continue;
|
|
311
|
+
seen.add(key);
|
|
312
|
+
total += item.tokens;
|
|
313
|
+
}
|
|
314
|
+
return total;
|
|
315
|
+
}
|
|
316
|
+
//# sourceMappingURL=audit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit.js","sourceRoot":"","sources":["../../../src/core/learn/audit.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The subtractive half of `/learn`: which lines in a context file describe\n * things that no longer exist?\n *\n * The mining pipeline can only ever propose additions. Nothing in it moves the\n * always-loaded token surface down, so a context file accumulates: a rule\n * naming a deleted workflow, a command that was removed, a file that moved two\n * refactors ago. Those lines cost tokens on every request forever and are worse\n * than useless, because the agent believes them.\n *\n * This is deliberately deterministic — no model call, no cache, no state. It\n * reads the context files already in force, pulls out the referents they name\n * in backticks, and asks the filesystem. That makes it instant and free, which\n * is what lets it be the half you run most often.\n *\n * Precision is bought with exclusions rather than cleverness, because a noisy\n * audit is one nobody reads. Three rules do most of the work:\n *\n * - **Only path-like referents with a separator.** A bare `auth.json` could be\n * anywhere or nowhere; `docs/providers.md` is a claim about this repo.\n * - **Resolve against every package root, not just the repo root.** A monorepo\n * names `src/cli/args.ts` relative to the package being discussed, and\n * checking only the repo root reports the entire contributing guide as stale.\n * - **Skip lines that assert absence.** \"these are all gone\", \"e.g.\n * `bedrock-utils.ts`\", \"create `foo.ts`\" legitimately name files that do not\n * exist. Deciding this in general is a judgement call; a short vocabulary of\n * assertive forms catches the cases that occur in practice.\n *\n * What is left is a short list where a wrong entry costs one glance and a right\n * one costs a line of always-loaded context. That asymmetry is the reason the\n * remaining false positives are acceptable and silent misses are not.\n */\n\nimport { type Dirent, existsSync, readdirSync, readFileSync } from \"node:fs\";\nimport { join, resolve, sep } from \"node:path\";\n\n/** One referent that did not resolve, with the line that claimed it. */\nexport interface StaleReference {\n\t/** Context file the claim lives in. */\n\tfile: string;\n\t/** 1-based line number. */\n\tline: number;\n\t/** The line, trimmed — what the reader would delete or fix. */\n\tlineText: string;\n\t/** The referent that could not be found. */\n\treferent: string;\n\tkind: \"path\" | \"script\";\n\t/** Rough token cost of the line, so the value of deleting it is visible. */\n\ttokens: number;\n}\n\n/** Why a referent was not checked. Reported as counts so the audit's reach is visible. */\nexport interface AuditSkips {\n\t/** Contains a placeholder or a glob, e.g. an angle-bracket stand-in or a star. */\n\tplaceholder: number;\n\t/** Home-relative or absolute: a runtime location, not a repo artifact. */\n\truntime: number;\n\t/** A URL, or a git ref like `origin/main`. */\n\texternal: number;\n\t/** No path separator, so the claim is not about a specific location. */\n\tambiguous: number;\n\t/** The line asserts the referent is absent, optional, or to be created. */\n\tassertsAbsence: number;\n}\n\nexport interface AuditReport {\n\t/** Context files audited, with their recurring cost. */\n\tfiles: Array<{ path: string; tokens: number }>;\n\t/** Context files skipped because they live outside the working tree. */\n\tskippedFiles: string[];\n\t/** Referents actually resolved against the filesystem. */\n\tchecked: number;\n\tskipped: AuditSkips;\n\tstale: StaleReference[];\n\t/** Directories referents were resolved against, nearest first. */\n\troots: string[];\n}\n\n/** Directories never descended into when discovering package roots. */\nconst IGNORED_DIRS = new Set([\"node_modules\", \".git\", \"dist\", \"build\", \"coverage\", \"out\", \".next\", \".turbo\"]);\n\n/** How deep to look for package roots. Deep enough for a monorepo, shallow enough to stay instant. */\nconst MAX_ROOT_DEPTH = 3;\n\n/** Extensions that make a token a file reference even without an obvious path shape. */\nconst FILE_EXTENSIONS =\n\t/\\.(md|markdown|ts|tsx|js|jsx|mjs|cjs|json|jsonc|ya?ml|toml|sh|bash|zsh|py|rs|go|lock|txt|css|html)$/i;\n\n/**\n * Forms that legitimately name something absent.\n *\n * Every one of these was a false positive on a real context file before it was\n * excluded. `removed`/`gone` describe a deletion; `e.g.`/`for example` name a\n * pattern rather than a file; `create`/`scaffold` describe an artifact the\n * reader is being told to write.\n *\n * The list is deliberately short. Broad prohibitions (\"never edit X\", \"do not\n * run Y\") name things that exist and are exactly the claims worth checking, so\n * matching on those words would trade the audit's whole purpose for a little\n * precision.\n */\nconst ABSENCE_MARKERS = [\n\t\"removed\",\n\t\"deleted\",\n\t\" gone\",\n\t\"no longer\",\n\t\"used to\",\n\t\"if present\",\n\t\"if it exists\",\n\t\"optional\",\n\t\"e.g.\",\n\t\"for example\",\n\t\"create \",\n\t\"scaffold\",\n];\n\n/** Referents that look like a git ref rather than a path. */\nconst GIT_REF_PREFIXES = [\"origin/\", \"upstream/\", \"refs/\", \"HEAD\"];\n\n/** Rough token estimate, matching the convention used elsewhere for context files. */\nfunction estimateTokens(text: string): number {\n\treturn Math.round(Buffer.byteLength(text, \"utf-8\") / 4);\n}\n\n/**\n * Directories a relative referent may be resolved against.\n *\n * The repo root alone is not enough: a monorepo's contributing notes name\n * `src/cli/args.ts` meaning \"inside the package under discussion\", and resolving\n * that only from the root reports every such line as stale. Every directory\n * holding a `package.json` is therefore a root, nearest-shallowest first.\n */\nexport function resolutionRoots(base: string): string[] {\n\tconst roots: string[] = [resolve(base)];\n\n\tconst walk = (dir: string, depth: number): void => {\n\t\tif (depth > MAX_ROOT_DEPTH) return;\n\t\tlet entries: Dirent[];\n\t\ttry {\n\t\t\tentries = readdirSync(dir, { withFileTypes: true });\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\t\tfor (const entry of entries) {\n\t\t\tif (!entry.isDirectory() || IGNORED_DIRS.has(entry.name)) continue;\n\t\t\tconst child = join(dir, entry.name);\n\t\t\tif (existsSync(join(child, \"package.json\"))) roots.push(child);\n\t\t\twalk(child, depth + 1);\n\t\t}\n\t};\n\twalk(resolve(base), 1);\n\n\treturn roots;\n}\n\n/** Every backticked span in a line, in order. */\nfunction backtickedSpans(line: string): string[] {\n\tconst out: string[] = [];\n\tconst pattern = /`([^`\\n]+)`/g;\n\tlet match = pattern.exec(line);\n\twhile (match) {\n\t\tif (match[1]) out.push(match[1]);\n\t\tmatch = pattern.exec(line);\n\t}\n\treturn out;\n}\n\n/** True when the line reads as a statement about something absent, optional, or yet to be written. */\nfunction assertsAbsence(line: string): boolean {\n\tconst lower = line.toLowerCase();\n\treturn ABSENCE_MARKERS.some((marker) => lower.includes(marker));\n}\n\n/** The script name a `bun run x` / `npm run x` reference names, if it is one. */\nfunction scriptReference(token: string): string | undefined {\n\tconst match = /^(?:bun|npm|pnpm|yarn)\\s+run\\s+([A-Za-z0-9:_-]+)$/.exec(token.trim());\n\treturn match?.[1];\n}\n\ntype Classification =\n\t| { kind: \"path\" }\n\t| { kind: \"script\"; script: string }\n\t| { kind: \"skip\"; reason: keyof AuditSkips };\n\n/**\n * Decide whether a backticked token is a checkable claim about this repo.\n *\n * Ordering matters: the skip reasons are reported as counts, and a token that\n * matches several should be attributed to the most specific one, so a\n * placeholder is a placeholder rather than \"ambiguous\".\n */\nexport function classifyReferent(token: string): Classification {\n\tconst value = token.trim();\n\tconst script = scriptReference(value);\n\tif (script) return { kind: \"script\", script };\n\n\tif (/[<>*{}$|]/.test(value)) return { kind: \"skip\", reason: \"placeholder\" };\n\tif (/^https?:\\/\\//i.test(value)) return { kind: \"skip\", reason: \"external\" };\n\tif (GIT_REF_PREFIXES.some((prefix) => value.startsWith(prefix))) return { kind: \"skip\", reason: \"external\" };\n\t// A shell command, a prose fragment, or a flag list — not a path.\n\tif (/\\s/.test(value)) return { kind: \"skip\", reason: \"ambiguous\" };\n\tif (value.startsWith(\"~\") || value.startsWith(sep) || /^[A-Za-z]:[\\\\/]/.test(value)) {\n\t\treturn { kind: \"skip\", reason: \"runtime\" };\n\t}\n\tif (value === \".\" || value === \"..\") return { kind: \"skip\", reason: \"ambiguous\" };\n\t// The load-bearing precision rule: without a separator the token names a\n\t// filename that could be anywhere, and \"anywhere\" is not a claim worth\n\t// contradicting. A bare `stream.test.ts` in a monorepo is not stale, it is\n\t// under-specified.\n\tif (!value.includes(\"/\")) return { kind: \"skip\", reason: \"ambiguous\" };\n\t// Past the separator rule, something that is neither a known file type nor an\n\t// obvious directory is more likely prose than a path.\n\tif (!FILE_EXTENSIONS.test(value) && !value.endsWith(\"/\")) return { kind: \"skip\", reason: \"ambiguous\" };\n\n\treturn { kind: \"path\" };\n}\n\n/** True when the referent resolves against any root. Directories count. */\nfunction pathResolves(referent: string, roots: string[]): boolean {\n\tconst relative = referent.replace(/\\/+$/, \"\");\n\tif (!relative) return false;\n\treturn roots.some((root) => existsSync(join(root, relative)));\n}\n\n/** Script names declared by any `package.json` at any resolution root. */\nfunction declaredScripts(roots: string[]): Set<string> {\n\tconst names = new Set<string>();\n\tfor (const root of roots) {\n\t\tconst manifest = join(root, \"package.json\");\n\t\tif (!existsSync(manifest)) continue;\n\t\ttry {\n\t\t\tconst parsed = JSON.parse(readFileSync(manifest, \"utf-8\")) as { scripts?: Record<string, unknown> };\n\t\t\tfor (const name of Object.keys(parsed.scripts ?? {})) names.add(name);\n\t\t} catch {\n\t\t\t// A malformed manifest is not this command's problem; treat it as\n\t\t\t// declaring nothing rather than failing the audit.\n\t\t}\n\t}\n\treturn names;\n}\n\n/**\n * The project a referent is resolved against: the nearest ancestor holding a\n * `.git`, or `cwd` when there is none.\n *\n * Not `cwd` itself. Context files are collected by walking up from `cwd`, so in\n * a monorepo the repo's `AGENTS.md` sits *above* the package you are working\n * in — and running from a package root is the normal case, not the exception.\n * Anchoring on `cwd` meant the file with all the claims in it was declared \"not\n * in this working tree\" and skipped, so the audit passed by checking nothing.\n */\nexport function findProjectRoot(cwd: string): string {\n\tlet dir = resolve(cwd);\n\twhile (true) {\n\t\tif (existsSync(join(dir, \".git\"))) return dir;\n\t\tconst parent = resolve(dir, \"..\");\n\t\tif (parent === dir) return resolve(cwd);\n\t\tdir = parent;\n\t}\n}\n\n/** True when the path is inside the project, so its claims are about this repo. */\nfunction insideTree(path: string, root: string): boolean {\n\tconst base = resolve(root);\n\tconst target = resolve(path);\n\treturn target === base || target.startsWith(base + sep);\n}\n\nexport interface AuditOptions {\n\tcwd: string;\n\t/** Context files in force, as loaded for the system prompt. */\n\tfiles: Array<{ path: string; tokens?: number }>;\n}\n\n/**\n * Check every referent named by the repo-scope context files.\n *\n * Files outside the project are listed but not audited: a rule in\n * `~/.agents/AGENTS.md` naming `src/index.ts` is a claim about whichever repo\n * it was written for, and resolving it here would report another project's\n * rules as broken.\n *\n * File contents are re-read from disk rather than taken from the loader, which\n * truncates oversized files for the prompt — auditing the truncation would\n * silently stop checking exactly the files most likely to have gone stale.\n */\nexport function auditContextFiles(options: AuditOptions): AuditReport {\n\t// Rooted at the project, not at `cwd`: a path in the repo's context file is\n\t// written relative to the repo, and is being read from wherever you happen to\n\t// be working.\n\tconst projectRoot = findProjectRoot(options.cwd);\n\tconst roots = resolutionRoots(projectRoot);\n\tconst scripts = declaredScripts(roots);\n\n\tconst report: AuditReport = {\n\t\tfiles: [],\n\t\tskippedFiles: [],\n\t\tchecked: 0,\n\t\tskipped: { placeholder: 0, runtime: 0, external: 0, ambiguous: 0, assertsAbsence: 0 },\n\t\tstale: [],\n\t\troots,\n\t};\n\n\tfor (const file of options.files) {\n\t\tif (!insideTree(file.path, projectRoot)) {\n\t\t\treport.skippedFiles.push(file.path);\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet content: string;\n\t\ttry {\n\t\t\tcontent = readFileSync(file.path, \"utf-8\");\n\t\t} catch {\n\t\t\treport.skippedFiles.push(file.path);\n\t\t\tcontinue;\n\t\t}\n\t\treport.files.push({ path: file.path, tokens: file.tokens ?? estimateTokens(content) });\n\n\t\tconst lines = content.split(\"\\n\");\n\t\tlet inFence = false;\n\t\tfor (const [index, raw] of lines.entries()) {\n\t\t\t// A fenced block is example code, not a claim about the repo. Its\n\t\t\t// contents are also where most of a context file's plausible-looking\n\t\t\t// paths live, so auditing it is almost pure noise.\n\t\t\tif (raw.trimStart().startsWith(\"```\")) {\n\t\t\t\tinFence = !inFence;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (inFence) continue;\n\n\t\t\tconst spans = backtickedSpans(raw);\n\t\t\tif (spans.length === 0) continue;\n\n\t\t\tconst absence = assertsAbsence(raw);\n\t\t\tfor (const span of spans) {\n\t\t\t\tconst classification = classifyReferent(span);\n\t\t\t\tif (classification.kind === \"skip\") {\n\t\t\t\t\treport.skipped[classification.reason]++;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (absence) {\n\t\t\t\t\treport.skipped.assertsAbsence++;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\treport.checked++;\n\t\t\t\tconst resolved =\n\t\t\t\t\tclassification.kind === \"script\" ? scripts.has(classification.script) : pathResolves(span, roots);\n\t\t\t\tif (resolved) continue;\n\n\t\t\t\treport.stale.push({\n\t\t\t\t\tfile: file.path,\n\t\t\t\t\tline: index + 1,\n\t\t\t\t\tlineText: raw.trim(),\n\t\t\t\t\treferent: span,\n\t\t\t\t\tkind: classification.kind,\n\t\t\t\t\ttokens: estimateTokens(raw),\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\treturn report;\n}\n\n/** Total recurring cost of the lines the audit flagged. */\nexport function staleTokens(report: AuditReport): number {\n\tconst seen = new Set<string>();\n\tlet total = 0;\n\tfor (const item of report.stale) {\n\t\tconst key = `${item.file}:${item.line}`;\n\t\tif (seen.has(key)) continue;\n\t\tseen.add(key);\n\t\ttotal += item.tokens;\n\t}\n\treturn total;\n}\n"]}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session memo of the miner's output, keyed on file content.
|
|
3
|
+
*
|
|
4
|
+
* This is what makes an LLM-read-everything pipeline affordable. A closed
|
|
5
|
+
* session transcript never changes again, so the model needs to read it exactly
|
|
6
|
+
* once in its life. Hash the bytes, keep the candidates, and a routine `/learn`
|
|
7
|
+
* pays for the one or two sessions written since the last run while the other
|
|
8
|
+
* eighteen come back for free.
|
|
9
|
+
*
|
|
10
|
+
* It also happens to be the right answer to "incremental vs. full history",
|
|
11
|
+
* which an earlier design tried to solve with an mtime cursor. A cursor breaks
|
|
12
|
+
* the counting: if a run only *reads* sessions newer than the cursor, a
|
|
13
|
+
* directive said once today has a count of one, because the four earlier
|
|
14
|
+
* occurrences were never in the scan. Caching moves the skipping to the
|
|
15
|
+
* expensive step only — the reduce step still runs over every cached session
|
|
16
|
+
* every time, so the cross-session counts stay exact no matter how little was
|
|
17
|
+
* mined this run.
|
|
18
|
+
*
|
|
19
|
+
* The cache is disposable. Deleting it costs one re-mine and nothing else, so
|
|
20
|
+
* every failure path here degrades to "mine it again" rather than to an error.
|
|
21
|
+
*/
|
|
22
|
+
import type { MinedCandidate } from "./mine.js";
|
|
23
|
+
export interface CachedMining {
|
|
24
|
+
/** Session identity, carried so a cache hit does not need the transcript reparsed. */
|
|
25
|
+
sessionId: string;
|
|
26
|
+
/** Session start time, ISO. */
|
|
27
|
+
timestamp: string;
|
|
28
|
+
candidates: MinedCandidate[];
|
|
29
|
+
/** When this entry was written, ISO. */
|
|
30
|
+
minedAt: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function getLearnCacheDir(agentDir: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Content hash of a session file.
|
|
35
|
+
*
|
|
36
|
+
* Content, not mtime: a resumed session gets a fresh mtime with identical
|
|
37
|
+
* bytes, and a file copied between machines gets a new mtime too. Both would
|
|
38
|
+
* force a needless re-mine. Content also makes the reverse mistake impossible —
|
|
39
|
+
* a file whose bytes changed always misses the cache, which matters because the
|
|
40
|
+
* live session is appended to between runs.
|
|
41
|
+
*/
|
|
42
|
+
export declare function hashSessionFile(file: string): string | undefined;
|
|
43
|
+
/** Look up a previously mined session. Any unreadable entry reads as a miss. */
|
|
44
|
+
export declare function readCachedMining(agentDir: string, hash: string): CachedMining | undefined;
|
|
45
|
+
/** Store a mined session. Failing to cache is never worth failing the run over. */
|
|
46
|
+
export declare function writeCachedMining(agentDir: string, hash: string, entry: CachedMining): void;
|
|
47
|
+
/**
|
|
48
|
+
* Drop entries nothing has referenced in a long time, so a machine that has
|
|
49
|
+
* been running this for a year does not keep every session it ever saw.
|
|
50
|
+
*/
|
|
51
|
+
export declare function pruneLearnCache(agentDir: string, now?: Date): void;
|
|
52
|
+
/**
|
|
53
|
+
* Counting what a run still owes the model lives in `extract.ts:planMining`,
|
|
54
|
+
* not here: the answer depends on which sessions the window actually selects,
|
|
55
|
+
* and duplicating that selection is how the confirmation prompt ends up
|
|
56
|
+
* quoting a number the run does not honour.
|
|
57
|
+
*/
|
|
58
|
+
//# sourceMappingURL=cache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"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"]}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session memo of the miner's output, keyed on file content.
|
|
3
|
+
*
|
|
4
|
+
* This is what makes an LLM-read-everything pipeline affordable. A closed
|
|
5
|
+
* session transcript never changes again, so the model needs to read it exactly
|
|
6
|
+
* once in its life. Hash the bytes, keep the candidates, and a routine `/learn`
|
|
7
|
+
* pays for the one or two sessions written since the last run while the other
|
|
8
|
+
* eighteen come back for free.
|
|
9
|
+
*
|
|
10
|
+
* It also happens to be the right answer to "incremental vs. full history",
|
|
11
|
+
* which an earlier design tried to solve with an mtime cursor. A cursor breaks
|
|
12
|
+
* the counting: if a run only *reads* sessions newer than the cursor, a
|
|
13
|
+
* directive said once today has a count of one, because the four earlier
|
|
14
|
+
* occurrences were never in the scan. Caching moves the skipping to the
|
|
15
|
+
* expensive step only — the reduce step still runs over every cached session
|
|
16
|
+
* every time, so the cross-session counts stay exact no matter how little was
|
|
17
|
+
* mined this run.
|
|
18
|
+
*
|
|
19
|
+
* The cache is disposable. Deleting it costs one re-mine and nothing else, so
|
|
20
|
+
* every failure path here degrades to "mine it again" rather than to an error.
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { writeFileAtomicSync } from "../../utils/atomic-file.js";
|
|
26
|
+
/**
|
|
27
|
+
* Bump when the miner prompt, the candidate shape, or what the miner is shown
|
|
28
|
+
* changes.
|
|
29
|
+
*
|
|
30
|
+
* The version is part of the cache key, not a field inside the entry, so a bump
|
|
31
|
+
* invalidates every entry at once without a migration or a sweep — old files
|
|
32
|
+
* simply stop being looked up, and the pruner reclaims them on age.
|
|
33
|
+
*
|
|
34
|
+
* v2: transcripts no longer carry successful tool output or replayed
|
|
35
|
+
* slash-command bodies, and candidates are dropped when their quote cannot be
|
|
36
|
+
* found in what the user said. Entries mined before that were read from a
|
|
37
|
+
* different transcript than the one the pipeline now produces, so keeping them
|
|
38
|
+
* would mean counting evidence the current rules would have rejected.
|
|
39
|
+
*
|
|
40
|
+
* v3: candidates no longer carry a label. Naming moved to a global pass that
|
|
41
|
+
* sees the whole window, which is also what makes this file model-independent:
|
|
42
|
+
* a cached label was frozen at mining time, so changing the `fast` tier forked
|
|
43
|
+
* the vocabulary permanently and split every count across the seam.
|
|
44
|
+
*/
|
|
45
|
+
const CACHE_VERSION = 3;
|
|
46
|
+
/** Entries untouched for this long are reclaimed. */
|
|
47
|
+
const CACHE_RETENTION_DAYS = 180;
|
|
48
|
+
export function getLearnCacheDir(agentDir) {
|
|
49
|
+
return join(agentDir, "learn", "cache");
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Content hash of a session file.
|
|
53
|
+
*
|
|
54
|
+
* Content, not mtime: a resumed session gets a fresh mtime with identical
|
|
55
|
+
* bytes, and a file copied between machines gets a new mtime too. Both would
|
|
56
|
+
* force a needless re-mine. Content also makes the reverse mistake impossible —
|
|
57
|
+
* a file whose bytes changed always misses the cache, which matters because the
|
|
58
|
+
* live session is appended to between runs.
|
|
59
|
+
*/
|
|
60
|
+
export function hashSessionFile(file) {
|
|
61
|
+
try {
|
|
62
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex").slice(0, 32);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function entryPath(agentDir, hash) {
|
|
69
|
+
return join(getLearnCacheDir(agentDir), `v${CACHE_VERSION}-${hash}.json`);
|
|
70
|
+
}
|
|
71
|
+
/** Look up a previously mined session. Any unreadable entry reads as a miss. */
|
|
72
|
+
export function readCachedMining(agentDir, hash) {
|
|
73
|
+
const path = entryPath(agentDir, hash);
|
|
74
|
+
try {
|
|
75
|
+
if (!existsSync(path))
|
|
76
|
+
return undefined;
|
|
77
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
78
|
+
if (!Array.isArray(parsed.candidates) || typeof parsed.sessionId !== "string")
|
|
79
|
+
return undefined;
|
|
80
|
+
return {
|
|
81
|
+
sessionId: parsed.sessionId,
|
|
82
|
+
timestamp: typeof parsed.timestamp === "string" ? parsed.timestamp : new Date(0).toISOString(),
|
|
83
|
+
candidates: parsed.candidates,
|
|
84
|
+
minedAt: typeof parsed.minedAt === "string" ? parsed.minedAt : new Date(0).toISOString(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Store a mined session. Failing to cache is never worth failing the run over. */
|
|
92
|
+
export function writeCachedMining(agentDir, hash, entry) {
|
|
93
|
+
try {
|
|
94
|
+
mkdirSync(getLearnCacheDir(agentDir), { recursive: true });
|
|
95
|
+
writeFileAtomicSync(entryPath(agentDir, hash), `${JSON.stringify(entry, null, 2)}\n`);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// The cost is re-mining this session next run.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Drop entries nothing has referenced in a long time, so a machine that has
|
|
103
|
+
* been running this for a year does not keep every session it ever saw.
|
|
104
|
+
*/
|
|
105
|
+
export function pruneLearnCache(agentDir, now = new Date()) {
|
|
106
|
+
const dir = getLearnCacheDir(agentDir);
|
|
107
|
+
if (!existsSync(dir))
|
|
108
|
+
return;
|
|
109
|
+
const cutoff = now.getTime() - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
110
|
+
try {
|
|
111
|
+
for (const name of readdirSync(dir)) {
|
|
112
|
+
const path = join(dir, name);
|
|
113
|
+
try {
|
|
114
|
+
if (statSync(path).mtime.getTime() < cutoff)
|
|
115
|
+
rmSync(path, { force: true });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Concurrent run reclaimed it first.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// An unreadable cache directory is not an error worth surfacing.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Counting what a run still owes the model lives in `extract.ts:planMining`,
|
|
128
|
+
* not here: the answer depends on which sessions the window actually selects,
|
|
129
|
+
* and duplicating that selection is how the confirmation prompt ends up
|
|
130
|
+
* quoting a number the run does not honour.
|
|
131
|
+
*/
|
|
132
|
+
//# sourceMappingURL=cache.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache.js","sourceRoot":"","sources":["../../../src/core/learn/cache.ts"],"names":[],"mappings":"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","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"]}
|