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:
+4
-503
@@ -5,25 +5,17 @@ import { readLocalProviders } from '../server-settings/pi-mono';
|
||||
import { readSttConfig } from '../server-settings/stt';
|
||||
import { listPiModels } from './list-models';
|
||||
import { getHomeDirForRole } from '../../data-path';
|
||||
import { resolveBaseCwd } from './websocket';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* REST API Endpoints — Session management and model info
|
||||
*/
|
||||
|
||||
export const piRestRouter = createRouter();
|
||||
|
||||
/**
|
||||
* GET /api/pi/models
|
||||
* List available models via `pi --list-models` (with stored API keys + PI_CODING_AGENT_DIR).
|
||||
* Local provider friendly names are resolved from stored local-providers config.
|
||||
*/
|
||||
piRestRouter.get('/pi/models', async (ctx: Context) => {
|
||||
try {
|
||||
const models = await listPiModels();
|
||||
|
||||
// Build providerNames map
|
||||
const providerNames: Record<string, string> = { 'claude-code': 'Claude Code' };
|
||||
const localProviders = await readLocalProviders();
|
||||
for (const lp of localProviders) {
|
||||
@@ -38,39 +30,9 @@ piRestRouter.get('/pi/models', async (ctx: Context) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/pi/sessions
|
||||
* List all sessions for the current user.
|
||||
* Optionally filter by cwd/cwdRoot to show only project-scoped sessions.
|
||||
*/
|
||||
piRestRouter.post('/pi/sessions', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const body = await ctx.req.json().catch(() => ({}));
|
||||
const userHome = getHomeDirForRole(user.email, user.role);
|
||||
const filterCwd = body.cwd ? resolveBaseCwd(user.email, user.role, body.cwd) : null;
|
||||
const contextFilter = body.context
|
||||
? { context: body.context as string, contextId: body.contextId as string | undefined }
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
let sessions = await storage.listUserSessions(userHome, contextFilter);
|
||||
if (filterCwd) {
|
||||
sessions = sessions.filter((s) => s.cwd === filterCwd);
|
||||
}
|
||||
return ctx.json({ sessions });
|
||||
} catch (err) {
|
||||
logger.error('Failed to list sessions', { email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to list sessions' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/pi/sessions/:sessionId
|
||||
* Get session detail with full message history
|
||||
* Get session detail with full message history (used by active chat to load messages)
|
||||
*/
|
||||
piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
@@ -86,484 +48,23 @@ piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
const userHome = getHomeDirForRole(user.email, user.role);
|
||||
|
||||
try {
|
||||
// Try loading from root first
|
||||
let meta, messages;
|
||||
try {
|
||||
({ meta, messages } = await storage.loadSession(userHome, sessionId));
|
||||
} catch {
|
||||
// Not in root, search in groups
|
||||
const groups = await storage.listGroups(userHome);
|
||||
let found = false;
|
||||
|
||||
for (const group of groups) {
|
||||
try {
|
||||
({ meta, messages } = await storage.loadSession(userHome, sessionId, group.slug));
|
||||
found = true;
|
||||
break;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
throw new Error('Session not found');
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json({
|
||||
session: {
|
||||
...meta,
|
||||
messages,
|
||||
},
|
||||
});
|
||||
const { meta, messages } = await storage.loadSession(userHome, sessionId);
|
||||
return ctx.json({ session: { ...meta, messages } });
|
||||
} catch {
|
||||
// Session not found - this is expected for new sessions, don't log as error
|
||||
return ctx.json({ error: 'Session not found' }, 404);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/pi/sessions/:sessionId/messages
|
||||
* Client-side message save — no-op, sessions are persisted server-side via WebSocket events
|
||||
* No-op — sessions are persisted server-side via WebSocket events
|
||||
*/
|
||||
piRestRouter.put('/pi/sessions/:sessionId/messages', async (ctx: Context) => {
|
||||
return ctx.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/pi/sessions/:sessionId
|
||||
* Update session metadata (e.g., rename)
|
||||
*/
|
||||
piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
if (!sessionId) {
|
||||
return ctx.json({ error: 'Session ID required' }, 400);
|
||||
}
|
||||
|
||||
const body = await ctx.req.json();
|
||||
if (!body.title || typeof body.title !== 'string') {
|
||||
return ctx.json({ error: 'Title is required and must be a string' }, 400);
|
||||
}
|
||||
|
||||
const userHome = getHomeDirForRole(user.email, user.role);
|
||||
|
||||
try {
|
||||
// Find the session (root or in group)
|
||||
let groupSlug: string | null = null;
|
||||
|
||||
try {
|
||||
const { meta } = await storage.loadSession(userHome, sessionId);
|
||||
groupSlug = meta.groupSlug || null;
|
||||
} catch {
|
||||
// Not in root, search groups
|
||||
const groups = await storage.listGroups(userHome);
|
||||
for (const group of groups) {
|
||||
try {
|
||||
await storage.loadSession(userHome, sessionId, group.slug);
|
||||
groupSlug = group.slug;
|
||||
break;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedMeta = await storage.updateSessionMeta(
|
||||
userHome,
|
||||
sessionId,
|
||||
{
|
||||
title: body.title,
|
||||
},
|
||||
groupSlug,
|
||||
);
|
||||
|
||||
return ctx.json({
|
||||
success: true,
|
||||
session: updatedMeta,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to update session', { sessionId, email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to update session' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/pi/sessions/:sessionId
|
||||
* Delete a session
|
||||
*/
|
||||
piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
if (!sessionId) {
|
||||
return ctx.json({ error: 'Session ID required' }, 400);
|
||||
}
|
||||
|
||||
const userHome = getHomeDirForRole(user.email, user.role);
|
||||
|
||||
try {
|
||||
// Find the session (root or in group)
|
||||
let groupSlug: string | null = null;
|
||||
|
||||
try {
|
||||
const { meta } = await storage.loadSession(userHome, sessionId);
|
||||
groupSlug = meta.groupSlug || null;
|
||||
} catch {
|
||||
// Not in root, search groups
|
||||
const groups = await storage.listGroups(userHome);
|
||||
for (const group of groups) {
|
||||
try {
|
||||
const { meta } = await storage.loadSession(userHome, sessionId, group.slug);
|
||||
groupSlug = meta.groupSlug || null;
|
||||
break;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await storage.deleteSession(userHome, sessionId, groupSlug);
|
||||
|
||||
// Update group session count if in a group
|
||||
if (groupSlug) {
|
||||
try {
|
||||
const group = await storage.loadGroup(userHome, groupSlug);
|
||||
group.sessionCount = Math.max(0, group.sessionCount - 1);
|
||||
group.updatedAt = Date.now();
|
||||
await storage.saveGroup(userHome, group);
|
||||
} catch {
|
||||
// Group might not exist anymore
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json({ success: true });
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete session', { sessionId, email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to delete session' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/pi/sessions
|
||||
* Delete all sessions, optionally filtered by context
|
||||
*/
|
||||
piRestRouter.delete('/pi/sessions', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const body = await ctx.req.json().catch(() => ({}));
|
||||
const sessionIds = Array.isArray(body.sessionIds) ? (body.sessionIds as string[]) : undefined;
|
||||
const contextFilter = body.context
|
||||
? { context: body.context as string, contextId: body.contextId as string | undefined }
|
||||
: undefined;
|
||||
const userHome = getHomeDirForRole(user.email, user.role);
|
||||
|
||||
try {
|
||||
const allSessions = await storage.listUserSessions(userHome, contextFilter);
|
||||
const sessions = sessionIds
|
||||
? allSessions.filter((s) => sessionIds.includes(s.id))
|
||||
: allSessions;
|
||||
let deleted = 0;
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
await storage.deleteSession(userHome, session.id, session.groupSlug);
|
||||
deleted++;
|
||||
} catch {
|
||||
// Skip sessions that fail to delete
|
||||
}
|
||||
}
|
||||
logger.info('Bulk deleted sessions', {
|
||||
email: user.email,
|
||||
deleted,
|
||||
total: sessions.length,
|
||||
context: contextFilter?.context,
|
||||
sessionIds: sessionIds?.length,
|
||||
});
|
||||
return ctx.json({ success: true, deleted });
|
||||
} catch (err) {
|
||||
logger.error('Failed to bulk delete sessions', { email: user.email, error: String(err) });
|
||||
return ctx.json({ error: 'Failed to delete sessions' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/pi/sessions/search
|
||||
* Search sessions by query
|
||||
*/
|
||||
piRestRouter.get('/pi/sessions/search', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const query = ctx.req.query('q');
|
||||
if (!query) {
|
||||
return ctx.json({ error: 'Query parameter required' }, 400);
|
||||
}
|
||||
|
||||
const userHome = getHomeDirForRole(user.email, user.role);
|
||||
|
||||
try {
|
||||
const results = await storage.searchSessions(userHome, query);
|
||||
return ctx.json({ results });
|
||||
} catch (err) {
|
||||
logger.error('Failed to search sessions', { query, email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to search sessions' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/pi/groups
|
||||
* Create a new group
|
||||
*/
|
||||
piRestRouter.post('/pi/groups', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const body = await ctx.req.json();
|
||||
|
||||
if (!body.name || typeof body.name !== 'string') {
|
||||
return ctx.json({ error: 'Name is required and must be a string' }, 400);
|
||||
}
|
||||
|
||||
if (!body.slug || typeof body.slug !== 'string') {
|
||||
return ctx.json({ error: 'Slug is required and must be a string' }, 400);
|
||||
}
|
||||
|
||||
const userHome = getHomeDirForRole(user.email, user.role);
|
||||
|
||||
try {
|
||||
// Check if group already exists
|
||||
const exists = await storage.groupExists(userHome, body.slug);
|
||||
if (exists) {
|
||||
return ctx.json({ error: 'Group with this slug already exists' }, 409);
|
||||
}
|
||||
|
||||
const groupMeta = {
|
||||
name: body.name,
|
||||
slug: body.slug,
|
||||
description: body.description || '',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
sessionCount: 0,
|
||||
};
|
||||
|
||||
await storage.saveGroup(userHome, groupMeta);
|
||||
|
||||
// Move sessions to group if provided
|
||||
if (body.sessionIds && Array.isArray(body.sessionIds)) {
|
||||
for (const sessionId of body.sessionIds) {
|
||||
try {
|
||||
// Find session (check both root and other groups)
|
||||
let sessionMeta;
|
||||
try {
|
||||
const { meta } = await storage.loadSession(userHome, sessionId);
|
||||
sessionMeta = meta;
|
||||
} catch {
|
||||
// Session might be in another group - skip
|
||||
continue;
|
||||
}
|
||||
|
||||
await storage.moveSession(userHome, sessionId, sessionMeta.groupSlug || null, body.slug);
|
||||
groupMeta.sessionCount++;
|
||||
} catch (err) {
|
||||
logger.error('Failed to move session to group', { sessionId, groupSlug: body.slug, error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// Update group with final session count
|
||||
await storage.saveGroup(userHome, groupMeta);
|
||||
}
|
||||
|
||||
return ctx.json({
|
||||
success: true,
|
||||
group: groupMeta,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to create group', { slug: body.slug, email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to create group' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/pi/groups
|
||||
* List all groups
|
||||
*/
|
||||
piRestRouter.get('/pi/groups', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const userHome = getHomeDirForRole(user.email, user.role);
|
||||
|
||||
try {
|
||||
const groups = await storage.listGroups(userHome);
|
||||
return ctx.json({ groups });
|
||||
} catch (err) {
|
||||
logger.error('Failed to list groups', { email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to list groups' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/pi/groups/:groupSlug
|
||||
* Update group metadata
|
||||
*/
|
||||
piRestRouter.patch('/pi/groups/:groupSlug', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const groupSlug = ctx.req.param('groupSlug');
|
||||
if (!groupSlug) {
|
||||
return ctx.json({ error: 'Group slug required' }, 400);
|
||||
}
|
||||
|
||||
const body = await ctx.req.json();
|
||||
|
||||
const updates: Record<string, string> = {};
|
||||
if (body.name && typeof body.name === 'string') {
|
||||
updates.name = body.name;
|
||||
}
|
||||
if (body.description !== undefined) {
|
||||
updates.description = body.description;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return ctx.json({ error: 'No valid updates provided' }, 400);
|
||||
}
|
||||
|
||||
const userHome = getHomeDirForRole(user.email, user.role);
|
||||
|
||||
try {
|
||||
const updatedGroup = await storage.updateGroupMeta(userHome, groupSlug, updates);
|
||||
return ctx.json({
|
||||
success: true,
|
||||
group: updatedGroup,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to update group', { groupSlug, email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to update group' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/pi/groups/:groupSlug
|
||||
* Delete a group (moves sessions to root)
|
||||
*/
|
||||
piRestRouter.delete('/pi/groups/:groupSlug', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const groupSlug = ctx.req.param('groupSlug');
|
||||
if (!groupSlug) {
|
||||
return ctx.json({ error: 'Group slug required' }, 400);
|
||||
}
|
||||
|
||||
const userHome = getHomeDirForRole(user.email, user.role);
|
||||
|
||||
try {
|
||||
await storage.deleteGroup(userHome, groupSlug);
|
||||
return ctx.json({ success: true });
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete group', { groupSlug, email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to delete group' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/pi/sessions/:sessionId/move
|
||||
* Move session to/from a group
|
||||
*/
|
||||
piRestRouter.post('/pi/sessions/:sessionId/move', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
if (!sessionId) {
|
||||
return ctx.json({ error: 'Session ID required' }, 400);
|
||||
}
|
||||
|
||||
const body = await ctx.req.json();
|
||||
const toGroupSlug = body.groupSlug === null ? null : body.groupSlug;
|
||||
|
||||
if (toGroupSlug !== null && typeof toGroupSlug !== 'string') {
|
||||
return ctx.json({ error: 'groupSlug must be a string or null' }, 400);
|
||||
}
|
||||
|
||||
const userHome = getHomeDirForRole(user.email, user.role);
|
||||
|
||||
try {
|
||||
// Find the session in root or any group
|
||||
let fromGroupSlug: string | null = null;
|
||||
let sessionFound = false;
|
||||
|
||||
// Try root level first
|
||||
try {
|
||||
await storage.loadSession(userHome, sessionId);
|
||||
fromGroupSlug = null;
|
||||
sessionFound = true;
|
||||
} catch {
|
||||
// Not in root, check groups
|
||||
const groups = await storage.listGroups(userHome);
|
||||
for (const group of groups) {
|
||||
try {
|
||||
await storage.loadSession(userHome, sessionId, group.slug);
|
||||
fromGroupSlug = group.slug;
|
||||
sessionFound = true;
|
||||
break;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionFound) {
|
||||
return ctx.json({ error: 'Session not found' }, 404);
|
||||
}
|
||||
|
||||
// Validate target group exists
|
||||
if (toGroupSlug !== null) {
|
||||
const groupExists = await storage.groupExists(userHome, toGroupSlug);
|
||||
if (!groupExists) {
|
||||
return ctx.json({ error: 'Target group does not exist' }, 404);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedMeta = await storage.moveSession(userHome, sessionId, fromGroupSlug, toGroupSlug);
|
||||
|
||||
return ctx.json({
|
||||
success: true,
|
||||
session: updatedMeta,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to move session', { sessionId, toGroupSlug, email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to move session' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/pi/stt
|
||||
* Proxy audio to configured Whisper server for speech-to-text transcription.
|
||||
* Accepts multipart form data with audio file + whisper params.
|
||||
*/
|
||||
piRestRouter.post('/pi/stt', async (ctx: Context) => {
|
||||
const sttConfig = await readSttConfig();
|
||||
|
||||
Reference in New Issue
Block a user