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
package/src/store.ts ADDED
@@ -0,0 +1,4926 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readdir, readFile, stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ import type { Event, Message, Part } from '@opencode-ai/sdk';
6
+
7
+ import {
8
+ buildActiveSummaryText,
9
+ renderAutomaticRetrievalContext,
10
+ resolveArchiveTransformWindow,
11
+ selectAutomaticRetrievalHits,
12
+ } from './archive-transform.js';
13
+ import {
14
+ AUTOMATIC_RETRIEVAL_QUERY_TOKENS,
15
+ AUTOMATIC_RETRIEVAL_QUERY_VARIANTS,
16
+ AUTOMATIC_RETRIEVAL_RECENT_MESSAGES,
17
+ EXPAND_MESSAGE_LIMIT,
18
+ STORE_SCHEMA_VERSION,
19
+ SUMMARY_BRANCH_FACTOR,
20
+ SUMMARY_LEAF_MESSAGES,
21
+ SUMMARY_NODE_CHAR_LIMIT,
22
+ } from './constants.js';
23
+ import { type DoctorReport, type DoctorSessionIssue, formatDoctorReport } from './doctor.js';
24
+ import { getLogger, isStartupLoggingEnabled } from './logging.js';
25
+ import {
26
+ type CompiledPrivacyOptions,
27
+ compilePrivacyOptions,
28
+ redactStructuredValue,
29
+ redactText,
30
+ } from './privacy.js';
31
+ import { safeQuery, safeQueryOne, validateRow, withTransaction } from './sql-utils.js';
32
+ import {
33
+ type ArtifactData,
34
+ buildArtifactSearchContent as buildArtifactSearchContentModule,
35
+ type ExternalizedMessage,
36
+ type ExternalizedSession,
37
+ externalizeMessage as externalizeMessageModule,
38
+ externalizeSession as externalizeSessionModule,
39
+ formatArtifactMetadataLines as formatArtifactMetadataLinesModule,
40
+ materializeArtifactRow as materializeArtifactRowModule,
41
+ persistStoredSessionSync as persistStoredSessionSyncModule,
42
+ replaceStoredMessageSync as replaceStoredMessageSyncModule,
43
+ } from './store-artifacts.js';
44
+ import {
45
+ buildFtsQuery,
46
+ filterTokensByTfidf,
47
+ refreshSearchIndexesSync as refreshSearchIndexesModule,
48
+ replaceMessageSearchRowSync as replaceMessageSearchRowModule,
49
+ replaceMessageSearchRowsSync as replaceMessageSearchRowsModule,
50
+ searchByScan as searchByScanModule,
51
+ searchWithFts as searchWithFtsModule,
52
+ } from './store-search.js';
53
+ import {
54
+ type ArtifactBlobRow,
55
+ type ArtifactRow,
56
+ exportStoreSnapshot,
57
+ importStoreSnapshot,
58
+ type MessageRow,
59
+ type PartRow,
60
+ type SessionRow,
61
+ type SnapshotScope,
62
+ type SnapshotWorktreeMode,
63
+ type SummaryEdgeRow,
64
+ type SummaryNodeRow,
65
+ type SummaryStateRow,
66
+ } from './store-snapshot.js';
67
+ import type {
68
+ CapturedEvent,
69
+ ConversationMessage,
70
+ NormalizedSession,
71
+ OpencodeLcmOptions,
72
+ ScopeName,
73
+ SearchResult,
74
+ StoreStats,
75
+ } from './types.js';
76
+ import {
77
+ asRecord,
78
+ clamp,
79
+ filterIntentTokens,
80
+ firstFiniteNumber,
81
+ formatRetentionDays,
82
+ hashContent,
83
+ isAutomaticRetrievalNoise,
84
+ parseJson,
85
+ sanitizeAutomaticRetrievalSourceText,
86
+ shortNodeID,
87
+ shouldSuppressLowSignalAutomaticRetrievalAnchor,
88
+ tokenizeQuery,
89
+ truncate,
90
+ } from './utils.js';
91
+ import { normalizeWorktreeKey } from './worktree-key.js';
92
+
93
+ type ResumeMap = Record<string, string>;
94
+
95
+ type SummaryNodeData = {
96
+ nodeID: string;
97
+ sessionID: string;
98
+ level: number;
99
+ nodeKind: 'leaf' | 'internal';
100
+ startIndex: number;
101
+ endIndex: number;
102
+ messageIDs: string[];
103
+ summaryText: string;
104
+ createdAt: number;
105
+ };
106
+
107
+ export type SessionReadRow = {
108
+ session_id: string;
109
+ title: string | null;
110
+ parent_session_id: string | null;
111
+ root_session_id: string | null;
112
+ lineage_depth: number | null;
113
+ session_directory: string | null;
114
+ worktree_key: string | null;
115
+ pinned: number;
116
+ pin_reason: string | null;
117
+ deleted: number;
118
+ updated_at: number;
119
+ created_at: number;
120
+ event_count: number;
121
+ };
122
+
123
+ export type MessageReadRow = {
124
+ session_id: string;
125
+ message_id: string;
126
+ role: string;
127
+ created_at: number;
128
+ };
129
+
130
+ export type PartReadRow = {
131
+ session_id: string;
132
+ message_id: string;
133
+ part_id: string;
134
+ part_type: string;
135
+ sort_key: number;
136
+ state_json: string;
137
+ created_at: number;
138
+ };
139
+
140
+ export type ArtifactReadRow = {
141
+ artifact_id: string;
142
+ session_id: string;
143
+ message_id: string;
144
+ part_id: string;
145
+ artifact_kind: string;
146
+ field_name: string;
147
+ content_hash: string | null;
148
+ preview_text: string;
149
+ metadata_json: string;
150
+ char_count: number;
151
+ created_at: number;
152
+ };
153
+
154
+ export type ArtifactBlobReadRow = {
155
+ content_hash: string;
156
+ content_text: string;
157
+ char_count: number;
158
+ created_at: number;
159
+ };
160
+
161
+ import type {
162
+ ResolvedRetentionPolicy,
163
+ RetentionBlobCandidate,
164
+ RetentionSessionCandidate,
165
+ } from './store-retention.js';
166
+ import type { SqlDatabaseLike, SqlStatementLike } from './store-types.js';
167
+
168
+ function readSchemaVersionSync(db: SqlDatabaseLike): number {
169
+ const result = db.prepare('PRAGMA user_version').get() as Record<string, unknown> | undefined;
170
+ if (!result || typeof result !== 'object') return 0;
171
+ for (const value of Object.values(result)) {
172
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
173
+ }
174
+ return 0;
175
+ }
176
+
177
+ function assertSupportedSchemaVersionSync(db: SqlDatabaseLike, maxVersion: number): void {
178
+ const schemaVersion = readSchemaVersionSync(db);
179
+ if (schemaVersion <= maxVersion) return;
180
+ throw new Error(
181
+ `Unsupported store schema version: ${schemaVersion}. This build supports up to ${maxVersion}.`,
182
+ );
183
+ }
184
+
185
+ function writeSchemaVersionSync(db: SqlDatabaseLike, version: number): void {
186
+ db.exec(`PRAGMA user_version = ${Math.max(0, Math.trunc(version))}`);
187
+ }
188
+
189
+ function readSessionHeader(db: SqlDatabaseLike, sessionID: string): SessionReadRow | undefined {
190
+ return db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(sessionID) as
191
+ | SessionReadRow
192
+ | undefined;
193
+ }
194
+
195
+ function readAllSessions(db: SqlDatabaseLike): SessionReadRow[] {
196
+ return db.prepare('SELECT * FROM sessions ORDER BY updated_at DESC').all() as SessionReadRow[];
197
+ }
198
+
199
+ function readChildSessions(db: SqlDatabaseLike, parentSessionID: string): SessionReadRow[] {
200
+ return db
201
+ .prepare('SELECT * FROM sessions WHERE parent_session_id = ? ORDER BY updated_at DESC')
202
+ .all(parentSessionID) as SessionReadRow[];
203
+ }
204
+
205
+ function readLineageChain(db: SqlDatabaseLike, sessionID: string): SessionReadRow[] {
206
+ const chain: SessionReadRow[] = [];
207
+ let current = readSessionHeader(db, sessionID);
208
+ while (current) {
209
+ chain.unshift(current);
210
+ const parentID = current.parent_session_id;
211
+ if (!parentID) break;
212
+ current = readSessionHeader(db, parentID);
213
+ }
214
+ return chain;
215
+ }
216
+
217
+ function readMessagesForSession(db: SqlDatabaseLike, sessionID: string): MessageReadRow[] {
218
+ return db
219
+ .prepare('SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC')
220
+ .all(sessionID) as MessageReadRow[];
221
+ }
222
+
223
+ function _readPartsForSession(db: SqlDatabaseLike, sessionID: string): PartReadRow[] {
224
+ return db
225
+ .prepare('SELECT * FROM parts WHERE session_id = ? ORDER BY message_id ASC, sort_key ASC')
226
+ .all(sessionID) as PartReadRow[];
227
+ }
228
+
229
+ function readArtifactsForSession(db: SqlDatabaseLike, sessionID: string): ArtifactReadRow[] {
230
+ return db
231
+ .prepare('SELECT * FROM artifacts WHERE session_id = ? ORDER BY created_at DESC')
232
+ .all(sessionID) as ArtifactReadRow[];
233
+ }
234
+
235
+ function readArtifact(db: SqlDatabaseLike, artifactID: string): ArtifactReadRow | undefined {
236
+ return db.prepare('SELECT * FROM artifacts WHERE artifact_id = ?').get(artifactID) as
237
+ | ArtifactReadRow
238
+ | undefined;
239
+ }
240
+
241
+ function readArtifactBlob(
242
+ db: SqlDatabaseLike,
243
+ contentHash: string,
244
+ ): ArtifactBlobReadRow | undefined {
245
+ return db.prepare('SELECT * FROM artifact_blobs WHERE content_hash = ?').get(contentHash) as
246
+ | ArtifactBlobReadRow
247
+ | undefined;
248
+ }
249
+
250
+ function _readOrphanArtifactBlobRows(db: SqlDatabaseLike): ArtifactBlobReadRow[] {
251
+ return db
252
+ .prepare(
253
+ `SELECT b.* FROM artifact_blobs b
254
+ WHERE NOT EXISTS (
255
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
256
+ )
257
+ ORDER BY b.created_at ASC`,
258
+ )
259
+ .all() as ArtifactBlobReadRow[];
260
+ }
261
+
262
+ function readLatestSessionID(db: SqlDatabaseLike): string | undefined {
263
+ const row = db
264
+ .prepare('SELECT session_id FROM sessions ORDER BY updated_at DESC LIMIT 1')
265
+ .get() as { session_id: string } | undefined;
266
+ return row?.session_id;
267
+ }
268
+
269
+ function readSessionStats(db: SqlDatabaseLike): {
270
+ sessionCount: number;
271
+ messageCount: number;
272
+ artifactCount: number;
273
+ summaryNodeCount: number;
274
+ blobCount: number;
275
+ orphanBlobCount: number;
276
+ orphanBlobChars: number;
277
+ } {
278
+ const sessions = db.prepare('SELECT COUNT(*) AS count FROM sessions').get() as { count: number };
279
+ const messages = db.prepare('SELECT COUNT(*) AS count FROM messages').get() as { count: number };
280
+ const artifacts = db.prepare('SELECT COUNT(*) AS count FROM artifacts').get() as {
281
+ count: number;
282
+ };
283
+ const summaryNodes = db.prepare('SELECT COUNT(*) AS count FROM summary_nodes').get() as {
284
+ count: number;
285
+ };
286
+ const blobs = db
287
+ .prepare(
288
+ `SELECT COUNT(*) AS count, COALESCE(SUM(char_count), 0) AS chars
289
+ FROM artifact_blobs b
290
+ WHERE NOT EXISTS (
291
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
292
+ )`,
293
+ )
294
+ .get() as { count: number; chars: number };
295
+ return {
296
+ sessionCount: sessions.count,
297
+ messageCount: messages.count,
298
+ artifactCount: artifacts.count,
299
+ summaryNodeCount: summaryNodes.count,
300
+ blobCount: blobs.count,
301
+ orphanBlobCount: blobs.count,
302
+ orphanBlobChars: blobs.chars,
303
+ };
304
+ }
305
+
306
+ type ReadSessionOptions = {
307
+ artifactMessageIDs?: string[];
308
+ };
309
+
310
+ function extractSessionID(event: unknown): string | undefined {
311
+ const record = asRecord(event);
312
+ if (!record) return undefined;
313
+
314
+ if (typeof record.sessionID === 'string') return record.sessionID;
315
+
316
+ const properties = asRecord(record.properties);
317
+ if (!properties) return undefined;
318
+
319
+ if (typeof properties.sessionID === 'string') return properties.sessionID;
320
+
321
+ const info = asRecord(properties.info);
322
+ if (info && typeof info.sessionID === 'string') return info.sessionID;
323
+
324
+ const part = asRecord(properties.part);
325
+ if (part && typeof part.sessionID === 'string') return part.sessionID;
326
+
327
+ return undefined;
328
+ }
329
+
330
+ function extractTimestamp(event: unknown): number {
331
+ const record = asRecord(event);
332
+ if (!record) return Date.now();
333
+
334
+ const properties = asRecord(record.properties);
335
+ const time = asRecord(properties?.time);
336
+
337
+ if (typeof record.timestamp === 'number') return record.timestamp;
338
+ if (typeof properties?.timestamp === 'number') return properties.timestamp;
339
+ if (typeof time?.created === 'number') return time.created;
340
+ if (typeof properties?.time === 'number') return properties.time;
341
+
342
+ return Date.now();
343
+ }
344
+
345
+ function normalizeEvent(event: unknown): CapturedEvent | null {
346
+ const record = asRecord(event);
347
+ if (!record || typeof record.type !== 'string') return null;
348
+
349
+ return {
350
+ id: randomUUID(),
351
+ type: record.type,
352
+ sessionID: extractSessionID(event),
353
+ timestamp: extractTimestamp(event),
354
+ payload: event,
355
+ };
356
+ }
357
+
358
+ function getDeferredPartUpdateKey(event: Event): string | undefined {
359
+ if (event.type !== 'message.part.updated') return undefined;
360
+ return `${event.properties.part.sessionID}:${event.properties.part.messageID}:${event.properties.part.id}`;
361
+ }
362
+
363
+ function compareMessages(a: ConversationMessage, b: ConversationMessage): number {
364
+ return a.info.time.created - b.info.time.created;
365
+ }
366
+
367
+ function buildSummaryNodeID(sessionID: string, level: number, slot: number): string {
368
+ return `sum_${hashContent(`summary:${sessionID}`).slice(0, 12)}_l${level}_p${slot}`;
369
+ }
370
+
371
+ function hydratePartFromArtifacts(part: Part, artifacts: ArtifactData[]): void {
372
+ for (const artifact of artifacts) {
373
+ switch (part.type) {
374
+ case 'text':
375
+ case 'reasoning':
376
+ if (artifact.fieldName === 'text') part.text = artifact.contentText;
377
+ break;
378
+ case 'tool':
379
+ if (part.state.status === 'completed' && artifact.fieldName === 'output')
380
+ part.state.output = artifact.contentText;
381
+ if (part.state.status === 'error' && artifact.fieldName === 'error')
382
+ part.state.error = artifact.contentText;
383
+ if (
384
+ part.state.status === 'completed' &&
385
+ artifact.fieldName.startsWith('attachment_text:')
386
+ ) {
387
+ const index = Number(artifact.fieldName.split(':')[1]);
388
+ const attachment = part.state.attachments?.[index];
389
+ if (attachment?.source?.text) {
390
+ attachment.source.text.value = artifact.contentText;
391
+ attachment.source.text.start = 0;
392
+ attachment.source.text.end = artifact.contentText.length;
393
+ }
394
+ }
395
+ break;
396
+ case 'file':
397
+ if (artifact.fieldName === 'source' && part.source?.text) {
398
+ part.source.text.value = artifact.contentText;
399
+ part.source.text.start = 0;
400
+ part.source.text.end = artifact.contentText.length;
401
+ }
402
+ break;
403
+ case 'snapshot':
404
+ if (artifact.fieldName === 'snapshot') part.snapshot = artifact.contentText;
405
+ break;
406
+ case 'agent':
407
+ if (artifact.fieldName === 'source' && part.source) {
408
+ part.source.value = artifact.contentText;
409
+ part.source.start = 0;
410
+ part.source.end = artifact.contentText.length;
411
+ }
412
+ break;
413
+ case 'subtask':
414
+ if (artifact.fieldName === 'prompt') part.prompt = artifact.contentText;
415
+ if (artifact.fieldName === 'description') part.description = artifact.contentText;
416
+ break;
417
+ default:
418
+ break;
419
+ }
420
+ }
421
+ }
422
+
423
+ function isSyntheticLcmTextPart(part: Part, markers?: string[]): boolean {
424
+ if (part.type !== 'text') return false;
425
+ const marker = part.metadata?.opencodeLcm;
426
+ if (typeof marker !== 'string') return false;
427
+ return markers ? markers.includes(marker) : true;
428
+ }
429
+
430
+ function guessMessageText(message: ConversationMessage, ignoreToolPrefixes: string[]): string {
431
+ const segments: string[] = [];
432
+
433
+ for (const part of message.parts) {
434
+ switch (part.type) {
435
+ case 'text': {
436
+ if (isSyntheticLcmTextPart(part, ['archive-summary', 'retrieved-context', 'archived-part']))
437
+ break;
438
+ if (part.text.startsWith('[Archived by opencode-lcm:')) break;
439
+ const sanitized = sanitizeAutomaticRetrievalSourceText(part.text);
440
+ if (sanitized) segments.push(sanitized);
441
+ break;
442
+ }
443
+ case 'reasoning': {
444
+ if (part.text.startsWith('[Archived by opencode-lcm:')) break;
445
+ const sanitized = sanitizeAutomaticRetrievalSourceText(part.text);
446
+ if (sanitized) segments.push(sanitized);
447
+ break;
448
+ }
449
+ case 'file': {
450
+ const sourcePath = part.source?.path;
451
+ const filename = part.filename;
452
+ const inlineText = part.source?.text?.value;
453
+ segments.push([sourcePath ?? filename ?? 'file', inlineText].filter(Boolean).join(': '));
454
+ break;
455
+ }
456
+ case 'tool': {
457
+ if (ignoreToolPrefixes.some((prefix) => part.tool.startsWith(prefix))) break;
458
+ const state = part.state;
459
+ if (state.status === 'completed') segments.push(`${part.tool}: ${state.output}`);
460
+ if (state.status === 'error') segments.push(`${part.tool}: ${state.error}`);
461
+ if (state.status === 'pending' || state.status === 'running') {
462
+ segments.push(`${part.tool}: ${JSON.stringify(state.input)}`);
463
+ }
464
+ if (state.status === 'completed' && state.attachments && state.attachments.length > 0) {
465
+ const attachmentNames = state.attachments
466
+ .map((file) => file.source?.path ?? file.filename ?? file.url)
467
+ .filter(Boolean)
468
+ .slice(0, 4);
469
+ if (attachmentNames.length > 0)
470
+ segments.push(`${part.tool} attachments: ${attachmentNames.join(', ')}`);
471
+ }
472
+ break;
473
+ }
474
+ case 'subtask':
475
+ segments.push(`${part.agent}: ${part.description}`);
476
+ break;
477
+ case 'agent':
478
+ segments.push(part.name);
479
+ break;
480
+ case 'snapshot':
481
+ segments.push(part.snapshot);
482
+ break;
483
+ default:
484
+ break;
485
+ }
486
+ }
487
+
488
+ return truncate(segments.filter(Boolean).join('\n').replace(/\s+/g, ' ').trim(), 500);
489
+ }
490
+
491
+ function listFiles(message: ConversationMessage): string[] {
492
+ const files = new Set<string>();
493
+
494
+ for (const part of message.parts) {
495
+ if (part.type === 'file') {
496
+ if (part.source?.path) files.add(part.source.path);
497
+ else if (part.filename) files.add(part.filename);
498
+ }
499
+
500
+ if (part.type === 'patch') {
501
+ for (const file of part.files.slice(0, 20)) files.add(file);
502
+ }
503
+ }
504
+
505
+ return [...files];
506
+ }
507
+
508
+ function makeSessionTitle(session: NormalizedSession): string | undefined {
509
+ if (session.title) return session.title;
510
+
511
+ const firstUser = session.messages.find((message) => message.info.role === 'user');
512
+ if (!firstUser) return undefined;
513
+
514
+ return truncate(guessMessageText(firstUser, []), 80);
515
+ }
516
+
517
+ function archivePlaceholder(label: string): string {
518
+ return `[Archived by opencode-lcm: ${label}. Use lcm_resume, lcm_grep, or lcm_expand for details.]`;
519
+ }
520
+
521
+ function logStartupPhase(phase: string, context?: Record<string, unknown>): void {
522
+ if (!isStartupLoggingEnabled()) return;
523
+ getLogger().info(`startup phase: ${phase}`, context);
524
+ }
525
+
526
+ type SqliteRuntime = 'bun' | 'node';
527
+ type SqliteRuntimeOptions = {
528
+ envOverride?: string | undefined;
529
+ isBunRuntime?: boolean;
530
+ platform?: string | undefined;
531
+ };
532
+
533
+ function normalizeSqliteRuntimeOverride(value: string | undefined): SqliteRuntime | 'auto' {
534
+ const normalized = value?.trim().toLowerCase();
535
+ if (normalized === 'bun' || normalized === 'node') return normalized;
536
+ return 'auto';
537
+ }
538
+
539
+ export function resolveSqliteRuntimeCandidates(options?: SqliteRuntimeOptions): SqliteRuntime[] {
540
+ const override = normalizeSqliteRuntimeOverride(
541
+ options?.envOverride ?? process.env.OPENCODE_LCM_SQLITE_RUNTIME,
542
+ );
543
+ if (override !== 'auto') return [override];
544
+
545
+ const isBunRuntime =
546
+ options?.isBunRuntime ?? (typeof globalThis === 'object' && 'Bun' in globalThis);
547
+ if (!isBunRuntime) return ['node'];
548
+
549
+ const platform = options?.platform ?? process.platform;
550
+ return platform === 'win32' ? ['node', 'bun'] : ['bun', 'node'];
551
+ }
552
+
553
+ export function resolveSqliteRuntime(options?: SqliteRuntimeOptions): SqliteRuntime {
554
+ return resolveSqliteRuntimeCandidates(options)[0];
555
+ }
556
+
557
+ function isSqliteRuntimeImportError(runtime: SqliteRuntime, error: unknown): boolean {
558
+ const code =
559
+ typeof error === 'object' && error && 'code' in error && typeof error.code === 'string'
560
+ ? error.code
561
+ : undefined;
562
+ const message = error instanceof Error ? error.message : String(error);
563
+ const specifier = runtime === 'bun' ? 'bun:sqlite' : 'node:sqlite';
564
+
565
+ if (!message.includes(specifier)) return false;
566
+ if (code === 'ERR_UNKNOWN_BUILTIN_MODULE' || code === 'ERR_MODULE_NOT_FOUND') return true;
567
+
568
+ return (
569
+ message.includes('Cannot find module') ||
570
+ message.includes('Cannot find package') ||
571
+ message.includes('No such built-in module')
572
+ );
573
+ }
574
+
575
+ function hasErrorCode(error: unknown, code: string): boolean {
576
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
577
+ }
578
+
579
+ async function openBunSqliteDatabase(dbPath: string): Promise<SqlDatabaseLike> {
580
+ const { Database } = await import('bun:sqlite');
581
+ const db = new (
582
+ Database as new (
583
+ path: string,
584
+ opts?: { create: boolean },
585
+ ) => {
586
+ exec(sql: string): void;
587
+ close(): void;
588
+ prepare(sql: string): {
589
+ run(...args: unknown[]): void;
590
+ get(...args: unknown[]): Record<string, unknown>;
591
+ all(...args: unknown[]): Record<string, unknown>[];
592
+ values(...args: unknown[]): unknown[][];
593
+ };
594
+ query(sql: string): {
595
+ run(...args: unknown[]): void;
596
+ get(...args: unknown[]): Record<string, unknown>;
597
+ all(...args: unknown[]): Record<string, unknown>[];
598
+ };
599
+ }
600
+ )(dbPath, { create: true });
601
+ db.exec('PRAGMA foreign_keys = ON');
602
+ db.exec('PRAGMA busy_timeout = 5000');
603
+
604
+ return {
605
+ exec(sql: string) {
606
+ return db.exec(sql);
607
+ },
608
+ close() {
609
+ db.close();
610
+ },
611
+ prepare(sql: string) {
612
+ const statement = typeof db.prepare === 'function' ? db.prepare(sql) : db.query(sql);
613
+ return {
614
+ run(...args: unknown[]) {
615
+ return statement.run(...args);
616
+ },
617
+ get(...args: unknown[]) {
618
+ return statement.get(...args);
619
+ },
620
+ all(...args: unknown[]) {
621
+ return statement.all(...args);
622
+ },
623
+ };
624
+ },
625
+ };
626
+ }
627
+
628
+ async function openNodeSqliteDatabase(dbPath: string): Promise<SqlDatabaseLike> {
629
+ const { DatabaseSync } = await import('node:sqlite');
630
+ const db = new DatabaseSync(dbPath, {
631
+ enableForeignKeyConstraints: true,
632
+ timeout: 5000,
633
+ });
634
+
635
+ return {
636
+ exec(sql: string) {
637
+ return db.exec(sql);
638
+ },
639
+ close() {
640
+ db.close();
641
+ },
642
+ prepare(sql: string) {
643
+ return db.prepare(sql) as SqlStatementLike;
644
+ },
645
+ };
646
+ }
647
+
648
+ async function openSqliteDatabase(dbPath: string): Promise<SqlDatabaseLike> {
649
+ const candidates = resolveSqliteRuntimeCandidates();
650
+ const openers: Record<SqliteRuntime, (path: string) => Promise<SqlDatabaseLike>> = {
651
+ bun: openBunSqliteDatabase,
652
+ node: openNodeSqliteDatabase,
653
+ };
654
+
655
+ let lastError: unknown;
656
+ for (const [index, runtime] of candidates.entries()) {
657
+ try {
658
+ return await openers[runtime](dbPath);
659
+ } catch (error) {
660
+ if (!isSqliteRuntimeImportError(runtime, error) || index === candidates.length - 1) {
661
+ throw error;
662
+ }
663
+
664
+ lastError = error;
665
+ logStartupPhase('open-db:sqlite-runtime-fallback', {
666
+ runtime,
667
+ fallbackRuntime: candidates[index + 1],
668
+ message: error instanceof Error ? error.message : String(error),
669
+ });
670
+ }
671
+ }
672
+
673
+ throw lastError instanceof Error
674
+ ? lastError
675
+ : new Error('Unable to initialize a supported SQLite runtime.');
676
+ }
677
+
678
+ export class SqliteLcmStore {
679
+ private static readonly deferredPartUpdateDelayMs = 250;
680
+ private readonly baseDir: string;
681
+ private readonly dbPath: string;
682
+ private readonly privacy: CompiledPrivacyOptions;
683
+ private readonly workspaceDirectory: string;
684
+ private db?: SqlDatabaseLike;
685
+ private dbReadyPromise?: Promise<void>;
686
+ private readonly pendingPartUpdates = new Map<string, Event>();
687
+ private pendingPartUpdateTimer?: ReturnType<typeof setTimeout>;
688
+ private pendingPartUpdateFlushPromise?: Promise<void>;
689
+
690
+ constructor(
691
+ projectDir: string,
692
+ private readonly options: OpencodeLcmOptions,
693
+ ) {
694
+ this.privacy = compilePrivacyOptions(options.privacy);
695
+ this.workspaceDirectory = projectDir;
696
+ this.baseDir = path.join(projectDir, options.storeDir ?? '.lcm');
697
+ this.dbPath = path.join(this.baseDir, 'lcm.db');
698
+ }
699
+
700
+ async init(): Promise<void> {
701
+ await mkdir(this.baseDir, { recursive: true });
702
+ }
703
+
704
+ private async prepareForRead(): Promise<void> {
705
+ await this.ensureDbReady();
706
+ await this.flushDeferredPartUpdates();
707
+ }
708
+
709
+ private scheduleDeferredPartUpdateFlush(): void {
710
+ if (this.pendingPartUpdateTimer || this.pendingPartUpdates.size === 0) return;
711
+
712
+ this.pendingPartUpdateTimer = setTimeout(() => {
713
+ this.pendingPartUpdateTimer = undefined;
714
+ void this.flushDeferredPartUpdates();
715
+ }, SqliteLcmStore.deferredPartUpdateDelayMs);
716
+
717
+ if (
718
+ typeof this.pendingPartUpdateTimer === 'object' &&
719
+ this.pendingPartUpdateTimer &&
720
+ 'unref' in this.pendingPartUpdateTimer &&
721
+ typeof this.pendingPartUpdateTimer.unref === 'function'
722
+ ) {
723
+ this.pendingPartUpdateTimer.unref();
724
+ }
725
+ }
726
+
727
+ private clearDeferredPartUpdateTimer(): void {
728
+ if (!this.pendingPartUpdateTimer) return;
729
+ clearTimeout(this.pendingPartUpdateTimer);
730
+ this.pendingPartUpdateTimer = undefined;
731
+ }
732
+
733
+ private clearDeferredPartUpdatesForSession(sessionID?: string): void {
734
+ if (!sessionID || this.pendingPartUpdates.size === 0) return;
735
+
736
+ for (const [key, event] of this.pendingPartUpdates.entries()) {
737
+ if (event.type !== 'message.part.updated') continue;
738
+ if (event.properties.part.sessionID !== sessionID) continue;
739
+ this.pendingPartUpdates.delete(key);
740
+ }
741
+
742
+ if (this.pendingPartUpdates.size === 0) this.clearDeferredPartUpdateTimer();
743
+ }
744
+
745
+ private clearDeferredPartUpdatesForMessage(sessionID?: string, messageID?: string): void {
746
+ if (!sessionID || !messageID || this.pendingPartUpdates.size === 0) return;
747
+
748
+ for (const [key, event] of this.pendingPartUpdates.entries()) {
749
+ if (event.type !== 'message.part.updated') continue;
750
+ if (event.properties.part.sessionID !== sessionID) continue;
751
+ if (event.properties.part.messageID !== messageID) continue;
752
+ this.pendingPartUpdates.delete(key);
753
+ }
754
+
755
+ if (this.pendingPartUpdates.size === 0) this.clearDeferredPartUpdateTimer();
756
+ }
757
+
758
+ private clearDeferredPartUpdateForPart(
759
+ sessionID?: string,
760
+ messageID?: string,
761
+ partID?: string,
762
+ ): void {
763
+ if (!sessionID || !messageID || !partID || this.pendingPartUpdates.size === 0) return;
764
+ this.pendingPartUpdates.delete(`${sessionID}:${messageID}:${partID}`);
765
+ if (this.pendingPartUpdates.size === 0) this.clearDeferredPartUpdateTimer();
766
+ }
767
+
768
+ async captureDeferred(event: Event): Promise<void> {
769
+ switch (event.type) {
770
+ case 'message.part.updated': {
771
+ const key = getDeferredPartUpdateKey(event);
772
+ if (!key) return await this.capture(event);
773
+ this.pendingPartUpdates.set(key, event);
774
+ this.scheduleDeferredPartUpdateFlush();
775
+ return;
776
+ }
777
+ case 'message.part.removed':
778
+ this.clearDeferredPartUpdateForPart(
779
+ event.properties.sessionID,
780
+ event.properties.messageID,
781
+ event.properties.partID,
782
+ );
783
+ break;
784
+ case 'message.removed':
785
+ this.clearDeferredPartUpdatesForMessage(
786
+ event.properties.sessionID,
787
+ event.properties.messageID,
788
+ );
789
+ break;
790
+ case 'session.deleted':
791
+ this.clearDeferredPartUpdatesForSession(extractSessionID(event));
792
+ break;
793
+ default:
794
+ break;
795
+ }
796
+
797
+ await this.capture(event);
798
+ }
799
+
800
+ async flushDeferredPartUpdates(): Promise<void> {
801
+ if (this.pendingPartUpdateFlushPromise) return this.pendingPartUpdateFlushPromise;
802
+ if (this.pendingPartUpdates.size === 0) return;
803
+
804
+ this.clearDeferredPartUpdateTimer();
805
+ this.pendingPartUpdateFlushPromise = (async () => {
806
+ while (this.pendingPartUpdates.size > 0) {
807
+ const batch = [...this.pendingPartUpdates.values()];
808
+ this.pendingPartUpdates.clear();
809
+ for (const event of batch) {
810
+ await this.capture(event);
811
+ }
812
+ }
813
+ })().finally(() => {
814
+ this.pendingPartUpdateFlushPromise = undefined;
815
+ if (this.pendingPartUpdates.size > 0) this.scheduleDeferredPartUpdateFlush();
816
+ });
817
+
818
+ return this.pendingPartUpdateFlushPromise;
819
+ }
820
+
821
+ private async ensureDbReady(): Promise<void> {
822
+ if (this.db) return;
823
+ if (this.dbReadyPromise) return this.dbReadyPromise;
824
+ this.dbReadyPromise = this.openAndInitializeDb();
825
+ return this.dbReadyPromise;
826
+ }
827
+
828
+ private async openAndInitializeDb(): Promise<void> {
829
+ logStartupPhase('open-db:start', { dbPath: this.dbPath });
830
+ await mkdir(this.baseDir, { recursive: true });
831
+ logStartupPhase('open-db:connect', {
832
+ runtime: typeof globalThis === 'object' && 'Bun' in globalThis ? 'bun' : 'node',
833
+ sqliteRuntime: resolveSqliteRuntime(),
834
+ });
835
+ const db = await openSqliteDatabase(this.dbPath);
836
+ this.db = db;
837
+
838
+ try {
839
+ logStartupPhase('open-db:schema-check');
840
+ this.assertSupportedSchemaVersionSync();
841
+ db.exec('PRAGMA journal_mode = WAL');
842
+ db.exec('PRAGMA synchronous = NORMAL');
843
+ logStartupPhase('open-db:create-tables');
844
+ db.exec(`
845
+ CREATE TABLE IF NOT EXISTS events (
846
+ id TEXT PRIMARY KEY,
847
+ session_id TEXT,
848
+ event_type TEXT NOT NULL,
849
+ ts INTEGER NOT NULL,
850
+ payload_json TEXT NOT NULL
851
+ );
852
+ CREATE INDEX IF NOT EXISTS idx_events_session_ts ON events(session_id, ts);
853
+
854
+ CREATE TABLE IF NOT EXISTS sessions (
855
+ session_id TEXT PRIMARY KEY,
856
+ title TEXT,
857
+ session_directory TEXT,
858
+ worktree_key TEXT,
859
+ parent_session_id TEXT,
860
+ root_session_id TEXT,
861
+ lineage_depth INTEGER,
862
+ pinned INTEGER NOT NULL DEFAULT 0,
863
+ pin_reason TEXT,
864
+ updated_at INTEGER NOT NULL DEFAULT 0,
865
+ compacted_at INTEGER,
866
+ deleted INTEGER NOT NULL DEFAULT 0,
867
+ event_count INTEGER NOT NULL DEFAULT 0
868
+ );
869
+
870
+ CREATE TABLE IF NOT EXISTS messages (
871
+ message_id TEXT PRIMARY KEY,
872
+ session_id TEXT NOT NULL,
873
+ created_at INTEGER NOT NULL,
874
+ info_json TEXT NOT NULL,
875
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
876
+ );
877
+ CREATE INDEX IF NOT EXISTS idx_messages_session_created ON messages(session_id, created_at, message_id);
878
+
879
+ CREATE TABLE IF NOT EXISTS parts (
880
+ part_id TEXT PRIMARY KEY,
881
+ session_id TEXT NOT NULL,
882
+ message_id TEXT NOT NULL,
883
+ sort_key INTEGER NOT NULL DEFAULT 0,
884
+ part_json TEXT NOT NULL,
885
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE,
886
+ FOREIGN KEY (message_id) REFERENCES messages(message_id) ON DELETE CASCADE
887
+ );
888
+ CREATE INDEX IF NOT EXISTS idx_parts_message_sort ON parts(message_id, sort_key, part_id);
889
+
890
+ CREATE TABLE IF NOT EXISTS resumes (
891
+ session_id TEXT PRIMARY KEY,
892
+ note TEXT NOT NULL,
893
+ updated_at INTEGER NOT NULL,
894
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
895
+ );
896
+
897
+ CREATE TABLE IF NOT EXISTS artifacts (
898
+ artifact_id TEXT PRIMARY KEY,
899
+ session_id TEXT NOT NULL,
900
+ message_id TEXT NOT NULL,
901
+ part_id TEXT NOT NULL,
902
+ artifact_kind TEXT NOT NULL,
903
+ field_name TEXT NOT NULL,
904
+ preview_text TEXT NOT NULL,
905
+ content_text TEXT NOT NULL,
906
+ content_hash TEXT,
907
+ metadata_json TEXT NOT NULL DEFAULT '{}',
908
+ char_count INTEGER NOT NULL,
909
+ created_at INTEGER NOT NULL,
910
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE,
911
+ FOREIGN KEY (message_id) REFERENCES messages(message_id) ON DELETE CASCADE
912
+ );
913
+ CREATE INDEX IF NOT EXISTS idx_artifacts_session_message ON artifacts(session_id, message_id, part_id);
914
+
915
+ CREATE TABLE IF NOT EXISTS artifact_blobs (
916
+ content_hash TEXT PRIMARY KEY,
917
+ content_text TEXT NOT NULL,
918
+ char_count INTEGER NOT NULL,
919
+ created_at INTEGER NOT NULL
920
+ );
921
+
922
+ CREATE TABLE IF NOT EXISTS summary_nodes (
923
+ node_id TEXT PRIMARY KEY,
924
+ session_id TEXT NOT NULL,
925
+ level INTEGER NOT NULL,
926
+ node_kind TEXT NOT NULL,
927
+ start_index INTEGER NOT NULL,
928
+ end_index INTEGER NOT NULL,
929
+ message_ids_json TEXT NOT NULL,
930
+ summary_text TEXT NOT NULL,
931
+ created_at INTEGER NOT NULL,
932
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
933
+ );
934
+ CREATE INDEX IF NOT EXISTS idx_summary_nodes_session_level ON summary_nodes(session_id, level);
935
+
936
+ CREATE TABLE IF NOT EXISTS summary_edges (
937
+ session_id TEXT NOT NULL,
938
+ parent_id TEXT NOT NULL,
939
+ child_id TEXT NOT NULL,
940
+ child_position INTEGER NOT NULL,
941
+ PRIMARY KEY (parent_id, child_id),
942
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE,
943
+ FOREIGN KEY (parent_id) REFERENCES summary_nodes(node_id) ON DELETE CASCADE,
944
+ FOREIGN KEY (child_id) REFERENCES summary_nodes(node_id) ON DELETE CASCADE
945
+ );
946
+ CREATE INDEX IF NOT EXISTS idx_summary_edges_session_parent ON summary_edges(session_id, parent_id, child_position);
947
+
948
+ CREATE TABLE IF NOT EXISTS summary_state (
949
+ session_id TEXT PRIMARY KEY,
950
+ archived_count INTEGER NOT NULL,
951
+ latest_message_created INTEGER NOT NULL,
952
+ archived_signature TEXT NOT NULL DEFAULT '',
953
+ root_node_ids_json TEXT NOT NULL,
954
+ updated_at INTEGER NOT NULL,
955
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
956
+ );
957
+
958
+ CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5(
959
+ session_id UNINDEXED,
960
+ message_id UNINDEXED,
961
+ role UNINDEXED,
962
+ created_at UNINDEXED,
963
+ content
964
+ );
965
+
966
+ CREATE VIRTUAL TABLE IF NOT EXISTS summary_fts USING fts5(
967
+ session_id UNINDEXED,
968
+ node_id UNINDEXED,
969
+ level UNINDEXED,
970
+ created_at UNINDEXED,
971
+ content
972
+ );
973
+
974
+ CREATE VIRTUAL TABLE IF NOT EXISTS artifact_fts USING fts5(
975
+ session_id UNINDEXED,
976
+ artifact_id UNINDEXED,
977
+ message_id UNINDEXED,
978
+ part_id UNINDEXED,
979
+ artifact_kind UNINDEXED,
980
+ created_at UNINDEXED,
981
+ content
982
+ );
983
+ `);
984
+
985
+ this.ensureSessionColumnsSync();
986
+ this.ensureSummaryStateColumnsSync();
987
+ this.ensureArtifactColumnsSync();
988
+ logStartupPhase('open-db:create-indexes');
989
+ db.exec('CREATE INDEX IF NOT EXISTS idx_artifacts_content_hash ON artifacts(content_hash)');
990
+ db.exec(
991
+ 'CREATE INDEX IF NOT EXISTS idx_sessions_root ON sessions(root_session_id, updated_at DESC)',
992
+ );
993
+ db.exec(
994
+ 'CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id, updated_at DESC)',
995
+ );
996
+ db.exec(
997
+ 'CREATE INDEX IF NOT EXISTS idx_sessions_worktree ON sessions(worktree_key, updated_at DESC)',
998
+ );
999
+ logStartupPhase('open-db:migrate-legacy-artifacts');
1000
+ await this.migrateLegacyArtifacts();
1001
+ logStartupPhase('open-db:write-schema-version', { schemaVersion: STORE_SCHEMA_VERSION });
1002
+ this.writeSchemaVersionSync(STORE_SCHEMA_VERSION);
1003
+ logStartupPhase('open-db:deferred-init:start');
1004
+ this.completeDeferredInit();
1005
+ logStartupPhase('open-db:ready');
1006
+ } catch (error) {
1007
+ logStartupPhase('open-db:error', {
1008
+ message: error instanceof Error ? error.message : String(error),
1009
+ });
1010
+ db.close();
1011
+ this.db = undefined;
1012
+ this.dbReadyPromise = undefined;
1013
+ throw error;
1014
+ }
1015
+ }
1016
+
1017
+ private deferredInitCompleted = false;
1018
+
1019
+ private readSchemaVersionSync(): number {
1020
+ return firstFiniteNumber(this.getDb().prepare('PRAGMA user_version').get()) ?? 0;
1021
+ }
1022
+
1023
+ private assertSupportedSchemaVersionSync(): void {
1024
+ const schemaVersion = this.readSchemaVersionSync();
1025
+ if (schemaVersion <= STORE_SCHEMA_VERSION) return;
1026
+ throw new Error(
1027
+ `Unsupported store schema version: ${schemaVersion}. This build supports up to ${STORE_SCHEMA_VERSION}.`,
1028
+ );
1029
+ }
1030
+
1031
+ private writeSchemaVersionSync(version: number): void {
1032
+ this.getDb().exec(`PRAGMA user_version = ${Math.max(0, Math.trunc(version))}`);
1033
+ }
1034
+
1035
+ private completeDeferredInit(): void {
1036
+ if (this.deferredInitCompleted) return;
1037
+ if (this.hasPendingArtifactBlobBackfillSync()) {
1038
+ logStartupPhase('deferred-init:artifact-backfill');
1039
+ this.backfillArtifactBlobsSync();
1040
+ }
1041
+ logStartupPhase('deferred-init:orphan-blob-cleanup');
1042
+ this.deleteOrphanArtifactBlobsSync();
1043
+ if (
1044
+ this.options.retention.staleSessionDays !== undefined ||
1045
+ this.options.retention.deletedSessionDays !== undefined ||
1046
+ this.options.retention.orphanBlobDays !== undefined
1047
+ ) {
1048
+ logStartupPhase('deferred-init:retention-prune');
1049
+ this.applyRetentionPruneSync({ apply: true });
1050
+ }
1051
+ if (this.hasPendingLineageRefreshSync()) {
1052
+ logStartupPhase('deferred-init:lineage-refresh');
1053
+ this.refreshAllLineageSync();
1054
+ }
1055
+ logStartupPhase('deferred-init:done');
1056
+ this.deferredInitCompleted = true;
1057
+ }
1058
+
1059
+ private hasPendingArtifactBlobBackfillSync(): boolean {
1060
+ const row = this.getDb()
1061
+ .prepare(
1062
+ "SELECT COUNT(*) AS count FROM artifacts WHERE content_hash IS NULL OR content_text != ''",
1063
+ )
1064
+ .get() as { count: number };
1065
+ return row.count > 0;
1066
+ }
1067
+
1068
+ private hasPendingLineageRefreshSync(): boolean {
1069
+ const row = this.getDb()
1070
+ .prepare(
1071
+ 'SELECT COUNT(*) AS count FROM sessions WHERE root_session_id IS NULL OR lineage_depth IS NULL',
1072
+ )
1073
+ .get() as { count: number };
1074
+ return row.count > 0;
1075
+ }
1076
+
1077
+ close(): void {
1078
+ this.clearDeferredPartUpdateTimer();
1079
+ this.pendingPartUpdates.clear();
1080
+ if (!this.db) return;
1081
+ this.db.close();
1082
+ this.db = undefined;
1083
+ this.dbReadyPromise = undefined;
1084
+ }
1085
+
1086
+ async capture(event: Event): Promise<void> {
1087
+ await this.ensureDbReady();
1088
+
1089
+ const normalized = normalizeEvent(event);
1090
+ if (!normalized) return;
1091
+
1092
+ if (this.shouldRecordEvent(normalized.type)) {
1093
+ this.writeEvent(normalized);
1094
+ }
1095
+
1096
+ if (!normalized.sessionID) return;
1097
+ if (!this.shouldPersistSessionForEvent(normalized.type)) return;
1098
+
1099
+ const session = this.readSessionSync(normalized.sessionID, {
1100
+ artifactMessageIDs: this.captureArtifactHydrationMessageIDs(normalized),
1101
+ });
1102
+ const previousParentSessionID = session.parentSessionID;
1103
+ let next = this.applyEvent(session, normalized);
1104
+ next.updatedAt = Math.max(next.updatedAt, normalized.timestamp);
1105
+ next.eventCount += 1;
1106
+ next = this.prepareSessionForPersistence(next);
1107
+ const shouldSyncDerivedState = this.shouldSyncDerivedSessionStateForEvent(
1108
+ session,
1109
+ next,
1110
+ normalized,
1111
+ );
1112
+
1113
+ await this.persistCapturedSession(next, normalized);
1114
+
1115
+ if (this.shouldRefreshLineageForEvent(normalized.type)) {
1116
+ this.refreshAllLineageSync();
1117
+ const refreshed = this.readSessionHeaderSync(normalized.sessionID);
1118
+ if (refreshed) {
1119
+ next = {
1120
+ ...next,
1121
+ parentSessionID: refreshed.parentSessionID,
1122
+ rootSessionID: refreshed.rootSessionID,
1123
+ lineageDepth: refreshed.lineageDepth,
1124
+ };
1125
+ }
1126
+ }
1127
+
1128
+ if (shouldSyncDerivedState) {
1129
+ this.syncDerivedSessionStateSync(this.readSessionSync(normalized.sessionID));
1130
+ }
1131
+
1132
+ if (
1133
+ this.shouldSyncDerivedLineageSubtree(
1134
+ normalized.type,
1135
+ previousParentSessionID,
1136
+ next.parentSessionID,
1137
+ )
1138
+ ) {
1139
+ this.syncDerivedLineageSubtreeSync(normalized.sessionID, true);
1140
+ }
1141
+
1142
+ if (this.shouldCleanupOrphanBlobsForEvent(normalized.type)) {
1143
+ this.deleteOrphanArtifactBlobsSync();
1144
+ }
1145
+ }
1146
+
1147
+ async stats(): Promise<StoreStats> {
1148
+ await this.prepareForRead();
1149
+ const db = this.getDb();
1150
+ const totalRow = validateRow<{ count: number; latest: number | null }>(
1151
+ db.prepare('SELECT COUNT(*) AS count, MAX(ts) AS latest FROM events').get(),
1152
+ { count: 'number', latest: 'nullable' },
1153
+ 'stats.totalEvents',
1154
+ );
1155
+ const sessionRow = validateRow<{ count: number }>(
1156
+ db.prepare('SELECT COUNT(*) AS count FROM sessions').get(),
1157
+ { count: 'number' },
1158
+ 'stats.sessionCount',
1159
+ );
1160
+ const typeRows = safeQuery<{ event_type: string; count: number }>(
1161
+ db.prepare(
1162
+ 'SELECT event_type, COUNT(*) AS count FROM events GROUP BY event_type ORDER BY count DESC',
1163
+ ),
1164
+ [],
1165
+ 'stats.eventTypes',
1166
+ );
1167
+ const summaryNodeRow = validateRow<{ count: number }>(
1168
+ db.prepare('SELECT COUNT(*) AS count FROM summary_nodes').get(),
1169
+ { count: 'number' },
1170
+ 'stats.summaryNodeCount',
1171
+ );
1172
+ const summaryStateRow = validateRow<{ count: number }>(
1173
+ db.prepare('SELECT COUNT(*) AS count FROM summary_state').get(),
1174
+ { count: 'number' },
1175
+ 'stats.summaryStateCount',
1176
+ );
1177
+ const artifactRow = validateRow<{ count: number }>(
1178
+ db.prepare('SELECT COUNT(*) AS count FROM artifacts').get(),
1179
+ { count: 'number' },
1180
+ 'stats.artifactCount',
1181
+ );
1182
+ const blobRow = validateRow<{ count: number }>(
1183
+ db.prepare('SELECT COUNT(*) AS count FROM artifact_blobs').get(),
1184
+ { count: 'number' },
1185
+ 'stats.artifactBlobCount',
1186
+ );
1187
+ const sharedBlobRow = validateRow<{ count: number }>(
1188
+ db
1189
+ .prepare(
1190
+ `SELECT COUNT(*) AS count FROM (
1191
+ SELECT content_hash FROM artifacts
1192
+ WHERE content_hash IS NOT NULL
1193
+ GROUP BY content_hash
1194
+ HAVING COUNT(*) > 1
1195
+ )`,
1196
+ )
1197
+ .get(),
1198
+ { count: 'number' },
1199
+ 'stats.sharedArtifactBlobCount',
1200
+ );
1201
+ const orphanBlobRow = validateRow<{ count: number }>(
1202
+ db
1203
+ .prepare(
1204
+ `SELECT COUNT(*) AS count FROM artifact_blobs b
1205
+ WHERE NOT EXISTS (
1206
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
1207
+ )`,
1208
+ )
1209
+ .get(),
1210
+ { count: 'number' },
1211
+ 'stats.orphanArtifactBlobCount',
1212
+ );
1213
+ const rootRow = validateRow<{ count: number }>(
1214
+ db.prepare('SELECT COUNT(*) AS count FROM sessions WHERE parent_session_id IS NULL').get(),
1215
+ { count: 'number' },
1216
+ 'stats.rootSessionCount',
1217
+ );
1218
+ const branchedRow = validateRow<{ count: number }>(
1219
+ db
1220
+ .prepare('SELECT COUNT(*) AS count FROM sessions WHERE parent_session_id IS NOT NULL')
1221
+ .get(),
1222
+ { count: 'number' },
1223
+ 'stats.branchedSessionCount',
1224
+ );
1225
+ const pinnedRow = validateRow<{ count: number }>(
1226
+ db.prepare('SELECT COUNT(*) AS count FROM sessions WHERE pinned = 1').get(),
1227
+ { count: 'number' },
1228
+ 'stats.pinnedSessionCount',
1229
+ );
1230
+ const worktreeRow = validateRow<{ count: number }>(
1231
+ db
1232
+ .prepare(
1233
+ 'SELECT COUNT(DISTINCT worktree_key) AS count FROM sessions WHERE worktree_key IS NOT NULL',
1234
+ )
1235
+ .get(),
1236
+ { count: 'number' },
1237
+ 'stats.worktreeCount',
1238
+ );
1239
+
1240
+ return {
1241
+ schemaVersion: readSchemaVersionSync(db),
1242
+ totalEvents: totalRow.count,
1243
+ sessionCount: sessionRow.count,
1244
+ latestEventAt: totalRow.latest ?? undefined,
1245
+ eventTypes: Object.fromEntries(typeRows.map((row) => [row.event_type, row.count])),
1246
+ summaryNodeCount: summaryNodeRow.count,
1247
+ summaryStateCount: summaryStateRow.count,
1248
+ rootSessionCount: rootRow.count,
1249
+ branchedSessionCount: branchedRow.count,
1250
+ artifactCount: artifactRow.count,
1251
+ artifactBlobCount: blobRow.count,
1252
+ sharedArtifactBlobCount: sharedBlobRow.count,
1253
+ orphanArtifactBlobCount: orphanBlobRow.count,
1254
+ worktreeCount: worktreeRow.count,
1255
+ pinnedSessionCount: pinnedRow.count,
1256
+ };
1257
+ }
1258
+
1259
+ async grep(input: {
1260
+ query: string;
1261
+ sessionID?: string;
1262
+ scope?: string;
1263
+ limit?: number;
1264
+ }): Promise<SearchResult[]> {
1265
+ await this.prepareForRead();
1266
+ const resolvedScope = this.resolveConfiguredScope('grep', input.scope, input.sessionID);
1267
+ const sessionIDs = this.resolveScopeSessionIDs(resolvedScope, input.sessionID);
1268
+ const limit = input.limit ?? 5;
1269
+ const needle = input.query.trim();
1270
+ if (!needle) return [];
1271
+
1272
+ const ftsResults = this.searchWithFts(needle, sessionIDs, limit);
1273
+ if (ftsResults.length > 0) return ftsResults;
1274
+ return this.searchByScan(needle.toLowerCase(), sessionIDs, limit);
1275
+ }
1276
+
1277
+ async describe(input?: { sessionID?: string; scope?: string }): Promise<string> {
1278
+ await this.prepareForRead();
1279
+ const scope = this.resolveConfiguredScope('describe', input?.scope, input?.sessionID);
1280
+ const sessionID = input?.sessionID;
1281
+
1282
+ if (scope !== 'session') {
1283
+ const scopedSessions = this.readScopedSessionsSync(
1284
+ this.resolveScopeSessionIDs(scope, sessionID),
1285
+ );
1286
+ if (scopedSessions.length === 0) return 'No archived sessions yet.';
1287
+
1288
+ return [
1289
+ `Scope: ${scope}`,
1290
+ `Sessions: ${scopedSessions.length}`,
1291
+ `Latest update: ${Math.max(...scopedSessions.map((session) => session.updatedAt))}`,
1292
+ `Root sessions: ${new Set(scopedSessions.map((session) => session.rootSessionID ?? session.sessionID)).size}`,
1293
+ `Worktrees: ${new Set(scopedSessions.map((session) => normalizeWorktreeKey(session.directory)).filter(Boolean)).size}`,
1294
+ 'Matching sessions:',
1295
+ ...scopedSessions
1296
+ .sort((a, b) => b.updatedAt - a.updatedAt)
1297
+ .slice(0, 8)
1298
+ .map((session) => {
1299
+ const root = session.rootSessionID ?? session.sessionID;
1300
+ const worktree = normalizeWorktreeKey(session.directory) ?? 'unknown';
1301
+ return `- ${session.sessionID}: ${makeSessionTitle(session) ?? 'Untitled session'} (root=${root}, worktree=${worktree})`;
1302
+ }),
1303
+ ].join('\n');
1304
+ }
1305
+
1306
+ if (!sessionID) {
1307
+ const sessions = this.readAllSessionsSync();
1308
+ if (sessions.length === 0) return 'No archived sessions yet.';
1309
+
1310
+ return [
1311
+ `Archived sessions: ${sessions.length}`,
1312
+ `Latest update: ${Math.max(...sessions.map((session) => session.updatedAt))}`,
1313
+ `Root sessions: ${sessions.filter((session) => !session.parentSessionID).length}`,
1314
+ `Branched sessions: ${sessions.filter((session) => Boolean(session.parentSessionID)).length}`,
1315
+ 'Recent sessions:',
1316
+ ...sessions
1317
+ .sort((a, b) => b.updatedAt - a.updatedAt)
1318
+ .slice(0, 5)
1319
+ .map(
1320
+ (session) =>
1321
+ `- ${session.sessionID}: ${makeSessionTitle(session) ?? 'Untitled session'}`,
1322
+ ),
1323
+ ].join('\n');
1324
+ }
1325
+
1326
+ const session = this.readSessionSync(sessionID);
1327
+ if (session.messages.length === 0) return 'No archived events yet.';
1328
+
1329
+ const roots = this.getSummaryRootsForSession(session);
1330
+ const userMessages = session.messages.filter((message) => message.info.role === 'user');
1331
+ const assistantMessages = session.messages.filter(
1332
+ (message) => message.info.role === 'assistant',
1333
+ );
1334
+ const files = new Set(session.messages.flatMap(listFiles));
1335
+ const recent = session.messages.slice(-5).map((message) => {
1336
+ const snippet =
1337
+ guessMessageText(message, this.options.interop.ignoreToolPrefixes) || '(no text content)';
1338
+ return `- ${message.info.role} ${message.info.id}: ${snippet}`;
1339
+ });
1340
+
1341
+ return [
1342
+ `Session: ${session.sessionID}`,
1343
+ `Title: ${makeSessionTitle(session) ?? 'Unknown'}`,
1344
+ `Directory: ${session.directory ?? 'unknown'}`,
1345
+ `Parent session: ${session.parentSessionID ?? 'none'}`,
1346
+ `Root session: ${session.rootSessionID ?? session.sessionID}`,
1347
+ `Lineage depth: ${session.lineageDepth ?? 0}`,
1348
+ `Pinned: ${session.pinned ? `yes${session.pinReason ? ` (${session.pinReason})` : ''}` : 'no'}`,
1349
+ `Messages: ${session.messages.length}`,
1350
+ `User messages: ${userMessages.length}`,
1351
+ `Assistant messages: ${assistantMessages.length}`,
1352
+ `Tracked files: ${files.size}`,
1353
+ `Summary roots: ${roots.length}`,
1354
+ `Child branches: ${this.readChildSessionsSync(session.sessionID).length}`,
1355
+ `Last updated: ${session.updatedAt}`,
1356
+ ...(roots.length > 0
1357
+ ? [
1358
+ 'Summary root previews:',
1359
+ ...roots
1360
+ .slice(0, 4)
1361
+ .map((node) => `- ${shortNodeID(node.nodeID)}: ${node.summaryText}`),
1362
+ ]
1363
+ : []),
1364
+ 'Recent entries:',
1365
+ ...recent,
1366
+ ].join('\n');
1367
+ }
1368
+
1369
+ async doctor(input?: { sessionID?: string; apply?: boolean; limit?: number }): Promise<string> {
1370
+ await this.prepareForRead();
1371
+ const limit = clamp(input?.limit ?? 10, 1, 50);
1372
+ const sessionID = input?.sessionID;
1373
+ const apply = input?.apply ?? false;
1374
+
1375
+ const before = this.collectDoctorReport(sessionID);
1376
+ if (!apply || !this.hasDoctorIssues(before)) {
1377
+ return formatDoctorReport(before, limit);
1378
+ }
1379
+
1380
+ const checkedSessions = sessionID
1381
+ ? [sessionID]
1382
+ : this.readAllSessionsSync().map((session) => session.sessionID);
1383
+ const appliedActions: string[] = [];
1384
+
1385
+ this.ensureSessionColumnsSync();
1386
+ this.ensureSummaryStateColumnsSync();
1387
+ this.ensureArtifactColumnsSync();
1388
+ appliedActions.push('ensured schema columns');
1389
+
1390
+ if (before.summarySessionsNeedingRebuild.length > 0 || before.orphanSummaryEdges > 0) {
1391
+ this.rebuildSummarySessionsSync(checkedSessions);
1392
+ appliedActions.push(`rebuilt summary DAGs for ${checkedSessions.length} checked session(s)`);
1393
+ }
1394
+
1395
+ if (before.lineageSessionsNeedingRefresh.length > 0) {
1396
+ this.refreshAllLineageSync();
1397
+ this.syncAllDerivedSessionStateSync(true);
1398
+ appliedActions.push('refreshed lineage metadata');
1399
+ }
1400
+
1401
+ if (before.orphanArtifactBlobs > 0) {
1402
+ this.backfillArtifactBlobsSync();
1403
+ const deleted = this.deleteOrphanArtifactBlobsSync();
1404
+ if (deleted.length > 0) {
1405
+ appliedActions.push(`deleted ${deleted.length} orphan artifact blob(s)`);
1406
+ }
1407
+ }
1408
+
1409
+ if (
1410
+ before.messageFts.expected !== before.messageFts.actual ||
1411
+ before.summaryFts.expected !== before.summaryFts.actual ||
1412
+ before.artifactFts.expected !== before.artifactFts.actual ||
1413
+ before.summarySessionsNeedingRebuild.length > 0 ||
1414
+ before.orphanSummaryEdges > 0
1415
+ ) {
1416
+ this.refreshSearchIndexesSync(checkedSessions);
1417
+ appliedActions.push('rebuilt FTS indexes');
1418
+ }
1419
+
1420
+ const after = this.collectDoctorReport(sessionID);
1421
+ after.status = this.hasDoctorIssues(after) ? 'issues-found' : 'repaired';
1422
+ after.appliedActions = appliedActions;
1423
+ return formatDoctorReport(after, limit);
1424
+ }
1425
+
1426
+ private collectDoctorReport(sessionID?: string): DoctorReport {
1427
+ const sessions = sessionID ? [this.readSessionSync(sessionID)] : this.readAllSessionsSync();
1428
+ const sessionIDs = sessions.map((session) => session.sessionID);
1429
+ const summarySessionsNeedingRebuild = sessions
1430
+ .map((session) => this.diagnoseSummarySession(session))
1431
+ .filter((issue): issue is DoctorSessionIssue => Boolean(issue));
1432
+ const lineageSessionsNeedingRefresh = sessions
1433
+ .filter((session) => this.needsLineageRefresh(session))
1434
+ .map((session) => session.sessionID);
1435
+
1436
+ const messageFtsExpected = sessions.reduce((count, session) => {
1437
+ return (
1438
+ count +
1439
+ session.messages.filter(
1440
+ (message) =>
1441
+ guessMessageText(message, this.options.interop.ignoreToolPrefixes).length > 0,
1442
+ ).length
1443
+ );
1444
+ }, 0);
1445
+
1446
+ const report: DoctorReport = {
1447
+ scope: sessionID ? `session:${sessionID}` : 'all',
1448
+ checkedSessions: sessions.length,
1449
+ summarySessionsNeedingRebuild,
1450
+ lineageSessionsNeedingRefresh,
1451
+ orphanSummaryEdges: this.countScopedOrphanSummaryEdges(sessionIDs),
1452
+ messageFts: {
1453
+ expected: messageFtsExpected,
1454
+ actual: this.countScopedFtsRows('message_fts', sessionIDs),
1455
+ },
1456
+ summaryFts: {
1457
+ expected: this.readScopedSummaryRowsSync(sessionIDs).length,
1458
+ actual: this.countScopedFtsRows('summary_fts', sessionIDs),
1459
+ },
1460
+ artifactFts: {
1461
+ expected: this.readScopedArtifactRowsSync(sessionIDs).length,
1462
+ actual: this.countScopedFtsRows('artifact_fts', sessionIDs),
1463
+ },
1464
+ orphanArtifactBlobs: this.readOrphanArtifactBlobRowsSync().length,
1465
+ status: 'clean',
1466
+ };
1467
+
1468
+ report.status = this.hasDoctorIssues(report) ? 'issues-found' : 'clean';
1469
+ return report;
1470
+ }
1471
+
1472
+ private hasDoctorIssues(report: DoctorReport): boolean {
1473
+ return (
1474
+ report.summarySessionsNeedingRebuild.length > 0 ||
1475
+ report.lineageSessionsNeedingRefresh.length > 0 ||
1476
+ report.orphanSummaryEdges > 0 ||
1477
+ report.messageFts.expected !== report.messageFts.actual ||
1478
+ report.summaryFts.expected !== report.summaryFts.actual ||
1479
+ report.artifactFts.expected !== report.artifactFts.actual ||
1480
+ report.orphanArtifactBlobs > 0
1481
+ );
1482
+ }
1483
+
1484
+ private diagnoseSummarySession(session: NormalizedSession): DoctorSessionIssue | undefined {
1485
+ const issues: string[] = [];
1486
+ const archived = this.getArchivedMessages(session.messages);
1487
+ const state = safeQueryOne<SummaryStateRow>(
1488
+ this.getDb().prepare('SELECT * FROM summary_state WHERE session_id = ?'),
1489
+ [session.sessionID],
1490
+ 'diagnoseSummarySession',
1491
+ );
1492
+ const summaryNodeCount = safeQueryOne<{ count: number }>(
1493
+ this.getDb().prepare('SELECT COUNT(*) AS count FROM summary_nodes WHERE session_id = ?'),
1494
+ [session.sessionID],
1495
+ 'diagnoseSummarySession.nodeCount',
1496
+ ) ?? { count: 0 };
1497
+ const summaryEdgeCount = safeQueryOne<{ count: number }>(
1498
+ this.getDb().prepare('SELECT COUNT(*) AS count FROM summary_edges WHERE session_id = ?'),
1499
+ [session.sessionID],
1500
+ 'diagnoseSummarySession.edgeCount',
1501
+ ) ?? { count: 0 };
1502
+
1503
+ if (archived.length === 0) {
1504
+ if (state) issues.push('unexpected-summary-state');
1505
+ if (summaryNodeCount.count > 0) issues.push('unexpected-summary-nodes');
1506
+ if (summaryEdgeCount.count > 0) issues.push('unexpected-summary-edges');
1507
+ return issues.length > 0 ? { sessionID: session.sessionID, issues } : undefined;
1508
+ }
1509
+
1510
+ const latestMessageCreated = archived.at(-1)?.info.time.created ?? 0;
1511
+ const archivedSignature = this.buildArchivedSignature(archived);
1512
+ const rootIDs = state ? parseJson<string[]>(state.root_node_ids_json) : [];
1513
+ const roots = rootIDs
1514
+ .map((nodeID) => this.readSummaryNodeSync(nodeID))
1515
+ .filter((node): node is SummaryNodeData => Boolean(node));
1516
+
1517
+ if (!state) {
1518
+ issues.push('missing-summary-state');
1519
+ } else {
1520
+ if (state.archived_count !== archived.length) issues.push('archived-count-mismatch');
1521
+ if (state.latest_message_created !== latestMessageCreated)
1522
+ issues.push('latest-message-mismatch');
1523
+ if (state.archived_signature !== archivedSignature)
1524
+ issues.push('archived-signature-mismatch');
1525
+ if (rootIDs.length === 0) issues.push('missing-root-node-ids');
1526
+ if (roots.length !== rootIDs.length) {
1527
+ issues.push('missing-root-node-record');
1528
+ } else if (
1529
+ rootIDs.length > 0 &&
1530
+ !this.canReuseSummaryGraphSync(session.sessionID, archived, roots)
1531
+ ) {
1532
+ issues.push('invalid-summary-graph');
1533
+ }
1534
+ }
1535
+
1536
+ if (summaryNodeCount.count === 0) issues.push('missing-summary-nodes');
1537
+ return issues.length > 0 ? { sessionID: session.sessionID, issues } : undefined;
1538
+ }
1539
+
1540
+ private needsLineageRefresh(session: NormalizedSession): boolean {
1541
+ const chain = this.readLineageChainSync(session.sessionID);
1542
+ const expectedRoot = chain[0]?.sessionID ?? session.sessionID;
1543
+ const expectedDepth = Math.max(0, chain.length - 1);
1544
+ return (
1545
+ (session.rootSessionID ?? session.sessionID) !== expectedRoot ||
1546
+ (session.lineageDepth ?? 0) !== expectedDepth
1547
+ );
1548
+ }
1549
+
1550
+ private rebuildSummarySessionsSync(sessionIDs: string[]): void {
1551
+ for (const sessionID of sessionIDs) {
1552
+ const session = this.readSessionSync(sessionID);
1553
+ this.ensureSummaryGraphSync(sessionID, this.getArchivedMessages(session.messages));
1554
+ }
1555
+ }
1556
+
1557
+ private countScopedFtsRows(
1558
+ table: 'message_fts' | 'summary_fts' | 'artifact_fts',
1559
+ sessionIDs?: string[],
1560
+ ): number {
1561
+ if (sessionIDs && sessionIDs.length === 0) return 0;
1562
+
1563
+ if (!sessionIDs) {
1564
+ const row = this.getDb().prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as {
1565
+ count: number;
1566
+ };
1567
+ return row.count;
1568
+ }
1569
+
1570
+ const placeholders = sessionIDs.map(() => '?').join(', ');
1571
+ const row = this.getDb()
1572
+ .prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE session_id IN (${placeholders})`)
1573
+ .get(...sessionIDs) as { count: number };
1574
+ return row.count;
1575
+ }
1576
+
1577
+ private countScopedOrphanSummaryEdges(sessionIDs?: string[]): number {
1578
+ if (sessionIDs && sessionIDs.length === 0) return 0;
1579
+
1580
+ const scopeClause = sessionIDs
1581
+ ? `e.session_id IN (${sessionIDs.map(() => '?').join(', ')}) AND `
1582
+ : '';
1583
+ const row = this.getDb()
1584
+ .prepare(
1585
+ `SELECT COUNT(*) AS count
1586
+ FROM summary_edges e
1587
+ WHERE ${scopeClause}(
1588
+ NOT EXISTS (SELECT 1 FROM summary_nodes parent WHERE parent.node_id = e.parent_id)
1589
+ OR NOT EXISTS (SELECT 1 FROM summary_nodes child WHERE child.node_id = e.child_id)
1590
+ )`,
1591
+ )
1592
+ .get(...(sessionIDs ?? [])) as { count: number };
1593
+ return row.count;
1594
+ }
1595
+
1596
+ private shouldRefreshLineageForEvent(eventType: string): boolean {
1597
+ return (
1598
+ eventType === 'session.created' ||
1599
+ eventType === 'session.updated' ||
1600
+ eventType === 'session.deleted'
1601
+ );
1602
+ }
1603
+
1604
+ private shouldPersistSessionForEvent(eventType: string): boolean {
1605
+ return (
1606
+ eventType === 'session.created' ||
1607
+ eventType === 'session.updated' ||
1608
+ eventType === 'session.deleted' ||
1609
+ eventType === 'session.compacted' ||
1610
+ eventType === 'message.updated' ||
1611
+ eventType === 'message.removed' ||
1612
+ eventType === 'message.part.updated' ||
1613
+ eventType === 'message.part.removed'
1614
+ );
1615
+ }
1616
+
1617
+ private shouldRecordEvent(eventType: string): boolean {
1618
+ if (this.shouldPersistSessionForEvent(eventType)) return true;
1619
+
1620
+ return (
1621
+ eventType === 'session.error' ||
1622
+ eventType === 'permission.asked' ||
1623
+ eventType === 'permission.replied' ||
1624
+ eventType === 'question.asked' ||
1625
+ eventType === 'question.replied'
1626
+ );
1627
+ }
1628
+
1629
+ private shouldSyncDerivedLineageSubtree(
1630
+ eventType: string,
1631
+ previousParentSessionID?: string,
1632
+ nextParentSessionID?: string,
1633
+ ): boolean {
1634
+ return (
1635
+ eventType === 'session.created' ||
1636
+ (eventType === 'session.updated' && previousParentSessionID !== nextParentSessionID)
1637
+ );
1638
+ }
1639
+
1640
+ private shouldCleanupOrphanBlobsForEvent(eventType: string): boolean {
1641
+ return (
1642
+ eventType === 'message.removed' ||
1643
+ eventType === 'message.part.updated' ||
1644
+ eventType === 'message.part.removed'
1645
+ );
1646
+ }
1647
+
1648
+ private captureArtifactHydrationMessageIDs(event: CapturedEvent): string[] {
1649
+ const payload = event.payload as Event;
1650
+
1651
+ switch (payload.type) {
1652
+ case 'message.updated':
1653
+ return [payload.properties.info.id];
1654
+ case 'message.part.updated':
1655
+ return [payload.properties.part.messageID];
1656
+ case 'message.part.removed':
1657
+ return [payload.properties.messageID];
1658
+ default:
1659
+ return [];
1660
+ }
1661
+ }
1662
+
1663
+ private archivedMessageIDs(messages: ConversationMessage[]): string[] {
1664
+ return this.getArchivedMessages(messages).map((message) => message.info.id);
1665
+ }
1666
+
1667
+ private didArchivedMessagesChange(
1668
+ before: ConversationMessage[],
1669
+ after: ConversationMessage[],
1670
+ ): boolean {
1671
+ const beforeIDs = this.archivedMessageIDs(before);
1672
+ const afterIDs = this.archivedMessageIDs(after);
1673
+ if (beforeIDs.length !== afterIDs.length) return true;
1674
+ return beforeIDs.some((messageID, index) => messageID !== afterIDs[index]);
1675
+ }
1676
+
1677
+ private isArchivedMessage(messages: ConversationMessage[], messageID?: string): boolean {
1678
+ if (!messageID) return false;
1679
+ return this.archivedMessageIDs(messages).includes(messageID);
1680
+ }
1681
+
1682
+ private shouldSyncDerivedSessionStateForEvent(
1683
+ previous: NormalizedSession,
1684
+ next: NormalizedSession,
1685
+ event: CapturedEvent,
1686
+ ): boolean {
1687
+ const payload = event.payload as Event;
1688
+
1689
+ switch (payload.type) {
1690
+ case 'message.updated': {
1691
+ const messageID = payload.properties.info.id;
1692
+ return (
1693
+ this.didArchivedMessagesChange(previous.messages, next.messages) ||
1694
+ this.isArchivedMessage(previous.messages, messageID) ||
1695
+ this.isArchivedMessage(next.messages, messageID)
1696
+ );
1697
+ }
1698
+ case 'message.removed':
1699
+ return this.didArchivedMessagesChange(previous.messages, next.messages);
1700
+ case 'message.part.updated': {
1701
+ const messageID = payload.properties.part.messageID;
1702
+ return (
1703
+ this.isArchivedMessage(previous.messages, messageID) ||
1704
+ this.isArchivedMessage(next.messages, messageID)
1705
+ );
1706
+ }
1707
+ case 'message.part.removed': {
1708
+ const messageID = payload.properties.messageID;
1709
+ return (
1710
+ this.isArchivedMessage(previous.messages, messageID) ||
1711
+ this.isArchivedMessage(next.messages, messageID)
1712
+ );
1713
+ }
1714
+ default:
1715
+ return false;
1716
+ }
1717
+ }
1718
+
1719
+ private syncAllDerivedSessionStateSync(preserveExistingResume = false): void {
1720
+ for (const session of this.readAllSessionsSync()) {
1721
+ this.syncDerivedSessionStateSync(session, preserveExistingResume);
1722
+ }
1723
+ }
1724
+
1725
+ private syncDerivedSessionStateSync(
1726
+ session: NormalizedSession,
1727
+ preserveExistingResume = false,
1728
+ ): SummaryNodeData[] {
1729
+ const roots = this.ensureSummaryGraphSync(
1730
+ session.sessionID,
1731
+ this.getArchivedMessages(session.messages),
1732
+ );
1733
+ this.writeResumeSync(session, roots, preserveExistingResume);
1734
+ return roots;
1735
+ }
1736
+
1737
+ private syncDerivedLineageSubtreeSync(sessionID: string, preserveExistingResume = false): void {
1738
+ const queue = [sessionID];
1739
+ const seen = new Set<string>([sessionID]);
1740
+
1741
+ while (queue.length > 0) {
1742
+ const currentSessionID = queue.shift();
1743
+ if (!currentSessionID) continue;
1744
+
1745
+ if (currentSessionID !== sessionID) {
1746
+ this.syncDerivedSessionStateSync(
1747
+ this.readSessionSync(currentSessionID),
1748
+ preserveExistingResume,
1749
+ );
1750
+ }
1751
+
1752
+ for (const child of this.readChildSessionsSync(currentSessionID)) {
1753
+ if (seen.has(child.sessionID)) continue;
1754
+ seen.add(child.sessionID);
1755
+ queue.push(child.sessionID);
1756
+ }
1757
+ }
1758
+ }
1759
+
1760
+ private writeResumeSync(
1761
+ session: NormalizedSession,
1762
+ roots: SummaryNodeData[],
1763
+ preserveExistingResume = false,
1764
+ ): void {
1765
+ const db = this.getDb();
1766
+ if (session.messages.length === 0) {
1767
+ db.prepare('DELETE FROM resumes WHERE session_id = ?').run(session.sessionID);
1768
+ return;
1769
+ }
1770
+
1771
+ const existing = this.getResumeSync(session.sessionID);
1772
+ if (preserveExistingResume && existing && !this.isManagedResumeNote(existing)) {
1773
+ return;
1774
+ }
1775
+
1776
+ const note = this.buildResumeNote(session, roots);
1777
+ db.prepare(
1778
+ `INSERT INTO resumes (session_id, note, updated_at)
1779
+ VALUES (?, ?, ?)
1780
+ ON CONFLICT(session_id) DO UPDATE SET note = excluded.note, updated_at = excluded.updated_at`,
1781
+ ).run(session.sessionID, note, Date.now());
1782
+ }
1783
+
1784
+ private isManagedResumeNote(note: string): boolean {
1785
+ return note.startsWith('LCM prototype resume note\n') || note === 'LCM prototype resume note';
1786
+ }
1787
+
1788
+ private resolveRetentionPolicy(input?: {
1789
+ staleSessionDays?: number;
1790
+ deletedSessionDays?: number;
1791
+ orphanBlobDays?: number;
1792
+ }): ResolvedRetentionPolicy {
1793
+ return {
1794
+ staleSessionDays: input?.staleSessionDays ?? this.options.retention.staleSessionDays,
1795
+ deletedSessionDays: input?.deletedSessionDays ?? this.options.retention.deletedSessionDays,
1796
+ orphanBlobDays: input?.orphanBlobDays ?? this.options.retention.orphanBlobDays,
1797
+ };
1798
+ }
1799
+
1800
+ private retentionCutoff(days: number): number {
1801
+ return Date.now() - days * 24 * 60 * 60 * 1000;
1802
+ }
1803
+
1804
+ private applyRetentionPruneSync(input?: {
1805
+ staleSessionDays?: number;
1806
+ deletedSessionDays?: number;
1807
+ orphanBlobDays?: number;
1808
+ apply?: boolean;
1809
+ }): { deletedSessions: number; deletedBlobs: number; deletedBlobChars: number } {
1810
+ const policy = this.resolveRetentionPolicy(input);
1811
+
1812
+ if (input?.apply === false) {
1813
+ return { deletedSessions: 0, deletedBlobs: 0, deletedBlobChars: 0 };
1814
+ }
1815
+
1816
+ const staleSessions =
1817
+ policy.staleSessionDays === undefined
1818
+ ? []
1819
+ : this.readSessionRetentionCandidates(false, policy.staleSessionDays);
1820
+ const deletedSessions =
1821
+ policy.deletedSessionDays === undefined
1822
+ ? []
1823
+ : this.readSessionRetentionCandidates(true, policy.deletedSessionDays);
1824
+ const combinedSessions = [...staleSessions, ...deletedSessions];
1825
+ const uniqueSessionIDs = [...new Set(combinedSessions.map((row) => row.session_id))];
1826
+ const initialOrphanBlobs =
1827
+ policy.orphanBlobDays === undefined
1828
+ ? []
1829
+ : this.readOrphanBlobRetentionCandidates(policy.orphanBlobDays);
1830
+
1831
+ if (uniqueSessionIDs.length === 0 && initialOrphanBlobs.length === 0) {
1832
+ return { deletedSessions: 0, deletedBlobs: 0, deletedBlobChars: 0 };
1833
+ }
1834
+
1835
+ const db = this.getDb();
1836
+ let deletedBlobs: RetentionBlobCandidate[] = [];
1837
+ withTransaction(db, 'retentionPrune', () => {
1838
+ for (const sessionID of uniqueSessionIDs) {
1839
+ this.clearSessionDataSync(sessionID);
1840
+ }
1841
+
1842
+ deletedBlobs =
1843
+ policy.orphanBlobDays === undefined
1844
+ ? []
1845
+ : this.readOrphanBlobRetentionCandidates(policy.orphanBlobDays);
1846
+ if (deletedBlobs.length > 0) {
1847
+ const deleteBlob = db.prepare('DELETE FROM artifact_blobs WHERE content_hash = ?');
1848
+ for (const blob of deletedBlobs) deleteBlob.run(blob.content_hash);
1849
+ }
1850
+ });
1851
+
1852
+ if (uniqueSessionIDs.length > 0) {
1853
+ this.refreshAllLineageSync();
1854
+ this.syncAllDerivedSessionStateSync(true);
1855
+ this.refreshSearchIndexesSync();
1856
+ }
1857
+
1858
+ return {
1859
+ deletedSessions: uniqueSessionIDs.length,
1860
+ deletedBlobs: deletedBlobs.length,
1861
+ deletedBlobChars: deletedBlobs.reduce((sum, row) => sum + row.char_count, 0),
1862
+ };
1863
+ }
1864
+
1865
+ private formatRetentionSessionCandidate(row: RetentionSessionCandidate): string {
1866
+ const title = row.title ?? 'Untitled session';
1867
+ const worktree = normalizeWorktreeKey(row.session_directory ?? undefined) ?? 'unknown';
1868
+ const root = row.root_session_id ?? row.session_id;
1869
+ return `- ${row.session_id} pinned=${row.pinned === 1 ? 'true' : 'false'} deleted=${row.deleted === 1 ? 'true' : 'false'} updated_at=${row.updated_at} messages=${row.message_count} artifacts=${row.artifact_count} root=${root} worktree=${worktree} title=${title}`;
1870
+ }
1871
+
1872
+ private readSessionRetentionCandidates(
1873
+ deleted: boolean,
1874
+ days: number,
1875
+ limit?: number,
1876
+ ): RetentionSessionCandidate[] {
1877
+ const params: Array<number | string> = [this.retentionCutoff(days), deleted ? 1 : 0];
1878
+ const sql = `
1879
+ SELECT
1880
+ s.session_id,
1881
+ s.title,
1882
+ s.session_directory,
1883
+ s.root_session_id,
1884
+ s.pinned,
1885
+ s.deleted,
1886
+ s.updated_at,
1887
+ s.event_count,
1888
+ (SELECT COUNT(*) FROM messages m WHERE m.session_id = s.session_id) AS message_count,
1889
+ (SELECT COUNT(*) FROM artifacts a WHERE a.session_id = s.session_id) AS artifact_count
1890
+ FROM sessions s
1891
+ WHERE s.updated_at <= ?
1892
+ AND s.deleted = ?
1893
+ AND s.pinned = 0
1894
+ AND NOT EXISTS (
1895
+ SELECT 1 FROM sessions child WHERE child.parent_session_id = s.session_id
1896
+ )
1897
+ ORDER BY s.updated_at ASC
1898
+ ${limit ? 'LIMIT ?' : ''}`;
1899
+
1900
+ if (limit) params.push(limit);
1901
+ return this.getDb()
1902
+ .prepare(sql)
1903
+ .all(...params) as RetentionSessionCandidate[];
1904
+ }
1905
+
1906
+ private countSessionRetentionCandidates(deleted: boolean, days: number): number {
1907
+ const row = this.getDb()
1908
+ .prepare(
1909
+ `SELECT COUNT(*) AS count
1910
+ FROM sessions s
1911
+ WHERE s.updated_at <= ?
1912
+ AND s.deleted = ?
1913
+ AND s.pinned = 0
1914
+ AND NOT EXISTS (
1915
+ SELECT 1 FROM sessions child WHERE child.parent_session_id = s.session_id
1916
+ )`,
1917
+ )
1918
+ .get(this.retentionCutoff(days), deleted ? 1 : 0) as { count: number };
1919
+ return row.count;
1920
+ }
1921
+
1922
+ private readOrphanBlobRetentionCandidates(
1923
+ days: number,
1924
+ limit?: number,
1925
+ ): RetentionBlobCandidate[] {
1926
+ const params: Array<number> = [this.retentionCutoff(days)];
1927
+ const sql = `
1928
+ SELECT content_hash, char_count, created_at
1929
+ FROM artifact_blobs b
1930
+ WHERE b.created_at <= ?
1931
+ AND NOT EXISTS (
1932
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
1933
+ )
1934
+ ORDER BY char_count DESC, created_at ASC
1935
+ ${limit ? 'LIMIT ?' : ''}`;
1936
+ if (limit) params.push(limit);
1937
+ return this.getDb()
1938
+ .prepare(sql)
1939
+ .all(...params) as RetentionBlobCandidate[];
1940
+ }
1941
+
1942
+ private countOrphanBlobRetentionCandidates(days: number): number {
1943
+ const row = this.getDb()
1944
+ .prepare(
1945
+ `SELECT COUNT(*) AS count
1946
+ FROM artifact_blobs b
1947
+ WHERE b.created_at <= ?
1948
+ AND NOT EXISTS (
1949
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
1950
+ )`,
1951
+ )
1952
+ .get(this.retentionCutoff(days)) as { count: number };
1953
+ return row.count;
1954
+ }
1955
+
1956
+ private sumOrphanBlobRetentionChars(days: number): number {
1957
+ const row = this.getDb()
1958
+ .prepare(
1959
+ `SELECT COALESCE(SUM(char_count), 0) AS chars
1960
+ FROM artifact_blobs b
1961
+ WHERE b.created_at <= ?
1962
+ AND NOT EXISTS (
1963
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
1964
+ )`,
1965
+ )
1966
+ .get(this.retentionCutoff(days)) as { chars: number };
1967
+ return row.chars;
1968
+ }
1969
+
1970
+ private normalizeScope(scope?: string): SnapshotScope | undefined {
1971
+ if (scope === 'session' || scope === 'root' || scope === 'worktree' || scope === 'all')
1972
+ return scope;
1973
+ return undefined;
1974
+ }
1975
+
1976
+ private resolveConfiguredScope(
1977
+ operation: 'grep' | 'describe',
1978
+ explicitScope?: string,
1979
+ sessionID?: string,
1980
+ ): 'session' | 'root' | 'worktree' | 'all' {
1981
+ const explicit = this.normalizeScope(explicitScope);
1982
+ if (explicit) return explicit;
1983
+
1984
+ const worktreeKey = this.resolveScopeWorktreeKey(sessionID);
1985
+ if (worktreeKey) {
1986
+ const profile = this.options.scopeProfiles.find(
1987
+ (entry) => normalizeWorktreeKey(entry.worktree) === worktreeKey,
1988
+ );
1989
+ if (profile?.[operation]) return profile[operation];
1990
+ }
1991
+
1992
+ return this.options.scopeDefaults[operation];
1993
+ }
1994
+
1995
+ private resolveScopeWorktreeKey(sessionID?: string): string | undefined {
1996
+ if (sessionID) {
1997
+ const session = this.readSessionHeaderSync(sessionID);
1998
+ const sessionWorktree = normalizeWorktreeKey(session?.directory);
1999
+ if (sessionWorktree) return sessionWorktree;
2000
+ }
2001
+
2002
+ return normalizeWorktreeKey(this.workspaceDirectory);
2003
+ }
2004
+
2005
+ private resolveScopeSessionIDs(scope?: string, sessionID?: string): string[] | undefined {
2006
+ const normalizedScope = this.normalizeScope(scope) ?? this.options.scopeDefaults.grep;
2007
+ if (normalizedScope === 'all') return undefined;
2008
+
2009
+ const resolvedSessionID = sessionID ?? this.latestSessionIDSync();
2010
+ if (!resolvedSessionID) return [];
2011
+ if (normalizedScope === 'session') return [resolvedSessionID];
2012
+
2013
+ const session = this.readSessionHeaderSync(resolvedSessionID);
2014
+ if (!session) return [];
2015
+
2016
+ if (normalizedScope === 'root') {
2017
+ const rootSessionID = session.rootSessionID ?? session.sessionID;
2018
+ const rows = this.getDb()
2019
+ .prepare(
2020
+ 'SELECT session_id FROM sessions WHERE root_session_id = ? OR session_id = ? ORDER BY updated_at DESC',
2021
+ )
2022
+ .all(rootSessionID, rootSessionID) as Array<{ session_id: string }>;
2023
+ return [...new Set(rows.map((row) => row.session_id))];
2024
+ }
2025
+
2026
+ const worktreeKey = normalizeWorktreeKey(session.directory);
2027
+ if (!worktreeKey) return [resolvedSessionID];
2028
+ const rows = this.getDb()
2029
+ .prepare('SELECT session_id FROM sessions WHERE worktree_key = ? ORDER BY updated_at DESC')
2030
+ .all(worktreeKey) as Array<{ session_id: string }>;
2031
+ return [...new Set(rows.map((row) => row.session_id))];
2032
+ }
2033
+
2034
+ private readScopedSessionRowsSync(sessionIDs?: string[]): SessionRow[] {
2035
+ if (!sessionIDs) {
2036
+ return this.getDb()
2037
+ .prepare('SELECT * FROM sessions ORDER BY updated_at DESC')
2038
+ .all() as SessionRow[];
2039
+ }
2040
+ if (sessionIDs.length === 0) return [];
2041
+
2042
+ return this.getDb()
2043
+ .prepare(
2044
+ `SELECT * FROM sessions WHERE session_id IN (${sessionIDs.map(() => '?').join(', ')}) ORDER BY updated_at DESC`,
2045
+ )
2046
+ .all(...sessionIDs) as SessionRow[];
2047
+ }
2048
+
2049
+ private readScopedMessageRowsSync(sessionIDs?: string[]): MessageRow[] {
2050
+ if (!sessionIDs) {
2051
+ return this.getDb()
2052
+ .prepare('SELECT * FROM messages ORDER BY created_at ASC, message_id ASC')
2053
+ .all() as MessageRow[];
2054
+ }
2055
+ if (sessionIDs.length === 0) return [];
2056
+
2057
+ return this.getDb()
2058
+ .prepare(
2059
+ `SELECT * FROM messages WHERE session_id IN (${sessionIDs.map(() => '?').join(', ')}) ORDER BY created_at ASC, message_id ASC`,
2060
+ )
2061
+ .all(...sessionIDs) as MessageRow[];
2062
+ }
2063
+
2064
+ private readScopedPartRowsSync(sessionIDs?: string[]): PartRow[] {
2065
+ if (!sessionIDs) {
2066
+ return this.getDb()
2067
+ .prepare('SELECT * FROM parts ORDER BY message_id ASC, sort_key ASC, part_id ASC')
2068
+ .all() as PartRow[];
2069
+ }
2070
+ if (sessionIDs.length === 0) return [];
2071
+
2072
+ return this.getDb()
2073
+ .prepare(
2074
+ `SELECT * FROM parts WHERE session_id IN (${sessionIDs.map(() => '?').join(', ')}) ORDER BY message_id ASC, sort_key ASC, part_id ASC`,
2075
+ )
2076
+ .all(...sessionIDs) as PartRow[];
2077
+ }
2078
+
2079
+ private readScopedResumeRowsSync(
2080
+ sessionIDs?: string[],
2081
+ ): Array<{ session_id: string; note: string; updated_at: number }> {
2082
+ if (!sessionIDs) {
2083
+ return this.getDb().prepare('SELECT * FROM resumes ORDER BY updated_at DESC').all() as Array<{
2084
+ session_id: string;
2085
+ note: string;
2086
+ updated_at: number;
2087
+ }>;
2088
+ }
2089
+ if (sessionIDs.length === 0) return [];
2090
+
2091
+ return this.getDb()
2092
+ .prepare(
2093
+ `SELECT * FROM resumes WHERE session_id IN (${sessionIDs.map(() => '?').join(', ')}) ORDER BY updated_at DESC`,
2094
+ )
2095
+ .all(...sessionIDs) as Array<{ session_id: string; note: string; updated_at: number }>;
2096
+ }
2097
+
2098
+ private readScopedSessionsSync(sessionIDs?: string[]): NormalizedSession[] {
2099
+ if (!sessionIDs) return this.readAllSessionsSync();
2100
+ if (sessionIDs.length === 0) return [];
2101
+ if (sessionIDs.length <= 1) return sessionIDs.map((id) => this.readSessionSync(id));
2102
+
2103
+ return this.readSessionsBatchSync(sessionIDs).filter(
2104
+ (session) => session.messages.length > 0 || session.eventCount > 0,
2105
+ );
2106
+ }
2107
+
2108
+ private readScopedSummaryRowsSync(sessionIDs?: string[]): SummaryNodeRow[] {
2109
+ if (!sessionIDs) {
2110
+ return this.getDb()
2111
+ .prepare('SELECT * FROM summary_nodes ORDER BY created_at DESC')
2112
+ .all() as SummaryNodeRow[];
2113
+ }
2114
+ if (sessionIDs.length === 0) return [];
2115
+
2116
+ return this.getDb()
2117
+ .prepare(
2118
+ `SELECT * FROM summary_nodes WHERE session_id IN (${sessionIDs.map(() => '?').join(', ')}) ORDER BY created_at DESC`,
2119
+ )
2120
+ .all(...sessionIDs) as SummaryNodeRow[];
2121
+ }
2122
+
2123
+ private readScopedSummaryEdgeRowsSync(sessionIDs?: string[]): SummaryEdgeRow[] {
2124
+ if (!sessionIDs) {
2125
+ return this.getDb()
2126
+ .prepare(
2127
+ 'SELECT * FROM summary_edges ORDER BY session_id ASC, parent_id ASC, child_position ASC',
2128
+ )
2129
+ .all() as SummaryEdgeRow[];
2130
+ }
2131
+ if (sessionIDs.length === 0) return [];
2132
+
2133
+ return this.getDb()
2134
+ .prepare(
2135
+ `SELECT * FROM summary_edges WHERE session_id IN (${sessionIDs.map(() => '?').join(', ')}) ORDER BY session_id ASC, parent_id ASC, child_position ASC`,
2136
+ )
2137
+ .all(...sessionIDs) as SummaryEdgeRow[];
2138
+ }
2139
+
2140
+ private readScopedSummaryStateRowsSync(sessionIDs?: string[]): SummaryStateRow[] {
2141
+ if (!sessionIDs) {
2142
+ return this.getDb()
2143
+ .prepare('SELECT * FROM summary_state ORDER BY updated_at DESC')
2144
+ .all() as SummaryStateRow[];
2145
+ }
2146
+ if (sessionIDs.length === 0) return [];
2147
+
2148
+ return this.getDb()
2149
+ .prepare(
2150
+ `SELECT * FROM summary_state WHERE session_id IN (${sessionIDs.map(() => '?').join(', ')}) ORDER BY updated_at DESC`,
2151
+ )
2152
+ .all(...sessionIDs) as SummaryStateRow[];
2153
+ }
2154
+
2155
+ private readScopedArtifactRowsSync(sessionIDs?: string[]): ArtifactRow[] {
2156
+ if (!sessionIDs) {
2157
+ return this.getDb()
2158
+ .prepare('SELECT * FROM artifacts ORDER BY created_at DESC')
2159
+ .all() as ArtifactRow[];
2160
+ }
2161
+ if (sessionIDs.length === 0) return [];
2162
+
2163
+ return this.getDb()
2164
+ .prepare(
2165
+ `SELECT * FROM artifacts WHERE session_id IN (${sessionIDs.map(() => '?').join(', ')}) ORDER BY created_at DESC`,
2166
+ )
2167
+ .all(...sessionIDs) as ArtifactRow[];
2168
+ }
2169
+
2170
+ private readScopedArtifactBlobRowsSync(sessionIDs?: string[]): ArtifactBlobRow[] {
2171
+ if (!sessionIDs) {
2172
+ return this.getDb()
2173
+ .prepare('SELECT * FROM artifact_blobs ORDER BY created_at ASC')
2174
+ .all() as ArtifactBlobRow[];
2175
+ }
2176
+ if (sessionIDs.length === 0) return [];
2177
+
2178
+ return this.getDb()
2179
+ .prepare(
2180
+ `SELECT DISTINCT b.*
2181
+ FROM artifact_blobs b
2182
+ JOIN artifacts a ON a.content_hash = b.content_hash
2183
+ WHERE a.session_id IN (${sessionIDs.map(() => '?').join(', ')})
2184
+ ORDER BY b.created_at ASC`,
2185
+ )
2186
+ .all(...sessionIDs) as ArtifactBlobRow[];
2187
+ }
2188
+
2189
+ async lineage(sessionID?: string): Promise<string> {
2190
+ await this.prepareForRead();
2191
+ const resolvedSessionID = sessionID ?? this.latestSessionIDSync();
2192
+ if (!resolvedSessionID) return 'No archived sessions yet.';
2193
+
2194
+ const session = this.readSessionSync(resolvedSessionID);
2195
+ const chain = this.readLineageChainSync(resolvedSessionID);
2196
+ const children = this.readChildSessionsSync(resolvedSessionID);
2197
+ const siblings = session.parentSessionID
2198
+ ? this.readChildSessionsSync(session.parentSessionID).filter(
2199
+ (child) => child.sessionID !== resolvedSessionID,
2200
+ )
2201
+ : [];
2202
+
2203
+ return [
2204
+ `Session: ${session.sessionID}`,
2205
+ `Title: ${makeSessionTitle(session) ?? 'Unknown'}`,
2206
+ `Worktree: ${normalizeWorktreeKey(session.directory) ?? 'unknown'}`,
2207
+ `Root session: ${session.rootSessionID ?? session.sessionID}`,
2208
+ `Parent session: ${session.parentSessionID ?? 'none'}`,
2209
+ `Lineage depth: ${session.lineageDepth ?? 0}`,
2210
+ 'Lineage chain:',
2211
+ ...chain.map(
2212
+ (entry, index) =>
2213
+ `${entry.sessionID === resolvedSessionID ? '*' : '-'} depth=${index} ${entry.sessionID}: ${makeSessionTitle(entry) ?? 'Untitled session'}`,
2214
+ ),
2215
+ ...(siblings.length > 0
2216
+ ? [
2217
+ 'Sibling branches:',
2218
+ ...siblings.map(
2219
+ (entry) => `- ${entry.sessionID}: ${makeSessionTitle(entry) ?? 'Untitled session'}`,
2220
+ ),
2221
+ ]
2222
+ : []),
2223
+ ...(children.length > 0
2224
+ ? [
2225
+ 'Child branches:',
2226
+ ...children.map(
2227
+ (entry) => `- ${entry.sessionID}: ${makeSessionTitle(entry) ?? 'Untitled session'}`,
2228
+ ),
2229
+ ]
2230
+ : []),
2231
+ ].join('\n');
2232
+ }
2233
+
2234
+ async pinSession(input: { sessionID?: string; reason?: string }): Promise<string> {
2235
+ await this.prepareForRead();
2236
+ const sessionID = input.sessionID ?? this.latestSessionIDSync();
2237
+ if (!sessionID) return 'No archived sessions yet.';
2238
+
2239
+ const session = this.readSessionHeaderSync(sessionID);
2240
+ if (!session) return 'Unknown session.';
2241
+ const reason = input.reason?.trim() || 'Pinned by user';
2242
+
2243
+ this.getDb()
2244
+ .prepare('UPDATE sessions SET pinned = 1, pin_reason = ? WHERE session_id = ?')
2245
+ .run(reason, sessionID);
2246
+ return [`session=${sessionID}`, 'pinned=true', `reason=${reason}`].join('\n');
2247
+ }
2248
+
2249
+ async unpinSession(input: { sessionID?: string }): Promise<string> {
2250
+ await this.prepareForRead();
2251
+ const sessionID = input.sessionID ?? this.latestSessionIDSync();
2252
+ if (!sessionID) return 'No archived sessions yet.';
2253
+
2254
+ const session = this.readSessionHeaderSync(sessionID);
2255
+ if (!session) return 'Unknown session.';
2256
+ this.getDb()
2257
+ .prepare('UPDATE sessions SET pinned = 0, pin_reason = NULL WHERE session_id = ?')
2258
+ .run(sessionID);
2259
+ return [`session=${sessionID}`, 'pinned=false'].join('\n');
2260
+ }
2261
+
2262
+ async artifact(input: { artifactID: string; chars?: number }): Promise<string> {
2263
+ await this.prepareForRead();
2264
+ const artifact = this.readArtifactSync(input.artifactID);
2265
+ if (!artifact) return 'Unknown artifact.';
2266
+
2267
+ const maxChars = Math.max(
2268
+ 200,
2269
+ Math.min(this.options.artifactViewChars, input.chars ?? this.options.artifactViewChars),
2270
+ );
2271
+ return [
2272
+ `Artifact: ${artifact.artifactID}`,
2273
+ `Session: ${artifact.sessionID}`,
2274
+ `Message: ${artifact.messageID}`,
2275
+ `Part: ${artifact.partID}`,
2276
+ `Kind: ${artifact.artifactKind}`,
2277
+ `Field: ${artifact.fieldName}`,
2278
+ `Content hash: ${artifact.contentHash}`,
2279
+ `Characters: ${artifact.charCount}`,
2280
+ ...this.formatArtifactMetadataLines(artifact.metadata),
2281
+ 'Preview:',
2282
+ truncate(artifact.previewText, this.options.artifactPreviewChars),
2283
+ 'Content:',
2284
+ truncate(artifact.contentText, maxChars),
2285
+ ].join('\n');
2286
+ }
2287
+
2288
+ async blobStats(input?: { limit?: number }): Promise<string> {
2289
+ await this.prepareForRead();
2290
+ const limit = clamp(input?.limit ?? 5, 1, 20);
2291
+ const db = this.getDb();
2292
+ const totals = db
2293
+ .prepare(
2294
+ `SELECT
2295
+ COUNT(*) AS blob_count,
2296
+ COALESCE(SUM(char_count), 0) AS blob_chars,
2297
+ COALESCE(SUM(CASE WHEN EXISTS (SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash) THEN 0 ELSE char_count END), 0) AS orphan_chars
2298
+ FROM artifact_blobs b`,
2299
+ )
2300
+ .get() as { blob_count: number; blob_chars: number; orphan_chars: number };
2301
+ const referenced = db
2302
+ .prepare(
2303
+ 'SELECT COUNT(DISTINCT content_hash) AS count FROM artifacts WHERE content_hash IS NOT NULL',
2304
+ )
2305
+ .get() as { count: number };
2306
+ const sharedCount = db
2307
+ .prepare(
2308
+ `SELECT COUNT(*) AS count FROM (
2309
+ SELECT content_hash FROM artifacts
2310
+ WHERE content_hash IS NOT NULL
2311
+ GROUP BY content_hash
2312
+ HAVING COUNT(*) > 1
2313
+ )`,
2314
+ )
2315
+ .get() as { count: number };
2316
+ const orphanCount = db
2317
+ .prepare(
2318
+ `SELECT COUNT(*) AS count FROM artifact_blobs b
2319
+ WHERE NOT EXISTS (
2320
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
2321
+ )`,
2322
+ )
2323
+ .get() as { count: number };
2324
+ const shared = db
2325
+ .prepare(
2326
+ `SELECT a.content_hash AS content_hash, COUNT(*) AS ref_count, MAX(b.char_count) AS char_count
2327
+ FROM artifacts a
2328
+ JOIN artifact_blobs b ON b.content_hash = a.content_hash
2329
+ WHERE a.content_hash IS NOT NULL
2330
+ GROUP BY a.content_hash
2331
+ HAVING COUNT(*) > 1
2332
+ ORDER BY ref_count DESC, char_count DESC
2333
+ LIMIT ?`,
2334
+ )
2335
+ .all(limit) as Array<{ content_hash: string; ref_count: number; char_count: number }>;
2336
+ const orphan = db
2337
+ .prepare(
2338
+ `SELECT content_hash, char_count, created_at
2339
+ FROM artifact_blobs b
2340
+ WHERE NOT EXISTS (
2341
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
2342
+ )
2343
+ ORDER BY char_count DESC, created_at ASC
2344
+ LIMIT ?`,
2345
+ )
2346
+ .all(limit) as Array<{ content_hash: string; char_count: number; created_at: number }>;
2347
+ const saved = db
2348
+ .prepare(
2349
+ `SELECT COALESCE(SUM((ref_count - 1) * char_count), 0) AS chars_saved FROM (
2350
+ SELECT a.content_hash AS content_hash, COUNT(*) AS ref_count, MAX(b.char_count) AS char_count
2351
+ FROM artifacts a
2352
+ JOIN artifact_blobs b ON b.content_hash = a.content_hash
2353
+ WHERE a.content_hash IS NOT NULL
2354
+ GROUP BY a.content_hash
2355
+ HAVING COUNT(*) > 1
2356
+ )`,
2357
+ )
2358
+ .get() as { chars_saved: number };
2359
+
2360
+ return [
2361
+ `artifact_blobs=${totals.blob_count}`,
2362
+ `referenced_blobs=${referenced.count}`,
2363
+ `shared_blobs=${sharedCount.count}`,
2364
+ `orphan_blobs=${orphanCount.count}`,
2365
+ `blob_chars=${totals.blob_chars}`,
2366
+ `orphan_blob_chars=${totals.orphan_chars}`,
2367
+ `saved_chars_from_dedup=${saved.chars_saved}`,
2368
+ ...(shared.length > 0
2369
+ ? [
2370
+ 'top_shared_blobs:',
2371
+ ...shared.map(
2372
+ (row) =>
2373
+ `- ${row.content_hash.slice(0, 16)} refs=${row.ref_count} chars=${row.char_count}`,
2374
+ ),
2375
+ ]
2376
+ : ['top_shared_blobs:', '- none']),
2377
+ ...(orphan.length > 0
2378
+ ? [
2379
+ 'orphan_blobs_preview:',
2380
+ ...orphan.map(
2381
+ (row) =>
2382
+ `- ${row.content_hash.slice(0, 16)} chars=${row.char_count} created_at=${row.created_at}`,
2383
+ ),
2384
+ ]
2385
+ : ['orphan_blobs_preview:', '- none']),
2386
+ ].join('\n');
2387
+ }
2388
+
2389
+ private readOrphanArtifactBlobRowsSync(): RetentionBlobCandidate[] {
2390
+ return this.getDb()
2391
+ .prepare(
2392
+ `SELECT content_hash, char_count, created_at
2393
+ FROM artifact_blobs b
2394
+ WHERE NOT EXISTS (
2395
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
2396
+ )
2397
+ ORDER BY char_count DESC, created_at ASC`,
2398
+ )
2399
+ .all() as RetentionBlobCandidate[];
2400
+ }
2401
+
2402
+ private deleteOrphanArtifactBlobsSync(): RetentionBlobCandidate[] {
2403
+ const orphanRows = this.readOrphanArtifactBlobRowsSync();
2404
+ if (orphanRows.length === 0) return [];
2405
+
2406
+ this.getDb()
2407
+ .prepare(
2408
+ `DELETE FROM artifact_blobs
2409
+ WHERE NOT EXISTS (
2410
+ SELECT 1 FROM artifacts a WHERE a.content_hash = artifact_blobs.content_hash
2411
+ )`,
2412
+ )
2413
+ .run();
2414
+
2415
+ return orphanRows;
2416
+ }
2417
+
2418
+ async gcBlobs(input?: { apply?: boolean; limit?: number }): Promise<string> {
2419
+ await this.prepareForRead();
2420
+ const apply = input?.apply ?? false;
2421
+ const limit = clamp(input?.limit ?? 10, 1, 50);
2422
+ const orphanRows = this.readOrphanArtifactBlobRowsSync();
2423
+
2424
+ const totalChars = orphanRows.reduce((sum, row) => sum + row.char_count, 0);
2425
+ if (orphanRows.length === 0) {
2426
+ return ['orphan_blobs=0', 'deleted_blobs=0', 'deleted_blob_chars=0', 'status=clean'].join(
2427
+ '\n',
2428
+ );
2429
+ }
2430
+
2431
+ if (!apply) {
2432
+ return [
2433
+ `orphan_blobs=${orphanRows.length}`,
2434
+ `orphan_blob_chars=${totalChars}`,
2435
+ 'status=dry-run',
2436
+ 'preview:',
2437
+ ...orphanRows
2438
+ .slice(0, limit)
2439
+ .map(
2440
+ (row) =>
2441
+ `- ${row.content_hash.slice(0, 16)} chars=${row.char_count} created_at=${row.created_at}`,
2442
+ ),
2443
+ 'Re-run with apply=true to delete orphan blobs.',
2444
+ ].join('\n');
2445
+ }
2446
+
2447
+ this.deleteOrphanArtifactBlobsSync();
2448
+
2449
+ return [
2450
+ `orphan_blobs=${orphanRows.length}`,
2451
+ `deleted_blobs=${orphanRows.length}`,
2452
+ `deleted_blob_chars=${totalChars}`,
2453
+ 'status=applied',
2454
+ 'deleted_preview:',
2455
+ ...orphanRows
2456
+ .slice(0, limit)
2457
+ .map(
2458
+ (row) =>
2459
+ `- ${row.content_hash.slice(0, 16)} chars=${row.char_count} created_at=${row.created_at}`,
2460
+ ),
2461
+ ].join('\n');
2462
+ }
2463
+
2464
+ private readPrunableEventTypeCountsSync(): Array<{ eventType: string; count: number }> {
2465
+ const rows = this.getDb()
2466
+ .prepare(
2467
+ 'SELECT event_type, COUNT(*) AS count FROM events GROUP BY event_type ORDER BY count DESC',
2468
+ )
2469
+ .all() as Array<{ event_type: string; count: number }>;
2470
+
2471
+ return rows
2472
+ .filter((row) => !this.shouldRecordEvent(row.event_type))
2473
+ .map((row) => ({
2474
+ eventType: row.event_type,
2475
+ count: row.count,
2476
+ }));
2477
+ }
2478
+
2479
+ private async readStoreFileSizes(): Promise<{
2480
+ dbBytes: number;
2481
+ walBytes: number;
2482
+ shmBytes: number;
2483
+ totalBytes: number;
2484
+ }> {
2485
+ const readBytes = async (filePath: string): Promise<number> => {
2486
+ try {
2487
+ return (await stat(filePath)).size;
2488
+ } catch {
2489
+ return 0;
2490
+ }
2491
+ };
2492
+
2493
+ const dbBytes = await readBytes(this.dbPath);
2494
+ const walBytes = await readBytes(`${this.dbPath}-wal`);
2495
+ const shmBytes = await readBytes(`${this.dbPath}-shm`);
2496
+
2497
+ return {
2498
+ dbBytes,
2499
+ walBytes,
2500
+ shmBytes,
2501
+ totalBytes: dbBytes + walBytes + shmBytes,
2502
+ };
2503
+ }
2504
+
2505
+ async compactEventLog(input?: {
2506
+ apply?: boolean;
2507
+ vacuum?: boolean;
2508
+ limit?: number;
2509
+ }): Promise<string> {
2510
+ await this.prepareForRead();
2511
+ const apply = input?.apply ?? false;
2512
+ const vacuum = input?.vacuum ?? true;
2513
+ const limit = clamp(input?.limit ?? 10, 1, 50);
2514
+ const candidates = this.readPrunableEventTypeCountsSync();
2515
+ const candidateEvents = candidates.reduce((sum, row) => sum + row.count, 0);
2516
+ const beforeSizes = await this.readStoreFileSizes();
2517
+
2518
+ if (!apply || candidateEvents === 0) {
2519
+ return [
2520
+ `candidate_events=${candidateEvents}`,
2521
+ `apply=false`,
2522
+ `vacuum_requested=${vacuum}`,
2523
+ `db_bytes=${beforeSizes.dbBytes}`,
2524
+ `wal_bytes=${beforeSizes.walBytes}`,
2525
+ `shm_bytes=${beforeSizes.shmBytes}`,
2526
+ `total_bytes=${beforeSizes.totalBytes}`,
2527
+ ...(candidates.length > 0
2528
+ ? [
2529
+ 'candidate_event_types:',
2530
+ ...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
2531
+ ]
2532
+ : ['candidate_event_types:', '- none']),
2533
+ ].join('\n');
2534
+ }
2535
+
2536
+ const eventTypes = candidates.map((row) => row.eventType);
2537
+ if (eventTypes.length > 0) {
2538
+ const placeholders = eventTypes.map(() => '?').join(', ');
2539
+ this.getDb()
2540
+ .prepare(`DELETE FROM events WHERE event_type IN (${placeholders})`)
2541
+ .run(...eventTypes);
2542
+ }
2543
+
2544
+ let vacuumApplied = false;
2545
+ this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
2546
+ if (vacuum) {
2547
+ this.getDb().exec('VACUUM');
2548
+ this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
2549
+ vacuumApplied = true;
2550
+ }
2551
+
2552
+ const afterSizes = await this.readStoreFileSizes();
2553
+
2554
+ return [
2555
+ `candidate_events=${candidateEvents}`,
2556
+ `deleted_events=${candidateEvents}`,
2557
+ `apply=true`,
2558
+ `vacuum_requested=${vacuum}`,
2559
+ `vacuum_applied=${vacuumApplied}`,
2560
+ `db_bytes_before=${beforeSizes.dbBytes}`,
2561
+ `wal_bytes_before=${beforeSizes.walBytes}`,
2562
+ `shm_bytes_before=${beforeSizes.shmBytes}`,
2563
+ `total_bytes_before=${beforeSizes.totalBytes}`,
2564
+ `db_bytes_after=${afterSizes.dbBytes}`,
2565
+ `wal_bytes_after=${afterSizes.walBytes}`,
2566
+ `shm_bytes_after=${afterSizes.shmBytes}`,
2567
+ `total_bytes_after=${afterSizes.totalBytes}`,
2568
+ ...(candidates.length > 0
2569
+ ? [
2570
+ 'deleted_event_types:',
2571
+ ...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
2572
+ ]
2573
+ : ['deleted_event_types:', '- none']),
2574
+ ].join('\n');
2575
+ }
2576
+
2577
+ async retentionReport(input?: {
2578
+ staleSessionDays?: number;
2579
+ deletedSessionDays?: number;
2580
+ orphanBlobDays?: number;
2581
+ limit?: number;
2582
+ }): Promise<string> {
2583
+ await this.prepareForRead();
2584
+ const limit = clamp(input?.limit ?? 10, 1, 50);
2585
+ const policy = this.resolveRetentionPolicy(input);
2586
+ const staleSessions =
2587
+ policy.staleSessionDays === undefined
2588
+ ? []
2589
+ : this.readSessionRetentionCandidates(false, policy.staleSessionDays, limit);
2590
+ const deletedSessions =
2591
+ policy.deletedSessionDays === undefined
2592
+ ? []
2593
+ : this.readSessionRetentionCandidates(true, policy.deletedSessionDays, limit);
2594
+ const orphanBlobs =
2595
+ policy.orphanBlobDays === undefined
2596
+ ? []
2597
+ : this.readOrphanBlobRetentionCandidates(policy.orphanBlobDays, limit);
2598
+
2599
+ const totalStaleSessions =
2600
+ policy.staleSessionDays === undefined
2601
+ ? 0
2602
+ : this.countSessionRetentionCandidates(false, policy.staleSessionDays);
2603
+ const totalDeletedSessions =
2604
+ policy.deletedSessionDays === undefined
2605
+ ? 0
2606
+ : this.countSessionRetentionCandidates(true, policy.deletedSessionDays);
2607
+ const totalOrphanBlobs =
2608
+ policy.orphanBlobDays === undefined
2609
+ ? 0
2610
+ : this.countOrphanBlobRetentionCandidates(policy.orphanBlobDays);
2611
+ const orphanBlobChars =
2612
+ policy.orphanBlobDays === undefined
2613
+ ? 0
2614
+ : this.sumOrphanBlobRetentionChars(policy.orphanBlobDays);
2615
+
2616
+ return [
2617
+ `stale_session_days=${formatRetentionDays(policy.staleSessionDays)}`,
2618
+ `deleted_session_days=${formatRetentionDays(policy.deletedSessionDays)}`,
2619
+ `orphan_blob_days=${formatRetentionDays(policy.orphanBlobDays)}`,
2620
+ `stale_session_candidates=${totalStaleSessions}`,
2621
+ `deleted_session_candidates=${totalDeletedSessions}`,
2622
+ `orphan_blob_candidates=${totalOrphanBlobs}`,
2623
+ `orphan_blob_candidate_chars=${orphanBlobChars}`,
2624
+ ...(staleSessions.length > 0
2625
+ ? [
2626
+ 'stale_sessions_preview:',
2627
+ ...staleSessions.map((row) => this.formatRetentionSessionCandidate(row)),
2628
+ ]
2629
+ : ['stale_sessions_preview:', '- none']),
2630
+ ...(deletedSessions.length > 0
2631
+ ? [
2632
+ 'deleted_sessions_preview:',
2633
+ ...deletedSessions.map((row) => this.formatRetentionSessionCandidate(row)),
2634
+ ]
2635
+ : ['deleted_sessions_preview:', '- none']),
2636
+ ...(orphanBlobs.length > 0
2637
+ ? [
2638
+ 'orphan_blobs_preview:',
2639
+ ...orphanBlobs.map(
2640
+ (row) =>
2641
+ `- ${row.content_hash.slice(0, 16)} chars=${row.char_count} created_at=${row.created_at}`,
2642
+ ),
2643
+ ]
2644
+ : ['orphan_blobs_preview:', '- none']),
2645
+ ].join('\n');
2646
+ }
2647
+
2648
+ async retentionPrune(input?: {
2649
+ staleSessionDays?: number;
2650
+ deletedSessionDays?: number;
2651
+ orphanBlobDays?: number;
2652
+ apply?: boolean;
2653
+ limit?: number;
2654
+ }): Promise<string> {
2655
+ await this.prepareForRead();
2656
+ const apply = input?.apply ?? false;
2657
+ const limit = clamp(input?.limit ?? 10, 1, 50);
2658
+ const policy = this.resolveRetentionPolicy(input);
2659
+ const staleSessions =
2660
+ policy.staleSessionDays === undefined
2661
+ ? []
2662
+ : this.readSessionRetentionCandidates(false, policy.staleSessionDays);
2663
+ const deletedSessions =
2664
+ policy.deletedSessionDays === undefined
2665
+ ? []
2666
+ : this.readSessionRetentionCandidates(true, policy.deletedSessionDays);
2667
+ const combinedSessions = [...staleSessions, ...deletedSessions];
2668
+ const initialOrphanBlobs =
2669
+ policy.orphanBlobDays === undefined
2670
+ ? []
2671
+ : this.readOrphanBlobRetentionCandidates(policy.orphanBlobDays);
2672
+
2673
+ if (!apply) {
2674
+ return [
2675
+ `stale_session_candidates=${staleSessions.length}`,
2676
+ `deleted_session_candidates=${deletedSessions.length}`,
2677
+ `orphan_blob_candidates=${initialOrphanBlobs.length}`,
2678
+ 'status=dry-run',
2679
+ ...(combinedSessions.length > 0
2680
+ ? [
2681
+ 'session_preview:',
2682
+ ...combinedSessions
2683
+ .slice(0, limit)
2684
+ .map((row) => this.formatRetentionSessionCandidate(row)),
2685
+ ]
2686
+ : ['session_preview:', '- none']),
2687
+ ...(initialOrphanBlobs.length > 0
2688
+ ? [
2689
+ 'blob_preview:',
2690
+ ...initialOrphanBlobs
2691
+ .slice(0, limit)
2692
+ .map(
2693
+ (row) =>
2694
+ `- ${row.content_hash.slice(0, 16)} chars=${row.char_count} created_at=${row.created_at}`,
2695
+ ),
2696
+ ]
2697
+ : ['blob_preview:', '- none']),
2698
+ 'Re-run with apply=true to prune the candidates above.',
2699
+ ].join('\n');
2700
+ }
2701
+
2702
+ const result = this.applyRetentionPruneSync({ ...input, apply: true });
2703
+
2704
+ let combinedPreview: string[] = [];
2705
+ if (combinedSessions.length > 0) {
2706
+ combinedPreview = [
2707
+ 'deleted_sessions_preview:',
2708
+ ...combinedSessions.slice(0, limit).map((row) => this.formatRetentionSessionCandidate(row)),
2709
+ ];
2710
+ } else {
2711
+ combinedPreview = ['deleted_sessions_preview:', '- none'];
2712
+ }
2713
+
2714
+ let deletedBlobPreview: string[] = [];
2715
+ if (initialOrphanBlobs.length > 0) {
2716
+ deletedBlobPreview = [
2717
+ 'deleted_blobs_preview:',
2718
+ ...initialOrphanBlobs
2719
+ .slice(0, limit)
2720
+ .map(
2721
+ (row) =>
2722
+ `- ${row.content_hash.slice(0, 16)} chars=${row.char_count} created_at=${row.created_at}`,
2723
+ ),
2724
+ ];
2725
+ } else {
2726
+ deletedBlobPreview = ['deleted_blobs_preview:', '- none'];
2727
+ }
2728
+
2729
+ return [
2730
+ `deleted_sessions=${result.deletedSessions}`,
2731
+ `deleted_blobs=${result.deletedBlobs}`,
2732
+ `deleted_blob_chars=${result.deletedBlobChars}`,
2733
+ 'status=applied',
2734
+ ...combinedPreview,
2735
+ ...deletedBlobPreview,
2736
+ ].join('\n');
2737
+ }
2738
+
2739
+ async exportSnapshot(input: {
2740
+ filePath: string;
2741
+ sessionID?: string;
2742
+ scope?: string;
2743
+ }): Promise<string> {
2744
+ await this.prepareForRead();
2745
+ return exportStoreSnapshot(
2746
+ {
2747
+ workspaceDirectory: this.workspaceDirectory,
2748
+ normalizeScope: this.normalizeScope.bind(this),
2749
+ resolveScopeSessionIDs: this.resolveScopeSessionIDs.bind(this),
2750
+ readScopedSessionRowsSync: this.readScopedSessionRowsSync.bind(this),
2751
+ readScopedMessageRowsSync: this.readScopedMessageRowsSync.bind(this),
2752
+ readScopedPartRowsSync: this.readScopedPartRowsSync.bind(this),
2753
+ readScopedResumeRowsSync: this.readScopedResumeRowsSync.bind(this),
2754
+ readScopedArtifactRowsSync: this.readScopedArtifactRowsSync.bind(this),
2755
+ readScopedArtifactBlobRowsSync: this.readScopedArtifactBlobRowsSync.bind(this),
2756
+ readScopedSummaryRowsSync: this.readScopedSummaryRowsSync.bind(this),
2757
+ readScopedSummaryEdgeRowsSync: this.readScopedSummaryEdgeRowsSync.bind(this),
2758
+ readScopedSummaryStateRowsSync: this.readScopedSummaryStateRowsSync.bind(this),
2759
+ },
2760
+ input,
2761
+ );
2762
+ }
2763
+
2764
+ async importSnapshot(input: {
2765
+ filePath: string;
2766
+ mode?: 'replace' | 'merge';
2767
+ worktreeMode?: SnapshotWorktreeMode;
2768
+ }): Promise<string> {
2769
+ await this.prepareForRead();
2770
+ return importStoreSnapshot(
2771
+ {
2772
+ workspaceDirectory: this.workspaceDirectory,
2773
+ getDb: () => this.getDb(),
2774
+ clearSessionDataSync: this.clearSessionDataSync.bind(this),
2775
+ backfillArtifactBlobsSync: this.backfillArtifactBlobsSync.bind(this),
2776
+ refreshAllLineageSync: this.refreshAllLineageSync.bind(this),
2777
+ syncAllDerivedSessionStateSync: this.syncAllDerivedSessionStateSync.bind(this),
2778
+ refreshSearchIndexesSync: this.refreshSearchIndexesSync.bind(this),
2779
+ },
2780
+ input,
2781
+ );
2782
+ }
2783
+
2784
+ async resume(sessionID?: string): Promise<string> {
2785
+ await this.prepareForRead();
2786
+ const resolvedSessionID = sessionID ?? this.latestSessionIDSync();
2787
+ if (!resolvedSessionID) return 'No stored resume snapshots yet.';
2788
+
2789
+ const existing = this.getResumeSync(resolvedSessionID);
2790
+ if (existing && !this.isManagedResumeNote(existing)) return existing;
2791
+
2792
+ const generated = await this.buildCompactionContext(resolvedSessionID);
2793
+ return generated ?? existing ?? 'No stored resume snapshot for that session.';
2794
+ }
2795
+
2796
+ async expand(input: {
2797
+ sessionID?: string;
2798
+ nodeID?: string;
2799
+ query?: string;
2800
+ depth?: number;
2801
+ messageLimit?: number;
2802
+ includeRaw?: boolean;
2803
+ }): Promise<string> {
2804
+ await this.prepareForRead();
2805
+ const depth = clamp(input.depth ?? 1, 1, 4);
2806
+ const messageLimit = clamp(input.messageLimit ?? EXPAND_MESSAGE_LIMIT, 1, 20);
2807
+ const query = input.query?.trim();
2808
+
2809
+ if (!input.nodeID) {
2810
+ const sessionID = input.sessionID ?? this.latestSessionIDSync();
2811
+ if (!sessionID) return 'No archived summary nodes yet.';
2812
+
2813
+ const session = this.readSessionSync(sessionID);
2814
+ let roots = this.getSummaryRootsForSession(session);
2815
+ if (roots.length === 0) return 'No archived summary nodes yet.';
2816
+
2817
+ if (query) {
2818
+ const matches = this.findExpandMatches(sessionID, query);
2819
+ roots = roots.filter((node) => this.nodeMatchesQuery(node, matches));
2820
+ if (roots.length === 0) return `No archived summary nodes matched "${query}".`;
2821
+ }
2822
+
2823
+ return [
2824
+ `Session: ${sessionID}`,
2825
+ query
2826
+ ? `Archived summary roots matching "${query}": ${roots.length}`
2827
+ : `Archived summary roots: ${roots.length}`,
2828
+ 'Use lcm_expand with one of these node IDs for more detail:',
2829
+ ...roots.map(
2830
+ (node) =>
2831
+ `- ${node.nodeID} (messages ${node.startIndex + 1}-${node.endIndex + 1}, level ${node.level}): ${node.summaryText}`,
2832
+ ),
2833
+ ].join('\n');
2834
+ }
2835
+
2836
+ const node = this.readSummaryNodeSync(input.nodeID);
2837
+ if (!node) return 'Unknown summary node.';
2838
+
2839
+ const session = this.readSessionSync(node.sessionID);
2840
+ if (!query)
2841
+ return this.renderExpandedNode(session, node, depth, input.includeRaw ?? true, messageLimit);
2842
+
2843
+ const matches = this.findExpandMatches(node.sessionID, query);
2844
+ if (!this.nodeMatchesQuery(node, matches)) {
2845
+ return `No descendants in ${node.nodeID} matched "${query}".`;
2846
+ }
2847
+
2848
+ return this.renderTargetedExpansion(
2849
+ session,
2850
+ node,
2851
+ depth,
2852
+ input.includeRaw ?? true,
2853
+ messageLimit,
2854
+ query,
2855
+ matches,
2856
+ );
2857
+ }
2858
+
2859
+ async buildCompactionContext(sessionID: string): Promise<string | undefined> {
2860
+ await this.prepareForRead();
2861
+ const session = this.readSessionSync(sessionID);
2862
+ if (session.messages.length === 0) return undefined;
2863
+
2864
+ const roots = this.getSummaryRootsForSession(session);
2865
+ const note = this.buildResumeNote(session, roots);
2866
+ this.getDb()
2867
+ .prepare(
2868
+ `INSERT INTO resumes (session_id, note, updated_at)
2869
+ VALUES (?, ?, ?)
2870
+ ON CONFLICT(session_id) DO UPDATE SET note = excluded.note, updated_at = excluded.updated_at`,
2871
+ )
2872
+ .run(sessionID, note, Date.now());
2873
+ return note;
2874
+ }
2875
+
2876
+ async transformMessages(messages: ConversationMessage[]): Promise<boolean> {
2877
+ await this.prepareForRead();
2878
+ if (messages.length < this.options.minMessagesForTransform) return false;
2879
+
2880
+ const window = resolveArchiveTransformWindow(messages, this.options.freshTailMessages);
2881
+ if (!window) return false;
2882
+
2883
+ const { anchor, archived, recent } = window;
2884
+
2885
+ const roots = this.ensureSummaryGraphSync(anchor.info.sessionID, archived);
2886
+ if (roots.length === 0) return false;
2887
+
2888
+ const summary = buildActiveSummaryText(roots, archived.length, this.options.summaryCharBudget);
2889
+ const retrieval = await this.buildAutomaticRetrievalContext(
2890
+ anchor.info.sessionID,
2891
+ recent,
2892
+ anchor,
2893
+ );
2894
+ for (const message of archived) {
2895
+ this.compactMessageInPlace(message);
2896
+ }
2897
+
2898
+ anchor.parts = anchor.parts.filter(
2899
+ (part) => !isSyntheticLcmTextPart(part, ['archive-summary', 'retrieved-context']),
2900
+ );
2901
+ const syntheticParts: Part[] = [];
2902
+ if (retrieval) {
2903
+ syntheticParts.push({
2904
+ id: `lcm-memory-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
2905
+ sessionID: anchor.info.sessionID,
2906
+ messageID: anchor.info.id,
2907
+ type: 'text',
2908
+ text: retrieval,
2909
+ synthetic: true,
2910
+ metadata: { opencodeLcm: 'retrieved-context' },
2911
+ });
2912
+ }
2913
+ syntheticParts.push({
2914
+ id: `lcm-summary-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
2915
+ sessionID: anchor.info.sessionID,
2916
+ messageID: anchor.info.id,
2917
+ type: 'text',
2918
+ text: summary,
2919
+ synthetic: true,
2920
+ metadata: { opencodeLcm: 'archive-summary' },
2921
+ });
2922
+ anchor.parts.push(...syntheticParts);
2923
+ return true;
2924
+ }
2925
+
2926
+ systemHint(): string | undefined {
2927
+ if (!this.options.systemHint) return undefined;
2928
+
2929
+ return [
2930
+ 'Archived session state may exist outside the active prompt.',
2931
+ 'opencode-lcm may automatically recall archived context when it looks relevant to the current turn.',
2932
+ 'Use lcm_describe, lcm_grep, lcm_resume, lcm_expand, or lcm_artifact only when deeper archive inspection is still needed.',
2933
+ 'Keep ctx_* usage selective and treat those calls as infrastructure, not task intent.',
2934
+ ].join(' ');
2935
+ }
2936
+
2937
+ private async buildAutomaticRetrievalContext(
2938
+ sessionID: string,
2939
+ recent: ConversationMessage[],
2940
+ anchor: ConversationMessage,
2941
+ ): Promise<string | undefined> {
2942
+ if (!this.options.automaticRetrieval.enabled) return undefined;
2943
+
2944
+ const query = this.buildAutomaticRetrievalQuery(anchor, recent);
2945
+ if (!query) return undefined;
2946
+
2947
+ const allowedHits =
2948
+ clamp(this.options.automaticRetrieval.maxMessageHits, 0, 4) +
2949
+ clamp(this.options.automaticRetrieval.maxSummaryHits, 0, 3) +
2950
+ clamp(this.options.automaticRetrieval.maxArtifactHits, 0, 3);
2951
+ if (allowedHits <= 0) return undefined;
2952
+
2953
+ const targetHits = this.resolveAutomaticRetrievalTargetHits(allowedHits);
2954
+ const results: SearchResult[] = [];
2955
+ const seenResults = new Set<string>();
2956
+ const searchedScopes: ScopeName[] = [];
2957
+ const scopeStats: Array<{
2958
+ scope: string;
2959
+ budget: number;
2960
+ rawResults: number;
2961
+ selectedHits: number;
2962
+ }> = [];
2963
+ let stopReason = 'scope-order-exhausted';
2964
+ let hits = this.selectAutomaticRetrievalHits(sessionID, recent, query.tokens, results);
2965
+
2966
+ for (const scope of this.buildAutomaticRetrievalScopeOrder(sessionID)) {
2967
+ const budget = this.resolveAutomaticRetrievalScopeBudget(scope);
2968
+ if (budget <= 0) {
2969
+ scopeStats.push({ scope, budget, rawResults: 0, selectedHits: 0 });
2970
+ continue;
2971
+ }
2972
+
2973
+ searchedScopes.push(scope);
2974
+ let scopeRawResults = 0;
2975
+ let scopeSelectedHits = 0;
2976
+
2977
+ for (const candidateQuery of query.queries) {
2978
+ const remainingBudget = budget - scopeRawResults;
2979
+ if (remainingBudget <= 0) break;
2980
+
2981
+ const previousHits = hits;
2982
+ const scopedResults = await this.grep({
2983
+ query: candidateQuery,
2984
+ sessionID,
2985
+ scope,
2986
+ limit: remainingBudget,
2987
+ });
2988
+
2989
+ for (const result of scopedResults) {
2990
+ const key = `${result.type}:${result.id}`;
2991
+ if (seenResults.has(key)) continue;
2992
+ seenResults.add(key);
2993
+ results.push(result);
2994
+ scopeRawResults += 1;
2995
+ }
2996
+
2997
+ hits = this.selectAutomaticRetrievalHits(sessionID, recent, query.tokens, results);
2998
+ scopeSelectedHits += this.countNewAutomaticRetrievalHits(previousHits, hits);
2999
+
3000
+ if (hits.length >= allowedHits) {
3001
+ stopReason = 'hit-quota-reached';
3002
+ break;
3003
+ }
3004
+
3005
+ if (hits.length >= targetHits) {
3006
+ stopReason = 'target-hits-reached';
3007
+ break;
3008
+ }
3009
+ }
3010
+
3011
+ scopeStats.push({
3012
+ scope,
3013
+ budget,
3014
+ rawResults: scopeRawResults,
3015
+ selectedHits: scopeSelectedHits,
3016
+ });
3017
+
3018
+ if (
3019
+ hits.length > 0 &&
3020
+ this.options.automaticRetrieval.stop.stopOnFirstScopeWithHits &&
3021
+ scopeSelectedHits > 0
3022
+ ) {
3023
+ stopReason = 'first-scope-hit';
3024
+ }
3025
+
3026
+ if (stopReason !== 'scope-order-exhausted') {
3027
+ return renderAutomaticRetrievalContext(
3028
+ searchedScopes,
3029
+ hits,
3030
+ clamp(this.options.automaticRetrieval.maxChars, 240, 4000),
3031
+ {
3032
+ queries: query.queries,
3033
+ rawResults: results.length,
3034
+ stopReason,
3035
+ scopeStats,
3036
+ },
3037
+ );
3038
+ }
3039
+ }
3040
+
3041
+ if (hits.length === 0) return undefined;
3042
+
3043
+ return renderAutomaticRetrievalContext(
3044
+ searchedScopes,
3045
+ hits,
3046
+ clamp(this.options.automaticRetrieval.maxChars, 240, 4000),
3047
+ {
3048
+ queries: query.queries,
3049
+ rawResults: results.length,
3050
+ stopReason,
3051
+ scopeStats,
3052
+ },
3053
+ );
3054
+ }
3055
+
3056
+ private buildAutomaticRetrievalQuery(
3057
+ anchor: ConversationMessage,
3058
+ recent: ConversationMessage[],
3059
+ ): { queries: string[]; tokens: string[] } | undefined {
3060
+ const minTokens = clamp(
3061
+ this.options.automaticRetrieval.minTokens,
3062
+ 1,
3063
+ AUTOMATIC_RETRIEVAL_QUERY_TOKENS,
3064
+ );
3065
+ const tokens: string[] = [];
3066
+ const anchorText = sanitizeAutomaticRetrievalSourceText(
3067
+ guessMessageText(anchor, this.options.interop.ignoreToolPrefixes),
3068
+ );
3069
+ const anchorFiles = listFiles(anchor);
3070
+ const pushTokens = (value?: string) => {
3071
+ if (!value || tokens.length >= AUTOMATIC_RETRIEVAL_QUERY_TOKENS) return;
3072
+ const sanitized = sanitizeAutomaticRetrievalSourceText(value);
3073
+ if (!sanitized) return;
3074
+ for (const token of filterIntentTokens(tokenizeQuery(sanitized))) {
3075
+ if (tokens.includes(token)) continue;
3076
+ tokens.push(token);
3077
+ if (tokens.length >= AUTOMATIC_RETRIEVAL_QUERY_TOKENS) break;
3078
+ }
3079
+ };
3080
+
3081
+ pushTokens(anchorText);
3082
+ for (const file of anchorFiles) pushTokens(path.basename(file));
3083
+ const anchorSignalCount = tokens.length;
3084
+ if (anchorSignalCount === 0) return undefined;
3085
+ if (
3086
+ shouldSuppressLowSignalAutomaticRetrievalAnchor(
3087
+ anchorText,
3088
+ anchorSignalCount,
3089
+ minTokens,
3090
+ anchorFiles.length,
3091
+ )
3092
+ ) {
3093
+ return undefined;
3094
+ }
3095
+
3096
+ for (const message of recent.slice(-AUTOMATIC_RETRIEVAL_RECENT_MESSAGES)) {
3097
+ for (const file of listFiles(message)) pushTokens(path.basename(file));
3098
+ }
3099
+
3100
+ const recentUsers = recent
3101
+ .filter((message) => message.info.role === 'user' && message.info.id !== anchor.info.id)
3102
+ .slice(-AUTOMATIC_RETRIEVAL_RECENT_MESSAGES)
3103
+ .reverse();
3104
+ for (const message of recentUsers) {
3105
+ if (tokens.length >= minTokens) break;
3106
+ pushTokens(guessMessageText(message, this.options.interop.ignoreToolPrefixes));
3107
+ }
3108
+
3109
+ if (tokens.length < minTokens) return undefined;
3110
+ const queryTokens = tokens.slice(0, 5);
3111
+
3112
+ // Apply TF-IDF weighting to filter corpus-common noise tokens
3113
+ const weightedTokens = filterTokensByTfidf(this.getDb(), queryTokens, {
3114
+ minTokens,
3115
+ });
3116
+
3117
+ return {
3118
+ queries: this.buildAutomaticRetrievalQueries(weightedTokens, minTokens),
3119
+ tokens: weightedTokens,
3120
+ };
3121
+ }
3122
+
3123
+ private buildAutomaticRetrievalQueries(tokens: string[], minTokens: number): string[] {
3124
+ const queries: string[] = [];
3125
+ const pushQuery = (parts: string[]) => {
3126
+ const normalized = parts.filter(Boolean);
3127
+ if (normalized.length < minTokens) return;
3128
+ const value = normalized.join(' ');
3129
+ if (!queries.includes(value)) queries.push(value);
3130
+ };
3131
+
3132
+ // Full token set (descending window from front)
3133
+ for (let size = Math.min(tokens.length, 4); size >= minTokens; size -= 1) {
3134
+ pushQuery(tokens.slice(0, size));
3135
+ if (queries.length >= AUTOMATIC_RETRIEVAL_QUERY_VARIANTS) return queries;
3136
+ }
3137
+
3138
+ // Sliding windows starting later in the token list
3139
+ for (let size = Math.min(tokens.length, 4); size >= Math.max(2, minTokens); size -= 1) {
3140
+ for (let start = 1; start + size <= tokens.length; start += 1) {
3141
+ pushQuery(tokens.slice(start, start + size));
3142
+ if (queries.length >= AUTOMATIC_RETRIEVAL_QUERY_VARIANTS) return queries;
3143
+ }
3144
+ }
3145
+
3146
+ // Adjacent bigram phrases — FTS NEAR/phrase queries rank adjacency higher
3147
+ if (tokens.length >= 2) {
3148
+ for (let i = 0; i < tokens.length - 1; i += 1) {
3149
+ const phrase = `"${tokens[i]} ${tokens[i + 1]}"`;
3150
+ if (!queries.includes(phrase)) queries.push(phrase);
3151
+ if (queries.length >= AUTOMATIC_RETRIEVAL_QUERY_VARIANTS) return queries;
3152
+ }
3153
+ }
3154
+
3155
+ // Skip-gram triples for longer token lists
3156
+ if (tokens.length >= 5) {
3157
+ pushQuery([tokens[0], tokens[1], tokens[4]]);
3158
+ pushQuery([tokens[0], tokens[2], tokens[4]]);
3159
+ }
3160
+
3161
+ return queries.slice(0, AUTOMATIC_RETRIEVAL_QUERY_VARIANTS);
3162
+ }
3163
+
3164
+ private buildAutomaticRetrievalScopeOrder(sessionID: string): ScopeName[] {
3165
+ const configured = this.resolveConfiguredScope('grep', undefined, sessionID);
3166
+ const candidates = [...this.options.automaticRetrieval.scopeOrder];
3167
+ if (configured === 'all' && !candidates.includes('all')) {
3168
+ candidates.push('all');
3169
+ }
3170
+
3171
+ const ordered: ScopeName[] = [];
3172
+ const seenScopes = new Set<string>();
3173
+
3174
+ for (const scope of candidates) {
3175
+ const sessionIDs = this.resolveScopeSessionIDs(scope, sessionID);
3176
+ const key = sessionIDs ? [...sessionIDs].sort().join(',') : 'all';
3177
+ if (seenScopes.has(key)) continue;
3178
+ seenScopes.add(key);
3179
+ ordered.push(scope);
3180
+ }
3181
+
3182
+ return ordered;
3183
+ }
3184
+
3185
+ private resolveAutomaticRetrievalScopeBudget(scope: ScopeName): number {
3186
+ return clamp(this.options.automaticRetrieval.scopeBudgets[scope], 0, 24);
3187
+ }
3188
+
3189
+ private resolveAutomaticRetrievalTargetHits(allowedHits: number): number {
3190
+ return clamp(this.options.automaticRetrieval.stop.targetHits, 1, allowedHits);
3191
+ }
3192
+
3193
+ private countNewAutomaticRetrievalHits(
3194
+ before: Array<{ kind: string; id: string }>,
3195
+ after: Array<{ kind: string; id: string }>,
3196
+ ): number {
3197
+ const seen = new Set(before.map((hit) => `${hit.kind}:${hit.id}`));
3198
+ return after.filter((hit) => !seen.has(`${hit.kind}:${hit.id}`)).length;
3199
+ }
3200
+
3201
+ private selectAutomaticRetrievalHits(
3202
+ sessionID: string,
3203
+ recent: ConversationMessage[],
3204
+ tokens: string[],
3205
+ results: SearchResult[],
3206
+ ) {
3207
+ const filteredResults = results.filter(
3208
+ (result) => !this.isAutomaticRetrievalNoiseResult(result),
3209
+ );
3210
+ return selectAutomaticRetrievalHits({
3211
+ recent,
3212
+ tokens,
3213
+ results: filteredResults,
3214
+ quotas: {
3215
+ message: clamp(this.options.automaticRetrieval.maxMessageHits, 0, 4),
3216
+ summary: clamp(this.options.automaticRetrieval.maxSummaryHits, 0, 3),
3217
+ artifact: clamp(this.options.automaticRetrieval.maxArtifactHits, 0, 3),
3218
+ },
3219
+ isFreshResult: (result, freshMessageIDs) =>
3220
+ this.isFreshAutomaticRetrievalResult(sessionID, freshMessageIDs, result),
3221
+ });
3222
+ }
3223
+
3224
+ private isAutomaticRetrievalNoiseResult(result: SearchResult): boolean {
3225
+ return isAutomaticRetrievalNoise(result.snippet);
3226
+ }
3227
+
3228
+ private isFreshAutomaticRetrievalResult(
3229
+ sessionID: string,
3230
+ freshMessageIDs: Set<string>,
3231
+ result: SearchResult,
3232
+ ): boolean {
3233
+ if (result.sessionID !== sessionID) return false;
3234
+ if (result.type === 'summary') return false;
3235
+ if (result.type.startsWith('artifact:')) {
3236
+ const artifact = this.readArtifactSync(result.id);
3237
+ return artifact ? freshMessageIDs.has(artifact.messageID) : false;
3238
+ }
3239
+ return freshMessageIDs.has(result.id);
3240
+ }
3241
+
3242
+ private buildResumeNote(session: NormalizedSession, roots: SummaryNodeData[]): string {
3243
+ const files = [...new Set(session.messages.flatMap(listFiles))].slice(0, 10);
3244
+ const recent = session.messages
3245
+ .slice(-4)
3246
+ .map(
3247
+ (message) =>
3248
+ `- ${message.info.role}: ${truncate(guessMessageText(message, this.options.interop.ignoreToolPrefixes), 160)}`,
3249
+ )
3250
+ .filter((line) => !line.endsWith(': '));
3251
+
3252
+ return truncate(
3253
+ [
3254
+ 'LCM prototype resume note',
3255
+ `Session: ${session.sessionID}`,
3256
+ `Title: ${makeSessionTitle(session) ?? 'Unknown'}`,
3257
+ `Root session: ${session.rootSessionID ?? session.sessionID}`,
3258
+ `Parent session: ${session.parentSessionID ?? 'none'}`,
3259
+ `Lineage depth: ${session.lineageDepth ?? 0}`,
3260
+ `Archived messages: ${Math.max(0, session.messages.length - this.options.freshTailMessages)}`,
3261
+ ...(roots.length > 0
3262
+ ? [
3263
+ 'Summary roots:',
3264
+ ...roots
3265
+ .slice(0, 4)
3266
+ .map((node) => `- ${node.nodeID}: ${truncate(node.summaryText, 160)}`),
3267
+ ]
3268
+ : []),
3269
+ ...(files.length > 0 ? [`Files touched: ${files.join(', ')}`] : []),
3270
+ ...(recent.length > 0 ? ['Recent archived activity:', ...recent] : []),
3271
+ 'Keep context-mode in charge of routing and sandbox tools.',
3272
+ 'Use lcm_describe, lcm_grep, lcm_resume, lcm_expand, or lcm_artifact for archived details.',
3273
+ ].join('\n'),
3274
+ this.options.compactContextLimit,
3275
+ );
3276
+ }
3277
+
3278
+ private compactMessageInPlace(message: ConversationMessage): void {
3279
+ for (const part of message.parts) {
3280
+ switch (part.type) {
3281
+ case 'text':
3282
+ if (part.metadata?.opencodeLcm === 'archive-summary') break;
3283
+ part.text = archivePlaceholder('older text elided');
3284
+ break;
3285
+ case 'reasoning':
3286
+ part.text = archivePlaceholder('reasoning omitted');
3287
+ break;
3288
+ case 'tool': {
3289
+ if (part.state.status === 'completed') {
3290
+ const label = this.shouldIgnoreTool(part.tool)
3291
+ ? 'infrastructure tool output omitted'
3292
+ : `tool output for ${part.tool} omitted`;
3293
+ part.state.output = archivePlaceholder(label);
3294
+ part.state.attachments = undefined;
3295
+ }
3296
+ if (part.state.status === 'error') {
3297
+ part.state.error = archivePlaceholder(`error output for ${part.tool} omitted`);
3298
+ }
3299
+ break;
3300
+ }
3301
+ case 'file':
3302
+ if (part.source?.text) {
3303
+ part.source.text.value = archivePlaceholder(
3304
+ part.source.path ?? part.filename ?? 'file contents omitted',
3305
+ );
3306
+ part.source.text.start = 0;
3307
+ part.source.text.end = part.source.text.value.length;
3308
+ }
3309
+ break;
3310
+ case 'snapshot':
3311
+ part.snapshot = archivePlaceholder('snapshot omitted');
3312
+ break;
3313
+ case 'agent':
3314
+ if (part.source) {
3315
+ part.source.value = archivePlaceholder(`agent source for ${part.name} omitted`);
3316
+ part.source.start = 0;
3317
+ part.source.end = part.source.value.length;
3318
+ }
3319
+ break;
3320
+ case 'patch':
3321
+ part.files = part.files.slice(0, 8);
3322
+ break;
3323
+ case 'subtask':
3324
+ part.prompt = truncate(part.prompt, this.options.partCharBudget);
3325
+ part.description = truncate(part.description, this.options.partCharBudget);
3326
+ break;
3327
+ default:
3328
+ break;
3329
+ }
3330
+ }
3331
+ }
3332
+
3333
+ private shouldIgnoreTool(toolName: string): boolean {
3334
+ return this.options.interop.ignoreToolPrefixes.some((prefix) => toolName.startsWith(prefix));
3335
+ }
3336
+
3337
+ private summarizeMessages(
3338
+ messages: ConversationMessage[],
3339
+ limit = SUMMARY_NODE_CHAR_LIMIT,
3340
+ ): string {
3341
+ const goals = messages
3342
+ .filter((message) => message.info.role === 'user')
3343
+ .map((message) => guessMessageText(message, this.options.interop.ignoreToolPrefixes))
3344
+ .filter(Boolean)
3345
+ .slice(0, 2)
3346
+ .map((text) => truncate(text, 90));
3347
+
3348
+ const work = messages
3349
+ .filter((message) => message.info.role === 'assistant')
3350
+ .map((message) => guessMessageText(message, this.options.interop.ignoreToolPrefixes))
3351
+ .filter(Boolean)
3352
+ .slice(-2)
3353
+ .map((text) => truncate(text, 90));
3354
+
3355
+ const files = [...new Set(messages.flatMap(listFiles))].slice(0, 4);
3356
+ const tools = [...new Set(this.listTools(messages))].slice(0, 4);
3357
+
3358
+ const segments = [
3359
+ goals.length > 0 ? `Goals: ${goals.join(' | ')}` : '',
3360
+ work.length > 0 ? `Work: ${work.join(' | ')}` : '',
3361
+ files.length > 0 ? `Files: ${files.join(', ')}` : '',
3362
+ tools.length > 0 ? `Tools: ${tools.join(', ')}` : '',
3363
+ ].filter(Boolean);
3364
+
3365
+ if (segments.length === 0) return truncate(`Archived messages ${messages.length}`, limit);
3366
+ return truncate(segments.join(' || '), limit);
3367
+ }
3368
+
3369
+ private listTools(messages: ConversationMessage[]): string[] {
3370
+ const tools: string[] = [];
3371
+ for (const message of messages) {
3372
+ for (const part of message.parts) {
3373
+ if (part.type !== 'tool') continue;
3374
+ if (this.shouldIgnoreTool(part.tool)) continue;
3375
+ tools.push(part.tool);
3376
+ }
3377
+ }
3378
+ return tools;
3379
+ }
3380
+
3381
+ private buildArchivedSignature(messages: ConversationMessage[]): string {
3382
+ const hash = createHash('sha256');
3383
+ for (const message of messages) {
3384
+ hash.update(message.info.id);
3385
+ hash.update(message.info.role);
3386
+ hash.update(String(message.info.time.created));
3387
+ hash.update(guessMessageText(message, this.options.interop.ignoreToolPrefixes));
3388
+ hash.update(JSON.stringify(listFiles(message)));
3389
+ hash.update(JSON.stringify(this.listTools([message])));
3390
+ hash.update(String(message.parts.length));
3391
+ }
3392
+ return hash.digest('hex');
3393
+ }
3394
+
3395
+ private getArchivedMessages(messages: ConversationMessage[]): ConversationMessage[] {
3396
+ const window = resolveArchiveTransformWindow(messages, this.options.freshTailMessages);
3397
+ if (window) return window.archived;
3398
+
3399
+ const archivedCount = Math.max(0, messages.length - this.options.freshTailMessages);
3400
+ return messages.slice(0, archivedCount);
3401
+ }
3402
+
3403
+ private getSummaryRootsForSession(session: NormalizedSession): SummaryNodeData[] {
3404
+ const archived = this.getArchivedMessages(session.messages);
3405
+ return this.ensureSummaryGraphSync(session.sessionID, archived);
3406
+ }
3407
+
3408
+ private ensureSummaryGraphSync(
3409
+ sessionID: string,
3410
+ archivedMessages: ConversationMessage[],
3411
+ ): SummaryNodeData[] {
3412
+ if (archivedMessages.length === 0) {
3413
+ this.clearSummaryGraphSync(sessionID);
3414
+ return [];
3415
+ }
3416
+
3417
+ const latestMessageCreated = archivedMessages.at(-1)?.info.time.created ?? 0;
3418
+ const archivedSignature = this.buildArchivedSignature(archivedMessages);
3419
+ const state = safeQueryOne<SummaryStateRow>(
3420
+ this.getDb().prepare('SELECT * FROM summary_state WHERE session_id = ?'),
3421
+ [sessionID],
3422
+ 'ensureSummaryGraphSync',
3423
+ );
3424
+
3425
+ if (
3426
+ state &&
3427
+ state.archived_count === archivedMessages.length &&
3428
+ state.latest_message_created === latestMessageCreated &&
3429
+ state.archived_signature === archivedSignature
3430
+ ) {
3431
+ const rootIDs = parseJson<string[]>(state.root_node_ids_json);
3432
+ const roots = rootIDs
3433
+ .map((nodeID) => this.readSummaryNodeSync(nodeID))
3434
+ .filter((node): node is SummaryNodeData => Boolean(node));
3435
+ if (
3436
+ rootIDs.length > 0 &&
3437
+ roots.length === rootIDs.length &&
3438
+ this.canReuseSummaryGraphSync(sessionID, archivedMessages, roots)
3439
+ ) {
3440
+ return roots;
3441
+ }
3442
+ }
3443
+
3444
+ return this.rebuildSummaryGraphSync(sessionID, archivedMessages, archivedSignature);
3445
+ }
3446
+
3447
+ private canReuseSummaryGraphSync(
3448
+ sessionID: string,
3449
+ archivedMessages: ConversationMessage[],
3450
+ roots: SummaryNodeData[],
3451
+ ): boolean {
3452
+ if (roots.length === 0) return false;
3453
+
3454
+ const expectedMessageIDs = archivedMessages.map((message) => message.info.id);
3455
+ const seen = new Set<string>();
3456
+
3457
+ const validateNode = (node: SummaryNodeData, expectedSlot: number): boolean => {
3458
+ if (node.sessionID !== sessionID) return false;
3459
+ if (node.nodeID !== buildSummaryNodeID(sessionID, node.level, expectedSlot)) return false;
3460
+ if (seen.has(node.nodeID)) return false;
3461
+ seen.add(node.nodeID);
3462
+
3463
+ if (
3464
+ node.startIndex < 0 ||
3465
+ node.endIndex < node.startIndex ||
3466
+ node.endIndex >= expectedMessageIDs.length
3467
+ ) {
3468
+ return false;
3469
+ }
3470
+
3471
+ const expectedNodeMessageIDs = expectedMessageIDs.slice(node.startIndex, node.endIndex + 1);
3472
+ if (node.messageIDs.length !== expectedNodeMessageIDs.length) return false;
3473
+ for (let index = 0; index < expectedNodeMessageIDs.length; index += 1) {
3474
+ if (node.messageIDs[index] !== expectedNodeMessageIDs[index]) return false;
3475
+ }
3476
+
3477
+ const expectedSummaryText = this.summarizeMessages(
3478
+ archivedMessages.slice(node.startIndex, node.endIndex + 1),
3479
+ );
3480
+ if (node.summaryText !== expectedSummaryText) return false;
3481
+
3482
+ const children = this.readSummaryChildrenSync(node.nodeID);
3483
+ if (node.nodeKind === 'leaf') {
3484
+ return (
3485
+ children.length === 0 && node.endIndex - node.startIndex + 1 <= SUMMARY_LEAF_MESSAGES
3486
+ );
3487
+ }
3488
+ if (children.length === 0 || children.length > SUMMARY_BRANCH_FACTOR) return false;
3489
+ if (children[0]?.startIndex !== node.startIndex) return false;
3490
+ if (children.at(-1)?.endIndex !== node.endIndex) return false;
3491
+
3492
+ let nextStartIndex = node.startIndex;
3493
+ for (const [childPosition, child] of children.entries()) {
3494
+ if (child.level !== node.level - 1) return false;
3495
+ if (child.startIndex !== nextStartIndex) return false;
3496
+ if (!validateNode(child, expectedSlot * SUMMARY_BRANCH_FACTOR + childPosition))
3497
+ return false;
3498
+ nextStartIndex = child.endIndex + 1;
3499
+ }
3500
+
3501
+ return nextStartIndex === node.endIndex + 1;
3502
+ };
3503
+
3504
+ let nextStartIndex = 0;
3505
+ for (const [rootSlot, root] of roots.entries()) {
3506
+ if (root.startIndex !== nextStartIndex) return false;
3507
+ if (!validateNode(root, rootSlot)) return false;
3508
+ nextStartIndex = root.endIndex + 1;
3509
+ }
3510
+
3511
+ return nextStartIndex === expectedMessageIDs.length;
3512
+ }
3513
+
3514
+ private rebuildSummaryGraphSync(
3515
+ sessionID: string,
3516
+ archivedMessages: ConversationMessage[],
3517
+ archivedSignature: string,
3518
+ ): SummaryNodeData[] {
3519
+ const now = Date.now();
3520
+ let level = 0;
3521
+ const nodes: SummaryNodeData[] = [];
3522
+ const edges: Array<{
3523
+ sessionID: string;
3524
+ parentID: string;
3525
+ childID: string;
3526
+ childPosition: number;
3527
+ }> = [];
3528
+
3529
+ const makeNode = (input: {
3530
+ nodeKind: 'leaf' | 'internal';
3531
+ startIndex: number;
3532
+ endIndex: number;
3533
+ messageIDs: string[];
3534
+ summaryText: string;
3535
+ level: number;
3536
+ slot: number;
3537
+ }): SummaryNodeData => ({
3538
+ nodeID: buildSummaryNodeID(sessionID, input.level, input.slot),
3539
+ sessionID,
3540
+ level: input.level,
3541
+ nodeKind: input.nodeKind,
3542
+ startIndex: input.startIndex,
3543
+ endIndex: input.endIndex,
3544
+ messageIDs: input.messageIDs,
3545
+ summaryText: input.summaryText,
3546
+ createdAt: now,
3547
+ });
3548
+
3549
+ let currentLevel: SummaryNodeData[] = [];
3550
+ for (
3551
+ let start = 0, slot = 0;
3552
+ start < archivedMessages.length;
3553
+ start += SUMMARY_LEAF_MESSAGES, slot += 1
3554
+ ) {
3555
+ const chunk = archivedMessages.slice(start, start + SUMMARY_LEAF_MESSAGES);
3556
+ const node = makeNode({
3557
+ nodeKind: 'leaf',
3558
+ startIndex: start,
3559
+ endIndex: start + chunk.length - 1,
3560
+ messageIDs: chunk.map((message) => message.info.id),
3561
+ summaryText: this.summarizeMessages(chunk),
3562
+ level,
3563
+ slot,
3564
+ });
3565
+ nodes.push(node);
3566
+ currentLevel.push(node);
3567
+ }
3568
+
3569
+ while (currentLevel.length > 1) {
3570
+ level += 1;
3571
+ const nextLevel: SummaryNodeData[] = [];
3572
+
3573
+ for (let index = 0; index < currentLevel.length; index += SUMMARY_BRANCH_FACTOR) {
3574
+ const children = currentLevel.slice(index, index + SUMMARY_BRANCH_FACTOR);
3575
+ const startIndex = children[0].startIndex;
3576
+ const endIndex = children.at(-1)?.endIndex ?? startIndex;
3577
+ const covered = archivedMessages.slice(startIndex, endIndex + 1);
3578
+ const node = makeNode({
3579
+ nodeKind: 'internal',
3580
+ startIndex,
3581
+ endIndex,
3582
+ messageIDs: covered.map((message) => message.info.id),
3583
+ summaryText: this.summarizeMessages(covered),
3584
+ level,
3585
+ slot: nextLevel.length,
3586
+ });
3587
+ nodes.push(node);
3588
+ nextLevel.push(node);
3589
+ children.forEach((child, childPosition) => {
3590
+ edges.push({
3591
+ sessionID,
3592
+ parentID: node.nodeID,
3593
+ childID: child.nodeID,
3594
+ childPosition,
3595
+ });
3596
+ });
3597
+ }
3598
+
3599
+ currentLevel = nextLevel;
3600
+ }
3601
+
3602
+ const roots = currentLevel;
3603
+ const db = this.getDb();
3604
+ withTransaction(db, 'rebuildSummaryGraph', () => {
3605
+ this.clearSummaryGraphSync(sessionID);
3606
+
3607
+ const insertNode = db.prepare(
3608
+ `INSERT INTO summary_nodes
3609
+ (node_id, session_id, level, node_kind, start_index, end_index, message_ids_json, summary_text, created_at)
3610
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
3611
+ );
3612
+ const insertEdge = db.prepare(
3613
+ `INSERT INTO summary_edges (session_id, parent_id, child_id, child_position)
3614
+ VALUES (?, ?, ?, ?)`,
3615
+ );
3616
+ const insertSummaryFts = db.prepare(
3617
+ 'INSERT INTO summary_fts (session_id, node_id, level, created_at, content) VALUES (?, ?, ?, ?, ?)',
3618
+ );
3619
+
3620
+ for (const node of nodes) {
3621
+ insertNode.run(
3622
+ node.nodeID,
3623
+ node.sessionID,
3624
+ node.level,
3625
+ node.nodeKind,
3626
+ node.startIndex,
3627
+ node.endIndex,
3628
+ JSON.stringify(node.messageIDs),
3629
+ node.summaryText,
3630
+ node.createdAt,
3631
+ );
3632
+ insertSummaryFts.run(
3633
+ node.sessionID,
3634
+ node.nodeID,
3635
+ String(node.level),
3636
+ String(node.createdAt),
3637
+ node.summaryText,
3638
+ );
3639
+ }
3640
+
3641
+ for (const edge of edges) {
3642
+ insertEdge.run(edge.sessionID, edge.parentID, edge.childID, edge.childPosition);
3643
+ }
3644
+
3645
+ db.prepare(
3646
+ `INSERT INTO summary_state (session_id, archived_count, latest_message_created, archived_signature, root_node_ids_json, updated_at)
3647
+ VALUES (?, ?, ?, ?, ?, ?)
3648
+ ON CONFLICT(session_id) DO UPDATE SET
3649
+ archived_count = excluded.archived_count,
3650
+ latest_message_created = excluded.latest_message_created,
3651
+ archived_signature = excluded.archived_signature,
3652
+ root_node_ids_json = excluded.root_node_ids_json,
3653
+ updated_at = excluded.updated_at`,
3654
+ ).run(
3655
+ sessionID,
3656
+ archivedMessages.length,
3657
+ archivedMessages.at(-1)?.info.time.created ?? 0,
3658
+ archivedSignature,
3659
+ JSON.stringify(roots.map((node) => node.nodeID)),
3660
+ now,
3661
+ );
3662
+ });
3663
+
3664
+ return roots;
3665
+ }
3666
+
3667
+ private readSummaryNodeSync(nodeID: string): SummaryNodeData | undefined {
3668
+ const row = safeQueryOne<SummaryNodeRow>(
3669
+ this.getDb().prepare('SELECT * FROM summary_nodes WHERE node_id = ?'),
3670
+ [nodeID],
3671
+ 'readSummaryNodeSync',
3672
+ );
3673
+ if (!row) return undefined;
3674
+
3675
+ return {
3676
+ nodeID: row.node_id,
3677
+ sessionID: row.session_id,
3678
+ level: row.level,
3679
+ nodeKind: row.node_kind === 'leaf' ? 'leaf' : 'internal',
3680
+ startIndex: row.start_index,
3681
+ endIndex: row.end_index,
3682
+ messageIDs: parseJson<string[]>(row.message_ids_json),
3683
+ summaryText: row.summary_text,
3684
+ createdAt: row.created_at,
3685
+ };
3686
+ }
3687
+
3688
+ private readSummaryChildrenSync(nodeID: string): SummaryNodeData[] {
3689
+ const rows = this.getDb()
3690
+ .prepare(
3691
+ `SELECT e.parent_id, e.child_id, e.child_position
3692
+ FROM summary_edges e
3693
+ WHERE e.parent_id = ?
3694
+ ORDER BY e.child_position ASC`,
3695
+ )
3696
+ .all(nodeID) as SummaryEdgeRow[];
3697
+
3698
+ return rows
3699
+ .map((row) => this.readSummaryNodeSync(row.child_id))
3700
+ .filter((node): node is SummaryNodeData => Boolean(node));
3701
+ }
3702
+
3703
+ private readArtifactBlobSync(contentHash?: string | null): ArtifactBlobRow | undefined {
3704
+ if (!contentHash) return undefined;
3705
+ return safeQueryOne<ArtifactBlobRow>(
3706
+ this.getDb().prepare('SELECT * FROM artifact_blobs WHERE content_hash = ?'),
3707
+ [contentHash],
3708
+ 'readArtifactBlobSync',
3709
+ );
3710
+ }
3711
+
3712
+ private materializeArtifactRow(row: ArtifactRow): ArtifactData {
3713
+ return materializeArtifactRowModule(this.artifactDeps(), row);
3714
+ }
3715
+
3716
+ private readArtifactSync(artifactID: string): ArtifactData | undefined {
3717
+ const row = safeQueryOne<ArtifactRow>(
3718
+ this.getDb().prepare('SELECT * FROM artifacts WHERE artifact_id = ?'),
3719
+ [artifactID],
3720
+ 'readArtifactSync',
3721
+ );
3722
+ if (!row) return undefined;
3723
+
3724
+ return this.materializeArtifactRow(row);
3725
+ }
3726
+
3727
+ private readArtifactsForSessionSync(sessionID: string): ArtifactData[] {
3728
+ const rows = this.getDb()
3729
+ .prepare(
3730
+ 'SELECT * FROM artifacts WHERE session_id = ? ORDER BY created_at ASC, artifact_id ASC',
3731
+ )
3732
+ .all(sessionID) as ArtifactRow[];
3733
+
3734
+ return rows.map((row) => this.materializeArtifactRow(row));
3735
+ }
3736
+
3737
+ private readArtifactsForMessageSync(messageID: string): ArtifactData[] {
3738
+ const rows = this.getDb()
3739
+ .prepare(
3740
+ 'SELECT * FROM artifacts WHERE message_id = ? ORDER BY created_at ASC, artifact_id ASC',
3741
+ )
3742
+ .all(messageID) as ArtifactRow[];
3743
+
3744
+ return rows.map((row) => this.materializeArtifactRow(row));
3745
+ }
3746
+
3747
+ private findExpandMatches(
3748
+ sessionID: string,
3749
+ query: string,
3750
+ ): {
3751
+ messageIDs: Set<string>;
3752
+ nodeIDs: Set<string>;
3753
+ artifactIDs: Set<string>;
3754
+ } {
3755
+ const messageIDs = new Set<string>();
3756
+ const nodeIDs = new Set<string>();
3757
+ const artifactIDs = new Set<string>();
3758
+ const ftsQuery = this.buildFtsQuery(query);
3759
+ const db = this.getDb();
3760
+
3761
+ if (ftsQuery) {
3762
+ try {
3763
+ const messageRows = db
3764
+ .prepare(
3765
+ 'SELECT message_id FROM message_fts WHERE session_id = ? AND message_fts MATCH ? LIMIT 200',
3766
+ )
3767
+ .all(sessionID, ftsQuery) as Array<{ message_id: string }>;
3768
+ for (const row of messageRows) messageIDs.add(row.message_id);
3769
+
3770
+ const nodeRows = db
3771
+ .prepare(
3772
+ 'SELECT node_id FROM summary_fts WHERE session_id = ? AND summary_fts MATCH ? LIMIT 200',
3773
+ )
3774
+ .all(sessionID, ftsQuery) as Array<{ node_id: string }>;
3775
+ for (const row of nodeRows) nodeIDs.add(row.node_id);
3776
+
3777
+ const artifactRows = db
3778
+ .prepare(
3779
+ 'SELECT artifact_id, message_id FROM artifact_fts WHERE session_id = ? AND artifact_fts MATCH ? LIMIT 200',
3780
+ )
3781
+ .all(sessionID, ftsQuery) as Array<{ artifact_id: string; message_id: string }>;
3782
+ for (const row of artifactRows) {
3783
+ artifactIDs.add(row.artifact_id);
3784
+ messageIDs.add(row.message_id);
3785
+ }
3786
+ } catch (error) {
3787
+ getLogger().debug('FTS query failed, falling back to scan', { query, error });
3788
+ }
3789
+ }
3790
+
3791
+ if (messageIDs.size === 0 && nodeIDs.size === 0 && artifactIDs.size === 0) {
3792
+ const lower = query.toLowerCase();
3793
+ const session = this.readSessionSync(sessionID);
3794
+ for (const message of session.messages) {
3795
+ const text = guessMessageText(
3796
+ message,
3797
+ this.options.interop.ignoreToolPrefixes,
3798
+ ).toLowerCase();
3799
+ if (text.includes(lower)) messageIDs.add(message.info.id);
3800
+ }
3801
+
3802
+ for (const artifact of this.readArtifactsForSessionSync(sessionID)) {
3803
+ if (`${artifact.previewText}\n${artifact.contentText}`.toLowerCase().includes(lower)) {
3804
+ artifactIDs.add(artifact.artifactID);
3805
+ messageIDs.add(artifact.messageID);
3806
+ }
3807
+ }
3808
+
3809
+ const summaryRows = db
3810
+ .prepare(
3811
+ 'SELECT node_id, summary_text FROM summary_nodes WHERE session_id = ? ORDER BY created_at ASC',
3812
+ )
3813
+ .all(sessionID) as Array<{ node_id: string; summary_text: string }>;
3814
+ for (const row of summaryRows) {
3815
+ if (row.summary_text.toLowerCase().includes(lower)) nodeIDs.add(row.node_id);
3816
+ }
3817
+ }
3818
+
3819
+ return { messageIDs, nodeIDs, artifactIDs };
3820
+ }
3821
+
3822
+ private nodeMatchesQuery(
3823
+ node: SummaryNodeData,
3824
+ matches: { messageIDs: Set<string>; nodeIDs: Set<string>; artifactIDs: Set<string> },
3825
+ ): boolean {
3826
+ if (matches.nodeIDs.has(node.nodeID)) return true;
3827
+ if (node.messageIDs.some((messageID) => matches.messageIDs.has(messageID))) return true;
3828
+ return this.readSummaryChildrenSync(node.nodeID).some((child) =>
3829
+ this.nodeMatchesQuery(child, matches),
3830
+ );
3831
+ }
3832
+
3833
+ private renderRawMessagesForNode(
3834
+ session: NormalizedSession,
3835
+ node: SummaryNodeData,
3836
+ messageLimit: number,
3837
+ matches?: { messageIDs: Set<string>; nodeIDs: Set<string>; artifactIDs: Set<string> },
3838
+ indent = '',
3839
+ ): string[] {
3840
+ const byID = new Map(session.messages.map((message) => [message.info.id, message]));
3841
+ const allCovered = node.messageIDs
3842
+ .map((messageID) => byID.get(messageID))
3843
+ .filter((message): message is ConversationMessage => Boolean(message));
3844
+
3845
+ const filteredCovered =
3846
+ matches && matches.messageIDs.size > 0
3847
+ ? allCovered.filter((message) => matches.messageIDs.has(message.info.id))
3848
+ : allCovered;
3849
+ const covered = (filteredCovered.length > 0 ? filteredCovered : allCovered).slice(
3850
+ 0,
3851
+ messageLimit,
3852
+ );
3853
+ if (covered.length === 0) return [];
3854
+
3855
+ const lines = [`${indent}Raw messages:`];
3856
+ for (const message of covered) {
3857
+ const snippet =
3858
+ guessMessageText(message, this.options.interop.ignoreToolPrefixes) || '(no text content)';
3859
+ lines.push(`${indent}- ${message.info.role} ${message.info.id}: ${truncate(snippet, 220)}`);
3860
+ const artifacts = this.readArtifactsForMessageSync(message.info.id);
3861
+ const shownArtifacts =
3862
+ matches && matches.artifactIDs.size > 0
3863
+ ? artifacts.filter((artifact) => matches.artifactIDs.has(artifact.artifactID))
3864
+ : artifacts;
3865
+ for (const artifact of shownArtifacts.slice(0, 4)) {
3866
+ lines.push(
3867
+ `${indent} artifact ${artifact.artifactID} ${artifact.artifactKind}/${artifact.fieldName} (${artifact.charCount} chars): ${truncate(artifact.previewText, 120)}`,
3868
+ );
3869
+ }
3870
+ if (shownArtifacts.length > 4) {
3871
+ lines.push(`${indent} ... ${shownArtifacts.length - 4} more artifact(s)`);
3872
+ }
3873
+ }
3874
+
3875
+ if (
3876
+ (filteredCovered.length > 0 ? filteredCovered.length : allCovered.length) > covered.length
3877
+ ) {
3878
+ lines.push(
3879
+ `${indent}- ... ${(filteredCovered.length > 0 ? filteredCovered.length : allCovered.length) - covered.length} more message(s)`,
3880
+ );
3881
+ }
3882
+ return lines;
3883
+ }
3884
+
3885
+ private collectTargetedNodeLines(
3886
+ session: NormalizedSession,
3887
+ node: SummaryNodeData,
3888
+ depth: number,
3889
+ includeRaw: boolean,
3890
+ messageLimit: number,
3891
+ matches: { messageIDs: Set<string>; nodeIDs: Set<string>; artifactIDs: Set<string> },
3892
+ indent = '',
3893
+ ): string[] {
3894
+ const lines = [
3895
+ `${indent}- ${node.nodeID} (level ${node.level}, messages ${node.startIndex + 1}-${node.endIndex + 1}): ${truncate(node.summaryText, 180)}`,
3896
+ ];
3897
+ const children = this.readSummaryChildrenSync(node.nodeID).filter((child) =>
3898
+ this.nodeMatchesQuery(child, matches),
3899
+ );
3900
+
3901
+ if (children.length > 0 && depth > 0) {
3902
+ for (const child of children) {
3903
+ lines.push(
3904
+ ...this.collectTargetedNodeLines(
3905
+ session,
3906
+ child,
3907
+ depth - 1,
3908
+ includeRaw,
3909
+ messageLimit,
3910
+ matches,
3911
+ `${indent} `,
3912
+ ),
3913
+ );
3914
+ }
3915
+ return lines;
3916
+ }
3917
+
3918
+ if (includeRaw) {
3919
+ lines.push(
3920
+ ...this.renderRawMessagesForNode(session, node, messageLimit, matches, `${indent} `),
3921
+ );
3922
+ }
3923
+ return lines;
3924
+ }
3925
+
3926
+ private renderTargetedExpansion(
3927
+ session: NormalizedSession,
3928
+ node: SummaryNodeData,
3929
+ depth: number,
3930
+ includeRaw: boolean,
3931
+ messageLimit: number,
3932
+ query: string,
3933
+ matches: { messageIDs: Set<string>; nodeIDs: Set<string>; artifactIDs: Set<string> },
3934
+ ): string {
3935
+ const lines = [
3936
+ `Node: ${node.nodeID}`,
3937
+ `Session: ${node.sessionID}`,
3938
+ `Query: ${query}`,
3939
+ `Level: ${node.level}`,
3940
+ `Coverage: archived messages ${node.startIndex + 1}-${node.endIndex + 1}`,
3941
+ `Summary: ${node.summaryText}`,
3942
+ 'Targeted descendants:',
3943
+ ];
3944
+
3945
+ const children = this.readSummaryChildrenSync(node.nodeID).filter((child) =>
3946
+ this.nodeMatchesQuery(child, matches),
3947
+ );
3948
+ if (children.length > 0) {
3949
+ for (const child of children) {
3950
+ lines.push(
3951
+ ...this.collectTargetedNodeLines(
3952
+ session,
3953
+ child,
3954
+ depth - 1,
3955
+ includeRaw,
3956
+ messageLimit,
3957
+ matches,
3958
+ '',
3959
+ ),
3960
+ );
3961
+ }
3962
+ return lines.join('\n');
3963
+ }
3964
+
3965
+ lines.push(...this.renderRawMessagesForNode(session, node, messageLimit, matches));
3966
+ return lines.join('\n');
3967
+ }
3968
+
3969
+ private renderExpandedNode(
3970
+ session: NormalizedSession,
3971
+ node: SummaryNodeData,
3972
+ depth: number,
3973
+ includeRaw: boolean,
3974
+ messageLimit: number,
3975
+ ): string {
3976
+ const children = this.readSummaryChildrenSync(node.nodeID);
3977
+ const lines = [
3978
+ `Node: ${node.nodeID}`,
3979
+ `Session: ${node.sessionID}`,
3980
+ `Level: ${node.level}`,
3981
+ `Coverage: archived messages ${node.startIndex + 1}-${node.endIndex + 1}`,
3982
+ `Summary: ${node.summaryText}`,
3983
+ ];
3984
+
3985
+ if (children.length > 0) {
3986
+ lines.push('Children:');
3987
+ for (const child of children) {
3988
+ lines.push(`- ${child.nodeID}: ${truncate(child.summaryText, 180)}`);
3989
+ }
3990
+
3991
+ if (depth > 1) {
3992
+ lines.push('Deeper descendants:');
3993
+ for (const child of children) {
3994
+ const grandChildren = this.readSummaryChildrenSync(child.nodeID);
3995
+ for (const grandChild of grandChildren.slice(0, SUMMARY_BRANCH_FACTOR)) {
3996
+ lines.push(
3997
+ `- ${child.nodeID} -> ${grandChild.nodeID}: ${truncate(grandChild.summaryText, 160)}`,
3998
+ );
3999
+ }
4000
+ }
4001
+ }
4002
+
4003
+ return lines.join('\n');
4004
+ }
4005
+
4006
+ if (!includeRaw) return lines.join('\n');
4007
+
4008
+ lines.push(...this.renderRawMessagesForNode(session, node, messageLimit));
4009
+
4010
+ return lines.join('\n');
4011
+ }
4012
+
4013
+ private buildFtsQuery(query: string): string | undefined {
4014
+ return buildFtsQuery(query);
4015
+ }
4016
+
4017
+ private searchDeps() {
4018
+ return {
4019
+ getDb: () => this.getDb(),
4020
+ readScopedSessionsSync: (sessionIDs?: string[]) => this.readScopedSessionsSync(sessionIDs),
4021
+ readScopedSummaryRowsSync: (sessionIDs?: string[]) =>
4022
+ this.readScopedSummaryRowsSync(sessionIDs),
4023
+ readScopedArtifactRowsSync: (sessionIDs?: string[]) =>
4024
+ this.readScopedArtifactRowsSync(sessionIDs),
4025
+ buildArtifactSearchContent: (row: ArtifactRow) =>
4026
+ this.buildArtifactSearchContent(this.materializeArtifactRow(row)),
4027
+ ignoreToolPrefixes: this.options.interop.ignoreToolPrefixes,
4028
+ guessMessageText: (message: ConversationMessage, ignorePrefixes: string[]) =>
4029
+ guessMessageText(message, ignorePrefixes),
4030
+ };
4031
+ }
4032
+
4033
+ private artifactDeps() {
4034
+ return {
4035
+ workspaceDirectory: this.workspaceDirectory,
4036
+ options: {
4037
+ artifactPreviewChars: this.options.artifactPreviewChars,
4038
+ binaryPreviewProviders: this.options.binaryPreviewProviders,
4039
+ largeContentThreshold: this.options.largeContentThreshold,
4040
+ previewBytePeek: this.options.previewBytePeek,
4041
+ privacy: this.privacy,
4042
+ },
4043
+ getDb: () => this.getDb(),
4044
+ readArtifactBlobSync: (contentHash?: string | null) => this.readArtifactBlobSync(contentHash),
4045
+ upsertSessionRowSync: (session: NormalizedSession) => this.upsertSessionRowSync(session),
4046
+ upsertMessageInfoSync: (sessionID: string, message: ConversationMessage) =>
4047
+ this.upsertMessageInfoSync(sessionID, message),
4048
+ deleteMessageSync: (sessionID: string, messageID: string) =>
4049
+ this.deleteMessageSync(sessionID, messageID),
4050
+ replaceMessageSearchRowSync: (sessionID: string, message: ConversationMessage) =>
4051
+ this.replaceMessageSearchRowSync(sessionID, message),
4052
+ replaceMessageSearchRowsSync: (session: NormalizedSession) =>
4053
+ this.replaceMessageSearchRowsSync(session),
4054
+ };
4055
+ }
4056
+
4057
+ private searchWithFts(query: string, sessionIDs?: string[], limit = 5): SearchResult[] {
4058
+ return searchWithFtsModule(this.searchDeps(), query, sessionIDs, limit);
4059
+ }
4060
+
4061
+ private searchByScan(query: string, sessionIDs?: string[], limit = 5): SearchResult[] {
4062
+ return searchByScanModule(this.searchDeps(), query, sessionIDs, limit);
4063
+ }
4064
+
4065
+ private replaceMessageSearchRowsSync(session: NormalizedSession): void {
4066
+ replaceMessageSearchRowsModule(this.searchDeps(), redactStructuredValue(session, this.privacy));
4067
+ }
4068
+
4069
+ private replaceMessageSearchRowSync(sessionID: string, message: ConversationMessage): void {
4070
+ replaceMessageSearchRowModule(
4071
+ this.searchDeps(),
4072
+ sessionID,
4073
+ redactStructuredValue(message, this.privacy),
4074
+ );
4075
+ }
4076
+
4077
+ private refreshSearchIndexesSync(sessionIDs?: string[]): void {
4078
+ refreshSearchIndexesModule(this.searchDeps(), sessionIDs);
4079
+ }
4080
+
4081
+ private ensureSessionColumnsSync(): void {
4082
+ const db = this.getDb();
4083
+ const columns = db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>;
4084
+ const names = new Set(columns.map((column) => column.name));
4085
+
4086
+ const ensure = (column: string, definition: string) => {
4087
+ if (names.has(column)) return;
4088
+ db.exec(`ALTER TABLE sessions ADD COLUMN ${definition}`);
4089
+ names.add(column);
4090
+ };
4091
+
4092
+ ensure('session_directory', 'session_directory TEXT');
4093
+ ensure('worktree_key', 'worktree_key TEXT');
4094
+ ensure('parent_session_id', 'parent_session_id TEXT');
4095
+ ensure('root_session_id', 'root_session_id TEXT');
4096
+ ensure('lineage_depth', 'lineage_depth INTEGER');
4097
+ ensure('pinned', 'pinned INTEGER NOT NULL DEFAULT 0');
4098
+ ensure('pin_reason', 'pin_reason TEXT');
4099
+ }
4100
+
4101
+ private ensureSummaryStateColumnsSync(): void {
4102
+ const db = this.getDb();
4103
+ const columns = db.prepare('PRAGMA table_info(summary_state)').all() as Array<{ name: string }>;
4104
+ const names = new Set(columns.map((column) => column.name));
4105
+ if (names.has('archived_signature')) return;
4106
+
4107
+ db.exec("ALTER TABLE summary_state ADD COLUMN archived_signature TEXT NOT NULL DEFAULT ''");
4108
+ }
4109
+
4110
+ private ensureArtifactColumnsSync(): void {
4111
+ const db = this.getDb();
4112
+ const columns = db.prepare('PRAGMA table_info(artifacts)').all() as Array<{ name: string }>;
4113
+ const names = new Set(columns.map((column) => column.name));
4114
+
4115
+ if (!names.has('metadata_json')) {
4116
+ db.exec("ALTER TABLE artifacts ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}' ");
4117
+ names.add('metadata_json');
4118
+ }
4119
+
4120
+ if (!names.has('content_hash')) {
4121
+ db.exec('ALTER TABLE artifacts ADD COLUMN content_hash TEXT');
4122
+ }
4123
+ }
4124
+
4125
+ private backfillArtifactBlobsSync(): void {
4126
+ const db = this.getDb();
4127
+ const rows = db
4128
+ .prepare('SELECT * FROM artifacts ORDER BY created_at ASC, artifact_id ASC')
4129
+ .all() as ArtifactRow[];
4130
+ if (rows.length === 0) return;
4131
+
4132
+ const insertBlob = db.prepare(
4133
+ `INSERT OR IGNORE INTO artifact_blobs (content_hash, content_text, char_count, created_at)
4134
+ VALUES (?, ?, ?, ?)`,
4135
+ );
4136
+ const updateArtifact = db.prepare(
4137
+ "UPDATE artifacts SET content_hash = ?, content_text = CASE WHEN content_text != '' THEN '' ELSE content_text END WHERE artifact_id = ?",
4138
+ );
4139
+
4140
+ for (const row of rows) {
4141
+ const contentText =
4142
+ row.content_text || this.readArtifactBlobSync(row.content_hash)?.content_text || '';
4143
+ if (!contentText) continue;
4144
+ const contentHash = row.content_hash ?? hashContent(contentText);
4145
+ insertBlob.run(contentHash, contentText, contentText.length, row.created_at);
4146
+ if (row.content_hash !== contentHash || row.content_text !== '') {
4147
+ updateArtifact.run(contentHash, row.artifact_id);
4148
+ }
4149
+ }
4150
+ }
4151
+
4152
+ private refreshAllLineageSync(): void {
4153
+ const db = this.getDb();
4154
+ const rows = db.prepare('SELECT session_id, parent_session_id FROM sessions').all() as Array<{
4155
+ session_id: string;
4156
+ parent_session_id: string | null;
4157
+ }>;
4158
+ const byID = new Map(rows.map((row) => [row.session_id, row]));
4159
+
4160
+ const invalidParentSessionIDs = new Set<string>();
4161
+ const visiting = new Set<string>();
4162
+ const visited = new Set<string>();
4163
+
4164
+ const detectInvalidParents = (sessionID: string): void => {
4165
+ if (visited.has(sessionID)) return;
4166
+
4167
+ visiting.add(sessionID);
4168
+ const row = byID.get(sessionID);
4169
+ const parentSessionID = row?.parent_session_id ?? undefined;
4170
+
4171
+ if (parentSessionID) {
4172
+ if (parentSessionID === sessionID || visiting.has(parentSessionID)) {
4173
+ invalidParentSessionIDs.add(sessionID);
4174
+ } else if (byID.has(parentSessionID)) {
4175
+ detectInvalidParents(parentSessionID);
4176
+ }
4177
+ }
4178
+
4179
+ visiting.delete(sessionID);
4180
+ visited.add(sessionID);
4181
+ };
4182
+
4183
+ for (const row of rows) detectInvalidParents(row.session_id);
4184
+
4185
+ if (invalidParentSessionIDs.size > 0) {
4186
+ const clearParent = db.prepare(
4187
+ 'UPDATE sessions SET parent_session_id = NULL WHERE session_id = ?',
4188
+ );
4189
+ for (const sessionID of invalidParentSessionIDs) {
4190
+ clearParent.run(sessionID);
4191
+ const row = byID.get(sessionID);
4192
+ if (row) row.parent_session_id = null;
4193
+ }
4194
+ }
4195
+
4196
+ const memo = new Map<string, { rootSessionID: string; lineageDepth: number }>();
4197
+
4198
+ const resolve = (sessionID: string): { rootSessionID: string; lineageDepth: number } => {
4199
+ const existing = memo.get(sessionID);
4200
+ if (existing) return existing;
4201
+
4202
+ const row = byID.get(sessionID);
4203
+ let resolved: { rootSessionID: string; lineageDepth: number };
4204
+
4205
+ if (!row?.parent_session_id) {
4206
+ resolved = { rootSessionID: sessionID, lineageDepth: 0 };
4207
+ } else {
4208
+ const parent = resolve(row.parent_session_id);
4209
+ resolved = {
4210
+ rootSessionID: parent.rootSessionID,
4211
+ lineageDepth: parent.lineageDepth + 1,
4212
+ };
4213
+ }
4214
+
4215
+ memo.set(sessionID, resolved);
4216
+ return resolved;
4217
+ };
4218
+
4219
+ const update = db.prepare(
4220
+ 'UPDATE sessions SET root_session_id = ?, lineage_depth = ? WHERE session_id = ?',
4221
+ );
4222
+ for (const row of rows) {
4223
+ const lineage = resolve(row.session_id);
4224
+ update.run(lineage.rootSessionID, lineage.lineageDepth, row.session_id);
4225
+ }
4226
+ }
4227
+
4228
+ private resolveLineageSync(
4229
+ sessionID: string,
4230
+ parentSessionID?: string,
4231
+ ): { rootSessionID: string; lineageDepth: number } {
4232
+ if (!parentSessionID) return { rootSessionID: sessionID, lineageDepth: 0 };
4233
+
4234
+ const parent = this.getDb()
4235
+ .prepare('SELECT root_session_id, lineage_depth FROM sessions WHERE session_id = ?')
4236
+ .get(parentSessionID) as
4237
+ | { root_session_id: string | null; lineage_depth: number | null }
4238
+ | undefined;
4239
+
4240
+ if (!parent) return { rootSessionID: parentSessionID, lineageDepth: 1 };
4241
+ return {
4242
+ rootSessionID: parent.root_session_id ?? parentSessionID,
4243
+ lineageDepth: (parent.lineage_depth ?? 0) + 1,
4244
+ };
4245
+ }
4246
+
4247
+ private applyEvent(session: NormalizedSession, event: CapturedEvent): NormalizedSession {
4248
+ const payload = event.payload as Event;
4249
+
4250
+ switch (payload.type) {
4251
+ case 'session.created':
4252
+ case 'session.updated':
4253
+ session.title = payload.properties.info.title;
4254
+ session.directory = payload.properties.info.directory;
4255
+ session.parentSessionID = payload.properties.info.parentID ?? undefined;
4256
+ session.deleted = false;
4257
+ return session;
4258
+ case 'session.deleted':
4259
+ session.title = payload.properties.info.title;
4260
+ session.directory = payload.properties.info.directory;
4261
+ session.parentSessionID = payload.properties.info.parentID ?? session.parentSessionID;
4262
+ session.deleted = true;
4263
+ return session;
4264
+ case 'session.compacted':
4265
+ session.compactedAt = event.timestamp;
4266
+ return session;
4267
+ case 'message.updated': {
4268
+ const existing = session.messages.find(
4269
+ (message) => message.info.id === payload.properties.info.id,
4270
+ );
4271
+ if (existing) existing.info = payload.properties.info;
4272
+ else {
4273
+ session.messages.push({ info: payload.properties.info, parts: [] });
4274
+ session.messages.sort(compareMessages);
4275
+ }
4276
+ return session;
4277
+ }
4278
+ case 'message.removed':
4279
+ session.messages = session.messages.filter(
4280
+ (message) => message.info.id !== payload.properties.messageID,
4281
+ );
4282
+ return session;
4283
+ case 'message.part.updated': {
4284
+ const message = session.messages.find(
4285
+ (entry) => entry.info.id === payload.properties.part.messageID,
4286
+ );
4287
+ if (!message) return session;
4288
+
4289
+ const existing = message.parts.findIndex((part) => part.id === payload.properties.part.id);
4290
+ if (existing >= 0) message.parts[existing] = payload.properties.part;
4291
+ else message.parts.push(payload.properties.part);
4292
+ return session;
4293
+ }
4294
+ case 'message.part.removed': {
4295
+ const message = session.messages.find(
4296
+ (entry) => entry.info.id === payload.properties.messageID,
4297
+ );
4298
+ if (!message) return session;
4299
+ message.parts = message.parts.filter((part) => part.id !== payload.properties.partID);
4300
+ return session;
4301
+ }
4302
+ default:
4303
+ return session;
4304
+ }
4305
+ }
4306
+
4307
+ private getResumeSync(sessionID: string): string | undefined {
4308
+ const row = safeQueryOne<{ note: string }>(
4309
+ this.getDb().prepare('SELECT note FROM resumes WHERE session_id = ?'),
4310
+ [sessionID],
4311
+ 'getResumeSync',
4312
+ );
4313
+ return row?.note;
4314
+ }
4315
+
4316
+ private readSessionHeaderSync(sessionID: string): NormalizedSession | undefined {
4317
+ const row = safeQueryOne<SessionRow>(
4318
+ this.getDb().prepare('SELECT * FROM sessions WHERE session_id = ?'),
4319
+ [sessionID],
4320
+ 'readSessionHeaderSync',
4321
+ );
4322
+ if (!row) return undefined;
4323
+
4324
+ return {
4325
+ sessionID: row.session_id,
4326
+ title: row.title ?? undefined,
4327
+ directory: row.session_directory ?? undefined,
4328
+ parentSessionID: row.parent_session_id ?? undefined,
4329
+ rootSessionID: row.root_session_id ?? undefined,
4330
+ lineageDepth: row.lineage_depth ?? undefined,
4331
+ pinned: Boolean(row.pinned),
4332
+ pinReason: row.pin_reason ?? undefined,
4333
+ updatedAt: row.updated_at,
4334
+ compactedAt: row.compacted_at ?? undefined,
4335
+ deleted: Boolean(row.deleted),
4336
+ eventCount: row.event_count,
4337
+ messages: [],
4338
+ };
4339
+ }
4340
+
4341
+ private clearSessionDataSync(sessionID: string): void {
4342
+ const db = this.getDb();
4343
+ db.prepare('DELETE FROM message_fts WHERE session_id = ?').run(sessionID);
4344
+ db.prepare('DELETE FROM summary_fts WHERE session_id = ?').run(sessionID);
4345
+ db.prepare('DELETE FROM artifact_fts WHERE session_id = ?').run(sessionID);
4346
+ db.prepare('DELETE FROM artifacts WHERE session_id = ?').run(sessionID);
4347
+ db.prepare('DELETE FROM summary_edges WHERE session_id = ?').run(sessionID);
4348
+ db.prepare('DELETE FROM summary_nodes WHERE session_id = ?').run(sessionID);
4349
+ db.prepare('DELETE FROM summary_state WHERE session_id = ?').run(sessionID);
4350
+ db.prepare('DELETE FROM resumes WHERE session_id = ?').run(sessionID);
4351
+ db.prepare('DELETE FROM parts WHERE session_id = ?').run(sessionID);
4352
+ db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionID);
4353
+ db.prepare('DELETE FROM events WHERE session_id = ?').run(sessionID);
4354
+ db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionID);
4355
+ }
4356
+
4357
+ private readChildSessionsSync(sessionID: string): NormalizedSession[] {
4358
+ const rows = this.getDb()
4359
+ .prepare(
4360
+ 'SELECT session_id FROM sessions WHERE parent_session_id = ? ORDER BY updated_at DESC',
4361
+ )
4362
+ .all(sessionID) as Array<{ session_id: string }>;
4363
+ return rows
4364
+ .map((row) => this.readSessionHeaderSync(row.session_id))
4365
+ .filter((row): row is NormalizedSession => Boolean(row));
4366
+ }
4367
+
4368
+ private readLineageChainSync(sessionID: string): NormalizedSession[] {
4369
+ const chain: NormalizedSession[] = [];
4370
+ const seen = new Set<string>();
4371
+ let currentID: string | undefined = sessionID;
4372
+
4373
+ while (currentID && !seen.has(currentID)) {
4374
+ seen.add(currentID);
4375
+ const session = this.readSessionHeaderSync(currentID);
4376
+ if (!session) break;
4377
+ chain.unshift(session);
4378
+ currentID = session.parentSessionID;
4379
+ }
4380
+
4381
+ return chain;
4382
+ }
4383
+
4384
+ private readAllSessionsSync(): NormalizedSession[] {
4385
+ const rows = this.getDb()
4386
+ .prepare(
4387
+ 'SELECT session_id FROM sessions WHERE event_count > 0 OR updated_at > 0 ORDER BY updated_at DESC',
4388
+ )
4389
+ .all() as Array<{ session_id: string }>;
4390
+ const sessionIDs = rows.map((row) => row.session_id);
4391
+ if (sessionIDs.length <= 1) return sessionIDs.map((id) => this.readSessionSync(id));
4392
+ return this.readSessionsBatchSync(sessionIDs);
4393
+ }
4394
+
4395
+ private readSessionsBatchSync(sessionIDs: string[]): NormalizedSession[] {
4396
+ const db = this.getDb();
4397
+ const placeholders = sessionIDs.map(() => '?').join(', ');
4398
+
4399
+ // 1. Session headers (batch)
4400
+ const sessionRows = db
4401
+ .prepare(`SELECT * FROM sessions WHERE session_id IN (${placeholders})`)
4402
+ .all(...sessionIDs) as SessionRow[];
4403
+ const sessionMap = new Map<string, SessionRow>();
4404
+ for (const row of sessionRows) sessionMap.set(row.session_id, row);
4405
+
4406
+ // 2. Messages (batch)
4407
+ const messageRows = db
4408
+ .prepare(
4409
+ `SELECT * FROM messages WHERE session_id IN (${placeholders}) ORDER BY session_id ASC, created_at ASC, message_id ASC`,
4410
+ )
4411
+ .all(...sessionIDs) as MessageRow[];
4412
+
4413
+ // 3. Parts (batch)
4414
+ const partRows = db
4415
+ .prepare(
4416
+ `SELECT * FROM parts WHERE session_id IN (${placeholders}) ORDER BY session_id ASC, message_id ASC, sort_key ASC, part_id ASC`,
4417
+ )
4418
+ .all(...sessionIDs) as PartRow[];
4419
+
4420
+ // 4. Artifacts (batch)
4421
+ const artifactRows = db
4422
+ .prepare(
4423
+ `SELECT * FROM artifacts WHERE session_id IN (${placeholders}) ORDER BY created_at ASC, artifact_id ASC`,
4424
+ )
4425
+ .all(...sessionIDs) as ArtifactRow[];
4426
+
4427
+ // 5. Artifact blobs (batch)
4428
+ const contentHashes = [
4429
+ ...new Set(artifactRows.map((r) => r.content_hash).filter(Boolean) as string[]),
4430
+ ];
4431
+ const blobMap = new Map<string, ArtifactBlobRow>();
4432
+ if (contentHashes.length > 0) {
4433
+ const blobPlaceholders = contentHashes.map(() => '?').join(', ');
4434
+ const blobRows = db
4435
+ .prepare(`SELECT * FROM artifact_blobs WHERE content_hash IN (${blobPlaceholders})`)
4436
+ .all(...contentHashes) as ArtifactBlobRow[];
4437
+ for (const blob of blobRows) blobMap.set(blob.content_hash, blob);
4438
+ }
4439
+
4440
+ // Group artifacts by part ID
4441
+ const artifactsByPart = new Map<string, ArtifactData[]>();
4442
+ for (const row of artifactRows) {
4443
+ const contentHash = row.content_hash;
4444
+ const blob = contentHash ? blobMap.get(contentHash) : undefined;
4445
+ const contentText = blob?.content_text ?? row.content_text;
4446
+ const artifact: ArtifactData = {
4447
+ artifactID: row.artifact_id,
4448
+ sessionID: row.session_id,
4449
+ messageID: row.message_id,
4450
+ partID: row.part_id,
4451
+ artifactKind: row.artifact_kind,
4452
+ fieldName: row.field_name,
4453
+ previewText: row.preview_text,
4454
+ contentText,
4455
+ contentHash: contentHash ?? hashContent(contentText),
4456
+ charCount: blob?.char_count ?? row.char_count,
4457
+ createdAt: row.created_at,
4458
+ metadata: parseJson<Record<string, unknown>>(row.metadata_json || '{}'),
4459
+ };
4460
+ const list = artifactsByPart.get(artifact.partID) ?? [];
4461
+ list.push(artifact);
4462
+ artifactsByPart.set(artifact.partID, list);
4463
+ }
4464
+
4465
+ // Assemble parts per session+message
4466
+ const partsBySessionMessage = new Map<string, Map<string, Part[]>>();
4467
+ for (const partRow of partRows) {
4468
+ const _messageKey = `${partRow.session_id}|${partRow.message_id}`;
4469
+ let partsByMessage = partsBySessionMessage.get(partRow.session_id);
4470
+ if (!partsByMessage) {
4471
+ partsByMessage = new Map();
4472
+ partsBySessionMessage.set(partRow.session_id, partsByMessage);
4473
+ }
4474
+ const part = parseJson<Part>(partRow.part_json);
4475
+ const artifacts = artifactsByPart.get(part.id) ?? [];
4476
+ hydratePartFromArtifacts(part, artifacts);
4477
+ const parts = partsByMessage.get(partRow.message_id) ?? [];
4478
+ parts.push(part);
4479
+ partsByMessage.set(partRow.message_id, parts);
4480
+ }
4481
+
4482
+ // Group messages per session
4483
+ const messagesBySession = new Map<string, Array<{ info: Message; parts: Part[] }>>();
4484
+ for (const messageRow of messageRows) {
4485
+ const sessionParts = partsBySessionMessage.get(messageRow.session_id);
4486
+ const messages = messagesBySession.get(messageRow.session_id) ?? [];
4487
+ messages.push({
4488
+ info: parseJson<Message>(messageRow.info_json),
4489
+ parts: sessionParts?.get(messageRow.message_id) ?? [],
4490
+ });
4491
+ messagesBySession.set(messageRow.session_id, messages);
4492
+ }
4493
+
4494
+ // Build NormalizedSession results
4495
+ return sessionIDs.map((sessionID) => {
4496
+ const row = sessionMap.get(sessionID);
4497
+ const messages = messagesBySession.get(sessionID) ?? [];
4498
+ if (!row) {
4499
+ return { sessionID, updatedAt: 0, eventCount: 0, messages };
4500
+ }
4501
+ return {
4502
+ sessionID: row.session_id,
4503
+ title: row.title ?? undefined,
4504
+ directory: row.session_directory ?? undefined,
4505
+ parentSessionID: row.parent_session_id ?? undefined,
4506
+ rootSessionID: row.root_session_id ?? undefined,
4507
+ lineageDepth: row.lineage_depth ?? undefined,
4508
+ pinned: Boolean(row.pinned),
4509
+ pinReason: row.pin_reason ?? undefined,
4510
+ updatedAt: row.updated_at,
4511
+ compactedAt: row.compacted_at ?? undefined,
4512
+ deleted: Boolean(row.deleted),
4513
+ eventCount: row.event_count,
4514
+ messages,
4515
+ };
4516
+ });
4517
+ }
4518
+
4519
+ private readSessionSync(sessionID: string, options?: ReadSessionOptions): NormalizedSession {
4520
+ const db = this.getDb();
4521
+ const row = safeQueryOne<SessionRow>(
4522
+ db.prepare('SELECT * FROM sessions WHERE session_id = ?'),
4523
+ [sessionID],
4524
+ 'readSessionSync',
4525
+ );
4526
+ const messageRows = db
4527
+ .prepare(
4528
+ 'SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC, message_id ASC',
4529
+ )
4530
+ .all(sessionID) as MessageRow[];
4531
+ const partRows = db
4532
+ .prepare(
4533
+ 'SELECT * FROM parts WHERE session_id = ? ORDER BY message_id ASC, sort_key ASC, part_id ASC',
4534
+ )
4535
+ .all(sessionID) as PartRow[];
4536
+ const artifactsByPart = new Map<string, ArtifactData[]>();
4537
+ const artifactMessageIDs = options?.artifactMessageIDs;
4538
+ const artifacts =
4539
+ artifactMessageIDs === undefined
4540
+ ? this.readArtifactsForSessionSync(sessionID)
4541
+ : [...new Set(artifactMessageIDs)].flatMap((messageID) =>
4542
+ this.readArtifactsForMessageSync(messageID),
4543
+ );
4544
+ for (const artifact of artifacts) {
4545
+ const list = artifactsByPart.get(artifact.partID) ?? [];
4546
+ list.push(artifact);
4547
+ artifactsByPart.set(artifact.partID, list);
4548
+ }
4549
+
4550
+ const partsByMessage = new Map<string, Part[]>();
4551
+ for (const partRow of partRows) {
4552
+ const parts = partsByMessage.get(partRow.message_id) ?? [];
4553
+ const part = parseJson<Part>(partRow.part_json);
4554
+ const artifacts = artifactsByPart.get(part.id) ?? [];
4555
+ hydratePartFromArtifacts(part, artifacts);
4556
+ parts.push(part);
4557
+ partsByMessage.set(partRow.message_id, parts);
4558
+ }
4559
+
4560
+ const messages = messageRows.map((messageRow) => ({
4561
+ info: parseJson<Message>(messageRow.info_json),
4562
+ parts: partsByMessage.get(messageRow.message_id) ?? [],
4563
+ }));
4564
+
4565
+ if (!row) {
4566
+ return {
4567
+ sessionID,
4568
+ updatedAt: 0,
4569
+ eventCount: 0,
4570
+ messages,
4571
+ };
4572
+ }
4573
+
4574
+ return {
4575
+ sessionID: row.session_id,
4576
+ title: row.title ?? undefined,
4577
+ directory: row.session_directory ?? undefined,
4578
+ parentSessionID: row.parent_session_id ?? undefined,
4579
+ rootSessionID: row.root_session_id ?? undefined,
4580
+ lineageDepth: row.lineage_depth ?? undefined,
4581
+ pinned: Boolean(row.pinned),
4582
+ pinReason: row.pin_reason ?? undefined,
4583
+ updatedAt: row.updated_at,
4584
+ compactedAt: row.compacted_at ?? undefined,
4585
+ deleted: Boolean(row.deleted),
4586
+ eventCount: row.event_count,
4587
+ messages,
4588
+ };
4589
+ }
4590
+
4591
+ private prepareSessionForPersistence(session: NormalizedSession): NormalizedSession {
4592
+ const parentSessionID = this.sanitizeParentSessionIDSync(
4593
+ session.sessionID,
4594
+ session.parentSessionID,
4595
+ );
4596
+ const lineage = this.resolveLineageSync(session.sessionID, parentSessionID);
4597
+ return {
4598
+ ...session,
4599
+ parentSessionID,
4600
+ rootSessionID: lineage.rootSessionID,
4601
+ lineageDepth: lineage.lineageDepth,
4602
+ };
4603
+ }
4604
+
4605
+ private sanitizeParentSessionIDSync(
4606
+ sessionID: string,
4607
+ parentSessionID?: string,
4608
+ ): string | undefined {
4609
+ if (!parentSessionID || parentSessionID === sessionID) return undefined;
4610
+
4611
+ const seen = new Set<string>([sessionID]);
4612
+ let currentSessionID: string | undefined = parentSessionID;
4613
+ while (currentSessionID) {
4614
+ if (seen.has(currentSessionID)) return undefined;
4615
+ seen.add(currentSessionID);
4616
+ const row = this.getDb()
4617
+ .prepare('SELECT parent_session_id FROM sessions WHERE session_id = ?')
4618
+ .get(currentSessionID) as { parent_session_id: string | null } | undefined;
4619
+ currentSessionID = row?.parent_session_id ?? undefined;
4620
+ }
4621
+
4622
+ return parentSessionID;
4623
+ }
4624
+
4625
+ private async persistCapturedSession(
4626
+ session: NormalizedSession,
4627
+ event: CapturedEvent,
4628
+ ): Promise<void> {
4629
+ const payload = event.payload as Event;
4630
+
4631
+ switch (payload.type) {
4632
+ case 'session.created':
4633
+ case 'session.updated':
4634
+ case 'session.deleted':
4635
+ case 'session.compacted':
4636
+ withTransaction(this.getDb(), 'capture', () => {
4637
+ this.upsertSessionRowSync(session);
4638
+ });
4639
+ return;
4640
+ case 'message.updated': {
4641
+ const message = session.messages.find(
4642
+ (entry) => entry.info.id === payload.properties.info.id,
4643
+ );
4644
+ withTransaction(this.getDb(), 'capture', () => {
4645
+ this.upsertSessionRowSync(session);
4646
+ if (message) {
4647
+ this.upsertMessageInfoSync(session.sessionID, message);
4648
+ this.replaceMessageSearchRowSync(session.sessionID, message);
4649
+ }
4650
+ });
4651
+ return;
4652
+ }
4653
+ case 'message.removed':
4654
+ withTransaction(this.getDb(), 'capture', () => {
4655
+ this.upsertSessionRowSync(session);
4656
+ this.deleteMessageSync(session.sessionID, payload.properties.messageID);
4657
+ });
4658
+ return;
4659
+ case 'message.part.updated': {
4660
+ const message = session.messages.find(
4661
+ (entry) => entry.info.id === payload.properties.part.messageID,
4662
+ );
4663
+ const externalized = message ? await this.externalizeMessage(message) : undefined;
4664
+ withTransaction(this.getDb(), 'capture', () => {
4665
+ this.upsertSessionRowSync(session);
4666
+ if (externalized) {
4667
+ this.replaceStoredMessageSync(
4668
+ session.sessionID,
4669
+ externalized.storedMessage,
4670
+ externalized.artifacts,
4671
+ );
4672
+ }
4673
+ });
4674
+ return;
4675
+ }
4676
+ case 'message.part.removed': {
4677
+ const message = session.messages.find(
4678
+ (entry) => entry.info.id === payload.properties.messageID,
4679
+ );
4680
+ const externalized = message ? await this.externalizeMessage(message) : undefined;
4681
+ withTransaction(this.getDb(), 'capture', () => {
4682
+ this.upsertSessionRowSync(session);
4683
+ if (externalized) {
4684
+ this.replaceStoredMessageSync(
4685
+ session.sessionID,
4686
+ externalized.storedMessage,
4687
+ externalized.artifacts,
4688
+ );
4689
+ }
4690
+ });
4691
+ return;
4692
+ }
4693
+ default: {
4694
+ const externalized = await this.externalizeSession(session);
4695
+ withTransaction(this.getDb(), 'capture', () => {
4696
+ this.persistStoredSessionSync(externalized.storedSession, externalized.artifacts);
4697
+ });
4698
+ }
4699
+ }
4700
+ }
4701
+
4702
+ private async persistSession(session: NormalizedSession): Promise<void> {
4703
+ const preparedSession = this.prepareSessionForPersistence(session);
4704
+ const { storedSession, artifacts } = await this.externalizeSession(preparedSession);
4705
+
4706
+ withTransaction(this.getDb(), 'persistSession', () => {
4707
+ this.persistStoredSessionSync(storedSession, artifacts);
4708
+ });
4709
+ }
4710
+
4711
+ private persistStoredSessionSync(
4712
+ storedSession: NormalizedSession,
4713
+ artifacts: ArtifactData[],
4714
+ ): void {
4715
+ persistStoredSessionSyncModule(this.artifactDeps(), storedSession, artifacts);
4716
+ }
4717
+
4718
+ private upsertSessionRowSync(session: NormalizedSession): void {
4719
+ const db = this.getDb();
4720
+ const title = session.title ? redactText(session.title, this.privacy) : undefined;
4721
+ const directory = session.directory ? redactText(session.directory, this.privacy) : undefined;
4722
+ const pinReason = session.pinReason ? redactText(session.pinReason, this.privacy) : undefined;
4723
+ const worktreeKey = normalizeWorktreeKey(directory);
4724
+
4725
+ db.prepare(
4726
+ `INSERT INTO sessions (session_id, title, session_directory, worktree_key, parent_session_id, root_session_id, lineage_depth, pinned, pin_reason, updated_at, compacted_at, deleted, event_count)
4727
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4728
+ ON CONFLICT(session_id) DO UPDATE SET
4729
+ title = excluded.title,
4730
+ session_directory = excluded.session_directory,
4731
+ worktree_key = excluded.worktree_key,
4732
+ parent_session_id = excluded.parent_session_id,
4733
+ root_session_id = excluded.root_session_id,
4734
+ lineage_depth = excluded.lineage_depth,
4735
+ pinned = excluded.pinned,
4736
+ pin_reason = excluded.pin_reason,
4737
+ updated_at = excluded.updated_at,
4738
+ compacted_at = excluded.compacted_at,
4739
+ deleted = excluded.deleted,
4740
+ event_count = excluded.event_count`,
4741
+ ).run(
4742
+ session.sessionID,
4743
+ title ?? null,
4744
+ directory ?? null,
4745
+ worktreeKey ?? null,
4746
+ session.parentSessionID ?? null,
4747
+ session.rootSessionID ?? session.sessionID,
4748
+ session.lineageDepth ?? 0,
4749
+ session.pinned ? 1 : 0,
4750
+ pinReason ?? null,
4751
+ session.updatedAt,
4752
+ session.compactedAt ?? null,
4753
+ session.deleted ? 1 : 0,
4754
+ session.eventCount,
4755
+ );
4756
+ }
4757
+
4758
+ private upsertMessageInfoSync(sessionID: string, message: ConversationMessage): void {
4759
+ const info = redactStructuredValue(message.info, this.privacy);
4760
+ this.getDb()
4761
+ .prepare(
4762
+ `INSERT INTO messages (message_id, session_id, created_at, info_json)
4763
+ VALUES (?, ?, ?, ?)
4764
+ ON CONFLICT(message_id) DO UPDATE SET
4765
+ session_id = excluded.session_id,
4766
+ created_at = excluded.created_at,
4767
+ info_json = excluded.info_json`,
4768
+ )
4769
+ .run(info.id, sessionID, info.time.created, JSON.stringify(info));
4770
+ }
4771
+
4772
+ private deleteMessageSync(sessionID: string, messageID: string): void {
4773
+ const db = this.getDb();
4774
+ db.prepare('DELETE FROM artifact_fts WHERE message_id = ?').run(messageID);
4775
+ db.prepare('DELETE FROM message_fts WHERE message_id = ?').run(messageID);
4776
+ db.prepare('DELETE FROM artifacts WHERE session_id = ? AND message_id = ?').run(
4777
+ sessionID,
4778
+ messageID,
4779
+ );
4780
+ db.prepare('DELETE FROM parts WHERE session_id = ? AND message_id = ?').run(
4781
+ sessionID,
4782
+ messageID,
4783
+ );
4784
+ db.prepare('DELETE FROM messages WHERE session_id = ? AND message_id = ?').run(
4785
+ sessionID,
4786
+ messageID,
4787
+ );
4788
+ }
4789
+
4790
+ private replaceStoredMessageSync(
4791
+ sessionID: string,
4792
+ storedMessage: ConversationMessage,
4793
+ artifacts: ArtifactData[],
4794
+ ): void {
4795
+ replaceStoredMessageSyncModule(this.artifactDeps(), sessionID, storedMessage, artifacts);
4796
+ }
4797
+
4798
+ private async externalizeMessage(message: ConversationMessage): Promise<ExternalizedMessage> {
4799
+ return externalizeMessageModule(this.artifactDeps(), message);
4800
+ }
4801
+
4802
+ private formatArtifactMetadataLines(metadata: Record<string, unknown>): string[] {
4803
+ return formatArtifactMetadataLinesModule(metadata);
4804
+ }
4805
+
4806
+ private buildArtifactSearchContent(artifact: ArtifactData): string {
4807
+ return buildArtifactSearchContentModule(artifact);
4808
+ }
4809
+
4810
+ private async externalizeSession(session: NormalizedSession): Promise<ExternalizedSession> {
4811
+ return externalizeSessionModule(this.artifactDeps(), session);
4812
+ }
4813
+
4814
+ private writeEvent(event: CapturedEvent): void {
4815
+ const payloadStub =
4816
+ event.type.startsWith('message.') || event.type.startsWith('session.')
4817
+ ? `[${event.type}]`
4818
+ : '';
4819
+ this.getDb()
4820
+ .prepare(
4821
+ `INSERT OR IGNORE INTO events (id, session_id, event_type, ts, payload_json)
4822
+ VALUES (?, ?, ?, ?, ?)`,
4823
+ )
4824
+ .run(event.id, event.sessionID ?? null, event.type, event.timestamp, payloadStub);
4825
+ }
4826
+
4827
+ private clearSummaryGraphSync(sessionID: string): void {
4828
+ const db = this.getDb();
4829
+ db.prepare('DELETE FROM summary_fts WHERE session_id = ?').run(sessionID);
4830
+ db.prepare('DELETE FROM summary_edges WHERE session_id = ?').run(sessionID);
4831
+ db.prepare('DELETE FROM summary_nodes WHERE session_id = ?').run(sessionID);
4832
+ db.prepare('DELETE FROM summary_state WHERE session_id = ?').run(sessionID);
4833
+ }
4834
+
4835
+ private latestSessionIDSync(): string | undefined {
4836
+ const row = this.getDb()
4837
+ .prepare(
4838
+ 'SELECT session_id FROM sessions WHERE event_count > 0 ORDER BY updated_at DESC LIMIT 1',
4839
+ )
4840
+ .get() as { session_id: string } | undefined;
4841
+ return row?.session_id;
4842
+ }
4843
+
4844
+ private async migrateLegacyArtifacts(): Promise<void> {
4845
+ const db = this.getDb();
4846
+ const existing = db.prepare('SELECT COUNT(*) AS count FROM sessions').get() as {
4847
+ count: number;
4848
+ };
4849
+ if (existing.count > 0) return;
4850
+
4851
+ const sessionsDir = path.join(this.baseDir, 'sessions');
4852
+ try {
4853
+ const entries = await readdir(sessionsDir);
4854
+ for (const entry of entries.filter((item) => item.endsWith('.json'))) {
4855
+ const content = await readFile(path.join(sessionsDir, entry), 'utf8');
4856
+ const session = parseJson<NormalizedSession>(content);
4857
+ await this.persistSession(session);
4858
+ }
4859
+ } catch (error) {
4860
+ if (!hasErrorCode(error, 'ENOENT')) {
4861
+ getLogger().debug('Legacy session snapshot migration skipped', { error });
4862
+ }
4863
+ }
4864
+
4865
+ const resumePath = path.join(this.baseDir, 'resume.json');
4866
+ try {
4867
+ const content = await readFile(resumePath, 'utf8');
4868
+ const resumes = parseJson<ResumeMap>(content);
4869
+ const insertResume = db.prepare(
4870
+ `INSERT INTO resumes (session_id, note, updated_at)
4871
+ VALUES (?, ?, ?)
4872
+ ON CONFLICT(session_id) DO UPDATE SET note = excluded.note, updated_at = excluded.updated_at`,
4873
+ );
4874
+ const now = Date.now();
4875
+ for (const [sessionID, note] of Object.entries(resumes)) {
4876
+ insertResume.run(sessionID, note, now);
4877
+ }
4878
+ } catch (error) {
4879
+ if (!hasErrorCode(error, 'ENOENT')) {
4880
+ getLogger().debug('Legacy resume migration skipped', { error });
4881
+ }
4882
+ }
4883
+
4884
+ const eventsPath = path.join(this.baseDir, 'events.jsonl');
4885
+ try {
4886
+ const content = await readFile(eventsPath, 'utf8');
4887
+ for (const line of content.split('\n').filter(Boolean)) {
4888
+ try {
4889
+ const event = parseJson<CapturedEvent>(line);
4890
+ this.writeEvent(event);
4891
+ } catch (error) {
4892
+ getLogger().debug('Malformed legacy event line skipped', { error });
4893
+ }
4894
+ }
4895
+ } catch (error) {
4896
+ if (!hasErrorCode(error, 'ENOENT')) {
4897
+ getLogger().debug('Legacy event migration skipped', { error });
4898
+ }
4899
+ }
4900
+ }
4901
+
4902
+ private getDb(): SqlDatabaseLike {
4903
+ if (!this.db) {
4904
+ throw new Error(
4905
+ 'LCM store database not ready. Call store.init() before any store operation.',
4906
+ );
4907
+ }
4908
+ return this.db;
4909
+ }
4910
+ }
4911
+
4912
+ export {
4913
+ assertSupportedSchemaVersionSync,
4914
+ readAllSessions,
4915
+ readArtifact,
4916
+ readArtifactBlob,
4917
+ readArtifactsForSession,
4918
+ readChildSessions,
4919
+ readLatestSessionID,
4920
+ readLineageChain,
4921
+ readMessagesForSession,
4922
+ readSchemaVersionSync,
4923
+ readSessionHeader,
4924
+ readSessionStats,
4925
+ writeSchemaVersionSync,
4926
+ };