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/src/store.ts CHANGED
@@ -303,10 +303,6 @@ function readSessionStats(db: SqlDatabaseLike): {
303
303
  };
304
304
  }
305
305
 
306
- type ReadSessionOptions = {
307
- artifactMessageIDs?: string[];
308
- };
309
-
310
306
  function extractSessionID(event: unknown): string | undefined {
311
307
  const record = asRecord(event);
312
308
  if (!record) return undefined;
@@ -364,6 +360,15 @@ function compareMessages(a: ConversationMessage, b: ConversationMessage): number
364
360
  return a.info.time.created - b.info.time.created;
365
361
  }
366
362
 
363
+ function emptySession(sessionID: string): NormalizedSession {
364
+ return {
365
+ sessionID,
366
+ updatedAt: 0,
367
+ eventCount: 0,
368
+ messages: [],
369
+ };
370
+ }
371
+
367
372
  function buildSummaryNodeID(sessionID: string, level: number, slot: number): string {
368
373
  return `sum_${hashContent(`summary:${sessionID}`).slice(0, 12)}_l${level}_p${slot}`;
369
374
  }
@@ -523,12 +528,23 @@ function logStartupPhase(phase: string, context?: Record<string, unknown>): void
523
528
  getLogger().info(`startup phase: ${phase}`, context);
524
529
  }
525
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
+
526
537
  type SqliteRuntime = 'bun' | 'node';
527
538
  type SqliteRuntimeOptions = {
528
539
  envOverride?: string | undefined;
529
540
  isBunRuntime?: boolean;
530
541
  platform?: string | undefined;
531
542
  };
543
+ type CaptureHydrationMode = 'full' | 'targeted';
544
+ type CaptureHydrationOptions = {
545
+ isBunRuntime?: boolean;
546
+ platform?: string | undefined;
547
+ };
532
548
 
533
549
  function normalizeSqliteRuntimeOverride(value: string | undefined): SqliteRuntime | 'auto' {
534
550
  const normalized = value?.trim().toLowerCase();
@@ -554,6 +570,20 @@ export function resolveSqliteRuntime(options?: SqliteRuntimeOptions): SqliteRunt
554
570
  return resolveSqliteRuntimeCandidates(options)[0];
555
571
  }
556
572
 
573
+ export function resolveCaptureHydrationMode(
574
+ options?: CaptureHydrationOptions,
575
+ ): CaptureHydrationMode {
576
+ const isBunRuntime =
577
+ options?.isBunRuntime ?? (typeof globalThis === 'object' && 'Bun' in globalThis);
578
+ const platform = options?.platform ?? process.platform;
579
+
580
+ // The targeted fresh-tail capture path is safe under Node, but the bundled
581
+ // Bun runtime on Windows has been the only environment where users have
582
+ // reported native crashes in this hot path. Keep the older full-session
583
+ // hydration there until Bun/Windows is proven stable again.
584
+ return isBunRuntime && platform === 'win32' ? 'full' : 'targeted';
585
+ }
586
+
557
587
  function isSqliteRuntimeImportError(runtime: SqliteRuntime, error: unknown): boolean {
558
588
  const code =
559
589
  typeof error === 'object' && error && 'code' in error && typeof error.code === 'string'
@@ -683,6 +713,10 @@ export class SqliteLcmStore {
683
713
  private readonly workspaceDirectory: string;
684
714
  private db?: SqlDatabaseLike;
685
715
  private dbReadyPromise?: Promise<void>;
716
+ private deferredInitTimer?: ReturnType<typeof setTimeout>;
717
+ private deferredInitPromise?: Promise<void>;
718
+ private deferredInitRequested = false;
719
+ private activeOperationCount = 0;
686
720
  private readonly pendingPartUpdates = new Map<string, Event>();
687
721
  private pendingPartUpdateTimer?: ReturnType<typeof setTimeout>;
688
722
  private pendingPartUpdateFlushPromise?: Promise<void>;
@@ -701,8 +735,27 @@ export class SqliteLcmStore {
701
735
  await mkdir(this.baseDir, { recursive: true });
702
736
  }
703
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
+
704
756
  private async prepareForRead(): Promise<void> {
705
757
  await this.ensureDbReady();
758
+ await this.waitForDeferredInitIfRunning();
706
759
  await this.flushDeferredPartUpdates();
707
760
  }
708
761
 
@@ -713,15 +766,7 @@ export class SqliteLcmStore {
713
766
  this.pendingPartUpdateTimer = undefined;
714
767
  void this.flushDeferredPartUpdates();
715
768
  }, SqliteLcmStore.deferredPartUpdateDelayMs);
716
-
717
- if (
718
- typeof this.pendingPartUpdateTimer === 'object' &&
719
- this.pendingPartUpdateTimer &&
720
- 'unref' in this.pendingPartUpdateTimer &&
721
- typeof this.pendingPartUpdateTimer.unref === 'function'
722
- ) {
723
- this.pendingPartUpdateTimer.unref();
724
- }
769
+ unrefTimer(this.pendingPartUpdateTimer);
725
770
  }
