nterminal 1.3.2 → 1.3.3

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.
Files changed (61) hide show
  1. package/README.md +4 -2
  2. package/bin/nterminal.js +5 -0
  3. package/dist/client/assets/MarkdownPreview-CWK2qIZp.js +1 -0
  4. package/dist/client/assets/{TranscriptMarkdownBody-Cu7KK3bx.js → TranscriptMarkdownBody-Ddi-rw6X.js} +1 -1
  5. package/dist/client/assets/index-CcAQThtj.js +56 -0
  6. package/dist/client/assets/{index-CosXAR2f.js → index-DDCpqDNb.js} +1 -1
  7. package/dist/client/assets/{index-lQ2cyEWO.css → index-DTTX24tu.css} +1 -1
  8. package/dist/client/index.html +2 -2
  9. package/dist/scripts/onboarding.js +9 -0
  10. package/dist/scripts/onboarding.js.map +1 -1
  11. package/dist/server/agent/agentProxy.d.ts +1 -0
  12. package/dist/server/agent/agentProxy.js +3 -1
  13. package/dist/server/agent/agentProxy.js.map +1 -1
  14. package/dist/server/agent/agentRoutes.js +16 -2
  15. package/dist/server/agent/agentRoutes.js.map +1 -1
  16. package/dist/server/index.js +2 -0
  17. package/dist/server/index.js.map +1 -1
  18. package/dist/server/routes/agentManagementRoutes.js +13 -2
  19. package/dist/server/routes/agentManagementRoutes.js.map +1 -1
  20. package/dist/server/routes/terminalRoutes.js +19 -2
  21. package/dist/server/routes/terminalRoutes.js.map +1 -1
  22. package/dist/server/routes/transcriptQuery.d.ts +14 -0
  23. package/dist/server/routes/transcriptQuery.js +57 -0
  24. package/dist/server/routes/transcriptQuery.js.map +1 -0
  25. package/dist/server/terminal/PtyAdapter.d.ts +12 -0
  26. package/dist/server/terminal/PtyAdapter.js.map +1 -1
  27. package/dist/server/terminal/TerminalManager.d.ts +6 -7
  28. package/dist/server/terminal/TerminalManager.js +57 -11
  29. package/dist/server/terminal/TerminalManager.js.map +1 -1
  30. package/dist/server/terminal/TmuxPtyAdapter.d.ts +15 -1
  31. package/dist/server/terminal/TmuxPtyAdapter.js +284 -13
  32. package/dist/server/terminal/TmuxPtyAdapter.js.map +1 -1
  33. package/dist/server/terminal/codexTranscriptSource.d.ts +18 -1
  34. package/dist/server/terminal/codexTranscriptSource.js +263 -68
  35. package/dist/server/terminal/codexTranscriptSource.js.map +1 -1
  36. package/dist/server/terminal/sqliteReader.d.ts +21 -0
  37. package/dist/server/terminal/sqliteReader.js +158 -0
  38. package/dist/server/terminal/sqliteReader.js.map +1 -0
  39. package/dist/server/terminal/subagentSources.js +3 -2
  40. package/dist/server/terminal/subagentSources.js.map +1 -1
  41. package/dist/server/terminal/transcriptHistory.d.ts +34 -0
  42. package/dist/server/terminal/transcriptHistory.js +81 -36
  43. package/dist/server/terminal/transcriptHistory.js.map +1 -1
  44. package/dist/server/terminal/transcriptStore.d.ts +64 -0
  45. package/dist/server/terminal/transcriptStore.js +779 -0
  46. package/dist/server/terminal/transcriptStore.js.map +1 -0
  47. package/dist/shared/nodeVersion.d.ts +3 -0
  48. package/dist/shared/nodeVersion.js +12 -0
  49. package/dist/shared/nodeVersion.js.map +1 -0
  50. package/dist/shared/protocol.d.ts +33 -0
  51. package/dist/shared/protocol.js.map +1 -1
  52. package/dist/shared/transcriptSearch.d.ts +2 -0
  53. package/dist/shared/transcriptSearch.js +12 -0
  54. package/dist/shared/transcriptSearch.js.map +1 -0
  55. package/dist/src/shared/nodeVersion.js +12 -0
  56. package/dist/src/shared/nodeVersion.js.map +1 -0
  57. package/docs/terminal-history.md +22 -4
  58. package/package.json +2 -2
  59. package/scripts/nterminalctl +8 -4
  60. package/dist/client/assets/MarkdownPreview-CzA5ZyZ3.js +0 -1
  61. package/dist/client/assets/index-wiyTLXQg.js +0 -56
