opencode-lcm 0.13.0 → 0.13.2

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/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.2] - 2026-04-08
11
+
12
+ ### Fixed
13
+ - Republish of 0.13.1 malformed-message hardening through CI with provenance
14
+
15
+ ## [0.13.1] - 2026-04-07
16
+
17
+ ### Fixed
18
+ - Archive transform now removes malformed messages from the outbound message array before returning control to OpenCode, preventing follow-on backend `Bad Request` failures
19
+ - Archive, resume, describe, search indexing, and capture paths now skip malformed `message.info` metadata defensively instead of throwing when required fields are missing
20
+
10
21
  ### Added
11
22
  - Opt-in `perf:archive` harness for large-archive regression coverage across transform, grep, snapshot, reopen, resume, and retention paths
12
23
  - Separate advisory `Archive Performance` workflow for scheduled/manual perf runs with JSON artifact upload
package/dist/store.d.ts CHANGED
@@ -248,6 +248,8 @@ export declare class SqliteLcmStore {
248
248
  buildCompactionContext(sessionID: string): Promise<string | undefined>;
249
249
  transformMessages(messages: ConversationMessage[]): Promise<boolean>;
250
250
  systemHint(): string | undefined;
251
+ private sanitizeSessionMessages;
252
+ private shouldSkipMalformedCapturedEvent;
251
253
  private buildAutomaticRetrievalContext;
252
254
  private buildAutomaticRetrievalQuery;
253
255
  private buildAutomaticRetrievalQueries;
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);
@@ -910,6 +958,8 @@ export class SqliteLcmStore {
910
958
  const normalized = normalizeEvent(event);
911
959
  if (!normalized)
912
960
  return;
961
+ if (this.shouldSkipMalformedCapturedEvent(normalized))
962
+ return;
913
963
  const shouldRecord = this.shouldRecordEvent(normalized.type);
914
964
  const shouldPersistSession = Boolean(normalized.sessionID) && this.shouldPersistSessionForEvent(normalized.type);
915
965
  if (!shouldRecord && !shouldPersistSession)
@@ -1346,8 +1396,9 @@ export class SqliteLcmStore {
1346
1396
  }
1347
1397
  }
1348
1398
  syncDerivedSessionStateSync(session, preserveExistingResume = false) {
1349
- const roots = this.ensureSummaryGraphSync(session.sessionID, this.getArchivedMessages(session.messages));
1350
- 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);
1351
1402
  return roots;
1352
1403
  }
1353
1404
  syncDerivedLineageSubtreeSync(sessionID, preserveExistingResume = false) {
@@ -2239,6 +2290,12 @@ export class SqliteLcmStore {
2239
2290
  }
2240
2291
  async transformMessages(messages) {
2241
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
+ }
2242
2299
  if (messages.length < this.options.minMessagesForTransform)
2243
2300
  return false;
2244
2301
  const window = resolveArchiveTransformWindow(messages, this.options.freshTailMessages);
@@ -2290,6 +2347,40 @@ export class SqliteLcmStore {
2290
2347
  'Keep ctx_* usage selective and treat those calls as infrastructure, not task intent.',
2291
2348
  ].join(' ');
2292
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
+ }
2293
2384
  async buildAutomaticRetrievalContext(sessionID, recent, anchor) {
2294
2385
  if (!this.options.automaticRetrieval.enabled)
2295
2386
  return undefined;
@@ -3096,9 +3187,17 @@ export class SqliteLcmStore {
3096
3187
  return searchByScanModule(this.searchDeps(), query, sessionIDs, limit);
3097
3188
  }
3098
3189
  replaceMessageSearchRowsSync(session) {
3099
- replaceMessageSearchRowsModule(this.searchDeps(), redactStructuredValue(session, this.privacy));
3190
+ const sanitizedSession = this.sanitizeSessionMessages(session, 'replaceMessageSearchRowsSync');
3191
+ replaceMessageSearchRowsModule(this.searchDeps(), redactStructuredValue(sanitizedSession, this.privacy));
3100
3192
  }
3101
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
+ }
3102
3201
  replaceMessageSearchRowModule(this.searchDeps(), sessionID, redactStructuredValue(message, this.privacy));
