@compaction/cli 0.1.4 → 0.3.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.
Files changed (170) hide show
  1. package/README.md +51 -12
  2. package/dist/cli/commands/activity.d.ts +8 -0
  3. package/dist/cli/commands/activity.js +35 -0
  4. package/dist/cli/commands/apply-context.js +68 -0
  5. package/dist/cli/commands/billing-delta.js +12 -1
  6. package/dist/cli/commands/capture-claude-code.d.ts +44 -0
  7. package/dist/cli/commands/capture-claude-code.js +206 -0
  8. package/dist/cli/commands/capture.js +283 -1
  9. package/dist/cli/commands/compact.js +24 -11
  10. package/dist/cli/commands/context.d.ts +2 -0
  11. package/dist/cli/commands/context.js +130 -0
  12. package/dist/cli/commands/dev.d.ts +17 -0
  13. package/dist/cli/commands/dev.js +131 -0
  14. package/dist/cli/commands/gateway.d.ts +31 -0
  15. package/dist/cli/commands/gateway.js +312 -0
  16. package/dist/cli/commands/hooks.d.ts +5 -0
  17. package/dist/cli/commands/hooks.js +181 -0
  18. package/dist/cli/commands/import.js +3 -1
  19. package/dist/cli/commands/init.js +422 -17
  20. package/dist/cli/commands/input-compaction-ab.d.ts +2 -0
  21. package/dist/cli/commands/input-compaction-ab.js +125 -0
  22. package/dist/cli/commands/optimize-hosted.d.ts +54 -0
  23. package/dist/cli/commands/optimize-hosted.js +123 -0
  24. package/dist/cli/commands/optimize.js +8 -0
  25. package/dist/cli/commands/output-shaping-ab.d.ts +2 -0
  26. package/dist/cli/commands/output-shaping-ab.js +132 -0
  27. package/dist/cli/commands/output-shaping.d.ts +7 -0
  28. package/dist/cli/commands/output-shaping.js +43 -0
  29. package/dist/cli/commands/policies.d.ts +2 -0
  30. package/dist/cli/commands/policies.js +80 -0
  31. package/dist/cli/commands/precall.d.ts +19 -0
  32. package/dist/cli/commands/precall.js +436 -0
  33. package/dist/cli/commands/recommend.js +1 -1
  34. package/dist/cli/commands/run.js +310 -18
  35. package/dist/cli/commands/upgrade-status.d.ts +42 -0
  36. package/dist/cli/commands/upgrade-status.js +152 -0
  37. package/dist/cli/cursor-export-read.d.ts +13 -0
  38. package/dist/cli/cursor-export-read.js +53 -0
  39. package/dist/cli/cursor-live-preflight.d.ts +19 -0
  40. package/dist/cli/cursor-live-preflight.js +46 -0
  41. package/dist/cli/index.js +39 -2
  42. package/dist/cli/onboarding/GatewayTui.d.ts +33 -0
  43. package/dist/cli/onboarding/GatewayTui.js +113 -0
  44. package/dist/cli/onboarding/model.d.ts +32 -1
  45. package/dist/cli/onboarding/model.js +27 -2
  46. package/dist/cli/terminal-logo.d.ts +27 -0
  47. package/dist/cli/terminal-logo.js +49 -0
  48. package/dist/core/activity-event.d.ts +107 -0
  49. package/dist/core/activity-event.js +182 -0
  50. package/dist/core/activity-store.d.ts +63 -0
  51. package/dist/core/activity-store.js +254 -0
  52. package/dist/core/activity-view.d.ts +76 -0
  53. package/dist/core/activity-view.js +120 -0
  54. package/dist/core/api-client/index.d.ts +3 -1
  55. package/dist/core/api-client/index.js +4 -0
  56. package/dist/core/api-client/payload.d.ts +5 -1
  57. package/dist/core/api-client/payload.js +8 -6
  58. package/dist/core/api-client/persisted-config.d.ts +57 -0
  59. package/dist/core/api-client/persisted-config.js +156 -0
  60. package/dist/core/api-client/tool.d.ts +29 -0
  61. package/dist/core/api-client/tool.js +50 -0
  62. package/dist/core/api-client/types.d.ts +15 -4
  63. package/dist/core/auto-apply-ask.d.ts +58 -0
  64. package/dist/core/auto-apply-ask.js +105 -0
  65. package/dist/core/auto-apply-gates.d.ts +76 -0
  66. package/dist/core/auto-apply-gates.js +113 -0
  67. package/dist/core/before-call-activity.d.ts +35 -0
  68. package/dist/core/before-call-activity.js +103 -0
  69. package/dist/core/before-call-recovery.d.ts +19 -0
  70. package/dist/core/before-call-recovery.js +46 -0
  71. package/dist/core/before-call-stdin.d.ts +59 -0
  72. package/dist/core/before-call-stdin.js +78 -0
  73. package/dist/core/before-call.d.ts +149 -0
  74. package/dist/core/before-call.js +358 -0
  75. package/dist/core/billing-delta/billing-delta-record.d.ts +28 -0
  76. package/dist/core/billing-delta/billing-delta-record.js +26 -0
  77. package/dist/core/capture-record.d.ts +40 -0
  78. package/dist/core/capture-record.js +55 -0
  79. package/dist/core/claude-code-before-call.d.ts +37 -0
  80. package/dist/core/claude-code-before-call.js +120 -0
  81. package/dist/core/claude-code-connect.d.ts +31 -0
  82. package/dist/core/claude-code-connect.js +87 -0
  83. package/dist/core/claude-code-hook-record.d.ts +71 -0
  84. package/dist/core/claude-code-hook-record.js +0 -0
  85. package/dist/core/claude-code-hooks.d.ts +77 -0
  86. package/dist/core/claude-code-hooks.js +141 -0
  87. package/dist/core/codex-capture.d.ts +45 -0
  88. package/dist/core/codex-capture.js +204 -0
  89. package/dist/core/command-runner.js +5 -1
  90. package/dist/core/compaction-artifacts.d.ts +51 -0
  91. package/dist/core/compaction-artifacts.js +242 -0
  92. package/dist/core/compactor.d.ts +9 -0
  93. package/dist/core/compactor.js +95 -0
  94. package/dist/core/context-store-eval-cases.d.ts +6 -0
  95. package/dist/core/context-store-eval-cases.js +331 -0
  96. package/dist/core/context-store-eval.d.ts +140 -0
  97. package/dist/core/context-store-eval.js +138 -0
  98. package/dist/core/context-store-fs.d.ts +73 -0
  99. package/dist/core/context-store-fs.js +157 -0
  100. package/dist/core/context-store-sufficiency.d.ts +98 -0
  101. package/dist/core/context-store-sufficiency.js +135 -0
  102. package/dist/core/context-store.d.ts +155 -0
  103. package/dist/core/context-store.js +228 -0
  104. package/dist/core/cross-surface-event.d.ts +306 -0
  105. package/dist/core/cross-surface-event.js +330 -0
  106. package/dist/core/cursor-capture.d.ts +54 -0
  107. package/dist/core/cursor-capture.js +215 -0
  108. package/dist/core/cursor-preflight-probe.d.ts +8 -0
  109. package/dist/core/cursor-preflight-probe.js +88 -0
  110. package/dist/core/cursor-preflight.d.ts +86 -0
  111. package/dist/core/cursor-preflight.js +126 -0
  112. package/dist/core/gateway/apply-activation.d.ts +39 -0
  113. package/dist/core/gateway/apply-activation.js +84 -0
  114. package/dist/core/gateway/apply-policy.d.ts +64 -0
  115. package/dist/core/gateway/apply-policy.js +221 -0
  116. package/dist/core/gateway/apply-receipt.d.ts +36 -0
  117. package/dist/core/gateway/apply-receipt.js +75 -0
  118. package/dist/core/gateway/cache-proof.d.ts +45 -0
  119. package/dist/core/gateway/cache-proof.js +65 -0
  120. package/dist/core/gateway/configure.d.ts +50 -0
  121. package/dist/core/gateway/configure.js +169 -0
  122. package/dist/core/gateway/openai-usage.d.ts +56 -0
  123. package/dist/core/gateway/openai-usage.js +128 -0
  124. package/dist/core/gateway/receipt.d.ts +138 -0
  125. package/dist/core/gateway/receipt.js +120 -0
  126. package/dist/core/gateway/recovery.d.ts +23 -0
  127. package/dist/core/gateway/recovery.js +68 -0
  128. package/dist/core/gateway/server.d.ts +51 -0
  129. package/dist/core/gateway/server.js +276 -0
  130. package/dist/core/gateway/status.d.ts +45 -0
  131. package/dist/core/gateway/status.js +109 -0
  132. package/dist/core/hook-usage-aggregate.d.ts +47 -0
  133. package/dist/core/hook-usage-aggregate.js +161 -0
  134. package/dist/core/input-compaction-ab.d.ts +111 -0
  135. package/dist/core/input-compaction-ab.js +158 -0
  136. package/dist/core/local-run-record.d.ts +109 -0
  137. package/dist/core/local-run-record.js +223 -0
  138. package/dist/core/output-shaping-ab.d.ts +140 -0
  139. package/dist/core/output-shaping-ab.js +146 -0
  140. package/dist/core/output-shaping-attach.d.ts +31 -0
  141. package/dist/core/output-shaping-attach.js +57 -0
  142. package/dist/core/output-shaping.d.ts +56 -0
  143. package/dist/core/output-shaping.js +89 -0
  144. package/dist/core/policy-middleware.d.ts +121 -0
  145. package/dist/core/policy-middleware.js +919 -0
  146. package/dist/core/policy-preferences.d.ts +99 -0
  147. package/dist/core/policy-preferences.js +232 -0
  148. package/dist/core/report-generator.d.ts +19 -1
  149. package/dist/core/report-generator.js +51 -0
  150. package/dist/core/run-aggregator.d.ts +56 -1
  151. package/dist/core/run-aggregator.js +93 -2
  152. package/dist/core/run-flow-report.d.ts +82 -0
  153. package/dist/core/run-flow-report.js +71 -0
  154. package/dist/core/safety-report.js +8 -1
  155. package/dist/core/shim-capture-bridge.d.ts +32 -0
  156. package/dist/core/shim-capture-bridge.js +88 -0
  157. package/dist/core/skill-injection-policy.d.ts +72 -0
  158. package/dist/core/skill-injection-policy.js +183 -0
  159. package/dist/core/spend-attribution.js +1 -1
  160. package/dist/core/token-accounting.d.ts +1 -1
  161. package/dist/core/tool-shim.d.ts +129 -0
  162. package/dist/core/tool-shim.js +447 -0
  163. package/dist/core/trace-adapters.d.ts +8 -0
  164. package/dist/core/trace-adapters.js +4 -0
  165. package/dist/core/trace-parser.d.ts +13 -13
  166. package/dist/core/trace-parser.js +6 -6
  167. package/dist/core/types.d.ts +45 -2
  168. package/dist/core/waste-detector.d.ts +20 -0
  169. package/dist/core/waste-detector.js +160 -6
  170. package/package.json +1 -1
