Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,55 @@ CheetahClaws: **A Lightweight** and **Easy-to-Use** Python Reimplementation of C


### Demos

#### Interactive terminal recordings (animated SVG — plays inline, no click needed)

<div align="center">
<img src="docs/media/casts/code_review.svg" width="850" alt="CheetahClaws code review demo"/>
</div>
<div align="center"><sub><i>Code review: profile a slow Python function, switch to local Ollama, apply the fix — 11× faster</i></sub></div>

<br/>

<div align="center">
<img src="docs/media/casts/research.svg" width="900" alt="CheetahClaws /research 20-source pipeline"/>
</div>
<div align="center"><sub><i><code>/research</code>: parallel fan-out across 20 sources incl. 知乎 / B站 / 微博 / 小红书, cross-platform attention heat table, citation verification</i></sub></div>

<br/>

<div align="center">
<img src="docs/media/casts/brainstorm.svg" width="900" alt="CheetahClaws /brainstorm multi-persona debate"/>
</div>
<div align="center"><sub><i><code>/brainstorm</code>: 5 expert personas (Architect / Skeptic / Pragmatist / DBA / PM) adversarially debate, then converge on a <code>todo_list.txt</code> ready for <code>/worker</code></i></sub></div>

<br/>

<div align="center">
<img src="docs/media/casts/lab.svg" width="900" alt="CheetahClaws /lab autonomous paper writing"/>
</div>
<div align="center"><sub><i><code>/lab</code> (Research Lab): 9 specialised agents drive a paper through 9 stages — questioning → survey → outline → sandboxed Python experiment → reviewer loop → citation verification → finalisation</i></sub></div>

<br/>

<div align="center">
<img src="docs/media/casts/research_agent.svg" width="900" alt="CheetahClaws /agent research_assistant autonomous loop"/>
</div>
<div align="center"><sub><i><code>/agent research_assistant</code>: autonomous background loop running every 4h, pushes daily digests to Telegram. Stagnation-stop guard auto-pauses to save tokens when output stops changing.</i></sub></div>

<br/>

<div align="center">
<img src="docs/media/casts/telegram.svg" width="900" alt="CheetahClaws Telegram bridge remote control"/>
</div>
<div align="center"><sub><i>Telegram bridge: full chat round-trip, slash-command passthrough, job queue with <code>!jobs</code>/<code>!cancel</code>, push notifications when long-running tasks finish. Same UX for <code>/wechat</code> and <code>/slack</code>.</i></sub></div>

<br/>

> **Tip:** the recordings are animated SVGs at [`docs/media/casts/`](docs/media/casts/). Source `.cast` files (asciinema v2) live next to them — replay locally with `asciinema play <file>.cast` or re-render with `svg-term --in <file>.cast --out <file>.svg`.

---

