@tonyclaw/agent-inspector 2.1.3 → 2.1.5

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 (59) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-BLYcm6Dg.js → CompareDrawer-SAn2KFl2.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-DaiTFf7n.js +117 -0
  4. package/.output/public/assets/{ReplayDialog-_kCy9L9E.js → ReplayDialog-CCwz0YUV.js} +1 -1
  5. package/.output/public/assets/{RequestAnatomy-DWp5pWsv.js → RequestAnatomy-DXcC4ydl.js} +1 -1
  6. package/.output/public/assets/{ResponseView-Ci92o6mt.js → ResponseView-Bh9t9aZF.js} +1 -1
  7. package/.output/public/assets/{StreamingChunkSequence-C-1ZQl_b.js → StreamingChunkSequence-B69M2rr-.js} +1 -1
  8. package/.output/public/assets/_sessionId-6fF5JpyQ.js +1 -0
  9. package/.output/public/assets/index-Bcq8bZoK.css +1 -0
  10. package/.output/public/assets/index-DBSbw8Gd.js +1 -0
  11. package/.output/public/assets/{main-RgLvmIQk.js → main-DZVKUeg6.js} +2 -2
  12. package/.output/server/_libs/lucide-react.mjs +204 -173
  13. package/.output/server/_libs/radix-ui__react-dialog.mjs +2 -0
  14. package/.output/server/{_sessionId-DnOYbKO7.mjs → _sessionId-COVUf_EG.mjs} +3 -3
  15. package/.output/server/_ssr/{CompareDrawer-D44_F13_.mjs → CompareDrawer-DqQHO4tk.mjs} +3 -3
  16. package/.output/server/_ssr/{ProxyViewerContainer-Cx4p_awW.mjs → ProxyViewerContainer-BEeF4V-C.mjs} +1006 -75
  17. package/.output/server/_ssr/{ReplayDialog-wScCF3Oc.mjs → ReplayDialog-DUZbsd5x.mjs} +4 -4
  18. package/.output/server/_ssr/{RequestAnatomy-Cf7R7PXS.mjs → RequestAnatomy-DKuIxoBj.mjs} +2 -2
  19. package/.output/server/_ssr/{ResponseView-7pVIZLL-.mjs → ResponseView-BB9CYmTJ.mjs} +3 -3
  20. package/.output/server/_ssr/{StreamingChunkSequence-Bs57_GbC.mjs → StreamingChunkSequence-CkY3uTjF.mjs} +3 -3
  21. package/.output/server/_ssr/{index-6eY__la9.mjs → index-CHTchkrO.mjs} +2 -2
  22. package/.output/server/_ssr/index.mjs +2 -2
  23. package/.output/server/_ssr/{router-DRZPhZB6.mjs → router-C8DJ5pQZ.mjs} +1972 -221
  24. package/.output/server/_tanstack-start-manifest_v-Z1ffcxDr.mjs +4 -0
  25. package/.output/server/index.mjs +68 -68
  26. package/package.json +2 -1
  27. package/src/components/ProxyViewer.tsx +87 -1
  28. package/src/components/ProxyViewerContainer.tsx +26 -0
  29. package/src/components/alerts/AlertsDialog.tsx +610 -0
  30. package/src/components/groups/GroupsDialog.tsx +41 -13
  31. package/src/components/proxy-viewer/LogEntry.tsx +324 -51
  32. package/src/components/ui/dialog.tsx +1 -0
  33. package/src/contracts/index.ts +15 -2
  34. package/src/contracts/log.ts +18 -0
  35. package/src/lib/alertContract.ts +79 -0
  36. package/src/lib/apiClient.ts +39 -0
  37. package/src/lib/export-logs.ts +47 -9
  38. package/src/lib/groupContract.ts +1 -0
  39. package/src/lib/logImportContract.ts +12 -0
  40. package/src/lib/useAlerts.ts +82 -0
  41. package/src/lib/useGroupEvidence.ts +6 -2
  42. package/src/lib/useGroups.ts +6 -2
  43. package/src/proxy/alerts.ts +664 -0
  44. package/src/proxy/logBodyChunks.ts +91 -0
  45. package/src/proxy/logImporter.ts +491 -0
  46. package/src/proxy/logIndex.ts +63 -2
  47. package/src/proxy/sqliteLogIndex.ts +612 -0
  48. package/src/proxy/store.ts +32 -7
  49. package/src/routes/api/alerts.summary.ts +24 -0
  50. package/src/routes/api/alerts.ts +52 -0
  51. package/src/routes/api/logs.$id.body.ts +44 -0
  52. package/src/routes/api/logs.import.ts +50 -0
  53. package/src/services/alerts.ts +12 -0
  54. package/src/services/groups.ts +1 -0
  55. package/.output/public/assets/ProxyViewerContainer-Ba5wFn00.js +0 -117
  56. package/.output/public/assets/_sessionId-DKzAmSNP.js +0 -1
  57. package/.output/public/assets/index-B-MZ9lQM.css +0 -1
  58. package/.output/public/assets/index-ZZHwgkWJ.js +0 -1
  59. package/.output/server/_tanstack-start-manifest_v-DfAVFkZ5.mjs +0 -4
