@ottocode/server 0.1.244 → 0.1.246

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +4 -3
  2. package/src/events/types.ts +9 -9
  3. package/src/index.ts +9 -4
  4. package/src/openapi/paths/auth.ts +11 -11
  5. package/src/openapi/paths/config.ts +118 -2
  6. package/src/openapi/paths/{setu.ts → ottorouter.ts} +31 -31
  7. package/src/openapi/paths/skills.ts +122 -0
  8. package/src/openapi/schemas.ts +35 -3
  9. package/src/openapi/spec.ts +3 -3
  10. package/src/routes/auth.ts +40 -46
  11. package/src/routes/branch.ts +3 -2
  12. package/src/routes/config/defaults.ts +10 -3
  13. package/src/routes/config/main.ts +3 -0
  14. package/src/routes/config/models.ts +84 -14
  15. package/src/routes/config/providers.ts +137 -4
  16. package/src/routes/config/utils.ts +72 -2
  17. package/src/routes/doctor.ts +15 -27
  18. package/src/routes/git/commit.ts +16 -5
  19. package/src/routes/{setu.ts → ottorouter.ts} +52 -49
  20. package/src/routes/research.ts +3 -3
  21. package/src/routes/session-messages.ts +14 -8
  22. package/src/routes/sessions.ts +12 -18
  23. package/src/routes/skills.ts +140 -59
  24. package/src/runtime/agent/registry.ts +5 -2
  25. package/src/runtime/agent/runner-setup.ts +123 -38
  26. package/src/runtime/agent/runner.ts +140 -4
  27. package/src/runtime/ask/service.ts +14 -11
  28. package/src/runtime/message/history-builder.ts +22 -6
  29. package/src/runtime/message/service.ts +7 -1
  30. package/src/runtime/prompt/builder.ts +12 -0
  31. package/src/runtime/prompt/capabilities.ts +200 -0
  32. package/src/runtime/provider/index.ts +106 -5
  33. package/src/runtime/provider/{setu.ts → ottorouter.ts} +22 -22
  34. package/src/runtime/provider/reasoning.ts +73 -17
  35. package/src/runtime/provider/selection.ts +17 -15
  36. package/src/runtime/session/db-operations.ts +1 -1
  37. package/src/runtime/session/manager.ts +1 -1
  38. package/src/runtime/session/queue.ts +7 -2
  39. package/src/runtime/stream/error-handler.ts +3 -3
@@ -1,4 +1,5 @@
1
1
  import { hasToolCall, streamText } from 'ai';
2
+ import { logger } from '@ottocode/sdk';
2
3
  import type { getDb } from '@ottocode/database';
3
4
  import { messageParts, sessions } from '@ottocode/database/schema';
4
5
  import { eq } from 'drizzle-orm';
@@ -80,6 +81,50 @@ function summarizeTraceValue(value: unknown, max = 160): string {
80
81
  return fallback.length > max ? `${fallback.slice(0, max)}…` : fallback;
81
82
  }
82
83
 
