@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 CHANGED
@@ -26,6 +26,8 @@ It registers:
26
26
  - `braid_cancel` — cancel a job with `{ "jobId": "..." }`.
27
27
  - `/braid [jobId]` — open a live flow panel in interactive Pi.
28
28
  - `/braid:review [target or focus]` — run a bounded parallel review/fix loop.
29
+ - `/braid:clear` — clear the progress widget above the editor; run it again while
30
+ the widget is empty to restore what was cleared.
29
31
 
30
32
  Submission and completion reminders use short session handles such as `job-1`.
31
33
  Status, cancellation and the panel accept either that exact handle or the original
@@ -212,7 +214,10 @@ Braid job-1 · running · 2/5 done · review
212
214
  The widget shows node progress, active node IDs, paused gates, and failures.
213
215
  It shows up to three active jobs, with an overflow count for the rest. When no
214
216
  jobs are active, the newest job's final completed/failed/cancelled status stays
215
- visible until another job is submitted or the session ends. Counts use the
217
+ visible until another job is submitted or the session ends. Run `/braid:clear`
218
+ to clear the widget: it clears every job submitted so far, including running
219
+ ones, and jobs submitted afterwards still appear. Running it again while the widget
220
+ is empty restores the most recently cleared jobs. Counts use the
216
221
  latest state of each current node definition; failed and skipped nodes count as
217
222
  done, and reruns or live edits can change progress and totals. RPC, JSON, and
218
223
  print modes do not create a terminal widget.
@@ -225,11 +230,81 @@ Use Left/Right to select jobs, Up/Down or Page Up/Page Down to scroll, `c` to
225
230
  cancel the selected job, and Escape or `q` to close the panel. Closing the panel
226
231
  leaves jobs running. In RPC or noninteractive modes, use `braid_status`.
227
232
 
