@codemem/opencode-plugin 0.44.0-alpha.1 → 0.44.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.
@@ -13,20 +13,28 @@ import {
13
13
  resolveAutoUpdatePlan,
14
14
  resolveUpgradeGuidance,
15
15
  } from "../lib/compat.js";
16
+ import {
17
+ DEFAULT_DRAIN_LIMIT,
18
+ DEFAULT_MAX_ENTRIES,
19
+ RAW_EVENT_SPOOL_FULL_CODE,
20
+ loadRawEventSpoolEntries,
21
+ removeRawEventSpoolEntry,
22
+ resolveSpoolDirectory,
23
+ writeRawEventSpoolEntry,
24
+ } from "../lib/raw-event-spool.js";
16
25
 
17
26
  const TRUTHY_VALUES = ["1", "true", "yes"];
18
27
  const DISABLED_VALUES = ["0", "false", "off"];
19
- const PINNED_BACKEND_VERSION = "0.44.0-alpha.1";
28
+ const PINNED_BACKEND_VERSION = "0.44.0";
20
29
  const COMPAT_CHECK_DELAY_MS = 1500;
21
30
  const COMPAT_CHECK_CACHE_TTL_MS = 5 * 60 * 1000;
22
31
  const MAX_UPDATE_STATUS_BYTES = 16 * 1024;
23
32
  const MAX_UPDATE_ACTION_CHARS = 1000;
24
33
  const MAX_UPDATE_VERSION_CHARS = 128;
25
- const STABLE_RELEASE_VERSION =
26
- /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
34
+ const RELEASE_VERSION =
35
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
27
36
  const CODEMEM_CONTEXT_PART_ID_PREFIX = "codemem-context-";
28
37
  const MAX_MESSAGE_INJECTION_CACHE_SESSIONS = 20;
29
- const MAX_MESSAGE_INJECTION_CACHE_MESSAGES = 100;
30
38
  const COMPACTION_INJECTION_SKIP_TTL_MS = 30 * 1000;
31
39
  const MAX_WORKING_SET_PATH_CHARS = 400;
32
40
  const VIEWER_HEALTH_CHECK_INTERVAL_MS = 60_000;
@@ -34,12 +42,34 @@ const VIEWER_HEALTH_TIMEOUT_MS = 5_000;
34
42
  const VIEWER_HEALTH_RESTART_THRESHOLD = 3;
35
43
  const VIEWER_HEALTH_RESTART_COOLDOWN_MS = 5 * 60_000;
36
44
  const RAW_EVENTS_STATUS_TIMEOUT_MS = 5_000;
45
+ const DEFAULT_INJECT_TOKEN_BUDGET = 800;
46
+ const DEFAULT_RETAINED_TOKEN_BUDGET = Infinity;
47
+ const CODEMEM_CONTEXT_PREFIX = "[codemem context]\n";
37
48
  const DEFAULT_EMBEDDING_MODEL = "Xenova/bge-small-en-v1.5";
38
49
  const DEFAULT_EMBEDDING_REVISION = "ea104dacec62c0de699686887e3f920caeb4f3e3";
50
+ const PLUGIN_REGISTRATIONS_KEY = Symbol.for("codemem.opencode-plugin.registrations");
39
51
 
40
52
  let compatCheckCache = null;
41
53
  const notifiedReleaseVersions = new Set();
42
54
 
55
+ const claimPluginRegistration = (cwd) => {
56
+ let registrations = globalThis[PLUGIN_REGISTRATIONS_KEY];
57
+ if (!(registrations instanceof Map)) {
58
+ registrations = new Map();
59
+ globalThis[PLUGIN_REGISTRATIONS_KEY] = registrations;
60
+ }
61
+ const projectPath = resolve(cwd);
62
+ if (registrations.has(projectPath)) return null;
63
+ const registration = Symbol(projectPath);
64
+ registrations.set(projectPath, registration);
65
+ return () => {
66
+ if (registrations.get(projectPath) === registration) {
67
+ registrations.delete(projectPath);
68
+ }
69
+ };
70
+ };
71
+ const rawEventSpoolDrainsInFlight = new Map();
72
+
43
73
  // Release an unread response body without surfacing cancellation failures.
