arisa 5.1.13 → 5.1.49

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 (49) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +2 -0
  3. package/package.json +8 -10
  4. package/src/core/agent/agent-manager.js +132 -70
  5. package/src/core/agent/pi-runtime.js +0 -8
  6. package/src/core/agent/system-shell-tool.js +13 -2
  7. package/src/core/artifacts/artifact-store.js +17 -18
  8. package/src/core/config/config-defaults.js +2 -2
  9. package/src/core/conversation/session-seed-store.js +85 -0
  10. package/src/core/tools/ipc-client.js +0 -2
  11. package/src/core/tools/official-tool-installer.js +78 -6
  12. package/src/core/tools/tool-dependencies.js +99 -0
  13. package/src/core/tools/tool-output-materializer.js +41 -0
  14. package/src/core/tools/tool-registry.js +145 -28
  15. package/src/official-tools.lock.json +209 -5
  16. package/src/runtime/arisa-capabilities.js +12 -1
  17. package/src/runtime/create-app.js +1 -0
  18. package/src/runtime/doctor.js +72 -23
  19. package/src/runtime/headless-tool-executor.js +2 -32
  20. package/src/runtime/paths.js +7 -1
  21. package/src/runtime/restart-receipt.js +90 -0
  22. package/src/runtime/tool-usage-report.js +25 -10
  23. package/src/transport/telegram/bot.js +403 -1015
  24. package/src/transport/telegram/chat-queue.js +132 -0
  25. package/src/transport/telegram/media.js +2 -2
  26. package/src/transport/telegram/model-callback.js +211 -0
  27. package/src/transport/telegram/model-controls.js +164 -0
  28. package/src/transport/telegram/prompt-builders.js +372 -0
  29. package/src/transport/telegram/task-dispatcher.js +94 -0
  30. package/src/transport/telegram/update-command.js +1 -1
  31. package/src/transport/telegram/workspace-group.js +83 -0
  32. package/test/agent-tool-policy.test.js +7 -1
  33. package/test/capabilities-security.test.js +21 -0
  34. package/test/context-and-task-bounds.test.js +33 -5
  35. package/test/doctor.test.js +57 -4
  36. package/test/model-selection.test.js +47 -1
  37. package/test/official-tool-dependencies.test.js +25 -0
  38. package/test/official-tool-installer.test.js +37 -9
  39. package/test/paths.test.js +4 -4
  40. package/test/restart-receipt.test.js +39 -0
  41. package/test/session-start-operational-notes.test.js +47 -0
  42. package/test/telegram-prompt-builders.test.js +33 -0
  43. package/test/telegram-task-dispatcher.test.js +102 -0
  44. package/test/telegram-workspace-group.test.js +76 -0
  45. package/test/tool-dependencies.test.js +53 -0
  46. package/test/tool-registry-run.test.js +81 -1
  47. package/test/tool-usage.test.js +26 -4
  48. package/test/topic-initialization.test.js +66 -0
  49. package/src/core/conversation/conversation-history-store.js +0 -142
