spiracha 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +27 -12
- package/README.md +42 -19
- package/apps/ui/AGENTS.md +18 -10
- package/apps/ui/README.md +36 -11
- package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
- package/apps/ui/src/components/antigravity-conversations-table.tsx +51 -11
- package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
- package/apps/ui/src/components/app-shell.tsx +46 -3
- package/apps/ui/src/components/breadcrumbs.tsx +21 -3
- package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
- package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
- package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
- package/apps/ui/src/components/data-table.tsx +23 -5
- package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
- package/apps/ui/src/components/export-dialog.tsx +79 -74
- package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
- package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
- package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
- package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
- package/apps/ui/src/components/page-header.tsx +15 -9
- package/apps/ui/src/components/projects-table.tsx +4 -1
- package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
- package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
- package/apps/ui/src/components/route-error-panel.tsx +13 -0
- package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
- package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
- package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
- package/apps/ui/src/components/threads-table.tsx +13 -47
- package/apps/ui/src/components/transcript-view.tsx +148 -52
- package/apps/ui/src/components/ui/select.tsx +1 -1
- package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
- package/apps/ui/src/lib/antigravity-server.ts +209 -11
- package/apps/ui/src/lib/antigravity-transcript-events.ts +57 -73
- package/apps/ui/src/lib/claude-code-queries.ts +8 -0
- package/apps/ui/src/lib/claude-code-server.ts +185 -7
- package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
- package/apps/ui/src/lib/codex-queries.ts +16 -0
- package/apps/ui/src/lib/codex-server.ts +134 -52
- package/apps/ui/src/lib/cursor-server.ts +68 -53
- package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
- package/apps/ui/src/lib/delete-batch.ts +52 -0
- package/apps/ui/src/lib/error-presentation.ts +43 -0
- package/apps/ui/src/lib/export-mutation.ts +20 -0
- package/apps/ui/src/lib/export-options.ts +15 -0
- package/apps/ui/src/lib/formatters.ts +3 -5
- package/apps/ui/src/lib/grok-queries.ts +22 -0
- package/apps/ui/src/lib/grok-server.ts +168 -0
- package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
- package/apps/ui/src/lib/kiro-server.ts +97 -7
- package/apps/ui/src/lib/mutation-error.ts +15 -0
- package/apps/ui/src/lib/opencode-server.ts +96 -7
- package/apps/ui/src/lib/path-utils.ts +1 -1
- package/apps/ui/src/lib/qoder-queries.ts +15 -0
- package/apps/ui/src/lib/qoder-server.ts +67 -11
- package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
- package/apps/ui/src/lib/route-search.ts +114 -0
- package/apps/ui/src/lib/route-state-reset.tsx +10 -0
- package/apps/ui/src/lib/settings-server.ts +33 -0
- package/apps/ui/src/lib/settings-store.tsx +82 -38
- package/apps/ui/src/lib/settings.ts +65 -0
- package/apps/ui/src/lib/source-session-export-server.ts +86 -3
- package/apps/ui/src/lib/thread-metadata.ts +22 -0
- package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
- package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
- package/apps/ui/src/lib/workspace-delete-navigation.ts +20 -0
- package/apps/ui/src/routeTree.gen.ts +341 -234
- package/apps/ui/src/routes/__root.tsx +12 -15
- package/apps/ui/src/routes/analytics.tsx +10 -61
- package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +149 -29
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +262 -42
- package/apps/ui/src/routes/antigravity.index.tsx +2 -2
- package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
- package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
- package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
- package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +168 -28
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +179 -45
- package/apps/ui/src/routes/claude-code.index.tsx +2 -2
- package/apps/ui/src/routes/codex.$project.tsx +33 -31
- package/apps/ui/src/routes/codex.index.tsx +5 -10
- package/apps/ui/src/routes/cursor-threads.$composerId.tsx +31 -18
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +44 -28
- package/apps/ui/src/routes/cursor.index.tsx +5 -2
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +401 -0
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +295 -0
- package/apps/ui/src/routes/grok.index.tsx +48 -0
- package/apps/ui/src/routes/index.tsx +2 -18
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +91 -27
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +213 -51
- package/apps/ui/src/routes/kiro.index.tsx +2 -2
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +108 -29
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +231 -55
- package/apps/ui/src/routes/opencode.index.tsx +6 -2
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +19 -15
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +89 -48
- package/apps/ui/src/routes/qoder.index.tsx +2 -2
- package/apps/ui/src/routes/threads.$threadId.tsx +620 -105
- package/apps/ui/vite.config.ts +22 -12
- package/bin/spiracha.ts +28 -1
- package/package.json +37 -27
- package/src/client.ts +183 -5
- package/src/lib/antigravity-db.ts +424 -94
- package/src/lib/antigravity-exporter-types.ts +3 -0
- package/src/lib/antigravity-keychain.ts +7 -8
- package/src/lib/antigravity-projects.ts +201 -0
- package/src/lib/antigravity-transcript-phase.ts +47 -0
- package/src/lib/claude-code-db.ts +534 -42
- package/src/lib/claude-code-exporter-types.ts +5 -0
- package/src/lib/claude-code-transcript-phase.ts +60 -1
- package/src/lib/claude-code-transcript.ts +8 -5
- package/src/lib/codex-analytics.ts +32 -5
- package/src/lib/codex-browser-db.ts +540 -137
- package/src/lib/codex-browser-export.ts +18 -27
- package/src/lib/codex-browser-types.ts +21 -1
- package/src/lib/codex-thread-cache.ts +106 -23
- package/src/lib/codex-thread-parser.ts +149 -55
- package/src/lib/codex-thread-recovery.ts +38 -11
- package/src/lib/codex-transcript-filter.ts +31 -0
- package/src/lib/codex-transcript-renderer.ts +87 -79
- package/src/lib/concurrency.ts +56 -3
- package/src/lib/conversation-api.ts +410 -27
- package/src/lib/conversation-data/adapter-helpers.ts +25 -3
- package/src/lib/conversation-data/antigravity-adapter.ts +70 -26
- package/src/lib/conversation-data/claude-code-adapter.ts +69 -12
- package/src/lib/conversation-data/codex-adapter.ts +108 -73
- package/src/lib/conversation-data/cursor-adapter.ts +77 -29
- package/src/lib/conversation-data/grok-adapter.ts +223 -0
- package/src/lib/conversation-data/index.ts +105 -11
- package/src/lib/conversation-data/kiro-adapter.ts +59 -12
- package/src/lib/conversation-data/message-selector.ts +2 -5
- package/src/lib/conversation-data/opencode-adapter.ts +67 -17
- package/src/lib/conversation-data/path-match.ts +1 -13
- package/src/lib/conversation-data/qoder-adapter.ts +67 -37
- package/src/lib/conversation-data/types.ts +43 -0
- package/src/lib/conversation-zip-export.ts +75 -0
- package/src/lib/cursor-db.ts +68 -28
- package/src/lib/cursor-id.ts +17 -0
- package/src/lib/cursor-recovery.ts +146 -35
- package/src/lib/cursor-transcript-phase.ts +40 -0
- package/src/lib/cursor-transcript.ts +21 -5
- package/src/lib/grok-db.ts +1068 -0
- package/src/lib/grok-exporter-types.ts +110 -0
- package/src/lib/grok-transcript-phase.ts +61 -0
- package/src/lib/grok-transcript.ts +167 -0
- package/src/lib/kiro-db.ts +187 -43
- package/src/lib/kiro-transcript.ts +0 -4
- package/src/lib/model-label.ts +11 -3
- package/src/lib/opencode-db.ts +599 -60
- package/src/lib/opencode-think-tags.ts +17 -3
- package/src/lib/opencode-transcript.ts +8 -5
- package/src/lib/portable-path.ts +9 -0
- package/src/lib/qoder-acp-client.ts +11 -2
- package/src/lib/qoder-db.ts +51 -17
- package/src/lib/qoder-transcript-phase.ts +64 -0
- package/src/lib/qoder-transcript.ts +30 -2
- package/src/lib/shared.ts +63 -15
- package/src/lib/sqlite-error.ts +31 -5
- package/src/lib/transcript-load-limiter.ts +87 -0
- package/src/lib/ui-cache.ts +185 -24
- package/src/lib/ui-export-archive.ts +58 -39
- package/src/lib/ui-export-files.ts +75 -15
- package/src/lib/ui-export-zip.ts +40 -0
- package/apps/ui/package.json +0 -65
|
@@ -1,6 +1,14 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { mapWithConcurrency } from './concurrency';
|
|
1
3
|
import {
|
|
4
|
+
type ConversationDetail,
|
|
2
5
|
type ConversationMessageSelector,
|
|
3
6
|
type ConversationSource,
|
|
7
|
+
type DeleteConversationOptions,
|
|
8
|
+
type DeleteConversationsOptions,
|
|
9
|
+
deleteConversation,
|
|
10
|
+
deleteConversations,
|
|
11
|
+
type ExportConversationsZipOptions,
|
|
4
12
|
type GetConversationOptions,
|
|
5
13
|
getConversation,
|
|
6
14
|
isConversationSource,
|
|
@@ -10,8 +18,11 @@ import {
|
|
|
10
18
|
renderConversationMarkdown,
|
|
11
19
|
resolveConversationRef,
|
|
12
20
|
} from './conversation-data';
|
|
21
|
+
import { createConversationMarkdownZip } from './conversation-zip-export';
|
|
13
22
|
|
|
14
23
|
type ConversationApiDependencies = {
|
|
24
|
+
deleteConversation?: typeof deleteConversation;
|
|
25
|
+
deleteConversations?: typeof deleteConversations;
|
|
15
26
|
getConversation?: typeof getConversation;
|
|
16
27
|
listConversationSources?: typeof listConversationSources;
|
|
17
28
|
listConversationsForPath?: typeof listConversationsForPath;
|
|
@@ -19,10 +30,18 @@ type ConversationApiDependencies = {
|
|
|
19
30
|
resolveConversationRef?: typeof resolveConversationRef;
|
|
20
31
|
};
|
|
21
32
|
|
|
22
|
-
type ApiErrorCode =
|
|
33
|
+
type ApiErrorCode =
|
|
34
|
+
| 'conversation_not_found'
|
|
35
|
+
| 'internal_error'
|
|
36
|
+
| 'method_not_allowed'
|
|
37
|
+
| 'not_found'
|
|
38
|
+
| 'unsupported_operation'
|
|
39
|
+
| 'validation_error';
|
|
23
40
|
type ParseResult<T> = { error: Response } | { value: T };
|
|
24
41
|
|
|
25
42
|
const MAX_CURSOR_OFFSET = 1_000_000;
|
|
43
|
+
const BATCH_LOAD_CONCURRENCY = 4;
|
|
44
|
+
const MAX_ID_BATCH_SIZE = 200;
|
|
26
45
|
const MAX_ID_LENGTH = 2048;
|
|
27
46
|
const MAX_LIMIT = 200;
|
|
28
47
|
const MAX_PATH_LENGTH = 4096;
|
|
@@ -80,10 +99,14 @@ const parseSources = (value: string | null): ParseResult<ConversationSource[] |
|
|
|
80
99
|
return { value: 'all' };
|
|
81
100
|
}
|
|
82
101
|
|
|
83
|
-
const sources =
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
102
|
+
const sources = [
|
|
103
|
+
...new Set(
|
|
104
|
+
value
|
|
105
|
+
.split(',')
|
|
106
|
+
.map((source) => source.trim())
|
|
107
|
+
.filter(Boolean),
|
|
108
|
+
),
|
|
109
|
+
];
|
|
87
110
|
const invalidSource = sources.find((source) => !isConversationSource(source));
|
|
88
111
|
|
|
89
112
|
return invalidSource ? { error: invalidSourceResponse(invalidSource) } : { value: sources as ConversationSource[] };
|
|
@@ -151,6 +174,9 @@ const validatePathLength = (field: string, value: string): Response | null => {
|
|
|
151
174
|
: invalidFieldResponse(field, value.length, `\`${field}\` is too long.`);
|
|
152
175
|
};
|
|
153
176
|
|
|
177
|
+
const validateAbsoluteCwd = (cwd: string): Response | null =>
|
|
178
|
+
path.isAbsolute(cwd) ? null : invalidFieldResponse('cwd', cwd, '`cwd` must be an absolute path.');
|
|
179
|
+
|
|
154
180
|
const validateTimestamp = (field: string, value: number | undefined): Response | null => {
|
|
155
181
|
if (value === undefined) {
|
|
156
182
|
return null;
|
|
@@ -161,6 +187,12 @@ const validateTimestamp = (field: string, value: number | undefined): Response |
|
|
|
161
187
|
: invalidFieldResponse(field, value, `\`${field}\` must be a non-negative epoch millisecond timestamp.`);
|
|
162
188
|
};
|
|
163
189
|
|
|
190
|
+
const validateConversationId = (id: string): Response | null => {
|
|
191
|
+
return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u.test(id) && !id.includes('..')
|
|
192
|
+
? null
|
|
193
|
+
: invalidFieldResponse('id', id, 'Conversation id contains characters that are not allowed by the stable API.');
|
|
194
|
+
};
|
|
195
|
+
|
|
164
196
|
const parseListLimitParam = (value: string | null): ParseResult<number | undefined> => {
|
|
165
197
|
if (!value) {
|
|
166
198
|
return { value: undefined };
|
|
@@ -204,6 +236,10 @@ const buildListOptions = (url: URL): ParseResult<ListConversationsForPathOptions
|
|
|
204
236
|
if (cwdLengthError) {
|
|
205
237
|
return { error: cwdLengthError };
|
|
206
238
|
}
|
|
239
|
+
const cwdAbsoluteError = validateAbsoluteCwd(cwd);
|
|
240
|
+
if (cwdAbsoluteError) {
|
|
241
|
+
return { error: cwdAbsoluteError };
|
|
242
|
+
}
|
|
207
243
|
|
|
208
244
|
const cursor = url.searchParams.get('cursor');
|
|
209
245
|
const cursorError = validateCursor(cursor);
|
|
@@ -251,11 +287,13 @@ const buildListOptions = (url: URL): ParseResult<ListConversationsForPathOptions
|
|
|
251
287
|
};
|
|
252
288
|
|
|
253
289
|
const normalizeMeta = (meta: { hasNext: boolean; nextCursor: string | null }) => ({
|
|
254
|
-
|
|
290
|
+
has_next: meta.hasNext,
|
|
255
291
|
next_cursor: meta.nextCursor,
|
|
256
292
|
});
|
|
257
293
|
|
|
258
294
|
const getDeps = (dependencies: ConversationApiDependencies) => ({
|
|
295
|
+
deleteConversation: dependencies.deleteConversation ?? deleteConversation,
|
|
296
|
+
deleteConversations: dependencies.deleteConversations ?? deleteConversations,
|
|
259
297
|
getConversation: dependencies.getConversation ?? getConversation,
|
|
260
298
|
listConversationSources: dependencies.listConversationSources ?? listConversationSources,
|
|
261
299
|
listConversationsForPath: dependencies.listConversationsForPath ?? listConversationsForPath,
|
|
@@ -319,6 +357,40 @@ const buildGetConversationOptions = (
|
|
|
319
357
|
};
|
|
320
358
|
};
|
|
321
359
|
|
|
360
|
+
const buildDeleteConversationOptions = (
|
|
361
|
+
source: string | undefined,
|
|
362
|
+
id: string | undefined,
|
|
363
|
+
): ParseResult<DeleteConversationOptions> => {
|
|
364
|
+
if (!source || !id) {
|
|
365
|
+
return { error: errorResponse('validation_error', 'Conversation source and id are required.', 400) };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!isConversationSource(source)) {
|
|
369
|
+
return { error: invalidSourceResponse(source) };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let decodedId: string;
|
|
373
|
+
try {
|
|
374
|
+
decodedId = decodeURIComponent(id);
|
|
375
|
+
} catch {
|
|
376
|
+
return { error: invalidFieldResponse('id', id, 'Conversation id must be URL encoded.') };
|
|
377
|
+
}
|
|
378
|
+
if (!decodedId.trim() || decodedId.length > MAX_ID_LENGTH) {
|
|
379
|
+
return { error: invalidFieldResponse('id', decodedId.length, 'Conversation id is invalid.') };
|
|
380
|
+
}
|
|
381
|
+
const idError = validateConversationId(decodedId);
|
|
382
|
+
if (idError) {
|
|
383
|
+
return { error: idError };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
value: {
|
|
388
|
+
id: decodedId,
|
|
389
|
+
source,
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
};
|
|
393
|
+
|
|
322
394
|
const handleGetConversation = async (
|
|
323
395
|
source: string | undefined,
|
|
324
396
|
id: string | undefined,
|
|
@@ -360,10 +432,288 @@ const handleExportConversation = async (
|
|
|
360
432
|
});
|
|
361
433
|
}
|
|
362
434
|
|
|
363
|
-
return new Response(
|
|
435
|
+
return new Response(
|
|
436
|
+
dependencies.renderConversationMarkdown(conversation, {
|
|
437
|
+
messageSelector: result.value.messageSelector,
|
|
438
|
+
}),
|
|
439
|
+
{
|
|
440
|
+
headers: {
|
|
441
|
+
'Cache-Control': 'no-store',
|
|
442
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
443
|
+
'X-Content-Type-Options': 'nosniff',
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
);
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const handleDeleteConversation = async (
|
|
450
|
+
source: string | undefined,
|
|
451
|
+
id: string | undefined,
|
|
452
|
+
dependencies: ReturnType<typeof getDeps>,
|
|
453
|
+
) => {
|
|
454
|
+
const result = buildDeleteConversationOptions(source, id);
|
|
455
|
+
if ('error' in result) {
|
|
456
|
+
return result.error;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const deleteResult = await dependencies.deleteConversation(result.value);
|
|
460
|
+
if (!deleteResult) {
|
|
461
|
+
return errorResponse(
|
|
462
|
+
'unsupported_operation',
|
|
463
|
+
`Deleting ${result.value.source} conversations is not supported by the stable API.`,
|
|
464
|
+
405,
|
|
465
|
+
{
|
|
466
|
+
source: result.value.source,
|
|
467
|
+
},
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (deleteResult.deletedIds.length === 0) {
|
|
472
|
+
return errorResponse('conversation_not_found', 'No conversation exists for that source and id.', 404, {
|
|
473
|
+
id: result.value.id,
|
|
474
|
+
source: result.value.source,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return jsonResponse({ data: deleteResult });
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const parseJsonBody = async (request: Request): Promise<ParseResult<Record<string, unknown>>> => {
|
|
482
|
+
let body: unknown;
|
|
483
|
+
try {
|
|
484
|
+
body = await request.json();
|
|
485
|
+
} catch {
|
|
486
|
+
return { error: errorResponse('validation_error', 'Request body must be JSON.', 400) };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return isRecord(body)
|
|
490
|
+
? { value: body }
|
|
491
|
+
: { error: errorResponse('validation_error', 'Request body must be a JSON object.', 400) };
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const parseJsonSourceOption = (body: Record<string, unknown>): ParseResult<ConversationSource> => {
|
|
495
|
+
const sourceOption = getStringOption(body, 'source', 'source');
|
|
496
|
+
if ('error' in sourceOption) {
|
|
497
|
+
return sourceOption;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const source = sourceOption.value?.trim();
|
|
501
|
+
if (!source) {
|
|
502
|
+
return { error: errorResponse('validation_error', '`source` is required.', 400, { field: 'source' }) };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return isConversationSource(source) ? { value: source } : { error: invalidSourceResponse(source) };
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const parseJsonIdsOption = (body: Record<string, unknown>): ParseResult<string[]> => {
|
|
509
|
+
const idsValue = getOption(body, 'ids', 'ids');
|
|
510
|
+
if (!Array.isArray(idsValue) || idsValue.length === 0) {
|
|
511
|
+
return {
|
|
512
|
+
error: invalidFieldResponse('ids', idsValue, '`ids` must be a non-empty array of explicit ids.'),
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (idsValue.length > MAX_ID_BATCH_SIZE) {
|
|
517
|
+
return {
|
|
518
|
+
error: invalidFieldResponse(
|
|
519
|
+
'ids',
|
|
520
|
+
idsValue.length,
|
|
521
|
+
`\`ids\` cannot include more than ${MAX_ID_BATCH_SIZE} ids.`,
|
|
522
|
+
),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const ids: string[] = [];
|
|
527
|
+
const seenIds = new Set<string>();
|
|
528
|
+
for (const [index, value] of idsValue.entries()) {
|
|
529
|
+
if (typeof value !== 'string') {
|
|
530
|
+
return {
|
|
531
|
+
error: invalidFieldResponse('ids', value, `\`ids[${index}]\` must be a string.`),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const id = value.trim();
|
|
536
|
+
if (!id || id.length > MAX_ID_LENGTH) {
|
|
537
|
+
return {
|
|
538
|
+
error: invalidFieldResponse('ids', value, `\`ids[${index}]\` is invalid.`),
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const idError = validateConversationId(id);
|
|
543
|
+
if (idError) {
|
|
544
|
+
return { error: idError };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (!seenIds.has(id)) {
|
|
548
|
+
seenIds.add(id);
|
|
549
|
+
ids.push(id);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return { value: ids };
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const parseJsonExportFormat = (body: Record<string, unknown>): ParseResult<'md'> => {
|
|
557
|
+
const outputFormat = getStringOption(body, 'outputFormat', 'output_format');
|
|
558
|
+
if ('error' in outputFormat) {
|
|
559
|
+
return outputFormat;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (outputFormat.value === undefined || outputFormat.value === 'md') {
|
|
563
|
+
return { value: 'md' };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return { error: invalidFieldResponse('output_format', outputFormat.value, '`output_format` must be "md".') };
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const parseJsonExportMessageSelector = (body: Record<string, unknown>): ParseResult<ConversationMessageSelector> => {
|
|
570
|
+
const messageSelectorValue = getStringOption(body, 'messageSelector', 'message_selector');
|
|
571
|
+
if ('error' in messageSelectorValue) {
|
|
572
|
+
return messageSelectorValue;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return parseMessageSelector(messageSelectorValue.value ?? null, 'all');
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
const parseConversationIdSetRecord = (body: Record<string, unknown>): ParseResult<DeleteConversationsOptions> => {
|
|
579
|
+
const source = parseJsonSourceOption(body);
|
|
580
|
+
if ('error' in source) {
|
|
581
|
+
return source;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const ids = parseJsonIdsOption(body);
|
|
585
|
+
if ('error' in ids) {
|
|
586
|
+
return ids;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
value: {
|
|
591
|
+
ids: ids.value,
|
|
592
|
+
source: source.value,
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const parseExportConversationsBody = async (request: Request): Promise<ParseResult<ExportConversationsZipOptions>> => {
|
|
598
|
+
const body = await parseJsonBody(request);
|
|
599
|
+
if ('error' in body) {
|
|
600
|
+
return body;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const idSet = parseConversationIdSetRecord(body.value);
|
|
604
|
+
if ('error' in idSet) {
|
|
605
|
+
return idSet;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const outputFormat = parseJsonExportFormat(body.value);
|
|
609
|
+
if ('error' in outputFormat) {
|
|
610
|
+
return outputFormat;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const messageSelector = parseJsonExportMessageSelector(body.value);
|
|
614
|
+
if ('error' in messageSelector) {
|
|
615
|
+
return messageSelector;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
value: {
|
|
620
|
+
ids: idSet.value.ids,
|
|
621
|
+
messageSelector: messageSelector.value,
|
|
622
|
+
outputFormat: outputFormat.value,
|
|
623
|
+
source: idSet.value.source,
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
const handleDeleteConversations = async (request: Request, dependencies: ReturnType<typeof getDeps>) => {
|
|
629
|
+
const body = await parseJsonBody(request);
|
|
630
|
+
if ('error' in body) {
|
|
631
|
+
return body.error;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const result = parseConversationIdSetRecord(body.value);
|
|
635
|
+
if ('error' in result) {
|
|
636
|
+
return result.error;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const deleteResult = await dependencies.deleteConversations(result.value);
|
|
640
|
+
if (!deleteResult) {
|
|
641
|
+
return errorResponse(
|
|
642
|
+
'unsupported_operation',
|
|
643
|
+
`Deleting ${result.value.source} conversations is not supported by the stable API.`,
|
|
644
|
+
405,
|
|
645
|
+
{
|
|
646
|
+
source: result.value.source,
|
|
647
|
+
},
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (deleteResult.deletedIds.length === 0) {
|
|
652
|
+
return errorResponse('conversation_not_found', 'No conversations exist for that source and id set.', 404, {
|
|
653
|
+
ids: result.value.ids,
|
|
654
|
+
source: result.value.source,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return jsonResponse({ data: deleteResult });
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const getConversationZipEntry = (conversation: ConversationDetail, markdown: string) => ({
|
|
662
|
+
fallbackBaseName: `${conversation.source}-${conversation.id}`,
|
|
663
|
+
markdown,
|
|
664
|
+
title: conversation.title,
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
const handleExportConversations = async (request: Request, dependencies: ReturnType<typeof getDeps>) => {
|
|
668
|
+
const result = await parseExportConversationsBody(request);
|
|
669
|
+
if ('error' in result) {
|
|
670
|
+
return result.error;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const loaded = await mapWithConcurrency(result.value.ids, BATCH_LOAD_CONCURRENCY, async (id) => {
|
|
674
|
+
const conversation = await dependencies.getConversation({
|
|
675
|
+
id,
|
|
676
|
+
messageSelector: result.value.messageSelector,
|
|
677
|
+
source: result.value.source,
|
|
678
|
+
});
|
|
679
|
+
if (!conversation) {
|
|
680
|
+
return { entry: null, id };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
entry: getConversationZipEntry(
|
|
685
|
+
conversation,
|
|
686
|
+
dependencies.renderConversationMarkdown(conversation, {
|
|
687
|
+
messageSelector: result.value.messageSelector,
|
|
688
|
+
}),
|
|
689
|
+
),
|
|
690
|
+
id,
|
|
691
|
+
};
|
|
692
|
+
});
|
|
693
|
+
const missingIds = loaded.filter(({ entry }) => !entry).map(({ id }) => id);
|
|
694
|
+
|
|
695
|
+
if (missingIds.length > 0) {
|
|
696
|
+
return errorResponse(
|
|
697
|
+
'conversation_not_found',
|
|
698
|
+
'Some conversations do not exist for that source and id set.',
|
|
699
|
+
404,
|
|
700
|
+
{
|
|
701
|
+
ids: missingIds,
|
|
702
|
+
source: result.value.source,
|
|
703
|
+
},
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const zip = await createConversationMarkdownZip({
|
|
708
|
+
entries: loaded.flatMap(({ entry }) => (entry ? [entry] : [])),
|
|
709
|
+
fileBaseName: `${result.value.source}-conversations-${result.value.ids.length}`,
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
return new Response(zip.blob, {
|
|
364
713
|
headers: {
|
|
365
714
|
'Cache-Control': 'no-store',
|
|
366
|
-
'Content-
|
|
715
|
+
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(zip.fileName)}`,
|
|
716
|
+
'Content-Type': zip.mimeType,
|
|
367
717
|
'X-Content-Type-Options': 'nosniff',
|
|
368
718
|
},
|
|
369
719
|
});
|
|
@@ -497,8 +847,8 @@ const parseJsonCwd = (body: Record<string, unknown>): ParseResult<string> => {
|
|
|
497
847
|
return { error: errorResponse('validation_error', '`cwd` is required.', 400, { field: 'cwd' }) };
|
|
498
848
|
}
|
|
499
849
|
|
|
500
|
-
const
|
|
501
|
-
return
|
|
850
|
+
const cwdError = validatePathLength('cwd', cwd) ?? validateAbsoluteCwd(cwd);
|
|
851
|
+
return cwdError ? { error: cwdError } : { value: cwd };
|
|
502
852
|
};
|
|
503
853
|
|
|
504
854
|
const parseJsonCursor = (body: Record<string, unknown>): ParseResult<string | null> => {
|
|
@@ -606,6 +956,7 @@ const validateListQueryOptions = (options: ListConversationsForPathOptions): Res
|
|
|
606
956
|
|
|
607
957
|
return (
|
|
608
958
|
validatePathLength('cwd', options.cwd) ??
|
|
959
|
+
validateAbsoluteCwd(options.cwd) ??
|
|
609
960
|
validateCursor(options.cursor) ??
|
|
610
961
|
validateLimit(options.limit) ??
|
|
611
962
|
validateTimestamp('updated_after_ms', options.updatedAfterMs) ??
|
|
@@ -669,7 +1020,7 @@ const API_ROUTES: ApiRoute[] = [
|
|
|
669
1020
|
},
|
|
670
1021
|
{
|
|
671
1022
|
handle: ({ dependencies, url }) => handleListConversations(url, dependencies),
|
|
672
|
-
matches: ({ source }) => !source,
|
|
1023
|
+
matches: ({ action, source }) => !source && !action,
|
|
673
1024
|
method: 'GET',
|
|
674
1025
|
resource: 'conversations',
|
|
675
1026
|
},
|
|
@@ -685,6 +1036,24 @@ const API_ROUTES: ApiRoute[] = [
|
|
|
685
1036
|
method: 'GET',
|
|
686
1037
|
resource: 'conversations',
|
|
687
1038
|
},
|
|
1039
|
+
{
|
|
1040
|
+
handle: ({ dependencies, id, source }) => handleDeleteConversation(source, id, dependencies),
|
|
1041
|
+
matches: ({ action, id, source }) => Boolean(source && id && !action),
|
|
1042
|
+
method: 'DELETE',
|
|
1043
|
+
resource: 'conversations',
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
handle: ({ dependencies, request }) => handleDeleteConversations(request, dependencies),
|
|
1047
|
+
matches: ({ action, source }) => !source && action === 'delete',
|
|
1048
|
+
method: 'POST',
|
|
1049
|
+
resource: 'conversations',
|
|
1050
|
+
},
|
|
1051
|
+
{
|
|
1052
|
+
handle: ({ dependencies, request }) => handleExportConversations(request, dependencies),
|
|
1053
|
+
matches: ({ action, source }) => !source && action === 'export',
|
|
1054
|
+
method: 'POST',
|
|
1055
|
+
resource: 'conversations',
|
|
1056
|
+
},
|
|
688
1057
|
{
|
|
689
1058
|
handle: ({ dependencies, url }) => handleResolve(url, dependencies),
|
|
690
1059
|
matches: ({ source }) => !source,
|
|
@@ -716,6 +1085,22 @@ const allowedMethodsForContext = (context: ApiRouteContext) => {
|
|
|
716
1085
|
].sort();
|
|
717
1086
|
};
|
|
718
1087
|
|
|
1088
|
+
const parseConversationApiSegments = (segments: string[], resource: string) => {
|
|
1089
|
+
if (segments.length === 3) {
|
|
1090
|
+
return { action: undefined, id: undefined, resource, source: undefined };
|
|
1091
|
+
}
|
|
1092
|
+
if (segments.length === 4 && (segments[3] === 'delete' || segments[3] === 'export')) {
|
|
1093
|
+
return { action: segments[3], id: undefined, resource, source: undefined };
|
|
1094
|
+
}
|
|
1095
|
+
if (segments.length === 5) {
|
|
1096
|
+
return { action: undefined, id: segments[4], resource, source: segments[3] };
|
|
1097
|
+
}
|
|
1098
|
+
if (segments.length === 6 && segments[5] === 'export') {
|
|
1099
|
+
return { action: 'export', id: segments[4], resource, source: segments[3] };
|
|
1100
|
+
}
|
|
1101
|
+
return { action: '__invalid__', id: undefined, resource, source: undefined };
|
|
1102
|
+
};
|
|
1103
|
+
|
|
719
1104
|
const parseApiSegments = (segments: string[]) => {
|
|
720
1105
|
if (segments.length < 3 || segments[0] !== 'api' || segments[1] !== 'v1') {
|
|
721
1106
|
return null;
|
|
@@ -727,23 +1112,12 @@ const parseApiSegments = (segments: string[]) => {
|
|
|
727
1112
|
}
|
|
728
1113
|
|
|
729
1114
|
if (resource === 'conversations') {
|
|
730
|
-
|
|
731
|
-
return { action: undefined, id: undefined, resource, source: undefined };
|
|
732
|
-
}
|
|
733
|
-
if (segments.length === 5) {
|
|
734
|
-
return { action: undefined, id: segments[4], resource, source: segments[3] };
|
|
735
|
-
}
|
|
736
|
-
if (segments.length === 6 && segments[5] === 'export') {
|
|
737
|
-
return { action: 'export', id: segments[4], resource, source: segments[3] };
|
|
738
|
-
}
|
|
739
|
-
return { action: '__invalid__', id: undefined, resource, source: undefined };
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
if (segments.length === 3) {
|
|
743
|
-
return { action: undefined, id: undefined, resource, source: undefined };
|
|
1115
|
+
return parseConversationApiSegments(segments, resource);
|
|
744
1116
|
}
|
|
745
1117
|
|
|
746
|
-
return
|
|
1118
|
+
return segments.length === 3
|
|
1119
|
+
? { action: undefined, id: undefined, resource, source: undefined }
|
|
1120
|
+
: { action: '__invalid__', id: undefined, resource, source: undefined };
|
|
747
1121
|
};
|
|
748
1122
|
|
|
749
1123
|
export const handleConversationApiRequest = async (
|
|
@@ -776,7 +1150,16 @@ export const handleConversationApiRequest = async (
|
|
|
776
1150
|
const route = findRoute(context);
|
|
777
1151
|
|
|
778
1152
|
if (route) {
|
|
779
|
-
|
|
1153
|
+
try {
|
|
1154
|
+
return await route.handle(context);
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
console.error('[spiracha:conversation-api] request_failed', {
|
|
1157
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1158
|
+
method: request.method,
|
|
1159
|
+
pathname: url.pathname,
|
|
1160
|
+
});
|
|
1161
|
+
return errorResponse('internal_error', 'Conversation API request failed.', 500);
|
|
1162
|
+
}
|
|
780
1163
|
}
|
|
781
1164
|
|
|
782
1165
|
if (parsed.resource && API_RESOURCES.has(parsed.resource)) {
|
|
@@ -4,8 +4,23 @@ import type {
|
|
|
4
4
|
ConversationMessagePhase,
|
|
5
5
|
ConversationMessageRole,
|
|
6
6
|
ConversationSource,
|
|
7
|
+
ListConversationsForPathOptions,
|
|
7
8
|
} from './types';
|
|
8
9
|
|
|
10
|
+
export const isWithinUpdatedWindow = (
|
|
11
|
+
updatedAtMs: number | null | undefined,
|
|
12
|
+
options: Pick<ListConversationsForPathOptions, 'updatedAfterMs' | 'updatedBeforeMs'>,
|
|
13
|
+
) => {
|
|
14
|
+
const comparableUpdatedAtMs = updatedAtMs ?? 0;
|
|
15
|
+
if (options.updatedAfterMs !== undefined && comparableUpdatedAtMs < options.updatedAfterMs) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
if (options.updatedBeforeMs !== undefined && comparableUpdatedAtMs > options.updatedBeforeMs) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
};
|
|
23
|
+
|
|
9
24
|
export const toDateMs = (value: string | number | null | undefined): number | null => {
|
|
10
25
|
if (typeof value === 'number') {
|
|
11
26
|
return Number.isFinite(value) ? value : null;
|
|
@@ -36,8 +51,12 @@ export const decodeFileUri = (value: string | null | undefined): string | null =
|
|
|
36
51
|
}
|
|
37
52
|
return pathname.replace(/^\/([A-Za-z]:)/u, '$1');
|
|
38
53
|
} catch {
|
|
39
|
-
const
|
|
40
|
-
|
|
54
|
+
const rawPathValue = value.slice('file://'.length);
|
|
55
|
+
try {
|
|
56
|
+
return decodeURIComponent(rawPathValue).replace(/^\/([A-Za-z]:)/u, '$1');
|
|
57
|
+
} catch {
|
|
58
|
+
return rawPathValue.replace(/^\/([A-Za-z]:)/u, '$1');
|
|
59
|
+
}
|
|
41
60
|
}
|
|
42
61
|
};
|
|
43
62
|
|
|
@@ -48,10 +67,13 @@ export const createDeepLinks = (
|
|
|
48
67
|
native: string | null = null,
|
|
49
68
|
): ConversationDeepLinks => ({
|
|
50
69
|
native,
|
|
51
|
-
spiracha: `spiracha://conversation/${source}/${id}`,
|
|
70
|
+
spiracha: `spiracha://conversation/${source}/${encodeURIComponent(id)}`,
|
|
52
71
|
ui: uiPath,
|
|
53
72
|
});
|
|
54
73
|
|
|
74
|
+
export const createConversationUiPath = (routeSegment: string, id: string) =>
|
|
75
|
+
`/${routeSegment}/${encodeURIComponent(id)}`;
|
|
76
|
+
|
|
55
77
|
export const normalizeRole = (role: string | null | undefined): ConversationMessageRole => {
|
|
56
78
|
if (role === 'assistant' || role === 'system' || role === 'tool' || role === 'user') {
|
|
57
79
|
return role;
|