opencode-lcm 0.13.4 → 0.13.6

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 CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.6] - 2026-04-11
11
+
12
+ ### Fixed
13
+ - Bun on Windows now leaves the plugin in a pre-SQLite safe mode by default and requires an explicit override to enable the full archive hooks
14
+
15
+ ## [0.13.5] - 2026-04-11
16
+
17
+ ### Fixed
18
+ - Restored schema-v2 compatibility for persisted stores after the PR #6 merge
19
+ - Removed the unfinished `llm-cli` summary configuration surface before shipping it
20
+ - Restored Biome-clean formatting so the full cross-platform CI matrix passes again
21
+
10
22
  ## [0.13.4] - 2026-04-09
11
23
 
12
24
  ### Fixed
package/README.md CHANGED
@@ -105,6 +105,9 @@ Add `opencode-lcm` to your `opencode.json` (project or global `~/.config/opencod
105
105
  > [!IMPORTANT]
106
106
  > All defaults are applied automatically. Expand below only if you need to override settings.
107
107
 
108
+ > [!IMPORTANT]
109
+ > On Bun for Windows, `opencode-lcm` now starts in a pre-SQLite safe mode and only exposes `lcm_status` by default. This avoids a Bun/Windows native-crash path seen in the field. To force-enable the full plugin anyway, set `"runtimeSafety": { "allowUnsafeBunWindows": true }` in the plugin config or export `OPENCODE_LCM_ALLOW_UNSAFE_BUN_WINDOWS=1` before starting OpenCode.
110
+
108
111
  <details>
109
112
  <summary><strong>Full Configuration</strong> (click to expand)</summary>
110
113
 
@@ -5,7 +5,7 @@
5
5
  export declare const SUMMARY_LEAF_MESSAGES = 6;
6
6
  export declare const SUMMARY_BRANCH_FACTOR = 3;
7
7
  export declare const SUMMARY_NODE_CHAR_LIMIT = 260;
8
- export declare const STORE_SCHEMA_VERSION = 1;
8
+ export declare const STORE_SCHEMA_VERSION = 2;
9
9
  export declare const EXPAND_MESSAGE_LIMIT = 6;
10
10
  export declare const AUTOMATIC_RETRIEVAL_QUERY_TOKENS = 8;
11
11
  export declare const AUTOMATIC_RETRIEVAL_RECENT_MESSAGES = 3;
package/dist/constants.js CHANGED
@@ -7,7 +7,7 @@ export const SUMMARY_LEAF_MESSAGES = 6;
7
7
  export const SUMMARY_BRANCH_FACTOR = 3;
8
8
  export const SUMMARY_NODE_CHAR_LIMIT = 260;
9
9
  // Store schema
10
- export const STORE_SCHEMA_VERSION = 1;
10
+ export const STORE_SCHEMA_VERSION = 2;
11
11
  // Message retrieval limits
12
12
  export const EXPAND_MESSAGE_LIMIT = 6;
13
13
  // Automatic retrieval configuration
package/dist/index.js CHANGED
@@ -1,8 +1,59 @@
1
1
  import { tool } from '@opencode-ai/plugin';
2
2
  import { resolveOptions } from './options.js';
3
3
  import { SqliteLcmStore } from './store.js';
4
+ const ALLOW_UNSAFE_BUN_WINDOWS_ENV = 'OPENCODE_LCM_ALLOW_UNSAFE_BUN_WINDOWS';
5
+ function isTruthyEnvFlag(value) {
6
+ if (!value)
7
+ return false;
8
+ const normalized = value.trim().toLowerCase();
9
+ return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
10
+ }
11
+ function isBunRuntime() {
12
+ return typeof globalThis === 'object' && globalThis !== null && 'Bun' in globalThis;
13
+ }
14
+ function isUnsafeBunWindowsRuntime() {
15
+ return isBunRuntime() && process.platform === 'win32';
16
+ }
17
+ function resolveAllowUnsafeBunWindows(options) {
18
+ return (options.runtimeSafety.allowUnsafeBunWindows ||
19
+ isTruthyEnvFlag(process.env[ALLOW_UNSAFE_BUN_WINDOWS_ENV]));
20
+ }
21
+ function buildSafeModeStatus(allowUnsafeBunWindows) {
22
+ return [
23
+ 'status=disabled',
24
+ 'reason=bun_windows_runtime_guard',
25
+ 'available_tools=lcm_status',
26
+ `platform=${process.platform}`,
27
+ `bun_runtime=${isBunRuntime()}`,
28
+ `runtime_safety_allow_unsafe_bun_windows=${allowUnsafeBunWindows}`,
29
+ 'override_config=runtimeSafety.allowUnsafeBunWindows=true',
30
+ `override_env=${ALLOW_UNSAFE_BUN_WINDOWS_ENV}=1`,
31
+ 'message=opencode-lcm disabled itself before opening SQLite because Bun on Windows has reported native crashes in this path',
32
+ ].join('\n');
33
+ }
34
+ function createSafeModeHooks(allowUnsafeBunWindows) {
35
+ return {
36
+ event: async () => { },
37
+ tool: {
38
+ lcm_status: tool({
39
+ description: 'Show archived LCM capture stats',
40
+ args: {},
41
+ async execute() {
42
+ return buildSafeModeStatus(allowUnsafeBunWindows);
43
+ },
44
+ }),
45
+ },
46
+ 'experimental.chat.messages.transform': async () => { },
47
+ 'experimental.chat.system.transform': async () => { },
48
+ 'experimental.session.compacting': async () => { },
49
+ };
50
+ }
4
51
  export const OpencodeLcmPlugin = async (ctx, rawOptions) => {
5
52
  const options = resolveOptions(rawOptions);
53
+ const allowUnsafeBunWindows = resolveAllowUnsafeBunWindows(options);
54
+ if (isUnsafeBunWindowsRuntime() && !allowUnsafeBunWindows) {
55
+ return createSafeModeHooks(allowUnsafeBunWindows);
56
+ }
6
57
  const store = new SqliteLcmStore(ctx.directory, options);
7
58
  await store.init();
8
59
  return {
@@ -49,6 +100,7 @@ export const OpencodeLcmPlugin = async (ctx, rawOptions) => {
49
100
  `fresh_tail_messages=${options.freshTailMessages}`,
50
101
  `min_messages_for_transform=${options.minMessagesForTransform}`,
51
102
  `large_content_threshold=${options.largeContentThreshold}`,
103
+ `runtime_safety_allow_unsafe_bun_windows=${allowUnsafeBunWindows}`,
52
104
  `binary_preview_providers=${options.binaryPreviewProviders.join(',')}`,
53
105
  `preview_byte_peek=${options.previewBytePeek}`,
54
106
  `privacy_exclude_tool_prefixes=${options.privacy.excludeToolPrefixes.join(',')}`,
package/dist/options.d.ts CHANGED
@@ -1,3 +1,4 @@
1
- import type { OpencodeLcmOptions } from './types.js';
1
+ import type { OpencodeLcmOptions, SummaryV2Options } from './types.js';
2
+ export declare const DEFAULT_SUMMARY_V2: SummaryV2Options;
2
3
  export declare const DEFAULT_OPTIONS: OpencodeLcmOptions;
3
4
  export declare function resolveOptions(raw: unknown): OpencodeLcmOptions;
package/dist/options.js CHANGED
@@ -36,6 +36,13 @@ const DEFAULT_AUTOMATIC_RETRIEVAL = {
36
36
  stopOnFirstScopeWithHits: false,
37
37
  },
38
38
  };
39
+ export const DEFAULT_SUMMARY_V2 = {
40
+ strategy: 'deterministic-v2',
41
+ perMessageBudget: 110,
42
+ };
43
+ const DEFAULT_RUNTIME_SAFETY = {
44
+ allowUnsafeBunWindows: false,
45
+ };
39
46
  export const DEFAULT_OPTIONS = {
40
47
  interop: DEFAULT_INTEROP,
41
48
  scopeDefaults: DEFAULT_SCOPE_DEFAULTS,
@@ -60,6 +67,8 @@ export const DEFAULT_OPTIONS = {
60
67
  'zip-metadata',
61
68
  ],
62
69
  previewBytePeek: 16,
70
+ summaryV2: DEFAULT_SUMMARY_V2,
71
+ runtimeSafety: DEFAULT_RUNTIME_SAFETY,
63
72
  };
64
73
  function asRecord(value) {
65
74
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -184,6 +193,22 @@ function asAutomaticRetrievalStopOptions(value, fallback) {
184
193
  stopOnFirstScopeWithHits: asBoolean(record?.stopOnFirstScopeWithHits, fallback.stopOnFirstScopeWithHits),
185
194
  };
186
195
  }
196
+ function asSummaryStrategy(value, fallback) {
197
+ return value === 'deterministic-v1' || value === 'deterministic-v2' ? value : fallback;
198
+ }
199
+ function asSummaryV2Options(value, fallback) {
200
+ const record = asRecord(value);
201
+ return {
202
+ strategy: asSummaryStrategy(record?.strategy, fallback.strategy),
203
+ perMessageBudget: asNumber(record?.perMessageBudget, fallback.perMessageBudget),
204
+ };
205
+ }
206
+ function asRuntimeSafetyOptions(value, fallback) {
207
+ const record = asRecord(value);
208
+ return {
209
+ allowUnsafeBunWindows: asBoolean(record?.allowUnsafeBunWindows, fallback.allowUnsafeBunWindows),
210
+ };
211
+ }
187
212
  export function resolveOptions(raw) {
188
213
  const options = asRecord(raw);
189
214
  const interop = asRecord(options?.interop);
@@ -213,5 +238,7 @@ export function resolveOptions(raw) {
213
238
  artifactViewChars: asNumber(options?.artifactViewChars, DEFAULT_OPTIONS.artifactViewChars),
214
239
  binaryPreviewProviders: asStringArray(options?.binaryPreviewProviders, DEFAULT_OPTIONS.binaryPreviewProviders),
215
240
  previewBytePeek: asNumber(options?.previewBytePeek, DEFAULT_OPTIONS.previewBytePeek),
241
+ summaryV2: asSummaryV2Options(options?.summaryV2, DEFAULT_SUMMARY_V2),
242
+ runtimeSafety: asRuntimeSafetyOptions(options?.runtimeSafety, DEFAULT_RUNTIME_SAFETY),
216
243
  };
217
244
  }
@@ -1,4 +1,5 @@
1
1
  import type { SqlDatabaseLike } from './store-types.js';
2
+ import type { SummaryStrategyName } from './types.js';
2
3
  export type SnapshotScope = 'session' | 'root' | 'worktree' | 'all';
3
4
  export type SnapshotWorktreeMode = 'auto' | 'preserve' | 'current';
4
5
  export type SessionRow = {
@@ -38,6 +39,7 @@ export type SummaryNodeRow = {
38
39
  end_index: number;
39
40
  message_ids_json: string;
40
41
  summary_text: string;
42
+ strategy: SummaryStrategyName;
41
43
  created_at: number;
42
44
  };
43
45
  export type SummaryEdgeRow = {
@@ -81,8 +81,8 @@ export async function importStoreSnapshot(bindings, input) {
81
81
  const insertBlob = db.prepare(`INSERT OR REPLACE INTO artifact_blobs (content_hash, content_text, char_count, created_at) VALUES (?, ?, ?, ?)`);
82
82
  const insertArtifact = db.prepare(`INSERT OR REPLACE INTO artifacts (artifact_id, session_id, message_id, part_id, artifact_kind, field_name, preview_text, content_text, content_hash, metadata_json, char_count, created_at)
83
83
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
84
- const insertNode = db.prepare(`INSERT OR REPLACE INTO summary_nodes (node_id, session_id, level, node_kind, start_index, end_index, message_ids_json, summary_text, created_at)
85
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
84
+ const insertNode = db.prepare(`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)
85
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
86
86
  const insertEdge = db.prepare(`INSERT OR REPLACE INTO summary_edges (session_id, parent_id, child_id, child_position) VALUES (?, ?, ?, ?)`);
87
87
  const insertState = db.prepare(`INSERT OR REPLACE INTO summary_state (session_id, archived_count, latest_message_created, archived_signature, root_node_ids_json, updated_at)
88
88
  VALUES (?, ?, ?, ?, ?, ?)`);
@@ -101,7 +101,7 @@ export async function importStoreSnapshot(bindings, input) {
101
101
  insertArtifact.run(row.artifact_id, row.session_id, row.message_id, row.part_id, row.artifact_kind, row.field_name, row.preview_text, row.content_text, row.content_hash, row.metadata_json, row.char_count, row.created_at);
102
102
  }
103
103
  for (const row of snapshot.summary_nodes) {
104
- insertNode.run(row.node_id, row.session_id, row.level, row.node_kind, row.start_index, row.end_index, row.message_ids_json, row.summary_text, row.created_at);
104
+ insertNode.run(row.node_id, row.session_id, row.level, row.node_kind, row.start_index, row.end_index, row.message_ids_json, row.summary_text, row.strategy, row.created_at);
105
105
  }
106
106
  for (const row of snapshot.summary_edges)
107
107
  insertEdge.run(row.session_id, row.parent_id, row.child_id, row.child_position);
@@ -232,6 +232,7 @@ function parseSummaryNodeRow(value) {
232
232
  end_index: expectNumber(row.end_index, 'summary_nodes[].end_index'),
233
233
  message_ids_json: expectString(row.message_ids_json, 'summary_nodes[].message_ids_json'),
234
234
  summary_text: expectString(row.summary_text, 'summary_nodes[].summary_text'),
235
+ strategy: expectString(row.strategy ?? 'deterministic-v1', 'summary_nodes[].strategy'),
235
236
  created_at: expectNumber(row.created_at, 'summary_nodes[].created_at'),
236
237
  };
237
238
  }
