@llblab/pi-actors 0.20.1 → 0.21.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/BACKLOG.md +0 -52
- package/CHANGELOG.md +25 -0
- package/README.md +7 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.js +385 -0
- package/dist/lib/actor-rooms.d.ts +1 -0
- package/dist/lib/actor-rooms.js +28 -11
- package/dist/lib/async-runs.d.ts +17 -1
- package/dist/lib/async-runs.js +127 -36
- package/dist/lib/command-templates.js +8 -1
- package/dist/lib/observability.d.ts +15 -0
- package/dist/lib/observability.js +103 -18
- package/dist/lib/recipe-discovery.js +13 -5
- package/dist/lib/recipe-references.js +137 -11
- package/dist/lib/tools.js +5 -5
- package/docs/README.md +1 -1
- package/docs/actor-messages.md +4 -1
- package/docs/async-runs.md +3 -3
- package/docs/recipe-library.md +14 -4
- package/docs/template-recipes.md +37 -7
- package/docs/tool-registry.md +4 -3
- package/index.js +19 -0
- package/index.ts +14 -0
- package/lib/actor-rooms.ts +35 -9
- package/lib/async-runs.ts +186 -48
- package/lib/command-templates.ts +8 -1
- package/lib/observability.ts +133 -20
- package/lib/recipe-discovery.ts +21 -6
- package/lib/recipe-references.ts +141 -17
- package/lib/tools.ts +10 -8
- package/package.json +5 -3
- package/recipes/pipeline-room-swarm.json +1 -1
- package/scripts/coordinator.mjs +60 -32
- package/scripts/locker.mjs +61 -23
- package/scripts/validate-recipe.mjs +2 -2
- package/skills/actors/SKILL.md +10 -9
- package/skills/swarm/SKILL.md +1 -1
package/lib/recipe-references.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import { basename, dirname, resolve } from "node:path";
|
|
9
|
+
import { basename, dirname, extname, resolve } from "node:path";
|
|
10
10
|
|
|
11
11
|
import type {
|
|
12
12
|
CommandTemplateConfig,
|
|
@@ -101,7 +101,9 @@ export function resolveRecipePath(
|
|
|
101
101
|
if (expanded.includes("/")) return resolve(expanded);
|
|
102
102
|
return resolve(
|
|
103
103
|
recipeRoot,
|
|
104
|
-
expanded.endsWith(".json")
|
|
104
|
+
expanded.endsWith(".json") || expanded.endsWith(".md")
|
|
105
|
+
? expanded
|
|
106
|
+
: `${expanded}.json`,
|
|
105
107
|
);
|
|
106
108
|
}
|
|
107
109
|
|
|
@@ -110,20 +112,26 @@ function isBareRecipeName(value: string): boolean {
|
|
|
110
112
|
return Boolean(trimmed) && !trimmed.includes("/") && !trimmed.startsWith("~") && !trimmed.includes("{");
|
|
111
113
|
}
|
|
112
114
|
|
|
113
|
-
function
|
|
115
|
+
function recipeNameFiles(value: string): string[] {
|
|
114
116
|
const trimmed = value.trim();
|
|
115
|
-
|
|
117
|
+
if (trimmed.endsWith(".json") || trimmed.endsWith(".md")) return [trimmed];
|
|
118
|
+
return [`${trimmed}.json`, `${trimmed}.md`];
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
function resolveRecipeImportPath(value: string, currentRecipeRoot: string): string {
|
|
119
122
|
if (!isBareRecipeName(value)) return resolveRecipePath(value, currentRecipeRoot);
|
|
120
|
-
const file = recipeNameFile(value);
|
|
121
123
|
const roots = [
|
|
122
124
|
Paths.getRecipeRoot(),
|
|
123
125
|
currentRecipeRoot,
|
|
124
126
|
Paths.getPackagedRecipeRoot(),
|
|
125
127
|
];
|
|
126
|
-
const candidates = [
|
|
128
|
+
const candidates = [
|
|
129
|
+
...new Set(
|
|
130
|
+
roots.flatMap((root) =>
|
|
131
|
+
recipeNameFiles(value).map((file) => resolve(root, file)),
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
];
|
|
127
135
|
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
|
|
128
136
|
}
|
|
129
137
|
|
|
@@ -134,14 +142,14 @@ export function getRecipePath(
|
|
|
134
142
|
if (typeof value !== "string") return undefined;
|
|
135
143
|
const trimmed = value.trim();
|
|
136
144
|
if (!trimmed || hasWhitespace(trimmed)) return undefined;
|
|
137
|
-
if (trimmed.endsWith(".json")
|
|
138
|
-
|
|
145
|
+
if (trimmed.endsWith(".json") || trimmed.endsWith(".md"))
|
|
146
|
+
return resolveRecipePath(trimmed, recipeRoot);
|
|
147
|
+
const jsonPath = resolveRecipePath(trimmed, recipeRoot);
|
|
148
|
+
const mdPath = resolveRecipePath(`${trimmed}.md`, recipeRoot);
|
|
149
|
+
const path = existsSync(jsonPath) ? jsonPath : mdPath;
|
|
139
150
|
if (!existsSync(path)) return undefined;
|
|
140
151
|
try {
|
|
141
|
-
const raw =
|
|
142
|
-
string,
|
|
143
|
-
unknown
|
|
144
|
-
>;
|
|
152
|
+
const raw = readRawRecipeConfig(path);
|
|
145
153
|
return raw && typeof raw === "object" && Object.hasOwn(raw, "template")
|
|
146
154
|
? path
|
|
147
155
|
: undefined;
|
|
@@ -232,6 +240,123 @@ function getRecipeCommandTemplate(
|
|
|
232
240
|
return normalizeRecipeTemplate({ ...envelope, template });
|
|
233
241
|
}
|
|
234
242
|
|
|
243
|
+
function parseMarkdownScalar(value: string): unknown {
|
|
244
|
+
const trimmed = value.trim();
|
|
245
|
+
if (!trimmed) return "";
|
|
246
|
+
if (
|
|
247
|
+
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
248
|
+
(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
|
249
|
+
) {
|
|
250
|
+
try {
|
|
251
|
+
return JSON.parse(trimmed);
|
|
252
|
+
} catch {
|
|
253
|
+
return trimmed;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const quoted = trimmed.match(/^(?:"([^"]*)"|'([^']*)')$/);
|
|
257
|
+
if (quoted) return quoted[1] ?? quoted[2] ?? "";
|
|
258
|
+
if (trimmed === "true") return true;
|
|
259
|
+
if (trimmed === "false") return false;
|
|
260
|
+
if (trimmed === "null") return null;
|
|
261
|
+
if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
262
|
+
return trimmed;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function parseMarkdownFrontmatterObject(
|
|
266
|
+
lines: string[],
|
|
267
|
+
): Record<string, unknown> | unknown[] {
|
|
268
|
+
if (lines.every((line) => /^\s*-\s+/.test(line))) {
|
|
269
|
+
return lines.map((line) => parseMarkdownScalar(line.replace(/^\s*-\s+/, "")));
|
|
270
|
+
}
|
|
271
|
+
const result: Record<string, unknown> = {};
|
|
272
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
273
|
+
const match = lines[index].match(/^\s{2}([A-Za-z_][A-Za-z0-9_.-]*):\s*(.*)$/);
|
|
274
|
+
if (!match) continue;
|
|
275
|
+
if (match[2]) {
|
|
276
|
+
result[match[1]] = parseMarkdownScalar(match[2]);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const nested: string[] = [];
|
|
280
|
+
while (index + 1 < lines.length && /^\s{4}/.test(lines[index + 1])) {
|
|
281
|
+
index += 1;
|
|
282
|
+
nested.push(lines[index].slice(2));
|
|
283
|
+
}
|
|
284
|
+
result[match[1]] = parseMarkdownFrontmatterObject(nested);
|
|
285
|
+
}
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function parseMarkdownFrontmatter(value: string): Record<string, unknown> {
|
|
290
|
+
const result: Record<string, unknown> = {};
|
|
291
|
+
const lines = value.split(/\r?\n/);
|
|
292
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
293
|
+
const line = lines[index];
|
|
294
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
295
|
+
const match = line.match(/^([A-Za-z_][A-Za-z0-9_.-]*):\s*(.*)$/);
|
|
296
|
+
if (!match) continue;
|
|
297
|
+
if (match[2]) {
|
|
298
|
+
result[match[1]] = parseMarkdownScalar(match[2]);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const nested: string[] = [];
|
|
302
|
+
while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
|
|
303
|
+
index += 1;
|
|
304
|
+
nested.push(lines[index]);
|
|
305
|
+
}
|
|
306
|
+
result[match[1]] = parseMarkdownFrontmatterObject(nested);
|
|
307
|
+
}
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function findMarkdownRecipeFence(
|
|
312
|
+
body: string,
|
|
313
|
+
): { info: string; body: string } | undefined {
|
|
314
|
+
const pattern = /```([^\n`]*)\n([\s\S]*?)```/g;
|
|
315
|
+
for (const match of body.matchAll(pattern)) {
|
|
316
|
+
const info = match[1].trim().toLowerCase();
|
|
317
|
+
if (
|
|
318
|
+
info.includes("recipe") ||
|
|
319
|
+
info.includes("template") ||
|
|
320
|
+
info.includes("command") ||
|
|
321
|
+
info.includes("json")
|
|
322
|
+
) {
|
|
323
|
+
return { info, body: match[2].trim() };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function parseMarkdownRecipeConfig(
|
|
330
|
+
content: string,
|
|
331
|
+
): Record<string, unknown> | undefined {
|
|
332
|
+
const lines = content.split(/\r?\n/);
|
|
333
|
+
if (lines[0]?.trim() !== "---") return undefined;
|
|
334
|
+
const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
335
|
+
if (end === -1) return undefined;
|
|
336
|
+
const frontmatter = parseMarkdownFrontmatter(lines.slice(1, end).join("\n"));
|
|
337
|
+
const fence = findMarkdownRecipeFence(lines.slice(end + 1).join("\n"));
|
|
338
|
+
if (!fence) return Object.hasOwn(frontmatter, "template") ? frontmatter : undefined;
|
|
339
|
+
const text = fence.body.trim();
|
|
340
|
+
if (!text) return undefined;
|
|
341
|
+
if (
|
|
342
|
+
fence.info.includes("json") ||
|
|
343
|
+
fence.info.includes("recipe") ||
|
|
344
|
+
text.startsWith("{") ||
|
|
345
|
+
text.startsWith("[") ||
|
|
346
|
+
text.startsWith('"')
|
|
347
|
+
) {
|
|
348
|
+
try {
|
|
349
|
+
const parsed = JSON.parse(text) as unknown;
|
|
350
|
+
if (isRecord(parsed) && Object.hasOwn(parsed, "template"))
|
|
351
|
+
return { ...frontmatter, ...parsed };
|
|
352
|
+
return { ...frontmatter, template: parsed };
|
|
353
|
+
} catch {
|
|
354
|
+
if (fence.info.includes("json") || fence.info.includes("recipe")) return undefined;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return { ...frontmatter, template: text };
|
|
358
|
+
}
|
|
359
|
+
|
|
235
360
|
export function readRawRecipeConfig(
|
|
236
361
|
path: string,
|
|
237
362
|
): Record<string, unknown> | undefined {
|
|
@@ -243,10 +368,9 @@ export function readRawRecipeConfig(
|
|
|
243
368
|
);
|
|
244
369
|
}
|
|
245
370
|
try {
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
>;
|
|
371
|
+
const content = readFileSync(path, "utf8");
|
|
372
|
+
if (path.endsWith(".md")) return parseMarkdownRecipeConfig(content);
|
|
373
|
+
const raw = JSON.parse(content) as Record<string, unknown>;
|
|
250
374
|
return raw && typeof raw === "object" ? raw : undefined;
|
|
251
375
|
} catch {
|
|
252
376
|
return undefined;
|
|
@@ -254,7 +378,7 @@ export function readRawRecipeConfig(
|
|
|
254
378
|
}
|
|
255
379
|
|
|
256
380
|
export function getRecipeIdFromPath(file: string): string {
|
|
257
|
-
return basename(file,
|
|
381
|
+
return basename(file, extname(file));
|
|
258
382
|
}
|
|
259
383
|
|
|
260
384
|
function readRecipeConfig(value: unknown): TemplateRecipeConfig | undefined {
|
package/lib/tools.ts
CHANGED
|
@@ -1089,7 +1089,7 @@ export function createActorMessageToolDefinition<TContext = unknown>(
|
|
|
1089
1089
|
} else if (message.type === "control.kill") {
|
|
1090
1090
|
result = AsyncRuns.killRun(address.value);
|
|
1091
1091
|
} else {
|
|
1092
|
-
result = AsyncRuns.sendRunMessage(
|
|
1092
|
+
result = await AsyncRuns.sendRunMessage(
|
|
1093
1093
|
address.value,
|
|
1094
1094
|
messageBodyToRunLine(message),
|
|
1095
1095
|
);
|
|
@@ -1131,7 +1131,7 @@ export function createActorMessageToolDefinition<TContext = unknown>(
|
|
|
1131
1131
|
ActorRooms.writeCommunicationSnapshot(stateDir, runId);
|
|
1132
1132
|
ActorRooms.appendBranchInboxMessage(stateDir, runId, message.to, message);
|
|
1133
1133
|
}
|
|
1134
|
-
result = AsyncRuns.sendRunMessage(
|
|
1134
|
+
result = await AsyncRuns.sendRunMessage(
|
|
1135
1135
|
address.value,
|
|
1136
1136
|
JSON.stringify(message),
|
|
1137
1137
|
);
|
|
@@ -1143,16 +1143,18 @@ export function createActorMessageToolDefinition<TContext = unknown>(
|
|
|
1143
1143
|
if (!stateDir) throw new Error(`${message.to} has no run state directory.`);
|
|
1144
1144
|
const recipients = getRoomMulticastRecipients(message, runId);
|
|
1145
1145
|
const roomResult = ActorRooms.appendRoomMessage(stateDir, address.room, message);
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1146
|
+
await Promise.all(
|
|
1147
|
+
recipients.map((recipient) =>
|
|
1148
|
+
AsyncRuns.sendRunMessage(
|
|
1149
|
+
runId,
|
|
1150
|
+
JSON.stringify({ ...message, to: recipient }),
|
|
1151
|
+
),
|
|
1150
1152
|
),
|
|
1151
1153
|
);
|
|
1152
1154
|
result = {
|
|
1153
1155
|
...roomResult,
|
|
1154
|
-
...(
|
|
1155
|
-
? { multicast: recipients, multicast_count:
|
|
1156
|
+
...(recipients.length > 0
|
|
1157
|
+
? { multicast: recipients, multicast_count: recipients.length }
|
|
1156
1158
|
: {}),
|
|
1157
1159
|
};
|
|
1158
1160
|
} else if (address.kind === "tool" && address.value) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llblab/pi-actors",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Local Actor Kernel for Pi",
|
|
6
6
|
"keywords": [
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"prepack": "npm run build"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
|
+
"index.js",
|
|
36
37
|
"index.ts",
|
|
37
38
|
"lib",
|
|
38
39
|
"scripts",
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
],
|
|
49
50
|
"pi": {
|
|
50
51
|
"extensions": [
|
|
51
|
-
"./index.
|
|
52
|
+
"./index.js"
|
|
52
53
|
],
|
|
53
54
|
"skills": [
|
|
54
55
|
"./skills/actors/SKILL.md",
|
|
@@ -57,7 +58,8 @@
|
|
|
57
58
|
"image": "https://github.com/llblab/pi-actors/raw/main/banner.jpg"
|
|
58
59
|
},
|
|
59
60
|
"peerDependencies": {
|
|
60
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
61
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
62
|
+
"@earendil-works/pi-tui": "*"
|
|
61
63
|
},
|
|
62
64
|
"devDependencies": {
|
|
63
65
|
"@types/node": "latest",
|
package/scripts/coordinator.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { createConnection } from "node:net";
|
|
5
6
|
|
|
6
7
|
function arg(name, fallback = "") {
|
|
7
8
|
const prefix = `--${name}=`;
|
|
@@ -102,10 +103,34 @@ async function acquireBranchInboxLock(runId, branchName) {
|
|
|
102
103
|
return acquireStateLock(`${runStateDir(runId)}/branches/${branchName}`, ".inbox.lock", `Branch inbox ${branchName}`);
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
async function readLockerControl(locker) {
|
|
107
|
+
const controlJson = `${locker.stateDir}/control.json`;
|
|
108
|
+
await waitForPath(controlJson);
|
|
109
|
+
return JSON.parse(await readFile(controlJson, "utf8"));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function writeNamedPipeLine(path, line) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const socket = createConnection(path);
|
|
115
|
+
let settled = false;
|
|
116
|
+
const finish = (error) => {
|
|
117
|
+
if (settled) return;
|
|
118
|
+
settled = true;
|
|
119
|
+
if (error) reject(error);
|
|
120
|
+
else resolve();
|
|
121
|
+
};
|
|
122
|
+
socket.on("error", finish);
|
|
123
|
+
socket.on("connect", () => socket.end(line, () => finish()));
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
105
127
|
async function writeLockerMessage(locker, message) {
|
|
106
128
|
if (!locker) return;
|
|
107
|
-
await
|
|
108
|
-
|
|
129
|
+
const control = locker.control ?? await readLockerControl(locker);
|
|
130
|
+
locker.control = control;
|
|
131
|
+
const line = `${JSON.stringify(message)}\n`;
|
|
132
|
+
if (control.type === "named-pipe") await writeNamedPipeLine(control.path, line);
|
|
133
|
+
else await writeFile(control.path, line, { flag: "a" });
|
|
109
134
|
await sleep(50);
|
|
110
135
|
}
|
|
111
136
|
|
|
@@ -125,8 +150,8 @@ async function startLocker(config) {
|
|
|
125
150
|
});
|
|
126
151
|
child.stdout.on("data", (chunk) => process.stdout.write(`[locker] ${chunk}`));
|
|
127
152
|
child.stderr.on("data", (chunk) => process.stderr.write(`[locker] ${chunk}`));
|
|
128
|
-
const locker = { child,
|
|
129
|
-
await
|
|
153
|
+
const locker = { child, stateDir: lockerStateDir };
|
|
154
|
+
locker.control = await readLockerControl(locker);
|
|
130
155
|
await writeLockerMessage(locker, {
|
|
131
156
|
type: "lock.enqueue",
|
|
132
157
|
body: { id: "coordinator-artifact", task: "Own final artifact synthesis", resources: [config.artifactPath || "artifact"] },
|
|
@@ -230,11 +255,23 @@ async function writeInboxMessages(inboxPath, messages) {
|
|
|
230
255
|
await writeFile(inboxPath, messages.map((message) => JSON.stringify(message)).join("\n") + "\n", "utf8");
|
|
231
256
|
}
|
|
232
257
|
|
|
233
|
-
async function
|
|
258
|
+
async function mutateBranchInbox(runId, branchName, mutate, fallback) {
|
|
234
259
|
const inboxPath = `${runStateDir(runId)}/branches/${branchName}/inbox.jsonl`;
|
|
235
260
|
const releaseLock = await acquireBranchInboxLock(runId, branchName);
|
|
236
261
|
try {
|
|
237
262
|
const messages = await readInboxLines(inboxPath);
|
|
263
|
+
const result = mutate(messages);
|
|
264
|
+
if (result.messages) await writeInboxMessages(inboxPath, result.messages);
|
|
265
|
+
return result.value;
|
|
266
|
+
} catch {
|
|
267
|
+
return fallback;
|
|
268
|
+
} finally {
|
|
269
|
+
await releaseLock();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function claimQueuedInboxMessages(runId, branchName) {
|
|
274
|
+
return mutateBranchInbox(runId, branchName, (messages) => {
|
|
238
275
|
const claimedAt = new Date().toISOString();
|
|
239
276
|
const queuedMessages = [];
|
|
240
277
|
const updated = messages.map((msg, index) => {
|
|
@@ -248,31 +285,22 @@ async function claimQueuedInboxMessages(runId, branchName) {
|
|
|
248
285
|
queuedMessages.push(claimed);
|
|
249
286
|
return claimed;
|
|
250
287
|
});
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
await releaseLock();
|
|
257
|
-
}
|
|
288
|
+
return {
|
|
289
|
+
messages: queuedMessages.length > 0 ? updated : undefined,
|
|
290
|
+
value: queuedMessages,
|
|
291
|
+
};
|
|
292
|
+
}, []);
|
|
258
293
|
}
|
|
259
294
|
|
|
260
295
|
async function updateInboxMessagesStatus(runId, branchName, ids, status) {
|
|
261
|
-
|
|
262
|
-
const releaseLock = await acquireBranchInboxLock(runId, branchName);
|
|
263
|
-
try {
|
|
264
|
-
const messages = await readInboxLines(inboxPath);
|
|
296
|
+
await mutateBranchInbox(runId, branchName, (messages) => {
|
|
265
297
|
const idSet = new Set(ids);
|
|
266
298
|
const updated = messages.map((msg) => {
|
|
267
299
|
if (!msg.id || !idSet.has(msg.id)) return msg;
|
|
268
300
|
return { ...msg, [`${status}_at`]: new Date().toISOString(), status };
|
|
269
301
|
});
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
// Best-effort write
|
|
273
|
-
} finally {
|
|
274
|
-
await releaseLock();
|
|
275
|
-
}
|
|
302
|
+
return { messages: updated, value: undefined };
|
|
303
|
+
});
|
|
276
304
|
}
|
|
277
305
|
|
|
278
306
|
async function executeParticipantPrompt(role, basePrompt, config) {
|
|
@@ -421,6 +449,13 @@ async function runPool(config, locker) {
|
|
|
421
449
|
}));
|
|
422
450
|
}
|
|
423
451
|
|
|
452
|
+
const coordinatorModes = {
|
|
453
|
+
consensus: runConsensus,
|
|
454
|
+
fanout: runFanout,
|
|
455
|
+
pipeline: runPipeline,
|
|
456
|
+
pool: runPool,
|
|
457
|
+
};
|
|
458
|
+
|
|
424
459
|
async function readJsonFile(path) {
|
|
425
460
|
try {
|
|
426
461
|
return JSON.parse(await readFile(path, "utf8"));
|
|
@@ -464,16 +499,9 @@ const failures = [];
|
|
|
464
499
|
const locker = await startLocker(config);
|
|
465
500
|
|
|
466
501
|
try {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
await runFanout(config, locker);
|
|
471
|
-
} else if (config.mode === "pool") {
|
|
472
|
-
await runPool(config, locker);
|
|
473
|
-
} else {
|
|
474
|
-
// default: consensus / swarm
|
|
475
|
-
await runConsensus(config, locker);
|
|
476
|
-
}
|
|
502
|
+
const runMode = coordinatorModes[config.mode];
|
|
503
|
+
if (!runMode) throw new Error(`Unknown coordinator mode: ${config.mode}`);
|
|
504
|
+
await runMode(config, locker);
|
|
477
505
|
await synthesize(config, locker);
|
|
478
506
|
} catch (globalError) {
|
|
479
507
|
failures.push(`Global coordinator error: ${globalError.message}`);
|
package/scripts/locker.mjs
CHANGED
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
writeFileSync,
|
|
9
9
|
} from "node:fs";
|
|
10
10
|
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
11
12
|
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { createServer } from "node:net";
|
|
12
14
|
import readline from "node:readline";
|
|
13
15
|
|
|
14
16
|
function parseArgs(argv) {
|
|
@@ -49,6 +51,17 @@ function writeJson(path, value) {
|
|
|
49
51
|
mkdirSync(dirname(path), { recursive: true });
|
|
50
52
|
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
51
53
|
}
|
|
54
|
+
function getControlEndpoint() {
|
|
55
|
+
if (process.platform !== "win32") return { path: controlPath, type: "fifo" };
|
|
56
|
+
const hash = createHash("sha256").update(resolve(stateDir)).digest("hex").slice(0, 20);
|
|
57
|
+
return { path: `\\\\.\\pipe\\pi-actors-locker-${hash}`, type: "named-pipe" };
|
|
58
|
+
}
|
|
59
|
+
function writeControlEndpoint(endpoint) {
|
|
60
|
+
writeJson(join(stateDir, "control.json"), endpoint);
|
|
61
|
+
const runPath = join(stateDir, "run.json");
|
|
62
|
+
const run = readJson(runPath, undefined);
|
|
63
|
+
if (run && typeof run === "object") writeJson(runPath, { ...run, control: endpoint });
|
|
64
|
+
}
|
|
52
65
|
function journal(event, data = {}) {
|
|
53
66
|
appendFileSync(
|
|
54
67
|
journalPath,
|
|
@@ -244,30 +257,55 @@ if (mode === "snapshot") {
|
|
|
244
257
|
process.exit(0);
|
|
245
258
|
}
|
|
246
259
|
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
if (
|
|
250
|
-
|
|
260
|
+
function handleLine(line) {
|
|
261
|
+
const message = normalizeMessage(line);
|
|
262
|
+
if (!message) return;
|
|
263
|
+
try {
|
|
264
|
+
handle(message);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
267
|
+
journal("lock.error", { error: text });
|
|
268
|
+
outbox("lock.error", text, { error: text }, "error");
|
|
269
|
+
}
|
|
251
270
|
}
|
|
252
|
-
writeJson(queuePath, readJson(queuePath, { items: [] }));
|
|
253
|
-
writeJson(locksPath, cleanExpiredLocks(readJson(locksPath, {})));
|
|
254
|
-
journal("lock.started", { leaseMs });
|
|
255
|
-
outbox("lock.started", "Locker ready", { leaseMs });
|
|
256
271
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
outbox("lock.error", text, { error: text }, "error");
|
|
271
|
-
}
|
|
272
|
+
async function serveFifo(endpoint) {
|
|
273
|
+
if (!existsSync(endpoint.path)) {
|
|
274
|
+
const result = spawnSync("mkfifo", [endpoint.path]);
|
|
275
|
+
if (result.status !== 0)
|
|
276
|
+
throw new Error(`mkfifo failed: ${result.stderr?.toString?.() ?? ""}`);
|
|
277
|
+
}
|
|
278
|
+
writeControlEndpoint(endpoint);
|
|
279
|
+
while (true) {
|
|
280
|
+
const stream = await import("node:fs").then((fs) =>
|
|
281
|
+
fs.createReadStream(endpoint.path, { encoding: "utf8" }),
|
|
282
|
+
);
|
|
283
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
284
|
+
for await (const line of rl) handleLine(line);
|
|
272
285
|
}
|
|
273
286
|
}
|
|
287
|
+
|
|
288
|
+
async function serveNamedPipe(endpoint) {
|
|
289
|
+
rmSync(endpoint.path, { force: true });
|
|
290
|
+
const server = createServer((socket) => {
|
|
291
|
+
const rl = readline.createInterface({ input: socket, crlfDelay: Infinity });
|
|
292
|
+
rl.on("line", handleLine);
|
|
293
|
+
});
|
|
294
|
+
await new Promise((resolveReady, rejectReady) => {
|
|
295
|
+
server.once("error", rejectReady);
|
|
296
|
+
server.listen(endpoint.path, () => {
|
|
297
|
+
server.off("error", rejectReady);
|
|
298
|
+
writeControlEndpoint(endpoint);
|
|
299
|
+
resolveReady();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
await new Promise(() => {});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const endpoint = getControlEndpoint();
|
|
306
|
+
writeJson(queuePath, readJson(queuePath, { items: [] }));
|
|
307
|
+
writeJson(locksPath, cleanExpiredLocks(readJson(locksPath, {})));
|
|
308
|
+
journal("lock.started", { leaseMs, control: endpoint.type });
|
|
309
|
+
outbox("lock.started", "Locker ready", { leaseMs, control: endpoint.type });
|
|
310
|
+
if (endpoint.type === "named-pipe") await serveNamedPipe(endpoint);
|
|
311
|
+
else await serveFifo(endpoint);
|
|
@@ -26,7 +26,7 @@ function usage() {
|
|
|
26
26
|
console.error(`Usage:
|
|
27
27
|
validate-recipe.mjs <recipe-file-or-dir> [--all]
|
|
28
28
|
|
|
29
|
-
Validates one template recipe file, or all *.json files in a directory when --all is set.`);
|
|
29
|
+
Validates one template recipe file, or all *.json/*.md files in a directory when --all is set.`);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function expandPath(value) {
|
|
@@ -56,7 +56,7 @@ function recipeFiles(target, all) {
|
|
|
56
56
|
throw new Error(`Recipe path is not a file or directory: ${target}`);
|
|
57
57
|
if (!all) throw new Error("Directory validation requires --all.");
|
|
58
58
|
return readdirSync(target)
|
|
59
|
-
.filter((file) => file.endsWith(".json"))
|
|
59
|
+
.filter((file) => file.endsWith(".json") || file.endsWith(".md"))
|
|
60
60
|
.sort((a, b) => a.localeCompare(b))
|
|
61
61
|
.map((file) => resolve(target, file));
|
|
62
62
|
}
|
package/skills/actors/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: actors
|
|
3
3
|
description: Highest-density practical guide for pi-actors. Read this skill whenever prompt and tools are not enough for spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 0.
|
|
5
|
+
version: 0.21.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Actors (pi-actors)
|
|
@@ -228,11 +228,11 @@ Priority for same-id recipes:
|
|
|
228
228
|
1. No recipe: no capability.
|
|
229
229
|
2. Packaged pi-actors recipe: standard-library declarative actor component.
|
|
230
230
|
3. Explicit ad hoc user recipe file outside `~/.pi/agent/recipes`.
|
|
231
|
-
4. User recipe in `~/.pi/agent/recipes/*.json`: highest-priority operator tool surface.
|
|
231
|
+
4. User recipe in `~/.pi/agent/recipes/*.json` or `*.md`: highest-priority operator tool surface.
|
|
232
232
|
|
|
233
|
-
Only matching filename ids compete. Higher priority shadows lower priority. An invalid or `disabled: true` higher-priority recipe blocks fallback so the agent does not silently run standard-library behavior when a user override is broken or intentionally disabled.
|
|
233
|
+
Only matching filename ids compete. Higher priority shadows lower priority; within one priority layer, same-id JSON shadows Markdown. An invalid or `disabled: true` higher-priority recipe blocks fallback so the agent does not silently run standard-library behavior when a user override is broken or intentionally disabled.
|
|
234
234
|
|
|
235
|
-
Muscle-memory lens: `~/.pi/agent/recipes/*.json`
|
|
235
|
+
Muscle-memory lens: `~/.pi/agent/recipes/*.json` and `*.md` are the agent's capability memory. Every recipe in that directory becomes an easy-to-call tool automatically and survives into later sessions. Agents grow this memory either by calling `register_tool`, which writes recipe files there under the hood, or by deliberately editing those recipe files. Treat this directory like `MEMORY.md` for executable habits: useful local patterns belong there; packaged recipes elsewhere are reusable components, not tools.
|
|
236
236
|
|
|
237
237
|
Usage lens: user recipes may carry extension-maintained launch metadata such as `usage.calls` and `usage.last_called`. The extension increments the counter when it starts that concrete recipe; agents should not hand-edit counters as part of normal recipe maintenance. Treat usage as evidence for usefulness analysis: heavily used recipes are good candidates for promotion, documentation, or stronger tests; unused recipes are cleanup candidates. Do not use failure counts as a primary usefulness signal because failures may reflect bad caller judgment rather than bad recipes. Do not delete or demote solely from counters without operator approval.
|
|
238
238
|
|
|
@@ -240,7 +240,7 @@ Cleanup rule: periodically inspect `~/.pi/agent/recipes` as the live muscle-memo
|
|
|
240
240
|
|
|
241
241
|
## Registered Tools
|
|
242
242
|
|
|
243
|
-
`register_tool` persists trusted local capabilities as recipe files in `~/.pi/agent/recipes/*.json
|
|
243
|
+
`register_tool` persists trusted local capabilities as recipe files in `~/.pi/agent/recipes/*.json`; hand-authored Markdown recipes in the same directory are also discovered as tools.
|
|
244
244
|
|
|
245
245
|
Use it when a command/template/recipe should become durable agent muscle memory. Prefer typed args or placeholder-derived args; use `update=true` for replacement and `template=null` or `template=""` for deletion. `register_tool` should create/update/delete recipe files in the user recipe root; direct file editing is allowed but is the lower-level path.
|
|
246
246
|
|
|
@@ -250,7 +250,7 @@ Tool templates may be:
|
|
|
250
250
|
- A file-backed recipe name/path.
|
|
251
251
|
- A complete recipe body, optionally `async: true`.
|
|
252
252
|
|
|
253
|
-
The user recipe root is the default tool set by location;
|
|
253
|
+
The user recipe root is the default tool set by location. It accepts canonical JSON recipes and literate Markdown recipes with frontmatter plus fenced `template`/`json recipe` blocks; same-id JSON shadows Markdown in the same priority layer. Packaged recipes are lower-priority standard-library components and are not tools unless copied or registered into the agent recipe root. Ideal runtime behavior is reactive: create/edit/delete recipe files, validate them, then connect valid tools or surface diagnostics without requiring agents to hand-maintain a separate registry.
|
|
254
254
|
|
|
255
255
|
## Recipe Navigator
|
|
256
256
|
|
|
@@ -258,8 +258,8 @@ Use packaged recipes by name with `spawn file=<name>` for async actors, or regis
|
|
|
258
258
|
|
|
259
259
|
### Coordination and Services
|
|
260
260
|
|
|
261
|
-
- [`coordinator-locker`](../../recipes/coordinator-locker.json): queue + acquire/renew/release lease locks + journaled coordinator messages.
|
|
262
|
-
- [`locker`](../../recipes/locker.json): modular queue + acquire/renew/release lease locks + journaled locker messages.
|
|
261
|
+
- [`coordinator-locker`](../../recipes/coordinator-locker.json): queue + acquire/renew/release lease locks + journaled coordinator messages + platform-adapted control metadata.
|
|
262
|
+
- [`locker`](../../recipes/locker.json): modular queue + acquire/renew/release lease locks + journaled locker messages + platform-adapted control metadata.
|
|
263
263
|
- [`utility-coordinator-lock-snapshot`](../../recipes/utility-coordinator-lock-snapshot.json): one-shot JSON snapshot of a coordinator-locker state directory.
|
|
264
264
|
- [`music-player`](../../recipes/music-player.json): background local/URL/directory/playlist playback actor controlled by messages.
|
|
265
265
|
|
|
@@ -280,7 +280,7 @@ Use packaged recipes by name with `spawn file=<name>` for async actors, or regis
|
|
|
280
280
|
- [`pipeline-docs-maintenance`](../../recipes/pipeline-docs-maintenance.json): docs index/review/planning → maintenance artifact.
|
|
281
281
|
- Artifacts: [`pipeline-artifact-report`](../../recipes/pipeline-artifact-report.json), [`pipeline-artifact-write`](../../recipes/pipeline-artifact-write.json), [`pipeline-artifact-bundle`](../../recipes/pipeline-artifact-bundle.json).
|
|
282
282
|
- Review gates: [`pipeline-quorum-review`](../../recipes/pipeline-quorum-review.json), [`pipeline-review-readiness`](../../recipes/pipeline-review-readiness.json).
|
|
283
|
-
- Task-first workflows: [`pipeline-architect-coordinator`](../../recipes/pipeline-architect-coordinator.json), [`pipeline-research-synthesis`](../../recipes/pipeline-research-synthesis.json), [`pipeline-development-tasking`](../../recipes/pipeline-development-tasking.json), [`pipeline-checkpoint-continuation`](../../recipes/pipeline-checkpoint-continuation.json), [`pipeline-media-library`](../../recipes/pipeline-media-library.json), [`pipeline-room-swarm`](../../recipes/pipeline-room-swarm.json). For room swarms, prefer `roles_path` for custom role JSON and keep role `name` ASCII-safe for branch addresses. Use `locker=true` when the swarm needs a coordinator-locker-backed artifact lock and journal.
|
|
283
|
+
- Task-first workflows: [`pipeline-architect-coordinator`](../../recipes/pipeline-architect-coordinator.json), [`pipeline-research-synthesis`](../../recipes/pipeline-research-synthesis.json), [`pipeline-development-tasking`](../../recipes/pipeline-development-tasking.json), [`pipeline-checkpoint-continuation`](../../recipes/pipeline-checkpoint-continuation.json), [`pipeline-media-library`](../../recipes/pipeline-media-library.json), [`pipeline-room-swarm`](../../recipes/pipeline-room-swarm.json). For room swarms, choose `mode` from `consensus`, `pipeline`, `fanout`, or `pool`; prefer `roles_path` for custom role JSON and keep role `name` ASCII-safe for branch addresses. Use `locker=true` when the swarm needs a coordinator-locker-backed artifact lock and journal.
|
|
284
284
|
|
|
285
285
|
### Utilities
|
|
286
286
|
|
|
@@ -339,6 +339,7 @@ Keep the split clean: methodology chooses coordination shape; pi-actors supplies
|
|
|
339
339
|
- Sending domain messages without checking `mailbox`.
|
|
340
340
|
- Expecting current room messages to wake prompt-only subagents; use direct branch messages or a runner protocol for initiating work.
|
|
341
341
|
- Reading only stdout and missing actor messages/artifacts.
|
|
342
|
+
- Assuming every packaged message-controlled script is native-Windows-ready; core run control is platform-adapted, but Unix-tool scripts must be migrated recipe by recipe.
|
|
342
343
|
- Baking local absolute paths into published docs or reusable recipes.
|
|
343
344
|
- Creating recipes that perform external side effects without explicit operator gates.
|
|
344
345
|
- Letting project insights live only in chat instead of updating BACKLOG/CHANGELOG/docs and, when agent behavior changes, the packaged skill or prompt guidance.
|
package/skills/swarm/SKILL.md
CHANGED