import { fileURLToPath } from "node:url"; import { BACKGROUND_CONTEXT, formatSkillInvocation, loadSkills, type Skill } from "@earendil-works/pi-agent-core"; import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/harness/env/nodejs"; import { CAPABILITY_CATALOG } from "@umt/contracts"; import { parse as parseYaml } from "yaml"; import type { PromptSection, SessionKind, SkillDoc } from "./types.ts"; // Skill(AG-5):packages/agent/skills//SKILL.md。发现、解析与校验用 Pi harness 的 loadSkills // (agentskills.io 格式:frontmatter name / description + 正文;name 必须等于目录名)。 // // Pi 的模型侧用法是把 索引放进系统提示,由模型用文件读取工具自己读 SKILL.md; // 我们的智能体没有(也不该有)文件读取工具,所以改成服务端按意图选:frontmatter 的 triggers 命中目标或追加消息时, // 首轮把正文注入系统提示的「Skill」层,后续消息用 Pi 的 formatSkillInvocation 附在用户消息前(见 runner)。 // 本项目的 frontmatter 扩展:title(中文名)、triggers(正则)、capabilities(用到的能力)、sessions(适用会话类型)。 /** 仓库内的 Skill 目录;deno compile 用 --include 嵌入(apps/server/deno.json 的 compile 任务) */ export const SKILLS_DIR = fileURLToPath(new URL("../../skills", import.meta.url)); const DEFAULT_SESSIONS: SessionKind[] = ["chat", "patrol", "investigate", "child"]; const SESSION_KINDS = new Set(["chat", "patrol", "investigate", "child", "vision"]); export interface SkillLoadResult { skills: SkillDoc[]; /** 格式问题(Pi 的诊断 + 扩展字段校验);测试要求为空 */ diagnostics: string[]; } function frontmatter(raw: string): Record { const text = raw.replace(/\r\n?/g, "\n"); if (!text.startsWith("---")) return {}; const end = text.indexOf("\n---", 3); if (end < 0) return {}; return (parseYaml(text.slice(4, end)) ?? {}) as Record; } const strings = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && x.trim() !== "") : []; /** 读目录下全部 Skill;失败的项进 diagnostics,不抛错(运行时缺 Skill 只少一层提示,不影响会话) */ export async function loadSkillDocs(dir: string = SKILLS_DIR): Promise { const env = new NodeExecutionEnv({ cwd: dir }); const diagnostics: string[] = []; let skills: Skill[] = []; try { const r = await loadSkills(env, dir, BACKGROUND_CONTEXT); skills = r.skills; for (const d of r.diagnostics) diagnostics.push(`${d.path}: ${d.message}`); } catch (err) { return { skills: [], diagnostics: [`${dir}: ${(err as Error).message}`] }; } const out: SkillDoc[] = []; for (const s of skills) { const raw = await env.readTextFile(s.filePath, BACKGROUND_CONTEXT); const fm = raw.ok ? frontmatter(raw.value) : {}; const triggers = strings(fm.triggers); const capabilities = strings(fm.capabilities); const sessions = strings(fm.sessions) as SessionKind[]; const title = typeof fm.title === "string" ? fm.title : s.name; const where = `${s.name}/SKILL.md`; if (triggers.length === 0) diagnostics.push(`${where}: 缺少 triggers`); for (const t of triggers) { try { new RegExp(t, "i"); } catch { diagnostics.push(`${where}: trigger 不是合法正则:${t}`); } } for (const c of capabilities) if (!(c in CAPABILITY_CATALOG)) diagnostics.push(`${where}: 未知能力 ${c}`); for (const k of sessions) if (!SESSION_KINDS.has(k)) diagnostics.push(`${where}: 未知会话类型 ${k}`); out.push({ name: s.name, title, description: s.description, triggers, capabilities, sessions: sessions.length ? sessions : DEFAULT_SESSIONS, content: s.content, filePath: s.filePath, }); } out.sort((a, b) => a.name.localeCompare(b.name)); return { skills: out, diagnostics }; } let cached: Promise | null = null; /** 进程内缓存的内置 Skill(首次调用时读盘) */ export function builtinSkills(): Promise { cached ??= loadSkillDocs(); return cached; } /** 意图命中数:每个触发词命中记 1 */ export function skillScore(skill: SkillDoc, text: string): number { let n = 0; for (const t of skill.triggers) { try { if (new RegExp(t, "i").test(text)) n += 1; } catch { // 非法正则在加载时已进 diagnostics } } return n; } export interface SelectSkillsOptions { kind: SessionKind; /** 本次可用的能力;给了时 Skill 至少要有一个能力可用才选 */ capabilities?: readonly string[]; /** 最多选几个(缺省 2) */ max?: number; /** 不管命中与否都选上的 Skill(巡检会话固定带 patrol) */ force?: readonly string[]; } /** 按意图选 Skill:命中多的在前,同分按名称;适用会话类型与可用能力不符的不选 */ export function selectSkills(skills: readonly SkillDoc[], text: string, opts: SelectSkillsOptions): SkillDoc[] { const avail = opts.capabilities ? new Set(opts.capabilities) : null; const usable = skills.filter( (s) => s.sessions.includes(opts.kind) && (!avail || s.capabilities.some((c) => avail.has(c))), ); const forced = usable.filter((s) => opts.force?.includes(s.name)); const scored = usable .filter((s) => !forced.includes(s)) .map((s) => ({ s, n: skillScore(s, text) })) .filter((x) => x.n > 0) .sort((a, b) => b.n - a.n || a.s.name.localeCompare(b.s.name)) .map((x) => x.s); return [...forced, ...scored].slice(0, Math.max(opts.max ?? 2, forced.length)); } /** 正文标题降两级(SKILL.md 里的 ## 在系统提示里变成 ####,不和层标题混) */ function demote(content: string): string { return content.replace(/^(#{1,4}) /gm, (_m, h: string) => `${h}## `); } export function renderSkill(s: SkillDoc): string { return [`### Skill「${s.title}」`, "", demote(s.content.trim())].join("\n"); } export function skillsLayer(loaded: readonly SkillDoc[], available: readonly SkillDoc[] = []): PromptSection { const rest = available.filter((a) => !loaded.some((l) => l.name === a.name)); if (loaded.length === 0 && rest.length === 0) return { layer: "skills", title: "Skill", lines: [] }; const lines: string[] = []; if (loaded.length > 0) { lines.push("按本次目标载入了下面的 Skill:照它的步骤做、按它的汇报模板写;和「角色与边界」冲突时以边界为准。"); for (const s of loaded) lines.push("", renderSkill(s)); } if (rest.length > 0) { lines.push("", "其余 Skill(用户后续消息换了意图时,系统会把对应 Skill 附在消息前面):"); for (const s of rest) lines.push(`- ${s.title}:${s.description}`); } return { layer: "skills", title: "Skill", lines }; } /** 追加消息命中新 Skill 时,用 Pi 的格式把 Skill 附在消息前(模型当作本条消息的做事说明) */ export function withSkillInvocation(skills: readonly SkillDoc[], text: string): string { if (skills.length === 0) return text; const blocks = skills.map((s) => formatSkillInvocation({ name: s.name, description: s.description, content: s.content, filePath: `skills/${s.name}/SKILL.md`, }), ); return `${blocks.join("\n\n")}\n\n${text}`; }