@xprnvd/pi-context-view 0.0.0-stage → 0.7.2

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/package.json +55 -5
  4. package/src/capture/adjustments.ts +649 -0
  5. package/src/capture/attribution.ts +25 -0
  6. package/src/capture/builder.ts +128 -0
  7. package/src/capture/diff.ts +455 -0
  8. package/src/capture/dispatch.ts +36 -0
  9. package/src/capture/guard.ts +287 -0
  10. package/src/capture/messages.ts +246 -0
  11. package/src/capture/payload.ts +584 -0
  12. package/src/capture/redact.ts +58 -0
  13. package/src/capture/register.ts +129 -0
  14. package/src/capture/request.ts +218 -0
  15. package/src/capture/tools.ts +69 -0
  16. package/src/capture/tracker.ts +71 -0
  17. package/src/command.ts +164 -0
  18. package/src/compaction.ts +57 -0
  19. package/src/config.ts +408 -0
  20. package/src/index.ts +191 -0
  21. package/src/injections.ts +670 -0
  22. package/src/measure.ts +887 -0
  23. package/src/message-preview.ts +72 -0
  24. package/src/model-identity.ts +48 -0
  25. package/src/model.ts +548 -0
  26. package/src/pi-version.ts +29 -0
  27. package/src/probe/failure.ts +34 -0
  28. package/src/probe/filter.ts +126 -0
  29. package/src/probe/silent-probe.ts +349 -0
  30. package/src/probe/token.ts +28 -0
  31. package/src/probe/trigger.ts +178 -0
  32. package/src/probe/view.ts +26 -0
  33. package/src/projection.ts +122 -0
  34. package/src/prompt-additions.ts +226 -0
  35. package/src/prompt-blocks.ts +80 -0
  36. package/src/replay.ts +234 -0
  37. package/src/settings.ts +62 -0
  38. package/src/snapshot.ts +188 -0
  39. package/src/system-prompt-rows.ts +311 -0
  40. package/src/text.ts +46 -0
  41. package/src/token-diff.ts +211 -0
  42. package/src/transcript.ts +19 -0
  43. package/src/ui/color.ts +52 -0
  44. package/src/ui/diff-preview.ts +181 -0
  45. package/src/ui/injections-model.ts +378 -0
  46. package/src/ui/injections-view.ts +729 -0
  47. package/src/ui/json-preview.ts +41 -0
  48. package/src/ui/layout.ts +148 -0
  49. package/src/ui/markers.ts +253 -0
  50. package/src/ui/notes.ts +117 -0
  51. package/src/ui/section-preview.ts +267 -0
  52. package/src/ui/skill-preview.ts +83 -0
  53. package/src/ui/status-bar.ts +94 -0
  54. package/src/ui/usage-map.ts +157 -0
  55. package/src/ui/usage-preview.ts +194 -0
  56. package/src/ui/usage-view.ts +1255 -0
  57. package/src/ui/wheel.ts +65 -0
  58. package/src/usage.ts +461 -0
