opencode-lcm 0.12.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/src/store.ts CHANGED
@@ -528,6 +528,12 @@ function logStartupPhase(phase: string, context?: Record<string, unknown>): void
528
528
  getLogger().info(`startup phase: ${phase}`, context);
529
529
  }
530
530
 
531
+ function unrefTimer(timer: ReturnType<typeof setTimeout> | undefined): void {
532
+ if (typeof timer === 'object' && timer && 'unref' in timer && typeof timer.unref === 'function') {
533
+ timer.unref();
534
+ }
535
+ }
536
+
531
537
  type SqliteRuntime = 'bun' | 'node';
532
538
  type SqliteRuntimeOptions = {
533
539
  envOverride?: string | undefined;
@@ -707,6 +713,10 @@ export class SqliteLcmStore {
707
713
  private readonly workspaceDirectory: string;
708
714
  private db?: SqlDatabaseLike;
709
715
  private dbReadyPromise?: Promise<void>;
716
+ private deferredInitTimer?: ReturnType<typeof setTimeout>;
717
+ private deferredInitPromise?: Promise<void>;
718
+ private deferredInitRequested = false;
719
+ private activeOperationCount = 0;
710
720
  private readonly pendingPartUpdates = new Map<string, Event>();
711
721
  private pendingPartUpdateTimer?: ReturnType<typeof setTimeout>;
712
722
  private pendingPartUpdateFlushPromise?: Promise<void>;
@@ -725,8 +735,27 @@ export class SqliteLcmStore {
725
735
  await mkdir(this.baseDir, { recursive: true });
726
736
  }
727
737
 
738
+ // Keep deferred SQLite maintenance off the active connection while a store operation is running.
739
+ private async withStoreActivity<T>(operation: () => Promise<T>): Promise<T> {
740
+ this.activeOperationCount += 1;
741
+ try {
742
+ return await operation();
743
+ } finally {
744
+ this.activeOperationCount -= 1;
745
+ if (this.activeOperationCount === 0 && this.deferredInitRequested) {
746
+ this.scheduleDeferredInit();
747
+ }
748
+ }
749
+ }
750
+
751
+ private async waitForDeferredInitIfRunning(): Promise<void> {
752
+ if (!this.deferredInitPromise) return;
753
+ await this.deferredInitPromise;
754
+ }
755
+
728
756
  private async prepareForRead(): Promise<void> {
729
757
  await this.ensureDbReady();
758
+ await this.waitForDeferredInitIfRunning();
730
759
  await this.flushDeferredPartUpdates();
731
760
  }
732
761
 
@@ -737,15 +766,7 @@ export class SqliteLcmStore {
737
766
  this.pendingPartUpdateTimer = undefined;
738
767
  void this.flushDeferredPartUpdates();
739
768
  }, SqliteLcmStore.deferredPartUpdateDelayMs);
740
-
741
- if (
742
- typeof this.pendingPartUpdateTimer === 'object' &&
743
- this.pendingPartUpdateTimer &&
744
- 'unref' in this.pendingPartUpdateTimer &&
745
- typeof this.pendingPartUpdateTimer.unref === 'function'
746
- ) {
747
- this.pendingPartUpdateTimer.unref();
748
- }
769
+ unrefTimer(this.pendingPartUpdateTimer);
749
770
  }
750
771
 
