@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,387 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import { loadConfig } from '@ottocode/sdk';
|
|
3
|
+
import { getDb } from '@ottocode/database';
|
|
4
|
+
import { messages, messageParts, sessions } from '@ottocode/database/schema';
|
|
5
|
+
import { eq, and, inArray } from 'drizzle-orm';
|
|
6
|
+
import { serializeError } from '../runtime/errors/api-error.ts';
|
|
7
|
+
import { logger } from '@ottocode/sdk';
|
|
8
|
+
|
|
9
|
+
const FILE_EDIT_TOOLS = [
|
|
10
|
+
'Write',
|
|
11
|
+
'ApplyPatch',
|
|
12
|
+
'Edit',
|
|
13
|
+
'write',
|
|
14
|
+
'apply_patch',
|
|
15
|
+
'edit',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
interface FileOperation {
|
|
19
|
+
path: string;
|
|
20
|
+
operation: 'write' | 'patch' | 'edit' | 'create';
|
|
21
|
+
timestamp: number;
|
|
22
|
+
toolCallId: string;
|
|
23
|
+
toolName: string;
|
|
24
|
+
patch?: string;
|
|
25
|
+
content?: string;
|
|
26
|
+
artifact?: {
|
|
27
|
+
kind: string;
|
|
28
|
+
patch?: string;
|
|
29
|
+
summary?: { additions: number; deletions: number };
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface SessionFile {
|
|
34
|
+
path: string;
|
|
35
|
+
operations: FileOperation[];
|
|
36
|
+
operationCount: number;
|
|
37
|
+
firstModified: number;
|
|
38
|
+
lastModified: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ToolResultData {
|
|
42
|
+
path?: string;
|
|
43
|
+
args?: Record<string, unknown>;
|
|
44
|
+
files?: Array<string | { path: string }>;
|
|
45
|
+
result?: {
|
|
46
|
+
ok?: boolean;
|
|
47
|
+
artifact?: {
|
|
48
|
+
kind?: string;
|
|
49
|
+
patch?: string;
|
|
50
|
+
summary?: { additions?: number; deletions?: number };
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
artifact?: {
|
|
54
|
+
kind?: string;
|
|
55
|
+
patch?: string;
|
|
56
|
+
summary?: { additions?: number; deletions?: number };
|
|
57
|
+
};
|
|
58
|
+
patch?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function extractFilePathFromToolCall(
|
|
62
|
+
toolName: string,
|
|
63
|
+
content: unknown,
|
|
64
|
+
): string | null {
|
|
65
|
+
if (!content || typeof content !== 'object') return null;
|
|
66
|
+
|
|
67
|
+
const c = content as Record<string, unknown>;
|
|
68
|
+
const args = c.args as Record<string, unknown> | undefined;
|
|
69
|
+
|
|
70
|
+
const name = toolName.toLowerCase();
|
|
71
|
+
|
|
72
|
+
if (name === 'write' || name === 'edit') {
|
|
73
|
+
if (args && typeof args.path === 'string') return args.path;
|
|
74
|
+
if (typeof c.path === 'string') return c.path;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (name === 'applypatch' || name === 'apply_patch') {
|
|
78
|
+
const patch = args?.patch ?? c.patch;
|
|
79
|
+
if (typeof patch === 'string') {
|
|
80
|
+
const matches = [
|
|
81
|
+
...patch.matchAll(/\*\*\* (?:Update|Add|Delete) File: (.+)/g),
|
|
82
|
+
];
|
|
83
|
+
if (matches.length > 0) return matches[0][1].trim();
|
|
84
|
+
const unifiedMatch = patch.match(/^(?:---|\+\+\+) [ab]\/(.+)$/m);
|
|
85
|
+
if (unifiedMatch) return unifiedMatch[1].trim();
|
|
86
|
+
}
|
|
87
|
+
if (args && typeof args.path === 'string') return args.path;
|
|
88
|
+
if (typeof c.path === 'string') return c.path;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function extractPatchFromToolCall(
|
|
95
|
+
toolName: string,
|
|
96
|
+
content: unknown,
|
|
97
|
+
): string | undefined {
|
|
98
|
+
if (!content || typeof content !== 'object') return undefined;
|
|
99
|
+
|
|
100
|
+
const c = content as Record<string, unknown>;
|
|
101
|
+
const args = c.args as Record<string, unknown> | undefined;
|
|
102
|
+
const name = toolName.toLowerCase();
|
|
103
|
+
|
|
104
|
+
if (name === 'applypatch' || name === 'apply_patch') {
|
|
105
|
+
const patch = args?.patch ?? c.patch;
|
|
106
|
+
if (typeof patch === 'string') return patch;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function extractContentFromToolCall(
|
|
113
|
+
toolName: string,
|
|
114
|
+
content: unknown,
|
|
115
|
+
): string | undefined {
|
|
116
|
+
if (!content || typeof content !== 'object') return undefined;
|
|
117
|
+
|
|
118
|
+
const c = content as Record<string, unknown>;
|
|
119
|
+
const args = c.args as Record<string, unknown> | undefined;
|
|
120
|
+
const name = toolName.toLowerCase();
|
|
121
|
+
|
|
122
|
+
if (name === 'write') {
|
|
123
|
+
const writeContent = args?.content ?? c.content;
|
|
124
|
+
if (typeof writeContent === 'string') return writeContent;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function extractFilesFromToolResult(
|
|
131
|
+
toolName: string,
|
|
132
|
+
content: unknown,
|
|
133
|
+
): string[] {
|
|
134
|
+
if (!content || typeof content !== 'object') return [];
|
|
135
|
+
|
|
136
|
+
const c = content as ToolResultData;
|
|
137
|
+
const files: string[] = [];
|
|
138
|
+
|
|
139
|
+
if (typeof c.path === 'string') {
|
|
140
|
+
files.push(c.path);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const args = c.args;
|
|
144
|
+
if (args && typeof args.path === 'string' && !files.includes(args.path)) {
|
|
145
|
+
files.push(args.path);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (Array.isArray(c.files)) {
|
|
149
|
+
for (const f of c.files) {
|
|
150
|
+
if (typeof f === 'string' && !files.includes(f)) files.push(f);
|
|
151
|
+
if (f && typeof f === 'object' && typeof f.path === 'string') {
|
|
152
|
+
if (!files.includes(f.path)) files.push(f.path);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const name = toolName.toLowerCase();
|
|
158
|
+
if (name === 'applypatch' || name === 'apply_patch') {
|
|
159
|
+
const patch =
|
|
160
|
+
c.patch ??
|
|
161
|
+
(args?.patch as string | undefined) ??
|
|
162
|
+
c.result?.artifact?.patch;
|
|
163
|
+
if (typeof patch === 'string') {
|
|
164
|
+
const matches = patch.matchAll(
|
|
165
|
+
/\*\*\* (?:Update|Add|Delete) File: (.+)/g,
|
|
166
|
+
);
|
|
167
|
+
for (const match of matches) {
|
|
168
|
+
const fp = match[1].trim();
|
|
169
|
+
if (!files.includes(fp)) files.push(fp);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return files;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function extractDataFromToolResult(
|
|
178
|
+
toolName: string,
|
|
179
|
+
content: unknown,
|
|
180
|
+
): {
|
|
181
|
+
patch?: string;
|
|
182
|
+
writeContent?: string;
|
|
183
|
+
artifact?: FileOperation['artifact'];
|
|
184
|
+
} {
|
|
185
|
+
if (!content || typeof content !== 'object') return {};
|
|
186
|
+
|
|
187
|
+
const c = content as ToolResultData;
|
|
188
|
+
const args = c.args as Record<string, unknown> | undefined;
|
|
189
|
+
const name = toolName.toLowerCase();
|
|
190
|
+
|
|
191
|
+
let patch: string | undefined;
|
|
192
|
+
let writeContent: string | undefined;
|
|
193
|
+
let artifact: FileOperation['artifact'] | undefined;
|
|
194
|
+
|
|
195
|
+
if (name === 'applypatch' || name === 'apply_patch') {
|
|
196
|
+
patch = (args?.patch as string | undefined) ?? c.patch;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (name === 'write') {
|
|
200
|
+
writeContent = args?.content as string | undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const rawArtifact = c.result?.artifact ?? c.artifact;
|
|
204
|
+
if (rawArtifact && typeof rawArtifact === 'object') {
|
|
205
|
+
artifact = {
|
|
206
|
+
kind: rawArtifact.kind || 'unknown',
|
|
207
|
+
patch: rawArtifact.patch,
|
|
208
|
+
summary: rawArtifact.summary
|
|
209
|
+
? {
|
|
210
|
+
additions: rawArtifact.summary.additions || 0,
|
|
211
|
+
deletions: rawArtifact.summary.deletions || 0,
|
|
212
|
+
}
|
|
213
|
+
: undefined,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { patch, writeContent, artifact };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function getOperationType(
|
|
221
|
+
toolName: string,
|
|
222
|
+
): 'write' | 'patch' | 'edit' | 'create' {
|
|
223
|
+
const name = toolName.toLowerCase();
|
|
224
|
+
if (name === 'write') return 'write';
|
|
225
|
+
if (name === 'applypatch' || name === 'apply_patch') return 'patch';
|
|
226
|
+
if (name === 'edit') return 'edit';
|
|
227
|
+
return 'write';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function registerSessionFilesRoutes(app: Hono) {
|
|
231
|
+
app.get('/v1/sessions/:sessionId/files', async (c) => {
|
|
232
|
+
try {
|
|
233
|
+
const sessionId = c.req.param('sessionId');
|
|
234
|
+
const projectRoot = c.req.query('project') || process.cwd();
|
|
235
|
+
const cfg = await loadConfig(projectRoot);
|
|
236
|
+
const db = await getDb(cfg.projectRoot);
|
|
237
|
+
|
|
238
|
+
const sessionRows = await db
|
|
239
|
+
.select()
|
|
240
|
+
.from(sessions)
|
|
241
|
+
.where(eq(sessions.id, sessionId))
|
|
242
|
+
.limit(1);
|
|
243
|
+
|
|
244
|
+
if (!sessionRows.length) {
|
|
245
|
+
return c.json({ error: 'Session not found' }, 404);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const messageRows = await db
|
|
249
|
+
.select({ id: messages.id })
|
|
250
|
+
.from(messages)
|
|
251
|
+
.where(eq(messages.sessionId, sessionId));
|
|
252
|
+
|
|
253
|
+
const messageIds = messageRows.map((m) => m.id);
|
|
254
|
+
|
|
255
|
+
if (!messageIds.length) {
|
|
256
|
+
return c.json({
|
|
257
|
+
files: [],
|
|
258
|
+
totalFiles: 0,
|
|
259
|
+
totalOperations: 0,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const parts = await db
|
|
264
|
+
.select()
|
|
265
|
+
.from(messageParts)
|
|
266
|
+
.where(
|
|
267
|
+
and(
|
|
268
|
+
inArray(messageParts.messageId, messageIds),
|
|
269
|
+
inArray(messageParts.toolName, FILE_EDIT_TOOLS),
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const fileOperationsMap = new Map<string, FileOperation[]>();
|
|
274
|
+
const toolCallDataMap = new Map<
|
|
275
|
+
string,
|
|
276
|
+
{ patch?: string; content?: string }
|
|
277
|
+
>();
|
|
278
|
+
|
|
279
|
+
for (const part of parts) {
|
|
280
|
+
if (!part.toolName) continue;
|
|
281
|
+
|
|
282
|
+
let content: unknown;
|
|
283
|
+
try {
|
|
284
|
+
content = JSON.parse(part.content);
|
|
285
|
+
} catch {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (part.type === 'tool_call') {
|
|
290
|
+
const callId = part.toolCallId || part.id;
|
|
291
|
+
const patch = extractPatchFromToolCall(part.toolName, content);
|
|
292
|
+
const writeContent = extractContentFromToolCall(
|
|
293
|
+
part.toolName,
|
|
294
|
+
content,
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
toolCallDataMap.set(callId, { patch, content: writeContent });
|
|
298
|
+
|
|
299
|
+
const path = extractFilePathFromToolCall(part.toolName, content);
|
|
300
|
+
if (path) {
|
|
301
|
+
const operation: FileOperation = {
|
|
302
|
+
path,
|
|
303
|
+
operation: getOperationType(part.toolName),
|
|
304
|
+
timestamp: part.startedAt || Date.now(),
|
|
305
|
+
toolCallId: callId,
|
|
306
|
+
toolName: part.toolName,
|
|
307
|
+
patch,
|
|
308
|
+
content: writeContent,
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const existing = fileOperationsMap.get(path) || [];
|
|
312
|
+
const isDuplicate = existing.some(
|
|
313
|
+
(op) => op.toolCallId === operation.toolCallId,
|
|
314
|
+
);
|
|
315
|
+
if (!isDuplicate) {
|
|
316
|
+
existing.push(operation);
|
|
317
|
+
fileOperationsMap.set(path, existing);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} else if (part.type === 'tool_result') {
|
|
321
|
+
const filePaths = extractFilesFromToolResult(part.toolName, content);
|
|
322
|
+
const { patch, writeContent, artifact } = extractDataFromToolResult(
|
|
323
|
+
part.toolName,
|
|
324
|
+
content,
|
|
325
|
+
);
|
|
326
|
+
const callId = part.toolCallId || part.id;
|
|
327
|
+
const callData = toolCallDataMap.get(callId);
|
|
328
|
+
|
|
329
|
+
for (const filePath of filePaths) {
|
|
330
|
+
if (!filePath) continue;
|
|
331
|
+
|
|
332
|
+
const existing = fileOperationsMap.get(filePath) || [];
|
|
333
|
+
const existingOp = existing.find((op) => op.toolCallId === callId);
|
|
334
|
+
|
|
335
|
+
if (existingOp) {
|
|
336
|
+
existingOp.artifact = artifact;
|
|
337
|
+
existingOp.timestamp = part.completedAt || existingOp.timestamp;
|
|
338
|
+
if (!existingOp.patch && patch) {
|
|
339
|
+
existingOp.patch = patch;
|
|
340
|
+
}
|
|
341
|
+
if (!existingOp.content && writeContent) {
|
|
342
|
+
existingOp.content = writeContent;
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
const operation: FileOperation = {
|
|
346
|
+
path: filePath,
|
|
347
|
+
operation: getOperationType(part.toolName),
|
|
348
|
+
timestamp: part.completedAt || part.startedAt || Date.now(),
|
|
349
|
+
toolCallId: callId,
|
|
350
|
+
toolName: part.toolName,
|
|
351
|
+
patch: callData?.patch ?? patch,
|
|
352
|
+
content: callData?.content ?? writeContent,
|
|
353
|
+
artifact,
|
|
354
|
+
};
|
|
355
|
+
existing.push(operation);
|
|
356
|
+
fileOperationsMap.set(filePath, existing);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const files: SessionFile[] = [];
|
|
363
|
+
for (const [path, operations] of fileOperationsMap) {
|
|
364
|
+
operations.sort((a, b) => a.timestamp - b.timestamp);
|
|
365
|
+
files.push({
|
|
366
|
+
path,
|
|
367
|
+
operations,
|
|
368
|
+
operationCount: operations.length,
|
|
369
|
+
firstModified: operations[0]?.timestamp || 0,
|
|
370
|
+
lastModified: operations[operations.length - 1]?.timestamp || 0,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
files.sort((a, b) => b.lastModified - a.lastModified);
|
|
375
|
+
|
|
376
|
+
return c.json({
|
|
377
|
+
files,
|
|
378
|
+
totalFiles: files.length,
|
|
379
|
+
totalOperations: parts.length,
|
|
380
|
+
});
|
|
381
|
+
} catch (error) {
|
|
382
|
+
logger.error('Failed to get session files', error);
|
|
383
|
+
const errorResponse = serializeError(error);
|
|
384
|
+
return c.json(errorResponse, errorResponse.error.status || 500);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import { loadConfig } from '@ottocode/sdk';
|
|
3
|
+
import { getDb } from '@ottocode/database';
|
|
4
|
+
import { messages, messageParts, sessions } from '@ottocode/database/schema';
|
|
5
|
+
import { eq, inArray } from 'drizzle-orm';
|
|
6
|
+
import {
|
|
7
|
+
validateProviderModel,
|
|
8
|
+
isProviderAuthorized,
|
|
9
|
+
ensureProviderEnv,
|
|
10
|
+
} from '@ottocode/sdk';
|
|
11
|
+
import { dispatchAssistantMessage } from '../runtime/message/service.ts';
|
|
12
|
+
import { logger } from '@ottocode/sdk';
|
|
13
|
+
import { serializeError } from '../runtime/errors/api-error.ts';
|
|
14
|
+
|
|
15
|
+
type MessagePartRow = typeof messageParts.$inferSelect;
|
|
16
|
+
type SessionRow = typeof sessions.$inferSelect;
|
|
17
|
+
|
|
18
|
+
export function registerSessionMessagesRoutes(app: Hono) {
|
|
19
|
+
// List messages for a session
|
|
20
|
+
app.get('/v1/sessions/:id/messages', async (c) => {
|
|
21
|
+
try {
|
|
22
|
+
const projectRoot = c.req.query('project') || process.cwd();
|
|
23
|
+
const cfg = await loadConfig(projectRoot);
|
|
24
|
+
const db = await getDb(cfg.projectRoot);
|
|
25
|
+
const id = c.req.param('id');
|
|
26
|
+
const rows = await db
|
|
27
|
+
.select()
|
|
28
|
+
.from(messages)
|
|
29
|
+
.where(eq(messages.sessionId, id))
|
|
30
|
+
.orderBy(messages.createdAt);
|
|
31
|
+
const without = c.req.query('without');
|
|
32
|
+
if (without !== 'parts') {
|
|
33
|
+
const ids = rows.map((m) => m.id);
|
|
34
|
+
const parts = ids.length
|
|
35
|
+
? await db
|
|
36
|
+
.select()
|
|
37
|
+
.from(messageParts)
|
|
38
|
+
.where(inArray(messageParts.messageId, ids))
|
|
39
|
+
: [];
|
|
40
|
+
const partsByMsg = new Map<string, MessagePartRow[]>();
|
|
41
|
+
for (const p of parts) {
|
|
42
|
+
const existing = partsByMsg.get(p.messageId);
|
|
43
|
+
if (existing) existing.push(p);
|
|
44
|
+
else partsByMsg.set(p.messageId, [p]);
|
|
45
|
+
}
|
|
46
|
+
const wantParsed = (() => {
|
|
47
|
+
const q = (c.req.query('parsed') || '').toLowerCase();
|
|
48
|
+
return q === '1' || q === 'true' || q === 'yes';
|
|
49
|
+
})();
|
|
50
|
+
function parseContent(raw: string): Record<string, unknown> | string {
|
|
51
|
+
try {
|
|
52
|
+
const v = JSON.parse(String(raw ?? ''));
|
|
53
|
+
if (v && typeof v === 'object' && !Array.isArray(v))
|
|
54
|
+
return v as Record<string, unknown>;
|
|
55
|
+
} catch {}
|
|
56
|
+
return raw;
|
|
57
|
+
}
|
|
58
|
+
const enriched = rows.map((m) => {
|
|
59
|
+
const parts = (partsByMsg.get(m.id) ?? []).sort(
|
|
60
|
+
(a, b) => a.index - b.index,
|
|
61
|
+
);
|
|
62
|
+
const mapped = parts.map((p) => {
|
|
63
|
+
const parsed = parseContent(p.content);
|
|
64
|
+
return wantParsed
|
|
65
|
+
? { ...p, content: parsed }
|
|
66
|
+
: { ...p, contentJson: parsed };
|
|
67
|
+
});
|
|
68
|
+
return { ...m, parts: mapped };
|
|
69
|
+
});
|
|
70
|
+
return c.json(enriched);
|
|
71
|
+
}
|
|
72
|
+
return c.json(rows);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
logger.error('Failed to list session messages', error);
|
|
75
|
+
const errorResponse = serializeError(error);
|
|
76
|
+
return c.json(errorResponse, errorResponse.error.status || 500);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Post a user message and get assistant reply (non-streaming for v0)
|
|
81
|
+
app.post('/v1/sessions/:id/messages', async (c) => {
|
|
82
|
+
try {
|
|
83
|
+
const projectRoot = c.req.query('project') || process.cwd();
|
|
84
|
+
const cfg = await loadConfig(projectRoot);
|
|
85
|
+
const db = await getDb(cfg.projectRoot);
|
|
86
|
+
const sessionId = c.req.param('id');
|
|
87
|
+
const body = await c.req.json().catch(() => ({}));
|
|
88
|
+
|
|
89
|
+
// DEBUG: Log received body
|
|
90
|
+
logger.info('[API] Received message request', {
|
|
91
|
+
sessionId,
|
|
92
|
+
hasContent: !!body?.content,
|
|
93
|
+
hasUserContext: !!body?.userContext,
|
|
94
|
+
userContext: body?.userContext
|
|
95
|
+
? `${String(body.userContext).substring(0, 50)}...`
|
|
96
|
+
: 'NONE',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Load session to inherit its provider/model/agent by default
|
|
100
|
+
const sessionRows = await db
|
|
101
|
+
.select()
|
|
102
|
+
.from(sessions)
|
|
103
|
+
.where(eq(sessions.id, sessionId));
|
|
104
|
+
if (!sessionRows.length) {
|
|
105
|
+
logger.warn('Session not found', { sessionId });
|
|
106
|
+
return c.json({ error: 'Session not found' }, 404);
|
|
107
|
+
}
|
|
108
|
+
const sess: SessionRow = sessionRows[0];
|
|
109
|
+
const provider = body?.provider ?? sess.provider ?? cfg.defaults.provider;
|
|
110
|
+
const modelName = body?.model ?? sess.model ?? cfg.defaults.model;
|
|
111
|
+
const agent = body?.agent ?? sess.agent ?? cfg.defaults.agent;
|
|
112
|
+
const content = body?.content ?? '';
|
|
113
|
+
const userContext = body?.userContext;
|
|
114
|
+
const images = Array.isArray(body?.images) ? body.images : undefined;
|
|
115
|
+
const files = Array.isArray(body?.files) ? body.files : undefined;
|
|
116
|
+
|
|
117
|
+
// DEBUG: Log extracted userContext
|
|
118
|
+
logger.info('[API] Extracted userContext', {
|
|
119
|
+
userContext: userContext
|
|
120
|
+
? `${String(userContext).substring(0, 50)}...`
|
|
121
|
+
: 'NONE',
|
|
122
|
+
typeOf: typeof userContext,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const reasoning = body?.reasoningText === true;
|
|
126
|
+
|
|
127
|
+
// Validate model capabilities if tools are allowed for this agent
|
|
128
|
+
const wantsToolCalls = true; // agent toolset may be non-empty
|
|
129
|
+
try {
|
|
130
|
+
validateProviderModel(provider, modelName, { wantsToolCalls });
|
|
131
|
+
} catch (err) {
|
|
132
|
+
logger.error('Model validation failed', err, { provider, modelName });
|
|
133
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
134
|
+
return c.json({ error: message }, 400);
|
|
135
|
+
}
|
|
136
|
+
// Enforce provider auth: only allow providers/models the user authenticated for
|
|
137
|
+
const authorized = await isProviderAuthorized(cfg, provider);
|
|
138
|
+
if (!authorized) {
|
|
139
|
+
logger.warn('Provider not authorized', { provider });
|
|
140
|
+
return c.json(
|
|
141
|
+
{
|
|
142
|
+
error: `Provider ${provider} is not configured. Run \`otto auth login\` to add credentials.`,
|
|
143
|
+
},
|
|
144
|
+
400,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
await ensureProviderEnv(cfg, provider);
|
|
148
|
+
|
|
149
|
+
const { assistantMessageId } = await dispatchAssistantMessage({
|
|
150
|
+
cfg,
|
|
151
|
+
db,
|
|
152
|
+
session: sess,
|
|
153
|
+
agent,
|
|
154
|
+
provider,
|
|
155
|
+
model: modelName,
|
|
156
|
+
content,
|
|
157
|
+
oneShot: Boolean(body?.oneShot),
|
|
158
|
+
userContext,
|
|
159
|
+
reasoningText: reasoning,
|
|
160
|
+
images,
|
|
161
|
+
files,
|
|
162
|
+
});
|
|
163
|
+
return c.json({ messageId: assistantMessageId }, 202);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
logger.error('Failed to create session message', error);
|
|
166
|
+
const errorResponse = serializeError(error);
|
|
167
|
+
return c.json(errorResponse, errorResponse.error.status || 500);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import { subscribe } from '../events/bus.ts';
|
|
3
|
+
import type { OttoEvent } from '../events/types.ts';
|
|
4
|
+
|
|
5
|
+
function safeStringify(obj: unknown): string {
|
|
6
|
+
return JSON.stringify(obj, (_key, value) =>
|
|
7
|
+
typeof value === 'bigint' ? Number(value) : value,
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function registerSessionStreamRoute(app: Hono) {
|
|
12
|
+
app.get('/v1/sessions/:id/stream', async (c) => {
|
|
13
|
+
const sessionId = c.req.param('id');
|
|
14
|
+
const headers = new Headers({
|
|
15
|
+
'Content-Type': 'text/event-stream',
|
|
16
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
17
|
+
Connection: 'keep-alive',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
|
|
22
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
23
|
+
start(controller) {
|
|
24
|
+
const write = (evt: OttoEvent) => {
|
|
25
|
+
let line: string;
|
|
26
|
+
try {
|
|
27
|
+
line =
|
|
28
|
+
`event: ${evt.type}\n` +
|
|
29
|
+
`data: ${safeStringify(evt.payload ?? {})}\n\n`;
|
|
30
|
+
} catch {
|
|
31
|
+
line = `event: ${evt.type}\ndata: {}\n\n`;
|
|
32
|
+
}
|
|
33
|
+
controller.enqueue(encoder.encode(line));
|
|
34
|
+
};
|
|
35
|
+
const unsubscribe = subscribe(sessionId, write);
|
|
36
|
+
// Initial ping
|
|
37
|
+
controller.enqueue(encoder.encode(`: connected ${sessionId}\n\n`));
|
|
38
|
+
// Heartbeat every 5s to prevent idle timeout (Bun default is 10s)
|
|
39
|
+
const hb = setInterval(() => {
|
|
40
|
+
try {
|
|
41
|
+
controller.enqueue(encoder.encode(`: hb ${Date.now()}\n\n`));
|
|
42
|
+
} catch {
|
|
43
|
+
// Controller might be closed
|
|
44
|
+
clearInterval(hb);
|
|
45
|
+
}
|
|
46
|
+
}, 5000);
|
|
47
|
+
|
|
48
|
+
const signal = c.req.raw?.signal as AbortSignal | undefined;
|
|
49
|
+
signal?.addEventListener('abort', () => {
|
|
50
|
+
clearInterval(hb);
|
|
51
|
+
unsubscribe();
|
|
52
|
+
try {
|
|
53
|
+
controller.close();
|
|
54
|
+
} catch {}
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return new Response(stream, { headers });
|
|
60
|
+
});
|
|
61
|
+
}
|