talon-agent 3.33.4 → 3.34.1

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 (98) hide show
  1. package/package.json +5 -2
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/handler.ts +428 -415
  4. package/src/backend/claude-sdk/stream.ts +76 -53
  5. package/src/backend/codex/handler/message.ts +275 -366
  6. package/src/backend/codex/handler/rollout-accounting.ts +137 -0
  7. package/src/backend/openai-agents/handler/message.ts +282 -356
  8. package/src/backend/remote-server/chat-turn.ts +66 -122
  9. package/src/backend/remote-server/index.ts +4 -0
  10. package/src/backend/remote-server/mcp.ts +73 -9
  11. package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
  12. package/src/backend/remote-server/sessions.ts +2 -2
  13. package/src/backend/remote-server/turn.ts +58 -55
  14. package/src/backend/shared/cache-telemetry.ts +17 -1
  15. package/src/backend/shared/handler-to-events.ts +11 -16
  16. package/src/backend/shared/index.ts +14 -15
  17. package/src/backend/shared/result-events.ts +30 -0
  18. package/src/backend/shared/turn-phases.ts +277 -0
  19. package/src/bootstrap.ts +120 -79
  20. package/src/cli/setup.ts +375 -349
  21. package/src/core/background/cron-spec.ts +273 -0
  22. package/src/core/background/heartbeat/agent.ts +167 -104
  23. package/src/core/background/triggers/exit.ts +19 -2
  24. package/src/core/background/triggers/resume.ts +14 -7
  25. package/src/core/background/triggers/state.ts +9 -0
  26. package/src/core/engine/backend-controller/pool.ts +2 -0
  27. package/src/core/engine/backend-controller/state.ts +25 -12
  28. package/src/core/engine/gateway-actions/cron.ts +28 -292
  29. package/src/core/engine/gateway-routes.ts +239 -0
  30. package/src/core/engine/gateway.ts +66 -238
  31. package/src/core/mcp-hub/child-transport.ts +215 -0
  32. package/src/core/mcp-hub/children.ts +72 -13
  33. package/src/core/mcp-hub/index.ts +32 -13
  34. package/src/core/models/active-model.ts +29 -6
  35. package/src/core/vfs/mounts/files.ts +128 -115
  36. package/src/core/weaver/shuttle.ts +33 -2
  37. package/src/core/weaver/weaver.ts +37 -6
  38. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  39. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  40. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  41. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  42. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  43. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  44. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  45. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  46. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  47. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  48. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  49. package/src/frontend/discord/callbacks/index.ts +4 -4
  50. package/src/frontend/discord/connection.ts +36 -0
  51. package/src/frontend/discord/diagnostics.ts +62 -0
  52. package/src/frontend/discord/guild-policy.ts +89 -0
  53. package/src/frontend/discord/index.ts +44 -305
  54. package/src/frontend/discord/outbound.ts +61 -0
  55. package/src/frontend/discord/ready.ts +73 -0
  56. package/src/frontend/discord/runtime.ts +55 -0
  57. package/src/frontend/native/chat-lifecycle.ts +39 -0
  58. package/src/frontend/native/chat-wire.ts +69 -0
  59. package/src/frontend/native/context.ts +106 -0
  60. package/src/frontend/native/control.ts +78 -0
  61. package/src/frontend/native/emit.ts +167 -0
  62. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  63. package/src/frontend/native/handlers.ts +121 -0
  64. package/src/frontend/native/history.ts +101 -0
  65. package/src/frontend/native/index.ts +90 -1293
  66. package/src/frontend/native/media.ts +43 -0
  67. package/src/frontend/native/models.ts +221 -0
  68. package/src/frontend/native/queue.ts +47 -0
  69. package/src/frontend/native/reset.ts +50 -0
  70. package/src/frontend/native/routes/chats.ts +115 -0
  71. package/src/frontend/native/routes/daemon.ts +70 -0
  72. package/src/frontend/native/routes/host.ts +151 -0
  73. package/src/frontend/native/routes/index.ts +22 -0
  74. package/src/frontend/native/routes/mesh.ts +72 -0
  75. package/src/frontend/native/routes/models.ts +54 -0
  76. package/src/frontend/native/routes/params.ts +29 -0
  77. package/src/frontend/native/routes/pre-auth.ts +94 -0
  78. package/src/frontend/native/routes/table.ts +92 -0
  79. package/src/frontend/native/runtime.ts +109 -0
  80. package/src/frontend/native/server.ts +30 -555
  81. package/src/frontend/native/status.ts +26 -0
  82. package/src/frontend/native/tool-result.ts +48 -0
  83. package/src/frontend/native/turn.ts +341 -0
  84. package/src/frontend/telegram/admin.ts +75 -52
  85. package/src/frontend/whatsapp/access.ts +67 -0
  86. package/src/frontend/whatsapp/connection.ts +280 -0
  87. package/src/frontend/whatsapp/inbound.ts +327 -0
  88. package/src/frontend/whatsapp/index.ts +28 -599
  89. package/src/frontend/whatsapp/runtime.ts +74 -0
  90. package/src/storage/metrics.ts +18 -0
  91. package/src/storage/session-record.ts +29 -0
  92. package/src/storage/sessions.ts +24 -0
  93. package/src/storage/trigger-store.ts +8 -0
  94. package/src/util/boot-timer.ts +31 -0
  95. package/src/util/concurrency.ts +28 -0
  96. package/src/util/watchdog.ts +32 -7
  97. package/src/frontend/discord/callbacks/components.ts +0 -793
  98. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -115,261 +115,302 @@ export interface RemoteModelPresentation {
115
115
  ): string;
