/**
* Skill block parser & builder.
*
* Pi's `_expandSkillCommand` (in `@earendil-works/pi-coding-agent`) wraps skill
* expansions in a `…\n\nargs` envelope.
* The dashboard's bridge expander (`packages/extension/src/prompt-expander.ts`)
* aligns to the same byte format. This module is the single source of truth for
* both producing and recovering that envelope.
*
* See change: render-skill-invocations-collapsibly.
*/
export interface SkillBlock {
/** Bare skill name (no `skill:` prefix), e.g. `"openspec-explore"`. */
name: string;
/** Absolute path to `SKILL.md`. */
location: string;
/**
* Skill body with the `References are relative to .\n\n` preamble
* stripped — what users see in the card. The preamble is bridge-internal
* orientation for the LLM and is not interesting to users.
*
* Pi's own `parseSkillBlock` returns the unstripped form (calls it `content`).
* We strip here so the renderer doesn't have to. If the preamble shape ever
* changes upstream and stripping fails, `body` falls back to the captured
* content verbatim.
*/
body: string;
/** User text after the skill name. `undefined` when no args were typed. */
args: string | undefined;
/** Slash-command form: `"/skill:" + name + (args ? " " + args : "")`. */
condensed: string;
}
/**
* Anchored, non-greedy match of a wrapped skill block.
*
* Why anchored: prevents a literal `` substring elsewhere in user text
* from matching. Why non-greedy + optional trailing args at end-of-string:
* forces the regex engine to extend the body to the last valid
* `\n(\n\nargs)?$` boundary, so SKILL.md bodies that document the
* `` tag (e.g. include the literal text in code samples) do not
* terminate the match prematurely.
*/
const SKILL_BLOCK_RE =
/^\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/;
/**
* Parse a skill block from message text. Returns `null` when the input is not
* a well-formed skill envelope.
*/
/**
* Strip the `References are relative to .\n\n` preamble from a captured
* `` content block. Returns the stripped body, or the input unchanged if
* the preamble shape doesn't match.
*/
function stripReferencesPreamble(content: string): string {
const m = content.match(/^References are relative to [^\n]+\.\n\n([\s\S]*)$/);
return m ? m[1] : content;
}
export function parseSkillBlock(text: string): SkillBlock | null {
const m = text.match(SKILL_BLOCK_RE);
if (!m) return null;
const name = m[1];
const location = m[2];
const body = stripReferencesPreamble(m[3]);
const args = m[4];
const condensed = `/skill:${name}${args ? " " + args : ""}`;
return { name, location, body, args, condensed };
}
export interface BuildSkillBlockArgs {
/** Bare skill name (no `skill:` prefix). */
name: string;
/** Absolute path to `SKILL.md`. */
filePath: string;
/** Skill base directory — `dirname(filePath)`. */
baseDir: string;
/** Skill body, frontmatter already stripped. Should be `.trim()`-ed. */
body: string;
/** Optional user-typed args appended after the closing tag. */
userArgs?: string;
}
/**
* Build a skill block in the exact byte format pi's `_expandSkillCommand`
* produces. The output is byte-identical to pi's output for the same inputs;
* `parseSkillBlock(buildSkillBlock(x))` round-trips for `name`, `body`, `args`.
*/
export function buildSkillBlock(input: BuildSkillBlockArgs): string {
const wrapper =
`\n` +
`References are relative to ${input.baseDir}.\n\n` +
`${input.body}\n` +
``;
return input.userArgs ? `${wrapper}\n\n${input.userArgs}` : wrapper;
}
/**
* Condense a user-message content string for `firstMessage` / display purposes.
*
* If `text` parses as a `` envelope, returns the slash-command form
* (`/skill:name args`) truncated to `maxLen` chars. Otherwise returns
* `text.slice(0, maxLen)`. Used by session-scanner / session-discovery so the
* 200-char `firstMessage` shows the recognisable slash form instead of the
* front of an opaque wrapper.
*
* See change: render-skill-invocations-collapsibly.
*/
export function condenseForFirstMessage(text: string, maxLen: number): string {
const block = parseSkillBlock(text);
if (block) return block.condensed.slice(0, maxLen);
return text.slice(0, maxLen);
}