pi-open-agents 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Child system prompt construction for subagents.
3
+ *
4
+ * Builds the system prompt that will be passed to the child pi process.
5
+ * Includes the agent body and optional skill injection.
6
+ */
7
+
8
+ import type { AgentDefinition } from "../types.ts";
9
+ import { escapeXml } from "../utils/format.ts";
10
+ import { resolveSkills, type ResolveSkillsResult } from "./skills.ts";
11
+
12
+ export interface BuildPromptOptions {
13
+ agent: AgentDefinition;
14
+ cwd: string;
15
+ agentDir?: string;
16
+ }
17
+
18
+ export interface BuildPromptResult {
19
+ prompt: string;
20
+ skills: ResolveSkillsResult;
21
+ }
22
+
23
+ /**
24
+ * Format resolved skills as an XML block for injection into the system prompt.
25
+ */
26
+ function formatSkillsForPrompt(
27
+ skills: Array<{ name: string; description: string; location: string }>,
28
+ ): string {
29
+ if (skills.length === 0) return "";
30
+
31
+ const lines = [
32
+ "\n\nThe following skills provide specialized instructions for specific tasks.",
33
+ "Use the read tool to load a skill's file when the task matches its description.",
34
+ "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
35
+ "",
36
+ "<available_skills>",
37
+ ];
38
+
39
+ for (const skill of skills) {
40
+ lines.push(" <skill>");
41
+ lines.push(` <name>${escapeXml(skill.name)}</name>`);
42
+ lines.push(` <description>${escapeXml(skill.description)}</description>`);
43
+ lines.push(` <location>${escapeXml(skill.location)}</location>`);
44
+ lines.push(" </skill>");
45
+ }
46
+
47
+ lines.push("</available_skills>");
48
+ return lines.join("\n");
49
+ }
50
+
51
+ /**
52
+ * Build the system prompt for a subagent.
53
+ *
54
+ * Starts with the agent body, appends skill injection if the agent has skills.
55
+ */
56
+ export async function buildSubagentPrompt(options: BuildPromptOptions): Promise<BuildPromptResult> {
57
+ let prompt = options.agent.prompt;
58
+
59
+ let skillsResult: ResolveSkillsResult = {
60
+ resolved: [],
61
+ missing: [],
62
+ skippedPackages: [],
63
+ warnings: [],
64
+ };
65
+
66
+ if (options.agent.skills && options.agent.skills.length > 0) {
67
+ skillsResult = await resolveSkills(options.agent.skills, {
68
+ cwd: options.cwd,
69
+ agentDir: options.agentDir,
70
+ });
71
+
72
+ const injection = formatSkillsForPrompt(skillsResult.resolved);
73
+ if (injection) {
74
+ prompt = `${prompt}${injection}`;
75
+ }
76
+ }
77
+
78
+ return { prompt, skills: skillsResult };
79
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * TUI rendering for subagent results.
3
+ *
4
+ * Provides collapsed and expanded views showing:
5
+ * - Status line (✓/✗ agent — N tools · Xs)
6
+ * - Tool call list
7
+ * - Output summary (collapsed) or full output (expanded)
8
+ * - Usage stats
9
+ */
10
+
11
+ import type { AgentProgress, AgentResult } from "./executor.ts";
12
+ import { numberArg, preview, shortenPath, stringArg, formatTokens } from "../utils/format.ts";
13
+
14
+ // ─── Types ───────────────────────────────────────────────────────────────────
15
+
16
+ export interface RenderOptions {
17
+ expanded: boolean;
18
+ suppressOutput?: boolean;
19
+ suppressUsage?: boolean;
20
+ expandHint?: string;
21
+ }
22
+
23
+ export type ResultLineKind = "status" | "tool" | "hint" | "usage" | "output" | "blank";
24
+
25
+ export interface ResultLine {
26
+ text: string;
27
+ kind: ResultLineKind;
28
+ singleLine: boolean;
29
+ tool?: {
30
+ name: string;
31
+ args: Record<string, unknown>;
32
+ status: "running" | "done" | "error";
33
+ };
34
+ }
35
+
36
+ export type ContextUsageSeverity = "dim" | "warning" | "error";
37
+
38
+ const TOOL_LOG_LIMIT = 20;
39
+
40
+ // ─── Usage Formatting ────────────────────────────────────────────────────────
41
+
42
+ export function contextUsageSeverity(usage: {
43
+ contextTokens?: number;
44
+ contextWindow?: number;
45
+ }): ContextUsageSeverity {
46
+ if (!usage.contextWindow || usage.contextWindow <= 0) return "dim";
47
+ const percent = (usage.contextTokens ?? 0) / usage.contextWindow;
48
+ if (percent >= 0.9) return "error";
49
+ if (percent >= 0.7) return "warning";
50
+ return "dim";
51
+ }
52
+
53
+ function elapsedSeconds(ms: number): number {
54
+ return Math.round(ms / 1000);
55
+ }
56
+
57
+ export function formatUsage(progress: AgentProgress): string {
58
+ const usage = progress.usage;
59
+ const parts: string[] = [];
60
+
61
+ if (usage.contextWindow && usage.contextWindow > 0) {
62
+ const percent = ((usage.contextTokens / usage.contextWindow) * 100).toFixed(1);
63
+ parts.push(`${percent}%/${formatTokens(usage.contextWindow)}`);
64
+ }
65
+
66
+ parts.push(`↑${formatTokens(usage.input)}`);
67
+ parts.push(`↓${formatTokens(usage.output)}`);
68
+ parts.push(`R${formatTokens(usage.cacheRead)}`);
69
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
70
+ parts.push(`$${usage.cost.toFixed(3)}`);
71
+
72
+ return parts.join(" ");
73
+ }
74
+
75
+ // ─── Tool Title Formatting ───────────────────────────────────────────────────
76
+
77
+ function pathArg(args: Record<string, unknown>, fallback?: string): string {
78
+ return shortenPath(args.path ?? args.file_path) ?? fallback ?? "...";
79
+ }
80
+
81
+ function lineRange(args: Record<string, unknown>): string {
82
+ if (args.offset === undefined && args.limit === undefined) return "";
83
+ const startLine = numberArg(args, "offset") ?? 1;
84
+ const limit = numberArg(args, "limit");
85
+ const endLine = limit !== undefined ? startLine + limit - 1 : undefined;
86
+ return `:${startLine}${endLine !== undefined ? `-${endLine}` : ""}`;
87
+ }
88
+
89
+ function limitSuffix(args: Record<string, unknown>): string {
90
+ const limit = numberArg(args, "limit");
91
+ return limit !== undefined ? ` (limit ${limit})` : "";
92
+ }
93
+
94
+ function formatToolTitle(name: string, args: Record<string, unknown>): string {
95
+ switch (name) {
96
+ case "read":
97
+ return `read ${pathArg(args)}${lineRange(args)}`;
98
+ case "bash": {
99
+ const command = stringArg(args, "command") ?? "";
100
+ const timeout = numberArg(args, "timeout");
101
+ return `$ ${command}${timeout !== undefined ? ` (timeout ${timeout}s)` : ""}`;
102
+ }
103
+ case "edit":
104
+ return `edit ${pathArg(args)}`;
105
+ case "write":
106
+ return `write ${pathArg(args)}`;
107
+ case "find": {
108
+ const pattern = stringArg(args, "pattern") ?? "";
109
+ return `find ${pattern} in ${pathArg(args, ".")}${limitSuffix(args)}`;
110
+ }
111
+ case "grep": {
112
+ const pattern = stringArg(args, "pattern") ?? "";
113
+ const glob = stringArg(args, "glob");
114
+ const globSuffix = glob ? ` (${glob})` : "";
115
+ const limit = numberArg(args, "limit");
116
+ const limitText = limit !== undefined ? ` limit ${limit}` : "";
117
+ return `grep /${pattern}/ in ${pathArg(args, ".")}${globSuffix}${limitText}`;
118
+ }
119
+ case "ls":
120
+ return `ls ${pathArg(args, ".")}${limitSuffix(args)}`;
121
+ case "webfetch": {
122
+ const url = stringArg(args, "url") ?? "";
123
+ const mode = stringArg(args, "mode");
124
+ return `webfetch ${url}${mode ? ` (${mode})` : ""}`.trimEnd();
125
+ }
126
+ case "subagent": {
127
+ const agent = stringArg(args, "agent");
128
+ const task = stringArg(args, "task");
129
+ const taskPreview = task ? ` ${JSON.stringify(preview(task, 60))}` : "";
130
+ return `${name} ${agent ?? ""}${taskPreview}`.trimEnd();
131
+ }
132
+ default: {
133
+ const values = Object.values(args).filter((v) => typeof v === "string") as string[];
134
+ return `${name}${values.length > 0 ? ` ${JSON.stringify(preview(values[0], 80))}` : ""}`;
135
+ }
136
+ }
137
+ }
138
+
139
+ // ─── Line Formatting ─────────────────────────────────────────────────────────
140
+
141
+ function indent(text: string, spaces: number): string {
142
+ const prefix = " ".repeat(spaces);
143
+ return text
144
+ .split("\n")
145
+ .map((line) => `${prefix}${line}`)
146
+ .join("\n");
147
+ }
148
+
149
+ function summaryText(markdown: string): string {
150
+ return markdown.split("\n").slice(0, 20).join("\n");
151
+ }
152
+
153
+ function formatToolLineItems(
154
+ progress: AgentProgress,
155
+ options: RenderOptions,
156
+ ): ResultLine[] {
157
+ const lines: ResultLine[] = [];
158
+ const hiddenCount = options.expanded ? 0 : Math.max(0, progress.tools.length - TOOL_LOG_LIMIT);
159
+ const visibleTools = progress.tools.slice(hiddenCount);
160
+
161
+ if (hiddenCount > 0) {
162
+ const expandHint = options.expandHint ?? "to expand";
163
+ lines.push({
164
+ text: ` ... (${hiddenCount} earlier tool calls, ${expandHint})`,
165
+ kind: "hint",
166
+ singleLine: true,
167
+ });
168
+ }
169
+
170
+ for (const tool of visibleTools) {
171
+ lines.push({
172
+ text: `${tool.status === "running" ? "▸" : " "} ${formatToolTitle(tool.name, tool.args)}`,
173
+ kind: "tool",
174
+ singleLine: true,
175
+ tool: { name: tool.name, args: tool.args, status: tool.status },
176
+ });
177
+ if ((options.expanded || progress.status === "running") && tool.nested) {
178
+ for (const line of formatResultLines(tool.nested, {
179
+ expanded: false,
180
+ suppressOutput: true,
181
+ })) {
182
+ lines.push({ ...line, text: indent(line.text, 2) });
183
+ }
184
+ }
185
+ }
186
+
187
+ return lines;
188
+ }
189
+
190
+ export function formatSubagentCall(args: {
191
+ agent?: string;
192
+ task?: string;
193
+ }, options: Partial<RenderOptions> = {}): string {
194
+ if (options.expanded) return `subagent ${args.agent ?? "..."}\n${args.task ?? "..."}`;
195
+ return `subagent ${args.agent ?? "..."} ${preview(args.task ?? "...", 60)}`;
196
+ }
197
+
198
+ export function formatResultLines(
199
+ progress: AgentProgress,
200
+ options: RenderOptions,
201
+ ): ResultLine[] {
202
+ const icon = progress.status === "error" ? "✗" : progress.status === "done" ? "✓" : "▸";
203
+ const sessionBadge = progress.session?.effective === "fork" ? " [fork]" : "";
204
+ const statusLine = `${icon} ${progress.agent}${sessionBadge}${progress.model ? ` (${progress.model})` : ""} — ${progress.tools.length} tools · ${elapsedSeconds(progress.elapsedMs)}s`;
205
+ const toolLines = formatToolLineItems(progress, options);
206
+ const usage = formatUsage(progress);
207
+
208
+ const lines: ResultLine[] = [
209
+ { text: "", kind: "blank", singleLine: false },
210
+ { text: statusLine, kind: "status", singleLine: false },
211
+ ...(progress.session?.warning
212
+ ? [{ text: `session: ${progress.session.warning}`, kind: "hint" as const, singleLine: true }]
213
+ : []),
214
+ ...toolLines,
215
+ ];
216
+
217
+ if (progress.status === "running" || options.suppressOutput) {
218
+ if (!options.suppressUsage) {
219
+ lines.push({ text: "", kind: "blank", singleLine: false });
220
+ lines.push({ text: usage, kind: "usage", singleLine: false });
221
+ }
222
+ return lines;
223
+ }
224
+
225
+ const output = options.expanded
226
+ ? progress.output || "(no output)"
227
+ : summaryText(progress.output) || "(no output)";
228
+
229
+ lines.push({ text: "", kind: "blank", singleLine: false });
230
+ lines.push({ text: output, kind: "output", singleLine: false });
231
+
232
+ if (!options.expanded) {
233
+ const totalLines = (progress.output || "").split("\n").length;
234
+ if (totalLines > 20) {
235
+ const hidden = totalLines - 20;
236
+ const expandHint = options.expandHint ?? "to expand";
237
+ lines.push({
238
+ text: `... (${hidden} more lines, ${expandHint})`,
239
+ kind: "hint",
240
+ singleLine: true,
241
+ });
242
+ }
243
+ }
244
+
245
+ if (!options.suppressUsage) {
246
+ lines.push({ text: "", kind: "blank", singleLine: false });
247
+ lines.push({ text: usage, kind: "usage", singleLine: false });
248
+ }
249
+
250
+ return lines;
251
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Per-agent skill resolution with wildcard support.
3
+ *
4
+ * Collects skills from multiple directories and resolves them against
5
+ * an agent's skill list (which may include wildcards like "lark-*").
6
+ */
7
+
8
+ import {
9
+ DefaultPackageManager,
10
+ SettingsManager,
11
+ getAgentDir,
12
+ type ResolvedResource,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ import { parseFrontmatter } from "../config/frontmatter.ts";
18
+
19
+ export interface ResolvedSkill {
20
+ name: string;
21
+ description: string;
22
+ location: string;
23
+ }
24
+
25
+ export interface ResolveSkillsOptions {
26
+ cwd: string;
27
+ agentDir?: string;
28
+ globalDir?: string;
29
+ fs?: SkillResolverFs;
30
+ packageSkillFiles?: string[];
31
+ }
32
+
33
+ export interface ResolveSkillsResult {
34
+ resolved: ResolvedSkill[];
35
+ missing: string[];
36
+ skippedPackages: string[];
37
+ warnings: string[];
38
+ }
39
+
40
+ export interface SkillResolverFs {
41
+ exists(filePath: string): boolean;
42
+ readFile(filePath: string): string;
43
+ listFiles?(dir: string): string[];
44
+ }
45
+
46
+ // ─── Default FS ──────────────────────────────────────────────────────────────
47
+
48
+ const defaultSkillFs: SkillResolverFs = {
49
+ exists(filePath) {
50
+ return fs.existsSync(filePath);
51
+ },
52
+ readFile(filePath) {
53
+ return fs.readFileSync(filePath, "utf-8");
54
+ },
55
+ listFiles(dir) {
56
+ try {
57
+ return fs
58
+ .readdirSync(dir, { withFileTypes: true })
59
+ .map((entry) => path.join(dir, entry.name))
60
+ .sort();
61
+ } catch {
62
+ return [];
63
+ }
64
+ },
65
+ };
66
+
67
+ function defaultGlobalSkillsDir(): string {
68
+ return path.join(os.homedir(), ".pi", "agent", "skills");
69
+ }
70
+
71
+ // ─── Skill File Reading ──────────────────────────────────────────────────────
72
+
73
+ interface ReadSkillResult {
74
+ skill?: ResolvedSkill;
75
+ warning?: string;
76
+ }
77
+
78
+ function readSkill(filePath: string, fileSystem: SkillResolverFs): ReadSkillResult | undefined {
79
+ const content = fileSystem.readFile(filePath);
80
+ const { data } = parseFrontmatter(content);
81
+
82
+ const name = typeof data.name === "string" ? data.name.trim() : "";
83
+ const description = typeof data.description === "string" ? data.description.trim() : "";
84
+
85
+ if (!name) {
86
+ return { warning: `skill missing required field: name: ${filePath}` };
87
+ }
88
+ if (!description) return undefined;
89
+
90
+ return {
91
+ skill: { name, description, location: filePath },
92
+ };
93
+ }
94
+
95
+ // ─── Collection ──────────────────────────────────────────────────────────────
96
+
97
+ interface CollectedSkills {
98
+ byName: Map<string, ResolvedSkill>;
99
+ warnings: string[];
100
+ skippedPackages: string[];
101
+ }
102
+
103
+ const collectedSkillsCache = new Map<string, Promise<CollectedSkills>>();
104
+
105
+ function listSkillFilesInDir(dir: string, fileSystem: SkillResolverFs): string[] {
106
+ if (!fileSystem.listFiles) return [];
107
+ return fileSystem
108
+ .listFiles(dir)
109
+ .map((entry) => path.join(entry, "SKILL.md"))
110
+ .filter((filePath) => fileSystem.exists(filePath));
111
+ }
112
+
113
+ function addSkillFile(
114
+ filePath: string,
115
+ fileSystem: SkillResolverFs,
116
+ byName: Map<string, ResolvedSkill>,
117
+ warnings: string[],
118
+ ): void {
119
+ try {
120
+ const result = readSkill(filePath, fileSystem);
121
+ if (!result) return;
122
+ if (result.warning) warnings.push(result.warning);
123
+ if (result.skill && !byName.has(result.skill.name)) {
124
+ byName.set(result.skill.name, result.skill);
125
+ }
126
+ } catch (error) {
127
+ warnings.push(
128
+ `skill could not be read: ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
129
+ );
130
+ }
131
+ }
132
+
133
+ async function resolvePackageSkillFiles(
134
+ options: ResolveSkillsOptions,
135
+ ): Promise<{ files: string[]; skippedPackages: string[] }> {
136
+ if (options.packageSkillFiles) {
137
+ return { files: options.packageSkillFiles, skippedPackages: [] };
138
+ }
139
+ if (options.fs) {
140
+ return { files: [], skippedPackages: [] };
141
+ }
142
+
143
+ const skippedPackages: string[] = [];
144
+ const agentDir = options.agentDir ?? getAgentDir();
145
+ const settingsManager = SettingsManager.create(options.cwd, agentDir);
146
+ const packageManager = new DefaultPackageManager({
147
+ cwd: options.cwd,
148
+ agentDir,
149
+ settingsManager,
150
+ });
151
+ const resolvedPaths = await packageManager.resolve(async (source) => {
152
+ skippedPackages.push(source);
153
+ return "skip";
154
+ });
155
+
156
+ return {
157
+ files: resolvedPaths.skills
158
+ .filter((resource: ResolvedResource) => resource.enabled)
159
+ .map((resource: ResolvedResource) => resource.path),
160
+ skippedPackages,
161
+ };
162
+ }
163
+
164
+ async function collectSkills(options: ResolveSkillsOptions): Promise<CollectedSkills> {
165
+ const cwd = options.cwd;
166
+ const globalDir = options.globalDir ?? defaultGlobalSkillsDir();
167
+ const fileSystem = options.fs ?? defaultSkillFs;
168
+ const byName = new Map<string, ResolvedSkill>();
169
+ const warnings: string[] = [];
170
+
171
+ for (const dir of [
172
+ path.join(cwd, ".agents", "skills"),
173
+ path.join(cwd, ".pi", "skills"),
174
+ globalDir,
175
+ path.join(os.homedir(), ".agents", "skills"),
176
+ ]) {
177
+ for (const filePath of listSkillFilesInDir(dir, fileSystem)) {
178
+ addSkillFile(filePath, fileSystem, byName, warnings);
179
+ }
180
+ }
181
+
182
+ const packageSkills = await resolvePackageSkillFiles(options);
183
+ for (const filePath of packageSkills.files) {
184
+ addSkillFile(filePath, fileSystem, byName, warnings);
185
+ }
186
+
187
+ return { byName, warnings, skippedPackages: packageSkills.skippedPackages };
188
+ }
189
+
190
+ function skillsCacheKey(options: ResolveSkillsOptions): string | undefined {
191
+ if (options.fs) return undefined;
192
+ return JSON.stringify({
193
+ cwd: options.cwd,
194
+ agentDir: options.agentDir,
195
+ globalDir: options.globalDir ?? defaultGlobalSkillsDir(),
196
+ packageSkillFiles: options.packageSkillFiles,
197
+ });
198
+ }
199
+
200
+ async function getCollectedSkills(options: ResolveSkillsOptions): Promise<CollectedSkills> {
201
+ const cacheKey = skillsCacheKey(options);
202
+ if (!cacheKey) return collectSkills(options);
203
+
204
+ let cached = collectedSkillsCache.get(cacheKey);
205
+ if (!cached) {
206
+ cached = collectSkills(options);
207
+ collectedSkillsCache.set(cacheKey, cached);
208
+ }
209
+ return cached;
210
+ }
211
+
212
+ // ─── Wildcard Matching ───────────────────────────────────────────────────────
213
+
214
+ function wildcardToRegex(pattern: string): RegExp {
215
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
216
+ return new RegExp(`^${escaped}$`);
217
+ }
218
+
219
+ function hasWildcard(pattern: string): boolean {
220
+ return pattern.includes("*");
221
+ }
222
+
223
+ // ─── Public API ──────────────────────────────────────────────────────────────
224
+
225
+ /**
226
+ * Resolve an agent's skill list into concrete skill definitions.
227
+ *
228
+ * Supports exact names and wildcards (e.g. "lark-*", "github").
229
+ */
230
+ export async function resolveSkills(
231
+ skillNames: string[],
232
+ options: ResolveSkillsOptions,
233
+ ): Promise<ResolveSkillsResult> {
234
+ const requestedNames = skillNames.map((name) => name.trim()).filter(Boolean);
235
+ if (requestedNames.length === 0) {
236
+ return { resolved: [], missing: [], skippedPackages: [], warnings: [] };
237
+ }
238
+
239
+ const collected = await getCollectedSkills(options);
240
+ const resolved: ResolvedSkill[] = [];
241
+ const missing: string[] = [];
242
+ const seen = new Set<string>();
243
+
244
+ function addSkill(skill: ResolvedSkill): void {
245
+ if (seen.has(skill.name)) return;
246
+ seen.add(skill.name);
247
+ resolved.push(skill);
248
+ }
249
+
250
+ for (const requestedName of requestedNames) {
251
+ if (hasWildcard(requestedName)) {
252
+ const regex = wildcardToRegex(requestedName);
253
+ let matched = false;
254
+ for (const skill of collected.byName.values()) {
255
+ if (!regex.test(skill.name)) continue;
256
+ matched = true;
257
+ addSkill(skill);
258
+ }
259
+ if (!matched) missing.push(requestedName);
260
+ continue;
261
+ }
262
+
263
+ const skill = collected.byName.get(requestedName);
264
+ if (skill) addSkill(skill);
265
+ else missing.push(requestedName);
266
+ }
267
+
268
+ return {
269
+ resolved,
270
+ missing,
271
+ skippedPackages: collected.skippedPackages,
272
+ warnings: collected.warnings,
273
+ };
274
+ }