@tonyclaw/agent-inspector 2.0.17 → 2.0.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.
Files changed (73) hide show
  1. package/.output/cli.js +149 -40
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-DDg7Cs4N.js → CompareDrawer-D-90PIPw.js} +1 -1
  4. package/.output/public/assets/ProxyViewerContainer-CxRDWacg.js +114 -0
  5. package/.output/public/assets/ReplayDialog-DzlLYoTO.js +1 -0
  6. package/.output/public/assets/{RequestAnatomy-DR8OICOJ.js → RequestAnatomy-0hbyf6oa.js} +1 -1
  7. package/.output/public/assets/ResponseView-CKe0tNqr.js +1 -0
  8. package/.output/public/assets/StreamingChunkSequence-enhfdeNh.js +1 -0
  9. package/.output/public/assets/_sessionId-CJ-SusES.js +1 -0
  10. package/.output/public/assets/index-BbFsmQRC.js +1 -0
  11. package/.output/public/assets/index-BfBgrkgU.css +1 -0
  12. package/.output/public/assets/{main-bwZlEXw2.js → main-o9-hg8g1.js} +2 -2
  13. package/.output/server/_libs/lucide-react.mjs +141 -118
  14. package/.output/server/_libs/tanstack__react-virtual.mjs +13 -2
  15. package/.output/server/_libs/tanstack__virtual-core.mjs +24 -1
  16. package/.output/server/{_sessionId-BEuJJhqT.mjs → _sessionId-F8lm6PIb.mjs} +3 -2
  17. package/.output/server/_ssr/{CompareDrawer-yTO93GMz.mjs → CompareDrawer-Cf1yfwfd.mjs} +4 -3
  18. package/.output/server/_ssr/{ProxyViewerContainer-C8qCkHGB.mjs → ProxyViewerContainer-Bmu9867L.mjs} +349 -74
  19. package/.output/server/_ssr/{ReplayDialog-DRF9PG7Z.mjs → ReplayDialog-DxZfLBOe.mjs} +199 -17
  20. package/.output/server/_ssr/{RequestAnatomy-DBcuV_jV.mjs → RequestAnatomy-CbdERGZd.mjs} +4 -3
  21. package/.output/server/_ssr/{ResponseView-JJm78HZT.mjs → ResponseView-CvLd6qxI.mjs} +4 -3
  22. package/.output/server/_ssr/{StreamingChunkSequence-DyGKjBYx.mjs → StreamingChunkSequence-B_lkRB6o.mjs} +4 -3
  23. package/.output/server/_ssr/{index-T7JG28t6.mjs → index-ejrSItfz.mjs} +3 -2
  24. package/.output/server/_ssr/index.mjs +2 -2
  25. package/.output/server/_ssr/{router-BJob8RN3.mjs → router-SbCnbDc2.mjs} +896 -240
  26. package/.output/server/{_tanstack-start-manifest_v-CuiFi3XC.mjs → _tanstack-start-manifest_v-yx8ftqWz.mjs} +1 -1
  27. package/.output/server/index.mjs +60 -60
  28. package/README.md +26 -109
  29. package/package.json +2 -1
  30. package/src/cli/doctor.ts +160 -8
  31. package/src/cli/templates/skill-onboard.ts +2 -20
  32. package/src/components/OnboardingBanner.tsx +13 -13
  33. package/src/components/ProxyViewer.tsx +55 -38
  34. package/src/components/ProxyViewerContainer.tsx +5 -0
  35. package/src/components/providers/SettingsDialog.tsx +113 -2
  36. package/src/components/proxy-viewer/ConversationGroup.tsx +20 -3
  37. package/src/components/proxy-viewer/ConversationGroupList.tsx +144 -0
  38. package/src/components/proxy-viewer/ConversationHeader.tsx +0 -2
  39. package/src/components/proxy-viewer/ReplayDialog.tsx +200 -13
  40. package/src/components/proxy-viewer/index.ts +1 -1
  41. package/src/knowledge/openclawClient.ts +0 -8
  42. package/src/knowledge/types.ts +0 -2
  43. package/src/lib/export-logs.ts +72 -5
  44. package/src/lib/providerTestContract.ts +0 -1
  45. package/src/lib/useProviders.ts +1 -1
  46. package/src/mcp/server.ts +14 -6
  47. package/src/mcp/toolHandlers.ts +21 -4
  48. package/src/proxy/chunkStorage.ts +2 -4
  49. package/src/proxy/dataDir.ts +37 -15
  50. package/src/proxy/formats/anthropic/index.ts +0 -1
  51. package/src/proxy/formats/anthropic/schemas.ts +0 -2
  52. package/src/proxy/handler.ts +41 -29
  53. package/src/proxy/logFinalizer.ts +93 -11
  54. package/src/proxy/logger.ts +35 -10
  55. package/src/proxy/providers.ts +12 -5
  56. package/src/proxy/rawStreamCapture.ts +85 -0
  57. package/src/proxy/schemas.ts +1 -51
  58. package/src/proxy/sessionRuntime.ts +0 -9
  59. package/src/proxy/socketTracker.ts +15 -8
  60. package/src/proxy/store.ts +414 -13
  61. package/src/routes/api/logs.$id.replay.ts +66 -1
  62. package/src/routes/api/logs.stream.ts +11 -3
  63. package/src/routes/api/logs.ts +51 -17
  64. package/src/routes/api/providers.$providerId.ts +10 -3
  65. package/src/routes/api/providers.ts +6 -3
  66. package/.output/public/assets/ProxyViewerContainer-DbbK1y7N.js +0 -114
  67. package/.output/public/assets/ReplayDialog-C1zWNkoz.js +0 -1
  68. package/.output/public/assets/ResponseView-Dh9iSj6h.js +0 -1
  69. package/.output/public/assets/StreamingChunkSequence-B_xElyH3.js +0 -1
  70. package/.output/public/assets/_sessionId-gv1a_NNu.js +0 -1
  71. package/.output/public/assets/index-D_nZj9Vt.css +0 -1
  72. package/.output/public/assets/index-u-LwDaeH.js +0 -1
  73. package/src/components/ui/json-expansion-button.tsx +0 -56
