opencode-lcm 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/store.js CHANGED
@@ -167,6 +167,14 @@ function getDeferredPartUpdateKey(event) {
167
167
  function compareMessages(a, b) {
168
168
  return a.info.time.created - b.info.time.created;
169
169
  }
170
+ function emptySession(sessionID) {
171
+ return {
172
+ sessionID,
173
+ updatedAt: 0,
174
+ eventCount: 0,
175
+ messages: [],
176
+ };
177
+ }
170
178
  function buildSummaryNodeID(sessionID, level, slot) {
171
179
  return `sum_${hashContent(`summary:${sessionID}`).slice(0, 12)}_l${level}_p${slot}`;
172
180
  }
@@ -328,6 +336,11 @@ function logStartupPhase(phase, context) {
328
336
  return;
329
337
  getLogger().info(`startup phase: ${phase}`, context);
330
338
  }
339
+ function unrefTimer(timer) {
340
+ if (typeof timer === 'object' && timer && 'unref' in timer && typeof timer.unref === 'function') {
341
+ timer.unref();
342
+ }
343
+ }
331
344
  function normalizeSqliteRuntimeOverride(value) {
332
345
  const normalized = value?.trim().toLowerCase();
333
346
  if (normalized === 'bun' || normalized === 'node')
@@ -347,6 +360,15 @@ export function resolveSqliteRuntimeCandidates(options) {
347
360
  export function resolveSqliteRuntime(options) {
348
361
  return resolveSqliteRuntimeCandidates(options)[0];
349
362
  }
363
+ export function resolveCaptureHydrationMode(options) {
364
+ const isBunRuntime = options?.isBunRuntime ?? (typeof globalThis === 'object' && 'Bun' in globalThis);
365
+ const platform = options?.platform ?? process.platform;
366
+ // The targeted fresh-tail capture path is safe under Node, but the bundled
367
+ // Bun runtime on Windows has been the only environment where users have
368
+ // reported native crashes in this hot path. Keep the older full-session
369
+ // hydration there until Bun/Windows is proven stable again.
370
+ return isBunRuntime && platform === 'win32' ? 'full' : 'targeted';
371
+ }
350
372
  function isSqliteRuntimeImportError(runtime, error) {
351
373
  const code = typeof error === 'object' && error && 'code' in error && typeof error.code === 'string'
352
374
  ? error.code
@@ -446,6 +468,10 @@ export class SqliteLcmStore {
446
468
  workspaceDirectory;
447
469
  db;
448
470
  dbReadyPromise;
471
+ deferredInitTimer;
472
+ deferredInitPromise;
473
+ deferredInitRequested = false;
474
+ activeOperationCount = 0;
449
475
  pendingPartUpdates = new Map();
450
476
  pendingPartUpdateTimer;
451
477
  pendingPartUpdateFlushPromise;
@@ -459,8 +485,27 @@ export class SqliteLcmStore {
459
485
  async init() {
460
486
  await mkdir(this.baseDir, { recursive: true });
461
487
  }
488
+ // Keep deferred SQLite maintenance off the active connection while a store operation is running.
489
+ async withStoreActivity(operation) {
490
+ this.activeOperationCount += 1;
491
+ try {
492
+ return await operation();
493
+ }
494
+ finally {
495
+ this.activeOperationCount -= 1;
496
+ if (this.activeOperationCount === 0 && this.deferredInitRequested) {
497
+ this.scheduleDeferredInit();
498
+ }
499
+ }
500
+ }
501
+ async waitForDeferredInitIfRunning() {
502
+ if (!this.deferredInitPromise)
503
+ return;
504
+ await this.deferredInitPromise;
505
+ }
462
506
  async prepareForRead() {
463
507
  await this.ensureDbReady();
508
+ await this.waitForDeferredInitIfRunning();
464
509
  await this.flushDeferredPartUpdates();
465
510
  }
466
511
  scheduleDeferredPartUpdateFlush() {
@@ -470,12 +515,7 @@ export class SqliteLcmStore {
470
515
  this.pendingPartUpdateTimer = undefined;
471
516
  void this.flushDeferredPartUpdates();
472
517
  }, 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
- }
518
+ unrefTimer(this.pendingPartUpdateTimer);
479
519
  }
480
520
  clearDeferredPartUpdateTimer() {
481
521
  if (!this.pendingPartUpdateTimer)
@@ -519,57 +559,64 @@ export class SqliteLcmStore {
519
559
  this.clearDeferredPartUpdateTimer();
520
560
  }
521
561
  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;
562
+ return this.withStoreActivity(async () => {
563
+ switch (event.type) {
564
+ case 'message.part.updated': {
565
+ const key = getDeferredPartUpdateKey(event);
566
+ if (!key)
567
+ return await this.capture(event);
568
+ this.pendingPartUpdates.set(key, event);
569
+ this.scheduleDeferredPartUpdateFlush();
570
+ return;
571
+ }
572
+ case 'message.part.removed':
573
+ this.clearDeferredPartUpdateForPart(event.properties.sessionID, event.properties.messageID, event.properties.partID);
574
+ break;
575
+ case 'message.removed':
576
+ this.clearDeferredPartUpdatesForMessage(event.properties.sessionID, event.properties.messageID);
577
+ break;
578
+ case 'session.deleted':
579
+ this.clearDeferredPartUpdatesForSession(extractSessionID(event));
580
+ break;
581
+ default:
582
+ break;
530
583
  }
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);
584
+ await this.capture(event);
585
+ });
544
586
  }
545
587
  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);
588
+ return this.withStoreActivity(async () => {
589
+ if (this.pendingPartUpdateFlushPromise)
590
+ return this.pendingPartUpdateFlushPromise;
591
+ if (this.pendingPartUpdates.size === 0)
592
+ return;
593
+ this.clearDeferredPartUpdateTimer();
594
+ this.pendingPartUpdateFlushPromise = (async () => {
595
+ while (this.pendingPartUpdates.size > 0) {
596
+ const batch = [...this.pendingPartUpdates.values()];
597
+ this.pendingPartUpdates.clear();
598
+ for (const event of batch) {
599
+ await this.capture(event);
600
+ }
557
601
  }
558
- }
559
- })().finally(() => {
560
- this.pendingPartUpdateFlushPromise = undefined;
561
- if (this.pendingPartUpdates.size > 0)
562
- this.scheduleDeferredPartUpdateFlush();
602
+ })().finally(() => {
603
+ this.pendingPartUpdateFlushPromise = undefined;
604
+ if (this.pendingPartUpdates.size > 0)
605
+ this.scheduleDeferredPartUpdateFlush();
606
+ });
607
+ return this.pendingPartUpdateFlushPromise;
563
608
  });
564
- return this.pendingPartUpdateFlushPromise;
565
609
  }
566
610
  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;
611
+ if (!this.dbReadyPromise) {
612
+ if (this.db) {
613
+ this.scheduleDeferredInit();
614
+ return;
615
+ }
616
+ this.dbReadyPromise = this.openAndInitializeDb();
617
+ }
618
+ await this.dbReadyPromise;
619
+ this.scheduleDeferredInit();
573
620
  }
