@tritard/waterbrother 0.5.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/LICENSE +21 -0
- package/README.md +279 -0
- package/bin/waterbrother.js +7 -0
- package/package.json +46 -0
- package/src/agent.js +551 -0
- package/src/cli.js +5760 -0
- package/src/config.js +216 -0
- package/src/decider.js +131 -0
- package/src/grok-client.js +268 -0
- package/src/impact.js +161 -0
- package/src/mcp.js +376 -0
- package/src/modes.js +84 -0
- package/src/panel.js +154 -0
- package/src/path-utils.js +78 -0
- package/src/prompt.js +182 -0
- package/src/reviewer.js +140 -0
- package/src/session-store.js +154 -0
- package/src/task-store.js +178 -0
- package/src/tools.js +2206 -0
- package/src/workflow.js +153 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { normalizeAutonomyMode, normalizeExperienceMode } from "./modes.js";
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = path.join(os.homedir(), ".waterbrother");
|
|
7
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
8
|
+
const PROJECT_CONFIG_DIRNAME = ".waterbrother";
|
|
9
|
+
const PROJECT_CONFIG_FILENAME = "config.json";
|
|
10
|
+
const DEFAULT_APPROVAL_POLICY = {
|
|
11
|
+
autoApprovePaths: ["src/**", "app/**", "lib/**", "tests/**", "test/**", "docs/**", "public/**"],
|
|
12
|
+
askPaths: ["package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock", "tsconfig.json", ".env*", "**/migrations/**", "**/.github/**", "vercel.json", "Dockerfile", "docker-compose*.yml"],
|
|
13
|
+
denyPaths: [".git/**"],
|
|
14
|
+
allowShellCommands: ["git status", "git diff", "git rev-parse", "git branch", "git log", "pwd", "ls", "rg", "cat", "npm test", "npm run test", "npm run lint", "npm run typecheck", "pnpm test", "pnpm lint", "pnpm typecheck", "yarn test", "yarn lint", "yarn typecheck", "bun test", "bun run lint", "bun run typecheck"],
|
|
15
|
+
askShellCommands: ["npm install", "pnpm install", "yarn install", "bun install"],
|
|
16
|
+
denyShellPatterns: ["(^|\\s)rm\\s+-rf\\s+(/|~|\\.)", "git\\s+reset\\s+--hard", "curl[^\\n|]*\\|\\s*(sh|bash)", "wget[^\\n|]*\\|\\s*(sh|bash)"]
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function asStringArray(value) {
|
|
20
|
+
if (!Array.isArray(value)) return [];
|
|
21
|
+
return value.map((item) => String(item || '').trim()).filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeApprovalPolicy(policy = {}) {
|
|
25
|
+
const source = policy && typeof policy === "object" ? policy : {};
|
|
26
|
+
return {
|
|
27
|
+
autoApprovePaths: asStringArray(source.autoApprovePaths !== undefined ? source.autoApprovePaths : DEFAULT_APPROVAL_POLICY.autoApprovePaths),
|
|
28
|
+
askPaths: asStringArray(source.askPaths !== undefined ? source.askPaths : DEFAULT_APPROVAL_POLICY.askPaths),
|
|
29
|
+
denyPaths: asStringArray(source.denyPaths !== undefined ? source.denyPaths : DEFAULT_APPROVAL_POLICY.denyPaths),
|
|
30
|
+
allowShellCommands: asStringArray(source.allowShellCommands !== undefined ? source.allowShellCommands : DEFAULT_APPROVAL_POLICY.allowShellCommands),
|
|
31
|
+
askShellCommands: asStringArray(source.askShellCommands !== undefined ? source.askShellCommands : DEFAULT_APPROVAL_POLICY.askShellCommands),
|
|
32
|
+
denyShellPatterns: asStringArray(source.denyShellPatterns !== undefined ? source.denyShellPatterns : DEFAULT_APPROVAL_POLICY.denyShellPatterns)
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeVerification(value = {}) {
|
|
37
|
+
const source = value && typeof value === "object" ? value : {};
|
|
38
|
+
return {
|
|
39
|
+
commands: asStringArray(source.commands),
|
|
40
|
+
timeoutMs: Number.isFinite(Number(source.timeoutMs)) ? Math.max(1000, Math.floor(Number(source.timeoutMs))) : 180000
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeReviewer(value = {}) {
|
|
45
|
+
const source = value && typeof value === "object" ? value : {};
|
|
46
|
+
const thresholdRaw = String(source.blockOn || "none").trim().toLowerCase();
|
|
47
|
+
return {
|
|
48
|
+
enabled: source.enabled !== undefined ? Boolean(source.enabled) : true,
|
|
49
|
+
model: String(source.model || "").trim(),
|
|
50
|
+
maxDiffChars: Number.isFinite(Number(source.maxDiffChars)) ? Math.max(2000, Math.floor(Number(source.maxDiffChars))) : 24000,
|
|
51
|
+
blockOn: ["none", "block", "caution"].includes(thresholdRaw) ? thresholdRaw : "none"
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeTaskDefaults(value = {}) {
|
|
56
|
+
const source = value && typeof value === "object" ? value : {};
|
|
57
|
+
return {
|
|
58
|
+
branchPrefix: String(source.branchPrefix || "wb/").trim(),
|
|
59
|
+
autoSuggestContract: source.autoSuggestContract !== false,
|
|
60
|
+
requireDecisionBeforeBuild: source.requireDecisionBeforeBuild !== false
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeImpact(value = {}) {
|
|
65
|
+
const source = value && typeof value === "object" ? value : {};
|
|
66
|
+
return {
|
|
67
|
+
enabled: source.enabled !== undefined ? Boolean(source.enabled) : true,
|
|
68
|
+
maxRelated: Number.isFinite(Number(source.maxRelated)) ? Math.max(4, Math.min(30, Math.floor(Number(source.maxRelated)))) : 10,
|
|
69
|
+
maxTests: Number.isFinite(Number(source.maxTests)) ? Math.max(2, Math.min(20, Math.floor(Number(source.maxTests)))) : 6
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readJsonFile(filePath) {
|
|
74
|
+
try {
|
|
75
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
76
|
+
return JSON.parse(raw);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function mergeConfigLayers(userConfig = {}, projectConfig = {}) {
|
|
86
|
+
const mergedMcpServers = {
|
|
87
|
+
...(userConfig.mcpServers && typeof userConfig.mcpServers === "object" ? userConfig.mcpServers : {}),
|
|
88
|
+
...(projectConfig.mcpServers && typeof projectConfig.mcpServers === "object" ? projectConfig.mcpServers : {})
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
...userConfig,
|
|
93
|
+
...projectConfig,
|
|
94
|
+
...(Object.keys(mergedMcpServers).length > 0 ? { mcpServers: mergedMcpServers } : {})
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getProjectConfigDir(cwd) {
|
|
99
|
+
return path.join(cwd, PROJECT_CONFIG_DIRNAME);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getProjectConfigPath(cwd) {
|
|
103
|
+
return path.join(getProjectConfigDir(cwd), PROJECT_CONFIG_FILENAME);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function ensureConfigDir(scope = "user", cwd = process.cwd()) {
|
|
107
|
+
if (scope === "project") {
|
|
108
|
+
await fs.mkdir(getProjectConfigDir(cwd), { recursive: true });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function loadUserConfig() {
|
|
116
|
+
return readJsonFile(CONFIG_FILE);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function loadProjectConfig(cwd) {
|
|
120
|
+
if (!cwd) return {};
|
|
121
|
+
return readJsonFile(getProjectConfigPath(cwd));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function loadConfigLayers(cwd = process.cwd()) {
|
|
125
|
+
const [userConfig, projectConfig] = await Promise.all([loadUserConfig(), loadProjectConfig(cwd)]);
|
|
126
|
+
return {
|
|
127
|
+
userConfig,
|
|
128
|
+
projectConfig,
|
|
129
|
+
config: mergeConfigLayers(userConfig, projectConfig)
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function loadConfig(cwd = process.cwd()) {
|
|
134
|
+
const layers = await loadConfigLayers(cwd);
|
|
135
|
+
return layers.config;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function saveConfig(config, { scope = "user", cwd = process.cwd() } = {}) {
|
|
139
|
+
const filePath = scope === "project" ? getProjectConfigPath(cwd) : CONFIG_FILE;
|
|
140
|
+
await ensureConfigDir(scope, cwd);
|
|
141
|
+
await fs.writeFile(filePath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function resolveRuntimeConfig(config, overrides = {}) {
|
|
145
|
+
const baseUrl =
|
|
146
|
+
overrides.baseUrl ||
|
|
147
|
+
process.env.XAI_BASE_URL ||
|
|
148
|
+
process.env.GROK_BASE_URL ||
|
|
149
|
+
config.baseUrl ||
|
|
150
|
+
"https://api.x.ai/v1";
|
|
151
|
+
|
|
152
|
+
const apiKey =
|
|
153
|
+
overrides.apiKey ||
|
|
154
|
+
process.env.XAI_API_KEY ||
|
|
155
|
+
process.env.GROK_API_KEY ||
|
|
156
|
+
config.apiKey ||
|
|
157
|
+
"";
|
|
158
|
+
|
|
159
|
+
const model =
|
|
160
|
+
overrides.model ||
|
|
161
|
+
process.env.XAI_MODEL ||
|
|
162
|
+
process.env.GROK_MODEL ||
|
|
163
|
+
config.model ||
|
|
164
|
+
"grok-code-fast-1";
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
baseUrl,
|
|
168
|
+
apiKey,
|
|
169
|
+
model,
|
|
170
|
+
approvalMode: overrides.approvalMode || config.approvalMode || "on-request",
|
|
171
|
+
traceMode: overrides.traceMode || config.traceMode || "on",
|
|
172
|
+
receiptMode: overrides.receiptMode || config.receiptMode || "auto",
|
|
173
|
+
autoCompactThreshold:
|
|
174
|
+
overrides.autoCompactThreshold !== undefined
|
|
175
|
+
? overrides.autoCompactThreshold
|
|
176
|
+
: config.autoCompactThreshold !== undefined
|
|
177
|
+
? config.autoCompactThreshold
|
|
178
|
+
: 0.9,
|
|
179
|
+
agentProfile: overrides.agentProfile || config.agentProfile || "coder",
|
|
180
|
+
designModel: overrides.designModel || config.designModel || "grok-4.20-multi-agent-beta-0309",
|
|
181
|
+
requireTurnContracts:
|
|
182
|
+
overrides.requireTurnContracts !== undefined
|
|
183
|
+
? Boolean(overrides.requireTurnContracts)
|
|
184
|
+
: config.requireTurnContracts !== undefined
|
|
185
|
+
? Boolean(config.requireTurnContracts)
|
|
186
|
+
: true,
|
|
187
|
+
approvalPolicy: normalizeApprovalPolicy(overrides.approvalPolicy !== undefined ? overrides.approvalPolicy : config.approvalPolicy),
|
|
188
|
+
verification: normalizeVerification(overrides.verification !== undefined ? overrides.verification : config.verification),
|
|
189
|
+
reviewer: normalizeReviewer(overrides.reviewer !== undefined ? overrides.reviewer : config.reviewer),
|
|
190
|
+
impact: normalizeImpact(overrides.impact !== undefined ? overrides.impact : config.impact),
|
|
191
|
+
experienceMode: normalizeExperienceMode(overrides.experienceMode !== undefined ? overrides.experienceMode : config.experienceMode || "standard"),
|
|
192
|
+
autonomyMode: normalizeAutonomyMode(overrides.autonomyMode !== undefined ? overrides.autonomyMode : config.autonomyMode || "scoped"),
|
|
193
|
+
panelEnabled:
|
|
194
|
+
overrides.panelEnabled !== undefined
|
|
195
|
+
? Boolean(overrides.panelEnabled)
|
|
196
|
+
: config.panelEnabled !== undefined
|
|
197
|
+
? Boolean(config.panelEnabled)
|
|
198
|
+
: true,
|
|
199
|
+
decisionModel: overrides.decisionModel || config.decisionModel || "",
|
|
200
|
+
taskDefaults: normalizeTaskDefaults(
|
|
201
|
+
overrides.taskDefaults !== undefined ? overrides.taskDefaults : config.taskDefaults
|
|
202
|
+
),
|
|
203
|
+
profiles: config.profiles && typeof config.profiles === "object" ? config.profiles : {},
|
|
204
|
+
onboardingCompleted: Boolean(config.onboardingCompleted),
|
|
205
|
+
mcpServers:
|
|
206
|
+
overrides.mcpServers && typeof overrides.mcpServers === "object"
|
|
207
|
+
? overrides.mcpServers
|
|
208
|
+
: config.mcpServers && typeof config.mcpServers === "object"
|
|
209
|
+
? config.mcpServers
|
|
210
|
+
: {}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function getConfigPath({ scope = "user", cwd = process.cwd() } = {}) {
|
|
215
|
+
return scope === "project" ? getProjectConfigPath(cwd) : CONFIG_FILE;
|
|
216
|
+
}
|
package/src/decider.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createJsonCompletion } from "./grok-client.js";
|
|
2
|
+
|
|
3
|
+
const DECISION_SYSTEM_PROMPT = `You are a senior software architect helping a developer choose an implementation strategy.
|
|
4
|
+
|
|
5
|
+
You will be given a task goal and optionally some project context. Your job is to:
|
|
6
|
+
1. Analyze the goal and propose 2-4 concrete implementation options
|
|
7
|
+
2. Each option should have a clear scope (file paths, commands)
|
|
8
|
+
3. Recommend one option with a rationale
|
|
9
|
+
4. Flag open risks
|
|
10
|
+
|
|
11
|
+
Respond with ONLY a JSON object matching this schema:
|
|
12
|
+
{
|
|
13
|
+
"goal": "one-line restatement of the goal",
|
|
14
|
+
"options": [
|
|
15
|
+
{
|
|
16
|
+
"id": "minimal|strategic|exploratory|custom_id",
|
|
17
|
+
"title": "Short title",
|
|
18
|
+
"summary": "One paragraph explaining the approach",
|
|
19
|
+
"pros": ["..."],
|
|
20
|
+
"cons": ["..."],
|
|
21
|
+
"risk": "low|medium|high",
|
|
22
|
+
"fileCount": 4,
|
|
23
|
+
"scope": {
|
|
24
|
+
"paths": ["src/auth/**", "tests/auth/**"],
|
|
25
|
+
"commands": ["npm test -- auth"]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"recommendation": "option_id",
|
|
30
|
+
"rationale": "Why this option fits best given current constraints",
|
|
31
|
+
"openRisks": ["risk description"]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
Rules:
|
|
35
|
+
- Always include at least a "minimal" option
|
|
36
|
+
- Be concrete about file paths and commands — guess from the goal if needed
|
|
37
|
+
- Keep summaries factual, not promotional
|
|
38
|
+
- If you cannot determine scope, say so in openRisks
|
|
39
|
+
- Do not include markdown, code fences, or explanatory text outside the JSON`;
|
|
40
|
+
|
|
41
|
+
function buildDecisionPrompt({ goal, memory, taskName }) {
|
|
42
|
+
const parts = [`Goal: ${goal}`];
|
|
43
|
+
if (taskName) parts.push(`Task: ${taskName}`);
|
|
44
|
+
if (memory) parts.push(`Project context (WATERBROTHER.md):\n${memory}`);
|
|
45
|
+
parts.push("Propose implementation options as JSON.");
|
|
46
|
+
return parts.join("\n\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeDecision(decision) {
|
|
50
|
+
if (!decision || typeof decision !== "object") return null;
|
|
51
|
+
const options = Array.isArray(decision.options) ? decision.options : [];
|
|
52
|
+
return {
|
|
53
|
+
goal: String(decision.goal || "").trim(),
|
|
54
|
+
options: options.map((opt) => ({
|
|
55
|
+
id: String(opt.id || "unknown").trim(),
|
|
56
|
+
title: String(opt.title || "").trim(),
|
|
57
|
+
summary: String(opt.summary || "").trim(),
|
|
58
|
+
pros: Array.isArray(opt.pros) ? opt.pros.map(String) : [],
|
|
59
|
+
cons: Array.isArray(opt.cons) ? opt.cons.map(String) : [],
|
|
60
|
+
risk: ["low", "medium", "high"].includes(opt.risk) ? opt.risk : "medium",
|
|
61
|
+
fileCount: Number.isFinite(Number(opt.fileCount)) ? Math.max(0, Math.floor(Number(opt.fileCount))) : null,
|
|
62
|
+
scope: opt.scope && typeof opt.scope === "object"
|
|
63
|
+
? {
|
|
64
|
+
paths: Array.isArray(opt.scope.paths) ? opt.scope.paths.map(String) : [],
|
|
65
|
+
commands: Array.isArray(opt.scope.commands) ? opt.scope.commands.map(String) : []
|
|
66
|
+
}
|
|
67
|
+
: { paths: [], commands: [] }
|
|
68
|
+
})),
|
|
69
|
+
recommendation: String(decision.recommendation || "").trim(),
|
|
70
|
+
rationale: String(decision.rationale || "").trim(),
|
|
71
|
+
openRisks: Array.isArray(decision.openRisks) ? decision.openRisks.map(String) : []
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function runDecisionPass({
|
|
76
|
+
apiKey,
|
|
77
|
+
baseUrl,
|
|
78
|
+
model,
|
|
79
|
+
goal,
|
|
80
|
+
taskName,
|
|
81
|
+
memory,
|
|
82
|
+
signal
|
|
83
|
+
}) {
|
|
84
|
+
if (!goal) throw new Error("goal is required for /decide");
|
|
85
|
+
|
|
86
|
+
const messages = [
|
|
87
|
+
{ role: "system", content: DECISION_SYSTEM_PROMPT },
|
|
88
|
+
{ role: "user", content: buildDecisionPrompt({ goal, memory, taskName }) }
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const completion = await createJsonCompletion({
|
|
92
|
+
apiKey,
|
|
93
|
+
baseUrl,
|
|
94
|
+
model,
|
|
95
|
+
messages,
|
|
96
|
+
temperature: 0.3,
|
|
97
|
+
signal
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const decision = normalizeDecision(completion.json);
|
|
101
|
+
if (!decision || decision.options.length === 0) {
|
|
102
|
+
throw new Error("Decision pass returned no options");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
decision,
|
|
107
|
+
usage: completion.usage || null
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function formatDecisionForDisplay(decision) {
|
|
112
|
+
if (!decision) return "No decision available.";
|
|
113
|
+
const lines = [];
|
|
114
|
+
lines.push(`Goal: ${decision.goal}`);
|
|
115
|
+
lines.push("");
|
|
116
|
+
for (const opt of decision.options) {
|
|
117
|
+
const rec = opt.id === decision.recommendation ? " (recommended)" : "";
|
|
118
|
+
lines.push(`Option: ${opt.id} — ${opt.title}${rec}`);
|
|
119
|
+
lines.push(` ${opt.summary}`);
|
|
120
|
+
if (opt.fileCount != null) lines.push(` Files: ~${opt.fileCount}`);
|
|
121
|
+
lines.push(` Risk: ${opt.risk}`);
|
|
122
|
+
if (opt.pros.length > 0) lines.push(` Pros: ${opt.pros.join(", ")}`);
|
|
123
|
+
if (opt.cons.length > 0) lines.push(` Cons: ${opt.cons.join(", ")}`);
|
|
124
|
+
if (opt.scope.paths.length > 0) lines.push(` Scope: ${opt.scope.paths.join(", ")}`);
|
|
125
|
+
if (opt.scope.commands.length > 0) lines.push(` Commands: ${opt.scope.commands.join(", ")}`);
|
|
126
|
+
lines.push("");
|
|
127
|
+
}
|
|
128
|
+
if (decision.rationale) lines.push(`Rationale: ${decision.rationale}`);
|
|
129
|
+
if (decision.openRisks.length > 0) lines.push(`Open risks: ${decision.openRisks.join("; ")}`);
|
|
130
|
+
return lines.join("\n");
|
|
131
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
function trimTrailingSlash(url) {
|
|
2
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function buildChatPayload({ model, messages, tools, temperature }) {
|
|
6
|
+
const payload = {
|
|
7
|
+
model,
|
|
8
|
+
messages,
|
|
9
|
+
temperature
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
if (tools && tools.length > 0) {
|
|
13
|
+
payload.tools = tools;
|
|
14
|
+
payload.tool_choice = "auto";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return payload;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function appendToolCallDelta(message, deltaToolCall) {
|
|
21
|
+
if (!deltaToolCall || typeof deltaToolCall !== "object") return;
|
|
22
|
+
const index = Number.isInteger(deltaToolCall.index) ? deltaToolCall.index : 0;
|
|
23
|
+
if (!Array.isArray(message.tool_calls)) message.tool_calls = [];
|
|
24
|
+
if (!message.tool_calls[index]) {
|
|
25
|
+
message.tool_calls[index] = {
|
|
26
|
+
id: deltaToolCall.id || null,
|
|
27
|
+
type: "function",
|
|
28
|
+
function: {
|
|
29
|
+
name: "",
|
|
30
|
+
arguments: ""
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const target = message.tool_calls[index];
|
|
36
|
+
if (deltaToolCall.id) target.id = deltaToolCall.id;
|
|
37
|
+
if (deltaToolCall.type) target.type = deltaToolCall.type;
|
|
38
|
+
if (deltaToolCall.function?.name) target.function.name += deltaToolCall.function.name;
|
|
39
|
+
if (deltaToolCall.function?.arguments) target.function.arguments += deltaToolCall.function.arguments;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function requestJson({ apiKey, baseUrl, endpoint, method = "GET", payload, signal }) {
|
|
43
|
+
if (!apiKey) {
|
|
44
|
+
throw new Error("Missing API key. Set XAI_API_KEY or run: waterbrother config set apiKey <key>");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const response = await fetch(`${trimTrailingSlash(baseUrl)}${endpoint}`, {
|
|
48
|
+
method,
|
|
49
|
+
headers: {
|
|
50
|
+
"content-type": "application/json",
|
|
51
|
+
authorization: `Bearer ${apiKey}`
|
|
52
|
+
},
|
|
53
|
+
body: payload ? JSON.stringify(payload) : undefined,
|
|
54
|
+
signal
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const bodyText = await response.text();
|
|
58
|
+
let body;
|
|
59
|
+
try {
|
|
60
|
+
body = JSON.parse(bodyText);
|
|
61
|
+
} catch {
|
|
62
|
+
body = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
const details = body ? JSON.stringify(body, null, 2) : bodyText;
|
|
67
|
+
throw new Error(`Grok API error (${response.status}): ${details}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return body;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function createChatCompletion({
|
|
74
|
+
apiKey,
|
|
75
|
+
baseUrl,
|
|
76
|
+
model,
|
|
77
|
+
messages,
|
|
78
|
+
tools,
|
|
79
|
+
temperature = 0.2,
|
|
80
|
+
signal
|
|
81
|
+
}) {
|
|
82
|
+
const payload = buildChatPayload({ model, messages, tools, temperature });
|
|
83
|
+
|
|
84
|
+
const body = await requestJson({
|
|
85
|
+
apiKey,
|
|
86
|
+
baseUrl,
|
|
87
|
+
endpoint: "/chat/completions",
|
|
88
|
+
method: "POST",
|
|
89
|
+
payload,
|
|
90
|
+
signal
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const message = body?.choices?.[0]?.message;
|
|
94
|
+
if (!message) {
|
|
95
|
+
throw new Error(`Unexpected Grok API response: ${JSON.stringify(body)}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
id: body.id,
|
|
100
|
+
usage: body.usage,
|
|
101
|
+
message
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function createChatCompletionStream({
|
|
106
|
+
apiKey,
|
|
107
|
+
baseUrl,
|
|
108
|
+
model,
|
|
109
|
+
messages,
|
|
110
|
+
tools,
|
|
111
|
+
temperature = 0.2,
|
|
112
|
+
signal,
|
|
113
|
+
onDelta
|
|
114
|
+
}) {
|
|
115
|
+
if (!apiKey) {
|
|
116
|
+
throw new Error("Missing API key. Set XAI_API_KEY or run: waterbrother config set apiKey <key>");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const payload = {
|
|
120
|
+
...buildChatPayload({ model, messages, tools, temperature }),
|
|
121
|
+
stream: true
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const response = await fetch(`${trimTrailingSlash(baseUrl)}/chat/completions`, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: {
|
|
127
|
+
"content-type": "application/json",
|
|
128
|
+
authorization: `Bearer ${apiKey}`
|
|
129
|
+
},
|
|
130
|
+
body: JSON.stringify(payload),
|
|
131
|
+
signal
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
const bodyText = await response.text();
|
|
136
|
+
throw new Error(`Grok API error (${response.status}): ${bodyText}`);
|
|
137
|
+
}
|
|
138
|
+
if (!response.body) {
|
|
139
|
+
throw new Error("Grok API stream error: response body missing");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const reader = response.body.getReader();
|
|
143
|
+
const decoder = new TextDecoder();
|
|
144
|
+
const message = { role: "assistant", content: "" };
|
|
145
|
+
let usage = null;
|
|
146
|
+
let id = null;
|
|
147
|
+
let buffer = "";
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
while (true) {
|
|
151
|
+
const { done, value } = await reader.read();
|
|
152
|
+
if (done) break;
|
|
153
|
+
buffer += decoder.decode(value, { stream: true });
|
|
154
|
+
|
|
155
|
+
while (true) {
|
|
156
|
+
const splitAt = buffer.indexOf("\n\n");
|
|
157
|
+
if (splitAt === -1) break;
|
|
158
|
+
const eventBlock = buffer.slice(0, splitAt);
|
|
159
|
+
buffer = buffer.slice(splitAt + 2);
|
|
160
|
+
|
|
161
|
+
const lines = eventBlock
|
|
162
|
+
.split("\n")
|
|
163
|
+
.map((line) => line.trim())
|
|
164
|
+
.filter((line) => line.startsWith("data:"));
|
|
165
|
+
|
|
166
|
+
for (const line of lines) {
|
|
167
|
+
const data = line.slice(5).trim();
|
|
168
|
+
if (!data) continue;
|
|
169
|
+
if (data === "[DONE]") {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let parsed;
|
|
174
|
+
try {
|
|
175
|
+
parsed = JSON.parse(data);
|
|
176
|
+
} catch {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
id = parsed.id || id;
|
|
181
|
+
usage = parsed.usage || usage;
|
|
182
|
+
const delta = parsed?.choices?.[0]?.delta;
|
|
183
|
+
if (!delta) continue;
|
|
184
|
+
|
|
185
|
+
if (typeof delta.content === "string" && delta.content.length > 0) {
|
|
186
|
+
message.content += delta.content;
|
|
187
|
+
if (onDelta) onDelta(delta.content);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
191
|
+
for (const deltaToolCall of delta.tool_calls) {
|
|
192
|
+
appendToolCallDelta(message, deltaToolCall);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} finally {
|
|
199
|
+
reader.releaseLock();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
id,
|
|
204
|
+
usage,
|
|
205
|
+
message
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function extractJson(text) {
|
|
210
|
+
const raw = String(text || "").trim();
|
|
211
|
+
// Try raw parse first
|
|
212
|
+
try { return JSON.parse(raw); } catch {}
|
|
213
|
+
// Try markdown-fenced JSON
|
|
214
|
+
const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
|
|
215
|
+
if (fenced) {
|
|
216
|
+
try { return JSON.parse(fenced[1].trim()); } catch {}
|
|
217
|
+
}
|
|
218
|
+
// Try first { ... } block
|
|
219
|
+
const braceStart = raw.indexOf("{");
|
|
220
|
+
const braceEnd = raw.lastIndexOf("}");
|
|
221
|
+
if (braceStart !== -1 && braceEnd > braceStart) {
|
|
222
|
+
try { return JSON.parse(raw.slice(braceStart, braceEnd + 1)); } catch {}
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function createJsonCompletion({
|
|
228
|
+
apiKey,
|
|
229
|
+
baseUrl,
|
|
230
|
+
model,
|
|
231
|
+
messages,
|
|
232
|
+
temperature = 0.2,
|
|
233
|
+
signal
|
|
234
|
+
}) {
|
|
235
|
+
const completion = await createChatCompletion({
|
|
236
|
+
apiKey,
|
|
237
|
+
baseUrl,
|
|
238
|
+
model,
|
|
239
|
+
messages,
|
|
240
|
+
temperature,
|
|
241
|
+
signal
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const content = String(completion?.message?.content || "").trim();
|
|
245
|
+
const parsed = extractJson(content);
|
|
246
|
+
if (!parsed) {
|
|
247
|
+
const error = new Error(`Failed to parse JSON from model response`);
|
|
248
|
+
error.rawContent = content;
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
return { ...completion, json: parsed };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function listModels({ apiKey, baseUrl }) {
|
|
255
|
+
const body = await requestJson({
|
|
256
|
+
apiKey,
|
|
257
|
+
baseUrl,
|
|
258
|
+
endpoint: "/models",
|
|
259
|
+
method: "GET"
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const models = Array.isArray(body?.data) ? body.data : [];
|
|
263
|
+
return models.map((item) => ({
|
|
264
|
+
id: item.id,
|
|
265
|
+
created: item.created,
|
|
266
|
+
owned_by: item.owned_by
|
|
267
|
+
}));
|
|
268
|
+
}
|