opencode-lcm 0.11.0 → 0.12.0
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/README.md +16 -3
- package/dist/store.d.ts +10 -4
- package/dist/store.js +112 -58
- package/package.json +1 -1
- package/src/store.ts +155 -79
package/README.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Opencode-lcm (Lossless Context Memory)
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/opencode-lcm)
|
|
3
4
|
[](https://github.com/Plutarch01/opencode-lcm/actions/workflows/ci.yml)
|
|
4
5
|
[](https://opensource.org/licenses/MIT)
|
|
5
6
|
|
|
6
|
-
A transparent long-memory plugin for [OpenCode](https://github.com/sst/opencode), based on the [Lossless Context Memory (LCM)](https://papers.voltropy.com/LCM) research. It captures older session context outside the active prompt, compresses it into searchable summaries and artifacts, then automatically recalls relevant details back into the prompt when the current turn needs them. The model does not become smarter, but it behaves much better across long, compacted sessions because important prior context stops disappearing.
|
|
7
|
+
A transparent long-memory plugin for [OpenCode](https://github.com/sst/opencode), based on the [Lossless Context Memory (LCM)](https://papers.voltropy.com/LCM) research by Voltropy. It captures older session context outside the active prompt, compresses it into searchable summaries and artifacts, then automatically recalls relevant details back into the prompt when the current turn needs them. The model does not become smarter, but it behaves much better across long, compacted sessions because important prior context stops disappearing.
|
|
7
8
|
|
|
8
9
|
<!-- Add a demo screenshot or GIF here -->
|
|
9
10
|
<!--  -->
|
|
@@ -31,12 +32,24 @@ OpenCode will automatically download the latest version from npm on startup. No
|
|
|
31
32
|
### From source
|
|
32
33
|
|
|
33
34
|
```sh
|
|
34
|
-
git clone https://github.com/
|
|
35
|
+
git clone https://github.com/Plutarch01/opencode-lcm.git
|
|
35
36
|
cd opencode-lcm
|
|
36
37
|
npm install
|
|
37
38
|
npm run build
|
|
38
39
|
```
|
|
39
40
|
|
|
41
|
+
Then copy or symlink the built plugin into your plugins directory:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
# Project-level
|
|
45
|
+
cp dist/index.js .opencode/plugins/opencode-lcm.js
|
|
46
|
+
|
|
47
|
+
# Or global
|
|
48
|
+
cp dist/index.js ~/.config/opencode/plugins/opencode-lcm.js
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Local plugins in `.opencode/plugins/` or `~/.config/opencode/plugins/` are loaded automatically by OpenCode at startup.
|
|
52
|
+
|
|
40
53
|
## How It Works
|
|
41
54
|
|
|
42
55
|
OpenCode handles compaction normally — when the conversation gets too large, it shrinks the prompt. `opencode-lcm` works alongside that by saving older details *outside* the prompt, then searching that archive later to pull back only what matters.
|
package/dist/store.d.ts
CHANGED
|
@@ -78,8 +78,14 @@ type SqliteRuntimeOptions = {
|
|
|
78
78
|
isBunRuntime?: boolean;
|
|
79
79
|
platform?: string | undefined;
|
|
80
80
|
};
|
|
81
|
+
type CaptureHydrationMode = 'full' | 'targeted';
|
|
82
|
+
type CaptureHydrationOptions = {
|
|
83
|
+
isBunRuntime?: boolean;
|
|
84
|
+
platform?: string | undefined;
|
|
85
|
+
};
|
|
81
86
|
export declare function resolveSqliteRuntimeCandidates(options?: SqliteRuntimeOptions): SqliteRuntime[];
|
|
82
87
|
export declare function resolveSqliteRuntime(options?: SqliteRuntimeOptions): SqliteRuntime;
|
|
88
|
+
export declare function resolveCaptureHydrationMode(options?: CaptureHydrationOptions): CaptureHydrationMode;
|
|
83
89
|
export declare class SqliteLcmStore {
|
|
84
90
|
private readonly options;
|
|
85
91
|
private static readonly deferredPartUpdateDelayMs;
|
|
@@ -141,10 +147,6 @@ export declare class SqliteLcmStore {
|
|
|
141
147
|
private shouldRecordEvent;
|
|
142
148
|
private shouldSyncDerivedLineageSubtree;
|
|
143
149
|
private shouldCleanupOrphanBlobsForEvent;
|
|
144
|
-
private captureArtifactHydrationMessageIDs;
|
|
145
|
-
private archivedMessageIDs;
|
|
146
|
-
private didArchivedMessagesChange;
|
|
147
|
-
private isArchivedMessage;
|
|
148
150
|
private shouldSyncDerivedSessionStateForEvent;
|
|
149
151
|
private syncAllDerivedSessionStateSync;
|
|
150
152
|
private syncDerivedSessionStateSync;
|
|
@@ -296,6 +298,10 @@ export declare class SqliteLcmStore {
|
|
|
296
298
|
private readSessionSync;
|
|
297
299
|
private prepareSessionForPersistence;
|
|
298
300
|
private sanitizeParentSessionIDSync;
|
|
301
|
+
private readSessionForCaptureSync;
|
|
302
|
+
private readMessageSync;
|
|
303
|
+
private readMessageCountSync;
|
|
304
|
+
private isMessageArchivedSync;
|
|
299
305
|
private persistCapturedSession;
|
|
300
306
|
private persistSession;
|
|
301
307
|
private persistStoredSessionSync;
|
package/dist/store.js
CHANGED
|
@@ -167,6 +167,14 @@ function getDeferredPartUpdateKey(event) {
|
|
|
167
167
|
function compareMessages(a, b) {
|
|
168
168
|
return a.info.time.created - b.info.time.created;
|
|
169
169
|
}
|
|
170
|
+
function emptySession(sessionID) {
|
|
171
|
+
return {
|
|
172
|
+
sessionID,
|
|
173
|
+
updatedAt: 0,
|
|
174
|
+
eventCount: 0,
|
|
175
|
+
messages: [],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
170
178
|
function buildSummaryNodeID(sessionID, level, slot) {
|
|
171
179
|
return `sum_${hashContent(`summary:${sessionID}`).slice(0, 12)}_l${level}_p${slot}`;
|
|
172
180
|
}
|
|
@@ -347,6 +355,15 @@ export function resolveSqliteRuntimeCandidates(options) {
|
|
|
347
355
|
export function resolveSqliteRuntime(options) {
|
|
348
356
|
return resolveSqliteRuntimeCandidates(options)[0];
|
|
349
357
|
}
|
|
358
|
+
export function resolveCaptureHydrationMode(options) {
|
|
359
|
+
const isBunRuntime = options?.isBunRuntime ?? (typeof globalThis === 'object' && 'Bun' in globalThis);
|
|
360
|
+
const platform = options?.platform ?? process.platform;
|
|
361
|
+
// The targeted fresh-tail capture path is safe under Node, but the bundled
|
|
362
|
+
// Bun runtime on Windows has been the only environment where users have
|
|
363
|
+
// reported native crashes in this hot path. Keep the older full-session
|
|
364
|
+
// hydration there until Bun/Windows is proven stable again.
|
|
365
|
+
return isBunRuntime && platform === 'win32' ? 'full' : 'targeted';
|
|
366
|
+
}
|
|
350
367
|
function isSqliteRuntimeImportError(runtime, error) {
|
|
351
368
|
const code = typeof error === 'object' && error && 'code' in error && typeof error.code === 'string'
|
|
352
369
|
? error.code
|
|
@@ -820,15 +837,15 @@ export class SqliteLcmStore {
|
|
|
820
837
|
return;
|
|
821
838
|
if (!this.shouldPersistSessionForEvent(normalized.type))
|
|
822
839
|
return;
|
|
823
|
-
const session =
|
|
824
|
-
|
|
825
|
-
|
|
840
|
+
const session = resolveCaptureHydrationMode() === 'targeted'
|
|
841
|
+
? this.readSessionForCaptureSync(normalized)
|
|
842
|
+
: this.readSessionSync(normalized.sessionID);
|
|
826
843
|
const previousParentSessionID = session.parentSessionID;
|
|
844
|
+
const shouldSyncDerivedState = this.shouldSyncDerivedSessionStateForEvent(session, normalized);
|
|
827
845
|
let next = this.applyEvent(session, normalized);
|
|
828
846
|
next.updatedAt = Math.max(next.updatedAt, normalized.timestamp);
|
|
829
847
|
next.eventCount += 1;
|
|
830
848
|
next = this.prepareSessionForPersistence(next);
|
|
831
|
-
const shouldSyncDerivedState = this.shouldSyncDerivedSessionStateForEvent(session, next, normalized);
|
|
832
849
|
await this.persistCapturedSession(next, normalized);
|
|
833
850
|
if (this.shouldRefreshLineageForEvent(normalized.type)) {
|
|
834
851
|
this.refreshAllLineageSync();
|
|
@@ -1206,54 +1223,33 @@ export class SqliteLcmStore {
|
|
|
1206
1223
|
eventType === 'message.part.updated' ||
|
|
1207
1224
|
eventType === 'message.part.removed');
|
|
1208
1225
|
}
|
|
1209
|
-
|
|
1210
|
-
const payload = event.payload;
|
|
1211
|
-
switch (payload.type) {
|
|
1212
|
-
case 'message.updated':
|
|
1213
|
-
return [payload.properties.info.id];
|
|
1214
|
-
case 'message.part.updated':
|
|
1215
|
-
return [payload.properties.part.messageID];
|
|
1216
|
-
case 'message.part.removed':
|
|
1217
|
-
return [payload.properties.messageID];
|
|
1218
|
-
default:
|
|
1219
|
-
return [];
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
archivedMessageIDs(messages) {
|
|
1223
|
-
return this.getArchivedMessages(messages).map((message) => message.info.id);
|
|
1224
|
-
}
|
|
1225
|
-
didArchivedMessagesChange(before, after) {
|
|
1226
|
-
const beforeIDs = this.archivedMessageIDs(before);
|
|
1227
|
-
const afterIDs = this.archivedMessageIDs(after);
|
|
1228
|
-
if (beforeIDs.length !== afterIDs.length)
|
|
1229
|
-
return true;
|
|
1230
|
-
return beforeIDs.some((messageID, index) => messageID !== afterIDs[index]);
|
|
1231
|
-
}
|
|
1232
|
-
isArchivedMessage(messages, messageID) {
|
|
1233
|
-
if (!messageID)
|
|
1234
|
-
return false;
|
|
1235
|
-
return this.archivedMessageIDs(messages).includes(messageID);
|
|
1236
|
-
}
|
|
1237
|
-
shouldSyncDerivedSessionStateForEvent(previous, next, event) {
|
|
1226
|
+
shouldSyncDerivedSessionStateForEvent(session, event) {
|
|
1238
1227
|
const payload = event.payload;
|
|
1239
1228
|
switch (payload.type) {
|
|
1240
1229
|
case 'message.updated': {
|
|
1241
|
-
const
|
|
1242
|
-
|
|
1243
|
-
this.
|
|
1244
|
-
|
|
1230
|
+
const existing = session.messages.find((message) => message.info.id === payload.properties.info.id);
|
|
1231
|
+
if (existing) {
|
|
1232
|
+
return this.isMessageArchivedSync(session.sessionID, existing.info.id, existing.info.time.created);
|
|
1233
|
+
}
|
|
1234
|
+
return this.readMessageCountSync(session.sessionID) >= this.options.freshTailMessages;
|
|
1235
|
+
}
|
|
1236
|
+
case 'message.removed': {
|
|
1237
|
+
const existing = safeQueryOne(this.getDb().prepare('SELECT created_at FROM messages WHERE session_id = ? AND message_id = ?'), [session.sessionID, payload.properties.messageID], 'shouldSyncDerivedSessionStateForEvent.messageRemoved');
|
|
1238
|
+
if (!existing)
|
|
1239
|
+
return false;
|
|
1240
|
+
return this.readMessageCountSync(session.sessionID) > this.options.freshTailMessages;
|
|
1245
1241
|
}
|
|
1246
|
-
case 'message.removed':
|
|
1247
|
-
return this.didArchivedMessagesChange(previous.messages, next.messages);
|
|
1248
1242
|
case 'message.part.updated': {
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
|
|
1243
|
+
const message = session.messages.find((entry) => entry.info.id === payload.properties.part.messageID);
|
|
1244
|
+
if (!message)
|
|
1245
|
+
return false;
|
|
1246
|
+
return this.isMessageArchivedSync(session.sessionID, message.info.id, message.info.time.created);
|
|
1252
1247
|
}
|
|
1253
1248
|
case 'message.part.removed': {
|
|
1254
|
-
const
|
|
1255
|
-
|
|
1256
|
-
|
|
1249
|
+
const message = session.messages.find((entry) => entry.info.id === payload.properties.messageID);
|
|
1250
|
+
if (!message)
|
|
1251
|
+
return false;
|
|
1252
|
+
return this.isMessageArchivedSync(session.sessionID, message.info.id, message.info.time.created);
|
|
1257
1253
|
}
|
|
1258
1254
|
default:
|
|
1259
1255
|
return false;
|
|
@@ -3361,7 +3357,7 @@ export class SqliteLcmStore {
|
|
|
3361
3357
|
const row = sessionMap.get(sessionID);
|
|
3362
3358
|
const messages = messagesBySession.get(sessionID) ?? [];
|
|
3363
3359
|
if (!row) {
|
|
3364
|
-
return { sessionID,
|
|
3360
|
+
return { ...emptySession(sessionID), messages };
|
|
3365
3361
|
}
|
|
3366
3362
|
return {
|
|
3367
3363
|
sessionID: row.session_id,
|
|
@@ -3380,7 +3376,7 @@ export class SqliteLcmStore {
|
|
|
3380
3376
|
};
|
|
3381
3377
|
});
|
|
3382
3378
|
}
|
|
3383
|
-
readSessionSync(sessionID
|
|
3379
|
+
readSessionSync(sessionID) {
|
|
3384
3380
|
const db = this.getDb();
|
|
3385
3381
|
const row = safeQueryOne(db.prepare('SELECT * FROM sessions WHERE session_id = ?'), [sessionID], 'readSessionSync');
|
|
3386
3382
|
const messageRows = db
|
|
@@ -3390,11 +3386,7 @@ export class SqliteLcmStore {
|
|
|
3390
3386
|
.prepare('SELECT * FROM parts WHERE session_id = ? ORDER BY message_id ASC, sort_key ASC, part_id ASC')
|
|
3391
3387
|
.all(sessionID);
|
|
3392
3388
|
const artifactsByPart = new Map();
|
|
3393
|
-
const
|
|
3394
|
-
const artifacts = artifactMessageIDs === undefined
|
|
3395
|
-
? this.readArtifactsForSessionSync(sessionID)
|
|
3396
|
-
: [...new Set(artifactMessageIDs)].flatMap((messageID) => this.readArtifactsForMessageSync(messageID));
|
|
3397
|
-
for (const artifact of artifacts) {
|
|
3389
|
+
for (const artifact of this.readArtifactsForSessionSync(sessionID)) {
|
|
3398
3390
|
const list = artifactsByPart.get(artifact.partID) ?? [];
|
|
3399
3391
|
list.push(artifact);
|
|
3400
3392
|
artifactsByPart.set(artifact.partID, list);
|
|
@@ -3413,12 +3405,7 @@ export class SqliteLcmStore {
|
|
|
3413
3405
|
parts: partsByMessage.get(messageRow.message_id) ?? [],
|
|
3414
3406
|
}));
|
|
3415
3407
|
if (!row) {
|
|
3416
|
-
return {
|
|
3417
|
-
sessionID,
|
|
3418
|
-
updatedAt: 0,
|
|
3419
|
-
eventCount: 0,
|
|
3420
|
-
messages,
|
|
3421
|
-
};
|
|
3408
|
+
return { ...emptySession(sessionID), messages };
|
|
3422
3409
|
}
|
|
3423
3410
|
return {
|
|
3424
3411
|
sessionID: row.session_id,
|
|
@@ -3462,6 +3449,73 @@ export class SqliteLcmStore {
|
|
|
3462
3449
|
}
|
|
3463
3450
|
return parentSessionID;
|
|
3464
3451
|
}
|
|
3452
|
+
readSessionForCaptureSync(event) {
|
|
3453
|
+
const sessionID = event.sessionID;
|
|
3454
|
+
if (!sessionID)
|
|
3455
|
+
return emptySession('');
|
|
3456
|
+
const session = this.readSessionHeaderSync(sessionID) ?? emptySession(sessionID);
|
|
3457
|
+
const payload = event.payload;
|
|
3458
|
+
switch (payload.type) {
|
|
3459
|
+
case 'message.updated': {
|
|
3460
|
+
const message = this.readMessageSync(sessionID, payload.properties.info.id);
|
|
3461
|
+
if (message)
|
|
3462
|
+
session.messages = [message];
|
|
3463
|
+
return session;
|
|
3464
|
+
}
|
|
3465
|
+
case 'message.part.updated': {
|
|
3466
|
+
const message = this.readMessageSync(sessionID, payload.properties.part.messageID);
|
|
3467
|
+
if (message)
|
|
3468
|
+
session.messages = [message];
|
|
3469
|
+
return session;
|
|
3470
|
+
}
|
|
3471
|
+
case 'message.part.removed': {
|
|
3472
|
+
const message = this.readMessageSync(sessionID, payload.properties.messageID);
|
|
3473
|
+
if (message)
|
|
3474
|
+
session.messages = [message];
|
|
3475
|
+
return session;
|
|
3476
|
+
}
|
|
3477
|
+
default:
|
|
3478
|
+
return session;
|
|
3479
|
+
}
|
|
3480
|
+
}
|
|
3481
|
+
readMessageSync(sessionID, messageID) {
|
|
3482
|
+
const db = this.getDb();
|
|
3483
|
+
const row = safeQueryOne(db.prepare('SELECT * FROM messages WHERE session_id = ? AND message_id = ?'), [sessionID, messageID], 'readMessageSync');
|
|
3484
|
+
if (!row)
|
|
3485
|
+
return undefined;
|
|
3486
|
+
const artifactsByPart = new Map();
|
|
3487
|
+
for (const artifact of this.readArtifactsForMessageSync(messageID)) {
|
|
3488
|
+
const list = artifactsByPart.get(artifact.partID) ?? [];
|
|
3489
|
+
list.push(artifact);
|
|
3490
|
+
artifactsByPart.set(artifact.partID, list);
|
|
3491
|
+
}
|
|
3492
|
+
const parts = db
|
|
3493
|
+
.prepare('SELECT * FROM parts WHERE session_id = ? AND message_id = ? ORDER BY sort_key ASC, part_id ASC')
|
|
3494
|
+
.all(sessionID, messageID);
|
|
3495
|
+
return {
|
|
3496
|
+
info: parseJson(row.info_json),
|
|
3497
|
+
parts: parts.map((partRow) => {
|
|
3498
|
+
const part = parseJson(partRow.part_json);
|
|
3499
|
+
hydratePartFromArtifacts(part, artifactsByPart.get(part.id) ?? []);
|
|
3500
|
+
return part;
|
|
3501
|
+
}),
|
|
3502
|
+
};
|
|
3503
|
+
}
|
|
3504
|
+
readMessageCountSync(sessionID) {
|
|
3505
|
+
const row = this.getDb()
|
|
3506
|
+
.prepare('SELECT COUNT(*) AS count FROM messages WHERE session_id = ?')
|
|
3507
|
+
.get(sessionID);
|
|
3508
|
+
return row.count;
|
|
3509
|
+
}
|
|
3510
|
+
isMessageArchivedSync(sessionID, messageID, createdAt) {
|
|
3511
|
+
const row = this.getDb()
|
|
3512
|
+
.prepare(`SELECT COUNT(*) AS count
|
|
3513
|
+
FROM messages
|
|
3514
|
+
WHERE session_id = ?
|
|
3515
|
+
AND (created_at > ? OR (created_at = ? AND message_id > ?))`)
|
|
3516
|
+
.get(sessionID, createdAt, createdAt, messageID);
|
|
3517
|
+
return row.count >= this.options.freshTailMessages;
|
|
3518
|
+
}
|
|
3465
3519
|
async persistCapturedSession(session, event) {
|
|
3466
3520
|
const payload = event.payload;
|
|
3467
3521
|
switch (payload.type) {
|
package/package.json
CHANGED
package/src/store.ts
CHANGED
|
@@ -303,10 +303,6 @@ function readSessionStats(db: SqlDatabaseLike): {
|
|
|
303
303
|
};
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
-
type ReadSessionOptions = {
|
|
307
|
-
artifactMessageIDs?: string[];
|
|
308
|
-
};
|
|
309
|
-
|
|
310
306
|
function extractSessionID(event: unknown): string | undefined {
|
|
311
307
|
const record = asRecord(event);
|
|
312
308
|
if (!record) return undefined;
|
|
@@ -364,6 +360,15 @@ function compareMessages(a: ConversationMessage, b: ConversationMessage): number
|
|
|
364
360
|
return a.info.time.created - b.info.time.created;
|
|
365
361
|
}
|
|
366
362
|
|
|
363
|
+
function emptySession(sessionID: string): NormalizedSession {
|
|
364
|
+
return {
|
|
365
|
+
sessionID,
|
|
366
|
+
updatedAt: 0,
|
|
367
|
+
eventCount: 0,
|
|
368
|
+
messages: [],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
367
372
|
function buildSummaryNodeID(sessionID: string, level: number, slot: number): string {
|
|
368
373
|
return `sum_${hashContent(`summary:${sessionID}`).slice(0, 12)}_l${level}_p${slot}`;
|
|
369
374
|
}
|
|
@@ -529,6 +534,11 @@ type SqliteRuntimeOptions = {
|
|
|
529
534
|
isBunRuntime?: boolean;
|
|
530
535
|
platform?: string | undefined;
|
|
531
536
|
};
|
|
537
|
+
type CaptureHydrationMode = 'full' | 'targeted';
|
|
538
|
+
type CaptureHydrationOptions = {
|
|
539
|
+
isBunRuntime?: boolean;
|
|
540
|
+
platform?: string | undefined;
|
|
541
|
+
};
|
|
532
542
|
|
|
533
543
|
function normalizeSqliteRuntimeOverride(value: string | undefined): SqliteRuntime | 'auto' {
|
|
534
544
|
const normalized = value?.trim().toLowerCase();
|
|
@@ -554,6 +564,20 @@ export function resolveSqliteRuntime(options?: SqliteRuntimeOptions): SqliteRunt
|
|
|
554
564
|
return resolveSqliteRuntimeCandidates(options)[0];
|
|
555
565
|
}
|
|
556
566
|
|
|
567
|
+
export function resolveCaptureHydrationMode(
|
|
568
|
+
options?: CaptureHydrationOptions,
|
|
569
|
+
): CaptureHydrationMode {
|
|
570
|
+
const isBunRuntime =
|
|
571
|
+
options?.isBunRuntime ?? (typeof globalThis === 'object' && 'Bun' in globalThis);
|
|
572
|
+
const platform = options?.platform ?? process.platform;
|
|
573
|
+
|
|
574
|
+
// The targeted fresh-tail capture path is safe under Node, but the bundled
|
|
575
|
+
// Bun runtime on Windows has been the only environment where users have
|
|
576
|
+
// reported native crashes in this hot path. Keep the older full-session
|
|
577
|
+
// hydration there until Bun/Windows is proven stable again.
|
|
578
|
+
return isBunRuntime && platform === 'win32' ? 'full' : 'targeted';
|
|
579
|
+
}
|
|
580
|
+
|
|
557
581
|
function isSqliteRuntimeImportError(runtime: SqliteRuntime, error: unknown): boolean {
|
|
558
582
|
const code =
|
|
559
583
|
typeof error === 'object' && error && 'code' in error && typeof error.code === 'string'
|
|
@@ -1096,19 +1120,16 @@ export class SqliteLcmStore {
|
|
|
1096
1120
|
if (!normalized.sessionID) return;
|
|
1097
1121
|
if (!this.shouldPersistSessionForEvent(normalized.type)) return;
|
|
1098
1122
|
|
|
1099
|
-
const session =
|
|
1100
|
-
|
|
1101
|
-
|
|
1123
|
+
const session =
|
|
1124
|
+
resolveCaptureHydrationMode() === 'targeted'
|
|
1125
|
+
? this.readSessionForCaptureSync(normalized)
|
|
1126
|
+
: this.readSessionSync(normalized.sessionID);
|
|
1102
1127
|
const previousParentSessionID = session.parentSessionID;
|
|
1128
|
+
const shouldSyncDerivedState = this.shouldSyncDerivedSessionStateForEvent(session, normalized);
|
|
1103
1129
|
let next = this.applyEvent(session, normalized);
|
|
1104
1130
|
next.updatedAt = Math.max(next.updatedAt, normalized.timestamp);
|
|
1105
1131
|
next.eventCount += 1;
|
|
1106
1132
|
next = this.prepareSessionForPersistence(next);
|
|
1107
|
-
const shouldSyncDerivedState = this.shouldSyncDerivedSessionStateForEvent(
|
|
1108
|
-
session,
|
|
1109
|
-
next,
|
|
1110
|
-
normalized,
|
|
1111
|
-
);
|
|
1112
1133
|
|
|
1113
1134
|
await this.persistCapturedSession(next, normalized);
|
|
1114
1135
|
|
|
@@ -1645,70 +1666,58 @@ export class SqliteLcmStore {
|
|
|
1645
1666
|
);
|
|
1646
1667
|
}
|
|
1647
1668
|
|
|
1648
|
-
private captureArtifactHydrationMessageIDs(event: CapturedEvent): string[] {
|
|
1649
|
-
const payload = event.payload as Event;
|
|
1650
|
-
|
|
1651
|
-
switch (payload.type) {
|
|
1652
|
-
case 'message.updated':
|
|
1653
|
-
return [payload.properties.info.id];
|
|
1654
|
-
case 'message.part.updated':
|
|
1655
|
-
return [payload.properties.part.messageID];
|
|
1656
|
-
case 'message.part.removed':
|
|
1657
|
-
return [payload.properties.messageID];
|
|
1658
|
-
default:
|
|
1659
|
-
return [];
|
|
1660
|
-
}
|
|
1661
|
-
}
|
|
1662
|
-
|
|
1663
|
-
private archivedMessageIDs(messages: ConversationMessage[]): string[] {
|
|
1664
|
-
return this.getArchivedMessages(messages).map((message) => message.info.id);
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
private didArchivedMessagesChange(
|
|
1668
|
-
before: ConversationMessage[],
|
|
1669
|
-
after: ConversationMessage[],
|
|
1670
|
-
): boolean {
|
|
1671
|
-
const beforeIDs = this.archivedMessageIDs(before);
|
|
1672
|
-
const afterIDs = this.archivedMessageIDs(after);
|
|
1673
|
-
if (beforeIDs.length !== afterIDs.length) return true;
|
|
1674
|
-
return beforeIDs.some((messageID, index) => messageID !== afterIDs[index]);
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
private isArchivedMessage(messages: ConversationMessage[], messageID?: string): boolean {
|
|
1678
|
-
if (!messageID) return false;
|
|
1679
|
-
return this.archivedMessageIDs(messages).includes(messageID);
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
1669
|
private shouldSyncDerivedSessionStateForEvent(
|
|
1683
|
-
|
|
1684
|
-
next: NormalizedSession,
|
|
1670
|
+
session: NormalizedSession,
|
|
1685
1671
|
event: CapturedEvent,
|
|
1686
1672
|
): boolean {
|
|
1687
1673
|
const payload = event.payload as Event;
|
|
1688
1674
|
|
|
1689
1675
|
switch (payload.type) {
|
|
1690
1676
|
case 'message.updated': {
|
|
1691
|
-
const
|
|
1692
|
-
|
|
1693
|
-
this.didArchivedMessagesChange(previous.messages, next.messages) ||
|
|
1694
|
-
this.isArchivedMessage(previous.messages, messageID) ||
|
|
1695
|
-
this.isArchivedMessage(next.messages, messageID)
|
|
1677
|
+
const existing = session.messages.find(
|
|
1678
|
+
(message) => message.info.id === payload.properties.info.id,
|
|
1696
1679
|
);
|
|
1680
|
+
if (existing) {
|
|
1681
|
+
return this.isMessageArchivedSync(
|
|
1682
|
+
session.sessionID,
|
|
1683
|
+
existing.info.id,
|
|
1684
|
+
existing.info.time.created,
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
return this.readMessageCountSync(session.sessionID) >= this.options.freshTailMessages;
|
|
1689
|
+
}
|
|
1690
|
+
case 'message.removed': {
|
|
1691
|
+
const existing = safeQueryOne<{ created_at: number }>(
|
|
1692
|
+
this.getDb().prepare(
|
|
1693
|
+
'SELECT created_at FROM messages WHERE session_id = ? AND message_id = ?',
|
|
1694
|
+
),
|
|
1695
|
+
[session.sessionID, payload.properties.messageID],
|
|
1696
|
+
'shouldSyncDerivedSessionStateForEvent.messageRemoved',
|
|
1697
|
+
);
|
|
1698
|
+
if (!existing) return false;
|
|
1699
|
+
return this.readMessageCountSync(session.sessionID) > this.options.freshTailMessages;
|
|
1697
1700
|
}
|
|
1698
|
-
case 'message.removed':
|
|
1699
|
-
return this.didArchivedMessagesChange(previous.messages, next.messages);
|
|
1700
1701
|
case 'message.part.updated': {
|
|
1701
|
-
const
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1702
|
+
const message = session.messages.find(
|
|
1703
|
+
(entry) => entry.info.id === payload.properties.part.messageID,
|
|
1704
|
+
);
|
|
1705
|
+
if (!message) return false;
|
|
1706
|
+
return this.isMessageArchivedSync(
|
|
1707
|
+
session.sessionID,
|
|
1708
|
+
message.info.id,
|
|
1709
|
+
message.info.time.created,
|
|
1705
1710
|
);
|
|
1706
1711
|
}
|
|
1707
1712
|
case 'message.part.removed': {
|
|
1708
|
-
const
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1713
|
+
const message = session.messages.find(
|
|
1714
|
+
(entry) => entry.info.id === payload.properties.messageID,
|
|
1715
|
+
);
|
|
1716
|
+
if (!message) return false;
|
|
1717
|
+
return this.isMessageArchivedSync(
|
|
1718
|
+
session.sessionID,
|
|
1719
|
+
message.info.id,
|
|
1720
|
+
message.info.time.created,
|
|
1712
1721
|
);
|
|
1713
1722
|
}
|
|
1714
1723
|
default:
|
|
@@ -4496,7 +4505,7 @@ export class SqliteLcmStore {
|
|
|
4496
4505
|
const row = sessionMap.get(sessionID);
|
|
4497
4506
|
const messages = messagesBySession.get(sessionID) ?? [];
|
|
4498
4507
|
if (!row) {
|
|
4499
|
-
return { sessionID,
|
|
4508
|
+
return { ...emptySession(sessionID), messages };
|
|
4500
4509
|
}
|
|
4501
4510
|
return {
|
|
4502
4511
|
sessionID: row.session_id,
|
|
@@ -4516,7 +4525,7 @@ export class SqliteLcmStore {
|
|
|
4516
4525
|
});
|
|
4517
4526
|
}
|
|
4518
4527
|
|
|
4519
|
-
private readSessionSync(sessionID: string
|
|
4528
|
+
private readSessionSync(sessionID: string): NormalizedSession {
|
|
4520
4529
|
const db = this.getDb();
|
|
4521
4530
|
const row = safeQueryOne<SessionRow>(
|
|
4522
4531
|
db.prepare('SELECT * FROM sessions WHERE session_id = ?'),
|
|
@@ -4534,14 +4543,7 @@ export class SqliteLcmStore {
|
|
|
4534
4543
|
)
|
|
4535
4544
|
.all(sessionID) as PartRow[];
|
|
4536
4545
|
const artifactsByPart = new Map<string, ArtifactData[]>();
|
|
4537
|
-
const
|
|
4538
|
-
const artifacts =
|
|
4539
|
-
artifactMessageIDs === undefined
|
|
4540
|
-
? this.readArtifactsForSessionSync(sessionID)
|
|
4541
|
-
: [...new Set(artifactMessageIDs)].flatMap((messageID) =>
|
|
4542
|
-
this.readArtifactsForMessageSync(messageID),
|
|
4543
|
-
);
|
|
4544
|
-
for (const artifact of artifacts) {
|
|
4546
|
+
for (const artifact of this.readArtifactsForSessionSync(sessionID)) {
|
|
4545
4547
|
const list = artifactsByPart.get(artifact.partID) ?? [];
|
|
4546
4548
|
list.push(artifact);
|
|
4547
4549
|
artifactsByPart.set(artifact.partID, list);
|
|
@@ -4563,12 +4565,7 @@ export class SqliteLcmStore {
|
|
|
4563
4565
|
}));
|
|
4564
4566
|
|
|
4565
4567
|
if (!row) {
|
|
4566
|
-
return {
|
|
4567
|
-
sessionID,
|
|
4568
|
-
updatedAt: 0,
|
|
4569
|
-
eventCount: 0,
|
|
4570
|
-
messages,
|
|
4571
|
-
};
|
|
4568
|
+
return { ...emptySession(sessionID), messages };
|
|
4572
4569
|
}
|
|
4573
4570
|
|
|
4574
4571
|
return {
|
|
@@ -4622,6 +4619,85 @@ export class SqliteLcmStore {
|
|
|
4622
4619
|
return parentSessionID;
|
|
4623
4620
|
}
|
|
4624
4621
|
|
|
4622
|
+
private readSessionForCaptureSync(event: CapturedEvent): NormalizedSession {
|
|
4623
|
+
const sessionID = event.sessionID;
|
|
4624
|
+
if (!sessionID) return emptySession('');
|
|
4625
|
+
|
|
4626
|
+
const session = this.readSessionHeaderSync(sessionID) ?? emptySession(sessionID);
|
|
4627
|
+
const payload = event.payload as Event;
|
|
4628
|
+
|
|
4629
|
+
switch (payload.type) {
|
|
4630
|
+
case 'message.updated': {
|
|
4631
|
+
const message = this.readMessageSync(sessionID, payload.properties.info.id);
|
|
4632
|
+
if (message) session.messages = [message];
|
|
4633
|
+
return session;
|
|
4634
|
+
}
|
|
4635
|
+
case 'message.part.updated': {
|
|
4636
|
+
const message = this.readMessageSync(sessionID, payload.properties.part.messageID);
|
|
4637
|
+
if (message) session.messages = [message];
|
|
4638
|
+
return session;
|
|
4639
|
+
}
|
|
4640
|
+
case 'message.part.removed': {
|
|
4641
|
+
const message = this.readMessageSync(sessionID, payload.properties.messageID);
|
|
4642
|
+
if (message) session.messages = [message];
|
|
4643
|
+
return session;
|
|
4644
|
+
}
|
|
4645
|
+
default:
|
|
4646
|
+
return session;
|
|
4647
|
+
}
|
|
4648
|
+
}
|
|
4649
|
+
|
|
4650
|
+
private readMessageSync(sessionID: string, messageID: string): ConversationMessage | undefined {
|
|
4651
|
+
const db = this.getDb();
|
|
4652
|
+
const row = safeQueryOne<MessageRow>(
|
|
4653
|
+
db.prepare('SELECT * FROM messages WHERE session_id = ? AND message_id = ?'),
|
|
4654
|
+
[sessionID, messageID],
|
|
4655
|
+
'readMessageSync',
|
|
4656
|
+
);
|
|
4657
|
+
if (!row) return undefined;
|
|
4658
|
+
|
|
4659
|
+
const artifactsByPart = new Map<string, ArtifactData[]>();
|
|
4660
|
+
for (const artifact of this.readArtifactsForMessageSync(messageID)) {
|
|
4661
|
+
const list = artifactsByPart.get(artifact.partID) ?? [];
|
|
4662
|
+
list.push(artifact);
|
|
4663
|
+
artifactsByPart.set(artifact.partID, list);
|
|
4664
|
+
}
|
|
4665
|
+
|
|
4666
|
+
const parts = db
|
|
4667
|
+
.prepare(
|
|
4668
|
+
'SELECT * FROM parts WHERE session_id = ? AND message_id = ? ORDER BY sort_key ASC, part_id ASC',
|
|
4669
|
+
)
|
|
4670
|
+
.all(sessionID, messageID) as PartRow[];
|
|
4671
|
+
|
|
4672
|
+
return {
|
|
4673
|
+
info: parseJson<Message>(row.info_json),
|
|
4674
|
+
parts: parts.map((partRow) => {
|
|
4675
|
+
const part = parseJson<Part>(partRow.part_json);
|
|
4676
|
+
hydratePartFromArtifacts(part, artifactsByPart.get(part.id) ?? []);
|
|
4677
|
+
return part;
|
|
4678
|
+
}),
|
|
4679
|
+
};
|
|
4680
|
+
}
|
|
4681
|
+
|
|
4682
|
+
private readMessageCountSync(sessionID: string): number {
|
|
4683
|
+
const row = this.getDb()
|
|
4684
|
+
.prepare('SELECT COUNT(*) AS count FROM messages WHERE session_id = ?')
|
|
4685
|
+
.get(sessionID) as { count: number };
|
|
4686
|
+
return row.count;
|
|
4687
|
+
}
|
|
4688
|
+
|
|
4689
|
+
private isMessageArchivedSync(sessionID: string, messageID: string, createdAt: number): boolean {
|
|
4690
|
+
const row = this.getDb()
|
|
4691
|
+
.prepare(
|
|
4692
|
+
`SELECT COUNT(*) AS count
|
|
4693
|
+
FROM messages
|
|
4694
|
+
WHERE session_id = ?
|
|
4695
|
+
AND (created_at > ? OR (created_at = ? AND message_id > ?))`,
|
|
4696
|
+
)
|
|
4697
|
+
.get(sessionID, createdAt, createdAt, messageID) as { count: number };
|
|
4698
|
+
return row.count >= this.options.freshTailMessages;
|
|
4699
|
+
}
|
|
4700
|
+
|
|
4625
4701
|
private async persistCapturedSession(
|
|
4626
4702
|
session: NormalizedSession,
|
|
4627
4703
|
event: CapturedEvent,
|