@llblab/pi-actors 0.21.0 → 0.22.1

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.
@@ -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 compactBranchInbox(messages: Array<Record<string, unknown>>): string {
291
- if (messages.length === 0) return "\n(no branch inbox messages)";
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
- mailbox,
1013
+ details,
993
1014
  input.verbose === true,
994
- `\nrun=${String(status.run ?? runId)} accepts=${Array.isArray(mailbox.accepts) ? mailbox.accepts.join(",") : ""} emits=${Array.isArray(mailbox.emits) ? mailbox.emits.join(",") : ""}`,
1015
+ compactRunMailbox(String(status.run ?? runId), mailbox, messages),
995
1016
  ),
996
1017
  },
997
1018
  ],
998
- details: { mailbox },
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.21.0",
3
+ "version": "0.22.1",
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",
@@ -6,7 +6,7 @@
6
6
  "source:string",
7
7
  "loop:bool",
8
8
  "volume:int",
9
- "player:enum(auto,mpv,ffplay,cvlc,play)",
9
+ "player:enum(auto,mpv,afplay,ffplay,cvlc,play,wmp)",
10
10
  "state_dir:path"
11
11
  ],
12
12
  "defaults": {