@compaction/cli 0.2.0 → 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.
- package/README.md +41 -12
- package/dist/cli/commands/activity.d.ts +8 -0
- package/dist/cli/commands/activity.js +35 -0
- package/dist/cli/commands/apply-context.js +68 -0
- package/dist/cli/commands/billing-delta.js +12 -1
- package/dist/cli/commands/capture-claude-code.d.ts +44 -0
- package/dist/cli/commands/capture-claude-code.js +206 -0
- package/dist/cli/commands/capture.js +283 -1
- package/dist/cli/commands/compact.js +9 -5
- package/dist/cli/commands/dev.d.ts +17 -0
- package/dist/cli/commands/dev.js +131 -0
- package/dist/cli/commands/gateway.d.ts +31 -0
- package/dist/cli/commands/gateway.js +312 -0
- package/dist/cli/commands/hooks.d.ts +5 -0
- package/dist/cli/commands/hooks.js +181 -0
- package/dist/cli/commands/init.js +422 -17
- package/dist/cli/commands/input-compaction-ab.d.ts +2 -0
- package/dist/cli/commands/input-compaction-ab.js +125 -0
- package/dist/cli/commands/optimize-hosted.d.ts +54 -0
- package/dist/cli/commands/optimize-hosted.js +123 -0
- package/dist/cli/commands/optimize.js +8 -0
- package/dist/cli/commands/output-shaping-ab.d.ts +2 -0
- package/dist/cli/commands/output-shaping-ab.js +132 -0
- package/dist/cli/commands/output-shaping.d.ts +7 -0
- package/dist/cli/commands/output-shaping.js +43 -0
- package/dist/cli/commands/policies.d.ts +2 -0
- package/dist/cli/commands/policies.js +80 -0
- package/dist/cli/commands/precall.d.ts +19 -0
- package/dist/cli/commands/precall.js +436 -0
- package/dist/cli/commands/recommend.js +1 -1
- package/dist/cli/commands/run.js +297 -12
- package/dist/cli/commands/upgrade-status.d.ts +42 -0
- package/dist/cli/commands/upgrade-status.js +152 -0
- package/dist/cli/cursor-export-read.d.ts +13 -0
- package/dist/cli/cursor-export-read.js +53 -0
- package/dist/cli/cursor-live-preflight.d.ts +19 -0
- package/dist/cli/cursor-live-preflight.js +46 -0
- package/dist/cli/index.js +36 -2
- package/dist/cli/onboarding/GatewayTui.d.ts +33 -0
- package/dist/cli/onboarding/GatewayTui.js +113 -0
- package/dist/cli/onboarding/model.d.ts +32 -1
- package/dist/cli/onboarding/model.js +27 -2
- package/dist/cli/terminal-logo.d.ts +27 -0
- package/dist/cli/terminal-logo.js +49 -0
- package/dist/core/activity-event.d.ts +107 -0
- package/dist/core/activity-event.js +182 -0
- package/dist/core/activity-store.d.ts +63 -0
- package/dist/core/activity-store.js +254 -0
- package/dist/core/activity-view.d.ts +76 -0
- package/dist/core/activity-view.js +120 -0
- package/dist/core/api-client/index.d.ts +3 -1
- package/dist/core/api-client/index.js +4 -0
- package/dist/core/api-client/payload.d.ts +5 -1
- package/dist/core/api-client/payload.js +8 -6
- package/dist/core/api-client/persisted-config.d.ts +57 -0
- package/dist/core/api-client/persisted-config.js +156 -0
- package/dist/core/api-client/tool.d.ts +29 -0
- package/dist/core/api-client/tool.js +50 -0
- package/dist/core/api-client/types.d.ts +15 -4
- package/dist/core/auto-apply-ask.d.ts +58 -0
- package/dist/core/auto-apply-ask.js +105 -0
- package/dist/core/auto-apply-gates.d.ts +76 -0
- package/dist/core/auto-apply-gates.js +113 -0
- package/dist/core/before-call-activity.d.ts +35 -0
- package/dist/core/before-call-activity.js +103 -0
- package/dist/core/before-call-recovery.d.ts +19 -0
- package/dist/core/before-call-recovery.js +46 -0
- package/dist/core/before-call-stdin.d.ts +59 -0
- package/dist/core/before-call-stdin.js +78 -0
- package/dist/core/before-call.d.ts +149 -0
- package/dist/core/before-call.js +358 -0
- package/dist/core/billing-delta/billing-delta-record.d.ts +28 -0
- package/dist/core/billing-delta/billing-delta-record.js +26 -0
- package/dist/core/capture-record.d.ts +40 -0
- package/dist/core/capture-record.js +55 -0
- package/dist/core/claude-code-before-call.d.ts +37 -0
- package/dist/core/claude-code-before-call.js +120 -0
- package/dist/core/claude-code-connect.d.ts +31 -0
- package/dist/core/claude-code-connect.js +87 -0
- package/dist/core/claude-code-hook-record.d.ts +71 -0
- package/dist/core/claude-code-hook-record.js +0 -0
- package/dist/core/claude-code-hooks.d.ts +77 -0
- package/dist/core/claude-code-hooks.js +141 -0
- package/dist/core/codex-capture.d.ts +45 -0
- package/dist/core/codex-capture.js +204 -0
- package/dist/core/command-runner.js +5 -1
- package/dist/core/compaction-artifacts.d.ts +51 -0
- package/dist/core/compaction-artifacts.js +242 -0
- package/dist/core/compactor.d.ts +9 -0
- package/dist/core/compactor.js +95 -0
- package/dist/core/cross-surface-event.d.ts +306 -0
- package/dist/core/cross-surface-event.js +330 -0
- package/dist/core/cursor-capture.d.ts +54 -0
- package/dist/core/cursor-capture.js +215 -0
- package/dist/core/cursor-preflight-probe.d.ts +8 -0
- package/dist/core/cursor-preflight-probe.js +88 -0
- package/dist/core/cursor-preflight.d.ts +86 -0
- package/dist/core/cursor-preflight.js +126 -0
- package/dist/core/gateway/apply-activation.d.ts +39 -0
- package/dist/core/gateway/apply-activation.js +84 -0
- package/dist/core/gateway/apply-policy.d.ts +64 -0
- package/dist/core/gateway/apply-policy.js +221 -0
- package/dist/core/gateway/apply-receipt.d.ts +36 -0
- package/dist/core/gateway/apply-receipt.js +75 -0
- package/dist/core/gateway/cache-proof.d.ts +45 -0
- package/dist/core/gateway/cache-proof.js +65 -0
- package/dist/core/gateway/configure.d.ts +50 -0
- package/dist/core/gateway/configure.js +169 -0
- package/dist/core/gateway/openai-usage.d.ts +56 -0
- package/dist/core/gateway/openai-usage.js +128 -0
- package/dist/core/gateway/receipt.d.ts +138 -0
- package/dist/core/gateway/receipt.js +120 -0
- package/dist/core/gateway/recovery.d.ts +23 -0
- package/dist/core/gateway/recovery.js +68 -0
- package/dist/core/gateway/server.d.ts +51 -0
- package/dist/core/gateway/server.js +276 -0
- package/dist/core/gateway/status.d.ts +45 -0
- package/dist/core/gateway/status.js +109 -0
- package/dist/core/hook-usage-aggregate.d.ts +47 -0
- package/dist/core/hook-usage-aggregate.js +161 -0
- package/dist/core/input-compaction-ab.d.ts +111 -0
- package/dist/core/input-compaction-ab.js +158 -0
- package/dist/core/local-run-record.d.ts +109 -0
- package/dist/core/local-run-record.js +223 -0
- package/dist/core/output-shaping-ab.d.ts +140 -0
- package/dist/core/output-shaping-ab.js +146 -0
- package/dist/core/output-shaping-attach.d.ts +31 -0
- package/dist/core/output-shaping-attach.js +57 -0
- package/dist/core/output-shaping.d.ts +56 -0
- package/dist/core/output-shaping.js +89 -0
- package/dist/core/policy-middleware.d.ts +121 -0
- package/dist/core/policy-middleware.js +919 -0
- package/dist/core/policy-preferences.d.ts +99 -0
- package/dist/core/policy-preferences.js +232 -0
- package/dist/core/run-aggregator.d.ts +11 -1
- package/dist/core/run-aggregator.js +29 -2
- package/dist/core/run-flow-report.d.ts +82 -0
- package/dist/core/run-flow-report.js +71 -0
- package/dist/core/safety-report.js +8 -1
- package/dist/core/shim-capture-bridge.d.ts +32 -0
- package/dist/core/shim-capture-bridge.js +88 -0
- package/dist/core/skill-injection-policy.d.ts +72 -0
- package/dist/core/skill-injection-policy.js +183 -0
- package/dist/core/spend-attribution.js +1 -1
- package/dist/core/token-accounting.d.ts +1 -1
- package/dist/core/tool-shim.d.ts +129 -0
- package/dist/core/tool-shim.js +447 -0
- package/dist/core/trace-parser.d.ts +13 -13
- package/dist/core/trace-parser.js +6 -6
- package/dist/core/types.d.ts +12 -2
- package/dist/core/waste-detector.d.ts +20 -0
- package/dist/core/waste-detector.js +160 -6
- 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
|