package/dist/store.d.ts CHANGED
@@ -264,6 +264,8 @@ export declare class SqliteLcmStore {
264
264
  private compactMessageInPlace;
265
265
  private shouldIgnoreTool;
266
266
  private summarizeMessages;
267
+ private summarizeMessagesDeterministicV1;
268
+ private summarizeMessagesDeterministicV2;
267
269
  private listTools;
268
270
  private buildArchivedSignature;
269
271
  private getArchivedMessages;
@@ -294,6 +296,7 @@ export declare class SqliteLcmStore {
294
296
  private refreshSearchIndexesSync;
295
297
  private ensureSessionColumnsSync;
296
298
  private ensureSummaryStateColumnsSync;
299
+ private ensureSummaryNodeColumnsSync;
297
300
  private ensureArtifactColumnsSync;
298
301
  private backfillArtifactBlobsSync;
299
302
  private refreshAllLineageSync;
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
  }
@@ -775,6 +782,7 @@ export class SqliteLcmStore {
775
782
  end_index INTEGER NOT NULL,
776
783
  message_ids_json TEXT NOT NULL,
777
784
  summary_text TEXT NOT NULL,
785
+ strategy TEXT NOT NULL DEFAULT 'deterministic-v1',
778
786
  created_at INTEGER NOT NULL,
779
787
  FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
780
788
  );
@@ -830,6 +838,7 @@ export class SqliteLcmStore {
830
838
  `);
831
839
  this.ensureSessionColumnsSync();
832
840
  this.ensureSummaryStateColumnsSync();
841
+ this.ensureSummaryNodeColumnsSync();
833
842
  this.ensureArtifactColumnsSync();
834
843
  logStartupPhase('open-db:create-indexes');
835
844
  db.exec('CREATE INDEX IF NOT EXISTS idx_artifacts_content_hash ON artifacts(content_hash)');
@@ -1262,7 +1271,7 @@ export class SqliteLcmStore {
1262
1271
  issues.push('unexpected-summary-edges');
1263
1272
  return issues.length > 0 ? { sessionID: session.sessionID, issues } : undefined;
1264
1273
  }
1265
- const latestMessageCreated = archived.at(-1)?.info?.time?.created ?? 0;
1274
+ const latestMessageCreated = messageCreatedAt(archived.at(-1));
1266
1275
  const archivedSignature = this.buildArchivedSignature(archived);
1267
1276
  const rootIDs = state ? parseJson(state.root_node_ids_json) : [];
1268
1277
  const roots = rootIDs
@@ -1373,7 +1382,7 @@ export class SqliteLcmStore {
1373
1382
  case 'message.updated': {
1374
1383
  const existing = session.messages.find((message) => message.info.id === payload.properties.info.id);
1375
1384
  if (existing) {
1376
- return this.isMessageArchivedSync(session.sessionID, existing.info.id, existing.info?.time?.created ?? 0);
1385
+ return this.isMessageArchivedSync(session.sessionID, existing.info.id, messageCreatedAt(existing));
1377
1386
  }
1378
1387
  return this.readMessageCountSync(session.sessionID) >= this.options.freshTailMessages;
1379
1388
  }
@@ -1387,13 +1396,13 @@ export class SqliteLcmStore {
1387
1396
  const message = session.messages.find((entry) => entry.info.id === payload.properties.part.messageID);
1388
1397
  if (!message)
1389
1398
  return false;
1390
- return this.isMessageArchivedSync(session.sessionID, message.info.id, message.info?.time?.created ?? 0);
1399
+ return this.isMessageArchivedSync(session.sessionID, message.info.id, messageCreatedAt(message));
1391
1400
  }
1392
1401
  case 'message.part.removed': {
1393
1402
  const message = session.messages.find((entry) => entry.info.id === payload.properties.messageID);
1394
1403
  if (!message)
1395
1404
  return false;
1396
- return this.isMessageArchivedSync(session.sessionID, message.info.id, message.info?.time?.created ?? 0);
1405
+ return this.isMessageArchivedSync(session.sessionID, message.info.id, messageCreatedAt(message));
1397
1406
  }
1398
1407
  default:
1399
1408
  return false;
@@ -2711,6 +2720,12 @@ export class SqliteLcmStore {
2711
2720
  return this.options.interop.ignoreToolPrefixes.some((prefix) => toolName.startsWith(prefix));
2712
2721
  }
2713
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) {
2714
2729
  const goals = messages
2715
2730
  .filter((message) => message.info.role === 'user')
2716
2731
  .map((message) => guessMessageText(message, this.options.interop.ignoreToolPrefixes))
@@ -2735,6 +2750,55 @@ export class SqliteLcmStore {
2735
2750
  return truncate(`Archived messages ${messages.length}`, limit);
2736
2751
  return truncate(segments.join(' || '), limit);
2737
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
+ }
2738
2802
  listTools(messages) {
2739
2803
  const tools = [];
2740
2804
  for (const message of messages) {
@@ -2751,13 +2815,13 @@ export class SqliteLcmStore {
2751
2815
  buildArchivedSignature(messages) {
2752
2816
  const hash = createHash('sha256');
2753
2817
  for (const message of messages) {
2754
- hash.update(message.info.id);
2755
- hash.update(message.info.role);
2756
- hash.update(String(message.info?.time?.created ?? 0));
2757
- 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) ?? ''));
2758
2822
  hash.update(JSON.stringify(listFiles(message)));
2759
2823
  hash.update(JSON.stringify(this.listTools([message])));
2760
- hash.update(String(message.parts.length));
2824
+ hash.update(String(messageParts(message).length));
2761
2825
  }
2762
2826
  return hash.digest('hex');
2763
2827
  }
@@ -2777,7 +2841,7 @@ export class SqliteLcmStore {
2777
2841
  this.clearSummaryGraphSync(sessionID);
2778
2842
  return [];
2779
2843
  }
2780
- const latestMessageCreated = archivedMessages.at(-1)?.info?.time?.created ?? 0;
2844
+ const latestMessageCreated = messageCreatedAt(archivedMessages.at(-1));
2781
2845
  const archivedSignature = this.buildArchivedSignature(archivedMessages);
2782
2846
  const state = safeQueryOne(this.getDb().prepare('SELECT * FROM summary_state WHERE session_id = ?'), [sessionID], 'ensureSummaryGraphSync');
2783
2847
  if (state &&
@@ -2824,6 +2888,8 @@ export class SqliteLcmStore {
2824
2888
  const expectedSummaryText = this.summarizeMessages(archivedMessages.slice(node.startIndex, node.endIndex + 1));
2825
2889
  if (node.summaryText !== expectedSummaryText)
2826
2890
  return false;
2891
+ if (node.strategy !== (this.options.summaryV2?.strategy ?? 'deterministic-v1'))
2892
+ return false;
2827
2893
  const children = this.readSummaryChildrenSync(node.nodeID);
2828
2894
  if (node.nodeKind === 'leaf') {
2829
2895
  return (children.length === 0 && node.endIndex - node.startIndex + 1 <= SUMMARY_LEAF_MESSAGES);
@@ -2858,6 +2924,7 @@ export class SqliteLcmStore {
2858
2924
  }
2859
2925
  rebuildSummaryGraphSync(sessionID, archivedMessages, archivedSignature) {
2860
2926
  const now = Date.now();
2927
+ const summaryStrategy = this.options.summaryV2?.strategy ?? 'deterministic-v1';
2861
2928
  let level = 0;
2862
2929
  const nodes = [];
2863
2930
  const edges = [];
@@ -2870,6 +2937,7 @@ export class SqliteLcmStore {
2870
2937
  endIndex: input.endIndex,
2871
2938
  messageIDs: input.messageIDs,
2872
2939
  summaryText: input.summaryText,
2940
+ strategy: summaryStrategy,
2873
2941
  createdAt: now,
2874
2942
  });
2875
2943
  let currentLevel = [];
@@ -2922,13 +2990,13 @@ export class SqliteLcmStore {
2922
2990
  withTransaction(db, 'rebuildSummaryGraph', () => {
2923
2991
  this.clearSummaryGraphSync(sessionID);
2924
2992
  const insertNode = db.prepare(`INSERT INTO summary_nodes
2925
- (node_id, session_id, level, node_kind, start_index, end_index, message_ids_json, summary_text, created_at)
2926
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
2993
+ (node_id, session_id, level, node_kind, start_index, end_index, message_ids_json, summary_text, strategy, created_at)
2994
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
2927
2995
  const insertEdge = db.prepare(`INSERT INTO summary_edges (session_id, parent_id, child_id, child_position)
2928
2996
  VALUES (?, ?, ?, ?)`);
2929
2997
  const insertSummaryFts = db.prepare('INSERT INTO summary_fts (session_id, node_id, level, created_at, content) VALUES (?, ?, ?, ?, ?)');
2930
2998
  for (const node of nodes) {
2931
- 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);
2932
3000
  insertSummaryFts.run(node.sessionID, node.nodeID, String(node.level), String(node.createdAt), node.summaryText);
2933
3001
  }
2934
3002
  for (const edge of edges) {
@@ -2941,7 +3009,7 @@ export class SqliteLcmStore {
2941
3009
  latest_message_created = excluded.latest_message_created,
2942
3010
  archived_signature = excluded.archived_signature,
2943
3011
  root_node_ids_json = excluded.root_node_ids_json,
2944
- 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);
2945
3013
  });