726
771
 
727
772
  private clearDeferredPartUpdateTimer(): void {
@@ -766,63 +811,73 @@ export class SqliteLcmStore {
766
811
  }
767
812
 
768
813
  async captureDeferred(event: Event): Promise<void> {
769
- switch (event.type) {
770
- case 'message.part.updated': {
771
- const key = getDeferredPartUpdateKey(event);
772
- if (!key) return await this.capture(event);
773
- this.pendingPartUpdates.set(key, event);
774
- this.scheduleDeferredPartUpdateFlush();
775
- return;
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;
776
841
  }
777
- case 'message.part.removed':
778
- this.clearDeferredPartUpdateForPart(
779
- event.properties.sessionID,
780
- event.properties.messageID,
781
- event.properties.partID,
782
- );
783
- break;
784
- case 'message.removed':
785
- this.clearDeferredPartUpdatesForMessage(
786
- event.properties.sessionID,
787
- event.properties.messageID,
788
- );
789
- break;
790
- case 'session.deleted':
791
- this.clearDeferredPartUpdatesForSession(extractSessionID(event));
792
- break;
793
- default:
794
- break;
795
- }
796
842
 
797
- await this.capture(event);
843
+ await this.capture(event);
844
+ });
798
845
  }
799
846
 
800
847
  async flushDeferredPartUpdates(): Promise<void> {
801
- if (this.pendingPartUpdateFlushPromise) return this.pendingPartUpdateFlushPromise;
802
- if (this.pendingPartUpdates.size === 0) return;
803
-
804
- this.clearDeferredPartUpdateTimer();
805
- this.pendingPartUpdateFlushPromise = (async () => {
806
- while (this.pendingPartUpdates.size > 0) {
807
- const batch = [...this.pendingPartUpdates.values()];
808
- this.pendingPartUpdates.clear();
809
- for (const event of batch) {
810
- await this.capture(event);
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
+ }
811
860
  }
812
- }
813
- })().finally(() => {
814
- this.pendingPartUpdateFlushPromise = undefined;
815
- if (this.pendingPartUpdates.size > 0) this.scheduleDeferredPartUpdateFlush();
816
- });
861
+ })().finally(() => {
862
+ this.pendingPartUpdateFlushPromise = undefined;
863
+ if (this.pendingPartUpdates.size > 0) this.scheduleDeferredPartUpdateFlush();
864
+ });
817
865
 
818
- return this.pendingPartUpdateFlushPromise;
866
+ return this.pendingPartUpdateFlushPromise;
867
+ });
819
868
  }
820
869
 
821
870
  private async ensureDbReady(): Promise<void> {
822
- if (this.db) return;
823
- if (this.dbReadyPromise) return this.dbReadyPromise;
824
- this.dbReadyPromise = this.openAndInitializeDb();
825
- return this.dbReadyPromise;
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();
826
881
  }
827
882
 
