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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -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 +514 -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 +251 -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 +487 -0
  46. package/src/ui/injections-view.ts +1023 -0
  47. package/src/ui/json-preview.ts +41 -0
  48. package/src/ui/layout.ts +161 -0
  49. package/src/ui/markers.ts +253 -0
  50. package/src/ui/notes.ts +117 -0
  51. package/src/ui/search.ts +427 -0
  52. package/src/ui/section-preview.ts +267 -0
  53. package/src/ui/skill-preview.ts +83 -0
  54. package/src/ui/status-bar.ts +94 -0
  55. package/src/ui/usage-map.ts +157 -0
  56. package/src/ui/usage-preview.ts +202 -0
  57. package/src/ui/usage-view.ts +1428 -0
  58. package/src/ui/wheel.ts +65 -0
  59. package/src/usage.ts +461 -0
package/src/measure.ts ADDED
@@ -0,0 +1,887 @@
1
+ /**
2
+ * Pure measurement logic: split a captured system prompt into semantic items
3
+ * and estimate token sizes. No pi API access — unit-testable.
4
+ *
5
+ * Pi renders independently replaceable XML sections. Their wrappers locate
6
+ * content; custom sections remain System Prompt parts rather than additions.
7
+ * Unwrapped text between or after sections is an extension addition. In the
8
+ * `tools` and `rules` sections, pi renders each distinct bullet exactly once,
9
+ * in active-tool order, so tool lines are annotated with their tools. System
10
+ * Prompt counts all of its text, extension lines included.
11
+ */
12
+ import {
13
+ AGGREGATE_SOURCE,
14
+ BUILT_IN_TOOLS_LABEL,
15
+ type ExtensionProvenance,
16
+ extensionSource,
17
+ type InjectedReference,
18
+ INSTRUCTION_FILES_LABEL,
19
+ type InjectionItem,
20
+ type InjectionKind,
21
+ type InjectionSection,
22
+ type InjectionSource,
23
+ type JsonSpan,
24
+ PI_SOURCE,
25
+ type RequestChange,
26
+ SKILLS_LABEL,
27
+ SYSTEM_PROMPT_LABEL,
28
+ type TextSpan,
29
+ } from "./model.ts";
30
+ import { type PromptAdditionOptions, splitPromptAdditions } from "./prompt-additions.ts";
31
+ import {
32
+ AVAILABLE_TOOLS_BLOCK,
33
+ BASE_PROMPT_BLOCKS,
34
+ DOCUMENTATION_BLOCK,
35
+ findPromptSections,
36
+ findSectionToolBlocks,
37
+ GUIDELINES_BLOCK,
38
+ type LocatedPromptBlock,
39
+ type PromptSection,
40
+ } from "./prompt-blocks.ts";
41
+
42
+ /** Part names shared by a tool's dropped prompt lines and pi's own prompt blocks. */
43
+ const AVAILABLE_TOOLS_LABEL = AVAILABLE_TOOLS_BLOCK.label;
44
+ const GUIDELINES_LABEL = GUIDELINES_BLOCK.label;
45
+
46
+ /** System Prompt part holding the unwrapped text extensions added around pi's sections. */
47
+ const EXTENSION_ADDITIONS_BLOCK = { id: "base-prompt:additions", label: "Extension Additions" };
48
+
49
+ /** Leading part of pi's prompt, before its first XML section. */
50
+ const PREAMBLE_BLOCK = { id: "base-prompt:preamble", label: "Preamble" };
51
+
52
+ /** Id of the System Prompt item, which holds pi's whole prompt. */
53
+ export const SYSTEM_PROMPT_ITEM_ID = "base-prompt";
54
+
55
+ /** `ToolSlice.source` of a recorded tool no extension registers now: its owner is unknown. */
56
+ export const UNREGISTERED_TOOL_SOURCE = "unattributed";
57
+
58
+ /** Known XML section names use Pi terminology while retaining existing semantic ids. */
59
+ const SECTION_PARTS: Readonly<Record<string, { id: string; label: string }>> = {
60
+ tools: AVAILABLE_TOOLS_BLOCK,
61
+ rules: GUIDELINES_BLOCK,
62
+ docs: DOCUMENTATION_BLOCK,
63
+ addendum: { id: "base-prompt:appended", label: "Addendum" },
64
+ cwd: { id: "base-prompt:current-dir", label: "Current Dir" },
65
+ project_context: { id: "base-prompt:context-files", label: INSTRUCTION_FILES_LABEL },
66
+ skills: { id: "base-prompt:skills", label: SKILLS_LABEL },
67
+ };
68
+
69
+ /** Ids of the System Prompt parts pi renders itself; custom sections and Extension Additions are not among them. */
70
+ const PI_PROMPT_PART_IDS: ReadonlySet<string> = new Set([
71
+ PREAMBLE_BLOCK.id,
72
+ ...Object.values(SECTION_PARTS).map((part) => part.id),
73
+ ]);
74
+
75
+ /**
76
+ * Whether a System Prompt part is one pi renders itself, so pi's default
77
+ * prompt has it too. A custom section is an extension's own, and Extension
78
+ * Additions hold only text extensions added.
79
+ */
80
+ export function isPiPromptPart(id: string): boolean {
81
+ return PI_PROMPT_PART_IDS.has(id);
82
+ }
83
+
84
+ /** Minimal slice of BuildSystemPromptOptions that measurement needs. */
85
+ export interface PromptOptionsSlice {
86
+ /** Home directory used to abbreviate context-file paths; omitted disables it. */
87
+ homeDir?: string;
88
+ /** Replacement preamble; pi's native sections it leaves absent are marked dropped. */
89
+ customPrompt?: string;
90
+ /** Custom section bodies, including overrides of Pi's named sections. */
91
+ sections?: Record<string, string>;
92
+ }
93
+
94
+ /** One active tool as it contributes to the initial context. */
95
+ export interface ToolSlice {
96
+ name: string;
97
+ /** Sent to the provider with every request. */
98
+ description: string;
99
+ /** JSON-serialized parameter schema; sent to the provider with every request. */
100
+ parametersJson: string;
101
+ /** One-line snippet rendered into the prompt's Available tools list. */
102
+ snippet?: string;
103
+ /** Guideline bullets rendered into the prompt's Guidelines section. */
104
+ guidelines: string[];
105
+ /** Provenance, e.g. "builtin", "npm:pi-web-providers", or `UNREGISTERED_TOOL_SOURCE`. */
106
+ source: string;
107
+ /** Entry path of the owning extension; tells path extensions (`cli`, `local`) apart. */
108
+ sourcePath?: string;
109
+ /** Request-only change of the declaration; a deleted tool keeps only its uncounted definition. */
110
+ change?: RequestChange;
111
+ }
112
+
113
+ /** One section the request deleted, as the session recorded it. */
114
+ export interface DeletedSection {
115
+ readonly name: string;
116
+ /** Recorded section text, including its XML wrapper. */
117
+ readonly text: string;
118
+ }
119
+
120
+ /** Request-only prompt changes and their replayed layout; a forced prompt ignores them. */
121
+ export interface PromptChanges {
122
+ /** Added or changed sections by name; `preamble` also stands for changed plain content. */
123
+ readonly sections?: ReadonlyMap<string, Exclude<RequestChange, "deleted" | "forced">>;
124
+ /** Sections the request removed, in session order. */
125
+ readonly deleted?: readonly DeletedSection[];
126
+ /** Exact recorded layout, including sections without Pi's usual XML framing. */
127
+ readonly replayed?: { readonly content: string; readonly sections: Readonly<Record<string, string | null>> };
128
+ }
129
+
130
+ /**
131
+ * Split a captured system prompt into semantic items: pi base prompt,
132
+ * appended prompt, context files, skills, and active tool definitions. The
133
+ * unwrapped text extensions added around pi's sections is the System Prompt's
134
+ * Extension Additions part. `changes` marks the parts a request-only change
135
+ * touched.
136
+ */
137
+ export function analyzeSystemPrompt(
138
+ systemPrompt: string,
139
+ options: PromptOptionsSlice,
140
+ tools: ToolSlice[] = [],
141
+ additions: PromptAdditionOptions = {},
142
+ changes: PromptChanges = {},
143
+ ): InjectionItem[] {
144
+ const names = new Set<string>();
145
+ const sections = (changes.replayed === undefined
146
+ ? findPromptSections(systemPrompt)
147
+ : locateReplayedSections(changes.replayed)).filter((section) => {
148
+ if (names.has(section.name)) return false;
149
+ names.add(section.name);
150
+ return true;
151
+ });
152
+ if (sections.length === 0 && changes.replayed === undefined) {
153
+ return analyzeForcedPrompt(systemPrompt, tools, additions.sources ?? []);
154
+ }
155
+ return analyzePromptSections(systemPrompt, sections, options, tools, additions, changes);
156
+ }
157
+
158
+ /**
159
+ * Locate sections from the replay's exact join order, not by searching their
160
+ * text. Request patches may supply inline wrappers, unwrapped text, or empty
161
+ * sections; identical text elsewhere in the prompt must not steal their marker.
162
+ */
163
+ function locateReplayedSections(state: NonNullable<PromptChanges["replayed"]>): PromptSection[] {
164
+ const located: PromptSection[] = [];
165
+ let offset = state.content.length;
166
+ for (const [name, text] of Object.entries(state.sections)) {
167
+ if (text === null) continue;
168
+ const start = offset + (offset > 0 && text.length > 0 ? 2 : 0);
169
+ const end = start + text.length;
170
+ offset = end;
171
+ if (name === "preamble") continue;
172
+ const open = `<${name}>`;
173
+ const close = `</${name}>`;
174
+ const wrapped = text.startsWith(open) && text.endsWith(close);
175
+ let bodyStart = wrapped ? start + open.length : start;
176
+ let bodyEnd = wrapped ? end - close.length : end;
177
+ if (wrapped && text[open.length] === "\n") bodyStart++;
178
+ if (wrapped && text[text.length - close.length - 1] === "\n") bodyEnd--;
179
+ // PromptSection includes the character before its body for tool bullet attribution
180
+ located.push({ name, start, end, body: { start: bodyStart - 1, end: Math.max(bodyStart, bodyEnd) } });
181
+ }
182
+ return located;
183
+ }
184
+
185
+ /**
186
+ * Pi always renders a `cwd` section, so only a forced prompt has none. It is
187
+ * one undivided System Prompt part: no text in it is evidence of a block, an
188
+ * addition, or a tool line.
189
+ */
190
+ function analyzeForcedPrompt(
191
+ prompt: string,
192
+ tools: ToolSlice[],
193
+ roster: readonly ExtensionProvenance[],
194
+ ): InjectionItem[] {
195
+ const items: InjectionItem[] = [];
196
+ measureTools(prompt, tools, items, [], [], roster);
197
+ items.unshift(createSystemPromptItem([{ ...PREAMBLE_BLOCK, kind: "base-prompt", text: prompt }]));
198
+ return items;
199
+ }
200
+
201
+ /** Measure XML section bodies once, keeping arbitrary sections out of extension attribution. */
202
+ function analyzePromptSections(
203
+ prompt: string,
204
+ sections: readonly PromptSection[],
205
+ options: PromptOptionsSlice,
206
+ tools: ToolSlice[],
207
+ additions: PromptAdditionOptions,
208
+ changes: PromptChanges,
209
+ ): InjectionItem[] {
210
+ const items: InjectionItem[] = [];
211
+ const parts: PromptPart[] = [];
212
+ const blocks = findSectionToolBlocks(prompt, sections);
213
+ const replaced = Boolean(options.customPrompt);
214
+ const droppedIds = replaced ? BASE_PROMPT_BLOCKS
215
+ .filter((block) => !sections.some((section) => SECTION_PARTS[section.name]?.id === block.id)
216
+ && !changes.deleted?.some((section) => SECTION_PARTS[section.name]?.id === block.id))
217
+ .map((block) => block.id) : [];
218
+ const lines = measureTools(prompt, tools, items, droppedIds, blocks, additions.sources ?? []);
219
+ const preambleEnd = sections[0]?.start ?? prompt.length;
220
+ appendPromptPart(parts, { ...PREAMBLE_BLOCK, change: changes.sections?.get("preamble") },
221
+ prompt.slice(0, preambleEnd).trimEnd());
222
+ for (const block of BASE_PROMPT_BLOCKS) {
223
+ if (droppedIds.includes(block.id)) {
224
+ const dropped = lines.dropped.get(block.id);
225
+ parts.push({ ...block, kind: "base-prompt", text: dropped?.text ?? "", dropped: true,
226
+ injectedReferences: dropped?.injectedReferences });
227
+ }
228
+ }
229
+ for (const section of sections) {
230
+ const body = prompt.slice(section.body.start + 1, section.body.end);
231
+ const change = changes.sections?.get(section.name);
232
+ if (measureGeneratedSection(section.name, body, options, items, change)) continue;
233
+ parts.push({
234
+ ...measureSectionPart(prompt, section, lines.rendered,
235
+ blocks.find((candidate) => candidate.start === section.body.start)?.moved),
236
+ ...changeField(change),
237
+ });
238
+ }
239
+ for (const deleted of changes.deleted ?? []) {
240
+ const part = deletedSectionPart(deleted);
241
+ // A dropped part already stands for a block the request lacks
242
+ if (!parts.some((existing) => existing.id === part.id)) parts.push(part);
243
+ }
244
+ // Only unwrapped gaps are additions. Sections after cwd still belong to the
245
+ // prompt, even when an earlier handler never saw them or cwd was removed.
246
+ const extensionAdditions = measurePromptAdditions(prompt, preambleEnd, { ...additions, excluded: sections });
247
+ if (extensionAdditions !== undefined) {
248
+ parts.push({ ...EXTENSION_ADDITIONS_BLOCK, kind: "prompt-addition", ...extensionAdditions });
249
+ }
250
+ items.unshift(createSystemPromptItem(parts));
251
+ return items;
252
+ }
253
+
254
+ /** Measure one XML section body, annotating the extension tool lines pi rendered into it. */
255
+ function measureSectionPart(
256
+ prompt: string,
257
+ section: PromptSection,
258
+ lines: readonly InjectedSpan[],
259
+ moved: boolean | undefined,
260
+ ): PromptPart {
261
+ const block = SECTION_PARTS[section.name] ?? { id: `base-prompt:section:${section.name}`, label: section.name };
262
+ const toolSurface = section.name === "tools" || section.name === "rules";
263
+ // A tool surface keeps the newline before its first bullet, so that bullet's span stays inside the part
264
+ const start = toolSurface && prompt[section.body.start] === "\n" ? section.body.start : section.body.start + 1;
265
+ const references = lines.filter((line) => line.partId === block.id && line.start >= start
266
+ && line.end <= section.body.end).sort((a, b) => a.start - b.start).map((line): InjectedReference => ({
267
+ start: line.start - start, end: line.end - start, source: line.source, tool: line.tool,
268
+ }));
269
+ return {
270
+ id: block.id, label: block.label, text: prompt.slice(start, section.body.end),
271
+ kind: section.name === "addendum" ? "append-prompt" : "base-prompt", moved,
272
+ injectedReferences: references.length > 0 ? references : undefined,
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Uncounted part for a section the request deleted, keeping the session text
278
+ * without its XML wrapper for the preview.
279
+ */
280
+ function deletedSectionPart(section: DeletedSection): PromptPart {
281
+ const block = SECTION_PARTS[section.name] ??
282
+ (section.name === "preamble" ? PREAMBLE_BLOCK : { id: `base-prompt:section:${section.name}`, label: section.name });
283
+ const open = `<${section.name}>`;
284
+ const close = `</${section.name}>`;
285
+ const wrapped = section.text.startsWith(open) && section.text.endsWith(close);
286
+ return {
287
+ id: block.id,
288
+ kind: section.name === "addendum" ? "append-prompt" : "base-prompt",
289
+ label: block.label,
290
+ text: wrapped ? section.text.slice(open.length, section.text.length - close.length).replace(/^\n|\n$/g, "") : section.text,
291
+ change: "deleted",
292
+ };
293
+ }
294
+
295
+ /** Split generated records only when present; overridden or unknown content stays visible. */
296
+ function measureGeneratedSection(
297
+ name: string,
298
+ body: string,
299
+ options: PromptOptionsSlice,
300
+ items: InjectionItem[],
301
+ change: RequestChange | undefined,
302
+ ): boolean {
303
+ if (options.sections?.[name]) return false;
304
+ if (name === "project_context" && body.includes("<project_instructions path=")) {
305
+ const before = items.length;
306
+ measureContextFiles(body, options.homeDir, items);
307
+ if (change !== undefined && items.length > before) items[items.length - 1] = { ...items[items.length - 1], change };
308
+ return true;
309
+ }
310
+ if (name === "skills" && body.includes("<available_skills>")) {
311
+ // Read the captured records, not today's loader metadata after resume or a patch.
312
+ const record =
313
+ /<skill>\s*<name>(.*?)<\/name>\s*<description>([\s\S]*?)<\/description>\s*<location>(.*?)<\/location>/g;
314
+ const skills = [...body.matchAll(record)].map((match) => ({
315
+ name: decodeXml(match[1]), description: decodeXml(match[2]), filePath: decodeXml(match[3]),
316
+ }));
317
+ if (skills.length === 0) return false;
318
+ const children = skills.map((skill) => createItem(`skill:${skill.name}`, "skills", PI_SOURCE, skill.name,
319
+ [skill.name, skill.description, skill.filePath].join("\n"))).sort((a, b) => b.tokens - a.tokens);
320
+ const aggregate = createAggregateItem("skills", "skills", PI_SOURCE, `${SKILLS_LABEL} (${children.length})`, children);
321
+ items.push(change === undefined ? aggregate : { ...aggregate, change });
322
+ return true;
323
+ }
324
+ return false;
325
+ }
326
+
327
+ /** Decode the five XML entities Pi escapes when rendering skill metadata. */
328
+ function decodeXml(text: string): string {
329
+ const entities: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
330
+ return text.replace(/&(amp|lt|gt|quot|apos);/g, (_match, name: string) => entities[name]);
331
+ }
332
+
333
+ /**
334
+ * Join the unwrapped text extensions added around pi's sections into the text
335
+ * of the Extension Additions part, which System Prompt counts. Each run is
336
+ * annotated with the extension it was guessed to belong to, or with
337
+ * unattributed. Undefined when there is no such text.
338
+ */
339
+ function measurePromptAdditions(
340
+ systemPrompt: string,
341
+ start: number,
342
+ options: PromptAdditionOptions,
343
+ ): AnnotatedLines | undefined {
344
+ if (systemPrompt.slice(start).trim().length === 0) return undefined;
345
+ const additions: AnnotatedLines = { text: "", injectedReferences: [] };
346
+ for (const run of splitPromptAdditions(systemPrompt, start, options)) {
347
+ appendAnnotatedLine(additions, run.text, { source: run.source, tool: run.tool, attribution: run.attribution });
348
+ }
349
+ return additions.injectedReferences.length > 0 ? additions : undefined;
350
+ }
351
+
352
+ /** Extension lines joined into the text of one System Prompt part, each annotated with its source. */
353
+ interface AnnotatedLines {
354
+ text: string;
355
+ readonly injectedReferences: InjectedReference[];
356
+ }
357
+
358
+ /** Append one extension line and annotate its range with the line's source. */
359
+ function appendAnnotatedLine(
360
+ lines: AnnotatedLines,
361
+ text: string,
362
+ owner: Omit<InjectedReference, keyof TextSpan>,
363
+ ): void {
364
+ lines.injectedReferences.push({ start: lines.text.length, end: lines.text.length + text.length, ...owner });
365
+ lines.text += text;
366
+ }
367
+
368
+ /** Same chars/4 heuristic pi's estimateTokens uses for text content. */
369
+ export function textTokens(text: string): number {
370
+ return charTokens(text.length);
371
+ }
372
+
373
+ /** Token estimate for an already known character count. */
374
+ export function charTokens(chars: number): number {
375
+ return Math.ceil(chars / 4);
376
+ }
377
+
378
+ /** Where extension tools' prompt lines ended up: rendered into pi's blocks, or dropped with them. */
379
+ interface ToolPromptLines {
380
+ /** Rendered lines, annotated on the System Prompt part that counts them. */
381
+ readonly rendered: InjectedSpan[];
382
+ /** Extension lines a replacement suppressed, keyed by the part that would have carried them. */
383
+ readonly dropped: Map<string, AnnotatedLines>;
384
+ }
385
+
386
+ /**
387
+ * Measure active tool definitions and locate the prompt snippet/guideline lines
388
+ * pi rendered for extension tools, which System Prompt counts. Built-in tools
389
+ * collapse into one aggregate pi-native item. Lines a `--system-prompt`
390
+ * replacement suppressed belong to the parts named by `droppedPartIds`; each
391
+ * tool keeps those as dropped, uncounted sections. `roster` holds every
392
+ * extension's provenance, so path extensions get distinct labels.
393
+ */
394
+ function measureTools(
395
+ prompt: string,
396
+ tools: ToolSlice[],
397
+ items: InjectionItem[],
398
+ droppedPartIds: readonly string[],
399
+ blocks: readonly LocatedPromptBlock[],
400
+ roster: readonly ExtensionProvenance[],
401
+ ): ToolPromptLines {
402
+ const claimedGuidelines = new Set(piOwnedGuidelines(tools.filter((tool) => tool.change !== "deleted")));
403
+ const rendered: InjectedSpan[] = [];
404
+ const dropped = new Map<string, AnnotatedLines>();
405
+ const builtinChildren: InjectionItem[] = [];
406
+ for (const tool of tools) {
407
+ if (tool.change === "deleted") {
408
+ const deleted = createDeletedToolItem(tool, toolSource(tool, roster));
409
+ if (tool.source === "builtin") builtinChildren.push(deleted);
410
+ else items.push(deleted);
411
+ continue;
412
+ }
413
+ // Built-in tools claim their bullets without annotating them, so a later
414
+ // extension tool repeating one cannot take a line pi already renders for
415
+ // pi itself or for a built-in tool.
416
+ const ownedGuidelines = claimGuidelines(tool, claimedGuidelines);
417
+ const definition = { ...createDefinitionSection(tool), ...changeField(tool.change) };
418
+ const lines = toolPromptLines(tool, ownedGuidelines);
419
+ const droppedLines = lines.filter((line) => droppedPartIds.includes(line.partId));
420
+ // A tool counts only its definition; its rendered lines count in System Prompt
421
+ const sections = [...droppedSections(droppedLines), definition];
422
+ if (tool.source === "builtin") {
423
+ builtinChildren.push(withChange(createToolItem(`tool:builtin:${tool.name}`, PI_SOURCE, tool.name, sections),
424
+ tool.change));
425
+ continue;
426
+ }
427
+ const owner: InjectedOwner = { source: toolSource(tool, roster), tool: tool.name };
428
+ collectDroppedLines(dropped, droppedLines, owner);
429
+ rendered.push(...locateRenderedLines(prompt, blocks, lines, owner));
430
+ items.push(withChange(createToolItem(`tool:${tool.source}:${tool.name}`, owner.source, tool.name, sections),
431
+ tool.change));
432
+ }
433
+ if (builtinChildren.length > 0) {
434
+ builtinChildren.sort((a, b) => b.tokens - a.tokens);
435
+ // The count names the tools the request declared, not the deleted ones listed with them
436
+ const declared = builtinChildren.filter((child) => child.change !== "deleted").length;
437
+ const label = `${BUILT_IN_TOOLS_LABEL} (${declared})`;
438
+ items.push(createAggregateItem("tool:builtin", "tool", PI_SOURCE, label, builtinChildren));
439
+ }
440
+ return { rendered, dropped };
441
+ }
442
+
443
+ /**
444
+ * A tool the request deleted: its session definition only, for the preview, at
445
+ * 0 tokens. Its prompt lines stay with whatever text still holds them.
446
+ */
447
+ function createDeletedToolItem(tool: ToolSlice, source: InjectionSource): InjectionItem {
448
+ const id = tool.source === "builtin" ? `tool:builtin:${tool.name}` : `tool:${tool.source}:${tool.name}`;
449
+ const definition: SectionDraft = { ...createDefinitionSection(tool), change: "deleted" };
450
+ return { ...createToolItem(id, source, tool.name, [definition]), change: "deleted" };
451
+ }
452
+
453
+ /**
454
+ * Owner of a tool: pi for a built-in, `unattributed` for an unregistered
455
+ * tool, otherwise its extension labelled against the roster.
456
+ */
457
+ function toolSource(tool: ToolSlice, roster: readonly ExtensionProvenance[]): InjectionSource {
458
+ if (tool.source === "builtin") return PI_SOURCE;
459
+ if (tool.source === UNREGISTERED_TOOL_SOURCE) return AGGREGATE_SOURCE;
460
+ return extensionSource({ source: tool.source, path: tool.sourcePath }, roster);
461
+ }
462
+
463
+ /** The item with a request-only change, or unchanged when there is none. */
464
+ function withChange(item: InjectionItem, change: RequestChange | undefined): InjectionItem {
465
+ return change === undefined ? item : { ...item, change };
466
+ }
467
+
468
+ /** `{ change }` when there is one, so unchanged parts carry no extra key. */
469
+ function changeField(change: RequestChange | undefined): { readonly change?: RequestChange } {
470
+ return change === undefined ? {} : { change };
471
+ }
472
+
473
+ /** One prompt line pi renders for a tool, and the System Prompt part it renders it into. */
474
+ interface ToolPromptLine {
475
+ readonly partId: string;
476
+ /** Section name the line belongs to, shared by the part and the owning tool. */
477
+ readonly label: string;
478
+ /** Line exactly as pi renders it, including its leading line break. */
479
+ readonly text: string;
480
+ }
481
+
482
+ /** Prompt lines pi renders for one tool: its snippet bullet, then the guidelines it owns. */
483
+ function toolPromptLines(tool: ToolSlice, ownedGuidelines: string[]): ToolPromptLine[] {
484
+ const lines: ToolPromptLine[] = [];
485
+ if (tool.snippet !== undefined) {
486
+ lines.push({
487
+ partId: AVAILABLE_TOOLS_BLOCK.id,
488
+ label: AVAILABLE_TOOLS_LABEL,
489
+ text: `\n- ${tool.name}: ${tool.snippet}`,
490
+ });
491
+ }
492
+ for (const guideline of ownedGuidelines) {
493
+ lines.push({ partId: GUIDELINES_BLOCK.id, label: GUIDELINES_LABEL, text: `\n- ${guideline}` });
494
+ }
495
+ return lines;
496
+ }
497
+
498
+ /**
499
+ * Group one tool's dropped lines into a preview-only section per part, so its
500
+ * preview shows what the replacement gave up without claiming tokens pi never
501
+ * sent.
502
+ */
503
+ function droppedSections(lines: readonly ToolPromptLine[]): SectionDraft[] {
504
+ const sections: SectionDraft[] = [];
505
+ for (const line of lines) {
506
+ const last = sections.length - 1;
507
+ const current = sections[last];
508
+ if (current !== undefined && current.label === line.label) {
509
+ sections[last] = { ...current, text: current.text + line.text };
510
+ continue;
511
+ }
512
+ sections.push({ label: line.label, text: line.text, dropped: true });
513
+ }
514
+ return sections;
515
+ }
516
+
517
+ /**
518
+ * Add one extension tool's dropped lines, annotated, to the uncounted text of
519
+ * the parts pi would have rendered them into.
520
+ */
521
+ function collectDroppedLines(
522
+ dropped: Map<string, AnnotatedLines>,
523
+ lines: readonly ToolPromptLine[],
524
+ owner: InjectedOwner,
525
+ ): void {
526
+ for (const line of lines) {
527
+ let part = dropped.get(line.partId);
528
+ if (part === undefined) {
529
+ part = { text: "", injectedReferences: [] };
530
+ dropped.set(line.partId, part);
531
+ }
532
+ appendAnnotatedLine(part, line.text, owner);
533
+ }
534
+ }
535
+
536
+ /** One labeled part of a tool item's text, before it receives its token share. */
537
+ interface SectionDraft {
538
+ readonly label: string;
539
+ readonly text: string;
540
+ /** True for text a `--system-prompt` replacement dropped: shown for reference, never counted. */
541
+ readonly dropped?: boolean;
542
+ /** True for a block an extension moved out of the region pi rendered it into. */
543
+ readonly moved?: boolean;
544
+ /** Request-only change; a deleted section is shown for reference, never counted. */
545
+ readonly change?: RequestChange;
546
+ /** Serialized JSON inside `text`; marked here rather than detected in the preview. */
547
+ readonly jsonSpan?: JsonSpan;
548
+ /** Extension lines inside `text`, annotated with their source in previews. */
549
+ readonly injectedReferences?: readonly InjectedReference[];
550
+ }
551
+
552
+ /** The payload one tool sends with every request: its name, description, and parameter schema. */
553
+ function createDefinitionSection(tool: ToolSlice): SectionDraft {
554
+ const heading = `${tool.name}: ${tool.description}\n`;
555
+ return {
556
+ label: "Definition",
557
+ text: `${heading}${tool.parametersJson}`,
558
+ jsonSpan: { start: heading.length, end: heading.length + tool.parametersJson.length },
559
+ };
560
+ }
561
+
562
+ /** The extension tool that produced a prompt line. */
563
+ interface InjectedOwner {
564
+ readonly source: InjectionSource;
565
+ /** Tool of `source` that produced the line, rendered as a label qualifier. */
566
+ readonly tool?: string;
567
+ }
568
+
569
+ /** Prompt location, owner, and System Prompt part of a rendered extension tool line. */
570
+ interface InjectedSpan extends Span, InjectedOwner {
571
+ readonly partId: string;
572
+ }
573
+
574
+ /**
575
+ * Locate one extension tool's lines in the bullet regions of the blocks pi
576
+ * renders them into. A line pi did not render there gets no annotation.
577
+ */
578
+ function locateRenderedLines(
579
+ prompt: string,
580
+ blocks: readonly LocatedPromptBlock[],
581
+ lines: readonly ToolPromptLine[],
582
+ owner: InjectedOwner,
583
+ ): InjectedSpan[] {
584
+ return lines.flatMap((line) => {
585
+ const span = findBlockLine(prompt, blocks.find((block) => block.id === line.partId)?.bullets, line.text);
586
+ return span === undefined ? [] : [{ ...span, ...owner, partId: line.partId }];
587
+ });
588
+ }
589
+
590
+ /** Find one complete prompt bullet inside its block, never a prefix of another bullet. */
591
+ function findBlockLine(prompt: string, block: Span | undefined, line: string): Span | undefined {
592
+ if (block === undefined) return undefined;
593
+ let start = prompt.indexOf(line, block.start);
594
+ while (start !== -1 && start + line.length <= block.end) {
595
+ const end = start + line.length;
596
+ if (end === block.end || prompt[end] === "\n") return { start, end };
597
+ start = prompt.indexOf(line, end);
598
+ }
599
+ return undefined;
600
+ }
601
+
602
+ /**
603
+ * Bullets pi's Guidelines section carries on its own behalf, reserved before
604
+ * any tool can claim one. Pi adds this file-exploration bullet ahead of tool
605
+ * guidelines, so an identical tool bullet is deduplicated away. Pi's two
606
+ * trailing bullets ("Be concise in your responses", "Show file paths clearly
607
+ * when working with files") need no reservation: pi appends them after tool
608
+ * guidelines, where a tool declaring one already owns the rendered line.
609
+ */
610
+ function piOwnedGuidelines(tools: ToolSlice[]): string[] {
611
+ const names = new Set(tools.map((tool) => tool.name));
612
+ const shellOnly = (names.has("bash") || names.has("powershell")) &&
613
+ !names.has("grep") && !names.has("find") && !names.has("ls");
614
+ if (!shellOnly) return [];
615
+ if (names.has("bash") && names.has("powershell")) {
616
+ return ["Use bash or PowerShell for file operations like listing, searching, and finding files"];
617
+ }
618
+ if (names.has("powershell")) {
619
+ return ["Use PowerShell for file operations like listing, searching, and finding files"];
620
+ }
621
+ return ["Use bash for file operations like ls, rg, find"];
622
+ }
623
+
624
+ /**
625
+ * Guideline texts this tool is the first to declare, in pi's active-tool
626
+ * order. Pi renders each distinct bullet once, so a later tool repeating one
627
+ * contributes no prompt line and must not count its tokens again.
628
+ */
629
+ function claimGuidelines(tool: ToolSlice, claimed: Set<string>): string[] {
630
+ const owned: string[] = [];
631
+ for (const guideline of tool.guidelines) {
632
+ const text = guideline.trim();
633
+ if (text.length === 0 || claimed.has(text)) continue;
634
+ claimed.add(text);
635
+ owned.push(text);
636
+ }
637
+ return owned;
638
+ }
639
+
640
+ /**
641
+ * Expose each context file recorded in pi's project-context section body as a
642
+ * child of one Instruction Files aggregate, without counting the XML transport
643
+ * scaffolding. Paths come from the captured records, not today's loader.
644
+ */
645
+ function measureContextFiles(body: string, homeDir: string | undefined, items: InjectionItem[]): void {
646
+ const children: InjectionItem[] = [];
647
+ for (const match of body.matchAll(/<project_instructions path="([^"]+)">/g)) {
648
+ const filePath = match[1];
649
+ const content = findContextFileContent(body, filePath);
650
+ if (content === undefined) continue;
651
+ children.push(createItem(
652
+ `context-file:${filePath}`,
653
+ "context-file",
654
+ PI_SOURCE,
655
+ abbreviateHome(filePath, homeDir),
656
+ content,
657
+ ));
658
+ }
659
+ children.sort((a, b) => b.tokens - a.tokens);
660
+ if (children.length === 0) return;
661
+
662
+ const label = `${INSTRUCTION_FILES_LABEL} (${children.length})`;
663
+ items.push(createAggregateItem("context-files", "context-file", PI_SOURCE, label, children));
664
+ }
665
+
666
+ /** One labeled part of the System Prompt item, before it receives its token share. */
667
+ interface PromptPart {
668
+ readonly id: string;
669
+ readonly kind: InjectionKind;
670
+ readonly label: string;
671
+ readonly text: string;
672
+ /**
673
+ * True for a block a `--system-prompt` replacement dropped: no pi text, no
674
+ * tokens. Its text holds only the extension lines pi would have rendered.
675
+ */
676
+ readonly dropped?: boolean;
677
+ /** True for a block an extension moved out of the region pi rendered it into. */
678
+ readonly moved?: boolean;
679
+ /** Request-only change; a deleted part keeps its session text but no tokens. */
680
+ readonly change?: RequestChange;
681
+ /** Extension lines inside `text`, annotated with their source in previews. */
682
+ readonly injectedReferences?: readonly InjectedReference[];
683
+ }
684
+
685
+ /** Record one pi-authored part, skipping a block pi rendered no text into. */
686
+ function appendPromptPart(
687
+ parts: PromptPart[],
688
+ block: Pick<PromptPart, "id" | "label" | "moved" | "change">,
689
+ text: string,
690
+ ): void {
691
+ if (text.length === 0 && block.change === undefined) return;
692
+ parts.push({ id: block.id, kind: "base-prompt", label: block.label, text, moved: block.moved,
693
+ ...changeField(block.change) });
694
+ }
695
+
696
+ /**
697
+ * Build the System Prompt item from its labeled parts. Parts concatenate back to
698
+ * the item text and take cumulative shares of its estimate, so children break the
699
+ * item down without adding tokens. A prompt with one part stays undivided.
700
+ */
701
+ function createSystemPromptItem(parts: readonly PromptPart[]): InjectionItem {
702
+ const text = countedText(parts);
703
+ const item = createItem(SYSTEM_PROMPT_ITEM_ID, "base-prompt", PI_SOURCE, SYSTEM_PROMPT_LABEL, text);
704
+ if (parts.length === 0) return item;
705
+ if (parts.length === 1 && parts[0].id === PREAMBLE_BLOCK.id && parts[0].change !== "deleted") {
706
+ return withChange(item, parts[0].change);
707
+ }
708
+ const sections = allocateSectionTokens(parts.map((part) => ({
709
+ label: part.label,
710
+ text: part.text,
711
+ dropped: part.dropped,
712
+ moved: part.moved,
713
+ ...changeField(part.change),
714
+ injectedReferences: part.injectedReferences,
715
+ })));
716
+ return {
717
+ ...item,
718
+ sections,
719
+ children: parts.map((part, index) => ({
720
+ ...createItem(part.id, part.kind, PI_SOURCE, part.label, part.text),
721
+ chars: isUncounted(part) ? 0 : part.text.length,
722
+ tokens: sections[index]?.tokens ?? 0,
723
+ dropped: part.dropped,
724
+ moved: part.moved,
725
+ ...changeField(part.change),
726
+ injectedReferences: part.injectedReferences,
727
+ })),
728
+ };
729
+ }
730
+
731
+ /** Build an InjectionItem with derived char/token sizes. */
732
+ function createItem(
733
+ id: string,
734
+ kind: InjectionKind,
735
+ source: InjectionSource,
736
+ label: string,
737
+ text: string,
738
+ ): InjectionItem {
739
+ return {
740
+ id,
741
+ kind,
742
+ source,
743
+ label,
744
+ chars: text.length,
745
+ tokens: textTokens(text),
746
+ text,
747
+ };
748
+ }
749
+
750
+ /** Build a tool item whose raw text is exactly the concatenation of its sections. */
751
+ function createToolItem(
752
+ id: string,
753
+ source: InjectionSource,
754
+ label: string,
755
+ sections: SectionDraft[],
756
+ ): InjectionItem {
757
+ const text = countedText(sections);
758
+ return { ...createItem(id, "tool", source, label, text), sections: allocateSectionTokens(sections) };
759
+ }
760
+
761
+ /** Text an item actually sends: everything but dropped and deleted parts. */
762
+ function countedText(parts: readonly UncountablePart[]): string {
763
+ return parts.filter((part) => !isUncounted(part)).map((part) => part.text).join("");
764
+ }
765
+
766
+ /** A part that may carry text the request never sent. */
767
+ interface UncountablePart {
768
+ readonly text: string;
769
+ readonly dropped?: boolean;
770
+ readonly change?: RequestChange;
771
+ }
772
+
773
+ /** Whether a part's text is shown for reference only: a replacement dropped it, or the request deleted it. */
774
+ function isUncounted(part: UncountablePart): boolean {
775
+ return part.dropped === true || part.change === "deleted";
776
+ }
777
+
778
+ /**
779
+ * Give each section its share of the item estimate. Shares are cumulative
780
+ * differences rather than independently rounded counts, so they always sum to
781
+ * the item total. A dropped or deleted section reads 0 tokens and leaves the
782
+ * shares of the sections pi did send unchanged.
783
+ */
784
+ function allocateSectionTokens(sections: SectionDraft[]): InjectionSection[] {
785
+ let chars = 0;
786
+ let allocated = 0;
787
+ return sections.map((section) => {
788
+ if (isUncounted(section)) return { ...section, tokens: 0 };
789
+ chars += section.text.length;
790
+ const cumulative = charTokens(chars);
791
+ const tokens = cumulative - allocated;
792
+ allocated = cumulative;
793
+ return { ...section, tokens };
794
+ });
795
+ }
796
+
797
+ /** Line break joining consecutive child texts inside an aggregate's raw text. */
798
+ const CHILD_SEPARATOR = "\n";
799
+
800
+ /**
801
+ * Build an aggregate whose totals exactly reconcile with its child items and
802
+ * whose preview presents every child as its own labeled part, so each child
803
+ * keeps its subheader, token share, and marked JSON run.
804
+ */
805
+ function createAggregateItem(
806
+ id: string,
807
+ kind: InjectionKind,
808
+ source: InjectionSource,
809
+ label: string,
810
+ children: InjectionItem[],
811
+ ): InjectionItem {
812
+ return {
813
+ ...createItem(id, kind, source, label, children.map((child) => child.text).join(CHILD_SEPARATOR)),
814
+ chars: children.reduce((sum, child) => sum + child.chars, 0),
815
+ tokens: children.reduce((sum, child) => sum + child.tokens, 0),
816
+ sections: children.map((child, index) =>
817
+ childSection(child, index === 0 ? "" : CHILD_SEPARATOR)
818
+ ),
819
+ children,
820
+ };
821
+ }
822
+
823
+ /**
824
+ * One child as a labeled part of its aggregate, opening with the separator the
825
+ * aggregate text joins on; the preview drops that separator again.
826
+ */
827
+ function childSection(child: InjectionItem, separator: string): InjectionSection {
828
+ const span = childJsonSpan(child);
829
+ const text = child.change === "deleted" ? child.sections?.[0]?.text ?? child.text : child.text;
830
+ return {
831
+ label: child.label,
832
+ text: `${separator}${text}`,
833
+ tokens: child.tokens,
834
+ ...changeField(child.change),
835
+ jsonSpan: span === undefined
836
+ ? undefined
837
+ : { start: span.start + separator.length, end: span.end + separator.length },
838
+ };
839
+ }
840
+
841
+ /**
842
+ * The JSON run marked inside a child's whole text: its own span, or the span of
843
+ * its single part. A child split into several parts marks each part separately,
844
+ * so no one run covers its text and the aggregate part expands nothing.
845
+ */
846
+ function childJsonSpan(child: InjectionItem): JsonSpan | undefined {
847
+ const sections = child.sections;
848
+ if (sections === undefined) return child.jsonSpan;
849
+ return sections.length === 1 ? sections[0]?.jsonSpan : undefined;
850
+ }
851
+
852
+ /** Replace a leading home-directory prefix with `~` for compact path labels. */
853
+ function abbreviateHome(path: string, homeDir: string | undefined): string {
854
+ if (homeDir === undefined || homeDir.length === 0) return path;
855
+ if (path === homeDir) return "~";
856
+ if (path.startsWith(`${homeDir}/`)) return `~${path.slice(homeDir.length)}`;
857
+ return path;
858
+ }
859
+
860
+ /** Half-open [start, end) character range within the base prompt. */
861
+ interface Span {
862
+ start: number;
863
+ end: number;
864
+ }
865
+
866
+ /** Extract one context file's final content without its project-instructions wrapper. */
867
+ function findContextFileContent(systemPrompt: string, filePath: string): string | undefined {
868
+ const open = `<project_instructions path="${filePath}">`;
869
+ const close = "</project_instructions>";
870
+ const wrapper = findDelimitedSpan(systemPrompt, open, close);
871
+ if (wrapper === undefined) return undefined;
872
+ let start = wrapper.start + open.length;
873
+ let end = wrapper.end - close.length;
874
+ if (systemPrompt.startsWith("\r\n", start)) start += 2;
875
+ else if (systemPrompt[start] === "\n") start++;
876
+ if (systemPrompt.slice(Math.max(start, end - 2), end) === "\r\n") end -= 2;
877
+ else if (systemPrompt[end - 1] === "\n") end--;
878
+ return systemPrompt.slice(start, end);
879
+ }
880
+
881
+ /** Locate a complete delimited transport wrapper. */
882
+ function findDelimitedSpan(text: string, open: string, close: string): Span | undefined {
883
+ const start = text.indexOf(open);
884
+ if (start === -1) return undefined;
885
+ const closeStart = text.indexOf(close, start + open.length);
886
+ return closeStart === -1 ? undefined : { start, end: closeStart + close.length };
887
+ }