Skip to content

Commit dde6425

Browse files
committed
feat(server): add skill activation handling for read calls and update related components
1 parent bdf7bc1 commit dde6425

8 files changed

Lines changed: 256 additions & 4 deletions

File tree

server/src/agent/events.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,25 @@
44
* Pi-internal lifecycle noise so the client contract stays small.
55
*/
66
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
7+
import { skillLabelForRead } from "./skill-label.ts";
78

89
export interface ClientFrame {
910
type: string;
1011
[k: string]: unknown;
1112
}
1213

14+
/** Frontmatter skill name when a `read` call is a skill activation. */
15+
export function skillFieldFor(
16+
toolName: string,
17+
args: unknown,
18+
sandboxRoot: string,
19+
): { skill: string } | undefined {
20+
if (toolName !== "read") return undefined;
21+
const p = (args as { path?: unknown } | null | undefined)?.path;
22+
const skill = skillLabelForRead(p, sandboxRoot);
23+
return skill ? { skill } : undefined;
24+
}
25+
1326
/**
1427
* Rewrite absolute sandbox paths to sandbox-relative ones for display.
1528
*
@@ -190,6 +203,7 @@ export function toClientFrame(
190203
toolCallId: ev.toolCallId,
191204
toolName: ev.toolName,
192205
args: relativizeSandboxPaths(ev.args, sandboxRoot),
206+
...skillFieldFor(ev.toolName, ev.args, sandboxRoot),
193207
};
194208
case "tool_execution_update":
195209
return { type: "tool_update", toolCallId: ev.toolCallId, toolName: ev.toolName };

server/src/agent/session-history.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* a reopened transcript renders exactly like it did while streaming — prose,
77
* reasoning blocks, and tool activity rows with args and capped results.
88
*/
9-
import { relativizeSandboxPaths, type ClientFrame } from "./events.ts";
9+
import { relativizeSandboxPaths, skillFieldFor, type ClientFrame } from "./events.ts";
1010
import {
1111
readRows,
1212
textOf,
@@ -88,6 +88,7 @@ export function toHistory(file: string, sandboxRoot = ""): HistoryMessage[] {
8888
toolCallId: call.id,
8989
toolName: call.name,
9090
args: relativizeSandboxPaths(call.arguments ?? {}, sandboxRoot),
91+
...skillFieldFor(call.name, call.arguments, sandboxRoot),
9192
},
9293
m.timestamp,
9394
);

server/src/agent/skill-label.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Resolve a skill's display name from a `read` tool call.
3+
*
4+
* Pi has no "skill invoked" event: skills are advertised in the system prompt
5+
* and the agent activates one by reading its `SKILL.md` (or, for single-file
6+
* skills, the `.md` directly under a skills root). When a read targets such a
7+
* file, the emitters attach the skill's name to the `tool_start` client frame
8+
* (`skill` field) so the chat UI can label the row as a skill activation.
9+
*
10+
* The authoritative name is the frontmatter `name:` — parsed with Pi's own
11+
* skill loader so the label always matches what the system prompt advertises
12+
* to the model. The directory (or file) basename is the fallback when the
13+
* file is gone or its frontmatter carries no usable name.
14+
*/
15+
import path from "node:path";
16+
import { loadSkillsFromDir } from "@earendil-works/pi-coding-agent";
17+
18+
/**
19+
* Path-shape detection + fallback name. Mirrored client-side in
20+
* `web/src/lib/skill-invocation.ts` (which acts as the display fallback when
21+
* a frame carries no `skill` field).
22+
*/
23+
function skillPathName(p: string): string | null {
24+
const norm = p.replaceAll("\\", "/");
25+
// Directory skill: any <dir>/SKILL.md — the Agent Skills standard names the
26+
// skill after the directory holding SKILL.md, wherever it lives.
27+
const segments = norm.split("/").filter((s) => s && s !== ".");
28+
if (segments.length >= 2 && segments[segments.length - 1] === "SKILL.md") {
29+
return segments[segments.length - 2];
30+
}
31+
// Single-file skill: a .md directly under a Pi skills root (project
32+
// `.pi/skills/` or global `~/.pi/agent/skills/`).
33+
const single = norm.match(/(?:^|\/)\.pi\/(?:agent\/)?skills\/([^/]+)\.md$/);
34+
return single ? single[1] : null;
35+
}
36+
37+
/** Frontmatter `name:` of the skill file, via Pi's loader; null when the file
38+
* is unreadable or the loader rejects it. */
39+
function frontmatterName(absFile: string): string | null {
40+
try {
41+
const { skills } = loadSkillsFromDir({
42+
dir: path.dirname(absFile),
43+
source: "read",
44+
});
45+
const target = path.resolve(absFile);
46+
const hit = skills.find((s) => path.resolve(s.filePath) === target);
47+
return hit?.name || null;
48+
} catch {
49+
return null;
50+
}
51+
}
52+
53+
/**
54+
* Skill name for a `read` tool path, or null when the read is not a skill
55+
* activation. Relative paths resolve against `sandboxRoot` (the agent cwd).
56+
*/
57+
export function skillLabelForRead(
58+
rawPath: unknown,
59+
sandboxRoot: string,
60+
): string | null {
61+
if (typeof rawPath !== "string") return null;
62+
const fallback = skillPathName(rawPath);
63+
if (!fallback) return null;
64+
const abs = path.isAbsolute(rawPath)
65+
? rawPath
66+
: path.join(sandboxRoot, rawPath);
67+
return frontmatterName(abs) ?? fallback;
68+
}

