/** * chat-persistence.ts — Message persistence functions for the chat route. * * Extracted from chat.ts to keep files under 800 LOC. * These functions depend on `fs`, `path` — no server state. */ import fs from 'node:fs'; import path from 'node:path'; import { GENERATION_FAILED_PREFIX } from '@waggle/shared'; /** * Persist a chat message to the session's .jsonl file on disk. * This ensures messages survive server restarts. */ export function persistMessage( dataDir: string, workspaceId: string, sessionId: string, msg: { role: string; content: string }, ): void { const sessionsDir = path.join(dataDir, 'workspaces', workspaceId, 'sessions'); if (!fs.existsSync(sessionsDir)) { fs.mkdirSync(sessionsDir, { recursive: true }); } const filePath = path.join(sessionsDir, `${sessionId}.jsonl`); // Create file with meta line if it doesn't exist if (!fs.existsSync(filePath)) { const meta = JSON.stringify({ type: 'meta', title: null, created: new Date().toISOString() }); fs.writeFileSync(filePath, meta + '\n', 'utf-8'); } const line = JSON.stringify({ role: msg.role, content: msg.content, timestamp: new Date().toISOString() }); fs.appendFileSync(filePath, line + '\n', 'utf-8'); } /** * Strip a trailing failed user+assistant pair from a session's .jsonl file. * * A chat error persists the user turn followed by an assistant turn whose * content begins with GENERATION_FAILED_PREFIX. A client Retry re-issues the * same user message, which would leave the old failed pair duplicated on * reload. Call this before persisting the retried turn to drop that pair. * * Only rewrites when the tail is exactly a failed pair (assistant-failure line * preceded by a user line). Idempotent and safe otherwise. Returns whether it * stripped anything. */ export function stripTrailingFailedPair( dataDir: string, workspaceId: string, sessionId: string, ): boolean { const filePath = path.join(dataDir, 'workspaces', workspaceId, 'sessions', `${sessionId}.jsonl`); if (!fs.existsSync(filePath)) return false; const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter((l) => l.trim() !== ''); if (lines.length < 2) return false; const parse = (line: string): { role?: unknown; content?: unknown; type?: unknown } | null => { try { return JSON.parse(line); } catch { return null; } }; const lastIdx = lines.length - 1; const last = parse(lines[lastIdx]); if (!last || last.type === 'meta' || last.role !== 'assistant' || typeof last.content !== 'string' || !last.content.startsWith(GENERATION_FAILED_PREFIX)) { return false; } const prev = parse(lines[lastIdx - 1]); if (!prev || prev.type === 'meta' || prev.role !== 'user') return false; const kept = lines.slice(0, lastIdx - 1); fs.writeFileSync(filePath, kept.length ? kept.join('\n') + '\n' : '', 'utf-8'); return true; } /** * Load chat messages from a session's .jsonl file on disk. * Returns messages in the format expected by the agent loop. */ export function loadSessionMessages( dataDir: string, workspaceId: string, sessionId: string, ): Array<{ role: string; content: string }> { const filePath = path.join(dataDir, 'workspaces', workspaceId, 'sessions', `${sessionId}.jsonl`); if (!fs.existsSync(filePath)) return []; const content = fs.readFileSync(filePath, 'utf-8').trim(); if (!content) return []; const messages: Array<{ role: string; content: string }> = []; for (const line of content.split('\n')) { if (!line.trim()) continue; try { const parsed = JSON.parse(line); if (parsed.type === 'meta') continue; // skip metadata line if (parsed.role && parsed.content !== undefined) { messages.push({ role: parsed.role, content: parsed.content }); } } catch { // skip malformed lines } } return messages; }