@llblab/pi-actors 0.16.4 → 0.17.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/AGENTS.md +7 -7
- package/BACKLOG.md +42 -11
- package/CHANGELOG.md +14 -8
- package/README.md +39 -12
- package/banner.jpg +0 -0
- package/docs/actor-messages.md +63 -2
- package/docs/async-runs.md +25 -3
- package/docs/recipe-library.md +3 -3
- package/docs/template-recipes.md +3 -4
- package/docs/tool-registry.md +7 -12
- package/index.ts +58 -3
- package/lib/actor-inspector-tui.ts +426 -0
- package/lib/actor-messages.ts +18 -0
- package/lib/actor-rooms.ts +369 -0
- package/lib/async-runs.ts +17 -1
- package/lib/config.ts +1 -1
- package/lib/paths.ts +1 -1
- package/lib/prompts.ts +3 -2
- package/lib/recipe-discovery.ts +83 -1
- package/lib/recipe-migration.ts +2 -2
- package/lib/recipe-references.ts +2 -0
- package/lib/tools.ts +292 -9
- package/package.json +1 -1
- package/recipes/lens-swarm.json +0 -1
- package/skills/actors/SKILL.md +51 -8
- package/skills/swarm/SKILL.md +1 -1
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actor room persistence helpers.
|
|
3
|
+
* Zones: room timelines, room rosters, cross-branch discovery state
|
|
4
|
+
* Owns small file-backed room state; routing policy stays in tools/runtime adapters.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
|
|
10
|
+
import type { ActorMessage } from "./actor-messages.ts";
|
|
11
|
+
|
|
12
|
+
export interface RoomMember {
|
|
13
|
+
address: string;
|
|
14
|
+
caps?: unknown;
|
|
15
|
+
claim?: unknown;
|
|
16
|
+
joined_at: string;
|
|
17
|
+
last_seen: string;
|
|
18
|
+
parent?: unknown;
|
|
19
|
+
role?: unknown;
|
|
20
|
+
status?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RoomTimelineEntry extends ActorMessage {
|
|
24
|
+
received_at: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RoomAppendResult {
|
|
28
|
+
room: string;
|
|
29
|
+
message_count: number;
|
|
30
|
+
roster_count: number;
|
|
31
|
+
sent: true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RoomMessagePreview {
|
|
35
|
+
body_preview?: string;
|
|
36
|
+
from?: string;
|
|
37
|
+
summary?: string;
|
|
38
|
+
timestamp: string;
|
|
39
|
+
to: string;
|
|
40
|
+
type: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RoomStatus {
|
|
44
|
+
room: string;
|
|
45
|
+
message_count: number;
|
|
46
|
+
roster_count: number;
|
|
47
|
+
last_message_at?: string;
|
|
48
|
+
last_message_from?: string;
|
|
49
|
+
last_message_summary?: string;
|
|
50
|
+
last_message_type?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RoomContact {
|
|
54
|
+
address: string;
|
|
55
|
+
caps?: unknown;
|
|
56
|
+
claim?: unknown;
|
|
57
|
+
parent?: unknown;
|
|
58
|
+
role?: unknown;
|
|
59
|
+
status?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ActorCommunicationSnapshot {
|
|
63
|
+
contacts?: RoomContact[];
|
|
64
|
+
parent?: string;
|
|
65
|
+
root: string;
|
|
66
|
+
self: string;
|
|
67
|
+
rooms: Array<{
|
|
68
|
+
address: string;
|
|
69
|
+
members?: RoomMember[];
|
|
70
|
+
name: string;
|
|
71
|
+
}>;
|
|
72
|
+
updated_at: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function roomDir(stateDir: string, room: string): string {
|
|
76
|
+
return path.join(stateDir, "rooms", room);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function messagesFile(stateDir: string, room: string): string {
|
|
80
|
+
return path.join(roomDir(stateDir, room), "messages.jsonl");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function rosterFile(stateDir: string, room: string): string {
|
|
84
|
+
return path.join(roomDir(stateDir, room), "roster.json");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function snapshotFile(stateDir: string): string {
|
|
88
|
+
return path.join(stateDir, "communication.json");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function branchSnapshotFile(stateDir: string, branch: string): string {
|
|
92
|
+
return path.join(stateDir, "branches", branch, "communication.json");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function branchIdFromAddress(address: string | undefined, run: string): string | undefined {
|
|
96
|
+
if (!address) return undefined;
|
|
97
|
+
const match = new RegExp(`^branch:${run}/(.+)$`).exec(address);
|
|
98
|
+
return match?.[1];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ensureRoomDir(stateDir: string, room: string): void {
|
|
102
|
+
fs.mkdirSync(roomDir(stateDir, room), { recursive: true });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
106
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
107
|
+
? (value as Record<string, unknown>)
|
|
108
|
+
: {};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function runFromRoomAddress(address: string): string | undefined {
|
|
112
|
+
const match = /^room:([^/]+)(?:\/main)?$/.exec(address);
|
|
113
|
+
return match?.[1];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readJsonFile<T>(file: string, fallback: T): T {
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(fs.readFileSync(file, "utf8")) as T;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return fallback;
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function writeJsonFile(file: string, value: unknown): void {
|
|
126
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
127
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function readRoomRoster(
|
|
131
|
+
stateDir: string,
|
|
132
|
+
room: string,
|
|
133
|
+
): Record<string, RoomMember> {
|
|
134
|
+
return readJsonFile<Record<string, RoomMember>>(rosterFile(stateDir, room), {});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function writeRoomRoster(
|
|
138
|
+
stateDir: string,
|
|
139
|
+
room: string,
|
|
140
|
+
roster: Record<string, RoomMember>,
|
|
141
|
+
): void {
|
|
142
|
+
ensureRoomDir(stateDir, room);
|
|
143
|
+
writeJsonFile(rosterFile(stateDir, room), roster);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function updateRosterForMessage(
|
|
147
|
+
stateDir: string,
|
|
148
|
+
room: string,
|
|
149
|
+
message: ActorMessage,
|
|
150
|
+
receivedAt: string,
|
|
151
|
+
): Record<string, RoomMember> {
|
|
152
|
+
const roster = readRoomRoster(stateDir, room);
|
|
153
|
+
if (!message.from) return roster;
|
|
154
|
+
if (message.type === "actor.leave") {
|
|
155
|
+
delete roster[message.from];
|
|
156
|
+
writeRoomRoster(stateDir, room, roster);
|
|
157
|
+
return roster;
|
|
158
|
+
}
|
|
159
|
+
const body = asRecord(message.body);
|
|
160
|
+
const current = roster[message.from];
|
|
161
|
+
roster[message.from] = {
|
|
162
|
+
address: message.from,
|
|
163
|
+
joined_at: current?.joined_at ?? receivedAt,
|
|
164
|
+
last_seen: receivedAt,
|
|
165
|
+
...(body.caps !== undefined ? { caps: body.caps } : current?.caps !== undefined ? { caps: current.caps } : {}),
|
|
166
|
+
...(body.claim !== undefined ? { claim: body.claim } : current?.claim !== undefined ? { claim: current.claim } : {}),
|
|
167
|
+
...(body.parent !== undefined ? { parent: body.parent } : current?.parent !== undefined ? { parent: current.parent } : {}),
|
|
168
|
+
...(body.role !== undefined ? { role: body.role } : current?.role !== undefined ? { role: current.role } : { role: "actor" }),
|
|
169
|
+
status: String(body.status ?? current?.status ?? "present"),
|
|
170
|
+
};
|
|
171
|
+
writeRoomRoster(stateDir, room, roster);
|
|
172
|
+
return roster;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function appendRoomMessage(
|
|
176
|
+
stateDir: string,
|
|
177
|
+
room: string,
|
|
178
|
+
message: ActorMessage,
|
|
179
|
+
): RoomAppendResult {
|
|
180
|
+
ensureRoomDir(stateDir, room);
|
|
181
|
+
const receivedAt = new Date().toISOString();
|
|
182
|
+
const entry: RoomTimelineEntry = { ...message, received_at: receivedAt };
|
|
183
|
+
fs.appendFileSync(messagesFile(stateDir, room), `${JSON.stringify(entry)}\n`);
|
|
184
|
+
const roster = updateRosterForMessage(stateDir, room, message, receivedAt);
|
|
185
|
+
const run = runFromRoomAddress(message.to);
|
|
186
|
+
if (run) {
|
|
187
|
+
writeCommunicationSnapshot(stateDir, run);
|
|
188
|
+
if (message.from && branchIdFromAddress(message.from, run)) {
|
|
189
|
+
writeBranchCommunicationSnapshot(stateDir, run, message.from);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
message_count: readRoomMessages(stateDir, room).length,
|
|
194
|
+
room,
|
|
195
|
+
roster_count: Object.keys(roster).length,
|
|
196
|
+
sent: true,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function readRoomMessages(
|
|
201
|
+
stateDir: string,
|
|
202
|
+
room: string,
|
|
203
|
+
limit = 40,
|
|
204
|
+
): RoomTimelineEntry[] {
|
|
205
|
+
try {
|
|
206
|
+
const lines = fs
|
|
207
|
+
.readFileSync(messagesFile(stateDir, room), "utf8")
|
|
208
|
+
.split("\n")
|
|
209
|
+
.filter(Boolean);
|
|
210
|
+
return lines.slice(-Math.max(1, limit)).map((line) => JSON.parse(line));
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function previewValue(value: unknown, maxLength = 120): string | undefined {
|
|
218
|
+
if (value === undefined) return undefined;
|
|
219
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
220
|
+
const compact = text.replaceAll(/\s+/g, " ").trim();
|
|
221
|
+
if (!compact) return undefined;
|
|
222
|
+
return compact.length > maxLength
|
|
223
|
+
? `${compact.slice(0, Math.max(0, maxLength - 1))}…`
|
|
224
|
+
: compact;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function readRoomMessagePreviews(
|
|
228
|
+
stateDir: string,
|
|
229
|
+
room: string,
|
|
230
|
+
limit = 40,
|
|
231
|
+
): RoomMessagePreview[] {
|
|
232
|
+
return readRoomMessages(stateDir, room, limit).map((message) => ({
|
|
233
|
+
...(previewValue(message.body) ? { body_preview: previewValue(message.body) } : {}),
|
|
234
|
+
...(message.from ? { from: message.from } : {}),
|
|
235
|
+
...(message.summary ? { summary: message.summary } : {}),
|
|
236
|
+
timestamp: message.received_at,
|
|
237
|
+
to: message.to,
|
|
238
|
+
type: message.type,
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function getRoomStatus(stateDir: string, room: string): RoomStatus {
|
|
243
|
+
const messages = readRoomMessages(stateDir, room, Number.MAX_SAFE_INTEGER);
|
|
244
|
+
const last = messages[messages.length - 1];
|
|
245
|
+
return {
|
|
246
|
+
...(last
|
|
247
|
+
? {
|
|
248
|
+
last_message_at: last.received_at,
|
|
249
|
+
...(last.from ? { last_message_from: last.from } : {}),
|
|
250
|
+
...(last.summary ? { last_message_summary: last.summary } : {}),
|
|
251
|
+
last_message_type: last.type,
|
|
252
|
+
}
|
|
253
|
+
: {}),
|
|
254
|
+
message_count: messages.length,
|
|
255
|
+
room,
|
|
256
|
+
roster_count: Object.keys(readRoomRoster(stateDir, room)).length,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function ensureRoomMember(
|
|
261
|
+
stateDir: string,
|
|
262
|
+
run: string,
|
|
263
|
+
room: string,
|
|
264
|
+
address: string,
|
|
265
|
+
body: Record<string, unknown>,
|
|
266
|
+
summary: string,
|
|
267
|
+
): RoomAppendResult {
|
|
268
|
+
const roster = readRoomRoster(stateDir, room);
|
|
269
|
+
if (roster[address]) {
|
|
270
|
+
return {
|
|
271
|
+
message_count: readRoomMessages(stateDir, room).length,
|
|
272
|
+
room,
|
|
273
|
+
roster_count: Object.keys(roster).length,
|
|
274
|
+
sent: true,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return appendRoomMessage(stateDir, room, {
|
|
278
|
+
body,
|
|
279
|
+
from: address,
|
|
280
|
+
summary,
|
|
281
|
+
to: `room:${run}`,
|
|
282
|
+
type: "actor.join",
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function ensureDefaultRoom(stateDir: string, run: string): RoomAppendResult {
|
|
287
|
+
return ensureRoomMember(
|
|
288
|
+
stateDir,
|
|
289
|
+
run,
|
|
290
|
+
"main",
|
|
291
|
+
`run:${run}`,
|
|
292
|
+
{ role: "run", status: "present" },
|
|
293
|
+
"Run joined default room",
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function readCommunicationSnapshot(
|
|
298
|
+
stateDir: string,
|
|
299
|
+
): ActorCommunicationSnapshot | undefined {
|
|
300
|
+
try {
|
|
301
|
+
return JSON.parse(
|
|
302
|
+
fs.readFileSync(snapshotFile(stateDir), "utf8"),
|
|
303
|
+
) as ActorCommunicationSnapshot;
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function readRoomContacts(
|
|
311
|
+
stateDir: string,
|
|
312
|
+
room: string,
|
|
313
|
+
self?: string,
|
|
314
|
+
): RoomContact[] {
|
|
315
|
+
return Object.values(readRoomRoster(stateDir, room))
|
|
316
|
+
.filter((member) => member.address !== self)
|
|
317
|
+
.map((member) => ({
|
|
318
|
+
address: member.address,
|
|
319
|
+
...(member.caps !== undefined ? { caps: member.caps } : {}),
|
|
320
|
+
...(member.claim !== undefined ? { claim: member.claim } : {}),
|
|
321
|
+
...(member.parent !== undefined ? { parent: member.parent } : {}),
|
|
322
|
+
...(member.role !== undefined ? { role: member.role } : {}),
|
|
323
|
+
...(member.status !== undefined ? { status: member.status } : {}),
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function buildCommunicationSnapshot(
|
|
328
|
+
stateDir: string,
|
|
329
|
+
run: string,
|
|
330
|
+
self: string,
|
|
331
|
+
): ActorCommunicationSnapshot {
|
|
332
|
+
const mainRoster = Object.values(readRoomRoster(stateDir, "main"));
|
|
333
|
+
const contacts = readRoomContacts(stateDir, "main", self);
|
|
334
|
+
return {
|
|
335
|
+
...(contacts.length > 0 ? { contacts } : {}),
|
|
336
|
+
...(self !== `run:${run}` ? { parent: `run:${run}` } : {}),
|
|
337
|
+
root: `run:${run}`,
|
|
338
|
+
self,
|
|
339
|
+
rooms: [
|
|
340
|
+
{
|
|
341
|
+
address: `room:${run}`,
|
|
342
|
+
...(mainRoster.length > 0 ? { members: mainRoster } : {}),
|
|
343
|
+
name: "main",
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
updated_at: new Date().toISOString(),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function writeCommunicationSnapshot(
|
|
351
|
+
stateDir: string,
|
|
352
|
+
run: string,
|
|
353
|
+
): ActorCommunicationSnapshot {
|
|
354
|
+
const snapshot = buildCommunicationSnapshot(stateDir, run, `run:${run}`);
|
|
355
|
+
writeJsonFile(snapshotFile(stateDir), snapshot);
|
|
356
|
+
return snapshot;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function writeBranchCommunicationSnapshot(
|
|
360
|
+
stateDir: string,
|
|
361
|
+
run: string,
|
|
362
|
+
self: string,
|
|
363
|
+
): ActorCommunicationSnapshot {
|
|
364
|
+
const branch = branchIdFromAddress(self, run);
|
|
365
|
+
if (!branch) throw new Error(`Expected branch:${run}/<branch>; got ${self}`);
|
|
366
|
+
const snapshot = buildCommunicationSnapshot(stateDir, run, self);
|
|
367
|
+
writeJsonFile(branchSnapshotFile(stateDir, branch), snapshot);
|
|
368
|
+
return snapshot;
|
|
369
|
+
}
|
package/lib/async-runs.ts
CHANGED
|
@@ -296,6 +296,7 @@ function prepareStateDirForStart(stateDir: string): void {
|
|
|
296
296
|
}
|
|
297
297
|
for (const file of [
|
|
298
298
|
"events.jsonl",
|
|
299
|
+
"inbox.jsonl",
|
|
299
300
|
"outbox.jsonl",
|
|
300
301
|
"progress.json",
|
|
301
302
|
"result.json",
|
|
@@ -331,6 +332,9 @@ export function startRun(
|
|
|
331
332
|
const argv = ["--experimental-strip-types", RUNNER_PATH, stateDir];
|
|
332
333
|
const values = {
|
|
333
334
|
...(startParams.values || {}),
|
|
335
|
+
actor_address: `run:${run}`,
|
|
336
|
+
communication_file: join(stateDir, "communication.json"),
|
|
337
|
+
default_room: `room:${run}`,
|
|
334
338
|
run_id: run,
|
|
335
339
|
state_dir: stateDir,
|
|
336
340
|
};
|
|
@@ -481,6 +485,7 @@ export function getRunStatus(runOrDir: string): Record<string, unknown> {
|
|
|
481
485
|
return {
|
|
482
486
|
...meta,
|
|
483
487
|
eventsFile: join(stateDir, "events.jsonl"),
|
|
488
|
+
inboxFile: join(stateDir, "inbox.jsonl"),
|
|
484
489
|
outboxFile: join(stateDir, "outbox.jsonl"),
|
|
485
490
|
progress: readJson(join(stateDir, "progress.json")) || null,
|
|
486
491
|
result: result || null,
|
|
@@ -632,11 +637,22 @@ export function sendRunMessage(
|
|
|
632
637
|
const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(
|
|
633
638
|
trimmedMessage,
|
|
634
639
|
);
|
|
640
|
+
const ts = new Date().toISOString();
|
|
635
641
|
writeFileSync(
|
|
636
642
|
join(stateDir, "events.jsonl"),
|
|
637
|
-
`${JSON.stringify({ bytes, event: "run.message", terminal: terminalMessage || undefined, ts
|
|
643
|
+
`${JSON.stringify({ bytes, event: "run.message", terminal: terminalMessage || undefined, ts })}\n`,
|
|
638
644
|
{ flag: "a" },
|
|
639
645
|
);
|
|
646
|
+
try {
|
|
647
|
+
const envelope = JSON.parse(message) as Record<string, unknown>;
|
|
648
|
+
writeFileSync(
|
|
649
|
+
join(stateDir, "inbox.jsonl"),
|
|
650
|
+
`${JSON.stringify({ ...envelope, received_at: ts })}\n`,
|
|
651
|
+
{ flag: "a" },
|
|
652
|
+
);
|
|
653
|
+
} catch {
|
|
654
|
+
// Plain control lines are already represented in events.jsonl.
|
|
655
|
+
}
|
|
640
656
|
if (terminalMessage) {
|
|
641
657
|
markTerminalHandled(stateDir, {
|
|
642
658
|
event: "run.message",
|
package/lib/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Persistent tool registry config helpers
|
|
3
3
|
* Zones: registry config, persistence, migration boundary
|
|
4
|
-
* Owns
|
|
4
|
+
* Owns registered-tool config loading, normalization, unsupported-shape rejection, and serialization
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, readFileSync } from "node:fs";
|
package/lib/paths.ts
CHANGED
package/lib/prompts.ts
CHANGED
|
@@ -24,11 +24,12 @@ export const ONBOARDING_SYSTEM_PROMPT = `pi-actors quick model:
|
|
|
24
24
|
- Layers: task -> command template -> recipe/tool -> spawn -> run:<id>; tool:<name> wraps registered capabilities.
|
|
25
25
|
- Command templates stay sync: string leaf, array sequence, object node; flags include args/defaults, parallel, when, timeout, delay, retry, failure, recover, repeat, output.
|
|
26
26
|
- Placeholders support typed/default args plus {value??fallback} and {flag?yes:no}.
|
|
27
|
-
-
|
|
27
|
+
- ~/.pi/agent/recipes/*.json is actor muscle memory: every recipe there is auto-registered as an agent tool across sessions; register_tool writes there.
|
|
28
|
+
- Recipes own template directly and may declare metadata/defaults/imports/mailbox/artifacts.
|
|
28
29
|
- Recipe imports are local variables; imported recipes are definitions, not nested async runs; parent async:true creates one run.
|
|
29
30
|
- Use spawn/message/inspect for actor-level start/send/observe; avoid runtime/FIFO/outbox vocabulary in public guidance.
|
|
30
31
|
- Run state lives under ~/.pi/agent/tmp/pi-actors/runs; inspect status/tail/messages/mailbox/files/artifacts intentionally and avoid busy-polling.
|
|
31
|
-
-
|
|
32
|
+
- Maintain ~/.pi/agent/recipes like MEMORY.md for capabilities: keep useful tools, curate stale ones; packaged recipes are lower-priority components, not tools by location.
|
|
32
33
|
- Foreground tools/templates fit short work; async recipes/runs fit subagents, services, fanout, media, and long pipelines.
|
|
33
34
|
- Long fanout = parent async recipe wrapping template(parallel:true) and imports; packaged fanout recipes bubble branch completion messages.
|
|
34
35
|
- For deeper pi-actors guidance, inspect installed extension sources/docs/recipes; README and docs are not automatically in context.`;
|
package/lib/recipe-discovery.ts
CHANGED
|
@@ -74,7 +74,7 @@ function listRecipeFiles(root: string): string[] {
|
|
|
74
74
|
(entry) =>
|
|
75
75
|
entry.isFile() &&
|
|
76
76
|
entry.name.endsWith(".json") &&
|
|
77
|
-
entry.name !== "
|
|
77
|
+
entry.name !== "legacy-tool-registry-migration-report.json",
|
|
78
78
|
)
|
|
79
79
|
.map((entry) => join(root, entry.name))
|
|
80
80
|
.sort();
|
|
@@ -192,7 +192,87 @@ export function discoverRecipes(roots: string[]): RecipeDiscoveryResult {
|
|
|
192
192
|
return discoverRecipeSources(roots.map((root) => ({ root })));
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
function recipeUsage(config: TemplateRecipeConfig | undefined): Record<string, unknown> | undefined {
|
|
196
|
+
const usage = (config as { usage?: unknown } | undefined)?.usage;
|
|
197
|
+
return usage && typeof usage === "object" && !Array.isArray(usage)
|
|
198
|
+
? (usage as Record<string, unknown>)
|
|
199
|
+
: undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function cleanupRecommendation(entry: DiscoveredRecipe): Record<string, unknown> | undefined {
|
|
203
|
+
if (entry.invalid) {
|
|
204
|
+
return {
|
|
205
|
+
id: entry.id,
|
|
206
|
+
path: entry.path,
|
|
207
|
+
reason: "invalid recipe blocks lower-priority entries with the same id",
|
|
208
|
+
actions: ["fix", "delete", "archive"],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
if (entry.shadowed) {
|
|
212
|
+
return {
|
|
213
|
+
id: entry.id,
|
|
214
|
+
path: entry.path,
|
|
215
|
+
reason: "shadowed by a higher-priority recipe",
|
|
216
|
+
actions: ["merge", "delete", "archive"],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (entry.disabled) {
|
|
220
|
+
return {
|
|
221
|
+
id: entry.id,
|
|
222
|
+
path: entry.path,
|
|
223
|
+
reason: "disabled recipe is retained but not exposed as a tool",
|
|
224
|
+
actions: ["keep disabled", "delete", "archive"],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const usage = recipeUsage(entry.config);
|
|
228
|
+
const calls = Number(usage?.calls ?? 0);
|
|
229
|
+
if (entry.mutableUsage && entry.tool && calls === 0) {
|
|
230
|
+
return {
|
|
231
|
+
id: entry.id,
|
|
232
|
+
path: entry.path,
|
|
233
|
+
reason: "active user tool has no recorded launches",
|
|
234
|
+
actions: ["keep as tool", "set tool false", "delete", "archive"],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (entry.mutableUsage && !entry.tool) {
|
|
238
|
+
return {
|
|
239
|
+
id: entry.id,
|
|
240
|
+
path: entry.path,
|
|
241
|
+
reason: "user recipe is a component, not an active tool",
|
|
242
|
+
actions: ["keep component", "enable tool", "merge", "delete", "archive"],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (entry.shadows.length > 0) {
|
|
246
|
+
return {
|
|
247
|
+
id: entry.id,
|
|
248
|
+
path: entry.path,
|
|
249
|
+
reason: `overrides ${entry.shadows.length} lower-priority recipe(s)`,
|
|
250
|
+
actions: ["keep override", "merge", "delete", "archive"],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function recommendationForEntry(
|
|
257
|
+
entry: DiscoveredRecipe,
|
|
258
|
+
activePath: string | undefined,
|
|
259
|
+
): Record<string, unknown> | undefined {
|
|
260
|
+
const recommendation = cleanupRecommendation(entry);
|
|
261
|
+
if (!recommendation) return undefined;
|
|
262
|
+
if (entry.shadowed && activePath) {
|
|
263
|
+
return {
|
|
264
|
+
...recommendation,
|
|
265
|
+
reason: `shadowed by ${activePath}`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return recommendation;
|
|
269
|
+
}
|
|
270
|
+
|
|
195
271
|
export function summarizeDiscovery(result: RecipeDiscoveryResult): Record<string, unknown> {
|
|
272
|
+
const recommendations = result.entries
|
|
273
|
+
.map((entry) => recommendationForEntry(entry, result.active.get(entry.id)?.path))
|
|
274
|
+
.filter((entry): entry is Record<string, unknown> => Boolean(entry))
|
|
275
|
+
.sort((a, b) => String(a.id).localeCompare(String(b.id)) || String(a.path).localeCompare(String(b.path)));
|
|
196
276
|
return {
|
|
197
277
|
active: [...result.active.values()].map((entry) => ({
|
|
198
278
|
id: entry.id,
|
|
@@ -202,6 +282,7 @@ export function summarizeDiscovery(result: RecipeDiscoveryResult): Record<string
|
|
|
202
282
|
disabled: entry.disabled,
|
|
203
283
|
invalid: entry.invalid,
|
|
204
284
|
shadows: entry.shadows,
|
|
285
|
+
...(recipeUsage(entry.config) ? { usage: recipeUsage(entry.config) } : {}),
|
|
205
286
|
})).sort((a, b) => a.id.localeCompare(b.id)),
|
|
206
287
|
shadowed: result.entries
|
|
207
288
|
.filter((entry) => entry.shadowed)
|
|
@@ -215,6 +296,7 @@ export function summarizeDiscovery(result: RecipeDiscoveryResult): Record<string
|
|
|
215
296
|
.filter((entry) => entry.disabled)
|
|
216
297
|
.map((entry) => ({ id: entry.id, path: entry.path }))
|
|
217
298
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
299
|
+
recommendations,
|
|
218
300
|
diagnostics: result.diagnostics,
|
|
219
301
|
};
|
|
220
302
|
}
|
package/lib/recipe-migration.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Legacy
|
|
2
|
+
* Legacy tool-registry to recipe-file migration helpers
|
|
3
3
|
* Zones: registry migration, recipe persistence, compatibility diagnostics
|
|
4
4
|
* Owns one-way migration from legacy tool registry entries into user recipe files
|
|
5
5
|
*/
|
|
@@ -98,7 +98,7 @@ export function migrateLegacyToolRegistry(
|
|
|
98
98
|
|
|
99
99
|
if (loaded.tools.size === 0) result.skipped.push(basename(configPath));
|
|
100
100
|
|
|
101
|
-
const reportPath = join(recipeRoot, "
|
|
101
|
+
const reportPath = join(recipeRoot, "legacy-tool-registry-migration-report.json");
|
|
102
102
|
writeJsonAtomic(reportPath, {
|
|
103
103
|
source: configPath,
|
|
104
104
|
migrated: result.migrated,
|
package/lib/recipe-references.ts
CHANGED
|
@@ -50,6 +50,7 @@ export interface TemplateRecipeDefinition {
|
|
|
50
50
|
recover?: CommandTemplateValue;
|
|
51
51
|
repeat?: number;
|
|
52
52
|
values?: Record<string, unknown>;
|
|
53
|
+
usage?: Record<string, unknown>;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
export interface TemplateRecipeConfig extends TemplateRecipeDefinition {
|
|
@@ -597,6 +598,7 @@ export function readResolvedRecipeConfig(
|
|
|
597
598
|
? { repeat: substituted.repeat }
|
|
598
599
|
: {}),
|
|
599
600
|
...(isRecord(substituted.values) ? { values: substituted.values } : {}),
|
|
601
|
+
...(isRecord(substituted.usage) ? { usage: substituted.usage } : {}),
|
|
600
602
|
};
|
|
601
603
|
}
|
|
602
604
|
|