@spinabot/brigade 0.1.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -0
  4. package/SECURITY.md +208 -0
  5. package/assets/brigade-wordmark-on-black.png +0 -0
  6. package/assets/brigade-wordmark.png +0 -0
  7. package/brigade.mjs +96 -0
  8. package/dist/cli/chat-cmd.js +120 -0
  9. package/dist/cli/config-cmd.js +132 -0
  10. package/dist/cli/connect-cmd.js +447 -0
  11. package/dist/cli/doctor-cmd.js +317 -0
  12. package/dist/cli/gateway-cmd.js +92 -0
  13. package/dist/cli.js +287 -0
  14. package/dist/core/agent.js +1123 -0
  15. package/dist/core/config.js +80 -0
  16. package/dist/core/console-stream.js +188 -0
  17. package/dist/core/error-classifier.js +354 -0
  18. package/dist/core/event-logger.js +122 -0
  19. package/dist/core/model-caps.js +185 -0
  20. package/dist/core/provider-payload-mutators.js +517 -0
  21. package/dist/core/provider-quirks.js +285 -0
  22. package/dist/core/server.js +459 -0
  23. package/dist/core/smart-compaction.js +209 -0
  24. package/dist/core/system-prompt-defaults.js +88 -0
  25. package/dist/core/system-prompt-guidance.js +269 -0
  26. package/dist/core/system-prompt.js +884 -0
  27. package/dist/index.js +30 -0
  28. package/dist/integrations/ollama.js +140 -0
  29. package/dist/protocol.js +49 -0
  30. package/dist/providers/catalog.js +100 -0
  31. package/dist/providers/validate-key.js +197 -0
  32. package/dist/tui/client.js +263 -0
  33. package/dist/ui/brand-frames-cli.js +20 -0
  34. package/dist/ui/brand-frames.js +36 -0
  35. package/dist/ui/brand.js +402 -0
  36. package/dist/ui/chat.js +929 -0
  37. package/dist/ui/onboarding.js +400 -0
  38. package/dist/ui/theme.js +51 -0
  39. package/package.json +92 -0