@@ -0,0 +1,779 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { chmod, mkdir, open, readdir, rename, rm, rmdir, stat, statfs, unlink, utimes } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { performance } from 'node:perf_hooks';
5
+ import { normalizeTranscriptSearchQuery, transcriptSearchQueryError } from '../../shared/transcriptSearch.js';
6
+ import { cursorMatchesSource, decodeCursor, encodeCursor, fileFingerprint, readRenderedTranscriptWindow, readTranscriptHistoryPage, TRANSCRIPT_PARSER_VERSION } from './transcriptHistory.js';
7
+ const SEGMENT_BYTES = 128 * 1024;
8
+ const SEARCH_SEGMENT_BYTES = 1024 * 1024;
9
+ const SEARCH_BYTES = 8 * 1024 * 1024;
10
+ const SEARCH_MS = 100;
11
+ const MAX_DISK_BYTES = 5 * 1024 * 1024 * 1024;
12
+ const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
13
+ const MIN_FREE_BYTES = 512 * 1024 * 1024;
14
+ const MAINTENANCE_MS = 5 * 60 * 1000;
15
+ const BACKGROUND_POLL_MS = 30_000;
16
+ const UNKNOWN_CACHE_BYTES = 64 * 1024 * 1024;
17
+ const MAX_JSON_BYTES = 16 * 1024 * 1024;
18
+ const DUPLICATE_CONTEXT = 64 * 1024;
19
+ const MAX_QUEUE = 8;
20
+ /** Disposable private derived data; source JSONL remains authoritative.
21
+ * One instance per state directory. No open descriptors or rendered records are
22
+ * retained between calls. The queue bounds both work and waiting request memory.
23
+ */
24
+ export class TranscriptStore {
25
+ options;
26
+ root;
27
+ busy = false;
28
+ queue = [];
29
+ jobs = new Map();
30
+ diskUsage = new Map();
31
+ maintenanceAt = 0;
32
+ maintenanceRetryAt = 0;
33
+ accountingReady = false;
34
+ maintenance;
35
+ revisions = new Map();
36
+ spaceCheckedAt = 0;
37
+ freeBytes = 0;
38
+ timer;
39
+ wakeAt = 0;
40
+ disposed = false;
41
+ backgroundAbort = new AbortController();
42
+ constructor(rootDir, options = {}) {
43
+ this.options = options;
44
+ this.root = join(rootDir, 'transcript-store');
45
+ this.wakeBackground(1000);
46
+ }
47
+ dispose() {
48
+ this.disposed = true;
49
+ clearTimeout(this.timer);
50
+ this.backgroundAbort.abort();
51
+ this.jobs.clear();
52
+ void this.maintenance?.return(undefined).catch(() => { });
53
+ }
54
+ readPage(terminalId, source, options = {}, signal) {
55
+ return this.schedule(async () => {
56
+ const session = await this.session(source, options.includeTools ?? false, signal);
57
+ try {
58
+ const cursor = options.around ?? options.cursor;
59
+ const around = options.around ? decodeCursor(options.around) : null;
60
+ if (cursor) {
61
+ const decoded = decodeCursor(cursor);
62
+ if (!decoded || !cursorMatchesSource(decoded, source, session.manifest.identity)
63
+ || (decoded.snapshotSize !== undefined && decoded.snapshotSize > session.manifest.size))
64
+ return null;
65
+ }
66
+ const result = await readTranscriptHistoryPage(terminalId, source, {
67
+ ...options, ...(cursor === undefined ? {} : { cursor }), includeTools: options.includeTools ?? false,
68
+ ...(options.around === undefined ? {} : { archiveTrailer: false })
69
+ }, async (handle, before, bytes, end) => {
70
+ // The history reader borrows this session's descriptor and fixed end.
71
+ if (fileFingerprint(await handle.stat()) !== session.manifest.identity || end > session.manifest.size)
72
+ return null;
73
+ const window = await this.pageWindow(session, before, bytes, end, options);
74
+ if (around?.matchOffset !== undefined) {
75
+ window.records = window.records.map(record => record.endOffset === around.beforeOffset
76
+ ? { ...record, block: searchContext(record.block, around.matchOffset) } : record);
77
+ }
78
+ return window;
79
+ }, signal, { handle: session.handle, size: session.manifest.size });
80
+ return await this.commit(session) ? result : null;
81
+ }
82
+ finally {
83
+ await session.handle.close();
84
+ }
85
+ }, signal);
86
+ }
87
+ search(terminalId, source, options, signal) {
88
+ const query = normalizeTranscriptSearchQuery(options.query);
89
+ if (transcriptSearchQueryError(query))
90
+ return Promise.resolve(null);
91
+ return this.schedule(async () => {
92
+ const includeTools = options.includeTools ?? false;
93
+ const session = await this.session(source, includeTools, signal);
94
+ try {
95
+ const key = this.key(source, includeTools);
96
+ const previous = options.cursor ? parseSearchCursor(options.cursor) : undefined;
97
+ if (previous === null || (previous && (previous.key !== key || previous.query !== query
98
+ || previous.identity !== session.manifest.identity
99
+ || (previous.snapshot === session.manifest.size && previous.snapshotMtime !== session.manifest.mtime)
100
+ || previous.snapshot > session.manifest.size)))
101
+ return null;
102
+ const snapshot = previous?.snapshot ?? session.manifest.size;
103
+ let before = previous?.before ?? snapshot;
104
+ const initialBefore = before;
105
+ let scannedBytes = 0;
106
+ const hits = [];
107
+ // Cache setup is not search work. Even on a busy disk, process at
108
+ // least one window/record before returning a continuation cursor.
109
+ const started = performance.now();
110
+ while (before > 0 && hits.length < 20 && scannedBytes < SEARCH_BYTES &&
111
+ (before === initialBefore || performance.now() - started < SEARCH_MS)) {
112
+ signal?.throwIfAborted();
113
+ const remaining = SEARCH_BYTES - scannedBytes;
114
+ const cached = this.segment(session, before, snapshot);
115
+ if (scannedBytes > 0 && (cached ? cached.bytes > remaining : remaining < 4 * SEGMENT_BYTES))
116
+ break;
117
+ const bytesBefore = session.readBytes;
118
+ const window = await this.window(session, before, snapshot, Math.min(SEARCH_SEGMENT_BYTES, Math.max(SEGMENT_BYTES, remaining - 4 * SEGMENT_BYTES)));
119
+ let stopped = false;
120
+ for (let index = window.records.length - 1; index >= 0; index -= 1) {
121
+ if (index % 16 === 0) {
122
+ await yieldLoop();
123
+ signal?.throwIfAborted();
124
+ }
125
+ const record = window.records[index];
126
+ const normalized = record.block.normalize('NFC');
127
+ const text = normalized.toLowerCase();
128
+ const match = text.indexOf(query);
129
+ before = record.startOffset;
130
+ if (match >= 0) {
131
+ const header = record.block.slice(0, record.block.indexOf('\n'));
132
+ const metadata = /^(?:\[([^\]]+)\] )?(.*)$/.exec(header);
133
+ hits.push({
134
+ id: `${session.manifest.identity}:${record.startOffset}`,
135
+ preview: normalized.slice(Math.max(0, match - 80), match + query.length + 160),
136
+ timestamp: metadata[1] ?? '', role: metadata[2] ?? '',
137
+ cursor: encodeCursor(source, record.endOffset, session.manifest.identity, snapshot, match)
138
+ });
139
+ }
140
+ if (hits.length === 20 || performance.now() - started >= SEARCH_MS) {
141
+ stopped = true;
142
+ break;
143
+ }
144
+ }
145
+ if (!stopped)
146
+ before = window.startOffset;
147
+ scannedBytes += session.readBytes - bytesBefore;
148
+ }
149
+ if (!await this.commit(session))
150
+ return null;
151
+ return {
152
+ terminalId, source: { provider: source.provider, sessionId: source.sessionId },
153
+ query, hits, scannedBytes, complete: before === 0,
154
+ cursor: before === 0 ? null : encodeSearchCursor({
155
+ v: 2, key, snapshotMtime: previous?.snapshotMtime ?? session.manifest.mtime,
156
+ identity: session.manifest.identity, query, snapshot, before
157
+ })
158
+ };
159
+ }
160
+ finally {
161
+ await session.handle.close();
162
+ }
163
+ }, signal);
164
+ }
165
+ key(source, tools) {
166
+ return createHash('sha256').update(JSON.stringify([source.provider, source.sessionId, source.filePath, tools])).digest('hex');
167
+ }
168
+ async session(source, includeTools, signal, background = false) {
169
+ signal?.throwIfAborted();
170
+ const dir = join(this.root, this.key(source, includeTools));
171
+ const handle = await open(source.filePath, 'r');
172
+ try {
173
+ const info = await handle.stat();
174
+ const identity = fileFingerprint(info);
175
+ let cacheAvailable = true;
176
+ try {
177
+ await mkdir(this.root, { recursive: true, mode: 0o700 });
178
+ await chmod(this.root, 0o700);
179
+ await mkdir(dir, { mode: 0o700, recursive: true });
180
+ await chmod(dir, 0o700);
181
+ }
182
+ catch {
183
+ cacheAvailable = false;
184
+ }
185
+ const cachedManifest = cacheAvailable ? await readJson(join(dir, 'manifest.json')) : null;
186
+ let manifest = validManifest(cachedManifest) ? cachedManifest : null;
187
+ const previousFiles = manifest?.segments.map(segment => segment.file) ?? [];
188
+ if (!manifest || manifest.version !== TRANSCRIPT_PARSER_VERSION || manifest.identity !== identity
189
+ || info.size < manifest.size || (info.size === manifest.size && info.mtimeMs !== manifest.mtime)) {
190
+ manifest = { version: TRANSCRIPT_PARSER_VERSION, identity, size: info.size, mtime: info.mtimeMs, segments: [] };
191
+ }
192
+ else if (info.size > manifest.size) {
193
+ // Only the tail can change during an append: open records and Codex
194
+ // duplicate context. Keep the immutable prefix without a rebuild.
195
+ const oldSize = manifest.size;
196
+ manifest.segments = manifest.segments.filter(segment => segment.end + DUPLICATE_CONTEXT < oldSize);
197
+ manifest.size = info.size;
198
+ manifest.mtime = info.mtimeMs;
199
+ }
200
+ manifest.accessedAt = background
201
+ ? (manifest.accessedAt ?? this.jobs.get(this.key(source, includeTools))?.accessedAt ?? 0)
202
+ : Date.now();
203
+ manifest.source = source;
204
+ manifest.includeTools = includeTools;
205
+ return { dir, manifest, handle, source, includeTools, signal, readBytes: 0, cacheAvailable, background, previousFiles };
206
+ }
207
+ catch (error) {
208
+ await handle.close();
209
+ throw error;
210
+ }
211
+ }
212
+ async window(session, before, snapshot, readBytes = SEGMENT_BYTES) {
213
+ session.signal?.throwIfAborted();
214
+ const contextEnd = Math.min(snapshot, before + DUPLICATE_CONTEXT);
215
+ const fromCache = await this.cachedWindow(session, before, snapshot);
216
+ if (fromCache)
217
+ return fromCache;
218
+ const countedHandle = {
219
+ read: async (buffer, offset, length, position) => {
220
+ session.signal?.throwIfAborted();
221
+ const result = await session.handle.read(buffer, offset, length, position);
222
+ session.readBytes += result.bytesRead;
223
+ return result;
224
+ }
225
+ };
226
+ // Stop new extraction at an existing segment boundary. History's small
227
+ // windows and search's larger windows then form one non-overlapping index.
228
+ let floor = 0;
229
+ for (const segment of session.manifest.segments) {
230
+ if (segment.end < before)
231
+ floor = Math.max(floor, segment.end);
232
+ }
233
+ const result = await readRenderedTranscriptWindow(countedHandle, session.source, before, readBytes, snapshot, 0, session.includeTools, session.signal, floor);
234
+ if (!result || result.startOffset >= before)
235
+ throw new Error('Transcript changed while reading');
236
+ if (session.cacheAvailable) {
237
+ try {
238
+ if (!await this.canWrite()) {
239
+ session.cacheAvailable = false;
240
+ return result;
241
+ }
242
+ const file = `${randomUUID()}.json`;
243
+ const bytes = await atomicJson(session.dir, file, result);
244
+ this.revisions.set(session.dir, (this.revisions.get(session.dir) ?? 0) + 1);
245
+ this.diskUsage.set(session.dir, (this.diskUsage.get(session.dir) ?? 0) + bytes);
246
+ this.freeBytes -= bytes;
247
+ session.manifest.segments = session.manifest.segments.filter(segment => segment.end <= result.startOffset || segment.start >= before);
248
+ session.manifest.segments.push({ file, start: result.startOffset, end: before, contextEnd, bytes, used: Date.now() });
249
+ }
250
+ catch {
251
+ session.cacheAvailable = false;
252
+ }
253
+ }
254
+ return result;
255
+ }
256
+ async cachedWindow(session, before, snapshot) {
257
+ const cached = this.segment(session, before, snapshot);
258
+ if (cached) {
259
+ const result = await readJson(join(session.dir, cached.file), bytes => { session.readBytes += bytes; });
260
+ if (validWindow(result, cached.start, cached.end)) {
261
+ cached.used = Date.now();
262
+ return { startOffset: cached.start, records: result.records.filter(record => record.startOffset < before) };
263
+ }
264
+ session.manifest.segments = session.manifest.segments.filter(segment => segment !== cached);
265
+ }
266
+ return null;
267
+ }
268
+ segment(session, before, snapshot) {
269
+ if (!session.cacheAvailable)
270
+ return undefined;
271
+ return session.manifest.segments.find(segment => segment.start < before && before <= segment.end
272
+ && segment.contextEnd === Math.min(snapshot, segment.end + DUPLICATE_CONTEXT));
273
+ }
274
+ async pageWindow(session, before, bytes, snapshot, options) {
275
+ const records = [];
276
+ let offset = before;
277
+ let chars = 0;
278
+ let lines = 0;
279
+ const targetRecords = Number.isInteger(options.limit) && options.limit > 0 ? Math.min(1000, options.limit) : 400;
280
+ const targetLines = Number.isInteger(options.maxLines) && options.maxLines > 0 ? Math.min(5000, options.maxLines) : 5000;
281
+ const floor = Math.max(0, before - bytes);
282
+ while (offset > 0) {
283
+ session.signal?.throwIfAborted();
284
+ const warmOnly = offset <= floor;
285
+ if (warmOnly && (records.length >= targetRecords || lines >= targetLines || chars >= 1024 * 1024))
286
+ break;
287
+ // Beyond the bounded cold window, expand by rendered content, not raw
288
+ // tool payload bytes. A cache gap stops expansion rather than rebuilding.
289
+ const window = warmOnly ? await this.cachedWindow(session, offset, snapshot) : await this.window(session, offset, snapshot);
290
+ if (!window)
291
+ break;
292
+ records.unshift(...window.records);
293
+ for (const record of window.records) {
294
+ chars += record.block.length;
295
+ lines += record.block.split('\n').length;
296
+ }
297
+ offset = window.startOffset;
298
+ }
299
+ return { startOffset: offset, records };
300
+ }
301
+ async commit(session) {
302
+ session.signal?.throwIfAborted();
303
+ const current = await stat(session.source.filePath);
304
+ if (fileFingerprint(current) !== session.manifest.identity || current.size < session.manifest.size
305
+ || (current.size === session.manifest.size && current.mtimeMs !== session.manifest.mtime))
306
+ return false;
307
+ if (!session.background && this.options.background !== false && !this.disposed) {
308
+ const key = this.key(session.source, session.includeTools);
309
+ const job = this.jobs.get(key);
310
+ this.jobs.set(key, {
311
+ ...job,
312
+ source: session.source, includeTools: session.includeTools,
313
+ accessedAt: session.manifest.accessedAt, next: job?.next ?? 0,
314
+ ...(job?.completeStamp === undefined ? {} : { completeStamp: job.completeStamp })
315
+ });
316
+ this.wakeBackground(250);
317
+ }
318
+ if (!session.cacheAvailable)
319
+ return true;
320
+ try {
321
+ await this.persist(session);
322
+ }
323
+ catch {
324
+ // Cache I/O is optional; a successfully read and validated source is not.
325
+ session.cacheAvailable = false;
326
+ }
327
+ session.signal?.throwIfAborted();
328
+ return true;
329
+ }
330
+ async persist(session) {
331
+ if (!await this.canWrite()) {
332
+ session.cacheAvailable = false;
333
+ return;
334
+ }
335
+ // Commit segments before the manifest. This is a disposable cache: atomic
336
+ // rename prevents partial JSON visibility; validation recovers missing or
337
+ // damaged files after power loss without fsync per tiny segment.
338
+ const manifestBytes = await atomicJson(session.dir, 'manifest.json', session.manifest);
339
+ this.revisions.set(session.dir, (this.revisions.get(session.dir) ?? 0) + 1);
340
+ const keep = new Set(['manifest.json', ...session.manifest.segments.map(segment => segment.file)]);
341
+ for (const entry of session.previousFiles) {
342
+ if (!keep.has(entry))
343
+ await rm(join(session.dir, entry), { force: true });
344
+ }
345
+ this.diskUsage.set(session.dir, manifestBytes + session.manifest.segments.reduce((sum, segment) => sum + segment.bytes, 0));
346
+ const accessed = new Date(session.manifest.accessedAt ?? 0);
347
+ await utimes(session.dir, accessed, accessed);
348
+ session.signal?.throwIfAborted();
349
+ }
350
+ async cleanup() {
351
+ if (!this.maintenance) {
352
+ if (Date.now() < this.maintenanceRetryAt)
353
+ return;
354
+ if (Date.now() < this.maintenanceAt && this.totalBytes() < MAX_DISK_BYTES)
355
+ return;
356
+ this.maintenance = this.scanCache();
357
+ }
358
+ const started = performance.now();
359
+ try {
360
+ for (let i = 0; i < 32 && !this.disposed && !this.queue.length; i++) {
361
+ const step = await this.maintenance.next();
362
+ if (step.done) {
363
+ this.maintenance = undefined;
364
+ this.maintenanceAt = this.maintenanceRetryAt > Date.now()
365
+ ? this.maintenanceRetryAt : Date.now() + MAINTENANCE_MS;
366
+ return;
367
+ }
368
+ if (performance.now() - started >= 10)
369
+ return;
370
+ }
371
+ }
372
+ catch {
373
+ this.maintenance = undefined;
374
+ this.maintenanceRetryAt = Date.now() + BACKGROUND_POLL_MS;
375
+ this.maintenanceAt = Date.now() + BACKGROUND_POLL_MS;
376
+ }
377
+ }
378
+ async *scanCache() {
379
+ const now = Date.now();
380
+ const sessions = [];
381
+ const entries = await readdir(this.root, { withFileTypes: true }).catch((error) => {
382
+ if (error.code === 'ENOENT')
383
+ return [];
384
+ throw error;
385
+ });
386
+ const seen = new Set();
387
+ for (const entry of entries) {
388
+ if (entry.isDirectory() && /^\.evict-[a-f0-9-]+$/.test(entry.name)) {
389
+ const dir = join(this.root, entry.name);
390
+ seen.add(dir);
391
+ yield* this.removeRetiredCache(dir);
392
+ continue;
393
+ }
394
+ if (!entry.isDirectory() || !/^[a-f0-9]{64}$/.test(entry.name))
395
+ continue;
396
+ const dir = join(this.root, entry.name);
397
+ seen.add(dir);
398
+ let bytes = 0;
399
+ try {
400
+ const revision = this.revisions.get(dir) ?? 0;
401
+ const info = await stat(dir);
402
+ const metadata = await readJson(join(dir, 'manifest.json'));
403
+ const manifest = validManifest(metadata) ? metadata : null;
404
+ const referenced = new Set(['manifest.json', ...manifest?.segments.map(segment => segment.file) ?? []]);
405
+ for (const file of await readdir(dir, { withFileTypes: true })) {
406
+ // A foreground request may replace segments between maintenance slices.
407
+ // Never delete files using a manifest captured before that write.
408
+ if ((this.revisions.get(dir) ?? 0) !== revision)
409
+ break;
410
+ if (file.isFile() && !referenced.has(file.name)) {
411
+ await rm(join(dir, file.name), { force: true });
412
+ }
413
+ else if (file.isFile()) {
414
+ bytes += (await stat(join(dir, file.name))).size;
415
+ }
416
+ yield;
417
+ }
418
+ if ((this.revisions.get(dir) ?? 0) !== revision) {
419
+ // The writer maintained its accounting; revisit orphans next cycle.
420
+ sessions.push({ dir, used: this.jobs.get(entry.name)?.accessedAt ?? Date.now(), bytes: this.diskUsage.get(dir) ?? 0 });
421
+ continue;
422
+ }
423
+ const used = Math.max(manifest?.accessedAt ?? info.mtimeMs, this.jobs.get(entry.name)?.accessedAt ?? 0);
424
+ sessions.push({ dir, used, bytes });
425
+ this.diskUsage.set(dir, bytes);
426
+ if (manifest?.source && typeof manifest.includeTools === 'boolean'
427
+ && now - used < RETENTION_MS && this.options.background !== false
428
+ && this.key(manifest.source, manifest.includeTools) === entry.name && !this.jobs.has(entry.name)) {
429
+ this.jobs.set(entry.name, { source: manifest.source, includeTools: manifest.includeTools, accessedAt: used, next: 0 });
430
+ }
431
+ }
432
+ catch (error) {
433
+ await this.cacheInspectionFailed(dir, error, bytes);
434
+ }
435
+ yield;
436
+ }
437
+ // New directories can be created while this scan yields. Do not discard
438
+ // their writer-maintained accounting just because they were not in entries.
439
+ for (const dir of this.diskUsage.keys()) {
440
+ if (!seen.has(dir)) {
441
+ try {
442
+ await stat(dir);
443
+ }
444
+ catch (error) {
445
+ await this.cacheInspectionFailed(dir, error);
446
+ }
447
+ }
448
+ yield;
449
+ }
450
+ this.accountingReady = true;
451
+ for (const session of sessions.sort((a, b) => a.used - b.used)) {
452
+ const key = session.dir.slice(this.root.length + 1);
453
+ const used = Math.max(session.used, this.jobs.get(key)?.accessedAt ?? 0);
454
+ // A request since enumeration makes this entry an eviction candidate for
455
+ // the next cycle, when its LRU position can be sorted accurately.
456
+ if (used > session.used)
457
+ continue;
458
+ if (this.totalBytes() < MAX_DISK_BYTES && Date.now() - used < RETENTION_MS)
459
+ continue;
460
+ const retired = join(this.root, `.evict-${randomUUID()}`);
461
+ try {
462
+ // Detach the old cache atomically. A foreground request can now create
463
+ // the original directory without sharing any paths with this deletion.
464
+ await rename(session.dir, retired);
465
+ this.diskUsage.set(retired, this.diskUsage.get(session.dir) ?? session.bytes);
466
+ this.diskUsage.delete(session.dir);
467
+ this.revisions.delete(session.dir);
468
+ this.jobs.delete(key);
469
+ }
470
+ catch (error) {
471
+ await this.cacheInspectionFailed(session.dir, error, session.bytes);
472
+ yield;
473
+ continue;
474
+ }
475
+ yield;
476
+ yield* this.removeRetiredCache(retired);
477
+ }
478
+ }
479
+ async cacheInspectionFailed(dir, error, observed = 0) {
480
+ if (error.code === 'ENOENT') {
481
+ try {
482
+ await stat(dir);
483
+ }
484
+ catch (missing) {
485
+ if (missing.code === 'ENOENT') {
486
+ this.diskUsage.delete(dir);
487
+ return;
488
+ }
489
+ }
490
+ }
491
+ // Unknown usage is not zero. Retain known usage, or a conservative reserve
492
+ // until a later inspection succeeds; statfs remains the disk safety guard.
493
+ this.diskUsage.set(dir, Math.max(observed, this.diskUsage.get(dir) ?? UNKNOWN_CACHE_BYTES));
494
+ this.maintenanceRetryAt = Date.now() + BACKGROUND_POLL_MS;
495
+ }
496
+ async *removeRetiredCache(dir) {
497
+ if (!this.diskUsage.has(dir))
498
+ this.diskUsage.set(dir, UNKNOWN_CACHE_BYTES);
499
+ try {
500
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
501
+ // Cache files are flat. Do not recurse into unexpected directories or
502
+ // follow symlinks. unlink removes only the link itself.
503
+ if (!entry.isDirectory()) {
504
+ try {
505
+ await unlink(join(dir, entry.name));
506
+ }
507
+ catch (error) {
508
+ if (error.code !== 'ENOENT')
509
+ await this.cacheInspectionFailed(dir, error);
510
+ }
511
+ }
512
+ yield;
513
+ }
514
+ await rmdir(dir);
515
+ this.diskUsage.delete(dir);
516
+ }
517
+ catch (error) {
518
+ await this.cacheInspectionFailed(dir, error);
519
+ }
520
+ yield;
521
+ }
522
+ totalBytes() {
523
+ let bytes = 0;
524
+ for (const size of this.diskUsage.values())
525
+ bytes += size;
526
+ return bytes;
527
+ }
528
+ async canWrite() {
529
+ if (Date.now() >= this.spaceCheckedAt) {
530
+ const space = await statfs(this.root);
531
+ this.freeBytes = space.bavail * space.bsize;
532
+ this.spaceCheckedAt = Date.now() + BACKGROUND_POLL_MS;
533
+ }
534
+ return this.accountingReady && this.freeBytes > MIN_FREE_BYTES && this.totalBytes() < MAX_DISK_BYTES;
535
+ }
536
+ wakeBackground(delay) {
537
+ if (this.disposed || this.options.background === false)
538
+ return;
539
+ const wakeAt = Date.now() + delay;
540
+ if (this.timer && this.wakeAt <= wakeAt)
541
+ return;
542
+ clearTimeout(this.timer);
543
+ this.wakeAt = wakeAt;
544
+ this.timer = setTimeout(() => {
545
+ this.timer = undefined;
546
+ void this.runBackground().finally(() => {
547
+ let next = this.maintenance ? 0 : Math.min(Date.now() + BACKGROUND_POLL_MS, this.maintenanceAt);
548
+ for (const job of this.jobs.values())
549
+ next = Math.min(next, job.next);
550
+ this.wakeBackground(Math.max(this.maintenance ? 25 : 250, next - Date.now()));
551
+ });
552
+ }, delay);
553
+ this.timer.unref();
554
+ }
555
+ async runBackground() {
556
+ // Background work never joins the foreground queue. One bounded extraction
557
+ // finishes before queued user requests run, then the next slice yields.
558
+ if (this.disposed || this.busy || this.queue.length)
559
+ return;
560
+ await this.schedule(async () => {
561
+ await this.cleanup();
562
+ if (this.queue.length || this.maintenance)
563
+ return null;
564
+ const now = Date.now();
565
+ for (const [key, job] of this.jobs) {
566
+ if (now - job.accessedAt >= RETENTION_MS) {
567
+ this.jobs.delete(key);
568
+ continue;
569
+ }
570
+ if (job.next > now)
571
+ continue;
572
+ // Rotate jobs so a large archive cannot starve other sessions.
573
+ this.jobs.delete(key);
574
+ this.jobs.set(key, job);
575
+ job.next = now + BACKGROUND_POLL_MS;
576
+ try {
577
+ if (!await this.canWrite())
578
+ break;
579
+ const info = await stat(job.source.filePath);
580
+ const stamp = `${fileFingerprint(info)}:${info.size}:${info.mtimeMs}`;
581
+ if (job.completeStamp === stamp)
582
+ break;
583
+ delete job.completeStamp;
584
+ const session = await this.session(job.source, job.includeTools, this.backgroundAbort.signal, true);
585
+ try {
586
+ if (!session.cacheAvailable)
587
+ break;
588
+ let before = session.manifest.size;
589
+ const segments = [...session.manifest.segments].sort((a, b) => b.end - a.end);
590
+ for (const segment of segments) {
591
+ if (segment.end < before)
592
+ break;
593
+ if (segment.start < before && segment.contextEnd === Math.min(session.manifest.size, segment.end + DUPLICATE_CONTEXT)) {
594
+ before = segment.start;
595
+ }
596
+ }
597
+ if (job.preferArchive && before > 0) {
598
+ let covered = 0;
599
+ for (let index = segments.length - 1; index >= 0; index--) {
600
+ const segment = segments[index];
601
+ if (segment.contextEnd !== Math.min(session.manifest.size, segment.end + DUPLICATE_CONTEXT))
602
+ continue;
603
+ if (segment.start > covered) {
604
+ before = segment.start;
605
+ break;
606
+ }
607
+ covered = Math.max(covered, segment.end);
608
+ }
609
+ }
610
+ if (before > 0) {
611
+ await this.window(session, before, session.manifest.size, SEARCH_SEGMENT_BYTES);
612
+ if (await this.commit(session) && session.cacheAvailable) {
613
+ job.next = 0;
614
+ job.preferArchive = !job.preferArchive;
615
+ }
616
+ }
617
+ else {
618
+ job.completeStamp = stamp;
619
+ }
620
+ }
621
+ finally {
622
+ await session.handle.close();
623
+ }
624
+ }
625
+ catch (error) {
626
+ if (error.code === 'ENOENT')
627
+ this.jobs.delete(key);
628
+ }
629
+ break;
630
+ }
631
+ return null;
632
+ }, this.backgroundAbort.signal);
633
+ }
634
+ schedule(work, signal) {
635
+ if (this.disposed || signal?.aborted)
636
+ return Promise.resolve(null);
637
+ if (this.busy && this.queue.length >= MAX_QUEUE)
638
+ return Promise.resolve(null);
639
+ return new Promise((resolve) => {
640
+ const entry = {
641
+ signal, reject: () => resolve(null),
642
+ abort: () => {
643
+ const index = this.queue.indexOf(entry);
644
+ if (index >= 0)
645
+ this.queue.splice(index, 1);
646
+ resolve(null);
647
+ },
648
+ start: () => {
649
+ signal?.removeEventListener('abort', entry.abort);
650
+ this.busy = true;
651
+ void (async () => {
652
+ try {
653
+ signal?.throwIfAborted();
654
+ const result = await work();
655
+ resolve(signal?.aborted ? null : result);
656
+ }
657
+ catch {
658
+ resolve(null);
659
+ }
660
+ finally {
661
+ this.busy = false;
662
+ this.queue.shift()?.start();
663
+ }
664
+ })();
665
+ }
666
+ };
667
+ if (this.busy) {
668
+ this.queue.push(entry);
669
+ signal?.addEventListener('abort', entry.abort, { once: true });
670
+ }
671
+ else
672
+ entry.start();
673
+ });
674
+ }
675
+ }
676
+ async function atomicJson(dir, name, value) {
677
+ const data = JSON.stringify(value);
678
+ const temporary = join(dir, `${randomUUID()}.tmp`);
679
+ const handle = await open(temporary, 'wx', 0o600);
680
+ try {
681
+ await handle.writeFile(data);
682
+ }
683
+ finally {
684
+ await handle.close();
685
+ }
686
+ await rename(temporary, join(dir, name));
687
+ return Buffer.byteLength(data);
688
+ }
689
+ async function readJson(path, onRead) {
690
+ let handle;
691
+ try {
692
+ handle = await open(path, 'r');
693
+ await handle.chmod(0o600);
694
+ const info = await handle.stat();
695
+ if (info.size > MAX_JSON_BYTES)
696
+ return null;
697
+ const data = await handle.readFile('utf8');
698
+ onRead?.(Buffer.byteLength(data));
699
+ return JSON.parse(data);
700
+ }
701
+ catch {
702
+ return null;
703
+ }
704
+ finally {
705
+ await handle?.close();
706
+ }
707
+ }
708
+ function isObject(value) {
709
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
710
+ }
711
+ function isOffset(value) {
712
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
713
+ }
714
+ function validManifest(value) {
715
+ if (!isObject(value) || !isOffset(value.size))
716
+ return false;
717
+ const size = value.size;
718
+ return typeof value.identity === 'string' && isOffset(value.version)
719
+ && typeof value.mtime === 'number' && Number.isFinite(value.mtime)
720
+ && (value.accessedAt === undefined || isOffset(value.accessedAt))
721
+ && (value.includeTools === undefined || typeof value.includeTools === 'boolean')
722
+ && (value.source === undefined || (isObject(value.source)
723
+ && (value.source.provider === 'codex' || value.source.provider === 'claude')
724
+ && typeof value.source.sessionId === 'string' && typeof value.source.filePath === 'string'))
725
+ && Array.isArray(value.segments)
726
+ && value.segments.every((segment) => isObject(segment)
727
+ && typeof segment.file === 'string' && /^[a-f0-9-]+\.json$/.test(segment.file)
728
+ && isOffset(segment.start) && isOffset(segment.end)
729
+ && segment.end > segment.start && segment.end <= size
730
+ && isOffset(segment.contextEnd) && segment.contextEnd >= segment.end && segment.contextEnd <= size
731
+ && isOffset(segment.bytes) && typeof segment.used === 'number' && Number.isFinite(segment.used));
732
+ }
733
+ function validWindow(value, start, end) {
734
+ if (!isObject(value))
735
+ return false;
736
+ let previousEnd = start;
737
+ return value.startOffset === start && Array.isArray(value.records) && value.records.every((record) => {
738
+ if (!isObject(record) || !isOffset(record.startOffset) || !isOffset(record.endOffset))
739
+ return false;
740
+ const valid = record.startOffset >= previousEnd
741
+ && record.startOffset >= start && record.startOffset < end
742
+ && record.endOffset > record.startOffset && record.endOffset <= end && typeof record.block === 'string';
743
+ previousEnd = record.endOffset;
744
+ return valid;
745
+ });
746
+ }
747
+ function encodeSearchCursor(cursor) {
748
+ return Buffer.from(JSON.stringify(cursor)).toString('base64url');
749
+ }
750
+ function parseSearchCursor(value) {
751
+ if (value.length > 4096)
752
+ return null;
753
+ try {
754
+ const cursor = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
755
+ return cursor.v === 2 && typeof cursor.key === 'string' && typeof cursor.identity === 'string'
756
+ && typeof cursor.query === 'string' && Number.isFinite(cursor.snapshotMtime)
757
+ && Number.isSafeInteger(cursor.snapshot) && Number.isSafeInteger(cursor.before)
758
+ && cursor.before >= 0 && cursor.snapshot >= cursor.before ? cursor : null;
759
+ }
760
+ catch {
761
+ return null;
762
+ }
763
+ }
764
+ function yieldLoop() {
765
+ return new Promise(resolve => setImmediate(resolve));
766
+ }
767
+ // Search hits can lie beyond the normal per-message display cap. Keep the
768
+ // original speaker header and a bounded excerpt without changing cached text.
769
+ function searchContext(block, offset) {
770
+ const text = block.normalize('NFC');
771
+ const headerEnd = text.indexOf('\n');
772
+ if (headerEnd < 0 || (text.length <= 4000 && text.split('\n').length <= 32))
773
+ return text;
774
+ const position = Math.max(headerEnd + 1, Math.min(offset, text.length));
775
+ const prefix = text.slice(Math.max(headerEnd + 1, position - 1000), position).split('\n').slice(-8).join('\n');
776
+ const suffix = text.slice(position, position + 4000).split('\n').slice(0, 24).join('\n');
777
+ return `${text.slice(0, headerEnd)}\n${position - prefix.length > headerEnd + 1 ? '[earlier text omitted]\n' : ''}${prefix}${suffix}${position + suffix.length < text.length ? '\n[later text omitted]' : ''}`;
778
+ }
779
+ //# sourceMappingURL=transcriptStore.js.map