@codemem/opencode-plugin 0.38.0 → 0.40.0-alpha.1

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";
18
+ const PINNED_BACKEND_VERSION = "0.40.0-alpha.1";
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);
@@ -622,7 +886,14 @@ const detectRunner = ({ cwd, envRunner }) => {
622
886
  }
623
887
  // Prefer the TS codemem if installed globally, fall back to npx
624
888
  try {
625
- const versionOutput = execSync("codemem --version", { encoding: "utf-8", timeout: 3000 }).trim();
889
+ const versionOutput = execSync("codemem --version", {
890
+ encoding: "utf-8",
891
+ timeout: 3000,
892
+ // Suppress shell "not found" noise when codemem is not on PATH; the
893
+ // catch below falls back to npx. Without this, stderr leaks to the
894
+ // terminal on every OpenCode startup for npx-only installs.
895
+ stdio: ["ignore", "pipe", "ignore"],
896
+ }).trim();
626
897
  // Accept any global codemem at or above the pinned backend version, so a
627
898
  // newer global install is used instead of silently downgrading to
628
899
  // `npx codemem@<pin>` (the old check only matched the exact pin or the
@@ -756,12 +1027,15 @@ export const OpencodeMemPlugin = async ({
756
1027
  const injectEnabled = envNotDisabled(
757
1028
  process.env.CODEMEM_INJECT_CONTEXT || "1"
758
1029
  );
1030
+ const injectSurface = resolveInjectSurface(process.env.CODEMEM_INJECT_SURFACE);
759
1031
  // Only use env overrides if explicitly set; otherwise CLI uses config defaults
760
1032
  const injectLimitEnv = process.env.CODEMEM_INJECT_LIMIT;
761
1033
  const injectLimit = injectLimitEnv ? parseNumber(injectLimitEnv, null) : null;
762
1034
  const injectTokenBudgetEnv = process.env.CODEMEM_INJECT_TOKEN_BUDGET;
763
1035
  const injectTokenBudget = injectTokenBudgetEnv ? parseNumber(injectTokenBudgetEnv, null) : null;
764
1036
  const injectionToastShown = new Set();
1037
+ const messageInjectionCache = new Map();
1038
+ const compactionInjectionSkips = new Map();
765
1039
  let sessionStartedAt = null;
766
1040
  let activeSessionID = null;
767
1041
  let viewerStarted = false;
@@ -1052,6 +1326,20 @@ export const OpencodeMemPlugin = async ({
1052
1326
  return event?.properties?.sessionID || null;
1053
1327
  };
1054
1328
 
1329
+ const extractHookSessionID = (input) => {
1330
+ if (!input || typeof input !== "object") {
1331
+ return null;
1332
+ }
1333
+ return (
1334
+ input.sessionID ||
1335
+ input.sessionId ||
1336
+ input.session?.id ||
1337
+ input.session?.sessionID ||
1338
+ input.properties?.sessionID ||
1339
+ null
1340
+ );
1341
+ };
1342
+
1055
1343
  // Session context tracking for comprehensive memories
1056
1344
  const sessionContext = {
1057
1345
  firstPrompt: null,
@@ -1500,21 +1788,34 @@ export const OpencodeMemPlugin = async ({
1500
1788
  await showToast(`${message}. Suggested action: ${guidance.action}`, "warning");
1501
1789
  };
1502
1790
 
1503
- const resolveInjectQuery = () =>
1504
- buildInjectQuery({
1505
- firstPrompt: sessionContext.firstPrompt,
1506
- lastPromptText,
1791
+ const resolveInjectQuery = (overrides = {}) => {
1792
+ const firstPrompt = hasOwn(overrides, "firstPrompt")
1793
+ ? overrides.firstPrompt
1794
+ : sessionContext.firstPrompt;
1795
+ const resolvedLastPromptText = hasOwn(overrides, "lastPromptText")
1796
+ ? overrides.lastPromptText
1797
+ : lastPromptText;
1798
+ return buildInjectQuery({
1799
+ firstPrompt,
1800
+ lastPromptText: resolvedLastPromptText,
1507
1801
  projectName: resolveProjectName(project, cwd),
1508
1802
  filesModified: sessionContext.filesModified,
1509
1803
  });
1804
+ };
1510
1805
 
1511
- const describeInjectQuery = (query) => {
1806
+ const describeInjectQuery = (query, overrides = {}) => {
1512
1807
  const safeQuery = redactLog((query || "").trim(), 240);
1513
1808
  const projectName = resolveProjectName(project, cwd) || "";
1809
+ const firstPrompt = hasOwn(overrides, "firstPrompt")
1810
+ ? overrides.firstPrompt
1811
+ : sessionContext.firstPrompt;
1812
+ const resolvedLastPromptText = hasOwn(overrides, "lastPromptText")
1813
+ ? overrides.lastPromptText
1814
+ : lastPromptText;
1514
1815
  return {
1515
1816
  safeQuery,
1516
- firstPromptLen: sessionContext.firstPrompt?.trim()?.length || 0,
1517
- lastPromptLen: lastPromptText?.trim()?.length || 0,
1817
+ firstPromptLen: firstPrompt?.trim()?.length || 0,
1818
+ lastPromptLen: resolvedLastPromptText?.trim()?.length || 0,
1518
1819
  projectName,
1519
1820
  filesModifiedCount: sessionContext.filesModified.size,
1520
1821
  };
@@ -1652,6 +1953,9 @@ export const OpencodeMemPlugin = async ({
1652
1953
  cwd: runnerFrom,
1653
1954
  timeout: 500,
1654
1955
  encoding: "utf-8",
1956
+ // Suppress "fatal: not a git repository" when the working directory is
1957
+ // not a git repo. The catch below leaves version as "unknown".
1958
+ stdio: ["ignore", "pipe", "ignore"],
1655
1959
  }).trim();
1656
1960
  } catch (err) {
1657
1961
  // Ignore - version will remain 'unknown'
@@ -1806,7 +2110,87 @@ export const OpencodeMemPlugin = async ({
1806
2110
  };
1807
2111
 
1808
2112
  return {
2113
+ "experimental.session.compacting": async (input) => {
2114
+ const sessionID = extractHookSessionID(input) || activeSessionID;
2115
+ markCompactionInjectionSkip(compactionInjectionSkips, sessionID);
2116
+ if (debug) {
2117
+ await logLine(
2118
+ `inject.compaction_skip_marked sessionID=${sessionID || "unknown"}`
2119
+ );
2120
+ }
2121
+ },
2122
+ "experimental.chat.messages.transform": async (input, output) => {
2123
+ if (injectSurface === "system") {
2124
+ return;
2125
+ }
2126
+ const latestUser = Array.isArray(output?.messages)
2127
+ ? findLatestUserMessage(output.messages)
2128
+ : null;
2129
+ const sessionID = latestUser ? resolveEntrySessionID(latestUser.entry) : input?.sessionID;
2130
+ const latestPromptText = latestUser ? extractMessageText(latestUser.entry) : "";
2131
+ if (debug) {
2132
+ const query = resolveInjectQuery({ lastPromptText: latestPromptText });
2133
+ const { safeQuery, firstPromptLen, lastPromptLen, projectName, filesModifiedCount } =
2134
+ describeInjectQuery(query, { lastPromptText: latestPromptText });
2135
+ await logLine(
2136
+ `inject.messages_transform sessionID=${sessionID || "unknown"} query_len=${
2137
+ query ? query.length : 0
2138
+ } 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}`
2139
+ );
2140
+ }
2141
+
2142
+ let applied = false;
2143
+ try {
2144
+ applied = await applyInjectedContextToMessages({
2145
+ injectEnabled,
2146
+ input,
2147
+ output,
2148
+ injectionToastShown,
2149
+ showToast: client.tui?.showToast
2150
+ ? async (message) => {
2151
+ await client.tui.showToast({
2152
+ body: {
2153
+ message,
2154
+ variant: "info",
2155
+ },
2156
+ });
2157
+ }
2158
+ : null,
2159
+ resolveInjectQuery,
2160
+ buildInjectedContext,
2161
+ messageInjectionCache,
2162
+ compactionInjectionSkips,
2163
+ });
2164
+ } catch (err) {
2165
+ await logLine(
2166
+ `inject.messages_transform.error sessionID=${sessionID || "unknown"} message=${JSON.stringify(err instanceof Error ? err.message : String(err))}`
2167
+ );
2168
+ }
2169
+ if (debug) {
2170
+ const partsCount = Array.isArray(output?.messages)
2171
+ ? output.messages.reduce(
2172
+ (count, entry) => count + (Array.isArray(entry?.parts) ? entry.parts.length : 0),
2173
+ 0
2174
+ )
2175
+ : 0;
2176
+ await logLine(
2177
+ `inject.messages_transform.result sessionID=${sessionID || "unknown"} applied=${Boolean(applied)} messages=${Array.isArray(output?.messages) ? output.messages.length : 0} parts=${partsCount}`
2178
+ );
2179
+ }
2180
+ },
1809
2181
  "experimental.chat.system.transform": async (input, output) => {
2182
+ if (injectSurface !== "system") {
2183
+ return;
2184
+ }
2185
+ const hookSessionID = input?.sessionID || activeSessionID;
2186
+ if (consumeCompactionInjectionSkip(compactionInjectionSkips, hookSessionID)) {
2187
+ if (debug) {
2188
+ await logLine(
2189
+ `inject.transform.skip_compaction sessionID=${hookSessionID || "unknown"}`
2190
+ );
2191
+ }
2192
+ return;
2193
+ }
1810
2194
  const query = resolveInjectQuery();
1811
2195
  if (debug) {
1812
2196
  const { safeQuery, firstPromptLen, lastPromptLen, projectName, filesModifiedCount } =
@@ -2033,6 +2417,8 @@ export const OpencodeMemPlugin = async ({
2033
2417
  activeSessionID = null;
2034
2418
  if (sessionID) {
2035
2419
  injectionToastShown.delete(sessionID);
2420
+ messageInjectionCache.delete(sessionID);
2421
+ compactionInjectionSkips.delete(sessionID);
2036
2422
  }
2037
2423
  await stopViewer();
2038
2424
  }
@@ -2143,7 +2529,11 @@ export const __testUtils = {
2143
2529
  buildPackArgs,
2144
2530
  parsePackText,
2145
2531
  parsePackMetrics,
2532
+ resolveInjectSurface,
2146
2533
  applyInjectedContextToOutput,
2534
+ applyInjectedContextToMessages,
2535
+ extractMessageText,
2536
+ isCodememContextPart,
2147
2537
  buildRunnerArgs,
2148
2538
  appendWorkingSetFileArgs,
2149
2539
  extractApplyPatchPaths,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemem/opencode-plugin",
3
- "version": "0.38.0",
3
+ "version": "0.40.0-alpha.1",
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.13"
21
+ "@opencode-ai/plugin": "1.18.4"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",