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/dist/store.js CHANGED
@@ -147,6 +147,46 @@ function extractTimestamp(event) {
147
147
  return properties.time;
148
148
  return Date.now();
149
149
  }
150
+ function logMalformedMessage(message, context, extra) {
151
+ getLogger().warn(message, {
152
+ operation: context.operation,
153
+ sessionID: context.sessionID,
154
+ eventType: context.eventType,
155
+ ...extra,
156
+ });
157
+ }
158
+ function getValidMessageInfo(info) {
159
+ const record = asRecord(info);
160
+ if (!record)
161
+ return undefined;
162
+ const time = asRecord(record.time);
163
+ if (typeof record.id !== 'string' ||
164
+ typeof record.sessionID !== 'string' ||
165
+ typeof record.role !== 'string' ||
166
+ typeof time?.created !== 'number' ||
167
+ !Number.isFinite(time.created)) {
168
+ return undefined;
169
+ }
170
+ return info;
171
+ }
172
+ function filterValidConversationMessages(messages, context) {
173
+ const valid = messages.filter((message) => Boolean(getValidMessageInfo(message?.info)));
174
+ const dropped = messages.length - valid.length;
175
+ if (dropped > 0 && context) {
176
+ logMalformedMessage('Skipping malformed conversation messages', context, { dropped });
177
+ }
178
+ return valid;
179
+ }
180
+ function isValidMessagePartUpdate(event) {
181
+ if (event.type !== 'message.part.updated')
182
+ return false;
183
+ const part = asRecord(event.properties.part);
184
+ if (!part)
185
+ return false;
186
+ return (typeof part.id === 'string' &&
187
+ typeof part.messageID === 'string' &&
188
+ typeof part.sessionID === 'string');
189
+ }
150
190
  function normalizeEvent(event) {
151
191
  const record = asRecord(event);
152
192
  if (!record || typeof record.type !== 'string')
@@ -165,7 +205,15 @@ function getDeferredPartUpdateKey(event) {
165
205
  return `${event.properties.part.sessionID}:${event.properties.part.messageID}:${event.properties.part.id}`;
166
206
  }
167
207
  function compareMessages(a, b) {
168
- return a.info.time.created - b.info.time.created;
208
+ const aInfo = getValidMessageInfo(a.info);
209
+ const bInfo = getValidMessageInfo(b.info);
210
+ if (!aInfo && !bInfo)
211
+ return 0;
212
+ if (!aInfo)
213
+ return 1;
214
+ if (!bInfo)
215
+ return -1;
216
+ return aInfo.time.created - bInfo.time.created || aInfo.id.localeCompare(bInfo.id);
169
217
  }
170
218
  function emptySession(sessionID) {
171
219
  return {
@@ -323,7 +371,7 @@ function listFiles(message) {
323
371
  function makeSessionTitle(session) {
324
372
  if (session.title)
325
373
  return session.title;
326
- const firstUser = session.messages.find((message) => message.info.role === 'user');
374
+ const firstUser = session.messages.find((message) => getValidMessageInfo(message.info)?.role === 'user');
327
375
  if (!firstUser)
328
376
  return undefined;
329
377
  return truncate(guessMessageText(firstUser, []), 80);
@@ -336,6 +384,11 @@ function logStartupPhase(phase, context) {
336
384
  return;
337
385
  getLogger().info(`startup phase: ${phase}`, context);
338
386
  }
387
+ function unrefTimer(timer) {
388
+ if (typeof timer === 'object' && timer && 'unref' in timer && typeof timer.unref === 'function') {
389
+ timer.unref();
390
+ }
391
+ }
339
392
  function normalizeSqliteRuntimeOverride(value) {
340
393
  const normalized = value?.trim().toLowerCase();
341
394
  if (normalized === 'bun' || normalized === 'node')
@@ -463,6 +516,10 @@ export class SqliteLcmStore {
463
516
  workspaceDirectory;
464
517
  db;
465
518
  dbReadyPromise;
519
+ deferredInitTimer;
520
+ deferredInitPromise;
521
+ deferredInitRequested = false;
522
+ activeOperationCount = 0;
466
523
  pendingPartUpdates = new Map();
467
524
  pendingPartUpdateTimer;
468
525
  pendingPartUpdateFlushPromise;
@@ -476,8 +533,27 @@ export class SqliteLcmStore {
476
533
  async init() {
477
534
  await mkdir(this.baseDir, { recursive: true });
478
535
  }
536
+ // Keep deferred SQLite maintenance off the active connection while a store operation is running.
537
+ async withStoreActivity(operation) {
538
+ this.activeOperationCount += 1;
539
+ try {
540
+ return await operation();
541
+ }
542
+ finally {
543
+ this.activeOperationCount -= 1;
544
+ if (this.activeOperationCount === 0 && this.deferredInitRequested) {
545
+ this.scheduleDeferredInit();
546
+ }
547
+ }
548
+ }
549
+ async waitForDeferredInitIfRunning() {
550
+ if (!this.deferredInitPromise)
551
+ return;
552
+ await this.deferredInitPromise;
553
+ }
479
554
  async prepareForRead() {
480
555
  await this.ensureDbReady();
556
+ await this.waitForDeferredInitIfRunning();
481
557
  await this.flushDeferredPartUpdates();
482
558
  }
483
559
  scheduleDeferredPartUpdateFlush() {
@@ -487,12 +563,7 @@ export class SqliteLcmStore {
487
563
  this.pendingPartUpdateTimer = undefined;
488
564
  void this.flushDeferredPartUpdates();
489
565
  }, SqliteLcmStore.deferredPartUpdateDelayMs);
490
- if (typeof this.pendingPartUpdateTimer === 'object' &&
491
- this.pendingPartUpdateTimer &&
492
- 'unref' in this.pendingPartUpdateTimer &&
493
- typeof this.pendingPartUpdateTimer.unref === 'function') {
494
- this.pendingPartUpdateTimer.unref();
495
- }
566
+ unrefTimer(this.pendingPartUpdateTimer);
496
567
  }
497
568
  clearDeferredPartUpdateTimer() {
498
569
  if (!this.pendingPartUpdateTimer)
@@ -536,57 +607,64 @@ export class SqliteLcmStore {
536
607
  this.clearDeferredPartUpdateTimer();
537
608
  }
538
609
  async captureDeferred(event) {
539
- switch (event.type) {
540
- case 'message.part.updated': {
541
- const key = getDeferredPartUpdateKey(event);
542
- if (!key)
543
- return await this.capture(event);
544
- this.pendingPartUpdates.set(key, event);
545
- this.scheduleDeferredPartUpdateFlush();
546
- return;
610
+ return this.withStoreActivity(async () => {
611
+ switch (event.type) {
612
+ case 'message.part.updated': {
613
+ const key = getDeferredPartUpdateKey(event);
614
+ if (!key)
615
+ return await this.capture(event);
616
+ this.pendingPartUpdates.set(key, event);
617
+ this.scheduleDeferredPartUpdateFlush();
618
+ return;
619
+ }
620
+ case 'message.part.removed':
621
+ this.clearDeferredPartUpdateForPart(event.properties.sessionID, event.properties.messageID, event.properties.partID);
622
+ break;
623
+ case 'message.removed':
624
+ this.clearDeferredPartUpdatesForMessage(event.properties.sessionID, event.properties.messageID);
625
+ break;
626
+ case 'session.deleted':
627
+ this.clearDeferredPartUpdatesForSession(extractSessionID(event));
628
+ break;
629
+ default:
630
+ break;
547
631
  }
548
- case 'message.part.removed':
549
- this.clearDeferredPartUpdateForPart(event.properties.sessionID, event.properties.messageID, event.properties.partID);
550
- break;
551
- case 'message.removed':
552
- this.clearDeferredPartUpdatesForMessage(event.properties.sessionID, event.properties.messageID);
553
- break;
554
- case 'session.deleted':
555
- this.clearDeferredPartUpdatesForSession(extractSessionID(event));
556
- break;
557
- default:
558
- break;
559
- }
560
- await this.capture(event);
632
+ await this.capture(event);
633
+ });
561
634
  }
562
635
  async flushDeferredPartUpdates() {
563
- if (this.pendingPartUpdateFlushPromise)
564
- return this.pendingPartUpdateFlushPromise;
565
- if (this.pendingPartUpdates.size === 0)
566
- return;
567
- this.clearDeferredPartUpdateTimer();
568
- this.pendingPartUpdateFlushPromise = (async () => {
569
- while (this.pendingPartUpdates.size > 0) {
570
- const batch = [...this.pendingPartUpdates.values()];
571
- this.pendingPartUpdates.clear();
572
- for (const event of batch) {
573
- await this.capture(event);
636
+ return this.withStoreActivity(async () => {
637
+ if (this.pendingPartUpdateFlushPromise)
638
+ return this.pendingPartUpdateFlushPromise;
639
+ if (this.pendingPartUpdates.size === 0)
640
+ return;
641
+ this.clearDeferredPartUpdateTimer();
642
+ this.pendingPartUpdateFlushPromise = (async () => {
643
+ while (this.pendingPartUpdates.size > 0) {
644
+ const batch = [...this.pendingPartUpdates.values()];
645
+ this.pendingPartUpdates.clear();
646
+ for (const event of batch) {
647
+ await this.capture(event);
648
+ }
574
649
  }
575
- }
576
- })().finally(() => {
577
- this.pendingPartUpdateFlushPromise = undefined;
578
- if (this.pendingPartUpdates.size > 0)
579
- this.scheduleDeferredPartUpdateFlush();
650
+ })().finally(() => {
651
+ this.pendingPartUpdateFlushPromise = undefined;
652
+ if (this.pendingPartUpdates.size > 0)
653
+ this.scheduleDeferredPartUpdateFlush();
654
+ });
655
+ return this.pendingPartUpdateFlushPromise;
580
656
  });
581
- return this.pendingPartUpdateFlushPromise;
582
657
  }
583
658
  async ensureDbReady() {
584
- if (this.db)
585
- return;
586
- if (this.dbReadyPromise)
587
- return this.dbReadyPromise;
588
- this.dbReadyPromise = this.openAndInitializeDb();
589
- return this.dbReadyPromise;
659
+ if (!this.dbReadyPromise) {
660
+ if (this.db) {
661
+ this.scheduleDeferredInit();
662
+ return;
663
+ }
664
+ this.dbReadyPromise = this.openAndInitializeDb();
665
+ }
666
+ await this.dbReadyPromise;
667
+ this.scheduleDeferredInit();
590
668
  }
591
669
  async openAndInitializeDb() {
592
670
  logStartupPhase('open-db:start', { dbPath: this.dbPath });
@@ -755,8 +833,6 @@ export class SqliteLcmStore {
755
833
  await this.migrateLegacyArtifacts();
756
834
  logStartupPhase('open-db:write-schema-version', { schemaVersion: STORE_SCHEMA_VERSION });
757
835
  this.writeSchemaVersionSync(STORE_SCHEMA_VERSION);
758
- logStartupPhase('open-db:deferred-init:start');
759
- this.completeDeferredInit();
760
836
  logStartupPhase('open-db:ready');
761
837
  }
762
838
  catch (error) {
@@ -770,6 +846,54 @@ export class SqliteLcmStore {
770
846
  }
771
847
  }
772
848
  deferredInitCompleted = false;
849
+ runDeferredInit() {
850
+ if (this.deferredInitCompleted)
851
+ return Promise.resolve();
852
+ if (this.deferredInitPromise)
853
+ return this.deferredInitPromise;
854
+ this.deferredInitPromise = this.withStoreActivity(async () => {
855
+ this.deferredInitRequested = false;
856
+ logStartupPhase('deferred-init:start');
857
+ this.completeDeferredInit();
858
+ })
859
+ .catch((error) => {
860
+ getLogger().warn('Deferred LCM maintenance failed', {
861
+ message: error instanceof Error ? error.message : String(error),
862
+ });
863
+ })
864
+ .finally(() => {
865
+ this.deferredInitPromise = undefined;
866
+ });
867
+ return this.deferredInitPromise;
868
+ }
869
+ scheduleDeferredInit() {
870
+ if (!this.db || this.deferredInitCompleted || this.deferredInitPromise) {
871
+ return;
872
+ }
873
+ this.deferredInitRequested = true;
874
+ if (this.activeOperationCount > 0 || this.deferredInitTimer)
875
+ return;
876
+ logStartupPhase('deferred-init:scheduled');
877
+ this.deferredInitTimer = setTimeout(() => {
878
+ this.deferredInitTimer = undefined;
879
+ if (this.activeOperationCount > 0) {
880
+ this.scheduleDeferredInit();
881
+ return;
882
+ }
883
+ void this.runDeferredInit();
884
+ }, 0);
885
+ unrefTimer(this.deferredInitTimer);
886
+ }
887
+ async ensureDeferredInitComplete() {
888
+ await this.ensureDbReady();
889
+ if (this.deferredInitCompleted)
890
+ return;
891
+ if (this.deferredInitTimer) {
892
+ clearTimeout(this.deferredInitTimer);
893
+ this.deferredInitTimer = undefined;
894
+ }
895
+ await this.runDeferredInit();
896
+ }
773
897
  readSchemaVersionSync() {
774
898
  return firstFiniteNumber(this.getDb().prepare('PRAGMA user_version').get()) ?? 0;
775
899
  }
@@ -819,6 +943,10 @@ export class SqliteLcmStore {
819
943
  close() {
820
944
  this.clearDeferredPartUpdateTimer();
821
945
  this.pendingPartUpdates.clear();
946
+ if (this.deferredInitTimer) {
947
+ clearTimeout(this.deferredInitTimer);
948
+ this.deferredInitTimer = undefined;
949
+ }
822
950
  if (!this.db)
823
951
  return;
824
952
  this.db.close();
@@ -826,51 +954,58 @@ export class SqliteLcmStore {
826
954
  this.dbReadyPromise = undefined;
827
955
  }
828
956
  async capture(event) {
829
- await this.ensureDbReady();
830
- const normalized = normalizeEvent(event);
831
- if (!normalized)
832
- return;
833
- if (this.shouldRecordEvent(normalized.type)) {
834
- this.writeEvent(normalized);
835
- }
836
- if (!normalized.sessionID)
837
- return;
838
- if (!this.shouldPersistSessionForEvent(normalized.type))
839
- return;
840
- const session = resolveCaptureHydrationMode() === 'targeted'
841
- ? this.readSessionForCaptureSync(normalized)
842
- : this.readSessionSync(normalized.sessionID);
843
- const previousParentSessionID = session.parentSessionID;
844
- const shouldSyncDerivedState = this.shouldSyncDerivedSessionStateForEvent(session, normalized);
845
- let next = this.applyEvent(session, normalized);
846
- next.updatedAt = Math.max(next.updatedAt, normalized.timestamp);
847
- next.eventCount += 1;
848
- next = this.prepareSessionForPersistence(next);
849
- await this.persistCapturedSession(next, normalized);
850
- if (this.shouldRefreshLineageForEvent(normalized.type)) {
851
- this.refreshAllLineageSync();
852
- const refreshed = this.readSessionHeaderSync(normalized.sessionID);
853
- if (refreshed) {
854
- next = {
855
- ...next,
856
- parentSessionID: refreshed.parentSessionID,
857
- rootSessionID: refreshed.rootSessionID,
858
- lineageDepth: refreshed.lineageDepth,
859
- };
957
+ return this.withStoreActivity(async () => {
958
+ const normalized = normalizeEvent(event);
959
+ if (!normalized)
960
+ return;
961
+ if (this.shouldSkipMalformedCapturedEvent(normalized))
962
+ return;
963
+ const shouldRecord = this.shouldRecordEvent(normalized.type);
964
+ const shouldPersistSession = Boolean(normalized.sessionID) && this.shouldPersistSessionForEvent(normalized.type);
965
+ if (!shouldRecord && !shouldPersistSession)
966
+ return;
967
+ await this.ensureDeferredInitComplete();
968
+ if (shouldRecord) {
969
+ this.writeEvent(normalized);
860
970
  }
861
- }
862
- if (shouldSyncDerivedState) {
863
- this.syncDerivedSessionStateSync(this.readSessionSync(normalized.sessionID));
864
- }
865
- if (this.shouldSyncDerivedLineageSubtree(normalized.type, previousParentSessionID, next.parentSessionID)) {
866
- this.syncDerivedLineageSubtreeSync(normalized.sessionID, true);
867
- }
868
- if (this.shouldCleanupOrphanBlobsForEvent(normalized.type)) {
869
- this.deleteOrphanArtifactBlobsSync();
870
- }
971
+ if (!normalized.sessionID || !shouldPersistSession)
972
+ return;
973
+ const session = resolveCaptureHydrationMode() === 'targeted'
974
+ ? this.readSessionForCaptureSync(normalized)
975
+ : this.readSessionSync(normalized.sessionID);
976
+ const previousParentSessionID = session.parentSessionID;
977
+ const shouldSyncDerivedState = this.shouldSyncDerivedSessionStateForEvent(session, normalized);
978
+ let next = this.applyEvent(session, normalized);
979
+ next.updatedAt = Math.max(next.updatedAt, normalized.timestamp);
980
+ next.eventCount += 1;
981
+ next = this.prepareSessionForPersistence(next);
982
+ await this.persistCapturedSession(next, normalized);
983
+ if (this.shouldRefreshLineageForEvent(normalized.type)) {
984
+ this.refreshAllLineageSync();
985
+ const refreshed = this.readSessionHeaderSync(normalized.sessionID);
986
+ if (refreshed) {
987
+ next = {
988
+ ...next,
989
+ parentSessionID: refreshed.parentSessionID,
990
+ rootSessionID: refreshed.rootSessionID,
991
+ lineageDepth: refreshed.lineageDepth,
992
+ };
993
+ }
994
+ }
995
+ if (shouldSyncDerivedState) {
996
+ this.syncDerivedSessionStateSync(this.readSessionSync(normalized.sessionID));
997
+ }
998
+ if (this.shouldSyncDerivedLineageSubtree(normalized.type, previousParentSessionID, next.parentSessionID)) {
999
+ this.syncDerivedLineageSubtreeSync(normalized.sessionID, true);
1000
+ }
1001
+ if (this.shouldCleanupOrphanBlobsForEvent(normalized.type)) {
1002
+ this.deleteOrphanArtifactBlobsSync();
1003
+ }
1004
+ });
871
1005
  }
872
1006
  async stats() {
873
1007
  await this.prepareForRead();
1008
+ await this.ensureDeferredInitComplete();
874
1009
  const db = this.getDb();
875
1010
  const totalRow = validateRow(db.prepare('SELECT COUNT(*) AS count, MAX(ts) AS latest FROM events').get(), { count: 'number', latest: 'nullable' }, 'stats.totalEvents');
876
1011
  const sessionRow = validateRow(db.prepare('SELECT COUNT(*) AS count FROM sessions').get(), { count: 'number' }, 'stats.sessionCount');
@@ -920,13 +1055,13 @@ export class SqliteLcmStore {
920
1055
  };
921
1056
  }
922
1057
  async grep(input) {
923
- await this.prepareForRead();
924
1058
  const resolvedScope = this.resolveConfiguredScope('grep', input.scope, input.sessionID);
925
- const sessionIDs = this.resolveScopeSessionIDs(resolvedScope, input.sessionID);
926
1059
  const limit = input.limit ?? 5;
927
1060
  const needle = input.query.trim();
928
1061
  if (!needle)
929
1062
  return [];
1063
+ await this.prepareForRead();
1064
+ const sessionIDs = this.resolveScopeSessionIDs(resolvedScope, input.sessionID);
930
1065
  const ftsResults = this.searchWithFts(needle, sessionIDs, limit);
931
1066
  if (ftsResults.length > 0)
932
1067
  return ftsResults;
@@ -1261,8 +1396,9 @@ export class SqliteLcmStore {
1261
1396
  }
1262
1397
  }
1263
1398
  syncDerivedSessionStateSync(session, preserveExistingResume = false) {
1264
- const roots = this.ensureSummaryGraphSync(session.sessionID, this.getArchivedMessages(session.messages));
1265
- this.writeResumeSync(session, roots, preserveExistingResume);
1399
+ const sanitizedSession = this.sanitizeSessionMessages(session, 'syncDerivedSessionStateSync');
1400
+ const roots = this.ensureSummaryGraphSync(sanitizedSession.sessionID, this.getArchivedMessages(sanitizedSession.messages));
1401
+ this.writeResumeSync(sanitizedSession, roots, preserveExistingResume);
1266
1402
  return roots;
1267
1403
  }
1268
1404
  syncDerivedLineageSubtreeSync(sessionID, preserveExistingResume = false) {
@@ -1864,66 +2000,68 @@ export class SqliteLcmStore {
1864
2000
  };
1865
2001
  }
1866
2002
  async compactEventLog(input) {
1867
- await this.prepareForRead();
1868
- const apply = input?.apply ?? false;
1869
- const vacuum = input?.vacuum ?? true;
1870
- const limit = clamp(input?.limit ?? 10, 1, 50);
1871
- const candidates = this.readPrunableEventTypeCountsSync();
1872
- const candidateEvents = candidates.reduce((sum, row) => sum + row.count, 0);
1873
- const beforeSizes = await this.readStoreFileSizes();
1874
- if (!apply || candidateEvents === 0) {
2003
+ return this.withStoreActivity(async () => {
2004
+ await this.prepareForRead();
2005
+ const apply = input?.apply ?? false;
2006
+ const vacuum = input?.vacuum ?? true;
2007
+ const limit = clamp(input?.limit ?? 10, 1, 50);
2008
+ const candidates = this.readPrunableEventTypeCountsSync();
2009
+ const candidateEvents = candidates.reduce((sum, row) => sum + row.count, 0);
2010
+ const beforeSizes = await this.readStoreFileSizes();
2011
+ if (!apply || candidateEvents === 0) {
2012
+ return [
2013
+ `candidate_events=${candidateEvents}`,
2014
+ `apply=false`,
2015
+ `vacuum_requested=${vacuum}`,
2016
+ `db_bytes=${beforeSizes.dbBytes}`,
2017
+ `wal_bytes=${beforeSizes.walBytes}`,
2018
+ `shm_bytes=${beforeSizes.shmBytes}`,
2019
+ `total_bytes=${beforeSizes.totalBytes}`,
2020
+ ...(candidates.length > 0
2021
+ ? [
2022
+ 'candidate_event_types:',
2023
+ ...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
2024
+ ]
2025
+ : ['candidate_event_types:', '- none']),
2026
+ ].join('\n');
2027
+ }
2028
+ const eventTypes = candidates.map((row) => row.eventType);
2029
+ if (eventTypes.length > 0) {
2030
+ const placeholders = eventTypes.map(() => '?').join(', ');
2031
+ this.getDb()
2032
+ .prepare(`DELETE FROM events WHERE event_type IN (${placeholders})`)
2033
+ .run(...eventTypes);
2034
+ }
2035
+ let vacuumApplied = false;
2036
+ this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
2037
+ if (vacuum) {
2038
+ this.getDb().exec('VACUUM');
2039
+ this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
2040
+ vacuumApplied = true;
2041
+ }
2042
+ const afterSizes = await this.readStoreFileSizes();
1875
2043
  return [
1876
2044
  `candidate_events=${candidateEvents}`,
1877
- `apply=false`,
2045
+ `deleted_events=${candidateEvents}`,
2046
+ `apply=true`,
1878
2047
  `vacuum_requested=${vacuum}`,
1879
- `db_bytes=${beforeSizes.dbBytes}`,
1880
- `wal_bytes=${beforeSizes.walBytes}`,
1881
- `shm_bytes=${beforeSizes.shmBytes}`,
1882
- `total_bytes=${beforeSizes.totalBytes}`,
2048
+ `vacuum_applied=${vacuumApplied}`,
2049
+ `db_bytes_before=${beforeSizes.dbBytes}`,
2050
+ `wal_bytes_before=${beforeSizes.walBytes}`,
2051
+ `shm_bytes_before=${beforeSizes.shmBytes}`,
2052
+ `total_bytes_before=${beforeSizes.totalBytes}`,
2053
+ `db_bytes_after=${afterSizes.dbBytes}`,
2054
+ `wal_bytes_after=${afterSizes.walBytes}`,
2055
+ `shm_bytes_after=${afterSizes.shmBytes}`,
2056
+ `total_bytes_after=${afterSizes.totalBytes}`,
1883
2057
  ...(candidates.length > 0
1884
2058
  ? [
1885
- 'candidate_event_types:',
2059
+ 'deleted_event_types:',
1886
2060
  ...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
1887
2061
  ]
1888
- : ['candidate_event_types:', '- none']),
2062
+ : ['deleted_event_types:', '- none']),
1889
2063
  ].join('\n');
1890
- }
1891
- const eventTypes = candidates.map((row) => row.eventType);
1892
- if (eventTypes.length > 0) {
1893
- const placeholders = eventTypes.map(() => '?').join(', ');
1894
- this.getDb()
1895
- .prepare(`DELETE FROM events WHERE event_type IN (${placeholders})`)
1896
- .run(...eventTypes);
1897
- }
1898
- let vacuumApplied = false;
1899
- this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
1900
- if (vacuum) {
1901
- this.getDb().exec('VACUUM');
1902
- this.getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
1903
- vacuumApplied = true;
1904
- }
1905
- const afterSizes = await this.readStoreFileSizes();
1906
- return [
1907
- `candidate_events=${candidateEvents}`,
1908
- `deleted_events=${candidateEvents}`,
1909
- `apply=true`,
1910
- `vacuum_requested=${vacuum}`,
1911
- `vacuum_applied=${vacuumApplied}`,
1912
- `db_bytes_before=${beforeSizes.dbBytes}`,
1913
- `wal_bytes_before=${beforeSizes.walBytes}`,
1914
- `shm_bytes_before=${beforeSizes.shmBytes}`,
1915
- `total_bytes_before=${beforeSizes.totalBytes}`,
1916
- `db_bytes_after=${afterSizes.dbBytes}`,
1917
- `wal_bytes_after=${afterSizes.walBytes}`,
1918
- `shm_bytes_after=${afterSizes.shmBytes}`,
1919
- `total_bytes_after=${afterSizes.totalBytes}`,
1920
- ...(candidates.length > 0
1921
- ? [
1922
- 'deleted_event_types:',
1923
- ...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
1924
- ]
1925
- : ['deleted_event_types:', '- none']),
1926
- ].join('\n');
2064
+ });
1927
2065
  }
1928
2066
  async retentionReport(input) {
1929
2067
  await this.prepareForRead();
@@ -2051,44 +2189,50 @@ export class SqliteLcmStore {
2051
2189
  ].join('\n');
2052
2190
  }
2053
2191
  async exportSnapshot(input) {
2054
- await this.prepareForRead();
2055
- return exportStoreSnapshot({
2056
- workspaceDirectory: this.workspaceDirectory,
2057
- normalizeScope: this.normalizeScope.bind(this),
2058
- resolveScopeSessionIDs: this.resolveScopeSessionIDs.bind(this),
2059
- readScopedSessionRowsSync: this.readScopedSessionRowsSync.bind(this),
2060
- readScopedMessageRowsSync: this.readScopedMessageRowsSync.bind(this),
2061
- readScopedPartRowsSync: this.readScopedPartRowsSync.bind(this),
2062
- readScopedResumeRowsSync: this.readScopedResumeRowsSync.bind(this),
2063
- readScopedArtifactRowsSync: this.readScopedArtifactRowsSync.bind(this),
2064
- readScopedArtifactBlobRowsSync: this.readScopedArtifactBlobRowsSync.bind(this),
2065
- readScopedSummaryRowsSync: this.readScopedSummaryRowsSync.bind(this),
2066
- readScopedSummaryEdgeRowsSync: this.readScopedSummaryEdgeRowsSync.bind(this),
2067
- readScopedSummaryStateRowsSync: this.readScopedSummaryStateRowsSync.bind(this),
2068
- }, input);
2192
+ return this.withStoreActivity(async () => {
2193
+ await this.prepareForRead();
2194
+ return exportStoreSnapshot({
2195
+ workspaceDirectory: this.workspaceDirectory,
2196
+ normalizeScope: this.normalizeScope.bind(this),
2197
+ resolveScopeSessionIDs: this.resolveScopeSessionIDs.bind(this),
2198
+ readScopedSessionRowsSync: this.readScopedSessionRowsSync.bind(this),
2199
+ readScopedMessageRowsSync: this.readScopedMessageRowsSync.bind(this),
2200
+ readScopedPartRowsSync: this.readScopedPartRowsSync.bind(this),
2201
+ readScopedResumeRowsSync: this.readScopedResumeRowsSync.bind(this),
2202
+ readScopedArtifactRowsSync: this.readScopedArtifactRowsSync.bind(this),
2203
+ readScopedArtifactBlobRowsSync: this.readScopedArtifactBlobRowsSync.bind(this),
2204
+ readScopedSummaryRowsSync: this.readScopedSummaryRowsSync.bind(this),
2205
+ readScopedSummaryEdgeRowsSync: this.readScopedSummaryEdgeRowsSync.bind(this),
2206
+ readScopedSummaryStateRowsSync: this.readScopedSummaryStateRowsSync.bind(this),
2207
+ }, input);
2208
+ });
2069
2209
  }
2070
2210
  async importSnapshot(input) {
2071
- await this.prepareForRead();
2072
- return importStoreSnapshot({
2073
- workspaceDirectory: this.workspaceDirectory,
2074
- getDb: () => this.getDb(),
2075
- clearSessionDataSync: this.clearSessionDataSync.bind(this),
2076
- backfillArtifactBlobsSync: this.backfillArtifactBlobsSync.bind(this),
2077
- refreshAllLineageSync: this.refreshAllLineageSync.bind(this),
2078
- syncAllDerivedSessionStateSync: this.syncAllDerivedSessionStateSync.bind(this),
2079
- refreshSearchIndexesSync: this.refreshSearchIndexesSync.bind(this),
2080
- }, input);
2211
+ return this.withStoreActivity(async () => {
2212
+ await this.prepareForRead();
2213
+ return importStoreSnapshot({
2214
+ workspaceDirectory: this.workspaceDirectory,
2215
+ getDb: () => this.getDb(),
2216
+ clearSessionDataSync: this.clearSessionDataSync.bind(this),
2217
+ backfillArtifactBlobsSync: this.backfillArtifactBlobsSync.bind(this),
2218
+ refreshAllLineageSync: this.refreshAllLineageSync.bind(this),
2219
+ syncAllDerivedSessionStateSync: this.syncAllDerivedSessionStateSync.bind(this),
2220
+ refreshSearchIndexesSync: this.refreshSearchIndexesSync.bind(this),
2221
+ }, input);
2222
+ });
2081
2223
  }
2082
2224
  async resume(sessionID) {
2083
- await this.prepareForRead();
2084
- const resolvedSessionID = sessionID ?? this.latestSessionIDSync();
2085
- if (!resolvedSessionID)
2086
- return 'No stored resume snapshots yet.';
2087
- const existing = this.getResumeSync(resolvedSessionID);
2088
- if (existing && !this.isManagedResumeNote(existing))
2089
- return existing;
2090
- const generated = await this.buildCompactionContext(resolvedSessionID);
2091
- return generated ?? existing ?? 'No stored resume snapshot for that session.';
2225
+ return this.withStoreActivity(async () => {
2226
+ await this.prepareForRead();
2227
+ const resolvedSessionID = sessionID ?? this.latestSessionIDSync();
2228
+ if (!resolvedSessionID)
2229
+ return 'No stored resume snapshots yet.';
2230
+ const existing = this.getResumeSync(resolvedSessionID);
2231
+ if (existing && !this.isManagedResumeNote(existing))
2232
+ return existing;
2233
+ const generated = await this.buildCompactionContext(resolvedSessionID);
2234
+ return generated ?? existing ?? 'No stored resume snapshot for that session.';
2235
+ });
2092
2236
  }
2093
2237
  async expand(input) {
2094
2238
  await this.prepareForRead();
@@ -2145,45 +2289,53 @@ export class SqliteLcmStore {
2145
2289
  return note;
2146
2290
  }
2147
2291
  async transformMessages(messages) {
2148
- await this.prepareForRead();
2149
- if (messages.length < this.options.minMessagesForTransform)
2150
- return false;
2151
- const window = resolveArchiveTransformWindow(messages, this.options.freshTailMessages);
2152
- if (!window)
2153
- return false;
2154
- const { anchor, archived, recent } = window;
2155
- const roots = this.ensureSummaryGraphSync(anchor.info.sessionID, archived);
2156
- if (roots.length === 0)
2157
- return false;
2158
- const summary = buildActiveSummaryText(roots, archived.length, this.options.summaryCharBudget);
2159
- const retrieval = await this.buildAutomaticRetrievalContext(anchor.info.sessionID, recent, anchor);
2160
- for (const message of archived) {
2161
- this.compactMessageInPlace(message);
2162
- }
2163
- anchor.parts = anchor.parts.filter((part) => !isSyntheticLcmTextPart(part, ['archive-summary', 'retrieved-context']));
2164
- const syntheticParts = [];
2165
- if (retrieval) {
2292
+ return this.withStoreActivity(async () => {
2293
+ const validMessages = filterValidConversationMessages(messages, {
2294
+ operation: 'transformMessages',
2295
+ });
2296
+ if (validMessages.length !== messages.length) {
2297
+ messages.splice(0, messages.length, ...validMessages);
2298
+ }
2299
+ if (messages.length < this.options.minMessagesForTransform)
2300
+ return false;
2301
+ const window = resolveArchiveTransformWindow(messages, this.options.freshTailMessages);
2302
+ if (!window)
2303
+ return false;
2304
+ await this.prepareForRead();
2305
+ const { anchor, archived, recent } = window;
2306
+ const roots = this.ensureSummaryGraphSync(anchor.info.sessionID, archived);
2307
+ if (roots.length === 0)
2308
+ return false;
2309
+ const summary = buildActiveSummaryText(roots, archived.length, this.options.summaryCharBudget);
2310
+ const retrieval = await this.buildAutomaticRetrievalContext(anchor.info.sessionID, recent, anchor);
2311
+ for (const message of archived) {
2312
+ this.compactMessageInPlace(message);
2313
+ }
2314
+ anchor.parts = anchor.parts.filter((part) => !isSyntheticLcmTextPart(part, ['archive-summary', 'retrieved-context']));
2315
+ const syntheticParts = [];
2316
+ if (retrieval) {
2317
+ syntheticParts.push({
2318
+ id: `lcm-memory-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
2319
+ sessionID: anchor.info.sessionID,
2320
+ messageID: anchor.info.id,
2321
+ type: 'text',
2322
+ text: retrieval,
2323
+ synthetic: true,
2324
+ metadata: { opencodeLcm: 'retrieved-context' },
2325
+ });
2326
+ }
2166
2327
  syntheticParts.push({
2167
- id: `lcm-memory-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
2328
+ id: `lcm-summary-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
2168
2329
  sessionID: anchor.info.sessionID,
2169
2330
  messageID: anchor.info.id,
2170
2331
  type: 'text',
2171
- text: retrieval,
2332
+ text: summary,
2172
2333
  synthetic: true,
2173
- metadata: { opencodeLcm: 'retrieved-context' },
2334
+ metadata: { opencodeLcm: 'archive-summary' },
2174
2335
  });
2175
- }
2176
- syntheticParts.push({
2177
- id: `lcm-summary-${randomUUID().replace(/-/g, '').slice(0, 12)}`,
2178
- sessionID: anchor.info.sessionID,
2179
- messageID: anchor.info.id,
2180
- type: 'text',
2181
- text: summary,
2182
- synthetic: true,
2183
- metadata: { opencodeLcm: 'archive-summary' },
2336
+ anchor.parts.push(...syntheticParts);
2337
+ return true;
2184
2338
  });
2185
- anchor.parts.push(...syntheticParts);
2186
- return true;
2187
2339
  }
2188
2340
  systemHint() {
2189
2341
  if (!this.options.systemHint)
@@ -2195,6 +2347,40 @@ export class SqliteLcmStore {
2195
2347
  'Keep ctx_* usage selective and treat those calls as infrastructure, not task intent.',
2196
2348
  ].join(' ');
2197
2349
  }
2350
+ sanitizeSessionMessages(session, operation) {
2351
+ const messages = filterValidConversationMessages(session.messages, {
2352
+ operation,
2353
+ sessionID: session.sessionID,
2354
+ });
2355
+ return messages.length === session.messages.length ? session : { ...session, messages };
2356
+ }
2357
+ shouldSkipMalformedCapturedEvent(event) {
2358
+ const payload = event.payload;
2359
+ switch (payload.type) {
2360
+ case 'message.updated': {
2361
+ if (getValidMessageInfo(payload.properties.info))
2362
+ return false;
2363
+ logMalformedMessage('Skipping malformed message.updated event', {
2364
+ operation: 'capture',
2365
+ sessionID: event.sessionID,
2366
+ eventType: payload.type,
2367
+ });
2368
+ return true;
2369
+ }
2370
+ case 'message.part.updated': {
2371
+ if (isValidMessagePartUpdate(payload))
2372
+ return false;
2373
+ logMalformedMessage('Skipping malformed message.part.updated event', {
2374
+ operation: 'capture',
2375
+ sessionID: event.sessionID,
2376
+ eventType: payload.type,
2377
+ });
2378
+ return true;
2379
+ }
2380
+ default:
2381
+ return false;
2382
+ }
2383
+ }
2198
2384
  async buildAutomaticRetrievalContext(sessionID, recent, anchor) {
2199
2385
  if (!this.options.automaticRetrieval.enabled)
2200
2386
  return undefined;
@@ -3001,9 +3187,17 @@ export class SqliteLcmStore {
3001
3187
  return searchByScanModule(this.searchDeps(), query, sessionIDs, limit);
3002
3188
  }
3003
3189
  replaceMessageSearchRowsSync(session) {
3004
- replaceMessageSearchRowsModule(this.searchDeps(), redactStructuredValue(session, this.privacy));
3190
+ const sanitizedSession = this.sanitizeSessionMessages(session, 'replaceMessageSearchRowsSync');
3191
+ replaceMessageSearchRowsModule(this.searchDeps(), redactStructuredValue(sanitizedSession, this.privacy));
3005
3192
  }
3006
3193
  replaceMessageSearchRowSync(sessionID, message) {
3194
+ if (!getValidMessageInfo(message.info)) {
3195
+ logMalformedMessage('Skipping malformed message search row', {
3196
+ operation: 'replaceMessageSearchRowSync',
3197
+ sessionID,
3198
+ });
3199
+ return;
3200
+ }
3007
3201
  replaceMessageSearchRowModule(this.searchDeps(), sessionID, redactStructuredValue(message, this.privacy));
3008
3202
  }
3009
3203
  refreshSearchIndexesSync(sessionIDs) {
@@ -3132,15 +3326,29 @@ export class SqliteLcmStore {
3132
3326
  resolveLineageSync(sessionID, parentSessionID) {
3133
3327
  if (!parentSessionID)
3134
3328
  return { rootSessionID: sessionID, lineageDepth: 0 };
3135
- const parent = this.getDb()
3136
- .prepare('SELECT root_session_id, lineage_depth FROM sessions WHERE session_id = ?')
3137
- .get(parentSessionID);
3138
- if (!parent)
3139
- return { rootSessionID: parentSessionID, lineageDepth: 1 };
3140
- return {
3141
- rootSessionID: parent.root_session_id ?? parentSessionID,
3142
- lineageDepth: (parent.lineage_depth ?? 0) + 1,
3143
- };
3329
+ const seen = new Set([sessionID]);
3330
+ let currentSessionID = parentSessionID;
3331
+ let lineageDepth = 1;
3332
+ while (currentSessionID && !seen.has(currentSessionID)) {
3333
+ seen.add(currentSessionID);
3334
+ const parent = this.getDb()
3335
+ .prepare('SELECT parent_session_id, root_session_id, lineage_depth FROM sessions WHERE session_id = ?')
3336
+ .get(currentSessionID);
3337
+ if (!parent)
3338
+ return { rootSessionID: currentSessionID, lineageDepth };
3339
+ if (parent.root_session_id && parent.lineage_depth !== null) {
3340
+ return {
3341
+ rootSessionID: parent.root_session_id,
3342
+ lineageDepth: parent.lineage_depth + lineageDepth,
3343
+ };
3344
+ }
3345
+ if (!parent.parent_session_id) {
3346
+ return { rootSessionID: currentSessionID, lineageDepth };
3347
+ }
3348
+ currentSessionID = parent.parent_session_id;
3349
+ lineageDepth += 1;
3350
+ }
3351
+ return { rootSessionID: parentSessionID, lineageDepth };
3144
3352
  }
3145
3353
  applyEvent(session, event) {
3146
3354
  const payload = event.payload;
@@ -3200,26 +3408,33 @@ export class SqliteLcmStore {
3200
3408
  const row = safeQueryOne(this.getDb().prepare('SELECT note FROM resumes WHERE session_id = ?'), [sessionID], 'getResumeSync');
3201
3409
  return row?.note;
3202
3410
  }
3203
- readSessionHeaderSync(sessionID) {
3204
- const row = safeQueryOne(this.getDb().prepare('SELECT * FROM sessions WHERE session_id = ?'), [sessionID], 'readSessionHeaderSync');
3205
- if (!row)
3206
- return undefined;
3411
+ materializeSessionRow(row, messages = []) {
3412
+ const parentSessionID = row.parent_session_id ?? undefined;
3413
+ const derivedLineage = row.root_session_id === null || row.lineage_depth === null
3414
+ ? this.resolveLineageSync(row.session_id, parentSessionID)
3415
+ : undefined;
3207
3416
  return {
3208
3417
  sessionID: row.session_id,
3209
3418
  title: row.title ?? undefined,
3210
3419
  directory: row.session_directory ?? undefined,
3211
- parentSessionID: row.parent_session_id ?? undefined,
3212
- rootSessionID: row.root_session_id ?? undefined,
3213
- lineageDepth: row.lineage_depth ?? undefined,
3420
+ parentSessionID,
3421
+ rootSessionID: row.root_session_id ?? derivedLineage?.rootSessionID,
3422
+ lineageDepth: row.lineage_depth ?? derivedLineage?.lineageDepth,
3214
3423
  pinned: Boolean(row.pinned),
3215
3424
  pinReason: row.pin_reason ?? undefined,
3216
3425
  updatedAt: row.updated_at,
3217
3426
  compactedAt: row.compacted_at ?? undefined,
3218
3427
  deleted: Boolean(row.deleted),
3219
3428
  eventCount: row.event_count,
3220
- messages: [],
3429
+ messages,
3221
3430
  };
3222
3431
  }
3432
+ readSessionHeaderSync(sessionID) {
3433
+ const row = safeQueryOne(this.getDb().prepare('SELECT * FROM sessions WHERE session_id = ?'), [sessionID], 'readSessionHeaderSync');
3434
+ if (!row)
3435
+ return undefined;
3436
+ return this.materializeSessionRow(row);
3437
+ }
3223
3438
  clearSessionDataSync(sessionID) {
3224
3439
  const db = this.getDb();
3225
3440
  db.prepare('DELETE FROM message_fts WHERE session_id = ?').run(sessionID);
@@ -3355,25 +3570,14 @@ export class SqliteLcmStore {
3355
3570
  // Build NormalizedSession results
3356
3571
  return sessionIDs.map((sessionID) => {
3357
3572
  const row = sessionMap.get(sessionID);
3358
- const messages = messagesBySession.get(sessionID) ?? [];
3573
+ const messages = filterValidConversationMessages(messagesBySession.get(sessionID) ?? [], {
3574
+ operation: 'readSessionsBatchSync',
3575
+ sessionID,
3576
+ });
3359
3577
  if (!row) {
3360
3578
  return { ...emptySession(sessionID), messages };
3361
3579
  }
3362
- return {
3363
- sessionID: row.session_id,
3364
- title: row.title ?? undefined,
3365
- directory: row.session_directory ?? undefined,
3366
- parentSessionID: row.parent_session_id ?? undefined,
3367
- rootSessionID: row.root_session_id ?? undefined,
3368
- lineageDepth: row.lineage_depth ?? undefined,
3369
- pinned: Boolean(row.pinned),
3370
- pinReason: row.pin_reason ?? undefined,
3371
- updatedAt: row.updated_at,
3372
- compactedAt: row.compacted_at ?? undefined,
3373
- deleted: Boolean(row.deleted),
3374
- eventCount: row.event_count,
3375
- messages,
3376
- };
3580
+ return this.materializeSessionRow(row, messages);
3377
3581
  });
3378
3582
  }
3379
3583
  readSessionSync(sessionID) {
@@ -3400,28 +3604,14 @@ export class SqliteLcmStore {
3400
3604
  parts.push(part);
3401
3605
  partsByMessage.set(partRow.message_id, parts);
3402
3606
  }
3403
- const messages = messageRows.map((messageRow) => ({
3607
+ const messages = filterValidConversationMessages(messageRows.map((messageRow) => ({
3404
3608
  info: parseJson(messageRow.info_json),
3405
3609
  parts: partsByMessage.get(messageRow.message_id) ?? [],
3406
- }));
3610
+ })), { operation: 'readSessionSync', sessionID });
3407
3611
  if (!row) {
3408
3612
  return { ...emptySession(sessionID), messages };
3409
3613
  }
3410
- return {
3411
- sessionID: row.session_id,
3412
- title: row.title ?? undefined,
3413
- directory: row.session_directory ?? undefined,
3414
- parentSessionID: row.parent_session_id ?? undefined,
3415
- rootSessionID: row.root_session_id ?? undefined,
3416
- lineageDepth: row.lineage_depth ?? undefined,
3417
- pinned: Boolean(row.pinned),
3418
- pinReason: row.pin_reason ?? undefined,
3419
- updatedAt: row.updated_at,
3420
- compactedAt: row.compacted_at ?? undefined,
3421
- deleted: Boolean(row.deleted),
3422
- eventCount: row.event_count,
3423
- messages,
3424
- };
3614
+ return this.materializeSessionRow(row, messages);
3425
3615
  }
3426
3616
  prepareSessionForPersistence(session) {
3427
3617
  const parentSessionID = this.sanitizeParentSessionIDSync(session.sessionID, session.parentSessionID);
@@ -3492,8 +3682,16 @@ export class SqliteLcmStore {
3492
3682
  const parts = db
3493
3683
  .prepare('SELECT * FROM parts WHERE session_id = ? AND message_id = ? ORDER BY sort_key ASC, part_id ASC')
3494
3684
  .all(sessionID, messageID);
3685
+ const info = parseJson(row.info_json);
3686
+ if (!getValidMessageInfo(info)) {
3687
+ logMalformedMessage('Skipping malformed stored message', {
3688
+ operation: 'readMessageSync',
3689
+ sessionID,
3690
+ }, { messageID });
3691
+ return undefined;
3692
+ }
3495
3693
  return {
3496
- info: parseJson(row.info_json),
3694
+ info,
3497
3695
  parts: parts.map((partRow) => {
3498
3696
  const part = parseJson(partRow.part_json);
3499
3697
  hydratePartFromArtifacts(part, artifactsByPart.get(part.id) ?? []);
@@ -3607,7 +3805,15 @@ export class SqliteLcmStore {
3607
3805
  event_count = excluded.event_count`).run(session.sessionID, title ?? null, directory ?? null, worktreeKey ?? null, session.parentSessionID ?? null, session.rootSessionID ?? session.sessionID, session.lineageDepth ?? 0, session.pinned ? 1 : 0, pinReason ?? null, session.updatedAt, session.compactedAt ?? null, session.deleted ? 1 : 0, session.eventCount);
3608
3806
  }
3609
3807
  upsertMessageInfoSync(sessionID, message) {
3610
- const info = redactStructuredValue(message.info, this.privacy);
3808
+ const validated = getValidMessageInfo(message.info);
3809
+ if (!validated) {
3810
+ logMalformedMessage('Skipping malformed message metadata', {
3811
+ operation: 'upsertMessageInfoSync',
3812
+ sessionID,
3813
+ });
3814
+ return;
3815
+ }
3816
+ const info = redactStructuredValue(validated, this.privacy);
3611
3817
  this.getDb()
3612
3818
  .prepare(`INSERT INTO messages (message_id, session_id, created_at, info_json)
3613
3819
  VALUES (?, ?, ?, ?)
@@ -3638,7 +3844,7 @@ export class SqliteLcmStore {
3638
3844
  return buildArtifactSearchContentModule(artifact);
3639
3845
  }
3640
3846
  async externalizeSession(session) {
3641
- return externalizeSessionModule(this.artifactDeps(), session);
3847
+ return externalizeSessionModule(this.artifactDeps(), this.sanitizeSessionMessages(session, 'externalizeSession'));
3642
3848
  }
3643
3849
  writeEvent(event) {
3644
3850
  const payloadStub = event.type.startsWith('message.') || event.type.startsWith('session.')