@@ -0,0 +1,91 @@
1
+ import { Buffer } from "node:buffer";
2
+ import type { CapturedLog, LogBodyChunk, LogBodyPart } from "../contracts";
3
+
4
+ export const DEFAULT_LOG_BODY_CHUNK_BYTES = 256 * 1024;
5
+ export const MAX_LOG_BODY_CHUNK_BYTES = 1024 * 1024;
6
+
7
+ export function normalizeLogBodyChunkOffset(offset: number): number {
8
+ if (!Number.isInteger(offset) || offset < 0) return 0;
9
+ return offset;
10
+ }
11
+
12
+ export function normalizeLogBodyChunkLimit(limit: number): number {
13
+ if (!Number.isInteger(limit) || limit < 1) return DEFAULT_LOG_BODY_CHUNK_BYTES;
14
+ return Math.min(limit, MAX_LOG_BODY_CHUNK_BYTES);
15
+ }
16
+
17
+ function bodyTextForPart(log: CapturedLog, part: LogBodyPart): string | null {
18
+ switch (part) {
19
+ case "request":
20
+ return log.rawRequestBody;
21
+ case "response":
22
+ return log.responseText;
23
+ }
24
+ }
25
+
26
+ function isUtf8ContinuationByte(byte: number | undefined): boolean {
27
+ return byte !== undefined && (byte & 0b1100_0000) === 0b1000_0000;
28
+ }
29
+
30
+ function expandToUtf8Boundary(buffer: Buffer, end: number): number {
31
+ if (end >= buffer.byteLength) return buffer.byteLength;
32
+ let safeEnd = end;
33
+ while (safeEnd < buffer.byteLength && isUtf8ContinuationByte(buffer[safeEnd])) {
34
+ safeEnd++;
35
+ }
36
+ return safeEnd;
37
+ }
38
+
39
+ export function createLogBodyChunk({
40
+ log,
41
+ part,
42
+ offset,
43
+ limit,
44
+ }: {
45
+ log: CapturedLog;
46
+ part: LogBodyPart;
47
+ offset: number;
48
+ limit: number;
49
+ }): LogBodyChunk {
50
+ const text = bodyTextForPart(log, part);
51
+ const normalizedLimit = normalizeLogBodyChunkLimit(limit);
52
+ if (text === null || text === "") {
53
+ return {
54
+ logId: log.id,
55
+ part,
56
+ text: "",
57
+ offset: 0,
58
+ limit: normalizedLimit,
59
+ totalBytes: 0,
60
+ textBytes: 0,
61
+ nextOffset: null,
62
+ hasMore: false,
63
+ contentMode: "empty",
64
+ };
65
+ }
66
+
67
+ const bodyBytes = Buffer.from(text, "utf8");
68
+ const totalBytes = bodyBytes.byteLength;
69
+ const normalizedOffset = Math.min(normalizeLogBodyChunkOffset(offset), totalBytes);
70
+ const end = expandToUtf8Boundary(
71
+ bodyBytes,
72
+ Math.min(normalizedOffset + normalizedLimit, totalBytes),
73
+ );
74
+ const chunkText = bodyBytes.toString("utf8", normalizedOffset, end);
75
+ const textBytes = end - normalizedOffset;
76
+ const hasMore = end < totalBytes;
77
+ const contentMode = hasMore || normalizedOffset > 0 ? "partial" : "full";
78
+
79
+ return {
80
+ logId: log.id,
81
+ part,
82
+ text: chunkText,
83
+ offset: normalizedOffset,
84
+ limit: normalizedLimit,
85
+ totalBytes,
86
+ textBytes,
87
+ nextOffset: hasMore ? end : null,
88
+ hasMore,
89
+ contentMode,
90
+ };
91
+ }
@@ -0,0 +1,491 @@
1
+ import { basename } from "node:path";
2
+ import JSZip from "jszip";
3
+ import { z } from "zod";
4
+ import {
5
+ CapturedLogSchema,
6
+ StreamingChunkSchema,
7
+ type ApiFormat,
8
+ type CapturedLog,
9
+ type StreamingChunk,
10
+ } from "../contracts";
11
+ import packageJson from "../../package.json";
12
+ import { apiFormatForPath } from "./formats";
13
+ import { analyzeToolSchemaWarnings } from "./toolSchemaWarnings";
14
+
15
+ export type ImportedLogDraft = Omit<CapturedLog, "id">;
16
+
17
+ export type ParsedLogImport =
18
+ | {
19
+ ok: true;
20
+ logs: ImportedLogDraft[];
21
+ skipped: number;
22
+ warnings: string[];
23
+ }
24
+ | {
25
+ ok: false;
26
+ message: string;
27
+ warnings: string[];
28
+ };
29
+
30
+ const ManifestSchema = z
31
+ .object({
32
+ version: z.string().optional(),
33
+ exportedAt: z.string().optional(),
34
+ mode: z.string().optional(),
35
+ redacted: z.boolean().optional(),
36
+ logIds: z.array(z.number().int().positive()).optional(),
37
+ })
38
+ .passthrough();
39
+
40
+ const LogsArraySchema = z.array(CapturedLogSchema);
41
+ const LogsEnvelopeSchema = z.object({ logs: z.array(CapturedLogSchema) }).passthrough();
42
+ const StreamingChunksImportSchema = z.object({
43
+ chunks: z.array(StreamingChunkSchema),
44
+ truncated: z.boolean().optional().default(false),
45
+ });
46
+
47
+ type ParsedManifest = z.infer<typeof ManifestSchema>;
48
+
49
+ const SEMVER_MAJOR_PATTERN = /^([0-9]+)\.[0-9]+\.[0-9]+(?:[-+].*)?$/;
50
+
51
+ function readRecordProperty(value: unknown, name: string): unknown {
52
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
53
+ const desc = Object.getOwnPropertyDescriptor(value, name);
54
+ return desc === undefined ? undefined : desc.value;
55
+ }
56
+
57
+ function parseJson(text: string | null): unknown {
58
+ if (text === null) return null;
59
+ try {
60
+ const parsed: unknown = JSON.parse(text);
61
+ return parsed;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function parseJsonLogs(text: string): CapturedLog[] | null {
68
+ const parsed = parseJson(text);
69
+ const direct = LogsArraySchema.safeParse(parsed);
70
+ if (direct.success) return direct.data;
71
+
72
+ const envelope = LogsEnvelopeSchema.safeParse(parsed);
73
+ if (envelope.success) return envelope.data.logs;
74
+
75
+ return null;
76
+ }
77
+
78
+ function parseJsonlLogs(text: string): { logs: CapturedLog[]; skipped: number } {
79
+ const logs: CapturedLog[] = [];
80
+ let skipped = 0;
81
+ for (const line of text.split(/\r?\n/)) {
82
+ if (line.trim() === "") continue;
83
+ const parsed = CapturedLogSchema.safeParse(parseJson(line));
84
+ if (parsed.success) {
85
+ logs.push(parsed.data);
86
+ } else {
87
+ skipped += 1;
88
+ }
89
+ }
90
+ return { logs, skipped };
91
+ }
92
+
93
+ function readStringProperty(value: unknown, name: string): string | null {
94
+ const property = readRecordProperty(value, name);
95
+ return typeof property === "string" ? property : null;
96
+ }
97
+
98
+ function hasProperty(value: unknown, name: string): boolean {
99
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
100
+ return Object.getOwnPropertyDescriptor(value, name) !== undefined;
101
+ }
102
+
103
+ function modelFromRequestText(requestText: string | null): string | null {
104
+ return readStringProperty(parseJson(requestText), "model");
105
+ }
106
+
107
+ function inferApiFormatFromBody(
108
+ path: string,
109
+ model: string | null,
110
+ requestText: string | null,
111
+ ): ApiFormat {
112
+ const pathFormat = apiFormatForPath(path);
113
+ if (pathFormat !== "unknown") return pathFormat;
114
+
115
+ const normalizedModel = model?.toLowerCase() ?? "";
116
+ if (normalizedModel.startsWith("claude-")) return "anthropic";
117
+ if (
118
+ normalizedModel.startsWith("gpt-") ||
119
+ normalizedModel.startsWith("o1-") ||
120
+ normalizedModel.startsWith("o3-") ||
121
+ normalizedModel.startsWith("o4-") ||
122
+ normalizedModel.startsWith("deepseek-") ||
123
+ normalizedModel.startsWith("glm-") ||
124
+ normalizedModel.startsWith("qwen")
125
+ ) {
126
+ return "openai";
127
+ }
128
+
129
+ const parsedRequest = parseJson(requestText);
130
+ if (hasProperty(parsedRequest, "system") || hasProperty(parsedRequest, "max_tokens")) {
131
+ return "anthropic";
132
+ }
133
+ return "openai";
134
+ }
135
+
136
+ function defaultPathForApiFormat(apiFormat: ApiFormat): string {
137
+ switch (apiFormat) {
138
+ case "anthropic":
139
+ return "/v1/messages";
140
+ case "openai":
141
+ return "/v1/chat/completions";
142
+ case "unknown":
143
+ return "/v1/chat/completions";
144
+ }
145
+ }
146
+
147
+ function normalizeTimestamp(value: string, fallback: string): string {
148
+ return Number.isNaN(new Date(value).getTime()) ? fallback : value;
149
+ }
150
+
151
+ function responseStatusFromText(responseText: string | null): number | null {
152
+ if (responseText === null) return null;
153
+ const parsed = parseJson(responseText);
154
+ return hasProperty(parsed, "error") ? 500 : 200;
155
+ }
156
+
157
+ function mergeWarnings(
158
+ existing: readonly string[] | undefined,
159
+ generated: readonly string[],
160
+ ): string[] | undefined {
161
+ const values: string[] = [];
162
+ for (const warning of existing ?? []) {
163
+ if (!values.includes(warning)) values.push(warning);
164
+ }
165
+ for (const warning of generated) {
166
+ if (!values.includes(warning)) values.push(warning);
167
+ }
168
+ return values.length === 0 ? undefined : values;
169
+ }
170
+
171
+ function withToolWarnings(log: ImportedLogDraft): ImportedLogDraft {
172
+ const warnings = analyzeToolSchemaWarnings({
173
+ rawRequestBody: log.rawRequestBody,
174
+ responseText: log.responseText,
175
+ apiFormat: log.apiFormat,
176
+ });
177
+ return {
178
+ ...log,
179
+ warnings: mergeWarnings(log.warnings, warnings),
180
+ };
181
+ }
182
+
183
+ function streamingChunksFromText(text: string | null):
184
+ | {
185
+ chunks: StreamingChunk[];
186
+ truncated: boolean;
187
+ }
188
+ | undefined {
189
+ const parsed = StreamingChunksImportSchema.safeParse(parseJson(text));
190
+ return parsed.success ? parsed.data : undefined;
191
+ }
192
+
193
+ function draftFromCapturedLog(
194
+ log: CapturedLog,
195
+ requestText: string | null,
196
+ responseText: string | null,
197
+ streamingChunks: { chunks: StreamingChunk[]; truncated: boolean } | undefined,
198
+ ): ImportedLogDraft {
199
+ const rawRequestBody = requestText ?? log.rawRequestBody;
200
+ const response = responseText ?? log.responseText;
201
+ const model = log.model ?? modelFromRequestText(rawRequestBody);
202
+ const apiFormat =
203
+ log.apiFormat === "unknown"
204
+ ? inferApiFormatFromBody(log.path, model, rawRequestBody)
205
+ : log.apiFormat;
206
+ const path = log.path.trim() === "" ? defaultPathForApiFormat(apiFormat) : log.path;
207
+
208
+ return withToolWarnings({
209
+ timestamp: normalizeTimestamp(log.timestamp, new Date().toISOString()),
210
+ method: log.method.trim() === "" ? "POST" : log.method,
211
+ path,
212
+ model,
213
+ sessionId: null,
214
+ rawRequestBody,
215
+ responseStatus: log.responseStatus ?? responseStatusFromText(response),
216
+ responseText: response,
217
+ inputTokens: log.inputTokens,
218
+ outputTokens: log.outputTokens,
219
+ cacheCreationInputTokens: log.cacheCreationInputTokens,
220
+ cacheReadInputTokens: log.cacheReadInputTokens,
221
+ elapsedMs: log.elapsedMs,
222
+ firstChunkMs: log.firstChunkMs ?? null,
223
+ totalStreamMs: log.totalStreamMs ?? null,
224
+ tokensPerSecond: log.tokensPerSecond ?? null,
225
+ streaming: log.streaming || streamingChunks !== undefined,
226
+ userAgent: log.userAgent,
227
+ origin: log.origin,
228
+ rawHeaders: log.rawHeaders,
229
+ headers: log.headers,
230
+ apiFormat,
231
+ isTest: false,
232
+ replayOfLogId: null,
233
+ providerName: log.providerName ?? null,
234
+ clientPort: log.clientPort ?? null,
235
+ clientPid: log.clientPid ?? null,
236
+ clientCwd: log.clientCwd ?? null,
237
+ clientProjectFolder: log.clientProjectFolder ?? null,
238
+ streamingChunks,
239
+ streamingChunksPath: null,
240
+ rawRequestBodyBytes: log.rawRequestBodyBytes,
241
+ responseTextBytes: log.responseTextBytes,
242
+ bodyContentMode: "full",
243
+ warnings: log.warnings,
244
+ error: log.error ?? null,
245
+ });
246
+ }
247
+
248
+ function legacyDraftFromParts(input: {
249
+ exportedAt: string;
250
+ requestText: string | null;
251
+ responseText: string | null;
252
+ streamingChunks: { chunks: StreamingChunk[]; truncated: boolean } | undefined;
253
+ }): ImportedLogDraft {
254
+ const model = modelFromRequestText(input.requestText);
255
+ const apiFormat = inferApiFormatFromBody("", model, input.requestText);
256
+ return withToolWarnings({
257
+ timestamp: input.exportedAt,
258
+ method: "POST",
259
+ path: defaultPathForApiFormat(apiFormat),
260
+ model,
261
+ sessionId: null,
262
+ rawRequestBody: input.requestText,
263
+ responseStatus: responseStatusFromText(input.responseText),
264
+ responseText: input.responseText,
265
+ inputTokens: null,
266
+ outputTokens: null,
267
+ cacheCreationInputTokens: null,
268
+ cacheReadInputTokens: null,
269
+ elapsedMs: null,
270
+ firstChunkMs: null,
271
+ totalStreamMs: null,
272
+ tokensPerSecond: null,
273
+ streaming: input.streamingChunks !== undefined,
274
+ userAgent: "agent-inspector-import",
275
+ origin: null,
276
+ apiFormat,
277
+ isTest: false,
278
+ replayOfLogId: null,
279
+ providerName: null,
280
+ clientPort: null,
281
+ clientPid: null,
282
+ clientCwd: null,
283
+ clientProjectFolder: null,
284
+ streamingChunks: input.streamingChunks,
285
+ streamingChunksPath: null,
286
+ bodyContentMode: "full",
287
+ error: null,
288
+ });
289
+ }
290
+
291
+ async function readZipText(zip: JSZip, path: string): Promise<string | null> {
292
+ const file = zip.file(path);
293
+ return file === null ? null : await file.async("text");
294
+ }
295
+
296
+ async function readManifest(zip: JSZip): Promise<ParsedManifest | null> {
297
+ const text = await readZipText(zip, "manifest.json");
298
+ const parsed = ManifestSchema.safeParse(parseJson(text));
299
+ return parsed.success ? parsed.data : null;
300
+ }
301
+
302
+ function versionMajor(version: string): number | null {
303
+ const match = SEMVER_MAJOR_PATTERN.exec(version);
304
+ if (match === null) return null;
305
+ const major = match[1];
306
+ return major === undefined ? null : Number.parseInt(major, 10);
307
+ }
308
+
309
+ function validateManifestVersion(manifest: ParsedManifest | null):
310
+ | {
311
+ ok: true;
312
+ warning: string | null;
313
+ }
314
+ | {
315
+ ok: false;
316
+ message: string;
317
+ } {
318
+ const exportedVersion = manifest?.version;
319
+ if (exportedVersion === undefined) {
320
+ return {
321
+ ok: true,
322
+ warning:
323
+ "Imported legacy export without version metadata. Compatibility could not be verified.",
324
+ };
325
+ }
326
+
327
+ const exportedMajor = versionMajor(exportedVersion);
328
+ if (exportedMajor === null) {
329
+ return {
330
+ ok: false,
331
+ message: `Unsupported export version "${exportedVersion}".`,
332
+ };
333
+ }
334
+
335
+ const currentMajor = versionMajor(packageJson.version);
336
+ if (currentMajor === null) {
337
+ return {
338
+ ok: true,
339
+ warning: `Current Agent Inspector version "${packageJson.version}" could not be verified against export version "${exportedVersion}".`,
340
+ };
341
+ }
342
+
343
+ if (exportedMajor !== currentMajor) {
344
+ return {
345
+ ok: false,
346
+ message: `Export version mismatch: archive was created by Agent Inspector v${exportedVersion}, current version is v${packageJson.version}. Import with a matching major version.`,
347
+ };
348
+ }
349
+
350
+ if (exportedVersion !== packageJson.version) {
351
+ return {
352
+ ok: true,
353
+ warning: `Imported export from Agent Inspector v${exportedVersion}; current version is v${packageJson.version}.`,
354
+ };
355
+ }
356
+
357
+ return { ok: true, warning: null };
358
+ }
359
+
360
+ async function parseZipLogImport(data: Uint8Array): Promise<ParsedLogImport> {
361
+ const warnings: string[] = [];
362
+ const zip = await JSZip.loadAsync(data);
363
+ const manifest = await readManifest(zip);
364
+ const versionCheck = validateManifestVersion(manifest);
365
+ if (!versionCheck.ok) {
366
+ return {
367
+ ok: false,
368
+ message: versionCheck.message,
369
+ warnings,
370
+ };
371
+ }
372
+ if (versionCheck.warning !== null) {
373
+ warnings.push(versionCheck.warning);
374
+ }
375
+
376
+ const exportedAt = normalizeTimestamp(manifest?.exportedAt ?? "", new Date().toISOString());
377
+ if (manifest?.redacted === true) {
378
+ warnings.push("Imported redacted export. Secrets and some replay details may be unavailable.");
379
+ }
380
+
381
+ const metadataText = await readZipText(zip, "logs.json");
382
+ const metadataLogs = metadataText === null ? null : parseJsonLogs(metadataText);
383
+ const logs: ImportedLogDraft[] = [];
384
+ let skipped = 0;
385
+
386
+ if (metadataLogs !== null) {
387
+ for (const log of metadataLogs) {
388
+ const requestText = await readZipText(zip, `#${String(log.id)}.Request.json`);
389
+ const responseText = await readZipText(zip, `#${String(log.id)}.Response.json`);
390
+ const streamingText = await readZipText(zip, `#${String(log.id)}.SSE.Response.json`);
391
+ logs.push(
392
+ draftFromCapturedLog(
393
+ log,
394
+ requestText,
395
+ responseText,
396
+ streamingChunksFromText(streamingText),
397
+ ),
398
+ );
399
+ }
400
+ return { ok: true, logs, skipped, warnings };
401
+ }
402
+
403
+ for (const originalId of manifest?.logIds ?? []) {
404
+ const requestText = await readZipText(zip, `#${String(originalId)}.Request.json`);
405
+ const responseText = await readZipText(zip, `#${String(originalId)}.Response.json`);
406
+ const streamingText = await readZipText(zip, `#${String(originalId)}.SSE.Response.json`);
407
+ if (requestText === null && responseText === null && streamingText === null) {
408
+ skipped += 1;
409
+ continue;
410
+ }
411
+ logs.push(
412
+ legacyDraftFromParts({
413
+ exportedAt,
414
+ requestText,
415
+ responseText,
416
+ streamingChunks: streamingChunksFromText(streamingText),
417
+ }),
418
+ );
419
+ }
420
+
421
+ if (logs.length === 0) {
422
+ return {
423
+ ok: false,
424
+ message: "No importable logs were found in the archive.",
425
+ warnings,
426
+ };
427
+ }
428
+
429
+ return { ok: true, logs, skipped, warnings };
430
+ }
431
+
432
+ function looksLikeZip(data: Uint8Array, fileName: string): boolean {
433
+ if (fileName.toLowerCase().endsWith(".zip")) return true;
434
+ return data[0] === 0x50 && data[1] === 0x4b;
435
+ }
436
+
437
+ function parseTextLogImport(text: string): ParsedLogImport {
438
+ const jsonLogs = parseJsonLogs(text);
439
+ if (jsonLogs !== null) {
440
+ return {
441
+ ok: true,
442
+ logs: jsonLogs.map((log) => draftFromCapturedLog(log, null, null, undefined)),
443
+ skipped: 0,
444
+ warnings: [],
445
+ };
446
+ }
447
+
448
+ const jsonl = parseJsonlLogs(text);
449
+ if (jsonl.logs.length > 0) {
450
+ return {
451
+ ok: true,
452
+ logs: jsonl.logs.map((log) => draftFromCapturedLog(log, null, null, undefined)),
453
+ skipped: jsonl.skipped,
454
+ warnings: [],
455
+ };
456
+ }
457
+
458
+ return {
459
+ ok: false,
460
+ message:
461
+ "Unsupported import file. Use an Agent Inspector export ZIP, logs.json, or JSONL file.",
462
+ warnings: [],
463
+ };
464
+ }
465
+
466
+ export async function parseImportedLogFile(input: {
467
+ fileName: string;
468
+ data: Uint8Array;
469
+ }): Promise<ParsedLogImport> {
470
+ try {
471
+ if (looksLikeZip(input.data, input.fileName)) {
472
+ return await parseZipLogImport(input.data);
473
+ }
474
+
475
+ return parseTextLogImport(new TextDecoder("utf-8").decode(input.data));
476
+ } catch (err) {
477
+ return {
478
+ ok: false,
479
+ message: err instanceof Error ? err.message : "Failed to parse import file.",
480
+ warnings: [],
481
+ };
482
+ }
483
+ }
484
+
485
+ export function sessionNameFromImportFileName(fileName: string): string {
486
+ const normalized = fileName.replaceAll("\\", "/");
487
+ const base = basename(normalized)
488
+ .replace(/\.(zip|json|jsonl)$/i, "")
489
+ .trim();
490
+ return base === "" ? `import-${new Date().toISOString().slice(0, 10)}` : base;
491
+ }
@@ -5,6 +5,14 @@ import { createInterface } from "node:readline";
5
5
  import { Buffer } from "node:buffer";
6
6
  import { resolveLogDir, logger } from "./logger";
7
7
  import type { ApiFormat, CapturedLog } from "./schemas";
8
+ import {
9
+ findSqliteLogIndexEntry,
10
+ getSqliteLogIndexMaxId,
11
+ listSqliteLogIndexEntries,
12
+ replaceSqliteLogIndexEntries,
13
+ syncSqliteLogIndexEntries,
14
+ upsertSqliteLogIndexEntry,
15
+ } from "./sqliteLogIndex";
8
16
 
9
17
  export type LogIndexSummary = {
10
18
  id: number;
@@ -72,6 +80,7 @@ function getIndexPath(): string {
72
80
  }
73
81
 
74
82
  let cachedIndex: LogIndex | null = null;
83
+ let sqliteBackfillScheduledMaxId = 0;
75
84
 
76
85
  function textByteLength(value: string | null): number | null {
77
86
  return value === null ? null : Buffer.byteLength(value, "utf8");
@@ -191,13 +200,15 @@ export async function addToIndex(
191
200
  metadata?: LogIndexEntryMetadata,
192
201
  ): Promise<void> {
193
202
  const index = await loadIndex();
194
- index.entries[id] =
203
+ const entry: LogIndexEntry =
195
204
  metadata === undefined
196
205
  ? { id, file, byteOffset, byteLength }
197
206
  : { id, file, byteOffset, byteLength, ...metadata };
207
+ index.entries[id] = entry;
198
208
  if (id > index.maxId) {
199
209
  index.maxId = id;
200
210
  }
211
+ void upsertSqliteLogIndexEntry(entry);
201
212
  // Defer disk writes to reduce I/O - flush after a batch of updates
202
213
  scheduleIndexFlush();
203
214
  }
@@ -232,15 +243,62 @@ export async function flushIndex(): Promise<void> {
232
243
  }
233
244
 
234
245
  export async function findInIndex(id: number): Promise<LogIndexEntry | null> {
246
+ const sqliteEntry = await findSqliteLogIndexEntry(id);
247
+ if (sqliteEntry !== null) return sqliteEntry;
248
+
235
249
  const index = await loadIndex();
236
250
  return index.entries[id] ?? null;
237
251
  }
238
252
 
239
253
  export async function listIndexEntries(): Promise<LogIndexEntry[]> {
240
254
  const index = await loadIndex();
255
+ const sqliteMaxId = await getSqliteLogIndexMaxId();
256
+ if (sqliteMaxId !== null && sqliteMaxId >= index.maxId) {
257
+ const sqliteEntries = await listSqliteLogIndexEntries();
258
+ if (sqliteEntries !== null) return sqliteEntries;
259
+ }
260
+ maybeBackfillSqliteIndex(index, sqliteMaxId);
241
261
  return Object.values(index.entries).sort((left, right) => left.id - right.id);
242
262
  }
243
263
 
264
+ export async function listFilteredIndexEntries({
265
+ sessionId,
266
+ model,
267
+ }: {
268
+ sessionId?: string;
269
+ model?: string;
270
+ }): Promise<LogIndexEntry[]> {
271
+ const index = await loadIndex();
272
+ const sqliteMaxId = await getSqliteLogIndexMaxId();
273
+ if (sqliteMaxId !== null && sqliteMaxId >= index.maxId) {
274
+ const sqliteEntries = await listSqliteLogIndexEntries({ sessionId, model });
275
+ if (sqliteEntries !== null) return sqliteEntries;
276
+ }
277
+ maybeBackfillSqliteIndex(index, sqliteMaxId);
278
+
279
+ return Object.values(index.entries)
280
+ .sort((left, right) => left.id - right.id)
281
+ .filter((entry) => {
282
+ if (sessionId !== undefined && entry.sessionId !== sessionId) return false;
283
+ if (model !== undefined && entry.model !== model) return false;
284
+ return true;
285
+ });
286
+ }
287
+
288
+ function maybeBackfillSqliteIndex(index: LogIndex, sqliteMaxId: number | null): void {
289
+ if (sqliteMaxId === null) return;
290
+ if (index.maxId <= sqliteMaxId) return;
291
+ if (index.maxId <= sqliteBackfillScheduledMaxId) return;
292
+
293
+ sqliteBackfillScheduledMaxId = index.maxId;
294
+ const entries = Object.values(index.entries);
295
+ void syncSqliteLogIndexEntries(entries).then((ok) => {
296
+ if (!ok && sqliteMaxId < sqliteBackfillScheduledMaxId) {
297
+ sqliteBackfillScheduledMaxId = sqliteMaxId;
298
+ }
299
+ });
300
+ }
301
+
244
302
  type FileIndexResult = {
245
303
  entries: Record<number, LogIndexEntry>;
246
304
  maxId: number;
@@ -386,6 +444,7 @@ export async function rebuildIndex(): Promise<LogIndex> {
386
444
  if (!existsSync(logDir)) {
387
445
  cachedIndex = newIndex;
388
446
  await saveIndex(newIndex);
447
+ await replaceSqliteLogIndexEntries([]);
389
448
  return newIndex;
390
449
  }
391
450
 
@@ -405,6 +464,7 @@ export async function rebuildIndex(): Promise<LogIndex> {
405
464
 
406
465
  cachedIndex = newIndex;
407
466
  await saveIndex(newIndex);
467
+ await replaceSqliteLogIndexEntries(Object.values(newIndex.entries));
408
468
  return newIndex;
409
469
  }
410
470
 
@@ -441,7 +501,8 @@ export async function getNextLogId(): Promise<number> {
441
501
  await acquireLock();
442
502
  try {
443
503
  const index = await loadIndex();
444
- const nextId = index.maxId + 1;
504
+ const sqliteMaxId = await getSqliteLogIndexMaxId();
505
+ const nextId = Math.max(index.maxId, sqliteMaxId ?? 0) + 1;
445
506
  index.maxId = nextId;
446
507
  // Synchronously update the index in memory (disk write is deferred via batching)
447
508
  cachedIndex = index;