@codemem/opencode-plugin 0.40.0-alpha.1 → 0.40.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.
@@ -1,6 +1,6 @@
1
1
  import { appendFile, mkdir } from "node:fs/promises";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
- import { basename, dirname, join, resolve } from "node:path";
3
+ import { basename, dirname, join, posix, resolve, win32 } from "node:path";
4
4
  import { createHash } from "node:crypto";
5
5
  import { spawn as nodeSpawn, execSync } from "node:child_process";
6
6
  import { tool } from "@opencode-ai/plugin";
@@ -15,13 +15,14 @@ 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.40.0-alpha.1";
18
+ const PINNED_BACKEND_VERSION = "0.40.0";
19
19
  const COMPAT_CHECK_DELAY_MS = 1500;
20
20
  const COMPAT_CHECK_CACHE_TTL_MS = 5 * 60 * 1000;
21
21
  const CODEMEM_CONTEXT_PART_ID_PREFIX = "codemem-context-";
22
22
  const MAX_MESSAGE_INJECTION_CACHE_SESSIONS = 20;
23
23
  const MAX_MESSAGE_INJECTION_CACHE_MESSAGES = 100;
24
24
  const COMPACTION_INJECTION_SKIP_TTL_MS = 30 * 1000;
25
+ const MAX_WORKING_SET_PATH_CHARS = 400;
25
26
 
26
27
  let compatCheckCache = null;
27
28
 
@@ -41,6 +42,55 @@ const resolveInjectSurface = (value) => {
41
42
 
42
43
  const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value || {}, key);
43
44
 
45
+ const deterministicUuid = (namespace, components) => {
46
+ const hex = createHash("sha256")
47
+ .update(JSON.stringify([namespace, ...components.map((value) => String(value ?? ""))]))
48
+ .digest("hex");
49
+ const bytes = Buffer.from(hex.slice(0, 32), "hex");
50
+ bytes[6] = (bytes[6] & 0x0f) | 0x50;
51
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
52
+ const value = bytes.toString("hex");
53
+ return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
54
+ };
55
+
56
+ const promptPackIdentity = ({
57
+ source = "opencode",
58
+ sessionID = "unknown",
59
+ requestKey,
60
+ surface,
61
+ promptNumber = 0,
62
+ queryHash,
63
+ }) => {
64
+ const components = [source, sessionID, requestKey, surface, promptNumber, queryHash];
65
+ return {
66
+ attemptId: deterministicUuid("codemem.prompt-pack.attempt.v1", components),
67
+ requestId: deterministicUuid("codemem.prompt-pack.request.v1", components),
68
+ };
69
+ };
70
+
71
+ const hashPromptPackQuery = (query) =>
72
+ createHash("sha256").update(String(query || "")).digest("hex");
73
+
74
+ const redactPackCommand = (runner, runnerArgs, packArgs) => {
75
+ const safePackArgs = [...packArgs];
76
+ if (safePackArgs[0] === "pack" && safePackArgs.length > 1) {
77
+ safePackArgs[1] = "[query-redacted]";
78
+ }
79
+ for (let index = 0; index < safePackArgs.length - 1; index += 1) {
80
+ if (safePackArgs[index] === "--working-set-file") {
81
+ safePackArgs[index + 1] = "[path-redacted]";
82
+ }
83
+ }
84
+ return [runner, ...runnerArgs, ...safePackArgs].join(" ");
85
+ };
86
+
87
+ const rejectsInternalLedgerFlag = (result) => {
88
+ if (!result || result.exitCode === 0) return false;
89
+ const diagnostics = `${result.stderr || ""}\n${result.stdout || ""}`;
90
+ return diagnostics.includes("--internal-ledger")
91
+ && /(?:unknown|unsupported|unrecognized|invalid)\s+(?:option|argument)/i.test(diagnostics);
92
+ };
93
+
44
94
  const DEFAULT_LOG_PATH = (homeDir, cwd) => `${homeDir || cwd}/.codemem/plugin.log`;
45
95
 
