spiracha 2.0.0 → 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 (104) hide show
  1. package/README.md +6 -1
  2. package/apps/ui/README.md +20 -2
  3. package/apps/ui/package.json +2 -2
  4. package/apps/ui/src/components/antigravity-conversations-table.tsx +50 -11
  5. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  6. package/apps/ui/src/components/app-shell.tsx +1 -0
  7. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  8. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  9. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  10. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  11. package/apps/ui/src/components/data-table.tsx +7 -5
  12. package/apps/ui/src/components/export-dialog.tsx +2 -12
  13. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  14. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  15. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  16. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  17. package/apps/ui/src/components/page-header.tsx +15 -9
  18. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  19. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  20. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  21. package/apps/ui/src/components/threads-table.tsx +9 -46
  22. package/apps/ui/src/components/transcript-view.tsx +148 -52
  23. package/apps/ui/src/components/ui/select.tsx +1 -1
  24. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  25. package/apps/ui/src/lib/antigravity-server.ts +152 -7
  26. package/apps/ui/src/lib/antigravity-transcript-events.ts +11 -3
  27. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  28. package/apps/ui/src/lib/claude-code-server.ts +175 -7
  29. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  30. package/apps/ui/src/lib/codex-queries.ts +19 -3
  31. package/apps/ui/src/lib/codex-server.ts +135 -53
  32. package/apps/ui/src/lib/cursor-server.ts +48 -5
  33. package/apps/ui/src/lib/formatters.ts +3 -5
  34. package/apps/ui/src/lib/grok-queries.ts +22 -0
  35. package/apps/ui/src/lib/grok-server.ts +169 -0
  36. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  37. package/apps/ui/src/lib/kiro-server.ts +85 -7
  38. package/apps/ui/src/lib/opencode-server.ts +85 -7
  39. package/apps/ui/src/lib/qoder-server.ts +67 -11
  40. package/apps/ui/src/lib/route-search.ts +114 -0
  41. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  42. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  43. package/apps/ui/src/lib/workspace-delete-navigation.ts +12 -0
  44. package/apps/ui/src/routeTree.gen.ts +107 -0
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +76 -10
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +269 -31
  47. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  48. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  49. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  50. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +143 -17
  51. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +165 -26
  52. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +2 -2
  53. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +11 -1
  54. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +388 -0
  55. package/apps/ui/src/routes/grok.$workspaceKey.tsx +301 -0
  56. package/apps/ui/src/routes/grok.index.tsx +48 -0
  57. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +66 -15
  58. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +199 -32
  59. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +105 -19
  60. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +236 -44
  61. package/apps/ui/src/routes/opencode.index.tsx +16 -3
  62. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +7 -4
  63. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +75 -28
  64. package/apps/ui/src/routes/threads.$threadId.tsx +548 -64
  65. package/package.json +19 -18
  66. package/src/client.ts +134 -1
  67. package/src/lib/antigravity-db.ts +208 -32
  68. package/src/lib/antigravity-exporter-types.ts +3 -0
  69. package/src/lib/antigravity-projects.ts +201 -0
  70. package/src/lib/claude-code-db.ts +498 -32
  71. package/src/lib/claude-code-exporter-types.ts +5 -0
  72. package/src/lib/claude-code-transcript-phase.ts +60 -1
  73. package/src/lib/claude-code-transcript.ts +8 -5
  74. package/src/lib/codex-browser-export.ts +23 -27
  75. package/src/lib/codex-thread-cache.ts +41 -13
  76. package/src/lib/codex-thread-parser.ts +86 -35
  77. package/src/lib/codex-transcript-filter.ts +31 -0
  78. package/src/lib/codex-transcript-renderer.ts +39 -19
  79. package/src/lib/concurrency.ts +41 -0
  80. package/src/lib/conversation-api.ts +399 -19
  81. package/src/lib/conversation-data/antigravity-adapter.ts +21 -2
  82. package/src/lib/conversation-data/claude-code-adapter.ts +37 -6
  83. package/src/lib/conversation-data/codex-adapter.ts +28 -4
  84. package/src/lib/conversation-data/cursor-adapter.ts +35 -1
  85. package/src/lib/conversation-data/grok-adapter.ts +210 -0
  86. package/src/lib/conversation-data/index.ts +76 -1
  87. package/src/lib/conversation-data/kiro-adapter.ts +41 -7
  88. package/src/lib/conversation-data/opencode-adapter.ts +24 -2
  89. package/src/lib/conversation-data/qoder-adapter.ts +44 -19
  90. package/src/lib/conversation-data/types.ts +43 -0
  91. package/src/lib/conversation-zip-export.ts +57 -0
  92. package/src/lib/cursor-db.ts +34 -5
  93. package/src/lib/grok-db.ts +1026 -0
  94. package/src/lib/grok-exporter-types.ts +110 -0
  95. package/src/lib/grok-transcript-phase.ts +52 -0
  96. package/src/lib/grok-transcript.ts +154 -0
  97. package/src/lib/kiro-db.ts +52 -1
  98. package/src/lib/model-label.ts +11 -3
  99. package/src/lib/opencode-db.ts +598 -55
  100. package/src/lib/opencode-transcript.ts +8 -5
  101. package/src/lib/shared.ts +12 -0
  102. package/src/lib/transcript-load-limiter.ts +82 -0
  103. package/src/lib/ui-cache.ts +43 -17
  104. package/src/lib/ui-export-archive.ts +60 -0
