@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.
- package/LICENSE +21 -0
- package/README.md +112 -2
- package/package.json +55 -5
- package/src/capture/adjustments.ts +649 -0
- package/src/capture/attribution.ts +25 -0
- package/src/capture/builder.ts +128 -0
- package/src/capture/diff.ts +455 -0
- package/src/capture/dispatch.ts +36 -0
- package/src/capture/guard.ts +287 -0
- package/src/capture/messages.ts +246 -0
- package/src/capture/payload.ts +584 -0
- package/src/capture/redact.ts +58 -0
- package/src/capture/register.ts +129 -0
- package/src/capture/request.ts +218 -0
- package/src/capture/tools.ts +69 -0
- package/src/capture/tracker.ts +71 -0
- package/src/command.ts +164 -0
- package/src/compaction.ts +57 -0
- package/src/config.ts +408 -0
- package/src/index.ts +191 -0
- package/src/injections.ts +670 -0
- package/src/measure.ts +887 -0
- package/src/message-preview.ts +72 -0
- package/src/model-identity.ts +48 -0
- package/src/model.ts +548 -0
- package/src/pi-version.ts +29 -0
- package/src/probe/failure.ts +34 -0
- package/src/probe/filter.ts +126 -0
- package/src/probe/silent-probe.ts +349 -0
- package/src/probe/token.ts +28 -0
- package/src/probe/trigger.ts +178 -0
- package/src/probe/view.ts +26 -0
- package/src/projection.ts +122 -0
- package/src/prompt-additions.ts +226 -0
- package/src/prompt-blocks.ts +80 -0
- package/src/replay.ts +234 -0
- package/src/settings.ts +62 -0
- package/src/snapshot.ts +188 -0
- package/src/system-prompt-rows.ts +311 -0
- package/src/text.ts +46 -0
- package/src/token-diff.ts +211 -0
- package/src/transcript.ts +19 -0
- package/src/ui/color.ts +52 -0
- package/src/ui/diff-preview.ts +181 -0
- package/src/ui/injections-model.ts +378 -0
- package/src/ui/injections-view.ts +729 -0
- package/src/ui/json-preview.ts +41 -0
- package/src/ui/layout.ts +148 -0
- package/src/ui/markers.ts +253 -0
- package/src/ui/notes.ts +117 -0
- package/src/ui/section-preview.ts +267 -0
- package/src/ui/skill-preview.ts +83 -0
- package/src/ui/status-bar.ts +94 -0
- package/src/ui/usage-map.ts +157 -0
- package/src/ui/usage-preview.ts +194 -0
- package/src/ui/usage-view.ts +1255 -0
- package/src/ui/wheel.ts +65 -0
- package/src/usage.ts +461 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injections composition from a request snapshot: the session projection at
|
|
3
|
+
* the snapshot's leaf, rebuilt when the view opens, with the snapshot's
|
|
4
|
+
* request-only changes marked and hidden tools left out, plus the payload
|
|
5
|
+
* guard's payload changes. Every contribution carries the tokens it gained and
|
|
6
|
+
* lost against its base. Messages and payload changes each form one
|
|
7
|
+
* `unattributed` row. Reads the snapshot and current Pi data only; imports no
|
|
8
|
+
* capture or probe module.
|
|
9
|
+
*/
|
|
10
|
+
import { getCurrentSystemMessage } from "@earendil-works/pi-ai";
|
|
11
|
+
import {
|
|
12
|
+
type BuildSystemPromptOptions,
|
|
13
|
+
estimateTokens,
|
|
14
|
+
type SessionEntry,
|
|
15
|
+
type ToolInfo,
|
|
16
|
+
} from "@earendil-works/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
analyzeSystemPrompt,
|
|
20
|
+
isPiPromptPart,
|
|
21
|
+
type PromptChanges,
|
|
22
|
+
SYSTEM_PROMPT_ITEM_ID,
|
|
23
|
+
textTokens,
|
|
24
|
+
type ToolSlice,
|
|
25
|
+
} from "./measure.ts";
|
|
26
|
+
import { messagePreview } from "./message-preview.ts";
|
|
27
|
+
import {
|
|
28
|
+
AGGREGATE_SOURCE,
|
|
29
|
+
buildSnapshot,
|
|
30
|
+
type InjectedReference,
|
|
31
|
+
type InjectionItem,
|
|
32
|
+
type InjectionSection,
|
|
33
|
+
type InjectionSnapshot,
|
|
34
|
+
type RequestChange,
|
|
35
|
+
sumTokenDiffs,
|
|
36
|
+
type TextDiff,
|
|
37
|
+
type TokenDiff,
|
|
38
|
+
} from "./model.ts";
|
|
39
|
+
import { type MessageFilter, type ProjectedMessage, readProjection } from "./projection.ts";
|
|
40
|
+
import type { PromptSourceSlice } from "./prompt-additions.ts";
|
|
41
|
+
import {
|
|
42
|
+
applySystemChanges,
|
|
43
|
+
buildNativeSnapshot,
|
|
44
|
+
markForcedPrompt,
|
|
45
|
+
type NativeSnapshotInput,
|
|
46
|
+
replayedToolSlices,
|
|
47
|
+
splitHiddenTools,
|
|
48
|
+
} from "./replay.ts";
|
|
49
|
+
import type {
|
|
50
|
+
ConversationChange,
|
|
51
|
+
GuardFinding,
|
|
52
|
+
GuardResult,
|
|
53
|
+
MessagePart,
|
|
54
|
+
RequestMessage,
|
|
55
|
+
RequestSnapshot,
|
|
56
|
+
} from "./snapshot.ts";
|
|
57
|
+
import { buildSystemPromptRows, partEditors } from "./system-prompt-rows.ts";
|
|
58
|
+
import { type DiffSide, measureTokenDiff } from "./token-diff.ts";
|
|
59
|
+
import { type SystemMessage, systemMessageText } from "./transcript.ts";
|
|
60
|
+
|
|
61
|
+
/** Row label of a payload change, by the message part it changed. */
|
|
62
|
+
const PAYLOAD_CHANGE_LABELS: Readonly<Record<MessagePart, string>> = {
|
|
63
|
+
system: "system message",
|
|
64
|
+
user: "user message",
|
|
65
|
+
assistant: "assistant message",
|
|
66
|
+
"tool-call": "tool call",
|
|
67
|
+
"tool-result": "tool result",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Session aggregates backing prompt parts when a request replaces or deletes their generated sections. */
|
|
71
|
+
const GENERATED_RECORD_ITEMS: Readonly<Record<string, string>> = {
|
|
72
|
+
"base-prompt:context-files": "context-files",
|
|
73
|
+
"base-prompt:skills": "skills",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Everything the composition reads besides the snapshot itself. */
|
|
77
|
+
export interface InjectionsInput {
|
|
78
|
+
readonly snapshot: RequestSnapshot;
|
|
79
|
+
/** Current session entries; they are append-only, so the snapshot's leaf still rebuilds its baseline. */
|
|
80
|
+
readonly entries: SessionEntry[];
|
|
81
|
+
/** Removes recorded probe messages, as capture did for the baseline. */
|
|
82
|
+
readonly filterMessages: MessageFilter;
|
|
83
|
+
/**
|
|
84
|
+
* Live prompt options; only `customPrompt` is used, to mark dropped blocks.
|
|
85
|
+
* The snapshot's hidden tools replace `hiddenTools`.
|
|
86
|
+
*/
|
|
87
|
+
readonly options: BuildSystemPromptOptions;
|
|
88
|
+
readonly allTools: readonly ToolInfo[];
|
|
89
|
+
/** Loaded extension provenance: labels path extensions and guesses who appended prompt text. */
|
|
90
|
+
readonly promptSources?: readonly PromptSourceSlice[];
|
|
91
|
+
/** Live prompt and active tools, used only when the branch recorded no system state. */
|
|
92
|
+
readonly systemPrompt: string;
|
|
93
|
+
readonly activeToolNames: readonly string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the Injections tree of one request: its replayed prompt and tools,
|
|
98
|
+
* then one row for custom messages and message changes and one for the
|
|
99
|
+
* guard's payload changes. The tools Pi hid are left out; their names supply
|
|
100
|
+
* the description note.
|
|
101
|
+
*/
|
|
102
|
+
export function buildInjectionsSnapshot(input: InjectionsInput): InjectionSnapshot {
|
|
103
|
+
const baseline = readProjection(input.entries, input.snapshot.leafId, input.filterMessages);
|
|
104
|
+
const findings = guardFindings(input.snapshot.guard);
|
|
105
|
+
const prompt = measureRequestPrompt(input, baseline.map(({ message }) => message));
|
|
106
|
+
const items = [
|
|
107
|
+
...prompt.items,
|
|
108
|
+
...measureMessages(input, baseline),
|
|
109
|
+
...measurePayloadChanges(findings),
|
|
110
|
+
];
|
|
111
|
+
return { ...buildSnapshot(items), hiddenTools: prompt.hiddenTools };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The degraded fallback without a request snapshot: the live prompt and tools,
|
|
116
|
+
* compared with pi's default prompt like a captured request.
|
|
117
|
+
*/
|
|
118
|
+
export function buildFallbackInjectionsSnapshot(input: NativeSnapshotInput): InjectionSnapshot {
|
|
119
|
+
const native = measureNativePrompt(input);
|
|
120
|
+
return { ...buildSnapshot(withPromptDiffs(native.items, native.items)), hiddenTools: native.hiddenTools };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Findings of every compared channel; a pending guard has none yet. */
|
|
124
|
+
function guardFindings(guard: GuardResult): readonly GuardFinding[] {
|
|
125
|
+
return guard.status === "pending" ? [] : guard.findings ?? [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ============================================================================
|
|
129
|
+
// Prompt and tools
|
|
130
|
+
// ============================================================================
|
|
131
|
+
|
|
132
|
+
/** Measured prompt and tool items, and names of the tools Pi hid from the model and the items leave out. */
|
|
133
|
+
interface RequestPrompt {
|
|
134
|
+
readonly items: readonly InjectionItem[];
|
|
135
|
+
readonly hiddenTools: readonly string[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Measure the replayed prompt and tools with the request's system changes
|
|
140
|
+
* applied. A forced prompt replaces every section, so only tool changes reach
|
|
141
|
+
* it. A tool Pi hid is left out, whatever structured change it had. A branch
|
|
142
|
+
* with no recorded system state uses the live prompt and tools.
|
|
143
|
+
*/
|
|
144
|
+
function measureRequestPrompt(input: InjectionsInput, baseline: readonly RequestMessage[]): RequestPrompt {
|
|
145
|
+
const hiddenTools = input.snapshot.hiddenTools ?? [];
|
|
146
|
+
const base = getCurrentSystemMessage(baseline);
|
|
147
|
+
const forced = input.snapshot.forcedPrompt;
|
|
148
|
+
if (base === undefined) {
|
|
149
|
+
const live = { ...input, options: { ...input.options, hiddenTools: [...hiddenTools] } };
|
|
150
|
+
const native = measureNativePrompt({ ...live, forcedPrompt: forced });
|
|
151
|
+
const session = forced === undefined ? native : measureNativePrompt(live);
|
|
152
|
+
return { items: withPromptDiffs(native.items, session.items), hiddenTools: native.hiddenTools };
|
|
153
|
+
}
|
|
154
|
+
const request = applySystemChanges(base, input.snapshot.changes.system);
|
|
155
|
+
const declarations = splitHiddenTools(request.declarations, hiddenTools);
|
|
156
|
+
const tools: ToolSlice[] = [
|
|
157
|
+
...replayedToolSlices(request.state, declarations.declared, input.allTools)
|
|
158
|
+
.map((tool) => withToolChange(tool, request.tools.get(tool.name))),
|
|
159
|
+
...replayedToolSlices(base, request.deletedTools, input.allTools)
|
|
160
|
+
.map((tool) => withToolChange(tool, "deleted")),
|
|
161
|
+
];
|
|
162
|
+
const options = { homeDir: process.env.HOME, customPrompt: input.options.customPrompt };
|
|
163
|
+
const additions = { sources: input.promptSources };
|
|
164
|
+
const measured = markForcedPrompt(analyzeSystemPrompt(
|
|
165
|
+
forced ?? systemMessageText(request.state),
|
|
166
|
+
options,
|
|
167
|
+
tools,
|
|
168
|
+
additions,
|
|
169
|
+
forced === undefined ? request.prompt : {},
|
|
170
|
+
), forced);
|
|
171
|
+
// Measure the edited prompt Pi would have sent only when a forced prompt replaced some edit
|
|
172
|
+
const edited = forced !== undefined && hasPromptChanges(request.prompt)
|
|
173
|
+
? analyzeSystemPrompt(systemMessageText(request.state), options, tools, additions, request.prompt)
|
|
174
|
+
: undefined;
|
|
175
|
+
const items = edited === undefined ? measured : withForcedEdits(measured, edited);
|
|
176
|
+
return {
|
|
177
|
+
items: withPromptDiffs(items, measureSessionPrompt(input, base, hiddenTools)),
|
|
178
|
+
hiddenTools: declarations.hiddenNames,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Whether a request changed any prompt section or content, not only tools. */
|
|
183
|
+
function hasPromptChanges(prompt: PromptChanges): boolean {
|
|
184
|
+
return (prompt.sections?.size ?? 0) > 0 || (prompt.deleted?.length ?? 0) > 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Mark the System Prompt parts whose request edit a forced prompt replaced.
|
|
189
|
+
* Pi projects the forced text after every `context_with_system` handler, so
|
|
190
|
+
* captured section and content edits never reach the provider. A part the
|
|
191
|
+
* forced prompt still sent keeps its estimate; an added or modified part it
|
|
192
|
+
* lacks is appended at 0 tokens. An undivided forced prompt has no parts to
|
|
193
|
+
* mark: its Forced marker already says the whole prompt was replaced.
|
|
194
|
+
*/
|
|
195
|
+
function withForcedEdits(items: readonly InjectionItem[], edited: readonly InjectionItem[]): InjectionItem[] {
|
|
196
|
+
const partEdits = new Map<string, InjectionItem>();
|
|
197
|
+
const recordEdits = new Map<string, InjectionItem>();
|
|
198
|
+
for (const item of edited) {
|
|
199
|
+
if (item.id === SYSTEM_PROMPT_ITEM_ID) {
|
|
200
|
+
for (const part of item.children ?? []) if (part.change !== undefined) partEdits.set(part.id, part);
|
|
201
|
+
} else if ((item.kind === "context-file" || item.kind === "skills") && item.change !== undefined) {
|
|
202
|
+
// Generated instruction-file and skill sections become their own items
|
|
203
|
+
recordEdits.set(item.id, item);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return items.map((item) => item.id === SYSTEM_PROMPT_ITEM_ID
|
|
207
|
+
? withForcedPartEdits(item, partEdits)
|
|
208
|
+
: withSentForcedEdit(item, recordEdits.get(item.id)));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** System Prompt with its sent parts marked and the edited parts it lacks appended at 0 tokens. */
|
|
212
|
+
function withForcedPartEdits(prompt: InjectionItem, edits: ReadonlyMap<string, InjectionItem>): InjectionItem {
|
|
213
|
+
const { children, sections } = prompt;
|
|
214
|
+
if (children === undefined || sections === undefined || edits.size === 0) return prompt;
|
|
215
|
+
const sent = children.map((child) => withSentForcedEdit(child, edits.get(child.id)));
|
|
216
|
+
const sentIds = new Set(children.map((child) => child.id));
|
|
217
|
+
// Deleting a part the forced prompt lacks anyway changes nothing
|
|
218
|
+
const unsent = [...edits.values()].filter((edit) => !sentIds.has(edit.id) && edit.change !== "deleted")
|
|
219
|
+
.map((edit): InjectionItem => ({
|
|
220
|
+
id: edit.id, kind: edit.kind, source: edit.source, label: edit.label,
|
|
221
|
+
chars: 0, tokens: 0, text: "", forcedEdit: { text: edit.text, sent: false },
|
|
222
|
+
}));
|
|
223
|
+
return {
|
|
224
|
+
...prompt,
|
|
225
|
+
children: [...sent, ...unsent],
|
|
226
|
+
sections: [
|
|
227
|
+
...sections.map((section, index) => {
|
|
228
|
+
const forcedEdit = sent[index]?.forcedEdit;
|
|
229
|
+
return forcedEdit === undefined ? section : { ...section, forcedEdit };
|
|
230
|
+
}),
|
|
231
|
+
...unsent.map((part): InjectionSection => ({
|
|
232
|
+
label: part.label, text: "", tokens: 0, forcedEdit: part.forcedEdit,
|
|
233
|
+
})),
|
|
234
|
+
],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A sent item with the edit the forced prompt replaced, unless the forced prompt sent that very text. */
|
|
239
|
+
function withSentForcedEdit(item: InjectionItem, edit: InjectionItem | undefined): InjectionItem {
|
|
240
|
+
if (edit === undefined) return item;
|
|
241
|
+
const text = edit.change === "deleted" ? "" : edit.text;
|
|
242
|
+
return text === item.text ? item : { ...item, forcedEdit: { text, sent: true } };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The session's prompt and tools at the snapshot's leaf, measured like the
|
|
247
|
+
* request but without its changes: the base the request's prompt and tool
|
|
248
|
+
* rows compare with.
|
|
249
|
+
*/
|
|
250
|
+
function measureSessionPrompt(
|
|
251
|
+
input: InjectionsInput,
|
|
252
|
+
base: SystemMessage,
|
|
253
|
+
hiddenTools: readonly string[],
|
|
254
|
+
): InjectionItem[] {
|
|
255
|
+
const session = applySystemChanges(base, []);
|
|
256
|
+
const declared = splitHiddenTools(session.declarations, hiddenTools).declared;
|
|
257
|
+
return analyzeSystemPrompt(
|
|
258
|
+
systemMessageText(session.state),
|
|
259
|
+
{ homeDir: process.env.HOME, customPrompt: input.options.customPrompt },
|
|
260
|
+
replayedToolSlices(session.state, declared, input.allTools),
|
|
261
|
+
{ sources: input.promptSources },
|
|
262
|
+
{ replayed: session.prompt.replayed },
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Live prompt and tool items without recorded system state, and the tools Pi hid. */
|
|
267
|
+
function measureNativePrompt(input: NativeSnapshotInput): RequestPrompt {
|
|
268
|
+
const native = buildNativeSnapshot(input);
|
|
269
|
+
return { items: native.groups.flatMap((group) => group.items), hiddenTools: native.hiddenTools ?? [] };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The slice with a request-only change, or unchanged when there is none. */
|
|
273
|
+
function withToolChange(tool: ToolSlice, change: RequestChange | undefined): ToolSlice {
|
|
274
|
+
return change === undefined ? tool : { ...tool, change };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ============================================================================
|
|
278
|
+
// Messages
|
|
279
|
+
// ============================================================================
|
|
280
|
+
|
|
281
|
+
/** A conversation change that references the session message it replaces or removes. */
|
|
282
|
+
type EntryChange = Extract<ConversationChange, { readonly type: "modified" | "deleted" }>;
|
|
283
|
+
|
|
284
|
+
/** One message's preview part and whether the request sent it. */
|
|
285
|
+
interface MeasuredMessage {
|
|
286
|
+
readonly section: InjectionSection;
|
|
287
|
+
readonly sent: boolean;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* One `messages (N)` row for the session's custom messages and every
|
|
292
|
+
* conversation change, or none without either. Its parts follow the session's
|
|
293
|
+
* order, a modified or deleted message in place of its session original, then
|
|
294
|
+
* additions, which carry no position, in capture order. `N` counts the
|
|
295
|
+
* messages the request sent, so deletions are left out.
|
|
296
|
+
*/
|
|
297
|
+
function measureMessages(input: InjectionsInput, baseline: readonly ProjectedMessage[]): InjectionItem[] {
|
|
298
|
+
const changes = input.snapshot.changes.conversation;
|
|
299
|
+
const prompts = userPromptNumbers(input);
|
|
300
|
+
const requestPrompt = input.snapshot.leafId === null ? undefined : prompts.get(input.snapshot.leafId);
|
|
301
|
+
const targets = new Map<ProjectedMessage, EntryChange>();
|
|
302
|
+
const unplaced: ConversationChange[] = [];
|
|
303
|
+
for (const change of changes) {
|
|
304
|
+
// An entry rarely holds more than one conversation message; take the first one not yet changed
|
|
305
|
+
const target = change.type === "added" ? undefined : baseline.find((candidate) =>
|
|
306
|
+
candidate.entryId === change.entryId && candidate.message.role !== "system" && !targets.has(candidate));
|
|
307
|
+
if (target === undefined || change.type === "added") unplaced.push(change);
|
|
308
|
+
else targets.set(target, change);
|
|
309
|
+
}
|
|
310
|
+
const messages: MeasuredMessage[] = [];
|
|
311
|
+
for (const entry of baseline) {
|
|
312
|
+
const change = targets.get(entry);
|
|
313
|
+
const userPrompt = prompts.get(entry.entryId);
|
|
314
|
+
if (change !== undefined) messages.push(changedMessagePart(change, entry.message, userPrompt));
|
|
315
|
+
else if (entry.message.role === "custom") {
|
|
316
|
+
messages.push({ section: { ...messageSection(entry.message), message: { userPrompt } }, sent: true });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
messages.push(...unplaced.map((change) => changedMessagePart(change, undefined, requestPrompt)));
|
|
320
|
+
return messages.length === 0 ? [] : [createMessagesItem(messages)];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Prompt counts on the snapshot's whole root-to-leaf branch, before compaction
|
|
325
|
+
* and context omissions. Probe identities are excluded. Walking parent links
|
|
326
|
+
* leaves out abandoned branches and entries appended after capture.
|
|
327
|
+
*/
|
|
328
|
+
function userPromptNumbers(input: InjectionsInput): Map<string, number> {
|
|
329
|
+
const byId = new Map(input.entries.map((entry) => [entry.id, entry]));
|
|
330
|
+
const branch: SessionEntry[] = [];
|
|
331
|
+
let entry = input.snapshot.leafId === null ? undefined : byId.get(input.snapshot.leafId);
|
|
332
|
+
while (entry !== undefined) {
|
|
333
|
+
branch.push(entry);
|
|
334
|
+
entry = entry.parentId === null ? undefined : byId.get(entry.parentId);
|
|
335
|
+
}
|
|
336
|
+
const numbers = new Map<string, number>();
|
|
337
|
+
let count = 0;
|
|
338
|
+
for (const entry of branch.reverse()) {
|
|
339
|
+
if (entry.type === "message" && entry.message.role === "user"
|
|
340
|
+
&& input.filterMessages([entry.message]).length > 0) count++;
|
|
341
|
+
if (count > 0) numbers.set(entry.id, count);
|
|
342
|
+
}
|
|
343
|
+
return numbers;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The one messages row: the sent messages' estimates and diffs, and every message's part. */
|
|
347
|
+
function createMessagesItem(messages: readonly MeasuredMessage[]): InjectionItem {
|
|
348
|
+
const sections = messages.map((message) => message.section);
|
|
349
|
+
const text = messages.filter((message) => message.sent).map((message) => message.section.text).join("\n\n");
|
|
350
|
+
const diff = sumTokenDiffs(sections.map((section) => section.diff));
|
|
351
|
+
return {
|
|
352
|
+
id: "messages",
|
|
353
|
+
kind: "message",
|
|
354
|
+
source: AGGREGATE_SOURCE,
|
|
355
|
+
label: `messages (${messages.filter((message) => message.sent).length})`,
|
|
356
|
+
chars: text.length,
|
|
357
|
+
tokens: sections.reduce((sum, section) => sum + section.tokens, 0),
|
|
358
|
+
text,
|
|
359
|
+
sections,
|
|
360
|
+
...diffField(diff),
|
|
361
|
+
...(sections.some((section) => section.requestOnly === true) ? { requestOnly: true } : {}),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* One request-only message part with its token diff against the session.
|
|
367
|
+
* A modification keeps its redacted original for a preview-only word diff;
|
|
368
|
+
* a deletion reads 0 tokens and shows its original as removed text.
|
|
369
|
+
*/
|
|
370
|
+
function changedMessagePart(
|
|
371
|
+
change: ConversationChange,
|
|
372
|
+
original: RequestMessage | undefined,
|
|
373
|
+
userPrompt: number | undefined,
|
|
374
|
+
): MeasuredMessage {
|
|
375
|
+
switch (change.type) {
|
|
376
|
+
case "added": {
|
|
377
|
+
const section = messageSection(change.message, true);
|
|
378
|
+
const diff = measureTokenDiff(undefined, section);
|
|
379
|
+
return {
|
|
380
|
+
section: { ...section, change: "added", requestOnly: true, message: { userPrompt }, ...diffField(diff) },
|
|
381
|
+
sent: true,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
case "modified": {
|
|
385
|
+
const request = { ...messageSection(change.message, true), change: "modified" as const, requestOnly: true,
|
|
386
|
+
message: { userPrompt } };
|
|
387
|
+
if (original === undefined) return { section: request, sent: true };
|
|
388
|
+
const session = messageSection(original);
|
|
389
|
+
const diff = measureTokenDiff(session, request);
|
|
390
|
+
return {
|
|
391
|
+
section: { ...request, ...diffField(diff),
|
|
392
|
+
message: { userPrompt, original: { text: session.text, jsonSpan: session.jsonSpan } } },
|
|
393
|
+
sent: true,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
case "deleted": {
|
|
397
|
+
const deleted = { change: "deleted" as const, requestOnly: true, message: { userPrompt } };
|
|
398
|
+
if (original === undefined) {
|
|
399
|
+
return { section: { label: change.attribution.customType ?? "message", text: "", tokens: 0, ...deleted },
|
|
400
|
+
sent: false };
|
|
401
|
+
}
|
|
402
|
+
const session = messageSection(original);
|
|
403
|
+
const diff = measureTokenDiff(session, { text: "", tokens: 0 });
|
|
404
|
+
return { section: { ...session, tokens: 0, ...deleted, ...diffField(diff) }, sent: false };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** One message as a preview part: labeled by its customType or role, with its content-only preview. */
|
|
410
|
+
function messageSection(message: RequestMessage, redacted = false): InjectionSection {
|
|
411
|
+
const { text, jsonSpan } = messagePreview(message, redacted);
|
|
412
|
+
return {
|
|
413
|
+
label: message.role === "custom" ? message.customType : message.role,
|
|
414
|
+
text,
|
|
415
|
+
tokens: estimateTokens(message),
|
|
416
|
+
...(jsonSpan === undefined ? {} : { jsonSpan }),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ============================================================================
|
|
421
|
+
// Payload changes
|
|
422
|
+
// ============================================================================
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* One `payload changes (N)` row with one part per finding, in the guard's
|
|
426
|
+
* order, or none without findings. `N` counts every finding, deletions
|
|
427
|
+
* included. The guard keeps only the changed lines, so each part counts the
|
|
428
|
+
* lines the payload added, and a deletion reads 0 tokens. Its token diff
|
|
429
|
+
* compares the added lines with the removed ones, the lines of the captured
|
|
430
|
+
* request.
|
|
431
|
+
*/
|
|
432
|
+
function measurePayloadChanges(findings: readonly GuardFinding[]): InjectionItem[] {
|
|
433
|
+
if (findings.length === 0) return [];
|
|
434
|
+
const sections = findings.map((finding): InjectionSection => {
|
|
435
|
+
const text = finding.lines.filter((line) => line.type === "added").map((line) => line.text).join("\n");
|
|
436
|
+
const removed = finding.lines.filter((line) => line.type === "removed").map((line) => line.text).join("\n");
|
|
437
|
+
const base = removed.length === 0 ? undefined : { text: removed, tokens: textTokens(removed) };
|
|
438
|
+
const tokens = textTokens(text);
|
|
439
|
+
return {
|
|
440
|
+
label: finding.type === "payload-change" ? PAYLOAD_CHANGE_LABELS[finding.part] : finding.name,
|
|
441
|
+
text,
|
|
442
|
+
tokens,
|
|
443
|
+
...diffField(measureTokenDiff(base, { text, tokens })),
|
|
444
|
+
...(finding.type === "payload-tool-change" ? { toolDeclaration: true } : {}),
|
|
445
|
+
changedLines: finding.lines,
|
|
446
|
+
};
|
|
447
|
+
});
|
|
448
|
+
const text = sections.map((section) => section.text).filter((part) => part.length > 0).join("\n");
|
|
449
|
+
return [{
|
|
450
|
+
id: "payload-changes",
|
|
451
|
+
kind: "payload-change",
|
|
452
|
+
source: AGGREGATE_SOURCE,
|
|
453
|
+
label: `payload changes (${findings.length})`,
|
|
454
|
+
chars: text.length,
|
|
455
|
+
tokens: sections.reduce((sum, section) => sum + section.tokens, 0),
|
|
456
|
+
text,
|
|
457
|
+
sections,
|
|
458
|
+
...diffField(sumTokenDiffs(sections.map((section) => section.diff))),
|
|
459
|
+
}];
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ============================================================================
|
|
463
|
+
// Token diffs
|
|
464
|
+
// ============================================================================
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Prompt and tool items with the tokens each gained and lost against its base.
|
|
468
|
+
* Rows under pi compare with pi's default prompt: the session's version of a
|
|
469
|
+
* pi part without the extension lines measured in it, so saved and
|
|
470
|
+
* request-only extension text both count as added. A custom section and
|
|
471
|
+
* Extension Additions are extension text with no default; a deleted custom
|
|
472
|
+
* section compares with the session instead, so its deletion stays visible.
|
|
473
|
+
* Tools, instruction files, and skills compare with the session. A parent sums
|
|
474
|
+
* its children, plus the removal of children only the session has. Each leaf
|
|
475
|
+
* also learns whether it is request-only, and each System Prompt part whether
|
|
476
|
+
* the session saves part of its diff and who made it. The `system prompt` rows
|
|
477
|
+
* that split these prompt changes by source follow the items.
|
|
478
|
+
*/
|
|
479
|
+
function withPromptDiffs(items: readonly InjectionItem[], session: readonly InjectionItem[]): InjectionItem[] {
|
|
480
|
+
const sessionById = new Map<string, InjectionItem>();
|
|
481
|
+
for (const item of session) {
|
|
482
|
+
sessionById.set(item.id, item);
|
|
483
|
+
for (const child of item.children ?? []) sessionById.set(child.id, child);
|
|
484
|
+
}
|
|
485
|
+
const context: DiffContext = {
|
|
486
|
+
sessionById,
|
|
487
|
+
sessionItems: session,
|
|
488
|
+
sentIds: new Set(items.map((item) => item.id)),
|
|
489
|
+
forced: items.some((item) => item.id === SYSTEM_PROMPT_ITEM_ID && item.change === "forced"),
|
|
490
|
+
};
|
|
491
|
+
const diffed = items.map((item) => withItemDiff(item, context, false));
|
|
492
|
+
return [...diffed, ...buildSystemPromptRows(diffed, session)];
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** The session's prompt and tool items that request items compare with. */
|
|
496
|
+
interface DiffContext {
|
|
497
|
+
/** Session items and their children, by id. */
|
|
498
|
+
readonly sessionById: ReadonlyMap<string, InjectionItem>;
|
|
499
|
+
/** Top-level session items, which an undivided System Prompt compares with. */
|
|
500
|
+
readonly sessionItems: readonly InjectionItem[];
|
|
501
|
+
/** Ids of the request's top-level items. */
|
|
502
|
+
readonly sentIds: ReadonlySet<string>;
|
|
503
|
+
/** True when the request carried a forced prompt, whose text the session never keeps. */
|
|
504
|
+
readonly forced: boolean;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* One item with its diff and, for a leaf, its request-only state; `promptPart`
|
|
509
|
+
* marks a System Prompt part. Its sections mirror its children one to one, so
|
|
510
|
+
* preview subheaders show the same diffs and markers as the rows.
|
|
511
|
+
*/
|
|
512
|
+
function withItemDiff(item: InjectionItem, context: DiffContext, promptPart: boolean): InjectionItem {
|
|
513
|
+
// An unsent part stands for an edit, not text: its parent counts any session text it lacks
|
|
514
|
+
if (item.dropped === true || item.forcedEdit?.sent === false) return item;
|
|
515
|
+
const children = item.children ?? [];
|
|
516
|
+
if (children.length === 0) {
|
|
517
|
+
const base = itemBase(item, context, promptPart);
|
|
518
|
+
const request: DiffSide = item.change === "deleted" ? { text: "", tokens: 0 } : item;
|
|
519
|
+
const requestOnly = isRequestOnly(item, context);
|
|
520
|
+
const diffed: InjectionItem = {
|
|
521
|
+
...item,
|
|
522
|
+
...diffField(measureTokenDiff(base, request)),
|
|
523
|
+
...(requestOnly ? {
|
|
524
|
+
requestOnly: true,
|
|
525
|
+
sessionOriginal: context.sessionById.has(item.id) || base !== undefined,
|
|
526
|
+
} : {}),
|
|
527
|
+
};
|
|
528
|
+
if (!promptPart) return diffed;
|
|
529
|
+
const editors = partEditors(diffed);
|
|
530
|
+
const textDiff = diffed.diff === undefined ? undefined : partTextDiff(item, base, request);
|
|
531
|
+
return {
|
|
532
|
+
...diffed,
|
|
533
|
+
...(hasSavedChange(item, base, context) ? { savedChange: true } : {}),
|
|
534
|
+
...(editors === undefined ? {} : { editors }),
|
|
535
|
+
...(textDiff === undefined ? {} : { textDiff }),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
const parts = item.id === SYSTEM_PROMPT_ITEM_ID;
|
|
539
|
+
const diffed = children.map((child) => withItemDiff(child, context, parts));
|
|
540
|
+
const sent = new Set(children.filter((child) => child.forcedEdit?.sent !== false).map((child) => child.id));
|
|
541
|
+
const sessionOnly = (context.sessionById.get(item.id)?.children ?? []).filter((child) => !sent.has(child.id));
|
|
542
|
+
const diff = sumTokenDiffs([
|
|
543
|
+
...diffed.map((child) => child.diff),
|
|
544
|
+
...sessionOnly.map((child) => removedDiff(defaultSide(child, parts))),
|
|
545
|
+
]);
|
|
546
|
+
return {
|
|
547
|
+
...item,
|
|
548
|
+
...diffField(diff),
|
|
549
|
+
children: diffed,
|
|
550
|
+
sections: item.sections?.map((section, index) => ({
|
|
551
|
+
...section,
|
|
552
|
+
...diffField(diffed[index]?.diff),
|
|
553
|
+
...(diffed[index]?.requestOnly === true ? { requestOnly: true } : {}),
|
|
554
|
+
...(diffed[index]?.textDiff === undefined ? {} : { textDiff: diffed[index]?.textDiff }),
|
|
555
|
+
})),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* The texts behind one changed System Prompt part's diff, against the same
|
|
561
|
+
* base as its token diff. Undefined for a part with extension lines: their
|
|
562
|
+
* highlighted, annotated lines show the change, and their owners' rows
|
|
563
|
+
* split the diff, so one body would not match each row's share.
|
|
564
|
+
*/
|
|
565
|
+
function partTextDiff(item: InjectionItem, base: DiffSide | undefined, request: DiffSide): TextDiff | undefined {
|
|
566
|
+
if ((item.injectedReferences?.length ?? 0) > 0) return undefined;
|
|
567
|
+
return { base: base?.text ?? "", request: request.text };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Whether the request sent other content than the session keeps for one leaf:
|
|
572
|
+
* a request-only addition, edit, or deletion, or forced prompt text that
|
|
573
|
+
* differs from the session's version. Generated instruction files and skills
|
|
574
|
+
* compare their child text with the session too: section changes mark the
|
|
575
|
+
* aggregate, not its individual records. Tool changes reach a forced prompt
|
|
576
|
+
* as usual, and the undivided System Prompt carries only its `forced` change.
|
|
577
|
+
*/
|
|
578
|
+
function isRequestOnly(item: InjectionItem, context: DiffContext): boolean {
|
|
579
|
+
if (item.change !== undefined && item.change !== "forced") return true;
|
|
580
|
+
if (item.kind === "tool" || item.id === SYSTEM_PROMPT_ITEM_ID) return false;
|
|
581
|
+
if (!context.forced && item.kind !== "context-file" && item.kind !== "skills") return false;
|
|
582
|
+
return context.sessionById.get(item.id)?.text !== item.text;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Whether the session's own version of a System Prompt part already differs
|
|
587
|
+
* from the part's base, so the session saves part of its diff: extension
|
|
588
|
+
* lines in a pi part, or an extension's own part the session keeps. A
|
|
589
|
+
* deletion compares with the session, so none of it is saved.
|
|
590
|
+
*/
|
|
591
|
+
function hasSavedChange(item: InjectionItem, base: DiffSide | undefined, context: DiffContext): boolean {
|
|
592
|
+
if (item.change === "deleted") return false;
|
|
593
|
+
const session = context.sessionById.get(item.id);
|
|
594
|
+
if (session === undefined || session.dropped === true) return false;
|
|
595
|
+
return base === undefined || base.text !== session.text;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* What one request item compares with: pi's default text of a pi part, no
|
|
600
|
+
* base for an extension's own part unless the request deleted it, pi's whole
|
|
601
|
+
* default prompt for an undivided System Prompt, or the session's version of
|
|
602
|
+
* anything else. Undefined counts the whole item as added.
|
|
603
|
+
*/
|
|
604
|
+
function itemBase(item: InjectionItem, context: DiffContext, promptPart: boolean): DiffSide | undefined {
|
|
605
|
+
if (item.id === SYSTEM_PROMPT_ITEM_ID) return defaultPromptSide(context);
|
|
606
|
+
// A generated section changes into a prompt part when replaced or deleted
|
|
607
|
+
const recordId = GENERATED_RECORD_ITEMS[item.id];
|
|
608
|
+
const records = recordId === undefined ? undefined : context.sessionById.get(recordId);
|
|
609
|
+
if (promptPart && records !== undefined) return records;
|
|
610
|
+
const session = context.sessionById.get(item.id);
|
|
611
|
+
if (promptPart && !isPiPromptPart(item.id)) {
|
|
612
|
+
if (item.change !== "deleted") return undefined;
|
|
613
|
+
return session ?? { text: item.text, tokens: textTokens(item.text) };
|
|
614
|
+
}
|
|
615
|
+
return session === undefined ? undefined : defaultSide(session, promptPart);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* What pi's default prompt holds of one session item: a pi part without the
|
|
620
|
+
* extension lines in it, nothing of an extension's own part or of a part a
|
|
621
|
+
* replacement dropped, and any other item whole.
|
|
622
|
+
*/
|
|
623
|
+
function defaultSide(item: InjectionItem, promptPart: boolean): DiffSide | undefined {
|
|
624
|
+
if (item.dropped === true) return undefined;
|
|
625
|
+
if (!promptPart) return item;
|
|
626
|
+
if (!isPiPromptPart(item.id)) return undefined;
|
|
627
|
+
const references = item.injectedReferences ?? [];
|
|
628
|
+
if (references.length === 0) return item;
|
|
629
|
+
const text = withoutReferences(item.text, references);
|
|
630
|
+
return { text, tokens: textTokens(text) };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Pi's whole default prompt, for a System Prompt without parts, such as a
|
|
635
|
+
* forced prompt without sections: the default text of every session part, and
|
|
636
|
+
* the instruction files and skills the request does not list separately.
|
|
637
|
+
*/
|
|
638
|
+
function defaultPromptSide(context: DiffContext): DiffSide | undefined {
|
|
639
|
+
const prompt = context.sessionById.get(SYSTEM_PROMPT_ITEM_ID);
|
|
640
|
+
if (prompt === undefined) return undefined;
|
|
641
|
+
const parts = prompt.children?.map((part) => defaultSide(part, true)) ?? [prompt];
|
|
642
|
+
const records = context.sessionItems.filter((item) =>
|
|
643
|
+
(item.kind === "context-file" || item.kind === "skills") && !context.sentIds.has(item.id));
|
|
644
|
+
const sides = [...parts, ...records].filter((side): side is DiffSide => side !== undefined && side.text.length > 0);
|
|
645
|
+
return {
|
|
646
|
+
text: sides.map((side) => side.text).join("\n"),
|
|
647
|
+
tokens: sides.reduce((sum, side) => sum + side.tokens, 0),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Text without the extension lines its references cover. */
|
|
652
|
+
function withoutReferences(text: string, references: readonly InjectedReference[]): string {
|
|
653
|
+
let kept = "";
|
|
654
|
+
let offset = 0;
|
|
655
|
+
for (const reference of [...references].sort((a, b) => a.start - b.start)) {
|
|
656
|
+
kept += text.slice(offset, reference.start);
|
|
657
|
+
offset = Math.max(offset, reference.end);
|
|
658
|
+
}
|
|
659
|
+
return kept + text.slice(offset);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** The removal of a whole base, or undefined when there is none. */
|
|
663
|
+
function removedDiff(base: DiffSide | undefined): TokenDiff | undefined {
|
|
664
|
+
return base === undefined ? undefined : measureTokenDiff(base, { text: "", tokens: 0 });
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** `{ diff }` when there is one, so unchanged items carry no extra key. */
|
|
668
|
+
function diffField(diff: TokenDiff | undefined): { readonly diff?: TokenDiff } {
|
|
669
|
+
return diff === undefined ? {} : { diff };
|
|
670
|
+
}
|