wavebrain 0.2.0 → 0.3.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/README.md +9 -44
- package/bin/wavebrain.mjs +32 -7
- package/package.json +3 -11
- package/assets/SKILL.md +0 -27
- package/assets/hermes/__init__.py +0 -282
- package/assets/hermes/plugin.yaml +0 -8
- package/assets/hermes/rules.json +0 -31
- package/assets/hooks/core.mjs +0 -320
- package/assets/hooks/hook.mjs +0 -28
- package/assets/hooks/rules.json +0 -31
- package/src/capture.mjs +0 -188
- package/src/cli.mjs +0 -353
- package/src/files.mjs +0 -292
- package/src/state.mjs +0 -36
- package/src/targets.mjs +0 -438
package/assets/hooks/core.mjs
DELETED
|
@@ -1,320 +0,0 @@
|
|
|
1
|
-
// WaveBrain auto-capture, shared by every agent plugin. No dependencies (Node >= 18).
|
|
2
|
-
//
|
|
3
|
-
// Hooks call two verbs:
|
|
4
|
-
// observe: after a tool call. Records high-stakes actions (deploys, pushes, publishes,
|
|
5
|
-
// payments, emails, database writes) to a local queue. Never touches the network
|
|
6
|
-
// unless the queue has been waiting a long time.
|
|
7
|
-
// flush: at the end of a turn. Sends queued actions to WaveBrain, skipping any the agent
|
|
8
|
-
// already logged itself with log_action during that turn.
|
|
9
|
-
//
|
|
10
|
-
// Capture is local-first: a failed upload stays in ~/.wavebrain/queue and is retried next turn.
|
|
11
|
-
|
|
12
|
-
import fs from "node:fs";
|
|
13
|
-
import os from "node:os";
|
|
14
|
-
import path from "node:path";
|
|
15
|
-
import { fileURLToPath } from "node:url";
|
|
16
|
-
|
|
17
|
-
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
-
export const DEFAULT_API_URL = "https://getwavebrain.com/api/v1/actions";
|
|
19
|
-
const MAX_AGE_MS = 7 * 24 * 3600 * 1000; // drop queued items older than a week
|
|
20
|
-
const STALE_MS = 10 * 60 * 1000; // flush from observe if something has waited this long
|
|
21
|
-
const MAX_PER_FLUSH = 25;
|
|
22
|
-
|
|
23
|
-
export function home(env = process.env) {
|
|
24
|
-
return env.WAVEBRAIN_HOME || path.join(env.HOME || os.homedir(), ".wavebrain");
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function loadRules(file = path.join(HERE, "rules.json")) {
|
|
28
|
-
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
29
|
-
return {
|
|
30
|
-
shell: raw.shell.map((r) => ({ ...r, re: new RegExp(r.pattern, "i") })),
|
|
31
|
-
mcpWriteVerbs: raw.mcp_write_verbs,
|
|
32
|
-
mcpSkipServers: new Set(raw.mcp_skip_servers),
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function loadConfig(env = process.env) {
|
|
37
|
-
let cfg = {};
|
|
38
|
-
try {
|
|
39
|
-
cfg = JSON.parse(fs.readFileSync(path.join(home(env), "config.json"), "utf8"));
|
|
40
|
-
} catch {}
|
|
41
|
-
return {
|
|
42
|
-
apiKey: env.WAVEBRAIN_API_KEY || cfg.apiKey || null,
|
|
43
|
-
apiUrl: env.WAVEBRAIN_API_URL || cfg.apiUrl || DEFAULT_API_URL,
|
|
44
|
-
enabled: env.WAVEBRAIN_HOOKS !== "off" && cfg.autoCapture !== false,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// ---------- secrets ----------
|
|
49
|
-
|
|
50
|
-
const SECRET_PATTERNS = [
|
|
51
|
-
/\b(sk|rk|pk)_(live|test)_[A-Za-z0-9]{8,}/g, // Stripe
|
|
52
|
-
/\bwhsec_[A-Za-z0-9]{8,}/g,
|
|
53
|
-
/\b(ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{16,}/g,
|
|
54
|
-
/\bnpm_[A-Za-z0-9]{20,}/g,
|
|
55
|
-
/\bal_[A-Za-z0-9_-]{16,}/g, // WaveBrain keys
|
|
56
|
-
/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, // Slack
|
|
57
|
-
/\bAKIA[0-9A-Z]{16}\b/g, // AWS
|
|
58
|
-
/\bAIza[0-9A-Za-z_-]{30,}/g, // Google
|
|
59
|
-
/\b(sk-(proj-|ant-)?)[A-Za-z0-9_-]{16,}/g, // OpenAI / Anthropic
|
|
60
|
-
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, // JWT
|
|
61
|
-
/(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]{12,}/gi,
|
|
62
|
-
/\b([a-z][a-z0-9+.-]*:\/\/)[^\s:@/]+:[^\s@/]+@/gi, // user:pass@ in URLs
|
|
63
|
-
/((?:password|passwd|secret|token|api[_-]?key|apikey|auth|access[_-]?key|private[_-]?key)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi,
|
|
64
|
-
/(--(?:password|token|api-key|secret|auth)[= ])("[^"]*"|'[^']*'|\S+)/gi,
|
|
65
|
-
/\b[A-Za-z0-9+/_-]{40,}={0,2}/g, // long opaque blobs
|
|
66
|
-
];
|
|
67
|
-
|
|
68
|
-
export function redact(text) {
|
|
69
|
-
let s = String(text ?? "");
|
|
70
|
-
for (const re of SECRET_PATTERNS) {
|
|
71
|
-
s = s.replace(re, (m, a, b) => {
|
|
72
|
-
if (re.source.startsWith("((?:password") || re.source.startsWith("(--(")) return `${a}[redacted]`;
|
|
73
|
-
if (re.source.startsWith("(Bearer")) return `${a} [redacted]`;
|
|
74
|
-
if (re.source.startsWith("\\b([a-z]")) return `${a}[redacted]@`;
|
|
75
|
-
return "[redacted]";
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
return s;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// ---------- classification ----------
|
|
82
|
-
|
|
83
|
-
export function shellCommand(input) {
|
|
84
|
-
const c = input?.command ?? input?.cmd;
|
|
85
|
-
if (Array.isArray(c)) return c.join(" ");
|
|
86
|
-
return typeof c === "string" ? c : "";
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const SHELL_TOOLS = new Set(["bash", "shell", "local_shell", "exec", "terminal", "run_terminal_cmd", "execute_command"]);
|
|
90
|
-
|
|
91
|
-
/** Splits "mcp__server__tool" (Claude Code, Codex) or "mcp_server_tool" (Hermes) into parts. */
|
|
92
|
-
export function mcpParts(name) {
|
|
93
|
-
let m = name.match(/^mcp__(.+?)__(.+)$/);
|
|
94
|
-
if (m) return { server: m[1], tool: m[2] };
|
|
95
|
-
m = name.match(/^mcp_([a-z0-9-]+)_(.+)$/i);
|
|
96
|
-
if (m) return { server: m[1], tool: m[2] };
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function isAgentLog(toolName, toolInput) {
|
|
101
|
-
const n = String(toolName || "");
|
|
102
|
-
if (/wavebrain.*log_action$/i.test(n)) return true;
|
|
103
|
-
// Cursor reports MCP calls as tool "MCP" with the real name inside the input.
|
|
104
|
-
const inner = toolInput?.toolName || toolInput?.tool_name || toolInput?.name;
|
|
105
|
-
return /^mcp$/i.test(n) && /log_action$/i.test(String(inner || "")) && /wavebrain/i.test(JSON.stringify(toolInput));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const firstLine = (s, n = 140) => {
|
|
109
|
-
const one = String(s).replace(/\s+/g, " ").trim();
|
|
110
|
-
return one.length > n ? one.slice(0, n - 1) + "…" : one;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Returns { type, title, details, rule } for a tool call worth recording, or null.
|
|
115
|
-
* ev: { toolName, toolInput, cwd, failed }
|
|
116
|
-
*/
|
|
117
|
-
export function classify(rules, ev) {
|
|
118
|
-
const name = String(ev.toolName || "");
|
|
119
|
-
const input = ev.toolInput || {};
|
|
120
|
-
const where = ev.cwd ? ` in ${path.basename(ev.cwd)}` : "";
|
|
121
|
-
const outcome = ev.failed ? " It failed." : "";
|
|
122
|
-
|
|
123
|
-
if (SHELL_TOOLS.has(name.toLowerCase())) {
|
|
124
|
-
const cmd = shellCommand(input).trim();
|
|
125
|
-
if (!cmd) return null;
|
|
126
|
-
for (const r of rules.shell) {
|
|
127
|
-
const m = cmd.match(r.re);
|
|
128
|
-
if (!m) continue;
|
|
129
|
-
// The segment that matched, not the whole compound command.
|
|
130
|
-
const from = m.index + (m[1]?.length ?? 0);
|
|
131
|
-
const start = Math.max(0, ...["&&", ";", "|"].map((d) => {
|
|
132
|
-
const i = cmd.lastIndexOf(d, from - 1);
|
|
133
|
-
return i < 0 ? 0 : i + d.length;
|
|
134
|
-
}));
|
|
135
|
-
const ends = ["&&", ";", "|"].map((d) => cmd.indexOf(d, m.index + m[0].length)).filter((i) => i >= 0);
|
|
136
|
-
const segment = cmd.slice(start, ends.length ? Math.min(...ends) : cmd.length).trim();
|
|
137
|
-
const safe = redact(segment);
|
|
138
|
-
return {
|
|
139
|
-
type: r.type,
|
|
140
|
-
title: firstLine(r.title.replace("{cmd}", firstLine(safe, 120)), 200),
|
|
141
|
-
details: `Ran \`${firstLine(redact(cmd), 1500)}\`${where}.${outcome} (Recorded automatically by the WaveBrain hook.)`,
|
|
142
|
-
rule: r.id,
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const mcp = mcpParts(name) ?? (/^mcp$/i.test(name) ? { server: String(input.server || input.serverName || "mcp"), tool: String(input.toolName || input.tool_name || input.name || "") } : null);
|
|
149
|
-
if (mcp && mcp.tool) {
|
|
150
|
-
if (rules.mcpSkipServers.has(mcp.server)) return null;
|
|
151
|
-
const verbs = mcp.tool.toLowerCase().split(/[_\-.]+/);
|
|
152
|
-
if (!verbs.some((v) => rules.mcpWriteVerbs.includes(v))) return null;
|
|
153
|
-
const args = redact(JSON.stringify(input.arguments ?? input.args ?? input)).slice(0, 1500);
|
|
154
|
-
return {
|
|
155
|
-
type: "api_call",
|
|
156
|
-
title: firstLine(`Used ${mcp.server}: ${mcp.tool.replace(/[_-]+/g, " ")}`, 200),
|
|
157
|
-
details: `Called the MCP tool ${mcp.server}/${mcp.tool}${where} with ${args}.${outcome} (Recorded automatically by the WaveBrain hook.)`,
|
|
158
|
-
rule: "mcp-write",
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// ---------- queue ----------
|
|
165
|
-
|
|
166
|
-
const safeId = (s) => String(s || "default").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80);
|
|
167
|
-
|
|
168
|
-
export function queueFile(host, session, env = process.env) {
|
|
169
|
-
return path.join(home(env), "queue", `${safeId(host)}-${safeId(session)}.jsonl`);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function readQueue(file) {
|
|
173
|
-
try {
|
|
174
|
-
return fs
|
|
175
|
-
.readFileSync(file, "utf8")
|
|
176
|
-
.split("\n")
|
|
177
|
-
.filter(Boolean)
|
|
178
|
-
.map((l) => {
|
|
179
|
-
try {
|
|
180
|
-
return JSON.parse(l);
|
|
181
|
-
} catch {
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
})
|
|
185
|
-
.filter(Boolean);
|
|
186
|
-
} catch {
|
|
187
|
-
return [];
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function append(file, item) {
|
|
192
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
193
|
-
fs.appendFileSync(file, JSON.stringify(item) + "\n", { mode: 0o600 });
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** After a tool call. Returns what was queued (for tests), or null. */
|
|
197
|
-
export async function observe({ host, session, toolName, toolInput, cwd, failed }, { env = process.env, rules = loadRules(), now = Date.now() } = {}) {
|
|
198
|
-
const cfg = loadConfig(env);
|
|
199
|
-
if (!cfg.enabled) return null;
|
|
200
|
-
const file = queueFile(host, session, env);
|
|
201
|
-
if (isAgentLog(toolName, toolInput)) {
|
|
202
|
-
append(file, { kind: "agent_logged", at: now });
|
|
203
|
-
return { kind: "agent_logged" };
|
|
204
|
-
}
|
|
205
|
-
const action = classify(rules, { toolName, toolInput, cwd, failed });
|
|
206
|
-
if (!action) return null;
|
|
207
|
-
const item = {
|
|
208
|
-
kind: "action",
|
|
209
|
-
at: now,
|
|
210
|
-
payload: {
|
|
211
|
-
agent: host,
|
|
212
|
-
type: action.type,
|
|
213
|
-
title: action.title,
|
|
214
|
-
details: action.details,
|
|
215
|
-
...(cwd && { project: path.basename(cwd) }),
|
|
216
|
-
status: failed ? "failure" : "success",
|
|
217
|
-
metadata: { source: "wavebrain-hook", host, rule: action.rule },
|
|
218
|
-
},
|
|
219
|
-
};
|
|
220
|
-
append(file, item);
|
|
221
|
-
// Hosts without a reliable end-of-turn hook still get their actions out eventually.
|
|
222
|
-
const q = readQueue(file);
|
|
223
|
-
if (q.some((x) => x.kind === "action" && now - x.at > STALE_MS)) await flush({ host, session }, { env, now });
|
|
224
|
-
return item;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** End of turn: send queued actions the agent didn't log itself. Returns counts. */
|
|
228
|
-
export async function flush({ host, session }, { env = process.env, now = Date.now(), fetchImpl = globalThis.fetch } = {}) {
|
|
229
|
-
const cfg = loadConfig(env);
|
|
230
|
-
const file = queueFile(host, session, env);
|
|
231
|
-
const items = readQueue(file);
|
|
232
|
-
if (!items.length) return { sent: 0, skipped: 0, kept: 0 };
|
|
233
|
-
|
|
234
|
-
// The agent described its own work: drop what was captured before its last log_action.
|
|
235
|
-
const lastLog = Math.max(-Infinity, ...items.filter((x) => x.kind === "agent_logged").map((x) => x.at));
|
|
236
|
-
let actions = items.filter((x) => x.kind === "action" && x.at > lastLog && now - x.at < MAX_AGE_MS);
|
|
237
|
-
// Two installs (plugin + init hooks) would queue the same call twice within moments.
|
|
238
|
-
const seen = new Set();
|
|
239
|
-
actions = actions.filter((x) => {
|
|
240
|
-
const k = `${x.payload?.title}|${Math.round(x.at / 5000)}`;
|
|
241
|
-
return seen.has(k) ? false : (seen.add(k), true);
|
|
242
|
-
});
|
|
243
|
-
const skipped = items.filter((x) => x.kind === "action").length - actions.length;
|
|
244
|
-
|
|
245
|
-
if (!cfg.apiKey || !cfg.enabled || !fetchImpl) {
|
|
246
|
-
rewrite(file, actions);
|
|
247
|
-
return { sent: 0, skipped, kept: actions.length };
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const keep = [];
|
|
251
|
-
let sent = 0;
|
|
252
|
-
for (const [i, item] of actions.entries()) {
|
|
253
|
-
if (i >= MAX_PER_FLUSH) {
|
|
254
|
-
keep.push(item);
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
try {
|
|
258
|
-
const res = await fetchImpl(cfg.apiUrl, {
|
|
259
|
-
method: "POST",
|
|
260
|
-
headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` },
|
|
261
|
-
body: JSON.stringify(item.payload),
|
|
262
|
-
signal: AbortSignal.timeout(5000),
|
|
263
|
-
});
|
|
264
|
-
if (res.ok) sent++;
|
|
265
|
-
else if (res.status === 400 || res.status === 401 || res.status === 402) {
|
|
266
|
-
// Not retryable: bad payload, bad key, or plan quota. Drop rather than retry forever.
|
|
267
|
-
} else keep.push(item);
|
|
268
|
-
} catch {
|
|
269
|
-
keep.push(item);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
rewrite(file, keep);
|
|
273
|
-
return { sent, skipped, kept: keep.length };
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function rewrite(file, items) {
|
|
277
|
-
if (!items.length) {
|
|
278
|
-
try {
|
|
279
|
-
fs.unlinkSync(file);
|
|
280
|
-
} catch {}
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
const tmp = `${file}.tmp-${process.pid}`;
|
|
284
|
-
fs.writeFileSync(tmp, items.map((x) => JSON.stringify(x)).join("\n") + "\n", { mode: 0o600 });
|
|
285
|
-
fs.renameSync(tmp, file);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// ---------- host payloads ----------
|
|
289
|
-
|
|
290
|
-
/** Plain-text tool output (Codex) has no exit code: treat a clear error line at the start or end as a failure. */
|
|
291
|
-
export function looksFailed(resp) {
|
|
292
|
-
if (typeof resp !== "string") return false;
|
|
293
|
-
const lines = resp.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
294
|
-
if (!lines.length) return false;
|
|
295
|
-
const bad = (l) => /^(fatal|error|Error|ERROR)(\[[^\]]*\])?:/.test(l) || /: command not found$|^Permission denied|^npm ERR!|^Traceback \(most recent call last\)/.test(l);
|
|
296
|
-
return bad(lines[0]) || bad(lines[lines.length - 1]);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** Normalizes a hook's stdin JSON into { session, toolName, toolInput, cwd, failed }. */
|
|
300
|
-
export function fromHookInput(host, input) {
|
|
301
|
-
const resp = input.tool_response ?? input.tool_output ?? input.result ?? {};
|
|
302
|
-
const exit = resp?.exit_code ?? resp?.exitCode ?? input.exit_code ?? input.exitCode;
|
|
303
|
-
const failed =
|
|
304
|
-
(typeof exit === "number" && exit !== 0) ||
|
|
305
|
-
looksFailed(resp) || // Codex sends only the output text, without an exit code
|
|
306
|
-
|
|
307
|
-
resp?.interrupted === true ||
|
|
308
|
-
resp?.success === false ||
|
|
309
|
-
input.status === "error" ||
|
|
310
|
-
/Failure$/.test(input.hook_event_name || "") || // Claude Code: PostToolUseFailure
|
|
311
|
-
Boolean(input.error);
|
|
312
|
-
return {
|
|
313
|
-
host,
|
|
314
|
-
session: input.session_id || input.conversation_id || input.sessionId || "default",
|
|
315
|
-
toolName: input.tool_name || input.toolName || (input.command !== undefined ? "Shell" : ""),
|
|
316
|
-
toolInput: input.tool_input || input.toolInput || (input.command !== undefined ? { command: input.command } : {}),
|
|
317
|
-
cwd: input.cwd || (Array.isArray(input.workspace_roots) ? input.workspace_roots[0] : undefined),
|
|
318
|
-
failed: Boolean(failed),
|
|
319
|
-
};
|
|
320
|
-
}
|
package/assets/hooks/hook.mjs
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Hook entry point: node hook.mjs <host> <observe|flush>
|
|
3
|
-
// Reads the agent's hook JSON on stdin. Always exits 0 and prints nothing, so a WaveBrain
|
|
4
|
-
// problem can never block or slow down the agent's own work.
|
|
5
|
-
|
|
6
|
-
import { flush, fromHookInput, observe } from "./core.mjs";
|
|
7
|
-
|
|
8
|
-
const [host = "agent", verb = "observe"] = process.argv.slice(2);
|
|
9
|
-
|
|
10
|
-
async function readStdin() {
|
|
11
|
-
if (process.stdin.isTTY) return "";
|
|
12
|
-
let data = "";
|
|
13
|
-
for await (const chunk of process.stdin) data += chunk;
|
|
14
|
-
return data;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
try {
|
|
18
|
-
let input = {};
|
|
19
|
-
try {
|
|
20
|
-
input = JSON.parse((await readStdin()) || "{}");
|
|
21
|
-
} catch {}
|
|
22
|
-
const ev = fromHookInput(host, input);
|
|
23
|
-
if (verb === "observe") await observe(ev);
|
|
24
|
-
else if (verb === "flush") await flush(ev);
|
|
25
|
-
} catch (e) {
|
|
26
|
-
if (process.env.WAVEBRAIN_DEBUG) console.error(`wavebrain hook: ${e?.stack || e}`);
|
|
27
|
-
}
|
|
28
|
-
process.exit(0);
|
package/assets/hooks/rules.json
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$comment": "Shell commands that are recorded automatically. Each rule: regex (case-insensitive, matched against the whole command), WaveBrain action type, and a title template ({cmd} = first matching command segment). Shared by every plugin; edit here, then run `node scripts/sync.mjs`.",
|
|
3
|
-
"version": 1,
|
|
4
|
-
"shell": [
|
|
5
|
-
{ "id": "git-push", "pattern": "(^|[\\s;&|(])git\\s+push\\b", "type": "push", "title": "Pushed to git: {cmd}" },
|
|
6
|
-
{ "id": "git-commit", "pattern": "(^|[\\s;&|(])git\\s+commit\\b", "type": "commit", "title": "Made a git commit: {cmd}" },
|
|
7
|
-
{ "id": "gh-pr-create", "pattern": "(^|[\\s;&|(])gh\\s+pr\\s+create\\b", "type": "pull_request", "title": "Opened a pull request: {cmd}" },
|
|
8
|
-
{ "id": "gh-pr-merge", "pattern": "(^|[\\s;&|(])gh\\s+pr\\s+merge\\b", "type": "merge", "title": "Merged a pull request: {cmd}" },
|
|
9
|
-
{ "id": "gh-release", "pattern": "(^|[\\s;&|(])gh\\s+release\\s+create\\b", "type": "release", "title": "Created a GitHub release: {cmd}" },
|
|
10
|
-
{ "id": "vercel-deploy", "pattern": "(^|[\\s;&|(])(npx\\s+)?vercel\\b[^;&|]*(\\s--prod\\b|\\sdeploy\\b|\\sredeploy\\b|\\spromote\\b)", "type": "deploy", "title": "Deployed with Vercel: {cmd}" },
|
|
11
|
-
{ "id": "vercel-env", "pattern": "(^|[\\s;&|(])(npx\\s+)?vercel\\s+env\\s+(add|rm|remove|update)\\b", "type": "file_change", "title": "Changed Vercel environment variables: {cmd}" },
|
|
12
|
-
{ "id": "railway", "pattern": "(^|[\\s;&|(])railway\\s+(up|deploy|redeploy|variables\\s+--set|variable\\s+set)\\b", "type": "deploy", "title": "Deployed with Railway: {cmd}" },
|
|
13
|
-
{ "id": "fly", "pattern": "(^|[\\s;&|(])(fly|flyctl)\\s+(deploy|secrets\\s+set)\\b", "type": "deploy", "title": "Deployed with Fly.io: {cmd}" },
|
|
14
|
-
{ "id": "wrangler", "pattern": "(^|[\\s;&|(])(npx\\s+)?wrangler\\s+(deploy|publish|secret\\s+put)\\b", "type": "deploy", "title": "Deployed with Cloudflare Wrangler: {cmd}" },
|
|
15
|
-
{ "id": "netlify", "pattern": "(^|[\\s;&|(])netlify\\s+deploy\\b", "type": "deploy", "title": "Deployed with Netlify: {cmd}" },
|
|
16
|
-
{ "id": "kubectl", "pattern": "(^|[\\s;&|(])kubectl\\s+(apply|rollout|delete|scale|set)\\b", "type": "deploy", "title": "Changed Kubernetes: {cmd}" },
|
|
17
|
-
{ "id": "terraform", "pattern": "(^|[\\s;&|(])(terraform|tofu)\\s+(apply|destroy)\\b", "type": "deploy", "title": "Applied infrastructure changes: {cmd}" },
|
|
18
|
-
{ "id": "docker-push", "pattern": "(^|[\\s;&|(])docker\\s+(push|buildx\\s+build[^;&|]*--push)\\b", "type": "publish", "title": "Pushed a Docker image: {cmd}" },
|
|
19
|
-
{ "id": "npm-publish", "pattern": "(^|[\\s;&|(])(npm|pnpm|yarn|bun)\\s+publish\\b", "type": "publish", "title": "Published a package: {cmd}" },
|
|
20
|
-
{ "id": "eas", "pattern": "(^|[\\s;&|(])(npx\\s+)?eas\\s+(submit|update|build)\\b", "type": "release", "title": "Shipped a mobile build with EAS: {cmd}" },
|
|
21
|
-
{ "id": "xcrun-altool", "pattern": "(^|[\\s;&|(])xcrun\\s+altool\\b[^;&|]*--upload|(^|[\\s;&|(])fastlane\\s+(deliver|pilot|upload_to|release)", "type": "release", "title": "Uploaded an app build: {cmd}" },
|
|
22
|
-
{ "id": "stripe", "pattern": "(^|[\\s;&|(])stripe\\s+(products|prices|customers|subscriptions|refunds|payment_intents|webhook_endpoints)\\s+(create|update|delete|cancel)\\b", "type": "api_call", "title": "Changed Stripe: {cmd}" },
|
|
23
|
-
{ "id": "db-migrate", "pattern": "(^|[\\s;&|(])((npx\\s+)?(prisma\\s+migrate\\s+deploy|drizzle-kit\\s+(migrate|push))|(pnpm|npm|yarn)\\s+(run\\s+)?db:(migrate|push))\\b", "type": "database", "title": "Ran database migrations: {cmd}" },
|
|
24
|
-
{ "id": "psql-write", "pattern": "(^|[\\s;&|(])psql\\b[^;&|]*\\b(insert\\s+into|update\\s+\\w+\\s+set|delete\\s+from|drop\\s+table|alter\\s+table|truncate)\\b", "type": "database", "title": "Changed a database: {cmd}" },
|
|
25
|
-
{ "id": "curl-write", "pattern": "(^|[\\s;&|(])curl\\b[^;&|]*\\s(-X|--request)\\s*(POST|PUT|PATCH|DELETE)\\b", "type": "api_call", "title": "Called an API: {cmd}" },
|
|
26
|
-
{ "id": "send-mail", "pattern": "(^|[\\s;&|(])(sendmail|mail|mutt|himalaya\\s+(send|message\\s+send))\\b", "type": "email", "title": "Sent an email: {cmd}" }
|
|
27
|
-
],
|
|
28
|
-
"$comment_mcp": "MCP tool calls are recorded when the tool name matches one of these (after the mcp__<server>__ prefix). Read-only tools (list, get, search, read, fetch) are skipped.",
|
|
29
|
-
"mcp_write_verbs": ["create", "update", "delete", "remove", "send", "post", "publish", "deploy", "merge", "push", "submit", "cancel", "refund", "charge", "invite", "set", "add", "upload", "write", "reply", "comment", "approve", "close", "archive"],
|
|
30
|
-
"mcp_skip_servers": ["wavebrain", "context-mode", "plugin_context-mode_context-mode", "claude-in-chrome", "honcho"]
|
|
31
|
-
}
|
package/src/capture.mjs
DELETED
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
// Auto-capture: the hooks, skills and plugins `init` installs next to each agent's MCP server.
|
|
2
|
-
// Everything here goes through Session.edit, so --dry-run, backups and `remove` work the same way.
|
|
3
|
-
|
|
4
|
-
import { spawnSync } from "node:child_process";
|
|
5
|
-
import fs from "node:fs";
|
|
6
|
-
import path from "node:path";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { Conflict, parseJson, readText } from "./files.mjs";
|
|
9
|
-
|
|
10
|
-
const ASSETS = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "assets");
|
|
11
|
-
const asset = (...p) => fs.readFileSync(path.join(ASSETS, ...p), "utf8");
|
|
12
|
-
const dump = (obj) => JSON.stringify(obj, null, 2) + "\n";
|
|
13
|
-
|
|
14
|
-
export const HOOK_FILES = ["core.mjs", "hook.mjs", "rules.json"];
|
|
15
|
-
export const HERMES_FILES = ["__init__.py", "plugin.yaml", "rules.json"];
|
|
16
|
-
|
|
17
|
-
export function wbHome(ctx) {
|
|
18
|
-
return ctx.env.WAVEBRAIN_HOME || path.join(ctx.home, ".wavebrain");
|
|
19
|
-
}
|
|
20
|
-
export const hooksDir = (ctx) => path.join(wbHome(ctx), "hooks");
|
|
21
|
-
export const configFile = (ctx) => path.join(wbHome(ctx), "config.json");
|
|
22
|
-
|
|
23
|
-
/** The command a hook runs. Quoted so homes with spaces work. */
|
|
24
|
-
export function hookCommand(ctx, host, verb) {
|
|
25
|
-
return `node "${path.join(hooksDir(ctx), "hook.mjs")}" ${host} ${verb}`;
|
|
26
|
-
}
|
|
27
|
-
const isOurs = (cmd) => typeof cmd === "string" && /wavebrain/.test(cmd) && /hook\.mjs/.test(cmd);
|
|
28
|
-
|
|
29
|
-
export const restUrl = (mcpUrl) => mcpUrl.replace(/\/api\/mcp\/?$/, "/api/v1/actions");
|
|
30
|
-
|
|
31
|
-
// ---------- shared files: the hook runner and ~/.wavebrain/config.json ----------
|
|
32
|
-
|
|
33
|
-
/** Copies the hook runner into ~/.wavebrain/hooks and saves the key for hooks to use. */
|
|
34
|
-
export function stageCapture(ctx, s, { key, url }) {
|
|
35
|
-
const out = [];
|
|
36
|
-
for (const f of HOOK_FILES) {
|
|
37
|
-
const file = path.join(hooksDir(ctx), f);
|
|
38
|
-
const before = readText(file);
|
|
39
|
-
out.push({ ref: `capture:${f}`, file, kind: s.edit(file, () => asset("hooks", f)), createdFile: before === null });
|
|
40
|
-
}
|
|
41
|
-
const cf = configFile(ctx);
|
|
42
|
-
const before = readText(cf);
|
|
43
|
-
out.push({
|
|
44
|
-
ref: "capture:config",
|
|
45
|
-
file: cf,
|
|
46
|
-
createdFile: before === null,
|
|
47
|
-
kind: s.edit(cf, (t) => {
|
|
48
|
-
const cur = safeParse(t, cf);
|
|
49
|
-
const next = { ...cur, apiKey: key, apiUrl: restUrl(url), autoCapture: cur.autoCapture ?? true };
|
|
50
|
-
return JSON.stringify(cur) === JSON.stringify(next) && t !== null ? t : dump(next);
|
|
51
|
-
}),
|
|
52
|
-
});
|
|
53
|
-
return out;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function unstageCapture(ctx, s) {
|
|
57
|
-
return [...HOOK_FILES.map((f) => path.join(hooksDir(ctx), f)), configFile(ctx)]
|
|
58
|
-
.filter((f) => readText(f) !== null)
|
|
59
|
-
.map((file) => ({ ref: "capture:files", file, kind: s.edit(file, () => null) }));
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function safeParse(text, file) {
|
|
63
|
-
try {
|
|
64
|
-
return parseJson(text, file);
|
|
65
|
-
} catch {
|
|
66
|
-
return {};
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ---------- hooks.json style files ----------
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* format "nested" (Claude Code settings.json, Codex hooks.json):
|
|
74
|
-
* { hooks: { Event: [ { matcher?, hooks: [ { type: "command", command, timeout } ] } ] } }
|
|
75
|
-
* format "flat" (Cursor hooks.json):
|
|
76
|
-
* { version: 1, hooks: { event: [ { command, matcher? } ] } }
|
|
77
|
-
* entries: [{ event, matcher?, command, timeout? }]
|
|
78
|
-
*/
|
|
79
|
-
export function addHooks(text, file, format, entries) {
|
|
80
|
-
const obj = parseJson(text, file);
|
|
81
|
-
if (obj.hooks !== undefined && (typeof obj.hooks !== "object" || Array.isArray(obj.hooks))) {
|
|
82
|
-
throw new Conflict(`${file}: "hooks" isn't an object; add WaveBrain's hooks by hand instead`);
|
|
83
|
-
}
|
|
84
|
-
const cleaned = stripOurs(obj, format);
|
|
85
|
-
if (format === "flat") cleaned.version ??= 1;
|
|
86
|
-
cleaned.hooks ??= {};
|
|
87
|
-
for (const e of entries) {
|
|
88
|
-
const list = Array.isArray(cleaned.hooks[e.event]) ? cleaned.hooks[e.event] : [];
|
|
89
|
-
if (format === "flat") list.push({ command: e.command, ...(e.matcher && { matcher: e.matcher }) });
|
|
90
|
-
else list.push({ ...(e.matcher && { matcher: e.matcher }), hooks: [{ type: "command", command: e.command, timeout: e.timeout ?? 30 }] });
|
|
91
|
-
cleaned.hooks[e.event] = list;
|
|
92
|
-
}
|
|
93
|
-
const next = dump(cleaned);
|
|
94
|
-
return text !== null && JSON.stringify(parseJson(text, file)) === JSON.stringify(cleaned) ? text : next;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function removeHooks(text, file, format, { deleteIfEmpty = false } = {}) {
|
|
98
|
-
if (text === null) return null;
|
|
99
|
-
const obj = parseJson(text, file);
|
|
100
|
-
const cleaned = stripOurs(obj, format);
|
|
101
|
-
if (JSON.stringify(cleaned) === JSON.stringify(obj)) return text;
|
|
102
|
-
if (cleaned.hooks && !Object.keys(cleaned.hooks).length) delete cleaned.hooks;
|
|
103
|
-
if (format === "flat" && Object.keys(cleaned).every((k) => k === "version")) delete cleaned.version;
|
|
104
|
-
if (deleteIfEmpty && !Object.keys(cleaned).length) return null;
|
|
105
|
-
return dump(cleaned);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function stripOurs(obj, format) {
|
|
109
|
-
const copy = JSON.parse(JSON.stringify(obj));
|
|
110
|
-
if (!copy.hooks || typeof copy.hooks !== "object") return copy;
|
|
111
|
-
for (const [event, list] of Object.entries(copy.hooks)) {
|
|
112
|
-
if (!Array.isArray(list)) continue;
|
|
113
|
-
const kept =
|
|
114
|
-
format === "flat"
|
|
115
|
-
? list.filter((h) => !isOurs(h?.command))
|
|
116
|
-
: list
|
|
117
|
-
.map((g) => (Array.isArray(g?.hooks) ? { ...g, hooks: g.hooks.filter((h) => !isOurs(h?.command)) } : g))
|
|
118
|
-
.filter((g) => !Array.isArray(g?.hooks) || g.hooks.length);
|
|
119
|
-
if (kept.length) copy.hooks[event] = kept;
|
|
120
|
-
else delete copy.hooks[event];
|
|
121
|
-
}
|
|
122
|
-
return copy;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export function hasOurHooks(text) {
|
|
126
|
-
return typeof text === "string" && /wavebrain[^"\n]*hook\.mjs/.test(text);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// ---------- Codex: [features] hooks = true ----------
|
|
130
|
-
|
|
131
|
-
const TAG = "# added by wavebrain init";
|
|
132
|
-
|
|
133
|
-
export function codexEnableHooks(text) {
|
|
134
|
-
const t = text ?? "";
|
|
135
|
-
const lines = t.split("\n");
|
|
136
|
-
const h = lines.findIndex((l) => /^\s*\[features\]\s*(#.*)?$/.test(l));
|
|
137
|
-
if (h === -1) {
|
|
138
|
-
// Before our server block if there is one, so re-running init (which rewrites that block
|
|
139
|
-
// at the end of the file) doesn't reorder anything.
|
|
140
|
-
const block = lines.findIndex((l) => l.trim().startsWith("# >>> wavebrain"));
|
|
141
|
-
if (block !== -1) {
|
|
142
|
-
lines.splice(block, 0, `[features] ${TAG}`, `hooks = true ${TAG}`, "");
|
|
143
|
-
return lines.join("\n");
|
|
144
|
-
}
|
|
145
|
-
return `${t.replace(/\n*$/, t ? "\n\n" : "")}[features] ${TAG}\nhooks = true ${TAG}\n`;
|
|
146
|
-
}
|
|
147
|
-
let end = lines.findIndex((l, i) => i > h && /^\s*\[/.test(l));
|
|
148
|
-
if (end === -1) end = lines.length;
|
|
149
|
-
const existing = lines.slice(h + 1, end).findIndex((l) => /^\s*hooks\s*=/.test(l));
|
|
150
|
-
if (existing !== -1) {
|
|
151
|
-
if (/^\s*hooks\s*=\s*true\b/.test(lines[h + 1 + existing])) return text;
|
|
152
|
-
throw new Conflict("~/.codex/config.toml sets [features] hooks = false; WaveBrain's hooks need it on (left as is)");
|
|
153
|
-
}
|
|
154
|
-
lines.splice(h + 1, 0, `hooks = true ${TAG}`);
|
|
155
|
-
return lines.join("\n");
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export function codexDisableHooks(text) {
|
|
159
|
-
if (text === null || !text.includes(TAG)) return text;
|
|
160
|
-
const out = text
|
|
161
|
-
.split("\n")
|
|
162
|
-
.filter((l) => !(l.includes(TAG) && /^\s*hooks\s*=/.test(l)))
|
|
163
|
-
.map((l) => (l.includes(TAG) && /^\s*\[features\]/.test(l) ? null : l))
|
|
164
|
-
.filter((l) => l !== null)
|
|
165
|
-
.join("\n")
|
|
166
|
-
.replace(/\n{3,}/g, "\n\n");
|
|
167
|
-
return out.trim() ? out : null;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// ---------- skills and the Hermes plugin ----------
|
|
171
|
-
|
|
172
|
-
export function skillText() {
|
|
173
|
-
return asset("SKILL.md");
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
export function hermesPluginFiles() {
|
|
177
|
-
return HERMES_FILES.map((f) => [f, asset("hermes", f)]);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Runs `hermes plugins enable|disable wavebrain` against one Hermes home. Best effort. */
|
|
181
|
-
export function hermesPluginToggle(ctx, hermesDir, on) {
|
|
182
|
-
const r = spawnSync("hermes", ["plugins", on ? "enable" : "disable", "wavebrain"], {
|
|
183
|
-
env: { ...ctx.env, HERMES_HOME: hermesDir },
|
|
184
|
-
encoding: "utf8",
|
|
185
|
-
timeout: 60_000,
|
|
186
|
-
});
|
|
187
|
-
return r.status === 0;
|
|
188
|
-
}
|