46
96
  const resolveLogPath = (logPathEnvRaw, cwd, homeDir) => {
@@ -161,16 +211,82 @@ const extractApplyPatchPaths = (patchText) => {
161
211
  return paths;
162
212
  };
163
213
 
214
+ const windowsPathFlavor = (value, doubleSlashIsUnc = true) =>
215
+ /^[A-Za-z]:[\\/]/.test(value)
216
+ || /^\\\\/.test(value)
217
+ || (doubleSlashIsUnc && /^\/\/[^/]+[\\/][^/]+/.test(value));
218
+
219
+ const hasTraversalSegment = (value) =>
220
+ value.replaceAll("\\", "/").split("/").includes("..");
221
+
222
+ const normalizeWorkingSetPath = (value, repositoryRoot) => {
223
+ if (typeof value !== "string" || typeof repositoryRoot !== "string") return null;
224
+ const candidate = value.trim();
225
+ if (
226
+ !candidate
227
+ || candidate.length > MAX_WORKING_SET_PATH_CHARS
228
+ || hasTraversalSegment(candidate)
229
+ ) {
230
+ return null;
231
+ }
232
+
233
+ const root = repositoryRoot.trim();
234
+ const candidateIsAbsolute = posix.isAbsolute(candidate) || win32.isAbsolute(candidate);
235
+ if (candidateIsAbsolute) {
236
+ if (!root || hasTraversalSegment(root)) return null;
237
+ const rootUsesWindows = windowsPathFlavor(root);
238
+ const candidateUsesWindows = windowsPathFlavor(candidate, rootUsesWindows);
239
+ if (candidateUsesWindows !== rootUsesWindows) return null;
240
+ const pathApi = candidateUsesWindows ? win32 : posix;
241
+ if (!pathApi.isAbsolute(root) || !pathApi.isAbsolute(candidate)) return null;
242
+ const relative = pathApi.relative(pathApi.normalize(root), pathApi.normalize(candidate));
243
+ if (
244
+ !relative
245
+ || relative === ".."
246
+ || relative.startsWith(`..${pathApi.sep}`)
247
+ || pathApi.isAbsolute(relative)
248
+ ) {
249
+ return null;
250
+ }
251
+ const normalized = relative.replaceAll("\\", "/");
252
+ return normalized.length <= MAX_WORKING_SET_PATH_CHARS ? normalized : null;
253
+ }
254
+
255
+ if (/^[A-Za-z]:/.test(candidate)) return null;
256
+ const normalized = posix.normalize(candidate.replaceAll("\\", "/")).replace(/^\.\//, "");
257
+ if (
258
+ !normalized
259
+ || normalized === "."
260
+ || normalized === ".."
261
+ || normalized.startsWith("../")
262
+ || posix.isAbsolute(normalized)
263
+ || normalized.length > MAX_WORKING_SET_PATH_CHARS
264
+ ) {
265
+ return null;
266
+ }
267
+ return normalized;
268
+ };
269
+
270
+ const addWorkingSetPath = (paths, value, repositoryRoot) => {
271
+ const normalized = normalizeWorkingSetPath(value, repositoryRoot);
272
+ if (!normalized) return null;
273
+ const duplicate = windowsPathFlavor(repositoryRoot)
274
+ ? Array.from(paths).some((existing) => existing.toLowerCase() === normalized.toLowerCase())
275
+ : paths.has(normalized);
276
+ if (!duplicate) paths.add(normalized);
277
+ return normalized;
278
+ };
279
+
164
280
  const appendWorkingSetFileArgs = (args, workingSetFiles) => {
165
281
  if (!Array.isArray(workingSetFiles) || workingSetFiles.length === 0) {
166
282
  return args;
167
283
  }
168
284
  for (const file of workingSetFiles) {
169
285
  const normalized = String(file || "").trim();
170
- if (!normalized) {
286
+ if (!normalized || normalized.length > MAX_WORKING_SET_PATH_CHARS) {
171
287
  continue;
172
288
  }
173
- args.push("--working-set-file", normalized.slice(0, 400));
289
+ args.push("--working-set-file", normalized);
174
290
  }
175
291
  return args;
176
292
  };
@@ -212,12 +328,15 @@ const buildInjectQuery = ({ firstPrompt, lastPromptText, projectName, filesModif
212
328
  return query.length > 500 ? query.slice(0, 500) : query;
213
329
  };
214
330
 
215
- const buildPackArgs = ({ query, filesModified, injectLimit, injectTokenBudget }) => {
331
+ const buildPackArgs = ({ query, filesModified, injectLimit, injectTokenBudget, internalLedger = false }) => {
216
332
  const workingSetFiles = Array.from(filesModified || [])
217
333
  .slice(-8)
218
334
  .map((value) => String(value || "").trim())
219
335
  .filter(Boolean);
220
336
  const args = ["pack", query, "--json"];
337
+ if (internalLedger) {
338
+ args.push("--internal-ledger");
339
+ }
221
340
  if (injectLimit !== null && Number.isFinite(injectLimit) && injectLimit > 0) {
222
341
  args.push("--limit", String(injectLimit));
223
342
  }
@@ -255,6 +374,63 @@ const parsePackMetrics = (stdout) => {
255
374
  }
256
375
  };
257
376
 
377
+ const parsePackOutput = (result) => {
378
+ const succeeded = result?.exitCode === 0;
379
+ let payload = null;
380
+ if (succeeded) {
381
+ try {
382
+ payload = JSON.parse(result.stdout);
383
+ } catch {
384
+ payload = null;
385
+ }
386
+ }
387
+ const ledgerConflict = payload?.ledger_outcome?.ok === false
388
+ && payload.ledger_outcome.errorCode === "retrieval_ledger_write_failed"
389
+ && payload.ledger_outcome.reason === "idempotency_conflict";
390
+ const metrics = succeeded ? payload?.metrics || null : null;
391
+ const itemCount = Number.isFinite(Number(metrics?.total_items))
392
+ ? Number(metrics.total_items)
393
+ : null;
394
+ const candidatePackText = succeeded && itemCount !== 0
395
+ ? String(payload?.pack_text || "").trim()
396
+ : "";
397
+ return {
398
+ conflictPackText: ledgerConflict ? candidatePackText : "",
399
+ ledgerConflict,
400
+ metrics,
401
+ itemCount,
402
+ // The real builder renders section headings even when it selected no
403
+ // memories. Treat the structured count as authoritative so headings alone
404
+ // are never handed to the model as retrieved context.
405
+ packText: !ledgerConflict ? candidatePackText : "",
406
+ };
407
+ };
408
+
409
+ const canonicalJson = (value) => {
410
+ if (Array.isArray(value)) {
411
+ return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
412
+ }
413
+ if (value && typeof value === "object") {
414
+ return `{${Object.keys(value)
415
+ .sort()
416
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
417
+ .join(",")}}`;
418
+ }
419
+ return JSON.stringify(value);
420
+ };
421
+
422
+ const promptPackArtifactFingerprint = (stdout, packText) => {
423
+ try {
424
+ const payload = JSON.parse(stdout);
425
+ if (/^[0-9a-f]{64}$/i.test(payload?.ledger_artifact_fingerprint || "")) {
426
+ return payload.ledger_artifact_fingerprint.toLowerCase();
427
+ }
428
+ return createHash("sha256").update(canonicalJson(payload)).digest("hex");
429
+ } catch {
430
+ return createHash("sha256").update(packText).digest("hex");
431
+ }
432
+ };
433
+
258
434
  const applyInjectedContextToOutput = async ({
259
435
  injectEnabled,
260
436
  input,
@@ -263,8 +439,11 @@ const applyInjectedContextToOutput = async ({
263
439
  showToast,
264
440
  resolveInjectQuery,
265
441
  buildInjectedContext,
442
+ confirmDelivery,
443
+ recordSkipped,
266
444
  }) => {
267
445
  if (!injectEnabled) {
446
+ recordSkipped?.("injection_disabled", input?.sessionID || null);
268
447
  return false;
269
448
  }
270
449
 
@@ -277,7 +456,12 @@ const applyInjectedContextToOutput = async ({
277
456
  // O(1) hit when the cache key isn't tied to the prompt that produced
278
457
  // the pack.
279
458
  const query = resolveInjectQuery();
280
- const injected = await buildInjectedContext(query);
459
+ const sessionID = input?.sessionID || null;
460
+ const injected = await buildInjectedContext(query, {
461
+ sessionID,
462
+ requestKey: `system:${sessionID || "unknown"}`,
463
+ surface: "system",
464
+ });
281
465
  if (!injected?.text) {
282
466
  return false;
283
467
  }
@@ -291,10 +475,20 @@ const applyInjectedContextToOutput = async ({
291
475
  }
292
476
  }
293
477
 
294
- if (!Array.isArray(output.system)) {
295
- output.system = [];
478
+ try {
479
+ if (!Array.isArray(output.system)) {
480
+ output.system = [];
481
+ }
482
+ output.system.push(injected.text);
483
+ } catch (error) {
484
+ if (injected.attemptId) {
485
+ confirmDelivery?.(injected.attemptId, "failed");
486
+ }
487
+ throw error;
488
+ }
489
+ if (injected.attemptId) {
490
+ confirmDelivery?.(injected.attemptId);
296
491
  }
297
- output.system.push(injected.text);
298
492
  return true;
299
493
  };
300
494
 
@@ -428,7 +622,7 @@ const normalizeInjectedMessageParts = (messages, sessionCache) => {
428
622
  }
429
623
  }
430
624
  if (existingText && sessionCache && messageId && !sessionCache.has(messageId)) {
431
- sessionCache.set(messageId, existingText);
625
+ sessionCache.set(messageId, { text: existingText, attemptId: null, reconstructed: true });
432
626
  trimSessionMessageInjectionCache(sessionCache);
433
627
  }
434
628
  if (retainedParts.length !== entry.parts.length) {
@@ -450,7 +644,12 @@ const buildInjectedContextPart = (entry, index, text, options = {}) => {
450
644
  };
451
645
  };
452
646
 
453
- const appendCachedInjectedContextParts = (messages, sessionCache, sessionID = null) => {
647
+ const appendCachedInjectedContextParts = async (
648
+ messages,
649
+ sessionCache,
650
+ sessionID = null,
651
+ options = {},
652
+ ) => {
454
653
  let appended = 0;
455
654
  for (let index = 0; index < messages.length; index += 1) {
456
655
  const entry = messages[index];
@@ -461,11 +660,49 @@ const appendCachedInjectedContextParts = (messages, sessionCache, sessionID = nu
461
660
  if (!messageId) {
462
661
  continue;
463
662
  }
464
- const text = sessionCache.get(messageId);
663
+ const cached = sessionCache.get(messageId);
664
+ const text = typeof cached === "string" ? cached : cached?.text;
465
665
  if (!text) {
466
666
  continue;
467
667
  }
468
- entry.parts.push(buildInjectedContextPart(entry, index, text, { messageId, sessionID }));
668
+ let attemptId = cached?.attemptId || null;
669
+ let cacheReuseReady = null;
670
+ if (
671
+ messageId === options.latestMessageId
672
+ && !options.latestWasBuilt
673
+ && attemptId
674
+ && options.recordCacheReuse
675
+ ) {
676
+ const reuse = options.recordCacheReuse(cached, {
677
+ messageId,
678
+ sessionID,
679
+ });
680
+ attemptId = typeof reuse === "string" ? reuse : reuse?.attemptId || null;
681
+ cacheReuseReady = typeof reuse === "object" ? reuse?.ready : null;
682
+ }
683
+ try {
684
+ entry.parts.push(buildInjectedContextPart(entry, index, text, { messageId, sessionID }));
685
+ } catch (error) {
686
+ if (messageId === options.latestMessageId && attemptId && options.confirmDelivery) {
687
+ if (cacheReuseReady) {
688
+ void Promise.resolve(cacheReuseReady)
689
+ .then(() => options.confirmDelivery(attemptId, "failed"))
690
+ .catch(() => {});
691
+ } else {
692
+ options.confirmDelivery(attemptId, "failed");
693
+ }
694
+ }
695
+ throw error;
696
+ }
697
+ if (messageId === options.latestMessageId && attemptId && options.confirmDelivery) {
698
+ if (cacheReuseReady) {
699
+ void Promise.resolve(cacheReuseReady)
700
+ .then(() => options.confirmDelivery(attemptId))
701
+ .catch(() => {});
702
+ } else {
703
+ options.confirmDelivery(attemptId);
704
+ }
705
+ }
469
706
  appended += 1;
470
707
  }
471
708
  return appended;
@@ -481,18 +718,26 @@ const applyInjectedContextToMessages = async ({
481
718
  buildInjectedContext,
482
719
  messageInjectionCache,
483
720
  compactionInjectionSkips,
721
+ confirmDelivery,
722
+ recordCacheReuse,
723
+ recordSkipped,
484
724
  }) => {
485
- if (!injectEnabled || !Array.isArray(output?.messages)) {
486
- return false;
487
- }
488
-
489
- const latestUser = findLatestUserMessage(output.messages);
725
+ const hasMessages = Array.isArray(output?.messages);
726
+ const latestUser = hasMessages ? findLatestUserMessage(output.messages) : null;
490
727
  const sessionID = latestUser
491
728
  ? resolveEntrySessionID(latestUser.entry) || input?.sessionID || null
492
729
  : input?.sessionID || null;
730
+ if (!injectEnabled) {
731
+ recordSkipped?.("injection_disabled", sessionID);
732
+ return false;
733
+ }
734
+ if (!hasMessages) {
735
+ return false;
736
+ }
493
737
 
494
738
  if (consumeCompactionInjectionSkip(compactionInjectionSkips, sessionID)) {
495
739
  normalizeInjectedMessageParts(output.messages, null);
740
+ recordSkipped?.("compaction_skipped", sessionID);
496
741
  return false;
497
742
  }
498
743
 
@@ -505,24 +750,49 @@ const applyInjectedContextToMessages = async ({
505
750
 
506
751
  const latestMessageId = resolveEntryMessageId(latestUser.entry);
507
752
  const canReplay = Boolean(sessionCache && latestMessageId);
508
- if (!canReplay || !sessionCache.has(latestMessageId)) {
753
+ const latestCached = canReplay ? sessionCache.get(latestMessageId) : null;
754
+ const latestWasCached = Boolean(latestCached) && latestCached?.reconstructed !== true;
755
+ let latestWasBuilt = false;
756
+ if (!latestWasCached) {
509
757
  const firstUser = findFirstUserMessage(output.messages);
510
758
  const query = resolveInjectQuery({
511
759
  firstPrompt: firstUser ? extractMessageText(firstUser.entry) : null,
512
760
  lastPromptText: extractMessageText(latestUser.entry),
513
761
  });
514
- const injected = await buildInjectedContext(query);
762
+ const injected = await buildInjectedContext(query, {
763
+ sessionID,
764
+ requestKey: latestMessageId || fallbackEntryMessageId(latestUser.entry, latestUser.index),
765
+ surface: "message",
766
+ });
515
767
  if (injected?.text) {
768
+ latestWasBuilt = true;
516
769
  if (canReplay) {
517
- sessionCache.set(latestMessageId, injected.text);
770
+ sessionCache.set(latestMessageId, {
771
+ text: injected.text,
772
+ attemptId: injected.attemptId || null,
773
+ requestId: injected.requestId || null,
774
+ queryHash: injected.queryHash || null,
775
+ promptNumber: injected.promptNumber || 0,
776
+ reuseCount: 0,
777
+ });
518
778
  trimSessionMessageInjectionCache(sessionCache);
519
779
  } 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
- );
780
+ try {
781
+ latestUser.entry.parts.push(
782
+ buildInjectedContextPart(latestUser.entry, latestUser.index, injected.text, {
783
+ messageId: latestMessageId || undefined,
784
+ sessionID: sessionID || undefined,
785
+ })
786
+ );
787
+ } catch (error) {
788
+ if (injected.attemptId) {
789
+ confirmDelivery?.(injected.attemptId, "failed");
790
+ }
791
+ throw error;
792
+ }
793
+ if (injected.attemptId) {
794
+ confirmDelivery?.(injected.attemptId);
795
+ }
526
796
  }
527
797
 
528
798
  const toastKey = sessionID || latestMessageId || "unknown";
@@ -534,6 +804,8 @@ const applyInjectedContextToMessages = async ({
534
804
  // best-effort only
535
805
  }
536
806
  }
807
+ } else if (canReplay && latestCached?.reconstructed === true) {
808
+ sessionCache.delete(latestMessageId);
537
809
  }
538
810
  }
539
811
 
@@ -544,7 +816,12 @@ const applyInjectedContextToMessages = async ({
544
816
  );
545
817
  }
546
818
 
547
- const appended = appendCachedInjectedContextParts(output.messages, sessionCache, sessionID);
819
+ const appended = await appendCachedInjectedContextParts(output.messages, sessionCache, sessionID, {
820
+ latestMessageId,
821
+ latestWasBuilt,
822
+ confirmDelivery,
823
+ recordCacheReuse,
824
+ });
548
825
  return appended > 0;
549
826
  };
550
827
 
@@ -1036,10 +1313,15 @@ export const OpencodeMemPlugin = async ({
1036
1313
  const injectionToastShown = new Set();
1037
1314
  const messageInjectionCache = new Map();
1038
1315
  const compactionInjectionSkips = new Map();
1316
+ const disabledInjectionRecorded = new Set();
1317
+ const attemptStartedAt = new Map();
1318
+ const promptPackRetryCounts = new Map();
1319
+ const successfulPromptPackArtifacts = new Map();
1039
1320
  let sessionStartedAt = null;
1040
1321
  let activeSessionID = null;
1041
1322
  let viewerStarted = false;
1042
1323
  let promptCounter = 0;
1324
+ let skippedAttemptCounter = 0;
1043
1325
  let lastPromptText = null;
1044
1326
  let lastAssistantText = null;
1045
1327
  const assistantUsageCaptured = new Set();
@@ -1628,6 +1910,93 @@ export const OpencodeMemPlugin = async ({
1628
1910
  const runCli = async (args, options = {}) =>
1629
1911
  runCommand([runner, ...runnerArgs, ...args], options);
1630
1912
 
1913
+ const attemptMetadata = (identity, sessionID = null, promptNumber = promptCounter) => ({
1914
+ attempt_id: identity.attemptId,
1915
+ started_at: (() => {
1916
+ const existing = attemptStartedAt.get(identity.attemptId);
1917
+ if (existing) return existing;
1918
+ const created = new Date().toISOString();
1919
+ attemptStartedAt.set(identity.attemptId, created);
1920
+ while (attemptStartedAt.size > 2000) {
1921
+ const oldest = attemptStartedAt.keys().next().value;
1922
+ if (!oldest) break;
1923
+ attemptStartedAt.delete(oldest);
1924
+ }
1925
+ return created;
1926
+ })(),
1927
+ source: "opencode",
1928
+ ...(sessionID ? { stream_id: String(sessionID), source_session_id: String(sessionID) } : {}),
1929
+ ...(promptNumber > 0 ? { prompt_number: promptNumber } : {}),
1930
+ request_id: identity.requestId,
1931
+ });
1932
+
1933
+ const runPromptPackLedger = async (payload) => {
1934
+ try {
1935
+ return await runCli(["prompt-pack-ledger"], {
1936
+ stdinText: JSON.stringify(payload),
1937
+ });
1938
+ } catch {
1939
+ return null;
1940
+ }
1941
+ };
1942
+
1943
+ const skippedIdentity = (failureCode, sessionID, surface, eventKey) =>
1944
+ promptPackIdentity({
1945
+ sessionID: sessionID || "unknown",
1946
+ requestKey: `${failureCode}:${eventKey}`,
1947
+ surface,
1948
+ promptNumber: promptCounter,
1949
+ queryHash: hashPromptPackQuery(""),
1950
+ });
1951
+
1952
+ const recordSkippedPromptPack = (failureCode, sessionID = null, surface = injectSurface) => {
1953
+ const sessionKey = String(sessionID || "unknown");
1954
+ const memoKey = `${surface}:${sessionKey}`;
1955
+ if (failureCode === "injection_disabled" && disabledInjectionRecorded.has(memoKey)) {
1956
+ return null;
1957
+ }
1958
+ if (failureCode === "injection_disabled") disabledInjectionRecorded.add(memoKey);
1959
+ const identity = skippedIdentity(
1960
+ failureCode,
1961
+ sessionID,
1962
+ surface,
1963
+ failureCode === "injection_disabled" ? "once" : `event-${++skippedAttemptCounter}`,
1964
+ );
1965
+ void runPromptPackLedger({
1966
+ action: "record",
1967
+ ...attemptMetadata(identity, sessionID),
1968
+ retrieval_status: "skipped",
1969
+ failure_code: failureCode,
1970
+ failure_stage: "policy",
1971
+ });
1972
+ return identity.attemptId;
1973
+ };
1974
+
1975
+ const recordCachedPromptPack = (cached, { messageId, sessionID } = {}) => {
1976
+ cached.reuseCount = (cached.reuseCount || 0) + 1;
1977
+ const identity = promptPackIdentity({
1978
+ sessionID: sessionID || "unknown",
1979
+ requestKey: `${messageId || "unknown"}:cache:${cached.reuseCount}`,
1980
+ surface: "message",
1981
+ promptNumber: cached.promptNumber || promptCounter,
1982
+ queryHash: cached.queryHash || hashPromptPackQuery(""),
1983
+ });
1984
+ const ready = runPromptPackLedger({
1985
+ action: "cache_reuse",
1986
+ ...attemptMetadata(identity, sessionID, cached.promptNumber || promptCounter),
1987
+ original_attempt_id: cached.attemptId,
1988
+ });
1989
+ return { attemptId: identity.attemptId, ready };
1990
+ };
1991
+
1992
+ const confirmPromptPackDelivery = (attemptId, deliveryStatus = "handed_off") => {
1993
+ void runPromptPackLedger({
1994
+ action: "delivery",
1995
+ attempt_id: attemptId,
1996
+ delivery_status: deliveryStatus,
1997
+ });
1998
+ };
1999
+
1631
2000
  const showToast = async (message, variant = "warning") => {
1632
2001
  if (backendUpdatePolicy === "off") {
1633
2002
  return;
@@ -1827,28 +2196,300 @@ export const OpencodeMemPlugin = async ({
1827
2196
  return masked.length > limit ? `${masked.slice(0, limit)}…` : masked;
1828
2197
  };
1829
2198
 
1830
- const buildInjectedContext = async (query) => {
1831
- const packArgs = buildPackArgs({
1832
- query,
1833
- filesModified: sessionContext.filesModified,
1834
- injectLimit,
1835
- injectTokenBudget,
2199
+ const advancePromptPackRetryIdentity = (attemptKey) => {
2200
+ promptPackRetryCounts.set(
2201
+ attemptKey,
2202
+ (promptPackRetryCounts.get(attemptKey) || 0) + 1
2203
+ );
2204
+ while (promptPackRetryCounts.size > 2000) {
2205
+ const oldest = promptPackRetryCounts.keys().next().value;
2206
+ if (!oldest) break;
2207
+ promptPackRetryCounts.delete(oldest);
2208
+ }
2209
+ };
2210
+
2211
+ const rememberSuccessfulPromptPackArtifact = (
2212
+ attemptKey,
2213
+ retryCount,
2214
+ fingerprint
2215
+ ) => {
2216
+ successfulPromptPackArtifacts.delete(attemptKey);
2217
+ successfulPromptPackArtifacts.set(attemptKey, {
2218
+ retryCount,
2219
+ fingerprint,
2220
+ });
2221
+ while (successfulPromptPackArtifacts.size > 2000) {
2222
+ const oldest = successfulPromptPackArtifacts.keys().next().value;
2223
+ if (!oldest) break;
2224
+ successfulPromptPackArtifacts.delete(oldest);
2225
+ }
2226
+ };
2227
+
2228
+ const buildInjectedContext = async (query, context = {}) => {
2229
+ const queryHash = hashPromptPackQuery(query);
2230
+ const sessionID = context.sessionID || activeSessionID || "unknown";
2231
+ const surface = context.surface || injectSurface;
2232
+ const requestKey = context.requestKey || "unknown";
2233
+ const attemptKey = JSON.stringify([
2234
+ sessionID,
2235
+ requestKey,
2236
+ surface,
2237
+ promptCounter,
2238
+ queryHash,
2239
+ ]);
2240
+ let retryCount = promptPackRetryCounts.get(attemptKey) || 0;
2241
+ const resolveIdentity = () => promptPackIdentity({
2242
+ sessionID,
2243
+ requestKey: retryCount > 0
2244
+ ? `${requestKey}:empty-retry:${retryCount}`
2245
+ : requestKey,
2246
+ surface,
2247
+ promptNumber: promptCounter,
2248
+ queryHash,
1836
2249
  });
1837
- const result = await runCli(packArgs);
2250
+ let identity = resolveIdentity();
2251
+ let metadata = attemptMetadata(
2252
+ identity,
2253
+ context.sessionID || activeSessionID || null,
2254
+ );
2255
+ const runPack = async () => {
2256
+ let packArgs = buildPackArgs({
2257
+ query,
2258
+ filesModified: sessionContext.filesModified,
2259
+ injectLimit,
2260
+ injectTokenBudget,
2261
+ internalLedger: true,
2262
+ });
2263
+ let result = await runCli(packArgs, { stdinText: JSON.stringify(metadata) });
2264
+ if (rejectsInternalLedgerFlag(result)) {
2265
+ packArgs = packArgs.filter((arg) => arg !== "--internal-ledger");
2266
+ result = await runCli(packArgs);
2267
+ }
2268
+ return { packArgs, result };
2269
+ };
2270
+ let { packArgs, result } = await runPack();
2271
+ let { packText, conflictPackText, metrics, itemCount, ledgerConflict } = parsePackOutput(result);
2272
+ let artifactFingerprint = packText
2273
+ ? promptPackArtifactFingerprint(result.stdout, packText)
2274
+ : "";
2275
+ let injectedIdentity = identity;
2276
+ let repairFallbackUsed = false;
2277
+ if (ledgerConflict) {
2278
+ // A restarted plugin has no artifact cache to predict this conflict. The
2279
+ // CLI marker is authoritative: never attribute delivery to the stale
2280
+ // identity, and retry once with a fresh deterministic identity.
2281
+ await log("warn", "codemem prompt-pack ledger conflict; retrying with fresh identity", {
2282
+ sessionID,
2283
+ surface,
2284
+ });
2285
+ const fallback = conflictPackText
2286
+ ? {
2287
+ packArgs,
2288
+ result,
2289
+ packText: conflictPackText,
2290
+ metrics,
2291
+ itemCount,
2292
+ artifactFingerprint: promptPackArtifactFingerprint(result.stdout, conflictPackText),
2293
+ ledgerConflict,
2294
+ }
2295
+ : null;
2296
+ advancePromptPackRetryIdentity(attemptKey);
2297
+ retryCount = promptPackRetryCounts.get(attemptKey) || 0;
2298
+ identity = resolveIdentity();
2299
+ metadata = attemptMetadata(
2300
+ identity,
2301
+ context.sessionID || activeSessionID || null,
2302
+ );
2303
+ ({ packArgs, result } = await runPack());
2304
+ // Repair-conflict bytes are never preferred over the original preserved pack.
2305
+ ({ packText, metrics, itemCount, ledgerConflict } = parsePackOutput(result));
2306
+ artifactFingerprint = packText
2307
+ ? promptPackArtifactFingerprint(result.stdout, packText)
2308
+ : "";
2309
+ const repairFailed = ledgerConflict
2310
+ || !result
2311
+ || result.exitCode !== 0
2312
+ || (!packText && itemCount !== 0);
2313
+ if (fallback && repairFailed) {
2314
+ if (ledgerConflict) {
2315
+ await log("warn", "codemem prompt-pack fresh identity also conflicted", {
2316
+ sessionID,
2317
+ surface,
2318
+ });
2319
+ } else {
2320
+ const malformedSuccess = result?.exitCode === 0;
2321
+ const exitCode = result?.exitCode ?? "unknown";
2322
+ const stderr = redactLog(result?.stderr ? result.stderr.trim() : "");
2323
+ const cmd = redactPackCommand(runner, runnerArgs, packArgs);
2324
+ await logLine(
2325
+ `inject.pack.identity_repair_failed reason=${malformedSuccess ? "malformed_success" : "command_failed"} exit=${exitCode} cmd=${cmd}` +
2326
+ `${stderr ? ` stderr=${stderr}` : ""}`
2327
+ );
2328
+ await log("warn", "codemem prompt-pack identity repair failed", {
2329
+ reason: malformedSuccess ? "malformed_success" : "command_failed",
2330
+ exitCode,
2331
+ });
2332
+ void runPromptPackLedger({
2333
+ action: "record",
2334
+ ...metadata,
2335
+ retrieval_status: "failed",
2336
+ failure_code: malformedSuccess
2337
+ ? "pack_identity_repair_failed"
2338
+ : "pack_command_failed",
2339
+ failure_stage: malformedSuccess ? "decode" : "transport",
2340
+ });
2341
+ }
2342
+ advancePromptPackRetryIdentity(attemptKey);
2343
+ retryCount = promptPackRetryCounts.get(attemptKey) || 0;
2344
+ ({
2345
+ packArgs,
2346
+ result,
2347
+ packText,
2348
+ metrics,
2349
+ itemCount,
2350
+ artifactFingerprint,
2351
+ ledgerConflict,
2352
+ } = fallback);
2353
+ // Neither persisted identity represents fallback delivery: the first
2354
+ // conflicted and the fresh repair failed. Keep the bytes fail-open but
2355
+ // leave delivery and replay attribution empty.
2356
+ injectedIdentity = null;
2357
+ repairFallbackUsed = true;
2358
+ } else {
2359
+ injectedIdentity = identity;
2360
+ }
2361
+ }
2362
+ if (packText) {
2363
+ const previous = successfulPromptPackArtifacts.get(attemptKey);
2364
+ if (
2365
+ previous?.retryCount === retryCount
2366
+ && previous.fingerprint !== artifactFingerprint
2367
+ ) {
2368
+ // The in-memory fingerprint detected a changed artifact before handoff.
2369
+ // Defensively rebuild once with a fresh identity; older CLIs do not
2370
+ // expose the persisted conflict marker used by the restart path above.
2371
+ // Keep the usable changed bytes as a fail-open fallback: diagnostics
2372
+ // identity repair must never suppress context injection.
2373
+ const fallback = {
2374
+ packArgs,
2375
+ result,
2376
+ packText,
2377
+ metrics,
2378
+ itemCount,
2379
+ artifactFingerprint,
2380
+ ledgerConflict,
2381
+ };
2382
+ advancePromptPackRetryIdentity(attemptKey);
2383
+ retryCount = promptPackRetryCounts.get(attemptKey) || 0;
2384
+ identity = resolveIdentity();
2385
+ metadata = attemptMetadata(
2386
+ identity,
2387
+ context.sessionID || activeSessionID || null,
2388
+ );
2389
+ ({ packArgs, result } = await runPack());
2390
+ ({ packText, metrics, itemCount, ledgerConflict } = parsePackOutput(result));
2391
+ artifactFingerprint = packText
2392
+ ? promptPackArtifactFingerprint(result.stdout, packText)
2393
+ : "";
2394
+
2395
+ if (ledgerConflict) {
2396
+ // The repair identity is also persisted with different artifacts.
2397
+ // Preserve the marker for the fail-closed return below; never use the
2398
+ // stale changed bytes as the transport-failure fallback.
2399
+ injectedIdentity = identity;
2400
+ } else if (result?.exitCode === 0 && itemCount === 0) {
2401
+ // A fresh identity can legitimately resolve to no results if the
2402
+ // underlying memory set changed between rebuilds. The CLI already
2403
+ // recorded that terminal outcome; do not misclassify it as a decode
2404
+ // failure or fall back to stale non-empty context.
2405
+ injectedIdentity = identity;
2406
+ } else if (!result || result.exitCode !== 0 || !packText) {
2407
+ const malformedSuccess = result?.exitCode === 0;
2408
+ const exitCode = result?.exitCode ?? "unknown";
2409
+ const stderr = redactLog(result?.stderr ? result.stderr.trim() : "");
2410
+ const cmd = redactPackCommand(runner, runnerArgs, packArgs);
2411
+ await logLine(
2412
+ `inject.pack.identity_repair_failed reason=${malformedSuccess ? "malformed_success" : "command_failed"} exit=${exitCode} cmd=${cmd}` +
2413
+ `${stderr ? ` stderr=${stderr}` : ""}`
2414
+ );
2415
+ await log("warn", "codemem prompt-pack identity repair failed", {
2416
+ reason: malformedSuccess ? "malformed_success" : "command_failed",
2417
+ exitCode,
2418
+ });
2419
+ void runPromptPackLedger({
2420
+ action: "record",
2421
+ ...metadata,
2422
+ retrieval_status: "failed",
2423
+ failure_code: malformedSuccess
2424
+ ? "pack_identity_repair_failed"
2425
+ : "pack_command_failed",
2426
+ failure_stage: malformedSuccess ? "decode" : "transport",
2427
+ });
2428
+ advancePromptPackRetryIdentity(attemptKey);
2429
+
2430
+ ({
2431
+ packArgs,
2432
+ result,
2433
+ packText,
2434
+ metrics,
2435
+ itemCount,
2436
+ artifactFingerprint,
2437
+ ledgerConflict,
2438
+ } = fallback);
2439
+ // The stale attempt does not represent these changed bytes, and the
2440
+ // fresh repair attempt failed. Inject without delivery attribution
2441
+ // rather than falsely marking either ledger attempt handed off.
2442
+ injectedIdentity = null;
2443
+ repairFallbackUsed = true;
2444
+ } else {
2445
+ injectedIdentity = identity;
2446
+ }
2447
+ }
2448
+ }
1838
2449
  if (!result || result.exitCode !== 0) {
1839
2450
  const exitCode = result?.exitCode ?? "unknown";
1840
2451
  const stderr = redactLog(result?.stderr ? result.stderr.trim() : "");
1841
2452
  const stdout = redactLog(result?.stdout ? result.stdout.trim() : "");
1842
- const cmd = [runner, ...runnerArgs, ...packArgs].join(" ");
2453
+ const cmd = redactPackCommand(runner, runnerArgs, packArgs);
1843
2454
  await logLine(
1844
2455
  `inject.pack.error ${exitCode} cmd=${cmd}` +
1845
2456
  `${stderr ? ` stderr=${stderr}` : ""}` +
1846
2457
  `${stdout ? ` stdout=${stdout}` : ""}`
1847
2458
  );
1848
- return "";
2459
+ void runPromptPackLedger({
2460
+ action: "record",
2461
+ ...metadata,
2462
+ retrieval_status: "failed",
2463
+ failure_code: "pack_command_failed",
2464
+ failure_stage: "transport",
2465
+ });
2466
+ advancePromptPackRetryIdentity(attemptKey);
2467
+ return {
2468
+ text: "",
2469
+ attemptId: identity.attemptId,
2470
+ requestId: identity.requestId,
2471
+ queryHash,
2472
+ promptNumber: promptCounter,
2473
+ };
2474
+ }
2475
+ if (ledgerConflict && !repairFallbackUsed) {
2476
+ await log("warn", "codemem prompt-pack fresh identity also conflicted", {
2477
+ sessionID,
2478
+ surface,
2479
+ });
2480
+ advancePromptPackRetryIdentity(attemptKey);
2481
+ return {
2482
+ text: "",
2483
+ attemptId: identity.attemptId,
2484
+ requestId: identity.requestId,
2485
+ queryHash,
2486
+ promptNumber: promptCounter,
2487
+ };
1849
2488
  }
1850
- const packText = parsePackText(result.stdout);
1851
2489
  if (!packText) {
2490
+ if (itemCount === 0) {
2491
+ advancePromptPackRetryIdentity(attemptKey);
2492
+ }
1852
2493
  if (debug) {
1853
2494
  const { safeQuery, firstPromptLen, lastPromptLen, projectName, filesModifiedCount } =
1854
2495
  describeInjectQuery(query);
@@ -1856,27 +2497,46 @@ export const OpencodeMemPlugin = async ({
1856
2497
  `inject.pack.empty query_len=${query ? query.length : 0} query=${JSON.stringify(safeQuery)} first_prompt_len=${firstPromptLen} last_prompt_len=${lastPromptLen} project=${JSON.stringify(projectName)} files_modified=${filesModifiedCount} stdout=${JSON.stringify(redactLog((result.stdout || "").trim(), 240))}`
1857
2498
  );
1858
2499
  }
1859
- return "";
2500
+ return {
2501
+ text: "",
2502
+ attemptId: identity.attemptId,
2503
+ requestId: identity.requestId,
2504
+ queryHash,
2505
+ promptNumber: promptCounter,
2506
+ };
1860
2507
  }
1861
- const metrics = parsePackMetrics(result.stdout);
1862
2508
  // The pack JSON exposes the item count as `total_items`; `metrics.items`
1863
2509
  // does not exist on that payload, so reading it would always log 0.
1864
- const itemCount = Number.isFinite(Number(metrics?.total_items))
1865
- ? Number(metrics.total_items)
1866
- : 0;
1867
2510
  const packTokens = Number.isFinite(Number(metrics?.pack_tokens))
1868
2511
  ? Number(metrics.pack_tokens)
1869
2512
  : 0;
1870
2513
  await logLine(
1871
- `inject.pack.ok source=opencode items=${itemCount} pack_tokens=${packTokens} query_len=${query ? query.length : 0}`
2514
+ `inject.pack.ok source=opencode items=${itemCount ?? 0} pack_tokens=${packTokens} query_len=${query ? query.length : 0}`
1872
2515
  );
2516
+ if (!repairFallbackUsed) {
2517
+ rememberSuccessfulPromptPackArtifact(
2518
+ attemptKey,
2519
+ retryCount,
2520
+ artifactFingerprint || promptPackArtifactFingerprint(result.stdout, packText)
2521
+ );
2522
+ }
1873
2523
  if (metrics) {
1874
2524
  return {
1875
2525
  text: `[codemem context]\n${packText}`,
1876
2526
  metrics,
2527
+ attemptId: injectedIdentity?.attemptId || null,
2528
+ requestId: injectedIdentity?.requestId || null,
2529
+ queryHash,
2530
+ promptNumber: promptCounter,
1877
2531
  };
1878
2532
  }
1879
- return { text: `[codemem context]\n${packText}` };
2533
+ return {
2534
+ text: `[codemem context]\n${packText}`,
2535
+ attemptId: injectedIdentity?.attemptId || null,
2536
+ requestId: injectedIdentity?.requestId || null,
2537
+ queryHash,
2538
+ promptNumber: promptCounter,
2539
+ };
1880
2540
  };
1881
2541
 
1882
2542
  const stopViewer = async () => {
@@ -2160,6 +2820,9 @@ export const OpencodeMemPlugin = async ({
2160
2820
  buildInjectedContext,
2161
2821
  messageInjectionCache,
2162
2822
  compactionInjectionSkips,
2823
+ confirmDelivery: confirmPromptPackDelivery,
2824
+ recordCacheReuse: recordCachedPromptPack,
2825
+ recordSkipped: recordSkippedPromptPack,
2163
2826
  });
2164
2827
  } catch (err) {
2165
2828
  await logLine(
@@ -2184,6 +2847,7 @@ export const OpencodeMemPlugin = async ({
2184
2847
  }
2185
2848
  const hookSessionID = input?.sessionID || activeSessionID;
2186
2849
  if (consumeCompactionInjectionSkip(compactionInjectionSkips, hookSessionID)) {
2850
+ recordSkippedPromptPack("compaction_skipped", hookSessionID, "system");
2187
2851
  if (debug) {
2188
2852
  await logLine(
2189
2853
  `inject.transform.skip_compaction sessionID=${hookSessionID || "unknown"}`
@@ -2224,6 +2888,8 @@ export const OpencodeMemPlugin = async ({
2224
2888
  : null,
2225
2889
  resolveInjectQuery,
2226
2890
  buildInjectedContext,
2891
+ confirmDelivery: confirmPromptPackDelivery,
2892
+ recordSkipped: recordSkippedPromptPack,
2227
2893
  });
2228
2894
  } catch (err) {
2229
2895
  await logLine(
@@ -2408,6 +3074,9 @@ export const OpencodeMemPlugin = async ({
2408
3074
  activeSessionID = sessionID || null;
2409
3075
  sessionStartedAt = new Date().toISOString();
2410
3076
  promptCounter = 0;
3077
+ skippedAttemptCounter = 0;
3078
+ disabledInjectionRecorded.delete("message:unknown");
3079
+ disabledInjectionRecorded.delete("system:unknown");
2411
3080
  lastPromptText = null;
2412
3081
  lastAssistantText = null;
2413
3082
  resetSessionContext();
@@ -2419,6 +3088,8 @@ export const OpencodeMemPlugin = async ({
2419
3088
  injectionToastShown.delete(sessionID);
2420
3089
  messageInjectionCache.delete(sessionID);
2421
3090
  compactionInjectionSkips.delete(sessionID);
3091
+ disabledInjectionRecorded.delete(`message:${sessionID}`);
3092
+ disabledInjectionRecorded.delete(`system:${sessionID}`);
2422
3093
  }
2423
3094
  await stopViewer();
2424
3095
  }
@@ -2438,15 +3109,15 @@ export const OpencodeMemPlugin = async ({
2438
3109
  if (filePath) {
2439
3110
  const lowerTool = toolName.toLowerCase();
2440
3111
  if (lowerTool === "edit" || lowerTool === "write") {
2441
- sessionContext.filesModified.add(filePath);
3112
+ addWorkingSetPath(sessionContext.filesModified, filePath, cwd);
2442
3113
  } else if (lowerTool === "read") {
2443
- sessionContext.filesRead.add(filePath);
3114
+ addWorkingSetPath(sessionContext.filesRead, filePath, cwd);
2444
3115
  }
2445
3116
  }
2446
3117
  if (toolName.toLowerCase() === "apply_patch") {
2447
3118
  const patchPaths = extractApplyPatchPaths(args.patchText);
2448
3119
  for (const path of patchPaths) {
2449
- sessionContext.filesModified.add(path);
3120
+ addWorkingSetPath(sessionContext.filesModified, path, cwd);
2450
3121
  }
2451
3122
  }
2452
3123
 
@@ -2527,6 +3198,11 @@ export const __testUtils = {
2527
3198
  resolveProjectName,
2528
3199
  buildInjectQuery,
2529
3200
  buildPackArgs,
3201
+ deterministicUuid,
3202
+ promptPackIdentity,
3203
+ hashPromptPackQuery,
3204
+ redactPackCommand,
3205
+ rejectsInternalLedgerFlag,
2530
3206
  parsePackText,
2531
3207
  parsePackMetrics,
2532
3208
  resolveInjectSurface,
@@ -2537,6 +3213,8 @@ export const __testUtils = {
2537
3213
  buildRunnerArgs,
2538
3214
  appendWorkingSetFileArgs,
2539
3215
  extractApplyPatchPaths,
3216
+ normalizeWorkingSetPath,
3217
+ addWorkingSetPath,
2540
3218
  mapOpencodeEventTypeToAdapterType,
2541
3219
  buildOpencodeAdapterPayload,
2542
3220
  buildOpencodeAdapterEvent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemem/opencode-plugin",
3
- "version": "0.40.0-alpha.1",
3
+ "version": "0.40.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.18.4"
21
+ "@opencode-ai/plugin": "1.18.12"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",