@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,285 @@
1
+ /**
2
+ * Provider-specific message-shape transforms.
3
+ *
4
+ * Each provider has at least one quirk that breaks an otherwise-valid Pi
5
+ * message before the API accepts it. The fixes here are surgical: each
6
+ * transform is a pure function over `AgentMessage[]`, gated on the active
7
+ * model so we don't pessimize providers that don't need them.
8
+ *
9
+ * Wired via `buildAgent`'s `transformContext` chain. The chain runs every
10
+ * LLM call so the fixes apply to BOTH initial requests and resumed sessions.
11
+ *
12
+ * Quirks handled:
13
+ * - dropAnthropicThinkingBlocks — strip stale thinking blocks in history
14
+ * - sanitizeMistralToolCallIds — rewrite IDs to Mistral's 9-char format
15
+ * - downgradeOpenAIResponsesPairs — drop thinking when paired with toolCall
16
+ * - decodeXaiToolCallArgs (hook) — decode HTML entities in xAI tool args
17
+ */
18
+ /* ─────────────────────────── 1. Strip stale thinking blocks ─────────────────────────── */
19
+ /**
20
+ * Strip `thinking` content blocks from assistant messages in history.
21
+ * Always-on defensive cleanup with one nuance:
22
+ *
23
+ * - When the ACTIVE model is Anthropic-compatible, the LATEST assistant
24
+ * message keeps its thinking block — Anthropic uses the signature for
25
+ * prompt-cache continuity on the next turn.
26
+ * - For every other active model (Gemini, OpenAI, Mistral, etc.) we strip
27
+ * ALL thinking blocks. They have no continuity reason to keep them and
28
+ * would otherwise reject `{type:"thinking"}` as an unknown content type.
29
+ *
30
+ * Why the nuance matters: the bug Brigade had before this fix was that a
31
+ * /model switch from Anthropic → Gemini left history full of Anthropic
32
+ * thinking blocks. With the OLD provider-conditional gate (only fires when
33
+ * active model is Anthropic), Gemini received them. With this new shape
34
+ * the strip happens for every NON-Anthropic active model, including the
35
+ * mid-conversation switch case.
36
+ *
37
+ * `activeModel` is optional — passing `undefined` means "I don't know the
38
+ * provider; strip everything to be safe." That's the correct default if
39
+ * the caller can't reach session.model for any reason.
40
+ *
41
+ * The cross-provider strip (vs. unconditional Anthropic-shaped strip) is
42
+ * what makes mid-conversation /model switches work cleanly.
43
+ */
44
+ export function dropAnthropicThinkingBlocks(messages, activeModel) {
45
+ if (!Array.isArray(messages) || messages.length === 0)
46
+ return messages;
47
+ // Only preserve the latest assistant's thinking when the active model is
48
+ // Anthropic-flavored (cache continuity matters). For any other model
49
+ // — including no model at all — strip every thinking block.
50
+ const preserveLatestIdx = isAnthropicLikeModel(activeModel)
51
+ ? findLastAssistantIndex(messages)
52
+ : -1;
53
+ let touched = false;
54
+ const out = messages.map((msg, i) => {
55
+ const m = msg;
56
+ if (m?.role !== "assistant" || !Array.isArray(m.content))
57
+ return msg;
58
+ if (i === preserveLatestIdx)
59
+ return msg; // preserve the last one for Anthropic cache
60
+ const filtered = m.content.filter((b) => b?.type !== "thinking");
61
+ if (filtered.length === m.content.length)
62
+ return msg; // nothing changed
63
+ touched = true;
64
+ // Anthropic rejects an assistant message with EMPTY content too.
65
+ // Replace with a single empty text block if dropping thinking left
66
+ // the message bare.
67
+ const content = filtered.length > 0 ? filtered : [{ type: "text", text: "" }];
68
+ return { ...m, content };
69
+ });
70
+ return touched ? out : messages;
71
+ }
72
+ function findLastAssistantIndex(messages) {
73
+ for (let i = messages.length - 1; i >= 0; i--) {
74
+ const m = messages[i];
75
+ if (m?.role === "assistant" && Array.isArray(m.content))
76
+ return i;
77
+ }
78
+ return -1;
79
+ }
80
+ /** Whether the active model is Anthropic-flavored (direct, Bedrock, Vertex, OpenRouter→Anthropic). */
81
+ export function isAnthropicLikeModel(model) {
82
+ if (!model)
83
+ return false;
84
+ const provider = (model.provider ?? "").toLowerCase();
85
+ const api = (model.api ?? "").toLowerCase();
86
+ if (provider === "anthropic")
87
+ return true;
88
+ if (api === "anthropic-messages")
89
+ return true;
90
+ if (provider === "openrouter" && /claude|anthropic/i.test(model.id ?? ""))
91
+ return true;
92
+ if (provider === "bedrock" || provider === "vertex")
93
+ return /claude|anthropic/i.test(model.id ?? "");
94
+ return false;
95
+ }
96
+ /* ─────────────────────────── 2. Mistral tool-call ID format ─────────────────────────── */
97
+ /**
98
+ * Mistral's tool-call API requires IDs matching `[a-zA-Z0-9]{9}` exactly.
99
+ * Pi/Anthropic emit `toolu_01a2b3c...` (longer, with underscores); OpenAI
100
+ * emits `call_xyz...`. Mistral rejects both.
101
+ *
102
+ * The fix: rewrite IDs in BOTH the assistant's `toolCall` blocks AND the
103
+ * matching `toolResult` messages, keeping the mapping consistent within
104
+ * a single transformContext pass.
105
+ *
106
+ * The 9-char ID is generated deterministically from the original ID so the
107
+ * mapping is stable across re-sends — same input, same output.
108
+ */
109
+ export function sanitizeMistralToolCallIds(messages) {
110
+ if (!Array.isArray(messages) || messages.length === 0)
111
+ return messages;
112
+ const idMap = new Map();
113
+ const mapId = (orig) => {
114
+ const cached = idMap.get(orig);
115
+ if (cached)
116
+ return cached;
117
+ // Deterministic 9-char id derived from the original. Hash → base36 → pad/slice.
118
+ let h = 0;
119
+ for (let i = 0; i < orig.length; i++) {
120
+ h = ((h << 5) - h + orig.charCodeAt(i)) | 0;
121
+ }
122
+ const positive = h < 0 ? h + 0x80000000 : h;
123
+ const id = positive.toString(36).padStart(9, "0").slice(-9);
124
+ idMap.set(orig, id);
125
+ return id;
126
+ };
127
+ const valid = /^[a-zA-Z0-9]{9}$/;
128
+ let touched = false;
129
+ const out = messages.map((msg) => {
130
+ const m = msg;
131
+ // toolResult messages carry the id at the message level.
132
+ if (m?.role === "toolResult" && typeof m.toolCallId === "string" && !valid.test(m.toolCallId)) {
133
+ touched = true;
134
+ return { ...m, toolCallId: mapId(m.toolCallId) };
135
+ }
136
+ // Assistant messages may carry toolCall blocks with ids inside content.
137
+ if (m?.role === "assistant" && Array.isArray(m.content)) {
138
+ let blockTouched = false;
139
+ const newContent = m.content.map((block) => {
140
+ if (block?.type === "toolCall" && typeof block.id === "string" && !valid.test(block.id)) {
141
+ blockTouched = true;
142
+ return { ...block, id: mapId(block.id) };
143
+ }
144
+ return block;
145
+ });
146
+ if (blockTouched) {
147
+ touched = true;
148
+ return { ...m, content: newContent };
149
+ }
150
+ }
151
+ return msg;
152
+ });
153
+ return touched ? out : messages;
154
+ }
155
+ /** Whether the active model is Mistral. */
156
+ export function isMistralModel(model) {
157
+ if (!model)
158
+ return false;
159
+ const provider = (model.provider ?? "").toLowerCase();
160
+ if (provider === "mistral" || provider === "mistralai")
161
+ return true;
162
+ if (provider === "openrouter" && /mistral|mixtral|codestral/i.test(model.id ?? ""))
163
+ return true;
164
+ return false;
165
+ }
166
+ /* ─────────────────────────── 3. OpenAI Responses API downgrade ─────────────────────────── */
167
+ /**
168
+ * OpenAI's Responses API rejects assistant messages that contain BOTH a
169
+ * reasoning block AND a function_call (tool_call) in the same message.
170
+ *
171
+ * The full fix is to strip the reasoning signature from the toolCall id
172
+ * (the `fc_` prefix that pairs it with the reasoning) so OpenAI accepts
173
+ * the call alone.
174
+ *
175
+ * Simpler approximation here: when a message has BOTH thinking AND toolCall,
176
+ * drop the thinking block (the reasoning is gone, but the tool call lives).
177
+ * This costs cache continuity but unblocks the call. Acceptable trade for v1.
178
+ */
179
+ export function downgradeOpenAIResponsesReasoningPairs(messages) {
180
+ if (!Array.isArray(messages) || messages.length === 0)
181
+ return messages;
182
+ let touched = false;
183
+ const out = messages.map((msg) => {
184
+ const m = msg;
185
+ if (m?.role !== "assistant" || !Array.isArray(m.content))
186
+ return msg;
187
+ const hasThinking = m.content.some((b) => b?.type === "thinking");
188
+ const hasToolCall = m.content.some((b) => b?.type === "toolCall");
189
+ if (!hasThinking || !hasToolCall)
190
+ return msg;
191
+ touched = true;
192
+ const filtered = m.content.filter((b) => b?.type !== "thinking");
193
+ const content = filtered.length > 0 ? filtered : [{ type: "text", text: "" }];
194
+ return { ...m, content };
195
+ });
196
+ return touched ? out : messages;
197
+ }
198
+ /** Whether the active model uses OpenAI's Responses API. */
199
+ export function isOpenAIResponsesModel(model) {
200
+ if (!model)
201
+ return false;
202
+ const api = (model.api ?? "").toLowerCase();
203
+ if (api === "openai-responses" || api === "responses")
204
+ return true;
205
+ // Models whose ids hint at the Responses API (o1, o3 reasoning models route through it).
206
+ const provider = (model.provider ?? "").toLowerCase();
207
+ if (provider === "openai" && /^o[13](?:-|$)/i.test(model.id ?? ""))
208
+ return true;
209
+ return false;
210
+ }
211
+ /* ─────────────────────────── 4. xAI tool-call argument decode ─────────────────────────── */
212
+ /**
213
+ * xAI / Grok occasionally returns tool-call argument JSON where string values
214
+ * are HTML-entity-encoded (`&quot;` for `"`, `&amp;` for `&`, etc.). Pi parses
215
+ * the JSON OK but the args object then has unusable strings — `path:
216
+ * "&#x2F;tmp&#x2F;file.txt"` instead of `path: "/tmp/file.txt"`.
217
+ *
218
+ * The cleanest fix is to decode at the stream level, but Brigade has no
219
+ * streamFn hook in v1 (we don't fork Pi's auth wrapper), so we fix at
220
+ * `beforeToolCall` instead: walk the args, decode every string recursively,
221
+ * hand the cleaned args to Pi for execution.
222
+ *
223
+ * This is a function FACTORY because beforeToolCall hooks need to be wired
224
+ * via `buildAgent`'s `beforeToolCall` option. The default unknownToolGuard
225
+ * already runs there; this composes with it by mutating `ctx.toolCall.arguments`
226
+ * in place before the guard sees it.
227
+ */
228
+ const HTML_ENTITY_MAP = {
229
+ "&quot;": '"',
230
+ "&amp;": "&",
231
+ "&lt;": "<",
232
+ "&gt;": ">",
233
+ "&#39;": "'",
234
+ "&apos;": "'",
235
+ "&nbsp;": " ",
236
+ };
237
+ const HTML_NUMERIC_HEX = /&#x([0-9a-fA-F]+);/g;
238
+ const HTML_NUMERIC_DEC = /&#(\d+);/g;
239
+ const HTML_NAMED = /&(?:quot|amp|lt|gt|apos|nbsp|#39);/g;
240
+ /**
241
+ * Decode HTML entities from a single string. Only fires when the input
242
+ * actually contains entities — the no-entity fast path returns the input
243
+ * unchanged with no allocations.
244
+ */
245
+ export function decodeHtmlEntities(s) {
246
+ if (typeof s !== "string" || s.indexOf("&") < 0)
247
+ return s;
248
+ let out = s.replace(HTML_NAMED, (m) => HTML_ENTITY_MAP[m] ?? m);
249
+ out = out.replace(HTML_NUMERIC_HEX, (_m, hex) => {
250
+ const code = parseInt(hex, 16);
251
+ return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : _m;
252
+ });
253
+ out = out.replace(HTML_NUMERIC_DEC, (_m, dec) => {
254
+ const code = parseInt(dec, 10);
255
+ return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : _m;
256
+ });
257
+ return out;
258
+ }
259
+ /** Recursively decode every string value in a tool-args object. */
260
+ export function decodeXaiToolCallArgs(args) {
261
+ if (typeof args === "string")
262
+ return decodeHtmlEntities(args);
263
+ if (Array.isArray(args))
264
+ return args.map((v) => decodeXaiToolCallArgs(v));
265
+ if (args && typeof args === "object") {
266
+ const out = {};
267
+ for (const [k, v] of Object.entries(args)) {
268
+ out[k] = decodeXaiToolCallArgs(v);
269
+ }
270
+ return out;
271
+ }
272
+ return args;
273
+ }
274
+ /** Whether the active model is xAI / Grok (or routed via OpenRouter to Grok). */
275
+ export function isXaiModel(model) {
276
+ if (!model)
277
+ return false;
278
+ const provider = (model.provider ?? "").toLowerCase();
279
+ if (provider === "xai" || provider === "x-ai")
280
+ return true;
281
+ if (provider === "openrouter" && /grok|xai|x-ai/i.test(model.id ?? ""))
282
+ return true;
283
+ return false;
284
+ }
285
+ //# sourceMappingURL=provider-quirks.js.map