|
| 1 | +import { createHash } from "crypto"; |
| 2 | +import * as fs from "fs"; |
| 3 | +import * as os from "os"; |
| 4 | +import * as path from "path"; |
| 5 | + |
| 6 | +export type HookInput = Record<string, unknown>; |
| 7 | + |
| 8 | +export interface HookState { |
| 9 | + promptId: string; |
| 10 | + client: string; |
| 11 | + createdAt: string; |
| 12 | +} |
| 13 | + |
| 14 | +export interface McpToolCaller { |
| 15 | + (name: string, args: Record<string, unknown>): Promise<string>; |
| 16 | +} |
| 17 | + |
| 18 | +const STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000; |
| 19 | + |
| 20 | +export function parseHookInput(raw: string): HookInput { |
| 21 | + const normalized = raw.replace(/^\uFEFF/, "").trim(); |
| 22 | + return normalized ? JSON.parse(normalized) as HookInput : {}; |
| 23 | +} |
| 24 | + |
| 25 | +export function detectClient(input: HookInput): string { |
| 26 | + const explicit = stringField(input, "client"); |
| 27 | + if (explicit) return explicit.toLowerCase(); |
| 28 | + |
| 29 | + const event = stringField(input, "hook_event_name"); |
| 30 | + if (event === "UserPromptSubmit" || event === "Stop") return "claude"; |
| 31 | + if (event === "BeforeAgent" || event === "AfterAgent") return "gemini"; |
| 32 | + return "generic"; |
| 33 | +} |
| 34 | + |
| 35 | +export function extractPrompt(input: HookInput): string | undefined { |
| 36 | + return firstString(input, ["prompt", "user_prompt", "input"]); |
| 37 | +} |
| 38 | + |
| 39 | +export function extractPromptId(text: string): string | undefined { |
| 40 | + const tagged = text.match(/\[PROMPT_ID:\s*([^\]\s]+)\]/)?.[1]; |
| 41 | + if (tagged) return tagged; |
| 42 | + try { |
| 43 | + const parsed = JSON.parse(text) as { promptId?: unknown }; |
| 44 | + return typeof parsed.promptId === "string" ? parsed.promptId : undefined; |
| 45 | + } catch { |
| 46 | + return undefined; |
| 47 | + } |
| 48 | +} |
| 49 | + |
| 50 | +export function extractOutputLength(input: HookInput): number { |
| 51 | + return firstString(input, [ |
| 52 | + "prompt_response", |
| 53 | + "last_assistant_message", |
| 54 | + "response", |
| 55 | + "output", |
| 56 | + ])?.length ?? 0; |
| 57 | +} |
| 58 | + |
| 59 | +export function buildLintContext(lintText: string, promptId?: string): string { |
| 60 | + let gaps: Array<{ message?: unknown; suggestedAction?: unknown }> = []; |
| 61 | + try { |
| 62 | + const parsed = JSON.parse(lintText) as { gaps?: Array<{ message?: unknown; suggestedAction?: unknown }> }; |
| 63 | + gaps = Array.isArray(parsed.gaps) ? parsed.gaps : []; |
| 64 | + } catch { |
| 65 | + return promptId |
| 66 | + ? `PromptImprover tracked this turn as ${promptId}. Continue normally.` |
| 67 | + : "PromptImprover linting completed. Continue normally."; |
| 68 | + } |
| 69 | + |
| 70 | + const lines = gaps.slice(0, 5).map((gap) => { |
| 71 | + const message = typeof gap.message === "string" ? gap.message : "Prompt quality gap detected."; |
| 72 | + const action = typeof gap.suggestedAction === "string" ? ` ${gap.suggestedAction}` : ""; |
| 73 | + return `- ${message}${action}`; |
| 74 | + }); |
| 75 | + |
| 76 | + const tracking = promptId ? ` Tracking ID: ${promptId}.` : ""; |
| 77 | + if (lines.length === 0) return `PromptImprover found no actionable prompt gaps.${tracking}`; |
| 78 | + return `PromptImprover found advisory gaps.${tracking}\n${lines.join("\n")}`; |
| 79 | +} |
| 80 | + |
| 81 | +export function allowOutput(input: HookInput, additionalContext?: string): Record<string, unknown> { |
| 82 | + const event = stringField(input, "hook_event_name"); |
| 83 | + if (!additionalContext) return { decision: "allow" }; |
| 84 | + |
| 85 | + return { |
| 86 | + decision: "allow", |
| 87 | + hookSpecificOutput: { |
| 88 | + ...(event ? { hookEventName: event } : {}), |
| 89 | + additionalContext, |
| 90 | + }, |
| 91 | + }; |
| 92 | +} |
| 93 | + |
| 94 | +export function statePath(input: HookInput): string { |
| 95 | + const key = [ |
| 96 | + detectClient(input), |
| 97 | + stringField(input, "session_id") ?? stringField(input, "sessionId") ?? "no-session", |
| 98 | + stringField(input, "cwd") ?? process.cwd(), |
| 99 | + ].join("|"); |
| 100 | + const hash = createHash("sha256").update(key).digest("hex"); |
| 101 | + return path.join(os.tmpdir(), "promptimprover-hooks", `${hash}.json`); |
| 102 | +} |
| 103 | + |
| 104 | +export function saveState(input: HookInput, state: HookState): void { |
| 105 | + const file = statePath(input); |
| 106 | + fs.mkdirSync(path.dirname(file), { recursive: true }); |
| 107 | + fs.writeFileSync(file, JSON.stringify(state), { encoding: "utf8", mode: 0o600 }); |
| 108 | +} |
| 109 | + |
| 110 | +export function loadState(input: HookInput): HookState | undefined { |
| 111 | + const file = statePath(input); |
| 112 | + try { |
| 113 | + const state = JSON.parse(fs.readFileSync(file, "utf8")) as HookState; |
| 114 | + if (!state.promptId || Date.now() - Date.parse(state.createdAt) > STATE_MAX_AGE_MS) { |
| 115 | + fs.rmSync(file, { force: true }); |
| 116 | + return undefined; |
| 117 | + } |
| 118 | + return state; |
| 119 | + } catch { |
| 120 | + return undefined; |
| 121 | + } |
| 122 | +} |
| 123 | + |
| 124 | +export function clearState(input: HookInput): void { |
| 125 | + fs.rmSync(statePath(input), { force: true }); |
| 126 | +} |
| 127 | + |
| 128 | +export async function runPrePrompt(input: HookInput, callTool: McpToolCaller): Promise<Record<string, unknown>> { |
| 129 | + const prompt = extractPrompt(input); |
| 130 | + if (!prompt?.trim()) return allowOutput(input); |
| 131 | + |
| 132 | + try { |
| 133 | + const lintText = await callTool("lint_prompt", { prompt, semantic: false }); |
| 134 | + const promptId = extractPromptId(lintText); |
| 135 | + if (promptId) { |
| 136 | + saveState(input, { |
| 137 | + promptId, |
| 138 | + client: detectClient(input), |
| 139 | + createdAt: new Date().toISOString(), |
| 140 | + }); |
| 141 | + } |
| 142 | + return allowOutput(input, buildLintContext(lintText, promptId)); |
| 143 | + } catch { |
| 144 | + return allowOutput(input); |
| 145 | + } |
| 146 | +} |
| 147 | + |
| 148 | +export async function runPostExecution(input: HookInput, callTool: McpToolCaller): Promise<Record<string, unknown>> { |
| 149 | + const state = loadState(input); |
| 150 | + const promptId = stringField(input, "prompt_id") ?? state?.promptId; |
| 151 | + if (!promptId) return allowOutput(input); |
| 152 | + |
| 153 | + const client = state?.client ?? detectClient(input); |
| 154 | + const outputLength = extractOutputLength(input); |
| 155 | + await callTool("record_agent_output", { |
| 156 | + prompt_id: promptId, |
| 157 | + output_summary: `${client} completed the tracked turn; output_length=${outputLength}.`, |
| 158 | + artifacts_json: JSON.stringify({ |
| 159 | + client, |
| 160 | + hook_event: stringField(input, "hook_event_name") ?? "manual", |
| 161 | + output_length: outputLength, |
| 162 | + }), |
| 163 | + status: stringField(input, "status") === "failed" ? "failed" : "completed", |
| 164 | + }); |
| 165 | + clearState(input); |
| 166 | + return allowOutput(input); |
| 167 | +} |
| 168 | + |
| 169 | +function firstString(input: HookInput, fields: string[]): string | undefined { |
| 170 | + for (const field of fields) { |
| 171 | + const value = stringField(input, field); |
| 172 | + if (value) return value; |
| 173 | + } |
| 174 | + return undefined; |
| 175 | +} |
| 176 | + |
| 177 | +function stringField(input: HookInput, field: string): string | undefined { |
| 178 | + const value = input[field]; |
| 179 | + return typeof value === "string" ? value : undefined; |
| 180 | +} |
0 commit comments