Files
platform/src/servers/api/pi/rest.ts
T
2026-02-23 06:49:18 +00:00

500 lines
14 KiB
TypeScript

import type { Context } from 'hono';
import { createRouter } from '../../create-router';
import * as storage from './storage';
import { readApiKeys, readLocalProviders } from '../server-settings/pi-mono';
import { listPiModels } from './list-models';
import { getHomeDir } 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 storedKeys = await readApiKeys();
const models = await listPiModels(storedKeys);
// Build providerNames map for officer-local-* providers
const providerNames: Record<string, string> = {};
const localProviders = await readLocalProviders();
for (const lp of localProviders) {
providerNames[`officer-local-${lp.id}`] = lp.name;
}
return ctx.json({ models, providerNames, hostHome: process.env.HOME ?? '' });
} catch (err) {
logger.error('Failed to list models', { error: String(err) });
return ctx.json({ models: [], providerNames: {} });
}
});
/**
* 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 = getHomeDir(user.email);
const filterCwd = body.cwd ? resolveBaseCwd(user.email, body.cwdRoot, body.cwd) : null;
try {
let sessions = await storage.listUserSessions(userHome);
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
*/
piRestRouter.get('/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 = getHomeDir(user.email);
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,
},
});
} catch {
// Session not found - this is expected for new sessions, don't log as error
return ctx.json({ error: 'Session not found' }, 404);
}
});
/**
* 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 = getHomeDir(user.email);
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 = getHomeDir(user.email);
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);
}
});
/**
* 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 = getHomeDir(user.email);
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 = getHomeDir(user.email);
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 = getHomeDir(user.email);
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 = getHomeDir(user.email);
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 = getHomeDir(user.email);
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 = getHomeDir(user.email);
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);
}
});