hydra-nano 0.1.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 +96 -153
- package/package.json +15 -4
- package/skills/hydra/SKILL.md +13 -19
- package/skills/hydra/scripts/hooks/route-tests.mjs +45 -13
- package/skills/hydra/scripts/hooks/session-guide.mjs +21 -5
- package/skills/hydra/scripts/hydra.mjs +267 -23
- package/skills/hydra/scripts/install.mjs +20 -1
- package/skills/hydra/scripts/lib/claude_hooks.mjs +109 -5
- package/skills/hydra/scripts/lib/integrations.mjs +288 -0
- package/skills/hydra/scripts/lib/ledger.mjs +110 -7
- package/skills/hydra/scripts/lib/refactor.mjs +1122 -0
- package/skills/hydra/scripts/lib/test_logs.mjs +10 -6
- package/skills/hydra/scripts/mcp.mjs +179 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Hydra in every coding tool: the MCP server (mcp.mjs) for tools, and a shell-command hook that sends
|
|
2
|
+
// test/build runs through Hydra wherever the tool has one. Claude Code keeps its skill and hooks
|
|
3
|
+
// (claude_hooks.mjs) and gets no MCP entry, since that would duplicate the same tools.
|
|
4
|
+
//
|
|
5
|
+
// tool MCP entry hook
|
|
6
|
+
// codex ~/.codex/config.toml [mcp_servers.hydra] ~/.codex/hooks.json PreToolUse + SessionStart (trust with /hooks)
|
|
7
|
+
// cursor ~/.cursor/mcp.json mcpServers.hydra ~/.cursor/hooks.json beforeShellExecution + sessionStart
|
|
8
|
+
// opencode ~/.config/opencode/opencode.json(c) mcp.hydra ~/.config/opencode/plugins/hydra.js tool.execute.before
|
|
9
|
+
// gemini-cli ~/.gemini/settings.json mcpServers.hydra same file, hooks.BeforeTool on run_shell_command
|
|
10
|
+
// antigravity ~/.gemini/antigravity/mcp_config.json none known; the MCP server's instructions guide it
|
|
11
|
+
// antigravity-ide ~/.gemini/config/mcp_config.json none known; the skill in ~/.gemini/config/skills/hydra guides it
|
|
12
|
+
//
|
|
13
|
+
// Antigravity IDE (com.google.antigravity-ide, data in ~/.gemini/antigravity-ide) is a different app from
|
|
14
|
+
// the older Antigravity (~/.gemini/antigravity): it reads MCP servers and skills from ~/.gemini/config.
|
|
15
|
+
//
|
|
16
|
+
// Every file Hydra edits is backed up once as <file>.before-hydra, written atomically, and only
|
|
17
|
+
// Hydra's own entries are added or removed. A file Hydra cannot edit safely (JSON with comments, or
|
|
18
|
+
// invalid) is left alone and the entry to add by hand is reported instead.
|
|
19
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
const home = () => process.env.HYDRA_TOOLS_HOME || os.homedir();
|
|
24
|
+
const OURS = /[\\/](?:\.hydra[\\/]scripts|skills[\\/]hydra[\\/]scripts)[\\/]/;
|
|
25
|
+
const BEGIN = '# >>> hydra (managed by Hydra; remove with: hydra agents remove codex) >>>';
|
|
26
|
+
const END = '# <<< hydra <<<';
|
|
27
|
+
|
|
28
|
+
/** Hydra's own copy of its scripts, which every tool's MCP entry and hook points at. */
|
|
29
|
+
export const sharedScripts = () => path.join(process.env.HYDRA_HOME ?? path.join(os.homedir(), '.hydra'), 'scripts');
|
|
30
|
+
|
|
31
|
+
/** Copy the skill's scripts to sharedScripts(), so registrations never point into an npx cache.
|
|
32
|
+
* Its SKILL.md goes beside them, for tools that load skills from a folder of their own. */
|
|
33
|
+
export function installSharedScripts(from) {
|
|
34
|
+
const target = sharedScripts();
|
|
35
|
+
if (path.resolve(from) === path.resolve(target)) return target; // Already running from the shared copy.
|
|
36
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
37
|
+
rmSync(`${target}.hydra-tmp`, { recursive: true, force: true });
|
|
38
|
+
cpSync(from, `${target}.hydra-tmp`, { recursive: true });
|
|
39
|
+
rmSync(target, { recursive: true, force: true });
|
|
40
|
+
renameSync(`${target}.hydra-tmp`, target);
|
|
41
|
+
const skill = path.join(from, '..', 'SKILL.md');
|
|
42
|
+
if (existsSync(skill)) copyFileSync(skill, path.join(path.dirname(target), 'SKILL.md'));
|
|
43
|
+
return target;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const quote = value => JSON.stringify(value);
|
|
47
|
+
const node = () => process.execPath;
|
|
48
|
+
const hookCommand = (scripts, name, format) => `${quote(node())} ${quote(path.join(scripts, 'hooks', name))} --format ${format}`;
|
|
49
|
+
|
|
50
|
+
function backup(file) {
|
|
51
|
+
if (existsSync(file) && !existsSync(`${file}.before-hydra`)) copyFileSync(file, `${file}.before-hydra`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function write(file, text) {
|
|
55
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
56
|
+
backup(file);
|
|
57
|
+
writeFileSync(`${file}.hydra-tmp`, text);
|
|
58
|
+
renameSync(`${file}.hydra-tmp`, file);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class Manual extends Error {
|
|
62
|
+
constructor(file, snippet, why) { super(`${file} ${why}; add this by hand:\n${snippet}`); this.manual = true; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read a JSON settings file Hydra may rewrite. Comments would be lost on rewrite, so they stop it. */
|
|
66
|
+
function loadJson(file, snippet) {
|
|
67
|
+
if (!existsSync(file)) return {};
|
|
68
|
+
const text = readFileSync(file, 'utf8');
|
|
69
|
+
if (!text.trim()) return {};
|
|
70
|
+
const withoutStrings = text.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
|
71
|
+
if (/\/\/|\/\*/.test(withoutStrings)) throw new Manual(file, snippet, 'has comments that a rewrite would drop');
|
|
72
|
+
let value;
|
|
73
|
+
try { value = JSON.parse(text); } catch { value = null; }
|
|
74
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Manual(file, snippet, 'is not valid JSON');
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const saveJson = (file, value) => write(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
79
|
+
const mentionsOurs = value => OURS.test(JSON.stringify(value ?? ''));
|
|
80
|
+
|
|
81
|
+
/** Add or remove Hydra's entry in a JSON object of named servers, such as mcpServers. */
|
|
82
|
+
function jsonServer(file, key, entry) {
|
|
83
|
+
return {
|
|
84
|
+
install() {
|
|
85
|
+
const snippet = JSON.stringify({ [key]: { hydra: entry } }, null, 2);
|
|
86
|
+
const settings = loadJson(file, snippet);
|
|
87
|
+
settings[key] = { ...(settings[key] ?? {}), hydra: entry };
|
|
88
|
+
saveJson(file, settings);
|
|
89
|
+
return `${file}: ${key}.hydra`;
|
|
90
|
+
},
|
|
91
|
+
remove() {
|
|
92
|
+
if (!existsSync(file)) return null;
|
|
93
|
+
const settings = loadJson(file, '');
|
|
94
|
+
if (!settings[key]?.hydra || !mentionsOurs(settings[key].hydra)) return null;
|
|
95
|
+
delete settings[key].hydra;
|
|
96
|
+
if (!Object.keys(settings[key]).length) delete settings[key];
|
|
97
|
+
saveJson(file, settings);
|
|
98
|
+
return `${file}: ${key}.hydra`;
|
|
99
|
+
},
|
|
100
|
+
status: () => existsSync(file) && (() => { try { return mentionsOurs(loadJson(file, '')[key]?.hydra); } catch { return false; } })(),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Claude-style hook lists ({event: [{matcher, hooks: [{type, command}]}]}) under `root` in a JSON file. */
|
|
105
|
+
function jsonHooks(file, events, { root = 'hooks', extra = {} } = {}) {
|
|
106
|
+
const without = settings => {
|
|
107
|
+
for (const [event, groups] of Object.entries(settings[root] ?? {})) {
|
|
108
|
+
if (!Array.isArray(groups)) continue;
|
|
109
|
+
const kept = groups.map(group => (group.hooks ? { ...group, hooks: group.hooks.filter(hook => !OURS.test(hook.command ?? '')) } : group))
|
|
110
|
+
.filter(group => (group.hooks ? group.hooks.length : !OURS.test(group.command ?? '')));
|
|
111
|
+
if (kept.length) settings[root][event] = kept; else delete settings[root][event];
|
|
112
|
+
}
|
|
113
|
+
if (settings[root] && !Object.keys(settings[root]).length) delete settings[root];
|
|
114
|
+
return settings;
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
install() {
|
|
118
|
+
const snippet = JSON.stringify({ ...extra, [root]: events }, null, 2);
|
|
119
|
+
const settings = without(loadJson(file, snippet));
|
|
120
|
+
Object.assign(settings, Object.fromEntries(Object.entries(extra).filter(([k]) => settings[k] === undefined)));
|
|
121
|
+
settings[root] ??= {};
|
|
122
|
+
for (const [event, groups] of Object.entries(events)) (settings[root][event] ??= []).push(...groups);
|
|
123
|
+
saveJson(file, settings);
|
|
124
|
+
return `${file}: ${Object.keys(events).join(', ')}`;
|
|
125
|
+
},
|
|
126
|
+
remove() {
|
|
127
|
+
if (!existsSync(file)) return null;
|
|
128
|
+
const settings = loadJson(file, '');
|
|
129
|
+
const before = JSON.stringify(settings);
|
|
130
|
+
without(settings);
|
|
131
|
+
if (JSON.stringify(settings) === before) return null;
|
|
132
|
+
saveJson(file, settings);
|
|
133
|
+
return `${file}: hooks`;
|
|
134
|
+
},
|
|
135
|
+
status: () => existsSync(file) && OURS.test(readFileSync(file, 'utf8')),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Codex keeps MCP servers in TOML; Hydra's table sits between marker comments it alone writes. */
|
|
140
|
+
function tomlServer(file, scripts) {
|
|
141
|
+
const block = `${BEGIN}\n[mcp_servers.hydra]\ncommand = ${quote(node())}\nargs = [${quote(path.join(scripts, 'mcp.mjs'))}]\nstartup_timeout_sec = 20\ntool_timeout_sec = 1800\n${END}\n`;
|
|
142
|
+
// Install appends "\n" + block to text ending in a newline, so removing "\n" + block restores it exactly.
|
|
143
|
+
const strip = text => text.replace(new RegExp(`\\n?${BEGIN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${END}\\n?`), '');
|
|
144
|
+
return {
|
|
145
|
+
install() {
|
|
146
|
+
const text = existsSync(file) ? strip(readFileSync(file, 'utf8')) : '';
|
|
147
|
+
if (/^\[mcp_servers\.hydra\]/m.test(text)) throw new Manual(file, block, 'already has an [mcp_servers.hydra] table that Hydra did not write');
|
|
148
|
+
// Appended at the end: a TOML table runs until the next header, so nothing after it is captured.
|
|
149
|
+
write(file, `${text.replace(/\s*$/, '\n')}\n${block}`);
|
|
150
|
+
return `${file}: [mcp_servers.hydra]`;
|
|
151
|
+
},
|
|
152
|
+
remove() {
|
|
153
|
+
if (!existsSync(file)) return null;
|
|
154
|
+
const text = readFileSync(file, 'utf8');
|
|
155
|
+
if (!text.includes(BEGIN)) return null;
|
|
156
|
+
write(file, strip(text));
|
|
157
|
+
return `${file}: [mcp_servers.hydra]`;
|
|
158
|
+
},
|
|
159
|
+
status: () => existsSync(file) && readFileSync(file, 'utf8').includes(BEGIN),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** OpenCode loads every file in its plugins folder; Hydra's is one small file that imports the shared scripts. */
|
|
164
|
+
function opencodePlugin(file, scripts) {
|
|
165
|
+
const url = name => quote(new URL(`file://${path.join(scripts, name).replace(/\\/g, '/')}`).href);
|
|
166
|
+
const source = `// Hydra (managed by Hydra; remove with: hydra agents remove opencode): send test/build runs through Hydra.
|
|
167
|
+
import { testRun } from ${url('lib/claude_hooks.mjs')};
|
|
168
|
+
import { record } from ${url('lib/ledger.mjs')};
|
|
169
|
+
|
|
170
|
+
export const Hydra = async () => ({
|
|
171
|
+
'tool.execute.before': async (input, output) => {
|
|
172
|
+
if (input.tool !== 'bash') return;
|
|
173
|
+
const command = String(output.args?.command ?? '');
|
|
174
|
+
const run = testRun(command);
|
|
175
|
+
if (!run) return;
|
|
176
|
+
record({ ts: new Date().toISOString(), kind: 'hook', hook: 'route-tests', agent: 'opencode', decision: 'deny',
|
|
177
|
+
command: command.slice(0, 300), run, ...(input.sessionID ? { session: input.sessionID } : {}) });
|
|
178
|
+
throw new Error('Test/build runs go through Hydra so the full log stays out of context. Run it as: ' +
|
|
179
|
+
'node ' + ${quote(JSON.stringify(path.join(scripts, 'hydra.mjs')))} + ' run -- ' + run + ', or call the hydra_run tool with that command. ' +
|
|
180
|
+
'If you truly need the raw log, prefix the original command with HYDRA_RAW=1.');
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
`;
|
|
184
|
+
return {
|
|
185
|
+
install() { write(file, source); return file; },
|
|
186
|
+
remove() {
|
|
187
|
+
if (!existsSync(file) || !readFileSync(file, 'utf8').includes('managed by Hydra')) return null;
|
|
188
|
+
rmSync(file);
|
|
189
|
+
if (existsSync(`${file}.before-hydra`)) rmSync(`${file}.before-hydra`);
|
|
190
|
+
return file;
|
|
191
|
+
},
|
|
192
|
+
status: () => existsSync(file) && readFileSync(file, 'utf8').includes('managed by Hydra'),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The hydra skill folder (SKILL.md and the scripts it names) in a tool's own skills folder.
|
|
197
|
+
* An older hydra skill there is replaced; a folder that is not the hydra skill is left alone. */
|
|
198
|
+
function skillFolder(dir, scripts) {
|
|
199
|
+
const skillFile = path.join(dir, 'SKILL.md');
|
|
200
|
+
const ours = () => existsSync(skillFile) && /^name:\s*hydra\s*$/m.test(readFileSync(skillFile, 'utf8'));
|
|
201
|
+
return {
|
|
202
|
+
install() {
|
|
203
|
+
const source = path.join(path.dirname(scripts), 'SKILL.md');
|
|
204
|
+
if (!existsSync(source)) throw new Manual(dir, 'npx github:rahul05ranjan/hydra', `needs the hydra skill, but ${source} is missing; run the installer again`);
|
|
205
|
+
if (existsSync(dir) && !ours()) throw new Manual(dir, `the hydra skill folder (SKILL.md and scripts/) from ${path.dirname(scripts)}`, 'exists and is not the hydra skill');
|
|
206
|
+
rmSync(`${dir}.hydra-tmp`, { recursive: true, force: true });
|
|
207
|
+
mkdirSync(`${dir}.hydra-tmp`, { recursive: true });
|
|
208
|
+
copyFileSync(source, path.join(`${dir}.hydra-tmp`, 'SKILL.md'));
|
|
209
|
+
cpSync(scripts, path.join(`${dir}.hydra-tmp`, 'scripts'), { recursive: true });
|
|
210
|
+
rmSync(dir, { recursive: true, force: true });
|
|
211
|
+
renameSync(`${dir}.hydra-tmp`, dir);
|
|
212
|
+
return `${dir}: skill`;
|
|
213
|
+
},
|
|
214
|
+
remove() {
|
|
215
|
+
if (!ours()) return null;
|
|
216
|
+
rmSync(dir, { recursive: true, force: true });
|
|
217
|
+
return `${dir}: skill`;
|
|
218
|
+
},
|
|
219
|
+
status: () => ours() && existsSync(path.join(dir, 'scripts', 'hydra.mjs')),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const opencodeConfig = dir => ['opencode.json', 'opencode.jsonc'].map(name => path.join(dir, name)).find(existsSync) ?? path.join(dir, 'opencode.json');
|
|
224
|
+
|
|
225
|
+
/** Every tool Hydra can join, with where it lives and what Hydra registers there. */
|
|
226
|
+
export function tools(scripts = sharedScripts()) {
|
|
227
|
+
const h = home();
|
|
228
|
+
const mcp = { command: node(), args: [path.join(scripts, 'mcp.mjs')] };
|
|
229
|
+
const codex = path.join(h, '.codex');
|
|
230
|
+
const cursor = path.join(h, '.cursor');
|
|
231
|
+
const opencode = path.join(h, '.config', 'opencode');
|
|
232
|
+
const gemini = path.join(h, '.gemini');
|
|
233
|
+
return {
|
|
234
|
+
codex: { dir: codex, parts: {
|
|
235
|
+
mcp: tomlServer(path.join(codex, 'config.toml'), scripts),
|
|
236
|
+
hooks: jsonHooks(path.join(codex, 'hooks.json'), {
|
|
237
|
+
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: hookCommand(scripts, 'route-tests.mjs', 'codex'), timeout: 10 }] }],
|
|
238
|
+
SessionStart: [{ hooks: [{ type: 'command', command: hookCommand(scripts, 'session-guide.mjs', 'codex'), timeout: 10 }] }] }) },
|
|
239
|
+
note: 'Codex runs new hooks only after you trust them: open Codex and run /hooks.' },
|
|
240
|
+
cursor: { dir: cursor, parts: {
|
|
241
|
+
mcp: jsonServer(path.join(cursor, 'mcp.json'), 'mcpServers', mcp),
|
|
242
|
+
hooks: jsonHooks(path.join(cursor, 'hooks.json'), {
|
|
243
|
+
beforeShellExecution: [{ command: hookCommand(scripts, 'route-tests.mjs', 'cursor'), timeout: 10 }],
|
|
244
|
+
sessionStart: [{ command: hookCommand(scripts, 'session-guide.mjs', 'cursor'), timeout: 10 }] }, { extra: { version: 1 } }) } },
|
|
245
|
+
opencode: { dir: opencode, parts: {
|
|
246
|
+
mcp: jsonServer(opencodeConfig(opencode), 'mcp', { type: 'local', command: [mcp.command, ...mcp.args], enabled: true, timeout: 1_800_000 }),
|
|
247
|
+
hooks: opencodePlugin(path.join(opencode, 'plugins', 'hydra.js'), scripts) } },
|
|
248
|
+
'gemini-cli': { dir: path.join(gemini, 'settings.json'), parts: {
|
|
249
|
+
mcp: jsonServer(path.join(gemini, 'settings.json'), 'mcpServers', { ...mcp, timeout: 1_800_000 }),
|
|
250
|
+
hooks: jsonHooks(path.join(gemini, 'settings.json'), {
|
|
251
|
+
BeforeTool: [{ matcher: 'run_shell_command', hooks: [{ name: 'hydra', type: 'command', command: hookCommand(scripts, 'route-tests.mjs', 'gemini'), timeout: 10_000 }] }] }) } },
|
|
252
|
+
antigravity: { dir: path.join(gemini, 'antigravity'), parts: {
|
|
253
|
+
mcp: jsonServer(path.join(gemini, 'antigravity', 'mcp_config.json'), 'mcpServers', mcp) } },
|
|
254
|
+
'antigravity-ide': { dir: path.join(gemini, 'antigravity-ide'), parts: {
|
|
255
|
+
mcp: jsonServer(path.join(gemini, 'config', 'mcp_config.json'), 'mcpServers', mcp),
|
|
256
|
+
skill: skillFolder(path.join(gemini, 'config', 'skills', 'hydra'), scripts) } },
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The tools whose folders exist on this machine. */
|
|
261
|
+
export const detected = (scripts) => Object.entries(tools(scripts)).filter(([, tool]) => existsSync(tool.dir)).map(([id]) => id);
|
|
262
|
+
|
|
263
|
+
/** Register (or with remove, take out) Hydra for each tool; one tool failing never stops the others. */
|
|
264
|
+
export function apply(ids, action, { scripts = sharedScripts(), hooks = true } = {}) {
|
|
265
|
+
const all = tools(scripts);
|
|
266
|
+
return ids.map(id => {
|
|
267
|
+
const tool = all[id];
|
|
268
|
+
if (!tool) return { id, error: `unknown tool; known: ${Object.keys(all).join(', ')}` };
|
|
269
|
+
const changes = [];
|
|
270
|
+
const manual = [];
|
|
271
|
+
for (const [name, part] of Object.entries(tool.parts)) {
|
|
272
|
+
if (action === 'install' && name === 'hooks' && !hooks) continue;
|
|
273
|
+
try {
|
|
274
|
+
const change = part[action]();
|
|
275
|
+
if (change) changes.push(change);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (!error.manual) throw error;
|
|
278
|
+
manual.push(error.message);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return { id, changes, manual, ...(action === 'install' && tool.note && tool.parts.hooks && hooks ? { note: tool.note } : {}) };
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function status(scripts = sharedScripts()) {
|
|
286
|
+
return Object.entries(tools(scripts)).map(([id, tool]) => ({ id, present: existsSync(tool.dir),
|
|
287
|
+
...Object.fromEntries(Object.entries(tool.parts).map(([name, part]) => [name, Boolean(part.status())])) }));
|
|
288
|
+
}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
// Local telemetry: one JSON line per Hydra call or hook decision in ~/.hydra/ledger.jsonl.
|
|
2
|
+
// Nothing leaves the machine. HYDRA_TELEMETRY=0 turns recording off.
|
|
3
|
+
// kind 'call' (or no kind, rows written before telemetry v2): a head answered; raw/returned token estimates.
|
|
4
|
+
// kind 'hook': the PreToolUse hook turned a command back ('deny'), let HYDRA_RAW=1 through ('raw'), let a
|
|
5
|
+
// run through that its own pipeline already cuts to a few lines ('trimmed') or passed it ('pass', recorded
|
|
6
|
+
// without the command). hookMs is the hook process's whole lifetime.
|
|
7
|
+
// kind 'error': a command failed (model unavailable, crash).
|
|
1
8
|
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
9
|
import os from 'node:os';
|
|
3
10
|
import path from 'node:path';
|
|
@@ -8,27 +15,123 @@ const file = () => path.join(home(), 'ledger.jsonl');
|
|
|
8
15
|
// A rough count that needs no tokenizer; every figure built on it is an estimate.
|
|
9
16
|
export const tokens = chars => Math.ceil(chars / 4);
|
|
10
17
|
|
|
18
|
+
export const enabled = () => process.env.HYDRA_TELEMETRY !== '0';
|
|
19
|
+
|
|
20
|
+
/** Who is calling: the agent family and its session when the agent says so, else a plain shell.
|
|
21
|
+
* AI_AGENT differs inside hooks (claude-code_<version>_harness) and the agent's shell (..._agent),
|
|
22
|
+
* so `agent` keeps only the family and `agentDetail` the full value. */
|
|
23
|
+
export function caller(session = process.env.CLAUDE_CODE_SESSION_ID) {
|
|
24
|
+
// Antigravity sets no agent variable; macOS passes the launching app's bundle id to every child process.
|
|
25
|
+
const app = /^com\.google\.antigravity(?:-ide)?$/.test(process.env.__CFBundleIdentifier ?? '') ? process.env.__CFBundleIdentifier : undefined;
|
|
26
|
+
// HYDRA_AGENT is the MCP client's own name (codex-mcp-client, gemini-cli, opencode, ...), set by mcp.mjs.
|
|
27
|
+
const detail = process.env.HYDRA_AGENT || process.env.AI_AGENT || (process.env.CLAUDECODE ? undefined : app);
|
|
28
|
+
const agent = process.env.HYDRA_AGENT ? detail.toLowerCase().replace(/[-_ ]?mcp[-_ ]?client$/, '')
|
|
29
|
+
: process.env.AI_AGENT ? detail.split('_')[0] : process.env.CLAUDECODE ? 'claude-code' : app ? 'antigravity' : 'shell';
|
|
30
|
+
return { agent, ...(detail ? { agentDetail: detail } : {}), ...(process.env.HYDRA_VIA ? { via: process.env.HYDRA_VIA } : {}),
|
|
31
|
+
...(session ? { session } : {}) };
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
export function record(entry) {
|
|
35
|
+
if (!enabled()) return;
|
|
12
36
|
try {
|
|
13
37
|
mkdirSync(home(), { recursive: true });
|
|
14
|
-
appendFileSync(file(), `${JSON.stringify(entry)}\n`);
|
|
38
|
+
appendFileSync(file(), `${JSON.stringify({ v: 2, kind: 'call', ...caller(), ...entry })}\n`);
|
|
15
39
|
} catch {
|
|
16
40
|
// Accounting must never fail the answer.
|
|
17
41
|
}
|
|
18
42
|
}
|
|
19
43
|
|
|
20
|
-
|
|
21
|
-
|
|
44
|
+
/** Wrap a chat function so a call's record can say how often and how long the local model ran. */
|
|
45
|
+
export function metered(chat) {
|
|
46
|
+
const meter = { modelCalls: 0, modelSeconds: 0 };
|
|
47
|
+
meter.chat = async opts => {
|
|
48
|
+
const started = Date.now();
|
|
49
|
+
meter.modelCalls += 1;
|
|
50
|
+
try {
|
|
51
|
+
return await chat(opts);
|
|
52
|
+
} finally {
|
|
53
|
+
meter.modelSeconds = Number((meter.modelSeconds + (Date.now() - started) / 1000).toFixed(2));
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
return meter;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function rows() {
|
|
22
60
|
try {
|
|
23
|
-
|
|
61
|
+
return readFileSync(file(), 'utf8').split('\n').filter(Boolean).flatMap(line => {
|
|
62
|
+
try { return [JSON.parse(line)]; } catch { return []; }
|
|
63
|
+
});
|
|
24
64
|
} catch {
|
|
25
|
-
// No ledger yet.
|
|
65
|
+
return []; // No ledger yet.
|
|
26
66
|
}
|
|
27
|
-
|
|
28
|
-
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const isCall = row => (row.kind ?? 'call') === 'call';
|
|
70
|
+
|
|
71
|
+
export function stats(all = rows()) {
|
|
72
|
+
const total = { calls: 0, raw: 0, returned: 0, heads: {} };
|
|
73
|
+
for (const row of all.filter(isCall)) {
|
|
74
|
+
total.calls += 1;
|
|
29
75
|
total.raw += row.raw;
|
|
30
76
|
total.returned += row.returned;
|
|
31
77
|
total.heads[row.head] = (total.heads[row.head] ?? 0) + 1;
|
|
32
78
|
}
|
|
33
79
|
return total;
|
|
34
80
|
}
|
|
81
|
+
|
|
82
|
+
const percentile = (values, p) => {
|
|
83
|
+
if (!values.length) return 0;
|
|
84
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
85
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What the ledger says Hydra should improve, newest data first. `since` is an ISO time.
|
|
90
|
+
* Per head: calls, tokens saved, calls that returned more than the raw output, latency, model use.
|
|
91
|
+
* Hooks: denials, how many the agent followed with a Hydra run in the same session, and
|
|
92
|
+
* HYDRA_RAW=1 bypasses, which are often a pattern the hook should not have matched.
|
|
93
|
+
*/
|
|
94
|
+
export function findings(all = rows(), { since } = {}) {
|
|
95
|
+
const recent = since ? all.filter(row => (row.ts ?? '') >= since) : all;
|
|
96
|
+
const calls = recent.filter(isCall);
|
|
97
|
+
const heads = {};
|
|
98
|
+
for (const row of calls) {
|
|
99
|
+
const head = heads[row.head] ??= { calls: 0, raw: 0, returned: 0, costMore: [], seconds: [], modelCalls: 0, failedRuns: 0 };
|
|
100
|
+
head.calls += 1;
|
|
101
|
+
head.raw += row.raw;
|
|
102
|
+
head.returned += row.returned;
|
|
103
|
+
head.seconds.push(row.seconds ?? 0);
|
|
104
|
+
head.modelCalls += row.modelCalls ?? 0;
|
|
105
|
+
if (row.exit) head.failedRuns += 1;
|
|
106
|
+
if (row.returned > row.raw) head.costMore.push(row);
|
|
107
|
+
}
|
|
108
|
+
const perHead = Object.fromEntries(Object.entries(heads).map(([name, h]) => [name, {
|
|
109
|
+
calls: h.calls, saved: h.raw - h.returned, savedShare: h.raw ? Number(((h.raw - h.returned) / h.raw).toFixed(3)) : 0,
|
|
110
|
+
costMore: h.costMore.length, p50Seconds: percentile(h.seconds, 0.5), p90Seconds: percentile(h.seconds, 0.9),
|
|
111
|
+
modelCalls: h.modelCalls, failedRuns: h.failedRuns,
|
|
112
|
+
costMoreExamples: h.costMore.slice(-3).map(row => ({ question: row.question, raw: row.raw, returned: row.returned })),
|
|
113
|
+
}]));
|
|
114
|
+
const hooks = recent.filter(row => row.kind === 'hook');
|
|
115
|
+
const hookMs = hooks.map(row => row.hookMs).filter(Number.isFinite);
|
|
116
|
+
const denials = hooks.filter(row => row.decision === 'deny');
|
|
117
|
+
const followed = denials.filter(deny => calls.some(call => call.session && call.session === deny.session
|
|
118
|
+
&& call.head === 'test' && call.ts >= deny.ts && Date.parse(call.ts) - Date.parse(deny.ts) <= 5 * 60_000));
|
|
119
|
+
const agents = {};
|
|
120
|
+
for (const row of recent) {
|
|
121
|
+
const agent = agents[row.agent ?? 'unknown'] ??= { calls: 0, viaMcp: 0, denials: 0, sessions: 0 };
|
|
122
|
+
if (isCall(row)) { agent.calls += 1; if (row.via === 'mcp') agent.viaMcp += 1; }
|
|
123
|
+
if (row.kind === 'hook' && row.decision === 'deny') agent.denials += 1;
|
|
124
|
+
if (row.kind === 'session') agent.sessions += 1;
|
|
125
|
+
}
|
|
126
|
+
const sessions = new Set(recent.map(row => row.session).filter(Boolean));
|
|
127
|
+
const sessionsUsing = new Set(calls.map(row => row.session).filter(Boolean));
|
|
128
|
+
return {
|
|
129
|
+
since: since ?? recent[0]?.ts ?? null, calls: calls.length, perHead, agents,
|
|
130
|
+
hooks: { decisions: hooks.length, p50Ms: percentile(hookMs, 0.5), p90Ms: percentile(hookMs, 0.9),
|
|
131
|
+
denials: denials.length, followedByHydra: followed.length,
|
|
132
|
+
trimmed: hooks.filter(row => row.decision === 'trimmed').length,
|
|
133
|
+
rawBypasses: hooks.filter(row => row.decision === 'raw').map(row => row.command).slice(-5) },
|
|
134
|
+
errors: recent.filter(row => row.kind === 'error').slice(-5).map(row => ({ ts: row.ts, command: row.command, error: row.error })),
|
|
135
|
+
sessions: { seen: sessions.size, usingHydra: sessionsUsing.size },
|
|
136
|
+
};
|
|
137
|
+
}
|