574
621
  async openAndInitializeDb() {
575
622
  logStartupPhase('open-db:start', { dbPath: this.dbPath });
@@ -738,8 +785,6 @@ export class SqliteLcmStore {
738
785
  await this.migrateLegacyArtifacts();
739
786
  logStartupPhase('open-db:write-schema-version', { schemaVersion: STORE_SCHEMA_VERSION });
740
787
  this.writeSchemaVersionSync(STORE_SCHEMA_VERSION);
741
- logStartupPhase('open-db:deferred-init:start');
742
- this.completeDeferredInit();
743
788
  logStartupPhase('open-db:ready');
744
789
  }
745
790
  catch (error) {
@@ -753,6 +798,54 @@ export class SqliteLcmStore {
753
798
  }
754
799
  }
755
800
  deferredInitCompleted = false;
801
+ runDeferredInit() {
802
+ if (this.deferredInitCompleted)
803
+ return Promise.resolve();
804
+ if (this.deferredInitPromise)
805
+ return this.deferredInitPromise;
806
+ this.deferredInitPromise = this.withStoreActivity(async () => {
807
+ this.deferredInitRequested = false;
808
+ logStartupPhase('deferred-init:start');
809
+ this.completeDeferredInit();
810
+ })
811
+ .catch((error) => {
812
+ getLogger().warn('Deferred LCM maintenance failed', {
813
+ message: error instanceof Error ? error.message : String(error),
814
+ });
815
+ })
816
+ .finally(() => {
817
+ this.deferredInitPromise = undefined;
818
+ });
819
+ return this.deferredInitPromise;
820
+ }
821
+ scheduleDeferredInit() {
822
+ if (!this.db || this.deferredInitCompleted || this.deferredInitPromise) {
823
+ return;
824
+ }
825
+ this.deferredInitRequested = true;
826
+ if (this.activeOperationCount > 0 || this.deferredInitTimer)
827
+ return;
828
+ logStartupPhase('deferred-init:scheduled');
829
+ this.deferredInitTimer = setTimeout(() => {
830
+ this.deferredInitTimer = undefined;
831
+ if (this.activeOperationCount > 0) {
832
+ this.scheduleDeferredInit();
833
+ return;
834
+ }
835
+ void this.runDeferredInit();
836
+ }, 0);
837
+ unrefTimer(this.deferredInitTimer);
838
+ }
839
+ async ensureDeferredInitComplete() {
840
+ await this.ensureDbReady();
841
+ if (this.deferredInitCompleted)
842
+ return;
843
+ if (this.deferredInitTimer) {
844
+ clearTimeout(this.deferredInitTimer);
845
+ this.deferredInitTimer = undefined;
846
+ }
847
+ await this.runDeferredInit();
848
+ }
756
849
  readSchemaVersionSync() {
757
850
  return firstFiniteNumber(this.getDb().prepare('PRAGMA user_version').get()) ?? 0;
758
851
  }
@@ -802,6 +895,10 @@ export class SqliteLcmStore {
802
895
  close() {
803
896
  this.clearDeferredPartUpdateTimer();
804
897
  this.pendingPartUpdates.clear();
898
+ if (this.deferredInitTimer) {
899
+ clearTimeout(this.deferredInitTimer);
900
+ this.deferredInitTimer = undefined;
901
+ }
805
902
  if (!this.db)
806
903
  return;
807
904
  this.db.close();
@@ -809,51 +906,56 @@ export class SqliteLcmStore {
809
906
  this.dbReadyPromise = undefined;
810
907
  }
811
908
  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
- };
909
+ return this.withStoreActivity(async () => {
910
+ const normalized = normalizeEvent(event);
911
+ if (!normalized)
912
+ return;
913
+ const shouldRecord = this.shouldRecordEvent(normalized.type);
914
+ const shouldPersistSession = Boolean(normalized.sessionID) && this.shouldPersistSessionForEvent(normalized.type);
915
+ if (!shouldRecord && !shouldPersistSession)
916
+ return;
917
+ await this.ensureDeferredInitComplete();
918
+ if (shouldRecord) {
919
+ this.writeEvent(normalized);
843
920
  }
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
- }
921
+ if (!normalized.sessionID || !shouldPersistSession)
922
+ return;
923
+ const session = resolveCaptureHydrationMode() === 'targeted'
924
+ ? this.readSessionForCaptureSync(normalized)
925
+ : this.readSessionSync(normalized.sessionID);
926
+ const previousParentSessionID = session.parentSessionID;
927
+ const shouldSyncDerivedState = this.shouldSyncDerivedSessionStateForEvent(session, normalized);
928
+ let next = this.applyEvent(session, normalized);
929
+ next.updatedAt = Math.max(next.updatedAt, normalized.timestamp);
930
+ next.eventCount += 1;
931
+ next = this.prepareSessionForPersistence(next);
932
+ await this.persistCapturedSession(next, normalized);
933
+ if (this.shouldRefreshLineageForEvent(normalized.type)) {
934
+ this.refreshAllLineageSync();
935
+ const refreshed = this.readSessionHeaderSync(normalized.sessionID);
936
+ if (refreshed) {
937
+ next = {
938
+ ...next,
939
+ parentSessionID: refreshed.parentSessionID,
940
+ rootSessionID: refreshed.rootSessionID,
941
+ lineageDepth: refreshed.lineageDepth,
942
+ };
943
+ }
944
+ }
945
+ if (shouldSyncDerivedState) {
946
+ this.syncDerivedSessionStateSync(this.readSessionSync(normalized.sessionID));
947
+ }
948
+ if (this.shouldSyncDerivedLineageSubtree(normalized.type, previousParentSessionID, next.parentSessionID)) {
949
+ this.syncDerivedLineageSubtreeSync(normalized.sessionID, true);
950
+ }
951
+ if (this.shouldCleanupOrphanBlobsForEvent(normalized.type)) {
952
+ this.deleteOrphanArtifactBlobsSync();
953
+ }
954
+ });
854
955
  }