228
- Run `/braid <jobId> <nodeId>` to open the panel on one node's latest invocation,
229
- or `/braid <jobId> <executionId>` for an exact invocation such as an earlier loop
230
- iteration. The node view refreshes live with its status, model, elapsed time,
231
- token usage, context size, tool-call count, workspace, and full output or error.
232
- Press Backspace to return to the whole graph; switching jobs also returns to it.
233
+ A flowchart wider than the terminal is cropped to the terminal width around the
234
+ first running node, or from its left edge; a `[chart 1–76 of 245 cols]` line
235
+ shows the visible columns.
236
+
237
+ Press `v` to select a node: the arrow keys move a `◆` marker through the
238
+ flowchart (starting at a running node), and the view pans horizontally and
239
+ scrolls vertically to keep the selected node in sight. If the flowchart cannot be
240
+ drawn, a node list replaces it while selecting. Enter opens that node's latest
241
+ invocation as a live session. The session is drawn with Pi's own chat
242
+ components, as in the main conversation: the worker's prompt, streamed
243
+ assistant text and thinking, and tool cards whose arguments and output update
244
+ while they run. The node summary—status, elapsed time, usage, context, and live
245
+ activity (see below)—stays pinned at the top while the session scrolls. The
246
+ session follows the newest lines; scroll up to pause, and press End to follow again. Left/Right switch between
247
+ invocations of the same node (for example loop iterations), and ctrl+o expands
248
+ tool output. Escape steps back one level—session, then node selection, then the
249
+ graph—and closes the panel from the graph; `q` closes it from anywhere. Running
250
+ invocations and the 20 most recently finished ones per job keep their session;
251
+ older ones show the summary and activity history.
252
+
253
+ ### Assistant
254
+
255
+ Press Tab to open an assistant and Tab again to hide it. While it is open,
256
+ every key goes to it, including `q` and `c`; type a question and press Enter.
257
+ Escape stops an answer in progress, or hides the assistant. PgUp/PgDn or the
258
+ mouse wheel scroll the conversation. A panel at least 100 columns wide shows
259
+ the assistant beside the graph or session; a narrower one shows it in their
260
+ place.
261
+
262
+ Conversations are scoped. On the graph, the assistant talks about the whole
263
+ job. With a node selected (`v`) or its session open, it uses that node's own
264
+ conversation, shared by all of the node's executions; each question says which
265
+ execution is shown. Each scope keeps its history for the Pi session, including
266
+ while the assistant or the panel is hidden.
267
+
268
+ In fullscreen mode the mouse wheel also scrolls the left side: the graph, a
269
+ node session, or a node's details and history. Scrolling a session up stops it
270
+ following new output; scrolling back to the bottom resumes.
271
+
272
+ The assistant uses the main session's current model. It does not see the main
273
+ conversation; each question tells it what the panel shows (the graph, the
274
+ selected node, or the open session), and it reads the job through its own
275
+ tools: job status and graph, an execution's details and activity, and the
276
+ latest messages of a worker's conversation. It cannot edit the graph, cancel,
277
+ or resume; ask the main agent for that.
278
+
279
+ To steer a running execution, tell the assistant what it should do. The
280
+ assistant messages that execution for you, and only when you ask. The message
281
+ waits, shown as pending in the execution's session, and is delivered before the
282
+ worker's next model request. A tool batch that is running finishes first and is
283
+ never interrupted. If it arrives while the worker is writing its final answer,
284
+ the worker gets one more round to take it into account. The worker receives it
285
+ labelled as a message from the supervising human, separate from the node prompt
286
+ and predecessor outputs; it cannot lift tool, budget, workspace, or decision
287
+ constraints (a decision node that has already called `decide` keeps its
288
+ choice). Delivered messages appear in the session, in the activity history, in
289
+ the event log, and in the execution's `humanInputs` in `braid_status` results,
290
+ so it stays visible that the result did not come from the graph alone. Messages
291
+ still pending when the execution ends are marked as not delivered and are not
292
+ recorded as input. Finished executions, including those held by `pauseAfter`,
293
+ cannot be messaged.
294
+
295
+ Run `/braid <jobId> <nodeId>` to open that node's latest invocation directly in
296
+ the session view, or `/braid <jobId> <executionId>` for an exact invocation such
297
+ as an earlier loop iteration. The header shows live activity for that exact invocation: the current phase
298
+ (workspace preparation, worker, model request, tool, or checkpoint/cleanup) and
299
+ how long it has been in it, time since the last observed signal, total time, the
300
+ in-flight model request (sent, awaiting the first stream event, or streaming
301
+ text/reasoning/tool-call arguments with a character count and latest tail), and
302
+ running tools with their arguments, elapsed time, and latest output. Focused
303
+ `braid_status` reads, and invocations whose session is no longer retained, add a
304
+ chronological history of model requests and responses, assistant text, tool calls
305
+ and results, workspace changes, and completion, with bounded detail previews.
306
+ A quiet period is shown as elapsed time, not declared a stall. If the host cannot
307
+ report a signal—for example a model registry without streaming—the view says so.
233
308
  Tab completes job handles and then that job's node IDs.
234
309
  IDs with leading/trailing whitespace, terminal controls, or a leading double
235
310
  quote use JSON string syntax, such as `/braid job-1 " review "` or
@@ -241,7 +316,10 @@ estimates or provider-reported usage, context-window sizes, filesystem tool-call
241
316
  counts, and the execution log. Active nodes are marked `▶ ACTIVE`. The status
242
317
  tool also renders a flowchart; expand its result to see more log events.
243
318
  When selecting `nodeId` or `executionId`, it instead shows that invocation's
244
- output/error, with the full text available on expansion.
319
+ output/error and activity, with the full text and history available on expansion.
320
+ The JSON includes `activity` with the latest 40 history entries; pass
321
+ `activityBefore` with an entry sequence to page earlier ones. While the invocation
322
+ runs, `activity` comes before `node` so it survives preview truncation.
245
323
 
246
324
  `/braid` now opens this panel; it no longer arms the next prompt. To request
247
325
  Braid explicitly, ask the agent to analyze the task using Braid.