2946
3014
  return roots;
2947
3015
  }
@@ -2958,6 +3026,7 @@ export class SqliteLcmStore {
2958
3026
  endIndex: row.end_index,
2959
3027
  messageIDs: parseJson(row.message_ids_json),
2960
3028
  summaryText: row.summary_text,
3029
+ strategy: row.strategy,
2961
3030
  createdAt: row.created_at,
2962
3031
  };
2963
3032
  }
@@ -3238,6 +3307,14 @@ export class SqliteLcmStore {
3238
3307
  return;
3239
3308
  db.exec("ALTER TABLE summary_state ADD COLUMN archived_signature TEXT NOT NULL DEFAULT ''");
3240
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
+ }
3241
3318
  ensureArtifactColumnsSync() {
3242
3319
  const db = this.getDb();
3243
3320
  const columns = db.prepare('PRAGMA table_info(artifacts)').all();
package/dist/types.d.ts CHANGED
@@ -45,6 +45,14 @@ 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
+ };
53
+ export type RuntimeSafetyOptions = {
54
+ allowUnsafeBunWindows: boolean;
55
+ };
48
56
  export type OpencodeLcmOptions = {
49
57
  interop: InteropOptions;
50
58
  scopeDefaults: ScopeDefaults;
@@ -64,6 +72,8 @@ export type OpencodeLcmOptions = {
64
72
  artifactViewChars: number;
65
73
  binaryPreviewProviders: string[];
66
74
  previewBytePeek: number;
75
+ summaryV2: SummaryV2Options;
76
+ runtimeSafety: RuntimeSafetyOptions;
67
77
  };
68
78
  export type CapturedEvent = {
69
79
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-lcm",
3
- "version": "0.13.4",
3
+ "version": "0.13.6",
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/index.ts CHANGED
@@ -2,11 +2,75 @@ import { type Hooks, type PluginInput, tool } from '@opencode-ai/plugin';
2
2
 
3
3
  import { resolveOptions } from './options.js';
4
4
  import { SqliteLcmStore } from './store.js';
5
+ import type { OpencodeLcmOptions } from './types.js';
5
6
 
6
7
  type PluginWithOptions = (ctx: PluginInput, rawOptions?: unknown) => Promise<Hooks>;
7
8
 
9
+ const ALLOW_UNSAFE_BUN_WINDOWS_ENV = 'OPENCODE_LCM_ALLOW_UNSAFE_BUN_WINDOWS';
10
+
11
+ function isTruthyEnvFlag(value: string | undefined): boolean {
12
+ if (!value) return false;
13
+ const normalized = value.trim().toLowerCase();
14
+ return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
15
+ }
16
+
17
+ function isBunRuntime(): boolean {
18
+ return typeof globalThis === 'object' && globalThis !== null && 'Bun' in globalThis;
19
+ }
20
+
21
+ function isUnsafeBunWindowsRuntime(): boolean {
22
+ return isBunRuntime() && process.platform === 'win32';
23
+ }
24
+
25
+ function resolveAllowUnsafeBunWindows(options: OpencodeLcmOptions): boolean {
26
+ return (
27
+ options.runtimeSafety.allowUnsafeBunWindows ||
28
+ isTruthyEnvFlag(process.env[ALLOW_UNSAFE_BUN_WINDOWS_ENV])
29
+ );
30
+ }
31
+
32
+ function buildSafeModeStatus(allowUnsafeBunWindows: boolean): string {
33
+ return [
34
+ 'status=disabled',
35
+ 'reason=bun_windows_runtime_guard',
36
+ 'available_tools=lcm_status',
37
+ `platform=${process.platform}`,
38
+ `bun_runtime=${isBunRuntime()}`,
39
+ `runtime_safety_allow_unsafe_bun_windows=${allowUnsafeBunWindows}`,
40
+ 'override_config=runtimeSafety.allowUnsafeBunWindows=true',
41
+ `override_env=${ALLOW_UNSAFE_BUN_WINDOWS_ENV}=1`,
42
+ 'message=opencode-lcm disabled itself before opening SQLite because Bun on Windows has reported native crashes in this path',
43
+ ].join('\n');
44
+ }
45
+
46
+ function createSafeModeHooks(allowUnsafeBunWindows: boolean): Hooks {
47
+ return {
48
+ event: async () => {},
49
+
50
+ tool: {
51
+ lcm_status: tool({
52
+ description: 'Show archived LCM capture stats',
53
+ args: {},
54
+ async execute() {
55
+ return buildSafeModeStatus(allowUnsafeBunWindows);
56
+ },
57
+ }),
58
+ },
59
+
60
+ 'experimental.chat.messages.transform': async () => {},
61
+ 'experimental.chat.system.transform': async () => {},
62
+ 'experimental.session.compacting': async () => {},
63
+ };
64
+ }
65
+
8
66
  export const OpencodeLcmPlugin: PluginWithOptions = async (ctx, rawOptions) => {
9
67
  const options = resolveOptions(rawOptions);
68
+ const allowUnsafeBunWindows = resolveAllowUnsafeBunWindows(options);
69
+
70
+ if (isUnsafeBunWindowsRuntime() && !allowUnsafeBunWindows) {
71
+ return createSafeModeHooks(allowUnsafeBunWindows);
72
+ }
73
+
10
74
  const store = new SqliteLcmStore(ctx.directory, options);
11
75
 
12
76
  await store.init();
@@ -56,6 +120,7 @@ export const OpencodeLcmPlugin: PluginWithOptions = async (ctx, rawOptions) => {
56
120
  `fresh_tail_messages=${options.freshTailMessages}`,
57
121
  `min_messages_for_transform=${options.minMessagesForTransform}`,
58
122
  `large_content_threshold=${options.largeContentThreshold}`,
123
+ `runtime_safety_allow_unsafe_bun_windows=${allowUnsafeBunWindows}`,
59
124
  `binary_preview_providers=${options.binaryPreviewProviders.join(',')}`,
60
125
  `preview_byte_peek=${options.previewBytePeek}`,
61
126
  `privacy_exclude_tool_prefixes=${options.privacy.excludeToolPrefixes.join(',')}`,
package/src/options.ts CHANGED
@@ -6,9 +6,12 @@ import type {
6
6
  OpencodeLcmOptions,
7
7
  PrivacyOptions,
8
8
  RetentionPolicyOptions,
9
+ RuntimeSafetyOptions,
9
10
  ScopeDefaults,
10
11
  ScopeName,
11
12
  ScopeProfile,
13
+ SummaryStrategyName,
14
+ SummaryV2Options,
12
15
  } from './types.js';
13
16
 
14
17
  const DEFAULT_INTEROP: InteropOptions = {
@@ -54,6 +57,15 @@ const DEFAULT_AUTOMATIC_RETRIEVAL: AutomaticRetrievalOptions = {
54
57
  },
55
58
  };
56
59
 
60
+ export const DEFAULT_SUMMARY_V2: SummaryV2Options = {
61
+ strategy: 'deterministic-v2',
62
+ perMessageBudget: 110,
63
+ };
64
+
65
+ const DEFAULT_RUNTIME_SAFETY: RuntimeSafetyOptions = {
66
+ allowUnsafeBunWindows: false,
67
+ };
68
+
57
69
  export const DEFAULT_OPTIONS: OpencodeLcmOptions = {
58
70
  interop: DEFAULT_INTEROP,
59
71
  scopeDefaults: DEFAULT_SCOPE_DEFAULTS,
@@ -78,6 +90,8 @@ export const DEFAULT_OPTIONS: OpencodeLcmOptions = {
78
90
  'zip-metadata',
79
91
  ],
80
92
  previewBytePeek: 16,
93
+ summaryV2: DEFAULT_SUMMARY_V2,
94
+ runtimeSafety: DEFAULT_RUNTIME_SAFETY,
81
95
  };
82
96
 
83
97
  function asRecord(value: unknown): Record<string, unknown> | undefined {
@@ -238,6 +252,28 @@ function asAutomaticRetrievalStopOptions(
238
252
  };
239
253
  }
240
254
 
255
+ function asSummaryStrategy(value: unknown, fallback: SummaryStrategyName): SummaryStrategyName {
256
+ return value === 'deterministic-v1' || value === 'deterministic-v2' ? value : fallback;
257
+ }
258
+
259
+ function asSummaryV2Options(value: unknown, fallback: SummaryV2Options): SummaryV2Options {
260
+ const record = asRecord(value);
261
+ return {
262
+ strategy: asSummaryStrategy(record?.strategy, fallback.strategy),
263
+ perMessageBudget: asNumber(record?.perMessageBudget, fallback.perMessageBudget),
264
+ };
265
+ }
266
+
267
+ function asRuntimeSafetyOptions(
268
+ value: unknown,
269
+ fallback: RuntimeSafetyOptions,
270
+ ): RuntimeSafetyOptions {
271
+ const record = asRecord(value);
272
+ return {
273
+ allowUnsafeBunWindows: asBoolean(record?.allowUnsafeBunWindows, fallback.allowUnsafeBunWindows),
274
+ };
275
+ }
276
+
241
277
  export function resolveOptions(raw: unknown): OpencodeLcmOptions {
242
278
  const options = asRecord(raw);
243
279
  const interop = asRecord(options?.interop);
@@ -293,5 +329,7 @@ export function resolveOptions(raw: unknown): OpencodeLcmOptions {
293
329
  DEFAULT_OPTIONS.binaryPreviewProviders,
294
330
  ),
295
331
  previewBytePeek: asNumber(options?.previewBytePeek, DEFAULT_OPTIONS.previewBytePeek),
332
+ summaryV2: asSummaryV2Options(options?.summaryV2, DEFAULT_SUMMARY_V2),
333
+ runtimeSafety: asRuntimeSafetyOptions(options?.runtimeSafety, DEFAULT_RUNTIME_SAFETY),
296
334
  };
297
335
  }
@@ -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
  }
package/src/store.ts CHANGED
@@ -72,6 +72,7 @@ import type {
72
72
  ScopeName,
73
73
  SearchResult,
74
74
  StoreStats,
75
+ SummaryStrategyName,
75
76
  } from './types.js';
76
77
  import {
77
78
  asRecord,
@@ -101,6 +102,7 @@ type SummaryNodeData = {
101
102
  endIndex: number;
102
103
  messageIDs: string[];
103
104
  summaryText: string;
105
+ strategy: SummaryStrategyName;
104
106
  createdAt: number;
105
107
  };
106
108
 
@@ -379,6 +381,7 @@ function filterValidConversationMessages(
379
381
  messages: ConversationMessage[],
380
382
  context?: MessageValidationContext,
381
383
  ): ConversationMessage[] {
384
+ if (context?.operation === 'transformMessages') return messages;
382
385
  const valid = messages.filter((message) => Boolean(getValidMessageInfo(message?.info)));
383
386
  const dropped = messages.length - valid.length;
384
387
  if (dropped > 0 && context) {
@@ -416,14 +419,21 @@ function getDeferredPartUpdateKey(event: Event): string | undefined {
416
419
  return `${event.properties.part.sessionID}:${event.properties.part.messageID}:${event.properties.part.id}`;
417
420
  }
418
421
 
419
- function compareMessages(a: ConversationMessage, b: ConversationMessage): number {
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;
422
+ function messageCreatedAt(message: ConversationMessage | undefined): number {
423
+ const created = message?.info?.time?.created;
424
+ return typeof created === 'number' && Number.isFinite(created) ? created : 0;
425
+ }
426
+
427
+ function messageParts(message: ConversationMessage | undefined): Part[] {
428
+ return Array.isArray(message?.parts) ? message.parts : [];
429
+ }
425
430
 
426
- return aInfo.time.created - bInfo.time.created || aInfo.id.localeCompare(bInfo.id);
431
+ function signatureString(value: unknown, fallback = ''): string {
432
+ return typeof value === 'string' ? value : fallback;
433
+ }
434
+
435
+ function compareMessages(a: ConversationMessage, b: ConversationMessage): number {
436
+ return messageCreatedAt(a) - messageCreatedAt(b);
427
437
  }
428
438
 
429
439
  function emptySession(sessionID: string): NormalizedSession {
@@ -501,19 +511,21 @@ function isSyntheticLcmTextPart(part: Part, markers?: string[]): boolean {
501
511
  function guessMessageText(message: ConversationMessage, ignoreToolPrefixes: string[]): string {
502
512
  const segments: string[] = [];
503
513
 
504
- for (const part of message.parts) {
514
+ for (const part of messageParts(message)) {
505
515
  switch (part.type) {
506
516
  case 'text': {
507
517
  if (isSyntheticLcmTextPart(part, ['archive-summary', 'retrieved-context', 'archived-part']))
508
518
  break;
509
- if (part.text.startsWith('[Archived by opencode-lcm:')) break;
510
- const sanitized = sanitizeAutomaticRetrievalSourceText(part.text);
519
+ const text = typeof part.text === 'string' ? part.text : '';
520
+ if (text.startsWith('[Archived by opencode-lcm:')) break;
521
+ const sanitized = sanitizeAutomaticRetrievalSourceText(text);
511
522
  if (sanitized) segments.push(sanitized);
512
523
  break;
513
524
  }
514
525
  case 'reasoning': {
515
- if (part.text.startsWith('[Archived by opencode-lcm:')) break;
516
- const sanitized = sanitizeAutomaticRetrievalSourceText(part.text);
526
+ const text = typeof part.text === 'string' ? part.text : '';
527
+ if (text.startsWith('[Archived by opencode-lcm:')) break;
528
+ const sanitized = sanitizeAutomaticRetrievalSourceText(text);
517
529
  if (sanitized) segments.push(sanitized);
518
530
  break;
519
531
  }
@@ -525,12 +537,13 @@ function guessMessageText(message: ConversationMessage, ignoreToolPrefixes: stri
525
537
  break;
526
538
  }
527
539
  case 'tool': {
528
- if (ignoreToolPrefixes.some((prefix) => part.tool.startsWith(prefix))) break;
540
+ const toolName = typeof part.tool === 'string' ? part.tool : '';
541
+ if (ignoreToolPrefixes.some((prefix) => toolName.startsWith(prefix))) break;
529
542
  const state = part.state;
530
- if (state.status === 'completed') segments.push(`${part.tool}: ${state.output}`);
531
- if (state.status === 'error') segments.push(`${part.tool}: ${state.error}`);
543
+ if (state.status === 'completed') segments.push(`${toolName}: ${state.output}`);
544
+ if (state.status === 'error') segments.push(`${toolName}: ${state.error}`);
532
545
  if (state.status === 'pending' || state.status === 'running') {
533
- segments.push(`${part.tool}: ${JSON.stringify(state.input)}`);
546
+ segments.push(`${toolName}: ${JSON.stringify(state.input)}`);
534
547
  }
535
548
  if (state.status === 'completed' && state.attachments && state.attachments.length > 0) {
536
549
  const attachmentNames = state.attachments
@@ -538,7 +551,7 @@ function guessMessageText(message: ConversationMessage, ignoreToolPrefixes: stri
538
551
  .filter(Boolean)
539
552
  .slice(0, 4);
540
553
  if (attachmentNames.length > 0)
541
- segments.push(`${part.tool} attachments: ${attachmentNames.join(', ')}`);
554
+ segments.push(`${toolName} attachments: ${attachmentNames.join(', ')}`);
542
555
  }
543
556
  break;
544
557
  }
@@ -1065,6 +1078,7 @@ export class SqliteLcmStore {
1065
1078
  end_index INTEGER NOT NULL,
1066
1079
  message_ids_json TEXT NOT NULL,
1067
1080
  summary_text TEXT NOT NULL,
1081
+ strategy TEXT NOT NULL DEFAULT 'deterministic-v1',
1068
1082
  created_at INTEGER NOT NULL,
1069
1083
  FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
1070
1084
  );
@@ -1121,6 +1135,7 @@ export class SqliteLcmStore {
1121
1135
 
1122
1136
  this.ensureSessionColumnsSync();
1123
1137
  this.ensureSummaryStateColumnsSync();
1138
+ this.ensureSummaryNodeColumnsSync();
1124
1139
  this.ensureArtifactColumnsSync();
1125
1140
  logStartupPhase('open-db:create-indexes');
1126
1141
  db.exec('CREATE INDEX IF NOT EXISTS idx_artifacts_content_hash ON artifacts(content_hash)');
@@ -1710,7 +1725,7 @@ export class SqliteLcmStore {
1710
1725
  return issues.length > 0 ? { sessionID: session.sessionID, issues } : undefined;
1711
1726
  }
1712
1727
 
1713
- const latestMessageCreated = archived.at(-1)?.info?.time?.created ?? 0;
1728
+ const latestMessageCreated = messageCreatedAt(archived.at(-1));
1714
1729
  const archivedSignature = this.buildArchivedSignature(archived);
1715
1730
  const rootIDs = state ? parseJson<string[]>(state.root_node_ids_json) : [];
1716
1731
  const roots = rootIDs
@@ -1863,7 +1878,7 @@ export class SqliteLcmStore {
1863
1878
  return this.isMessageArchivedSync(
1864
1879
  session.sessionID,
1865
1880
  existing.info.id,
1866
- existing.info?.time?.created ?? 0,
1881
+ messageCreatedAt(existing),
1867
1882
  );
1868
1883
  }
1869
1884
 
@@ -1888,7 +1903,7 @@ export class SqliteLcmStore {
1888
1903
  return this.isMessageArchivedSync(
1889
1904
  session.sessionID,
1890
1905
  message.info.id,
1891
- message.info?.time?.created ?? 0,
1906
+ messageCreatedAt(message),
1892
1907
  );
1893
1908
  }
1894
1909
  case 'message.part.removed': {
@@ -1899,7 +1914,7 @@ export class SqliteLcmStore {
1899
1914
  return this.isMessageArchivedSync(
1900
1915
  session.sessionID,
1901
1916
  message.info.id,
1902
- message.info?.time?.created ?? 0,
1917
+ messageCreatedAt(message),
1903
1918
  );
1904
1919
  }
1905
1920
  default:
@@ -3588,6 +3603,16 @@ export class SqliteLcmStore {
3588
3603
  private summarizeMessages(
3589
3604
  messages: ConversationMessage[],
3590
3605
  limit = SUMMARY_NODE_CHAR_LIMIT,
3606
+ ): string {
3607
+ const strategy = this.options.summaryV2?.strategy ?? 'deterministic-v1';
3608
+ return strategy === 'deterministic-v2'
3609
+ ? this.summarizeMessagesDeterministicV2(messages, limit)
3610
+ : this.summarizeMessagesDeterministicV1(messages, limit);
3611
+ }
3612
+
3613
+ private summarizeMessagesDeterministicV1(
3614
+ messages: ConversationMessage[],
3615
+ limit = SUMMARY_NODE_CHAR_LIMIT,
3591
3616
  ): string {
3592
3617
  const goals = messages
3593
3618
  .filter((message) => message.info.role === 'user')
@@ -3617,6 +3642,71 @@ export class SqliteLcmStore {
3617
3642
  return truncate(segments.join(' || '), limit);
3618
3643
  }
3619
3644
 
3645
+ private summarizeMessagesDeterministicV2(
3646
+ messages: ConversationMessage[],
3647
+ limit = SUMMARY_NODE_CHAR_LIMIT,
3648
+ ): string {
3649
+ const perMsgBudget = this.options.summaryV2?.perMessageBudget ?? 110;
3650
+ const ignoreToolPrefixes = this.options.interop.ignoreToolPrefixes;
3651
+ const userTexts = messages
3652
+ .filter((message) => message.info.role === 'user')
3653
+ .map((message) => guessMessageText(message, ignoreToolPrefixes))
3654
+ .filter(Boolean);
3655
+ const assistantTexts = messages
3656
+ .filter((message) => message.info.role === 'assistant')
3657
+ .map((message) => guessMessageText(message, ignoreToolPrefixes))
3658
+ .filter(Boolean);
3659
+ const allFiles = [...new Set(messages.flatMap(listFiles))];
3660
+ const allTools = [...new Set(this.listTools(messages))];
3661
+ const hasErrors = messages.some((message) =>
3662
+ message.parts.some(
3663
+ (part) =>
3664
+ (part.type === 'tool' && 'state' in part && part.state?.status === 'error') ||
3665
+ (part.type === 'text' &&
3666
+ /\b(?:error|exception|fail(?:ed|ure)?)\b/i.test(part.text ?? '')),
3667
+ ),
3668
+ );
3669
+
3670
+ const segments: string[] = [];
3671
+ if (userTexts.length > 0) {
3672
+ const first = truncate(userTexts[0], perMsgBudget);
3673
+ if (userTexts.length > 1) {
3674
+ const last = truncate(userTexts[userTexts.length - 1], perMsgBudget);
3675
+ segments.push(`Goals: ${first} → ${last}`);
3676
+ } else {
3677
+ segments.push(`Goals: ${first}`);
3678
+ }
3679
+ }
3680
+
3681
+ if (assistantTexts.length > 0) {
3682
+ const recent = assistantTexts
3683
+ .slice(-2)
3684
+ .map((text) => truncate(text, perMsgBudget))
3685
+ .join(' | ');
3686
+ segments.push(`Work: ${recent}`);
3687
+ }
3688
+
3689
+ if (allFiles.length > 0) {
3690
+ const shown = allFiles.slice(0, 6).join(', ');
3691
+ segments.push(
3692
+ allFiles.length > 6 ? `Files[${allFiles.length}]: ${shown}` : `Files: ${shown}`,
3693
+ );
3694
+ }
3695
+
3696
+ if (allTools.length > 0) {
3697
+ const shown = allTools.slice(0, 6).join(', ');
3698
+ segments.push(
3699
+ allTools.length > 6 ? `Tools[${allTools.length}]: ${shown}` : `Tools: ${shown}`,
3700
+ );
3701
+ }
3702
+
3703
+ if (hasErrors) segments.push('⚠err');
3704
+ segments.push(`${messages.length}msg(u:${userTexts.length}/a:${assistantTexts.length})`);
3705
+
3706
+ if (segments.length === 0) return truncate(`Archived ${messages.length} messages`, limit);
3707
+ return truncate(segments.join(' || '), limit);
3708
+ }
3709
+
3620
3710
  private listTools(messages: ConversationMessage[]): string[] {
3621
3711
  const tools: string[] = [];
3622
3712
  for (const message of messages) {
@@ -3632,13 +3722,13 @@ export class SqliteLcmStore {
3632
3722
  private buildArchivedSignature(messages: ConversationMessage[]): string {
3633
3723
  const hash = createHash('sha256');
3634
3724
  for (const message of messages) {
3635
- hash.update(message.info.id);
3636
- hash.update(message.info.role);
3637
- hash.update(String(message.info?.time?.created ?? 0));
3638
- hash.update(guessMessageText(message, this.options.interop.ignoreToolPrefixes));
3725
+ hash.update(signatureString(message.info?.id, 'unknown-message'));
3726
+ hash.update(signatureString(message.info?.role, 'unknown-role'));
3727
+ hash.update(String(messageCreatedAt(message)));
3728
+ hash.update(String(guessMessageText(message, this.options.interop.ignoreToolPrefixes) ?? ''));
3639
3729
  hash.update(JSON.stringify(listFiles(message)));
3640
3730
  hash.update(JSON.stringify(this.listTools([message])));
3641
- hash.update(String(message.parts.length));
3731
+ hash.update(String(messageParts(message).length));
3642
3732
  }
3643
3733
  return hash.digest('hex');
3644
3734
  }
@@ -3665,7 +3755,7 @@ export class SqliteLcmStore {
3665
3755
  return [];
3666
3756
  }
3667
3757
 
3668
- const latestMessageCreated = archivedMessages.at(-1)?.info?.time?.created ?? 0;
3758
+ const latestMessageCreated = messageCreatedAt(archivedMessages.at(-1));
3669
3759
  const archivedSignature = this.buildArchivedSignature(archivedMessages);
3670
3760
  const state = safeQueryOne<SummaryStateRow>(
3671
3761
  this.getDb().prepare('SELECT * FROM summary_state WHERE session_id = ?'),
@@ -3729,6 +3819,7 @@ export class SqliteLcmStore {
3729
3819
  archivedMessages.slice(node.startIndex, node.endIndex + 1),
3730
3820
  );
3731
3821
  if (node.summaryText !== expectedSummaryText) return false;
3822
+ if (node.strategy !== (this.options.summaryV2?.strategy ?? 'deterministic-v1')) return false;
3732
3823
 
3733
3824
  const children = this.readSummaryChildrenSync(node.nodeID);
3734
3825
  if (node.nodeKind === 'leaf') {
@@ -3768,6 +3859,7 @@ export class SqliteLcmStore {
3768
3859
  archivedSignature: string,
3769
3860
  ): SummaryNodeData[] {
3770
3861
  const now = Date.now();
3862
+ const summaryStrategy = this.options.summaryV2?.strategy ?? 'deterministic-v1';
3771
3863
  let level = 0;
3772
3864
  const nodes: SummaryNodeData[] = [];
3773
3865
  const edges: Array<{
@@ -3794,6 +3886,7 @@ export class SqliteLcmStore {
3794
3886
  endIndex: input.endIndex,
3795
3887
  messageIDs: input.messageIDs,
3796
3888
  summaryText: input.summaryText,
3889
+ strategy: summaryStrategy,
3797
3890
  createdAt: now,
3798
3891
  });
3799
3892
 
@@ -3857,8 +3950,8 @@ export class SqliteLcmStore {
3857
3950
 
3858
3951
  const insertNode = db.prepare(
3859
3952
  `INSERT INTO summary_nodes
3860
- (node_id, session_id, level, node_kind, start_index, end_index, message_ids_json, summary_text, created_at)
3861
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
3953
+ (node_id, session_id, level, node_kind, start_index, end_index, message_ids_json, summary_text, strategy, created_at)
3954
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
3862
3955
  );
3863
3956
  const insertEdge = db.prepare(
3864
3957
  `INSERT INTO summary_edges (session_id, parent_id, child_id, child_position)
@@ -3878,6 +3971,7 @@ export class SqliteLcmStore {
3878
3971
  node.endIndex,
3879
3972
  JSON.stringify(node.messageIDs),
3880
3973
  node.summaryText,
3974
+ node.strategy,
3881
3975
  node.createdAt,
3882
3976
  );
3883
3977
  insertSummaryFts.run(
@@ -3905,7 +3999,7 @@ export class SqliteLcmStore {
3905
3999
  ).run(
3906
4000
  sessionID,
3907
4001
  archivedMessages.length,
3908
- archivedMessages.at(-1)?.info?.time?.created ?? 0,
4002
+ messageCreatedAt(archivedMessages.at(-1)),
3909
4003
  archivedSignature,
3910
4004
  JSON.stringify(roots.map((node) => node.nodeID)),
3911
4005
  now,
@@ -3932,6 +4026,7 @@ export class SqliteLcmStore {
3932
4026
  endIndex: row.end_index,
3933
4027
  messageIDs: parseJson<string[]>(row.message_ids_json),
3934
4028
  summaryText: row.summary_text,
4029
+ strategy: row.strategy,
3935
4030
  createdAt: row.created_at,
3936
4031
  };
3937
4032
  }
@@ -4370,6 +4465,17 @@ export class SqliteLcmStore {
4370
4465
  db.exec("ALTER TABLE summary_state ADD COLUMN archived_signature TEXT NOT NULL DEFAULT ''");
4371
4466
  }
4372
4467
 
4468
+ private ensureSummaryNodeColumnsSync(): void {
4469
+ const db = this.getDb();
4470
+ const columns = db.prepare('PRAGMA table_info(summary_nodes)').all() as Array<{ name: string }>;
4471
+ const names = new Set(columns.map((column) => column.name));
4472
+ if (names.has('strategy')) return;
4473
+
4474
+ db.exec(
4475
+ "ALTER TABLE summary_nodes ADD COLUMN strategy TEXT NOT NULL DEFAULT 'deterministic-v1'",
4476
+ );
4477
+ }
4478
+
4373
4479
  private ensureArtifactColumnsSync(): void {
4374
4480
  const db = this.getDb();
4375
4481
  const columns = db.prepare('PRAGMA table_info(artifacts)').all() as Array<{ name: string }>;
package/src/types.ts CHANGED
@@ -55,6 +55,17 @@ export type AutomaticRetrievalOptions = {
55
55
  stop: AutomaticRetrievalStopOptions;
56
56
  };
57
57
 
58
+ export type SummaryStrategyName = 'deterministic-v1' | 'deterministic-v2';
59
+
60
+ export type SummaryV2Options = {
61
+ strategy: SummaryStrategyName;
62
+ perMessageBudget: number;
63
+ };
64
+
65
+ export type RuntimeSafetyOptions = {
66
+ allowUnsafeBunWindows: boolean;
67
+ };
68
+
58
69
  export type OpencodeLcmOptions = {
59
70
  interop: InteropOptions;
60
71
  scopeDefaults: ScopeDefaults;
@@ -74,6 +85,8 @@ export type OpencodeLcmOptions = {
74
85
  artifactViewChars: number;
75
86
  binaryPreviewProviders: string[];
76
87
  previewBytePeek: number;
88
+ summaryV2: SummaryV2Options;
89
+ runtimeSafety: RuntimeSafetyOptions;
77
90
  };
78
91
 
79
92
  export type CapturedEvent = {