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) }; }