116
116
  }
117
117
 
118
- export function createRemoteModelPresentation(
119
- options: RemotePresentationOptions,
120
- ): RemoteModelPresentation {
121
- const {
122
- label,
123
- getCatalog,
124
- maxCallbackIdLength,
125
- allowCallbackSeparators,
126
- quickPickLimit,
127
- groupThreshold = 60,
128
- } = options;
118
+ /** `RemotePresentationOptions` with defaults applied — what every renderer takes. */
119
+ type Knobs = Required<RemotePresentationOptions>;
129
120
 
130
- function isCallbackSafeModelID(modelID: string): boolean {
131
- if (modelID.length > maxCallbackIdLength) return false;
132
- if (allowCallbackSeparators) return true;
133
- return !modelID.includes(":") && !modelID.includes("/");
134
- }
121
+ const LIST_PREVIEW = 24;
122
+ const FREE_PREVIEW = 8;
135
123
 
136
- function getQuickPickModels(
137
- catalog: RemoteModelCatalog,
138
- currentModelID?: string,
139
- ): Array<RemoteModelCatalogEntry> {
140
- const picks: Array<RemoteModelCatalogEntry> = [];
141
- const seen = new Set<string>();
124
+ function isCallbackSafeModelID(knobs: Knobs, modelID: string): boolean {
125
+ if (modelID.length > knobs.maxCallbackIdLength) return false;
126
+ if (knobs.allowCallbackSeparators) return true;
127
+ return !modelID.includes(":") && !modelID.includes("/");
128
+ }
142
129
 
143
- const tryAdd = (model: RemoteModelCatalogEntry | undefined) => {
144
- if (!model || seen.has(model.id) || !isCallbackSafeModelID(model.id))
145
- return;
146
- picks.push(model);
147
- seen.add(model.id);
148
- };
130
+ /** `• <selection> — <name> (<provider> · <price> · <ctx> · <availability>)` */
131
+ function describeModel(
132
+ model: RemoteModelCatalogEntry,
133
+ catalog: RemoteModelCatalog,
134
+ ): string {
135
+ const tags = [
136
+ model.providerName,
137
+ model.free ? "free" : `$${model.costInput}/${model.costOutput}`,
138
+ `${formatCtxWindow(model.contextWindow)} ctx`,
139
+ getAvailabilityLabel(model),
140
+ ];
141
+ return `• ${getRemoteModelSelectionValue(model, catalog)} — ${model.name} (${tags.join(" · ")})`;
142
+ }
149
143
 
150
- if (currentModelID) {
151
- const currentModel = resolveRemoteModelInput(currentModelID, catalog);
152
- if (currentModel.kind === "exact") {
153
- tryAdd(currentModel.model);
154
- } else if (currentModel.kind === "ambiguous") {
155
- tryAdd(currentModel.matches[0]);
156
- }
157
- }
144
+ function describeProvider(
145
+ provider: RemoteModelCatalog["providers"][number],
146
+ ): string {
147
+ const detail = provider.connected
148
+ ? "connected"
149
+ : provider.loginRequired
150
+ ? `login: ${provider.authMethods.join(", ")}`
151
+ : provider.envRequired
152
+ ? `env: ${provider.envKeys.join(", ")}`
153
+ : provider.source;
154
+ return `• ${provider.name} (${provider.id}) — ${detail} · ${provider.modelCount} models`;
155
+ }
158
156
 
159
- for (const model of catalog.connectedFreeModels) {
160
- if (picks.length >= quickPickLimit) break;
161
- tryAdd(model);
162
- }
157
+ function getQuickPickModels(
158
+ knobs: Knobs,
159
+ catalog: RemoteModelCatalog,
160
+ currentModelID?: string,
161
+ ): Array<RemoteModelCatalogEntry> {
162
+ const picks: Array<RemoteModelCatalogEntry> = [];
163
+ const seen = new Set<string>();
164
+
165
+ const tryAdd = (model: RemoteModelCatalogEntry | undefined) => {
166
+ if (!model || seen.has(model.id) || !isCallbackSafeModelID(knobs, model.id))
167
+ return;
168
+ picks.push(model);
169
+ seen.add(model.id);
170
+ };
163
171
 
164
- if (picks.length < quickPickLimit) {
165
- for (const model of catalog.connectedModels) {
166
- if (picks.length >= quickPickLimit) break;
167
- tryAdd(model);
168
- }
172
+ if (currentModelID) {
173
+ const currentModel = resolveRemoteModelInput(currentModelID, catalog);
174
+ if (currentModel.kind === "exact") {
175
+ tryAdd(currentModel.model);
176
+ } else if (currentModel.kind === "ambiguous") {
177
+ tryAdd(currentModel.matches[0]);
169
178
  }
179
+ }
170
180
 
171
- return picks;
181
+ for (const model of catalog.connectedFreeModels) {
182
+ if (picks.length >= knobs.quickPickLimit) break;
183
+ tryAdd(model);
172
184
  }
173
185
 
174
- async function getSettingsPresentation(
175
- activeModel: string,
176
- options: RemotePickerOptions = {},
177
- ): Promise<RemotePickerResult> {
178
- const callbackPrefix = options.callbackPrefix ?? "settings:model:";
179
- const navPrefix = options.navCallbackPrefix ?? "settings:models";
180
- const catalog = await getCatalog();
181
- const current = getRemoteModelInfo(catalog, activeModel);
186
+ if (picks.length < knobs.quickPickLimit) {
187
+ for (const model of catalog.connectedModels) {
188
+ if (picks.length >= knobs.quickPickLimit) break;
189
+ tryAdd(model);
190
+ }
191
+ }
182
192
 
183
- const filter = options.filter === "free" ? "free" : "all";
184
- const selectable = catalog.connectedModels.filter((m) =>
185
- isCallbackSafeModelID(m.id),
186
- );
187
- const freeCount = selectable.filter((m) => m.free).length;
188
- const scoped = selectable.filter(
189
- (m) =>
190
- (filter === "all" || m.free) &&
191
- (!options.provider || m.providerID === options.provider),
192
- );
193
+ return picks;
194
+ }
193
195
 
194
- // A remote catalog can run to hundreds of models, and only the first
195
- // page's worth ever fit on screen — so offer the provider list as the
196
- // way in when nothing narrower was asked for and the flat list would be
197
- // unreadable anyway.
198
- const providers = catalog.connectedProviders;
199
- const asGroups =
200
- !options.provider &&
201
- providers.length > 1 &&
202
- scoped.length > groupThreshold;
196
+ /** One page of the picker: provider chips when the flat list is unreadable, models otherwise. */
197
+ type PickerPage = {
198
+ asGroups: boolean;
199
+ page: number;
200
+ totalPages: number;
201
+ providers: RemoteModelCatalog["connectedProviders"];
202
+ models: Array<RemoteModelCatalogEntry>;
203
+ };
203
204
 
204
- const pageSize = Math.max(1, options.pageSize ?? quickPickLimit);
205
- const source = asGroups ? providers : scoped;
206
- const totalPages = Math.max(1, Math.ceil(source.length / pageSize));
207
- const page = Math.min(Math.max(1, options.page ?? 1), totalPages);
208
- const slice = source.slice((page - 1) * pageSize, page * pageSize);
205
+ function pagePicker(
206
+ knobs: Knobs,
207
+ catalog: RemoteModelCatalog,
208
+ scoped: Array<RemoteModelCatalogEntry>,
209
+ options: RemotePickerOptions,
210
+ ): PickerPage {
211
+ // A remote catalog can run to hundreds of models, and only the first
212
+ // page's worth ever fit on screen — so offer the provider list as the
213
+ // way in when nothing narrower was asked for and the flat list would be
214
+ // unreadable anyway.
215
+ const providers = catalog.connectedProviders;
216
+ const asGroups =
217
+ !options.provider &&
218
+ providers.length > 1 &&
219
+ scoped.length > knobs.groupThreshold;
220
+ const pageSize = Math.max(1, options.pageSize ?? knobs.quickPickLimit);
221
+ const source = asGroups ? providers : scoped;
222
+ const totalPages = Math.max(1, Math.ceil(source.length / pageSize));
223
+ const page = Math.min(Math.max(1, options.page ?? 1), totalPages);
224
+ const from = (page - 1) * pageSize;
225
+ return {
226
+ asGroups,
227
+ page,
228
+ totalPages,
229
+ providers: asGroups ? providers.slice(from, from + pageSize) : [],
230
+ models: asGroups ? [] : scoped.slice(from, from + pageSize),
231
+ };
232
+ }
209
233
 
210
- const modelButtons: Array<ModelButton> = asGroups
211
- ? (slice as typeof providers).map((p) => ({
212
- text: `${p.name} (${p.modelCount})`,
213
- callback_data: `${navPrefix}:provider:${p.id}`,
214
- }))
215
- : (slice as typeof scoped).map((m) => {
216
- const btnLabel =
217
- m.id.length <= 20 ? m.id : m.name.length <= 20 ? m.name : m.id;
218
- const txt = m.free ? `${btnLabel} ★` : btnLabel;
219
- const sel =
220
- current &&
221
- m.id === current.id &&
222
- m.providerID === current.providerID;
223
- return {
224
- text: sel ? `✓ ${txt}` : txt,
225
- callback_data: `${callbackPrefix}${m.id}`,
226
- };
227
- });
228
- if (!asGroups) {
229
- modelButtons.push({
230
- text: "Reset",
231
- callback_data: `${callbackPrefix}reset`,
232
- });
233
- }
234
+ function pickerButtons(
235
+ paged: PickerPage,
236
+ current: RemoteModelCatalogEntry | undefined,
237
+ callbackPrefix: string,
238
+ navPrefix: string,
239
+ ): Array<ModelButton> {
240
+ if (paged.asGroups) {
241
+ return paged.providers.map((p) => ({
242
+ text: `${p.name} (${p.modelCount})`,
243
+ callback_data: `${navPrefix}:provider:${p.id}`,
244
+ }));
245
+ }
246
+ const buttons = paged.models.map((m) => {
247
+ const btnLabel =
248
+ m.id.length <= 20 ? m.id : m.name.length <= 20 ? m.name : m.id;
249
+ const txt = m.free ? `${btnLabel} ★` : btnLabel;
250
+ const sel =
251
+ current && m.id === current.id && m.providerID === current.providerID;
252
+ return {
253
+ text: sel ? `✓ ${txt}` : txt,
254
+ callback_data: `${callbackPrefix}${m.id}`,
255
+ };
256
+ });
257
+ buttons.push({ text: "Reset", callback_data: `${callbackPrefix}reset` });
258
+ return buttons;
259
+ }
234
260
 
235
- const details: Array<string> = [];
236
- if (current) {
237
- details.push(
238
- `Provider: ${current.providerName} · ${getAvailabilityLabel(current)}`,
239
- );
240
- details.push(
241
- `Context: ${formatCtxWindow(current.contextWindow)} · reasoning ${current.reasoning ? "yes" : "no"} · tools ${current.toolcall ? "yes" : "no"}`,
242
- );
243
- }
244
- const np = catalog.connectedProviders.length;
245
- const nm = catalog.connectedModels.length;
261
+ function pickerDetails(
262
+ knobs: Knobs,
263
+ catalog: RemoteModelCatalog,
264
+ current: RemoteModelCatalogEntry | undefined,
265
+ ): Array<string> {
266
+ const details: Array<string> = [];
267
+ if (current) {
246
268
  details.push(
247
- `${label}: ${np} provider${np === 1 ? "" : "s"} connected · ${nm} model${nm === 1 ? "" : "s"} usable`,
269
+ `Provider: ${current.providerName} · ${getAvailabilityLabel(current)}`,
270
+ );
271
+ details.push(
272
+ `Context: ${formatCtxWindow(current.contextWindow)} · reasoning ${current.reasoning ? "yes" : "no"} · tools ${current.toolcall ? "yes" : "no"}`,
248
273
  );
249
- if (catalog.loginProviders.length > 0) {
250
- const preview = catalog.loginProviders
251
- .slice(0, 4)
252
- .map((p) => p.name)
253
- .join(", ");
254
- details.push(
255
- `Login available: ${preview}${catalog.loginProviders.length > 4 ? "…" : ""}`,
256
- );
257
- }
258
- details.push("Hint: use /model <name> to switch.");
259
- return {
260
- modelButtons,
261
- modelDetails: details,
262
- view: asGroups ? "groups" : "models",
263
- page,
264
- totalPages,
265
- filter,
266
- freeCount,
267
- totalCount: selectable.length,
268
- ...(options.provider ? { provider: options.provider } : {}),
269
- };
270
274
  }
275
+ const np = catalog.connectedProviders.length;
276
+ const nm = catalog.connectedModels.length;
277
+ details.push(
278
+ `${knobs.label}: ${np} provider${np === 1 ? "" : "s"} connected · ${nm} model${nm === 1 ? "" : "s"} usable`,
279
+ );
280
+ if (catalog.loginProviders.length > 0) {
281
+ const preview = catalog.loginProviders
282
+ .slice(0, 4)
283
+ .map((p) => p.name)
284
+ .join(", ");
285
+ details.push(
286
+ `Login available: ${preview}${catalog.loginProviders.length > 4 ? "…" : ""}`,
287
+ );
288
+ }
289
+ details.push("Hint: use /model <name> to switch.");
290
+ return details;
291
+ }
271
292
 
272
- async function renderModelSummary(
273
- activeModel: string,
274
- defaultModel: string,
275
- ): Promise<{ text: string; quickButtons: Array<ModelButton> }> {
276
- const { modelButtons, modelDetails } =
277
- await getSettingsPresentation(activeModel);
278
- const catalog = await getCatalog();
279
- const current = getRemoteModelInfo(catalog, activeModel);
280
- const currentLabel = current
281
- ? getRemoteModelSelectionValue(current, catalog)
282
- : activeModel;
283
- const freePreview = catalog.connectedFreeModels.slice(0, 8);
293
+ async function getSettingsPresentation(
294
+ knobs: Knobs,
295
+ activeModel: string,
296
+ options: RemotePickerOptions = {},
297
+ ): Promise<RemotePickerResult> {
298
+ const callbackPrefix = options.callbackPrefix ?? "settings:model:";
299
+ const navPrefix = options.navCallbackPrefix ?? "settings:models";
300
+ const catalog = await knobs.getCatalog();
301
+ const current = getRemoteModelInfo(catalog, activeModel);
284
302
 
285
- const lines = [
286
- `Model: ${currentLabel}${activeModel === defaultModel ? " (default)" : ""}`,
287
- ...modelDetails,
288
- ];
289
- if (freePreview.length > 0) {
290
- lines.push("", "Free now");
291
- for (const m of freePreview) {
292
- const tags = [
293
- m.providerName,
294
- m.free ? "free" : `$${m.costInput}/${m.costOutput}`,
295
- `${formatCtxWindow(m.contextWindow)} ctx`,
296
- getAvailabilityLabel(m),
297
- ];
298
- lines.push(
299
- `• ${getRemoteModelSelectionValue(m, catalog)} — ${m.name} (${tags.join(" · ")})`,
300
- );
301
- }
302
- }
303
- return { text: lines.join("\n"), quickButtons: modelButtons };
303
+ const filter = options.filter === "free" ? "free" : "all";
304
+ const selectable = catalog.connectedModels.filter((m) =>
305
+ isCallbackSafeModelID(knobs, m.id),
306
+ );
307
+ const freeCount = selectable.filter((m) => m.free).length;
308
+ const scoped = selectable.filter(
309
+ (m) =>
310
+ (filter === "all" || m.free) &&
311
+ (!options.provider || m.providerID === options.provider),
312
+ );
313
+ const paged = pagePicker(knobs, catalog, scoped, options);
314
+ return {
315
+ modelButtons: pickerButtons(paged, current, callbackPrefix, navPrefix),
316
+ modelDetails: pickerDetails(knobs, catalog, current),
317
+ view: paged.asGroups ? "groups" : "models",
318
+ page: paged.page,
319
+ totalPages: paged.totalPages,
320
+ filter,
321
+ freeCount,
322
+ totalCount: selectable.length,
323
+ ...(options.provider ? { provider: options.provider } : {}),
324
+ };
325
+ }
326
+
327
+ async function renderModelSummary(
328
+ knobs: Knobs,
329
+ activeModel: string,
330
+ defaultModel: string,
331
+ ): Promise<{ text: string; quickButtons: Array<ModelButton> }> {
332
+ const { modelButtons, modelDetails } = await getSettingsPresentation(
333
+ knobs,
334
+ activeModel,
335
+ );
336
+ const catalog = await knobs.getCatalog();
337
+ const current = getRemoteModelInfo(catalog, activeModel);
338
+ const currentLabel = current
339
+ ? getRemoteModelSelectionValue(current, catalog)
340
+ : activeModel;
341
+ const freePreview = catalog.connectedFreeModels.slice(0, FREE_PREVIEW);
342
+
343
+ const lines = [
344
+ `Model: ${currentLabel}${activeModel === defaultModel ? " (default)" : ""}`,
345
+ ...modelDetails,
346
+ ];
347
+ if (freePreview.length > 0) {
348
+ lines.push("", "Free now");
349
+ for (const m of freePreview) lines.push(describeModel(m, catalog));
304
350
  }
351
+ return { text: lines.join("\n"), quickButtons: modelButtons };
352
+ }
305
353
 
306
- async function renderModelList(
307
- mode: "free" | "all" | "providers",
308
- ): Promise<string> {
309
- const catalog = await getCatalog();
310
- if (mode === "providers") {
311
- const lines = [`${label} Providers`];
312
- for (const p of catalog.providers.slice(0, 24)) {
313
- const detail = p.connected
314
- ? "connected"
315
- : p.loginRequired
316
- ? `login: ${p.authMethods.join(", ")}`
317
- : p.envRequired
318
- ? `env: ${p.envKeys.join(", ")}`
319
- : p.source;
320
- lines.push(
321
- `• ${p.name} (${p.id}) — ${detail} · ${p.modelCount} models`,
322
- );
323
- }
324
- if (catalog.providers.length > 24)
325
- lines.push(`…and ${catalog.providers.length - 24} more`);
326
- return lines.join("\n");
327
- }
328
- const source =
329
- mode === "free" ? catalog.connectedFreeModels : catalog.connectedModels;
330
- const title =
331
- mode === "free" ? "Connected Free Models" : "Connected Models";
332
- const lines = [title];
333
- for (const m of source.slice(0, 24)) {
334
- const tags = [
335
- m.providerName,
336
- m.free ? "free" : `$${m.costInput}/${m.costOutput}`,
337
- `${formatCtxWindow(m.contextWindow)} ctx`,
338
- getAvailabilityLabel(m),
339
- ];
340
- lines.push(
341
- `• ${getRemoteModelSelectionValue(m, catalog)} — ${m.name} (${tags.join(" · ")})`,
342
- );
354
+ async function renderModelList(
355
+ knobs: Knobs,
356
+ mode: "free" | "all" | "providers",
357
+ ): Promise<string> {
358
+ const catalog = await knobs.getCatalog();
359
+ if (mode === "providers") {
360
+ const lines = [`${knobs.label} Providers`];
361
+ for (const p of catalog.providers.slice(0, LIST_PREVIEW)) {
362
+ lines.push(describeProvider(p));
343
363
  }
344
- if (source.length > 24) lines.push(`…and ${source.length - 24} more`);
364
+ if (catalog.providers.length > LIST_PREVIEW)
365
+ lines.push(`…and ${catalog.providers.length - LIST_PREVIEW} more`);
345
366
  return lines.join("\n");
346
367
  }
347
-
348
- function formatSelectionError(
349
- input: string,
350
- resolution: Exclude<RemoteModelResolution, { kind: "exact" }>,
351
- catalog: RemoteModelCatalog,
352
- ): string {
353
- if (resolution.kind === "missing")
354
- return `No ${label} model matched "${input}".`;
355
- const preview = resolution.matches
356
- .slice(0, 6)
357
- .map((m) => {
358
- const provider =
359
- m.providerName === m.providerID
360
- ? m.providerName
361
- : `${m.providerName} / ${m.providerID}`;
362
- return `${getRemoteModelSelectionValue(m, catalog)} — ${provider} (${getAvailabilityLabel(m)})`;
363
- })
364
- .join(", ");
365
- return `Model query "${input}" is ambiguous. Try one of: ${preview}`;
368
+ const source =
369
+ mode === "free" ? catalog.connectedFreeModels : catalog.connectedModels;
370
+ const title = mode === "free" ? "Connected Free Models" : "Connected Models";
371
+ const lines = [title];
372
+ for (const m of source.slice(0, LIST_PREVIEW)) {
373
+ lines.push(describeModel(m, catalog));
366
374
  }
375
+ if (source.length > LIST_PREVIEW)
376
+ lines.push(`…and ${source.length - LIST_PREVIEW} more`);
377
+ return lines.join("\n");
378
+ }
379
+
380
+ function formatSelectionError(
381
+ knobs: Knobs,
382
+ input: string,
383
+ resolution: Exclude<RemoteModelResolution, { kind: "exact" }>,
384
+ catalog: RemoteModelCatalog,
385
+ ): string {
386
+ if (resolution.kind === "missing")
387
+ return `No ${knobs.label} model matched "${input}".`;
388
+ const preview = resolution.matches
389
+ .slice(0, 6)
390
+ .map((m) => {
391
+ const provider =
392
+ m.providerName === m.providerID
393
+ ? m.providerName
394
+ : `${m.providerName} / ${m.providerID}`;
395
+ return `${getRemoteModelSelectionValue(m, catalog)} — ${provider} (${getAvailabilityLabel(m)})`;
396
+ })
397
+ .join(", ");
398
+ return `Model query "${input}" is ambiguous. Try one of: ${preview}`;
399
+ }
367
400
 
401
+ export function createRemoteModelPresentation(
402
+ options: RemotePresentationOptions,
403
+ ): RemoteModelPresentation {
404
+ const knobs: Knobs = { groupThreshold: 60, ...options };
368
405
  return {
369
- getQuickPickModels,
370
- getSettingsPresentation,
371
- renderModelSummary,
372
- renderModelList,
373
- formatSelectionError,
406
+ getQuickPickModels: (catalog, currentModelID) =>
407
+ getQuickPickModels(knobs, catalog, currentModelID),
408
+ getSettingsPresentation: (activeModel, pickerOptions) =>
409
+ getSettingsPresentation(knobs, activeModel, pickerOptions),
410
+ renderModelSummary: (activeModel, defaultModel) =>
411
+ renderModelSummary(knobs, activeModel, defaultModel),
412
+ renderModelList: (mode) => renderModelList(knobs, mode),
413
+ formatSelectionError: (input, resolution, catalog) =>
414
+ formatSelectionError(knobs, input, resolution, catalog),
374
415
  };
375
416
  }
@@ -21,7 +21,7 @@ import {
21
21
  TALON_MCP_SERVER_NAME,
22
22
  TALON_PLUGIN_MCP_SERVER_NAME,
23
23
  getChatMcpServerName,
24
- safeMcpNamePart,
24
+ getPluginMcpServerPrefix,
25
25
  } from "./mcp.js";
26
26
 
27
27
  /**
@@ -52,7 +52,7 @@ import {
52
52
  */
53
53
  export function buildPermissionRuleset(chatId: string): RemotePermissionRule[] {
54
54
  const ourServerName = getChatMcpServerName(chatId);
55
- const ourPluginPrefix = `${TALON_PLUGIN_MCP_SERVER_NAME}-${safeMcpNamePart(chatId, "chat")}-`;
55
+ const ourPluginPrefix = getPluginMcpServerPrefix(chatId);
56
56
  return [
57
57
  { permission: "tool", pattern: `${ourServerName}_*`, action: "allow" },
58
58
  {