spiracha 1.0.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.
@@ -0,0 +1,110 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import type { ExportFormat, JsonValue } from './shared';
4
+
5
+ export type CodexCliOptions = {
6
+ dbPath: string;
7
+ inputDir: string;
8
+ outputDir: string;
9
+ cwdFilter: string | null;
10
+ projectFilter: string | null;
11
+ threadIds: string[];
12
+ optimized: boolean;
13
+ includeTools: boolean;
14
+ outputFormat: ExportFormat;
15
+ flat: boolean;
16
+ };
17
+
18
+ export type CodexExportedFile = {
19
+ sourcePath: string;
20
+ outputPath: string;
21
+ threadId: string | null;
22
+ };
23
+
24
+ export type CodexExportRunResult = {
25
+ outputDir: string;
26
+ exportedCount: number;
27
+ files: CodexExportedFile[];
28
+ missingThreadIds: string[];
29
+ };
30
+
31
+ export type SessionMeta = {
32
+ id?: string;
33
+ timestamp?: string;
34
+ cwd?: string;
35
+ source?: string;
36
+ originator?: string;
37
+ cli_version?: string;
38
+ };
39
+
40
+ export type MessageRecord = {
41
+ role: string;
42
+ content: JsonValue;
43
+ phase?: string;
44
+ };
45
+
46
+ export type ToolRecord = {
47
+ kind: 'call' | 'output';
48
+ name: string;
49
+ callId: string | null;
50
+ argumentsText?: string;
51
+ outputText?: string;
52
+ };
53
+
54
+ export type ThreadRow = {
55
+ id: string;
56
+ rollout_path: string;
57
+ created_at: number;
58
+ updated_at: number;
59
+ source: string;
60
+ model_provider: string;
61
+ cwd: string;
62
+ title: string;
63
+ sandbox_policy: string;
64
+ approval_mode: string;
65
+ tokens_used: number;
66
+ has_user_event: number;
67
+ archived: number;
68
+ archived_at: number | null;
69
+ git_sha: string | null;
70
+ git_branch: string | null;
71
+ git_origin_url: string | null;
72
+ cli_version: string;
73
+ first_user_message: string;
74
+ agent_nickname: string | null;
75
+ agent_role: string | null;
76
+ memory_mode: string;
77
+ model: string | null;
78
+ reasoning_effort: string | null;
79
+ agent_path: string | null;
80
+ };
81
+
82
+ export type SpawnEdgeRow = {
83
+ parent_thread_id: string;
84
+ child_thread_id: string;
85
+ status: string;
86
+ };
87
+
88
+ export type ThreadRelations = {
89
+ parentThreadId: string | null;
90
+ childEdges: SpawnEdgeRow[];
91
+ };
92
+
93
+ export type ExportTarget = {
94
+ sessionFile: string;
95
+ outputRelativePath: string;
96
+ thread: ThreadRow | null;
97
+ relations: ThreadRelations;
98
+ fallbackReason: string | null;
99
+ };
100
+
101
+ export type ThreadData = {
102
+ threadsById: Map<string, ThreadRow>;
103
+ parentByChildId: Map<string, SpawnEdgeRow>;
104
+ childEdgesByParentId: Map<string, SpawnEdgeRow[]>;
105
+ };
106
+
107
+ export const DEFAULT_CODEX_DIR = path.join(os.homedir(), '.codex');
108
+ export const DEFAULT_DB_PATH = path.join(DEFAULT_CODEX_DIR, 'state_5.sqlite');
109
+ export const DEFAULT_INPUT_DIR = path.join(DEFAULT_CODEX_DIR, 'sessions');
110
+ export const DEFAULT_OUTPUT_DIR = path.join(process.cwd(), 'exports');
@@ -0,0 +1,116 @@
1
+ import path from 'node:path';
2
+ import {
3
+ buildExportTargets,
4
+ findJsonlFiles,
5
+ loadThreadData,
6
+ shouldScanFallbackSessionFiles,
7
+ } from './codex-exporter-db';
8
+ import { convertSessionFile } from './codex-exporter-transcript';
9
+ import type { CodexCliOptions, CodexExportedFile, CodexExportRunResult } from './codex-exporter-types';
10
+ import { writeExportFile } from './shared';
11
+
12
+ export {
13
+ getCodexHelpText,
14
+ parseCodexCliArgs,
15
+ parseThreadSelectionArg,
16
+ resolveDefaultOutputDir,
17
+ } from './codex-exporter-cli';
18
+ export {
19
+ buildExportTargets,
20
+ buildSpawnEdgeQuery,
21
+ buildThreadQuery,
22
+ findJsonlFiles,
23
+ loadThreadData,
24
+ matchesFilters,
25
+ shouldScanFallbackSessionFiles,
26
+ toCodexRelativePath,
27
+ toOutputRelativePath,
28
+ } from './codex-exporter-db';
29
+ export {
30
+ compactMessageText,
31
+ convertSessionFile,
32
+ formatToolOutputSummary,
33
+ parseExecCommandArguments,
34
+ } from './codex-exporter-transcript';
35
+ export {
36
+ type CodexCliOptions,
37
+ type CodexExportedFile,
38
+ type CodexExportRunResult,
39
+ DEFAULT_CODEX_DIR,
40
+ DEFAULT_DB_PATH,
41
+ DEFAULT_INPUT_DIR,
42
+ DEFAULT_OUTPUT_DIR,
43
+ type ExportTarget,
44
+ type MessageRecord,
45
+ type SessionMeta,
46
+ type SpawnEdgeRow,
47
+ type ThreadData,
48
+ type ThreadRelations,
49
+ type ThreadRow,
50
+ type ToolRecord,
51
+ } from './codex-exporter-types';
52
+
53
+ export const runCodexExport = async (options: CodexCliOptions): Promise<CodexExportRunResult> => {
54
+ const threadData = loadThreadData(options.dbPath, options);
55
+ const sessionFiles = shouldScanFallbackSessionFiles(options) ? await findJsonlFiles(options.inputDir) : [];
56
+
57
+ if (threadData.threadsById.size === 0 && sessionFiles.length === 0) {
58
+ throw new Error(`No threads found in ${options.dbPath} and no .jsonl files found in ${options.inputDir}`);
59
+ }
60
+
61
+ const exportTargets = buildExportTargets(threadData, sessionFiles, options);
62
+ const files = await writeCodexExportTargets(exportTargets, options);
63
+ const missingThreadIds = options.threadIds.filter((threadId) => !threadData.threadsById.has(threadId));
64
+
65
+ if (shouldThrowNoMatchError(options, files.length)) {
66
+ throw new Error(buildNoMatchErrorMessage(options));
67
+ }
68
+
69
+ return {
70
+ exportedCount: files.length,
71
+ files,
72
+ missingThreadIds,
73
+ outputDir: options.outputDir,
74
+ };
75
+ };
76
+
77
+ const writeCodexExportTargets = async (
78
+ exportTargets: ReturnType<typeof buildExportTargets>,
79
+ options: CodexCliOptions,
80
+ ): Promise<CodexExportedFile[]> => {
81
+ const files: CodexExportedFile[] = [];
82
+
83
+ for (const target of exportTargets) {
84
+ const content = await convertSessionFile(target, options);
85
+ if (!content) {
86
+ continue;
87
+ }
88
+
89
+ const outputPath = path.join(options.outputDir, target.outputRelativePath);
90
+ await writeExportFile(outputPath, content);
91
+ files.push({
92
+ outputPath,
93
+ sourcePath: target.sessionFile,
94
+ threadId: target.thread?.id ?? null,
95
+ });
96
+ }
97
+
98
+ return files;
99
+ };
100
+
101
+ const shouldThrowNoMatchError = (options: CodexCliOptions, exportedCount: number): boolean => {
102
+ return (
103
+ exportedCount === 0 &&
104
+ (options.threadIds.length > 0 || options.cwdFilter !== null || options.projectFilter !== null)
105
+ );
106
+ };
107
+
108
+ const buildNoMatchErrorMessage = (options: CodexCliOptions): string => {
109
+ const filters = [
110
+ options.cwdFilter ? `cwd=${options.cwdFilter}` : null,
111
+ options.projectFilter ? `project=${options.projectFilter}` : null,
112
+ options.threadIds.length > 0 ? `threadIds=${options.threadIds.join(',')}` : null,
113
+ ].filter(Boolean);
114
+
115
+ return `No chats matched the requested filters (${filters.join('; ')})`;
116
+ };
@@ -0,0 +1,448 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { access, lstat } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { stdin as input, stdout as output } from 'node:process';
6
+ import { createInterface, type Interface } from 'node:readline/promises';
7
+ import { checkbox } from '@inquirer/prompts';
8
+ import { type ClaudeCliOptions, runClaudeExport } from './claude-exporter';
9
+ import { type CodexCliOptions, runCodexExport } from './codex-exporter';
10
+ import { DEFAULT_DB_PATH, DEFAULT_INPUT_DIR } from './codex-exporter-types';
11
+ import { type ExportFormat, expandHome, getPortablePathBasename } from './shared';
12
+
13
+ type InteractiveTargetKind =
14
+ | 'codex_threads'
15
+ | 'codex_project'
16
+ | 'codex_projects_multi'
17
+ | 'codex_cwd'
18
+ | 'claude_path'
19
+ | 'unknown';
20
+
21
+ type InteractiveInference = {
22
+ kind: InteractiveTargetKind;
23
+ value: string | null;
24
+ };
25
+
26
+ type InteractiveExportResult =
27
+ | {
28
+ mode: 'codex';
29
+ outputDir: string;
30
+ exportedCount: number;
31
+ missingThreadIds: string[];
32
+ files: { sourcePath: string; outputPath: string; threadId: string | null }[];
33
+ }
34
+ | {
35
+ mode: 'claude';
36
+ outputPath: string;
37
+ sourcePath: string;
38
+ };
39
+
40
+ export const runInteractiveExport = async (): Promise<InteractiveExportResult> => {
41
+ const rl = createPromptInterface();
42
+
43
+ try {
44
+ output.write('Interactive export mode\n\n');
45
+
46
+ const initial = (
47
+ await rl.question(
48
+ 'Paste a Codex deeplink/thread id, project name, cwd path, or Claude export path.\nLeave blank to pick from a menu.\n> ',
49
+ )
50
+ ).trim();
51
+
52
+ const inferred = await inferInteractiveTarget(initial);
53
+ const selection = inferred.kind === 'unknown' ? await promptForTargetKind(rl) : inferred.kind;
54
+
55
+ switch (selection) {
56
+ case 'codex_threads':
57
+ return await runInteractiveCodexThreads(rl, inferred);
58
+ case 'codex_project':
59
+ return await runInteractiveCodexProject(rl, inferred);
60
+ case 'codex_projects_multi':
61
+ return await runInteractiveCodexProjectsMulti(rl);
62
+ case 'codex_cwd':
63
+ return await runInteractiveCodexCwd(rl, inferred);
64
+ case 'claude_path':
65
+ return await runInteractiveClaude(rl, inferred);
66
+ default:
67
+ throw new Error('Unsupported interactive selection');
68
+ }
69
+ } finally {
70
+ rl.close();
71
+ }
72
+ };
73
+
74
+ export const inferInteractiveTarget = async (value: string): Promise<InteractiveInference> => {
75
+ const trimmed = value.trim();
76
+ if (!trimmed) {
77
+ return { kind: 'unknown', value: null };
78
+ }
79
+
80
+ const expanded = expandHome(trimmed);
81
+ const pathStats = await lstat(expanded).catch(() => null);
82
+
83
+ if (pathStats) {
84
+ return await inferInteractiveTargetFromPath(expanded, pathStats);
85
+ }
86
+
87
+ return inferInteractiveTargetFromText(trimmed, expanded);
88
+ };
89
+
90
+ const inferInteractiveTargetFromPath = async (
91
+ expanded: string,
92
+ pathStats: Awaited<ReturnType<typeof lstat>>,
93
+ ): Promise<InteractiveInference> => {
94
+ if (pathStats.isDirectory()) {
95
+ const metadataExists = await access(path.join(expanded, 'metadata.json'))
96
+ .then(() => true)
97
+ .catch(() => false);
98
+ return {
99
+ kind: metadataExists ? 'claude_path' : 'codex_cwd',
100
+ value: expanded,
101
+ };
102
+ }
103
+
104
+ if (pathStats.isFile()) {
105
+ return {
106
+ kind: expanded.endsWith('.jsonl') ? 'claude_path' : 'unknown',
107
+ value: expanded,
108
+ };
109
+ }
110
+
111
+ return { kind: 'unknown', value: expanded };
112
+ };
113
+
114
+ const inferInteractiveTargetFromText = (trimmed: string, expanded: string): InteractiveInference => {
115
+ if (trimmed.startsWith('codex://threads/') || isRawThreadId(trimmed)) {
116
+ return { kind: 'codex_threads', value: trimmed };
117
+ }
118
+
119
+ if (trimmed.includes(path.sep) || trimmed.startsWith('~')) {
120
+ return { kind: 'codex_cwd', value: expanded };
121
+ }
122
+
123
+ return { kind: 'codex_project', value: trimmed };
124
+ };
125
+
126
+ const promptForTargetKind = async (rl: Interface): Promise<Exclude<InteractiveTargetKind, 'unknown'>> => {
127
+ output.write(
128
+ [
129
+ '',
130
+ 'What do you want to export?',
131
+ '1. Specific Codex thread(s)',
132
+ '2. Codex project name',
133
+ '3. Exact Codex cwd path',
134
+ '4. Claude transcript file or export directory',
135
+ '5. Select one or more Codex projects',
136
+ '',
137
+ ].join('\n'),
138
+ );
139
+
140
+ while (true) {
141
+ const choice = (await rl.question('Choose 1-5: ')).trim();
142
+ if (choice === '1') {
143
+ return 'codex_threads';
144
+ }
145
+ if (choice === '2') {
146
+ return 'codex_project';
147
+ }
148
+ if (choice === '3') {
149
+ return 'codex_cwd';
150
+ }
151
+ if (choice === '4') {
152
+ return 'claude_path';
153
+ }
154
+ if (choice === '5') {
155
+ return 'codex_projects_multi';
156
+ }
157
+
158
+ output.write('Please enter 1, 2, 3, 4, or 5.\n');
159
+ }
160
+ };
161
+
162
+ const runInteractiveCodexThreads = async (
163
+ rl: Interface,
164
+ inferred: InteractiveInference,
165
+ ): Promise<InteractiveExportResult> => {
166
+ const dbPath = resolveInteractiveDbPath();
167
+ const raw =
168
+ inferred.kind === 'codex_threads' && inferred.value
169
+ ? inferred.value
170
+ : (
171
+ await rl.question(
172
+ 'Enter one or more Codex deeplinks or raw thread ids, separated by commas or spaces:\n> ',
173
+ )
174
+ ).trim();
175
+
176
+ const threadIds = normalizeInteractiveThreadSelections(raw);
177
+ if (threadIds.length === 0) {
178
+ throw new Error('At least one Codex thread id or deeplink is required.');
179
+ }
180
+
181
+ const options = await promptForCommonCodexOptions(rl, dbPath, {
182
+ cwdFilter: null,
183
+ projectFilter: null,
184
+ threadIds,
185
+ });
186
+ const result = await runCodexExport(options);
187
+ return { mode: 'codex', ...result };
188
+ };
189
+
190
+ const runInteractiveCodexProject = async (
191
+ rl: Interface,
192
+ inferred: InteractiveInference,
193
+ ): Promise<InteractiveExportResult> => {
194
+ const dbPath = resolveInteractiveDbPath();
195
+ const project = (
196
+ inferred.kind === 'codex_project' && inferred.value
197
+ ? inferred.value
198
+ : (await rl.question('Enter the Codex project name (cwd basename):\n> ')).trim()
199
+ ).trim();
200
+
201
+ if (!project) {
202
+ throw new Error('A project name is required.');
203
+ }
204
+
205
+ const options = await promptForCommonCodexOptions(rl, dbPath, {
206
+ cwdFilter: null,
207
+ projectFilter: project,
208
+ threadIds: [],
209
+ });
210
+ const result = await runCodexExport(options);
211
+ return { mode: 'codex', ...result };
212
+ };
213
+
214
+ const runInteractiveCodexProjectsMulti = async (rl: Interface): Promise<InteractiveExportResult> => {
215
+ const dbPath = resolveInteractiveDbPath();
216
+ const projects = listCodexProjects(dbPath);
217
+ if (projects.length === 0) {
218
+ throw new Error(`No Codex projects found in ${dbPath}.`);
219
+ }
220
+
221
+ output.write('Use Space to toggle projects, and Enter to confirm.\n');
222
+ // Inquirer manages the TTY directly; reopen readline afterwards for follow-up prompts.
223
+ rl.close();
224
+ const selectedProjects = await checkbox({
225
+ choices: projects.map((project) => ({ name: project, value: project })),
226
+ message: 'Select Codex project(s) to export:',
227
+ pageSize: 15,
228
+ });
229
+
230
+ if (selectedProjects.length === 0) {
231
+ throw new Error('At least one project must be selected.');
232
+ }
233
+
234
+ const threadIds = listThreadIdsForProjects(dbPath, selectedProjects);
235
+ if (threadIds.length === 0) {
236
+ throw new Error('No threads found for the selected projects.');
237
+ }
238
+
239
+ const followupRl = createPromptInterface();
240
+ try {
241
+ const options = await promptForCommonCodexOptions(followupRl, dbPath, {
242
+ cwdFilter: null,
243
+ projectFilter: null,
244
+ threadIds,
245
+ });
246
+ const result = await runCodexExport(options);
247
+ return { mode: 'codex', ...result };
248
+ } finally {
249
+ followupRl.close();
250
+ }
251
+ };
252
+
253
+ const runInteractiveCodexCwd = async (
254
+ rl: Interface,
255
+ inferred: InteractiveInference,
256
+ ): Promise<InteractiveExportResult> => {
257
+ const dbPath = resolveInteractiveDbPath();
258
+ const cwdInput =
259
+ inferred.kind === 'codex_cwd' && inferred.value
260
+ ? inferred.value
261
+ : (await rl.question('Enter the exact Codex cwd path:\n> ')).trim();
262
+ const cwdFilter = expandHome(cwdInput);
263
+
264
+ if (!cwdFilter) {
265
+ throw new Error('A cwd path is required.');
266
+ }
267
+
268
+ const options = await promptForCommonCodexOptions(rl, dbPath, {
269
+ cwdFilter,
270
+ projectFilter: null,
271
+ threadIds: [],
272
+ });
273
+ const result = await runCodexExport(options);
274
+ return { mode: 'codex', ...result };
275
+ };
276
+
277
+ const runInteractiveClaude = async (
278
+ rl: Interface,
279
+ inferred: InteractiveInference,
280
+ ): Promise<InteractiveExportResult> => {
281
+ const inputPath = expandHome(
282
+ inferred.kind === 'claude_path' && inferred.value
283
+ ? inferred.value
284
+ : (await rl.question('Enter the Claude transcript .jsonl file or export directory:\n> ')).trim(),
285
+ );
286
+
287
+ if (!inputPath) {
288
+ throw new Error('A Claude transcript path is required.');
289
+ }
290
+
291
+ const outputFormat = await promptForOutputFormat(rl);
292
+ const includeTools = await promptYesNo(rl, 'Include tool output? [y/N]: ', false);
293
+ const outputPath = await promptOptionalPath(rl, 'Optional output path or directory (leave blank for default):\n> ');
294
+
295
+ const result = await runClaudeExport({
296
+ includeTools,
297
+ inputPath,
298
+ outputFormat,
299
+ outputPath,
300
+ } satisfies ClaudeCliOptions);
301
+
302
+ return { mode: 'claude', ...result };
303
+ };
304
+
305
+ const promptForCommonCodexOptions = async (
306
+ rl: Interface,
307
+ dbPath: string,
308
+ target: Pick<CodexCliOptions, 'threadIds' | 'cwdFilter' | 'projectFilter'>,
309
+ ): Promise<CodexCliOptions> => {
310
+ const outputFormat = await promptForOutputFormat(rl);
311
+ const optimized = await promptYesNo(rl, 'Use optimized output? [y/N]: ', false);
312
+ const includeTools = await promptYesNo(rl, 'Include tool logs? [y/N]: ', false);
313
+ const flat = await promptYesNo(rl, 'Write to a flat output folder? [y/N]: ', false);
314
+ const outputDir = await promptOptionalPath(rl, 'Optional output directory (leave blank for default):\n> ');
315
+
316
+ return {
317
+ cwdFilter: target.cwdFilter,
318
+ dbPath,
319
+ flat,
320
+ includeTools,
321
+ inputDir: DEFAULT_INPUT_DIR,
322
+ optimized,
323
+ outputDir: outputDir ?? resolveInteractiveOutputDir(target.cwdFilter),
324
+ outputFormat,
325
+ projectFilter: target.projectFilter,
326
+ threadIds: target.threadIds,
327
+ };
328
+ };
329
+
330
+ const resolveInteractiveOutputDir = (cwdFilter: string | null) => {
331
+ if (cwdFilter) {
332
+ const basename = getPortablePathBasename(cwdFilter);
333
+ if (basename) {
334
+ return path.join(process.cwd(), basename);
335
+ }
336
+ }
337
+
338
+ return path.join(process.cwd(), 'exports');
339
+ };
340
+
341
+ const promptForOutputFormat = async (rl: Interface): Promise<ExportFormat> => {
342
+ output.write(['', 'Output format:', '1. Markdown (.md)', '2. Plain text (.txt)', ''].join('\n'));
343
+
344
+ while (true) {
345
+ const choice = (await rl.question('Choose 1-2 [1]: ')).trim();
346
+ if (!choice || choice === '1') {
347
+ return 'md';
348
+ }
349
+ if (choice === '2') {
350
+ return 'txt';
351
+ }
352
+
353
+ output.write('Please enter 1 or 2.\n');
354
+ }
355
+ };
356
+
357
+ const promptYesNo = async (rl: Interface, prompt: string, defaultValue: boolean): Promise<boolean> => {
358
+ while (true) {
359
+ const answer = (await rl.question(prompt)).trim().toLowerCase();
360
+ if (!answer) {
361
+ return defaultValue;
362
+ }
363
+ if (answer === 'y' || answer === 'yes') {
364
+ return true;
365
+ }
366
+ if (answer === 'n' || answer === 'no') {
367
+ return false;
368
+ }
369
+
370
+ output.write('Please answer y or n.\n');
371
+ }
372
+ };
373
+
374
+ const promptOptionalPath = async (rl: Interface, prompt: string): Promise<string | null> => {
375
+ const answer = (await rl.question(prompt)).trim();
376
+ return answer ? expandHome(answer) : null;
377
+ };
378
+
379
+ const normalizeInteractiveThreadSelections = (value: string): string[] => {
380
+ const rawTokens = value
381
+ .split(/[,\s]+/)
382
+ .map((token) => token.trim())
383
+ .filter(Boolean);
384
+
385
+ const threadIds = rawTokens.map((token) => {
386
+ if (token.startsWith('codex://threads/')) {
387
+ return token.replace(/^codex:\/\/threads\//u, '');
388
+ }
389
+ if (isRawThreadId(token)) {
390
+ return token;
391
+ }
392
+
393
+ throw new Error(`Unsupported thread selection: ${token}`);
394
+ });
395
+
396
+ return [...new Set(threadIds)];
397
+ };
398
+
399
+ const listCodexProjects = (dbPath: string): string[] => {
400
+ const db = new Database(dbPath, { readonly: true });
401
+ try {
402
+ const rows = db.query("SELECT DISTINCT cwd FROM threads WHERE cwd IS NOT NULL AND cwd != ''").all() as Array<{
403
+ cwd: string;
404
+ }>;
405
+ return [...new Set(rows.map((row) => getPortablePathBasename(row.cwd)).filter(Boolean))].sort();
406
+ } finally {
407
+ db.close();
408
+ }
409
+ };
410
+
411
+ const resolveInteractiveDbPath = (): string => {
412
+ const candidates = [DEFAULT_DB_PATH, path.join(os.homedir(), '.codex', 'sqlite', 'state_5.sqlite')];
413
+
414
+ for (const candidate of candidates) {
415
+ try {
416
+ const db = new Database(candidate, { readonly: true });
417
+ db.close();
418
+ return candidate;
419
+ } catch {}
420
+ }
421
+
422
+ throw new Error(`Unable to open Codex thread database. Tried: ${candidates.join(', ')}`);
423
+ };
424
+
425
+ const listThreadIdsForProjects = (dbPath: string, projectNames: string[]): string[] => {
426
+ if (projectNames.length === 0) {
427
+ return [];
428
+ }
429
+
430
+ const db = new Database(dbPath, { readonly: true });
431
+ try {
432
+ const projectNameSet = new Set(projectNames);
433
+ const rows = db
434
+ .query("SELECT id, cwd FROM threads WHERE cwd IS NOT NULL AND cwd != '' ORDER BY updated_at DESC")
435
+ .all() as Array<{ id: string; cwd: string }>;
436
+ return rows.filter((row) => projectNameSet.has(getPortablePathBasename(row.cwd))).map((row) => row.id);
437
+ } finally {
438
+ db.close();
439
+ }
440
+ };
441
+
442
+ const createPromptInterface = (): Interface => {
443
+ return createInterface({ input, output });
444
+ };
445
+
446
+ const isRawThreadId = (value: string): boolean => {
447
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value);
448
+ };