opencode-telegram-group-topics-bot 0.11.2

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 (101) hide show
  1. package/.env.example +74 -0
  2. package/LICENSE +21 -0
  3. package/README.md +305 -0
  4. package/dist/agent/manager.js +60 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +47 -0
  7. package/dist/bot/commands/abort.js +116 -0
  8. package/dist/bot/commands/commands.js +389 -0
  9. package/dist/bot/commands/constants.js +20 -0
  10. package/dist/bot/commands/definitions.js +25 -0
  11. package/dist/bot/commands/help.js +27 -0
  12. package/dist/bot/commands/models.js +38 -0
  13. package/dist/bot/commands/new.js +247 -0
  14. package/dist/bot/commands/opencode-start.js +85 -0
  15. package/dist/bot/commands/opencode-stop.js +44 -0
  16. package/dist/bot/commands/projects.js +304 -0
  17. package/dist/bot/commands/rename.js +173 -0
  18. package/dist/bot/commands/sessions.js +491 -0
  19. package/dist/bot/commands/start.js +67 -0
  20. package/dist/bot/commands/status.js +138 -0
  21. package/dist/bot/constants.js +49 -0
  22. package/dist/bot/handlers/agent.js +127 -0
  23. package/dist/bot/handlers/context.js +125 -0
  24. package/dist/bot/handlers/document.js +65 -0
  25. package/dist/bot/handlers/inline-menu.js +124 -0
  26. package/dist/bot/handlers/model.js +152 -0
  27. package/dist/bot/handlers/permission.js +281 -0
  28. package/dist/bot/handlers/prompt.js +263 -0
  29. package/dist/bot/handlers/question.js +285 -0
  30. package/dist/bot/handlers/variant.js +147 -0
  31. package/dist/bot/handlers/voice.js +173 -0
  32. package/dist/bot/index.js +945 -0
  33. package/dist/bot/message-patterns.js +4 -0
  34. package/dist/bot/middleware/auth.js +30 -0
  35. package/dist/bot/middleware/interaction-guard.js +80 -0
  36. package/dist/bot/middleware/unknown-command.js +22 -0
  37. package/dist/bot/scope.js +222 -0
  38. package/dist/bot/telegram-constants.js +3 -0
  39. package/dist/bot/telegram-rate-limiter.js +263 -0
  40. package/dist/bot/utils/commands.js +21 -0
  41. package/dist/bot/utils/file-download.js +91 -0
  42. package/dist/bot/utils/keyboard.js +85 -0
  43. package/dist/bot/utils/send-with-markdown-fallback.js +57 -0
  44. package/dist/bot/utils/session-error-filter.js +34 -0
  45. package/dist/bot/utils/topic-link.js +29 -0
  46. package/dist/cli/args.js +98 -0
  47. package/dist/cli.js +80 -0
  48. package/dist/config.js +103 -0
  49. package/dist/i18n/de.js +330 -0
  50. package/dist/i18n/en.js +330 -0
  51. package/dist/i18n/es.js +330 -0
  52. package/dist/i18n/index.js +102 -0
  53. package/dist/i18n/ru.js +330 -0
  54. package/dist/i18n/zh.js +330 -0
  55. package/dist/index.js +28 -0
  56. package/dist/interaction/cleanup.js +24 -0
  57. package/dist/interaction/constants.js +25 -0
  58. package/dist/interaction/guard.js +100 -0
  59. package/dist/interaction/manager.js +113 -0
  60. package/dist/interaction/types.js +1 -0
  61. package/dist/keyboard/manager.js +115 -0
  62. package/dist/keyboard/types.js +1 -0
  63. package/dist/model/capabilities.js +62 -0
  64. package/dist/model/manager.js +257 -0
  65. package/dist/model/types.js +24 -0
  66. package/dist/opencode/client.js +13 -0
  67. package/dist/opencode/events.js +159 -0
  68. package/dist/opencode/prompt-submit-error.js +101 -0
  69. package/dist/permission/manager.js +92 -0
  70. package/dist/permission/types.js +1 -0
  71. package/dist/pinned/manager.js +405 -0
  72. package/dist/pinned/types.js +1 -0
  73. package/dist/process/manager.js +273 -0
  74. package/dist/process/types.js +1 -0
  75. package/dist/project/manager.js +88 -0
  76. package/dist/question/manager.js +186 -0
  77. package/dist/question/types.js +1 -0
  78. package/dist/rename/manager.js +64 -0
  79. package/dist/runtime/bootstrap.js +350 -0
  80. package/dist/runtime/mode.js +74 -0
  81. package/dist/runtime/paths.js +37 -0
  82. package/dist/runtime/process-error-handlers.js +24 -0
  83. package/dist/session/cache-manager.js +455 -0
  84. package/dist/session/manager.js +87 -0
  85. package/dist/settings/manager.js +283 -0
  86. package/dist/stt/client.js +64 -0
  87. package/dist/summary/aggregator.js +625 -0
  88. package/dist/summary/formatter.js +417 -0
  89. package/dist/summary/tool-message-batcher.js +277 -0
  90. package/dist/topic/colors.js +8 -0
  91. package/dist/topic/constants.js +10 -0
  92. package/dist/topic/manager.js +161 -0
  93. package/dist/topic/title-constants.js +2 -0
  94. package/dist/topic/title-format.js +10 -0
  95. package/dist/topic/title-sync.js +17 -0
  96. package/dist/utils/error-format.js +29 -0
  97. package/dist/utils/logger.js +175 -0
  98. package/dist/utils/safe-background-task.js +33 -0
  99. package/dist/variant/manager.js +103 -0
  100. package/dist/variant/types.js +1 -0
  101. package/package.json +76 -0
