merge: pi-durable(智能体运行时迁到 pi-durable 1.1 + pi-ai 1.1)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei # Conflicts: # CHANGELOG.md
This commit is contained in:
commit
67f6fb48ac
26 files changed
+877
-366
No files matched your search
@@ -1,36 +1,57 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
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 必须等于目录名)。
|
||||
// Skill(AG-5):packages/agent/skills/<name>/SKILL.md(agentskills.io 格式:frontmatter name / description + 正文;
|
||||
// name 必须等于目录名)。
|
||||
//
|
||||
// Pi 的模型侧用法是把 <available_skills> 索引放进系统提示,由模型用文件读取工具自己读 SKILL.md;
|
||||
// 我们的智能体没有(也不该有)文件读取工具,所以改成服务端按意图选:frontmatter 的 triggers 命中目标或追加消息时,
|
||||
// 首轮把正文注入系统提示的「Skill」层,后续消息用 Pi 的 formatSkillInvocation 附在用户消息前(见 runner)。
|
||||
// 首轮把正文注入系统提示的「Skill」层,后续消息用 Pi 的 skill 块格式附在用户消息前(见 runner)。
|
||||
// 本项目的 frontmatter 扩展:title(中文名)、triggers(正则)、capabilities(用到的能力)、sessions(适用会话类型)。
|
||||
//
|
||||
// 发现 / 解析 / 校验与 skill 块格式取自 pi-agent-core 0.99.2 的 harness/skills.ts(MIT,© Earendil Works);
|
||||
// pi 1.x 把 harness 移到 pi-durable 后不再提供 skills,这里只保留本项目用到的部分:
|
||||
// 一层目录下的 <name>/SKILL.md,不读 .gitignore、不收根目录的散 .md、不跟随符号链接。
|
||||
|
||||
/** 仓库内的 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"]);
|
||||
const MAX_NAME_LENGTH = 64;
|
||||
const MAX_DESCRIPTION_LENGTH = 1024;
|
||||
|
||||
export interface SkillLoadResult {
|
||||
skills: SkillDoc[];
|
||||
/** 格式问题(Pi 的诊断 + 扩展字段校验);测试要求为空 */
|
||||
/** 格式问题(Pi 同款校验 + 扩展字段校验);测试要求为空 */
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
function frontmatter(raw: string): Record<string, unknown> {
|
||||
/** frontmatter + 正文(与 Pi 的 parseFrontmatter 一致:没有 frontmatter 时整篇是正文) */
|
||||
function parseSkillFile(raw: string): { frontmatter: Record<string, unknown>; body: string } {
|
||||
const text = raw.replace(/\r\n?/g, "\n");
|
||||
if (!text.startsWith("---")) return {};
|
||||
if (!text.startsWith("---")) return { frontmatter: {}, body: text };
|
||||
const end = text.indexOf("\n---", 3);
|
||||
if (end < 0) return {};
|
||||
return (parseYaml(text.slice(4, end)) ?? {}) as Record<string, unknown>;
|
||||
if (end < 0) return { frontmatter: {}, body: text };
|
||||
return {
|
||||
frontmatter: (parseYaml(text.slice(4, end)) ?? {}) as Record<string, unknown>,
|
||||
body: text.slice(end + 4).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Pi 的 name 规则:等于目录名、≤64、只含小写字母数字与连字符、不以连字符开头结尾、不连续 */
|
||||
function validateName(name: string, dirName: string): string[] {
|
||||
const errors: string[] = [];
|
||||
if (name !== dirName) errors.push(`name "${name}" 与目录名 "${dirName}" 不一致`);
|
||||
if (name.length > MAX_NAME_LENGTH) errors.push(`name 超过 ${MAX_NAME_LENGTH} 个字符`);
|
||||
if (!/^[a-z0-9-]+$/.test(name)) errors.push("name 只能用小写字母、数字和连字符");
|
||||
if (name.startsWith("-") || name.endsWith("-")) errors.push("name 不能以连字符开头或结尾");
|
||||
if (name.includes("--")) errors.push("name 不能有连续连字符");
|
||||
return errors;
|
||||
}
|
||||
|
||||
const strings = (v: unknown): string[] =>
|
||||
@@ -38,25 +59,46 @@ const strings = (v: unknown): string[] =>
|
||||
|
||||
/** 读目录下全部 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[] = [];
|
||||
let entries: { name: string; isDirectory(): boolean }[];
|
||||
try {
|
||||
const r = await loadSkills(env, dir, BACKGROUND_CONTEXT);
|
||||
skills = r.skills;
|
||||
for (const d of r.diagnostics) diagnostics.push(`${d.path}: ${d.message}`);
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} 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) : {};
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
||||
const filePath = join(dir, entry.name, "SKILL.md");
|
||||
const where = `${entry.name}/SKILL.md`;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(filePath, "utf8");
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") diagnostics.push(`${where}: ${(err as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
let fm: Record<string, unknown>;
|
||||
let body: string;
|
||||
try {
|
||||
({ frontmatter: fm, body } = parseSkillFile(raw));
|
||||
} catch (err) {
|
||||
diagnostics.push(`${where}: frontmatter 解析失败:${(err as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
const description = typeof fm.description === "string" ? fm.description : "";
|
||||
if (!description.trim()) {
|
||||
diagnostics.push(`${where}: 缺少 description`);
|
||||
continue;
|
||||
}
|
||||
if (description.length > MAX_DESCRIPTION_LENGTH)
|
||||
diagnostics.push(`${where}: description 超过 ${MAX_DESCRIPTION_LENGTH} 个字符`);
|
||||
const name = typeof fm.name === "string" && fm.name ? fm.name : entry.name;
|
||||
for (const e of validateName(name, entry.name)) diagnostics.push(`${where}: ${e}`);
|
||||
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`;
|
||||
const title = typeof fm.title === "string" ? fm.title : name;
|
||||
if (triggers.length === 0) diagnostics.push(`${where}: 缺少 triggers`);
|
||||
for (const t of triggers) {
|
||||
try {
|
||||
@@ -68,14 +110,14 @@ export async function loadSkillDocs(dir: string = SKILLS_DIR): Promise<SkillLoad
|
||||
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,
|
||||
name,
|
||||
title,
|
||||
description: s.description,
|
||||
description,
|
||||
triggers,
|
||||
capabilities,
|
||||
sessions: sessions.length ? sessions : DEFAULT_SESSIONS,
|
||||
content: s.content,
|
||||
filePath: s.filePath,
|
||||
content: body,
|
||||
filePath,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -153,16 +195,17 @@ export function skillsLayer(loaded: readonly SkillDoc[], available: readonly Ski
|
||||
return { layer: "skills", title: "Skill", lines };
|
||||
}
|
||||
|
||||
/** Pi 的 skill 调用块格式(pi-agent-core 0.99.2 formatSkillInvocation) */
|
||||
function formatSkillInvocation(skill: { name: string; filePath: string; content: string }): string {
|
||||
const dir = skill.filePath.replace(/[\\/]+$/, "").replace(/[\\/][^\\/]*$/, "") || "/";
|
||||
return `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${dir}.\n\n${skill.content}\n</skill>`;
|
||||
}
|
||||
|
||||
/** 追加消息命中新 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`,
|
||||
}),
|
||||
formatSkillInvocation({ name: s.name, content: s.content, filePath: `skills/${s.name}/SKILL.md` }),
|
||||
);
|
||||
return `${blocks.join("\n\n")}\n\n${text}`;
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export interface LayeredPromptInput {
|
||||
constraints?: readonly string[];
|
||||
}
|
||||
|
||||
/** 一个 Skill(Pi harness Skill + 本项目的 frontmatter 扩展) */
|
||||
/** 一个 Skill(Pi 的 agentskills.io 格式 + 本项目的 frontmatter 扩展) */
|
||||
export interface SkillDoc {
|
||||
/** 目录名,小写字母 / 数字 / 连字符 */
|
||||
name: string;
|
||||
|
||||
Reference in new issue
Block a user