@ottocode/server 0.1.260 → 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 (67) 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/ask/service.ts +1 -0
  42. package/src/runtime/message/compaction-limits.ts +3 -3
  43. package/src/runtime/provider/reasoning.ts +2 -1
  44. package/src/runtime/session/db-operations.ts +4 -3
  45. package/src/runtime/utils/token.ts +7 -2
  46. package/src/tools/adapter.ts +21 -0
  47. package/src/openapi/paths/ask.ts +0 -81
  48. package/src/openapi/paths/auth.ts +0 -687
  49. package/src/openapi/paths/branch.ts +0 -102
  50. package/src/openapi/paths/config.ts +0 -485
  51. package/src/openapi/paths/doctor.ts +0 -165
  52. package/src/openapi/paths/files.ts +0 -236
  53. package/src/openapi/paths/git.ts +0 -690
  54. package/src/openapi/paths/mcp.ts +0 -339
  55. package/src/openapi/paths/messages.ts +0 -103
  56. package/src/openapi/paths/ottorouter.ts +0 -594
  57. package/src/openapi/paths/provider-usage.ts +0 -59
  58. package/src/openapi/paths/research.ts +0 -227
  59. package/src/openapi/paths/session-approval.ts +0 -93
  60. package/src/openapi/paths/session-extras.ts +0 -336
  61. package/src/openapi/paths/session-files.ts +0 -91
  62. package/src/openapi/paths/sessions.ts +0 -210
  63. package/src/openapi/paths/skills.ts +0 -377
  64. package/src/openapi/paths/stream.ts +0 -26
  65. package/src/openapi/paths/terminals.ts +0 -226
  66. package/src/openapi/paths/tunnel.ts +0 -163
  67. package/src/openapi/spec.ts +0 -73
@@ -8,385 +8,832 @@ import { hasConfiguredProvider } from '@ottocode/sdk';
8
8
  import { serializeError } from '../runtime/errors/api-error.ts';
9
9
  import { logger } from '@ottocode/sdk';
10
10
  import { publish } from '../events/bus.ts';
11
+ import { openApiRoute } from '../openapi/route.ts';
11
12
 
