@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,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