@@ -0,0 +1,287 @@
1
+ /**
2
+ * PayloadGuard: deferred comparison of the tool-declaration and message
3
+ * channels, and dispatch confirmation. Physical selections parse
4
+ * immediately after the payload copy; virtual selections keep the copy until
5
+ * dispatch supplies the model.
6
+ */
7
+ import type { ModelIdentity } from "../model-identity.ts";
8
+ import type { Dispatch, GuardFinding, GuardResult } from "../snapshot.ts";
9
+ import {
10
+ type ConvertedRequest, type ConvertibleCapture, convertCapturedRequest, renderExpectedUnits,
11
+ } from "./adjustments.ts";
12
+ import { compareMessageUnits } from "./messages.ts";
13
+ import {
14
+ copyPayload, type PayloadCopy, type PayloadDeclaration, parsePayloadMessages, parsePayloadTools,
15
+ } from "./payload.ts";
16
+ import { resolveForcedPrompt } from "./request.ts";
17
+ import { compareToolDeclarations } from "./tools.ts";
18
+
19
+ /** Model identity and the capabilities message normalization reads. */
20
+ export interface GuardModel extends ModelIdentity {
21
+ /** Input modalities; without `image`, Pi replaces images with placeholders. */
22
+ readonly input: readonly string[];
23
+ /** API-specific compatibility settings; only top-level boolean flags are read. */
24
+ readonly compat?: object;
25
+ }
26
+
27
+ /** What the guard reads from a capture, before releasing the transcript. */
28
+ export interface GuardCapture extends Omit<ConvertibleCapture, "forcedPrompt"> {
29
+ readonly id: number;
30
+ /** `ctx.getSystemPrompt()` at capture; the guard detects a forced prompt from it. */
31
+ readonly effectivePrompt: string;
32
+ readonly requestModel?: GuardModel;
33
+ /** Tools Pi left out of the request; their missing declarations are expected. */
34
+ readonly hiddenTools?: readonly string[];
35
+ }
36
+
37
+ /** Where guard results go; SnapshotBuilder in production. */
38
+ export interface GuardPublisher {
39
+ /** Replace the earlier guard, including on a dispatch mismatch. */
40
+ settleGuard(id: number, guard: GuardResult): void;
41
+ /** Stop accepting updates after the final result. */
42
+ release(id: number): void;
43
+ }
44
+
45
+ /** Ports of the guard. */
46
+ export interface PayloadGuardOptions {
47
+ readonly publisher: GuardPublisher;
48
+ /** Pi's `images.blockImages` setting, read at the payload hook. */
49
+ readonly blockImages: () => boolean;
50
+ }
51
+
52
+ /** Catalog lookup, as `ctx.modelRegistry.find()` does. */
53
+ export type FindModel = (provider: string, modelId: string) => GuardModel | undefined;
54
+
55
+ /** Minimal comparison data; no schemas, image data, or signatures. */
56
+ interface ExpectedRequest {
57
+ readonly declarations: readonly PayloadDeclaration[];
58
+ /** Undefined when the captured request could not be converted. */
59
+ readonly transcript?: ConvertedRequest;
60
+ }
61
+
62
+ /**
63
+ * Result of comparing one channel:
64
+ * compared the channel was compared; `findings` may be empty
65
+ * unsupported the channel could not be compared, for `reason`
66
+ */
67
+ type ChannelResult =
68
+ | { readonly status: "compared"; readonly findings: readonly GuardFinding[] }
69
+ | { readonly status: "unsupported"; readonly reason: string };
70
+
71
+ /** State retained until comparison and dispatch confirmation have both ended. */
72
+ interface PairedState {
73
+ readonly id: number;
74
+ readonly requestModel?: GuardModel;
75
+ readonly blockImages: boolean;
76
+ capture?: GuardCapture;
77
+ expected?: ExpectedRequest;
78
+ payload?: PayloadCopy;
79
+ scheduled?: NodeJS.Immediate;
80
+ /** The virtual route's catalog model, once known. */
81
+ dispatchedModel?: GuardModel;
82
+ dispatch?: Dispatch;
83
+ /** A failure that replaces even a comparison already published on the physical path. */
84
+ failure?: GuardResult;
85
+ finished: boolean;
86
+ }
87
+
88
+ /** Compare payloads and release raw copies before retaining the final snapshots. */
89
+ export class PayloadGuard {
90
+ private readonly options: PayloadGuardOptions;
91
+ private readonly open = new Map<number, PairedState>();
92
+
93
+ public constructor(options: PayloadGuardOptions) {
94
+ this.options = options;
95
+ }
96
+
97
+ /** Copy synchronously in the payload hook; defer all comparison work. */
98
+ public accept(capture: GuardCapture, payload: unknown): void {
99
+ const state: PairedState = {
100
+ id: capture.id,
101
+ requestModel: capture.requestModel,
102
+ capture,
103
+ payload: copyPayload(payload),
104
+ blockImages: this.options.blockImages(),
105
+ finished: false,
106
+ };
107
+ this.open.set(state.id, state);
108
+ this.schedule(state);
109
+ }
110
+
111
+ /**
112
+ * Read identity once, without parsing in the event handler. Physical identity
113
+ * mismatches invalidate the result, never reparse. Virtual
114
+ * requests use the dispatched model's API, not the payload shape.
115
+ */
116
+ public confirm(id: number, dispatch: Dispatch, findModel: FindModel): void {
117
+ const state = this.open.get(id);
118
+ if (state === undefined || state.finished) return;
119
+ state.finished = true;
120
+ state.dispatch = dispatch;
121
+ const model = state.requestModel;
122
+ if (model?.api === "pi-virtual") {
123
+ state.dispatchedModel = findModel(dispatch.provider, dispatch.model);
124
+ if (state.dispatchedModel === undefined || !sameDispatch(toDispatch(state.dispatchedModel), dispatch)) {
125
+ state.failure = { status: "incomplete", reason: "The dispatched model is not registered.", dispatch };
126
+ }
127
+ } else if (model !== undefined && !sameDispatch(toDispatch(model), dispatch)) {
128
+ state.failure = {
129
+ status: "incomplete", reason: "The dispatched provider, API, or model differs from the captured selection.", dispatch,
130
+ };
131
+ }
132
+ this.schedule(state);
133
+ }
134
+
135
+ /** A paired request ended without dispatch metadata; discard any provisional result. */
136
+ public finishUnconfirmed(id: number): void {
137
+ const state = this.open.get(id);
138
+ if (state === undefined || state.finished) return;
139
+ state.finished = true;
140
+ state.failure = { status: "incomplete", reason: "No dispatch identity was observed for this request." };
141
+ this.schedule(state);
142
+ }
143
+
144
+ /** Cancel scheduled work and release payloads and comparison inputs at shutdown. */
145
+ public clear(): void {
146
+ for (const state of this.open.values()) {
147
+ if (state.scheduled !== undefined) clearImmediate(state.scheduled);
148
+ }
149
+ this.open.clear();
150
+ }
151
+
152
+ /** Schedule at most one job per request; failures never escape into the host. */
153
+ private schedule(state: PairedState): void {
154
+ if (state.scheduled !== undefined) return;
155
+ state.scheduled = setImmediate(() => {
156
+ state.scheduled = undefined;
157
+ try {
158
+ this.process(state);
159
+ } catch {
160
+ this.options.publisher.settleGuard(state.id, {
161
+ status: "incomplete", reason: "The payload could not be compared.",
162
+ });
163
+ state.capture = undefined;
164
+ state.expected = undefined;
165
+ state.payload = undefined;
166
+ }
167
+ if (state.finished) {
168
+ this.open.delete(state.id);
169
+ this.options.publisher.release(state.id);
170
+ }
171
+ });
172
+ }
173
+
174
+ /** Reduce the capture to comparison data first, even while a virtual request waits for dispatch. */
175
+ private process(state: PairedState): void {
176
+ if (state.failure !== undefined) {
177
+ this.options.publisher.settleGuard(state.id, state.failure);
178
+ return;
179
+ }
180
+ if (state.capture !== undefined) {
181
+ state.expected = reduceCapture(state.capture, state.blockImages);
182
+ state.capture = undefined;
183
+ }
184
+ const model = state.requestModel?.api === "pi-virtual" ? state.dispatchedModel : state.requestModel;
185
+ if (model === undefined && state.requestModel?.api === "pi-virtual") return;
186
+ const copy = state.payload;
187
+ const expected = state.expected;
188
+ state.payload = undefined;
189
+ state.expected = undefined;
190
+ if (copy === undefined || expected === undefined) return; // Already compared on the physical path
191
+ if (model === undefined) {
192
+ this.options.publisher.settleGuard(state.id, {
193
+ status: "incomplete", reason: "No model was selected for this request.",
194
+ });
195
+ return;
196
+ }
197
+ this.evaluate(state, copy, expected, model);
198
+ }
199
+
200
+ /**
201
+ * Compare both channels and publish their findings. Only two compared
202
+ * channels complete the guard; otherwise the compared one keeps its findings.
203
+ */
204
+ private evaluate(state: PairedState, copy: PayloadCopy, expected: ExpectedRequest, model: GuardModel): void {
205
+ const dispatch = state.dispatch ?? toDispatch(model);
206
+ if (!copy.supported) {
207
+ this.options.publisher.settleGuard(state.id, { status: "incomplete", reason: copy.reason, dispatch });
208
+ return;
209
+ }
210
+ const tools = compareToolChannel(copy.payload, expected, model);
211
+ const messages = compareMessageChannel(copy.payload, expected.transcript, model);
212
+ const compared = [tools, messages].flatMap((channel) => channel.status === "compared" ? [channel.findings] : []);
213
+ const failure = tools.status === "unsupported" ? tools : messages.status === "unsupported" ? messages : undefined;
214
+ const guard: GuardResult = failure === undefined
215
+ ? { status: "complete", dispatch, findings: compared.flat() }
216
+ : {
217
+ status: "incomplete", reason: failure.reason, dispatch,
218
+ ...(compared.length === 0 ? {} : { findings: compared.flat() }),
219
+ };
220
+ this.options.publisher.settleGuard(state.id, guard);
221
+ }
222
+ }
223
+
224
+ /**
225
+ * The comparison data of a capture: the declarations Pi sends, without the
226
+ * tools it hid, and the converted transcript. A request that cannot be
227
+ * converted leaves only the message channel uncompared.
228
+ */
229
+ function reduceCapture(capture: GuardCapture, blockImages: boolean): ExpectedRequest {
230
+ const hidden = new Set(capture.hiddenTools);
231
+ const tools = {
232
+ declarations: (capture.system?.toolsAdded ?? [])
233
+ .filter(({ name }) => !hidden.has(name))
234
+ .map(({ name, description }) => ({ name, description })),
235
+ };
236
+ try {
237
+ const forcedPrompt = resolveForcedPrompt(capture);
238
+ const convertible = forcedPrompt === undefined ? capture : { ...capture, forcedPrompt };
239
+ return { ...tools, transcript: convertCapturedRequest(convertible, { blockImages }) };
240
+ } catch {
241
+ return tools;
242
+ }
243
+ }
244
+
245
+ /** Compare the payload's tool declarations with the expected ones. */
246
+ function compareToolChannel(payload: unknown, expected: ExpectedRequest, model: GuardModel): ChannelResult {
247
+ const parsed = parsePayloadTools(model.api, payload);
248
+ if (parsed.status !== "parsed") return parsed;
249
+ const findings = compareToolDeclarations({
250
+ expected: expected.declarations,
251
+ declarations: parsed.declarations,
252
+ ignoreNameCase: model.api === "anthropic-messages",
253
+ });
254
+ return { status: "compared", findings };
255
+ }
256
+
257
+ /** Compare the payload's text units with those Pi would send for the captured request. */
258
+ function compareMessageChannel(
259
+ payload: unknown,
260
+ transcript: ConvertedRequest | undefined,
261
+ model: GuardModel,
262
+ ): ChannelResult {
263
+ if (transcript === undefined) {
264
+ return { status: "unsupported", reason: "The captured request could not be converted for comparison." };
265
+ }
266
+ const parsed = parsePayloadMessages(model.api, payload);
267
+ if (parsed.status !== "parsed") return parsed;
268
+ let expected;
269
+ try {
270
+ expected = renderExpectedUnits(transcript, model);
271
+ } catch {
272
+ // An unexpected message shape must not hide the tool channel's result
273
+ return { status: "unsupported", reason: "The captured request could not be normalized for this model." };
274
+ }
275
+ const findings = compareMessageUnits(expected, parsed.units, { ignoreNameCase: model.api === "anthropic-messages" });
276
+ return { status: "compared", findings };
277
+ }
278
+
279
+ /** Dispatch identity without mutable model metadata. */
280
+ function toDispatch(model: GuardModel): Dispatch {
281
+ return { provider: model.provider, api: model.api, model: model.id };
282
+ }
283
+
284
+ /** Whether two identities name the same provider, API, and model. */
285
+ function sameDispatch(a: Dispatch, b: Dispatch): boolean {
286
+ return a.provider === b.provider && a.api === b.api && a.model === b.model;
287
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * PayloadGuard's message channel: compare the text units a payload sends
3
+ * with the units Pi would send for the captured request. Keys ignore
4
+ * whitespace; an LCS alignment matches them, and every unit left over is a
5
+ * payload change with its changed lines. Pure functions over process-local data.
6
+ */
7
+ import type { GuardFinding, MessagePart, PayloadChangeLine } from "../snapshot.ts";
8
+ import { alignSequences } from "./diff.ts";
9
+
10
+ /** Unpaired UTF-16 surrogates; Pi removes them from text before sending it. */
11
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
12
+ const WHITESPACE = /\s+/gu;
13
+
14
+ /** Parts that Pi omits when their text is empty; a tool call or result always counts. */
15
+ const OMITTED_WHEN_EMPTY: ReadonlySet<MessagePart> = new Set(["system", "user", "assistant"]);
16
+
17
+ /** One model-facing text unit: a run of one message part's text, a tool call, or a tool result. */
18
+ export interface MessageUnit {
19
+ readonly part: MessagePart;
20
+ /** Text as sent; for a tool call, its arguments as canonical JSON or grammar input. */
21
+ readonly text: string;
22
+ /** Tool name of a tool call. */
23
+ readonly name?: string;
24
+ }
25
+
26
+ /** Options of one message-channel comparison. */
27
+ export interface MessageComparisonOptions {
28
+ /** Match tool-call names without case, as for Claude Code names with an Anthropic OAuth token. */
29
+ readonly ignoreNameCase: boolean;
30
+ }
31
+
32
+ /**
33
+ * Collects units in message order. Text of one part merges with the previous
34
+ * unit until a message boundary or another part, so how a serializer splits a
35
+ * message into blocks does not matter.
36
+ */
37
+ export class UnitCollector {
38
+ private readonly units: MessageUnit[] = [];
39
+ private open = false;
40
+
41
+ /** Start a new message: the next text never merges with earlier units. */
42
+ public boundary(): void {
43
+ this.open = false;
44
+ }
45
+
46
+ /** Add text of a system, user, or assistant part. */
47
+ public text(part: "system" | "user" | "assistant", text: string): void {
48
+ const previous = this.units.at(-1);
49
+ if (this.open && previous?.part === part) {
50
+ this.units[this.units.length - 1] = { part, text: `${previous.text}\n${text}` };
51
+ return;
52
+ }
53
+ this.units.push({ part, text });
54
+ this.open = true;
55
+ }
56
+
57
+ /** Add one tool call; it ends the current text run. */
58
+ public toolCall(name: string, text: string): void {
59
+ this.units.push({ part: "tool-call", name, text });
60
+ this.open = false;
61
+ }
62
+
63
+ /** Add one tool result; it ends the current text run. */
64
+ public toolResult(text: string): void {
65
+ this.units.push({ part: "tool-result", text });
66
+ this.open = false;
67
+ }
68
+
69
+ /** The collected units, in order. */
70
+ public result(): MessageUnit[] {
71
+ return this.units;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Compare the expected units with the payload's units:
77
+ * only in the payload added
78
+ * only in the expected units deleted
79
+ * both with one part between the same matches modified, with the differing lines
80
+ * Units whose text is only whitespace are skipped where Pi omits them too.
81
+ */
82
+ export function compareMessageUnits(
83
+ expected: readonly MessageUnit[],
84
+ sent: readonly MessageUnit[],
85
+ options: MessageComparisonOptions,
86
+ ): GuardFinding[] {
87
+ const key = (unit: MessageUnit) => unitKey(unit, options.ignoreNameCase);
88
+ const [before, after] = trimMatchedEnds(expected, sent, key);
89
+ return findGaps(before.filter(isSent), after.filter(isSent), key).flatMap(classifyGap);
90
+ }
91
+
92
+ /**
93
+ * Lines that differ between two texts, ignoring whitespace and blank lines:
94
+ * removed lines of `before`, then added lines of `after`, for each gap.
95
+ */
96
+ export function diffLines(before: string, after: string): PayloadChangeLine[] {
97
+ const beforeLines = splitLines(before);
98
+ const afterLines = splitLines(after);
99
+ return findGaps(beforeLines, afterLines, normalizeText)
100
+ .flatMap((gap) => [...gap.deleted.map(removedLine), ...gap.added.map(addedLine)]);
101
+ }
102
+
103
+ /** Text without whitespace and unpaired surrogates: what a unit key compares. */
104
+ export function normalizeText(text: string): string {
105
+ return text.replace(LONE_SURROGATE, "").replace(WHITESPACE, "");
106
+ }
107
+
108
+ /**
109
+ * Drop the ends that the alignment would match anyway, so that only the middle
110
+ * builds keys: exactly equal units at the start, units with equal keys right
111
+ * after them, then exactly equal units at the end. The alignment extends the
112
+ * start by key before it trims the end, so the end stops where the start
113
+ * stopped; otherwise a key-equal unit could match on the other side.
114
+ */
115
+ function trimMatchedEnds(
116
+ expected: readonly MessageUnit[],
117
+ sent: readonly MessageUnit[],
118
+ key: (unit: MessageUnit) => string,
119
+ ): [readonly MessageUnit[], readonly MessageUnit[]] {
120
+ let start = 0;
121
+ while (start < expected.length && start < sent.length && unitsEqual(expected[start], sent[start])) start++;
122
+ let expectedStart = start;
123
+ let sentStart = start;
124
+ for (;;) {
125
+ while (expectedStart < expected.length && !isSent(expected[expectedStart])) expectedStart++;
126
+ while (sentStart < sent.length && !isSent(sent[sentStart])) sentStart++;
127
+ if (expectedStart === expected.length || sentStart === sent.length) break;
128
+ if (key(expected[expectedStart]) !== key(sent[sentStart])) break;
129
+ expectedStart++;
130
+ sentStart++;
131
+ }
132
+ let end = 0;
133
+ while (end < expected.length - expectedStart && end < sent.length - sentStart
134
+ && unitsEqual(expected[expected.length - 1 - end], sent[sent.length - 1 - end])) end++;
135
+ return [expected.slice(expectedStart, expected.length - end), sent.slice(sentStart, sent.length - end)];
136
+ }
137
+
138
+ /** Whether two units are exactly equal, which implies equal keys without building them. */
139
+ function unitsEqual(a: MessageUnit, b: MessageUnit): boolean {
140
+ return a.part === b.part && a.name === b.name && a.text === b.text;
141
+ }
142
+
143
+ /** Items between two consecutive aligned matches. */
144
+ interface Gap<Item> {
145
+ readonly deleted: readonly Item[];
146
+ readonly added: readonly Item[];
147
+ }
148
+
149
+ /** Align two sequences by key and return the unmatched items between matches, in order. */
150
+ function findGaps<Item>(before: readonly Item[], after: readonly Item[], key: (item: Item) => string): Gap<Item>[] {
151
+ const ids = new Map<string, number>();
152
+ const intern = (item: Item) => {
153
+ const text = key(item);
154
+ let id = ids.get(text);
155
+ if (id === undefined) {
156
+ id = ids.size;
157
+ ids.set(text, id);
158
+ }
159
+ return id;
160
+ };
161
+ const matches = alignSequences(before.map(intern), after.map(intern));
162
+ const gaps: Gap<Item>[] = [];
163
+ let beforeIndex = 0;
164
+ let afterIndex = 0;
165
+ // A sentinel match after both ends closes the last gap
166
+ for (const match of [...matches, { before: before.length, after: after.length }]) {
167
+ const gap = { deleted: before.slice(beforeIndex, match.before), added: after.slice(afterIndex, match.after) };
168
+ if (gap.deleted.length > 0 || gap.added.length > 0) gaps.push(gap);
169
+ beforeIndex = match.before + 1;
170
+ afterIndex = match.after + 1;
171
+ }
172
+ return gaps;
173
+ }
174
+
175
+ /**
176
+ * Pair each deleted unit with the next unpaired added unit of the same part,
177
+ * keeping order. Deletions come first, then the gap's payload units in order.
178
+ */
179
+ function classifyGap(gap: Gap<MessageUnit>): GuardFinding[] {
180
+ const pairs = new Map<number, MessageUnit>();
181
+ const unpaired: MessageUnit[] = [];
182
+ let searchFrom = 0;
183
+ for (const unit of gap.deleted) {
184
+ const position = gap.added.findIndex((candidate, index) => index >= searchFrom && candidate.part === unit.part);
185
+ if (position === -1) {
186
+ unpaired.push(unit);
187
+ continue;
188
+ }
189
+ pairs.set(position, unit);
190
+ searchFrom = position + 1;
191
+ }
192
+ const findings: GuardFinding[] = unpaired.map((unit) =>
193
+ payloadChange("deleted", unit, unitLines(unit).map(removedLine)));
194
+ for (const [index, unit] of gap.added.entries()) {
195
+ const original = pairs.get(index);
196
+ findings.push(original === undefined
197
+ ? payloadChange("added", unit, unitLines(unit).map(addedLine))
198
+ : payloadChange("modified", unit, diffLines(displayText(original), displayText(unit))));
199
+ }
200
+ return findings;
201
+ }
202
+
203
+ /** A payload-change finding for one unit. */
204
+ function payloadChange(
205
+ change: "added" | "modified" | "deleted",
206
+ unit: MessageUnit,
207
+ lines: readonly PayloadChangeLine[],
208
+ ): GuardFinding {
209
+ return { type: "payload-change", change, part: unit.part, lines };
210
+ }
211
+
212
+ /** Non-blank lines of a unit as findings show it. */
213
+ function unitLines(unit: MessageUnit): string[] {
214
+ return splitLines(displayText(unit));
215
+ }
216
+
217
+ /** Whether a unit reaches the model: Pi drops system, user, and assistant text that is only whitespace. */
218
+ function isSent(unit: MessageUnit): boolean {
219
+ return !OMITTED_WHEN_EMPTY.has(unit.part) || normalizeText(unit.text).length > 0;
220
+ }
221
+
222
+ /** Comparison key: part, tool name, and text without whitespace. */
223
+ function unitKey(unit: MessageUnit, ignoreNameCase: boolean): string {
224
+ const name = unit.name === undefined ? "" : ignoreNameCase ? unit.name.toLowerCase() : unit.name;
225
+ return `${unit.part}\u0000${name}\u0000${normalizeText(unit.text)}`;
226
+ }
227
+
228
+ /** Text of a unit as findings show it; a tool call leads with its name. */
229
+ function displayText(unit: MessageUnit): string {
230
+ return unit.name === undefined ? unit.text : `${unit.name} ${unit.text}`;
231
+ }
232
+
233
+ /** Lines of a text that hold more than whitespace. */
234
+ function splitLines(text: string): string[] {
235
+ return text.split(/\r?\n/).filter((line) => normalizeText(line).length > 0);
236
+ }
237
+
238
+ /** A line only in the payload. */
239
+ function addedLine(text: string): PayloadChangeLine {
240
+ return { type: "added", text };
241
+ }
242
+
243
+ /** A line only in the captured request. */
244
+ function removedLine(text: string): PayloadChangeLine {
245
+ return { type: "removed", text };
246
+ }