@ottocode/server 0.1.205 → 0.1.207
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 +3 -3
- package/src/openapi/paths/git.ts +135 -0
- package/src/openapi/schemas.ts +7 -0
- package/src/routes/files.ts +41 -0
- package/src/routes/git/index.ts +2 -0
- package/src/routes/git/remote.ts +121 -0
- package/src/routes/git/schemas.ts +11 -0
- package/src/routes/git/status.ts +22 -0
- package/src/routes/mcp.ts +204 -3
- package/src/runtime/agent/mcp-prepare-step.ts +69 -0
- package/src/runtime/agent/runner-setup.ts +14 -5
- package/src/runtime/agent/runner.ts +48 -1
- package/src/runtime/message/tool-history-tracker.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ottocode/server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.207",
|
|
4
4
|
"description": "HTTP API server for ottocode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"typecheck": "tsc --noEmit"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@ottocode/sdk": "0.1.
|
|
53
|
-
"@ottocode/database": "0.1.
|
|
52
|
+
"@ottocode/sdk": "0.1.207",
|
|
53
|
+
"@ottocode/database": "0.1.207",
|
|
54
54
|
"drizzle-orm": "^0.44.5",
|
|
55
55
|
"hono": "^4.9.9",
|
|
56
56
|
"zod": "^4.1.8"
|
package/src/openapi/paths/git.ts
CHANGED
|
@@ -500,4 +500,139 @@ export const gitPaths = {
|
|
|
500
500
|
},
|
|
501
501
|
},
|
|
502
502
|
},
|
|
503
|
+
'/v1/git/remotes': {
|
|
504
|
+
get: {
|
|
505
|
+
tags: ['git'],
|
|
506
|
+
operationId: 'getGitRemotes',
|
|
507
|
+
summary: 'List git remotes',
|
|
508
|
+
parameters: [projectQueryParam()],
|
|
509
|
+
responses: {
|
|
510
|
+
200: {
|
|
511
|
+
description: 'OK',
|
|
512
|
+
content: {
|
|
513
|
+
'application/json': {
|
|
514
|
+
schema: {
|
|
515
|
+
type: 'object',
|
|
516
|
+
properties: {
|
|
517
|
+
status: { type: 'string', enum: ['ok'] },
|
|
518
|
+
data: {
|
|
519
|
+
type: 'object',
|
|
520
|
+
properties: {
|
|
521
|
+
remotes: {
|
|
522
|
+
type: 'array',
|
|
523
|
+
items: {
|
|
524
|
+
type: 'object',
|
|
525
|
+
properties: {
|
|
526
|
+
name: { type: 'string' },
|
|
527
|
+
url: { type: 'string' },
|
|
528
|
+
type: { type: 'string' },
|
|
529
|
+
},
|
|
530
|
+
required: ['name', 'url', 'type'],
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
required: ['remotes'],
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
required: ['status', 'data'],
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
400: gitErrorResponse(),
|
|
543
|
+
500: gitErrorResponse(),
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
post: {
|
|
547
|
+
tags: ['git'],
|
|
548
|
+
operationId: 'addGitRemote',
|
|
549
|
+
summary: 'Add a git remote',
|
|
550
|
+
requestBody: {
|
|
551
|
+
required: true,
|
|
552
|
+
content: {
|
|
553
|
+
'application/json': {
|
|
554
|
+
schema: {
|
|
555
|
+
type: 'object',
|
|
556
|
+
properties: {
|
|
557
|
+
project: { type: 'string' },
|
|
558
|
+
name: { type: 'string' },
|
|
559
|
+
url: { type: 'string' },
|
|
560
|
+
},
|
|
561
|
+
required: ['name', 'url'],
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
responses: {
|
|
567
|
+
200: {
|
|
568
|
+
description: 'OK',
|
|
569
|
+
content: {
|
|
570
|
+
'application/json': {
|
|
571
|
+
schema: {
|
|
572
|
+
type: 'object',
|
|
573
|
+
properties: {
|
|
574
|
+
status: { type: 'string', enum: ['ok'] },
|
|
575
|
+
data: {
|
|
576
|
+
type: 'object',
|
|
577
|
+
properties: {
|
|
578
|
+
name: { type: 'string' },
|
|
579
|
+
url: { type: 'string' },
|
|
580
|
+
},
|
|
581
|
+
required: ['name', 'url'],
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
required: ['status', 'data'],
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
400: gitErrorResponse(),
|
|
590
|
+
500: gitErrorResponse(),
|
|
591
|
+
},
|
|
592
|
+
},
|
|
593
|
+
delete: {
|
|
594
|
+
tags: ['git'],
|
|
595
|
+
operationId: 'removeGitRemote',
|
|
596
|
+
summary: 'Remove a git remote',
|
|
597
|
+
requestBody: {
|
|
598
|
+
required: true,
|
|
599
|
+
content: {
|
|
600
|
+
'application/json': {
|
|
601
|
+
schema: {
|
|
602
|
+
type: 'object',
|
|
603
|
+
properties: {
|
|
604
|
+
project: { type: 'string' },
|
|
605
|
+
name: { type: 'string' },
|
|
606
|
+
},
|
|
607
|
+
required: ['name'],
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
responses: {
|
|
613
|
+
200: {
|
|
614
|
+
description: 'OK',
|
|
615
|
+
content: {
|
|
616
|
+
'application/json': {
|
|
617
|
+
schema: {
|
|
618
|
+
type: 'object',
|
|
619
|
+
properties: {
|
|
620
|
+
status: { type: 'string', enum: ['ok'] },
|
|
621
|
+
data: {
|
|
622
|
+
type: 'object',
|
|
623
|
+
properties: {
|
|
624
|
+
removed: { type: 'string' },
|
|
625
|
+
},
|
|
626
|
+
required: ['removed'],
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
required: ['status', 'data'],
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
500: gitErrorResponse(),
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
},
|
|
503
638
|
} as const;
|
package/src/openapi/schemas.ts
CHANGED
|
@@ -234,6 +234,11 @@ export const schemas = {
|
|
|
234
234
|
},
|
|
235
235
|
hasChanges: { type: 'boolean' },
|
|
236
236
|
hasConflicts: { type: 'boolean' },
|
|
237
|
+
hasUpstream: { type: 'boolean' },
|
|
238
|
+
remotes: {
|
|
239
|
+
type: 'array',
|
|
240
|
+
items: { type: 'string' },
|
|
241
|
+
},
|
|
237
242
|
},
|
|
238
243
|
required: [
|
|
239
244
|
'branch',
|
|
@@ -245,6 +250,8 @@ export const schemas = {
|
|
|
245
250
|
'conflicted',
|
|
246
251
|
'hasChanges',
|
|
247
252
|
'hasConflicts',
|
|
253
|
+
'hasUpstream',
|
|
254
|
+
'remotes',
|
|
248
255
|
],
|
|
249
256
|
},
|
|
250
257
|
GitFile: {
|
package/src/routes/files.ts
CHANGED
|
@@ -346,4 +346,45 @@ export function registerFilesRoutes(app: Hono) {
|
|
|
346
346
|
return c.json({ error: serializeError(err) }, 500);
|
|
347
347
|
}
|
|
348
348
|
});
|
|
349
|
+
|
|
350
|
+
app.get('/v1/files/raw', async (c) => {
|
|
351
|
+
try {
|
|
352
|
+
const projectRoot = c.req.query('project') || process.cwd();
|
|
353
|
+
const filePath = c.req.query('path');
|
|
354
|
+
|
|
355
|
+
if (!filePath) {
|
|
356
|
+
return c.json({ error: 'Missing required query parameter: path' }, 400);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const absPath = join(projectRoot, filePath);
|
|
360
|
+
if (!absPath.startsWith(projectRoot)) {
|
|
361
|
+
return c.json({ error: 'Path traversal not allowed' }, 403);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
|
365
|
+
const mimeTypes: Record<string, string> = {
|
|
366
|
+
png: 'image/png',
|
|
367
|
+
jpg: 'image/jpeg',
|
|
368
|
+
jpeg: 'image/jpeg',
|
|
369
|
+
gif: 'image/gif',
|
|
370
|
+
svg: 'image/svg+xml',
|
|
371
|
+
webp: 'image/webp',
|
|
372
|
+
ico: 'image/x-icon',
|
|
373
|
+
bmp: 'image/bmp',
|
|
374
|
+
avif: 'image/avif',
|
|
375
|
+
};
|
|
376
|
+
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
377
|
+
|
|
378
|
+
const data = await readFile(absPath);
|
|
379
|
+
return new Response(data, {
|
|
380
|
+
headers: {
|
|
381
|
+
'Content-Type': contentType,
|
|
382
|
+
'Cache-Control': 'no-cache',
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
} catch (err) {
|
|
386
|
+
logger.error('Files raw route error:', err);
|
|
387
|
+
return c.json({ error: serializeError(err) }, 500);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
349
390
|
}
|
package/src/routes/git/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { registerCommitRoutes } from './commit.ts';
|
|
|
7
7
|
import { registerPushRoute } from './push.ts';
|
|
8
8
|
import { registerPullRoute } from './pull.ts';
|
|
9
9
|
import { registerInitRoute } from './init.ts';
|
|
10
|
+
import { registerRemoteRoutes } from './remote.ts';
|
|
10
11
|
|
|
11
12
|
export type { GitFile } from './types.ts';
|
|
12
13
|
|
|
@@ -19,4 +20,5 @@ export function registerGitRoutes(app: Hono) {
|
|
|
19
20
|
registerPushRoute(app);
|
|
20
21
|
registerPullRoute(app);
|
|
21
22
|
registerInitRoute(app);
|
|
23
|
+
registerRemoteRoutes(app);
|
|
22
24
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { gitRemoteAddSchema, gitRemoteRemoveSchema } from './schemas.ts';
|
|
5
|
+
import { validateAndGetGitRoot } from './utils.ts';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
export function registerRemoteRoutes(app: Hono) {
|
|
10
|
+
app.get('/v1/git/remotes', async (c) => {
|
|
11
|
+
try {
|
|
12
|
+
const project = c.req.query('project');
|
|
13
|
+
const requestedPath = project || process.cwd();
|
|
14
|
+
|
|
15
|
+
const validation = await validateAndGetGitRoot(requestedPath);
|
|
16
|
+
if ('error' in validation) {
|
|
17
|
+
return c.json(
|
|
18
|
+
{ status: 'error', error: validation.error, code: validation.code },
|
|
19
|
+
400,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { gitRoot } = validation;
|
|
24
|
+
|
|
25
|
+
const { stdout } = await execFileAsync('git', ['remote', '-v'], {
|
|
26
|
+
cwd: gitRoot,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const remotes: { name: string; url: string; type: string }[] = [];
|
|
30
|
+
const seen = new Set<string>();
|
|
31
|
+
for (const line of stdout.trim().split('\n').filter(Boolean)) {
|
|
32
|
+
const match = line.match(/^(\S+)\s+(\S+)\s+\((\w+)\)$/);
|
|
33
|
+
if (match) {
|
|
34
|
+
const key = `${match[1]}:${match[3]}`;
|
|
35
|
+
if (!seen.has(key)) {
|
|
36
|
+
seen.add(key);
|
|
37
|
+
remotes.push({
|
|
38
|
+
name: match[1],
|
|
39
|
+
url: match[2],
|
|
40
|
+
type: match[3],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return c.json({ status: 'ok', data: { remotes } });
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return c.json(
|
|
49
|
+
{
|
|
50
|
+
status: 'error',
|
|
51
|
+
error:
|
|
52
|
+
error instanceof Error ? error.message : 'Failed to list remotes',
|
|
53
|
+
},
|
|
54
|
+
500,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
app.post('/v1/git/remotes', async (c) => {
|
|
60
|
+
try {
|
|
61
|
+
const body = await c.req.json().catch(() => ({}));
|
|
62
|
+
const { project, name, url } = gitRemoteAddSchema.parse(body);
|
|
63
|
+
const requestedPath = project || process.cwd();
|
|
64
|
+
|
|
65
|
+
const validation = await validateAndGetGitRoot(requestedPath);
|
|
66
|
+
if ('error' in validation) {
|
|
67
|
+
return c.json(
|
|
68
|
+
{ status: 'error', error: validation.error, code: validation.code },
|
|
69
|
+
400,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { gitRoot } = validation;
|
|
74
|
+
|
|
75
|
+
await execFileAsync('git', ['remote', 'add', name, url], {
|
|
76
|
+
cwd: gitRoot,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return c.json({
|
|
80
|
+
status: 'ok',
|
|
81
|
+
data: { name, url },
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const message =
|
|
85
|
+
error instanceof Error ? error.message : 'Failed to add remote';
|
|
86
|
+
const status = message.includes('already exists') ? 400 : 500;
|
|
87
|
+
return c.json({ status: 'error', error: message }, status);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
app.delete('/v1/git/remotes', async (c) => {
|
|
92
|
+
try {
|
|
93
|
+
const body = await c.req.json().catch(() => ({}));
|
|
94
|
+
const { project, name } = gitRemoteRemoveSchema.parse(body);
|
|
95
|
+
const requestedPath = project || process.cwd();
|
|
96
|
+
|
|
97
|
+
const validation = await validateAndGetGitRoot(requestedPath);
|
|
98
|
+
if ('error' in validation) {
|
|
99
|
+
return c.json(
|
|
100
|
+
{ status: 'error', error: validation.error, code: validation.code },
|
|
101
|
+
400,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { gitRoot } = validation;
|
|
106
|
+
|
|
107
|
+
await execFileAsync('git', ['remote', 'remove', name], {
|
|
108
|
+
cwd: gitRoot,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return c.json({
|
|
112
|
+
status: 'ok',
|
|
113
|
+
data: { removed: name },
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const message =
|
|
117
|
+
error instanceof Error ? error.message : 'Failed to remove remote';
|
|
118
|
+
return c.json({ status: 'error', error: message }, 500);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -50,3 +50,14 @@ export const gitPushSchema = z.object({
|
|
|
50
50
|
export const gitPullSchema = z.object({
|
|
51
51
|
project: z.string().optional(),
|
|
52
52
|
});
|
|
53
|
+
|
|
54
|
+
export const gitRemoteAddSchema = z.object({
|
|
55
|
+
project: z.string().optional(),
|
|
56
|
+
name: z.string().min(1),
|
|
57
|
+
url: z.string().min(1),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const gitRemoteRemoveSchema = z.object({
|
|
61
|
+
project: z.string().optional(),
|
|
62
|
+
name: z.string().min(1),
|
|
63
|
+
});
|
package/src/routes/git/status.ts
CHANGED
|
@@ -45,6 +45,26 @@ export function registerStatusRoute(app: Hono) {
|
|
|
45
45
|
|
|
46
46
|
const branch = await getCurrentBranch(gitRoot);
|
|
47
47
|
|
|
48
|
+
let hasUpstream = false;
|
|
49
|
+
try {
|
|
50
|
+
await execFileAsync(
|
|
51
|
+
'git',
|
|
52
|
+
['rev-parse', '--abbrev-ref', '@{upstream}'],
|
|
53
|
+
{ cwd: gitRoot },
|
|
54
|
+
);
|
|
55
|
+
hasUpstream = true;
|
|
56
|
+
} catch {}
|
|
57
|
+
|
|
58
|
+
let remotes: string[] = [];
|
|
59
|
+
try {
|
|
60
|
+
const { stdout: remotesOutput } = await execFileAsync(
|
|
61
|
+
'git',
|
|
62
|
+
['remote'],
|
|
63
|
+
{ cwd: gitRoot },
|
|
64
|
+
);
|
|
65
|
+
remotes = remotesOutput.trim().split('\n').filter(Boolean);
|
|
66
|
+
} catch {}
|
|
67
|
+
|
|
48
68
|
const hasChanges =
|
|
49
69
|
staged.length > 0 ||
|
|
50
70
|
unstaged.length > 0 ||
|
|
@@ -59,6 +79,8 @@ export function registerStatusRoute(app: Hono) {
|
|
|
59
79
|
branch,
|
|
60
80
|
ahead,
|
|
61
81
|
behind,
|
|
82
|
+
hasUpstream,
|
|
83
|
+
remotes,
|
|
62
84
|
gitRoot,
|
|
63
85
|
workingDir: requestedPath,
|
|
64
86
|
staged,
|
package/src/routes/mcp.ts
CHANGED
|
@@ -8,6 +8,39 @@ import {
|
|
|
8
8
|
addMCPServerToConfig,
|
|
9
9
|
removeMCPServerFromConfig,
|
|
10
10
|
} from '@ottocode/sdk';
|
|
11
|
+
import {
|
|
12
|
+
authorizeCopilot,
|
|
13
|
+
pollForCopilotTokenOnce,
|
|
14
|
+
getAuth,
|
|
15
|
+
setAuth,
|
|
16
|
+
} from '@ottocode/sdk';
|
|
17
|
+
|
|
18
|
+
const GITHUB_COPILOT_HOSTS = [
|
|
19
|
+
'api.githubcopilot.com',
|
|
20
|
+
'copilot-proxy.githubusercontent.com',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function isGitHubCopilotUrl(url?: string): boolean {
|
|
24
|
+
if (!url) return false;
|
|
25
|
+
try {
|
|
26
|
+
const parsed = new URL(url);
|
|
27
|
+
return GITHUB_COPILOT_HOSTS.some(
|
|
28
|
+
(h) => parsed.hostname === h || parsed.hostname.endsWith(`.${h}`),
|
|
29
|
+
);
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const copilotMCPSessions = new Map<
|
|
36
|
+
string,
|
|
37
|
+
{
|
|
38
|
+
deviceCode: string;
|
|
39
|
+
interval: number;
|
|
40
|
+
serverName: string;
|
|
41
|
+
createdAt: number;
|
|
42
|
+
}
|
|
43
|
+
>();
|
|
11
44
|
|
|
12
45
|
export function registerMCPRoutes(app: Hono) {
|
|
13
46
|
app.get('/v1/mcp/servers', async (c) => {
|
|
@@ -30,6 +63,7 @@ export function registerMCPRoutes(app: Hono) {
|
|
|
30
63
|
authRequired: status?.authRequired ?? false,
|
|
31
64
|
authenticated: status?.authenticated ?? false,
|
|
32
65
|
scope: s.scope ?? 'global',
|
|
66
|
+
...(isGitHubCopilotUrl(s.url) ? { authType: 'copilot-device' } : {}),
|
|
33
67
|
};
|
|
34
68
|
});
|
|
35
69
|
|
|
@@ -104,6 +138,10 @@ export function registerMCPRoutes(app: Hono) {
|
|
|
104
138
|
try {
|
|
105
139
|
const manager = getMCPManager();
|
|
106
140
|
if (manager) {
|
|
141
|
+
const config = await loadMCPConfig(projectRoot, getGlobalConfigDir());
|
|
142
|
+
const serverConfig = config.servers.find((s) => s.name === name);
|
|
143
|
+
const scope = serverConfig?.scope ?? 'global';
|
|
144
|
+
await manager.clearAuthData(name, scope, projectRoot);
|
|
107
145
|
await manager.stopServer(name);
|
|
108
146
|
}
|
|
109
147
|
|
|
@@ -144,6 +182,37 @@ export function registerMCPRoutes(app: Hono) {
|
|
|
144
182
|
const status = (await manager.getStatusAsync()).find(
|
|
145
183
|
(s) => s.name === name,
|
|
146
184
|
);
|
|
185
|
+
|
|
186
|
+
if (isGitHubCopilotUrl(serverConfig.url) && !status?.connected) {
|
|
187
|
+
const MCP_SCOPES =
|
|
188
|
+
'repo read:org read:packages gist notifications read:project security_events';
|
|
189
|
+
const existingAuth = await getAuth('copilot');
|
|
190
|
+
const hasMCPScopes =
|
|
191
|
+
existingAuth?.type === 'oauth' && existingAuth.scopes === MCP_SCOPES;
|
|
192
|
+
|
|
193
|
+
if (!existingAuth || existingAuth.type !== 'oauth' || !hasMCPScopes) {
|
|
194
|
+
const deviceData = await authorizeCopilot({ mcp: true });
|
|
195
|
+
const sessionId = crypto.randomUUID();
|
|
196
|
+
copilotMCPSessions.set(sessionId, {
|
|
197
|
+
deviceCode: deviceData.deviceCode,
|
|
198
|
+
interval: deviceData.interval,
|
|
199
|
+
serverName: name,
|
|
200
|
+
createdAt: Date.now(),
|
|
201
|
+
});
|
|
202
|
+
return c.json({
|
|
203
|
+
ok: true,
|
|
204
|
+
name,
|
|
205
|
+
connected: false,
|
|
206
|
+
authRequired: true,
|
|
207
|
+
authType: 'copilot-device',
|
|
208
|
+
sessionId,
|
|
209
|
+
userCode: deviceData.userCode,
|
|
210
|
+
verificationUri: deviceData.verificationUri,
|
|
211
|
+
interval: deviceData.interval,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
147
216
|
return c.json({
|
|
148
217
|
ok: true,
|
|
149
218
|
name,
|
|
@@ -185,6 +254,48 @@ export function registerMCPRoutes(app: Hono) {
|
|
|
185
254
|
return c.json({ ok: false, error: `Server "${name}" not found` }, 404);
|
|
186
255
|
}
|
|
187
256
|
|
|
257
|
+
if (isGitHubCopilotUrl(serverConfig.url)) {
|
|
258
|
+
try {
|
|
259
|
+
const MCP_SCOPES =
|
|
260
|
+
'repo read:org read:packages gist notifications read:project security_events';
|
|
261
|
+
const existingAuth = await getAuth('copilot');
|
|
262
|
+
if (
|
|
263
|
+
existingAuth?.type === 'oauth' &&
|
|
264
|
+
existingAuth.refresh &&
|
|
265
|
+
existingAuth.scopes === MCP_SCOPES
|
|
266
|
+
) {
|
|
267
|
+
return c.json({
|
|
268
|
+
ok: true,
|
|
269
|
+
name,
|
|
270
|
+
authType: 'copilot-device',
|
|
271
|
+
authenticated: true,
|
|
272
|
+
message: 'Already authenticated with MCP scopes',
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const deviceData = await authorizeCopilot({ mcp: true });
|
|
277
|
+
const sessionId = crypto.randomUUID();
|
|
278
|
+
copilotMCPSessions.set(sessionId, {
|
|
279
|
+
deviceCode: deviceData.deviceCode,
|
|
280
|
+
interval: deviceData.interval,
|
|
281
|
+
serverName: name,
|
|
282
|
+
createdAt: Date.now(),
|
|
283
|
+
});
|
|
284
|
+
return c.json({
|
|
285
|
+
ok: true,
|
|
286
|
+
name,
|
|
287
|
+
authType: 'copilot-device',
|
|
288
|
+
sessionId,
|
|
289
|
+
userCode: deviceData.userCode,
|
|
290
|
+
verificationUri: deviceData.verificationUri,
|
|
291
|
+
interval: deviceData.interval,
|
|
292
|
+
});
|
|
293
|
+
} catch (err) {
|
|
294
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
295
|
+
return c.json({ ok: false, error: msg }, 500);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
188
299
|
try {
|
|
189
300
|
let manager = getMCPManager();
|
|
190
301
|
if (!manager) {
|
|
@@ -212,7 +323,66 @@ export function registerMCPRoutes(app: Hono) {
|
|
|
212
323
|
app.post('/v1/mcp/servers/:name/auth/callback', async (c) => {
|
|
213
324
|
const name = c.req.param('name');
|
|
214
325
|
const body = await c.req.json();
|
|
215
|
-
const { code } = body;
|
|
326
|
+
const { code, sessionId } = body;
|
|
327
|
+
|
|
328
|
+
if (sessionId) {
|
|
329
|
+
const session = copilotMCPSessions.get(sessionId);
|
|
330
|
+
if (!session || session.serverName !== name) {
|
|
331
|
+
return c.json({ ok: false, error: 'Session expired or invalid' }, 400);
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
const result = await pollForCopilotTokenOnce(session.deviceCode);
|
|
335
|
+
if (result.status === 'complete') {
|
|
336
|
+
copilotMCPSessions.delete(sessionId);
|
|
337
|
+
await setAuth(
|
|
338
|
+
'copilot',
|
|
339
|
+
{
|
|
340
|
+
type: 'oauth',
|
|
341
|
+
refresh: result.accessToken,
|
|
342
|
+
access: result.accessToken,
|
|
343
|
+
expires: 0,
|
|
344
|
+
scopes:
|
|
345
|
+
'repo read:org read:packages gist notifications read:project security_events',
|
|
346
|
+
},
|
|
347
|
+
undefined,
|
|
348
|
+
'global',
|
|
349
|
+
);
|
|
350
|
+
const projectRoot = process.cwd();
|
|
351
|
+
const config = await loadMCPConfig(projectRoot, getGlobalConfigDir());
|
|
352
|
+
const serverConfig = config.servers.find((s) => s.name === name);
|
|
353
|
+
let mcpMgr = getMCPManager();
|
|
354
|
+
if (serverConfig) {
|
|
355
|
+
if (!mcpMgr) {
|
|
356
|
+
mcpMgr = await initializeMCP({ servers: [] }, projectRoot);
|
|
357
|
+
}
|
|
358
|
+
await mcpMgr.restartServer(serverConfig);
|
|
359
|
+
}
|
|
360
|
+
mcpMgr = getMCPManager();
|
|
361
|
+
const status = mcpMgr
|
|
362
|
+
? (await mcpMgr.getStatusAsync()).find((s) => s.name === name)
|
|
363
|
+
: undefined;
|
|
364
|
+
return c.json({
|
|
365
|
+
ok: true,
|
|
366
|
+
status: 'complete',
|
|
367
|
+
name,
|
|
368
|
+
connected: status?.connected ?? false,
|
|
369
|
+
tools: status?.tools ?? [],
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if (result.status === 'pending') {
|
|
373
|
+
return c.json({ ok: true, status: 'pending' });
|
|
374
|
+
}
|
|
375
|
+
copilotMCPSessions.delete(sessionId);
|
|
376
|
+
return c.json({
|
|
377
|
+
ok: false,
|
|
378
|
+
status: 'error',
|
|
379
|
+
error: result.status === 'error' ? result.error : 'Unknown error',
|
|
380
|
+
});
|
|
381
|
+
} catch (err) {
|
|
382
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
383
|
+
return c.json({ ok: false, error: msg }, 500);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
216
386
|
|
|
217
387
|
if (!code) {
|
|
218
388
|
return c.json({ ok: false, error: 'code is required' }, 400);
|
|
@@ -245,8 +415,21 @@ export function registerMCPRoutes(app: Hono) {
|
|
|
245
415
|
|
|
246
416
|
app.get('/v1/mcp/servers/:name/auth/status', async (c) => {
|
|
247
417
|
const name = c.req.param('name');
|
|
248
|
-
const
|
|
418
|
+
const projectRoot = process.cwd();
|
|
419
|
+
const config = await loadMCPConfig(projectRoot, getGlobalConfigDir());
|
|
420
|
+
const serverConfig = config.servers.find((s) => s.name === name);
|
|
421
|
+
|
|
422
|
+
if (serverConfig && isGitHubCopilotUrl(serverConfig.url)) {
|
|
423
|
+
try {
|
|
424
|
+
const auth = await getAuth('copilot');
|
|
425
|
+
const authenticated = auth?.type === 'oauth' && !!auth.refresh;
|
|
426
|
+
return c.json({ authenticated, authType: 'copilot-device' });
|
|
427
|
+
} catch {
|
|
428
|
+
return c.json({ authenticated: false, authType: 'copilot-device' });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
249
431
|
|
|
432
|
+
const manager = getMCPManager();
|
|
250
433
|
if (!manager) {
|
|
251
434
|
return c.json({ authenticated: false });
|
|
252
435
|
}
|
|
@@ -261,8 +444,26 @@ export function registerMCPRoutes(app: Hono) {
|
|
|
261
444
|
|
|
262
445
|
app.delete('/v1/mcp/servers/:name/auth', async (c) => {
|
|
263
446
|
const name = c.req.param('name');
|
|
264
|
-
const
|
|
447
|
+
const projectRoot = process.cwd();
|
|
448
|
+
const config = await loadMCPConfig(projectRoot, getGlobalConfigDir());
|
|
449
|
+
const serverConfig = config.servers.find((s) => s.name === name);
|
|
265
450
|
|
|
451
|
+
if (serverConfig && isGitHubCopilotUrl(serverConfig.url)) {
|
|
452
|
+
try {
|
|
453
|
+
const { removeAuth } = await import('@ottocode/sdk');
|
|
454
|
+
await removeAuth('copilot');
|
|
455
|
+
const manager = getMCPManager();
|
|
456
|
+
if (manager) {
|
|
457
|
+
await manager.stopServer(name);
|
|
458
|
+
}
|
|
459
|
+
return c.json({ ok: true, name });
|
|
460
|
+
} catch (err) {
|
|
461
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
462
|
+
return c.json({ ok: false, error: msg }, 500);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const manager = getMCPManager();
|
|
266
467
|
if (!manager) {
|
|
267
468
|
return c.json({ ok: false, error: 'No MCP manager active' }, 400);
|
|
268
469
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Tool } from 'ai';
|
|
2
|
+
import { debugLog } from '../debug/index.ts';
|
|
3
|
+
|
|
4
|
+
export interface MCPPrepareStepState {
|
|
5
|
+
mcpToolsRecord: Record<string, Tool>;
|
|
6
|
+
loadedMCPTools: Set<string>;
|
|
7
|
+
baseToolNames: string[];
|
|
8
|
+
canonicalToRegistration: Record<string, string>;
|
|
9
|
+
loadToolRegistrationName: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createMCPPrepareStepState(
|
|
13
|
+
mcpToolsRecord: Record<string, Tool>,
|
|
14
|
+
baseToolNames: string[],
|
|
15
|
+
canonicalToRegistration: Record<string, string>,
|
|
16
|
+
loadToolRegistrationName: string,
|
|
17
|
+
): MCPPrepareStepState {
|
|
18
|
+
return {
|
|
19
|
+
mcpToolsRecord,
|
|
20
|
+
loadedMCPTools: new Set(),
|
|
21
|
+
baseToolNames,
|
|
22
|
+
canonicalToRegistration,
|
|
23
|
+
loadToolRegistrationName,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildPrepareStep(state: MCPPrepareStepState) {
|
|
28
|
+
return async ({
|
|
29
|
+
stepNumber,
|
|
30
|
+
steps,
|
|
31
|
+
}: {
|
|
32
|
+
stepNumber: number;
|
|
33
|
+
steps: unknown[];
|
|
34
|
+
}) => {
|
|
35
|
+
const previousSteps = steps as Array<{
|
|
36
|
+
toolCalls?: Array<{ toolName: string; input: unknown }>;
|
|
37
|
+
toolResults?: Array<{ toolName: string; output: unknown }>;
|
|
38
|
+
}>;
|
|
39
|
+
|
|
40
|
+
for (const step of previousSteps) {
|
|
41
|
+
if (!step.toolCalls) continue;
|
|
42
|
+
for (const call of step.toolCalls) {
|
|
43
|
+
if (call.toolName !== state.loadToolRegistrationName) continue;
|
|
44
|
+
const result = (step.toolResults ?? []).find(
|
|
45
|
+
(r) => r.toolName === state.loadToolRegistrationName,
|
|
46
|
+
);
|
|
47
|
+
const output = result?.output as { loaded?: string[] } | undefined;
|
|
48
|
+
if (!output?.loaded) continue;
|
|
49
|
+
for (const canonicalName of output.loaded) {
|
|
50
|
+
const regName =
|
|
51
|
+
state.canonicalToRegistration[canonicalName] ?? canonicalName;
|
|
52
|
+
if (!state.loadedMCPTools.has(regName)) {
|
|
53
|
+
state.loadedMCPTools.add(regName);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const activeTools = [...state.baseToolNames, ...state.loadedMCPTools];
|
|
60
|
+
|
|
61
|
+
if (state.loadedMCPTools.size > 0) {
|
|
62
|
+
debugLog(
|
|
63
|
+
`[MCP prepareStep] step=${stepNumber}, active MCP tools: ${[...state.loadedMCPTools].join(', ')}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { activeTools };
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -8,6 +8,7 @@ import { resolveModel } from '../provider/index.ts';
|
|
|
8
8
|
import { resolveAgentConfig } from './registry.ts';
|
|
9
9
|
import { composeSystemPrompt } from '../prompt/builder.ts';
|
|
10
10
|
import { discoverProjectTools } from '@ottocode/sdk';
|
|
11
|
+
import type { Tool } from 'ai';
|
|
11
12
|
import { adaptTools } from '../../tools/adapter.ts';
|
|
12
13
|
import { buildDatabaseTools } from '../../tools/database/index.ts';
|
|
13
14
|
import { debugLog, time, isDebugEnabled } from '../debug/index.ts';
|
|
@@ -39,6 +40,7 @@ export interface SetupResult {
|
|
|
39
40
|
providerOptions: Record<string, unknown>;
|
|
40
41
|
needsSpoof: boolean;
|
|
41
42
|
isOpenAIOAuth: boolean;
|
|
43
|
+
mcpToolsRecord: Record<string, Tool>;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
const THINKING_BUDGET = 16000;
|
|
@@ -143,7 +145,9 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
|
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
const toolsTimer = time('runner:discoverTools');
|
|
146
|
-
const
|
|
148
|
+
const discovered = await discoverProjectTools(cfg.projectRoot);
|
|
149
|
+
const allTools = discovered.tools;
|
|
150
|
+
const { mcpToolsRecord } = discovered;
|
|
147
151
|
|
|
148
152
|
if (opts.agent === 'research') {
|
|
149
153
|
const currentSession = sessionRows[0];
|
|
@@ -151,19 +155,23 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
|
|
|
151
155
|
|
|
152
156
|
const dbTools = buildDatabaseTools(cfg.projectRoot, parentSessionId);
|
|
153
157
|
for (const dt of dbTools) {
|
|
154
|
-
|
|
158
|
+
discovered.tools.push(dt);
|
|
155
159
|
}
|
|
156
160
|
debugLog(
|
|
157
161
|
`[tools] Added ${dbTools.length} database tools for research agent (parent: ${parentSessionId ?? 'none'})`,
|
|
158
162
|
);
|
|
159
163
|
}
|
|
160
164
|
|
|
161
|
-
toolsTimer.end({
|
|
165
|
+
toolsTimer.end({
|
|
166
|
+
count: allTools.length + Object.keys(mcpToolsRecord).length,
|
|
167
|
+
});
|
|
162
168
|
const allowedNames = new Set([...(agentCfg.tools || []), 'finish']);
|
|
163
169
|
const gated = allTools.filter(
|
|
164
|
-
(tool) => allowedNames.has(tool.name) || tool.name
|
|
170
|
+
(tool) => allowedNames.has(tool.name) || tool.name === 'load_mcp_tools',
|
|
171
|
+
);
|
|
172
|
+
debugLog(
|
|
173
|
+
`[tools] ${gated.length} gated tools, ${Object.keys(mcpToolsRecord).length} lazy MCP tools`,
|
|
165
174
|
);
|
|
166
|
-
debugLog(`[tools] ${gated.length} allowed tools (including MCP)`);
|
|
167
175
|
|
|
168
176
|
debugLog(`[RUNNER] About to create model with provider: ${opts.provider}`);
|
|
169
177
|
debugLog(`[RUNNER] About to create model ID: ${opts.model}`);
|
|
@@ -249,6 +257,7 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
|
|
|
249
257
|
providerOptions,
|
|
250
258
|
needsSpoof: oauth.needsSpoof,
|
|
251
259
|
isOpenAIOAuth: oauth.isOpenAIOAuth,
|
|
260
|
+
mcpToolsRecord,
|
|
252
261
|
};
|
|
253
262
|
}
|
|
254
263
|
|
|
@@ -26,6 +26,11 @@ import {
|
|
|
26
26
|
import { pruneSession } from '../message/compaction.ts';
|
|
27
27
|
import { triggerDeferredTitleGeneration } from '../message/service.ts';
|
|
28
28
|
import { setupRunner } from './runner-setup.ts';
|
|
29
|
+
import {
|
|
30
|
+
createMCPPrepareStepState,
|
|
31
|
+
buildPrepareStep,
|
|
32
|
+
} from './mcp-prepare-step.ts';
|
|
33
|
+
import { adaptTools as adaptToolsFn } from '../../tools/adapter.ts';
|
|
29
34
|
import {
|
|
30
35
|
type ReasoningState,
|
|
31
36
|
handleReasoningStart,
|
|
@@ -83,13 +88,54 @@ async function runAssistant(opts: RunOpts) {
|
|
|
83
88
|
additionalSystemMessages,
|
|
84
89
|
model,
|
|
85
90
|
effectiveMaxOutputTokens,
|
|
86
|
-
toolset,
|
|
87
91
|
sharedCtx,
|
|
88
92
|
firstToolTimer,
|
|
89
93
|
firstToolSeen,
|
|
90
94
|
providerOptions,
|
|
91
95
|
isOpenAIOAuth,
|
|
96
|
+
mcpToolsRecord,
|
|
92
97
|
} = setup;
|
|
98
|
+
let { toolset } = setup;
|
|
99
|
+
|
|
100
|
+
const hasMCPTools = Object.keys(mcpToolsRecord).length > 0;
|
|
101
|
+
let prepareStep: ReturnType<typeof buildPrepareStep> | undefined;
|
|
102
|
+
|
|
103
|
+
if (hasMCPTools) {
|
|
104
|
+
const baseToolNames = Object.keys(toolset);
|
|
105
|
+
const { getAuth: getAuthFn } = await import('@ottocode/sdk');
|
|
106
|
+
const providerAuth = await getAuthFn(opts.provider, cfg.projectRoot);
|
|
107
|
+
const adaptedMCP = adaptToolsFn(
|
|
108
|
+
Object.entries(mcpToolsRecord).map(([name, tool]) => ({ name, tool })),
|
|
109
|
+
sharedCtx,
|
|
110
|
+
opts.provider,
|
|
111
|
+
providerAuth?.type,
|
|
112
|
+
);
|
|
113
|
+
toolset = { ...toolset, ...adaptedMCP };
|
|
114
|
+
const canonicalToRegistration: Record<string, string> = {};
|
|
115
|
+
for (const canonical of Object.keys(mcpToolsRecord)) {
|
|
116
|
+
const regKeys = Object.keys(adaptedMCP);
|
|
117
|
+
const regName = regKeys.find(
|
|
118
|
+
(k) =>
|
|
119
|
+
k === canonical ||
|
|
120
|
+
k.toLowerCase().replace(/_/g, '') ===
|
|
121
|
+
canonical.toLowerCase().replace(/_/g, ''),
|
|
122
|
+
);
|
|
123
|
+
canonicalToRegistration[canonical] = regName ?? canonical;
|
|
124
|
+
}
|
|
125
|
+
const loadToolRegName =
|
|
126
|
+
Object.keys(toolset).find(
|
|
127
|
+
(k) =>
|
|
128
|
+
k === 'load_mcp_tools' ||
|
|
129
|
+
k.toLowerCase().replace(/_/g, '') === 'loadmcptools',
|
|
130
|
+
) ?? 'load_mcp_tools';
|
|
131
|
+
const mcpState = createMCPPrepareStepState(
|
|
132
|
+
mcpToolsRecord,
|
|
133
|
+
baseToolNames,
|
|
134
|
+
canonicalToRegistration,
|
|
135
|
+
loadToolRegName,
|
|
136
|
+
);
|
|
137
|
+
prepareStep = buildPrepareStep(mcpState);
|
|
138
|
+
}
|
|
93
139
|
|
|
94
140
|
const isFirstMessage = !history.some((m) => m.role === 'assistant');
|
|
95
141
|
|
|
@@ -213,6 +259,7 @@ async function runAssistant(opts: RunOpts) {
|
|
|
213
259
|
...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}),
|
|
214
260
|
abortSignal: opts.abortSignal,
|
|
215
261
|
stopWhen: stopWhenCondition,
|
|
262
|
+
...(prepareStep ? { prepareStep } : {}),
|
|
216
263
|
// biome-ignore lint/suspicious/noExplicitAny: AI SDK callback types mismatch
|
|
217
264
|
onStepFinish: onStepFinish as any,
|
|
218
265
|
// biome-ignore lint/suspicious/noExplicitAny: AI SDK callback types mismatch
|
|
@@ -80,6 +80,9 @@ function describeToolResult(info: ToolResultInfo): TargetDescriptor | null {
|
|
|
80
80
|
case 'multiedit':
|
|
81
81
|
return describeEdit(info);
|
|
82
82
|
default:
|
|
83
|
+
if (toolName.includes('__')) {
|
|
84
|
+
return describeMcpTool(info);
|
|
85
|
+
}
|
|
83
86
|
return null;
|
|
84
87
|
}
|
|
85
88
|
}
|
|
@@ -215,3 +218,54 @@ function describeEdit(info: ToolResultInfo): TargetDescriptor | null {
|
|
|
215
218
|
const summary = `[previous edit] ${normalized}`;
|
|
216
219
|
return { keys: [key], summary };
|
|
217
220
|
}
|
|
221
|
+
|
|
222
|
+
function describeMcpTool(info: ToolResultInfo): TargetDescriptor | null {
|
|
223
|
+
const { toolName } = info;
|
|
224
|
+
const result = getRecord(info.result);
|
|
225
|
+
const args = getRecord(info.args);
|
|
226
|
+
|
|
227
|
+
const hasImages =
|
|
228
|
+
result && Array.isArray(result.images) && result.images.length > 0;
|
|
229
|
+
const resultStr =
|
|
230
|
+
result && typeof result.result === 'string' ? result.result : null;
|
|
231
|
+
const estimatedSize = hasImages
|
|
232
|
+
? estimateBase64Size(result.images as Array<{ data: string }>)
|
|
233
|
+
: resultStr
|
|
234
|
+
? resultStr.length
|
|
235
|
+
: 0;
|
|
236
|
+
|
|
237
|
+
if (estimatedSize < 2000 && !hasImages) return null;
|
|
238
|
+
|
|
239
|
+
const argsHint = args
|
|
240
|
+
? Object.entries(args)
|
|
241
|
+
.slice(0, 3)
|
|
242
|
+
.map(([k, v]) => {
|
|
243
|
+
const val =
|
|
244
|
+
typeof v === 'string'
|
|
245
|
+
? v.length > 30
|
|
246
|
+
? `${v.slice(0, 27)}…`
|
|
247
|
+
: v
|
|
248
|
+
: JSON.stringify(v);
|
|
249
|
+
return `${k}=${val}`;
|
|
250
|
+
})
|
|
251
|
+
.join(' ')
|
|
252
|
+
: '';
|
|
253
|
+
|
|
254
|
+
const sizeLabel = hasImages
|
|
255
|
+
? `${(result.images as unknown[]).length} image(s), ~${Math.round(estimatedSize / 1024)}KB`
|
|
256
|
+
: `~${Math.round(estimatedSize / 1024)}KB`;
|
|
257
|
+
|
|
258
|
+
const key = `mcp:${toolName}`;
|
|
259
|
+
const summary = `[previous MCP call] ${toolName}${argsHint ? ` (${argsHint})` : ''} → ${sizeLabel}`;
|
|
260
|
+
return { keys: [key], summary };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function estimateBase64Size(images: Array<{ data: string }>): number {
|
|
264
|
+
let total = 0;
|
|
265
|
+
for (const img of images) {
|
|
266
|
+
if (typeof img.data === 'string') {
|
|
267
|
+
total += Math.floor(img.data.length * 0.75);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return total;
|
|
271
|
+
}
|