pi-context-view 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/doc/images/context-injections.png +0 -0
- package/doc/images/context-usage.png +0 -0
- package/doc/images/pi-context-view.png +0 -0
- package/package.json +53 -0
- package/src/capture.ts +396 -0
- package/src/command.ts +134 -0
- package/src/index.ts +119 -0
- package/src/measure.ts +356 -0
- package/src/model.ts +201 -0
- package/src/ui/injections-model.ts +239 -0
- package/src/ui/injections-view.ts +369 -0
- package/src/ui/layout.ts +69 -0
- package/src/ui/usage-map.ts +97 -0
- package/src/ui/usage-view.ts +603 -0
- package/src/usage.ts +318 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-context-view — inspect what occupies the model context.
|
|
3
|
+
*
|
|
4
|
+
* Passively captures the first real turn, or runs one on-demand silent probe
|
|
5
|
+
* when a context view is opened before any real turn.
|
|
6
|
+
*/
|
|
7
|
+
import { buildSessionContext, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
getContextArgumentCompletions,
|
|
11
|
+
parseContextCommand,
|
|
12
|
+
reportCommandMessage,
|
|
13
|
+
resolveInitialCapture,
|
|
14
|
+
} from "./command.ts";
|
|
15
|
+
import {
|
|
16
|
+
buildNativeSnapshot,
|
|
17
|
+
InitialCaptureState,
|
|
18
|
+
mergeContextOnlyMessages,
|
|
19
|
+
SilentProbeState,
|
|
20
|
+
} from "./capture.ts";
|
|
21
|
+
import { showInjectionsView } from "./ui/injections-view.ts";
|
|
22
|
+
import { showUsageView } from "./ui/usage-view.ts";
|
|
23
|
+
import { computeUsage, toReportedUsage } from "./usage.ts";
|
|
24
|
+
|
|
25
|
+
export default function (pi: ExtensionAPI) {
|
|
26
|
+
const capture = new InitialCaptureState();
|
|
27
|
+
const probe = new SilentProbeState();
|
|
28
|
+
|
|
29
|
+
pi.on("input", (event) => {
|
|
30
|
+
probe.observeInput(event.source, event.text);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
pi.on("before_agent_start", (event) => {
|
|
34
|
+
probe.beginRun(event.prompt);
|
|
35
|
+
capture.prepare(event.systemPromptOptions);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
pi.on("turn_start", (_event, ctx) => {
|
|
39
|
+
if (probe.isCurrentRun) ctx.abort();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
pi.on("message_start", (event) => {
|
|
43
|
+
probe.recordMessage(event.message);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
pi.on("message_end", (event) => {
|
|
47
|
+
const message = probe.sanitizeAssistant(event.message);
|
|
48
|
+
return message === undefined ? undefined : { message };
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
pi.on("context", (event, ctx) => {
|
|
52
|
+
const messages = probe.filterMessages(event.messages);
|
|
53
|
+
const baselineMessages = probe.filterMessages(
|
|
54
|
+
buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages,
|
|
55
|
+
);
|
|
56
|
+
capture.finalize({
|
|
57
|
+
systemPrompt: ctx.getSystemPrompt(),
|
|
58
|
+
messages,
|
|
59
|
+
baselineMessages,
|
|
60
|
+
allTools: pi.getAllTools(),
|
|
61
|
+
activeToolNames: pi.getActiveTools(),
|
|
62
|
+
origin: probe.isCurrentRun ? "synthetic-probe" : "real-turn",
|
|
63
|
+
});
|
|
64
|
+
return messages === event.messages ? undefined : { messages };
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
68
|
+
if (!probe.isCurrentRun) return;
|
|
69
|
+
if (ctx.mode === "tui") ctx.ui.setWorkingVisible(true);
|
|
70
|
+
probe.settle(capture.snapshot !== undefined);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
pi.on("session_shutdown", () => {
|
|
74
|
+
probe.fail("Session ended before the silent probe completed.");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
pi.registerCommand("context", {
|
|
78
|
+
// RegisteredCommand has no argumentHint; mimic pi's `<hint> — <description>` style.
|
|
79
|
+
description: "[usage|injections] — Inspect context usage or injections",
|
|
80
|
+
getArgumentCompletions: getContextArgumentCompletions,
|
|
81
|
+
handler: async (args, ctx) => {
|
|
82
|
+
const command = parseContextCommand(args);
|
|
83
|
+
if (command.type === "invalid") {
|
|
84
|
+
reportCommandMessage(ctx, command.message, "error");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (ctx.mode !== "tui") {
|
|
88
|
+
reportCommandMessage(ctx, "/context requires TUI mode.", "warning");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const initial = await resolveInitialCapture(pi, capture, probe, ctx);
|
|
92
|
+
if (command.view === "injections") {
|
|
93
|
+
await showInjectionsView(ctx, {
|
|
94
|
+
snapshot: initial.snapshot,
|
|
95
|
+
degradedReason: initial.degradedReason,
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const current = buildNativeSnapshot({
|
|
100
|
+
systemPrompt: ctx.getSystemPrompt(),
|
|
101
|
+
options: ctx.getSystemPromptOptions(),
|
|
102
|
+
allTools: pi.getAllTools(),
|
|
103
|
+
activeToolNames: pi.getActiveTools(),
|
|
104
|
+
});
|
|
105
|
+
await showUsageView(ctx, {
|
|
106
|
+
usage: computeUsage({
|
|
107
|
+
snapshot: mergeContextOnlyMessages(current, initial.snapshot),
|
|
108
|
+
// ReadonlySessionManager lacks buildSessionContext(); use pi's exported builder.
|
|
109
|
+
messages: probe.filterMessages(
|
|
110
|
+
buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages,
|
|
111
|
+
),
|
|
112
|
+
reported: toReportedUsage(ctx.getContextUsage()),
|
|
113
|
+
modelLabel: ctx.model?.id,
|
|
114
|
+
}),
|
|
115
|
+
degradedReason: initial.degradedReason,
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
package/src/measure.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
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
|
+
* Splitting relies on structural markers that pi's buildSystemPrompt() emits
|
|
6
|
+
* deterministically (verified against pi 0.80.6 dist/core/system-prompt.js):
|
|
7
|
+
*
|
|
8
|
+
* - context files: <project_instructions path="...">...</project_instructions>
|
|
9
|
+
* - skills block: "The following skills provide..." through </available_skills>
|
|
10
|
+
* - base prompt ends before the "Current date/Current working directory" footer
|
|
11
|
+
* → anything after that footer was appended by before_agent_start handlers.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
AGGREGATE_SOURCE_ID,
|
|
15
|
+
type InjectionItem,
|
|
16
|
+
type InjectionKind,
|
|
17
|
+
type InjectionSource,
|
|
18
|
+
PI_SOURCE_ID,
|
|
19
|
+
} from "./model.ts";
|
|
20
|
+
|
|
21
|
+
const PI_SOURCE: InjectionSource = { id: PI_SOURCE_ID, label: "pi", native: true };
|
|
22
|
+
const AGGREGATE_SOURCE: InjectionSource = {
|
|
23
|
+
id: AGGREGATE_SOURCE_ID,
|
|
24
|
+
label: "extensions (aggregate)",
|
|
25
|
+
native: false,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** One visible skill before pi adds XML transport framing. */
|
|
29
|
+
export interface SkillSlice {
|
|
30
|
+
name: string;
|
|
31
|
+
description: string;
|
|
32
|
+
filePath: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Minimal slice of BuildSystemPromptOptions that measurement needs. */
|
|
36
|
+
export interface PromptOptionsSlice {
|
|
37
|
+
cwd: string;
|
|
38
|
+
/** Home directory used to abbreviate context-file paths; omitted disables it. */
|
|
39
|
+
homeDir?: string;
|
|
40
|
+
customPrompt?: string;
|
|
41
|
+
appendSystemPrompt?: string;
|
|
42
|
+
contextFilePaths?: string[];
|
|
43
|
+
skills?: SkillSlice[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One active tool as it contributes to the initial context. */
|
|
47
|
+
export interface ToolSlice {
|
|
48
|
+
name: string;
|
|
49
|
+
/** Sent to the provider with every request. */
|
|
50
|
+
description: string;
|
|
51
|
+
/** JSON-serialized parameter schema; sent to the provider with every request. */
|
|
52
|
+
parametersJson: string;
|
|
53
|
+
/** One-line snippet rendered into the prompt's Available tools list. */
|
|
54
|
+
snippet?: string;
|
|
55
|
+
/** Guideline bullets rendered into the prompt's Guidelines section. */
|
|
56
|
+
guidelines: string[];
|
|
57
|
+
/** Provenance, e.g. "builtin" or "npm:pi-web-providers". */
|
|
58
|
+
source: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Split a captured system prompt into semantic items: pi base prompt,
|
|
63
|
+
* appended prompt, context files, skills, active tool contributions, and the
|
|
64
|
+
* aggregate appended by extensions.
|
|
65
|
+
*/
|
|
66
|
+
export function analyzeSystemPrompt(
|
|
67
|
+
systemPrompt: string,
|
|
68
|
+
options: PromptOptionsSlice,
|
|
69
|
+
tools: ToolSlice[] = [],
|
|
70
|
+
): InjectionItem[] {
|
|
71
|
+
const items: InjectionItem[] = [];
|
|
72
|
+
const carvedSpans: Span[] = [];
|
|
73
|
+
|
|
74
|
+
const footer = findBasePromptFooter(systemPrompt, options.cwd);
|
|
75
|
+
const base = footer === undefined ? systemPrompt : systemPrompt.slice(0, footer.start);
|
|
76
|
+
|
|
77
|
+
const usesCustomPrompt = options.customPrompt !== undefined && options.customPrompt.length > 0;
|
|
78
|
+
measureTools(usesCustomPrompt ? "" : base, tools, items, carvedSpans);
|
|
79
|
+
measureContextFiles(base, options, items, carvedSpans);
|
|
80
|
+
measureSkills(base, options, items, carvedSpans);
|
|
81
|
+
measureAppendedPrompt(base, options, items, carvedSpans);
|
|
82
|
+
|
|
83
|
+
const baseLabel = usesCustomPrompt ? "Custom Prompt (--system-prompt)" : "Base Prompt";
|
|
84
|
+
items.unshift(createItem("base-prompt", "base-prompt", PI_SOURCE, baseLabel, carve(base, carvedSpans)));
|
|
85
|
+
|
|
86
|
+
if (footer !== undefined && footer.end < systemPrompt.length) {
|
|
87
|
+
const added = systemPrompt.slice(footer.end);
|
|
88
|
+
if (added.trim().length > 0) {
|
|
89
|
+
items.push(
|
|
90
|
+
createItem(
|
|
91
|
+
"prompt-addition:aggregate",
|
|
92
|
+
"prompt-addition",
|
|
93
|
+
AGGREGATE_SOURCE,
|
|
94
|
+
"system prompt additions",
|
|
95
|
+
added,
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return items;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Same chars/4 heuristic pi's estimateTokens uses for text content. */
|
|
105
|
+
export function textTokens(text: string): number {
|
|
106
|
+
return Math.ceil(text.length / 4);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Measure active tool contributions: per-tool definition payloads plus the
|
|
111
|
+
* prompt snippet/guideline lines carved out of the base prompt. Built-in
|
|
112
|
+
* tools collapse into one aggregate pi-native item.
|
|
113
|
+
*/
|
|
114
|
+
function measureTools(base: string, tools: ToolSlice[], items: InjectionItem[], carvedSpans: Span[]): void {
|
|
115
|
+
const builtinChildren: InjectionItem[] = [];
|
|
116
|
+
for (const tool of tools) {
|
|
117
|
+
const definition = `${tool.name}: ${tool.description}\n${tool.parametersJson}`;
|
|
118
|
+
if (tool.source === "builtin") {
|
|
119
|
+
builtinChildren.push(createItem(`tool:builtin:${tool.name}`, "tool", PI_SOURCE, tool.name, definition));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
let promptText = "";
|
|
123
|
+
if (tool.snippet !== undefined) {
|
|
124
|
+
const span = findExactSpan(base, `\n- ${tool.name}: ${tool.snippet}`);
|
|
125
|
+
if (span !== undefined) {
|
|
126
|
+
promptText += base.slice(span.start, span.end);
|
|
127
|
+
carvedSpans.push(span);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const guideline of tool.guidelines) {
|
|
131
|
+
const span = findExactSpan(base, `\n- ${guideline.trim()}`);
|
|
132
|
+
if (span !== undefined) {
|
|
133
|
+
promptText += base.slice(span.start, span.end);
|
|
134
|
+
carvedSpans.push(span);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const source = extensionSource(tool.source);
|
|
138
|
+
items.push(createItem(`tool:${tool.source}:${tool.name}`, "tool", source, tool.name, promptText + definition));
|
|
139
|
+
}
|
|
140
|
+
if (builtinChildren.length > 0) {
|
|
141
|
+
builtinChildren.sort((a, b) => b.tokens - a.tokens);
|
|
142
|
+
const label = `Built-in Tools (${builtinChildren.length})`;
|
|
143
|
+
const text = builtinChildren.map((child) => child.text).join("\n");
|
|
144
|
+
items.push({
|
|
145
|
+
...createItem("tool:builtin", "tool", PI_SOURCE, label, text),
|
|
146
|
+
chars: builtinChildren.reduce((sum, child) => sum + child.chars, 0),
|
|
147
|
+
tokens: builtinChildren.reduce((sum, child) => sum + child.tokens, 0),
|
|
148
|
+
children: builtinChildren,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Measure context-file contents without counting pi's XML transport scaffolding. */
|
|
154
|
+
function measureContextFiles(
|
|
155
|
+
base: string,
|
|
156
|
+
options: PromptOptionsSlice,
|
|
157
|
+
items: InjectionItem[],
|
|
158
|
+
carvedSpans: Span[],
|
|
159
|
+
): void {
|
|
160
|
+
const sectionSpan = findContextSectionSpan(base);
|
|
161
|
+
if (sectionSpan === undefined) return;
|
|
162
|
+
for (const filePath of options.contextFilePaths ?? []) {
|
|
163
|
+
const content = findContextFileContent(base, filePath);
|
|
164
|
+
if (content === undefined) continue;
|
|
165
|
+
items.push(
|
|
166
|
+
createItem(
|
|
167
|
+
`context-file:${filePath}`,
|
|
168
|
+
"context-file",
|
|
169
|
+
PI_SOURCE,
|
|
170
|
+
abbreviateHome(filePath, options.homeDir),
|
|
171
|
+
content,
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
carvedSpans.push(expandLineBreaks(base, sectionSpan));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Carve the skills section and expose each semantic skill record as a child item. */
|
|
179
|
+
function measureSkills(
|
|
180
|
+
base: string,
|
|
181
|
+
options: PromptOptionsSlice,
|
|
182
|
+
items: InjectionItem[],
|
|
183
|
+
carvedSpans: Span[],
|
|
184
|
+
): void {
|
|
185
|
+
const sectionSpan = findSkillsSpan(base);
|
|
186
|
+
if (sectionSpan === undefined) return;
|
|
187
|
+
const children = (options.skills ?? [])
|
|
188
|
+
.map((skill) => createItem(
|
|
189
|
+
`skill:${skill.name}`,
|
|
190
|
+
"skills",
|
|
191
|
+
PI_SOURCE,
|
|
192
|
+
skill.name,
|
|
193
|
+
[skill.name, skill.description, skill.filePath].join("\n"),
|
|
194
|
+
))
|
|
195
|
+
.sort((a, b) => b.tokens - a.tokens);
|
|
196
|
+
carvedSpans.push(expandLineBreaks(base, sectionSpan));
|
|
197
|
+
if (children.length === 0) return;
|
|
198
|
+
|
|
199
|
+
items.push(createAggregateItem("skills", "skills", PI_SOURCE, `Skills (${children.length})`, children));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Carve the --append-system-prompt text out of the base prompt when present. */
|
|
203
|
+
function measureAppendedPrompt(
|
|
204
|
+
base: string,
|
|
205
|
+
options: PromptOptionsSlice,
|
|
206
|
+
items: InjectionItem[],
|
|
207
|
+
carvedSpans: Span[],
|
|
208
|
+
): void {
|
|
209
|
+
const append = options.appendSystemPrompt;
|
|
210
|
+
if (append === undefined || append.length === 0) return;
|
|
211
|
+
const generatedStarts = [findContextSectionSpan(base)?.start, findSkillsSpan(base)?.start]
|
|
212
|
+
.filter((start): start is number => start !== undefined);
|
|
213
|
+
const generatedStart = generatedStarts.length === 0 ? base.length : Math.min(...generatedStarts);
|
|
214
|
+
const beforeGeneratedSections = Math.max(0, generatedStart - append.length);
|
|
215
|
+
const expectedStart = base.lastIndexOf(append, beforeGeneratedSections);
|
|
216
|
+
const start = expectedStart === -1 ? base.lastIndexOf(append) : expectedStart;
|
|
217
|
+
if (start === -1) return;
|
|
218
|
+
items.push(createItem("append-prompt", "append-prompt", PI_SOURCE, "appended system prompt", append));
|
|
219
|
+
carvedSpans.push({ start, end: start + append.length });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Build an initial-phase InjectionItem with derived char/token sizes. */
|
|
223
|
+
function createItem(
|
|
224
|
+
id: string,
|
|
225
|
+
kind: InjectionKind,
|
|
226
|
+
source: InjectionSource,
|
|
227
|
+
label: string,
|
|
228
|
+
text: string,
|
|
229
|
+
): InjectionItem {
|
|
230
|
+
return {
|
|
231
|
+
id,
|
|
232
|
+
phase: "initial",
|
|
233
|
+
kind,
|
|
234
|
+
source,
|
|
235
|
+
label,
|
|
236
|
+
chars: text.length,
|
|
237
|
+
tokens: textTokens(text),
|
|
238
|
+
text,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Build an aggregate whose totals exactly reconcile with its child items. */
|
|
243
|
+
function createAggregateItem(
|
|
244
|
+
id: string,
|
|
245
|
+
kind: InjectionKind,
|
|
246
|
+
source: InjectionSource,
|
|
247
|
+
label: string,
|
|
248
|
+
children: InjectionItem[],
|
|
249
|
+
): InjectionItem {
|
|
250
|
+
return {
|
|
251
|
+
...createItem(id, kind, source, label, children.map((child) => child.text).join("\n")),
|
|
252
|
+
chars: children.reduce((sum, child) => sum + child.chars, 0),
|
|
253
|
+
tokens: children.reduce((sum, child) => sum + child.tokens, 0),
|
|
254
|
+
children,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Injection source for a non-builtin tool provenance string. */
|
|
259
|
+
function extensionSource(source: string): InjectionSource {
|
|
260
|
+
return { id: `tool-source:${source}`, label: source, native: false };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Replace a leading home-directory prefix with `~` for compact path labels. */
|
|
264
|
+
function abbreviateHome(path: string, homeDir: string | undefined): string {
|
|
265
|
+
if (homeDir === undefined || homeDir.length === 0) return path;
|
|
266
|
+
if (path === homeDir) return "~";
|
|
267
|
+
if (path.startsWith(`${homeDir}/`)) return `~${path.slice(homeDir.length)}`;
|
|
268
|
+
return path;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Remove the given spans from text, tolerating overlaps, and return the remainder. */
|
|
272
|
+
function carve(text: string, spans: Span[]): string {
|
|
273
|
+
spans.sort((a, b) => a.start - b.start);
|
|
274
|
+
let remainder = "";
|
|
275
|
+
let cursor = 0;
|
|
276
|
+
for (const span of spans) {
|
|
277
|
+
if (span.start > cursor) remainder += text.slice(cursor, span.start);
|
|
278
|
+
cursor = Math.max(cursor, span.end);
|
|
279
|
+
}
|
|
280
|
+
return remainder + text.slice(cursor);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Half-open [start, end) character range within the base prompt. */
|
|
284
|
+
interface Span {
|
|
285
|
+
start: number;
|
|
286
|
+
end: number;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Locate pi's dynamic date/CWD footer so it can be excluded from Base Prompt and extension additions. */
|
|
290
|
+
function findBasePromptFooter(systemPrompt: string, cwd: string): Span | undefined {
|
|
291
|
+
const promptCwd = cwd.replace(/\\/g, "/");
|
|
292
|
+
const cwdLine = `\nCurrent working directory: ${promptCwd}`;
|
|
293
|
+
let cwdStart = systemPrompt.lastIndexOf(cwdLine);
|
|
294
|
+
while (cwdStart !== -1) {
|
|
295
|
+
const dateStart = systemPrompt.lastIndexOf("\nCurrent date: ", cwdStart);
|
|
296
|
+
const dateLine = dateStart === -1 ? "" : systemPrompt.slice(dateStart, cwdStart);
|
|
297
|
+
if (/^\nCurrent date: \d{4}-\d{2}-\d{2}$/.test(dateLine)) {
|
|
298
|
+
return { start: dateStart, end: cwdStart + cwdLine.length };
|
|
299
|
+
}
|
|
300
|
+
cwdStart = systemPrompt.lastIndexOf(cwdLine, cwdStart - 1);
|
|
301
|
+
}
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Span of the first exact occurrence of needle, or undefined. */
|
|
306
|
+
function findExactSpan(haystack: string, needle: string): Span | undefined {
|
|
307
|
+
const start = haystack.indexOf(needle);
|
|
308
|
+
return start === -1 ? undefined : { start, end: start + needle.length };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Span of pi's complete project-context transport section. */
|
|
312
|
+
function findContextSectionSpan(systemPrompt: string): Span | undefined {
|
|
313
|
+
return findDelimitedSpan(systemPrompt, "<project_context>", "</project_context>");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Extract one context file's final content without its project-instructions wrapper. */
|
|
317
|
+
function findContextFileContent(systemPrompt: string, filePath: string): string | undefined {
|
|
318
|
+
const open = `<project_instructions path="${filePath}">`;
|
|
319
|
+
const close = "</project_instructions>";
|
|
320
|
+
const wrapper = findDelimitedSpan(systemPrompt, open, close);
|
|
321
|
+
if (wrapper === undefined) return undefined;
|
|
322
|
+
let start = wrapper.start + open.length;
|
|
323
|
+
let end = wrapper.end - close.length;
|
|
324
|
+
if (systemPrompt.startsWith("\r\n", start)) start += 2;
|
|
325
|
+
else if (systemPrompt[start] === "\n") start++;
|
|
326
|
+
if (systemPrompt.slice(Math.max(start, end - 2), end) === "\r\n") end -= 2;
|
|
327
|
+
else if (systemPrompt[end - 1] === "\n") end--;
|
|
328
|
+
return systemPrompt.slice(start, end);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Span of the skills intro sentence through </available_skills>. */
|
|
332
|
+
function findSkillsSpan(systemPrompt: string): Span | undefined {
|
|
333
|
+
const open = "The following skills provide specialized instructions";
|
|
334
|
+
const close = "</available_skills>";
|
|
335
|
+
const start = systemPrompt.lastIndexOf(open);
|
|
336
|
+
if (start === -1) return undefined;
|
|
337
|
+
const end = systemPrompt.indexOf(close, start);
|
|
338
|
+
return end === -1 ? undefined : { start, end: end + close.length };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Locate a complete delimited transport wrapper. */
|
|
342
|
+
function findDelimitedSpan(text: string, open: string, close: string): Span | undefined {
|
|
343
|
+
const start = text.indexOf(open);
|
|
344
|
+
if (start === -1) return undefined;
|
|
345
|
+
const closeStart = text.indexOf(close, start + open.length);
|
|
346
|
+
return closeStart === -1 ? undefined : { start, end: closeStart + close.length };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Include surrounding transport-only line breaks when carving a generated section. */
|
|
350
|
+
function expandLineBreaks(text: string, span: Span): Span {
|
|
351
|
+
let start = span.start;
|
|
352
|
+
let end = span.end;
|
|
353
|
+
while (start > 0 && (text[start - 1] === "\n" || text[start - 1] === "\r")) start--;
|
|
354
|
+
while (end < text.length && (text[end] === "\n" || text[end] === "\r")) end++;
|
|
355
|
+
return { start, end };
|
|
356
|
+
}
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic data model shared by capture, measurement, and UI. Pure types —
|
|
3
|
+
* no pi access. Hierarchy and provenance live in typed fields; UI code must
|
|
4
|
+
* never parse labels to recover source, kind, or parent/child relationships.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const PI_SOURCE_ID = "pi";
|
|
8
|
+
export const AGGREGATE_SOURCE_ID = "aggregate:extensions";
|
|
9
|
+
|
|
10
|
+
/** What produced the captured snapshot. */
|
|
11
|
+
export type CaptureOrigin = "real-turn" | "synthetic-probe";
|
|
12
|
+
|
|
13
|
+
/** The frozen lifecycle phase represented by the v0.2.0 injection model. */
|
|
14
|
+
export type InjectionPhase = "initial";
|
|
15
|
+
|
|
16
|
+
/** What kind of context data an injection item is. */
|
|
17
|
+
export type InjectionKind =
|
|
18
|
+
| "base-prompt"
|
|
19
|
+
| "append-prompt"
|
|
20
|
+
| "context-file"
|
|
21
|
+
| "skills"
|
|
22
|
+
| "tool"
|
|
23
|
+
| "prompt-addition"
|
|
24
|
+
| "message";
|
|
25
|
+
|
|
26
|
+
/** Who contributed one or more injection items. */
|
|
27
|
+
export interface InjectionSource {
|
|
28
|
+
/** Stable internal id, namespaced by source kind. */
|
|
29
|
+
readonly id: string;
|
|
30
|
+
/** Human-readable group label. */
|
|
31
|
+
readonly label: string;
|
|
32
|
+
/** True for pi-native components. */
|
|
33
|
+
readonly native: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One measured context injection. */
|
|
37
|
+
export interface InjectionItem {
|
|
38
|
+
/** Stable id, unique within a snapshot. */
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly phase: InjectionPhase;
|
|
41
|
+
readonly kind: InjectionKind;
|
|
42
|
+
readonly source: InjectionSource;
|
|
43
|
+
/** Human-readable item label without embedded hierarchy or source. */
|
|
44
|
+
readonly label: string;
|
|
45
|
+
readonly chars: number;
|
|
46
|
+
/** Estimated tokens (chars/4 heuristic unless measured as a message). */
|
|
47
|
+
readonly tokens: number;
|
|
48
|
+
/** Raw injected text for preview. Process-local; never log or persist. */
|
|
49
|
+
readonly text: string;
|
|
50
|
+
/** True when a message exists only in the transformed provider context, not the session branch. */
|
|
51
|
+
readonly contextOnly?: boolean;
|
|
52
|
+
/** Constituent sub-items (e.g. individual built-in tools or skills), largest first. */
|
|
53
|
+
readonly children?: readonly InjectionItem[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Items of one source, with a precomputed total. */
|
|
57
|
+
export interface InjectionGroup {
|
|
58
|
+
readonly source: InjectionSource;
|
|
59
|
+
readonly items: readonly InjectionItem[];
|
|
60
|
+
readonly totalTokens: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The frozen Initial snapshot presented by the Injections view. */
|
|
64
|
+
export interface InitialSnapshot {
|
|
65
|
+
readonly origin: CaptureOrigin;
|
|
66
|
+
readonly capturedAt: Date;
|
|
67
|
+
readonly groups: readonly InjectionGroup[];
|
|
68
|
+
readonly totalTokens: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One estimated usage category, optionally with a constituent breakdown. */
|
|
72
|
+
export interface UsageCategory {
|
|
73
|
+
/** Stable id, unique within a usage snapshot level. */
|
|
74
|
+
readonly id: string;
|
|
75
|
+
readonly label: string;
|
|
76
|
+
/** Estimated tokens (chars/4 heuristic). */
|
|
77
|
+
readonly tokens: number;
|
|
78
|
+
/** Breakdown of the parent (e.g. per tool or per customType), not additional totals. */
|
|
79
|
+
readonly children?: readonly UsageCategory[];
|
|
80
|
+
/** Content entries backing the category preview; leaves only. */
|
|
81
|
+
readonly entries?: readonly UsagePreviewEntry[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One content entry shown in a Usage category preview. */
|
|
85
|
+
export interface UsagePreviewEntry {
|
|
86
|
+
/** Message time (epoch ms); absent for Initial-snapshot components. */
|
|
87
|
+
readonly timestamp?: number;
|
|
88
|
+
/** Bracket header cells, e.g. ["assistant", "read"] or ["code-style"]. */
|
|
89
|
+
readonly breadcrumb: readonly string[];
|
|
90
|
+
/** Estimated tokens for this entry alone. */
|
|
91
|
+
readonly tokens: number;
|
|
92
|
+
/** Raw content for preview. Process-local; never log or persist. */
|
|
93
|
+
readonly text: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Pi-reported usage; tokens/percent are omitted when unknown (e.g. right after compaction). */
|
|
97
|
+
export interface ReportedContextUsage {
|
|
98
|
+
readonly tokens?: number;
|
|
99
|
+
readonly contextWindow: number;
|
|
100
|
+
readonly percent?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** On-demand estimated context composition presented by the Usage view. */
|
|
104
|
+
export interface ContextUsageSnapshot {
|
|
105
|
+
readonly computedAt: Date;
|
|
106
|
+
readonly modelLabel?: string;
|
|
107
|
+
readonly reported?: ReportedContextUsage;
|
|
108
|
+
readonly categories: readonly UsageCategory[];
|
|
109
|
+
/** Sum of the top-level category estimates. */
|
|
110
|
+
readonly estimatedTokens: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Group measured items by source. Pi-native components come first, extension
|
|
115
|
+
* sources follow by total size, and the unattributable aggregate comes last.
|
|
116
|
+
* Items inside each group follow a fixed semantic order (base prompt, tools,
|
|
117
|
+
* skills, then everything else by size). Returned objects own all nested data;
|
|
118
|
+
* later mutation of the input cannot change the groups.
|
|
119
|
+
*/
|
|
120
|
+
export function groupInjections(items: readonly InjectionItem[]): InjectionGroup[] {
|
|
121
|
+
const groups = new Map<string, MutableGroup>();
|
|
122
|
+
for (const input of items) {
|
|
123
|
+
const item = copyItem(input);
|
|
124
|
+
let group = groups.get(item.source.id);
|
|
125
|
+
if (group === undefined) {
|
|
126
|
+
group = { source: item.source, items: [], totalTokens: 0 };
|
|
127
|
+
groups.set(item.source.id, group);
|
|
128
|
+
}
|
|
129
|
+
group.items.push(item);
|
|
130
|
+
group.totalTokens += item.tokens;
|
|
131
|
+
}
|
|
132
|
+
for (const group of groups.values()) {
|
|
133
|
+
group.items.sort(compareItems);
|
|
134
|
+
}
|
|
135
|
+
return [...groups.values()].sort(compareGroups);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Build an owned Initial snapshot from measured items. */
|
|
139
|
+
export function buildSnapshot(
|
|
140
|
+
items: readonly InjectionItem[],
|
|
141
|
+
origin: CaptureOrigin,
|
|
142
|
+
capturedAt: Date,
|
|
143
|
+
): InitialSnapshot {
|
|
144
|
+
const groups = groupInjections(items);
|
|
145
|
+
return {
|
|
146
|
+
origin,
|
|
147
|
+
capturedAt: new Date(capturedAt),
|
|
148
|
+
groups,
|
|
149
|
+
totalTokens: groups.reduce((sum, group) => sum + group.totalTokens, 0),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Internal accumulator for groupInjections before freezing into InjectionGroup. */
|
|
154
|
+
interface MutableGroup {
|
|
155
|
+
source: InjectionSource;
|
|
156
|
+
items: InjectionItem[];
|
|
157
|
+
totalTokens: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Owned copy of an item, including its nested source and children. */
|
|
161
|
+
function copyItem(item: InjectionItem): InjectionItem {
|
|
162
|
+
return {
|
|
163
|
+
...item,
|
|
164
|
+
source: { ...item.source },
|
|
165
|
+
children: item.children?.map((child) => copyItem(child)),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Order items within a group: base/appended prompt first, then built-in tools,
|
|
171
|
+
* other tools, skills, and finally everything else by size descending.
|
|
172
|
+
*/
|
|
173
|
+
function compareItems(a: InjectionItem, b: InjectionItem): number {
|
|
174
|
+
const rankDelta = itemRank(a) - itemRank(b);
|
|
175
|
+
if (rankDelta !== 0) return rankDelta;
|
|
176
|
+
return b.tokens - a.tokens;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Fixed display rank by kind; built-in tools precede other tools. */
|
|
180
|
+
function itemRank(item: InjectionItem): number {
|
|
181
|
+
switch (item.kind) {
|
|
182
|
+
case "base-prompt":
|
|
183
|
+
case "append-prompt":
|
|
184
|
+
return 0;
|
|
185
|
+
case "tool":
|
|
186
|
+
return item.id === "tool:builtin" ? 1 : 2;
|
|
187
|
+
case "skills":
|
|
188
|
+
return 3;
|
|
189
|
+
default:
|
|
190
|
+
return 4;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Order groups: pi-native first, then extensions by size, aggregate last. */
|
|
195
|
+
function compareGroups(a: InjectionGroup, b: InjectionGroup): number {
|
|
196
|
+
if (a.source.native !== b.source.native) return a.source.native ? -1 : 1;
|
|
197
|
+
const aAggregate = a.source.id === AGGREGATE_SOURCE_ID;
|
|
198
|
+
const bAggregate = b.source.id === AGGREGATE_SOURCE_ID;
|
|
199
|
+
if (aAggregate !== bAggregate) return aAggregate ? 1 : -1;
|
|
200
|
+
return b.totalTokens - a.totalTokens;
|
|
201
|
+
}
|