#!/usr/bin/env node /** * Minimal native agent hook bridge. * * Keeps agent integrations close to Loom's core: SQLite remains the source of * truth, SessionStart injects buildLoomContext(), and PostToolUse reuses the * deterministic auto-capture extractor. */ import { stdin } from "node:process"; import { buildLoomContext } from "./context.js"; import { captureToolResult } from "./capture.js"; import { LoomStore, openDb } from "./store.js"; export type AgentHookEvent = "SessionStart" | "PostToolUse" | "Stop"; export type AgentHookPlatform = "codex" | "claude"; export interface AgentHookInput { hook_event_name?: string; session_id?: string; id?: string; sessionId?: string; cwd?: string; source?: string; tool_name?: string; toolName?: string; tool_input?: unknown; toolInput?: unknown; tool_response?: unknown; toolResponse?: unknown; last_assistant_message?: string; lastAssistantMessage?: string; } export interface AgentHookOutput { continue: true; suppressOutput?: boolean; hookSpecificOutput?: { hookEventName: AgentHookEvent; additionalContext?: string; }; } export async function handleAgentHook( platform: AgentHookPlatform, event: AgentHookEvent, input: AgentHookInput, ): Promise { if (isDisabled(platform)) return quiet(); return withStore((store) => { if (event === "SessionStart") return handleContext(platform, store); if (event === "PostToolUse") return handleCapture(platform, store, input); if (event === "Stop") return handleStop(platform, store, input); return quiet(); }); } export async function readStdinJson(): Promise { const chunks: Buffer[] = []; for await (const chunk of stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); if (chunks.length === 0) return {}; return JSON.parse(Buffer.concat(chunks).toString("utf8")) as AgentHookInput; } export function quiet(): AgentHookOutput { return { continue: true, suppressOutput: true }; } function handleContext(platform: AgentHookPlatform, store: LoomStore): AgentHookOutput { const context = buildLoomContext(store, { scope_type: readHookEnv(platform, "SCOPE_TYPE"), scope_id: readHookEnv(platform, "SCOPE_ID"), maxTokens: readPositiveInt(readHookEnv(platform, "MAX_TOKENS"), 350), maxTotalChars: readPositiveInt(readHookEnv(platform, "MAX_TOTAL_CHARS"), 1800), }); return { continue: true, suppressOutput: true, hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context, }, }; } function handleCapture(platform: AgentHookPlatform, store: LoomStore, input: AgentHookInput): AgentHookOutput { const toolName = pickString(input.tool_name, input.toolName); if (!toolName) return quiet(); const toolInput = input.tool_input ?? input.toolInput; const toolResponse = input.tool_response ?? input.toolResponse; const result = captureToolResult({ toolName, input: isRecord(toolInput) ? toolInput : {}, output: stringifyToolResponse(toolResponse), isError: isErrorResponse(toolResponse), }); const sessionId = pickString(input.session_id, input.id, input.sessionId) ?? `${platform}-${Date.now().toString(36)}`; store.storeRawEvent({ session_id: sessionId, event_type: `${platform}.${toolName}`, payload: result.rawPayload, }); for (const mem of result.memories) { store.store({ ...mem, importance: Math.min(mem.importance, 0.75), provenance: "auto_captured", }); } return quiet(); } function handleStop(platform: AgentHookPlatform, store: LoomStore, input: AgentHookInput): AgentHookOutput { const last = pickString(input.last_assistant_message, input.lastAssistantMessage)?.trim() ?? ""; if (!last) return quiet(); store.storeRawEvent({ session_id: pickString(input.session_id, input.id, input.sessionId) ?? `${platform}-${Date.now().toString(36)}`, event_type: `${platform}.stop`, payload: { last_assistant_message: last.slice(0, 2000) }, }); return quiet(); } function withStore(fn: (store: LoomStore) => T): T { const db = openDb(); try { return fn(new LoomStore(db)); } finally { db.close(); } } function isDisabled(platform: AgentHookPlatform): boolean { return process.env[`PI_LOOM_${platform.toUpperCase()}_HOOK_DISABLE`] === "true" || process.env.PI_LOOM_HOOK_DISABLE === "true"; } function readHookEnv(platform: AgentHookPlatform, key: string): string | undefined { const platformKey = `PI_LOOM_${platform.toUpperCase()}_${key}`; const genericKey = `PI_LOOM_HOOK_${key}`; return process.env[platformKey] ?? process.env[genericKey]; } function readPositiveInt(value: string | undefined, fallback: number): number { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } function stringifyToolResponse(value: unknown): string | undefined { if (value === undefined || value === null) return undefined; if (typeof value === "string") return value; return JSON.stringify(value).slice(0, 5000); } function isErrorResponse(value: unknown): boolean { if (!isRecord(value)) return false; return value.isError === true || value.error === true || typeof value.error === "string"; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function pickString(...values: unknown[]): string | undefined { for (const value of values) { if (typeof value === "string" && value.length > 0) return value; } return undefined; }