opencode-lcm 0.13.2 → 0.13.5

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/dist/store.js CHANGED
@@ -170,6 +170,8 @@ function getValidMessageInfo(info) {
170
170
  return info;
171
171
  }
172
172
  function filterValidConversationMessages(messages, context) {
173
+ if (context?.operation === 'transformMessages')
174
+ return messages;
173
175
  const valid = messages.filter((message) => Boolean(getValidMessageInfo(message?.info)));
174
176
  const dropped = messages.length - valid.length;
175
177
  if (dropped > 0 && context) {
@@ -204,16 +206,18 @@ function getDeferredPartUpdateKey(event) {
204
206
  return undefined;
205
207
  return `${event.properties.part.sessionID}:${event.properties.part.messageID}:${event.properties.part.id}`;
206
208
  }
209
+ function messageCreatedAt(message) {
210
+ const created = message?.info?.time?.created;
211
+ return typeof created === 'number' && Number.isFinite(created) ? created : 0;
212
+ }
213
+ function messageParts(message) {
214
+ return Array.isArray(message?.parts) ? message.parts : [];
215
+ }
216
+ function signatureString(value, fallback = '') {
217
+ return typeof value === 'string' ? value : fallback;
218
+ }
207
219
  function compareMessages(a, b) {
208
- const aInfo = getValidMessageInfo(a.info);
209
- const bInfo = getValidMessageInfo(b.info);
210
- if (!aInfo && !bInfo)
211
- return 0;
212
- if (!aInfo)
213
- return 1;
214
- if (!bInfo)
215
- return -1;
216
- return aInfo.time.created - bInfo.time.created || aInfo.id.localeCompare(bInfo.id);
220
+ return messageCreatedAt(a) - messageCreatedAt(b);
217
221
  }
218
222
  function emptySession(sessionID) {
219
223
  return {
@@ -289,22 +293,24 @@ function isSyntheticLcmTextPart(part, markers) {
289
293
  }
290
294
  function guessMessageText(message, ignoreToolPrefixes) {
291
295
  const segments = [];
292
- for (const part of message.parts) {
296
+ for (const part of messageParts(message)) {
293
297
  switch (part.type) {
294
298
  case 'text': {
295
299
  if (isSyntheticLcmTextPart(part, ['archive-summary', 'retrieved-context', 'archived-part']))
296
300
  break;
297
- if (part.text.startsWith('[Archived by opencode-lcm:'))
301
+ const text = typeof part.text === 'string' ? part.text : '';
302
+ if (text.startsWith('[Archived by opencode-lcm:'))
298
303
  break;
299
- const sanitized = sanitizeAutomaticRetrievalSourceText(part.text);
304
+ const sanitized = sanitizeAutomaticRetrievalSourceText(text);
300
305
  if (sanitized)
301
306
  segments.push(sanitized);
302
307
  break;
303
308
  }
304
309
  case 'reasoning': {
305
- if (part.text.startsWith('[Archived by opencode-lcm:'))
310
+ const text = typeof part.text === 'string' ? part.text : '';
311
+ if (text.startsWith('[Archived by opencode-lcm:'))
306
312
  break;
307
- const sanitized = sanitizeAutomaticRetrievalSourceText(part.text);
313
+ const sanitized = sanitizeAutomaticRetrievalSourceText(text);
308
314
  if (sanitized)
309
315
  segments.push(sanitized);
310
316
  break;
@@ -317,15 +323,16 @@ function guessMessageText(message, ignoreToolPrefixes) {
317
323
  break;
318
324
  }
319
325
  case 'tool': {
320
- if (ignoreToolPrefixes.some((prefix) => part.tool.startsWith(prefix)))
326
+ const toolName = typeof part.tool === 'string' ? part.tool : '';
327
+ if (ignoreToolPrefixes.some((prefix) => toolName.startsWith(prefix)))
321
328
  break;
322
329
  const state = part.state;
323
330
  if (state.status === 'completed')
324
- segments.push(`${part.tool}: ${state.output}`);
331
+ segments.push(`${toolName}: ${state.output}`);
325
332
  if (state.status === 'error')
326
- segments.push(`${part.tool}: ${state.error}`);
333
+ segments.push(`${toolName}: ${state.error}`);
327
334
  if (state.status === 'pending' || state.status === 'running') {
328
- segments.push(`${part.tool}: ${JSON.stringify(state.input)}`);
335
+ segments.push(`${toolName}: ${JSON.stringify(state.input)}`);
329
336
  }
330
337
  if (state.status === 'completed' && state.attachments && state.attachments.length > 0) {
331
338
  const attachmentNames = state.attachments
@@ -333,7 +340,7 @@ function guessMessageText(message, ignoreToolPrefixes) {
333
340
  .filter(Boolean)
334
341
  .slice(0, 4);
335
342
  if (attachmentNames.length > 0)
336
- segments.push(`${part.tool} attachments: ${attachmentNames.join(', ')}`);
343
+ segments.push(`${toolName} attachments: ${attachmentNames.join(', ')}`);
337
344
  }
338
345
  break;
339
346
  }
@@ -417,6 +424,13 @@ export function resolveCaptureHydrationMode(options) {
417
424
  // hydration there until Bun/Windows is proven stable again.
418
425
  return isBunRuntime && platform === 'win32' ? 'full' : 'targeted';
419
426
  }
427
+ function shouldUseLightweightPartCapture(event, options) {
428
+ const isBunRuntime = options?.isBunRuntime ?? (typeof globalThis === 'object' && 'Bun' in globalThis);
429
+ const platform = options?.platform ?? process.platform;
430
+ if (!isBunRuntime || platform !== 'win32')
431
+ return false;
432
+ return event.type === 'message.part.updated' || event.type === 'message.part.removed';
433
+ }
420
434
  function isSqliteRuntimeImportError(runtime, error) {
421
435
  const code = typeof error === 'object' && error && 'code' in error && typeof error.code === 'string'
422
436
  ? error.code
@@ -768,6 +782,7 @@ export class SqliteLcmStore {
768
782
  end_index INTEGER NOT NULL,
769
783
  message_ids_json TEXT NOT NULL,
770
784
  summary_text TEXT NOT NULL,
785
+ strategy TEXT NOT NULL DEFAULT 'deterministic-v1',
771
786
  created_at INTEGER NOT NULL,
772
787
  FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
773
788
  );
@@ -823,6 +838,7 @@ export class SqliteLcmStore {
823
838
  `);
824
839
  this.ensureSessionColumnsSync();
825
840
  this.ensureSummaryStateColumnsSync();
841
+ this.ensureSummaryNodeColumnsSync();
826
842
  this.ensureArtifactColumnsSync();
827
843
  logStartupPhase('open-db:create-indexes');
828
844
  db.exec('CREATE INDEX IF NOT EXISTS idx_artifacts_content_hash ON artifacts(content_hash)');
@@ -970,9 +986,11 @@ export class SqliteLcmStore {
970
986
  }
971
987
  if (!normalized.sessionID || !shouldPersistSession)
972
988
  return;
973
- const session = resolveCaptureHydrationMode() === 'targeted'
974
- ? this.readSessionForCaptureSync(normalized)
975
- : this.readSessionSync(normalized.sessionID);
989
+ const session = shouldUseLightweightPartCapture(normalized)
990
+ ? this.readSessionForCaptureSync(normalized, { hydrateArtifacts: false })
991
+ : resolveCaptureHydrationMode() === 'targeted'
992
+ ? this.readSessionForCaptureSync(normalized)
993
+ : this.readSessionSync(normalized.sessionID);
976
994
  const previousParentSessionID = session.parentSessionID;
977
995
  const shouldSyncDerivedState = this.shouldSyncDerivedSessionStateForEvent(session, normalized);
978
996
  let next = this.applyEvent(session, normalized);
@@ -1253,7 +1271,7 @@ export class SqliteLcmStore {
1253
1271
  issues.push('unexpected-summary-edges');
1254
1272
  return issues.length > 0 ? { sessionID: session.sessionID, issues } : undefined;
1255
1273
  }
1256
- const latestMessageCreated = archived.at(-1)?.info.time.created ?? 0;
1274
+ const latestMessageCreated = messageCreatedAt(archived.at(-1));
1257
1275
  const archivedSignature = this.buildArchivedSignature(archived);
1258
1276
  const rootIDs = state ? parseJson(state.root_node_ids_json) : [];
1259
1277
  const roots = rootIDs
@@ -1364,7 +1382,7 @@ export class SqliteLcmStore {
1364
1382
  case 'message.updated': {
1365
1383
  const existing = session.messages.find((message) => message.info.id === payload.properties.info.id);
1366
1384
  if (existing) {
1367
- return this.isMessageArchivedSync(session.sessionID, existing.info.id, existing.info.time.created);
1385
+ return this.isMessageArchivedSync(session.sessionID, existing.info.id, messageCreatedAt(existing));
1368
1386
  }
1369
1387
  return this.readMessageCountSync(session.sessionID) >= this.options.freshTailMessages;
1370
1388
  }
@@ -1378,13 +1396,13 @@ export class SqliteLcmStore {
1378
1396
  const message = session.messages.find((entry) => entry.info.id === payload.properties.part.messageID);
1379
1397
  if (!message)
1380
1398
  return false;
1381
- return this.isMessageArchivedSync(session.sessionID, message.info.id, message.info.time.created);
1399
+ return this.isMessageArchivedSync(session.sessionID, message.info.id, messageCreatedAt(message));
1382
1400
  }
1383
1401
  case 'message.part.removed': {
1384
1402
  const message = session.messages.find((entry) => entry.info.id === payload.properties.messageID);
1385
1403
  if (!message)
1386
1404
  return false;
1387
- return this.isMessageArchivedSync(session.sessionID, message.info.id, message.info.time.created);
1405
+ return this.isMessageArchivedSync(session.sessionID, message.info.id, messageCreatedAt(message));
1388
1406
  }
1389
1407
  default:
1390
1408
  return false;
@@ -2702,6 +2720,12 @@ export class SqliteLcmStore {
2702
2720
  return this.options.interop.ignoreToolPrefixes.some((prefix) => toolName.startsWith(prefix));
2703
2721
  }
2704
2722
  summarizeMessages(messages, limit = SUMMARY_NODE_CHAR_LIMIT) {
2723
+ const strategy = this.options.summaryV2?.strategy ?? 'deterministic-v1';
2724
+ return strategy === 'deterministic-v2'
2725
+ ? this.summarizeMessagesDeterministicV2(messages, limit)
2726
+ : this.summarizeMessagesDeterministicV1(messages, limit);
2727
+ }
2728
+ summarizeMessagesDeterministicV1(messages, limit = SUMMARY_NODE_CHAR_LIMIT) {
2705
2729
  const goals = messages
2706
2730
  .filter((message) => message.info.role === 'user')
2707
2731
  .map((message) => guessMessageText(message, this.options.interop.ignoreToolPrefixes))
@@ -2726,6 +2750,55 @@ export class SqliteLcmStore {
2726
2750
  return truncate(`Archived messages ${messages.length}`, limit);
2727
2751
  return truncate(segments.join(' || '), limit);
2728
2752
  }
2753
+ summarizeMessagesDeterministicV2(messages, limit = SUMMARY_NODE_CHAR_LIMIT) {
2754
+ const perMsgBudget = this.options.summaryV2?.perMessageBudget ?? 110;
2755
+ const ignoreToolPrefixes = this.options.interop.ignoreToolPrefixes;
2756
+ const userTexts = messages
2757
+ .filter((message) => message.info.role === 'user')
2758
+ .map((message) => guessMessageText(message, ignoreToolPrefixes))
2759
+ .filter(Boolean);
2760
+ const assistantTexts = messages
2761
+ .filter((message) => message.info.role === 'assistant')
2762
+ .map((message) => guessMessageText(message, ignoreToolPrefixes))
2763
+ .filter(Boolean);
2764
+ const allFiles = [...new Set(messages.flatMap(listFiles))];
2765
+ const allTools = [...new Set(this.listTools(messages))];
2766
+ const hasErrors = messages.some((message) => message.parts.some((part) => (part.type === 'tool' && 'state' in part && part.state?.status === 'error') ||
2767
+ (part.type === 'text' &&
2768
+ /\b(?:error|exception|fail(?:ed|ure)?)\b/i.test(part.text ?? ''))));
2769
+ const segments = [];
2770
+ if (userTexts.length > 0) {
2771
+ const first = truncate(userTexts[0], perMsgBudget);
2772
+ if (userTexts.length > 1) {
2773
+ const last = truncate(userTexts[userTexts.length - 1], perMsgBudget);
2774
+ segments.push(`Goals: ${first} → ${last}`);
2775
+ }
2776
+ else {
2777
+ segments.push(`Goals: ${first}`);
2778
+ }
2779
+ }
2780
+ if (assistantTexts.length > 0) {
2781
+ const recent = assistantTexts
2782
+ .slice(-2)
2783
+ .map((text) => truncate(text, perMsgBudget))
2784
+ .join(' | ');
2785
+ segments.push(`Work: ${recent}`);
2786
+ }
2787
+ if (allFiles.length > 0) {
2788
+ const shown = allFiles.slice(0, 6).join(', ');
2789
+ segments.push(allFiles.length > 6 ? `Files[${allFiles.length}]: ${shown}` : `Files: ${shown}`);
2790
+ }
2791
+ if (allTools.length > 0) {
2792
+ const shown = allTools.slice(0, 6).join(', ');
2793
+ segments.push(allTools.length > 6 ? `Tools[${allTools.length}]: ${shown}` : `Tools: ${shown}`);
2794
+ }
2795
+ if (hasErrors)
2796
+ segments.push('⚠err');
2797
+ segments.push(`${messages.length}msg(u:${userTexts.length}/a:${assistantTexts.length})`);
2798
+ if (segments.length === 0)
2799
+ return truncate(`Archived ${messages.length} messages`, limit);
2800
+ return truncate(segments.join(' || '), limit);
2801
+ }
2729
2802
  listTools(messages) {
2730
2803
  const tools = [];
2731
2804
  for (const message of messages) {
@@ -2742,13 +2815,13 @@ export class SqliteLcmStore {
2742
2815
  buildArchivedSignature(messages) {
2743
2816
  const hash = createHash('sha256');
2744
2817
  for (const message of messages) {
2745
- hash.update(message.info.id);
2746
- hash.update(message.info.role);
2747
- hash.update(String(message.info.time.created));
2748
- hash.update(guessMessageText(message, this.options.interop.ignoreToolPrefixes));
2818
+ hash.update(signatureString(message.info?.id, 'unknown-message'));
2819
+ hash.update(signatureString(message.info?.role, 'unknown-role'));
2820
+ hash.update(String(messageCreatedAt(message)));
2821
+ hash.update(String(guessMessageText(message, this.options.interop.ignoreToolPrefixes) ?? ''));
2749
2822
  hash.update(JSON.stringify(listFiles(message)));
2750
2823
  hash.update(JSON.stringify(this.listTools([message])));
2751
- hash.update(String(message.parts.length));
2824
+ hash.update(String(messageParts(message).length));
2752
2825
  }
2753
2826
  return hash.digest('hex');
2754
2827
  }
@@ -2768,7 +2841,7 @@ export class SqliteLcmStore {
2768
2841
  this.clearSummaryGraphSync(sessionID);
2769
2842
  return [];
2770
2843
  }
2771
- const latestMessageCreated = archivedMessages.at(-1)?.info.time.created ?? 0;
2844
+ const latestMessageCreated = messageCreatedAt(archivedMessages.at(-1));
2772
2845
  const archivedSignature = this.buildArchivedSignature(archivedMessages);
2773
2846
  const state = safeQueryOne(this.getDb().prepare('SELECT * FROM summary_state WHERE session_id = ?'), [sessionID], 'ensureSummaryGraphSync');
2774
2847
  if (state &&
@@ -2815,6 +2888,8 @@ export class SqliteLcmStore {
2815
2888
  const expectedSummaryText = this.summarizeMessages(archivedMessages.slice(node.startIndex, node.endIndex + 1));
2816
2889
  if (node.summaryText !== expectedSummaryText)
2817
2890
  return false;
2891
+ if (node.strategy !== (this.options.summaryV2?.strategy ?? 'deterministic-v1'))
2892
+ return false;
2818
2893
  const children = this.readSummaryChildrenSync(node.nodeID);
2819
2894
  if (node.nodeKind === 'leaf') {
2820
2895
  return (children.length === 0 && node.endIndex - node.startIndex + 1 <= SUMMARY_LEAF_MESSAGES);
@@ -2849,6 +2924,7 @@ export class SqliteLcmStore {
2849
2924
  }
2850
2925
  rebuildSummaryGraphSync(sessionID, archivedMessages, archivedSignature) {
2851
2926
  const now = Date.now();
2927
+ const summaryStrategy = this.options.summaryV2?.strategy ?? 'deterministic-v1';
2852
2928
  let level = 0;
2853
2929
  const nodes = [];
2854
2930
  const edges = [];
@@ -2861,6 +2937,7 @@ export class SqliteLcmStore {
2861
2937
  endIndex: input.endIndex,
2862
2938
  messageIDs: input.messageIDs,
2863
2939
  summaryText: input.summaryText,
2940
+ strategy: summaryStrategy,
2864
2941
  createdAt: now,
2865
2942
  });
2866
2943
  let currentLevel = [];
@@ -2913,13 +2990,13 @@ export class SqliteLcmStore {
2913
2990
  withTransaction(db, 'rebuildSummaryGraph', () => {
2914
2991
  this.clearSummaryGraphSync(sessionID);
2915
2992
  const insertNode = db.prepare(`INSERT INTO summary_nodes
2916
- (node_id, session_id, level, node_kind, start_index, end_index, message_ids_json, summary_text, created_at)
2917
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
2993
+ (node_id, session_id, level, node_kind, start_index, end_index, message_ids_json, summary_text, strategy, created_at)
2994
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
2918
2995
  const insertEdge = db.prepare(`INSERT INTO summary_edges (session_id, parent_id, child_id, child_position)
2919
2996
  VALUES (?, ?, ?, ?)`);
2920
2997
  const insertSummaryFts = db.prepare('INSERT INTO summary_fts (session_id, node_id, level, created_at, content) VALUES (?, ?, ?, ?, ?)');
2921
2998
  for (const node of nodes) {
2922
- insertNode.run(node.nodeID, node.sessionID, node.level, node.nodeKind, node.startIndex, node.endIndex, JSON.stringify(node.messageIDs), node.summaryText, node.createdAt);
2999
+ insertNode.run(node.nodeID, node.sessionID, node.level, node.nodeKind, node.startIndex, node.endIndex, JSON.stringify(node.messageIDs), node.summaryText, node.strategy, node.createdAt);
2923
3000
  insertSummaryFts.run(node.sessionID, node.nodeID, String(node.level), String(node.createdAt), node.summaryText);
2924
3001
  }
2925
3002
  for (const edge of edges) {
@@ -2932,7 +3009,7 @@ export class SqliteLcmStore {
2932
3009
  latest_message_created = excluded.latest_message_created,
2933
3010
  archived_signature = excluded.archived_signature,
2934
3011
  root_node_ids_json = excluded.root_node_ids_json,
2935
- updated_at = excluded.updated_at`).run(sessionID, archivedMessages.length, archivedMessages.at(-1)?.info.time.created ?? 0, archivedSignature, JSON.stringify(roots.map((node) => node.nodeID)), now);
3012
+ updated_at = excluded.updated_at`).run(sessionID, archivedMessages.length, messageCreatedAt(archivedMessages.at(-1)), archivedSignature, JSON.stringify(roots.map((node) => node.nodeID)), now);
2936
3013
  });
2937
3014
  return roots;
2938
3015
  }
@@ -2949,6 +3026,7 @@ export class SqliteLcmStore {
2949
3026
  endIndex: row.end_index,
2950
3027
  messageIDs: parseJson(row.message_ids_json),
2951
3028
  summaryText: row.summary_text,
3029
+ strategy: row.strategy,
2952
3030
  createdAt: row.created_at,
2953
3031
  };
2954
3032
  }
@@ -3229,6 +3307,14 @@ export class SqliteLcmStore {
3229
3307
  return;
3230
3308
  db.exec("ALTER TABLE summary_state ADD COLUMN archived_signature TEXT NOT NULL DEFAULT ''");
3231
3309
  }
3310
+ ensureSummaryNodeColumnsSync() {
3311
+ const db = this.getDb();
3312
+ const columns = db.prepare('PRAGMA table_info(summary_nodes)').all();
3313
+ const names = new Set(columns.map((column) => column.name));
3314
+ if (names.has('strategy'))
3315
+ return;
3316
+ db.exec("ALTER TABLE summary_nodes ADD COLUMN strategy TEXT NOT NULL DEFAULT 'deterministic-v1'");
3317
+ }
3232
3318
  ensureArtifactColumnsSync() {
3233
3319
  const db = this.getDb();
3234
3320
  const columns = db.prepare('PRAGMA table_info(artifacts)').all();
@@ -3639,7 +3725,7 @@ export class SqliteLcmStore {
3639
3725
  }
3640
3726
  return parentSessionID;
3641
3727
  }
3642
- readSessionForCaptureSync(event) {
3728
+ readSessionForCaptureSync(event, options) {
3643
3729
  const sessionID = event.sessionID;
3644
3730
  if (!sessionID)
3645
3731
  return emptySession('');
@@ -3647,19 +3733,19 @@ export class SqliteLcmStore {
3647
3733
  const payload = event.payload;
3648
3734
  switch (payload.type) {
3649
3735
  case 'message.updated': {
3650
- const message = this.readMessageSync(sessionID, payload.properties.info.id);
3736
+ const message = this.readMessageSync(sessionID, payload.properties.info.id, options);
3651
3737
  if (message)
3652
3738
  session.messages = [message];
3653
3739
  return session;
3654
3740
  }
3655
3741
  case 'message.part.updated': {
3656
- const message = this.readMessageSync(sessionID, payload.properties.part.messageID);
3742
+ const message = this.readMessageSync(sessionID, payload.properties.part.messageID, options);
3657
3743
  if (message)
3658
3744
  session.messages = [message];
3659
3745
  return session;
3660
3746
  }
3661
3747
  case 'message.part.removed': {
3662
- const message = this.readMessageSync(sessionID, payload.properties.messageID);
3748
+ const message = this.readMessageSync(sessionID, payload.properties.messageID, options);
3663
3749
  if (message)
3664
3750
  session.messages = [message];
3665
3751
  return session;
@@ -3668,16 +3754,19 @@ export class SqliteLcmStore {
3668
3754
  return session;
3669
3755
  }
3670
3756
  }
3671
- readMessageSync(sessionID, messageID) {
3757
+ readMessageSync(sessionID, messageID, options) {
3672
3758
  const db = this.getDb();
3673
3759
  const row = safeQueryOne(db.prepare('SELECT * FROM messages WHERE session_id = ? AND message_id = ?'), [sessionID, messageID], 'readMessageSync');
3674
3760
  if (!row)
3675
3761
  return undefined;
3762
+ const hydrateArtifacts = options?.hydrateArtifacts ?? true;
3676
3763
  const artifactsByPart = new Map();
3677
- for (const artifact of this.readArtifactsForMessageSync(messageID)) {
3678
- const list = artifactsByPart.get(artifact.partID) ?? [];
3679
- list.push(artifact);
3680
- artifactsByPart.set(artifact.partID, list);
3764
+ if (hydrateArtifacts) {
3765
+ for (const artifact of this.readArtifactsForMessageSync(messageID)) {
3766
+ const list = artifactsByPart.get(artifact.partID) ?? [];
3767
+ list.push(artifact);
3768
+ artifactsByPart.set(artifact.partID, list);
3769
+ }
3681
3770
  }
3682
3771
  const parts = db
3683
3772
  .prepare('SELECT * FROM parts WHERE session_id = ? AND message_id = ? ORDER BY sort_key ASC, part_id ASC')
@@ -3694,7 +3783,8 @@ export class SqliteLcmStore {
3694
3783
  info,
3695
3784
  parts: parts.map((partRow) => {
3696
3785
  const part = parseJson(partRow.part_json);
3697
- hydratePartFromArtifacts(part, artifactsByPart.get(part.id) ?? []);
3786
+ if (hydrateArtifacts)
3787
+ hydratePartFromArtifacts(part, artifactsByPart.get(part.id) ?? []);
3698
3788
  return part;
3699
3789
  }),
3700
3790
  };
@@ -3744,22 +3834,34 @@ export class SqliteLcmStore {
3744
3834
  return;
3745
3835
  case 'message.part.updated': {
3746
3836
  const message = session.messages.find((entry) => entry.info.id === payload.properties.part.messageID);
3837
+ const preservedArtifacts = message
3838
+ ? this.readArtifactsForMessageSync(message.info.id).filter((artifact) => artifact.partID !== payload.properties.part.id)
3839
+ : [];
3747
3840
  const externalized = message ? await this.externalizeMessage(message) : undefined;
3748
3841
  withTransaction(this.getDb(), 'capture', () => {
3749
3842
  this.upsertSessionRowSync(session);
3750
3843
  if (externalized) {
3751
- this.replaceStoredMessageSync(session.sessionID, externalized.storedMessage, externalized.artifacts);
3844
+ this.replaceStoredMessageSync(session.sessionID, externalized.storedMessage, [
3845
+ ...preservedArtifacts,
3846
+ ...externalized.artifacts,
3847
+ ]);
3752
3848
  }
3753
3849
  });
3754
3850
  return;
3755
3851
  }
3756
3852
  case 'message.part.removed': {
3757
3853
  const message = session.messages.find((entry) => entry.info.id === payload.properties.messageID);
3854
+ const preservedArtifacts = message
3855
+ ? this.readArtifactsForMessageSync(message.info.id).filter((artifact) => artifact.partID !== payload.properties.partID)
3856
+ : [];
3758
3857
  const externalized = message ? await this.externalizeMessage(message) : undefined;
3759
3858
  withTransaction(this.getDb(), 'capture', () => {
3760
3859
  this.upsertSessionRowSync(session);
3761
3860
  if (externalized) {
3762
- this.replaceStoredMessageSync(session.sessionID, externalized.storedMessage, externalized.artifacts);
3861
+ this.replaceStoredMessageSync(session.sessionID, externalized.storedMessage, [
3862
+ ...preservedArtifacts,
3863
+ ...externalized.artifacts,
3864
+ ]);
3763
3865
  }
3764
3866
  });
3765
3867
  return;
package/dist/types.d.ts CHANGED
@@ -45,6 +45,11 @@ export type AutomaticRetrievalOptions = {
45
45
  scopeBudgets: AutomaticRetrievalScopeBudgets;
46
46
  stop: AutomaticRetrievalStopOptions;
47
47
  };
48
+ export type SummaryStrategyName = 'deterministic-v1' | 'deterministic-v2';
49
+ export type SummaryV2Options = {
50
+ strategy: SummaryStrategyName;
51
+ perMessageBudget: number;
52
+ };
48
53
  export type OpencodeLcmOptions = {
49
54
  interop: InteropOptions;
50
55
  scopeDefaults: ScopeDefaults;
@@ -64,6 +69,7 @@ export type OpencodeLcmOptions = {
64
69
  artifactViewChars: number;
65
70
  binaryPreviewProviders: string[];
66
71
  previewBytePeek: number;
72
+ summaryV2: SummaryV2Options;
67
73
  };
68
74
  export type CapturedEvent = {
69
75
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-lcm",
3
- "version": "0.13.2",
3
+ "version": "0.13.5",
4
4
  "description": "Long-memory plugin for OpenCode with context-mode interop",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/constants.ts CHANGED
@@ -9,7 +9,7 @@ export const SUMMARY_BRANCH_FACTOR = 3;
9
9
  export const SUMMARY_NODE_CHAR_LIMIT = 260;
10
10
 
11
11
  // Store schema
12
- export const STORE_SCHEMA_VERSION = 1;
12
+ export const STORE_SCHEMA_VERSION = 2;
13
13
 
14
14
  // Message retrieval limits
15
15
  export const EXPAND_MESSAGE_LIMIT = 6;
package/src/options.ts CHANGED
@@ -9,6 +9,8 @@ import type {
9
9
  ScopeDefaults,
10
10
  ScopeName,
11
11
  ScopeProfile,
12
+ SummaryStrategyName,
13
+ SummaryV2Options,
12
14
  } from './types.js';
13
15
 
14
16
  const DEFAULT_INTEROP: InteropOptions = {
@@ -54,6 +56,11 @@ const DEFAULT_AUTOMATIC_RETRIEVAL: AutomaticRetrievalOptions = {
54
56
  },
55
57
  };
56
58
 
59
+ export const DEFAULT_SUMMARY_V2: SummaryV2Options = {
60
+ strategy: 'deterministic-v2',
61
+ perMessageBudget: 110,
62
+ };
63
+
57
64
  export const DEFAULT_OPTIONS: OpencodeLcmOptions = {
58
65
  interop: DEFAULT_INTEROP,
59
66
  scopeDefaults: DEFAULT_SCOPE_DEFAULTS,
@@ -78,6 +85,7 @@ export const DEFAULT_OPTIONS: OpencodeLcmOptions = {
78
85
  'zip-metadata',
79
86
  ],
80
87
  previewBytePeek: 16,
88
+ summaryV2: DEFAULT_SUMMARY_V2,
81
89
  };
82
90
 
83
91
  function asRecord(value: unknown): Record<string, unknown> | undefined {
@@ -238,6 +246,18 @@ function asAutomaticRetrievalStopOptions(
238
246
  };
239
247
  }
240
248
 
249
+ function asSummaryStrategy(value: unknown, fallback: SummaryStrategyName): SummaryStrategyName {
250
+ return value === 'deterministic-v1' || value === 'deterministic-v2' ? value : fallback;
251
+ }
252
+
253
+ function asSummaryV2Options(value: unknown, fallback: SummaryV2Options): SummaryV2Options {
254
+ const record = asRecord(value);
255
+ return {
256
+ strategy: asSummaryStrategy(record?.strategy, fallback.strategy),
257
+ perMessageBudget: asNumber(record?.perMessageBudget, fallback.perMessageBudget),
258
+ };
259
+ }
260
+
241
261
  export function resolveOptions(raw: unknown): OpencodeLcmOptions {
242
262
  const options = asRecord(raw);
243
263
  const interop = asRecord(options?.interop);
@@ -293,5 +313,6 @@ export function resolveOptions(raw: unknown): OpencodeLcmOptions {
293
313
  DEFAULT_OPTIONS.binaryPreviewProviders,
294
314
  ),
295
315
  previewBytePeek: asNumber(options?.previewBytePeek, DEFAULT_OPTIONS.previewBytePeek),
316
+ summaryV2: asSummaryV2Options(options?.summaryV2, DEFAULT_SUMMARY_V2),
296
317
  };
297
318
  }
@@ -479,7 +479,7 @@ export async function externalizeMessage(
479
479
  const { storedPart, artifacts: nextArtifacts } = await externalizePart(
480
480
  bindings,
481
481
  part,
482
- message.info.time.created,
482
+ message.info?.time?.created ?? 0,
483
483
  );
484
484
  artifacts.push(...nextArtifacts);
485
485
  storedParts.push(storedPart);
@@ -509,7 +509,7 @@ export async function externalizeSession(
509
509
  const { storedPart, artifacts: nextArtifacts } = await externalizePart(
510
510
  bindings,
511
511
  part,
512
- message.info.time.created,
512
+ message.info?.time?.created ?? 0,
513
513
  );
514
514
  artifacts.push(...nextArtifacts);
515
515
  storedParts.push(storedPart);
@@ -607,7 +607,7 @@ export function persistStoredSessionSync(
607
607
  insertMessage.run(
608
608
  message.info.id,
609
609
  storedSession.sessionID,
610
- message.info.time.created,
610
+ message.info?.time?.created ?? 0,
611
611
  JSON.stringify(message.info),
612
612
  );
613
613
 
@@ -296,7 +296,7 @@ export function searchByScan(
296
296
  id: message.info.id,
297
297
  type: message.info.role,
298
298
  sessionID: session.sessionID,
299
- timestamp: message.info.time.created,
299
+ timestamp: message.info?.time?.created ?? 0,
300
300
  snippet: buildSnippet(blob, query),
301
301
  content: blob,
302
302
  sourceKind: 'message',
@@ -361,7 +361,7 @@ function insertMessageSearchRowsSync(deps: FtsDeps, session: NormalizedSession):
361
361
  session.sessionID,
362
362
  message.info.id,
363
363
  message.info.role,
364
- String(message.info.time.created),
364
+ String(message.info?.time?.created ?? 0),
365
365
  content,
366
366
  );
367
367
  }
@@ -380,7 +380,13 @@ export function replaceMessageSearchRowSync(
380
380
 
381
381
  db.prepare(
382
382
  'INSERT INTO message_fts (session_id, message_id, role, created_at, content) VALUES (?, ?, ?, ?, ?)',
383
- ).run(sessionID, message.info.id, message.info.role, String(message.info.time.created), content);
383
+ ).run(
384
+ sessionID,
385
+ message.info.id,
386
+ message.info.role,
387
+ String(message.info?.time?.created ?? 0),
388
+ content,
389
+ );
384
390
  }
385
391
 
386
392
  export function replaceSummarySearchRowsSync(deps: FtsDeps, sessionIDs?: string[]): void {
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
 
4
4
  import { withTransaction } from './sql-utils.js';
5
5
  import type { SqlDatabaseLike } from './store-types.js';
6
+ import type { SummaryStrategyName } from './types.js';
6
7
  import { resolveWorkspacePath } from './workspace-path.js';
7
8
  import { normalizeWorktreeKey } from './worktree-key.js';
8
9
 
@@ -50,6 +51,7 @@ export type SummaryNodeRow = {
50
51
  end_index: number;
51
52
  message_ids_json: string;
52
53
  summary_text: string;
54
+ strategy: SummaryStrategyName;
53
55
  created_at: number;
54
56
  };
55
57
 
@@ -254,8 +256,8 @@ export async function importStoreSnapshot(
254
256
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
255
257
  );
256
258
  const insertNode = db.prepare(
257
- `INSERT OR REPLACE INTO summary_nodes (node_id, session_id, level, node_kind, start_index, end_index, message_ids_json, summary_text, created_at)
258
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
259
+ `INSERT OR REPLACE INTO summary_nodes (node_id, session_id, level, node_kind, start_index, end_index, message_ids_json, summary_text, strategy, created_at)
260
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
259
261
  );
260
262
  const insertEdge = db.prepare(
261
263
  `INSERT OR REPLACE INTO summary_edges (session_id, parent_id, child_id, child_position) VALUES (?, ?, ?, ?)`,
@@ -315,6 +317,7 @@ export async function importStoreSnapshot(
315
317
  row.end_index,
316
318
  row.message_ids_json,
317
319
  row.summary_text,
320
+ row.strategy,
318
321
  row.created_at,
319
322
  );
320
323
  }
@@ -464,6 +467,10 @@ function parseSummaryNodeRow(value: unknown): SummaryNodeRow {
464
467
  end_index: expectNumber(row.end_index, 'summary_nodes[].end_index'),
465
468
  message_ids_json: expectString(row.message_ids_json, 'summary_nodes[].message_ids_json'),
466
469
  summary_text: expectString(row.summary_text, 'summary_nodes[].summary_text'),
470
+ strategy: expectString(
471
+ row.strategy ?? 'deterministic-v1',
472
+ 'summary_nodes[].strategy',
473
+ ) as SummaryStrategyName,
467
474
  created_at: expectNumber(row.created_at, 'summary_nodes[].created_at'),
468
475
  };
469
476
  }