@chrok/pi-braid 0.3.1 → 0.3.3

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/dist/runner.js CHANGED
@@ -41,6 +41,9 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
41
41
  const model = registry.find(name.slice(0, slash), name.slice(slash + 1));
42
42
  if (!model)
43
43
  throw new Error(`Pi model '${name}' is not registered; use an exact provider/modelId from /model`);
44
+ const activity = options.activity?.recorder(request);
45
+ if (!registry.stream)
46
+ activity?.limitation("This Pi model registry does not stream; model activity is observed only when a request starts and finishes.");
44
47
  const workspace = request.workspace ?? {
45
48
  nodeId: request.node.id, mode: "read-only", workingDirectory: sourceDirectory, state: "ready",
46
49
  };
@@ -139,6 +142,14 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
139
142
  tools: allToolDefinitions,
140
143
  };
141
144
  const toolByName = new Map(fileTools.map((tool) => [tool.name, tool]));
145
+ const executionId = request.execution.executionId;
146
+ const transcripts = executionId === undefined ? undefined : options.transcripts;
147
+ transcripts?.start(executionId, request.node.id, workingDirectory, {
148
+ prompt: request.node.prompt ?? `Default ${request.node.type} instructions`,
149
+ goal: request.goal,
150
+ predecessors: request.predecessors.length,
151
+ workspace: `${workspace.mode} · ${workingDirectory}`,
152
+ }, toolByName);
142
153
  const systemPrompt = context.systemPrompt;
143
154
  const sessionId = crypto.randomUUID();
144
155
  const usage = { inputTokens: 0, outputTokens: 0 };
@@ -171,14 +182,54 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
171
182
  toolRounds,
172
183
  phase: "model",
173
184
  });
174
- // Registry.complete resolves stored API keys, OAuth, custom headers and endpoints.
175
- const response = await registry.complete(model, context, {
185
+ // The registry resolves stored API keys, OAuth, custom headers and endpoints.
186
+ // complete() is stream().result(); streaming only adds observation.
187
+ const requestOptions = {
176
188
  signal: request.signal,
177
189
  sessionId,
178
190
  cacheRetention: "none",
179
191
  transport: "sse",
180
192
  maxRetries: 0,
193
+ };
194
+ activity?.modelRequest();
195
+ let response;
196
+ try {
197
+ if (registry.stream) {
198
+ const stream = registry.stream(model, context, requestOptions);
199
+ for await (const event of stream) {
200
+ if (event.type !== "done" && event.type !== "error")
201
+ transcripts?.streaming(executionId, event.partial);
202
+ if (event.type === "text_delta")
203
+ activity?.modelStream("text", event.delta);
204
+ else if (event.type === "thinking_delta")
205
+ activity?.modelStream("reasoning", event.delta);
206
+ else if (event.type === "toolcall_start" || event.type === "toolcall_delta") {
207
+ const block = event.partial.content[event.contentIndex];
208
+ activity?.modelStream("tool_call", event.type === "toolcall_delta" ? event.delta : "", block?.type === "toolCall" ? block.name : undefined);
209
+ }
210
+ }
211
+ response = await stream.result();
212
+ }
213
+ else {
214
+ response = await registry.complete(model, context, requestOptions);
215
+ }
216
+ }
217
+ catch (error) {
218
+ activity?.modelError(error instanceof Error ? error.message : String(error));
219
+ throw error;
220
+ }
221
+ transcripts?.assistant(executionId, response);
222
+ activity?.modelResponse({
223
+ text: response.content.flatMap(block => block.type === "text" ? [block.text] : []).join("\n\n"),
224
+ reasoning: response.content.flatMap(block => block.type === "thinking" ? [block.thinking] : []).join("\n\n"),
225
+ toolCalls: response.content.flatMap(block => block.type === "toolCall" ? [{ id: block.id, name: block.name }] : []),
226
+ stopReason: response.stopReason,
227
+ inputTokens: response.usage.input + response.usage.cacheRead + response.usage.cacheWrite,
228
+ outputTokens: response.usage.output,
181
229
  });
230
+ if (response.stopReason !== "stop" && response.stopReason !== "toolUse") {
231
+ activity?.modelError(response.errorMessage || `Pi model ended with '${response.stopReason}'`);
232
+ }
182
233
  actualModel = `${response.provider}/${response.responseModel ?? response.model}`;
183
234
  const providerContextTokens = response.usage.input +
184
235
  response.usage.cacheRead +
@@ -234,7 +285,13 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
234
285
  return toolResult(call, "Merge dispositions recorded. Return your final answer.", false);
235
286
  }
236
287
  const args = validateToolCall(allToolDefinitions, call);
237
- const execute = () => tool.execute(call.id, args, request.signal, undefined);
288
+ const execute = () => tool.execute(call.id, args, request.signal,
289
+ // Bash sends cumulative, throttled output snapshots.
290
+ (partial) => {
291
+ activity?.toolOutput(call.id, partial.content
292
+ .flatMap(block => block.type === "text" ? [block.text] : []).join(""), "replace");
293
+ transcripts?.toolUpdate(executionId, call.id, { content: partial.content, details: partial.details });
294
+ });
238
295
  const result = ["write", "edit", "bash", "powershell"].includes(call.name) && request.withWorkspaceWrite
239
296
  ? await request.withWorkspaceWrite(execute)
240
297
  : await execute();
@@ -254,6 +311,24 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
254
311
  return toolResult(call, error instanceof Error ? error.message : String(error), true);
255
312
  }
