/* * Veskslop daemon — per-channel message cache in a tmp folder with a size limit. * * The cache backs conversation context. It keeps the newest messages per * channel and evicts the least recently written channel files when the folder * exceeds the size limit. */ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { compareSnowflakes, type CachedMessage } from "./communication.js"; export const CACHE_DEFAULT_MAX_BYTES = 50 * 1024 * 1024; const MAX_MESSAGES_PER_CHANNEL = 200; interface ChannelCacheFile { messages: CachedMessage[]; } export class MessageCache { private readonly dir: string; private readonly maxBytes: number; constructor( dir = process.env.VESKSLOP_CACHE_DIR ?? join(tmpdir(), "veskslop-cache"), maxBytes = Number(process.env.VESKSLOP_CACHE_MAX_BYTES ?? CACHE_DEFAULT_MAX_BYTES) ) { this.dir = dir; this.maxBytes = maxBytes; mkdirSync(this.dir, { recursive: true }); } get(channelId: string): CachedMessage[] { const file = this.file(channelId); if (!existsSync(file)) return []; try { return (JSON.parse(readFileSync(file, "utf8")) as ChannelCacheFile).messages; } catch { return []; } } newestId(channelId: string): string | undefined { return this.get(channelId).at(-1)?.id; } /** Merge messages by ID, keep the newest, write, then evict old files. */ merge(channelId: string, incoming: readonly CachedMessage[]): CachedMessage[] { const byId = new Map(); for (const message of [...this.get(channelId), ...incoming]) byId.set(message.id, message); const merged = [...byId.values()] .sort((a, b) => compareSnowflakes(a.id, b.id)) .slice(-MAX_MESSAGES_PER_CHANNEL); this.write(channelId, merged); this.evict(); return merged; } /** Update cached content after an edit. */ edit(channelId: string, messageId: string, content: string): boolean { const messages = this.get(channelId); const message = messages.find(item => item.id === messageId); if (!message) return false; message.content = content; this.write(channelId, messages); return true; } /** Drop a deleted message from the cache. */ remove(channelId: string, messageId: string): boolean { const messages = this.get(channelId); const next = messages.filter(item => item.id !== messageId); if (next.length === messages.length) return false; this.write(channelId, next); return true; } private file(channelId: string): string { return join(this.dir, `${channelId}.json`); } private write(channelId: string, messages: CachedMessage[]) { try { writeFileSync(this.file(channelId), JSON.stringify({ messages } satisfies ChannelCacheFile)); } catch (error) { console.error("[cache] write failed:", error); } } private evict() { try { const files = readdirSync(this.dir) .filter(name => name.endsWith(".json")) .map(name => { const path = join(this.dir, name); const stats = statSync(path); return { path, size: stats.size, mtime: stats.mtimeMs }; }); let total = files.reduce((sum, file) => sum + file.size, 0); if (total <= this.maxBytes) return; files.sort((a, b) => a.mtime - b.mtime); for (const file of files) { if (total <= this.maxBytes) break; rmSync(file.path, { force: true }); total -= file.size; } } catch (error) { console.error("[cache] eviction failed:", error); } } }