855
956
  async stats() {
856
957
  await this.prepareForRead();
958
+ await this.ensureDeferredInitComplete();
857
959
  const db = this.getDb();
858
960
  const totalRow = validateRow(db.prepare('SELECT COUNT(*) AS count, MAX(ts) AS latest FROM events').get(), { count: 'number', latest: 'nullable' }, 'stats.totalEvents');
859
961
  const sessionRow = validateRow(db.prepare('SELECT COUNT(*) AS count FROM sessions').get(), { count: 'number' }, 'stats.sessionCount');
@@ -903,13 +1005,13 @@ export class SqliteLcmStore {
903
1005
  };
904
1006
  }
905
1007
  async grep(input) {
906
- await this.prepareForRead();
907
1008
  const resolvedScope = this.resolveConfiguredScope('grep', input.scope, input.sessionID);
908
- const sessionIDs = this.resolveScopeSessionIDs(resolvedScope, input.sessionID);
909
1009
  const limit = input.limit ?? 5;
910
1010
  const needle = input.query.trim();
911
1011
  if (!needle)
912
1012
  return [];
1013
+ await this.prepareForRead();
1014
+ const sessionIDs = this.resolveScopeSessionIDs(resolvedScope, input.sessionID);
913
1015
  const ftsResults = this.searchWithFts(needle, sessionIDs, limit);
914
1016
  if (ftsResults.length > 0)
915
1017
  return ftsResults;
@@ -1206,54 +1308,33 @@ export class SqliteLcmStore {
1206
1308
  eventType === 'message.part.updated' ||
1207
1309
  eventType === 'message.part.removed');
1208
1310
  }
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) {
1311
+ shouldSyncDerivedSessionStateForEvent(session, event) {
1238
1312
  const payload = event.payload;
1239
1313
  switch (payload.type) {
1240
1314
  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));
1315
+ const existing = session.messages.find((message) => message.info.id === payload.properties.info.id);
1316
+ if (existing) {
1317
+ return this.isMessageArchivedSync(session.sessionID, existing.info.id, existing.info.time.created);
1318
+ }
1319
+ return this.readMessageCountSync(session.sessionID) >= this.options.freshTailMessages;
1320
+ }
1321
+ case 'message.removed': {
1322
+ const existing = safeQueryOne(this.getDb().prepare('SELECT created_at FROM messages WHERE session_id = ? AND message_id = ?'), [session.sessionID, payload.properties.messageID], 'shouldSyncDerivedSessionStateForEvent.messageRemoved');
1323
+ if (!existing)
1324
+ return false;
1325
+ return this.readMessageCountSync(session.sessionID) > this.options.freshTailMessages;
1245
1326
  }
