spiracha 2.1.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 +37 -19
- package/apps/ui/AGENTS.md +18 -10
- package/apps/ui/README.md +17 -10
- package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
- package/apps/ui/src/components/antigravity-conversations-table.tsx +1 -0
- package/apps/ui/src/components/app-shell.tsx +45 -3
- package/apps/ui/src/components/data-table.tsx +17 -1
- package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
- package/apps/ui/src/components/export-dialog.tsx +77 -62
- package/apps/ui/src/components/projects-table.tsx +4 -1
- package/apps/ui/src/components/route-error-panel.tsx +13 -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 +4 -1
- package/apps/ui/src/lib/antigravity-server.ts +70 -17
- package/apps/ui/src/lib/antigravity-transcript-events.ts +46 -70
- package/apps/ui/src/lib/claude-code-server.ts +14 -4
- package/apps/ui/src/lib/codex-queries.ts +3 -3
- package/apps/ui/src/lib/codex-server.ts +3 -3
- package/apps/ui/src/lib/cursor-server.ts +41 -69
- 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/grok-server.ts +2 -3
- package/apps/ui/src/lib/kiro-server.ts +14 -2
- package/apps/ui/src/lib/mutation-error.ts +15 -0
- package/apps/ui/src/lib/opencode-server.ts +13 -2
- 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-transcript-events.ts +1 -1
- 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 +1 -1
- package/apps/ui/src/lib/thread-metadata.ts +22 -0
- package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
- package/apps/ui/src/lib/workspace-delete-navigation.ts +8 -0
- package/apps/ui/src/routeTree.gen.ts +282 -282
- 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 +73 -19
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +42 -60
- package/apps/ui/src/routes/antigravity.index.tsx +2 -2
- package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +25 -11
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +27 -32
- 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 +29 -16
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +33 -27
- package/apps/ui/src/routes/cursor.index.tsx +5 -2
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +25 -12
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +20 -26
- package/apps/ui/src/routes/grok.index.tsx +2 -2
- package/apps/ui/src/routes/index.tsx +2 -18
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +25 -12
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +27 -32
- package/apps/ui/src/routes/kiro.index.tsx +2 -2
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +29 -36
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +45 -61
- package/apps/ui/src/routes/opencode.index.tsx +8 -17
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +12 -11
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +19 -25
- package/apps/ui/src/routes/qoder.index.tsx +2 -2
- package/apps/ui/src/routes/threads.$threadId.tsx +87 -56
- package/apps/ui/vite.config.ts +22 -12
- package/bin/spiracha.ts +28 -1
- package/package.json +26 -17
- package/src/client.ts +61 -16
- package/src/lib/antigravity-db.ts +236 -82
- package/src/lib/antigravity-keychain.ts +7 -8
- package/src/lib/antigravity-transcript-phase.ts +47 -0
- package/src/lib/claude-code-db.ts +45 -19
- package/src/lib/codex-analytics.ts +32 -5
- package/src/lib/codex-browser-db.ts +540 -137
- package/src/lib/codex-browser-export.ts +2 -7
- package/src/lib/codex-browser-types.ts +21 -1
- package/src/lib/codex-thread-cache.ts +88 -33
- package/src/lib/codex-thread-parser.ts +78 -35
- package/src/lib/codex-thread-recovery.ts +38 -11
- package/src/lib/codex-transcript-renderer.ts +49 -61
- package/src/lib/concurrency.ts +15 -3
- package/src/lib/conversation-api.ts +34 -31
- package/src/lib/conversation-data/adapter-helpers.ts +25 -3
- package/src/lib/conversation-data/antigravity-adapter.ts +54 -29
- package/src/lib/conversation-data/claude-code-adapter.ts +32 -6
- package/src/lib/conversation-data/codex-adapter.ts +91 -80
- package/src/lib/conversation-data/cursor-adapter.ts +48 -34
- package/src/lib/conversation-data/grok-adapter.ts +19 -6
- package/src/lib/conversation-data/index.ts +29 -10
- package/src/lib/conversation-data/kiro-adapter.ts +18 -5
- package/src/lib/conversation-data/message-selector.ts +2 -5
- package/src/lib/conversation-data/opencode-adapter.ts +43 -15
- package/src/lib/conversation-data/path-match.ts +1 -13
- package/src/lib/conversation-data/qoder-adapter.ts +23 -18
- package/src/lib/conversation-zip-export.ts +20 -2
- package/src/lib/cursor-db.ts +34 -23
- 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 +76 -34
- package/src/lib/grok-transcript-phase.ts +11 -2
- package/src/lib/grok-transcript.ts +15 -2
- package/src/lib/kiro-db.ts +161 -68
- package/src/lib/kiro-transcript.ts +0 -4
- package/src/lib/opencode-db.ts +4 -8
- package/src/lib/opencode-think-tags.ts +17 -3
- 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 +51 -15
- package/src/lib/sqlite-error.ts +31 -5
- package/src/lib/transcript-load-limiter.ts +8 -3
- package/src/lib/ui-cache.ts +152 -17
- package/src/lib/ui-export-archive.ts +14 -55
- 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
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { constants, Database } from 'bun:sqlite';
|
|
2
2
|
import { closeSync, openSync, readdirSync, readSync, type Stats, statSync } from 'node:fs';
|
|
3
|
-
import { rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { realpath, rename, rm, stat } from 'node:fs/promises';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { StringDecoder } from 'node:string_decoder';
|
|
7
7
|
import { pathToFileURL } from 'node:url';
|
|
8
8
|
import type {
|
|
9
9
|
DashboardSummary,
|
|
10
|
+
DashboardThreadSummary,
|
|
10
11
|
DeleteProjectResult,
|
|
11
12
|
DeleteThreadsResult,
|
|
12
13
|
DynamicToolRow,
|
|
@@ -14,11 +15,12 @@ import type {
|
|
|
14
15
|
ThreadBrowseData,
|
|
15
16
|
ThreadListEntry,
|
|
16
17
|
} from './codex-browser-types';
|
|
17
|
-
import {
|
|
18
|
+
import { getCachedCodexTranscriptStats, getThreadRolloutLoadState } from './codex-thread-cache';
|
|
18
19
|
import type { ThreadRelations, ThreadRow } from './codex-thread-types';
|
|
19
20
|
import { DEFAULT_CODEX_DIR, DEFAULT_DB_PATH } from './codex-thread-types';
|
|
20
21
|
import { mapWithConcurrency } from './concurrency';
|
|
21
|
-
import {
|
|
22
|
+
import { getPortablePathBasename } from './portable-path';
|
|
23
|
+
import { cleanInlineTitle } from './shared';
|
|
22
24
|
import { runWithSqliteRetry } from './sqlite-retry';
|
|
23
25
|
import { invalidateCacheByPrefix } from './ui-cache';
|
|
24
26
|
|
|
@@ -40,7 +42,76 @@ const FALLBACK_STATS_HEAD_READ_LIMIT_BYTES = 512 * 1024;
|
|
|
40
42
|
const FALLBACK_STATS_TAIL_READ_LIMIT_BYTES = 512 * 1024;
|
|
41
43
|
const FALLBACK_STATS_RECORD_PATTERN = /"type"\s*:\s*"(?:agent_message|message|token_count|turn_context)"/u;
|
|
42
44
|
const THREAD_ID_PATTERN = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/iu;
|
|
43
|
-
const
|
|
45
|
+
const THREAD_ROW_COLUMNS = `
|
|
46
|
+
id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
|
|
47
|
+
sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at,
|
|
48
|
+
git_sha, git_branch, git_origin_url, cli_version, first_user_message, agent_nickname,
|
|
49
|
+
agent_role, memory_mode, model, reasoning_effort, agent_path, created_at_ms,
|
|
50
|
+
updated_at_ms, thread_source, preview
|
|
51
|
+
`;
|
|
52
|
+
const PROJECT_CWD_FILTER = `
|
|
53
|
+
typeof(cwd) = 'text'
|
|
54
|
+
AND (
|
|
55
|
+
RTRIM(cwd, '/\\') = ?1
|
|
56
|
+
OR SUBSTR(RTRIM(cwd, '/\\'), -(LENGTH(?1) + 1)) = '/' || ?1
|
|
57
|
+
OR SUBSTR(RTRIM(cwd, '/\\'), -(LENGTH(?1) + 1)) = '\\' || ?1
|
|
58
|
+
)
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
type ActivityTimestampedThread = {
|
|
62
|
+
id: string;
|
|
63
|
+
rollout_path: string;
|
|
64
|
+
updated_at: number;
|
|
65
|
+
updated_at_ms: number | null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type DashboardThreadCandidate = DashboardThreadSummary & Pick<ThreadRow, 'first_user_message' | 'rollout_path'>;
|
|
69
|
+
|
|
70
|
+
type ProjectAggregateRow = {
|
|
71
|
+
archived_thread_count: number;
|
|
72
|
+
cwd: string;
|
|
73
|
+
last_updated_at_ms: number | null;
|
|
74
|
+
model: string | null;
|
|
75
|
+
thread_count: number;
|
|
76
|
+
total_tokens: number;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type ThreadGoalRow = {
|
|
80
|
+
created_at_ms: number;
|
|
81
|
+
goal_id: string;
|
|
82
|
+
objective: string;
|
|
83
|
+
status: string;
|
|
84
|
+
time_used_seconds: number;
|
|
85
|
+
token_budget: number | null;
|
|
86
|
+
tokens_used: number;
|
|
87
|
+
updated_at_ms: number;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
type ProjectSummaryAccumulator = {
|
|
91
|
+
archivedThreadCount: number;
|
|
92
|
+
cwdPaths: Set<string>;
|
|
93
|
+
lastUpdatedAtMs: number | null;
|
|
94
|
+
modelNames: Set<string>;
|
|
95
|
+
name: string;
|
|
96
|
+
threadCount: number;
|
|
97
|
+
totalTokens: number;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
type ProjectSummaryMap = Map<string, ProjectSummaryAccumulator>;
|
|
101
|
+
type SessionFileIndexCacheEntry = {
|
|
102
|
+
fingerprint: string;
|
|
103
|
+
sessionFilesByThreadId: Map<string, string>;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type FallbackThreadRowCacheEntry = {
|
|
107
|
+
fingerprint: string;
|
|
108
|
+
row: ThreadRow | null;
|
|
109
|
+
sessionMeta: FallbackSessionMeta | null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const sessionFileIndexCache = new Map<string, SessionFileIndexCacheEntry>();
|
|
113
|
+
const sessionIndexEntriesCache = new Map<string, { entries: SessionIndexEntry[]; fingerprint: string }>();
|
|
114
|
+
const fallbackThreadRowCache = new Map<string, FallbackThreadRowCacheEntry>();
|
|
44
115
|
let sessionIndexMutationQueue = Promise.resolve();
|
|
45
116
|
|
|
46
117
|
type SessionIndexEntry = {
|
|
@@ -55,6 +126,7 @@ type FallbackSessionMeta = {
|
|
|
55
126
|
agent_role?: string;
|
|
56
127
|
cli_version?: string;
|
|
57
128
|
cwd?: string;
|
|
129
|
+
dynamic_tools?: unknown;
|
|
58
130
|
forked_from_id?: string;
|
|
59
131
|
id?: string;
|
|
60
132
|
model_provider?: string;
|
|
@@ -128,7 +200,7 @@ const openWritableDb = (dbPath: string, busyTimeoutMs: number) => {
|
|
|
128
200
|
}
|
|
129
201
|
};
|
|
130
202
|
|
|
131
|
-
const toTimestampMs = (thread: ThreadRow) => {
|
|
203
|
+
const toTimestampMs = (thread: Pick<ThreadRow, 'updated_at' | 'updated_at_ms'>) => {
|
|
132
204
|
return thread.updated_at_ms ?? thread.updated_at * 1000;
|
|
133
205
|
};
|
|
134
206
|
|
|
@@ -217,11 +289,15 @@ export const resolveCodexThreadDbPath = () => {
|
|
|
217
289
|
throw new Error(`Unable to open Codex thread database. Tried: ${candidates.join(', ')}`);
|
|
218
290
|
};
|
|
219
291
|
|
|
220
|
-
const
|
|
292
|
+
const readThreads = (dbPath: string, projectName: string | null = null): ThreadRow[] => {
|
|
221
293
|
return withReadonlyDb(dbPath, (db) => {
|
|
294
|
+
const projectFilter = projectName ? `WHERE ${PROJECT_CWD_FILTER}` : '';
|
|
222
295
|
return db
|
|
223
|
-
.query(
|
|
224
|
-
|
|
296
|
+
.query(
|
|
297
|
+
`SELECT ${THREAD_ROW_COLUMNS} FROM threads ${projectFilter}
|
|
298
|
+
ORDER BY COALESCE(updated_at_ms, updated_at * 1000) DESC, id DESC`,
|
|
299
|
+
)
|
|
300
|
+
.all(...(projectName ? [projectName] : [])) as ThreadRow[];
|
|
225
301
|
});
|
|
226
302
|
};
|
|
227
303
|
|
|
@@ -230,6 +306,34 @@ const resolveCodexDirFromDbPath = (dbPath: string) => {
|
|
|
230
306
|
return path.basename(dbDir) === 'sqlite' ? path.dirname(dbDir) : dbDir;
|
|
231
307
|
};
|
|
232
308
|
|
|
309
|
+
const resolveCodexRolloutPath = (dbPath: string, rolloutPath: string) =>
|
|
310
|
+
path.isAbsolute(rolloutPath) ? rolloutPath : path.join(resolveCodexDirFromDbPath(dbPath), rolloutPath);
|
|
311
|
+
|
|
312
|
+
const assertSafeCodexRolloutPaths = async (dbPath: string, rolloutPaths: string[]): Promise<void> => {
|
|
313
|
+
const codexDir = path.resolve(resolveCodexDirFromDbPath(dbPath));
|
|
314
|
+
const canonicalCodexDir = await realpath(codexDir).catch(() => codexDir);
|
|
315
|
+
|
|
316
|
+
await Promise.all(
|
|
317
|
+
rolloutPaths.map(async (rolloutPath) => {
|
|
318
|
+
const resolvedPath = path.resolve(resolveCodexRolloutPath(dbPath, rolloutPath));
|
|
319
|
+
const canonicalPath = await realpath(resolvedPath).catch(() => resolvedPath);
|
|
320
|
+
const relativePath = path.relative(canonicalCodexDir, canonicalPath);
|
|
321
|
+
if (!relativePath || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
|
|
322
|
+
throw new Error(`Unsafe Codex rollout path: ${rolloutPath}`);
|
|
323
|
+
}
|
|
324
|
+
}),
|
|
325
|
+
);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
export class CodexThreadNotFoundError extends Error {
|
|
329
|
+
readonly code = 'CODEX_THREAD_NOT_FOUND';
|
|
330
|
+
|
|
331
|
+
constructor(threadId: string) {
|
|
332
|
+
super(`Thread not found: ${threadId}`);
|
|
333
|
+
this.name = 'CodexThreadNotFoundError';
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
233
337
|
const parseJsonlObject = <T>(line: string): T | null => {
|
|
234
338
|
try {
|
|
235
339
|
return JSON.parse(line) as T;
|
|
@@ -281,8 +385,11 @@ const readJsonlObjects = <T>(filePath: string, onRecord: (record: T) => void) =>
|
|
|
281
385
|
}
|
|
282
386
|
|
|
283
387
|
emitJsonlLine(pending + decoder.end(), onRecord);
|
|
284
|
-
} catch {
|
|
285
|
-
|
|
388
|
+
} catch (error) {
|
|
389
|
+
if ((error as { code?: unknown }).code === 'ENOENT') {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
throw error;
|
|
286
393
|
} finally {
|
|
287
394
|
if (descriptor !== null) {
|
|
288
395
|
closeSync(descriptor);
|
|
@@ -299,13 +406,39 @@ const collectJsonlObjects = <T>(filePath: string): T[] => {
|
|
|
299
406
|
};
|
|
300
407
|
|
|
301
408
|
const readSessionIndexEntries = (codexDir: string): SessionIndexEntry[] => {
|
|
302
|
-
|
|
409
|
+
const sessionIndexPath = path.join(codexDir, 'session_index.jsonl');
|
|
410
|
+
let fingerprint = 'missing';
|
|
411
|
+
try {
|
|
412
|
+
const metadata = statSync(sessionIndexPath);
|
|
413
|
+
fingerprint = `${metadata.size}:${metadata.mtimeMs}`;
|
|
414
|
+
} catch {}
|
|
415
|
+
const cached = sessionIndexEntriesCache.get(codexDir);
|
|
416
|
+
if (cached?.fingerprint === fingerprint) {
|
|
417
|
+
return cached.entries;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const entries = collectJsonlObjects<SessionIndexEntry>(sessionIndexPath).filter(
|
|
303
421
|
(entry) => typeof entry.id === 'string' && entry.id.length > 0,
|
|
304
422
|
);
|
|
423
|
+
sessionIndexEntriesCache.set(codexDir, { entries, fingerprint });
|
|
424
|
+
return entries;
|
|
305
425
|
};
|
|
306
426
|
|
|
307
427
|
const collectSessionFilesByThreadId = (sessionsDir: string): Map<string, string> => {
|
|
428
|
+
const ambiguousThreadIds = new Set<string>();
|
|
308
429
|
const sessionFiles = new Map<string, string>();
|
|
430
|
+
const recordSessionFile = (fileName: string, filePath: string) => {
|
|
431
|
+
const threadId = THREAD_ID_PATTERN.exec(fileName)?.[1];
|
|
432
|
+
if (!threadId || ambiguousThreadIds.has(threadId)) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (sessionFiles.has(threadId)) {
|
|
436
|
+
sessionFiles.delete(threadId);
|
|
437
|
+
ambiguousThreadIds.add(threadId);
|
|
438
|
+
} else {
|
|
439
|
+
sessionFiles.set(threadId, filePath);
|
|
440
|
+
}
|
|
441
|
+
};
|
|
309
442
|
const visit = (directory: string) => {
|
|
310
443
|
const entries = (() => {
|
|
311
444
|
try {
|
|
@@ -329,10 +462,7 @@ const collectSessionFilesByThreadId = (sessionsDir: string): Map<string, string>
|
|
|
329
462
|
continue;
|
|
330
463
|
}
|
|
331
464
|
|
|
332
|
-
|
|
333
|
-
if (threadId && !sessionFiles.has(threadId)) {
|
|
334
|
-
sessionFiles.set(threadId, entryPath);
|
|
335
|
-
}
|
|
465
|
+
recordSessionFile(entry.name, entryPath);
|
|
336
466
|
}
|
|
337
467
|
};
|
|
338
468
|
|
|
@@ -340,6 +470,36 @@ const collectSessionFilesByThreadId = (sessionsDir: string): Map<string, string>
|
|
|
340
470
|
return sessionFiles;
|
|
341
471
|
};
|
|
342
472
|
|
|
473
|
+
const getSessionFileIndexFingerprint = (sessionsDir: string) => {
|
|
474
|
+
const toFingerprintPart = (targetPath: string) => {
|
|
475
|
+
try {
|
|
476
|
+
const metadata = statSync(targetPath);
|
|
477
|
+
return `${metadata.size}:${metadata.mtimeMs}`;
|
|
478
|
+
} catch {
|
|
479
|
+
return 'missing';
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
return `${toFingerprintPart(path.join(path.dirname(sessionsDir), 'session_index.jsonl'))}:${toFingerprintPart(
|
|
484
|
+
sessionsDir,
|
|
485
|
+
)}`;
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const getSessionFilesByThreadId = (sessionsDir: string) => {
|
|
489
|
+
const fingerprint = getSessionFileIndexFingerprint(sessionsDir);
|
|
490
|
+
const cached = sessionFileIndexCache.get(sessionsDir);
|
|
491
|
+
if (cached?.fingerprint === fingerprint) {
|
|
492
|
+
return cached.sessionFilesByThreadId;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const sessionFilesByThreadId = collectSessionFilesByThreadId(sessionsDir);
|
|
496
|
+
sessionFileIndexCache.set(sessionsDir, {
|
|
497
|
+
fingerprint,
|
|
498
|
+
sessionFilesByThreadId,
|
|
499
|
+
});
|
|
500
|
+
return sessionFilesByThreadId;
|
|
501
|
+
};
|
|
502
|
+
|
|
343
503
|
const findSessionFileByThreadId = (sessionsDir: string, threadId: string): string | null => {
|
|
344
504
|
const lookup = (sessionFilesByThreadId: Map<string, string>) => {
|
|
345
505
|
const sessionFile = sessionFilesByThreadId.get(threadId);
|
|
@@ -354,15 +514,7 @@ const findSessionFileByThreadId = (sessionsDir: string, threadId: string): strin
|
|
|
354
514
|
}
|
|
355
515
|
};
|
|
356
516
|
|
|
357
|
-
|
|
358
|
-
const cachedMatch = cached ? lookup(cached) : null;
|
|
359
|
-
if (cachedMatch) {
|
|
360
|
-
return cachedMatch;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const refreshed = collectSessionFilesByThreadId(sessionsDir);
|
|
364
|
-
sessionFileIndexCache.set(sessionsDir, refreshed);
|
|
365
|
-
return lookup(refreshed);
|
|
517
|
+
return lookup(getSessionFilesByThreadId(sessionsDir));
|
|
366
518
|
};
|
|
367
519
|
|
|
368
520
|
const readSessionMetaLine = (sessionFile: string): string | null => {
|
|
@@ -430,6 +582,32 @@ const objectOrNull = (value: unknown) => {
|
|
|
430
582
|
: null;
|
|
431
583
|
};
|
|
432
584
|
|
|
585
|
+
const parseFallbackDynamicTools = (sessionMeta: FallbackSessionMeta, threadId: string): DynamicToolRow[] => {
|
|
586
|
+
if (!Array.isArray(sessionMeta.dynamic_tools)) {
|
|
587
|
+
return [];
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return sessionMeta.dynamic_tools.flatMap((value, position) => {
|
|
591
|
+
const tool = objectOrNull(value);
|
|
592
|
+
if (!tool) {
|
|
593
|
+
return [];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return [
|
|
597
|
+
{
|
|
598
|
+
deferLoading: tool.deferLoading === true || tool.defer_loading === true,
|
|
599
|
+
description: stringOrNull(tool.description) ?? '',
|
|
600
|
+
inputSchema: (objectOrNull(tool.inputSchema) ??
|
|
601
|
+
objectOrNull(tool.input_schema)) as DynamicToolRow['inputSchema'],
|
|
602
|
+
name: stringOrNull(tool.name) ?? 'unknown',
|
|
603
|
+
namespace: stringOrNull(tool.namespace),
|
|
604
|
+
position,
|
|
605
|
+
threadId,
|
|
606
|
+
},
|
|
607
|
+
];
|
|
608
|
+
});
|
|
609
|
+
};
|
|
610
|
+
|
|
433
611
|
const isFallbackSubagent = (sessionMeta: FallbackSessionMeta) => {
|
|
434
612
|
return Boolean(
|
|
435
613
|
sessionMeta.thread_source === 'subagent' ||
|
|
@@ -521,8 +699,8 @@ const readFallbackRolloutStatsHead = (sessionFile: string, stats: FallbackRollou
|
|
|
521
699
|
return position;
|
|
522
700
|
}
|
|
523
701
|
|
|
524
|
-
decoder.end();
|
|
525
|
-
return position;
|
|
702
|
+
const partialLine = pending + decoder.end();
|
|
703
|
+
return Math.max(0, position - Buffer.byteLength(partialLine, 'utf8'));
|
|
526
704
|
} catch {
|
|
527
705
|
return 0;
|
|
528
706
|
} finally {
|
|
@@ -596,14 +774,14 @@ const readFallbackRolloutStatsTail = (
|
|
|
596
774
|
}
|
|
597
775
|
};
|
|
598
776
|
|
|
599
|
-
const readFallbackRolloutStats = (sessionFile: string): FallbackRolloutStats => {
|
|
777
|
+
const readFallbackRolloutStats = (sessionFile: string, knownFileStats?: Stats): FallbackRolloutStats => {
|
|
600
778
|
const stats: FallbackRolloutStats = {
|
|
601
779
|
model: null,
|
|
602
780
|
tokensUsed: 0,
|
|
603
781
|
};
|
|
604
782
|
|
|
605
783
|
try {
|
|
606
|
-
const fileStats = statSync(sessionFile);
|
|
784
|
+
const fileStats = knownFileStats ?? statSync(sessionFile);
|
|
607
785
|
if (!fileStats.isFile()) {
|
|
608
786
|
return stats;
|
|
609
787
|
}
|
|
@@ -622,18 +800,14 @@ const buildFallbackThreadRow = (
|
|
|
622
800
|
sessionFile: string,
|
|
623
801
|
sessionMeta: FallbackSessionMeta,
|
|
624
802
|
rolloutStats: FallbackRolloutStats,
|
|
803
|
+
rolloutMtimeMs: number,
|
|
625
804
|
): ThreadRow | null => {
|
|
626
805
|
const cwd = stringOrNull(sessionMeta.cwd);
|
|
627
806
|
if (!cwd) {
|
|
628
807
|
return null;
|
|
629
808
|
}
|
|
630
809
|
|
|
631
|
-
|
|
632
|
-
try {
|
|
633
|
-
mtimeMs = statSync(sessionFile).mtimeMs;
|
|
634
|
-
} catch {}
|
|
635
|
-
|
|
636
|
-
const updatedAtMs = parseIsoMs(entry.updated_at, mtimeMs);
|
|
810
|
+
const updatedAtMs = parseIsoMs(entry.updated_at, rolloutMtimeMs);
|
|
637
811
|
const createdAtMs = parseIsoMs(sessionMeta.timestamp, updatedAtMs);
|
|
638
812
|
const title = entry.thread_name?.trim() || path.basename(sessionFile, '.jsonl');
|
|
639
813
|
const source = stringOrNull(sessionMeta.source) ?? 'session_file';
|
|
@@ -676,8 +850,17 @@ const readFallbackThreadRow = (
|
|
|
676
850
|
sessionFile: string,
|
|
677
851
|
options: FallbackThreadRowOptions = {},
|
|
678
852
|
): ThreadRow | null => {
|
|
679
|
-
|
|
853
|
+
let fileStats: Stats;
|
|
854
|
+
try {
|
|
855
|
+
fileStats = statSync(sessionFile);
|
|
856
|
+
} catch {
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
const fingerprint = `${fileStats.size}:${fileStats.mtimeMs}:${entry.thread_name ?? ''}:${entry.updated_at ?? ''}`;
|
|
860
|
+
const cached = fallbackThreadRowCache.get(sessionFile);
|
|
861
|
+
const sessionMeta = cached?.fingerprint === fingerprint ? cached.sessionMeta : readFallbackSessionMeta(sessionFile);
|
|
680
862
|
if (!sessionMeta) {
|
|
863
|
+
fallbackThreadRowCache.set(sessionFile, { fingerprint, row: null, sessionMeta: null });
|
|
681
864
|
return null;
|
|
682
865
|
}
|
|
683
866
|
|
|
@@ -690,7 +873,19 @@ const readFallbackThreadRow = (
|
|
|
690
873
|
return null;
|
|
691
874
|
}
|
|
692
875
|
|
|
693
|
-
|
|
876
|
+
if (cached?.fingerprint === fingerprint) {
|
|
877
|
+
return cached.row;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const row = buildFallbackThreadRow(
|
|
881
|
+
entry,
|
|
882
|
+
sessionFile,
|
|
883
|
+
sessionMeta,
|
|
884
|
+
readFallbackRolloutStats(sessionFile, fileStats),
|
|
885
|
+
fileStats.mtimeMs,
|
|
886
|
+
);
|
|
887
|
+
fallbackThreadRowCache.set(sessionFile, { fingerprint, row, sessionMeta });
|
|
888
|
+
return row;
|
|
694
889
|
};
|
|
695
890
|
|
|
696
891
|
const readFallbackThreadRows = (
|
|
@@ -700,7 +895,7 @@ const readFallbackThreadRows = (
|
|
|
700
895
|
options: ReadFallbackThreadRowsOptions = {},
|
|
701
896
|
): ThreadRow[] => {
|
|
702
897
|
const codexDir = resolveCodexDirFromDbPath(dbPath);
|
|
703
|
-
const sessionFilesByThreadId =
|
|
898
|
+
const sessionFilesByThreadId = getSessionFilesByThreadId(path.join(codexDir, 'sessions'));
|
|
704
899
|
const fallbackThreads: ThreadRow[] = [];
|
|
705
900
|
|
|
706
901
|
for (const entry of readSessionIndexEntries(codexDir)) {
|
|
@@ -758,36 +953,55 @@ const mergeFallbackThreadRows = (dbPath: string, threads: ThreadRow[], projectNa
|
|
|
758
953
|
});
|
|
759
954
|
};
|
|
760
955
|
|
|
761
|
-
const applyRolloutActivityTimestamps =
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
956
|
+
const applyRolloutActivityTimestamps = async <T extends ActivityTimestampedThread>(
|
|
957
|
+
dbPath: string,
|
|
958
|
+
threads: T[],
|
|
959
|
+
): Promise<T[]> => {
|
|
960
|
+
const activeThreads = await mapWithConcurrency(threads, THREAD_LIST_IO_CONCURRENCY, async (thread): Promise<T> => {
|
|
961
|
+
const rolloutPath = resolveCodexRolloutPath(dbPath, thread.rollout_path);
|
|
962
|
+
const normalizedThread =
|
|
963
|
+
rolloutPath === thread.rollout_path ? thread : { ...thread, rollout_path: rolloutPath };
|
|
964
|
+
let rolloutUpdatedAtMs = toTimestampMs(thread);
|
|
965
|
+
try {
|
|
966
|
+
rolloutUpdatedAtMs = Math.max(rolloutUpdatedAtMs, (await stat(rolloutPath)).mtimeMs);
|
|
967
|
+
} catch {}
|
|
768
968
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
969
|
+
if (rolloutUpdatedAtMs <= toTimestampMs(thread)) {
|
|
970
|
+
return normalizedThread;
|
|
971
|
+
}
|
|
772
972
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
.sort((left, right) => {
|
|
780
|
-
const updatedDifference = toTimestampMs(right) - toTimestampMs(left);
|
|
781
|
-
if (updatedDifference !== 0) {
|
|
782
|
-
return updatedDifference;
|
|
783
|
-
}
|
|
973
|
+
return {
|
|
974
|
+
...normalizedThread,
|
|
975
|
+
updated_at: Math.floor(rolloutUpdatedAtMs / 1000),
|
|
976
|
+
updated_at_ms: Math.floor(rolloutUpdatedAtMs),
|
|
977
|
+
};
|
|
978
|
+
});
|
|
784
979
|
|
|
785
|
-
|
|
786
|
-
|
|
980
|
+
return activeThreads.sort((left, right) => {
|
|
981
|
+
const updatedDifference = toTimestampMs(right) - toTimestampMs(left);
|
|
982
|
+
if (updatedDifference !== 0) {
|
|
983
|
+
return updatedDifference;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
return right.id.localeCompare(left.id);
|
|
987
|
+
});
|
|
787
988
|
};
|
|
788
989
|
|
|
789
|
-
const
|
|
790
|
-
|
|
990
|
+
const compactDashboardThread = (thread: DashboardThreadCandidate): DashboardThreadSummary => {
|
|
991
|
+
return {
|
|
992
|
+
cwd: thread.cwd,
|
|
993
|
+
id: thread.id,
|
|
994
|
+
model: thread.model,
|
|
995
|
+
preview: cleanInlineTitle(thread.preview || thread.first_user_message || ''),
|
|
996
|
+
title: cleanInlineTitle(thread.title),
|
|
997
|
+
tokens_used: thread.tokens_used,
|
|
998
|
+
updated_at: thread.updated_at,
|
|
999
|
+
updated_at_ms: thread.updated_at_ms,
|
|
1000
|
+
};
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
const buildDashboardRecentThreads = (threads: DashboardThreadCandidate[]) => {
|
|
1004
|
+
const bestThreadByProject = new Map<string, DashboardThreadCandidate>();
|
|
791
1005
|
for (const thread of threads) {
|
|
792
1006
|
const project = getPortablePathBasename(thread.cwd);
|
|
793
1007
|
if (!project) {
|
|
@@ -812,31 +1026,12 @@ const buildDashboardRecentThreads = (threads: ThreadRow[]) => {
|
|
|
812
1026
|
.slice(0, 5)
|
|
813
1027
|
.map((thread) => ({
|
|
814
1028
|
project: getPortablePathBasename(thread.cwd),
|
|
815
|
-
thread:
|
|
1029
|
+
thread: compactDashboardThread(thread),
|
|
816
1030
|
}));
|
|
817
1031
|
};
|
|
818
1032
|
|
|
819
|
-
const filterThreadsByProject = (threads: ThreadRow[], projectName: string | null) => {
|
|
820
|
-
if (!projectName) {
|
|
821
|
-
return threads;
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
return threads.filter((thread) => getPortablePathBasename(thread.cwd) === projectName);
|
|
825
|
-
};
|
|
826
|
-
|
|
827
1033
|
const buildProjectSummaryMap = (threads: ThreadRow[]) => {
|
|
828
|
-
const projectMap = new Map
|
|
829
|
-
string,
|
|
830
|
-
{
|
|
831
|
-
archivedThreadCount: number;
|
|
832
|
-
cwdPaths: Set<string>;
|
|
833
|
-
lastUpdatedAtMs: number | null;
|
|
834
|
-
modelNames: Set<string>;
|
|
835
|
-
name: string;
|
|
836
|
-
threadCount: number;
|
|
837
|
-
totalTokens: number;
|
|
838
|
-
}
|
|
839
|
-
>();
|
|
1034
|
+
const projectMap: ProjectSummaryMap = new Map();
|
|
840
1035
|
|
|
841
1036
|
for (const thread of threads) {
|
|
842
1037
|
const projectName = getPortablePathBasename(thread.cwd);
|
|
@@ -867,7 +1062,37 @@ const buildProjectSummaryMap = (threads: ThreadRow[]) => {
|
|
|
867
1062
|
return projectMap;
|
|
868
1063
|
};
|
|
869
1064
|
|
|
870
|
-
const
|
|
1065
|
+
const mergeProjectAggregateRows = (projectMap: ProjectSummaryMap, rows: ProjectAggregateRow[]) => {
|
|
1066
|
+
for (const row of rows) {
|
|
1067
|
+
const projectName = getPortablePathBasename(row.cwd);
|
|
1068
|
+
if (!projectName) {
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const current = projectMap.get(projectName) ?? {
|
|
1073
|
+
archivedThreadCount: 0,
|
|
1074
|
+
cwdPaths: new Set<string>(),
|
|
1075
|
+
lastUpdatedAtMs: null,
|
|
1076
|
+
modelNames: new Set<string>(),
|
|
1077
|
+
name: projectName,
|
|
1078
|
+
threadCount: 0,
|
|
1079
|
+
totalTokens: 0,
|
|
1080
|
+
};
|
|
1081
|
+
current.archivedThreadCount += Number(row.archived_thread_count);
|
|
1082
|
+
current.cwdPaths.add(row.cwd);
|
|
1083
|
+
current.lastUpdatedAtMs = Math.max(current.lastUpdatedAtMs ?? 0, Number(row.last_updated_at_ms ?? 0));
|
|
1084
|
+
if (row.model) {
|
|
1085
|
+
current.modelNames.add(row.model);
|
|
1086
|
+
}
|
|
1087
|
+
current.threadCount += Number(row.thread_count);
|
|
1088
|
+
current.totalTokens += Number(row.total_tokens);
|
|
1089
|
+
projectMap.set(projectName, current);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
return projectMap;
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
const mapProjectSummaries = (projectMap: ProjectSummaryMap): ProjectSummary[] => {
|
|
871
1096
|
return [...projectMap.values()]
|
|
872
1097
|
.map((project) => {
|
|
873
1098
|
return {
|
|
@@ -1023,12 +1248,19 @@ const getSessionFilesForThreadIds = (dbPath: string, threadIds: string[]) => {
|
|
|
1023
1248
|
return sessionFile ? [sessionFile] : [];
|
|
1024
1249
|
}
|
|
1025
1250
|
|
|
1026
|
-
const sessionFilesByThreadId =
|
|
1251
|
+
const sessionFilesByThreadId = getSessionFilesByThreadId(path.join(codexDir, 'sessions'));
|
|
1027
1252
|
return threadIds
|
|
1028
1253
|
.map((threadId) => sessionFilesByThreadId.get(threadId))
|
|
1029
1254
|
.filter((value): value is string => Boolean(value));
|
|
1030
1255
|
};
|
|
1031
1256
|
|
|
1257
|
+
const validateSessionFileDeletionTargets = async (dbPath: string, threadIds: string[]): Promise<void> => {
|
|
1258
|
+
const dbSessionFiles = withReadonlyDb(dbPath, (db) =>
|
|
1259
|
+
getThreadDeleteTargets(db, threadIds).map((target) => target.rollout_path),
|
|
1260
|
+
);
|
|
1261
|
+
await assertSafeCodexRolloutPaths(dbPath, [...dbSessionFiles, ...getSessionFilesForThreadIds(dbPath, threadIds)]);
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1032
1264
|
const filterSessionIndexLines = (lines: string[], threadIds: Set<string>) => {
|
|
1033
1265
|
const removedThreadIds: string[] = [];
|
|
1034
1266
|
const retainedLines: string[] = [];
|
|
@@ -1051,13 +1283,52 @@ const filterSessionIndexLines = (lines: string[], threadIds: Set<string>) => {
|
|
|
1051
1283
|
return { removedThreadIds, retainedLines };
|
|
1052
1284
|
};
|
|
1053
1285
|
|
|
1054
|
-
const
|
|
1286
|
+
export const mergeSessionIndexLinesForRewrite = (
|
|
1287
|
+
initialLines: string[],
|
|
1288
|
+
retainedLines: string[],
|
|
1289
|
+
currentLines: string[],
|
|
1290
|
+
deletedThreadIds: Set<string>,
|
|
1291
|
+
) => {
|
|
1292
|
+
const initial = new Set(initialLines.map((line) => line.trim()).filter(Boolean));
|
|
1293
|
+
const merged = retainedLines.map((line) => line.trim()).filter(Boolean);
|
|
1294
|
+
const included = new Set(merged);
|
|
1295
|
+
|
|
1296
|
+
for (const line of currentLines) {
|
|
1297
|
+
const trimmed = line.trim();
|
|
1298
|
+
if (!trimmed || initial.has(trimmed) || included.has(trimmed)) {
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
const entry = parseJsonlObject<SessionIndexEntry>(trimmed);
|
|
1302
|
+
if (entry?.id && deletedThreadIds.has(entry.id)) {
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
merged.push(trimmed);
|
|
1306
|
+
included.add(trimmed);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
return merged;
|
|
1310
|
+
};
|
|
1311
|
+
|
|
1312
|
+
const writeSessionIndexLines = async (
|
|
1313
|
+
sessionIndexPath: string,
|
|
1314
|
+
codexDir: string,
|
|
1315
|
+
initialLines: string[],
|
|
1316
|
+
retainedLines: string[],
|
|
1317
|
+
deletedThreadIds: Set<string>,
|
|
1318
|
+
) => {
|
|
1055
1319
|
const tempSessionIndexPath = path.join(
|
|
1056
1320
|
codexDir,
|
|
1057
1321
|
`.session_index.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`,
|
|
1058
1322
|
);
|
|
1059
1323
|
try {
|
|
1060
|
-
await Bun.
|
|
1324
|
+
const currentLines = (await Bun.file(sessionIndexPath).text()).split(/\r?\n/u);
|
|
1325
|
+
const mergedLines = mergeSessionIndexLinesForRewrite(
|
|
1326
|
+
initialLines,
|
|
1327
|
+
retainedLines,
|
|
1328
|
+
currentLines,
|
|
1329
|
+
deletedThreadIds,
|
|
1330
|
+
);
|
|
1331
|
+
await Bun.write(tempSessionIndexPath, mergedLines.length > 0 ? `${mergedLines.join('\n')}\n` : '');
|
|
1061
1332
|
await rename(tempSessionIndexPath, sessionIndexPath);
|
|
1062
1333
|
} catch (error) {
|
|
1063
1334
|
await rm(tempSessionIndexPath, { force: true });
|
|
@@ -1084,7 +1355,7 @@ const removeSessionIndexEntries = async (codexDir: string, threadIds: string[])
|
|
|
1084
1355
|
return [];
|
|
1085
1356
|
}
|
|
1086
1357
|
|
|
1087
|
-
await writeSessionIndexLines(sessionIndexPath, codexDir, retainedLines);
|
|
1358
|
+
await writeSessionIndexLines(sessionIndexPath, codexDir, lines, retainedLines, uniqueThreadIds);
|
|
1088
1359
|
return uniqueValues(removedThreadIds);
|
|
1089
1360
|
};
|
|
1090
1361
|
|
|
@@ -1098,7 +1369,7 @@ const removeSessionIndexEntries = async (codexDir: string, threadIds: string[])
|
|
|
1098
1369
|
|
|
1099
1370
|
const listFallbackThreadIdsForProject = (dbPath: string, existingThreadIds: Set<string>, projectName: string) => {
|
|
1100
1371
|
const codexDir = resolveCodexDirFromDbPath(dbPath);
|
|
1101
|
-
const sessionFilesByThreadId =
|
|
1372
|
+
const sessionFilesByThreadId = getSessionFilesByThreadId(path.join(codexDir, 'sessions'));
|
|
1102
1373
|
const fallbackThreadIds: string[] = [];
|
|
1103
1374
|
|
|
1104
1375
|
for (const entry of readSessionIndexEntries(codexDir)) {
|
|
@@ -1127,20 +1398,56 @@ const deleteSessionIndexEntriesForThreads = async (
|
|
|
1127
1398
|
deleteSessionFiles: boolean,
|
|
1128
1399
|
) => {
|
|
1129
1400
|
const codexDir = resolveCodexDirFromDbPath(dbPath);
|
|
1401
|
+
const fallbackSessionFiles = deleteSessionFiles ? getSessionFilesForThreadIds(dbPath, threadIds) : [];
|
|
1402
|
+
const deletedSessionFiles = deleteSessionFiles
|
|
1403
|
+
? await deleteThreadSessionFiles([
|
|
1404
|
+
...dbDeletedSessionFiles.map((sessionFile) => resolveCodexRolloutPath(dbPath, sessionFile)),
|
|
1405
|
+
...fallbackSessionFiles,
|
|
1406
|
+
])
|
|
1407
|
+
: [];
|
|
1130
1408
|
const removedThreadIds = await removeSessionIndexEntries(codexDir, threadIds);
|
|
1131
|
-
const fallbackSessionFiles = deleteSessionFiles ? getSessionFilesForThreadIds(dbPath, removedThreadIds) : [];
|
|
1132
1409
|
|
|
1133
1410
|
return {
|
|
1134
|
-
deletedSessionFiles
|
|
1135
|
-
? await deleteThreadSessionFiles([...dbDeletedSessionFiles, ...fallbackSessionFiles])
|
|
1136
|
-
: [],
|
|
1411
|
+
deletedSessionFiles,
|
|
1137
1412
|
deletedThreadIds: removedThreadIds,
|
|
1138
1413
|
};
|
|
1139
1414
|
};
|
|
1140
1415
|
|
|
1141
|
-
|
|
1416
|
+
const readProjectAggregateRows = (db: Database) => {
|
|
1417
|
+
return db
|
|
1418
|
+
.query(`
|
|
1419
|
+
SELECT
|
|
1420
|
+
cwd,
|
|
1421
|
+
model,
|
|
1422
|
+
SUM(CASE WHEN archived <> 0 THEN 1 ELSE 0 END) AS archived_thread_count,
|
|
1423
|
+
MAX(COALESCE(updated_at_ms, updated_at * 1000)) AS last_updated_at_ms,
|
|
1424
|
+
COUNT(*) AS thread_count,
|
|
1425
|
+
COALESCE(SUM(tokens_used), 0) AS total_tokens
|
|
1426
|
+
FROM threads
|
|
1427
|
+
WHERE typeof(cwd) = 'text' AND TRIM(cwd) <> ''
|
|
1428
|
+
GROUP BY cwd, model
|
|
1429
|
+
`)
|
|
1430
|
+
.all() as ProjectAggregateRow[];
|
|
1431
|
+
};
|
|
1432
|
+
|
|
1433
|
+
const readDbThreadIds = (db: Database) => {
|
|
1434
|
+
const rows = db.query('SELECT id FROM threads').all() as Array<{ id: string }>;
|
|
1435
|
+
return new Set(rows.map((row) => row.id));
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
const readProjectSummaryDatabaseData = (dbPath: string) => {
|
|
1439
|
+
return withReadonlyDb(dbPath, (db) => ({
|
|
1440
|
+
existingThreadIds: readDbThreadIds(db),
|
|
1441
|
+
projectAggregates: readProjectAggregateRows(db),
|
|
1442
|
+
}));
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1445
|
+
export const listCodexProjects = async (dbPath: string): Promise<ProjectSummary[]> => {
|
|
1446
|
+
const database = readProjectSummaryDatabaseData(dbPath);
|
|
1447
|
+
const fallbackThreads = readFallbackThreadRows(dbPath, database.existingThreadIds);
|
|
1448
|
+
|
|
1142
1449
|
return mapProjectSummaries(
|
|
1143
|
-
buildProjectSummaryMap(
|
|
1450
|
+
mergeProjectAggregateRows(buildProjectSummaryMap(fallbackThreads), database.projectAggregates),
|
|
1144
1451
|
);
|
|
1145
1452
|
};
|
|
1146
1453
|
|
|
@@ -1162,12 +1469,8 @@ export const listProjectThreads = async (
|
|
|
1162
1469
|
projectName: string,
|
|
1163
1470
|
options: ListProjectThreadsOptions = {},
|
|
1164
1471
|
): Promise<ThreadListEntry[]> => {
|
|
1165
|
-
const threads = mergeFallbackThreadRows(
|
|
1166
|
-
|
|
1167
|
-
filterThreadsByProject(readAllThreads(dbPath), projectName),
|
|
1168
|
-
projectName,
|
|
1169
|
-
);
|
|
1170
|
-
const activeThreads = applyRolloutActivityTimestamps(threads);
|
|
1472
|
+
const threads = mergeFallbackThreadRows(dbPath, readThreads(dbPath, projectName), projectName);
|
|
1473
|
+
const activeThreads = await applyRolloutActivityTimestamps(dbPath, threads);
|
|
1171
1474
|
const entries = await mapWithConcurrency(activeThreads, THREAD_LIST_IO_CONCURRENCY, async (thread) => {
|
|
1172
1475
|
const rollout = await getThreadRolloutLoadState(thread.rollout_path, options.largeTranscriptThresholdBytes);
|
|
1173
1476
|
|
|
@@ -1199,16 +1502,16 @@ export const listProjectThreads = async (
|
|
|
1199
1502
|
};
|
|
1200
1503
|
}
|
|
1201
1504
|
|
|
1202
|
-
const
|
|
1505
|
+
const stats = await getCachedCodexTranscriptStats(thread.rollout_path);
|
|
1203
1506
|
|
|
1204
1507
|
return {
|
|
1205
1508
|
project: projectName,
|
|
1206
1509
|
rolloutSizeBytes: rollout.fileSizeBytes,
|
|
1207
1510
|
stats: {
|
|
1208
1511
|
deferred: false,
|
|
1209
|
-
execCommandCount:
|
|
1210
|
-
toolCallCount:
|
|
1211
|
-
webSearchEventCount:
|
|
1512
|
+
execCommandCount: stats.execCommandCount,
|
|
1513
|
+
toolCallCount: stats.toolCallCount,
|
|
1514
|
+
webSearchEventCount: stats.webSearchEventCount,
|
|
1212
1515
|
},
|
|
1213
1516
|
thread: compactThreadListRow(thread),
|
|
1214
1517
|
};
|
|
@@ -1223,48 +1526,130 @@ export const getThreadBrowseData = (dbPath: string, threadId: string): ThreadBro
|
|
|
1223
1526
|
const dbThread = db.query('SELECT * FROM threads WHERE id = ? LIMIT 1').get(threadId) as ThreadRow | null;
|
|
1224
1527
|
const thread = dbThread ?? readFallbackThreadRowById(dbPath, threadId, { includeSubagents: true }) ?? null;
|
|
1225
1528
|
if (!thread) {
|
|
1226
|
-
throw new
|
|
1529
|
+
throw new CodexThreadNotFoundError(threadId);
|
|
1227
1530
|
}
|
|
1531
|
+
const normalizedThread = {
|
|
1532
|
+
...thread,
|
|
1533
|
+
rollout_path: resolveCodexRolloutPath(dbPath, thread.rollout_path),
|
|
1534
|
+
};
|
|
1228
1535
|
|
|
1229
1536
|
const dynamicTools =
|
|
1230
1537
|
dbThread && existingTableNames.has('thread_dynamic_tools')
|
|
1538
|
+
? (
|
|
1539
|
+
db
|
|
1540
|
+
.query(
|
|
1541
|
+
'SELECT thread_id, position, name, description, input_schema, defer_loading, namespace FROM thread_dynamic_tools WHERE thread_id = ? ORDER BY position ASC',
|
|
1542
|
+
)
|
|
1543
|
+
.all(threadId) as Array<Record<string, number | string | null>>
|
|
1544
|
+
).map((row) => parseDynamicToolRow(row))
|
|
1545
|
+
: parseFallbackDynamicTools(readFallbackSessionMeta(thread.rollout_path) ?? {}, threadId);
|
|
1546
|
+
const goals =
|
|
1547
|
+
dbThread && existingTableNames.has('thread_goals')
|
|
1231
1548
|
? (db
|
|
1232
1549
|
.query(
|
|
1233
|
-
'SELECT
|
|
1550
|
+
'SELECT goal_id, objective, status, token_budget, tokens_used, time_used_seconds, created_at_ms, updated_at_ms FROM thread_goals WHERE thread_id = ? ORDER BY updated_at_ms DESC, goal_id ASC',
|
|
1234
1551
|
)
|
|
1235
|
-
.all(threadId) as
|
|
1552
|
+
.all(threadId) as ThreadGoalRow[])
|
|
1236
1553
|
: [];
|
|
1237
1554
|
|
|
1238
1555
|
return {
|
|
1239
|
-
dynamicTools
|
|
1240
|
-
|
|
1556
|
+
dynamicTools,
|
|
1557
|
+
goals: goals.map((goal) => ({
|
|
1558
|
+
createdAtMs: goal.created_at_ms,
|
|
1559
|
+
goalId: goal.goal_id,
|
|
1560
|
+
objective: goal.objective,
|
|
1561
|
+
status: goal.status,
|
|
1562
|
+
timeUsedSeconds: goal.time_used_seconds,
|
|
1563
|
+
tokenBudget: goal.token_budget,
|
|
1564
|
+
tokensUsed: goal.tokens_used,
|
|
1565
|
+
updatedAtMs: goal.updated_at_ms,
|
|
1566
|
+
})),
|
|
1567
|
+
project: getPortablePathBasename(normalizedThread.cwd),
|
|
1241
1568
|
relations: getRelationsForThread(db, threadId, existingTableNames),
|
|
1242
|
-
thread,
|
|
1569
|
+
thread: normalizedThread,
|
|
1243
1570
|
};
|
|
1244
1571
|
});
|
|
1245
1572
|
};
|
|
1246
1573
|
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1574
|
+
type DashboardDatabaseTotals = {
|
|
1575
|
+
archived_threads: number;
|
|
1576
|
+
total_threads: number;
|
|
1577
|
+
total_tokens: number;
|
|
1578
|
+
};
|
|
1579
|
+
|
|
1580
|
+
const readDashboardDatabaseData = (dbPath: string) => {
|
|
1581
|
+
return withReadonlyDb(dbPath, (db) => {
|
|
1582
|
+
const totals = db
|
|
1583
|
+
.query(`
|
|
1584
|
+
SELECT
|
|
1585
|
+
SUM(CASE WHEN archived <> 0 THEN 1 ELSE 0 END) AS archived_threads,
|
|
1586
|
+
COUNT(*) AS total_threads,
|
|
1587
|
+
COALESCE(SUM(tokens_used), 0) AS total_tokens
|
|
1588
|
+
FROM threads
|
|
1589
|
+
`)
|
|
1590
|
+
.get() as DashboardDatabaseTotals;
|
|
1591
|
+
const recentCandidates = db
|
|
1592
|
+
.query(`
|
|
1593
|
+
SELECT
|
|
1594
|
+
id,
|
|
1595
|
+
rollout_path,
|
|
1596
|
+
cwd,
|
|
1597
|
+
title,
|
|
1598
|
+
preview,
|
|
1599
|
+
first_user_message,
|
|
1600
|
+
model,
|
|
1601
|
+
tokens_used,
|
|
1602
|
+
updated_at,
|
|
1603
|
+
updated_at_ms
|
|
1604
|
+
FROM threads
|
|
1605
|
+
WHERE typeof(cwd) = 'text' AND TRIM(cwd) <> ''
|
|
1606
|
+
`)
|
|
1607
|
+
.all() as DashboardThreadCandidate[];
|
|
1608
|
+
const existingTableNames = getExistingTableNames(db);
|
|
1609
|
+
const relationCount = existingTableNames.has('thread_spawn_edges')
|
|
1610
|
+
? (
|
|
1611
|
+
db
|
|
1612
|
+
.query(`
|
|
1613
|
+
SELECT COUNT(*) AS count
|
|
1614
|
+
FROM (
|
|
1615
|
+
SELECT parent_thread_id AS thread_id FROM thread_spawn_edges
|
|
1616
|
+
UNION
|
|
1617
|
+
SELECT child_thread_id AS thread_id FROM thread_spawn_edges
|
|
1618
|
+
)
|
|
1619
|
+
`)
|
|
1620
|
+
.get() as { count: number }
|
|
1621
|
+
).count
|
|
1622
|
+
: 0;
|
|
1254
1623
|
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1624
|
+
return {
|
|
1625
|
+
existingThreadIds: readDbThreadIds(db),
|
|
1626
|
+
projectAggregates: readProjectAggregateRows(db),
|
|
1627
|
+
recentCandidates,
|
|
1628
|
+
relationCount: Number(relationCount),
|
|
1629
|
+
totals,
|
|
1630
|
+
};
|
|
1261
1631
|
});
|
|
1632
|
+
};
|
|
1633
|
+
|
|
1634
|
+
export const getCodexDashboardSummary = async (dbPath: string): Promise<DashboardSummary> => {
|
|
1635
|
+
const database = readDashboardDatabaseData(dbPath);
|
|
1636
|
+
const fallbackThreads = readFallbackThreadRows(dbPath, database.existingThreadIds);
|
|
1637
|
+
const recentCandidates = await applyRolloutActivityTimestamps(dbPath, [
|
|
1638
|
+
...database.recentCandidates,
|
|
1639
|
+
...fallbackThreads,
|
|
1640
|
+
]);
|
|
1641
|
+
const projects = mapProjectSummaries(
|
|
1642
|
+
mergeProjectAggregateRows(buildProjectSummaryMap(fallbackThreads), database.projectAggregates),
|
|
1643
|
+
);
|
|
1644
|
+
const fallbackArchivedThreads = fallbackThreads.filter((thread) => Boolean(thread.archived)).length;
|
|
1645
|
+
const archivedThreads = Number(database.totals.archived_threads ?? 0) + fallbackArchivedThreads;
|
|
1646
|
+
const totalThreads = Number(database.totals.total_threads) + fallbackThreads.length;
|
|
1262
1647
|
|
|
1263
1648
|
return {
|
|
1264
|
-
activeThreads:
|
|
1265
|
-
archivedThreads
|
|
1266
|
-
recentThreads: buildDashboardRecentThreads(
|
|
1267
|
-
threadsWithRelations,
|
|
1649
|
+
activeThreads: totalThreads - archivedThreads,
|
|
1650
|
+
archivedThreads,
|
|
1651
|
+
recentThreads: buildDashboardRecentThreads(recentCandidates),
|
|
1652
|
+
threadsWithRelations: database.relationCount,
|
|
1268
1653
|
topProjectsByThreadCount: [...projects]
|
|
1269
1654
|
.sort((left, right) => {
|
|
1270
1655
|
if (left.threadCount !== right.threadCount) {
|
|
@@ -1276,8 +1661,9 @@ export const getCodexDashboardSummary = (dbPath: string): DashboardSummary => {
|
|
|
1276
1661
|
.slice(0, 5),
|
|
1277
1662
|
topProjectsByTokens: projects.slice(0, 5),
|
|
1278
1663
|
totalProjects: projects.length,
|
|
1279
|
-
totalThreads
|
|
1280
|
-
totalTokens:
|
|
1664
|
+
totalThreads,
|
|
1665
|
+
totalTokens:
|
|
1666
|
+
Number(database.totals.total_tokens) + fallbackThreads.reduce((sum, thread) => sum + thread.tokens_used, 0),
|
|
1281
1667
|
};
|
|
1282
1668
|
};
|
|
1283
1669
|
|
|
@@ -1287,6 +1673,9 @@ export const deleteCodexThread = async (
|
|
|
1287
1673
|
options: DeleteThreadOptions = {},
|
|
1288
1674
|
): Promise<DeleteThreadsResult> => {
|
|
1289
1675
|
const threadIds = [threadId];
|
|
1676
|
+
if (options.deleteSessionFiles) {
|
|
1677
|
+
await validateSessionFileDeletionTargets(dbPath, threadIds);
|
|
1678
|
+
}
|
|
1290
1679
|
const result = withWritableDb(dbPath, (db) => {
|
|
1291
1680
|
return deleteThreadIds(db, threadIds);
|
|
1292
1681
|
});
|
|
@@ -1314,6 +1703,9 @@ export const deleteCodexThreads = async (
|
|
|
1314
1703
|
options: DeleteThreadOptions = {},
|
|
1315
1704
|
): Promise<DeleteThreadsResult> => {
|
|
1316
1705
|
const uniqueThreadIds = uniqueValues(threadIds);
|
|
1706
|
+
if (options.deleteSessionFiles) {
|
|
1707
|
+
await validateSessionFileDeletionTargets(dbPath, uniqueThreadIds);
|
|
1708
|
+
}
|
|
1317
1709
|
const result = withWritableDb(dbPath, (db) => {
|
|
1318
1710
|
return deleteThreadIds(db, uniqueThreadIds);
|
|
1319
1711
|
});
|
|
@@ -1340,14 +1732,25 @@ export const deleteCodexProject = async (
|
|
|
1340
1732
|
projectName: string,
|
|
1341
1733
|
options: DeleteProjectOptions = {},
|
|
1342
1734
|
): Promise<DeleteProjectResult> => {
|
|
1343
|
-
const existingThreadIds = new Set(
|
|
1735
|
+
const existingThreadIds = new Set(
|
|
1736
|
+
withReadonlyDb(dbPath, (db) =>
|
|
1737
|
+
(db.query('SELECT id FROM threads').all() as Array<{ id: string }>).map(({ id }) => id),
|
|
1738
|
+
),
|
|
1739
|
+
);
|
|
1344
1740
|
const fallbackThreadIds = listFallbackThreadIdsForProject(dbPath, existingThreadIds, projectName);
|
|
1741
|
+
const projectThreadIds = withReadonlyDb(dbPath, (db) =>
|
|
1742
|
+
(
|
|
1743
|
+
db.query(`SELECT id FROM threads WHERE ${PROJECT_CWD_FILTER}`).all(projectName) as Array<{
|
|
1744
|
+
id: string;
|
|
1745
|
+
}>
|
|
1746
|
+
).map(({ id }) => id),
|
|
1747
|
+
);
|
|
1748
|
+
const allThreadIds = [...projectThreadIds, ...fallbackThreadIds];
|
|
1749
|
+
if (options.deleteSessionFiles) {
|
|
1750
|
+
await validateSessionFileDeletionTargets(dbPath, allThreadIds);
|
|
1751
|
+
}
|
|
1345
1752
|
const result = withWritableDb(dbPath, (db) => {
|
|
1346
|
-
const
|
|
1347
|
-
const threadIds = threads
|
|
1348
|
-
.filter((thread) => getPortablePathBasename(thread.cwd) === projectName)
|
|
1349
|
-
.map((thread) => thread.id);
|
|
1350
|
-
const deleted = deleteThreadIds(db, threadIds);
|
|
1753
|
+
const deleted = deleteThreadIds(db, projectThreadIds);
|
|
1351
1754
|
|
|
1352
1755
|
return {
|
|
1353
1756
|
...deleted,
|
|
@@ -1374,7 +1777,7 @@ export const deleteCodexProject = async (
|
|
|
1374
1777
|
};
|
|
1375
1778
|
|
|
1376
1779
|
export const listScopedThreads = (dbPath: string, projectName: string | null): ThreadRow[] => {
|
|
1377
|
-
return mergeFallbackThreadRows(dbPath,
|
|
1780
|
+
return mergeFallbackThreadRows(dbPath, readThreads(dbPath, projectName), projectName);
|
|
1378
1781
|
};
|
|
1379
1782
|
|
|
1380
1783
|
export const invalidateCodexUiCaches = async () => {
|