@aliwey/bmo 2.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 (100) hide show
  1. package/README.md +90 -0
  2. package/bin/bmo.js +188 -0
  3. package/cli.py +1129 -0
  4. package/config/__init__.py +0 -0
  5. package/config/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/config/__pycache__/settings.cpython-313.pyc +0 -0
  7. package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
  8. package/config/settings.py +104 -0
  9. package/config/system-prompt.json +18 -0
  10. package/core/__init__.py +0 -0
  11. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  12. package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
  13. package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
  14. package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
  15. package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
  16. package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
  17. package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
  18. package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
  19. package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
  20. package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
  21. package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
  22. package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
  23. package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
  24. package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
  25. package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
  26. package/core/__pycache__/security.cpython-313.pyc +0 -0
  27. package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
  28. package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
  29. package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
  30. package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
  31. package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
  32. package/core/bfp_a2a_bridge.py +399 -0
  33. package/core/bfp_agent.py +98 -0
  34. package/core/bfp_agent_card.py +161 -0
  35. package/core/bfp_connector.py +177 -0
  36. package/core/bfp_discovery.py +105 -0
  37. package/core/bfp_identity.py +83 -0
  38. package/core/bfp_tasks.py +70 -0
  39. package/core/bfp_transport.py +368 -0
  40. package/core/bmo_engine.py +405 -0
  41. package/core/bot_client.py +838 -0
  42. package/core/budget_tracker.py +62 -0
  43. package/core/cli_renderer.py +177 -0
  44. package/core/goal_runner.py +129 -0
  45. package/core/request_worker.py +242 -0
  46. package/core/security.py +42 -0
  47. package/core/shared_state.py +4 -0
  48. package/core/worker_manager.py +71 -0
  49. package/core/worker_multiproc.py +155 -0
  50. package/core/worker_protocol.py +30 -0
  51. package/core/worker_subprocess.py +222 -0
  52. package/handlers/__init__.py +0 -0
  53. package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
  54. package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
  55. package/handlers/messages.py +2761 -0
  56. package/main.py +125 -0
  57. package/memory.md +43 -0
  58. package/models/__init__.py +0 -0
  59. package/models/__pycache__/__init__.cpython-313.pyc +0 -0
  60. package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
  61. package/models/chat_models.py +143 -0
  62. package/package.json +50 -0
  63. package/registry/worker.js +108 -0
  64. package/registry/wrangler.toml +11 -0
  65. package/requirements.txt +13 -0
  66. package/scripts/bmo_init.js +115 -0
  67. package/scripts/postinstall.js +265 -0
  68. package/scripts/relay_cmd.js +276 -0
  69. package/scripts/web_cmd.js +136 -0
  70. package/setup.py +26 -0
  71. package/storage/__init__.py +0 -0
  72. package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
  73. package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
  74. package/storage/__pycache__/storage.cpython-313.pyc +0 -0
  75. package/storage/sqlite_storage.py +658 -0
  76. package/storage/storage.py +265 -0
  77. package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
  78. package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
  79. package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
  80. package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
  81. package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
  82. package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
  83. package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
  84. package/tools/bfp_relay.py +359 -0
  85. package/tools/bot.db +0 -0
  86. package/tools/get_session_summaries.py +45 -0
  87. package/tools/mcp_bridge.py +109 -0
  88. package/tools/mcp_server.py +531 -0
  89. package/tools/register_mcp_task.py +20 -0
  90. package/tools/run_detached.bat +32 -0
  91. package/tools/run_mcp_standalone.py +16 -0
  92. package/tools/task_registry.py +184 -0
  93. package/tools/test_mcp_connection.py +80 -0
  94. package/webchat/package-lock.json +1528 -0
  95. package/webchat/package.json +12 -0
  96. package/webchat/public/app.js +1293 -0
  97. package/webchat/public/index.html +226 -0
  98. package/webchat/public/index.html.bak +416 -0
  99. package/webchat/public/styles.css +2435 -0
  100. package/webchat/server.js +645 -0
