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 +11 -0
- package/dist/store.d.ts +2 -0
- package/dist/store.js +129 -11
- package/package.json +1 -1
- package/src/store.ts +172 -14
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
|
-
|
|
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
|
|
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
|
|
1350
|
-
this.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
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
|
-
|
|
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(
|
|
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
|
-
|
|
1837
|
-
this.getArchivedMessages(
|
|
1907
|
+
sanitizedSession.sessionID,
|
|
1908
|
+
this.getArchivedMessages(sanitizedSession.messages),
|
|
1838
1909
|
);
|
|
1839
|
-
this.writeResumeSync(
|
|
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
|
-
|
|
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 =
|
|
4697
|
-
|
|
4698
|
-
|
|
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
|
|
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
|
|
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(
|
|
5162
|
+
return externalizeSessionModule(
|
|
5163
|
+
this.artifactDeps(),
|
|
5164
|
+
this.sanitizeSessionMessages(session, 'externalizeSession'),
|
|
5165
|
+
);
|
|
5008
5166
|
}
|
|
5009
5167
|
|
|
5010
5168
|
private writeEvent(event: CapturedEvent): void {
|