Files
ECC/scripts/lib/agent-emit-pi.js
haelyraandalizahidraja eb7ffd8d64 feat(agents): integrate isolated Pi conversion foundation from PR 3022
Carry the canonical IR, Pi emitter and source-schema doctor from https://github.com/affaan-m/ECC/pull/3022. Cursor and OpenCode emitters remain with the open source PR pending capability fixes.

Co-authored-by: alizahidraja <alizahidrajaa@gmail.com>
2026-10-10 17:43:18 -04:00

135 lines
4.5 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* ECC Agent IR — Pi emitter.
*
* Turns one IR object into a native Pi subagent definition (markdown file with
* Pi frontmatter). The frontmatter contract is verified against
* `@tintinweb/pi-subagents@0.19.0`: fields are `name` (the dispatch id),
* `description`, `tools` (a comma-separated strict child allowlist), and
* `prompt_mode` (`replace` keeps the body as the complete system prompt). The
* prompt lives in the document body.
*
* v1 behavior:
* - The source model tier is preserved as a YAML comment
* (`# source model tier: opus`) rather than a `model:` field, because
* Claude tiers are not valid Pi model ids. Pi's default child model
* applies; the tier is lossless in the IR and visible in the emitted file.
* - Unmapped tools (WebSearch, WebFetch, mcp__*) are never silently dropped;
* they surface in per-agent `warnings`.
* - Specialist isolation disables inherited extensions and skills, so the
* builtin allowlist cannot silently inherit configured extension tools.
* - The allowlist is always written. An empty allowlist is emitted as
* `tools: none`, never blank, because the companion's `csvList` treats a
* blank/missing `tools:` as *all* built-ins — which would escalate a
* fully-unmapped agent to every tool.
*/
const { mapToolToPi } = require('./agent-tool-map');
// Pi core built-in agent names (case-sensitive). ECC agent names are kebab-case
// and do not collide today; warn if a future one ever does.
const RESERVED_AGENT_NAMES = new Set(['general-purpose', 'Explore', 'Plan']);
/**
* Emit a Pi agent definition for one IR object.
*
* @param {object} ir
* @returns {{ markdown: string, warnings: string[], tools: string[] }}
*/
function emitPiAgent(ir) {
const warnings = [];
const seen = new Set();
const tools = [];
for (const sourceTool of ir.tools) {
const mapped = mapToolToPi(sourceTool);
if (mapped.tool && !seen.has(mapped.tool)) {
seen.add(mapped.tool);
tools.push(mapped.tool);
}
if (mapped.note) {
warnings.push(`${ir.id}: ${mapped.note}`);
}
}
if (RESERVED_AGENT_NAMES.has(ir.name)) {
warnings.push(`${ir.id}: name '${ir.name}' collides with a Pi built-in agent; rename to avoid shadowing`);
}
// Never emit a blank allowlist: `none` is the explicit "no tools" sentinel.
const toolsValue = tools.length > 0 ? tools.join(', ') : 'none';
const frontmatter = [
'---',
...(ir.model ? [`# source model tier: ${ir.model}`] : []),
`name: ${yamlScalar(ir.name)}`,
`description: ${yamlScalar(ir.description)}`,
`tools: ${toolsValue}`,
'prompt_mode: replace',
'isolated: true',
'extensions: false',
'skills: false',
'---',
];
const body = (ir.body || '').replace(/^\n+/, '').trimEnd();
const markdown = frontmatter.join('\n') + '\n\n' + body + '\n';
return { markdown, warnings, tools };
}
/** Emit a single-line YAML scalar, quoted only when needed. */
function yamlScalar(value) {
const s = String(value);
// Quote when it contains a leading/trailing space, a colon followed by a
// space, a comment-starting hash, a leading special char, or a newline.
if (/^\s|\s$|: |\n|\s#|^[-?*&|>#@`"'\][{}!,]/.test(s)) {
return JSON.stringify(s);
}
return s;
}
/**
* Emit the full set of Pi agents, sorted by id for determinism, plus notes on
* the deliberate (documented) lossy conversions.
*
* @param {object[]} irs
* @returns {{ results: object[], warnings: string[], notes: string[] }}
*/
function emitAllPiAgents(irs) {
const results = [];
const warnings = [];
let modelTiers = {};
let unsupported = 0;
for (const ir of [...irs].sort((a, b) => a.id.localeCompare(b.id))) {
const { markdown, warnings: w, tools } = emitPiAgent(ir);
results.push({ id: ir.id, name: ir.name, tools, markdown });
warnings.push(...w);
if (ir.model) {
modelTiers = { ...modelTiers, [ir.model]: (modelTiers[ir.model] || 0) + 1 };
}
unsupported += ir.tools.filter(t => mapToolToPi(t).unsupported).length;
}
const notes = [];
if (Object.keys(modelTiers).length) {
const tiers = Object.entries(modelTiers).map(([t, n]) => `${t} x${n}`).join(', ');
notes.push(`model tiers preserved as comments (${tiers}) — Pi uses its default child model`);
}
if (unsupported) {
notes.push(`unmapped tools: ${unsupported} (WebSearch/WebFetch/mcp__* are not Pi built-ins) — see warnings`);
}
return { results, warnings, notes };
}
module.exports = {
RESERVED_AGENT_NAMES,
emitPiAgent,
emitAllPiAgents,
yamlScalar,
};