1246
- case 'message.removed':
1247
- return this.didArchivedMessagesChange(previous.messages, next.messages);
1248
1327
  case 'message.part.updated': {
1249
- const messageID = payload.properties.part.messageID;
1250
- return (this.isArchivedMessage(previous.messages, messageID) ||
1251
- this.isArchivedMessage(next.messages, messageID));
1328
+ const message = session.messages.find((entry) => entry.info.id === payload.properties.part.messageID);
1329
+ if (!message)
1330
+ return false;
1331
+ return this.isMessageArchivedSync(session.sessionID, message.info.id, message.info.time.created);
1252
1332
  }
1253
1333
  case 'message.part.removed': {
1254
- const messageID = payload.properties.messageID;
1255
- return (this.isArchivedMessage(previous.messages, messageID) ||
1256
- this.isArchivedMessage(next.messages, messageID));
1334
+ const message = session.messages.find((entry) => entry.info.id === payload.properties.messageID);
1335
+ if (!message)
1336
+ return false;
1337
+ return this.isMessageArchivedSync(session.sessionID, message.info.id, message.info.time.created);
1257
1338
  }
1258
1339
  default:
1259
1340
  return false;
@@ -1868,66 +1949,68 @@ export class SqliteLcmStore {
1868
1949
  };
1869
1950
  }
