src/store/storeVoice.ts

// Extracted Store Methods import type { Database } from "bun:sqlite";

import { clamp } from "../utils.ts"; import { safeJsonParse } from "../normalization/valueParsers.ts";

interface VoiceStore { db: Database; }

interface VoiceSessionRow { id: number; created_at: string; guild_id: string | null; kind: string; content: string | null; metadata: string | null; }

interface VoiceSessionEventRow { id: number; created_at: string; guild_id: string | null; channel_id: string | null; message_id: string | null; user_id: string | null; kind: string; content: string | null; metadata: string | null; usd_cost: number; }

export function getRecentVoiceSessions( store: VoiceStore, limit = 3, opts: { sinceIso?: string | null; guildId?: string | null } = {} ) { const boundedLimit = clamp(Math.floor(Number(limit) || 3), 1, 200); const normalizedSinceIso = String(opts?.sinceIso || "").trim(); const normalizedGuildId = String(opts?.guildId || "").trim(); const conditions = ["kind IN ('voice_session_start', 'voice_session_end')"]; const params: string[] = []; if (normalizedSinceIso) { conditions.push("created_at >= ?"); params.push(normalizedSinceIso); } if (normalizedGuildId) { conditions.push("guild_id = ?"); params.push(normalizedGuildId); } const rows = store.db .prepare<VoiceSessionRow, string[]>( SELECT id, created_at, guild_id, kind, content, metadata FROM actions WHERE ${conditions.join(" AND ")} ORDER BY created_at DESC ) .all(...params);

const starts = new Map<string, { guildId: string; mode: string; startedAt: string }>(); const ends = new Map<string, { endedAt: string; durationSeconds: number; endReason: string }>();

for (const row of rows) { const meta = safeJsonParse(row.metadata, null); const sessionId = meta?.sessionId; if (!sessionId) continue;

if (row.kind === "voice_session_start" && !starts.has(sessionId)) { starts.set(sessionId, { guildId: row.guild_id || "", mode: meta.mode || "voice_agent", startedAt: row.created_at }); } else if (row.kind === "voice_session_end" && !ends.has(sessionId)) { ends.set(sessionId, { endedAt: row.created_at, durationSeconds: Number(meta.durationSeconds) || 0, endReason: row.content || "unknown" }); } }

const sessions: Array<{ sessionId: string; guildId: string; mode: string; startedAt: string; endedAt: string; durationSeconds: number; endReason: string; }> = [];

for (const [sessionId, end] of ends) { const start = starts.get(sessionId); if (!start) continue; sessions.push({ sessionId, ...start, ...end }); }

sessions.sort((a, b) => (b.endedAt > a.endedAt ? 1 : -1)); return sessions.slice(0, boundedLimit); }

export function getVoiceSessionEvents(store: VoiceStore, sessionId: string, limit = 500) { const sanitized = String(sessionId || "").replace(/[%_\]/g, ""); if (!sanitized) return []; const boundedLimit = clamp(Math.floor(Number(limit) || 500), 1, 2000);

const rows = store.db .prepare<VoiceSessionEventRow, [string, number]>( SELECT id, created_at, guild_id, channel_id, message_id, user_id, kind, content, metadata, usd_cost FROM actions WHERE kind LIKE 'voice\\_%' ESCAPE '\\' AND metadata LIKE ? ORDER BY created_at ASC LIMIT ? ) .all(%"sessionId":"${sanitized}"%, boundedLimit);

return rows.map((row) => ({ ...row, metadata: safeJsonParse(row.metadata, null) })); }