751
772
  private clearDeferredPartUpdateTimer(): void {
@@ -790,63 +811,73 @@ export class SqliteLcmStore {
790
811
  }
791
812
 
792
813
  async captureDeferred(event: Event): Promise<void> {
793
- switch (event.type) {
794
- case 'message.part.updated': {
795
- const key = getDeferredPartUpdateKey(event);
796
- if (!key) return await this.capture(event);
797
- this.pendingPartUpdates.set(key, event);
798
- this.scheduleDeferredPartUpdateFlush();
799
- return;
814
+ return this.withStoreActivity(async () => {
815
+ switch (event.type) {
816
+ case 'message.part.updated': {
817
+ const key = getDeferredPartUpdateKey(event);
818
+ if (!key) return await this.capture(event);
819
+ this.pendingPartUpdates.set(key, event);
820
+ this.scheduleDeferredPartUpdateFlush();
821
+ return;
822
+ }
823
+ case 'message.part.removed':
824
+ this.clearDeferredPartUpdateForPart(
825
+ event.properties.sessionID,
826
+ event.properties.messageID,
827
+ event.properties.partID,
828
+ );
829
+ break;
830
+ case 'message.removed':
831
+ this.clearDeferredPartUpdatesForMessage(
832
+ event.properties.sessionID,
833
+ event.properties.messageID,
834
+ );
835
+ break;
836
+ case 'session.deleted':
837
+ this.clearDeferredPartUpdatesForSession(extractSessionID(event));
838
+ break;
839
+ default:
840
+ break;
800
841
  }
801
- case 'message.part.removed':
802
- this.clearDeferredPartUpdateForPart(
803
- event.properties.sessionID,
804
- event.properties.messageID,
805
- event.properties.partID,
806
- );
807
- break;
808
- case 'message.removed':
809
- this.clearDeferredPartUpdatesForMessage(
810
- event.properties.sessionID,
811
- event.properties.messageID,
812
- );
813
- break;
814
- case 'session.deleted':
815
- this.clearDeferredPartUpdatesForSession(extractSessionID(event));
816
- break;
817
- default:
818
- break;
819
- }
820
842
 
821
- await this.capture(event);
843
+ await this.capture(event);
844
+ });
822
845
  }
823
846
 
824
847
  async flushDeferredPartUpdates(): Promise<void> {
825
- if (this.pendingPartUpdateFlushPromise) return this.pendingPartUpdateFlushPromise;
826
- if (this.pendingPartUpdates.size === 0) return;
827
-
828
- this.clearDeferredPartUpdateTimer();
829
- this.pendingPartUpdateFlushPromise = (async () => {
830
- while (this.pendingPartUpdates.size > 0) {
831
- const batch = [...this.pendingPartUpdates.values()];
832
- this.pendingPartUpdates.clear();
833
- for (const event of batch) {
834
- await this.capture(event);
848
+ return this.withStoreActivity(async () => {
849
+ if (this.pendingPartUpdateFlushPromise) return this.pendingPartUpdateFlushPromise;
850
+ if (this.pendingPartUpdates.size === 0) return;
851
+
852
+ this.clearDeferredPartUpdateTimer();
853
+ this.pendingPartUpdateFlushPromise = (async () => {
854
+ while (this.pendingPartUpdates.size > 0) {
855
+ const batch = [...this.pendingPartUpdates.values()];
856
+ this.pendingPartUpdates.clear();
857
+ for (const event of batch) {
858
+ await this.capture(event);
859
+ }
835
860
  }
836
- }
837
- })().finally(() => {
838
- this.pendingPartUpdateFlushPromise = undefined;
839
- if (this.pendingPartUpdates.size > 0) this.scheduleDeferredPartUpdateFlush();
840
- });
861
+ })().finally(() => {
862
+ this.pendingPartUpdateFlushPromise = undefined;
863
+ if (this.pendingPartUpdates.size > 0) this.scheduleDeferredPartUpdateFlush();
864
+ });
841
865
 
842
- return this.pendingPartUpdateFlushPromise;
866
+ return this.pendingPartUpdateFlushPromise;
867
+ });
843
868
  }
844
869
 
845
870
  private async ensureDbReady(): Promise<void> {
846
- if (this.db) return;
847
- if (this.dbReadyPromise) return this.dbReadyPromise;
848
- this.dbReadyPromise = this.openAndInitializeDb();
849
- return this.dbReadyPromise;
871
+ if (!this.dbReadyPromise) {
872
+ if (this.db) {
873
+ this.scheduleDeferredInit();
874
+ return;
875
+ }
876
+ this.dbReadyPromise = this.openAndInitializeDb();
877
+ }
878
+
879
+ await this.dbReadyPromise;
880
+ this.scheduleDeferredInit();
850
881
  }
851
882
 
852
883
  private async openAndInitializeDb(): Promise<void> {
@@ -1024,8 +1055,6 @@ export class SqliteLcmStore {
1024
1055
  await this.migrateLegacyArtifacts();
1025
1056
  logStartupPhase('open-db:write-schema-version', { schemaVersion: STORE_SCHEMA_VERSION });
1026
1057
  this.writeSchemaVersionSync(STORE_SCHEMA_VERSION);
1027
- logStartupPhase('open-db:deferred-init:start');
1028
- this.completeDeferredInit();
1029
1058
  logStartupPhase('open-db:ready');
1030
1059
  } catch (error) {
1031
1060
  logStartupPhase('open-db:error', {
@@ -1040,6 +1069,59 @@ export class SqliteLcmStore {
1040
1069
 
1041
1070
  private deferredInitCompleted = false;
1042
1071
 
1072
+ private runDeferredInit(): Promise<void> {
1073
+ if (this.deferredInitCompleted) return Promise.resolve();
1074
+ if (this.deferredInitPromise) return this.deferredInitPromise;
1075
+
1076
+ this.deferredInitPromise = this.withStoreActivity(async () => {
1077
+ this.deferredInitRequested = false;
1078
+ logStartupPhase('deferred-init:start');
1079
+ this.completeDeferredInit();
1080
+ })
1081
+ .catch((error) => {
1082
+ getLogger().warn('Deferred LCM maintenance failed', {
1083
+ message: error instanceof Error ? error.message : String(error),
1084
+ });
1085
+ })
1086
+ .finally(() => {
1087
+ this.deferredInitPromise = undefined;
1088
+ });
1089
+
1090
+ return this.deferredInitPromise;
1091
+ }
1092
+
1093
+ private scheduleDeferredInit(): void {
1094
+ if (!this.db || this.deferredInitCompleted || this.deferredInitPromise) {
1095
+ return;
1096
+ }
1097
+
1098
+ this.deferredInitRequested = true;
1099
+ if (this.activeOperationCount > 0 || this.deferredInitTimer) return;
1100
+
1101
+ logStartupPhase('deferred-init:scheduled');
1102
+ this.deferredInitTimer = setTimeout(() => {
1103
+ this.deferredInitTimer = undefined;
1104
+ if (this.activeOperationCount > 0) {
1105
+ this.scheduleDeferredInit();
1106
+ return;
1107
+ }
1108
+ void this.runDeferredInit();
1109
+ }, 0);
1110
+ unrefTimer(this.deferredInitTimer);
1111
+ }
1112
+
1113
+ private async ensureDeferredInitComplete(): Promise<void> {
1114
+ await this.ensureDbReady();
1115
+ if (this.deferredInitCompleted) return;
1116
+
1117
+ if (this.deferredInitTimer) {
1118
+ clearTimeout(this.deferredInitTimer);
1119
+ this.deferredInitTimer = undefined;
1120
+ }
1121
+
1122
+ await this.runDeferredInit();
1123
+ }
1124
+
1043
1125
  private readSchemaVersionSync(): number {
1044
1126
  return firstFiniteNumber(this.getDb().prepare('PRAGMA user_version').get()) ?? 0;
1045
1127
  }
@@ -1101,6 +1183,10 @@ export class SqliteLcmStore {
1101
1183
  close(): void {
1102
1184
  this.clearDeferredPartUpdateTimer();
1103
1185
  this.pendingPartUpdates.clear();
1186
+ if (this.deferredInitTimer) {
1187
+ clearTimeout(this.deferredInitTimer);
1188
+ this.deferredInitTimer = undefined;
1189
+ }
1104
1190
  if (!this.db) return;
1105
1191
  this.db.close();
1106
1192
  this.db = undefined;
@@ -1108,65 +1194,75 @@ export class SqliteLcmStore {
1108
1194
  }
1109
1195
 
1110
1196
  async capture(event: Event): Promise<void> {
1111
- await this.ensureDbReady();
1112
-
1113
- const normalized = normalizeEvent(event);
1114
- if (!normalized) return;
1197
+ return this.withStoreActivity(async () => {
1198
+ const normalized = normalizeEvent(event);
1199
+ if (!normalized) return;
1115
1200
 
1116
- if (this.shouldRecordEvent(normalized.type)) {
1117
- this.writeEvent(normalized);
1118
- }
1201
+ const shouldRecord = this.shouldRecordEvent(normalized.type);
1202
+ const shouldPersistSession =
1203
+ Boolean(normalized.sessionID) && this.shouldPersistSessionForEvent(normalized.type);
1204
+ if (!shouldRecord && !shouldPersistSession) return;
1119
1205
 
1120
- if (!normalized.sessionID) return;
1121
- if (!this.shouldPersistSessionForEvent(normalized.type)) return;
1206
+ await this.ensureDeferredInitComplete();
1122
1207
 
1123
- const session =
1124
- resolveCaptureHydrationMode() === 'targeted'
1125
- ? this.readSessionForCaptureSync(normalized)
1126
- : this.readSessionSync(normalized.sessionID);
1127
- const previousParentSessionID = session.parentSessionID;
1128
- const shouldSyncDerivedState = this.shouldSyncDerivedSessionStateForEvent(session, normalized);
1129
- let next = this.applyEvent(session, normalized);
1130
- next.updatedAt = Math.max(next.updatedAt, normalized.timestamp);
1131
- next.eventCount += 1;
1132
- next = this.prepareSessionForPersistence(next);
1208
+ if (shouldRecord) {
1209
+ this.writeEvent(normalized);
1210
+ }
1133
1211
 
1134
- await this.persistCapturedSession(next, normalized);
1212
+ if (!normalized.sessionID || !shouldPersistSession) return;
1135
1213
 
1136
- if (this.shouldRefreshLineageForEvent(normalized.type)) {
1137
- this.refreshAllLineageSync();
1138
- const refreshed = this.readSessionHeaderSync(normalized.sessionID);
1139
- if (refreshed) {
1140
- next = {
1141
- ...next,
1142
- parentSessionID: refreshed.parentSessionID,
1143
- rootSessionID: refreshed.rootSessionID,
1144
- lineageDepth: refreshed.lineageDepth,
1145
- };
1214
+ const session =
1215
+ resolveCaptureHydrationMode() === 'targeted'
1216
+ ? this.readSessionForCaptureSync(normalized)
1217
+ : this.readSessionSync(normalized.sessionID);
1218
+ const previousParentSessionID = session.parentSessionID;
1219
+ const shouldSyncDerivedState = this.shouldSyncDerivedSessionStateForEvent(
1220
+ session,
1221
+ normalized,
1222
+ );
1223
+ let next = this.applyEvent(session, normalized);
1224
+ next.updatedAt = Math.max(next.updatedAt, normalized.timestamp);
1225
+ next.eventCount += 1;
1226
+ next = this.prepareSessionForPersistence(next);
1227
+
1228
+ await this.persistCapturedSession(next, normalized);
1229
+
1230
+ if (this.shouldRefreshLineageForEvent(normalized.type)) {
1231
+ this.refreshAllLineageSync();
1232
+ const refreshed = this.readSessionHeaderSync(normalized.sessionID);
1233
+ if (refreshed) {
1234
+ next = {
1235
+ ...next,
1236
+ parentSessionID: refreshed.parentSessionID,
1237
+ rootSessionID: refreshed.rootSessionID,
1238
+ lineageDepth: refreshed.lineageDepth,
1239
+ };
1240
+ }
1146
1241
  }
1147
- }
1148
1242
 
1149
- if (shouldSyncDerivedState) {
1150
- this.syncDerivedSessionStateSync(this.readSessionSync(normalized.sessionID));
1151
- }
1243
+ if (shouldSyncDerivedState) {
1244
+ this.syncDerivedSessionStateSync(this.readSessionSync(normalized.sessionID));
1245
+ }
1152
1246
 
1153
- if (
1154
- this.shouldSyncDerivedLineageSubtree(
1155
- normalized.type,
1156
- previousParentSessionID,
1157
- next.parentSessionID,
1158
- )
1159
- ) {
1160
- this.syncDerivedLineageSubtreeSync(normalized.sessionID, true);
1161
- }
1247
+ if (
1248
+ this.shouldSyncDerivedLineageSubtree(
1249
+ normalized.type,
1250
+ previousParentSessionID,
1251
+ next.parentSessionID,
1252
+ )
1253
+ ) {
1254
+ this.syncDerivedLineageSubtreeSync(normalized.sessionID, true);
1255
+ }
1162
1256
 
1163
- if (this.shouldCleanupOrphanBlobsForEvent(normalized.type)) {
1164
- this.deleteOrphanArtifactBlobsSync();
1165
- }
1257
+ if (this.shouldCleanupOrphanBlobsForEvent(normalized.type)) {
1258
+ this.deleteOrphanArtifactBlobsSync();
1259
+ }
1260
+ });
1166
1261
  }
1167
1262
 
1168
1263
  async stats(): Promise<StoreStats> {
1169
1264
  await this.prepareForRead();
1265
+ await this.ensureDeferredInitComplete();
1170
1266
  const db = this.getDb();
1171
1267
  const totalRow = validateRow<{ count: number; latest: number | null }>(
1172
1268
  db.prepare('SELECT COUNT(*) AS count, MAX(ts) AS latest FROM events').get(),
@@ -1283,13 +1379,14 @@ export class SqliteLcmStore {
1283
1379
  scope?: string;
1284
1380
  limit?: number;
1285
1381
  }): Promise<SearchResult[]> {
1286
- await this.prepareForRead();
1287
1382
  const resolvedScope = this.resolveConfiguredScope('grep', input.scope, input.sessionID);
1288
- const sessionIDs = this.resolveScopeSessionIDs(resolvedScope, input.sessionID);
1289
1383
  const limit = input.limit ?? 5;
1290
1384
  const needle = input.query.trim();
1291
1385
  if (!needle) return [];
1292
1386
 
1387
+ await this.prepareForRead();
1388
+ const sessionIDs = this.resolveScopeSessionIDs(resolvedScope, input.sessionID);
1389
+
1293
1390
  const ftsResults = this.searchWithFts(needle, sessionIDs, limit);
1294
1391
  if (ftsResults.length > 0) return ftsResults;
1295
1392
  return this.searchByScan(needle.toLowerCase(), sessionIDs, limit);
@@ -2516,71 +2613,73 @@ export class SqliteLcmStore {
2516
2613
  vacuum?: boolean;
2517
2614
  limit?: number;
2518
2615
  }): Promise<string> {
2519
- await this.prepareForRead();
2520
- const apply = input?.apply ?? false;
2521
- const vacuum = input?.vacuum ?? true;
2522
- const limit = clamp(input?.limit ?? 10, 1, 50);
2523
- const candidates = this.readPrunableEventTypeCountsSync();
2524
- const candidateEvents = candidates.reduce((sum, row) => sum + row.count, 0);
2525
- const beforeSizes = await this.readStoreFileSizes();
2616
+ return this.withStoreActivity(async () => {
2617
+ await this.prepareForRead();
2618
+ const apply = input?.apply ?? false;
2619
+ const vacuum = input?.vacuum ?? true;
2620
+ const limit = clamp(input?.limit ?? 10, 1, 50);
2621
+ const candidates = this.readPrunableEventTypeCountsSync();
2622
+ const candidateEvents = candidates.reduce((sum, row) => sum + row.count, 0);
2623
+ const beforeSizes = await this.readStoreFileSizes();
2624
+
2625
+ if (!apply || candidateEvents === 0) {
2626
+ return [
2627
+ `candidate_events=${candidateEvents}`,
2628
+ `apply=false`,
2629
+ `vacuum_requested=${vacuum}`,
2630
+ `db_bytes=${beforeSizes.dbBytes}`,
2631
+ `wal_bytes=${beforeSizes.walBytes}`,
2632
+ `shm_bytes=${beforeSizes.shmBytes}`,
2633
+ `total_bytes=${beforeSizes.totalBytes}`,
2634
+ ...(candidates.length > 0
2635
+ ? [
2636
+ 'candidate_event_types:',
2637
+ ...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
2638
+ ]
2639
+ : ['candidate_event_types:', '- none']),
2640
+ ].join('\n');
2641
+ }
2642
+
2643
+ const eventTypes = candidates.map((row) => row.eventType);
2644
+ if (eventTypes.length > 0) {
2645
+ const placeholders = eventTypes.map(() => '?').join(', ');
2646
+ this.getDb()
2647
+ .prepare(`DELETE FROM events WHERE event_type IN (${placeholders})`)
2648
+ .run(...eventTypes);
2649
+ }
2650
+
2651
+ let vacuumApplied = false;
2652
+ this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
2653
+ if (vacuum) {
2654
+ this.getDb().exec('VACUUM');
2655
+ this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
2656
+ vacuumApplied = true;
2657
+ }
2658
+
2659
+ const afterSizes = await this.readStoreFileSizes();
2526
2660
 
2527
- if (!apply || candidateEvents === 0) {
2528
2661
  return [
2529
2662
  `candidate_events=${candidateEvents}`,
2530
- `apply=false`,
2663
+ `deleted_events=${candidateEvents}`,
2664
+ `apply=true`,
2531
2665
  `vacuum_requested=${vacuum}`,
2532
- `db_bytes=${beforeSizes.dbBytes}`,
2533
- `wal_bytes=${beforeSizes.walBytes}`,
2534
- `shm_bytes=${beforeSizes.shmBytes}`,
2535
- `total_bytes=${beforeSizes.totalBytes}`,
2666
+ `vacuum_applied=${vacuumApplied}`,
2667
+ `db_bytes_before=${beforeSizes.dbBytes}`,
2668
+ `wal_bytes_before=${beforeSizes.walBytes}`,
2669
+ `shm_bytes_before=${beforeSizes.shmBytes}`,
2670
+ `total_bytes_before=${beforeSizes.totalBytes}`,
2671
+ `db_bytes_after=${afterSizes.dbBytes}`,
2672
+ `wal_bytes_after=${afterSizes.walBytes}`,
2673
+ `shm_bytes_after=${afterSizes.shmBytes}`,
2674
+ `total_bytes_after=${afterSizes.totalBytes}`,
2536
2675
  ...(candidates.length > 0
2537
2676
  ? [
2538
- 'candidate_event_types:',
2677
+ 'deleted_event_types:',
2539
2678
  ...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
2540
2679
  ]
2541
- : ['candidate_event_types:', '- none']),
2680
+ : ['deleted_event_types:', '- none']),
2542
2681
  ].join('\n');
2543
- }
2544
-
2545
- const eventTypes = candidates.map((row) => row.eventType);
2546
- if (eventTypes.length > 0) {
2547
- const placeholders = eventTypes.map(() => '?').join(', ');
2548
- this.getDb()
2549
- .prepare(`DELETE FROM events WHERE event_type IN (${placeholders})`)
2550
- .run(...eventTypes);
2551
- }
2552
-
2553
- let vacuumApplied = false;
2554
- this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
2555
- if (vacuum) {
2556
- this.getDb().exec('VACUUM');
2557
- this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
2558
- vacuumApplied = true;
2559
- }
2560
-
2561
- const afterSizes = await this.readStoreFileSizes();
2562
-
2563
- return [
2564
- `candidate_events=${candidateEvents}`,
2565
- `deleted_events=${candidateEvents}`,
2566
- `apply=true`,
2567
- `vacuum_requested=${vacuum}`,
2568
- `vacuum_applied=${vacuumApplied}`,
2569
- `db_bytes_before=${beforeSizes.dbBytes}`,
2570
- `wal_bytes_before=${beforeSizes.walBytes}`,
2571
- `shm_bytes_before=${beforeSizes.shmBytes}`,
2572
- `total_bytes_before=${beforeSizes.totalBytes}`,
2573
- `db_bytes_after=${afterSizes.dbBytes}`,
2574
- `wal_bytes_after=${afterSizes.walBytes}`,
2575
- `shm_bytes_after=${afterSizes.shmBytes}`,
2576
- `total_bytes_after=${afterSizes.totalBytes}`,
2577
- ...(candidates.length > 0
2578
- ? [
2579
- 'deleted_event_types:',
2580
- ...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
2581
- ]
2582
- : ['deleted_event_types:', '- none']),
2583
- ].join('\n');
2682
+ });
2584
2683
  }
2585
2684
 
2586
2685
  async retentionReport(input?: {
@@ -2750,24 +2849,26 @@ export class SqliteLcmStore {
2750
2849
  sessionID?: string;
2751
2850
  scope?: string;
2752
2851
  }): Promise<string> {
2753
- await this.prepareForRead();
2754
- return exportStoreSnapshot(
2755
- {
2756
- workspaceDirectory: this.workspaceDirectory,
2757
- normalizeScope: this.normalizeScope.bind(this),
2758
- resolveScopeSessionIDs: this.resolveScopeSessionIDs.bind(this),
2759
- readScopedSessionRowsSync: this.readScopedSessionRowsSync.bind(this),
2760
- readScopedMessageRowsSync: this.readScopedMessageRowsSync.bind(this),
2761
- readScopedPartRowsSync: this.readScopedPartRowsSync.bind(this),
2762
- readScopedResumeRowsSync: this.readScopedResumeRowsSync.bind(this),
2763
- readScopedArtifactRowsSync: this.readScopedArtifactRowsSync.bind(this),
2764
- readScopedArtifactBlobRowsSync: this.readScopedArtifactBlobRowsSync.bind(this),
2765
- readScopedSummaryRowsSync: this.readScopedSummaryRowsSync.bind(this),
2766
- readScopedSummaryEdgeRowsSync: this.readScopedSummaryEdgeRowsSync.bind(this),
2767
- readScopedSummaryStateRowsSync: this.readScopedSummaryStateRowsSync.bind(this),
2768
- },
2769
- input,
2770
- );
2852
+ return this.withStoreActivity(async () => {
2853
+ await this.prepareForRead();
2854
+ return exportStoreSnapshot(
2855
+ {
2856
+ workspaceDirectory: this.workspaceDirectory,
2857
+ normalizeScope: this.normalizeScope.bind(this),
2858
+ resolveScopeSessionIDs: this.resolveScopeSessionIDs.bind(this),
2859
+ readScopedSessionRowsSync: this.readScopedSessionRowsSync.bind(this),
2860
+ readScopedMessageRowsSync: this.readScopedMessageRowsSync.bind(this),
2861
+ readScopedPartRowsSync: this.readScopedPartRowsSync.bind(this),
2862
+ readScopedResumeRowsSync: this.readScopedResumeRowsSync.bind(this),
2863
+ readScopedArtifactRowsSync: this.readScopedArtifactRowsSync.bind(this),
2864
+ readScopedArtifactBlobRowsSync: this.readScopedArtifactBlobRowsSync.bind(this),
2865
+ readScopedSummaryRowsSync: this.readScopedSummaryRowsSync.bind(this),
2866
+ readScopedSummaryEdgeRowsSync: this.readScopedSummaryEdgeRowsSync.bind(this),
2867
+ readScopedSummaryStateRowsSync: this.readScopedSummaryStateRowsSync.bind(this),
2868
+ },
2869
+ input,
2870
+ );
2871
+ });
2771
2872
  }
2772
2873
 
2773
2874
  async importSnapshot(input: {
@@ -2775,31 +2876,35 @@ export class SqliteLcmStore {
2775
2876
  mode?: 'replace' | 'merge';
2776
2877
  worktreeMode?: SnapshotWorktreeMode;
2777
2878
  }): Promise<string> {
2778
- await this.prepareForRead();
2779
- return importStoreSnapshot(
2780
- {
2781
- workspaceDirectory: this.workspaceDirectory,
2782
- getDb: () => this.getDb(),
2783
- clearSessionDataSync: this.clearSessionDataSync.bind(this),
2784
- backfillArtifactBlobsSync: this.backfillArtifactBlobsSync.bind(this),
2785
- refreshAllLineageSync: this.refreshAllLineageSync.bind(this),
2786
- syncAllDerivedSessionStateSync: this.syncAllDerivedSessionStateSync.bind(this),
2787
- refreshSearchIndexesSync: this.refreshSearchIndexesSync.bind(this),
2788
- },
2789
- input,
2790
- );
2879
+ return this.withStoreActivity(async () => {
2880
+ await this.prepareForRead();
2881
+ return importStoreSnapshot(
2882
+ {
2883
+ workspaceDirectory: this.workspaceDirectory,
2884
+ getDb: () => this.getDb(),
2885
+ clearSessionDataSync: this.clearSessionDataSync.bind(this),
2886
+ backfillArtifactBlobsSync: this.backfillArtifactBlobsSync.bind(this),
2887
+ refreshAllLineageSync: this.refreshAllLineageSync.bind(this),
2888
+ syncAllDerivedSessionStateSync: this.syncAllDerivedSessionStateSync.bind(this),
2889
+ refreshSearchIndexesSync: this.refreshSearchIndexesSync.bind(this),
2890
+ },
2891
+ input,
2892
+ );
2893
+ });
2791
2894
  }
2792
2895
 
2793
2896
  async resume(sessionID?: string): Promise<string> {
2794
- await this.prepareForRead();
2795
- const resolvedSessionID = sessionID ?? this.latestSessionIDSync();
2796
- if (!resolvedSessionID) return 'No stored resume snapshots yet.';
2897
+ return this.withStoreActivity(async () => {
2898
+ await this.prepareForRead();
2899
+ const resolvedSessionID = sessionID ?? this.latestSessionIDSync();
2900
+ if (!resolvedSessionID) return 'No stored resume snapshots yet.';
2797
2901
 
2798
- const existing = this.getResumeSync(resolvedSessionID);
2799
- if (existing && !this.isManagedResumeNote(existing)) return existing;
2902
+ const existing = this.getResumeSync(resolvedSessionID);
2903
+ if (existing && !this.isManagedResumeNote(existing)) return existing;
2800
2904
 
2801
- const generated = await this.buildCompactionContext(resolvedSessionID);
2802
- return generated ?? existing ?? 'No stored resume snapshot for that session.';
2905
+ const generated = await this.buildCompactionContext(resolvedSessionID);
2906
+ return generated ?? existing ?? 'No stored resume snapshot for that session.';
2907
+ });
2803
2908
  }
2804
2909
 
2805
2910
  async expand(input: {
@@ -2883,53 +2988,60 @@ export class SqliteLcmStore {
2883
2988
  }
2884
2989
 
2885
2990
  async transformMessages(messages: ConversationMessage[]): Promise<boolean> {
2886
- await this.prepareForRead();
2887
- if (messages.length < this.options.minMessagesForTransform) return false;
2991
+ return this.withStoreActivity(async () => {
2992
+ if (messages.length < this.options.minMessagesForTransform) return false;
2888
2993
 
2889
- const window = resolveArchiveTransformWindow(messages, this.options.freshTailMessages);
2890
- if (!window) return false;
2994
+ const window = resolveArchiveTransformWindow(messages, this.options.freshTailMessages);
2995
+ if (!window) return false;
2891
2996
 
2892
- const { anchor, archived, recent } = window;
2997
+ await this.prepareForRead();
2893
2998
 
2894
- const roots = this.ensureSummaryGraphSync(anchor.info.sessionID, archived);
2895
- if (roots.length === 0) return false;
2999
+ const { anchor, archived, recent } = window;
2896
3000
 
2897
- const summary = buildActiveSummaryText(roots, archived.length, this.options.summaryCharBudget);
2898
- const retrieval = await this.buildAutomaticRetrievalContext(
2899
- anchor.info.sessionID,
2900
- recent,
2901
- anchor,
2902
- );
2903
- for (const message of archived) {
2904
- this.compactMessageInPlace(message);
2905
- }
3001
+ const roots = this.ensureSummaryGraphSync(anchor.info.sessionID, archived);
3002
+ if (roots.length === 0) return false;
2906
3003
 
2907
- anchor.parts = anchor.parts.filter(
2908
- (part) => !isSyntheticLcmTextPart(part, ['archive-summary', 'retrieved-context']),
2909
- );
2910
- const syntheticParts: Part[] = [];
2911
- if (retrieval) {
3004
+ const summary = buildActiveSummaryText(
3005
+ roots,
3006
+ archived.length,
3007
+ this.options.summaryCharBudget,
3008
+ );
3009
+ const retrieval = await this.buildAutomaticRetrievalContext(
3010
+ anchor.info.sessionID,
3011
+ recent,
3012
+ anchor,
3013
+ );
3014
+ for (const message of archived) {
3015
+ this.compactMessageInPlace(message);
3016
+ }
3017
+
3018
+ anchor.parts = anchor.parts.filter(
3019
+ (part) => !isSyntheticLcmTextPart(part, ['archive-summary', 'retrieved-context']),
3020
+ );
3021
+ const syntheticParts: Part[] = [];
3022
+ if (retrieval) {
3023
+ syntheticParts.push({
3024
+ id: `lcm-memory-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
3025
+ sessionID: anchor.info.sessionID,
3026
+ messageID: anchor.info.id,
3027
+ type: 'text',
3028
+ text: retrieval,
3029
+ synthetic: true,
3030
+ metadata: { opencodeLcm: 'retrieved-context' },
3031
+ });
3032
+ }
2912
3033
  syntheticParts.push({
2913
- id: `lcm-memory-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
3034
+ id: `lcm-summary-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
2914
3035
  sessionID: anchor.info.sessionID,
2915
3036
  messageID: anchor.info.id,
2916
3037
  type: 'text',
2917
- text: retrieval,
3038
+ text: summary,
2918
3039
  synthetic: true,
2919
- metadata: { opencodeLcm: 'retrieved-context' },
3040
+ metadata: { opencodeLcm: 'archive-summary' },
2920
3041
  });
2921
- }
2922
- syntheticParts.push({
2923
- id: `lcm-summary-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
2924
- sessionID: anchor.info.sessionID,
2925
- messageID: anchor.info.id,
2926
- type: 'text',
2927
- text: summary,
2928
- synthetic: true,
2929
- metadata: { opencodeLcm: 'archive-summary' },
3042
+ anchor.parts.push(...syntheticParts);
3043
+ return true;
2930
3044
  });
2931
- anchor.parts.push(...syntheticParts);
2932
- return true;
2933
3045
  }
2934
3046
 
2935
3047
  systemHint(): string | undefined {
@@ -4240,17 +4352,40 @@ export class SqliteLcmStore {
4240
4352
  ): { rootSessionID: string; lineageDepth: number } {
4241
4353
  if (!parentSessionID) return { rootSessionID: sessionID, lineageDepth: 0 };
4242
4354
 
4243
- const parent = this.getDb()
4244
- .prepare('SELECT root_session_id, lineage_depth FROM sessions WHERE session_id = ?')
4245
- .get(parentSessionID) as
4246
- | { root_session_id: string | null; lineage_depth: number | null }
4247
- | undefined;
4355
+ const seen = new Set<string>([sessionID]);
4356
+ let currentSessionID: string | undefined = parentSessionID;
4357
+ let lineageDepth = 1;
4248
4358
 
4249
- if (!parent) return { rootSessionID: parentSessionID, lineageDepth: 1 };
4250
- return {
4251
- rootSessionID: parent.root_session_id ?? parentSessionID,
4252
- lineageDepth: (parent.lineage_depth ?? 0) + 1,
4253
- };
4359
+ while (currentSessionID && !seen.has(currentSessionID)) {
4360
+ seen.add(currentSessionID);
4361
+ const parent = this.getDb()
4362
+ .prepare(
4363
+ 'SELECT parent_session_id, root_session_id, lineage_depth FROM sessions WHERE session_id = ?',
4364
+ )
4365
+ .get(currentSessionID) as
4366
+ | {
4367
+ parent_session_id: string | null;
4368
+ root_session_id: string | null;
4369
+ lineage_depth: number | null;
4370
+ }
4371
+ | undefined;
4372
+
4373
+ if (!parent) return { rootSessionID: currentSessionID, lineageDepth };
4374
+ if (parent.root_session_id && parent.lineage_depth !== null) {
4375
+ return {
4376
+ rootSessionID: parent.root_session_id,
4377
+ lineageDepth: parent.lineage_depth + lineageDepth,
4378
+ };
4379
+ }
4380
+ if (!parent.parent_session_id) {
4381
+ return { rootSessionID: currentSessionID, lineageDepth };
4382
+ }
4383
+
4384
+ currentSessionID = parent.parent_session_id;
4385
+ lineageDepth += 1;
4386
+ }
4387
+
4388
+ return { rootSessionID: parentSessionID, lineageDepth };
4254
4389
  }
4255
4390
 
4256
4391
  private applyEvent(session: NormalizedSession, event: CapturedEvent): NormalizedSession {
@@ -4322,31 +4457,44 @@ export class SqliteLcmStore {
4322
4457
  return row?.note;
4323
4458
  }
4324
4459
 
4325
- private readSessionHeaderSync(sessionID: string): NormalizedSession | undefined {
4326
- const row = safeQueryOne<SessionRow>(
4327
- this.getDb().prepare('SELECT * FROM sessions WHERE session_id = ?'),
4328
- [sessionID],
4329
- 'readSessionHeaderSync',
4330
- );
4331
- if (!row) return undefined;
4460
+ private materializeSessionRow(
4461
+ row: SessionRow,
4462
+ messages: ConversationMessage[] = [],
4463
+ ): NormalizedSession {
4464
+ const parentSessionID = row.parent_session_id ?? undefined;
4465
+ const derivedLineage =
4466
+ row.root_session_id === null || row.lineage_depth === null
4467
+ ? this.resolveLineageSync(row.session_id, parentSessionID)
4468
+ : undefined;
4332
4469
 
4333
4470
  return {
4334
4471
  sessionID: row.session_id,
4335
4472
  title: row.title ?? undefined,
4336
4473
  directory: row.session_directory ?? undefined,
4337
- parentSessionID: row.parent_session_id ?? undefined,
4338
- rootSessionID: row.root_session_id ?? undefined,
4339
- lineageDepth: row.lineage_depth ?? undefined,
4474
+ parentSessionID,
4475
+ rootSessionID: row.root_session_id ?? derivedLineage?.rootSessionID,
4476
+ lineageDepth: row.lineage_depth ?? derivedLineage?.lineageDepth,
4340
4477
  pinned: Boolean(row.pinned),
4341
4478
  pinReason: row.pin_reason ?? undefined,
4342
4479
  updatedAt: row.updated_at,
4343
4480
  compactedAt: row.compacted_at ?? undefined,
4344
4481
  deleted: Boolean(row.deleted),
4345
4482
  eventCount: row.event_count,
4346
- messages: [],
4483
+ messages,
4347
4484
  };
4348
4485
  }
4349
4486
 
4487
+ private readSessionHeaderSync(sessionID: string): NormalizedSession | undefined {
4488
+ const row = safeQueryOne<SessionRow>(
4489
+ this.getDb().prepare('SELECT * FROM sessions WHERE session_id = ?'),
4490
+ [sessionID],
4491
+ 'readSessionHeaderSync',
4492
+ );
4493
+ if (!row) return undefined;
4494
+
4495
+ return this.materializeSessionRow(row);
4496
+ }
4497
+
4350
4498
  private clearSessionDataSync(sessionID: string): void {
4351
4499
  const db = this.getDb();
4352
4500
  db.prepare('DELETE FROM message_fts WHERE session_id = ?').run(sessionID);
@@ -4507,21 +4655,7 @@ export class SqliteLcmStore {
4507
4655
  if (!row) {
4508
4656
  return { ...emptySession(sessionID), messages };
4509
4657
  }
4510
- return {
4511
- sessionID: row.session_id,
4512
- title: row.title ?? undefined,
4513
- directory: row.session_directory ?? undefined,
4514
- parentSessionID: row.parent_session_id ?? undefined,
4515
- rootSessionID: row.root_session_id ?? undefined,
4516
- lineageDepth: row.lineage_depth ?? undefined,
4517
- pinned: Boolean(row.pinned),
4518
- pinReason: row.pin_reason ?? undefined,
4519
- updatedAt: row.updated_at,
4520
- compactedAt: row.compacted_at ?? undefined,
4521
- deleted: Boolean(row.deleted),
4522
- eventCount: row.event_count,
4523
- messages,
4524
- };
4658
+ return this.materializeSessionRow(row, messages);
4525
4659
  });
4526
4660
  }
4527
4661
 
@@ -4568,21 +4702,7 @@ export class SqliteLcmStore {
4568
4702
  return { ...emptySession(sessionID), messages };
4569
4703
  }
4570
4704
 
4571
- return {
4572
- sessionID: row.session_id,
4573
- title: row.title ?? undefined,
4574
- directory: row.session_directory ?? undefined,
4575
- parentSessionID: row.parent_session_id ?? undefined,
4576
- rootSessionID: row.root_session_id ?? undefined,
4577
- lineageDepth: row.lineage_depth ?? undefined,
4578
- pinned: Boolean(row.pinned),
4579
- pinReason: row.pin_reason ?? undefined,
4580
- updatedAt: row.updated_at,
4581
- compactedAt: row.compacted_at ?? undefined,
4582
- deleted: Boolean(row.deleted),
4583
- eventCount: row.event_count,
4584
- messages,
4585
- };
4705
+ return this.materializeSessionRow(row, messages);
4586
4706
  }
4587
4707
 
4588
4708
  private prepareSessionForPersistence(session: NormalizedSession): NormalizedSession {