@codemem/opencode-plugin 0.38.0-alpha.2 → 0.39.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.
@@ -15,9 +15,13 @@ import {
15
15
 
16
16
  const TRUTHY_VALUES = ["1", "true", "yes"];
17
17
  const DISABLED_VALUES = ["0", "false", "off"];
18
- const PINNED_BACKEND_VERSION = "0.38.0-alpha.2";
18
+ const PINNED_BACKEND_VERSION = "0.39.0";
19
19
  const COMPAT_CHECK_DELAY_MS = 1500;
20
20
  const COMPAT_CHECK_CACHE_TTL_MS = 5 * 60 * 1000;
21
+ const CODEMEM_CONTEXT_PART_ID_PREFIX = "codemem-context-";
22
+ const MAX_MESSAGE_INJECTION_CACHE_SESSIONS = 20;
23
+ const MAX_MESSAGE_INJECTION_CACHE_MESSAGES = 100;
24
+ const COMPACTION_INJECTION_SKIP_TTL_MS = 30 * 1000;
21
25
 
22
26
  let compatCheckCache = null;
23
27
 
@@ -27,6 +31,16 @@ const envHasValue = (value, truthyValues) =>
27
31
  const envNotDisabled = (value) =>
28
32
  !DISABLED_VALUES.includes(normalizeEnvValue(value));
29
33
 
34
+ const resolveInjectSurface = (value) => {
35
+ const normalized = String(value || "message").trim().toLowerCase();
36
+ if (normalized === "system") {
37
+ return "system";
38
+ }
39
+ return "message";
40
+ };
41
+
42
+ const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value || {}, key);
43
+
30
44
  const DEFAULT_LOG_PATH = (homeDir, cwd) => `${homeDir || cwd}/.codemem/plugin.log`;
31
45
 
