opencode-lcm 0.12.0 → 0.13.1

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
@@ -338,6 +338,66 @@ function extractTimestamp(event: unknown): number {
338
338
  return Date.now();
339
339
  }
340
340
 
341
+ type MessageValidationContext = {
342
+ operation: string;
343
+ sessionID?: string;
344
+ eventType?: string;
345
+ };
346
+
347
+ function logMalformedMessage(
348
+ message: string,
349
+ context: MessageValidationContext,
350
+ extra?: Record<string, unknown>,
351
+ ): void {
352
+ getLogger().warn(message, {
353
+ operation: context.operation,
354
+ sessionID: context.sessionID,
355
+ eventType: context.eventType,
356
+ ...extra,
357
+ });
358
+ }
359
+
360
+ function getValidMessageInfo(info: unknown): Message | undefined {
361
+ const record = asRecord(info);
362
+ if (!record) return undefined;
363
+
364
+ const time = asRecord(record.time);
365
+ if (
366
+ typeof record.id !== 'string' ||
367
+ typeof record.sessionID !== 'string' ||
368
+ typeof record.role !== 'string' ||
369
+ typeof time?.created !== 'number' ||
370
+ !Number.isFinite(time.created)
371
+ ) {
372
+ return undefined;
373
+ }
374
+
375
+ return info as Message;
376
+ }
377
+
378
+ function filterValidConversationMessages(
379
+ messages: ConversationMessage[],
380
+ context?: MessageValidationContext,
381
+ ): ConversationMessage[] {
382
+ const valid = messages.filter((message) => Boolean(getValidMessageInfo(message?.info)));
383
+ const dropped = messages.length - valid.length;
384
+ if (dropped > 0 && context) {
385
+ logMalformedMessage('Skipping malformed conversation messages', context, { dropped });
386
+ }
387
+ return valid;
388
+ }
389
+
390
+ function isValidMessagePartUpdate(event: Event): boolean {
391
+ if (event.type !== 'message.part.updated') return false;
392
+ const part = asRecord(event.properties.part);
393
+ if (!part) return false;
394
+ return (
395
+ typeof part.id === 'string' &&
396
+ typeof part.messageID === 'string' &&
397
+ typeof part.sessionID === 'string'
398
+ );
399
+ }
400
+
341
401
  function normalizeEvent(event: unknown): CapturedEvent | null {
342
402
  const record = asRecord(event);
343
403
  if (!record || typeof record.type !== 'string') return null;
@@ -357,7 +417,13 @@ function getDeferredPartUpdateKey(event: Event): string | undefined {
357
417
  }
358
418
 
359
419
  function compareMessages(a: ConversationMessage, b: ConversationMessage): number {
360
- return a.info.time.created - b.info.time.created;
420
+ const aInfo = getValidMessageInfo(a.info);
421
+ const bInfo = getValidMessageInfo(b.info);
422
+ if (!aInfo && !bInfo) return 0;
423
+ if (!aInfo) return 1;
424
+ if (!bInfo) return -1;
425
+
426
+ return aInfo.time.created - bInfo.time.created || aInfo.id.localeCompare(bInfo.id);
361
427
  }
362
428
 
363
429
  function emptySession(sessionID: string): NormalizedSession {
@@ -513,7 +579,9 @@ function listFiles(message: ConversationMessage): string[] {
513
579
  function makeSessionTitle(session: NormalizedSession): string | undefined {
514
580
  if (session.title) return session.title;
515
581
 
516
- const firstUser = session.messages.find((message) => message.info.role === 'user');
582
+ const firstUser = session.messages.find(
583
+ (message) => getValidMessageInfo(message.info)?.role === 'user',
584
+ );
517
585
  if (!firstUser) return undefined;
518
586
 
519
587
  return truncate(guessMessageText(firstUser, []), 80);
@@ -528,6 +596,12 @@ function logStartupPhase(phase: string, context?: Record<string, unknown>): void
528
596
  getLogger().info(`startup phase: ${phase}`, context);
529
597
  }
530
598
 
599
+ function unrefTimer(timer: ReturnType<typeof setTimeout> | undefined): void {
600
+ if (typeof timer === 'object' && timer && 'unref' in timer && typeof timer.unref === 'function') {
601
+ timer.unref();
602
+ }
603
+ }
604
+
531
605
  type SqliteRuntime = 'bun' | 'node';
532
606
  type SqliteRuntimeOptions = {
533
607
  envOverride?: string | undefined;
@@ -707,6 +781,10 @@ export class SqliteLcmStore {
707
781
  private readonly workspaceDirectory: string;
708
782
  private db?: SqlDatabaseLike;
709
783
  private dbReadyPromise?: Promise<void>;
784
+ private deferredInitTimer?: ReturnType<typeof setTimeout>;
785
+ private deferredInitPromise?: Promise<void>;
786
+ private deferredInitRequested = false;
787
+ private activeOperationCount = 0;
710
788
  private readonly pendingPartUpdates = new Map<string, Event>();
711
789
  private pendingPartUpdateTimer?: ReturnType<typeof setTimeout>;
712
790
  private pendingPartUpdateFlushPromise?: Promise<void>;
@@ -725,8 +803,27 @@ export class SqliteLcmStore {
725
803
  await mkdir(this.baseDir, { recursive: true });
726
804
  }
727
805
 
806
+ // Keep deferred SQLite maintenance off the active connection while a store operation is running.
807
+ private async withStoreActivity<T>(operation: () => Promise<T>): Promise<T> {
808
+ this.activeOperationCount += 1;
809
+ try {
810
+ return await operation();
811
+ } finally {
812
+ this.activeOperationCount -= 1;
813
+ if (this.activeOperationCount === 0 && this.deferredInitRequested) {
814
+ this.scheduleDeferredInit();
815
+ }
816
+ }
817
+ }
818
+
819
+ private async waitForDeferredInitIfRunning(): Promise<void> {
820
+ if (!this.deferredInitPromise) return;
821
+ await this.deferredInitPromise;
822
+ }
823
+
728
824
  private async prepareForRead(): Promise<void> {
729
825
  await this.ensureDbReady();
826
+ await this.waitForDeferredInitIfRunning();
730
827
  await this.flushDeferredPartUpdates();
731
828
  }
732
829
 
@@ -737,15 +834,7 @@ export class SqliteLcmStore {
737
834
  this.pendingPartUpdateTimer = undefined;
738
835
  void this.flushDeferredPartUpdates();
739
836
  }, 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
- }
837
+ unrefTimer(this.pendingPartUpdateTimer);
749
838
  }
750
839
 
751
840
  private clearDeferredPartUpdateTimer(): void {
@@ -790,63 +879,73 @@ export class SqliteLcmStore {
790
879
  }
791
880
 
792
881
  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;
882
+ return this.withStoreActivity(async () => {
883
+ switch (event.type) {
884
+ case 'message.part.updated': {
885
+ const key = getDeferredPartUpdateKey(event);
886
+ if (!key) return await this.capture(event);
887
+ this.pendingPartUpdates.set(key, event);
888
+ this.scheduleDeferredPartUpdateFlush();
889
+ return;
890
+ }
891
+ case 'message.part.removed':
892
+ this.clearDeferredPartUpdateForPart(
893
+ event.properties.sessionID,
894
+ event.properties.messageID,
895
+ event.properties.partID,
896
+ );
897
+ break;
898
+ case 'message.removed':
899
+ this.clearDeferredPartUpdatesForMessage(
900
+ event.properties.sessionID,
901
+ event.properties.messageID,
902
+ );
903
+ break;
904
+ case 'session.deleted':
905
+ this.clearDeferredPartUpdatesForSession(extractSessionID(event));
906
+ break;
907
+ default:
908
+ break;
800
909
  }
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
910
 
821
- await this.capture(event);
911
+ await this.capture(event);
912
+ });
822
913
  }
823
914
 
824
915
  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);
916
+ return this.withStoreActivity(async () => {
917
+ if (this.pendingPartUpdateFlushPromise) return this.pendingPartUpdateFlushPromise;
918
+ if (this.pendingPartUpdates.size === 0) return;
919
+
920
+ this.clearDeferredPartUpdateTimer();
921
+ this.pendingPartUpdateFlushPromise = (async () => {
922
+ while (this.pendingPartUpdates.size > 0) {
923
+ const batch = [...this.pendingPartUpdates.values()];
924
+ this.pendingPartUpdates.clear();
925
+ for (const event of batch) {
926
+ await this.capture(event);
927
+ }
835
928
  }
836
- }
837
- })().finally(() => {
838
- this.pendingPartUpdateFlushPromise = undefined;
839
- if (this.pendingPartUpdates.size > 0) this.scheduleDeferredPartUpdateFlush();
840
- });
929
+ })().finally(() => {
930
+ this.pendingPartUpdateFlushPromise = undefined;
931
+ if (this.pendingPartUpdates.size > 0) this.scheduleDeferredPartUpdateFlush();
932
+ });
841
933
 
842
- return this.pendingPartUpdateFlushPromise;
934
+ return this.pendingPartUpdateFlushPromise;
935
+ });
843
936
  }
