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
+4 -503
View File
@@ -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();
+21 -20
View File
@@ -1,7 +1,7 @@
export type Message = {
id: string;
timestamp: number;
role: "user" | "assistant" | "tool";
role: 'user' | 'assistant' | 'tool';
text?: string;
model?: string;
cost?: MessageCost;
@@ -45,7 +45,7 @@ export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhi
export type ClientMessage =
| {
type: "chat";
type: 'chat';
prompt: string;
displayText?: string;
sessionId?: string;
@@ -58,20 +58,21 @@ export type ClientMessage =
thinking?: ThinkingLevel;
context?: string;
contextId?: string;
resumeSummary?: string;
}
| {
type: "resume";
type: 'resume';
sessionId: string;
cwd?: string;
cwdRoot?: string;
}
| {
type: "stop";
type: 'stop';
};
export type ServerMessage =
| {
type: "session:init";
type: 'session:init';
sessionId: string;
model: string;
cwd: string;
@@ -79,67 +80,67 @@ export type ServerMessage =
contextId?: string;
}
| {
type: "assistant:text";
type: 'assistant:text';
text: string;
}
| {
type: "assistant:delta";
type: 'assistant:delta';
text: string;
}
| {
type: "tool:start";
type: 'tool:start';
toolCallId: string;
toolName: string;
toolInput: Record<string, unknown>;
}
| {
type: "tool:result";
type: 'tool:result';
toolCallId: string;
output: string;
isError: boolean;
}
| {
type: "result";
type: 'result';
sessionId: string;
cost: MessageCost;
}
| {
type: "sync:messages";
type: 'sync:messages';
sessionId: string;
messages: Message[];
isGenerating: boolean;
streamingText: string;
}
| {
type: "error";
type: 'error';
message: string;
errorCode?: string;
}
| {
type: "stopped";
type: 'stopped';
};
export type PiEvent =
| { type: "text"; text: string }
| { type: "delta"; text: string }
| { type: 'text'; text: string }
| { type: 'delta'; text: string }
| {
type: "tool:start";
type: 'tool:start';
toolCallId: string;
toolName: string;
toolInput: Record<string, unknown>;
}
| {
type: "tool:result";
type: 'tool:result';
toolCallId: string;
output: string;
isError: boolean;
}
| {
type: "result";
type: 'result';
cost: MessageCost;
}
| { type: "error"; message: string }
| { type: "stopped" };
| { type: 'error'; message: string }
| { type: 'stopped' };
export type UserSession = {
sessionId: string;
+10 -3
View File
@@ -248,11 +248,17 @@ async function handleChat(
thinking?: string;
context?: string;
contextId?: string;
resumeSummary?: string;
},
): Promise<void> {
const { email, username, userId } = ws.data;
const sessionId = msg.sessionId || randomUUID();
// Prepend resume summary to the prompt if present
const prompt = msg.resumeSummary
? `Here is a summary of a previous conversation to continue from:\n\n${msg.resumeSummary}\n\n---\n\nUser's new message: ${msg.prompt}`
: msg.prompt;
// Use provided model, or fall back to user default, or use system default
let model = msg.model;
let modelSource = 'client-provided';
@@ -277,7 +283,7 @@ async function handleChat(
});
if (model.startsWith('claude-code')) {
return handleClaudeCodeChat(ws, sessionId, model, msg);
return handleClaudeCodeChat(ws, sessionId, model, msg, prompt);
}
const homeDir = getHomeDirForRole(email, ws.data.role);
@@ -365,7 +371,7 @@ async function handleChat(
// Send prompt to Pi via sidecar
const requestId = randomUUID();
session.isGenerating = true;
sidecar.sendPiPrompt(sessionId, msg.prompt, requestId);
sidecar.sendPiPrompt(sessionId, prompt, requestId);
}
async function handleClaudeCodeChat(
@@ -382,6 +388,7 @@ async function handleClaudeCodeChat(
cwdRoot?: string;
sandboxed?: boolean;
},
effectivePrompt: string,
): Promise<void> {
const { email, username, userId } = ws.data;
const homeDir = getHomeDirForRole(email, ws.data.role);
@@ -428,7 +435,7 @@ async function handleClaudeCodeChat(
userId,
email,
username,
prompt: msg.prompt,
prompt: effectivePrompt,
sessionKey: sessionId,
cwd,
model,
@@ -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,
});
});
+3 -4
View File
@@ -30,10 +30,9 @@ export const scrapeRouter = createRouter();
scrapeRouter.post('/', async (ctx) => {
const user = ctx.get('user');
const { url, sessionId, provider } = ctx.get('body') as {
const { url, sessionId } = ctx.get('body') as {
url: string;
sessionId?: string;
provider?: 'claude' | 'pi-mono';
};
if (!url) return ctx.json({ error: 'url is required' }, 400);
@@ -140,9 +139,9 @@ scrapeRouter.post('/', async (ctx) => {
let attachmentId: string;
let saveDir: string;
if (sessionId && provider) {
if (sessionId) {
attachmentId = `${slug}.html`;
saveDir = getAttachmentsDir(user.email, provider, sessionId);
saveDir = getAttachmentsDir(user.email, sessionId);
} else {
attachmentId = `${crypto.randomUUID()}.html`;
saveDir = getTmpAttachmentsDir(user.email);
-207
View File
@@ -1,207 +0,0 @@
import { Hono } from 'hono';
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { getClaudeDir, getSessionDir, getArchivedSessionDir, getPiMonoDir, getPiMonoSessionDir } from '@@/data-path';
import type { HonoVariables } from '@@/create-router';
export const sessionsRouter = new Hono<{ Variables: HonoVariables }>();
// --- List all sessions (merged from both providers) ---
sessionsRouter.get('/sessions', async (ctx) => {
const { email } = ctx.get('user');
const [claudeSessions, piMonoSessions] = await Promise.all([fetchClaudeSessions(email), fetchPiMonoSessions(email)]);
const merged = [...claudeSessions, ...piMonoSessions].sort((a, b) => b.createdAt - a.createdAt);
return ctx.json(merged);
});
// --- Messages ---
sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => {
const { email } = ctx.get('user');
const provider = ctx.req.param('provider');
const id = ctx.req.param('id');
if (provider === 'claude') {
const file = Bun.file(join(getSessionDir(email, id), 'messages.json'));
if (!(await file.exists())) return ctx.json([]);
try {
return ctx.json(await file.json());
} catch {
return ctx.json([]);
}
}
if (provider === 'pi-mono') {
const file = Bun.file(join(getPiMonoSessionDir(email, id), 'messages.json'));
if (!(await file.exists())) return ctx.json([]);
try {
return ctx.json(await file.json());
} catch {
return ctx.json([]);
}
}
return ctx.json({ error: 'invalid provider' }, 400);
});
sessionsRouter.put('/sessions/:provider/:id/messages', async (ctx) => {
const { email } = ctx.get('user');
const provider = ctx.req.param('provider');
const id = ctx.req.param('id');
if (provider !== 'claude' && provider !== 'pi-mono') return ctx.json({ error: 'invalid provider' }, 400);
const messages = ctx.get('body');
const dir = provider === 'pi-mono' ? getPiMonoSessionDir(email, id) : getSessionDir(email, id);
await Bun.write(join(dir, 'messages.json'), JSON.stringify(messages));
return ctx.json({ ok: true });
});
// --- Rename ---
sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
const { email } = ctx.get('user');
const provider = ctx.req.param('provider');
const id = ctx.req.param('id');
const body = ctx.get('body') as { title?: string };
if (!body?.title || typeof body.title !== 'string') return ctx.json({ error: 'title required' }, 400);
if (provider === 'claude') {
const dir = getSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
let meta: Record<string, unknown>;
try {
meta = await metaFile.json();
} catch {
return ctx.json({ error: 'corrupted session' }, 500);
}
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
return ctx.json({ ok: true });
}
if (provider === 'pi-mono') {
const dir = getPiMonoSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
let meta: Record<string, unknown>;
try {
meta = await metaFile.json();
} catch {
return ctx.json({ error: 'corrupted session' }, 500);
}
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
return ctx.json({ ok: true });
}
return ctx.json({ error: 'invalid provider' }, 400);
});
// --- Delete ---
sessionsRouter.delete('/sessions/:provider/:id', async (ctx) => {
const { email } = ctx.get('user');
const provider = ctx.req.param('provider');
const id = ctx.req.param('id');
if (provider === 'claude') {
const dir = getSessionDir(email, id);
try {
await rm(dir, { recursive: true });
} catch {
// dir may not exist
}
return ctx.json({ ok: true });
}
if (provider === 'pi-mono') {
const dir = getPiMonoSessionDir(email, id);
try {
await rm(dir, { recursive: true });
} catch {
// dir may not exist
}
return ctx.json({ ok: true });
}
return ctx.json({ error: 'invalid provider' }, 400);
});
// --- Archive (Claude only) ---
sessionsRouter.post('/sessions/:provider/:id/archive', async (ctx) => {
const { email } = ctx.get('user');
const provider = ctx.req.param('provider');
const id = ctx.req.param('id');
if (provider === 'pi-mono') return ctx.json({ error: 'pi-mono sessions cannot be archived' }, 400);
if (provider !== 'claude') return ctx.json({ error: 'invalid provider' }, 400);
const src = getSessionDir(email, id);
const dest = getArchivedSessionDir(email, id);
await mkdir(join(getClaudeDir(email), 'archived'), { recursive: true });
await rename(src, dest);
return ctx.json({ ok: true });
});
// --- Helpers ---
type SessionMeta = {
id: string;
title: string;
createdAt: number;
provider: 'claude' | 'pi-mono';
model?: string | null;
};
async function fetchClaudeSessions(email: string): Promise<SessionMeta[]> {
const dir = getClaudeDir(email);
try {
const entries = await readdir(dir);
const sessions = await Promise.all(
entries
.filter((name) => name !== 'archived')
.map(async (id) => {
try {
const metaFile = Bun.file(join(dir, id, 'meta.json'));
if (!(await metaFile.exists())) return null;
const meta = await metaFile.json();
return { ...meta, provider: 'claude' as const };
} catch {
return null;
}
}),
);
return sessions.filter((s): s is SessionMeta => s !== null);
} catch {
return [];
}
}
async function fetchPiMonoSessions(email: string): Promise<SessionMeta[]> {
const dir = getPiMonoDir(email);
try {
const entries = await readdir(dir);
const sessions = await Promise.all(
entries.map(async (id) => {
try {
const metaFile = Bun.file(join(dir, id, 'meta.json'));
if (!(await metaFile.exists())) return null;
const meta = await metaFile.json();
return { ...meta, provider: 'pi-mono' as const };
} catch {
return null;
}
}),
);
return sessions.filter((s): s is SessionMeta => s !== null);
} catch {
return [];
}
}
+2 -3
View File
@@ -13,7 +13,6 @@ uploadRouter.post('/', async (ctx) => {
const file = body.file as File | null;
const sessionId = (body.sessionId as string) || null;
const provider = (body.provider as 'claude' | 'pi-mono') || null;
if (!file || !(file instanceof File)) {
return ctx.json({ error: 'file is required' }, 400);
@@ -33,8 +32,8 @@ uploadRouter.post('/', async (ctx) => {
const filename = `${crypto.randomUUID()}.${ext}`;
let saveDir: string;
if (sessionId && provider) {
saveDir = getAttachmentsDir(user.email, provider, sessionId);
if (sessionId) {
saveDir = getAttachmentsDir(user.email, sessionId);
} else {
saveDir = getTmpAttachmentsDir(user.email);
}