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.
- package/README.md +6 -1
- package/apps/ui/README.md +20 -2
- package/apps/ui/package.json +2 -2
- package/apps/ui/src/components/antigravity-conversations-table.tsx +50 -11
- package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
- package/apps/ui/src/components/app-shell.tsx +1 -0
- 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 +7 -5
- package/apps/ui/src/components/export-dialog.tsx +2 -12
- 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/qoder-sessions-table.tsx +23 -2
- package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
- package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
- package/apps/ui/src/components/threads-table.tsx +9 -46
- 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 +152 -7
- package/apps/ui/src/lib/antigravity-transcript-events.ts +11 -3
- package/apps/ui/src/lib/claude-code-queries.ts +8 -0
- package/apps/ui/src/lib/claude-code-server.ts +175 -7
- package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
- package/apps/ui/src/lib/codex-queries.ts +19 -3
- package/apps/ui/src/lib/codex-server.ts +135 -53
- package/apps/ui/src/lib/cursor-server.ts +48 -5
- 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 +169 -0
- package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
- package/apps/ui/src/lib/kiro-server.ts +85 -7
- package/apps/ui/src/lib/opencode-server.ts +85 -7
- package/apps/ui/src/lib/qoder-server.ts +67 -11
- package/apps/ui/src/lib/route-search.ts +114 -0
- package/apps/ui/src/lib/source-session-export-server.ts +86 -3
- package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
- package/apps/ui/src/lib/workspace-delete-navigation.ts +12 -0
- package/apps/ui/src/routeTree.gen.ts +107 -0
- package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +76 -10
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +269 -31
- 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 +143 -17
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +165 -26
- package/apps/ui/src/routes/cursor-threads.$composerId.tsx +2 -2
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +11 -1
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +388 -0
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +301 -0
- package/apps/ui/src/routes/grok.index.tsx +48 -0
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +66 -15
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +199 -32
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +105 -19
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +236 -44
- package/apps/ui/src/routes/opencode.index.tsx +16 -3
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +7 -4
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +75 -28
- package/apps/ui/src/routes/threads.$threadId.tsx +548 -64
- package/package.json +19 -18
- package/src/client.ts +134 -1
- package/src/lib/antigravity-db.ts +208 -32
- package/src/lib/antigravity-exporter-types.ts +3 -0
- package/src/lib/antigravity-projects.ts +201 -0
- package/src/lib/claude-code-db.ts +498 -32
- 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-browser-export.ts +23 -27
- package/src/lib/codex-thread-cache.ts +41 -13
- package/src/lib/codex-thread-parser.ts +86 -35
- package/src/lib/codex-transcript-filter.ts +31 -0
- package/src/lib/codex-transcript-renderer.ts +39 -19
- package/src/lib/concurrency.ts +41 -0
- package/src/lib/conversation-api.ts +399 -19
- package/src/lib/conversation-data/antigravity-adapter.ts +21 -2
- package/src/lib/conversation-data/claude-code-adapter.ts +37 -6
- package/src/lib/conversation-data/codex-adapter.ts +28 -4
- package/src/lib/conversation-data/cursor-adapter.ts +35 -1
- package/src/lib/conversation-data/grok-adapter.ts +210 -0
- package/src/lib/conversation-data/index.ts +76 -1
- package/src/lib/conversation-data/kiro-adapter.ts +41 -7
- package/src/lib/conversation-data/opencode-adapter.ts +24 -2
- package/src/lib/conversation-data/qoder-adapter.ts +44 -19
- package/src/lib/conversation-data/types.ts +43 -0
- package/src/lib/conversation-zip-export.ts +57 -0
- package/src/lib/cursor-db.ts +34 -5
- package/src/lib/grok-db.ts +1026 -0
- package/src/lib/grok-exporter-types.ts +110 -0
- package/src/lib/grok-transcript-phase.ts +52 -0
- package/src/lib/grok-transcript.ts +154 -0
- package/src/lib/kiro-db.ts +52 -1
- package/src/lib/model-label.ts +11 -3
- package/src/lib/opencode-db.ts +598 -55
- package/src/lib/opencode-transcript.ts +8 -5
- package/src/lib/shared.ts +12 -0
- package/src/lib/transcript-load-limiter.ts +82 -0
- package/src/lib/ui-cache.ts +43 -17
- package/src/lib/ui-export-archive.ts +60 -0
package/src/lib/opencode-db.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { constants, Database } from 'bun:sqlite';
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { chmod, readdir, rename, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
3
5
|
import path from 'node:path';
|
|
4
6
|
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { createConcurrencyLimiter, mapWithConcurrency } from './concurrency';
|
|
5
8
|
import {
|
|
6
9
|
getDefaultOpenCodeDataDir,
|
|
7
10
|
type OpenCodeModelInfo,
|
|
@@ -23,10 +26,27 @@ import {
|
|
|
23
26
|
type JsonValue,
|
|
24
27
|
workspacePathMatchesQuery,
|
|
25
28
|
} from './shared';
|
|
29
|
+
import { runWithSqliteRetry } from './sqlite-retry';
|
|
26
30
|
|
|
27
31
|
export { getDefaultOpenCodeDataDir, resolveOpenCodeDbPath };
|
|
28
32
|
|
|
29
|
-
export const
|
|
33
|
+
export const OPENCODE_READ_DB_OPEN_FLAGS = constants.SQLITE_OPEN_READWRITE | constants.SQLITE_OPEN_URI;
|
|
34
|
+
const OPENCODE_READONLY_DB_OPEN_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI;
|
|
35
|
+
const DEFAULT_OPENCODE_DB_CONCURRENCY = 2;
|
|
36
|
+
const DESKTOP_STATE_FILE_CONCURRENCY = 4;
|
|
37
|
+
const OPENCODE_OPTIONAL_SESSION_TABLES = [
|
|
38
|
+
'session_context_epoch',
|
|
39
|
+
'session_input',
|
|
40
|
+
'session_message',
|
|
41
|
+
'session_share',
|
|
42
|
+
'todo',
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
type OpenCodeOptionalSessionTable = (typeof OPENCODE_OPTIONAL_SESSION_TABLES)[number];
|
|
46
|
+
|
|
47
|
+
export type DeleteOpenCodeSessionResult = {
|
|
48
|
+
deletedSessionIds: string[];
|
|
49
|
+
};
|
|
30
50
|
|
|
31
51
|
type WorkspaceRow = {
|
|
32
52
|
archivedSessionCount: number;
|
|
@@ -61,11 +81,11 @@ type SessionRow = {
|
|
|
61
81
|
summaryFiles: number | null;
|
|
62
82
|
textPartCount: number;
|
|
63
83
|
title: string;
|
|
64
|
-
tokensCacheRead: number;
|
|
65
|
-
tokensCacheWrite: number;
|
|
66
|
-
tokensInput: number;
|
|
67
|
-
tokensOutput: number;
|
|
68
|
-
tokensReasoning: number;
|
|
84
|
+
tokensCacheRead: number | null;
|
|
85
|
+
tokensCacheWrite: number | null;
|
|
86
|
+
tokensInput: number | null;
|
|
87
|
+
tokensOutput: number | null;
|
|
88
|
+
tokensReasoning: number | null;
|
|
69
89
|
toolPartCount: number;
|
|
70
90
|
worktree: string;
|
|
71
91
|
};
|
|
@@ -87,20 +107,142 @@ type PartRow = {
|
|
|
87
107
|
|
|
88
108
|
const MAIN_SESSION_FILTER = 's.parent_id IS NULL';
|
|
89
109
|
|
|
110
|
+
let nextOpenCodeDbLoadId = 1;
|
|
111
|
+
let activeOpenCodeDbLoads = 0;
|
|
112
|
+
let queuedOpenCodeDbLoads = 0;
|
|
113
|
+
|
|
114
|
+
export const resolveOpenCodeDbConcurrency = (value = process.env.SPIRACHA_OPENCODE_DB_CONCURRENCY): number => {
|
|
115
|
+
const parsed = Number.parseInt(value ?? '', 10);
|
|
116
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_OPENCODE_DB_CONCURRENCY;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const openCodeDbLimiter = createConcurrencyLimiter(resolveOpenCodeDbConcurrency());
|
|
120
|
+
const openCodeDesktopStateLimiter = createConcurrencyLimiter(1);
|
|
121
|
+
|
|
122
|
+
const logOpenCodeDb = (event: string, details: Record<string, unknown>) => {
|
|
123
|
+
if (process.env.SPIRACHA_OPENCODE_DB_LOGS !== '0') {
|
|
124
|
+
console.info(`[spiracha:opencode-db] ${event}`, details);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const runWithOpenCodeDbLimit = async <T>(operation: string, dbPath: string, action: () => T): Promise<T> => {
|
|
129
|
+
const loadId = nextOpenCodeDbLoadId;
|
|
130
|
+
nextOpenCodeDbLoadId += 1;
|
|
131
|
+
queuedOpenCodeDbLoads += 1;
|
|
132
|
+
const queuedAt = Date.now();
|
|
133
|
+
|
|
134
|
+
return openCodeDbLimiter(async () => {
|
|
135
|
+
queuedOpenCodeDbLoads -= 1;
|
|
136
|
+
activeOpenCodeDbLoads += 1;
|
|
137
|
+
const startedAt = Date.now();
|
|
138
|
+
logOpenCodeDb('start', {
|
|
139
|
+
active: activeOpenCodeDbLoads,
|
|
140
|
+
dbPath,
|
|
141
|
+
loadId,
|
|
142
|
+
operation,
|
|
143
|
+
queued: queuedOpenCodeDbLoads,
|
|
144
|
+
waitMs: startedAt - queuedAt,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
return action();
|
|
149
|
+
} catch (error) {
|
|
150
|
+
logOpenCodeDb('error', {
|
|
151
|
+
active: activeOpenCodeDbLoads,
|
|
152
|
+
dbPath,
|
|
153
|
+
durationMs: Date.now() - startedAt,
|
|
154
|
+
error: error instanceof Error ? error.message : String(error),
|
|
155
|
+
loadId,
|
|
156
|
+
operation,
|
|
157
|
+
queued: queuedOpenCodeDbLoads,
|
|
158
|
+
});
|
|
159
|
+
throw error;
|
|
160
|
+
} finally {
|
|
161
|
+
activeOpenCodeDbLoads -= 1;
|
|
162
|
+
logOpenCodeDb('finish', {
|
|
163
|
+
active: activeOpenCodeDbLoads,
|
|
164
|
+
dbPath,
|
|
165
|
+
durationMs: Date.now() - startedAt,
|
|
166
|
+
loadId,
|
|
167
|
+
operation,
|
|
168
|
+
queued: queuedOpenCodeDbLoads,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const getDefaultOpenCodeDesktopStateDir = (
|
|
175
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
176
|
+
homeDir = os.homedir(),
|
|
177
|
+
): string | null => {
|
|
178
|
+
const configured = env.SPIRACHA_OPENCODE_DESKTOP_STATE_DIR?.trim();
|
|
179
|
+
if (configured) {
|
|
180
|
+
return configured;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return process.platform === 'darwin'
|
|
184
|
+
? path.join(homeDir, 'Library', 'Application Support', 'ai.opencode.desktop')
|
|
185
|
+
: null;
|
|
186
|
+
};
|
|
187
|
+
|
|
90
188
|
const pathExists = async (target: string): Promise<boolean> => {
|
|
91
189
|
return await stat(target)
|
|
92
190
|
.then(() => true)
|
|
93
191
|
.catch(() => false);
|
|
94
192
|
};
|
|
95
193
|
|
|
96
|
-
export const
|
|
194
|
+
export const getOpenCodeReadDbUri = (dbPath: string, mode: 'ro' | 'rw' = 'rw'): string => {
|
|
97
195
|
const url = pathToFileURL(dbPath);
|
|
98
|
-
url.searchParams.set('mode',
|
|
196
|
+
url.searchParams.set('mode', mode);
|
|
99
197
|
return url.href;
|
|
100
198
|
};
|
|
101
199
|
|
|
102
|
-
|
|
103
|
-
|
|
200
|
+
const configureOpenCodeReadDb = (db: Database): Database => {
|
|
201
|
+
try {
|
|
202
|
+
db.exec('PRAGMA busy_timeout = 1000');
|
|
203
|
+
db.exec('PRAGMA query_only = ON');
|
|
204
|
+
db.query('SELECT 1 FROM sqlite_schema LIMIT 1').get();
|
|
205
|
+
return db;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
db.close();
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
export const openOpenCodeReadDb = (dbPath: string): Database => {
|
|
213
|
+
try {
|
|
214
|
+
return configureOpenCodeReadDb(
|
|
215
|
+
new Database(getOpenCodeReadDbUri(dbPath, 'ro'), OPENCODE_READONLY_DB_OPEN_FLAGS),
|
|
216
|
+
);
|
|
217
|
+
} catch {
|
|
218
|
+
return configureOpenCodeReadDb(new Database(getOpenCodeReadDbUri(dbPath), OPENCODE_READ_DB_OPEN_FLAGS));
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const withOpenCodeReadonlyDb = <T>(dbPath: string, action: (db: Database) => T): T => {
|
|
223
|
+
return runWithSqliteRetry({
|
|
224
|
+
action: () => {
|
|
225
|
+
const db = openOpenCodeReadDb(dbPath);
|
|
226
|
+
try {
|
|
227
|
+
return action(db);
|
|
228
|
+
} finally {
|
|
229
|
+
db.close();
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const withOpenCodeWritableDb = <T>(dbPath: string, action: (db: Database) => T): T => {
|
|
236
|
+
return runWithSqliteRetry({
|
|
237
|
+
action: () => {
|
|
238
|
+
const db = new Database(dbPath);
|
|
239
|
+
try {
|
|
240
|
+
return action(db);
|
|
241
|
+
} finally {
|
|
242
|
+
db.close();
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
});
|
|
104
246
|
};
|
|
105
247
|
|
|
106
248
|
const parseJsonValue = (value: string | null): JsonValue | string | null => {
|
|
@@ -120,6 +262,30 @@ const parseJsonObject = (value: string): Record<string, JsonValue> => {
|
|
|
120
262
|
return asObject(parsed) ?? {};
|
|
121
263
|
};
|
|
122
264
|
|
|
265
|
+
const parseMutableJsonObject = (value: string): Record<string, unknown> | null => {
|
|
266
|
+
try {
|
|
267
|
+
const parsed = JSON.parse(value) as unknown;
|
|
268
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
269
|
+
? (parsed as Record<string, unknown>)
|
|
270
|
+
: null;
|
|
271
|
+
} catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const parseMutableJsonArray = (value: string): unknown[] | null => {
|
|
277
|
+
try {
|
|
278
|
+
const parsed = JSON.parse(value) as unknown;
|
|
279
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const encodeOpenCodeDirectoryKey = (directory: string): string => {
|
|
286
|
+
return Buffer.from(directory).toString('base64').replace(/=+$/u, '');
|
|
287
|
+
};
|
|
288
|
+
|
|
123
289
|
const cleanLabel = (value: string | null | undefined): string | null => {
|
|
124
290
|
const cleaned = value?.replace(/\s+/g, ' ').trim();
|
|
125
291
|
return cleaned ? cleaned : null;
|
|
@@ -197,6 +363,11 @@ const toWorkspaceGroup = (row: WorkspaceRow): OpenCodeWorkspaceGroup => {
|
|
|
197
363
|
const toSessionSummary = (row: SessionRow): OpenCodeSessionSummary => {
|
|
198
364
|
const label = getWorkspaceLabel(row.projectName, row.worktree);
|
|
199
365
|
const model = parseModelInfo(row.model);
|
|
366
|
+
const tokensCacheRead = row.tokensCacheRead ?? 0;
|
|
367
|
+
const tokensCacheWrite = row.tokensCacheWrite ?? 0;
|
|
368
|
+
const tokensInput = row.tokensInput ?? 0;
|
|
369
|
+
const tokensOutput = row.tokensOutput ?? 0;
|
|
370
|
+
const tokensReasoning = row.tokensReasoning ?? 0;
|
|
200
371
|
return {
|
|
201
372
|
agent: row.agent,
|
|
202
373
|
archivedAtMs: row.archivedAtMs,
|
|
@@ -219,14 +390,13 @@ const toSessionSummary = (row: SessionRow): OpenCodeSessionSummary => {
|
|
|
219
390
|
summaryFiles: row.summaryFiles,
|
|
220
391
|
textPartCount: row.textPartCount,
|
|
221
392
|
title: cleanLabel(row.title) ?? row.sessionId,
|
|
222
|
-
tokensCacheRead
|
|
223
|
-
tokensCacheWrite
|
|
224
|
-
tokensInput
|
|
225
|
-
tokensOutput
|
|
226
|
-
tokensReasoning
|
|
393
|
+
tokensCacheRead,
|
|
394
|
+
tokensCacheWrite,
|
|
395
|
+
tokensInput,
|
|
396
|
+
tokensOutput,
|
|
397
|
+
tokensReasoning,
|
|
227
398
|
toolPartCount: row.toolPartCount,
|
|
228
|
-
totalTokens:
|
|
229
|
-
row.tokensInput + row.tokensOutput + row.tokensReasoning + row.tokensCacheRead + row.tokensCacheWrite,
|
|
399
|
+
totalTokens: tokensInput + tokensOutput + tokensReasoning + tokensCacheRead + tokensCacheWrite,
|
|
230
400
|
workspaceKey: getWorkspaceKey(row.projectId),
|
|
231
401
|
workspaceLabel: label,
|
|
232
402
|
worktree: row.worktree,
|
|
@@ -336,15 +506,14 @@ export const listOpenCodeWorkspaceGroups = async (
|
|
|
336
506
|
return [];
|
|
337
507
|
}
|
|
338
508
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
509
|
+
return runWithOpenCodeDbLimit('list-workspaces', dbPath, () =>
|
|
510
|
+
withOpenCodeReadonlyDb(dbPath, (db) => {
|
|
511
|
+
const rows = db
|
|
512
|
+
.query(`${workspaceRowsQuery} ORDER BY lastActiveMs DESC, p.worktree ASC`)
|
|
513
|
+
.all() as WorkspaceRow[];
|
|
514
|
+
return rows.map(toWorkspaceGroup);
|
|
515
|
+
}),
|
|
516
|
+
);
|
|
348
517
|
};
|
|
349
518
|
|
|
350
519
|
const openCodeWorkspaceMatchesQuery = (workspace: OpenCodeWorkspaceGroup, query: string): boolean => {
|
|
@@ -392,16 +561,391 @@ export const listOpenCodeSessionsForGroup = async (
|
|
|
392
561
|
return [];
|
|
393
562
|
}
|
|
394
563
|
|
|
395
|
-
|
|
564
|
+
return runWithOpenCodeDbLimit('list-sessions', dbPath, () =>
|
|
565
|
+
withOpenCodeReadonlyDb(dbPath, (db) => {
|
|
566
|
+
return readSessionSummaries(db, `s.project_id = ? AND ${MAIN_SESSION_FILTER}`, [projectId]);
|
|
567
|
+
}),
|
|
568
|
+
);
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const readOpenCodeSessionSummary = (db: Database, sessionId: string): OpenCodeSessionSummary | null => {
|
|
572
|
+
return readSessionSummaries(db, `s.id = ? AND ${MAIN_SESSION_FILTER}`, [sessionId])[0] ?? null;
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const readOpenCodeSessionTreeIds = (db: Database, sessionId: string): string[] => {
|
|
576
|
+
const rows = db
|
|
577
|
+
.query(
|
|
578
|
+
`WITH RECURSIVE session_tree(id) AS (
|
|
579
|
+
SELECT id FROM session WHERE id = ?
|
|
580
|
+
UNION ALL
|
|
581
|
+
SELECT child.id
|
|
582
|
+
FROM session child
|
|
583
|
+
JOIN session_tree parent ON child.parent_id = parent.id
|
|
584
|
+
)
|
|
585
|
+
SELECT id FROM session_tree`,
|
|
586
|
+
)
|
|
587
|
+
.all(sessionId) as Array<{ id: string }>;
|
|
588
|
+
return rows.map((row) => row.id);
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const hasOpenCodeTable = (db: Database, tableName: string): boolean => {
|
|
592
|
+
return Boolean(db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName));
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const deleteRowsBySessionId = (
|
|
596
|
+
db: Database,
|
|
597
|
+
tableName: OpenCodeOptionalSessionTable,
|
|
598
|
+
sessionIds: string[],
|
|
599
|
+
placeholders: string,
|
|
600
|
+
) => {
|
|
601
|
+
if (hasOpenCodeTable(db, tableName)) {
|
|
602
|
+
db.query(`DELETE FROM ${tableName} WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const deleteOpenCodeEventRows = (db: Database, sessionIds: string[], placeholders: string) => {
|
|
607
|
+
if (hasOpenCodeTable(db, 'event')) {
|
|
608
|
+
db.query(`DELETE FROM event WHERE aggregate_id IN (${placeholders})`).run(...sessionIds);
|
|
609
|
+
}
|
|
610
|
+
if (hasOpenCodeTable(db, 'event_sequence')) {
|
|
611
|
+
db.query(`DELETE FROM event_sequence WHERE aggregate_id IN (${placeholders})`).run(...sessionIds);
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const deleteEmptyOpenCodeProjects = (
|
|
616
|
+
db: Database,
|
|
617
|
+
projects: Array<{ projectId: string; worktree: string }>,
|
|
618
|
+
): string[] => {
|
|
619
|
+
const emptyWorktrees: string[] = [];
|
|
620
|
+
for (const project of projects) {
|
|
621
|
+
const remaining = db
|
|
622
|
+
.query('SELECT COUNT(*) AS count FROM session WHERE project_id = ?')
|
|
623
|
+
.get(project.projectId) as { count: number };
|
|
624
|
+
if (remaining.count === 0) {
|
|
625
|
+
db.query('DELETE FROM project WHERE id = ?').run(project.projectId);
|
|
626
|
+
emptyWorktrees.push(project.worktree);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return emptyWorktrees;
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const deleteOpenCodeSessionRows = (
|
|
633
|
+
db: Database,
|
|
634
|
+
sessionIds: string[],
|
|
635
|
+
placeholders: string,
|
|
636
|
+
projectRows: Array<{ projectId: string; worktree: string }>,
|
|
637
|
+
): string[] => {
|
|
638
|
+
deleteOpenCodeEventRows(db, sessionIds, placeholders);
|
|
639
|
+
for (const tableName of OPENCODE_OPTIONAL_SESSION_TABLES) {
|
|
640
|
+
deleteRowsBySessionId(db, tableName, sessionIds, placeholders);
|
|
641
|
+
}
|
|
642
|
+
db.query(`DELETE FROM part WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
|
643
|
+
db.query(`DELETE FROM message WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
|
644
|
+
db.query(`DELETE FROM session WHERE id IN (${placeholders})`).run(...sessionIds);
|
|
645
|
+
return deleteEmptyOpenCodeProjects(db, projectRows);
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const removeObjectKeysForSessionIds = (target: Record<string, unknown>, sessionIds: Set<string>) => {
|
|
649
|
+
let changed = false;
|
|
650
|
+
const sessionIdList = [...sessionIds];
|
|
651
|
+
for (const key of Object.keys(target)) {
|
|
652
|
+
if (sessionIds.has(key) || sessionIdList.some((sessionId) => key.endsWith(`/${sessionId}`))) {
|
|
653
|
+
delete target[key];
|
|
654
|
+
changed = true;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return changed;
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
const cloneMutableJsonObject = (value: unknown): Record<string, unknown> | null => {
|
|
661
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
662
|
+
? (structuredClone(value) as Record<string, unknown>)
|
|
663
|
+
: null;
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const updateJsonStringField = (
|
|
667
|
+
state: Record<string, unknown>,
|
|
668
|
+
key: string,
|
|
669
|
+
update: (value: string) => { changed: boolean; next: unknown },
|
|
670
|
+
) => {
|
|
671
|
+
const current = state[key];
|
|
672
|
+
if (typeof current !== 'string') {
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const result = update(current);
|
|
677
|
+
if (!result.changed) {
|
|
678
|
+
return false;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
state[key] = JSON.stringify(result.next);
|
|
682
|
+
return true;
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
const cleanOpenCodeServerProjects = (projects: Record<string, unknown>, worktrees: Set<string>): boolean => {
|
|
686
|
+
let changed = false;
|
|
687
|
+
for (const [serverId, entries] of Object.entries(projects)) {
|
|
688
|
+
if (!Array.isArray(entries)) {
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const nextEntries = entries.filter((entry) => {
|
|
693
|
+
return !(
|
|
694
|
+
entry &&
|
|
695
|
+
typeof entry === 'object' &&
|
|
696
|
+
worktrees.has(String((entry as Record<string, unknown>).worktree ?? ''))
|
|
697
|
+
);
|
|
698
|
+
});
|
|
699
|
+
if (nextEntries.length !== entries.length) {
|
|
700
|
+
projects[serverId] = nextEntries;
|
|
701
|
+
changed = true;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return changed;
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
const cleanOpenCodeServerLastProject = (lastProject: Record<string, unknown>, worktrees: Set<string>): boolean => {
|
|
708
|
+
let changed = false;
|
|
709
|
+
for (const [serverId, directory] of Object.entries(lastProject)) {
|
|
710
|
+
if (worktrees.has(String(directory))) {
|
|
711
|
+
delete lastProject[serverId];
|
|
712
|
+
changed = true;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return changed;
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
const cleanOpenCodeServerState = (value: string, worktrees: Set<string>) => {
|
|
719
|
+
const parsed = parseMutableJsonObject(value);
|
|
720
|
+
const projects = cloneMutableJsonObject(parsed?.projects);
|
|
721
|
+
const lastProject = cloneMutableJsonObject(parsed?.lastProject);
|
|
722
|
+
if (!parsed) {
|
|
723
|
+
return { changed: false, next: parsed };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const projectsChanged = projects ? cleanOpenCodeServerProjects(projects, worktrees) : false;
|
|
727
|
+
const lastProjectChanged = lastProject ? cleanOpenCodeServerLastProject(lastProject, worktrees) : false;
|
|
728
|
+
parsed.projects = projects ?? parsed.projects;
|
|
729
|
+
parsed.lastProject = lastProject ?? parsed.lastProject;
|
|
730
|
+
|
|
731
|
+
const changed = projectsChanged || lastProjectChanged;
|
|
732
|
+
return changed ? { changed: true, next: parsed } : { changed: false, next: parsed };
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
const cleanOpenCodeDesktopStateObject = (
|
|
736
|
+
state: Record<string, unknown>,
|
|
737
|
+
sessionIds: Set<string>,
|
|
738
|
+
worktrees: Set<string>,
|
|
739
|
+
): boolean => {
|
|
740
|
+
let changed = false;
|
|
741
|
+
const encodedWorktrees = new Set([...worktrees].map(encodeOpenCodeDirectoryKey));
|
|
742
|
+
const sessionIdList = [...sessionIds];
|
|
743
|
+
|
|
744
|
+
for (const key of Object.keys(state)) {
|
|
745
|
+
if (sessionIdList.some((sessionId) => key.startsWith(`session:${sessionId}:`))) {
|
|
746
|
+
delete state[key];
|
|
747
|
+
changed = true;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
changed =
|
|
752
|
+
updateJsonStringField(state, 'notification', (value) => {
|
|
753
|
+
const parsed = parseMutableJsonObject(value);
|
|
754
|
+
const list = parsed?.list;
|
|
755
|
+
if (!Array.isArray(list)) {
|
|
756
|
+
return { changed: false, next: parsed };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const nextList = list.filter((item) => {
|
|
760
|
+
return !(
|
|
761
|
+
item &&
|
|
762
|
+
typeof item === 'object' &&
|
|
763
|
+
(sessionIds.has(String((item as Record<string, unknown>).session ?? '')) ||
|
|
764
|
+
worktrees.has(String((item as Record<string, unknown>).directory ?? '')))
|
|
765
|
+
);
|
|
766
|
+
});
|
|
767
|
+
if (nextList.length === list.length) {
|
|
768
|
+
return { changed: false, next: parsed };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return { changed: true, next: { ...parsed, list: nextList } };
|
|
772
|
+
}) || changed;
|
|
773
|
+
|
|
774
|
+
changed =
|
|
775
|
+
updateJsonStringField(state, 'layout.page', (value) => {
|
|
776
|
+
const parsed = parseMutableJsonObject(value);
|
|
777
|
+
const lastProjectSession = cloneMutableJsonObject(parsed?.lastProjectSession);
|
|
778
|
+
if (!parsed || !lastProjectSession) {
|
|
779
|
+
return { changed: false, next: parsed };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
let nextChanged = false;
|
|
783
|
+
for (const [directory, session] of Object.entries(lastProjectSession)) {
|
|
784
|
+
if (
|
|
785
|
+
session &&
|
|
786
|
+
typeof session === 'object' &&
|
|
787
|
+
(sessionIds.has(String((session as Record<string, unknown>).id ?? '')) ||
|
|
788
|
+
worktrees.has(directory) ||
|
|
789
|
+
worktrees.has(String((session as Record<string, unknown>).directory ?? '')))
|
|
790
|
+
) {
|
|
791
|
+
delete lastProjectSession[directory];
|
|
792
|
+
nextChanged = true;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return nextChanged
|
|
797
|
+
? { changed: true, next: { ...parsed, lastProjectSession } }
|
|
798
|
+
: { changed: false, next: parsed };
|
|
799
|
+
}) || changed;
|
|
800
|
+
|
|
801
|
+
changed = updateJsonStringField(state, 'server', (value) => cleanOpenCodeServerState(value, worktrees)) || changed;
|
|
802
|
+
|
|
803
|
+
changed =
|
|
804
|
+
updateJsonStringField(state, 'permission', (value) => {
|
|
805
|
+
const parsed = parseMutableJsonObject(value);
|
|
806
|
+
const autoAccept = cloneMutableJsonObject(parsed?.autoAccept);
|
|
807
|
+
if (!parsed || !autoAccept) {
|
|
808
|
+
return { changed: false, next: parsed };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const nextChanged = removeObjectKeysForSessionIds(autoAccept, sessionIds);
|
|
812
|
+
return nextChanged ? { changed: true, next: { ...parsed, autoAccept } } : { changed: false, next: parsed };
|
|
813
|
+
}) || changed;
|
|
814
|
+
|
|
815
|
+
changed =
|
|
816
|
+
updateJsonStringField(state, 'tabs', (value) => {
|
|
817
|
+
const parsed = parseMutableJsonArray(value);
|
|
818
|
+
if (!parsed) {
|
|
819
|
+
return { changed: false, next: parsed };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const next = parsed.filter((item) => {
|
|
823
|
+
return !(
|
|
824
|
+
item &&
|
|
825
|
+
typeof item === 'object' &&
|
|
826
|
+
(sessionIds.has(String((item as Record<string, unknown>).sessionId ?? '')) ||
|
|
827
|
+
encodedWorktrees.has(String((item as Record<string, unknown>).dirBase64 ?? '')))
|
|
828
|
+
);
|
|
829
|
+
});
|
|
830
|
+
return next.length === parsed.length ? { changed: false, next: parsed } : { changed: true, next };
|
|
831
|
+
}) || changed;
|
|
832
|
+
|
|
833
|
+
changed =
|
|
834
|
+
updateJsonStringField(state, 'workspace:model-selection', (value) => {
|
|
835
|
+
const parsed = parseMutableJsonObject(value);
|
|
836
|
+
const session = cloneMutableJsonObject(parsed?.session);
|
|
837
|
+
if (!parsed || !session) {
|
|
838
|
+
return { changed: false, next: parsed };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const nextChanged = removeObjectKeysForSessionIds(session, sessionIds);
|
|
842
|
+
return nextChanged ? { changed: true, next: { ...parsed, session } } : { changed: false, next: parsed };
|
|
843
|
+
}) || changed;
|
|
844
|
+
|
|
845
|
+
changed =
|
|
846
|
+
updateJsonStringField(state, 'workspace:followup', (value) => {
|
|
847
|
+
const parsed = parseMutableJsonObject(value);
|
|
848
|
+
if (!parsed) {
|
|
849
|
+
return { changed: false, next: parsed };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
let nextChanged = false;
|
|
853
|
+
for (const key of ['items', 'failed', 'paused', 'edit']) {
|
|
854
|
+
const child = cloneMutableJsonObject(parsed[key]);
|
|
855
|
+
if (child && removeObjectKeysForSessionIds(child, sessionIds)) {
|
|
856
|
+
parsed[key] = child;
|
|
857
|
+
nextChanged = true;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
return nextChanged ? { changed: true, next: parsed } : { changed: false, next: parsed };
|
|
862
|
+
}) || changed;
|
|
863
|
+
|
|
864
|
+
return changed;
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
const writeOpenCodeDesktopState = async (filePath: string, state: Record<string, unknown>): Promise<void> => {
|
|
868
|
+
const tempPath = `${filePath}.${randomUUID()}.tmp`;
|
|
869
|
+
const mode = (await stat(filePath)).mode & 0o777;
|
|
396
870
|
try {
|
|
397
|
-
|
|
871
|
+
await Bun.write(tempPath, `${JSON.stringify(state, null, '\t')}\n`);
|
|
872
|
+
await chmod(tempPath, mode);
|
|
873
|
+
await rename(tempPath, filePath);
|
|
398
874
|
} finally {
|
|
399
|
-
|
|
875
|
+
await rm(tempPath, { force: true });
|
|
400
876
|
}
|
|
401
877
|
};
|
|
402
878
|
|
|
403
|
-
const
|
|
404
|
-
|
|
879
|
+
export const deleteOpenCodeDesktopSessionState = async (
|
|
880
|
+
sessionIds: string[],
|
|
881
|
+
stateDir = getDefaultOpenCodeDesktopStateDir(),
|
|
882
|
+
worktrees: string[] = [],
|
|
883
|
+
): Promise<string[]> => {
|
|
884
|
+
return openCodeDesktopStateLimiter(async () => {
|
|
885
|
+
if (!stateDir || (sessionIds.length === 0 && worktrees.length === 0) || !(await pathExists(stateDir))) {
|
|
886
|
+
return [];
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const sessionIdSet = new Set(sessionIds);
|
|
890
|
+
const worktreeSet = new Set(worktrees);
|
|
891
|
+
const fileNames = (await readdir(stateDir)).filter((fileName) => fileName.endsWith('.dat'));
|
|
892
|
+
const changedFiles = await mapWithConcurrency(
|
|
893
|
+
fileNames,
|
|
894
|
+
DESKTOP_STATE_FILE_CONCURRENCY,
|
|
895
|
+
async (fileName): Promise<string | null> => {
|
|
896
|
+
const filePath = path.join(stateDir, fileName);
|
|
897
|
+
const state = parseMutableJsonObject(await Bun.file(filePath).text());
|
|
898
|
+
if (!state || !cleanOpenCodeDesktopStateObject(state, sessionIdSet, worktreeSet)) {
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
await writeOpenCodeDesktopState(filePath, state);
|
|
903
|
+
return filePath;
|
|
904
|
+
},
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
return changedFiles.filter((filePath): filePath is string => filePath !== null);
|
|
908
|
+
});
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
export const deleteOpenCodeSession = async (
|
|
912
|
+
dbPath: string,
|
|
913
|
+
sessionId: string,
|
|
914
|
+
): Promise<DeleteOpenCodeSessionResult> => {
|
|
915
|
+
if (!(await pathExists(dbPath))) {
|
|
916
|
+
return { deletedSessionIds: [] };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const result = await runWithOpenCodeDbLimit('delete-session', dbPath, () =>
|
|
920
|
+
withOpenCodeWritableDb(dbPath, (db) => {
|
|
921
|
+
const sessionIds = readOpenCodeSessionTreeIds(db, sessionId);
|
|
922
|
+
if (sessionIds.length === 0) {
|
|
923
|
+
return { deletedSessionIds: [], emptyWorktrees: [] };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const placeholders = sessionIds.map(() => '?').join(', ');
|
|
927
|
+
const projectRows = db
|
|
928
|
+
.query(
|
|
929
|
+
`SELECT DISTINCT pr.id AS projectId, pr.worktree
|
|
930
|
+
FROM session s
|
|
931
|
+
JOIN project pr ON pr.id = s.project_id
|
|
932
|
+
WHERE s.id IN (${placeholders})`,
|
|
933
|
+
)
|
|
934
|
+
.all(...sessionIds) as Array<{ projectId: string; worktree: string }>;
|
|
935
|
+
const emptyWorktrees = db.transaction(() =>
|
|
936
|
+
deleteOpenCodeSessionRows(db, sessionIds, placeholders, projectRows),
|
|
937
|
+
)();
|
|
938
|
+
|
|
939
|
+
return { deletedSessionIds: sessionIds, emptyWorktrees };
|
|
940
|
+
}),
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
await deleteOpenCodeDesktopSessionState(
|
|
944
|
+
result.deletedSessionIds.length > 0 ? result.deletedSessionIds : [sessionId],
|
|
945
|
+
undefined,
|
|
946
|
+
result.emptyWorktrees,
|
|
947
|
+
);
|
|
948
|
+
return { deletedSessionIds: result.deletedSessionIds };
|
|
405
949
|
};
|
|
406
950
|
|
|
407
951
|
const getMessageRole = (raw: Record<string, JsonValue>): string => asString(raw.role ?? null) ?? 'unknown';
|
|
@@ -584,27 +1128,26 @@ export const readOpenCodeSessionTranscript = async (
|
|
|
584
1128
|
return null;
|
|
585
1129
|
}
|
|
586
1130
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
}
|
|
1131
|
+
return runWithOpenCodeDbLimit('read-session', dbPath, () =>
|
|
1132
|
+
withOpenCodeReadonlyDb(dbPath, (db) => {
|
|
1133
|
+
const session = readOpenCodeSessionSummary(db, sessionId);
|
|
1134
|
+
if (!session) {
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const messages = readMessages(db, sessionId);
|
|
1139
|
+
const partCount = messages.reduce((total, message) => total + message.parts.length, 0);
|
|
1140
|
+
const renderablePartCount = messages.reduce(
|
|
1141
|
+
(total, message) => total + message.parts.filter(isRenderablePart).length,
|
|
1142
|
+
0,
|
|
1143
|
+
);
|
|
1144
|
+
|
|
1145
|
+
return {
|
|
1146
|
+
messages,
|
|
1147
|
+
partCount,
|
|
1148
|
+
renderablePartCount,
|
|
1149
|
+
session,
|
|
1150
|
+
};
|
|
1151
|
+
}),
|
|
1152
|
+
);
|
|
610
1153
|
};
|