/**
* Pure measurement logic: split a captured system prompt into semantic items
* and estimate token sizes. No pi API access — unit-testable.
*
* Splitting relies on structural markers that pi's buildSystemPrompt() emits
* deterministically (verified against pi 0.81.1 dist/core/system-prompt.js):
*
* - context files: ...
* - skills block: "The following skills provide..." through
* - base prompt ends before the "Current working directory" footer (pi 0.81),
* optionally preceded by a "Current date" line (pi 0.80)
* → anything after that footer was appended by before_agent_start handlers.
*/
import {
AGGREGATE_SOURCE_ID,
type InjectionItem,
type InjectionKind,
type InjectionSource,
PI_SOURCE_ID,
} from "./model.ts";
const PI_SOURCE: InjectionSource = { id: PI_SOURCE_ID, label: "pi", native: true };
const AGGREGATE_SOURCE: InjectionSource = {
id: AGGREGATE_SOURCE_ID,
label: "extensions (aggregate)",
native: false,
};
/** One visible skill before pi adds XML transport framing. */
export interface SkillSlice {
name: string;
description: string;
filePath: string;
}
/** Minimal slice of BuildSystemPromptOptions that measurement needs. */
export interface PromptOptionsSlice {
cwd: string;
/** Home directory used to abbreviate context-file paths; omitted disables it. */
homeDir?: string;
customPrompt?: string;
appendSystemPrompt?: string;
contextFilePaths?: string[];
skills?: SkillSlice[];
}
/** One active tool as it contributes to the initial context. */
export interface ToolSlice {
name: string;
/** Sent to the provider with every request. */
description: string;
/** JSON-serialized parameter schema; sent to the provider with every request. */
parametersJson: string;
/** One-line snippet rendered into the prompt's Available tools list. */
snippet?: string;
/** Guideline bullets rendered into the prompt's Guidelines section. */
guidelines: string[];
/** Provenance, e.g. "builtin" or "npm:pi-web-providers". */
source: string;
}
/**
* Split a captured system prompt into semantic items: pi base prompt,
* appended prompt, context files, skills, active tool contributions, and the
* aggregate appended by extensions.
*/
export function analyzeSystemPrompt(
systemPrompt: string,
options: PromptOptionsSlice,
tools: ToolSlice[] = [],
): InjectionItem[] {
const items: InjectionItem[] = [];
const carvedSpans: Span[] = [];
const footer = findBasePromptFooter(systemPrompt, options.cwd);
const base = footer === undefined ? systemPrompt : systemPrompt.slice(0, footer.start);
const usesCustomPrompt = options.customPrompt !== undefined && options.customPrompt.length > 0;
measureTools(usesCustomPrompt ? "" : base, tools, items, carvedSpans);
measureContextFiles(base, options, items, carvedSpans);
measureSkills(base, options, items, carvedSpans);
measureAppendedPrompt(base, options, items, carvedSpans);
const baseLabel = usesCustomPrompt ? "Custom Prompt (--system-prompt)" : "Base Prompt";
items.unshift(createItem("base-prompt", "base-prompt", PI_SOURCE, baseLabel, carve(base, carvedSpans)));
if (footer !== undefined && footer.end < systemPrompt.length) {
const added = systemPrompt.slice(footer.end);
if (added.trim().length > 0) {
items.push(
createItem(
"prompt-addition:aggregate",
"prompt-addition",
AGGREGATE_SOURCE,
"system prompt additions",
added,
),
);
}
}
return items;
}
/** Same chars/4 heuristic pi's estimateTokens uses for text content. */
export function textTokens(text: string): number {
return Math.ceil(text.length / 4);
}
/**
* Measure active tool contributions: per-tool definition payloads plus the
* prompt snippet/guideline lines carved out of the base prompt. Built-in
* tools collapse into one aggregate pi-native item.
*/
function measureTools(base: string, tools: ToolSlice[], items: InjectionItem[], carvedSpans: Span[]): void {
const builtinChildren: InjectionItem[] = [];
for (const tool of tools) {
const definition = `${tool.name}: ${tool.description}\n${tool.parametersJson}`;
if (tool.source === "builtin") {
builtinChildren.push(createItem(`tool:builtin:${tool.name}`, "tool", PI_SOURCE, tool.name, definition));
continue;
}
let promptText = "";
if (tool.snippet !== undefined) {
const span = findExactSpan(base, `\n- ${tool.name}: ${tool.snippet}`);
if (span !== undefined) {
promptText += base.slice(span.start, span.end);
carvedSpans.push(span);
}
}
for (const guideline of tool.guidelines) {
const span = findExactSpan(base, `\n- ${guideline.trim()}`);
if (span !== undefined) {
promptText += base.slice(span.start, span.end);
carvedSpans.push(span);
}
}
const source = extensionSource(tool.source);
items.push(createItem(`tool:${tool.source}:${tool.name}`, "tool", source, tool.name, promptText + definition));
}
if (builtinChildren.length > 0) {
builtinChildren.sort((a, b) => b.tokens - a.tokens);
const label = `Built-in Tools (${builtinChildren.length})`;
const text = builtinChildren.map((child) => child.text).join("\n");
items.push({
...createItem("tool:builtin", "tool", PI_SOURCE, label, text),
chars: builtinChildren.reduce((sum, child) => sum + child.chars, 0),
tokens: builtinChildren.reduce((sum, child) => sum + child.tokens, 0),
children: builtinChildren,
});
}
}
/** Measure context-file contents without counting pi's XML transport scaffolding. */
function measureContextFiles(
base: string,
options: PromptOptionsSlice,
items: InjectionItem[],
carvedSpans: Span[],
): void {
const sectionSpan = findContextSectionSpan(base);
if (sectionSpan === undefined) return;
for (const filePath of options.contextFilePaths ?? []) {
const content = findContextFileContent(base, filePath);
if (content === undefined) continue;
items.push(
createItem(
`context-file:${filePath}`,
"context-file",
PI_SOURCE,
abbreviateHome(filePath, options.homeDir),
content,
),
);
}
carvedSpans.push(expandLineBreaks(base, sectionSpan));
}
/** Carve the skills section and expose each semantic skill record as a child item. */
function measureSkills(
base: string,
options: PromptOptionsSlice,
items: InjectionItem[],
carvedSpans: Span[],
): void {
const sectionSpan = findSkillsSpan(base);
if (sectionSpan === undefined) return;
const children = (options.skills ?? [])
.map((skill) => createItem(
`skill:${skill.name}`,
"skills",
PI_SOURCE,
skill.name,
[skill.name, skill.description, skill.filePath].join("\n"),
))
.sort((a, b) => b.tokens - a.tokens);
carvedSpans.push(expandLineBreaks(base, sectionSpan));
if (children.length === 0) return;
items.push(createAggregateItem("skills", "skills", PI_SOURCE, `Skills (${children.length})`, children));
}
/** Carve the --append-system-prompt text out of the base prompt when present. */
function measureAppendedPrompt(
base: string,
options: PromptOptionsSlice,
items: InjectionItem[],
carvedSpans: Span[],
): void {
const append = options.appendSystemPrompt;
if (append === undefined || append.length === 0) return;
const generatedStarts = [findContextSectionSpan(base)?.start, findSkillsSpan(base)?.start]
.filter((start): start is number => start !== undefined);
const generatedStart = generatedStarts.length === 0 ? base.length : Math.min(...generatedStarts);
const beforeGeneratedSections = Math.max(0, generatedStart - append.length);
const expectedStart = base.lastIndexOf(append, beforeGeneratedSections);
const start = expectedStart === -1 ? base.lastIndexOf(append) : expectedStart;
if (start === -1) return;
items.push(createItem("append-prompt", "append-prompt", PI_SOURCE, "appended system prompt", append));
carvedSpans.push({ start, end: start + append.length });
}
/** Build an initial-phase InjectionItem with derived char/token sizes. */
function createItem(
id: string,
kind: InjectionKind,
source: InjectionSource,
label: string,
text: string,
): InjectionItem {
return {
id,
phase: "initial",
kind,
source,
label,
chars: text.length,
tokens: textTokens(text),
text,
};
}
/** Build an aggregate whose totals exactly reconcile with its child items. */
function createAggregateItem(
id: string,
kind: InjectionKind,
source: InjectionSource,
label: string,
children: InjectionItem[],
): InjectionItem {
return {
...createItem(id, kind, source, label, children.map((child) => child.text).join("\n")),
chars: children.reduce((sum, child) => sum + child.chars, 0),
tokens: children.reduce((sum, child) => sum + child.tokens, 0),
children,
};
}
/** Injection source for a non-builtin tool provenance string. */
function extensionSource(source: string): InjectionSource {
return { id: `tool-source:${source}`, label: source, native: false };
}
/** Replace a leading home-directory prefix with `~` for compact path labels. */
function abbreviateHome(path: string, homeDir: string | undefined): string {
if (homeDir === undefined || homeDir.length === 0) return path;
if (path === homeDir) return "~";
if (path.startsWith(`${homeDir}/`)) return `~${path.slice(homeDir.length)}`;
return path;
}
/** Remove the given spans from text, tolerating overlaps, and return the remainder. */
function carve(text: string, spans: Span[]): string {
spans.sort((a, b) => a.start - b.start);
let remainder = "";
let cursor = 0;
for (const span of spans) {
if (span.start > cursor) remainder += text.slice(cursor, span.start);
cursor = Math.max(cursor, span.end);
}
return remainder + text.slice(cursor);
}
/** Half-open [start, end) character range within the base prompt. */
interface Span {
start: number;
end: number;
}
/**
* Locate pi's dynamic CWD footer so it can be excluded from Base Prompt and
* extension additions. Pi 0.81 emits only the "Current working directory"
* line; pi 0.80 preceded it with a "Current date" line, still recognized for
* compatibility. The CWD line must match the exact resolved cwd on a complete
* line (preceded by "\n", followed by "\n" or end of prompt) so ordinary
* prompt text mentioning the cwd is not mistaken for the footer.
*/
function findBasePromptFooter(systemPrompt: string, cwd: string): Span | undefined {
const promptCwd = cwd.replace(/\\/g, "/");
const cwdLine = `\nCurrent working directory: ${promptCwd}`;
let cwdStart = systemPrompt.lastIndexOf(cwdLine);
while (cwdStart !== -1) {
const end = cwdStart + cwdLine.length;
if (end === systemPrompt.length || systemPrompt[end] === "\n") {
const dateStart = systemPrompt.lastIndexOf("\nCurrent date: ", cwdStart);
const dateLine = dateStart === -1 ? "" : systemPrompt.slice(dateStart, cwdStart);
const start = /^\nCurrent date: \d{4}-\d{2}-\d{2}$/.test(dateLine) ? dateStart : cwdStart;
return { start, end };
}
cwdStart = systemPrompt.lastIndexOf(cwdLine, cwdStart - 1);
}
return undefined;
}
/** Span of the first exact occurrence of needle, or undefined. */
function findExactSpan(haystack: string, needle: string): Span | undefined {
const start = haystack.indexOf(needle);
return start === -1 ? undefined : { start, end: start + needle.length };
}
/** Span of pi's complete project-context transport section. */
function findContextSectionSpan(systemPrompt: string): Span | undefined {
return findDelimitedSpan(systemPrompt, "", "");
}
/** Extract one context file's final content without its project-instructions wrapper. */
function findContextFileContent(systemPrompt: string, filePath: string): string | undefined {
const open = ``;
const close = "";
const wrapper = findDelimitedSpan(systemPrompt, open, close);
if (wrapper === undefined) return undefined;
let start = wrapper.start + open.length;
let end = wrapper.end - close.length;
if (systemPrompt.startsWith("\r\n", start)) start += 2;
else if (systemPrompt[start] === "\n") start++;
if (systemPrompt.slice(Math.max(start, end - 2), end) === "\r\n") end -= 2;
else if (systemPrompt[end - 1] === "\n") end--;
return systemPrompt.slice(start, end);
}
/** Span of the skills intro sentence through . */
function findSkillsSpan(systemPrompt: string): Span | undefined {
const open = "The following skills provide specialized instructions";
const close = "";
const start = systemPrompt.lastIndexOf(open);
if (start === -1) return undefined;
const end = systemPrompt.indexOf(close, start);
return end === -1 ? undefined : { start, end: end + close.length };
}
/** Locate a complete delimited transport wrapper. */
function findDelimitedSpan(text: string, open: string, close: string): Span | undefined {
const start = text.indexOf(open);
if (start === -1) return undefined;
const closeStart = text.indexOf(close, start + open.length);
return closeStart === -1 ? undefined : { start, end: closeStart + close.length };
}
/** Include surrounding transport-only line breaks when carving a generated section. */
function expandLineBreaks(text: string, span: Span): Span {
let start = span.start;
let end = span.end;
while (start > 0 && (text[start - 1] === "\n" || text[start - 1] === "\r")) start--;
while (end < text.length && (text[end] === "\n" || text[end] === "\r")) end++;
return { start, end };
}