From 302bae3c69f6802a7ecdbd790dc7c88e4b4e23f0 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:28:43 -0400 Subject: [PATCH 1/2] feat(matrix): Matrix messages panel in mesh dashboard + /api/matrix/* endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _matrix_config() reads homeserver/token/room_id from env or ~/.config/sourceos/matrix.yaml - Add _matrix_recent_messages(n) fetches CS API dir=b, filters m.text, reverses newest-last - Inject matrix_messages + matrix_room_id into gather_state() - Dashboard: Matrix Room card after Runbooks — shows sender, timestamp, body (120 chars) - GET /api/matrix/rooms proxies joined_rooms CS API (5s timeout, graceful error envelope) - GET /api/matrix/messages returns last 20 messages for Lua/WezTerm consumers --- assets/sourceos/bin/turtle-mesh-serve | 117 ++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/assets/sourceos/bin/turtle-mesh-serve b/assets/sourceos/bin/turtle-mesh-serve index bc6b37dd0ff..cc62dc8c354 100755 --- a/assets/sourceos/bin/turtle-mesh-serve +++ b/assets/sourceos/bin/turtle-mesh-serve @@ -26,6 +26,7 @@ import queue import sys import threading import time +import urllib.parse import urllib.request from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path @@ -99,6 +100,69 @@ def _matrix_bridge_alive() -> bool: return False +def _matrix_config() -> dict: + """Read Matrix config from env or ~/.config/sourceos/matrix.yaml.""" + yaml_path = Path.home() / ".config" / "sourceos" / "matrix.yaml" + yaml: dict = {} + if yaml_path.exists(): + try: + import re as _re + with open(yaml_path) as f: + for line in f: + line = line.strip() + m = _re.match(r'^(\w[\w_]*):\s*(.+)$', line) + if m: + yaml[m.group(1)] = m.group(2).strip('"\'') + except Exception: + pass + + def _cfg(env_key: str, yaml_key: str, default: str = "") -> str: + return os.environ.get(env_key) or yaml.get(yaml_key) or yaml.get(env_key) or default + + return { + "homeserver_url": _cfg("MATRIX_HOMESERVER_URL", "homeserver_url", "").rstrip("/"), + "access_token": _cfg("MATRIX_ACCESS_TOKEN", "access_token", ""), + "room_id": _cfg("MATRIX_BOT_ROOM_ID", "room_id", ""), + } + + +def _matrix_recent_messages(n: int = 8) -> list[dict]: + """Fetch the last n messages from the configured Matrix room via CS API. + + Returns list of {sender, body, ts} dicts. Returns [] on any error. + Uses urllib.request with 3s timeout. No new imports needed. + """ + try: + cfg = _matrix_config() + homeserver = cfg.get("homeserver_url", "") + token = cfg.get("access_token", "") + room_id = cfg.get("room_id", "") + if not (homeserver and token and room_id): + return [] + encoded_room = urllib.parse.quote(room_id, safe="") + url = f"{homeserver}/_matrix/client/v3/rooms/{encoded_room}/messages?dir=b&limit={n}" + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) + with urllib.request.urlopen(req, timeout=3) as resp: + data = json.loads(resp.read().decode()) + messages = [] + for ev in data.get("chunk", []): + if ev.get("type") != "m.room.message": + continue + content = ev.get("content", {}) + if content.get("msgtype") != "m.text": + continue + messages.append({ + "sender": ev.get("sender", ""), + "body": content.get("body", ""), + "ts": ev.get("origin_server_ts"), + }) + # chunk is newest-first (dir=b); reverse so newest-last + messages.reverse() + return messages + except Exception: + return [] + + def gather_state() -> dict: mesh = load_jsonl_tail(MESH_DIR / "context.jsonl", 60) active = load_json(MESH_DIR / "active.json") @@ -154,6 +218,8 @@ def gather_state() -> dict: "k3s_tunnel_up": _k3s_tunnel_alive(), "matrix_bridge_up": _matrix_bridge_alive(), "runbooks": runbooks, + "matrix_messages": _matrix_recent_messages(8), + "matrix_room_id": _matrix_config().get("room_id", ""), } @@ -320,6 +386,10 @@ h2{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em
[] # receive wormhole, post result to room
-# turtle-matrix-bridge status # show config + connectivity
+# turtle-matrix-bridge daemon [] # tail mesh → Matrix (runs forever)
+# turtle-matrix-bridge send [-r ] [] # post text; reads stdin if no text
+# turtle-matrix-bridge send-file [-r ]
+# turtle-matrix-bridge send-code [-r ]
+# turtle-matrix-bridge pipe [-r ] [--lang ] # stdin → code block
+# turtle-matrix-bridge reply [-r ]
+# turtle-matrix-bridge react [-r ]
+# turtle-matrix-bridge rooms # list joined rooms
+# turtle-matrix-bridge messages [] [-r ] # fetch last n messages
+# turtle-matrix-bridge wormhole-send [-r ]
+# turtle-matrix-bridge wormhole-pipe [-r ] # stdin → wormhole → post code
+# turtle-matrix-bridge wormhole-recv [-r ]
+# turtle-matrix-bridge ctx [-r ] # post active context snapshot
+# turtle-matrix-bridge status
from __future__ import annotations
import json
+import mimetypes
import os
import re
+import shutil
import subprocess
import sys
+import tempfile
import time
import urllib.error
+import urllib.parse
import urllib.request
import uuid
from pathlib import Path
@@ -40,40 +46,54 @@ from typing import Any
# ── Config ────────────────────────────────────────────────────────────────────
-YAML_CONFIG = Path.home() / ".config" / "sourceos" / "matrix.yaml"
-MESH_JSONL = Path.home() / ".local" / "state" / "sourceos" / "memory-mesh" / "context.jsonl"
+YAML_CONFIG = Path.home() / ".config" / "sourceos" / "matrix.yaml"
+MESH_DIR = Path.home() / ".local" / "state" / "sourceos" / "memory-mesh"
STATE_DIR = Path.home() / ".local" / "state" / "sourceos"
CURSOR_FILE = STATE_DIR / "matrix-bridge-cursor"
+ACTIVE_JSON = MESH_DIR / "active.json"
DEFAULT_FORWARD_EVENTS = {
- "cloudshell-connect",
- "k3s-tunnel-start",
- "split-view",
- "error-triage",
- "note-saved",
- "runbook-run",
+ "cloudshell-connect", "k3s-tunnel-start", "split-view",
+ "error-triage", "note-saved", "runbook-run",
+}
+
+_LANG_MAP: dict[str, str] = {
+ ".py": "python", ".js": "javascript", ".ts": "typescript",
+ ".sh": "bash", ".zsh": "bash", ".yaml": "yaml", ".yml": "yaml",
+ ".json": "json", ".go": "go", ".rs": "rust", ".lua": "lua",
+ ".md": "markdown", ".sql": "sql", ".toml": "toml", ".diff": "diff",
+ ".patch": "diff", ".tf": "hcl", ".nix": "nix",
}
+
def _load_yaml_config() -> dict[str, str]:
if not YAML_CONFIG.exists():
return {}
try:
- import re as _re
cfg: dict[str, str] = {}
with open(YAML_CONFIG) as f:
for line in f:
line = line.strip()
- m = _re.match(r'^(\w[\w_]*):\s*(.+)$', line)
- if m:
- cfg[m.group(1)] = m.group(2).strip('"\'')
+ if line.startswith("#") or ":" not in line:
+ continue
+ k, _, v = line.partition(":")
+ cfg[k.strip()] = v.strip().strip("\"'")
return cfg
except Exception:
return {}
+
_yaml = _load_yaml_config()
+
def _cfg(key: str, default: str = "") -> str:
- return os.environ.get(key) or _yaml.get(key.lower().replace("matrix_", "").replace("_", "_")) or _yaml.get(key) or default
+ return (
+ os.environ.get(key)
+ or _yaml.get(key)
+ or _yaml.get(key.lower())
+ or default
+ )
+
HOMESERVER_URL = _cfg("MATRIX_HOMESERVER_URL", "http://localhost:8448").rstrip("/")
ACCESS_TOKEN = _cfg("MATRIX_ACCESS_TOKEN", "")
@@ -81,7 +101,8 @@ DEFAULT_ROOM_ID = _cfg("MATRIX_BOT_ROOM_ID", "")
_ev_cfg = _cfg("MATRIX_BRIDGE_EVENTS", "")
FORWARD_EVENTS = set(_ev_cfg.split(",")) if _ev_cfg else DEFAULT_FORWARD_EVENTS
-# ── Matrix CS API helpers ─────────────────────────────────────────────────────
+# ── Matrix CS API ─────────────────────────────────────────────────────────────
+
def _headers() -> dict[str, str]:
h = {"Content-Type": "application/json"}
@@ -89,24 +110,26 @@ def _headers() -> dict[str, str]:
h["Authorization"] = f"Bearer {ACCESS_TOKEN}"
return h
+
def _req(method: str, path: str, body: dict | None = None, timeout: int = 10) -> dict:
url = f"{HOMESERVER_URL}{path}"
- data = json.dumps(body).encode() if body else None
+ data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, headers=_headers(), method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
- body_text = e.read().decode()
- raise RuntimeError(f"Matrix HTTP {e.code}: {body_text}") from e
+ raise RuntimeError(f"Matrix {e.code}: {e.read().decode()[:300]}") from e
-def _send_message(room_id: str, text: str, thread_event_id: str | None = None,
- formatted_html: str | None = None) -> str:
+
+def _send_message(
+ room_id: str,
+ text: str,
+ thread_event_id: str | None = None,
+ formatted_html: str | None = None,
+) -> str:
txn = uuid.uuid4().hex
- content: dict[str, Any] = {
- "msgtype": "m.text",
- "body": text,
- }
+ content: dict[str, Any] = {"msgtype": "m.text", "body": text}
if formatted_html:
content["format"] = "org.matrix.custom.html"
content["formatted_body"] = formatted_html
@@ -115,167 +138,303 @@ def _send_message(room_id: str, text: str, thread_event_id: str | None = None,
"rel_type": "m.thread",
"event_id": thread_event_id,
}
- result = _req("PUT", f"/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/send/m.room.message/{txn}", content)
+ room_enc = urllib.parse.quote(room_id, safe="")
+ result = _req("PUT", f"/_matrix/client/v3/rooms/{room_enc}/send/m.room.message/{txn}", content)
return result.get("event_id", "")
-# python 3.9+ has urllib.parse but we import lazily
-import urllib.parse
-def _send_code_block(room_id: str, code: str, lang: str = "", label: str = "") -> str:
- import html
- plain = f"```{lang}\n{code}\n```"
- if label:
- plain = f"{label}\n{plain}"
- escaped = html.escape(code)
- fmted = f"{html.escape(label)}
" if label else ""
- fmted += f"{escaped}
"
- return _send_message(room_id, plain, formatted_html=fmted)
+def _send_reaction(room_id: str, event_id: str, emoji: str) -> str:
+ txn = uuid.uuid4().hex
+ room_enc = urllib.parse.quote(room_id, safe="")
+ result = _req(
+ "PUT",
+ f"/_matrix/client/v3/rooms/{room_enc}/send/m.reaction/{txn}",
+ {"m.relates_to": {"rel_type": "m.annotation", "event_id": event_id, "key": emoji}},
+ )
+ return result.get("event_id", "")
+
+
+def _edit_message(room_id: str, event_id: str, new_text: str) -> str:
+ txn = uuid.uuid4().hex
+ room_enc = urllib.parse.quote(room_id, safe="")
+ result = _req(
+ "PUT",
+ f"/_matrix/client/v3/rooms/{room_enc}/send/m.room.message/{txn}",
+ {
+ "msgtype": "m.text",
+ "body": f"* {new_text}",
+ "m.new_content": {"msgtype": "m.text", "body": new_text},
+ "m.relates_to": {"rel_type": "m.replace", "event_id": event_id},
+ },
+ )
+ return result.get("event_id", "")
+
+
+def _joined_rooms() -> list[str]:
+ result = _req("GET", "/_matrix/client/v3/joined_rooms")
+ return result.get("joined_rooms", [])
+
+
+def _room_messages(room_id: str, limit: int = 20) -> list[dict]:
+ room_enc = urllib.parse.quote(room_id, safe="")
+ try:
+ result = _req(
+ "GET",
+ f"/_matrix/client/v3/rooms/{room_enc}/messages?dir=b&limit={limit}",
+ timeout=5,
+ )
+ msgs = []
+ for ev in reversed(result.get("chunk", [])):
+ if ev.get("type") != "m.room.message":
+ continue
+ content = ev.get("content", {})
+ if content.get("msgtype") != "m.text":
+ continue
+ msgs.append({
+ "event_id": ev.get("event_id", ""),
+ "sender": ev.get("sender", ""),
+ "body": content.get("body", ""),
+ "ts": ev.get("origin_server_ts", 0),
+ })
+ return msgs
+ except Exception:
+ return []
+
+
+def _room_name(room_id: str) -> str:
+ room_enc = urllib.parse.quote(room_id, safe="")
+ try:
+ result = _req("GET", f"/_matrix/client/v3/rooms/{room_enc}/state/m.room.name/", timeout=5)
+ return result.get("name", room_id)
+ except Exception:
+ return room_id
+
+
+def _lang_for(path: Path) -> str:
+ return _LANG_MAP.get(path.suffix.lower(), "")
+
+
+def _code_block_html(code: str, lang: str, label: str = "") -> tuple[str, str]:
+ """Return (plain_text, formatted_html) for a code block."""
+ import html as _html
+ escaped = _html.escape(code)
+ plain = (f"{label}\n" if label else "") + f"```{lang}\n{code}\n```"
+ html_label = f"{_html.escape(label)}
" if label else ""
+ fmted = html_label + f"{escaped}
"
+ return plain, fmted
+
+
+def _send_code_block(
+ room_id: str,
+ code: str,
+ lang: str = "",
+ label: str = "",
+ thread_event_id: str | None = None,
+) -> str:
+ plain, fmted = _code_block_html(code, lang, label)
+ return _send_message(room_id, plain, thread_event_id=thread_event_id, formatted_html=fmted)
+
# ── Wormhole ──────────────────────────────────────────────────────────────────
+_WORMHOLE_CODE_RE = re.compile(r'\b(\d+(?:-\w+){2,})\b')
+
+
def _wormhole_bin() -> str | None:
for candidate in ["wormhole", "wormhole-william"]:
- try:
- result = subprocess.run(
- ["which", candidate], capture_output=True, text=True, timeout=5
- )
- if result.returncode == 0:
- return candidate
- except Exception:
- continue
+ found = shutil.which(candidate)
+ if found:
+ return found
+ explicit = "/usr/local/bin/wormhole-william"
+ if os.path.isfile(explicit):
+ return explicit
return None
-_WORMHOLE_CODE_RE = re.compile(r'\b(\d+-[\w]+-[\w]+(?:-[\w]+)*)\b')
-def _wormhole_send(path: str, room_id: str) -> str:
+def _wormhole_send_path(path: str, room_id: str) -> str:
bin_ = _wormhole_bin()
if not bin_:
- return "⚠ magic-wormhole not found. Install with: pip install magic-wormhole or brew install magic-wormhole"
+ return "⚠ magic-wormhole not found — install with: brew install magic-wormhole"
_send_message(room_id, f"⏳ Starting wormhole send for `{Path(path).name}`…")
-
proc = subprocess.Popen(
[bin_, "send", path],
- stdout=subprocess.PIPE,
- stderr=subprocess.STDOUT,
- text=True,
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
+ )
+ code = _capture_wormhole_code(proc, timeout=60)
+ if not code:
+ proc.terminate()
+ return "⚠ Could not get wormhole code within 60s"
+ return _post_wormhole_code(room_id, code)
+
+
+def _wormhole_send_text(text: str, room_id: str) -> str:
+ bin_ = _wormhole_bin()
+ if not bin_:
+ return "⚠ magic-wormhole not found — install with: brew install magic-wormhole"
+
+ _send_message(room_id, "⏳ Starting wormhole text send…")
+ proc = subprocess.Popen(
+ [bin_, "send", "--text", text],
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
+ code = _capture_wormhole_code(proc, timeout=60)
+ if not code:
+ proc.terminate()
+ return "⚠ Could not get wormhole code within 60s"
+ return _post_wormhole_code(room_id, code)
+
+
+def _wormhole_send_stdin(room_id: str) -> str:
+ """Read stdin, save to temp file, send via wormhole."""
+ data = sys.stdin.buffer.read()
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".txt", prefix="mx-pipe-") as f:
+ f.write(data)
+ tmp = f.name
+ try:
+ return _wormhole_send_path(tmp, room_id)
+ finally:
+ try:
+ os.unlink(tmp)
+ except OSError:
+ pass
- code: str | None = None
- deadline = time.monotonic() + 60
+
+def _capture_wormhole_code(proc: subprocess.Popen, timeout: int = 60) -> str | None:
+ deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
line = proc.stdout.readline() if proc.stdout else ""
if not line:
if proc.poll() is not None:
break
- time.sleep(0.2)
+ time.sleep(0.1)
continue
m = _WORMHOLE_CODE_RE.search(line)
if m:
- code = m.group(1)
- break
+ return m.group(1)
+ return None
- if not code:
- proc.terminate()
- return "⚠ Could not get wormhole code within 60s"
- # proc stays running in background — receiver picks up the file
- reply = (
+def _post_wormhole_code(room_id: str, code: str) -> str:
+ msg = (
f"📦 **Wormhole code:** `{code}`\n\n"
- f"Receive with:\n```\nwormhole receive {code}\n```\n"
- f"or in this room:\n```\n!qes wormhole recv {code}\n```"
+ f"Receive:\n```\nwormhole receive {code}\n```\n"
+ f"or: `!qes wormhole recv {code}`"
+ )
+ import html as _html
+ fmted = (
+ f"📦 Wormhole code: {_html.escape(code)}
"
+ f"Receive: wormhole receive {_html.escape(code)}"
+ f"
or: !qes wormhole recv {_html.escape(code)}
"
)
- _send_message(room_id, reply, formatted_html=(
- f"📦 Wormhole code: {code}
"
- f"Receive with: wormhole receive {code}"
- f"
or in this room: !qes wormhole recv {code}
"
- ))
+ _send_message(room_id, msg, formatted_html=fmted)
return code
+
def _wormhole_recv(code: str, room_id: str) -> str:
bin_ = _wormhole_bin()
if not bin_:
- return "⚠ magic-wormhole not found. Install with: pip install magic-wormhole"
+ return "⚠ magic-wormhole not found"
dest = Path.home() / "Downloads" / "wormhole-recv"
dest.mkdir(parents=True, exist_ok=True)
- _send_message(room_id, f"⏳ Receiving wormhole `{code}`…")
+ _send_message(room_id, f"⏳ Receiving `{code}`…")
try:
result = subprocess.run(
- [bin_, "receive", "--accept-file", "--output-file", str(dest / code), code],
+ [bin_, "receive", "--accept-file", code],
capture_output=True, text=True, timeout=120, cwd=str(dest),
)
except subprocess.TimeoutExpired:
return "⚠ wormhole receive timed out (120s)"
+ out = (result.stdout + result.stderr).strip()
if result.returncode != 0:
- return f"⚠ wormhole receive failed:\n```\n{result.stderr[:800]}\n```"
+ return f"⚠ wormhole failed:\n```\n{out[:600]}\n```"
- # Find what was downloaded
- out = result.stdout.strip() or result.stderr.strip()
- path_match = re.search(r'Received file written to (.+)', out)
- saved = path_match.group(1).strip() if path_match else str(dest)
- return f"✅ Received → `{saved}`"
+ match = re.search(r'(?:Received file written to|Saved to)[:\s]+(.+)', out)
+ saved = match.group(1).strip() if match else str(dest)
+ reply = f"✅ Received → `{saved}`"
+ _send_message(room_id, reply)
+ return reply
-# ── Mesh event tail ───────────────────────────────────────────────────────────
-def _read_cursor() -> int:
+# ── Context snapshot ──────────────────────────────────────────────────────────
+
+def _post_ctx(room_id: str) -> str:
try:
- return int(CURSOR_FILE.read_text().strip())
+ active = json.loads(ACTIVE_JSON.read_text()) if ACTIVE_JSON.exists() else {}
except Exception:
- return 0
+ active = {}
+
+ mesh_tail: list[dict] = []
+ mesh_jsonl = MESH_DIR / "context.jsonl"
+ if mesh_jsonl.exists():
+ lines = mesh_jsonl.read_text(errors="replace").splitlines()
+ for line in lines[-10:]:
+ try:
+ mesh_tail.append(json.loads(line))
+ except Exception:
+ pass
+
+ lines_out = ["**Active context:**"]
+ if active:
+ for k, v in active.items():
+ lines_out.append(f" {k}: {v}")
+ else:
+ lines_out.append(" (none)")
+
+ lines_out.append("\n**Recent mesh events:**")
+ for ev in mesh_tail:
+ ts = str(ev.get("ts", ""))[:16]
+ lines_out.append(f" {ts} [{ev.get('type','')}] {ev.get('title','')}")
+
+ text = "\n".join(lines_out)
+ return _send_message(room_id, text)
-def _write_cursor(pos: int) -> None:
- CURSOR_FILE.write_text(str(pos))
-def _event_to_matrix_body(event: dict) -> str | None:
+# ── Mesh event daemon ─────────────────────────────────────────────────────────
+
+def _event_to_body(event: dict) -> str | None:
ev_type = event.get("type", "")
if ev_type not in FORWARD_EVENTS:
return None
-
- ts = event.get("ts", "")[:19].replace("T", " ")
- data = event.get("data", {})
+ ts = str(event.get("ts", ""))[:16].replace("T", " ")
+ data = event.get("data", {})
title = event.get("title", ev_type)
- if ev_type == "cloudshell-connect":
- host = data.get("host", "?")
- return f"🔗 **Cloudshell connected** → `{host}` at {ts}"
-
- if ev_type == "k3s-tunnel-start":
- port = data.get("local_port", "16443")
- return f"🚇 **k3s tunnel open** → `localhost:{port}` at {ts}"
+ mapping = {
+ "cloudshell-connect": lambda: f"🔗 **Cloudshell** → `{data.get('host','?')}` at {ts}",
+ "k3s-tunnel-start": lambda: f"🚇 **k3s tunnel** → `localhost:{data.get('local_port','16443')}` at {ts}",
+ "split-view": lambda: f"⧉ **Split view** `{data.get('left','')}` ‖ `{data.get('right','')}` at {ts}",
+ "error-triage": lambda: f"⚠ **Error triage** `{str(data.get('cmd',''))[:60]}` rc={data.get('rc','?')} at {ts}",
+ "note-saved": lambda: f"📝 **Note** `{data.get('title', data.get('name',''))}` at {ts}",
+ "runbook-run": lambda: f"📋 **Runbook** `{data.get('name','')}` at {ts}",
+ }
+ fn = mapping.get(ev_type)
+ return fn() if fn else f"ℹ **{title}** at {ts}"
- if ev_type == "split-view":
- left = data.get("left", "")
- right = data.get("right", "")
- return f"⧉ **Split view** opened: `{left}` ‖ `{right}` at {ts}"
- if ev_type == "error-triage":
- cmd = data.get("cmd", "")[:80]
- rc = data.get("rc", "?")
- return f"⚠ **Error triage** — `{cmd}` exited {rc} at {ts}"
+def _read_cursor() -> int:
+ try:
+ return int(CURSOR_FILE.read_text().strip())
+ except Exception:
+ return 0
- if ev_type == "note-saved":
- name = data.get("title", data.get("name", ""))
- return f"📝 **Note saved:** `{name}` at {ts}"
- if ev_type == "runbook-run":
- name = data.get("name", "")
- return f"📋 **Runbook run:** `{name}` at {ts}"
+def _write_cursor(pos: int) -> None:
+ CURSOR_FILE.write_text(str(pos))
- return f"ℹ **{title}** at {ts}"
def _daemon(room_id: str) -> None:
- print(f"turtle-matrix-bridge: daemon starting, room={room_id}", flush=True)
+ print(f"turtle-matrix-bridge: daemon room={room_id}", flush=True)
pos = _read_cursor()
-
+ mesh_jsonl = MESH_DIR / "context.jsonl"
while True:
- if not MESH_JSONL.exists():
+ if not mesh_jsonl.exists():
time.sleep(5)
continue
-
- with open(MESH_JSONL) as f:
+ with open(mesh_jsonl) as f:
f.seek(pos)
while True:
line = f.readline()
@@ -287,107 +446,233 @@ def _daemon(room_id: str) -> None:
event = json.loads(line.strip())
except json.JSONDecodeError:
continue
- body = _event_to_matrix_body(event)
+ body = _event_to_body(event)
if body:
try:
_send_message(room_id, body)
- print(f" → forwarded: {event.get('type')}", flush=True)
except Exception as exc:
- print(f" ⚠ send failed: {exc}", flush=True)
-
+ print(f" ⚠ {exc}", flush=True)
time.sleep(2)
-# ── CLI ───────────────────────────────────────────────────────────────────────
-def _require_room(argv: list[str], offset: int) -> str:
- if len(argv) > offset:
- return argv[offset]
- if DEFAULT_ROOM_ID:
- return DEFAULT_ROOM_ID
- print("⚠ No room ID. Set MATRIX_BOT_ROOM_ID or pass as argument.", file=sys.stderr)
- sys.exit(1)
+# ── CLI arg helpers ───────────────────────────────────────────────────────────
+
+def _parse_flags(argv: list[str]) -> tuple[list[str], dict[str, str]]:
+ """Strip -r/--room and --lang flags, return (positional_args, flags)."""
+ flags: dict[str, str] = {}
+ pos: list[str] = []
+ i = 0
+ while i < len(argv):
+ if argv[i] in ("-r", "--room") and i + 1 < len(argv):
+ flags["room"] = argv[i + 1]
+ i += 2
+ elif argv[i] == "--lang" and i + 1 < len(argv):
+ flags["lang"] = argv[i + 1]
+ i += 2
+ elif argv[i].startswith("--room="):
+ flags["room"] = argv[i][7:]
+ i += 1
+ elif argv[i].startswith("--lang="):
+ flags["lang"] = argv[i][7:]
+ i += 1
+ else:
+ pos.append(argv[i])
+ i += 1
+ return pos, flags
+
+
+def _room(flags: dict[str, str]) -> str:
+ r = flags.get("room") or DEFAULT_ROOM_ID
+ if not r:
+ print("⚠ No room ID. Set MATRIX_BOT_ROOM_ID or pass -r ", file=sys.stderr)
+ sys.exit(1)
+ return r
+
def _status() -> None:
print(f"homeserver : {HOMESERVER_URL}")
- print(f"token : {'set' if ACCESS_TOKEN else 'NOT SET'}")
+ print(f"token : {'set (' + ACCESS_TOKEN[:8] + '…)' if ACCESS_TOKEN else 'NOT SET'}")
print(f"room : {DEFAULT_ROOM_ID or 'NOT SET'}")
print(f"forward : {', '.join(sorted(FORWARD_EVENTS))}")
- print(f"mesh jsonl : {'exists' if MESH_JSONL.exists() else 'missing'}")
- print(f"cursor : {_read_cursor()} bytes")
- print(f"wormhole : {_wormhole_bin() or 'not found'}")
- if ACCESS_TOKEN and HOMESERVER_URL:
+ print(f"wormhole : {_wormhole_bin() or 'not found — brew install magic-wormhole'}")
+ print(f"mesh jsonl : {'exists' if (MESH_DIR / 'context.jsonl').exists() else 'missing'}")
+ pid_file = STATE_DIR / "matrix-bridge.pid"
+ pid_ok = False
+ if pid_file.exists():
+ try:
+ pid = int(pid_file.read_text().strip())
+ os.kill(pid, 0)
+ pid_ok = True
+ except Exception:
+ pass
+ print(f"daemon : {'running (pid ' + str(pid) + ')' if pid_ok else 'stopped'}")
+ if ACCESS_TOKEN:
try:
- me = _req("GET", "/_matrix/client/v3/account/whoami")
+ me = _req("GET", "/_matrix/client/v3/account/whoami", timeout=5)
print(f"whoami : {me.get('user_id', '?')}")
+ rooms = _joined_rooms()
+ print(f"rooms : {len(rooms)} joined")
except Exception as exc:
print(f"whoami : ⚠ {exc}")
+
+# ── Main ──────────────────────────────────────────────────────────────────────
+
def main() -> None:
- argv = sys.argv[1:]
- if not argv:
+ if len(sys.argv) < 2:
print(__doc__.strip())
sys.exit(0)
- cmd = argv[0]
+ cmd = sys.argv[1]
+ rest = sys.argv[2:]
+ pos, flags = _parse_flags(rest)
if cmd == "daemon":
- room = _require_room(argv, 1)
+ room = pos[0] if pos else _room(flags)
_daemon(room)
elif cmd == "send":
- if len(argv) < 2:
- print("Usage: turtle-matrix-bridge send []", file=sys.stderr)
+ room = _room(flags)
+ if pos:
+ text = " ".join(pos)
+ elif not sys.stdin.isatty():
+ text = sys.stdin.read()
+ else:
+ print("Usage: turtle-matrix-bridge send [-r ] []", file=sys.stderr)
sys.exit(1)
- text = argv[1]
- room = _require_room(argv, 2)
- eid = _send_message(room, text)
- print(f"sent: {eid}")
+ eid = _send_message(room, text.strip())
+ print(eid)
+
+ elif cmd == "pipe":
+ room = _room(flags)
+ lang = flags.get("lang", "")
+ data = sys.stdin.read()
+ if not lang and not lang:
+ # Try to detect from first line shebang or content
+ first = data.lstrip()[:20]
+ if first.startswith("#!/"):
+ lang = "bash"
+ elif first.startswith("{") or first.startswith("["):
+ lang = "json"
+ eid = _send_code_block(room, data.strip()[:8000], lang=lang)
+ print(eid)
elif cmd == "send-file":
- if len(argv) < 2:
- print("Usage: turtle-matrix-bridge send-file []", file=sys.stderr)
+ if not pos:
+ print("Usage: turtle-matrix-bridge send-file [-r ] ", file=sys.stderr)
sys.exit(1)
- path = Path(argv[1])
- room = _require_room(argv, 2)
+ path = Path(pos[0])
+ room = _room(flags)
try:
content = path.read_text(errors="replace")
except Exception as exc:
- print(f"⚠ Cannot read {path}: {exc}", file=sys.stderr)
+ print(f"⚠ {exc}", file=sys.stderr)
sys.exit(1)
- lang = path.suffix.lstrip(".") or "text"
- eid = _send_code_block(room, content[:8000], lang=lang, label=str(path.name))
- print(f"sent: {eid}")
+ lang = flags.get("lang") or _lang_for(path)
+ eid = _send_code_block(room, content[:8000], lang=lang, label=path.name)
+ print(eid)
elif cmd == "send-code":
- if len(argv) < 3:
- print("Usage: turtle-matrix-bridge send-code []", file=sys.stderr)
+ if len(pos) < 2:
+ print("Usage: turtle-matrix-bridge send-code [-r ]", file=sys.stderr)
+ sys.exit(1)
+ lang, path_str = pos[0], pos[1]
+ room = _room(flags)
+ content = Path(path_str).read_text(errors="replace")
+ eid = _send_code_block(room, content[:8000], lang=lang, label=Path(path_str).name)
+ print(eid)
+
+ elif cmd == "reply":
+ if len(pos) < 2:
+ print("Usage: turtle-matrix-bridge reply [-r ]", file=sys.stderr)
sys.exit(1)
- lang = argv[1]
- path = Path(argv[2])
- room = _require_room(argv, 3)
- content = path.read_text(errors="replace")
- eid = _send_code_block(room, content[:8000], lang=lang, label=str(path.name))
- print(f"sent: {eid}")
+ event_id = pos[0]
+ text = " ".join(pos[1:])
+ room = _room(flags)
+ eid = _send_message(room, text, thread_event_id=event_id)
+ print(eid)
+
+ elif cmd == "react":
+ if len(pos) < 2:
+ print("Usage: turtle-matrix-bridge react [-r ]", file=sys.stderr)
+ sys.exit(1)
+ event_id, emoji = pos[0], pos[1]
+ room = _room(flags)
+ eid = _send_reaction(room, event_id, emoji)
+ print(eid)
+
+ elif cmd == "edit":
+ if len(pos) < 2:
+ print("Usage: turtle-matrix-bridge edit [-r ]", file=sys.stderr)
+ sys.exit(1)
+ event_id = pos[0]
+ new_text = " ".join(pos[1:])
+ room = _room(flags)
+ eid = _edit_message(room, event_id, new_text)
+ print(eid)
+
+ elif cmd == "rooms":
+ try:
+ rooms = _joined_rooms()
+ except Exception as exc:
+ print(f"⚠ {exc}", file=sys.stderr)
+ sys.exit(1)
+ if not rooms:
+ print("(no rooms joined)")
+ return
+ for r in rooms:
+ name = _room_name(r)
+ label = f" {name}" if name != r else ""
+ print(f"{r}{label}")
+
+ elif cmd == "messages":
+ room = _room(flags)
+ n = int(pos[0]) if pos else 20
+ try:
+ msgs = _room_messages(room, n)
+ except Exception as exc:
+ print(f"⚠ {exc}", file=sys.stderr)
+ sys.exit(1)
+ for m in msgs:
+ ts = str(m.get("ts", ""))
+ try:
+ import datetime
+ dt = datetime.datetime.fromtimestamp(int(ts) / 1000)
+ ts_str = dt.strftime("%H:%M")
+ except Exception:
+ ts_str = ts[:5]
+ sender = m.get("sender", "").split(":")[0].lstrip("@")
+ print(f"[{ts_str}] <{sender}> {m.get('body','')[:120]}")
elif cmd == "wormhole-send":
- if len(argv) < 2:
- print("Usage: turtle-matrix-bridge wormhole-send []", file=sys.stderr)
+ if not pos:
+ print("Usage: turtle-matrix-bridge wormhole-send [-r ] ", file=sys.stderr)
sys.exit(1)
- path = argv[1]
- room = _require_room(argv, 2)
- code = _wormhole_send(path, room)
+ room = _room(flags)
+ code = _wormhole_send_path(pos[0], room)
+ print(code)
+
+ elif cmd == "wormhole-pipe":
+ room = _room(flags)
+ if sys.stdin.isatty():
+ print("⚠ wormhole-pipe reads from stdin (pipe something in)", file=sys.stderr)
+ sys.exit(1)
+ code = _wormhole_send_stdin(room)
print(code)
elif cmd == "wormhole-recv":
- if len(argv) < 2:
- print("Usage: turtle-matrix-bridge wormhole-recv []", file=sys.stderr)
+ if not pos:
+ print("Usage: turtle-matrix-bridge wormhole-recv [-r ] ", file=sys.stderr)
sys.exit(1)
- code = argv[1]
- room = _require_room(argv, 2)
- result = _wormhole_recv(code, room)
- _send_message(room, result)
+ room = _room(flags)
+ result = _wormhole_recv(pos[0], room)
print(result)
+ elif cmd == "ctx":
+ room = _room(flags)
+ eid = _post_ctx(room)
+ print(eid)
+
elif cmd == "status":
_status()
@@ -395,5 +680,6 @@ def main() -> None:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
+
if __name__ == "__main__":
main()
diff --git a/assets/sourceos/shell/turtle-shell-init.zsh b/assets/sourceos/shell/turtle-shell-init.zsh
index 704b64635a4..283c0ab1c19 100644
--- a/assets/sourceos/shell/turtle-shell-init.zsh
+++ b/assets/sourceos/shell/turtle-shell-init.zsh
@@ -1968,43 +1968,85 @@ _turtle_matrix_bridge_bin() {
printf '%s' "$(dirname "${(%):-%x}")/turtle-matrix-bridge"
}
-# mx [room_id] — post a plain text message to the default Matrix room
+# mx [-r ] []
+# Post text to Matrix room. Reads stdin when piped: cmd | mx
+# Examples: mx "deploy done" • k3s get pods | mx • mx -r !ops:srv hi
mx() {
- if [[ -z "$1" ]]; then
- printf 'Usage: mx [room_id]\n' >&2
+ local _bin; _bin="$(_turtle_matrix_bridge_bin)"
+ if [[ ! -t 0 ]]; then
+ python3 "$_bin" send "$@"
+ elif [[ -n "$1" ]]; then
+ python3 "$_bin" send "$@"
+ else
+ printf 'Usage: mx [-r ] | cmd | mx\n' >&2
return 1
fi
- python3 "$(_turtle_matrix_bridge_bin)" send "$@"
}
-# mxf [room_id] — post a file as a code block to the Matrix room
+# mxf [-r ] [--lang ]
+# Post a file as a syntax-highlighted code block
mxf() {
- if [[ -z "$1" ]]; then
- printf 'Usage: mxf [room_id]\n' >&2
- return 1
- fi
+ [[ -z "$1" ]] && { printf 'Usage: mxf [-r ] \n' >&2; return 1; }
python3 "$(_turtle_matrix_bridge_bin)" send-file "$@"
}
-# mxw [room_id] — wormhole-send a file and post the code to the Matrix room
+# mxpipe [-r ] [--lang ]
+# Pipe stdin as a code block: k3s get pods | mxpipe --lang yaml
+mxpipe() { python3 "$(_turtle_matrix_bridge_bin)" pipe "$@" }
+
+# mxw [-r ]
+# Wormhole-send a file; posts the wormhole receive code to the Matrix room
+# Also works with stdin: k3s logs mypod | mxw (no file arg = stdin mode)
mxw() {
- if [[ -z "$1" ]]; then
- printf 'Usage: mxw [room_id]\n' >&2
+ local _bin; _bin="$(_turtle_matrix_bridge_bin)"
+ if [[ ! -t 0 ]] && [[ -z "$1" || "$1" == -* ]]; then
+ python3 "$_bin" wormhole-pipe "$@"
+ elif [[ -n "$1" ]]; then
+ python3 "$_bin" wormhole-send "$@"
+ else
+ printf 'Usage: mxw [-r ] | cmd | mxw\n' >&2
return 1
fi
- python3 "$(_turtle_matrix_bridge_bin)" wormhole-send "$@"
}
-# mxr [room_id] — wormhole-receive by code, post result to Matrix room
+# mxr [-r ]
+# Receive a wormhole transfer and post the saved path to the room
mxr() {
- if [[ -z "$1" ]]; then
- printf 'Usage: mxr [room_id]\n' >&2
- return 1
- fi
+ [[ -z "$1" ]] && { printf 'Usage: mxr [-r ] \n' >&2; return 1; }
python3 "$(_turtle_matrix_bridge_bin)" wormhole-recv "$@"
}
-# mxstatus — show Matrix bridge config + connectivity
+# mxreply [-r ]
+# Send a threaded reply to a specific Matrix event
+mxreply() {
+ [[ $# -lt 2 ]] && { printf 'Usage: mxreply [-r ] \n' >&2; return 1; }
+ python3 "$(_turtle_matrix_bridge_bin)" reply "$@"
+}
+
+# mxreact [-r ]
+# Send an emoji reaction to a Matrix event
+mxreact() {
+ [[ $# -lt 2 ]] && { printf 'Usage: mxreact [-r ] \n' >&2; return 1; }
+ python3 "$(_turtle_matrix_bridge_bin)" react "$@"
+}
+
+# mxedit [-r ]
+# Edit (replace) a Matrix message you sent
+mxedit() {
+ [[ $# -lt 2 ]] && { printf 'Usage: mxedit [-r ] \n' >&2; return 1; }
+ python3 "$(_turtle_matrix_bridge_bin)" edit "$@"
+}
+
+# mxrooms — list joined Matrix rooms with names
+mxrooms() { python3 "$(_turtle_matrix_bridge_bin)" rooms }
+
+# mxlog [-r ] [] — print last n Matrix messages from the room (default 20)
+mxlog() { python3 "$(_turtle_matrix_bridge_bin)" messages "$@" }
+
+# mxctx [-r ] — post current active context + mesh tail to Matrix room
+mxctx() { python3 "$(_turtle_matrix_bridge_bin)" ctx "$@" }
+
+# mxstatus — show Matrix bridge config + connectivity
mxstatus() { python3 "$(_turtle_matrix_bridge_bin)" status }
# Auto-start Matrix bridge daemon at shell init if config is present
diff --git a/assets/sourceos/turtleterm.lua b/assets/sourceos/turtleterm.lua
index 160a3878421..4223f1d8ad3 100644
--- a/assets/sourceos/turtleterm.lua
+++ b/assets/sourceos/turtleterm.lua
@@ -1455,10 +1455,15 @@ local PALETTE_COMMANDS = {
{ label = '☁ k3s tunnel start (ktunnel) —', id = 'k3s_tunnel' },
{ label = '☁ CloudShell status (csh-status) —', id = 'cloudshell_status' },
-- Matrix bridge
- { label = '💬 Matrix: send message (mx) CMD+SHIFT+ALT+M', id = 'matrix_send' },
- { label = '💬 Matrix: send file (mxf) —', id = 'matrix_send_file' },
- { label = '🕳 Matrix: wormhole send (mxw) —', id = 'matrix_wormhole' },
- { label = '💬 Matrix: bridge status (mxstatus) —', id = 'matrix_status' },
+ { label = '💬 Matrix: send message (mx) CMD+SHIFT+ALT+M', id = 'matrix_send' },
+ { label = '💬 Matrix: pipe output (| mxpipe) —', id = 'matrix_pipe' },
+ { label = '💬 Matrix: send file (mxf) —', id = 'matrix_send_file'},
+ { label = '🕳 Matrix: wormhole send (mxw) CMD+SHIFT+ALT+W', id = 'matrix_wormhole' },
+ { label = '🕳 Matrix: wormhole pipe (| mxw) —', id = 'matrix_wh_pipe' },
+ { label = '💬 Matrix: list rooms (mxrooms) —', id = 'matrix_rooms' },
+ { label = '💬 Matrix: recent log (mxlog) —', id = 'matrix_log' },
+ { label = '💬 Matrix: post context (mxctx) —', id = 'matrix_ctx' },
+ { label = '💬 Matrix: bridge status (mxstatus) —', id = 'matrix_status' },
}
local function turtle_command_palette()
@@ -1564,8 +1569,13 @@ local function turtle_command_palette()
k3s_tunnel = act.SendString('ktunnel start\n'),
cloudshell_status = act.SendString('csh-status\n'),
matrix_send = act.SendString('mx '),
+ matrix_pipe = act.SendString('mxpipe'),
matrix_send_file = act.SendString('mxf '),
matrix_wormhole = act.SendString('mxw '),
+ matrix_wh_pipe = act.SendString('mxw'),
+ matrix_rooms = act.SendString('mxrooms\n'),
+ matrix_log = act.SendString('mxlog\n'),
+ matrix_ctx = act.SendString('mxctx\n'),
matrix_status = act.SendString('mxstatus\n'),
}
local a = dispatch[id]