import { appendFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import type { TextContent } from "@earendil-works/pi-ai"; import { globalFilePath } from "../config.js"; import type { Event, ReadonlyEntryFilter } from "../domain/entry.js"; import type { EvaluationContext, ToolCallEvent, ToolResultEvent, } from "./types.js"; /** Resolve a dot-separated filter key through own candidate properties. */ function resolveFilterField( candidate: Record, key: string, ): unknown { let current: unknown = candidate; for (const segment of key.split(".")) { if ( typeof current !== "object" || current === null || !Object.prototype.hasOwnProperty.call(current, segment) ) { return undefined; } current = (current as Record)[segment]; } return current; } function matchScalar(expected: unknown, actual: unknown): boolean { if (typeof expected === "string") { return typeof actual === "string" && new RegExp(expected).test(actual); } return actual === expected; } /** Exact, ANDed Hook selector semantics shared by Filters and Actions. */ export function matchResultSelector( selector: { readonly outcome?: unknown; readonly code?: unknown }, result: { readonly outcome: unknown; readonly code: unknown }, ): boolean { for (const field of ["outcome", "code"] as const) { const expected = selector[field]; if (expected === undefined) continue; const actual = result[field]; if (Array.isArray(expected)) { if (!expected.some((value) => value === actual)) return false; } else if (expected !== actual) { return false; } } return true; } /** Shared Filter semantics for Native and Hook Result Event candidates. */ export function matchFilter( filter: ReadonlyEntryFilter | undefined, candidate: Record, ): boolean { if (!filter) return true; for (const [key, expected] of Object.entries(filter)) { const actual = resolveFilterField(candidate, key); if (Array.isArray(expected)) { if (!expected.some((value) => matchScalar(value, actual))) return false; } else if (!matchScalar(expected, actual)) { return false; } } return true; } function logEnvironment(env: Record, event: Event): void { if (process.env.PIHOOKIT_LOG_ENV?.toLowerCase() !== "true") return; try { const directory = join(dirname(globalFilePath()), ".hook-env-log"); const today = new Date().toISOString().slice(0, 10); const path = join(directory, `${today}.jsonl`); mkdirSync(directory, { recursive: true }); appendFileSync(path, `${JSON.stringify({ ts: new Date().toISOString(), event, env, })}\n`); } catch { // Diagnostic logging must never change a hook decision. } } export function buildToolCallEnvironment( event: ToolCallEvent, context: EvaluationContext, ): Record { const env = { ...context.metadata, PI_EVENT: "tool_call", PI_TOOL_NAME: event.toolName, PI_TOOL_CALL_ID: event.toolCallId, PI_TOOL_INPUT: JSON.stringify(event.input), PI_CWD: context.cwd, } satisfies Record; logEnvironment(env, "tool_call"); return env; } export function buildToolResultEnvironment( event: ToolResultEvent, context: EvaluationContext, ): Record { const resultText = event.content .filter((content): content is TextContent => content.type === "text") .map((content) => content.text) .join("\n"); const env = { ...context.metadata, PI_EVENT: "tool_result", PI_TOOL_NAME: event.toolName, PI_TOOL_CALL_ID: event.toolCallId, PI_TOOL_INPUT: JSON.stringify(event.input), PI_TOOL_RESULT: resultText, PI_TOOL_IS_ERROR: event.isError ? "true" : "false", PI_CWD: context.cwd, } satisfies Record; logEnvironment(env, "tool_result"); return env; } export function buildLifecycleEnvironment( event: Event, payload: Record, context: EvaluationContext, ): Record { const env = { ...context.metadata, PI_EVENT: event, PI_EVENT_PAYLOAD: JSON.stringify(payload), PI_CWD: context.cwd, } satisfies Record; logEnvironment(env, event); return env; }