44
74
  const discardResponseBody = (response) => {
45
75
  try {
@@ -354,10 +384,74 @@ const clearCompatCheckCache = () => {
354
384
  compatCheckCache = null;
355
385
  };
356
386
 
357
- const isStableReleaseVersion = (value) => {
358
- if (typeof value !== "string" || value.length > MAX_UPDATE_VERSION_CHARS) return false;
359
- const match = STABLE_RELEASE_VERSION.exec(value);
360
- return Boolean(match && match.slice(1, 4).map(Number).every(Number.isSafeInteger));
387
+ const parseReleaseVersion = (value) => {
388
+ if (typeof value !== "string" || value.length > MAX_UPDATE_VERSION_CHARS) return null;
389
+ const match = RELEASE_VERSION.exec(value);
390
+ if (!match || !match.slice(1, 4).map(Number).every(Number.isSafeInteger)) return null;
391
+ const prerelease = match[4]?.split(".") || [];
392
+ if (prerelease.some((identifier) => /^0\d+$/.test(identifier))) return null;
393
+ return { core: match.slice(1, 4).map(Number), prerelease };
394
+ };
395
+
396
+ const releaseChannelForVersion = (value) => {
397
+ const parsed = parseReleaseVersion(value);
398
+ if (!parsed) return null;
399
+ if (parsed.prerelease.length === 0) return "latest";
400
+ const channel = parsed.prerelease[0];
401
+ return channel === "alpha" || channel === "beta" || channel === "rc" ? channel : null;
402
+ };
403
+
404
+ const comparePrereleaseIdentifier = (left, right) => {
405
+ if (left === undefined) return right === undefined ? 0 : -1;
406
+ if (right === undefined) return 1;
407
+ if (left === right) return 0;
408
+ const leftNumeric = /^\d+$/.test(left);
409
+ const rightNumeric = /^\d+$/.test(right);
410
+ if (leftNumeric && rightNumeric && left.length !== right.length) {
411
+ return Math.sign(left.length - right.length);
412
+ }
413
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
414
+ return left < right ? -1 : 1;
415
+ };
416
+
417
+ const compareReleaseVersions = (left, right) => {
418
+ const leftVersion = parseReleaseVersion(left);
419
+ const rightVersion = parseReleaseVersion(right);
420
+ if (!leftVersion || !rightVersion) return null;
421
+ for (let index = 0; index < leftVersion.core.length; index += 1) {
422
+ const difference = leftVersion.core[index] - rightVersion.core[index];
423
+ if (difference !== 0) return Math.sign(difference);
424
+ }
425
+ if (leftVersion.prerelease.length === 0) {
426
+ return rightVersion.prerelease.length === 0 ? 0 : 1;
427
+ }
428
+ if (rightVersion.prerelease.length === 0) return -1;
429
+ const length = Math.max(leftVersion.prerelease.length, rightVersion.prerelease.length);
430
+ for (let index = 0; index < length; index += 1) {
431
+ const comparison = comparePrereleaseIdentifier(
432
+ leftVersion.prerelease[index],
433
+ rightVersion.prerelease[index],
434
+ );
435
+ if (comparison !== 0) return comparison;
436
+ }
437
+ return 0;
438
+ };
439
+
440
+ const isSameChannelVersionAtLeast = (currentVersion, minimumVersion) => {
441
+ const currentChannel = releaseChannelForVersion(currentVersion);
442
+ if (!currentChannel || currentChannel !== releaseChannelForVersion(minimumVersion)) return false;
443
+ const comparison = compareReleaseVersions(currentVersion, minimumVersion);
444
+ return comparison !== null && comparison >= 0;
445
+ };
446
+
447
+ const PINNED_RELEASE_CHANNEL = releaseChannelForVersion(PINNED_BACKEND_VERSION);
448
+
449
+ const guidanceMatchesRelease = (guidance, latestVersion) => {
450
+ const versions =
451
+ String(guidance || "").match(
452
+ /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?/g,
453
+ ) || [];
454
+ return versions.length === 0 || versions.every((version) => version === latestVersion);
361
455
  };
362
456
 
363
457
  const parseReleaseNotification = (result) => {
@@ -368,18 +462,24 @@ const parseReleaseNotification = (result) => {
368
462
  const status = JSON.parse(stdout);
369
463
  if (!status || typeof status !== "object" || Array.isArray(status)) return null;
370
464
  if (status.update_available !== true) return null;
371
- if (!isStableReleaseVersion(status.latest_version)) {
465
+ if (
466
+ !PINNED_RELEASE_CHANNEL
467
+ || status.channel !== PINNED_RELEASE_CHANNEL
468
+ || releaseChannelForVersion(status.latest_version) !== PINNED_RELEASE_CHANNEL
469
+ ) {
372
470
  return null;
373
471
  }
374
472
  if (
375
473
  typeof status.recommended_action !== "string"
376
474
  || !status.recommended_action.trim()
377
475
  || status.recommended_action.length > MAX_UPDATE_ACTION_CHARS
476
+ || !guidanceMatchesRelease(status.recommended_action, status.latest_version)
378
477
  ) {
379
478
  return null;
380
479
  }
381
480
  return {
382
481
  latestVersion: status.latest_version,
482
+ channel: PINNED_RELEASE_CHANNEL,
383
483
  recommendedAction: status.recommended_action.trim(),
384
484
  autoUpdateEligible: status.auto_update_eligible === true,
385
485
  };
@@ -691,7 +791,7 @@ const parsePackOutput = (result) => {
691
791
  ? Number(metrics.total_items)
692
792
  : null;
693
793
  const candidatePackText = succeeded && itemCount !== 0
694
- ? String(payload?.pack_text || "").trim()
794
+ ? String(payload?.pack_text || "")
695
795
  : "";
696
796
  return {
697
797
  conflictPackText: ledgerConflict ? candidatePackText : "",
@@ -738,6 +838,7 @@ const isValidLedgerFailureHttpPayload = (payload) =>
738
838
  && (
739
839
  payload.errorCode === "retrieval_ledger_write_failed"
740
840
  || payload.errorCode === "retrieval_ledger_delivery_write_failed"
841
+ || payload.errorCode === "automatic_recall_write_failed"
741
842
  )
742
843
  && typeof payload.reason === "string";
743
844
 
@@ -761,6 +862,20 @@ const isViewerInvalidRequestPayload = (payload) =>
761
862
  && isRecord(payload.error)
762
863
  && payload.error.code === "invalid_request";
763
864
 
865
+ const classifyRawEventViewerFailure = (payload) => {
866
+ if (isViewerDbMismatchPayload(payload)) return "database";
867
+ if (isViewerIdentityMismatchPayload(payload)) return "identity";
868
+ if (isViewerContractUnsupportedPayload(payload)) return "contract";
869
+ return "connection";
870
+ };
871
+
872
+ const RAW_EVENT_FAILURE_ACTIONS = Object.freeze({
873
+ database: "restart the viewer from the same workspace/config",
874
+ identity: "restart Codemem and OpenCode with the same environment",
875
+ contract: "update Codemem on the installed channel, then restart OpenCode",
876
+ connection: "check or restart the viewer",
877
+ });
878
+
764
879
  // Dependency-free port of @codemem/core prompt-transport semantics. Keep the
765
880
  // range and classifier parity pinned by the plugin injection tests.
766
881
  const PROMPT_TRANSPORT_PROTOCOL_RANGE = Object.freeze({
@@ -815,9 +930,10 @@ const isViewerPolicyOrAuthFailurePayload = (payload) => {
815
930
  ].includes(code);
816
931
  };
817
932
 
818
- const viewerFailureClassification = (cause, disposition) => ({
933
+ const viewerFailureClassification = (cause, disposition, kind = null) => ({
819
934
  cause,
820
935
  disposition,
936
+ kind,
821
937
  retryable: disposition !== "terminal",
822
938
  });
823
939
 
@@ -854,6 +970,7 @@ const classifyViewerHttpFailure = ({
854
970
  kind: "viewer_contract_unsupported",
855
971
  compatibleProfile,
856
972
  }),
973
+ "viewer_contract_unsupported",
857
974
  );
858
975
  }
859
976
  if (operation === "prompt-pack-ledger" && isValidLedgerFailureHttpPayload(body)) {
@@ -906,6 +1023,7 @@ const buildPackHttpBody = ({
906
1023
  dbPath,
907
1024
  identityTarget,
908
1025
  attempt,
1026
+ automaticContext,
909
1027
  }) => ({
910
1028
  context: query,
911
1029
  limit: injectLimit !== null && Number.isFinite(injectLimit) && injectLimit > 0
@@ -926,6 +1044,7 @@ const buildPackHttpBody = ({
926
1044
  .map((value) => String(value || "").trim())
927
1045
  .filter((value) => value && value.length <= MAX_WORKING_SET_PATH_CHARS),
928
1046
  attempt,
1047
+ ...(automaticContext !== undefined ? { automatic_context: automaticContext } : {}),
929
1048
  });
930
1049
 
931
1050
  const canonicalJson = (value) => {
@@ -1114,16 +1233,11 @@ const getSessionMessageInjectionCache = (messageInjectionCache, sessionID) => {
1114
1233
  return sessionCache;
1115
1234
  };
1116
1235
 
1117
- const trimSessionMessageInjectionCache = (sessionCache) => {
1118
- if (!sessionCache) return;
1119
- while (sessionCache.size > MAX_MESSAGE_INJECTION_CACHE_MESSAGES) {
1120
- const oldestKey = sessionCache.keys().next().value;
1121
- if (!oldestKey) break;
1122
- sessionCache.delete(oldestKey);
1236
+ const normalizeInjectedMessageParts = (messages, sessionCache, { pruneAbsent = true } = {}) => {
1237
+ const presentIds = new Set(messages.map(resolveEntryMessageId).filter(Boolean));
1238
+ for (const id of sessionCache?.keys() || []) {
1239
+ if (pruneAbsent && !presentIds.has(id)) sessionCache.delete(id);
1123
1240
  }
1124
- };
1125
-
1126
- const normalizeInjectedMessageParts = (messages, sessionCache) => {
1127
1241
  for (let index = 0; index < messages.length; index += 1) {
1128
1242
  const entry = messages[index];
1129
1243
  if (!isUserMessageEntry(entry) || !Array.isArray(entry.parts)) {
@@ -1131,24 +1245,19 @@ const normalizeInjectedMessageParts = (messages, sessionCache) => {
1131
1245
  }
1132
1246
 
1133
1247
  const messageId = resolveEntryMessageId(entry);
1134
- const retainedParts = [];
1135
- let existingText = null;
1136
- for (const part of entry.parts) {
1137
- if (isCodememContextPart(part)) {
1138
- const text = String(part?.text || "");
1139
- if (text.trim()) {
1140
- existingText = text;
1141
- }
1142
- } else {
1143
- retainedParts.push(part);
1144
- }
1145
- }
1146
- if (existingText && sessionCache && messageId && !sessionCache.has(messageId)) {
1147
- sessionCache.set(messageId, { text: existingText, attemptId: null, reconstructed: true });
1148
- trimSessionMessageInjectionCache(sessionCache);
1149
- }
1150
- if (retainedParts.length !== entry.parts.length) {
1151
- entry.parts.splice(0, entry.parts.length, ...retainedParts);
1248
+ const parts = entry.parts.filter(isCodememContextPart);
1249
+ if (parts.length && sessionCache && messageId) {
1250
+ const cached = sessionCache.get(messageId);
1251
+ sessionCache.set(messageId, {
1252
+ ...cached,
1253
+ text: parts[0].text,
1254
+ parts: parts.map((part) => {
1255
+ const recall = retainedPartRecall(part, cached?.parts);
1256
+ return recall
1257
+ ? { ...part, metadata: { ...part.metadata, codemem: recall } }
1258
+ : { ...part };
1259
+ }),
1260
+ });
1152
1261
  }
1153
1262
  }
1154
1263
  };
@@ -1163,6 +1272,7 @@ const buildInjectedContextPart = (entry, index, text, options = {}) => {
1163
1272
  type: "text",
1164
1273
  text,
1165
1274
  synthetic: true,
1275
+ ...(options.recall ? { metadata: { codemem: options.recall } } : {}),
1166
1276
  };
1167
1277
  };
1168
1278
 
@@ -1203,7 +1313,10 @@ const appendCachedInjectedContextParts = async (
1203
1313
  cacheReuseReady = typeof reuse === "object" ? reuse?.ready : null;
1204
1314
  }
1205
1315
  try {
1206
- entry.parts.push(buildInjectedContextPart(entry, index, text, { messageId, sessionID }));
1316
+ if (!entry.parts.some(isCodememContextPart)) {
1317
+ const parts = cached?.parts || [buildInjectedContextPart(entry, index, text, { messageId, sessionID })];
1318
+ entry.parts.push(...parts.map((part) => ({ ...part })));
1319
+ }
1207
1320
  } catch (error) {
1208
1321
  if (messageId === options.latestMessageId && attemptId && options.confirmDelivery) {
1209
1322
  if (cacheReuseReady) {
@@ -1230,6 +1343,11 @@ const appendCachedInjectedContextParts = async (
1230
1343
  return appended;
1231
1344
  };
1232
1345
 
1346
+ const scopeMessagesToSession = (messages, sessionID) => messages.filter((entry) => {
1347
+ const entrySession = resolveEntrySessionID(entry);
1348
+ return !entrySession || !sessionID || entrySession === sessionID;
1349
+ });
1350
+
1233
1351
  const applyInjectedContextToMessages = async ({
1234
1352
  injectEnabled,
1235
1353
  input,
@@ -1243,40 +1361,82 @@ const applyInjectedContextToMessages = async ({
1243
1361
  confirmDelivery,
1244
1362
  recordCacheReuse,
1245
1363
  recordSkipped,
1364
+ injectTokenBudget = DEFAULT_INJECT_TOKEN_BUDGET,
1365
+ retainedTokenBudget = DEFAULT_RETAINED_TOKEN_BUDGET,
1366
+ workingSet = [],
1367
+ recordMeasurement,
1246
1368
  }) => {
1247
1369
  const hasMessages = Array.isArray(output?.messages);
1248
- const latestUser = hasMessages ? findLatestUserMessage(output.messages) : null;
1249
- const sessionID = latestUser
1250
- ? resolveEntrySessionID(latestUser.entry) || input?.sessionID || null
1251
- : input?.sessionID || null;
1370
+ const hookSessionID = input?.sessionID || null;
1371
+ const hookMessages = hasMessages ? scopeMessagesToSession(output.messages, hookSessionID) : [];
1372
+ const latestUser = findLatestUserMessage(hookMessages);
1373
+ const sessionID = hookSessionID || (latestUser ? resolveEntrySessionID(latestUser.entry) : null);
1374
+ // Both transforms use the hook session when an entry omits session identity.
1375
+ const messages = scopeMessagesToSession(hookMessages, sessionID);
1376
+ const report = (reason, newTokens = 0, duplicates = 0) => {
1377
+ const measurement = {
1378
+ new_tokens: newTokens,
1379
+ retained_tokens: countRetainedInjectionTokens(messages),
1380
+ duplicates_omitted: duplicates,
1381
+ reason,
1382
+ };
1383
+ try { void Promise.resolve(recordMeasurement?.(measurement)).catch(() => {}); } catch { /* best effort */ }
1384
+ };
1252
1385
  if (!injectEnabled) {
1253
1386
  recordSkipped?.("injection_disabled", sessionID);
1387
+ report("injection_disabled");
1254
1388
  return false;
1255
1389
  }
1256
1390
  if (!hasMessages) {
1391
+ report("missing_history");
1257
1392
  return false;
1258
1393
  }
1259
-
1260
1394
  if (consumeCompactionInjectionSkip(compactionInjectionSkips, sessionID)) {
1261
- normalizeInjectedMessageParts(output.messages, null);
1395
+ const cache = getSessionMessageInjectionCache(messageInjectionCache, sessionID);
1396
+ normalizeInjectedMessageParts(messages, cache, { pruneAbsent: false });
1262
1397
  recordSkipped?.("compaction_skipped", sessionID);
1398
+ report("compaction_skipped");
1263
1399
  return false;
1264
1400
  }
1265
1401
 
1266
1402
  if (!latestUser) {
1403
+ report("missing_user");
1267
1404
  return false;
1268
1405
  }
1269
1406
 
1270
1407
  const sessionCache = getSessionMessageInjectionCache(messageInjectionCache, sessionID);
1271
- normalizeInjectedMessageParts(output.messages, sessionCache);
1408
+ normalizeInjectedMessageParts(messages, sessionCache);
1272
1409
 
1273
1410
  const latestMessageId = resolveEntryMessageId(latestUser.entry);
1274
1411
  const canReplay = Boolean(sessionCache && latestMessageId);
1275
1412
  const latestCached = canReplay ? sessionCache.get(latestMessageId) : null;
1276
- const latestWasCached = Boolean(latestCached) && latestCached?.reconstructed !== true;
1413
+ const latestWasCached = Boolean(latestCached) || latestUser.entry.parts?.some(isCodememContextPart);
1414
+ if (sessionCache) {
1415
+ try {
1416
+ await appendCachedInjectedContextParts(messages, sessionCache, sessionID, {
1417
+ latestMessageId, confirmDelivery, recordCacheReuse,
1418
+ });
1419
+ } catch (error) {
1420
+ report("delivery_failed");
1421
+ throw error;
1422
+ }
1423
+ }
1424
+ const retainedTokens = countRetainedInjectionTokens(messages);
1425
+ const fullBudget = Math.min(injectTokenBudget, Math.max(0, retainedTokenBudget - retainedTokens));
1277
1426
  let latestWasBuilt = false;
1427
+ let newlyDelivered = 0;
1428
+ let duplicates = 0;
1429
+ let reason = latestWasCached ? "replay" : "no_context";
1278
1430
  if (!latestWasCached) {
1279
- const firstUser = findFirstUserMessage(output.messages);
1431
+ if (reserveContextPrefixBudget(fullBudget) === null) {
1432
+ if (reserveContextPrefixBudget(injectTokenBudget) !== null) {
1433
+ recordSkipped?.("allowance_exhausted", sessionID);
1434
+ }
1435
+ report("allowance_exhausted");
1436
+ return retainedTokens > 0;
1437
+ }
1438
+ const firstUser = findFirstUserMessage(messages);
1439
+ const workingContext = workingContextDigest(messages, workingSet);
1280
1440
  const query = resolveInjectQuery({
1281
1441
  firstPrompt: firstUser ? extractMessageText(firstUser.entry) : null,
1282
1442
  lastPromptText: extractMessageText(latestUser.entry),
@@ -1284,36 +1444,62 @@ const applyInjectedContextToMessages = async ({
1284
1444
  const injected = await buildInjectedContext(query, {
1285
1445
  sessionID,
1286
1446
  requestKey: latestMessageId || fallbackEntryMessageId(latestUser.entry, latestUser.index),
1447
+ fallbackTurn: sessionID && latestMessageId ? null : latestUser.index,
1287
1448
  surface: "message",
1449
+ tokenBudget: fullBudget,
1450
+ retainedItems: retainedMemoryFingerprints(messages, sessionCache),
1451
+ retainedMetadata: retainedMetadataGaps(messages, sessionCache),
1452
+ workingContext,
1453
+ continuationOnly: isContinuationOnly(extractMessageText(latestUser.entry), messages, workingContext, sessionCache),
1288
1454
  });
1289
- if (injected?.text) {
1455
+ // Another transform may have delivered while retrieval was awaiting I/O.
1456
+ if (sessionCache) {
1457
+ await appendCachedInjectedContextParts(messages, sessionCache, sessionID, {
1458
+ latestMessageId, confirmDelivery, recordCacheReuse,
1459
+ });
1460
+ }
1461
+ if (latestUser.entry.parts?.some(isCodememContextPart)) {
1462
+ duplicates = injected?.duplicates || 0;
1463
+ if (injected?.attemptId) {
1464
+ confirmDelivery?.(injected.attemptId, "unknown", injected.evaluation);
1465
+ }
1466
+ report("replay", 0, duplicates);
1467
+ return true;
1468
+ }
1469
+ const deliveryBudget = Math.min(fullBudget, Math.max(
1470
+ 0, retainedTokenBudget - countRetainedInjectionTokens(messages),
1471
+ ));
1472
+ duplicates = injected?.duplicates || 0;
1473
+ reason = injected?.skipReason || "no_context";
1474
+ if (injected?.text && estimateTokens(injected.text) <= deliveryBudget) {
1290
1475
  latestWasBuilt = true;
1291
- if (canReplay) {
1292
- sessionCache.set(latestMessageId, {
1293
- text: injected.text,
1294
- attemptId: injected.attemptId || null,
1295
- requestId: injected.requestId || null,
1296
- queryHash: injected.queryHash || null,
1297
- promptNumber: injected.promptNumber || 0,
1298
- reuseCount: 0,
1299
- });
1300
- trimSessionMessageInjectionCache(sessionCache);
1301
- } else if (Array.isArray(latestUser.entry.parts)) {
1476
+ if (Array.isArray(latestUser.entry.parts)) {
1302
1477
  try {
1303
1478
  latestUser.entry.parts.push(
1304
1479
  buildInjectedContextPart(latestUser.entry, latestUser.index, injected.text, {
1305
1480
  messageId: latestMessageId || undefined,
1306
1481
  sessionID: sessionID || undefined,
1482
+ recall: injected.recall,
1307
1483
  })
1308
1484
  );
1309
1485
  } catch (error) {
1310
1486
  if (injected.attemptId) {
1311
- confirmDelivery?.(injected.attemptId, "failed");
1487
+ confirmDelivery?.(injected.attemptId, "failed", injected.evaluation);
1312
1488
  }
1489
+ report("delivery_failed", 0, duplicates);
1313
1490
  throw error;
1314
1491
  }
1492
+ newlyDelivered = estimateTokens(injected.text);
1493
+ reason = "delivered";
1315
1494
  if (injected.attemptId) {
1316
- confirmDelivery?.(injected.attemptId);
1495
+ confirmDelivery?.(injected.attemptId, "handed_off", injected.evaluation);
1496
+ }
1497
+ if (canReplay) {
1498
+ sessionCache.set(latestMessageId, {
1499
+ ...injected,
1500
+ parts: latestUser.entry.parts.filter(isCodememContextPart).map((part) => ({ ...part })),
1501
+ reuseCount: 0,
1502
+ });
1317
1503
  }
1318
1504
  }
1319
1505
 
@@ -1326,10 +1512,15 @@ const applyInjectedContextToMessages = async ({
1326
1512
  // best-effort only
1327
1513
  }
1328
1514
  }
1329
- } else if (canReplay && latestCached?.reconstructed === true) {
1330
- sessionCache.delete(latestMessageId);
1515
+ } else if (injected?.skipReason) {
1516
+ // The retrieval already has an attempt. Withheld output is not a second retrieval.
1517
+ if (injected.attemptId) confirmDelivery?.(injected.attemptId, "unknown", injected.evaluation);
1518
+ } else if (injected?.text) {
1519
+ reason = "budget_rejected";
1520
+ if (injected.attemptId) confirmDelivery?.(injected.attemptId, "failed", injected.evaluation);
1331
1521
  }
1332
1522
  }
1523
+ report(reason, newlyDelivered, duplicates);
1333
1524
 
1334
1525
  if (!canReplay) {
1335
1526
  return Boolean(
@@ -1338,13 +1529,136 @@ const applyInjectedContextToMessages = async ({
1338
1529
  );
1339
1530
  }
1340
1531
 
1341
- const appended = await appendCachedInjectedContextParts(output.messages, sessionCache, sessionID, {
1342
- latestMessageId,
1343
- latestWasBuilt,
1344
- confirmDelivery,
1345
- recordCacheReuse,
1346
- });
1347
- return appended > 0;
1532
+ return latestWasBuilt || retainedTokens > 0;
1533
+ };
1534
+
1535
+ const countRetainedInjectionTokens = (messages) => messages.reduce(
1536
+ (sum, entry) => sum + (entry.parts || []).filter(isCodememContextPart)
1537
+ .reduce((tokens, part) => tokens + estimateTokens(part.text), 0),
1538
+ 0,
1539
+ );
1540
+
1541
+ const injectionDigest = (text) => createHash("sha256").update(text).digest("hex");
1542
+
1543
+ const retainedPartRecall = (part, cachedParts = []) => {
1544
+ const valid = (candidate) => candidate?.metadata?.codemem?.v === 1
1545
+ && candidate.metadata.codemem.digest === injectionDigest(String(part.text || ""));
1546
+ if (valid(part)) return part.metadata.codemem;
1547
+ const cached = cachedParts.find((candidate) => candidate.id === part.id && candidate.text === part.text && valid(candidate));
1548
+ return cached?.metadata?.codemem;
1549
+ };
1550
+
1551
+ const workingContextDigest = (messages, workingSet) => {
1552
+ const scalar = (value) => typeof value === "string" ? value.slice(0, 256) : null;
1553
+ const hash = createHash("sha256");
1554
+ for (const path of [...workingSet].filter((value) => typeof value === "string").sort()) {
1555
+ hash.update(JSON.stringify(["file", path.slice(0, MAX_WORKING_SET_PATH_CHARS)]));
1556
+ }
1557
+ for (const entry of messages) {
1558
+ for (const part of entry.parts || []) {
1559
+ if (part.type === "text") continue;
1560
+ // Never traverse args, output, attachments, or arbitrary tool-state payloads.
1561
+ hash.update(JSON.stringify([
1562
+ scalar(entry.info?.id), scalar(part.type), scalar(part.id),
1563
+ scalar(part.callID), scalar(part.state?.status),
1564
+ ]));
1565
+ }
1566
+ }
1567
+ return hash.digest("hex");
1568
+ };
1569
+
1570
+ const isContinuationOnly = (prompt, messages, workingContext, sessionCache) => {
1571
+ if (!/^(continue|proceed|go on|keep going)[.!]?$/i.test(prompt.trim())) return false;
1572
+ const latest = findLatestUserMessage(messages);
1573
+ for (let index = (latest?.index ?? 0) - 1; index >= 0; index -= 1) {
1574
+ const entry = messages[index];
1575
+ if (!isUserMessageEntry(entry)) continue;
1576
+ return (entry.parts || []).some((part) => {
1577
+ const recall = retainedPartRecall(part, sessionCache?.get(resolveEntryMessageId(entry))?.parts);
1578
+ return isCodememContextPart(part) && recall?.v === 1
1579
+ && recall.digest === injectionDigest(String(part.text || ""))
1580
+ && recall.workingContext === workingContext;
1581
+ });
1582
+ }
1583
+ return false;
1584
+ };
1585
+
1586
+ const retainedMemoryFingerprints = (messages, sessionCache) => {
1587
+ const retained = new Set();
1588
+ for (const entry of messages) {
1589
+ for (const part of (entry.parts || []).filter(isCodememContextPart)) {
1590
+ const recall = retainedPartRecall(part, sessionCache?.get(resolveEntryMessageId(entry))?.parts);
1591
+ if (recall?.v !== 1 || recall.digest !== injectionDigest(String(part.text || ""))) continue;
1592
+ for (const item of Array.isArray(recall.items) ? recall.items : []) {
1593
+ if (isRecord(item) && Number.isSafeInteger(item.id) && item.id > 0 && /^[a-f0-9]{64}$/.test(item.fingerprint)) {
1594
+ retained.add(`${item.id}:${item.fingerprint}`);
1595
+ }
1596
+ }
1597
+ }
1598
+ }
1599
+ return retained;
1600
+ };
1601
+
1602
+ const filterRetainedPack = (stdout, packText, retained = new Set()) => {
1603
+ const fallback = { text: packText, items: [], duplicates: 0, packMetadata: "missing" };
1604
+ let payload;
1605
+ try { payload = JSON.parse(stdout); } catch { return fallback; }
1606
+ const items = payload?.rendered_items;
1607
+ if (items != null) fallback.packMetadata = "invalid";
1608
+ // Old transports and malformed metadata remain eligible rather than guessing from prose.
1609
+ if (payload?.pack_text !== packText || !Array.isArray(items) || !items.length) return fallback;
1610
+ if (payload.metrics?.total_items !== items.length) return fallback;
1611
+ const spans = [];
1612
+ const seen = new Set();
1613
+ for (const item of items) {
1614
+ if (!isRecord(item) || !Number.isSafeInteger(item.id) || item.id <= 0 || seen.has(item.id)
1615
+ || !/^[a-f0-9]{64}$/.test(item.fingerprint) || !Array.isArray(item.spans) || !item.spans.length) return fallback;
1616
+ seen.add(item.id);
1617
+ for (const span of item.spans) {
1618
+ if (!isRecord(span) || !Number.isSafeInteger(span.start) || !Number.isSafeInteger(span.end)
1619
+ || span.start < 0 || span.end <= span.start || span.end > packText.length) return fallback;
1620
+ spans.push({ ...span, omit: retained.has(`${item.id}:${item.fingerprint}`) });
1621
+ }
1622
+ }
1623
+ if (payload.items != null) {
1624
+ if (!Array.isArray(payload.items) || payload.items.length !== seen.size) return fallback;
1625
+ const selectedIds = new Set(payload.items.map((item) => item?.id));
1626
+ if (selectedIds.size !== seen.size || [...selectedIds].some((id) => !seen.has(id))) return fallback;
1627
+ }
1628
+ for (const ids of [payload.item_ids, payload.metrics?.pack_item_ids]) {
1629
+ if (ids == null) continue;
1630
+ if (!Array.isArray(ids) || new Set(ids).size !== ids.length
1631
+ || ids.some((id) => !Number.isSafeInteger(id) || id <= 0)
1632
+ || [...seen].some((id) => !ids.includes(id))) return fallback;
1633
+ }
1634
+ spans.sort((a, b) => a.start - b.start);
1635
+ if (spans.some((span, index) => index > 0 && span.start < spans[index - 1].end)) return fallback;
1636
+ const kept = items.filter((item) => !retained.has(`${item.id}:${item.fingerprint}`));
1637
+ let text = packText;
1638
+ for (const span of spans.reverse()) {
1639
+ if (span.omit) text = text.slice(0, span.start) + text.slice(span.end);
1640
+ }
1641
+ return {
1642
+ text: kept.length ? text : "",
1643
+ items: kept.map(({ id, fingerprint }) => ({ id, fingerprint })),
1644
+ duplicates: items.length - kept.length,
1645
+ packMetadata: "valid",
1646
+ };
1647
+ };
1648
+
1649
+ const retainedMetadataGaps = (messages, sessionCache) => {
1650
+ let missingRetainedMetadata = false;
1651
+ let invalidRetainedMetadata = false;
1652
+ for (const entry of messages) {
1653
+ for (const part of (entry.parts || []).filter(isCodememContextPart)) {
1654
+ const recall = retainedPartRecall(part, sessionCache?.get(resolveEntryMessageId(entry))?.parts);
1655
+ if (recall?.v === 1 && Array.isArray(recall.items) && recall.items.length > 0
1656
+ && recall.items.every((item) => isRecord(item) && Number.isSafeInteger(item.id) && item.id > 0 && /^[a-f0-9]{64}$/.test(item.fingerprint))) continue;
1657
+ if (part.metadata?.codemem != null || recall != null) invalidRetainedMetadata = true;
1658
+ else missingRetainedMetadata = true;
1659
+ }
1660
+ }
1661
+ return { missingRetainedMetadata, invalidRetainedMetadata };
1348
1662
  };
1349
1663
 
1350
1664
  const mapOpencodeEventTypeToAdapterType = (eventType) => {
@@ -1594,7 +1908,11 @@ const trimEventQueue = ({ events, maxEvents, hardMaxEvents, onUnsentPressure, on
1594
1908
  hardMaxEvents > 0 &&
1595
1909
  events.length > hardMaxEvents
1596
1910
  ) {
1597
- const dropped = events.shift();
1911
+ const durableIndex = events.findIndex(
1912
+ (queued) => queued && typeof queued === "object" && queued._raw_spooled
1913
+ );
1914
+ const dropIndex = durableIndex >= 0 ? durableIndex : 0;
1915
+ const [dropped] = events.splice(dropIndex, 1);
1598
1916
  if (typeof onForcedDrop === "function") {
1599
1917
  onForcedDrop(dropped, events.length, hardMaxEvents);
1600
1918
  }
@@ -1651,6 +1969,25 @@ const parsePositiveInt = (value, fallback) => {
1651
1969
  return parsed;
1652
1970
  };
1653
1971
 
1972
+ const estimateTokens = (text) => Math.ceil(String(text || "").length / 4);
1973
+
1974
+ const resolveInjectTokenBudget = (value) =>
1975
+ parsePositiveInt(value, DEFAULT_INJECT_TOKEN_BUDGET);
1976
+
1977
+ const resolveRetainedTokenBudget = (value) => {
1978
+ const text = String(value ?? "").trim();
1979
+ const parsed = Number(text);
1980
+ return /^\d+$/.test(text) && Number.isSafeInteger(parsed) && parsed > 0
1981
+ ? parsed : DEFAULT_RETAINED_TOKEN_BUDGET;
1982
+ };
1983
+
1984
+ const reserveContextPrefixBudget = (tokenBudget) => {
1985
+ const packBudget = tokenBudget - estimateTokens(CODEMEM_CONTEXT_PREFIX);
1986
+ return packBudget > 0 ? packBudget : null;
1987
+ };
1988
+
1989
+ const wrapInjectedContext = (packText) => `${CODEMEM_CONTEXT_PREFIX}${packText}`;
1990
+
1654
1991
  export const buildInjectionToastMessage = (metrics) => {
1655
1992
  const items = asFiniteNonNegativeInt(metrics?.items) ?? asFiniteNonNegativeInt(metrics?.total_items);
1656
1993
  const packTokens = asFiniteNonNegativeInt(metrics?.pack_tokens);
@@ -1693,22 +2030,10 @@ const detectRunner = ({ cwd, envRunner }) => {
1693
2030
  // terminal on every OpenCode startup for npx-only installs.
1694
2031
  stdio: ["ignore", "pipe", "ignore"],
1695
2032
  }).trim();
1696
- // Accept any global codemem at or above the pinned backend version, so a
1697
- // newer global install is used instead of silently downgrading to
1698
- // `npx codemem@<pin>` (the old check only matched the exact pin or the
1699
- // long-obsolete 0.2x series).
1700
- // Accept a global codemem only when it is the EXACT pinned version, or a
1701
- // clean (non-prerelease) release at least as new as the pin. isVersionAtLeast
1702
- // ignores prerelease suffixes, so without this a prerelease like
1703
- // "0.36.1-alpha.0" would wrongly compare >= the "0.36.1" release even though
1704
- // SemVer orders it below. Requiring an exact match for prereleases also
1705
- // handles a prerelease PIN correctly (the release script can set one): only
1706
- // the identical prerelease is trusted, never an older/different one.
1707
- const isPrerelease = versionOutput.includes("-");
1708
- const matchesOrCleanNewer =
1709
- versionOutput === PINNED_BACKEND_VERSION ||
1710
- (!isPrerelease && isVersionAtLeast(versionOutput, PINNED_BACKEND_VERSION));
1711
- if (matchesOrCleanNewer) {
2033
+ // Use a newer global CLI only when it remains on the plugin's release
2034
+ // channel. This prevents both channel crossing and fallback to an older pin
2035
+ // immediately after a successful prerelease auto-update.
2036
+ if (isSameChannelVersionAtLeast(versionOutput, PINNED_BACKEND_VERSION)) {
1712
2037
  return "codemem";
1713
2038
  }
1714
2039
  } catch {
@@ -1822,6 +2147,13 @@ export const CodememPlugin = async ({
1822
2147
  if (pluginIgnored) {
1823
2148
  return {};
1824
2149
  }
2150
+ const releasePluginRegistration = claimPluginRegistration(cwd);
2151
+ if (!releasePluginRegistration) {
2152
+ await log("warn", "codemem duplicate plugin registration skipped", {
2153
+ next_action: "remove either the configured npm plugin or the project-local Codemem plugin",
2154
+ });
2155
+ return {};
2156
+ }
1825
2157
 
1826
2158
  const runner = detectRunner({
1827
2159
  cwd,
@@ -1871,15 +2203,30 @@ export const CodememPlugin = async ({
1871
2203
  process.env.CODEMEM_INJECT_CONTEXT || "1"
1872
2204
  );
1873
2205
  const injectSurface = resolveInjectSurface(process.env.CODEMEM_INJECT_SURFACE);
1874
- // Only use env overrides if explicitly set; otherwise CLI uses config defaults
2206
+ // The item limit remains optional so the CLI can use its configured default.
1875
2207
  const injectLimitEnv = process.env.CODEMEM_INJECT_LIMIT;
1876
2208
  const injectLimit = injectLimitEnv ? parseNumber(injectLimitEnv, null) : null;
1877
- const injectTokenBudgetEnv = process.env.CODEMEM_INJECT_TOKEN_BUDGET;
1878
- const injectTokenBudget = injectTokenBudgetEnv ? parseNumber(injectTokenBudgetEnv, null) : null;
2209
+ const injectTokenBudget = resolveInjectTokenBudget(
2210
+ process.env.CODEMEM_INJECT_TOKEN_BUDGET
2211
+ );
2212
+ const injectPackTokenBudget = reserveContextPrefixBudget(injectTokenBudget);
2213
+ const retainedTokenBudget = resolveRetainedTokenBudget(process.env.CODEMEM_INJECT_RETAINED_TOKEN_BUDGET);
2214
+ if (injectEnabled && injectPackTokenBudget === null) {
2215
+ await log(
2216
+ "warn",
2217
+ "codemem context injection skipped: token budget is too small",
2218
+ {
2219
+ inject_token_budget: injectTokenBudget,
2220
+ required_prefix_tokens: estimateTokens(CODEMEM_CONTEXT_PREFIX),
2221
+ next_action: "increase CODEMEM_INJECT_TOKEN_BUDGET or unset it to use the default",
2222
+ }
2223
+ );
2224
+ }
1879
2225
  const injectionToastShown = new Set();
1880
2226
  const messageInjectionCache = new Map();
1881
2227
  const compactionInjectionSkips = new Map();
1882
2228
  const disabledInjectionRecorded = new Set();
2229
+ const latestPolicySkips = new Map();
1883
2230
  const attemptStartedAt = new Map();
1884
2231
  const promptPackRetryCounts = new Map();
1885
2232
  const successfulPromptPackArtifacts = new Map();
@@ -1923,9 +2270,22 @@ export const CodememPlugin = async ({
1923
2270
  process.env.CODEMEM_RAW_EVENTS_HARD_MAX || "2000",
1924
2271
  2000
1925
2272
  );
2273
+ const rawEventSpoolDrainLimit = parsePositiveInt(
2274
+ process.env.CODEMEM_RAW_EVENT_SPOOL_DRAIN_LIMIT,
2275
+ DEFAULT_DRAIN_LIMIT,
2276
+ );
2277
+ const rawEventSpoolMaxEntries = parsePositiveInt(
2278
+ process.env.CODEMEM_RAW_EVENT_SPOOL_MAX_ENTRIES,
2279
+ DEFAULT_MAX_ENTRIES,
2280
+ );
2281
+ const rawEventSpoolHome = process.env.HOME?.trim() || homedir();
2282
+ const rawEventSpoolDirectory = resolveSpoolDirectory(rawEventSpoolHome);
2283
+ // Memoize the exact serialized envelope by queued-object identity so retries
2284
+ // cannot drift in timestamp, ID, property order, or bytes.
2285
+ const rawEventEnvelopes = new WeakMap();
1926
2286
  let streamUnavailableUntil = 0;
1927
- let streamErrorNoted = false;
1928
- let fallbackFailureNoted = false;
2287
+ let spoolPersistenceFailureNoted = null;
2288
+ let spoolLoadFailureNoted = false;
1929
2289
  let lastStatusCheckAt = 0;
1930
2290
  let lastStatusAvailable = true;
1931
2291
  let promptPackTransportUnavailableUntil = 0;
@@ -1937,9 +2297,9 @@ export const CodememPlugin = async ({
1937
2297
  return `${Date.now()}-${Math.random()}`;
1938
2298
  };
1939
2299
 
1940
- const queueRawEventViaCli = async (body) => {
2300
+ const queueRawEventViaCli = async (body, serialized = JSON.stringify(body)) => {
1941
2301
  const runFallback = () => runCli(["enqueue-raw-event"], {
1942
- stdinText: JSON.stringify(body),
2302
+ stdinText: serialized,
1943
2303
  });
1944
2304
  let result = await runFallback();
1945
2305
  let classification = classifyFallbackCommandResult(result);
@@ -1954,23 +2314,191 @@ export const CodememPlugin = async ({
1954
2314
  const error = new Error(
1955
2315
  retryExhausted ? `${classification.cause} after retry` : classification.cause
1956
2316
  );
1957
- error.retryable = classification.retryable && !attemptedRetry;
2317
+ error.retryable = classification.retryable;
1958
2318
  throw error;
1959
2319
  }
1960
2320
  return true;
1961
2321
  };
1962
2322
 
1963
2323
  const lastToastAtBySession = new Map();
1964
- const shouldToast = (sessionID) => {
2324
+ const shouldToast = (sessionID, category = "general") => {
1965
2325
  const now = Date.now();
1966
- const last = lastToastAtBySession.get(sessionID) || 0;
2326
+ const key = `${sessionID || "unknown"}:${category}`;
2327
+ const last = lastToastAtBySession.get(key) || 0;
1967
2328
  if (now - last < 60000) {
1968
2329
  return false;
1969
2330
  }
1970
- lastToastAtBySession.set(sessionID, now);
2331
+ lastToastAtBySession.set(key, now);
1971
2332
  return true;
1972
2333
  };
1973
2334
 
2335
+ const warnSpoolPersistenceFailure = async (sessionID, reason) => {
2336
+ const message = reason === "spool_full"
2337
+ ? "codemem raw-event retry spool is full; repair Codemem, then archive retained entries"
2338
+ : "codemem could not save a raw event for retry; it remains queued in memory";
2339
+ try {
2340
+ await client.app.log({
2341
+ service: "codemem",
2342
+ level: "error",
2343
+ message,
2344
+ extra: { category: "persistence", reason },
2345
+ });
2346
+ } catch {
2347
+ // Best-effort app logging only.
2348
+ }
2349
+ if (spoolPersistenceFailureNoted === reason) return;
2350
+ spoolPersistenceFailureNoted = reason;
2351
+ if (!client.tui?.showToast || !shouldToast(sessionID, `persistence:${reason}`)) return;
2352
+ try {
2353
+ await client.tui.showToast({
2354
+ body: { message: `codemem: ${message}`, variant: "error" },
2355
+ });
2356
+ } catch {
2357
+ // Best-effort toast only.
2358
+ }
2359
+ };
2360
+
2361
+ const persistRawEventForRetry = async ({ body, serialized, payload, sessionID }) => {
2362
+ try {
2363
+ await writeRawEventSpoolEntry({
2364
+ envelope: body,
2365
+ serialized,
2366
+ homeDir: rawEventSpoolHome,
2367
+ maxEntries: rawEventSpoolMaxEntries,
2368
+ });
2369
+ spoolPersistenceFailureNoted = null;
2370
+ if (payload && typeof payload === "object") {
2371
+ payload._raw_spooled = true;
2372
+ }
2373
+ return true;
2374
+ } catch (error) {
2375
+ const reason = error?.code === RAW_EVENT_SPOOL_FULL_CODE ? "spool_full" : "write_failed";
2376
+ await logLine(`raw_events.spool.${reason} category=persistence`);
2377
+ await warnSpoolPersistenceFailure(sessionID, reason);
2378
+ return false;
2379
+ }
2380
+ };
2381
+
2382
+ const removeRawEventFromSpool = async ({ eventId, payload }) => {
2383
+ try {
2384
+ await removeRawEventSpoolEntry({ eventId, homeDir: rawEventSpoolHome });
2385
+ if (payload && typeof payload === "object") {
2386
+ payload._raw_spooled = false;
2387
+ }
2388
+ } catch {
2389
+ await logLine("raw_events.spool.cleanup_failed category=persistence");
2390
+ }
2391
+ };
2392
+
2393
+ const notifyRawEventDelivery = async ({ category, delivered, durable, sessionID }) => {
2394
+ const action = RAW_EVENT_FAILURE_ACTIONS[category] || RAW_EVENT_FAILURE_ACTIONS.connection;
2395
+ let outcome = "was saved for retry";
2396
+ if (delivered) {
2397
+ outcome = "was queued via CLI";
2398
+ } else if (!durable) {
2399
+ outcome = "was not saved for retry; still queued in memory";
2400
+ }
2401
+ const message = `codemem raw event ${outcome}; ${action}`;
2402
+ let delivery = "memory";
2403
+ if (delivered) {
2404
+ delivery = "cli";
2405
+ } else if (durable) {
2406
+ delivery = "spool";
2407
+ }
2408
+ try {
2409
+ await client.app.log({
2410
+ service: "codemem",
2411
+ level: delivered ? "warn" : "error",
2412
+ message,
2413
+ extra: {
2414
+ category,
2415
+ delivery,
2416
+ },
2417
+ });
2418
+ } catch {
2419
+ // Best-effort app logging only.
2420
+ }
2421
+ if (!client.tui?.showToast || !shouldToast(sessionID, `${category}:${delivery}`)) return;
2422
+ try {
2423
+ await client.tui.showToast({
2424
+ body: { message: `codemem: ${message}`, variant: delivered ? "warning" : "error" },
2425
+ });
2426
+ } catch {
2427
+ // Best-effort toast only.
2428
+ }
2429
+ };
2430
+
2431
+ const drainRawEventSpool = () => {
2432
+ if (!rawEventsEnabled) {
2433
+ return Promise.resolve();
2434
+ }
2435
+ const existingDrain = rawEventSpoolDrainsInFlight.get(rawEventSpoolDirectory);
2436
+ if (existingDrain) {
2437
+ return existingDrain;
2438
+ }
2439
+ const drainPromise = (async () => {
2440
+ let loaded;
2441
+ try {
2442
+ loaded = await loadRawEventSpoolEntries({
2443
+ homeDir: rawEventSpoolHome,
2444
+ limit: rawEventSpoolDrainLimit,
2445
+ });
2446
+ } catch {
2447
+ await logLine("raw_events.spool.load_failed category=persistence");
2448
+ const message = "codemem could not read saved raw events; spool entries were left untouched";
2449
+ try {
2450
+ await client.app.log({
2451
+ service: "codemem",
2452
+ level: "error",
2453
+ message,
2454
+ extra: { category: "persistence", delivery: "load" },
2455
+ });
2456
+ } catch {
2457
+ // Best-effort app logging only.
2458
+ }
2459
+ if (!spoolLoadFailureNoted) {
2460
+ spoolLoadFailureNoted = true;
2461
+ if (client.tui?.showToast && shouldToast(null, "persistence:load")) {
2462
+ try {
2463
+ await client.tui.showToast({
2464
+ body: { message: `codemem: ${message}`, variant: "error" },
2465
+ });
2466
+ } catch {
2467
+ // Best-effort toast only.
2468
+ }
2469
+ }
2470
+ }
2471
+ return;
2472
+ }
2473
+ spoolLoadFailureNoted = false;
2474
+ if (loaded.corruptCount > 0) {
2475
+ await logLine(`raw_events.spool.corrupt_retained count=${loaded.corruptCount}`);
2476
+ }
2477
+ for (const entry of loaded.entries) {
2478
+ try {
2479
+ await queueRawEventViaCli(entry.envelope, entry.serialized);
2480
+ await removeRawEventFromSpool({ eventId: entry.eventId });
2481
+ } catch (error) {
2482
+ await logLine("raw_events.spool.drain_deferred category=fallback");
2483
+ if (error?.retryable === true) {
2484
+ break;
2485
+ }
2486
+ }
2487
+ }
2488
+ })();
2489
+ const trackedDrain = drainPromise
2490
+ .catch(async () => {
2491
+ await logLine("raw_events.spool.drain_failed category=persistence");
2492
+ })
2493
+ .finally(() => {
2494
+ if (rawEventSpoolDrainsInFlight.get(rawEventSpoolDirectory) === trackedDrain) {
2495
+ rawEventSpoolDrainsInFlight.delete(rawEventSpoolDirectory);
2496
+ }
2497
+ });
2498
+ rawEventSpoolDrainsInFlight.set(rawEventSpoolDirectory, trackedDrain);
2499
+ return trackedDrain;
2500
+ };
2501
+
1974
2502
  const buildViewerCliArgs = (action) => {
1975
2503
  const args = ["serve", action, "--host", viewerHost, "--port", viewerPort];
1976
2504
  if (String(viewerDbPath || "").trim()) {
@@ -1990,66 +2518,54 @@ export const CodememPlugin = async ({
1990
2518
  return false;
1991
2519
  }
1992
2520
  const now = Date.now();
1993
- const body = buildRawEventEnvelope({
1994
- sessionID,
1995
- type,
1996
- payload,
1997
- cwd,
1998
- project: resolveProjectName(project, cwd),
1999
- startedAt: sessionStartedAt,
2000
- nowMs: now,
2001
- nowMono:
2002
- typeof performance !== "undefined" && performance.now
2003
- ? performance.now()
2004
- : null,
2005
- nextEventId,
2006
- });
2007
-
2521
+ let cachedEnvelope = payload && typeof payload === "object"
2522
+ ? rawEventEnvelopes.get(payload)
2523
+ : null;
2524
+ if (!cachedEnvelope) {
2525
+ const builtEnvelope = buildRawEventEnvelope({
2526
+ sessionID,
2527
+ type,
2528
+ payload,
2529
+ cwd,
2530
+ project: resolveProjectName(project, cwd),
2531
+ startedAt: sessionStartedAt,
2532
+ nowMs: now,
2533
+ nowMono:
2534
+ typeof performance !== "undefined" && performance.now
2535
+ ? performance.now()
2536
+ : null,
2537
+ nextEventId,
2538
+ });
2539
+ const serialized = JSON.stringify(builtEnvelope);
2540
+ cachedEnvelope = { body: JSON.parse(serialized), serialized };
2541
+ if (payload && typeof payload === "object") {
2542
+ rawEventEnvelopes.set(payload, cachedEnvelope);
2543
+ }
2544
+ }
2545
+ const { body, serialized } = cachedEnvelope;
2008
2546
  if (now < streamUnavailableUntil) {
2547
+ const durable = await persistRawEventForRetry({ body, serialized, payload, sessionID });
2009
2548
  try {
2010
- await queueRawEventViaCli(body);
2011
- fallbackFailureNoted = false;
2549
+ await queueRawEventViaCli(body, serialized);
2550
+ await removeRawEventFromSpool({ eventId: body.event_id, payload });
2012
2551
  if (payload && typeof payload === "object") {
2013
2552
  payload._raw_enqueued = true;
2014
2553
  }
2554
+ await notifyRawEventDelivery({
2555
+ category: "connection",
2556
+ delivered: true,
2557
+ durable,
2558
+ sessionID,
2559
+ });
2015
2560
  return true;
2016
- } catch (fallbackErr) {
2017
- if (payload && typeof payload === "object") {
2018
- payload._raw_fallback_terminal = fallbackErr?.retryable !== true;
2019
- }
2020
- await logLine(
2021
- `raw_events.fallback.error sessionID=${sessionID} type=${type} err=${String(
2022
- fallbackErr
2023
- ).slice(0, 200)}`
2024
- );
2025
- if (!fallbackFailureNoted) {
2026
- fallbackFailureNoted = true;
2027
- try {
2028
- await client.app.log({
2029
- service: "codemem",
2030
- level: "error",
2031
- message: "codemem fallback enqueue failed during stream backoff",
2032
- extra: {
2033
- sessionID,
2034
- backoffMs: rawEventsBackoffMs,
2035
- },
2036
- });
2037
- } catch (logErr) {
2038
- // best-effort logging only
2039
- }
2040
- if (client.tui?.showToast && shouldToast(sessionID)) {
2041
- try {
2042
- await client.tui.showToast({
2043
- body: {
2044
- message: "codemem: fallback enqueue failed while stream is down",
2045
- variant: "error",
2046
- },
2047
- });
2048
- } catch (toastErr) {
2049
- // best-effort only
2050
- }
2051
- }
2052
- }
2561
+ } catch {
2562
+ await logLine("raw_events.fallback.error category=connection");
2563
+ await notifyRawEventDelivery({
2564
+ category: "connection",
2565
+ delivered: false,
2566
+ durable,
2567
+ sessionID,
2568
+ });
2053
2569
  return false;
2054
2570
  }
2055
2571
  }
@@ -2080,130 +2596,46 @@ export const CodememPlugin = async ({
2080
2596
  });
2081
2597
  if (!postResp.ok) {
2082
2598
  let responseBody = null;
2083
- if (postResp.status === 409) {
2084
- try {
2085
- responseBody = await postResp.json();
2086
- } catch {
2087
- // Generic failure handling below remains the safe fallback.
2088
- }
2599
+ try {
2600
+ responseBody = await postResp.json();
2601
+ } catch {
2602
+ // Generic connection guidance below remains the safe fallback.
2089
2603
  }
2090
- const targetMismatch = isViewerDbMismatchPayload(responseBody)
2091
- || isViewerIdentityMismatchPayload(responseBody)
2092
- || isViewerContractUnsupportedPayload(responseBody);
2093
2604
  const postError = new Error(`raw-events post failed (${postResp.status})`);
2094
- postError.viewerTargetMismatch = targetMismatch;
2605
+ postError.rawEventFailureCategory = classifyRawEventViewerFailure(responseBody);
2095
2606
  throw postError;
2096
2607
  }
2097
2608
  streamUnavailableUntil = 0;
2098
- streamErrorNoted = false;
2099
- fallbackFailureNoted = false;
2100
2609
  lastStatusAvailable = true;
2610
+ await removeRawEventFromSpool({ eventId: body.event_id, payload });
2101
2611
  if (payload && typeof payload === "object") {
2102
2612
  payload._raw_enqueued = true;
2103
2613
  }
2104
2614
  return true;
2105
2615
  } catch (err) {
2616
+ const category = err?.rawEventFailureCategory || "connection";
2106
2617
  streamUnavailableUntil = Date.now() + Math.max(1000, rawEventsBackoffMs);
2107
- await logLine(`raw_events.error sessionID=${sessionID} type=${type} err=${String(err).slice(0, 200)}`);
2108
- try {
2109
- await client.app.log({
2110
- service: "codemem",
2111
- level: "error",
2112
- message: "Failed to stream raw events to codemem viewer",
2113
- extra: {
2114
- sessionID,
2115
- type,
2116
- viewerHost,
2117
- viewerPort,
2118
- viewerTargetMismatch: err?.viewerTargetMismatch === true,
2119
- error: String(err),
2120
- },
2121
- });
2122
- } catch (logErr) {
2123
- // best-effort logging only
2124
- }
2618
+ await logLine(`raw_events.error category=${category}`);
2619
+ const durable = await persistRawEventForRetry({ body, serialized, payload, sessionID });
2125
2620
 
2126
2621
  let fallbackOk = false;
2127
2622
  try {
2128
- await queueRawEventViaCli(body);
2623
+ await queueRawEventViaCli(body, serialized);
2624
+ await removeRawEventFromSpool({ eventId: body.event_id, payload });
2129
2625
  fallbackOk = true;
2130
- } catch (fallbackErr) {
2131
- if (payload && typeof payload === "object") {
2132
- payload._raw_fallback_terminal = fallbackErr?.retryable !== true;
2133
- }
2134
- await logLine(
2135
- `raw_events.fallback.error sessionID=${sessionID} type=${type} err=${String(
2136
- fallbackErr
2137
- ).slice(0, 200)}`
2138
- );
2626
+ } catch {
2627
+ await logLine(`raw_events.fallback.error category=${category}`);
2139
2628
  }
2140
2629
 
2141
2630
  if (fallbackOk) {
2142
- fallbackFailureNoted = false;
2143
2631
  if (payload && typeof payload === "object") {
2144
2632
  payload._raw_enqueued = true;
2145
2633
  }
2146
- if (!streamErrorNoted) {
2147
- streamErrorNoted = true;
2148
- try {
2149
- await client.app.log({
2150
- service: "codemem",
2151
- level: "warn",
2152
- message: "codemem stream unavailable; queued raw event via CLI fallback",
2153
- extra: {
2154
- sessionID,
2155
- backoffMs: rawEventsBackoffMs,
2156
- },
2157
- });
2158
- } catch (logErr) {
2159
- // best-effort logging only
2160
- }
2161
- }
2162
- if (client.tui?.showToast && shouldToast(sessionID)) {
2163
- try {
2164
- await client.tui.showToast({
2165
- body: {
2166
- message: "codemem: viewer stream unavailable; queue fallback active",
2167
- variant: "warning",
2168
- },
2169
- });
2170
- } catch (toastErr) {
2171
- // best-effort only
2172
- }
2173
- }
2634
+ await notifyRawEventDelivery({ category, delivered: true, durable, sessionID });
2174
2635
  return true;
2175
2636
  }
2176
2637
 
2177
- if (!streamErrorNoted) {
2178
- streamErrorNoted = true;
2179
- fallbackFailureNoted = true;
2180
- try {
2181
- await client.app.log({
2182
- service: "codemem",
2183
- level: "error",
2184
- message: "codemem stream unavailable; fallback enqueue failed",
2185
- extra: {
2186
- sessionID,
2187
- backoffMs: rawEventsBackoffMs,
2188
- },
2189
- });
2190
- } catch (logErr) {
2191
- // best-effort logging only
2192
- }
2193
- }
2194
-
2195
- if (client.tui?.showToast && shouldToast(sessionID)) {
2196
- try {
2197
- await client.tui.showToast({
2198
- body: {
2199
- message: `codemem: stream unavailable (${viewerHost}:${viewerPort}); fallback failed`,
2200
- variant: "error",
2201
- },
2202
- });
2203
- } catch (toastErr) {
2204
- // best-effort only
2205
- }
2206
- }
2638
+ await notifyRawEventDelivery({ category, delivered: false, durable, sessionID });
2207
2639
  return false;
2208
2640
  }
2209
2641
  };
@@ -2709,7 +3141,7 @@ export const CodememPlugin = async ({
2709
3141
  request_id: identity.requestId,
2710
3142
  });
2711
3143
 
2712
- const runPromptPackLedger = async (payload) => {
3144
+ const runPromptPackLedger = async (payload, { viewerOnly = false } = {}) => {
2713
3145
  const viewerPayload = {
2714
3146
  ...payload,
2715
3147
  db_path: promptPackDbPath,
@@ -2730,12 +3162,12 @@ export const CodememPlugin = async ({
2730
3162
  };
2731
3163
  }
2732
3164
 
2733
- const { cause, retryable } = httpResult.classification;
3165
+ const { cause, kind, retryable } = httpResult.classification;
2734
3166
  await logLine(
2735
3167
  `inject.ledger.http_error cause=${JSON.stringify(redactLog(cause, 200))} retryable=${retryable}`
2736
3168
  );
2737
- if (!retryable) {
2738
- return { exitCode: 1, stdout: "", stderr: cause, transport: "viewer" };
3169
+ if (!retryable || viewerOnly) {
3170
+ return { exitCode: 1, stdout: "", stderr: cause, transport: "viewer", failureKind: kind };
2739
3171
  }
2740
3172
 
2741
3173
  try {
@@ -2757,9 +3189,18 @@ export const CodememPlugin = async ({
2757
3189
  queryHash: hashPromptPackQuery(""),
2758
3190
  });
2759
3191
 
2760
- const recordSkippedPromptPack = (failureCode, sessionID = null, surface = injectSurface) => {
3192
+ const recordSkippedPromptPack = (failureCode, sessionID = null, surface = injectSurface, requestKey = null) => {
2761
3193
  const sessionKey = String(sessionID || "unknown");
2762
3194
  const memoKey = `${surface}:${sessionKey}`;
3195
+ if (requestKey) {
3196
+ const signature = `${requestKey}:${failureCode}`;
3197
+ if (latestPolicySkips.get(memoKey) === signature) return null;
3198
+ latestPolicySkips.delete(memoKey);
3199
+ latestPolicySkips.set(memoKey, signature);
3200
+ while (latestPolicySkips.size > MAX_MESSAGE_INJECTION_CACHE_SESSIONS) {
3201
+ latestPolicySkips.delete(latestPolicySkips.keys().next().value);
3202
+ }
3203
+ }
2763
3204
  if (failureCode === "injection_disabled" && disabledInjectionRecorded.has(memoKey)) {
2764
3205
  return null;
2765
3206
  }
@@ -2768,7 +3209,7 @@ export const CodememPlugin = async ({
2768
3209
  failureCode,
2769
3210
  sessionID,
2770
3211
  surface,
2771
- failureCode === "injection_disabled" ? "once" : `event-${++skippedAttemptCounter}`,
3212
+ requestKey || (failureCode === "injection_disabled" ? "once" : `event-${++skippedAttemptCounter}`),
2772
3213
  );
2773
3214
  void runPromptPackLedger({
2774
3215
  action: "record",
@@ -2797,12 +3238,28 @@ export const CodememPlugin = async ({
2797
3238
  return { attemptId: identity.attemptId, ready };
2798
3239
  };
2799
3240
 
2800
- const confirmPromptPackDelivery = (attemptId, deliveryStatus = "handed_off") => {
3241
+ const confirmPromptPackDelivery = (attemptId, deliveryStatus = "handed_off", evaluation) => {
3242
+ const delivery = {
3243
+ action: "delivery", attempt_id: attemptId, delivery_status: deliveryStatus,
3244
+ };
2801
3245
  void runPromptPackLedger({
2802
- action: "delivery",
2803
- attempt_id: attemptId,
2804
- delivery_status: deliveryStatus,
2805
- });
3246
+ ...delivery,
3247
+ ...evaluation,
3248
+ }).then((result) => {
3249
+ // Retry additive fields only after the transport identifies that exact compatibility case.
3250
+ if (
3251
+ evaluation
3252
+ && result?.transport === "viewer"
3253
+ && result.failureKind === "viewer_contract_unsupported"
3254
+ ) {
3255
+ return runPromptPackLedger(delivery, { viewerOnly: true });
3256
+ }
3257
+ // Older CLI fallbacks can reject optional fields. Preserve the original receipt;
3258
+ // never retry a Viewer policy/auth rejection through a different transport.
3259
+ if (evaluation && result?.transport === "cli" && result.exitCode !== 0) {
3260
+ return runPromptPackLedger(delivery);
3261
+ }
3262
+ }).catch(() => {});
2806
3263
  };
2807
3264
 
2808
3265
  const showToast = async (message, variant = "warning") => {
@@ -3126,11 +3583,44 @@ export const CodememPlugin = async ({
3126
3583
  }
3127
3584
  };
3128
3585
 
3586
+ let fallbackEvaluationSessionId = nextEventId();
3129
3587
  const buildInjectedContext = async (query, context = {}) => {
3588
+ const requestPackBudget = reserveContextPrefixBudget(context.tokenBudget ?? injectTokenBudget);
3130
3589
  const queryHash = hashPromptPackQuery(query);
3131
- const sessionID = context.sessionID || activeSessionID || "unknown";
3590
+ // Requester eligibility comes only from the transform's own session identity.
3591
+ // activeSessionID tracks the latest event stream, which can belong to another
3592
+ // overlapping session; inferring from it would authorize the wrong summaries.
3593
+ const requesterHostSessionID = context.sessionID || null;
3594
+ const sessionID = requesterHostSessionID || "unknown";
3132
3595
  const surface = context.surface || injectSurface;
3133
- const requestKey = context.requestKey || "unknown";
3596
+ let requestKey = context.requestKey || "unknown";
3597
+ if (context.fallbackTurn != null) {
3598
+ requestKey = JSON.stringify([requestKey, fallbackEvaluationSessionId, promptCounter, context.fallbackTurn]);
3599
+ }
3600
+ const evaluationFields = (
3601
+ beforeText,
3602
+ afterText,
3603
+ candidateItems,
3604
+ duplicatesOmitted,
3605
+ packMetadata,
3606
+ artifactFingerprint,
3607
+ ) => ({
3608
+ evaluation_key: injectionDigest(JSON.stringify([
3609
+ sessionID,
3610
+ requestKey,
3611
+ surface,
3612
+ queryHash,
3613
+ artifactFingerprint,
3614
+ ])),
3615
+ automatic_recall: {
3616
+ v: 1, candidateItems, duplicatesOmitted,
3617
+ beforeTokens: beforeText ? estimateTokens(wrapInjectedContext(beforeText)) : 0,
3618
+ afterTokens: afterText ? estimateTokens(wrapInjectedContext(afterText)) : 0,
3619
+ missingRetainedMetadata: context.retainedMetadata?.missingRetainedMetadata === true,
3620
+ invalidRetainedMetadata: context.retainedMetadata?.invalidRetainedMetadata === true,
3621
+ packMetadata,
3622
+ },
3623
+ });
3134
3624
  const attemptKey = JSON.stringify([
3135
3625
  sessionID,
3136
3626
  requestKey,
@@ -3151,14 +3641,23 @@ export const CodememPlugin = async ({
3151
3641
  let identity = resolveIdentity();
3152
3642
  let metadata = attemptMetadata(
3153
3643
  identity,
3154
- context.sessionID || activeSessionID || null,
3644
+ requesterHostSessionID,
3155
3645
  );
3646
+ if (requestPackBudget === null) {
3647
+ return {
3648
+ text: "",
3649
+ attemptId: identity.attemptId,
3650
+ requestId: identity.requestId,
3651
+ queryHash,
3652
+ promptNumber: promptCounter,
3653
+ };
3654
+ }
3156
3655
  const runPack = async () => {
3157
3656
  let packArgs = buildPackArgs({
3158
3657
  query,
3159
3658
  filesModified: sessionContext.filesModified,
3160
3659
  injectLimit,
3161
- injectTokenBudget,
3660
+ injectTokenBudget: requestPackBudget,
3162
3661
  internalLedger: true,
3163
3662
  });
3164
3663
  const httpResult = await postViewerJson({
@@ -3168,12 +3667,15 @@ export const CodememPlugin = async ({
3168
3667
  query,
3169
3668
  filesModified: sessionContext.filesModified,
3170
3669
  injectLimit,
3171
- injectTokenBudget,
3670
+ injectTokenBudget: requestPackBudget,
3172
3671
  projectName: normalizeProjectLabel(process.env.CODEMEM_PROJECT),
3173
3672
  cwd,
3174
3673
  dbPath: promptPackDbPath,
3175
3674
  identityTarget: promptPackIdentityTarget,
3176
3675
  attempt: metadata,
3676
+ automaticContext: requesterHostSessionID
3677
+ ? { source: "opencode", host_session_id: requesterHostSessionID }
3678
+ : null,
3177
3679
  }),
3178
3680
  validate: isValidPackHttpPayload,
3179
3681
  });
@@ -3200,16 +3702,17 @@ export const CodememPlugin = async ({
3200
3702
  };
3201
3703
  }
3202
3704
 
3203
- let result = await runCli(packArgs, { stdinText: JSON.stringify(metadata) });
3705
+ const result = await runCli(packArgs, { stdinText: JSON.stringify(metadata) });
3204
3706
  if (rejectsInternalLedgerFlag(result)) {
3205
- packArgs = packArgs.filter((arg) => arg !== "--internal-ledger");
3206
- result = await runCli(packArgs);
3707
+ // An older CLI cannot enforce requester-session continuity. Do not retry
3708
+ // through its generic pack path, which may inject an unrelated summary.
3709
+ await logLine("inject.pack.cli_legacy_unsafe_fallback_blocked");
3207
3710
  }
3208
3711
  return { packArgs, result: { ...result, transport: "cli" } };
3209
3712
  };
3210
3713
  let { packArgs, result } = await runPack();
3211
3714
  let { packText, conflictPackText, metrics, itemCount, ledgerConflict } = parsePackOutput(result);
3212
- let artifactFingerprint = packText
3715
+ let artifactFingerprint = packText || itemCount === 0
3213
3716
  ? promptPackArtifactFingerprint(result.stdout, packText)
3214
3717
  : "";
3215
3718
  let injectedIdentity = identity;
@@ -3236,14 +3739,11 @@ export const CodememPlugin = async ({
3236
3739
  advancePromptPackRetryIdentity(attemptKey);
3237
3740
  retryCount = promptPackRetryCounts.get(attemptKey) || 0;
3238
3741
  identity = resolveIdentity();
3239
- metadata = attemptMetadata(
3240
- identity,
3241
- context.sessionID || activeSessionID || null,
3242
- );
3742
+ metadata = attemptMetadata(identity, requesterHostSessionID);
3243
3743
  ({ packArgs, result } = await runPack());
3244
3744
  // Repair-conflict bytes are never preferred over the original preserved pack.
3245
3745
  ({ packText, metrics, itemCount, ledgerConflict } = parsePackOutput(result));
3246
- artifactFingerprint = packText
3746
+ artifactFingerprint = packText || itemCount === 0
3247
3747
  ? promptPackArtifactFingerprint(result.stdout, packText)
3248
3748
  : "";
3249
3749
  const repairFailed = ledgerConflict
@@ -3322,13 +3822,10 @@ export const CodememPlugin = async ({
3322
3822
  advancePromptPackRetryIdentity(attemptKey);
3323
3823
  retryCount = promptPackRetryCounts.get(attemptKey) || 0;
3324
3824
  identity = resolveIdentity();
3325
- metadata = attemptMetadata(
3326
- identity,
3327
- context.sessionID || activeSessionID || null,
3328
- );
3825
+ metadata = attemptMetadata(identity, requesterHostSessionID);
3329
3826
  ({ packArgs, result } = await runPack());
3330
3827
  ({ packText, metrics, itemCount, ledgerConflict } = parsePackOutput(result));
3331
- artifactFingerprint = packText
3828
+ artifactFingerprint = packText || itemCount === 0
3332
3829
  ? promptPackArtifactFingerprint(result.stdout, packText)
3333
3830
  : "";
3334
3831
 
@@ -3428,6 +3925,10 @@ export const CodememPlugin = async ({
3428
3925
  }
3429
3926
  if (!packText) {
3430
3927
  if (itemCount === 0) {
3928
+ if (surface === "message") {
3929
+ void runPromptPackLedger({ action: "recall", attempt_id: identity.attemptId,
3930
+ ...evaluationFields("", "", 0, 0, "valid", artifactFingerprint) }).catch(() => {});
3931
+ }
3431
3932
  advancePromptPackRetryIdentity(attemptKey);
3432
3933
  }
3433
3934
  if (debug) {
@@ -3460,9 +3961,34 @@ export const CodememPlugin = async ({
3460
3961
  artifactFingerprint || promptPackArtifactFingerprint(result.stdout, packText)
3461
3962
  );
3462
3963
  }
3964
+ const filtered = context.surface === "message"
3965
+ ? filterRetainedPack(result.stdout, packText, context.retainedItems)
3966
+ : { text: packText, items: [], duplicates: 0 };
3967
+ const text = filtered.text ? wrapInjectedContext(filtered.text) : "";
3968
+ const evaluation = surface === "message"
3969
+ ? evaluationFields(
3970
+ packText,
3971
+ filtered.text,
3972
+ itemCount,
3973
+ filtered.duplicates,
3974
+ filtered.packMetadata,
3975
+ artifactFingerprint,
3976
+ )
3977
+ : undefined;
3978
+ const recall = filtered.items.length
3979
+ ? { v: 1, digest: injectionDigest(text), items: filtered.items, workingContext: context.workingContext }
3980
+ : undefined;
3981
+ let skipReason = null;
3982
+ if (!text && filtered.duplicates > 0) {
3983
+ skipReason = context.continuationOnly ? "continuation_only" : "unchanged_memories";
3984
+ }
3463
3985
  if (metrics) {
3464
3986
  return {
3465
- text: `[codemem context]\n${packText}`,
3987
+ text,
3988
+ recall,
3989
+ duplicates: filtered.duplicates,
3990
+ evaluation,
3991
+ skipReason,
3466
3992
  metrics,
3467
3993
  attemptId: injectedIdentity?.attemptId || null,
3468
3994
  requestId: injectedIdentity?.requestId || null,
@@ -3471,7 +3997,11 @@ export const CodememPlugin = async ({
3471
3997
  };
3472
3998
  }
3473
3999
  return {
3474
- text: `[codemem context]\n${packText}`,
4000
+ text,
4001
+ recall,
4002
+ duplicates: filtered.duplicates,
4003
+ evaluation,
4004
+ skipReason,
3475
4005
  attemptId: injectedIdentity?.attemptId || null,
3476
4006
  requestId: injectedIdentity?.requestId || null,
3477
4007
  queryHash,
@@ -3575,9 +4105,17 @@ export const CodememPlugin = async ({
3575
4105
  void logLine(`queue.pressure unsent_preserved queued=${queuedCount} max_events=${cap}`);
3576
4106
  },
3577
4107
  onForcedDrop: (dropped, queuedCount, hardCap) => {
4108
+ const durableSpoolCoverage = dropped?._raw_spooled === true;
3578
4109
  void logLine(
3579
- `queue.drop hard_cap event_id=${dropped?._raw_event_id || "unknown"} queued=${queuedCount} hard_max=${hardCap}`
4110
+ `queue.drop hard_cap durable_spool_coverage=${durableSpoolCoverage} queued=${queuedCount} hard_max=${hardCap}`
3580
4111
  );
4112
+ void log("error", "codemem raw event dropped at in-memory hard cap", {
4113
+ category: "queue_capacity",
4114
+ delivery: durableSpoolCoverage ? "spool" : "memory",
4115
+ durable_spool_coverage: durableSpoolCoverage,
4116
+ queued_count: queuedCount,
4117
+ hard_max_events: hardCap,
4118
+ });
3581
4119
  },
3582
4120
  });
3583
4121
  };
@@ -3607,7 +4145,6 @@ export const CodememPlugin = async ({
3607
4145
  ...adapterAnnotatedEvent,
3608
4146
  _raw_event_id: rawEventId,
3609
4147
  _raw_session_id: resolvedSessionID,
3610
- _raw_retry_count: 0,
3611
4148
  };
3612
4149
  recordEvent(queuedEvent);
3613
4150
  void emitRawEvent({
@@ -3619,6 +4156,7 @@ export const CodememPlugin = async ({
3619
4156
 
3620
4157
  const flushEvents = async () => {
3621
4158
  if (!events.length) {
4159
+ await drainRawEventSpool();
3622
4160
  await logLine("flush.skip empty");
3623
4161
  return;
3624
4162
  }
@@ -3630,18 +4168,10 @@ export const CodememPlugin = async ({
3630
4168
  }
3631
4169
 
3632
4170
  const failed = [];
3633
- let droppedCount = 0;
3634
4171
  for (const queuedEvent of batch) {
3635
4172
  if (queuedEvent && typeof queuedEvent === "object" && queuedEvent._raw_enqueued) {
3636
4173
  continue;
3637
4174
  }
3638
- if (queuedEvent && typeof queuedEvent === "object" && queuedEvent._raw_fallback_terminal) {
3639
- droppedCount += 1;
3640
- await logLine(
3641
- `flush.drop terminal_fallback event_id=${queuedEvent?._raw_event_id || "unknown"}`
3642
- );
3643
- continue;
3644
- }
3645
4175
  const queuedSessionID =
3646
4176
  queuedEvent?._raw_session_id ||
3647
4177
  queuedEvent?.properties?.sessionID ||
@@ -3652,27 +4182,13 @@ export const CodememPlugin = async ({
3652
4182
  payload: queuedEvent,
3653
4183
  });
3654
4184
  if (!ok) {
3655
- if (queuedEvent?._raw_fallback_terminal) {
3656
- droppedCount += 1;
3657
- await logLine(
3658
- `flush.drop terminal_fallback event_id=${queuedEvent?._raw_event_id || "unknown"}`
3659
- );
3660
- continue;
3661
- }
3662
- const currentRetry =
3663
- typeof queuedEvent?._raw_retry_count === "number" && Number.isFinite(queuedEvent._raw_retry_count)
3664
- ? queuedEvent._raw_retry_count
3665
- : 0;
3666
- const nextRetry = currentRetry + 1;
3667
- failed.push({
3668
- ...queuedEvent,
3669
- _raw_retry_count: nextRetry,
3670
- });
4185
+ failed.push(queuedEvent);
3671
4186
  }
3672
4187
  }
3673
4188
  if (failed.length) {
3674
4189
  events.unshift(...failed);
3675
4190
  await logLine(`flush.retry_deferred count=${failed.length}`);
4191
+ await drainRawEventSpool();
3676
4192
  return;
3677
4193
  }
3678
4194
 
@@ -3683,12 +4199,20 @@ export const CodememPlugin = async ({
3683
4199
  await logLine(
3684
4200
  `flush.stream_only finalize count=${batch.length} tools=${sessionContext.toolCount} prompts=${sessionContext.promptCount} duration=${Math.round(durationMs / 1000)}s`
3685
4201
  );
3686
- await logLine(`flush.ok count=${batch.length - droppedCount} dropped=${droppedCount}`);
4202
+ await logLine(`flush.ok count=${batch.length} dropped=0`);
3687
4203
  sessionStartedAt = null;
3688
4204
  resetSessionContext();
4205
+ await drainRawEventSpool();
3689
4206
  };
3690
4207
 
4208
+ void drainRawEventSpool();
4209
+
3691
4210
  return {
4211
+ dispose: () => {
4212
+ clearTimeout(updateCheckTimer);
4213
+ stopHealthCheck();
4214
+ releasePluginRegistration();
4215
+ },
3692
4216
  "experimental.session.compacting": async (input) => {
3693
4217
  const sessionID = extractHookSessionID(input) || activeSessionID;
3694
4218
  markCompactionInjectionSkip(compactionInjectionSkips, sessionID);
@@ -3702,10 +4226,12 @@ export const CodememPlugin = async ({
3702
4226
  if (injectSurface === "system") {
3703
4227
  return;
3704
4228
  }
3705
- const latestUser = Array.isArray(output?.messages)
3706
- ? findLatestUserMessage(output.messages)
3707
- : null;
3708
- const sessionID = latestUser ? resolveEntrySessionID(latestUser.entry) : input?.sessionID;
4229
+ const hookMessages = Array.isArray(output?.messages)
4230
+ ? scopeMessagesToSession(output.messages, input?.sessionID || null)
4231
+ : [];
4232
+ const latestUser = findLatestUserMessage(hookMessages);
4233
+ const sessionID = input?.sessionID
4234
+ || (latestUser ? resolveEntrySessionID(latestUser.entry) : null);
3709
4235
  const latestPromptText = latestUser ? extractMessageText(latestUser.entry) : "";
3710
4236
  if (debug) {
3711
4237
  const query = resolveInjectQuery({ lastPromptText: latestPromptText });
@@ -3738,10 +4264,16 @@ export const CodememPlugin = async ({
3738
4264
  resolveInjectQuery,
3739
4265
  buildInjectedContext,
3740
4266
  messageInjectionCache,
4267
+ injectTokenBudget,
4268
+ retainedTokenBudget,
4269
+ workingSet: [...sessionContext.filesModified, ...sessionContext.filesRead],
4270
+ recordMeasurement: (measurement) => logLine(`inject.recall ${JSON.stringify(measurement)}`),
3741
4271
  compactionInjectionSkips,
3742
4272
  confirmDelivery: confirmPromptPackDelivery,
3743
4273
  recordCacheReuse: recordCachedPromptPack,
3744
- recordSkipped: recordSkippedPromptPack,
4274
+ recordSkipped: (reason, sessionID) => recordSkippedPromptPack(
4275
+ reason, sessionID, "message", latestUser ? resolveEntryMessageId(latestUser.entry) : null,
4276
+ ),
3745
4277
  });
3746
4278
  } catch (err) {
3747
4279
  await logLine(
@@ -3993,6 +4525,7 @@ export const CodememPlugin = async ({
3993
4525
  activeSessionID = sessionID || null;
3994
4526
  sessionStartedAt = new Date().toISOString();
3995
4527
  promptCounter = 0;
4528
+ fallbackEvaluationSessionId = nextEventId();
3996
4529
  skippedAttemptCounter = 0;
3997
4530
  disabledInjectionRecorded.delete("message:unknown");
3998
4531
  disabledInjectionRecorded.delete("system:unknown");
@@ -4004,11 +4537,18 @@ export const CodememPlugin = async ({
4004
4537
  if (eventType === "session.deleted") {
4005
4538
  activeSessionID = null;
4006
4539
  if (sessionID) {
4540
+ for (const key of lastToastAtBySession.keys()) {
4541
+ if (key.startsWith(`${sessionID}:`)) {
4542
+ lastToastAtBySession.delete(key);
4543
+ }
4544
+ }
4007
4545
  injectionToastShown.delete(sessionID);
4008
4546
  messageInjectionCache.delete(sessionID);
4009
4547
  compactionInjectionSkips.delete(sessionID);
4010
4548
  disabledInjectionRecorded.delete(`message:${sessionID}`);
4011
4549
  disabledInjectionRecorded.delete(`system:${sessionID}`);
4550
+ latestPolicySkips.delete(`message:${sessionID}`);
4551
+ latestPolicySkips.delete(`system:${sessionID}`);
4012
4552
  }
4013
4553
  await stopViewer();
4014
4554
  }
@@ -4130,6 +4670,8 @@ export const __testUtils = {
4130
4670
  redactPackCommand,
4131
4671
  rejectsInternalLedgerFlag,
4132
4672
  classifyFallbackCommandResult,
4673
+ classifyRawEventViewerFailure,
4674
+ detectRunner,
4133
4675
  PROMPT_TRANSPORT_PROTOCOL_RANGE,
4134
4676
  normalizePromptTransportProtocolRange,
4135
4677
  arePromptTransportProtocolRangesCompatible,
@@ -4159,5 +4701,18 @@ export const __testUtils = {
4159
4701
  buildRawEventEnvelope,
4160
4702
  trimEventQueue,
4161
4703
  parsePositiveInt,
4704
+ resolveInjectTokenBudget,
4705
+ reserveContextPrefixBudget,
4706
+ wrapInjectedContext,
4707
+ estimateTokens,
4708
+ countRetainedInjectionTokens,
4709
+ retainedMemoryFingerprints,
4710
+ retainedMetadataGaps,
4711
+ filterRetainedPack,
4712
+ isContinuationOnly,
4713
+ resolveRetainedTokenBudget,
4714
+ workingContextDigest,
4715
+ DEFAULT_INJECT_TOKEN_BUDGET,
4716
+ CODEMEM_CONTEXT_PREFIX,
4162
4717
  createViewerHealthMonitor,
4163
4718
  };