@llblab/pi-actors 0.17.0 → 0.18.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 +5 -3
- package/BACKLOG.md +54 -29
- package/CHANGELOG.md +18 -2
- package/README.md +184 -300
- package/docs/actor-messages.md +6 -2
- package/docs/async-runs.md +3 -5
- package/docs/command-templates.md +2 -0
- package/docs/recipe-library.md +3 -0
- package/docs/task-first-recipes.md +29 -0
- package/docs/template-recipes.md +9 -14
- package/index.ts +158 -34
- package/lib/actor-inspector-tui.ts +374 -118
- package/lib/actor-rooms.ts +222 -24
- package/lib/async-runs.ts +59 -1
- package/lib/execution.ts +17 -0
- package/lib/file-state.ts +2 -1
- package/lib/observability.ts +82 -2
- package/lib/prompts.ts +2 -2
- package/lib/recipe-discovery.ts +86 -6
- package/lib/recipe-migration.ts +0 -2
- package/lib/recipe-references.ts +43 -10
- package/lib/temp.ts +55 -2
- package/lib/tools.ts +99 -11
- package/package.json +1 -1
- package/recipes/coordinator-locker.json +0 -1
- package/recipes/lens-swarm.json +0 -1
- package/recipes/music-player.json +0 -1
- package/recipes/pipeline-architect-coordinator.json +0 -1
- package/recipes/pipeline-artifact-bundle.json +0 -1
- package/recipes/pipeline-artifact-report.json +0 -1
- package/recipes/pipeline-artifact-write.json +0 -1
- package/recipes/pipeline-async-run-ops.json +0 -1
- package/recipes/pipeline-checkpoint-continuation.json +0 -1
- package/recipes/pipeline-development-tasking.json +0 -1
- package/recipes/pipeline-docs-maintenance.json +0 -1
- package/recipes/pipeline-media-library.json +0 -1
- package/recipes/pipeline-quorum-review.json +0 -1
- package/recipes/pipeline-release-readiness.json +0 -1
- package/recipes/pipeline-release-summary.json +0 -1
- package/recipes/pipeline-repo-health.json +0 -1
- package/recipes/pipeline-research-synthesis.json +0 -1
- package/recipes/pipeline-review-readiness.json +0 -1
- package/recipes/pipeline-room-swarm.json +48 -0
- package/recipes/subagent-artifact.json +0 -1
- package/recipes/subagent-checkpoint.json +0 -1
- package/recipes/subagent-conflict-report.json +0 -1
- package/recipes/subagent-contradiction-map.json +0 -1
- package/recipes/subagent-critic.json +0 -1
- package/recipes/subagent-evidence-map.json +0 -1
- package/recipes/subagent-followup.json +0 -1
- package/recipes/subagent-judge.json +0 -1
- package/recipes/subagent-merge.json +0 -1
- package/recipes/subagent-message.json +0 -1
- package/recipes/subagent-normalize.json +0 -1
- package/recipes/subagent-plan.json +0 -1
- package/recipes/subagent-prompt.json +0 -1
- package/recipes/subagent-quorum.json +0 -1
- package/recipes/subagent-review-coordinator.json +0 -1
- package/recipes/subagent-review.json +0 -1
- package/recipes/subagent-task-card.json +0 -1
- package/recipes/subagent-tools.json +0 -1
- package/recipes/subagent-verify.json +0 -1
- package/recipes/subagents-prompts.json +0 -1
- package/recipes/utility-actor-message.json +0 -1
- package/recipes/utility-artifact-manifest.json +0 -1
- package/recipes/utility-artifact-write.json +0 -1
- package/recipes/utility-changelog-head.json +0 -1
- package/recipes/utility-changelog-section.json +0 -1
- package/recipes/utility-coordinator-lock-snapshot.json +0 -1
- package/recipes/utility-git-log.json +0 -1
- package/recipes/utility-git-status.json +0 -1
- package/recipes/utility-jsonl-tail.json +0 -1
- package/recipes/utility-markdown-index.json +0 -1
- package/recipes/utility-package-summary.json +0 -1
- package/recipes/utility-playlist-build.json +0 -1
- package/recipes/utility-playlist-scan.json +0 -1
- package/recipes/utility-run-ops-snapshot.json +0 -1
- package/recipes/utility-run-state-files.json +0 -1
- package/recipes/utility-run-summary.json +0 -1
- package/recipes/utility-skill-summary.json +0 -1
- package/recipes/utility-validate-recipe.json +0 -1
- package/recipes/utility-validation-wrapper.json +0 -1
- package/scripts/room-swarm.mjs +243 -0
- package/skills/actors/SKILL.md +25 -12
- package/skills/swarm/SKILL.md +15 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"async": true,
|
|
3
|
+
"args": [
|
|
4
|
+
"mission:string",
|
|
5
|
+
"model:string",
|
|
6
|
+
"thinking:string",
|
|
7
|
+
"roles:string",
|
|
8
|
+
"roles_path:path",
|
|
9
|
+
"rounds:int",
|
|
10
|
+
"delay:int",
|
|
11
|
+
"locker:bool",
|
|
12
|
+
"locker_lease_ms:int",
|
|
13
|
+
"artifact_path:path",
|
|
14
|
+
"repo:path"
|
|
15
|
+
],
|
|
16
|
+
"defaults": {
|
|
17
|
+
"thinking": "off",
|
|
18
|
+
"roles": "",
|
|
19
|
+
"roles_path": "",
|
|
20
|
+
"rounds": "4",
|
|
21
|
+
"delay": "10",
|
|
22
|
+
"locker": "false",
|
|
23
|
+
"locker_lease_ms": "600000",
|
|
24
|
+
"artifact_path": "{state_dir}/room-swarm-artifact.md",
|
|
25
|
+
"repo": "~/.pi/agent/extensions/pi-actors"
|
|
26
|
+
},
|
|
27
|
+
"artifacts": {
|
|
28
|
+
"artifact": "{artifact_path}",
|
|
29
|
+
"locker_journal": "{state_dir}/locker/journal.jsonl",
|
|
30
|
+
"locker_locks": "{state_dir}/locker/locks.json",
|
|
31
|
+
"locker_queue": "{state_dir}/locker/queue.json"
|
|
32
|
+
},
|
|
33
|
+
"mailbox": {
|
|
34
|
+
"accepts": [
|
|
35
|
+
"control.stop",
|
|
36
|
+
"control.cancel",
|
|
37
|
+
"control.kill"
|
|
38
|
+
],
|
|
39
|
+
"emits": [
|
|
40
|
+
"chat.message",
|
|
41
|
+
"actor.join",
|
|
42
|
+
"actor.leave",
|
|
43
|
+
"run.done",
|
|
44
|
+
"run.failed"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"template": "{repo}/scripts/room-swarm.mjs --run-id={run_id} --mission={mission} --model={model} --thinking={thinking} --roles={roles} --roles-path={roles_path} --rounds={rounds} --delay={delay} --locker={locker} --locker-lease-ms={locker_lease_ms} --artifact-path={artifact_path}"
|
|
48
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
function arg(name, fallback = "") {
|
|
7
|
+
const prefix = `--${name}=`;
|
|
8
|
+
const value = process.argv.find((item) => item.startsWith(prefix));
|
|
9
|
+
return value ? value.slice(prefix.length) : fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function numberArg(name, fallback) {
|
|
13
|
+
const value = Number(arg(name, String(fallback)));
|
|
14
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function boolArg(name, fallback = false) {
|
|
18
|
+
const value = arg(name, String(fallback)).trim().toLowerCase();
|
|
19
|
+
return ["1", "true", "yes", "on"].includes(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeRoles(value) {
|
|
23
|
+
if (!Array.isArray(value)) throw new Error("roles must be an array");
|
|
24
|
+
return value.map((role, index) => ({
|
|
25
|
+
name: String(role.name ?? `actor-${index + 1}`),
|
|
26
|
+
persona: String(role.persona ?? role.role ?? "room participant"),
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseRoles(raw) {
|
|
31
|
+
if (!raw.trim()) return defaultRoles;
|
|
32
|
+
try {
|
|
33
|
+
return normalizeRoles(JSON.parse(raw));
|
|
34
|
+
} catch {
|
|
35
|
+
return raw.split(",").map((item, index) => ({
|
|
36
|
+
name: item.trim() || `actor-${index + 1}`,
|
|
37
|
+
persona: "room participant",
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function loadRoles(raw, path) {
|
|
43
|
+
if (path.trim()) return normalizeRoles(JSON.parse(await readFile(path, "utf8")));
|
|
44
|
+
return parseRoles(raw);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function shellQuote(value) {
|
|
48
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function agentDir() {
|
|
52
|
+
return process.env.PI_CODING_AGENT_DIR || `${process.env.HOME}/.pi/agent`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runStateDir(runId) {
|
|
56
|
+
return `${agentDir()}/tmp/pi-actors/runs/${runId}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function sleep(ms) {
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function waitForPath(path, timeoutMs = 5000) {
|
|
64
|
+
const started = Date.now();
|
|
65
|
+
while (!existsSync(path)) {
|
|
66
|
+
if (Date.now() - started > timeoutMs) throw new Error(`timed out waiting for ${path}`);
|
|
67
|
+
await sleep(50);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function writeLockerMessage(locker, message) {
|
|
72
|
+
if (!locker) return;
|
|
73
|
+
await waitForPath(locker.controlPath);
|
|
74
|
+
await writeFile(locker.controlPath, `${JSON.stringify(message)}\n`, { flag: "a" });
|
|
75
|
+
await sleep(50);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function startLocker(config) {
|
|
79
|
+
if (!config.locker) return undefined;
|
|
80
|
+
const lockerStateDir = `${runStateDir(config.runId)}/locker`;
|
|
81
|
+
await mkdir(lockerStateDir, { recursive: true });
|
|
82
|
+
const child = spawn(new URL("./coordinator-locker.mjs", import.meta.url).pathname, [
|
|
83
|
+
"serve",
|
|
84
|
+
"--state-dir",
|
|
85
|
+
lockerStateDir,
|
|
86
|
+
"--lease-ms",
|
|
87
|
+
String(config.lockerLeaseMs),
|
|
88
|
+
], {
|
|
89
|
+
env: { ...process.env, run_id: config.runId },
|
|
90
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
91
|
+
});
|
|
92
|
+
child.stdout.on("data", (chunk) => process.stdout.write(`[locker] ${chunk}`));
|
|
93
|
+
child.stderr.on("data", (chunk) => process.stderr.write(`[locker] ${chunk}`));
|
|
94
|
+
const locker = { child, controlPath: `${lockerStateDir}/control.fifo`, stateDir: lockerStateDir };
|
|
95
|
+
await waitForPath(locker.controlPath);
|
|
96
|
+
await writeLockerMessage(locker, {
|
|
97
|
+
type: "coord.enqueue",
|
|
98
|
+
body: { id: "room-swarm-artifact", task: "Own final room-swarm artifact synthesis", resources: [config.artifactPath || "artifact"] },
|
|
99
|
+
});
|
|
100
|
+
return locker;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function stopLocker(locker) {
|
|
104
|
+
if (!locker) return;
|
|
105
|
+
try {
|
|
106
|
+
await writeLockerMessage(locker, { type: "control.stop", body: {} });
|
|
107
|
+
await sleep(100);
|
|
108
|
+
} finally {
|
|
109
|
+
if (!locker.child.killed) locker.child.kill("SIGTERM");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function runPi(prompt, model, thinking) {
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
const child = spawn("pi", [
|
|
116
|
+
"--model", model,
|
|
117
|
+
"--thinking", thinking,
|
|
118
|
+
"--tools", "inspect,message",
|
|
119
|
+
"--no-context-files",
|
|
120
|
+
"--no-skills",
|
|
121
|
+
"--no-session",
|
|
122
|
+
"-p",
|
|
123
|
+
prompt,
|
|
124
|
+
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
125
|
+
let stdout = "";
|
|
126
|
+
let stderr = "";
|
|
127
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
128
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
129
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
130
|
+
child.on("error", (error) => resolve({ code: 1, stdout, stderr: String(error) }));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function participant(role, config) {
|
|
135
|
+
const displayName = role.name;
|
|
136
|
+
const address = `branch:${config.runId}/${role.name}`;
|
|
137
|
+
const joinPrompt = `You are ${displayName}, ${role.persona}. Mission: ${config.mission}. Call tool message exactly once with to=${shellQuote(config.room)}, from=${shellQuote(address)}, type='actor.join', summary='${displayName} joined', body JSON {"role":${JSON.stringify(role.persona)},"display":${JSON.stringify(displayName)},"caps":["coordination","synthesis"],"claim":"coordinate on mission"}. Then print one short line.`;
|
|
138
|
+
await runPi(joinPrompt, config.model, config.thinking);
|
|
139
|
+
for (let round = 1; round <= config.rounds; round += 1) {
|
|
140
|
+
const prompt = `You are ${displayName} (${address}), ${role.persona}. Mission: ${config.mission}. Round ${round}/${config.rounds}. First call inspect target=${config.room} view=previews lines=30 and inspect target=${config.room} view=contacts. Then call message once to ${config.room} from ${address} type=chat.message. Body: 2-4 sentences that react to a named participant, propose the next coordination step, and refine the shared artifact. Use contacts for peer names and addresses, but keep this packaged swarm's coordination room-visible; do not send direct branch messages unless a caller-specific worker protocol says recipients consume them. End stdout with summary <=160 chars.`;
|
|
141
|
+
const result = await runPi(prompt, config.model, config.thinking);
|
|
142
|
+
process.stdout.write(`[${role.name} round ${round}] code=${result.code}\n${result.stdout}\n`);
|
|
143
|
+
if (result.stderr.trim()) process.stderr.write(`[${role.name} round ${round}] ${result.stderr}\n`);
|
|
144
|
+
if (round < config.rounds && config.delay > 0) await new Promise((resolve) => setTimeout(resolve, config.delay * 1000));
|
|
145
|
+
}
|
|
146
|
+
const leavePrompt = `Call tool message exactly once with to=${shellQuote(config.room)}, from=${shellQuote(address)}, type='actor.leave', summary='${displayName} left', body='finished coordinated work'. Then print goodbye.`;
|
|
147
|
+
await runPi(leavePrompt, config.model, config.thinking);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function readRoomTranscript(config) {
|
|
151
|
+
const messagesPath = `${agentDir()}/tmp/pi-actors/runs/${config.runId}/rooms/main/messages.jsonl`;
|
|
152
|
+
try {
|
|
153
|
+
const raw = await readFile(messagesPath, "utf8");
|
|
154
|
+
return raw
|
|
155
|
+
.split("\n")
|
|
156
|
+
.filter(Boolean)
|
|
157
|
+
.slice(-160)
|
|
158
|
+
.map((line) => {
|
|
159
|
+
try {
|
|
160
|
+
const message = JSON.parse(line);
|
|
161
|
+
const from = String(message.from || "unknown").replace(/^branch:[^/]+\//, "");
|
|
162
|
+
const type = String(message.type || "message");
|
|
163
|
+
const body = typeof message.body === "string" ? message.body : JSON.stringify(message.body ?? "");
|
|
164
|
+
return `${from} [${type}]: ${body}`;
|
|
165
|
+
} catch {
|
|
166
|
+
return line;
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
.join("\n");
|
|
170
|
+
} catch {
|
|
171
|
+
return "";
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function synthesize(config, locker) {
|
|
176
|
+
if (!config.artifactPath) return;
|
|
177
|
+
await writeLockerMessage(locker, {
|
|
178
|
+
type: "coord.claim",
|
|
179
|
+
body: { owner: "room-swarm:synthesizer" },
|
|
180
|
+
});
|
|
181
|
+
const transcript = await readRoomTranscript(config);
|
|
182
|
+
const prompt = `Synthesize this room-swarm transcript into a concise Markdown artifact. Mission: ${config.mission}. Include: Title, Consensus, Roles, Protocol, Final Artifact Shape, Next Actions, Open Questions. Use only the transcript evidence below.\n\nTRANSCRIPT:\n${transcript.slice(-24000)}`;
|
|
183
|
+
const result = await runPi(prompt, config.model, config.thinking);
|
|
184
|
+
await writeFile(config.artifactPath, result.stdout.trim() ? result.stdout : "# Room Swarm Artifact\n\nNo synthesis output.\n", "utf8");
|
|
185
|
+
await writeLockerMessage(locker, {
|
|
186
|
+
type: "coord.complete",
|
|
187
|
+
body: { id: "room-swarm-artifact", artifact: config.artifactPath },
|
|
188
|
+
});
|
|
189
|
+
await writeLockerMessage(locker, {
|
|
190
|
+
type: "lock.release",
|
|
191
|
+
body: { resource: config.artifactPath },
|
|
192
|
+
});
|
|
193
|
+
process.stdout.write(`artifact=${config.artifactPath}\n`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const defaultRoles = [
|
|
197
|
+
{ name: "mapper", persona: "systems mapper; tracks shared structure and dependencies" },
|
|
198
|
+
{ name: "memory", persona: "memory keeper; preserves decisions and unresolved questions" },
|
|
199
|
+
{ name: "risk", persona: "risk scout; challenges weak coordination and missing owners" },
|
|
200
|
+
{ name: "flow", persona: "flow designer; turns scattered ideas into process rhythm" },
|
|
201
|
+
{ name: "operator", persona: "operator; converts ideas into concrete next actions" },
|
|
202
|
+
{ name: "narrative", persona: "narrative synthesizer; keeps the artifact coherent" },
|
|
203
|
+
{ name: "interface", persona: "interface designer; makes outputs visible and usable" },
|
|
204
|
+
{ name: "facilitator", persona: "facilitator; asks for consensus and convergence" },
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
const config = {
|
|
208
|
+
runId: arg("run-id", process.env.run_id || process.env.RUN_ID || "room-swarm"),
|
|
209
|
+
mission: arg("mission", "Coordinate a shared artifact and converge on next actions"),
|
|
210
|
+
model: arg("model", ""),
|
|
211
|
+
thinking: arg("thinking", "off"),
|
|
212
|
+
roles: await loadRoles(arg("roles", ""), arg("roles-path", "")),
|
|
213
|
+
rounds: numberArg("rounds", 4),
|
|
214
|
+
delay: numberArg("delay", 10),
|
|
215
|
+
artifactPath: arg("artifact-path", ""),
|
|
216
|
+
locker: boolArg("locker", false),
|
|
217
|
+
lockerLeaseMs: numberArg("locker-lease-ms", 600000),
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
if (!config.model) {
|
|
221
|
+
console.error("--model is required");
|
|
222
|
+
process.exit(2);
|
|
223
|
+
}
|
|
224
|
+
config.room = `room:${config.runId}`;
|
|
225
|
+
|
|
226
|
+
const failures = [];
|
|
227
|
+
const locker = await startLocker(config);
|
|
228
|
+
try {
|
|
229
|
+
await Promise.all(config.roles.map(async (role) => {
|
|
230
|
+
try {
|
|
231
|
+
await participant(role, config);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
failures.push(`${role.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
234
|
+
}
|
|
235
|
+
}));
|
|
236
|
+
await synthesize(config, locker);
|
|
237
|
+
} finally {
|
|
238
|
+
await stopLocker(locker);
|
|
239
|
+
}
|
|
240
|
+
if (failures.length > 0) {
|
|
241
|
+
console.error(failures.join("\n"));
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|