@aeondave/pi-skill-optimizer 3.0.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/BENCHMARK.md +354 -0
- package/CHANGELOG.md +117 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/assets/banner.png +0 -0
- package/docs/ARCHITECTURE.md +66 -0
- package/docs/CONFIGURATION.md +138 -0
- package/docs/RANKING.md +155 -0
- package/package.json +85 -0
- package/src/activation.ts +88 -0
- package/src/aliases.ts +111 -0
- package/src/config.ts +446 -0
- package/src/corpus.ts +416 -0
- package/src/evaluation.ts +692 -0
- package/src/generate.ts +365 -0
- package/src/history.ts +147 -0
- package/src/index.ts +1362 -0
- package/src/init-profile.ts +96 -0
- package/src/init-prompt.ts +10 -0
- package/src/init-readiness.ts +123 -0
- package/src/optimize.ts +424 -0
- package/src/output-recovery.ts +19 -0
- package/src/output.ts +629 -0
- package/src/persistence.ts +535 -0
- package/src/profile.ts +253 -0
- package/src/prompts/init-profile.md +40 -0
- package/src/request.ts +304 -0
- package/src/skill-loader.ts +138 -0
- package/src/skills.ts +1230 -0
- package/src/stats.ts +178 -0
- package/src/tools.ts +548 -0
- package/src/usage.ts +256 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous persistence primitives for the extension runtime.
|
|
3
|
+
*
|
|
4
|
+
* Writes are atomic per file and serialized with an ownership-token lock.
|
|
5
|
+
* Profile writes additionally use optimistic revisions so an init computed from
|
|
6
|
+
* a stale snapshot cannot replace a newer profile. Usage and telemetry writes
|
|
7
|
+
* merge process-local deltas into the latest snapshot while holding the lock.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
11
|
+
import {
|
|
12
|
+
closeSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
fsyncSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
openSync,
|
|
17
|
+
readdirSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
renameSync,
|
|
20
|
+
lstatSync,
|
|
21
|
+
unlinkSync,
|
|
22
|
+
utimesSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
} from "node:fs";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { EMPTY_PROFILE, mergeProfiles, normalizeProfile, type SkillOptimizerProfile } from "./profile.ts";
|
|
28
|
+
import { addExtractionTelemetry, addProviderCacheTelemetry, addSavings, EMPTY_EXTRACTION_TELEMETRY, EMPTY_PROVIDER_CACHE_TELEMETRY, EMPTY_SAVINGS, normalizeExtractionTelemetry, normalizeProviderCacheTelemetry, normalizeStatsFile, toStatsFile, type ExtractionTelemetry, type ProviderCacheTelemetry, type SavingsByArea } from "./stats.ts";
|
|
29
|
+
import { mergeUsageStats, normalizeUsageFile, pruneUsageStats, toUsageFile, type SkillUsageStats, type UsagePruneOptions } from "./usage.ts";
|
|
30
|
+
|
|
31
|
+
const LOCK_WAIT_MS = 250;
|
|
32
|
+
const LOCK_STALE_MS = 30_000;
|
|
33
|
+
const LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(4));
|
|
34
|
+
|
|
35
|
+
const OUTPUT_ARCHIVE_ID_PREFIX = "sko:";
|
|
36
|
+
const OUTPUT_ARCHIVE_DIGEST_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
37
|
+
const OUTPUT_ARCHIVE_FILE_RE = /^([A-Za-z0-9_-]{43})\.txt$/;
|
|
38
|
+
const DEFAULT_OUTPUT_ARCHIVE_DIR = join(tmpdir(), "pi-skill-optimizer", "outputs");
|
|
39
|
+
const LEGACY_OUTPUT_ARCHIVE_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
40
|
+
const OUTPUT_ARCHIVE_METADATA_VERSION = 1;
|
|
41
|
+
export const DEFAULT_OUTPUT_ARCHIVE_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
42
|
+
|
|
43
|
+
export interface TemporaryOutputArchiveOptions {
|
|
44
|
+
/** Internal storage root. IDs never contain this path. */
|
|
45
|
+
directory?: string;
|
|
46
|
+
/** Clock override for deterministic cleanup and tests. */
|
|
47
|
+
now?: number;
|
|
48
|
+
/** Requested lifetime when saving; each archive stores its own expiry. Defaults to seven days. */
|
|
49
|
+
ttlMs?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface StoredProfile {
|
|
53
|
+
exists: boolean;
|
|
54
|
+
profile: SkillOptimizerProfile;
|
|
55
|
+
hashes: Record<string, string>;
|
|
56
|
+
initVersion: number;
|
|
57
|
+
revision: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ProfileWrite {
|
|
61
|
+
path: string;
|
|
62
|
+
profile: SkillOptimizerProfile;
|
|
63
|
+
skillCount: number;
|
|
64
|
+
hashes: Record<string, string>;
|
|
65
|
+
expectedRevision: string | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class ConcurrentFileUpdateError extends Error {
|
|
69
|
+
constructor(path: string) {
|
|
70
|
+
super(`file changed while preparing update: ${path}`);
|
|
71
|
+
this.name = "ConcurrentFileUpdateError";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function fileExists(path: string): boolean {
|
|
76
|
+
return existsSync(path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function revision(text: string): string {
|
|
80
|
+
return createHash("sha256").update(text).digest("hex");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readText(path: string): { text: string; revision: string } | undefined {
|
|
84
|
+
try {
|
|
85
|
+
const text = readFileSync(path, "utf8");
|
|
86
|
+
return { text, revision: revision(text) };
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readJson(path: string): unknown | undefined {
|
|
94
|
+
const stored = readText(path);
|
|
95
|
+
return stored ? JSON.parse(stored.text) : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Replace a JSON file atomically. Callers coordinate conflicting writers. */
|
|
99
|
+
export function writeJsonAtomic(path: string, value: unknown): void {
|
|
100
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
101
|
+
const temp = join(dirname(path), `.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`);
|
|
102
|
+
let fd: number | undefined;
|
|
103
|
+
try {
|
|
104
|
+
fd = openSync(temp, "wx", 0o600);
|
|
105
|
+
writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
106
|
+
fsyncSync(fd);
|
|
107
|
+
closeSync(fd);
|
|
108
|
+
fd = undefined;
|
|
109
|
+
renameSync(temp, path);
|
|
110
|
+
} finally {
|
|
111
|
+
if (fd !== undefined) {
|
|
112
|
+
try { closeSync(fd); } catch { /* best effort */ }
|
|
113
|
+
}
|
|
114
|
+
try { unlinkSync(temp); } catch (err) {
|
|
115
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") { /* best effort */ }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function recoverAbandonedLock(lockPath: string): void {
|
|
121
|
+
const info = lstatSync(lockPath);
|
|
122
|
+
if (!info.isFile() || info.isSymbolicLink() || Date.now() - info.mtimeMs <= LOCK_STALE_MS) return;
|
|
123
|
+
const token = readFileSync(lockPath, "utf8");
|
|
124
|
+
const pidText = /^(\d+):\d+:[a-z0-9]+$/.exec(token)?.[1];
|
|
125
|
+
const ownerPid = Number(pidText);
|
|
126
|
+
if (!Number.isSafeInteger(ownerPid) || ownerPid <= 0) return;
|
|
127
|
+
try {
|
|
128
|
+
// Signal 0 probes liveness; it does not terminate the owner. EPERM is not death.
|
|
129
|
+
process.kill(ownerPid, 0);
|
|
130
|
+
return;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") return;
|
|
133
|
+
}
|
|
134
|
+
// Another process may already have recovered and replaced the lock.
|
|
135
|
+
if (readFileSync(lockPath, "utf8") === token) unlinkSync(lockPath);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function acquireLock(path: string): { lockPath: string; token: string } {
|
|
139
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
140
|
+
const lockPath = `${path}.lock`;
|
|
141
|
+
const token = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
142
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
143
|
+
while (true) {
|
|
144
|
+
let fd: number | undefined;
|
|
145
|
+
try {
|
|
146
|
+
fd = openSync(lockPath, "wx", 0o600);
|
|
147
|
+
writeFileSync(fd, token, "utf8");
|
|
148
|
+
fsyncSync(fd);
|
|
149
|
+
closeSync(fd);
|
|
150
|
+
return { lockPath, token };
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (fd !== undefined) {
|
|
153
|
+
try { closeSync(fd); } catch { /* best effort */ }
|
|
154
|
+
try { unlinkSync(lockPath); } catch { /* best effort */ }
|
|
155
|
+
}
|
|
156
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
157
|
+
try {
|
|
158
|
+
recoverAbandonedLock(lockPath);
|
|
159
|
+
} catch {
|
|
160
|
+
// Another process may have released or replaced it.
|
|
161
|
+
}
|
|
162
|
+
if (Date.now() >= deadline) throw new Error(`timed out waiting for file lock: ${lockPath}`);
|
|
163
|
+
Atomics.wait(LOCK_SLEEP, 0, 0, 10);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function releaseLock(lock: { lockPath: string; token: string }): void {
|
|
169
|
+
try {
|
|
170
|
+
if (readFileSync(lock.lockPath, "utf8") === lock.token) unlinkSync(lock.lockPath);
|
|
171
|
+
} catch {
|
|
172
|
+
// A stale-lock recovery may already have replaced or removed it.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Acquire multiple locks in stable order to avoid cross-process deadlocks. */
|
|
177
|
+
export function withFileLocks<T>(paths: readonly string[], action: () => T): T {
|
|
178
|
+
const locks: Array<{ lockPath: string; token: string }> = [];
|
|
179
|
+
try {
|
|
180
|
+
for (const path of [...new Set(paths)].sort()) locks.push(acquireLock(path));
|
|
181
|
+
return action();
|
|
182
|
+
} finally {
|
|
183
|
+
for (let i = locks.length - 1; i >= 0; i--) releaseLock(locks[i]);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sanitizeHashes(value: unknown): Record<string, string> {
|
|
188
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
189
|
+
const out: Record<string, string> = {};
|
|
190
|
+
for (const [name, hash] of Object.entries(value as Record<string, unknown>)) {
|
|
191
|
+
if (typeof hash === "string" && hash) out[name] = hash;
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function readStoredProfile(path: string): StoredProfile {
|
|
197
|
+
const stored = readText(path);
|
|
198
|
+
if (!stored) return { exists: false, profile: EMPTY_PROFILE, hashes: {}, initVersion: 0, revision: null };
|
|
199
|
+
const parsed = JSON.parse(stored.text) as unknown;
|
|
200
|
+
const raw = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
201
|
+
? parsed as { skillHashes?: unknown; initVersion?: unknown }
|
|
202
|
+
: {};
|
|
203
|
+
return {
|
|
204
|
+
exists: true,
|
|
205
|
+
profile: normalizeProfile(parsed),
|
|
206
|
+
hashes: sanitizeHashes(raw.skillHashes),
|
|
207
|
+
initVersion: typeof raw.initVersion === "number" ? raw.initVersion : 0,
|
|
208
|
+
revision: stored.revision,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function loadMergedProfile(paths: { global: string; project: string }): SkillOptimizerProfile {
|
|
213
|
+
const globalProfile = readStoredProfile(paths.global).profile;
|
|
214
|
+
if (paths.project === paths.global) return globalProfile;
|
|
215
|
+
return mergeProfiles(globalProfile, readStoredProfile(paths.project).profile);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Commit one or more profile files after verifying every source revision. */
|
|
219
|
+
export function writeProfileFiles(writes: readonly ProfileWrite[], initVersion: number, now = Date.now()): void {
|
|
220
|
+
if (writes.length === 0) return;
|
|
221
|
+
const paths = writes.map((write) => write.path);
|
|
222
|
+
if (new Set(paths).size !== paths.length) throw new Error("duplicate path in profile write set");
|
|
223
|
+
withFileLocks(paths, () => {
|
|
224
|
+
for (const write of writes) {
|
|
225
|
+
const currentRevision = readText(write.path)?.revision ?? null;
|
|
226
|
+
if (currentRevision !== write.expectedRevision) throw new ConcurrentFileUpdateError(write.path);
|
|
227
|
+
}
|
|
228
|
+
const generatedAt = new Date(now).toISOString();
|
|
229
|
+
for (const write of writes) {
|
|
230
|
+
writeJsonAtomic(write.path, {
|
|
231
|
+
version: 2,
|
|
232
|
+
initVersion,
|
|
233
|
+
generatedAt,
|
|
234
|
+
skillCount: write.skillCount,
|
|
235
|
+
...write.profile,
|
|
236
|
+
skillHashes: write.hashes,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function loadUsageFile(path: string): SkillUsageStats {
|
|
243
|
+
const parsed = readJson(path);
|
|
244
|
+
return parsed === undefined ? {} : normalizeUsageFile(parsed);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function saveUsageDelta(path: string, delta: SkillUsageStats, pruneOptions?: UsagePruneOptions): SkillUsageStats {
|
|
248
|
+
return withFileLocks([path], () => {
|
|
249
|
+
const merged = mergeUsageStats(loadUsageFile(path), delta);
|
|
250
|
+
const next = pruneOptions ? pruneUsageStats(merged, pruneOptions) : merged;
|
|
251
|
+
writeJsonAtomic(path, toUsageFile(next));
|
|
252
|
+
return next;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function pruneUsageFile(path: string, options: UsagePruneOptions): SkillUsageStats {
|
|
257
|
+
return withFileLocks([path], () => {
|
|
258
|
+
const current = loadUsageFile(path);
|
|
259
|
+
const pruned = pruneUsageStats(current, options);
|
|
260
|
+
if (pruned !== current) writeJsonAtomic(path, toUsageFile(pruned));
|
|
261
|
+
return pruned;
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function loadStatsFile(path: string): SavingsByArea {
|
|
266
|
+
const parsed = readJson(path);
|
|
267
|
+
return parsed === undefined ? { ...EMPTY_SAVINGS } : normalizeStatsFile(parsed);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function loadExtractionTelemetryFile(path: string): ExtractionTelemetry {
|
|
271
|
+
const parsed = readJson(path);
|
|
272
|
+
return parsed === undefined ? { ...EMPTY_EXTRACTION_TELEMETRY } : normalizeExtractionTelemetry(parsed);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function loadProviderCacheTelemetryFile(path: string): ProviderCacheTelemetry {
|
|
276
|
+
const parsed = readJson(path);
|
|
277
|
+
return parsed === undefined ? { ...EMPTY_PROVIDER_CACHE_TELEMETRY } : normalizeProviderCacheTelemetry(parsed);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface SavedStats {
|
|
281
|
+
savings: SavingsByArea;
|
|
282
|
+
extraction: ExtractionTelemetry;
|
|
283
|
+
cache: ProviderCacheTelemetry;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Load all v1-v3 statistics with a single read, suitable for runtime snapshots. */
|
|
287
|
+
export function loadStatsSnapshot(path: string): SavedStats {
|
|
288
|
+
const parsed = readJson(path);
|
|
289
|
+
if (parsed === undefined) {
|
|
290
|
+
return {
|
|
291
|
+
savings: { ...EMPTY_SAVINGS },
|
|
292
|
+
extraction: { ...EMPTY_EXTRACTION_TELEMETRY },
|
|
293
|
+
cache: { ...EMPTY_PROVIDER_CACHE_TELEMETRY },
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
savings: normalizeStatsFile(parsed),
|
|
298
|
+
extraction: normalizeExtractionTelemetry(parsed),
|
|
299
|
+
cache: normalizeProviderCacheTelemetry(parsed),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function saveStatsDeltas(
|
|
304
|
+
path: string,
|
|
305
|
+
savingsDelta: SavingsByArea,
|
|
306
|
+
extractionDelta: ExtractionTelemetry,
|
|
307
|
+
cacheDelta: ProviderCacheTelemetry = EMPTY_PROVIDER_CACHE_TELEMETRY,
|
|
308
|
+
): SavedStats {
|
|
309
|
+
return withFileLocks([path], () => {
|
|
310
|
+
const parsed = readJson(path);
|
|
311
|
+
const savings = addSavings(parsed === undefined ? { ...EMPTY_SAVINGS } : normalizeStatsFile(parsed), savingsDelta);
|
|
312
|
+
const extraction = addExtractionTelemetry(
|
|
313
|
+
parsed === undefined ? { ...EMPTY_EXTRACTION_TELEMETRY } : normalizeExtractionTelemetry(parsed),
|
|
314
|
+
extractionDelta,
|
|
315
|
+
);
|
|
316
|
+
const cache = addProviderCacheTelemetry(
|
|
317
|
+
parsed === undefined ? { ...EMPTY_PROVIDER_CACHE_TELEMETRY } : normalizeProviderCacheTelemetry(parsed),
|
|
318
|
+
cacheDelta,
|
|
319
|
+
);
|
|
320
|
+
writeJsonAtomic(path, toStatsFile(savings, Date.now(), extraction, cache));
|
|
321
|
+
return { savings, extraction, cache };
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function saveStatsDelta(path: string, delta: SavingsByArea): SavingsByArea {
|
|
326
|
+
return saveStatsDeltas(path, delta, EMPTY_EXTRACTION_TELEMETRY).savings;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Atomically merge one authoritative `message_end` usage delta into stats v3. */
|
|
330
|
+
export function saveProviderCacheTelemetryDelta(path: string, delta: ProviderCacheTelemetry): ProviderCacheTelemetry {
|
|
331
|
+
return saveStatsDeltas(path, EMPTY_SAVINGS, EMPTY_EXTRACTION_TELEMETRY, delta).cache;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function archiveDirectory(options: TemporaryOutputArchiveOptions): string {
|
|
335
|
+
return options.directory ?? DEFAULT_OUTPUT_ARCHIVE_DIR;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function archiveNow(options: TemporaryOutputArchiveOptions): number {
|
|
339
|
+
return typeof options.now === "number" && Number.isFinite(options.now) ? options.now : Date.now();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function archiveTtl(options: TemporaryOutputArchiveOptions): number {
|
|
343
|
+
return typeof options.ttlMs === "number" && Number.isFinite(options.ttlMs) && options.ttlMs >= 0
|
|
344
|
+
? options.ttlMs
|
|
345
|
+
: DEFAULT_OUTPUT_ARCHIVE_TTL_MS;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function outputDigest(value: string | Buffer): string {
|
|
349
|
+
return createHash("sha256").update(value).digest("base64url");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function hasWellFormedUtf16(text: string): boolean {
|
|
353
|
+
for (let index = 0; index < text.length; index++) {
|
|
354
|
+
const unit = text.charCodeAt(index);
|
|
355
|
+
if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
356
|
+
const next = text.charCodeAt(index + 1);
|
|
357
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
|
|
358
|
+
index++;
|
|
359
|
+
} else if (unit >= 0xdc00 && unit <= 0xdfff) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function digestFromArchiveId(id: string): string | undefined {
|
|
367
|
+
if (!id.startsWith(OUTPUT_ARCHIVE_ID_PREFIX)) return undefined;
|
|
368
|
+
const digest = id.slice(OUTPUT_ARCHIVE_ID_PREFIX.length);
|
|
369
|
+
return OUTPUT_ARCHIVE_DIGEST_RE.test(digest) ? digest : undefined;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function outputArchivePath(directory: string, digest: string): string {
|
|
373
|
+
return join(directory, `${digest}.txt`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function outputArchiveMetadataPath(directory: string, digest: string): string {
|
|
377
|
+
return join(directory, `${digest}.meta.json`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
interface ArchiveExpiry {
|
|
381
|
+
valid: boolean;
|
|
382
|
+
expiresAt: number;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** A missing sidecar is a legacy archive and receives a fixed seven-day lease. */
|
|
386
|
+
function readArchiveExpiry(metadataPath: string, legacyMtimeMs?: number): ArchiveExpiry {
|
|
387
|
+
let info: ReturnType<typeof lstatSync>;
|
|
388
|
+
try {
|
|
389
|
+
info = lstatSync(metadataPath);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
392
|
+
return {
|
|
393
|
+
valid: true,
|
|
394
|
+
expiresAt: legacyMtimeMs === undefined ? Number.NEGATIVE_INFINITY : legacyMtimeMs + LEGACY_OUTPUT_ARCHIVE_TTL_MS,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
return { valid: false, expiresAt: 0 };
|
|
398
|
+
}
|
|
399
|
+
if (!info.isFile() || info.isSymbolicLink()) return { valid: false, expiresAt: 0 };
|
|
400
|
+
let parsed: unknown;
|
|
401
|
+
try { parsed = JSON.parse(readFileSync(metadataPath, "utf8")) as unknown; }
|
|
402
|
+
catch { return { valid: false, expiresAt: 0 }; }
|
|
403
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { valid: false, expiresAt: 0 };
|
|
404
|
+
const metadata = parsed as { version?: unknown; expiresAt?: unknown };
|
|
405
|
+
if (metadata.version !== OUTPUT_ARCHIVE_METADATA_VERSION
|
|
406
|
+
|| typeof metadata.expiresAt !== "number"
|
|
407
|
+
|| !Number.isFinite(metadata.expiresAt)) return { valid: false, expiresAt: 0 };
|
|
408
|
+
return { valid: true, expiresAt: metadata.expiresAt };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function writeOutputArchive(path: string, directory: string, text: string, digest: string, now: number): boolean {
|
|
412
|
+
const temp = join(directory, `.${process.pid}-${randomBytes(12).toString("hex")}.tmp`);
|
|
413
|
+
let fd: number | undefined;
|
|
414
|
+
try {
|
|
415
|
+
fd = openSync(temp, "wx", 0o600);
|
|
416
|
+
writeFileSync(fd, text, "utf8");
|
|
417
|
+
fsyncSync(fd);
|
|
418
|
+
closeSync(fd);
|
|
419
|
+
fd = undefined;
|
|
420
|
+
renameSync(temp, path);
|
|
421
|
+
const timestamp = new Date(now);
|
|
422
|
+
utimesSync(path, timestamp, timestamp);
|
|
423
|
+
const info = lstatSync(path);
|
|
424
|
+
return info.isFile() && !info.isSymbolicLink() && outputDigest(readFileSync(path)) === digest;
|
|
425
|
+
} finally {
|
|
426
|
+
if (fd !== undefined) {
|
|
427
|
+
try { closeSync(fd); } catch { /* best effort */ }
|
|
428
|
+
}
|
|
429
|
+
try { unlinkSync(temp); } catch (err) {
|
|
430
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") { /* best effort */ }
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Archive a full tool output and return a provider-safe content-addressed handle.
|
|
437
|
+
* The handle contains only a SHA-256 digest, never a local path. Saving identical
|
|
438
|
+
* content returns the same handle and refreshes its TTL.
|
|
439
|
+
*/
|
|
440
|
+
export function saveTemporaryOutput(text: string, options: TemporaryOutputArchiveOptions = {}): string | undefined {
|
|
441
|
+
if (!hasWellFormedUtf16(text)) return undefined;
|
|
442
|
+
const directory = archiveDirectory(options);
|
|
443
|
+
const now = archiveNow(options);
|
|
444
|
+
const digest = outputDigest(text);
|
|
445
|
+
const id = `${OUTPUT_ARCHIVE_ID_PREFIX}${digest}`;
|
|
446
|
+
const path = outputArchivePath(directory, digest);
|
|
447
|
+
const metadataPath = outputArchiveMetadataPath(directory, digest);
|
|
448
|
+
try {
|
|
449
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
450
|
+
return withFileLocks([metadataPath], () => {
|
|
451
|
+
let info: ReturnType<typeof lstatSync>;
|
|
452
|
+
let existing = true;
|
|
453
|
+
try {
|
|
454
|
+
info = lstatSync(path);
|
|
455
|
+
if (!info.isFile() || info.isSymbolicLink() || outputDigest(readFileSync(path)) !== digest) return undefined;
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return undefined;
|
|
458
|
+
existing = false;
|
|
459
|
+
if (!writeOutputArchive(path, directory, text, digest, now)) return undefined;
|
|
460
|
+
info = lstatSync(path);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const previous = readArchiveExpiry(metadataPath, existing ? info.mtimeMs : undefined);
|
|
464
|
+
if (!previous.valid) return undefined;
|
|
465
|
+
const expiresAt = Math.max(previous.expiresAt, now + archiveTtl(options));
|
|
466
|
+
writeJsonAtomic(metadataPath, { version: OUTPUT_ARCHIVE_METADATA_VERSION, expiresAt });
|
|
467
|
+
return id;
|
|
468
|
+
});
|
|
469
|
+
} catch {
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Resolve an opaque archive handle using its stored expiry and verified content digest. */
|
|
475
|
+
export function resolveTemporaryOutput(id: string, options: TemporaryOutputArchiveOptions = {}): string | undefined {
|
|
476
|
+
const digest = digestFromArchiveId(id);
|
|
477
|
+
if (!digest) return undefined;
|
|
478
|
+
const directory = archiveDirectory(options);
|
|
479
|
+
const path = outputArchivePath(directory, digest);
|
|
480
|
+
const metadataPath = outputArchiveMetadataPath(directory, digest);
|
|
481
|
+
try {
|
|
482
|
+
return withFileLocks([metadataPath], () => {
|
|
483
|
+
const info = lstatSync(path);
|
|
484
|
+
if (!info.isFile() || info.isSymbolicLink()) return undefined;
|
|
485
|
+
const expiry = readArchiveExpiry(metadataPath, info.mtimeMs);
|
|
486
|
+
if (!expiry.valid) return undefined;
|
|
487
|
+
if (archiveNow(options) > expiry.expiresAt) {
|
|
488
|
+
try { unlinkSync(path); } catch { /* best effort */ }
|
|
489
|
+
try { unlinkSync(metadataPath); } catch { /* best effort */ }
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
const stored = readFileSync(path);
|
|
493
|
+
if (outputDigest(stored) !== digest) return undefined;
|
|
494
|
+
return stored.toString("utf8");
|
|
495
|
+
});
|
|
496
|
+
} catch {
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Remove only recognized content archives whose stored or legacy expiry passed. */
|
|
502
|
+
export function cleanupTemporaryOutputs(options: TemporaryOutputArchiveOptions = {}): number {
|
|
503
|
+
const directory = archiveDirectory(options);
|
|
504
|
+
const now = archiveNow(options);
|
|
505
|
+
let removed = 0;
|
|
506
|
+
let entries;
|
|
507
|
+
try {
|
|
508
|
+
entries = readdirSync(directory, { withFileTypes: true, encoding: "utf8" });
|
|
509
|
+
} catch {
|
|
510
|
+
return 0;
|
|
511
|
+
}
|
|
512
|
+
for (const entry of entries) {
|
|
513
|
+
if (typeof entry === "string" || !entry.isFile()) continue;
|
|
514
|
+
const match = OUTPUT_ARCHIVE_FILE_RE.exec(entry.name);
|
|
515
|
+
if (!match) continue;
|
|
516
|
+
const digest = match[1];
|
|
517
|
+
const path = join(directory, entry.name);
|
|
518
|
+
const metadataPath = outputArchiveMetadataPath(directory, digest);
|
|
519
|
+
try {
|
|
520
|
+
const deleted = withFileLocks([metadataPath], () => {
|
|
521
|
+
const info = lstatSync(path);
|
|
522
|
+
if (!info.isFile() || info.isSymbolicLink()) return false;
|
|
523
|
+
const expiry = readArchiveExpiry(metadataPath, info.mtimeMs);
|
|
524
|
+
if (!expiry.valid || now <= expiry.expiresAt) return false;
|
|
525
|
+
unlinkSync(path);
|
|
526
|
+
try { unlinkSync(metadataPath); } catch { /* best effort */ }
|
|
527
|
+
return true;
|
|
528
|
+
});
|
|
529
|
+
if (deleted) removed += 1;
|
|
530
|
+
} catch {
|
|
531
|
+
// Concurrent resolution or cleanup is harmless.
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return removed;
|
|
535
|
+
}
|