@tonyclaw/agent-inspector 2.0.42 → 2.1.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.
Files changed (42) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-Cfqxlo-U.js → CompareDrawer-VDpcSNGM.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-fsXSjqtm.js +117 -0
  4. package/.output/public/assets/{ReplayDialog-daRidZo_.js → ReplayDialog-CoDgTzHR.js} +1 -1
  5. package/.output/public/assets/RequestAnatomy-t3plgkPE.js +1 -0
  6. package/.output/public/assets/ResponseView-BYuKiNeG.js +1 -0
  7. package/.output/public/assets/{StreamingChunkSequence-nK-0LpbM.js → StreamingChunkSequence-DOdU38Is.js} +1 -1
  8. package/.output/public/assets/_sessionId-ew5QlyZ2.js +1 -0
  9. package/.output/public/assets/index-DFICWD6o.js +1 -0
  10. package/.output/public/assets/index-zLLCkAnF.css +1 -0
  11. package/.output/public/assets/{main-DOy_Q96H.js → main-DmzLt6ti.js} +2 -2
  12. package/.output/server/_libs/lucide-react.mjs +219 -178
  13. package/.output/server/{_sessionId-DtYRZHlM.mjs → _sessionId-CCN1UoEf.mjs} +3 -3
  14. package/.output/server/_ssr/{CompareDrawer-fy1ARwtx.mjs → CompareDrawer-DkSY3vxP.mjs} +4 -4
  15. package/.output/server/_ssr/{ProxyViewerContainer-9GJtCWCq.mjs → ProxyViewerContainer-OTh1V_Kh.mjs} +1034 -448
  16. package/.output/server/_ssr/{ReplayDialog-BrS7syOE.mjs → ReplayDialog-DhI6yxrR.mjs} +5 -5
  17. package/.output/server/_ssr/{RequestAnatomy-BtWt1cWY.mjs → RequestAnatomy-DN1SJVVL.mjs} +4 -4
  18. package/.output/server/_ssr/{ResponseView-CkKGYE6m.mjs → ResponseView-DW-kpJL5.mjs} +4 -4
  19. package/.output/server/_ssr/{StreamingChunkSequence-C7rB3osr.mjs → StreamingChunkSequence-BAE57Fae.mjs} +4 -4
  20. package/.output/server/_ssr/{index-D24WforP.mjs → index-C9ryJEna.mjs} +3 -3
  21. package/.output/server/_ssr/index.mjs +2 -2
  22. package/.output/server/_ssr/{router-CVqVjBzJ.mjs → router-DIIJGyML.mjs} +458 -10
  23. package/.output/server/{_tanstack-start-manifest_v-DSikvwru.mjs → _tanstack-start-manifest_v-C_v9-E5d.mjs} +1 -1
  24. package/.output/server/index.mjs +63 -63
  25. package/package.json +1 -1
  26. package/src/components/OnboardingBanner.tsx +74 -68
  27. package/src/components/ProxyViewer.tsx +942 -297
  28. package/src/components/ProxyViewerContainer.tsx +199 -5
  29. package/src/components/groups/GroupsDialog.tsx +11 -10
  30. package/src/components/proxy-viewer/LogEntry.tsx +107 -56
  31. package/src/components/proxy-viewer/LogEntryHeader.tsx +12 -6
  32. package/src/components/ui/crab-logo.tsx +0 -50
  33. package/src/components/ui/dialog.tsx +1 -1
  34. package/src/proxy/logIndex.ts +188 -1
  35. package/src/proxy/store.ts +405 -3
  36. package/src/routes/api/logs.ts +36 -0
  37. package/.output/public/assets/ProxyViewerContainer-CE9pAX4c.js +0 -117
  38. package/.output/public/assets/RequestAnatomy-CJ7EPQGQ.js +0 -1
  39. package/.output/public/assets/ResponseView-xBmr54hB.js +0 -1
  40. package/.output/public/assets/_sessionId-CbWEG5ej.js +0 -1
  41. package/.output/public/assets/index-BfGJEb-2.css +0 -1
  42. package/.output/public/assets/index-D1MkoT4l.js +0 -1
