@introspection-ai/pi-recipes 0.1.0-beta.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.
- package/README.md +116 -0
- package/dist/child-agent.d.ts +40 -0
- package/dist/child-agent.js +235 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +479 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/pi-extension.d.ts +26 -0
- package/dist/pi-extension.js +809 -0
- package/dist/recipe-agent.d.ts +51 -0
- package/dist/recipe-agent.js +359 -0
- package/dist/recipe-dev.d.ts +30 -0
- package/dist/recipe-dev.js +180 -0
- package/dist/recipe-package.d.ts +38 -0
- package/dist/recipe-package.js +231 -0
- package/dist/recipe-publish.d.ts +30 -0
- package/dist/recipe-publish.js +192 -0
- package/dist/recipe-store.d.ts +66 -0
- package/dist/recipe-store.js +691 -0
- package/docs/pi-extension.md +413 -0
- package/docs/recipe-cli.md +529 -0
- package/docs/recipe-flow.md +148 -0
- package/package.json +92 -0
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { basename, extname, join, relative } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { defineTool, } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
import { createRecipeChildAgentRunner, promptResultText, } from "./child-agent.js";
|
|
9
|
+
import { loadRecipeAgentDefinitions, loadRecipeSystemPrompt, resolveRecipeAgentDefinition, validateRecipeAgentDefinitions, } from "./recipe-agent.js";
|
|
10
|
+
import { packageResourcePaths, readPiPackageManifest, validatePiPackageManifest, } from "./recipe-package.js";
|
|
11
|
+
import { resolveRecipeDirectory } from "./recipe-store.js";
|
|
12
|
+
const RunRecipeAgentParams = Type.Object({
|
|
13
|
+
action: Type.Optional(Type.Union([
|
|
14
|
+
Type.Literal("start"),
|
|
15
|
+
Type.Literal("status"),
|
|
16
|
+
Type.Literal("wait"),
|
|
17
|
+
Type.Literal("interrupt"),
|
|
18
|
+
Type.Literal("close"),
|
|
19
|
+
])),
|
|
20
|
+
id: Type.Optional(Type.String()),
|
|
21
|
+
name: Type.Optional(Type.String()),
|
|
22
|
+
task: Type.Optional(Type.String()),
|
|
23
|
+
label: Type.Optional(Type.String()),
|
|
24
|
+
wait: Type.Optional(Type.Boolean()),
|
|
25
|
+
});
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
class RecipeLaunchError extends Error {
|
|
28
|
+
constructor(message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "RecipeLaunchError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function stringFlag(value) {
|
|
34
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
35
|
+
}
|
|
36
|
+
function isPathLikeRecipeInput(input) {
|
|
37
|
+
return (input.startsWith("/") ||
|
|
38
|
+
input.startsWith(".") ||
|
|
39
|
+
input.startsWith("~") ||
|
|
40
|
+
input.includes("/"));
|
|
41
|
+
}
|
|
42
|
+
function recipeNotFoundMessage(input, resolvedPath) {
|
|
43
|
+
const lines = [`Recipe "${input}" was not found.`];
|
|
44
|
+
if (isPathLikeRecipeInput(input)) {
|
|
45
|
+
lines.push(`Resolved path: ${resolvedPath}`);
|
|
46
|
+
lines.push("Make sure that directory exists and contains package.json with a pi block.");
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
lines.push(`No installed recipe matched "${input}", and no local directory exists at: ${resolvedPath}`);
|
|
50
|
+
lines.push("Run `recipes list` to see installed recipes, or `recipes install <source>` first.");
|
|
51
|
+
}
|
|
52
|
+
lines.push("Then launch again with `pi --recipe <recipe>`.");
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
|
55
|
+
function recipeLoadErrorMessage(input, reason) {
|
|
56
|
+
return [
|
|
57
|
+
`Recipe "${input}" could not be loaded.`,
|
|
58
|
+
reason,
|
|
59
|
+
"Run `recipes doctor <recipe>` for a validation report.",
|
|
60
|
+
].join("\n");
|
|
61
|
+
}
|
|
62
|
+
function modelParts(spec) {
|
|
63
|
+
const index = spec.indexOf("/");
|
|
64
|
+
if (index < 0) {
|
|
65
|
+
throw new Error(`Invalid recipe model "${spec}" - expected "<provider>/<model_id>"`);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
provider: spec.slice(0, index),
|
|
69
|
+
model: spec.slice(index + 1),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function applySystemInstructions(base, instructions) {
|
|
73
|
+
if (!instructions)
|
|
74
|
+
return base;
|
|
75
|
+
if (instructions.mode === "replace")
|
|
76
|
+
return instructions.content;
|
|
77
|
+
return [base, instructions.content].filter(Boolean).join("\n\n");
|
|
78
|
+
}
|
|
79
|
+
function runtimeContextPrompt(base, state) {
|
|
80
|
+
return [
|
|
81
|
+
base,
|
|
82
|
+
[
|
|
83
|
+
"## Recipe Runtime Context",
|
|
84
|
+
"- Current workspace: " + state.cwd,
|
|
85
|
+
"- Recipe directory: " + state.recipeDir,
|
|
86
|
+
].join("\n"),
|
|
87
|
+
].filter(Boolean).join("\n\n");
|
|
88
|
+
}
|
|
89
|
+
function visibleSubagents(state) {
|
|
90
|
+
const definitions = loadRecipeAgentDefinitions(state.recipeDir);
|
|
91
|
+
const names = state.agent.subagentsDeclared
|
|
92
|
+
? state.agent.subagents
|
|
93
|
+
: [...new Set([...definitions.values()].map((agent) => agent.name))]
|
|
94
|
+
.filter((name) => name !== state.agentName);
|
|
95
|
+
return names
|
|
96
|
+
.map((name) => definitions.get(name))
|
|
97
|
+
.filter((agent) => Boolean(agent));
|
|
98
|
+
}
|
|
99
|
+
function textResult(text, details) {
|
|
100
|
+
return {
|
|
101
|
+
content: [{ type: "text", text }],
|
|
102
|
+
details,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function asRecord(value) {
|
|
106
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
107
|
+
? value
|
|
108
|
+
: null;
|
|
109
|
+
}
|
|
110
|
+
function contentText(content) {
|
|
111
|
+
if (typeof content === "string")
|
|
112
|
+
return content;
|
|
113
|
+
if (!Array.isArray(content))
|
|
114
|
+
return "";
|
|
115
|
+
return content
|
|
116
|
+
.map((part) => {
|
|
117
|
+
const record = asRecord(part);
|
|
118
|
+
return record?.type === "text" && typeof record.text === "string"
|
|
119
|
+
? record.text
|
|
120
|
+
: "";
|
|
121
|
+
})
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.join("\n");
|
|
124
|
+
}
|
|
125
|
+
function resultText(result) {
|
|
126
|
+
const record = asRecord(result);
|
|
127
|
+
return contentText(record?.content).trim();
|
|
128
|
+
}
|
|
129
|
+
function themeFg(theme, name, text) {
|
|
130
|
+
return theme?.fg ? theme.fg(name, text) : text;
|
|
131
|
+
}
|
|
132
|
+
function themeBold(theme, text) {
|
|
133
|
+
return theme?.bold ? theme.bold(text) : text;
|
|
134
|
+
}
|
|
135
|
+
function truncateLine(text, max = 120) {
|
|
136
|
+
const singleLine = text.replace(/\s+/g, " ").trim();
|
|
137
|
+
return singleLine.length > max ? `${singleLine.slice(0, max - 3)}...` : singleLine;
|
|
138
|
+
}
|
|
139
|
+
function toolArgText(args, keys) {
|
|
140
|
+
const record = asRecord(args);
|
|
141
|
+
for (const key of keys) {
|
|
142
|
+
const value = record?.[key];
|
|
143
|
+
if (typeof value === "string" && value.trim())
|
|
144
|
+
return value.trim();
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
function describeChildToolCall(call) {
|
|
149
|
+
const target = toolArgText(call.args, ["path", "file_path"]) ??
|
|
150
|
+
toolArgText(call.args, ["command"]) ??
|
|
151
|
+
toolArgText(call.args, ["pattern", "query"]);
|
|
152
|
+
return target ? `${call.name} ${truncateLine(target, 80)}` : call.name;
|
|
153
|
+
}
|
|
154
|
+
function childToolStatusText(status) {
|
|
155
|
+
if (status === "running")
|
|
156
|
+
return "running";
|
|
157
|
+
if (status === "failed")
|
|
158
|
+
return "failed";
|
|
159
|
+
return "done";
|
|
160
|
+
}
|
|
161
|
+
function formatRecipeAgentCall(args, theme) {
|
|
162
|
+
const action = args.action ?? "start";
|
|
163
|
+
const agent = args.name ?? args.id ?? "agent";
|
|
164
|
+
const label = args.label ? ` ${themeFg(theme, "muted", `(${args.label})`)}` : "";
|
|
165
|
+
return `${themeFg(theme, "toolTitle", themeBold(theme, `agent ${action}`))} ${themeFg(theme, "accent", agent)}${label}`;
|
|
166
|
+
}
|
|
167
|
+
function formatRecipeAgentResult(details, text, options, theme) {
|
|
168
|
+
if (!details?.id)
|
|
169
|
+
return text;
|
|
170
|
+
const lines = [
|
|
171
|
+
themeFg(theme, details.error ? "warning" : "muted", `Status: ${details.status ?? (options.isPartial ? "running" : "completed")}`),
|
|
172
|
+
];
|
|
173
|
+
if (details.tool_calls?.length) {
|
|
174
|
+
lines.push("", themeFg(theme, "muted", "Tool calls:"));
|
|
175
|
+
for (const call of details.tool_calls) {
|
|
176
|
+
const label = describeChildToolCall(call);
|
|
177
|
+
const status = childToolStatusText(call.status);
|
|
178
|
+
lines.push(` - ${themeFg(theme, call.status === "failed" ? "warning" : "toolOutput", label)} ${themeFg(theme, "muted", `[${status}]`)}`);
|
|
179
|
+
if (options.expanded && call.output) {
|
|
180
|
+
lines.push(` ${themeFg(theme, "toolOutput", truncateLine(call.output))}`);
|
|
181
|
+
}
|
|
182
|
+
if (call.error) {
|
|
183
|
+
lines.push(` ${themeFg(theme, "warning", truncateLine(call.error))}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (details.error) {
|
|
188
|
+
lines.push("", themeFg(theme, "warning", `Error: ${details.error}`));
|
|
189
|
+
}
|
|
190
|
+
else if (details.output?.trim()) {
|
|
191
|
+
lines.push("", themeFg(theme, "muted", "Output:"), themeFg(theme, "toolOutput", details.output.trim()));
|
|
192
|
+
}
|
|
193
|
+
else if (!options.isPartial && text.trim()) {
|
|
194
|
+
lines.push("", themeFg(theme, "toolOutput", text.trim()));
|
|
195
|
+
}
|
|
196
|
+
return lines.join("\n");
|
|
197
|
+
}
|
|
198
|
+
function describeRun(run) {
|
|
199
|
+
const suffix = run.error ? `: ${run.error}` : run.output ? `: ${run.output}` : "";
|
|
200
|
+
return `${run.id} ${run.agent}: ${run.status}${suffix}`;
|
|
201
|
+
}
|
|
202
|
+
function runDetails(run, action = "status") {
|
|
203
|
+
return {
|
|
204
|
+
action,
|
|
205
|
+
id: run.id,
|
|
206
|
+
agent: run.agent,
|
|
207
|
+
label: run.label,
|
|
208
|
+
task: run.task,
|
|
209
|
+
status: run.status,
|
|
210
|
+
startedAt: run.startedAt,
|
|
211
|
+
completedAt: run.completedAt,
|
|
212
|
+
output: run.output,
|
|
213
|
+
error: run.error,
|
|
214
|
+
tool_calls: run.toolCalls,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function runBlock(run) {
|
|
218
|
+
const lines = [
|
|
219
|
+
`Recipe agent: ${run.agent}`,
|
|
220
|
+
`Run: ${run.id}`,
|
|
221
|
+
run.label ? `Label: ${run.label}` : undefined,
|
|
222
|
+
`Status: ${run.status}`,
|
|
223
|
+
"",
|
|
224
|
+
"Prompt:",
|
|
225
|
+
run.task,
|
|
226
|
+
"",
|
|
227
|
+
"Output:",
|
|
228
|
+
run.error ? `Error: ${run.error}` : run.output?.trim() || "(waiting for output...)",
|
|
229
|
+
].filter((line) => line !== undefined);
|
|
230
|
+
return lines.join("\n");
|
|
231
|
+
}
|
|
232
|
+
function nameList(names) {
|
|
233
|
+
return names.length > 0 ? names.join(", ") : "(none)";
|
|
234
|
+
}
|
|
235
|
+
function bulletList(items) {
|
|
236
|
+
return items.length > 0 ? items.map((item) => ` - ${item}`) : [" - none"];
|
|
237
|
+
}
|
|
238
|
+
function activeRecipeTools(state, activeTools) {
|
|
239
|
+
const active = new Set(activeTools);
|
|
240
|
+
const recipeTools = new Set(state.agent.tools);
|
|
241
|
+
if (visibleSubagents(state).length > 0)
|
|
242
|
+
recipeTools.add("agent");
|
|
243
|
+
return [...recipeTools]
|
|
244
|
+
.filter((tool) => active.has(tool))
|
|
245
|
+
.sort();
|
|
246
|
+
}
|
|
247
|
+
function normalizeSelector(value) {
|
|
248
|
+
return value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\.[^/.]+$/, "");
|
|
249
|
+
}
|
|
250
|
+
function extensionSelectorSet(recipeDir, extensionPath) {
|
|
251
|
+
const relativePath = relative(recipeDir, extensionPath).replace(/\\/g, "/");
|
|
252
|
+
const withoutExtension = normalizeSelector(relativePath);
|
|
253
|
+
const base = basename(extensionPath, extname(extensionPath));
|
|
254
|
+
const parts = withoutExtension.split("/");
|
|
255
|
+
const parent = parts.length > 1 ? parts[parts.length - 2] : undefined;
|
|
256
|
+
return new Set([
|
|
257
|
+
relativePath,
|
|
258
|
+
withoutExtension,
|
|
259
|
+
base,
|
|
260
|
+
parent && base === "index" ? parent : undefined,
|
|
261
|
+
].filter((value) => Boolean(value)));
|
|
262
|
+
}
|
|
263
|
+
function extensionSelectorMatches(recipeDir, extensionPath, selector) {
|
|
264
|
+
const normalized = normalizeSelector(selector.trim());
|
|
265
|
+
if (!normalized)
|
|
266
|
+
return false;
|
|
267
|
+
if (normalized === "*")
|
|
268
|
+
return true;
|
|
269
|
+
return extensionSelectorSet(recipeDir, extensionPath).has(normalized);
|
|
270
|
+
}
|
|
271
|
+
function filterExtensionPaths(recipeDir, extensionPaths, agent) {
|
|
272
|
+
const include = agent.extensions?.include;
|
|
273
|
+
const exclude = agent.extensions?.exclude ?? [];
|
|
274
|
+
return extensionPaths.filter((extensionPath) => {
|
|
275
|
+
const included = include === undefined
|
|
276
|
+
? true
|
|
277
|
+
: include.some((selector) => extensionSelectorMatches(recipeDir, extensionPath, selector));
|
|
278
|
+
if (!included)
|
|
279
|
+
return false;
|
|
280
|
+
return !exclude.some((selector) => extensionSelectorMatches(recipeDir, extensionPath, selector));
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
function recipeSummary(state, activeTools) {
|
|
284
|
+
const subagents = visibleSubagents(state).map((agent) => agent.name);
|
|
285
|
+
return [
|
|
286
|
+
"Active Recipe",
|
|
287
|
+
`Name: ${state.manifest.name}@${state.manifest.version}`,
|
|
288
|
+
state.manifest.description ? `Description: ${state.manifest.description}` : undefined,
|
|
289
|
+
`Agent: ${state.agentName}`,
|
|
290
|
+
`Model: ${state.agent.model?.name ?? "(session default)"}`,
|
|
291
|
+
`Thinking level: ${state.agent.model?.thinkingLevel ?? "(session default)"}`,
|
|
292
|
+
`Subagents: ${nameList(subagents)}`,
|
|
293
|
+
"",
|
|
294
|
+
"Active recipe tools:",
|
|
295
|
+
...bulletList(activeRecipeTools(state, activeTools)),
|
|
296
|
+
"",
|
|
297
|
+
`Directory: ${state.recipeDir}`,
|
|
298
|
+
`Workspace: ${state.cwd}`,
|
|
299
|
+
].filter((line) => line !== undefined).join("\n");
|
|
300
|
+
}
|
|
301
|
+
function emitRunUpdate(run, onUpdate) {
|
|
302
|
+
onUpdate?.({
|
|
303
|
+
content: [{ type: "text", text: runBlock(run) }],
|
|
304
|
+
details: runDetails(run, "update"),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
function applyChildToolEvent(run, event) {
|
|
308
|
+
const existing = run.toolCalls.find((call) => call.id === event.id);
|
|
309
|
+
if (event.type === "start") {
|
|
310
|
+
if (existing) {
|
|
311
|
+
existing.name = event.name;
|
|
312
|
+
existing.args = event.args;
|
|
313
|
+
existing.status = "running";
|
|
314
|
+
existing.completedAt = undefined;
|
|
315
|
+
existing.output = undefined;
|
|
316
|
+
existing.error = undefined;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
run.toolCalls.push({
|
|
320
|
+
id: event.id,
|
|
321
|
+
name: event.name,
|
|
322
|
+
args: event.args,
|
|
323
|
+
status: "running",
|
|
324
|
+
startedAt: new Date().toISOString(),
|
|
325
|
+
});
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const call = existing ??
|
|
329
|
+
(() => {
|
|
330
|
+
const created = {
|
|
331
|
+
id: event.id,
|
|
332
|
+
name: event.name,
|
|
333
|
+
args: event.args,
|
|
334
|
+
status: "running",
|
|
335
|
+
startedAt: new Date().toISOString(),
|
|
336
|
+
};
|
|
337
|
+
run.toolCalls.push(created);
|
|
338
|
+
return created;
|
|
339
|
+
})();
|
|
340
|
+
call.name = event.name;
|
|
341
|
+
call.args = event.args;
|
|
342
|
+
if (event.type === "update") {
|
|
343
|
+
const text = resultText(event.partialResult);
|
|
344
|
+
if (text)
|
|
345
|
+
call.output = text;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
call.completedAt = new Date().toISOString();
|
|
349
|
+
call.status = event.isError ? "failed" : "completed";
|
|
350
|
+
const text = resultText(event.result);
|
|
351
|
+
if (text)
|
|
352
|
+
call.output = text;
|
|
353
|
+
if (event.isError)
|
|
354
|
+
call.error = text || "Tool failed";
|
|
355
|
+
}
|
|
356
|
+
function resolvePackage(specifier) {
|
|
357
|
+
try {
|
|
358
|
+
return import.meta.resolve(specifier);
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// Fall through to CommonJS resolution for packages that do not expose ESM exports.
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
return require.resolve(specifier);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function recipeExtensionAliases() {
|
|
371
|
+
return Object.fromEntries([
|
|
372
|
+
["@earendil-works/pi-coding-agent", resolvePackage("@earendil-works/pi-coding-agent")],
|
|
373
|
+
["@earendil-works/pi-agent-core", resolvePackage("@earendil-works/pi-agent-core")],
|
|
374
|
+
["@earendil-works/pi-ai", resolvePackage("@earendil-works/pi-ai")],
|
|
375
|
+
["@earendil-works/pi-ai/oauth", resolvePackage("@earendil-works/pi-ai/oauth")],
|
|
376
|
+
["typebox", resolvePackage("typebox")],
|
|
377
|
+
["typebox/compile", resolvePackage("typebox/compile")],
|
|
378
|
+
["typebox/value", resolvePackage("typebox/value")],
|
|
379
|
+
["@sinclair/typebox", resolvePackage("typebox")],
|
|
380
|
+
["@sinclair/typebox/compile", resolvePackage("typebox/compile")],
|
|
381
|
+
["@sinclair/typebox/value", resolvePackage("typebox/value")],
|
|
382
|
+
].filter((entry) => Boolean(entry[1])));
|
|
383
|
+
}
|
|
384
|
+
function loadJiti() {
|
|
385
|
+
try {
|
|
386
|
+
return require("jiti");
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
const piAgentEntry = resolvePackage("@earendil-works/pi-coding-agent");
|
|
390
|
+
if (!piAgentEntry) {
|
|
391
|
+
throw new Error("Unable to resolve @earendil-works/pi-coding-agent for recipe extension loading");
|
|
392
|
+
}
|
|
393
|
+
const piRequire = createRequire(piAgentEntry);
|
|
394
|
+
return piRequire("jiti");
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async function loadRecipeExtensionFactory(recipeDir, extensionPath) {
|
|
398
|
+
const { createJiti } = loadJiti();
|
|
399
|
+
const recipeLoaderUrl = pathToFileURL(join(recipeDir, ".recipe-extension-loader.js")).href;
|
|
400
|
+
const jiti = createJiti(recipeLoaderUrl, {
|
|
401
|
+
moduleCache: false,
|
|
402
|
+
alias: recipeExtensionAliases(),
|
|
403
|
+
});
|
|
404
|
+
const loaded = await jiti.import(extensionPath, { default: true });
|
|
405
|
+
const factory = typeof loaded === "function"
|
|
406
|
+
? loaded
|
|
407
|
+
: loaded && typeof loaded === "object" && "default" in loaded && typeof loaded.default === "function"
|
|
408
|
+
? loaded.default
|
|
409
|
+
: undefined;
|
|
410
|
+
if (!factory) {
|
|
411
|
+
throw new Error(`Recipe extension does not export a factory function: ${extensionPath}`);
|
|
412
|
+
}
|
|
413
|
+
return factory;
|
|
414
|
+
}
|
|
415
|
+
export function createPiRecipesExtension(opts = {}) {
|
|
416
|
+
const env = opts.env ?? process.env;
|
|
417
|
+
const createChildAgentRunner = opts.createChildAgentRunner ?? createRecipeChildAgentRunner;
|
|
418
|
+
let state = null;
|
|
419
|
+
let lastLaunchErrorKey = null;
|
|
420
|
+
let childRunIndex = 0;
|
|
421
|
+
const childRuns = new Map();
|
|
422
|
+
function recipeFlag(pi) {
|
|
423
|
+
return stringFlag(pi.getFlag("recipe")) ?? stringFlag(env.PI_RECIPE_DIR);
|
|
424
|
+
}
|
|
425
|
+
function selectedAgentName(pi) {
|
|
426
|
+
return stringFlag(pi.getFlag("agent")) ?? stringFlag(env.PI_AGENT_NAME);
|
|
427
|
+
}
|
|
428
|
+
function loadState(pi, cwd) {
|
|
429
|
+
const flag = recipeFlag(pi);
|
|
430
|
+
if (!flag)
|
|
431
|
+
return null;
|
|
432
|
+
const recipeDir = resolveRecipeDirectory(flag, { cwd, env });
|
|
433
|
+
if (!existsSync(recipeDir)) {
|
|
434
|
+
throw new RecipeLaunchError(recipeNotFoundMessage(flag, recipeDir));
|
|
435
|
+
}
|
|
436
|
+
const requestedAgentName = selectedAgentName(pi);
|
|
437
|
+
const key = [cwd, recipeDir, requestedAgentName ?? ""].join("\0");
|
|
438
|
+
if (state?.key === key)
|
|
439
|
+
return state;
|
|
440
|
+
let manifest;
|
|
441
|
+
try {
|
|
442
|
+
manifest = readPiPackageManifest(recipeDir);
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
throw new RecipeLaunchError(recipeLoadErrorMessage(flag, err instanceof Error ? err.message : String(err)));
|
|
446
|
+
}
|
|
447
|
+
const validation = validatePiPackageManifest(manifest);
|
|
448
|
+
const errors = validation.findings.filter((finding) => finding.severity === "error");
|
|
449
|
+
if (errors.length > 0) {
|
|
450
|
+
throw new RecipeLaunchError(recipeLoadErrorMessage(flag, errors.map((finding) => finding.message).join("\n")));
|
|
451
|
+
}
|
|
452
|
+
const agentFindings = validateRecipeAgentDefinitions(recipeDir);
|
|
453
|
+
if (agentFindings.length > 0) {
|
|
454
|
+
throw new RecipeLaunchError([
|
|
455
|
+
`Recipe "${manifest.name}" has invalid agents.`,
|
|
456
|
+
...agentFindings.map((finding) => `- ${finding.message}`),
|
|
457
|
+
"Add the missing fields to each agent, even if empty.",
|
|
458
|
+
].join("\n"));
|
|
459
|
+
}
|
|
460
|
+
const resolved = resolveRecipeAgentDefinition({
|
|
461
|
+
recipeDir,
|
|
462
|
+
agentName: requestedAgentName,
|
|
463
|
+
});
|
|
464
|
+
if (!resolved.agent) {
|
|
465
|
+
const availableAgents = [...loadRecipeAgentDefinitions(recipeDir).keys()].sort();
|
|
466
|
+
throw new RecipeLaunchError([
|
|
467
|
+
`Recipe "${manifest.name}" loaded, but agent "${resolved.agentName}" was not found.`,
|
|
468
|
+
availableAgents.length > 0
|
|
469
|
+
? `Available agents: ${availableAgents.join(", ")}`
|
|
470
|
+
: "No recipe agents were found.",
|
|
471
|
+
"Launch with `pi --recipe <recipe> --agent <agent>` or update the recipe agents.",
|
|
472
|
+
].join("\n"));
|
|
473
|
+
}
|
|
474
|
+
const extensionPaths = filterExtensionPaths(recipeDir, packageResourcePaths(manifest, "extensions"), resolved.agent);
|
|
475
|
+
state = {
|
|
476
|
+
key,
|
|
477
|
+
cwd,
|
|
478
|
+
recipeDir,
|
|
479
|
+
manifest,
|
|
480
|
+
agentName: resolved.agentName,
|
|
481
|
+
agent: resolved.agent,
|
|
482
|
+
skillPaths: packageResourcePaths(manifest, "skills"),
|
|
483
|
+
promptPaths: packageResourcePaths(manifest, "prompts"),
|
|
484
|
+
themePaths: packageResourcePaths(manifest, "themes"),
|
|
485
|
+
extensionPaths,
|
|
486
|
+
extensionsLoaded: false,
|
|
487
|
+
configured: false,
|
|
488
|
+
};
|
|
489
|
+
return state;
|
|
490
|
+
}
|
|
491
|
+
function safeLoadState(pi, cwd, ctx) {
|
|
492
|
+
try {
|
|
493
|
+
return loadState(pi, cwd);
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
if (!(err instanceof RecipeLaunchError))
|
|
497
|
+
throw err;
|
|
498
|
+
const key = [cwd, recipeFlag(pi) ?? "", err.message].join("\0");
|
|
499
|
+
if (ctx && lastLaunchErrorKey !== key) {
|
|
500
|
+
ctx.ui.notify(err.message, "warning");
|
|
501
|
+
lastLaunchErrorKey = key;
|
|
502
|
+
}
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async function loadRecipeExtensions(pi, ctx, launchState) {
|
|
507
|
+
if (launchState.extensionsLoaded)
|
|
508
|
+
return;
|
|
509
|
+
let loadedCount = 0;
|
|
510
|
+
for (const extensionPath of launchState.extensionPaths) {
|
|
511
|
+
try {
|
|
512
|
+
const factory = await loadRecipeExtensionFactory(launchState.recipeDir, extensionPath);
|
|
513
|
+
await factory(pi);
|
|
514
|
+
loadedCount += 1;
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
518
|
+
ctx.ui.notify(`Recipe extension failed to load: ${extensionPath}\n${message}`, "warning");
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
launchState.extensionsLoaded = true;
|
|
522
|
+
if (launchState.extensionPaths.length > 0) {
|
|
523
|
+
ctx.ui.notify(`Recipe extensions: ${loadedCount}/${launchState.extensionPaths.length} loaded`, "info");
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
async function configureSession(pi, ctx, launchState) {
|
|
527
|
+
if (launchState.configured)
|
|
528
|
+
return;
|
|
529
|
+
const labelParts = [
|
|
530
|
+
`${launchState.manifest.name}@${launchState.manifest.version}`,
|
|
531
|
+
`agent:${launchState.agentName}`,
|
|
532
|
+
].filter(Boolean);
|
|
533
|
+
pi.setSessionName(labelParts.join(" "));
|
|
534
|
+
const modelSpec = launchState.agent.model?.name;
|
|
535
|
+
if (modelSpec) {
|
|
536
|
+
const { provider, model } = modelParts(modelSpec);
|
|
537
|
+
const lookupProvider = provider === "gemini" ? "google" : provider;
|
|
538
|
+
const resolvedModel = ctx.modelRegistry.find(lookupProvider, model);
|
|
539
|
+
if (!resolvedModel) {
|
|
540
|
+
throw new Error(`Recipe model is not available: ${modelSpec}`);
|
|
541
|
+
}
|
|
542
|
+
const ok = await pi.setModel(resolvedModel);
|
|
543
|
+
if (!ok) {
|
|
544
|
+
throw new Error(`Recipe model has no configured API key: ${modelSpec}`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const thinkingLevel = launchState.agent.model?.thinkingLevel;
|
|
548
|
+
if (thinkingLevel) {
|
|
549
|
+
pi.setThinkingLevel(thinkingLevel);
|
|
550
|
+
}
|
|
551
|
+
const activeTools = new Set(launchState.agent.tools);
|
|
552
|
+
if (visibleSubagents(launchState).length > 0)
|
|
553
|
+
activeTools.add("agent");
|
|
554
|
+
pi.setActiveTools([...activeTools]);
|
|
555
|
+
launchState.configured = true;
|
|
556
|
+
}
|
|
557
|
+
async function runChildAgent(launchState, agentName, task, label, ctx, onUpdate) {
|
|
558
|
+
const id = `recipe-agent-${++childRunIndex}`;
|
|
559
|
+
let run;
|
|
560
|
+
const runner = createChildAgentRunner({
|
|
561
|
+
recipeDir: launchState.recipeDir,
|
|
562
|
+
workspaceDir: launchState.cwd,
|
|
563
|
+
env,
|
|
564
|
+
agentName,
|
|
565
|
+
authStorage: ctx.modelRegistry.authStorage,
|
|
566
|
+
modelRegistry: ctx.modelRegistry,
|
|
567
|
+
onAssistantMessage(text, stream) {
|
|
568
|
+
if (!run)
|
|
569
|
+
return;
|
|
570
|
+
if (stream === "delta") {
|
|
571
|
+
run.output = `${run.output ?? ""}${text}`;
|
|
572
|
+
}
|
|
573
|
+
else if (!run.output?.trim()) {
|
|
574
|
+
run.output = text;
|
|
575
|
+
}
|
|
576
|
+
emitRunUpdate(run, onUpdate);
|
|
577
|
+
},
|
|
578
|
+
onToolEvent(event) {
|
|
579
|
+
if (!run)
|
|
580
|
+
return;
|
|
581
|
+
applyChildToolEvent(run, event);
|
|
582
|
+
emitRunUpdate(run, onUpdate);
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
run = {
|
|
586
|
+
id,
|
|
587
|
+
agent: agentName,
|
|
588
|
+
label,
|
|
589
|
+
task,
|
|
590
|
+
status: "running",
|
|
591
|
+
startedAt: new Date().toISOString(),
|
|
592
|
+
toolCalls: [],
|
|
593
|
+
runner,
|
|
594
|
+
promise: Promise.resolve(undefined),
|
|
595
|
+
};
|
|
596
|
+
emitRunUpdate(run, onUpdate);
|
|
597
|
+
run.promise = (async () => {
|
|
598
|
+
try {
|
|
599
|
+
await runner.start();
|
|
600
|
+
const result = await runner.prompt(task);
|
|
601
|
+
const finalOutput = promptResultText(result);
|
|
602
|
+
if (finalOutput && finalOutput.length >= (run.output?.length ?? 0)) {
|
|
603
|
+
run.output = finalOutput;
|
|
604
|
+
}
|
|
605
|
+
else if (!run.output?.trim()) {
|
|
606
|
+
run.output = "(no final response)";
|
|
607
|
+
}
|
|
608
|
+
run.status = "completed";
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
if (run.status !== "interrupted")
|
|
612
|
+
run.status = "failed";
|
|
613
|
+
run.error = err instanceof Error ? err.message : String(err);
|
|
614
|
+
}
|
|
615
|
+
finally {
|
|
616
|
+
run.completedAt = new Date().toISOString();
|
|
617
|
+
emitRunUpdate(run, onUpdate);
|
|
618
|
+
await runner.shutdown();
|
|
619
|
+
}
|
|
620
|
+
return run;
|
|
621
|
+
})();
|
|
622
|
+
childRuns.set(id, run);
|
|
623
|
+
return run;
|
|
624
|
+
}
|
|
625
|
+
async function waitForRun(run, signal, onUpdate) {
|
|
626
|
+
if (!signal)
|
|
627
|
+
return await run.promise;
|
|
628
|
+
const interrupt = () => {
|
|
629
|
+
run.status = "interrupted";
|
|
630
|
+
void run.runner.cancel();
|
|
631
|
+
emitRunUpdate(run, onUpdate);
|
|
632
|
+
};
|
|
633
|
+
if (signal.aborted)
|
|
634
|
+
interrupt();
|
|
635
|
+
signal.addEventListener("abort", interrupt, { once: true });
|
|
636
|
+
try {
|
|
637
|
+
return await run.promise;
|
|
638
|
+
}
|
|
639
|
+
finally {
|
|
640
|
+
signal.removeEventListener("abort", interrupt);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
async function handleAgentTool(params, signal, onUpdate, ctx) {
|
|
644
|
+
if (!state) {
|
|
645
|
+
return {
|
|
646
|
+
...textResult("No recipe is active. Launch Pi with --recipe <dir> to use recipe agents.", {
|
|
647
|
+
error: "recipe_not_active",
|
|
648
|
+
}),
|
|
649
|
+
isError: true,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
const action = params.action ?? "start";
|
|
653
|
+
if (action === "status") {
|
|
654
|
+
const runs = params.id
|
|
655
|
+
? [childRuns.get(params.id)].filter((run) => Boolean(run))
|
|
656
|
+
: [...childRuns.values()];
|
|
657
|
+
if (params.id && runs.length === 0) {
|
|
658
|
+
return { ...textResult(`Unknown recipe agent run: ${params.id}`, { id: params.id }), isError: true };
|
|
659
|
+
}
|
|
660
|
+
return textResult(runs.length > 0 ? runs.map(describeRun).join("\n") : "No recipe agent runs have been started yet.", { action, agent_runs: runs.map((run) => runDetails(run)) });
|
|
661
|
+
}
|
|
662
|
+
if (action === "wait") {
|
|
663
|
+
const run = params.id ? childRuns.get(params.id) : [...childRuns.values()].at(-1);
|
|
664
|
+
if (!run) {
|
|
665
|
+
return { ...textResult(params.id ? `Unknown recipe agent run: ${params.id}` : "No recipe agent runs have been started yet.", {}), isError: true };
|
|
666
|
+
}
|
|
667
|
+
emitRunUpdate(run, onUpdate);
|
|
668
|
+
await waitForRun(run, signal, onUpdate);
|
|
669
|
+
emitRunUpdate(run, onUpdate);
|
|
670
|
+
return textResult(runBlock(run), runDetails(run, action));
|
|
671
|
+
}
|
|
672
|
+
if (action === "interrupt") {
|
|
673
|
+
const run = params.id ? childRuns.get(params.id) : undefined;
|
|
674
|
+
if (!run)
|
|
675
|
+
return { ...textResult(params.id ? `Unknown recipe agent run: ${params.id}` : "Interrupt requires a recipe agent run id.", {}), isError: true };
|
|
676
|
+
run.status = "interrupted";
|
|
677
|
+
await run.runner.cancel();
|
|
678
|
+
emitRunUpdate(run, onUpdate);
|
|
679
|
+
return textResult(runBlock(run), runDetails(run, action));
|
|
680
|
+
}
|
|
681
|
+
if (action === "close") {
|
|
682
|
+
const run = params.id ? childRuns.get(params.id) : undefined;
|
|
683
|
+
if (!run)
|
|
684
|
+
return { ...textResult(params.id ? `Unknown recipe agent run: ${params.id}` : "Close requires a recipe agent run id.", {}), isError: true };
|
|
685
|
+
childRuns.delete(run.id);
|
|
686
|
+
return textResult(`Closed recipe agent run ${run.id} (${run.agent}).`, { id: run.id });
|
|
687
|
+
}
|
|
688
|
+
if (!params.name || !params.task) {
|
|
689
|
+
return {
|
|
690
|
+
...textResult("Starting a recipe agent requires both name and task.", {
|
|
691
|
+
available_agents: visibleSubagents(state).map((agent) => agent.name),
|
|
692
|
+
}),
|
|
693
|
+
isError: true,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
const visible = visibleSubagents(state);
|
|
697
|
+
const agent = visible.find((item) => item.name === params.name);
|
|
698
|
+
if (!agent) {
|
|
699
|
+
return {
|
|
700
|
+
...textResult(`Unknown or unavailable recipe agent: ${params.name}. Available agents: ${visible.map((item) => item.name).join(", ")}`, { action, agent: params.name, available_agents: visible.map((item) => item.name) }),
|
|
701
|
+
isError: true,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
const run = await runChildAgent(state, agent.name, params.task, params.label, ctx, onUpdate);
|
|
705
|
+
if (params.wait !== false) {
|
|
706
|
+
await waitForRun(run, signal, onUpdate);
|
|
707
|
+
return textResult(runBlock(run), runDetails(run, action));
|
|
708
|
+
}
|
|
709
|
+
return textResult(runBlock(run), runDetails(run, action));
|
|
710
|
+
}
|
|
711
|
+
return (pi) => {
|
|
712
|
+
pi.registerFlag("recipe", {
|
|
713
|
+
description: "Recipe folder, installed recipe name, or installed recipe source to use for this Pi session",
|
|
714
|
+
type: "string",
|
|
715
|
+
});
|
|
716
|
+
pi.registerFlag("agent", {
|
|
717
|
+
description: "Recipe agent to use",
|
|
718
|
+
type: "string",
|
|
719
|
+
});
|
|
720
|
+
pi.registerCommand("recipe", {
|
|
721
|
+
description: "Inspect or reload the active recipe",
|
|
722
|
+
handler: async (args, ctx) => {
|
|
723
|
+
const action = args.trim();
|
|
724
|
+
if (action === "reload") {
|
|
725
|
+
const launchState = safeLoadState(pi, ctx.cwd, ctx);
|
|
726
|
+
if (!launchState) {
|
|
727
|
+
if (!recipeFlag(pi)) {
|
|
728
|
+
ctx.ui.notify("No recipe is active. Launch Pi with --recipe <recipe>.", "info");
|
|
729
|
+
}
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
state = null;
|
|
733
|
+
childRuns.clear();
|
|
734
|
+
await ctx.waitForIdle();
|
|
735
|
+
await ctx.reload();
|
|
736
|
+
ctx.ui.notify(`Recipe reload requested: ${launchState.manifest.name}@${launchState.manifest.version}`, "info");
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (action) {
|
|
740
|
+
ctx.ui.notify("Usage: /recipe [reload]", "info");
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const launchState = safeLoadState(pi, ctx.cwd, ctx);
|
|
744
|
+
if (!launchState) {
|
|
745
|
+
if (!recipeFlag(pi)) {
|
|
746
|
+
ctx.ui.notify("No recipe is active. Launch Pi with --recipe <recipe>.", "info");
|
|
747
|
+
}
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
ctx.ui.notify(recipeSummary(launchState, pi.getActiveTools()), "info");
|
|
751
|
+
},
|
|
752
|
+
});
|
|
753
|
+
pi.registerTool(defineTool({
|
|
754
|
+
name: "agent",
|
|
755
|
+
label: "Recipe agent",
|
|
756
|
+
description: [
|
|
757
|
+
"Start or manage another agent from the active recipe.",
|
|
758
|
+
"Start calls stream the prompted task and subagent assistant output in one tool block.",
|
|
759
|
+
"By default start waits for completion; pass wait=false only when a background run is desired.",
|
|
760
|
+
"This tool is active only when the selected recipe agent has available subagents.",
|
|
761
|
+
].join("\n"),
|
|
762
|
+
parameters: RunRecipeAgentParams,
|
|
763
|
+
renderCall(params, theme, context) {
|
|
764
|
+
const text = context.lastComponent ?? new Text("", 0, 0);
|
|
765
|
+
text.setText(formatRecipeAgentCall(params, theme));
|
|
766
|
+
return text;
|
|
767
|
+
},
|
|
768
|
+
renderResult(result, options, theme, context) {
|
|
769
|
+
const text = context.lastComponent ?? new Text("", 0, 0);
|
|
770
|
+
const details = result.details;
|
|
771
|
+
const fallbackText = contentText(result.content);
|
|
772
|
+
text.setText(formatRecipeAgentResult(details, fallbackText, options, theme));
|
|
773
|
+
return text;
|
|
774
|
+
},
|
|
775
|
+
async execute(_runId, params, signal, onUpdate, ctx) {
|
|
776
|
+
return await handleAgentTool(params, signal, onUpdate, ctx);
|
|
777
|
+
},
|
|
778
|
+
}));
|
|
779
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
780
|
+
const launchState = safeLoadState(pi, ctx.cwd, ctx);
|
|
781
|
+
if (!launchState)
|
|
782
|
+
return;
|
|
783
|
+
await loadRecipeExtensions(pi, ctx, launchState);
|
|
784
|
+
await configureSession(pi, ctx, launchState);
|
|
785
|
+
ctx.ui.notify(`Recipe: ${launchState.manifest.name}@${launchState.manifest.version} (${basename(launchState.recipeDir)})`, "info");
|
|
786
|
+
});
|
|
787
|
+
pi.on("resources_discover", (event) => {
|
|
788
|
+
const launchState = safeLoadState(pi, event.cwd);
|
|
789
|
+
if (!launchState)
|
|
790
|
+
return {};
|
|
791
|
+
return {
|
|
792
|
+
skillPaths: launchState.skillPaths,
|
|
793
|
+
promptPaths: launchState.promptPaths,
|
|
794
|
+
themePaths: launchState.themePaths,
|
|
795
|
+
};
|
|
796
|
+
});
|
|
797
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
798
|
+
const launchState = safeLoadState(pi, ctx.cwd, ctx);
|
|
799
|
+
if (!launchState)
|
|
800
|
+
return {};
|
|
801
|
+
const base = loadRecipeSystemPrompt(launchState.recipeDir) ?? event.systemPrompt;
|
|
802
|
+
const recipePrompt = applySystemInstructions(base, launchState.agent.systemInstructions);
|
|
803
|
+
const systemPrompt = runtimeContextPrompt(recipePrompt, launchState);
|
|
804
|
+
return { systemPrompt };
|
|
805
|
+
});
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
export default createPiRecipesExtension();
|
|
809
|
+
//# sourceMappingURL=pi-extension.js.map
|