152 lines
4.3 KiB
TypeScript
152 lines
4.3 KiB
TypeScript
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<string, Session>();
|
|
|
|
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;
|
|
}
|
|
}
|