import { isAbsolute, normalize, resolve } from "node:path"; import type { Skill } from "@earendil-works/pi-coding-agent"; /** Raw `/skill:name` at start of user input (SPEC §6.2 #1). */ const SLASH_SKILL_RE = /^\/skill:([a-z0-9-]+)/; /** mirror agent-session `parseSkillBlock` — global scan (SPEC §6.2 #2). */ const SKILL_BLOCK_RE = /\n([\s\S]*?)\n<\/skill>/g; export interface ParsedSkillBlock { name: string; location: string; content: string; } /** Minimal skill fields for read-path matching (SPEC §6.2 #3). */ export type SkillPathMeta = Pick; function normalizePathForCompare(filePath: string): string { return normalize(filePath); } /** Text from user message content (string or text blocks). */ export function userMessageText(content: unknown): string { if (typeof content === "string") { return content; } if (!Array.isArray(content)) { return ""; } const parts: string[] = []; for (const part of content) { if (!part || typeof part !== "object") { continue; } const block = part as { type?: string; text?: string }; if (block.type === "text" && typeof block.text === "string") { parts.push(block.text); } } return parts.join("\n"); } /** Returns skill name when text is a slash skill command, else null. */ export function detectSlashSkill(text: string): string | null { const match = SLASH_SKILL_RE.exec(text); return match?.[1] ?? null; } /** All skill blocks in message text (empty when none). */ export function parseSkillBlocksFromText(text: string): ParsedSkillBlock[] { const blocks: ParsedSkillBlock[] = []; for (const match of text.matchAll(SKILL_BLOCK_RE)) { blocks.push({ name: match[1], location: match[2], content: match[3], }); } return blocks; } /** Match read tool path to a registered skill SKILL.md (SPEC §6.2 #3). */ export function matchReadPathToSkill( path: string, skills: readonly SkillPathMeta[], ): SkillPathMeta | null { const normalizedPath = normalizePathForCompare(path); for (const skill of skills) { const skillPath = normalizePathForCompare(skill.filePath); if (normalizedPath === skillPath) { return skill; } if (!isAbsolute(path)) { const resolvedFromBase = normalizePathForCompare(resolve(skill.baseDir, path)); if (resolvedFromBase === skillPath) { return skill; } } } return null; } /** Read-path detection only when trackReadPaths is enabled (SPEC §6.2, §3). */ export function matchReadPathToSkillWhenEnabled( path: string, skills: readonly SkillPathMeta[], trackReadPaths: boolean, ): SkillPathMeta | null { if (!trackReadPaths) { return null; } return matchReadPathToSkill(path, skills); }