@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.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/dist/git.d.ts +81 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +225 -0
- package/dist/git.js.map +1 -0
- package/dist/i18n/en.d.ts +10 -0
- package/dist/i18n/en.d.ts.map +1 -0
- package/dist/i18n/en.js +694 -0
- package/dist/i18n/en.js.map +1 -0
- package/dist/i18n/es.d.ts +4 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +694 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/index.d.ts +34 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +46 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/i18n/types.d.ts +363 -0
- package/dist/i18n/types.d.ts.map +1 -0
- package/dist/i18n/types.js +2 -0
- package/dist/i18n/types.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2824 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown.d.ts +246 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +492 -0
- package/dist/markdown.js.map +1 -0
- package/dist/suggest-cache.d.ts +119 -0
- package/dist/suggest-cache.d.ts.map +1 -0
- package/dist/suggest-cache.js +225 -0
- package/dist/suggest-cache.js.map +1 -0
- package/package.json +49 -0
- package/src/git.ts +294 -0
- package/src/i18n/en.ts +825 -0
- package/src/i18n/es.ts +838 -0
- package/src/i18n/index.ts +57 -0
- package/src/i18n/types.ts +372 -0
- package/src/index.ts +3873 -0
- package/src/markdown.ts +717 -0
- package/src/suggest-cache.ts +268 -0
|
@@ -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
|
+
}
|