@@ -0,0 +1,132 @@
1
+ export function createChatStateStore() {
2
+ const states = new Map();
3
+
4
+ function reset(chatId) {
5
+ const state = {
6
+ processing: false,
7
+ pendingPrompts: [],
8
+ pendingPromptContexts: [],
9
+ continueAfterClose: false,
10
+ historyRevision: 0,
11
+ beforeNextPrompt: null,
12
+ activeSession: null,
13
+ assistantMessages: new Map(),
14
+ stopQueuedTyping: null
15
+ };
16
+ states.set(String(chatId), state);
17
+ return state;
18
+ }
19
+
20
+ return {
21
+ get(chatId) {
22
+ const key = String(chatId);
23
+ return states.get(key) || reset(key);
24
+ },
25
+ reset,
26
+ anyProcessing() {
27
+ return [...states.values()].some((state) => state.processing);
28
+ }
29
+ };
30
+ }
31
+
32
+ export function queueChatPrompt(chatState, prompt, { replace = false, ctx = null } = {}) {
33
+ chatState.pendingPromptContexts ||= [];
34
+ if (replace) {
35
+ chatState.pendingPrompts = [];
36
+ chatState.pendingPromptContexts = [];
37
+ }
38
+ chatState.pendingPrompts.push(prompt);
39
+ chatState.pendingPromptContexts.push(ctx);
40
+ }
41
+
42
+ function takeQueuedPrompt(chatState) {
43
+ return {
44
+ prompt: chatState.pendingPrompts.shift() || "",
45
+ ctx: (chatState.pendingPromptContexts ||= []).shift() || null
46
+ };
47
+ }
48
+
49
+ export function resolveTelegramBusyMessageMode(config, chatId) {
50
+ const chatMode = config.telegram?.chatMeta?.[String(chatId)]?.busyMessageMode;
51
+ const mode = chatMode || config.telegram?.busyMessageMode;
52
+ return mode === "steer" ? "steer" : "queue";
53
+ }
54
+
55
+ export async function routeBusyPrompt({ chatState, prompt, mode = "queue", replaceQueued = false, ctx = null }) {
56
+ const session = chatState.activeSession;
57
+ if (
58
+ mode === "steer"
59
+ && !replaceQueued
60
+ && !chatState.continueAfterClose
61
+ && !chatState.beforeNextPrompt
62
+ && session?.isStreaming
63
+ && typeof session.steer === "function"
64
+ ) {
65
+ try {
66
+ await session.steer(prompt);
67
+ return { disposition: "steered" };
68
+ } catch (error) {
69
+ queueChatPrompt(chatState, prompt, { ctx });
70
+ return { disposition: "queued", steerError: error };
71
+ }
72
+ }
73
+
74
+ queueChatPrompt(chatState, prompt, { replace: replaceQueued, ctx });
75
+ return { disposition: "queued" };
76
+ }
77
+
78
+ function stopQueuedTyping(chatState) {
79
+ chatState.stopQueuedTyping?.();
80
+ chatState.stopQueuedTyping = null;
81
+ }
82
+
83
+ export async function drainChatPromptQueue({
84
+ chatState,
85
+ initialPrompt,
86
+ initialCtx = null,
87
+ processPrompt,
88
+ onPromptFailure,
89
+ onPromptInterrupted,
90
+ beforeInitialPrompt
91
+ }) {
92
+ let currentPrompt = initialPrompt;
93
+ let currentCtx = initialCtx;
94
+
95
+ try {
96
+ await beforeInitialPrompt?.();
97
+ while (currentPrompt) {
98
+ while (chatState.beforeNextPrompt) {
99
+ const gate = chatState.beforeNextPrompt;
100
+ await gate;
101
+ if (chatState.beforeNextPrompt === gate) chatState.beforeNextPrompt = null;
102
+ }
103
+ if (chatState.continueAfterClose && chatState.pendingPrompts.length) {
104
+ const queued = takeQueuedPrompt(chatState);
105
+ currentPrompt = queued.prompt;
106
+ currentCtx = queued.ctx;
107
+ chatState.continueAfterClose = false;
108
+ }
109
+ try {
110
+ await processPrompt({ prompt: currentPrompt, ctx: currentCtx });
111
+ } catch (error) {
112
+ if (chatState.continueAfterClose && chatState.pendingPrompts.length) {
113
+ await onPromptInterrupted?.(error);
114
+ } else {
115
+ await onPromptFailure?.(error);
116
+ throw error;
117
+ }
118
+ } finally {
119
+ currentCtx = null;
120
+ }
121
+
122
+ const queued = takeQueuedPrompt(chatState);
123
+ currentPrompt = queued.prompt;
124
+ currentCtx = queued.ctx;
125
+ chatState.continueAfterClose = false;
126
+ }
127
+ } finally {
128
+ stopQueuedTyping(chatState);
129
+ chatState.processing = false;
130
+ chatState.activeSession = null;
131
+ }
132
+ }
@@ -46,9 +46,9 @@ export function formatLocationText(message) {
46
46
  return lines.join("\n");
47
47
  }
48
48
 
