import { createHash } from "node:crypto"; export interface MessageLike { id?: string; role: string; timestamp?: number; content?: unknown; toolCallId?: string; [_: string]: unknown; } export interface SessionManagerLike { getBranch?: () => Array<{ type?: string; id?: string; message?: unknown }>; getEntries?: () => Array<{ type?: string; id?: string; message?: unknown }>; } /** * pi messages (AgentMessage) carry no id — the id lives on the session entry * (MessageEntry). The message object stored in the entry is the SAME reference * that flows through message_end and session_before_compact, so entries can be * matched to messages by reference. */ export function findEntryId(sessionManager: SessionManagerLike | undefined, message: MessageLike): string | null { if (!sessionManager) return null; const entries = sessionManager.getBranch?.() ?? sessionManager.getEntries?.() ?? []; for (let i = entries.length - 1; i >= 0; i--) { const e = entries[i]; if (e?.type === "message" && e.message === message && typeof e.id === "string" && e.id) { return e.id; } } return null; } /** * Deterministic fallback id when the session entry cannot be resolved: * - tool results: the provider's stable toolCallId * - otherwise: sha1 of role|timestamp|content * Stable across runs and identical between realtime population and compaction, * so rows never duplicate. */ export function derivedId(m: MessageLike): string { if (m.role === "toolResult" && typeof m.toolCallId === "string" && m.toolCallId) { return `tool_${m.toolCallId}`; } const canonical = JSON.stringify({ role: m.role, timestamp: m.timestamp ?? 0, content: m.content ?? null }); return `h_${createHash("sha1").update(canonical).digest("hex").slice(0, 16)}`; }