84
+ function nowMs(): number {
85
+ const perf = globalThis.performance;
86
+ if (perf && typeof perf.now === 'function') return perf.now();
87
+ return Date.now();
88
+ }
89
+
90
+ function approximateMessageChars(
91
+ messages: Array<{ role: string; content: string | Array<unknown> }>,
92
+ ): number {
93
+ let total = 0;
94
+ for (const message of messages) {
95
+ total += message.role.length;
96
+ if (typeof message.content === 'string') {
97
+ total += message.content.length;
98
+ continue;
99
+ }
100
+ try {
101
+ total += JSON.stringify(message.content).length;
102
+ } catch {}
103
+ }
104
+ return total;
105
+ }
106
+
107
+ function summarizeToolShape(tools: Record<string, unknown>) {
108
+ const names = Object.keys(tools);
109
+ const entries = names.map((name) => {
110
+ const toolValue = tools[name];
111
+ let approxChars = 0;
112
+ try {
113
+ approxChars = JSON.stringify(toolValue).length;
114
+ } catch {}
115
+ return { name, approxChars };
116
+ });
117
+ entries.sort((a, b) => b.approxChars - a.approxChars);
118
+ return {
119
+ toolNames: names,
120
+ toolSchemaCharsApprox: entries.reduce(
121
+ (total, entry) => total + entry.approxChars,
122
+ 0,
123
+ ),
124
+ largestTools: entries.slice(0, 8),
125
+ };
126
+ }
127
+
83
128
  async function shouldPreemptivelyAutoCompact(
84
129
  db: Awaited<ReturnType<typeof getDb>>,
85
130
  opts: RunOpts,
@@ -119,6 +164,8 @@ export async function runSessionLoop(sessionId: string) {
119
164
  }
120
165
 
121
166
  async function runAssistant(opts: RunOpts) {
167
+ const runStartedAt = nowMs();
168
+ const queueWaitMs = opts.queuedAt ? runStartedAt - opts.queuedAt : 0;
122
169
  const setup = await setupRunner(opts);
123
170
  const {
124
171
  cfg,
@@ -134,6 +181,7 @@ async function runAssistant(opts: RunOpts) {
134
181
  providerOptions,
135
182
  isOpenAIOAuth,
136
183
  mcpToolsRecord,
184
+ timings,
137
185
  } = setup;
138
186
  let { toolset } = setup;
139
187
 
@@ -296,6 +344,25 @@ async function runAssistant(opts: RunOpts) {
296
344
 
297
345
  const streamStartTimer = time('runner:first-delta');
298
346
  let firstDeltaSeen = false;
347
+ const logFirstOutputLatency = (kind: 'text' | 'reasoning') => {
348
+ if (firstDeltaSeen) return;
349
+ firstDeltaSeen = true;
350
+ const firstOutputMs = nowMs() - runStartedAt;
351
+ streamStartTimer.end({ kind, queueWaitMs, setupMs: timings.totalMs });
352
+ logger.info('[latency] first output', {
353
+ sessionId: opts.sessionId,
354
+ messageId: opts.assistantMessageId,
355
+ agent: opts.agent,
356
+ provider: opts.provider,
357
+ model: opts.model,
358
+ kind,
359
+ queueWaitMs,
360
+ firstOutputMs,
361
+ setupMs: timings.totalMs,
362
+ totalSinceEnqueueMs: queueWaitMs + firstOutputMs,
363
+ timings,
364
+ });
365
+ };
299
366
 
300
367
  let currentPartId: string | null = null;
301
368
  let accumulated = '';
@@ -387,8 +454,39 @@ async function runAssistant(opts: RunOpts) {
387
454
  const stopWhenCondition = isCopilotResponsesApi
388
455
  ? undefined
389
456
  : hasToolCall('finish');
457
+ const toolShape = summarizeToolShape(toolset as Record<string, unknown>);
458
+ logger.info('[latency] stream request ready', {
459
+ sessionId: opts.sessionId,
460
+ messageId: opts.assistantMessageId,
461
+ agent: opts.agent,
462
+ provider: opts.provider,
463
+ model: opts.model,
464
+ queueWaitMs,
465
+ setupMs: timings.totalMs,
466
+ messageCount: messagesWithSystemInstructions.length,
467
+ toolCount: Object.keys(toolset).length,
468
+ toolNames: toolShape.toolNames,
469
+ toolSchemaCharsApprox: toolShape.toolSchemaCharsApprox,
470
+ largestTools: toolShape.largestTools,
471
+ hasPrepareStep: Boolean(prepareStep),
472
+ providerOptionsKeys: Object.keys(providerOptions),
473
+ systemPromptChars: system.length,
474
+ messageCharsApprox: approximateMessageChars(messagesWithSystemInstructions),
475
+ additionalSystemMessages: additionalSystemMessages.length,
476
+ historyMessages: history.length,
477
+ });
390
478
 
391
479
  try {
480
+ const streamInvocationStartedAt = nowMs();
481
+ logger.info('[latency] streamText invoke', {
482
+ sessionId: opts.sessionId,
483
+ messageId: opts.assistantMessageId,
484
+ agent: opts.agent,
485
+ provider: opts.provider,
486
+ model: opts.model,
487
+ queueWaitMs,
488
+ setupMs: timings.totalMs,
489
+ });
392
490
  const result = streamText({
393
491
  model,
394
492
  tools: toolset,
@@ -412,10 +510,34 @@ async function runAssistant(opts: RunOpts) {
412
510
  onFinish: onFinish as any,
413
511
  // biome-ignore lint/suspicious/noExplicitAny: AI SDK streamText options type
414
512
  } as any);
513
+ logger.info('[latency] streamText returned', {
514
+ sessionId: opts.sessionId,
515
+ messageId: opts.assistantMessageId,
516
+ agent: opts.agent,
517
+ provider: opts.provider,
518
+ model: opts.model,
519
+ invokeMs: nowMs() - streamInvocationStartedAt,
520
+ });
415
521
  const tracedToolInputNamesById = new Map<string, string>();
522
+ let firstFullStreamPartSeen = false;
523
+ let firstPublishedDeltaSeen = false;
416
524
 
417
525
  for await (const part of result.fullStream) {
418
526
  if (!part) continue;
527
+ if (!firstFullStreamPartSeen) {
528
+ firstFullStreamPartSeen = true;
529
+ logger.info('[latency] first fullStream part', {
530
+ sessionId: opts.sessionId,
531
+ messageId: opts.assistantMessageId,
532
+ agent: opts.agent,
533
+ provider: opts.provider,
534
+ model: opts.model,
535
+ partType: part.type,
536
+ sinceRunStartMs: nowMs() - runStartedAt,
537
+ queueWaitMs,
538
+ setupMs: timings.totalMs,
539
+ });
540
+ }
419
541
 
420
542
  if (part.type === 'tool-input-start') {
421
543
  if (shouldTraceToolInput(part.toolName)) {
@@ -482,10 +604,7 @@ async function runAssistant(opts: RunOpts) {
482
604
  continue;
483
605
  }
484
606
 
485
- if (!firstDeltaSeen) {
486
- firstDeltaSeen = true;
487
- streamStartTimer.end();
488
- }
607
+ logFirstOutputLatency('text');
489
608
 
490
609
  if (!currentPartId) {
491
610
  currentPartId = crypto.randomUUID();
@@ -514,6 +633,20 @@ async function runAssistant(opts: RunOpts) {
514
633
  delta,
515
634
  },
516
635
  });
636
+ if (!firstPublishedDeltaSeen) {
637
+ firstPublishedDeltaSeen = true;
638
+ logger.info('[latency] first published delta', {
639
+ sessionId: opts.sessionId,
640
+ messageId: opts.assistantMessageId,
641
+ agent: opts.agent,
642
+ provider: opts.provider,
643
+ model: opts.model,
644
+ sinceRunStartMs: nowMs() - runStartedAt,
645
+ queueWaitMs,
646
+ setupMs: timings.totalMs,
647
+ deltaPreview: delta.length > 80 ? `${delta.slice(0, 80)}…` : delta,
648
+ });
649
+ }
517
650
  await db
518
651
  .update(messageParts)
519
652
  .set({ content: JSON.stringify({ text: accumulated }) })
@@ -537,6 +670,9 @@ async function runAssistant(opts: RunOpts) {
537
670
  }
538
671
 
539
672
  if (part.type === 'reasoning-delta') {
673
+ if (part.text) {
674
+ logFirstOutputLatency('reasoning');
675
+ }
540
676
  await handleReasoningDelta(
541
677
  part.id,
542
678
  part.text,
@@ -16,7 +16,7 @@ import {
16
16
  validateProviderModel,
17
17
  isProviderAuthorized,
18
18
  ensureProviderEnv,
19
- isProviderId,
19
+ hasConfiguredProvider,
20
20
  providerEnvVar,
21
21
  type ProviderId,
22
22
  type ReasoningLevel,
@@ -134,10 +134,11 @@ async function processAskRequest(
134
134
  openai: { enabled: true },
135
135
  anthropic: { enabled: true },
136
136
  google: { enabled: true },
137
+ 'ollama-cloud': { enabled: true, baseURL: 'https://ollama.com' },
137
138
  openrouter: { enabled: true },
138
139
  opencode: { enabled: true },
139
140
  copilot: { enabled: true },
140
- setu: { enabled: true },
141
+ ottorouter: { enabled: true },
141
142
  zai: { enabled: true },
142
143
  'zai-coding': { enabled: true },
143
144
  moonshot: { enabled: true },
@@ -204,20 +205,22 @@ async function processAskRequest(
204
205
  name: agentName,
205
206
  prompt: request.agentPrompt,
206
207
  tools: request.tools ?? ['progress_update', 'finish'],
207
- provider: isProviderId(request.provider)
208
- ? (request.provider as ProviderId)
209
- : undefined,
208
+ provider:
209
+ typeof request.provider === 'string' &&
210
+ hasConfiguredProvider(cfg, request.provider)
211
+ ? request.provider
212
+ : undefined,
210
213
  model: request.model,
211
214
  }
212
215
  : await resolveAgentConfig(cfg.projectRoot, agentName);
213
216
  agentTimer.end({ agent: agentName });
214
- const agentProviderDefault = isProviderId(agentCfg.provider)
217
+ const agentProviderDefault = hasConfiguredProvider(cfg, agentCfg.provider)
215
218
  ? agentCfg.provider
216
219
  : cfg.defaults.provider;
217
220
  const agentModelDefault = agentCfg.model ?? cfg.defaults.model;
218
221
 
219
- const explicitProvider = isProviderId(request.provider)
220
- ? (request.provider as ProviderId)
222
+ const explicitProvider = hasConfiguredProvider(cfg, request.provider)
223
+ ? request.provider
221
224
  : undefined;
222
225
 
223
226
  let providerSelection: ProviderSelection;
@@ -265,8 +268,8 @@ async function processAskRequest(
265
268
  providerForMessage = providerSelection.provider;
266
269
  modelForMessage = providerSelection.model;
267
270
  } else if (session.provider && session.model) {
268
- const sessionProvider = isProviderId(session.provider)
269
- ? (session.provider as ProviderId)
271
+ const sessionProvider = hasConfiguredProvider(cfg, session.provider)
272
+ ? session.provider
270
273
  : agentProviderDefault;
271
274
  providerForMessage = sessionProvider;
272
275
  modelForMessage = session.model;
@@ -302,7 +305,7 @@ async function processAskRequest(
302
305
  } as SessionRow;
303
306
  }
304
307
 
305
- validateProviderModel(providerForMessage, modelForMessage);
308
+ validateProviderModel(providerForMessage, modelForMessage, cfg);
306
309
 
307
310
  if (!request.skipFileConfig && !request.config && !request.credentials) {
308
311
  await ensureProviderEnv(cfg, providerForMessage);
@@ -7,7 +7,7 @@ import {
7
7
  } from 'ai';
8
8
  import type { getDb } from '@ottocode/database';
9
9
  import { messages, messageParts } from '@ottocode/database/schema';
10
- import { eq, asc } from 'drizzle-orm';
10
+ import { eq, asc, inArray } from 'drizzle-orm';
11
11
  import { ToolHistoryTracker } from './tool-history-tracker.ts';
12
12
 
13
13
  /**
@@ -24,16 +24,32 @@ export async function buildHistoryMessages(
24
24
  .from(messages)
25
25
  .where(eq(messages.sessionId, sessionId))
26
26
  .orderBy(asc(messages.createdAt));
27
+ const messageIds = rows.map((row) => row.id);
28
+ const allParts = messageIds.length
29
+ ? await db
30
+ .select()
31
+ .from(messageParts)
32
+ .where(inArray(messageParts.messageId, messageIds))
33
+ .orderBy(asc(messageParts.messageId), asc(messageParts.index))
34
+ : [];
35
+ const partsByMessageId = new Map<
36
+ string,
37
+ (typeof messageParts.$inferSelect)[]
38
+ >();
39
+ for (const part of allParts) {
40
+ const existing = partsByMessageId.get(part.messageId);
41
+ if (existing) {
42
+ existing.push(part);
43
+ continue;
44
+ }
45
+ partsByMessageId.set(part.messageId, [part]);
46
+ }
27
47
 
28
48
  const history: ModelMessage[] = [];
29
49
  const toolHistory = new ToolHistoryTracker();
30
50
 
31
51
  for (const m of rows) {
32
- const parts = await db
33
- .select()
34
- .from(messageParts)
35
- .where(eq(messageParts.messageId, m.id))
36
- .orderBy(asc(messageParts.index));
52
+ const parts = partsByMessageId.get(m.id) ?? [];
37
53
 
38
54
  if (
39
55
  m.role === 'assistant' &&
@@ -9,6 +9,7 @@ import { runSessionLoop } from '../agent/runner.ts';
9
9
  import { resolveModel } from '../provider/index.ts';
10
10
  import {
11
11
  getFastModelForAuth,
12
+ getProviderDefinition,
12
13
  logger,
13
14
  type ProviderId,
14
15
  type ReasoningLevel,
@@ -316,8 +317,13 @@ async function generateSessionTitle(args: {
316
317
  const { getAuth } = await import('@ottocode/sdk');
317
318
  const auth = await getAuth(provider, cfg.projectRoot);
318
319
  const oauth = detectOAuth(provider, auth);
320
+ const providerDefinition = getProviderDefinition(cfg, provider);
319
321
 
320
- const titleModel = getFastModelForAuth(provider, auth?.type) ?? modelName;
322
+ const titleModel =
323
+ providerDefinition?.source === 'custom' ||
324
+ providerDefinition?.compatibility === 'ollama'
325
+ ? modelName
326
+ : (getFastModelForAuth(provider, auth?.type) ?? modelName);
321
327
  const model = await resolveModel(provider, titleModel, cfg);
322
328
 
323
329
  const promptText = String(content ?? '').slice(0, 2000);
@@ -20,6 +20,7 @@ import ANTHROPIC_SPOOF_PROMPT from '@ottocode/sdk/prompts/providers/anthropicSpo
20
20
  };
21
21
 
22
22
  import { getTerminalManager } from '@ottocode/sdk';
23
+ import { buildCapabilitySummary } from './capabilities.ts';
23
24
 
24
25
  export type ComposedSystemPrompt = {
25
26
  prompt: string;
@@ -29,6 +30,8 @@ export type ComposedSystemPrompt = {
29
30
  export async function composeSystemPrompt(options: {
30
31
  provider: string;
31
32
  model?: string;
33
+ promptFamily?: import('@ottocode/sdk').ProviderPromptFamily | null;
34
+ skillSettings?: import('@ottocode/sdk').OttoConfig['skills'];
32
35
  projectRoot: string;
33
36
  agentPrompt: string;
34
37
  oneShot?: boolean;
@@ -68,6 +71,7 @@ export async function composeSystemPrompt(options: {
68
71
  options.provider,
69
72
  options.model,
70
73
  options.projectRoot,
74
+ options.promptFamily ?? undefined,
71
75
  );
72
76
  const baseInstructions = (BASE_PROMPT || '').trim();
73
77
  const agentInstructions = options.agentPrompt.trim();
@@ -126,6 +130,14 @@ export async function composeSystemPrompt(options: {
126
130
  }
127
131
  }
128
132
 
133
+ const capabilitySummary = buildCapabilitySummary({
134
+ skillSettings: options.skillSettings,
135
+ });
136
+ if (capabilitySummary.prompt) {
137
+ parts.push(capabilitySummary.prompt);
138
+ components.push(...capabilitySummary.components);
139
+ }
140
+
129
141
  // Add user-provided context if present
130
142
  if (options.userContext?.trim()) {
131
143
  const userContextBlock = [
@@ -0,0 +1,200 @@
1
+ import {
2
+ filterDiscoveredSkills,
3
+ getDiscoveredSkills,
4
+ getMCPManager,
5
+ summarizeDescription,
6
+ type DiscoveredSkill,
7
+ type OttoConfig,
8
+ } from '@ottocode/sdk';
9
+
10
+ const MAX_SKILLS = 8;
11
+ const MAX_MCP_SERVERS = 8;
12
+ const MAX_MCP_TOOLS_PER_SERVER = 3;
13
+
14
+ export type CapabilitySummaryMCPTool = {
15
+ name: string;
16
+ server: string;
17
+ description?: string;
18
+ };
19
+
20
+ export type CapabilitySummaryResult = {
21
+ prompt: string;
22
+ components: string[];
23
+ };
24
+
25
+ /**
26
+ * Build a compact prompt block that advertises available skills and started MCP
27
+ * servers without inlining their full instructions or tool catalogs.
28
+ */
29
+ export function buildCapabilitySummary(options?: {
30
+ skillSettings?: OttoConfig['skills'];
31
+ skills?: DiscoveredSkill[];
32
+ mcpTools?: CapabilitySummaryMCPTool[];
33
+ }): CapabilitySummaryResult {
34
+ const skillLines = buildSkillLines(options?.skills, options?.skillSettings);
35
+ const mcpLines = buildMCPLines(options?.mcpTools);
36
+ const components = ['capabilities'];
37
+ const sections: string[] = [];
38
+
39
+ if (skillLines.length > 0) {
40
+ sections.push(['Skills:', ...skillLines].join('\n'));
41
+ components.push('capabilities:skills');
42
+ }
43
+ if (mcpLines.length > 0) {
44
+ sections.push(['Started MCP capabilities:', ...mcpLines].join('\n'));
45
+ components.push('capabilities:mcp');
46
+ }
47
+
48
+ if (sections.length === 0) {
49
+ return { prompt: '', components: [] };
50
+ }
51
+
52
+ const prompt = [
53
+ '<optional-capabilities>',
54
+ 'You have additional capabilities available when they may help with the task.',
55
+ 'Use them proactively when relevant, but do not load or call them unnecessarily.',
56
+ '',
57
+ sections.join('\n\n'),
58
+ '',
59
+ 'When one of these capabilities may help, prefer using it instead of ignoring it.',
60
+ '</optional-capabilities>',
61
+ ].join('\n');
62
+
63
+ return { prompt, components };
64
+ }
65
+
66
+ function buildSkillLines(
67
+ providedSkills: DiscoveredSkill[] | undefined,
68
+ skillSettings: OttoConfig['skills'] | undefined,
69
+ ): string[] {
70
+ const skills = filterDiscoveredSkills(
71
+ providedSkills ?? getDiscoveredSkills(),
72
+ skillSettings,
73
+ );
74
+ const seen = new Set<string>();
75
+ const unique: DiscoveredSkill[] = [];
76
+
77
+ for (const skill of skills) {
78
+ const name = skill.name.trim();
79
+ if (!name || seen.has(name)) continue;
80
+ seen.add(name);
81
+ unique.push(skill);
82
+ }
83
+
84
+ unique.sort((a, b) => a.name.localeCompare(b.name));
85
+
86
+ const visible = unique.slice(0, MAX_SKILLS).map((skill) => {
87
+ const summary = finalizeSentence(summarizeDescription(skill.description));
88
+ const description = summary || 'Task-specific instructions and guidance';
89
+ return `- ${skill.name}: ${description}. Load with \`skill\` when it matches the task.`;
90
+ });
91
+
92
+ const remaining = unique.length - visible.length;
93
+ if (remaining > 0) {
94
+ visible.push(
95
+ `- ${remaining} more skill${remaining === 1 ? '' : 's'} available via \`skill\`.`,
96
+ );
97
+ }
98
+
99
+ return visible;
100
+ }
101
+
102
+ function buildMCPLines(
103
+ providedMCPTools: CapabilitySummaryMCPTool[] | undefined,
104
+ ): string[] {
105
+ const tools = providedMCPTools ?? getLiveMCPTools();
106
+ if (tools.length === 0) return [];
107
+
108
+ const grouped = new Map<string, CapabilitySummaryMCPTool[]>();
109
+ for (const tool of tools) {
110
+ const list = grouped.get(tool.server) ?? [];
111
+ list.push(tool);
112
+ grouped.set(tool.server, list);
113
+ }
114
+
115
+ const servers = Array.from(grouped.entries()).sort(([a], [b]) =>
116
+ a.localeCompare(b),
117
+ );
118
+ const visible = servers
119
+ .slice(0, MAX_MCP_SERVERS)
120
+ .map(([server, serverTools]) => {
121
+ const summary = summarizeMCPServer(server, serverTools);
122
+ return `- ${server}: ${summary}. Load relevant tools with \`load_mcp_tools\` when the task may benefit from them.`;
123
+ });
124
+
125
+ const remaining = servers.length - visible.length;
126
+ if (remaining > 0) {
127
+ visible.push(
128
+ `- ${remaining} more started MCP server${remaining === 1 ? '' : 's'} available via \`load_mcp_tools\`.`,
129
+ );
130
+ }
131
+
132
+ return visible;
133
+ }
134
+
135
+ function getLiveMCPTools(): CapabilitySummaryMCPTool[] {
136
+ const manager = getMCPManager();
137
+ if (!manager?.started) return [];
138
+ return manager.getTools().map(({ name, server, tool }) => ({
139
+ name,
140
+ server,
141
+ description: tool.description,
142
+ }));
143
+ }
144
+
145
+ function summarizeMCPServer(
146
+ server: string,
147
+ tools: CapabilitySummaryMCPTool[],
148
+ ): string {
149
+ const namedTools = dedupeStrings(
150
+ tools
151
+ .map((tool) => stripServerPrefix(tool.name, server))
152
+ .filter((name) => name.length > 0),
153
+ );
154
+ const representativeNames = namedTools.slice(0, MAX_MCP_TOOLS_PER_SERVER);
155
+ const descriptiveText = dedupeStrings(
156
+ tools
157
+ .map((tool) => tool.description?.trim() ?? '')
158
+ .filter((description) => description.length > 0)
159
+ .map((description) => description.replace(/^MCP tool:\s*/i, '')),
160
+ ).map((description) => finalizeSentence(description));
161
+ const summaryFromDescription = descriptiveText.find(
162
+ (description) => description.length > 0,
163
+ );
164
+
165
+ if (summaryFromDescription) {
166
+ if (representativeNames.length === 0) {
167
+ return summaryFromDescription;
168
+ }
169
+ return `${summaryFromDescription}; tools include ${representativeNames.join(', ')}`;
170
+ }
171
+
172
+ if (representativeNames.length === 0) {
173
+ return `external tools exposed by the ${server} MCP server`;
174
+ }
175
+
176
+ return `external ${server} tools such as ${representativeNames.join(', ')}`;
177
+ }
178
+
179
+ function stripServerPrefix(name: string, server: string): string {
180
+ const prefix = `${server}__`;
181
+ return name.startsWith(prefix) ? name.slice(prefix.length) : name;
182
+ }
183
+
184
+ function finalizeSentence(value: string): string {
185
+ const normalized = value.replace(/\s+/g, ' ').trim();
186
+ if (!normalized) return '';
187
+ return normalized.replace(/[.!?;:,\s]+$/g, '');
188
+ }
189
+
190
+ function dedupeStrings(values: string[]): string[] {
191
+ const seen = new Set<string>();
192
+ const out: string[] = [];
193
+ for (const value of values) {
194
+ const normalized = value.trim();
195
+ if (!normalized || seen.has(normalized)) continue;
196
+ seen.add(normalized);
197
+ out.push(normalized);
198
+ }
199
+ return out;
200
+ }