pi-agent-flow 1.1.0 → 1.2.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/runner-events.js DELETED
@@ -1,559 +0,0 @@
1
- /**
2
- * Helpers for parsing Pi JSON mode events and summarizing flow results.
3
- */
4
-
5
- // WeakMap-based side tables to avoid polluting caller objects and survive frozen/sealed objects.
6
- const seenSignaturesMap = new WeakMap();
7
- const streamingTextBufferMap = new WeakMap();
8
- const lastEmittedWordCountMap = new WeakMap();
9
- const streamingEstimateMap = new WeakMap();
10
- const smoothedTpsMap = new WeakMap();
11
- const lastEmitTimeMap = new WeakMap();
12
- const pendingTokensMap = new WeakMap();
13
- const ctxBaselineMap = new WeakMap();
14
- const ctxStreamingCharsMap = new WeakMap();
15
- const pauseAfterNextEmitMap = new WeakMap();
16
-
17
- function getSeenFlowMessageSignatures(result) {
18
- if (!seenSignaturesMap.has(result)) {
19
- seenSignaturesMap.set(result, new Set());
20
- }
21
- return seenSignaturesMap.get(result);
22
- }
23
-
24
- function getStreamingTextState(result) {
25
- if (!streamingTextBufferMap.has(result)) {
26
- streamingTextBufferMap.set(result, "");
27
- lastEmittedWordCountMap.set(result, 0);
28
- }
29
- return {
30
- get buffer() { return streamingTextBufferMap.get(result); },
31
- set buffer(v) { streamingTextBufferMap.set(result, v); },
32
- get lastEmittedWordCount() { return lastEmittedWordCountMap.get(result); },
33
- set lastEmittedWordCount(v) { lastEmittedWordCountMap.set(result, v); },
34
- };
35
- }
36
-
37
- /**
38
- * Drain the accumulated streaming text buffer and return it.
39
- * Updates the last-emitted word count for threshold tracking.
40
- */
41
- export function drainStreamingText(result) {
42
- const state = getStreamingTextState(result);
43
- const buf = state.buffer;
44
- if (!buf) return "";
45
- state.buffer = "";
46
- state.lastEmittedWordCount = 0;
47
- return buf;
48
- }
49
-
50
- // ---------------------------------------------------------------------------
51
- // Streaming token estimate
52
- // ---------------------------------------------------------------------------
53
-
54
- /** Chars per token heuristic for output estimation. */
55
- const CHARS_PER_TOKEN = 4;
56
-
57
- /** Minimum elapsed ms between TPS samples. */
58
- const MIN_TPS_SAMPLE_MS = 100;
59
- /** Cap on instantaneous TPS to suppress burst artifacts. */
60
- const MAX_INSTANT_TPS = 300;
61
- /** Calibration scale to align heuristic tokens with empirical display range. */
62
- const TPS_CALIBRATION = 0.1;
63
- /** EMA smoothing factor for tokens-per-second (higher = more responsive). */
64
- const EMA_ALPHA = 0.1;
65
-
66
- function getStreamingEstimate(result) {
67
- if (!streamingEstimateMap.has(result)) {
68
- streamingEstimateMap.set(result, { chars: 0 });
69
- }
70
- return streamingEstimateMap.get(result);
71
- }
72
-
73
- /**
74
- * Lazily initialize TPS tracking state on the result object.
75
- * Returns an accessor object backed by a WeakMap so frozen/sealed
76
- * objects do not throw.
77
- */
78
- function getTpsState(result) {
79
- if (!smoothedTpsMap.has(result)) {
80
- smoothedTpsMap.set(result, 0);
81
- lastEmitTimeMap.set(result, 0);
82
- pendingTokensMap.set(result, 0);
83
- pauseAfterNextEmitMap.set(result, false);
84
- }
85
- return {
86
- get smoothedTps() { return smoothedTpsMap.get(result); },
87
- set smoothedTps(v) { smoothedTpsMap.set(result, v); },
88
- get lastEmitTime() { return lastEmitTimeMap.get(result); },
89
- set lastEmitTime(v) { lastEmitTimeMap.set(result, v); },
90
- get pendingTokens() { return pendingTokensMap.get(result); },
91
- set pendingTokens(v) { pendingTokensMap.set(result, v); },
92
- get pauseAfterNextEmit() { return pauseAfterNextEmitMap.get(result); },
93
- set pauseAfterNextEmit(v) { pauseAfterNextEmitMap.set(result, v); },
94
- };
95
- }
96
-
97
- /**
98
- * Update the EMA-smoothed tokens-per-second based on a new sample.
99
- * Called from emitUpdate() with the estimated output tokens since last emit.
100
- * Accumulates tokens in pendingTokens and only computes a rate when
101
- * MIN_TPS_SAMPLE_MS has elapsed. Applies MAX_INSTANT_TPS cap before EMA.
102
- */
103
- export function updateSmoothedTps(result, estimatedTokens) {
104
- const tracker = getTpsState(result);
105
-
106
- if (estimatedTokens <= 0) {
107
- if (tracker.pauseAfterNextEmit) {
108
- tracker.lastEmitTime = 0;
109
- tracker.pauseAfterNextEmit = false;
110
- }
111
- return;
112
- }
113
-
114
- if (tracker.lastEmitTime === 0) {
115
- // First emit after a gap — seed the value directly
116
- tracker.lastEmitTime = Date.now();
117
- tracker.pauseAfterNextEmit = false;
118
- return;
119
- }
120
-
121
- tracker.pendingTokens += estimatedTokens;
122
- const now = Date.now();
123
- const deltaMs = now - tracker.lastEmitTime;
124
- if (deltaMs < MIN_TPS_SAMPLE_MS) {
125
- // If a pause was requested but we can't compute yet, reset the timer
126
- // so the upcoming gap (e.g., tool execution) isn't counted.
127
- if (tracker.pauseAfterNextEmit) {
128
- tracker.lastEmitTime = 0;
129
- tracker.pauseAfterNextEmit = false;
130
- tracker.pendingTokens = 0;
131
- }
132
- return;
133
- }
134
- const deltaSec = deltaMs / 1000;
135
- let instantRate = (tracker.pendingTokens * TPS_CALIBRATION) / deltaSec;
136
- if (instantRate > MAX_INSTANT_TPS) {
137
- instantRate = MAX_INSTANT_TPS;
138
- }
139
- if (tracker.smoothedTps === 0) {
140
- tracker.smoothedTps = instantRate;
141
- } else {
142
- tracker.smoothedTps = EMA_ALPHA * instantRate + (1 - EMA_ALPHA) * tracker.smoothedTps;
143
- }
144
- tracker.lastEmitTime = now;
145
- tracker.pendingTokens = 0;
146
-
147
- if (tracker.pauseAfterNextEmit) {
148
- tracker.lastEmitTime = 0;
149
- tracker.pauseAfterNextEmit = false;
150
- }
151
- }
152
-
153
- /**
154
- * Return the current EMA-smoothed tokens-per-second value.
155
- */
156
- export function drainSmoothedTps(result) {
157
- const tracker = getTpsState(result);
158
- return tracker.smoothedTps;
159
- }
160
-
161
- /**
162
- * Lazily initialize ctx baseline tracking state on the result object.
163
- * Returns an accessor object backed by a WeakMap so frozen/sealed
164
- * objects do not throw.
165
- */
166
- function getCtxState(result) {
167
- if (!ctxBaselineMap.has(result)) {
168
- ctxBaselineMap.set(result, 0);
169
- ctxStreamingCharsMap.set(result, 0);
170
- }
171
- return {
172
- get baseline() { return ctxBaselineMap.get(result); },
173
- set baseline(v) { ctxBaselineMap.set(result, v); },
174
- get streamingChars() { return ctxStreamingCharsMap.get(result); },
175
- set streamingChars(v) { ctxStreamingCharsMap.set(result, v); },
176
- };
177
- }
178
-
179
- /**
180
- * Track streaming characters and estimate output tokens.
181
- * Called on every streaming delta.
182
- */
183
- function updateStreamingEstimate(result, deltaLength) {
184
- if (deltaLength <= 0) return;
185
- const est = getStreamingEstimate(result);
186
- est.chars += deltaLength;
187
- // Also accumulate chars for ctx estimation (not drained on emit)
188
- const ctxState = getCtxState(result);
189
- ctxState.streamingChars += deltaLength;
190
- }
191
-
192
- /**
193
- * Drain the current streaming estimate and return estimated output tokens.
194
- * Returns 0 when no streaming has occurred.
195
- */
196
- export function drainStreamingEstimate(result) {
197
- const est = getStreamingEstimate(result);
198
- const tokens = Math.floor(est.chars / CHARS_PER_TOKEN);
199
- est.chars = 0;
200
- return tokens;
201
- }
202
-
203
- /**
204
- * Return the estimated context tokens: last known real totalTokens (baseline)
205
- * plus any additional output tokens estimated since that baseline was set.
206
- * Returns 0 before the first message_end when no baseline exists yet,
207
- * in which case the caller should fall back to the streaming output estimate.
208
- */
209
- export function drainCtxEstimate(result) {
210
- const ctxState = getCtxState(result);
211
- const streamingTokens = Math.floor(ctxState.streamingChars / CHARS_PER_TOKEN);
212
- return ctxState.baseline + streamingTokens;
213
- }
214
-
215
- /**
216
- * Accumulate a text or thinking delta into the streaming buffer.
217
- * Returns true if the caller should emit an update.
218
- */
219
- function accumulateStreamingDelta(result, delta) {
220
- if (!delta) return false;
221
- const state = getStreamingTextState(result);
222
- state.buffer = state.buffer + delta;
223
- updateStreamingEstimate(result, delta.length);
224
- if (state.buffer.length - state.lastEmittedWordCount >= 40) {
225
- state.lastEmittedWordCount = state.buffer.length;
226
- return true;
227
- }
228
- return false;
229
- }
230
-
231
- export function stableStringify(value, seen = new WeakSet()) {
232
- if (value === null || typeof value !== "object") {
233
- return JSON.stringify(value);
234
- }
235
-
236
- if (seen.has(value)) {
237
- return '"[Circular]"';
238
- }
239
- seen.add(value);
240
-
241
- if (Array.isArray(value)) {
242
- const out = `[${value.map((item) => stableStringify(item, seen)).join(",")}]`;
243
- seen.delete(value);
244
- return out;
245
- }
246
-
247
- const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
248
- const out = `{${entries
249
- .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue, seen)}`)
250
- .join(",")}}`;
251
- seen.delete(value);
252
- return out;
253
- }
254
-
255
- function getMessageSignature(message) {
256
- return stableStringify(message);
257
- }
258
-
259
- function updateAssistantMetadata(result, message) {
260
- if (!message || message.role !== "assistant") return;
261
- if (!result.model && message.model) result.model = message.model;
262
- if (message.stopReason) result.stopReason = message.stopReason;
263
- if (message.errorMessage) result.errorMessage = message.errorMessage;
264
- }
265
-
266
- function addFlowAssistantMessage(result, message) {
267
- if (!message || message.role !== "assistant") return false;
268
-
269
- updateAssistantMetadata(result, message);
270
-
271
- const signature = getMessageSignature(message);
272
- const seen = getSeenFlowMessageSignatures(result);
273
- if (seen.has(signature)) return false;
274
- seen.add(signature);
275
-
276
- result.messages.push(message);
277
-
278
- // Reset streaming estimate when actual usage arrives
279
- const est = getStreamingEstimate(result);
280
- est.chars = 0;
281
-
282
- result.usage.turns++;
283
- const usage = message.usage;
284
- if (usage) {
285
- result.usage.input += usage.input || 0;
286
- result.usage.output += usage.output || 0;
287
- result.usage.cacheRead += usage.cacheRead || 0;
288
- result.usage.cacheWrite += usage.cacheWrite || 0;
289
- result.usage.cost += usage.cost?.total || 0;
290
- result.usage.contextTokens = usage.totalTokens || 0;
291
-
292
- // Snapshot ctx baseline for smooth streaming estimation
293
- const ctxState = getCtxState(result);
294
- ctxState.baseline = usage.totalTokens || 0;
295
- ctxState.streamingChars = 0;
296
- }
297
-
298
- // Count tool call parts in the message content and estimate their tokens
299
- if (Array.isArray(message.content)) {
300
- let toolCallChars = 0;
301
- for (const part of message.content) {
302
- if (part.type === "toolCall") {
303
- result.usage.toolCalls++;
304
- const tcText = JSON.stringify({ name: part.name, args: part.arguments || part.input || {} });
305
- toolCallChars += tcText.length;
306
- }
307
- }
308
- if (toolCallChars > 0) {
309
- updateStreamingEstimate(result, toolCallChars);
310
- const tracker = getTpsState(result);
311
- tracker.pauseAfterNextEmit = true;
312
- }
313
- }
314
-
315
- return true;
316
- }
317
-
318
- function addFlowToolMessage(result, message) {
319
- if (!message || message.role !== "tool") return false;
320
-
321
- const signature = getMessageSignature(message);
322
- const seen = getSeenFlowMessageSignatures(result);
323
- if (seen.has(signature)) return false;
324
- seen.add(signature);
325
-
326
- result.messages.push(message);
327
- return true;
328
- }
329
-
330
- function addFlowMessages(result, messages) {
331
- if (!Array.isArray(messages)) return false;
332
- let changed = false;
333
- for (const message of messages) {
334
- if (message && message.role === "tool") {
335
- if (addFlowToolMessage(result, message)) changed = true;
336
- } else if (message && message.role === "assistant") {
337
- if (addFlowAssistantMessage(result, message)) changed = true;
338
- }
339
- }
340
- return changed;
341
- }
342
-
343
- function processFlowEvent(event, result) {
344
- if (!event || typeof event !== "object") return false;
345
-
346
- switch (event.type) {
347
- case "message_end":
348
- return addFlowMessages(result, [event.message]);
349
-
350
- case "turn_end":
351
- return addFlowMessages(result, [event.message]);
352
-
353
- case "agent_end":
354
- result.sawAgentEnd = true;
355
- return addFlowMessages(result, event.messages);
356
-
357
- case "message_update": {
358
- const evt = event.assistantMessageEvent;
359
- if (!evt || typeof evt !== "object") return false;
360
- if (evt.type === "text_delta") {
361
- return accumulateStreamingDelta(result, evt.delta);
362
- }
363
- if (evt.type === "thinking_delta") {
364
- return accumulateStreamingDelta(result, evt.delta);
365
- }
366
- return false;
367
- }
368
-
369
- default:
370
- return false;
371
- }
372
- }
373
-
374
- export function processFlowJsonLine(line, result) {
375
- if (!line.trim()) return false;
376
-
377
- let event;
378
- try {
379
- event = JSON.parse(line);
380
- } catch {
381
- return false;
382
- }
383
-
384
- return processFlowEvent(event, result);
385
- }
386
-
387
- export function getFlowFinalText(messages) {
388
- if (!Array.isArray(messages)) return "";
389
-
390
- for (let i = messages.length - 1; i >= 0; i--) {
391
- const message = messages[i];
392
- if (!message || message.role !== "assistant") {
393
- continue;
394
- }
395
- if (typeof message.content === "string" && message.content.length > 0) {
396
- return message.content;
397
- }
398
- if (!Array.isArray(message.content)) {
399
- continue;
400
- }
401
-
402
- for (const part of message.content) {
403
- if (part?.type === "text" && typeof part.text === "string" && part.text.length > 0) {
404
- return part.text;
405
- }
406
- }
407
- }
408
-
409
- return "";
410
- }
411
-
412
- function extractNonReadToolCalls(messages) {
413
- const calls = [];
414
- if (!Array.isArray(messages)) return calls;
415
- for (const msg of messages) {
416
- if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
417
- for (const part of msg.content) {
418
- if (part.type === "toolCall" && part.name !== "read") {
419
- calls.push({ name: part.name, args: part.arguments || part.input || {} });
420
- }
421
- }
422
- }
423
- return calls;
424
- }
425
-
426
- function formatToolCallShort(tc) {
427
- const args = tc.args || {};
428
- switch (tc.name) {
429
- case "edit":
430
- case "write":
431
- return `${tc.name} ${(args.file_path || args.path || "?").split("/").pop()}`;
432
- case "bash": {
433
- const cmd = (args.command || "").replace(/[\n\r\t]+/g, " ").replace(/ +/g, " ").trim();
434
- return `bash ${cmd.length > 40 ? cmd.slice(0, 40) + "..." : cmd}`;
435
- }
436
- case "grep":
437
- return `grep /${args.pattern || "?"}/ in ${args.path || "."}`;
438
- case "find":
439
- return `find ${args.pattern || "*"} in ${args.path || "."}`;
440
- case "ls":
441
- return `ls ${args.path || "."}`;
442
- case "batch": {
443
- const ops = Array.isArray(args.o) ? args.o : Array.isArray(args.op) ? args.op : Array.isArray(args.operations) ? args.operations : Array.isArray(args) ? args : [];
444
- if (ops.length === 0) return "batch (empty)";
445
- const first = ops[0] || {};
446
- const firstPath = (first.p ?? first.path ?? "?").split("/").pop();
447
- const opType = first.o ?? first.op ?? "?";
448
- const label = ops.length === 1 ? `${opType} ${firstPath}` : `${opType} ${firstPath} +${ops.length - 1} more`;
449
- return `batch ${label}`;
450
- }
451
- default:
452
- return tc.name;
453
- }
454
- }
455
-
456
- /**
457
- * Match tool calls with their results to build a paired list.
458
- * Returns [{ name, command/args, output }] limited to the most recent pairs.
459
- */
460
- function matchToolCallsWithResults(messages, maxPairs) {
461
- if (!Array.isArray(messages)) return [];
462
- const pairs = [];
463
-
464
- // Build a map of toolCallId -> tool result output
465
- const resultMap = new Map();
466
- for (const msg of messages) {
467
- if (msg.role !== "tool" || !Array.isArray(msg.content)) continue;
468
- const id = msg.toolCallId || msg.tool_call_id || "";
469
- if (!id) continue;
470
- const text = msg.content
471
- .filter((p) => p.type === "text" && typeof p.text === "string")
472
- .map((p) => p.text)
473
- .join("\n");
474
- resultMap.set(id, text);
475
- }
476
-
477
- // Walk assistant messages to find tool calls that have matching results
478
- for (const msg of messages) {
479
- if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
480
- for (const part of msg.content) {
481
- if (part.type !== "toolCall") continue;
482
- const id = part.toolCallId || part.tool_call_id || "";
483
- if (!id || !resultMap.has(id)) continue;
484
- const name = part.name || part.toolName || "unknown";
485
- const args = part.arguments || part.input || {};
486
- const output = resultMap.get(id);
487
- pairs.push({ name, args, output });
488
- }
489
- }
490
-
491
- // Return the most recent pairs
492
- return pairs.slice(-maxPairs);
493
- }
494
-
495
- /** Max tool result output chars to include per tool call in the summary. */
496
- const TOOL_RESULT_MAX_CHARS = 2000;
497
-
498
- export function getFlowSummaryText(result) {
499
- const finalText = getFlowFinalText(result?.messages);
500
- const isError =
501
- (typeof result?.exitCode === "number" && result.exitCode > 0) ||
502
- result?.stopReason === "error" ||
503
- result?.stopReason === "aborted";
504
-
505
- // Build error base for failed flows
506
- let errorBase = "";
507
- if (isError) {
508
- if (typeof result?.errorMessage === "string" && result.errorMessage.trim()) {
509
- errorBase = result.errorMessage.trim();
510
- } else if (typeof result?.stderr === "string" && result.stderr.trim()) {
511
- errorBase = result.stderr.trim();
512
- } else {
513
- errorBase = "Flow failed";
514
- }
515
- }
516
-
517
- // Extract tool call/result pairs for context
518
- const toolPairs = matchToolCallsWithResults(result?.messages, 10);
519
- const toolSummaryParts = [];
520
-
521
- for (const pair of toolPairs) {
522
- const callLabel = formatToolCallShort({ name: pair.name, args: pair.args });
523
- if (pair.output.trim()) {
524
- const truncated = pair.output.length > TOOL_RESULT_MAX_CHARS
525
- ? pair.output.slice(0, TOOL_RESULT_MAX_CHARS) + "\n... (truncated)"
526
- : pair.output;
527
- toolSummaryParts.push(`${callLabel}:\n${truncated}`);
528
- } else {
529
- toolSummaryParts.push(`${callLabel}: (no output)`);
530
- }
531
- }
532
-
533
- const toolContext = toolSummaryParts.length > 0
534
- ? "\n\n[Tool Results]\n" + toolSummaryParts.join("\n---\n")
535
- : "";
536
-
537
- // If there's final text, include it plus tool context
538
- if (finalText) {
539
- return finalText + toolContext;
540
- }
541
-
542
- // No final text
543
- if (isError) {
544
- // Surface partial tool calls (excluding read) for failed/aborted flows
545
- const toolCalls = extractNonReadToolCalls(result?.messages);
546
- if (toolCalls.length > 0) {
547
- const formatted = toolCalls.map(formatToolCallShort).join(", ");
548
- return `${errorBase}\nPartial work: ${formatted}${toolContext}`;
549
- }
550
- return errorBase;
551
- }
552
-
553
- // Success but no final text — show tool results if any
554
- if (toolContext) {
555
- return toolContext.trim();
556
- }
557
-
558
- return "(no output)";
559
- }
File without changes