From cf7a177d3dfb94847eeb50b08d213090db98cfb8 Mon Sep 17 00:00:00 2001 From: sowen1023 Date: Mon, 31 Aug 2026 11:36:23 +0800 Subject: [PATCH 1/6] feat(skills): expand inline skill references --- README.md | 2 + extensions/inline-skill-references/index.ts | 99 +++++++ .../inline-skill-references/index.test.ts | 249 ++++++++++++++++++ 3 files changed, 350 insertions(+) create mode 100644 extensions/inline-skill-references/index.ts create mode 100644 tests/extensions/inline-skill-references/index.test.ts diff --git a/README.md b/README.md index 12736b99..77fd2ec7 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,8 @@ OpenPI 会把长期进程放到后台,把独立任务交给隔离 Context 的 > `/plan` 是一个运行时安全例外:进入或恢复 Plan Mode 时会为当前 Session 自动加载 `search` 组,让只读调研直接使用结构化 Git 工具。 > 在交互输入框中,保留词 `Subagent` / `Workflow`,以及已被识别的中文能力请求,会使用 Claude Code 风格的薰衣草紫显示;浅色终端自动使用更深的紫色以维持可读性。变色表示提交后会加载对应能力。因为英文名称本身就是授权词,讨论中写出它们也会开闸;条件句和否定句仍保持普通显示,Suggestion 幽灵文字也要在用户接受进输入框后才参与识别。 +当前 Session 中由 Pi 加载的 Skill 可以显式嵌入普通输入,例如 `用 $qfei-code-review 审查这个 PR`。OpenPI 保留这条原始用户消息,并把对应 `SKILL.md` 正文作为隐藏的模型上下文加载;同一条输入中的重复引用只加载一次,未知 Skill、`\$escaped`、以及 `abc$embedded` 形式保持原样。解析只使用 Pi 为当前模型轮提供的 Skill 集合,不扫描目录或维护第二套注册表。 + > [!IMPORTANT] > 默认安装是安静的:不改主题、不绑定 Provider 或模型、不开启下一步预测,也不执行 post-edit 命令。Capability discovery 默认 `explicit`;只有用户通过 `/openpi-setup` 选择 `adaptive` 后,模型才会常驻看到一个小型发现网关并可自主加载额外能力。 diff --git a/extensions/inline-skill-references/index.ts b/extensions/inline-skill-references/index.ts new file mode 100644 index 00000000..8540f8fd --- /dev/null +++ b/extensions/inline-skill-references/index.ts @@ -0,0 +1,99 @@ +import { readFile } from "node:fs/promises"; +import { + type ExtensionAPI, + type Skill, + stripFrontmatter, +} from "@earendil-works/pi-coding-agent"; + +const INLINE_SKILL_REFERENCE_CHARACTER = /[\p{L}\p{M}\p{N}_-]/u; + +function startsAtReferenceBoundary(prompt: string, index: number) { + if (index === 0) return true; + const previous = prompt[index - 1]; + return previous === " " || previous === "\t"; +} + +function readReferenceName(prompt: string, dollarIndex: number) { + let end = dollarIndex + 1; + while ( + end < prompt.length && + INLINE_SKILL_REFERENCE_CHARACTER.test(prompt[end] ?? "") + ) { + end += 1; + } + return { name: prompt.slice(dollarIndex + 1, end), end }; +} + +export function referencedSkills(prompt: string, skills: readonly Skill[]) { + const skillsByName = new Map(); + for (const skill of skills) { + if (!skillsByName.has(skill.name)) skillsByName.set(skill.name, skill); + } + + const selected: Skill[] = []; + const selectedNames = new Set(); + for (let index = 0; index < prompt.length; index += 1) { + if (prompt[index] !== "$" || !startsAtReferenceBoundary(prompt, index)) { + continue; + } + + const reference = readReferenceName(prompt, index); + index = reference.end - 1; + const skill = skillsByName.get(reference.name); + if (!skill || selectedNames.has(skill.name)) continue; + selectedNames.add(skill.name); + selected.push(skill); + } + return selected; +} + +function skillEnvelope(skill: Skill, content: string) { + const body = stripFrontmatter(content).trim(); + return `\nReferences are relative to ${skill.baseDir}.\n\n${body}\n`; +} + +async function loadSkillEnvelope(skill: Skill) { + try { + return skillEnvelope(skill, await readFile(skill.filePath, "utf8")); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to load inline Skill "${skill.name}" from ${skill.filePath}: ${reason}`, + ); + } +} + +export default function inlineSkillReferences(pi: ExtensionAPI) { + let pendingPrompt: string | undefined; + + pi.on("input", (event) => { + if (!event.streamingBehavior) pendingPrompt = event.text; + return { action: "continue" }; + }); + + pi.on("before_agent_start", async (event) => { + const prompt = pendingPrompt; + pendingPrompt = undefined; + if (prompt === undefined) return; + const skills = referencedSkills( + prompt, + event.systemPromptOptions.skills ?? [], + ); + if (skills.length === 0) return; + + const envelopes: string[] = []; + for (const skill of skills) { + envelopes.push(await loadSkillEnvelope(skill)); + } + return { + message: { + customType: "openpi-inline-skill-references", + content: envelopes.join("\n\n"), + display: false, + details: { + skills: skills.map(({ name, filePath }) => ({ name, filePath })), + }, + }, + }; + }); +} diff --git a/tests/extensions/inline-skill-references/index.test.ts b/tests/extensions/inline-skill-references/index.test.ts new file mode 100644 index 00000000..dfa591a8 --- /dev/null +++ b/tests/extensions/inline-skill-references/index.test.ts @@ -0,0 +1,249 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + createAgentSession, + DefaultResourceLoader, + type ExtensionError, + ModelRuntime, + SessionManager, + SettingsManager, + type Skill, +} from "@earendil-works/pi-coding-agent"; +import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai"; +import inlineSkillReferences, { + referencedSkills, +} from "../../../extensions/inline-skill-references/index.ts"; + +function skill(name: string): Skill { + const baseDir = `/skills/${name}`; + const filePath = `${baseDir}/SKILL.md`; + return { + name, + description: `${name} description`, + filePath, + baseDir, + sourceInfo: { + path: filePath, + source: baseDir, + scope: "temporary", + origin: "top-level", + baseDir, + }, + disableModelInvocation: false, + }; +} + +test("selects known references once in first-reference order", () => { + const skills = [skill("first-skill"), skill("second-skill")]; + + assert.deepEqual( + referencedSkills( + "$second-skill, then $first-skill. Repeat\t$second-skill)", + skills, + ).map(({ name }) => name), + ["second-skill", "first-skill"], + ); +}); + +test("accepts only start-of-input or horizontal-whitespace boundaries", () => { + const available = [skill("review")]; + + for (const prompt of [ + "abc$review", + "\\$review", + "line one\n$review", + "$unknown", + "$reviewer", + "$review_thing", + "$review技能", + ]) { + assert.deepEqual(referencedSkills(prompt, available), [], prompt); + } + + assert.deepEqual( + referencedSkills("Use $review, please", available).map(({ name }) => name), + ["review"], + ); +}); + +test("resolves each turn from Pi's supplied Skill set without caching", () => { + const prompt = "Use $current-skill"; + + assert.deepEqual( + referencedSkills(prompt, [skill("current-skill")]).map(({ name }) => name), + ["current-skill"], + ); + assert.deepEqual(referencedSkills(prompt, [skill("replacement-skill")]), []); +}); + +interface SessionResult { + readonly messages: Awaited< + ReturnType + >["session"]["messages"]; + readonly providerMessages: unknown; + readonly errors: ExtensionError[]; +} + +async function runSession( + options: { prompt?: string; removeSkillBeforePrompt?: boolean } = {}, +) { + const root = await mkdtemp(path.join(tmpdir(), "openpi-inline-skills-")); + const cwd = path.join(root, "workspace"); + const agentDir = path.join(root, "agent"); + const skillDir = path.join(agentDir, "skills", "review"); + const skillPath = path.join(skillDir, "SKILL.md"); + const otherSkillDir = path.join(agentDir, "skills", "other"); + await mkdir(cwd, { recursive: true }); + await mkdir(skillDir, { recursive: true }); + await mkdir(otherSkillDir, { recursive: true }); + await writeFile( + skillPath, + [ + "---", + "name: review", + "description: Review the requested change", + "disable-model-invocation: true", + "---", + "Follow the review body, not the frontmatter. Do not expand $other from this body.", + "", + ].join("\n"), + ); + await writeFile( + path.join(otherSkillDir, "SKILL.md"), + [ + "---", + "name: other", + "description: Another Skill", + "---", + "This body must load only from an explicit user reference.", + "", + ].join("\n"), + ); + + const snapshots: unknown[] = []; + const errors: ExtensionError[] = []; + const provider = fauxProvider({ + api: "openpi-inline-skills-test", + provider: `openpi-inline-skills-${path.basename(root)}`, + models: [{ id: "fixture", name: "Fixture", reasoning: false }], + }); + provider.setResponses([ + (context) => { + snapshots.push(structuredClone(context.messages)); + return fauxAssistantMessage("Done."); + }, + ]); + + const settingsManager = SettingsManager.inMemory(undefined, { + projectTrusted: false, + }); + const modelRuntime = await ModelRuntime.create({ + authPath: path.join(agentDir, "auth.json"), + modelsPath: path.join(agentDir, "models.json"), + }); + modelRuntime.registerNativeProvider(provider.provider); + await modelRuntime.setRuntimeApiKey(provider.provider.id, "fixture-key"); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + settingsManager, + extensionFactories: [inlineSkillReferences], + }); + await loader.reload(); + const { session } = await createAgentSession({ + cwd, + agentDir, + model: provider.getModel(), + modelRuntime, + settingsManager, + resourceLoader: loader, + sessionManager: SessionManager.inMemory(cwd), + }); + + try { + await session.bindExtensions({ + mode: "print", + onError: (error) => errors.push(error), + }); + if (options.removeSkillBeforePrompt) await unlink(skillPath); + await session.prompt( + options.prompt ?? "Please use $review, then report the result.", + ); + await session.waitForIdle(); + return { + messages: structuredClone(session.messages), + providerMessages: snapshots[0], + errors, + } satisfies SessionResult; + } finally { + session.dispose(); + await rm(root, { recursive: true, force: true }); + } +} + +test("keeps raw user text visible and adds frontmatter-free hidden model context", async () => { + const result = await runSession(); + const user = result.messages.find(({ role }) => role === "user"); + const hidden = result.messages.find( + (message) => + message.role === "custom" && + message.customType === "openpi-inline-skill-references", + ); + + assert.equal(user?.role, "user"); + assert.deepEqual(user.content, [ + { + type: "text", + text: "Please use $review, then report the result.", + }, + ]); + assert.equal(hidden?.role, "custom"); + assert.equal(hidden.display, false); + assert.match(String(hidden.content), / { + const result = await runSession({ prompt: "/skill:review" }); + + assert.equal( + result.messages.some(({ role }) => role === "custom"), + false, + ); + const modelContext = JSON.stringify(result.providerMessages); + assert.match(modelContext, / { + const result = await runSession({ removeSkillBeforePrompt: true }); + + assert.equal( + result.messages.some(({ role }) => role === "custom"), + false, + ); + assert.equal(result.errors.length, 1); + assert.equal(result.errors[0]?.event, "before_agent_start"); + assert.match( + result.errors[0]?.error ?? "", + /Failed to load inline Skill "review"/, + ); + assert.doesNotMatch( + JSON.stringify(result.providerMessages), + / Date: Mon, 31 Aug 2026 12:01:53 +0800 Subject: [PATCH 2/6] fix(skills): expand queued inline references --- extensions/inline-skill-references/index.ts | 100 +++++++++++---- .../inline-skill-references/index.test.ts | 121 ++++++++++++++---- 2 files changed, 170 insertions(+), 51 deletions(-) diff --git a/extensions/inline-skill-references/index.ts b/extensions/inline-skill-references/index.ts index 8540f8fd..a08f455d 100644 --- a/extensions/inline-skill-references/index.ts +++ b/extensions/inline-skill-references/index.ts @@ -1,11 +1,17 @@ import { readFile } from "node:fs/promises"; +import { contentText } from "@earendil-works/pi-ai"; import { + type AgentSession, type ExtensionAPI, + parseSkillBlock, type Skill, stripFrontmatter, } from "@earendil-works/pi-coding-agent"; const INLINE_SKILL_REFERENCE_CHARACTER = /[\p{L}\p{M}\p{N}_-]/u; +const INLINE_SKILL_MESSAGE_TYPE = "openpi-inline-skill-references"; + +type AgentMessage = AgentSession["messages"][number]; function startsAtReferenceBoundary(prompt: string, index: number) { if (index === 0) return true; @@ -63,37 +69,79 @@ async function loadSkillEnvelope(skill: Skill) { } } -export default function inlineSkillReferences(pi: ExtensionAPI) { - let pendingPrompt: string | undefined; +function referenceText(message: AgentMessage) { + if (message.role !== "user") return; + const text = contentText(message.content, ""); + const nativeSkill = parseSkillBlock(text); + return nativeSkill ? (nativeSkill.userMessage ?? "") : text; +} - pi.on("input", (event) => { - if (!event.streamingBehavior) pendingPrompt = event.text; - return { action: "continue" }; - }); +function alreadyHasInlineSkillMessage(message: AgentMessage | undefined) { + return ( + message?.role === "custom" && + message.customType === INLINE_SKILL_MESSAGE_TYPE + ); +} - pi.on("before_agent_start", async (event) => { - const prompt = pendingPrompt; - pendingPrompt = undefined; - if (prompt === undefined) return; - const skills = referencedSkills( - prompt, - event.systemPromptOptions.skills ?? [], - ); - if (skills.length === 0) return; +export async function injectInlineSkillReferences( + messages: readonly AgentMessage[], + skills: readonly Skill[], +) { + const next: AgentMessage[] = []; + const loads = new Map>(); + let changed = false; + + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + next.push(message); + const prompt = referenceText(message); + if ( + prompt === undefined || + alreadyHasInlineSkillMessage(messages[index + 1]) + ) { + continue; + } + + const referenced = referencedSkills(prompt, skills); + if (referenced.length === 0) continue; const envelopes: string[] = []; - for (const skill of skills) { - envelopes.push(await loadSkillEnvelope(skill)); + for (const skill of referenced) { + let load = loads.get(skill); + if (!load) { + load = loadSkillEnvelope(skill); + loads.set(skill, load); + } + envelopes.push(await load); } - return { - message: { - customType: "openpi-inline-skill-references", - content: envelopes.join("\n\n"), - display: false, - details: { - skills: skills.map(({ name, filePath }) => ({ name, filePath })), - }, + next.push({ + role: "custom", + customType: INLINE_SKILL_MESSAGE_TYPE, + content: envelopes.join("\n\n"), + display: false, + details: { + skills: referenced.map(({ name, filePath }) => ({ name, filePath })), }, - }; + timestamp: message.timestamp, + }); + changed = true; + } + + return changed ? next : undefined; +} + +export default function inlineSkillReferences(pi: ExtensionAPI) { + let currentSkills: readonly Skill[] = []; + + pi.on("before_agent_start", (event) => { + currentSkills = event.systemPromptOptions.skills ?? []; + }); + + pi.on("context", async (event) => { + const messages = await injectInlineSkillReferences( + event.messages, + currentSkills, + ); + if (messages) return { messages }; }); } diff --git a/tests/extensions/inline-skill-references/index.test.ts b/tests/extensions/inline-skill-references/index.test.ts index dfa591a8..23e01539 100644 --- a/tests/extensions/inline-skill-references/index.test.ts +++ b/tests/extensions/inline-skill-references/index.test.ts @@ -14,6 +14,7 @@ import { } from "@earendil-works/pi-coding-agent"; import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai"; import inlineSkillReferences, { + injectInlineSkillReferences, referencedSkills, } from "../../../extensions/inline-skill-references/index.ts"; @@ -83,12 +84,20 @@ interface SessionResult { readonly messages: Awaited< ReturnType >["session"]["messages"]; - readonly providerMessages: unknown; + readonly providerMessages: unknown[]; readonly errors: ExtensionError[]; } async function runSession( - options: { prompt?: string; removeSkillBeforePrompt?: boolean } = {}, + options: { + prompt?: string; + removeSkillBeforePrompt?: boolean; + queued?: { + behavior: "steer" | "followUp"; + direct: boolean; + prompt: string; + }; + } = {}, ) { const root = await mkdtemp(path.join(tmpdir(), "openpi-inline-skills-")); const cwd = path.join(root, "workspace"); @@ -130,12 +139,12 @@ async function runSession( provider: `openpi-inline-skills-${path.basename(root)}`, models: [{ id: "fixture", name: "Fixture", reasoning: false }], }); - provider.setResponses([ - (context) => { + provider.setResponses( + Array.from({ length: options.queued ? 2 : 1 }, () => (context) => { snapshots.push(structuredClone(context.messages)); return fauxAssistantMessage("Done."); - }, - ]); + }), + ); const settingsManager = SettingsManager.inMemory(undefined, { projectTrusted: false, @@ -169,13 +178,32 @@ async function runSession( onError: (error) => errors.push(error), }); if (options.removeSkillBeforePrompt) await unlink(skillPath); + let queued: Promise | undefined; + let didQueue = false; + const queuedInput = options.queued; + const unsubscribe = queuedInput + ? session.subscribe((event) => { + if (event.type !== "turn_start" || didQueue) return; + didQueue = true; + queued = queuedInput.direct + ? session[queuedInput.behavior](queuedInput.prompt) + : session.prompt(queuedInput.prompt, { + streamingBehavior: queuedInput.behavior, + }); + }) + : undefined; await session.prompt( - options.prompt ?? "Please use $review, then report the result.", + options.prompt ?? + (options.queued + ? "Start the run." + : "Please use $review, then report the result."), ); + await queued; await session.waitForIdle(); + unsubscribe?.(); return { messages: structuredClone(session.messages), - providerMessages: snapshots[0], + providerMessages: snapshots, errors, } satisfies SessionResult; } finally { @@ -187,11 +215,6 @@ async function runSession( test("keeps raw user text visible and adds frontmatter-free hidden model context", async () => { const result = await runSession(); const user = result.messages.find(({ role }) => role === "user"); - const hidden = result.messages.find( - (message) => - message.role === "custom" && - message.customType === "openpi-inline-skill-references", - ); assert.equal(user?.role, "user"); assert.deepEqual(user.content, [ @@ -200,22 +223,70 @@ test("keeps raw user text visible and adds frontmatter-free hidden model context text: "Please use $review, then report the result.", }, ]); - assert.equal(hidden?.role, "custom"); - assert.equal(hidden.display, false); - assert.match(String(hidden.content), / + message.role === "custom" && + message.customType === "openpi-inline-skill-references", + ), + false, ); - assert.doesNotMatch(String(hidden.content), /disable-model-invocation/); - assert.doesNotMatch(String(hidden.content), /