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/dist/store.js ADDED
@@ -0,0 +1,3673 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readdir, readFile, stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { buildActiveSummaryText, renderAutomaticRetrievalContext, resolveArchiveTransformWindow, selectAutomaticRetrievalHits, } from './archive-transform.js';
5
+ import { AUTOMATIC_RETRIEVAL_QUERY_TOKENS, AUTOMATIC_RETRIEVAL_QUERY_VARIANTS, AUTOMATIC_RETRIEVAL_RECENT_MESSAGES, EXPAND_MESSAGE_LIMIT, STORE_SCHEMA_VERSION, SUMMARY_BRANCH_FACTOR, SUMMARY_LEAF_MESSAGES, SUMMARY_NODE_CHAR_LIMIT, } from './constants.js';
6
+ import { formatDoctorReport } from './doctor.js';
7
+ import { getLogger, isStartupLoggingEnabled } from './logging.js';
8
+ import { compilePrivacyOptions, redactStructuredValue, redactText, } from './privacy.js';
9
+ import { safeQuery, safeQueryOne, validateRow, withTransaction } from './sql-utils.js';
10
+ import { buildArtifactSearchContent as buildArtifactSearchContentModule, externalizeMessage as externalizeMessageModule, externalizeSession as externalizeSessionModule, formatArtifactMetadataLines as formatArtifactMetadataLinesModule, materializeArtifactRow as materializeArtifactRowModule, persistStoredSessionSync as persistStoredSessionSyncModule, replaceStoredMessageSync as replaceStoredMessageSyncModule, } from './store-artifacts.js';
11
+ import { buildFtsQuery, filterTokensByTfidf, refreshSearchIndexesSync as refreshSearchIndexesModule, replaceMessageSearchRowSync as replaceMessageSearchRowModule, replaceMessageSearchRowsSync as replaceMessageSearchRowsModule, searchByScan as searchByScanModule, searchWithFts as searchWithFtsModule, } from './store-search.js';
12
+ import { exportStoreSnapshot, importStoreSnapshot, } from './store-snapshot.js';
13
+ import { asRecord, clamp, filterIntentTokens, firstFiniteNumber, formatRetentionDays, hashContent, isAutomaticRetrievalNoise, parseJson, sanitizeAutomaticRetrievalSourceText, shortNodeID, shouldSuppressLowSignalAutomaticRetrievalAnchor, tokenizeQuery, truncate, } from './utils.js';
14
+ import { normalizeWorktreeKey } from './worktree-key.js';
15
+ function readSchemaVersionSync(db) {
16
+ const result = db.prepare('PRAGMA user_version').get();
17
+ if (!result || typeof result !== 'object')
18
+ return 0;
19
+ for (const value of Object.values(result)) {
20
+ if (typeof value === 'number' && Number.isFinite(value))
21
+ return value;
22
+ }
23
+ return 0;
24
+ }
25
+ function assertSupportedSchemaVersionSync(db, maxVersion) {
26
+ const schemaVersion = readSchemaVersionSync(db);
27
+ if (schemaVersion <= maxVersion)
28
+ return;
29
+ throw new Error(`Unsupported store schema version: ${schemaVersion}. This build supports up to ${maxVersion}.`);
30
+ }
31
+ function writeSchemaVersionSync(db, version) {
32
+ db.exec(`PRAGMA user_version = ${Math.max(0, Math.trunc(version))}`);
33
+ }
34
+ function readSessionHeader(db, sessionID) {
35
+ return db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(sessionID);
36
+ }
37
+ function readAllSessions(db) {
38
+ return db.prepare('SELECT * FROM sessions ORDER BY updated_at DESC').all();
39
+ }
40
+ function readChildSessions(db, parentSessionID) {
41
+ return db
42
+ .prepare('SELECT * FROM sessions WHERE parent_session_id = ? ORDER BY updated_at DESC')
43
+ .all(parentSessionID);
44
+ }
45
+ function readLineageChain(db, sessionID) {
46
+ const chain = [];
47
+ let current = readSessionHeader(db, sessionID);
48
+ while (current) {
49
+ chain.unshift(current);
50
+ const parentID = current.parent_session_id;
51
+ if (!parentID)
52
+ break;
53
+ current = readSessionHeader(db, parentID);
54
+ }
55
+ return chain;
56
+ }
57
+ function readMessagesForSession(db, sessionID) {
58
+ return db
59
+ .prepare('SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC')
60
+ .all(sessionID);
61
+ }
62
+ function _readPartsForSession(db, sessionID) {
63
+ return db
64
+ .prepare('SELECT * FROM parts WHERE session_id = ? ORDER BY message_id ASC, sort_key ASC')
65
+ .all(sessionID);
66
+ }
67
+ function readArtifactsForSession(db, sessionID) {
68
+ return db
69
+ .prepare('SELECT * FROM artifacts WHERE session_id = ? ORDER BY created_at DESC')
70
+ .all(sessionID);
71
+ }
72
+ function readArtifact(db, artifactID) {
73
+ return db.prepare('SELECT * FROM artifacts WHERE artifact_id = ?').get(artifactID);
74
+ }
75
+ function readArtifactBlob(db, contentHash) {
76
+ return db.prepare('SELECT * FROM artifact_blobs WHERE content_hash = ?').get(contentHash);
77
+ }
78
+ function _readOrphanArtifactBlobRows(db) {
79
+ return db
80
+ .prepare(`SELECT b.* FROM artifact_blobs b
81
+ WHERE NOT EXISTS (
82
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
83
+ )
84
+ ORDER BY b.created_at ASC`)
85
+ .all();
86
+ }
87
+ function readLatestSessionID(db) {
88
+ const row = db
89
+ .prepare('SELECT session_id FROM sessions ORDER BY updated_at DESC LIMIT 1')
90
+ .get();
91
+ return row?.session_id;
92
+ }
93
+ function readSessionStats(db) {
94
+ const sessions = db.prepare('SELECT COUNT(*) AS count FROM sessions').get();
95
+ const messages = db.prepare('SELECT COUNT(*) AS count FROM messages').get();
96
+ const artifacts = db.prepare('SELECT COUNT(*) AS count FROM artifacts').get();
97
+ const summaryNodes = db.prepare('SELECT COUNT(*) AS count FROM summary_nodes').get();
98
+ const blobs = db
99
+ .prepare(`SELECT COUNT(*) AS count, COALESCE(SUM(char_count), 0) AS chars
100
+ FROM artifact_blobs b
101
+ WHERE NOT EXISTS (
102
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
103
+ )`)
104
+ .get();
105
+ return {
106
+ sessionCount: sessions.count,
107
+ messageCount: messages.count,
108
+ artifactCount: artifacts.count,
109
+ summaryNodeCount: summaryNodes.count,
110
+ blobCount: blobs.count,
111
+ orphanBlobCount: blobs.count,
112
+ orphanBlobChars: blobs.chars,
113
+ };
114
+ }
115
+ function extractSessionID(event) {
116
+ const record = asRecord(event);
117
+ if (!record)
118
+ return undefined;
119
+ if (typeof record.sessionID === 'string')
120
+ return record.sessionID;
121
+ const properties = asRecord(record.properties);
122
+ if (!properties)
123
+ return undefined;
124
+ if (typeof properties.sessionID === 'string')
125
+ return properties.sessionID;
126
+ const info = asRecord(properties.info);
127
+ if (info && typeof info.sessionID === 'string')
128
+ return info.sessionID;
129
+ const part = asRecord(properties.part);
130
+ if (part && typeof part.sessionID === 'string')
131
+ return part.sessionID;
132
+ return undefined;
133
+ }
134
+ function extractTimestamp(event) {
135
+ const record = asRecord(event);
136
+ if (!record)
137
+ return Date.now();
138
+ const properties = asRecord(record.properties);
139
+ const time = asRecord(properties?.time);
140
+ if (typeof record.timestamp === 'number')
141
+ return record.timestamp;
142
+ if (typeof properties?.timestamp === 'number')
143
+ return properties.timestamp;
144
+ if (typeof time?.created === 'number')
145
+ return time.created;
146
+ if (typeof properties?.time === 'number')
147
+ return properties.time;
148
+ return Date.now();
149
+ }
150
+ function normalizeEvent(event) {
151
+ const record = asRecord(event);
152
+ if (!record || typeof record.type !== 'string')
153
+ return null;
154
+ return {
155
+ id: randomUUID(),
156
+ type: record.type,
157
+ sessionID: extractSessionID(event),
158
+ timestamp: extractTimestamp(event),
159
+ payload: event,
160
+ };
161
+ }
162
+ function getDeferredPartUpdateKey(event) {
163
+ if (event.type !== 'message.part.updated')
164
+ return undefined;
165
+ return `${event.properties.part.sessionID}:${event.properties.part.messageID}:${event.properties.part.id}`;
166
+ }
167
+ function compareMessages(a, b) {
168
+ return a.info.time.created - b.info.time.created;
169
+ }
170
+ function buildSummaryNodeID(sessionID, level, slot) {
171
+ return `sum_${hashContent(`summary:${sessionID}`).slice(0, 12)}_l${level}_p${slot}`;
172
+ }
173
+ function hydratePartFromArtifacts(part, artifacts) {
174
+ for (const artifact of artifacts) {
175
+ switch (part.type) {
176
+ case 'text':
177
+ case 'reasoning':
178
+ if (artifact.fieldName === 'text')
179
+ part.text = artifact.contentText;
180
+ break;
181
+ case 'tool':
182
+ if (part.state.status === 'completed' && artifact.fieldName === 'output')
183
+ part.state.output = artifact.contentText;
184
+ if (part.state.status === 'error' && artifact.fieldName === 'error')
185
+ part.state.error = artifact.contentText;
186
+ if (part.state.status === 'completed' &&
187
+ artifact.fieldName.startsWith('attachment_text:')) {
188
+ const index = Number(artifact.fieldName.split(':')[1]);
189
+ const attachment = part.state.attachments?.[index];
190
+ if (attachment?.source?.text) {
191
+ attachment.source.text.value = artifact.contentText;
192
+ attachment.source.text.start = 0;
193
+ attachment.source.text.end = artifact.contentText.length;
194
+ }
195
+ }
196
+ break;
197
+ case 'file':
198
+ if (artifact.fieldName === 'source' && part.source?.text) {
199
+ part.source.text.value = artifact.contentText;
200
+ part.source.text.start = 0;
201
+ part.source.text.end = artifact.contentText.length;
202
+ }
203
+ break;
204
+ case 'snapshot':
205
+ if (artifact.fieldName === 'snapshot')
206
+ part.snapshot = artifact.contentText;
207
+ break;
208
+ case 'agent':
209
+ if (artifact.fieldName === 'source' && part.source) {
210
+ part.source.value = artifact.contentText;
211
+ part.source.start = 0;
212
+ part.source.end = artifact.contentText.length;
213
+ }
214
+ break;
215
+ case 'subtask':
216
+ if (artifact.fieldName === 'prompt')
217
+ part.prompt = artifact.contentText;
218
+ if (artifact.fieldName === 'description')
219
+ part.description = artifact.contentText;
220
+ break;
221
+ default:
222
+ break;
223
+ }
224
+ }
225
+ }
226
+ function isSyntheticLcmTextPart(part, markers) {
227
+ if (part.type !== 'text')
228
+ return false;
229
+ const marker = part.metadata?.opencodeLcm;
230
+ if (typeof marker !== 'string')
231
+ return false;
232
+ return markers ? markers.includes(marker) : true;
233
+ }
234
+ function guessMessageText(message, ignoreToolPrefixes) {
235
+ const segments = [];
236
+ for (const part of message.parts) {
237
+ switch (part.type) {
238
+ case 'text': {
239
+ if (isSyntheticLcmTextPart(part, ['archive-summary', 'retrieved-context', 'archived-part']))
240
+ break;
241
+ if (part.text.startsWith('[Archived by opencode-lcm:'))
242
+ break;
243
+ const sanitized = sanitizeAutomaticRetrievalSourceText(part.text);
244
+ if (sanitized)
245
+ segments.push(sanitized);
246
+ break;
247
+ }
248
+ case 'reasoning': {
249
+ if (part.text.startsWith('[Archived by opencode-lcm:'))
250
+ break;
251
+ const sanitized = sanitizeAutomaticRetrievalSourceText(part.text);
252
+ if (sanitized)
253
+ segments.push(sanitized);
254
+ break;
255
+ }
256
+ case 'file': {
257
+ const sourcePath = part.source?.path;
258
+ const filename = part.filename;
259
+ const inlineText = part.source?.text?.value;
260
+ segments.push([sourcePath ?? filename ?? 'file', inlineText].filter(Boolean).join(': '));
261
+ break;
262
+ }
263
+ case 'tool': {
264
+ if (ignoreToolPrefixes.some((prefix) => part.tool.startsWith(prefix)))
265
+ break;
266
+ const state = part.state;
267
+ if (state.status === 'completed')
268
+ segments.push(`${part.tool}: ${state.output}`);
269
+ if (state.status === 'error')
270
+ segments.push(`${part.tool}: ${state.error}`);
271
+ if (state.status === 'pending' || state.status === 'running') {
272
+ segments.push(`${part.tool}: ${JSON.stringify(state.input)}`);
273
+ }
274
+ if (state.status === 'completed' && state.attachments && state.attachments.length > 0) {
275
+ const attachmentNames = state.attachments
276
+ .map((file) => file.source?.path ?? file.filename ?? file.url)
277
+ .filter(Boolean)
278
+ .slice(0, 4);
279
+ if (attachmentNames.length > 0)
280
+ segments.push(`${part.tool} attachments: ${attachmentNames.join(', ')}`);
281
+ }
282
+ break;
283
+ }
284
+ case 'subtask':
285
+ segments.push(`${part.agent}: ${part.description}`);
286
+ break;
287
+ case 'agent':
288
+ segments.push(part.name);
289
+ break;
290
+ case 'snapshot':
291
+ segments.push(part.snapshot);
292
+ break;
293
+ default:
294
+ break;
295
+ }
296
+ }
297
+ return truncate(segments.filter(Boolean).join('\n').replace(/\s+/g, ' ').trim(), 500);
298
+ }
299
+ function listFiles(message) {
300
+ const files = new Set();
301
+ for (const part of message.parts) {
302
+ if (part.type === 'file') {
303
+ if (part.source?.path)
304
+ files.add(part.source.path);
305
+ else if (part.filename)
306
+ files.add(part.filename);
307
+ }
308
+ if (part.type === 'patch') {
309
+ for (const file of part.files.slice(0, 20))
310
+ files.add(file);
311
+ }
312
+ }
313
+ return [...files];
314
+ }
315
+ function makeSessionTitle(session) {
316
+ if (session.title)
317
+ return session.title;
318
+ const firstUser = session.messages.find((message) => message.info.role === 'user');
319
+ if (!firstUser)
320
+ return undefined;
321
+ return truncate(guessMessageText(firstUser, []), 80);
322
+ }
323
+ function archivePlaceholder(label) {
324
+ return `[Archived by opencode-lcm: ${label}. Use lcm_resume, lcm_grep, or lcm_expand for details.]`;
325
+ }
326
+ function logStartupPhase(phase, context) {
327
+ if (!isStartupLoggingEnabled())
328
+ return;
329
+ getLogger().info(`startup phase: ${phase}`, context);
330
+ }
331
+ function normalizeSqliteRuntimeOverride(value) {
332
+ const normalized = value?.trim().toLowerCase();
333
+ if (normalized === 'bun' || normalized === 'node')
334
+ return normalized;
335
+ return 'auto';
336
+ }
337
+ export function resolveSqliteRuntimeCandidates(options) {
338
+ const override = normalizeSqliteRuntimeOverride(options?.envOverride ?? process.env.OPENCODE_LCM_SQLITE_RUNTIME);
339
+ if (override !== 'auto')
340
+ return [override];
341
+ const isBunRuntime = options?.isBunRuntime ?? (typeof globalThis === 'object' && 'Bun' in globalThis);
342
+ if (!isBunRuntime)
343
+ return ['node'];
344
+ const platform = options?.platform ?? process.platform;
345
+ return platform === 'win32' ? ['node', 'bun'] : ['bun', 'node'];
346
+ }
347
+ export function resolveSqliteRuntime(options) {
348
+ return resolveSqliteRuntimeCandidates(options)[0];
349
+ }
350
+ function isSqliteRuntimeImportError(runtime, error) {
351
+ const code = typeof error === 'object' && error && 'code' in error && typeof error.code === 'string'
352
+ ? error.code
353
+ : undefined;
354
+ const message = error instanceof Error ? error.message : String(error);
355
+ const specifier = runtime === 'bun' ? 'bun:sqlite' : 'node:sqlite';
356
+ if (!message.includes(specifier))
357
+ return false;
358
+ if (code === 'ERR_UNKNOWN_BUILTIN_MODULE' || code === 'ERR_MODULE_NOT_FOUND')
359
+ return true;
360
+ return (message.includes('Cannot find module') ||
361
+ message.includes('Cannot find package') ||
362
+ message.includes('No such built-in module'));
363
+ }
364
+ function hasErrorCode(error, code) {
365
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
366
+ }
367
+ async function openBunSqliteDatabase(dbPath) {
368
+ const { Database } = await import('bun:sqlite');
369
+ const db = new Database(dbPath, { create: true });
370
+ db.exec('PRAGMA foreign_keys = ON');
371
+ db.exec('PRAGMA busy_timeout = 5000');
372
+ return {
373
+ exec(sql) {
374
+ return db.exec(sql);
375
+ },
376
+ close() {
377
+ db.close();
378
+ },
379
+ prepare(sql) {
380
+ const statement = typeof db.prepare === 'function' ? db.prepare(sql) : db.query(sql);
381
+ return {
382
+ run(...args) {
383
+ return statement.run(...args);
384
+ },
385
+ get(...args) {
386
+ return statement.get(...args);
387
+ },
388
+ all(...args) {
389
+ return statement.all(...args);
390
+ },
391
+ };
392
+ },
393
+ };
394
+ }
395
+ async function openNodeSqliteDatabase(dbPath) {
396
+ const { DatabaseSync } = await import('node:sqlite');
397
+ const db = new DatabaseSync(dbPath, {
398
+ enableForeignKeyConstraints: true,
399
+ timeout: 5000,
400
+ });
401
+ return {
402
+ exec(sql) {
403
+ return db.exec(sql);
404
+ },
405
+ close() {
406
+ db.close();
407
+ },
408
+ prepare(sql) {
409
+ return db.prepare(sql);
410
+ },
411
+ };
412
+ }
413
+ async function openSqliteDatabase(dbPath) {
414
+ const candidates = resolveSqliteRuntimeCandidates();
415
+ const openers = {
416
+ bun: openBunSqliteDatabase,
417
+ node: openNodeSqliteDatabase,
418
+ };
419
+ let lastError;
420
+ for (const [index, runtime] of candidates.entries()) {
421
+ try {
422
+ return await openers[runtime](dbPath);
423
+ }
424
+ catch (error) {
425
+ if (!isSqliteRuntimeImportError(runtime, error) || index === candidates.length - 1) {
426
+ throw error;
427
+ }
428
+ lastError = error;
429
+ logStartupPhase('open-db:sqlite-runtime-fallback', {
430
+ runtime,
431
+ fallbackRuntime: candidates[index + 1],
432
+ message: error instanceof Error ? error.message : String(error),
433
+ });
434
+ }
435
+ }
436
+ throw lastError instanceof Error
437
+ ? lastError
438
+ : new Error('Unable to initialize a supported SQLite runtime.');
439
+ }
440
+ export class SqliteLcmStore {
441
+ options;
442
+ static deferredPartUpdateDelayMs = 250;
443
+ baseDir;
444
+ dbPath;
445
+ privacy;
446
+ workspaceDirectory;
447
+ db;
448
+ dbReadyPromise;
449
+ pendingPartUpdates = new Map();
450
+ pendingPartUpdateTimer;
451
+ pendingPartUpdateFlushPromise;
452
+ constructor(projectDir, options) {
453
+ this.options = options;
454
+ this.privacy = compilePrivacyOptions(options.privacy);
455
+ this.workspaceDirectory = projectDir;
456
+ this.baseDir = path.join(projectDir, options.storeDir ?? '.lcm');
457
+ this.dbPath = path.join(this.baseDir, 'lcm.db');
458
+ }
459
+ async init() {
460
+ await mkdir(this.baseDir, { recursive: true });
461
+ }
462
+ async prepareForRead() {
463
+ await this.ensureDbReady();
464
+ await this.flushDeferredPartUpdates();
465
+ }
466
+ scheduleDeferredPartUpdateFlush() {
467
+ if (this.pendingPartUpdateTimer || this.pendingPartUpdates.size === 0)
468
+ return;
469
+ this.pendingPartUpdateTimer = setTimeout(() => {
470
+ this.pendingPartUpdateTimer = undefined;
471
+ void this.flushDeferredPartUpdates();
472
+ }, SqliteLcmStore.deferredPartUpdateDelayMs);
473
+ if (typeof this.pendingPartUpdateTimer === 'object' &&
474
+ this.pendingPartUpdateTimer &&
475
+ 'unref' in this.pendingPartUpdateTimer &&
476
+ typeof this.pendingPartUpdateTimer.unref === 'function') {
477
+ this.pendingPartUpdateTimer.unref();
478
+ }
479
+ }
480
+ clearDeferredPartUpdateTimer() {
481
+ if (!this.pendingPartUpdateTimer)
482
+ return;
483
+ clearTimeout(this.pendingPartUpdateTimer);
484
+ this.pendingPartUpdateTimer = undefined;
485
+ }
486
+ clearDeferredPartUpdatesForSession(sessionID) {
487
+ if (!sessionID || this.pendingPartUpdates.size === 0)
488
+ return;
489
+ for (const [key, event] of this.pendingPartUpdates.entries()) {
490
+ if (event.type !== 'message.part.updated')
491
+ continue;
492
+ if (event.properties.part.sessionID !== sessionID)
493
+ continue;
494
+ this.pendingPartUpdates.delete(key);
495
+ }
496
+ if (this.pendingPartUpdates.size === 0)
497
+ this.clearDeferredPartUpdateTimer();
498
+ }
499
+ clearDeferredPartUpdatesForMessage(sessionID, messageID) {
500
+ if (!sessionID || !messageID || this.pendingPartUpdates.size === 0)
501
+ return;
502
+ for (const [key, event] of this.pendingPartUpdates.entries()) {
503
+ if (event.type !== 'message.part.updated')
504
+ continue;
505
+ if (event.properties.part.sessionID !== sessionID)
506
+ continue;
507
+ if (event.properties.part.messageID !== messageID)
508
+ continue;
509
+ this.pendingPartUpdates.delete(key);
510
+ }
511
+ if (this.pendingPartUpdates.size === 0)
512
+ this.clearDeferredPartUpdateTimer();
513
+ }
514
+ clearDeferredPartUpdateForPart(sessionID, messageID, partID) {
515
+ if (!sessionID || !messageID || !partID || this.pendingPartUpdates.size === 0)
516
+ return;
517
+ this.pendingPartUpdates.delete(`${sessionID}:${messageID}:${partID}`);
518
+ if (this.pendingPartUpdates.size === 0)
519
+ this.clearDeferredPartUpdateTimer();
520
+ }
521
+ async captureDeferred(event) {
522
+ switch (event.type) {
523
+ case 'message.part.updated': {
524
+ const key = getDeferredPartUpdateKey(event);
525
+ if (!key)
526
+ return await this.capture(event);
527
+ this.pendingPartUpdates.set(key, event);
528
+ this.scheduleDeferredPartUpdateFlush();
529
+ return;
530
+ }
531
+ case 'message.part.removed':
532
+ this.clearDeferredPartUpdateForPart(event.properties.sessionID, event.properties.messageID, event.properties.partID);
533
+ break;
534
+ case 'message.removed':
535
+ this.clearDeferredPartUpdatesForMessage(event.properties.sessionID, event.properties.messageID);
536
+ break;
537
+ case 'session.deleted':
538
+ this.clearDeferredPartUpdatesForSession(extractSessionID(event));
539
+ break;
540
+ default:
541
+ break;
542
+ }
543
+ await this.capture(event);
544
+ }
545
+ async flushDeferredPartUpdates() {
546
+ if (this.pendingPartUpdateFlushPromise)
547
+ return this.pendingPartUpdateFlushPromise;
548
+ if (this.pendingPartUpdates.size === 0)
549
+ return;
550
+ this.clearDeferredPartUpdateTimer();
551
+ this.pendingPartUpdateFlushPromise = (async () => {
552
+ while (this.pendingPartUpdates.size > 0) {
553
+ const batch = [...this.pendingPartUpdates.values()];
554
+ this.pendingPartUpdates.clear();
555
+ for (const event of batch) {
556
+ await this.capture(event);
557
+ }
558
+ }
559
+ })().finally(() => {
560
+ this.pendingPartUpdateFlushPromise = undefined;
561
+ if (this.pendingPartUpdates.size > 0)
562
+ this.scheduleDeferredPartUpdateFlush();
563
+ });
564
+ return this.pendingPartUpdateFlushPromise;
565
+ }
566
+ async ensureDbReady() {
567
+ if (this.db)
568
+ return;
569
+ if (this.dbReadyPromise)
570
+ return this.dbReadyPromise;
571
+ this.dbReadyPromise = this.openAndInitializeDb();
572
+ return this.dbReadyPromise;
573
+ }
574
+ async openAndInitializeDb() {
575
+ logStartupPhase('open-db:start', { dbPath: this.dbPath });
576
+ await mkdir(this.baseDir, { recursive: true });
577
+ logStartupPhase('open-db:connect', {
578
+ runtime: typeof globalThis === 'object' && 'Bun' in globalThis ? 'bun' : 'node',
579
+ sqliteRuntime: resolveSqliteRuntime(),
580
+ });
581
+ const db = await openSqliteDatabase(this.dbPath);
582
+ this.db = db;
583
+ try {
584
+ logStartupPhase('open-db:schema-check');
585
+ this.assertSupportedSchemaVersionSync();
586
+ db.exec('PRAGMA journal_mode = WAL');
587
+ db.exec('PRAGMA synchronous = NORMAL');
588
+ logStartupPhase('open-db:create-tables');
589
+ db.exec(`
590
+ CREATE TABLE IF NOT EXISTS events (
591
+ id TEXT PRIMARY KEY,
592
+ session_id TEXT,
593
+ event_type TEXT NOT NULL,
594
+ ts INTEGER NOT NULL,
595
+ payload_json TEXT NOT NULL
596
+ );
597
+ CREATE INDEX IF NOT EXISTS idx_events_session_ts ON events(session_id, ts);
598
+
599
+ CREATE TABLE IF NOT EXISTS sessions (
600
+ session_id TEXT PRIMARY KEY,
601
+ title TEXT,
602
+ session_directory TEXT,
603
+ worktree_key TEXT,
604
+ parent_session_id TEXT,
605
+ root_session_id TEXT,
606
+ lineage_depth INTEGER,
607
+ pinned INTEGER NOT NULL DEFAULT 0,
608
+ pin_reason TEXT,
609
+ updated_at INTEGER NOT NULL DEFAULT 0,
610
+ compacted_at INTEGER,
611
+ deleted INTEGER NOT NULL DEFAULT 0,
612
+ event_count INTEGER NOT NULL DEFAULT 0
613
+ );
614
+
615
+ CREATE TABLE IF NOT EXISTS messages (
616
+ message_id TEXT PRIMARY KEY,
617
+ session_id TEXT NOT NULL,
618
+ created_at INTEGER NOT NULL,
619
+ info_json TEXT NOT NULL,
620
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
621
+ );
622
+ CREATE INDEX IF NOT EXISTS idx_messages_session_created ON messages(session_id, created_at, message_id);
623
+
624
+ CREATE TABLE IF NOT EXISTS parts (
625
+ part_id TEXT PRIMARY KEY,
626
+ session_id TEXT NOT NULL,
627
+ message_id TEXT NOT NULL,
628
+ sort_key INTEGER NOT NULL DEFAULT 0,
629
+ part_json TEXT NOT NULL,
630
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE,
631
+ FOREIGN KEY (message_id) REFERENCES messages(message_id) ON DELETE CASCADE
632
+ );
633
+ CREATE INDEX IF NOT EXISTS idx_parts_message_sort ON parts(message_id, sort_key, part_id);
634
+
635
+ CREATE TABLE IF NOT EXISTS resumes (
636
+ session_id TEXT PRIMARY KEY,
637
+ note TEXT NOT NULL,
638
+ updated_at INTEGER NOT NULL,
639
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
640
+ );
641
+
642
+ CREATE TABLE IF NOT EXISTS artifacts (
643
+ artifact_id TEXT PRIMARY KEY,
644
+ session_id TEXT NOT NULL,
645
+ message_id TEXT NOT NULL,
646
+ part_id TEXT NOT NULL,
647
+ artifact_kind TEXT NOT NULL,
648
+ field_name TEXT NOT NULL,
649
+ preview_text TEXT NOT NULL,
650
+ content_text TEXT NOT NULL,
651
+ content_hash TEXT,
652
+ metadata_json TEXT NOT NULL DEFAULT '{}',
653
+ char_count INTEGER NOT NULL,
654
+ created_at INTEGER NOT NULL,
655
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE,
656
+ FOREIGN KEY (message_id) REFERENCES messages(message_id) ON DELETE CASCADE
657
+ );
658
+ CREATE INDEX IF NOT EXISTS idx_artifacts_session_message ON artifacts(session_id, message_id, part_id);
659
+
660
+ CREATE TABLE IF NOT EXISTS artifact_blobs (
661
+ content_hash TEXT PRIMARY KEY,
662
+ content_text TEXT NOT NULL,
663
+ char_count INTEGER NOT NULL,
664
+ created_at INTEGER NOT NULL
665
+ );
666
+
667
+ CREATE TABLE IF NOT EXISTS summary_nodes (
668
+ node_id TEXT PRIMARY KEY,
669
+ session_id TEXT NOT NULL,
670
+ level INTEGER NOT NULL,
671
+ node_kind TEXT NOT NULL,
672
+ start_index INTEGER NOT NULL,
673
+ end_index INTEGER NOT NULL,
674
+ message_ids_json TEXT NOT NULL,
675
+ summary_text TEXT NOT NULL,
676
+ created_at INTEGER NOT NULL,
677
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
678
+ );
679
+ CREATE INDEX IF NOT EXISTS idx_summary_nodes_session_level ON summary_nodes(session_id, level);
680
+
681
+ CREATE TABLE IF NOT EXISTS summary_edges (
682
+ session_id TEXT NOT NULL,
683
+ parent_id TEXT NOT NULL,
684
+ child_id TEXT NOT NULL,
685
+ child_position INTEGER NOT NULL,
686
+ PRIMARY KEY (parent_id, child_id),
687
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE,
688
+ FOREIGN KEY (parent_id) REFERENCES summary_nodes(node_id) ON DELETE CASCADE,
689
+ FOREIGN KEY (child_id) REFERENCES summary_nodes(node_id) ON DELETE CASCADE
690
+ );
691
+ CREATE INDEX IF NOT EXISTS idx_summary_edges_session_parent ON summary_edges(session_id, parent_id, child_position);
692
+
693
+ CREATE TABLE IF NOT EXISTS summary_state (
694
+ session_id TEXT PRIMARY KEY,
695
+ archived_count INTEGER NOT NULL,
696
+ latest_message_created INTEGER NOT NULL,
697
+ archived_signature TEXT NOT NULL DEFAULT '',
698
+ root_node_ids_json TEXT NOT NULL,
699
+ updated_at INTEGER NOT NULL,
700
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
701
+ );
702
+
703
+ CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5(
704
+ session_id UNINDEXED,
705
+ message_id UNINDEXED,
706
+ role UNINDEXED,
707
+ created_at UNINDEXED,
708
+ content
709
+ );
710
+
711
+ CREATE VIRTUAL TABLE IF NOT EXISTS summary_fts USING fts5(
712
+ session_id UNINDEXED,
713
+ node_id UNINDEXED,
714
+ level UNINDEXED,
715
+ created_at UNINDEXED,
716
+ content
717
+ );
718
+
719
+ CREATE VIRTUAL TABLE IF NOT EXISTS artifact_fts USING fts5(
720
+ session_id UNINDEXED,
721
+ artifact_id UNINDEXED,
722
+ message_id UNINDEXED,
723
+ part_id UNINDEXED,
724
+ artifact_kind UNINDEXED,
725
+ created_at UNINDEXED,
726
+ content
727
+ );
728
+ `);
729
+ this.ensureSessionColumnsSync();
730
+ this.ensureSummaryStateColumnsSync();
731
+ this.ensureArtifactColumnsSync();
732
+ logStartupPhase('open-db:create-indexes');
733
+ db.exec('CREATE INDEX IF NOT EXISTS idx_artifacts_content_hash ON artifacts(content_hash)');
734
+ db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_root ON sessions(root_session_id, updated_at DESC)');
735
+ db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id, updated_at DESC)');
736
+ db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_worktree ON sessions(worktree_key, updated_at DESC)');
737
+ logStartupPhase('open-db:migrate-legacy-artifacts');
738
+ await this.migrateLegacyArtifacts();
739
+ logStartupPhase('open-db:write-schema-version', { schemaVersion: STORE_SCHEMA_VERSION });
740
+ this.writeSchemaVersionSync(STORE_SCHEMA_VERSION);
741
+ logStartupPhase('open-db:deferred-init:start');
742
+ this.completeDeferredInit();
743
+ logStartupPhase('open-db:ready');
744
+ }
745
+ catch (error) {
746
+ logStartupPhase('open-db:error', {
747
+ message: error instanceof Error ? error.message : String(error),
748
+ });
749
+ db.close();
750
+ this.db = undefined;
751
+ this.dbReadyPromise = undefined;
752
+ throw error;
753
+ }
754
+ }
755
+ deferredInitCompleted = false;
756
+ readSchemaVersionSync() {
757
+ return firstFiniteNumber(this.getDb().prepare('PRAGMA user_version').get()) ?? 0;
758
+ }
759
+ assertSupportedSchemaVersionSync() {
760
+ const schemaVersion = this.readSchemaVersionSync();
761
+ if (schemaVersion <= STORE_SCHEMA_VERSION)
762
+ return;
763
+ throw new Error(`Unsupported store schema version: ${schemaVersion}. This build supports up to ${STORE_SCHEMA_VERSION}.`);
764
+ }
765
+ writeSchemaVersionSync(version) {
766
+ this.getDb().exec(`PRAGMA user_version = ${Math.max(0, Math.trunc(version))}`);
767
+ }
768
+ completeDeferredInit() {
769
+ if (this.deferredInitCompleted)
770
+ return;
771
+ if (this.hasPendingArtifactBlobBackfillSync()) {
772
+ logStartupPhase('deferred-init:artifact-backfill');
773
+ this.backfillArtifactBlobsSync();
774
+ }
775
+ logStartupPhase('deferred-init:orphan-blob-cleanup');
776
+ this.deleteOrphanArtifactBlobsSync();
777
+ if (this.options.retention.staleSessionDays !== undefined ||
778
+ this.options.retention.deletedSessionDays !== undefined ||
779
+ this.options.retention.orphanBlobDays !== undefined) {
780
+ logStartupPhase('deferred-init:retention-prune');
781
+ this.applyRetentionPruneSync({ apply: true });
782
+ }
783
+ if (this.hasPendingLineageRefreshSync()) {
784
+ logStartupPhase('deferred-init:lineage-refresh');
785
+ this.refreshAllLineageSync();
786
+ }
787
+ logStartupPhase('deferred-init:done');
788
+ this.deferredInitCompleted = true;
789
+ }
790
+ hasPendingArtifactBlobBackfillSync() {
791
+ const row = this.getDb()
792
+ .prepare("SELECT COUNT(*) AS count FROM artifacts WHERE content_hash IS NULL OR content_text != ''")
793
+ .get();
794
+ return row.count > 0;
795
+ }
796
+ hasPendingLineageRefreshSync() {
797
+ const row = this.getDb()
798
+ .prepare('SELECT COUNT(*) AS count FROM sessions WHERE root_session_id IS NULL OR lineage_depth IS NULL')
799
+ .get();
800
+ return row.count > 0;
801
+ }
802
+ close() {
803
+ this.clearDeferredPartUpdateTimer();
804
+ this.pendingPartUpdates.clear();
805
+ if (!this.db)
806
+ return;
807
+ this.db.close();
808
+ this.db = undefined;
809
+ this.dbReadyPromise = undefined;
810
+ }
811
+ async capture(event) {
812
+ await this.ensureDbReady();
813
+ const normalized = normalizeEvent(event);
814
+ if (!normalized)
815
+ return;
816
+ if (this.shouldRecordEvent(normalized.type)) {
817
+ this.writeEvent(normalized);
818
+ }
819
+ if (!normalized.sessionID)
820
+ return;
821
+ if (!this.shouldPersistSessionForEvent(normalized.type))
822
+ return;
823
+ const session = this.readSessionSync(normalized.sessionID, {
824
+ artifactMessageIDs: this.captureArtifactHydrationMessageIDs(normalized),
825
+ });
826
+ const previousParentSessionID = session.parentSessionID;
827
+ let next = this.applyEvent(session, normalized);
828
+ next.updatedAt = Math.max(next.updatedAt, normalized.timestamp);
829
+ next.eventCount += 1;
830
+ next = this.prepareSessionForPersistence(next);
831
+ const shouldSyncDerivedState = this.shouldSyncDerivedSessionStateForEvent(session, next, normalized);
832
+ await this.persistCapturedSession(next, normalized);
833
+ if (this.shouldRefreshLineageForEvent(normalized.type)) {
834
+ this.refreshAllLineageSync();
835
+ const refreshed = this.readSessionHeaderSync(normalized.sessionID);
836
+ if (refreshed) {
837
+ next = {
838
+ ...next,
839
+ parentSessionID: refreshed.parentSessionID,
840
+ rootSessionID: refreshed.rootSessionID,
841
+ lineageDepth: refreshed.lineageDepth,
842
+ };
843
+ }
844
+ }
845
+ if (shouldSyncDerivedState) {
846
+ this.syncDerivedSessionStateSync(this.readSessionSync(normalized.sessionID));
847
+ }
848
+ if (this.shouldSyncDerivedLineageSubtree(normalized.type, previousParentSessionID, next.parentSessionID)) {
849
+ this.syncDerivedLineageSubtreeSync(normalized.sessionID, true);
850
+ }
851
+ if (this.shouldCleanupOrphanBlobsForEvent(normalized.type)) {
852
+ this.deleteOrphanArtifactBlobsSync();
853
+ }
854
+ }
855
+ async stats() {
856
+ await this.prepareForRead();
857
+ const db = this.getDb();
858
+ const totalRow = validateRow(db.prepare('SELECT COUNT(*) AS count, MAX(ts) AS latest FROM events').get(), { count: 'number', latest: 'nullable' }, 'stats.totalEvents');
859
+ const sessionRow = validateRow(db.prepare('SELECT COUNT(*) AS count FROM sessions').get(), { count: 'number' }, 'stats.sessionCount');
860
+ const typeRows = safeQuery(db.prepare('SELECT event_type, COUNT(*) AS count FROM events GROUP BY event_type ORDER BY count DESC'), [], 'stats.eventTypes');
861
+ const summaryNodeRow = validateRow(db.prepare('SELECT COUNT(*) AS count FROM summary_nodes').get(), { count: 'number' }, 'stats.summaryNodeCount');
862
+ const summaryStateRow = validateRow(db.prepare('SELECT COUNT(*) AS count FROM summary_state').get(), { count: 'number' }, 'stats.summaryStateCount');
863
+ const artifactRow = validateRow(db.prepare('SELECT COUNT(*) AS count FROM artifacts').get(), { count: 'number' }, 'stats.artifactCount');
864
+ const blobRow = validateRow(db.prepare('SELECT COUNT(*) AS count FROM artifact_blobs').get(), { count: 'number' }, 'stats.artifactBlobCount');
865
+ const sharedBlobRow = validateRow(db
866
+ .prepare(`SELECT COUNT(*) AS count FROM (
867
+ SELECT content_hash FROM artifacts
868
+ WHERE content_hash IS NOT NULL
869
+ GROUP BY content_hash
870
+ HAVING COUNT(*) > 1
871
+ )`)
872
+ .get(), { count: 'number' }, 'stats.sharedArtifactBlobCount');
873
+ const orphanBlobRow = validateRow(db
874
+ .prepare(`SELECT COUNT(*) AS count FROM artifact_blobs b
875
+ WHERE NOT EXISTS (
876
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
877
+ )`)
878
+ .get(), { count: 'number' }, 'stats.orphanArtifactBlobCount');
879
+ const rootRow = validateRow(db.prepare('SELECT COUNT(*) AS count FROM sessions WHERE parent_session_id IS NULL').get(), { count: 'number' }, 'stats.rootSessionCount');
880
+ const branchedRow = validateRow(db
881
+ .prepare('SELECT COUNT(*) AS count FROM sessions WHERE parent_session_id IS NOT NULL')
882
+ .get(), { count: 'number' }, 'stats.branchedSessionCount');
883
+ const pinnedRow = validateRow(db.prepare('SELECT COUNT(*) AS count FROM sessions WHERE pinned = 1').get(), { count: 'number' }, 'stats.pinnedSessionCount');
884
+ const worktreeRow = validateRow(db
885
+ .prepare('SELECT COUNT(DISTINCT worktree_key) AS count FROM sessions WHERE worktree_key IS NOT NULL')
886
+ .get(), { count: 'number' }, 'stats.worktreeCount');
887
+ return {
888
+ schemaVersion: readSchemaVersionSync(db),
889
+ totalEvents: totalRow.count,
890
+ sessionCount: sessionRow.count,
891
+ latestEventAt: totalRow.latest ?? undefined,
892
+ eventTypes: Object.fromEntries(typeRows.map((row) => [row.event_type, row.count])),
893
+ summaryNodeCount: summaryNodeRow.count,
894
+ summaryStateCount: summaryStateRow.count,
895
+ rootSessionCount: rootRow.count,
896
+ branchedSessionCount: branchedRow.count,
897
+ artifactCount: artifactRow.count,
898
+ artifactBlobCount: blobRow.count,
899
+ sharedArtifactBlobCount: sharedBlobRow.count,
900
+ orphanArtifactBlobCount: orphanBlobRow.count,
901
+ worktreeCount: worktreeRow.count,
902
+ pinnedSessionCount: pinnedRow.count,
903
+ };
904
+ }
905
+ async grep(input) {
906
+ await this.prepareForRead();
907
+ const resolvedScope = this.resolveConfiguredScope('grep', input.scope, input.sessionID);
908
+ const sessionIDs = this.resolveScopeSessionIDs(resolvedScope, input.sessionID);
909
+ const limit = input.limit ?? 5;
910
+ const needle = input.query.trim();
911
+ if (!needle)
912
+ return [];
913
+ const ftsResults = this.searchWithFts(needle, sessionIDs, limit);
914
+ if (ftsResults.length > 0)
915
+ return ftsResults;
916
+ return this.searchByScan(needle.toLowerCase(), sessionIDs, limit);
917
+ }
918
+ async describe(input) {
919
+ await this.prepareForRead();
920
+ const scope = this.resolveConfiguredScope('describe', input?.scope, input?.sessionID);
921
+ const sessionID = input?.sessionID;
922
+ if (scope !== 'session') {
923
+ const scopedSessions = this.readScopedSessionsSync(this.resolveScopeSessionIDs(scope, sessionID));
924
+ if (scopedSessions.length === 0)
925
+ return 'No archived sessions yet.';
926
+ return [
927
+ `Scope: ${scope}`,
928
+ `Sessions: ${scopedSessions.length}`,
929
+ `Latest update: ${Math.max(...scopedSessions.map((session) => session.updatedAt))}`,
930
+ `Root sessions: ${new Set(scopedSessions.map((session) => session.rootSessionID ?? session.sessionID)).size}`,
931
+ `Worktrees: ${new Set(scopedSessions.map((session) => normalizeWorktreeKey(session.directory)).filter(Boolean)).size}`,
932
+ 'Matching sessions:',
933
+ ...scopedSessions
934
+ .sort((a, b) => b.updatedAt - a.updatedAt)
935
+ .slice(0, 8)
936
+ .map((session) => {
937
+ const root = session.rootSessionID ?? session.sessionID;
938
+ const worktree = normalizeWorktreeKey(session.directory) ?? 'unknown';
939
+ return `- ${session.sessionID}: ${makeSessionTitle(session) ?? 'Untitled session'} (root=${root}, worktree=${worktree})`;
940
+ }),
941
+ ].join('\n');
942
+ }
943
+ if (!sessionID) {
944
+ const sessions = this.readAllSessionsSync();
945
+ if (sessions.length === 0)
946
+ return 'No archived sessions yet.';
947
+ return [
948
+ `Archived sessions: ${sessions.length}`,
949
+ `Latest update: ${Math.max(...sessions.map((session) => session.updatedAt))}`,
950
+ `Root sessions: ${sessions.filter((session) => !session.parentSessionID).length}`,
951
+ `Branched sessions: ${sessions.filter((session) => Boolean(session.parentSessionID)).length}`,
952
+ 'Recent sessions:',
953
+ ...sessions
954
+ .sort((a, b) => b.updatedAt - a.updatedAt)
955
+ .slice(0, 5)
956
+ .map((session) => `- ${session.sessionID}: ${makeSessionTitle(session) ?? 'Untitled session'}`),
957
+ ].join('\n');
958
+ }
959
+ const session = this.readSessionSync(sessionID);
960
+ if (session.messages.length === 0)
961
+ return 'No archived events yet.';
962
+ const roots = this.getSummaryRootsForSession(session);
963
+ const userMessages = session.messages.filter((message) => message.info.role === 'user');
964
+ const assistantMessages = session.messages.filter((message) => message.info.role === 'assistant');
965
+ const files = new Set(session.messages.flatMap(listFiles));
966
+ const recent = session.messages.slice(-5).map((message) => {
967
+ const snippet = guessMessageText(message, this.options.interop.ignoreToolPrefixes) || '(no text content)';
968
+ return `- ${message.info.role} ${message.info.id}: ${snippet}`;
969
+ });
970
+ return [
971
+ `Session: ${session.sessionID}`,
972
+ `Title: ${makeSessionTitle(session) ?? 'Unknown'}`,
973
+ `Directory: ${session.directory ?? 'unknown'}`,
974
+ `Parent session: ${session.parentSessionID ?? 'none'}`,
975
+ `Root session: ${session.rootSessionID ?? session.sessionID}`,
976
+ `Lineage depth: ${session.lineageDepth ?? 0}`,
977
+ `Pinned: ${session.pinned ? `yes${session.pinReason ? ` (${session.pinReason})` : ''}` : 'no'}`,
978
+ `Messages: ${session.messages.length}`,
979
+ `User messages: ${userMessages.length}`,
980
+ `Assistant messages: ${assistantMessages.length}`,
981
+ `Tracked files: ${files.size}`,
982
+ `Summary roots: ${roots.length}`,
983
+ `Child branches: ${this.readChildSessionsSync(session.sessionID).length}`,
984
+ `Last updated: ${session.updatedAt}`,
985
+ ...(roots.length > 0
986
+ ? [
987
+ 'Summary root previews:',
988
+ ...roots
989
+ .slice(0, 4)
990
+ .map((node) => `- ${shortNodeID(node.nodeID)}: ${node.summaryText}`),
991
+ ]
992
+ : []),
993
+ 'Recent entries:',
994
+ ...recent,
995
+ ].join('\n');
996
+ }
997
+ async doctor(input) {
998
+ await this.prepareForRead();
999
+ const limit = clamp(input?.limit ?? 10, 1, 50);
1000
+ const sessionID = input?.sessionID;
1001
+ const apply = input?.apply ?? false;
1002
+ const before = this.collectDoctorReport(sessionID);
1003
+ if (!apply || !this.hasDoctorIssues(before)) {
1004
+ return formatDoctorReport(before, limit);
1005
+ }
1006
+ const checkedSessions = sessionID
1007
+ ? [sessionID]
1008
+ : this.readAllSessionsSync().map((session) => session.sessionID);
1009
+ const appliedActions = [];
1010
+ this.ensureSessionColumnsSync();
1011
+ this.ensureSummaryStateColumnsSync();
1012
+ this.ensureArtifactColumnsSync();
1013
+ appliedActions.push('ensured schema columns');
1014
+ if (before.summarySessionsNeedingRebuild.length > 0 || before.orphanSummaryEdges > 0) {
1015
+ this.rebuildSummarySessionsSync(checkedSessions);
1016
+ appliedActions.push(`rebuilt summary DAGs for ${checkedSessions.length} checked session(s)`);
1017
+ }
1018
+ if (before.lineageSessionsNeedingRefresh.length > 0) {
1019
+ this.refreshAllLineageSync();
1020
+ this.syncAllDerivedSessionStateSync(true);
1021
+ appliedActions.push('refreshed lineage metadata');
1022
+ }
1023
+ if (before.orphanArtifactBlobs > 0) {
1024
+ this.backfillArtifactBlobsSync();
1025
+ const deleted = this.deleteOrphanArtifactBlobsSync();
1026
+ if (deleted.length > 0) {
1027
+ appliedActions.push(`deleted ${deleted.length} orphan artifact blob(s)`);
1028
+ }
1029
+ }
1030
+ if (before.messageFts.expected !== before.messageFts.actual ||
1031
+ before.summaryFts.expected !== before.summaryFts.actual ||
1032
+ before.artifactFts.expected !== before.artifactFts.actual ||
1033
+ before.summarySessionsNeedingRebuild.length > 0 ||
1034
+ before.orphanSummaryEdges > 0) {
1035
+ this.refreshSearchIndexesSync(checkedSessions);
1036
+ appliedActions.push('rebuilt FTS indexes');
1037
+ }
1038
+ const after = this.collectDoctorReport(sessionID);
1039
+ after.status = this.hasDoctorIssues(after) ? 'issues-found' : 'repaired';
1040
+ after.appliedActions = appliedActions;
1041
+ return formatDoctorReport(after, limit);
1042
+ }
1043
+ collectDoctorReport(sessionID) {
1044
+ const sessions = sessionID ? [this.readSessionSync(sessionID)] : this.readAllSessionsSync();
1045
+ const sessionIDs = sessions.map((session) => session.sessionID);
1046
+ const summarySessionsNeedingRebuild = sessions
1047
+ .map((session) => this.diagnoseSummarySession(session))
1048
+ .filter((issue) => Boolean(issue));
1049
+ const lineageSessionsNeedingRefresh = sessions
1050
+ .filter((session) => this.needsLineageRefresh(session))
1051
+ .map((session) => session.sessionID);
1052
+ const messageFtsExpected = sessions.reduce((count, session) => {
1053
+ return (count +
1054
+ session.messages.filter((message) => guessMessageText(message, this.options.interop.ignoreToolPrefixes).length > 0).length);
1055
+ }, 0);
1056
+ const report = {
1057
+ scope: sessionID ? `session:${sessionID}` : 'all',
1058
+ checkedSessions: sessions.length,
1059
+ summarySessionsNeedingRebuild,
1060
+ lineageSessionsNeedingRefresh,
1061
+ orphanSummaryEdges: this.countScopedOrphanSummaryEdges(sessionIDs),
1062
+ messageFts: {
1063
+ expected: messageFtsExpected,
1064
+ actual: this.countScopedFtsRows('message_fts', sessionIDs),
1065
+ },
1066
+ summaryFts: {
1067
+ expected: this.readScopedSummaryRowsSync(sessionIDs).length,
1068
+ actual: this.countScopedFtsRows('summary_fts', sessionIDs),
1069
+ },
1070
+ artifactFts: {
1071
+ expected: this.readScopedArtifactRowsSync(sessionIDs).length,
1072
+ actual: this.countScopedFtsRows('artifact_fts', sessionIDs),
1073
+ },
1074
+ orphanArtifactBlobs: this.readOrphanArtifactBlobRowsSync().length,
1075
+ status: 'clean',
1076
+ };
1077
+ report.status = this.hasDoctorIssues(report) ? 'issues-found' : 'clean';
1078
+ return report;
1079
+ }
1080
+ hasDoctorIssues(report) {
1081
+ return (report.summarySessionsNeedingRebuild.length > 0 ||
1082
+ report.lineageSessionsNeedingRefresh.length > 0 ||
1083
+ report.orphanSummaryEdges > 0 ||
1084
+ report.messageFts.expected !== report.messageFts.actual ||
1085
+ report.summaryFts.expected !== report.summaryFts.actual ||
1086
+ report.artifactFts.expected !== report.artifactFts.actual ||
1087
+ report.orphanArtifactBlobs > 0);
1088
+ }
1089
+ diagnoseSummarySession(session) {
1090
+ const issues = [];
1091
+ const archived = this.getArchivedMessages(session.messages);
1092
+ const state = safeQueryOne(this.getDb().prepare('SELECT * FROM summary_state WHERE session_id = ?'), [session.sessionID], 'diagnoseSummarySession');
1093
+ const summaryNodeCount = safeQueryOne(this.getDb().prepare('SELECT COUNT(*) AS count FROM summary_nodes WHERE session_id = ?'), [session.sessionID], 'diagnoseSummarySession.nodeCount') ?? { count: 0 };
1094
+ const summaryEdgeCount = safeQueryOne(this.getDb().prepare('SELECT COUNT(*) AS count FROM summary_edges WHERE session_id = ?'), [session.sessionID], 'diagnoseSummarySession.edgeCount') ?? { count: 0 };
1095
+ if (archived.length === 0) {
1096
+ if (state)
1097
+ issues.push('unexpected-summary-state');
1098
+ if (summaryNodeCount.count > 0)
1099
+ issues.push('unexpected-summary-nodes');
1100
+ if (summaryEdgeCount.count > 0)
1101
+ issues.push('unexpected-summary-edges');
1102
+ return issues.length > 0 ? { sessionID: session.sessionID, issues } : undefined;
1103
+ }
1104
+ const latestMessageCreated = archived.at(-1)?.info.time.created ?? 0;
1105
+ const archivedSignature = this.buildArchivedSignature(archived);
1106
+ const rootIDs = state ? parseJson(state.root_node_ids_json) : [];
1107
+ const roots = rootIDs
1108
+ .map((nodeID) => this.readSummaryNodeSync(nodeID))
1109
+ .filter((node) => Boolean(node));
1110
+ if (!state) {
1111
+ issues.push('missing-summary-state');
1112
+ }
1113
+ else {
1114
+ if (state.archived_count !== archived.length)
1115
+ issues.push('archived-count-mismatch');
1116
+ if (state.latest_message_created !== latestMessageCreated)
1117
+ issues.push('latest-message-mismatch');
1118
+ if (state.archived_signature !== archivedSignature)
1119
+ issues.push('archived-signature-mismatch');
1120
+ if (rootIDs.length === 0)
1121
+ issues.push('missing-root-node-ids');
1122
+ if (roots.length !== rootIDs.length) {
1123
+ issues.push('missing-root-node-record');
1124
+ }
1125
+ else if (rootIDs.length > 0 &&
1126
+ !this.canReuseSummaryGraphSync(session.sessionID, archived, roots)) {
1127
+ issues.push('invalid-summary-graph');
1128
+ }
1129
+ }
1130
+ if (summaryNodeCount.count === 0)
1131
+ issues.push('missing-summary-nodes');
1132
+ return issues.length > 0 ? { sessionID: session.sessionID, issues } : undefined;
1133
+ }
1134
+ needsLineageRefresh(session) {
1135
+ const chain = this.readLineageChainSync(session.sessionID);
1136
+ const expectedRoot = chain[0]?.sessionID ?? session.sessionID;
1137
+ const expectedDepth = Math.max(0, chain.length - 1);
1138
+ return ((session.rootSessionID ?? session.sessionID) !== expectedRoot ||
1139
+ (session.lineageDepth ?? 0) !== expectedDepth);
1140
+ }
1141
+ rebuildSummarySessionsSync(sessionIDs) {
1142
+ for (const sessionID of sessionIDs) {
1143
+ const session = this.readSessionSync(sessionID);
1144
+ this.ensureSummaryGraphSync(sessionID, this.getArchivedMessages(session.messages));
1145
+ }
1146
+ }
1147
+ countScopedFtsRows(table, sessionIDs) {
1148
+ if (sessionIDs && sessionIDs.length === 0)
1149
+ return 0;
1150
+ if (!sessionIDs) {
1151
+ const row = this.getDb().prepare(`SELECT COUNT(*) AS count FROM ${table}`).get();
1152
+ return row.count;
1153
+ }
1154
+ const placeholders = sessionIDs.map(() => '?').join(', ');
1155
+ const row = this.getDb()
1156
+ .prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE session_id IN (${placeholders})`)
1157
+ .get(...sessionIDs);
1158
+ return row.count;
1159
+ }
1160
+ countScopedOrphanSummaryEdges(sessionIDs) {
1161
+ if (sessionIDs && sessionIDs.length === 0)
1162
+ return 0;
1163
+ const scopeClause = sessionIDs
1164
+ ? `e.session_id IN (${sessionIDs.map(() => '?').join(', ')}) AND `
1165
+ : '';
1166
+ const row = this.getDb()
1167
+ .prepare(`SELECT COUNT(*) AS count
1168
+ FROM summary_edges e
1169
+ WHERE ${scopeClause}(
1170
+ NOT EXISTS (SELECT 1 FROM summary_nodes parent WHERE parent.node_id = e.parent_id)
1171
+ OR NOT EXISTS (SELECT 1 FROM summary_nodes child WHERE child.node_id = e.child_id)
1172
+ )`)
1173
+ .get(...(sessionIDs ?? []));
1174
+ return row.count;
1175
+ }
1176
+ shouldRefreshLineageForEvent(eventType) {
1177
+ return (eventType === 'session.created' ||
1178
+ eventType === 'session.updated' ||
1179
+ eventType === 'session.deleted');
1180
+ }
1181
+ shouldPersistSessionForEvent(eventType) {
1182
+ return (eventType === 'session.created' ||
1183
+ eventType === 'session.updated' ||
1184
+ eventType === 'session.deleted' ||
1185
+ eventType === 'session.compacted' ||
1186
+ eventType === 'message.updated' ||
1187
+ eventType === 'message.removed' ||
1188
+ eventType === 'message.part.updated' ||
1189
+ eventType === 'message.part.removed');
1190
+ }
1191
+ shouldRecordEvent(eventType) {
1192
+ if (this.shouldPersistSessionForEvent(eventType))
1193
+ return true;
1194
+ return (eventType === 'session.error' ||
1195
+ eventType === 'permission.asked' ||
1196
+ eventType === 'permission.replied' ||
1197
+ eventType === 'question.asked' ||
1198
+ eventType === 'question.replied');
1199
+ }
1200
+ shouldSyncDerivedLineageSubtree(eventType, previousParentSessionID, nextParentSessionID) {
1201
+ return (eventType === 'session.created' ||
1202
+ (eventType === 'session.updated' && previousParentSessionID !== nextParentSessionID));
1203
+ }
1204
+ shouldCleanupOrphanBlobsForEvent(eventType) {
1205
+ return (eventType === 'message.removed' ||
1206
+ eventType === 'message.part.updated' ||
1207
+ eventType === 'message.part.removed');
1208
+ }
1209
+ captureArtifactHydrationMessageIDs(event) {
1210
+ const payload = event.payload;
1211
+ switch (payload.type) {
1212
+ case 'message.updated':
1213
+ return [payload.properties.info.id];
1214
+ case 'message.part.updated':
1215
+ return [payload.properties.part.messageID];
1216
+ case 'message.part.removed':
1217
+ return [payload.properties.messageID];
1218
+ default:
1219
+ return [];
1220
+ }
1221
+ }
1222
+ archivedMessageIDs(messages) {
1223
+ return this.getArchivedMessages(messages).map((message) => message.info.id);
1224
+ }
1225
+ didArchivedMessagesChange(before, after) {
1226
+ const beforeIDs = this.archivedMessageIDs(before);
1227
+ const afterIDs = this.archivedMessageIDs(after);
1228
+ if (beforeIDs.length !== afterIDs.length)
1229
+ return true;
1230
+ return beforeIDs.some((messageID, index) => messageID !== afterIDs[index]);
1231
+ }
1232
+ isArchivedMessage(messages, messageID) {
1233
+ if (!messageID)
1234
+ return false;
1235
+ return this.archivedMessageIDs(messages).includes(messageID);
1236
+ }
1237
+ shouldSyncDerivedSessionStateForEvent(previous, next, event) {
1238
+ const payload = event.payload;
1239
+ switch (payload.type) {
1240
+ case 'message.updated': {
1241
+ const messageID = payload.properties.info.id;
1242
+ return (this.didArchivedMessagesChange(previous.messages, next.messages) ||
1243
+ this.isArchivedMessage(previous.messages, messageID) ||
1244
+ this.isArchivedMessage(next.messages, messageID));
1245
+ }
1246
+ case 'message.removed':
1247
+ return this.didArchivedMessagesChange(previous.messages, next.messages);
1248
+ case 'message.part.updated': {
1249
+ const messageID = payload.properties.part.messageID;
1250
+ return (this.isArchivedMessage(previous.messages, messageID) ||
1251
+ this.isArchivedMessage(next.messages, messageID));
1252
+ }
1253
+ case 'message.part.removed': {
1254
+ const messageID = payload.properties.messageID;
1255
+ return (this.isArchivedMessage(previous.messages, messageID) ||
1256
+ this.isArchivedMessage(next.messages, messageID));
1257
+ }
1258
+ default:
1259
+ return false;
1260
+ }
1261
+ }
1262
+ syncAllDerivedSessionStateSync(preserveExistingResume = false) {
1263
+ for (const session of this.readAllSessionsSync()) {
1264
+ this.syncDerivedSessionStateSync(session, preserveExistingResume);
1265
+ }
1266
+ }
1267
+ syncDerivedSessionStateSync(session, preserveExistingResume = false) {
1268
+ const roots = this.ensureSummaryGraphSync(session.sessionID, this.getArchivedMessages(session.messages));
1269
+ this.writeResumeSync(session, roots, preserveExistingResume);
1270
+ return roots;
1271
+ }
1272
+ syncDerivedLineageSubtreeSync(sessionID, preserveExistingResume = false) {
1273
+ const queue = [sessionID];
1274
+ const seen = new Set([sessionID]);
1275
+ while (queue.length > 0) {
1276
+ const currentSessionID = queue.shift();
1277
+ if (!currentSessionID)
1278
+ continue;
1279
+ if (currentSessionID !== sessionID) {
1280
+ this.syncDerivedSessionStateSync(this.readSessionSync(currentSessionID), preserveExistingResume);
1281
+ }
1282
+ for (const child of this.readChildSessionsSync(currentSessionID)) {
1283
+ if (seen.has(child.sessionID))
1284
+ continue;
1285
+ seen.add(child.sessionID);
1286
+ queue.push(child.sessionID);
1287
+ }
1288
+ }
1289
+ }
1290
+ writeResumeSync(session, roots, preserveExistingResume = false) {
1291
+ const db = this.getDb();
1292
+ if (session.messages.length === 0) {
1293
+ db.prepare('DELETE FROM resumes WHERE session_id = ?').run(session.sessionID);
1294
+ return;
1295
+ }
1296
+ const existing = this.getResumeSync(session.sessionID);
1297
+ if (preserveExistingResume && existing && !this.isManagedResumeNote(existing)) {
1298
+ return;
1299
+ }
1300
+ const note = this.buildResumeNote(session, roots);
1301
+ db.prepare(`INSERT INTO resumes (session_id, note, updated_at)
1302
+ VALUES (?, ?, ?)
1303
+ ON CONFLICT(session_id) DO UPDATE SET note = excluded.note, updated_at = excluded.updated_at`).run(session.sessionID, note, Date.now());
1304
+ }
1305
+ isManagedResumeNote(note) {
1306
+ return note.startsWith('LCM prototype resume note\n') || note === 'LCM prototype resume note';
1307
+ }
1308
+ resolveRetentionPolicy(input) {
1309
+ return {
1310
+ staleSessionDays: input?.staleSessionDays ?? this.options.retention.staleSessionDays,
1311
+ deletedSessionDays: input?.deletedSessionDays ?? this.options.retention.deletedSessionDays,
1312
+ orphanBlobDays: input?.orphanBlobDays ?? this.options.retention.orphanBlobDays,
1313
+ };
1314
+ }
1315
+ retentionCutoff(days) {
1316
+ return Date.now() - days * 24 * 60 * 60 * 1000;
1317
+ }
1318
+ applyRetentionPruneSync(input) {
1319
+ const policy = this.resolveRetentionPolicy(input);
1320
+ if (input?.apply === false) {
1321
+ return { deletedSessions: 0, deletedBlobs: 0, deletedBlobChars: 0 };
1322
+ }
1323
+ const staleSessions = policy.staleSessionDays === undefined
1324
+ ? []
1325
+ : this.readSessionRetentionCandidates(false, policy.staleSessionDays);
1326
+ const deletedSessions = policy.deletedSessionDays === undefined
1327
+ ? []
1328
+ : this.readSessionRetentionCandidates(true, policy.deletedSessionDays);
1329
+ const combinedSessions = [...staleSessions, ...deletedSessions];
1330
+ const uniqueSessionIDs = [...new Set(combinedSessions.map((row) => row.session_id))];
1331
+ const initialOrphanBlobs = policy.orphanBlobDays === undefined
1332
+ ? []
1333
+ : this.readOrphanBlobRetentionCandidates(policy.orphanBlobDays);
1334
+ if (uniqueSessionIDs.length === 0 && initialOrphanBlobs.length === 0) {
1335
+ return { deletedSessions: 0, deletedBlobs: 0, deletedBlobChars: 0 };
1336
+ }
1337
+ const db = this.getDb();
1338
+ let deletedBlobs = [];
1339
+ withTransaction(db, 'retentionPrune', () => {
1340
+ for (const sessionID of uniqueSessionIDs) {
1341
+ this.clearSessionDataSync(sessionID);
1342
+ }
1343
+ deletedBlobs =
1344
+ policy.orphanBlobDays === undefined
1345
+ ? []
1346
+ : this.readOrphanBlobRetentionCandidates(policy.orphanBlobDays);
1347
+ if (deletedBlobs.length > 0) {
1348
+ const deleteBlob = db.prepare('DELETE FROM artifact_blobs WHERE content_hash = ?');
1349
+ for (const blob of deletedBlobs)
1350
+ deleteBlob.run(blob.content_hash);
1351
+ }
1352
+ });
1353
+ if (uniqueSessionIDs.length > 0) {
1354
+ this.refreshAllLineageSync();
1355
+ this.syncAllDerivedSessionStateSync(true);
1356
+ this.refreshSearchIndexesSync();
1357
+ }
1358
+ return {
1359
+ deletedSessions: uniqueSessionIDs.length,
1360
+ deletedBlobs: deletedBlobs.length,
1361
+ deletedBlobChars: deletedBlobs.reduce((sum, row) => sum + row.char_count, 0),
1362
+ };
1363
+ }
1364
+ formatRetentionSessionCandidate(row) {
1365
+ const title = row.title ?? 'Untitled session';
1366
+ const worktree = normalizeWorktreeKey(row.session_directory ?? undefined) ?? 'unknown';
1367
+ const root = row.root_session_id ?? row.session_id;
1368
+ 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}`;
1369
+ }
1370
+ readSessionRetentionCandidates(deleted, days, limit) {
1371
+ const params = [this.retentionCutoff(days), deleted ? 1 : 0];
1372
+ const sql = `
1373
+ SELECT
1374
+ s.session_id,
1375
+ s.title,
1376
+ s.session_directory,
1377
+ s.root_session_id,
1378
+ s.pinned,
1379
+ s.deleted,
1380
+ s.updated_at,
1381
+ s.event_count,
1382
+ (SELECT COUNT(*) FROM messages m WHERE m.session_id = s.session_id) AS message_count,
1383
+ (SELECT COUNT(*) FROM artifacts a WHERE a.session_id = s.session_id) AS artifact_count
1384
+ FROM sessions s
1385
+ WHERE s.updated_at <= ?
1386
+ AND s.deleted = ?
1387
+ AND s.pinned = 0
1388
+ AND NOT EXISTS (
1389
+ SELECT 1 FROM sessions child WHERE child.parent_session_id = s.session_id
1390
+ )
1391
+ ORDER BY s.updated_at ASC
1392
+ ${limit ? 'LIMIT ?' : ''}`;
1393
+ if (limit)
1394
+ params.push(limit);
1395
+ return this.getDb()
1396
+ .prepare(sql)
1397
+ .all(...params);
1398
+ }
1399
+ countSessionRetentionCandidates(deleted, days) {
1400
+ const row = this.getDb()
1401
+ .prepare(`SELECT COUNT(*) AS count
1402
+ FROM sessions s
1403
+ WHERE s.updated_at <= ?
1404
+ AND s.deleted = ?
1405
+ AND s.pinned = 0
1406
+ AND NOT EXISTS (
1407
+ SELECT 1 FROM sessions child WHERE child.parent_session_id = s.session_id
1408
+ )`)
1409
+ .get(this.retentionCutoff(days), deleted ? 1 : 0);
1410
+ return row.count;
1411
+ }
1412
+ readOrphanBlobRetentionCandidates(days, limit) {
1413
+ const params = [this.retentionCutoff(days)];
1414
+ const sql = `
1415
+ SELECT content_hash, char_count, created_at
1416
+ FROM artifact_blobs b
1417
+ WHERE b.created_at <= ?
1418
+ AND NOT EXISTS (
1419
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
1420
+ )
1421
+ ORDER BY char_count DESC, created_at ASC
1422
+ ${limit ? 'LIMIT ?' : ''}`;
1423
+ if (limit)
1424
+ params.push(limit);
1425
+ return this.getDb()
1426
+ .prepare(sql)
1427
+ .all(...params);
1428
+ }
1429
+ countOrphanBlobRetentionCandidates(days) {
1430
+ const row = this.getDb()
1431
+ .prepare(`SELECT COUNT(*) AS count
1432
+ FROM artifact_blobs b
1433
+ WHERE b.created_at <= ?
1434
+ AND NOT EXISTS (
1435
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
1436
+ )`)
1437
+ .get(this.retentionCutoff(days));
1438
+ return row.count;
1439
+ }
1440
+ sumOrphanBlobRetentionChars(days) {
1441
+ const row = this.getDb()
1442
+ .prepare(`SELECT COALESCE(SUM(char_count), 0) AS chars
1443
+ FROM artifact_blobs b
1444
+ WHERE b.created_at <= ?
1445
+ AND NOT EXISTS (
1446
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
1447
+ )`)
1448
+ .get(this.retentionCutoff(days));
1449
+ return row.chars;
1450
+ }
1451
+ normalizeScope(scope) {
1452
+ if (scope === 'session' || scope === 'root' || scope === 'worktree' || scope === 'all')
1453
+ return scope;
1454
+ return undefined;
1455
+ }
1456
+ resolveConfiguredScope(operation, explicitScope, sessionID) {
1457
+ const explicit = this.normalizeScope(explicitScope);
1458
+ if (explicit)
1459
+ return explicit;
1460
+ const worktreeKey = this.resolveScopeWorktreeKey(sessionID);
1461
+ if (worktreeKey) {
1462
+ const profile = this.options.scopeProfiles.find((entry) => normalizeWorktreeKey(entry.worktree) === worktreeKey);
1463
+ if (profile?.[operation])
1464
+ return profile[operation];
1465
+ }
1466
+ return this.options.scopeDefaults[operation];
1467
+ }
1468
+ resolveScopeWorktreeKey(sessionID) {
1469
+ if (sessionID) {
1470
+ const session = this.readSessionHeaderSync(sessionID);
1471
+ const sessionWorktree = normalizeWorktreeKey(session?.directory);
1472
+ if (sessionWorktree)
1473
+ return sessionWorktree;
1474
+ }
1475
+ return normalizeWorktreeKey(this.workspaceDirectory);
1476
+ }
1477
+ resolveScopeSessionIDs(scope, sessionID) {
1478
+ const normalizedScope = this.normalizeScope(scope) ?? this.options.scopeDefaults.grep;
1479
+ if (normalizedScope === 'all')
1480
+ return undefined;
1481
+ const resolvedSessionID = sessionID ?? this.latestSessionIDSync();
1482
+ if (!resolvedSessionID)
1483
+ return [];
1484
+ if (normalizedScope === 'session')
1485
+ return [resolvedSessionID];
1486
+ const session = this.readSessionHeaderSync(resolvedSessionID);
1487
+ if (!session)
1488
+ return [];
1489
+ if (normalizedScope === 'root') {
1490
+ const rootSessionID = session.rootSessionID ?? session.sessionID;
1491
+ const rows = this.getDb()
1492
+ .prepare('SELECT session_id FROM sessions WHERE root_session_id = ? OR session_id = ? ORDER BY updated_at DESC')
1493
+ .all(rootSessionID, rootSessionID);
1494
+ return [...new Set(rows.map((row) => row.session_id))];
1495
+ }
1496
+ const worktreeKey = normalizeWorktreeKey(session.directory);
1497
+ if (!worktreeKey)
1498
+ return [resolvedSessionID];
1499
+ const rows = this.getDb()
1500
+ .prepare('SELECT session_id FROM sessions WHERE worktree_key = ? ORDER BY updated_at DESC')
1501
+ .all(worktreeKey);
1502
+ return [...new Set(rows.map((row) => row.session_id))];
1503
+ }
1504
+ readScopedSessionRowsSync(sessionIDs) {
1505
+ if (!sessionIDs) {
1506
+ return this.getDb()
1507
+ .prepare('SELECT * FROM sessions ORDER BY updated_at DESC')
1508
+ .all();
1509
+ }
1510
+ if (sessionIDs.length === 0)
1511
+ return [];
1512
+ return this.getDb()
1513
+ .prepare(`SELECT * FROM sessions WHERE session_id IN (${sessionIDs.map(() => '?').join(', ')}) ORDER BY updated_at DESC`)
1514
+ .all(...sessionIDs);
1515
+ }
1516
+ readScopedMessageRowsSync(sessionIDs) {
1517
+ if (!sessionIDs) {
1518
+ return this.getDb()
1519
+ .prepare('SELECT * FROM messages ORDER BY created_at ASC, message_id ASC')
1520
+ .all();
1521
+ }
1522
+ if (sessionIDs.length === 0)
1523
+ return [];
1524
+ return this.getDb()
1525
+ .prepare(`SELECT * FROM messages WHERE session_id IN (${sessionIDs.map(() => '?').join(', ')}) ORDER BY created_at ASC, message_id ASC`)
1526
+ .all(...sessionIDs);
1527
+ }
1528
+ readScopedPartRowsSync(sessionIDs) {
1529
+ if (!sessionIDs) {
1530
+ return this.getDb()
1531
+ .prepare('SELECT * FROM parts ORDER BY message_id ASC, sort_key ASC, part_id ASC')
1532
+ .all();
1533
+ }
1534
+ if (sessionIDs.length === 0)
1535
+ return [];
1536
+ return this.getDb()
1537
+ .prepare(`SELECT * FROM parts WHERE session_id IN (${sessionIDs.map(() => '?').join(', ')}) ORDER BY message_id ASC, sort_key ASC, part_id ASC`)
1538
+ .all(...sessionIDs);
1539
+ }
1540
+ readScopedResumeRowsSync(sessionIDs) {
1541
+ if (!sessionIDs) {
1542
+ return this.getDb().prepare('SELECT * FROM resumes ORDER BY updated_at DESC').all();
1543
+ }
1544
+ if (sessionIDs.length === 0)
1545
+ return [];
1546
+ return this.getDb()
1547
+ .prepare(`SELECT * FROM resumes WHERE session_id IN (${sessionIDs.map(() => '?').join(', ')}) ORDER BY updated_at DESC`)
1548
+ .all(...sessionIDs);
1549
+ }
1550
+ readScopedSessionsSync(sessionIDs) {
1551
+ if (!sessionIDs)
1552
+ return this.readAllSessionsSync();
1553
+ if (sessionIDs.length === 0)
1554
+ return [];
1555
+ if (sessionIDs.length <= 1)
1556
+ return sessionIDs.map((id) => this.readSessionSync(id));
1557
+ return this.readSessionsBatchSync(sessionIDs).filter((session) => session.messages.length > 0 || session.eventCount > 0);
1558
+ }
1559
+ readScopedSummaryRowsSync(sessionIDs) {
1560
+ if (!sessionIDs) {
1561
+ return this.getDb()
1562
+ .prepare('SELECT * FROM summary_nodes ORDER BY created_at DESC')
1563
+ .all();
1564
+ }
1565
+ if (sessionIDs.length === 0)
1566
+ return [];
1567
+ return this.getDb()
1568
+ .prepare(`SELECT * FROM summary_nodes WHERE session_id IN (${sessionIDs.map(() => '?').join(', ')}) ORDER BY created_at DESC`)
1569
+ .all(...sessionIDs);
1570
+ }
1571
+ readScopedSummaryEdgeRowsSync(sessionIDs) {
1572
+ if (!sessionIDs) {
1573
+ return this.getDb()
1574
+ .prepare('SELECT * FROM summary_edges ORDER BY session_id ASC, parent_id ASC, child_position ASC')
1575
+ .all();
1576
+ }
1577
+ if (sessionIDs.length === 0)
1578
+ return [];
1579
+ return this.getDb()
1580
+ .prepare(`SELECT * FROM summary_edges WHERE session_id IN (${sessionIDs.map(() => '?').join(', ')}) ORDER BY session_id ASC, parent_id ASC, child_position ASC`)
1581
+ .all(...sessionIDs);
1582
+ }
1583
+ readScopedSummaryStateRowsSync(sessionIDs) {
1584
+ if (!sessionIDs) {
1585
+ return this.getDb()
1586
+ .prepare('SELECT * FROM summary_state ORDER BY updated_at DESC')
1587
+ .all();
1588
+ }
1589
+ if (sessionIDs.length === 0)
1590
+ return [];
1591
+ return this.getDb()
1592
+ .prepare(`SELECT * FROM summary_state WHERE session_id IN (${sessionIDs.map(() => '?').join(', ')}) ORDER BY updated_at DESC`)
1593
+ .all(...sessionIDs);
1594
+ }
1595
+ readScopedArtifactRowsSync(sessionIDs) {
1596
+ if (!sessionIDs) {
1597
+ return this.getDb()
1598
+ .prepare('SELECT * FROM artifacts ORDER BY created_at DESC')
1599
+ .all();
1600
+ }
1601
+ if (sessionIDs.length === 0)
1602
+ return [];
1603
+ return this.getDb()
1604
+ .prepare(`SELECT * FROM artifacts WHERE session_id IN (${sessionIDs.map(() => '?').join(', ')}) ORDER BY created_at DESC`)
1605
+ .all(...sessionIDs);
1606
+ }
1607
+ readScopedArtifactBlobRowsSync(sessionIDs) {
1608
+ if (!sessionIDs) {
1609
+ return this.getDb()
1610
+ .prepare('SELECT * FROM artifact_blobs ORDER BY created_at ASC')
1611
+ .all();
1612
+ }
1613
+ if (sessionIDs.length === 0)
1614
+ return [];
1615
+ return this.getDb()
1616
+ .prepare(`SELECT DISTINCT b.*
1617
+ FROM artifact_blobs b
1618
+ JOIN artifacts a ON a.content_hash = b.content_hash
1619
+ WHERE a.session_id IN (${sessionIDs.map(() => '?').join(', ')})
1620
+ ORDER BY b.created_at ASC`)
1621
+ .all(...sessionIDs);
1622
+ }
1623
+ async lineage(sessionID) {
1624
+ await this.prepareForRead();
1625
+ const resolvedSessionID = sessionID ?? this.latestSessionIDSync();
1626
+ if (!resolvedSessionID)
1627
+ return 'No archived sessions yet.';
1628
+ const session = this.readSessionSync(resolvedSessionID);
1629
+ const chain = this.readLineageChainSync(resolvedSessionID);
1630
+ const children = this.readChildSessionsSync(resolvedSessionID);
1631
+ const siblings = session.parentSessionID
1632
+ ? this.readChildSessionsSync(session.parentSessionID).filter((child) => child.sessionID !== resolvedSessionID)
1633
+ : [];
1634
+ return [
1635
+ `Session: ${session.sessionID}`,
1636
+ `Title: ${makeSessionTitle(session) ?? 'Unknown'}`,
1637
+ `Worktree: ${normalizeWorktreeKey(session.directory) ?? 'unknown'}`,
1638
+ `Root session: ${session.rootSessionID ?? session.sessionID}`,
1639
+ `Parent session: ${session.parentSessionID ?? 'none'}`,
1640
+ `Lineage depth: ${session.lineageDepth ?? 0}`,
1641
+ 'Lineage chain:',
1642
+ ...chain.map((entry, index) => `${entry.sessionID === resolvedSessionID ? '*' : '-'} depth=${index} ${entry.sessionID}: ${makeSessionTitle(entry) ?? 'Untitled session'}`),
1643
+ ...(siblings.length > 0
1644
+ ? [
1645
+ 'Sibling branches:',
1646
+ ...siblings.map((entry) => `- ${entry.sessionID}: ${makeSessionTitle(entry) ?? 'Untitled session'}`),
1647
+ ]
1648
+ : []),
1649
+ ...(children.length > 0
1650
+ ? [
1651
+ 'Child branches:',
1652
+ ...children.map((entry) => `- ${entry.sessionID}: ${makeSessionTitle(entry) ?? 'Untitled session'}`),
1653
+ ]
1654
+ : []),
1655
+ ].join('\n');
1656
+ }
1657
+ async pinSession(input) {
1658
+ await this.prepareForRead();
1659
+ const sessionID = input.sessionID ?? this.latestSessionIDSync();
1660
+ if (!sessionID)
1661
+ return 'No archived sessions yet.';
1662
+ const session = this.readSessionHeaderSync(sessionID);
1663
+ if (!session)
1664
+ return 'Unknown session.';
1665
+ const reason = input.reason?.trim() || 'Pinned by user';
1666
+ this.getDb()
1667
+ .prepare('UPDATE sessions SET pinned = 1, pin_reason = ? WHERE session_id = ?')
1668
+ .run(reason, sessionID);
1669
+ return [`session=${sessionID}`, 'pinned=true', `reason=${reason}`].join('\n');
1670
+ }
1671
+ async unpinSession(input) {
1672
+ await this.prepareForRead();
1673
+ const sessionID = input.sessionID ?? this.latestSessionIDSync();
1674
+ if (!sessionID)
1675
+ return 'No archived sessions yet.';
1676
+ const session = this.readSessionHeaderSync(sessionID);
1677
+ if (!session)
1678
+ return 'Unknown session.';
1679
+ this.getDb()
1680
+ .prepare('UPDATE sessions SET pinned = 0, pin_reason = NULL WHERE session_id = ?')
1681
+ .run(sessionID);
1682
+ return [`session=${sessionID}`, 'pinned=false'].join('\n');
1683
+ }
1684
+ async artifact(input) {
1685
+ await this.prepareForRead();
1686
+ const artifact = this.readArtifactSync(input.artifactID);
1687
+ if (!artifact)
1688
+ return 'Unknown artifact.';
1689
+ const maxChars = Math.max(200, Math.min(this.options.artifactViewChars, input.chars ?? this.options.artifactViewChars));
1690
+ return [
1691
+ `Artifact: ${artifact.artifactID}`,
1692
+ `Session: ${artifact.sessionID}`,
1693
+ `Message: ${artifact.messageID}`,
1694
+ `Part: ${artifact.partID}`,
1695
+ `Kind: ${artifact.artifactKind}`,
1696
+ `Field: ${artifact.fieldName}`,
1697
+ `Content hash: ${artifact.contentHash}`,
1698
+ `Characters: ${artifact.charCount}`,
1699
+ ...this.formatArtifactMetadataLines(artifact.metadata),
1700
+ 'Preview:',
1701
+ truncate(artifact.previewText, this.options.artifactPreviewChars),
1702
+ 'Content:',
1703
+ truncate(artifact.contentText, maxChars),
1704
+ ].join('\n');
1705
+ }
1706
+ async blobStats(input) {
1707
+ await this.prepareForRead();
1708
+ const limit = clamp(input?.limit ?? 5, 1, 20);
1709
+ const db = this.getDb();
1710
+ const totals = db
1711
+ .prepare(`SELECT
1712
+ COUNT(*) AS blob_count,
1713
+ COALESCE(SUM(char_count), 0) AS blob_chars,
1714
+ 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
1715
+ FROM artifact_blobs b`)
1716
+ .get();
1717
+ const referenced = db
1718
+ .prepare('SELECT COUNT(DISTINCT content_hash) AS count FROM artifacts WHERE content_hash IS NOT NULL')
1719
+ .get();
1720
+ const sharedCount = db
1721
+ .prepare(`SELECT COUNT(*) AS count FROM (
1722
+ SELECT content_hash FROM artifacts
1723
+ WHERE content_hash IS NOT NULL
1724
+ GROUP BY content_hash
1725
+ HAVING COUNT(*) > 1
1726
+ )`)
1727
+ .get();
1728
+ const orphanCount = db
1729
+ .prepare(`SELECT COUNT(*) AS count FROM artifact_blobs b
1730
+ WHERE NOT EXISTS (
1731
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
1732
+ )`)
1733
+ .get();
1734
+ const shared = db
1735
+ .prepare(`SELECT a.content_hash AS content_hash, COUNT(*) AS ref_count, MAX(b.char_count) AS char_count
1736
+ FROM artifacts a
1737
+ JOIN artifact_blobs b ON b.content_hash = a.content_hash
1738
+ WHERE a.content_hash IS NOT NULL
1739
+ GROUP BY a.content_hash
1740
+ HAVING COUNT(*) > 1
1741
+ ORDER BY ref_count DESC, char_count DESC
1742
+ LIMIT ?`)
1743
+ .all(limit);
1744
+ const orphan = db
1745
+ .prepare(`SELECT content_hash, char_count, created_at
1746
+ FROM artifact_blobs b
1747
+ WHERE NOT EXISTS (
1748
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
1749
+ )
1750
+ ORDER BY char_count DESC, created_at ASC
1751
+ LIMIT ?`)
1752
+ .all(limit);
1753
+ const saved = db
1754
+ .prepare(`SELECT COALESCE(SUM((ref_count - 1) * char_count), 0) AS chars_saved FROM (
1755
+ SELECT a.content_hash AS content_hash, COUNT(*) AS ref_count, MAX(b.char_count) AS char_count
1756
+ FROM artifacts a
1757
+ JOIN artifact_blobs b ON b.content_hash = a.content_hash
1758
+ WHERE a.content_hash IS NOT NULL
1759
+ GROUP BY a.content_hash
1760
+ HAVING COUNT(*) > 1
1761
+ )`)
1762
+ .get();
1763
+ return [
1764
+ `artifact_blobs=${totals.blob_count}`,
1765
+ `referenced_blobs=${referenced.count}`,
1766
+ `shared_blobs=${sharedCount.count}`,
1767
+ `orphan_blobs=${orphanCount.count}`,
1768
+ `blob_chars=${totals.blob_chars}`,
1769
+ `orphan_blob_chars=${totals.orphan_chars}`,
1770
+ `saved_chars_from_dedup=${saved.chars_saved}`,
1771
+ ...(shared.length > 0
1772
+ ? [
1773
+ 'top_shared_blobs:',
1774
+ ...shared.map((row) => `- ${row.content_hash.slice(0, 16)} refs=${row.ref_count} chars=${row.char_count}`),
1775
+ ]
1776
+ : ['top_shared_blobs:', '- none']),
1777
+ ...(orphan.length > 0
1778
+ ? [
1779
+ 'orphan_blobs_preview:',
1780
+ ...orphan.map((row) => `- ${row.content_hash.slice(0, 16)} chars=${row.char_count} created_at=${row.created_at}`),
1781
+ ]
1782
+ : ['orphan_blobs_preview:', '- none']),
1783
+ ].join('\n');
1784
+ }
1785
+ readOrphanArtifactBlobRowsSync() {
1786
+ return this.getDb()
1787
+ .prepare(`SELECT content_hash, char_count, created_at
1788
+ FROM artifact_blobs b
1789
+ WHERE NOT EXISTS (
1790
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
1791
+ )
1792
+ ORDER BY char_count DESC, created_at ASC`)
1793
+ .all();
1794
+ }
1795
+ deleteOrphanArtifactBlobsSync() {
1796
+ const orphanRows = this.readOrphanArtifactBlobRowsSync();
1797
+ if (orphanRows.length === 0)
1798
+ return [];
1799
+ this.getDb()
1800
+ .prepare(`DELETE FROM artifact_blobs
1801
+ WHERE NOT EXISTS (
1802
+ SELECT 1 FROM artifacts a WHERE a.content_hash = artifact_blobs.content_hash
1803
+ )`)
1804
+ .run();
1805
+ return orphanRows;
1806
+ }
1807
+ async gcBlobs(input) {
1808
+ await this.prepareForRead();
1809
+ const apply = input?.apply ?? false;
1810
+ const limit = clamp(input?.limit ?? 10, 1, 50);
1811
+ const orphanRows = this.readOrphanArtifactBlobRowsSync();
1812
+ const totalChars = orphanRows.reduce((sum, row) => sum + row.char_count, 0);
1813
+ if (orphanRows.length === 0) {
1814
+ return ['orphan_blobs=0', 'deleted_blobs=0', 'deleted_blob_chars=0', 'status=clean'].join('\n');
1815
+ }
1816
+ if (!apply) {
1817
+ return [
1818
+ `orphan_blobs=${orphanRows.length}`,
1819
+ `orphan_blob_chars=${totalChars}`,
1820
+ 'status=dry-run',
1821
+ 'preview:',
1822
+ ...orphanRows
1823
+ .slice(0, limit)
1824
+ .map((row) => `- ${row.content_hash.slice(0, 16)} chars=${row.char_count} created_at=${row.created_at}`),
1825
+ 'Re-run with apply=true to delete orphan blobs.',
1826
+ ].join('\n');
1827
+ }
1828
+ this.deleteOrphanArtifactBlobsSync();
1829
+ return [
1830
+ `orphan_blobs=${orphanRows.length}`,
1831
+ `deleted_blobs=${orphanRows.length}`,
1832
+ `deleted_blob_chars=${totalChars}`,
1833
+ 'status=applied',
1834
+ 'deleted_preview:',
1835
+ ...orphanRows
1836
+ .slice(0, limit)
1837
+ .map((row) => `- ${row.content_hash.slice(0, 16)} chars=${row.char_count} created_at=${row.created_at}`),
1838
+ ].join('\n');
1839
+ }
1840
+ readPrunableEventTypeCountsSync() {
1841
+ const rows = this.getDb()
1842
+ .prepare('SELECT event_type, COUNT(*) AS count FROM events GROUP BY event_type ORDER BY count DESC')
1843
+ .all();
1844
+ return rows
1845
+ .filter((row) => !this.shouldRecordEvent(row.event_type))
1846
+ .map((row) => ({
1847
+ eventType: row.event_type,
1848
+ count: row.count,
1849
+ }));
1850
+ }
1851
+ async readStoreFileSizes() {
1852
+ const readBytes = async (filePath) => {
1853
+ try {
1854
+ return (await stat(filePath)).size;
1855
+ }
1856
+ catch {
1857
+ return 0;
1858
+ }
1859
+ };
1860
+ const dbBytes = await readBytes(this.dbPath);
1861
+ const walBytes = await readBytes(`${this.dbPath}-wal`);
1862
+ const shmBytes = await readBytes(`${this.dbPath}-shm`);
1863
+ return {
1864
+ dbBytes,
1865
+ walBytes,
1866
+ shmBytes,
1867
+ totalBytes: dbBytes + walBytes + shmBytes,
1868
+ };
1869
+ }
1870
+ async compactEventLog(input) {
1871
+ await this.prepareForRead();
1872
+ const apply = input?.apply ?? false;
1873
+ const vacuum = input?.vacuum ?? true;
1874
+ const limit = clamp(input?.limit ?? 10, 1, 50);
1875
+ const candidates = this.readPrunableEventTypeCountsSync();
1876
+ const candidateEvents = candidates.reduce((sum, row) => sum + row.count, 0);
1877
+ const beforeSizes = await this.readStoreFileSizes();
1878
+ if (!apply || candidateEvents === 0) {
1879
+ return [
1880
+ `candidate_events=${candidateEvents}`,
1881
+ `apply=false`,
1882
+ `vacuum_requested=${vacuum}`,
1883
+ `db_bytes=${beforeSizes.dbBytes}`,
1884
+ `wal_bytes=${beforeSizes.walBytes}`,
1885
+ `shm_bytes=${beforeSizes.shmBytes}`,
1886
+ `total_bytes=${beforeSizes.totalBytes}`,
1887
+ ...(candidates.length > 0
1888
+ ? [
1889
+ 'candidate_event_types:',
1890
+ ...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
1891
+ ]
1892
+ : ['candidate_event_types:', '- none']),
1893
+ ].join('\n');
1894
+ }
1895
+ const eventTypes = candidates.map((row) => row.eventType);
1896
+ if (eventTypes.length > 0) {
1897
+ const placeholders = eventTypes.map(() => '?').join(', ');
1898
+ this.getDb()
1899
+ .prepare(`DELETE FROM events WHERE event_type IN (${placeholders})`)
1900
+ .run(...eventTypes);
1901
+ }
1902
+ let vacuumApplied = false;
1903
+ this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
1904
+ if (vacuum) {
1905
+ this.getDb().exec('VACUUM');
1906
+ this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
1907
+ vacuumApplied = true;
1908
+ }
1909
+ const afterSizes = await this.readStoreFileSizes();
1910
+ return [
1911
+ `candidate_events=${candidateEvents}`,
1912
+ `deleted_events=${candidateEvents}`,
1913
+ `apply=true`,
1914
+ `vacuum_requested=${vacuum}`,
1915
+ `vacuum_applied=${vacuumApplied}`,
1916
+ `db_bytes_before=${beforeSizes.dbBytes}`,
1917
+ `wal_bytes_before=${beforeSizes.walBytes}`,
1918
+ `shm_bytes_before=${beforeSizes.shmBytes}`,
1919
+ `total_bytes_before=${beforeSizes.totalBytes}`,
1920
+ `db_bytes_after=${afterSizes.dbBytes}`,
1921
+ `wal_bytes_after=${afterSizes.walBytes}`,
1922
+ `shm_bytes_after=${afterSizes.shmBytes}`,
1923
+ `total_bytes_after=${afterSizes.totalBytes}`,
1924
+ ...(candidates.length > 0
1925
+ ? [
1926
+ 'deleted_event_types:',
1927
+ ...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
1928
+ ]
1929
+ : ['deleted_event_types:', '- none']),
1930
+ ].join('\n');
1931
+ }
1932
+ async retentionReport(input) {
1933
+ await this.prepareForRead();
1934
+ const limit = clamp(input?.limit ?? 10, 1, 50);
1935
+ const policy = this.resolveRetentionPolicy(input);
1936
+ const staleSessions = policy.staleSessionDays === undefined
1937
+ ? []
1938
+ : this.readSessionRetentionCandidates(false, policy.staleSessionDays, limit);
1939
+ const deletedSessions = policy.deletedSessionDays === undefined
1940
+ ? []
1941
+ : this.readSessionRetentionCandidates(true, policy.deletedSessionDays, limit);
1942
+ const orphanBlobs = policy.orphanBlobDays === undefined
1943
+ ? []
1944
+ : this.readOrphanBlobRetentionCandidates(policy.orphanBlobDays, limit);
1945
+ const totalStaleSessions = policy.staleSessionDays === undefined
1946
+ ? 0
1947
+ : this.countSessionRetentionCandidates(false, policy.staleSessionDays);
1948
+ const totalDeletedSessions = policy.deletedSessionDays === undefined
1949
+ ? 0
1950
+ : this.countSessionRetentionCandidates(true, policy.deletedSessionDays);
1951
+ const totalOrphanBlobs = policy.orphanBlobDays === undefined
1952
+ ? 0
1953
+ : this.countOrphanBlobRetentionCandidates(policy.orphanBlobDays);
1954
+ const orphanBlobChars = policy.orphanBlobDays === undefined
1955
+ ? 0
1956
+ : this.sumOrphanBlobRetentionChars(policy.orphanBlobDays);
1957
+ return [
1958
+ `stale_session_days=${formatRetentionDays(policy.staleSessionDays)}`,
1959
+ `deleted_session_days=${formatRetentionDays(policy.deletedSessionDays)}`,
1960
+ `orphan_blob_days=${formatRetentionDays(policy.orphanBlobDays)}`,
1961
+ `stale_session_candidates=${totalStaleSessions}`,
1962
+ `deleted_session_candidates=${totalDeletedSessions}`,
1963
+ `orphan_blob_candidates=${totalOrphanBlobs}`,
1964
+ `orphan_blob_candidate_chars=${orphanBlobChars}`,
1965
+ ...(staleSessions.length > 0
1966
+ ? [
1967
+ 'stale_sessions_preview:',
1968
+ ...staleSessions.map((row) => this.formatRetentionSessionCandidate(row)),
1969
+ ]
1970
+ : ['stale_sessions_preview:', '- none']),
1971
+ ...(deletedSessions.length > 0
1972
+ ? [
1973
+ 'deleted_sessions_preview:',
1974
+ ...deletedSessions.map((row) => this.formatRetentionSessionCandidate(row)),
1975
+ ]
1976
+ : ['deleted_sessions_preview:', '- none']),
1977
+ ...(orphanBlobs.length > 0
1978
+ ? [
1979
+ 'orphan_blobs_preview:',
1980
+ ...orphanBlobs.map((row) => `- ${row.content_hash.slice(0, 16)} chars=${row.char_count} created_at=${row.created_at}`),
1981
+ ]
1982
+ : ['orphan_blobs_preview:', '- none']),
1983
+ ].join('\n');
1984
+ }
1985
+ async retentionPrune(input) {
1986
+ await this.prepareForRead();
1987
+ const apply = input?.apply ?? false;
1988
+ const limit = clamp(input?.limit ?? 10, 1, 50);
1989
+ const policy = this.resolveRetentionPolicy(input);
1990
+ const staleSessions = policy.staleSessionDays === undefined
1991
+ ? []
1992
+ : this.readSessionRetentionCandidates(false, policy.staleSessionDays);
1993
+ const deletedSessions = policy.deletedSessionDays === undefined
1994
+ ? []
1995
+ : this.readSessionRetentionCandidates(true, policy.deletedSessionDays);
1996
+ const combinedSessions = [...staleSessions, ...deletedSessions];
1997
+ const initialOrphanBlobs = policy.orphanBlobDays === undefined
1998
+ ? []
1999
+ : this.readOrphanBlobRetentionCandidates(policy.orphanBlobDays);
2000
+ if (!apply) {
2001
+ return [
2002
+ `stale_session_candidates=${staleSessions.length}`,
2003
+ `deleted_session_candidates=${deletedSessions.length}`,
2004
+ `orphan_blob_candidates=${initialOrphanBlobs.length}`,
2005
+ 'status=dry-run',
2006
+ ...(combinedSessions.length > 0
2007
+ ? [
2008
+ 'session_preview:',
2009
+ ...combinedSessions
2010
+ .slice(0, limit)
2011
+ .map((row) => this.formatRetentionSessionCandidate(row)),
2012
+ ]
2013
+ : ['session_preview:', '- none']),
2014
+ ...(initialOrphanBlobs.length > 0
2015
+ ? [
2016
+ 'blob_preview:',
2017
+ ...initialOrphanBlobs
2018
+ .slice(0, limit)
2019
+ .map((row) => `- ${row.content_hash.slice(0, 16)} chars=${row.char_count} created_at=${row.created_at}`),
2020
+ ]
2021
+ : ['blob_preview:', '- none']),
2022
+ 'Re-run with apply=true to prune the candidates above.',
2023
+ ].join('\n');
2024
+ }
2025
+ const result = this.applyRetentionPruneSync({ ...input, apply: true });
2026
+ let combinedPreview = [];
2027
+ if (combinedSessions.length > 0) {
2028
+ combinedPreview = [
2029
+ 'deleted_sessions_preview:',
2030
+ ...combinedSessions.slice(0, limit).map((row) => this.formatRetentionSessionCandidate(row)),
2031
+ ];
2032
+ }
2033
+ else {
2034
+ combinedPreview = ['deleted_sessions_preview:', '- none'];
2035
+ }
2036
+ let deletedBlobPreview = [];
2037
+ if (initialOrphanBlobs.length > 0) {
2038
+ deletedBlobPreview = [
2039
+ 'deleted_blobs_preview:',
2040
+ ...initialOrphanBlobs
2041
+ .slice(0, limit)
2042
+ .map((row) => `- ${row.content_hash.slice(0, 16)} chars=${row.char_count} created_at=${row.created_at}`),
2043
+ ];
2044
+ }
2045
+ else {
2046
+ deletedBlobPreview = ['deleted_blobs_preview:', '- none'];
2047
+ }
2048
+ return [
2049
+ `deleted_sessions=${result.deletedSessions}`,
2050
+ `deleted_blobs=${result.deletedBlobs}`,
2051
+ `deleted_blob_chars=${result.deletedBlobChars}`,
2052
+ 'status=applied',
2053
+ ...combinedPreview,
2054
+ ...deletedBlobPreview,
2055
+ ].join('\n');
2056
+ }
2057
+ async exportSnapshot(input) {
2058
+ await this.prepareForRead();
2059
+ return exportStoreSnapshot({
2060
+ workspaceDirectory: this.workspaceDirectory,
2061
+ normalizeScope: this.normalizeScope.bind(this),
2062
+ resolveScopeSessionIDs: this.resolveScopeSessionIDs.bind(this),
2063
+ readScopedSessionRowsSync: this.readScopedSessionRowsSync.bind(this),
2064
+ readScopedMessageRowsSync: this.readScopedMessageRowsSync.bind(this),
2065
+ readScopedPartRowsSync: this.readScopedPartRowsSync.bind(this),
2066
+ readScopedResumeRowsSync: this.readScopedResumeRowsSync.bind(this),
2067
+ readScopedArtifactRowsSync: this.readScopedArtifactRowsSync.bind(this),
2068
+ readScopedArtifactBlobRowsSync: this.readScopedArtifactBlobRowsSync.bind(this),
2069
+ readScopedSummaryRowsSync: this.readScopedSummaryRowsSync.bind(this),
2070
+ readScopedSummaryEdgeRowsSync: this.readScopedSummaryEdgeRowsSync.bind(this),
2071
+ readScopedSummaryStateRowsSync: this.readScopedSummaryStateRowsSync.bind(this),
2072
+ }, input);
2073
+ }
2074
+ async importSnapshot(input) {
2075
+ await this.prepareForRead();
2076
+ return importStoreSnapshot({
2077
+ workspaceDirectory: this.workspaceDirectory,
2078
+ getDb: () => this.getDb(),
2079
+ clearSessionDataSync: this.clearSessionDataSync.bind(this),
2080
+ backfillArtifactBlobsSync: this.backfillArtifactBlobsSync.bind(this),
2081
+ refreshAllLineageSync: this.refreshAllLineageSync.bind(this),
2082
+ syncAllDerivedSessionStateSync: this.syncAllDerivedSessionStateSync.bind(this),
2083
+ refreshSearchIndexesSync: this.refreshSearchIndexesSync.bind(this),
2084
+ }, input);
2085
+ }
2086
+ async resume(sessionID) {
2087
+ await this.prepareForRead();
2088
+ const resolvedSessionID = sessionID ?? this.latestSessionIDSync();
2089
+ if (!resolvedSessionID)
2090
+ return 'No stored resume snapshots yet.';
2091
+ const existing = this.getResumeSync(resolvedSessionID);
2092
+ if (existing && !this.isManagedResumeNote(existing))
2093
+ return existing;
2094
+ const generated = await this.buildCompactionContext(resolvedSessionID);
2095
+ return generated ?? existing ?? 'No stored resume snapshot for that session.';
2096
+ }
2097
+ async expand(input) {
2098
+ await this.prepareForRead();
2099
+ const depth = clamp(input.depth ?? 1, 1, 4);
2100
+ const messageLimit = clamp(input.messageLimit ?? EXPAND_MESSAGE_LIMIT, 1, 20);
2101
+ const query = input.query?.trim();
2102
+ if (!input.nodeID) {
2103
+ const sessionID = input.sessionID ?? this.latestSessionIDSync();
2104
+ if (!sessionID)
2105
+ return 'No archived summary nodes yet.';
2106
+ const session = this.readSessionSync(sessionID);
2107
+ let roots = this.getSummaryRootsForSession(session);
2108
+ if (roots.length === 0)
2109
+ return 'No archived summary nodes yet.';
2110
+ if (query) {
2111
+ const matches = this.findExpandMatches(sessionID, query);
2112
+ roots = roots.filter((node) => this.nodeMatchesQuery(node, matches));
2113
+ if (roots.length === 0)
2114
+ return `No archived summary nodes matched "${query}".`;
2115
+ }
2116
+ return [
2117
+ `Session: ${sessionID}`,
2118
+ query
2119
+ ? `Archived summary roots matching "${query}": ${roots.length}`
2120
+ : `Archived summary roots: ${roots.length}`,
2121
+ 'Use lcm_expand with one of these node IDs for more detail:',
2122
+ ...roots.map((node) => `- ${node.nodeID} (messages ${node.startIndex + 1}-${node.endIndex + 1}, level ${node.level}): ${node.summaryText}`),
2123
+ ].join('\n');
2124
+ }
2125
+ const node = this.readSummaryNodeSync(input.nodeID);
2126
+ if (!node)
2127
+ return 'Unknown summary node.';
2128
+ const session = this.readSessionSync(node.sessionID);
2129
+ if (!query)
2130
+ return this.renderExpandedNode(session, node, depth, input.includeRaw ?? true, messageLimit);
2131
+ const matches = this.findExpandMatches(node.sessionID, query);
2132
+ if (!this.nodeMatchesQuery(node, matches)) {
2133
+ return `No descendants in ${node.nodeID} matched "${query}".`;
2134
+ }
2135
+ return this.renderTargetedExpansion(session, node, depth, input.includeRaw ?? true, messageLimit, query, matches);
2136
+ }
2137
+ async buildCompactionContext(sessionID) {
2138
+ await this.prepareForRead();
2139
+ const session = this.readSessionSync(sessionID);
2140
+ if (session.messages.length === 0)
2141
+ return undefined;
2142
+ const roots = this.getSummaryRootsForSession(session);
2143
+ const note = this.buildResumeNote(session, roots);
2144
+ this.getDb()
2145
+ .prepare(`INSERT INTO resumes (session_id, note, updated_at)
2146
+ VALUES (?, ?, ?)
2147
+ ON CONFLICT(session_id) DO UPDATE SET note = excluded.note, updated_at = excluded.updated_at`)
2148
+ .run(sessionID, note, Date.now());
2149
+ return note;
2150
+ }
2151
+ async transformMessages(messages) {
2152
+ await this.prepareForRead();
2153
+ if (messages.length < this.options.minMessagesForTransform)
2154
+ return false;
2155
+ const window = resolveArchiveTransformWindow(messages, this.options.freshTailMessages);
2156
+ if (!window)
2157
+ return false;
2158
+ const { anchor, archived, recent } = window;
2159
+ const roots = this.ensureSummaryGraphSync(anchor.info.sessionID, archived);
2160
+ if (roots.length === 0)
2161
+ return false;
2162
+ const summary = buildActiveSummaryText(roots, archived.length, this.options.summaryCharBudget);
2163
+ const retrieval = await this.buildAutomaticRetrievalContext(anchor.info.sessionID, recent, anchor);
2164
+ for (const message of archived) {
2165
+ this.compactMessageInPlace(message);
2166
+ }
2167
+ anchor.parts = anchor.parts.filter((part) => !isSyntheticLcmTextPart(part, ['archive-summary', 'retrieved-context']));
2168
+ const syntheticParts = [];
2169
+ if (retrieval) {
2170
+ syntheticParts.push({
2171
+ id: `lcm-memory-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
2172
+ sessionID: anchor.info.sessionID,
2173
+ messageID: anchor.info.id,
2174
+ type: 'text',
2175
+ text: retrieval,
2176
+ synthetic: true,
2177
+ metadata: { opencodeLcm: 'retrieved-context' },
2178
+ });
2179
+ }
2180
+ syntheticParts.push({
2181
+ id: `lcm-summary-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
2182
+ sessionID: anchor.info.sessionID,
2183
+ messageID: anchor.info.id,
2184
+ type: 'text',
2185
+ text: summary,
2186
+ synthetic: true,
2187
+ metadata: { opencodeLcm: 'archive-summary' },
2188
+ });
2189
+ anchor.parts.push(...syntheticParts);
2190
+ return true;
2191
+ }
2192
+ systemHint() {
2193
+ if (!this.options.systemHint)
2194
+ return undefined;
2195
+ return [
2196
+ 'Archived session state may exist outside the active prompt.',
2197
+ 'opencode-lcm may automatically recall archived context when it looks relevant to the current turn.',
2198
+ 'Use lcm_describe, lcm_grep, lcm_resume, lcm_expand, or lcm_artifact only when deeper archive inspection is still needed.',
2199
+ 'Keep ctx_* usage selective and treat those calls as infrastructure, not task intent.',
2200
+ ].join(' ');
2201
+ }
2202
+ async buildAutomaticRetrievalContext(sessionID, recent, anchor) {
2203
+ if (!this.options.automaticRetrieval.enabled)
2204
+ return undefined;
2205
+ const query = this.buildAutomaticRetrievalQuery(anchor, recent);
2206
+ if (!query)
2207
+ return undefined;
2208
+ const allowedHits = clamp(this.options.automaticRetrieval.maxMessageHits, 0, 4) +
2209
+ clamp(this.options.automaticRetrieval.maxSummaryHits, 0, 3) +
2210
+ clamp(this.options.automaticRetrieval.maxArtifactHits, 0, 3);
2211
+ if (allowedHits <= 0)
2212
+ return undefined;
2213
+ const targetHits = this.resolveAutomaticRetrievalTargetHits(allowedHits);
2214
+ const results = [];
2215
+ const seenResults = new Set();
2216
+ const searchedScopes = [];
2217
+ const scopeStats = [];
2218
+ let stopReason = 'scope-order-exhausted';
2219
+ let hits = this.selectAutomaticRetrievalHits(sessionID, recent, query.tokens, results);
2220
+ for (const scope of this.buildAutomaticRetrievalScopeOrder(sessionID)) {
2221
+ const budget = this.resolveAutomaticRetrievalScopeBudget(scope);
2222
+ if (budget <= 0) {
2223
+ scopeStats.push({ scope, budget, rawResults: 0, selectedHits: 0 });
2224
+ continue;
2225
+ }
2226
+ searchedScopes.push(scope);
2227
+ let scopeRawResults = 0;
2228
+ let scopeSelectedHits = 0;
2229
+ for (const candidateQuery of query.queries) {
2230
+ const remainingBudget = budget - scopeRawResults;
2231
+ if (remainingBudget <= 0)
2232
+ break;
2233
+ const previousHits = hits;
2234
+ const scopedResults = await this.grep({
2235
+ query: candidateQuery,
2236
+ sessionID,
2237
+ scope,
2238
+ limit: remainingBudget,
2239
+ });
2240
+ for (const result of scopedResults) {
2241
+ const key = `${result.type}:${result.id}`;
2242
+ if (seenResults.has(key))
2243
+ continue;
2244
+ seenResults.add(key);
2245
+ results.push(result);
2246
+ scopeRawResults += 1;
2247
+ }
2248
+ hits = this.selectAutomaticRetrievalHits(sessionID, recent, query.tokens, results);
2249
+ scopeSelectedHits += this.countNewAutomaticRetrievalHits(previousHits, hits);
2250
+ if (hits.length >= allowedHits) {
2251
+ stopReason = 'hit-quota-reached';
2252
+ break;
2253
+ }
2254
+ if (hits.length >= targetHits) {
2255
+ stopReason = 'target-hits-reached';
2256
+ break;
2257
+ }
2258
+ }
2259
+ scopeStats.push({
2260
+ scope,
2261
+ budget,
2262
+ rawResults: scopeRawResults,
2263
+ selectedHits: scopeSelectedHits,
2264
+ });
2265
+ if (hits.length > 0 &&
2266
+ this.options.automaticRetrieval.stop.stopOnFirstScopeWithHits &&
2267
+ scopeSelectedHits > 0) {
2268
+ stopReason = 'first-scope-hit';
2269
+ }
2270
+ if (stopReason !== 'scope-order-exhausted') {
2271
+ return renderAutomaticRetrievalContext(searchedScopes, hits, clamp(this.options.automaticRetrieval.maxChars, 240, 4000), {
2272
+ queries: query.queries,
2273
+ rawResults: results.length,
2274
+ stopReason,
2275
+ scopeStats,
2276
+ });
2277
+ }
2278
+ }
2279
+ if (hits.length === 0)
2280
+ return undefined;
2281
+ return renderAutomaticRetrievalContext(searchedScopes, hits, clamp(this.options.automaticRetrieval.maxChars, 240, 4000), {
2282
+ queries: query.queries,
2283
+ rawResults: results.length,
2284
+ stopReason,
2285
+ scopeStats,
2286
+ });
2287
+ }
2288
+ buildAutomaticRetrievalQuery(anchor, recent) {
2289
+ const minTokens = clamp(this.options.automaticRetrieval.minTokens, 1, AUTOMATIC_RETRIEVAL_QUERY_TOKENS);
2290
+ const tokens = [];
2291
+ const anchorText = sanitizeAutomaticRetrievalSourceText(guessMessageText(anchor, this.options.interop.ignoreToolPrefixes));
2292
+ const anchorFiles = listFiles(anchor);
2293
+ const pushTokens = (value) => {
2294
+ if (!value || tokens.length >= AUTOMATIC_RETRIEVAL_QUERY_TOKENS)
2295
+ return;
2296
+ const sanitized = sanitizeAutomaticRetrievalSourceText(value);
2297
+ if (!sanitized)
2298
+ return;
2299
+ for (const token of filterIntentTokens(tokenizeQuery(sanitized))) {
2300
+ if (tokens.includes(token))
2301
+ continue;
2302
+ tokens.push(token);
2303
+ if (tokens.length >= AUTOMATIC_RETRIEVAL_QUERY_TOKENS)
2304
+ break;
2305
+ }
2306
+ };
2307
+ pushTokens(anchorText);
2308
+ for (const file of anchorFiles)
2309
+ pushTokens(path.basename(file));
2310
+ const anchorSignalCount = tokens.length;
2311
+ if (anchorSignalCount === 0)
2312
+ return undefined;
2313
+ if (shouldSuppressLowSignalAutomaticRetrievalAnchor(anchorText, anchorSignalCount, minTokens, anchorFiles.length)) {
2314
+ return undefined;
2315
+ }
2316
+ for (const message of recent.slice(-AUTOMATIC_RETRIEVAL_RECENT_MESSAGES)) {
2317
+ for (const file of listFiles(message))
2318
+ pushTokens(path.basename(file));
2319
+ }
2320
+ const recentUsers = recent
2321
+ .filter((message) => message.info.role === 'user' && message.info.id !== anchor.info.id)
2322
+ .slice(-AUTOMATIC_RETRIEVAL_RECENT_MESSAGES)
2323
+ .reverse();
2324
+ for (const message of recentUsers) {
2325
+ if (tokens.length >= minTokens)
2326
+ break;
2327
+ pushTokens(guessMessageText(message, this.options.interop.ignoreToolPrefixes));
2328
+ }
2329
+ if (tokens.length < minTokens)
2330
+ return undefined;
2331
+ const queryTokens = tokens.slice(0, 5);
2332
+ // Apply TF-IDF weighting to filter corpus-common noise tokens
2333
+ const weightedTokens = filterTokensByTfidf(this.getDb(), queryTokens, {
2334
+ minTokens,
2335
+ });
2336
+ return {
2337
+ queries: this.buildAutomaticRetrievalQueries(weightedTokens, minTokens),
2338
+ tokens: weightedTokens,
2339
+ };
2340
+ }
2341
+ buildAutomaticRetrievalQueries(tokens, minTokens) {
2342
+ const queries = [];
2343
+ const pushQuery = (parts) => {
2344
+ const normalized = parts.filter(Boolean);
2345
+ if (normalized.length < minTokens)
2346
+ return;
2347
+ const value = normalized.join(' ');
2348
+ if (!queries.includes(value))
2349
+ queries.push(value);
2350
+ };
2351
+ // Full token set (descending window from front)
2352
+ for (let size = Math.min(tokens.length, 4); size >= minTokens; size -= 1) {
2353
+ pushQuery(tokens.slice(0, size));
2354
+ if (queries.length >= AUTOMATIC_RETRIEVAL_QUERY_VARIANTS)
2355
+ return queries;
2356
+ }
2357
+ // Sliding windows starting later in the token list
2358
+ for (let size = Math.min(tokens.length, 4); size >= Math.max(2, minTokens); size -= 1) {
2359
+ for (let start = 1; start + size <= tokens.length; start += 1) {
2360
+ pushQuery(tokens.slice(start, start + size));
2361
+ if (queries.length >= AUTOMATIC_RETRIEVAL_QUERY_VARIANTS)
2362
+ return queries;
2363
+ }
2364
+ }
2365
+ // Adjacent bigram phrases — FTS NEAR/phrase queries rank adjacency higher
2366
+ if (tokens.length >= 2) {
2367
+ for (let i = 0; i < tokens.length - 1; i += 1) {
2368
+ const phrase = `"${tokens[i]} ${tokens[i + 1]}"`;
2369
+ if (!queries.includes(phrase))
2370
+ queries.push(phrase);
2371
+ if (queries.length >= AUTOMATIC_RETRIEVAL_QUERY_VARIANTS)
2372
+ return queries;
2373
+ }
2374
+ }
2375
+ // Skip-gram triples for longer token lists
2376
+ if (tokens.length >= 5) {
2377
+ pushQuery([tokens[0], tokens[1], tokens[4]]);
2378
+ pushQuery([tokens[0], tokens[2], tokens[4]]);
2379
+ }
2380
+ return queries.slice(0, AUTOMATIC_RETRIEVAL_QUERY_VARIANTS);
2381
+ }
2382
+ buildAutomaticRetrievalScopeOrder(sessionID) {
2383
+ const configured = this.resolveConfiguredScope('grep', undefined, sessionID);
2384
+ const candidates = [...this.options.automaticRetrieval.scopeOrder];
2385
+ if (configured === 'all' && !candidates.includes('all')) {
2386
+ candidates.push('all');
2387
+ }
2388
+ const ordered = [];
2389
+ const seenScopes = new Set();
2390
+ for (const scope of candidates) {
2391
+ const sessionIDs = this.resolveScopeSessionIDs(scope, sessionID);
2392
+ const key = sessionIDs ? [...sessionIDs].sort().join(',') : 'all';
2393
+ if (seenScopes.has(key))
2394
+ continue;
2395
+ seenScopes.add(key);
2396
+ ordered.push(scope);
2397
+ }
2398
+ return ordered;
2399
+ }
2400
+ resolveAutomaticRetrievalScopeBudget(scope) {
2401
+ return clamp(this.options.automaticRetrieval.scopeBudgets[scope], 0, 24);
2402
+ }
2403
+ resolveAutomaticRetrievalTargetHits(allowedHits) {
2404
+ return clamp(this.options.automaticRetrieval.stop.targetHits, 1, allowedHits);
2405
+ }
2406
+ countNewAutomaticRetrievalHits(before, after) {
2407
+ const seen = new Set(before.map((hit) => `${hit.kind}:${hit.id}`));
2408
+ return after.filter((hit) => !seen.has(`${hit.kind}:${hit.id}`)).length;
2409
+ }
2410
+ selectAutomaticRetrievalHits(sessionID, recent, tokens, results) {
2411
+ const filteredResults = results.filter((result) => !this.isAutomaticRetrievalNoiseResult(result));
2412
+ return selectAutomaticRetrievalHits({
2413
+ recent,
2414
+ tokens,
2415
+ results: filteredResults,
2416
+ quotas: {
2417
+ message: clamp(this.options.automaticRetrieval.maxMessageHits, 0, 4),
2418
+ summary: clamp(this.options.automaticRetrieval.maxSummaryHits, 0, 3),
2419
+ artifact: clamp(this.options.automaticRetrieval.maxArtifactHits, 0, 3),
2420
+ },
2421
+ isFreshResult: (result, freshMessageIDs) => this.isFreshAutomaticRetrievalResult(sessionID, freshMessageIDs, result),
2422
+ });
2423
+ }
2424
+ isAutomaticRetrievalNoiseResult(result) {
2425
+ return isAutomaticRetrievalNoise(result.snippet);
2426
+ }
2427
+ isFreshAutomaticRetrievalResult(sessionID, freshMessageIDs, result) {
2428
+ if (result.sessionID !== sessionID)
2429
+ return false;
2430
+ if (result.type === 'summary')
2431
+ return false;
2432
+ if (result.type.startsWith('artifact:')) {
2433
+ const artifact = this.readArtifactSync(result.id);
2434
+ return artifact ? freshMessageIDs.has(artifact.messageID) : false;
2435
+ }
2436
+ return freshMessageIDs.has(result.id);
2437
+ }
2438
+ buildResumeNote(session, roots) {
2439
+ const files = [...new Set(session.messages.flatMap(listFiles))].slice(0, 10);
2440
+ const recent = session.messages
2441
+ .slice(-4)
2442
+ .map((message) => `- ${message.info.role}: ${truncate(guessMessageText(message, this.options.interop.ignoreToolPrefixes), 160)}`)
2443
+ .filter((line) => !line.endsWith(': '));
2444
+ return truncate([
2445
+ 'LCM prototype resume note',
2446
+ `Session: ${session.sessionID}`,
2447
+ `Title: ${makeSessionTitle(session) ?? 'Unknown'}`,
2448
+ `Root session: ${session.rootSessionID ?? session.sessionID}`,
2449
+ `Parent session: ${session.parentSessionID ?? 'none'}`,
2450
+ `Lineage depth: ${session.lineageDepth ?? 0}`,
2451
+ `Archived messages: ${Math.max(0, session.messages.length - this.options.freshTailMessages)}`,
2452
+ ...(roots.length > 0
2453
+ ? [
2454
+ 'Summary roots:',
2455
+ ...roots
2456
+ .slice(0, 4)
2457
+ .map((node) => `- ${node.nodeID}: ${truncate(node.summaryText, 160)}`),
2458
+ ]
2459
+ : []),
2460
+ ...(files.length > 0 ? [`Files touched: ${files.join(', ')}`] : []),
2461
+ ...(recent.length > 0 ? ['Recent archived activity:', ...recent] : []),
2462
+ 'Keep context-mode in charge of routing and sandbox tools.',
2463
+ 'Use lcm_describe, lcm_grep, lcm_resume, lcm_expand, or lcm_artifact for archived details.',
2464
+ ].join('\n'), this.options.compactContextLimit);
2465
+ }
2466
+ compactMessageInPlace(message) {
2467
+ for (const part of message.parts) {
2468
+ switch (part.type) {
2469
+ case 'text':
2470
+ if (part.metadata?.opencodeLcm === 'archive-summary')
2471
+ break;
2472
+ part.text = archivePlaceholder('older text elided');
2473
+ break;
2474
+ case 'reasoning':
2475
+ part.text = archivePlaceholder('reasoning omitted');
2476
+ break;
2477
+ case 'tool': {
2478
+ if (part.state.status === 'completed') {
2479
+ const label = this.shouldIgnoreTool(part.tool)
2480
+ ? 'infrastructure tool output omitted'
2481
+ : `tool output for ${part.tool} omitted`;
2482
+ part.state.output = archivePlaceholder(label);
2483
+ part.state.attachments = undefined;
2484
+ }
2485
+ if (part.state.status === 'error') {
2486
+ part.state.error = archivePlaceholder(`error output for ${part.tool} omitted`);
2487
+ }
2488
+ break;
2489
+ }
2490
+ case 'file':
2491
+ if (part.source?.text) {
2492
+ part.source.text.value = archivePlaceholder(part.source.path ?? part.filename ?? 'file contents omitted');
2493
+ part.source.text.start = 0;
2494
+ part.source.text.end = part.source.text.value.length;
2495
+ }
2496
+ break;
2497
+ case 'snapshot':
2498
+ part.snapshot = archivePlaceholder('snapshot omitted');
2499
+ break;
2500
+ case 'agent':
2501
+ if (part.source) {
2502
+ part.source.value = archivePlaceholder(`agent source for ${part.name} omitted`);
2503
+ part.source.start = 0;
2504
+ part.source.end = part.source.value.length;
2505
+ }
2506
+ break;
2507
+ case 'patch':
2508
+ part.files = part.files.slice(0, 8);
2509
+ break;
2510
+ case 'subtask':
2511
+ part.prompt = truncate(part.prompt, this.options.partCharBudget);
2512
+ part.description = truncate(part.description, this.options.partCharBudget);
2513
+ break;
2514
+ default:
2515
+ break;
2516
+ }
2517
+ }
2518
+ }
2519
+ shouldIgnoreTool(toolName) {
2520
+ return this.options.interop.ignoreToolPrefixes.some((prefix) => toolName.startsWith(prefix));
2521
+ }
2522
+ summarizeMessages(messages, limit = SUMMARY_NODE_CHAR_LIMIT) {
2523
+ const goals = messages
2524
+ .filter((message) => message.info.role === 'user')
2525
+ .map((message) => guessMessageText(message, this.options.interop.ignoreToolPrefixes))
2526
+ .filter(Boolean)
2527
+ .slice(0, 2)
2528
+ .map((text) => truncate(text, 90));
2529
+ const work = messages
2530
+ .filter((message) => message.info.role === 'assistant')
2531
+ .map((message) => guessMessageText(message, this.options.interop.ignoreToolPrefixes))
2532
+ .filter(Boolean)
2533
+ .slice(-2)
2534
+ .map((text) => truncate(text, 90));
2535
+ const files = [...new Set(messages.flatMap(listFiles))].slice(0, 4);
2536
+ const tools = [...new Set(this.listTools(messages))].slice(0, 4);
2537
+ const segments = [
2538
+ goals.length > 0 ? `Goals: ${goals.join(' | ')}` : '',
2539
+ work.length > 0 ? `Work: ${work.join(' | ')}` : '',
2540
+ files.length > 0 ? `Files: ${files.join(', ')}` : '',
2541
+ tools.length > 0 ? `Tools: ${tools.join(', ')}` : '',
2542
+ ].filter(Boolean);
2543
+ if (segments.length === 0)
2544
+ return truncate(`Archived messages ${messages.length}`, limit);
2545
+ return truncate(segments.join(' || '), limit);
2546
+ }
2547
+ listTools(messages) {
2548
+ const tools = [];
2549
+ for (const message of messages) {
2550
+ for (const part of message.parts) {
2551
+ if (part.type !== 'tool')
2552
+ continue;
2553
+ if (this.shouldIgnoreTool(part.tool))
2554
+ continue;
2555
+ tools.push(part.tool);
2556
+ }
2557
+ }
2558
+ return tools;
2559
+ }
2560
+ buildArchivedSignature(messages) {
2561
+ const hash = createHash('sha256');
2562
+ for (const message of messages) {
2563
+ hash.update(message.info.id);
2564
+ hash.update(message.info.role);
2565
+ hash.update(String(message.info.time.created));
2566
+ hash.update(guessMessageText(message, this.options.interop.ignoreToolPrefixes));
2567
+ hash.update(JSON.stringify(listFiles(message)));
2568
+ hash.update(JSON.stringify(this.listTools([message])));
2569
+ hash.update(String(message.parts.length));
2570
+ }
2571
+ return hash.digest('hex');
2572
+ }
2573
+ getArchivedMessages(messages) {
2574
+ const window = resolveArchiveTransformWindow(messages, this.options.freshTailMessages);
2575
+ if (window)
2576
+ return window.archived;
2577
+ const archivedCount = Math.max(0, messages.length - this.options.freshTailMessages);
2578
+ return messages.slice(0, archivedCount);
2579
+ }
2580
+ getSummaryRootsForSession(session) {
2581
+ const archived = this.getArchivedMessages(session.messages);
2582
+ return this.ensureSummaryGraphSync(session.sessionID, archived);
2583
+ }
2584
+ ensureSummaryGraphSync(sessionID, archivedMessages) {
2585
+ if (archivedMessages.length === 0) {
2586
+ this.clearSummaryGraphSync(sessionID);
2587
+ return [];
2588
+ }
2589
+ const latestMessageCreated = archivedMessages.at(-1)?.info.time.created ?? 0;
2590
+ const archivedSignature = this.buildArchivedSignature(archivedMessages);
2591
+ const state = safeQueryOne(this.getDb().prepare('SELECT * FROM summary_state WHERE session_id = ?'), [sessionID], 'ensureSummaryGraphSync');
2592
+ if (state &&
2593
+ state.archived_count === archivedMessages.length &&
2594
+ state.latest_message_created === latestMessageCreated &&
2595
+ state.archived_signature === archivedSignature) {
2596
+ const rootIDs = parseJson(state.root_node_ids_json);
2597
+ const roots = rootIDs
2598
+ .map((nodeID) => this.readSummaryNodeSync(nodeID))
2599
+ .filter((node) => Boolean(node));
2600
+ if (rootIDs.length > 0 &&
2601
+ roots.length === rootIDs.length &&
2602
+ this.canReuseSummaryGraphSync(sessionID, archivedMessages, roots)) {
2603
+ return roots;
2604
+ }
2605
+ }
2606
+ return this.rebuildSummaryGraphSync(sessionID, archivedMessages, archivedSignature);
2607
+ }
2608
+ canReuseSummaryGraphSync(sessionID, archivedMessages, roots) {
2609
+ if (roots.length === 0)
2610
+ return false;
2611
+ const expectedMessageIDs = archivedMessages.map((message) => message.info.id);
2612
+ const seen = new Set();
2613
+ const validateNode = (node, expectedSlot) => {
2614
+ if (node.sessionID !== sessionID)
2615
+ return false;
2616
+ if (node.nodeID !== buildSummaryNodeID(sessionID, node.level, expectedSlot))
2617
+ return false;
2618
+ if (seen.has(node.nodeID))
2619
+ return false;
2620
+ seen.add(node.nodeID);
2621
+ if (node.startIndex < 0 ||
2622
+ node.endIndex < node.startIndex ||
2623
+ node.endIndex >= expectedMessageIDs.length) {
2624
+ return false;
2625
+ }
2626
+ const expectedNodeMessageIDs = expectedMessageIDs.slice(node.startIndex, node.endIndex + 1);
2627
+ if (node.messageIDs.length !== expectedNodeMessageIDs.length)
2628
+ return false;
2629
+ for (let index = 0; index < expectedNodeMessageIDs.length; index += 1) {
2630
+ if (node.messageIDs[index] !== expectedNodeMessageIDs[index])
2631
+ return false;
2632
+ }
2633
+ const expectedSummaryText = this.summarizeMessages(archivedMessages.slice(node.startIndex, node.endIndex + 1));
2634
+ if (node.summaryText !== expectedSummaryText)
2635
+ return false;
2636
+ const children = this.readSummaryChildrenSync(node.nodeID);
2637
+ if (node.nodeKind === 'leaf') {
2638
+ return (children.length === 0 && node.endIndex - node.startIndex + 1 <= SUMMARY_LEAF_MESSAGES);
2639
+ }
2640
+ if (children.length === 0 || children.length > SUMMARY_BRANCH_FACTOR)
2641
+ return false;
2642
+ if (children[0]?.startIndex !== node.startIndex)
2643
+ return false;
2644
+ if (children.at(-1)?.endIndex !== node.endIndex)
2645
+ return false;
2646
+ let nextStartIndex = node.startIndex;
2647
+ for (const [childPosition, child] of children.entries()) {
2648
+ if (child.level !== node.level - 1)
2649
+ return false;
2650
+ if (child.startIndex !== nextStartIndex)
2651
+ return false;
2652
+ if (!validateNode(child, expectedSlot * SUMMARY_BRANCH_FACTOR + childPosition))
2653
+ return false;
2654
+ nextStartIndex = child.endIndex + 1;
2655
+ }
2656
+ return nextStartIndex === node.endIndex + 1;
2657
+ };
2658
+ let nextStartIndex = 0;
2659
+ for (const [rootSlot, root] of roots.entries()) {
2660
+ if (root.startIndex !== nextStartIndex)
2661
+ return false;
2662
+ if (!validateNode(root, rootSlot))
2663
+ return false;
2664
+ nextStartIndex = root.endIndex + 1;
2665
+ }
2666
+ return nextStartIndex === expectedMessageIDs.length;
2667
+ }
2668
+ rebuildSummaryGraphSync(sessionID, archivedMessages, archivedSignature) {
2669
+ const now = Date.now();
2670
+ let level = 0;
2671
+ const nodes = [];
2672
+ const edges = [];
2673
+ const makeNode = (input) => ({
2674
+ nodeID: buildSummaryNodeID(sessionID, input.level, input.slot),
2675
+ sessionID,
2676
+ level: input.level,
2677
+ nodeKind: input.nodeKind,
2678
+ startIndex: input.startIndex,
2679
+ endIndex: input.endIndex,
2680
+ messageIDs: input.messageIDs,
2681
+ summaryText: input.summaryText,
2682
+ createdAt: now,
2683
+ });
2684
+ let currentLevel = [];
2685
+ for (let start = 0, slot = 0; start < archivedMessages.length; start += SUMMARY_LEAF_MESSAGES, slot += 1) {
2686
+ const chunk = archivedMessages.slice(start, start + SUMMARY_LEAF_MESSAGES);
2687
+ const node = makeNode({
2688
+ nodeKind: 'leaf',
2689
+ startIndex: start,
2690
+ endIndex: start + chunk.length - 1,
2691
+ messageIDs: chunk.map((message) => message.info.id),
2692
+ summaryText: this.summarizeMessages(chunk),
2693
+ level,
2694
+ slot,
2695
+ });
2696
+ nodes.push(node);
2697
+ currentLevel.push(node);
2698
+ }
2699
+ while (currentLevel.length > 1) {
2700
+ level += 1;
2701
+ const nextLevel = [];
2702
+ for (let index = 0; index < currentLevel.length; index += SUMMARY_BRANCH_FACTOR) {
2703
+ const children = currentLevel.slice(index, index + SUMMARY_BRANCH_FACTOR);
2704
+ const startIndex = children[0].startIndex;
2705
+ const endIndex = children.at(-1)?.endIndex ?? startIndex;
2706
+ const covered = archivedMessages.slice(startIndex, endIndex + 1);
2707
+ const node = makeNode({
2708
+ nodeKind: 'internal',
2709
+ startIndex,
2710
+ endIndex,
2711
+ messageIDs: covered.map((message) => message.info.id),
2712
+ summaryText: this.summarizeMessages(covered),
2713
+ level,
2714
+ slot: nextLevel.length,
2715
+ });
2716
+ nodes.push(node);
2717
+ nextLevel.push(node);
2718
+ children.forEach((child, childPosition) => {
2719
+ edges.push({
2720
+ sessionID,
2721
+ parentID: node.nodeID,
2722
+ childID: child.nodeID,
2723
+ childPosition,
2724
+ });
2725
+ });
2726
+ }
2727
+ currentLevel = nextLevel;
2728
+ }
2729
+ const roots = currentLevel;
2730
+ const db = this.getDb();
2731
+ withTransaction(db, 'rebuildSummaryGraph', () => {
2732
+ this.clearSummaryGraphSync(sessionID);
2733
+ const insertNode = db.prepare(`INSERT INTO summary_nodes
2734
+ (node_id, session_id, level, node_kind, start_index, end_index, message_ids_json, summary_text, created_at)
2735
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
2736
+ const insertEdge = db.prepare(`INSERT INTO summary_edges (session_id, parent_id, child_id, child_position)
2737
+ VALUES (?, ?, ?, ?)`);
2738
+ const insertSummaryFts = db.prepare('INSERT INTO summary_fts (session_id, node_id, level, created_at, content) VALUES (?, ?, ?, ?, ?)');
2739
+ for (const node of nodes) {
2740
+ insertNode.run(node.nodeID, node.sessionID, node.level, node.nodeKind, node.startIndex, node.endIndex, JSON.stringify(node.messageIDs), node.summaryText, node.createdAt);
2741
+ insertSummaryFts.run(node.sessionID, node.nodeID, String(node.level), String(node.createdAt), node.summaryText);
2742
+ }
2743
+ for (const edge of edges) {
2744
+ insertEdge.run(edge.sessionID, edge.parentID, edge.childID, edge.childPosition);
2745
+ }
2746
+ db.prepare(`INSERT INTO summary_state (session_id, archived_count, latest_message_created, archived_signature, root_node_ids_json, updated_at)
2747
+ VALUES (?, ?, ?, ?, ?, ?)
2748
+ ON CONFLICT(session_id) DO UPDATE SET
2749
+ archived_count = excluded.archived_count,
2750
+ latest_message_created = excluded.latest_message_created,
2751
+ archived_signature = excluded.archived_signature,
2752
+ root_node_ids_json = excluded.root_node_ids_json,
2753
+ updated_at = excluded.updated_at`).run(sessionID, archivedMessages.length, archivedMessages.at(-1)?.info.time.created ?? 0, archivedSignature, JSON.stringify(roots.map((node) => node.nodeID)), now);
2754
+ });
2755
+ return roots;
2756
+ }
2757
+ readSummaryNodeSync(nodeID) {
2758
+ const row = safeQueryOne(this.getDb().prepare('SELECT * FROM summary_nodes WHERE node_id = ?'), [nodeID], 'readSummaryNodeSync');
2759
+ if (!row)
2760
+ return undefined;
2761
+ return {
2762
+ nodeID: row.node_id,
2763
+ sessionID: row.session_id,
2764
+ level: row.level,
2765
+ nodeKind: row.node_kind === 'leaf' ? 'leaf' : 'internal',
2766
+ startIndex: row.start_index,
2767
+ endIndex: row.end_index,
2768
+ messageIDs: parseJson(row.message_ids_json),
2769
+ summaryText: row.summary_text,
2770
+ createdAt: row.created_at,
2771
+ };
2772
+ }
2773
+ readSummaryChildrenSync(nodeID) {
2774
+ const rows = this.getDb()
2775
+ .prepare(`SELECT e.parent_id, e.child_id, e.child_position
2776
+ FROM summary_edges e
2777
+ WHERE e.parent_id = ?
2778
+ ORDER BY e.child_position ASC`)
2779
+ .all(nodeID);
2780
+ return rows
2781
+ .map((row) => this.readSummaryNodeSync(row.child_id))
2782
+ .filter((node) => Boolean(node));
2783
+ }
2784
+ readArtifactBlobSync(contentHash) {
2785
+ if (!contentHash)
2786
+ return undefined;
2787
+ return safeQueryOne(this.getDb().prepare('SELECT * FROM artifact_blobs WHERE content_hash = ?'), [contentHash], 'readArtifactBlobSync');
2788
+ }
2789
+ materializeArtifactRow(row) {
2790
+ return materializeArtifactRowModule(this.artifactDeps(), row);
2791
+ }
2792
+ readArtifactSync(artifactID) {
2793
+ const row = safeQueryOne(this.getDb().prepare('SELECT * FROM artifacts WHERE artifact_id = ?'), [artifactID], 'readArtifactSync');
2794
+ if (!row)
2795
+ return undefined;
2796
+ return this.materializeArtifactRow(row);
2797
+ }
2798
+ readArtifactsForSessionSync(sessionID) {
2799
+ const rows = this.getDb()
2800
+ .prepare('SELECT * FROM artifacts WHERE session_id = ? ORDER BY created_at ASC, artifact_id ASC')
2801
+ .all(sessionID);
2802
+ return rows.map((row) => this.materializeArtifactRow(row));
2803
+ }
2804
+ readArtifactsForMessageSync(messageID) {
2805
+ const rows = this.getDb()
2806
+ .prepare('SELECT * FROM artifacts WHERE message_id = ? ORDER BY created_at ASC, artifact_id ASC')
2807
+ .all(messageID);
2808
+ return rows.map((row) => this.materializeArtifactRow(row));
2809
+ }
2810
+ findExpandMatches(sessionID, query) {
2811
+ const messageIDs = new Set();
2812
+ const nodeIDs = new Set();
2813
+ const artifactIDs = new Set();
2814
+ const ftsQuery = this.buildFtsQuery(query);
2815
+ const db = this.getDb();
2816
+ if (ftsQuery) {
2817
+ try {
2818
+ const messageRows = db
2819
+ .prepare('SELECT message_id FROM message_fts WHERE session_id = ? AND message_fts MATCH ? LIMIT 200')
2820
+ .all(sessionID, ftsQuery);
2821
+ for (const row of messageRows)
2822
+ messageIDs.add(row.message_id);
2823
+ const nodeRows = db
2824
+ .prepare('SELECT node_id FROM summary_fts WHERE session_id = ? AND summary_fts MATCH ? LIMIT 200')
2825
+ .all(sessionID, ftsQuery);
2826
+ for (const row of nodeRows)
2827
+ nodeIDs.add(row.node_id);
2828
+ const artifactRows = db
2829
+ .prepare('SELECT artifact_id, message_id FROM artifact_fts WHERE session_id = ? AND artifact_fts MATCH ? LIMIT 200')
2830
+ .all(sessionID, ftsQuery);
2831
+ for (const row of artifactRows) {
2832
+ artifactIDs.add(row.artifact_id);
2833
+ messageIDs.add(row.message_id);
2834
+ }
2835
+ }
2836
+ catch (error) {
2837
+ getLogger().debug('FTS query failed, falling back to scan', { query, error });
2838
+ }
2839
+ }
2840
+ if (messageIDs.size === 0 && nodeIDs.size === 0 && artifactIDs.size === 0) {
2841
+ const lower = query.toLowerCase();
2842
+ const session = this.readSessionSync(sessionID);
2843
+ for (const message of session.messages) {
2844
+ const text = guessMessageText(message, this.options.interop.ignoreToolPrefixes).toLowerCase();
2845
+ if (text.includes(lower))
2846
+ messageIDs.add(message.info.id);
2847
+ }
2848
+ for (const artifact of this.readArtifactsForSessionSync(sessionID)) {
2849
+ if (`${artifact.previewText}\n${artifact.contentText}`.toLowerCase().includes(lower)) {
2850
+ artifactIDs.add(artifact.artifactID);
2851
+ messageIDs.add(artifact.messageID);
2852
+ }
2853
+ }
2854
+ const summaryRows = db
2855
+ .prepare('SELECT node_id, summary_text FROM summary_nodes WHERE session_id = ? ORDER BY created_at ASC')
2856
+ .all(sessionID);
2857
+ for (const row of summaryRows) {
2858
+ if (row.summary_text.toLowerCase().includes(lower))
2859
+ nodeIDs.add(row.node_id);
2860
+ }
2861
+ }
2862
+ return { messageIDs, nodeIDs, artifactIDs };
2863
+ }
2864
+ nodeMatchesQuery(node, matches) {
2865
+ if (matches.nodeIDs.has(node.nodeID))
2866
+ return true;
2867
+ if (node.messageIDs.some((messageID) => matches.messageIDs.has(messageID)))
2868
+ return true;
2869
+ return this.readSummaryChildrenSync(node.nodeID).some((child) => this.nodeMatchesQuery(child, matches));
2870
+ }
2871
+ renderRawMessagesForNode(session, node, messageLimit, matches, indent = '') {
2872
+ const byID = new Map(session.messages.map((message) => [message.info.id, message]));
2873
+ const allCovered = node.messageIDs
2874
+ .map((messageID) => byID.get(messageID))
2875
+ .filter((message) => Boolean(message));
2876
+ const filteredCovered = matches && matches.messageIDs.size > 0
2877
+ ? allCovered.filter((message) => matches.messageIDs.has(message.info.id))
2878
+ : allCovered;
2879
+ const covered = (filteredCovered.length > 0 ? filteredCovered : allCovered).slice(0, messageLimit);
2880
+ if (covered.length === 0)
2881
+ return [];
2882
+ const lines = [`${indent}Raw messages:`];
2883
+ for (const message of covered) {
2884
+ const snippet = guessMessageText(message, this.options.interop.ignoreToolPrefixes) || '(no text content)';
2885
+ lines.push(`${indent}- ${message.info.role} ${message.info.id}: ${truncate(snippet, 220)}`);
2886
+ const artifacts = this.readArtifactsForMessageSync(message.info.id);
2887
+ const shownArtifacts = matches && matches.artifactIDs.size > 0
2888
+ ? artifacts.filter((artifact) => matches.artifactIDs.has(artifact.artifactID))
2889
+ : artifacts;
2890
+ for (const artifact of shownArtifacts.slice(0, 4)) {
2891
+ lines.push(`${indent} artifact ${artifact.artifactID} ${artifact.artifactKind}/${artifact.fieldName} (${artifact.charCount} chars): ${truncate(artifact.previewText, 120)}`);
2892
+ }
2893
+ if (shownArtifacts.length > 4) {
2894
+ lines.push(`${indent} ... ${shownArtifacts.length - 4} more artifact(s)`);
2895
+ }
2896
+ }
2897
+ if ((filteredCovered.length > 0 ? filteredCovered.length : allCovered.length) > covered.length) {
2898
+ lines.push(`${indent}- ... ${(filteredCovered.length > 0 ? filteredCovered.length : allCovered.length) - covered.length} more message(s)`);
2899
+ }
2900
+ return lines;
2901
+ }
2902
+ collectTargetedNodeLines(session, node, depth, includeRaw, messageLimit, matches, indent = '') {
2903
+ const lines = [
2904
+ `${indent}- ${node.nodeID} (level ${node.level}, messages ${node.startIndex + 1}-${node.endIndex + 1}): ${truncate(node.summaryText, 180)}`,
2905
+ ];
2906
+ const children = this.readSummaryChildrenSync(node.nodeID).filter((child) => this.nodeMatchesQuery(child, matches));
2907
+ if (children.length > 0 && depth > 0) {
2908
+ for (const child of children) {
2909
+ lines.push(...this.collectTargetedNodeLines(session, child, depth - 1, includeRaw, messageLimit, matches, `${indent} `));
2910
+ }
2911
+ return lines;
2912
+ }
2913
+ if (includeRaw) {
2914
+ lines.push(...this.renderRawMessagesForNode(session, node, messageLimit, matches, `${indent} `));
2915
+ }
2916
+ return lines;
2917
+ }
2918
+ renderTargetedExpansion(session, node, depth, includeRaw, messageLimit, query, matches) {
2919
+ const lines = [
2920
+ `Node: ${node.nodeID}`,
2921
+ `Session: ${node.sessionID}`,
2922
+ `Query: ${query}`,
2923
+ `Level: ${node.level}`,
2924
+ `Coverage: archived messages ${node.startIndex + 1}-${node.endIndex + 1}`,
2925
+ `Summary: ${node.summaryText}`,
2926
+ 'Targeted descendants:',
2927
+ ];
2928
+ const children = this.readSummaryChildrenSync(node.nodeID).filter((child) => this.nodeMatchesQuery(child, matches));
2929
+ if (children.length > 0) {
2930
+ for (const child of children) {
2931
+ lines.push(...this.collectTargetedNodeLines(session, child, depth - 1, includeRaw, messageLimit, matches, ''));
2932
+ }
2933
+ return lines.join('\n');
2934
+ }
2935
+ lines.push(...this.renderRawMessagesForNode(session, node, messageLimit, matches));
2936
+ return lines.join('\n');
2937
+ }
2938
+ renderExpandedNode(session, node, depth, includeRaw, messageLimit) {
2939
+ const children = this.readSummaryChildrenSync(node.nodeID);
2940
+ const lines = [
2941
+ `Node: ${node.nodeID}`,
2942
+ `Session: ${node.sessionID}`,
2943
+ `Level: ${node.level}`,
2944
+ `Coverage: archived messages ${node.startIndex + 1}-${node.endIndex + 1}`,
2945
+ `Summary: ${node.summaryText}`,
2946
+ ];
2947
+ if (children.length > 0) {
2948
+ lines.push('Children:');
2949
+ for (const child of children) {
2950
+ lines.push(`- ${child.nodeID}: ${truncate(child.summaryText, 180)}`);
2951
+ }
2952
+ if (depth > 1) {
2953
+ lines.push('Deeper descendants:');
2954
+ for (const child of children) {
2955
+ const grandChildren = this.readSummaryChildrenSync(child.nodeID);
2956
+ for (const grandChild of grandChildren.slice(0, SUMMARY_BRANCH_FACTOR)) {
2957
+ lines.push(`- ${child.nodeID} -> ${grandChild.nodeID}: ${truncate(grandChild.summaryText, 160)}`);
2958
+ }
2959
+ }
2960
+ }
2961
+ return lines.join('\n');
2962
+ }
2963
+ if (!includeRaw)
2964
+ return lines.join('\n');
2965
+ lines.push(...this.renderRawMessagesForNode(session, node, messageLimit));
2966
+ return lines.join('\n');
2967
+ }
2968
+ buildFtsQuery(query) {
2969
+ return buildFtsQuery(query);
2970
+ }
2971
+ searchDeps() {
2972
+ return {
2973
+ getDb: () => this.getDb(),
2974
+ readScopedSessionsSync: (sessionIDs) => this.readScopedSessionsSync(sessionIDs),
2975
+ readScopedSummaryRowsSync: (sessionIDs) => this.readScopedSummaryRowsSync(sessionIDs),
2976
+ readScopedArtifactRowsSync: (sessionIDs) => this.readScopedArtifactRowsSync(sessionIDs),
2977
+ buildArtifactSearchContent: (row) => this.buildArtifactSearchContent(this.materializeArtifactRow(row)),
2978
+ ignoreToolPrefixes: this.options.interop.ignoreToolPrefixes,
2979
+ guessMessageText: (message, ignorePrefixes) => guessMessageText(message, ignorePrefixes),
2980
+ };
2981
+ }
2982
+ artifactDeps() {
2983
+ return {
2984
+ workspaceDirectory: this.workspaceDirectory,
2985
+ options: {
2986
+ artifactPreviewChars: this.options.artifactPreviewChars,
2987
+ binaryPreviewProviders: this.options.binaryPreviewProviders,
2988
+ largeContentThreshold: this.options.largeContentThreshold,
2989
+ previewBytePeek: this.options.previewBytePeek,
2990
+ privacy: this.privacy,
2991
+ },
2992
+ getDb: () => this.getDb(),
2993
+ readArtifactBlobSync: (contentHash) => this.readArtifactBlobSync(contentHash),
2994
+ upsertSessionRowSync: (session) => this.upsertSessionRowSync(session),
2995
+ upsertMessageInfoSync: (sessionID, message) => this.upsertMessageInfoSync(sessionID, message),
2996
+ deleteMessageSync: (sessionID, messageID) => this.deleteMessageSync(sessionID, messageID),
2997
+ replaceMessageSearchRowSync: (sessionID, message) => this.replaceMessageSearchRowSync(sessionID, message),
2998
+ replaceMessageSearchRowsSync: (session) => this.replaceMessageSearchRowsSync(session),
2999
+ };
3000
+ }
3001
+ searchWithFts(query, sessionIDs, limit = 5) {
3002
+ return searchWithFtsModule(this.searchDeps(), query, sessionIDs, limit);
3003
+ }
3004
+ searchByScan(query, sessionIDs, limit = 5) {
3005
+ return searchByScanModule(this.searchDeps(), query, sessionIDs, limit);
3006
+ }
3007
+ replaceMessageSearchRowsSync(session) {
3008
+ replaceMessageSearchRowsModule(this.searchDeps(), redactStructuredValue(session, this.privacy));
3009
+ }
3010
+ replaceMessageSearchRowSync(sessionID, message) {
3011
+ replaceMessageSearchRowModule(this.searchDeps(), sessionID, redactStructuredValue(message, this.privacy));
3012
+ }
3013
+ refreshSearchIndexesSync(sessionIDs) {
3014
+ refreshSearchIndexesModule(this.searchDeps(), sessionIDs);
3015
+ }
3016
+ ensureSessionColumnsSync() {
3017
+ const db = this.getDb();
3018
+ const columns = db.prepare('PRAGMA table_info(sessions)').all();
3019
+ const names = new Set(columns.map((column) => column.name));
3020
+ const ensure = (column, definition) => {
3021
+ if (names.has(column))
3022
+ return;
3023
+ db.exec(`ALTER TABLE sessions ADD COLUMN ${definition}`);
3024
+ names.add(column);
3025
+ };
3026
+ ensure('session_directory', 'session_directory TEXT');
3027
+ ensure('worktree_key', 'worktree_key TEXT');
3028
+ ensure('parent_session_id', 'parent_session_id TEXT');
3029
+ ensure('root_session_id', 'root_session_id TEXT');
3030
+ ensure('lineage_depth', 'lineage_depth INTEGER');
3031
+ ensure('pinned', 'pinned INTEGER NOT NULL DEFAULT 0');
3032
+ ensure('pin_reason', 'pin_reason TEXT');
3033
+ }
3034
+ ensureSummaryStateColumnsSync() {
3035
+ const db = this.getDb();
3036
+ const columns = db.prepare('PRAGMA table_info(summary_state)').all();
3037
+ const names = new Set(columns.map((column) => column.name));
3038
+ if (names.has('archived_signature'))
3039
+ return;
3040
+ db.exec("ALTER TABLE summary_state ADD COLUMN archived_signature TEXT NOT NULL DEFAULT ''");
3041
+ }
3042
+ ensureArtifactColumnsSync() {
3043
+ const db = this.getDb();
3044
+ const columns = db.prepare('PRAGMA table_info(artifacts)').all();
3045
+ const names = new Set(columns.map((column) => column.name));
3046
+ if (!names.has('metadata_json')) {
3047
+ db.exec("ALTER TABLE artifacts ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}' ");
3048
+ names.add('metadata_json');
3049
+ }
3050
+ if (!names.has('content_hash')) {
3051
+ db.exec('ALTER TABLE artifacts ADD COLUMN content_hash TEXT');
3052
+ }
3053
+ }
3054
+ backfillArtifactBlobsSync() {
3055
+ const db = this.getDb();
3056
+ const rows = db
3057
+ .prepare('SELECT * FROM artifacts ORDER BY created_at ASC, artifact_id ASC')
3058
+ .all();
3059
+ if (rows.length === 0)
3060
+ return;
3061
+ const insertBlob = db.prepare(`INSERT OR IGNORE INTO artifact_blobs (content_hash, content_text, char_count, created_at)
3062
+ VALUES (?, ?, ?, ?)`);
3063
+ const updateArtifact = db.prepare("UPDATE artifacts SET content_hash = ?, content_text = CASE WHEN content_text != '' THEN '' ELSE content_text END WHERE artifact_id = ?");
3064
+ for (const row of rows) {
3065
+ const contentText = row.content_text || this.readArtifactBlobSync(row.content_hash)?.content_text || '';
3066
+ if (!contentText)
3067
+ continue;
3068
+ const contentHash = row.content_hash ?? hashContent(contentText);
3069
+ insertBlob.run(contentHash, contentText, contentText.length, row.created_at);
3070
+ if (row.content_hash !== contentHash || row.content_text !== '') {
3071
+ updateArtifact.run(contentHash, row.artifact_id);
3072
+ }
3073
+ }
3074
+ }
3075
+ refreshAllLineageSync() {
3076
+ const db = this.getDb();
3077
+ const rows = db.prepare('SELECT session_id, parent_session_id FROM sessions').all();
3078
+ const byID = new Map(rows.map((row) => [row.session_id, row]));
3079
+ const invalidParentSessionIDs = new Set();
3080
+ const visiting = new Set();
3081
+ const visited = new Set();
3082
+ const detectInvalidParents = (sessionID) => {
3083
+ if (visited.has(sessionID))
3084
+ return;
3085
+ visiting.add(sessionID);
3086
+ const row = byID.get(sessionID);
3087
+ const parentSessionID = row?.parent_session_id ?? undefined;
3088
+ if (parentSessionID) {
3089
+ if (parentSessionID === sessionID || visiting.has(parentSessionID)) {
3090
+ invalidParentSessionIDs.add(sessionID);
3091
+ }
3092
+ else if (byID.has(parentSessionID)) {
3093
+ detectInvalidParents(parentSessionID);
3094
+ }
3095
+ }
3096
+ visiting.delete(sessionID);
3097
+ visited.add(sessionID);
3098
+ };
3099
+ for (const row of rows)
3100
+ detectInvalidParents(row.session_id);
3101
+ if (invalidParentSessionIDs.size > 0) {
3102
+ const clearParent = db.prepare('UPDATE sessions SET parent_session_id = NULL WHERE session_id = ?');
3103
+ for (const sessionID of invalidParentSessionIDs) {
3104
+ clearParent.run(sessionID);
3105
+ const row = byID.get(sessionID);
3106
+ if (row)
3107
+ row.parent_session_id = null;
3108
+ }
3109
+ }
3110
+ const memo = new Map();
3111
+ const resolve = (sessionID) => {
3112
+ const existing = memo.get(sessionID);
3113
+ if (existing)
3114
+ return existing;
3115
+ const row = byID.get(sessionID);
3116
+ let resolved;
3117
+ if (!row?.parent_session_id) {
3118
+ resolved = { rootSessionID: sessionID, lineageDepth: 0 };
3119
+ }
3120
+ else {
3121
+ const parent = resolve(row.parent_session_id);
3122
+ resolved = {
3123
+ rootSessionID: parent.rootSessionID,
3124
+ lineageDepth: parent.lineageDepth + 1,
3125
+ };
3126
+ }
3127
+ memo.set(sessionID, resolved);
3128
+ return resolved;
3129
+ };
3130
+ const update = db.prepare('UPDATE sessions SET root_session_id = ?, lineage_depth = ? WHERE session_id = ?');
3131
+ for (const row of rows) {
3132
+ const lineage = resolve(row.session_id);
3133
+ update.run(lineage.rootSessionID, lineage.lineageDepth, row.session_id);
3134
+ }
3135
+ }
3136
+ resolveLineageSync(sessionID, parentSessionID) {
3137
+ if (!parentSessionID)
3138
+ return { rootSessionID: sessionID, lineageDepth: 0 };
3139
+ const parent = this.getDb()
3140
+ .prepare('SELECT root_session_id, lineage_depth FROM sessions WHERE session_id = ?')
3141
+ .get(parentSessionID);
3142
+ if (!parent)
3143
+ return { rootSessionID: parentSessionID, lineageDepth: 1 };
3144
+ return {
3145
+ rootSessionID: parent.root_session_id ?? parentSessionID,
3146
+ lineageDepth: (parent.lineage_depth ?? 0) + 1,
3147
+ };
3148
+ }
3149
+ applyEvent(session, event) {
3150
+ const payload = event.payload;
3151
+ switch (payload.type) {
3152
+ case 'session.created':
3153
+ case 'session.updated':
3154
+ session.title = payload.properties.info.title;
3155
+ session.directory = payload.properties.info.directory;
3156
+ session.parentSessionID = payload.properties.info.parentID ?? undefined;
3157
+ session.deleted = false;
3158
+ return session;
3159
+ case 'session.deleted':
3160
+ session.title = payload.properties.info.title;
3161
+ session.directory = payload.properties.info.directory;
3162
+ session.parentSessionID = payload.properties.info.parentID ?? session.parentSessionID;
3163
+ session.deleted = true;
3164
+ return session;
3165
+ case 'session.compacted':
3166
+ session.compactedAt = event.timestamp;
3167
+ return session;
3168
+ case 'message.updated': {
3169
+ const existing = session.messages.find((message) => message.info.id === payload.properties.info.id);
3170
+ if (existing)
3171
+ existing.info = payload.properties.info;
3172
+ else {
3173
+ session.messages.push({ info: payload.properties.info, parts: [] });
3174
+ session.messages.sort(compareMessages);
3175
+ }
3176
+ return session;
3177
+ }
3178
+ case 'message.removed':
3179
+ session.messages = session.messages.filter((message) => message.info.id !== payload.properties.messageID);
3180
+ return session;
3181
+ case 'message.part.updated': {
3182
+ const message = session.messages.find((entry) => entry.info.id === payload.properties.part.messageID);
3183
+ if (!message)
3184
+ return session;
3185
+ const existing = message.parts.findIndex((part) => part.id === payload.properties.part.id);
3186
+ if (existing >= 0)
3187
+ message.parts[existing] = payload.properties.part;
3188
+ else
3189
+ message.parts.push(payload.properties.part);
3190
+ return session;
3191
+ }
3192
+ case 'message.part.removed': {
3193
+ const message = session.messages.find((entry) => entry.info.id === payload.properties.messageID);
3194
+ if (!message)
3195
+ return session;
3196
+ message.parts = message.parts.filter((part) => part.id !== payload.properties.partID);
3197
+ return session;
3198
+ }
3199
+ default:
3200
+ return session;
3201
+ }
3202
+ }
3203
+ getResumeSync(sessionID) {
3204
+ const row = safeQueryOne(this.getDb().prepare('SELECT note FROM resumes WHERE session_id = ?'), [sessionID], 'getResumeSync');
3205
+ return row?.note;
3206
+ }
3207
+ readSessionHeaderSync(sessionID) {
3208
+ const row = safeQueryOne(this.getDb().prepare('SELECT * FROM sessions WHERE session_id = ?'), [sessionID], 'readSessionHeaderSync');
3209
+ if (!row)
3210
+ return undefined;
3211
+ return {
3212
+ sessionID: row.session_id,
3213
+ title: row.title ?? undefined,
3214
+ directory: row.session_directory ?? undefined,
3215
+ parentSessionID: row.parent_session_id ?? undefined,
3216
+ rootSessionID: row.root_session_id ?? undefined,
3217
+ lineageDepth: row.lineage_depth ?? undefined,
3218
+ pinned: Boolean(row.pinned),
3219
+ pinReason: row.pin_reason ?? undefined,
3220
+ updatedAt: row.updated_at,
3221
+ compactedAt: row.compacted_at ?? undefined,
3222
+ deleted: Boolean(row.deleted),
3223
+ eventCount: row.event_count,
3224
+ messages: [],
3225
+ };
3226
+ }
3227
+ clearSessionDataSync(sessionID) {
3228
+ const db = this.getDb();
3229
+ db.prepare('DELETE FROM message_fts WHERE session_id = ?').run(sessionID);
3230
+ db.prepare('DELETE FROM summary_fts WHERE session_id = ?').run(sessionID);
3231
+ db.prepare('DELETE FROM artifact_fts WHERE session_id = ?').run(sessionID);
3232
+ db.prepare('DELETE FROM artifacts WHERE session_id = ?').run(sessionID);
3233
+ db.prepare('DELETE FROM summary_edges WHERE session_id = ?').run(sessionID);
3234
+ db.prepare('DELETE FROM summary_nodes WHERE session_id = ?').run(sessionID);
3235
+ db.prepare('DELETE FROM summary_state WHERE session_id = ?').run(sessionID);
3236
+ db.prepare('DELETE FROM resumes WHERE session_id = ?').run(sessionID);
3237
+ db.prepare('DELETE FROM parts WHERE session_id = ?').run(sessionID);
3238
+ db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionID);
3239
+ db.prepare('DELETE FROM events WHERE session_id = ?').run(sessionID);
3240
+ db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionID);
3241
+ }
3242
+ readChildSessionsSync(sessionID) {
3243
+ const rows = this.getDb()
3244
+ .prepare('SELECT session_id FROM sessions WHERE parent_session_id = ? ORDER BY updated_at DESC')
3245
+ .all(sessionID);
3246
+ return rows
3247
+ .map((row) => this.readSessionHeaderSync(row.session_id))
3248
+ .filter((row) => Boolean(row));
3249
+ }
3250
+ readLineageChainSync(sessionID) {
3251
+ const chain = [];
3252
+ const seen = new Set();
3253
+ let currentID = sessionID;
3254
+ while (currentID && !seen.has(currentID)) {
3255
+ seen.add(currentID);
3256
+ const session = this.readSessionHeaderSync(currentID);
3257
+ if (!session)
3258
+ break;
3259
+ chain.unshift(session);
3260
+ currentID = session.parentSessionID;
3261
+ }
3262
+ return chain;
3263
+ }
3264
+ readAllSessionsSync() {
3265
+ const rows = this.getDb()
3266
+ .prepare('SELECT session_id FROM sessions WHERE event_count > 0 OR updated_at > 0 ORDER BY updated_at DESC')
3267
+ .all();
3268
+ const sessionIDs = rows.map((row) => row.session_id);
3269
+ if (sessionIDs.length <= 1)
3270
+ return sessionIDs.map((id) => this.readSessionSync(id));
3271
+ return this.readSessionsBatchSync(sessionIDs);
3272
+ }
3273
+ readSessionsBatchSync(sessionIDs) {
3274
+ const db = this.getDb();
3275
+ const placeholders = sessionIDs.map(() => '?').join(', ');
3276
+ // 1. Session headers (batch)
3277
+ const sessionRows = db
3278
+ .prepare(`SELECT * FROM sessions WHERE session_id IN (${placeholders})`)
3279
+ .all(...sessionIDs);
3280
+ const sessionMap = new Map();
3281
+ for (const row of sessionRows)
3282
+ sessionMap.set(row.session_id, row);
3283
+ // 2. Messages (batch)
3284
+ const messageRows = db
3285
+ .prepare(`SELECT * FROM messages WHERE session_id IN (${placeholders}) ORDER BY session_id ASC, created_at ASC, message_id ASC`)
3286
+ .all(...sessionIDs);
3287
+ // 3. Parts (batch)
3288
+ const partRows = db
3289
+ .prepare(`SELECT * FROM parts WHERE session_id IN (${placeholders}) ORDER BY session_id ASC, message_id ASC, sort_key ASC, part_id ASC`)
3290
+ .all(...sessionIDs);
3291
+ // 4. Artifacts (batch)
3292
+ const artifactRows = db
3293
+ .prepare(`SELECT * FROM artifacts WHERE session_id IN (${placeholders}) ORDER BY created_at ASC, artifact_id ASC`)
3294
+ .all(...sessionIDs);
3295
+ // 5. Artifact blobs (batch)
3296
+ const contentHashes = [
3297
+ ...new Set(artifactRows.map((r) => r.content_hash).filter(Boolean)),
3298
+ ];
3299
+ const blobMap = new Map();
3300
+ if (contentHashes.length > 0) {
3301
+ const blobPlaceholders = contentHashes.map(() => '?').join(', ');
3302
+ const blobRows = db
3303
+ .prepare(`SELECT * FROM artifact_blobs WHERE content_hash IN (${blobPlaceholders})`)
3304
+ .all(...contentHashes);
3305
+ for (const blob of blobRows)
3306
+ blobMap.set(blob.content_hash, blob);
3307
+ }
3308
+ // Group artifacts by part ID
3309
+ const artifactsByPart = new Map();
3310
+ for (const row of artifactRows) {
3311
+ const contentHash = row.content_hash;
3312
+ const blob = contentHash ? blobMap.get(contentHash) : undefined;
3313
+ const contentText = blob?.content_text ?? row.content_text;
3314
+ const artifact = {
3315
+ artifactID: row.artifact_id,
3316
+ sessionID: row.session_id,
3317
+ messageID: row.message_id,
3318
+ partID: row.part_id,
3319
+ artifactKind: row.artifact_kind,
3320
+ fieldName: row.field_name,
3321
+ previewText: row.preview_text,
3322
+ contentText,
3323
+ contentHash: contentHash ?? hashContent(contentText),
3324
+ charCount: blob?.char_count ?? row.char_count,
3325
+ createdAt: row.created_at,
3326
+ metadata: parseJson(row.metadata_json || '{}'),
3327
+ };
3328
+ const list = artifactsByPart.get(artifact.partID) ?? [];
3329
+ list.push(artifact);
3330
+ artifactsByPart.set(artifact.partID, list);
3331
+ }
3332
+ // Assemble parts per session+message
3333
+ const partsBySessionMessage = new Map();
3334
+ for (const partRow of partRows) {
3335
+ const _messageKey = `${partRow.session_id}|${partRow.message_id}`;
3336
+ let partsByMessage = partsBySessionMessage.get(partRow.session_id);
3337
+ if (!partsByMessage) {
3338
+ partsByMessage = new Map();
3339
+ partsBySessionMessage.set(partRow.session_id, partsByMessage);
3340
+ }
3341
+ const part = parseJson(partRow.part_json);
3342
+ const artifacts = artifactsByPart.get(part.id) ?? [];
3343
+ hydratePartFromArtifacts(part, artifacts);
3344
+ const parts = partsByMessage.get(partRow.message_id) ?? [];
3345
+ parts.push(part);
3346
+ partsByMessage.set(partRow.message_id, parts);
3347
+ }
3348
+ // Group messages per session
3349
+ const messagesBySession = new Map();
3350
+ for (const messageRow of messageRows) {
3351
+ const sessionParts = partsBySessionMessage.get(messageRow.session_id);
3352
+ const messages = messagesBySession.get(messageRow.session_id) ?? [];
3353
+ messages.push({
3354
+ info: parseJson(messageRow.info_json),
3355
+ parts: sessionParts?.get(messageRow.message_id) ?? [],
3356
+ });
3357
+ messagesBySession.set(messageRow.session_id, messages);
3358
+ }
3359
+ // Build NormalizedSession results
3360
+ return sessionIDs.map((sessionID) => {
3361
+ const row = sessionMap.get(sessionID);
3362
+ const messages = messagesBySession.get(sessionID) ?? [];
3363
+ if (!row) {
3364
+ return { sessionID, updatedAt: 0, eventCount: 0, messages };
3365
+ }
3366
+ return {
3367
+ sessionID: row.session_id,
3368
+ title: row.title ?? undefined,
3369
+ directory: row.session_directory ?? undefined,
3370
+ parentSessionID: row.parent_session_id ?? undefined,
3371
+ rootSessionID: row.root_session_id ?? undefined,
3372
+ lineageDepth: row.lineage_depth ?? undefined,
3373
+ pinned: Boolean(row.pinned),
3374
+ pinReason: row.pin_reason ?? undefined,
3375
+ updatedAt: row.updated_at,
3376
+ compactedAt: row.compacted_at ?? undefined,
3377
+ deleted: Boolean(row.deleted),
3378
+ eventCount: row.event_count,
3379
+ messages,
3380
+ };
3381
+ });
3382
+ }
3383
+ readSessionSync(sessionID, options) {
3384
+ const db = this.getDb();
3385
+ const row = safeQueryOne(db.prepare('SELECT * FROM sessions WHERE session_id = ?'), [sessionID], 'readSessionSync');
3386
+ const messageRows = db
3387
+ .prepare('SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC, message_id ASC')
3388
+ .all(sessionID);
3389
+ const partRows = db
3390
+ .prepare('SELECT * FROM parts WHERE session_id = ? ORDER BY message_id ASC, sort_key ASC, part_id ASC')
3391
+ .all(sessionID);
3392
+ const artifactsByPart = new Map();
3393
+ const artifactMessageIDs = options?.artifactMessageIDs;
3394
+ const artifacts = artifactMessageIDs === undefined
3395
+ ? this.readArtifactsForSessionSync(sessionID)
3396
+ : [...new Set(artifactMessageIDs)].flatMap((messageID) => this.readArtifactsForMessageSync(messageID));
3397
+ for (const artifact of artifacts) {
3398
+ const list = artifactsByPart.get(artifact.partID) ?? [];
3399
+ list.push(artifact);
3400
+ artifactsByPart.set(artifact.partID, list);
3401
+ }
3402
+ const partsByMessage = new Map();
3403
+ for (const partRow of partRows) {
3404
+ const parts = partsByMessage.get(partRow.message_id) ?? [];
3405
+ const part = parseJson(partRow.part_json);
3406
+ const artifacts = artifactsByPart.get(part.id) ?? [];
3407
+ hydratePartFromArtifacts(part, artifacts);
3408
+ parts.push(part);
3409
+ partsByMessage.set(partRow.message_id, parts);
3410
+ }
3411
+ const messages = messageRows.map((messageRow) => ({
3412
+ info: parseJson(messageRow.info_json),
3413
+ parts: partsByMessage.get(messageRow.message_id) ?? [],
3414
+ }));
3415
+ if (!row) {
3416
+ return {
3417
+ sessionID,
3418
+ updatedAt: 0,
3419
+ eventCount: 0,
3420
+ messages,
3421
+ };
3422
+ }
3423
+ return {
3424
+ sessionID: row.session_id,
3425
+ title: row.title ?? undefined,
3426
+ directory: row.session_directory ?? undefined,
3427
+ parentSessionID: row.parent_session_id ?? undefined,
3428
+ rootSessionID: row.root_session_id ?? undefined,
3429
+ lineageDepth: row.lineage_depth ?? undefined,
3430
+ pinned: Boolean(row.pinned),
3431
+ pinReason: row.pin_reason ?? undefined,
3432
+ updatedAt: row.updated_at,
3433
+ compactedAt: row.compacted_at ?? undefined,
3434
+ deleted: Boolean(row.deleted),
3435
+ eventCount: row.event_count,
3436
+ messages,
3437
+ };
3438
+ }
3439
+ prepareSessionForPersistence(session) {
3440
+ const parentSessionID = this.sanitizeParentSessionIDSync(session.sessionID, session.parentSessionID);
3441
+ const lineage = this.resolveLineageSync(session.sessionID, parentSessionID);
3442
+ return {
3443
+ ...session,
3444
+ parentSessionID,
3445
+ rootSessionID: lineage.rootSessionID,
3446
+ lineageDepth: lineage.lineageDepth,
3447
+ };
3448
+ }
3449
+ sanitizeParentSessionIDSync(sessionID, parentSessionID) {
3450
+ if (!parentSessionID || parentSessionID === sessionID)
3451
+ return undefined;
3452
+ const seen = new Set([sessionID]);
3453
+ let currentSessionID = parentSessionID;
3454
+ while (currentSessionID) {
3455
+ if (seen.has(currentSessionID))
3456
+ return undefined;
3457
+ seen.add(currentSessionID);
3458
+ const row = this.getDb()
3459
+ .prepare('SELECT parent_session_id FROM sessions WHERE session_id = ?')
3460
+ .get(currentSessionID);
3461
+ currentSessionID = row?.parent_session_id ?? undefined;
3462
+ }
3463
+ return parentSessionID;
3464
+ }
3465
+ async persistCapturedSession(session, event) {
3466
+ const payload = event.payload;
3467
+ switch (payload.type) {
3468
+ case 'session.created':
3469
+ case 'session.updated':
3470
+ case 'session.deleted':
3471
+ case 'session.compacted':
3472
+ withTransaction(this.getDb(), 'capture', () => {
3473
+ this.upsertSessionRowSync(session);
3474
+ });
3475
+ return;
3476
+ case 'message.updated': {
3477
+ const message = session.messages.find((entry) => entry.info.id === payload.properties.info.id);
3478
+ withTransaction(this.getDb(), 'capture', () => {
3479
+ this.upsertSessionRowSync(session);
3480
+ if (message) {
3481
+ this.upsertMessageInfoSync(session.sessionID, message);
3482
+ this.replaceMessageSearchRowSync(session.sessionID, message);
3483
+ }
3484
+ });
3485
+ return;
3486
+ }
3487
+ case 'message.removed':
3488
+ withTransaction(this.getDb(), 'capture', () => {
3489
+ this.upsertSessionRowSync(session);
3490
+ this.deleteMessageSync(session.sessionID, payload.properties.messageID);
3491
+ });
3492
+ return;
3493
+ case 'message.part.updated': {
3494
+ const message = session.messages.find((entry) => entry.info.id === payload.properties.part.messageID);
3495
+ const externalized = message ? await this.externalizeMessage(message) : undefined;
3496
+ withTransaction(this.getDb(), 'capture', () => {
3497
+ this.upsertSessionRowSync(session);
3498
+ if (externalized) {
3499
+ this.replaceStoredMessageSync(session.sessionID, externalized.storedMessage, externalized.artifacts);
3500
+ }
3501
+ });
3502
+ return;
3503
+ }
3504
+ case 'message.part.removed': {
3505
+ const message = session.messages.find((entry) => entry.info.id === payload.properties.messageID);
3506
+ const externalized = message ? await this.externalizeMessage(message) : undefined;
3507
+ withTransaction(this.getDb(), 'capture', () => {
3508
+ this.upsertSessionRowSync(session);
3509
+ if (externalized) {
3510
+ this.replaceStoredMessageSync(session.sessionID, externalized.storedMessage, externalized.artifacts);
3511
+ }
3512
+ });
3513
+ return;
3514
+ }
3515
+ default: {
3516
+ const externalized = await this.externalizeSession(session);
3517
+ withTransaction(this.getDb(), 'capture', () => {
3518
+ this.persistStoredSessionSync(externalized.storedSession, externalized.artifacts);
3519
+ });
3520
+ }
3521
+ }
3522
+ }
3523
+ async persistSession(session) {
3524
+ const preparedSession = this.prepareSessionForPersistence(session);
3525
+ const { storedSession, artifacts } = await this.externalizeSession(preparedSession);
3526
+ withTransaction(this.getDb(), 'persistSession', () => {
3527
+ this.persistStoredSessionSync(storedSession, artifacts);
3528
+ });
3529
+ }
3530
+ persistStoredSessionSync(storedSession, artifacts) {
3531
+ persistStoredSessionSyncModule(this.artifactDeps(), storedSession, artifacts);
3532
+ }
3533
+ upsertSessionRowSync(session) {
3534
+ const db = this.getDb();
3535
+ const title = session.title ? redactText(session.title, this.privacy) : undefined;
3536
+ const directory = session.directory ? redactText(session.directory, this.privacy) : undefined;
3537
+ const pinReason = session.pinReason ? redactText(session.pinReason, this.privacy) : undefined;
3538
+ const worktreeKey = normalizeWorktreeKey(directory);
3539
+ db.prepare(`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)
3540
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3541
+ ON CONFLICT(session_id) DO UPDATE SET
3542
+ title = excluded.title,
3543
+ session_directory = excluded.session_directory,
3544
+ worktree_key = excluded.worktree_key,
3545
+ parent_session_id = excluded.parent_session_id,
3546
+ root_session_id = excluded.root_session_id,
3547
+ lineage_depth = excluded.lineage_depth,
3548
+ pinned = excluded.pinned,
3549
+ pin_reason = excluded.pin_reason,
3550
+ updated_at = excluded.updated_at,
3551
+ compacted_at = excluded.compacted_at,
3552
+ deleted = excluded.deleted,
3553
+ event_count = excluded.event_count`).run(session.sessionID, title ?? null, directory ?? null, worktreeKey ?? null, session.parentSessionID ?? null, session.rootSessionID ?? session.sessionID, session.lineageDepth ?? 0, session.pinned ? 1 : 0, pinReason ?? null, session.updatedAt, session.compactedAt ?? null, session.deleted ? 1 : 0, session.eventCount);
3554
+ }
3555
+ upsertMessageInfoSync(sessionID, message) {
3556
+ const info = redactStructuredValue(message.info, this.privacy);
3557
+ this.getDb()
3558
+ .prepare(`INSERT INTO messages (message_id, session_id, created_at, info_json)
3559
+ VALUES (?, ?, ?, ?)
3560
+ ON CONFLICT(message_id) DO UPDATE SET
3561
+ session_id = excluded.session_id,
3562
+ created_at = excluded.created_at,
3563
+ info_json = excluded.info_json`)
3564
+ .run(info.id, sessionID, info.time.created, JSON.stringify(info));
3565
+ }
3566
+ deleteMessageSync(sessionID, messageID) {
3567
+ const db = this.getDb();
3568
+ db.prepare('DELETE FROM artifact_fts WHERE message_id = ?').run(messageID);
3569
+ db.prepare('DELETE FROM message_fts WHERE message_id = ?').run(messageID);
3570
+ db.prepare('DELETE FROM artifacts WHERE session_id = ? AND message_id = ?').run(sessionID, messageID);
3571
+ db.prepare('DELETE FROM parts WHERE session_id = ? AND message_id = ?').run(sessionID, messageID);
3572
+ db.prepare('DELETE FROM messages WHERE session_id = ? AND message_id = ?').run(sessionID, messageID);
3573
+ }
3574
+ replaceStoredMessageSync(sessionID, storedMessage, artifacts) {
3575
+ replaceStoredMessageSyncModule(this.artifactDeps(), sessionID, storedMessage, artifacts);
3576
+ }
3577
+ async externalizeMessage(message) {
3578
+ return externalizeMessageModule(this.artifactDeps(), message);
3579
+ }
3580
+ formatArtifactMetadataLines(metadata) {
3581
+ return formatArtifactMetadataLinesModule(metadata);
3582
+ }
3583
+ buildArtifactSearchContent(artifact) {
3584
+ return buildArtifactSearchContentModule(artifact);
3585
+ }
3586
+ async externalizeSession(session) {
3587
+ return externalizeSessionModule(this.artifactDeps(), session);
3588
+ }
3589
+ writeEvent(event) {
3590
+ const payloadStub = event.type.startsWith('message.') || event.type.startsWith('session.')
3591
+ ? `[${event.type}]`
3592
+ : '';
3593
+ this.getDb()
3594
+ .prepare(`INSERT OR IGNORE INTO events (id, session_id, event_type, ts, payload_json)
3595
+ VALUES (?, ?, ?, ?, ?)`)
3596
+ .run(event.id, event.sessionID ?? null, event.type, event.timestamp, payloadStub);
3597
+ }
3598
+ clearSummaryGraphSync(sessionID) {
3599
+ const db = this.getDb();
3600
+ db.prepare('DELETE FROM summary_fts WHERE session_id = ?').run(sessionID);
3601
+ db.prepare('DELETE FROM summary_edges WHERE session_id = ?').run(sessionID);
3602
+ db.prepare('DELETE FROM summary_nodes WHERE session_id = ?').run(sessionID);
3603
+ db.prepare('DELETE FROM summary_state WHERE session_id = ?').run(sessionID);
3604
+ }
3605
+ latestSessionIDSync() {
3606
+ const row = this.getDb()
3607
+ .prepare('SELECT session_id FROM sessions WHERE event_count > 0 ORDER BY updated_at DESC LIMIT 1')
3608
+ .get();
3609
+ return row?.session_id;
3610
+ }
3611
+ async migrateLegacyArtifacts() {
3612
+ const db = this.getDb();
3613
+ const existing = db.prepare('SELECT COUNT(*) AS count FROM sessions').get();
3614
+ if (existing.count > 0)
3615
+ return;
3616
+ const sessionsDir = path.join(this.baseDir, 'sessions');
3617
+ try {
3618
+ const entries = await readdir(sessionsDir);
3619
+ for (const entry of entries.filter((item) => item.endsWith('.json'))) {
3620
+ const content = await readFile(path.join(sessionsDir, entry), 'utf8');
3621
+ const session = parseJson(content);
3622
+ await this.persistSession(session);
3623
+ }
3624
+ }
3625
+ catch (error) {
3626
+ if (!hasErrorCode(error, 'ENOENT')) {
3627
+ getLogger().debug('Legacy session snapshot migration skipped', { error });
3628
+ }
3629
+ }
3630
+ const resumePath = path.join(this.baseDir, 'resume.json');
3631
+ try {
3632
+ const content = await readFile(resumePath, 'utf8');
3633
+ const resumes = parseJson(content);
3634
+ const insertResume = db.prepare(`INSERT INTO resumes (session_id, note, updated_at)
3635
+ VALUES (?, ?, ?)
3636
+ ON CONFLICT(session_id) DO UPDATE SET note = excluded.note, updated_at = excluded.updated_at`);
3637
+ const now = Date.now();
3638
+ for (const [sessionID, note] of Object.entries(resumes)) {
3639
+ insertResume.run(sessionID, note, now);
3640
+ }
3641
+ }
3642
+ catch (error) {
3643
+ if (!hasErrorCode(error, 'ENOENT')) {
3644
+ getLogger().debug('Legacy resume migration skipped', { error });
3645
+ }
3646
+ }
3647
+ const eventsPath = path.join(this.baseDir, 'events.jsonl');
3648
+ try {
3649
+ const content = await readFile(eventsPath, 'utf8');
3650
+ for (const line of content.split('\n').filter(Boolean)) {
3651
+ try {
3652
+ const event = parseJson(line);
3653
+ this.writeEvent(event);
3654
+ }
3655
+ catch (error) {
3656
+ getLogger().debug('Malformed legacy event line skipped', { error });
3657
+ }
3658
+ }
3659
+ }
3660
+ catch (error) {
3661
+ if (!hasErrorCode(error, 'ENOENT')) {
3662
+ getLogger().debug('Legacy event migration skipped', { error });
3663
+ }
3664
+ }
3665
+ }
3666
+ getDb() {
3667
+ if (!this.db) {
3668
+ throw new Error('LCM store database not ready. Call store.init() before any store operation.');
3669
+ }
3670
+ return this.db;
3671
+ }
3672
+ }
3673
+ export { assertSupportedSchemaVersionSync, readAllSessions, readArtifact, readArtifactBlob, readArtifactsForSession, readChildSessions, readLatestSessionID, readLineageChain, readMessagesForSession, readSchemaVersionSync, readSessionHeader, readSessionStats, writeSchemaVersionSync, };