@@ -0,0 +1,929 @@
1
+ /**
2
+ * Brigade chat TUI.
3
+ *
4
+ * Pi-TUI components, glued to the Pi Agent event stream:
5
+ * - Status header (model · token usage · cost)
6
+ * - Conversation log (Markdown components, one per assistant turn)
7
+ * - Tool call indicators (Text components inserted inline)
8
+ * - Loader (CancellableLoader during agent thinking)
9
+ * - Editor (multi-line input with history)
10
+ * - Footer hint
11
+ *
12
+ * Streaming: each `message_delta` event updates the current Markdown component.
13
+ * Differential rendering means only changed lines repaint — no flicker.
14
+ */
15
+ import * as path from "node:path";
16
+ import { CancellableLoader, Editor, Input, Markdown, SelectList, Text, } from "@mariozechner/pi-tui";
17
+ import chalk from "chalk";
18
+ import { classifySensitiveStopReason, runWithContentQualityRetry, runWithFallback, runWithHeartbeat, runWithLengthContinuation, runWithStreamTimeout, runWithThinkingFallback, switchModelMidTurn, } from "../core/agent.js";
19
+ import { BRIGADE_DIR, loadConfig, saveConfig } from "../core/config.js";
20
+ import { cleanProviderError, describeModelCapabilities, pickStreamIdleMs } from "../core/model-caps.js";
21
+ import { discoverOllamaModels, writeOllamaToModelsJson } from "../integrations/ollama.js";
22
+ import { findProvider, PROVIDERS } from "../providers/catalog.js";
23
+ import { validateApiKeyOnline } from "../providers/validate-key.js";
24
+ import { renderBrandHeader } from "./brand.js";
25
+ import { brand, editorTheme, markdownTheme, selectListTheme } from "./theme.js";
26
+ const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
27
+ export async function runChat(opts) {
28
+ const { session, tui, authStorage, modelRegistry } = opts;
29
+ let provider = opts.provider;
30
+ let modelId = opts.modelId;
31
+ // ── brand wordmark at the top of the chat screen ────────────────────
32
+ // Same chunky 4-stop metallic wordmark from onboarding. Pi-TUI is
33
+ // stream-based, so this appears once at boot and naturally scrolls
34
+ // out of view as the conversation grows below.
35
+ renderBrandHeader(tui);
36
+ // ── status header (model · tokens · cost) ───────────────────────────
37
+ const header = new Text("", 0, 0);
38
+ tui.addChild(header);
39
+ const divider = new Text(brand.dim("─".repeat(80)), 0, 0);
40
+ tui.addChild(divider);
41
+ // usage state we render in the header
42
+ let totalIn = 0;
43
+ let totalOut = 0;
44
+ let totalCost = 0;
45
+ // Cost is only displayed when the provider reports it. Many free-tier providers
46
+ // (Groq, free Gemini tier, Ollama) don't report usage cost, so we hide the field
47
+ // instead of permanently showing $0.0000 — which would mislead users.
48
+ //
49
+ // Also includes per-model capabilities (thinking level, vision, ctx, $/Mtok)
50
+ // derived from the live `session.model` + `session.thinkingLevel` so it stays
51
+ // accurate across thinking-level changes and (future) model swaps.
52
+ const updateHeader = (extra) => {
53
+ const providerName = findProvider(provider)?.name ?? provider;
54
+ const caps = session.model
55
+ ? describeModelCapabilities(session.model, session.thinkingLevel)
56
+ : "";
57
+ const capsStr = caps ? ` · ${caps}` : "";
58
+ const tokens = totalIn + totalOut > 0 ? ` · ${(totalIn + totalOut).toLocaleString()} tok` : "";
59
+ const cost = totalCost > 0 ? ` · $${totalCost.toFixed(4)}` : "";
60
+ // Live context usage. Pi recomputes this after each LLM response, so
61
+ // the percentage stays accurate as the conversation grows. We only
62
+ // show it once it crosses 50% — below that the user doesn't need to
63
+ // worry. Highlights in amber at 75%+ to warn before auto-compact fires.
64
+ const usage = session.getContextUsage();
65
+ let usageStr = "";
66
+ if (usage?.percent != null && usage.percent >= 50) {
67
+ const pct = Math.round(usage.percent);
68
+ const colored = pct >= 75 ? brand.amber(`${pct}% ctx`) : brand.dim(`${pct}% ctx`);
69
+ usageStr = ` · ${colored}`;
70
+ }
71
+ const tail = extra ? ` · ${extra}` : "";
72
+ header.setText(` ${brand.amber("●")} ${brand.white("Brigade")} ${brand.dim(`${providerName} · ${modelId}${capsStr}${tokens}${cost}`)}${usageStr}${brand.dim(tail)}`);
73
+ };
74
+ updateHeader();
75
+ // ── editor ──────────────────────────────────────────────────────────
76
+ const editor = new Editor(tui, editorTheme);
77
+ tui.addChild(editor);
78
+ tui.setFocus(editor);
79
+ // Hint line below the editor
80
+ tui.addChild(new Text(brand.dim(" Enter to send · Ctrl+C abort · Ctrl+D quit · /model /provider /thinking /compact /help"), 0, 0));
81
+ // ── streaming state ─────────────────────────────────────────────────
82
+ let isAgentRunning = false;
83
+ let activeAssistant = null;
84
+ let activeLoader = null;
85
+ let pendingTools = new Map();
86
+ /**
87
+ * Extract concatenated text from an assistant message's content blocks.
88
+ * The cumulative content is what Pi maintains — we read from it instead of
89
+ * trying to stitch deltas ourselves, because not every provider emits
90
+ * `text_delta` events (some only emit `text_start` + `text_end`, and
91
+ * reasoning models intersperse `thinking_*` events). Reading from
92
+ * `event.message.content` is what Pi's own interactive mode does and is
93
+ * the only robust approach across providers.
94
+ */
95
+ const extractAssistantText = (message) => {
96
+ if (!message || !Array.isArray(message.content))
97
+ return "";
98
+ return message.content
99
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
100
+ .map((b) => b.text)
101
+ .join("");
102
+ };
103
+ /** Pull text from a user message — used by mid-turn /model switch to replay the last user message on the new model. */
104
+ const extractUserText = (message) => {
105
+ if (!message)
106
+ return "";
107
+ if (typeof message.content === "string")
108
+ return message.content;
109
+ if (!Array.isArray(message.content))
110
+ return "";
111
+ return message.content
112
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
113
+ .map((b) => b.text)
114
+ .join("");
115
+ };
116
+ const insertBeforeEditor = (component) => {
117
+ // children: [header, divider, ...messages, editor, hint]
118
+ const children = tui.children;
119
+ const editorIdx = children.indexOf(editor);
120
+ if (editorIdx < 0) {
121
+ tui.addChild(component);
122
+ }
123
+ else {
124
+ children.splice(editorIdx, 0, component);
125
+ tui.requestRender();
126
+ }
127
+ };
128
+ const removeChild = (component) => {
129
+ try {
130
+ tui.removeChild(component);
131
+ }
132
+ catch {
133
+ /* ignore */
134
+ }
135
+ };
136
+ /**
137
+ * Run an inline picker over the conversation: insert a SelectList right above
138
+ * the editor, steal focus, await a choice, then remove the list and any
139
+ * supporting label rows. Returns the chosen value, or `null` if the user
140
+ * pressed Esc/Ctrl+C. The chat stays scrolled and intact throughout —
141
+ * no clear-screen, no flash.
142
+ */
143
+ const inlinePick = async (title, items, opts = {}) => {
144
+ const labelRow = new Text(` ${brand.amber(title)}`, 0, 0);
145
+ insertBeforeEditor(labelRow);
146
+ const [minW, maxW] = opts.primaryWidth ?? [22, 38];
147
+ const list = new SelectList(items, Math.min(items.length, 12), selectListTheme, {
148
+ minPrimaryColumnWidth: minW,
149
+ maxPrimaryColumnWidth: maxW,
150
+ });
151
+ insertBeforeEditor(list);
152
+ tui.setFocus(list);
153
+ tui.requestRender();
154
+ try {
155
+ const chosen = await new Promise((resolve, reject) => {
156
+ list.onSelect = (item) => resolve(item);
157
+ list.onCancel = () => reject(new Error("cancel"));
158
+ });
159
+ return chosen._value;
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ finally {
165
+ removeChild(list);
166
+ removeChild(labelRow);
167
+ tui.setFocus(editor);
168
+ tui.requestRender();
169
+ }
170
+ };
171
+ /**
172
+ * Inline single-line text input — same lifecycle as inlinePick. Used for
173
+ * API-key entry, base-URL prompts, and the like during /provider.
174
+ * Returns the typed value, or `null` if the user pressed Esc.
175
+ */
176
+ const inlinePrompt = async (label, hint) => {
177
+ const labelRow = new Text(` ${brand.amber(label)}`, 0, 0);
178
+ insertBeforeEditor(labelRow);
179
+ const hintRow = hint ? new Text(brand.dim(` ${hint}`), 0, 0) : null;
180
+ if (hintRow)
181
+ insertBeforeEditor(hintRow);
182
+ const input = new Input();
183
+ insertBeforeEditor(input);
184
+ tui.setFocus(input);
185
+ tui.requestRender();
186
+ try {
187
+ return await new Promise((resolve, reject) => {
188
+ input.onSubmit = (value) => resolve(value.trim());
189
+ input.onEscape = () => reject(new Error("cancel"));
190
+ });
191
+ }
192
+ catch {
193
+ return null;
194
+ }
195
+ finally {
196
+ removeChild(input);
197
+ if (hintRow)
198
+ removeChild(hintRow);
199
+ removeChild(labelRow);
200
+ tui.setFocus(editor);
201
+ tui.requestRender();
202
+ }
203
+ };
204
+ /**
205
+ * Switch to a new (provider, modelId) pair. Resolves the model object via
206
+ * ModelRegistry, calls Pi's setModel (which validates auth + persists to
207
+ * session), and updates Brigade's local state + saved config so the next
208
+ * boot resumes here. Returns whether the switch succeeded.
209
+ */
210
+ const switchToModel = async (newProvider, newModelId) => {
211
+ const model = modelRegistry.find(newProvider, newModelId);
212
+ if (!model) {
213
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`No model "${newModelId}" found for provider "${newProvider}".`)}`, 0, 0));
214
+ return false;
215
+ }
216
+ try {
217
+ await session.setModel(model);
218
+ }
219
+ catch (err) {
220
+ const msg = err instanceof Error ? err.message : String(err);
221
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(msg)}`, 0, 0));
222
+ return false;
223
+ }
224
+ provider = newProvider;
225
+ modelId = newModelId;
226
+ await saveConfig({ defaultProvider: newProvider, defaultModelId: newModelId });
227
+ updateHeader();
228
+ insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim("switched to")} ${brand.white(`${newProvider} · ${newModelId}`)}`, 0, 0));
229
+ return true;
230
+ };
231
+ // ── replay: print prior conversation if resumed ────────────────────
232
+ for (const m of session.messages) {
233
+ if (m.role === "user" && Array.isArray(m.content)) {
234
+ const text = m.content
235
+ .filter((b) => b.type === "text")
236
+ .map((b) => b.text)
237
+ .join("");
238
+ if (text) {
239
+ insertBeforeEditor(new Markdown(`${brand.user("you")} ${text}`, 1, 0, markdownTheme));
240
+ }
241
+ }
242
+ else if (m.role === "assistant" && Array.isArray(m.content)) {
243
+ const text = m.content
244
+ .filter((b) => b.type === "text")
245
+ .map((b) => b.text)
246
+ .join("");
247
+ if (text) {
248
+ insertBeforeEditor(new Markdown(`${brand.agent("brigade")} ${text}`, 1, 0, markdownTheme));
249
+ }
250
+ }
251
+ }
252
+ // ── subscribe to Pi events ─────────────────────────────────────────
253
+ session.subscribe((event) => {
254
+ switch (event.type) {
255
+ case "agent_start": {
256
+ isAgentRunning = true;
257
+ editor.disableSubmit = true;
258
+ updateHeader("thinking…");
259
+ activeLoader = new CancellableLoader(tui, (s) => brand.amber(s), (s) => brand.dim(s), "thinking");
260
+ insertBeforeEditor(activeLoader);
261
+ break;
262
+ }
263
+ case "message_update": {
264
+ // Pull text from the cumulative message — works for every provider
265
+ // regardless of whether they emit text_delta, text_end, or just
266
+ // the final message all at once. This is the same pattern Pi's
267
+ // own interactive mode uses.
268
+ const msg = event.message;
269
+ if (!msg || msg.role !== "assistant")
270
+ break;
271
+ const text = extractAssistantText(msg);
272
+ if (!text)
273
+ break; // tool calls / thinking blocks only — nothing to render yet
274
+ if (activeLoader) {
275
+ removeChild(activeLoader);
276
+ activeLoader = null;
277
+ }
278
+ if (!activeAssistant) {
279
+ activeAssistant = new Markdown(`${brand.agent("brigade")} ${text}`, 1, 0, markdownTheme);
280
+ insertBeforeEditor(activeAssistant);
281
+ }
282
+ else {
283
+ activeAssistant.setText(`${brand.agent("brigade")} ${text}`);
284
+ tui.requestRender();
285
+ }
286
+ break;
287
+ }
288
+ case "message_end": {
289
+ // Final safety net: some providers don't stream incrementally —
290
+ // they only emit message_end with the full message. If we never
291
+ // rendered any text from message_update, render it now.
292
+ const msg = event.message;
293
+ if (!msg || msg.role !== "assistant")
294
+ break;
295
+ const text = extractAssistantText(msg);
296
+ if (!text)
297
+ break;
298
+ if (activeLoader) {
299
+ removeChild(activeLoader);
300
+ activeLoader = null;
301
+ }
302
+ if (!activeAssistant) {
303
+ activeAssistant = new Markdown(`${brand.agent("brigade")} ${text}`, 1, 0, markdownTheme);
304
+ insertBeforeEditor(activeAssistant);
305
+ }
306
+ else {
307
+ activeAssistant.setText(`${brand.agent("brigade")} ${text}`);
308
+ tui.requestRender();
309
+ }
310
+ break;
311
+ }
312
+ case "tool_execution_start": {
313
+ if (activeLoader) {
314
+ removeChild(activeLoader);
315
+ activeLoader = null;
316
+ }
317
+ const summary = formatToolArgs(event.toolName, event.args);
318
+ const indicator = new Text(` ${brand.tool("⚡")} ${brand.tool(event.toolName)} ${brand.dim(summary)}`, 0, 0);
319
+ pendingTools.set(event.toolCallId, indicator);
320
+ insertBeforeEditor(indicator);
321
+ break;
322
+ }
323
+ case "tool_execution_end": {
324
+ const indicator = pendingTools.get(event.toolCallId);
325
+ if (indicator) {
326
+ const mark = event.isError ? brand.error("✗") : brand.tool("✓");
327
+ indicator.setText(` ${mark} ${brand.tool(event.toolName)}`);
328
+ tui.requestRender();
329
+ pendingTools.delete(event.toolCallId);
330
+ }
331
+ break;
332
+ }
333
+ case "turn_end": {
334
+ const usage = event.message?.usage;
335
+ if (usage) {
336
+ totalIn += usage.input ?? 0;
337
+ totalOut += usage.output ?? 0;
338
+ totalCost += usage.cost ?? 0;
339
+ updateHeader();
340
+ }
341
+ break;
342
+ }
343
+ case "compaction_start": {
344
+ // Pi auto-compacts when context fills. Surface it so the user
345
+ // understands the brief pause + the assistant message that
346
+ // follows comes from a fresh summary.
347
+ const usage = session.getContextUsage();
348
+ const pct = usage?.percent != null ? `${Math.round(usage.percent)}%` : "high";
349
+ insertBeforeEditor(new Text(` ${brand.dim(`⚡ compacting context (was ${pct})…`)}`, 0, 0));
350
+ break;
351
+ }
352
+ case "compaction_end": {
353
+ const ev = event;
354
+ if (ev.aborted) {
355
+ insertBeforeEditor(new Text(` ${brand.dim("compaction aborted")}`, 0, 0));
356
+ }
357
+ else {
358
+ // Pi's getContextUsage returns null right after compaction by
359
+ // design — token estimates need a fresh LLM response to
360
+ // recalculate. Show that explicitly instead of a confusing "?".
361
+ const after = session.getContextUsage();
362
+ const afterStr = after?.percent != null
363
+ ? `usage now ${Math.round(after.percent)}%`
364
+ : "usage refreshes after your next message";
365
+ insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`compacted · ${afterStr}`)}`, 0, 0));
366
+ }
367
+ updateHeader();
368
+ break;
369
+ }
370
+ case "agent_end": {
371
+ isAgentRunning = false;
372
+ editor.disableSubmit = false;
373
+ activeAssistant = null;
374
+ if (activeLoader) {
375
+ removeChild(activeLoader);
376
+ activeLoader = null;
377
+ }
378
+ // Last-resort safety net: if no text was ever rendered (no message_update,
379
+ // no message_end), surface the final message text or any error message
380
+ // from the agent end event. Without this, a bad request leaves the user
381
+ // staring at "thinking…" gone-but-no-reply. Provider errors arrive as
382
+ // nested JSON blobs (Pi wraps the upstream response) — cleanProviderError
383
+ // peels them down to a single human-readable line.
384
+ const messages = event.messages;
385
+ if (Array.isArray(messages) && messages.length > 0) {
386
+ const last = messages[messages.length - 1];
387
+ if (last && last.role === "assistant") {
388
+ const text = extractAssistantText(last);
389
+ const errMsg = last.errorMessage;
390
+ // Sensitive stop reasons (refusal, content filter, policy block)
391
+ // produce empty content with a meaningful stopReason. Translate
392
+ // to a friendly message — without this the user sees nothing.
393
+ const sensitive = !text ? classifySensitiveStopReason(last) : null;
394
+ if (!text && errMsg) {
395
+ const cleaned = cleanProviderError(errMsg);
396
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(cleaned)}`, 0, 0));
397
+ }
398
+ else if (sensitive) {
399
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(sensitive.userMessage)}`, 0, 0));
400
+ }
401
+ else if (!text && (last.stopReason === "error" || last.stopReason === "aborted")) {
402
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`Agent ended with no reply (${last.stopReason})`)}`, 0, 0));
403
+ }
404
+ }
405
+ }
406
+ updateHeader();
407
+ break;
408
+ }
409
+ case "auto_retry_start": {
410
+ // Pi auto-retries transient provider errors (rate limit, 5xx,
411
+ // connection drop). Tell the user it's happening — without this,
412
+ // a slow retry looks like the model is just hanging.
413
+ const ev = event;
414
+ const attempt = ev.attempt ?? 1;
415
+ const max = ev.maxAttempts ?? 1;
416
+ const waitS = Math.round((ev.delayMs ?? 0) / 100) / 10;
417
+ insertBeforeEditor(new Text(` ${brand.dim(`↻ retrying (attempt ${attempt}/${max}, waiting ${waitS}s)…`)}`, 0, 0));
418
+ break;
419
+ }
420
+ case "auto_retry_end": {
421
+ const ev = event;
422
+ if (ev.success === false) {
423
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`gave up after ${ev.attempt} attempts`)}`, 0, 0));
424
+ }
425
+ break;
426
+ }
427
+ }
428
+ });
429
+ // ── input handling ─────────────────────────────────────────────────
430
+ editor.onSubmit = async (value) => {
431
+ const trimmed = value.trim();
432
+ if (!trimmed)
433
+ return;
434
+ // Mid-turn submit → STEER, not drop. Pi queues the message and the
435
+ // model sees it after the current tool round completes — no abort,
436
+ // no lost context. This is what "steer" means at the loop level and
437
+ // it's a key part of a production-grade chat UX.
438
+ if (isAgentRunning) {
439
+ // Slash commands during a turn are still handled locally — they
440
+ // shouldn't reach the model. (This block intentionally mirrors the
441
+ // post-loop set; if a new local command lands below, mirror it here.)
442
+ if (trimmed === "/exit" || trimmed === "/quit") {
443
+ tui.stop();
444
+ process.exit(0);
445
+ }
446
+ // /model <id> mid-turn → live model switch. Aborts the current
447
+ // turn cleanly, swaps the model, re-prompts with the user's
448
+ // original message. Hot-swap UX; way better than "abort, wait,
449
+ // /model, retype".
450
+ if (trimmed.startsWith("/model ")) {
451
+ editor.setText("");
452
+ const targetId = trimmed.slice("/model ".length).trim();
453
+ const matches = modelRegistry.getAvailable().filter((m) => m.id === targetId);
454
+ const target = matches.find((m) => m.provider === provider) ?? matches[0];
455
+ if (!target) {
456
+ insertBeforeEditor(new Text(` ${brand.error(`✗ no configured model with id "${targetId}". Type /model to list.`)}`, 0, 0));
457
+ return;
458
+ }
459
+ // Find the most recent user message to replay on the new model.
460
+ const lastUser = [...session.messages].reverse().find((m) => m.role === "user");
461
+ const replayMsg = lastUser ? extractUserText(lastUser) : "";
462
+ if (!replayMsg) {
463
+ insertBeforeEditor(new Text(` ${brand.error("✗ no user message to replay on the new model.")}`, 0, 0));
464
+ return;
465
+ }
466
+ insertBeforeEditor(new Text(` ${brand.dim(`↻ aborting current turn, switching to ${targetId}, re-running…`)}`, 0, 0));
467
+ try {
468
+ const swapped = await switchModelMidTurn(session, target, replayMsg);
469
+ if (!swapped) {
470
+ // Turn ended between our `isAgentRunning` check and switchModelMidTurn —
471
+ // the in-flight signal had already cleared. Fall back to a normal
472
+ // post-turn switch using the same code path the post-turn /model uses.
473
+ await switchToModel(target.provider, target.id);
474
+ return;
475
+ }
476
+ provider = target.provider;
477
+ modelId = target.id;
478
+ await saveConfig({ defaultProvider: provider, defaultModelId: modelId });
479
+ updateHeader();
480
+ }
481
+ catch (err) {
482
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`Switch failed: ${err instanceof Error ? err.message : String(err)}`)}`, 0, 0));
483
+ }
484
+ return;
485
+ }
486
+ editor.setText("");
487
+ session.agent.steer({
488
+ role: "user",
489
+ content: [{ type: "text", text: trimmed }],
490
+ });
491
+ insertBeforeEditor(new Markdown(`${brand.user("you")} ${trimmed}`, 1, 0, markdownTheme));
492
+ insertBeforeEditor(new Text(` ${brand.dim("↳ queued — the model will see this on its next turn")}`, 0, 0));
493
+ return;
494
+ }
495
+ // slash commands (handled locally, never sent to the model)
496
+ if (trimmed === "/exit" || trimmed === "/quit") {
497
+ tui.stop();
498
+ process.exit(0);
499
+ }
500
+ if (trimmed === "/clear") {
501
+ editor.setText("");
502
+ return;
503
+ }
504
+ if (trimmed === "/help") {
505
+ insertBeforeEditor(new Markdown(`${brand.dim("commands")}\n` +
506
+ `- ${chalk.bold("/exit")} or ${chalk.bold("/quit")} — quit Brigade\n` +
507
+ `- ${chalk.bold("/help")} — this list\n` +
508
+ `- ${chalk.bold("/clear")} — clear the input\n` +
509
+ `- ${chalk.bold("/model")} — switch to another configured model\n` +
510
+ `- ${chalk.bold("/model <id>")} — switch directly by model id\n` +
511
+ `- ${chalk.bold("/provider")} — add a new provider mid-session\n` +
512
+ `- ${chalk.bold("/thinking <level>")} — set reasoning effort (off|minimal|low|medium|high|xhigh)\n` +
513
+ `- ${chalk.bold("/compact")} — summarize older turns to free up context\n` +
514
+ `- ${chalk.bold("Ctrl+C")} — abort the current turn\n` +
515
+ `- ${chalk.bold("Ctrl+D")} — quit`, 1, 0, markdownTheme));
516
+ editor.setText("");
517
+ return;
518
+ }
519
+ // /compact — manually trigger Pi's compaction. Auto-compaction runs in
520
+ // the background at high context usage; this lets the user trigger it
521
+ // on demand (or summarize early before a long task).
522
+ if (trimmed === "/compact") {
523
+ editor.setText("");
524
+ const usage = session.getContextUsage();
525
+ if (usage?.percent != null) {
526
+ insertBeforeEditor(new Text(` ${brand.dim(`Compacting (current usage ${Math.round(usage.percent)}%)…`)}`, 0, 0));
527
+ }
528
+ else {
529
+ insertBeforeEditor(new Text(` ${brand.dim("Compacting…")}`, 0, 0));
530
+ }
531
+ try {
532
+ const result = await session.compact();
533
+ // getContextUsage returns null right after compaction (Pi: token
534
+ // estimate needs a fresh LLM response). Show that explicitly.
535
+ const after = session.getContextUsage();
536
+ const afterStr = after?.percent != null
537
+ ? `usage now ${Math.round(after.percent)}%`
538
+ : "usage refreshes after your next message";
539
+ const before = result?.tokensBefore ? `${(result.tokensBefore / 1000).toFixed(1)}k` : "?";
540
+ insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`Compacted ${before} of older context · ${afterStr}`)}`, 0, 0));
541
+ updateHeader();
542
+ }
543
+ catch (err) {
544
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`Compaction failed: ${err instanceof Error ? err.message : String(err)}`)}`, 0, 0));
545
+ }
546
+ return;
547
+ }
548
+ // /model — list/switch already-configured models. Without args, opens an
549
+ // inline picker over every model with auth set. With args, switches directly
550
+ // (matches by id; if multiple providers expose the same id, prefers the
551
+ // current provider, otherwise the first match).
552
+ if (trimmed === "/model" || trimmed.startsWith("/model ")) {
553
+ editor.setText("");
554
+ const available = modelRegistry.getAvailable();
555
+ if (available.length === 0) {
556
+ insertBeforeEditor(new Text(` ${brand.error("✗ no configured models — try /provider to add one.")}`, 0, 0));
557
+ return;
558
+ }
559
+ const arg = trimmed === "/model" ? "" : trimmed.slice("/model ".length).trim();
560
+ if (arg) {
561
+ // Direct switch by id. Prefer current provider on tie.
562
+ const matches = available.filter((m) => m.id === arg);
563
+ const target = matches.find((m) => m.provider === provider) ?? matches[0];
564
+ if (!target) {
565
+ insertBeforeEditor(new Text(` ${brand.error(`✗ no configured model with id "${arg}".`)} ${brand.dim(`Run /model to see the list.`)}`, 0, 0));
566
+ return;
567
+ }
568
+ await switchToModel(target.provider, target.id);
569
+ return;
570
+ }
571
+ // Inline picker. Sort by current-provider-first, then reasoning, then ctx.
572
+ const sorted = [...available].sort((a, b) => {
573
+ if (a.provider !== b.provider) {
574
+ if (a.provider === provider)
575
+ return -1;
576
+ if (b.provider === provider)
577
+ return 1;
578
+ return a.provider.localeCompare(b.provider);
579
+ }
580
+ if (!!a.reasoning !== !!b.reasoning)
581
+ return a.reasoning ? -1 : 1;
582
+ return (b.contextWindow ?? 0) - (a.contextWindow ?? 0);
583
+ });
584
+ const items = sorted.map((m) => ({
585
+ value: `${m.provider}::${m.id}`,
586
+ label: `${m.id}${m.id === modelId && m.provider === provider ? brand.amber(" (current)") : ""}`,
587
+ description: `${findProvider(m.provider)?.name ?? m.provider} · ${describeModelCapabilities(m)}`,
588
+ _value: { p: m.provider, id: m.id },
589
+ }));
590
+ const picked = await inlinePick("Switch model", items, { primaryWidth: [22, 38] });
591
+ if (!picked)
592
+ return;
593
+ if (picked.p === provider && picked.id === modelId) {
594
+ insertBeforeEditor(new Text(` ${brand.dim("already on that model.")}`, 0, 0));
595
+ return;
596
+ }
597
+ await switchToModel(picked.p, picked.id);
598
+ return;
599
+ }
600
+ // /provider — add a new provider mid-session. Picks the unconfigured ones,
601
+ // runs the same key-entry / Ollama-discovery code as onboarding, and (on
602
+ // success) auto-switches to the first model from that provider.
603
+ if (trimmed === "/provider") {
604
+ editor.setText("");
605
+ // Show only providers we don't already have credentials/registration for.
606
+ const configuredProviders = new Set(modelRegistry.getAvailable().map((m) => m.provider));
607
+ const candidates = PROVIDERS.filter((p) => !configuredProviders.has(p.id));
608
+ if (candidates.length === 0) {
609
+ insertBeforeEditor(new Text(` ${brand.dim("all curated providers are already configured. Use /model to switch.")}`, 0, 0));
610
+ return;
611
+ }
612
+ const items = candidates.map((p) => ({
613
+ value: p.id,
614
+ label: p.name,
615
+ description: p.description,
616
+ _value: p,
617
+ }));
618
+ const picked = await inlinePick("Add a provider", items, { primaryWidth: [18, 22] });
619
+ if (!picked)
620
+ return;
621
+ // Three paths now:
622
+ // - custom: user supplies provider id + baseUrl + apiKey + a model id
623
+ // - local (ollama): discover models via /api/tags
624
+ // - remote: just collect API key + use Pi's catalog
625
+ if (picked.custom) {
626
+ const providerId = await inlinePrompt("Give this connection a short name", "Lowercase letters and dashes only — for example: together, fireworks, on-prem.");
627
+ if (!providerId)
628
+ return;
629
+ if (!/^[a-z][a-z0-9_-]*$/.test(providerId)) {
630
+ insertBeforeEditor(new Text(` ${brand.error("✗ Use lowercase letters, numbers, and dashes only.")}`, 0, 0));
631
+ return;
632
+ }
633
+ const baseUrl = await inlinePrompt("Endpoint URL", "The OpenAI-compatible URL — for example: https://api.together.xyz/v1");
634
+ if (!baseUrl)
635
+ return;
636
+ if (!/^https?:\/\//i.test(baseUrl)) {
637
+ insertBeforeEditor(new Text(` ${brand.error("✗ The URL should start with https:// or http://.")}`, 0, 0));
638
+ return;
639
+ }
640
+ const apiKey = await inlinePrompt("API key", "Type \"none\" if your endpoint doesn't require one.");
641
+ if (!apiKey)
642
+ return;
643
+ const oneModelId = await inlinePrompt("Model name", "Pick the model you'd like to use first — for example: meta-llama/Llama-3.3-70B-Instruct-Turbo");
644
+ if (!oneModelId)
645
+ return;
646
+ // Confirmation step — show the user a summary of what we're about
647
+ // to save before we write anything. Catches typos in the URL or
648
+ // model id before they cause failed requests later.
649
+ const maskedKey = apiKey.length > 8
650
+ ? `${apiKey.slice(0, 4)}…${apiKey.slice(-4)}`
651
+ : apiKey === "none"
652
+ ? "none"
653
+ : "(hidden)";
654
+ insertBeforeEditor(new Text(` ${brand.amber("Review:")}`, 0, 0));
655
+ insertBeforeEditor(new Text(` ${brand.dim("Name:")} ${brand.white(providerId)}`, 0, 0));
656
+ insertBeforeEditor(new Text(` ${brand.dim("Endpoint:")} ${brand.white(baseUrl)}`, 0, 0));
657
+ insertBeforeEditor(new Text(` ${brand.dim("API key:")} ${brand.white(maskedKey)}`, 0, 0));
658
+ insertBeforeEditor(new Text(` ${brand.dim("Model:")} ${brand.white(oneModelId)}`, 0, 0));
659
+ const confirmed = await inlinePick("Save this connection?", [
660
+ { value: "save", label: "Yes, save and switch", description: "Adds this connection and switches to it now", _value: true },
661
+ { value: "cancel", label: "No, cancel", description: "Discard everything you typed", _value: false },
662
+ ], { primaryWidth: [22, 28] });
663
+ if (!confirmed) {
664
+ insertBeforeEditor(new Text(` ${brand.dim("Cancelled — nothing was saved.")}`, 0, 0));
665
+ return;
666
+ }
667
+ // Write the custom provider into models.json. We write a single model
668
+ // with conservative defaults; the user can extend the entry by hand
669
+ // for additional models. Reasoning is heuristic — set true if the id
670
+ // hints at a reasoning model.
671
+ const guessReasoning = /o[13]\b/i.test(oneModelId) || /r1\b/i.test(oneModelId) || /think/i.test(oneModelId) || /qwen[23]/i.test(oneModelId);
672
+ const fs = await import("node:fs/promises");
673
+ const modelsPath = path.join(BRIGADE_DIR, "models.json");
674
+ let existing = { providers: {} };
675
+ try {
676
+ existing = JSON.parse(await fs.readFile(modelsPath, "utf8"));
677
+ if (!existing.providers)
678
+ existing.providers = {};
679
+ }
680
+ catch {
681
+ /* file missing — start fresh */
682
+ }
683
+ existing.providers[providerId] = {
684
+ baseUrl: baseUrl.replace(/\/$/, ""),
685
+ api: "openai-completions",
686
+ apiKey,
687
+ models: [
688
+ {
689
+ id: oneModelId,
690
+ name: oneModelId,
691
+ reasoning: guessReasoning,
692
+ input: ["text"],
693
+ contextWindow: 32_768,
694
+ maxTokens: 8_192,
695
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
696
+ },
697
+ ],
698
+ };
699
+ try {
700
+ await fs.writeFile(modelsPath, JSON.stringify(existing, null, 2), "utf8");
701
+ modelRegistry.refresh();
702
+ }
703
+ catch (err) {
704
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`Couldn't save the connection: ${err instanceof Error ? err.message : String(err)}`)}`, 0, 0));
705
+ return;
706
+ }
707
+ insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`Connected ${providerId} · switching to ${oneModelId}…`)}`, 0, 0));
708
+ await switchToModel(providerId, oneModelId);
709
+ return;
710
+ }
711
+ if (picked.local && picked.id === "ollama") {
712
+ const baseUrl = picked.baseUrl ?? "http://localhost:11434";
713
+ insertBeforeEditor(new Text(` ${brand.dim("Scanning Ollama…")}`, 0, 0));
714
+ let discovered;
715
+ try {
716
+ discovered = await discoverOllamaModels(baseUrl);
717
+ }
718
+ catch (err) {
719
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(err instanceof Error ? err.message : String(err))}`, 0, 0));
720
+ return;
721
+ }
722
+ try {
723
+ await writeOllamaToModelsJson(path.join(BRIGADE_DIR, "models.json"), baseUrl, discovered);
724
+ modelRegistry.refresh();
725
+ }
726
+ catch (err) {
727
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`Couldn't save the connection: ${err instanceof Error ? err.message : String(err)}`)}`, 0, 0));
728
+ return;
729
+ }
730
+ insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`Ollama connected · ${discovered.length} model${discovered.length === 1 ? "" : "s"} ready. Switching…`)}`, 0, 0));
731
+ // Auto-switch to the first discovered model.
732
+ if (discovered[0])
733
+ await switchToModel("ollama", discovered[0].id);
734
+ return;
735
+ }
736
+ // Remote provider — collect API key inline, validate, save.
737
+ const key = await inlinePrompt(`Paste your ${picked.name} API key`, `We'll keep it private to this device. Need a key? ${picked.keyUrl}`);
738
+ if (!key)
739
+ return;
740
+ if (key.length < 16 || /\s/.test(key)) {
741
+ insertBeforeEditor(new Text(` ${brand.error("✗ That doesn't look right — try copying the key again.")}`, 0, 0));
742
+ return;
743
+ }
744
+ const validating = new CancellableLoader(tui, (s) => brand.amber(s), (s) => brand.dim(s), `Connecting to ${picked.name}…`);
745
+ insertBeforeEditor(validating);
746
+ const onlineCheck = await validateApiKeyOnline(picked.id, key);
747
+ removeChild(validating);
748
+ if (!onlineCheck.ok) {
749
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(onlineCheck.reason)}`, 0, 0));
750
+ return;
751
+ }
752
+ authStorage.set(picked.id, { type: "api_key", key });
753
+ authStorage.reload();
754
+ modelRegistry.refresh();
755
+ // Auto-switch to a sensible default model from the new provider —
756
+ // reasoning-first, then largest context window.
757
+ const newModels = modelRegistry.getAvailable().filter((m) => m.provider === picked.id);
758
+ if (newModels.length === 0) {
759
+ insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`${picked.name} connected, but no models are available right now. Try /model later.`)}`, 0, 0));
760
+ return;
761
+ }
762
+ const sorted = [...newModels].sort((a, b) => {
763
+ if (!!a.reasoning !== !!b.reasoning)
764
+ return a.reasoning ? -1 : 1;
765
+ return (b.contextWindow ?? 0) - (a.contextWindow ?? 0);
766
+ });
767
+ insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`${picked.name} connected · switching to ${sorted[0].id}…`)}`, 0, 0));
768
+ await switchToModel(picked.id, sorted[0].id);
769
+ return;
770
+ }
771
+ // /thinking [level] — reads or sets the model's reasoning effort.
772
+ // Pi clamps to model capabilities (e.g. "off" gets coerced for non-reasoning
773
+ // models), so we can pass through the user's choice and let Pi decide what's
774
+ // actually applicable. Without args, reports current state + valid options.
775
+ if (trimmed === "/thinking" || trimmed.startsWith("/thinking ")) {
776
+ editor.setText("");
777
+ if (!session.supportsThinking()) {
778
+ insertBeforeEditor(new Text(` ${brand.dim(`This model (${modelId}) does not support thinking — nothing to set.`)}`, 0, 0));
779
+ return;
780
+ }
781
+ const arg = trimmed === "/thinking" ? "" : trimmed.slice("/thinking ".length).trim().toLowerCase();
782
+ const available = session.getAvailableThinkingLevels();
783
+ if (!arg) {
784
+ insertBeforeEditor(new Text(` ${brand.dim("thinking is")} ${brand.amber(session.thinkingLevel)} ${brand.dim("· available:")} ${brand.dim(available.join(" "))}`, 0, 0));
785
+ return;
786
+ }
787
+ if (!VALID_THINKING_LEVELS.includes(arg)) {
788
+ insertBeforeEditor(new Text(` ${brand.error(`✗ unknown level "${arg}".`)} ${brand.dim(`Try one of: ${available.join(", ")}`)}`, 0, 0));
789
+ return;
790
+ }
791
+ if (!available.includes(arg)) {
792
+ insertBeforeEditor(new Text(` ${brand.error(`✗ "${arg}" is not supported on ${modelId}.`)} ${brand.dim(`Available: ${available.join(", ")}`)}`, 0, 0));
793
+ return;
794
+ }
795
+ session.setThinkingLevel(arg);
796
+ updateHeader();
797
+ insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim("thinking set to")} ${brand.amber(arg)}`, 0, 0));
798
+ return;
799
+ }
800
+ // echo user message
801
+ insertBeforeEditor(new Markdown(`${brand.user("you")} ${trimmed}`, 1, 0, markdownTheme));
802
+ editor.setText("");
803
+ try {
804
+ // Build the fallback chain from config. We accept ONE fallback in
805
+ // BrigadeConfig today; future versions can extend to an array.
806
+ const cfg = await loadConfig();
807
+ const fallbackModel = cfg.fallbackProvider && cfg.fallbackModelId
808
+ ? modelRegistry.find(cfg.fallbackProvider, cfg.fallbackModelId)
809
+ : undefined;
810
+ // Compose the loop wrappers from the inside out:
811
+ // 1. session.prompt() — Pi's actual loop
812
+ // 2. runWithStreamTimeout() — aborts if the loop goes silent for 60s
813
+ // 3. runWithFallback() — on hard error, walks the fallback chain
814
+ // Order matters: timeout wraps the prompt INSIDE fallback, so each
815
+ // fallback attempt gets its own fresh 60s watcher.
816
+ await runWithFallback(session, trimmed, {
817
+ fallbacks: fallbackModel ? [{ model: fallbackModel }] : [],
818
+ // Per-attempt wrappers. Composition (outer → inner):
819
+ // 1. runWithHeartbeat — every 30s of silence, show "still
820
+ // working… Ns elapsed" so the user
821
+ // knows we're alive (esp. local 30B
822
+ // models that take minutes per turn)
823
+ // 2. runWithStreamTimeout — abort after per-provider idle threshold
824
+ // (60s cloud / 5min Ollama)
825
+ // 3. runWithContentQualityRetry — re-prompt with a steer if the model
826
+ // returned empty / reasoning-only /
827
+ // planning-only output
828
+ // 4. runWithThinkingFallback — auto-downgrade thinking on rejection
829
+ // 5. session.prompt — Pi's actual loop
830
+ wrapAttempt: (promptFn) => runWithHeartbeat(session, () => runWithStreamTimeout(session, () => runWithLengthContinuation(session, () => runWithContentQualityRetry(session, () => runWithThinkingFallback(session, promptFn, {
831
+ onDowngrade: (originalLevel) => {
832
+ insertBeforeEditor(new Text(` ${brand.dim(`This model doesn't support thinking — switching from ${originalLevel} to off and retrying…`)}`, 0, 0));
833
+ },
834
+ }), {
835
+ onRetry: (reason) => {
836
+ const label = reason === "empty"
837
+ ? "no visible answer — re-prompting"
838
+ : reason === "reasoning-only"
839
+ ? "model emitted only reasoning — asking for visible answer"
840
+ : "model described an action but didn't take it — asking it to actually do it";
841
+ insertBeforeEditor(new Text(` ${brand.dim(`↻ ${label}…`)}`, 0, 0));
842
+ },
843
+ }), {
844
+ onContinue: () => {
845
+ insertBeforeEditor(new Text(` ${brand.dim("↻ reply was truncated — asking the model to continue…")}`, 0, 0));
846
+ },
847
+ }), {
848
+ // Per-provider timeout. Cloud non-reasoning: 60s; cloud
849
+ // reasoning: 180s; Ollama: 5min; custom: 3min.
850
+ idleMs: session.model ? pickStreamIdleMs(session.model) : 60_000,
851
+ onTimeout: (ms) => {
852
+ insertBeforeEditor(new Text(` ${brand.dim(`⏳ no response for ${Math.round(ms / 1000)}s — aborting…`)}`, 0, 0));
853
+ },
854
+ }), {
855
+ intervalMs: 30_000,
856
+ onHeartbeat: (ms) => {
857
+ const sec = Math.round(ms / 1000);
858
+ insertBeforeEditor(new Text(` ${brand.dim(`still working… ${sec}s elapsed`)}`, 0, 0));
859
+ },
860
+ }),
861
+ onFallback: (reason) => {
862
+ insertBeforeEditor(new Text(` ${brand.dim(`↻ primary failed (${cleanProviderError(reason)}) — trying ${cfg.fallbackModelId}…`)}`, 0, 0));
863
+ },
864
+ onFallbackExhausted: (reason) => {
865
+ insertBeforeEditor(new Text(` ${brand.error("✗ all fallback models failed:")} ${brand.error(cleanProviderError(reason))}`, 0, 0));
866
+ },
867
+ });
868
+ }
869
+ catch (err) {
870
+ // Provider errors arrive here when the request itself throws (vs being
871
+ // captured into an assistant message and surfaced via agent_end). They're
872
+ // often nested-JSON blobs — clean them before showing.
873
+ //
874
+ // We reset isAgentRunning + disableSubmit defensively because some error
875
+ // paths fire BEFORE `agent_start` (e.g. sync auth-resolver failures), so
876
+ // the `agent_end` event handler never runs. Re-assigning false when it's
877
+ // already false is a no-op, so this is safe even when agent_end did fire.
878
+ const raw = err instanceof Error ? err.message : String(err);
879
+ const msg = cleanProviderError(raw);
880
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(msg)}`, 0, 0));
881
+ isAgentRunning = false;
882
+ editor.disableSubmit = false;
883
+ updateHeader();
884
+ }
885
+ };
886
+ // NOTE: tui.start() is called once in index.ts BEFORE splash + onboarding,
887
+ // so the renderer is already alive by the time we get here. Don't start twice.
888
+ // Likewise, the SIGINT handler is wired ONCE at the top level (index.ts) and
889
+ // delegates here via the returned ChatHandle — preventing handler stacking.
890
+ tui.requestRender();
891
+ return {
892
+ abort: () => {
893
+ if (!isAgentRunning)
894
+ return false;
895
+ session.abort().catch(() => { });
896
+ isAgentRunning = false;
897
+ editor.disableSubmit = false;
898
+ if (activeLoader) {
899
+ removeChild(activeLoader);
900
+ activeLoader = null;
901
+ }
902
+ insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.dim("aborted")}`, 0, 0));
903
+ updateHeader();
904
+ return true;
905
+ },
906
+ isRunning: () => isAgentRunning,
907
+ };
908
+ }
909
+ /* ────────────────────────────── helpers ──────────────────────────────── */
910
+ function formatToolArgs(name, args) {
911
+ if (!args || typeof args !== "object")
912
+ return "";
913
+ if (args.path)
914
+ return String(args.path);
915
+ if (args.command)
916
+ return String(args.command).slice(0, 60);
917
+ if (args.pattern)
918
+ return String(args.pattern);
919
+ if (args.query)
920
+ return `"${String(args.query)}"`;
921
+ const keys = Object.keys(args);
922
+ if (keys.length === 0)
923
+ return "";
924
+ return keys
925
+ .slice(0, 2)
926
+ .map((k) => `${k}=${JSON.stringify(args[k]).slice(0, 30)}`)
927
+ .join(" ");
928
+ }
929
+ //# sourceMappingURL=chat.js.map