@ottocode/server 0.1.259 → 0.1.261

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.
Files changed (69) hide show
  1. package/package.json +4 -3
  2. package/src/index.ts +5 -4
  3. package/src/openapi/register.ts +92 -0
  4. package/src/openapi/route.ts +22 -0
  5. package/src/routes/ask.ts +210 -99
  6. package/src/routes/auth.ts +1701 -626
  7. package/src/routes/branch.ts +281 -90
  8. package/src/routes/config/agents.ts +79 -32
  9. package/src/routes/config/cwd.ts +46 -14
  10. package/src/routes/config/debug.ts +159 -30
  11. package/src/routes/config/defaults.ts +182 -64
  12. package/src/routes/config/main.ts +109 -73
  13. package/src/routes/config/models.ts +304 -137
  14. package/src/routes/config/providers.ts +462 -166
  15. package/src/routes/config/utils.ts +2 -2
  16. package/src/routes/doctor.ts +395 -161
  17. package/src/routes/files.ts +650 -260
  18. package/src/routes/git/branch.ts +143 -52
  19. package/src/routes/git/commit.ts +347 -141
  20. package/src/routes/git/diff.ts +239 -116
  21. package/src/routes/git/init.ts +103 -23
  22. package/src/routes/git/pull.ts +167 -65
  23. package/src/routes/git/push.ts +222 -117
  24. package/src/routes/git/remote.ts +401 -100
  25. package/src/routes/git/staging.ts +502 -141
  26. package/src/routes/git/status.ts +171 -78
  27. package/src/routes/mcp.ts +1129 -404
  28. package/src/routes/openapi.ts +27 -4
  29. package/src/routes/ottorouter.ts +1221 -389
  30. package/src/routes/provider-usage.ts +153 -36
  31. package/src/routes/research.ts +817 -370
  32. package/src/routes/root.ts +50 -6
  33. package/src/routes/session-approval.ts +228 -54
  34. package/src/routes/session-files.ts +265 -134
  35. package/src/routes/session-messages.ts +330 -150
  36. package/src/routes/session-stream.ts +83 -2
  37. package/src/routes/sessions.ts +1830 -780
  38. package/src/routes/skills.ts +849 -161
  39. package/src/routes/terminals.ts +469 -103
  40. package/src/routes/tunnel.ts +394 -118
  41. package/src/runtime/agent/runner-reasoning.ts +38 -3
  42. package/src/runtime/agent/runner.ts +1 -0
  43. package/src/runtime/ask/service.ts +1 -0
  44. package/src/runtime/message/compaction-limits.ts +3 -3
  45. package/src/runtime/provider/reasoning.ts +18 -7
  46. package/src/runtime/session/db-operations.ts +4 -3
  47. package/src/runtime/utils/token.ts +7 -2
  48. package/src/tools/adapter.ts +21 -0
  49. package/src/openapi/paths/ask.ts +0 -81
  50. package/src/openapi/paths/auth.ts +0 -687
  51. package/src/openapi/paths/branch.ts +0 -102
  52. package/src/openapi/paths/config.ts +0 -485
  53. package/src/openapi/paths/doctor.ts +0 -165
  54. package/src/openapi/paths/files.ts +0 -236
  55. package/src/openapi/paths/git.ts +0 -690
  56. package/src/openapi/paths/mcp.ts +0 -339
  57. package/src/openapi/paths/messages.ts +0 -103
  58. package/src/openapi/paths/ottorouter.ts +0 -594
  59. package/src/openapi/paths/provider-usage.ts +0 -59
  60. package/src/openapi/paths/research.ts +0 -227
  61. package/src/openapi/paths/session-approval.ts +0 -93
  62. package/src/openapi/paths/session-extras.ts +0 -336
  63. package/src/openapi/paths/session-files.ts +0 -91
  64. package/src/openapi/paths/sessions.ts +0 -210
  65. package/src/openapi/paths/skills.ts +0 -377
  66. package/src/openapi/paths/stream.ts +0 -26
  67. package/src/openapi/paths/terminals.ts +0 -226
  68. package/src/openapi/paths/tunnel.ts +0 -163
  69. package/src/openapi/spec.ts +0 -73
