@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/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
- /** Record a metric entry in memory and append to disk. */
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
- this.entries.push(entry);
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
- hookEntries.push({
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;
@@ -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): Promise<ServerContext>;
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(): void;
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
- const handlers = manifest.handlers[event] ?? [];
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
- const results = await (0, handlers_js_1.executeHandlers)(event, input, handlers);
123
- // Record metrics
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
- return { server, metrics, startTime, manifest };
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 child = (0, child_process_1.spawn)(process.execPath, [process.argv[1], 'start', '--foreground'], {
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
- /** Handler types in manifest */
18
- export type HandlerType = 'script' | 'inline';
19
- /** Configuration for a single handler */
20
- export interface HandlerConfig {
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: HandlerType;
23
- command?: string;
24
- module?: string;
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
- /** The full manifest structure */
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
- /** Result from executing a single handler */
37
- export interface HandlerResult {
38
- id: string;
39
- ok: boolean;
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
- /** A single metrics entry */
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 {};