@@ -8,8 +8,6 @@ export type { JsonValue };
8
8
 
9
9
  export type RequestFormat = "anthropic" | "openai" | "unknown";
10
10
 
11
- import { safeGetOwnProperty } from "../lib/objectUtils";
12
-
13
11
  /**
14
12
  * A single SSE event from a streaming response.
15
13
  */
@@ -50,6 +48,7 @@ export const CapturedLogSchema = z.object({
50
48
  headers: z.record(z.string(), z.string()).optional(),
51
49
  apiFormat: z.enum(["anthropic", "openai", "unknown"]).default("unknown"),
52
50
  isTest: z.boolean().optional().default(false),
51
+ replayOfLogId: z.number().nullable().optional(),
53
52
  providerName: z.string().nullable().optional(),
54
53
  clientPort: z.number().nullable().optional(),
55
54
  clientPid: z.number().nullable().optional(),
@@ -74,9 +73,6 @@ export type TokenUsage = {
74
73
  // Import types and schemas from formats
75
74
  // ============================================================
76
75
 
77
- // Import types and schemas from formats for internal use
78
- import { InspectorRequestSchema, type InspectorRequest } from "./formats/anthropic/schemas";
79
-
80
76
  // Re-export types and schemas from formats for backward compatibility
81
77
  export {
82
78
  InspectorRequestSchema,
@@ -84,7 +80,6 @@ export {
84
80
  SseEventSchema,
85
81
  } from "./formats/anthropic/schemas";
86
82
  export type {
87
- InspectorRequest,
88
83
  InspectorResponse,
89
84
  ResponseContentBlockType,
90
85
  } from "./formats/anthropic/schemas";
@@ -98,35 +93,6 @@ export {
98
93
  parseOpenAIResponse,
99
94
  } from "./formats/openai/schemas";
100
95
 
101
- // ============================================================
102
- // Utility functions
103
- // ============================================================
104
-
105
- function detectFormat(rawBody: string | null): RequestFormat {
106
- if (rawBody === null) return "unknown";
107
- try {
108
- const json: unknown = JSON.parse(rawBody);
109
- if (typeof json === "object" && json !== null && !Array.isArray(json)) {
110
- const hasModel = safeGetOwnProperty(json, "model") !== undefined;
111
- const hasMessages = safeGetOwnProperty(json, "messages") !== undefined;
112
- if (hasModel && hasMessages) {
113
- // Anthropic requests put `system` as a top-level key; OpenAI does not.
114
- // Both formats can have `tools`, so we check `system` as the discriminator.
115
- if (safeGetOwnProperty(json, "system") !== undefined) {
116
- return "anthropic";
117
- }
118
- const msgVal = safeGetOwnProperty(json, "messages");
119
- if (Array.isArray(msgVal)) {
120
- return "openai";
121
- }
122
- }
123
- }
124
- return "unknown";
125
- } catch {
126
- return "unknown";
127
- }
128
- }
129
-
130
96
  // Schema for model name in request body (used for routing)
131
97
  const RequestModelSchema = z.object({
132
98
  model: z.string(),
@@ -191,19 +157,3 @@ export function extractRequestMetadata(body: string | null, headers: Headers): R
191
157
  }
192
158
  return { model: null, sessionId: headerSessionId };
193
159
  }
194
-
195
- /**
196
- * Parse a raw request body into an InspectorRequest.
197
- * @deprecated Use FormatHandler.parseRequest() instead
198
- */
199
- export function parseRequest(rawBody: string | null): InspectorRequest | null {
200
- if (rawBody === null) return null;
201
- try {
202
- const json: unknown = JSON.parse(rawBody);
203
- const result = InspectorRequestSchema.safeParse(json);
204
- if (result.success) return result.data;
205
- return null;
206
- } catch {
207
- return null;
208
- }
209
- }
@@ -1,6 +1,5 @@
1
1
  import { logger } from "./logger";
2
2
  import { executeFinalizeLogJob, type FinalizeLogJob } from "./logFinalizer";
3
- import type { CapturedLog } from "./schemas";
4
3
  import {
5
4
  getLogSessionId,
6
5
  markSessionTaskFinished,
@@ -70,14 +69,6 @@ export function enqueueSessionTask<T>(
70
69
  return current;
71
70
  }
72
71
 
73
- export function enqueueLogTask<T>(
74
- log: CapturedLog,
75
- taskName: SessionTaskName,
76
- task: SessionTask<T>,
77
- ): Promise<T> {
78
- return enqueueSessionTask(getLogSessionId(log), taskName, task);
79
- }
80
-
81
72
  export function enqueueFinalizeLogJob(job: FinalizeLogJob): Promise<void> {
82
73
  return enqueueSessionTask(getLogSessionId(job.log), "finalize-log", () =>
83
74
  executeFinalizeLogJob(job),
@@ -1,5 +1,6 @@
1
1
  import { exec, execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { z } from "zod";
3
4
  import { logger } from "./logger";
4
5
 
5
6
  const execAsync = promisify(exec);
@@ -20,6 +21,12 @@ type CacheEntry = ClientInfo & {
20
21
  const cache = new Map<number, CacheEntry>();
21
22
  const CACHE_TTL_MS = 5 * 60 * 1000;
22
23
  const MAX_CACHE_SIZE = 200;
24
+ const RemotePortSchema = z.number().int().min(1).max(65_535);
25
+ const SocketSchema = z
26
+ .object({
27
+ remotePort: RemotePortSchema.nullish(),
28
+ })
29
+ .passthrough();
23
30
 
24
31
  // Deduplicate concurrent lookups for the same port
25
32
  const inflight = new Map<number, Promise<ClientInfo>>();
@@ -50,14 +57,14 @@ function setCache(port: number, info: ClientInfo): void {
50
57
  * Get the remote port from a Request's underlying socket.
51
58
  * Works with Bun, Node.js, and Nitro's event.node.req.
52
59
  */
53
- function extractRemotePort(request: Request): number | null {
54
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
55
- const socket = (request as any).socket;
56
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
57
- const remotePort = (socket as any)?.remotePort;
58
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
59
- if (remotePort !== undefined && remotePort !== null) return remotePort;
60
- return null;
60
+ export function extractRemotePort(request: Request): number | null {
61
+ const descriptor = Object.getOwnPropertyDescriptor(request, "socket");
62
+ if (descriptor === undefined || descriptor.get !== undefined) return null;
63
+ const parsed = SocketSchema.safeParse(descriptor.value);
64
+ if (!parsed.success) return null;
65
+ const remotePort = parsed.data.remotePort;
66
+ if (remotePort === undefined || remotePort === null) return null;
67
+ return remotePort;
61
68
  }
62
69
 
63
70
  /**
@@ -1,11 +1,25 @@
1
- import { readFileSync, existsSync, createReadStream } from "node:fs";
2
- import { open } from "node:fs/promises";
1
+ import { readFileSync, existsSync, createReadStream, readdirSync, statSync } from "node:fs";
2
+ import { open, readFile, unlink, writeFile } from "node:fs/promises";
3
3
  import { readdir as readdirCallback } from "node:fs/promises";
4
4
  import { createInterface } from "node:readline";
5
5
  import { Buffer } from "node:buffer";
6
6
  import { join } from "node:path";
7
- import { appendLogEntry, resolveLogDir, logger } from "./logger";
8
- import { addToIndex, findInIndex, getNextLogId, getCurrentLogFile } from "./logIndex";
7
+ import {
8
+ appendLogEntry,
9
+ resolveLogDir,
10
+ logger,
11
+ runWithFlushedLogWriteLock,
12
+ runWithLogWriteLock,
13
+ } from "./logger";
14
+ import { getChunksDir } from "./chunkStorage";
15
+ import {
16
+ addToIndex,
17
+ findInIndex,
18
+ flushIndex,
19
+ getNextLogId,
20
+ getCurrentLogFile,
21
+ rebuildIndex,
22
+ } from "./logIndex";
9
23
  import { writeChunks } from "./chunkStorage";
10
24
  import type { CapturedLog } from "./schemas";
11
25
  import { CapturedLogSchema } from "./schemas";
@@ -27,6 +41,42 @@ export { getLogSessionId, getSessionSnapshots } from "./sessionSupervisor";
27
41
 
28
42
  const MAX_MEMORY_CACHE = 100;
29
43
 
44
+ export type LogStorageStats = {
45
+ memoryCount: number;
46
+ logDir: string;
47
+ logFileCount: number;
48
+ logBytes: number;
49
+ chunkDir: string;
50
+ chunkFileCount: number;
51
+ chunkBytes: number;
52
+ };
53
+
54
+ export type ClearPersistedLogStorageResult = {
55
+ cleared: number;
56
+ logFilesDeleted: number;
57
+ chunkFilesDeleted: number;
58
+ };
59
+
60
+ export type ClearPersistedLogsByIdsResult = {
61
+ cleared: number;
62
+ logFilesRewritten: number;
63
+ chunkFilesDeleted: number;
64
+ };
65
+
66
+ export type ListLogsPageOptions = {
67
+ sessionId?: string;
68
+ model?: string;
69
+ offset: number;
70
+ limit: number;
71
+ };
72
+
73
+ export type ListLogsPageResult = {
74
+ logs: CapturedLog[];
75
+ total: number;
76
+ offset: number;
77
+ limit: number;
78
+ };
79
+
30
80
  // Memory cache: id -> CapturedLog (recent logs only, FIFO eviction)
31
81
  const memoryCache: Map<number, CapturedLog> = new Map();
32
82
 
@@ -88,9 +138,11 @@ export async function addTestLogEntry(entry: Omit<CapturedLog, "id">): Promise<C
88
138
  streamingChunksPath,
89
139
  };
90
140
 
91
- const logFile = getCurrentLogFile();
92
- appendLogEntry(log);
93
- await addToIndex(id, logFile, -1, -1);
141
+ await runWithLogWriteLock(async () => {
142
+ const logFile = getCurrentLogFile();
143
+ appendLogEntry(log);
144
+ await addToIndex(id, logFile, -1, -1);
145
+ });
94
146
 
95
147
  addToCache(log);
96
148
  observeSessionLog(log, session.source);
@@ -154,9 +206,11 @@ export async function createLog(
154
206
  };
155
207
 
156
208
  // Write to disk and update index
157
- const logFile = getCurrentLogFile();
158
- appendLogEntry(log);
159
- await addToIndex(id, logFile, -1, -1); // line numbers not tracked precisely
209
+ await runWithLogWriteLock(async () => {
210
+ const logFile = getCurrentLogFile();
211
+ appendLogEntry(log);
212
+ await addToIndex(id, logFile, -1, -1); // line numbers not tracked precisely
213
+ });
160
214
 
161
215
  // Add to memory cache
162
216
  addToCache(log);
@@ -262,12 +316,105 @@ export function getFilteredLogs(sessionId?: string, model?: string): CapturedLog
262
316
  // Cache maintains insertion order (sorted by ID since logs are added in ID order)
263
317
  // Use spread instead of Array.from for slightly better performance
264
318
  return [...memoryCache.values()].filter((l) => {
265
- if (sessionId !== undefined && getLogSessionId(l) !== sessionId) return false;
266
- if (model !== undefined && l.model !== model) return false;
267
- return true;
319
+ return matchesLogFilters(l, sessionId, model);
268
320
  });
269
321
  }
270
322
 
323
+ function matchesLogFilters(log: CapturedLog, sessionId?: string, model?: string): boolean {
324
+ if (sessionId !== undefined && getLogSessionId(log) !== sessionId) return false;
325
+ if (model !== undefined && log.model !== model) return false;
326
+ return true;
327
+ }
328
+
329
+ function requestedWindowFitsMemory(
330
+ filteredMemoryCount: number,
331
+ offset: number,
332
+ limit: number,
333
+ ): boolean {
334
+ return offset + limit <= filteredMemoryCount;
335
+ }
336
+
337
+ function paginateLogs(logs: CapturedLog[], offset: number, limit: number): CapturedLog[] {
338
+ return logs.slice(offset, offset + limit);
339
+ }
340
+
341
+ async function readPersistedLogsById(): Promise<Map<number, CapturedLog>> {
342
+ const byId = new Map<number, CapturedLog>();
343
+ const logDir = resolveLogDir();
344
+ if (!existsSync(logDir)) return byId;
345
+
346
+ try {
347
+ const entries = await readdirCallback(logDir);
348
+ const files = entries.filter((file) => file.endsWith(".jsonl")).sort();
349
+ for (const file of files) {
350
+ await readPersistedLogFile(join(logDir, file), byId);
351
+ }
352
+ } catch (err) {
353
+ logger.error("[store] Failed to list persisted logs:", String(err));
354
+ }
355
+
356
+ for (const log of memoryCache.values()) {
357
+ byId.set(log.id, log);
358
+ }
359
+
360
+ return byId;
361
+ }
362
+
363
+ async function readPersistedLogFile(
364
+ filePath: string,
365
+ byId: Map<number, CapturedLog>,
366
+ ): Promise<void> {
367
+ if (!existsSync(filePath)) return;
368
+
369
+ try {
370
+ const fileStream = createInterface({
371
+ input: createReadStream(filePath),
372
+ crlfDelay: Infinity,
373
+ });
374
+
375
+ for await (const line of fileStream) {
376
+ if (line.trim() === "") continue;
377
+ try {
378
+ const parsed: unknown = JSON.parse(line);
379
+ const result = CapturedLogSchema.safeParse(parsed);
380
+ if (result.success) {
381
+ const log = normalizeLogSession(result.data);
382
+ byId.set(log.id, log);
383
+ observeSessionLog(log);
384
+ }
385
+ } catch {
386
+ // Skip malformed lines.
387
+ }
388
+ }
389
+ } catch (err) {
390
+ logger.error("[store] Failed to read persisted log file:", filePath, String(err));
391
+ }
392
+ }
393
+
394
+ export async function listLogsPage(options: ListLogsPageOptions): Promise<ListLogsPageResult> {
395
+ const memoryLogs = getFilteredLogs(options.sessionId, options.model);
396
+ if (requestedWindowFitsMemory(memoryLogs.length, options.offset, options.limit)) {
397
+ return {
398
+ logs: paginateLogs(memoryLogs, options.offset, options.limit),
399
+ total: memoryLogs.length,
400
+ offset: options.offset,
401
+ limit: options.limit,
402
+ };
403
+ }
404
+
405
+ const persistedById = await readPersistedLogsById();
406
+ const persistedLogs = [...persistedById.values()]
407
+ .filter((log) => matchesLogFilters(log, options.sessionId, options.model))
408
+ .sort((left, right) => left.id - right.id);
409
+
410
+ return {
411
+ logs: paginateLogs(persistedLogs, options.offset, options.limit),
412
+ total: persistedLogs.length,
413
+ offset: options.offset,
414
+ limit: options.limit,
415
+ };
416
+ }
417
+
271
418
  export function getSessions(): string[] {
272
419
  return getSessionIds();
273
420
  }
@@ -280,6 +427,60 @@ export function getModels(): string[] {
280
427
  return [...set];
281
428
  }
282
429
 
430
+ export function getReplayLogsForSource(sourceLogId: number): CapturedLog[] {
431
+ return [...memoryCache.values()].filter((log) => log.replayOfLogId === sourceLogId);
432
+ }
433
+
434
+ function collectDirectoryStats(
435
+ dir: string,
436
+ shouldCount: (fileName: string) => boolean,
437
+ ): { fileCount: number; bytes: number } {
438
+ if (!existsSync(dir)) return { fileCount: 0, bytes: 0 };
439
+ let fileCount = 0;
440
+ let bytes = 0;
441
+ const visit = (currentDir: string, depth: number): void => {
442
+ if (depth > 8) return;
443
+ let entries: string[];
444
+ try {
445
+ entries = readdirSync(currentDir);
446
+ } catch {
447
+ return;
448
+ }
449
+ for (const entry of entries) {
450
+ const path = join(currentDir, entry);
451
+ try {
452
+ const stats = statSync(path);
453
+ if (stats.isDirectory()) {
454
+ visit(path, depth + 1);
455
+ } else if (shouldCount(entry)) {
456
+ fileCount += 1;
457
+ bytes += stats.size;
458
+ }
459
+ } catch {
460
+ // Ignore files that disappear while stats are being collected.
461
+ }
462
+ }
463
+ };
464
+ visit(dir, 0);
465
+ return { fileCount, bytes };
466
+ }
467
+
468
+ export function getLogStorageStats(): LogStorageStats {
469
+ const logDir = resolveLogDir();
470
+ const chunkDir = getChunksDir();
471
+ const logStats = collectDirectoryStats(logDir, (fileName) => fileName.endsWith(".jsonl"));
472
+ const chunkStats = collectDirectoryStats(chunkDir, (fileName) => fileName.endsWith(".json"));
473
+ return {
474
+ memoryCount: memoryCache.size,
475
+ logDir,
476
+ logFileCount: logStats.fileCount,
477
+ logBytes: logStats.bytes,
478
+ chunkDir,
479
+ chunkFileCount: chunkStats.fileCount,
480
+ chunkBytes: chunkStats.bytes,
481
+ };
482
+ }
483
+
283
484
  /**
284
485
  * Load recent completed log entries from all log files into the memory cache.
285
486
  * Called on server startup so the frontend shows existing logs.
@@ -359,6 +560,206 @@ export function clearAllLogs(): { cleared: number } {
359
560
  return { cleared: count };
360
561
  }
361
562
 
563
+ async function deleteMatchingFiles(
564
+ dir: string,
565
+ shouldDelete: (fileName: string) => boolean,
566
+ ): Promise<number> {
567
+ if (!existsSync(dir)) return 0;
568
+
569
+ let deleted = 0;
570
+ let entries: string[];
571
+ try {
572
+ entries = await readdirCallback(dir);
573
+ } catch (err) {
574
+ logger.warn("[store] Failed to list log storage directory:", String(err));
575
+ return deleted;
576
+ }
577
+
578
+ for (const entry of entries) {
579
+ if (!shouldDelete(entry)) continue;
580
+ try {
581
+ await unlink(join(dir, entry));
582
+ deleted += 1;
583
+ } catch (err) {
584
+ logger.warn("[store] Failed to delete log storage file:", entry, String(err));
585
+ }
586
+ }
587
+ return deleted;
588
+ }
589
+
590
+ export async function clearPersistedLogStorage(): Promise<ClearPersistedLogStorageResult> {
591
+ return await runWithFlushedLogWriteLock(async () => {
592
+ await flushIndex();
593
+
594
+ const result = clearAllLogs();
595
+ const [logFilesDeleted, chunkFilesDeleted] = await Promise.all([
596
+ deleteMatchingFiles(
597
+ resolveLogDir(),
598
+ (fileName) => fileName.endsWith(".jsonl") || fileName === "logs.idx",
599
+ ),
600
+ deleteMatchingFiles(
601
+ getChunksDir(),
602
+ (fileName) =>
603
+ fileName.endsWith(".json") || (fileName.startsWith(".") && fileName.endsWith(".tmp")),
604
+ ),
605
+ ]);
606
+
607
+ await rebuildIndex();
608
+
609
+ return {
610
+ cleared: result.cleared,
611
+ logFilesDeleted,
612
+ chunkFilesDeleted,
613
+ };
614
+ });
615
+ }
616
+
617
+ function readLogIdFromLine(line: string): number | null {
618
+ if (line.trim() === "") return null;
619
+ try {
620
+ const parsed: unknown = JSON.parse(line);
621
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
622
+ const idDesc = Object.getOwnPropertyDescriptor(parsed, "id");
623
+ if (idDesc === undefined || typeof idDesc.value !== "number") return null;
624
+ return idDesc.value;
625
+ } catch {
626
+ return null;
627
+ }
628
+ }
629
+
630
+ type RewriteLogFileResult = {
631
+ rewritten: boolean;
632
+ removedIds: Set<number>;
633
+ };
634
+
635
+ async function rewriteLogFileWithoutIds(
636
+ filePath: string,
637
+ ids: ReadonlySet<number>,
638
+ ): Promise<RewriteLogFileResult> {
639
+ let content: string;
640
+ try {
641
+ content = await readFile(filePath, "utf-8");
642
+ } catch (err) {
643
+ logger.warn("[store] Failed to read log file for deletion:", filePath, String(err));
644
+ return { rewritten: false, removedIds: new Set() };
645
+ }
646
+
647
+ const lines = content.split("\n");
648
+ const keptLines: string[] = [];
649
+ const removedIds = new Set<number>();
650
+ for (const line of lines) {
651
+ if (line === "") continue;
652
+ const id = readLogIdFromLine(line);
653
+ if (id !== null && ids.has(id)) {
654
+ removedIds.add(id);
655
+ } else {
656
+ keptLines.push(line);
657
+ }
658
+ }
659
+
660
+ if (removedIds.size === 0) return { rewritten: false, removedIds };
661
+
662
+ try {
663
+ if (keptLines.length === 0) {
664
+ await unlink(filePath);
665
+ } else {
666
+ await writeFile(filePath, `${keptLines.join("\n")}\n`, "utf-8");
667
+ }
668
+ } catch (err) {
669
+ logger.warn("[store] Failed to rewrite log file after deletion:", filePath, String(err));
670
+ return { rewritten: false, removedIds: new Set() };
671
+ }
672
+
673
+ return { rewritten: true, removedIds };
674
+ }
675
+
676
+ type RewriteLogFilesResult = {
677
+ logFilesRewritten: number;
678
+ removedIds: Set<number>;
679
+ };
680
+
681
+ async function rewriteLogFilesWithoutIds(ids: ReadonlySet<number>): Promise<RewriteLogFilesResult> {
682
+ const logDir = resolveLogDir();
683
+ if (!existsSync(logDir)) return { logFilesRewritten: 0, removedIds: new Set() };
684
+
685
+ let entries: string[];
686
+ try {
687
+ entries = await readdirCallback(logDir);
688
+ } catch (err) {
689
+ logger.warn("[store] Failed to list log directory for deletion:", String(err));
690
+ return { logFilesRewritten: 0, removedIds: new Set() };
691
+ }
692
+
693
+ let logFilesRewritten = 0;
694
+ const removedIds = new Set<number>();
695
+ for (const entry of entries) {
696
+ if (!entry.endsWith(".jsonl")) continue;
697
+ const result = await rewriteLogFileWithoutIds(join(logDir, entry), ids);
698
+ if (result.rewritten) logFilesRewritten += 1;
699
+ for (const removedId of result.removedIds) {
700
+ removedIds.add(removedId);
701
+ }
702
+ }
703
+ return { logFilesRewritten, removedIds };
704
+ }
705
+
706
+ async function deleteChunkFilesByIds(ids: ReadonlySet<number>): Promise<number> {
707
+ const chunkDir = getChunksDir();
708
+ if (!existsSync(chunkDir)) return 0;
709
+
710
+ let deleted = 0;
711
+ for (const id of ids) {
712
+ const candidates = [`${String(id)}.json`, `.${String(id)}.tmp`];
713
+ for (const candidate of candidates) {
714
+ try {
715
+ await unlink(join(chunkDir, candidate));
716
+ deleted += 1;
717
+ } catch {
718
+ // Missing chunk files are expected for non-streaming logs.
719
+ }
720
+ }
721
+ }
722
+ return deleted;
723
+ }
724
+
725
+ export async function clearPersistedLogsByIds(
726
+ ids: readonly number[],
727
+ ): Promise<ClearPersistedLogsByIdsResult> {
728
+ const uniqueIds = new Set(ids);
729
+ if (uniqueIds.size === 0) {
730
+ return { cleared: 0, logFilesRewritten: 0, chunkFilesDeleted: 0 };
731
+ }
732
+
733
+ return await runWithFlushedLogWriteLock(async () => {
734
+ await flushIndex();
735
+
736
+ const memoryRemovedIds = new Set<number>();
737
+ for (const id of uniqueIds) {
738
+ if (memoryCache.has(id)) memoryRemovedIds.add(id);
739
+ }
740
+ const result = clearLogsByIds([...uniqueIds]);
741
+ const [rewriteResult, chunkFilesDeleted] = await Promise.all([
742
+ rewriteLogFilesWithoutIds(uniqueIds),
743
+ deleteChunkFilesByIds(uniqueIds),
744
+ ]);
745
+
746
+ await rebuildIndex();
747
+
748
+ const clearedIds = new Set<number>(rewriteResult.removedIds);
749
+ if (result.cleared > 0) {
750
+ for (const id of memoryRemovedIds) {
751
+ clearedIds.add(id);
752
+ }
753
+ }
754
+
755
+ return {
756
+ cleared: clearedIds.size,
757
+ logFilesRewritten: rewriteResult.logFilesRewritten,
758
+ chunkFilesDeleted,
759
+ };
760
+ });
761
+ }
762
+
362
763
  /**
363
764
  * Remove a specific set of log IDs from the in-memory cache. Returns the
364
765
  * number of logs actually removed. IDs not present in the cache are ignored.