@@ -0,0 +1,645 @@
1
+ const express = require('express');
2
+ const http = require('http');
3
+ const { Server } = require('socket.io');
4
+ const cors = require('cors');
5
+ const path = require('path');
6
+ const fs = require('fs');
7
+ const { v4: uuidv4 } = require('uuid');
8
+ const {
9
+ db, listSessions, getSessionById, getActiveSession, getMessages,
10
+ createSession, setActiveSession, switchSession,
11
+ addMessageStmt, updateSessionTime, getOpenCodeSessionId, setOpenCodeSessionId,
12
+ updateSummary, deleteMessages, updateTitle,
13
+ } = require('./db');
14
+
15
+ // ── Load shared system prompt config (single source of truth) ──
16
+ const configPath = path.join(__dirname, '..', 'config', 'system-prompt.json');
17
+ const systemConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
18
+
19
+ function buildSystemPrompt(mode = 'execute', agent = 'default', chatId = null, sessionId = null) {
20
+ const modeInstruction = systemConfig.mode_prompts[mode] || systemConfig.mode_prompts.execute;
21
+ const agentInstruction = systemConfig.agent_prompts[agent] || '';
22
+
23
+ let chatContext = '';
24
+ if (chatId) {
25
+ chatContext = `\n\n[USER_CONTEXT]\nCURRENT_CHAT_ID: ${chatId}\nCURRENT_SESSION_ID: ${sessionId || 'unknown'}\n[END USER_CONTEXT]`;
26
+ chatContext += '\n\n<b>FILE STORAGE</b>: When creating files, save them inside <code>data/files/</code>. Use date-based subfolders: <code>data/files/{YYYY-MM-DD}/{CURRENT_SESSION_ID}_{HHMMSS}_{filename}</code> so files are linked to sessions and dates.';
27
+ }
28
+
29
+ return systemConfig.base_prompt + systemConfig.memory_instruction + systemConfig.tool_instruction + chatContext + systemConfig.anti_loop + agentInstruction + `\n\nCURRENT PROTOCOL: ${modeInstruction}`;
30
+ }
31
+
32
+ const app = express();
33
+ const server = http.createServer(app);
34
+ const io = new Server(server, { cors: { origin: '*', methods: ['GET', 'POST'] } });
35
+
36
+ app.use(cors());
37
+ app.use(express.json());
38
+ app.use(express.static(path.join(__dirname, 'public')));
39
+
40
+ const OPENCODE_PORT = process.env.OPENCODE_PORT || process.env.OPENCODE_SERVER_PORT || '4800';
41
+ const OPENCODE_URL = `http://127.0.0.1:${OPENCODE_PORT}`;
42
+
43
+ // ── REST Endpoints ────────────────────────────────────────────
44
+
45
+ function resolveChatId(chatId) {
46
+ if (chatId) return chatId;
47
+ const row = db.prepare('SELECT DISTINCT chat_id FROM sessions ORDER BY updated_at DESC LIMIT 1').get();
48
+ return row ? row.chat_id : null;
49
+ }
50
+
51
+ app.get('/api/sessions', (req, res) => {
52
+ const chatId = resolveChatId(parseInt(req.query.chat_id));
53
+ if (!chatId) return res.json([]);
54
+ try {
55
+ const rows = listSessions.all(chatId, chatId);
56
+ const activeRow = getActiveSession.get(chatId);
57
+ const sessions = rows.map(r => ({
58
+ session_id: r.id,
59
+ title: r.title || r.id.slice(0, 8),
60
+ summary: (r.summary || '').slice(0, 100),
61
+ msg_count: r.msg_count,
62
+ updated_at: r.updated_at,
63
+ is_active: r.id === (activeRow ? activeRow.session_id : null),
64
+ }));
65
+ res.json(sessions);
66
+ } catch (e) {
67
+ res.status(500).json({ error: e.message });
68
+ }
69
+ });
70
+
71
+ app.post('/api/new-session', express.json(), (req, res) => {
72
+ const cid = resolveChatId(req.body.chat_id);
73
+ if (!cid) return res.status(400).json({ error: 'chat_id required' });
74
+ const uid = req.body.user_id || cid;
75
+ const now = Date.now() / 1000;
76
+ const sid = uuidv4();
77
+ try {
78
+ createSession.run(sid, cid, uid, req.body.username || '', now, now);
79
+ setActiveSession.run(cid, sid);
80
+ res.json({ session_id: sid });
81
+ } catch (e) {
82
+ res.status(500).json({ error: e.message });
83
+ }
84
+ });
85
+
86
+ app.post('/api/switch-session', express.json(), (req, res) => {
87
+ const { chat_id, session_id } = req.body;
88
+ const cid = resolveChatId(chat_id);
89
+ if (!cid) return res.status(400).json({ error: 'chat_id required' });
90
+ try {
91
+ switchSession.run(cid, session_id);
92
+ res.json({ ok: true });
93
+ } catch (e) {
94
+ res.status(500).json({ error: e.message });
95
+ }
96
+ });
97
+
98
+ app.post('/api/set-session', express.json(), (req, res) => {
99
+ const { chat_id, session_id } = req.body;
100
+ if (!chat_id || !session_id) {
101
+ return res.status(400).json({ error: 'chat_id and session_id required' });
102
+ }
103
+ try {
104
+ const session = getSessionById.get(session_id);
105
+ if (!session) {
106
+ return res.status(404).json({ error: 'Session not found' });
107
+ }
108
+ setActiveSession.run(chat_id, session_id);
109
+ let ocRow = getOpenCodeSessionId.get(session_id);
110
+ let opencode_session_id = ocRow ? ocRow.opencode_session_id : null;
111
+
112
+ // Share Telegram session's OpenCode session for continuity
113
+ if (!opencode_session_id) {
114
+ const telRow = db.prepare(
115
+ `SELECT opencode_session_id FROM sessions
116
+ WHERE chat_id = ? AND opencode_session_id IS NOT NULL
117
+ ORDER BY updated_at DESC LIMIT 1`
118
+ ).get(chat_id);
119
+ if (telRow && telRow.opencode_session_id) {
120
+ setOpenCodeSessionId.run(telRow.opencode_session_id, session_id);
121
+ opencode_session_id = telRow.opencode_session_id;
122
+ }
123
+ }
124
+
125
+ res.json({
126
+ opencode_session_id,
127
+ session: {
128
+ id: session.id,
129
+ title: session.title,
130
+ chat_id: session.chat_id,
131
+ }
132
+ });
133
+ } catch (e) {
134
+ res.status(500).json({ error: e.message });
135
+ }
136
+ });
137
+
138
+ app.get('/api/messages', (req, res) => {
139
+ const sessionId = req.query.session_id;
140
+ const limit = parseInt(req.query.limit) || 50;
141
+ if (!sessionId) return res.status(400).json({ error: 'session_id required' });
142
+ try {
143
+ const msgs = getMessages.all(sessionId, limit);
144
+ res.json(msgs);
145
+ } catch (e) {
146
+ res.status(500).json({ error: e.message });
147
+ }
148
+ });
149
+
150
+ app.post('/api/summarize', express.json(), async (req, res) => {
151
+ const { session_id } = req.body;
152
+ if (!session_id) return res.status(400).json({ error: 'session_id required' });
153
+ try {
154
+ const msgs = getMessages.all(session_id, 50);
155
+ const history = msgs.map(m => `${m.sender === 'user' ? 'User' : 'Assistant'}: ${m.content}`).join('\n');
156
+ const ocSid = await createOpenCodeSession();
157
+ const result = await callOpenCode(`Summarize this conversation concisely:\n\n${history}`, ocSid);
158
+ const now = Date.now() / 1000;
159
+ updateSummary.run(result.text, now, session_id);
160
+ res.json({ summary: result.text });
161
+ } catch (e) {
162
+ res.status(500).json({ error: e.message });
163
+ }
164
+ });
165
+
166
+ app.post('/api/clear', express.json(), (req, res) => {
167
+ const { session_id } = req.body;
168
+ if (!session_id) return res.status(400).json({ error: 'session_id required' });
169
+ try {
170
+ deleteMessages.run(session_id);
171
+ res.json({ ok: true });
172
+ } catch (e) {
173
+ res.status(500).json({ error: e.message });
174
+ }
175
+ });
176
+
177
+ app.get('/api/models', async (req, res) => {
178
+ try {
179
+ const r = await fetch(`${OPENCODE_URL}/provider`);
180
+ if (!r.ok) return res.status(502).json({ error: 'OpenCode unavailable' });
181
+ const data = await r.json();
182
+
183
+ // data has: { all: [...], connected: [...] }
184
+ const allProviders = data.all || [];
185
+ const connectedIds = data.connected || [];
186
+
187
+ const validProviders = [];
188
+ for (const p of allProviders) {
189
+ const pId = p.id;
190
+ const isAccessible = connectedIds.includes(pId);
191
+ const models = p.models || {};
192
+
193
+ const providerModels = [];
194
+ for (const [mid, mData] of Object.entries(models)) {
195
+ const isFree = mid.toLowerCase().includes(':free') ||
196
+ (mData.name || '').toLowerCase().includes('free') ||
197
+ pId === 'opencode';
198
+
199
+ if (isAccessible || isFree) {
200
+ let label = mData.name || mid;
201
+ label = label.replace('-latest', '').replace('-pro', ' Pro').replace('-lite', ' Lite').replace('-flash', ' Flash');
202
+
203
+ providerModels.push({
204
+ id: `${pId}/${mid}`,
205
+ pid: pId,
206
+ mid: mid,
207
+ name: label,
208
+ is_free: isFree,
209
+ });
210
+ }
211
+ }
212
+
213
+ if (providerModels.length > 0) {
214
+ providerModels.sort((a, b) => (a.is_free === b.is_free ? 0 : a.is_free ? -1 : 1));
215
+ validProviders.push({
216
+ id: pId,
217
+ name: p.name || pId,
218
+ models: providerModels,
219
+ });
220
+ }
221
+ }
222
+
223
+ // Ensure 'opencode' is always first
224
+ validProviders.sort((a, b) => (a.id === 'opencode' ? -1 : b.id === 'opencode' ? 1 : 0));
225
+
226
+ res.json({ providers: validProviders, connected: connectedIds });
227
+ } catch (e) {
228
+ res.status(502).json({ error: e.message });
229
+ }
230
+ });
231
+
232
+ app.get('/api/agents', (req, res) => {
233
+ const chatId = resolveChatId(parseInt(req.query.chat_id));
234
+ if (!chatId) return res.json([]);
235
+ try {
236
+ const rows = db.prepare(
237
+ 'SELECT agent_id, name, description, system_prompt FROM user_agents WHERE chat_id = ? ORDER BY created_at DESC'
238
+ ).all(chatId);
239
+ res.json(rows.map(r => ({
240
+ agent_id: r.agent_id,
241
+ name: r.name,
242
+ description: r.description || '',
243
+ system_prompt: r.system_prompt || '',
244
+ })));
245
+ } catch (e) {
246
+ res.status(500).json({ error: e.message });
247
+ }
248
+ });
249
+
250
+ app.post('/api/create-agent', express.json(), (req, res) => {
251
+ const { chat_id, name, description, system_prompt } = req.body;
252
+ if (!name) return res.status(400).json({ error: 'name required' });
253
+ try {
254
+ const now = Date.now() / 1000;
255
+ const uid = resolveChatId(chat_id) || 0;
256
+ if (!uid) return res.status(400).json({ error: 'chat_id required' });
257
+ db.prepare(
258
+ 'INSERT INTO user_agents (chat_id, user_id, name, description, system_prompt, created_at) VALUES (?, ?, ?, ?, ?, ?)'
259
+ ).run(uid, uid, name, description || '', system_prompt || '', now);
260
+ res.json({ ok: true });
261
+ } catch (e) {
262
+ res.status(500).json({ error: e.message });
263
+ }
264
+ });
265
+
266
+ app.post('/api/update-agent', express.json(), (req, res) => {
267
+ const { agent_id, name, description, system_prompt } = req.body;
268
+ if (!agent_id || !name) return res.status(400).json({ error: 'agent_id and name required' });
269
+ try {
270
+ db.prepare(
271
+ 'UPDATE user_agents SET name = ?, description = ?, system_prompt = ? WHERE agent_id = ?'
272
+ ).run(name, description || '', system_prompt || '', agent_id);
273
+ res.json({ ok: true });
274
+ } catch (e) {
275
+ res.status(500).json({ error: e.message });
276
+ }
277
+ });
278
+
279
+ app.post('/api/delete-agent', express.json(), (req, res) => {
280
+ const { agent_id } = req.body;
281
+ if (!agent_id) return res.status(400).json({ error: 'agent_id required' });
282
+ try {
283
+ db.prepare('DELETE FROM user_agents WHERE agent_id = ?').run(agent_id);
284
+ res.json({ ok: true });
285
+ } catch (e) {
286
+ res.status(500).json({ error: e.message });
287
+ }
288
+ });
289
+
290
+ app.post('/api/generate-agent-prompt', express.json(), async (req, res) => {
291
+ const { description } = req.body;
292
+ if (!description) return res.status(400).json({ error: 'description required' });
293
+ try {
294
+ // Generate a structured prompt template locally instead of calling OpenCode
295
+ const systemPrompt = `You are ${description}.
296
+
297
+ Your role and guidelines:
298
+ - Act as: ${description}
299
+ - Tone: Professional, helpful, and engaging
300
+ - Always stay in character
301
+ - Provide clear, accurate, and relevant responses
302
+ - If unsure, ask clarifying questions
303
+
304
+ Remember your identity and purpose in every interaction.`;
305
+
306
+ res.json({ system_prompt: systemPrompt });
307
+ } catch (e) {
308
+ res.status(500).json({ error: e.message });
309
+ }
310
+ });
311
+
312
+ app.post('/api/set-title', express.json(), (req, res) => {
313
+ const { session_id, title } = req.body;
314
+ if (!session_id) return res.status(400).json({ error: 'session_id required' });
315
+ try {
316
+ updateTitle.run(title, Date.now() / 1000, session_id);
317
+ res.json({ ok: true });
318
+ } catch (e) {
319
+ res.status(500).json({ error: e.message });
320
+ }
321
+ });
322
+
323
+ app.post('/api/switch-model', express.json(), async (req, res) => {
324
+ const { session_id, provider, model } = req.body;
325
+ if (!session_id) return res.status(400).json({ error: 'session_id required' });
326
+ try {
327
+ const parts = (model || 'big-pickle').split('/');
328
+ const bareModelId = parts.pop();
329
+ const providerId = provider || parts.pop() || 'opencode';
330
+ const payload = { model: { id: bareModelId, providerID: providerId } };
331
+ const r = await fetch(`${OPENCODE_URL}/session`, {
332
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
333
+ });
334
+ const data = await r.json();
335
+ const newOcSid = data.id;
336
+ const initMsgId = `msg_init_${Date.now()}`;
337
+ await fetch(`${OPENCODE_URL}/session/${newOcSid}/init`, {
338
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
339
+ body: JSON.stringify({ messageID: initMsgId, modelID: bareModelId, providerID: providerId })
340
+ });
341
+ db.prepare('UPDATE sessions SET opencode_session_id = ?, updated_at = ? WHERE id = ?').run(newOcSid, Date.now() / 1000, session_id);
342
+ res.json({ opencode_session_id: newOcSid });
343
+ } catch (e) {
344
+ res.status(500).json({ error: e.message });
345
+ }
346
+ });
347
+
348
+ // ── OpenCode helpers ───────────────────────────────────────────
349
+
350
+ function generateTitle(firstMessage) {
351
+ const words = firstMessage.trim().split(/\s+/);
352
+ if (words.length <= 6) return firstMessage.trim();
353
+ return words.slice(0, 6).join(' ') + '…';
354
+ }
355
+
356
+ async function createOpenCodeSession(modelId = 'qwen3.6-plus-free', providerId = 'opencode') {
357
+ const payload = {
358
+ model: { id: modelId, providerID: providerId }
359
+ };
360
+ const controller = new AbortController();
361
+ const timeout = setTimeout(() => controller.abort(), 10000);
362
+ try {
363
+ const r = await fetch(`${OPENCODE_URL}/session`, {
364
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
365
+ signal: controller.signal
366
+ });
367
+ clearTimeout(timeout);
368
+ if (!r.ok) {
369
+ const errText = await r.text().catch(() => '');
370
+ throw new Error(`create session failed: ${r.status} - ${errText.slice(0, 200)}`);
371
+ }
372
+ const data = await r.json();
373
+ const sessionId = data.id;
374
+ console.log('[OpenCode] Created session:', sessionId, 'model:', modelId);
375
+ return sessionId;
376
+ } catch (e) {
377
+ clearTimeout(timeout);
378
+ throw e;
379
+ }
380
+ }
381
+
382
+ async function callOpenCode(text, sessionId, mode = 'execute', agent = 'default', chatId = null, onReasoning = null, modelId = null, providerId = null) {
383
+ const systemPrompt = buildSystemPrompt(mode, agent, chatId, sessionId);
384
+
385
+ const payload = {
386
+ parts: [
387
+ { type: 'text', text: systemPrompt, synthetic: true },
388
+ { type: 'text', text }
389
+ ]
390
+ };
391
+
392
+ console.log('[OpenCode] Sending to session:', sessionId);
393
+
394
+ async function doSend(sid, timeoutMs = 15000) {
395
+ const controller = new AbortController();
396
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
397
+ try {
398
+ const r = await fetch(`${OPENCODE_URL}/session/${sid}/message`, {
399
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
400
+ signal: controller.signal
401
+ });
402
+ clearTimeout(timeout);
403
+ return r;
404
+ } catch (e) {
405
+ clearTimeout(timeout);
406
+ console.log('[OpenCode] doSend error:', e.message);
407
+ return null;
408
+ }
409
+ }
410
+
411
+ let r = await doSend(sessionId);
412
+
413
+ if (r && r.status === 404) {
414
+ console.log('[OpenCode] Session not found, creating new one...');
415
+ sessionId = await createOpenCodeSession(modelId || 'qwen3.6-plus-free', providerId || 'opencode');
416
+ r = await doSend(sessionId);
417
+ }
418
+
419
+ // 500 with a recoverable model → create new session and retry
420
+ if (r && r.status === 500 && modelId) {
421
+ console.log('[OpenCode] Session 500, creating new session with model:', modelId);
422
+ sessionId = await createOpenCodeSession(modelId, providerId);
423
+ r = await doSend(sessionId, 30000);
424
+ }
425
+
426
+ // Abort/timeout (null r) on a fresh session → retry with longer timeout once
427
+ if (!r && modelId) {
428
+ console.log('[OpenCode] doSend aborted, retrying with 30s timeout...');
429
+ r = await doSend(sessionId, 30000);
430
+ }
431
+
432
+ // Timeout (null r) is not an error — fall through to polling
433
+ if (r && !r.ok) {
434
+ const status = r.status;
435
+ const errText = await r.text().catch(() => '');
436
+ console.log('[OpenCode] Prompt failed:', status, errText.slice(0, 300));
437
+ return { text: `Error: opencode returned status ${status} - ${errText.slice(0, 200)}`, reasoning: '', sessionId };
438
+ }
439
+
440
+ // Try to extract response from the immediate POST response first
441
+ if (r) {
442
+ try {
443
+ console.log('[OpenCode] Parsing POST response...');
444
+ const data = await r.json();
445
+ console.log('[OpenCode] POST response keys:', Object.keys(data));
446
+ const result = extractResponse(data);
447
+ if (result.text) {
448
+ console.log('[OpenCode] Response from POST:', result.text.slice(0, 100));
449
+ return { ...result, sessionId };
450
+ }
451
+ } catch (e) {
452
+ console.log('[OpenCode] Could not parse POST response:', e.message);
453
+ }
454
+ }
455
+
456
+ // Poll for response
457
+ const deadline = Date.now() + 60000;
458
+ const pollInterval = 1500;
459
+ let lastReasoning = '';
460
+
461
+ console.log('[OpenCode] Polling for response...');
462
+
463
+ while (Date.now() < deadline) {
464
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
465
+
466
+ try {
467
+ const controller = new AbortController();
468
+ const pollTimeout = setTimeout(() => controller.abort(), 5000);
469
+ const mr = await fetch(`${OPENCODE_URL}/session/${sessionId}/message?limit=20`, {
470
+ signal: controller.signal
471
+ });
472
+ clearTimeout(pollTimeout);
473
+
474
+ if (!mr.ok) continue;
475
+
476
+ const messages = await mr.json();
477
+ if (!messages || messages.length === 0) continue;
478
+
479
+ const msg = messages[messages.length - 1];
480
+
481
+ // Emit live reasoning updates
482
+ if (onReasoning) {
483
+ let reasoning = '';
484
+ for (const p of (msg.parts || [])) {
485
+ if (p.type === 'reasoning' && p.text) reasoning += p.text;
486
+ }
487
+ if (reasoning && reasoning !== lastReasoning) {
488
+ onReasoning(reasoning);
489
+ lastReasoning = reasoning;
490
+ }
491
+ }
492
+
493
+ const info = msg.info || {};
494
+ if (info.role !== 'assistant') continue;
495
+
496
+ const result = extractResponse(msg);
497
+ if (result.text) {
498
+ console.log('[OpenCode] Response from poll:', result.text.slice(0, 100));
499
+ return { ...result, sessionId };
500
+ }
501
+ } catch (e) {
502
+ console.log('[OpenCode] Poll error:', e.message);
503
+ }
504
+ }
505
+
506
+ console.log('[OpenCode] Poll timeout');
507
+ return { text: '(Response timed out)', reasoning: '', sessionId };
508
+ }
509
+
510
+ function extractResponse(msg) {
511
+ const parts = msg.parts || [];
512
+ const info = msg.info || {};
513
+
514
+ // Check for error parts
515
+ const errorParts = parts.filter(p => p.type === 'error');
516
+ if (errorParts.length > 0) {
517
+ const errorMsg = errorParts[0].message || 'Unknown error';
518
+ return { text: `Error: ${errorMsg}`, reasoning: '' };
519
+ }
520
+
521
+ // Extract reasoning/thinking
522
+ let reasoning = '';
523
+ for (const p of parts) {
524
+ if (p.type === 'reasoning' && p.text) {
525
+ reasoning += p.text;
526
+ }
527
+ }
528
+
529
+ // Extract text parts (skip synthetic)
530
+ const textParts = [];
531
+ for (const p of parts) {
532
+ if (p.synthetic) continue;
533
+ if (p.type === 'text' && p.text) {
534
+ textParts.push(p.text);
535
+ }
536
+ }
537
+
538
+ const responseText = textParts.join('\n').trim();
539
+
540
+ // Check if complete
541
+ const hasFinish = parts.some(p => p.type === 'step-finish');
542
+ const finishReason = info.finish;
543
+ const isComplete = hasFinish || ['stop', 'error', 'length'].includes(finishReason);
544
+
545
+ if (isComplete || responseText) {
546
+ return { text: responseText || '(no response)', reasoning };
547
+ }
548
+
549
+ return { text: '', reasoning };
550
+ }
551
+
552
+ // ── Socket.IO ──────────────────────────────────────────────────
553
+
554
+ io.on('connection', (socket) => {
555
+ console.log('Client connected:', socket.id);
556
+ let activeSessionId = null;
557
+ let activeChatId = null;
558
+
559
+ socket.on('set_session', (sid) => { activeSessionId = sid; });
560
+
561
+ socket.on('user_message', async (data) => {
562
+ const text = data.text;
563
+ const chatId = resolveChatId(data.chat_id) || activeChatId;
564
+ const sid = data.session_id || activeSessionId;
565
+ const model = data.model;
566
+ // Cache chat_id for this socket session
567
+ if (chatId) activeChatId = chatId;
568
+ if (!sid) return socket.emit('error', { message: 'No active session' });
569
+
570
+ console.log('[Socket] user_message:', text.slice(0, 50), 'session:', sid, 'model:', model);
571
+
572
+ const now = Date.now() / 1000;
573
+ addMessageStmt.run(sid, 'user', text, now, 'webchat');
574
+ updateSessionTime.run(now, sid);
575
+ io.emit('message', { id: now, role: 'user', text, time: new Date().toISOString(), session_id: sid });
576
+ io.emit('bmo_status', { status: 'typing' });
577
+
578
+ // Auto-title on 2nd message
579
+ try {
580
+ const msgCount = db.prepare('SELECT COUNT(*) as cnt FROM messages WHERE session_id = ?').get(sid);
581
+ if (msgCount.cnt === 2) {
582
+ const msgs = getMessages.all(sid, 2);
583
+ const firstMsg = msgs[0]?.content || text;
584
+ const title = generateTitle(firstMsg);
585
+ updateTitle.run(title, now, sid);
586
+ io.emit('session_title_updated', { session_id: sid, title });
587
+ }
588
+ } catch (e) {
589
+ console.error('Auto-title error:', e.message);
590
+ }
591
+
592
+ try {
593
+ const row = getOpenCodeSessionId.get(sid);
594
+ let ocSid = row ? row.opencode_session_id : null;
595
+ console.log('[Socket] OpenCode session ID from DB:', ocSid);
596
+
597
+ let modelId = 'qwen3.6-plus-free';
598
+ let providerId = 'opencode';
599
+ if (model) {
600
+ const parts = model.split('/');
601
+ modelId = parts.pop();
602
+ providerId = parts.pop() || 'opencode';
603
+ }
604
+
605
+ if (!ocSid) {
606
+ console.log('[Socket] No OpenCode session, creating new one with model:', modelId);
607
+ ocSid = await createOpenCodeSession(modelId, providerId);
608
+ setOpenCodeSessionId.run(ocSid, sid);
609
+ console.log('[Socket] Saved OpenCode session:', ocSid);
610
+ }
611
+
612
+ const result = await callOpenCode(text, ocSid, 'execute', 'default', chatId, (reasoning) => {
613
+ io.emit('reasoning', { text: reasoning, session_id: sid });
614
+ }, modelId, providerId);
615
+
616
+ // Save new sessionId if callOpenCode recovered from 500/404
617
+ if (result && result.sessionId && result.sessionId !== ocSid) {
618
+ setOpenCodeSessionId.run(result.sessionId, sid);
619
+ console.log('[Socket] Updated OpenCode session after recovery:', result.sessionId);
620
+ }
621
+ const responseText = result.text;
622
+ const reasoningText = result.reasoning || '';
623
+ addMessageStmt.run(sid, 'bmo', responseText, Date.now() / 1000, 'webchat');
624
+ io.emit('message', {
625
+ id: Date.now() / 1000, role: 'bmo', text: responseText, reasoning: reasoningText,
626
+ time: new Date().toISOString(), session_id: sid,
627
+ });
628
+ io.emit('reasoning', { text: null, session_id: sid });
629
+ } catch (e) {
630
+ console.error('[Socket] Error:', e.message);
631
+ io.emit('message', {
632
+ id: Date.now() / 1000, role: 'bmo', text: `Error: ${e.message}`,
633
+ time: new Date().toISOString(), session_id: sid,
634
+ });
635
+ }
636
+ io.emit('bmo_status', { status: 'online' });
637
+ });
638
+
639
+ socket.on('disconnect', () => { console.log('Client disconnected:', socket.id); });
640
+ });
641
+
642
+ const PORT = process.env.PORT || 3456;
643
+ server.listen(PORT, '0.0.0.0', () => {
644
+ console.log(`BMO WebChat running on http://0.0.0.0:${PORT}`);
645
+ });