feat: claude one-shot port from nanobot python codebase (v0.1.4.post4)

This commit is contained in:
Joe Fleming
2026-03-13 08:58:43 -06:00
parent 37c66a1bbf
commit a857bf95cd
53 changed files with 5002 additions and 8 deletions

154
src/session/manager.ts Normal file
View 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;
}
}