1870
1951
  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) {
1952
+ return this.withStoreActivity(async () => {
1953
+ await this.prepareForRead();
1954
+ const apply = input?.apply ?? false;
1955
+ const vacuum = input?.vacuum ?? true;
1956
+ const limit = clamp(input?.limit ?? 10, 1, 50);
1957
+ const candidates = this.readPrunableEventTypeCountsSync();
1958
+ const candidateEvents = candidates.reduce((sum, row) => sum + row.count, 0);
1959
+ const beforeSizes = await this.readStoreFileSizes();
1960
+ if (!apply || candidateEvents === 0) {
1961
+ return [
1962
+ `candidate_events=${candidateEvents}`,
1963
+ `apply=false`,
1964
+ `vacuum_requested=${vacuum}`,
1965
+ `db_bytes=${beforeSizes.dbBytes}`,
1966
+ `wal_bytes=${beforeSizes.walBytes}`,
1967
+ `shm_bytes=${beforeSizes.shmBytes}`,
1968
+ `total_bytes=${beforeSizes.totalBytes}`,
1969
+ ...(candidates.length > 0
1970
+ ? [
1971
+ 'candidate_event_types:',
1972
+ ...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
1973
+ ]
1974
+ : ['candidate_event_types:', '- none']),
1975
+ ].join('\n');
1976
+ }
1977
+ const eventTypes = candidates.map((row) => row.eventType);
1978
+ if (eventTypes.length > 0) {
1979
+ const placeholders = eventTypes.map(() => '?').join(', ');
1980
+ this.getDb()
1981
+ .prepare(`DELETE FROM events WHERE event_type IN (${placeholders})`)
1982
+ .run(...eventTypes);
1983
+ }
1984
+ let vacuumApplied = false;
1985
+ this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
1986
+ if (vacuum) {
1987
+ this.getDb().exec('VACUUM');
1988
+ this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
1989
+ vacuumApplied = true;
1990
+ }
1991
+ const afterSizes = await this.readStoreFileSizes();
1879
1992
  return [
1880
1993
  `candidate_events=${candidateEvents}`,
1881
- `apply=false`,
1994
+ `deleted_events=${candidateEvents}`,
1995
+ `apply=true`,
1882
1996
  `vacuum_requested=${vacuum}`,
1883
- `db_bytes=${beforeSizes.dbBytes}`,
1884
- `wal_bytes=${beforeSizes.walBytes}`,
1885
- `shm_bytes=${beforeSizes.shmBytes}`,
1886
- `total_bytes=${beforeSizes.totalBytes}`,
1997
+ `vacuum_applied=${vacuumApplied}`,
1998
+ `db_bytes_before=${beforeSizes.dbBytes}`,
1999
+ `wal_bytes_before=${beforeSizes.walBytes}`,
2000
+ `shm_bytes_before=${beforeSizes.shmBytes}`,
2001
+ `total_bytes_before=${beforeSizes.totalBytes}`,
2002
+ `db_bytes_after=${afterSizes.dbBytes}`,
2003
+ `wal_bytes_after=${afterSizes.walBytes}`,
2004
+ `shm_bytes_after=${afterSizes.shmBytes}`,
2005
+ `total_bytes_after=${afterSizes.totalBytes}`,
1887
2006
  ...(candidates.length > 0
1888
2007
  ? [
1889
- 'candidate_event_types:',
2008
+ 'deleted_event_types:',
1890
2009
  ...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
1891
2010
  ]
1892
- : ['candidate_event_types:', '- none']),
2011
+ : ['deleted_event_types:', '- none']),
1893
2012
  ].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');
2013
+ });
1931
2014
  }
1932
2015
  async retentionReport(input) {
1933
2016
  await this.prepareForRead();
@@ -2055,44 +2138,50 @@ export class SqliteLcmStore {
2055
2138
  ].join('\n');
2056
2139
  }
2057
2140
  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);
2141
+ return this.withStoreActivity(async () => {
2142
+ await this.prepareForRead();
2143
+ return exportStoreSnapshot({
2144
+ workspaceDirectory: this.workspaceDirectory,
2145
+ normalizeScope: this.normalizeScope.bind(this),
2146
+ resolveScopeSessionIDs: this.resolveScopeSessionIDs.bind(this),
2147
+ readScopedSessionRowsSync: this.readScopedSessionRowsSync.bind(this),
2148
+ readScopedMessageRowsSync: this.readScopedMessageRowsSync.bind(this),
2149
+ readScopedPartRowsSync: this.readScopedPartRowsSync.bind(this),
2150
+ readScopedResumeRowsSync: this.readScopedResumeRowsSync.bind(this),
2151
+ readScopedArtifactRowsSync: this.readScopedArtifactRowsSync.bind(this),
2152
+ readScopedArtifactBlobRowsSync: this.readScopedArtifactBlobRowsSync.bind(this),
2153
+ readScopedSummaryRowsSync: this.readScopedSummaryRowsSync.bind(this),
2154
+ readScopedSummaryEdgeRowsSync: this.readScopedSummaryEdgeRowsSync.bind(this),
2155
+ readScopedSummaryStateRowsSync: this.readScopedSummaryStateRowsSync.bind(this),
2156
+ }, input);
2157
+ });
2073
2158
  }
