zardbot-telegram 1.0.0

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 (111) hide show
  1. package/.env.example +116 -0
  2. package/LICENSE +21 -0
  3. package/README.md +250 -0
  4. package/dist/agent/manager.js +88 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +49 -0
  7. package/dist/bot/commands/abort.js +121 -0
  8. package/dist/bot/commands/commands.js +480 -0
  9. package/dist/bot/commands/definitions.js +27 -0
  10. package/dist/bot/commands/help.js +10 -0
  11. package/dist/bot/commands/models.js +38 -0
  12. package/dist/bot/commands/new.js +70 -0
  13. package/dist/bot/commands/opencode-start.js +101 -0
  14. package/dist/bot/commands/opencode-stop.js +44 -0
  15. package/dist/bot/commands/projects.js +223 -0
  16. package/dist/bot/commands/rename.js +139 -0
  17. package/dist/bot/commands/sessions.js +351 -0
  18. package/dist/bot/commands/start.js +43 -0
  19. package/dist/bot/commands/status.js +95 -0
  20. package/dist/bot/commands/task.js +399 -0
  21. package/dist/bot/commands/tasklist.js +220 -0
  22. package/dist/bot/commands/voice.js +145 -0
  23. package/dist/bot/handlers/agent.js +118 -0
  24. package/dist/bot/handlers/context.js +100 -0
  25. package/dist/bot/handlers/document.js +65 -0
  26. package/dist/bot/handlers/inline-menu.js +119 -0
  27. package/dist/bot/handlers/model.js +143 -0
  28. package/dist/bot/handlers/permission.js +235 -0
  29. package/dist/bot/handlers/prompt.js +240 -0
  30. package/dist/bot/handlers/question.js +390 -0
  31. package/dist/bot/handlers/tts.js +89 -0
  32. package/dist/bot/handlers/variant.js +138 -0
  33. package/dist/bot/handlers/voice.js +173 -0
  34. package/dist/bot/index.js +977 -0
  35. package/dist/bot/message-patterns.js +4 -0
  36. package/dist/bot/middleware/auth.js +30 -0
  37. package/dist/bot/middleware/interaction-guard.js +95 -0
  38. package/dist/bot/middleware/unknown-command.js +22 -0
  39. package/dist/bot/streaming/response-streamer.js +286 -0
  40. package/dist/bot/streaming/tool-call-streamer.js +285 -0
  41. package/dist/bot/utils/busy-guard.js +15 -0
  42. package/dist/bot/utils/commands.js +21 -0
  43. package/dist/bot/utils/file-download.js +91 -0
  44. package/dist/bot/utils/finalize-assistant-response.js +52 -0
  45. package/dist/bot/utils/keyboard.js +69 -0
  46. package/dist/bot/utils/send-with-markdown-fallback.js +165 -0
  47. package/dist/bot/utils/telegram-text.js +28 -0
  48. package/dist/bot/utils/thinking-message.js +8 -0
  49. package/dist/cli/args.js +98 -0
  50. package/dist/cli.js +80 -0
  51. package/dist/config.js +97 -0
  52. package/dist/i18n/de.js +357 -0
  53. package/dist/i18n/en.js +357 -0
  54. package/dist/i18n/es.js +357 -0
  55. package/dist/i18n/fr.js +357 -0
  56. package/dist/i18n/index.js +109 -0
  57. package/dist/i18n/ru.js +357 -0
  58. package/dist/i18n/zh.js +357 -0
  59. package/dist/index.js +26 -0
  60. package/dist/interaction/busy.js +8 -0
  61. package/dist/interaction/cleanup.js +32 -0
  62. package/dist/interaction/guard.js +140 -0
  63. package/dist/interaction/manager.js +106 -0
  64. package/dist/interaction/types.js +1 -0
  65. package/dist/keyboard/manager.js +172 -0
  66. package/dist/keyboard/types.js +1 -0
  67. package/dist/model/capabilities.js +62 -0
  68. package/dist/model/context-limit.js +57 -0
  69. package/dist/model/manager.js +259 -0
  70. package/dist/model/types.js +24 -0
  71. package/dist/opencode/client.js +13 -0
  72. package/dist/opencode/events.js +140 -0
  73. package/dist/permission/manager.js +100 -0
  74. package/dist/permission/types.js +1 -0
  75. package/dist/pinned/format.js +29 -0
  76. package/dist/pinned/manager.js +682 -0
  77. package/dist/pinned/types.js +1 -0
  78. package/dist/process/manager.js +273 -0
  79. package/dist/process/types.js +1 -0
  80. package/dist/project/manager.js +88 -0
  81. package/dist/question/manager.js +176 -0
  82. package/dist/question/types.js +1 -0
  83. package/dist/rename/manager.js +53 -0
  84. package/dist/runtime/bootstrap.js +350 -0
  85. package/dist/runtime/mode.js +74 -0
  86. package/dist/runtime/paths.js +37 -0
  87. package/dist/scheduled-task/creation-manager.js +113 -0
  88. package/dist/scheduled-task/display.js +239 -0
  89. package/dist/scheduled-task/executor.js +87 -0
  90. package/dist/scheduled-task/foreground-state.js +32 -0
  91. package/dist/scheduled-task/next-run.js +207 -0
  92. package/dist/scheduled-task/runtime.js +368 -0
  93. package/dist/scheduled-task/schedule-parser.js +169 -0
  94. package/dist/scheduled-task/store.js +65 -0
  95. package/dist/scheduled-task/types.js +19 -0
  96. package/dist/session/cache-manager.js +455 -0
  97. package/dist/session/manager.js +10 -0
  98. package/dist/settings/manager.js +158 -0
  99. package/dist/stt/client.js +97 -0
  100. package/dist/summary/aggregator.js +1136 -0
  101. package/dist/summary/formatter.js +491 -0
  102. package/dist/summary/subagent-formatter.js +63 -0
  103. package/dist/summary/tool-message-batcher.js +90 -0
  104. package/dist/tts/client.js +130 -0
  105. package/dist/utils/error-format.js +29 -0
  106. package/dist/utils/logger.js +127 -0
  107. package/dist/utils/safe-background-task.js +33 -0
  108. package/dist/utils/telegram-rate-limit-retry.js +93 -0
  109. package/dist/variant/manager.js +103 -0
  110. package/dist/variant/types.js +1 -0
  111. package/package.json +79 -0