@@ -0,0 +1,1026 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, readdir, rename, rm, stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { createConcurrencyLimiter, mapWithConcurrency } from './concurrency';
5
+ import {
6
+ type GrokSessionSummary,
7
+ type GrokSessionTranscript,
8
+ type GrokTranscriptEntry,
9
+ type GrokTranscriptPart,
10
+ type GrokWorkspaceGroup,
11
+ getDefaultGrokHome,
12
+ resolveGrokHome,
13
+ resolveGrokSessionsDir,
14
+ } from './grok-exporter-types';
15
+ import {
16
+ asNumber,
17
+ asObject,
18
+ asString,
19
+ cleanExtractedText,
20
+ cleanInlineTitle,
21
+ getPortablePathBasename,
22
+ isWorkspacePathQuery,
23
+ type JsonValue,
24
+ readJsonlObjects,
25
+ workspacePathMatchesQuery,
26
+ } from './shared';
27
+
28
+ export { getDefaultGrokHome, resolveGrokHome, resolveGrokSessionsDir };
29
+
30
+ const READ_CONCURRENCY = 8;
31
+ const DELETE_CONCURRENCY = 1;
32
+ const WORKSPACE_KEY_PREFIX = 'workspace:';
33
+ const grokDeleteLimiter = createConcurrencyLimiter(DELETE_CONCURRENCY);
34
+
35
+ type GrokSessionDirectory = {
36
+ directoryName: string;
37
+ sessionDir: string;
38
+ };
39
+
40
+ type ReadGrokSessionTranscriptOptions = {
41
+ includeRawPayloads?: boolean;
42
+ };
43
+
44
+ type SessionStats = {
45
+ assistantMessageCount: number;
46
+ messageCount: number;
47
+ reasoningCount: number;
48
+ renderablePartCount: number;
49
+ toolCallCount: number;
50
+ toolResultCount: number;
51
+ userMessageCount: number;
52
+ };
53
+
54
+ type SummaryIdentity = {
55
+ agentName: string | null;
56
+ createdAtMs: number | null;
57
+ currentModelId: string | null;
58
+ cwd: string | null;
59
+ gitBranch: string | null;
60
+ gitRemotes: string[];
61
+ gitRootDir: string | null;
62
+ headCommit: string | null;
63
+ lastActiveAtMs: number | null;
64
+ messageCount: number | null;
65
+ sandboxProfile: string | null;
66
+ sessionId: string | null;
67
+ title: string | null;
68
+ };
69
+
70
+ export type DeleteGrokSessionResult = {
71
+ deletedFiles: string[];
72
+ deletedSessionIds: string[];
73
+ };
74
+
75
+ const pathExists = async (target: string): Promise<boolean> => {
76
+ return await stat(target)
77
+ .then(() => true)
78
+ .catch(() => false);
79
+ };
80
+
81
+ const toIso = (value: number | null): string | null => {
82
+ return value === null ? null : new Date(value).toISOString();
83
+ };
84
+
85
+ const parseTimestampMs = (value: JsonValue | undefined): number | null => {
86
+ if (typeof value === 'number' && Number.isFinite(value)) {
87
+ return value > 0 && value < 10_000_000_000 ? value * 1000 : value;
88
+ }
89
+
90
+ if (typeof value !== 'string' || !value.trim()) {
91
+ return null;
92
+ }
93
+
94
+ const numeric = Number(value);
95
+ if (Number.isFinite(numeric)) {
96
+ return numeric > 0 && numeric < 10_000_000_000 ? numeric * 1000 : numeric;
97
+ }
98
+
99
+ const parsed = Date.parse(value);
100
+ return Number.isFinite(parsed) ? parsed : null;
101
+ };
102
+
103
+ const cleanLabel = (value: string | null | undefined): string | null => {
104
+ const cleaned = value?.replace(/\s+/g, ' ').trim();
105
+ return cleaned ? cleaned : null;
106
+ };
107
+
108
+ const getWorkspaceKey = (directoryName: string): string => `${WORKSPACE_KEY_PREFIX}${directoryName}`;
109
+
110
+ const getDirectoryNameFromWorkspaceKey = (workspaceKey: string): string | null => {
111
+ return workspaceKey.startsWith(WORKSPACE_KEY_PREFIX) ? workspaceKey.slice(WORKSPACE_KEY_PREFIX.length) : null;
112
+ };
113
+
114
+ const decodeWorkspaceDirectoryName = (directoryName: string): string => {
115
+ try {
116
+ return decodeURIComponent(directoryName);
117
+ } catch {
118
+ return directoryName;
119
+ }
120
+ };
121
+
122
+ const getWorkspaceLabel = (worktree: string): string => {
123
+ return getPortablePathBasename(worktree) || worktree;
124
+ };
125
+
126
+ const getWorkspaceUri = (worktree: string): string => {
127
+ return worktree.startsWith(path.sep) ? `file://${worktree}` : worktree;
128
+ };
129
+
130
+ const getGrokHomeFromSessionsDir = (sessionsDir: string): string => {
131
+ return path.basename(sessionsDir) === 'sessions' ? path.dirname(sessionsDir) : resolveGrokHome();
132
+ };
133
+
134
+ const formatJsonLike = (value: JsonValue | undefined): string | null => {
135
+ if (value === undefined || value === null) {
136
+ return null;
137
+ }
138
+
139
+ if (typeof value === 'string') {
140
+ return value;
141
+ }
142
+
143
+ return JSON.stringify(value, null, 2);
144
+ };
145
+
146
+ const readJsonObjectFile = async (filePath: string): Promise<Record<string, JsonValue> | null> => {
147
+ const raw = (await Bun.file(filePath)
148
+ .json()
149
+ .catch(() => null)) as JsonValue | null;
150
+ return asObject(raw);
151
+ };
152
+
153
+ const listJsonFiles = async (dirPath: string): Promise<string[]> => {
154
+ const entries = await readdir(dirPath, { withFileTypes: true }).catch(() => []);
155
+ return entries
156
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
157
+ .map((entry) => path.join(dirPath, entry.name))
158
+ .sort();
159
+ };
160
+
161
+ const getJsonObjectList = (value: JsonValue | undefined): Record<string, JsonValue>[] => {
162
+ if (!Array.isArray(value)) {
163
+ return [];
164
+ }
165
+
166
+ return value.flatMap((item) => {
167
+ const object = asObject(item);
168
+ return object ? [object] : [];
169
+ });
170
+ };
171
+
172
+ const textFromContentValue = (value: JsonValue | undefined): string => {
173
+ if (typeof value === 'string') {
174
+ return value;
175
+ }
176
+
177
+ if (Array.isArray(value)) {
178
+ return value
179
+ .map((item) => {
180
+ if (typeof item === 'string') {
181
+ return item;
182
+ }
183
+
184
+ const object = asObject(item);
185
+ if (!object) {
186
+ return '';
187
+ }
188
+
189
+ return (
190
+ asString(object.text ?? null) ??
191
+ asString(object.content ?? null) ??
192
+ textFromContentValue(object.content) ??
193
+ ''
194
+ );
195
+ })
196
+ .filter(Boolean)
197
+ .join('\n\n');
198
+ }
199
+
200
+ const object = asObject(value ?? null);
201
+ return object ? (asString(object.text ?? null) ?? asString(object.content ?? null) ?? '') : '';
202
+ };
203
+
204
+ const unwrapGrokTextEnvelope = (text: string): string => {
205
+ const trimmed = text.trim();
206
+ const userQuery = trimmed.match(/^<user_query>\s*([\s\S]*?)\s*<\/user_query>$/u);
207
+ return userQuery?.[1]?.trim() ?? trimmed;
208
+ };
209
+
210
+ const isGrokSystemContextEnvelope = (text: string): boolean => {
211
+ const trimmed = text.trimStart();
212
+ return (
213
+ trimmed.startsWith('<user_info>') ||
214
+ trimmed.startsWith('<summary_request>') ||
215
+ trimmed.startsWith('<system-reminder>')
216
+ );
217
+ };
218
+
219
+ const getReasoningText = (raw: Record<string, JsonValue>): string => {
220
+ const summary = Array.isArray(raw.summary) ? raw.summary : [];
221
+ const summaryText = summary
222
+ .map((item) => {
223
+ const object = asObject(item);
224
+ return asString(object?.summary_text ?? null) ?? asString(object?.text ?? null) ?? '';
225
+ })
226
+ .filter(Boolean)
227
+ .join('\n\n');
228
+
229
+ return summaryText || asString(raw.content ?? null) || '';
230
+ };
231
+
232
+ const parseToolCallPart = (
233
+ raw: Record<string, JsonValue>,
234
+ entryId: string,
235
+ index: number,
236
+ includeRawPayloads: boolean,
237
+ ): GrokTranscriptPart | null => {
238
+ const toolName = asString(raw.name ?? null) ?? 'unknown';
239
+ const argumentsText = formatJsonLike(raw.arguments);
240
+ return {
241
+ argumentsText,
242
+ partId: `${entryId}:tool-call:${index}`,
243
+ raw: includeRawPayloads ? raw : {},
244
+ toolCallId: asString(raw.id ?? null),
245
+ toolName,
246
+ type: 'tool_call',
247
+ };
248
+ };
249
+
250
+ const parseAssistantParts = (
251
+ raw: Record<string, JsonValue>,
252
+ entryId: string,
253
+ includeRawPayloads: boolean,
254
+ ): GrokTranscriptPart[] => {
255
+ const parts: GrokTranscriptPart[] = [];
256
+ const text = asString(raw.content ?? null) ?? '';
257
+ if (text.trim()) {
258
+ parts.push({
259
+ partId: `${entryId}:text`,
260
+ raw: includeRawPayloads ? { content: text } : {},
261
+ text,
262
+ type: 'text',
263
+ });
264
+ }
265
+
266
+ const toolCalls = Array.isArray(raw.tool_calls) ? raw.tool_calls : [];
267
+ toolCalls.forEach((item, index) => {
268
+ const object = asObject(item);
269
+ const part = object ? parseToolCallPart(object, entryId, index, includeRawPayloads) : null;
270
+ if (part) {
271
+ parts.push(part);
272
+ }
273
+ });
274
+
275
+ return parts;
276
+ };
277
+
278
+ const parseTextEntryPart = (
279
+ raw: Record<string, JsonValue>,
280
+ entryId: string,
281
+ includeRawPayloads: boolean,
282
+ ): GrokTranscriptPart[] => {
283
+ const text = unwrapGrokTextEnvelope(textFromContentValue(raw.content));
284
+ return text
285
+ ? [
286
+ {
287
+ partId: `${entryId}:text`,
288
+ raw: includeRawPayloads ? raw : {},
289
+ text,
290
+ type: 'text',
291
+ },
292
+ ]
293
+ : [];
294
+ };
295
+
296
+ const parseReasoningParts = (
297
+ raw: Record<string, JsonValue>,
298
+ entryId: string,
299
+ includeRawPayloads: boolean,
300
+ ): GrokTranscriptPart[] => {
301
+ const text = getReasoningText(raw).trim();
302
+ return text
303
+ ? [
304
+ {
305
+ partId: `${entryId}:reasoning`,
306
+ raw: includeRawPayloads ? raw : {},
307
+ text,
308
+ type: 'reasoning',
309
+ },
310
+ ]
311
+ : [];
312
+ };
313
+
314
+ const parseToolResultParts = (
315
+ raw: Record<string, JsonValue>,
316
+ entryId: string,
317
+ includeRawPayloads: boolean,
318
+ ): GrokTranscriptPart[] => {
319
+ const outputText = textFromContentValue(raw.content).trim();
320
+ return outputText
321
+ ? [
322
+ {
323
+ outputText,
324
+ partId: `${entryId}:tool-result`,
325
+ raw: includeRawPayloads ? raw : {},
326
+ toolCallId: asString(raw.tool_call_id ?? null),
327
+ type: 'tool_result',
328
+ },
329
+ ]
330
+ : [];
331
+ };
332
+
333
+ const getEntryRole = (type: string): string => {
334
+ if (type === 'system' || type === 'user' || type === 'assistant') {
335
+ return type;
336
+ }
337
+
338
+ if (type === 'reasoning') {
339
+ return 'assistant';
340
+ }
341
+
342
+ if (type === 'tool_result') {
343
+ return 'tool';
344
+ }
345
+
346
+ return 'unknown';
347
+ };
348
+
349
+ const getTranscriptEntryRole = (type: string, parts: GrokTranscriptPart[]): string => {
350
+ if (type === 'user' && parts.some((part) => part.type === 'text' && isGrokSystemContextEnvelope(part.text ?? ''))) {
351
+ return 'system';
352
+ }
353
+
354
+ return getEntryRole(type);
355
+ };
356
+
357
+ const parseTranscriptEntry = (
358
+ raw: Record<string, JsonValue>,
359
+ sessionId: string,
360
+ index: number,
361
+ includeRawPayloads: boolean,
362
+ ): GrokTranscriptEntry | null => {
363
+ const type = asString(raw.type ?? null) ?? 'unknown';
364
+ const entryId = asString(raw.id ?? null) ?? `${sessionId}:${index}`;
365
+ const parts =
366
+ type === 'assistant'
367
+ ? parseAssistantParts(raw, entryId, includeRawPayloads)
368
+ : type === 'reasoning'
369
+ ? parseReasoningParts(raw, entryId, includeRawPayloads)
370
+ : type === 'tool_result'
371
+ ? parseToolResultParts(raw, entryId, includeRawPayloads)
372
+ : parseTextEntryPart(raw, entryId, includeRawPayloads);
373
+
374
+ if (parts.length === 0) {
375
+ return null;
376
+ }
377
+
378
+ return {
379
+ createdAtMs: null,
380
+ entryId,
381
+ modelFingerprint: asString(raw.model_fingerprint ?? null),
382
+ modelId: asString(raw.model_id ?? null),
383
+ parts,
384
+ raw: includeRawPayloads ? raw : {},
385
+ role: getTranscriptEntryRole(type, parts),
386
+ timestamp: null,
387
+ type,
388
+ };
389
+ };
390
+
391
+ const readGrokChatHistory = async (
392
+ sessionDir: string,
393
+ chatHistoryPath: string,
394
+ ): Promise<Record<string, JsonValue>[]> => {
395
+ const liveEvents: Record<string, JsonValue>[] = [];
396
+ for await (const raw of readJsonlObjects(chatHistoryPath)) {
397
+ liveEvents.push(raw);
398
+ }
399
+
400
+ const requestFiles = await listJsonFiles(path.join(sessionDir, 'compaction_requests'));
401
+ const archivedHistory = (
402
+ await Promise.all(
403
+ requestFiles.map(async (filePath) => {
404
+ const request = await readJsonObjectFile(filePath);
405
+ return getJsonObjectList(request?.chat_history);
406
+ }),
407
+ )
408
+ )
409
+ .filter((history) => history.length > 0)
410
+ .sort((left, right) => right.length - left.length)[0];
411
+
412
+ if (!archivedHistory) {
413
+ return liveEvents;
414
+ }
415
+
416
+ const checkpointFiles = await listJsonFiles(path.join(sessionDir, 'compaction_checkpoints'));
417
+ const latestCheckpoint = (
418
+ await Promise.all(
419
+ checkpointFiles.map(async (filePath) => {
420
+ const checkpoint = await readJsonObjectFile(filePath);
421
+ return {
422
+ compactedHistory: getJsonObjectList(checkpoint?.compacted_history),
423
+ createdAtMs: parseTimestampMs(checkpoint?.created_at) ?? 0,
424
+ promptIndex: asNumber(checkpoint?.prompt_index_at_compaction ?? null),
425
+ };
426
+ }),
427
+ )
428
+ ).sort((left, right) => right.createdAtMs - left.createdAtMs)[0];
429
+
430
+ const compactedHistoryIsLivePrefix = latestCheckpoint?.compactedHistory.every(
431
+ (event, index) => JSON.stringify(event) === JSON.stringify(liveEvents[index]),
432
+ );
433
+ const liveTailStart = compactedHistoryIsLivePrefix
434
+ ? latestCheckpoint?.compactedHistory.length
435
+ : latestCheckpoint?.promptIndex;
436
+ const liveTail = Number.isFinite(liveTailStart)
437
+ ? liveEvents.slice(Math.max(0, Math.min(liveEvents.length, Math.floor(liveTailStart ?? 0))))
438
+ : liveEvents;
439
+ return [...archivedHistory, ...liveTail];
440
+ };
441
+
442
+ const createEmptyStats = (): SessionStats => ({
443
+ assistantMessageCount: 0,
444
+ messageCount: 0,
445
+ reasoningCount: 0,
446
+ renderablePartCount: 0,
447
+ toolCallCount: 0,
448
+ toolResultCount: 0,
449
+ userMessageCount: 0,
450
+ });
451
+
452
+ const isRenderablePart = (part: GrokTranscriptPart): boolean => {
453
+ if (part.type === 'text' || part.type === 'reasoning') {
454
+ return Boolean(part.text?.trim());
455
+ }
456
+
457
+ if (part.type === 'tool_call') {
458
+ return Boolean(part.toolName || part.argumentsText?.trim());
459
+ }
460
+
461
+ if (part.type === 'tool_result') {
462
+ return Boolean(part.outputText?.trim());
463
+ }
464
+
465
+ return false;
466
+ };
467
+
468
+ const updateStatsFromEntry = (stats: SessionStats, entry: GrokTranscriptEntry) => {
469
+ if (entry.role === 'assistant' || entry.role === 'user') {
470
+ stats.messageCount += entry.parts.some((part) => part.type === 'text') ? 1 : 0;
471
+ }
472
+
473
+ if (entry.role === 'assistant' && entry.parts.some((part) => part.type === 'text')) {
474
+ stats.assistantMessageCount += 1;
475
+ }
476
+
477
+ if (entry.role === 'user' && entry.parts.some((part) => part.type === 'text')) {
478
+ stats.userMessageCount += 1;
479
+ }
480
+
481
+ stats.reasoningCount += entry.parts.filter((part) => part.type === 'reasoning').length;
482
+ stats.toolCallCount += entry.parts.filter((part) => part.type === 'tool_call').length;
483
+ stats.toolResultCount += entry.parts.filter((part) => part.type === 'tool_result').length;
484
+ stats.renderablePartCount += entry.parts.filter(isRenderablePart).length;
485
+ };
486
+
487
+ const getStringList = (value: JsonValue | undefined): string[] => {
488
+ if (!Array.isArray(value)) {
489
+ return [];
490
+ }
491
+
492
+ return value.flatMap((item) => {
493
+ const stringValue = asString(item);
494
+ return stringValue ? [stringValue] : [];
495
+ });
496
+ };
497
+
498
+ const getSummaryCwd = (summary: Record<string, JsonValue>, info: Record<string, JsonValue> | null): string | null => {
499
+ return asString(info?.cwd ?? null) ?? asString(summary.cwd ?? null);
500
+ };
501
+
502
+ const getSummaryMessageCount = (summary: Record<string, JsonValue>): number | null => {
503
+ return typeof summary.num_chat_messages === 'number' ? summary.num_chat_messages : null;
504
+ };
505
+
506
+ const getSummarySessionId = (
507
+ summary: Record<string, JsonValue>,
508
+ info: Record<string, JsonValue> | null,
509
+ ): string | null => {
510
+ return asString(info?.id ?? null) ?? asString(summary.id ?? null);
511
+ };
512
+
513
+ const getSummaryTitle = (summary: Record<string, JsonValue>): string | null => {
514
+ return cleanLabel(asString(summary.generated_title ?? null) ?? asString(summary.session_summary ?? null));
515
+ };
516
+
517
+ const readSummaryIdentity = async (summaryPath: string): Promise<SummaryIdentity | null> => {
518
+ const summary = await readJsonObjectFile(summaryPath);
519
+ if (!summary) {
520
+ return null;
521
+ }
522
+
523
+ const info = asObject(summary.info ?? null);
524
+
525
+ return {
526
+ agentName: asString(summary.agent_name ?? null),
527
+ createdAtMs: parseTimestampMs(summary.created_at),
528
+ currentModelId: asString(summary.current_model_id ?? null),
529
+ cwd: getSummaryCwd(summary, info),
530
+ gitBranch: asString(summary.head_branch ?? null),
531
+ gitRemotes: getStringList(summary.git_remotes),
532
+ gitRootDir: asString(summary.git_root_dir ?? null),
533
+ headCommit: asString(summary.head_commit ?? null),
534
+ lastActiveAtMs: parseTimestampMs(summary.last_active_at) ?? parseTimestampMs(summary.updated_at),
535
+ messageCount: getSummaryMessageCount(summary),
536
+ sandboxProfile: asString(summary.sandbox_profile ?? null),
537
+ sessionId: getSummarySessionId(summary, info),
538
+ title: getSummaryTitle(summary),
539
+ };
540
+ };
541
+
542
+ const readModelLabels = async (grokHome: string): Promise<Map<string, string>> => {
543
+ const raw = await readJsonObjectFile(path.join(grokHome, 'models_cache.json'));
544
+ const models = asObject(raw?.models ?? null);
545
+ const labels = new Map<string, string>();
546
+ if (!models) {
547
+ return labels;
548
+ }
549
+
550
+ for (const [modelId, modelValue] of Object.entries(models)) {
551
+ const info = asObject(asObject(modelValue)?.info ?? null);
552
+ const label = cleanLabel(asString(info?.name ?? null));
553
+ if (label) {
554
+ labels.set(modelId, label);
555
+ }
556
+ }
557
+
558
+ return labels;
559
+ };
560
+
561
+ const getFirstUserText = (entries: GrokTranscriptEntry[]): string | null => {
562
+ const userEntry = entries.find((entry) => entry.role === 'user');
563
+ const textPart = userEntry?.parts.find((part) => part.type === 'text' && part.text?.trim());
564
+ return cleanExtractedText(textPart?.text ?? '').trim() || null;
565
+ };
566
+
567
+ const toSessionSummary = (
568
+ file: GrokSessionDirectory,
569
+ identity: SummaryIdentity,
570
+ stats: SessionStats,
571
+ entries: GrokTranscriptEntry[],
572
+ modelLabels: Map<string, string>,
573
+ ): GrokSessionSummary => {
574
+ const sessionId = identity.sessionId ?? path.basename(file.sessionDir);
575
+ const worktree = identity.cwd ?? decodeWorkspaceDirectoryName(file.directoryName);
576
+ const title = cleanInlineTitle(identity.title ?? getFirstUserText(entries) ?? sessionId);
577
+ const currentModelId = identity.currentModelId ?? entries.find((entry) => entry.modelId)?.modelId ?? null;
578
+ const updatedAtMs = identity.lastActiveAtMs;
579
+ const chatHistoryPath = path.join(file.sessionDir, 'chat_history.jsonl');
580
+ const updatesPath = path.join(file.sessionDir, 'updates.jsonl');
581
+
582
+ return {
583
+ ...stats,
584
+ agentName: identity.agentName,
585
+ chatHistoryPath,
586
+ chatMessageCount: identity.messageCount ?? stats.messageCount,
587
+ createdAtIso: toIso(identity.createdAtMs),
588
+ createdAtMs: identity.createdAtMs,
589
+ currentModelId,
590
+ cwd: identity.cwd,
591
+ gitBranch: identity.gitBranch,
592
+ gitRemotes: identity.gitRemotes,
593
+ gitRootDir: identity.gitRootDir,
594
+ headCommit: identity.headCommit,
595
+ lastActiveAtIso: toIso(updatedAtMs),
596
+ lastActiveAtMs: updatedAtMs,
597
+ messageCount: stats.messageCount,
598
+ modelLabel: currentModelId ? (modelLabels.get(currentModelId) ?? null) : null,
599
+ sandboxProfile: identity.sandboxProfile,
600
+ sessionDir: file.sessionDir,
601
+ sessionId,
602
+ summaryPath: path.join(file.sessionDir, 'summary.json'),
603
+ title,
604
+ updatesPath,
605
+ workspaceKey: getWorkspaceKey(file.directoryName),
606
+ workspaceLabel: getWorkspaceLabel(worktree),
607
+ worktree,
608
+ };
609
+ };
610
+
611
+ const readSessionDirectory = async (
612
+ file: GrokSessionDirectory,
613
+ modelLabels: Map<string, string>,
614
+ options: ReadGrokSessionTranscriptOptions = {},
615
+ ): Promise<GrokSessionTranscript | null> => {
616
+ const includeRawPayloads = options.includeRawPayloads ?? true;
617
+ const summaryPath = path.join(file.sessionDir, 'summary.json');
618
+ const chatHistoryPath = path.join(file.sessionDir, 'chat_history.jsonl');
619
+ const identity = await readSummaryIdentity(summaryPath);
620
+ if (!identity || !(await pathExists(chatHistoryPath))) {
621
+ return null;
622
+ }
623
+
624
+ const sessionId = identity.sessionId ?? path.basename(file.sessionDir);
625
+ const stats = createEmptyStats();
626
+ const entries: GrokTranscriptEntry[] = [];
627
+ const rawEvents: Record<string, JsonValue>[] = [];
628
+ let index = 0;
629
+
630
+ for (const raw of await readGrokChatHistory(file.sessionDir, chatHistoryPath)) {
631
+ if (includeRawPayloads) {
632
+ rawEvents.push(raw);
633
+ }
634
+ const entry = parseTranscriptEntry(raw, sessionId, index, includeRawPayloads);
635
+ index += 1;
636
+ if (!entry) {
637
+ continue;
638
+ }
639
+
640
+ entries.push(entry);
641
+ updateStatsFromEntry(stats, entry);
642
+ }
643
+
644
+ return {
645
+ entries,
646
+ rawEvents,
647
+ rawPayloadsOmitted: includeRawPayloads ? undefined : true,
648
+ renderablePartCount: stats.renderablePartCount,
649
+ session: toSessionSummary(file, identity, stats, entries, modelLabels),
650
+ };
651
+ };
652
+
653
+ const listSessionDirectoriesForWorkspace = async (
654
+ sessionsDir: string,
655
+ directoryName: string,
656
+ ): Promise<GrokSessionDirectory[]> => {
657
+ const workspaceDir = path.join(sessionsDir, directoryName);
658
+ return listSessionDirectoriesUnderWorkspace(workspaceDir, directoryName);
659
+ };
660
+
661
+ const listSessionDirectoriesUnderWorkspace = async (
662
+ root: string,
663
+ directoryName: string,
664
+ ): Promise<GrokSessionDirectory[]> => {
665
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
666
+ const directories: GrokSessionDirectory[] = [];
667
+
668
+ for (const entry of entries) {
669
+ if (!entry.isDirectory()) {
670
+ continue;
671
+ }
672
+
673
+ const entryPath = path.join(root, entry.name);
674
+ if (
675
+ (await pathExists(path.join(entryPath, 'summary.json'))) &&
676
+ (await pathExists(path.join(entryPath, 'chat_history.jsonl')))
677
+ ) {
678
+ directories.push({ directoryName, sessionDir: entryPath });
679
+ }
680
+ directories.push(...(await listSessionDirectoriesUnderWorkspace(entryPath, directoryName)));
681
+ }
682
+
683
+ return directories.sort((left, right) => left.sessionDir.localeCompare(right.sessionDir));
684
+ };
685
+
686
+ const listSessionDirectories = async (sessionsDir: string): Promise<GrokSessionDirectory[]> => {
687
+ if (!(await pathExists(sessionsDir))) {
688
+ return [];
689
+ }
690
+
691
+ const workspaceDirs = (await readdir(sessionsDir, { withFileTypes: true }))
692
+ .filter((entry) => entry.isDirectory())
693
+ .map((entry) => entry.name)
694
+ .sort();
695
+ const groupedFiles = await mapWithConcurrency(workspaceDirs, READ_CONCURRENCY, (directoryName) =>
696
+ listSessionDirectoriesForWorkspace(sessionsDir, directoryName),
697
+ );
698
+ return groupedFiles.flat();
699
+ };
700
+
701
+ const readSessionDirectories = async (
702
+ sessionsDir: string,
703
+ files: GrokSessionDirectory[],
704
+ ): Promise<GrokSessionTranscript[]> => {
705
+ const modelLabels = await readModelLabels(getGrokHomeFromSessionsDir(sessionsDir));
706
+ const transcripts = await mapWithConcurrency(files, READ_CONCURRENCY, (file) =>
707
+ readSessionDirectory(file, modelLabels),
708
+ );
709
+ return transcripts.flatMap((transcript) => (transcript ? [transcript] : []));
710
+ };
711
+
712
+ const hasConversationMessages = (transcript: GrokSessionTranscript): boolean => {
713
+ return transcript.session.userMessageCount > 0 || transcript.session.assistantMessageCount > 0;
714
+ };
715
+
716
+ const compareNullableMsDesc = (left: number | null, right: number | null): number => {
717
+ return (right ?? 0) - (left ?? 0);
718
+ };
719
+
720
+ const sumSessions = (sessions: GrokSessionSummary[], key: keyof SessionStats): number => {
721
+ return sessions.reduce((total, session) => total + session[key], 0);
722
+ };
723
+
724
+ const toWorkspaceGroup = (directoryName: string, sessions: GrokSessionSummary[]): GrokWorkspaceGroup => {
725
+ const worktree = sessions[0]?.worktree ?? decodeWorkspaceDirectoryName(directoryName);
726
+ const lastActiveAtMs = sessions.reduce<number | null>((latest, session) => {
727
+ if (session.lastActiveAtMs === null) {
728
+ return latest;
729
+ }
730
+
731
+ return latest === null ? session.lastActiveAtMs : Math.max(latest, session.lastActiveAtMs);
732
+ }, null);
733
+
734
+ return {
735
+ assistantMessageCount: sumSessions(sessions, 'assistantMessageCount'),
736
+ chatMessageCount: sessions.reduce((total, session) => total + session.chatMessageCount, 0),
737
+ directoryName,
738
+ key: getWorkspaceKey(directoryName),
739
+ label: getWorkspaceLabel(worktree),
740
+ lastActiveAtIso: toIso(lastActiveAtMs),
741
+ lastActiveAtMs,
742
+ messageCount: sessions.reduce((total, session) => total + session.messageCount, 0),
743
+ reasoningCount: sumSessions(sessions, 'reasoningCount'),
744
+ sessionCount: sessions.length,
745
+ toolCallCount: sumSessions(sessions, 'toolCallCount'),
746
+ toolResultCount: sumSessions(sessions, 'toolResultCount'),
747
+ uri: getWorkspaceUri(worktree),
748
+ userMessageCount: sumSessions(sessions, 'userMessageCount'),
749
+ worktree,
750
+ };
751
+ };
752
+
753
+ export const listGrokWorkspaceGroups = async (
754
+ sessionsDir = resolveGrokSessionsDir(),
755
+ ): Promise<GrokWorkspaceGroup[]> => {
756
+ const files = await listSessionDirectories(sessionsDir);
757
+ const transcripts = await readSessionDirectories(sessionsDir, files);
758
+ const sessionsByDirectory = new Map<string, GrokSessionSummary[]>();
759
+
760
+ for (const transcript of transcripts) {
761
+ if (!hasConversationMessages(transcript)) {
762
+ continue;
763
+ }
764
+
765
+ const directoryName = getDirectoryNameFromWorkspaceKey(transcript.session.workspaceKey);
766
+ if (!directoryName) {
767
+ continue;
768
+ }
769
+
770
+ const sessions = sessionsByDirectory.get(directoryName) ?? [];
771
+ sessions.push(transcript.session);
772
+ sessionsByDirectory.set(directoryName, sessions);
773
+ }
774
+
775
+ return [...sessionsByDirectory.entries()]
776
+ .map(([directoryName, sessions]) => toWorkspaceGroup(directoryName, sessions))
777
+ .sort(
778
+ (left, right) =>
779
+ compareNullableMsDesc(left.lastActiveAtMs, right.lastActiveAtMs) ||
780
+ left.worktree.localeCompare(right.worktree),
781
+ );
782
+ };
783
+
784
+ const grokWorkspaceMatchesQuery = (workspace: GrokWorkspaceGroup, query: string): boolean => {
785
+ const raw = query.trim();
786
+ if (!raw) {
787
+ return true;
788
+ }
789
+
790
+ const lowered = raw.toLowerCase();
791
+ if (
792
+ workspace.key.toLowerCase() === lowered ||
793
+ workspace.directoryName.toLowerCase() === lowered ||
794
+ workspace.label.toLowerCase() === lowered
795
+ ) {
796
+ return true;
797
+ }
798
+
799
+ if (isWorkspacePathQuery(raw)) {
800
+ return workspacePathMatchesQuery(workspace.worktree, raw);
801
+ }
802
+
803
+ return getPortablePathBasename(workspace.worktree).toLowerCase() === lowered;
804
+ };
805
+
806
+ export const findGrokWorkspaceGroups = (groups: GrokWorkspaceGroup[], query: string): GrokWorkspaceGroup[] => {
807
+ return groups.filter((group) => grokWorkspaceMatchesQuery(group, query));
808
+ };
809
+
810
+ const sortSessions = (sessions: GrokSessionSummary[]): GrokSessionSummary[] => {
811
+ return [...sessions].sort(
812
+ (left, right) =>
813
+ compareNullableMsDesc(left.lastActiveAtMs, right.lastActiveAtMs) || left.title.localeCompare(right.title),
814
+ );
815
+ };
816
+
817
+ export const listGrokSessionsForGroup = async (
818
+ workspaceKey: string,
819
+ sessionsDir = resolveGrokSessionsDir(),
820
+ ): Promise<GrokSessionSummary[]> => {
821
+ const directoryName = getDirectoryNameFromWorkspaceKey(workspaceKey);
822
+ if (!directoryName || !(await pathExists(sessionsDir))) {
823
+ return [];
824
+ }
825
+
826
+ const files = await listSessionDirectoriesForWorkspace(sessionsDir, directoryName);
827
+ const transcripts = await readSessionDirectories(sessionsDir, files);
828
+ return sortSessions(transcripts.filter(hasConversationMessages).map((transcript) => transcript.session));
829
+ };
830
+
831
+ const locateSessionDirectory = async (sessionsDir: string, sessionId: string): Promise<GrokSessionDirectory | null> => {
832
+ const files = await listSessionDirectories(sessionsDir);
833
+ const directMatch = files.find((file) => path.basename(file.sessionDir) === sessionId);
834
+ if (directMatch) {
835
+ return directMatch;
836
+ }
837
+
838
+ const modelLabels = await readModelLabels(getGrokHomeFromSessionsDir(sessionsDir));
839
+ const located = await mapWithConcurrency(files, READ_CONCURRENCY, async (file) => {
840
+ const transcript = await readSessionDirectory(file, modelLabels);
841
+ return transcript?.session.sessionId === sessionId ? file : null;
842
+ });
843
+ return located.find((file) => file !== null) ?? null;
844
+ };
845
+
846
+ const listFilesRecursively = async (root: string): Promise<string[]> => {
847
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
848
+ const files: string[] = [];
849
+
850
+ for (const entry of entries) {
851
+ const entryPath = path.join(root, entry.name);
852
+ if (entry.isFile()) {
853
+ files.push(entryPath);
854
+ continue;
855
+ }
856
+
857
+ if (entry.isDirectory()) {
858
+ files.push(...(await listFilesRecursively(entryPath)));
859
+ }
860
+ }
861
+
862
+ return files;
863
+ };
864
+
865
+ const listDirectoriesRecursively = async (root: string): Promise<string[]> => {
866
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
867
+ const directories: string[] = [];
868
+
869
+ for (const entry of entries) {
870
+ if (!entry.isDirectory()) {
871
+ continue;
872
+ }
873
+
874
+ const entryPath = path.join(root, entry.name);
875
+ directories.push(entryPath, ...(await listDirectoriesRecursively(entryPath)));
876
+ }
877
+
878
+ return directories;
879
+ };
880
+
881
+ const listRelatedSubagentDirectories = async (sessionsDir: string, sessionId: string): Promise<string[]> => {
882
+ const candidates = (await listDirectoriesRecursively(sessionsDir)).filter(
883
+ (directoryPath) =>
884
+ path.basename(directoryPath) === sessionId && path.basename(path.dirname(directoryPath)) === 'subagents',
885
+ );
886
+ const related = await mapWithConcurrency(candidates, READ_CONCURRENCY, async (directoryPath) => {
887
+ const metadata = await readJsonObjectFile(path.join(directoryPath, 'meta.json'));
888
+ return asString(metadata?.child_session_id ?? null) === sessionId ? directoryPath : null;
889
+ });
890
+ return related.filter((directoryPath): directoryPath is string => directoryPath !== null);
891
+ };
892
+
893
+ const pruneReportedTaskCompletion = (value: JsonValue, sessionId: string): boolean => {
894
+ const root = asObject(value);
895
+ const state = asObject(root?.state ?? null);
896
+ const completions = asObject(state?.['grok_build.ReportedTaskCompletions'] ?? null);
897
+ const reported = completions?.reported;
898
+ if (!completions || !Array.isArray(reported)) {
899
+ return false;
900
+ }
901
+
902
+ const next = reported.filter((entry) => entry !== sessionId);
903
+ if (next.length === reported.length) {
904
+ return false;
905
+ }
906
+
907
+ completions.reported = next;
908
+ return true;
909
+ };
910
+
911
+ const writeGrokJsonFile = async (filePath: string, value: JsonValue): Promise<void> => {
912
+ const tempPath = `${filePath}.${randomUUID()}.tmp`;
913
+ const mode = (await stat(filePath)).mode & 0o777;
914
+ try {
915
+ await Bun.write(tempPath, `${JSON.stringify(value, null, 2)}\n`);
916
+ await chmod(tempPath, mode);
917
+ await rename(tempPath, filePath);
918
+ } finally {
919
+ await rm(tempPath, { force: true });
920
+ }
921
+ };
922
+
923
+ const pruneResourcesStateReferences = async (sessionsDir: string, sessionId: string): Promise<void> => {
924
+ const resourceStatePaths = (await listFilesRecursively(sessionsDir)).filter(
925
+ (filePath) => path.basename(filePath) === 'resources_state.json',
926
+ );
927
+
928
+ for (const resourceStatePath of resourceStatePaths) {
929
+ const value = (await Bun.file(resourceStatePath)
930
+ .json()
931
+ .catch(() => null)) as JsonValue | null;
932
+ if (value === null) {
933
+ continue;
934
+ }
935
+
936
+ if (pruneReportedTaskCompletion(value, sessionId)) {
937
+ await writeGrokJsonFile(resourceStatePath, value);
938
+ }
939
+ }
940
+ };
941
+
942
+ const removeActiveSessionEntry = async (sessionsDir: string, sessionId: string): Promise<void> => {
943
+ const activeSessionsPath = path.join(getGrokHomeFromSessionsDir(sessionsDir), 'active_sessions.json');
944
+ const file = Bun.file(activeSessionsPath);
945
+ if (!(await file.exists())) {
946
+ return;
947
+ }
948
+
949
+ const value = (await file.json().catch(() => null)) as JsonValue | null;
950
+ if (!Array.isArray(value)) {
951
+ console.warn('[spiracha:grok-db] malformed_active_sessions', { path: activeSessionsPath });
952
+ return;
953
+ }
954
+
955
+ const next = value.filter((item) => asString(asObject(item)?.session_id ?? null) !== sessionId);
956
+ if (next.length === value.length) {
957
+ return;
958
+ }
959
+
960
+ await writeGrokJsonFile(activeSessionsPath, next);
961
+ };
962
+
963
+ export const readGrokSessionTranscript = async (
964
+ sessionsDir: string,
965
+ sessionId: string,
966
+ options: ReadGrokSessionTranscriptOptions = {},
967
+ ): Promise<GrokSessionTranscript | null> => {
968
+ if (!(await pathExists(sessionsDir))) {
969
+ return null;
970
+ }
971
+
972
+ const file = await locateSessionDirectory(sessionsDir, sessionId);
973
+ if (!file) {
974
+ return null;
975
+ }
976
+
977
+ const modelLabels = await readModelLabels(getGrokHomeFromSessionsDir(sessionsDir));
978
+ return readSessionDirectory(file, modelLabels, options);
979
+ };
980
+
981
+ const isSafeGrokSessionId = (sessionId: string): boolean =>
982
+ /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u.test(sessionId) && !sessionId.includes('..');
983
+
984
+ const deleteGrokSessionWithLimit = async (sessionsDir: string, sessionId: string): Promise<DeleteGrokSessionResult> => {
985
+ if (!isSafeGrokSessionId(sessionId)) {
986
+ return { deletedFiles: [], deletedSessionIds: [] };
987
+ }
988
+
989
+ if (!(await pathExists(sessionsDir))) {
990
+ return { deletedFiles: [], deletedSessionIds: [] };
991
+ }
992
+
993
+ const directMatches = (await listSessionDirectories(sessionsDir)).filter(
994
+ (file) => path.basename(file.sessionDir) === sessionId,
995
+ );
996
+ const directories =
997
+ directMatches.length > 0
998
+ ? directMatches
999
+ : await locateSessionDirectory(sessionsDir, sessionId).then((file) => (file ? [file] : []));
1000
+ const uniqueSessionDirs = [...new Set(directories.map((file) => file.sessionDir))];
1001
+ const relatedSubagentDirs = await listRelatedSubagentDirectories(sessionsDir, sessionId);
1002
+ const deleteDirs = [...new Set([...uniqueSessionDirs, ...relatedSubagentDirs])];
1003
+ if (deleteDirs.length === 0) {
1004
+ return { deletedFiles: [], deletedSessionIds: [] };
1005
+ }
1006
+
1007
+ const deletedFiles = (
1008
+ await Promise.all(
1009
+ deleteDirs.map(async (directoryPath) => {
1010
+ const files = await listFilesRecursively(directoryPath);
1011
+ await rm(directoryPath, { force: true, recursive: true });
1012
+ return files;
1013
+ }),
1014
+ )
1015
+ ).flat();
1016
+ await removeActiveSessionEntry(sessionsDir, sessionId);
1017
+ await pruneResourcesStateReferences(sessionsDir, sessionId);
1018
+ return {
1019
+ deletedFiles,
1020
+ deletedSessionIds: [sessionId],
1021
+ };
1022
+ };
1023
+
1024
+ export const deleteGrokSession = async (sessionsDir: string, sessionId: string): Promise<DeleteGrokSessionResult> => {
1025
+ return grokDeleteLimiter(() => deleteGrokSessionWithLimit(sessionsDir, sessionId));
1026
+ };