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/CHANGELOG.md +6 -0
- package/dist/store-doctor.d.ts +73 -0
- package/dist/store-doctor.js +106 -0
- package/dist/store-schema.d.ts +8 -0
- package/dist/store-schema.js +23 -0
- package/dist/store-session-read.d.ts +97 -0
- package/dist/store-session-read.js +80 -0
- package/dist/store.d.ts +12 -0
- package/dist/store.js +477 -271
- package/package.json +1 -1
- package/src/store.ts +577 -299
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);
|
|
@@ -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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
911
|
+
await this.capture(event);
|
|
912
|
+
});
|
|
822
913
|
}
|
|
823
914
|
|
|
824
915
|
async flushDeferredPartUpdates(): Promise<void> {
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
});
|
|
929
|
+
})().finally(() => {
|
|
930
|
+
this.pendingPartUpdateFlushPromise = undefined;
|
|
931
|
+
if (this.pendingPartUpdates.size > 0) this.scheduleDeferredPartUpdateFlush();
|
|
932
|
+
});
|
|
841
933
|
|
|
842
|
-
|
|
934
|
+
return this.pendingPartUpdateFlushPromise;
|
|
935
|
+
});
|
|
843
936
|
}
|
|
844
937
|
|
|
845
938
|
private async ensureDbReady(): Promise<void> {
|
|
846
|
-
if (this.
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
1265
|
+
return this.withStoreActivity(async () => {
|
|
1266
|
+
const normalized = normalizeEvent(event);
|
|
1267
|
+
if (!normalized) return;
|
|
1112
1268
|
|
|
1113
|
-
|
|
1114
|
-
if (!normalized) return;
|
|
1269
|
+
if (this.shouldSkipMalformedCapturedEvent(normalized)) return;
|
|
1115
1270
|
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
-
|
|
1121
|
-
if (!this.shouldPersistSessionForEvent(normalized.type)) return;
|
|
1276
|
+
await this.ensureDeferredInitComplete();
|
|
1122
1277
|
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
1282
|
+
if (!normalized.sessionID || !shouldPersistSession) return;
|
|
1135
1283
|
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1313
|
+
if (shouldSyncDerivedState) {
|
|
1314
|
+
this.syncDerivedSessionStateSync(this.readSessionSync(normalized.sessionID));
|
|
1315
|
+
}
|
|
1152
1316
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
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
|
-
|
|
1164
|
-
|
|
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
|
-
|
|
1740
|
-
this.getArchivedMessages(
|
|
1907
|
+
sanitizedSession.sessionID,
|
|
1908
|
+
this.getArchivedMessages(sanitizedSession.messages),
|
|
1741
1909
|
);
|
|
1742
|
-
this.writeResumeSync(
|
|
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
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
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
|
-
`
|
|
2734
|
+
`deleted_events=${candidateEvents}`,
|
|
2735
|
+
`apply=true`,
|
|
2531
2736
|
`vacuum_requested=${vacuum}`,
|
|
2532
|
-
`
|
|
2533
|
-
`
|
|
2534
|
-
`
|
|
2535
|
-
`
|
|
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
|
-
'
|
|
2748
|
+
'deleted_event_types:',
|
|
2539
2749
|
...candidates.slice(0, limit).map((row) => `- ${row.eventType} count=${row.count}`),
|
|
2540
2750
|
]
|
|
2541
|
-
: ['
|
|
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
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
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
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
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
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
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
|
-
|
|
2799
|
-
|
|
2973
|
+
const existing = this.getResumeSync(resolvedSessionID);
|
|
2974
|
+
if (existing && !this.isManagedResumeNote(existing)) return existing;
|
|
2800
2975
|
|
|
2801
|
-
|
|
2802
|
-
|
|
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
|
-
|
|
2887
|
-
|
|
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
|
-
|
|
2890
|
-
|
|
3071
|
+
const window = resolveArchiveTransformWindow(messages, this.options.freshTailMessages);
|
|
3072
|
+
if (!window) return false;
|
|
2891
3073
|
|
|
2892
|
-
|
|
3074
|
+
await this.prepareForRead();
|
|
2893
3075
|
|
|
2894
|
-
|
|
2895
|
-
if (roots.length === 0) return false;
|
|
3076
|
+
const { anchor, archived, recent } = window;
|
|
2896
3077
|
|
|
2897
|
-
|
|
2898
|
-
|
|
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
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
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-
|
|
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:
|
|
3115
|
+
text: summary,
|
|
2918
3116
|
synthetic: true,
|
|
2919
|
-
metadata: { opencodeLcm: '
|
|
3117
|
+
metadata: { opencodeLcm: 'archive-summary' },
|
|
2920
3118
|
});
|
|
2921
|
-
|
|
2922
|
-
|
|
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
|
-
|
|
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
|
|
4244
|
-
|
|
4245
|
-
|
|
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
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
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
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
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
|
|
4338
|
-
rootSessionID: row.root_session_id ??
|
|
4339
|
-
lineageDepth: row.lineage_depth ??
|
|
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 =
|
|
4563
|
-
|
|
4564
|
-
|
|
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
|
|
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
|
|
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(
|
|
5162
|
+
return externalizeSessionModule(
|
|
5163
|
+
this.artifactDeps(),
|
|
5164
|
+
this.sanitizeSessionMessages(session, 'externalizeSession'),
|
|
5165
|
+
);
|
|
4888
5166
|
}
|
|
4889
5167
|
|
|
4890
5168
|
private writeEvent(event: CapturedEvent): void {
|