@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,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brigade event logger — writes every Pi session event to a JSONL file
|
|
3
|
+
* under `~/.brigade/logs/<YYYY-MM-DD>.jsonl`. One file per day, append-only.
|
|
4
|
+
*
|
|
5
|
+
* Why: Brigade has no other log sink. When something goes weird (model
|
|
6
|
+
* hangs, hallucination, mid-turn switch confusion), the user can `cat`
|
|
7
|
+
* today's log file and see the actual event sequence — including timestamps,
|
|
8
|
+
* tool calls, errors, retries, all of it.
|
|
9
|
+
*
|
|
10
|
+
* Format: one JSON object per line, with these always-present fields:
|
|
11
|
+
* { ts: <ISO 8601>, type: <event.type>, ... event-specific fields ... }
|
|
12
|
+
*
|
|
13
|
+
* Failure mode: if the log file can't be opened or a write fails, the logger
|
|
14
|
+
* silently degrades — log loss is preferable to crashing the user's chat.
|
|
15
|
+
* Errors are surfaced to stderr at startup ONLY (not on every write).
|
|
16
|
+
*/
|
|
17
|
+
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { BRIGADE_DIR } from "./config.js";
|
|
20
|
+
/** Directory we append to. Created lazily on first write. */
|
|
21
|
+
const LOGS_DIR = path.join(BRIGADE_DIR, "logs");
|
|
22
|
+
/** YYYY-MM-DD using UTC so log files don't roll over at confusing local times. */
|
|
23
|
+
function todayFile() {
|
|
24
|
+
const now = new Date();
|
|
25
|
+
const y = now.getUTCFullYear();
|
|
26
|
+
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
27
|
+
const d = String(now.getUTCDate()).padStart(2, "0");
|
|
28
|
+
return path.join(LOGS_DIR, `${y}-${m}-${d}.jsonl`);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Subscribe to a session's events and write each one to today's log file.
|
|
32
|
+
* Returns an unsubscribe function — call it when the session ends to detach.
|
|
33
|
+
*
|
|
34
|
+
* One unsub per session is sufficient; we don't deduplicate.
|
|
35
|
+
*/
|
|
36
|
+
export function attachEventLogger(session) {
|
|
37
|
+
let warnedOnce = false;
|
|
38
|
+
const ensureDir = () => {
|
|
39
|
+
try {
|
|
40
|
+
if (!existsSync(LOGS_DIR))
|
|
41
|
+
mkdirSync(LOGS_DIR, { recursive: true });
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (!warnedOnce) {
|
|
46
|
+
warnedOnce = true;
|
|
47
|
+
// Print once at first failure — never on every write
|
|
48
|
+
process.stderr.write(`brigade: could not create log directory ${LOGS_DIR}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const writeEvent = (event) => {
|
|
54
|
+
if (!ensureDir())
|
|
55
|
+
return;
|
|
56
|
+
// Build the row. Strip massive fields (full message contents repeated on
|
|
57
|
+
// every delta) — we want the log to be greppable, not a token-by-token
|
|
58
|
+
// replay. Tool calls, errors, retries, state changes are kept full.
|
|
59
|
+
const row = serializeForLog(event);
|
|
60
|
+
try {
|
|
61
|
+
appendFileSync(todayFile(), JSON.stringify(row) + "\n", "utf8");
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* drop the line — never crash the chat for a log write */
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
return session.subscribe(writeEvent);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Convert a Pi event into a log-friendly row. The full event payload often
|
|
71
|
+
* includes the cumulative assistant message on every delta — that turns a
|
|
72
|
+
* 500-token reply into 500 copies of itself in the log. We keep just what's
|
|
73
|
+
* useful for debugging:
|
|
74
|
+
*
|
|
75
|
+
* - All event types kept (so the timeline is complete)
|
|
76
|
+
* - For message_update: just the type + delta (not the full cumulative content)
|
|
77
|
+
* - For message_end: full final content (so we can replay the assistant reply)
|
|
78
|
+
* - For tool_execution_*: full args/result (this is what we want to debug)
|
|
79
|
+
* - For agent_*, turn_*, compaction_*, auto_retry_*: keep all fields
|
|
80
|
+
*/
|
|
81
|
+
function serializeForLog(event) {
|
|
82
|
+
const ts = new Date().toISOString();
|
|
83
|
+
const ev = event;
|
|
84
|
+
const base = { ts, type: ev.type };
|
|
85
|
+
switch (ev.type) {
|
|
86
|
+
case "message_update": {
|
|
87
|
+
// Keep just the inner event type + delta if present; skip the
|
|
88
|
+
// cumulative `message` payload (logged at message_end).
|
|
89
|
+
const inner = ev.assistantMessageEvent;
|
|
90
|
+
return {
|
|
91
|
+
...base,
|
|
92
|
+
inner: inner?.type,
|
|
93
|
+
delta: inner?.delta,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
case "message_end":
|
|
97
|
+
// Final message — full content kept for replay/debugging.
|
|
98
|
+
return { ...base, role: ev.message?.role, content: ev.message?.content, stopReason: ev.message?.stopReason };
|
|
99
|
+
case "tool_execution_start":
|
|
100
|
+
return { ...base, toolCallId: ev.toolCallId, toolName: ev.toolName, args: ev.args };
|
|
101
|
+
case "tool_execution_end":
|
|
102
|
+
return { ...base, toolCallId: ev.toolCallId, toolName: ev.toolName, isError: ev.isError, result: ev.result };
|
|
103
|
+
case "auto_retry_start":
|
|
104
|
+
return { ...base, attempt: ev.attempt, maxAttempts: ev.maxAttempts, delayMs: ev.delayMs, errorMessage: ev.errorMessage };
|
|
105
|
+
case "auto_retry_end":
|
|
106
|
+
return { ...base, success: ev.success, attempt: ev.attempt, finalError: ev.finalError };
|
|
107
|
+
case "compaction_end":
|
|
108
|
+
return { ...base, aborted: ev.aborted, willRetry: ev.willRetry, errorMessage: ev.errorMessage };
|
|
109
|
+
case "agent_end":
|
|
110
|
+
// Don't dump every message — just count.
|
|
111
|
+
return { ...base, messageCount: Array.isArray(ev.messages) ? ev.messages.length : 0 };
|
|
112
|
+
default:
|
|
113
|
+
// agent_start, turn_start, turn_end, message_start, compaction_start,
|
|
114
|
+
// session_info_changed, queue_update — keep base fields only.
|
|
115
|
+
return base;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Path of today's log file — useful for `/log` slash command later. */
|
|
119
|
+
export function getTodayLogPath() {
|
|
120
|
+
return todayFile();
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=event-logger.js.map
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model capability adapter.
|
|
3
|
+
*
|
|
4
|
+
* Pi-AI exposes ~150+ models across 9 providers. They have wildly different
|
|
5
|
+
* defaults, quirks, and breakages. This module is the single place where
|
|
6
|
+
* Brigade encodes that knowledge. Two responsibilities:
|
|
7
|
+
*
|
|
8
|
+
* 1. Pick a SAFE initial `thinkingLevel` for a freshly-selected model
|
|
9
|
+
* (some reasoning-only models reject `thinking budget = 0`).
|
|
10
|
+
* 2. Render a human-readable description of what a model supports
|
|
11
|
+
* (used in the header and in the model picker).
|
|
12
|
+
*
|
|
13
|
+
* If a new model regression shows up — "X provider rejects Y when Z" —
|
|
14
|
+
* encode it here, not scattered across agent.ts/tui.ts/onboarding.ts.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Pick the SAFE initial `thinkingLevel` for this model.
|
|
18
|
+
*
|
|
19
|
+
* Pi clamps to "off" for non-reasoning models server-side, so we don't
|
|
20
|
+
* strictly need to handle that case ourselves — but doing it here keeps
|
|
21
|
+
* Brigade's behavior obvious without having to chase Pi internals.
|
|
22
|
+
*
|
|
23
|
+
* Reasoning models default to `"low"` because:
|
|
24
|
+
* - Several models (Gemini 2.5 Pro, Gemini 3.x Pro) REJECT `"off"` —
|
|
25
|
+
* they require a non-zero thinking budget. `"low"` is the cheapest
|
|
26
|
+
* value that satisfies them.
|
|
27
|
+
* - For Anthropic / OpenAI o-series, `"low"` produces fast responses
|
|
28
|
+
* without burning tokens on heavy chain-of-thought.
|
|
29
|
+
* - Users who want more reasoning can `/thinking high` at runtime.
|
|
30
|
+
*/
|
|
31
|
+
export function pickInitialThinkingLevel(model) {
|
|
32
|
+
if (!model.reasoning)
|
|
33
|
+
return "off";
|
|
34
|
+
return "low";
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Short human description of a model's notable capabilities,
|
|
38
|
+
* suitable for the chat header (e.g. "thinking · vision · 1M ctx · $1.25/Mtok").
|
|
39
|
+
*
|
|
40
|
+
* Renders only what's relevant — omits cost when zero (free tier),
|
|
41
|
+
* omits vision when text-only, omits thinking when not supported.
|
|
42
|
+
*/
|
|
43
|
+
export function describeModelCapabilities(model, currentThinkingLevel) {
|
|
44
|
+
const parts = [];
|
|
45
|
+
if (model.reasoning) {
|
|
46
|
+
// Show the active thinking level so the user can see what they're paying for.
|
|
47
|
+
// `off` is an explicit user choice — show it so it doesn't look like a bug.
|
|
48
|
+
parts.push(currentThinkingLevel ? `think:${currentThinkingLevel}` : "thinking");
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(model.input) && model.input.includes("image")) {
|
|
51
|
+
parts.push("vision");
|
|
52
|
+
}
|
|
53
|
+
if (typeof model.contextWindow === "number" && model.contextWindow > 0) {
|
|
54
|
+
parts.push(`${formatTokens(model.contextWindow)} ctx`);
|
|
55
|
+
}
|
|
56
|
+
if (model.cost && model.cost.input > 0) {
|
|
57
|
+
parts.push(`$${model.cost.input.toFixed(2)}/Mtok in`);
|
|
58
|
+
}
|
|
59
|
+
return parts.join(" · ");
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Pick the SAFE stream-idle timeout for this model in milliseconds.
|
|
63
|
+
*
|
|
64
|
+
* Wrong defaults here are user-visible failures: too low and we abort a
|
|
65
|
+
* working slow model (the "qwen3-coder:30b: no response for 60s" bug from
|
|
66
|
+
* the screenshot); too high and a genuinely-stalled connection makes the
|
|
67
|
+
* user wait minutes before getting feedback.
|
|
68
|
+
*
|
|
69
|
+
* Trade-off chosen:
|
|
70
|
+
* - Cloud (Anthropic, OpenAI, Google, Groq, etc.) → 60s
|
|
71
|
+
* First token typically <3s; >60s of silence is genuine stall.
|
|
72
|
+
* - Cloud reasoning (Gemini 2.5 Pro thinking, etc.) → 180s
|
|
73
|
+
* Heavy reasoning can take 30-60s before any text emits.
|
|
74
|
+
* - Ollama (local) → 300s (5 min)
|
|
75
|
+
* 30B model on consumer GPU: 30-90s first token, 5+ min for complex.
|
|
76
|
+
* - Custom OpenAI-compatible → 180s
|
|
77
|
+
* Could be cloud OR local (Together, Fireworks, vLLM, LM Studio);
|
|
78
|
+
* conservative default keeps both working.
|
|
79
|
+
*/
|
|
80
|
+
export function pickStreamIdleMs(model) {
|
|
81
|
+
const provider = (model?.provider ?? "").toLowerCase();
|
|
82
|
+
// Local providers — always generous. Even small models can take seconds
|
|
83
|
+
// per token on a busy laptop.
|
|
84
|
+
if (provider === "ollama")
|
|
85
|
+
return 300_000; // 5 min
|
|
86
|
+
const KNOWN_CLOUD_PROVIDERS = new Set([
|
|
87
|
+
"anthropic",
|
|
88
|
+
"openai",
|
|
89
|
+
"google",
|
|
90
|
+
"google-vertex",
|
|
91
|
+
"openrouter",
|
|
92
|
+
"groq",
|
|
93
|
+
"cerebras",
|
|
94
|
+
"xai",
|
|
95
|
+
"deepseek",
|
|
96
|
+
"mistral",
|
|
97
|
+
"github-copilot",
|
|
98
|
+
"vercel-ai-gateway",
|
|
99
|
+
]);
|
|
100
|
+
const isCloud = KNOWN_CLOUD_PROVIDERS.has(provider);
|
|
101
|
+
// Custom OpenAI-compatible endpoints — could be cloud OR local. Default
|
|
102
|
+
// conservative so on-prem vLLM / LM Studio keep working.
|
|
103
|
+
if (!isCloud)
|
|
104
|
+
return 180_000;
|
|
105
|
+
// Reasoning-capable cloud models can sit silent during extended thinking.
|
|
106
|
+
if (model?.reasoning)
|
|
107
|
+
return 180_000;
|
|
108
|
+
return 60_000; // cloud, non-reasoning
|
|
109
|
+
}
|
|
110
|
+
/** Tokens → "1M", "200k", etc. */
|
|
111
|
+
function formatTokens(n) {
|
|
112
|
+
if (n >= 1_000_000) {
|
|
113
|
+
const m = n / 1_000_000;
|
|
114
|
+
return `${m === Math.floor(m) ? m : m.toFixed(1)}M`;
|
|
115
|
+
}
|
|
116
|
+
if (n >= 1000)
|
|
117
|
+
return `${Math.round(n / 1000)}k`;
|
|
118
|
+
return String(n);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Parse a raw provider error message (often an embedded JSON blob) into
|
|
122
|
+
* a single human-readable line. Each provider returns errors in a slightly
|
|
123
|
+
* different shape — this function unwraps the common ones.
|
|
124
|
+
*
|
|
125
|
+
* Examples this handles:
|
|
126
|
+
* - Google: `{"error":{"message":"Budget 0 is invalid…","code":400,…}}`
|
|
127
|
+
* - OpenAI: `{"error":{"message":"Invalid API key…","type":"invalid_request_error","code":"invalid_api_key"}}`
|
|
128
|
+
* - Anthropic: `{"error":{"type":"authentication_error","message":"…"}}`
|
|
129
|
+
* - Pi-wrapped: `{"error":{"message":"<the JSON above stringified>","code":400,"status":"Bad Request"}}`
|
|
130
|
+
*
|
|
131
|
+
* Returns the cleaned message, or the original input if nothing matched.
|
|
132
|
+
* Never throws.
|
|
133
|
+
*/
|
|
134
|
+
export function cleanProviderError(raw) {
|
|
135
|
+
if (!raw)
|
|
136
|
+
return raw;
|
|
137
|
+
// Strip a single layer of Pi wrapping if present, then attempt to peel
|
|
138
|
+
// any nested JSON message. We loop a few times because some providers
|
|
139
|
+
// stringify their JSON error inside another JSON error.
|
|
140
|
+
let current = raw.trim();
|
|
141
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
142
|
+
const peeled = peelOneJsonError(current);
|
|
143
|
+
if (peeled === null || peeled === current)
|
|
144
|
+
break;
|
|
145
|
+
current = peeled;
|
|
146
|
+
}
|
|
147
|
+
// Final cleanup — collapse whitespace and trim.
|
|
148
|
+
return current.replace(/\s+/g, " ").trim();
|
|
149
|
+
}
|
|
150
|
+
function peelOneJsonError(raw) {
|
|
151
|
+
const trimmed = raw.trim();
|
|
152
|
+
if (!trimmed.startsWith("{"))
|
|
153
|
+
return null;
|
|
154
|
+
let parsed;
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(trimmed);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (!parsed || typeof parsed !== "object")
|
|
162
|
+
return null;
|
|
163
|
+
const obj = parsed;
|
|
164
|
+
// Shape: { error: { message: "...", ... } } — Google, OpenAI, Anthropic, most.
|
|
165
|
+
if (obj.error && typeof obj.error === "object") {
|
|
166
|
+
const inner = obj.error;
|
|
167
|
+
if (typeof inner.message === "string" && inner.message.length > 0) {
|
|
168
|
+
return inner.message;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Shape: { error: "string" } — Ollama, some HF endpoints.
|
|
172
|
+
if (typeof obj.error === "string" && obj.error.length > 0) {
|
|
173
|
+
return obj.error;
|
|
174
|
+
}
|
|
175
|
+
// Shape: { message: "..." } — Cohere, some Mistral errors.
|
|
176
|
+
if (typeof obj.message === "string" && obj.message.length > 0) {
|
|
177
|
+
return obj.message;
|
|
178
|
+
}
|
|
179
|
+
// Shape: { detail: "..." } — FastAPI-style (Replicate, some self-hosted).
|
|
180
|
+
if (typeof obj.detail === "string" && obj.detail.length > 0) {
|
|
181
|
+
return obj.detail;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
//# sourceMappingURL=model-caps.js.map
|