import type { MentionedSkill } from "./mentions.ts";
import type { InjectedSkillMeta } from "./message.ts";
import { estimateTokens, stripFrontmatter } from "@earendil-works/pi-coding-agent";
import { readFile, realpath, stat } from "node:fs/promises";
import { INLINE_SKILLS_TYPE } from "./message.ts";
const H1_PATTERN = /^#\s+(.+?)\s*$/m;
export function escapeXml(value: string): string {
return value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
// Block shape matches codex's SkillInstructions fragment; the body intentionally
// has frontmatter stripped (codex injects the raw file).
export function buildSkillBlock(name: string, path: string, body: string): string {
return `\n${escapeXml(name)}\n${escapeXml(path)}\n${body}\n`;
}
export function reminderBody(name: string): string {
return `Reminder to use $${name}.`;
}
interface ContextMessageLike {
role?: string;
customType?: string;
details?: unknown;
}
// Identity of an injected body: same resolved file, unchanged size and mtime.
// Any of these differing forces a fresh full injection instead of a reminder.
export function fingerprint(path: string, mtimeMs: number, size: number): string {
return `${path} ${mtimeMs} ${size}`;
}
function isFullRecord(value: unknown): value is InjectedSkillMeta {
if (typeof value !== "object" || value === null)
return false;
const record = value as Record;
return record.mode === "full"
&& typeof record.name === "string"
&& typeof record.path === "string"
&& typeof record.mtimeMs === "number"
&& typeof record.size === "number";
}
// Latest full injection wins per skill name; messages must come from the
// active session context so compacted-away injections naturally re-inject.
// Historical `details` is untrusted (unknown in the pi API), so every field is
// validated before use — a malformed record is ignored, never thrown on.
export function collectPreviousFull(messages: readonly unknown[]): Map {
const full = new Map();
for (const raw of messages) {
const message = raw as ContextMessageLike;
if (message.role !== "custom" || message.customType !== INLINE_SKILLS_TYPE)
continue;
const details = message.details as { skills?: unknown } | undefined;
if (!details || !Array.isArray(details.skills))
continue;
for (const skill of details.skills)
if (isFullRecord(skill))
full.set(skill.name, fingerprint(skill.path, skill.mtimeMs, skill.size));
}
return full;
}
export interface BuildInjectionOptions {
previousFull: Map | null;
notify?: (message: string) => void;
}
export interface InjectionResult {
content: string;
skills: InjectedSkillMeta[];
}
function estimateBlockTokens(block: string): number {
const message = { role: "custom", customType: INLINE_SKILLS_TYPE, content: block, display: true, timestamp: 0 };
return estimateTokens(message as Parameters[0]);
}
type BuiltSkill = { block: string; meta: InjectedSkillMeta };
type BuildResult = BuiltSkill | { warning: string };
export async function buildInjection(mentioned: MentionedSkill[], options: BuildInjectionOptions): Promise {
// Skills are independent files — load them concurrently (map preserves order).
// Failures return a warning rather than notifying inline, so side effects stay
// out of the parallel tasks and fire in deterministic mention order below.
const results = await Promise.all(mentioned.map(async (skill): Promise => {
try {
const resolvedPath = await realpath(skill.path);
const [raw, fileStat] = await Promise.all([readFile(resolvedPath, "utf8"), stat(resolvedPath)]);
// pi's stripFrontmatter already trims the body when frontmatter is
// present. Strip only surrounding blank lines here so a frontmatter-less
// body keeps its internal indentation instead of being fully trimmed.
const body = stripFrontmatter(raw).replace(/^\n+/, "").trimEnd();
const current = fingerprint(resolvedPath, fileStat.mtimeMs, fileStat.size);
const mode = options.previousFull?.get(skill.name) === current ? "reminder" : "full";
const block = buildSkillBlock(skill.name, resolvedPath, mode === "reminder" ? reminderBody(skill.name) : body);
const meta: InjectedSkillMeta = {
name: skill.name,
path: resolvedPath,
mode,
label: body.match(H1_PATTERN)?.[1]?.trim() || `$${skill.name}`,
mtimeMs: fileStat.mtimeMs,
size: fileStat.size,
tokenCount: estimateBlockTokens(block),
};
return { block, meta };
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return { warning: `pi-inline-skills: failed to load skill "${skill.name}": ${reason}` };
}
}));
const ok: BuiltSkill[] = [];
for (const result of results) {
if ("warning" in result)
options.notify?.(result.warning);
else
ok.push(result);
}
if (ok.length === 0)
return undefined;
return { content: ok.map((b) => b.block).join("\n\n"), skills: ok.map((b) => b.meta) };
}