fix(coding-agent): identify user-invoked skills and expose skill directory

User-invoked skills (typed /skill:, steered, follow-up, interrupted/
resumed via compaction, ACP, RPC) only appended a bare "Skill: <path>"
line, so the model neither learned the user had invoked that specific
skill nor where the skill directory was. Relative paths in skill bodies
(scripts/, templates/) could not be resolved.

Route all user-invoked paths through a self-identifying, baseDir-aware
prompt template; keep hidden autoload skills on the minimal non-user
format. Interactive skillCommands now carries the loaded Skill object
instead of a bare path so baseDir flows through without reconstruction.
The invocation kind defaults to "user" to keep buildSkillPromptMessage
source-compatible.

Op: correct
Restores: spec:user-invoked-skill-prompt-self-identifies-and-exposes-skill-directory
This commit is contained in:
metaphorics
2026-06-30 23:11:05 +09:00
parent 5bc68f57cd
commit fa64097992
15 changed files with 163 additions and 63 deletions
@@ -1,6 +1,6 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import { getProjectDir } from "@oh-my-pi/pi-utils";
import { getProjectDir, prompt } from "@oh-my-pi/pi-utils";
import {
isValidManagedSkillName,
MANAGED_SKILLS_PROVIDER_ID,
@@ -11,6 +11,8 @@ import type { SourceMeta } from "../capability/types";
import type { SkillsSettings } from "../config/settings";
import { type Skill as CapabilitySkill, loadCapability } from "../discovery";
import { compareSkillOrder, scanSkillsFromDir } from "../discovery/helpers";
import autoloadTemplate from "../prompts/skills/autoload.md" with { type: "text" };
import userInvocationTemplate from "../prompts/skills/user-invocation.md" with { type: "text" };
import type { SkillPromptDetails } from "../session/messages";
import { expandTilde } from "../tools/path-utils";
export interface Skill {
@@ -384,18 +386,39 @@ export function getSkillSlashCommandName(skill: Pick<Skill, "name">): string {
return `skill:${skill.name}`;
}
export type SkillInvocationKind = "user" | "autoload";
export async function buildSkillPromptMessage(
skill: Pick<Skill, "name" | "filePath">,
skill: Pick<Skill, "name" | "filePath" | "baseDir">,
args: string,
invocation: SkillInvocationKind = "user",
): Promise<BuiltSkillPromptMessage> {
const content = await Bun.file(skill.filePath).text();
const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
const metaLines = [`Skill: ${skill.filePath}`];
const trimmedArgs = args.trim();
if (trimmedArgs) {
metaLines.push(`User: ${trimmedArgs}`);
let message: string;
if (invocation === "user") {
// User-invoked skills announce themselves and expose their skill directory
// so the model resolves the skill's own relative paths (scripts/, templates/).
message = prompt
.render(userInvocationTemplate, {
name: skill.name,
body,
baseDir: skill.baseDir,
userArgs: trimmedArgs || undefined,
})
.trim();
} else {
// Autoload skills are hidden, non-user context — they MUST NOT claim the
// user invoked them; this keeps the minimal provenance-only format.
message = prompt
.render(autoloadTemplate, {
body,
filePath: skill.filePath,
userArgs: trimmedArgs || undefined,
})
.trim();
}
const message = `${body}\n\n---\n\n${metaLines.join("\n")}`;
return {
message,
details: {