opencode-lcm 0.11.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/LICENSE +21 -0
  3. package/README.md +207 -0
  4. package/dist/archive-transform.d.ts +45 -0
  5. package/dist/archive-transform.js +81 -0
  6. package/dist/constants.d.ts +12 -0
  7. package/dist/constants.js +16 -0
  8. package/dist/doctor.d.ts +22 -0
  9. package/dist/doctor.js +44 -0
  10. package/dist/index.d.ts +4 -0
  11. package/dist/index.js +306 -0
  12. package/dist/logging.d.ts +14 -0
  13. package/dist/logging.js +28 -0
  14. package/dist/options.d.ts +3 -0
  15. package/dist/options.js +217 -0
  16. package/dist/preview-providers.d.ts +20 -0
  17. package/dist/preview-providers.js +246 -0
  18. package/dist/privacy.d.ts +16 -0
  19. package/dist/privacy.js +92 -0
  20. package/dist/search-ranking.d.ts +12 -0
  21. package/dist/search-ranking.js +98 -0
  22. package/dist/sql-utils.d.ts +31 -0
  23. package/dist/sql-utils.js +80 -0
  24. package/dist/store-artifacts.d.ts +50 -0
  25. package/dist/store-artifacts.js +374 -0
  26. package/dist/store-retention.d.ts +39 -0
  27. package/dist/store-retention.js +90 -0
  28. package/dist/store-search.d.ts +37 -0
  29. package/dist/store-search.js +298 -0
  30. package/dist/store-snapshot.d.ts +133 -0
  31. package/dist/store-snapshot.js +325 -0
  32. package/dist/store-types.d.ts +14 -0
  33. package/dist/store-types.js +5 -0
  34. package/dist/store.d.ts +316 -0
  35. package/dist/store.js +3673 -0
  36. package/dist/types.d.ts +117 -0
  37. package/dist/types.js +1 -0
  38. package/dist/utils.d.ts +35 -0
  39. package/dist/utils.js +414 -0
  40. package/dist/workspace-path.d.ts +1 -0
  41. package/dist/workspace-path.js +15 -0
  42. package/dist/worktree-key.d.ts +1 -0
  43. package/dist/worktree-key.js +6 -0
  44. package/package.json +61 -0
  45. package/src/archive-transform.ts +147 -0
  46. package/src/bun-sqlite.d.ts +18 -0
  47. package/src/constants.ts +20 -0
  48. package/src/doctor.ts +83 -0
  49. package/src/index.ts +330 -0
  50. package/src/logging.ts +41 -0
  51. package/src/options.ts +297 -0
  52. package/src/preview-providers.ts +298 -0
  53. package/src/privacy.ts +122 -0
  54. package/src/search-ranking.ts +145 -0
  55. package/src/sql-utils.ts +107 -0
  56. package/src/store-artifacts.ts +666 -0
  57. package/src/store-retention.ts +152 -0
  58. package/src/store-search.ts +440 -0
  59. package/src/store-snapshot.ts +582 -0
  60. package/src/store-types.ts +16 -0
  61. package/src/store.ts +4926 -0
  62. package/src/types.ts +132 -0
  63. package/src/utils.ts +444 -0
  64. package/src/workspace-path.ts +20 -0
  65. package/src/worktree-key.ts +5 -0