@@ -0,0 +1,919 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { calculateCost } from "./cost-calculator.js";
3
+ import { contentHash } from "./content-hash.js";
4
+ import { createStateCapsule } from "./state-capsule.js";
5
+ import { DEFAULT_MODEL } from "./pricing.js";
6
+ import { estimateTextTokens, estimateTraceTokens } from "./token-estimator.js";
7
+ import { detectWaste } from "./waste-detector.js";
8
+ import { createSkillInjectionCapsules, findSkillInjectionCompactionCandidates } from "./skill-injection-policy.js";
9
+ import { COMPACTION_POLICY_NAME, SKILL_INJECTION_POLICY_NAME, SUPPORTED_COMPACTION_POLICY_NAMES, isSupportedCompactionPolicyName } from "./policy-types.js";
10
+ // Re-export the public policy surface (now owned by ./policy-types.js) so existing engine
11
+ // consumers that import these from policy-middleware keep working unchanged. The ALGORITHM stays
12
+ // here; only the public-safe names/types moved to the public module to sever the public→engine
13
+ // runtime import edge (open-core Phase 1a).
14
+ export { COMPACTION_POLICY_NAME, SKILL_INJECTION_POLICY_NAME, SUPPORTED_COMPACTION_POLICY_NAMES, isSupportedCompactionPolicyName };
15
+ const DEFAULT_MIN_DUPLICATE_TOKENS = 200;
16
+ const DEFAULT_MIN_DUPLICATE_CHARACTERS = 800;
17
+ function roundCurrency(value) {
18
+ return Number(value.toFixed(6));
19
+ }
20
+ function createTraceFromMessages(messages, model) {
21
+ const generatedAt = new Date().toISOString();
22
+ return {
23
+ id: `policy_middleware_messages_${randomUUID()}`,
24
+ title: "Policy middleware message payload",
25
+ artifactVersion: "agent-trace-v1",
26
+ source: "manual",
27
+ createdAt: generatedAt,
28
+ generatedAt,
29
+ model,
30
+ messages
31
+ };
32
+ }
33
+ function resolveTrace(input) {
34
+ if (input.trace) {
35
+ return input.trace;
36
+ }
37
+ if (input.messages) {
38
+ return createTraceFromMessages(input.messages, input.model ?? DEFAULT_MODEL);
39
+ }
40
+ throw new Error("applyCompactionPolicy requires either a trace or messages payload.");
41
+ }
42
+ function assertSupportedPolicy(policy) {
43
+ if (policy?.policyName && policy.policyName !== COMPACTION_POLICY_NAME) {
44
+ throw new Error(`Unsupported compaction policy: ${policy.policyName}`);
45
+ }
46
+ }
47
+ function meetsThresholds(content, thresholds) {
48
+ return estimateTextTokens(content) >= thresholds.minDuplicateTokens && content.length >= thresholds.minDuplicateCharacters;
49
+ }
50
+ function findRepeatCandidates(trace, thresholds) {
51
+ const messageById = new Map(trace.messages.map((message, index) => [message.id, { message, index }]));
52
+ const candidates = [];
53
+ for (const finding of detectWaste(trace, thresholds)) {
54
+ if (finding.category !== "repeated_tool_output") {
55
+ continue;
56
+ }
57
+ const [sourceMessageId, duplicateMessageId] = finding.messageIds;
58
+ const source = messageById.get(sourceMessageId);
59
+ const duplicate = messageById.get(duplicateMessageId);
60
+ if (!source || !duplicate) {
61
+ continue;
62
+ }
63
+ const duplicateTokens = finding.estimatedTokens;
64
+ candidates.push({
65
+ finding,
66
+ sourceMessage: source.message,
67
+ sourceIndex: source.index,
68
+ duplicateMessage: duplicate.message,
69
+ duplicateIndex: duplicate.index,
70
+ duplicateTokens
71
+ });
72
+ }
73
+ return candidates;
74
+ }
75
+ /**
76
+ * Input-compaction category 1 (`superseded_same_source_read`) candidates.
77
+ *
78
+ * For each category-1 finding from `detectWaste` (`messageIds[0]` = KEPT latest authoritative read;
79
+ * `messageIds[1..]` = earlier superseded reads), produce one `WholeMessageCompaction` per superseded
80
+ * earlier read. Reuses the EXACT recoverability model of the tool-output policy with NO fork: each
81
+ * superseded earlier read is compacted whole-message, and its source pointer references the EARLIER
82
+ * READ ITSELF (self-pointer) — whose raw content is RETAINED in the original trace. Self-pointing is
83
+ * the correct recoverability binding for supersession: the dropped earlier copy is byte-recoverable
84
+ * from the retained original at that pointer, and (source === the compacted position's own original)
85
+ * trivially satisfies safety-report.ts / `wholeMessagePointerCoversPosition` (pointer honesty +
86
+ * normalized-equal-to-itself). The LATEST authoritative read (`messageIds[0]`) is NEVER a candidate,
87
+ * so it stays in the compacted trace verbatim. The finding carried on each candidate is the SAME
88
+ * category-1 finding (so `candidateToFinding`/safety surfaces report the honest category).
89
+ */
90
+ function findSupersededReadCandidates(trace, thresholds) {
91
+ const messageById = new Map(trace.messages.map((message, index) => [message.id, { message, index }]));
92
+ const candidates = [];
93
+ for (const finding of detectWaste(trace, thresholds)) {
94
+ if (finding.category !== "superseded_same_source_read") {
95
+ continue;
96
+ }
97
+ const [, ...supersededIds] = finding.messageIds;
98
+ for (const supersededId of supersededIds) {
99
+ const earlier = messageById.get(supersededId);
100
+ if (!earlier) {
101
+ continue;
102
+ }
103
+ // Defense-in-depth: re-assert the same thresholds the detector applied (the engine never
104
+ // compacts a span that does not independently clear the min-tokens/chars bar).
105
+ if (!meetsThresholds(earlier.message.content, thresholds)) {
106
+ continue;
107
+ }
108
+ candidates.push({
109
+ replacementMode: "whole_message",
110
+ // Self-pointer: source === the earlier superseded read itself (retained in the original
111
+ // trace), so the dropped copy is recoverable from the retained original at this pointer.
112
+ sourceMessage: earlier.message,
113
+ sourceIndex: earlier.index,
114
+ targetMessage: earlier.message,
115
+ targetIndex: earlier.index,
116
+ replacedTokens: estimateTextTokens(earlier.message.content),
117
+ // One whole-message finding per compacted earlier read (keeps the latest authoritative id
118
+ // [0] as the KEPT reference; this id is the compacted earlier read).
119
+ finding: {
120
+ category: "superseded_same_source_read",
121
+ messageIds: [finding.messageIds[0], supersededId],
122
+ summary: finding.summary,
123
+ estimatedTokens: estimateTextTokens(earlier.message.content)
124
+ }
125
+ });
126
+ }
127
+ }
128
+ return candidates;
129
+ }
130
+ function findEligibleSourcePayloads(messages, thresholds) {
131
+ return messages.flatMap((message, index) => {
132
+ if (message.role !== "tool" || !meetsThresholds(message.content, thresholds)) {
133
+ return [];
134
+ }
135
+ return [
136
+ {
137
+ sourceMessage: message,
138
+ sourceIndex: index,
139
+ sourceTokens: estimateTextTokens(message.content)
140
+ }
141
+ ];
142
+ });
143
+ }
144
+ function isEmbeddedTargetRole(role) {
145
+ return role === "user" || role === "assistant" || role === "system";
146
+ }
147
+ function rangesOverlap(first, second) {
148
+ return first.start < second.end && second.start < first.end;
149
+ }
150
+ function findNonOverlappingOccurrences(content, payload, occupiedRanges) {
151
+ const ranges = [];
152
+ let searchFrom = 0;
153
+ while (searchFrom < content.length) {
154
+ const start = content.indexOf(payload, searchFrom);
155
+ if (start === -1) {
156
+ break;
157
+ }
158
+ const range = { start, end: start + payload.length };
159
+ if (!occupiedRanges.some((occupiedRange) => rangesOverlap(range, occupiedRange))) {
160
+ ranges.push(range);
161
+ }
162
+ searchFrom = range.end;
163
+ }
164
+ return ranges;
165
+ }
166
+ function compareEmbeddedSourcePriority(first, second) {
167
+ const lengthDelta = second.sourceMessage.content.length - first.sourceMessage.content.length;
168
+ if (lengthDelta !== 0) {
169
+ return lengthDelta;
170
+ }
171
+ return first.sourceIndex - second.sourceIndex;
172
+ }
173
+ function findEmbeddedCandidates(trace, thresholds) {
174
+ const sourcePayloads = findEligibleSourcePayloads(trace.messages, thresholds);
175
+ const candidates = [];
176
+ for (const [targetIndex, targetMessage] of trace.messages.entries()) {
177
+ if (!isEmbeddedTargetRole(targetMessage.role)) {
178
+ continue;
179
+ }
180
+ const occupiedRanges = [];
181
+ const targetOccurrences = [];
182
+ const eligiblePriorSources = sourcePayloads
183
+ .filter((sourcePayload) => sourcePayload.sourceIndex < targetIndex)
184
+ .sort(compareEmbeddedSourcePriority);
185
+ for (const sourcePayload of eligiblePriorSources) {
186
+ const ranges = findNonOverlappingOccurrences(targetMessage.content, sourcePayload.sourceMessage.content, occupiedRanges);
187
+ for (const range of ranges) {
188
+ occupiedRanges.push(range);
189
+ targetOccurrences.push({
190
+ sourceMessage: sourcePayload.sourceMessage,
191
+ sourceIndex: sourcePayload.sourceIndex,
192
+ sourceTokens: sourcePayload.sourceTokens,
193
+ range
194
+ });
195
+ }
196
+ }
197
+ const occurrencesBySourceId = new Map();
198
+ for (const occurrence of targetOccurrences) {
199
+ const existing = occurrencesBySourceId.get(occurrence.sourceMessage.id) ?? [];
200
+ existing.push(occurrence);
201
+ occurrencesBySourceId.set(occurrence.sourceMessage.id, existing);
202
+ }
203
+ for (const sourcePayload of eligiblePriorSources) {
204
+ const occurrences = occurrencesBySourceId.get(sourcePayload.sourceMessage.id);
205
+ if (!occurrences) {
206
+ continue;
207
+ }
208
+ candidates.push({
209
+ sourceMessage: sourcePayload.sourceMessage,
210
+ sourceIndex: sourcePayload.sourceIndex,
211
+ targetMessage,
212
+ targetIndex,
213
+ sourceTokens: sourcePayload.sourceTokens,
214
+ replacedRanges: occurrences.map((occurrence) => occurrence.range).sort((first, second) => first.start - second.start)
215
+ });
216
+ }
217
+ }
218
+ return candidates;
219
+ }
220
+ function createSourcePointer(trace, message, messageIndex) {
221
+ return {
222
+ traceId: trace.id,
223
+ messageId: message.id,
224
+ messageIndex,
225
+ contentSha256: contentHash(message.content)
226
+ };
227
+ }
228
+ function sourceExcerptPreview(content) {
229
+ const normalized = content.trim().replace(/\s+/g, " ");
230
+ return normalized.length > 160 ? `${normalized.slice(0, 157)}...` : normalized;
231
+ }
232
+ function createCapsuleText(sourcePointer, sourceMessage, replacedTokens, isSuperseded = false) {
233
+ // Honest wording per category: category 1 compacts an EARLIER superseded read (self-pointer to the
234
+ // retained earlier copy), not a duplicate of a kept first copy.
235
+ const whatWasCompacted = isSuperseded
236
+ ? `Superseded earlier ${sourceMessage.toolName ?? "tool"} read from the same source was compacted before this model call (a later read of the same source is authoritative and is retained verbatim).`
237
+ : `Repeated ${sourceMessage.toolName ?? "tool"} output was compacted before this model call.`;
238
+ return [
239
+ `[state capsule: ${COMPACTION_POLICY_NAME}]`,
240
+ whatWasCompacted,
241
+ `Raw source pointer: trace=${sourcePointer.traceId} message=${sourcePointer.messageId} index=${sourcePointer.messageIndex} sha256=${sourcePointer.contentSha256}.`,
242
+ `The original payload is retained locally at that pointer for audit and replay.`,
243
+ `Estimated raw ${isSuperseded ? "superseded" : "duplicate"} input tokens replaced: ${replacedTokens}.`
244
+ ].join("\n");
245
+ }
246
+ function candidateToFinding(candidate) {
247
+ if (candidate.replacementMode === "whole_message") {
248
+ return candidate.finding;
249
+ }
250
+ return {
251
+ category: "repeated_tool_output",
252
+ messageIds: [candidate.sourceMessage.id, candidate.targetMessage.id],
253
+ summary: `Embedded ${candidate.sourceMessage.toolName ?? "tool"} output from ${candidate.sourceMessage.id} compacted inside ${candidate.targetMessage.id}.`,
254
+ estimatedTokens: candidate.replacedTokens
255
+ };
256
+ }
257
+ function createCapsules(trace, candidates) {
258
+ const baseCapsule = createStateCapsule(trace, candidates.map(candidateToFinding));
259
+ return candidates.map((candidate, index) => {
260
+ const sourcePointer = createSourcePointer(trace, candidate.sourceMessage, candidate.sourceIndex);
261
+ const isSuperseded = candidate.replacementMode === "whole_message" &&
262
+ candidate.finding.category === "superseded_same_source_read";
263
+ const capsule = {
264
+ id: `state_capsule_${index + 1}`,
265
+ policyName: COMPACTION_POLICY_NAME,
266
+ traceId: trace.id,
267
+ sourcePointer,
268
+ compactedMessageIds: [candidate.targetMessage.id],
269
+ replacementMode: candidate.replacementMode,
270
+ sourceExcerptPreview: sourceExcerptPreview(candidate.sourceMessage.content),
271
+ originalPayloadTokenCount: estimateTextTokens(candidate.sourceMessage.content),
272
+ text: createCapsuleText(sourcePointer, candidate.sourceMessage, candidate.replacedTokens, isSuperseded),
273
+ retainedFacts: baseCapsule.retainedFacts,
274
+ openQuestions: baseCapsule.openQuestions,
275
+ safetyNotes: [
276
+ ...baseCapsule.safetyNotes,
277
+ isSuperseded
278
+ ? `Raw superseded earlier read remains available at trace ${sourcePointer.traceId} message ${sourcePointer.messageId}.`
279
+ : `Raw repeated output remains available at trace ${sourcePointer.traceId} message ${sourcePointer.messageId}.`,
280
+ isSuperseded
281
+ ? `Only the superseded earlier read message ${candidate.targetMessage.id} was replaced with a state capsule; the later authoritative read of the same source is retained verbatim.`
282
+ : `Only ${candidate.replacementMode === "whole_message" ? "duplicate tool output message" : "embedded payload in message"} ${candidate.targetMessage.id} was replaced with a state capsule.`
283
+ ]
284
+ };
285
+ if (candidate.replacementMode === "embedded_payload") {
286
+ capsule.replacedRanges = candidate.replacedRanges;
287
+ capsule.replacedContentSha256 = candidate.replacedContentSha256;
288
+ }
289
+ return capsule;
290
+ });
291
+ }
292
+ function createCompactionMetadata(capsule, messageId) {
293
+ const metadata = {
294
+ policyName: capsule.policyName,
295
+ capsuleId: capsule.id,
296
+ rawSourcePointer: capsule.sourcePointer,
297
+ compactedMessageId: messageId,
298
+ replacementMode: capsule.replacementMode
299
+ };
300
+ if (capsule.replacementMode === "embedded_payload") {
301
+ metadata.replacedRanges = capsule.replacedRanges ?? [];
302
+ metadata.replacedContentSha256 = capsule.replacedContentSha256 ?? "";
303
+ }
304
+ if (capsule.skillInjectionProvenance) {
305
+ metadata.skillInjectionProvenance = capsule.skillInjectionProvenance;
306
+ }
307
+ return metadata;
308
+ }
309
+ function compactMessages(messages, candidates, capsules, skillInjectionCapsules = []) {
310
+ const wholeMessageCapsuleById = new Map();
311
+ const embeddedByTargetId = new Map();
312
+ for (const [index, candidate] of candidates.entries()) {
313
+ const capsule = capsules[index];
314
+ if (candidate.replacementMode === "whole_message") {
315
+ wholeMessageCapsuleById.set(candidate.targetMessage.id, capsule);
316
+ continue;
317
+ }
318
+ const existing = embeddedByTargetId.get(candidate.targetMessage.id) ?? [];
319
+ existing.push({ candidate, capsule });
320
+ embeddedByTargetId.set(candidate.targetMessage.id, existing);
321
+ }
322
+ // Sequential composition (design §Q1): skill-injection capsules are also `whole_message`
323
+ // and operate on a DISJOINT message set (role:user injections vs role:tool outputs), so they
324
+ // simply add to the same whole-message replacement map. Order is independent.
325
+ for (const capsule of skillInjectionCapsules) {
326
+ for (const compactedId of capsule.compactedMessageIds) {
327
+ wholeMessageCapsuleById.set(compactedId, capsule);
328
+ }
329
+ }
330
+ return messages.map((message) => {
331
+ const wholeMessageCapsule = wholeMessageCapsuleById.get(message.id);
332
+ if (wholeMessageCapsule) {
333
+ return {
334
+ ...message,
335
+ content: wholeMessageCapsule.text,
336
+ metadata: {
337
+ ...message.metadata,
338
+ compaction: createCompactionMetadata(wholeMessageCapsule, message.id)
339
+ }
340
+ };
341
+ }
342
+ const embeddedReplacements = embeddedByTargetId.get(message.id);
343
+ if (!embeddedReplacements) {
344
+ return { ...message, metadata: message.metadata ? { ...message.metadata } : undefined };
345
+ }
346
+ const occurrences = embeddedReplacements
347
+ .flatMap(({ candidate, capsule }) => candidate.replacedRanges.map((range) => ({ range, capsule })))
348
+ .sort((first, second) => first.range.start - second.range.start);
349
+ let content = "";
350
+ let cursor = 0;
351
+ for (const occurrence of occurrences) {
352
+ content += message.content.slice(cursor, occurrence.range.start);
353
+ content += occurrence.capsule.text;
354
+ cursor = occurrence.range.end;
355
+ }
356
+ content += message.content.slice(cursor);
357
+ const replacementMetadata = embeddedReplacements.map(({ capsule }) => createCompactionMetadata(capsule, message.id));
358
+ const [firstReplacementMetadata] = replacementMetadata;
359
+ return {
360
+ ...message,
361
+ content,
362
+ metadata: {
363
+ ...message.metadata,
364
+ compaction: replacementMetadata.length === 1
365
+ ? firstReplacementMetadata
366
+ : {
367
+ ...firstReplacementMetadata,
368
+ replacements: replacementMetadata
369
+ }
370
+ }
371
+ };
372
+ });
373
+ }
374
+ function collectSafetyNotes(toolOutputCapsules, skillInjectionCapsules = []) {
375
+ if (toolOutputCapsules.length === 0 && skillInjectionCapsules.length === 0) {
376
+ return [
377
+ "No repeated large tool output met the policy thresholds; no payload content was replaced.",
378
+ "The middleware is deterministic and local; no provider API was called."
379
+ ];
380
+ }
381
+ const notes = [];
382
+ if (toolOutputCapsules.length > 0) {
383
+ notes.push(`Applied ${COMPACTION_POLICY_NAME} to ${toolOutputCapsules.length} repeated tool output replacement(s).`);
384
+ }
385
+ if (skillInjectionCapsules.length > 0) {
386
+ notes.push(`Applied ${SKILL_INJECTION_POLICY_NAME} (approval-gated) to ${skillInjectionCapsules.length} byte-identical same-skill skill-injection replacement(s).`);
387
+ }
388
+ notes.push("Raw payloads are preserved by source pointer for audit and replay.", "The middleware is deterministic and local; no provider API was called.");
389
+ return notes;
390
+ }
391
+ function collectCompactedMessageIds(capsules) {
392
+ return Array.from(new Set(capsules.flatMap((capsule) => capsule.compactedMessageIds)));
393
+ }
394
+ /**
395
+ * Error thrown when the provenance-completeness write-time invariant is violated.
396
+ * See `docs/ops/provenance-completeness-invariant.md`.
397
+ */
398
+ export class ProvenanceCompletenessError extends Error {
399
+ constructor(message) {
400
+ super(message);
401
+ this.name = "ProvenanceCompletenessError";
402
+ }
403
+ }
404
+ function isResolvableSourcePointer(pointer) {
405
+ return (typeof pointer?.traceId === "string" &&
406
+ typeof pointer.messageId === "string" &&
407
+ typeof pointer.messageIndex === "number" &&
408
+ typeof pointer.contentSha256 === "string" &&
409
+ pointer.contentSha256.length > 0);
410
+ }
411
+ /**
412
+ * Whole-message dedup equivalence — MUST match `normalizeContent` in waste-detector.ts (the
413
+ * signature `detectWaste` keys whole-message duplicates by) and `sourceExcerptPreview` here.
414
+ * `detectWaste` selects a later tool output as a duplicate of a retained copy when their
415
+ * NORMALIZED content is equal (whitespace collapsed), NOT when their raw bytes are equal. The
416
+ * provenance guard's whole-message binding therefore MUST use this same equivalence, or it would
417
+ * reject honest compactions where the retained/canonical copy and the compacted later copy are
418
+ * normalized-equal but raw-different (e.g. a re-read file with different whitespace) — a copy that
419
+ * the shipped recoverability check in safety-report.ts ACCEPTS (it resolves the pointer to the
420
+ * canonical source and matches the pointer hash against THAT source, never against the later
421
+ * copy's own raw bytes). The guard must never be stricter than the shipped recoverability semantics.
422
+ */
423
+ function normalizeDedupContent(content) {
424
+ return content.trim().replace(/\s+/g, " ");
425
+ }
426
+ /**
427
+ * Resolve a source pointer to its referenced ORIGINAL message, mirroring safety-report.ts's
428
+ * `findOriginalMessage`: prefer the message at `messageIndex` when its id matches, else fall back
429
+ * to the first message with the pointer's id. Returns undefined when no original messages are
430
+ * available or the pointer resolves to nothing (so the caller fails closed).
431
+ */
432
+ function resolveSourceByPointer(pointer, originalMessages) {
433
+ if (!originalMessages) {
434
+ return undefined;
435
+ }
436
+ const atIndex = originalMessages[pointer.messageIndex];
437
+ if (atIndex?.id === pointer.messageId) {
438
+ return atIndex;
439
+ }
440
+ return originalMessages.find((message) => message.id === pointer.messageId);
441
+ }
442
+ /**
443
+ * Does a whole-message source pointer LEGITIMATELY cover `positionOriginalContent` under the SAME
444
+ * equivalence the writer/dedup and safety-report use? Two conditions, mirroring safety-report.ts's
445
+ * recoverability check exactly:
446
+ * (a) POINTER HONESTY: the pointer resolves to a real original message whose raw content hashes to
447
+ * `pointer.contentSha256` (safety-report's `pointersWithPayloadMismatch` check). A pointer
448
+ * that lies about what it points at cannot cover anything.
449
+ * (b) DEDUP EQUIVALENCE: the resolved canonical/source content is NORMALIZED-equal to the changed
450
+ * position's own original payload — i.e. the position's original is recoverable from the
451
+ * canonical copy the pointer references under the dedup's normalized equivalence.
452
+ * This is the per-occurrence binding for whole-message compactions. It PASSES the honest
453
+ * whitespace-variant duplicate (normalized-equal canonical + later copy) and FAILS a malicious
454
+ * occurrence substitution (the displaced position's original is normalized-DIFFERENT from the
455
+ * canonical content the surviving pointer references). It is never stricter than safety-report.ts.
456
+ */
457
+ function wholeMessagePointerCoversPosition(pointer, positionOriginalContent, originalMessages) {
458
+ const source = resolveSourceByPointer(pointer, originalMessages);
459
+ if (!source) {
460
+ return false;
461
+ }
462
+ if (contentHash(source.content) !== pointer.contentSha256) {
463
+ return false; // pointer is not honest about the source it references
464
+ }
465
+ return normalizeDedupContent(source.content) === normalizeDedupContent(positionOriginalContent);
466
+ }
467
+ /**
468
+ * Read the `metadata.compaction` provenance entries carried by a compacted message.
469
+ * Mirrors `readCompactionMetadata` in safety-report.ts: a multi-replacement message
470
+ * carries its per-replacement entries under `replacements`; otherwise the single entry.
471
+ */
472
+ function readMessageCompactionMetadata(message) {
473
+ const metadata = message.metadata?.compaction;
474
+ if (!metadata) {
475
+ return [];
476
+ }
477
+ if (Array.isArray(metadata.replacements) && metadata.replacements.length > 0) {
478
+ return metadata.replacements;
479
+ }
480
+ return [metadata];
481
+ }
482
+ /**
483
+ * Does this capsule genuinely cover the original content at a changed position carrying the
484
+ * capsule's id — under the SAME equivalence the writer/dedup and safety-report use? This is the
485
+ * per-occurrence BINDING that distinguishes a capsule which legitimately covers THIS occurrence's
486
+ * original payload from one that merely shares the (non-unique) message id (Codex P2, PR #280),
487
+ * WITHOUT being stricter than the shipped recoverability semantics (Codex P1 follow-up, PR #282).
488
+ *
489
+ * - WHOLE-MESSAGE capsule: `detectWaste` selects the compacted later copy as a duplicate of the
490
+ * retained canonical copy when their NORMALIZED content is equal (whitespace collapsed) — NOT
491
+ * when their raw bytes are equal. The capsule's `sourcePointer` references that CANONICAL copy
492
+ * (its `contentSha256` hashes the canonical copy's raw bytes, not the later copy's). So the
493
+ * binding is: the pointer resolves to a real canonical source whose raw content hashes to the
494
+ * pointer (pointer honesty), AND that canonical content is NORMALIZED-equal to the changed
495
+ * position's own original payload (the dedup equivalence). This is exactly what safety-report.ts
496
+ * proves recoverable (`wholeMessagePointerCoversPosition`); a raw-hash-equality binding would
497
+ * FALSE-REJECT an honest whitespace-variant duplicate that safety-report accepts.
498
+ * - EMBEDDED-PAYLOAD capsule: embedded sources are matched by RAW `indexOf` against the target's
499
+ * original content (`findNonOverlappingOccurrences`), so the replaced sub-range is byte-identical
500
+ * to the source payload. SOME replaced sub-range of P_orig must therefore hash to
501
+ * `sourcePointer.contentSha256` (equivalently `replacedContentSha256`). No normalization gap
502
+ * exists here, so the raw sub-range hash check is correct and not over-strict.
503
+ *
504
+ * Honest runs match 1:1; a duplicated/mis-bound capsule whose canonical source is normalized-
505
+ * DIFFERENT from the displaced occurrence's original does not satisfy this for that occurrence.
506
+ */
507
+ function capsuleCoversOriginalContent(capsule, originalContent, originalMessages) {
508
+ const expectedHash = capsule.sourcePointer.contentSha256;
509
+ if (typeof expectedHash !== "string" || expectedHash.length === 0) {
510
+ return false;
511
+ }
512
+ if (capsule.replacementMode === "embedded_payload") {
513
+ const ranges = capsule.replacedRanges ?? [];
514
+ return ranges.some((range) => {
515
+ if (range.start < 0 || range.end > originalContent.length || range.start >= range.end) {
516
+ return false;
517
+ }
518
+ return contentHash(originalContent.slice(range.start, range.end)) === expectedHash;
519
+ });
520
+ }
521
+ // whole_message (the default / shipped tool-output policy and skill-injection capsules): bind by
522
+ // the dedup's NORMALIZED equivalence against the resolved canonical source, mirroring
523
+ // safety-report.ts recoverability — never stricter than the shipped semantics.
524
+ return wholeMessagePointerCoversPosition(capsule.sourcePointer, originalContent, originalMessages);
525
+ }
526
+ /**
527
+ * Compare the original trace messages against the writer's compacted output POSITIONALLY over
528
+ * `max(original.length, compacted.length)` positions, classifying every divergent position.
529
+ *
530
+ * This is the non-tautological "content changed" signal: it is derived from the real
531
+ * before/after payloads, INDEPENDENT of any state capsule or `compactedMessageIds` value.
532
+ * A message whose content was replaced is "changed" here regardless of whether a covering
533
+ * capsule exists for it — exactly the regression case (capsules dropped while content was still
534
+ * replaced) the capsule-derived trigger could not catch.
535
+ *
536
+ * Comparison is POSITIONAL, not id-keyed: the writer (`compactMessages`) emits the compacted
537
+ * messages as a 1:1 positional map of the original messages, and message ids are NOT unique
538
+ * (e.g. a Claude assistant turn with a text block + a tool_use block yields two messages that
539
+ * share the same id). Keying by id would collapse those distinct payloads and mis-report a
540
+ * change.
541
+ *
542
+ * Every position in `[0, max(original.length, compacted.length))` is classified against the
543
+ * honest-writer invariant — a COMPLETE 1:1 id-preserving positional map:
544
+ * - BOTH present, SAME id, same content ⇒ unchanged.
545
+ * - BOTH present, SAME id, differing content ⇒ CONTENT-CHANGED (covered if provenance exists).
546
+ * - BOTH present, DIFFERING id ⇒ STRUCTURAL divergence. A compacted id at a position differs
547
+ * from the original id there only if the writer dropped the original at that slot and inserted/
548
+ * duplicated/substituted a different message — even at equal length. No capsule for the
549
+ * DISPLACING id can recover the original it displaced, so this can carry no covering provenance.
550
+ * - original present, compacted absent ⇒ DROPPED (structural divergence; cannot be covered).
551
+ * - compacted present, original absent ⇒ ADDED (structural divergence; cannot be covered).
552
+ * This closes the whole positional-divergence class: any drop, add, swap, substitution, or
553
+ * duplication fails toward firing the guard, never toward a silent no-op. Honest runs (1:1
554
+ * in-place replacement that preserves each id at its position, equal length) produce zero
555
+ * structural divergences, so this stays a true no-op for them.
556
+ */
557
+ function diffMessagesByContent(originalMessages, compactedMessages) {
558
+ const contentChangedPositions = [];
559
+ let structuralDivergences = 0;
560
+ const positions = Math.max(originalMessages.length, compactedMessages.length);
561
+ for (let index = 0; index < positions; index += 1) {
562
+ const original = originalMessages[index];
563
+ const compacted = compactedMessages[index];
564
+ if (original && compacted) {
565
+ if (compacted.id !== original.id) {
566
+ // Same-length (or in-range) position whose compacted id differs from the original id at
567
+ // that slot: the writer dropped the original here and inserted/duplicated/substituted a
568
+ // DIFFERENT message. This is NOT a coverable content change — a capsule/metadata for the
569
+ // displacing id sources from a different original (or none) and cannot make the displaced
570
+ // original recoverable. It is a STRUCTURAL divergence, failed closed before any id-based
571
+ // provenance check.
572
+ structuralDivergences += 1;
573
+ continue;
574
+ }
575
+ // Same id at the same position: a 1:1 id-preserving slot. A differing payload here is a
576
+ // real, coverable in-place content change (the honest writer's only allowed mutation).
577
+ // Record the POSITION (index + id), not just the id: ids are non-unique, so coverage must
578
+ // later be matched per occurrence/position, not collapsed by id.
579
+ if (compacted.content !== original.content) {
580
+ contentChangedPositions.push({ index, id: compacted.id });
581
+ }
582
+ continue;
583
+ }
584
+ // Exactly one side exists at this position: the original was DROPPED, or a message was
585
+ // ADDED. Either way there is no in-place counterpart that could carry covering provenance,
586
+ // so this is a structural divergence the guard must fail closed on.
587
+ structuralDivergences += 1;
588
+ }
589
+ return { contentChangedPositions, structuralDivergences };
590
+ }
591
+ /**
592
+ * Provenance-completeness write-boundary guard (design: docs/ops/provenance-completeness-invariant.md).
593
+ *
594
+ * Invariant: `compaction_changed_content ⟹ provenance_present`. The "content actually
595
+ * changed" trigger is derived by comparing each original message's payload against the final
596
+ * compacted payload (`diffMessagesByContent`) — NOT from `compactedMessageIds` collected
597
+ * from the capsules. Deriving the trigger from the capsules is tautological: if a regressed
598
+ * path drops/empties the capsule provenance while STILL replacing message content, the
599
+ * capsule-derived id set goes empty and the guard would no-op, missing exactly the
600
+ * changed-but-zero-provenance case it exists to reject. Comparing real content instead means a
601
+ * replaced message is "changed" regardless of whether any capsule exists for it.
602
+ *
603
+ * When content changed (by either real content change OR a capsule-claimed compacted id — the
604
+ * union, so no existing check is weakened), EVERY changed message id MUST have a covering state
605
+ * capsule, AND EVERY changed message MUST carry `metadata.compaction` with a resolvable
606
+ * `rawSourcePointer` + content hash (mirrors the required recoverability checks in
607
+ * safety-report.ts). On violation, FAIL CLOSED: throw, so a changed-but-zero/incomplete-provenance
608
+ * result can never be emitted as a silent no-op the eval harness cannot distinguish from a true no-op.
609
+ *
610
+ * The content-change comparison runs over `max(original.length, compacted.length)` positions
611
+ * (`diffMessagesByContent`) and enforces the COMPLETE honest-writer invariant: a 1:1 id-preserving
612
+ * positional map of original→compacted. A position with the SAME id whose content was replaced is a
613
+ * coverable change; ANYTHING that is not 1:1 id-preserving is a STRUCTURAL divergence that can carry
614
+ * no covering provenance and fails the guard closed — a DROPPED position (original present, compacted
615
+ * absent), an ADDED position (compacted present, original absent), AND a same-length position whose
616
+ * compacted id differs from the original id at that slot (a swap/substitution/duplication, i.e. one
617
+ * original dropped and a different id inserted at equal length). The last case is rejected BEFORE the
618
+ * id-based checks precisely because a capsule/metadata that exists for the DISPLACING id sources from
619
+ * a different original and can never make the displaced original recoverable. This closes the
620
+ * previously-missed cases where a regressed writer silently DROPS a message, or keeps the array length
621
+ * while substituting/duplicating an id, and the surviving capsules/ids happen to cover the wrong id.
622
+ *
623
+ * This is a NO-OP for every honest run: the shipped writer co-produces a covering capsule and
624
+ * `metadata.compaction` (with rawSourcePointer + contentSha256) for every compacted span, so
625
+ * every content-changed id is fully covered. The guard only fires on a regressed/malformed/
626
+ * hand-constructed path. `originalMessages` is optional for backward compatibility; when omitted,
627
+ * the guard falls back to the capsule-derived ids alone (the pre-hardening behavior).
628
+ *
629
+ * OCCURRENCE-AWARE COVERAGE — HASH BINDING (closes the shared-id residual, 2026-06-15). Message
630
+ * ids are NON-UNIQUE (a Claude turn's text + tool_use blocks share one id). A malformed
631
+ * same-length output can DROP one shared-id position and DUPLICATE the other into its slot: every
632
+ * position still has a matching id (the structural check passes, since both share the id), and the
633
+ * displaced position carries the duplicate's valid metadata, so an id-keyed coverage check is
634
+ * satisfied by a capsule belonging to a DIFFERENT occurrence while the displaced original stays
635
+ * unrecoverable. Merely COUNTING distinct covering capsules per id is ALSO insufficient (Codex P2,
636
+ * PR #280): a malformed output can keep BOTH shared-id capsules present (count satisfied) yet
637
+ * duplicate one occurrence's compacted message + metadata into the other's slot. To close this,
638
+ * when `originalMessages` is provided the coverage is matched per OCCURRENCE BY ORIGINAL-CONTENT
639
+ * HASH: each changed POSITION p (index + id, original payload P_orig) is bound to a DISTINCT,
640
+ * not-yet-consumed capsule whose `sourcePointer.contentSha256` hashes to the content that capsule
641
+ * replaced at p (whole P_orig for a whole-message capsule, a replaced sub-range of P_orig for an
642
+ * embedded one), AND p's OWN `metadata.compaction` pointer must hash to that same original. A
643
+ * shared-id duplication fails because the displaced slot's pointer hashes to a DIFFERENT
644
+ * occurrence's payload, not to its own original, so it FAILS CLOSED; an honest duplicate-id
645
+ * compaction (two messages sharing an id, each its own hash-correct capsule) still PASSES. The
646
+ * honest writer emits, per compacted target, exactly one capsule + metadata whose contentSha256 is
647
+ * that target's original payload hash, so every changed position binds 1:1 — a true no-op. (Binding
648
+ * is by original-content HASH, not by `sourcePointer.messageIndex`, which records the SOURCE payload
649
+ * index, not the changed/target position — an index match would break honest whole-message/embedded
650
+ * runs.)
651
+ */
652
+ export function assertProvenanceCompleteness(compactedMessages, stateCapsules, compactedMessageIds, originalMessages) {
653
+ // Independent "content actually changed" signal: real before/after content comparison over
654
+ // max(original, compacted) positions, not derived from the capsules. This yields both the
655
+ // coverable content-changed POSITIONS (index + id, so shared ids do not collapse) and a count of
656
+ // STRUCTURAL divergences (dropped/added positions that can carry no covering provenance).
657
+ const divergence = originalMessages
658
+ ? diffMessagesByContent(originalMessages, compactedMessages)
659
+ : { contentChangedPositions: [], structuralDivergences: 0 };
660
+ // Structural divergence (anything that is NOT a 1:1 id-preserving positional map: a DROPPED
661
+ // position, an ADDED position, or a same-length position whose compacted id differs from the
662
+ // original id there) can never have covering provenance — a dropped message has no compacted
663
+ // message to carry metadata; an added/substituted/duplicated id sources from a different original
664
+ // (or none), so a capsule for that displacing id cannot recover the original it displaced. Fail
665
+ // closed before any id-based check: a structural anomaly can never be made recoverable by a
666
+ // capsule for a DIFFERENT id, and is exactly the malformed/truncated artifact this guard rejects.
667
+ if (divergence.structuralDivergences > 0) {
668
+ throw new ProvenanceCompletenessError(`Provenance-completeness violation: the compacted output diverges structurally from the original at ${divergence.structuralDivergences} position(s) (a message was dropped, added, or its id at that position was substituted/duplicated — i.e. not a 1:1 id-preserving positional map). Such positions can carry no covering provenance. Refusing to emit a changed-but-unrecoverable artifact.`);
669
+ }
670
+ // Deterministic trigger: nothing actually changed ⇒ true no-op ⇒ nothing to assert. (Content
671
+ // changed positions OR capsule-claimed ids; either is enough to require provenance.)
672
+ if (divergence.contentChangedPositions.length === 0 && new Set(compactedMessageIds).size === 0) {
673
+ return;
674
+ }
675
+ // Requirement 1 (occurrence-aware, HASH-BOUND). Counting distinct covering capsules per id is NOT
676
+ // sufficient (Codex P2, PR #280): a malformed same-length output can KEEP both shared-id capsules
677
+ // present (so the per-id distinct-capsule COUNT is satisfied) yet DUPLICATE one occurrence's
678
+ // compacted message + metadata into the other occurrence's slot — leaving that displaced
679
+ // occurrence's ORIGINAL payload unrecoverable, because no surviving capsule's sourcePointer hashes
680
+ // to that occurrence's original content. Counting capsules ≠ binding each changed occurrence to a
681
+ // capsule that actually covers THAT occurrence's original content.
682
+ //
683
+ // So instead of counting, MATCH: for each changed POSITION p (carrying id X, with original payload
684
+ // P_orig), consume a DISTINCT capsule covering id X whose source pointer's `contentSha256` equals
685
+ // the hash of the original content that capsule actually replaced AT p — the whole P_orig for a
686
+ // whole-message capsule, or the replaced payload sub-range of P_orig for an embedded capsule. A
687
+ // capsule consumed by one position cannot be reused by another (greedy 1:1 assignment). If any
688
+ // changed position has no distinct, hash-correct covering capsule → FAIL CLOSED.
689
+ //
690
+ // No-op for honest runs: the shipped writer emits, per changed position, exactly one capsule whose
691
+ // sourcePointer.contentSha256 = that position's original payload hash (whole-message: the duplicate
692
+ // is byte-identical to the source it points at; embedded: the capsule carries the replaced
693
+ // payload's hash and its replacedRanges locate that payload inside P_orig). So every changed
694
+ // position matches a distinct capsule 1:1. The shared-id duplication fails because position 0's
695
+ // ORIGINAL hash does not match the duplicated (occurrence-1) capsule, and occurrence 0's own
696
+ // capsule — even if still present — replaced occurrence-0 content the malformed slot no longer
697
+ // carries; the greedy match still binds it to position 0 by its correct original hash, so the
698
+ // failing signal is precisely the slot whose metadata points at the WRONG occurrence (Requirement
699
+ // 2 below catches that). Either way the malformed artifact is refused.
700
+ if (originalMessages) {
701
+ const consumedCapsules = new Set();
702
+ const positionsMissingCoverage = [];
703
+ for (const position of divergence.contentChangedPositions) {
704
+ const originalContent = originalMessages[position.index]?.content ?? "";
705
+ const match = stateCapsules.find((capsule) => !consumedCapsules.has(capsule) &&
706
+ new Set(capsule.compactedMessageIds).has(position.id) &&
707
+ capsuleCoversOriginalContent(capsule, originalContent, originalMessages));
708
+ if (match) {
709
+ consumedCapsules.add(match);
710
+ }
711
+ else {
712
+ positionsMissingCoverage.push(position);
713
+ }
714
+ }
715
+ if (positionsMissingCoverage.length > 0) {
716
+ throw new ProvenanceCompletenessError(`Provenance-completeness violation: content was compacted but ${positionsMissingCoverage.length} changed occurrence(s) have no DISTINCT covering state capsule whose source pointer hashes to that occurrence's original payload: ${positionsMissingCoverage
717
+ .map((position) => `${position.id}@${position.index}`)
718
+ .join(", ")}. Refusing to emit a changed-but-unrecoverable artifact.`);
719
+ }
720
+ }
721
+ // Requirement 1b (capsule-claimed-only ids — pre-hardening behavior PRESERVED). Ids claimed via
722
+ // `compactedMessageIds` that are NOT also content-changed positions (e.g. callers that pass only
723
+ // compactedMessageIds without original messages, like the stripped-metadata regression test) must
724
+ // still have ≥1 distinct covering capsule. Hash-binding cannot apply here (no original payload is
725
+ // available for those ids), so this keeps the prior id-keyed count check — no prior check weakened.
726
+ const contentChangedIds = new Set(divergence.contentChangedPositions.map((position) => position.id));
727
+ const distinctCapsuleCountById = new Map();
728
+ for (const capsule of stateCapsules) {
729
+ for (const coveredId of new Set(capsule.compactedMessageIds)) {
730
+ distinctCapsuleCountById.set(coveredId, (distinctCapsuleCountById.get(coveredId) ?? 0) + 1);
731
+ }
732
+ }
733
+ const underCoveredIds = [...new Set(compactedMessageIds)]
734
+ .filter((messageId) => !contentChangedIds.has(messageId))
735
+ .filter((messageId) => (distinctCapsuleCountById.get(messageId) ?? 0) < 1)
736
+ .map((messageId) => `${messageId} (need 1, have ${distinctCapsuleCountById.get(messageId) ?? 0})`);
737
+ if (underCoveredIds.length > 0) {
738
+ throw new ProvenanceCompletenessError(`Provenance-completeness violation: content was compacted but ${underCoveredIds.length} compacted message id(s) have no covering state capsule for every changed occurrence: ${underCoveredIds.join(", ")}. Refusing to emit a changed-but-unrecoverable artifact.`);
739
+ }
740
+ // Requirement 2 (occurrence-aware): each changed POSITION's own compacted message object must
741
+ // carry resolvable metadata.compaction (rawSourcePointer + content hash) for its id — read
742
+ // positionally, not collapsed by id, so a displaced position cannot borrow a different
743
+ // occurrence's metadata. Capsule-claimed ids with no content-changed position are additionally
744
+ // checked by id (the pre-hardening behavior), so no prior check is weakened.
745
+ const metadataResolvesForId = (message, messageId) => {
746
+ if (!message) {
747
+ return false;
748
+ }
749
+ return readMessageCompactionMetadata(message).some((metadata) => {
750
+ const coveredId = metadata.compactedMessageId ?? message.id;
751
+ return coveredId === messageId && isResolvableSourcePointer(metadata.rawSourcePointer);
752
+ });
753
+ };
754
+ // For a CONTENT-CHANGED position, the metadata must additionally be BOUND to THAT position's
755
+ // ORIGINAL payload under the SAME equivalence the writer/dedup and safety-report use: some
756
+ // metadata entry for the position's id whose rawSourcePointer legitimately covers the content
757
+ // actually replaced at this position. This is the catch for Codex P2 (PR #280): when occurrence
758
+ // 1's compacted message + metadata is DUPLICATED into occurrence 0's slot, occurrence 0's slot
759
+ // carries occurrence 1's pointer (referencing occurrence 1's canonical payload), which is
760
+ // normalized-DIFFERENT from occurrence 0's ORIGINAL content — so even though both capsules are
761
+ // present (the count passes) and the displaced metadata is "resolvable" (the by-id check passes),
762
+ // the per-position binding fails and the artifact is refused.
763
+ //
764
+ // The whole-message branch binds by the dedup's NORMALIZED equivalence against the canonical
765
+ // source the pointer references (`wholeMessagePointerCoversPosition`), NOT by raw-hash equality of
766
+ // the position's own bytes — otherwise it would FALSE-REJECT an honest whitespace-variant
767
+ // duplicate that safety-report.ts accepts (Codex P1 follow-up, PR #282). The embedded branch keeps
768
+ // the raw sub-range hash (embedded sources are matched by raw indexOf, so there is no
769
+ // normalization gap). Honest runs are unaffected: each position's own metadata records a pointer
770
+ // to a canonical source that is normalized-equal to (the whole-message case) — or a raw sub-range
771
+ // of (the embedded case) — that position's original payload.
772
+ const metadataHashBindsToOriginal = (message, messageId, originalContent) => {
773
+ if (!message) {
774
+ return false;
775
+ }
776
+ return readMessageCompactionMetadata(message).some((metadata) => {
777
+ const coveredId = metadata.compactedMessageId ?? message.id;
778
+ if (coveredId !== messageId || !isResolvableSourcePointer(metadata.rawSourcePointer)) {
779
+ return false;
780
+ }
781
+ const pointer = metadata.rawSourcePointer;
782
+ if (metadata.replacementMode === "embedded_payload") {
783
+ const pointerHash = pointer.contentSha256;
784
+ const ranges = metadata.replacedRanges ?? [];
785
+ return ranges.some((range) => {
786
+ if (range.start < 0 || range.end > originalContent.length || range.start >= range.end) {
787
+ return false;
788
+ }
789
+ return contentHash(originalContent.slice(range.start, range.end)) === pointerHash;
790
+ });
791
+ }
792
+ // whole_message: the pointer's canonical source must be normalized-equal to this position's
793
+ // original payload (and the pointer must honestly hash that canonical source) — mirroring
794
+ // safety-report.ts recoverability, never stricter.
795
+ return wholeMessagePointerCoversPosition(pointer, originalContent, originalMessages);
796
+ });
797
+ };
798
+ const positionsMissingProvenance = divergence.contentChangedPositions.filter((position) => {
799
+ const message = compactedMessages[position.index];
800
+ if (!metadataResolvesForId(message, position.id)) {
801
+ return true;
802
+ }
803
+ if (!originalMessages) {
804
+ return false;
805
+ }
806
+ const originalContent = originalMessages[position.index]?.content ?? "";
807
+ return !metadataHashBindsToOriginal(message, position.id, originalContent);
808
+ });
809
+ // Capsule-claimed ids that are not also content-changed positions: fall back to id-keyed
810
+ // metadata resolution over all compacted messages (preserves the prior behavior for callers that
811
+ // pass compactedMessageIds without original messages, e.g. the stripped-metadata regression test).
812
+ const contentChangedIdSet = new Set(divergence.contentChangedPositions.map((position) => position.id));
813
+ const idOnlyClaimedIds = [...new Set(compactedMessageIds)].filter((messageId) => !contentChangedIdSet.has(messageId));
814
+ const idOnlyMissingProvenance = idOnlyClaimedIds.filter((messageId) => !compactedMessages.some((message) => metadataResolvesForId(message, messageId)));
815
+ const missingProvenanceLabels = [
816
+ ...positionsMissingProvenance.map((position) => `${position.id}@${position.index}`),
817
+ ...idOnlyMissingProvenance
818
+ ];
819
+ if (missingProvenanceLabels.length > 0) {
820
+ throw new ProvenanceCompletenessError(`Provenance-completeness violation: content was compacted but ${missingProvenanceLabels.length} changed message(s) lack metadata.compaction with a resolvable rawSourcePointer + content hash: ${missingProvenanceLabels.join(", ")}. Refusing to emit a changed-but-unrecoverable artifact.`);
821
+ }
822
+ }
823
+ export function applyCompactionPolicy(input) {
824
+ assertSupportedPolicy(input.policy);
825
+ const trace = resolveTrace(input);
826
+ const thresholds = {
827
+ minDuplicateTokens: input.thresholds?.minDuplicateTokens ?? DEFAULT_MIN_DUPLICATE_TOKENS,
828
+ minDuplicateCharacters: input.thresholds?.minDuplicateCharacters ?? DEFAULT_MIN_DUPLICATE_CHARACTERS
829
+ };
830
+ const tokenEstimateBefore = estimateTraceTokens(trace);
831
+ const costEstimateBefore = calculateCost(trace.model, tokenEstimateBefore);
832
+ const repeatCandidates = findRepeatCandidates(trace, thresholds);
833
+ const embeddedCandidates = findEmbeddedCandidates(trace, thresholds);
834
+ // ADDITIVE input-compaction category 1 (superseded same-source reads). These are whole-message
835
+ // self-pointer compactions of EARLIER superseded reads; the latest authoritative read is never a
836
+ // candidate (stays verbatim). They reuse the identical capsule/recoverability machinery below.
837
+ // Conservative de-overlap: if a message is ALREADY a target of the identical-repeat or embedded
838
+ // policy (it matched some other identical output elsewhere), leave it to that policy — never
839
+ // double-compact the same position. This keeps the existing policy's behavior authoritative.
840
+ const alreadyTargetedIds = new Set([
841
+ ...repeatCandidates.map((candidate) => candidate.duplicateMessage.id),
842
+ ...embeddedCandidates.map((candidate) => candidate.targetMessage.id)
843
+ ]);
844
+ const supersededCandidates = findSupersededReadCandidates(trace, thresholds).filter((candidate) => !alreadyTargetedIds.has(candidate.targetMessage.id));
845
+ const candidates = [
846
+ ...repeatCandidates.map((candidate) => ({
847
+ replacementMode: "whole_message",
848
+ sourceMessage: candidate.sourceMessage,
849
+ sourceIndex: candidate.sourceIndex,
850
+ targetMessage: candidate.duplicateMessage,
851
+ targetIndex: candidate.duplicateIndex,
852
+ replacedTokens: candidate.duplicateTokens,
853
+ finding: candidate.finding
854
+ })),
855
+ ...supersededCandidates,
856
+ ...embeddedCandidates.map((candidate) => ({
857
+ replacementMode: "embedded_payload",
858
+ sourceMessage: candidate.sourceMessage,
859
+ sourceIndex: candidate.sourceIndex,
860
+ targetMessage: candidate.targetMessage,
861
+ targetIndex: candidate.targetIndex,
862
+ replacedTokens: candidate.sourceTokens * candidate.replacedRanges.length,
863
+ replacedRanges: candidate.replacedRanges,
864
+ replacedContentSha256: contentHash(candidate.sourceMessage.content)
865
+ }))
866
+ ].sort((first, second) => first.targetIndex - second.targetIndex || first.sourceIndex - second.sourceIndex);
867
+ const stateCapsules = createCapsules(trace, candidates);
868
+ // ── Sequential two-policy composition (design Open Question #1, resolved) ────────────────
869
+ // The two policies operate on DISJOINT message sets: `stale_tool_output_to_state_capsule`
870
+ // acts on role:tool messages; `repeated_skill_injection_to_state_capsule` acts on role:user
871
+ // skill injections. Because the sets are disjoint, sequential application with INDEPENDENT
872
+ // capsule sets is order-independent and needs no general policy registry — a contained,
873
+ // explicit two-policy composition. The skill-injection policy is APPROVAL-GATED: it runs only
874
+ // when `compactSkillInjections === true` (set on explicit approval). Default => compacts nothing.
875
+ const skillInjectionCandidates = input.compactSkillInjections === true
876
+ ? findSkillInjectionCompactionCandidates(trace)
877
+ : [];
878
+ const skillInjectionCapsules = skillInjectionCandidates.length > 0
879
+ ? createSkillInjectionCapsules(trace, skillInjectionCandidates, { retainedFacts: stateCapsules[0]?.retainedFacts ?? [], openQuestions: stateCapsules[0]?.openQuestions ?? [], safetyNotes: [] }, stateCapsules.length)
880
+ : [];
881
+ const compactedMessages = compactMessages(trace.messages, candidates, stateCapsules, skillInjectionCapsules);
882
+ const compactedTrace = {
883
+ ...trace,
884
+ id: `${trace.id}_policy_compacted`,
885
+ title: `${trace.title} (policy compacted)`,
886
+ messages: compactedMessages
887
+ };
888
+ const tokenEstimateAfter = estimateTraceTokens(compactedTrace);
889
+ const costEstimateAfter = calculateCost(compactedTrace.model, tokenEstimateAfter);
890
+ const tokensSaved = Math.max(0, tokenEstimateBefore.inputTokens - tokenEstimateAfter.inputTokens);
891
+ const savingsEstimate = Math.max(0, costEstimateBefore.totalCostUsd - costEstimateAfter.totalCostUsd);
892
+ const allCapsules = [...stateCapsules, ...skillInjectionCapsules];
893
+ const appliedPolicyNames = [
894
+ COMPACTION_POLICY_NAME,
895
+ ...(skillInjectionCapsules.length > 0 ? [SKILL_INJECTION_POLICY_NAME] : [])
896
+ ];
897
+ const compactedMessageIds = collectCompactedMessageIds(allCapsules);
898
+ // Provenance-completeness write-boundary guard (fail-closed). No-op for honest runs
899
+ // because the writer above co-produces capsule + metadata.compaction for every compacted
900
+ // span; only fires on a regressed/malformed path. See docs/ops/provenance-completeness-invariant.md.
901
+ assertProvenanceCompleteness(compactedMessages, allCapsules, compactedMessageIds, trace.messages);
902
+ return {
903
+ compactedTrace: input.trace ? compactedTrace : undefined,
904
+ compactedMessages,
905
+ stateCapsules: allCapsules,
906
+ compactedMessageIds,
907
+ tokenEstimateBefore,
908
+ tokenEstimateAfter,
909
+ tokensSaved,
910
+ costEstimateBefore,
911
+ costEstimateAfter,
912
+ savingsEstimate: roundCurrency(savingsEstimate),
913
+ appliedPolicyName: COMPACTION_POLICY_NAME,
914
+ appliedPolicyNames,
915
+ skillInjectionCapsules,
916
+ safetyNotes: collectSafetyNotes(stateCapsules, skillInjectionCapsules)
917
+ };
918
+ }
919
+ //# sourceMappingURL=policy-middleware.js.map