server/test/skill-label.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { afterAll, beforeEach, describe, expect, it } from "vitest";
4+
import { PROJECTS_ROOT } from "../src/config.ts";
5+
import { ensureProjectExists, resolvePaths } from "../src/projects.ts";
6+
import { skillLabelForRead } from "../src/agent/skill-label.ts";
7+
import { skillFieldFor } from "../src/agent/events.ts";
8+
9+
function reset(): void {
10+
fs.rmSync(PROJECTS_ROOT, { recursive: true, force: true });
11+
fs.mkdirSync(PROJECTS_ROOT, { recursive: true });
12+
}
13+
function makeSkill(dir: string, dirName: string, fmName: string): string {
14+
const d = path.join(dir, dirName);
15+
fs.mkdirSync(d, { recursive: true });
16+
const file = path.join(d, "SKILL.md");
17+
fs.writeFileSync(
18+
file,
19+
`---\nname: ${fmName}\ndescription: test skill\n---\n\nBody.\n`,
20+
"utf-8",
21+
);
22+
return file;
23+
}
24+
beforeEach(reset);
25+
afterAll(() => fs.rmSync(PROJECTS_ROOT, { recursive: true, force: true }));
26+
27+
describe("skillLabelForRead", () => {
28+
it("returns the frontmatter name, not the directory name", () => {
29+
ensureProjectExists("p1");
30+
const paths = resolvePaths("p1");
31+
makeSkill(paths.skillsDir, "scrnaseq-qc-dir", "single-cell-qc");
32+
expect(skillLabelForRead(".pi/skills/scrnaseq-qc-dir/SKILL.md", paths.sandbox)).toBe(
33+
"single-cell-qc",
34+
);
35+
});
36+
37+
it("resolves absolute paths too", () => {
38+
ensureProjectExists("p2");
39+
const paths = resolvePaths("p2");
40+
const file = makeSkill(paths.skillsDir, "foo", "fancy-foo");
41+
expect(skillLabelForRead(file, paths.sandbox)).toBe("fancy-foo");
42+
});
43+
44+
it("falls back to the directory name when the file is gone", () => {
45+
ensureProjectExists("p3");
46+
const paths = resolvePaths("p3");
47+
expect(skillLabelForRead(".pi/skills/vanished/SKILL.md", paths.sandbox)).toBe(
48+
"vanished",
49+
);
50+
});
51+
52+
it("ignores non-skill reads", () => {
53+
ensureProjectExists("p4");
54+
const paths = resolvePaths("p4");
55+
expect(skillLabelForRead("analysis/results.md", paths.sandbox)).toBeNull();
56+
expect(skillLabelForRead(".pi/skills/foo/references/api.md", paths.sandbox)).toBeNull();
57+
expect(skillLabelForRead(undefined, paths.sandbox)).toBeNull();
58+
});
59+
});
60+
61+
describe("skillFieldFor", () => {
62+
it("attaches the skill field only for read calls on skill files", () => {
63+
ensureProjectExists("p5");
64+
const paths = resolvePaths("p5");
65+
makeSkill(paths.skillsDir, "bar", "better-bar");
66+
expect(
67+
skillFieldFor("read", { path: ".pi/skills/bar/SKILL.md" }, paths.sandbox),
68+
).toEqual({ skill: "better-bar" });
69+
expect(skillFieldFor("read", { path: "notes.md" }, paths.sandbox)).toBeUndefined();
70+
expect(
71+
skillFieldFor("bash", { command: "cat .pi/skills/bar/SKILL.md" }, paths.sandbox),
72+
).toBeUndefined();
73+
});
74+
});