844
937
 
845
938
  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;
939
+ if (!this.dbReadyPromise) {
940
+ if (this.db) {
941
+ this.scheduleDeferredInit();
942
+ return;
943
+ }
944
+ this.dbReadyPromise = this.openAndInitializeDb();
945
+ }
946
+
947
+ await this.dbReadyPromise;
948
+ this.scheduleDeferredInit();
850
949
  }
851
950
 
852
951
  private async openAndInitializeDb(): Promise<void> {
@@ -1024,8 +1123,6 @@ export class SqliteLcmStore {
1024
1123
  await this.migrateLegacyArtifacts();
1025
1124
  logStartupPhase('open-db:write-schema-version', { schemaVersion: STORE_SCHEMA_VERSION });
1026
1125
  this.writeSchemaVersionSync(STORE_SCHEMA_VERSION);
1027
- logStartupPhase('open-db:deferred-init:start');
1028
- this.completeDeferredInit();
1029
1126
  logStartupPhase('open-db:ready');
1030
1127
  } catch (error) {
1031
1128
  logStartupPhase('open-db:error', {
@@ -1040,6 +1137,59 @@ export class SqliteLcmStore {
1040
1137
 
1041
1138
  private deferredInitCompleted = false;
1042
1139
 
1140
+ private runDeferredInit(): Promise<void> {
1141
+ if (this.deferredInitCompleted) return Promise.resolve();
1142
+ if (this.deferredInitPromise) return this.deferredInitPromise;
1143
+
1144
+ this.deferredInitPromise = this.withStoreActivity(async () => {
1145
+ this.deferredInitRequested = false;
1146
+ logStartupPhase('deferred-init:start');
1147
+ this.completeDeferredInit();
1148
+ })
1149
+ .catch((error) => {
1150
+ getLogger().warn('Deferred LCM maintenance failed', {
1151
+ message: error instanceof Error ? error.message : String(error),
1152
+ });
1153
+ })
1154
+ .finally(() => {
1155
+ this.deferredInitPromise = undefined;
1156
+ });
1157
+
1158
+ return this.deferredInitPromise;
1159
+ }
1160
+
1161
+ private scheduleDeferredInit(): void {
1162
+ if (!this.db || this.deferredInitCompleted || this.deferredInitPromise) {
1163
+ return;
1164
+ }
1165
+
1166
+ this.deferredInitRequested = true;
1167
+ if (this.activeOperationCount > 0 || this.deferredInitTimer) return;
1168
+
1169
+ logStartupPhase('deferred-init:scheduled');
1170
+ this.deferredInitTimer = setTimeout(() => {
1171
+ this.deferredInitTimer = undefined;
1172
+ if (this.activeOperationCount > 0) {
1173
+ this.scheduleDeferredInit();
1174
+ return;
1175
+ }
1176
+ void this.runDeferredInit();
1177
+ }, 0);
1178
+ unrefTimer(this.deferredInitTimer);
1179
+ }
1180
+
1181
+ private async ensureDeferredInitComplete(): Promise<void> {
1182
+ await this.ensureDbReady();
1183
+ if (this.deferredInitCompleted) return;
1184
+
1185
+ if (this.deferredInitTimer) {
1186
+ clearTimeout(this.deferredInitTimer);
1187
+ this.deferredInitTimer = undefined;
1188
+ }
1189
+
1190
+ await this.runDeferredInit();
1191
+ }
1192
+
1043
1193
  private readSchemaVersionSync(): number {
1044
1194
  return firstFiniteNumber(this.getDb().prepare('PRAGMA user_version').get()) ?? 0;
1045
1195
  }
@@ -1101,6 +1251,10 @@ export class SqliteLcmStore {
1101
1251
  close(): void {
1102
1252
  this.clearDeferredPartUpdateTimer();
1103
1253
  this.pendingPartUpdates.clear();
1254
+ if (this.deferredInitTimer) {
1255
+ clearTimeout(this.deferredInitTimer);
1256
+ this.deferredInitTimer = undefined;
1257
+ }
1104
1258
  if (!this.db) return;
1105
1259
  this.db.close();
1106
1260
  this.db = undefined;
@@ -1108,65 +1262,77 @@ export class SqliteLcmStore {
1108
1262
  }
1109
1263
 
1110
1264
  async capture(event: Event): Promise<void> {
1111
- await this.ensureDbReady();
1265
+ return this.withStoreActivity(async () => {
1266
+ const normalized = normalizeEvent(event);
1267
+ if (!normalized) return;
1112
1268
 
1113
- const normalized = normalizeEvent(event);
1114
- if (!normalized) return;
1269
+ if (this.shouldSkipMalformedCapturedEvent(normalized)) return;
1115
1270
 
1116
- if (this.shouldRecordEvent(normalized.type)) {
1117
- this.writeEvent(normalized);
1118
- }
1271
+ const shouldRecord = this.shouldRecordEvent(normalized.type);
1272
+ const shouldPersistSession =
1273
+ Boolean(normalized.sessionID) && this.shouldPersistSessionForEvent(normalized.type);
1274
+ if (!shouldRecord && !shouldPersistSession) return;
1119
1275
 
1120
- if (!normalized.sessionID) return;
1121
- if (!this.shouldPersistSessionForEvent(normalized.type)) return;
1276
+ await this.ensureDeferredInitComplete();
1122
1277
 
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);
1278
+ if (shouldRecord) {
1279
+ this.writeEvent(normalized);
1280
+ }
1133
1281
 
1134
- await this.persistCapturedSession(next, normalized);
1282
+ if (!normalized.sessionID || !shouldPersistSession) return;
1135
1283
 
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
- };
1284
+ const session =
1285
+ resolveCaptureHydrationMode() === 'targeted'
1286
+ ? this.readSessionForCaptureSync(normalized)
1287
+ : this.readSessionSync(normalized.sessionID);
1288
+ const previousParentSessionID = session.parentSessionID;
1289
+ const shouldSyncDerivedState = this.shouldSyncDerivedSessionStateForEvent(
1290
+ session,
1291
+ normalized,
1292
+ );
1293
+ let next = this.applyEvent(session, normalized);
1294
+ next.updatedAt = Math.max(next.updatedAt, normalized.timestamp);
1295
+ next.eventCount += 1;
1296
+ next = this.prepareSessionForPersistence(next);
1297
+
1298
+ await this.persistCapturedSession(next, normalized);
1299
+
1300
+ if (this.shouldRefreshLineageForEvent(normalized.type)) {
1301
+ this.refreshAllLineageSync();
1302
+ const refreshed = this.readSessionHeaderSync(normalized.sessionID);
1303
+ if (refreshed) {
1304
+ next = {
1305
+ ...next,
1306
+ parentSessionID: refreshed.parentSessionID,
1307
+ rootSessionID: refreshed.rootSessionID,
1308
+ lineageDepth: refreshed.lineageDepth,
1309
+ };
1310
+ }
1146
1311
  }
1147
- }
1148
1312
 
1149
- if (shouldSyncDerivedState) {
1150
- this.syncDerivedSessionStateSync(this.readSessionSync(normalized.sessionID));
1151
- }
1313
+ if (shouldSyncDerivedState) {
1314
+ this.syncDerivedSessionStateSync(this.readSessionSync(normalized.sessionID));
1315
+ }
1152
1316
 
1153
- if (
1154
- this.shouldSyncDerivedLineageSubtree(
1155
- normalized.type,
1156
- previousParentSessionID,
1157
- next.parentSessionID,
1158
- )
1159
- ) {
1160
- this.syncDerivedLineageSubtreeSync(normalized.sessionID, true);
1161
- }
1317
+ if (
1318
+ this.shouldSyncDerivedLineageSubtree(
1319
+ normalized.type,
1320
+ previousParentSessionID,
1321
+ next.parentSessionID,
1322
+ )
1323
+ ) {
1324
+ this.syncDerivedLineageSubtreeSync(normalized.sessionID, true);
1325
+ }
1162
1326
 
1163
- if (this.shouldCleanupOrphanBlobsForEvent(normalized.type)) {
1164
- this.deleteOrphanArtifactBlobsSync();
1165
- }
1327
+ if (this.shouldCleanupOrphanBlobsForEvent(normalized.type)) {
1328
+ this.deleteOrphanArtifactBlobsSync();
1329
+ }
1330
+ });
1166
1331
  }
1167
1332
 
1168
1333
  async stats(): Promise<StoreStats> {
1169
1334
  await this.prepareForRead();
1335
+ await this.ensureDeferredInitComplete();
1170
1336
  const db = this.getDb();
1171
1337
  const totalRow = validateRow<{ count: number; latest: number | null }>(
1172
1338
  db.prepare('SELECT COUNT(*) AS count, MAX(ts) AS latest FROM events').get(),
@@ -1283,13 +1449,14 @@ export class SqliteLcmStore {
1283
1449
  scope?: string;
1284
1450
  limit?: number;
1285
1451
  }): Promise<SearchResult[]> {
1286
- await this.prepareForRead();
1287
1452
  const resolvedScope = this.resolveConfiguredScope('grep', input.scope, input.sessionID);
1288
- const sessionIDs = this.resolveScopeSessionIDs(resolvedScope, input.sessionID);
1289
1453
  const limit = input.limit ?? 5;
1290
1454
  const needle = input.query.trim();
1291
1455
  if (!needle) return [];
1292
1456
 
1457
+ await this.prepareForRead();
1458
+ const sessionIDs = this.resolveScopeSessionIDs(resolvedScope, input.sessionID);
1459
+
1293
1460
  const ftsResults = this.searchWithFts(needle, sessionIDs, limit);
1294
1461
  if (ftsResults.length > 0) return ftsResults;
1295
1462
  return this.searchByScan(needle.toLowerCase(), sessionIDs, limit);
@@ -1735,11 +1902,12 @@ export class SqliteLcmStore {
1735
1902
  session: NormalizedSession,
1736
1903
  preserveExistingResume = false,
1737
1904
  ): SummaryNodeData[] {
1905
+ const sanitizedSession = this.sanitizeSessionMessages(session, 'syncDerivedSessionStateSync');
1738
1906
  const roots = this.ensureSummaryGraphSync(
1739
- session.sessionID,
1740
- this.getArchivedMessages(session.messages),
1907
+ sanitizedSession.sessionID,
1908
+ this.getArchivedMessages(sanitizedSession.messages),
1741
1909
  );
1742
- this.writeResumeSync(session, roots, preserveExistingResume);
1910
+ this.writeResumeSync(sanitizedSession, roots, preserveExistingResume);
1743
1911
  return roots;
1744
1912
  }
1745
1913
 
@@ -2516,71 +2684,73 @@ export class SqliteLcmStore {
2516
2684
  vacuum?: boolean;
2517
2685
  limit?: number;
2518
2686
  }): 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();
2687
+ return this.withStoreActivity(async () => {
2688
+ await this.prepareForRead();
2689
+ const apply = input?.apply ?? false;
2690
+ const vacuum = input?.vacuum ?? true;
2691
+ const limit = clamp(input?.limit ?? 10, 1, 50);
2692
+ const candidates = this.readPrunableEventTypeCountsSync();
2693
+ const candidateEvents = candidates.reduce((sum, row) => sum + row.count, 0);
2694
+ const beforeSizes = await this.readStoreFileSizes();
2695
+
2696
+ if (!apply || candidateEvents === 0) {
2697
+ return [
2698
+ `candidate_events=${candidateEvents}`,
2699
+ `apply=false`,
2700
+ `vacuum_requested=${vacuum}`,
2701
+ `db_bytes=${beforeSizes.dbBytes}`,
2702
+ `wal_bytes=${beforeSizes.walBytes}`,
2703
+ `shm_bytes=${beforeSizes.shmBytes}`,
2704
+ `total_bytes=${beforeSizes.totalBytes}`,
2705
+ ...(candidates.length > 0
2706
+ ? [
2707
+ 'candidate_event_types:',
2708
+ ...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
2709
+ ]
2710
+ : ['candidate_event_types:', '- none']),
2711
+ ].join('\n');
2712
+ }
2713
+
2714
+ const eventTypes = candidates.map((row) => row.eventType);
2715
+ if (eventTypes.length > 0) {
2716
+ const placeholders = eventTypes.map(() => '?').join(', ');
2717
+ this.getDb()
2718
+ .prepare(`DELETE FROM events WHERE event_type IN (${placeholders})`)
2719
+ .run(...eventTypes);
2720
+ }
2721
+
2722
+ let vacuumApplied = false;
2723
+ this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
2724
+ if (vacuum) {
2725
+ this.getDb().exec('VACUUM');
2726
+ this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
2727
+ vacuumApplied = true;
2728
+ }
2729
+
2730
+ const afterSizes = await this.readStoreFileSizes();
2526
2731
 
2527
- if (!apply || candidateEvents === 0) {
2528
2732
  return [
2529
2733
  `candidate_events=${candidateEvents}`,
2530
- `apply=false`,
2734
+ `deleted_events=${candidateEvents}`,
2735
+ `apply=true`,
2531
2736
  `vacuum_requested=${vacuum}`,
2532
- `db_bytes=${beforeSizes.dbBytes}`,
2533
- `wal_bytes=${beforeSizes.walBytes}`,
2534
- `shm_bytes=${beforeSizes.shmBytes}`,
2535
- `total_bytes=${beforeSizes.totalBytes}`,
2737
+ `vacuum_applied=${vacuumApplied}`,
2738
+ `db_bytes_before=${beforeSizes.dbBytes}`,
2739
+ `wal_bytes_before=${beforeSizes.walBytes}`,
2740
+ `shm_bytes_before=${beforeSizes.shmBytes}`,
2741
+ `total_bytes_before=${beforeSizes.totalBytes}`,
2742
+ `db_bytes_after=${afterSizes.dbBytes}`,
2743
+ `wal_bytes_after=${afterSizes.walBytes}`,
2744
+ `shm_bytes_after=${afterSizes.shmBytes}`,
2745
+ `total_bytes_after=${afterSizes.totalBytes}`,
2536
2746
  ...(candidates.length > 0
2537
2747
  ? [
2538
- 'candidate_event_types:',
2748
+ 'deleted_event_types:',
2539
2749
  ...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
2540
2750
  ]
2541
- : ['candidate_event_types:', '- none']),
2751
+ : ['deleted_event_types:', '- none']),
2542
2752
  ].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');
2753
+ });
2584
2754
  }
2585
2755
 
2586
2756
  async retentionReport(input?: {
@@ -2750,24 +2920,26 @@ export class SqliteLcmStore {
2750
2920
  sessionID?: string;
2751
2921
  scope?: string;
2752
2922
  }): 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
- );
2923
+ return this.withStoreActivity(async () => {
2924
+ await this.prepareForRead();
2925
+ return exportStoreSnapshot(
2926
+ {
2927
+ workspaceDirectory: this.workspaceDirectory,
2928
+ normalizeScope: this.normalizeScope.bind(this),
2929
+ resolveScopeSessionIDs: this.resolveScopeSessionIDs.bind(this),
2930
+ readScopedSessionRowsSync: this.readScopedSessionRowsSync.bind(this),
2931
+ readScopedMessageRowsSync: this.readScopedMessageRowsSync.bind(this),
2932
+ readScopedPartRowsSync: this.readScopedPartRowsSync.bind(this),
2933
+ readScopedResumeRowsSync: this.readScopedResumeRowsSync.bind(this),
2934
+ readScopedArtifactRowsSync: this.readScopedArtifactRowsSync.bind(this),
2935
+ readScopedArtifactBlobRowsSync: this.readScopedArtifactBlobRowsSync.bind(this),
2936
+ readScopedSummaryRowsSync: this.readScopedSummaryRowsSync.bind(this),
2937
+ readScopedSummaryEdgeRowsSync: this.readScopedSummaryEdgeRowsSync.bind(this),
2938
+ readScopedSummaryStateRowsSync: this.readScopedSummaryStateRowsSync.bind(this),
2939
+ },
2940
+ input,
2941
+ );
2942
+ });
2771
2943
  }
2772
2944
 
2773
2945
  async importSnapshot(input: {
@@ -2775,31 +2947,35 @@ export class SqliteLcmStore {
2775
2947
  mode?: 'replace' | 'merge';
2776
2948
  worktreeMode?: SnapshotWorktreeMode;
2777
2949
  }): 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
- );
2950
+ return this.withStoreActivity(async () => {
2951
+ await this.prepareForRead();
2952
+ return importStoreSnapshot(
2953
+ {
2954
+ workspaceDirectory: this.workspaceDirectory,
2955
+ getDb: () => this.getDb(),
2956
+ clearSessionDataSync: this.clearSessionDataSync.bind(this),
2957
+ backfillArtifactBlobsSync: this.backfillArtifactBlobsSync.bind(this),
2958
+ refreshAllLineageSync: this.refreshAllLineageSync.bind(this),
2959
+ syncAllDerivedSessionStateSync: this.syncAllDerivedSessionStateSync.bind(this),
2960
+ refreshSearchIndexesSync: this.refreshSearchIndexesSync.bind(this),
2961
+ },
2962
+ input,
2963
+ );
2964
+ });
2791
2965
  }
2792
2966
 
2793
2967
  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.';
2968
+ return this.withStoreActivity(async () => {
2969
+ await this.prepareForRead();
2970
+ const resolvedSessionID = sessionID ?? this.latestSessionIDSync();
2971
+ if (!resolvedSessionID) return 'No stored resume snapshots yet.';
2797
2972
 
2798
- const existing = this.getResumeSync(resolvedSessionID);
2799
- if (existing && !this.isManagedResumeNote(existing)) return existing;
2973
+ const existing = this.getResumeSync(resolvedSessionID);
2974
+ if (existing && !this.isManagedResumeNote(existing)) return existing;
2800
2975
 
2801
- const generated = await this.buildCompactionContext(resolvedSessionID);
2802
- return generated ?? existing ?? 'No stored resume snapshot for that session.';
2976
+ const generated = await this.buildCompactionContext(resolvedSessionID);
2977
+ return generated ?? existing ?? 'No stored resume snapshot for that session.';
2978
+ });
2803
2979
  }
2804
2980
 
2805
2981
  async expand(input: {
@@ -2883,53 +3059,66 @@ export class SqliteLcmStore {
2883
3059
  }
2884
3060
 
2885
3061
  async transformMessages(messages: ConversationMessage[]): Promise<boolean> {
2886
- await this.prepareForRead();
2887
- if (messages.length < this.options.minMessagesForTransform) return false;
3062
+ return this.withStoreActivity(async () => {
3063
+ const validMessages = filterValidConversationMessages(messages, {
3064
+ operation: 'transformMessages',
3065
+ });
3066
+ if (validMessages.length !== messages.length) {
3067
+ messages.splice(0, messages.length, ...validMessages);
3068
+ }
3069
+ if (messages.length < this.options.minMessagesForTransform) return false;
2888
3070
 
2889
- const window = resolveArchiveTransformWindow(messages, this.options.freshTailMessages);
2890
- if (!window) return false;
3071
+ const window = resolveArchiveTransformWindow(messages, this.options.freshTailMessages);
3072
+ if (!window) return false;
2891
3073
 
2892
- const { anchor, archived, recent } = window;
3074
+ await this.prepareForRead();
2893
3075
 
2894
- const roots = this.ensureSummaryGraphSync(anchor.info.sessionID, archived);
2895
- if (roots.length === 0) return false;
3076
+ const { anchor, archived, recent } = window;
2896
3077
 
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
- }
3078
+ const roots = this.ensureSummaryGraphSync(anchor.info.sessionID, archived);
3079
+ if (roots.length === 0) return false;
2906
3080
 
2907
- anchor.parts = anchor.parts.filter(
2908
- (part) => !isSyntheticLcmTextPart(part, ['archive-summary', 'retrieved-context']),
2909
- );
2910
- const syntheticParts: Part[] = [];
2911
- if (retrieval) {
3081
+ const summary = buildActiveSummaryText(
3082
+ roots,
3083
+ archived.length,
3084
+ this.options.summaryCharBudget,
3085
+ );
3086
+ const retrieval = await this.buildAutomaticRetrievalContext(
3087
+ anchor.info.sessionID,
3088
+ recent,
3089
+ anchor,
3090
+ );
3091
+ for (const message of archived) {
3092
+ this.compactMessageInPlace(message);
3093
+ }
3094
+
3095
+ anchor.parts = anchor.parts.filter(
3096
+ (part) => !isSyntheticLcmTextPart(part, ['archive-summary', 'retrieved-context']),
3097
+ );
3098
+ const syntheticParts: Part[] = [];
3099
+ if (retrieval) {
3100
+ syntheticParts.push({
3101
+ id: `lcm-memory-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
3102
+ sessionID: anchor.info.sessionID,
3103
+ messageID: anchor.info.id,
3104
+ type: 'text',
3105
+ text: retrieval,
3106
+ synthetic: true,
3107
+ metadata: { opencodeLcm: 'retrieved-context' },
3108
+ });
3109
+ }
2912
3110
  syntheticParts.push({
2913
- id: `lcm-memory-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
3111
+ id: `lcm-summary-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
2914
3112
  sessionID: anchor.info.sessionID,
2915
3113
  messageID: anchor.info.id,
2916
3114
  type: 'text',
2917
- text: retrieval,
3115
+ text: summary,
2918
3116
  synthetic: true,
2919
- metadata: { opencodeLcm: 'retrieved-context' },
3117
+ metadata: { opencodeLcm: 'archive-summary' },
2920
3118
  });
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' },
3119
+ anchor.parts.push(...syntheticParts);
3120
+ return true;
2930
3121
  });
2931
- anchor.parts.push(...syntheticParts);
2932
- return true;
2933
3122
  }
2934
3123
 
2935
3124
  systemHint(): string | undefined {
@@ -2943,6 +3132,44 @@ export class SqliteLcmStore {
2943
3132
  ].join(' ');
2944
3133
  }
2945
3134
 
3135
+ private sanitizeSessionMessages(
3136
+ session: NormalizedSession,
3137
+ operation: string,
3138
+ ): NormalizedSession {
3139
+ const messages = filterValidConversationMessages(session.messages, {
3140
+ operation,
3141
+ sessionID: session.sessionID,
3142
+ });
3143
+ return messages.length === session.messages.length ? session : { ...session, messages };
3144
+ }
3145
+
3146
+ private shouldSkipMalformedCapturedEvent(event: CapturedEvent): boolean {
3147
+ const payload = event.payload as Event;
3148
+
3149
+ switch (payload.type) {
3150
+ case 'message.updated': {
3151
+ if (getValidMessageInfo(payload.properties.info)) return false;
3152
+ logMalformedMessage('Skipping malformed message.updated event', {
3153
+ operation: 'capture',
3154
+ sessionID: event.sessionID,
3155
+ eventType: payload.type,
3156
+ });
3157
+ return true;
3158
+ }
3159
+ case 'message.part.updated': {
3160
+ if (isValidMessagePartUpdate(payload)) return false;
3161
+ logMalformedMessage('Skipping malformed message.part.updated event', {
3162
+ operation: 'capture',
3163
+ sessionID: event.sessionID,
3164
+ eventType: payload.type,
3165
+ });
3166
+ return true;
3167
+ }
3168
+ default:
3169
+ return false;
3170
+ }
3171
+ }
3172
+
2946
3173
  private async buildAutomaticRetrievalContext(
2947
3174
  sessionID: string,
2948
3175
  recent: ConversationMessage[],
@@ -4072,10 +4299,22 @@ export class SqliteLcmStore {
4072
4299
  }
4073
4300
 
4074
4301
  private replaceMessageSearchRowsSync(session: NormalizedSession): void {
4075
- replaceMessageSearchRowsModule(this.searchDeps(), redactStructuredValue(session, this.privacy));
4302
+ const sanitizedSession = this.sanitizeSessionMessages(session, 'replaceMessageSearchRowsSync');
4303
+ replaceMessageSearchRowsModule(
4304
+ this.searchDeps(),
4305
+ redactStructuredValue(sanitizedSession, this.privacy),
4306
+ );
4076
4307
  }
4077
4308
 
4078
4309
  private replaceMessageSearchRowSync(sessionID: string, message: ConversationMessage): void {
4310
+ if (!getValidMessageInfo(message.info)) {
4311
+ logMalformedMessage('Skipping malformed message search row', {
4312
+ operation: 'replaceMessageSearchRowSync',
4313
+ sessionID,
4314
+ });
4315
+ return;
4316
+ }
4317
+
4079
4318
  replaceMessageSearchRowModule(
4080
4319
  this.searchDeps(),
4081
4320
  sessionID,
@@ -4240,17 +4479,40 @@ export class SqliteLcmStore {
4240
4479
  ): { rootSessionID: string; lineageDepth: number } {
4241
4480
  if (!parentSessionID) return { rootSessionID: sessionID, lineageDepth: 0 };
4242
4481
 
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;
4482
+ const seen = new Set<string>([sessionID]);
4483
+ let currentSessionID: string | undefined = parentSessionID;
4484
+ let lineageDepth = 1;
4248
4485
 
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
- };
4486
+ while (currentSessionID && !seen.has(currentSessionID)) {
4487
+ seen.add(currentSessionID);
4488
+ const parent = this.getDb()
4489
+ .prepare(
4490
+ 'SELECT parent_session_id, root_session_id, lineage_depth FROM sessions WHERE session_id = ?',
4491
+ )
4492
+ .get(currentSessionID) as
4493
+ | {
4494
+ parent_session_id: string | null;
4495
+ root_session_id: string | null;
4496
+ lineage_depth: number | null;
4497
+ }
4498
+ | undefined;
4499
+
4500
+ if (!parent) return { rootSessionID: currentSessionID, lineageDepth };
4501
+ if (parent.root_session_id && parent.lineage_depth !== null) {
4502
+ return {
4503
+ rootSessionID: parent.root_session_id,
4504
+ lineageDepth: parent.lineage_depth + lineageDepth,
4505
+ };
4506
+ }
4507
+ if (!parent.parent_session_id) {
4508
+ return { rootSessionID: currentSessionID, lineageDepth };
4509
+ }
4510
+
4511
+ currentSessionID = parent.parent_session_id;
4512
+ lineageDepth += 1;
4513
+ }
4514
+
4515
+ return { rootSessionID: parentSessionID, lineageDepth };
4254
4516
  }
4255
4517
 
4256
4518
  private applyEvent(session: NormalizedSession, event: CapturedEvent): NormalizedSession {
@@ -4322,31 +4584,44 @@ export class SqliteLcmStore {
4322
4584
  return row?.note;
4323
4585
  }
4324
4586
 
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;
4587
+ private materializeSessionRow(
4588
+ row: SessionRow,
4589
+ messages: ConversationMessage[] = [],
4590
+ ): NormalizedSession {
4591
+ const parentSessionID = row.parent_session_id ?? undefined;
4592
+ const derivedLineage =
4593
+ row.root_session_id === null || row.lineage_depth === null
4594
+ ? this.resolveLineageSync(row.session_id, parentSessionID)
4595
+ : undefined;
4332
4596
 
4333
4597
  return {
4334
4598
  sessionID: row.session_id,
4335
4599
  title: row.title ?? undefined,
4336
4600
  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,
4601
+ parentSessionID,
4602
+ rootSessionID: row.root_session_id ?? derivedLineage?.rootSessionID,
4603
+ lineageDepth: row.lineage_depth ?? derivedLineage?.lineageDepth,
4340
4604
  pinned: Boolean(row.pinned),
4341
4605
  pinReason: row.pin_reason ?? undefined,
4342
4606
  updatedAt: row.updated_at,
4343
4607
  compactedAt: row.compacted_at ?? undefined,
4344
4608
  deleted: Boolean(row.deleted),
4345
4609
  eventCount: row.event_count,
4346
- messages: [],
4610
+ messages,
4347
4611
  };
4348
4612
  }
4349
4613
 
4614
+ private readSessionHeaderSync(sessionID: string): NormalizedSession | undefined {
4615
+ const row = safeQueryOne<SessionRow>(
4616
+ this.getDb().prepare('SELECT * FROM sessions WHERE session_id = ?'),
4617
+ [sessionID],
4618
+ 'readSessionHeaderSync',
4619
+ );
4620
+ if (!row) return undefined;
4621
+
4622
+ return this.materializeSessionRow(row);
4623
+ }
4624
+
4350
4625
  private clearSessionDataSync(sessionID: string): void {
4351
4626
  const db = this.getDb();
4352
4627
  db.prepare('DELETE FROM message_fts WHERE session_id = ?').run(sessionID);
@@ -4503,25 +4778,14 @@ export class SqliteLcmStore {
4503
4778
  // Build NormalizedSession results
4504
4779
  return sessionIDs.map((sessionID) => {
4505
4780
  const row = sessionMap.get(sessionID);
4506
- const messages = messagesBySession.get(sessionID) ?? [];
4781
+ const messages = filterValidConversationMessages(messagesBySession.get(sessionID) ?? [], {
4782
+ operation: 'readSessionsBatchSync',
4783
+ sessionID,
4784
+ });
4507
4785
  if (!row) {
4508
4786
  return { ...emptySession(sessionID), messages };
4509
4787
  }
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
- };
4788
+ return this.materializeSessionRow(row, messages);
4525
4789
  });
4526
4790
  }
4527
4791
 
@@ -4559,30 +4823,19 @@ export class SqliteLcmStore {
4559
4823
  partsByMessage.set(partRow.message_id, parts);
4560
4824
  }
4561
4825
 
4562
- const messages = messageRows.map((messageRow) => ({
4563
- info: parseJson<Message>(messageRow.info_json),
4564
- parts: partsByMessage.get(messageRow.message_id) ?? [],
4565
- }));
4826
+ const messages = filterValidConversationMessages(
4827
+ messageRows.map((messageRow) => ({
4828
+ info: parseJson<Message>(messageRow.info_json),
4829
+ parts: partsByMessage.get(messageRow.message_id) ?? [],
4830
+ })),
4831
+ { operation: 'readSessionSync', sessionID },
4832
+ );
4566
4833
 
4567
4834
  if (!row) {
4568
4835
  return { ...emptySession(sessionID), messages };
4569
4836
  }
4570
4837
 
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
- };
4838
+ return this.materializeSessionRow(row, messages);
4586
4839
  }
4587
4840
 
4588
4841
  private prepareSessionForPersistence(session: NormalizedSession): NormalizedSession {
@@ -4669,8 +4922,21 @@ export class SqliteLcmStore {
4669
4922
  )
4670
4923
  .all(sessionID, messageID) as PartRow[];
4671
4924
 
4925
+ const info = parseJson<Message>(row.info_json);
4926
+ if (!getValidMessageInfo(info)) {
4927
+ logMalformedMessage(
4928
+ 'Skipping malformed stored message',
4929
+ {
4930
+ operation: 'readMessageSync',
4931
+ sessionID,
4932
+ },
4933
+ { messageID },
4934
+ );
4935
+ return undefined;
4936
+ }
4937
+
4672
4938
  return {
4673
- info: parseJson<Message>(row.info_json),
4939
+ info,
4674
4940
  parts: parts.map((partRow) => {
4675
4941
  const part = parseJson<Part>(partRow.part_json);
4676
4942
  hydratePartFromArtifacts(part, artifactsByPart.get(part.id) ?? []);
@@ -4832,7 +5098,16 @@ export class SqliteLcmStore {
4832
5098
  }
4833
5099
 
4834
5100
  private upsertMessageInfoSync(sessionID: string, message: ConversationMessage): void {
4835
- const info = redactStructuredValue(message.info, this.privacy);
5101
+ const validated = getValidMessageInfo(message.info);
5102
+ if (!validated) {
5103
+ logMalformedMessage('Skipping malformed message metadata', {
5104
+ operation: 'upsertMessageInfoSync',
5105
+ sessionID,
5106
+ });
5107
+ return;
5108
+ }
5109
+
5110
+ const info = redactStructuredValue(validated, this.privacy);
4836
5111
  this.getDb()
4837
5112
  .prepare(
4838
5113
  `INSERT INTO messages (message_id, session_id, created_at, info_json)
@@ -4884,7 +5159,10 @@ export class SqliteLcmStore {
4884
5159
  }
4885
5160
 
4886
5161
  private async externalizeSession(session: NormalizedSession): Promise<ExternalizedSession> {
4887
- return externalizeSessionModule(this.artifactDeps(), session);
5162
+ return externalizeSessionModule(
5163
+ this.artifactDeps(),
5164
+ this.sanitizeSessionMessages(session, 'externalizeSession'),
5165
+ );
4888
5166
  }
4889
5167
 
4890
5168
  private writeEvent(event: CapturedEvent): void {