@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.
Files changed (111) hide show
  1. package/package.json +42 -0
  2. package/src/events/bus.ts +43 -0
  3. package/src/events/types.ts +32 -0
  4. package/src/index.ts +281 -0
  5. package/src/openapi/helpers.ts +64 -0
  6. package/src/openapi/paths/ask.ts +70 -0
  7. package/src/openapi/paths/config.ts +218 -0
  8. package/src/openapi/paths/files.ts +72 -0
  9. package/src/openapi/paths/git.ts +457 -0
  10. package/src/openapi/paths/messages.ts +92 -0
  11. package/src/openapi/paths/sessions.ts +90 -0
  12. package/src/openapi/paths/setu.ts +154 -0
  13. package/src/openapi/paths/stream.ts +26 -0
  14. package/src/openapi/paths/terminals.ts +226 -0
  15. package/src/openapi/schemas.ts +345 -0
  16. package/src/openapi/spec.ts +49 -0
  17. package/src/presets.ts +85 -0
  18. package/src/routes/ask.ts +113 -0
  19. package/src/routes/auth.ts +592 -0
  20. package/src/routes/branch.ts +106 -0
  21. package/src/routes/config/agents.ts +44 -0
  22. package/src/routes/config/cwd.ts +21 -0
  23. package/src/routes/config/defaults.ts +45 -0
  24. package/src/routes/config/index.ts +16 -0
  25. package/src/routes/config/main.ts +73 -0
  26. package/src/routes/config/models.ts +139 -0
  27. package/src/routes/config/providers.ts +46 -0
  28. package/src/routes/config/utils.ts +120 -0
  29. package/src/routes/files.ts +218 -0
  30. package/src/routes/git/branch.ts +75 -0
  31. package/src/routes/git/commit.ts +209 -0
  32. package/src/routes/git/diff.ts +137 -0
  33. package/src/routes/git/index.ts +18 -0
  34. package/src/routes/git/push.ts +160 -0
  35. package/src/routes/git/schemas.ts +48 -0
  36. package/src/routes/git/staging.ts +208 -0
  37. package/src/routes/git/status.ts +83 -0
  38. package/src/routes/git/types.ts +31 -0
  39. package/src/routes/git/utils.ts +249 -0
  40. package/src/routes/openapi.ts +6 -0
  41. package/src/routes/research.ts +392 -0
  42. package/src/routes/root.ts +5 -0
  43. package/src/routes/session-approval.ts +63 -0
  44. package/src/routes/session-files.ts +387 -0
  45. package/src/routes/session-messages.ts +170 -0
  46. package/src/routes/session-stream.ts +61 -0
  47. package/src/routes/sessions.ts +814 -0
  48. package/src/routes/setu.ts +346 -0
  49. package/src/routes/terminals.ts +227 -0
  50. package/src/runtime/agent/registry.ts +351 -0
  51. package/src/runtime/agent/runner-reasoning.ts +108 -0
  52. package/src/runtime/agent/runner-setup.ts +257 -0
  53. package/src/runtime/agent/runner.ts +375 -0
  54. package/src/runtime/agent-registry.ts +6 -0
  55. package/src/runtime/ask/service.ts +369 -0
  56. package/src/runtime/context/environment.ts +202 -0
  57. package/src/runtime/debug/index.ts +117 -0
  58. package/src/runtime/debug/state.ts +140 -0
  59. package/src/runtime/errors/api-error.ts +192 -0
  60. package/src/runtime/errors/handling.ts +199 -0
  61. package/src/runtime/message/compaction-auto.ts +154 -0
  62. package/src/runtime/message/compaction-context.ts +101 -0
  63. package/src/runtime/message/compaction-detect.ts +26 -0
  64. package/src/runtime/message/compaction-limits.ts +37 -0
  65. package/src/runtime/message/compaction-mark.ts +111 -0
  66. package/src/runtime/message/compaction-prune.ts +75 -0
  67. package/src/runtime/message/compaction.ts +21 -0
  68. package/src/runtime/message/history-builder.ts +266 -0
  69. package/src/runtime/message/service.ts +468 -0
  70. package/src/runtime/message/tool-history-tracker.ts +204 -0
  71. package/src/runtime/prompt/builder.ts +167 -0
  72. package/src/runtime/provider/anthropic.ts +50 -0
  73. package/src/runtime/provider/copilot.ts +12 -0
  74. package/src/runtime/provider/google.ts +8 -0
  75. package/src/runtime/provider/index.ts +60 -0
  76. package/src/runtime/provider/moonshot.ts +8 -0
  77. package/src/runtime/provider/oauth-adapter.ts +237 -0
  78. package/src/runtime/provider/openai.ts +18 -0
  79. package/src/runtime/provider/opencode.ts +7 -0
  80. package/src/runtime/provider/openrouter.ts +7 -0
  81. package/src/runtime/provider/selection.ts +118 -0
  82. package/src/runtime/provider/setu.ts +126 -0
  83. package/src/runtime/provider/zai.ts +16 -0
  84. package/src/runtime/session/branch.ts +280 -0
  85. package/src/runtime/session/db-operations.ts +285 -0
  86. package/src/runtime/session/manager.ts +99 -0
  87. package/src/runtime/session/queue.ts +243 -0
  88. package/src/runtime/stream/abort-handler.ts +65 -0
  89. package/src/runtime/stream/error-handler.ts +371 -0
  90. package/src/runtime/stream/finish-handler.ts +101 -0
  91. package/src/runtime/stream/handlers.ts +5 -0
  92. package/src/runtime/stream/step-finish.ts +93 -0
  93. package/src/runtime/stream/types.ts +25 -0
  94. package/src/runtime/tools/approval.ts +180 -0
  95. package/src/runtime/tools/context.ts +83 -0
  96. package/src/runtime/tools/mapping.ts +154 -0
  97. package/src/runtime/tools/setup.ts +44 -0
  98. package/src/runtime/topup/manager.ts +110 -0
  99. package/src/runtime/utils/cwd.ts +69 -0
  100. package/src/runtime/utils/token.ts +35 -0
  101. package/src/tools/adapter.ts +634 -0
  102. package/src/tools/database/get-parent-session.ts +183 -0
  103. package/src/tools/database/get-session-context.ts +161 -0
  104. package/src/tools/database/index.ts +42 -0
  105. package/src/tools/database/present-session-links.ts +47 -0
  106. package/src/tools/database/query-messages.ts +160 -0
  107. package/src/tools/database/query-sessions.ts +126 -0
  108. package/src/tools/database/search-history.ts +135 -0
  109. package/src/types/sql-imports.d.ts +5 -0
  110. package/sst-env.d.ts +8 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,249 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { extname, join } from 'node:path';