828
883
  private async openAndInitializeDb(): Promise<void> {
@@ -1000,8 +1055,6 @@ export class SqliteLcmStore {
1000
1055
  await this.migrateLegacyArtifacts();
1001
1056
  logStartupPhase('open-db:write-schema-version', { schemaVersion: STORE_SCHEMA_VERSION });
1002
1057
  this.writeSchemaVersionSync(STORE_SCHEMA_VERSION);
1003
- logStartupPhase('open-db:deferred-init:start');
1004
- this.completeDeferredInit();
1005
1058
  logStartupPhase('open-db:ready');
1006
1059
  } catch (error) {
1007
1060
  logStartupPhase('open-db:error', {
@@ -1016,6 +1069,59 @@ export class SqliteLcmStore {
1016
1069
 
1017
1070
  private deferredInitCompleted = false;
1018
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
+
1019
1125
  private readSchemaVersionSync(): number {
1020
1126
  return firstFiniteNumber(this.getDb().prepare('PRAGMA user_version').get()) ?? 0;
1021
1127
  }
@@ -1077,6 +1183,10 @@ export class SqliteLcmStore {
1077
1183
  close(): void {
1078
1184
  this.clearDeferredPartUpdateTimer();
1079
1185
  this.pendingPartUpdates.clear();
1186
+ if (this.deferredInitTimer) {
1187
+ clearTimeout(this.deferredInitTimer);
1188
+ this.deferredInitTimer = undefined;
1189
+ }
1080
1190
  if (!this.db) return;
1081
1191
  this.db.close();
1082
1192
  this.db = undefined;
@@ -1084,68 +1194,75 @@ export class SqliteLcmStore {
1084
1194
  }
1085
1195
 
1086
1196
  async capture(event: Event): Promise<void> {
1087
- await this.ensureDbReady();
1088
-
1089
- const normalized = normalizeEvent(event);
1090
- if (!normalized) return;
1197
+ return this.withStoreActivity(async () => {
1198
+ const normalized = normalizeEvent(event);
1199
+ if (!normalized) return;
1091
1200
 
1092
- if (this.shouldRecordEvent(normalized.type)) {
1093
- this.writeEvent(normalized);
1094
- }
1201
+ const shouldRecord = this.shouldRecordEvent(normalized.type);
1202
+ const shouldPersistSession =
1203
+ Boolean(normalized.sessionID) && this.shouldPersistSessionForEvent(normalized.type);
1204
+ if (!shouldRecord && !shouldPersistSession) return;
1095
1205
 
1096
- if (!normalized.sessionID) return;
1097
- if (!this.shouldPersistSessionForEvent(normalized.type)) return;
1206
+ await this.ensureDeferredInitComplete();
1098
1207
 
1099
- const session = this.readSessionSync(normalized.sessionID, {
1100
- artifactMessageIDs: this.captureArtifactHydrationMessageIDs(normalized),
1101
- });
1102
- const previousParentSessionID = session.parentSessionID;
1103
- let next = this.applyEvent(session, normalized);
1104
- next.updatedAt = Math.max(next.updatedAt, normalized.timestamp);
1105
- next.eventCount += 1;
1106
- next = this.prepareSessionForPersistence(next);
1107
- const shouldSyncDerivedState = this.shouldSyncDerivedSessionStateForEvent(
1108
- session,
1109
- next,
1110
- normalized,
1111
- );
1208
+ if (shouldRecord) {
1209
+ this.writeEvent(normalized);
1210
+ }
1112
1211
 
1113
- await this.persistCapturedSession(next, normalized);
1212
+ if (!normalized.sessionID || !shouldPersistSession) return;
1114
1213
 
1115
- if (this.shouldRefreshLineageForEvent(normalized.type)) {
1116
- this.refreshAllLineageSync();
1117
- const refreshed = this.readSessionHeaderSync(normalized.sessionID);
1118
- if (refreshed) {
1119
- next = {
1120
- ...next,
1121
- parentSessionID: refreshed.parentSessionID,
1122
- rootSessionID: refreshed.rootSessionID,
1123
- lineageDepth: refreshed.lineageDepth,
1124
- };
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
+ }
1125
1241
  }
1126
- }
1127
1242
 
1128
- if (shouldSyncDerivedState) {
1129
- this.syncDerivedSessionStateSync(this.readSessionSync(normalized.sessionID));
1130
- }
1243
+ if (shouldSyncDerivedState) {
1244
+ this.syncDerivedSessionStateSync(this.readSessionSync(normalized.sessionID));
1245
+ }
1131
1246
 
1132
- if (
1133
- this.shouldSyncDerivedLineageSubtree(
1134
- normalized.type,
1135
- previousParentSessionID,
1136
- next.parentSessionID,
1137
- )
1138
- ) {
1139
- this.syncDerivedLineageSubtreeSync(normalized.sessionID, true);
1140
- }
1247
+ if (
1248
+ this.shouldSyncDerivedLineageSubtree(
1249
+ normalized.type,
1250
+ previousParentSessionID,
1251
+ next.parentSessionID,
1252
+ )
1253
+ ) {
1254
+ this.syncDerivedLineageSubtreeSync(normalized.sessionID, true);
1255
+ }
1141
1256
 
1142
- if (this.shouldCleanupOrphanBlobsForEvent(normalized.type)) {
1143
- this.deleteOrphanArtifactBlobsSync();
1144
- }
1257
+ if (this.shouldCleanupOrphanBlobsForEvent(normalized.type)) {
1258
+ this.deleteOrphanArtifactBlobsSync();
1259
+ }
1260
+ });
1145
1261
  }
1146
1262
 
1147
1263
  async stats(): Promise<StoreStats> {
1148
1264
  await this.prepareForRead();
1265
+ await this.ensureDeferredInitComplete();
1149
1266
  const db = this.getDb();
1150
1267
  const totalRow = validateRow<{ count: number; latest: number | null }>(
1151
1268
  db.prepare('SELECT COUNT(*) AS count, MAX(ts) AS latest FROM events').get(),
@@ -1262,13 +1379,14 @@ export class SqliteLcmStore {
1262
1379
  scope?: string;
1263
1380
  limit?: number;
1264
1381
  }): Promise<SearchResult[]> {
1265
- await this.prepareForRead();
1266
1382
  const resolvedScope = this.resolveConfiguredScope('grep', input.scope, input.sessionID);
1267
- const sessionIDs = this.resolveScopeSessionIDs(resolvedScope, input.sessionID);
1268
1383
  const limit = input.limit ?? 5;
1269
1384
  const needle = input.query.trim();
1270
1385
  if (!needle) return [];
1271
1386
 
1387
+ await this.prepareForRead();
1388
+ const sessionIDs = this.resolveScopeSessionIDs(resolvedScope, input.sessionID);
1389
+
1272
1390
  const ftsResults = this.searchWithFts(needle, sessionIDs, limit);
1273
1391
  if (ftsResults.length > 0) return ftsResults;
1274
1392
  return this.searchByScan(needle.toLowerCase(), sessionIDs, limit);
@@ -1645,70 +1763,58 @@ export class SqliteLcmStore {
1645
1763
  );
1646
1764
  }
1647
1765
 
1648
- private captureArtifactHydrationMessageIDs(event: CapturedEvent): string[] {
1649
- const payload = event.payload as Event;
1650
-
1651
- switch (payload.type) {
1652
- case 'message.updated':
1653
- return [payload.properties.info.id];
1654
- case 'message.part.updated':
1655
- return [payload.properties.part.messageID];
1656
- case 'message.part.removed':
1657
- return [payload.properties.messageID];
1658
- default:
1659
- return [];
1660
- }
1661
- }
1662
-
1663
- private archivedMessageIDs(messages: ConversationMessage[]): string[] {
1664
- return this.getArchivedMessages(messages).map((message) => message.info.id);
1665
- }
1666
-
1667
- private didArchivedMessagesChange(
1668
- before: ConversationMessage[],
1669
- after: ConversationMessage[],
1670
- ): boolean {
1671
- const beforeIDs = this.archivedMessageIDs(before);
1672
- const afterIDs = this.archivedMessageIDs(after);
1673
- if (beforeIDs.length !== afterIDs.length) return true;
1674
- return beforeIDs.some((messageID, index) => messageID !== afterIDs[index]);
1675
- }
1676
-
1677
- private isArchivedMessage(messages: ConversationMessage[], messageID?: string): boolean {
1678
- if (!messageID) return false;
1679
- return this.archivedMessageIDs(messages).includes(messageID);
1680
- }
1681
-
1682
1766
  private shouldSyncDerivedSessionStateForEvent(
1683
- previous: NormalizedSession,
1684
- next: NormalizedSession,
1767
+ session: NormalizedSession,
1685
1768
  event: CapturedEvent,
1686
1769
  ): boolean {
1687
1770
  const payload = event.payload as Event;
1688
1771
 
1689
1772
  switch (payload.type) {
1690
1773
  case 'message.updated': {
1691
- const messageID = payload.properties.info.id;
1692
- return (
1693
- this.didArchivedMessagesChange(previous.messages, next.messages) ||
1694
- this.isArchivedMessage(previous.messages, messageID) ||
1695
- this.isArchivedMessage(next.messages, messageID)
1774
+ const existing = session.messages.find(
1775
+ (message) => message.info.id === payload.properties.info.id,
1696
1776
  );
1777
+ if (existing) {
1778
+ return this.isMessageArchivedSync(
1779
+ session.sessionID,
1780
+ existing.info.id,
1781
+ existing.info.time.created,
1782
+ );
1783
+ }
1784
+
1785
+ return this.readMessageCountSync(session.sessionID) >= this.options.freshTailMessages;
1786
+ }
1787
+ case 'message.removed': {
1788
+ const existing = safeQueryOne<{ created_at: number }>(
1789
+ this.getDb().prepare(
1790
+ 'SELECT created_at FROM messages WHERE session_id = ? AND message_id = ?',
1791
+ ),
1792
+ [session.sessionID, payload.properties.messageID],
1793
+ 'shouldSyncDerivedSessionStateForEvent.messageRemoved',
1794
+ );
1795
+ if (!existing) return false;
1796
+ return this.readMessageCountSync(session.sessionID) > this.options.freshTailMessages;
1697
1797
  }
1698
- case 'message.removed':
1699
- return this.didArchivedMessagesChange(previous.messages, next.messages);
1700
1798
  case 'message.part.updated': {
1701
- const messageID = payload.properties.part.messageID;
1702
- return (
1703
- this.isArchivedMessage(previous.messages, messageID) ||
1704
- this.isArchivedMessage(next.messages, messageID)
1799
+ const message = session.messages.find(
1800
+ (entry) => entry.info.id === payload.properties.part.messageID,
1801
+ );
1802
+ if (!message) return false;
1803
+ return this.isMessageArchivedSync(
1804
+ session.sessionID,
1805
+ message.info.id,
1806
+ message.info.time.created,
1705
1807
  );
1706
1808
  }
1707
1809
  case 'message.part.removed': {
1708
- const messageID = payload.properties.messageID;
1709
- return (
1710
- this.isArchivedMessage(previous.messages, messageID) ||
1711
- this.isArchivedMessage(next.messages, messageID)
1810
+ const message = session.messages.find(
1811
+ (entry) => entry.info.id === payload.properties.messageID,
1812
+ );
1813
+ if (!message) return false;
1814
+ return this.isMessageArchivedSync(
1815
+ session.sessionID,
1816
+ message.info.id,
1817
+ message.info.time.created,
1712
1818
  );
1713
1819
  }
1714
1820
  default:
@@ -2507,71 +2613,73 @@ export class SqliteLcmStore {
2507
2613
  vacuum?: boolean;
2508
2614
  limit?: number;
2509
2615
  }): Promise<string> {
2510
- await this.prepareForRead();
2511
- const apply = input?.apply ?? false;
2512
- const vacuum = input?.vacuum ?? true;
2513
- const limit = clamp(input?.limit ?? 10, 1, 50);
2514
- const candidates = this.readPrunableEventTypeCountsSync();
2515
- const candidateEvents = candidates.reduce((sum, row) => sum + row.count, 0);
2516
- const beforeSizes = await this.readStoreFileSizes();
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();
2517
2660
 
2518
- if (!apply || candidateEvents === 0) {
2519
2661
  return [
2520
2662
  `candidate_events=${candidateEvents}`,
2521
- `apply=false`,
2663
+ `deleted_events=${candidateEvents}`,
2664
+ `apply=true`,
2522
2665
  `vacuum_requested=${vacuum}`,
2523
- `db_bytes=${beforeSizes.dbBytes}`,
2524
- `wal_bytes=${beforeSizes.walBytes}`,
2525
- `shm_bytes=${beforeSizes.shmBytes}`,
2526
- `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}`,
2527
2675
  ...(candidates.length > 0
2528
2676
  ? [
2529
- 'candidate_event_types:',
2677
+ 'deleted_event_types:',
2530
2678
  ...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
2531
2679
  ]
2532
- : ['candidate_event_types:', '- none']),
2680
+ : ['deleted_event_types:', '- none']),
2533
2681
  ].join('\n');
2534
- }
2535
-
2536
- const eventTypes = candidates.map((row) => row.eventType);
2537
- if (eventTypes.length > 0) {
2538
- const placeholders = eventTypes.map(() => '?').join(', ');
2539
- this.getDb()
2540
- .prepare(`DELETE FROM events WHERE event_type IN (${placeholders})`)
2541
- .run(...eventTypes);
2542
- }
2543
-
2544
- let vacuumApplied = false;
2545
- this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
2546
- if (vacuum) {
2547
- this.getDb().exec('VACUUM');
2548
- this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
2549
- vacuumApplied = true;
2550
- }
2551
-
2552
- const afterSizes = await this.readStoreFileSizes();
2553
-
2554
- return [
2555
- `candidate_events=${candidateEvents}`,
2556
- `deleted_events=${candidateEvents}`,
2557
- `apply=true`,
2558
- `vacuum_requested=${vacuum}`,
2559
- `vacuum_applied=${vacuumApplied}`,
2560
- `db_bytes_before=${beforeSizes.dbBytes}`,
2561
- `wal_bytes_before=${beforeSizes.walBytes}`,
2562
- `shm_bytes_before=${beforeSizes.shmBytes}`,
2563
- `total_bytes_before=${beforeSizes.totalBytes}`,
2564
- `db_bytes_after=${afterSizes.dbBytes}`,
2565
- `wal_bytes_after=${afterSizes.walBytes}`,
2566
- `shm_bytes_after=${afterSizes.shmBytes}`,
2567
- `total_bytes_after=${afterSizes.totalBytes}`,
2568
- ...(candidates.length > 0
2569
- ? [
2570
- 'deleted_event_types:',
2571
- ...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
2572
- ]
2573
- : ['deleted_event_types:', '- none']),
2574
- ].join('\n');
2682
+ });
2575
2683
  }
2576
2684
 
2577
2685
  async retentionReport(input?: {
@@ -2741,24 +2849,26 @@ export class SqliteLcmStore {
2741
2849
  sessionID?: string;
2742
2850
  scope?: string;
2743
2851
  }): Promise<string> {
2744
- await this.prepareForRead();
2745
- return exportStoreSnapshot(
2746
- {
2747
- workspaceDirectory: this.workspaceDirectory,
2748
- normalizeScope: this.normalizeScope.bind(this),
2749
- resolveScopeSessionIDs: this.resolveScopeSessionIDs.bind(this),
2750
- readScopedSessionRowsSync: this.readScopedSessionRowsSync.bind(this),
2751
- readScopedMessageRowsSync: this.readScopedMessageRowsSync.bind(this),
2752
- readScopedPartRowsSync: this.readScopedPartRowsSync.bind(this),
2753
- readScopedResumeRowsSync: this.readScopedResumeRowsSync.bind(this),
2754
- readScopedArtifactRowsSync: this.readScopedArtifactRowsSync.bind(this),
2755
- readScopedArtifactBlobRowsSync: this.readScopedArtifactBlobRowsSync.bind(this),
2756
- readScopedSummaryRowsSync: this.readScopedSummaryRowsSync.bind(this),
2757
- readScopedSummaryEdgeRowsSync: this.readScopedSummaryEdgeRowsSync.bind(this),
2758
- readScopedSummaryStateRowsSync: this.readScopedSummaryStateRowsSync.bind(this),
2759
- },
2760
- input,
2761
- );
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
+ });
2762
2872
  }
2763
2873
 
2764
2874
  async importSnapshot(input: {
@@ -2766,31 +2876,35 @@ export class SqliteLcmStore {
2766
2876
  mode?: 'replace' | 'merge';
2767
2877
  worktreeMode?: SnapshotWorktreeMode;
2768
2878
  }): Promise<string> {
2769
- await this.prepareForRead();
2770
- return importStoreSnapshot(
2771
- {
2772
- workspaceDirectory: this.workspaceDirectory,
2773
- getDb: () => this.getDb(),
2774
- clearSessionDataSync: this.clearSessionDataSync.bind(this),
2775
- backfillArtifactBlobsSync: this.backfillArtifactBlobsSync.bind(this),
2776
- refreshAllLineageSync: this.refreshAllLineageSync.bind(this),
2777
- syncAllDerivedSessionStateSync: this.syncAllDerivedSessionStateSync.bind(this),
2778
- refreshSearchIndexesSync: this.refreshSearchIndexesSync.bind(this),
2779
- },
2780
- input,
2781
- );
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
+ });
2782
2894
  }
2783
2895
 
2784
2896
  async resume(sessionID?: string): Promise<string> {
2785
- await this.prepareForRead();
2786
- const resolvedSessionID = sessionID ?? this.latestSessionIDSync();
2787
- if (!resolvedSessionID) return 'No stored resume snapshots yet.';
2897
+ return this.withStoreActivity(async () => {
2898
+ await this.prepareForRead();
2899
+ const resolvedSessionID = sessionID ?? this.latestSessionIDSync();
2900
+ if (!resolvedSessionID) return 'No stored resume snapshots yet.';
2788
2901
 
2789
- const existing = this.getResumeSync(resolvedSessionID);
2790
- if (existing && !this.isManagedResumeNote(existing)) return existing;
2902
+ const existing = this.getResumeSync(resolvedSessionID);
2903
+ if (existing && !this.isManagedResumeNote(existing)) return existing;
2791
2904
 
2792
- const generated = await this.buildCompactionContext(resolvedSessionID);
2793
- 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
+ });
2794
2908
  }
2795
2909
 
2796
2910
  async expand(input: {
@@ -2874,53 +2988,60 @@ export class SqliteLcmStore {
2874
2988
  }
2875
2989
 
2876
2990
  async transformMessages(messages: ConversationMessage[]): Promise<boolean> {
2877
- await this.prepareForRead();
2878
- if (messages.length < this.options.minMessagesForTransform) return false;
2991
+ return this.withStoreActivity(async () => {
2992
+ if (messages.length < this.options.minMessagesForTransform) return false;
2879
2993
 
2880
- const window = resolveArchiveTransformWindow(messages, this.options.freshTailMessages);
2881
- if (!window) return false;
2994
+ const window = resolveArchiveTransformWindow(messages, this.options.freshTailMessages);
2995
+ if (!window) return false;
2882
2996
 
2883
- const { anchor, archived, recent } = window;
2997
+ await this.prepareForRead();
2884
2998
 
2885
- const roots = this.ensureSummaryGraphSync(anchor.info.sessionID, archived);
2886
- if (roots.length === 0) return false;
2999
+ const { anchor, archived, recent } = window;
2887
3000
 
2888
- const summary = buildActiveSummaryText(roots, archived.length, this.options.summaryCharBudget);
2889
- const retrieval = await this.buildAutomaticRetrievalContext(
2890
- anchor.info.sessionID,
2891
- recent,
2892
- anchor,
2893
- );
2894
- for (const message of archived) {
2895
- this.compactMessageInPlace(message);
2896
- }
3001
+ const roots = this.ensureSummaryGraphSync(anchor.info.sessionID, archived);
3002
+ if (roots.length === 0) return false;
2897
3003
 
2898
- anchor.parts = anchor.parts.filter(
2899
- (part) => !isSyntheticLcmTextPart(part, ['archive-summary', 'retrieved-context']),
2900
- );
2901
- const syntheticParts: Part[] = [];
2902
- 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
+ }
2903
3033
  syntheticParts.push({
2904
- id: `lcm-memory-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
3034
+ id: `lcm-summary-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
2905
3035
  sessionID: anchor.info.sessionID,
2906
3036
  messageID: anchor.info.id,
2907
3037
  type: 'text',
2908
- text: retrieval,
3038
+ text: summary,
2909
3039
  synthetic: true,
2910
- metadata: { opencodeLcm: 'retrieved-context' },
3040
+ metadata: { opencodeLcm: 'archive-summary' },
2911
3041
  });
2912
- }
2913
- syntheticParts.push({
2914
- id: `lcm-summary-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
2915
- sessionID: anchor.info.sessionID,
2916
- messageID: anchor.info.id,
2917
- type: 'text',
2918
- text: summary,
2919
- synthetic: true,
2920
- metadata: { opencodeLcm: 'archive-summary' },
3042
+ anchor.parts.push(...syntheticParts);
3043
+ return true;
2921
3044
  });
2922
- anchor.parts.push(...syntheticParts);
2923
- return true;
2924
3045
  }
2925
3046
 
2926
3047
  systemHint(): string | undefined {
@@ -4231,17 +4352,40 @@ export class SqliteLcmStore {
4231
4352
  ): { rootSessionID: string; lineageDepth: number } {
4232
4353
  if (!parentSessionID) return { rootSessionID: sessionID, lineageDepth: 0 };
4233
4354
 
4234
- const parent = this.getDb()
4235
- .prepare('SELECT root_session_id, lineage_depth FROM sessions WHERE session_id = ?')
4236
- .get(parentSessionID) as
4237
- | { root_session_id: string | null; lineage_depth: number | null }
4238
- | undefined;
4355
+ const seen = new Set<string>([sessionID]);
4356
+ let currentSessionID: string | undefined = parentSessionID;
4357
+ let lineageDepth = 1;
4239
4358
 
4240
- if (!parent) return { rootSessionID: parentSessionID, lineageDepth: 1 };
4241
- return {
4242
- rootSessionID: parent.root_session_id ?? parentSessionID,
4243
- lineageDepth: (parent.lineage_depth ?? 0) + 1,
4244
- };
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 };
4245
4389
  }
4246
4390
 
4247
4391
  private applyEvent(session: NormalizedSession, event: CapturedEvent): NormalizedSession {
@@ -4313,31 +4457,44 @@ export class SqliteLcmStore {
4313
4457
  return row?.note;
4314
4458
  }
4315
4459
 
4316
- private readSessionHeaderSync(sessionID: string): NormalizedSession | undefined {
4317
- const row = safeQueryOne<SessionRow>(
4318
- this.getDb().prepare('SELECT * FROM sessions WHERE session_id = ?'),
4319
- [sessionID],
4320
- 'readSessionHeaderSync',
4321
- );
4322
- if (!row) return undefined;
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;
4323
4469
 
4324
4470
  return {
4325
4471
  sessionID: row.session_id,
4326
4472
  title: row.title ?? undefined,
4327
4473
  directory: row.session_directory ?? undefined,
4328
- parentSessionID: row.parent_session_id ?? undefined,
4329
- rootSessionID: row.root_session_id ?? undefined,
4330
- lineageDepth: row.lineage_depth ?? undefined,
4474
+ parentSessionID,
4475
+ rootSessionID: row.root_session_id ?? derivedLineage?.rootSessionID,
4476
+ lineageDepth: row.lineage_depth ?? derivedLineage?.lineageDepth,
4331
4477
  pinned: Boolean(row.pinned),
4332
4478
  pinReason: row.pin_reason ?? undefined,
4333
4479
  updatedAt: row.updated_at,
4334
4480
  compactedAt: row.compacted_at ?? undefined,
4335
4481
  deleted: Boolean(row.deleted),
4336
4482
  eventCount: row.event_count,
4337
- messages: [],
4483
+ messages,
4338
4484
  };
4339
4485
  }
4340
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
+
4341
4498
  private clearSessionDataSync(sessionID: string): void {
4342
4499
  const db = this.getDb();
4343
4500
  db.prepare('DELETE FROM message_fts WHERE session_id = ?').run(sessionID);
@@ -4496,27 +4653,13 @@ export class SqliteLcmStore {
4496
4653
  const row = sessionMap.get(sessionID);
4497
4654
  const messages = messagesBySession.get(sessionID) ?? [];
4498
4655
  if (!row) {
4499
- return { sessionID, updatedAt: 0, eventCount: 0, messages };
4656
+ return { ...emptySession(sessionID), messages };
4500
4657
  }
4501
- return {
4502
- sessionID: row.session_id,
4503
- title: row.title ?? undefined,
4504
- directory: row.session_directory ?? undefined,
4505
- parentSessionID: row.parent_session_id ?? undefined,
4506
- rootSessionID: row.root_session_id ?? undefined,
4507
- lineageDepth: row.lineage_depth ?? undefined,
4508
- pinned: Boolean(row.pinned),
4509
- pinReason: row.pin_reason ?? undefined,
4510
- updatedAt: row.updated_at,
4511
- compactedAt: row.compacted_at ?? undefined,
4512
- deleted: Boolean(row.deleted),
4513
- eventCount: row.event_count,
4514
- messages,
4515
- };
4658
+ return this.materializeSessionRow(row, messages);
4516
4659
  });
4517
4660
  }
4518
4661
 
4519
- private readSessionSync(sessionID: string, options?: ReadSessionOptions): NormalizedSession {
4662
+ private readSessionSync(sessionID: string): NormalizedSession {
4520
4663
  const db = this.getDb();
4521
4664
  const row = safeQueryOne<SessionRow>(
4522
4665
  db.prepare('SELECT * FROM sessions WHERE session_id = ?'),
@@ -4534,14 +4677,7 @@ export class SqliteLcmStore {
4534
4677
  )
4535
4678
  .all(sessionID) as PartRow[];
4536
4679
  const artifactsByPart = new Map<string, ArtifactData[]>();
4537
- const artifactMessageIDs = options?.artifactMessageIDs;
4538
- const artifacts =
4539
- artifactMessageIDs === undefined
4540
- ? this.readArtifactsForSessionSync(sessionID)
4541
- : [...new Set(artifactMessageIDs)].flatMap((messageID) =>
4542
- this.readArtifactsForMessageSync(messageID),
4543
- );
4544
- for (const artifact of artifacts) {
4680
+ for (const artifact of this.readArtifactsForSessionSync(sessionID)) {
4545
4681
  const list = artifactsByPart.get(artifact.partID) ?? [];
4546
4682
  list.push(artifact);
4547
4683
  artifactsByPart.set(artifact.partID, list);
@@ -4563,29 +4699,10 @@ export class SqliteLcmStore {
4563
4699
  }));
4564
4700
 
4565
4701
  if (!row) {
4566
- return {
4567
- sessionID,
4568
- updatedAt: 0,
4569
- eventCount: 0,
4570
- messages,
4571
- };
4702
+ return { ...emptySession(sessionID), messages };
4572
4703
  }
4573
4704
 
4574
- return {
4575
- sessionID: row.session_id,
4576
- title: row.title ?? undefined,
4577
- directory: row.session_directory ?? undefined,
4578
- parentSessionID: row.parent_session_id ?? undefined,
4579
- rootSessionID: row.root_session_id ?? undefined,
4580
- lineageDepth: row.lineage_depth ?? undefined,
4581
- pinned: Boolean(row.pinned),
4582
- pinReason: row.pin_reason ?? undefined,
4583
- updatedAt: row.updated_at,
4584
- compactedAt: row.compacted_at ?? undefined,
4585
- deleted: Boolean(row.deleted),
4586
- eventCount: row.event_count,
4587
- messages,
4588
- };
4705
+ return this.materializeSessionRow(row, messages);
4589
4706
  }
4590
4707
 
4591
4708
  private prepareSessionForPersistence(session: NormalizedSession): NormalizedSession {
@@ -4622,6 +4739,85 @@ export class SqliteLcmStore {
4622
4739
  return parentSessionID;
4623
4740
  }
4624
4741
 
4742
+ private readSessionForCaptureSync(event: CapturedEvent): NormalizedSession {
4743
+ const sessionID = event.sessionID;
4744
+ if (!sessionID) return emptySession('');
4745
+
4746
+ const session = this.readSessionHeaderSync(sessionID) ?? emptySession(sessionID);
4747
+ const payload = event.payload as Event;
4748
+
4749
+ switch (payload.type) {
4750
+ case 'message.updated': {
4751
+ const message = this.readMessageSync(sessionID, payload.properties.info.id);
4752
+ if (message) session.messages = [message];
4753
+ return session;
4754
+ }
4755
+ case 'message.part.updated': {
4756
+ const message = this.readMessageSync(sessionID, payload.properties.part.messageID);
4757
+ if (message) session.messages = [message];
4758
+ return session;
4759
+ }
4760
+ case 'message.part.removed': {
4761
+ const message = this.readMessageSync(sessionID, payload.properties.messageID);
4762
+ if (message) session.messages = [message];
4763
+ return session;
4764
+ }
4765
+ default:
4766
+ return session;
4767
+ }
4768
+ }
4769
+
4770
+ private readMessageSync(sessionID: string, messageID: string): ConversationMessage | undefined {
4771
+ const db = this.getDb();
4772
+ const row = safeQueryOne<MessageRow>(
4773
+ db.prepare('SELECT * FROM messages WHERE session_id = ? AND message_id = ?'),
4774
+ [sessionID, messageID],
4775
+ 'readMessageSync',
4776
+ );
4777
+ if (!row) return undefined;
4778
+
4779
+ const artifactsByPart = new Map<string, ArtifactData[]>();
4780
+ for (const artifact of this.readArtifactsForMessageSync(messageID)) {
4781
+ const list = artifactsByPart.get(artifact.partID) ?? [];
4782
+ list.push(artifact);
4783
+ artifactsByPart.set(artifact.partID, list);
4784
+ }
4785
+
4786
+ const parts = db
4787
+ .prepare(
4788
+ 'SELECT * FROM parts WHERE session_id = ? AND message_id = ? ORDER BY sort_key ASC, part_id ASC',
4789
+ )
4790
+ .all(sessionID, messageID) as PartRow[];
4791
+
4792
+ return {
4793
+ info: parseJson<Message>(row.info_json),
4794
+ parts: parts.map((partRow) => {
4795
+ const part = parseJson<Part>(partRow.part_json);
4796
+ hydratePartFromArtifacts(part, artifactsByPart.get(part.id) ?? []);
4797
+ return part;
4798
+ }),
4799
+ };
4800
+ }
4801
+
4802
+ private readMessageCountSync(sessionID: string): number {
4803
+ const row = this.getDb()
4804
+ .prepare('SELECT COUNT(*) AS count FROM messages WHERE session_id = ?')
4805
+ .get(sessionID) as { count: number };
4806
+ return row.count;
4807
+ }
4808
+
4809
+ private isMessageArchivedSync(sessionID: string, messageID: string, createdAt: number): boolean {
4810
+ const row = this.getDb()
4811
+ .prepare(
4812
+ `SELECT COUNT(*) AS count
4813
+ FROM messages
4814
+ WHERE session_id = ?
4815
+ AND (created_at > ? OR (created_at = ? AND message_id > ?))`,
4816
+ )
4817
+ .get(sessionID, createdAt, createdAt, messageID) as { count: number };
4818
+ return row.count >= this.options.freshTailMessages;
4819
+ }
4820
+
4625
4821
  private async persistCapturedSession(
4626
4822
  session: NormalizedSession,
4627
4823
  event: CapturedEvent,