From 18e55095b075521c16edb4213553a7306284d744 Mon Sep 17 00:00:00 2001 From: lwc <1803086257@qq.com> Date: Sun, 30 Aug 2026 23:36:22 +0800 Subject: [PATCH] feat(skills): support inline $ Skill references (#282) --- extensions/inline-skill-references/index.ts | 146 +++++ .../inline-skill-references/index.test.ts | 578 ++++++++++++++++++ 2 files changed, 724 insertions(+) create mode 100644 extensions/inline-skill-references/index.ts create mode 100644 tests/extensions/inline-skill-references/index.test.ts diff --git a/extensions/inline-skill-references/index.ts b/extensions/inline-skill-references/index.ts new file mode 100644 index 0000000..7be0f4f --- /dev/null +++ b/extensions/inline-skill-references/index.ts @@ -0,0 +1,146 @@ +import { readFile } from "node:fs/promises"; +import { + parseSkillBlock, + stripFrontmatter, + type BeforeAgentStartEvent, + type ExtensionAPI, +} from "@earendil-works/pi-coding-agent"; + +const SKILL_COMMAND_PREFIX = "skill:"; +const INLINE_SKILL_REFERENCE = /(?:^|[ \t])\$([^\s$]*)$/; +const SUBMITTED_INLINE_SKILL_REFERENCE = /(?:^|[ \t])\$([^\s]+)/gm; +const REFERENCE_TERMINATOR = /^(?:$|[^\p{L}\p{N}_$-])/u; + +type LoadedSkill = NonNullable< + BeforeAgentStartEvent["systemPromptOptions"]["skills"] +>[number]; + +/** + * Pi owns the Skill registry. This adapter derives short-lived completion + * candidates from its current slash-command projection on each request. + */ +function inlineSkillSuggestions(pi: ExtensionAPI, prefix: string) { + const namePrefix = prefix.slice(1); + + return pi + .getCommands() + .filter( + (command) => + command.source === "skill" && + command.name.startsWith(SKILL_COMMAND_PREFIX), + ) + .map((command) => ({ + name: command.name.slice(SKILL_COMMAND_PREFIX.length), + description: command.description, + })) + .filter((skill) => skill.name.startsWith(namePrefix)) + .map((skill) => ({ + value: `$${skill.name}`, + label: `$${skill.name}`, + ...(skill.description ? { description: skill.description } : {}), + })); +} + +function referencedSkills(prompt: string, skills: readonly LoadedSkill[]) { + const selected = new Set(); + const references: LoadedSkill[] = []; + + for (const match of prompt.matchAll(SUBMITTED_INLINE_SKILL_REFERENCE)) { + const reference = match[1]!; + const skill = skills.find( + (candidate) => + reference.startsWith(candidate.name) && + REFERENCE_TERMINATOR.test(reference.slice(candidate.name.length)), + ); + if (skill && !selected.has(skill.name)) { + selected.add(skill.name); + references.push(skill); + } + } + + return references; +} + +/** + * Mirror Pi's native Skill envelope while retaining Pi's turn-scoped Skill + * projection as the only authority for which files may be read. + */ +function formatInlineSkillContent(skill: LoadedSkill, body: string) { + return `\nReferences are relative to ${skill.baseDir}.\n\n${body}\n`; +} + +async function inlineSkillContent(skill: LoadedSkill) { + const content = await readFile(skill.filePath, "utf8"); + return formatInlineSkillContent(skill, stripFrontmatter(content).trim()); +} + +export function createInlineSkillReferencesExtension() { + return function inlineSkillReferences(pi: ExtensionAPI) { + pi.on("session_start", (_event, ctx) => { + if (ctx.mode !== "tui") return; + + ctx.ui.addAutocompleteProvider((current) => ({ + triggerCharacters: ["$"], + async getSuggestions(lines, cursorLine, cursorCol, options) { + const beforeCursor = (lines[cursorLine] ?? "").slice(0, cursorCol); + const match = beforeCursor.match(INLINE_SKILL_REFERENCE); + if (!match) { + return current.getSuggestions( + lines, + cursorLine, + cursorCol, + options, + ); + } + + const prefix = `$${match[1] ?? ""}`; + const items = inlineSkillSuggestions(pi, prefix); + return items.length > 0 ? { prefix, items } : null; + }, + applyCompletion(lines, cursorLine, cursorCol, item, prefix) { + return current.applyCompletion( + lines, + cursorLine, + cursorCol, + item, + prefix, + ); + }, + shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { + return ( + current.shouldTriggerFileCompletion?.( + lines, + cursorLine, + cursorCol, + ) ?? true + ); + }, + })); + }); + + pi.on("before_agent_start", async (event) => { + // Pi expands /skill:name before this hook. Its exported parser separates + // the already-loaded native Skill body from the user's trailing arguments. + const nativeSkill = parseSkillBlock(event.prompt); + const userPrompt = nativeSkill ? nativeSkill.userMessage : event.prompt; + if (!userPrompt) return; + + const skills = referencedSkills( + userPrompt, + event.systemPromptOptions.skills ?? [], + ); + if (skills.length === 0) return; + + const content = await Promise.all(skills.map(inlineSkillContent)); + return { + message: { + customType: "openpi-inline-skill-reference", + content: content.join("\n\n"), + display: false, + }, + }; + }); + }; +} + +export default createInlineSkillReferencesExtension(); 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 0000000..3037195 --- /dev/null +++ b/tests/extensions/inline-skill-references/index.test.ts @@ -0,0 +1,578 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import test from "node:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + AutocompleteProviderFactory, + BeforeAgentStartEvent, + BeforeAgentStartEventResult, + ExtensionAPI, + ExtensionContext, + SlashCommandInfo, +} from "@earendil-works/pi-coding-agent"; +import { + CombinedAutocompleteProvider, + Editor, + type AutocompleteProvider, + type TUI, +} from "@earendil-works/pi-tui"; +import inlineSkillReferencesExtension from "../../../extensions/inline-skill-references/index.ts"; + +const sourceInfo = { + path: "/fixture", + source: "local", + scope: "project", + origin: "top-level", +} as const; + +function command( + name: string, + source: SlashCommandInfo["source"], + description: string, +): SlashCommandInfo { + return { name, source, description, sourceInfo }; +} + +function autocompleteHarness( + commands: SlashCommandInfo[], + delegated: AutocompleteProvider = new CombinedAutocompleteProvider( + [], + "/fixture", + ), +) { + const sessionStarts: Array< + (event: unknown, context: ExtensionContext) => void + > = []; + let factory: AutocompleteProviderFactory | undefined; + const pi = { + on( + event: string, + handler: (event: unknown, ctx: ExtensionContext) => void, + ) { + if (event === "session_start") sessionStarts.push(handler); + }, + getCommands: () => commands, + } as unknown as ExtensionAPI; + + inlineSkillReferencesExtension(pi); + for (const start of sessionStarts) { + start({}, { + mode: "tui", + ui: { + addAutocompleteProvider(candidate: AutocompleteProviderFactory) { + factory = candidate; + }, + }, + } as unknown as ExtensionContext); + } + + assert.ok( + factory, + "the TUI session should register an autocomplete provider", + ); + return factory(delegated); +} + +function expansionHarness() { + const beforeStarts: Array< + ( + event: BeforeAgentStartEvent, + context: ExtensionContext, + ) => + | Promise + | BeforeAgentStartEventResult + | void + > = []; + const pi = { + on( + event: string, + handler: ( + event: BeforeAgentStartEvent, + context: ExtensionContext, + ) => + | Promise + | BeforeAgentStartEventResult + | void, + ) { + if (event === "before_agent_start") beforeStarts.push(handler); + }, + } as unknown as ExtensionAPI; + + inlineSkillReferencesExtension(pi); + assert.equal( + beforeStarts.length, + 1, + "the shared pre-model hook is registered", + ); + + return async (event: BeforeAgentStartEvent) => + beforeStarts[0]!(event, {} as ExtensionContext); +} + +interface SkillFixture { + readonly name: string; + readonly description: string; + readonly body: string; +} + +async function createSkillFixtures(fixtures: readonly SkillFixture[]) { + const root = await mkdtemp(join(tmpdir(), "openpi-inline-skill-")); + const skills = await Promise.all( + fixtures.map(async (fixture) => { + const baseDir = join(root, fixture.name); + const filePath = join(baseDir, "SKILL.md"); + await mkdir(baseDir); + await writeFile( + filePath, + [ + "---", + `name: ${fixture.name}`, + `description: ${fixture.description}`, + "---", + "", + fixture.body, + ].join("\n"), + ); + return { + name: fixture.name, + description: fixture.description, + filePath, + baseDir, + sourceInfo, + disableModelInvocation: false, + }; + }), + ); + return { root, skills }; +} + +test("inline completion lists only the current Skill commands with descriptions", async () => { + const provider = autocompleteHarness([ + command("skill:review", "skill", "Review a change"), + command("skill:tdd", "skill", "Work test-first"), + command("reload", "extension", "Reload resources"), + command("release-notes", "prompt", "Draft release notes"), + ]); + + const suggestions = await provider.getSuggestions( + ["Please use $r"], + 0, + "Please use $r".length, + { signal: new AbortController().signal }, + ); + + assert.deepEqual(suggestions, { + prefix: "$r", + items: [ + { + value: "$review", + label: "$review", + description: "Review a change", + }, + ], + }); +}); + +test("inline completion follows Pi's current command projection without a cache", async () => { + const commands = [command("skill:review", "skill", "Review a change")]; + const provider = autocompleteHarness(commands); + + commands.splice(0, 1, command("skill:release", "skill", "Prepare a release")); + const suggestions = await provider.getSuggestions(["$r"], 0, 2, { + signal: new AbortController().signal, + }); + + assert.deepEqual(suggestions?.items, [ + { + value: "$release", + label: "$release", + description: "Prepare a release", + }, + ]); +}); + +test("inline completion does not impose a second Skill-name validator", async () => { + const provider = autocompleteHarness([ + command("skill:Review", "skill", "Review a change"), + ]); + + const suggestions = await provider.getSuggestions( + ["Please use $R"], + 0, + "Please use $R".length, + { signal: new AbortController().signal }, + ); + + assert.deepEqual(suggestions, { + prefix: "$R", + items: [ + { + value: "$Review", + label: "$Review", + description: "Review a change", + }, + ], + }); +}); + +test("inline completion is registered only for TUI sessions", () => { + const sessionStarts: Array< + (event: unknown, context: ExtensionContext) => void + > = []; + let registered = false; + const pi = { + on( + event: string, + handler: (event: unknown, ctx: ExtensionContext) => void, + ) { + if (event === "session_start") sessionStarts.push(handler); + }, + } as unknown as ExtensionAPI; + + inlineSkillReferencesExtension(pi); + sessionStarts[0]!( + {} as unknown, + { + mode: "rpc", + ui: { + addAutocompleteProvider() { + registered = true; + }, + }, + } as unknown as ExtensionContext, + ); + + assert.equal(registered, false); +}); + +test("inline completion delegates slash and file completion unchanged", async () => { + const calls: string[] = []; + const delegated: AutocompleteProvider = { + async getSuggestions() { + calls.push("suggestions"); + return { + prefix: "delegated", + items: [{ value: "delegated", label: "delegated" }], + }; + }, + applyCompletion(lines, cursorLine, cursorCol) { + calls.push("apply"); + return { lines: ["delegated"], cursorLine, cursorCol: 9 }; + }, + shouldTriggerFileCompletion() { + calls.push("file-trigger"); + return false; + }, + }; + const provider = autocompleteHarness([], delegated); + + const slash = await provider.getSuggestions(["/reload"], 0, 7, { + signal: new AbortController().signal, + }); + const file = await provider.getSuggestions(["Use @README"], 0, 11, { + signal: new AbortController().signal, + }); + const completion = provider.applyCompletion( + ["$review"], + 0, + 7, + { + value: "$review", + label: "$review", + }, + "$review", + ); + const fileTrigger = provider.shouldTriggerFileCompletion?.(["@README"], 0, 7); + + assert.deepEqual(slash, { + prefix: "delegated", + items: [{ value: "delegated", label: "delegated" }], + }); + assert.deepEqual(file, slash); + assert.deepEqual(completion, { + lines: ["delegated"], + cursorLine: 0, + cursorCol: 9, + }); + assert.equal(fileTrigger, false); + assert.deepEqual(calls, [ + "suggestions", + "suggestions", + "apply", + "file-trigger", + ]); +}); + +test("Tab accepts an inline Skill completion without submitting the editor", async () => { + const provider = autocompleteHarness([ + command("skill:review", "skill", "Review a change"), + ]); + let signalSuggestionsReady: () => void = () => {}; + const suggestionsReady = new Promise((resolve) => { + signalSuggestionsReady = resolve; + }); + const editor = new Editor( + { + terminal: { rows: 24 }, + requestRender() { + signalSuggestionsReady(); + }, + } as unknown as TUI, + { + borderColor: (text) => text, + selectList: { + selectedPrefix: (text) => text, + selectedText: (text) => text, + description: (text) => text, + scrollInfo: (text) => text, + noMatch: (text) => text, + }, + }, + ); + const submitted: string[] = []; + editor.onSubmit = (text) => submitted.push(text); + editor.setAutocompleteProvider(provider); + editor.setText("Use "); + + editor.handleInput("$"); + await suggestionsReady; + assert.match(editor.render(80).join("\n"), /\$review/); + editor.handleInput("\t"); + + assert.equal(editor.getText(), "Use $review"); + assert.deepEqual(submitted, []); +}); + +test("a submitted inline reference preserves user text and injects hidden Skill content", async () => { + const fixtures = await createSkillFixtures([ + { + name: "review", + description: "Review a change", + body: "Review the diff before approving it.", + }, + ]); + const skill = fixtures.skills[0]!; + const expand = expansionHarness(); + const prompt = "Please use $review."; + + try { + const result = await expand({ + type: "before_agent_start", + prompt, + systemPrompt: "system", + systemPromptOptions: { + cwd: fixtures.root, + skills: fixtures.skills, + }, + }); + + assert.equal(prompt, "Please use $review."); + assert.equal(result?.message?.display, false); + assert.equal( + result?.message?.content, + [ + ``, + `References are relative to ${skill.baseDir}.`, + "", + "Review the diff before approving it.", + "", + ].join("\n"), + ); + } finally { + await rm(fixtures.root, { recursive: true, force: true }); + } +}); + +test("ordinary punctuation terminates an inline Skill reference", async () => { + const fixtures = await createSkillFixtures([ + { + name: "review", + description: "Review a change", + body: "Review the diff before approving it.", + }, + ]); + const expand = expansionHarness(); + + try { + for (const punctuation of ["(", "#", "/"]) { + const result = await expand({ + type: "before_agent_start", + prompt: `Use $review${punctuation}`, + systemPrompt: "system", + systemPromptOptions: { cwd: fixtures.root, skills: fixtures.skills }, + }); + + assert.equal(result?.message?.display, false, punctuation); + } + } finally { + await rm(fixtures.root, { recursive: true, force: true }); + } +}); + +test("native slash Skill content is skipped while its user arguments expand", async () => { + const fixtures = await createSkillFixtures([ + { + name: "review", + description: "Review a change", + body: "Review the diff before approving it.", + }, + ]); + const expand = expansionHarness(); + const nativeSkillPrompt = [ + '', + "References are relative to /fixture/native.", + "", + "This native Skill body mentions $review.", + "", + "", + "Then apply $review.", + ].join("\n"); + + try { + const result = await expand({ + type: "before_agent_start", + prompt: nativeSkillPrompt, + systemPrompt: "system", + systemPromptOptions: { cwd: fixtures.root, skills: fixtures.skills }, + }); + + assert.equal(result?.message?.display, false); + const content = result?.message?.content; + if (typeof content !== "string") { + throw new Error("expected the hidden Skill message to contain text"); + } + assert.equal((content.match(/ { + const fixtures = await createSkillFixtures([ + { + name: "tdd", + description: "Work test-first", + body: "Start with a failing test.", + }, + { + name: "review", + description: "Review a change", + body: "Review the diff before approving it.", + }, + ]); + const expand = expansionHarness(); + const prompt = "$tdd, then $review.) Finally, $tdd again."; + + try { + const result = await expand({ + type: "before_agent_start", + prompt, + systemPrompt: "system", + systemPromptOptions: { cwd: fixtures.root, skills: fixtures.skills }, + }); + const content = result?.message?.content; + + assert.equal(prompt, "$tdd, then $review.) Finally, $tdd again."); + if (typeof content !== "string") { + throw new Error("expected the hidden Skill message to contain text"); + } + assert.equal((content.match(/ { + const fixtures = await createSkillFixtures([ + { + name: "review", + description: "Review a change", + body: "Review the diff before approving it.", + }, + ]); + const expand = expansionHarness(); + const prompt = "$missing, \\$review, prose$review, and $review_extra."; + + try { + const result = await expand({ + type: "before_agent_start", + prompt, + systemPrompt: "system", + systemPromptOptions: { cwd: fixtures.root, skills: fixtures.skills }, + }); + + assert.equal( + prompt, + "$missing, \\$review, prose$review, and $review_extra.", + ); + assert.equal(result, undefined); + } finally { + await rm(fixtures.root, { recursive: true, force: true }); + } +}); + +test("a known reference expands alongside ignored candidates in the same line", async () => { + const fixtures = await createSkillFixtures([ + { + name: "review", + description: "Review a change", + body: "Review the diff before approving it.", + }, + ]); + const expand = expansionHarness(); + const prompt = + "$missing, $review, \\$review, prose$review, and $review_extra."; + + try { + const result = await expand({ + type: "before_agent_start", + prompt, + systemPrompt: "system", + systemPromptOptions: { cwd: fixtures.root, skills: fixtures.skills }, + }); + + assert.equal(result?.message?.display, false); + const content = result?.message?.content; + if (typeof content !== "string") { + throw new Error("expected the hidden Skill message to contain text"); + } + assert.equal((content.match(/ { + const fixtureRoot = await mkdtemp(join(tmpdir(), "openpi-inline-skill-")); + const expand = expansionHarness(); + + try { + await assert.rejects( + expand({ + type: "before_agent_start", + prompt: "$review", + systemPrompt: "system", + systemPromptOptions: { + cwd: fixtureRoot, + skills: [ + { + name: "review", + description: "Review a change", + filePath: join(fixtureRoot, "missing-SKILL.md"), + baseDir: fixtureRoot, + sourceInfo, + disableModelInvocation: false, + }, + ], + }, + }), + { code: "ENOENT" }, + ); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } +});