spiracha 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/apps/ui/README.md +20 -2
- package/apps/ui/package.json +2 -2
- package/apps/ui/src/components/antigravity-conversations-table.tsx +50 -11
- package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
- package/apps/ui/src/components/app-shell.tsx +1 -0
- package/apps/ui/src/components/breadcrumbs.tsx +21 -3
- package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
- package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
- package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
- package/apps/ui/src/components/data-table.tsx +7 -5
- package/apps/ui/src/components/export-dialog.tsx +2 -12
- package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
- package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
- package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
- package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
- package/apps/ui/src/components/page-header.tsx +15 -9
- package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
- package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
- package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
- package/apps/ui/src/components/threads-table.tsx +9 -46
- package/apps/ui/src/components/transcript-view.tsx +148 -52
- package/apps/ui/src/components/ui/select.tsx +1 -1
- package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
- package/apps/ui/src/lib/antigravity-server.ts +152 -7
- package/apps/ui/src/lib/antigravity-transcript-events.ts +11 -3
- package/apps/ui/src/lib/claude-code-queries.ts +8 -0
- package/apps/ui/src/lib/claude-code-server.ts +175 -7
- package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
- package/apps/ui/src/lib/codex-queries.ts +19 -3
- package/apps/ui/src/lib/codex-server.ts +135 -53
- package/apps/ui/src/lib/cursor-server.ts +48 -5
- package/apps/ui/src/lib/formatters.ts +3 -5
- package/apps/ui/src/lib/grok-queries.ts +22 -0
- package/apps/ui/src/lib/grok-server.ts +169 -0
- package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
- package/apps/ui/src/lib/kiro-server.ts +85 -7
- package/apps/ui/src/lib/opencode-server.ts +85 -7
- package/apps/ui/src/lib/qoder-server.ts +67 -11
- package/apps/ui/src/lib/route-search.ts +114 -0
- package/apps/ui/src/lib/source-session-export-server.ts +86 -3
- package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
- package/apps/ui/src/lib/workspace-delete-navigation.ts +12 -0
- package/apps/ui/src/routeTree.gen.ts +107 -0
- package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +76 -10
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +269 -31
- package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
- package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
- package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
- package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +143 -17
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +165 -26
- package/apps/ui/src/routes/cursor-threads.$composerId.tsx +2 -2
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +11 -1
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +388 -0
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +301 -0
- package/apps/ui/src/routes/grok.index.tsx +48 -0
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +66 -15
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +199 -32
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +105 -19
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +236 -44
- package/apps/ui/src/routes/opencode.index.tsx +16 -3
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +7 -4
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +75 -28
- package/apps/ui/src/routes/threads.$threadId.tsx +548 -64
- package/package.json +19 -18
- package/src/client.ts +134 -1
- package/src/lib/antigravity-db.ts +208 -32
- package/src/lib/antigravity-exporter-types.ts +3 -0
- package/src/lib/antigravity-projects.ts +201 -0
- package/src/lib/claude-code-db.ts +498 -32
- package/src/lib/claude-code-exporter-types.ts +5 -0
- package/src/lib/claude-code-transcript-phase.ts +60 -1
- package/src/lib/claude-code-transcript.ts +8 -5
- package/src/lib/codex-browser-export.ts +23 -27
- package/src/lib/codex-thread-cache.ts +41 -13
- package/src/lib/codex-thread-parser.ts +86 -35
- package/src/lib/codex-transcript-filter.ts +31 -0
- package/src/lib/codex-transcript-renderer.ts +39 -19
- package/src/lib/concurrency.ts +41 -0
- package/src/lib/conversation-api.ts +399 -19
- package/src/lib/conversation-data/antigravity-adapter.ts +21 -2
- package/src/lib/conversation-data/claude-code-adapter.ts +37 -6
- package/src/lib/conversation-data/codex-adapter.ts +28 -4
- package/src/lib/conversation-data/cursor-adapter.ts +35 -1
- package/src/lib/conversation-data/grok-adapter.ts +210 -0
- package/src/lib/conversation-data/index.ts +76 -1
- package/src/lib/conversation-data/kiro-adapter.ts +41 -7
- package/src/lib/conversation-data/opencode-adapter.ts +24 -2
- package/src/lib/conversation-data/qoder-adapter.ts +44 -19
- package/src/lib/conversation-data/types.ts +43 -0
- package/src/lib/conversation-zip-export.ts +57 -0
- package/src/lib/cursor-db.ts +34 -5
- package/src/lib/grok-db.ts +1026 -0
- package/src/lib/grok-exporter-types.ts +110 -0
- package/src/lib/grok-transcript-phase.ts +52 -0
- package/src/lib/grok-transcript.ts +154 -0
- package/src/lib/kiro-db.ts +52 -1
- package/src/lib/model-label.ts +11 -3
- package/src/lib/opencode-db.ts +598 -55
- package/src/lib/opencode-transcript.ts +8 -5
- package/src/lib/shared.ts +12 -0
- package/src/lib/transcript-load-limiter.ts +82 -0
- package/src/lib/ui-cache.ts +43 -17
- package/src/lib/ui-export-archive.ts +60 -0
package/package.json
CHANGED
|
@@ -11,37 +11,37 @@
|
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@tailwindcss/typography": "0.5.20",
|
|
14
|
-
"@tailwindcss/vite": "4.3.
|
|
15
|
-
"@tanstack/devtools-vite": "0.
|
|
14
|
+
"@tailwindcss/vite": "4.3.2",
|
|
15
|
+
"@tanstack/devtools-vite": "0.8.1",
|
|
16
16
|
"@tanstack/match-sorter-utils": "8.19.4",
|
|
17
|
-
"@tanstack/react-devtools": "0.10.
|
|
18
|
-
"@tanstack/react-query": "5.101.
|
|
19
|
-
"@tanstack/react-query-devtools": "5.101.
|
|
20
|
-
"@tanstack/react-router": "1.170.
|
|
17
|
+
"@tanstack/react-devtools": "0.10.8",
|
|
18
|
+
"@tanstack/react-query": "5.101.2",
|
|
19
|
+
"@tanstack/react-query-devtools": "5.101.2",
|
|
20
|
+
"@tanstack/react-router": "1.170.18",
|
|
21
21
|
"@tanstack/react-router-devtools": "1.167.0",
|
|
22
22
|
"@tanstack/react-router-ssr-query": "1.167.1",
|
|
23
|
-
"@tanstack/react-start": "1.168.
|
|
23
|
+
"@tanstack/react-start": "1.168.28",
|
|
24
24
|
"@tanstack/react-table": "8.21.3",
|
|
25
|
-
"@tanstack/react-virtual": "3.14.
|
|
26
|
-
"@tanstack/router-plugin": "1.168.
|
|
27
|
-
"@vitejs/plugin-react": "6.0.
|
|
25
|
+
"@tanstack/react-virtual": "3.14.6",
|
|
26
|
+
"@tanstack/router-plugin": "1.168.20",
|
|
27
|
+
"@vitejs/plugin-react": "6.0.3",
|
|
28
28
|
"class-variance-authority": "0.7.1",
|
|
29
29
|
"clsx": "2.1.1",
|
|
30
|
-
"lucide-react": "1.
|
|
31
|
-
"radix-ui": "1.
|
|
30
|
+
"lucide-react": "1.24.0",
|
|
31
|
+
"radix-ui": "1.6.2",
|
|
32
32
|
"react": "19.2.7",
|
|
33
33
|
"react-dom": "19.2.7",
|
|
34
34
|
"tailwind-merge": "3.6.0",
|
|
35
|
-
"tailwindcss": "4.3.
|
|
35
|
+
"tailwindcss": "4.3.2",
|
|
36
36
|
"tw-animate-css": "1.4.0",
|
|
37
|
-
"vite": "8.
|
|
37
|
+
"vite": "8.1.4",
|
|
38
38
|
"zod": "4.4.3"
|
|
39
39
|
},
|
|
40
|
-
"description": "Browse and expose local Codex, Claude Code, Kiro, Qoder, Cursor, Antigravity, and OpenCode conversation history through a local UI and stable data API.",
|
|
40
|
+
"description": "Browse and expose local Codex, Claude Code, Grok, Kiro, Qoder, Cursor, Antigravity, and OpenCode conversation history through a local UI and stable data API.",
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/bun": "^1.3.14",
|
|
43
|
-
"@types/node": "^
|
|
44
|
-
"typescript": "^
|
|
43
|
+
"@types/node": "^26.1.1",
|
|
44
|
+
"typescript": "^7.0.2"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"bun": ">=1.3.14"
|
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
"keywords": [
|
|
82
82
|
"codex",
|
|
83
83
|
"claude",
|
|
84
|
+
"grok",
|
|
84
85
|
"kiro",
|
|
85
86
|
"qoder",
|
|
86
87
|
"cursor",
|
|
@@ -112,7 +113,7 @@
|
|
|
112
113
|
"ui:preview": "bun run --cwd apps/ui preview"
|
|
113
114
|
},
|
|
114
115
|
"type": "module",
|
|
115
|
-
"version": "2.
|
|
116
|
+
"version": "2.1.0",
|
|
116
117
|
"workspaces": [
|
|
117
118
|
"apps/*"
|
|
118
119
|
]
|
package/src/client.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
+
deleteConversation as deleteLocalConversation,
|
|
3
|
+
deleteConversations as deleteLocalConversations,
|
|
2
4
|
getConversation as getLocalConversation,
|
|
3
5
|
listConversationSources as listLocalConversationSources,
|
|
4
6
|
listConversationsForPath as listLocalConversationsForPath,
|
|
@@ -11,10 +13,17 @@ import type {
|
|
|
11
13
|
ConversationMessageSelector,
|
|
12
14
|
ConversationPage,
|
|
13
15
|
ConversationSourceInfo,
|
|
16
|
+
ConversationZipDownload,
|
|
17
|
+
DeleteConversationOptions,
|
|
18
|
+
DeleteConversationResult,
|
|
19
|
+
DeleteConversationsOptions,
|
|
20
|
+
DeleteConversationsResult,
|
|
21
|
+
ExportConversationsZipOptions,
|
|
14
22
|
GetConversationOptions,
|
|
15
23
|
ListConversationsForPathOptions,
|
|
16
24
|
ResolvedConversationRef,
|
|
17
25
|
} from './lib/conversation-data/types';
|
|
26
|
+
import { createConversationMarkdownZip } from './lib/conversation-zip-export';
|
|
18
27
|
|
|
19
28
|
export type {
|
|
20
29
|
ConversationDataLocations,
|
|
@@ -28,6 +37,12 @@ export type {
|
|
|
28
37
|
ConversationPathMatch,
|
|
29
38
|
ConversationSource,
|
|
30
39
|
ConversationSourceInfo,
|
|
40
|
+
ConversationZipDownload,
|
|
41
|
+
DeleteConversationOptions,
|
|
42
|
+
DeleteConversationResult,
|
|
43
|
+
DeleteConversationsOptions,
|
|
44
|
+
DeleteConversationsResult,
|
|
45
|
+
ExportConversationsZipOptions,
|
|
31
46
|
GetConversationOptions,
|
|
32
47
|
ListConversationsForPathOptions,
|
|
33
48
|
ResolvedConversationRef,
|
|
@@ -70,7 +85,10 @@ export type CreateConversationClientOptions = HttpConversationClientOptions | Lo
|
|
|
70
85
|
export type ExportConversationMarkdownOptions = GetConversationOptions;
|
|
71
86
|
|
|
72
87
|
export type ConversationClient = {
|
|
88
|
+
deleteConversation: (options: DeleteConversationOptions) => Promise<DeleteConversationResult | null>;
|
|
89
|
+
deleteConversations: (options: DeleteConversationsOptions) => Promise<DeleteConversationsResult | null>;
|
|
73
90
|
exportConversationMarkdown: (options: ExportConversationMarkdownOptions) => Promise<string | null>;
|
|
91
|
+
exportConversationsZip: (options: ExportConversationsZipOptions) => Promise<ConversationZipDownload | null>;
|
|
74
92
|
getConversation: (options: GetConversationOptions) => Promise<ConversationDetail | null>;
|
|
75
93
|
listConversations: (options: ListConversationsForPathOptions) => Promise<ConversationPage>;
|
|
76
94
|
listSources: () => Promise<ConversationSourceInfo[]>;
|
|
@@ -198,6 +216,34 @@ const fetchTextOrNull = async (url: URL, init?: RequestInit): Promise<string | n
|
|
|
198
216
|
return response.text();
|
|
199
217
|
};
|
|
200
218
|
|
|
219
|
+
const fileNameFromContentDisposition = (contentDisposition: string | null, fallback: string) => {
|
|
220
|
+
const encodedMatch = contentDisposition?.match(/filename\*=UTF-8''([^;]+)/iu);
|
|
221
|
+
if (encodedMatch?.[1]) {
|
|
222
|
+
try {
|
|
223
|
+
return decodeURIComponent(encodedMatch[1]);
|
|
224
|
+
} catch {
|
|
225
|
+
return fallback;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const quotedMatch = contentDisposition?.match(/filename="([^"]+)"/iu);
|
|
230
|
+
return quotedMatch?.[1] || fallback;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const fetchZipOrNull = async (url: URL, init?: RequestInit): Promise<ConversationZipDownload | null> => {
|
|
234
|
+
const response = await fetchResponse(url, init);
|
|
235
|
+
if (response.status === 404) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await assertOkResponse(response);
|
|
240
|
+
return {
|
|
241
|
+
blob: await response.blob(),
|
|
242
|
+
fileName: fileNameFromContentDisposition(response.headers.get('Content-Disposition'), 'conversations.zip'),
|
|
243
|
+
mimeType: 'application/zip',
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
|
|
201
247
|
const requireData = <T>(envelope: HttpEnvelope<T>, label: string): T => {
|
|
202
248
|
if (envelope.data === undefined) {
|
|
203
249
|
throw new SpirachaClientError(`Spiracha API response did not include ${label}.`);
|
|
@@ -229,11 +275,62 @@ const rejectHttpLocations = (locations: ConversationDataLocations | undefined):
|
|
|
229
275
|
}
|
|
230
276
|
};
|
|
231
277
|
|
|
278
|
+
const buildBatchBody = ({ ids, messageSelector, outputFormat, source }: ExportConversationsZipOptions) => ({
|
|
279
|
+
ids,
|
|
280
|
+
message_selector: messageSelector,
|
|
281
|
+
output_format: outputFormat,
|
|
282
|
+
source,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const exportLocalConversationsZip = async (
|
|
286
|
+
exportOptions: ExportConversationsZipOptions,
|
|
287
|
+
locations: ConversationDataLocations | undefined,
|
|
288
|
+
) => {
|
|
289
|
+
const options = withDefaultLocations(exportOptions, locations);
|
|
290
|
+
const conversations = await Promise.all(
|
|
291
|
+
options.ids.map((id) =>
|
|
292
|
+
getLocalConversation({
|
|
293
|
+
id,
|
|
294
|
+
locations: options.locations,
|
|
295
|
+
messageSelector: options.messageSelector ?? 'all',
|
|
296
|
+
source: options.source,
|
|
297
|
+
}),
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
if (conversations.some((conversation) => conversation === null)) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return createConversationMarkdownZip({
|
|
306
|
+
entries: conversations.map((conversation, index) => {
|
|
307
|
+
const resolvedConversation = conversation!;
|
|
308
|
+
return {
|
|
309
|
+
fallbackBaseName: `${options.source}-${options.ids[index]}`,
|
|
310
|
+
markdown: renderLocalConversationMarkdown(resolvedConversation, {
|
|
311
|
+
messageSelector: options.messageSelector ?? 'all',
|
|
312
|
+
}),
|
|
313
|
+
title: resolvedConversation.title,
|
|
314
|
+
};
|
|
315
|
+
}),
|
|
316
|
+
fileBaseName: `${options.source}-conversations-${options.ids.length}`,
|
|
317
|
+
});
|
|
318
|
+
};
|
|
319
|
+
|
|
232
320
|
const makeLocalClient = (options: LocalConversationClientOptions): ConversationClient => ({
|
|
321
|
+
deleteConversation: (deleteOptions) =>
|
|
322
|
+
deleteLocalConversation(withDefaultLocations(deleteOptions, options.locations)),
|
|
323
|
+
deleteConversations: (deleteOptions) =>
|
|
324
|
+
deleteLocalConversations(withDefaultLocations(deleteOptions, options.locations)),
|
|
233
325
|
exportConversationMarkdown: async (getOptions) => {
|
|
234
326
|
const conversation = await getLocalConversation(withDefaultLocations(getOptions, options.locations));
|
|
235
|
-
return conversation
|
|
327
|
+
return conversation
|
|
328
|
+
? renderLocalConversationMarkdown(conversation, {
|
|
329
|
+
messageSelector: getOptions.messageSelector,
|
|
330
|
+
})
|
|
331
|
+
: null;
|
|
236
332
|
},
|
|
333
|
+
exportConversationsZip: (exportOptions) => exportLocalConversationsZip(exportOptions, options.locations),
|
|
237
334
|
getConversation: (getOptions) => getLocalConversation(withDefaultLocations(getOptions, options.locations)),
|
|
238
335
|
listConversations: (listOptions) =>
|
|
239
336
|
listLocalConversationsForPath(withDefaultLocations(listOptions, options.locations)),
|
|
@@ -245,6 +342,34 @@ const makeHttpClient = (options: HttpConversationClientOptions): ConversationCli
|
|
|
245
342
|
const baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
246
343
|
|
|
247
344
|
return {
|
|
345
|
+
deleteConversation: async (deleteOptions) => {
|
|
346
|
+
rejectHttpLocations(deleteOptions.locations);
|
|
347
|
+
const { id, source } = deleteOptions;
|
|
348
|
+
const url = makeHttpUrl(baseUrl, `/api/v1/conversations/${source}/${encodeURIComponent(id)}`);
|
|
349
|
+
const envelope = await fetchJsonOrNull<DeleteConversationResult>(url, { method: 'DELETE' });
|
|
350
|
+
if (!envelope) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
return requireData(envelope, 'a delete result');
|
|
354
|
+
},
|
|
355
|
+
deleteConversations: async (deleteOptions) => {
|
|
356
|
+
rejectHttpLocations(deleteOptions.locations);
|
|
357
|
+
const envelope = await fetchJsonOrNull<DeleteConversationsResult>(
|
|
358
|
+
makeHttpUrl(baseUrl, '/api/v1/conversations/delete'),
|
|
359
|
+
{
|
|
360
|
+
body: JSON.stringify({
|
|
361
|
+
ids: deleteOptions.ids,
|
|
362
|
+
source: deleteOptions.source,
|
|
363
|
+
}),
|
|
364
|
+
headers: { 'Content-Type': 'application/json' },
|
|
365
|
+
method: 'POST',
|
|
366
|
+
},
|
|
367
|
+
);
|
|
368
|
+
if (!envelope) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
return requireData(envelope, 'a delete result');
|
|
372
|
+
},
|
|
248
373
|
exportConversationMarkdown: async (getOptions) => {
|
|
249
374
|
rejectHttpLocations(getOptions.locations);
|
|
250
375
|
const { id, messageSelector, source } = getOptions;
|
|
@@ -252,6 +377,14 @@ const makeHttpClient = (options: HttpConversationClientOptions): ConversationCli
|
|
|
252
377
|
appendMessageSelector(url, messageSelector);
|
|
253
378
|
return fetchTextOrNull(url);
|
|
254
379
|
},
|
|
380
|
+
exportConversationsZip: async (exportOptions) => {
|
|
381
|
+
rejectHttpLocations(exportOptions.locations);
|
|
382
|
+
return fetchZipOrNull(makeHttpUrl(baseUrl, '/api/v1/conversations/export'), {
|
|
383
|
+
body: JSON.stringify(buildBatchBody(exportOptions)),
|
|
384
|
+
headers: { 'Content-Type': 'application/json' },
|
|
385
|
+
method: 'POST',
|
|
386
|
+
});
|
|
387
|
+
},
|
|
255
388
|
getConversation: async (getOptions) => {
|
|
256
389
|
rejectHttpLocations(getOptions.locations);
|
|
257
390
|
const { id, messageSelector, source } = getOptions;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdir, stat } from 'node:fs/promises';
|
|
1
|
+
import { readdir, rm, stat } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import {
|
|
4
4
|
type AntigravityArtifact,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
resolveAntigravityRoots,
|
|
12
12
|
} from './antigravity-exporter-types';
|
|
13
13
|
import { decryptAntigravitySafeStoragePayload } from './antigravity-keychain';
|
|
14
|
+
import { resolveAntigravityProjectNames } from './antigravity-projects';
|
|
14
15
|
import { mapWithConcurrency } from './concurrency';
|
|
15
16
|
|
|
16
17
|
type ProtoField = {
|
|
@@ -25,6 +26,7 @@ type SummaryEntry = {
|
|
|
25
26
|
createdAtMs: number | null;
|
|
26
27
|
indexedItemCount: number | null;
|
|
27
28
|
lastUpdatedAtMs: number | null;
|
|
29
|
+
projectId: string | null;
|
|
28
30
|
summaryPath: string;
|
|
29
31
|
title: string;
|
|
30
32
|
workspaceFolder: string | null;
|
|
@@ -33,6 +35,11 @@ type SummaryEntry = {
|
|
|
33
35
|
workspaceUri: string | null;
|
|
34
36
|
};
|
|
35
37
|
|
|
38
|
+
export type DeleteAntigravityConversationResult = {
|
|
39
|
+
deletedConversationIds: string[];
|
|
40
|
+
deletedPaths: string[];
|
|
41
|
+
};
|
|
42
|
+
|
|
36
43
|
type ConversationFile = {
|
|
37
44
|
bytes: number;
|
|
38
45
|
mtimeMs: number;
|
|
@@ -48,7 +55,6 @@ const isSafeConversationId = (value: string) => SAFE_CONVERSATION_ID_PATTERN.tes
|
|
|
48
55
|
type TranscriptFile = {
|
|
49
56
|
bytes: number;
|
|
50
57
|
entryCount: number;
|
|
51
|
-
fullPath: string | null;
|
|
52
58
|
mtimeMs: number;
|
|
53
59
|
model: string | null;
|
|
54
60
|
path: string;
|
|
@@ -271,8 +277,8 @@ const parseContextWorkspaceInfo = (field: ProtoField | null): WorkspaceInfo | nu
|
|
|
271
277
|
// Antigravity summary parsing is reverse-engineered from agyhub_summaries_proto.pb:
|
|
272
278
|
// entry field 1 = conversation id, entry field 2 = summary message. Inside that summary,
|
|
273
279
|
// field 1 = title, 2 = indexed item count, 3 = last-updated timestamp, 7 = created timestamp,
|
|
274
|
-
// 9 = workspace info, and 17 = context
|
|
275
|
-
//
|
|
280
|
+
// 9 = workspace info, and 17 = context metadata. Context field 18 is the Antigravity
|
|
281
|
+
// project id; workspace parsing uses context field 7 or nested workspace field 1.
|
|
276
282
|
const parseSummaryEntry = (entryField: ProtoField, summaryPath: string): SummaryEntry | null => {
|
|
277
283
|
try {
|
|
278
284
|
const entryFields = nestedFields(entryField);
|
|
@@ -283,6 +289,7 @@ const parseSummaryEntry = (entryField: ProtoField, summaryPath: string): Summary
|
|
|
283
289
|
}
|
|
284
290
|
|
|
285
291
|
const summaryFields = nestedFields(summaryBytes);
|
|
292
|
+
const contextFields = nestedFields(firstField(summaryFields, 17));
|
|
286
293
|
const workspace =
|
|
287
294
|
parseWorkspaceInfo(firstField(summaryFields, 9)) ??
|
|
288
295
|
parseContextWorkspaceInfo(firstField(summaryFields, 17)) ??
|
|
@@ -294,6 +301,7 @@ const parseSummaryEntry = (entryField: ProtoField, summaryPath: string): Summary
|
|
|
294
301
|
createdAtMs: parseTimestampMs(firstField(summaryFields, 7)),
|
|
295
302
|
indexedItemCount: fieldNumberValue(summaryFields, 2),
|
|
296
303
|
lastUpdatedAtMs: parseTimestampMs(firstField(summaryFields, 3)),
|
|
304
|
+
projectId: fieldString(contextFields, 18),
|
|
297
305
|
summaryPath,
|
|
298
306
|
title: cleanTitle(fieldString(summaryFields, 1), conversationId),
|
|
299
307
|
};
|
|
@@ -318,6 +326,84 @@ export const readAntigravitySummaryIndex = async (summaryPath: string): Promise<
|
|
|
318
326
|
}
|
|
319
327
|
};
|
|
320
328
|
|
|
329
|
+
const getFieldBounds = (
|
|
330
|
+
buffer: Uint8Array,
|
|
331
|
+
start: number,
|
|
332
|
+
): {
|
|
333
|
+
end: number;
|
|
334
|
+
fieldNumber: number;
|
|
335
|
+
payloadEnd: number;
|
|
336
|
+
payloadStart: number;
|
|
337
|
+
wireType: number;
|
|
338
|
+
} => {
|
|
339
|
+
const key = readVarint(buffer, start, buffer.length);
|
|
340
|
+
const fieldNumber = key.value >> 3;
|
|
341
|
+
const wireType = key.value & 7;
|
|
342
|
+
let index = key.next;
|
|
343
|
+
|
|
344
|
+
if (wireType === 0) {
|
|
345
|
+
const value = readVarint(buffer, index, buffer.length);
|
|
346
|
+
return { end: value.next, fieldNumber, payloadEnd: value.next, payloadStart: index, wireType };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (wireType === 1) {
|
|
350
|
+
return { end: index + 8, fieldNumber, payloadEnd: index + 8, payloadStart: index, wireType };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (wireType === 2) {
|
|
354
|
+
const length = readVarint(buffer, index, buffer.length);
|
|
355
|
+
index = length.next;
|
|
356
|
+
return {
|
|
357
|
+
end: index + length.value,
|
|
358
|
+
fieldNumber,
|
|
359
|
+
payloadEnd: index + length.value,
|
|
360
|
+
payloadStart: index,
|
|
361
|
+
wireType,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (wireType === 5) {
|
|
366
|
+
return { end: index + 4, fieldNumber, payloadEnd: index + 4, payloadStart: index, wireType };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
throw new Error(`Unsupported protobuf wire type: ${wireType}`);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const removeConversationFromSummaryIndex = async (summaryPath: string, conversationId: string): Promise<boolean> => {
|
|
373
|
+
if (!(await pathExists(summaryPath))) {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const buffer = new Uint8Array(await Bun.file(summaryPath).arrayBuffer());
|
|
378
|
+
const retained: Uint8Array[] = [];
|
|
379
|
+
let removed = false;
|
|
380
|
+
let index = 0;
|
|
381
|
+
|
|
382
|
+
while (index < buffer.length) {
|
|
383
|
+
const bounds = getFieldBounds(buffer, index);
|
|
384
|
+
const fieldBytes = buffer.slice(index, bounds.end);
|
|
385
|
+
const shouldRemove =
|
|
386
|
+
bounds.fieldNumber === 1 &&
|
|
387
|
+
bounds.wireType === 2 &&
|
|
388
|
+
fieldString(parseProtoFields(buffer, bounds.payloadStart, bounds.payloadEnd), 1) === conversationId;
|
|
389
|
+
|
|
390
|
+
if (shouldRemove) {
|
|
391
|
+
removed = true;
|
|
392
|
+
} else {
|
|
393
|
+
retained.push(fieldBytes);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
index = bounds.end;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!removed) {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
await Bun.write(summaryPath, Buffer.concat(retained));
|
|
404
|
+
return true;
|
|
405
|
+
};
|
|
406
|
+
|
|
321
407
|
const preferConversationFile = (
|
|
322
408
|
current: ConversationFile | undefined,
|
|
323
409
|
candidate: ConversationFile,
|
|
@@ -543,15 +629,22 @@ const preferTranscriptFile = (current: TranscriptFile | undefined, candidate: Tr
|
|
|
543
629
|
return candidate;
|
|
544
630
|
}
|
|
545
631
|
|
|
632
|
+
const sourceRank = (source: TranscriptFile['source']) => (source === 'transcript' ? 2 : 1);
|
|
633
|
+
const candidateRank = sourceRank(candidate.source);
|
|
634
|
+
const currentRank = sourceRank(current.source);
|
|
635
|
+
if (candidateRank !== currentRank) {
|
|
636
|
+
return candidateRank > currentRank ? candidate : current;
|
|
637
|
+
}
|
|
638
|
+
|
|
546
639
|
if (candidate.mtimeMs !== current.mtimeMs) {
|
|
547
640
|
return candidate.mtimeMs > current.mtimeMs ? candidate : current;
|
|
548
641
|
}
|
|
549
642
|
|
|
550
|
-
if (candidate.
|
|
551
|
-
return candidate.
|
|
643
|
+
if (candidate.entryCount !== current.entryCount) {
|
|
644
|
+
return candidate.entryCount > current.entryCount ? candidate : current;
|
|
552
645
|
}
|
|
553
646
|
|
|
554
|
-
return candidate.
|
|
647
|
+
return candidate.bytes > current.bytes ? candidate : current;
|
|
555
648
|
};
|
|
556
649
|
|
|
557
650
|
const readTranscriptFileCandidate = async (
|
|
@@ -566,14 +659,10 @@ const readTranscriptFileCandidate = async (
|
|
|
566
659
|
return null;
|
|
567
660
|
}
|
|
568
661
|
|
|
569
|
-
const fullPath = path.join(logsDir, 'transcript_full.jsonl');
|
|
570
|
-
const hasFullTranscript = await pathExists(fullPath);
|
|
571
|
-
const modelSourcePath = hasFullTranscript ? fullPath : transcriptPath;
|
|
572
662
|
return {
|
|
573
663
|
bytes: info.size,
|
|
574
|
-
entryCount: await countJsonlEntries(
|
|
575
|
-
|
|
576
|
-
model: await extractTranscriptModel(modelSourcePath),
|
|
664
|
+
entryCount: await countJsonlEntries(transcriptPath),
|
|
665
|
+
model: await extractTranscriptModel(transcriptPath),
|
|
577
666
|
mtimeMs: info.mtimeMs,
|
|
578
667
|
path: transcriptPath,
|
|
579
668
|
root,
|
|
@@ -601,8 +690,9 @@ const readTranscriptFilesForRoot = async (root: string): Promise<Map<string, Tra
|
|
|
601
690
|
|
|
602
691
|
const logsDir = path.join(brainDir, entry.name, '.system_generated', 'logs');
|
|
603
692
|
for (const candidate of [
|
|
604
|
-
{ name: '
|
|
693
|
+
{ name: 'transcript_full.jsonl', source: 'transcript' as const },
|
|
605
694
|
{ name: 'transcript.jsonl', source: 'transcript' as const },
|
|
695
|
+
{ name: 'overview.txt', source: 'overview' as const },
|
|
606
696
|
]) {
|
|
607
697
|
const transcript = await readTranscriptFileCandidate(root, logsDir, candidate);
|
|
608
698
|
if (transcript) {
|
|
@@ -689,6 +779,8 @@ const toConversation = (
|
|
|
689
779
|
): AntigravityConversation => {
|
|
690
780
|
const fallbackTitle = artifacts[0]?.summary ?? conversationId;
|
|
691
781
|
const artifactBytes = artifacts.reduce((total, artifact) => total + artifact.bytes, 0);
|
|
782
|
+
const conversationBytes = file?.bytes ?? 0;
|
|
783
|
+
const transcriptBytes = transcript?.bytes ?? 0;
|
|
692
784
|
const workspace = resolveConversationWorkspace(summary, file, transcript, artifacts);
|
|
693
785
|
const lastUpdatedAtMs = resolveConversationLastUpdatedAt(artifacts, file, summary, transcript);
|
|
694
786
|
const sourceRoot = resolveConversationSourceRoot(file, transcript, artifacts);
|
|
@@ -697,7 +789,7 @@ const toConversation = (
|
|
|
697
789
|
artifactBytes,
|
|
698
790
|
artifactCount: artifacts.length,
|
|
699
791
|
artifacts,
|
|
700
|
-
conversationBytes
|
|
792
|
+
conversationBytes,
|
|
701
793
|
conversationId,
|
|
702
794
|
conversationMtimeMs: file?.mtimeMs ?? null,
|
|
703
795
|
conversationPath: file?.path ?? null,
|
|
@@ -705,10 +797,12 @@ const toConversation = (
|
|
|
705
797
|
indexedItemCount: summary?.indexedItemCount ?? null,
|
|
706
798
|
lastUpdatedAtMs,
|
|
707
799
|
model: transcript?.model ?? null,
|
|
800
|
+
projectId: summary?.projectId ?? null,
|
|
708
801
|
sourceRoot,
|
|
709
802
|
summaryPath: summary?.summaryPath ?? null,
|
|
710
803
|
title: summary?.title ?? cleanTitle(fallbackTitle, conversationId),
|
|
711
|
-
|
|
804
|
+
totalBytes: conversationBytes + transcriptBytes + artifactBytes,
|
|
805
|
+
transcriptBytes,
|
|
712
806
|
transcriptEntryCount: transcript?.entryCount ?? 0,
|
|
713
807
|
transcriptPath: transcript?.path ?? null,
|
|
714
808
|
transcriptSource: resolveConversationTranscriptSource(file, transcript),
|
|
@@ -750,25 +844,30 @@ export const listAntigravityConversations = async (
|
|
|
750
844
|
|
|
751
845
|
export const groupAntigravityConversations = (
|
|
752
846
|
conversations: AntigravityConversation[],
|
|
847
|
+
projectNames: ReadonlyMap<string, string> = new Map(),
|
|
753
848
|
): AntigravityWorkspaceGroup[] => {
|
|
754
849
|
const groups = new Map<string, AntigravityWorkspaceGroup>();
|
|
755
850
|
for (const conversation of conversations) {
|
|
756
|
-
const
|
|
851
|
+
const projectName = conversation.projectId ? projectNames.get(conversation.projectId) : null;
|
|
852
|
+
const groupKey = conversation.projectId ? `project:${conversation.projectId}` : conversation.workspaceKey;
|
|
853
|
+
const current = groups.get(groupKey) ?? {
|
|
757
854
|
artifactCount: 0,
|
|
758
855
|
conversationBytes: 0,
|
|
759
856
|
conversationCount: 0,
|
|
760
|
-
key:
|
|
761
|
-
label: conversation.workspaceLabel,
|
|
857
|
+
key: groupKey,
|
|
858
|
+
label: projectName ?? conversation.workspaceLabel,
|
|
762
859
|
lastActiveMs: 0,
|
|
860
|
+
totalBytes: 0,
|
|
763
861
|
transcriptCount: 0,
|
|
764
|
-
uri: conversation.workspaceUri,
|
|
862
|
+
uri: conversation.projectId ? null : conversation.workspaceUri,
|
|
765
863
|
};
|
|
766
864
|
current.artifactCount += conversation.artifactCount;
|
|
767
865
|
current.conversationBytes += conversation.conversationBytes;
|
|
768
866
|
current.conversationCount += 1;
|
|
769
867
|
current.lastActiveMs = Math.max(current.lastActiveMs, conversation.lastUpdatedAtMs ?? 0);
|
|
770
868
|
current.transcriptCount += conversation.transcriptEntryCount > 0 ? 1 : 0;
|
|
771
|
-
|
|
869
|
+
current.totalBytes += conversation.totalBytes;
|
|
870
|
+
groups.set(groupKey, current);
|
|
772
871
|
}
|
|
773
872
|
|
|
774
873
|
return [...groups.values()].sort((a, b) => b.lastActiveMs - a.lastActiveMs || a.label.localeCompare(b.label));
|
|
@@ -777,16 +876,67 @@ export const groupAntigravityConversations = (
|
|
|
777
876
|
export const listAntigravityWorkspaceGroups = async (
|
|
778
877
|
roots = resolveAntigravityRoots(),
|
|
779
878
|
): Promise<AntigravityWorkspaceGroup[]> => {
|
|
780
|
-
|
|
879
|
+
const conversations = await listAntigravityConversations(roots);
|
|
880
|
+
const projectNames = await resolveAntigravityProjectNames(
|
|
881
|
+
conversations.flatMap((conversation) => (conversation.projectId ? [conversation.projectId] : [])),
|
|
882
|
+
);
|
|
883
|
+
return groupAntigravityConversations(conversations, projectNames);
|
|
781
884
|
};
|
|
782
885
|
|
|
783
886
|
export const listAntigravityConversationsForGroup = async (
|
|
784
887
|
workspaceKey: string,
|
|
785
888
|
roots = resolveAntigravityRoots(),
|
|
786
889
|
): Promise<AntigravityConversation[]> => {
|
|
787
|
-
return (await listAntigravityConversations(roots)).filter(
|
|
788
|
-
|
|
789
|
-
|
|
890
|
+
return (await listAntigravityConversations(roots)).filter((conversation) => {
|
|
891
|
+
const conversationGroupKey = conversation.projectId
|
|
892
|
+
? `project:${conversation.projectId}`
|
|
893
|
+
: conversation.workspaceKey;
|
|
894
|
+
return conversationGroupKey === workspaceKey;
|
|
895
|
+
});
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
const existingAntigravityDeletePaths = async (root: string, conversationId: string): Promise<string[]> => {
|
|
899
|
+
const conversationPath = path.join(getAntigravityConversationDir(root), `${conversationId}.pb`);
|
|
900
|
+
const artifactDir = path.join(getAntigravityBrainDir(root), conversationId);
|
|
901
|
+
const logsDir = path.join(artifactDir, '.system_generated', 'logs');
|
|
902
|
+
const candidates = [
|
|
903
|
+
conversationPath,
|
|
904
|
+
path.join(logsDir, 'overview.txt'),
|
|
905
|
+
path.join(logsDir, 'transcript.jsonl'),
|
|
906
|
+
path.join(logsDir, 'transcript_full.jsonl'),
|
|
907
|
+
artifactDir,
|
|
908
|
+
];
|
|
909
|
+
const exists = await Promise.all(candidates.map(pathExists));
|
|
910
|
+
return candidates.filter((_, index) => exists[index]);
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
export const deleteAntigravityConversation = async (
|
|
914
|
+
roots: string[],
|
|
915
|
+
conversationId: string,
|
|
916
|
+
): Promise<DeleteAntigravityConversationResult> => {
|
|
917
|
+
if (!isSafeConversationId(conversationId)) {
|
|
918
|
+
return { deletedConversationIds: [], deletedPaths: [] };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const deletedPaths: string[] = [];
|
|
922
|
+
let deletedSummary = false;
|
|
923
|
+
|
|
924
|
+
for (const root of roots) {
|
|
925
|
+
deletedSummary =
|
|
926
|
+
(await removeConversationFromSummaryIndex(getAntigravitySummaryIndexPath(root), conversationId)) ||
|
|
927
|
+
deletedSummary;
|
|
928
|
+
|
|
929
|
+
const rootPaths = await existingAntigravityDeletePaths(root, conversationId);
|
|
930
|
+
deletedPaths.push(...rootPaths);
|
|
931
|
+
|
|
932
|
+
await rm(path.join(getAntigravityConversationDir(root), `${conversationId}.pb`), { force: true });
|
|
933
|
+
await rm(path.join(getAntigravityBrainDir(root), conversationId), { force: true, recursive: true });
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
return {
|
|
937
|
+
deletedConversationIds: deletedSummary || deletedPaths.length > 0 ? [conversationId] : [],
|
|
938
|
+
deletedPaths: [...new Set(deletedPaths)],
|
|
939
|
+
};
|
|
790
940
|
};
|
|
791
941
|
|
|
792
942
|
export const renderAntigravityArtifactsMarkdown = async (
|
|
@@ -918,7 +1068,7 @@ const renderLogEntry = (entry: AntigravityLogEntry): string[] => {
|
|
|
918
1068
|
const heading = logEntryHeading(entry);
|
|
919
1069
|
const timestamp = getString(entry.created_at);
|
|
920
1070
|
const content = cleanLogContent(entry);
|
|
921
|
-
const thinking = getString(entry.thinking);
|
|
1071
|
+
const thinking = getString(entry.thinking) ?? '';
|
|
922
1072
|
const parts = [`## ${heading}`, ''];
|
|
923
1073
|
|
|
924
1074
|
if (timestamp) {
|
|
@@ -1080,6 +1230,7 @@ const renderAntigravityTranscriptMarkdown = async (conversation: AntigravityConv
|
|
|
1080
1230
|
conversation.transcriptSource === 'overview'
|
|
1081
1231
|
? 'antigravity_overview_transcript'
|
|
1082
1232
|
: 'antigravity_jsonl_transcript';
|
|
1233
|
+
const sections = entries.flatMap((entry) => renderLogEntry(entry));
|
|
1083
1234
|
const parts = [
|
|
1084
1235
|
`# ${conversation.title}`,
|
|
1085
1236
|
'',
|
|
@@ -1087,12 +1238,9 @@ const renderAntigravityTranscriptMarkdown = async (conversation: AntigravityConv
|
|
|
1087
1238
|
`- conversation_id: \`${conversation.conversationId}\``,
|
|
1088
1239
|
conversation.workspaceUri ? `- workspace: \`${conversation.workspaceUri}\`` : '',
|
|
1089
1240
|
'',
|
|
1241
|
+
...sections,
|
|
1090
1242
|
].filter(Boolean);
|
|
1091
1243
|
|
|
1092
|
-
for (const entry of entries) {
|
|
1093
|
-
parts.push(...renderLogEntry(entry));
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
1244
|
return `${parts.join('\n').trimEnd()}\n`;
|
|
1097
1245
|
};
|
|
1098
1246
|
|
|
@@ -1116,9 +1264,37 @@ const renderDecryptedSafeStorageMarkdown = (conversation: AntigravityConversatio
|
|
|
1116
1264
|
.join('\n');
|
|
1117
1265
|
};
|
|
1118
1266
|
|
|
1267
|
+
const renderAntigravitySummaryMarkdown = (conversation: AntigravityConversation): string | null => {
|
|
1268
|
+
if (conversation.artifactCount > 0) {
|
|
1269
|
+
return null;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
if (!conversation.summaryPath && conversation.indexedItemCount === null) {
|
|
1273
|
+
return null;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
return [
|
|
1277
|
+
`# ${conversation.title}`,
|
|
1278
|
+
'',
|
|
1279
|
+
'- exported_from: `antigravity_summary_index`',
|
|
1280
|
+
`- conversation_id: \`${conversation.conversationId}\``,
|
|
1281
|
+
conversation.workspaceUri ? `- workspace: \`${conversation.workspaceUri}\`` : '',
|
|
1282
|
+
conversation.indexedItemCount !== null ? `- indexed_items: \`${conversation.indexedItemCount}\`` : '',
|
|
1283
|
+
conversation.createdAtMs ? `- created_at: \`${new Date(conversation.createdAtMs).toISOString()}\`` : '',
|
|
1284
|
+
conversation.lastUpdatedAtMs ? `- updated_at: \`${new Date(conversation.lastUpdatedAtMs).toISOString()}\`` : '',
|
|
1285
|
+
'',
|
|
1286
|
+
'No local transcript log was found for this Antigravity conversation. The export contains the summary index metadata that Antigravity retained.',
|
|
1287
|
+
'',
|
|
1288
|
+
]
|
|
1289
|
+
.filter(Boolean)
|
|
1290
|
+
.join('\n');
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1119
1293
|
export const renderAntigravityConversationMarkdown = async (
|
|
1120
1294
|
conversation: AntigravityConversation,
|
|
1121
|
-
options: {
|
|
1295
|
+
options: {
|
|
1296
|
+
keychainSecret?: string | null;
|
|
1297
|
+
} = {},
|
|
1122
1298
|
): Promise<string | null> => {
|
|
1123
1299
|
const transcript = await renderAntigravityTranscriptMarkdown(conversation);
|
|
1124
1300
|
if (transcript) {
|
|
@@ -1133,5 +1309,5 @@ export const renderAntigravityConversationMarkdown = async (
|
|
|
1133
1309
|
}
|
|
1134
1310
|
}
|
|
1135
1311
|
|
|
1136
|
-
return
|
|
1312
|
+
return renderAntigravitySummaryMarkdown(conversation);
|
|
1137
1313
|
};
|