256
313
  };
314
+ const executeCall = async (call) => {
315
+ if (call.name !== "decide")
316
+ return executeFileTool(call);
317
+ if (request.node.type !== "decision") {
318
+ return toolResult(call, "decide is available only on decision nodes", true);
319
+ }
320
+ const args = call.arguments;
321
+ if (!args ||
322
+ Array.isArray(args) ||
323
+ Object.keys(args).length !== 1 ||
324
+ typeof args.choice !== "string") {
325
+ throw new Error("decide requires exactly one string argument: choice");
326
+ }
327
+ // No coercion or prose parsing: the core enforces the enum and exactly one call.
328
+ request.decide(args.choice);
329
+ decided = true;
330
+ return toolResult(call, JSON.stringify({ choice: args.choice }), false);
331
+ };
257
332
  while (true) {
258
333
  const response = await complete();
259
334
  textParts.push(...response.content
@@ -287,26 +362,20 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
287
362
  const results = [];
288
363
  for (const call of calls) {
289
364
  request.signal.throwIfAborted();
290
- if (call.name === "decide") {
291
- if (request.node.type !== "decision") {
292
- results.push(toolResult(call, "decide is available only on decision nodes", true));
293
- continue;
294
- }
295
- const args = call.arguments;
296
- if (!args ||
297
- Array.isArray(args) ||
298
- Object.keys(args).length !== 1 ||
299
- typeof args.choice !== "string") {
300
- throw new Error("decide requires exactly one string argument: choice");
301
- }
302
- // No coercion or prose parsing: the core enforces the enum and exactly one call.
303
- request.decide(args.choice);
304
- decided = true;
305
- results.push(toolResult(call, JSON.stringify({ choice: args.choice }), false));
365
+ activity?.toolStart(call.id, call.name, call.arguments);
366
+ transcripts?.toolStart(executionId, call.id);
367
+ let result;
368
+ try {
369
+ result = await executeCall(call);
306
370
  }
307
- else {
308
- results.push(await executeFileTool(call));
371
+ catch (error) {
372
+ activity?.toolEnd(call.id, error instanceof Error ? error.message : String(error), true);
373
+ throw error;
309
374
  }
375
+ activity?.toolEnd(call.id, result.content
376
+ .flatMap(block => block.type === "text" ? [block.text] : []).join("\n"), result.isError);
377
+ transcripts?.toolResult(executionId, result);
378
+ results.push(result);
310
379
  }
311
380
  context.messages.push(...results);
312
381
  if (decided) {
@@ -0,0 +1,112 @@
1
+ import { AssistantMessageComponent, getMarkdownTheme, ToolExecutionComponent, UserMessageComponent, } from "@earendil-works/pi-coding-agent";
2
+ import { Container, stripTerminalSequences, Text } from "@earendil-works/pi-tui";
3
+ const plain = (value) => stripTerminalSequences(value).replace(/\p{Cc}/gu, " ");
4
+ /**
5
+ * Renders a worker conversation with Pi's own chat components, applying only
6
+ * what changed since the last sync, the way Pi's interactive mode streams.
7
+ */
8
+ export class NodeSessionView {
9
+ tui;
10
+ theme;
11
+ chat = new Container();
12
+ transcript;
13
+ version = -1;
14
+ rendered = 0;
15
+ streaming;
16
+ tools = new Map();
17
+ partials = new Map();
18
+ expanded = false;
19
+ constructor(tui, theme) {
20
+ this.tui = tui;
21
+ this.theme = theme;
22
+ }
23
+ /** Tool output expansion, like Pi's ctrl+o. */
24
+ toggleExpanded() {
25
+ this.expanded = !this.expanded;
26
+ for (const tool of this.tools.values())
27
+ tool.setExpanded(this.expanded);
28
+ this.version = -1;
29
+ }
30
+ render(transcript, width) {
31
+ this.sync(transcript);
32
+ // Pi marks its own prompts for terminal shell integration; these are not prompts.
33
+ return this.chat.render(width).map(line => line.replace(/\x1b\]133;[^\x07\x1b]*(?:\x07|\x1b\\)/g, ""));
34
+ }
35
+ sync(transcript) {
36
+ if (transcript !== this.transcript)
37
+ this.reset(transcript);
38
+ if (transcript.version === this.version)
39
+ return;
40
+ this.version = transcript.version;
41
+ for (; this.rendered < transcript.messages.length; this.rendered++) {
42
+ const message = transcript.messages[this.rendered];
43
+ if (message.role === "toolResult") {
44
+ this.tools.get(message.toolCallId)?.updateResult(message);
45
+ this.partials.delete(message.toolCallId);
46
+ continue;
47
+ }
48
+ // The final message replaces the streamed partial in the same component.
49
+ const component = this.streaming ?? this.add(new AssistantMessageComponent(undefined, false, getMarkdownTheme()));
50
+ this.streaming = undefined;
51
+ component.updateContent(message, false);
52
+ this.toolCalls(message, transcript, true);
53
+ }
54
+ if (transcript.streaming) {
55
+ this.streaming ??= this.add(new AssistantMessageComponent(undefined, false, getMarkdownTheme()));
56
+ this.streaming.updateContent(transcript.streaming, true);
57
+ this.toolCalls(transcript.streaming, transcript, false);
58
+ }
59
+ for (const [callId, partial] of transcript.running) {
60
+ const tool = this.tools.get(callId);
61
+ if (!tool || this.partials.get(callId) === partial)
62
+ continue;
63
+ this.partials.set(callId, partial);
64
+ tool.markExecutionStarted();
65
+ if (partial)
66
+ tool.updateResult({ ...partial, isError: false }, true);
67
+ }
68
+ }
69
+ toolCalls(message, transcript, complete) {
70
+ for (const block of message.content) {
71
+ if (block.type !== "toolCall")
72
+ continue;
73
+ let tool = this.tools.get(block.id);
74
+ if (!tool) {
75
+ tool = new ToolExecutionComponent(block.name, block.id, block.arguments, undefined, transcript.renderers.get(block.name), this.tui, transcript.cwd);
76
+ tool.setExpanded(this.expanded);
77
+ this.tools.set(block.id, tool);
78
+ this.add(tool);
79
+ }
80
+ else if (!complete)
81
+ tool.updateArgs(block.arguments);
82
+ if (complete) {
83
+ if (message.stopReason === "aborted" || message.stopReason === "error") {
84
+ tool.updateResult({ content: [{ type: "text", text: message.errorMessage || "Error" }], isError: true });
85
+ }
86
+ else {
87
+ tool.updateArgs(block.arguments);
88
+ tool.setArgsComplete();
89
+ }
90
+ }
91
+ }
92
+ }
93
+ reset(transcript) {
94
+ this.transcript = transcript;
95
+ this.chat.clear();
96
+ this.tools.clear();
97
+ this.partials.clear();
98
+ this.streaming = undefined;
99
+ this.rendered = 0;
100
+ this.version = -1;
101
+ const intro = transcript.intro;
102
+ this.add(new UserMessageComponent(intro.prompt, getMarkdownTheme()));
103
+ this.add(new Text(this.theme.fg("dim", [
104
+ `goal: ${plain(intro.goal)}`,
105
+ `context: ${intro.predecessors} predecessor output${intro.predecessors === 1 ? "" : "s"} · workspace ${plain(intro.workspace)}`,
106
+ ].join("\n")), 1, 0));
107
+ }
108
+ add(component) {
109
+ this.chat.addChild(component);
110
+ return component;
111
+ }
112
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Live worker conversations keyed by execution ID. Running executions and the
3
+ * newest finished ones are retained; older transcripts are dropped to bound memory.
4
+ */
5
+ export class PiTranscripts {
6
+ onChange;
7
+ maxFinished;
8
+ transcripts = new Map();
9
+ finishedIds = [];
10
+ pending;
11
+ constructor(onChange = () => { }, maxFinished = 20) {
12
+ this.onChange = onChange;
13
+ this.maxFinished = maxFinished;
14
+ }
15
+ get(executionId) {
16
+ return this.transcripts.get(executionId);
17
+ }
18
+ start(executionId, nodeId, cwd, intro, renderers) {
19
+ this.transcripts.set(executionId, {
20
+ executionId, nodeId, cwd, intro, messages: [], running: new Map(), renderers, version: 0, finished: false,
21
+ });
22
+ this.changed();
23
+ }
24
+ streaming(executionId, partial) {
25
+ this.update(executionId, transcript => { transcript.streaming = partial; }, true);
26
+ }
27
+ assistant(executionId, message) {
28
+ this.update(executionId, transcript => {
29
+ delete transcript.streaming;
30
+ transcript.messages.push(message);
31
+ });
32
+ }
33
+ toolStart(executionId, callId) {
34
+ this.update(executionId, transcript => { transcript.running.set(callId, undefined); });
35
+ }
36
+ toolUpdate(executionId, callId, partial) {
37
+ this.update(executionId, transcript => {
38
+ if (transcript.running.has(callId))
39
+ transcript.running.set(callId, partial);
40
+ }, true);
41
+ }
42
+ toolResult(executionId, result) {
43
+ this.update(executionId, transcript => {
44
+ transcript.running.delete(result.toolCallId);
45
+ transcript.messages.push(result);
46
+ });
47
+ }
48
+ /** Close interrupted streams and tools so the view does not show them as still running. */
49
+ finish(executionId, reason = "Execution ended before this step finished") {
50
+ if (executionId === undefined)
51
+ return;
52
+ this.update(executionId, transcript => {
53
+ if (transcript.streaming) {
54
+ transcript.messages.push({ ...transcript.streaming, stopReason: "aborted", errorMessage: reason });
55
+ delete transcript.streaming;
56
+ }
57
+ for (const callId of transcript.running.keys()) {
58
+ const call = transcript.messages.flatMap(message => message.role === "assistant" ? message.content : [])
59
+ .find(block => block.type === "toolCall" && block.id === callId);
60
+ transcript.messages.push({
61
+ role: "toolResult", toolCallId: callId, toolName: call?.type === "toolCall" ? call.name : "tool",
62
+ content: [{ type: "text", text: reason }], isError: true, timestamp: Date.now(),
63
+ });
64
+ }
65
+ transcript.running.clear();
66
+ transcript.finished = true;
67
+ if (!this.finishedIds.includes(executionId))
68
+ this.finishedIds.push(executionId);
69
+ while (this.finishedIds.length > this.maxFinished)
70
+ this.transcripts.delete(this.finishedIds.shift());
71
+ });
72
+ }
73
+ /** Token and output bursts coalesce into at most one notification per 100 ms. */
74
+ update(executionId, change, burst = false) {
75
+ const transcript = this.transcripts.get(executionId);
76
+ if (!transcript || transcript.finished)
77
+ return;
78
+ try {
79
+ change(transcript);
80
+ transcript.version++;
81
+ }
82
+ catch {
83
+ // Display state must never affect node execution.
84
+ }
85
+ if (!burst)
86
+ this.changed();
87
+ else if (!this.pending) {
88
+ this.pending = setTimeout(() => this.changed(), 100);
89
+ this.pending.unref?.();
90
+ }
91
+ }
92
+ changed() {
93
+ clearTimeout(this.pending);
94
+ this.pending = undefined;
95
+ try {
96
+ this.onChange();
97
+ }
98
+ catch { /* Observers cannot fail a worker. */ }
99
+ }
100
+ }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@chrok/pi-braid",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "license": "MIT",
5
5
  "description": "Pi extension for Braid: background agent graphs, bounded loops, live updates, and a flow panel",
6
6
  "type": "module",
7
7
  "dependencies": {
8
8
  "grok-mermaid": "^0.2.2",
9
- "@chrok/braid": "0.3.1"
9
+ "@chrok/braid": "0.3.3"
10
10
  },
11
11
  "peerDependencies": {
12
12
  "@earendil-works/pi-ai": "*",