feat: DeepSeek API integration + Whale lift (hooks, tool repair, MCP permissions, token tracking)
DeepSeek API: - @ai-sdk/deepseek provider replaces openai-compatible for deepseek-* models - Token tracking: cache_hit/reasoning tokens flow API → DB → WS frames → UI - thinking effort levels (off/low/medium/high/xhigh/max) via AGENTS.md frontmatter - V4 models: deepseek-v4-flash, deepseek-v4-pro - Wired for both chat and coder panes Whale lifts: - Tool input repair (schema-based type coercion, markdown link unwrapping) - Hooks system (6 lifecycle events, shell exec, JSON stdin/stdout contract) - Per-MCP-server permissions (allow/ask/deny) - token tracking UI (cache N, think N in message stats line) Infra: - New DB columns: messages.cache_tokens, messages.reasoning_tokens - New WS frame fields: cache_tokens, reasoning_tokens on message_complete - coder provider snapshot merges DeepSeek models alongside llama-swap
This commit is contained in:
299
apps/server/src/services/hooks.ts
Normal file
299
apps/server/src/services/hooks.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* vWhale: lifecycle hook runner. Hooks are shell commands that fire at key
|
||||
* points in the inference pipeline. Each hook receives a JSON payload on
|
||||
* stdin and can return JSON on stdout to influence behavior.
|
||||
*
|
||||
* Inspired by Whale's hook system with 11 lifecycle events. BooCode
|
||||
* implements the most relevant subset: PreToolUse, PostToolUse,
|
||||
* UserPromptSubmit, Stop, PreCompact, PostCompact.
|
||||
*
|
||||
* Config: JSON file at HOOKS_CONFIG_PATH (default /data/hooks.json).
|
||||
* Format:
|
||||
* ```json
|
||||
* {
|
||||
* "hooks": {
|
||||
* "PreToolUse": [
|
||||
* { "match": "shell_run", "command": "python3 /data/hooks/check_shell.py", "timeout": 30 }
|
||||
* ],
|
||||
* "Stop": [
|
||||
* { "command": "node /data/hooks/log_turn.mjs" }
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
|
||||
// ─── Events ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type HookEvent =
|
||||
| 'PreToolUse'
|
||||
| 'PostToolUse'
|
||||
| 'UserPromptSubmit'
|
||||
| 'Stop'
|
||||
| 'PreCompact'
|
||||
| 'PostCompact';
|
||||
|
||||
const ALL_EVENTS: HookEvent[] = [
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'UserPromptSubmit',
|
||||
'Stop',
|
||||
'PreCompact',
|
||||
'PostCompact',
|
||||
];
|
||||
|
||||
// ─── Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface HookConfig {
|
||||
/** Glob or exact tool name to match (PreToolUse/PostToolUse only). Omit or '*' for all. */
|
||||
match?: string;
|
||||
/** Shell command to run. Receives JSON payload on stdin. */
|
||||
command: string;
|
||||
/** Timeout in seconds (default 30). */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface HooksConfig {
|
||||
hooks: Partial<Record<HookEvent, HookConfig[]>>;
|
||||
}
|
||||
|
||||
// ─── Payloads ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PreToolUsePayload {
|
||||
event: 'PreToolUse';
|
||||
session_id: string;
|
||||
tool_name: string;
|
||||
tool_args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PostToolUsePayload {
|
||||
event: 'PostToolUse';
|
||||
session_id: string;
|
||||
tool_name: string;
|
||||
tool_args: Record<string, unknown>;
|
||||
tool_result: unknown;
|
||||
tool_error?: string;
|
||||
}
|
||||
|
||||
export interface UserPromptSubmitPayload {
|
||||
event: 'UserPromptSubmit';
|
||||
session_id: string;
|
||||
chat_id: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface StopPayload {
|
||||
event: 'Stop';
|
||||
session_id: string;
|
||||
chat_id: string;
|
||||
last_assistant_text: string;
|
||||
turn: number;
|
||||
}
|
||||
|
||||
export interface PreCompactPayload {
|
||||
event: 'PreCompact';
|
||||
session_id: string;
|
||||
chat_id: string;
|
||||
messages_before: number;
|
||||
}
|
||||
|
||||
export interface PostCompactPayload {
|
||||
event: 'PostCompact';
|
||||
session_id: string;
|
||||
chat_id: string;
|
||||
messages_before: number;
|
||||
messages_after: number;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export type HookPayload =
|
||||
| PreToolUsePayload
|
||||
| PostToolUsePayload
|
||||
| UserPromptSubmitPayload
|
||||
| StopPayload
|
||||
| PreCompactPayload
|
||||
| PostCompactPayload;
|
||||
|
||||
// ─── Response ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type HookDecision = 'pass' | 'warn' | 'block';
|
||||
|
||||
export interface HookResponse {
|
||||
decision?: HookDecision;
|
||||
reason?: string;
|
||||
/** When present, replaces the original tool args / user prompt. */
|
||||
updated_input?: Record<string, unknown> | string;
|
||||
/** Injected into the model's context for the next turn. */
|
||||
additional_context?: string;
|
||||
}
|
||||
|
||||
// ─── Runner ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface HookRunner {
|
||||
/** Run all hooks for the given event. Returns the effective response. */
|
||||
run(event: HookEvent, payload: HookPayload, log?: FastifyBaseLogger): Promise<HookResponse>;
|
||||
}
|
||||
|
||||
let hooksConfig: HooksConfig | null = null;
|
||||
let hooksPath: string | null = null;
|
||||
|
||||
/** Load hooks config from disk. Missing file = no hooks. Never throws. */
|
||||
export function loadHooksConfig(path: string): HooksConfig {
|
||||
hooksPath = path;
|
||||
if (!existsSync(path)) {
|
||||
hooksConfig = { hooks: {} };
|
||||
return hooksConfig;
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
const parsed = JSON.parse(raw) as HooksConfig;
|
||||
hooksConfig = {
|
||||
hooks: { ...parsed.hooks },
|
||||
};
|
||||
// Validate event names
|
||||
for (const event of Object.keys(hooksConfig.hooks)) {
|
||||
if (!ALL_EVENTS.includes(event as HookEvent)) {
|
||||
console.warn(`hooks: unknown event '${event}' in ${path} — ignoring`);
|
||||
delete hooksConfig.hooks[event as HookEvent];
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`hooks: failed to load ${path}`, err);
|
||||
hooksConfig = { hooks: {} };
|
||||
}
|
||||
return hooksConfig;
|
||||
}
|
||||
|
||||
/** Reload the config file (call after a PATCH). */
|
||||
export function reloadHooksConfig(): HooksConfig {
|
||||
if (hooksPath) return loadHooksConfig(hooksPath);
|
||||
hooksConfig = { hooks: {} };
|
||||
return hooksConfig;
|
||||
}
|
||||
|
||||
function getConfig(): HooksConfig {
|
||||
return hooksConfig ?? { hooks: {} };
|
||||
}
|
||||
|
||||
/** Create a HookRunner for the current config. */
|
||||
export function createHookRunner(): HookRunner {
|
||||
return {
|
||||
async run(event, payload, log): Promise<HookResponse> {
|
||||
const configs = getConfig().hooks[event];
|
||||
if (!configs || configs.length === 0) return { decision: 'pass' };
|
||||
|
||||
// Pre-filter by match pattern for tool events
|
||||
const toolName = 'tool_name' in payload ? (payload as PreToolUsePayload).tool_name : undefined;
|
||||
|
||||
let effective: HookResponse = { decision: 'pass' };
|
||||
|
||||
for (const cfg of configs) {
|
||||
// Skip if match doesn't apply
|
||||
if (toolName && cfg.match && cfg.match !== '*' && cfg.match !== toolName) continue;
|
||||
|
||||
const result = await runSingleHook(cfg, payload, log);
|
||||
// Merge decisions: block > warn > pass
|
||||
if (result.decision === 'block') {
|
||||
effective = { ...result, decision: 'block' };
|
||||
break; // block is terminal
|
||||
}
|
||||
if (result.decision === 'warn' && effective.decision !== 'block') {
|
||||
effective = { ...result, decision: 'warn' };
|
||||
}
|
||||
// Merge additional_context and updated_input
|
||||
if (result.additional_context) {
|
||||
effective.additional_context = effective.additional_context
|
||||
? effective.additional_context + '\n' + result.additional_context
|
||||
: result.additional_context;
|
||||
}
|
||||
if (result.updated_input && !effective.updated_input) {
|
||||
effective.updated_input = result.updated_input;
|
||||
}
|
||||
}
|
||||
|
||||
return effective;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runSingleHook(
|
||||
cfg: HookConfig,
|
||||
payload: HookPayload,
|
||||
log?: FastifyBaseLogger,
|
||||
): Promise<HookResponse> {
|
||||
const timeoutMs = (cfg.timeout ?? 30) * 1000;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('sh', ['-c', cfg.command], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: timeoutMs,
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
|
||||
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
|
||||
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
child.kill('SIGTERM');
|
||||
log?.warn({ event: payload.event, command: cfg.command }, 'hooks: timeout');
|
||||
resolve({ decision: 'warn', reason: 'hook timed out' });
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
log?.warn({ err, event: payload.event }, 'hooks: spawn error');
|
||||
resolve({ decision: 'warn', reason: `hook failed: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
|
||||
const out = Buffer.concat(stdout).toString('utf8').trim();
|
||||
const errOut = Buffer.concat(stderr).toString('utf8').trim();
|
||||
|
||||
if (code !== 0 && !out) {
|
||||
log?.warn({ event: payload.event, code, stderr: errOut.slice(0, 200) }, 'hooks: non-zero exit');
|
||||
resolve({ decision: 'warn', reason: `hook exited ${code}` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse stdout as JSON response
|
||||
if (out) {
|
||||
try {
|
||||
const parsed = JSON.parse(out) as HookResponse;
|
||||
resolve(parsed);
|
||||
return;
|
||||
} catch {
|
||||
// Not JSON — treat as pass with stdout as context
|
||||
if (out.length > 0) {
|
||||
resolve({ decision: 'pass', additional_context: out });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolve({ decision: 'pass' });
|
||||
});
|
||||
|
||||
// Write payload to stdin
|
||||
const json = JSON.stringify(payload);
|
||||
child.stdin.write(json);
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user