@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,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,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,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
|
+
}
|