@llblab/pi-actors 0.42.2 → 0.43.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.
Files changed (254) hide show
  1. package/AGENTS.md +121 -175
  2. package/CHANGELOG.md +16 -0
  3. package/README.md +113 -276
  4. package/dist/fixtures/protocol/control-endpoint.json +6 -0
  5. package/dist/fixtures/protocol/control-record.json +9 -0
  6. package/dist/fixtures/protocol/recipe-summary.json +4 -12
  7. package/dist/fixtures/protocol/trace-event.json +9 -0
  8. package/dist/lib/async-runs.d.ts +14 -38
  9. package/dist/lib/async-runs.js +158 -108
  10. package/dist/lib/control.d.ts +12 -0
  11. package/dist/lib/control.js +84 -0
  12. package/dist/lib/execution-sessions.d.ts +17 -0
  13. package/dist/lib/execution-sessions.js +85 -0
  14. package/dist/lib/file-state.d.ts +1 -0
  15. package/dist/lib/file-state.js +17 -5
  16. package/dist/lib/inspector-actions.d.ts +2 -2
  17. package/dist/lib/inspector-actions.js +2 -2
  18. package/dist/lib/inspector-command.js +3 -3
  19. package/dist/lib/inspector-overlay.d.ts +52 -70
  20. package/dist/lib/inspector-overlay.js +532 -905
  21. package/dist/lib/inspector.d.ts +3 -71
  22. package/dist/lib/inspector.js +19 -665
  23. package/dist/lib/limits.d.ts +4 -2
  24. package/dist/lib/limits.js +4 -2
  25. package/dist/lib/observability.d.ts +17 -17
  26. package/dist/lib/observability.js +45 -84
  27. package/dist/lib/pi.d.ts +1 -1
  28. package/dist/lib/prompts.d.ts +1 -1
  29. package/dist/lib/prompts.js +2 -2
  30. package/dist/lib/recipe-control.d.ts +7 -0
  31. package/dist/lib/recipe-control.js +39 -0
  32. package/dist/lib/recipes-discovery.js +2 -0
  33. package/dist/lib/recipes-references.d.ts +1 -14
  34. package/dist/lib/recipes-references.js +6 -21
  35. package/dist/lib/review-projection.js +1 -5
  36. package/dist/lib/run-ui-runtime.js +2 -2
  37. package/dist/lib/runs-control-delivery.d.ts +21 -0
  38. package/dist/lib/runs-control-delivery.js +127 -0
  39. package/dist/lib/runs-controls.d.ts +35 -0
  40. package/dist/lib/runs-controls.js +144 -0
  41. package/dist/lib/runs-retention.d.ts +7 -0
  42. package/dist/lib/runs-retention.js +27 -3
  43. package/dist/lib/runs-start.js +4 -2
  44. package/dist/lib/runs-status.js +11 -6
  45. package/dist/lib/runs-trace.d.ts +24 -0
  46. package/dist/lib/runs-trace.js +98 -0
  47. package/dist/lib/runtime-notifier.d.ts +1 -1
  48. package/dist/lib/runtime-notifier.js +1 -1
  49. package/dist/lib/tools-inspect.d.ts +3 -3
  50. package/dist/lib/tools-inspect.js +203 -708
  51. package/dist/lib/tools-local.js +2 -10
  52. package/dist/lib/tools-message.d.ts +7 -7
  53. package/dist/lib/tools-message.js +95 -396
  54. package/dist/lib/tools-response.d.ts +1 -4
  55. package/dist/lib/tools-response.js +5 -39
  56. package/dist/lib/tools-spawn.js +16 -28
  57. package/dist/lib/tools.js +1 -2
  58. package/dist/lib/trace-projection.d.ts +22 -0
  59. package/dist/lib/trace-projection.js +165 -0
  60. package/dist/recipes/draft-review.json +0 -10
  61. package/dist/recipes/lens-swarm.json +0 -14
  62. package/dist/recipes/music-player.json +10 -19
  63. package/dist/recipes/pipeline-architect-coordinator.json +0 -11
  64. package/dist/recipes/pipeline-artifact-bundle.json +1 -22
  65. package/dist/recipes/pipeline-artifact-report.json +1 -18
  66. package/dist/recipes/pipeline-artifact-write.json +1 -18
  67. package/dist/recipes/pipeline-async-run-ops.json +0 -12
  68. package/dist/recipes/pipeline-checkpoint-continuation.json +0 -14
  69. package/dist/recipes/pipeline-development-tasking.json +0 -12
  70. package/dist/recipes/pipeline-docs-maintenance.json +0 -12
  71. package/dist/recipes/pipeline-media-library.json +0 -12
  72. package/dist/recipes/pipeline-quorum-review.json +0 -12
  73. package/dist/recipes/pipeline-release-readiness.json +0 -12
  74. package/dist/recipes/pipeline-release-summary.json +0 -12
  75. package/dist/recipes/pipeline-repo-health.json +0 -12
  76. package/dist/recipes/pipeline-research-synthesis.json +0 -11
  77. package/dist/recipes/pipeline-review-readiness.json +0 -12
  78. package/dist/recipes/resource-locker.json +27 -0
  79. package/dist/recipes/subagent-artifact.json +0 -9
  80. package/dist/recipes/subagent-checkpoint.json +0 -10
  81. package/dist/recipes/subagent-conflict-report.json +0 -11
  82. package/dist/recipes/subagent-contradiction-map.json +0 -11
  83. package/dist/recipes/subagent-critic.json +0 -11
  84. package/dist/recipes/subagent-evidence-map.json +0 -11
  85. package/dist/recipes/subagent-followup.json +0 -10
  86. package/dist/recipes/subagent-judge.json +0 -11
  87. package/dist/recipes/subagent-merge.json +0 -11
  88. package/dist/recipes/subagent-normalize.json +0 -11
  89. package/dist/recipes/subagent-plan.json +0 -11
  90. package/dist/recipes/subagent-preflight.json +0 -11
  91. package/dist/recipes/subagent-prompt.json +0 -10
  92. package/dist/recipes/subagent-quorum.json +0 -10
  93. package/dist/recipes/subagent-review-coordinator.json +0 -14
  94. package/dist/recipes/subagent-review.json +0 -11
  95. package/dist/recipes/subagent-task-card.json +0 -11
  96. package/dist/recipes/subagent-tools.json +0 -10
  97. package/dist/recipes/subagent-verify.json +0 -11
  98. package/dist/recipes/subagents-prompts.json +0 -10
  99. package/dist/recipes/tool-review.json +0 -10
  100. package/dist/scripts/async-runner.mjs +25 -25
  101. package/dist/scripts/conformance.mjs +4 -2
  102. package/dist/scripts/locker.mjs +200 -66
  103. package/dist/scripts/music-player.mjs +159 -150
  104. package/dist/scripts/recipe-utils.mjs +6 -96
  105. package/dist/scripts/release-gates.mjs +60 -0
  106. package/dist/scripts/validate-recipe.mjs +3 -53
  107. package/dist/skills/actors/SKILL.md +53 -266
  108. package/dist/skills/swarm/SKILL.md +11 -33
  109. package/docs/0.43-baseline.md +44 -0
  110. package/docs/README.md +3 -3
  111. package/docs/actor-inspector.md +26 -64
  112. package/docs/actors-deep-reference.md +92 -50
  113. package/docs/async-runs.md +81 -328
  114. package/docs/command-templates.md +2 -2
  115. package/docs/component-recipes.md +30 -133
  116. package/docs/recipe-library.md +57 -182
  117. package/docs/task-first-recipes.md +10 -12
  118. package/docs/template-recipes.md +76 -289
  119. package/docs/tool-registry.md +41 -161
  120. package/fixtures/protocol/control-endpoint.json +6 -0
  121. package/fixtures/protocol/control-record.json +9 -0
  122. package/fixtures/protocol/recipe-summary.json +4 -12
  123. package/fixtures/protocol/trace-event.json +9 -0
  124. package/lib/async-runs.ts +202 -201
  125. package/lib/control.ts +102 -0
  126. package/lib/execution-sessions.ts +111 -0
  127. package/lib/file-state.ts +17 -4
  128. package/lib/inspector-actions.ts +2 -2
  129. package/lib/inspector-command.ts +3 -3
  130. package/lib/inspector-overlay.ts +577 -1121
  131. package/lib/inspector.ts +46 -979
  132. package/lib/limits.ts +4 -2
  133. package/lib/observability.ts +63 -104
  134. package/lib/pi.ts +1 -1
  135. package/lib/prompts.ts +2 -2
  136. package/lib/recipe-control.ts +45 -0
  137. package/lib/recipes-discovery.ts +2 -0
  138. package/lib/recipes-references.ts +9 -45
  139. package/lib/review-projection.ts +1 -5
  140. package/lib/run-ui-runtime.ts +2 -2
  141. package/lib/runs-control-delivery.ts +181 -0
  142. package/lib/runs-controls.ts +204 -0
  143. package/lib/runs-retention.ts +38 -3
  144. package/lib/runs-start.ts +4 -2
  145. package/lib/runs-status.ts +11 -6
  146. package/lib/runs-trace.ts +132 -0
  147. package/lib/runtime-notifier.ts +1 -1
  148. package/lib/tools-inspect.ts +240 -901
  149. package/lib/tools-local.ts +2 -12
  150. package/lib/tools-message.ts +112 -519
  151. package/lib/tools-response.ts +5 -52
  152. package/lib/tools-spawn.ts +16 -32
  153. package/lib/tools.ts +1 -2
  154. package/lib/trace-projection.ts +221 -0
  155. package/package.json +2 -1
  156. package/recipes/draft-review.json +0 -10
  157. package/recipes/lens-swarm.json +0 -14
  158. package/recipes/music-player.json +10 -19
  159. package/recipes/pipeline-architect-coordinator.json +0 -11
  160. package/recipes/pipeline-artifact-bundle.json +1 -22
  161. package/recipes/pipeline-artifact-report.json +1 -18
  162. package/recipes/pipeline-artifact-write.json +1 -18
  163. package/recipes/pipeline-async-run-ops.json +0 -12
  164. package/recipes/pipeline-checkpoint-continuation.json +0 -14
  165. package/recipes/pipeline-development-tasking.json +0 -12
  166. package/recipes/pipeline-docs-maintenance.json +0 -12
  167. package/recipes/pipeline-media-library.json +0 -12
  168. package/recipes/pipeline-quorum-review.json +0 -12
  169. package/recipes/pipeline-release-readiness.json +0 -12
  170. package/recipes/pipeline-release-summary.json +0 -12
  171. package/recipes/pipeline-repo-health.json +0 -12
  172. package/recipes/pipeline-research-synthesis.json +0 -11
  173. package/recipes/pipeline-review-readiness.json +0 -12
  174. package/recipes/resource-locker.json +27 -0
  175. package/recipes/subagent-artifact.json +0 -9
  176. package/recipes/subagent-checkpoint.json +0 -10
  177. package/recipes/subagent-conflict-report.json +0 -11
  178. package/recipes/subagent-contradiction-map.json +0 -11
  179. package/recipes/subagent-critic.json +0 -11
  180. package/recipes/subagent-evidence-map.json +0 -11
  181. package/recipes/subagent-followup.json +0 -10
  182. package/recipes/subagent-judge.json +0 -11
  183. package/recipes/subagent-merge.json +0 -11
  184. package/recipes/subagent-normalize.json +0 -11
  185. package/recipes/subagent-plan.json +0 -11
  186. package/recipes/subagent-preflight.json +0 -11
  187. package/recipes/subagent-prompt.json +0 -10
  188. package/recipes/subagent-quorum.json +0 -10
  189. package/recipes/subagent-review-coordinator.json +0 -14
  190. package/recipes/subagent-review.json +0 -11
  191. package/recipes/subagent-task-card.json +0 -11
  192. package/recipes/subagent-tools.json +0 -10
  193. package/recipes/subagent-verify.json +0 -11
  194. package/recipes/subagents-prompts.json +0 -10
  195. package/recipes/tool-review.json +0 -10
  196. package/scripts/async-runner.mjs +25 -25
  197. package/scripts/conformance.mjs +4 -2
  198. package/scripts/locker.mjs +200 -66
  199. package/scripts/music-player.mjs +159 -150
  200. package/scripts/recipe-utils.mjs +6 -96
  201. package/scripts/release-gates.mjs +60 -0
  202. package/scripts/validate-recipe.mjs +3 -53
  203. package/skills/actors/SKILL.md +53 -266
  204. package/skills/swarm/SKILL.md +11 -33
  205. package/dist/fixtures/protocol/actor-message-branch.json +0 -13
  206. package/dist/fixtures/protocol/mailbox-contract.json +0 -15
  207. package/dist/fixtures/protocol/room-message.json +0 -11
  208. package/dist/fixtures/protocol/room-roster.json +0 -11
  209. package/dist/fixtures/protocol/run-inbox-message.json +0 -9
  210. package/dist/fixtures/protocol/run-outbox-event.json +0 -9
  211. package/dist/lib/mailbox-loop.d.ts +0 -41
  212. package/dist/lib/mailbox-loop.js +0 -60
  213. package/dist/lib/messages.d.ts +0 -25
  214. package/dist/lib/messages.js +0 -122
  215. package/dist/lib/rooms.d.ts +0 -104
  216. package/dist/lib/rooms.js +0 -647
  217. package/dist/lib/runs-mailbox.d.ts +0 -25
  218. package/dist/lib/runs-mailbox.js +0 -146
  219. package/dist/lib/runs-messages.d.ts +0 -15
  220. package/dist/lib/runs-messages.js +0 -179
  221. package/dist/lib/runs-outbox.d.ts +0 -41
  222. package/dist/lib/runs-outbox.js +0 -87
  223. package/dist/lib/tools-mailbox.d.ts +0 -8
  224. package/dist/lib/tools-mailbox.js +0 -48
  225. package/dist/recipes/actor-worker.json +0 -39
  226. package/dist/recipes/coordinator-locker.json +0 -45
  227. package/dist/recipes/locker.json +0 -45
  228. package/dist/recipes/pipeline-room-swarm.json +0 -50
  229. package/dist/recipes/subagent-message.json +0 -32
  230. package/dist/recipes/utility-actor-message.json +0 -23
  231. package/dist/scripts/actor-worker.mjs +0 -214
  232. package/dist/scripts/coordinator.mjs +0 -799
  233. package/docs/actor-messages.md +0 -225
  234. package/fixtures/protocol/actor-message-branch.json +0 -13
  235. package/fixtures/protocol/mailbox-contract.json +0 -15
  236. package/fixtures/protocol/room-message.json +0 -11
  237. package/fixtures/protocol/room-roster.json +0 -11
  238. package/fixtures/protocol/run-inbox-message.json +0 -9
  239. package/fixtures/protocol/run-outbox-event.json +0 -9
  240. package/lib/mailbox-loop.ts +0 -144
  241. package/lib/messages.ts +0 -151
  242. package/lib/rooms.ts +0 -939
  243. package/lib/runs-mailbox.ts +0 -208
  244. package/lib/runs-messages.ts +0 -252
  245. package/lib/runs-outbox.ts +0 -144
  246. package/lib/tools-mailbox.ts +0 -56
  247. package/recipes/actor-worker.json +0 -39
  248. package/recipes/coordinator-locker.json +0 -45
  249. package/recipes/locker.json +0 -45
  250. package/recipes/pipeline-room-swarm.json +0 -50
  251. package/recipes/subagent-message.json +0 -32
  252. package/recipes/utility-actor-message.json +0 -23
  253. package/scripts/actor-worker.mjs +0 -214
  254. package/scripts/coordinator.mjs +0 -799