49
- export async function captureIncomingArtifact(ctx, artifactStore) {
49
+ export async function captureIncomingArtifact(ctx, artifactStore, { storageChatId = ctx.chat.id } = {}) {
50
50
  const chatId = ctx.chat.id;
51
- const store = artifactStore.forChat(chatId);
51
+ const store = artifactStore.forChat(storageChatId);
52
52
  const baseSource = {
53
53
  type: "telegram",
54
54
  chatId,
@@ -0,0 +1,211 @@
1
+ import { getErrorMessage } from "../../core/agent/auth-flow.js";
2
+ import { resolveChatModel, resolveChatSpeed, resolveChatThinkingLevel } from "../../core/agent/model-selection.js";
3
+ import { clampModelThinkingLevel, listModelThinkingLevels, modelSupportsThinking } from "../../core/agent/pi-runtime.js";
4
+ import { modelSupportsSpeed } from "../../core/agent/model-speed.js";
5
+ import { parseEffortPickerAction, parseModelPickerAction, parseSpeedPickerAction } from "./model-picker.js";
6
+
7
+ export async function closeModelPicker(ctx, { messageText, callbackText }) {
8
+ await ctx.api.editMessageText(
9
+ ctx.chat.id,
10
+ ctx.callbackQuery.message.message_id,
11
+ messageText
12
+ );
13
+ await ctx.answerCallbackQuery({ text: callbackText });
14
+ }
15
+
16
+ export function createTelegramModelCallbackHandler({
17
+ config,
18
+ authorizeContext,
19
+ contextRoute,
20
+ getChatState,
21
+ getProviderModels,
22
+ showModelPicker,
23
+ showEffortPicker,
24
+ persistChatModel,
25
+ persistChatEffort,
26
+ persistChatSpeed,
27
+ logger
28
+ }) {
29
+ return async (ctx, next) => {
30
+ const modelAction = parseModelPickerAction(ctx.callbackQuery.data);
31
+ const effortAction = modelAction ? null : parseEffortPickerAction(ctx.callbackQuery.data);
32
+ const speedAction = modelAction || effortAction ? null : parseSpeedPickerAction(ctx.callbackQuery.data);
33
+ const action = modelAction || effortAction || speedAction;
34
+ if (!action) return next();
35
+ if (action.type === "noop") {
36
+ await ctx.answerCallbackQuery();
37
+ return;
38
+ }
39
+
40
+ const auth = await authorizeContext(ctx);
41
+ if (!auth.ok) {
42
+ await ctx.answerCallbackQuery({ text: "This chat is not authorized.", show_alert: true });
43
+ return;
44
+ }
45
+
46
+ const modelChatId = contextRoute(ctx).sessionId;
47
+
48
+ try {
49
+ if (action.type === "page") {
50
+ await showModelPicker(ctx, action.value);
51
+ await ctx.answerCallbackQuery();
52
+ return;
53
+ }
54
+
55
+ const models = await getProviderModels(modelChatId);
56
+ const chatBusy = getChatState(modelChatId).processing;
57
+
58
+ if (action.type === "select") {
59
+ const model = models[action.value];
60
+ if (!model) {
61
+ await ctx.answerCallbackQuery({ text: "This model list is no longer current. Run /model again.", show_alert: true });
62
+ return;
63
+ }
64
+ if (modelSupportsThinking(model)) {
65
+ await showEffortPicker(ctx, {
66
+ model,
67
+ modelIndex: action.value,
68
+ selectedThinkingLevel: clampModelThinkingLevel(model, resolveChatThinkingLevel(config, modelChatId))
69
+ });
70
+ await ctx.answerCallbackQuery({ text: `Choose effort for ${model.id}.` });
71
+ return;
72
+ }
73
+ if (chatBusy) {
74
+ await ctx.answerCallbackQuery({ text: "Wait for the current response before changing models.", show_alert: true });
75
+ return;
76
+ }
77
+
78
+ const currentModelId = resolveChatModel(config, modelChatId);
79
+ const currentEffort = resolveChatThinkingLevel(config, modelChatId);
80
+ if (model.id === currentModelId && currentEffort === "off") {
81
+ await closeModelPicker(ctx, {
82
+ messageText: `Already using ${model.provider}/${model.id}.`,
83
+ callbackText: `Already using ${model.id}.`
84
+ });
85
+ return;
86
+ }
87
+
88
+ await persistChatModel(modelChatId, model, "off");
89
+ await ctx.api.editMessageText(
90
+ ctx.chat.id,
91
+ ctx.callbackQuery.message.message_id,
92
+ `Model changed to ${model.provider}/${model.id}.\nA new chat context will start with your next message.`
93
+ );
94
+ await ctx.answerCallbackQuery({ text: `Using ${model.id}.` });
95
+ return;
96
+ }
97
+
98
+ if (action.type === "model-effort") {
99
+ const model = models[action.modelIndex];
100
+ if (!model) {
101
+ await ctx.answerCallbackQuery({ text: "This model list is no longer current. Run /model again.", show_alert: true });
102
+ return;
103
+ }
104
+ const levels = listModelThinkingLevels(model);
105
+ if (!levels.includes(action.level)) {
106
+ await ctx.answerCallbackQuery({ text: "That effort level is not available for this model.", show_alert: true });
107
+ return;
108
+ }
109
+
110
+ const currentModelId = resolveChatModel(config, modelChatId);
111
+ const currentEffort = resolveChatThinkingLevel(config, modelChatId);
112
+ if (model.id === currentModelId && action.level === currentEffort) {
113
+ await closeModelPicker(ctx, {
114
+ messageText: `Already using ${model.provider}/${model.id} (effort: ${action.level}).`,
115
+ callbackText: `Already using ${model.id} at ${action.level}.`
116
+ });
117
+ return;
118
+ }
119
+ if (model.id === currentModelId) {
120
+ await persistChatEffort(modelChatId, model, action.level);
121
+ await ctx.api.editMessageText(
122
+ ctx.chat.id,
123
+ ctx.callbackQuery.message.message_id,
124
+ `Effort set to ${action.level} for ${model.provider}/${model.id}.`
125
+ );
126
+ await ctx.answerCallbackQuery({ text: `Effort: ${action.level}.` });
127
+ return;
128
+ }
129
+ if (chatBusy) {
130
+ await ctx.answerCallbackQuery({ text: "Wait for the current response before changing models.", show_alert: true });
131
+ return;
132
+ }
133
+
134
+ await persistChatModel(modelChatId, model, action.level);
135
+ await ctx.api.editMessageText(
136
+ ctx.chat.id,
137
+ ctx.callbackQuery.message.message_id,
138
+ `Model changed to ${model.provider}/${model.id} (effort: ${action.level}).\nA new chat context will start with your next message.`
139
+ );
140
+ await ctx.answerCallbackQuery({ text: `Using ${model.id} / ${action.level}.` });
141
+ return;
142
+ }
143
+
144
+ if (action.type === "effort") {
145
+ const model = models.find((item) => item.id === resolveChatModel(config, modelChatId));
146
+ if (!model) {
147
+ await ctx.answerCallbackQuery({ text: "Current model is unavailable. Run /model again.", show_alert: true });
148
+ return;
149
+ }
150
+ if (!modelSupportsThinking(model)) {
151
+ await ctx.answerCallbackQuery({ text: "This model does not support effort levels.", show_alert: true });
152
+ return;
153
+ }
154
+ const levels = listModelThinkingLevels(model);
155
+ if (!levels.includes(action.level)) {
156
+ await ctx.answerCallbackQuery({ text: "That effort level is not available for this model.", show_alert: true });
157
+ return;
158
+ }
159
+ const currentEffort = resolveChatThinkingLevel(config, modelChatId);
160
+ if (action.level === currentEffort) {
161
+ await closeModelPicker(ctx, {
162
+ messageText: `Already using effort ${action.level} for ${model.provider}/${model.id}.`,
163
+ callbackText: `Already using effort ${action.level}.`
164
+ });
165
+ return;
166
+ }
167
+ await persistChatEffort(modelChatId, model, action.level);
168
+ await ctx.api.editMessageText(
169
+ ctx.chat.id,
170
+ ctx.callbackQuery.message.message_id,
171
+ `Effort set to ${action.level} for ${model.provider}/${model.id}.`
172
+ );
173
+ await ctx.answerCallbackQuery({ text: `Effort: ${action.level}.` });
174
+ return;
175
+ }
176
+
177
+ if (action.type === "speed") {
178
+ const model = models.find((item) => item.id === resolveChatModel(config, modelChatId));
179
+ if (!model) {
180
+ await ctx.answerCallbackQuery({ text: "Current model is unavailable. Run /model again.", show_alert: true });
181
+ return;
182
+ }
183
+ if (!modelSupportsSpeed(model)) {
184
+ await ctx.answerCallbackQuery({ text: "This model does not support speed 1.5x.", show_alert: true });
185
+ return;
186
+ }
187
+ const currentSpeed = resolveChatSpeed(config, modelChatId);
188
+ if (action.speed === currentSpeed) {
189
+ await closeModelPicker(ctx, {
190
+ messageText: `Already using speed ${action.speed.toFixed(1)}x for ${model.provider}/${model.id}.`,
191
+ callbackText: `Already using speed ${action.speed.toFixed(1)}x.`
192
+ });
193
+ return;
194
+ }
195
+ await persistChatSpeed(modelChatId, model, action.speed);
196
+ await ctx.api.editMessageText(
197
+ ctx.chat.id,
198
+ ctx.callbackQuery.message.message_id,
199
+ `Speed set to ${action.speed.toFixed(1)}x for ${model.provider}/${model.id}.`
200
+ );
201
+ await ctx.answerCallbackQuery({ text: `Speed: ${action.speed.toFixed(1)}x.` });
202
+ }
203
+ } catch (error) {
204
+ logger?.error("telegram", `model selection failed for chat ${ctx.chat.id}: ${getErrorMessage(error)}`);
205
+ await ctx.answerCallbackQuery({
206
+ text: "Could not change the model, effort, or speed.",
207
+ show_alert: true
208
+ }).catch(() => {});
209
+ }
210
+ };
211
+ }
@@ -0,0 +1,164 @@
1
+ import {
2
+ getAgentConfig,
3
+ resolveChatModel,
4
+ resolveChatSpeed,
5
+ resolveChatThinkingLevel,
6
+ selectChatModel,
7
+ selectChatSpeed,
8
+ selectChatThinkingLevel
9
+ } from "../../core/agent/model-selection.js";
10
+ import { clampModelThinkingLevel, createPiRuntime, listModelThinkingLevels, listProviderModels, modelSupportsThinking } from "../../core/agent/pi-runtime.js";
11
+ import { clampModelSpeed, MODEL_SPEEDS, modelSupportsSpeed } from "../../core/agent/model-speed.js";
12
+ import { buildEffortPicker, buildModelPicker, buildSpeedPicker, reverseModelOrder } from "./model-picker.js";
13
+
14
+ function chatKey(chatId) {
15
+ return String(chatId);
16
+ }
17
+
18
+ async function editOrReplyPicker(ctx, picker) {
19
+ const extra = { reply_markup: picker.replyMarkup };
20
+ const messageId = ctx.callbackQuery?.message?.message_id;
21
+ if (messageId) return ctx.api.editMessageText(ctx.chat.id, messageId, picker.text, extra);
22
+ return ctx.reply(picker.text, extra);
23
+ }
24
+
25
+ async function editOrReplyText(ctx, text) {
26
+ if (ctx.callbackQuery?.message?.message_id) {
27
+ return ctx.api.editMessageText(ctx.chat.id, ctx.callbackQuery.message.message_id, text);
28
+ }
29
+ return ctx.reply(text);
30
+ }
31
+
32
+ function restorePreviousSelection(agentConfig, key, hadSelections, previousSelection) {
33
+ if (previousSelection) {
34
+ agentConfig.chatModels[key] = previousSelection;
35
+ } else {
36
+ delete agentConfig.chatModels[key];
37
+ if (!hadSelections) delete agentConfig.chatModels;
38
+ }
39
+ }
40
+
41
+ export function createTelegramModelControls({ config, saveConfig, agentManager, contextRoute }) {
42
+ async function getProviderModels() {
43
+ const runtime = createPiRuntime({
44
+ provider: config.pi.provider,
45
+ apiKey: config.pi.apiKey
46
+ });
47
+ return reverseModelOrder(listProviderModels(config.pi.provider, runtime));
48
+ }
49
+
50
+ async function showModelPicker(ctx, page = 0) {
51
+ const route = contextRoute(ctx);
52
+ const agentConfig = getAgentConfig(config);
53
+ const picker = buildModelPicker({
54
+ provider: agentConfig.provider,
55
+ models: await getProviderModels(),
56
+ selectedModelId: resolveChatModel(config, route.sessionId),
57
+ selectedThinkingLevel: resolveChatThinkingLevel(config, route.sessionId),
58
+ selectedSpeed: resolveChatSpeed(config, route.sessionId),
59
+ page,
60
+ pageSize: config.telegram.modelPickerPageSize
61
+ });
62
+ return editOrReplyPicker(ctx, picker);
63
+ }
64
+
65
+ async function showEffortPicker(ctx, { model, modelIndex, selectedThinkingLevel } = {}) {
66
+ const route = contextRoute(ctx);
67
+ const agentConfig = getAgentConfig(config);
68
+ const models = await getProviderModels();
69
+ const resolvedModel = model || models.find((item) => item.id === resolveChatModel(config, route.sessionId));
70
+ if (!resolvedModel) throw new Error(`Model not found for provider ${agentConfig.provider}`);
71
+ if (!modelSupportsThinking(resolvedModel)) {
72
+ return editOrReplyText(ctx, `${resolvedModel.provider}/${resolvedModel.id} does not support effort levels.`);
73
+ }
74
+ const picker = buildEffortPicker({
75
+ provider: resolvedModel.provider,
76
+ modelId: resolvedModel.id,
77
+ levels: listModelThinkingLevels(resolvedModel),
78
+ selectedThinkingLevel: selectedThinkingLevel
79
+ ?? clampModelThinkingLevel(resolvedModel, resolveChatThinkingLevel(config, route.sessionId)),
80
+ modelIndex
81
+ });
82
+ return editOrReplyPicker(ctx, picker);
83
+ }
84
+
85
+ async function showSpeedPicker(ctx) {
86
+ const route = contextRoute(ctx);
87
+ const agentConfig = getAgentConfig(config);
88
+ const models = await getProviderModels();
89
+ const model = models.find((item) => item.id === resolveChatModel(config, route.sessionId));
90
+ if (!model) throw new Error(`Model not found for provider ${agentConfig.provider}`);
91
+ if (!modelSupportsSpeed(model)) {
92
+ return editOrReplyText(ctx, `${model.provider}/${model.id} does not support speed 1.5x.`);
93
+ }
94
+ const picker = buildSpeedPicker({
95
+ provider: model.provider,
96
+ modelId: model.id,
97
+ speeds: MODEL_SPEEDS,
98
+ selectedSpeed: resolveChatSpeed(config, route.sessionId)
99
+ });
100
+ return editOrReplyPicker(ctx, picker);
101
+ }
102
+
103
+ async function persistChatModel(chatId, model, thinkingLevel) {
104
+ const agentConfig = getAgentConfig(config);
105
+ const key = chatKey(chatId);
106
+ const hadSelections = Boolean(agentConfig.chatModels);
107
+ const previousSelection = agentConfig.chatModels?.[key];
108
+ const level = clampModelThinkingLevel(model, thinkingLevel ?? resolveChatThinkingLevel(config, chatId));
109
+ const speed = clampModelSpeed(model, resolveChatSpeed(config, chatId));
110
+ selectChatModel(config, chatId, model, { thinkingLevel: level, speed });
111
+ try {
112
+ await saveConfig(config);
113
+ } catch (error) {
114
+ restorePreviousSelection(agentConfig, key, hadSelections, previousSelection);
115
+ throw error;
116
+ }
117
+ agentManager.resetSession(chatId);
118
+ return level;
119
+ }
120
+
121
+ async function persistChatEffort(chatId, model, thinkingLevel) {
122
+ const agentConfig = getAgentConfig(config);
123
+ const key = chatKey(chatId);
124
+ const hadSelections = Boolean(agentConfig.chatModels);
125
+ const previousSelection = agentConfig.chatModels?.[key];
126
+ const level = clampModelThinkingLevel(model, thinkingLevel);
127
+ selectChatThinkingLevel(config, chatId, level);
128
+ try {
129
+ await saveConfig(config);
130
+ } catch (error) {
131
+ restorePreviousSelection(agentConfig, key, hadSelections, previousSelection);
132
+ throw error;
133
+ }
134
+ return level;
135
+ }
136
+
137
+ async function persistChatSpeed(chatId, model, speed) {
138
+ const agentConfig = getAgentConfig(config);
139
+ const key = chatKey(chatId);
140
+ const hadSelections = Boolean(agentConfig.chatModels);
141
+ const previousSelection = agentConfig.chatModels?.[key];
142
+ const level = clampModelSpeed(model, speed);
143
+ await agentManager.setModelSpeed(chatId, level);
144
+ selectChatSpeed(config, chatId, level);
145
+ try {
146
+ await saveConfig(config);
147
+ } catch (error) {
148
+ restorePreviousSelection(agentConfig, key, hadSelections, previousSelection);
149
+ agentManager.clearSessionCache(chatId);
150
+ throw error;
151
+ }
152
+ return level;
153
+ }
154
+
155
+ return {
156
+ getProviderModels,
157
+ showModelPicker,
158
+ showEffortPicker,
159
+ showSpeedPicker,
160
+ persistChatModel,
161
+ persistChatEffort,
162
+ persistChatSpeed
163
+ };
164
+ }