@@ -0,0 +1,106 @@
1
+ import { logger } from "../utils/logger.js";
2
+ export const DEFAULT_ALLOWED_INTERACTION_COMMANDS = ["/help", "/status", "/abort"];
3
+ function normalizeCommand(command) {
4
+ const trimmed = command.trim().toLowerCase();
5
+ if (!trimmed) {
6
+ return null;
7
+ }
8
+ const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
9
+ const withoutMention = withSlash.split("@")[0];
10
+ if (withoutMention.length <= 1) {
11
+ return null;
12
+ }
13
+ return withoutMention;
14
+ }
15
+ function normalizeAllowedCommands(commands) {
16
+ if (commands === undefined) {
17
+ return [...DEFAULT_ALLOWED_INTERACTION_COMMANDS];
18
+ }
19
+ const normalized = new Set();
20
+ for (const command of commands) {
21
+ const value = normalizeCommand(command);
22
+ if (value) {
23
+ normalized.add(value);
24
+ }
25
+ }
26
+ return Array.from(normalized);
27
+ }
28
+ function cloneState(state) {
29
+ return {
30
+ ...state,
31
+ allowedCommands: [...state.allowedCommands],
32
+ metadata: { ...state.metadata },
33
+ };
34
+ }
35
+ class InteractionManager {
36
+ state = null;
37
+ start(options) {
38
+ const now = Date.now();
39
+ let expiresAt = null;
40
+ if (this.state) {
41
+ this.clear("state_replaced");
42
+ }
43
+ if (typeof options.expiresInMs === "number") {
44
+ expiresAt = now + options.expiresInMs;
45
+ }
46
+ const nextState = {
47
+ kind: options.kind,
48
+ expectedInput: options.expectedInput,
49
+ allowedCommands: normalizeAllowedCommands(options.allowedCommands),
50
+ metadata: options.metadata ? { ...options.metadata } : {},
51
+ createdAt: now,
52
+ expiresAt,
53
+ };
54
+ this.state = nextState;
55
+ logger.info(`[InteractionManager] Started interaction: kind=${nextState.kind}, expectedInput=${nextState.expectedInput}, allowedCommands=${nextState.allowedCommands.join(",") || "none"}`);
56
+ return cloneState(nextState);
57
+ }
58
+ get() {
59
+ if (!this.state) {
60
+ return null;
61
+ }
62
+ return cloneState(this.state);
63
+ }
64
+ getSnapshot() {
65
+ return this.get();
66
+ }
67
+ isActive() {
68
+ return this.state !== null;
69
+ }
70
+ isExpired(referenceTimeMs = Date.now()) {
71
+ if (!this.state || this.state.expiresAt === null) {
72
+ return false;
73
+ }
74
+ return referenceTimeMs >= this.state.expiresAt;
75
+ }
76
+ transition(options) {
77
+ if (!this.state) {
78
+ return null;
79
+ }
80
+ const now = Date.now();
81
+ this.state = {
82
+ ...this.state,
83
+ kind: options.kind ?? this.state.kind,
84
+ expectedInput: options.expectedInput ?? this.state.expectedInput,
85
+ allowedCommands: options.allowedCommands !== undefined
86
+ ? normalizeAllowedCommands(options.allowedCommands)
87
+ : [...this.state.allowedCommands],
88
+ metadata: options.metadata ? { ...options.metadata } : { ...this.state.metadata },
89
+ expiresAt: options.expiresInMs === undefined
90
+ ? this.state.expiresAt
91
+ : options.expiresInMs === null
92
+ ? null
93
+ : now + options.expiresInMs,
94
+ };
95
+ logger.debug(`[InteractionManager] Transitioned interaction: kind=${this.state.kind}, expectedInput=${this.state.expectedInput}, allowedCommands=${this.state.allowedCommands.join(",") || "none"}`);
96
+ return cloneState(this.state);
97
+ }
98
+ clear(reason = "manual") {
99
+ if (!this.state) {
100
+ return;
101
+ }
102
+ logger.info(`[InteractionManager] Cleared interaction: reason=${reason}, kind=${this.state.kind}, expectedInput=${this.state.expectedInput}`);
103
+ this.state = null;
104
+ }
105
+ }
106
+ export const interactionManager = new InteractionManager();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,172 @@
1
+ import { createMainKeyboard } from "../bot/utils/keyboard.js";
2
+ import { getStoredAgent } from "../agent/manager.js";
3
+ import { getStoredModel } from "../model/manager.js";
4
+ import { formatVariantForButton } from "../variant/manager.js";
5
+ import { logger } from "../utils/logger.js";
6
+ import { t } from "../i18n/index.js";
7
+ /**
8
+ * Keyboard Manager - manages Reply Keyboard state and updates
9
+ * Singleton pattern
10
+ */
11
+ class KeyboardManager {
12
+ state = null;
13
+ api = null;
14
+ chatId = null;
15
+ lastUpdateTime = 0;
16
+ UPDATE_DEBOUNCE_MS = 2000; // Don't update more than once per 2 seconds
17
+ /**
18
+ * Initialize the keyboard manager with Telegram API and chat ID
19
+ * Loads initial state from settings/config
20
+ */
21
+ initialize(api, chatId) {
22
+ this.api = api;
23
+ this.chatId = chatId;
24
+ // Initialize state from settings/config on first call
25
+ if (!this.state) {
26
+ const currentModel = getStoredModel();
27
+ this.state = {
28
+ currentAgent: getStoredAgent(),
29
+ currentModel: currentModel,
30
+ contextInfo: null,
31
+ variantName: formatVariantForButton(currentModel.variant || "default"),
32
+ };
33
+ logger.debug(`[KeyboardManager] Initialized with agent="${this.state.currentAgent}", model="${this.state.currentModel.providerID}/${this.state.currentModel.modelID}", variant="${currentModel.variant || "default"}", chatId=${chatId}`);
34
+ }
35
+ else {
36
+ logger.debug("[KeyboardManager] Already initialized, updating chatId:", chatId);
37
+ }
38
+ }
39
+ /**
40
+ * Update current agent
41
+ */
42
+ updateAgent(agent) {
43
+ if (!this.state) {
44
+ logger.warn("[KeyboardManager] Cannot update agent: not initialized");
45
+ return;
46
+ }
47
+ this.state.currentAgent = agent;
48
+ logger.debug(`[KeyboardManager] Agent updated: ${agent}`);
49
+ }
50
+ /**
51
+ * Update current model
52
+ */
53
+ updateModel(model) {
54
+ if (!this.state) {
55
+ logger.warn("[KeyboardManager] Cannot update model: not initialized");
56
+ return;
57
+ }
58
+ this.state.currentModel = model;
59
+ this.state.variantName = formatVariantForButton(model.variant || "default");
60
+ logger.debug(`[KeyboardManager] Model updated: ${model.providerID}/${model.modelID}, variant: ${model.variant || "default"}`);
61
+ }
62
+ /**
63
+ * Update current variant
64
+ */
65
+ updateVariant(variantId) {
66
+ if (!this.state) {
67
+ logger.warn("[KeyboardManager] Cannot update variant: not initialized");
68
+ return;
69
+ }
70
+ this.state.variantName = formatVariantForButton(variantId);
71
+ logger.debug(`[KeyboardManager] Variant updated: ${variantId}`);
72
+ }
73
+ /**
74
+ * Update context information
75
+ */
76
+ updateContext(tokensUsed, tokensLimit) {
77
+ if (!this.state) {
78
+ logger.warn("[KeyboardManager] Cannot update context: not initialized");
79
+ return;
80
+ }
81
+ this.state.contextInfo = { tokensUsed, tokensLimit };
82
+ logger.debug(`[KeyboardManager] Context updated: ${tokensUsed}/${tokensLimit}`);
83
+ }
84
+ /**
85
+ * Clear context information
86
+ */
87
+ clearContext() {
88
+ if (!this.state) {
89
+ logger.warn("[KeyboardManager] Cannot clear context: not initialized");
90
+ return;
91
+ }
92
+ this.state.contextInfo = null;
93
+ logger.debug("[KeyboardManager] Context cleared");
94
+ }
95
+ /**
96
+ * Get current context info
97
+ */
98
+ getContextInfo() {
99
+ return this.state?.contextInfo ?? null;
100
+ }
101
+ /**
102
+ * Build keyboard with current state
103
+ */
104
+ buildKeyboard() {
105
+ if (!this.state) {
106
+ logger.warn("[KeyboardManager] Cannot build keyboard: not initialized");
107
+ // Return a minimal keyboard as fallback
108
+ return createMainKeyboard("build", { providerID: "", modelID: "" }, undefined);
109
+ }
110
+ return createMainKeyboard(this.state.currentAgent, this.state.currentModel, this.state.contextInfo ?? undefined, this.state.variantName);
111
+ }
112
+ /**
113
+ * Send keyboard update to user
114
+ * Implements debouncing to avoid rate limits
115
+ */
116
+ async sendKeyboardUpdate(chatId) {
117
+ if (!this.api) {
118
+ logger.warn("[KeyboardManager] API not initialized");
119
+ return;
120
+ }
121
+ const targetChatId = chatId ?? this.chatId;
122
+ if (!targetChatId) {
123
+ logger.warn("[KeyboardManager] No chatId available");
124
+ return;
125
+ }
126
+ // Debounce: don't update more frequently than UPDATE_DEBOUNCE_MS
127
+ const now = Date.now();
128
+ if (now - this.lastUpdateTime < this.UPDATE_DEBOUNCE_MS) {
129
+ logger.debug("[KeyboardManager] Update debounced");
130
+ return;
131
+ }
132
+ this.lastUpdateTime = now;
133
+ try {
134
+ const keyboard = this.buildKeyboard();
135
+ // Send a dummy message with updated keyboard
136
+ // This is needed because Reply Keyboard updates require a message
137
+ await this.api.sendMessage(targetChatId, t("keyboard.updated"), {
138
+ reply_markup: keyboard,
139
+ });
140
+ logger.debug("[KeyboardManager] Keyboard update sent");
141
+ }
142
+ catch (err) {
143
+ logger.error("[KeyboardManager] Failed to send keyboard update:", err);
144
+ }
145
+ }
146
+ /**
147
+ * Update keyboard without sending a message (for use in existing messages)
148
+ * Returns undefined if not initialized (caller should handle this)
149
+ */
150
+ getKeyboard() {
151
+ if (!this.state) {
152
+ logger.warn("[KeyboardManager] Cannot get keyboard: not initialized");
153
+ return undefined;
154
+ }
155
+ return this.buildKeyboard();
156
+ }
157
+ /**
158
+ * Get current keyboard state
159
+ * Returns undefined if not initialized
160
+ */
161
+ getState() {
162
+ return this.state ?? undefined;
163
+ }
164
+ /**
165
+ * Check if keyboard manager is initialized
166
+ */
167
+ isInitialized() {
168
+ return this.state !== null;
169
+ }
170
+ }
171
+ // Export singleton instance
172
+ export const keyboardManager = new KeyboardManager();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ import { opencodeClient } from "../opencode/client.js";
2
+ import { logger } from "../utils/logger.js";
3
+ const capabilitiesCache = {};
4
+ /**
5
+ * Get model capabilities from OpenCode API
6
+ * Results are cached in memory per model
7
+ */
8
+ export async function getModelCapabilities(providerID, modelID) {
9
+ const cacheKey = `${providerID}/${modelID}`;
10
+ if (capabilitiesCache[cacheKey] !== undefined) {
11
+ logger.debug(`[ModelCapabilities] Cache hit for ${cacheKey}`);
12
+ return capabilitiesCache[cacheKey];
13
+ }
14
+ try {
15
+ logger.debug(`[ModelCapabilities] Fetching capabilities for ${cacheKey}`);
16
+ const response = await opencodeClient.config.providers();
17
+ if (response.error || !response.data) {
18
+ logger.error("[ModelCapabilities] API returned error:", response.error);
19
+ capabilitiesCache[cacheKey] = null;
20
+ return null;
21
+ }
22
+ const providers = response.data.providers;
23
+ const provider = providers.find((p) => p.id === providerID);
24
+ if (!provider) {
25
+ logger.warn(`[ModelCapabilities] Provider ${providerID} not found`);
26
+ capabilitiesCache[cacheKey] = null;
27
+ return null;
28
+ }
29
+ const model = provider.models[modelID];
30
+ if (!model) {
31
+ logger.warn(`[ModelCapabilities] Model ${cacheKey} not found in provider`);
32
+ capabilitiesCache[cacheKey] = null;
33
+ return null;
34
+ }
35
+ logger.debug(`[ModelCapabilities] Found capabilities for ${cacheKey}`);
36
+ capabilitiesCache[cacheKey] = model.capabilities;
37
+ return model.capabilities;
38
+ }
39
+ catch (error) {
40
+ logger.error("[ModelCapabilities] Failed to fetch providers:", error);
41
+ capabilitiesCache[cacheKey] = null;
42
+ return null;
43
+ }
44
+ }
45
+ /**
46
+ * Check if model supports a specific input type
47
+ */
48
+ export function supportsInput(capabilities, inputType) {
49
+ if (!capabilities) {
50
+ return false;
51
+ }
52
+ return capabilities.input[inputType] === true;
53
+ }
54
+ /**
55
+ * Check if model supports attachments in general
56
+ */
57
+ export function supportsAttachment(capabilities) {
58
+ if (!capabilities) {
59
+ return false;
60
+ }
61
+ return capabilities.attachment === true;
62
+ }
@@ -0,0 +1,57 @@
1
+ import { opencodeClient } from "../opencode/client.js";
2
+ import { logger } from "../utils/logger.js";
3
+ import { DEFAULT_CONTEXT_LIMIT } from "../pinned/format.js";
4
+ const PROVIDER_CACHE_TTL_MS = 10 * 60 * 1000;
5
+ const contextLimitCache = new Map();
6
+ let providersCacheExpiresAt = 0;
7
+ let providersFetchInFlight = null;
8
+ function getModelKey(providerID, modelID) {
9
+ return `${providerID}/${modelID}`;
10
+ }
11
+ async function refreshContextLimitCache() {
12
+ if (Date.now() < providersCacheExpiresAt) {
13
+ return;
14
+ }
15
+ if (providersFetchInFlight) {
16
+ await providersFetchInFlight;
17
+ return;
18
+ }
19
+ providersFetchInFlight = (async () => {
20
+ try {
21
+ const { data, error } = await opencodeClient.config.providers();
22
+ if (error || !data) {
23
+ logger.warn("[ModelContextLimit] Failed to fetch providers:", error);
24
+ return;
25
+ }
26
+ contextLimitCache.clear();
27
+ for (const provider of data.providers) {
28
+ for (const [modelID, model] of Object.entries(provider.models)) {
29
+ if (model?.limit?.context) {
30
+ contextLimitCache.set(getModelKey(provider.id, modelID), model.limit.context);
31
+ }
32
+ }
33
+ }
34
+ providersCacheExpiresAt = Date.now() + PROVIDER_CACHE_TTL_MS;
35
+ logger.debug(`[ModelContextLimit] Cached limits for ${contextLimitCache.size} provider/model pairs`);
36
+ }
37
+ catch (error) {
38
+ logger.warn("[ModelContextLimit] Error refreshing providers cache:", error);
39
+ }
40
+ finally {
41
+ providersFetchInFlight = null;
42
+ }
43
+ })();
44
+ await providersFetchInFlight;
45
+ }
46
+ export async function getModelContextLimit(providerID, modelID) {
47
+ if (!providerID || !modelID) {
48
+ return DEFAULT_CONTEXT_LIMIT;
49
+ }
50
+ const cacheKey = getModelKey(providerID, modelID);
51
+ const cachedLimit = contextLimitCache.get(cacheKey);
52
+ if (cachedLimit) {
53
+ return cachedLimit;
54
+ }
55
+ await refreshContextLimitCache();
56
+ return contextLimitCache.get(cacheKey) ?? DEFAULT_CONTEXT_LIMIT;
57
+ }
@@ -0,0 +1,259 @@
1
+ import { getCurrentModel, setCurrentModel } from "../settings/manager.js";
2
+ import { config } from "../config.js";
3
+ import { opencodeClient } from "../opencode/client.js";
4
+ import { logger } from "../utils/logger.js";
5
+ import path from "node:path";
6
+ const MODEL_CATALOG_CACHE_TTL_MS = 10 * 60 * 1000;
7
+ let cachedValidModelKeys = null;
8
+ let modelCatalogCacheExpiresAt = 0;
9
+ let modelCatalogFetchInFlight = null;
10
+ function getModelKey(providerID, modelID) {
11
+ return `${providerID}/${modelID}`;
12
+ }
13
+ function getEnvDefaultModel() {
14
+ const providerID = config.opencode.model.provider;
15
+ const modelID = config.opencode.model.modelId;
16
+ if (!providerID || !modelID) {
17
+ return null;
18
+ }
19
+ return { providerID, modelID };
20
+ }
21
+ function dedupeModels(models) {
22
+ const unique = new Map();
23
+ for (const model of models) {
24
+ const key = `${model.providerID}/${model.modelID}`;
25
+ if (!unique.has(key)) {
26
+ unique.set(key, model);
27
+ }
28
+ }
29
+ return Array.from(unique.values());
30
+ }
31
+ function filterModelsByCatalog(models, validModelKeys) {
32
+ if (!validModelKeys) {
33
+ return models;
34
+ }
35
+ return models.filter((model) => validModelKeys.has(getModelKey(model.providerID, model.modelID)));
36
+ }
37
+ async function getValidModelKeys() {
38
+ if (cachedValidModelKeys && Date.now() < modelCatalogCacheExpiresAt) {
39
+ logger.debug(`[ModelManager] Model catalog cache hit: models=${cachedValidModelKeys.size}, ttlMs=${modelCatalogCacheExpiresAt - Date.now()}`);
40
+ return cachedValidModelKeys;
41
+ }
42
+ if (modelCatalogFetchInFlight) {
43
+ logger.debug("[ModelManager] Awaiting in-flight model catalog refresh");
44
+ return modelCatalogFetchInFlight;
45
+ }
46
+ modelCatalogFetchInFlight = (async () => {
47
+ try {
48
+ logger.debug("[ModelManager] Refreshing model catalog from OpenCode API");
49
+ const response = await opencodeClient.config.providers();
50
+ if (response.error || !response.data) {
51
+ logger.warn("[ModelManager] Failed to refresh model catalog:", response.error);
52
+ if (cachedValidModelKeys) {
53
+ logger.warn("[ModelManager] Using stale model catalog cache after refresh failure");
54
+ return cachedValidModelKeys;
55
+ }
56
+ return null;
57
+ }
58
+ const validModelKeys = new Set();
59
+ for (const provider of response.data.providers) {
60
+ for (const modelID of Object.keys(provider.models)) {
61
+ validModelKeys.add(getModelKey(provider.id, modelID));
62
+ }
63
+ }
64
+ cachedValidModelKeys = validModelKeys;
65
+ modelCatalogCacheExpiresAt = Date.now() + MODEL_CATALOG_CACHE_TTL_MS;
66
+ logger.debug(`[ModelManager] Model catalog refreshed: providers=${response.data.providers.length}, models=${validModelKeys.size}`);
67
+ return cachedValidModelKeys;
68
+ }
69
+ catch (err) {
70
+ logger.warn("[ModelManager] Error refreshing model catalog:", err);
71
+ if (cachedValidModelKeys) {
72
+ logger.warn("[ModelManager] Using stale model catalog cache after refresh exception");
73
+ return cachedValidModelKeys;
74
+ }
75
+ return null;
76
+ }
77
+ finally {
78
+ modelCatalogFetchInFlight = null;
79
+ }
80
+ })();
81
+ return modelCatalogFetchInFlight;
82
+ }
83
+ function normalizeFavoriteModels(state) {
84
+ if (!Array.isArray(state.favorite)) {
85
+ return [];
86
+ }
87
+ return state.favorite
88
+ .filter((model) => typeof model?.providerID === "string" &&
89
+ model.providerID.length > 0 &&
90
+ typeof model.modelID === "string" &&
91
+ model.modelID.length > 0)
92
+ .map((model) => ({
93
+ providerID: model.providerID,
94
+ modelID: model.modelID,
95
+ }));
96
+ }
97
+ function normalizeRecentModels(state) {
98
+ if (!Array.isArray(state.recent)) {
99
+ return [];
100
+ }
101
+ return state.recent
102
+ .filter((model) => typeof model?.providerID === "string" &&
103
+ model.providerID.length > 0 &&
104
+ typeof model.modelID === "string" &&
105
+ model.modelID.length > 0)
106
+ .map((model) => ({
107
+ providerID: model.providerID,
108
+ modelID: model.modelID,
109
+ }));
110
+ }
111
+ function getOpenCodeModelStatePath() {
112
+ const xdgStateHome = process.env.XDG_STATE_HOME;
113
+ if (xdgStateHome && xdgStateHome.trim().length > 0) {
114
+ return path.join(xdgStateHome, "opencode", "model.json");
115
+ }
116
+ const homeDir = process.env.HOME || process.env.USERPROFILE || "";
117
+ return path.join(homeDir, ".local", "state", "opencode", "model.json");
118
+ }
119
+ /**
120
+ * Get favorite and recent models from OpenCode local state file.
121
+ * Config model is always treated as favorite.
122
+ */
123
+ export async function getModelSelectionLists() {
124
+ const envDefaultModel = getEnvDefaultModel();
125
+ try {
126
+ const fs = await import("fs/promises");
127
+ const stateFilePath = getOpenCodeModelStatePath();
128
+ const content = await fs.readFile(stateFilePath, "utf-8");
129
+ const state = JSON.parse(content);
130
+ const rawFavorites = normalizeFavoriteModels(state);
131
+ const rawRecent = normalizeRecentModels(state);
132
+ const shouldValidateWithCatalog = rawFavorites.length > 0 || rawRecent.length > 0;
133
+ const validModelKeys = shouldValidateWithCatalog ? await getValidModelKeys() : null;
134
+ const validatedFavorites = filterModelsByCatalog(rawFavorites, validModelKeys);
135
+ const validatedRecent = filterModelsByCatalog(rawRecent, validModelKeys);
136
+ const favorites = envDefaultModel
137
+ ? dedupeModels([...validatedFavorites, envDefaultModel])
138
+ : validatedFavorites;
139
+ if (rawFavorites.length === 0 && envDefaultModel) {
140
+ logger.info(`[ModelManager] No favorites in ${stateFilePath}, using config model as favorite`);
141
+ }
142
+ if (favorites.length === 0) {
143
+ logger.warn(`[ModelManager] No favorites in ${stateFilePath}`);
144
+ }
145
+ const filteredOutFavorites = rawFavorites.length - validatedFavorites.length;
146
+ const filteredOutRecent = rawRecent.length - validatedRecent.length;
147
+ if (filteredOutFavorites > 0 || filteredOutRecent > 0) {
148
+ logger.info(`[ModelManager] Filtered unavailable models from OpenCode state: favoritesRemoved=${filteredOutFavorites}, recentRemoved=${filteredOutRecent}`);
149
+ }
150
+ const favoriteKeys = new Set(favorites.map((model) => getModelKey(model.providerID, model.modelID)));
151
+ const recent = dedupeModels(validatedRecent).filter((model) => !favoriteKeys.has(getModelKey(model.providerID, model.modelID)));
152
+ logger.debug(`[ModelManager] Loaded model selection lists from ${stateFilePath}: favorites=${favorites.length}, recent=${recent.length}`);
153
+ return { favorites, recent };
154
+ }
155
+ catch (err) {
156
+ if (envDefaultModel) {
157
+ logger.warn("[ModelManager] Failed to load OpenCode model state, using config model as favorite:", err);
158
+ return {
159
+ favorites: [envDefaultModel],
160
+ recent: [],
161
+ };
162
+ }
163
+ logger.error("[ModelManager] Failed to load OpenCode model state:", err);
164
+ return {
165
+ favorites: [],
166
+ recent: [],
167
+ };
168
+ }
169
+ }
170
+ /**
171
+ * Validate stored selected model against OpenCode providers catalog.
172
+ * If selected model is unavailable, fallback to env default model.
173
+ */
174
+ export async function reconcileStoredModelSelection() {
175
+ const currentModel = getCurrentModel();
176
+ if (!currentModel?.providerID || !currentModel.modelID) {
177
+ return;
178
+ }
179
+ const validModelKeys = await getValidModelKeys();
180
+ if (!validModelKeys) {
181
+ logger.warn("[ModelManager] Skipping stored model validation: model catalog unavailable");
182
+ return;
183
+ }
184
+ const currentModelKey = getModelKey(currentModel.providerID, currentModel.modelID);
185
+ if (validModelKeys.has(currentModelKey)) {
186
+ return;
187
+ }
188
+ const envDefaultModel = getEnvDefaultModel();
189
+ if (!envDefaultModel) {
190
+ logger.warn(`[ModelManager] Stored model ${currentModelKey} is unavailable and env default model is missing`);
191
+ return;
192
+ }
193
+ const fallbackKey = getModelKey(envDefaultModel.providerID, envDefaultModel.modelID);
194
+ logger.warn(`[ModelManager] Stored model ${currentModelKey} is unavailable, falling back to ${fallbackKey}`);
195
+ setCurrentModel({
196
+ providerID: envDefaultModel.providerID,
197
+ modelID: envDefaultModel.modelID,
198
+ variant: "default",
199
+ });
200
+ }
201
+ export function __resetModelCatalogCacheForTests() {
202
+ cachedValidModelKeys = null;
203
+ modelCatalogCacheExpiresAt = 0;
204
+ modelCatalogFetchInFlight = null;
205
+ }
206
+ /**
207
+ * Get list of favorite models from OpenCode local state file
208
+ * Falls back to env default model if file is unavailable or empty
209
+ */
210
+ export async function getFavoriteModels() {
211
+ const { favorites } = await getModelSelectionLists();
212
+ return favorites;
213
+ }
214
+ /**
215
+ * Get current model from settings or fallback to config
216
+ * @returns Current model info
217
+ */
218
+ export function fetchCurrentModel() {
219
+ return getStoredModel();
220
+ }
221
+ /**
222
+ * Select model and persist to settings
223
+ * @param modelInfo Model to select
224
+ */
225
+ export function selectModel(modelInfo) {
226
+ logger.info(`[ModelManager] Selected model: ${modelInfo.providerID}/${modelInfo.modelID}`);
227
+ setCurrentModel(modelInfo);
228
+ }
229
+ /**
230
+ * Get stored model from settings (synchronous)
231
+ * ALWAYS returns a model - fallback to config if not found
232
+ * @returns Current model info
233
+ */
234
+ export function getStoredModel() {
235
+ const storedModel = getCurrentModel();
236
+ if (storedModel) {
237
+ // Ensure variant is set (default to "default")
238
+ if (!storedModel.variant) {
239
+ storedModel.variant = "default";
240
+ }
241
+ return storedModel;
242
+ }
243
+ // Fallback to model from config (environment variables)
244
+ if (config.opencode.model.provider && config.opencode.model.modelId) {
245
+ logger.debug("[ModelManager] Using model from config");
246
+ return {
247
+ providerID: config.opencode.model.provider,
248
+ modelID: config.opencode.model.modelId,
249
+ variant: "default",
250
+ };
251
+ }
252
+ // This should not happen if config is properly set
253
+ logger.warn("[ModelManager] No model found in settings or config, returning empty model");
254
+ return {
255
+ providerID: "",
256
+ modelID: "",
257
+ variant: "default",
258
+ };
259
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Model types and formatting utilities
3
+ */
4
+ /**
5
+ * Format model for button display (compact format)
6
+ * @param providerID Provider ID
7
+ * @param modelID Model ID
8
+ * @returns Formatted string "providerID/modelID"
9
+ */
10
+ export function formatModelForButton(providerID, modelID) {
11
+ // If model name is too long, we only truncate the model part
12
+ const displayModelId = modelID.length > 20 ? `${modelID.substring(0, 17)}...` : modelID;
13
+ const displayProviderId = providerID.length > 15 ? `${providerID.substring(0, 12)}...` : providerID;
14
+ return `🤖 ${displayProviderId}\n${displayModelId}`;
15
+ }
16
+ /**
17
+ * Format model for display in messages (full format)
18
+ * @param providerID Provider ID
19
+ * @param modelID Model ID
20
+ * @returns Formatted string "providerID / modelID"
21
+ */
22
+ export function formatModelForDisplay(providerID, modelID) {
23
+ return `${providerID} / ${modelID}`;
24
+ }