@chrok/pi-braid 0.3.2 → 0.3.4
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/README.md +85 -7
- package/dist/assistant.js +345 -0
- package/dist/command.js +428 -51
- package/dist/display.js +166 -19
- package/dist/human-input.js +60 -0
- package/dist/index.js +17 -3
- package/dist/jobs.js +78 -3
- package/dist/runner.js +109 -23
- package/dist/session-view.js +138 -0
- package/dist/shell-tools.js +12 -5
- package/dist/transcript.js +114 -0
- package/package.json +2 -2
package/dist/runner.js
CHANGED
|
@@ -4,6 +4,7 @@ import { formatBudgetReminder, gitToolDefinition, finishMergeToolDefinition, mer
|
|
|
4
4
|
import { createWorktreeWriteTools } from "./write-tools.js";
|
|
5
5
|
import { createAvailableReadTools } from "./read-tools.js";
|
|
6
6
|
import { createWorkspaceShellTools } from "./shell-tools.js";
|
|
7
|
+
import { formatHumanInput } from "./human-input.js";
|
|
7
8
|
/** Keep Pi's provider/auth plumbing and filesystem capabilities out of Braid's core. */
|
|
8
9
|
export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd()) {
|
|
9
10
|
const options = typeof onUsageOrOptions === "function"
|
|
@@ -41,6 +42,9 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
41
42
|
const model = registry.find(name.slice(0, slash), name.slice(slash + 1));
|
|
42
43
|
if (!model)
|
|
43
44
|
throw new Error(`Pi model '${name}' is not registered; use an exact provider/modelId from /model`);
|
|
45
|
+
const activity = options.activity?.recorder(request);
|
|
46
|
+
if (!registry.stream)
|
|
47
|
+
activity?.limitation("This Pi model registry does not stream; model activity is observed only when a request starts and finishes.");
|
|
44
48
|
const workspace = request.workspace ?? {
|
|
45
49
|
nodeId: request.node.id, mode: "read-only", workingDirectory: sourceDirectory, state: "ready",
|
|
46
50
|
};
|
|
@@ -139,6 +143,24 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
139
143
|
tools: allToolDefinitions,
|
|
140
144
|
};
|
|
141
145
|
const toolByName = new Map(fileTools.map((tool) => [tool.name, tool]));
|
|
146
|
+
const executionId = request.execution.executionId;
|
|
147
|
+
const transcripts = executionId === undefined ? undefined : options.transcripts;
|
|
148
|
+
transcripts?.start(executionId, request.node.id, workingDirectory, {
|
|
149
|
+
prompt: request.node.prompt ?? `Default ${request.node.type} instructions`,
|
|
150
|
+
goal: request.goal,
|
|
151
|
+
predecessors: request.predecessors.length,
|
|
152
|
+
workspace: `${workspace.mode} · ${workingDirectory}`,
|
|
153
|
+
}, toolByName);
|
|
154
|
+
const humanInputs = executionId === undefined ? undefined : options.humanInputs;
|
|
155
|
+
// Between model requests only: a running tool batch is never interrupted.
|
|
156
|
+
const deliverHumanInputs = () => {
|
|
157
|
+
const inputs = humanInputs?.take(executionId) ?? [];
|
|
158
|
+
for (const input of inputs) {
|
|
159
|
+
request.recordHumanInput?.(input.text);
|
|
160
|
+
context.messages.push({ role: "user", content: formatHumanInput(input.text), timestamp: Date.now() });
|
|
161
|
+
}
|
|
162
|
+
transcripts?.human(executionId, inputs.map(input => input.text), true);
|
|
163
|
+
};
|
|
142
164
|
const systemPrompt = context.systemPrompt;
|
|
143
165
|
const sessionId = crypto.randomUUID();
|
|
144
166
|
const usage = { inputTokens: 0, outputTokens: 0 };
|
|
@@ -150,6 +172,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
150
172
|
let reportedContextTokens;
|
|
151
173
|
const complete = async () => {
|
|
152
174
|
request.signal.throwIfAborted();
|
|
175
|
+
deliverHumanInputs();
|
|
153
176
|
const budgets = [];
|
|
154
177
|
if (Number.isFinite(maxToolRounds)) {
|
|
155
178
|
budgets.push(`Tool round budget: ${toolRounds}/${maxToolRounds} used; ${maxToolRounds - toolRounds} remaining. A round is one assistant response containing tool calls.`);
|
|
@@ -171,14 +194,54 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
171
194
|
toolRounds,
|
|
172
195
|
phase: "model",
|
|
173
196
|
});
|
|
174
|
-
//
|
|
175
|
-
|
|
197
|
+
// The registry resolves stored API keys, OAuth, custom headers and endpoints.
|
|
198
|
+
// complete() is stream().result(); streaming only adds observation.
|
|
199
|
+
const requestOptions = {
|
|
176
200
|
signal: request.signal,
|
|
177
201
|
sessionId,
|
|
178
202
|
cacheRetention: "none",
|
|
179
203
|
transport: "sse",
|
|
180
204
|
maxRetries: 0,
|
|
205
|
+
};
|
|
206
|
+
activity?.modelRequest();
|
|
207
|
+
let response;
|
|
208
|
+
try {
|
|
209
|
+
if (registry.stream) {
|
|
210
|
+
const stream = registry.stream(model, context, requestOptions);
|
|
211
|
+
for await (const event of stream) {
|
|
212
|
+
if (event.type !== "done" && event.type !== "error")
|
|
213
|
+
transcripts?.streaming(executionId, event.partial);
|
|
214
|
+
if (event.type === "text_delta")
|
|
215
|
+
activity?.modelStream("text", event.delta);
|
|
216
|
+
else if (event.type === "thinking_delta")
|
|
217
|
+
activity?.modelStream("reasoning", event.delta);
|
|
218
|
+
else if (event.type === "toolcall_start" || event.type === "toolcall_delta") {
|
|
219
|
+
const block = event.partial.content[event.contentIndex];
|
|
220
|
+
activity?.modelStream("tool_call", event.type === "toolcall_delta" ? event.delta : "", block?.type === "toolCall" ? block.name : undefined);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
response = await stream.result();
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
response = await registry.complete(model, context, requestOptions);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
activity?.modelError(error instanceof Error ? error.message : String(error));
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
transcripts?.assistant(executionId, response);
|
|
234
|
+
activity?.modelResponse({
|
|
235
|
+
text: response.content.flatMap(block => block.type === "text" ? [block.text] : []).join("\n\n"),
|
|
236
|
+
reasoning: response.content.flatMap(block => block.type === "thinking" ? [block.thinking] : []).join("\n\n"),
|
|
237
|
+
toolCalls: response.content.flatMap(block => block.type === "toolCall" ? [{ id: block.id, name: block.name }] : []),
|
|
238
|
+
stopReason: response.stopReason,
|
|
239
|
+
inputTokens: response.usage.input + response.usage.cacheRead + response.usage.cacheWrite,
|
|
240
|
+
outputTokens: response.usage.output,
|
|
181
241
|
});
|
|
242
|
+
if (response.stopReason !== "stop" && response.stopReason !== "toolUse") {
|
|
243
|
+
activity?.modelError(response.errorMessage || `Pi model ended with '${response.stopReason}'`);
|
|
244
|
+
}
|
|
182
245
|
actualModel = `${response.provider}/${response.responseModel ?? response.model}`;
|
|
183
246
|
const providerContextTokens = response.usage.input +
|
|
184
247
|
response.usage.cacheRead +
|
|
@@ -234,7 +297,13 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
234
297
|
return toolResult(call, "Merge dispositions recorded. Return your final answer.", false);
|
|
235
298
|
}
|
|
236
299
|
const args = validateToolCall(allToolDefinitions, call);
|
|
237
|
-
const execute = () => tool.execute(call.id, args, request.signal,
|
|
300
|
+
const execute = () => tool.execute(call.id, args, request.signal,
|
|
301
|
+
// Bash sends cumulative, throttled output snapshots.
|
|
302
|
+
(partial) => {
|
|
303
|
+
activity?.toolOutput(call.id, partial.content
|
|
304
|
+
.flatMap(block => block.type === "text" ? [block.text] : []).join(""), "replace");
|
|
305
|
+
transcripts?.toolUpdate(executionId, call.id, { content: partial.content, details: partial.details });
|
|
306
|
+
});
|
|
238
307
|
const result = ["write", "edit", "bash", "powershell"].includes(call.name) && request.withWorkspaceWrite
|
|
239
308
|
? await request.withWorkspaceWrite(execute)
|
|
240
309
|
: await execute();
|
|
@@ -254,6 +323,24 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
254
323
|
return toolResult(call, error instanceof Error ? error.message : String(error), true);
|
|
255
324
|
}
|
|
256
325
|
};
|
|
326
|
+
const executeCall = async (call) => {
|
|
327
|
+
if (call.name !== "decide")
|
|
328
|
+
return executeFileTool(call);
|
|
329
|
+
if (request.node.type !== "decision") {
|
|
330
|
+
return toolResult(call, "decide is available only on decision nodes", true);
|
|
331
|
+
}
|
|
332
|
+
const args = call.arguments;
|
|
333
|
+
if (!args ||
|
|
334
|
+
Array.isArray(args) ||
|
|
335
|
+
Object.keys(args).length !== 1 ||
|
|
336
|
+
typeof args.choice !== "string") {
|
|
337
|
+
throw new Error("decide requires exactly one string argument: choice");
|
|
338
|
+
}
|
|
339
|
+
// No coercion or prose parsing: the core enforces the enum and exactly one call.
|
|
340
|
+
request.decide(args.choice);
|
|
341
|
+
decided = true;
|
|
342
|
+
return toolResult(call, JSON.stringify({ choice: args.choice }), false);
|
|
343
|
+
};
|
|
257
344
|
while (true) {
|
|
258
345
|
const response = await complete();
|
|
259
346
|
textParts.push(...response.content
|
|
@@ -261,8 +348,13 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
261
348
|
.map((block) => block.text));
|
|
262
349
|
const calls = response.content.filter((block) => block.type === "toolCall");
|
|
263
350
|
if (calls.length === 0) {
|
|
264
|
-
if (response.stopReason === "stop")
|
|
265
|
-
|
|
351
|
+
if (response.stopReason === "stop") {
|
|
352
|
+
// A message that arrived during the final request gets one more round.
|
|
353
|
+
if (!humanInputs?.pending(executionId).length)
|
|
354
|
+
break;
|
|
355
|
+
context.messages.push(response);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
266
358
|
throw new Error("Pi model requested tool use without a tool call");
|
|
267
359
|
}
|
|
268
360
|
if (response.stopReason !== "toolUse") {
|
|
@@ -287,26 +379,20 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
287
379
|
const results = [];
|
|
288
380
|
for (const call of calls) {
|
|
289
381
|
request.signal.throwIfAborted();
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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));
|
|
382
|
+
activity?.toolStart(call.id, call.name, call.arguments);
|
|
383
|
+
transcripts?.toolStart(executionId, call.id);
|
|
384
|
+
let result;
|
|
385
|
+
try {
|
|
386
|
+
result = await executeCall(call);
|
|
306
387
|
}
|
|
307
|
-
|
|
308
|
-
|
|
388
|
+
catch (error) {
|
|
389
|
+
activity?.toolEnd(call.id, error instanceof Error ? error.message : String(error), true);
|
|
390
|
+
throw error;
|
|
309
391
|
}
|
|
392
|
+
activity?.toolEnd(call.id, result.content
|
|
393
|
+
.flatMap(block => block.type === "text" ? [block.text] : []).join("\n"), result.isError);
|
|
394
|
+
transcripts?.toolResult(executionId, result);
|
|
395
|
+
results.push(result);
|
|
310
396
|
}
|
|
311
397
|
context.messages.push(...results);
|
|
312
398
|
if (decided) {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { AssistantMessageComponent, getMarkdownTheme, ToolExecutionComponent, UserMessageComponent, } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, Spacer, 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 === "human") {
|
|
44
|
+
this.gap();
|
|
45
|
+
this.add(new Text(message.delivered
|
|
46
|
+
? this.theme.fg("warning", "✎ Human input · delivered before the next model request")
|
|
47
|
+
: this.theme.fg("error", "✎ Human input · not delivered: the execution ended before its next model request"), 1, 0));
|
|
48
|
+
this.add(new UserMessageComponent(message.text, getMarkdownTheme()));
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (message.role === "user") {
|
|
52
|
+
const text = typeof message.content === "string" ? message.content
|
|
53
|
+
: message.content.flatMap(block => block.type === "text" ? [block.text] : []).join("\n");
|
|
54
|
+
this.gap();
|
|
55
|
+
this.add(new UserMessageComponent(text, getMarkdownTheme()));
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (message.role === "toolResult") {
|
|
59
|
+
this.tools.get(message.toolCallId)?.updateResult(message);
|
|
60
|
+
this.partials.delete(message.toolCallId);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// The final message replaces the streamed partial in the same component.
|
|
64
|
+
const component = this.streaming ?? this.add(new AssistantMessageComponent(undefined, false, getMarkdownTheme()));
|
|
65
|
+
this.streaming = undefined;
|
|
66
|
+
component.updateContent(message, false);
|
|
67
|
+
this.toolCalls(message, transcript, true);
|
|
68
|
+
// A reply that requests no tools ends the turn; leave room below it right away.
|
|
69
|
+
if (message.stopReason !== "toolUse")
|
|
70
|
+
this.gap();
|
|
71
|
+
}
|
|
72
|
+
if (transcript.streaming) {
|
|
73
|
+
this.streaming ??= this.add(new AssistantMessageComponent(undefined, false, getMarkdownTheme()));
|
|
74
|
+
this.streaming.updateContent(transcript.streaming, true);
|
|
75
|
+
this.toolCalls(transcript.streaming, transcript, false);
|
|
76
|
+
}
|
|
77
|
+
for (const [callId, partial] of transcript.running) {
|
|
78
|
+
const tool = this.tools.get(callId);
|
|
79
|
+
if (!tool || this.partials.get(callId) === partial)
|
|
80
|
+
continue;
|
|
81
|
+
this.partials.set(callId, partial);
|
|
82
|
+
tool.markExecutionStarted();
|
|
83
|
+
if (partial)
|
|
84
|
+
tool.updateResult({ ...partial, isError: false }, true);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
toolCalls(message, transcript, complete) {
|
|
88
|
+
for (const block of message.content) {
|
|
89
|
+
if (block.type !== "toolCall")
|
|
90
|
+
continue;
|
|
91
|
+
let tool = this.tools.get(block.id);
|
|
92
|
+
if (!tool) {
|
|
93
|
+
tool = new ToolExecutionComponent(block.name, block.id, block.arguments, undefined, transcript.renderers.get(block.name), this.tui, transcript.cwd);
|
|
94
|
+
tool.setExpanded(this.expanded);
|
|
95
|
+
this.tools.set(block.id, tool);
|
|
96
|
+
this.add(tool);
|
|
97
|
+
}
|
|
98
|
+
else if (!complete)
|
|
99
|
+
tool.updateArgs(block.arguments);
|
|
100
|
+
if (complete) {
|
|
101
|
+
if (message.stopReason === "aborted" || message.stopReason === "error") {
|
|
102
|
+
tool.updateResult({ content: [{ type: "text", text: message.errorMessage || "Error" }], isError: true });
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
tool.updateArgs(block.arguments);
|
|
106
|
+
tool.setArgsComplete();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
reset(transcript) {
|
|
112
|
+
this.transcript = transcript;
|
|
113
|
+
this.chat.clear();
|
|
114
|
+
this.tools.clear();
|
|
115
|
+
this.partials.clear();
|
|
116
|
+
this.streaming = undefined;
|
|
117
|
+
this.rendered = 0;
|
|
118
|
+
this.version = -1;
|
|
119
|
+
const intro = transcript.intro;
|
|
120
|
+
if (!intro)
|
|
121
|
+
return;
|
|
122
|
+
this.add(new UserMessageComponent(intro.prompt, getMarkdownTheme()));
|
|
123
|
+
this.add(new Text(this.theme.fg("dim", [
|
|
124
|
+
`goal: ${plain(intro.goal)}`,
|
|
125
|
+
`context: ${intro.predecessors} predecessor output${intro.predecessors === 1 ? "" : "s"} · workspace ${plain(intro.workspace)}`,
|
|
126
|
+
].join("\n")), 1, 0));
|
|
127
|
+
}
|
|
128
|
+
/** One blank line between turns: after a finished reply, or before a new message. */
|
|
129
|
+
gap() {
|
|
130
|
+
const last = this.chat.children.at(-1);
|
|
131
|
+
if (last && !(last instanceof Spacer))
|
|
132
|
+
this.add(new Spacer(1));
|
|
133
|
+
}
|
|
134
|
+
add(component) {
|
|
135
|
+
this.chat.addChild(component);
|
|
136
|
+
return component;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/dist/shell-tools.js
CHANGED
|
@@ -32,15 +32,22 @@ function managedOperations(resolveShell, prefix = "") {
|
|
|
32
32
|
if (!child.pid)
|
|
33
33
|
return;
|
|
34
34
|
if (process.platform === "win32") {
|
|
35
|
-
await new Promise(
|
|
35
|
+
const failure = await new Promise(resolve => {
|
|
36
36
|
execFile(join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"), ["/F", "/T", "/PID", String(child.pid)], { windowsHide: true }, error => {
|
|
37
37
|
// taskkill reports 128 when the process has already exited.
|
|
38
|
-
|
|
39
|
-
reject(error);
|
|
40
|
-
else
|
|
41
|
-
resolve();
|
|
38
|
+
resolve(error && error.code !== 128 ? error : undefined);
|
|
42
39
|
});
|
|
43
40
|
});
|
|
41
|
+
if (failure) {
|
|
42
|
+
// With /T, a descendant that exits during the tree walk makes taskkill
|
|
43
|
+
// fail although the shell was killed. Fail only if the shell survives.
|
|
44
|
+
const stopped = await Promise.race([
|
|
45
|
+
exited.then(() => true, () => true),
|
|
46
|
+
new Promise(resolve => setTimeout(() => resolve(false), 2000).unref()),
|
|
47
|
+
]);
|
|
48
|
+
if (!stopped)
|
|
49
|
+
throw failure;
|
|
50
|
+
}
|
|
44
51
|
}
|
|
45
52
|
else {
|
|
46
53
|
try {
|
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
/** Record human messages: delivered before a model request, or left over when the execution ended. */
|
|
49
|
+
human(executionId, texts, delivered) {
|
|
50
|
+
if (!texts.length)
|
|
51
|
+
return;
|
|
52
|
+
this.update(executionId, transcript => {
|
|
53
|
+
for (const text of texts)
|
|
54
|
+
transcript.messages.push({ role: "human", text, timestamp: Date.now(), delivered });
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Accepting messages: the execution's session has started and has not finished. */
|
|
58
|
+
isLive(executionId) {
|
|
59
|
+
const transcript = this.transcripts.get(executionId);
|
|
60
|
+
return transcript !== undefined && !transcript.finished;
|
|
61
|
+
}
|
|
62
|
+
/** Close interrupted streams and tools so the view does not show them as still running. */
|
|
63
|
+
finish(executionId, reason = "Execution ended before this step finished") {
|
|
64
|
+
if (executionId === undefined)
|
|
65
|
+
return;
|
|
66
|
+
this.update(executionId, transcript => {
|
|
67
|
+
if (transcript.streaming) {
|
|
68
|
+
transcript.messages.push({ ...transcript.streaming, stopReason: "aborted", errorMessage: reason });
|
|
69
|
+
delete transcript.streaming;
|
|
70
|
+
}
|
|
71
|
+
for (const callId of transcript.running.keys()) {
|
|
72
|
+
const call = transcript.messages.flatMap(message => message.role === "assistant" ? message.content : [])
|
|
73
|
+
.find(block => block.type === "toolCall" && block.id === callId);
|
|
74
|
+
transcript.messages.push({
|
|
75
|
+
role: "toolResult", toolCallId: callId, toolName: call?.type === "toolCall" ? call.name : "tool",
|
|
76
|
+
content: [{ type: "text", text: reason }], isError: true, timestamp: Date.now(),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
transcript.running.clear();
|
|
80
|
+
transcript.finished = true;
|
|
81
|
+
if (!this.finishedIds.includes(executionId))
|
|
82
|
+
this.finishedIds.push(executionId);
|
|
83
|
+
while (this.finishedIds.length > this.maxFinished)
|
|
84
|
+
this.transcripts.delete(this.finishedIds.shift());
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** Token and output bursts coalesce into at most one notification per 100 ms. */
|
|
88
|
+
update(executionId, change, burst = false) {
|
|
89
|
+
const transcript = this.transcripts.get(executionId);
|
|
90
|
+
if (!transcript || transcript.finished)
|
|
91
|
+
return;
|
|
92
|
+
try {
|
|
93
|
+
change(transcript);
|
|
94
|
+
transcript.version++;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Display state must never affect node execution.
|
|
98
|
+
}
|
|
99
|
+
if (!burst)
|
|
100
|
+
this.changed();
|
|
101
|
+
else if (!this.pending) {
|
|
102
|
+
this.pending = setTimeout(() => this.changed(), 100);
|
|
103
|
+
this.pending.unref?.();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
changed() {
|
|
107
|
+
clearTimeout(this.pending);
|
|
108
|
+
this.pending = undefined;
|
|
109
|
+
try {
|
|
110
|
+
this.onChange();
|
|
111
|
+
}
|
|
112
|
+
catch { /* Observers cannot fail a worker. */ }
|
|
113
|
+
}
|
|
114
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrok/pi-braid",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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.
|
|
9
|
+
"@chrok/braid": "0.3.4"
|
|
10
10
|
},
|
|
11
11
|
"peerDependencies": {
|
|
12
12
|
"@earendil-works/pi-ai": "*",
|