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