@llblab/pi-actors 0.16.4 → 0.17.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 +7 -7
- package/BACKLOG.md +42 -11
- package/CHANGELOG.md +14 -8
- package/README.md +39 -12
- package/banner.jpg +0 -0
- package/docs/actor-messages.md +63 -2
- package/docs/async-runs.md +25 -3
- package/docs/recipe-library.md +3 -3
- package/docs/template-recipes.md +3 -4
- package/docs/tool-registry.md +7 -12
- package/index.ts +58 -3
- package/lib/actor-inspector-tui.ts +426 -0
- package/lib/actor-messages.ts +18 -0
- package/lib/actor-rooms.ts +369 -0
- package/lib/async-runs.ts +17 -1
- package/lib/config.ts +1 -1
- package/lib/paths.ts +1 -1
- package/lib/prompts.ts +3 -2
- package/lib/recipe-discovery.ts +83 -1
- package/lib/recipe-migration.ts +2 -2
- package/lib/recipe-references.ts +2 -0
- package/lib/tools.ts +292 -9
- package/package.json +1 -1
- package/recipes/lens-swarm.json +0 -1
- package/skills/actors/SKILL.md +51 -8
- package/skills/swarm/SKILL.md +1 -1
package/docs/tool-registry.md
CHANGED
|
@@ -6,18 +6,16 @@ This document is the local adaptation of the portable [Command Template Standard
|
|
|
6
6
|
|
|
7
7
|
## Registry Model
|
|
8
8
|
|
|
9
|
-
The
|
|
9
|
+
The registry source is location-discovered recipes, not a live tool-only JSON file and not a recipe-owned boolean:
|
|
10
10
|
|
|
11
11
|
- `~/.pi/agent/recipes/*.json` is the highest-priority user recipe root and the operator-managed tool set.
|
|
12
|
-
- Recipes in that root are tools by
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
- Packaged or ad hoc recipes opt into tool exposure with `tool: true`.
|
|
12
|
+
- Recipes in that root are tools by location.
|
|
13
|
+
- Packaged pi-actors recipes are the lower-priority standard library of declarative actor config components, not automatically registered tools.
|
|
14
|
+
- Ad hoc recipe files outside the user recipe root are components unless explicitly registered/copied into `~/.pi/agent/recipes`.
|
|
16
15
|
- Recipe identity is the filename basename; `~/.pi/agent/recipes/docs_review.json` has id/tool name `docs_review`.
|
|
17
16
|
|
|
18
|
-
`~/.pi/agent/actors-tools.json` is legacy compatibility input. On startup, pi-actors migrates it into recipe files when possible, writes a migration report, and archives the source only when migration has no conflicts or invalid generated recipes.
|
|
19
17
|
|
|
20
|
-
Because the user recipe directory is sticky agent muscle memory, runtime launches update `usage.calls`, `usage.last_called`, and a content `usage.fingerprint` on user-owned recipe files. If authored recipe content changes, the next launch resets `usage.calls` and records `usage.reset_at` before counting the launch, so usage evidence follows the current recipe meaning rather than an older file history.
|
|
18
|
+
Because the user recipe directory is sticky agent muscle memory, runtime launches update `usage.calls`, `usage.last_called`, and a content `usage.fingerprint` on user-owned recipe files. If authored recipe content changes, the next launch resets `usage.calls` and records `usage.reset_at` before counting the launch, so usage evidence follows the current recipe meaning rather than an older file history. `inspect target=recipes view=summary verbose=true` includes usage metadata and operator-gated cleanup recommendations for invalid, shadowed, disabled, component-only, unused, or overriding recipes. Recommended actions stay explicit: keep as a tool/component, enable, merge, fix, delete, or archive. The extension does not maintain a failure counter and agents should not silently clean tools during unrelated work.
|
|
21
19
|
|
|
22
20
|
`register_tool` is the preferred agent-facing mutation API. It creates, updates, and deletes recipe files in `~/.pi/agent/recipes`; agents do not need to edit the files directly for normal registration. Direct file edits are still valid for operators and advanced agents. Runtime behavior is reactive: file creation, deletion, or edits in the user recipe root trigger validation and tool-set refresh, with invalid recipes surfaced as diagnostics rather than silently ignored.
|
|
23
21
|
|
|
@@ -62,18 +60,17 @@ For reusable actor workflows, register a small tool whose `template` points to a
|
|
|
62
60
|
```text
|
|
63
61
|
register_tool name=docs_review \
|
|
64
62
|
description="Start an async docs review actor" \
|
|
65
|
-
template="
|
|
63
|
+
template="docs_review" \
|
|
66
64
|
args="scope:path,model:string"
|
|
67
65
|
```
|
|
68
66
|
|
|
69
|
-
This writes or updates `~/.pi/agent/recipes/docs_review.json` with
|
|
67
|
+
This writes or updates `~/.pi/agent/recipes/docs_review.json` with a recipe-reference template. Its location in the user recipe root makes it a tool. If the referenced recipe contains `async: true`, calling the tool starts a detached actor run and returns metadata immediately. If `async` is omitted or false, the same recipe runs foreground and returns normal tool output.
|
|
70
68
|
|
|
71
69
|
When co-location is clearer than a separate file, `register_tool` writes the recipe fields directly into the user recipe file:
|
|
72
70
|
|
|
73
71
|
```json
|
|
74
72
|
{
|
|
75
73
|
"description": "Start an async docs review",
|
|
76
|
-
"tool": true,
|
|
77
74
|
"async": true,
|
|
78
75
|
"args": ["scope:path", "model:string"],
|
|
79
76
|
"template": "pi -p --model {model} --tools read,bash \"Review {scope}\""
|
|
@@ -95,7 +92,6 @@ Tool names come from recipe filenames in `~/.pi/agent/recipes`. Recipe files def
|
|
|
95
92
|
```json
|
|
96
93
|
{
|
|
97
94
|
"description": "Transcribe an audio file",
|
|
98
|
-
"tool": true,
|
|
99
95
|
"template": "~/bin/transcribe {file:path} {lang=ru} {model:string}"
|
|
100
96
|
}
|
|
101
97
|
```
|
|
@@ -103,7 +99,6 @@ Tool names come from recipe filenames in `~/.pi/agent/recipes`. Recipe files def
|
|
|
103
99
|
```json
|
|
104
100
|
{
|
|
105
101
|
"description": "Run pi as a non-interactive sub-agent",
|
|
106
|
-
"tool": true,
|
|
107
102
|
"args": ["prompt:string", "model:string"],
|
|
108
103
|
"template": "pi -p --model {model} --no-tools {prompt}"
|
|
109
104
|
}
|
package/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* pi-actors — actor runtime and persistent local tool registry for pi.
|
|
3
3
|
* Zones: composition root, pi agent, actor runtime
|
|
4
4
|
*
|
|
5
|
-
* Wraps command templates as callable pi tools, stores
|
|
5
|
+
* Wraps command templates as callable pi tools, stores durable user tools as recipe files, and exposes actor orchestration across reloads and sessions.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, readdirSync, watch, type FSWatcher } from "node:fs";
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
ExtensionContext,
|
|
13
13
|
} from "@earendil-works/pi-coding-agent";
|
|
14
14
|
|
|
15
|
+
import * as ActorInspectorTui from "./lib/actor-inspector-tui.ts";
|
|
15
16
|
import * as CommandTemplates from "./lib/command-templates.ts";
|
|
16
17
|
import * as Observability from "./lib/observability.ts";
|
|
17
18
|
import * as Paths from "./lib/paths.ts";
|
|
@@ -47,6 +48,8 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
|
|
|
47
48
|
const observedRuns = new Map<string, Observability.RunObservedStatus>();
|
|
48
49
|
const observedRunEventLines = new Map<string, number>();
|
|
49
50
|
let runStatusFrame = 0;
|
|
51
|
+
let communicationWidgetVisible = false;
|
|
52
|
+
let inspectorVerbosity: ActorInspectorTui.ActorInspectorVerbosity = "verbose";
|
|
50
53
|
const getRunOwnerId = (ctx: ExtensionContext): string =>
|
|
51
54
|
ctx.sessionManager.getSessionId();
|
|
52
55
|
const updateRunUi = (ctx: ExtensionContext, notify = false): void => {
|
|
@@ -57,7 +60,37 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
|
|
|
57
60
|
"zz-pi-actors-runs",
|
|
58
61
|
status ? ctx.ui.theme.fg("dim", status) : undefined,
|
|
59
62
|
);
|
|
60
|
-
ctx.ui.setWidget(
|
|
63
|
+
ctx.ui.setWidget(
|
|
64
|
+
"zz-pi-actors-comms",
|
|
65
|
+
communicationWidgetVisible
|
|
66
|
+
? () => ({
|
|
67
|
+
invalidate() {},
|
|
68
|
+
render(width: number) {
|
|
69
|
+
return (
|
|
70
|
+
ActorInspectorTui.renderInspectorWidget(
|
|
71
|
+
ActorInspectorTui.readActorInspectorPreviews(
|
|
72
|
+
RUN_STATE_ROOT,
|
|
73
|
+
14,
|
|
74
|
+
{ ownerId, currentRunOnly: true },
|
|
75
|
+
),
|
|
76
|
+
width,
|
|
77
|
+
{
|
|
78
|
+
actor: (text) => ctx.ui.theme.fg("accent", text),
|
|
79
|
+
muted: (text) => ctx.ui.theme.fg("dim", text),
|
|
80
|
+
preview: (text) => ctx.ui.theme.fg("text", text),
|
|
81
|
+
stripe: (text) => text,
|
|
82
|
+
stripeAlt: (text) => ctx.ui.theme.bg("customMessageBg", text),
|
|
83
|
+
target: (text) => ctx.ui.theme.fg("success", text),
|
|
84
|
+
type: (text) => ctx.ui.theme.fg("warning", text),
|
|
85
|
+
},
|
|
86
|
+
{ verbosity: inspectorVerbosity },
|
|
87
|
+
) ?? []
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
: undefined,
|
|
92
|
+
{ placement: "belowEditor" },
|
|
93
|
+
);
|
|
61
94
|
const transitions = Observability.detectRunTransitions(
|
|
62
95
|
observedRuns,
|
|
63
96
|
summary,
|
|
@@ -135,7 +168,9 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
|
|
|
135
168
|
if (!existsSync(RUN_STATE_ROOT)) return;
|
|
136
169
|
if (!stateRootWatcher) {
|
|
137
170
|
try {
|
|
138
|
-
stateRootWatcher = watch(RUN_STATE_ROOT, () =>
|
|
171
|
+
stateRootWatcher = watch(RUN_STATE_ROOT, () =>
|
|
172
|
+
scheduleRunEventUpdate(ctx),
|
|
173
|
+
);
|
|
139
174
|
stateRootWatcher.on("error", () => {
|
|
140
175
|
stateRootWatcher?.close();
|
|
141
176
|
stateRootWatcher = undefined;
|
|
@@ -207,6 +242,26 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
|
|
|
207
242
|
closeRunWatchers();
|
|
208
243
|
closeRecipeWatcher();
|
|
209
244
|
});
|
|
245
|
+
pi.registerCommand("actors-inspector-toggle", {
|
|
246
|
+
description: "Toggle actor inspector widget",
|
|
247
|
+
handler: async (_args, ctx) => {
|
|
248
|
+
communicationWidgetVisible = !communicationWidgetVisible;
|
|
249
|
+
updateRunUi(ctx);
|
|
250
|
+
ctx.ui.notify(
|
|
251
|
+
`Actor inspector ${communicationWidgetVisible ? "shown" : "hidden"}`,
|
|
252
|
+
"info",
|
|
253
|
+
);
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
pi.registerCommand("actors-inspector-verbosity", {
|
|
257
|
+
description: "Toggle actor inspector verbosity",
|
|
258
|
+
handler: async (_args, ctx) => {
|
|
259
|
+
inspectorVerbosity =
|
|
260
|
+
inspectorVerbosity === "verbose" ? "compact" : "verbose";
|
|
261
|
+
updateRunUi(ctx);
|
|
262
|
+
ctx.ui.notify(`Actor inspector ${inspectorVerbosity} mode`, "info");
|
|
263
|
+
},
|
|
264
|
+
});
|
|
210
265
|
pi.on("before_agent_start", async (event) => ({
|
|
211
266
|
systemPrompt: `${event.systemPrompt}\n\n${Prompts.ONBOARDING_SYSTEM_PROMPT}`,
|
|
212
267
|
}));
|
|
@@ -0,0 +1,426 @@
|
|
|
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 type { ActorMessage } from "./actor-messages.ts";
|
|
10
|
+
import * as Paths from "./paths.ts";
|
|
11
|
+
|
|
12
|
+
export interface ActorInspectorPreview {
|
|
13
|
+
body_preview?: string;
|
|
14
|
+
channel: "broadcast" | "direct" | "room";
|
|
15
|
+
from?: string;
|
|
16
|
+
run: string;
|
|
17
|
+
sequence?: number;
|
|
18
|
+
summary?: string;
|
|
19
|
+
stripe?: boolean;
|
|
20
|
+
timestamp: string;
|
|
21
|
+
to: string;
|
|
22
|
+
type: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ActorInspectorWidgetStyle {
|
|
26
|
+
actor?: (text: string) => string;
|
|
27
|
+
muted?: (text: string) => string;
|
|
28
|
+
preview?: (text: string) => string;
|
|
29
|
+
stripe?: (text: string) => string;
|
|
30
|
+
stripeAlt?: (text: string) => string;
|
|
31
|
+
target?: (text: string) => string;
|
|
32
|
+
type?: (text: string) => string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ActorInspectorVerbosity = "compact" | "verbose";
|
|
36
|
+
|
|
37
|
+
export interface ActorInspectorRenderOptions {
|
|
38
|
+
verbosity?: ActorInspectorVerbosity;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ActorInspectorPreviewReadOptions {
|
|
42
|
+
ownerId?: string;
|
|
43
|
+
currentRunOnly?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
47
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
48
|
+
? (value as Record<string, unknown>)
|
|
49
|
+
: {};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readJsonLines(file: string): Record<string, unknown>[] {
|
|
53
|
+
try {
|
|
54
|
+
return fs
|
|
55
|
+
.readFileSync(file, "utf8")
|
|
56
|
+
.split("\n")
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.flatMap((line) => {
|
|
59
|
+
try {
|
|
60
|
+
return [JSON.parse(line) as Record<string, unknown>];
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function previewValue(value: unknown, maxLength = 320): string | undefined {
|
|
72
|
+
if (value === undefined) return undefined;
|
|
73
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
74
|
+
const compact = text.replaceAll(/\s+/g, " ").trim();
|
|
75
|
+
if (!compact) return undefined;
|
|
76
|
+
return compact.length > maxLength
|
|
77
|
+
? `${compact.slice(0, Math.max(0, maxLength - 1))}…`
|
|
78
|
+
: compact;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function channelFor(message: Pick<ActorMessage, "to">): ActorInspectorPreview["channel"] {
|
|
82
|
+
if (message.to.startsWith("room:")) return "room";
|
|
83
|
+
if (message.to === "coordinator" || message.to.startsWith("session:")) return "broadcast";
|
|
84
|
+
return "direct";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function previewFromMessage(
|
|
88
|
+
run: string,
|
|
89
|
+
message: Record<string, unknown>,
|
|
90
|
+
timestamp: string,
|
|
91
|
+
): ActorInspectorPreview | undefined {
|
|
92
|
+
const to = typeof message.to === "string" ? message.to : undefined;
|
|
93
|
+
const type = typeof message.type === "string" ? message.type : undefined;
|
|
94
|
+
if (!to || !type) return undefined;
|
|
95
|
+
const from = typeof message.from === "string" ? message.from : undefined;
|
|
96
|
+
const summary = typeof message.summary === "string" ? message.summary : undefined;
|
|
97
|
+
return {
|
|
98
|
+
...(previewValue(message.body) ? { body_preview: previewValue(message.body) } : {}),
|
|
99
|
+
channel: channelFor({ to }),
|
|
100
|
+
...(from ? { from } : {}),
|
|
101
|
+
run,
|
|
102
|
+
...(summary ? { summary } : {}),
|
|
103
|
+
timestamp,
|
|
104
|
+
to,
|
|
105
|
+
type,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readRoomPreviews(run: string, stateDir: string): ActorInspectorPreview[] {
|
|
110
|
+
const roomsDir = path.join(stateDir, "rooms");
|
|
111
|
+
try {
|
|
112
|
+
return fs
|
|
113
|
+
.readdirSync(roomsDir, { withFileTypes: true })
|
|
114
|
+
.filter((entry) => entry.isDirectory())
|
|
115
|
+
.flatMap((entry) =>
|
|
116
|
+
readJsonLines(path.join(roomsDir, entry.name, "messages.jsonl"))
|
|
117
|
+
.map((message) =>
|
|
118
|
+
previewFromMessage(
|
|
119
|
+
run,
|
|
120
|
+
message,
|
|
121
|
+
String(message.received_at ?? message.timestamp ?? ""),
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
.filter((preview): preview is ActorInspectorPreview => Boolean(preview)),
|
|
125
|
+
);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readInboxPreviews(run: string, stateDir: string): ActorInspectorPreview[] {
|
|
133
|
+
return readJsonLines(path.join(stateDir, "inbox.jsonl"))
|
|
134
|
+
.map((message) =>
|
|
135
|
+
previewFromMessage(
|
|
136
|
+
run,
|
|
137
|
+
message,
|
|
138
|
+
String(message.received_at ?? message.timestamp ?? ""),
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
.filter((preview): preview is ActorInspectorPreview => Boolean(preview));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function readOutboxPreviews(run: string, stateDir: string): ActorInspectorPreview[] {
|
|
145
|
+
return readJsonLines(path.join(stateDir, "outbox.jsonl"))
|
|
146
|
+
.map((event) => {
|
|
147
|
+
const message = asRecord(event.message ?? event);
|
|
148
|
+
return previewFromMessage(
|
|
149
|
+
run,
|
|
150
|
+
message,
|
|
151
|
+
String(event.timestamp ?? event.created_at ?? event.emitted_at ?? ""),
|
|
152
|
+
);
|
|
153
|
+
})
|
|
154
|
+
.filter((preview): preview is ActorInspectorPreview => Boolean(preview));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function getRunOwnerId(stateDir: string): string | undefined {
|
|
158
|
+
try {
|
|
159
|
+
const meta = JSON.parse(
|
|
160
|
+
fs.readFileSync(path.join(stateDir, "run.json"), "utf8"),
|
|
161
|
+
) as Record<string, unknown>;
|
|
162
|
+
return typeof meta.ownerId === "string" ? meta.ownerId : undefined;
|
|
163
|
+
} catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function matchesOwner(stateDir: string, ownerId: string | undefined): boolean {
|
|
169
|
+
return ownerId === undefined || getRunOwnerId(stateDir) === ownerId;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function readActorInspectorPreviews(
|
|
173
|
+
stateRoot = Paths.getRunStateRoot(),
|
|
174
|
+
limit = 8,
|
|
175
|
+
options: ActorInspectorPreviewReadOptions = {},
|
|
176
|
+
): ActorInspectorPreview[] {
|
|
177
|
+
try {
|
|
178
|
+
const previews = fs
|
|
179
|
+
.readdirSync(stateRoot, { withFileTypes: true })
|
|
180
|
+
.filter((entry) => entry.isDirectory())
|
|
181
|
+
.flatMap((entry) => {
|
|
182
|
+
const stateDir = path.join(stateRoot, entry.name);
|
|
183
|
+
if (!matchesOwner(stateDir, options.ownerId)) return [];
|
|
184
|
+
return [
|
|
185
|
+
...readRoomPreviews(entry.name, stateDir),
|
|
186
|
+
...readInboxPreviews(entry.name, stateDir),
|
|
187
|
+
...readOutboxPreviews(entry.name, stateDir),
|
|
188
|
+
];
|
|
189
|
+
})
|
|
190
|
+
.filter((preview) => preview.timestamp)
|
|
191
|
+
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
192
|
+
const currentRun = options.currentRunOnly ? previews.at(-1)?.run : undefined;
|
|
193
|
+
return previews
|
|
194
|
+
.filter((preview) => !currentRun || preview.run === currentRun)
|
|
195
|
+
.map((preview, index) => ({
|
|
196
|
+
...preview,
|
|
197
|
+
sequence: index + 1,
|
|
198
|
+
stripe: index % 2 === 0,
|
|
199
|
+
}))
|
|
200
|
+
.slice(-Math.max(1, limit));
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function shorten(
|
|
208
|
+
value: string | undefined,
|
|
209
|
+
maxLength: number,
|
|
210
|
+
options: { preserveSpaces?: boolean } = { preserveSpaces: true },
|
|
211
|
+
): string {
|
|
212
|
+
if (!value) return "-";
|
|
213
|
+
const compact = options.preserveSpaces === false
|
|
214
|
+
? value.replaceAll(/\s+/g, "_")
|
|
215
|
+
: value.replaceAll(/\s+/g, " ").trim();
|
|
216
|
+
if (maxLength <= 1) return compact.slice(0, Math.max(0, maxLength));
|
|
217
|
+
return compact.length > maxLength
|
|
218
|
+
? `${compact.slice(0, Math.max(0, maxLength - 1))}…`
|
|
219
|
+
: compact;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function actorName(address: string | undefined): string {
|
|
223
|
+
if (!address) return "unknown";
|
|
224
|
+
const branch = /^branch:[^/]+\/(.+)$/.exec(address);
|
|
225
|
+
if (branch) return branch[1] || address;
|
|
226
|
+
const run = /^run:(.+)$/.exec(address);
|
|
227
|
+
if (run) return run[1] || address;
|
|
228
|
+
return address;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function roomName(address: string): string | undefined {
|
|
232
|
+
const room = /^room:([^/]+)(?:\/(main))?$/.exec(address);
|
|
233
|
+
return room ? room[1] : undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function routeText(preview: ActorInspectorPreview): string {
|
|
237
|
+
const actor = actorName(preview.from);
|
|
238
|
+
if (preview.channel === "room") return `${actor} # all`;
|
|
239
|
+
if (preview.channel === "broadcast") return `${actor} ⇢ ${preview.to}`;
|
|
240
|
+
return `${actor} → ${actorName(preview.to)}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function style(styleFn: ((text: string) => string) | undefined, text: string): string {
|
|
244
|
+
return styleFn ? styleFn(text) : text;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function previewText(preview: ActorInspectorPreview): string {
|
|
248
|
+
return preview.summary || preview.body_preview || "-";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function detailText(preview: ActorInspectorPreview): string {
|
|
252
|
+
return preview.body_preview || preview.summary || "-";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function stripAnsi(value: string): string {
|
|
256
|
+
return value.replaceAll(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function charDisplayWidth(char: string): number {
|
|
260
|
+
const code = char.codePointAt(0) ?? 0;
|
|
261
|
+
if (code === 0) return 0;
|
|
262
|
+
if (code < 32 || (code >= 0x7f && code < 0xa0)) return 0;
|
|
263
|
+
if (
|
|
264
|
+
code >= 0x1100 &&
|
|
265
|
+
(code <= 0x115f ||
|
|
266
|
+
code === 0x2329 ||
|
|
267
|
+
code === 0x232a ||
|
|
268
|
+
(code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
|
|
269
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
270
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
271
|
+
(code >= 0xfe10 && code <= 0xfe19) ||
|
|
272
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
273
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
274
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
275
|
+
(code >= 0x1f300 && code <= 0x1faff))
|
|
276
|
+
) return 2;
|
|
277
|
+
return 1;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function displayWidth(value: string): number {
|
|
281
|
+
return Array.from(stripAnsi(value)).reduce((sum, char) => sum + charDisplayWidth(char), 0);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function boundedLine(value: string, width: number): string {
|
|
285
|
+
if (displayWidth(value) <= width) return value;
|
|
286
|
+
if (width <= 1) return "";
|
|
287
|
+
let output = "";
|
|
288
|
+
let used = 0;
|
|
289
|
+
for (const char of Array.from(value)) {
|
|
290
|
+
const charWidth = charDisplayWidth(char);
|
|
291
|
+
if (used + charWidth > width - 2) break;
|
|
292
|
+
output += char;
|
|
293
|
+
used += charWidth;
|
|
294
|
+
}
|
|
295
|
+
return `${output}… `;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function padLine(plain: string, rendered: string, width: number, styles: ActorInspectorWidgetStyle): string {
|
|
299
|
+
const boundedPlain = boundedLine(plain, width);
|
|
300
|
+
const visible = boundedPlain === plain ? rendered : style(styles.preview, boundedPlain);
|
|
301
|
+
const padding = Math.max(0, width - displayWidth(boundedPlain));
|
|
302
|
+
return `${visible}${" ".repeat(padding)}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function renderCompactInspectorEntry(
|
|
306
|
+
preview: ActorInspectorPreview,
|
|
307
|
+
width: number,
|
|
308
|
+
sequenceWidth: number,
|
|
309
|
+
routeWidth: number,
|
|
310
|
+
typeWidth: number,
|
|
311
|
+
styles: ActorInspectorWidgetStyle,
|
|
312
|
+
stripe: boolean,
|
|
313
|
+
): string[] {
|
|
314
|
+
const separator = " ";
|
|
315
|
+
const prefix = " ";
|
|
316
|
+
const contentWidth = Math.max(8, width - prefix.length);
|
|
317
|
+
const sequence = String(preview.sequence ?? 0).padStart(sequenceWidth, " ");
|
|
318
|
+
const sequencePrefix = `${sequence} `;
|
|
319
|
+
const route = routeText(preview);
|
|
320
|
+
const routePadding = " ".repeat(Math.max(0, routeWidth - route.length));
|
|
321
|
+
const typePadding = " ".repeat(Math.max(0, typeWidth - preview.type.length));
|
|
322
|
+
const headline = previewText(preview);
|
|
323
|
+
const lead = `${sequencePrefix}${route}${routePadding}${separator}${preview.type}${typePadding}${separator}`;
|
|
324
|
+
const visibleHeadline = boundedLine(headline, Math.max(0, contentWidth - lead.length));
|
|
325
|
+
const plain = `${lead}${visibleHeadline}`;
|
|
326
|
+
const rendered = [
|
|
327
|
+
style(styles.muted, sequencePrefix),
|
|
328
|
+
style(styles.target, route),
|
|
329
|
+
routePadding,
|
|
330
|
+
separator,
|
|
331
|
+
style(styles.type, preview.type),
|
|
332
|
+
typePadding,
|
|
333
|
+
separator,
|
|
334
|
+
style(styles.preview, visibleHeadline),
|
|
335
|
+
].join("");
|
|
336
|
+
const line = `${prefix}${padLine(plain, rendered, contentWidth, styles)}`;
|
|
337
|
+
if (stripe && styles.stripe) return [styles.stripe(line)];
|
|
338
|
+
if (!stripe && styles.stripeAlt) return [styles.stripeAlt(line)];
|
|
339
|
+
return [line];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function renderVerboseInspectorEntry(
|
|
343
|
+
preview: ActorInspectorPreview,
|
|
344
|
+
width: number,
|
|
345
|
+
sequenceWidth: number,
|
|
346
|
+
labelWidth: number,
|
|
347
|
+
styles: ActorInspectorWidgetStyle,
|
|
348
|
+
stripe: boolean,
|
|
349
|
+
): string[] {
|
|
350
|
+
const separator = " ";
|
|
351
|
+
const prefix = " ";
|
|
352
|
+
const contentWidth = Math.max(8, width - prefix.length);
|
|
353
|
+
const sequence = String(preview.sequence ?? 0).padStart(sequenceWidth, " ");
|
|
354
|
+
const sequencePrefix = `${sequence} `;
|
|
355
|
+
const detailSequencePrefix = `${" ".repeat(sequenceWidth)} `;
|
|
356
|
+
const route = routeText(preview);
|
|
357
|
+
const routePadding = " ".repeat(Math.max(0, labelWidth - route.length));
|
|
358
|
+
const typePadding = " ".repeat(Math.max(0, labelWidth - preview.type.length));
|
|
359
|
+
const headline = previewText(preview);
|
|
360
|
+
const detail = detailText(preview);
|
|
361
|
+
const headerLead = `${sequencePrefix}${route}${routePadding}${separator}`;
|
|
362
|
+
const detailLead = `${detailSequencePrefix}${preview.type}${typePadding}${separator}`;
|
|
363
|
+
const visibleHeadline = boundedLine(headline, Math.max(0, contentWidth - headerLead.length));
|
|
364
|
+
const visibleDetail = boundedLine(detail, Math.max(0, contentWidth - detailLead.length));
|
|
365
|
+
const headerPlain = `${headerLead}${visibleHeadline}`;
|
|
366
|
+
const detailPlain = `${detailLead}${visibleDetail}`;
|
|
367
|
+
const header = [
|
|
368
|
+
style(styles.muted, sequencePrefix),
|
|
369
|
+
style(styles.target, route),
|
|
370
|
+
routePadding,
|
|
371
|
+
separator,
|
|
372
|
+
style(styles.preview, visibleHeadline),
|
|
373
|
+
].join("");
|
|
374
|
+
const detailLine = [
|
|
375
|
+
style(styles.muted, detailSequencePrefix),
|
|
376
|
+
style(styles.type, preview.type),
|
|
377
|
+
typePadding,
|
|
378
|
+
separator,
|
|
379
|
+
style(styles.preview, visibleDetail),
|
|
380
|
+
].join("");
|
|
381
|
+
const lines = [
|
|
382
|
+
`${prefix}${padLine(headerPlain, header, contentWidth, styles)}`,
|
|
383
|
+
`${prefix}${padLine(detailPlain, detailLine, contentWidth, styles)}`,
|
|
384
|
+
];
|
|
385
|
+
if (stripe && styles.stripe) return lines.map((line) => styles.stripe?.(line) ?? line);
|
|
386
|
+
if (!stripe && styles.stripeAlt) return lines.map((line) => styles.stripeAlt?.(line) ?? line);
|
|
387
|
+
return lines;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function renderInspectorWidget(
|
|
391
|
+
previews: ActorInspectorPreview[],
|
|
392
|
+
width = 80,
|
|
393
|
+
styles: ActorInspectorWidgetStyle = {},
|
|
394
|
+
options: ActorInspectorRenderOptions = {},
|
|
395
|
+
): string[] | undefined {
|
|
396
|
+
if (previews.length === 0) return undefined;
|
|
397
|
+
const safeWidth = Math.max(1, width);
|
|
398
|
+
const verbosity = options.verbosity ?? "verbose";
|
|
399
|
+
const visibleLimit = verbosity === "compact" ? 12 : 6;
|
|
400
|
+
const visible = previews
|
|
401
|
+
.map((preview, index) => ({
|
|
402
|
+
preview: { ...preview, sequence: preview.sequence ?? index + 1 },
|
|
403
|
+
stripe: preview.stripe ?? index % 2 === 0,
|
|
404
|
+
}))
|
|
405
|
+
.slice(-visibleLimit);
|
|
406
|
+
const sequenceWidth = Math.max(
|
|
407
|
+
1,
|
|
408
|
+
...visible.map(({ preview }) => String(preview.sequence ?? 0).length),
|
|
409
|
+
);
|
|
410
|
+
const lines: string[] = [];
|
|
411
|
+
if (verbosity === "compact") {
|
|
412
|
+
const routeWidth = Math.max(...visible.map(({ preview }) => routeText(preview).length));
|
|
413
|
+
const typeWidth = Math.max(...visible.map(({ preview }) => preview.type.length));
|
|
414
|
+
for (const { preview, stripe } of visible) {
|
|
415
|
+
lines.push(...renderCompactInspectorEntry(preview, safeWidth, sequenceWidth, routeWidth, typeWidth, styles, stripe));
|
|
416
|
+
}
|
|
417
|
+
return lines;
|
|
418
|
+
}
|
|
419
|
+
const labelWidth = Math.max(
|
|
420
|
+
...visible.flatMap(({ preview }) => [routeText(preview).length, preview.type.length]),
|
|
421
|
+
);
|
|
422
|
+
for (const { preview, stripe } of visible) {
|
|
423
|
+
lines.push(...renderVerboseInspectorEntry(preview, safeWidth, sequenceWidth, labelWidth, styles, stripe));
|
|
424
|
+
}
|
|
425
|
+
return lines;
|
|
426
|
+
}
|
package/lib/actor-messages.ts
CHANGED
|
@@ -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
|
|