/**
* Prompt Channels Extension
*
* Relocates AGENTS.md/project-context content and skills registry from the
* system prompt to user-level custom messages while keeping tiny system-level
* hints about where those channels live.
*
* Compatible with pi-coding-agent >= 0.67.1. If newer runtimes expose
* `event.systemPromptOptions`, this extension uses them as the source of truth;
* otherwise it falls back to parsing the assembled system prompt string.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const CUSTOM_TYPE = "prompt-channels";
// Old format (pi < ~0.68): markdown heading
const PROJECT_CONTEXT_ANCHOR_OLD = "# Project Context\n\nProject-specific instructions and guidelines:\n\n";
// New format (pi >= ~0.68): XML tags
const PROJECT_CONTEXT_OPEN_TAG = "";
const PROJECT_CONTEXT_CLOSE_TAG = "";
const SKILLS_INTRO_LINES = [
"The following skills provide specialized instructions for specific tasks.",
"Use the read tool to load a skill's file when the task matches its description.",
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
] as const;
const SKILLS_START_MARKER = `${SKILLS_INTRO_LINES.join("\n")}\n\n`;
const SKILLS_END_TAG = "";
const SYSTEM_HINTS = [
"## Context Channels",
"- Project/repository instructions may appear in tagged user-context messages with `` blocks.",
"- Available skills may appear in tagged reminder messages with ``; use them instead of guessing.",
].join("\n");
interface ContextFileLike {
path: string;
content: string;
}
interface SkillLike {
name: string;
description: string;
filePath?: string;
location?: string;
disableModelInvocation?: boolean;
}
interface SystemPromptOptionsLike {
contextFiles?: ContextFileLike[];
skills?: SkillLike[];
}
interface ChannelsState {
lastContextHash: string | undefined;
lastSkillsHash: string | undefined;
lastCwd: string | undefined;
pendingReinject: boolean;
/** When true, the conversation already has a prompt-channels message from a
* prior session (resume/fork). The first before_agent_start should save
* hashes but skip injection. */
skipInitialInject: boolean;
}
interface MessageLike {
role: string;
customType?: string;
}
function createState(): ChannelsState {
return {
lastContextHash: undefined,
lastSkillsHash: undefined,
lastCwd: undefined,
pendingReinject: true,
skipInitialInject: false,
};
}
function simpleHash(input: string): string {
let hash = 5381;
for (let i = 0; i < input.length; i++) {
hash = ((hash << 5) + hash + input.charCodeAt(i)) | 0;
}
return (hash >>> 0).toString(36);
}
function hashContent(content: string | null): string {
return content ? simpleHash(content) : "";
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function getSystemPromptOptions(event: unknown): SystemPromptOptionsLike | undefined {
const options = (event as { systemPromptOptions?: unknown }).systemPromptOptions;
if (!options || typeof options !== "object") return undefined;
return options as SystemPromptOptionsLike;
}
function formatContextFilesFromOptions(contextFiles: ContextFileLike[] | undefined): string | null {
if (!contextFiles || contextFiles.length === 0) return null;
return contextFiles.map((file) => `## ${file.path}\n\n${file.content}`).join("\n\n");
}
function formatSkillsFromOptions(skills: SkillLike[] | undefined): string | null {
const visibleSkills = (skills ?? []).filter((skill) => !skill.disableModelInvocation);
if (visibleSkills.length === 0) return null;
const lines = [...SKILLS_INTRO_LINES, "", ""];
for (const skill of visibleSkills) {
lines.push(" ");
lines.push(` ${escapeXml(skill.name)}`);
lines.push(` ${escapeXml(skill.description)}`);
lines.push(` ${escapeXml(skill.filePath ?? skill.location ?? "")}`);
lines.push(" ");
}
lines.push(SKILLS_END_TAG);
return lines.join("\n");
}
function getProjectContextRange(prompt: string): { start: number; end: number } | null {
// Try new XML format first (pi >= ~0.68)
const openIndex = prompt.indexOf(PROJECT_CONTEXT_OPEN_TAG);
if (openIndex !== -1) {
const closeIndex = prompt.indexOf(PROJECT_CONTEXT_CLOSE_TAG, openIndex + PROJECT_CONTEXT_OPEN_TAG.length);
if (closeIndex !== -1) {
const start = openIndex >= 2 && prompt.slice(openIndex - 2, openIndex) === "\n\n" ? openIndex - 2 : openIndex;
return { start, end: closeIndex + PROJECT_CONTEXT_CLOSE_TAG.length };
}
}
// Fall back to old markdown heading format
const anchorIndex = prompt.indexOf(PROJECT_CONTEXT_ANCHOR_OLD);
if (anchorIndex === -1) return null;
const contentStart = anchorIndex + PROJECT_CONTEXT_ANCHOR_OLD.length;
const dateIndex = prompt.indexOf("\n\nCurrent date:", contentStart);
const skillsIndex = prompt.indexOf(`\n\n${SKILLS_INTRO_LINES[0]}`, contentStart);
let end = prompt.length;
if (dateIndex !== -1 && dateIndex < end) end = dateIndex;
if (skillsIndex !== -1 && skillsIndex < end) end = skillsIndex;
const start = anchorIndex >= 2 && prompt.slice(anchorIndex - 2, anchorIndex) === "\n\n" ? anchorIndex - 2 : anchorIndex;
return { start, end };
}
function extractProjectContext(prompt: string): string | null {
const range = getProjectContextRange(prompt);
if (!range) return null;
const raw = prompt.slice(range.start, range.end);
// New XML format: parse tags
if (raw.includes(PROJECT_CONTEXT_OPEN_TAG)) {
const instructions: string[] = [];
const regex = /\n([\s\S]*?)\n<\/project_instructions>/g;
let match;
while ((match = regex.exec(raw)) !== null) {
instructions.push(`## ${match[1]}\n\n${match[2]}`);
}
return instructions.length > 0 ? instructions.join("\n\n") : null;
}
// Old markdown heading format
return raw.replace(/^\n\n# Project Context\n\nProject-specific instructions and guidelines:\n\n/, "");
}
function getSkillsRange(prompt: string): { start: number; end: number } | null {
const startIndex = prompt.indexOf(SKILLS_START_MARKER);
if (startIndex === -1) return null;
const endIndex = prompt.indexOf(SKILLS_END_TAG, startIndex);
if (endIndex === -1) return null;
const start = startIndex >= 2 && prompt.slice(startIndex - 2, startIndex) === "\n\n" ? startIndex - 2 : startIndex;
return { start, end: endIndex + SKILLS_END_TAG.length };
}
function extractSkillsSection(prompt: string): string | null {
const range = getSkillsRange(prompt);
if (!range) return null;
return prompt.slice(range.start, range.end).trim();
}
function stripRange(prompt: string, range: { start: number; end: number } | null): string {
if (!range) return prompt;
return prompt.slice(0, range.start) + prompt.slice(range.end);
}
function injectSystemHints(prompt: string): string {
if (prompt.includes("## Context Channels")) return prompt;
const dateAnchor = "\n\nCurrent date:";
const dateIndex = prompt.indexOf(dateAnchor);
const block = `\n\n${SYSTEM_HINTS}`;
if (dateIndex === -1) return `${prompt}${block}`;
return `${prompt.slice(0, dateIndex)}${block}${prompt.slice(dateIndex)}`;
}
function stripPrompt(prompt: string): string {
let result = prompt;
const projectRange = getProjectContextRange(result);
if (projectRange) result = stripRange(result, projectRange);
const skillsRange = getSkillsRange(result);
if (skillsRange) result = stripRange(result, skillsRange);
if (projectRange || skillsRange) result = injectSystemHints(result);
return result;
}
function formatContextMessage(contextContent: string, cwd: string): string {
if (!contextContent.trim()) return "";
return [
`# AGENTS.md instructions for ${cwd}`,
"",
"",
contextContent.trimEnd(),
"",
].join("\n");
}
function formatSkillsMessage(skillsContent: string): string {
if (!skillsContent.trim()) return "";
return ["", skillsContent.trim(), ""].join("\n");
}
function buildCombinedMessage(contextContent: string | null, skillsContent: string | null, cwd: string): string {
const parts: string[] = [];
if (contextContent?.trim()) {
parts.push(formatContextMessage(contextContent, cwd));
}
if (skillsContent?.trim()) {
parts.push(formatSkillsMessage(skillsContent));
}
return parts.join("\n\n---\n\n");
}
function isPromptChannelsMessage(message: MessageLike): boolean {
return message.role === "custom" && message.customType === CUSTOM_TYPE;
}
function findPreviousUserIndex(messages: MessageLike[], startIndex: number): number {
for (let i = startIndex - 1; i >= 0; i--) {
if (messages[i]?.role === "user") return i;
}
return -1;
}
function reorderPromptChannelsBeforeUser(messages: T[]): T[] {
const reordered = [...messages];
let changed = false;
for (let i = 0; i < reordered.length; i++) {
const message = reordered[i];
if (!message || !isPromptChannelsMessage(message)) continue;
const previousUserIndex = findPreviousUserIndex(reordered, i);
if (previousUserIndex === -1 || previousUserIndex === i) continue;
const [promptChannelsMessage] = reordered.splice(i, 1);
reordered.splice(previousUserIndex, 0, promptChannelsMessage);
changed = true;
}
return changed ? reordered : messages;
}
function resolveContextContent(systemPrompt: string, event: unknown): string | null {
const options = getSystemPromptOptions(event);
if (options && "contextFiles" in options) {
return formatContextFilesFromOptions(options.contextFiles);
}
return extractProjectContext(systemPrompt);
}
function resolveSkillsContent(systemPrompt: string, event: unknown): string | null {
const options = getSystemPromptOptions(event);
if (options && "skills" in options) {
return formatSkillsFromOptions(options.skills);
}
return extractSkillsSection(systemPrompt);
}
export default function promptChannels(pi: ExtensionAPI): void {
const state = createState();
const RESUME_REASONS = new Set(["resume", "fork"]);
pi.on("session_start", (event) => {
if (RESUME_REASONS.has(event.reason)) {
state.pendingReinject = false;
state.skipInitialInject = true;
}
});
pi.on("session_compact", () => {
state.pendingReinject = true;
});
pi.on("context", (event) => {
const reordered = reorderPromptChannelsBeforeUser(event.messages);
return reordered === event.messages ? undefined : { messages: reordered };
});
pi.on("before_agent_start", (event, ctx) => {
const { systemPrompt } = event;
const contextContent = resolveContextContent(systemPrompt, event);
const skillsContent = resolveSkillsContent(systemPrompt, event);
const contextHash = hashContent(contextContent);
const skillsHash = hashContent(skillsContent);
const cwdChanged = ctx.cwd !== state.lastCwd;
const shouldReinject =
state.pendingReinject ||
contextHash !== state.lastContextHash ||
skillsHash !== state.lastSkillsHash ||
cwdChanged;
const modifiedPrompt = stripPrompt(systemPrompt);
state.lastContextHash = contextHash;
state.lastSkillsHash = skillsHash;
state.lastCwd = ctx.cwd;
// On resume/fork the conversation already contains our custom message.
// Save hashes so future changes are detected, but skip injection.
if (state.skipInitialInject) {
state.skipInitialInject = false;
return modifiedPrompt === systemPrompt ? undefined : { systemPrompt: modifiedPrompt };
}
if (!shouldReinject) {
state.pendingReinject = false;
return modifiedPrompt === systemPrompt ? undefined : { systemPrompt: modifiedPrompt };
}
state.pendingReinject = false;
const messageContent = buildCombinedMessage(contextContent, skillsContent, ctx.cwd);
if (!messageContent.trim()) {
return modifiedPrompt === systemPrompt ? undefined : { systemPrompt: modifiedPrompt };
}
return {
systemPrompt: modifiedPrompt,
message: {
customType: CUSTOM_TYPE,
content: messageContent,
display: true,
details: {},
},
};
});
}