@trazum/cli 1.8.0

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.
@@ -0,0 +1,268 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import type { LlmProvider } from '@trazum/core';
7
+
8
+ /**
9
+ * Not asking the model the same question twice.
10
+ *
11
+ * The roadmap item this answers was "prompt caching for `--suggest`", meaning
12
+ * the API feature: mark a stable prefix with `cache_control` and pay a tenth of
13
+ * the price for it on every later call. **That cannot work here, and the reason
14
+ * is a number rather than an opinion.**
15
+ *
16
+ * Prompt caching has a minimum cacheable prefix — 512 tokens on the newest
17
+ * models, 1,024 on most, 4,096 on some — and a prefix shorter than the minimum
18
+ * is *silently* not cached: no error, no warning, `cache_creation_input_tokens`
19
+ * comes back zero. Trazum's suggest prompt is **291 tokens**. Marking it would
20
+ * have looked like an optimisation, cost a line of code, changed nothing, and
21
+ * been impossible to notice. `suggest-cache.test.js` measures it against the
22
+ * published minima so that stays true, or stops being true loudly.
23
+ *
24
+ * The stable prefix is also the only thing that *could* be cached: the rest of
25
+ * the request is the author's prompt, which is different every time. So there
26
+ * is no arrangement of `cache_control` that helps.
27
+ *
28
+ * What does help is the observation behind the request — running `--suggest`
29
+ * over a directory asks the same questions again on every run, and most of the
30
+ * prompts have not changed since the last one. Answering those from disk is not
31
+ * a 90% saving on the call, it is the whole call. On a re-run after editing two
32
+ * files out of forty, thirty-eight requests do not happen.
33
+ *
34
+ * Three decisions worth arguing with:
35
+ *
36
+ * **The raw response is cached, not the parsed suggestions.** Everything
37
+ * `suggestRewrites` does after the model answers — checking each `before`
38
+ * appears byte for byte, refusing anything that touches protected content,
39
+ * dropping overlaps — is deterministic and lives in the core. Caching the text
40
+ * means a hit is re-validated by *today's* rules rather than replaying a
41
+ * verdict reached by an older version. Same reasoning as recomputing token
42
+ * counts on read instead of storing them.
43
+ *
44
+ * **It is opt-in.** A cache hit returns what the model said last time, and a
45
+ * model is not a pure function — silently answering from a week-old response
46
+ * would be a surprise, in a tool whose other model-touching features
47
+ * (`--suggest`, `--apply-suggestions`, `--reorder`) all require asking twice.
48
+ *
49
+ * **The files are 0600 in a 0700 directory.** The cache holds prompt text, and
50
+ * a prompt is the most sensitive thing this tool ever touches — it is somebody's
51
+ * unreleased product behaviour. A world-readable cache in a shared home
52
+ * directory would publish it to every account on the machine.
53
+ */
54
+
55
+ /**
56
+ * Bumped when anything that shapes the answer changes and is not already in the
57
+ * key — the suggest system prompt, the response format, the checking rules.
58
+ * A stale entry answers a question that is no longer the one being asked.
59
+ *
60
+ * Exported so the test can derive a key independently rather than comparing
61
+ * `cacheKey` to itself.
62
+ */
63
+ export const SCHEMA = 2;
64
+
65
+ /** Seven days. Long enough for a working week, short enough that an alias that started pointing at a new model does not answer forever. */
66
+ export const DEFAULT_TTL_DAYS = 7;
67
+
68
+ export interface CacheEntry {
69
+ schema: number;
70
+ /** When it was written, so the TTL can be applied by the reader. */
71
+ at: number;
72
+ provider: string;
73
+ model: string;
74
+ /** The model's answer, before any checking. */
75
+ response: string;
76
+ }
77
+
78
+ /**
79
+ * Where the cache lives.
80
+ *
81
+ * `XDG_CACHE_HOME` first, because a user who set it meant it. Not the project
82
+ * directory: two checkouts of the same repository ask the same questions, and a
83
+ * per-checkout cache answers neither of them from the other.
84
+ */
85
+ export function cacheDir(env: NodeJS.ProcessEnv = process.env): string {
86
+ const base = env.XDG_CACHE_HOME?.trim() || join(homedir(), '.cache');
87
+ return join(base, 'trazum', 'suggestions');
88
+ }
89
+
90
+ /**
91
+ * The key: everything that changes the answer, and nothing that does not.
92
+ *
93
+ * `provider` and `model` are in here rather than only in the entry because two
94
+ * models answer differently — a hit from the wrong one is not a hit. The system
95
+ * prompt is in here rather than relying on `SCHEMA` alone, so a caller passing
96
+ * their own system prompt gets their own entries without anybody remembering to
97
+ * bump a constant.
98
+ */
99
+ export function cacheKey(input: {
100
+ provider: string;
101
+ model: string;
102
+ system: string;
103
+ user: string;
104
+ }): string {
105
+ // Length-prefixed rather than delimiter-joined: a delimiter that can occur
106
+ // inside a prompt lets two different inputs produce one key.
107
+ const parts = [String(SCHEMA), input.provider, input.model, input.system, input.user];
108
+ const canonical = parts.map((part) => `${part.length}:${part}`).join('');
109
+ return createHash('sha256').update(canonical, 'utf8').digest('hex');
110
+ }
111
+
112
+ function entryPath(dir: string, key: string): string {
113
+ return join(dir, `${key}.json`);
114
+ }
115
+
116
+ export function readEntry(
117
+ dir: string,
118
+ key: string,
119
+ now: number,
120
+ ttlDays: number,
121
+ ): CacheEntry | null {
122
+ let raw: string;
123
+ try {
124
+ raw = readFileSync(entryPath(dir, key), 'utf8');
125
+ } catch {
126
+ return null;
127
+ }
128
+
129
+ let entry: CacheEntry;
130
+ try {
131
+ entry = JSON.parse(raw) as CacheEntry;
132
+ } catch {
133
+ // A truncated write from an interrupted run. Treated as a miss rather than
134
+ // an error: the answer is one API call away, and refusing to run because a
135
+ // cache file is corrupt would be worse than the problem.
136
+ return null;
137
+ }
138
+
139
+ if (entry.schema !== SCHEMA) return null;
140
+ if (typeof entry.response !== 'string') return null;
141
+ if (typeof entry.at !== 'number') return null;
142
+ if (now - entry.at > ttlDays * 86_400_000) return null;
143
+
144
+ return entry;
145
+ }
146
+
147
+ export function writeEntry(dir: string, key: string, entry: CacheEntry): void {
148
+ try {
149
+ // 0700: the cache holds prompt text. A default-permission directory in a
150
+ // shared home publishes it to every other account on the machine.
151
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
152
+ writeFileSync(entryPath(dir, key), `${JSON.stringify(entry, null, 2)}\n`, { mode: 0o600 });
153
+ } catch {
154
+ // A cache that cannot be written is a cache that is not used. Read-only
155
+ // home directory, full disk, hostile umask — none of them are reasons to
156
+ // fail a command that was going to work.
157
+ }
158
+ }
159
+
160
+ /** Delete every entry. Returns how many went. */
161
+ export function clearCache(dir: string): number {
162
+ let removed = 0;
163
+ let names: string[];
164
+ try {
165
+ names = readdirSync(dir);
166
+ } catch {
167
+ return 0;
168
+ }
169
+
170
+ for (const name of names) {
171
+ // Only files this module wrote. A cache directory that deletes whatever it
172
+ // finds is a cache directory somebody eventually points at their home.
173
+ if (!/^[0-9a-f]{64}\.json$/.test(name)) continue;
174
+ try {
175
+ unlinkSync(join(dir, name));
176
+ removed += 1;
177
+ } catch {
178
+ // Already gone, or not ours to delete.
179
+ }
180
+ }
181
+ return removed;
182
+ }
183
+
184
+ /** Entry count and total bytes, so `--clear-suggestion-cache` can say what it emptied. */
185
+ export function cacheStats(dir: string): { entries: number; bytes: number } {
186
+ let names: string[];
187
+ try {
188
+ names = readdirSync(dir);
189
+ } catch {
190
+ return { entries: 0, bytes: 0 };
191
+ }
192
+
193
+ let entries = 0;
194
+ let bytes = 0;
195
+ for (const name of names) {
196
+ if (!/^[0-9a-f]{64}\.json$/.test(name)) continue;
197
+ try {
198
+ bytes += statSync(join(dir, name)).size;
199
+ entries += 1;
200
+ } catch {
201
+ // Raced with a clear. Not counted.
202
+ }
203
+ }
204
+ return { entries, bytes };
205
+ }
206
+
207
+ export interface CachedProvider extends LlmProvider {
208
+ /** How many calls this provider answered from disk. */
209
+ readonly hits: number;
210
+ /** How many it had to make. */
211
+ readonly misses: number;
212
+ }
213
+
214
+ /**
215
+ * Wraps a provider so identical questions are asked once.
216
+ *
217
+ * A wrapper rather than a change inside `suggestRewrites`, for two reasons: the
218
+ * core stays free of `node:fs` (it is browser-safe, and a test asserts the
219
+ * import graph), and every command that reaches for an LLM gets the cache by
220
+ * passing through one function rather than by each remembering to.
221
+ */
222
+ export function cachingProvider(
223
+ inner: LlmProvider,
224
+ options: {
225
+ dir: string;
226
+ ttlDays?: number;
227
+ now?: () => number;
228
+ },
229
+ ): CachedProvider {
230
+ const { dir, ttlDays = DEFAULT_TTL_DAYS, now = Date.now } = options;
231
+ let hits = 0;
232
+ let misses = 0;
233
+
234
+ return {
235
+ name: inner.name,
236
+ model: inner.model,
237
+ get hits() {
238
+ return hits;
239
+ },
240
+ get misses() {
241
+ return misses;
242
+ },
243
+ async complete({ system, user }) {
244
+ const key = cacheKey({ provider: inner.name, model: inner.model, system, user });
245
+
246
+ const cached = readEntry(dir, key, now(), ttlDays);
247
+ if (cached) {
248
+ hits += 1;
249
+ return cached.response;
250
+ }
251
+
252
+ const response = await inner.complete({ system, user });
253
+ misses += 1;
254
+
255
+ // Written after the call succeeds, so a failed request is not remembered
256
+ // as an answer. A thrown error propagates untouched.
257
+ writeEntry(dir, key, {
258
+ schema: SCHEMA,
259
+ at: now(),
260
+ provider: inner.name,
261
+ model: inner.model,
262
+ response,
263
+ });
264
+
265
+ return response;
266
+ },
267
+ };
268
+ }