@llblab/pi-actors 0.40.1 → 0.41.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/lib/inspector.ts CHANGED
@@ -12,6 +12,7 @@ import { visibleWidth } from "@earendil-works/pi-tui";
12
12
  import type { ActorMessage } from "./messages.ts";
13
13
  import * as Limits from "./limits.ts";
14
14
  import * as Paths from "./paths.ts";
15
+ import * as SessionEvidence from "./session-evidence.ts";
15
16
  import {
16
17
  readJsonFileResilient,
17
18
  readJsonlFileResilient,
@@ -63,6 +64,7 @@ export interface ActorInspectorPreviewReadOptions {
63
64
  ownerId?: string;
64
65
  branch?: string;
65
66
  currentRunOnly?: boolean;
67
+ run?: string;
66
68
  channels?: ActorInspectorPreview["channel"][];
67
69
  mention?: string;
68
70
  readKeys?: Iterable<string>;
@@ -70,221 +72,144 @@ export interface ActorInspectorPreviewReadOptions {
70
72
  unreadOnly?: boolean;
71
73
  }
72
74
 
73
- export interface ActorInspectorControllerState {
74
- branch?: string;
75
- channels?: ActorInspectorPreview["channel"][];
76
- mention?: string;
77
- readKeys: Set<string>;
78
- roomLimitPerRun: number;
79
- rows: number;
80
- selectedSequence?: number;
81
- unreadOnly: boolean;
82
- visible: boolean;
83
- }
84
-
85
- export interface ActorInspectorCommandResult {
86
- notify: string;
87
- type: "info" | "warning";
88
- update: boolean;
89
- }
90
-
91
- export const DEFAULT_ACTOR_INSPECTOR_ROWS = 12;
92
-
93
- export const ACTOR_INSPECTOR_COMMAND_DESCRIPTIONS = {
94
- filter:
95
- "Filter actor inspector rows: all, room, direct, broadcast, unread, branch <name>, mention <text>",
96
- inspect: "Inspect actor message by visible number",
97
- toggle: "Toggle actor inspector widget; optional row count",
98
- };
99
-
100
- export function createActorInspectorControllerState(): ActorInspectorControllerState {
101
- return {
102
- readKeys: new Set<string>(),
103
- roomLimitPerRun: DEFAULT_ACTOR_INSPECTOR_ROWS,
104
- rows: DEFAULT_ACTOR_INSPECTOR_ROWS,
105
- unreadOnly: false,
106
- visible: false,
107
- };
75
+ export interface ActorInspectorRunItem {
76
+ run: string;
77
+ status: string;
78
+ updatedAt?: string;
108
79
  }
109
80
 
110
- export function getActorInspectorPreviewOptions(
111
- state: ActorInspectorControllerState,
81
+ export function readActorInspectorRuns(
82
+ stateRoot: string,
112
83
  ownerId: string,
113
- ): ActorInspectorPreviewReadOptions {
114
- return {
115
- channels: state.channels,
116
- currentRunOnly: true,
117
- branch: state.branch,
118
- mention: state.mention,
119
- ownerId,
120
- readKeys: state.readKeys,
121
- roomLimitPerRun: state.roomLimitPerRun,
122
- unreadOnly: state.unreadOnly,
123
- };
124
- }
125
-
126
- export function handleActorInspectorToggle(
127
- state: ActorInspectorControllerState,
128
- args: unknown,
129
- ): ActorInspectorCommandResult {
130
- const raw = Array.isArray(args) ? args[0] : String(args ?? "");
131
- if (String(raw).trim()) {
132
- const rows = Number.parseInt(String(raw), 10);
133
- if (!Number.isFinite(rows) || rows <= 0) {
134
- return {
135
- notify: "Usage: /actors-inspector-toggle [rows] where rows > 0",
136
- type: "warning",
137
- update: false,
138
- };
139
- }
140
- state.rows = rows;
141
- state.roomLimitPerRun = rows;
142
- state.selectedSequence = undefined;
143
- state.visible = true;
144
- return {
145
- notify: `Actor inspector rows ${rows}`,
146
- type: "info",
147
- update: true,
148
- };
149
- }
150
- if (state.selectedSequence !== undefined) {
151
- state.selectedSequence = undefined;
152
- state.visible = true;
153
- return { notify: "Actor inspector table", type: "info", update: true };
154
- }
155
- if (state.visible) state.visible = false;
156
- else {
157
- state.rows = DEFAULT_ACTOR_INSPECTOR_ROWS;
158
- state.roomLimitPerRun = DEFAULT_ACTOR_INSPECTOR_ROWS;
159
- state.visible = true;
160
- }
161
- return {
162
- notify: `Actor inspector ${state.visible ? "shown" : "hidden"}`,
163
- type: "info",
164
- update: true,
165
- };
166
- }
167
-
168
- export function handleActorInspectorFilter(
169
- state: ActorInspectorControllerState,
170
- args: unknown,
171
- ): ActorInspectorCommandResult {
172
- const parts = Array.isArray(args)
173
- ? args.map(String)
174
- : String(args ?? "").split(/\s+/);
175
- const mode = (parts[0] ?? "").trim().toLowerCase();
176
- if (!mode || mode === "all" || mode === "clear") {
177
- state.channels = undefined;
178
- state.mention = undefined;
179
- state.branch = undefined;
180
- state.unreadOnly = false;
181
- } else if (mode === "room" || mode === "direct" || mode === "broadcast") {
182
- state.channels = [mode];
183
- state.mention = undefined;
184
- } else if (mode === "unread") {
185
- state.unreadOnly = true;
186
- } else if (mode === "branch" || mode === "current-branch") {
187
- const branch = parts.slice(1).join(" ").trim();
188
- if (!branch) {
189
- return {
190
- notify: `Usage: /actors-inspector-filter ${mode} <branch-name>`,
191
- type: "warning",
192
- update: false,
193
- };
194
- }
195
- state.branch = branch;
196
- } else if (mode === "mention") {
197
- const mention = parts.slice(1).join(" ").trim();
198
- if (!mention) {
199
- return {
200
- notify: "Usage: /actors-inspector-filter mention <text>",
201
- type: "warning",
202
- update: false,
203
- };
204
- }
205
- state.channels = undefined;
206
- state.mention = mention;
207
- } else {
208
- return {
209
- notify:
210
- "Usage: /actors-inspector-filter all|room|direct|broadcast|unread|branch <name>|mention <text>",
211
- type: "warning",
212
- update: false,
213
- };
84
+ ): ActorInspectorRunItem[] {
85
+ try {
86
+ return fs
87
+ .readdirSync(stateRoot, { withFileTypes: true })
88
+ .filter((entry) => entry.isDirectory())
89
+ .flatMap((entry) => {
90
+ const stateDir = path.join(stateRoot, entry.name);
91
+ if (!matchesOwner(stateDir, ownerId)) return [];
92
+ const progress = readJsonFileResilient<Record<string, unknown>>(
93
+ path.join(stateDir, "progress.json"),
94
+ {},
95
+ ).value;
96
+ const result = readJsonFileResilient<Record<string, unknown>>(
97
+ path.join(stateDir, "result.json"),
98
+ {},
99
+ ).value;
100
+ return [{
101
+ run: entry.name,
102
+ status: String(progress.phase ?? (Object.keys(result).length ? "terminal" : "unknown")),
103
+ ...(typeof progress.updatedAt === "string"
104
+ ? { updatedAt: progress.updatedAt }
105
+ : typeof result.completedAt === "string"
106
+ ? { updatedAt: result.completedAt }
107
+ : {}),
108
+ }];
109
+ })
110
+ .sort((left, right) =>
111
+ String(left.updatedAt ?? "").localeCompare(String(right.updatedAt ?? "")),
112
+ );
113
+ } catch (error) {
114
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
115
+ return [];
214
116
  }
215
- state.selectedSequence = undefined;
216
- state.visible = true;
217
- return {
218
- notify: `Actor inspector filter ${mode || "all"}`,
219
- type: "info",
220
- update: true,
221
- };
222
117
  }
223
118
 
224
- export function handleActorInspectorInspect(
225
- state: ActorInspectorControllerState,
226
- args: unknown,
227
- previews: ActorInspectorPreview[],
228
- ): ActorInspectorCommandResult {
229
- const raw = Array.isArray(args) ? args[0] : String(args ?? "");
230
- return selectActorInspectorSequence(
231
- state,
232
- Number.parseInt(String(raw), 10),
233
- previews,
234
- );
119
+ export interface ActorInspectorTurnItem extends SessionEvidence.SessionEvidenceTurn {
120
+ commandId: string;
121
+ diagnostics: string[];
122
+ promptBytes?: number;
123
+ promptFile?: string;
124
+ recipeContext?: unknown;
125
+ sessionFile: string;
126
+ sessionTruncated: boolean;
127
+ stage?: string;
235
128
  }
236
129
 
237
- export function selectActorInspectorSequence(
238
- state: ActorInspectorControllerState,
239
- sequence: number,
240
- previews: ActorInspectorPreview[],
241
- ): ActorInspectorCommandResult {
242
- if (!Number.isFinite(sequence) || sequence <= 0) {
243
- return {
244
- notify: "Usage: /actors-inspect <number>",
245
- type: "warning",
246
- update: false,
247
- };
130
+ function ownedSessionPath(
131
+ stateDir: string,
132
+ sessionFile: string,
133
+ ): string | undefined {
134
+ if (path.isAbsolute(sessionFile)) return undefined;
135
+ const sessionsRoot = path.resolve(stateDir, "sessions");
136
+ const resolved = path.resolve(stateDir, sessionFile);
137
+ if (!resolved.startsWith(`${sessionsRoot}${path.sep}`)) return undefined;
138
+ try {
139
+ const canonicalRoot = fs.realpathSync(sessionsRoot);
140
+ const canonicalFile = fs.realpathSync(resolved);
141
+ return canonicalFile.startsWith(`${canonicalRoot}${path.sep}`)
142
+ ? canonicalFile
143
+ : undefined;
144
+ } catch {
145
+ return undefined;
248
146
  }
249
- const preview = previews.find((item) => item.sequence === sequence);
250
- if (preview) state.readKeys.add(inspectorPreviewReadKey(preview));
251
- state.selectedSequence = sequence;
252
- state.visible = true;
253
- return {
254
- notify: `Actor inspect item ${sequence}`,
255
- type: "info",
256
- update: true,
257
- };
258
147
  }
259
148
 
260
- export function renderActorInspectorPanel(input: {
261
- stateRoot: string;
262
- state: ActorInspectorControllerState;
263
- ownerId: string;
264
- width: number;
265
- style: ActorInspectorWidgetStyle;
266
- }): string[] {
267
- const previews = readActorInspectorPreviews(
268
- input.stateRoot,
269
- input.state.rows,
270
- getActorInspectorPreviewOptions(input.state, input.ownerId),
149
+ export function readActorInspectorTurns(stateDir: string): ActorInspectorTurnItem[] {
150
+ const evidence = readJsonFileResilient<Record<string, unknown>>(
151
+ path.join(stateDir, "review-evidence.json"),
152
+ {},
153
+ ).value;
154
+ const commands = Array.isArray(evidence.commands)
155
+ ? (evidence.commands as Array<Record<string, unknown>>)
156
+ : [];
157
+ const recordedFiles = new Set(
158
+ commands.flatMap((command) =>
159
+ (Array.isArray(command.session_files) ? command.session_files : []).filter(
160
+ (file): file is string => typeof file === "string",
161
+ ),
162
+ ),
271
163
  );
272
- const rows =
273
- (input.state.selectedSequence !== undefined
274
- ? renderInspectorItemView(previews, input.width, input.style, {
275
- sequence: input.state.selectedSequence,
276
- })
277
- : renderInspectorWidget(previews, input.width, input.style)) ?? [];
278
- const run = previews[0]?.run;
279
- const roster =
280
- input.state.selectedSequence === undefined && run
281
- ? renderInspectorRosterPanel(
282
- readActorInspectorRoster(input.stateRoot, run),
283
- input.width,
284
- input.style,
285
- )
286
- : undefined;
287
- return roster ? [...roster, ...rows] : rows;
164
+ let discoveredCommands: Array<Record<string, unknown>> = [];
165
+ try {
166
+ const sessionsDir = path.join(stateDir, "sessions");
167
+ discoveredCommands = fs
168
+ .readdirSync(sessionsDir, { withFileTypes: true })
169
+ .filter((entry) => entry.isDirectory())
170
+ .flatMap((entry) => {
171
+ const files = fs
172
+ .readdirSync(path.join(sessionsDir, entry.name), {
173
+ withFileTypes: true,
174
+ })
175
+ .filter((file) => file.isFile() && file.name.endsWith(".jsonl"))
176
+ .map((file) => path.join("sessions", entry.name, file.name))
177
+ .filter((file) => !recordedFiles.has(file));
178
+ return files.length > 0
179
+ ? [{ id: entry.name, session_files: files, stage: "subagent" }]
180
+ : [];
181
+ });
182
+ } catch {
183
+ discoveredCommands = [];
184
+ }
185
+ return [...commands, ...discoveredCommands].flatMap((command) =>
186
+ (Array.isArray(command.session_files) ? command.session_files : [])
187
+ .filter((file): file is string => typeof file === "string")
188
+ .flatMap((file) => {
189
+ const sessionPath = ownedSessionPath(stateDir, file);
190
+ if (!sessionPath) return [];
191
+ const session = SessionEvidence.readSessionEvidence(sessionPath);
192
+ return session.turns.map((turn) => ({
193
+ ...turn,
194
+ commandId: String(command.id ?? "unknown"),
195
+ diagnostics: session.diagnostics.map((item) =>
196
+ `${item.line === undefined ? "" : `line ${item.line}: `}${item.message}`,
197
+ ),
198
+ ...(typeof command.prompt_bytes === "number"
199
+ ? { promptBytes: command.prompt_bytes }
200
+ : {}),
201
+ ...(typeof command.prompt_file === "string"
202
+ ? { promptFile: command.prompt_file }
203
+ : {}),
204
+ ...(command.recipe_context !== undefined
205
+ ? { recipeContext: command.recipe_context }
206
+ : {}),
207
+ sessionFile: file,
208
+ sessionTruncated: session.truncated,
209
+ ...(typeof command.stage === "string" ? { stage: command.stage } : {}),
210
+ }));
211
+ }),
212
+ ).map((turn, index) => ({ ...turn, index: index + 1 }));
288
213
  }
289
214
 
290
215
  function asRecord(value: unknown): Record<string, unknown> {
@@ -302,7 +227,9 @@ function previewValue(
302
227
  maxLength = Limits.INSPECTOR_BODY_PREVIEW_CHARS,
303
228
  ): string | undefined {
304
229
  if (value === undefined) return undefined;
305
- const text = typeof value === "string" ? value : JSON.stringify(value);
230
+ const redacted = SessionEvidence.redactSessionEvidenceValue(value);
231
+ const text =
232
+ typeof redacted === "string" ? redacted : JSON.stringify(redacted);
306
233
  const compact = text.replaceAll(/\s+/g, " ").trim();
307
234
  if (!compact) return undefined;
308
235
  return compact.length > maxLength
@@ -606,6 +533,7 @@ export function readActorInspectorPreviews(
606
533
  .flatMap((entry) => {
607
534
  const stateDir = path.join(stateRoot, entry.name);
608
535
  if (!matchesOwner(stateDir, options.ownerId)) return [];
536
+ if (options.run && entry.name !== options.run) return [];
609
537
  return [
610
538
  ...readRoomPreviews(entry.name, stateDir),
611
539
  ...readInboxPreviews(entry.name, stateDir),
package/lib/limits.ts CHANGED
@@ -10,3 +10,6 @@ export const COMPACT_PREVIEW_CHARS = 160;
10
10
  export const INSPECTOR_BODY_PREVIEW_CHARS = 320;
11
11
  export const ROOM_MESSAGE_PREVIEW_CHARS = 320;
12
12
  export const DOCTOR_ACTION_PREVIEW_CHARS = 72;
13
+ export const SESSION_EVIDENCE_MAX_TURNS = 100;
14
+ export const SESSION_EVIDENCE_TEXT_CHARS = 4_000;
15
+ export const SESSION_EVIDENCE_MAX_TOOL_CALLS = 100;
@@ -167,6 +167,31 @@ export function appendRecipeContextToPiArgs(
167
167
  return next;
168
168
  }
169
169
 
170
+ export interface ManagedPiSessionArgs {
171
+ args: string[];
172
+ sessionDir?: string;
173
+ }
174
+
175
+ const PI_SESSION_POLICY_FLAGS = new Set([
176
+ "--fork",
177
+ "--no-session",
178
+ "--session",
179
+ "--session-dir",
180
+ "--session-id",
181
+ ]);
182
+
183
+ export function attachPiSessionDir(
184
+ command: string,
185
+ args: string[],
186
+ sessionDir: string,
187
+ ): ManagedPiSessionArgs {
188
+ if (!isPiCommand(command) || findPiPrintPromptIndex(args) === undefined) {
189
+ return { args };
190
+ }
191
+ if (args.some((arg) => PI_SESSION_POLICY_FLAGS.has(arg))) return { args };
192
+ return { args: ["--session-dir", sessionDir, ...args], sessionDir };
193
+ }
194
+
170
195
  export interface MaterializedPiPrintPromptArgs {
171
196
  args: string[];
172
197
  promptBytes?: number;