Files
AgentsMeeting/gateway/scripts/session_router.py
T
hmo e8fba49cb8 feat(xxm): dynamic TUI session context injection for coregroup
- chat_bridge: mark_active_tui_session() + get_active_tui_session()
  Tracks active TUI session via temp/.active_tui_session.json,
  auto-expires after 1 hour (matching Hermes state_meta pattern)
- chat_bridge: inject TUI context when processing coregroup messages
  Bot now sees what hmo was discussing in TUI when replying in group
- session_router: record active session ID on every TUI message
  Uses _resolve_session() to get the current bound session ID
2026-06-20 01:27:39 +08:00

636 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Session Router — multi-channel session routing with command loop.
For XMPP/VC/WeChat channels, provides TUI-equivalent session experience:
- auto → resolves to the most recently active session (same as TUI --continue)
- list/switch sessions via NL ("切换到xxx") or commands
- LLM-driven command system (##list_sessions##, ##switch_session##, etc.)
Flow:
route(channel, sender, message) →
1. check selection mode (pending user choice)
2. build prompt with session context from SQLite
3. send to LLM → parse reply for ##commands##
4. if command → execute → append result → re-send to LLM (loop)
5. if no command → return final reply
"""
import os, json, time, re, sqlite3, threading
from datetime import datetime, timezone, timedelta
from typing import Optional
# ── Constants ──
DB_PATH = os.path.expanduser("~/.local/share/opencode/opencode.db")
MAX_LOOPS = 10
SELECTION_TIMEOUT = 120 # seconds
RECENT_MSG_LIMIT = 200 # context messages from SQLite (小荷 uses 200)
SESSION_LIST_LIMIT = 15 # max sessions shown in list
# ── Command regex: ##command## or ##command:args## ──
CMD_RE = re.compile(r"##(\w+)(?::([^#\n]*))?##")
# ── Timezone ──
TZ = timezone(timedelta(hours=8))
def _fmt_ts(ts_ms: int) -> str:
"""Format millisecond timestamp to MM-DD HH:MM string."""
return datetime.fromtimestamp(ts_ms / 1000, tz=TZ).strftime("%m-%d %H:%M")
def _src_tag(source: str) -> str:
"""Map source to display tag."""
return {
"tui": "[TUI] ",
"xmpp": "[群聊] ",
"vc": "[VC] ",
"bridge": "[桥接] ",
}.get(source, f"[{source}] ")
# ═══════════════════════════════════════════════════════════════
# Context extractor
# ═══════════════════════════════════════════════════════════════
def extract_session_context(session_id: str, limit: int = RECENT_MSG_LIMIT) -> str:
"""
Read last N conversational turns from opencode.db for a session.
Returns formatted string like:
用户: xxx\n小小莫: xxx\n...
Empty string on failure or no data.
"""
try:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
# 1. Get recent message IDs + timestamps
msg_rows = conn.execute("""
SELECT id, data, time_created FROM message
WHERE session_id=? ORDER BY time_created DESC LIMIT ?
""", (session_id, limit * 3)).fetchall()
if not msg_rows:
conn.close()
return ""
msg_ids = [r["id"] for r in msg_rows]
# 2. Get text parts for those messages
placeholders = ",".join("?" * len(msg_ids))
part_rows = conn.execute(
f"""
SELECT message_id, data FROM part
WHERE session_id=? AND message_id IN ({placeholders})
ORDER BY time_created
""",
(session_id, *msg_ids),
).fetchall()
conn.close()
# 3. Build role → parts + timestamp + source mapping
msg_map = {}
for r in msg_rows:
try:
d = json.loads(r["data"])
except (json.JSONDecodeError, ValueError):
d = {}
ts_str = _fmt_ts(r["time_created"]) if r["time_created"] else ""
source = d.get("source", "tui") # tui (default) / xmpp / vc / bridge
msg_map[r["id"]] = {"role": d.get("role", "?"), "ts": ts_str, "source": source, "parts": []}
for r in part_rows:
try:
d = json.loads(r["data"])
except (json.JSONDecodeError, ValueError):
continue
if d.get("type") == "text":
txt = (d.get("text") or "").strip()
if txt:
msg_map[r["message_id"]]["parts"].append(txt)
# 4. Format as conversation lines (chronological order) with timestamps + source labels
lines = []
for r in reversed(msg_rows):
info = msg_map[r["id"]]
role_label = "用户" if info["role"] == "user" else "小小莫"
src_label = _src_tag(info["source"])
ts_tag = f"[{info['ts']}] " if info["ts"] else ""
for txt in info["parts"][:3]:
lines.append(f"{ts_tag}{src_label}{role_label}: {txt}")
return "\n".join(lines[-limit:])
except Exception:
return ""
# ═══════════════════════════════════════════════════════════════
# Session Router
# ═══════════════════════════════════════════════════════════════
class SessionRouter:
"""
Routes messages from external channels to the correct session,
handling session switching, context injection, and command execution.
Args:
bridge: SessionBridge instance (raw LLM caller)
db_path: path to opencode.db
binding_file: path to session_routing.json
default_session: fallback session ID when nothing is bound
"""
def __init__(
self,
bridge,
db_path: str = DB_PATH,
binding_file: str = "",
default_session: str = "",
):
self.bridge = bridge
self.db_path = db_path
self.binding_file = binding_file or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"temp", "session_routing.json",
)
os.makedirs(os.path.dirname(self.binding_file), exist_ok=True)
self.default_session = default_session
self._lock = threading.Lock()
# Persisted bindings: {"channel:sender": "session_id" or "__auto__"}
self._bindings: dict[str, str] = {}
self._load_bindings()
# In-memory selection mode state
self._pending: dict[str, dict] = {}
# In-memory conversation context for active command loops
# {"channel:sender": [{"role": ..., "content": ...}, ...]}
self._contexts: dict[str, list[dict]] = {}
# Command registry
self._commands = {
"list_sessions": self._cmd_list_sessions,
"switch_session": self._cmd_switch_session,
"git_pull": self._cmd_git_pull,
"git_push": self._cmd_git_push,
"read_file": self._cmd_read_file,
"write_file": self._cmd_write_file,
"run": self._cmd_run,
"help": self._cmd_help,
}
# Command documentation (injected as part of system prompt)
self._cmd_guide = (
"你可以使用以下命令让 bot 执行操作,把命令放在回复中即可:\n"
"##list_sessions## 列出所有可用的 session\n"
"##switch_session:xxx## 切换到标题包含 xxx 的 session\n"
"##git_pull:path## git pull 指定目录,默认 ~/projects/self-growing-knowledge\n"
"##git_push:path;msg## git add/commit/pushmessage 可选\n"
"##read_file:path## 读取文件内容(展示前 2000 行)\n"
"##write_file:path|content## 写入/覆盖文件(注意:会覆盖!)\n"
"##run:command## 执行任意 shell 命令\n"
"##help## 查看所有可用命令\n"
)
# ── Binding persistence ──────────────────────────────
def _load_bindings(self):
try:
with open(self.binding_file, "r", encoding="utf-8") as f:
self._bindings = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, ValueError):
self._bindings = {}
def _save_bindings(self):
try:
with open(self.binding_file, "w", encoding="utf-8") as f:
json.dump(self._bindings, f, ensure_ascii=False, indent=2)
except Exception:
pass
def _get_binding(self, key: str) -> str:
"""Return session_id for key, or '__auto__' if not bound."""
return self._bindings.get(key, "__auto__")
def _set_binding(self, key: str, session_id: str):
with self._lock:
self._bindings[key] = session_id
self._save_bindings()
# ── Session resolution ───────────────────────────────
def _resolve_auto(self) -> str:
"""Query DB for the most recently updated session."""
try:
conn = sqlite3.connect(self.db_path)
row = conn.execute(
"SELECT id FROM session ORDER BY time_updated DESC LIMIT 1"
).fetchone()
conn.close()
if row:
return row[0]
except Exception:
pass
return self.default_session
def _resolve_session(self, key: str) -> str:
"""Resolve the effective session ID for a binding key."""
binding = self._get_binding(key)
if binding == "__auto__":
return self._resolve_auto()
return binding
def _get_session_title(self, session_id: str) -> str:
"""Look up session title from DB."""
try:
conn = sqlite3.connect(self.db_path)
row = conn.execute(
"SELECT title FROM session WHERE id=?", (session_id,)
).fetchone()
conn.close()
if row:
return row[0]
except Exception:
pass
return session_id[:20]
# ── Command parsing ─────────────────────────────────
@staticmethod
def _parse_command(text: str):
"""
Parse first ##command## or ##command:args## from reply.
Returns (cmd_name, args, clean_text).
- cmd_name: str or None
- args: str or None
- clean_text: text before the command, stripped
"""
m = CMD_RE.search(text)
if not m:
return None, None, text
cmd = m.group(1)
args = m.group(2).strip() if m.group(2) else None
clean_text = text[: m.start()].strip()
return cmd, args, clean_text
# ── Command handlers ─────────────────────────────────
def _cmd_list_sessions(self, key: str, args: Optional[str]) -> str:
"""Query and format session list."""
try:
conn = sqlite3.connect(self.db_path)
rows = conn.execute(
"""
SELECT id, title, time_updated FROM session
ORDER BY time_updated DESC LIMIT ?
""",
(SESSION_LIST_LIMIT,),
).fetchall()
conn.close()
current_id = self._resolve_session(key)
lines = []
for sid, title, ts in rows:
marker = " ← 当前" if sid == current_id else ""
lines.append(f" {title} ({_fmt_ts(ts)}){marker}")
return "可用 sessions\n" + "\n".join(lines)
except Exception as e:
return f"查询 session 失败:{e}"
def _cmd_switch_session(self, key: str, args: Optional[str]) -> str:
"""Fuzzy-match session title and switch."""
if not args:
return "请指定 session 名称,例如:##switch_session:接龙##"
try:
conn = sqlite3.connect(self.db_path)
rows = conn.execute(
"""
SELECT id, title FROM session
WHERE title LIKE ? ORDER BY time_updated DESC LIMIT ?
""",
(f"%{args}%", SESSION_LIST_LIMIT),
).fetchall()
conn.close()
except Exception as e:
return f"查询失败:{e}"
if not rows:
return f"未找到标题包含「{args}」的 session"
if len(rows) == 1:
sid, title = rows[0]
self._set_binding(key, sid)
return f"已切换到「{title}"
# Multiple matches → selection mode (list up to 15)
self._enter_selection(key, "switch_session", rows)
items = "\n".join(
f" {i}. {title}" for i, (_, title) in enumerate(rows, 1)
)
return f"找到 {len(rows)} 个匹配(仅显示前{SESSION_LIST_LIMIT}个),请回复编号选择:\n{items}"
# ── Git 命令 ──────────────────────────────────────────
def _cmd_git_pull(self, key: str, args: Optional[str]) -> str:
path = args or "~/projects/self-growing-knowledge"
try:
import subprocess
r = subprocess.run(
f"cd {path} && git pull origin master 2>&1",
shell=True, capture_output=True, text=True, timeout=60,
)
out = r.stdout.strip() + (f"\n{r.stderr.strip()}" if r.stderr.strip() else "")
return out or "(no output)"
except subprocess.TimeoutExpired:
return "(git pull 超时)"
except Exception as e:
return f"(错误: {e})"
def _cmd_git_push(self, key: str, args: Optional[str]) -> str:
path = "~/projects/self-growing-knowledge"
msg = "auto commit"
if args and ";" in args:
parts = args.split(";", 1)
path = parts[0].strip()
msg = parts[1].strip()
elif args:
path = args
try:
import subprocess
r = subprocess.run(
f"cd {path} && git add . && git commit -m '{msg}' && git push origin master 2>&1",
shell=True, capture_output=True, text=True, timeout=60,
)
out = r.stdout.strip() + (f"\n{r.stderr.strip()}" if r.stderr.strip() else "")
return out or "(no output)"
except subprocess.TimeoutExpired:
return "(git push 超时)"
except Exception as e:
return f"(错误: {e})"
def _cmd_read_file(self, key: str, args: Optional[str]) -> str:
if not args:
return "请指定文件路径"
try:
import os
path = os.path.expanduser(args)
with open(path, "r", encoding="utf-8", errors="replace") as f:
content = f.read(50000) # 前 50000 字符
lines = content.split("\n")
shown = "\n".join(lines[:2000])
if len(lines) > 2000:
shown += f"\n... ({len(lines) - 2000} 行已截断)"
return shown or "(空文件)"
except FileNotFoundError:
return f"(文件不存在: {args})"
except Exception as e:
return f"(读取失败: {e})"
def _cmd_write_file(self, key: str, args: Optional[str]) -> str:
if not args or "|" not in args:
return "用法: ##write_file:路径|内容##"
try:
import os
path_part, content = args.split("|", 1)
path = os.path.expanduser(path_part.strip())
with open(path, "w", encoding="utf-8") as f:
f.write(content)
return f"(已写入 {os.path.getsize(path)} 字节到 {path})"
except Exception as e:
return f"(写入失败: {e})"
def _cmd_run(self, key: str, args: Optional[str]) -> str:
if not args:
return "请指定命令"
try:
import subprocess
r = subprocess.run(
args, shell=True, capture_output=True, text=True, timeout=60,
)
out = r.stdout.strip() + (f"\n{r.stderr.strip()}" if r.stderr.strip() else "")
return out or f"(exit {r.returncode})"
except subprocess.TimeoutExpired:
return "(命令超时)"
except Exception as e:
return f"(错误: {e})"
def _cmd_help(self, key: str, args: Optional[str]) -> str:
return self._cmd_guide
# ── Selection mode ──────────────────────────────────
def _enter_selection(self, key: str, action: str, options: list):
self._pending[key] = {
"action": action,
"options": options,
"expires": time.time() + SELECTION_TIMEOUT,
}
def _handle_selection(self, key: str, message: str) -> Optional[str]:
"""
Handle a user message while in selection mode.
Returns reply text if selection resolved, None if message should
be processed normally (selection expired or cancelled).
"""
pending = self._pending.get(key)
if not pending:
return None
if time.time() > pending["expires"]:
del self._pending[key]
return "选择已超时(120s),请重新操作。"
text = message.strip()
# Cancel
if text.lower() in ("cancel", "取消", "算了"):
del self._pending[key]
return "已取消。"
options = pending["options"]
# Number selection
if text.isdigit():
idx = int(text) - 1
if 0 <= idx < len(options):
sid, title = options[idx]
del self._pending[key]
if pending["action"] == "switch_session":
self._set_binding(key, sid)
self._reset_context(key)
return f"已切换到「{title}"
return "操作完成。"
return f"请输入 1-{len(options)} 之间的编号。"
# Keyword filter (narrow down within current options)
matches = [(s, t) for s, t in options if text in t]
if len(matches) == 1:
sid, title = matches[0]
del self._pending[key]
if pending["action"] == "switch_session":
self._set_binding(key, sid)
self._reset_context(key)
return f"已切换到「{title}"
return "操作完成。"
elif len(matches) > 1:
self._pending[key]["options"] = matches
items = "\n".join(
f" {i}. {t}" for i, (_, t) in enumerate(matches, 1)
)
return f"找到多个匹配,请再次选择:\n{items}"
# No match
items = "\n".join(
f" {i}. {t}" for i, (_, t) in enumerate(options, 1)
)
return f"未找到匹配。请回复编号或输入 cancel 取消:\n{items}"
# ── Context management for multi-turn command loops ──
def _reset_context(self, key: str):
"""Clear accumulated conversation context for a key."""
self._contexts.pop(key, None)
# ── Prompt building ─────────────────────────────────
def _build_prompt(self, key: str, history: list[dict]) -> str:
"""
Build prompt with session title + command layer.
注意:不注入 TUI session 上下文(extract_session_context),
因为群聊对话跟 TUI 对话是两套上下文。桥接后的群聊上下文
由 SessionBridge 自己的 context log 管理(更干净)。
"""
session_id = self._resolve_session(key)
session_title = self._get_session_title(session_id)
lines = [
f"[session: {session_title}]",
"",
]
# 不注入 TUI session 上下文,避免驴头不对马嘴
# ctx = extract_session_context(session_id, limit=20)
# if ctx:
# lines.append("【最近对话】")
# lines.append(ctx)
# lines.append("")
lines.append(
"[可用命令] ##switch_session:xxx## 切换session##list_sessions## 列表,"
"##git_pull:路径## 拉代码,##git_push:路径;消息## 推代码,"
"##read_file:路径## 读文件,##write_file:路径|内容## 写文件,"
"##run:命令## 执行shell。说「去做」时必须同时用命令执行。"
)
lines.append("---")
if history:
for entry in history:
role_label = {
"user": "用户",
"assistant": "小小莫",
"system": "系统",
}.get(entry["role"], entry["role"])
lines.append(f"{role_label}{entry['content']}")
return "\n".join(lines)
# ── Core LLM command loop ───────────────────────────
def _llm_loop(
self, key: str, history: list[dict], loop_count: int = 0
) -> str:
"""
Send to LLM → parse command → execute → loop.
Returns final reply text (all commands stripped).
"""
if loop_count >= MAX_LOOPS:
return "(命令循环次数超限,请重试)"
prompt = self._build_prompt(key, history)
reply = self.bridge.send_raw(prompt)
if not reply:
return "(模型无响应,请稍后重试)"
cmd, args, clean_text = self._parse_command(reply)
if not cmd:
# No command → this is the final answer
return clean_text or reply
# Execute command
handler = self._commands.get(cmd)
if not handler:
# Unknown command → treat as normal reply
return clean_text or reply
result = handler(key, args)
# Append to history (mutates the list, seen by recursive call) and loop
history.append(
{"role": "assistant", "content": clean_text or f"(执行{cmd}"}
)
history.append(
{
"role": "system",
"content": (
f"##{cmd}## 执行结果:{result}\n"
"(请根据结果继续回复用户,如有需要可在回复中继续使用命令)"
),
}
)
return self._llm_loop(key, history, loop_count + 1)
# ── Public entry point ──────────────────────────────
def route(self, channel: str, sender: str, message: str) -> str:
"""
Route a message from an external channel.
Args:
channel: "xmpp", "vc", or "wechat"
sender: user identifier (JID / UID / WXID)
message: raw text from the user
Returns:
reply text to send back (all ##commands## stripped)
"""
key = f"{channel}:{sender}"
# 1. Check selection mode
sel_reply = self._handle_selection(key, message)
if sel_reply is not None:
return sel_reply
# 2. Reset accumulated context for fresh conversation
self._reset_context(key)
# 3. Build initial prompt with user message + channel context
prefix = ""
if channel == "xmpp" and "/" in sender:
# XMPP group chat: sender is "room/nickname"
room = sender.split("/")[0]
nick = sender.split("/")[1]
# Include nick so LLM knows who said it
prefix = f"[群聊/{room.split('@')[0]}] {nick}: "
tagged = f"{prefix}{message}"
history = [{"role": "user", "content": tagged}]
# Dynamic context switching: record active TUI session
# so bot can inject TUI context when replying to coregroup
if channel != "xmpp":
try:
sid = self._resolve_session(key, allow_create=False)
if sid:
from chat_bridge import mark_active_tui_session
mark_active_tui_session(sid)
except Exception:
pass
# 4. Run LLM command loop
return self._llm_loop(key, history)