2074
2159
  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);
2160
+ return this.withStoreActivity(async () => {
2161
+ await this.prepareForRead();
2162
+ return importStoreSnapshot({
2163
+ workspaceDirectory: this.workspaceDirectory,
2164
+ getDb: () => this.getDb(),
2165
+ clearSessionDataSync: this.clearSessionDataSync.bind(this),
2166
+ backfillArtifactBlobsSync: this.backfillArtifactBlobsSync.bind(this),
2167
+ refreshAllLineageSync: this.refreshAllLineageSync.bind(this),
2168
+ syncAllDerivedSessionStateSync: this.syncAllDerivedSessionStateSync.bind(this),
2169
+ refreshSearchIndexesSync: this.refreshSearchIndexesSync.bind(this),
2170
+ }, input);
2171
+ });
2085
2172
  }
2086
2173
  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.';
2174
+ return this.withStoreActivity(async () => {
2175
+ await this.prepareForRead();
2176
+ const resolvedSessionID = sessionID ?? this.latestSessionIDSync();
2177
+ if (!resolvedSessionID)
2178
+ return 'No stored resume snapshots yet.';
2179
+ const existing = this.getResumeSync(resolvedSessionID);
2180
+ if (existing && !this.isManagedResumeNote(existing))
2181
+ return existing;
2182
+ const generated = await this.buildCompactionContext(resolvedSessionID);
2183
+ return generated ?? existing ?? 'No stored resume snapshot for that session.';
2184
+ });
2096
2185
  }
