@llblab/pi-actors 0.21.0 → 0.22.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 +39 -0
- package/CHANGELOG.md +13 -1
- package/dist/lib/actor-rooms.js +18 -0
- package/dist/lib/async-runs.d.ts +19 -1
- package/dist/lib/async-runs.js +201 -10
- package/dist/lib/runtime-notifier.d.ts +48 -0
- package/dist/lib/runtime-notifier.js +137 -0
- package/dist/lib/tools.js +13 -4
- package/docs/actor-messages.md +5 -3
- package/docs/async-runs.md +4 -2
- package/docs/recipe-library.md +11 -4
- package/lib/actor-rooms.ts +23 -0
- package/lib/async-runs.ts +261 -15
- package/lib/runtime-notifier.ts +207 -0
- package/lib/tools.ts +26 -5
- package/package.json +2 -3
- package/recipes/music-player.json +1 -1
- package/scripts/coordinator.mjs +227 -114
- package/scripts/locker.mjs +30 -9
- package/scripts/music-player.mjs +401 -94
- package/skills/actors/SKILL.md +1 -1
- package/skills/swarm/SKILL.md +1 -1
- package/index.js +0 -19
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime wake notifications for actor state.
|
|
3
|
+
* Zones: advisory wake layer, file-backed runtime state, cross-platform notification boundary
|
|
4
|
+
* Owns best-effort live wake signals while durable mailbox/state files remain canonical.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import {
|
|
9
|
+
appendFileSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
statSync,
|
|
14
|
+
watch,
|
|
15
|
+
type FSWatcher,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { basename, dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
export interface RuntimeWakeEvent {
|
|
20
|
+
actor: string;
|
|
21
|
+
id: string;
|
|
22
|
+
metadata?: Record<string, unknown>;
|
|
23
|
+
reason: string;
|
|
24
|
+
state_dir: string;
|
|
25
|
+
ts: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RuntimeNotifierSubscription {
|
|
29
|
+
close(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type RuntimeReconcileReason = "initial" | "poll" | "wake";
|
|
33
|
+
|
|
34
|
+
export interface RuntimeReconcileEvent {
|
|
35
|
+
actor: string;
|
|
36
|
+
reason: RuntimeReconcileReason;
|
|
37
|
+
state_dir: string;
|
|
38
|
+
ts: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RuntimeNotifierSubscribeOptions {
|
|
42
|
+
onReconcile?: (event: RuntimeReconcileEvent) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface FileRuntimeNotifierOptions {
|
|
46
|
+
pollIntervalMs?: number;
|
|
47
|
+
replay?: boolean;
|
|
48
|
+
watch?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RuntimeNotifier {
|
|
52
|
+
notify(event: { actor: string; metadata?: Record<string, unknown>; reason: string }): RuntimeWakeEvent;
|
|
53
|
+
subscribe(
|
|
54
|
+
actor: string,
|
|
55
|
+
onWake: (event: RuntimeWakeEvent) => void,
|
|
56
|
+
options?: RuntimeNotifierSubscribeOptions,
|
|
57
|
+
): RuntimeNotifierSubscription;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
61
|
+
|
|
62
|
+
export function runtimeWakeFile(stateDir: string): string {
|
|
63
|
+
return join(stateDir, "wake.jsonl");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeWakeEvent(
|
|
67
|
+
stateDir: string,
|
|
68
|
+
event: { actor: string; metadata?: Record<string, unknown>; reason: string },
|
|
69
|
+
): RuntimeWakeEvent {
|
|
70
|
+
const actor = event.actor.trim();
|
|
71
|
+
const reason = event.reason.trim();
|
|
72
|
+
if (!actor) throw new Error("Runtime wake event requires actor.");
|
|
73
|
+
if (!reason) throw new Error("Runtime wake event requires reason.");
|
|
74
|
+
return {
|
|
75
|
+
actor,
|
|
76
|
+
id: randomUUID(),
|
|
77
|
+
...(event.metadata ? { metadata: event.metadata } : {}),
|
|
78
|
+
reason,
|
|
79
|
+
state_dir: stateDir,
|
|
80
|
+
ts: new Date().toISOString(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function notifyRuntimeWake(
|
|
85
|
+
stateDir: string,
|
|
86
|
+
event: { actor: string; metadata?: Record<string, unknown>; reason: string },
|
|
87
|
+
): RuntimeWakeEvent {
|
|
88
|
+
const normalized = normalizeWakeEvent(stateDir, event);
|
|
89
|
+
const file = runtimeWakeFile(stateDir);
|
|
90
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
91
|
+
appendFileSync(file, `${JSON.stringify(normalized)}\n`, "utf8");
|
|
92
|
+
return normalized;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseRuntimeWakeEventLine(
|
|
96
|
+
line: string,
|
|
97
|
+
): RuntimeWakeEvent | undefined {
|
|
98
|
+
try {
|
|
99
|
+
const record = JSON.parse(line) as Record<string, unknown>;
|
|
100
|
+
if (
|
|
101
|
+
typeof record.actor !== "string" ||
|
|
102
|
+
typeof record.id !== "string" ||
|
|
103
|
+
typeof record.reason !== "string" ||
|
|
104
|
+
typeof record.state_dir !== "string" ||
|
|
105
|
+
typeof record.ts !== "string"
|
|
106
|
+
) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
actor: record.actor,
|
|
111
|
+
id: record.id,
|
|
112
|
+
...(record.metadata &&
|
|
113
|
+
typeof record.metadata === "object" &&
|
|
114
|
+
!Array.isArray(record.metadata)
|
|
115
|
+
? { metadata: record.metadata as Record<string, unknown> }
|
|
116
|
+
: {}),
|
|
117
|
+
reason: record.reason,
|
|
118
|
+
state_dir: record.state_dir,
|
|
119
|
+
ts: record.ts,
|
|
120
|
+
};
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function readRuntimeWakeEvents(stateDir: string): RuntimeWakeEvent[] {
|
|
127
|
+
const file = runtimeWakeFile(stateDir);
|
|
128
|
+
if (!existsSync(file)) return [];
|
|
129
|
+
return readFileSync(file, "utf8")
|
|
130
|
+
.split("\n")
|
|
131
|
+
.filter((line) => line.trim())
|
|
132
|
+
.map(parseRuntimeWakeEventLine)
|
|
133
|
+
.filter((event): event is RuntimeWakeEvent => Boolean(event));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function createFileRuntimeNotifier(
|
|
137
|
+
stateDir: string,
|
|
138
|
+
options: FileRuntimeNotifierOptions = {},
|
|
139
|
+
): RuntimeNotifier {
|
|
140
|
+
const file = runtimeWakeFile(stateDir);
|
|
141
|
+
const pollIntervalMs = Math.max(
|
|
142
|
+
25,
|
|
143
|
+
Number(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
notify: (event) => notifyRuntimeWake(stateDir, event),
|
|
148
|
+
subscribe: (actor, onWake, subscribeOptions = {}) => {
|
|
149
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
150
|
+
let position =
|
|
151
|
+
options.replay || !existsSync(file) ? 0 : statSync(file).size;
|
|
152
|
+
let closed = false;
|
|
153
|
+
const reconcile = (reason: RuntimeReconcileReason): void => {
|
|
154
|
+
if (closed) return;
|
|
155
|
+
subscribeOptions.onReconcile?.({
|
|
156
|
+
actor,
|
|
157
|
+
reason,
|
|
158
|
+
state_dir: stateDir,
|
|
159
|
+
ts: new Date().toISOString(),
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
const drain = (): void => {
|
|
163
|
+
if (closed || !existsSync(file)) return;
|
|
164
|
+
const buffer = readFileSync(file);
|
|
165
|
+
if (position > buffer.length) position = 0;
|
|
166
|
+
const chunk = buffer.subarray(position).toString("utf8");
|
|
167
|
+
position = buffer.length;
|
|
168
|
+
for (const line of chunk.split("\n")) {
|
|
169
|
+
if (!line.trim()) continue;
|
|
170
|
+
const event = parseRuntimeWakeEventLine(line);
|
|
171
|
+
if (event && event.actor === actor) {
|
|
172
|
+
onWake(event);
|
|
173
|
+
reconcile("wake");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
let watcher: FSWatcher | undefined;
|
|
179
|
+
if (options.watch !== false) {
|
|
180
|
+
try {
|
|
181
|
+
watcher = watch(
|
|
182
|
+
dirname(file),
|
|
183
|
+
{ persistent: false },
|
|
184
|
+
(_eventType, changedFile) => {
|
|
185
|
+
if (!changedFile || String(changedFile) === basename(file)) drain();
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
} catch {
|
|
189
|
+
// fs.watch availability varies by platform/filesystem; polling below is the fallback.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
reconcile("initial");
|
|
193
|
+
const timer = setInterval(() => {
|
|
194
|
+
drain();
|
|
195
|
+
reconcile("poll");
|
|
196
|
+
}, pollIntervalMs);
|
|
197
|
+
timer.unref?.();
|
|
198
|
+
return {
|
|
199
|
+
close: () => {
|
|
200
|
+
closed = true;
|
|
201
|
+
clearInterval(timer);
|
|
202
|
+
watcher?.close();
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
package/lib/tools.ts
CHANGED
|
@@ -287,8 +287,11 @@ function compactCommunicationSnapshot(
|
|
|
287
287
|
return `\nself=${snapshot.self} root=${snapshot.root} rooms=${snapshot.rooms.length} updated_at=${snapshot.updated_at}`;
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
-
function
|
|
291
|
-
|
|
290
|
+
function compactInboxMessages(
|
|
291
|
+
messages: Array<Record<string, unknown>>,
|
|
292
|
+
emptyLabel: string,
|
|
293
|
+
): string {
|
|
294
|
+
if (messages.length === 0) return `\n(no ${emptyLabel} messages)`;
|
|
292
295
|
return `\n${messages
|
|
293
296
|
.map((message) =>
|
|
294
297
|
[
|
|
@@ -298,6 +301,7 @@ function compactBranchInbox(messages: Array<Record<string, unknown>>): string {
|
|
|
298
301
|
`from=${String(message.from ?? "")}`,
|
|
299
302
|
`to=${String(message.to ?? "")}`,
|
|
300
303
|
...(message.queued_at ? [`queued_at=${String(message.queued_at)}`] : []),
|
|
304
|
+
...(message.sent_at ? [`sent_at=${String(message.sent_at)}`] : []),
|
|
301
305
|
...(message.claimed_at ? [`claimed_at=${String(message.claimed_at)}`] : []),
|
|
302
306
|
...(message.handled_at ? [`handled_at=${String(message.handled_at)}`] : []),
|
|
303
307
|
...(message.failed_at ? [`failed_at=${String(message.failed_at)}`] : []),
|
|
@@ -306,6 +310,18 @@ function compactBranchInbox(messages: Array<Record<string, unknown>>): string {
|
|
|
306
310
|
.join("\n")}`;
|
|
307
311
|
}
|
|
308
312
|
|
|
313
|
+
function compactBranchInbox(messages: Array<Record<string, unknown>>): string {
|
|
314
|
+
return compactInboxMessages(messages, "branch inbox");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function compactRunMailbox(
|
|
318
|
+
run: string,
|
|
319
|
+
mailbox: Record<string, unknown>,
|
|
320
|
+
messages: Array<Record<string, unknown>>,
|
|
321
|
+
): string {
|
|
322
|
+
return `\nrun=${run} accepts=${Array.isArray(mailbox.accepts) ? mailbox.accepts.join(",") : ""} emits=${Array.isArray(mailbox.emits) ? mailbox.emits.join(",") : ""}${compactInboxMessages(messages, "run inbox")}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
309
325
|
function compactActorFiles(status: Record<string, unknown>): string {
|
|
310
326
|
const run = String(status.run ?? "<unknown>");
|
|
311
327
|
const artifacts = asRecord(status.artifacts);
|
|
@@ -984,18 +1000,23 @@ export function createInspectToolDefinition<TContext = unknown>(
|
|
|
984
1000
|
case "mailbox": {
|
|
985
1001
|
const status = assertRunAccessibleToContext(runId, ctx);
|
|
986
1002
|
const mailbox = asRecord(status.mailbox);
|
|
1003
|
+
const messages = AsyncRuns.readRunInboxMessages(
|
|
1004
|
+
runId,
|
|
1005
|
+
Number(input.lines || 40),
|
|
1006
|
+
);
|
|
1007
|
+
const details = { mailbox, messages };
|
|
987
1008
|
return {
|
|
988
1009
|
content: [
|
|
989
1010
|
{
|
|
990
1011
|
type: "text" as const,
|
|
991
1012
|
text: maybeJsonText(
|
|
992
|
-
|
|
1013
|
+
details,
|
|
993
1014
|
input.verbose === true,
|
|
994
|
-
|
|
1015
|
+
compactRunMailbox(String(status.run ?? runId), mailbox, messages),
|
|
995
1016
|
),
|
|
996
1017
|
},
|
|
997
1018
|
],
|
|
998
|
-
details
|
|
1019
|
+
details,
|
|
999
1020
|
};
|
|
1000
1021
|
}
|
|
1001
1022
|
case "communication": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llblab/pi-actors",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Local Actor Kernel for Pi",
|
|
6
6
|
"keywords": [
|
|
@@ -33,7 +33,6 @@
|
|
|
33
33
|
"prepack": "npm run build"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
|
-
"index.js",
|
|
37
36
|
"index.ts",
|
|
38
37
|
"lib",
|
|
39
38
|
"scripts",
|
|
@@ -49,7 +48,7 @@
|
|
|
49
48
|
],
|
|
50
49
|
"pi": {
|
|
51
50
|
"extensions": [
|
|
52
|
-
"./index.js"
|
|
51
|
+
"./dist/index.js"
|
|
53
52
|
],
|
|
54
53
|
"skills": [
|
|
55
54
|
"./skills/actors/SKILL.md",
|