@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
package/setup.py ADDED
@@ -0,0 +1,26 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="bmo",
5
+ version="2.0.0",
6
+ packages=find_packages(),
7
+ py_modules=["cli"],
8
+ install_requires=[
9
+ "python-telegram-bot>=20.0",
10
+ "python-dotenv>=1.0.0",
11
+ "httpx>=0.28.0",
12
+ "cryptography>=42.0.0",
13
+ "fastapi>=0.100.0",
14
+ "uvicorn>=0.22.0",
15
+ "mcp>=1.0.0",
16
+ "prompt-toolkit>=3.0",
17
+ "rich>=13.0",
18
+ "psutil",
19
+ "watchfiles"
20
+ ],
21
+ entry_points={
22
+ "console_scripts": [
23
+ "bmo=cli:main_run",
24
+ ],
25
+ },
26
+ )
File without changes
@@ -0,0 +1,658 @@
1
+ """
2
+ SQLite storage backend for sessions, messages, and memory.
3
+ Supports per-session isolation, summaries, and full message history.
4
+ """
5
+
6
+ import json
7
+ import sqlite3
8
+ import threading
9
+ from uuid import uuid4
10
+ from datetime import datetime
11
+ from typing import Optional, List, Dict
12
+
13
+ from models.chat_models import ChatSession, UserMemory
14
+ from config.settings import DATABASE_FILE, MAX_HISTORY_PER_CHAT, SESSIONS_FILE
15
+
16
+
17
+ _local = threading.local()
18
+
19
+
20
+ def _get_conn(db_path: str) -> sqlite3.Connection:
21
+ if not hasattr(_local, "conn") or _local.conn is None or getattr(_local, "conn_path", None) != db_path:
22
+ if hasattr(_local, "conn") and _local.conn is not None:
23
+ try:
24
+ _local.conn.close()
25
+ except Exception:
26
+ pass
27
+ _local.conn = sqlite3.connect(db_path, timeout=30.0)
28
+ _local.conn.row_factory = sqlite3.Row
29
+ _local.conn.execute("PRAGMA journal_mode=WAL")
30
+ _local.conn.execute("PRAGMA foreign_keys=ON")
31
+ _local.conn_path = db_path
32
+ return _local.conn
33
+
34
+
35
+ class SQLiteStorage:
36
+ def __init__(self, db_path: str = None):
37
+ self.db_path = db_path or str(DATABASE_FILE)
38
+ self._init_db()
39
+ self._migrate_schema()
40
+ self._migrate_from_jsonl()
41
+
42
+ def _conn(self) -> sqlite3.Connection:
43
+ return _get_conn(self.db_path)
44
+
45
+ def _init_db(self):
46
+ conn = self._conn()
47
+ conn.executescript("""
48
+ CREATE TABLE IF NOT EXISTS sessions (
49
+ id TEXT PRIMARY KEY,
50
+ chat_id INTEGER NOT NULL,
51
+ user_id INTEGER NOT NULL,
52
+ username TEXT,
53
+ title TEXT DEFAULT '',
54
+ created_at REAL NOT NULL,
55
+ updated_at REAL NOT NULL,
56
+ summary TEXT DEFAULT '',
57
+ opencode_session_id TEXT,
58
+ metadata TEXT DEFAULT '{}'
59
+ );
60
+
61
+ CREATE TABLE IF NOT EXISTS messages (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ session_id TEXT NOT NULL,
64
+ sender TEXT NOT NULL,
65
+ content TEXT NOT NULL,
66
+ timestamp REAL NOT NULL,
67
+ message_id INTEGER,
68
+ interface TEXT NOT NULL DEFAULT 'telegram',
69
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
70
+ );
71
+
72
+ CREATE INDEX IF NOT EXISTS idx_messages_session
73
+ ON messages(session_id, timestamp);
74
+
75
+ CREATE INDEX IF NOT EXISTS idx_sessions_chat
76
+ ON sessions(chat_id, updated_at);
77
+
78
+ CREATE TABLE IF NOT EXISTS active_sessions (
79
+ chat_id INTEGER PRIMARY KEY,
80
+ session_id TEXT NOT NULL
81
+ );
82
+
83
+ CREATE TABLE IF NOT EXISTS user_memory (
84
+ chat_id INTEGER PRIMARY KEY,
85
+ user_id INTEGER NOT NULL,
86
+ preferences TEXT DEFAULT '{}',
87
+ knowledge TEXT DEFAULT '{}',
88
+ created_at REAL NOT NULL,
89
+ updated_at REAL NOT NULL
90
+ );
91
+
92
+ CREATE TABLE IF NOT EXISTS user_agents (
93
+ agent_id INTEGER PRIMARY KEY AUTOINCREMENT,
94
+ chat_id INTEGER NOT NULL,
95
+ user_id INTEGER NOT NULL,
96
+ name TEXT NOT NULL,
97
+ description TEXT,
98
+ system_prompt TEXT NOT NULL,
99
+ created_at REAL NOT NULL
100
+ );
101
+
102
+ CREATE TABLE IF NOT EXISTS provider_keys (
103
+ provider_id TEXT NOT NULL,
104
+ provider_name TEXT,
105
+ key_name TEXT NOT NULL,
106
+ encrypted_value TEXT NOT NULL,
107
+ expires_at REAL,
108
+ created_at REAL NOT NULL,
109
+ PRIMARY KEY (provider_id, key_name)
110
+ );
111
+
112
+ CREATE TABLE IF NOT EXISTS permissions (
113
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
114
+ user_id INTEGER NOT NULL,
115
+ scope TEXT NOT NULL,
116
+ granted INTEGER DEFAULT 1,
117
+ created_at REAL NOT NULL,
118
+ UNIQUE(user_id, scope)
119
+ );
120
+ """)
121
+ conn.commit()
122
+
123
+ def _migrate_schema(self):
124
+ try:
125
+ self._exec("ALTER TABLE messages ADD COLUMN interface TEXT NOT NULL DEFAULT 'telegram'")
126
+ self._conn().commit()
127
+ except Exception:
128
+ pass
129
+
130
+ def _exec(self, sql: str, params: tuple = ()) -> sqlite3.Cursor:
131
+ return self._conn().execute(sql, params)
132
+
133
+ def _fetchone(self, sql: str, params: tuple = ()) -> Optional[sqlite3.Row]:
134
+ return self._conn().execute(sql, params).fetchone()
135
+
136
+ def _fetchall(self, sql: str, params: tuple = ()) -> List[sqlite3.Row]:
137
+ return self._conn().execute(sql, params).fetchall()
138
+
139
+ # ── Migration from JSONL ──────────────────────────────────────────────
140
+
141
+ def _migrate_from_jsonl(self):
142
+ old_path = SESSIONS_FILE
143
+ if not old_path.exists():
144
+ return
145
+
146
+ existing = self._fetchone("SELECT COUNT(*) as c FROM sessions")
147
+ if existing and existing["c"] > 0:
148
+ # Already migrated — but fix any broken active_sessions pointers.
149
+ self._fix_active_sessions()
150
+ return
151
+
152
+ try:
153
+ with open(old_path, "r", encoding="utf-8") as f:
154
+ lines = [l.strip() for l in f if l.strip()]
155
+ except Exception:
156
+ return
157
+
158
+ if not lines:
159
+ return
160
+
161
+ conn = self._conn()
162
+ # Track best session per chat (most messages = most relevant)
163
+ best_per_chat: dict = {} # chat_id -> (sid, msg_count)
164
+
165
+ for line in lines:
166
+ try:
167
+ data = json.loads(line)
168
+ sid = data.get("session_id") or str(uuid4())
169
+ chat_id = data.get("chat_id", 0)
170
+ user_id = data.get("user_id", 0)
171
+ metadata = data.get("metadata", {})
172
+ messages = data.get("messages", [])
173
+
174
+ conn.execute(
175
+ """INSERT OR IGNORE INTO sessions
176
+ (id, chat_id, user_id, username, title, created_at, updated_at, opencode_session_id, metadata)
177
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
178
+ (
179
+ sid, chat_id, user_id, data.get("username"),
180
+ data.get("title", ""),
181
+ data.get("created_at", 0), data.get("updated_at", 0),
182
+ metadata.get("opencode_session_id"),
183
+ json.dumps(metadata, ensure_ascii=False),
184
+ ),
185
+ )
186
+
187
+ for msg in messages:
188
+ conn.execute(
189
+ """INSERT INTO messages
190
+ (session_id, sender, content, timestamp, message_id)
191
+ VALUES (?, ?, ?, ?, ?)""",
192
+ (
193
+ sid, msg.get("sender", "user"),
194
+ msg.get("content", ""),
195
+ msg.get("timestamp", 0),
196
+ msg.get("message_id"),
197
+ ),
198
+ )
199
+
200
+ # Track session with most messages as "best" active candidate
201
+ prev = best_per_chat.get(chat_id)
202
+ if prev is None or len(messages) > prev[1]:
203
+ best_per_chat[chat_id] = (sid, len(messages))
204
+
205
+ except Exception as e:
206
+ print(f"Migration skip: {e}")
207
+
208
+ # Set active session to the best (most messages) session per chat
209
+ for chat_id, (sid, _) in best_per_chat.items():
210
+ conn.execute(
211
+ "INSERT OR REPLACE INTO active_sessions (chat_id, session_id) VALUES (?, ?)",
212
+ (chat_id, sid),
213
+ )
214
+
215
+ conn.commit()
216
+ print(f"Migrated {len(lines)} sessions from JSONL to SQLite")
217
+
218
+ def _fix_active_sessions(self):
219
+ """Ensure active_sessions points to the session with the most messages per chat.
220
+ This self-heals any active pointer that points to an empty ghost session."""
221
+ try:
222
+ chat_rows = self._fetchall("SELECT DISTINCT chat_id FROM sessions")
223
+ for chat_row in chat_rows:
224
+ chat_id = chat_row["chat_id"]
225
+ active = self._fetchone(
226
+ "SELECT session_id FROM active_sessions WHERE chat_id = ?",
227
+ (chat_id,),
228
+ )
229
+ if not active:
230
+ continue
231
+ active_sid = active["session_id"]
232
+ active_msg_count = self._fetchone(
233
+ "SELECT COUNT(*) as c FROM messages WHERE session_id = ?",
234
+ (active_sid,),
235
+ )
236
+ # Only fix if active session is empty (ghost session)
237
+ if active_msg_count and active_msg_count["c"] == 0:
238
+ best = self._fetchone(
239
+ """SELECT s.id FROM sessions s
240
+ LEFT JOIN messages m ON m.session_id = s.id
241
+ WHERE s.chat_id = ?
242
+ GROUP BY s.id
243
+ ORDER BY COUNT(m.id) DESC, s.updated_at DESC
244
+ LIMIT 1""",
245
+ (chat_id,),
246
+ )
247
+ if best and best["id"] != active_sid:
248
+ self._exec(
249
+ "UPDATE active_sessions SET session_id = ? WHERE chat_id = ?",
250
+ (best["id"], chat_id),
251
+ )
252
+ print(f"Auto-fixed active session for chat {chat_id} -> {best['id']}")
253
+ self._conn().commit()
254
+ except Exception as e:
255
+ print(f"_fix_active_sessions error: {e}")
256
+
257
+ # ── Session CRUD ──────────────────────────────────────────────────────
258
+
259
+ def save_session(self, session: ChatSession) -> bool:
260
+ """
261
+ Upserts session metadata ONLY (title, summary, opencode_session_id, metadata).
262
+ Does NOT touch the messages table — use add_message() for that.
263
+ This prevents stale in-memory session objects from overwriting DB messages.
264
+ """
265
+ try:
266
+ if not session.session_id:
267
+ session.session_id = str(uuid4())
268
+
269
+ metadata = session.metadata.copy()
270
+ # Sync the column with the metadata entry to avoid discrepancies
271
+ oc_sid = metadata.get("opencode_session_id")
272
+
273
+ metadata_json = json.dumps(metadata, ensure_ascii=False)
274
+ summary = metadata.get("session_summary", "")
275
+
276
+ self._exec(
277
+ """INSERT INTO sessions
278
+ (id, chat_id, user_id, username, title, created_at, updated_at, summary, opencode_session_id, metadata)
279
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
280
+ ON CONFLICT(id) DO UPDATE SET
281
+ title=excluded.title,
282
+ updated_at=excluded.updated_at,
283
+ summary=excluded.summary,
284
+ opencode_session_id=excluded.opencode_session_id,
285
+ metadata=excluded.metadata""",
286
+ (
287
+ session.session_id, session.chat_id, session.user_id,
288
+ session.username, session.title,
289
+ session.created_at, session.updated_at,
290
+ summary, oc_sid,
291
+ metadata_json,
292
+ ),
293
+ )
294
+
295
+ # Only set active session pointer if none exists yet for this chat.
296
+ # This prevents save_session from overwriting a manual session switch.
297
+ existing_active = self._fetchone(
298
+ "SELECT session_id FROM active_sessions WHERE chat_id = ?",
299
+ (session.chat_id,),
300
+ )
301
+ if not existing_active:
302
+ self._exec(
303
+ "INSERT OR REPLACE INTO active_sessions (chat_id, session_id) VALUES (?, ?)",
304
+ (session.chat_id, session.session_id),
305
+ )
306
+
307
+ self._conn().commit()
308
+ return True
309
+ except Exception as e:
310
+ print(f"Error saving session: {e}")
311
+ return False
312
+
313
+ def load_session(self, chat_id: int) -> Optional[ChatSession]:
314
+ try:
315
+ active = self._fetchone(
316
+ "SELECT session_id FROM active_sessions WHERE chat_id = ?",
317
+ (chat_id,),
318
+ )
319
+ session_id = active["session_id"] if active else None
320
+
321
+ if session_id:
322
+ row = self._fetchone(
323
+ "SELECT * FROM sessions WHERE id = ?", (session_id,)
324
+ )
325
+ if row:
326
+ return self._row_to_session(row)
327
+
328
+ candidates = self._fetchall(
329
+ "SELECT * FROM sessions WHERE chat_id = ? ORDER BY updated_at DESC LIMIT 1",
330
+ (chat_id,),
331
+ )
332
+ if candidates:
333
+ row = candidates[0]
334
+ self._exec(
335
+ "INSERT OR REPLACE INTO active_sessions (chat_id, session_id) VALUES (?, ?)",
336
+ (chat_id, dict(row)["id"]),
337
+ )
338
+ self._conn().commit()
339
+ return self._row_to_session(row)
340
+ return None
341
+ except Exception as e:
342
+ print(f"Error loading session: {e}")
343
+ return None
344
+
345
+
346
+ def set_active_session(self, chat_id: int, session_id: str) -> bool:
347
+ """Explicitly switch the active session pointer for a chat."""
348
+ try:
349
+ self._exec(
350
+ "INSERT OR REPLACE INTO active_sessions (chat_id, session_id) VALUES (?, ?)",
351
+ (chat_id, session_id),
352
+ )
353
+ self._conn().commit()
354
+ return True
355
+ except Exception as e:
356
+ print(f"Error setting active session: {e}")
357
+ return False
358
+
359
+ def delete_session(self, chat_id: int) -> bool:
360
+ try:
361
+ active = self._fetchone(
362
+ "SELECT session_id FROM active_sessions WHERE chat_id = ?",
363
+ (chat_id,),
364
+ )
365
+ if active:
366
+ self._exec("DELETE FROM messages WHERE session_id = ?", (active["session_id"],))
367
+ self._exec("DELETE FROM sessions WHERE id = ?", (active["session_id"],))
368
+ self._exec("DELETE FROM active_sessions WHERE chat_id = ?", (chat_id,))
369
+ self._conn().commit()
370
+ return True
371
+ except Exception as e:
372
+ print(f"Error deleting session: {e}")
373
+ return False
374
+
375
+ def add_message(self, chat_id: int, sender: str, content: str, interface: str = 'telegram') -> bool:
376
+ """Insert a single message directly into DB without rewriting the whole session."""
377
+ try:
378
+ active = self._fetchone(
379
+ "SELECT session_id FROM active_sessions WHERE chat_id = ?",
380
+ (chat_id,),
381
+ )
382
+ if not active:
383
+ return False
384
+ session_id = active["session_id"]
385
+
386
+ self._exec(
387
+ "INSERT INTO messages (session_id, sender, content, timestamp, interface) VALUES (?, ?, ?, ?, ?)",
388
+ (session_id, sender, content, datetime.now().timestamp(), interface),
389
+ )
390
+ self._exec(
391
+ "UPDATE sessions SET updated_at = ? WHERE id = ?",
392
+ (datetime.now().timestamp(), session_id),
393
+ )
394
+ self._conn().commit()
395
+ return True
396
+ except Exception as e:
397
+ print(f"Error adding message: {e}")
398
+ return False
399
+
400
+ def get_session_by_id(self, session_id: str) -> Optional[ChatSession]:
401
+ try:
402
+ row = self._fetchone(
403
+ "SELECT * FROM sessions WHERE id = ?", (session_id,)
404
+ )
405
+ return self._row_to_session(row) if row else None
406
+ except Exception as e:
407
+ print(f"Error loading session by ID: {e}")
408
+ return None
409
+
410
+ def get_messages(self, session_id: str, limit: int = 50) -> List[dict]:
411
+ rows = self._fetchall(
412
+ "SELECT sender, content, timestamp, message_id FROM messages WHERE session_id = ? ORDER BY timestamp DESC LIMIT ?",
413
+ (session_id, limit),
414
+ )
415
+ return [dict(r) for r in reversed(rows)]
416
+
417
+ def list_chat_sessions(self, chat_id: int) -> List[dict]:
418
+ rows = self._fetchall(
419
+ """SELECT id, title, summary,
420
+ (SELECT COUNT(*) FROM messages WHERE session_id = sessions.id) as msg_count,
421
+ updated_at,
422
+ (SELECT session_id FROM active_sessions WHERE chat_id = ?) as active_id
423
+ FROM sessions WHERE chat_id = ? ORDER BY updated_at DESC""",
424
+ (chat_id, chat_id),
425
+ )
426
+ return [
427
+ {
428
+ "session_id": r["id"],
429
+ "title": r["title"] or r["id"][:8],
430
+ "summary": r["summary"][:100] if r["summary"] else "",
431
+ "msg_count": r["msg_count"],
432
+ "updated_at": r["updated_at"],
433
+ "is_active": r["id"] == r["active_id"],
434
+ }
435
+ for r in rows
436
+ ]
437
+
438
+ def switch_session(self, chat_id: int, session_id: str) -> bool:
439
+ exists = self._fetchone(
440
+ "SELECT id FROM sessions WHERE id = ?", (session_id,)
441
+ )
442
+ if not exists:
443
+ return False
444
+ self._exec(
445
+ "INSERT OR REPLACE INTO active_sessions (chat_id, session_id) VALUES (?, ?)",
446
+ (chat_id, session_id),
447
+ )
448
+ self._conn().commit()
449
+ return True
450
+
451
+ def update_session_summary(self, session_id: str, summary: str):
452
+ self._exec(
453
+ "UPDATE sessions SET summary = ?, updated_at = ? WHERE id = ?",
454
+ (summary, datetime.now().timestamp(), session_id),
455
+ )
456
+ self._conn().commit()
457
+
458
+ def update_session_title(self, session_id: str, title: str):
459
+ self._exec(
460
+ "UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?",
461
+ (title, datetime.now().timestamp(), session_id),
462
+ )
463
+ self._conn().commit()
464
+
465
+ # ── Memory ────────────────────────────────────────────────────────────
466
+
467
+ def save_memory(self, memory: UserMemory) -> bool:
468
+ try:
469
+ self._exec(
470
+ """INSERT INTO user_memory (chat_id, user_id, preferences, knowledge, created_at, updated_at)
471
+ VALUES (?, ?, ?, ?, ?, ?)
472
+ ON CONFLICT(chat_id) DO UPDATE SET
473
+ user_id=excluded.user_id,
474
+ preferences=excluded.preferences,
475
+ knowledge=excluded.knowledge,
476
+ updated_at=excluded.updated_at""",
477
+ (
478
+ memory.chat_id, memory.user_id,
479
+ json.dumps(memory.preferences, ensure_ascii=False),
480
+ json.dumps(memory.knowledge, ensure_ascii=False),
481
+ memory.created_at, memory.updated_at,
482
+ ),
483
+ )
484
+ self._conn().commit()
485
+ return True
486
+ except Exception as e:
487
+ print(f"Error saving memory: {e}")
488
+ return False
489
+
490
+ def load_memory(self, chat_id: int) -> Optional[UserMemory]:
491
+ try:
492
+ row = self._fetchone(
493
+ "SELECT * FROM user_memory WHERE chat_id = ?", (chat_id,)
494
+ )
495
+ if row:
496
+ return UserMemory(
497
+ user_id=row["user_id"],
498
+ chat_id=row["chat_id"],
499
+ preferences=json.loads(row["preferences"]),
500
+ knowledge=json.loads(row["knowledge"]),
501
+ created_at=row["created_at"],
502
+ updated_at=row["updated_at"],
503
+ )
504
+ return None
505
+ except Exception as e:
506
+ print(f"Error loading memory: {e}")
507
+ return None
508
+
509
+ def list_sessions(self) -> List[int]:
510
+ rows = self._fetchall("SELECT DISTINCT chat_id FROM sessions")
511
+ return [r["chat_id"] for r in rows]
512
+
513
+ # ── Helpers ───────────────────────────────────────────────────────────
514
+
515
+ def _row_to_session(self, row: sqlite3.Row) -> ChatSession:
516
+ row_dict = dict(row) # Convert to plain dict so .get() works safely
517
+ metadata = json.loads(row_dict["metadata"]) if row_dict.get("metadata") else {}
518
+ if row_dict.get("summary"):
519
+ metadata["session_summary"] = row_dict["summary"]
520
+ # Always sync the column with metadata to ensure the column is the source of truth
521
+ metadata["opencode_session_id"] = row_dict.get("opencode_session_id")
522
+
523
+ msg_rows = self._fetchall(
524
+ "SELECT sender, content, timestamp, message_id FROM messages WHERE session_id = ? ORDER BY timestamp ASC",
525
+ (row_dict["id"],),
526
+ )
527
+ messages = [
528
+ {
529
+ "sender": m["sender"],
530
+ "content": m["content"],
531
+ "timestamp": m["timestamp"],
532
+ "message_id": m["message_id"],
533
+ }
534
+ for m in msg_rows
535
+ ]
536
+
537
+ return ChatSession(
538
+ chat_id=row_dict["chat_id"],
539
+ user_id=row_dict["user_id"],
540
+ username=row_dict["username"],
541
+ created_at=row_dict["created_at"],
542
+ updated_at=row_dict["updated_at"],
543
+ messages=messages,
544
+ metadata=metadata,
545
+ title=row_dict["title"] or "",
546
+ session_id=row_dict["id"],
547
+ )
548
+
549
+ def get_stats(self) -> dict:
550
+ """Returns global statistics for the admin."""
551
+ user_count = self._fetchone("SELECT COUNT(DISTINCT chat_id) FROM sessions")[0]
552
+ session_count = self._fetchone("SELECT COUNT(*) FROM sessions")[0]
553
+ message_count = self._fetchone("SELECT COUNT(*) FROM messages")[0]
554
+ return {
555
+ "users": user_count or 0,
556
+ "sessions": session_count or 0,
557
+ "messages": message_count or 0
558
+ }
559
+
560
+ # ── Provider Keys (Encrypted) ──────────────────────────────────────────
561
+
562
+ def save_provider_key(self, provider_id: str, provider_name: str, key_name: str, value: str, ttl_hours: Optional[int] = None):
563
+ """Encrypts and saves an API key for a provider."""
564
+ from core.security import encrypt_value
565
+ encrypted = encrypt_value(value)
566
+ expires_at = None
567
+ if ttl_hours:
568
+ expires_at = datetime.now().timestamp() + (ttl_hours * 3600)
569
+
570
+ self._exec(
571
+ """INSERT OR REPLACE INTO provider_keys (provider_id, provider_name, key_name, encrypted_value, expires_at, created_at)
572
+ VALUES (?, ?, ?, ?, ?, ?)""",
573
+ (provider_id, provider_name, key_name, encrypted, expires_at, datetime.now().timestamp())
574
+ )
575
+ self._conn().commit()
576
+
577
+ def get_provider_keys(self, provider_id: str) -> Dict[str, str]:
578
+ """Returns all valid (not expired) keys for a provider, decrypted."""
579
+ from core.security import decrypt_value
580
+ now = datetime.now().timestamp()
581
+ rows = self._fetchall(
582
+ "SELECT key_name, encrypted_value FROM provider_keys WHERE provider_id = ? AND (expires_at IS NULL OR expires_at > ?)",
583
+ (provider_id, now)
584
+ )
585
+ return {r["key_name"]: decrypt_value(r["encrypted_value"]) for r in rows}
586
+
587
+ def list_provider_keys(self) -> List[dict]:
588
+ """Returns a list of all providers that have saved keys."""
589
+ rows = self._fetchall(
590
+ "SELECT DISTINCT provider_id, provider_name, expires_at FROM provider_keys"
591
+ )
592
+ return [dict(r) for r in rows]
593
+
594
+ def delete_expired_keys(self):
595
+ """Removes all keys that have passed their expiration time."""
596
+ now = datetime.now().timestamp()
597
+ self._exec("DELETE FROM provider_keys WHERE expires_at IS NOT NULL AND expires_at <= ?", (now,))
598
+ self._conn().commit()
599
+
600
+ # ── Permissions ─────────────────────────────────────────────────────────
601
+
602
+ def check_permission(self, user_id: int, scope: str) -> Optional[int]:
603
+ """Returns 1 if granted, 0 if denied, None if not set."""
604
+ row = self._fetchone(
605
+ "SELECT granted FROM permissions WHERE user_id = ? AND scope = ?",
606
+ (user_id, scope)
607
+ )
608
+ return row["granted"] if row else None
609
+
610
+ def save_permission(self, user_id: int, scope: str, granted: int):
611
+ """Saves a permanent permission (always allow/deny)."""
612
+ now = datetime.now().timestamp()
613
+ self._exec(
614
+ "INSERT OR REPLACE INTO permissions (user_id, scope, granted, created_at) VALUES (?, ?, ?, ?)",
615
+ (user_id, scope, granted, now)
616
+ )
617
+ self._conn().commit()
618
+
619
+ def list_permissions(self, user_id: int) -> List[dict]:
620
+ """Lists all saved permissions for a user."""
621
+ rows = self._fetchall(
622
+ "SELECT scope, granted, created_at FROM permissions WHERE user_id = ?",
623
+ (user_id,)
624
+ )
625
+ return [dict(r) for r in rows]
626
+
627
+ def list_sessions(self, chat_id: int) -> List[dict]:
628
+ """Lists all sessions for a chat, sorted by update time."""
629
+ rows = self._fetchall(
630
+ "SELECT * FROM sessions WHERE chat_id = ? ORDER BY updated_at DESC",
631
+ (chat_id,)
632
+ )
633
+ return [dict(r) for r in rows]
634
+ def save_custom_agent(self, chat_id: int, user_id: int, name: str, description: str, system_prompt: str):
635
+ import time
636
+ self._exec(
637
+ "INSERT INTO user_agents (chat_id, user_id, name, description, system_prompt, created_at) VALUES (?, ?, ?, ?, ?, ?)",
638
+ (chat_id, user_id, name, description, system_prompt, time.time())
639
+ )
640
+ self._conn().commit()
641
+
642
+ def get_custom_agents(self, chat_id: int) -> list[dict]:
643
+ rows = self._fetchall(
644
+ "SELECT agent_id, name, description, system_prompt FROM user_agents WHERE chat_id = ? ORDER BY created_at DESC",
645
+ (chat_id,)
646
+ )
647
+ return [dict(row) for row in rows]
648
+
649
+ def delete_custom_agent(self, agent_id: int):
650
+ self._exec("DELETE FROM user_agents WHERE agent_id = ?", (agent_id,))
651
+ self._conn().commit()
652
+
653
+ def get_custom_agent_by_id(self, agent_id: int) -> Optional[dict]:
654
+ row = self._fetchone(
655
+ "SELECT name, system_prompt FROM user_agents WHERE agent_id = ?",
656
+ (agent_id,)
657
+ )
658
+ return dict(row) if row else None