32
46
  const resolveLogPath = (logPathEnvRaw, cwd, homeDir) => {
@@ -284,6 +298,256 @@ const applyInjectedContextToOutput = async ({
284
298
  return true;
285
299
  };
286
300
 
301
+ const isUserMessageEntry = (entry) => entry?.info?.role === "user";
302
+
303
+ const resolveEntryMessageId = (entry) => {
304
+ const id = entry?.info?.id || entry?.parts?.find((part) => part?.messageID)?.messageID;
305
+ return id ? String(id) : null;
306
+ };
307
+
308
+ const fallbackEntryMessageId = (entry, index = 0) => {
309
+ const id = resolveEntryMessageId(entry);
310
+ if (id) return id;
311
+ const text = extractMessageText(entry);
312
+ if (text) {
313
+ return `message-${createHash("sha256").update(text).digest("hex").slice(0, 16)}`;
314
+ }
315
+ return `message-${index}`;
316
+ };
317
+
318
+ const resolveEntrySessionID = (entry) => {
319
+ const id = entry?.info?.sessionID || entry?.parts?.find((part) => part?.sessionID)?.sessionID;
320
+ return id ? String(id) : null;
321
+ };
322
+
323
+ const isCodememContextPart = (part) =>
324
+ part?.type === "text" && String(part?.id || "").startsWith(CODEMEM_CONTEXT_PART_ID_PREFIX);
325
+
326
+ const extractMessageText = (entry) => {
327
+ if (!Array.isArray(entry?.parts)) {
328
+ return "";
329
+ }
330
+ return entry.parts
331
+ .filter((part) => part?.type === "text" && !isCodememContextPart(part))
332
+ .map((part) => String(part?.text || "").trim())
333
+ .filter(Boolean)
334
+ .join("\n")
335
+ .trim();
336
+ };
337
+
338
+ const findFirstUserMessage = (messages) => {
339
+ for (let index = 0; index < messages.length; index += 1) {
340
+ const entry = messages[index];
341
+ if (isUserMessageEntry(entry)) {
342
+ return { entry, index };
343
+ }
344
+ }
345
+ return null;
346
+ };
347
+
348
+ const findLatestUserMessage = (messages) => {
349
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
350
+ const entry = messages[index];
351
+ if (isUserMessageEntry(entry)) {
352
+ return { entry, index };
353
+ }
354
+ }
355
+ return null;
356
+ };
357
+
358
+ const markCompactionInjectionSkip = (compactionInjectionSkips, sessionID, now = Date.now()) => {
359
+ if (!compactionInjectionSkips || !sessionID) return;
360
+ compactionInjectionSkips.set(String(sessionID), now + COMPACTION_INJECTION_SKIP_TTL_MS);
361
+ };
362
+
363
+ const consumeCompactionInjectionSkip = (compactionInjectionSkips, sessionID, now = Date.now()) => {
364
+ if (!compactionInjectionSkips || !sessionID) return false;
365
+ const key = String(sessionID);
366
+ const expiresAt = compactionInjectionSkips.get(key);
367
+ if (!expiresAt) return false;
368
+ compactionInjectionSkips.delete(key);
369
+ return now <= expiresAt;
370
+ };
371
+
372
+ // OpenCode does not guarantee that synthetic message parts survive into the
373
+ // next transform call. Cache each message's injected block by message ID and
374
+ // re-append the exact same bytes on later turns: older prompts stay stable for
375
+ // provider prefix caching while only the newest user message triggers a fresh
376
+ // pack build. The replay cache only activates when OpenCode provides both a
377
+ // session ID and a real message ID; unidentified messages still receive current
378
+ // turn context, but are not cached because positional fallbacks can drift across
379
+ // turns or sessions. Do not "simplify" this into recomputing previous turns.
380
+ const getSessionMessageInjectionCache = (messageInjectionCache, sessionID) => {
381
+ if (!sessionID) {
382
+ return null;
383
+ }
384
+ const cacheKey = sessionID;
385
+ let sessionCache = messageInjectionCache.get(cacheKey);
386
+ if (sessionCache) {
387
+ // Refresh recency so the bounded cache behaves like a small LRU.
388
+ messageInjectionCache.delete(cacheKey);
389
+ } else {
390
+ sessionCache = new Map();
391
+ }
392
+ messageInjectionCache.set(cacheKey, sessionCache);
393
+ while (messageInjectionCache.size > MAX_MESSAGE_INJECTION_CACHE_SESSIONS) {
394
+ const oldestKey = messageInjectionCache.keys().next().value;
395
+ if (!oldestKey) break;
396
+ messageInjectionCache.delete(oldestKey);
397
+ }
398
+ return sessionCache;
399
+ };
400
+
401
+ const trimSessionMessageInjectionCache = (sessionCache) => {
402
+ if (!sessionCache) return;
403
+ while (sessionCache.size > MAX_MESSAGE_INJECTION_CACHE_MESSAGES) {
404
+ const oldestKey = sessionCache.keys().next().value;
405
+ if (!oldestKey) break;
406
+ sessionCache.delete(oldestKey);
407
+ }
408
+ };
409
+
410
+ const normalizeInjectedMessageParts = (messages, sessionCache) => {
411
+ for (let index = 0; index < messages.length; index += 1) {
412
+ const entry = messages[index];
413
+ if (!isUserMessageEntry(entry) || !Array.isArray(entry.parts)) {
414
+ continue;
415
+ }
416
+
417
+ const messageId = resolveEntryMessageId(entry);
418
+ const retainedParts = [];
419
+ let existingText = null;
420
+ for (const part of entry.parts) {
421
+ if (isCodememContextPart(part)) {
422
+ const text = String(part?.text || "");
423
+ if (text.trim()) {
424
+ existingText = text;
425
+ }
426
+ } else {
427
+ retainedParts.push(part);
428
+ }
429
+ }
430
+ if (existingText && sessionCache && messageId && !sessionCache.has(messageId)) {
431
+ sessionCache.set(messageId, existingText);
432
+ trimSessionMessageInjectionCache(sessionCache);
433
+ }
434
+ if (retainedParts.length !== entry.parts.length) {
435
+ entry.parts.splice(0, entry.parts.length, ...retainedParts);
436
+ }
437
+ }
438
+ };
439
+
440
+ const buildInjectedContextPart = (entry, index, text, options = {}) => {
441
+ const messageId = options.messageId || fallbackEntryMessageId(entry, index);
442
+ const sessionID = options.sessionID || resolveEntrySessionID(entry) || "unknown";
443
+ return {
444
+ id: `${CODEMEM_CONTEXT_PART_ID_PREFIX}${messageId}`,
445
+ sessionID,
446
+ messageID: messageId,
447
+ type: "text",
448
+ text,
449
+ synthetic: true,
450
+ };
451
+ };
452
+
453
+ const appendCachedInjectedContextParts = (messages, sessionCache, sessionID = null) => {
454
+ let appended = 0;
455
+ for (let index = 0; index < messages.length; index += 1) {
456
+ const entry = messages[index];
457
+ if (!isUserMessageEntry(entry) || !Array.isArray(entry.parts)) {
458
+ continue;
459
+ }
460
+ const messageId = resolveEntryMessageId(entry);
461
+ if (!messageId) {
462
+ continue;
463
+ }
464
+ const text = sessionCache.get(messageId);
465
+ if (!text) {
466
+ continue;
467
+ }
468
+ entry.parts.push(buildInjectedContextPart(entry, index, text, { messageId, sessionID }));
469
+ appended += 1;
470
+ }
471
+ return appended;
472
+ };
473
+
474
+ const applyInjectedContextToMessages = async ({
475
+ injectEnabled,
476
+ input,
477
+ output,
478
+ injectionToastShown,
479
+ showToast,
480
+ resolveInjectQuery,
481
+ buildInjectedContext,
482
+ messageInjectionCache,
483
+ compactionInjectionSkips,
484
+ }) => {
485
+ if (!injectEnabled || !Array.isArray(output?.messages)) {
486
+ return false;
487
+ }
488
+
489
+ const latestUser = findLatestUserMessage(output.messages);
490
+ const sessionID = latestUser
491
+ ? resolveEntrySessionID(latestUser.entry) || input?.sessionID || null
492
+ : input?.sessionID || null;
493
+
494
+ if (consumeCompactionInjectionSkip(compactionInjectionSkips, sessionID)) {
495
+ normalizeInjectedMessageParts(output.messages, null);
496
+ return false;
497
+ }
498
+
499
+ if (!latestUser) {
500
+ return false;
501
+ }
502
+
503
+ const sessionCache = getSessionMessageInjectionCache(messageInjectionCache, sessionID);
504
+ normalizeInjectedMessageParts(output.messages, sessionCache);
505
+
506
+ const latestMessageId = resolveEntryMessageId(latestUser.entry);
507
+ const canReplay = Boolean(sessionCache && latestMessageId);
508
+ if (!canReplay || !sessionCache.has(latestMessageId)) {
509
+ const firstUser = findFirstUserMessage(output.messages);
510
+ const query = resolveInjectQuery({
511
+ firstPrompt: firstUser ? extractMessageText(firstUser.entry) : null,
512
+ lastPromptText: extractMessageText(latestUser.entry),
513
+ });
514
+ const injected = await buildInjectedContext(query);
515
+ if (injected?.text) {
516
+ if (canReplay) {
517
+ sessionCache.set(latestMessageId, injected.text);
518
+ trimSessionMessageInjectionCache(sessionCache);
519
+ } else if (Array.isArray(latestUser.entry.parts)) {
520
+ latestUser.entry.parts.push(
521
+ buildInjectedContextPart(latestUser.entry, latestUser.index, injected.text, {
522
+ messageId: latestMessageId || undefined,
523
+ sessionID: sessionID || undefined,
524
+ })
525
+ );
526
+ }
527
+
528
+ const toastKey = sessionID || latestMessageId || "unknown";
529
+ if (!injectionToastShown.has(toastKey) && showToast) {
530
+ injectionToastShown.add(toastKey);
531
+ try {
532
+ await showToast(buildInjectionToastMessage(injected.metrics));
533
+ } catch {
534
+ // best-effort only
535
+ }
536
+ }
537
+ }
538
+ }
539
+
540
+ if (!canReplay) {
541
+ return Boolean(
542
+ Array.isArray(latestUser.entry.parts)
543
+ && latestUser.entry.parts.some(isCodememContextPart)
544
+ );
545
+ }
546
+
547
+ const appended = appendCachedInjectedContextParts(output.messages, sessionCache, sessionID);
548
+ return appended > 0;
549
+ };
550
+
287
551
  const mapOpencodeEventTypeToAdapterType = (eventType) => {
288
552
  if (eventType === "user_prompt") {
289
553
  return "prompt";
@@ -589,7 +853,7 @@ const parsePositiveInt = (value, fallback) => {
589
853
  };
590
854
 
591
855
  export const buildInjectionToastMessage = (metrics) => {
592
- const items = asFiniteNonNegativeInt(metrics?.items);
856
+ const items = asFiniteNonNegativeInt(metrics?.items) ?? asFiniteNonNegativeInt(metrics?.total_items);
593
857
  const packTokens = asFiniteNonNegativeInt(metrics?.pack_tokens);
594
858
  const avoided = asFiniteNonNegativeInt(metrics?.avoided_work_tokens);
595
859
  const avoidedUnknown = asNonNegativeCount(metrics?.avoided_work_unknown_items);
@@ -756,12 +1020,15 @@ export const OpencodeMemPlugin = async ({
756
1020
  const injectEnabled = envNotDisabled(
757
1021
  process.env.CODEMEM_INJECT_CONTEXT || "1"
758
1022
  );
1023
+ const injectSurface = resolveInjectSurface(process.env.CODEMEM_INJECT_SURFACE);
759
1024
  // Only use env overrides if explicitly set; otherwise CLI uses config defaults
760
1025
  const injectLimitEnv = process.env.CODEMEM_INJECT_LIMIT;
761
1026
  const injectLimit = injectLimitEnv ? parseNumber(injectLimitEnv, null) : null;
762
1027
  const injectTokenBudgetEnv = process.env.CODEMEM_INJECT_TOKEN_BUDGET;
763
1028
  const injectTokenBudget = injectTokenBudgetEnv ? parseNumber(injectTokenBudgetEnv, null) : null;
764
1029
  const injectionToastShown = new Set();
1030
+ const messageInjectionCache = new Map();
1031
+ const compactionInjectionSkips = new Map();
765
1032
  let sessionStartedAt = null;
766
1033
  let activeSessionID = null;
767
1034
  let viewerStarted = false;
@@ -1052,6 +1319,20 @@ export const OpencodeMemPlugin = async ({
1052
1319
  return event?.properties?.sessionID || null;
1053
1320
  };
1054
1321
 
1322
+ const extractHookSessionID = (input) => {
1323
+ if (!input || typeof input !== "object") {
1324
+ return null;
1325
+ }
1326
+ return (
1327
+ input.sessionID ||
1328
+ input.sessionId ||
1329
+ input.session?.id ||
1330
+ input.session?.sessionID ||
1331
+ input.properties?.sessionID ||
1332
+ null
1333
+ );
1334
+ };
1335
+
1055
1336
  // Session context tracking for comprehensive memories
1056
1337
  const sessionContext = {
1057
1338
  firstPrompt: null,
@@ -1500,21 +1781,34 @@ export const OpencodeMemPlugin = async ({
1500
1781
  await showToast(`${message}. Suggested action: ${guidance.action}`, "warning");
1501
1782
  };
1502
1783
 
1503
- const resolveInjectQuery = () =>
1504
- buildInjectQuery({
1505
- firstPrompt: sessionContext.firstPrompt,
1506
- lastPromptText,
1784
+ const resolveInjectQuery = (overrides = {}) => {
1785
+ const firstPrompt = hasOwn(overrides, "firstPrompt")
1786
+ ? overrides.firstPrompt
1787
+ : sessionContext.firstPrompt;
1788
+ const resolvedLastPromptText = hasOwn(overrides, "lastPromptText")
1789
+ ? overrides.lastPromptText
1790
+ : lastPromptText;
1791
+ return buildInjectQuery({
1792
+ firstPrompt,
1793
+ lastPromptText: resolvedLastPromptText,
1507
1794
  projectName: resolveProjectName(project, cwd),
1508
1795
  filesModified: sessionContext.filesModified,
1509
1796
  });
1797
+ };
1510
1798
 
1511
- const describeInjectQuery = (query) => {
1799
+ const describeInjectQuery = (query, overrides = {}) => {
1512
1800
  const safeQuery = redactLog((query || "").trim(), 240);
1513
1801
  const projectName = resolveProjectName(project, cwd) || "";
1802
+ const firstPrompt = hasOwn(overrides, "firstPrompt")
1803
+ ? overrides.firstPrompt
1804
+ : sessionContext.firstPrompt;
1805
+ const resolvedLastPromptText = hasOwn(overrides, "lastPromptText")
1806
+ ? overrides.lastPromptText
1807
+ : lastPromptText;
1514
1808
  return {
1515
1809
  safeQuery,
1516
- firstPromptLen: sessionContext.firstPrompt?.trim()?.length || 0,
1517
- lastPromptLen: lastPromptText?.trim()?.length || 0,
1810
+ firstPromptLen: firstPrompt?.trim()?.length || 0,
1811
+ lastPromptLen: resolvedLastPromptText?.trim()?.length || 0,
1518
1812
  projectName,
1519
1813
  filesModifiedCount: sessionContext.filesModified.size,
1520
1814
  };
@@ -1806,7 +2100,87 @@ export const OpencodeMemPlugin = async ({
1806
2100
  };
1807
2101
 
1808
2102
  return {
2103
+ "experimental.session.compacting": async (input) => {
2104
+ const sessionID = extractHookSessionID(input) || activeSessionID;
2105
+ markCompactionInjectionSkip(compactionInjectionSkips, sessionID);
2106
+ if (debug) {
2107
+ await logLine(
2108
+ `inject.compaction_skip_marked sessionID=${sessionID || "unknown"}`
2109
+ );
2110
+ }
2111
+ },
2112
+ "experimental.chat.messages.transform": async (input, output) => {
2113
+ if (injectSurface === "system") {
2114
+ return;
2115
+ }
2116
+ const latestUser = Array.isArray(output?.messages)
2117
+ ? findLatestUserMessage(output.messages)
2118
+ : null;
2119
+ const sessionID = latestUser ? resolveEntrySessionID(latestUser.entry) : input?.sessionID;
2120
+ const latestPromptText = latestUser ? extractMessageText(latestUser.entry) : "";
2121
+ if (debug) {
2122
+ const query = resolveInjectQuery({ lastPromptText: latestPromptText });
2123
+ const { safeQuery, firstPromptLen, lastPromptLen, projectName, filesModifiedCount } =
2124
+ describeInjectQuery(query, { lastPromptText: latestPromptText });
2125
+ await logLine(
2126
+ `inject.messages_transform sessionID=${sessionID || "unknown"} query_len=${
2127
+ query ? query.length : 0
2128
+ } inject_enabled=${injectEnabled} tui_toast=${Boolean(client.tui?.showToast)} query=${JSON.stringify(safeQuery)} first_prompt_len=${firstPromptLen} last_prompt_len=${lastPromptLen} project=${JSON.stringify(projectName)} files_modified=${filesModifiedCount}`
2129
+ );
2130
+ }
2131
+
2132
+ let applied = false;
2133
+ try {
2134
+ applied = await applyInjectedContextToMessages({
2135
+ injectEnabled,
2136
+ input,
2137
+ output,
2138
+ injectionToastShown,
2139
+ showToast: client.tui?.showToast
2140
+ ? async (message) => {
2141
+ await client.tui.showToast({
2142
+ body: {
2143
+ message,
2144
+ variant: "info",
2145
+ },
2146
+ });
2147
+ }
2148
+ : null,
2149
+ resolveInjectQuery,
2150
+ buildInjectedContext,
2151
+ messageInjectionCache,
2152
+ compactionInjectionSkips,
2153
+ });
2154
+ } catch (err) {
2155
+ await logLine(
2156
+ `inject.messages_transform.error sessionID=${sessionID || "unknown"} message=${JSON.stringify(err instanceof Error ? err.message : String(err))}`
2157
+ );
2158
+ }
2159
+ if (debug) {
2160
+ const partsCount = Array.isArray(output?.messages)
2161
+ ? output.messages.reduce(
2162
+ (count, entry) => count + (Array.isArray(entry?.parts) ? entry.parts.length : 0),
2163
+ 0
2164
+ )
2165
+ : 0;
2166
+ await logLine(
2167
+ `inject.messages_transform.result sessionID=${sessionID || "unknown"} applied=${Boolean(applied)} messages=${Array.isArray(output?.messages) ? output.messages.length : 0} parts=${partsCount}`
2168
+ );
2169
+ }
2170
+ },
1809
2171
  "experimental.chat.system.transform": async (input, output) => {
2172
+ if (injectSurface !== "system") {
2173
+ return;
2174
+ }
2175
+ const hookSessionID = input?.sessionID || activeSessionID;
2176
+ if (consumeCompactionInjectionSkip(compactionInjectionSkips, hookSessionID)) {
2177
+ if (debug) {
2178
+ await logLine(
2179
+ `inject.transform.skip_compaction sessionID=${hookSessionID || "unknown"}`
2180
+ );
2181
+ }
2182
+ return;
2183
+ }
1810
2184
  const query = resolveInjectQuery();
1811
2185
  if (debug) {
1812
2186
  const { safeQuery, firstPromptLen, lastPromptLen, projectName, filesModifiedCount } =
@@ -2033,6 +2407,8 @@ export const OpencodeMemPlugin = async ({
2033
2407
  activeSessionID = null;
2034
2408
  if (sessionID) {
2035
2409
  injectionToastShown.delete(sessionID);
2410
+ messageInjectionCache.delete(sessionID);
2411
+ compactionInjectionSkips.delete(sessionID);
2036
2412
  }
2037
2413
  await stopViewer();
2038
2414
  }
@@ -2143,7 +2519,11 @@ export const __testUtils = {
2143
2519
  buildPackArgs,
2144
2520
  parsePackText,
2145
2521
  parsePackMetrics,
2522
+ resolveInjectSurface,
2146
2523
  applyInjectedContextToOutput,
2524
+ applyInjectedContextToMessages,
2525
+ extractMessageText,
2526
+ isCodememContextPart,
2147
2527
  buildRunnerArgs,
2148
2528
  appendWorkingSetFileArgs,
2149
2529
  extractApplyPatchPaths,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemem/opencode-plugin",
3
- "version": "0.38.0-alpha.2",
3
+ "version": "0.39.0",
4
4
  "description": "CodeMem plugin for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -18,7 +18,7 @@
18
18
  ".opencode/lib/"
19
19
  ],
20
20
  "dependencies": {
21
- "@opencode-ai/plugin": "1.17.11"
21
+ "@opencode-ai/plugin": "1.17.13"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",