@llblab/pi-actors 0.16.4 → 0.17.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,532 @@
1
+ /**
2
+ * Actor inspector TUI previews.
3
+ * Zones: terminal actor inspection, room/direct message previews, no-dependency UI formatting
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+
9
+ import { visibleWidth } from "@earendil-works/pi-tui";
10
+
11
+ import type { ActorMessage } from "./actor-messages.ts";
12
+ import * as Paths from "./paths.ts";
13
+
14
+ export interface ActorInspectorPreview {
15
+ body_preview?: string;
16
+ channel: "broadcast" | "direct" | "room";
17
+ from?: string;
18
+ from_display?: string;
19
+ run: string;
20
+ sequence?: number;
21
+ summary?: string;
22
+ stripe?: boolean;
23
+ timestamp: string;
24
+ to: string;
25
+ type: string;
26
+ }
27
+
28
+ export interface ActorInspectorWidgetStyle {
29
+ actor?: (text: string) => string;
30
+ muted?: (text: string) => string;
31
+ preview?: (text: string) => string;
32
+ stripe?: (text: string) => string;
33
+ stripeAlt?: (text: string) => string;
34
+ target?: (text: string) => string;
35
+ type?: (text: string) => string;
36
+ }
37
+
38
+ export interface ActorInspectorRenderOptions {}
39
+
40
+ export interface ActorInspectorItemViewOptions {
41
+ sequence: number;
42
+ }
43
+
44
+ export interface ActorInspectorPreviewReadOptions {
45
+ ownerId?: string;
46
+ currentRunOnly?: boolean;
47
+ }
48
+
49
+ function asRecord(value: unknown): Record<string, unknown> {
50
+ return value && typeof value === "object" && !Array.isArray(value)
51
+ ? (value as Record<string, unknown>)
52
+ : {};
53
+ }
54
+
55
+ function readJsonLines(file: string): Record<string, unknown>[] {
56
+ try {
57
+ return fs
58
+ .readFileSync(file, "utf8")
59
+ .split("\n")
60
+ .filter(Boolean)
61
+ .flatMap((line) => {
62
+ try {
63
+ return [JSON.parse(line) as Record<string, unknown>];
64
+ } catch {
65
+ return [];
66
+ }
67
+ });
68
+ } catch (error) {
69
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
70
+ return [];
71
+ }
72
+ }
73
+
74
+ function previewValue(value: unknown, maxLength = 320): string | undefined {
75
+ if (value === undefined) return undefined;
76
+ const text = typeof value === "string" ? value : JSON.stringify(value);
77
+ const compact = text.replaceAll(/\s+/g, " ").trim();
78
+ if (!compact) return undefined;
79
+ return compact.length > maxLength
80
+ ? `${compact.slice(0, Math.max(0, maxLength - 1))}…`
81
+ : compact;
82
+ }
83
+
84
+ function channelFor(
85
+ message: Pick<ActorMessage, "to">,
86
+ ): ActorInspectorPreview["channel"] {
87
+ if (message.to.startsWith("room:")) return "room";
88
+ if (message.to === "coordinator" || message.to.startsWith("session:"))
89
+ return "broadcast";
90
+ return "direct";
91
+ }
92
+
93
+ function previewFromMessage(
94
+ run: string,
95
+ message: Record<string, unknown>,
96
+ timestamp: string,
97
+ displayNames: Record<string, string> = {},
98
+ ): ActorInspectorPreview | undefined {
99
+ const to = typeof message.to === "string" ? message.to : undefined;
100
+ const type = typeof message.type === "string" ? message.type : undefined;
101
+ if (!to || !type) return undefined;
102
+ const from = typeof message.from === "string" ? message.from : undefined;
103
+ const summary =
104
+ typeof message.summary === "string" ? message.summary : undefined;
105
+ const body = asRecord(message.body);
106
+ const display = from
107
+ ? typeof body.display === "string" && body.display.trim()
108
+ ? body.display.trim()
109
+ : displayNames[from]
110
+ : undefined;
111
+ return {
112
+ ...(previewValue(message.body)
113
+ ? { body_preview: previewValue(message.body) }
114
+ : {}),
115
+ channel: channelFor({ to }),
116
+ ...(from ? { from } : {}),
117
+ ...(display ? { from_display: display } : {}),
118
+ run,
119
+ ...(summary ? { summary } : {}),
120
+ timestamp,
121
+ to,
122
+ type,
123
+ };
124
+ }
125
+
126
+ function readRoomDisplayNames(stateDir: string, room: string): Record<string, string> {
127
+ try {
128
+ const roster = JSON.parse(
129
+ fs.readFileSync(path.join(stateDir, "rooms", room, "roster.json"), "utf8"),
130
+ ) as Record<string, Record<string, unknown>>;
131
+ return Object.fromEntries(
132
+ Object.entries(roster).flatMap(([address, member]) => {
133
+ const glyph = typeof member.glyph === "string" ? member.glyph.trim() : "";
134
+ const display = typeof member.display === "string" ? member.display.trim() : "";
135
+ if (display) return [[address, display]];
136
+ if (!glyph) return [];
137
+ return [[address, `${glyph} ${actorName(address)}`]];
138
+ }),
139
+ );
140
+ } catch {
141
+ return {};
142
+ }
143
+ }
144
+
145
+ function readRoomPreviews(
146
+ run: string,
147
+ stateDir: string,
148
+ ): ActorInspectorPreview[] {
149
+ const roomsDir = path.join(stateDir, "rooms");
150
+ try {
151
+ return fs
152
+ .readdirSync(roomsDir, { withFileTypes: true })
153
+ .filter((entry) => entry.isDirectory())
154
+ .flatMap((entry) => {
155
+ const displayNames = readRoomDisplayNames(stateDir, entry.name);
156
+ return readJsonLines(path.join(roomsDir, entry.name, "messages.jsonl"))
157
+ .map((message) =>
158
+ previewFromMessage(
159
+ run,
160
+ message,
161
+ String(message.received_at ?? message.timestamp ?? ""),
162
+ displayNames,
163
+ ),
164
+ )
165
+ .filter((preview): preview is ActorInspectorPreview =>
166
+ Boolean(preview),
167
+ );
168
+ });
169
+ } catch (error) {
170
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
171
+ return [];
172
+ }
173
+ }
174
+
175
+ function readInboxPreviews(
176
+ run: string,
177
+ stateDir: string,
178
+ ): ActorInspectorPreview[] {
179
+ return readJsonLines(path.join(stateDir, "inbox.jsonl"))
180
+ .map((message) =>
181
+ previewFromMessage(
182
+ run,
183
+ message,
184
+ String(message.received_at ?? message.timestamp ?? ""),
185
+ ),
186
+ )
187
+ .filter((preview): preview is ActorInspectorPreview => Boolean(preview));
188
+ }
189
+
190
+ function readOutboxPreviews(
191
+ run: string,
192
+ stateDir: string,
193
+ ): ActorInspectorPreview[] {
194
+ return readJsonLines(path.join(stateDir, "outbox.jsonl"))
195
+ .map((event) => {
196
+ const message = asRecord(event.message ?? event);
197
+ return previewFromMessage(
198
+ run,
199
+ message,
200
+ String(event.timestamp ?? event.created_at ?? event.emitted_at ?? ""),
201
+ );
202
+ })
203
+ .filter((preview): preview is ActorInspectorPreview => Boolean(preview));
204
+ }
205
+
206
+ function getRunOwnerId(stateDir: string): string | undefined {
207
+ try {
208
+ const meta = JSON.parse(
209
+ fs.readFileSync(path.join(stateDir, "run.json"), "utf8"),
210
+ ) as Record<string, unknown>;
211
+ return typeof meta.ownerId === "string" ? meta.ownerId : undefined;
212
+ } catch {
213
+ return undefined;
214
+ }
215
+ }
216
+
217
+ function matchesOwner(stateDir: string, ownerId: string | undefined): boolean {
218
+ return ownerId === undefined || getRunOwnerId(stateDir) === ownerId;
219
+ }
220
+
221
+ export function readActorInspectorPreviews(
222
+ stateRoot = Paths.getRunStateRoot(),
223
+ limit = 8,
224
+ options: ActorInspectorPreviewReadOptions = {},
225
+ ): ActorInspectorPreview[] {
226
+ try {
227
+ const previews = fs
228
+ .readdirSync(stateRoot, { withFileTypes: true })
229
+ .filter((entry) => entry.isDirectory())
230
+ .flatMap((entry) => {
231
+ const stateDir = path.join(stateRoot, entry.name);
232
+ if (!matchesOwner(stateDir, options.ownerId)) return [];
233
+ return [
234
+ ...readRoomPreviews(entry.name, stateDir),
235
+ ...readInboxPreviews(entry.name, stateDir),
236
+ ...readOutboxPreviews(entry.name, stateDir),
237
+ ];
238
+ })
239
+ .filter((preview) => preview.timestamp)
240
+ .sort((a, b) => a.timestamp.localeCompare(b.timestamp));
241
+ const currentRun = options.currentRunOnly
242
+ ? previews.at(-1)?.run
243
+ : undefined;
244
+ return previews
245
+ .filter((preview) => !currentRun || preview.run === currentRun)
246
+ .map((preview, index) => ({
247
+ ...preview,
248
+ sequence: index + 1,
249
+ stripe: index % 2 === 0,
250
+ }))
251
+ .slice(-Math.max(1, limit));
252
+ } catch (error) {
253
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
254
+ return [];
255
+ }
256
+ }
257
+
258
+ function shorten(
259
+ value: string | undefined,
260
+ maxLength: number,
261
+ options: { preserveSpaces?: boolean } = { preserveSpaces: true },
262
+ ): string {
263
+ if (!value) return "-";
264
+ const compact =
265
+ options.preserveSpaces === false
266
+ ? value.replaceAll(/\s+/g, "_")
267
+ : value.replaceAll(/\s+/g, " ").trim();
268
+ if (maxLength <= 1) return compact.slice(0, Math.max(0, maxLength));
269
+ return compact.length > maxLength
270
+ ? `${compact.slice(0, Math.max(0, maxLength - 1))}…`
271
+ : compact;
272
+ }
273
+
274
+ function actorName(address: string | undefined): string {
275
+ if (!address) return "unknown";
276
+ const branch = /^branch:[^/]+\/(.+)$/.exec(address);
277
+ if (branch) return branch[1] || address;
278
+ const run = /^run:(.+)$/.exec(address);
279
+ if (run) return run[1] || address;
280
+ return address;
281
+ }
282
+
283
+ function roomName(address: string): string | undefined {
284
+ const room = /^room:([^/]+)(?:\/(main))?$/.exec(address);
285
+ return room ? room[1] : undefined;
286
+ }
287
+
288
+ function routeText(preview: ActorInspectorPreview): string {
289
+ const actor = preview.from_display || actorName(preview.from);
290
+ if (preview.channel === "room") return `${actor} # all`;
291
+ if (preview.channel === "broadcast") return `${actor} ⇢ ${preview.to}`;
292
+ return `${actor} → ${actorName(preview.to)}`;
293
+ }
294
+
295
+ function style(
296
+ styleFn: ((text: string) => string) | undefined,
297
+ text: string,
298
+ ): string {
299
+ return styleFn ? styleFn(text) : text;
300
+ }
301
+
302
+ function previewText(preview: ActorInspectorPreview): string {
303
+ return preview.summary || preview.body_preview || "-";
304
+ }
305
+
306
+ function propertyValue(value: unknown): string {
307
+ if (value === undefined) return "";
308
+ if (typeof value === "string") return value;
309
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
310
+ return JSON.stringify(value);
311
+ }
312
+
313
+ function displayWidth(value: string): number {
314
+ return visibleWidth(value);
315
+ }
316
+
317
+ function boundedLine(value: string, width: number): string {
318
+ if (width <= 0) return "";
319
+ if (visibleWidth(value) <= width) return value;
320
+ const ellipsis = "...";
321
+ const ellipsisWidth = visibleWidth(ellipsis);
322
+ if (width <= ellipsisWidth) return ellipsis.slice(0, width);
323
+ let output = "";
324
+ let used = 0;
325
+ const maxTextWidth = width - ellipsisWidth;
326
+ const segmenter = new Intl.Segmenter();
327
+ for (const { segment } of segmenter.segment(value)) {
328
+ const segmentWidth = visibleWidth(segment);
329
+ if (used + segmentWidth > maxTextWidth) break;
330
+ output += segment;
331
+ used += segmentWidth;
332
+ }
333
+ return `${output}${ellipsis}`;
334
+ }
335
+
336
+ function padLine(
337
+ plain: string,
338
+ rendered: string,
339
+ width: number,
340
+ styles: ActorInspectorWidgetStyle,
341
+ ): string {
342
+ const boundedPlain = boundedLine(plain, width);
343
+ const visible =
344
+ boundedPlain === plain ? rendered : style(styles.preview, boundedPlain);
345
+ const padding = Math.max(0, width - visibleWidth(boundedPlain));
346
+ return `${visible}${" ".repeat(padding)}`;
347
+ }
348
+
349
+ function renderCompactInspectorEntry(
350
+ preview: ActorInspectorPreview,
351
+ width: number,
352
+ sequenceWidth: number,
353
+ routeWidth: number,
354
+ typeWidth: number,
355
+ styles: ActorInspectorWidgetStyle,
356
+ stripe: boolean,
357
+ ): string[] {
358
+ const separator = " ";
359
+ const prefix = " ";
360
+ const contentWidth = Math.max(8, width - prefix.length);
361
+ const sequence = String(preview.sequence ?? 0).padStart(sequenceWidth, " ");
362
+ const sequencePrefix = `${sequence} `;
363
+ const route = routeText(preview);
364
+ const routePadding = " ".repeat(
365
+ Math.max(0, routeWidth - displayWidth(route)),
366
+ );
367
+ const typePadding = " ".repeat(
368
+ Math.max(0, typeWidth - displayWidth(preview.type)),
369
+ );
370
+ const headline = previewText(preview);
371
+ const lead = `${sequencePrefix}${route}${routePadding}${separator}${preview.type}${typePadding}${separator}`;
372
+ const visibleHeadline = boundedLine(
373
+ headline,
374
+ Math.max(0, contentWidth - displayWidth(lead)),
375
+ );
376
+ const plain = `${lead}${visibleHeadline}`;
377
+ const rendered = [
378
+ style(styles.muted, sequencePrefix),
379
+ style(styles.target, route),
380
+ routePadding,
381
+ separator,
382
+ style(styles.type, preview.type),
383
+ typePadding,
384
+ separator,
385
+ style(styles.preview, visibleHeadline),
386
+ ].join("");
387
+ const line = `${prefix}${padLine(plain, rendered, contentWidth, styles)}`;
388
+ if (stripe && styles.stripe) return [styles.stripe(line)];
389
+ if (!stripe && styles.stripeAlt) return [styles.stripeAlt(line)];
390
+ return [line];
391
+ }
392
+
393
+ function renderInspectorEntry(
394
+ preview: ActorInspectorPreview,
395
+ width: number,
396
+ sequenceWidth: number,
397
+ routeWidth: number,
398
+ typeWidth: number,
399
+ summaryWidth: number,
400
+ styles: ActorInspectorWidgetStyle,
401
+ stripe: boolean,
402
+ ): string[] {
403
+ const separator = " ";
404
+ const prefix = " ";
405
+ const contentWidth = Math.max(8, width - prefix.length);
406
+ const sequence = String(preview.sequence ?? 0).padStart(sequenceWidth, " ");
407
+ const sequencePrefix = `${sequence}${separator}`;
408
+ const route = routeText(preview);
409
+ const type = preview.type;
410
+ const summary = preview.summary?.trim() ?? "";
411
+ const body = preview.body_preview?.trim() || (!summary ? previewText(preview) : "-");
412
+ const routePadding = " ".repeat(Math.max(0, routeWidth - displayWidth(route)));
413
+ const typePadding = " ".repeat(Math.max(0, typeWidth - displayWidth(type)));
414
+ const visibleSummary = boundedLine(summary, summaryWidth);
415
+ const summaryPadding = " ".repeat(Math.max(0, summaryWidth - displayWidth(visibleSummary)));
416
+ const lead = `${sequencePrefix}${route}${routePadding}${separator}${type}${typePadding}${separator}${visibleSummary}${summaryPadding}${separator}`;
417
+ const renderedLead = [
418
+ style(styles.muted, sequencePrefix),
419
+ style(styles.target, route),
420
+ routePadding,
421
+ separator,
422
+ style(styles.type, type),
423
+ typePadding,
424
+ separator,
425
+ style(styles.preview, visibleSummary),
426
+ summaryPadding,
427
+ separator,
428
+ ].join("");
429
+ const visibleBody = boundedLine(body, Math.max(0, contentWidth - displayWidth(lead)));
430
+ const plain = `${lead}${visibleBody}`;
431
+ const rendered = `${renderedLead}${style(styles.preview, visibleBody)}`;
432
+ const line = `${prefix}${padLine(plain, rendered, contentWidth, styles)}`;
433
+ if (stripe && styles.stripe) return [styles.stripe(line)];
434
+ if (!stripe && styles.stripeAlt) return [styles.stripeAlt(line)];
435
+ return [line];
436
+ }
437
+
438
+ export function renderInspectorItemView(
439
+ previews: ActorInspectorPreview[],
440
+ width = 80,
441
+ styles: ActorInspectorWidgetStyle = {},
442
+ options: ActorInspectorItemViewOptions,
443
+ ): string[] | undefined {
444
+ const preview = previews.find((item) => item.sequence === options.sequence);
445
+ if (!preview) return undefined;
446
+ const safeWidth = Math.max(1, width);
447
+ const orderedKeys = [
448
+ "channel",
449
+ "run",
450
+ "from",
451
+ "from_display",
452
+ "to",
453
+ "type",
454
+ "summary",
455
+ "body_preview",
456
+ "timestamp",
457
+ "stripe",
458
+ ] as const;
459
+ const entries = orderedKeys
460
+ .filter((key) => preview[key] !== undefined)
461
+ .map((key) => [key, propertyValue(preview[key])] as const);
462
+ const keyWidth = Math.max(1, ...entries.map(([key]) => displayWidth(key)));
463
+ const sequenceText = String(preview.sequence ?? options.sequence);
464
+ const sequencePadding = " ".repeat(Math.max(0, keyWidth - displayWidth(sequenceText)));
465
+ const headerSeparator = " ";
466
+ const route = routeText(preview);
467
+ const visibleRoute = boundedLine(
468
+ route,
469
+ Math.max(0, safeWidth - keyWidth - headerSeparator.length),
470
+ );
471
+ const headerPlain = `${sequenceText}${sequencePadding}${headerSeparator}${visibleRoute}`;
472
+ const header = `${style(styles.muted, sequenceText)}${sequencePadding}${headerSeparator}${style(styles.target, visibleRoute)}`;
473
+ const headerPadding = Math.max(0, safeWidth - visibleWidth(headerPlain));
474
+ const lines = [`${header}${" ".repeat(headerPadding)}`, ""];
475
+ for (const [key, value] of entries) {
476
+ const keyPadding = " ".repeat(Math.max(0, keyWidth - displayWidth(key)));
477
+ const separator = " ";
478
+ const valueWidth = Math.max(0, safeWidth - keyWidth - separator.length);
479
+ const visibleValue = boundedLine(value, valueWidth);
480
+ const plain = `${key}${keyPadding}${separator}${visibleValue}`;
481
+ const rendered = `${style(styles.muted, key)}${keyPadding}${separator}${style(styles.preview, visibleValue)}`;
482
+ const padding = Math.max(0, safeWidth - visibleWidth(plain));
483
+ lines.push(`${rendered}${" ".repeat(padding)}`);
484
+ }
485
+ return lines;
486
+ }
487
+
488
+ export function renderInspectorWidget(
489
+ previews: ActorInspectorPreview[],
490
+ width = 80,
491
+ styles: ActorInspectorWidgetStyle = {},
492
+ options: ActorInspectorRenderOptions = {},
493
+ ): string[] | undefined {
494
+ if (previews.length === 0) return undefined;
495
+ const safeWidth = Math.max(1, width);
496
+ void options;
497
+ const visible = previews.map((preview, index) => ({
498
+ preview: { ...preview, sequence: preview.sequence ?? index + 1 },
499
+ stripe: preview.stripe ?? index % 2 === 0,
500
+ }));
501
+ const sequenceWidth = Math.max(
502
+ 1,
503
+ ...visible.map(({ preview }) => String(preview.sequence ?? 0).length),
504
+ );
505
+ const lines: string[] = [];
506
+ const routeWidth = Math.max(
507
+ ...visible.map(({ preview }) => displayWidth(routeText(preview))),
508
+ );
509
+ const typeWidth = Math.max(
510
+ ...visible.map(({ preview }) => displayWidth(preview.type)),
511
+ );
512
+ const summaryWidths = visible
513
+ .map(({ preview }) => preview.summary?.trim())
514
+ .filter((summary): summary is string => Boolean(summary))
515
+ .map((summary) => displayWidth(summary));
516
+ const summaryWidth = summaryWidths.length ? Math.max(...summaryWidths) : 0;
517
+ for (const { preview, stripe } of visible) {
518
+ lines.push(
519
+ ...renderInspectorEntry(
520
+ preview,
521
+ safeWidth,
522
+ sequenceWidth,
523
+ routeWidth,
524
+ typeWidth,
525
+ summaryWidth,
526
+ styles,
527
+ stripe,
528
+ ),
529
+ );
530
+ }
531
+ return lines;
532
+ }
@@ -7,6 +7,7 @@
7
7
  export type ActorAddressKind =
8
8
  | "branch"
9
9
  | "coordinator"
10
+ | "room"
10
11
  | "run"
11
12
  | "session"
12
13
  | "tool";
@@ -15,6 +16,7 @@ export interface ActorAddress {
15
16
  kind: ActorAddressKind;
16
17
  value?: string;
17
18
  branch?: string;
19
+ room?: string;
18
20
  }
19
21
 
20
22
  export interface ActorMessage {
@@ -59,6 +61,19 @@ export function parseActorAddress(address: string): ActorAddress {
59
61
  branch: assertToken(branch || "", "branch id"),
60
62
  };
61
63
  }
64
+ case "room": {
65
+ const [run, room, ...extra] = rest.split("/");
66
+ if (extra.length > 0)
67
+ throw new Error(`Room address has too many parts: ${address}`);
68
+ if (room && room !== "main") {
69
+ throw new Error("Task rooms do not support named subrooms; use room:<run>.");
70
+ }
71
+ return {
72
+ kind,
73
+ value: assertToken(run || "", "room run"),
74
+ room: "main",
75
+ };
76
+ }
62
77
  case "run":
63
78
  case "session":
64
79
  case "tool":
@@ -73,6 +88,9 @@ export function formatActorAddress(address: ActorAddress): string {
73
88
  if (address.kind === "branch") {
74
89
  return `branch:${assertToken(address.value || "", "branch run")}/${assertToken(address.branch || "", "branch id")}`;
75
90
  }
91
+ if (address.kind === "room") {
92
+ return `room:${assertToken(address.value || "", "room run")}`;
93
+ }
76
94
  return `${address.kind}:${assertToken(address.value || "", `${address.kind} address`)}`;
77
95
  }
78
96