@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,218 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
import { exec } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { serializeError } from '../runtime/errors/api-error.ts';
|
|
7
|
+
import { logger } from '@ottocode/sdk';
|
|
8
|
+
|
|
9
|
+
const execAsync = promisify(exec);
|
|
10
|
+
|
|
11
|
+
const EXCLUDED_PATTERNS = [
|
|
12
|
+
'node_modules',
|
|
13
|
+
'.git',
|
|
14
|
+
'dist',
|
|
15
|
+
'build',
|
|
16
|
+
'.next',
|
|
17
|
+
'.nuxt',
|
|
18
|
+
'.turbo',
|
|
19
|
+
'coverage',
|
|
20
|
+
'.cache',
|
|
21
|
+
'.DS_Store',
|
|
22
|
+
'bun.lockb',
|
|
23
|
+
'.env',
|
|
24
|
+
'.env.local',
|
|
25
|
+
'.env.production',
|
|
26
|
+
'.env.development',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function shouldExclude(name: string): boolean {
|
|
30
|
+
for (const pattern of EXCLUDED_PATTERNS) {
|
|
31
|
+
if (pattern.includes('*')) {
|
|
32
|
+
const regex = new RegExp(`^${pattern.replace(/\*/g, '.*')}$`);
|
|
33
|
+
if (regex.test(name)) return true;
|
|
34
|
+
} else if (name === pattern || name.endsWith(pattern)) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function parseGitignore(projectRoot: string): Promise<Set<string>> {
|
|
42
|
+
const patterns = new Set<string>();
|
|
43
|
+
try {
|
|
44
|
+
const gitignorePath = join(projectRoot, '.gitignore');
|
|
45
|
+
const content = await readFile(gitignorePath, 'utf-8');
|
|
46
|
+
for (const line of content.split('\n')) {
|
|
47
|
+
const trimmed = line.trim();
|
|
48
|
+
if (trimmed && !trimmed.startsWith('#')) {
|
|
49
|
+
patterns.add(trimmed);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch (_err) {}
|
|
53
|
+
return patterns;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function matchesGitignorePattern(
|
|
57
|
+
relativePath: string,
|
|
58
|
+
patterns: Set<string>,
|
|
59
|
+
): boolean {
|
|
60
|
+
for (const pattern of patterns) {
|
|
61
|
+
const cleanPattern = pattern.replace(/^\//, '').replace(/\/$/, '');
|
|
62
|
+
const pathParts = relativePath.split('/');
|
|
63
|
+
|
|
64
|
+
if (pattern.endsWith('/')) {
|
|
65
|
+
if (pathParts[0] === cleanPattern) return true;
|
|
66
|
+
if (relativePath.startsWith(`${cleanPattern}/`)) return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (pattern.includes('*')) {
|
|
70
|
+
const regex = new RegExp(
|
|
71
|
+
`^${cleanPattern.replace(/\*/g, '.*').replace(/\?/g, '.')}$`,
|
|
72
|
+
);
|
|
73
|
+
if (regex.test(relativePath)) return true;
|
|
74
|
+
for (const part of pathParts) {
|
|
75
|
+
if (regex.test(part)) return true;
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
if (relativePath === cleanPattern) return true;
|
|
79
|
+
if (pathParts.includes(cleanPattern)) return true;
|
|
80
|
+
if (relativePath.startsWith(`${cleanPattern}/`)) return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function traverseDirectory(
|
|
87
|
+
dir: string,
|
|
88
|
+
projectRoot: string,
|
|
89
|
+
maxDepth: number,
|
|
90
|
+
currentDepth = 0,
|
|
91
|
+
limit: number,
|
|
92
|
+
collected: string[] = [],
|
|
93
|
+
gitignorePatterns?: Set<string>,
|
|
94
|
+
): Promise<{ files: string[]; truncated: boolean }> {
|
|
95
|
+
if (currentDepth >= maxDepth || collected.length >= limit) {
|
|
96
|
+
return { files: collected, truncated: collected.length >= limit };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
101
|
+
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (collected.length >= limit) {
|
|
104
|
+
return { files: collected, truncated: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (shouldExclude(entry.name)) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const fullPath = join(dir, entry.name);
|
|
112
|
+
const relativePath = relative(projectRoot, fullPath);
|
|
113
|
+
|
|
114
|
+
if (
|
|
115
|
+
gitignorePatterns &&
|
|
116
|
+
matchesGitignorePattern(relativePath, gitignorePatterns)
|
|
117
|
+
) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (entry.isDirectory()) {
|
|
122
|
+
const result = await traverseDirectory(
|
|
123
|
+
fullPath,
|
|
124
|
+
projectRoot,
|
|
125
|
+
maxDepth,
|
|
126
|
+
currentDepth + 1,
|
|
127
|
+
limit,
|
|
128
|
+
collected,
|
|
129
|
+
gitignorePatterns,
|
|
130
|
+
);
|
|
131
|
+
if (result.truncated) {
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
} else if (entry.isFile()) {
|
|
135
|
+
collected.push(relativePath);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
logger.warn(`Failed to read directory ${dir}:`, err);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { files: collected, truncated: false };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function getChangedFiles(
|
|
146
|
+
projectRoot: string,
|
|
147
|
+
): Promise<Map<string, string>> {
|
|
148
|
+
try {
|
|
149
|
+
const { stdout } = await execAsync('git status --porcelain', {
|
|
150
|
+
cwd: projectRoot,
|
|
151
|
+
});
|
|
152
|
+
const changedFiles = new Map<string, string>();
|
|
153
|
+
for (const line of stdout.split('\n')) {
|
|
154
|
+
if (line.length > 3) {
|
|
155
|
+
const statusCode = line.substring(0, 2).trim();
|
|
156
|
+
const filePath = line.substring(3).trim();
|
|
157
|
+
|
|
158
|
+
let status = 'modified';
|
|
159
|
+
if (statusCode.includes('A')) status = 'added';
|
|
160
|
+
else if (statusCode.includes('M')) status = 'modified';
|
|
161
|
+
else if (statusCode.includes('D')) status = 'deleted';
|
|
162
|
+
else if (statusCode.includes('R')) status = 'renamed';
|
|
163
|
+
else if (statusCode.includes('?')) status = 'untracked';
|
|
164
|
+
|
|
165
|
+
changedFiles.set(filePath, status);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return changedFiles;
|
|
169
|
+
} catch (_err) {
|
|
170
|
+
return new Set();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function registerFilesRoutes(app: Hono) {
|
|
175
|
+
app.get('/v1/files', async (c) => {
|
|
176
|
+
try {
|
|
177
|
+
const projectRoot = c.req.query('project') || process.cwd();
|
|
178
|
+
const maxDepth = Number.parseInt(c.req.query('maxDepth') || '10', 10);
|
|
179
|
+
const limit = Number.parseInt(c.req.query('limit') || '1000', 10);
|
|
180
|
+
|
|
181
|
+
const gitignorePatterns = await parseGitignore(projectRoot);
|
|
182
|
+
|
|
183
|
+
const result = await traverseDirectory(
|
|
184
|
+
projectRoot,
|
|
185
|
+
projectRoot,
|
|
186
|
+
maxDepth,
|
|
187
|
+
0,
|
|
188
|
+
limit,
|
|
189
|
+
[],
|
|
190
|
+
gitignorePatterns,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const changedFiles = await getChangedFiles(projectRoot);
|
|
194
|
+
|
|
195
|
+
result.files.sort((a, b) => {
|
|
196
|
+
const aChanged = changedFiles.has(a);
|
|
197
|
+
const bChanged = changedFiles.has(b);
|
|
198
|
+
if (aChanged && !bChanged) return -1;
|
|
199
|
+
if (!aChanged && bChanged) return 1;
|
|
200
|
+
return a.localeCompare(b);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
return c.json({
|
|
204
|
+
files: result.files,
|
|
205
|
+
changedFiles: Array.from(changedFiles.entries()).map(
|
|
206
|
+
([path, status]) => ({
|
|
207
|
+
path,
|
|
208
|
+
status,
|
|
209
|
+
}),
|
|
210
|
+
),
|
|
211
|
+
truncated: result.truncated,
|
|
212
|
+
});
|
|
213
|
+
} catch (err) {
|
|
214
|
+
logger.error('Files route error:', err);
|
|
215
|
+
return c.json({ error: serializeError(err) }, 500);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { gitStatusSchema } from './schemas.ts';
|
|
5
|
+
import {
|
|
6
|
+
validateAndGetGitRoot,
|
|
7
|
+
getAheadBehind,
|
|
8
|
+
getCurrentBranch,
|
|
9
|
+
} from './utils.ts';
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
export function registerBranchRoute(app: Hono) {
|
|
14
|
+
app.get('/v1/git/branch', async (c) => {
|
|
15
|
+
try {
|
|
16
|
+
const query = gitStatusSchema.parse({
|
|
17
|
+
project: c.req.query('project'),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const requestedPath = query.project || process.cwd();
|
|
21
|
+
|
|
22
|
+
const validation = await validateAndGetGitRoot(requestedPath);
|
|
23
|
+
if ('error' in validation) {
|
|
24
|
+
return c.json(
|
|
25
|
+
{ status: 'error', error: validation.error, code: validation.code },
|
|
26
|
+
400,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { gitRoot } = validation;
|
|
31
|
+
|
|
32
|
+
const branch = await getCurrentBranch(gitRoot);
|
|
33
|
+
|
|
34
|
+
const { ahead, behind } = await getAheadBehind(gitRoot);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const { stdout: remotes } = await execFileAsync('git', ['remote'], {
|
|
38
|
+
cwd: gitRoot,
|
|
39
|
+
});
|
|
40
|
+
const remoteList = remotes.trim().split('\n').filter(Boolean);
|
|
41
|
+
|
|
42
|
+
return c.json({
|
|
43
|
+
status: 'ok',
|
|
44
|
+
data: {
|
|
45
|
+
branch,
|
|
46
|
+
ahead,
|
|
47
|
+
behind,
|
|
48
|
+
remotes: remoteList,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
} catch {
|
|
52
|
+
return c.json({
|
|
53
|
+
status: 'ok',
|
|
54
|
+
data: {
|
|
55
|
+
branch,
|
|
56
|
+
ahead,
|
|
57
|
+
behind,
|
|
58
|
+
remotes: [],
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return c.json(
|
|
64
|
+
{
|
|
65
|
+
status: 'error',
|
|
66
|
+
error:
|
|
67
|
+
error instanceof Error
|
|
68
|
+
? error.message
|
|
69
|
+
: 'Failed to get branch info',
|
|
70
|
+
},
|
|
71
|
+
500,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { generateText, streamText } from 'ai';
|
|
5
|
+
import { eq } from 'drizzle-orm';
|
|
6
|
+
import type { ProviderId } from '@ottocode/sdk';
|
|
7
|
+
import { loadConfig, getAuth, getFastModelForAuth } from '@ottocode/sdk';
|
|
8
|
+
import { getDb } from '@ottocode/database';
|
|
9
|
+
import { sessions } from '@ottocode/database/schema';
|
|
10
|
+
import { gitCommitSchema, gitGenerateCommitMessageSchema } from './schemas.ts';
|
|
11
|
+
import { validateAndGetGitRoot, parseGitStatus } from './utils.ts';
|
|
12
|
+
import { resolveModel } from '../../runtime/provider/index.ts';
|
|
13
|
+
import { debugLog } from '../../runtime/debug/index.ts';
|
|
14
|
+
import {
|
|
15
|
+
detectOAuth,
|
|
16
|
+
adaptSimpleCall,
|
|
17
|
+
} from '../../runtime/provider/oauth-adapter.ts';
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFile);
|
|
20
|
+
|
|
21
|
+
export function registerCommitRoutes(app: Hono) {
|
|
22
|
+
app.post('/v1/git/commit', async (c) => {
|
|
23
|
+
try {
|
|
24
|
+
const body = await c.req.json();
|
|
25
|
+
const { message, project } = gitCommitSchema.parse(body);
|
|
26
|
+
|
|
27
|
+
const requestedPath = project || process.cwd();
|
|
28
|
+
|
|
29
|
+
const validation = await validateAndGetGitRoot(requestedPath);
|
|
30
|
+
if ('error' in validation) {
|
|
31
|
+
return c.json(
|
|
32
|
+
{ status: 'error', error: validation.error, code: validation.code },
|
|
33
|
+
400,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { gitRoot } = validation;
|
|
38
|
+
|
|
39
|
+
const { stdout } = await execFileAsync('git', ['commit', '-m', message], {
|
|
40
|
+
cwd: gitRoot,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return c.json({
|
|
44
|
+
status: 'ok',
|
|
45
|
+
data: {
|
|
46
|
+
message: stdout.trim(),
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return c.json(
|
|
51
|
+
{
|
|
52
|
+
status: 'error',
|
|
53
|
+
error: error instanceof Error ? error.message : 'Failed to commit',
|
|
54
|
+
},
|
|
55
|
+
500,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
app.post('/v1/git/generate-commit-message', async (c) => {
|
|
61
|
+
try {
|
|
62
|
+
const body = await c.req.json();
|
|
63
|
+
const { project, sessionId } = gitGenerateCommitMessageSchema.parse(body);
|
|
64
|
+
|
|
65
|
+
const requestedPath = project || process.cwd();
|
|
66
|
+
|
|
67
|
+
const validation = await validateAndGetGitRoot(requestedPath);
|
|
68
|
+
if ('error' in validation) {
|
|
69
|
+
return c.json(
|
|
70
|
+
{ status: 'error', error: validation.error, code: validation.code },
|
|
71
|
+
400,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { gitRoot } = validation;
|
|
76
|
+
|
|
77
|
+
const { stdout: diff } = await execFileAsync(
|
|
78
|
+
'git',
|
|
79
|
+
['diff', '--cached'],
|
|
80
|
+
{
|
|
81
|
+
cwd: gitRoot,
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (!diff.trim()) {
|
|
86
|
+
return c.json(
|
|
87
|
+
{
|
|
88
|
+
status: 'error',
|
|
89
|
+
error: 'No staged changes to generate message from',
|
|
90
|
+
},
|
|
91
|
+
400,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { stdout: statusOutput } = await execFileAsync(
|
|
96
|
+
'git',
|
|
97
|
+
['status', '--porcelain=v2'],
|
|
98
|
+
{ cwd: gitRoot },
|
|
99
|
+
);
|
|
100
|
+
const { staged } = parseGitStatus(statusOutput, gitRoot);
|
|
101
|
+
const fileList = staged.map((f) => `${f.status}: ${f.path}`).join('\n');
|
|
102
|
+
|
|
103
|
+
const config = await loadConfig();
|
|
104
|
+
|
|
105
|
+
let provider = (config.defaults?.provider || 'anthropic') as ProviderId;
|
|
106
|
+
|
|
107
|
+
if (sessionId) {
|
|
108
|
+
const db = await getDb();
|
|
109
|
+
const [session] = await db
|
|
110
|
+
.select({ provider: sessions.provider })
|
|
111
|
+
.from(sessions)
|
|
112
|
+
.where(eq(sessions.id, sessionId));
|
|
113
|
+
if (session?.provider) {
|
|
114
|
+
provider = session.provider as ProviderId;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const auth = await getAuth(provider, config.projectRoot);
|
|
119
|
+
const oauth = detectOAuth(provider, auth);
|
|
120
|
+
|
|
121
|
+
const modelId =
|
|
122
|
+
getFastModelForAuth(provider, auth?.type) ??
|
|
123
|
+
config.defaults?.model ??
|
|
124
|
+
'claude-3-5-sonnet-20241022';
|
|
125
|
+
const model = await resolveModel(provider, modelId, config);
|
|
126
|
+
|
|
127
|
+
const userPrompt = `Generate a commit message for these git changes.
|
|
128
|
+
|
|
129
|
+
Staged files:
|
|
130
|
+
${fileList}
|
|
131
|
+
|
|
132
|
+
Diff (first 4000 chars):
|
|
133
|
+
${diff.slice(0, 4000)}
|
|
134
|
+
|
|
135
|
+
Guidelines:
|
|
136
|
+
- CAREFULLY READ the diff above - describe what ACTUALLY changed
|
|
137
|
+
- Use conventional commits format: type(scope): description
|
|
138
|
+
- First line under 72 characters
|
|
139
|
+
- Add a blank line, then 2-4 short bullet points
|
|
140
|
+
- Each bullet describes ONE specific change you see in the diff
|
|
141
|
+
- Be ACCURATE - don't invent changes that aren't in the diff
|
|
142
|
+
- Keep bullets short (under 80 chars each)
|
|
143
|
+
- Do not include markdown code blocks or backticks
|
|
144
|
+
- Return ONLY the commit message text, nothing else
|
|
145
|
+
|
|
146
|
+
Example (for a diff that adds boolean returns to functions):
|
|
147
|
+
refactor(auth): return success status from login functions
|
|
148
|
+
|
|
149
|
+
- Add boolean return type to auth functions
|
|
150
|
+
- Return false on user cancellation or failure
|
|
151
|
+
- Check return value before proceeding with auth flow
|
|
152
|
+
|
|
153
|
+
Commit message:`;
|
|
154
|
+
|
|
155
|
+
const commitInstructions =
|
|
156
|
+
'You are a helpful assistant that generates accurate git commit messages based on the actual diff content.';
|
|
157
|
+
|
|
158
|
+
const adapted = adaptSimpleCall(oauth, {
|
|
159
|
+
instructions: commitInstructions,
|
|
160
|
+
userContent: userPrompt,
|
|
161
|
+
maxOutputTokens: 500,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (adapted.forceStream) {
|
|
165
|
+
debugLog('[COMMIT] Using streamText for OpenAI OAuth');
|
|
166
|
+
const result = streamText({
|
|
167
|
+
model,
|
|
168
|
+
system: adapted.system,
|
|
169
|
+
messages: adapted.messages,
|
|
170
|
+
providerOptions: adapted.providerOptions,
|
|
171
|
+
});
|
|
172
|
+
let text = '';
|
|
173
|
+
for await (const chunk of result.textStream) {
|
|
174
|
+
text += chunk;
|
|
175
|
+
}
|
|
176
|
+
const message = text.trim();
|
|
177
|
+
debugLog(`[COMMIT] OAuth result: "${message.slice(0, 80)}..."`);
|
|
178
|
+
return c.json({ status: 'ok', data: { message } });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const { text } = await generateText({
|
|
182
|
+
model,
|
|
183
|
+
system: adapted.system,
|
|
184
|
+
messages: adapted.messages,
|
|
185
|
+
maxOutputTokens: adapted.maxOutputTokens,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const message = text.trim();
|
|
189
|
+
|
|
190
|
+
return c.json({
|
|
191
|
+
status: 'ok',
|
|
192
|
+
data: {
|
|
193
|
+
message,
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return c.json(
|
|
198
|
+
{
|
|
199
|
+
status: 'error',
|
|
200
|
+
error:
|
|
201
|
+
error instanceof Error
|
|
202
|
+
? error.message
|
|
203
|
+
: 'Failed to generate commit message',
|
|
204
|
+
},
|
|
205
|
+
500,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { gitDiffSchema } from './schemas.ts';
|
|
7
|
+
import {
|
|
8
|
+
validateAndGetGitRoot,
|
|
9
|
+
checkIfNewFile,
|
|
10
|
+
inferLanguage,
|
|
11
|
+
summarizeDiff,
|
|
12
|
+
} from './utils.ts';
|
|
13
|
+
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
export function registerDiffRoute(app: Hono) {
|
|
17
|
+
app.get('/v1/git/diff', async (c) => {
|
|
18
|
+
try {
|
|
19
|
+
const query = gitDiffSchema.parse({
|
|
20
|
+
project: c.req.query('project'),
|
|
21
|
+
file: c.req.query('file'),
|
|
22
|
+
staged: c.req.query('staged'),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const requestedPath = query.project || process.cwd();
|
|
26
|
+
|
|
27
|
+
const validation = await validateAndGetGitRoot(requestedPath);
|
|
28
|
+
if ('error' in validation) {
|
|
29
|
+
return c.json(
|
|
30
|
+
{ status: 'error', error: validation.error, code: validation.code },
|
|
31
|
+
400,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { gitRoot } = validation;
|
|
36
|
+
const absPath = join(gitRoot, query.file);
|
|
37
|
+
|
|
38
|
+
const isNewFile = await checkIfNewFile(gitRoot, query.file);
|
|
39
|
+
|
|
40
|
+
if (isNewFile) {
|
|
41
|
+
try {
|
|
42
|
+
const content = await readFile(absPath, 'utf-8');
|
|
43
|
+
const lineCount = content.split('\n').length;
|
|
44
|
+
const language = inferLanguage(query.file);
|
|
45
|
+
|
|
46
|
+
return c.json({
|
|
47
|
+
status: 'ok',
|
|
48
|
+
data: {
|
|
49
|
+
file: query.file,
|
|
50
|
+
absPath,
|
|
51
|
+
diff: '',
|
|
52
|
+
content,
|
|
53
|
+
isNewFile: true,
|
|
54
|
+
isBinary: false,
|
|
55
|
+
insertions: lineCount,
|
|
56
|
+
deletions: 0,
|
|
57
|
+
language,
|
|
58
|
+
staged: !!query.staged,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return c.json(
|
|
63
|
+
{
|
|
64
|
+
status: 'error',
|
|
65
|
+
error:
|
|
66
|
+
error instanceof Error ? error.message : 'Failed to read file',
|
|
67
|
+
},
|
|
68
|
+
500,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const diffArgs = query.staged
|
|
74
|
+
? ['diff', '--cached', '--', query.file]
|
|
75
|
+
: ['diff', '--', query.file];
|
|
76
|
+
const numstatArgs = query.staged
|
|
77
|
+
? ['diff', '--cached', '--numstat', '--', query.file]
|
|
78
|
+
: ['diff', '--numstat', '--', query.file];
|
|
79
|
+
|
|
80
|
+
const [{ stdout: diffOutput }, { stdout: numstatOutput }] =
|
|
81
|
+
await Promise.all([
|
|
82
|
+
execFileAsync('git', diffArgs, { cwd: gitRoot }),
|
|
83
|
+
execFileAsync('git', numstatArgs, { cwd: gitRoot }),
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
let insertions = 0;
|
|
87
|
+
let deletions = 0;
|
|
88
|
+
let binary = false;
|
|
89
|
+
|
|
90
|
+
const numstatLine = numstatOutput.trim().split('\n').find(Boolean);
|
|
91
|
+
if (numstatLine) {
|
|
92
|
+
const [rawInsertions, rawDeletions] = numstatLine.split('\t');
|
|
93
|
+
if (rawInsertions === '-' || rawDeletions === '-') {
|
|
94
|
+
binary = true;
|
|
95
|
+
} else {
|
|
96
|
+
insertions = Number.parseInt(rawInsertions, 10) || 0;
|
|
97
|
+
deletions = Number.parseInt(rawDeletions, 10) || 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const diffText = diffOutput ?? '';
|
|
102
|
+
if (!binary) {
|
|
103
|
+
const summary = summarizeDiff(diffText);
|
|
104
|
+
binary = summary.binary;
|
|
105
|
+
if (insertions === 0 && deletions === 0) {
|
|
106
|
+
insertions = summary.insertions;
|
|
107
|
+
deletions = summary.deletions;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const language = inferLanguage(query.file);
|
|
112
|
+
|
|
113
|
+
return c.json({
|
|
114
|
+
status: 'ok',
|
|
115
|
+
data: {
|
|
116
|
+
file: query.file,
|
|
117
|
+
absPath,
|
|
118
|
+
diff: diffText,
|
|
119
|
+
isNewFile: false,
|
|
120
|
+
isBinary: binary,
|
|
121
|
+
insertions,
|
|
122
|
+
deletions,
|
|
123
|
+
language,
|
|
124
|
+
staged: !!query.staged,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return c.json(
|
|
129
|
+
{
|
|
130
|
+
status: 'error',
|
|
131
|
+
error: error instanceof Error ? error.message : 'Failed to get diff',
|
|
132
|
+
},
|
|
133
|
+
500,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import { registerStatusRoute } from './status.ts';
|
|
3
|
+
import { registerBranchRoute } from './branch.ts';
|
|
4
|
+
import { registerDiffRoute } from './diff.ts';
|
|
5
|
+
import { registerStagingRoutes } from './staging.ts';
|
|
6
|
+
import { registerCommitRoutes } from './commit.ts';
|
|
7
|
+
import { registerPushRoute } from './push.ts';
|
|
8
|
+
|
|
9
|
+
export type { GitFile } from './types.ts';
|
|
10
|
+
|
|
11
|
+
export function registerGitRoutes(app: Hono) {
|
|
12
|
+
registerStatusRoute(app);
|
|
13
|
+
registerBranchRoute(app);
|
|
14
|
+
registerDiffRoute(app);
|
|
15
|
+
registerStagingRoutes(app);
|
|
16
|
+
registerCommitRoutes(app);
|
|
17
|
+
registerPushRoute(app);
|
|
18
|
+
}
|