@ottocode/server 0.1.173
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +42 -0
- package/src/events/bus.ts +43 -0
- package/src/events/types.ts +32 -0
- package/src/index.ts +281 -0
- package/src/openapi/helpers.ts +64 -0
- package/src/openapi/paths/ask.ts +70 -0
- package/src/openapi/paths/config.ts +218 -0
- package/src/openapi/paths/files.ts +72 -0
- package/src/openapi/paths/git.ts +457 -0
- package/src/openapi/paths/messages.ts +92 -0
- package/src/openapi/paths/sessions.ts +90 -0
- package/src/openapi/paths/setu.ts +154 -0
- package/src/openapi/paths/stream.ts +26 -0
- package/src/openapi/paths/terminals.ts +226 -0
- package/src/openapi/schemas.ts +345 -0
- package/src/openapi/spec.ts +49 -0
- package/src/presets.ts +85 -0
- package/src/routes/ask.ts +113 -0
- package/src/routes/auth.ts +592 -0
- package/src/routes/branch.ts +106 -0
- package/src/routes/config/agents.ts +44 -0
- package/src/routes/config/cwd.ts +21 -0
- package/src/routes/config/defaults.ts +45 -0
- package/src/routes/config/index.ts +16 -0
- package/src/routes/config/main.ts +73 -0
- package/src/routes/config/models.ts +139 -0
- package/src/routes/config/providers.ts +46 -0
- package/src/routes/config/utils.ts +120 -0
- package/src/routes/files.ts +218 -0
- package/src/routes/git/branch.ts +75 -0
- package/src/routes/git/commit.ts +209 -0
- package/src/routes/git/diff.ts +137 -0
- package/src/routes/git/index.ts +18 -0
- package/src/routes/git/push.ts +160 -0
- package/src/routes/git/schemas.ts +48 -0
- package/src/routes/git/staging.ts +208 -0
- package/src/routes/git/status.ts +83 -0
- package/src/routes/git/types.ts +31 -0
- package/src/routes/git/utils.ts +249 -0
- package/src/routes/openapi.ts +6 -0
- package/src/routes/research.ts +392 -0
- package/src/routes/root.ts +5 -0
- package/src/routes/session-approval.ts +63 -0
- package/src/routes/session-files.ts +387 -0
- package/src/routes/session-messages.ts +170 -0
- package/src/routes/session-stream.ts +61 -0
- package/src/routes/sessions.ts +814 -0
- package/src/routes/setu.ts +346 -0
- package/src/routes/terminals.ts +227 -0
- package/src/runtime/agent/registry.ts +351 -0
- package/src/runtime/agent/runner-reasoning.ts +108 -0
- package/src/runtime/agent/runner-setup.ts +257 -0
- package/src/runtime/agent/runner.ts +375 -0
- package/src/runtime/agent-registry.ts +6 -0
- package/src/runtime/ask/service.ts +369 -0
- package/src/runtime/context/environment.ts +202 -0
- package/src/runtime/debug/index.ts +117 -0
- package/src/runtime/debug/state.ts +140 -0
- package/src/runtime/errors/api-error.ts +192 -0
- package/src/runtime/errors/handling.ts +199 -0
- package/src/runtime/message/compaction-auto.ts +154 -0
- package/src/runtime/message/compaction-context.ts +101 -0
- package/src/runtime/message/compaction-detect.ts +26 -0
- package/src/runtime/message/compaction-limits.ts +37 -0
- package/src/runtime/message/compaction-mark.ts +111 -0
- package/src/runtime/message/compaction-prune.ts +75 -0
- package/src/runtime/message/compaction.ts +21 -0
- package/src/runtime/message/history-builder.ts +266 -0
- package/src/runtime/message/service.ts +468 -0
- package/src/runtime/message/tool-history-tracker.ts +204 -0
- package/src/runtime/prompt/builder.ts +167 -0
- package/src/runtime/provider/anthropic.ts +50 -0
- package/src/runtime/provider/copilot.ts +12 -0
- package/src/runtime/provider/google.ts +8 -0
- package/src/runtime/provider/index.ts +60 -0
- package/src/runtime/provider/moonshot.ts +8 -0
- package/src/runtime/provider/oauth-adapter.ts +237 -0
- package/src/runtime/provider/openai.ts +18 -0
- package/src/runtime/provider/opencode.ts +7 -0
- package/src/runtime/provider/openrouter.ts +7 -0
- package/src/runtime/provider/selection.ts +118 -0
- package/src/runtime/provider/setu.ts +126 -0
- package/src/runtime/provider/zai.ts +16 -0
- package/src/runtime/session/branch.ts +280 -0
- package/src/runtime/session/db-operations.ts +285 -0
- package/src/runtime/session/manager.ts +99 -0
- package/src/runtime/session/queue.ts +243 -0
- package/src/runtime/stream/abort-handler.ts +65 -0
- package/src/runtime/stream/error-handler.ts +371 -0
- package/src/runtime/stream/finish-handler.ts +101 -0
- package/src/runtime/stream/handlers.ts +5 -0
- package/src/runtime/stream/step-finish.ts +93 -0
- package/src/runtime/stream/types.ts +25 -0
- package/src/runtime/tools/approval.ts +180 -0
- package/src/runtime/tools/context.ts +83 -0
- package/src/runtime/tools/mapping.ts +154 -0
- package/src/runtime/tools/setup.ts +44 -0
- package/src/runtime/topup/manager.ts +110 -0
- package/src/runtime/utils/cwd.ts +69 -0
- package/src/runtime/utils/token.ts +35 -0
- package/src/tools/adapter.ts +634 -0
- package/src/tools/database/get-parent-session.ts +183 -0
- package/src/tools/database/get-session-context.ts +161 -0
- package/src/tools/database/index.ts +42 -0
- package/src/tools/database/present-session-links.ts +47 -0
- package/src/tools/database/query-messages.ts +160 -0
- package/src/tools/database/query-sessions.ts +126 -0
- package/src/tools/database/search-history.ts +135 -0
- package/src/types/sql-imports.d.ts +5 -0
- package/sst-env.d.ts +8 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import { loadConfig } from '@ottocode/sdk';
|
|
3
|
+
import { userInfo } from 'node:os';
|
|
4
|
+
import { getDb } from '@ottocode/database';
|
|
5
|
+
import {
|
|
6
|
+
sessions,
|
|
7
|
+
messages,
|
|
8
|
+
messageParts,
|
|
9
|
+
shares,
|
|
10
|
+
} from '@ottocode/database/schema';
|
|
11
|
+
import { desc, eq, and, ne, inArray, or } from 'drizzle-orm';
|
|
12
|
+
import type { ProviderId } from '@ottocode/sdk';
|
|
13
|
+
import { isProviderId, catalog } from '@ottocode/sdk';
|
|
14
|
+
import { resolveAgentConfig } from '../runtime/agent/registry.ts';
|
|
15
|
+
import { createSession as createSessionRow } from '../runtime/session/manager.ts';
|
|
16
|
+
import { serializeError } from '../runtime/errors/api-error.ts';
|
|
17
|
+
import { logger } from '@ottocode/sdk';
|
|
18
|
+
|
|
19
|
+
export function registerSessionsRoutes(app: Hono) {
|
|
20
|
+
// List sessions
|
|
21
|
+
app.get('/v1/sessions', async (c) => {
|
|
22
|
+
const projectRoot = c.req.query('project') || process.cwd();
|
|
23
|
+
const cfg = await loadConfig(projectRoot);
|
|
24
|
+
const db = await getDb(cfg.projectRoot);
|
|
25
|
+
// Only return sessions for this project, excluding research sessions
|
|
26
|
+
const rows = await db
|
|
27
|
+
.select()
|
|
28
|
+
.from(sessions)
|
|
29
|
+
.where(
|
|
30
|
+
and(
|
|
31
|
+
eq(sessions.projectPath, cfg.projectRoot),
|
|
32
|
+
ne(sessions.sessionType, 'research'),
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
.orderBy(desc(sessions.lastActiveAt), desc(sessions.createdAt));
|
|
36
|
+
const normalized = rows.map((r) => {
|
|
37
|
+
let counts: Record<string, unknown> | undefined;
|
|
38
|
+
if (r.toolCountsJson) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(r.toolCountsJson);
|
|
41
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
42
|
+
counts = parsed as Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
46
|
+
const { toolCountsJson: _toolCountsJson, ...rest } = r;
|
|
47
|
+
return counts ? { ...rest, toolCounts: counts } : rest;
|
|
48
|
+
});
|
|
49
|
+
return c.json(normalized);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Create session
|
|
53
|
+
app.post('/v1/sessions', async (c) => {
|
|
54
|
+
const projectRoot = c.req.query('project') || process.cwd();
|
|
55
|
+
const cfg = await loadConfig(projectRoot);
|
|
56
|
+
const db = await getDb(cfg.projectRoot);
|
|
57
|
+
const body = (await c.req.json().catch(() => ({}))) as Record<
|
|
58
|
+
string,
|
|
59
|
+
unknown
|
|
60
|
+
>;
|
|
61
|
+
const agent = (body.agent as string | undefined) ?? cfg.defaults.agent;
|
|
62
|
+
const agentCfg = await resolveAgentConfig(cfg.projectRoot, agent);
|
|
63
|
+
const providerCandidate =
|
|
64
|
+
typeof body.provider === 'string' ? body.provider : undefined;
|
|
65
|
+
const provider: ProviderId = (() => {
|
|
66
|
+
if (providerCandidate && isProviderId(providerCandidate))
|
|
67
|
+
return providerCandidate;
|
|
68
|
+
if (agentCfg.provider && isProviderId(agentCfg.provider))
|
|
69
|
+
return agentCfg.provider;
|
|
70
|
+
return cfg.defaults.provider;
|
|
71
|
+
})();
|
|
72
|
+
const modelCandidate =
|
|
73
|
+
typeof body.model === 'string' ? body.model.trim() : undefined;
|
|
74
|
+
const model = modelCandidate?.length
|
|
75
|
+
? modelCandidate
|
|
76
|
+
: (agentCfg.model ?? cfg.defaults.model);
|
|
77
|
+
try {
|
|
78
|
+
const row = await createSessionRow({
|
|
79
|
+
db,
|
|
80
|
+
cfg,
|
|
81
|
+
agent,
|
|
82
|
+
provider,
|
|
83
|
+
model,
|
|
84
|
+
title: (body.title as string | null | undefined) ?? null,
|
|
85
|
+
});
|
|
86
|
+
return c.json(row, 201);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
logger.error('Failed to create session', err);
|
|
89
|
+
const errorResponse = serializeError(err);
|
|
90
|
+
return c.json(errorResponse, errorResponse.error.status || 400);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Update session preferences
|
|
95
|
+
app.patch('/v1/sessions/:sessionId', async (c) => {
|
|
96
|
+
try {
|
|
97
|
+
const sessionId = c.req.param('sessionId');
|
|
98
|
+
const projectRoot = c.req.query('project') || process.cwd();
|
|
99
|
+
const cfg = await loadConfig(projectRoot);
|
|
100
|
+
const db = await getDb(cfg.projectRoot);
|
|
101
|
+
|
|
102
|
+
const body = (await c.req.json().catch(() => ({}))) as Record<
|
|
103
|
+
string,
|
|
104
|
+
unknown
|
|
105
|
+
>;
|
|
106
|
+
|
|
107
|
+
// Fetch existing session
|
|
108
|
+
const existingRows = await db
|
|
109
|
+
.select()
|
|
110
|
+
.from(sessions)
|
|
111
|
+
.where(eq(sessions.id, sessionId))
|
|
112
|
+
.limit(1);
|
|
113
|
+
|
|
114
|
+
if (!existingRows.length) {
|
|
115
|
+
return c.json({ error: 'Session not found' }, 404);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const existingSession = existingRows[0];
|
|
119
|
+
|
|
120
|
+
// Verify session belongs to current project
|
|
121
|
+
if (existingSession.projectPath !== cfg.projectRoot) {
|
|
122
|
+
return c.json({ error: 'Session not found in this project' }, 404);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Prepare update data
|
|
126
|
+
const updates: {
|
|
127
|
+
agent?: string;
|
|
128
|
+
provider?: string;
|
|
129
|
+
model?: string;
|
|
130
|
+
lastActiveAt?: number;
|
|
131
|
+
} = {
|
|
132
|
+
lastActiveAt: Date.now(),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Validate agent if provided
|
|
136
|
+
if (typeof body.agent === 'string') {
|
|
137
|
+
const agentName = body.agent.trim();
|
|
138
|
+
if (agentName) {
|
|
139
|
+
// Agent validation: check if it exists via resolveAgentConfig
|
|
140
|
+
try {
|
|
141
|
+
await resolveAgentConfig(cfg.projectRoot, agentName);
|
|
142
|
+
updates.agent = agentName;
|
|
143
|
+
} catch (err) {
|
|
144
|
+
logger.warn('Invalid agent provided', { agent: agentName, err });
|
|
145
|
+
return c.json({ error: `Invalid agent: ${agentName}` }, 400);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Validate provider if provided
|
|
151
|
+
if (typeof body.provider === 'string') {
|
|
152
|
+
const providerName = body.provider.trim();
|
|
153
|
+
if (providerName && isProviderId(providerName)) {
|
|
154
|
+
updates.provider = providerName;
|
|
155
|
+
} else if (providerName) {
|
|
156
|
+
return c.json({ error: `Invalid provider: ${providerName}` }, 400);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Validate model if provided (and optionally verify it belongs to provider)
|
|
161
|
+
if (typeof body.model === 'string') {
|
|
162
|
+
const modelName = body.model.trim();
|
|
163
|
+
if (modelName) {
|
|
164
|
+
const targetProvider = (updates.provider ||
|
|
165
|
+
existingSession.provider) as ProviderId;
|
|
166
|
+
|
|
167
|
+
// Check if model exists for the provider
|
|
168
|
+
const providerCatalog = catalog[targetProvider];
|
|
169
|
+
if (providerCatalog) {
|
|
170
|
+
const modelExists = providerCatalog.models.some(
|
|
171
|
+
(m) => m.id === modelName,
|
|
172
|
+
);
|
|
173
|
+
if (!modelExists) {
|
|
174
|
+
return c.json(
|
|
175
|
+
{
|
|
176
|
+
error: `Model "${modelName}" not found for provider "${targetProvider}"`,
|
|
177
|
+
},
|
|
178
|
+
400,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
updates.model = modelName;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Perform update
|
|
188
|
+
await db.update(sessions).set(updates).where(eq(sessions.id, sessionId));
|
|
189
|
+
|
|
190
|
+
// Return updated session
|
|
191
|
+
const updatedRows = await db
|
|
192
|
+
.select()
|
|
193
|
+
.from(sessions)
|
|
194
|
+
.where(eq(sessions.id, sessionId))
|
|
195
|
+
.limit(1);
|
|
196
|
+
|
|
197
|
+
return c.json(updatedRows[0]);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
logger.error('Failed to update session', err);
|
|
200
|
+
const errorResponse = serializeError(err);
|
|
201
|
+
return c.json(errorResponse, errorResponse.error.status || 500);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Delete session
|
|
206
|
+
app.delete('/v1/sessions/:sessionId', async (c) => {
|
|
207
|
+
try {
|
|
208
|
+
const sessionId = c.req.param('sessionId');
|
|
209
|
+
const projectRoot = c.req.query('project') || process.cwd();
|
|
210
|
+
const cfg = await loadConfig(projectRoot);
|
|
211
|
+
const db = await getDb(cfg.projectRoot);
|
|
212
|
+
|
|
213
|
+
const existingRows = await db
|
|
214
|
+
.select()
|
|
215
|
+
.from(sessions)
|
|
216
|
+
.where(eq(sessions.id, sessionId))
|
|
217
|
+
.limit(1);
|
|
218
|
+
|
|
219
|
+
if (!existingRows.length) {
|
|
220
|
+
return c.json({ error: 'Session not found' }, 404);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const existingSession = existingRows[0];
|
|
224
|
+
|
|
225
|
+
if (existingSession.projectPath !== cfg.projectRoot) {
|
|
226
|
+
return c.json({ error: 'Session not found in this project' }, 404);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
await db
|
|
230
|
+
.delete(messageParts)
|
|
231
|
+
.where(
|
|
232
|
+
inArray(
|
|
233
|
+
messageParts.messageId,
|
|
234
|
+
db
|
|
235
|
+
.select({ id: messages.id })
|
|
236
|
+
.from(messages)
|
|
237
|
+
.where(eq(messages.sessionId, sessionId)),
|
|
238
|
+
),
|
|
239
|
+
);
|
|
240
|
+
await db.delete(messages).where(eq(messages.sessionId, sessionId));
|
|
241
|
+
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
|
242
|
+
|
|
243
|
+
return c.json({ success: true });
|
|
244
|
+
} catch (err) {
|
|
245
|
+
logger.error('Failed to delete session', err);
|
|
246
|
+
const errorResponse = serializeError(err);
|
|
247
|
+
return c.json(errorResponse, errorResponse.error.status || 500);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Abort session stream
|
|
252
|
+
app.delete('/v1/sessions/:sessionId/abort', async (c) => {
|
|
253
|
+
const sessionId = c.req.param('sessionId');
|
|
254
|
+
const body = (await c.req.json().catch(() => ({}))) as Record<
|
|
255
|
+
string,
|
|
256
|
+
unknown
|
|
257
|
+
>;
|
|
258
|
+
const messageId =
|
|
259
|
+
typeof body.messageId === 'string' ? body.messageId : undefined;
|
|
260
|
+
const clearQueue = body.clearQueue === true;
|
|
261
|
+
|
|
262
|
+
const { abortSession, abortMessage } = await import(
|
|
263
|
+
'../runtime/agent/runner.ts'
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
if (messageId) {
|
|
267
|
+
const result = abortMessage(sessionId, messageId);
|
|
268
|
+
return c.json({
|
|
269
|
+
success: result.removed,
|
|
270
|
+
wasRunning: result.wasRunning,
|
|
271
|
+
messageId,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
abortSession(sessionId, clearQueue);
|
|
276
|
+
return c.json({ success: true });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Get queue state for a session
|
|
280
|
+
app.get('/v1/sessions/:sessionId/queue', async (c) => {
|
|
281
|
+
const sessionId = c.req.param('sessionId');
|
|
282
|
+
const { getQueueState } = await import('../runtime/session/queue.ts');
|
|
283
|
+
const state = getQueueState(sessionId);
|
|
284
|
+
return c.json(
|
|
285
|
+
state ?? {
|
|
286
|
+
currentMessageId: null,
|
|
287
|
+
queuedMessages: [],
|
|
288
|
+
isRunning: false,
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Remove a message from the queue
|
|
294
|
+
app.delete('/v1/sessions/:sessionId/queue/:messageId', async (c) => {
|
|
295
|
+
const sessionId = c.req.param('sessionId');
|
|
296
|
+
const messageId = c.req.param('messageId');
|
|
297
|
+
const projectRoot = c.req.query('project') || process.cwd();
|
|
298
|
+
const cfg = await loadConfig(projectRoot);
|
|
299
|
+
const db = await getDb(cfg.projectRoot);
|
|
300
|
+
const { removeFromQueue, abortMessage } = await import(
|
|
301
|
+
'../runtime/session/queue.ts'
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
// First try to remove from queue (queued messages)
|
|
305
|
+
const removed = removeFromQueue(sessionId, messageId);
|
|
306
|
+
if (removed) {
|
|
307
|
+
// Delete messages from database
|
|
308
|
+
try {
|
|
309
|
+
// Find the assistant message to get its creation time
|
|
310
|
+
const assistantMsg = await db
|
|
311
|
+
.select()
|
|
312
|
+
.from(messages)
|
|
313
|
+
.where(eq(messages.id, messageId))
|
|
314
|
+
.limit(1);
|
|
315
|
+
|
|
316
|
+
if (assistantMsg.length > 0) {
|
|
317
|
+
// Find the user message that came right before (same session, created just before)
|
|
318
|
+
const userMsg = await db
|
|
319
|
+
.select()
|
|
320
|
+
.from(messages)
|
|
321
|
+
.where(
|
|
322
|
+
and(eq(messages.sessionId, sessionId), eq(messages.role, 'user')),
|
|
323
|
+
)
|
|
324
|
+
.orderBy(desc(messages.createdAt))
|
|
325
|
+
.limit(1);
|
|
326
|
+
|
|
327
|
+
const messageIdsToDelete = [messageId];
|
|
328
|
+
if (userMsg.length > 0) {
|
|
329
|
+
messageIdsToDelete.push(userMsg[0].id);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Delete message parts first (foreign key constraint)
|
|
333
|
+
await db
|
|
334
|
+
.delete(messageParts)
|
|
335
|
+
.where(inArray(messageParts.messageId, messageIdsToDelete));
|
|
336
|
+
// Delete messages
|
|
337
|
+
await db
|
|
338
|
+
.delete(messages)
|
|
339
|
+
.where(inArray(messages.id, messageIdsToDelete));
|
|
340
|
+
}
|
|
341
|
+
} catch (err) {
|
|
342
|
+
logger.error('Failed to delete queued messages from DB', err);
|
|
343
|
+
}
|
|
344
|
+
return c.json({ success: true, removed: true, wasQueued: true });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// If not in queue, try to abort (might be running)
|
|
348
|
+
const result = abortMessage(sessionId, messageId);
|
|
349
|
+
if (result.removed) {
|
|
350
|
+
return c.json({
|
|
351
|
+
success: true,
|
|
352
|
+
removed: true,
|
|
353
|
+
wasQueued: false,
|
|
354
|
+
wasRunning: result.wasRunning,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// If not queued or running, try to delete directly from database
|
|
359
|
+
// This handles system messages (like injected research context)
|
|
360
|
+
try {
|
|
361
|
+
const existingMsg = await db
|
|
362
|
+
.select()
|
|
363
|
+
.from(messages)
|
|
364
|
+
.where(
|
|
365
|
+
and(eq(messages.id, messageId), eq(messages.sessionId, sessionId)),
|
|
366
|
+
)
|
|
367
|
+
.limit(1);
|
|
368
|
+
|
|
369
|
+
if (existingMsg.length > 0) {
|
|
370
|
+
// Delete message parts first (foreign key constraint)
|
|
371
|
+
await db
|
|
372
|
+
.delete(messageParts)
|
|
373
|
+
.where(eq(messageParts.messageId, messageId));
|
|
374
|
+
// Delete message
|
|
375
|
+
await db.delete(messages).where(eq(messages.id, messageId));
|
|
376
|
+
|
|
377
|
+
return c.json({ success: true, removed: true, wasStored: true });
|
|
378
|
+
}
|
|
379
|
+
} catch (err) {
|
|
380
|
+
logger.error('Failed to delete message from DB', err);
|
|
381
|
+
return c.json({ success: false, error: 'Failed to delete message' }, 500);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return c.json({ success: false, removed: false }, 404);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
app.get('/v1/sessions/:sessionId/share', async (c) => {
|
|
388
|
+
const sessionId = c.req.param('sessionId');
|
|
389
|
+
const projectRoot = c.req.query('project') || process.cwd();
|
|
390
|
+
const cfg = await loadConfig(projectRoot);
|
|
391
|
+
const db = await getDb(cfg.projectRoot);
|
|
392
|
+
|
|
393
|
+
const share = await db
|
|
394
|
+
.select()
|
|
395
|
+
.from(shares)
|
|
396
|
+
.where(eq(shares.sessionId, sessionId))
|
|
397
|
+
.limit(1);
|
|
398
|
+
|
|
399
|
+
if (!share.length) {
|
|
400
|
+
return c.json({ shared: false });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const allMessages = await db
|
|
404
|
+
.select({ id: messages.id })
|
|
405
|
+
.from(messages)
|
|
406
|
+
.where(eq(messages.sessionId, sessionId))
|
|
407
|
+
.orderBy(messages.createdAt);
|
|
408
|
+
|
|
409
|
+
const totalMessages = allMessages.length;
|
|
410
|
+
const syncedIdx = allMessages.findIndex(
|
|
411
|
+
(m) => m.id === share[0].lastSyncedMessageId,
|
|
412
|
+
);
|
|
413
|
+
const syncedMessages = syncedIdx === -1 ? 0 : syncedIdx + 1;
|
|
414
|
+
const pendingMessages = totalMessages - syncedMessages;
|
|
415
|
+
|
|
416
|
+
return c.json({
|
|
417
|
+
shared: true,
|
|
418
|
+
shareId: share[0].shareId,
|
|
419
|
+
url: share[0].url,
|
|
420
|
+
title: share[0].title,
|
|
421
|
+
createdAt: share[0].createdAt,
|
|
422
|
+
lastSyncedAt: share[0].lastSyncedAt,
|
|
423
|
+
lastSyncedMessageId: share[0].lastSyncedMessageId,
|
|
424
|
+
syncedMessages,
|
|
425
|
+
totalMessages,
|
|
426
|
+
pendingMessages,
|
|
427
|
+
isSynced: pendingMessages === 0,
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const SHARE_API_URL =
|
|
432
|
+
process.env.OTTO_SHARE_API_URL || 'https://api.share.ottocode.io';
|
|
433
|
+
|
|
434
|
+
function getUsername(): string {
|
|
435
|
+
try {
|
|
436
|
+
return userInfo().username;
|
|
437
|
+
} catch {
|
|
438
|
+
return 'anonymous';
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
app.post('/v1/sessions/:sessionId/share', async (c) => {
|
|
443
|
+
const sessionId = c.req.param('sessionId');
|
|
444
|
+
const projectRoot = c.req.query('project') || process.cwd();
|
|
445
|
+
const cfg = await loadConfig(projectRoot);
|
|
446
|
+
const db = await getDb(cfg.projectRoot);
|
|
447
|
+
|
|
448
|
+
const session = await db
|
|
449
|
+
.select()
|
|
450
|
+
.from(sessions)
|
|
451
|
+
.where(eq(sessions.id, sessionId))
|
|
452
|
+
.limit(1);
|
|
453
|
+
if (!session.length) {
|
|
454
|
+
return c.json({ error: 'Session not found' }, 404);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const existingShare = await db
|
|
458
|
+
.select()
|
|
459
|
+
.from(shares)
|
|
460
|
+
.where(eq(shares.sessionId, sessionId))
|
|
461
|
+
.limit(1);
|
|
462
|
+
if (existingShare.length) {
|
|
463
|
+
return c.json({
|
|
464
|
+
shared: true,
|
|
465
|
+
shareId: existingShare[0].shareId,
|
|
466
|
+
url: existingShare[0].url,
|
|
467
|
+
message: 'Already shared',
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const allMessages = await db
|
|
472
|
+
.select()
|
|
473
|
+
.from(messages)
|
|
474
|
+
.where(eq(messages.sessionId, sessionId))
|
|
475
|
+
.orderBy(messages.createdAt);
|
|
476
|
+
|
|
477
|
+
if (!allMessages.length) {
|
|
478
|
+
return c.json({ error: 'Session has no messages' }, 400);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const msgParts = await db
|
|
482
|
+
.select()
|
|
483
|
+
.from(messageParts)
|
|
484
|
+
.where(
|
|
485
|
+
inArray(
|
|
486
|
+
messageParts.messageId,
|
|
487
|
+
allMessages.map((m) => m.id),
|
|
488
|
+
),
|
|
489
|
+
)
|
|
490
|
+
.orderBy(messageParts.index);
|
|
491
|
+
|
|
492
|
+
const partsByMessage = new Map<string, typeof msgParts>();
|
|
493
|
+
for (const part of msgParts) {
|
|
494
|
+
const list = partsByMessage.get(part.messageId) || [];
|
|
495
|
+
list.push(part);
|
|
496
|
+
partsByMessage.set(part.messageId, list);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const lastMessageId = allMessages[allMessages.length - 1].id;
|
|
500
|
+
const sess = session[0];
|
|
501
|
+
|
|
502
|
+
const sessionData = {
|
|
503
|
+
title: sess.title,
|
|
504
|
+
username: getUsername(),
|
|
505
|
+
agent: sess.agent,
|
|
506
|
+
provider: sess.provider,
|
|
507
|
+
model: sess.model,
|
|
508
|
+
createdAt: sess.createdAt,
|
|
509
|
+
stats: {
|
|
510
|
+
inputTokens: sess.totalInputTokens ?? 0,
|
|
511
|
+
outputTokens: sess.totalOutputTokens ?? 0,
|
|
512
|
+
cachedTokens: sess.totalCachedTokens ?? 0,
|
|
513
|
+
cacheCreationTokens: sess.totalCacheCreationTokens ?? 0,
|
|
514
|
+
reasoningTokens: sess.totalReasoningTokens ?? 0,
|
|
515
|
+
toolTimeMs: sess.totalToolTimeMs ?? 0,
|
|
516
|
+
toolCounts: sess.toolCountsJson ? JSON.parse(sess.toolCountsJson) : {},
|
|
517
|
+
},
|
|
518
|
+
messages: allMessages.map((m) => ({
|
|
519
|
+
id: m.id,
|
|
520
|
+
role: m.role,
|
|
521
|
+
createdAt: m.createdAt,
|
|
522
|
+
parts: (partsByMessage.get(m.id) || []).map((p) => ({
|
|
523
|
+
type: p.type,
|
|
524
|
+
content: p.content,
|
|
525
|
+
toolName: p.toolName,
|
|
526
|
+
toolCallId: p.toolCallId,
|
|
527
|
+
})),
|
|
528
|
+
})),
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
const res = await fetch(`${SHARE_API_URL}/share`, {
|
|
532
|
+
method: 'POST',
|
|
533
|
+
headers: { 'Content-Type': 'application/json' },
|
|
534
|
+
body: JSON.stringify({
|
|
535
|
+
sessionData,
|
|
536
|
+
title: sess.title,
|
|
537
|
+
lastMessageId,
|
|
538
|
+
}),
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
if (!res.ok) {
|
|
542
|
+
const err = await res.text();
|
|
543
|
+
return c.json({ error: `Failed to create share: ${err}` }, 500);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const data = (await res.json()) as {
|
|
547
|
+
shareId: string;
|
|
548
|
+
secret: string;
|
|
549
|
+
url: string;
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
await db.insert(shares).values({
|
|
553
|
+
sessionId,
|
|
554
|
+
shareId: data.shareId,
|
|
555
|
+
secret: data.secret,
|
|
556
|
+
url: data.url,
|
|
557
|
+
title: sess.title,
|
|
558
|
+
description: null,
|
|
559
|
+
createdAt: Date.now(),
|
|
560
|
+
lastSyncedAt: Date.now(),
|
|
561
|
+
lastSyncedMessageId: lastMessageId,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
return c.json({
|
|
565
|
+
shared: true,
|
|
566
|
+
shareId: data.shareId,
|
|
567
|
+
url: data.url,
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
app.put('/v1/sessions/:sessionId/share', async (c) => {
|
|
572
|
+
const sessionId = c.req.param('sessionId');
|
|
573
|
+
const projectRoot = c.req.query('project') || process.cwd();
|
|
574
|
+
const cfg = await loadConfig(projectRoot);
|
|
575
|
+
const db = await getDb(cfg.projectRoot);
|
|
576
|
+
|
|
577
|
+
const share = await db
|
|
578
|
+
.select()
|
|
579
|
+
.from(shares)
|
|
580
|
+
.where(eq(shares.sessionId, sessionId))
|
|
581
|
+
.limit(1);
|
|
582
|
+
if (!share.length) {
|
|
583
|
+
return c.json({ error: 'Session not shared. Use share first.' }, 400);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const session = await db
|
|
587
|
+
.select()
|
|
588
|
+
.from(sessions)
|
|
589
|
+
.where(eq(sessions.id, sessionId))
|
|
590
|
+
.limit(1);
|
|
591
|
+
if (!session.length) {
|
|
592
|
+
return c.json({ error: 'Session not found' }, 404);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const allMessages = await db
|
|
596
|
+
.select()
|
|
597
|
+
.from(messages)
|
|
598
|
+
.where(eq(messages.sessionId, sessionId))
|
|
599
|
+
.orderBy(messages.createdAt);
|
|
600
|
+
|
|
601
|
+
const msgParts = await db
|
|
602
|
+
.select()
|
|
603
|
+
.from(messageParts)
|
|
604
|
+
.where(
|
|
605
|
+
inArray(
|
|
606
|
+
messageParts.messageId,
|
|
607
|
+
allMessages.map((m) => m.id),
|
|
608
|
+
),
|
|
609
|
+
)
|
|
610
|
+
.orderBy(messageParts.index);
|
|
611
|
+
|
|
612
|
+
const partsByMessage = new Map<string, typeof msgParts>();
|
|
613
|
+
for (const part of msgParts) {
|
|
614
|
+
const list = partsByMessage.get(part.messageId) || [];
|
|
615
|
+
list.push(part);
|
|
616
|
+
partsByMessage.set(part.messageId, list);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const lastSyncedIdx = allMessages.findIndex(
|
|
620
|
+
(m) => m.id === share[0].lastSyncedMessageId,
|
|
621
|
+
);
|
|
622
|
+
const newMessages =
|
|
623
|
+
lastSyncedIdx === -1 ? allMessages : allMessages.slice(lastSyncedIdx + 1);
|
|
624
|
+
const lastMessageId =
|
|
625
|
+
allMessages[allMessages.length - 1]?.id ?? share[0].lastSyncedMessageId;
|
|
626
|
+
|
|
627
|
+
if (newMessages.length === 0) {
|
|
628
|
+
return c.json({
|
|
629
|
+
synced: true,
|
|
630
|
+
url: share[0].url,
|
|
631
|
+
newMessages: 0,
|
|
632
|
+
message: 'Already synced',
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const sess = session[0];
|
|
637
|
+
const sessionData = {
|
|
638
|
+
title: sess.title,
|
|
639
|
+
username: getUsername(),
|
|
640
|
+
agent: sess.agent,
|
|
641
|
+
provider: sess.provider,
|
|
642
|
+
model: sess.model,
|
|
643
|
+
createdAt: sess.createdAt,
|
|
644
|
+
stats: {
|
|
645
|
+
inputTokens: sess.totalInputTokens ?? 0,
|
|
646
|
+
outputTokens: sess.totalOutputTokens ?? 0,
|
|
647
|
+
cachedTokens: sess.totalCachedTokens ?? 0,
|
|
648
|
+
cacheCreationTokens: sess.totalCacheCreationTokens ?? 0,
|
|
649
|
+
reasoningTokens: sess.totalReasoningTokens ?? 0,
|
|
650
|
+
toolTimeMs: sess.totalToolTimeMs ?? 0,
|
|
651
|
+
toolCounts: sess.toolCountsJson ? JSON.parse(sess.toolCountsJson) : {},
|
|
652
|
+
},
|
|
653
|
+
messages: allMessages.map((m) => ({
|
|
654
|
+
id: m.id,
|
|
655
|
+
role: m.role,
|
|
656
|
+
createdAt: m.createdAt,
|
|
657
|
+
parts: (partsByMessage.get(m.id) || []).map((p) => ({
|
|
658
|
+
type: p.type,
|
|
659
|
+
content: p.content,
|
|
660
|
+
toolName: p.toolName,
|
|
661
|
+
toolCallId: p.toolCallId,
|
|
662
|
+
})),
|
|
663
|
+
})),
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const res = await fetch(`${SHARE_API_URL}/share/${share[0].shareId}`, {
|
|
667
|
+
method: 'PUT',
|
|
668
|
+
headers: {
|
|
669
|
+
'Content-Type': 'application/json',
|
|
670
|
+
'X-Share-Secret': share[0].secret,
|
|
671
|
+
},
|
|
672
|
+
body: JSON.stringify({
|
|
673
|
+
sessionData,
|
|
674
|
+
title: sess.title,
|
|
675
|
+
lastMessageId,
|
|
676
|
+
}),
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
if (!res.ok) {
|
|
680
|
+
const err = await res.text();
|
|
681
|
+
return c.json({ error: `Failed to sync share: ${err}` }, 500);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
await db
|
|
685
|
+
.update(shares)
|
|
686
|
+
.set({
|
|
687
|
+
title: sess.title,
|
|
688
|
+
lastSyncedAt: Date.now(),
|
|
689
|
+
lastSyncedMessageId: lastMessageId,
|
|
690
|
+
})
|
|
691
|
+
.where(eq(shares.sessionId, sessionId));
|
|
692
|
+
|
|
693
|
+
return c.json({
|
|
694
|
+
synced: true,
|
|
695
|
+
url: share[0].url,
|
|
696
|
+
newMessages: newMessages.length,
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
// Retry a failed assistant message
|
|
701
|
+
app.post('/v1/sessions/:sessionId/messages/:messageId/retry', async (c) => {
|
|
702
|
+
try {
|
|
703
|
+
const sessionId = c.req.param('sessionId');
|
|
704
|
+
const messageId = c.req.param('messageId');
|
|
705
|
+
const projectRoot = c.req.query('project') || process.cwd();
|
|
706
|
+
const cfg = await loadConfig(projectRoot);
|
|
707
|
+
const db = await getDb(cfg.projectRoot);
|
|
708
|
+
|
|
709
|
+
// Get the assistant message
|
|
710
|
+
const [assistantMsg] = await db
|
|
711
|
+
.select()
|
|
712
|
+
.from(messages)
|
|
713
|
+
.where(
|
|
714
|
+
and(
|
|
715
|
+
eq(messages.id, messageId),
|
|
716
|
+
eq(messages.sessionId, sessionId),
|
|
717
|
+
eq(messages.role, 'assistant'),
|
|
718
|
+
),
|
|
719
|
+
)
|
|
720
|
+
.limit(1);
|
|
721
|
+
|
|
722
|
+
if (!assistantMsg) {
|
|
723
|
+
return c.json({ error: 'Message not found' }, 404);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Only allow retry on error or complete messages
|
|
727
|
+
if (
|
|
728
|
+
assistantMsg.status !== 'error' &&
|
|
729
|
+
assistantMsg.status !== 'complete'
|
|
730
|
+
) {
|
|
731
|
+
return c.json(
|
|
732
|
+
{ error: 'Can only retry error or complete messages' },
|
|
733
|
+
400,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Get session for context
|
|
738
|
+
const [session] = await db
|
|
739
|
+
.select()
|
|
740
|
+
.from(sessions)
|
|
741
|
+
.where(eq(sessions.id, sessionId))
|
|
742
|
+
.limit(1);
|
|
743
|
+
|
|
744
|
+
if (!session) {
|
|
745
|
+
return c.json({ error: 'Session not found' }, 404);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Delete only error parts - preserve valid text/tool content
|
|
749
|
+
await db
|
|
750
|
+
.delete(messageParts)
|
|
751
|
+
.where(
|
|
752
|
+
and(
|
|
753
|
+
eq(messageParts.messageId, messageId),
|
|
754
|
+
or(
|
|
755
|
+
eq(messageParts.type, 'error'),
|
|
756
|
+
and(
|
|
757
|
+
eq(messageParts.type, 'tool_call'),
|
|
758
|
+
eq(messageParts.toolName, 'finish'),
|
|
759
|
+
),
|
|
760
|
+
),
|
|
761
|
+
),
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
// Reset message status to pending
|
|
765
|
+
await db
|
|
766
|
+
.update(messages)
|
|
767
|
+
.set({
|
|
768
|
+
status: 'pending',
|
|
769
|
+
error: null,
|
|
770
|
+
errorType: null,
|
|
771
|
+
errorDetails: null,
|
|
772
|
+
completedAt: null,
|
|
773
|
+
})
|
|
774
|
+
.where(eq(messages.id, messageId));
|
|
775
|
+
|
|
776
|
+
// Emit event so UI updates
|
|
777
|
+
const { publish } = await import('../events/bus.ts');
|
|
778
|
+
publish({
|
|
779
|
+
type: 'message.updated',
|
|
780
|
+
sessionId,
|
|
781
|
+
payload: { id: messageId, status: 'pending' },
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// Re-enqueue the assistant run
|
|
785
|
+
const { enqueueAssistantRun } = await import(
|
|
786
|
+
'../runtime/session/queue.ts'
|
|
787
|
+
);
|
|
788
|
+
const { runSessionLoop } = await import('../runtime/agent/runner.ts');
|
|
789
|
+
|
|
790
|
+
const toolApprovalMode = cfg.defaults.toolApproval ?? 'auto';
|
|
791
|
+
|
|
792
|
+
enqueueAssistantRun(
|
|
793
|
+
{
|
|
794
|
+
sessionId,
|
|
795
|
+
assistantMessageId: messageId,
|
|
796
|
+
agent: assistantMsg.agent ?? 'build',
|
|
797
|
+
provider: (assistantMsg.provider ??
|
|
798
|
+
cfg.defaults.provider) as ProviderId,
|
|
799
|
+
model: assistantMsg.model ?? cfg.defaults.model,
|
|
800
|
+
projectRoot: cfg.projectRoot,
|
|
801
|
+
oneShot: false,
|
|
802
|
+
toolApprovalMode,
|
|
803
|
+
},
|
|
804
|
+
runSessionLoop,
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
return c.json({ success: true, messageId });
|
|
808
|
+
} catch (err) {
|
|
809
|
+
logger.error('Failed to retry message', err);
|
|
810
|
+
const errorResponse = serializeError(err);
|
|
811
|
+
return c.json(errorResponse, errorResponse.error.status || 500);
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
}
|