saved sessions: replace transient history with persistent DB storage and auto-save

- Add saved_sessions table and CRUD endpoints (save, list, resume, update, delete)
- Save is instant (no LLM summarization), stores exact conversation with tool calls
- Resume loads full message history into chat UI, sends transcript to agent on first message
- Auto-save updates DB after every agent response once a session is saved
- Delete old filesystem-based session/group management (sessions router, useChatSessions, useChatGroups)
- Clean up ChatHeader, SessionList, ChatDetailPanel for saved sessions flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 13:36:25 +00:00
co-authored by Claude Opus 4.6
parent d88fe3cac7
commit 2a8df0def1
39 changed files with 3272 additions and 2257 deletions
@@ -0,0 +1,160 @@
import { createRouter } from '../../create-router';
import {
listSavedSessions,
getSavedSession,
createSavedSession,
updateSavedSessionMessages,
deleteSavedSession,
} from 'officerdb';
import * as storage from '../pi/storage';
import { getHomeDirForRole } from '../../data-path';
import type { Message, MessageCost } from '../pi/types';
export const savedSessionsRouter = createRouter();
// POST /saved-sessions — save session instantly (no LLM summary)
savedSessionsRouter.post('/', async (ctx) => {
const user = ctx.get('user');
const { sessionId } = ctx.get('body') as { sessionId: string };
if (!sessionId) {
return ctx.json({ error: 'sessionId is required' }, 400);
}
const homeDir = getHomeDirForRole(user.email, user.role);
let meta;
let messages: Message[];
try {
const loaded = await storage.loadSession(homeDir, sessionId);
meta = loaded.meta;
messages = loaded.messages;
} catch {
return ctx.json({ error: 'Session not found on disk' }, 404);
}
try {
const saved = await createSavedSession({
userId: user.id,
provider: meta.model,
context: meta.context ?? null,
contextId: meta.contextId ?? null,
title: meta.title,
summary: meta.title,
rawMessages: messages,
cwd: meta.cwd,
cost: meta.cost as unknown as Record<string, unknown>,
});
return ctx.json(saved, 201);
} catch (err) {
console.error('Failed to create saved session:', err);
return ctx.json({ error: 'Failed to save session' }, 500);
}
});
// GET /saved-sessions — list (no rawMessages)
savedSessionsRouter.get('/', async (ctx) => {
const user = ctx.get('user');
const rows = await listSavedSessions(user.id);
return ctx.json(rows);
});
// GET /saved-sessions/:id — full record
savedSessionsRouter.get('/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (Number.isNaN(id)) {
return ctx.json({ error: 'Invalid id' }, 400);
}
const session = await getSavedSession(id, user.id);
if (!session) {
return ctx.json({ error: 'Not found' }, 404);
}
return ctx.json(session);
});
// PUT /saved-sessions/:id — update messages from disk (auto-save after each turn)
savedSessionsRouter.put('/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
const { sessionId } = ctx.get('body') as { sessionId: string };
if (Number.isNaN(id)) {
return ctx.json({ error: 'Invalid id' }, 400);
}
if (!sessionId) {
return ctx.json({ error: 'sessionId is required' }, 400);
}
const homeDir = getHomeDirForRole(user.email, user.role);
let meta;
let messages: Message[];
try {
const loaded = await storage.loadSession(homeDir, sessionId);
meta = loaded.meta;
messages = loaded.messages;
} catch {
return ctx.json({ error: 'Session not found on disk' }, 404);
}
const updated = await updateSavedSessionMessages(
id,
user.id,
messages,
meta.cost as unknown as Record<string, unknown>,
);
if (!updated) {
return ctx.json({ error: 'Not found' }, 404);
}
return ctx.json({ ok: true });
});
// DELETE /saved-sessions/:id
savedSessionsRouter.delete('/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (Number.isNaN(id)) {
return ctx.json({ error: 'Invalid id' }, 400);
}
const deleted = await deleteSavedSession(id, user.id);
if (!deleted) {
return ctx.json({ error: 'Not found' }, 404);
}
return ctx.json({ ok: true });
});
// POST /saved-sessions/:id/resume — get full session for resuming
savedSessionsRouter.post('/:id/resume', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (Number.isNaN(id)) {
return ctx.json({ error: 'Invalid id' }, 400);
}
const session = await getSavedSession(id, user.id);
if (!session) {
return ctx.json({ error: 'Not found' }, 404);
}
const cost = session.cost as unknown as MessageCost;
return ctx.json({
context: session.context,
cwd: session.cwd,
model: session.provider,
cost,
rawMessages: session.rawMessages,
});
});