import { existsSync, readFileSync, realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const commandPattern = /^\/(ars-[a-z0-9-]+)(?:\s+([\s\S]*))?$/; const skillNames = ["deep-research", "academic-paper", "academic-paper-reviewer", "academic-pipeline"]; const skillPattern = new RegExp(`^/skill:(${skillNames.join("|")})(?:\\s|$)`); const stateEntryType = "ars-pi-state"; const orchestrationPattern = /subagent|workflow|parallel[-_ ]agent|multi[-_ ]agent/i; const webPattern = /websearch|web[-_ ]search|brave[-_ ]search|webfetch|web[-_ ]fetch|page[-_ ]retrieval/i; function uniqueMatches(items, pattern) { return [...new Set(items.filter((item) => pattern.test(item.search)).map((item) => item.label))]; } const xmlEntities = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", }; function decodeXml(text) { return text.replace(/&(amp|lt|gt|quot|apos);/g, (_match, entity) => xmlEntities[entity]); } function canonicalPath(path) { try { return realpathSync(path); } catch { return undefined; } } const skillLocations = new Set( skillNames .map((name) => canonicalPath(resolve(repoRoot, name, "SKILL.md"))) .filter((location) => location !== undefined), ); // Upstream SKILL.md files stay unmodified, so hide their exact Pi listings while ARS is inactive. function hideArsSkills(systemPrompt) { if (Array.isArray(systemPrompt)) return systemPrompt.map(hideArsSkills); return systemPrompt.replace( /(?:\r?\n)?[ \t]*(?:(?!)[\s\S])*?<\/skill>/g, (block) => { const location = block.match(/([^<]+)<\/location>/)?.[1]; const canonicalLocation = location ? canonicalPath(decodeXml(location)) : undefined; return canonicalLocation && skillLocations.has(canonicalLocation) ? "" : block; }, ); } async function probe(pi, command, args) { try { const result = await pi.exec(command, args, { timeout: 3000 }); if (result.code !== 0) return "missing"; return (result.stdout || result.stderr).trim().split("\n")[0] || "available"; } catch { return "missing"; } } const compatibility = ` ## Academic Research Skills compatibility for Pi Apply this section only while using an Academic Research Skills (ARS) skill or an \`/ars-*\` prompt. - The original Claude Code ARS files are the source of truth. Do not replace their research, writing, review, integrity, or checkpoint rules. - Resolve repository-relative ARS paths such as \`shared/\`, \`scripts/\`, \`commands/\`, \`deep-research/\`, \`academic-paper/\`, \`academic-paper-reviewer/\`, and \`academic-pipeline/\` from \`${repoRoot}\`. - Treat Claude Code tool names as capability names. Use the equivalent tools available in the current Pi session. - When ARS requests multiple agents, first inspect the available Pi tools and installed skills for a subagent, workflow, or parallel-agent capability; if none is obvious, search the configured Pi skill locations. Use a matching capability when available. Otherwise run the roles sequentially in the current agent and disclose that degraded execution mode. Do not require a specific orchestration add-on. - When ARS requests \`WebSearch\`, \`WebFetch\`, or \`/websearch\`, first inspect the available tools and installed skills for web search or page retrieval; if none is obvious, search the configured Pi skill locations. Prefer an installed web-search skill when available. Otherwise use a permitted HTTP or bibliographic-API capability. If none exists, report the limitation and do not claim that a source was verified. - Ask questions through normal conversation or any available user-question tool. Ignore Claude-only command \`model\` metadata and inherit the current Pi model. `; export default function (pi) { let arsActive = false; const setArsActive = (active) => { if (arsActive === active) return; arsActive = active; pi.appendEntry(stateEntryType, { active }); }; const restoreArsState = (ctx) => { const state = ctx.sessionManager.getBranch() .filter((entry) => entry.type === "custom" && entry.customType === stateEntryType) .pop(); arsActive = state?.data?.active === true; }; pi.on("session_start", (_event, ctx) => { restoreArsState(ctx); }); pi.on("session_tree", (_event, ctx) => { restoreArsState(ctx); }); pi.on("input", (event) => { const match = event.text.match(commandPattern); if (!match) { if (skillPattern.test(event.text)) setArsActive(true); return { action: "continue" }; } const commandPath = resolve(repoRoot, "commands", `${match[1]}.md`); if (!existsSync(commandPath)) return { action: "continue" }; setArsActive(true); const raw = readFileSync(commandPath, "utf8"); const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim(); const args = match[2]?.trim() ?? ""; const hasArgumentPlaceholder = body.includes("$ARGUMENTS") || body.includes("$@"); const executableBody = body.replace( /\b(python3?|bash|sh) scripts\/([^\s`]+)/g, (_match, command, script) => `${command} "${repoRoot}/scripts/${script}"`, ); const expanded = executableBody.replace(/\$ARGUMENTS|\$@/g, () => args); const commandText = args && !hasArgumentPlaceholder ? `${expanded}\n\nUser request / arguments:\n${args}` : expanded; const skillName = body.match(/^Trigger the `([^`]+)` (?:skill|orchestrator)/m)?.[1]; const text = skillName ? `/skill:${skillName} ${commandText}` : commandText; return { action: "transform", text }; }); pi.registerCommand("ars-pi-start", { description: "Enable ARS compatibility and automatic skill selection in this session", handler: async (_args, ctx) => { setArsActive(true); ctx.ui.notify("ARS compatibility instructions enabled for this session", "info"); }, }); pi.registerCommand("ars-pi-stop", { description: "Disable ARS compatibility and automatic skill selection in this session", handler: async (_args, ctx) => { setArsActive(false); ctx.ui.notify("ARS compatibility instructions disabled for this session", "info"); }, }); pi.registerCommand("ars-pi-doctor", { description: "Check optional Pi capabilities and local ARS runtime dependencies", handler: async () => { const activeTools = new Set(pi.getActiveTools()); const tools = pi.getAllTools().map((tool) => ({ label: `${activeTools.has(tool.name) ? "active tool" : "installed tool"}: ${tool.name}`, search: `${tool.name} ${tool.description}`, })); const commands = pi.getCommands() .filter((command) => !command.name.startsWith("ars-") && !command.name.startsWith("skill:academic-")) .map((command) => ({ label: `${command.source}: /${command.name}`, search: `${command.name} ${command.description ?? ""}`, })); const candidates = [...tools, ...commands]; const orchestration = uniqueMatches(candidates, orchestrationPattern); const web = uniqueMatches(candidates, webPattern); const python = await probe(pi, "python3", ["--version"]); const pyyaml = await probe(pi, "python3", ["-c", "import yaml; print(f'PyYAML {yaml.__version__}')"]); const pandoc = await probe(pi, "pandoc", ["--version"]); const tectonic = await probe(pi, "tectonic", ["--version"]); const sandbox = pi.getCommands().some((command) => command.name === "sandbox") ? `detected; allow read access to ${repoRoot} when running outside the checkout` : "not detected"; const format = (matches) => matches.length > 0 ? matches.join(", ") : "none; sequential/degraded mode"; const report = [ "ARS Pi doctor", `Repository: ${repoRoot}`, `Orchestration: ${format(orchestration)}`, `Web retrieval: ${format(web)}`, `Python: ${python}`, `PyYAML: ${pyyaml}`, `Pandoc: ${pandoc}`, `Tectonic: ${tectonic}`, `Sandbox: ${sandbox}`, "Claude hooks: unavailable in Pi; write-scope enforcement remains prompt-level", ].join("\n"); pi.sendMessage({ customType: "ars-pi-doctor", content: report, display: true }); }, }); pi.on("before_agent_start", (event) => { const options = event.systemPromptOptions; if (options?.sections && options.forceSystemPrompt === undefined) { // Compose with Pi's structured prompt instead of freezing a rendered snapshot. if (arsActive) { options.sections.ars_compatibility = compatibility; } else { options.skills = options.skills.filter((skill) => { const location = canonicalPath(skill.filePath); return !location || !skillLocations.has(location); }); delete options.sections.ars_compatibility; } return; } // Older runners and earlier complete-prompt overrides require the rendered fallback (#880). if (!arsActive) return { systemPrompt: hideArsSkills(event.systemPrompt) }; const sp = event.systemPrompt; return { systemPrompt: Array.isArray(sp) ? [...sp, compatibility] : `${sp}\n${compatibility}` }; }); }