<div align=center>
<img src="docs/media/demos/demo.gif" width="850"/>
</div>
Expand Down
30 changes: 29 additions & 1 deletion bridges/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,20 +252,48 @@ def _slack_terminal(cmd, ch, skey):
slash_cb = session_ctx.handle_slash
if slash_cb:
def _slack_slash_runner(_slash_text, _ch):
import io as _io, sys as _sys, re as _re_ansi
_slack_thread_local.active = True
sctx = runtime.get_ctx(config)
sctx.slack_current_channel = _ch
# Capture print()/info()/ok() output so commands
# like /help (which render via print) reach the
# user instead of disappearing into server logs
# (issue #84 follow-up — same root cause as the
# Telegram bridge).
_buf_out, _buf_err = _io.StringIO(), _io.StringIO()
_orig_out, _orig_err = _sys.stdout, _sys.stderr
class _Tee:
def __init__(self, *streams):
self._streams = streams
def write(self, data):
for s in self._streams:
try: s.write(data)
except Exception: pass
def flush(self):
for s in self._streams:
try: s.flush()
except Exception: pass
_sys.stdout = _Tee(_orig_out, _buf_out)
_sys.stderr = _Tee(_orig_err, _buf_err)
try:
cmd_type = slash_cb(_slash_text)
except Exception as e:
_sys.stdout, _sys.stderr = _orig_out, _orig_err
_slack_send(token, _ch, f"⚠ Error: {e}")
return
finally:
_sys.stdout, _sys.stderr = _orig_out, _orig_err
_slack_thread_local.active = False
sctx.slack_current_channel = None
_captured = (_buf_out.getvalue() + _buf_err.getvalue())
_captured = _re_ansi.sub(r'\x1b\[[0-9;]*m', '', _captured).strip()
if cmd_type == "simple":
cmd_name = _slash_text.strip().split()[0]
_slack_send(token, _ch, f"✅ {cmd_name} executed.")
if _captured:
_slack_send(token, _ch, _captured)
else:
_slack_send(token, _ch, f"✅ {cmd_name} executed.")
return
slack_state = session_ctx.agent_state
if slack_state and slack_state.messages:
Expand Down
57 changes: 49 additions & 8 deletions bridges/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,27 @@ def _handle_callback_query(token: str, chat_id: int, cb: dict,

_, prompt_id, value = cb_data.split(":", 2)
expected = getattr(session_ctx, "tg_callback_prompt_id", "") or ""
if expected and expected != prompt_id:
if not expected:
# No prompt is currently waiting — this click belongs to an
# already-answered or timed-out prompt. Don't edit the message
# with a fake "✓ Selected" confirmation; the user would think the
# action took effect when in fact nothing happens (issue #84
# follow-up). The acknowledgeCallbackQuery above clears the
# spinner so the click still feels handled.
return
if expected != prompt_id:
# Stale click from an earlier prompt — ignore so the live prompt
# keeps waiting for its own button press.
return

# Find the human label for this value, if we can match the message text.
label_for_value = value
for line in cb_text.splitlines():
# Lines look like "[1] ✅ Approve — y" but the label format is owned by
# the caller, so just take the value verbatim if no match.
pass
# Sanitize the value for visual confirmation. Callers can pass any
# string as the option value (it travels in callback_data), so escape
# backticks/Markdown markers before embedding in the edited message —
# otherwise editMessageText silently fails on parse errors and the
# user just sees the original prompt unchanged.
label_for_value = (
str(value).replace("\\", "\\\\").replace("`", "'").replace("*", "·")
)

if cb_msg:
new_body = cb_text + f"\n\n✓ Selected: `{label_for_value}`"
Expand Down Expand Up @@ -542,17 +552,48 @@ def _terminal_runner(cmd, chat_token, cid, skey):
slash_cb = session_ctx.handle_slash
if slash_cb:
def _slash_runner(_slash_text, _token, _chat_id):
import io as _io, sys as _sys, re as _re_ansi
_tg_thread_local.active = True
# Capture print()/info()/ok()/warn()/err() output so
# commands like /help (which render their menu via
# print) surface in the chat instead of disappearing
# into the server log (issue #84 follow-up).
_buf_out, _buf_err = _io.StringIO(), _io.StringIO()
_orig_out, _orig_err = _sys.stdout, _sys.stderr
class _Tee:
def __init__(self, *streams):
self._streams = streams
def write(self, data):
for s in self._streams:
try: s.write(data)
except Exception: pass
def flush(self):
for s in self._streams:
try: s.flush()
except Exception: pass
_sys.stdout = _Tee(_orig_out, _buf_out)
_sys.stderr = _Tee(_orig_err, _buf_err)
try:
cmd_type = slash_cb(_slash_text)
except Exception as e:
_sys.stdout, _sys.stderr = _orig_out, _orig_err
_tg_send(_token, _chat_id, f"⚠ Error: {e}")
return
finally:
_sys.stdout, _sys.stderr = _orig_out, _orig_err
_tg_thread_local.active = False
_captured = (_buf_out.getvalue() + _buf_err.getvalue())
_captured = _re_ansi.sub(r'\x1b\[[0-9;]*m', '', _captured).strip()
if cmd_type == "simple":
cmd_name = _slash_text.strip().split()[0]
_tg_send(_token, _chat_id, f"✅ {cmd_name} executed.")
# Forward the captured menu/status text so the
# user actually sees /help, /status, /model
# output. Fall back to the bare ack only when
# the command produced nothing.
if _captured:
_tg_send(_token, _chat_id, _captured)
else:
_tg_send(_token, _chat_id, f"✅ {cmd_name} executed.")
return
tg_state = session_ctx.agent_state
if tg_state and tg_state.messages:
Expand Down
30 changes: 29 additions & 1 deletion bridges/wechat.py
Original file line number Diff line number Diff line change
Expand Up @@ -615,20 +615,48 @@ def _wx_terminal(cmd, uid, skey):
slash_cb = session_ctx.handle_slash
if slash_cb:
def _wx_slash_runner(_slash_text, _uid):
import io as _io, sys as _sys, re as _re_ansi
_wx_thread_local.active = True
sctx = runtime.get_ctx(config)
sctx.wx_current_user_id = _uid
# Capture print()/info()/ok() output so commands
# like /help (which render via print) reach the
# user instead of disappearing into server logs
# (issue #84 follow-up — same root cause as the
# Telegram bridge).
_buf_out, _buf_err = _io.StringIO(), _io.StringIO()
_orig_out, _orig_err = _sys.stdout, _sys.stderr
class _Tee:
def __init__(self, *streams):
self._streams = streams
def write(self, data):
for s in self._streams:
try: s.write(data)
except Exception: pass
def flush(self):
for s in self._streams:
try: s.flush()
except Exception: pass
_sys.stdout = _Tee(_orig_out, _buf_out)
_sys.stderr = _Tee(_orig_err, _buf_err)
try:
cmd_type = slash_cb(_slash_text)
except Exception as e:
_sys.stdout, _sys.stderr = _orig_out, _orig_err
_wx_send(_uid, f"⚠ Error: {e}", config)
return
finally:
_sys.stdout, _sys.stderr = _orig_out, _orig_err
_wx_thread_local.active = False
sctx.wx_current_user_id = None
_captured = (_buf_out.getvalue() + _buf_err.getvalue())
_captured = _re_ansi.sub(r'\x1b\[[0-9;]*m', '', _captured).strip()
if cmd_type == "simple":
cmd_name = _slash_text.strip().split()[0]
_wx_send(_uid, f"✅ {cmd_name} executed.", config)
if _captured:
_wx_send(_uid, _captured, config)
else:
_wx_send(_uid, f"✅ {cmd_name} executed.", config)
return
wx_state = session_ctx.agent_state
if wx_state and wx_state.messages:
Expand Down
28 changes: 27 additions & 1 deletion cheetahclaws.py
Original file line number Diff line number Diff line change
Expand Up @@ -739,14 +739,28 @@ def _start_headless_bridges(config: dict) -> None:
return # nothing configured — no-op

import runtime as _runtime
from agent import AgentState, run as _agent_run, TextChunk, ToolStart, ToolEnd
from agent import (
AgentState, run as _agent_run,
TextChunk, ToolStart, ToolEnd, PermissionRequest,
)
from context import build_system_prompt

state = AgentState(messages=[], total_input_tokens=0, total_output_tokens=0)
session_ctx = _runtime.get_session_ctx(config.get("_session_id", "default"))
session_ctx.agent_state = state
# Wire the low-level Telegram sender so ask_input_interactive can render
# inline-keyboard permission prompts. Without this, the Telegram branch
# in tools/interaction.py:256 falls through to terminal input(), making
# approval prompts invisible in headless/Docker mode (issue #84 follow-up).
session_ctx.tg_send = _tg_send

def _headless_run_query(prompt: str, is_background: bool = False) -> None:
# Promote the per-turn telegram_incoming flag so _is_in_tg_turn() sees
# in_telegram_turn=True for the duration of this query. Without this,
# ask_input_interactive routes prompts to the terminal even though the
# query was triggered by an inbound Telegram message.
session_ctx.in_telegram_turn = session_ctx.telegram_incoming
session_ctx.telegram_incoming = False
system_prompt = build_system_prompt(config)
try:
for ev in _agent_run(prompt, state, config, system_prompt):
Expand All @@ -759,8 +773,20 @@ def _headless_run_query(prompt: str, is_background: bool = False) -> None:
elif isinstance(ev, ToolEnd) and session_ctx.on_tool_end:
try: session_ctx.on_tool_end(ev.name, str(ev.result or "")[:500])
except Exception: pass
elif isinstance(ev, PermissionRequest):
# Mirror repl()'s permission handling so headless deploys
# actually surface the [Approve][Reject][Accept all]
# inline-keyboard prompt over the bridge. Defaults to
# denied on any failure so a broken bridge never auto-runs
# a sensitive tool.
try:
ev.granted = ask_permission_interactive(ev.description, config)
except Exception:
ev.granted = False
except Exception:
pass # never let a bridge query crash the server thread
finally:
session_ctx.in_telegram_turn = False

session_ctx.run_query = _headless_run_query
# Wire slash-command dispatch so bridges' /<cmd> messages don't go to
Expand Down
Loading
Loading