@@ -0,0 +1,345 @@
1
+ import { Type, validateToolCall, } from "@earendil-works/pi-ai";
2
+ import { truncateHead } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ /** Bounds a single answer, independently of how the model behaves. */
5
+ const MAX_ROUNDS = 24;
6
+ const ACTIVITY_ENTRIES = 40;
7
+ const TRANSCRIPT_MESSAGES = 20;
8
+ const MESSAGE_PREVIEW = 1500;
9
+ const id = (description) => Type.String({ minLength: 1, description });
10
+ const TOOLS = [
11
+ {
12
+ name: "job_status",
13
+ description: "Read this job's goal, status, revision, graph definition (node prompts and edges), the latest result per node, and every execution with its status.",
14
+ parameters: Type.Object({}, { additionalProperties: false }),
15
+ },
16
+ {
17
+ name: "execution_details",
18
+ description: "Read one execution: status, output or error, decision, workspace, usage, human inputs, and live activity (phase, model/tool waits, recent history). Pass executionId, or nodeId for the node's latest execution.",
19
+ parameters: Type.Object({
20
+ executionId: Type.Optional(id("Exact execution ID.")),
21
+ nodeId: Type.Optional(id("Node ID; reads its latest execution.")),
22
+ activityBefore: Type.Optional(Type.Integer({ minimum: 1, description: "Page activity entries before this sequence number." })),
23
+ }, { additionalProperties: false }),
24
+ },
25
+ {
26
+ name: "execution_transcript",
27
+ description: "Read the latest messages of one execution's worker conversation: assistant text, tool calls and results, and human messages. Available while it runs and for recently finished executions.",
28
+ parameters: Type.Object({
29
+ executionId: id("Exact execution ID."),
30
+ last: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: `Number of latest messages; default ${TRANSCRIPT_MESSAGES}.` })),
31
+ }, { additionalProperties: false }),
32
+ },
33
+ {
34
+ name: "message_execution",
35
+ description: "Send a message to a running execution's worker on the user's behalf. It is delivered before the worker's next model request (a running tool batch finishes first) and is recorded as human input on the execution. Use only when the user asks to guide, correct, or inform a running execution. Write a self-contained instruction the worker can act on; it cannot see this conversation.",
36
+ parameters: Type.Object({
37
+ executionId: id("Exact ID of a running execution."),
38
+ message: id("The instruction for the worker."),
39
+ }, { additionalProperties: false }),
40
+ },
41
+ ];
42
+ const short = (value) => typeof value === "string" ? (value.length > 13 ? `${value.slice(0, 8)}…` : value) : "?";
43
+ const target = (args) => args.executionId !== undefined ? `execution ${short(args.executionId)}` : args.nodeId !== undefined ? `node ${JSON.stringify(args.nodeId)}` : "";
44
+ /** Compact tool cards: what was read or sent, with full results on ctrl+o. */
45
+ function renderer(call) {
46
+ return {
47
+ renderCall: (args, theme) => new Text(call(args ?? {}, theme), 0, 0),
48
+ renderResult: (result, options, theme) => {
49
+ const text = textOf(result.content);
50
+ const first = text.split("\n").find(line => line.trim()) ?? "";
51
+ return new Text(options.expanded ? text : theme.fg("dim", first.length > 160 ? `${first.slice(0, 159)}…` : first), 0, 0);
52
+ },
53
+ };
54
+ }
55
+ const RENDERERS = new Map([
56
+ ["job_status", renderer((_args, theme) => theme.fg("toolTitle", theme.bold("Read job status")))],
57
+ ["execution_details", renderer((args, theme) => theme.fg("toolTitle", theme.bold("Read details")) + " " + theme.fg("muted", target(args)))],
58
+ ["execution_transcript", renderer((args, theme) => theme.fg("toolTitle", theme.bold("Read conversation")) + " " + theme.fg("muted", `of ${target(args)}`))],
59
+ ["message_execution", renderer((args, theme) => theme.fg("warning", theme.bold("✎ Message")) + " " + theme.fg("muted", `to ${target(args)}`) +
60
+ (typeof args.message === "string" ? `\n${args.message}` : ""))],
61
+ ]);
62
+ const clip = (text, max = MESSAGE_PREVIEW) => text.length <= max ? text : `${text.slice(0, max)}… [${text.length - max} more characters]`;
63
+ const textOf = (content) => content.flatMap(block => block.type === "text" && block.text ? [block.text] : []).join("\n");
64
+ function describeTranscript(transcript, last) {
65
+ const lines = [];
66
+ const messages = transcript.messages.slice(-last);
67
+ if (messages.length < transcript.messages.length)
68
+ lines.push(`[${transcript.messages.length - messages.length} earlier messages omitted]`);
69
+ else
70
+ lines.push(`[node prompt] ${clip(transcript.intro.prompt)}`);
71
+ for (const message of messages) {
72
+ if (message.role === "human")
73
+ lines.push(`[human input · ${message.delivered ? "delivered" : "not delivered"}] ${clip(message.text)}`);
74
+ else if (message.role === "toolResult")
75
+ lines.push(`[${message.toolName} ${message.isError ? "error" : "result"}] ${clip(textOf(message.content), 800)}`);
76
+ else if (message.role === "assistant") {
77
+ for (const block of message.content) {
78
+ if (block.type === "text" && block.text.trim())
79
+ lines.push(`[assistant] ${clip(block.text)}`);
80
+ else if (block.type === "toolCall")
81
+ lines.push(`[tool call ${block.name}] ${clip(JSON.stringify(block.arguments), 400)}`);
82
+ }
83
+ if (message.stopReason === "error" || message.stopReason === "aborted")
84
+ lines.push(`[${message.stopReason}] ${message.errorMessage ?? ""}`);
85
+ }
86
+ }
87
+ if (transcript.streaming)
88
+ lines.push(`[assistant, streaming] ${clip(textOf(transcript.streaming.content))}`);
89
+ for (const callId of transcript.running.keys())
90
+ lines.push(`[tool running] ${callId}`);
91
+ lines.push(transcript.finished ? "[execution finished]" : "[execution running]");
92
+ return lines.join("\n");
93
+ }
94
+ /**
95
+ * A side conversation about one Braid job, or about one node of it. Each
96
+ * scope keeps its own history. It inspects the job through
97
+ * read-only tools and can message running executions; graph changes,
98
+ * cancellation, and resumption stay with the main agent.
99
+ */
100
+ export class BraidAssistant {
101
+ jobs;
102
+ jobId;
103
+ onChange;
104
+ nodeId;
105
+ conversation;
106
+ history = [];
107
+ queued = [];
108
+ controller;
109
+ pending;
110
+ disposed = false;
111
+ constructor(jobs, jobId, cwd, onChange,
112
+ /** The node this conversation is about; absent for the whole job. */
113
+ nodeId) {
114
+ this.jobs = jobs;
115
+ this.jobId = jobId;
116
+ this.onChange = onChange;
117
+ this.nodeId = nodeId;
118
+ this.conversation = { cwd, messages: [], running: new Map(), renderers: RENDERERS, version: 0 };
119
+ }
120
+ get busy() {
121
+ return this.controller !== undefined;
122
+ }
123
+ /** Ask a question or request an intervention. `view` describes what the panel shows. */
124
+ send(text, view, model) {
125
+ if (this.disposed || !text.trim())
126
+ return;
127
+ this.conversation.messages.push({ role: "user", content: text, timestamp: Date.now() });
128
+ this.queued.push({ text, view });
129
+ this.changed();
130
+ if (!this.controller)
131
+ void this.run(model);
132
+ }
133
+ /** Stop the current answer; queued questions are dropped. */
134
+ abort() {
135
+ this.queued = [];
136
+ this.controller?.abort();
137
+ }
138
+ dispose() {
139
+ this.disposed = true;
140
+ this.abort();
141
+ clearTimeout(this.pending);
142
+ }
143
+ systemPrompt() {
144
+ const job = this.jobs.get(this.jobId);
145
+ return "You are the Braid assistant inside the /braid panel of Pi. You help the user understand and steer one background Braid job " +
146
+ `(${job?.handle ?? this.jobId}: ${JSON.stringify(job?.goal ?? "")}). ` +
147
+ "A Braid job is a graph of nodes; each run of a node is an execution with its own isolated worker conversation. " +
148
+ "Use your tools to inspect the job instead of guessing, and cite node and execution IDs. " +
149
+ "When the user asks to guide, correct, or inform a running execution, use message_execution with a clear, self-contained instruction. " +
150
+ "If it is unclear which execution they mean, or none is running, say so instead of guessing. Never message an execution on your own initiative. " +
151
+ "Messages reach the worker before its next model request; they cannot change finished executions, the graph, or a decision already made. " +
152
+ "You cannot edit the graph, cancel, or resume; tell the user to ask the main Pi agent for that. " +
153
+ "You do not see the main conversation. Answer concisely, in the user's language." +
154
+ (this.nodeId === undefined
155
+ ? " This conversation is about the whole job; separate conversations exist for individual nodes."
156
+ : ` This conversation is about node ${JSON.stringify(this.nodeId)} and all of its executions; when the user says "it" or "this node", they mean this node. ` +
157
+ "Each question says which execution the panel shows. You may still read other nodes for context, but message only this node's executions unless the user names another.");
158
+ }
159
+ async run(model) {
160
+ const controller = new AbortController();
161
+ this.controller = controller;
162
+ try {
163
+ while (this.queued.length && !controller.signal.aborted) {
164
+ for (const { text, view } of this.queued.splice(0)) {
165
+ this.history.push({ role: "user", content: `[Panel view: ${view}]\n\n${text}`, timestamp: Date.now() });
166
+ }
167
+ await this.answer(model, controller.signal);
168
+ }
169
+ }
170
+ catch (error) {
171
+ const message = error instanceof Error ? error.message : String(error);
172
+ this.closeToolCalls();
173
+ this.fail(controller.signal.aborted ? "aborted" : "error", controller.signal.aborted ? "Stopped" : message);
174
+ }
175
+ finally {
176
+ this.controller = undefined;
177
+ this.conversation.running.clear();
178
+ this.changed();
179
+ }
180
+ }
181
+ async answer(source, signal) {
182
+ const model = source.model;
183
+ if (!model)
184
+ throw new Error("Choose a Pi model with /model to use the Braid assistant");
185
+ for (let round = 0; round < MAX_ROUNDS; round++) {
186
+ signal.throwIfAborted();
187
+ const context = { systemPrompt: this.systemPrompt(), messages: this.history, tools: TOOLS };
188
+ const options = { signal, maxRetries: 0 };
189
+ let response;
190
+ if (source.registry.stream) {
191
+ const stream = source.registry.stream(model, context, options);
192
+ for await (const event of stream) {
193
+ if (event.type === "done" || event.type === "error")
194
+ continue;
195
+ this.conversation.streaming = event.partial;
196
+ this.changed(true);
197
+ }
198
+ response = await stream.result();
199
+ }
200
+ else {
201
+ response = await source.registry.complete(model, context, options);
202
+ }
203
+ delete this.conversation.streaming;
204
+ this.conversation.messages.push(response);
205
+ this.changed();
206
+ if (response.stopReason === "aborted")
207
+ signal.throwIfAborted();
208
+ // Failed responses are shown, but never sent back to the provider.
209
+ if (response.stopReason === "error" || response.stopReason === "aborted")
210
+ return;
211
+ this.history.push(response);
212
+ const calls = response.content.filter((block) => block.type === "toolCall");
213
+ if (!calls.length)
214
+ return;
215
+ for (const call of calls) {
216
+ signal.throwIfAborted();
217
+ this.conversation.running.set(call.id, undefined);
218
+ this.changed();
219
+ const result = this.execute(call);
220
+ this.conversation.running.delete(call.id);
221
+ this.history.push(result);
222
+ this.conversation.messages.push(result);
223
+ this.changed();
224
+ }
225
+ }
226
+ this.fail("error", `Stopped after ${MAX_ROUNDS} tool rounds`);
227
+ }
228
+ execute(call) {
229
+ let text;
230
+ let isError = false;
231
+ try {
232
+ const args = validateToolCall(TOOLS, call);
233
+ text = this.tool(call.name, args);
234
+ }
235
+ catch (error) {
236
+ text = error instanceof Error ? error.message : String(error);
237
+ isError = true;
238
+ }
239
+ return { role: "toolResult", toolCallId: call.id, toolName: call.name, content: [{ type: "text", text }], isError, timestamp: Date.now() };
240
+ }
241
+ tool(name, args) {
242
+ const job = this.jobs.get(this.jobId);
243
+ if (!job)
244
+ throw this.jobs.unknownJob(this.jobId);
245
+ const preview = (value) => {
246
+ const full = JSON.stringify(value, null, 2);
247
+ const result = truncateHead(full);
248
+ return result.truncated ? `${result.content}\n[Truncated; ask about a specific node or execution.]` : full;
249
+ };
250
+ switch (name) {
251
+ case "job_status": {
252
+ const state = job.result ?? job.execution;
253
+ return preview({
254
+ handle: job.handle, goal: job.goal, status: job.status, error: job.error ?? state?.error,
255
+ revision: job.execution?.revision, pausedExecutionIds: job.execution?.pausedExecutionIds,
256
+ graph: job.execution?.graph,
257
+ nodes: state?.nodes,
258
+ executions: Object.values(state?.executions ?? {}).map(execution => ({
259
+ executionId: execution.executionId, nodeId: execution.id, status: execution.status,
260
+ ...(execution.iteration ? { iteration: execution.iteration } : {}),
261
+ ...(execution.humanInputs?.length ? { humanInputs: execution.humanInputs.length } : {}),
262
+ })),
263
+ });
264
+ }
265
+ case "execution_details": {
266
+ if (args.executionId === undefined && args.nodeId === undefined)
267
+ throw new Error("Pass executionId or nodeId");
268
+ const node = this.jobs.getNode(this.jobId, args.nodeId, args.executionId);
269
+ const activity = node.executionId ? this.jobs.getActivity(this.jobId, node.executionId, {
270
+ limit: ACTIVITY_ENTRIES, ...(args.activityBefore === undefined ? {} : { before: args.activityBefore }),
271
+ }) : undefined;
272
+ return preview({ node, ...(activity ? { activity } : {}) });
273
+ }
274
+ case "execution_transcript": {
275
+ const transcript = this.jobs.getTranscript(this.jobId, args.executionId);
276
+ if (!transcript)
277
+ throw new Error("No live conversation is retained for this execution; use execution_details for its output and activity history.");
278
+ return describeTranscript(transcript, args.last ?? TRANSCRIPT_MESSAGES);
279
+ }
280
+ case "message_execution": {
281
+ this.jobs.sendInput(this.jobId, args.executionId, args.message);
282
+ return "Queued. The worker receives it before its next model request, after any running tool batch.";
283
+ }
284
+ default:
285
+ throw new Error(`Unknown tool ${name}`);
286
+ }
287
+ }
288
+ /** Answer tool calls interrupted by an abort, so the next request is well formed. */
289
+ closeToolCalls() {
290
+ let index = this.history.length - 1;
291
+ while (index >= 0 && this.history[index].role !== "assistant")
292
+ index--;
293
+ const last = this.history[index];
294
+ if (last?.role !== "assistant")
295
+ return;
296
+ const answered = new Set(this.history.slice(index + 1).flatMap(message => message.role === "toolResult" ? [message.toolCallId] : []));
297
+ for (const block of last.content) {
298
+ if (block.type !== "toolCall" || answered.has(block.id))
299
+ continue;
300
+ const result = {
301
+ role: "toolResult", toolCallId: block.id, toolName: block.name,
302
+ content: [{ type: "text", text: "Stopped by the user before this tool ran" }], isError: true, timestamp: Date.now(),
303
+ };
304
+ this.history.push(result);
305
+ this.conversation.messages.push(result);
306
+ }
307
+ }
308
+ fail(stopReason, errorMessage) {
309
+ const partial = this.conversation.streaming;
310
+ delete this.conversation.streaming;
311
+ const message = {
312
+ role: "assistant", api: partial?.api ?? "braid", provider: partial?.provider ?? "braid", model: partial?.model ?? "assistant",
313
+ content: partial?.content ?? [], stopReason, errorMessage, timestamp: Date.now(),
314
+ usage: partial?.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
315
+ };
316
+ // The model sees what was said, without a dangling unanswered tool call.
317
+ if (message.content.some(block => block.type === "toolCall"))
318
+ message.content = message.content.filter(block => block.type !== "toolCall");
319
+ this.conversation.messages.push(message);
320
+ if (message.content.length)
321
+ this.history.push({ ...message, stopReason: "stop" });
322
+ this.changed();
323
+ }
324
+ /** Token bursts coalesce into at most one notification per 100 ms. */
325
+ changed(burst = false) {
326
+ this.conversation.version++;
327
+ if (!burst) {
328
+ clearTimeout(this.pending);
329
+ this.pending = undefined;
330
+ this.emit();
331
+ }
332
+ else if (!this.pending) {
333
+ this.pending = setTimeout(() => { this.pending = undefined; this.emit(); }, 100);
334
+ this.pending.unref?.();
335
+ }
336
+ }
337
+ emit() {
338
+ if (this.disposed)
339
+ return;
340
+ try {
341
+ this.onChange();
342
+ }
343
+ catch { /* Observers cannot fail the assistant. */ }
344
+ }
345
+ }