@mauribadnights/clooks 0.1.0 → 0.2.2
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 +215 -68
- package/dist/auth.d.ts +4 -0
- package/dist/auth.js +27 -0
- package/dist/cli.js +23 -4
- package/dist/constants.d.ts +8 -0
- package/dist/constants.js +10 -1
- package/dist/doctor.js +57 -1
- package/dist/filter.d.ts +11 -0
- package/dist/filter.js +42 -0
- package/dist/handlers.d.ts +8 -2
- package/dist/handlers.js +112 -35
- package/dist/index.d.ts +8 -3
- package/dist/index.js +22 -1
- package/dist/llm.d.ts +19 -0
- package/dist/llm.js +225 -0
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +38 -9
- package/dist/metrics.d.ts +29 -2
- package/dist/metrics.js +135 -6
- package/dist/migrate.js +6 -2
- package/dist/prefetch.d.ts +11 -0
- package/dist/prefetch.js +71 -0
- package/dist/server.d.ts +8 -2
- package/dist/server.js +79 -10
- package/dist/types.d.ts +78 -16
- package/dist/watcher.d.ts +18 -0
- package/dist/watcher.js +120 -0
- package/package.json +12 -2
package/dist/metrics.js
CHANGED
|
@@ -6,15 +6,43 @@ const fs_1 = require("fs");
|
|
|
6
6
|
const path_1 = require("path");
|
|
7
7
|
const constants_js_1 = require("./constants.js");
|
|
8
8
|
class MetricsCollector {
|
|
9
|
+
static MAX_ENTRIES = 1000;
|
|
9
10
|
entries = [];
|
|
10
|
-
|
|
11
|
+
ringIndex = 0;
|
|
12
|
+
totalRecorded = 0;
|
|
13
|
+
static METRICS_MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
|
14
|
+
static COSTS_MAX_BYTES = 1 * 1024 * 1024; // 1MB
|
|
15
|
+
/** Rotate a log file if it exceeds maxBytes. Keeps one backup (.1). */
|
|
16
|
+
rotateIfNeeded(filePath, maxBytes) {
|
|
17
|
+
try {
|
|
18
|
+
if (!(0, fs_1.existsSync)(filePath))
|
|
19
|
+
return;
|
|
20
|
+
const stat = (0, fs_1.statSync)(filePath);
|
|
21
|
+
if (stat.size >= maxBytes) {
|
|
22
|
+
(0, fs_1.renameSync)(filePath, filePath + '.1');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Non-critical — rotation failure is not fatal
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Record a metric entry in memory (ring buffer) and append to disk. */
|
|
11
30
|
record(entry) {
|
|
12
|
-
|
|
31
|
+
// Ring buffer: overwrite oldest when full
|
|
32
|
+
if (this.entries.length < MetricsCollector.MAX_ENTRIES) {
|
|
33
|
+
this.entries.push(entry);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
this.entries[this.ringIndex] = entry;
|
|
37
|
+
this.ringIndex = (this.ringIndex + 1) % MetricsCollector.MAX_ENTRIES;
|
|
38
|
+
}
|
|
39
|
+
this.totalRecorded++;
|
|
13
40
|
try {
|
|
14
41
|
const dir = (0, path_1.dirname)(constants_js_1.METRICS_FILE);
|
|
15
42
|
if (!(0, fs_1.existsSync)(dir)) {
|
|
16
43
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
17
44
|
}
|
|
45
|
+
this.rotateIfNeeded(constants_js_1.METRICS_FILE, MetricsCollector.METRICS_MAX_BYTES);
|
|
18
46
|
(0, fs_1.appendFileSync)(constants_js_1.METRICS_FILE, JSON.stringify(entry) + '\n', 'utf-8');
|
|
19
47
|
}
|
|
20
48
|
catch {
|
|
@@ -46,10 +74,7 @@ class MetricsCollector {
|
|
|
46
74
|
}
|
|
47
75
|
/** Get stats for a specific session. */
|
|
48
76
|
getSessionStats(sessionId) {
|
|
49
|
-
const all = this.loadAll().filter((e) =>
|
|
50
|
-
// MetricEntry doesn't have session_id, but we stored it in the entry if available
|
|
51
|
-
return e.session_id === sessionId;
|
|
52
|
-
});
|
|
77
|
+
const all = this.loadAll().filter((e) => e.session_id === sessionId);
|
|
53
78
|
const byEvent = new Map();
|
|
54
79
|
for (const entry of all) {
|
|
55
80
|
const existing = byEvent.get(entry.event) ?? [];
|
|
@@ -100,6 +125,103 @@ class MetricsCollector {
|
|
|
100
125
|
const all = this.loadAll();
|
|
101
126
|
return all.length;
|
|
102
127
|
}
|
|
128
|
+
// --- Cost tracking ---
|
|
129
|
+
/** Track a cost entry — appends to costs.jsonl. */
|
|
130
|
+
trackCost(entry) {
|
|
131
|
+
try {
|
|
132
|
+
const dir = (0, path_1.dirname)(constants_js_1.COSTS_FILE);
|
|
133
|
+
if (!(0, fs_1.existsSync)(dir)) {
|
|
134
|
+
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
135
|
+
}
|
|
136
|
+
this.rotateIfNeeded(constants_js_1.COSTS_FILE, MetricsCollector.COSTS_MAX_BYTES);
|
|
137
|
+
(0, fs_1.appendFileSync)(constants_js_1.COSTS_FILE, JSON.stringify(entry) + '\n', 'utf-8');
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Non-critical — cost tracking should not crash the daemon
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Get cost statistics from persisted cost entries. */
|
|
144
|
+
getCostStats() {
|
|
145
|
+
const entries = this.loadCosts();
|
|
146
|
+
let totalCost = 0;
|
|
147
|
+
let totalTokens = 0;
|
|
148
|
+
const byModel = {};
|
|
149
|
+
const byHandler = {};
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
totalCost += entry.cost_usd;
|
|
152
|
+
const tokens = entry.usage.input_tokens + entry.usage.output_tokens;
|
|
153
|
+
totalTokens += tokens;
|
|
154
|
+
// By model
|
|
155
|
+
if (!byModel[entry.model]) {
|
|
156
|
+
byModel[entry.model] = { cost: 0, tokens: 0 };
|
|
157
|
+
}
|
|
158
|
+
byModel[entry.model].cost += entry.cost_usd;
|
|
159
|
+
byModel[entry.model].tokens += tokens;
|
|
160
|
+
// By handler
|
|
161
|
+
if (!byHandler[entry.handler]) {
|
|
162
|
+
byHandler[entry.handler] = { cost: 0, tokens: 0, calls: 0 };
|
|
163
|
+
}
|
|
164
|
+
byHandler[entry.handler].cost += entry.cost_usd;
|
|
165
|
+
byHandler[entry.handler].tokens += tokens;
|
|
166
|
+
byHandler[entry.handler].calls++;
|
|
167
|
+
}
|
|
168
|
+
return { totalCost, totalTokens, byModel, byHandler };
|
|
169
|
+
}
|
|
170
|
+
/** Format cost data as a CLI-friendly table. */
|
|
171
|
+
formatCostTable() {
|
|
172
|
+
const entries = this.loadCosts();
|
|
173
|
+
if (entries.length === 0) {
|
|
174
|
+
return 'No LLM cost data recorded yet.';
|
|
175
|
+
}
|
|
176
|
+
const stats = this.getCostStats();
|
|
177
|
+
const lines = [];
|
|
178
|
+
lines.push('LLM Cost Summary');
|
|
179
|
+
lines.push(` Total: $${stats.totalCost.toFixed(4)} (${formatTokenCount(stats.totalTokens)} tokens)`);
|
|
180
|
+
lines.push('');
|
|
181
|
+
// By Model
|
|
182
|
+
lines.push(' By Model:');
|
|
183
|
+
for (const [model, data] of Object.entries(stats.byModel)) {
|
|
184
|
+
lines.push(` ${model.padEnd(22)} $${data.cost.toFixed(4)} (${formatTokenCount(data.tokens)} tokens)`);
|
|
185
|
+
}
|
|
186
|
+
lines.push('');
|
|
187
|
+
// By Handler
|
|
188
|
+
lines.push(' By Handler:');
|
|
189
|
+
for (const [handler, data] of Object.entries(stats.byHandler)) {
|
|
190
|
+
const avgTokens = data.calls > 0 ? Math.round(data.tokens / data.calls) : 0;
|
|
191
|
+
lines.push(` ${handler.padEnd(22)} $${data.cost.toFixed(4)} (${data.calls} calls, avg ${avgTokens} tokens)`);
|
|
192
|
+
}
|
|
193
|
+
// Batching savings estimate
|
|
194
|
+
const batchedCount = entries.filter(e => e.batched).length;
|
|
195
|
+
const unbatchedCount = entries.length - batchedCount;
|
|
196
|
+
if (batchedCount > 0) {
|
|
197
|
+
// Estimate: batched calls saved roughly (batchedCount - unique_batch_calls) API calls
|
|
198
|
+
// Simple heuristic: batched entries share cost, individual would each cost input overhead
|
|
199
|
+
const batchedCost = entries.filter(e => e.batched).reduce((s, e) => s + e.cost_usd, 0);
|
|
200
|
+
// Rough estimate: without batching, each would have its own input tokens overhead
|
|
201
|
+
const estimatedIndividualCost = batchedCost * 2; // conservative 2x estimate
|
|
202
|
+
const saved = estimatedIndividualCost - batchedCost;
|
|
203
|
+
if (saved > 0) {
|
|
204
|
+
const pct = Math.round((saved / (stats.totalCost + saved)) * 100);
|
|
205
|
+
lines.push('');
|
|
206
|
+
lines.push(` Batching saved: ~$${saved.toFixed(4)} (~${pct}% of what individual calls would cost)`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return lines.join('\n');
|
|
210
|
+
}
|
|
211
|
+
/** Load cost entries from disk. */
|
|
212
|
+
loadCosts() {
|
|
213
|
+
if (!(0, fs_1.existsSync)(constants_js_1.COSTS_FILE)) {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const raw = (0, fs_1.readFileSync)(constants_js_1.COSTS_FILE, 'utf-8');
|
|
218
|
+
const lines = raw.trim().split('\n').filter(Boolean);
|
|
219
|
+
return lines.map((line) => JSON.parse(line));
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
103
225
|
/** Load all entries from disk + memory (deduped by combining disk file). */
|
|
104
226
|
loadAll() {
|
|
105
227
|
if (!(0, fs_1.existsSync)(constants_js_1.METRICS_FILE)) {
|
|
@@ -116,6 +238,13 @@ class MetricsCollector {
|
|
|
116
238
|
}
|
|
117
239
|
}
|
|
118
240
|
exports.MetricsCollector = MetricsCollector;
|
|
241
|
+
function formatTokenCount(tokens) {
|
|
242
|
+
if (tokens >= 1_000_000)
|
|
243
|
+
return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
244
|
+
if (tokens >= 1_000)
|
|
245
|
+
return `${(tokens / 1_000).toFixed(1)}k`;
|
|
246
|
+
return String(tokens);
|
|
247
|
+
}
|
|
119
248
|
function padRow(cols) {
|
|
120
249
|
const widths = [20, 8, 8, 10, 10, 10];
|
|
121
250
|
return cols.map((col, i) => col.padEnd(widths[i])).join(' ');
|
package/dist/migrate.js
CHANGED
|
@@ -129,10 +129,14 @@ function migrate(options) {
|
|
|
129
129
|
}
|
|
130
130
|
// Add HTTP hook
|
|
131
131
|
if (hadHandlers > 0) {
|
|
132
|
-
|
|
132
|
+
const httpHook = {
|
|
133
133
|
type: 'http',
|
|
134
134
|
url: `http://localhost:${constants_js_1.DEFAULT_PORT}/hooks/${eventName}`,
|
|
135
|
-
}
|
|
135
|
+
};
|
|
136
|
+
if (manifest.settings?.authToken) {
|
|
137
|
+
httpHook.headers = { Authorization: `Bearer ${manifest.settings.authToken}` };
|
|
138
|
+
}
|
|
139
|
+
hookEntries.push(httpHook);
|
|
136
140
|
}
|
|
137
141
|
if (hookEntries.length > 0) {
|
|
138
142
|
// Wrap in a single rule group (no matcher — clooks handles dispatch)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PrefetchKey, PrefetchContext, HookInput } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Pre-fetch requested context data. Each key is fetched once and cached.
|
|
4
|
+
* Errors are caught per-key (a failed git_status doesn't block transcript).
|
|
5
|
+
*/
|
|
6
|
+
export declare function prefetchContext(keys: PrefetchKey[], input: HookInput): Promise<PrefetchContext>;
|
|
7
|
+
/**
|
|
8
|
+
* Render a prompt template by replacing $VARIABLES with actual values.
|
|
9
|
+
* Supported: $TRANSCRIPT, $GIT_STATUS, $GIT_DIFF, $ARGUMENTS, $TOOL_NAME, $PROMPT, $CWD
|
|
10
|
+
*/
|
|
11
|
+
export declare function renderPromptTemplate(template: string, input: HookInput, context: PrefetchContext): string;
|
package/dist/prefetch.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// clooks prefetch — shared context pre-fetching for handlers
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.prefetchContext = prefetchContext;
|
|
5
|
+
exports.renderPromptTemplate = renderPromptTemplate;
|
|
6
|
+
const fs_1 = require("fs");
|
|
7
|
+
const child_process_1 = require("child_process");
|
|
8
|
+
const MAX_TRANSCRIPT_BYTES = 50 * 1024; // 50KB
|
|
9
|
+
const MAX_GIT_DIFF_BYTES = 20 * 1024; // 20KB
|
|
10
|
+
/**
|
|
11
|
+
* Pre-fetch requested context data. Each key is fetched once and cached.
|
|
12
|
+
* Errors are caught per-key (a failed git_status doesn't block transcript).
|
|
13
|
+
*/
|
|
14
|
+
async function prefetchContext(keys, input) {
|
|
15
|
+
const ctx = {};
|
|
16
|
+
for (const key of keys) {
|
|
17
|
+
try {
|
|
18
|
+
switch (key) {
|
|
19
|
+
case 'transcript': {
|
|
20
|
+
if (input.transcript_path && (0, fs_1.existsSync)(input.transcript_path)) {
|
|
21
|
+
const raw = (0, fs_1.readFileSync)(input.transcript_path, 'utf-8');
|
|
22
|
+
// Truncate to last 50KB to avoid memory issues
|
|
23
|
+
ctx.transcript = raw.length > MAX_TRANSCRIPT_BYTES
|
|
24
|
+
? raw.slice(-MAX_TRANSCRIPT_BYTES)
|
|
25
|
+
: raw;
|
|
26
|
+
}
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
case 'git_status': {
|
|
30
|
+
const status = (0, child_process_1.execSync)('git status --porcelain', {
|
|
31
|
+
cwd: input.cwd,
|
|
32
|
+
encoding: 'utf-8',
|
|
33
|
+
timeout: 5000,
|
|
34
|
+
});
|
|
35
|
+
ctx.git_status = status;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
case 'git_diff': {
|
|
39
|
+
const diff = (0, child_process_1.execSync)('git diff --no-ext-diff --stat', {
|
|
40
|
+
cwd: input.cwd,
|
|
41
|
+
encoding: 'utf-8',
|
|
42
|
+
timeout: 5000,
|
|
43
|
+
});
|
|
44
|
+
// Truncate to 20KB
|
|
45
|
+
ctx.git_diff = diff.length > MAX_GIT_DIFF_BYTES
|
|
46
|
+
? diff.slice(0, MAX_GIT_DIFF_BYTES)
|
|
47
|
+
: diff;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Errors are silently caught per-key — a failed git_status doesn't block transcript
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return ctx;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Render a prompt template by replacing $VARIABLES with actual values.
|
|
60
|
+
* Supported: $TRANSCRIPT, $GIT_STATUS, $GIT_DIFF, $ARGUMENTS, $TOOL_NAME, $PROMPT, $CWD
|
|
61
|
+
*/
|
|
62
|
+
function renderPromptTemplate(template, input, context) {
|
|
63
|
+
return template
|
|
64
|
+
.replace(/\$TRANSCRIPT/g, context.transcript ?? '')
|
|
65
|
+
.replace(/\$GIT_STATUS/g, context.git_status ?? '')
|
|
66
|
+
.replace(/\$GIT_DIFF/g, context.git_diff ?? '')
|
|
67
|
+
.replace(/\$ARGUMENTS/g, input.tool_input ? JSON.stringify(input.tool_input) : '')
|
|
68
|
+
.replace(/\$TOOL_NAME/g, input.tool_name ?? '')
|
|
69
|
+
.replace(/\$PROMPT/g, input.prompt ?? '')
|
|
70
|
+
.replace(/\$CWD/g, input.cwd ?? '');
|
|
71
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Server } from 'http';
|
|
2
|
+
import type { FSWatcher } from 'fs';
|
|
2
3
|
import { MetricsCollector } from './metrics.js';
|
|
3
4
|
import type { Manifest } from './types.js';
|
|
4
5
|
export interface ServerContext {
|
|
@@ -6,6 +7,7 @@ export interface ServerContext {
|
|
|
6
7
|
metrics: MetricsCollector;
|
|
7
8
|
startTime: number;
|
|
8
9
|
manifest: Manifest;
|
|
10
|
+
watcher?: FSWatcher;
|
|
9
11
|
}
|
|
10
12
|
/**
|
|
11
13
|
* Create the HTTP server for hook handling.
|
|
@@ -14,7 +16,9 @@ export declare function createServer(manifest: Manifest, metrics: MetricsCollect
|
|
|
14
16
|
/**
|
|
15
17
|
* Start the daemon: bind the server and write PID file.
|
|
16
18
|
*/
|
|
17
|
-
export declare function startDaemon(manifest: Manifest, metrics: MetricsCollector
|
|
19
|
+
export declare function startDaemon(manifest: Manifest, metrics: MetricsCollector, options?: {
|
|
20
|
+
noWatch?: boolean;
|
|
21
|
+
}): Promise<ServerContext>;
|
|
18
22
|
/**
|
|
19
23
|
* Stop a running daemon by reading PID file and sending SIGTERM.
|
|
20
24
|
*/
|
|
@@ -26,4 +30,6 @@ export declare function isDaemonRunning(): boolean;
|
|
|
26
30
|
/**
|
|
27
31
|
* Start daemon as a detached background process.
|
|
28
32
|
*/
|
|
29
|
-
export declare function startDaemonBackground(
|
|
33
|
+
export declare function startDaemonBackground(options?: {
|
|
34
|
+
noWatch?: boolean;
|
|
35
|
+
}): void;
|
package/dist/server.js
CHANGED
|
@@ -10,7 +10,11 @@ const http_1 = require("http");
|
|
|
10
10
|
const fs_1 = require("fs");
|
|
11
11
|
const child_process_1 = require("child_process");
|
|
12
12
|
const handlers_js_1 = require("./handlers.js");
|
|
13
|
+
const prefetch_js_1 = require("./prefetch.js");
|
|
14
|
+
const watcher_js_1 = require("./watcher.js");
|
|
15
|
+
const auth_js_1 = require("./auth.js");
|
|
13
16
|
const constants_js_1 = require("./constants.js");
|
|
17
|
+
const manifest_js_1 = require("./manifest.js");
|
|
14
18
|
function log(msg) {
|
|
15
19
|
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
16
20
|
try {
|
|
@@ -78,21 +82,32 @@ function sendJson(res, status, data) {
|
|
|
78
82
|
*/
|
|
79
83
|
function createServer(manifest, metrics) {
|
|
80
84
|
const startTime = Date.now();
|
|
85
|
+
const ctx = { server: null, metrics, startTime, manifest };
|
|
86
|
+
const authToken = manifest.settings?.authToken ?? '';
|
|
81
87
|
const server = (0, http_1.createServer)(async (req, res) => {
|
|
82
88
|
const url = req.url ?? '/';
|
|
83
89
|
const method = req.method ?? 'GET';
|
|
84
|
-
// Health check endpoint
|
|
90
|
+
// Health check endpoint — no auth required for monitoring
|
|
85
91
|
if (method === 'GET' && url === '/health') {
|
|
86
|
-
const handlerCount = Object.values(manifest.handlers)
|
|
92
|
+
const handlerCount = Object.values(ctx.manifest.handlers)
|
|
87
93
|
.reduce((sum, arr) => sum + (arr?.length ?? 0), 0);
|
|
88
94
|
sendJson(res, 200, {
|
|
89
95
|
status: 'ok',
|
|
90
96
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
91
97
|
handlers_loaded: handlerCount,
|
|
92
|
-
port: manifest.settings?.port ?? constants_js_1.DEFAULT_PORT,
|
|
98
|
+
port: ctx.manifest.settings?.port ?? constants_js_1.DEFAULT_PORT,
|
|
93
99
|
});
|
|
94
100
|
return;
|
|
95
101
|
}
|
|
102
|
+
// Auth check for all POST requests
|
|
103
|
+
if (method === 'POST' && authToken) {
|
|
104
|
+
const authHeader = req.headers['authorization'];
|
|
105
|
+
if (!(0, auth_js_1.validateAuth)(authHeader, authToken)) {
|
|
106
|
+
log(`Auth failure from ${req.socket.remoteAddress}`);
|
|
107
|
+
sendJson(res, 401, { error: 'Unauthorized' });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
96
111
|
// Hook endpoint: POST /hooks/:eventName
|
|
97
112
|
const hookMatch = url.match(/^\/hooks\/([A-Za-z]+)$/);
|
|
98
113
|
if (method === 'POST' && hookMatch) {
|
|
@@ -102,7 +117,14 @@ function createServer(manifest, metrics) {
|
|
|
102
117
|
return;
|
|
103
118
|
}
|
|
104
119
|
const event = eventName;
|
|
105
|
-
|
|
120
|
+
// On SessionStart, reset session-isolated handlers across ALL events
|
|
121
|
+
if (event === 'SessionStart') {
|
|
122
|
+
const allHandlers = Object.values(ctx.manifest.handlers)
|
|
123
|
+
.flat()
|
|
124
|
+
.filter((h) => h != null);
|
|
125
|
+
(0, handlers_js_1.resetSessionIsolatedHandlers)(allHandlers);
|
|
126
|
+
}
|
|
127
|
+
const handlers = ctx.manifest.handlers[event] ?? [];
|
|
106
128
|
if (handlers.length === 0) {
|
|
107
129
|
sendJson(res, 200, {});
|
|
108
130
|
return;
|
|
@@ -119,8 +141,13 @@ function createServer(manifest, metrics) {
|
|
|
119
141
|
}
|
|
120
142
|
log(`Hook: ${eventName} (${handlers.length} handler${handlers.length > 1 ? 's' : ''})`);
|
|
121
143
|
try {
|
|
122
|
-
|
|
123
|
-
|
|
144
|
+
// Pre-fetch shared context if configured
|
|
145
|
+
let context;
|
|
146
|
+
if (ctx.manifest.prefetch && ctx.manifest.prefetch.length > 0) {
|
|
147
|
+
context = await (0, prefetch_js_1.prefetchContext)(ctx.manifest.prefetch, input);
|
|
148
|
+
}
|
|
149
|
+
const results = await (0, handlers_js_1.executeHandlers)(event, input, handlers, context);
|
|
150
|
+
// Record metrics and costs
|
|
124
151
|
for (const result of results) {
|
|
125
152
|
metrics.record({
|
|
126
153
|
ts: new Date().toISOString(),
|
|
@@ -129,7 +156,28 @@ function createServer(manifest, metrics) {
|
|
|
129
156
|
duration_ms: result.duration_ms,
|
|
130
157
|
ok: result.ok,
|
|
131
158
|
error: result.error,
|
|
159
|
+
filtered: result.filtered,
|
|
160
|
+
usage: result.usage,
|
|
161
|
+
cost_usd: result.cost_usd,
|
|
162
|
+
session_id: input.session_id,
|
|
132
163
|
});
|
|
164
|
+
// Track cost for LLM handlers
|
|
165
|
+
if (result.usage && result.cost_usd !== undefined && result.cost_usd > 0) {
|
|
166
|
+
// Find the handler config to get model info
|
|
167
|
+
const handlerConfig = handlers.find(h => h.id === result.id);
|
|
168
|
+
if (handlerConfig && handlerConfig.type === 'llm') {
|
|
169
|
+
const llmConfig = handlerConfig;
|
|
170
|
+
metrics.trackCost({
|
|
171
|
+
ts: new Date().toISOString(),
|
|
172
|
+
event,
|
|
173
|
+
handler: result.id,
|
|
174
|
+
model: llmConfig.model,
|
|
175
|
+
usage: result.usage,
|
|
176
|
+
cost_usd: result.cost_usd,
|
|
177
|
+
batched: !!llmConfig.batchGroup,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
133
181
|
}
|
|
134
182
|
const merged = mergeResults(results);
|
|
135
183
|
log(` -> ${results.filter((r) => r.ok).length}/${results.length} ok, response keys: ${Object.keys(merged).join(', ') || '(empty)'}`);
|
|
@@ -144,12 +192,13 @@ function createServer(manifest, metrics) {
|
|
|
144
192
|
// 404 for everything else
|
|
145
193
|
sendJson(res, 404, { error: 'Not found' });
|
|
146
194
|
});
|
|
147
|
-
|
|
195
|
+
ctx.server = server;
|
|
196
|
+
return ctx;
|
|
148
197
|
}
|
|
149
198
|
/**
|
|
150
199
|
* Start the daemon: bind the server and write PID file.
|
|
151
200
|
*/
|
|
152
|
-
function startDaemon(manifest, metrics) {
|
|
201
|
+
function startDaemon(manifest, metrics, options) {
|
|
153
202
|
return new Promise((resolve, reject) => {
|
|
154
203
|
const ctx = createServer(manifest, metrics);
|
|
155
204
|
const port = manifest.settings?.port ?? constants_js_1.DEFAULT_PORT;
|
|
@@ -169,12 +218,28 @@ function startDaemon(manifest, metrics) {
|
|
|
169
218
|
(0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
|
|
170
219
|
}
|
|
171
220
|
(0, fs_1.writeFileSync)(constants_js_1.PID_FILE, String(process.pid), 'utf-8');
|
|
221
|
+
// Start file watcher unless disabled
|
|
222
|
+
if (!options?.noWatch) {
|
|
223
|
+
ctx.watcher = (0, watcher_js_1.startWatcher)(constants_js_1.MANIFEST_PATH, () => {
|
|
224
|
+
try {
|
|
225
|
+
const newManifest = (0, manifest_js_1.loadManifest)();
|
|
226
|
+
ctx.manifest = newManifest;
|
|
227
|
+
log('Manifest reloaded successfully');
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
log(`Manifest reload failed (keeping previous config): ${err instanceof Error ? err.message : err}`);
|
|
231
|
+
}
|
|
232
|
+
}, (err) => {
|
|
233
|
+
log(`Watcher error: ${err.message}`);
|
|
234
|
+
}) ?? undefined;
|
|
235
|
+
}
|
|
172
236
|
log(`Daemon started on 127.0.0.1:${port} (pid ${process.pid})`);
|
|
173
237
|
resolve(ctx);
|
|
174
238
|
});
|
|
175
239
|
// Graceful shutdown
|
|
176
240
|
const shutdown = () => {
|
|
177
241
|
log('Shutting down...');
|
|
242
|
+
(0, watcher_js_1.stopWatcher)(ctx.watcher ?? null);
|
|
178
243
|
ctx.server.close(() => {
|
|
179
244
|
try {
|
|
180
245
|
if ((0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
@@ -257,8 +322,12 @@ function isDaemonRunning() {
|
|
|
257
322
|
/**
|
|
258
323
|
* Start daemon as a detached background process.
|
|
259
324
|
*/
|
|
260
|
-
function startDaemonBackground() {
|
|
261
|
-
const
|
|
325
|
+
function startDaemonBackground(options) {
|
|
326
|
+
const args = [process.argv[1], 'start', '--foreground'];
|
|
327
|
+
if (options?.noWatch) {
|
|
328
|
+
args.push('--no-watch');
|
|
329
|
+
}
|
|
330
|
+
const child = (0, child_process_1.spawn)(process.execPath, args, {
|
|
262
331
|
detached: true,
|
|
263
332
|
stdio: 'ignore',
|
|
264
333
|
});
|
package/dist/types.d.ts
CHANGED
|
@@ -14,34 +14,81 @@ export interface HookInput {
|
|
|
14
14
|
stop_hook_active?: boolean;
|
|
15
15
|
[key: string]: unknown;
|
|
16
16
|
}
|
|
17
|
-
/**
|
|
18
|
-
export type
|
|
19
|
-
/**
|
|
20
|
-
export
|
|
17
|
+
/** Supported LLM models */
|
|
18
|
+
export type LLMModel = 'claude-haiku-4-5' | 'claude-sonnet-4-6' | 'claude-opus-4-6';
|
|
19
|
+
/** Handler types — extended with 'llm' */
|
|
20
|
+
export type HandlerType = 'script' | 'inline' | 'llm';
|
|
21
|
+
/** LLM-specific handler config fields */
|
|
22
|
+
export interface LLMHandlerConfig {
|
|
21
23
|
id: string;
|
|
22
|
-
type:
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
type: 'llm';
|
|
25
|
+
model: LLMModel;
|
|
26
|
+
prompt: string;
|
|
27
|
+
batchGroup?: string;
|
|
28
|
+
maxTokens?: number;
|
|
29
|
+
temperature?: number;
|
|
30
|
+
filter?: string;
|
|
25
31
|
timeout?: number;
|
|
26
32
|
enabled?: boolean;
|
|
33
|
+
sessionIsolation?: boolean;
|
|
27
34
|
}
|
|
28
|
-
/**
|
|
35
|
+
/** Script handler config */
|
|
36
|
+
export interface ScriptHandlerConfig {
|
|
37
|
+
id: string;
|
|
38
|
+
type: 'script';
|
|
39
|
+
command: string;
|
|
40
|
+
filter?: string;
|
|
41
|
+
timeout?: number;
|
|
42
|
+
enabled?: boolean;
|
|
43
|
+
sessionIsolation?: boolean;
|
|
44
|
+
}
|
|
45
|
+
/** Inline handler config */
|
|
46
|
+
export interface InlineHandlerConfig {
|
|
47
|
+
id: string;
|
|
48
|
+
type: 'inline';
|
|
49
|
+
module: string;
|
|
50
|
+
filter?: string;
|
|
51
|
+
timeout?: number;
|
|
52
|
+
enabled?: boolean;
|
|
53
|
+
sessionIsolation?: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** Union of all handler configs */
|
|
56
|
+
export type HandlerConfig = ScriptHandlerConfig | InlineHandlerConfig | LLMHandlerConfig;
|
|
57
|
+
/** Prefetchable context keys */
|
|
58
|
+
export type PrefetchKey = 'transcript' | 'git_status' | 'git_diff';
|
|
59
|
+
/** Pre-fetched context data */
|
|
60
|
+
export interface PrefetchContext {
|
|
61
|
+
transcript?: string;
|
|
62
|
+
git_status?: string;
|
|
63
|
+
git_diff?: string;
|
|
64
|
+
}
|
|
65
|
+
/** Extended manifest with prefetch and LLM settings */
|
|
29
66
|
export interface Manifest {
|
|
30
67
|
handlers: Partial<Record<HookEvent, HandlerConfig[]>>;
|
|
68
|
+
prefetch?: PrefetchKey[];
|
|
31
69
|
settings?: {
|
|
32
70
|
port?: number;
|
|
33
71
|
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
|
72
|
+
anthropicApiKey?: string;
|
|
73
|
+
authToken?: string;
|
|
34
74
|
};
|
|
35
75
|
}
|
|
36
|
-
/**
|
|
37
|
-
export interface
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
output?: unknown;
|
|
41
|
-
error?: string;
|
|
42
|
-
duration_ms: number;
|
|
76
|
+
/** Token usage from API response */
|
|
77
|
+
export interface TokenUsage {
|
|
78
|
+
input_tokens: number;
|
|
79
|
+
output_tokens: number;
|
|
43
80
|
}
|
|
44
|
-
/**
|
|
81
|
+
/** Cost entry for tracking */
|
|
82
|
+
export interface CostEntry {
|
|
83
|
+
ts: string;
|
|
84
|
+
event: HookEvent;
|
|
85
|
+
handler: string;
|
|
86
|
+
model: LLMModel;
|
|
87
|
+
usage: TokenUsage;
|
|
88
|
+
cost_usd: number;
|
|
89
|
+
batched: boolean;
|
|
90
|
+
}
|
|
91
|
+
/** Extended metrics entry with optional cost fields */
|
|
45
92
|
export interface MetricEntry {
|
|
46
93
|
ts: string;
|
|
47
94
|
event: HookEvent;
|
|
@@ -49,6 +96,21 @@ export interface MetricEntry {
|
|
|
49
96
|
duration_ms: number;
|
|
50
97
|
ok: boolean;
|
|
51
98
|
error?: string;
|
|
99
|
+
filtered?: boolean;
|
|
100
|
+
usage?: TokenUsage;
|
|
101
|
+
cost_usd?: number;
|
|
102
|
+
session_id?: string;
|
|
103
|
+
}
|
|
104
|
+
/** Extended handler result with cost info */
|
|
105
|
+
export interface HandlerResult {
|
|
106
|
+
id: string;
|
|
107
|
+
ok: boolean;
|
|
108
|
+
output?: unknown;
|
|
109
|
+
error?: string;
|
|
110
|
+
duration_ms: number;
|
|
111
|
+
filtered?: boolean;
|
|
112
|
+
usage?: TokenUsage;
|
|
113
|
+
cost_usd?: number;
|
|
52
114
|
}
|
|
53
115
|
/** Runtime state for tracking consecutive failures */
|
|
54
116
|
export interface HandlerState {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type FSWatcher } from 'fs';
|
|
2
|
+
type ReloadCallback = () => void;
|
|
3
|
+
type ErrorCallback = (err: Error) => void;
|
|
4
|
+
export interface WatcherOptions {
|
|
5
|
+
onReload: ReloadCallback;
|
|
6
|
+
onError?: ErrorCallback;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Watch manifest.yaml for changes.
|
|
10
|
+
* Calls onReload when changes detected (debounced).
|
|
11
|
+
* If the manifest file doesn't exist, watches the config directory for its creation.
|
|
12
|
+
*/
|
|
13
|
+
export declare function startWatcher(manifestPath: string, onReload: ReloadCallback, onError?: ErrorCallback): FSWatcher | null;
|
|
14
|
+
/**
|
|
15
|
+
* Stop watching for file changes.
|
|
16
|
+
*/
|
|
17
|
+
export declare function stopWatcher(watcher: FSWatcher | null): void;
|
|
18
|
+
export {};
|