12
13
  export function registerResearchRoutes(app: Hono) {
13
- app.get('/v1/sessions/:parentId/research', async (c) => {
14
- const parentId = c.req.param('parentId');
15
- const projectRoot = c.req.query('project') || process.cwd();
16
- const cfg = await loadConfig(projectRoot);
17
- const db = await getDb(cfg.projectRoot);
18
-
19
- const parentRows = await db
20
- .select()
21
- .from(sessions)
22
- .where(eq(sessions.id, parentId))
23
- .limit(1);
24
-
25
- if (!parentRows.length || parentRows[0].projectPath !== cfg.projectRoot) {
26
- return c.json({ error: 'Parent session not found' }, 404);
27
- }
28
-
29
- const researchRows = await db
30
- .select({
31
- id: sessions.id,
32
- title: sessions.title,
33
- createdAt: sessions.createdAt,
34
- lastActiveAt: sessions.lastActiveAt,
35
- provider: sessions.provider,
36
- model: sessions.model,
37
- totalInputTokens: sessions.totalInputTokens,
38
- totalOutputTokens: sessions.totalOutputTokens,
39
- totalCachedTokens: sessions.totalCachedTokens,
40
- totalCacheCreationTokens: sessions.totalCacheCreationTokens,
41
- })
42
- .from(sessions)
43
- .where(
44
- and(
45
- eq(sessions.parentSessionId, parentId),
46
- eq(sessions.sessionType, 'research'),
47
- ),
48
- )
49
- .orderBy(desc(sessions.lastActiveAt), desc(sessions.createdAt));
50
-
51
- const sessionsWithCounts = await Promise.all(
52
- researchRows.map(async (row) => {
53
- const msgCount = await db
54
- .select({ count: count() })
55
- .from(messages)
56
- .where(eq(messages.sessionId, row.id));
57
- return {
58
- ...row,
59
- messageCount: msgCount[0]?.count ?? 0,
60
- };
61
- }),
62
- );
63
-
64
- return c.json({ sessions: sessionsWithCounts });
65
- });
66
-
67
- app.post('/v1/sessions/:parentId/research', async (c) => {
68
- const parentId = c.req.param('parentId');
69
- const projectRoot = c.req.query('project') || process.cwd();
70
- const cfg = await loadConfig(projectRoot);
71
- const db = await getDb(cfg.projectRoot);
72
- const body = (await c.req.json().catch(() => ({}))) as Record<
73
- string,
74
- unknown
75
- >;
76
-
77
- const parentRows = await db
78
- .select()
79
- .from(sessions)
80
- .where(eq(sessions.id, parentId))
81
- .limit(1);
82
-
83
- if (!parentRows.length || parentRows[0].projectPath !== cfg.projectRoot) {
84
- return c.json({ error: 'Parent session not found' }, 404);
85
- }
86
-
87
- const parent = parentRows[0];
88
-
89
- const providerCandidate =
90
- typeof body.provider === 'string' ? body.provider : undefined;
91
- const provider: ProviderId = (() => {
92
- if (providerCandidate && hasConfiguredProvider(cfg, providerCandidate))
93
- return providerCandidate;
94
- return parent.provider as ProviderId;
95
- })();
96
-
97
- const modelCandidate =
98
- typeof body.model === 'string' ? body.model.trim() : undefined;
99
- const model = modelCandidate?.length ? modelCandidate : parent.model;
100
-
101
- const id = crypto.randomUUID();
102
- const now = Date.now();
103
- const title = typeof body.title === 'string' ? body.title : null;
104
-
105
- const row = {
106
- id,
107
- title,
108
- agent: 'research',
109
- provider,
110
- model,
111
- projectPath: cfg.projectRoot,
112
- createdAt: now,
113
- lastActiveAt: now,
114
- parentSessionId: parentId,
115
- sessionType: 'research',
116
- totalInputTokens: null,
117
- totalOutputTokens: null,
118
- totalCachedTokens: null,
119
- totalCacheCreationTokens: null,
120
- totalReasoningTokens: null,
121
- totalToolTimeMs: null,
122
- toolCountsJson: null,
123
- };
124
-
125
- try {
126
- await db.insert(sessions).values(row);
127
- publish({ type: 'session.created', sessionId: id, payload: row });
128
- return c.json({ session: row, parentSessionId: parentId }, 201);
129
- } catch (err) {
130
- logger.error('Failed to create research session', err);
131
- const errorResponse = serializeError(err);
132
- return c.json(errorResponse, errorResponse.error.status || 400);
133
- }
134
- });
135
-
136
- app.delete('/v1/research/:researchId', async (c) => {
137
- const researchId = c.req.param('researchId');
138
- const projectRoot = c.req.query('project') || process.cwd();
139
- const cfg = await loadConfig(projectRoot);
140
- const db = await getDb(cfg.projectRoot);
141
-
142
- const rows = await db
143
- .select()
144
- .from(sessions)
145
- .where(eq(sessions.id, researchId))
146
- .limit(1);
147
-
148
- if (!rows.length) {
149
- return c.json({ error: 'Research session not found' }, 404);
150
- }
151
-
152
- const session = rows[0];
153
- if (session.projectPath !== cfg.projectRoot) {
154
- return c.json(
155
- { error: 'Research session not found in this project' },
156
- 404,
14
+ openApiRoute(
15
+ app,
16
+ {
17
+ method: 'get',
18
+ path: '/v1/sessions/{parentId}/research',
19
+ tags: ['sessions'],
20
+ operationId: 'listResearchSessions',
21
+ summary: 'List research sessions for a parent',
22
+ parameters: [
23
+ {
24
+ in: 'path',
25
+ name: 'parentId',
26
+ required: true,
27
+ schema: {
28
+ type: 'string',
29
+ },
30
+ },
31
+ {
32
+ in: 'query',
33
+ name: 'project',
34
+ required: false,
35
+ schema: {
36
+ type: 'string',
37
+ },
38
+ description:
39
+ 'Project root override (defaults to current working directory).',
40
+ },
41
+ ],
42
+ responses: {
43
+ '200': {
44
+ description: 'OK',
45
+ content: {
46
+ 'application/json': {
47
+ schema: {
48
+ type: 'object',
49
+ properties: {
50
+ sessions: {
51
+ type: 'array',
52
+ items: {
53
+ $ref: '#/components/schemas/Session',
54
+ },
55
+ },
56
+ },
57
+ required: ['sessions'],
58
+ },
59
+ },
60
+ },
61
+ },
62
+ '404': {
63
+ description: 'Bad Request',
64
+ content: {
65
+ 'application/json': {
66
+ schema: {
67
+ type: 'object',
68
+ properties: {
69
+ error: {
70
+ type: 'string',
71
+ },
72
+ },
73
+ required: ['error'],
74
+ },
75
+ },
76
+ },
77
+ },
78
+ },
79
+ },
80
+ async (c) => {
81
+ const parentId = c.req.param('parentId');
82
+ const projectRoot = c.req.query('project') || process.cwd();
83
+ const cfg = await loadConfig(projectRoot);
84
+ const db = await getDb(cfg.projectRoot);
85
+
86
+ const parentRows = await db
87
+ .select()
88
+ .from(sessions)
89
+ .where(eq(sessions.id, parentId))
90
+ .limit(1);
91
+
92
+ if (!parentRows.length || parentRows[0].projectPath !== cfg.projectRoot) {
93
+ return c.json({ error: 'Parent session not found' }, 404);
94
+ }
95
+
96
+ const researchRows = await db
97
+ .select({
98
+ id: sessions.id,
99
+ title: sessions.title,
100
+ createdAt: sessions.createdAt,
101
+ lastActiveAt: sessions.lastActiveAt,
102
+ provider: sessions.provider,
103
+ model: sessions.model,
104
+ totalInputTokens: sessions.totalInputTokens,
105
+ totalOutputTokens: sessions.totalOutputTokens,
106
+ totalCachedTokens: sessions.totalCachedTokens,
107
+ totalCacheCreationTokens: sessions.totalCacheCreationTokens,
108
+ })
109
+ .from(sessions)
110
+ .where(
111
+ and(
112
+ eq(sessions.parentSessionId, parentId),
113
+ eq(sessions.sessionType, 'research'),
114
+ ),
115
+ )
116
+ .orderBy(desc(sessions.lastActiveAt), desc(sessions.createdAt));
117
+
118
+ const sessionsWithCounts = await Promise.all(
119
+ researchRows.map(async (row) => {
120
+ const msgCount = await db
121
+ .select({ count: count() })
122
+ .from(messages)
123
+ .where(eq(messages.sessionId, row.id));
124
+ return {
125
+ ...row,
126
+ messageCount: msgCount[0]?.count ?? 0,
127
+ };
128
+ }),
157
129
  );
158
- }
159
-
160
- if (session.sessionType !== 'research') {
161
- return c.json({ error: 'Session is not a research session' }, 400);
162
- }
163
-
164
- await db.delete(sessions).where(eq(sessions.id, researchId));
165
- publish({
166
- type: 'session.deleted',
167
- sessionId: researchId,
168
- payload: { id: researchId },
169
- });
170
-
171
- return c.json({ success: true });
172
- });
173
-
174
- app.post('/v1/sessions/:parentId/inject', async (c) => {
175
- const parentId = c.req.param('parentId');
176
- const projectRoot = c.req.query('project') || process.cwd();
177
- const cfg = await loadConfig(projectRoot);
178
- const db = await getDb(cfg.projectRoot);
179
- const body = (await c.req.json().catch(() => ({}))) as Record<
180
- string,
181
- unknown
182
- >;
183
-
184
- const researchSessionId =
185
- typeof body.researchSessionId === 'string' ? body.researchSessionId : '';
186
- const label =
187
- typeof body.label === 'string' ? body.label : 'Research context';
188
-
189
- if (!researchSessionId) {
190
- return c.json({ error: 'researchSessionId is required' }, 400);
191
- }
192
-
193
- const [parentRows, researchRows] = await Promise.all([
194
- db.select().from(sessions).where(eq(sessions.id, parentId)).limit(1),
195
- db
130
+
131
+ return c.json({ sessions: sessionsWithCounts });
132
+ },
133
+ );
134
+
135
+ openApiRoute(
136
+ app,
137
+ {
138
+ method: 'post',
139
+ path: '/v1/sessions/{parentId}/research',
140
+ tags: ['sessions'],
141
+ operationId: 'createResearchSession',
142
+ summary: 'Create a research session',
143
+ parameters: [
144
+ {
145
+ in: 'path',
146
+ name: 'parentId',
147
+ required: true,
148
+ schema: {
149
+ type: 'string',
150
+ },
151
+ },
152
+ {
153
+ in: 'query',
154
+ name: 'project',
155
+ required: false,
156
+ schema: {
157
+ type: 'string',
158
+ },
159
+ description:
160
+ 'Project root override (defaults to current working directory).',
161
+ },
162
+ ],
163
+ requestBody: {
164
+ required: false,
165
+ content: {
166
+ 'application/json': {
167
+ schema: {
168
+ type: 'object',
169
+ properties: {
170
+ provider: {
171
+ type: 'string',
172
+ },
173
+ model: {
174
+ type: 'string',
175
+ },
176
+ title: {
177
+ type: 'string',
178
+ },
179
+ },
180
+ },
181
+ },
182
+ },
183
+ },
184
+ responses: {
185
+ '201': {
186
+ description: 'Created',
187
+ content: {
188
+ 'application/json': {
189
+ schema: {
190
+ type: 'object',
191
+ properties: {
192
+ session: {
193
+ $ref: '#/components/schemas/Session',
194
+ },
195
+ parentSessionId: {
196
+ type: 'string',
197
+ },
198
+ },
199
+ required: ['session', 'parentSessionId'],
200
+ },
201
+ },
202
+ },
203
+ },
204
+ '404': {
205
+ description: 'Bad Request',
206
+ content: {
207
+ 'application/json': {
208
+ schema: {
209
+ type: 'object',
210
+ properties: {
211
+ error: {
212
+ type: 'string',
213
+ },
214
+ },
215
+ required: ['error'],
216
+ },
217
+ },
218
+ },
219
+ },
220
+ },
221
+ },
222
+ async (c) => {
223
+ const parentId = c.req.param('parentId');
224
+ const projectRoot = c.req.query('project') || process.cwd();
225
+ const cfg = await loadConfig(projectRoot);
226
+ const db = await getDb(cfg.projectRoot);
227
+ const body = (await c.req.json().catch(() => ({}))) as Record<
228
+ string,
229
+ unknown
230
+ >;
231
+
232
+ const parentRows = await db
233
+ .select()
234
+ .from(sessions)
235
+ .where(eq(sessions.id, parentId))
236
+ .limit(1);
237
+
238
+ if (!parentRows.length || parentRows[0].projectPath !== cfg.projectRoot) {
239
+ return c.json({ error: 'Parent session not found' }, 404);
240
+ }
241
+
242
+ const parent = parentRows[0];
243
+
244
+ const providerCandidate =
245
+ typeof body.provider === 'string' ? body.provider : undefined;
246
+ const provider: ProviderId = (() => {
247
+ if (providerCandidate && hasConfiguredProvider(cfg, providerCandidate))
248
+ return providerCandidate;
249
+ return parent.provider as ProviderId;
250
+ })();
251
+
252
+ const modelCandidate =
253
+ typeof body.model === 'string' ? body.model.trim() : undefined;
254
+ const model = modelCandidate?.length ? modelCandidate : parent.model;
255
+
256
+ const id = crypto.randomUUID();
257
+ const now = Date.now();
258
+ const title = typeof body.title === 'string' ? body.title : null;
259
+
260
+ const row = {
261
+ id,
262
+ title,
263
+ agent: 'research',
264
+ provider,
265
+ model,
266
+ projectPath: cfg.projectRoot,
267
+ createdAt: now,
268
+ lastActiveAt: now,
269
+ parentSessionId: parentId,
270
+ sessionType: 'research',
271
+ totalInputTokens: null,
272
+ totalOutputTokens: null,
273
+ totalCachedTokens: null,
274
+ totalCacheCreationTokens: null,
275
+ totalReasoningTokens: null,
276
+ totalToolTimeMs: null,
277
+ toolCountsJson: null,
278
+ };
279
+
280
+ try {
281
+ await db.insert(sessions).values(row);
282
+ publish({ type: 'session.created', sessionId: id, payload: row });
283
+ return c.json({ session: row, parentSessionId: parentId }, 201);
284
+ } catch (err) {
285
+ logger.error('Failed to create research session', err);
286
+ const errorResponse = serializeError(err);
287
+ return c.json(errorResponse, errorResponse.error.status || 400);
288
+ }
289
+ },
290
+ );
291
+
292
+ openApiRoute(
293
+ app,
294
+ {
295
+ method: 'delete',
296
+ path: '/v1/research/{researchId}',
297
+ tags: ['sessions'],
298
+ operationId: 'deleteResearchSession',
299
+ summary: 'Delete a research session',
300
+ parameters: [
301
+ {
302
+ in: 'path',
303
+ name: 'researchId',
304
+ required: true,
305
+ schema: {
306
+ type: 'string',
307
+ },
308
+ },
309
+ {
310
+ in: 'query',
311
+ name: 'project',
312
+ required: false,
313
+ schema: {
314
+ type: 'string',
315
+ },
316
+ description:
317
+ 'Project root override (defaults to current working directory).',
318
+ },
319
+ ],
320
+ responses: {
321
+ '200': {
322
+ description: 'OK',
323
+ content: {
324
+ 'application/json': {
325
+ schema: {
326
+ type: 'object',
327
+ properties: {
328
+ success: {
329
+ type: 'boolean',
330
+ },
331
+ },
332
+ required: ['success'],
333
+ },
334
+ },
335
+ },
336
+ },
337
+ '400': {
338
+ description: 'Bad Request',
339
+ content: {
340
+ 'application/json': {
341
+ schema: {
342
+ type: 'object',
343
+ properties: {
344
+ error: {
345
+ type: 'string',
346
+ },
347
+ },
348
+ required: ['error'],
349
+ },
350
+ },
351
+ },
352
+ },
353
+ '404': {
354
+ description: 'Bad Request',
355
+ content: {
356
+ 'application/json': {
357
+ schema: {
358
+ type: 'object',
359
+ properties: {
360
+ error: {
361
+ type: 'string',
362
+ },
363
+ },
364
+ required: ['error'],
365
+ },
366
+ },
367
+ },
368
+ },
369
+ },
370
+ },
371
+ async (c) => {
372
+ const researchId = c.req.param('researchId');
373
+ const projectRoot = c.req.query('project') || process.cwd();
374
+ const cfg = await loadConfig(projectRoot);
375
+ const db = await getDb(cfg.projectRoot);
376
+
377
+ const rows = await db
196
378
  .select()
197
379
  .from(sessions)
198
- .where(eq(sessions.id, researchSessionId))
199
- .limit(1),
200
- ]);
201
-
202
- if (!parentRows.length || parentRows[0].projectPath !== cfg.projectRoot) {
203
- return c.json({ error: 'Parent session not found' }, 404);
204
- }
205
-
206
- if (!researchRows.length || researchRows[0].sessionType !== 'research') {
207
- return c.json({ error: 'Research session not found' }, 404);
208
- }
209
-
210
- const _researchSession = researchRows[0];
211
-
212
- const researchMessages = await db
213
- .select({
214
- id: messages.id,
215
- role: messages.role,
216
- createdAt: messages.createdAt,
217
- })
218
- .from(messages)
219
- .where(eq(messages.sessionId, researchSessionId))
220
- .orderBy(asc(messages.createdAt));
221
-
222
- let contextContent = '';
223
- for (const msg of researchMessages) {
224
- if (msg.role === 'user' || msg.role === 'assistant') {
225
- const parts = await db
226
- .select({ type: messageParts.type, content: messageParts.content })
227
- .from(messageParts)
228
- .where(eq(messageParts.messageId, msg.id))
229
- .orderBy(asc(messageParts.index));
230
-
231
- for (const part of parts) {
232
- if (part.type === 'text' && part.content) {
233
- contextContent += `[${msg.role}]: ${part.content}\n\n`;
380
+ .where(eq(sessions.id, researchId))
381
+ .limit(1);
382
+
383
+ if (!rows.length) {
384
+ return c.json({ error: 'Research session not found' }, 404);
385
+ }
386
+
387
+ const session = rows[0];
388
+ if (session.projectPath !== cfg.projectRoot) {
389
+ return c.json(
390
+ { error: 'Research session not found in this project' },
391
+ 404,
392
+ );
393
+ }
394
+
395
+ if (session.sessionType !== 'research') {
396
+ return c.json({ error: 'Session is not a research session' }, 400);
397
+ }
398
+
399
+ await db.delete(sessions).where(eq(sessions.id, researchId));
400
+ publish({
401
+ type: 'session.deleted',
402
+ sessionId: researchId,
403
+ payload: { id: researchId },
404
+ });
405
+
406
+ return c.json({ success: true });
407
+ },
408
+ );
409
+
410
+ openApiRoute(
411
+ app,
412
+ {
413
+ method: 'post',
414
+ path: '/v1/sessions/{parentId}/inject',
415
+ tags: ['sessions'],
416
+ operationId: 'injectResearchContext',
417
+ summary: 'Inject research context into parent session',
418
+ parameters: [
419
+ {
420
+ in: 'path',
421
+ name: 'parentId',
422
+ required: true,
423
+ schema: {
424
+ type: 'string',
425
+ },
426
+ },
427
+ {
428
+ in: 'query',
429
+ name: 'project',
430
+ required: false,
431
+ schema: {
432
+ type: 'string',
433
+ },
434
+ description:
435
+ 'Project root override (defaults to current working directory).',
436
+ },
437
+ ],
438
+ requestBody: {
439
+ required: true,
440
+ content: {
441
+ 'application/json': {
442
+ schema: {
443
+ type: 'object',
444
+ properties: {
445
+ researchSessionId: {
446
+ type: 'string',
447
+ },
448
+ label: {
449
+ type: 'string',
450
+ },
451
+ },
452
+ required: ['researchSessionId'],
453
+ },
454
+ },
455
+ },
456
+ },
457
+ responses: {
458
+ '200': {
459
+ description: 'OK',
460
+ content: {
461
+ 'application/json': {
462
+ schema: {
463
+ type: 'object',
464
+ properties: {
465
+ content: {
466
+ type: 'string',
467
+ },
468
+ label: {
469
+ type: 'string',
470
+ },
471
+ sessionId: {
472
+ type: 'string',
473
+ },
474
+ parentSessionId: {
475
+ type: 'string',
476
+ },
477
+ tokenEstimate: {
478
+ type: 'integer',
479
+ },
480
+ },
481
+ required: [
482
+ 'content',
483
+ 'label',
484
+ 'sessionId',
485
+ 'parentSessionId',
486
+ 'tokenEstimate',
487
+ ],
488
+ },
489
+ },
490
+ },
491
+ },
492
+ '400': {
493
+ description: 'Bad Request',
494
+ content: {
495
+ 'application/json': {
496
+ schema: {
497
+ type: 'object',
498
+ properties: {
499
+ error: {
500
+ type: 'string',
501
+ },
502
+ },
503
+ required: ['error'],
504
+ },
505
+ },
506
+ },
507
+ },
508
+ '404': {
509
+ description: 'Bad Request',
510
+ content: {
511
+ 'application/json': {
512
+ schema: {
513
+ type: 'object',
514
+ properties: {
515
+ error: {
516
+ type: 'string',
517
+ },
518
+ },
519
+ required: ['error'],
520
+ },
521
+ },
522
+ },
523
+ },
524
+ },
525
+ },
526
+ async (c) => {
527
+ const parentId = c.req.param('parentId');
528
+ const projectRoot = c.req.query('project') || process.cwd();
529
+ const cfg = await loadConfig(projectRoot);
530
+ const db = await getDb(cfg.projectRoot);
531
+ const body = (await c.req.json().catch(() => ({}))) as Record<
532
+ string,
533
+ unknown
534
+ >;
535
+
536
+ const researchSessionId =
537
+ typeof body.researchSessionId === 'string'
538
+ ? body.researchSessionId
539
+ : '';
540
+ const label =
541
+ typeof body.label === 'string' ? body.label : 'Research context';
542
+
543
+ if (!researchSessionId) {
544
+ return c.json({ error: 'researchSessionId is required' }, 400);
545
+ }
546
+
547
+ const [parentRows, researchRows] = await Promise.all([
548
+ db.select().from(sessions).where(eq(sessions.id, parentId)).limit(1),
549
+ db
550
+ .select()
551
+ .from(sessions)
552
+ .where(eq(sessions.id, researchSessionId))
553
+ .limit(1),
554
+ ]);
555
+
556
+ if (!parentRows.length || parentRows[0].projectPath !== cfg.projectRoot) {
557
+ return c.json({ error: 'Parent session not found' }, 404);
558
+ }
559
+
560
+ if (!researchRows.length || researchRows[0].sessionType !== 'research') {
561
+ return c.json({ error: 'Research session not found' }, 404);
562
+ }
563
+
564
+ const _researchSession = researchRows[0];
565
+
566
+ const researchMessages = await db
567
+ .select({
568
+ id: messages.id,
569
+ role: messages.role,
570
+ createdAt: messages.createdAt,
571
+ })
572
+ .from(messages)
573
+ .where(eq(messages.sessionId, researchSessionId))
574
+ .orderBy(asc(messages.createdAt));
575
+
576
+ let contextContent = '';
577
+ for (const msg of researchMessages) {
578
+ if (msg.role === 'user' || msg.role === 'assistant') {
579
+ const parts = await db
580
+ .select({ type: messageParts.type, content: messageParts.content })
581
+ .from(messageParts)
582
+ .where(eq(messageParts.messageId, msg.id))
583
+ .orderBy(asc(messageParts.index));
584
+
585
+ for (const part of parts) {
586
+ if (part.type === 'text' && part.content) {
587
+ contextContent += `[${msg.role}]: ${part.content}\n\n`;
588
+ }
234
589
  }
235
590
  }
236
591
  }
237
- }
238
-
239
- const injectedContext = `<research-context from="${researchSessionId}" label="${label}" injected-at="${new Date().toISOString()}">\n${contextContent}</research-context>`;
240
-
241
- // Return the content to the client instead of creating a system message
242
- // The client will store it in zustand and include it in the next user message
243
- return c.json({
244
- content: injectedContext,
245
- label,
246
- sessionId: researchSessionId,
247
- parentSessionId: parentId,
248
- tokenEstimate: Math.ceil(injectedContext.length / 4),
249
- });
250
- });
251
-
252
- app.post('/v1/research/:researchId/export', async (c) => {
253
- const researchId = c.req.param('researchId');
254
- const projectRoot = c.req.query('project') || process.cwd();
255
- const cfg = await loadConfig(projectRoot);
256
- const db = await getDb(cfg.projectRoot);
257
- const body = (await c.req.json().catch(() => ({}))) as Record<
258
- string,
259
- unknown
260
- >;
261
-
262
- const researchRows = await db
263
- .select()
264
- .from(sessions)
265
- .where(eq(sessions.id, researchId))
266
- .limit(1);
267
-
268
- if (!researchRows.length || researchRows[0].sessionType !== 'research') {
269
- return c.json({ error: 'Research session not found' }, 404);
270
- }
271
-
272
- const researchSession = researchRows[0];
273
-
274
- if (researchSession.projectPath !== cfg.projectRoot) {
275
- return c.json({ error: 'Research session not in this project' }, 404);
276
- }
277
-
278
- const providerCandidate =
279
- typeof body.provider === 'string' ? body.provider : undefined;
280
- const provider: ProviderId = (() => {
281
- if (providerCandidate && hasConfiguredProvider(cfg, providerCandidate))
282
- return providerCandidate;
283
- return cfg.defaults.provider;
284
- })();
285
-
286
- const modelCandidate =
287
- typeof body.model === 'string' ? body.model.trim() : undefined;
288
- const model = modelCandidate?.length ? modelCandidate : cfg.defaults.model;
289
-
290
- const agentCandidate =
291
- typeof body.agent === 'string' ? body.agent.trim() : undefined;
292
- const agent = agentCandidate?.length ? agentCandidate : cfg.defaults.agent;
293
-
294
- const researchMessages = await db
295
- .select({
296
- id: messages.id,
297
- role: messages.role,
298
- createdAt: messages.createdAt,
299
- })
300
- .from(messages)
301
- .where(eq(messages.sessionId, researchId))
302
- .orderBy(asc(messages.createdAt));
303
-
304
- let contextContent = '';
305
- for (const msg of researchMessages) {
306
- if (msg.role === 'user' || msg.role === 'assistant') {
307
- const parts = await db
308
- .select({ type: messageParts.type, content: messageParts.content })
309
- .from(messageParts)
310
- .where(eq(messageParts.messageId, msg.id))
311
- .orderBy(asc(messageParts.index));
312
-
313
- for (const part of parts) {
314
- if (part.type === 'text' && part.content) {
315
- contextContent += `[${msg.role}]: ${part.content}\n\n`;
592
+
593
+ const injectedContext = `<research-context from="${researchSessionId}" label="${label}" injected-at="${new Date().toISOString()}">\n${contextContent}</research-context>`;
594
+
595
+ // Return the content to the client instead of creating a system message
596
+ // The client will store it in zustand and include it in the next user message
597
+ return c.json({
598
+ content: injectedContext,
599
+ label,
600
+ sessionId: researchSessionId,
601
+ parentSessionId: parentId,
602
+ tokenEstimate: Math.ceil(injectedContext.length / 4),
603
+ });
604
+ },
605
+ );
606
+
607
+ openApiRoute(
608
+ app,
609
+ {
610
+ method: 'post',
611
+ path: '/v1/research/{researchId}/export',
612
+ tags: ['sessions'],
613
+ operationId: 'exportResearchSession',
614
+ summary: 'Export research session to a new main session',
615
+ parameters: [
616
+ {
617
+ in: 'path',
618
+ name: 'researchId',
619
+ required: true,
620
+ schema: {
621
+ type: 'string',
622
+ },
623
+ },
624
+ {
625
+ in: 'query',
626
+ name: 'project',
627
+ required: false,
628
+ schema: {
629
+ type: 'string',
630
+ },
631
+ description:
632
+ 'Project root override (defaults to current working directory).',
633
+ },
634
+ ],
635
+ requestBody: {
636
+ required: false,
637
+ content: {
638
+ 'application/json': {
639
+ schema: {
640
+ type: 'object',
641
+ properties: {
642
+ provider: {
643
+ type: 'string',
644
+ },
645
+ model: {
646
+ type: 'string',
647
+ },
648
+ agent: {
649
+ type: 'string',
650
+ },
651
+ },
652
+ },
653
+ },
654
+ },
655
+ },
656
+ responses: {
657
+ '201': {
658
+ description: 'Created',
659
+ content: {
660
+ 'application/json': {
661
+ schema: {
662
+ type: 'object',
663
+ properties: {
664
+ newSession: {
665
+ $ref: '#/components/schemas/Session',
666
+ },
667
+ injectedContext: {
668
+ type: 'string',
669
+ },
670
+ },
671
+ required: ['newSession', 'injectedContext'],
672
+ },
673
+ },
674
+ },
675
+ },
676
+ '404': {
677
+ description: 'Bad Request',
678
+ content: {
679
+ 'application/json': {
680
+ schema: {
681
+ type: 'object',
682
+ properties: {
683
+ error: {
684
+ type: 'string',
685
+ },
686
+ },
687
+ required: ['error'],
688
+ },
689
+ },
690
+ },
691
+ },
692
+ },
693
+ },
694
+ async (c) => {
695
+ const researchId = c.req.param('researchId');
696
+ const projectRoot = c.req.query('project') || process.cwd();
697
+ const cfg = await loadConfig(projectRoot);
698
+ const db = await getDb(cfg.projectRoot);
699
+ const body = (await c.req.json().catch(() => ({}))) as Record<
700
+ string,
701
+ unknown
702
+ >;
703
+
704
+ const researchRows = await db
705
+ .select()
706
+ .from(sessions)
707
+ .where(eq(sessions.id, researchId))
708
+ .limit(1);
709
+
710
+ if (!researchRows.length || researchRows[0].sessionType !== 'research') {
711
+ return c.json({ error: 'Research session not found' }, 404);
712
+ }
713
+
714
+ const researchSession = researchRows[0];
715
+
716
+ if (researchSession.projectPath !== cfg.projectRoot) {
717
+ return c.json({ error: 'Research session not in this project' }, 404);
718
+ }
719
+
720
+ const providerCandidate =
721
+ typeof body.provider === 'string' ? body.provider : undefined;
722
+ const provider: ProviderId = (() => {
723
+ if (providerCandidate && hasConfiguredProvider(cfg, providerCandidate))
724
+ return providerCandidate;
725
+ return cfg.defaults.provider;
726
+ })();
727
+
728
+ const modelCandidate =
729
+ typeof body.model === 'string' ? body.model.trim() : undefined;
730
+ const model = modelCandidate?.length
731
+ ? modelCandidate
732
+ : cfg.defaults.model;
733
+
734
+ const agentCandidate =
735
+ typeof body.agent === 'string' ? body.agent.trim() : undefined;
736
+ const agent = agentCandidate?.length
737
+ ? agentCandidate
738
+ : cfg.defaults.agent;
739
+
740
+ const researchMessages = await db
741
+ .select({
742
+ id: messages.id,
743
+ role: messages.role,
744
+ createdAt: messages.createdAt,
745
+ })
746
+ .from(messages)
747
+ .where(eq(messages.sessionId, researchId))
748
+ .orderBy(asc(messages.createdAt));
749
+
750
+ let contextContent = '';
751
+ for (const msg of researchMessages) {
752
+ if (msg.role === 'user' || msg.role === 'assistant') {
753
+ const parts = await db
754
+ .select({ type: messageParts.type, content: messageParts.content })
755
+ .from(messageParts)
756
+ .where(eq(messageParts.messageId, msg.id))
757
+ .orderBy(asc(messageParts.index));
758
+
759
+ for (const part of parts) {
760
+ if (part.type === 'text' && part.content) {
761
+ contextContent += `[${msg.role}]: ${part.content}\n\n`;
762
+ }
316
763
  }
317
764
  }
318
765
  }
319
- }
320
-
321
- const injectedContext = `<research-context from="${researchId}" exported-at="${new Date().toISOString()}">\n${contextContent}</research-context>`;
322
-
323
- const newSessionId = crypto.randomUUID();
324
- const now = Date.now();
325
-
326
- await db.insert(sessions).values({
327
- id: newSessionId,
328
- title: researchSession.title ? `From: ${researchSession.title}` : null,
329
- agent,
330
- provider,
331
- model,
332
- projectPath: cfg.projectRoot,
333
- createdAt: now,
334
- lastActiveAt: now,
335
- parentSessionId: null,
336
- sessionType: 'main',
337
- totalInputTokens: null,
338
- totalOutputTokens: null,
339
- totalCachedTokens: null,
340
- totalCacheCreationTokens: null,
341
- totalReasoningTokens: null,
342
- totalToolTimeMs: null,
343
- toolCountsJson: null,
344
- });
345
-
346
- const msgId = crypto.randomUUID();
347
- const partId = crypto.randomUUID();
348
-
349
- await db.insert(messages).values({
350
- id: msgId,
351
- sessionId: newSessionId,
352
- role: 'system',
353
- status: 'complete',
354
- agent,
355
- provider,
356
- model,
357
- createdAt: now,
358
- completedAt: now,
359
- });
360
-
361
- await db.insert(messageParts).values({
362
- id: partId,
363
- messageId: msgId,
364
- index: 0,
365
- type: 'text',
366
- content: injectedContext,
367
- agent,
368
- provider,
369
- model,
370
- });
371
-
372
- publish({
373
- type: 'session.created',
374
- sessionId: newSessionId,
375
- payload: { id: newSessionId },
376
- });
377
-
378
- const newSession = await db
379
- .select()
380
- .from(sessions)
381
- .where(eq(sessions.id, newSessionId))
382
- .limit(1);
383
-
384
- return c.json(
385
- {
386
- newSession: newSession[0],
387
- injectedContext,
388
- },
389
- 201,
390
- );
391
- });
766
+
767
+ const injectedContext = `<research-context from="${researchId}" exported-at="${new Date().toISOString()}">\n${contextContent}</research-context>`;
768
+
769
+ const newSessionId = crypto.randomUUID();
770
+ const now = Date.now();
771
+
772
+ await db.insert(sessions).values({
773
+ id: newSessionId,
774
+ title: researchSession.title ? `From: ${researchSession.title}` : null,
775
+ agent,
776
+ provider,
777
+ model,
778
+ projectPath: cfg.projectRoot,
779
+ createdAt: now,
780
+ lastActiveAt: now,
781
+ parentSessionId: null,
782
+ sessionType: 'main',
783
+ totalInputTokens: null,
784
+ totalOutputTokens: null,
785
+ totalCachedTokens: null,
786
+ totalCacheCreationTokens: null,
787
+ totalReasoningTokens: null,
788
+ totalToolTimeMs: null,
789
+ toolCountsJson: null,
790
+ });
791
+
792
+ const msgId = crypto.randomUUID();
793
+ const partId = crypto.randomUUID();
794
+
795
+ await db.insert(messages).values({
796
+ id: msgId,
797
+ sessionId: newSessionId,
798
+ role: 'system',
799
+ status: 'complete',
800
+ agent,
801
+ provider,
802
+ model,
803
+ createdAt: now,
804
+ completedAt: now,
805
+ });
806
+
807
+ await db.insert(messageParts).values({
808
+ id: partId,
809
+ messageId: msgId,
810
+ index: 0,
811
+ type: 'text',
812
+ content: injectedContext,
813
+ agent,
814
+ provider,
815
+ model,
816
+ });
817
+
818
+ publish({
819
+ type: 'session.created',
820
+ sessionId: newSessionId,
821
+ payload: { id: newSessionId },
822
+ });
823
+
824
+ const newSession = await db
825
+ .select()
826
+ .from(sessions)
827
+ .where(eq(sessions.id, newSessionId))
828
+ .limit(1);
829
+
830
+ return c.json(
831
+ {
832
+ newSession: newSession[0],
833
+ injectedContext,
834
+ },
835
+ 201,
836
+ );
837
+ },
838
+ );
392
839
  }