Files
waggle-os/packages/server/src/local/routes/chat-persistence.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

109 lines
3.8 KiB
TypeScript

/**
* 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;
}