@@ -0,0 +1,115 @@
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
+ import { SCOPE_CONTEXT, getScopeFromKey, getThreadIdFromScopeKey } from "../bot/scope.js";
8
+ class KeyboardManager {
9
+ stateByScope = new Map();
10
+ api = null;
11
+ chatId = null;
12
+ lastUpdateTimeByScope = new Map();
13
+ UPDATE_DEBOUNCE_MS = 2000;
14
+ getOrCreateState(scopeKey) {
15
+ const existing = this.stateByScope.get(scopeKey);
16
+ if (existing) {
17
+ return existing;
18
+ }
19
+ const currentModel = getStoredModel(scopeKey);
20
+ const state = {
21
+ currentAgent: getStoredAgent(scopeKey),
22
+ currentModel,
23
+ contextInfo: null,
24
+ variantName: formatVariantForButton(currentModel.variant || "default"),
25
+ };
26
+ this.stateByScope.set(scopeKey, state);
27
+ return state;
28
+ }
29
+ initialize(api, chatId, scopeKey = "global") {
30
+ this.api = api;
31
+ this.chatId = chatId;
32
+ const hadState = this.stateByScope.has(scopeKey);
33
+ const state = this.getOrCreateState(scopeKey);
34
+ if (!hadState) {
35
+ logger.debug(`[KeyboardManager] Initialized scope=${scopeKey} agent="${state.currentAgent}", model="${state.currentModel.providerID}/${state.currentModel.modelID}", variant="${state.currentModel.variant || "default"}", chatId=${chatId}`);
36
+ }
37
+ }
38
+ updateAgent(agent, scopeKey = "global") {
39
+ const state = this.getOrCreateState(scopeKey);
40
+ state.currentAgent = agent;
41
+ }
42
+ updateModel(model, scopeKey = "global") {
43
+ const state = this.getOrCreateState(scopeKey);
44
+ state.currentModel = model;
45
+ state.variantName = formatVariantForButton(model.variant || "default");
46
+ }
47
+ updateVariant(variantId, scopeKey = "global") {
48
+ const state = this.getOrCreateState(scopeKey);
49
+ state.variantName = formatVariantForButton(variantId);
50
+ }
51
+ updateContext(tokensUsed, tokensLimit, scopeKey = "global") {
52
+ const state = this.getOrCreateState(scopeKey);
53
+ state.contextInfo = { tokensUsed, tokensLimit };
54
+ }
55
+ clearContext(scopeKey = "global") {
56
+ const state = this.getOrCreateState(scopeKey);
57
+ state.contextInfo = null;
58
+ }
59
+ getContextInfo(scopeKey = "global") {
60
+ return this.getOrCreateState(scopeKey).contextInfo ?? null;
61
+ }
62
+ buildKeyboard(scopeKey) {
63
+ const state = this.getOrCreateState(scopeKey);
64
+ const scope = getScopeFromKey(scopeKey);
65
+ const effectiveContextInfo = scope?.context === SCOPE_CONTEXT.GROUP_GENERAL ? undefined : (state.contextInfo ?? undefined);
66
+ const keyboardOptions = scope?.context === SCOPE_CONTEXT.GROUP_GENERAL
67
+ ? {
68
+ contextFirst: true,
69
+ contextLabel: t("keyboard.general_defaults"),
70
+ }
71
+ : undefined;
72
+ return createMainKeyboard(state.currentAgent, state.currentModel, effectiveContextInfo, state.variantName, keyboardOptions);
73
+ }
74
+ async sendKeyboardUpdate(chatId, scopeKey = "global") {
75
+ if (!this.api) {
76
+ logger.warn("[KeyboardManager] API not initialized");
77
+ return;
78
+ }
79
+ const targetChatId = chatId ?? this.chatId;
80
+ if (!targetChatId) {
81
+ logger.warn("[KeyboardManager] No chatId available");
82
+ return;
83
+ }
84
+ const now = Date.now();
85
+ const lastUpdateTime = this.lastUpdateTimeByScope.get(scopeKey) ?? 0;
86
+ if (now - lastUpdateTime < this.UPDATE_DEBOUNCE_MS) {
87
+ return;
88
+ }
89
+ this.lastUpdateTimeByScope.set(scopeKey, now);
90
+ try {
91
+ const keyboard = this.buildKeyboard(scopeKey);
92
+ const threadId = getThreadIdFromScopeKey(scopeKey);
93
+ await this.api.sendMessage(targetChatId, t("keyboard.updated"), {
94
+ reply_markup: keyboard,
95
+ ...(typeof threadId === "number" ? { message_thread_id: threadId } : {}),
96
+ });
97
+ }
98
+ catch (err) {
99
+ logger.error("[KeyboardManager] Failed to send keyboard update:", err);
100
+ }
101
+ }
102
+ getKeyboard(scopeKey = "global") {
103
+ if (!this.stateByScope.has(scopeKey)) {
104
+ return undefined;
105
+ }
106
+ return this.buildKeyboard(scopeKey);
107
+ }
108
+ getState(scopeKey = "global") {
109
+ return this.stateByScope.get(scopeKey);
110
+ }
111
+ isInitialized(scopeKey = "global") {
112
+ return this.stateByScope.has(scopeKey);
113
+ }
114
+ }
115
+ 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,257 @@
1
+ import path from "node:path";
2
+ import { config } from "../config.js";
3
+ import { opencodeClient } from "../opencode/client.js";
4
+ import { getCurrentModel, getScopedModels, setCurrentModel } from "../settings/manager.js";
5
+ import { logger } from "../utils/logger.js";
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
+ export async function reconcileStoredModelSelection() {
171
+ const scopedEntries = Object.entries(getScopedModels()).filter(([, model]) => Boolean(model?.providerID && model.modelID));
172
+ if (scopedEntries.length === 0) {
173
+ return;
174
+ }
175
+ const validModelKeys = await getValidModelKeys();
176
+ if (!validModelKeys) {
177
+ logger.warn("[ModelManager] Skipping stored model validation: model catalog unavailable");
178
+ return;
179
+ }
180
+ const envDefaultModel = getEnvDefaultModel();
181
+ if (!envDefaultModel) {
182
+ logger.warn("[ModelManager] Cannot reconcile unavailable stored models: env default model is missing");
183
+ return;
184
+ }
185
+ const fallbackModel = {
186
+ providerID: envDefaultModel.providerID,
187
+ modelID: envDefaultModel.modelID,
188
+ variant: "default",
189
+ };
190
+ for (const [scopeKey, model] of scopedEntries) {
191
+ const currentModelKey = getModelKey(model.providerID, model.modelID);
192
+ if (validModelKeys.has(currentModelKey)) {
193
+ continue;
194
+ }
195
+ logger.warn(`[ModelManager] Stored model ${currentModelKey} is unavailable in scope ${scopeKey}, falling back to ${fallbackModel.providerID}/${fallbackModel.modelID}`);
196
+ setCurrentModel({ ...fallbackModel }, scopeKey);
197
+ }
198
+ }
199
+ export function __resetModelCatalogCacheForTests() {
200
+ cachedValidModelKeys = null;
201
+ modelCatalogCacheExpiresAt = 0;
202
+ modelCatalogFetchInFlight = null;
203
+ }
204
+ /**
205
+ * Get list of favorite models from OpenCode local state file
206
+ * Falls back to env default model if file is unavailable or empty
207
+ */
208
+ export async function getFavoriteModels() {
209
+ const { favorites } = await getModelSelectionLists();
210
+ return favorites;
211
+ }
212
+ /**
213
+ * Get current model from settings or fallback to config
214
+ * @returns Current model info
215
+ */
216
+ export function fetchCurrentModel(scopeKey = "global") {
217
+ return getStoredModel(scopeKey);
218
+ }
219
+ /**
220
+ * Select model and persist to settings
221
+ * @param modelInfo Model to select
222
+ */
223
+ export function selectModel(modelInfo, scopeKey = "global") {
224
+ logger.info(`[ModelManager] Selected model: ${modelInfo.providerID}/${modelInfo.modelID}`);
225
+ setCurrentModel(modelInfo, scopeKey);
226
+ }
227
+ /**
228
+ * Get stored model from settings (synchronous)
229
+ * ALWAYS returns a model - fallback to config if not found
230
+ * @returns Current model info
231
+ */
232
+ export function getStoredModel(scopeKey = "global") {
233
+ const storedModel = getCurrentModel(scopeKey);
234
+ if (storedModel) {
235
+ // Ensure variant is set (default to "default")
236
+ if (!storedModel.variant) {
237
+ storedModel.variant = "default";
238
+ }
239
+ return storedModel;
240
+ }
241
+ // Fallback to model from config (environment variables)
242
+ if (config.opencode.model.provider && config.opencode.model.modelId) {
243
+ logger.debug("[ModelManager] Using model from config");
244
+ return {
245
+ providerID: config.opencode.model.provider,
246
+ modelID: config.opencode.model.modelId,
247
+ variant: "default",
248
+ };
249
+ }
250
+ // This should not happen if config is properly set
251
+ logger.warn("[ModelManager] No model found in settings or config, returning empty model");
252
+ return {
253
+ providerID: "",
254
+ modelID: "",
255
+ variant: "default",
256
+ };
257
+ }
@@ -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
+ }
@@ -0,0 +1,13 @@
1
+ import { createOpencodeClient } from "@opencode-ai/sdk/v2";
2
+ import { config } from "../config.js";
3
+ const getAuth = () => {
4
+ if (!config.opencode.password) {
5
+ return undefined;
6
+ }
7
+ const credentials = `${config.opencode.username}:${config.opencode.password}`;
8
+ return `Basic ${Buffer.from(credentials).toString("base64")}`;
9
+ };
10
+ export const opencodeClient = createOpencodeClient({
11
+ baseUrl: config.opencode.apiUrl,
12
+ headers: config.opencode.password ? { Authorization: getAuth() } : undefined,
13
+ });
@@ -0,0 +1,159 @@
1
+ import { opencodeClient } from "./client.js";
2
+ import { logger } from "../utils/logger.js";
3
+ const RECONNECT_BASE_DELAY_MS = 1000;
4
+ const RECONNECT_MAX_DELAY_MS = 15000;
5
+ const FATAL_NO_STREAM_ERROR = "No stream returned from event subscription";
6
+ const workersByDirectory = new Map();
7
+ function getReconnectDelayMs(attempt) {
8
+ const exponentialDelay = RECONNECT_BASE_DELAY_MS * Math.pow(2, Math.max(0, attempt - 1));
9
+ return Math.min(exponentialDelay, RECONNECT_MAX_DELAY_MS);
10
+ }
11
+ function waitWithAbort(ms, signal) {
12
+ return new Promise((resolve) => {
13
+ if (signal.aborted) {
14
+ resolve(false);
15
+ return;
16
+ }
17
+ const onAbort = () => {
18
+ clearTimeout(timeoutId);
19
+ signal.removeEventListener("abort", onAbort);
20
+ resolve(false);
21
+ };
22
+ const timeoutId = setTimeout(() => {
23
+ signal.removeEventListener("abort", onAbort);
24
+ resolve(true);
25
+ }, ms);
26
+ signal.addEventListener("abort", onAbort, { once: true });
27
+ });
28
+ }
29
+ function getOrCreateWorker(directory) {
30
+ const existingWorker = workersByDirectory.get(directory);
31
+ if (existingWorker) {
32
+ return existingWorker;
33
+ }
34
+ const nextWorker = {
35
+ directory,
36
+ callbacks: new Set(),
37
+ abortController: new AbortController(),
38
+ loopPromise: null,
39
+ };
40
+ workersByDirectory.set(directory, nextWorker);
41
+ return nextWorker;
42
+ }
43
+ function dispatchEvent(worker, event) {
44
+ const callbacks = Array.from(worker.callbacks);
45
+ for (const callback of callbacks) {
46
+ setImmediate(() => {
47
+ try {
48
+ const result = callback(event);
49
+ if (result && typeof result.then === "function") {
50
+ void result.then(undefined, (error) => {
51
+ logger.error("[Events] Async callback rejected", { directory: worker.directory, eventType: event.type }, error);
52
+ });
53
+ }
54
+ }
55
+ catch (error) {
56
+ logger.error("[Events] Event callback failed", { directory: worker.directory, eventType: event.type }, error);
57
+ }
58
+ });
59
+ }
60
+ }
61
+ async function runWorkerLoop(worker) {
62
+ let reconnectAttempt = 0;
63
+ const { directory, abortController } = worker;
64
+ while (!abortController.signal.aborted && worker.callbacks.size > 0) {
65
+ try {
66
+ const result = await opencodeClient.event.subscribe({ directory }, { signal: abortController.signal });
67
+ if (!result.stream) {
68
+ throw new Error(FATAL_NO_STREAM_ERROR);
69
+ }
70
+ reconnectAttempt = 0;
71
+ for await (const event of result.stream) {
72
+ if (abortController.signal.aborted || worker.callbacks.size === 0) {
73
+ break;
74
+ }
75
+ await new Promise((resolve) => setImmediate(resolve));
76
+ dispatchEvent(worker, event);
77
+ }
78
+ if (abortController.signal.aborted || worker.callbacks.size === 0) {
79
+ break;
80
+ }
81
+ reconnectAttempt++;
82
+ const reconnectDelay = getReconnectDelayMs(reconnectAttempt);
83
+ logger.warn(`[Events] Stream ended, reconnecting`, {
84
+ directory,
85
+ reconnectAttempt,
86
+ reconnectDelay,
87
+ });
88
+ const shouldContinue = await waitWithAbort(reconnectDelay, abortController.signal);
89
+ if (!shouldContinue) {
90
+ break;
91
+ }
92
+ }
93
+ catch (error) {
94
+ if (abortController.signal.aborted || worker.callbacks.size === 0) {
95
+ break;
96
+ }
97
+ if (error instanceof Error && error.message === FATAL_NO_STREAM_ERROR) {
98
+ logger.error("[Events] Fatal event stream error", { directory }, error);
99
+ break;
100
+ }
101
+ reconnectAttempt++;
102
+ const reconnectDelay = getReconnectDelayMs(reconnectAttempt);
103
+ logger.error(`[Events] Stream failed, reconnecting`, { directory, reconnectAttempt, reconnectDelay }, error);
104
+ const shouldContinue = await waitWithAbort(reconnectDelay, abortController.signal);
105
+ if (!shouldContinue) {
106
+ break;
107
+ }
108
+ }
109
+ }
110
+ }
111
+ function ensureWorkerStarted(worker) {
112
+ if (worker.loopPromise) {
113
+ return;
114
+ }
115
+ worker.loopPromise = runWorkerLoop(worker)
116
+ .catch((error) => {
117
+ logger.error("[Events] Worker loop failed unexpectedly", { directory: worker.directory }, error);
118
+ })
119
+ .finally(() => {
120
+ worker.loopPromise = null;
121
+ if (worker.callbacks.size === 0 || worker.abortController.signal.aborted) {
122
+ workersByDirectory.delete(worker.directory);
123
+ }
124
+ });
125
+ }
126
+ export async function subscribeToEvents(directory, callback) {
127
+ const worker = getOrCreateWorker(directory);
128
+ worker.callbacks.add(callback);
129
+ ensureWorkerStarted(worker);
130
+ }
131
+ export function unsubscribeFromEvents(directory, callback) {
132
+ const worker = workersByDirectory.get(directory);
133
+ if (!worker) {
134
+ return;
135
+ }
136
+ worker.callbacks.delete(callback);
137
+ if (worker.callbacks.size > 0) {
138
+ return;
139
+ }
140
+ worker.abortController.abort();
141
+ workersByDirectory.delete(directory);
142
+ }
143
+ export function stopEventListening(directory) {
144
+ if (directory) {
145
+ const worker = workersByDirectory.get(directory);
146
+ if (!worker) {
147
+ return;
148
+ }
149
+ worker.abortController.abort();
150
+ workersByDirectory.delete(directory);
151
+ logger.info("[Events] Stopped event listener", { directory });
152
+ return;
153
+ }
154
+ for (const [workerDirectory, worker] of workersByDirectory.entries()) {
155
+ worker.abortController.abort();
156
+ workersByDirectory.delete(workerDirectory);
157
+ }
158
+ logger.info("[Events] Stopped all event listeners");
159
+ }