@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.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/SECURITY.md +208 -0
- package/assets/brigade-wordmark-on-black.png +0 -0
- package/assets/brigade-wordmark.png +0 -0
- package/brigade.mjs +96 -0
- package/dist/cli/chat-cmd.js +120 -0
- package/dist/cli/config-cmd.js +132 -0
- package/dist/cli/connect-cmd.js +447 -0
- package/dist/cli/doctor-cmd.js +317 -0
- package/dist/cli/gateway-cmd.js +92 -0
- package/dist/cli.js +287 -0
- package/dist/core/agent.js +1123 -0
- package/dist/core/config.js +80 -0
- package/dist/core/console-stream.js +188 -0
- package/dist/core/error-classifier.js +354 -0
- package/dist/core/event-logger.js +122 -0
- package/dist/core/model-caps.js +185 -0
- package/dist/core/provider-payload-mutators.js +517 -0
- package/dist/core/provider-quirks.js +285 -0
- package/dist/core/server.js +459 -0
- package/dist/core/smart-compaction.js +209 -0
- package/dist/core/system-prompt-defaults.js +88 -0
- package/dist/core/system-prompt-guidance.js +269 -0
- package/dist/core/system-prompt.js +884 -0
- package/dist/index.js +30 -0
- package/dist/integrations/ollama.js +140 -0
- package/dist/protocol.js +49 -0
- package/dist/providers/catalog.js +100 -0
- package/dist/providers/validate-key.js +197 -0
- package/dist/tui/client.js +263 -0
- package/dist/ui/brand-frames-cli.js +20 -0
- package/dist/ui/brand-frames.js +36 -0
- package/dist/ui/brand.js +402 -0
- package/dist/ui/chat.js +929 -0
- package/dist/ui/onboarding.js +400 -0
- package/dist/ui/theme.js +51 -0
- package/package.json +92 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brigade gateway server.
|
|
3
|
+
*
|
|
4
|
+
* Long-running headless process that owns:
|
|
5
|
+
* - the Pi AgentSession (model, messages, tools, hooks)
|
|
6
|
+
* - the AuthStorage + ModelRegistry
|
|
7
|
+
* - the JSONL event log
|
|
8
|
+
*
|
|
9
|
+
* Exposes a single WebSocket endpoint on `:7777` (configurable). Clients
|
|
10
|
+
* (TUI, future web/mobile) connect, receive a state snapshot + every Pi
|
|
11
|
+
* event, and send commands as request/response frames or one-way events.
|
|
12
|
+
*
|
|
13
|
+
* Architecture:
|
|
14
|
+
* - Raw `ws` package — no transport library, full control of the wire
|
|
15
|
+
* - Req/Res/Event tri-frame protocol on the same connection (see protocol.ts)
|
|
16
|
+
* - Tick heartbeat — server pushes a tick frame every TICK_INTERVAL_MS;
|
|
17
|
+
* client closes if no frame received in 2× that. Catches dead sockets.
|
|
18
|
+
* - Single source of truth: state lives only here. Clients hold a mirror
|
|
19
|
+
* refreshed via the `state` event after every mutation.
|
|
20
|
+
*
|
|
21
|
+
* State persistence: server is otherwise stateless. AuthStorage / ModelRegistry
|
|
22
|
+
* / config / sessions all live on disk under `~/.brigade/`. A server crash
|
|
23
|
+
* loses only in-flight turn state; resume picks up from the last persisted
|
|
24
|
+
* Pi session entry.
|
|
25
|
+
*/
|
|
26
|
+
import { createServer } from "node:http";
|
|
27
|
+
import { pathToFileURL } from "node:url";
|
|
28
|
+
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
|
29
|
+
import { WebSocketServer } from "ws";
|
|
30
|
+
import { DEFAULT_PORT, isFrame, modelToSummary, TICK_INTERVAL_MS, } from "../protocol.js";
|
|
31
|
+
import { buildAgent, runWithContentQualityRetry, runWithFallback, runWithHeartbeat, runWithLengthContinuation, runWithStreamTimeout, runWithThinkingFallback, switchModelMidTurn, } from "./agent.js";
|
|
32
|
+
import { BRIGADE_DIR, loadConfig, saveConfig } from "./config.js";
|
|
33
|
+
import { attachEventLogger, getTodayLogPath } from "./event-logger.js";
|
|
34
|
+
import { pickStreamIdleMs } from "./model-caps.js";
|
|
35
|
+
/* ────────────────────────── boot ────────────────────────── */
|
|
36
|
+
export async function startServer(opts = {}) {
|
|
37
|
+
const port = opts.port ?? (Number(process.env.BRIGADE_PORT) || DEFAULT_PORT);
|
|
38
|
+
const host = opts.host ?? "127.0.0.1";
|
|
39
|
+
// Resolve model + auth from saved config. Server requires the user has
|
|
40
|
+
// already onboarded (TUI runs onboarding before spawning us). If config
|
|
41
|
+
// is missing we throw with a clear message so the parent can re-onboard.
|
|
42
|
+
const authStorage = AuthStorage.create(`${BRIGADE_DIR}/auth.json`);
|
|
43
|
+
const modelRegistry = ModelRegistry.create(authStorage, `${BRIGADE_DIR}/models.json`);
|
|
44
|
+
const config = await loadConfig();
|
|
45
|
+
const provider = config.defaultProvider;
|
|
46
|
+
const modelId = config.defaultModelId;
|
|
47
|
+
if (!provider || !modelId) {
|
|
48
|
+
throw new Error("server: no saved config — run setup first");
|
|
49
|
+
}
|
|
50
|
+
let model = modelRegistry.find(provider, modelId);
|
|
51
|
+
if (!model) {
|
|
52
|
+
throw new Error(`server: model ${provider}/${modelId} not in registry`);
|
|
53
|
+
}
|
|
54
|
+
// Build the Pi session. This is the ONLY agent in the server process.
|
|
55
|
+
const session = await buildAgent({
|
|
56
|
+
authStorage,
|
|
57
|
+
modelRegistry,
|
|
58
|
+
model,
|
|
59
|
+
cwd: process.cwd(),
|
|
60
|
+
});
|
|
61
|
+
// Stream every Pi event to the JSONL log file. Logger silently degrades
|
|
62
|
+
// on I/O errors so log loss never crashes the server.
|
|
63
|
+
const detachLogger = attachEventLogger(session);
|
|
64
|
+
// Cumulative usage totals for the state snapshot. Pi reports per-turn
|
|
65
|
+
// usage on turn_end; we accumulate across turns.
|
|
66
|
+
let totalIn = 0;
|
|
67
|
+
let totalOut = 0;
|
|
68
|
+
let totalCost = 0;
|
|
69
|
+
let isAgentRunning = false;
|
|
70
|
+
const buildSnapshot = () => {
|
|
71
|
+
const usage = session.getContextUsage();
|
|
72
|
+
return {
|
|
73
|
+
provider,
|
|
74
|
+
modelId,
|
|
75
|
+
modelName: session.model?.name,
|
|
76
|
+
thinkingLevel: session.thinkingLevel,
|
|
77
|
+
supportsThinking: session.supportsThinking(),
|
|
78
|
+
availableThinkingLevels: session.getAvailableThinkingLevels(),
|
|
79
|
+
contextUsagePercent: usage?.percent ?? null,
|
|
80
|
+
totalTokensIn: totalIn,
|
|
81
|
+
totalTokensOut: totalOut,
|
|
82
|
+
totalCostUsd: totalCost,
|
|
83
|
+
isAgentRunning,
|
|
84
|
+
messageCount: session.messages.length,
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
/* ──────────────── transport ──────────────── */
|
|
88
|
+
const httpServer = createServer();
|
|
89
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
90
|
+
// `WebSocketServer` re-emits errors from the underlying httpServer (and
|
|
91
|
+
// can emit its own — bad upgrade frame, etc). With NO 'error' listener on
|
|
92
|
+
// wss, Node's EventEmitter throws the error, crashing the process with an
|
|
93
|
+
// unhandled stack trace BEFORE our listen-promise's reject can fire.
|
|
94
|
+
// Concrete repro: `npm run gateway` when 7777 is already bound.
|
|
95
|
+
//
|
|
96
|
+
// Capture and store; the listen promise below races against this. If a
|
|
97
|
+
// wssError lands before listen resolves, we use it as the reject reason.
|
|
98
|
+
let wssError;
|
|
99
|
+
wss.on("error", (err) => {
|
|
100
|
+
wssError = err instanceof Error ? err : new Error(String(err));
|
|
101
|
+
});
|
|
102
|
+
const clients = new Set();
|
|
103
|
+
/** Send one event to all connected clients. */
|
|
104
|
+
const broadcast = (event, payload) => {
|
|
105
|
+
const frame = { type: "event", event, payload };
|
|
106
|
+
const json = JSON.stringify(frame);
|
|
107
|
+
for (const ws of clients) {
|
|
108
|
+
if (ws.readyState === ws.OPEN)
|
|
109
|
+
ws.send(json);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
/* ──────────────── pi event forwarding ──────────────── */
|
|
113
|
+
const detachPi = session.subscribe((piEvent) => {
|
|
114
|
+
if (piEvent.type === "agent_start")
|
|
115
|
+
isAgentRunning = true;
|
|
116
|
+
if (piEvent.type === "agent_end")
|
|
117
|
+
isAgentRunning = false;
|
|
118
|
+
if (piEvent.type === "turn_end") {
|
|
119
|
+
const usage = piEvent.message?.usage;
|
|
120
|
+
if (usage) {
|
|
121
|
+
totalIn += usage.input ?? 0;
|
|
122
|
+
totalOut += usage.output ?? 0;
|
|
123
|
+
totalCost += usage.cost ?? 0;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Live console stream (verbose mode). Mirrors the JSONL file but
|
|
127
|
+
// human-readable. Same event sequence in both places.
|
|
128
|
+
opts.consoleStream?.pi(piEvent);
|
|
129
|
+
broadcast("pi", { event: piEvent });
|
|
130
|
+
broadcast("state", buildSnapshot());
|
|
131
|
+
});
|
|
132
|
+
/* ──────────────── request handler ──────────────── */
|
|
133
|
+
/**
|
|
134
|
+
* Type-safe request dispatcher. Each method handler returns its declared
|
|
135
|
+
* payload (per `ResponseFor`), which is wrapped into a ResponseFrame.
|
|
136
|
+
*
|
|
137
|
+
* Adding a new method:
|
|
138
|
+
* 1. Add the literal to RequestMethod in protocol.ts
|
|
139
|
+
* 2. Add params/payload types in RequestParams / ResponseFor
|
|
140
|
+
* 3. Add a case here returning the payload
|
|
141
|
+
* 4. Add a typed wrapper in the client
|
|
142
|
+
*/
|
|
143
|
+
const handleRequest = async (method, rawParams) => {
|
|
144
|
+
const params = rawParams;
|
|
145
|
+
switch (method) {
|
|
146
|
+
case "prompt": {
|
|
147
|
+
const p = params;
|
|
148
|
+
// Resolve fallback model fresh per turn — the user may have
|
|
149
|
+
// edited config (or rotated keys) between turns.
|
|
150
|
+
const cfgNow = await loadConfig();
|
|
151
|
+
const fallbackModel = cfgNow.fallbackProvider && cfgNow.fallbackModelId
|
|
152
|
+
? modelRegistry.find(cfgNow.fallbackProvider, cfgNow.fallbackModelId)
|
|
153
|
+
: undefined;
|
|
154
|
+
// Mirror the chat.ts composition so anything driven through the
|
|
155
|
+
// gateway gets the same loop semantics. Composition (outer→inner):
|
|
156
|
+
// 1. runWithFallback — multi-model failover (outermost)
|
|
157
|
+
// 2. runWithHeartbeat — periodic "still working…" log
|
|
158
|
+
// 3. runWithStreamTimeout — abort on per-provider idle threshold
|
|
159
|
+
// 4. runWithLengthContinuation — auto-continue truncated replies
|
|
160
|
+
// 5. runWithContentQualityRetry — re-prompt empty/reasoning-only/planning-only
|
|
161
|
+
// 6. runWithThinkingFallback — auto-downgrade thinking on rejection
|
|
162
|
+
// 7. session.prompt — Pi's actual loop (innermost)
|
|
163
|
+
await runWithFallback(session, p.text, {
|
|
164
|
+
fallbacks: fallbackModel ? [{ model: fallbackModel }] : [],
|
|
165
|
+
wrapAttempt: (promptFn) => runWithHeartbeat(session, () => runWithStreamTimeout(session, () => runWithLengthContinuation(session, () => runWithContentQualityRetry(session, () => runWithThinkingFallback(session, promptFn, {
|
|
166
|
+
onDowngrade: (originalLevel) => {
|
|
167
|
+
broadcast("log", {
|
|
168
|
+
level: "info",
|
|
169
|
+
message: `model doesn't support thinking — switching from ${originalLevel} to off and retrying`,
|
|
170
|
+
at: Date.now(),
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
}), {
|
|
174
|
+
onRetry: (reason) => {
|
|
175
|
+
broadcast("log", {
|
|
176
|
+
level: "info",
|
|
177
|
+
message: `${reason} — re-prompting for a usable answer`,
|
|
178
|
+
at: Date.now(),
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
}), {
|
|
182
|
+
onContinue: () => {
|
|
183
|
+
broadcast("log", {
|
|
184
|
+
level: "info",
|
|
185
|
+
message: "reply was truncated — asking the model to continue",
|
|
186
|
+
at: Date.now(),
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
}), {
|
|
190
|
+
idleMs: session.model ? pickStreamIdleMs(session.model) : 60_000,
|
|
191
|
+
onTimeout: (ms) => {
|
|
192
|
+
broadcast("log", {
|
|
193
|
+
level: "warn",
|
|
194
|
+
message: `no response for ${Math.round(ms / 1000)}s — aborting`,
|
|
195
|
+
at: Date.now(),
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
}), {
|
|
199
|
+
intervalMs: 30_000,
|
|
200
|
+
onHeartbeat: (ms) => {
|
|
201
|
+
broadcast("log", {
|
|
202
|
+
level: "info",
|
|
203
|
+
message: `still working… ${Math.round(ms / 1000)}s elapsed`,
|
|
204
|
+
at: Date.now(),
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
}),
|
|
208
|
+
onFallback: (reason, next) => {
|
|
209
|
+
broadcast("log", {
|
|
210
|
+
level: "warn",
|
|
211
|
+
message: `primary failed (${reason}) — trying ${next.label ?? "fallback"}`,
|
|
212
|
+
at: Date.now(),
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
onFallbackExhausted: (reason) => {
|
|
216
|
+
broadcast("log", {
|
|
217
|
+
level: "error",
|
|
218
|
+
message: `all fallback models failed: ${reason}`,
|
|
219
|
+
at: Date.now(),
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
// Per-error-class retry visibility — without these the connect
|
|
223
|
+
// TUI sees the gateway go silent for 30s/60s/5min during a
|
|
224
|
+
// rate-limit retry and assumes the daemon is dead. Broadcasting
|
|
225
|
+
// each retry attempt + each context-overflow compaction lets
|
|
226
|
+
// the operator (chat OR connect) understand what's happening.
|
|
227
|
+
retryPolicy: {
|
|
228
|
+
onRetry: (info) => {
|
|
229
|
+
broadcast("log", {
|
|
230
|
+
level: info.class === "context_overflow" ? "info" : "warn",
|
|
231
|
+
message: `${info.reason}`,
|
|
232
|
+
at: Date.now(),
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
onCompactBeforeRetry: () => {
|
|
236
|
+
broadcast("log", {
|
|
237
|
+
level: "info",
|
|
238
|
+
message: "context overflow — compacting then retrying same model",
|
|
239
|
+
at: Date.now(),
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
broadcast("state", buildSnapshot());
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
case "abort": {
|
|
248
|
+
await session.abort().catch(() => { });
|
|
249
|
+
broadcast("state", buildSnapshot());
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
case "steer": {
|
|
253
|
+
const p = params;
|
|
254
|
+
session.agent.steer({
|
|
255
|
+
role: "user",
|
|
256
|
+
content: [{ type: "text", text: p.text }],
|
|
257
|
+
});
|
|
258
|
+
broadcast("state", buildSnapshot());
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
case "set-model": {
|
|
262
|
+
const p = params;
|
|
263
|
+
const target = modelRegistry.find(p.provider, p.modelId);
|
|
264
|
+
if (!target)
|
|
265
|
+
throw new Error(`model ${p.provider}/${p.modelId} not found`);
|
|
266
|
+
await session.setModel(target);
|
|
267
|
+
model = target;
|
|
268
|
+
await saveConfig({ defaultProvider: p.provider, defaultModelId: p.modelId });
|
|
269
|
+
broadcast("state", buildSnapshot());
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
case "switch-model-mid-turn": {
|
|
273
|
+
const p = params;
|
|
274
|
+
const target = modelRegistry.find(p.provider, p.modelId);
|
|
275
|
+
if (!target)
|
|
276
|
+
throw new Error(`model ${p.provider}/${p.modelId} not found`);
|
|
277
|
+
await switchModelMidTurn(session, target, p.replayMessage);
|
|
278
|
+
model = target;
|
|
279
|
+
await saveConfig({ defaultProvider: p.provider, defaultModelId: p.modelId });
|
|
280
|
+
broadcast("state", buildSnapshot());
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
case "set-thinking": {
|
|
284
|
+
const p = params;
|
|
285
|
+
session.setThinkingLevel(p.level);
|
|
286
|
+
broadcast("state", buildSnapshot());
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
case "compact": {
|
|
290
|
+
await session.compact();
|
|
291
|
+
broadcast("state", buildSnapshot());
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
case "list-models": {
|
|
295
|
+
const models = modelRegistry.getAvailable().map((m) => modelToSummary(m));
|
|
296
|
+
return models;
|
|
297
|
+
}
|
|
298
|
+
case "refresh-models": {
|
|
299
|
+
modelRegistry.refresh();
|
|
300
|
+
broadcast("state", buildSnapshot());
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
case "get-state": {
|
|
304
|
+
return buildSnapshot();
|
|
305
|
+
}
|
|
306
|
+
default:
|
|
307
|
+
throw new Error(`unknown method: ${method}`);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
/* ──────────────── connection lifecycle ──────────────── */
|
|
311
|
+
wss.on("connection", (ws, req) => {
|
|
312
|
+
clients.add(ws);
|
|
313
|
+
const clientLabel = `${req.socket.remoteAddress ?? "?"}:${req.socket.remotePort ?? "?"}`;
|
|
314
|
+
opts.consoleStream?.clientConnected(clientLabel, clients.size);
|
|
315
|
+
// Send the initial snapshot so the client can render its header
|
|
316
|
+
// before any user action.
|
|
317
|
+
ws.send(JSON.stringify({ type: "event", event: "state", payload: buildSnapshot() }));
|
|
318
|
+
ws.on("message", async (data) => {
|
|
319
|
+
let frame;
|
|
320
|
+
try {
|
|
321
|
+
const parsed = JSON.parse(data.toString());
|
|
322
|
+
if (!isFrame(parsed))
|
|
323
|
+
return;
|
|
324
|
+
frame = parsed;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return; // unparseable — drop, never crash
|
|
328
|
+
}
|
|
329
|
+
if (frame.type !== "req")
|
|
330
|
+
return; // server only handles requests
|
|
331
|
+
const reqFrame = frame;
|
|
332
|
+
opts.consoleStream?.wsRequest(reqFrame.method, reqFrame.id, clientLabel);
|
|
333
|
+
const startedAt = Date.now();
|
|
334
|
+
try {
|
|
335
|
+
const payload = await handleRequest(reqFrame.method, reqFrame.params);
|
|
336
|
+
const response = {
|
|
337
|
+
type: "res",
|
|
338
|
+
id: reqFrame.id,
|
|
339
|
+
ok: true,
|
|
340
|
+
payload,
|
|
341
|
+
};
|
|
342
|
+
if (ws.readyState === ws.OPEN)
|
|
343
|
+
ws.send(JSON.stringify(response));
|
|
344
|
+
opts.consoleStream?.wsResponse(reqFrame.method, reqFrame.id, true, Date.now() - startedAt);
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
const response = {
|
|
348
|
+
type: "res",
|
|
349
|
+
id: reqFrame.id,
|
|
350
|
+
ok: false,
|
|
351
|
+
error: {
|
|
352
|
+
code: "internal",
|
|
353
|
+
message: err instanceof Error ? err.message : String(err),
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
if (ws.readyState === ws.OPEN)
|
|
357
|
+
ws.send(JSON.stringify(response));
|
|
358
|
+
opts.consoleStream?.wsResponse(reqFrame.method, reqFrame.id, false, Date.now() - startedAt);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
ws.on("close", () => {
|
|
362
|
+
clients.delete(ws);
|
|
363
|
+
opts.consoleStream?.clientDisconnected(clientLabel, clients.size);
|
|
364
|
+
});
|
|
365
|
+
ws.on("error", () => {
|
|
366
|
+
clients.delete(ws);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
/* ──────────────── tick heartbeat ──────────────── */
|
|
370
|
+
// Push an empty `state` snapshot every TICK_INTERVAL_MS so clients can
|
|
371
|
+
// detect a dead server (no frames in 2× this interval = close + reconnect).
|
|
372
|
+
// Sending the snapshot doubles as keep-alive AND consistency check.
|
|
373
|
+
const tickTimer = setInterval(() => {
|
|
374
|
+
broadcast("state", buildSnapshot());
|
|
375
|
+
}, TICK_INTERVAL_MS);
|
|
376
|
+
tickTimer.unref(); // don't block process exit on timer
|
|
377
|
+
/* ──────────────── start listening ──────────────── */
|
|
378
|
+
await new Promise((resolve, reject) => {
|
|
379
|
+
const onError = (err) => {
|
|
380
|
+
httpServer.removeListener("error", onError);
|
|
381
|
+
wss.removeListener("error", onWssError);
|
|
382
|
+
reject(err);
|
|
383
|
+
};
|
|
384
|
+
const onWssError = (err) => {
|
|
385
|
+
// wss errors during boot (typically the re-emitted EADDRINUSE)
|
|
386
|
+
// must reject the boot promise — without this, only `wssError`
|
|
387
|
+
// (captured above) sees them and the listen() callback never
|
|
388
|
+
// fires either, hanging forever.
|
|
389
|
+
httpServer.removeListener("error", onError);
|
|
390
|
+
wss.removeListener("error", onWssError);
|
|
391
|
+
reject(err);
|
|
392
|
+
};
|
|
393
|
+
httpServer.once("error", onError);
|
|
394
|
+
wss.once("error", onWssError);
|
|
395
|
+
httpServer.listen(port, host, () => {
|
|
396
|
+
httpServer.removeListener("error", onError);
|
|
397
|
+
wss.removeListener("error", onWssError);
|
|
398
|
+
// If wss already errored before listen's success fired, surface it.
|
|
399
|
+
if (wssError) {
|
|
400
|
+
reject(wssError);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
resolve();
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
if (opts.consoleStream) {
|
|
407
|
+
// Verbose mode owns the banner — formatted line per fact, colored.
|
|
408
|
+
opts.consoleStream.banner(host, port, getTodayLogPath());
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
// Default quiet mode — two plain lines so non-verbose stderr stays
|
|
412
|
+
// scannable for supervisors and `tail`.
|
|
413
|
+
process.stderr.write(`brigade-server: listening on ws://${host}:${port}\n`);
|
|
414
|
+
process.stderr.write(`brigade-server: logs at ${getTodayLogPath()}\n`);
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
port,
|
|
418
|
+
host,
|
|
419
|
+
async stop() {
|
|
420
|
+
clearInterval(tickTimer);
|
|
421
|
+
detachPi();
|
|
422
|
+
detachLogger();
|
|
423
|
+
for (const ws of clients) {
|
|
424
|
+
try {
|
|
425
|
+
ws.close();
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
/* ignore */
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
wss.close();
|
|
432
|
+
await new Promise((resolve) => httpServer.close(() => resolve()));
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
/* ────────────────────────── standalone entry ────────────────────────── */
|
|
437
|
+
/**
|
|
438
|
+
* Allow running this file directly: `npx tsx src/core/server.ts` or
|
|
439
|
+
* `node dist/core/server.js`. Uses the canonical Node pattern (compare
|
|
440
|
+
* import.meta.url against pathToFileURL of argv[1]) so Windows backslash
|
|
441
|
+
* paths and tsx loader prefixes don't confuse the equality check.
|
|
442
|
+
*/
|
|
443
|
+
const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
|
|
444
|
+
if (import.meta.url === entry) {
|
|
445
|
+
startServer()
|
|
446
|
+
.then((handle) => {
|
|
447
|
+
const onSignal = (sig) => {
|
|
448
|
+
process.stderr.write(`brigade-server: ${sig} received, shutting down\n`);
|
|
449
|
+
void handle.stop().then(() => process.exit(0));
|
|
450
|
+
};
|
|
451
|
+
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
|
452
|
+
process.on("SIGINT", () => onSignal("SIGINT"));
|
|
453
|
+
})
|
|
454
|
+
.catch((err) => {
|
|
455
|
+
process.stderr.write(`brigade-server: fatal: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
456
|
+
process.exit(1);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart tool-result compaction — context-scaled, two-tier (oversized +
|
|
3
|
+
* aggregate) shrinking with head+tail preservation.
|
|
4
|
+
*
|
|
5
|
+
* Brigade's older `truncateOversizedToolResults` was naïve: anything over a
|
|
6
|
+
* fixed 8KB cap got head-truncated. That's fine when ONE tool dumped a big
|
|
7
|
+
* stdout, but pathological when:
|
|
8
|
+
*
|
|
9
|
+
* - The model has a 200K context — 8K per result is wastefully small
|
|
10
|
+
* - 30 small-medium tool results collectively bloat past the budget
|
|
11
|
+
* (each is under 8K so blind truncation does nothing)
|
|
12
|
+
* - The IMPORTANT part of a tool result is at the END (error tracebacks,
|
|
13
|
+
* test summary lines) — head-only truncation throws away the diagnostic
|
|
14
|
+
* and keeps the noisy preamble
|
|
15
|
+
*
|
|
16
|
+
* Smart compaction does three things differently:
|
|
17
|
+
*
|
|
18
|
+
* 1. Budgets are CONTEXT-SCALED — `maxCharsPerResult` defaults to
|
|
19
|
+
* 30% of the model's context window, capped at 16KB.
|
|
20
|
+
*
|
|
21
|
+
* 2. Two-tier scoring:
|
|
22
|
+
* - PASS 1 (oversized): any single result over `maxCharsPerResult`
|
|
23
|
+
* is shrunk first. Highest reduction-potential wins.
|
|
24
|
+
* - PASS 2 (aggregate): if the SUM still exceeds `aggregateBudgetChars`,
|
|
25
|
+
* walk newest → oldest and shrink each by what's needed, leaving
|
|
26
|
+
* a `minKeepChars` floor.
|
|
27
|
+
*
|
|
28
|
+
* 3. Head+tail preservation when the truncated text contains error /
|
|
29
|
+
* summary patterns — keeps the first ~60% AND the last ~40% so a
|
|
30
|
+
* stack trace at the bottom of a long log is preserved.
|
|
31
|
+
*
|
|
32
|
+
* Pure function. Caller wires it via transformContext.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_OVERSIZED_CAP = 16_000;
|
|
35
|
+
const DEFAULT_AGGREGATE_CAP = 64_000;
|
|
36
|
+
const CHARS_PER_TOKEN_ROUGH = 4;
|
|
37
|
+
/**
|
|
38
|
+
* Sane FLOORS so the per-result and aggregate budgets never collapse to
|
|
39
|
+
* effectively-zero on tiny-context models (Cerebras 8K, Groq Llama-3.1-8B
|
|
40
|
+
* 8K). Without these, a 4K context window would derive a 1.2KB per-result
|
|
41
|
+
* cap and a 2KB aggregate budget — every tool result would shrink to almost
|
|
42
|
+
* nothing on every transformContext pass, destroying the model's working
|
|
43
|
+
* memory.
|
|
44
|
+
*
|
|
45
|
+
* Caller can override via explicit `maxCharsPerResult` / `aggregateBudgetChars`
|
|
46
|
+
* options to take a more aggressive cap when they know better.
|
|
47
|
+
*/
|
|
48
|
+
const MIN_OVERSIZED_CAP = 2_000;
|
|
49
|
+
const MIN_AGGREGATE_CAP = 4_000;
|
|
50
|
+
/**
|
|
51
|
+
* Default context window assumption when the caller didn't pass one. We use
|
|
52
|
+
* a CONSERVATIVE 32K instead of "Anthropic Sonnet's 200K" so a missing
|
|
53
|
+
* value doesn't grant pathological budgets on a small model. 32K is the
|
|
54
|
+
* common open-source baseline (Mistral, Llama, Qwen) — anything bigger and
|
|
55
|
+
* the caller really should pass `contextWindowTokens` explicitly.
|
|
56
|
+
*/
|
|
57
|
+
const SAFE_DEFAULT_CONTEXT_TOKENS = 32_000;
|
|
58
|
+
const ERROR_TAIL_PATTERNS = [
|
|
59
|
+
/error\b/i,
|
|
60
|
+
/exception\b/i,
|
|
61
|
+
/traceback/i,
|
|
62
|
+
/\bfail(?:ed|ure)?\b/i,
|
|
63
|
+
/stack\s*trace/i,
|
|
64
|
+
/\bsegfault/i,
|
|
65
|
+
/panic\b/i,
|
|
66
|
+
/^.*(\d+\s+passed.*\d+\s+failed)/im, // pytest-style summary
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* Walk message history, shrinking tool-result text blocks per the two-tier
|
|
70
|
+
* algorithm. Returns a new array; never mutates the input.
|
|
71
|
+
*
|
|
72
|
+
* Image / non-text content blocks pass through untouched (truncating base64
|
|
73
|
+
* would corrupt them).
|
|
74
|
+
*
|
|
75
|
+
* `transformContext` callers should run this BEFORE sanitizeMessages so
|
|
76
|
+
* truncation markers added here don't get a surrogate-strip pass over them.
|
|
77
|
+
*/
|
|
78
|
+
export function smartCompactToolResults(messages, options = {}) {
|
|
79
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
80
|
+
return { messages, stats: { oversizedCount: 0, aggregateReducibleChars: 0, totalSavedChars: 0 } };
|
|
81
|
+
}
|
|
82
|
+
// Defensive: a missing or non-positive contextWindow falls to a SAFE
|
|
83
|
+
// 32K default, not the previous 200K. Otherwise an 8K-context Groq /
|
|
84
|
+
// Cerebras model would inherit Anthropic-Sonnet-sized budgets and never
|
|
85
|
+
// compact. Negative / zero / NaN all collapse to the safe default.
|
|
86
|
+
const requestedCtx = options.contextWindowTokens;
|
|
87
|
+
const ctxTokens = typeof requestedCtx === "number" && Number.isFinite(requestedCtx) && requestedCtx > 0
|
|
88
|
+
? requestedCtx
|
|
89
|
+
: SAFE_DEFAULT_CONTEXT_TOKENS;
|
|
90
|
+
const ctxChars = ctxTokens * CHARS_PER_TOKEN_ROUGH;
|
|
91
|
+
// Per-result cap: 30% of context, capped at the oversized ceiling AND
|
|
92
|
+
// floored at MIN_OVERSIZED_CAP so a 4K model still gets ~2KB per result
|
|
93
|
+
// (enough to keep one bash output + one read result legible).
|
|
94
|
+
const maxCharsPerResult = options.maxCharsPerResult ??
|
|
95
|
+
Math.max(MIN_OVERSIZED_CAP, Math.min(Math.floor(ctxChars * 0.3), DEFAULT_OVERSIZED_CAP));
|
|
96
|
+
// Aggregate: 50% of context, capped + floored.
|
|
97
|
+
const aggregateBudget = options.aggregateBudgetChars ??
|
|
98
|
+
Math.max(MIN_AGGREGATE_CAP, Math.min(Math.floor(ctxChars * 0.5), DEFAULT_AGGREGATE_CAP));
|
|
99
|
+
// minKeep clamped so it can never EXCEED maxCharsPerResult — that would
|
|
100
|
+
// cause Pass 2 to try to shrink a result BELOW its already-applied cap,
|
|
101
|
+
// hitting an infinite "no progress" loop or, worse, growing it back.
|
|
102
|
+
const requestedMinKeep = options.minKeepChars ?? 2_000;
|
|
103
|
+
const minKeep = Math.min(maxCharsPerResult, Math.max(200, requestedMinKeep));
|
|
104
|
+
const preserveTail = options.preserveImportantTail ?? true;
|
|
105
|
+
const slots = [];
|
|
106
|
+
for (let mi = 0; mi < messages.length; mi++) {
|
|
107
|
+
const m = messages[mi];
|
|
108
|
+
if (m?.role !== "toolResult" || !Array.isArray(m.content))
|
|
109
|
+
continue;
|
|
110
|
+
for (let bi = 0; bi < m.content.length; bi++) {
|
|
111
|
+
const block = m.content[bi];
|
|
112
|
+
if (block?.type === "text" && typeof block.text === "string") {
|
|
113
|
+
slots.push({ mi, bi, len: block.text.length });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (slots.length === 0) {
|
|
118
|
+
return { messages, stats: { oversizedCount: 0, aggregateReducibleChars: 0, totalSavedChars: 0 } };
|
|
119
|
+
}
|
|
120
|
+
// Plan reductions per slot (slot key → target length).
|
|
121
|
+
const targetLength = new Map();
|
|
122
|
+
const slotKey = (s) => `${s.mi}:${s.bi}`;
|
|
123
|
+
// PASS 1 — oversized singles get capped to maxCharsPerResult.
|
|
124
|
+
let oversizedCount = 0;
|
|
125
|
+
for (const s of slots) {
|
|
126
|
+
if (s.len > maxCharsPerResult) {
|
|
127
|
+
oversizedCount++;
|
|
128
|
+
targetLength.set(slotKey(s), maxCharsPerResult);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// PASS 2 — aggregate. If the SUM (using post-pass-1 lengths) still
|
|
132
|
+
// exceeds the aggregate budget, shrink starting from NEWEST to OLDEST
|
|
133
|
+
// (newer = higher mi) by the amount needed, with a minKeep floor.
|
|
134
|
+
const lengthAfterPass1 = (s) => targetLength.get(slotKey(s)) ?? s.len;
|
|
135
|
+
let aggregate = slots.reduce((sum, s) => sum + lengthAfterPass1(s), 0);
|
|
136
|
+
let aggregateReducibleChars = Math.max(0, aggregate - aggregateBudget);
|
|
137
|
+
if (aggregate > aggregateBudget) {
|
|
138
|
+
// Sort newest-first (higher message index first).
|
|
139
|
+
const ordered = [...slots].sort((a, b) => b.mi - a.mi || b.bi - a.bi);
|
|
140
|
+
for (const s of ordered) {
|
|
141
|
+
if (aggregate <= aggregateBudget)
|
|
142
|
+
break;
|
|
143
|
+
const current = lengthAfterPass1(s);
|
|
144
|
+
const reducible = Math.max(0, current - minKeep);
|
|
145
|
+
if (reducible === 0)
|
|
146
|
+
continue;
|
|
147
|
+
const need = aggregate - aggregateBudget;
|
|
148
|
+
const cut = Math.min(reducible, need);
|
|
149
|
+
targetLength.set(slotKey(s), current - cut);
|
|
150
|
+
aggregate -= cut;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Apply the plan. If no slot was changed, return the input unchanged.
|
|
154
|
+
if (targetLength.size === 0) {
|
|
155
|
+
return {
|
|
156
|
+
messages,
|
|
157
|
+
stats: { oversizedCount: 0, aggregateReducibleChars, totalSavedChars: 0 },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
let totalSaved = 0;
|
|
161
|
+
const changedMsgIndices = new Set([...targetLength.keys()].map((k) => Number(k.split(":")[0])));
|
|
162
|
+
const out = messages.map((msg, mi) => {
|
|
163
|
+
if (!changedMsgIndices.has(mi))
|
|
164
|
+
return msg;
|
|
165
|
+
const m = msg;
|
|
166
|
+
const newContent = m.content.map((block, bi) => {
|
|
167
|
+
const target = targetLength.get(`${mi}:${bi}`);
|
|
168
|
+
if (target === undefined)
|
|
169
|
+
return block;
|
|
170
|
+
if (block?.type !== "text" || typeof block.text !== "string")
|
|
171
|
+
return block;
|
|
172
|
+
if (block.text.length <= target)
|
|
173
|
+
return block;
|
|
174
|
+
const original = block.text;
|
|
175
|
+
const truncated = preserveTail && hasErrorPattern(original)
|
|
176
|
+
? headAndTail(original, target)
|
|
177
|
+
: headOnly(original, target);
|
|
178
|
+
totalSaved += original.length - truncated.length;
|
|
179
|
+
return { ...block, text: truncated };
|
|
180
|
+
});
|
|
181
|
+
return { ...m, content: newContent };
|
|
182
|
+
});
|
|
183
|
+
return {
|
|
184
|
+
messages: out,
|
|
185
|
+
stats: { oversizedCount, aggregateReducibleChars, totalSavedChars: totalSaved },
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/* ─────────────────────────── helpers ─────────────────────────── */
|
|
189
|
+
function hasErrorPattern(text) {
|
|
190
|
+
// Cheap check first — only scan the LAST 4KB where errors typically live.
|
|
191
|
+
const slice = text.length > 4_000 ? text.slice(-4_000) : text;
|
|
192
|
+
return ERROR_TAIL_PATTERNS.some((p) => p.test(slice));
|
|
193
|
+
}
|
|
194
|
+
function headOnly(text, targetLen) {
|
|
195
|
+
const marker = "\n\n⚠️ [...truncated...]\n";
|
|
196
|
+
const head = Math.max(0, targetLen - marker.length);
|
|
197
|
+
const cut = text.length - head;
|
|
198
|
+
return `${text.slice(0, head)}${marker} (${cut} chars removed)`;
|
|
199
|
+
}
|
|
200
|
+
function headAndTail(text, targetLen) {
|
|
201
|
+
const marker = "\n\n⚠️ [...middle truncated, tail preserved...]\n\n";
|
|
202
|
+
// 60% head, 40% tail (after marker overhead).
|
|
203
|
+
const usable = Math.max(0, targetLen - marker.length);
|
|
204
|
+
const headLen = Math.floor(usable * 0.6);
|
|
205
|
+
const tailLen = Math.max(0, usable - headLen);
|
|
206
|
+
const cut = text.length - headLen - tailLen;
|
|
207
|
+
return `${text.slice(0, headLen)}${marker}(${cut} chars removed)\n\n${text.slice(text.length - tailLen)}`;
|
|
208
|
+
}
|
|
209
|
+
//# sourceMappingURL=smart-compaction.js.map
|