@@ -13,170 +13,350 @@ import { eq, inArray } from 'drizzle-orm';
13
13
  import { dispatchAssistantMessage } from '../runtime/message/service.ts';
14
14
  import { logger } from '@ottocode/sdk';
15
15
  import { serializeError } from '../runtime/errors/api-error.ts';
16
+ import { openApiRoute } from '../openapi/route.ts';
16
17
 
17
18
  type MessagePartRow = typeof messageParts.$inferSelect;
18
19
  type SessionRow = typeof sessions.$inferSelect;
19
20
 
20
21
  export function registerSessionMessagesRoutes(app: Hono) {
21
22
  // List messages for a session
22
- app.get('/v1/sessions/:id/messages', async (c) => {
23
- try {
24
- const projectRoot = c.req.query('project') || process.cwd();
25
- const cfg = await loadConfig(projectRoot);
26
- const db = await getDb(cfg.projectRoot);
27
- const id = c.req.param('id');
28
- const rows = await db
29
- .select()
30
- .from(messages)
31
- .where(eq(messages.sessionId, id))
32
- .orderBy(messages.createdAt);
33
- const without = c.req.query('without');
34
- if (without !== 'parts') {
35
- const ids = rows.map((m) => m.id);
36
- const parts = ids.length
37
- ? await db
38
- .select()
39
- .from(messageParts)
40
- .where(inArray(messageParts.messageId, ids))
41
- : [];
42
- const partsByMsg = new Map<string, MessagePartRow[]>();
43
- for (const p of parts) {
44
- const existing = partsByMsg.get(p.messageId);
45
- if (existing) existing.push(p);
46
- else partsByMsg.set(p.messageId, [p]);
47
- }
48
- const wantParsed = (() => {
49
- const q = (c.req.query('parsed') || '').toLowerCase();
50
- return q === '1' || q === 'true' || q === 'yes';
51
- })();
52
- function parseContent(raw: string): Record<string, unknown> | string {
53
- try {
54
- const v = JSON.parse(String(raw ?? ''));
55
- if (v && typeof v === 'object' && !Array.isArray(v))
56
- return v as Record<string, unknown>;
57
- } catch {}
58
- return raw;
59
- }
60
- const enriched = rows.map((m) => {
61
- const parts = (partsByMsg.get(m.id) ?? []).sort(
62
- (a, b) => a.index - b.index,
63
- );
64
- const mapped = parts.map((p) => {
65
- const parsed = parseContent(p.content);
66
- return wantParsed
67
- ? { ...p, content: parsed }
68
- : { ...p, contentJson: parsed };
23
+ openApiRoute(
24
+ app,
25
+ {
26
+ method: 'get',
27
+ path: '/v1/sessions/{id}/messages',
28
+ tags: ['messages'],
29
+ operationId: 'listMessages',
30
+ summary: 'List messages for a session',
31
+ parameters: [
32
+ {
33
+ in: 'query',
34
+ name: 'project',
35
+ required: false,
36
+ schema: {
37
+ type: 'string',
38
+ },
39
+ description:
40
+ 'Project root override (defaults to current working directory).',
41
+ },
42
+ {
43
+ in: 'path',
44
+ name: 'id',
45
+ required: true,
46
+ schema: {
47
+ type: 'string',
48
+ },
49
+ },
50
+ {
51
+ in: 'query',
52
+ name: 'without',
53
+ required: false,
54
+ schema: {
55
+ type: 'string',
56
+ enum: ['parts'],
57
+ },
58
+ description:
59
+ 'Exclude parts from the response. By default, parts are included.',
60
+ },
61
+ ],
62
+ responses: {
63
+ '200': {
64
+ description: 'OK',
65
+ content: {
66
+ 'application/json': {
67
+ schema: {
68
+ type: 'array',
69
+ items: {
70
+ allOf: [
71
+ {
72
+ $ref: '#/components/schemas/Message',
73
+ },
74
+ {
75
+ type: 'object',
76
+ properties: {
77
+ parts: {
78
+ type: 'array',
79
+ items: {
80
+ $ref: '#/components/schemas/MessagePart',
81
+ },
82
+ },
83
+ },
84
+ required: [],
85
+ },
86
+ ],
87
+ },
88
+ },
89
+ },
90
+ },
91
+ },
92
+ },
93
+ },
94
+ async (c) => {
95
+ try {
96
+ const projectRoot = c.req.query('project') || process.cwd();
97
+ const cfg = await loadConfig(projectRoot);
98
+ const db = await getDb(cfg.projectRoot);
99
+ const id = c.req.param('id');
100
+ const rows = await db
101
+ .select()
102
+ .from(messages)
103
+ .where(eq(messages.sessionId, id))
104
+ .orderBy(messages.createdAt);
105
+ const without = c.req.query('without');
106
+ if (without !== 'parts') {
107
+ const ids = rows.map((m) => m.id);
108
+ const parts = ids.length
109
+ ? await db
110
+ .select()
111
+ .from(messageParts)
112
+ .where(inArray(messageParts.messageId, ids))
113
+ : [];
114
+ const partsByMsg = new Map<string, MessagePartRow[]>();
115
+ for (const p of parts) {
116
+ const existing = partsByMsg.get(p.messageId);
117
+ if (existing) existing.push(p);
118
+ else partsByMsg.set(p.messageId, [p]);
119
+ }
120
+ const wantParsed = (() => {
121
+ const q = (c.req.query('parsed') || '').toLowerCase();
122
+ return q === '1' || q === 'true' || q === 'yes';
123
+ })();
124
+ function parseContent(raw: string): Record<string, unknown> | string {
125
+ try {
126
+ const v = JSON.parse(String(raw ?? ''));
127
+ if (v && typeof v === 'object' && !Array.isArray(v))
128
+ return v as Record<string, unknown>;
129
+ } catch {}
130
+ return raw;
131
+ }
132
+ const enriched = rows.map((m) => {
133
+ const parts = (partsByMsg.get(m.id) ?? []).sort(
134
+ (a, b) => a.index - b.index,
135
+ );
136
+ const mapped = parts.map((p) => {
137
+ const parsed = parseContent(p.content);
138
+ return wantParsed
139
+ ? { ...p, content: parsed }
140
+ : { ...p, contentJson: parsed };
141
+ });
142
+ return { ...m, parts: mapped };
69
143
  });
70
- return { ...m, parts: mapped };
71
- });
72
- return c.json(enriched);
144
+ return c.json(enriched);
145
+ }
146
+ return c.json(rows);
147
+ } catch (error) {
148
+ logger.error('Failed to list session messages', error);
149
+ const errorResponse = serializeError(error);
150
+ return c.json(errorResponse, errorResponse.error.status || 500);
73
151
  }
74
- return c.json(rows);
75
- } catch (error) {
76
- logger.error('Failed to list session messages', error);
77
- const errorResponse = serializeError(error);
78
- return c.json(errorResponse, errorResponse.error.status || 500);
79
- }
80
- });
152
+ },
153
+ );
81
154
 
82
155
  // Post a user message and get assistant reply (non-streaming for v0)
83
- app.post('/v1/sessions/:id/messages', async (c) => {
84
- try {
85
- const projectRoot = c.req.query('project') || process.cwd();
86
- const cfg = await loadConfig(projectRoot);
87
- const db = await getDb(cfg.projectRoot);
88
- const sessionId = c.req.param('id');
89
- const body = await c.req.json().catch(() => ({}));
156
+ openApiRoute(
157
+ app,
158
+ {
159
+ method: 'post',
160
+ path: '/v1/sessions/{id}/messages',
161
+ tags: ['messages'],
162
+ operationId: 'createMessage',
163
+ summary: 'Send a user message and enqueue assistant run',
164
+ parameters: [
165
+ {
166
+ in: 'query',
167
+ name: 'project',
168
+ required: false,
169
+ schema: {
170
+ type: 'string',
171
+ },
172
+ description:
173
+ 'Project root override (defaults to current working directory).',
174
+ },
175
+ {
176
+ in: 'path',
177
+ name: 'id',
178
+ required: true,
179
+ schema: {
180
+ type: 'string',
181
+ },
182
+ },
183
+ ],
184
+ requestBody: {
185
+ required: true,
186
+ content: {
187
+ 'application/json': {
188
+ schema: {
189
+ type: 'object',
190
+ required: ['content'],
191
+ properties: {
192
+ content: {
193
+ type: 'string',
194
+ },
195
+ agent: {
196
+ type: 'string',
197
+ description: 'Agent name. Defaults to config if omitted.',
198
+ },
199
+ provider: {
200
+ $ref: '#/components/schemas/Provider',
201
+ },
202
+ model: {
203
+ type: 'string',
204
+ },
205
+ userContext: {
206
+ type: 'string',
207
+ description:
208
+ 'Optional user-provided context to include in the system prompt.',
209
+ },
210
+ reasoningText: {
211
+ type: 'boolean',
212
+ description:
213
+ 'Enable extended thinking / reasoning for models that support it.',
214
+ },
215
+ reasoningLevel: {
216
+ type: 'string',
217
+ enum: ['minimal', 'low', 'medium', 'high', 'max', 'xhigh'],
218
+ description:
219
+ 'Reasoning intensity level for providers/models that support it.',
220
+ },
221
+ },
222
+ },
223
+ },
224
+ },
225
+ },
226
+ responses: {
227
+ '202': {
228
+ description: 'Accepted',
229
+ content: {
230
+ 'application/json': {
231
+ schema: {
232
+ type: 'object',
233
+ properties: {
234
+ messageId: {
235
+ type: 'string',
236
+ },
237
+ },
238
+ required: ['messageId'],
239
+ },
240
+ },
241
+ },
242
+ },
243
+ '400': {
244
+ description: 'Bad Request',
245
+ content: {
246
+ 'application/json': {
247
+ schema: {
248
+ type: 'object',
249
+ properties: {
250
+ error: {
251
+ type: 'string',
252
+ },
253
+ },
254
+ required: ['error'],
255
+ },
256
+ },
257
+ },
258
+ },
259
+ },
260
+ },
261
+ async (c) => {
262
+ try {
263
+ const projectRoot = c.req.query('project') || process.cwd();
264
+ const cfg = await loadConfig(projectRoot);
265
+ const db = await getDb(cfg.projectRoot);
266
+ const sessionId = c.req.param('id');
267
+ const body = await c.req.json().catch(() => ({}));
90
268
 
91
- // DEBUG: Log received body
92
- logger.info('[API] Received message request', {
93
- sessionId,
94
- hasContent: !!body?.content,
95
- hasUserContext: !!body?.userContext,
96
- userContext: body?.userContext
97
- ? `${String(body.userContext).substring(0, 50)}...`
98
- : 'NONE',
99
- });
269
+ // DEBUG: Log received body
270
+ logger.info('[API] Received message request', {
271
+ sessionId,
272
+ hasContent: !!body?.content,
273
+ hasUserContext: !!body?.userContext,
274
+ userContext: body?.userContext
275
+ ? `${String(body.userContext).substring(0, 50)}...`
276
+ : 'NONE',
277
+ });
100
278
 
101
- // Load session to inherit its provider/model/agent by default
102
- const sessionRows = await db
103
- .select()
104
- .from(sessions)
105
- .where(eq(sessions.id, sessionId));
106
- if (!sessionRows.length) {
107
- logger.warn('Session not found', { sessionId });
108
- return c.json({ error: 'Session not found' }, 404);
109
- }
110
- const sess: SessionRow = sessionRows[0];
111
- const provider = body?.provider ?? sess.provider ?? cfg.defaults.provider;
112
- const modelName = body?.model ?? sess.model ?? cfg.defaults.model;
113
- const agent = body?.agent ?? sess.agent ?? cfg.defaults.agent;
114
- const content = body?.content ?? '';
115
- const userContext = body?.userContext;
116
- const images = Array.isArray(body?.images) ? body.images : undefined;
117
- const files = Array.isArray(body?.files) ? body.files : undefined;
279
+ // Load session to inherit its provider/model/agent by default
280
+ const sessionRows = await db
281
+ .select()
282
+ .from(sessions)
283
+ .where(eq(sessions.id, sessionId));
284
+ if (!sessionRows.length) {
285
+ logger.warn('Session not found', { sessionId });
286
+ return c.json({ error: 'Session not found' }, 404);
287
+ }
288
+ const sess: SessionRow = sessionRows[0];
289
+ const provider =
290
+ body?.provider ?? sess.provider ?? cfg.defaults.provider;
291
+ const modelName = body?.model ?? sess.model ?? cfg.defaults.model;
292
+ const agent = body?.agent ?? sess.agent ?? cfg.defaults.agent;
293
+ const content = body?.content ?? '';
294
+ const userContext = body?.userContext;
295
+ const images = Array.isArray(body?.images) ? body.images : undefined;
296
+ const files = Array.isArray(body?.files) ? body.files : undefined;
118
297
 
119
- // DEBUG: Log extracted userContext
120
- logger.info('[API] Extracted userContext', {
121
- userContext: userContext
122
- ? `${String(userContext).substring(0, 50)}...`
123
- : 'NONE',
124
- typeOf: typeof userContext,
125
- });
298
+ // DEBUG: Log extracted userContext
299
+ logger.info('[API] Extracted userContext', {
300
+ userContext: userContext
301
+ ? `${String(userContext).substring(0, 50)}...`
302
+ : 'NONE',
303
+ typeOf: typeof userContext,
304
+ });
126
305
 
127
- const reasoning =
128
- body?.reasoningText ?? cfg.defaults.reasoningText ?? false;
129
- const reasoningLevel =
130
- (body?.reasoningLevel as ReasoningLevel | undefined) ??
131
- cfg.defaults.reasoningLevel ??
132
- 'high';
306
+ const reasoning =
307
+ body?.reasoningText ?? cfg.defaults.reasoningText ?? false;
308
+ const reasoningLevel =
309
+ (body?.reasoningLevel as ReasoningLevel | undefined) ??
310
+ cfg.defaults.reasoningLevel ??
311
+ 'high';
133
312
 
134
- // Validate model capabilities if tools are allowed for this agent
135
- const wantsToolCalls = true; // agent toolset may be non-empty
136
- try {
137
- validateProviderModel(provider, modelName, cfg, { wantsToolCalls });
138
- } catch (err) {
139
- logger.error('Model validation failed', err, { provider, modelName });
140
- const message = err instanceof Error ? err.message : String(err);
141
- return c.json({ error: message }, 400);
142
- }
143
- // Enforce provider auth: only allow providers/models the user authenticated for
144
- const authorized = await isProviderAuthorized(cfg, provider);
145
- if (!authorized) {
146
- logger.warn('Provider not authorized', { provider });
147
- return c.json(
148
- {
149
- error: `Provider ${provider} is not configured. Run \`otto auth login\` to add credentials.`,
150
- },
151
- 400,
152
- );
153
- }
154
- await ensureProviderEnv(cfg, provider);
155
- const providerDefinition = getProviderDefinition(cfg, provider);
313
+ // Validate model capabilities if tools are allowed for this agent
314
+ const wantsToolCalls = true; // agent toolset may be non-empty
315
+ try {
316
+ validateProviderModel(provider, modelName, cfg, { wantsToolCalls });
317
+ } catch (err) {
318
+ logger.error('Model validation failed', err, { provider, modelName });
319
+ const message = err instanceof Error ? err.message : String(err);
320
+ return c.json({ error: message }, 400);
321
+ }
322
+ // Enforce provider auth: only allow providers/models the user authenticated for
323
+ const authorized = await isProviderAuthorized(cfg, provider);
324
+ if (!authorized) {
325
+ logger.warn('Provider not authorized', { provider });
326
+ return c.json(
327
+ {
328
+ error: `Provider ${provider} is not configured. Run \`otto auth login\` to add credentials.`,
329
+ },
330
+ 400,
331
+ );
332
+ }
333
+ await ensureProviderEnv(cfg, provider);
334
+ const providerDefinition = getProviderDefinition(cfg, provider);
156
335
 
157
- const { assistantMessageId } = await dispatchAssistantMessage({
158
- cfg,
159
- db,
160
- session: sess,
161
- agent,
162
- provider,
163
- model: modelName,
164
- content,
165
- oneShot: Boolean(body?.oneShot),
166
- userContext,
167
- reasoningText:
168
- providerDefinition?.compatibility === 'ollama'
169
- ? (body?.reasoningText ?? false)
170
- : reasoning,
171
- reasoningLevel,
172
- images,
173
- files,
174
- });
175
- return c.json({ messageId: assistantMessageId }, 202);
176
- } catch (error) {
177
- logger.error('Failed to create session message', error);
178
- const errorResponse = serializeError(error);
179
- return c.json(errorResponse, errorResponse.error.status || 500);
180
- }
181
- });
336
+ const { assistantMessageId } = await dispatchAssistantMessage({
337
+ cfg,
338
+ db,
339
+ session: sess,
340
+ agent,
341
+ provider,
342
+ model: modelName,
343
+ content,
344
+ oneShot: Boolean(body?.oneShot),
345
+ userContext,
346
+ reasoningText:
347
+ providerDefinition?.compatibility === 'ollama'
348
+ ? (body?.reasoningText ?? false)
349
+ : reasoning,
350
+ reasoningLevel,
351
+ images,
352
+ files,
353
+ });
354
+ return c.json({ messageId: assistantMessageId }, 202);
355
+ } catch (error) {
356
+ logger.error('Failed to create session message', error);
357
+ const errorResponse = serializeError(error);
358
+ return c.json(errorResponse, errorResponse.error.status || 500);
359
+ }
360
+ },
361
+ );
182
362
  }
@@ -2,6 +2,7 @@ import type { Context } from 'hono';
2
2
  import type { Hono } from 'hono';
3
3
  import { subscribe } from '../events/bus.ts';
4
4
  import type { OttoEvent } from '../events/types.ts';
5
+ import { openApiRoute } from '../openapi/route.ts';
5
6
 
6
7
  function safeStringify(obj: unknown): string {
7
8
  return JSON.stringify(obj, (_key, value) =>
@@ -57,6 +58,86 @@ function handleSessionStream(c: Context) {
57
58
  }
58
59
 
59
60
  export function registerSessionStreamRoute(app: Hono) {
60
- app.get('/v1/sessions/:id/stream', handleSessionStream);
61
- app.post('/v1/sessions/:id/stream', handleSessionStream);
61
+ openApiRoute(
62
+ app,
63
+ {
64
+ method: 'get',
65
+ path: '/v1/sessions/{id}/stream',
66
+ tags: ['stream'],
67
+ operationId: 'subscribeSessionStream',
68
+ summary: 'Subscribe to session event stream (SSE)',
69
+ parameters: [
70
+ {
71
+ in: 'query',
72
+ name: 'project',
73
+ required: false,
74
+ schema: {
75
+ type: 'string',
76
+ },
77
+ description:
78
+ 'Project root override (defaults to current working directory).',
79
+ },
80
+ {
81
+ in: 'path',
82
+ name: 'id',
83
+ required: true,
84
+ schema: {
85
+ type: 'string',
86
+ },
87
+ },
88
+ ],
89
+ responses: {
90
+ '200': {
91
+ description: 'text/event-stream',
92
+ content: {
93
+ 'text/event-stream': {
94
+ schema: {
95
+ type: 'string',
96
+ description:
97
+ 'SSE event stream. Events include session.created, message.created, message.part.delta, tool.call, tool.delta, tool.result, message.completed, error.',
98
+ },
99
+ },
100
+ },
101
+ },
102
+ },
103
+ },
104
+ handleSessionStream,
105
+ );
106
+ openApiRoute(
107
+ app,
108
+ {
109
+ method: 'post',
110
+ path: '/v1/sessions/{id}/stream',
111
+ tags: ['stream'],
112
+ operationId: 'subscribeSessionStreamPost',
113
+ summary: 'Subscribe to session event stream (SSE) using POST',
114
+ parameters: [
115
+ {
116
+ in: 'query',
117
+ name: 'project',
118
+ required: false,
119
+ schema: { type: 'string' },
120
+ description:
121
+ 'Project root override (defaults to current working directory).',
122
+ },
123
+ {
124
+ in: 'path',
125
+ name: 'id',
126
+ required: true,
127
+ schema: { type: 'string' },
128
+ },
129
+ ],
130
+ responses: {
131
+ '200': {
132
+ description: 'text/event-stream',
133
+ content: {
134
+ 'text/event-stream': {
135
+ schema: { type: 'string' },
136
+ },
137
+ },
138
+ },
139
+ },
140
+ },
141
+ handleSessionStream,
142
+ );
62
143
  }