feat: claude one-shot port from nanobot python codebase (v0.1.4.post4)
This commit is contained in:
154
src/session/manager.ts
Normal file
154
src/session/manager.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user