import { parseSkillBlock } from "@earendil-works/pi-coding-agent";
/**
* Characters kept from a single skill body when it is carried across a
* compaction. Roughly four characters per token, matching the estimate Pi's
* own compaction uses.
*/
const MAX_SKILL_CHARS = 16000;
/** Combined character budget for every skill carried across one compaction. */
const MAX_CARRY_CHARS = 24000;
const TRUNCATED = "\n\n[skill content truncated to fit the context budget]";
type ContextMessage = {
role: string;
content?: unknown;
};
type TextPart = { type: "text"; text: string };
type SessionEntry = {
type: string;
message?: { role?: string; content?: unknown };
};
type Invocation = { name: string; block: string };
function isTextPart(value: unknown): value is TextPart {
return (
typeof value === "object" &&
value !== null &&
(value as { type?: unknown }).type === "text" &&
typeof (value as { text?: unknown }).text === "string"
);
}
/**
* Pi renders a skill invocation as one text part holding the block followed by
* the user's own request, so both live in a single message.
*/
function readInvocation(
text: string,
): { name: string; block: string; request: string } | undefined {
const parsed = parseSkillBlock(text);
if (!parsed) return undefined;
return {
name: parsed.name,
block: `\n${parsed.content}\n`,
request: parsed.userMessage ?? "",
};
}
function messageInvocation(
message: ContextMessage | SessionEntry["message"],
): ReturnType {
const content = message?.content;
if (typeof content === "string") return readInvocation(content);
if (!Array.isArray(content)) return undefined;
for (const part of content) {
if (!isTextPart(part)) continue;
const invocation = readInvocation(part.text);
if (invocation) return invocation;
}
return undefined;
}
function replaceInvocationText(
message: ContextMessage,
replacement: string,
): ContextMessage {
const content = message.content;
if (typeof content === "string") {
return { ...message, content: replacement };
}
if (!Array.isArray(content)) return message;
let done = false;
const next = content.map((part) => {
if (done || !isTextPart(part) || !parseSkillBlock(part.text)) return part;
done = true;
return { ...part, text: replacement };
});
return done ? { ...message, content: next } : message;
}
function prependText(message: ContextMessage, prefix: string): ContextMessage {
const content = message.content;
if (typeof content === "string") {
return { ...message, content: `${prefix}${content}` };
}
if (!Array.isArray(content)) return message;
const index = content.findIndex(isTextPart);
if (index < 0) {
return {
...message,
content: [{ type: "text", text: prefix }, ...content],
};
}
const part = content[index] as TextPart;
const next = [...content];
next[index] = { ...part, text: `${prefix}${part.text}` };
return { ...message, content: next };
}
function truncate(block: string, limit: number): string {
return block.length <= limit ? block : block.slice(0, limit) + TRUNCATED;
}
/**
* Keep every invoked skill present exactly once in the outgoing context.
*
* Pi inlines the whole `SKILL.md` body into a user message and then treats that
* message like any other: re-invoking a skill appends a second full copy, and
* compaction summarizes the copy away while the model keeps working under
* instructions it can no longer read. This collapses repeats to a short note
* and carries the most recent body across a compaction boundary, bounded by a
* character budget so the restored text cannot re-trigger the compaction that
* just ran.
*
* The first copy is never rewritten, so the cached request prefix stays byte
* stable across turns.
*/
export function pinSkillContext(
messages: T[],
branch: SessionEntry[],
): T[] {
const seen = new Set();
let changed = false;
let next = messages.map((message) => {
if (message.role !== "user") return message;
const invocation = messageInvocation(message);
if (!invocation) return message;
if (!seen.has(invocation.block)) {
seen.add(invocation.block);
return message;
}
changed = true;
const note = `[skill "${invocation.name}" is already loaded earlier in this conversation]`;
return replaceInvocationText(
message,
invocation.request ? `${note}\n\n${invocation.request}` : note,
) as T;
});
const carried = carryAcrossCompaction(next, branch, seen);
if (!carried) return changed ? next : messages;
const anchor = restoreAnchor(next);
if (anchor < 0) return changed ? next : messages;
next = [...next];
next[anchor] = prependText(next[anchor] as ContextMessage, carried) as T;
return next;
}
/**
* Collect the newest body of each skill that the branch invoked but the
* compacted context no longer carries, newest first until the budget runs out.
*/
function carryAcrossCompaction(
messages: ContextMessage[],
branch: SessionEntry[],
present: Set,
): string | undefined {
if (!messages.some((message) => message.role === "compactionSummary")) {
return undefined;
}
const missing = new Map();
for (const entry of branch) {
if (entry.type !== "message" || entry.message?.role !== "user") continue;
const invocation = messageInvocation(entry.message);
if (!invocation) continue;
// A skill file edited mid-session yields a new body, so the newest state
// of each name decides: a present body cancels an earlier missing one.
if (present.has(invocation.block)) missing.delete(invocation.name);
else missing.set(invocation.name, invocation);
}
if (!missing.size) return undefined;
const blocks: string[] = [];
let used = 0;
for (const invocation of [...missing.values()].reverse()) {
const block = truncate(invocation.block, MAX_SKILL_CHARS);
if (used + block.length > MAX_CARRY_CHARS) continue;
used += block.length;
blocks.unshift(block);
}
return blocks.length ? `${blocks.join("\n\n")}\n\n` : undefined;
}
/** The first user turn after the newest compaction summary. */
function restoreAnchor(messages: ContextMessage[]): number {
let summary = -1;
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index]?.role === "compactionSummary") {
summary = index;
break;
}
}
if (summary < 0) return -1;
for (let index = summary + 1; index < messages.length; index += 1) {
if (messages[index]?.role === "user") return index;
}
return -1;
}