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 CHANGED
@@ -1,9 +1,10 @@
1
- # opencode-lcm
1
+ # Opencode-lcm (Lossless Context Memory)
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/opencode-lcm)](https://www.npmjs.com/package/opencode-lcm)
3
4
  [![CI](https://github.com/Plutarch01/opencode-lcm/actions/workflows/ci.yml/badge.svg)](https://github.com/Plutarch01/opencode-lcm/actions/workflows/ci.yml)
4
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
  <!-- ![opencode-lcm in action](assets/images/lcm-demo.png) -->
@@ -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/plutarch01/opencode-lcm.git
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 = this.readSessionSync(normalized.sessionID, {
824
- artifactMessageIDs: this.captureArtifactHydrationMessageIDs(normalized),
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
- captureArtifactHydrationMessageIDs(event) {
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 messageID = payload.properties.info.id;
1242
- return (this.didArchivedMessagesChange(previous.messages, next.messages) ||
1243
- this.isArchivedMessage(previous.messages, messageID) ||
1244
- this.isArchivedMessage(next.messages, messageID));
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 messageID = payload.properties.part.messageID;
1250
- return (this.isArchivedMessage(previous.messages, messageID) ||
1251
- this.isArchivedMessage(next.messages, messageID));
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 messageID = payload.properties.messageID;
1255
- return (this.isArchivedMessage(previous.messages, messageID) ||
1256
- this.isArchivedMessage(next.messages, messageID));
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, updatedAt: 0, eventCount: 0, messages };
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, options) {
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 artifactMessageIDs = options?.artifactMessageIDs;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-lcm",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Long-memory plugin for OpenCode with context-mode interop",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/store.ts CHANGED
@@ -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 = this.readSessionSync(normalized.sessionID, {
1100
- artifactMessageIDs: this.captureArtifactHydrationMessageIDs(normalized),
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
- previous: NormalizedSession,
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 messageID = payload.properties.info.id;
1692
- return (
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 messageID = payload.properties.part.messageID;
1702
- return (
1703
- this.isArchivedMessage(previous.messages, messageID) ||
1704
- this.isArchivedMessage(next.messages, messageID)
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 messageID = payload.properties.messageID;
1709
- return (
1710
- this.isArchivedMessage(previous.messages, messageID) ||
1711
- this.isArchivedMessage(next.messages, messageID)
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, updatedAt: 0, eventCount: 0, messages };
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, options?: ReadSessionOptions): NormalizedSession {
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 artifactMessageIDs = options?.artifactMessageIDs;
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,