169 lines
7.2 KiB
TypeScript
169 lines
7.2 KiB
TypeScript
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/<name>/SKILL.md。发现、解析与校验用 Pi harness 的 loadSkills
|
||
// (agentskills.io 格式:frontmatter name / description + 正文;name 必须等于目录名)。
|
||
//
|
||
// Pi 的模型侧用法是把 <available_skills> 索引放进系统提示,由模型用文件读取工具自己读 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<SessionKind>(["chat", "patrol", "investigate", "child", "vision"]);
|
||
|
||
export interface SkillLoadResult {
|
||
skills: SkillDoc[];
|
||
/** 格式问题(Pi 的诊断 + 扩展字段校验);测试要求为空 */
|
||
diagnostics: string[];
|
||
}
|
||
|
||
function frontmatter(raw: string): Record<string, unknown> {
|
||
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<string, unknown>;
|
||
}
|
||
|
||
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<SkillLoadResult> {
|
||
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<SkillLoadResult> | null = null;
|
||
|
||
/** 进程内缓存的内置 Skill(首次调用时读盘) */
|
||
export function builtinSkills(): Promise<SkillLoadResult> {
|
||
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}`;
|
||
}
|