@@ -0,0 +1,666 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import type { Message, Part } from '@opencode-ai/sdk';
4
+ import { runBinaryPreviewProviders } from './preview-providers.js';
5
+ import {
6
+ type CompiledPrivacyOptions,
7
+ isExcludedTool,
8
+ matchesExcludedPath,
9
+ PRIVACY_EXCLUDED_FILE_CONTENT,
10
+ PRIVACY_EXCLUDED_FILE_REFERENCE,
11
+ PRIVACY_EXCLUDED_TOOL_OUTPUT,
12
+ PRIVACY_REDACTED_PATH_TEXT,
13
+ redactStructuredValue,
14
+ redactText,
15
+ } from './privacy.js';
16
+ import type { ArtifactBlobRow, ArtifactRow } from './store-snapshot.js';
17
+ import type { SqlDatabaseLike } from './store-types.js';
18
+ import type { ConversationMessage, NormalizedSession } from './types.js';
19
+ import {
20
+ classifyFileCategory,
21
+ formatMetadataValue,
22
+ hashContent,
23
+ inferFileExtension,
24
+ inferUrlScheme,
25
+ parseJson,
26
+ sanitizeAutomaticRetrievalSourceText,
27
+ truncate,
28
+ } from './utils.js';
29
+
30
+ export type ArtifactData = {
31
+ artifactID: string;
32
+ sessionID: string;
33
+ messageID: string;
34
+ partID: string;
35
+ artifactKind: string;
36
+ fieldName: string;
37
+ previewText: string;
38
+ contentText: string;
39
+ contentHash: string;
40
+ charCount: number;
41
+ createdAt: number;
42
+ metadata: Record<string, unknown>;
43
+ };
44
+
45
+ export type ExternalizedMessage = {
46
+ storedMessage: ConversationMessage;
47
+ artifacts: ArtifactData[];
48
+ };
49
+
50
+ export type ExternalizedSession = {
51
+ storedSession: NormalizedSession;
52
+ artifacts: ArtifactData[];
53
+ };
54
+
55
+ export type StoreArtifactBindings = {
56
+ workspaceDirectory: string;
57
+ options: {
58
+ artifactPreviewChars: number;
59
+ binaryPreviewProviders: string[];
60
+ largeContentThreshold: number;
61
+ previewBytePeek: number;
62
+ privacy: CompiledPrivacyOptions;
63
+ };
64
+ getDb(): SqlDatabaseLike;
65
+ readArtifactBlobSync(contentHash?: string | null): ArtifactBlobRow | undefined;
66
+ upsertSessionRowSync(session: NormalizedSession): void;
67
+ upsertMessageInfoSync(sessionID: string, message: ConversationMessage): void;
68
+ deleteMessageSync(sessionID: string, messageID: string): void;
69
+ replaceMessageSearchRowSync(sessionID: string, message: ConversationMessage): void;
70
+ replaceMessageSearchRowsSync(session: NormalizedSession): void;
71
+ };
72
+
73
+ function artifactPlaceholder(
74
+ artifactID: string,
75
+ label: string,
76
+ preview: string,
77
+ charCount: number,
78
+ ): string {
79
+ const body = preview ? ` Preview: ${preview}` : '';
80
+ return `[Externalized ${label} as ${artifactID} (${charCount} chars). Use lcm_artifact for full content.]${body}`;
81
+ }
82
+
83
+ function fileCategoryHint(category: string): string {
84
+ switch (category) {
85
+ case 'image':
86
+ return 'Visual asset or screenshot; exact pixels still require the source file.';
87
+ case 'pdf':
88
+ return 'Formatted document; exact layout and embedded pages still require the source file.';
89
+ case 'audio':
90
+ return 'Audio asset; waveform and transcription details still require the source file.';
91
+ case 'video':
92
+ return 'Video asset; frames and timing still require the source file.';
93
+ case 'archive':
94
+ return 'Bundled archive; internal file listing still requires unpacking the source file.';
95
+ case 'spreadsheet':
96
+ return 'Spreadsheet-like document; formulas and cell layout may require the source file.';
97
+ case 'presentation':
98
+ return 'Slide deck; visual layout and speaker notes may require the source file.';
99
+ case 'document':
100
+ return 'Rich document; styled content and embedded assets may require the source file.';
101
+ case 'code':
102
+ return 'Code or source-like file reference; load the file body if exact lines matter.';
103
+ case 'structured-data':
104
+ return 'Structured data file reference; exact records may require the full source body.';
105
+ default:
106
+ return 'Binary or opaque artifact reference; inspect the original file for exact contents.';
107
+ }
108
+ }
109
+
110
+ function createArtifactData(
111
+ bindings: StoreArtifactBindings,
112
+ input: {
113
+ sessionID: string;
114
+ messageID: string;
115
+ partID: string;
116
+ artifactKind: string;
117
+ fieldName: string;
118
+ contentText: string;
119
+ createdAt: number;
120
+ metadata?: Record<string, unknown>;
121
+ previewText?: string;
122
+ },
123
+ ): ArtifactData {
124
+ const contentText = redactText(input.contentText, bindings.options.privacy);
125
+ const metadata = redactStructuredValue(input.metadata ?? {}, bindings.options.privacy);
126
+ const previewText = redactText(
127
+ input.previewText ??
128
+ truncate(contentText.replace(/\s+/g, ' ').trim(), bindings.options.artifactPreviewChars),
129
+ bindings.options.privacy,
130
+ );
131
+ const contentHash = hashContent(contentText);
132
+ return {
133
+ artifactID: `art_${randomUUID().replace(/-/g, '').slice(0, 12)}`,
134
+ sessionID: input.sessionID,
135
+ messageID: input.messageID,
136
+ partID: input.partID,
137
+ artifactKind: input.artifactKind,
138
+ fieldName: input.fieldName,
139
+ previewText,
140
+ contentText,
141
+ contentHash,
142
+ charCount: contentText.length,
143
+ createdAt: input.createdAt,
144
+ metadata,
145
+ };
146
+ }
147
+
148
+ function filePrivacyCandidates(file: Extract<Part, { type: 'file' }>): Array<string | undefined> {
149
+ const sourcePath = file.source && 'path' in file.source ? file.source.path : undefined;
150
+ return [file.filename, file.url, sourcePath];
151
+ }
152
+
153
+ function excludeStoredFilePart(file: Extract<Part, { type: 'file' }>): void {
154
+ file.filename = PRIVACY_EXCLUDED_FILE_REFERENCE;
155
+ file.url = 'lcm://privacy-excluded';
156
+ if (file.source && 'path' in file.source) file.source.path = PRIVACY_REDACTED_PATH_TEXT;
157
+ if (file.source?.text?.value) {
158
+ file.source.text.value = PRIVACY_EXCLUDED_FILE_CONTENT;
159
+ file.source.text.start = 0;
160
+ file.source.text.end = file.source.text.value.length;
161
+ }
162
+ }
163
+
164
+ export function formatArtifactMetadataLines(metadata: Record<string, unknown>): string[] {
165
+ const lines = Object.entries(metadata)
166
+ .map(([key, value]) => {
167
+ const formatted = formatMetadataValue(value);
168
+ return formatted ? `${key}: ${formatted}` : undefined;
169
+ })
170
+ .filter((line): line is string => Boolean(line));
171
+
172
+ return lines.length > 0 ? ['Metadata:', ...lines] : [];
173
+ }
174
+
175
+ export function buildArtifactSearchContent(artifact: ArtifactData): string {
176
+ const metadata = Object.entries(artifact.metadata)
177
+ .map(([key, value]) => {
178
+ const formatted = formatMetadataValue(value);
179
+ return formatted ? `${key}: ${formatted}` : undefined;
180
+ })
181
+ .filter((line): line is string => Boolean(line))
182
+ .join('\n');
183
+
184
+ return [artifact.previewText, metadata, artifact.contentText].filter(Boolean).join('\n');
185
+ }
186
+
187
+ function buildFileArtifactMetadata(
188
+ file: Extract<Part, { type: 'file' }>,
189
+ extras: Record<string, unknown> = {},
190
+ ): Record<string, unknown> {
191
+ const sourcePath = file.source && 'path' in file.source ? file.source.path : undefined;
192
+ const extension = inferFileExtension(file.filename ?? sourcePath ?? file.url);
193
+ const category = classifyFileCategory(file.mime, extension);
194
+ return {
195
+ category,
196
+ extension,
197
+ mime: file.mime,
198
+ filename: file.filename,
199
+ url: file.url,
200
+ urlScheme: inferUrlScheme(file.url),
201
+ sourceType: file.source?.type,
202
+ sourcePath,
203
+ hint: fileCategoryHint(category),
204
+ ...extras,
205
+ };
206
+ }
207
+
208
+ async function buildBinaryPreviewArtifact(
209
+ bindings: StoreArtifactBindings,
210
+ file: Extract<Part, { type: 'file' }>,
211
+ fieldName: string,
212
+ label: string,
213
+ createdAt: number,
214
+ extras: Record<string, unknown> = {},
215
+ ): Promise<ArtifactData> {
216
+ const baseMetadata = buildFileArtifactMetadata(file, extras);
217
+ const category = typeof baseMetadata.category === 'string' ? baseMetadata.category : 'binary';
218
+ const extension = typeof baseMetadata.extension === 'string' ? baseMetadata.extension : undefined;
219
+ const name =
220
+ file.filename ??
221
+ (typeof baseMetadata.sourcePath === 'string' ? baseMetadata.sourcePath : undefined) ??
222
+ file.url ??
223
+ 'unknown file';
224
+ const previewDetails = await runBinaryPreviewProviders({
225
+ workspaceDirectory: bindings.workspaceDirectory,
226
+ file,
227
+ category,
228
+ extension,
229
+ mime: file.mime,
230
+ enabledProviders: bindings.options.binaryPreviewProviders,
231
+ bytePeek: bindings.options.previewBytePeek,
232
+ });
233
+ const summary = previewDetails.summaryBits.slice(0, 3).join(', ');
234
+ const contentText = [
235
+ `${label}`,
236
+ `Category: ${category}`,
237
+ `Name: ${name}`,
238
+ ...(typeof baseMetadata.sourcePath === 'string' ? [`Path: ${baseMetadata.sourcePath}`] : []),
239
+ ...(file.mime ? [`MIME: ${file.mime}`] : []),
240
+ ...(extension ? [`Extension: ${extension}`] : []),
241
+ ...(typeof baseMetadata.urlScheme === 'string'
242
+ ? [`URL scheme: ${baseMetadata.urlScheme}`]
243
+ : []),
244
+ ...(file.url ? [`URL: ${file.url}`] : []),
245
+ ...(typeof baseMetadata.hint === 'string' ? [`Hint: ${baseMetadata.hint}`] : []),
246
+ ...previewDetails.lines,
247
+ ].join('\n');
248
+ const previewText = truncate(
249
+ `${label}: ${name} (${category}${summary ? `, ${summary}` : ''})`,
250
+ bindings.options.artifactPreviewChars,
251
+ );
252
+
253
+ return createArtifactData(bindings, {
254
+ sessionID: file.sessionID,
255
+ messageID: file.messageID,
256
+ partID: file.id,
257
+ artifactKind: 'file',
258
+ fieldName,
259
+ contentText,
260
+ createdAt,
261
+ metadata: { ...baseMetadata, ...previewDetails.metadata },
262
+ previewText,
263
+ });
264
+ }
265
+
266
+ async function externalizePart(
267
+ bindings: StoreArtifactBindings,
268
+ part: Part,
269
+ createdAt: number,
270
+ ): Promise<{
271
+ storedPart: Part;
272
+ artifacts: ArtifactData[];
273
+ }> {
274
+ const storedPart = parseJson<Part>(JSON.stringify(part));
275
+ const artifacts: ArtifactData[] = [];
276
+ const privacy = bindings.options.privacy;
277
+
278
+ const externalize = (
279
+ artifactKind: string,
280
+ fieldName: string,
281
+ value: string,
282
+ metadata: Record<string, unknown> = {},
283
+ previewText?: string,
284
+ sanitize = false,
285
+ ): string => {
286
+ const contentText = sanitize ? sanitizeAutomaticRetrievalSourceText(value) : value;
287
+ if (contentText.length < bindings.options.largeContentThreshold) return contentText;
288
+
289
+ const artifact = createArtifactData(bindings, {
290
+ sessionID: storedPart.sessionID,
291
+ messageID: storedPart.messageID,
292
+ partID: storedPart.id,
293
+ artifactKind,
294
+ fieldName,
295
+ contentText,
296
+ createdAt,
297
+ metadata,
298
+ previewText,
299
+ });
300
+ artifacts.push(artifact);
301
+ return artifactPlaceholder(
302
+ artifact.artifactID,
303
+ `${artifactKind}/${fieldName}`,
304
+ artifact.previewText,
305
+ artifact.charCount,
306
+ );
307
+ };
308
+
309
+ switch (storedPart.type) {
310
+ case 'text':
311
+ storedPart.text = externalize('message', 'text', storedPart.text, {}, undefined, true);
312
+ if (artifacts.length > 0) {
313
+ storedPart.metadata = {
314
+ ...(storedPart.metadata ?? {}),
315
+ opencodeLcmArtifact: artifacts.map((artifact) => artifact.artifactID),
316
+ };
317
+ }
318
+ break;
319
+ case 'reasoning':
320
+ storedPart.text = externalize('reasoning', 'text', storedPart.text, {}, undefined, true);
321
+ if (artifacts.length > 0) {
322
+ storedPart.metadata = {
323
+ ...(storedPart.metadata ?? {}),
324
+ opencodeLcmArtifact: artifacts.map((artifact) => artifact.artifactID),
325
+ };
326
+ }
327
+ break;
328
+ case 'tool':
329
+ if (isExcludedTool(storedPart.tool, privacy)) {
330
+ storedPart.state.input = { excluded: true };
331
+ if ('metadata' in storedPart.state) storedPart.state.metadata = { excluded: true };
332
+ if (storedPart.state.status === 'completed') {
333
+ storedPart.state.output = PRIVACY_EXCLUDED_TOOL_OUTPUT;
334
+ storedPart.state.attachments = [];
335
+ }
336
+ if (storedPart.state.status === 'error') {
337
+ storedPart.state.error = PRIVACY_EXCLUDED_TOOL_OUTPUT;
338
+ }
339
+ break;
340
+ }
341
+ if (storedPart.state.status === 'completed') {
342
+ storedPart.state.output = externalize(
343
+ 'tool',
344
+ 'output',
345
+ storedPart.state.output,
346
+ {},
347
+ undefined,
348
+ true,
349
+ );
350
+ if (storedPart.state.attachments) {
351
+ const storedAttachments: Extract<Part, { type: 'file' }>[] = [];
352
+ for (const [index, attachment] of storedPart.state.attachments.entries()) {
353
+ if (matchesExcludedPath(filePrivacyCandidates(attachment), privacy)) {
354
+ excludeStoredFilePart(attachment);
355
+ storedAttachments.push(attachment);
356
+ continue;
357
+ }
358
+ const previewMetadata = {
359
+ attachmentIndex: index,
360
+ tool: storedPart.tool,
361
+ title: storedPart.state.status === 'completed' ? storedPart.state.title : undefined,
362
+ };
363
+ artifacts.push(
364
+ await buildBinaryPreviewArtifact(
365
+ bindings,
366
+ attachment,
367
+ `attachment:${index}`,
368
+ `Tool attachment for ${storedPart.tool}`,
369
+ createdAt,
370
+ previewMetadata,
371
+ ),
372
+ );
373
+
374
+ if (attachment.source?.text?.value) {
375
+ attachment.source.text.value = externalize(
376
+ 'file',
377
+ `attachment_text:${index}`,
378
+ attachment.source.text.value,
379
+ buildFileArtifactMetadata(attachment, previewMetadata),
380
+ );
381
+ attachment.source.text.start = 0;
382
+ attachment.source.text.end = attachment.source.text.value.length;
383
+ }
384
+ storedAttachments.push(attachment);
385
+ }
386
+ storedPart.state.attachments = storedAttachments;
387
+ }
388
+ }
389
+ if (storedPart.state.status === 'error') {
390
+ storedPart.state.error = externalize(
391
+ 'tool',
392
+ 'error',
393
+ storedPart.state.error,
394
+ {},
395
+ undefined,
396
+ true,
397
+ );
398
+ }
399
+ break;
400
+ case 'file':
401
+ if (matchesExcludedPath(filePrivacyCandidates(storedPart), privacy)) {
402
+ excludeStoredFilePart(storedPart);
403
+ break;
404
+ }
405
+ artifacts.push(
406
+ await buildBinaryPreviewArtifact(
407
+ bindings,
408
+ storedPart,
409
+ 'reference',
410
+ 'File reference',
411
+ createdAt,
412
+ ),
413
+ );
414
+ if (storedPart.source?.text?.value) {
415
+ storedPart.source.text.value = externalize(
416
+ 'file',
417
+ 'source',
418
+ storedPart.source.text.value,
419
+ buildFileArtifactMetadata(storedPart),
420
+ );
421
+ storedPart.source.text.start = 0;
422
+ storedPart.source.text.end = storedPart.source.text.value.length;
423
+ }
424
+ break;
425
+ case 'snapshot':
426
+ storedPart.snapshot = externalize(
427
+ 'snapshot',
428
+ 'snapshot',
429
+ storedPart.snapshot,
430
+ {},
431
+ undefined,
432
+ true,
433
+ );
434
+ break;
435
+ case 'agent':
436
+ if (storedPart.source?.value) {
437
+ storedPart.source.value = externalize(
438
+ 'agent',
439
+ 'source',
440
+ storedPart.source.value,
441
+ {},
442
+ undefined,
443
+ true,
444
+ );
445
+ storedPart.source.start = 0;
446
+ storedPart.source.end = storedPart.source.value.length;
447
+ }
448
+ break;
449
+ case 'subtask':
450
+ storedPart.prompt = externalize('subtask', 'prompt', storedPart.prompt, {}, undefined, true);
451
+ storedPart.description = externalize(
452
+ 'subtask',
453
+ 'description',
454
+ storedPart.description,
455
+ {},
456
+ undefined,
457
+ true,
458
+ );
459
+ break;
460
+ default:
461
+ break;
462
+ }
463
+
464
+ return {
465
+ storedPart: redactStructuredValue(storedPart, privacy),
466
+ artifacts,
467
+ };
468
+ }
469
+
470
+ export async function externalizeMessage(
471
+ bindings: StoreArtifactBindings,
472
+ message: ConversationMessage,
473
+ ): Promise<ExternalizedMessage> {
474
+ const artifacts: ArtifactData[] = [];
475
+ const storedInfo = parseJson<Message>(JSON.stringify(message.info));
476
+ const storedParts: Part[] = [];
477
+
478
+ for (const part of message.parts) {
479
+ const { storedPart, artifacts: nextArtifacts } = await externalizePart(
480
+ bindings,
481
+ part,
482
+ message.info.time.created,
483
+ );
484
+ artifacts.push(...nextArtifacts);
485
+ storedParts.push(storedPart);
486
+ }
487
+
488
+ return {
489
+ storedMessage: {
490
+ info: redactStructuredValue(storedInfo, bindings.options.privacy),
491
+ parts: storedParts,
492
+ },
493
+ artifacts,
494
+ };
495
+ }
496
+
497
+ export async function externalizeSession(
498
+ bindings: StoreArtifactBindings,
499
+ session: NormalizedSession,
500
+ ): Promise<ExternalizedSession> {
501
+ const artifacts: ArtifactData[] = [];
502
+ const storedMessages: ConversationMessage[] = [];
503
+
504
+ for (const message of session.messages) {
505
+ const storedInfo = parseJson<Message>(JSON.stringify(message.info));
506
+ const storedParts: Part[] = [];
507
+
508
+ for (const part of message.parts) {
509
+ const { storedPart, artifacts: nextArtifacts } = await externalizePart(
510
+ bindings,
511
+ part,
512
+ message.info.time.created,
513
+ );
514
+ artifacts.push(...nextArtifacts);
515
+ storedParts.push(storedPart);
516
+ }
517
+
518
+ storedMessages.push({
519
+ info: redactStructuredValue(storedInfo, bindings.options.privacy),
520
+ parts: storedParts,
521
+ });
522
+ }
523
+
524
+ return {
525
+ storedSession: redactStructuredValue(
526
+ {
527
+ ...session,
528
+ messages: storedMessages,
529
+ },
530
+ bindings.options.privacy,
531
+ ),
532
+ artifacts,
533
+ };
534
+ }
535
+
536
+ function insertArtifactsSync(bindings: StoreArtifactBindings, artifacts: ArtifactData[]): void {
537
+ if (artifacts.length === 0) return;
538
+
539
+ const db = bindings.getDb();
540
+ const insertBlob = db.prepare(
541
+ `INSERT OR IGNORE INTO artifact_blobs (content_hash, content_text, char_count, created_at)
542
+ VALUES (?, ?, ?, ?)`,
543
+ );
544
+ const insertArtifact = db.prepare(
545
+ `INSERT INTO artifacts
546
+ (artifact_id, session_id, message_id, part_id, artifact_kind, field_name, preview_text, content_text, content_hash, metadata_json, char_count, created_at)
547
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
548
+ );
549
+ const insertFts = db.prepare(
550
+ 'INSERT INTO artifact_fts (session_id, artifact_id, message_id, part_id, artifact_kind, created_at, content) VALUES (?, ?, ?, ?, ?, ?, ?)',
551
+ );
552
+
553
+ for (const artifact of artifacts) {
554
+ insertBlob.run(
555
+ artifact.contentHash,
556
+ artifact.contentText,
557
+ artifact.charCount,
558
+ artifact.createdAt,
559
+ );
560
+ insertArtifact.run(
561
+ artifact.artifactID,
562
+ artifact.sessionID,
563
+ artifact.messageID,
564
+ artifact.partID,
565
+ artifact.artifactKind,
566
+ artifact.fieldName,
567
+ artifact.previewText,
568
+ '',
569
+ artifact.contentHash,
570
+ JSON.stringify(artifact.metadata),
571
+ artifact.charCount,
572
+ artifact.createdAt,
573
+ );
574
+ insertFts.run(
575
+ artifact.sessionID,
576
+ artifact.artifactID,
577
+ artifact.messageID,
578
+ artifact.partID,
579
+ artifact.artifactKind,
580
+ String(artifact.createdAt),
581
+ buildArtifactSearchContent(artifact),
582
+ );
583
+ }
584
+ }
585
+
586
+ export function persistStoredSessionSync(
587
+ bindings: StoreArtifactBindings,
588
+ storedSession: NormalizedSession,
589
+ artifacts: ArtifactData[],
590
+ ): void {
591
+ const db = bindings.getDb();
592
+ bindings.upsertSessionRowSync(storedSession);
593
+
594
+ db.prepare('DELETE FROM artifact_fts WHERE session_id = ?').run(storedSession.sessionID);
595
+ db.prepare('DELETE FROM artifacts WHERE session_id = ?').run(storedSession.sessionID);
596
+ db.prepare('DELETE FROM parts WHERE session_id = ?').run(storedSession.sessionID);
597
+ db.prepare('DELETE FROM messages WHERE session_id = ?').run(storedSession.sessionID);
598
+
599
+ const insertMessage = db.prepare(
600
+ 'INSERT INTO messages (message_id, session_id, created_at, info_json) VALUES (?, ?, ?, ?)',
601
+ );
602
+ const insertPart = db.prepare(
603
+ 'INSERT INTO parts (part_id, session_id, message_id, sort_key, part_json) VALUES (?, ?, ?, ?, ?)',
604
+ );
605
+
606
+ for (const message of storedSession.messages) {
607
+ insertMessage.run(
608
+ message.info.id,
609
+ storedSession.sessionID,
610
+ message.info.time.created,
611
+ JSON.stringify(message.info),
612
+ );
613
+
614
+ message.parts.forEach((part, index) => {
615
+ insertPart.run(part.id, storedSession.sessionID, part.messageID, index, JSON.stringify(part));
616
+ });
617
+ }
618
+
619
+ insertArtifactsSync(bindings, artifacts);
620
+ bindings.replaceMessageSearchRowsSync(storedSession);
621
+ }
622
+
623
+ export function replaceStoredMessageSync(
624
+ bindings: StoreArtifactBindings,
625
+ sessionID: string,
626
+ storedMessage: ConversationMessage,
627
+ artifacts: ArtifactData[],
628
+ ): void {
629
+ const db = bindings.getDb();
630
+
631
+ bindings.deleteMessageSync(sessionID, storedMessage.info.id);
632
+ bindings.upsertMessageInfoSync(sessionID, storedMessage);
633
+
634
+ const insertPart = db.prepare(
635
+ 'INSERT INTO parts (part_id, session_id, message_id, sort_key, part_json) VALUES (?, ?, ?, ?, ?)',
636
+ );
637
+
638
+ storedMessage.parts.forEach((part, index) => {
639
+ insertPart.run(part.id, sessionID, part.messageID, index, JSON.stringify(part));
640
+ });
641
+
642
+ insertArtifactsSync(bindings, artifacts);
643
+ bindings.replaceMessageSearchRowSync(sessionID, storedMessage);
644
+ }
645
+
646
+ export function materializeArtifactRow(
647
+ bindings: StoreArtifactBindings,
648
+ row: ArtifactRow,
649
+ ): ArtifactData {
650
+ const blob = bindings.readArtifactBlobSync(row.content_hash);
651
+ const contentText = blob?.content_text ?? row.content_text;
652
+ return {
653
+ artifactID: row.artifact_id,
654
+ sessionID: row.session_id,
655
+ messageID: row.message_id,
656
+ partID: row.part_id,
657
+ artifactKind: row.artifact_kind,
658
+ fieldName: row.field_name,
659
+ previewText: row.preview_text,
660
+ contentText,
661
+ contentHash: row.content_hash ?? hashContent(contentText),
662
+ charCount: blob?.char_count ?? row.char_count,
663
+ createdAt: row.created_at,
664
+ metadata: parseJson<Record<string, unknown>>(row.metadata_json || '{}'),
665
+ };
666
+ }