package/lib/rooms.ts DELETED
@@ -1,939 +0,0 @@
1
- /**
2
- * Actor room persistence.
3
- * Zones: room timelines, rosters, branch inboxes, cross-branch discovery state
4
- * Owns file-backed room and branch-mailbox state; routing policy stays in tools/runtime adapters.
5
- */
6
-
7
- import * as fs from "node:fs";
8
- import { randomUUID } from "node:crypto";
9
- import * as path from "node:path";
10
-
11
- import type { ActorMessage } from "./messages.ts";
12
- import * as Limits from "./limits.ts";
13
- import { notifyRuntimeWake } from "./runtime-notifier.ts";
14
- import {
15
- formatStateReadDiagnostics,
16
- readJsonFileResilient,
17
- readJsonlFileResilient,
18
- } from "./state-readers.ts";
19
-
20
- const STATE_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
21
- const STATE_LOCK_TIMEOUT_MS = 5000;
22
- const DEFAULT_ROOM_MAX_MESSAGES = 10000;
23
- const DEFAULT_BRANCH_INBOX_TERMINAL_RETAINED = 2000;
24
- const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = 250;
25
-
26
- export interface RoomMember {
27
- address: string;
28
- caps?: unknown;
29
- claim?: unknown;
30
- display?: unknown;
31
- joined_at: string;
32
- last_seen: string;
33
- parent?: unknown;
34
- role?: unknown;
35
- status?: string;
36
- }
37
-
38
- export interface RoomTimelineEntry extends ActorMessage {
39
- received_at: string;
40
- }
41
-
42
- export interface RoomAppendResult {
43
- room: string;
44
- message_count: number;
45
- roster_count: number;
46
- sent: true;
47
- }
48
-
49
- export interface RoomMessagePreview {
50
- body_preview?: string;
51
- from?: string;
52
- summary?: string;
53
- timestamp: string;
54
- to: string;
55
- type: string;
56
- }
57
-
58
- export interface RoomCompactionInfo {
59
- compacted_at: string;
60
- dropped_count: number;
61
- first_kept_at?: string;
62
- last_kept_at?: string;
63
- max_messages: number;
64
- }
65
-
66
- export interface RoomStatus {
67
- room: string;
68
- message_count: number;
69
- roster_count: number;
70
- compaction?: RoomCompactionInfo;
71
- diagnostics?: string[];
72
- diagnostics_count?: number;
73
- last_message_at?: string;
74
- last_message_from?: string;
75
- last_message_summary?: string;
76
- last_message_type?: string;
77
- }
78
-
79
- export interface RoomContact {
80
- address: string;
81
- caps?: unknown;
82
- claim?: unknown;
83
- parent?: unknown;
84
- role?: unknown;
85
- status?: string;
86
- }
87
-
88
- export interface ActorCommunicationSnapshot {
89
- contacts?: RoomContact[];
90
- parent?: string;
91
- root: string;
92
- self: string;
93
- rooms: Array<{
94
- address: string;
95
- members?: RoomMember[];
96
- name: string;
97
- }>;
98
- updated_at: string;
99
- }
100
-
101
- function roomDir(stateDir: string, room: string): string {
102
- return path.join(stateDir, "rooms", room);
103
- }
104
-
105
- function messagesFile(stateDir: string, room: string): string {
106
- return path.join(roomDir(stateDir, room), "messages.jsonl");
107
- }
108
-
109
- function rosterFile(stateDir: string, room: string): string {
110
- return path.join(roomDir(stateDir, room), "roster.json");
111
- }
112
-
113
- function snapshotFile(stateDir: string): string {
114
- return path.join(stateDir, "communication.json");
115
- }
116
-
117
- function branchSnapshotFile(stateDir: string, branch: string): string {
118
- return path.join(stateDir, "branches", branch, "communication.json");
119
- }
120
-
121
- function branchInboxFile(stateDir: string, branch: string): string {
122
- return path.join(stateDir, "branches", branch, "inbox.jsonl");
123
- }
124
-
125
- function branchIdFromAddress(
126
- address: string | undefined,
127
- run: string,
128
- ): string | undefined {
129
- if (!address) return undefined;
130
- const match = new RegExp(`^branch:${run}/(.+)$`).exec(address);
131
- return match?.[1];
132
- }
133
-
134
- function ensureRoomDir(stateDir: string, room: string): void {
135
- fs.mkdirSync(roomDir(stateDir, room), { recursive: true });
136
- }
137
-
138
- function sleepSync(ms: number): void {
139
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
140
- }
141
-
142
- function acquireStateLock(
143
- parentDir: string,
144
- name: string,
145
- label: string,
146
- ): () => void {
147
- fs.mkdirSync(parentDir, { recursive: true });
148
- const lockDir = path.join(parentDir, name);
149
- const started = Date.now();
150
- while (true) {
151
- try {
152
- fs.mkdirSync(lockDir);
153
- fs.writeFileSync(
154
- path.join(lockDir, "owner.json"),
155
- `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`,
156
- );
157
- return () => fs.rmSync(lockDir, { recursive: true, force: true });
158
- } catch (error) {
159
- try {
160
- const stat = fs.statSync(lockDir);
161
- if (Date.now() - stat.mtimeMs > STATE_LOCK_MAX_AGE_MS) {
162
- fs.rmSync(lockDir, { recursive: true, force: true });
163
- continue;
164
- }
165
- } catch {
166
- continue;
167
- }
168
- if (Date.now() - started > STATE_LOCK_TIMEOUT_MS) {
169
- throw new Error(`${label} lock timed out.`, { cause: error });
170
- }
171
- sleepSync(10);
172
- }
173
- }
174
- }
175
-
176
- function acquireRoomLock(stateDir: string, room: string): () => void {
177
- return acquireStateLock(
178
- roomDir(stateDir, room),
179
- ".append.lock",
180
- `Room append ${room}`,
181
- );
182
- }
183
-
184
- function acquireBranchInboxLock(stateDir: string, branch: string): () => void {
185
- return acquireStateLock(
186
- path.dirname(branchInboxFile(stateDir, branch)),
187
- ".inbox.lock",
188
- `Branch inbox ${branch}`,
189
- );
190
- }
191
-
192
- function asRecord(value: unknown): Record<string, unknown> {
193
- return value && typeof value === "object" && !Array.isArray(value)
194
- ? (value as Record<string, unknown>)
195
- : {};
196
- }
197
-
198
- function runFromRoomAddress(address: string): string | undefined {
199
- const match = /^room:([^/]+)(?:\/main)?$/.exec(address);
200
- return match?.[1];
201
- }
202
-
203
- function readJsonFile<T>(file: string, fallback: T): T {
204
- return readJsonFileResilient<T>(file, fallback).value;
205
- }
206
-
207
- function writeJsonFile(file: string, value: unknown): void {
208
- fs.mkdirSync(path.dirname(file), { recursive: true });
209
- fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
210
- }
211
-
212
- function notifyActorWake(
213
- stateDir: string,
214
- actor: string,
215
- reason: string,
216
- metadata: Record<string, unknown> = {},
217
- ): void {
218
- try {
219
- notifyRuntimeWake(stateDir, { actor, metadata, reason });
220
- } catch {
221
- // Runtime wakes are advisory; durable room/inbox state remains canonical.
222
- }
223
- }
224
-
225
- function positiveEnvInt(name: string, fallback: number): number {
226
- const value = Number(process.env[name] ?? fallback);
227
- return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
228
- }
229
-
230
- function roomMaxMessages(): number {
231
- return positiveEnvInt(
232
- "PI_ACTORS_ROOM_MAX_MESSAGES",
233
- DEFAULT_ROOM_MAX_MESSAGES,
234
- );
235
- }
236
-
237
- function snapshotMinIntervalMs(): number {
238
- return positiveEnvInt(
239
- "PI_ACTORS_COMMUNICATION_SNAPSHOT_MIN_MS",
240
- DEFAULT_SNAPSHOT_MIN_INTERVAL_MS,
241
- );
242
- }
243
-
244
- function rosterMinIntervalMs(): number {
245
- return positiveEnvInt(
246
- "PI_ACTORS_ROOM_ROSTER_MIN_MS",
247
- DEFAULT_SNAPSHOT_MIN_INTERVAL_MS,
248
- );
249
- }
250
-
251
- function compactRoomMessages(stateDir: string, room: string): void {
252
- const maxMessages = roomMaxMessages();
253
- const file = messagesFile(stateDir, room);
254
- const lines = readJsonlTailLines(file, maxMessages + 1);
255
- if (lines.length <= maxMessages) return;
256
- const kept = lines.slice(-maxMessages);
257
- fs.writeFileSync(file, `${kept.join("\n")}\n`);
258
- const keptMessages = kept.flatMap((line) => {
259
- try {
260
- return [JSON.parse(line) as RoomTimelineEntry];
261
- } catch {
262
- return [];
263
- }
264
- });
265
- writeJsonFile(path.join(roomDir(stateDir, room), "compaction.json"), {
266
- compacted_at: new Date().toISOString(),
267
- dropped_count: lines.length - kept.length,
268
- ...(keptMessages[0]?.received_at
269
- ? { first_kept_at: keptMessages[0].received_at }
270
- : {}),
271
- ...(keptMessages.at(-1)?.received_at
272
- ? { last_kept_at: keptMessages.at(-1)?.received_at }
273
- : {}),
274
- max_messages: maxMessages,
275
- });
276
- }
277
-
278
- function readJsonlLineCount(file: string): number {
279
- const stat = fs.statSync(file);
280
- if (stat.size === 0) return 0;
281
- const fd = fs.openSync(file, "r");
282
- try {
283
- const chunkSize = 64 * 1024;
284
- const chunk = Buffer.allocUnsafe(chunkSize);
285
- let position = 0;
286
- let count = 0;
287
- let lastByte: number | undefined;
288
- while (position < stat.size) {
289
- const bytesRead = fs.readSync(
290
- fd,
291
- chunk,
292
- 0,
293
- Math.min(chunkSize, stat.size - position),
294
- position,
295
- );
296
- if (bytesRead <= 0) break;
297
- position += bytesRead;
298
- for (let index = 0; index < bytesRead; index += 1) {
299
- if (chunk[index] === 10) count += 1;
300
- }
301
- lastByte = chunk[bytesRead - 1];
302
- }
303
- return lastByte === 10 ? count : count + 1;
304
- } finally {
305
- fs.closeSync(fd);
306
- }
307
- }
308
-
309
- function readRoomMessageCount(stateDir: string, room: string): number {
310
- try {
311
- return readJsonlLineCount(messagesFile(stateDir, room));
312
- } catch (error) {
313
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0;
314
- throw error;
315
- }
316
- }
317
-
318
- function readJsonlTailLines(file: string, limit: number): string[] {
319
- const lineLimit = Math.max(1, limit);
320
- const stat = fs.statSync(file);
321
- if (stat.size === 0) return [];
322
- const fd = fs.openSync(file, "r");
323
- try {
324
- const chunkSize = 64 * 1024;
325
- const chunks: Buffer[] = [];
326
- let position = stat.size;
327
- let newlines = 0;
328
- while (position > 0 && newlines <= lineLimit) {
329
- const size = Math.min(chunkSize, position);
330
- position -= size;
331
- const chunk = Buffer.allocUnsafe(size);
332
- fs.readSync(fd, chunk, 0, size, position);
333
- chunks.unshift(chunk);
334
- for (let index = size - 1; index >= 0; index -= 1) {
335
- if (chunk[index] === 10) newlines += 1;
336
- }
337
- }
338
- return Buffer.concat(chunks)
339
- .toString("utf8")
340
- .split("\n")
341
- .filter(Boolean)
342
- .slice(-lineLimit);
343
- } finally {
344
- fs.closeSync(fd);
345
- }
346
- }
347
-
348
- export function readRoomRoster(
349
- stateDir: string,
350
- room: string,
351
- ): Record<string, RoomMember> {
352
- return readJsonFile<Record<string, RoomMember>>(
353
- rosterFile(stateDir, room),
354
- {},
355
- );
356
- }
357
-
358
- function writeRoomRoster(
359
- stateDir: string,
360
- room: string,
361
- roster: Record<string, RoomMember>,
362
- ): void {
363
- ensureRoomDir(stateDir, room);
364
- writeJsonFile(rosterFile(stateDir, room), roster);
365
- }
366
-
367
- function shouldDebounceFile(file: string, minIntervalMs: number): boolean {
368
- try {
369
- return Date.now() - fs.statSync(file).mtimeMs < minIntervalMs;
370
- } catch (error) {
371
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
372
- throw error;
373
- }
374
- }
375
-
376
- function shouldDebounceSnapshot(file: string): boolean {
377
- return shouldDebounceFile(file, snapshotMinIntervalMs());
378
- }
379
-
380
- function comparableRosterMember(member: RoomMember | undefined): string {
381
- if (!member) return "";
382
- const { last_seen: _lastSeen, ...semantic } = member;
383
- return JSON.stringify(semantic);
384
- }
385
-
386
- function shouldWriteRoomRosterMember(
387
- stateDir: string,
388
- room: string,
389
- before: RoomMember | undefined,
390
- after: RoomMember,
391
- ): boolean {
392
- if (!before) return true;
393
- if (comparableRosterMember(before) !== comparableRosterMember(after)) {
394
- return true;
395
- }
396
- return !shouldDebounceFile(rosterFile(stateDir, room), rosterMinIntervalMs());
397
- }
398
-
399
- function updateRosterForMessage(
400
- stateDir: string,
401
- room: string,
402
- message: ActorMessage,
403
- receivedAt: string,
404
- ): Record<string, RoomMember> {
405
- const roster = readRoomRoster(stateDir, room);
406
- if (!message.from) return roster;
407
- const body = asRecord(message.body);
408
- const current = roster[message.from];
409
- const next =
410
- message.type === "actor.leave"
411
- ? {
412
- address: message.from,
413
- joined_at: current?.joined_at ?? receivedAt,
414
- last_seen: receivedAt,
415
- ...(current?.caps !== undefined ? { caps: current.caps } : {}),
416
- ...(current?.claim !== undefined ? { claim: current.claim } : {}),
417
- ...(current?.display !== undefined
418
- ? { display: current.display }
419
- : {}),
420
- ...(current?.parent !== undefined ? { parent: current.parent } : {}),
421
- ...(current?.role !== undefined
422
- ? { role: current.role }
423
- : { role: "actor" }),
424
- status: String(body.status ?? "left"),
425
- }
426
- : {
427
- address: message.from,
428
- joined_at: current?.joined_at ?? receivedAt,
429
- last_seen: receivedAt,
430
- ...(body.caps !== undefined
431
- ? { caps: body.caps }
432
- : current?.caps !== undefined
433
- ? { caps: current.caps }
434
- : {}),
435
- ...(body.claim !== undefined
436
- ? { claim: body.claim }
437
- : current?.claim !== undefined
438
- ? { claim: current.claim }
439
- : {}),
440
- ...(body.display !== undefined
441
- ? { display: body.display }
442
- : current?.display !== undefined
443
- ? { display: current.display }
444
- : {}),
445
- ...(body.parent !== undefined
446
- ? { parent: body.parent }
447
- : current?.parent !== undefined
448
- ? { parent: current.parent }
449
- : {}),
450
- ...(body.role !== undefined
451
- ? { role: body.role }
452
- : current?.role !== undefined
453
- ? { role: current.role }
454
- : { role: "actor" }),
455
- status: String(body.status ?? current?.status ?? "present"),
456
- };
457
- roster[message.from] = next;
458
- if (shouldWriteRoomRosterMember(stateDir, room, current, next)) {
459
- writeRoomRoster(stateDir, room, roster);
460
- }
461
- return roster;
462
- }
463
-
464
- export interface BranchInboxRecord extends ActorMessage {
465
- claimed_at?: string;
466
- claimed_by?: string;
467
- error?: string;
468
- failed_at?: string;
469
- handled_at?: string;
470
- id?: string;
471
- queued_at?: string;
472
- status?: string;
473
- }
474
-
475
- export interface BranchInboxReadResult {
476
- corrupted: number;
477
- messages: BranchInboxRecord[];
478
- }
479
-
480
- function readBranchInboxFile(
481
- stateDir: string,
482
- run: string,
483
- address: string,
484
- limit = 40,
485
- ): BranchInboxReadResult {
486
- const branch = branchIdFromAddress(address, run);
487
- if (!branch)
488
- throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
489
- try {
490
- const lines = readJsonlTailLines(branchInboxFile(stateDir, branch), limit);
491
- const messages: BranchInboxRecord[] = [];
492
- let corrupted = 0;
493
- for (const line of lines) {
494
- try {
495
- messages.push(JSON.parse(line));
496
- } catch {
497
- corrupted += 1;
498
- }
499
- }
500
- return { corrupted, messages };
501
- } catch (error) {
502
- if ((error as NodeJS.ErrnoException).code === "ENOENT")
503
- return { corrupted: 0, messages: [] };
504
- throw error;
505
- }
506
- }
507
-
508
- function readAllBranchInboxLines(
509
- stateDir: string,
510
- run: string,
511
- address: string,
512
- ): Array<{ raw: string } | { message: BranchInboxRecord }> {
513
- const branch = branchIdFromAddress(address, run);
514
- if (!branch)
515
- throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
516
- try {
517
- const content = fs.readFileSync(branchInboxFile(stateDir, branch), "utf8");
518
- return content
519
- .split("\n")
520
- .filter((line) => line.length > 0)
521
- .map((line) => {
522
- try {
523
- return { message: JSON.parse(line) as BranchInboxRecord };
524
- } catch {
525
- return { raw: line };
526
- }
527
- });
528
- } catch (error) {
529
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
530
- throw error;
531
- }
532
- }
533
-
534
- export function readBranchInboxMessages(
535
- stateDir: string,
536
- run: string,
537
- address: string,
538
- limit = 40,
539
- ): BranchInboxRecord[] {
540
- return readBranchInboxFile(stateDir, run, address, limit).messages;
541
- }
542
-
543
- export function readBranchInboxDiagnostics(
544
- stateDir: string,
545
- run: string,
546
- address: string,
547
- limit = 40,
548
- ): BranchInboxReadResult {
549
- return readBranchInboxFile(stateDir, run, address, limit);
550
- }
551
-
552
- export function getBranchInboxTerminalRetainLimit(): number {
553
- const value = Number(
554
- process.env.PI_ACTORS_BRANCH_INBOX_TERMINAL_RETAINED ?? "",
555
- );
556
- return Number.isInteger(value) && value >= 0
557
- ? value
558
- : DEFAULT_BRANCH_INBOX_TERMINAL_RETAINED;
559
- }
560
-
561
- function compactBranchInboxMessages<T extends { status?: string }>(
562
- messages: T[],
563
- ): T[] {
564
- const retainTerminal = getBranchInboxTerminalRetainLimit();
565
- const active = messages.filter(
566
- (message) => message.status !== "handled" && message.status !== "failed",
567
- );
568
- const terminal = messages.filter(
569
- (message) => message.status === "handled" || message.status === "failed",
570
- );
571
- return [...terminal.slice(-retainTerminal), ...active];
572
- }
573
-
574
- export function appendBranchInboxMessage(
575
- stateDir: string,
576
- run: string,
577
- address: string,
578
- message: ActorMessage,
579
- ): void {
580
- const branch = branchIdFromAddress(address, run);
581
- if (!branch)
582
- throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
583
- const releaseLock = acquireBranchInboxLock(stateDir, branch);
584
- try {
585
- fs.writeFileSync(
586
- branchInboxFile(stateDir, branch),
587
- `${JSON.stringify({ ...message, id: randomUUID(), queued_at: new Date().toISOString(), status: "queued" })}\n`,
588
- { flag: "a" },
589
- );
590
- notifyActorWake(stateDir, address, "branch.message", {
591
- ...(message.from ? { from: message.from } : {}),
592
- type: message.type,
593
- });
594
- } finally {
595
- releaseLock();
596
- }
597
- }
598
-
599
- export function claimBranchInboxMessage(
600
- stateDir: string,
601
- run: string,
602
- address: string,
603
- owner = "runtime",
604
- statuses: string[] = ["queued"],
605
- ): BranchInboxRecord | undefined {
606
- const branch = branchIdFromAddress(address, run);
607
- if (!branch)
608
- throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
609
- const releaseLock = acquireBranchInboxLock(stateDir, branch);
610
- try {
611
- const file = branchInboxFile(stateDir, branch);
612
- const records = readAllBranchInboxLines(stateDir, run, address);
613
- let claimed: BranchInboxRecord | undefined;
614
- const now = new Date().toISOString();
615
- const updated = records.map((record) => {
616
- if (claimed || !("message" in record)) return record;
617
- const status = record.message.status ?? "queued";
618
- if (!statuses.includes(status)) return record;
619
- claimed = {
620
- ...record.message,
621
- claimed_at: now,
622
- claimed_by: owner,
623
- status: "claimed",
624
- };
625
- return { message: claimed };
626
- });
627
- if (!claimed) return undefined;
628
- fs.writeFileSync(
629
- file,
630
- `${updated.map((record) => ("raw" in record ? record.raw : JSON.stringify(record.message))).join("\n")}\n`,
631
- );
632
- notifyActorWake(stateDir, address, "branch.inbox.claim", {
633
- id: claimed.id,
634
- owner,
635
- });
636
- return claimed;
637
- } finally {
638
- releaseLock();
639
- }
640
- }
641
-
642
- export function updateBranchInboxMessageStatus(
643
- stateDir: string,
644
- run: string,
645
- address: string,
646
- id: string,
647
- status: "claimed" | "handled" | "failed",
648
- metadata: Record<string, unknown> = {},
649
- ): boolean {
650
- const branch = branchIdFromAddress(address, run);
651
- if (!branch)
652
- throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
653
- const releaseLock = acquireBranchInboxLock(stateDir, branch);
654
- try {
655
- const file = branchInboxFile(stateDir, branch);
656
- const records = readAllBranchInboxLines(stateDir, run, address);
657
- let changed = false;
658
- const timestampKey = `${status}_at`;
659
- const updated = records.map((record) => {
660
- if (!("message" in record) || record.message.id !== id) return record;
661
- changed = true;
662
- return {
663
- message: {
664
- ...record.message,
665
- ...metadata,
666
- [timestampKey]: new Date().toISOString(),
667
- status,
668
- },
669
- };
670
- });
671
- if (!changed) return false;
672
- const validMessages = updated.flatMap((record) =>
673
- "message" in record ? [record.message] : [],
674
- );
675
- const compactedIds = new Set(
676
- compactBranchInboxMessages(validMessages).map((message) => message.id),
677
- );
678
- const lines = updated.flatMap((record) => {
679
- if ("raw" in record) return [record.raw];
680
- if (record.message.id && !compactedIds.has(record.message.id)) return [];
681
- return [JSON.stringify(record.message)];
682
- });
683
- fs.writeFileSync(file, lines.length ? `${lines.join("\n")}\n` : "");
684
- notifyActorWake(stateDir, address, "branch.inbox.status", { id, status });
685
- return true;
686
- } finally {
687
- releaseLock();
688
- }
689
- }
690
-
691
- export function appendRoomMessage(
692
- stateDir: string,
693
- room: string,
694
- message: ActorMessage,
695
- ): RoomAppendResult {
696
- const releaseLock = acquireRoomLock(stateDir, room);
697
- try {
698
- const receivedAt = new Date().toISOString();
699
- const entry: RoomTimelineEntry = { ...message, received_at: receivedAt };
700
- fs.appendFileSync(
701
- messagesFile(stateDir, room),
702
- `${JSON.stringify(entry)}\n`,
703
- );
704
- compactRoomMessages(stateDir, room);
705
- const roster = updateRosterForMessage(stateDir, room, message, receivedAt);
706
- const run = runFromRoomAddress(message.to);
707
- if (run) {
708
- writeCommunicationSnapshot(stateDir, run);
709
- notifyActorWake(stateDir, message.to, "room.message", {
710
- ...(message.from ? { from: message.from } : {}),
711
- type: message.type,
712
- });
713
- if (message.from && branchIdFromAddress(message.from, run)) {
714
- writeBranchCommunicationSnapshotDebounced(stateDir, run, message.from);
715
- }
716
- }
717
- return {
718
- message_count: readRoomMessageCount(stateDir, room),
719
- room,
720
- roster_count: Object.keys(roster).length,
721
- sent: true,
722
- };
723
- } finally {
724
- releaseLock();
725
- }
726
- }
727
-
728
- export function readRoomMessages(
729
- stateDir: string,
730
- room: string,
731
- limit = 40,
732
- ): RoomTimelineEntry[] {
733
- try {
734
- const lines = readJsonlTailLines(messagesFile(stateDir, room), limit);
735
- return lines.flatMap((line) => {
736
- try {
737
- return [JSON.parse(line) as RoomTimelineEntry];
738
- } catch {
739
- return [];
740
- }
741
- });
742
- } catch (error) {
743
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
744
- throw error;
745
- }
746
- }
747
-
748
- function previewValue(
749
- value: unknown,
750
- maxLength = Limits.ROOM_MESSAGE_PREVIEW_CHARS,
751
- ): string | undefined {
752
- if (value === undefined) return undefined;
753
- const text = typeof value === "string" ? value : JSON.stringify(value);
754
- const compact = text.replaceAll(/\s+/g, " ").trim();
755
- if (!compact) return undefined;
756
- return compact.length > maxLength
757
- ? `${compact.slice(0, Math.max(0, maxLength - 1))}…`
758
- : compact;
759
- }
760
-
761
- export function readRoomMessagePreviews(
762
- stateDir: string,
763
- room: string,
764
- limit = 40,
765
- ): RoomMessagePreview[] {
766
- return readRoomMessages(stateDir, room, limit).map((message) => ({
767
- ...(previewValue(message.body)
768
- ? { body_preview: previewValue(message.body) }
769
- : {}),
770
- ...(message.from ? { from: message.from } : {}),
771
- ...(message.summary ? { summary: message.summary } : {}),
772
- timestamp: message.received_at,
773
- to: message.to,
774
- type: message.type,
775
- }));
776
- }
777
-
778
- export function getRoomStatus(stateDir: string, room: string): RoomStatus {
779
- const messageCount = readRoomMessageCount(stateDir, room);
780
- const [last] = readRoomMessages(stateDir, room, 1);
781
- const compactionRead = readJsonFileResilient<RoomCompactionInfo | undefined>(
782
- path.join(roomDir(stateDir, room), "compaction.json"),
783
- undefined,
784
- );
785
- const rosterRead = readJsonFileResilient<Record<string, RoomMember>>(
786
- rosterFile(stateDir, room),
787
- {},
788
- );
789
- const messageRead = readJsonlFileResilient<RoomTimelineEntry>(
790
- messagesFile(stateDir, room),
791
- );
792
- const diagnostics = [
793
- ...messageRead.diagnostics,
794
- ...rosterRead.diagnostics,
795
- ...compactionRead.diagnostics,
796
- ];
797
- return {
798
- ...(last
799
- ? {
800
- last_message_at: last.received_at,
801
- ...(last.from ? { last_message_from: last.from } : {}),
802
- ...(last.summary ? { last_message_summary: last.summary } : {}),
803
- last_message_type: last.type,
804
- }
805
- : {}),
806
- ...(compactionRead.value ? { compaction: compactionRead.value } : {}),
807
- ...(diagnostics.length
808
- ? {
809
- diagnostics: formatStateReadDiagnostics(diagnostics),
810
- diagnostics_count: diagnostics.length,
811
- }
812
- : {}),
813
- message_count: messageCount,
814
- room,
815
- roster_count: Object.keys(rosterRead.value).length,
816
- };
817
- }
818
-
819
- export function ensureRoomMember(
820
- stateDir: string,
821
- run: string,
822
- room: string,
823
- address: string,
824
- body: Record<string, unknown>,
825
- summary: string,
826
- ): RoomAppendResult {
827
- const roster = readRoomRoster(stateDir, room);
828
- if (roster[address]) {
829
- return {
830
- message_count: readRoomMessageCount(stateDir, room),
831
- room,
832
- roster_count: Object.keys(roster).length,
833
- sent: true,
834
- };
835
- }
836
- return appendRoomMessage(stateDir, room, {
837
- body,
838
- from: address,
839
- summary,
840
- to: `room:${run}`,
841
- type: "actor.join",
842
- });
843
- }
844
-
845
- export function ensureDefaultRoom(
846
- stateDir: string,
847
- run: string,
848
- ): RoomAppendResult {
849
- return ensureRoomMember(
850
- stateDir,
851
- run,
852
- "main",
853
- `run:${run}`,
854
- { role: "run", status: "present" },
855
- "Run joined default room",
856
- );
857
- }
858
-
859
- export function readCommunicationSnapshot(
860
- stateDir: string,
861
- ): ActorCommunicationSnapshot | undefined {
862
- return readJsonFileResilient<ActorCommunicationSnapshot | undefined>(
863
- snapshotFile(stateDir),
864
- undefined,
865
- ).value;
866
- }
867
-
868
- export function readRoomContacts(
869
- stateDir: string,
870
- room: string,
871
- self?: string,
872
- ): RoomContact[] {
873
- return Object.values(readRoomRoster(stateDir, room))
874
- .filter((member) => member.address !== self)
875
- .map((member) => ({
876
- address: member.address,
877
- ...(member.caps !== undefined ? { caps: member.caps } : {}),
878
- ...(member.claim !== undefined ? { claim: member.claim } : {}),
879
- ...(member.parent !== undefined ? { parent: member.parent } : {}),
880
- ...(member.role !== undefined ? { role: member.role } : {}),
881
- ...(member.status !== undefined ? { status: member.status } : {}),
882
- }));
883
- }
884
-
885
- function buildCommunicationSnapshot(
886
- stateDir: string,
887
- run: string,
888
- self: string,
889
- ): ActorCommunicationSnapshot {
890
- const mainRoster = Object.values(readRoomRoster(stateDir, "main"));
891
- const contacts = readRoomContacts(stateDir, "main", self);
892
- return {
893
- ...(contacts.length > 0 ? { contacts } : {}),
894
- ...(self !== `run:${run}` ? { parent: `run:${run}` } : {}),
895
- root: `run:${run}`,
896
- self,
897
- rooms: [
898
- {
899
- address: `room:${run}`,
900
- ...(mainRoster.length > 0 ? { members: mainRoster } : {}),
901
- name: "main",
902
- },
903
- ],
904
- updated_at: new Date().toISOString(),
905
- };
906
- }
907
-
908
- export function writeCommunicationSnapshot(
909
- stateDir: string,
910
- run: string,
911
- ): ActorCommunicationSnapshot {
912
- const snapshot = buildCommunicationSnapshot(stateDir, run, `run:${run}`);
913
- writeJsonFile(snapshotFile(stateDir), snapshot);
914
- return snapshot;
915
- }
916
-
917
- export function writeBranchCommunicationSnapshot(
918
- stateDir: string,
919
- run: string,
920
- self: string,
921
- ): ActorCommunicationSnapshot {
922
- const branch = branchIdFromAddress(self, run);
923
- if (!branch) throw new Error(`Expected branch:${run}/<branch>; got ${self}`);
924
- const snapshot = buildCommunicationSnapshot(stateDir, run, self);
925
- writeJsonFile(branchSnapshotFile(stateDir, branch), snapshot);
926
- return snapshot;
927
- }
928
-
929
- function writeBranchCommunicationSnapshotDebounced(
930
- stateDir: string,
931
- run: string,
932
- self: string,
933
- ): ActorCommunicationSnapshot | undefined {
934
- const branch = branchIdFromAddress(self, run);
935
- if (!branch) throw new Error(`Expected branch:${run}/<branch>; got ${self}`);
936
- if (shouldDebounceSnapshot(branchSnapshotFile(stateDir, branch)))
937
- return undefined;
938
- return writeBranchCommunicationSnapshot(stateDir, run, self);
939
- }