web/src/components/tool-activity.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
SearchIcon,
1212
TerminalIcon,
1313
UsersIcon,
14+
WandSparklesIcon,
1415
WrenchIcon,
1516
XIcon,
1617
} from "lucide-react";
@@ -22,6 +23,7 @@ import {
2223
CollapsibleTrigger,
2324
} from "@/components/ui/collapsible";
2425
import { Spinner } from "@/components/ui/spinner";
26+
import { skillNameFromRead } from "@/lib/skill-invocation";
2527
import type { ActivityItem } from "@/lib/use-agent";
2628
import { cn } from "@/lib/utils";
2729

@@ -92,9 +94,13 @@ function fullArgs(args: unknown): string {
9294

9395
function ToolCard({ item }: { item: ActivityItem }) {
9496
const [open, setOpen] = useState(false);
95-
const Icon = iconFor(item.toolName);
96-
const name = item.toolName ?? item.label;
97-
const summary = summarize(item.toolName, item.args);
97+
// A read of a SKILL.md is Pi's skill activation — surface the skill's name
98+
// instead of a generic file read (the path stays visible under Input). The
99+
// server resolves the frontmatter name; the path-derived name is a fallback.
100+
const skill = item.skillName ?? skillNameFromRead(item.toolName, item.args);
101+
const Icon = skill ? WandSparklesIcon : iconFor(item.toolName);
102+
const name = skill ? "skill" : (item.toolName ?? item.label);
103+
const summary = skill ?? summarize(item.toolName, item.args);
98104
const args = fullArgs(item.args);
99105
const hasDetail = Boolean(args || item.result);
100106

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { skillNameFromRead } from "./skill-invocation";
4+
5+
describe("skillNameFromRead", () => {
6+
it("names a project skill read by its directory", () => {
7+
expect(skillNameFromRead("read", { path: ".pi/skills/scrnaseq-qc/SKILL.md" })).toBe(
8+
"scrnaseq-qc",
9+
);
10+
});
11+
12+
it("uses the innermost directory for nested skills", () => {
13+
expect(
14+
skillNameFromRead("read", { path: ".pi/skills/genomics/variant-calling/SKILL.md" }),
15+
).toBe("variant-calling");
16+
});
17+
18+
it("recognizes SKILL.md outside the .pi tree (e.g. .agents/skills, absolute)", () => {
19+
expect(skillNameFromRead("read", { path: ".agents/skills/foo/SKILL.md" })).toBe("foo");
20+
expect(
21+
skillNameFromRead("read", { path: "/Users/x/.pi/agent/skills/bar/SKILL.md" }),
22+
).toBe("bar");
23+
});
24+
25+
it("recognizes single-file skills directly under a skills root", () => {
26+
expect(skillNameFromRead("read", { path: ".pi/skills/quick-notes.md" })).toBe(
27+
"quick-notes",
28+
);
29+
expect(
30+
skillNameFromRead("read", { path: "/Users/x/.pi/agent/skills/quick-notes.md" }),
31+
).toBe("quick-notes");
32+
});
33+
34+
it("normalizes Windows separators", () => {
35+
expect(skillNameFromRead("read", { path: ".pi\\skills\\foo\\SKILL.md" })).toBe("foo");
36+
});
37+
38+
it("ignores ordinary reads and other tools", () => {
39+
expect(skillNameFromRead("read", { path: "analysis/results.md" })).toBeNull();
40+
expect(skillNameFromRead("read", { path: ".pi/skills/foo/references/api.md" })).toBeNull();
41+
expect(skillNameFromRead("bash", { command: "cat .pi/skills/foo/SKILL.md" })).toBeNull();
42+
expect(skillNameFromRead("read", {})).toBeNull();
43+
expect(skillNameFromRead(undefined, { path: "x/SKILL.md" })).toBeNull();
44+
});
45+
46+
it("does not treat a bare root SKILL.md as a skill", () => {
47+
expect(skillNameFromRead("read", { path: "SKILL.md" })).toBeNull();
48+
expect(skillNameFromRead("read", { path: "./SKILL.md" })).toBeNull();
49+
});
50+
});

web/src/lib/skill-invocation.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Detect a skill activation from a `read` tool call.
3+
*
4+
* Pi has no dedicated "skill invoked" event: available skills are listed in
5+
* the system prompt and the agent activates one by reading its `SKILL.md`
6+
* (or, for single-file skills, the `.md` directly under a skills root).
7+
* Recognizing those reads lets the chat UI label the row with the skill's
8+
* name instead of a generic file read.
9+
*
10+
* The authoritative name is the frontmatter `name:`, which the server
11+
* resolves and attaches to the frame (`skill` field / ActivityItem.skillName
12+
* — see server/src/agent/skill-label.ts). This helper is the display
13+
* fallback for frames without it, deriving a name from the path (directory
14+
* containing `SKILL.md`, or the file's basename for single-file skills).
15+
*/
16+
export function skillNameFromRead(
17+
toolName: string | undefined,
18+
args: unknown,
19+
): string | null {
20+
if (toolName !== "read" || !args || typeof args !== "object") return null;
21+
const raw = (args as Record<string, unknown>).path;
22+
if (typeof raw !== "string") return null;
23+
const path = raw.replaceAll("\\", "/");
24+
// Directory skill: any <dir>/SKILL.md — the Agent Skills standard names the
25+
// skill after the directory holding SKILL.md, wherever it lives.
26+
const segments = path.split("/").filter((s) => s && s !== ".");
27+
if (segments.length >= 2 && segments[segments.length - 1] === "SKILL.md") {
28+
return segments[segments.length - 2];
29+
}
30+
// Single-file skill: a .md directly under a Pi skills root (project
31+
// `.pi/skills/` or global `~/.pi/agent/skills/`).
32+
const single = path.match(/(?:^|\/)\.pi\/(?:agent\/)?skills\/([^/]+)\.md$/);
33+
return single ? single[1] : null;
34+
}

web/src/lib/use-agent.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export interface ActivityItem {
1818
timestamp: number;
1919
/** Raw tool name (e.g. "bash", "write") for icon + summary rendering. */
2020
toolName?: string;
21+
/** Frontmatter skill name when this read is a skill activation (server-resolved). */
22+
skillName?: string;
2123
/** Tool arguments captured from tool_start (e.g. the bash command). */
2224
args?: unknown;
2325
/** Tool result text captured from tool_end (truncated server-side). */
@@ -70,6 +72,8 @@ export interface AgentFrame {
7072
type: string;
7173
delta?: string;
7274
toolName?: string;
75+
/** Frontmatter skill name attached to tool_start when the read is a skill activation. */
76+
skill?: string;
7377
toolCallId?: string;
7478
isError?: boolean;
7579
message?: string;
@@ -123,6 +127,7 @@ export function applyFrameToMessage(
123127
status: "running" as const,
124128
timestamp: now,
125129
toolName: frame.toolName ? String(frame.toolName) : undefined,
130+
skillName: typeof frame.skill === "string" ? frame.skill : undefined,
126131
args: frame.args,
127132
},
128133
].slice(-MAX_ACTIVITY_ITEMS),

0 commit comments

Comments
 (0)