3
+ import { promisify } from 'node:util';
4
+ import type { GitFile, GitRoot, GitError } from './types.ts';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ const LANGUAGE_MAP: Record<string, string> = {
9
+ js: 'javascript',
10
+ jsx: 'jsx',
11
+ ts: 'typescript',
12
+ tsx: 'tsx',
13
+ py: 'python',
14
+ rb: 'ruby',
15
+ go: 'go',
16
+ rs: 'rust',
17
+ java: 'java',
18
+ c: 'c',
19
+ cpp: 'cpp',
20
+ h: 'c',
21
+ hpp: 'cpp',
22
+ cs: 'csharp',
23
+ php: 'php',
24
+ sh: 'bash',
25
+ bash: 'bash',
26
+ zsh: 'bash',
27
+ sql: 'sql',
28
+ json: 'json',
29
+ yaml: 'yaml',
30
+ yml: 'yaml',
31
+ xml: 'xml',
32
+ html: 'html',
33
+ css: 'css',
34
+ scss: 'scss',
35
+ md: 'markdown',
36
+ txt: 'plaintext',
37
+ svelte: 'svelte',
38
+ };
39
+
40
+ export function inferLanguage(filePath: string): string {
41
+ const extension = extname(filePath).toLowerCase().replace('.', '');
42
+ if (!extension) {
43
+ return 'plaintext';
44
+ }
45
+ return LANGUAGE_MAP[extension] ?? 'plaintext';
46
+ }
47
+
48
+ export function summarizeDiff(diff: string): {
49
+ insertions: number;
50
+ deletions: number;
51
+ binary: boolean;
52
+ } {
53
+ let insertions = 0;
54
+ let deletions = 0;
55
+ let binary = false;
56
+
57
+ for (const line of diff.split('\n')) {
58
+ if (line.startsWith('Binary files ') || line.includes('GIT binary patch')) {
59
+ binary = true;
60
+ break;
61
+ }
62
+
63
+ if (line.startsWith('+') && !line.startsWith('+++')) {
64
+ insertions++;
65
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
66
+ deletions++;
67
+ }
68
+ }
69
+
70
+ return { insertions, deletions, binary };
71
+ }
72
+
73
+ export async function validateAndGetGitRoot(
74
+ requestedPath: string,
75
+ ): Promise<GitRoot | GitError> {
76
+ try {
77
+ const { stdout: gitRoot } = await execFileAsync(
78
+ 'git',
79
+ ['rev-parse', '--show-toplevel'],
80
+ {
81
+ cwd: requestedPath,
82
+ },
83
+ );
84
+ return { gitRoot: gitRoot.trim() };
85
+ } catch {
86
+ return {
87
+ error: 'Not a git repository',
88
+ code: 'NOT_A_GIT_REPO',
89
+ };
90
+ }
91
+ }
92
+
93
+ export async function checkIfNewFile(
94
+ gitRoot: string,
95
+ file: string,
96
+ ): Promise<boolean> {
97
+ try {
98
+ await execFileAsync('git', ['ls-files', '--error-unmatch', file], {
99
+ cwd: gitRoot,
100
+ });
101
+ return false;
102
+ } catch {
103
+ return true;
104
+ }
105
+ }
106
+
107
+ function getStatusFromCodeV2(code: string): GitFile['status'] {
108
+ switch (code) {
109
+ case 'M':
110
+ return 'modified';
111
+ case 'A':
112
+ return 'added';
113
+ case 'D':
114
+ return 'deleted';
115
+ case 'R':
116
+ return 'renamed';
117
+ case 'C':
118
+ return 'modified';
119
+ default:
120
+ return 'modified';
121
+ }
122
+ }
123
+
124
+ function getConflictType(xy: string): GitFile['conflictType'] {
125
+ switch (xy) {
126
+ case 'UU':
127
+ return 'both-modified';
128
+ case 'AA':
129
+ return 'both-added';
130
+ case 'DD':
131
+ return 'both-deleted';
132
+ case 'DU':
133
+ case 'UD':
134
+ return 'deleted-by-us';
135
+ case 'AU':
136
+ case 'UA':
137
+ return 'deleted-by-them';
138
+ default:
139
+ return 'both-modified';
140
+ }
141
+ }
142
+
143
+ export function parseGitStatus(
144
+ statusOutput: string,
145
+ gitRoot: string,
146
+ ): {
147
+ staged: GitFile[];
148
+ unstaged: GitFile[];
149
+ untracked: GitFile[];
150
+ conflicted: GitFile[];
151
+ } {
152
+ const lines = statusOutput.trim().split('\n').filter(Boolean);
153
+ const staged: GitFile[] = [];
154
+ const unstaged: GitFile[] = [];
155
+ const untracked: GitFile[] = [];
156
+ const conflicted: GitFile[] = [];
157
+
158
+ for (const line of lines) {
159
+ if (line.startsWith('1 ') || line.startsWith('2 ')) {
160
+ const parts = line.split(' ');
161
+ if (parts.length < 9) continue;
162
+
163
+ const xy = parts[1];
164
+ const x = xy[0];
165
+ const y = xy[1];
166
+ const path = parts.slice(8).join(' ');
167
+ const absPath = join(gitRoot, path);
168
+
169
+ if (x !== '.') {
170
+ staged.push({
171
+ path,
172
+ absPath,
173
+ status: getStatusFromCodeV2(x),
174
+ staged: true,
175
+ isNew: x === 'A',
176
+ });
177
+ }
178
+
179
+ if (y !== '.') {
180
+ unstaged.push({
181
+ path,
182
+ absPath,
183
+ status: getStatusFromCodeV2(y),
184
+ staged: false,
185
+ isNew: false,
186
+ });
187
+ }
188
+ } else if (line.startsWith('? ')) {
189
+ const path = line.slice(2);
190
+ const absPath = join(gitRoot, path);
191
+ untracked.push({
192
+ path,
193
+ absPath,
194
+ status: 'untracked',
195
+ staged: false,
196
+ isNew: true,
197
+ });
198
+ } else if (line.startsWith('u ')) {
199
+ const parts = line.split(' ');
200
+ if (parts.length < 11) continue;
201
+
202
+ const xy = parts[1];
203
+ const path = parts.slice(10).join(' ');
204
+ const absPath = join(gitRoot, path);
205
+
206
+ conflicted.push({
207
+ path,
208
+ absPath,
209
+ status: 'conflicted',
210
+ staged: false,
211
+ isNew: false,
212
+ conflictType: getConflictType(xy),
213
+ });
214
+ }
215
+ }
216
+
217
+ return { staged, unstaged, untracked, conflicted };
218
+ }
219
+
220
+ export async function getAheadBehind(
221
+ gitRoot: string,
222
+ ): Promise<{ ahead: number; behind: number }> {
223
+ try {
224
+ const { stdout } = await execFileAsync(
225
+ 'git',
226
+ ['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'],
227
+ { cwd: gitRoot },
228
+ );
229
+ const [ahead, behind] = stdout.trim().split(/\s+/).map(Number);
230
+ return { ahead: ahead || 0, behind: behind || 0 };
231
+ } catch {
232
+ return { ahead: 0, behind: 0 };
233
+ }
234
+ }
235
+
236
+ export async function getCurrentBranch(gitRoot: string): Promise<string> {
237
+ try {
238
+ const { stdout } = await execFileAsync(
239
+ 'git',
240
+ ['branch', '--show-current'],
241
+ {
242
+ cwd: gitRoot,
243
+ },
244
+ );
245
+ return stdout.trim();
246
+ } catch {
247
+ return 'unknown';
248
+ }
249
+ }
@@ -0,0 +1,6 @@
1
+ import type { Hono } from 'hono';
2
+ import { getOpenAPISpec } from '../openapi/spec.ts';
3
+
4
+ export function registerOpenApiRoute(app: Hono) {
5
+ app.get('/openapi.json', (c) => c.json(getOpenAPISpec()));
6
+ }
@@ -0,0 +1,392 @@
1
+ import type { Hono } from 'hono';
2
+ import { loadConfig } from '@ottocode/sdk';
3
+ import { getDb } from '@ottocode/database';
4
+ import { sessions, messages, messageParts } from '@ottocode/database/schema';
5
+ import { desc, eq, and, asc, count } from 'drizzle-orm';
6
+ import type { ProviderId } from '@ottocode/sdk';
7
+ import { isProviderId } from '@ottocode/sdk';
8
+ import { serializeError } from '../runtime/errors/api-error.ts';
9
+ import { logger } from '@ottocode/sdk';
10
+ import { publish } from '../events/bus.ts';
11
+
12
+ 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 && isProviderId(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,
157
+ );
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
196
+ .select()
197
+ .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`;
234
+ }
235
+ }
236
+ }
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 && isProviderId(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`;
316
+ }
317
+ }
318
+ }
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
+ });
392
+ }
@@ -0,0 +1,5 @@
1
+ import type { Hono } from 'hono';
2
+
3
+ export function registerRootRoutes(app: Hono) {
4
+ app.get('/', (c) => c.text('otto server running'));
5
+ }
@@ -0,0 +1,63 @@
1
+ import type { Hono } from 'hono';
2
+ import {
3
+ resolveApproval,
4
+ getPendingApproval,
5
+ getPendingApprovalsForSession,
6
+ } from '../runtime/tools/approval.ts';
7
+
8
+ export function registerSessionApprovalRoute(app: Hono) {
9
+ app.post('/v1/sessions/:id/approval', async (c) => {
10
+ const sessionId = c.req.param('id');
11
+ const body = await c.req.json<{
12
+ callId: string;
13
+ approved: boolean;
14
+ }>();
15
+
16
+ if (!body.callId) {
17
+ return c.json({ ok: false, error: 'callId is required' }, 400);
18
+ }
19
+
20
+ if (typeof body.approved !== 'boolean') {
21
+ return c.json({ ok: false, error: 'approved must be a boolean' }, 400);
22
+ }
23
+
24
+ const pending = getPendingApproval(body.callId);
25
+ if (!pending) {
26
+ return c.json(
27
+ { ok: false, error: 'No pending approval found for this callId' },
28
+ 404,
29
+ );
30
+ }
31
+
32
+ if (pending.sessionId !== sessionId) {
33
+ return c.json(
34
+ { ok: false, error: 'Approval does not belong to this session' },
35
+ 403,
36
+ );
37
+ }
38
+
39
+ const result = resolveApproval(body.callId, body.approved);
40
+
41
+ if (!result.ok) {
42
+ return c.json(result, 404);
43
+ }
44
+
45
+ return c.json({ ok: true, callId: body.callId, approved: body.approved });
46
+ });
47
+
48
+ app.get('/v1/sessions/:id/approval/pending', async (c) => {
49
+ const sessionId = c.req.param('id');
50
+ const pending = getPendingApprovalsForSession(sessionId);
51
+
52
+ return c.json({
53
+ ok: true,
54
+ pending: pending.map((p) => ({
55
+ callId: p.callId,
56
+ toolName: p.toolName,
57
+ args: p.args,
58
+ messageId: p.messageId,
59
+ createdAt: p.createdAt,
60
+ })),
61
+ });
62
+ });
63
+ }