3103
3202
  }
3104
3203
  refreshSearchIndexesSync(sessionIDs) {
@@ -3471,7 +3570,10 @@ export class SqliteLcmStore {
3471
3570
  // Build NormalizedSession results
3472
3571
  return sessionIDs.map((sessionID) => {
3473
3572
  const row = sessionMap.get(sessionID);
3474
- const messages = messagesBySession.get(sessionID) ?? [];
3573
+ const messages = filterValidConversationMessages(messagesBySession.get(sessionID) ?? [], {
3574
+ operation: 'readSessionsBatchSync',
3575
+ sessionID,
3576
+ });
3475
3577
  if (!row) {
3476
3578
  return { ...emptySession(sessionID), messages };
3477
3579
  }
@@ -3502,10 +3604,10 @@ export class SqliteLcmStore {
3502
3604
  parts.push(part);
3503
3605
  partsByMessage.set(partRow.message_id, parts);
3504
3606
  }
3505
- const messages = messageRows.map((messageRow) => ({
3607
+ const messages = filterValidConversationMessages(messageRows.map((messageRow) => ({
3506
3608
  info: parseJson(messageRow.info_json),
3507
3609
  parts: partsByMessage.get(messageRow.message_id) ?? [],
3508
- }));
3610
+ })), { operation: 'readSessionSync', sessionID });
3509
3611
  if (!row) {
3510
3612
  return { ...emptySession(sessionID), messages };
3511
3613
  }
@@ -3580,8 +3682,16 @@ export class SqliteLcmStore {
3580
3682
  const parts = db
3581
3683
  .prepare('SELECT * FROM parts WHERE session_id = ? AND message_id = ? ORDER BY sort_key ASC, part_id ASC')
3582
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
+ }
3583
3693
  return {
3584
- info: parseJson(row.info_json),
3694
+ info,
3585
3695
  parts: parts.map((partRow) => {
3586
3696
  const part = parseJson(partRow.part_json);
3587
3697
  hydratePartFromArtifacts(part, artifactsByPart.get(part.id) ?? []);
@@ -3695,7 +3805,15 @@ export class SqliteLcmStore {
3695
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);
3696
3806
  }
3697
3807
  upsertMessageInfoSync(sessionID, message) {
3698
- 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);
3699
3817
  this.getDb()
3700
3818
  .prepare(`INSERT INTO messages (message_id, session_id, created_at, info_json)
3701
3819
  VALUES (?, ?, ?, ?)
@@ -3726,7 +3844,7 @@ export class SqliteLcmStore {
3726
3844
  return buildArtifactSearchContentModule(artifact);
3727
3845
  }
3728
3846
  async externalizeSession(session) {
3729
- return externalizeSessionModule(this.artifactDeps(), session);
3847
+ return externalizeSessionModule(this.artifactDeps(), this.sanitizeSessionMessages(session, 'externalizeSession'));
3730
3848
  }
3731
3849
  writeEvent(event) {
3732
3850
  const payloadStub = event.type.startsWith('message.') || event.type.startsWith('session.')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-lcm",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "Long-memory plugin for OpenCode with context-mode interop",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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);
@@ -1198,6 +1266,8 @@ export class SqliteLcmStore {
1198
1266
  const normalized = normalizeEvent(event);
1199
1267
  if (!normalized) return;
1200
1268
 
1269
+ if (this.shouldSkipMalformedCapturedEvent(normalized)) return;
1270
+
1201
1271
  const shouldRecord = this.shouldRecordEvent(normalized.type);
1202
1272
  const shouldPersistSession =
1203
1273
  Boolean(normalized.sessionID) && this.shouldPersistSessionForEvent(normalized.type);
@@ -1832,11 +1902,12 @@ export class SqliteLcmStore {
1832
1902
  session: NormalizedSession,
1833
1903
  preserveExistingResume = false,
1834
1904
  ): SummaryNodeData[] {
1905
+ const sanitizedSession = this.sanitizeSessionMessages(session, 'syncDerivedSessionStateSync');
1835
1906
  const roots = this.ensureSummaryGraphSync(
1836
- session.sessionID,
1837
- this.getArchivedMessages(session.messages),
1907
+ sanitizedSession.sessionID,
1908
+ this.getArchivedMessages(sanitizedSession.messages),
1838
1909
  );
1839
- this.writeResumeSync(session, roots, preserveExistingResume);
1910
+ this.writeResumeSync(sanitizedSession, roots, preserveExistingResume);
1840
1911
  return roots;
1841
1912
  }
1842
1913
 
@@ -2989,6 +3060,12 @@ export class SqliteLcmStore {
2989
3060
 
2990
3061
  async transformMessages(messages: ConversationMessage[]): Promise<boolean> {
2991
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
+ }
2992
3069
  if (messages.length < this.options.minMessagesForTransform) return false;
2993
3070
 
2994
3071
  const window = resolveArchiveTransformWindow(messages, this.options.freshTailMessages);
@@ -3055,6 +3132,44 @@ export class SqliteLcmStore {
3055
3132
  ].join(' ');
3056
3133
  }
3057
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
+
3058
3173
  private async buildAutomaticRetrievalContext(
3059
3174
  sessionID: string,
3060
3175
  recent: ConversationMessage[],
@@ -4184,10 +4299,22 @@ export class SqliteLcmStore {
4184
4299
  }
4185
4300
 
4186
4301
  private replaceMessageSearchRowsSync(session: NormalizedSession): void {
4187
- 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
+ );
4188
4307
  }
4189
4308
 
4190
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
+
4191
4318
  replaceMessageSearchRowModule(
4192
4319
  this.searchDeps(),
4193
4320
  sessionID,
@@ -4651,7 +4778,10 @@ export class SqliteLcmStore {
4651
4778
  // Build NormalizedSession results
4652
4779
  return sessionIDs.map((sessionID) => {
4653
4780
  const row = sessionMap.get(sessionID);
4654
- const messages = messagesBySession.get(sessionID) ?? [];
4781
+ const messages = filterValidConversationMessages(messagesBySession.get(sessionID) ?? [], {
4782
+ operation: 'readSessionsBatchSync',
4783
+ sessionID,
4784
+ });
4655
4785
  if (!row) {
4656
4786
  return { ...emptySession(sessionID), messages };
4657
4787
  }
@@ -4693,10 +4823,13 @@ export class SqliteLcmStore {
4693
4823
  partsByMessage.set(partRow.message_id, parts);
4694
4824
  }
4695
4825
 
4696
- const messages = messageRows.map((messageRow) => ({
4697
- info: parseJson<Message>(messageRow.info_json),
4698
- parts: partsByMessage.get(messageRow.message_id) ?? [],
4699
- }));
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
+ );
4700
4833
 
4701
4834
  if (!row) {
4702
4835
  return { ...emptySession(sessionID), messages };
@@ -4789,8 +4922,21 @@ export class SqliteLcmStore {
4789
4922
  )
4790
4923
  .all(sessionID, messageID) as PartRow[];
4791
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
+
4792
4938
  return {
4793
- info: parseJson<Message>(row.info_json),
4939
+ info,
4794
4940
  parts: parts.map((partRow) => {
4795
4941
  const part = parseJson<Part>(partRow.part_json);
4796
4942
  hydratePartFromArtifacts(part, artifactsByPart.get(part.id) ?? []);
@@ -4952,7 +5098,16 @@ export class SqliteLcmStore {
4952
5098
  }
4953
5099
 
4954
5100
  private upsertMessageInfoSync(sessionID: string, message: ConversationMessage): void {
4955
- 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);
4956
5111
  this.getDb()
4957
5112
  .prepare(
4958
5113
  `INSERT INTO messages (message_id, session_id, created_at, info_json)
@@ -5004,7 +5159,10 @@ export class SqliteLcmStore {
5004
5159
  }
5005
5160
 
5006
5161
  private async externalizeSession(session: NormalizedSession): Promise<ExternalizedSession> {
5007
- return externalizeSessionModule(this.artifactDeps(), session);
5162
+ return externalizeSessionModule(
5163
+ this.artifactDeps(),
5164
+ this.sanitizeSessionMessages(session, 'externalizeSession'),
5165
+ );
5008
5166
  }
5009
5167
 
5010
5168
  private writeEvent(event: CapturedEvent): void {