@llblab/pi-actors 0.40.1 → 0.41.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.
@@ -101,15 +101,19 @@ export function createRunUiObservationState(): RunUiObservationState {
101
101
  export function readRunUiSnapshot(
102
102
  state: RunUiObservationState,
103
103
  ownerId: string,
104
+ options: { includeOutbox?: boolean; stateRoot?: string } = {},
104
105
  ): RunUiSnapshot {
105
- const summary = summarizeRuns(undefined, ownerId);
106
+ const summary = summarizeRuns(options.stateRoot, ownerId);
106
107
  const status = renderRunStatus(summary, state.frame++);
107
108
  return {
108
- outboxEvents: detectRunOutboxEvents(
109
- state.eventLines,
110
- summary,
111
- state.outboxEventIds,
112
- ),
109
+ outboxEvents:
110
+ options.includeOutbox === false
111
+ ? []
112
+ : detectRunOutboxEvents(
113
+ state.eventLines,
114
+ summary,
115
+ state.outboxEventIds,
116
+ ),
113
117
  status,
114
118
  summary,
115
119
  transitions: detectRunTransitions(state.observed, summary),
@@ -134,27 +138,55 @@ export function pruneRunUiObservationState(
134
138
  export function deliverRunTransitionNotifications(
135
139
  transitions: RunTransition[],
136
140
  sink: RunUiNotificationSink,
141
+ inFlight: Set<string> = new Set(),
137
142
  ): void {
138
143
  for (const transition of transitions) {
139
144
  if (!shouldNotifyRunTransition(transition)) continue;
140
- const text = formatRunTransitionMessage(transition);
141
- sink.notify(text, getRunTransitionNotificationType(transition));
142
- if (!shouldSendRunTransitionFollowUp(transition)) continue;
143
- sink.sendFollowUp({
144
- customType: "pi-actors-run",
145
- content: text,
146
- display: true,
147
- details: transition,
148
- });
149
- if (transition.stateDir) {
150
- AsyncRuns.markRunTerminalNotificationHandled(
151
- transition.stateDir,
152
- transition.to,
153
- );
145
+ const key = transition.stateDir ?? transition.run;
146
+ if (inFlight.has(key)) continue;
147
+ inFlight.add(key);
148
+ try {
149
+ const text = formatRunTransitionMessage(transition);
150
+ sink.notify(text, getRunTransitionNotificationType(transition));
151
+ if (!shouldSendRunTransitionFollowUp(transition)) continue;
152
+ sink.sendFollowUp({
153
+ customType: "pi-actors-run",
154
+ content: text,
155
+ display: true,
156
+ details: transition,
157
+ });
158
+ if (transition.stateDir) {
159
+ AsyncRuns.markRunTerminalNotificationHandled(
160
+ transition.stateDir,
161
+ transition.to,
162
+ );
163
+ }
164
+ } finally {
165
+ inFlight.delete(key);
154
166
  }
155
167
  }
156
168
  }
157
169
 
170
+ export function reconcileRunTerminalNotifications(input: {
171
+ inFlight?: Set<string>;
172
+ ownerId: string;
173
+ sink: RunUiNotificationSink;
174
+ state: RunUiObservationState;
175
+ stateRoot?: string;
176
+ }): RunUiSnapshot {
177
+ const snapshot = readRunUiSnapshot(input.state, input.ownerId, {
178
+ includeOutbox: false,
179
+ stateRoot: input.stateRoot,
180
+ });
181
+ deliverRunTransitionNotifications(
182
+ snapshot.transitions,
183
+ input.sink,
184
+ input.inFlight,
185
+ );
186
+ pruneRunUiObservationState(input.state, snapshot);
187
+ return snapshot;
188
+ }
189
+
158
190
  export function deliverRunOutboxNotifications(
159
191
  events: RunOutboxEvent[],
160
192
  sink: RunUiNotificationSink,
@@ -189,18 +221,94 @@ export interface RunRetirementExecution {
189
221
  stateDir: string;
190
222
  }
191
223
 
224
+ export type RunStateWatcherDiagnosticCode =
225
+ | "attach_failed"
226
+ | "error"
227
+ | "removed"
228
+ | "rearmed";
229
+
230
+ export interface RunStateWatcherDiagnostic {
231
+ code: RunStateWatcherDiagnosticCode;
232
+ id: number;
233
+ message: string;
234
+ path: string;
235
+ scope: "root" | "run";
236
+ ts: string;
237
+ }
238
+
192
239
  export interface RunStateWatcher {
193
240
  close(): void;
241
+ getDiagnostics(): RunStateWatcherDiagnostic[];
194
242
  refresh(): void;
195
243
  }
196
244
 
245
+ const RUN_WATCHER_DIAGNOSTIC_LIMIT = 32;
246
+
197
247
  export function createRunStateWatcher(input: {
198
- stateRoot?: string;
248
+ exists?: (path: string) => boolean;
249
+ listDirectories?: (path: string) => string[];
199
250
  onChange: () => void;
251
+ stateRoot?: string;
252
+ watchPath?: (path: string, onChange: () => void) => FSWatcher;
200
253
  }): RunStateWatcher {
201
254
  const stateRoot = input.stateRoot ?? Paths.getRunStateRoot();
255
+ const pathExists = input.exists ?? existsSync;
256
+ const listDirectories =
257
+ input.listDirectories ??
258
+ ((path: string) =>
259
+ readdirSync(path, { withFileTypes: true })
260
+ .filter((entry) => entry.isDirectory())
261
+ .map((entry) => join(path, entry.name)));
262
+ const watchPath = input.watchPath ?? ((path, onChange) => watch(path, onChange));
263
+ let diagnosticId = 0;
264
+ let rootDegraded = false;
202
265
  let stateRootWatcher: FSWatcher | undefined;
266
+ const degradedRunDirs = new Set<string>();
267
+ const diagnostics: RunStateWatcherDiagnostic[] = [];
268
+ const lastDiagnosticSignatures = new Map<string, string>();
203
269
  const runDirWatchers = new Map<string, FSWatcher>();
270
+ const record = (
271
+ code: RunStateWatcherDiagnosticCode,
272
+ scope: "root" | "run",
273
+ path: string,
274
+ error?: unknown,
275
+ ): void => {
276
+ const detail = error instanceof Error ? `: ${error.message}` : "";
277
+ const signature = `${code}${detail}`;
278
+ const signatureKey = `${scope}:${path}`;
279
+ if (lastDiagnosticSignatures.get(signatureKey) === signature) return;
280
+ lastDiagnosticSignatures.set(signatureKey, signature);
281
+ const recovery =
282
+ code === "rearmed"
283
+ ? "; terminal watch acceleration restored"
284
+ : "; terminal reconciliation remains active and watcher rearm will retry";
285
+ diagnostics.push({
286
+ code,
287
+ id: ++diagnosticId,
288
+ message: `Run-state ${scope} watcher ${code.replace("_", " ")} for ${path}${detail}${recovery}`,
289
+ path,
290
+ scope,
291
+ ts: new Date().toISOString(),
292
+ });
293
+ if (diagnostics.length > RUN_WATCHER_DIAGNOSTIC_LIMIT) diagnostics.shift();
294
+ };
295
+ const removeRunWatcher = (
296
+ stateDir: string,
297
+ watcher: FSWatcher,
298
+ options: { degraded?: boolean; error?: unknown } = {},
299
+ ): void => {
300
+ if (runDirWatchers.get(stateDir) !== watcher) return;
301
+ watcher.close();
302
+ runDirWatchers.delete(stateDir);
303
+ if (options.degraded === false) {
304
+ degradedRunDirs.delete(stateDir);
305
+ lastDiagnosticSignatures.delete(`run:${stateDir}`);
306
+ return;
307
+ }
308
+ degradedRunDirs.add(stateDir);
309
+ if (options.error) record("error", "run", stateDir, options.error);
310
+ record("removed", "run", stateDir);
311
+ };
204
312
  const close = (): void => {
205
313
  stateRootWatcher?.close();
206
314
  stateRootWatcher = undefined;
@@ -208,37 +316,95 @@ export function createRunStateWatcher(input: {
208
316
  runDirWatchers.clear();
209
317
  };
210
318
  const watchRunDir = (stateDir: string): void => {
211
- if (runDirWatchers.has(stateDir) || !existsSync(stateDir)) return;
319
+ if (runDirWatchers.has(stateDir) || !pathExists(stateDir)) return;
212
320
  try {
213
- const watcher = watch(stateDir, input.onChange);
214
- watcher.on("error", () => {
215
- watcher.close();
216
- runDirWatchers.delete(stateDir);
217
- });
321
+ const watcher = watchPath(stateDir, input.onChange);
322
+ watcher.on("error", (error) =>
323
+ removeRunWatcher(stateDir, watcher, { error }),
324
+ );
218
325
  runDirWatchers.set(stateDir, watcher);
219
- } catch {
220
- // Watching is best-effort; explicit inspect remains available.
326
+ if (degradedRunDirs.delete(stateDir)) record("rearmed", "run", stateDir);
327
+ } catch (error) {
328
+ degradedRunDirs.add(stateDir);
329
+ record("attach_failed", "run", stateDir, error);
221
330
  }
222
331
  };
223
332
  function refresh(): void {
224
- if (!existsSync(stateRoot)) return;
333
+ if (!pathExists(stateRoot)) return;
225
334
  if (!stateRootWatcher) {
226
335
  try {
227
- stateRootWatcher = watch(stateRoot, input.onChange);
228
- stateRootWatcher.on("error", () => {
229
- stateRootWatcher?.close();
336
+ const watcher = watchPath(stateRoot, input.onChange);
337
+ stateRootWatcher = watcher;
338
+ watcher.on("error", (error) => {
339
+ if (stateRootWatcher !== watcher) return;
340
+ watcher.close();
230
341
  stateRootWatcher = undefined;
342
+ rootDegraded = true;
343
+ record("error", "root", stateRoot, error);
344
+ record("removed", "root", stateRoot);
231
345
  });
232
- } catch {
233
- // Watching is best-effort; explicit inspect remains available.
346
+ if (rootDegraded) {
347
+ rootDegraded = false;
348
+ record("rearmed", "root", stateRoot);
349
+ }
350
+ } catch (error) {
351
+ rootDegraded = true;
352
+ record("attach_failed", "root", stateRoot, error);
234
353
  }
235
354
  }
236
- for (const entry of readdirSync(stateRoot, { withFileTypes: true })) {
237
- if (!entry.isDirectory()) continue;
238
- watchRunDir(`${stateRoot}/${entry.name}`);
355
+ let stateDirs: string[];
356
+ try {
357
+ stateDirs = listDirectories(stateRoot);
358
+ } catch (error) {
359
+ record("attach_failed", "root", stateRoot, error);
360
+ return;
239
361
  }
362
+ const present = new Set(stateDirs);
363
+ for (const [stateDir, watcher] of runDirWatchers) {
364
+ if (present.has(stateDir) && pathExists(stateDir)) continue;
365
+ removeRunWatcher(stateDir, watcher, { degraded: false });
366
+ }
367
+ for (const stateDir of stateDirs) watchRunDir(stateDir);
240
368
  }
241
- return { close, refresh };
369
+ return {
370
+ close,
371
+ getDiagnostics: () => [...diagnostics],
372
+ refresh,
373
+ };
374
+ }
375
+
376
+ export interface RunTerminalReconciliationLoop {
377
+ close(): void;
378
+ reconcileNow(): void;
379
+ start(): void;
380
+ }
381
+
382
+ export function createRunTerminalReconciliationLoop(input: {
383
+ intervalMs?: number;
384
+ onError?: (error: unknown) => void;
385
+ reconcile: () => void;
386
+ refreshWatcher: () => void;
387
+ }): RunTerminalReconciliationLoop {
388
+ const intervalMs = input.intervalMs ?? 10_000;
389
+ let interval: NodeJS.Timeout | undefined;
390
+ const reconcileNow = (): void => {
391
+ try {
392
+ input.refreshWatcher();
393
+ input.reconcile();
394
+ } catch (error) {
395
+ input.onError?.(error);
396
+ }
397
+ };
398
+ const close = (): void => {
399
+ if (interval) clearInterval(interval);
400
+ interval = undefined;
401
+ };
402
+ const start = (): void => {
403
+ close();
404
+ interval = setInterval(reconcileNow, intervalMs);
405
+ interval.unref?.();
406
+ };
407
+ return { close, reconcileNow, start };
242
408
  }
243
409
 
244
410
  export interface RunRetirementExecutorOptions {
@@ -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;
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Persisted Pi session evidence reader.
3
+ * Zones: subagent turns, active session branches, bounded/redacted previews
4
+ * Owns read-only normalization of session JSONL into inspector-ready turns.
5
+ */
6
+
7
+ import * as Limits from "./limits.ts";
8
+ import {
9
+ readJsonlFileResilient,
10
+ type StateReadDiagnostic,
11
+ } from "./state-readers.ts";
12
+
13
+ interface SessionEntry {
14
+ id?: unknown;
15
+ message?: unknown;
16
+ parentId?: unknown;
17
+ timestamp?: unknown;
18
+ type?: unknown;
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ export interface SessionEvidenceToolCall {
23
+ arguments?: unknown;
24
+ id: string;
25
+ name: string;
26
+ result?: unknown;
27
+ resultError?: boolean;
28
+ }
29
+
30
+ export interface SessionEvidenceTurn {
31
+ assistantEntryId?: string;
32
+ assistantText?: string;
33
+ error?: string;
34
+ index: number;
35
+ model?: string;
36
+ provider?: string;
37
+ stopReason?: string;
38
+ thinking?: string;
39
+ timestamp?: string;
40
+ toolCalls: SessionEvidenceToolCall[];
41
+ unmatchedToolResults: number;
42
+ usage?: unknown;
43
+ userEntryId?: string;
44
+ userText?: string;
45
+ }
46
+
47
+ export interface SessionEvidence {
48
+ activeLeafId?: string;
49
+ diagnostics: StateReadDiagnostic[];
50
+ path: string;
51
+ session?: Record<string, unknown>;
52
+ totalTurns: number;
53
+ truncated: boolean;
54
+ turns: SessionEvidenceTurn[];
55
+ }
56
+
57
+ export interface SessionEvidenceReadOptions {
58
+ maxTextChars?: number;
59
+ maxToolCalls?: number;
60
+ maxTurns?: number;
61
+ }
62
+
63
+ const SENSITIVE_KEY = /(?:^|[_-])(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|secret[-_]?access[-_]?key|token)$|^(?:access|refresh|auth|api)Token$|^(?:clientSecret|privateKey|secretAccessKey)$/i;
64
+ const SENSITIVE_TEXT = /(bearer\s+)[A-Za-z0-9._~+/=-]+|["']?\b(api[-_]?key|authorization|clientSecret|cookie|password|private[-_]?key|privateKey|secret|secretAccessKey|token)["']?(\s*[:=]\s*)["']?([^\s,;"'}]+)/gi;
65
+
66
+ function asRecord(value: unknown): Record<string, unknown> {
67
+ return value && typeof value === "object" && !Array.isArray(value)
68
+ ? (value as Record<string, unknown>)
69
+ : {};
70
+ }
71
+
72
+ function boundedText(value: unknown, maxChars: number): string | undefined {
73
+ if (typeof value !== "string") return undefined;
74
+ if (/^\s*[\[{]/.test(value)) {
75
+ try {
76
+ const structured = JSON.stringify(
77
+ redactSessionEvidenceValue(JSON.parse(value), maxChars),
78
+ );
79
+ return structured.length > maxChars
80
+ ? `${structured.slice(0, Math.max(0, maxChars - 1))}…`
81
+ : structured;
82
+ } catch {
83
+ // Fall through to bounded text redaction.
84
+ }
85
+ }
86
+ const redacted = value.replaceAll(
87
+ SENSITIVE_TEXT,
88
+ (match, bearer: string | undefined, key: string | undefined, separator: string | undefined) =>
89
+ bearer ? `${bearer}[REDACTED]` : `${key}${separator}[REDACTED]`,
90
+ );
91
+ return redacted.length > maxChars
92
+ ? `${redacted.slice(0, Math.max(0, maxChars - 1))}…`
93
+ : redacted;
94
+ }
95
+
96
+ export function redactSessionEvidenceValue(
97
+ value: unknown,
98
+ maxTextChars = Limits.SESSION_EVIDENCE_TEXT_CHARS,
99
+ seen = new WeakSet<object>(),
100
+ ): unknown {
101
+ if (typeof value === "string") return boundedText(value, maxTextChars);
102
+ if (!value || typeof value !== "object") return value;
103
+ if (seen.has(value)) return "[CIRCULAR]";
104
+ seen.add(value);
105
+ if (Array.isArray(value)) {
106
+ return value.map((item) => redactSessionEvidenceValue(item, maxTextChars, seen));
107
+ }
108
+ return Object.fromEntries(
109
+ Object.entries(value as Record<string, unknown>).map(([key, item]) => [
110
+ key,
111
+ SENSITIVE_KEY.test(key)
112
+ ? "[REDACTED]"
113
+ : redactSessionEvidenceValue(item, maxTextChars, seen),
114
+ ]),
115
+ );
116
+ }
117
+
118
+ function contentBlocks(message: Record<string, unknown>): Record<string, unknown>[] {
119
+ return Array.isArray(message.content)
120
+ ? message.content.map(asRecord)
121
+ : typeof message.content === "string"
122
+ ? [{ type: "text", text: message.content }]
123
+ : [];
124
+ }
125
+
126
+ function contentText(
127
+ message: Record<string, unknown>,
128
+ type: "text" | "thinking",
129
+ maxChars: number,
130
+ ): string | undefined {
131
+ const text = contentBlocks(message)
132
+ .filter((block) => block.type === type && typeof block[type] === "string")
133
+ .map((block) => String(block[type]))
134
+ .join("\n");
135
+ return boundedText(text, maxChars);
136
+ }
137
+
138
+ function activeBranch(
139
+ entries: SessionEntry[],
140
+ path: string,
141
+ diagnostics: StateReadDiagnostic[],
142
+ ): SessionEntry[] {
143
+ const treeEntries = entries.filter(
144
+ (entry) => entry.type !== "session" && typeof entry.id === "string",
145
+ );
146
+ const leaf = treeEntries.at(-1);
147
+ if (!leaf || typeof leaf.id !== "string") return [];
148
+ const byId = new Map(
149
+ treeEntries.map((entry) => [String(entry.id), entry] as const),
150
+ );
151
+ const branch: SessionEntry[] = [];
152
+ const visited = new Set<string>();
153
+ let current: SessionEntry | undefined = leaf;
154
+ while (current && typeof current.id === "string") {
155
+ if (visited.has(current.id)) {
156
+ diagnostics.push({ message: `session entry cycle at ${current.id}`, path });
157
+ break;
158
+ }
159
+ visited.add(current.id);
160
+ branch.push(current);
161
+ if (current.parentId === null || current.parentId === undefined) break;
162
+ if (typeof current.parentId !== "string" || !byId.has(current.parentId)) {
163
+ diagnostics.push({
164
+ message: `missing parent ${String(current.parentId)} for ${current.id}`,
165
+ path,
166
+ });
167
+ break;
168
+ }
169
+ current = byId.get(current.parentId);
170
+ }
171
+ return branch.reverse();
172
+ }
173
+
174
+ function toolCalls(
175
+ message: Record<string, unknown>,
176
+ maxTextChars: number,
177
+ maxToolCalls: number,
178
+ ): SessionEvidenceToolCall[] {
179
+ return contentBlocks(message)
180
+ .filter(
181
+ (block) =>
182
+ block.type === "toolCall" &&
183
+ typeof block.id === "string" &&
184
+ typeof block.name === "string",
185
+ )
186
+ .slice(0, maxToolCalls)
187
+ .map((block) => ({
188
+ arguments: redactSessionEvidenceValue(block.arguments, maxTextChars),
189
+ id: String(block.id),
190
+ name: String(block.name),
191
+ }));
192
+ }
193
+
194
+ export function readSessionEvidence(
195
+ path: string,
196
+ options: SessionEvidenceReadOptions = {},
197
+ ): SessionEvidence {
198
+ const maxTextChars = Math.max(
199
+ 1,
200
+ options.maxTextChars ?? Limits.SESSION_EVIDENCE_TEXT_CHARS,
201
+ );
202
+ const maxToolCalls = Math.max(
203
+ 1,
204
+ options.maxToolCalls ?? Limits.SESSION_EVIDENCE_MAX_TOOL_CALLS,
205
+ );
206
+ const maxTurns = Math.max(
207
+ 1,
208
+ options.maxTurns ?? Limits.SESSION_EVIDENCE_MAX_TURNS,
209
+ );
210
+ const read = readJsonlFileResilient<SessionEntry>(path);
211
+ const diagnostics = [...read.diagnostics];
212
+ const header = read.records.find((entry) => entry.type === "session");
213
+ const branch = activeBranch(read.records, path, diagnostics);
214
+ const turns: SessionEvidenceTurn[] = [];
215
+ let pendingUser: { id: string; text?: string } | undefined;
216
+ let currentTurn: SessionEvidenceTurn | undefined;
217
+ for (const entry of branch) {
218
+ if (entry.type !== "message") continue;
219
+ const message = asRecord(entry.message);
220
+ const role = message.role;
221
+ if (role === "user") {
222
+ pendingUser = {
223
+ id: String(entry.id),
224
+ text: contentText(message, "text", maxTextChars),
225
+ };
226
+ currentTurn = undefined;
227
+ continue;
228
+ }
229
+ if (role === "assistant") {
230
+ currentTurn = {
231
+ ...(typeof entry.id === "string" ? { assistantEntryId: entry.id } : {}),
232
+ ...(contentText(message, "text", maxTextChars)
233
+ ? { assistantText: contentText(message, "text", maxTextChars) }
234
+ : {}),
235
+ ...(typeof message.errorMessage === "string"
236
+ ? { error: boundedText(message.errorMessage, maxTextChars) }
237
+ : {}),
238
+ index: turns.length + 1,
239
+ ...(typeof message.model === "string" ? { model: message.model } : {}),
240
+ ...(typeof message.provider === "string"
241
+ ? { provider: message.provider }
242
+ : {}),
243
+ ...(typeof message.stopReason === "string"
244
+ ? { stopReason: message.stopReason }
245
+ : {}),
246
+ ...(contentText(message, "thinking", maxTextChars)
247
+ ? { thinking: contentText(message, "thinking", maxTextChars) }
248
+ : {}),
249
+ ...(typeof entry.timestamp === "string"
250
+ ? { timestamp: entry.timestamp }
251
+ : {}),
252
+ toolCalls: toolCalls(message, maxTextChars, maxToolCalls),
253
+ unmatchedToolResults: 0,
254
+ ...(message.usage !== undefined
255
+ ? { usage: redactSessionEvidenceValue(message.usage, maxTextChars) }
256
+ : {}),
257
+ ...(pendingUser
258
+ ? {
259
+ userEntryId: pendingUser.id,
260
+ ...(pendingUser.text ? { userText: pendingUser.text } : {}),
261
+ }
262
+ : {}),
263
+ };
264
+ pendingUser = undefined;
265
+ turns.push(currentTurn);
266
+ continue;
267
+ }
268
+ if (role === "toolResult" && currentTurn) {
269
+ const callId = typeof message.toolCallId === "string" ? message.toolCallId : "";
270
+ const call = currentTurn.toolCalls.find((item) => item.id === callId);
271
+ if (!call) {
272
+ currentTurn.unmatchedToolResults += 1;
273
+ continue;
274
+ }
275
+ call.result = redactSessionEvidenceValue(message.content, maxTextChars);
276
+ call.resultError = message.isError === true;
277
+ }
278
+ }
279
+ if (pendingUser) {
280
+ turns.push({
281
+ index: turns.length + 1,
282
+ toolCalls: [],
283
+ unmatchedToolResults: 0,
284
+ userEntryId: pendingUser.id,
285
+ ...(pendingUser.text ? { userText: pendingUser.text } : {}),
286
+ });
287
+ }
288
+ const firstVisibleIndex = Math.max(0, turns.length - maxTurns);
289
+ const visibleTurns = turns.slice(firstVisibleIndex).map((turn, index) => ({
290
+ ...turn,
291
+ index: firstVisibleIndex + index + 1,
292
+ }));
293
+ return {
294
+ ...(branch.at(-1)?.id ? { activeLeafId: String(branch.at(-1)?.id) } : {}),
295
+ diagnostics,
296
+ path,
297
+ ...(header ? { session: asRecord(header) } : {}),
298
+ totalTurns: turns.length,
299
+ truncated: turns.length > visibleTurns.length,
300
+ turns: visibleTurns,
301
+ };
302
+ }
@@ -7,6 +7,38 @@
7
7
  import * as Limits from "./limits.ts";
8
8
  import * as ToolsMailbox from "./tools-mailbox.ts";
9
9
 
10
+ export function withLeadingLineBreak(text: string): string {
11
+ return `\n${text.replace(/^\n+/, "")}`;
12
+ }
13
+
14
+ export function spaceToolResult<T>(result: T): T {
15
+ if (!result || typeof result !== "object") return result;
16
+ const content = (result as { content?: unknown }).content;
17
+ if (!Array.isArray(content)) return result;
18
+ return {
19
+ ...result,
20
+ content: content.map((item) =>
21
+ item &&
22
+ typeof item === "object" &&
23
+ (item as { type?: unknown }).type === "text" &&
24
+ typeof (item as { text?: unknown }).text === "string"
25
+ ? {
26
+ ...item,
27
+ text: withLeadingLineBreak((item as { text: string }).text),
28
+ }
29
+ : item,
30
+ ),
31
+ };
32
+ }
33
+
34
+ export function spaceToolError(error: unknown): unknown {
35
+ if (error instanceof Error) {
36
+ error.message = withLeadingLineBreak(error.message);
37
+ return error;
38
+ }
39
+ return new Error(withLeadingLineBreak(String(error)));
40
+ }
41
+
10
42
  export function asRecord(value: unknown): Record<string, unknown> {
11
43
  return value && typeof value === "object" && !Array.isArray(value)
12
44
  ? (value as Record<string, unknown>)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-actors",
3
- "version": "0.40.1",
3
+ "version": "0.41.1",
4
4
  "private": false,
5
5
  "description": "Local Actor Kernel for Pi",
6
6
  "keywords": [