From be8699ae4cd6ca942612b3ca1f4699eb70cf8f19 Mon Sep 17 00:00:00 2001 From: mohe Date: Sat, 20 Jun 2026 20:16:05 +0800 Subject: [PATCH 1/5] lead= switch, @mention auto-grant --- xmpp_agent_core.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/xmpp_agent_core.py b/xmpp_agent_core.py index 5e54579..878da1a 100644 --- a/xmpp_agent_core.py +++ b/xmpp_agent_core.py @@ -143,14 +143,19 @@ class AgentBot(ClientXMPP): # Coordinator 模式 — 全走 XMPP 消息 - # 1. hmo 切换 coordinator - if nickname == 'hmo' and 'coordinator=' in body.lower(): - for _name in ['mohe', 'zhiwei', 'xxm']: - if f'coordinator={_name}' in body.lower(): - self._coordinator = _name - self._granted = None - logging.info(f"👑 Coordinator 切换为 {_name}") - break + # 1. hmo 切换 coordinator(lead=xxx) + if nickname == 'hmo': + if 'lead=' in body.lower(): + for _name in ['mohe', 'zhiwei', 'xxm']: + if f'lead={_name}' in body.lower(): + self._coordinator = _name + self._granted = None + logging.info(f"👑 Coordinator 切换为 {_name}") + break + # hmo 直接 @点名 → 临时授权(一次) + elif any(tag in body for tag in [f'@{AGENT_NICK}', f'@{AGENT_NAME}']): + self._granted = AGENT_NICK + logging.info(f"🎤 被 hmo 点名,获得发言权") # 2. 检测授权信号(优先于收回,GRANT 可以覆盖 REVOKE) _grant_match = re.search(r'\[GRANT:(\w+)\]', body) @@ -210,9 +215,10 @@ class AgentBot(ClientXMPP): "3. 别人说错了关键事实,不纠正会有后果\n" "如果以上都不符合,你的回复必须只包含 __SILENT__ 这10个字符," "不要有任何其他内容(不要前缀、不要解释、不要标点、不要空格)。\n\n" - "注意:你是协调者(coordinator)。你的第一职责是管理讨论节奏,不是自己说话。\n" + "注意:你是协调者(lead)。你的第一职责是管理讨论节奏,不是自己说话。\n" "- 别人能回答的问题,不抢答。\n" "- 如果其他 Agent 更合适,用 [GRANT:agent名] 授权他们发言(例如 [GRANT:zhiwei])。\n" + "- hmo 直接 @点名某人 也会自动授权。\n" "- 如果有人跑题/刷屏,用 [REVOKE:agent名] 收回发言权(例如 [REVOKE:zhiwei])。\n" "- [GRANT] 可以覆盖 [REVOKE]。标记会显示在消息中。\n\n" f"[核心群 {room}] {nickname} 说: {body}" From 9a45f155a8898c08ae37ced0b00180209650aff9 Mon Sep 17 00:00:00 2001 From: mohe Date: Sat, 20 Jun 2026 20:18:47 +0800 Subject: [PATCH 2/5] match nick without @ (XMPP strips @ symbol) --- xmpp_agent_core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xmpp_agent_core.py b/xmpp_agent_core.py index 878da1a..d4cf5a5 100644 --- a/xmpp_agent_core.py +++ b/xmpp_agent_core.py @@ -152,8 +152,8 @@ class AgentBot(ClientXMPP): self._granted = None logging.info(f"👑 Coordinator 切换为 {_name}") break - # hmo 直接 @点名 → 临时授权(一次) - elif any(tag in body for tag in [f'@{AGENT_NICK}', f'@{AGENT_NAME}']): + # hmo 直接点名(@可能被 XMPP 客户端剥离,同时匹配昵称) + elif any(tag in body for tag in [f'@{AGENT_NICK}', f'@{AGENT_NAME}', AGENT_NICK, AGENT_NAME]): self._granted = AGENT_NICK logging.info(f"🎤 被 hmo 点名,获得发言权") From f1590e08d134743b867dbe7f4e4902e65a625283 Mon Sep 17 00:00:00 2001 From: mohe Date: Sat, 20 Jun 2026 20:21:21 +0800 Subject: [PATCH 3/5] revert bare-name match, keep only @ mention --- xmpp_agent_core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xmpp_agent_core.py b/xmpp_agent_core.py index d4cf5a5..878da1a 100644 --- a/xmpp_agent_core.py +++ b/xmpp_agent_core.py @@ -152,8 +152,8 @@ class AgentBot(ClientXMPP): self._granted = None logging.info(f"👑 Coordinator 切换为 {_name}") break - # hmo 直接点名(@可能被 XMPP 客户端剥离,同时匹配昵称) - elif any(tag in body for tag in [f'@{AGENT_NICK}', f'@{AGENT_NAME}', AGENT_NICK, AGENT_NAME]): + # hmo 直接 @点名 → 临时授权(一次) + elif any(tag in body for tag in [f'@{AGENT_NICK}', f'@{AGENT_NAME}']): self._granted = AGENT_NICK logging.info(f"🎤 被 hmo 点名,获得发言权") From aa7b67fc2bdc1329ea027bb1c1150e9dad54fc0c Mon Sep 17 00:00:00 2001 From: mohe Date: Sun, 21 Jun 2026 10:40:47 +0800 Subject: [PATCH 4/5] readonly: discard LLM response, never send; default=readonly for non-coordinator --- xmpp_agent_core.py | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/xmpp_agent_core.py b/xmpp_agent_core.py index 878da1a..5b8c7ab 100644 --- a/xmpp_agent_core.py +++ b/xmpp_agent_core.py @@ -188,10 +188,42 @@ class AgentBot(ClientXMPP): room = sender.split('/')[0] - # 4. 被 REVOKE 的人:只读模式 + # 4. 判断权限:协调者/被授权者可发言,其他人只读 + if not _is_coordinator and not _is_granted: + _rr = sender.split('/')[0] + _ro_body = f"【只读消息】你目前不是协调者,只需了解内容。\n\n[核心群 {_rr}] {nickname} 说: {body}" + try: + _ro_payload = json.dumps({ + "model": "hermes-agent", + "messages": [{"role": "user", "content": _ro_body}] + }).encode() + _ro_req = urllib.request.Request(GATEWAY, data=_ro_payload, method="POST") + _ro_req.add_header("Content-Type", "application/json") + _ro_req.add_header("Authorization", f"Bearer {API_KEY}") + _ro_req.add_header("X-Hermes-Session-Id", SESSION_ID) + _ro_loop = asyncio.get_event_loop() + await _ro_loop.run_in_executor(None, lambda: _opener.open(_ro_req, timeout=30)) + except Exception: + pass + return # 不发送任何回复 + + # 5. 被 REVOKE 的人:只读(虽然上面已经覆盖了,作为额外保障) if time.time() < getattr(self, '_revoked_until', 0): - _readonly = f"【只读消息】你目前被收回发言权。只需了解内容。输出 __SILENT__。\n\n[核心群 {room}] {nickname} 说: {body}" - await self.call_hermes(_readonly, sender, is_group=True) + _rr2 = sender.split('/')[0] + _rv_body = f"【只读消息】你目前被收回发言权。只需了解内容。\n\n[核心群 {_rr2}] {nickname} 说: {body}" + try: + _rv_payload = json.dumps({ + "model": "hermes-agent", + "messages": [{"role": "user", "content": _rv_body}] + }).encode() + _rv_req = urllib.request.Request(GATEWAY, data=_rv_payload, method="POST") + _rv_req.add_header("Content-Type", "application/json") + _rv_req.add_header("Authorization", f"Bearer {API_KEY}") + _rv_req.add_header("X-Hermes-Session-Id", SESSION_ID) + _rv_loop = asyncio.get_event_loop() + await _rv_loop.run_in_executor(None, lambda: _opener.open(_rv_req, timeout=30)) + except Exception: + pass return # 硬闭嘴闸门:hmo 说闭嘴类的话 → 静默 5 分钟 From 90ed30dd36ade36281cf518f1f00b01c15e99236 Mon Sep 17 00:00:00 2001 From: mohe Date: Sun, 21 Jun 2026 11:29:17 +0800 Subject: [PATCH 5/5] kanban API: comment endpoint + usage docs, notification includes curl example --- docs/kanban-api-reference.md | 59 +++++++++ scripts/kanban_api.py | 231 +++++++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 docs/kanban-api-reference.md create mode 100644 scripts/kanban_api.py diff --git a/docs/kanban-api-reference.md b/docs/kanban-api-reference.md new file mode 100644 index 0000000..8f8b59c --- /dev/null +++ b/docs/kanban-api-reference.md @@ -0,0 +1,59 @@ +# Kanban HTTP API — 跨机器任务协作 + +所有 Agent(mohe、zhiwei、xxm、xiao)通过 HTTP API 读写 kanban 任务卡片。 +不需要 Hermes,不需要 XMPP bot,只需要能 curl 到 `192.168.1.246:9580`。 + +## 基础 URL + +``` +http://192.168.1.246:9580/kanban +``` + +## 操作 + +### 查看所有任务 +```bash +curl -X POST http://192.168.1.246:9580/kanban/list -H "Content-Type: application/json" -d '{}' +``` + +可以筛选: +```bash +# 只看指派给自己的 +curl -X POST ... -d '{"assignee":"xxm"}' + +# 只看待处理 +curl -X POST ... -d '{"status":"ready"}' +``` + +### 查看单个任务 +```bash +curl -X POST http://192.168.1.246:9580/kanban/show -d '{"id":"t_3adcf4a6"}' +``` + +### 添加评论(有问题或进展时用) +```bash +curl -X POST http://192.168.1.246:9580/kanban/comment \ + -d '{"id":"t_3adcf4a6","author":"xxm","body":"这里有个问题:..."}' +``` + +评论会发到核心群通知所有人。 + +### 查看评论 +```bash +curl -X POST http://192.168.1.246:9580/kanban/comments -d '{"id":"t_3adcf4a6"}' +``` + +### 更新任务状态 +```bash +curl -X POST http://192.168.1.246:9580/kanban/update \ + -d '{"id":"t_3adcf4a6","status":"done"}' +``` + +## 沟通流程 + +1. 创建任务 → 核心群收到通知 +2. 你有问题 → 加评论(curl comment) +3. 评论被看到 → 有人回复评论 +4. 完成 → 更新状态为 done + +**不需要在群里说话。** 评论代替讨论,通知代替 @。 diff --git a/scripts/kanban_api.py b/scripts/kanban_api.py new file mode 100644 index 0000000..c0afe8d --- /dev/null +++ b/scripts/kanban_api.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Kanban HTTP API — 轻量 REST 接口,供跨机器 Agent 读写 kanban。 +不走 Hermes CLI,直接操作 SQLite,零依赖。""" +import http.server +import json +import sqlite3 +import urllib.parse +import os +from datetime import datetime + +KANBAN_DB = os.path.expanduser("~/.hermes/kanban.db") +HOST = "0.0.0.0" +PORT = 9580 + + +def db(): + return sqlite3.connect(KANBAN_DB) + + +class KanbanAPI(http.server.BaseHTTPRequestHandler): + def _json(self, data, status=200): + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(json.dumps(data, ensure_ascii=False).encode()) + + def _read_body(self): + length = int(self.headers.get("Content-Length", 0)) + return json.loads(self.rfile.read(length)) if length else {} + + def do_POST(self): + path = urllib.parse.urlparse(self.path).path.rstrip("/") + body = self._read_body() + + if path == "/kanban/create": + title = body.get("title", "") + assignee = body.get("assignee", "") + body_text = body.get("body", "") + if not title: + return self._json({"error": "title required"}, 400) + + # Use hermes CLI to create the card + import subprocess + cmd = ["hermes", "kanban", "create", title] + if assignee: + cmd += ["--assignee", assignee] + if body_text: + cmd += ["--body", body_text] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + + if r.returncode != 0: + return self._json({"error": r.stderr.strip()}, 500) + + # Parse output: "Created t_xxxxx (ready, assignee=xxx)" + task_id = None + for word in r.stdout.split(): + if word.startswith("t_"): + task_id = word.rstrip(",") + break + + return self._json({"ok": True, "task_id": task_id, "raw": r.stdout.strip()}) + + elif path == "/kanban/create_xmpp": + # 创建卡片并 XMPP 通知核心群 + title = body.get("title", "") + assignee = body.get("assignee", "") + body_text = body.get("body", "") + if not title: + return self._json({"error": "title required"}, 400) + + import subprocess + cmd = ["hermes", "kanban", "create", title] + if assignee: + cmd += ["--assignee", assignee] + if body_text: + cmd += ["--body", body_text] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if r.returncode != 0: + return self._json({"error": r.stderr.strip()}, 500) + + task_id = None + for word in r.stdout.split(): + if word.startswith("t_"): + task_id = word.rstrip(",") + break + + # 发 XMPP 通知到核心群(走 mohe bot 的 5804 桥) + xmpp_msg = f"[Kanban] 新任务 {task_id}: {title}" + if assignee: + xmpp_msg += f" → 指派给 {assignee}" + if body_text: + xmpp_msg += f"\n{body_text[:200]}" + xmpp_msg += f"\n📌 评论: curl -X POST http://192.168.1.246:9580/kanban/comment -d '{{\"id\":\"{task_id}\",\"author\":\"你的名字\",\"body\":\"...\"}}'" + + try: + import urllib.request as _ur + _noti = json.dumps({ + "to": "coregroup@conference.yoin.fun", + "body": xmpp_msg + }).encode() + _req = _ur.Request("http://127.0.0.1:5804", data=_noti, method="POST") + _req.add_header("Content-Type", "application/json") + _ur.urlopen(_req, timeout=5) + except Exception: + pass + + return self._json({"ok": True, "task_id": task_id, "notified": "coregroup"}) + + elif path == "/kanban/list": + status_filter = body.get("status") + assignee_filter = body.get("assignee") + + d = db() + query = "SELECT id, title, body, status, assignee, created_by, created_at FROM tasks" + params = [] + conditions = [] + if status_filter: + conditions.append("status = ?") + params.append(status_filter) + if assignee_filter: + conditions.append("assignee = ?") + params.append(assignee_filter) + if conditions: + query += " WHERE " + " AND ".join(conditions) + query += " ORDER BY created_at DESC" + + rows = d.execute(query, params).fetchall() + d.close() + + tasks = [] + for r in rows: + tasks.append({ + "id": r[0], "title": r[1], "body": r[2], "status": r[3], + "assignee": r[4], "created_by": r[5], "created_at": r[6] + }) + return self._json({"tasks": tasks}) + + elif path == "/kanban/comment": + task_id = body.get("id") + author = body.get("author", "unknown") + text = body.get("body", "") + if not task_id or not text: + return self._json({"error": "id and body required"}, 400) + d = db() + d.execute( + "INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)", + (task_id, author, text, int(__import__("time").time())) + ) + d.commit() + d.close() + # 通知核心群 + try: + _c_msg = f"[Kanban] {author} 评论了 {task_id}: {text[:100]}" + _c_noti = json.dumps({"to": "coregroup@conference.yoin.fun", "body": _c_msg}).encode() + _c_req = __import__("urllib.request").Request("http://127.0.0.1:5804", data=_c_noti, method="POST") + _c_req.add_header("Content-Type", "application/json") + __import__("urllib.request").urlopen(_c_req, timeout=5) + except Exception: + pass + return self._json({"ok": True}) + + elif path == "/kanban/comments": + task_id = body.get("id") + if not task_id: + return self._json({"error": "id required"}, 400) + d = db() + rows = d.execute( + "SELECT id, author, body, created_at FROM task_comments WHERE task_id = ? ORDER BY created_at", + (task_id,) + ).fetchall() + d.close() + comments = [{"id": r[0], "author": r[1], "body": r[2], "created_at": r[3]} for r in rows] + return self._json({"comments": comments}) + + elif path == "/kanban/update": + task_id = body.get("id") + if not task_id: + return self._json({"error": "id required"}, 400) + + updates = {} + for key in ("status", "assignee"): + if key in body: + updates[key] = body[key] + if not updates: + return self._json({"error": "nothing to update"}, 400) + + d = db() + set_clause = ", ".join(f"{k} = ?" for k in updates) + d.execute(f"UPDATE tasks SET {set_clause} WHERE id = ?", + list(updates.values()) + [task_id]) + d.commit() + d.close() + return self._json({"ok": True}) + + elif path == "/kanban/show": + task_id = body.get("id") + if not task_id: + return self._json({"error": "id required"}, 400) + d = db() + row = d.execute( + "SELECT id, title, body, status, assignee, created_by, created_at FROM tasks WHERE id = ?", + (task_id,) + ).fetchone() + d.close() + if not row: + return self._json({"error": "not found"}, 404) + return self._json({ + "id": row[0], "title": row[1], "body": row[2], + "status": row[3], "assignee": row[4], + "created_by": row[5], "created_at": row[6] + }) + + else: + return self._json({"error": "not found"}, 404) + + def do_OPTIONS(self): + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def log_message(self, fmt, *args): + print(f"[{datetime.now().strftime('%H:%M:%S')}] {args[0]}", flush=True) + + +if __name__ == "__main__": + server = http.server.HTTPServer((HOST, PORT), KanbanAPI) + print(f"Kanban API running on http://{HOST}:{PORT}") + server.serve_forever()