@codemem/opencode-plugin 0.40.0-alpha.1 → 0.40.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.
@@ -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.1";
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,94 @@ 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
+
94
+ const parseFallbackStructuredError = (stdout) => {
95
+ const lines = String(stdout || "").trim().split(/\r?\n/).reverse();
96
+ for (const line of lines) {
97
+ try {
98
+ const parsed = JSON.parse(line);
99
+ if (parsed && typeof parsed === "object") {
100
+ return {
101
+ code: typeof parsed.error === "string" ? parsed.error : "",
102
+ message: typeof parsed.message === "string" ? parsed.message : "",
103
+ };
104
+ }
105
+ } catch {
106
+ // Continue past non-JSON CLI output.
107
+ }
108
+ }
109
+ return { code: "", message: "" };
110
+ };
111
+
112
+ const classifyFallbackCommandResult = (result) => {
113
+ const structured = parseFallbackStructuredError(result?.stdout);
114
+ const diagnostic = [structured.code, structured.message, result?.stderr, result?.stdout]
115
+ .filter(Boolean)
116
+ .join("\n");
117
+ if (/SQLITE_(?:BUSY|LOCKED)|database(?: table)? (?:is )?(?:busy|locked)/i.test(diagnostic)) {
118
+ return { retryable: true, cause: "SQLite database is locked" };
119
+ }
120
+ if (result?.exitCode == null && /(?:timeout|timed out|ETIMEDOUT)/i.test(diagnostic)) {
121
+ return { retryable: true, cause: "enqueue-raw-event command timeout" };
122
+ }
123
+ if (structured.code === "validation_error" || /invalid raw event|session id required/i.test(diagnostic)) {
124
+ return { retryable: false, cause: "enqueue-raw-event validation failed" };
125
+ }
126
+ if (/unknown command ['\"]?enqueue-raw-event|command not found|\bENOENT\b/i.test(diagnostic)) {
127
+ return { retryable: false, cause: "enqueue-raw-event command unavailable" };
128
+ }
129
+ const exitCode = result?.exitCode ?? "unknown";
130
+ return { retryable: false, cause: `enqueue-raw-event failed (${exitCode})` };
131
+ };
132
+
44
133
  const DEFAULT_LOG_PATH = (homeDir, cwd) => `${homeDir || cwd}/.codemem/plugin.log`;
45
134
 
46
135
  const resolveLogPath = (logPathEnvRaw, cwd, homeDir) => {
@@ -161,16 +250,82 @@ const extractApplyPatchPaths = (patchText) => {
161
250
  return paths;
162
251
  };
163
252
 
253
+ const windowsPathFlavor = (value, doubleSlashIsUnc = true) =>
254
+ /^[A-Za-z]:[\\/]/.test(value)
255
+ || /^\\\\/.test(value)
256
+ || (doubleSlashIsUnc && /^\/\/[^/]+[\\/][^/]+/.test(value));
257
+
258
+ const hasTraversalSegment = (value) =>
259
+ value.replaceAll("\\", "/").split("/").includes("..");
260
+
261
+ const normalizeWorkingSetPath = (value, repositoryRoot) => {
262
+ if (typeof value !== "string" || typeof repositoryRoot !== "string") return null;
263
+ const candidate = value.trim();
264
+ if (
265
+ !candidate
266
+ || candidate.length > MAX_WORKING_SET_PATH_CHARS
267
+ || hasTraversalSegment(candidate)
268
+ ) {
269
+ return null;
270
+ }
271
+
272
+ const root = repositoryRoot.trim();
273
+ const candidateIsAbsolute = posix.isAbsolute(candidate) || win32.isAbsolute(candidate);
274
+ if (candidateIsAbsolute) {
275
+ if (!root || hasTraversalSegment(root)) return null;
276
+ const rootUsesWindows = windowsPathFlavor(root);
277
+ const candidateUsesWindows = windowsPathFlavor(candidate, rootUsesWindows);
278
+ if (candidateUsesWindows !== rootUsesWindows) return null;
279
+ const pathApi = candidateUsesWindows ? win32 : posix;
280
+ if (!pathApi.isAbsolute(root) || !pathApi.isAbsolute(candidate)) return null;
281
+ const relative = pathApi.relative(pathApi.normalize(root), pathApi.normalize(candidate));
282
+ if (
283
+ !relative
284
+ || relative === ".."
285
+ || relative.startsWith(`..${pathApi.sep}`)
286
+ || pathApi.isAbsolute(relative)
287
+ ) {
288
+ return null;
289
+ }
290
+ const normalized = relative.replaceAll("\\", "/");
291
+ return normalized.length <= MAX_WORKING_SET_PATH_CHARS ? normalized : null;
292
+ }
293
+
294
+ if (/^[A-Za-z]:/.test(candidate)) return null;
295
+ const normalized = posix.normalize(candidate.replaceAll("\\", "/")).replace(/^\.\//, "");
296
+ if (
297
+ !normalized
298
+ || normalized === "."
299
+ || normalized === ".."
300
+ || normalized.startsWith("../")
301
+ || posix.isAbsolute(normalized)
302
+ || normalized.length > MAX_WORKING_SET_PATH_CHARS
303
+ ) {
304
+ return null;
305
+ }
306
+ return normalized;
307
+ };
308
+
309
+ const addWorkingSetPath = (paths, value, repositoryRoot) => {
310
+ const normalized = normalizeWorkingSetPath(value, repositoryRoot);
311
+ if (!normalized) return null;
312
+ const duplicate = windowsPathFlavor(repositoryRoot)
313
+ ? Array.from(paths).some((existing) => existing.toLowerCase() === normalized.toLowerCase())
314
+ : paths.has(normalized);
315
+ if (!duplicate) paths.add(normalized);
316
+ return normalized;
317
+ };
318
+
164
319
  const appendWorkingSetFileArgs = (args, workingSetFiles) => {
165
320
  if (!Array.isArray(workingSetFiles) || workingSetFiles.length === 0) {
166
321
  return args;
167
322
  }
168
323
  for (const file of workingSetFiles) {
169
324
  const normalized = String(file || "").trim();
170
- if (!normalized) {
325
+ if (!normalized || normalized.length > MAX_WORKING_SET_PATH_CHARS) {
171
326
  continue;
172
327
  }
173
- args.push("--working-set-file", normalized.slice(0, 400));
328
+ args.push("--working-set-file", normalized);
174
329
  }
175
330
  return args;
176
331
  };
@@ -212,12 +367,15 @@ const buildInjectQuery = ({ firstPrompt, lastPromptText, projectName, filesModif
212
367
  return query.length > 500 ? query.slice(0, 500) : query;
213
368
  };
214
369
 
215
- const buildPackArgs = ({ query, filesModified, injectLimit, injectTokenBudget }) => {
370
+ const buildPackArgs = ({ query, filesModified, injectLimit, injectTokenBudget, internalLedger = false }) => {
216
371
  const workingSetFiles = Array.from(filesModified || [])
217
372
  .slice(-8)
218
373
  .map((value) => String(value || "").trim())
219
374
  .filter(Boolean);
220
375
  const args = ["pack", query, "--json"];
376
+ if (internalLedger) {
377
+ args.push("--internal-ledger");
378
+ }
221
379
  if (injectLimit !== null && Number.isFinite(injectLimit) && injectLimit > 0) {
222
380
  args.push("--limit", String(injectLimit));
223
381
  }
@@ -255,6 +413,63 @@ const parsePackMetrics = (stdout) => {
255
413
  }
256
414
  };
257
415
 
416
+ const parsePackOutput = (result) => {
417
+ const succeeded = result?.exitCode === 0;
418
+ let payload = null;
419
+ if (succeeded) {
420
+ try {
421
+ payload = JSON.parse(result.stdout);
422
+ } catch {
423
+ payload = null;
424
+ }
425
+ }
426
+ const ledgerConflict = payload?.ledger_outcome?.ok === false
427
+ && payload.ledger_outcome.errorCode === "retrieval_ledger_write_failed"
428
+ && payload.ledger_outcome.reason === "idempotency_conflict";
429
+ const metrics = succeeded ? payload?.metrics || null : null;
430
+ const itemCount = Number.isFinite(Number(metrics?.total_items))
431
+ ? Number(metrics.total_items)
432
+ : null;
433
+ const candidatePackText = succeeded && itemCount !== 0
434
+ ? String(payload?.pack_text || "").trim()
435
+ : "";
436
+ return {
437
+ conflictPackText: ledgerConflict ? candidatePackText : "",
438
+ ledgerConflict,
439
+ metrics,
440
+ itemCount,
441
+ // The real builder renders section headings even when it selected no
442
+ // memories. Treat the structured count as authoritative so headings alone
443
+ // are never handed to the model as retrieved context.
444
+ packText: !ledgerConflict ? candidatePackText : "",
445
+ };
446
+ };
447
+
448
+ const canonicalJson = (value) => {
449
+ if (Array.isArray(value)) {
450
+ return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
451
+ }
452
+ if (value && typeof value === "object") {
453
+ return `{${Object.keys(value)
454
+ .sort()
455
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
456
+ .join(",")}}`;
457
+ }
458
+ return JSON.stringify(value);
459
+ };
460
+
461
+ const promptPackArtifactFingerprint = (stdout, packText) => {
462
+ try {
463
+ const payload = JSON.parse(stdout);
464
+ if (/^[0-9a-f]{64}$/i.test(payload?.ledger_artifact_fingerprint || "")) {
465
+ return payload.ledger_artifact_fingerprint.toLowerCase();
466
+ }
467
+ return createHash("sha256").update(canonicalJson(payload)).digest("hex");
468
+ } catch {
469
+ return createHash("sha256").update(packText).digest("hex");
470
+ }
471
+ };
472
+
258
473
  const applyInjectedContextToOutput = async ({
259
474
  injectEnabled,
260
475
  input,
@@ -263,8 +478,11 @@ const applyInjectedContextToOutput = async ({
263
478
  showToast,
264
479
  resolveInjectQuery,
265
480
  buildInjectedContext,
481
+ confirmDelivery,
482
+ recordSkipped,
266
483
  }) => {
267
484
  if (!injectEnabled) {
485
+ recordSkipped?.("injection_disabled", input?.sessionID || null);
268
486
  return false;
269
487
  }
270
488
 
@@ -277,7 +495,12 @@ const applyInjectedContextToOutput = async ({
277
495
  // O(1) hit when the cache key isn't tied to the prompt that produced
278
496
  // the pack.
279
497
  const query = resolveInjectQuery();
280
- const injected = await buildInjectedContext(query);
498
+ const sessionID = input?.sessionID || null;
499
+ const injected = await buildInjectedContext(query, {
500
+ sessionID,
501
+ requestKey: `system:${sessionID || "unknown"}`,
502
+ surface: "system",
503
+ });
281
504
  if (!injected?.text) {
282
505
  return false;
283
506
  }
@@ -291,10 +514,20 @@ const applyInjectedContextToOutput = async ({
291
514
  }
292
515
  }
293
516
 
294
- if (!Array.isArray(output.system)) {
295
- output.system = [];
517
+ try {
518
+ if (!Array.isArray(output.system)) {
519
+ output.system = [];
520
+ }
521
+ output.system.push(injected.text);
522
+ } catch (error) {
523
+ if (injected.attemptId) {
524
+ confirmDelivery?.(injected.attemptId, "failed");
525
+ }
526
+ throw error;
527
+ }
528
+ if (injected.attemptId) {
529
+ confirmDelivery?.(injected.attemptId);
296
530
  }
297
- output.system.push(injected.text);
298
531
  return true;
299
532
  };
300
533
 
@@ -428,7 +661,7 @@ const normalizeInjectedMessageParts = (messages, sessionCache) => {
428
661
  }
429
662
  }
430
663
  if (existingText && sessionCache && messageId && !sessionCache.has(messageId)) {
431
- sessionCache.set(messageId, existingText);
664
+ sessionCache.set(messageId, { text: existingText, attemptId: null, reconstructed: true });
432
665
  trimSessionMessageInjectionCache(sessionCache);
433
666
  }
434
667
  if (retainedParts.length !== entry.parts.length) {
@@ -450,7 +683,12 @@ const buildInjectedContextPart = (entry, index, text, options = {}) => {
450
683
  };
451
684
  };
452
685
 
453
- const appendCachedInjectedContextParts = (messages, sessionCache, sessionID = null) => {
686
+ const appendCachedInjectedContextParts = async (
687
+ messages,
688
+ sessionCache,
689
+ sessionID = null,
690
+ options = {},
691
+ ) => {
454
692
  let appended = 0;
455
693
  for (let index = 0; index < messages.length; index += 1) {
456
694
  const entry = messages[index];
@@ -461,11 +699,49 @@ const appendCachedInjectedContextParts = (messages, sessionCache, sessionID = nu
461
699
  if (!messageId) {
462
700
  continue;
463
701
  }
464
- const text = sessionCache.get(messageId);
702
+ const cached = sessionCache.get(messageId);
703
+ const text = typeof cached === "string" ? cached : cached?.text;
465
704
  if (!text) {
466
705
  continue;
467
706
  }
468
- entry.parts.push(buildInjectedContextPart(entry, index, text, { messageId, sessionID }));
707
+ let attemptId = cached?.attemptId || null;
708
+ let cacheReuseReady = null;
709
+ if (
710
+ messageId === options.latestMessageId
711
+ && !options.latestWasBuilt
712
+ && attemptId
713
+ && options.recordCacheReuse
714
+ ) {
715
+ const reuse = options.recordCacheReuse(cached, {
716
+ messageId,
717
+ sessionID,
718
+ });
719
+ attemptId = typeof reuse === "string" ? reuse : reuse?.attemptId || null;
720
+ cacheReuseReady = typeof reuse === "object" ? reuse?.ready : null;
721
+ }
722
+ try {
723
+ entry.parts.push(buildInjectedContextPart(entry, index, text, { messageId, sessionID }));
724
+ } catch (error) {
725
+ if (messageId === options.latestMessageId && attemptId && options.confirmDelivery) {
726
+ if (cacheReuseReady) {
727
+ void Promise.resolve(cacheReuseReady)
728
+ .then(() => options.confirmDelivery(attemptId, "failed"))
729
+ .catch(() => {});
730
+ } else {
731
+ options.confirmDelivery(attemptId, "failed");
732
+ }
733
+ }
734
+ throw error;
735
+ }
736
+ if (messageId === options.latestMessageId && attemptId && options.confirmDelivery) {
737
+ if (cacheReuseReady) {
738
+ void Promise.resolve(cacheReuseReady)
739
+ .then(() => options.confirmDelivery(attemptId))
740
+ .catch(() => {});
741
+ } else {
742
+ options.confirmDelivery(attemptId);
743
+ }
744
+ }
469
745
  appended += 1;
470
746
  }
471
747
  return appended;
@@ -481,18 +757,26 @@ const applyInjectedContextToMessages = async ({
481
757
  buildInjectedContext,
482
758
  messageInjectionCache,
483
759
  compactionInjectionSkips,
760
+ confirmDelivery,
761
+ recordCacheReuse,
762
+ recordSkipped,
484
763
  }) => {
485
- if (!injectEnabled || !Array.isArray(output?.messages)) {
486
- return false;
487
- }
488
-
489
- const latestUser = findLatestUserMessage(output.messages);
764
+ const hasMessages = Array.isArray(output?.messages);
765
+ const latestUser = hasMessages ? findLatestUserMessage(output.messages) : null;
490
766
  const sessionID = latestUser
491
767
  ? resolveEntrySessionID(latestUser.entry) || input?.sessionID || null
492
768
  : input?.sessionID || null;
769
+ if (!injectEnabled) {
770
+ recordSkipped?.("injection_disabled", sessionID);
771
+ return false;
772
+ }
773
+ if (!hasMessages) {
774
+ return false;
775
+ }
493
776
 
494
777
  if (consumeCompactionInjectionSkip(compactionInjectionSkips, sessionID)) {
495
778
  normalizeInjectedMessageParts(output.messages, null);
779
+ recordSkipped?.("compaction_skipped", sessionID);
496
780
  return false;
497
781
  }
498
782
 
@@ -505,24 +789,49 @@ const applyInjectedContextToMessages = async ({
505
789
 
506
790
  const latestMessageId = resolveEntryMessageId(latestUser.entry);
507
791
  const canReplay = Boolean(sessionCache && latestMessageId);
508
- if (!canReplay || !sessionCache.has(latestMessageId)) {
792
+ const latestCached = canReplay ? sessionCache.get(latestMessageId) : null;
793
+ const latestWasCached = Boolean(latestCached) && latestCached?.reconstructed !== true;
794
+ let latestWasBuilt = false;
795
+ if (!latestWasCached) {
509
796
  const firstUser = findFirstUserMessage(output.messages);
510
797
  const query = resolveInjectQuery({
511
798
  firstPrompt: firstUser ? extractMessageText(firstUser.entry) : null,
512
799
  lastPromptText: extractMessageText(latestUser.entry),
513
800
  });
514
- const injected = await buildInjectedContext(query);
801
+ const injected = await buildInjectedContext(query, {
802
+ sessionID,
803
+ requestKey: latestMessageId || fallbackEntryMessageId(latestUser.entry, latestUser.index),
804
+ surface: "message",
805
+ });
515
806
  if (injected?.text) {
807
+ latestWasBuilt = true;
516
808
  if (canReplay) {
517
- sessionCache.set(latestMessageId, injected.text);
809
+ sessionCache.set(latestMessageId, {
810
+ text: injected.text,
811
+ attemptId: injected.attemptId || null,
812
+ requestId: injected.requestId || null,
813
+ queryHash: injected.queryHash || null,
814
+ promptNumber: injected.promptNumber || 0,
815
+ reuseCount: 0,
816
+ });
518
817
  trimSessionMessageInjectionCache(sessionCache);
519
818
  } 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
- );
819
+ try {
820
+ latestUser.entry.parts.push(
821
+ buildInjectedContextPart(latestUser.entry, latestUser.index, injected.text, {
822
+ messageId: latestMessageId || undefined,
823
+ sessionID: sessionID || undefined,
824
+ })
825
+ );
826
+ } catch (error) {
827
+ if (injected.attemptId) {
828
+ confirmDelivery?.(injected.attemptId, "failed");
829
+ }
830
+ throw error;
831
+ }
832
+ if (injected.attemptId) {
833
+ confirmDelivery?.(injected.attemptId);
834
+ }
526
835
  }
527
836
 
528
837
  const toastKey = sessionID || latestMessageId || "unknown";
@@ -534,6 +843,8 @@ const applyInjectedContextToMessages = async ({
534
843
  // best-effort only
535
844
  }
536
845
  }
846
+ } else if (canReplay && latestCached?.reconstructed === true) {
847
+ sessionCache.delete(latestMessageId);
537
848
  }
538
849
  }
539
850
 
@@ -544,7 +855,12 @@ const applyInjectedContextToMessages = async ({
544
855
  );
545
856
  }
546
857
 
547
- const appended = appendCachedInjectedContextParts(output.messages, sessionCache, sessionID);
858
+ const appended = await appendCachedInjectedContextParts(output.messages, sessionCache, sessionID, {
859
+ latestMessageId,
860
+ latestWasBuilt,
861
+ confirmDelivery,
862
+ recordCacheReuse,
863
+ });
548
864
  return appended > 0;
549
865
  };
550
866
 
@@ -1036,10 +1352,15 @@ export const OpencodeMemPlugin = async ({
1036
1352
  const injectionToastShown = new Set();
1037
1353
  const messageInjectionCache = new Map();
1038
1354
  const compactionInjectionSkips = new Map();
1355
+ const disabledInjectionRecorded = new Set();
1356
+ const attemptStartedAt = new Map();
1357
+ const promptPackRetryCounts = new Map();
1358
+ const successfulPromptPackArtifacts = new Map();
1039
1359
  let sessionStartedAt = null;
1040
1360
  let activeSessionID = null;
1041
1361
  let viewerStarted = false;
1042
1362
  let promptCounter = 0;
1363
+ let skippedAttemptCounter = 0;
1043
1364
  let lastPromptText = null;
1044
1365
  let lastAssistantText = null;
1045
1366
  const assistantUsageCaptured = new Set();
@@ -1087,13 +1408,24 @@ export const OpencodeMemPlugin = async ({
1087
1408
  };
1088
1409
 
1089
1410
  const queueRawEventViaCli = async (body) => {
1090
- const result = await runCli(["enqueue-raw-event"], {
1411
+ const runFallback = () => runCli(["enqueue-raw-event"], {
1091
1412
  stdinText: JSON.stringify(body),
1092
1413
  });
1414
+ let result = await runFallback();
1415
+ let classification = classifyFallbackCommandResult(result);
1416
+ let attemptedRetry = false;
1417
+ if (result?.exitCode !== 0 && classification.retryable) {
1418
+ attemptedRetry = true;
1419
+ result = await runFallback();
1420
+ classification = classifyFallbackCommandResult(result);
1421
+ }
1093
1422
  if (result?.exitCode !== 0) {
1094
- throw new Error(
1095
- `enqueue-raw-event failed (${result?.exitCode ?? "unknown"})`
1423
+ const retryExhausted = attemptedRetry && classification.retryable;
1424
+ const error = new Error(
1425
+ retryExhausted ? `${classification.cause} after retry` : classification.cause
1096
1426
  );
1427
+ error.retryable = classification.retryable && !attemptedRetry;
1428
+ throw error;
1097
1429
  }
1098
1430
  return true;
1099
1431
  };
@@ -1152,6 +1484,9 @@ export const OpencodeMemPlugin = async ({
1152
1484
  }
1153
1485
  return true;
1154
1486
  } catch (fallbackErr) {
1487
+ if (payload && typeof payload === "object") {
1488
+ payload._raw_fallback_terminal = fallbackErr?.retryable !== true;
1489
+ }
1155
1490
  await logLine(
1156
1491
  `raw_events.fallback.error sessionID=${sessionID} type=${type} err=${String(
1157
1492
  fallbackErr
@@ -1243,6 +1578,9 @@ export const OpencodeMemPlugin = async ({
1243
1578
  await queueRawEventViaCli(body);
1244
1579
  fallbackOk = true;
1245
1580
  } catch (fallbackErr) {
1581
+ if (payload && typeof payload === "object") {
1582
+ payload._raw_fallback_terminal = fallbackErr?.retryable !== true;
1583
+ }
1246
1584
  await logLine(
1247
1585
  `raw_events.fallback.error sessionID=${sessionID} type=${type} err=${String(
1248
1586
  fallbackErr
@@ -1288,6 +1626,7 @@ export const OpencodeMemPlugin = async ({
1288
1626
 
1289
1627
  if (!streamErrorNoted) {
1290
1628
  streamErrorNoted = true;
1629
+ fallbackFailureNoted = true;
1291
1630
  try {
1292
1631
  await client.app.log({
1293
1632
  service: "codemem",
@@ -1628,6 +1967,93 @@ export const OpencodeMemPlugin = async ({
1628
1967
  const runCli = async (args, options = {}) =>
1629
1968
  runCommand([runner, ...runnerArgs, ...args], options);
1630
1969
 
1970
+ const attemptMetadata = (identity, sessionID = null, promptNumber = promptCounter) => ({
1971
+ attempt_id: identity.attemptId,
1972
+ started_at: (() => {
1973
+ const existing = attemptStartedAt.get(identity.attemptId);
1974
+ if (existing) return existing;
1975
+ const created = new Date().toISOString();
1976
+ attemptStartedAt.set(identity.attemptId, created);
1977
+ while (attemptStartedAt.size > 2000) {
1978
+ const oldest = attemptStartedAt.keys().next().value;
1979
+ if (!oldest) break;
1980
+ attemptStartedAt.delete(oldest);
1981
+ }
1982
+ return created;
1983
+ })(),
1984
+ source: "opencode",
1985
+ ...(sessionID ? { stream_id: String(sessionID), source_session_id: String(sessionID) } : {}),
1986
+ ...(promptNumber > 0 ? { prompt_number: promptNumber } : {}),
1987
+ request_id: identity.requestId,
1988
+ });
1989
+
1990
+ const runPromptPackLedger = async (payload) => {
1991
+ try {
1992
+ return await runCli(["prompt-pack-ledger"], {
1993
+ stdinText: JSON.stringify(payload),
1994
+ });
1995
+ } catch {
1996
+ return null;
1997
+ }
1998
+ };
1999
+
2000
+ const skippedIdentity = (failureCode, sessionID, surface, eventKey) =>
2001
+ promptPackIdentity({
2002
+ sessionID: sessionID || "unknown",
2003
+ requestKey: `${failureCode}:${eventKey}`,
2004
+ surface,
2005
+ promptNumber: promptCounter,
2006
+ queryHash: hashPromptPackQuery(""),
2007
+ });
2008
+
2009
+ const recordSkippedPromptPack = (failureCode, sessionID = null, surface = injectSurface) => {
2010
+ const sessionKey = String(sessionID || "unknown");
2011
+ const memoKey = `${surface}:${sessionKey}`;
2012
+ if (failureCode === "injection_disabled" && disabledInjectionRecorded.has(memoKey)) {
2013
+ return null;
2014
+ }
2015
+ if (failureCode === "injection_disabled") disabledInjectionRecorded.add(memoKey);
2016
+ const identity = skippedIdentity(
2017
+ failureCode,
2018
+ sessionID,
2019
+ surface,
2020
+ failureCode === "injection_disabled" ? "once" : `event-${++skippedAttemptCounter}`,
2021
+ );
2022
+ void runPromptPackLedger({
2023
+ action: "record",
2024
+ ...attemptMetadata(identity, sessionID),
2025
+ retrieval_status: "skipped",
2026
+ failure_code: failureCode,
2027
+ failure_stage: "policy",
2028
+ });
2029
+ return identity.attemptId;
2030
+ };
2031
+
2032
+ const recordCachedPromptPack = (cached, { messageId, sessionID } = {}) => {
2033
+ cached.reuseCount = (cached.reuseCount || 0) + 1;
2034
+ const identity = promptPackIdentity({
2035
+ sessionID: sessionID || "unknown",
2036
+ requestKey: `${messageId || "unknown"}:cache:${cached.reuseCount}`,
2037
+ surface: "message",
2038
+ promptNumber: cached.promptNumber || promptCounter,
2039
+ queryHash: cached.queryHash || hashPromptPackQuery(""),
2040
+ });
2041
+ const ready = runPromptPackLedger({
2042
+ action: "cache_reuse",
2043
+ ...attemptMetadata(identity, sessionID, cached.promptNumber || promptCounter),
2044
+ original_attempt_id: cached.attemptId,
2045
+ });
2046
+ return { attemptId: identity.attemptId, ready };
2047
+ };
2048
+
2049
+ const confirmPromptPackDelivery = (attemptId, deliveryStatus = "handed_off") => {
2050
+ void runPromptPackLedger({
2051
+ action: "delivery",
2052
+ attempt_id: attemptId,
2053
+ delivery_status: deliveryStatus,
2054
+ });
2055
+ };
2056
+
1631
2057
  const showToast = async (message, variant = "warning") => {
1632
2058
  if (backendUpdatePolicy === "off") {
1633
2059
  return;
@@ -1827,28 +2253,300 @@ export const OpencodeMemPlugin = async ({
1827
2253
  return masked.length > limit ? `${masked.slice(0, limit)}…` : masked;
1828
2254
  };
1829
2255
 
1830
- const buildInjectedContext = async (query) => {
1831
- const packArgs = buildPackArgs({
1832
- query,
1833
- filesModified: sessionContext.filesModified,
1834
- injectLimit,
1835
- injectTokenBudget,
2256
+ const advancePromptPackRetryIdentity = (attemptKey) => {
2257
+ promptPackRetryCounts.set(
2258
+ attemptKey,
2259
+ (promptPackRetryCounts.get(attemptKey) || 0) + 1
2260
+ );
2261
+ while (promptPackRetryCounts.size > 2000) {
2262
+ const oldest = promptPackRetryCounts.keys().next().value;
2263
+ if (!oldest) break;
2264
+ promptPackRetryCounts.delete(oldest);
2265
+ }
2266
+ };
2267
+
2268
+ const rememberSuccessfulPromptPackArtifact = (
2269
+ attemptKey,
2270
+ retryCount,
2271
+ fingerprint
2272
+ ) => {
2273
+ successfulPromptPackArtifacts.delete(attemptKey);
2274
+ successfulPromptPackArtifacts.set(attemptKey, {
2275
+ retryCount,
2276
+ fingerprint,
2277
+ });
2278
+ while (successfulPromptPackArtifacts.size > 2000) {
2279
+ const oldest = successfulPromptPackArtifacts.keys().next().value;
2280
+ if (!oldest) break;
2281
+ successfulPromptPackArtifacts.delete(oldest);
2282
+ }
2283
+ };
2284
+
2285
+ const buildInjectedContext = async (query, context = {}) => {
2286
+ const queryHash = hashPromptPackQuery(query);
2287
+ const sessionID = context.sessionID || activeSessionID || "unknown";
2288
+ const surface = context.surface || injectSurface;
2289
+ const requestKey = context.requestKey || "unknown";
2290
+ const attemptKey = JSON.stringify([
2291
+ sessionID,
2292
+ requestKey,
2293
+ surface,
2294
+ promptCounter,
2295
+ queryHash,
2296
+ ]);
2297
+ let retryCount = promptPackRetryCounts.get(attemptKey) || 0;
2298
+ const resolveIdentity = () => promptPackIdentity({
2299
+ sessionID,
2300
+ requestKey: retryCount > 0
2301
+ ? `${requestKey}:empty-retry:${retryCount}`
2302
+ : requestKey,
2303
+ surface,
2304
+ promptNumber: promptCounter,
2305
+ queryHash,
1836
2306
  });
1837
- const result = await runCli(packArgs);
2307
+ let identity = resolveIdentity();
2308
+ let metadata = attemptMetadata(
2309
+ identity,
2310
+ context.sessionID || activeSessionID || null,
2311
+ );
2312
+ const runPack = async () => {
2313
+ let packArgs = buildPackArgs({
2314
+ query,
2315
+ filesModified: sessionContext.filesModified,
2316
+ injectLimit,
2317
+ injectTokenBudget,
2318
+ internalLedger: true,
2319
+ });
2320
+ let result = await runCli(packArgs, { stdinText: JSON.stringify(metadata) });
2321
+ if (rejectsInternalLedgerFlag(result)) {
2322
+ packArgs = packArgs.filter((arg) => arg !== "--internal-ledger");
2323
+ result = await runCli(packArgs);
2324
+ }
2325
+ return { packArgs, result };
2326
+ };
2327
+ let { packArgs, result } = await runPack();
2328
+ let { packText, conflictPackText, metrics, itemCount, ledgerConflict } = parsePackOutput(result);
2329
+ let artifactFingerprint = packText
2330
+ ? promptPackArtifactFingerprint(result.stdout, packText)
2331
+ : "";
2332
+ let injectedIdentity = identity;
2333
+ let repairFallbackUsed = false;
2334
+ if (ledgerConflict) {
2335
+ // A restarted plugin has no artifact cache to predict this conflict. The
2336
+ // CLI marker is authoritative: never attribute delivery to the stale
2337
+ // identity, and retry once with a fresh deterministic identity.
2338
+ await log("warn", "codemem prompt-pack ledger conflict; retrying with fresh identity", {
2339
+ sessionID,
2340
+ surface,
2341
+ });
2342
+ const fallback = conflictPackText
2343
+ ? {
2344
+ packArgs,
2345
+ result,
2346
+ packText: conflictPackText,
2347
+ metrics,
2348
+ itemCount,
2349
+ artifactFingerprint: promptPackArtifactFingerprint(result.stdout, conflictPackText),
2350
+ ledgerConflict,
2351
+ }
2352
+ : null;
2353
+ advancePromptPackRetryIdentity(attemptKey);
2354
+ retryCount = promptPackRetryCounts.get(attemptKey) || 0;
2355
+ identity = resolveIdentity();
2356
+ metadata = attemptMetadata(
2357
+ identity,
2358
+ context.sessionID || activeSessionID || null,
2359
+ );
2360
+ ({ packArgs, result } = await runPack());
2361
+ // Repair-conflict bytes are never preferred over the original preserved pack.
2362
+ ({ packText, metrics, itemCount, ledgerConflict } = parsePackOutput(result));
2363
+ artifactFingerprint = packText
2364
+ ? promptPackArtifactFingerprint(result.stdout, packText)
2365
+ : "";
2366
+ const repairFailed = ledgerConflict
2367
+ || !result
2368
+ || result.exitCode !== 0
2369
+ || (!packText && itemCount !== 0);
2370
+ if (fallback && repairFailed) {
2371
+ if (ledgerConflict) {
2372
+ await log("warn", "codemem prompt-pack fresh identity also conflicted", {
2373
+ sessionID,
2374
+ surface,
2375
+ });
2376
+ } else {
2377
+ const malformedSuccess = result?.exitCode === 0;
2378
+ const exitCode = result?.exitCode ?? "unknown";
2379
+ const stderr = redactLog(result?.stderr ? result.stderr.trim() : "");
2380
+ const cmd = redactPackCommand(runner, runnerArgs, packArgs);
2381
+ await logLine(
2382
+ `inject.pack.identity_repair_failed reason=${malformedSuccess ? "malformed_success" : "command_failed"} exit=${exitCode} cmd=${cmd}` +
2383
+ `${stderr ? ` stderr=${stderr}` : ""}`
2384
+ );
2385
+ await log("warn", "codemem prompt-pack identity repair failed", {
2386
+ reason: malformedSuccess ? "malformed_success" : "command_failed",
2387
+ exitCode,
2388
+ });
2389
+ void runPromptPackLedger({
2390
+ action: "record",
2391
+ ...metadata,
2392
+ retrieval_status: "failed",
2393
+ failure_code: malformedSuccess
2394
+ ? "pack_identity_repair_failed"
2395
+ : "pack_command_failed",
2396
+ failure_stage: malformedSuccess ? "decode" : "transport",
2397
+ });
2398
+ }
2399
+ advancePromptPackRetryIdentity(attemptKey);
2400
+ retryCount = promptPackRetryCounts.get(attemptKey) || 0;
2401
+ ({
2402
+ packArgs,
2403
+ result,
2404
+ packText,
2405
+ metrics,
2406
+ itemCount,
2407
+ artifactFingerprint,
2408
+ ledgerConflict,
2409
+ } = fallback);
2410
+ // Neither persisted identity represents fallback delivery: the first
2411
+ // conflicted and the fresh repair failed. Keep the bytes fail-open but
2412
+ // leave delivery and replay attribution empty.
2413
+ injectedIdentity = null;
2414
+ repairFallbackUsed = true;
2415
+ } else {
2416
+ injectedIdentity = identity;
2417
+ }
2418
+ }
2419
+ if (packText) {
2420
+ const previous = successfulPromptPackArtifacts.get(attemptKey);
2421
+ if (
2422
+ previous?.retryCount === retryCount
2423
+ && previous.fingerprint !== artifactFingerprint
2424
+ ) {
2425
+ // The in-memory fingerprint detected a changed artifact before handoff.
2426
+ // Defensively rebuild once with a fresh identity; older CLIs do not
2427
+ // expose the persisted conflict marker used by the restart path above.
2428
+ // Keep the usable changed bytes as a fail-open fallback: diagnostics
2429
+ // identity repair must never suppress context injection.
2430
+ const fallback = {
2431
+ packArgs,
2432
+ result,
2433
+ packText,
2434
+ metrics,
2435
+ itemCount,
2436
+ artifactFingerprint,
2437
+ ledgerConflict,
2438
+ };
2439
+ advancePromptPackRetryIdentity(attemptKey);
2440
+ retryCount = promptPackRetryCounts.get(attemptKey) || 0;
2441
+ identity = resolveIdentity();
2442
+ metadata = attemptMetadata(
2443
+ identity,
2444
+ context.sessionID || activeSessionID || null,
2445
+ );
2446
+ ({ packArgs, result } = await runPack());
2447
+ ({ packText, metrics, itemCount, ledgerConflict } = parsePackOutput(result));
2448
+ artifactFingerprint = packText
2449
+ ? promptPackArtifactFingerprint(result.stdout, packText)
2450
+ : "";
2451
+
2452
+ if (ledgerConflict) {
2453
+ // The repair identity is also persisted with different artifacts.
2454
+ // Preserve the marker for the fail-closed return below; never use the
2455
+ // stale changed bytes as the transport-failure fallback.
2456
+ injectedIdentity = identity;
2457
+ } else if (result?.exitCode === 0 && itemCount === 0) {
2458
+ // A fresh identity can legitimately resolve to no results if the
2459
+ // underlying memory set changed between rebuilds. The CLI already
2460
+ // recorded that terminal outcome; do not misclassify it as a decode
2461
+ // failure or fall back to stale non-empty context.
2462
+ injectedIdentity = identity;
2463
+ } else if (!result || result.exitCode !== 0 || !packText) {
2464
+ const malformedSuccess = result?.exitCode === 0;
2465
+ const exitCode = result?.exitCode ?? "unknown";
2466
+ const stderr = redactLog(result?.stderr ? result.stderr.trim() : "");
2467
+ const cmd = redactPackCommand(runner, runnerArgs, packArgs);
2468
+ await logLine(
2469
+ `inject.pack.identity_repair_failed reason=${malformedSuccess ? "malformed_success" : "command_failed"} exit=${exitCode} cmd=${cmd}` +
2470
+ `${stderr ? ` stderr=${stderr}` : ""}`
2471
+ );
2472
+ await log("warn", "codemem prompt-pack identity repair failed", {
2473
+ reason: malformedSuccess ? "malformed_success" : "command_failed",
2474
+ exitCode,
2475
+ });
2476
+ void runPromptPackLedger({
2477
+ action: "record",
2478
+ ...metadata,
2479
+ retrieval_status: "failed",
2480
+ failure_code: malformedSuccess
2481
+ ? "pack_identity_repair_failed"
2482
+ : "pack_command_failed",
2483
+ failure_stage: malformedSuccess ? "decode" : "transport",
2484
+ });
2485
+ advancePromptPackRetryIdentity(attemptKey);
2486
+
2487
+ ({
2488
+ packArgs,
2489
+ result,
2490
+ packText,
2491
+ metrics,
2492
+ itemCount,
2493
+ artifactFingerprint,
2494
+ ledgerConflict,
2495
+ } = fallback);
2496
+ // The stale attempt does not represent these changed bytes, and the
2497
+ // fresh repair attempt failed. Inject without delivery attribution
2498
+ // rather than falsely marking either ledger attempt handed off.
2499
+ injectedIdentity = null;
2500
+ repairFallbackUsed = true;
2501
+ } else {
2502
+ injectedIdentity = identity;
2503
+ }
2504
+ }
2505
+ }
1838
2506
  if (!result || result.exitCode !== 0) {
1839
2507
  const exitCode = result?.exitCode ?? "unknown";
1840
2508
  const stderr = redactLog(result?.stderr ? result.stderr.trim() : "");
1841
2509
  const stdout = redactLog(result?.stdout ? result.stdout.trim() : "");
1842
- const cmd = [runner, ...runnerArgs, ...packArgs].join(" ");
2510
+ const cmd = redactPackCommand(runner, runnerArgs, packArgs);
1843
2511
  await logLine(
1844
2512
  `inject.pack.error ${exitCode} cmd=${cmd}` +
1845
2513
  `${stderr ? ` stderr=${stderr}` : ""}` +
1846
2514
  `${stdout ? ` stdout=${stdout}` : ""}`
1847
2515
  );
1848
- return "";
2516
+ void runPromptPackLedger({
2517
+ action: "record",
2518
+ ...metadata,
2519
+ retrieval_status: "failed",
2520
+ failure_code: "pack_command_failed",
2521
+ failure_stage: "transport",
2522
+ });
2523
+ advancePromptPackRetryIdentity(attemptKey);
2524
+ return {
2525
+ text: "",
2526
+ attemptId: identity.attemptId,
2527
+ requestId: identity.requestId,
2528
+ queryHash,
2529
+ promptNumber: promptCounter,
2530
+ };
2531
+ }
2532
+ if (ledgerConflict && !repairFallbackUsed) {
2533
+ await log("warn", "codemem prompt-pack fresh identity also conflicted", {
2534
+ sessionID,
2535
+ surface,
2536
+ });
2537
+ advancePromptPackRetryIdentity(attemptKey);
2538
+ return {
2539
+ text: "",
2540
+ attemptId: identity.attemptId,
2541
+ requestId: identity.requestId,
2542
+ queryHash,
2543
+ promptNumber: promptCounter,
2544
+ };
1849
2545
  }
1850
- const packText = parsePackText(result.stdout);
1851
2546
  if (!packText) {
2547
+ if (itemCount === 0) {
2548
+ advancePromptPackRetryIdentity(attemptKey);
2549
+ }
1852
2550
  if (debug) {
1853
2551
  const { safeQuery, firstPromptLen, lastPromptLen, projectName, filesModifiedCount } =
1854
2552
  describeInjectQuery(query);
@@ -1856,27 +2554,46 @@ export const OpencodeMemPlugin = async ({
1856
2554
  `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
2555
  );
1858
2556
  }
1859
- return "";
2557
+ return {
2558
+ text: "",
2559
+ attemptId: identity.attemptId,
2560
+ requestId: identity.requestId,
2561
+ queryHash,
2562
+ promptNumber: promptCounter,
2563
+ };
1860
2564
  }
1861
- const metrics = parsePackMetrics(result.stdout);
1862
2565
  // The pack JSON exposes the item count as `total_items`; `metrics.items`
1863
2566
  // 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
2567
  const packTokens = Number.isFinite(Number(metrics?.pack_tokens))
1868
2568
  ? Number(metrics.pack_tokens)
1869
2569
  : 0;
1870
2570
  await logLine(
1871
- `inject.pack.ok source=opencode items=${itemCount} pack_tokens=${packTokens} query_len=${query ? query.length : 0}`
2571
+ `inject.pack.ok source=opencode items=${itemCount ?? 0} pack_tokens=${packTokens} query_len=${query ? query.length : 0}`
1872
2572
  );
2573
+ if (!repairFallbackUsed) {
2574
+ rememberSuccessfulPromptPackArtifact(
2575
+ attemptKey,
2576
+ retryCount,
2577
+ artifactFingerprint || promptPackArtifactFingerprint(result.stdout, packText)
2578
+ );
2579
+ }
1873
2580
  if (metrics) {
1874
2581
  return {
1875
2582
  text: `[codemem context]\n${packText}`,
1876
2583
  metrics,
2584
+ attemptId: injectedIdentity?.attemptId || null,
2585
+ requestId: injectedIdentity?.requestId || null,
2586
+ queryHash,
2587
+ promptNumber: promptCounter,
1877
2588
  };
1878
2589
  }
1879
- return { text: `[codemem context]\n${packText}` };
2590
+ return {
2591
+ text: `[codemem context]\n${packText}`,
2592
+ attemptId: injectedIdentity?.attemptId || null,
2593
+ requestId: injectedIdentity?.requestId || null,
2594
+ queryHash,
2595
+ promptNumber: promptCounter,
2596
+ };
1880
2597
  };
1881
2598
 
1882
2599
  const stopViewer = async () => {
@@ -2066,10 +2783,18 @@ export const OpencodeMemPlugin = async ({
2066
2783
  }
2067
2784
 
2068
2785
  const failed = [];
2786
+ let droppedCount = 0;
2069
2787
  for (const queuedEvent of batch) {
2070
2788
  if (queuedEvent && typeof queuedEvent === "object" && queuedEvent._raw_enqueued) {
2071
2789
  continue;
2072
2790
  }
2791
+ if (queuedEvent && typeof queuedEvent === "object" && queuedEvent._raw_fallback_terminal) {
2792
+ droppedCount += 1;
2793
+ await logLine(
2794
+ `flush.drop terminal_fallback event_id=${queuedEvent?._raw_event_id || "unknown"}`
2795
+ );
2796
+ continue;
2797
+ }
2073
2798
  const queuedSessionID =
2074
2799
  queuedEvent?._raw_session_id ||
2075
2800
  queuedEvent?.properties?.sessionID ||
@@ -2080,6 +2805,13 @@ export const OpencodeMemPlugin = async ({
2080
2805
  payload: queuedEvent,
2081
2806
  });
2082
2807
  if (!ok) {
2808
+ if (queuedEvent?._raw_fallback_terminal) {
2809
+ droppedCount += 1;
2810
+ await logLine(
2811
+ `flush.drop terminal_fallback event_id=${queuedEvent?._raw_event_id || "unknown"}`
2812
+ );
2813
+ continue;
2814
+ }
2083
2815
  const currentRetry =
2084
2816
  typeof queuedEvent?._raw_retry_count === "number" && Number.isFinite(queuedEvent._raw_retry_count)
2085
2817
  ? queuedEvent._raw_retry_count
@@ -2104,7 +2836,7 @@ export const OpencodeMemPlugin = async ({
2104
2836
  await logLine(
2105
2837
  `flush.stream_only finalize count=${batch.length} tools=${sessionContext.toolCount} prompts=${sessionContext.promptCount} duration=${Math.round(durationMs / 1000)}s`
2106
2838
  );
2107
- await logLine(`flush.ok count=${batch.length}`);
2839
+ await logLine(`flush.ok count=${batch.length - droppedCount} dropped=${droppedCount}`);
2108
2840
  sessionStartedAt = null;
2109
2841
  resetSessionContext();
2110
2842
  };
@@ -2160,6 +2892,9 @@ export const OpencodeMemPlugin = async ({
2160
2892
  buildInjectedContext,
2161
2893
  messageInjectionCache,
2162
2894
  compactionInjectionSkips,
2895
+ confirmDelivery: confirmPromptPackDelivery,
2896
+ recordCacheReuse: recordCachedPromptPack,
2897
+ recordSkipped: recordSkippedPromptPack,
2163
2898
  });
2164
2899
  } catch (err) {
2165
2900
  await logLine(
@@ -2184,6 +2919,7 @@ export const OpencodeMemPlugin = async ({
2184
2919
  }
2185
2920
  const hookSessionID = input?.sessionID || activeSessionID;
2186
2921
  if (consumeCompactionInjectionSkip(compactionInjectionSkips, hookSessionID)) {
2922
+ recordSkippedPromptPack("compaction_skipped", hookSessionID, "system");
2187
2923
  if (debug) {
2188
2924
  await logLine(
2189
2925
  `inject.transform.skip_compaction sessionID=${hookSessionID || "unknown"}`
@@ -2224,6 +2960,8 @@ export const OpencodeMemPlugin = async ({
2224
2960
  : null,
2225
2961
  resolveInjectQuery,
2226
2962
  buildInjectedContext,
2963
+ confirmDelivery: confirmPromptPackDelivery,
2964
+ recordSkipped: recordSkippedPromptPack,
2227
2965
  });
2228
2966
  } catch (err) {
2229
2967
  await logLine(
@@ -2408,6 +3146,9 @@ export const OpencodeMemPlugin = async ({
2408
3146
  activeSessionID = sessionID || null;
2409
3147
  sessionStartedAt = new Date().toISOString();
2410
3148
  promptCounter = 0;
3149
+ skippedAttemptCounter = 0;
3150
+ disabledInjectionRecorded.delete("message:unknown");
3151
+ disabledInjectionRecorded.delete("system:unknown");
2411
3152
  lastPromptText = null;
2412
3153
  lastAssistantText = null;
2413
3154
  resetSessionContext();
@@ -2419,6 +3160,8 @@ export const OpencodeMemPlugin = async ({
2419
3160
  injectionToastShown.delete(sessionID);
2420
3161
  messageInjectionCache.delete(sessionID);
2421
3162
  compactionInjectionSkips.delete(sessionID);
3163
+ disabledInjectionRecorded.delete(`message:${sessionID}`);
3164
+ disabledInjectionRecorded.delete(`system:${sessionID}`);
2422
3165
  }
2423
3166
  await stopViewer();
2424
3167
  }
@@ -2438,15 +3181,15 @@ export const OpencodeMemPlugin = async ({
2438
3181
  if (filePath) {
2439
3182
  const lowerTool = toolName.toLowerCase();
2440
3183
  if (lowerTool === "edit" || lowerTool === "write") {
2441
- sessionContext.filesModified.add(filePath);
3184
+ addWorkingSetPath(sessionContext.filesModified, filePath, cwd);
2442
3185
  } else if (lowerTool === "read") {
2443
- sessionContext.filesRead.add(filePath);
3186
+ addWorkingSetPath(sessionContext.filesRead, filePath, cwd);
2444
3187
  }
2445
3188
  }
2446
3189
  if (toolName.toLowerCase() === "apply_patch") {
2447
3190
  const patchPaths = extractApplyPatchPaths(args.patchText);
2448
3191
  for (const path of patchPaths) {
2449
- sessionContext.filesModified.add(path);
3192
+ addWorkingSetPath(sessionContext.filesModified, path, cwd);
2450
3193
  }
2451
3194
  }
2452
3195
 
@@ -2527,6 +3270,12 @@ export const __testUtils = {
2527
3270
  resolveProjectName,
2528
3271
  buildInjectQuery,
2529
3272
  buildPackArgs,
3273
+ deterministicUuid,
3274
+ promptPackIdentity,
3275
+ hashPromptPackQuery,
3276
+ redactPackCommand,
3277
+ rejectsInternalLedgerFlag,
3278
+ classifyFallbackCommandResult,
2530
3279
  parsePackText,
2531
3280
  parsePackMetrics,
2532
3281
  resolveInjectSurface,
@@ -2537,6 +3286,8 @@ export const __testUtils = {
2537
3286
  buildRunnerArgs,
2538
3287
  appendWorkingSetFileArgs,
2539
3288
  extractApplyPatchPaths,
3289
+ normalizeWorkingSetPath,
3290
+ addWorkingSetPath,
2540
3291
  mapOpencodeEventTypeToAdapterType,
2541
3292
  buildOpencodeAdapterPayload,
2542
3293
  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.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.18.4"
21
+ "@opencode-ai/plugin": "1.18.12"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",