import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { SessionMessage, SessionMeta } from './types.ts'; const MAX_HISTORY_MESSAGES = 200; export class Session { key: string; messages: SessionMessage[]; createdAt: string; updatedAt: string; lastConsolidated: number; constructor(key: string) { const now = new Date().toISOString(); this.key = key; this.messages = []; this.createdAt = now; this.updatedAt = now; this.lastConsolidated = 0; } /** * Return the slice of messages that haven't been consolidated yet, * aligned to start on a user turn. */ getHistory(maxMessages = 0): SessionMessage[] { let slice = this.messages.slice(this.lastConsolidated); // Align to the first user message so we never start mid-turn const firstUser = slice.findIndex((m) => m.role === 'user'); if (firstUser > 0) slice = slice.slice(firstUser); if (maxMessages > 0 && slice.length > maxMessages) { // Take the last N messages, aligned to a user turn let trimmed = slice.slice(-maxMessages); const firstU = trimmed.findIndex((m) => m.role === 'user'); if (firstU > 0) trimmed = trimmed.slice(firstU); return trimmed; } return slice; } clear(): void { this.messages = []; this.lastConsolidated = 0; this.updatedAt = new Date().toISOString(); } get meta(): SessionMeta { return { key: this.key, createdAt: this.createdAt, updatedAt: this.updatedAt, lastConsolidated: this.lastConsolidated, }; } } export class SessionManager { private _dir: string; private _cache = new Map(); constructor(workspacePath: string) { this._dir = join(workspacePath, 'sessions'); mkdirSync(this._dir, { recursive: true }); } private _keyToFilename(key: string): string { // Replace characters unsafe for filenames return key.replace(/[:/\\]/g, '_') + '.jsonl'; } private _filePath(key: string): string { return join(this._dir, this._keyToFilename(key)); } getOrCreate(key: string): Session { const cached = this._cache.get(key); if (cached) return cached; const session = this._load(key); this._cache.set(key, session); return session; } private _load(key: string): Session { const path = this._filePath(key); const session = new Session(key); if (!existsSync(path)) return session; const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean); if (lines.length === 0) return session; // First line is the metadata record try { const meta = JSON.parse(lines[0]!) as SessionMeta; session.createdAt = meta.createdAt; session.updatedAt = meta.updatedAt; session.lastConsolidated = meta.lastConsolidated ?? 0; } catch { // corrupt first line — treat as new session return session; } // Remaining lines are messages for (const line of lines.slice(1)) { try { session.messages.push(JSON.parse(line) as SessionMessage); } catch { // skip corrupt message lines } } // Cap total messages to avoid unbounded growth if (session.messages.length > MAX_HISTORY_MESSAGES) { const excess = session.messages.length - MAX_HISTORY_MESSAGES; session.messages = session.messages.slice(excess); session.lastConsolidated = Math.max(0, session.lastConsolidated - excess); } return session; } save(session: Session): void { session.updatedAt = new Date().toISOString(); const lines = [JSON.stringify(session.meta), ...session.messages.map((m) => JSON.stringify(m))]; writeFileSync(this._filePath(session.key), lines.join('\n') + '\n', 'utf8'); } invalidate(key: string): void { this._cache.delete(key); } listSessions(): SessionMeta[] { const { readdirSync } = require('node:fs') as typeof import('node:fs'); const files = readdirSync(this._dir).filter((f: string) => f.endsWith('.jsonl')); const metas: SessionMeta[] = []; for (const file of files) { try { const first = readFileSync(join(this._dir, file), 'utf8').split('\n')[0]; if (first) metas.push(JSON.parse(first) as SessionMeta); } catch { // skip unreadable } } return metas; } }