@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.
- package/README.md +90 -0
- package/bin/bmo.js +188 -0
- package/cli.py +1129 -0
- package/config/__init__.py +0 -0
- package/config/__pycache__/__init__.cpython-313.pyc +0 -0
- package/config/__pycache__/settings.cpython-313.pyc +0 -0
- package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
- package/config/settings.py +104 -0
- package/config/system-prompt.json +18 -0
- package/core/__init__.py +0 -0
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
- package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
- package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
- package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
- package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
- package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
- package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
- package/core/__pycache__/security.cpython-313.pyc +0 -0
- package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
- package/core/bfp_a2a_bridge.py +399 -0
- package/core/bfp_agent.py +98 -0
- package/core/bfp_agent_card.py +161 -0
- package/core/bfp_connector.py +177 -0
- package/core/bfp_discovery.py +105 -0
- package/core/bfp_identity.py +83 -0
- package/core/bfp_tasks.py +70 -0
- package/core/bfp_transport.py +368 -0
- package/core/bmo_engine.py +405 -0
- package/core/bot_client.py +838 -0
- package/core/budget_tracker.py +62 -0
- package/core/cli_renderer.py +177 -0
- package/core/goal_runner.py +129 -0
- package/core/request_worker.py +242 -0
- package/core/security.py +42 -0
- package/core/shared_state.py +4 -0
- package/core/worker_manager.py +71 -0
- package/core/worker_multiproc.py +155 -0
- package/core/worker_protocol.py +30 -0
- package/core/worker_subprocess.py +222 -0
- package/handlers/__init__.py +0 -0
- package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
- package/handlers/messages.py +2761 -0
- package/main.py +125 -0
- package/memory.md +43 -0
- package/models/__init__.py +0 -0
- package/models/__pycache__/__init__.cpython-313.pyc +0 -0
- package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
- package/models/chat_models.py +143 -0
- package/package.json +50 -0
- package/registry/worker.js +108 -0
- package/registry/wrangler.toml +11 -0
- package/requirements.txt +13 -0
- package/scripts/bmo_init.js +115 -0
- package/scripts/postinstall.js +265 -0
- package/scripts/relay_cmd.js +276 -0
- package/scripts/web_cmd.js +136 -0
- package/setup.py +26 -0
- package/storage/__init__.py +0 -0
- package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
- package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
- package/storage/__pycache__/storage.cpython-313.pyc +0 -0
- package/storage/sqlite_storage.py +658 -0
- package/storage/storage.py +265 -0
- package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
- package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
- package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
- package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
- package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
- package/tools/bfp_relay.py +359 -0
- package/tools/bot.db +0 -0
- package/tools/get_session_summaries.py +45 -0
- package/tools/mcp_bridge.py +109 -0
- package/tools/mcp_server.py +531 -0
- package/tools/register_mcp_task.py +20 -0
- package/tools/run_detached.bat +32 -0
- package/tools/run_mcp_standalone.py +16 -0
- package/tools/task_registry.py +184 -0
- package/tools/test_mcp_connection.py +80 -0
- package/webchat/package-lock.json +1528 -0
- package/webchat/package.json +12 -0
- package/webchat/public/app.js +1293 -0
- package/webchat/public/index.html +226 -0
- package/webchat/public/index.html.bak +416 -0
- package/webchat/public/styles.css +2435 -0
- 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
|
+
});
|