@@ -21,6 +21,7 @@ import {
21
21
  import { getChunksDir } from "./chunkStorage";
22
22
  import {
23
23
  addToIndex,
24
+ createLogIndexEntryMetadata,
24
25
  findInIndex,
25
26
  flushIndex,
26
27
  getNextLogId,
@@ -28,6 +29,7 @@ import {
28
29
  listIndexEntries,
29
30
  rebuildIndex,
30
31
  type LogIndexEntry,
32
+ type LogIndexSummary,
31
33
  } from "./logIndex";
32
34
  import { writeChunks } from "./chunkStorage";
33
35
  import type { SessionInfo, SessionLogSummary } from "../lib/sessionInfoContract";
@@ -92,6 +94,26 @@ export type ListLogsPageResult = {
92
94
  limit: number;
93
95
  };
94
96
 
97
+ export type ListLogsCursorPageOptions = {
98
+ sessionId?: string;
99
+ model?: string;
100
+ limit: number;
101
+ includeBodies?: boolean;
102
+ beforeLogId?: number;
103
+ afterLogId?: number;
104
+ anchor?: "newest" | "oldest";
105
+ };
106
+
107
+ export type ListLogsCursorPageResult = {
108
+ logs: CapturedLog[];
109
+ total: number;
110
+ limit: number;
111
+ hasOlder: boolean;
112
+ hasNewer: boolean;
113
+ oldestLogId: number | null;
114
+ newestLogId: number | null;
115
+ };
116
+
95
117
  export type GetSessionInfoOptions = {
96
118
  baseUrl: string;
97
119
  includeHistory?: boolean;
@@ -100,6 +122,74 @@ export type GetSessionInfoOptions = {
100
122
 
101
123
  // Memory cache: id -> CapturedLog (recent logs only, FIFO eviction)
102
124
  const memoryCache: Map<number, CapturedLog> = new Map();
125
+ const MAX_CURSOR_PAGE_INDEXES = 16;
126
+
127
+ type LogCursorPageIndex = {
128
+ key: string;
129
+ sessionId?: string;
130
+ model?: string;
131
+ ids: number[];
132
+ logsById: Map<number, CapturedLog>;
133
+ entriesById: Map<number, LogIndexEntry>;
134
+ };
135
+
136
+ const logCursorPageIndexes: Map<string, LogCursorPageIndex> = new Map();
137
+ let logCursorPageIndexBuildCount = 0;
138
+
139
+ function cursorPageIndexKey(
140
+ options: Pick<ListLogsCursorPageOptions, "sessionId" | "model">,
141
+ ): string {
142
+ return JSON.stringify({
143
+ sessionId: options.sessionId ?? null,
144
+ model: options.model ?? null,
145
+ });
146
+ }
147
+
148
+ function matchesCursorIndexFilters(log: CapturedLog, index: LogCursorPageIndex): boolean {
149
+ if (index.sessionId !== undefined && getLogSessionId(log) !== index.sessionId) return false;
150
+ if (index.model !== undefined && log.model !== index.model) return false;
151
+ return true;
152
+ }
153
+
154
+ function insertSortedLogId(ids: number[], id: number): void {
155
+ if (ids.includes(id)) return;
156
+ const insertionIndex = ids.findIndex((candidate) => candidate > id);
157
+ if (insertionIndex === -1) {
158
+ ids.push(id);
159
+ return;
160
+ }
161
+ ids.splice(insertionIndex, 0, id);
162
+ }
163
+
164
+ function upsertLogCursorPageIndexes(log: CapturedLog): void {
165
+ for (const index of logCursorPageIndexes.values()) {
166
+ if (!matchesCursorIndexFilters(log, index)) {
167
+ if (index.logsById.delete(log.id)) {
168
+ index.ids = index.ids.filter((id) => id !== log.id);
169
+ }
170
+ index.entriesById.delete(log.id);
171
+ continue;
172
+ }
173
+ index.logsById.set(log.id, log);
174
+ insertSortedLogId(index.ids, log.id);
175
+ }
176
+ }
177
+
178
+ function removeLogsFromCursorPageIndexes(ids: ReadonlySet<number>): void {
179
+ if (ids.size === 0) return;
180
+ for (const index of logCursorPageIndexes.values()) {
181
+ for (const id of ids) {
182
+ index.logsById.delete(id);
183
+ index.entriesById.delete(id);
184
+ }
185
+ index.ids = index.ids.filter((id) => !ids.has(id));
186
+ }
187
+ }
188
+
189
+ function clearLogCursorPageIndexes(): void {
190
+ logCursorPageIndexes.clear();
191
+ logCursorPageIndexBuildCount = 0;
192
+ }
103
193
 
104
194
  function evictOldestIfNeeded(): void {
105
195
  while (memoryCache.size > MAX_MEMORY_CACHE) {
@@ -125,11 +215,13 @@ function addToCache(log: CapturedLog): void {
125
215
  memoryCache.delete(cachedLog.id);
126
216
  }
127
217
  memoryCache.set(cachedLog.id, cachedLog);
218
+ upsertLogCursorPageIndexes(cachedLog);
128
219
  evictOldestIfNeeded();
129
220
  }
130
221
 
131
222
  function removeFromCache(id: number): void {
132
223
  memoryCache.delete(id);
224
+ removeLogsFromCursorPageIndexes(new Set([id]));
133
225
  }
134
226
 
135
227
  function textByteLength(value: string | null): number | null {
@@ -151,6 +243,45 @@ export function compactLogForList(log: CapturedLog): CapturedLog {
151
243
  };
152
244
  }
153
245
 
246
+ function compactLogFromIndexSummary(summary: LogIndexSummary): CapturedLog {
247
+ return normalizeLog({
248
+ id: summary.id,
249
+ timestamp: summary.timestamp,
250
+ method: summary.method,
251
+ path: summary.path,
252
+ model: summary.model,
253
+ sessionId: summary.sessionId,
254
+ rawRequestBody: null,
255
+ responseStatus: summary.responseStatus,
256
+ responseText: null,
257
+ inputTokens: summary.inputTokens,
258
+ outputTokens: summary.outputTokens,
259
+ cacheCreationInputTokens: summary.cacheCreationInputTokens,
260
+ cacheReadInputTokens: summary.cacheReadInputTokens,
261
+ elapsedMs: summary.elapsedMs,
262
+ firstChunkMs: summary.firstChunkMs,
263
+ totalStreamMs: summary.totalStreamMs,
264
+ tokensPerSecond: summary.tokensPerSecond,
265
+ streaming: summary.streaming,
266
+ userAgent: summary.userAgent,
267
+ origin: summary.origin,
268
+ apiFormat: summary.apiFormat,
269
+ isTest: summary.isTest,
270
+ replayOfLogId: summary.replayOfLogId,
271
+ providerName: summary.providerName,
272
+ clientPort: summary.clientPort,
273
+ clientPid: summary.clientPid,
274
+ clientCwd: summary.clientCwd,
275
+ clientProjectFolder: summary.clientProjectFolder,
276
+ streamingChunksPath: summary.streamingChunksPath,
277
+ rawRequestBodyBytes: summary.rawRequestBodyBytes,
278
+ responseTextBytes: summary.responseTextBytes,
279
+ bodyContentMode: "compact",
280
+ warnings: summary.warnings,
281
+ error: summary.error,
282
+ });
283
+ }
284
+
154
285
  function prepareLogForList(log: CapturedLog, includeBodies: boolean): CapturedLog {
155
286
  return includeBodies ? log : compactLogForList(log);
156
287
  }
@@ -187,7 +318,7 @@ export async function addTestLogEntry(entry: Omit<CapturedLog, "id">): Promise<C
187
318
  await runWithLogWriteLock(async () => {
188
319
  const logFile = getCurrentLogFile();
189
320
  appendLogEntry(log);
190
- await addToIndex(id, logFile, -1, -1);
321
+ await addToIndex(id, logFile, -1, -1, createLogIndexEntryMetadata(log));
191
322
  });
192
323
 
193
324
  addToCache(log);
@@ -258,7 +389,7 @@ export async function createLog(
258
389
  await runWithLogWriteLock(async () => {
259
390
  const logFile = getCurrentLogFile();
260
391
  appendLogEntry(log);
261
- await addToIndex(id, logFile, -1, -1); // line numbers not tracked precisely
392
+ await addToIndex(id, logFile, -1, -1, createLogIndexEntryMetadata(log));
262
393
  });
263
394
 
264
395
  // Add to memory cache
@@ -522,6 +653,234 @@ export async function listLogsPage(options: ListLogsPageOptions): Promise<ListLo
522
653
  };
523
654
  }
524
655
 
656
+ function matchesCursorWindow(id: number, options: ListLogsCursorPageOptions): boolean {
657
+ if (options.beforeLogId !== undefined && id >= options.beforeLogId) return false;
658
+ if (options.afterLogId !== undefined && id <= options.afterLogId) return false;
659
+ return true;
660
+ }
661
+
662
+ function selectCursorPageLogs(
663
+ ids: readonly number[],
664
+ options: ListLogsCursorPageOptions,
665
+ ): number[] {
666
+ const matchingWindow = ids.filter((id) => matchesCursorWindow(id, options));
667
+ if (options.afterLogId !== undefined || options.anchor === "oldest") {
668
+ return matchingWindow.slice(0, options.limit);
669
+ }
670
+ return matchingWindow.slice(Math.max(0, matchingWindow.length - options.limit));
671
+ }
672
+
673
+ function rememberLogCursorPageIndex(index: LogCursorPageIndex): LogCursorPageIndex {
674
+ if (logCursorPageIndexes.has(index.key)) {
675
+ logCursorPageIndexes.delete(index.key);
676
+ }
677
+ logCursorPageIndexes.set(index.key, index);
678
+ while (logCursorPageIndexes.size > MAX_CURSOR_PAGE_INDEXES) {
679
+ const oldestKey = logCursorPageIndexes.keys().next().value;
680
+ if (oldestKey === undefined) break;
681
+ logCursorPageIndexes.delete(oldestKey);
682
+ }
683
+ return index;
684
+ }
685
+
686
+ function hasNullableStringField(entry: LogIndexEntry, field: "sessionId" | "model"): boolean {
687
+ const desc = Object.getOwnPropertyDescriptor(entry, field);
688
+ if (desc === undefined) return false;
689
+ return typeof desc.value === "string" || desc.value === null;
690
+ }
691
+
692
+ function hasLogIndexFilterMetadata(entry: LogIndexEntry): boolean {
693
+ return hasNullableStringField(entry, "sessionId") && hasNullableStringField(entry, "model");
694
+ }
695
+
696
+ function hasLogIndexSummary(entry: LogIndexEntry): boolean {
697
+ return entry.summary !== undefined;
698
+ }
699
+
700
+ function matchesLogIndexEntryFilters(
701
+ entry: LogIndexEntry,
702
+ sessionId: string | undefined,
703
+ model: string | undefined,
704
+ ): boolean {
705
+ if (sessionId !== undefined && entry.sessionId !== sessionId) return false;
706
+ if (model !== undefined && entry.model !== model) return false;
707
+ return true;
708
+ }
709
+
710
+ async function buildLogCursorPageIndexFromLogIndex(
711
+ options: Pick<ListLogsCursorPageOptions, "sessionId" | "model">,
712
+ allowRebuild: boolean,
713
+ ): Promise<LogCursorPageIndex | null> {
714
+ let entries = await listIndexEntries();
715
+ if (entries.length === 0 && allowRebuild) {
716
+ await rebuildIndex();
717
+ entries = await listIndexEntries();
718
+ }
719
+
720
+ if (entries.some((entry) => !hasLogIndexFilterMetadata(entry))) {
721
+ if (!allowRebuild) return null;
722
+ await rebuildIndex();
723
+ return await buildLogCursorPageIndexFromLogIndex(options, false);
724
+ }
725
+
726
+ const matchingEntries = entries.filter((entry) =>
727
+ matchesLogIndexEntryFilters(entry, options.sessionId, options.model),
728
+ );
729
+
730
+ if (matchingEntries.some((entry) => !hasLogIndexSummary(entry))) {
731
+ if (!allowRebuild) return null;
732
+ await rebuildIndex();
733
+ return await buildLogCursorPageIndexFromLogIndex(options, false);
734
+ }
735
+
736
+ if (matchingEntries.some((entry) => !hasUsableIndexOffset(entry) && !memoryCache.has(entry.id))) {
737
+ if (!allowRebuild) return null;
738
+ await rebuildIndex();
739
+ return await buildLogCursorPageIndexFromLogIndex(options, false);
740
+ }
741
+
742
+ const idsById = new Set<number>();
743
+ const entriesById = new Map<number, LogIndexEntry>();
744
+ for (const entry of matchingEntries) {
745
+ idsById.add(entry.id);
746
+ entriesById.set(entry.id, entry);
747
+ }
748
+
749
+ const logsById = new Map<number, CapturedLog>();
750
+ for (const log of memoryCache.values()) {
751
+ if (!matchesLogFilters(log, options.sessionId, options.model)) continue;
752
+ idsById.add(log.id);
753
+ logsById.set(log.id, log);
754
+ }
755
+
756
+ return {
757
+ key: cursorPageIndexKey(options),
758
+ sessionId: options.sessionId,
759
+ model: options.model,
760
+ ids: [...idsById].sort((left, right) => left - right),
761
+ logsById,
762
+ entriesById,
763
+ };
764
+ }
765
+
766
+ async function buildLogCursorPageIndex(
767
+ options: Pick<ListLogsCursorPageOptions, "sessionId" | "model">,
768
+ ): Promise<LogCursorPageIndex> {
769
+ logCursorPageIndexBuildCount += 1;
770
+ const indexed = await buildLogCursorPageIndexFromLogIndex(options, true);
771
+ if (indexed !== null) return indexed;
772
+
773
+ const key = cursorPageIndexKey(options);
774
+ const logsById = new Map<number, CapturedLog>();
775
+
776
+ const visitLog = (log: CapturedLog): void => {
777
+ if (!matchesLogFilters(log, options.sessionId, options.model)) return;
778
+ logsById.set(log.id, log);
779
+ };
780
+
781
+ await visitPersistedLogs((log) => {
782
+ observeSessionLog(log);
783
+ visitLog(log);
784
+ });
785
+
786
+ for (const log of memoryCache.values()) {
787
+ visitLog(log);
788
+ }
789
+
790
+ return {
791
+ key,
792
+ sessionId: options.sessionId,
793
+ model: options.model,
794
+ ids: [...logsById.keys()].sort((left, right) => left - right),
795
+ logsById,
796
+ entriesById: new Map<number, LogIndexEntry>(),
797
+ };
798
+ }
799
+
800
+ async function getLogCursorPageIndex(
801
+ options: Pick<ListLogsCursorPageOptions, "sessionId" | "model">,
802
+ ): Promise<LogCursorPageIndex> {
803
+ const key = cursorPageIndexKey(options);
804
+ const cached = logCursorPageIndexes.get(key);
805
+ if (cached !== undefined) {
806
+ logCursorPageIndexes.delete(key);
807
+ logCursorPageIndexes.set(key, cached);
808
+ return cached;
809
+ }
810
+ return rememberLogCursorPageIndex(await buildLogCursorPageIndex(options));
811
+ }
812
+
813
+ async function hydrateCursorPageLogs(
814
+ index: LogCursorPageIndex,
815
+ pageIds: readonly number[],
816
+ ): Promise<CapturedLog[]> {
817
+ const logs: CapturedLog[] = [];
818
+ for (const id of pageIds) {
819
+ const cached = index.logsById.get(id);
820
+ if (cached !== undefined) {
821
+ logs.push(cached);
822
+ continue;
823
+ }
824
+
825
+ const log = await getLogById(id);
826
+ if (log === null) continue;
827
+ index.logsById.set(id, log);
828
+ logs.push(log);
829
+ }
830
+ return logs;
831
+ }
832
+
833
+ async function hydrateCompactCursorPageLogs(
834
+ index: LogCursorPageIndex,
835
+ pageIds: readonly number[],
836
+ ): Promise<CapturedLog[]> {
837
+ const logs: CapturedLog[] = [];
838
+ for (const id of pageIds) {
839
+ const cached = index.logsById.get(id);
840
+ if (cached !== undefined) {
841
+ logs.push(compactLogForList(cached));
842
+ continue;
843
+ }
844
+
845
+ const entry = index.entriesById.get(id);
846
+ if (entry !== undefined && entry.summary !== undefined) {
847
+ logs.push(compactLogFromIndexSummary(entry.summary));
848
+ continue;
849
+ }
850
+
851
+ const log = await getLogById(id);
852
+ if (log === null) continue;
853
+ index.logsById.set(id, log);
854
+ logs.push(compactLogForList(log));
855
+ }
856
+ return logs;
857
+ }
858
+
859
+ export async function listLogsCursorPage(
860
+ options: ListLogsCursorPageOptions,
861
+ ): Promise<ListLogsCursorPageResult> {
862
+ const includeBodies = options.includeBodies !== false;
863
+ const index = await getLogCursorPageIndex(options);
864
+ const pageIds = selectCursorPageLogs(index.ids, options);
865
+ const pageLogs = includeBodies
866
+ ? await hydrateCursorPageLogs(index, pageIds)
867
+ : await hydrateCompactCursorPageLogs(index, pageIds);
868
+ const oldestLogId = pageIds[0] ?? null;
869
+ const newestLogId = pageIds.at(-1) ?? null;
870
+ const oldestIndex = oldestLogId === null ? -1 : index.ids.indexOf(oldestLogId);
871
+ const newestIndex = newestLogId === null ? -1 : index.ids.indexOf(newestLogId);
872
+
873
+ return {
874
+ logs: pageLogs.map((log) => prepareLogForList(log, includeBodies)),
875
+ total: index.ids.length,
876
+ limit: options.limit,
877
+ hasOlder: oldestIndex > 0,
878
+ hasNewer: newestIndex >= 0 && newestIndex < index.ids.length - 1,
879
+ oldestLogId,
880
+ newestLogId,
881
+ };
882
+ }
883
+
525
884
  function findSessionSnapshot(sessionId: string): SessionSnapshot | null {
526
885
  for (const snapshot of getSessionSnapshots()) {
527
886
  if (snapshot.id === sessionId) return snapshot;
@@ -539,7 +898,12 @@ async function collectSessionLogSummaries(
539
898
  summariesById.set(log.id, buildSessionLogSummary(log));
540
899
  };
541
900
 
542
- if (includeHistory) {
901
+ const indexedSummaries = await collectIndexedSessionLogSummaries(sessionId);
902
+ for (const summary of indexedSummaries) {
903
+ summariesById.set(summary.id, summary);
904
+ }
905
+
906
+ if (includeHistory && summariesById.size === 0) {
543
907
  await visitPersistedLogs((log) => {
544
908
  observeSessionLog(log);
545
909
  observeMatchingLog(log);
@@ -553,6 +917,30 @@ async function collectSessionLogSummaries(
553
917
  return [...summariesById.values()];
554
918
  }
555
919
 
920
+ async function collectIndexedSessionLogSummaries(sessionId: string): Promise<SessionLogSummary[]> {
921
+ let entries = await listIndexEntries();
922
+ if (entries.length === 0) return [];
923
+
924
+ const needsMetadataRebuild = entries.some((entry) => !hasLogIndexFilterMetadata(entry));
925
+ const needsSummaryRebuild = entries.some((entry) => {
926
+ if (!matchesLogIndexEntryFilters(entry, sessionId, undefined)) return false;
927
+ return !hasLogIndexSummary(entry);
928
+ });
929
+
930
+ if (needsMetadataRebuild || needsSummaryRebuild) {
931
+ await rebuildIndex();
932
+ entries = await listIndexEntries();
933
+ }
934
+
935
+ const summaries: SessionLogSummary[] = [];
936
+ for (const entry of entries) {
937
+ if (!matchesLogIndexEntryFilters(entry, sessionId, undefined)) continue;
938
+ if (entry.summary === undefined) continue;
939
+ summaries.push(buildSessionLogSummary(compactLogFromIndexSummary(entry.summary)));
940
+ }
941
+ return summaries;
942
+ }
943
+
556
944
  async function includeSnapshotLastLogSummary(
557
945
  sessionId: string,
558
946
  snapshot: SessionSnapshot | null,
@@ -678,6 +1066,7 @@ export async function loadLogsIntoMemory(): Promise<void> {
678
1066
  }
679
1067
 
680
1068
  rebuildSessionRegistry(memoryCache.values());
1069
+ clearLogCursorPageIndexes();
681
1070
  }
682
1071
 
683
1072
  function rememberRecentCompletedLog(
@@ -706,6 +1095,7 @@ function findSmallestLogId(logsById: ReadonlyMap<number, CapturedLog>): number |
706
1095
  export function clearAllLogs(): { cleared: number } {
707
1096
  const count = memoryCache.size;
708
1097
  memoryCache.clear();
1098
+ clearLogCursorPageIndexes();
709
1099
  clearSessionRegistry();
710
1100
  return { cleared: count };
711
1101
  }
@@ -967,6 +1357,7 @@ export async function clearPersistedLogsByIds(
967
1357
  ]);
968
1358
 
969
1359
  await rebuildIndex();
1360
+ clearLogCursorPageIndexes();
970
1361
 
971
1362
  const clearedIds = new Set<number>(rewriteResult.removedIds);
972
1363
  if (result.cleared > 0) {
@@ -996,10 +1387,21 @@ export function clearLogsByIds(ids: readonly number[]): { cleared: number } {
996
1387
  for (const id of unique) {
997
1388
  removeFromCache(id);
998
1389
  }
1390
+ removeLogsFromCursorPageIndexes(unique);
999
1391
  rebuildSessionRegistry(memoryCache.values());
1000
1392
  return { cleared: unique.size };
1001
1393
  }
1002
1394
 
1395
+ export function _getLogCursorPageIndexStatsForTests(): {
1396
+ indexCount: number;
1397
+ buildCount: number;
1398
+ } {
1399
+ return {
1400
+ indexCount: logCursorPageIndexes.size,
1401
+ buildCount: logCursorPageIndexBuildCount,
1402
+ };
1403
+ }
1404
+
1003
1405
  // SSE event system for real-time log updates
1004
1406
  type LogUpdateHandler = (log: CapturedLog) => void;
1005
1407
  const sseHandlers: Set<LogUpdateHandler> = new Set();
@@ -5,6 +5,7 @@ import {
5
5
  clearPersistedLogsByIds,
6
6
  getLogStorageStats,
7
7
  getReplayLogsForSource,
8
+ listLogsCursorPage,
8
9
  listLogsPage,
9
10
  } from "../../proxy/store";
10
11
  import {
@@ -33,6 +34,15 @@ function parsePositiveInt(value: string | null, fallback: number): number {
33
34
  return parsed;
34
35
  }
35
36
 
37
+ type OptionalPositiveIntResult = { ok: true; value: number | null } | { ok: false };
38
+
39
+ function parseOptionalPositiveInt(value: string | null): OptionalPositiveIntResult {
40
+ if (value === null) return { ok: true, value: null };
41
+ const parsed = Number(value);
42
+ if (!Number.isInteger(parsed) || parsed < 1) return { ok: false };
43
+ return { ok: true, value: parsed };
44
+ }
45
+
36
46
  export const Route = createFileRoute("/api/logs")({
37
47
  server: {
38
48
  handlers: {
@@ -67,6 +77,32 @@ export const Route = createFileRoute("/api/logs")({
67
77
  const limit = parsePositiveInt(url.searchParams.get("limit"), 50);
68
78
  const includeBodies = url.searchParams.get("compact") !== "1";
69
79
 
80
+ if (url.searchParams.get("cursor") === "1" || url.searchParams.get("cursor") === "true") {
81
+ const beforeLogId = parseOptionalPositiveInt(url.searchParams.get("beforeLogId"));
82
+ const afterLogId = parseOptionalPositiveInt(url.searchParams.get("afterLogId"));
83
+ if (!beforeLogId.ok || !afterLogId.ok) {
84
+ return Response.json({ error: "Invalid cursor log ID" }, { status: 400 });
85
+ }
86
+ if (beforeLogId.value !== null && afterLogId.value !== null) {
87
+ return Response.json(
88
+ { error: "Use beforeLogId or afterLogId, not both" },
89
+ { status: 400 },
90
+ );
91
+ }
92
+ const anchor = url.searchParams.get("anchor") === "oldest" ? "oldest" : "newest";
93
+ return Response.json(
94
+ await listLogsCursorPage({
95
+ sessionId,
96
+ model,
97
+ limit,
98
+ includeBodies,
99
+ beforeLogId: beforeLogId.value ?? undefined,
100
+ afterLogId: afterLogId.value ?? undefined,
101
+ anchor,
102
+ }),
103
+ );
104
+ }
105
+
70
106
  return Response.json(
71
107
  await listLogsPage({ sessionId, model, offset, limit, includeBodies }),
72
108
  );