2097
2186
  async expand(input) {
2098
2187
  await this.prepareForRead();
@@ -2149,45 +2238,47 @@ export class SqliteLcmStore {
2149
2238
  return note;
2150
2239
  }
2151
2240
  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) {
2241
+ return this.withStoreActivity(async () => {
2242
+ if (messages.length < this.options.minMessagesForTransform)
2243
+ return false;
2244
+ const window = resolveArchiveTransformWindow(messages, this.options.freshTailMessages);
2245
+ if (!window)
2246
+ return false;
2247
+ await this.prepareForRead();
2248
+ const { anchor, archived, recent } = window;
2249
+ const roots = this.ensureSummaryGraphSync(anchor.info.sessionID, archived);
2250
+ if (roots.length === 0)
2251
+ return false;
2252
+ const summary = buildActiveSummaryText(roots, archived.length, this.options.summaryCharBudget);
2253
+ const retrieval = await this.buildAutomaticRetrievalContext(anchor.info.sessionID, recent, anchor);
2254
+ for (const message of archived) {
2255
+ this.compactMessageInPlace(message);
2256
+ }
2257
+ anchor.parts = anchor.parts.filter((part) => !isSyntheticLcmTextPart(part, ['archive-summary', 'retrieved-context']));
2258
+ const syntheticParts = [];
2259
+ if (retrieval) {
2260
+ syntheticParts.push({
2261
+ id: `lcm-memory-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
2262
+ sessionID: anchor.info.sessionID,
2263
+ messageID: anchor.info.id,
2264
+ type: 'text',
2265
+ text: retrieval,
2266
+ synthetic: true,
2267
+ metadata: { opencodeLcm: 'retrieved-context' },
2268
+ });
2269
+ }
2170
2270
  syntheticParts.push({
2171
- id: `lcm-memory-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
2271
+ id: `lcm-summary-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
2172
2272
  sessionID: anchor.info.sessionID,
2173
2273
  messageID: anchor.info.id,
2174
2274
  type: 'text',
2175
- text: retrieval,
2275
+ text: summary,
2176
2276
  synthetic: true,
2177
- metadata: { opencodeLcm: 'retrieved-context' },
2277
+ metadata: { opencodeLcm: 'archive-summary' },
2178
2278
  });
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' },
2279
+ anchor.parts.push(...syntheticParts);
2280
+ return true;
2188
2281
  });
2189
- anchor.parts.push(...syntheticParts);
2190
- return true;
2191
2282
  }
2192
2283
  systemHint() {
2193
2284
  if (!this.options.systemHint)
@@ -3136,15 +3227,29 @@ export class SqliteLcmStore {
3136
3227
  resolveLineageSync(sessionID, parentSessionID) {
3137
3228
  if (!parentSessionID)
3138
3229
  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
- };
3230
+ const seen = new Set([sessionID]);
3231
+ let currentSessionID = parentSessionID;
3232
+ let lineageDepth = 1;
3233
+ while (currentSessionID && !seen.has(currentSessionID)) {
3234
+ seen.add(currentSessionID);
3235
+ const parent = this.getDb()
3236
+ .prepare('SELECT parent_session_id, root_session_id, lineage_depth FROM sessions WHERE session_id = ?')
3237
+ .get(currentSessionID);
3238
+ if (!parent)
3239
+ return { rootSessionID: currentSessionID, lineageDepth };
3240
+ if (parent.root_session_id && parent.lineage_depth !== null) {
3241
+ return {
3242
+ rootSessionID: parent.root_session_id,
3243
+ lineageDepth: parent.lineage_depth + lineageDepth,
3244
+ };
3245
+ }
3246
+ if (!parent.parent_session_id) {
3247
+ return { rootSessionID: currentSessionID, lineageDepth };
3248
+ }
3249
+ currentSessionID = parent.parent_session_id;
3250
+ lineageDepth += 1;
3251
+ }
3252
+ return { rootSessionID: parentSessionID, lineageDepth };
3148
3253
  }
3149
3254
  applyEvent(session, event) {
3150
3255
  const payload = event.payload;
@@ -3204,26 +3309,33 @@ export class SqliteLcmStore {
3204
3309
  const row = safeQueryOne(this.getDb().prepare('SELECT note FROM resumes WHERE session_id = ?'), [sessionID], 'getResumeSync');
3205
3310
  return row?.note;
3206
3311
  }
3207
- readSessionHeaderSync(sessionID) {
3208
- const row = safeQueryOne(this.getDb().prepare('SELECT * FROM sessions WHERE session_id = ?'), [sessionID], 'readSessionHeaderSync');
3209
- if (!row)
3210
- return undefined;
3312
+ materializeSessionRow(row, messages = []) {
3313
+ const parentSessionID = row.parent_session_id ?? undefined;
3314
+ const derivedLineage = row.root_session_id === null || row.lineage_depth === null
3315
+ ? this.resolveLineageSync(row.session_id, parentSessionID)
3316
+ : undefined;
3211
3317
  return {
3212
3318
  sessionID: row.session_id,
3213
3319
  title: row.title ?? undefined,
3214
3320
  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,
3321
+ parentSessionID,
3322
+ rootSessionID: row.root_session_id ?? derivedLineage?.rootSessionID,
3323
+ lineageDepth: row.lineage_depth ?? derivedLineage?.lineageDepth,
3218
3324
  pinned: Boolean(row.pinned),
3219
3325
  pinReason: row.pin_reason ?? undefined,
3220
3326
  updatedAt: row.updated_at,
3221
3327
  compactedAt: row.compacted_at ?? undefined,
3222
3328
  deleted: Boolean(row.deleted),
3223
3329
  eventCount: row.event_count,
3224
- messages: [],
3330
+ messages,
3225
3331
  };
3226
3332
  }
3333
+ readSessionHeaderSync(sessionID) {
3334
+ const row = safeQueryOne(this.getDb().prepare('SELECT * FROM sessions WHERE session_id = ?'), [sessionID], 'readSessionHeaderSync');
3335
+ if (!row)
3336
+ return undefined;
3337
+ return this.materializeSessionRow(row);
3338
+ }
3227
3339
  clearSessionDataSync(sessionID) {
3228
3340
  const db = this.getDb();
3229
3341
  db.prepare('DELETE FROM message_fts WHERE session_id = ?').run(sessionID);
@@ -3361,26 +3473,12 @@ export class SqliteLcmStore {
3361
3473
  const row = sessionMap.get(sessionID);
3362
3474
  const messages = messagesBySession.get(sessionID) ?? [];
3363
3475
  if (!row) {
3364
- return { sessionID, updatedAt: 0, eventCount: 0, messages };
3476
+ return { ...emptySession(sessionID), messages };
3365
3477
  }
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
- };
3478
+ return this.materializeSessionRow(row, messages);
3381
3479
  });
3382
3480
  }
3383
- readSessionSync(sessionID, options) {
3481
+ readSessionSync(sessionID) {
3384
3482
  const db = this.getDb();
3385
3483
  const row = safeQueryOne(db.prepare('SELECT * FROM sessions WHERE session_id = ?'), [sessionID], 'readSessionSync');
3386
3484
  const messageRows = db
@@ -3390,11 +3488,7 @@ export class SqliteLcmStore {
3390
3488
  .prepare('SELECT * FROM parts WHERE session_id = ? ORDER BY message_id ASC, sort_key ASC, part_id ASC')
3391
3489
  .all(sessionID);
3392
3490
  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) {
3491
+ for (const artifact of this.readArtifactsForSessionSync(sessionID)) {
3398
3492
  const list = artifactsByPart.get(artifact.partID) ?? [];
3399
3493
  list.push(artifact);
3400
3494
  artifactsByPart.set(artifact.partID, list);
@@ -3413,28 +3507,9 @@ export class SqliteLcmStore {
3413
3507
  parts: partsByMessage.get(messageRow.message_id) ?? [],
3414
3508
  }));
3415
3509
  if (!row) {
3416
- return {
3417
- sessionID,
3418
- updatedAt: 0,
3419
- eventCount: 0,
3420
- messages,
3421
- };
3510
+ return { ...emptySession(sessionID), messages };
3422
3511
  }
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
- };
3512
+ return this.materializeSessionRow(row, messages);
3438
3513
  }
3439
3514
  prepareSessionForPersistence(session) {
3440
3515
  const parentSessionID = this.sanitizeParentSessionIDSync(session.sessionID, session.parentSessionID);
@@ -3462,6 +3537,73 @@ export class SqliteLcmStore {
3462
3537
  }
3463
3538
  return parentSessionID;
3464
3539
  }
3540
+ readSessionForCaptureSync(event) {
3541
+ const sessionID = event.sessionID;
3542
+ if (!sessionID)
3543
+ return emptySession('');
3544
+ const session = this.readSessionHeaderSync(sessionID) ?? emptySession(sessionID);
3545
+ const payload = event.payload;
3546
+ switch (payload.type) {
3547
+ case 'message.updated': {
3548
+ const message = this.readMessageSync(sessionID, payload.properties.info.id);
3549
+ if (message)
3550
+ session.messages = [message];
3551
+ return session;
3552
+ }
3553
+ case 'message.part.updated': {
3554
+ const message = this.readMessageSync(sessionID, payload.properties.part.messageID);
3555
+ if (message)
3556
+ session.messages = [message];
3557
+ return session;
3558
+ }
3559
+ case 'message.part.removed': {
3560
+ const message = this.readMessageSync(sessionID, payload.properties.messageID);
3561
+ if (message)
3562
+ session.messages = [message];
3563
+ return session;
3564
+ }
3565
+ default:
3566
+ return session;
3567
+ }
3568
+ }
3569
+ readMessageSync(sessionID, messageID) {
3570
+ const db = this.getDb();
3571
+ const row = safeQueryOne(db.prepare('SELECT * FROM messages WHERE session_id = ? AND message_id = ?'), [sessionID, messageID], 'readMessageSync');
3572
+ if (!row)
3573
+ return undefined;
3574
+ const artifactsByPart = new Map();
3575
+ for (const artifact of this.readArtifactsForMessageSync(messageID)) {
3576
+ const list = artifactsByPart.get(artifact.partID) ?? [];
3577
+ list.push(artifact);
3578
+ artifactsByPart.set(artifact.partID, list);
3579
+ }
3580
+ const parts = db
3581
+ .prepare('SELECT * FROM parts WHERE session_id = ? AND message_id = ? ORDER BY sort_key ASC, part_id ASC')
3582
+ .all(sessionID, messageID);
3583
+ return {
3584
+ info: parseJson(row.info_json),
3585
+ parts: parts.map((partRow) => {
3586
+ const part = parseJson(partRow.part_json);
3587
+ hydratePartFromArtifacts(part, artifactsByPart.get(part.id) ?? []);
3588
+ return part;
3589
+ }),
3590
+ };
3591
+ }
3592
+ readMessageCountSync(sessionID) {
3593
+ const row = this.getDb()
3594
+ .prepare('SELECT COUNT(*) AS count FROM messages WHERE session_id = ?')
3595
+ .get(sessionID);
3596
+ return row.count;
3597
+ }
3598
+ isMessageArchivedSync(sessionID, messageID, createdAt) {
3599
+ const row = this.getDb()
3600
+ .prepare(`SELECT COUNT(*) AS count
3601
+ FROM messages
3602
+ WHERE session_id = ?
3603
+ AND (created_at > ? OR (created_at = ? AND message_id > ?))`)
3604
+ .get(sessionID, createdAt, createdAt, messageID);
3605
+ return row.count >= this.options.freshTailMessages;
3606
+ }
3465
3607
  async persistCapturedSession(session, event) {
3466
3608
  const payload = event.payload;
3467
3609
  switch (payload.type) {