cleanup: remove clawd/openclaw references, fix permissions, add architecture docs

- Replace all ~/clawd and ~/.clawdbot paths with ~/echo-core equivalents
  in tools (git_commit, ralph_prd_generator, backup_config, lead-gen)
- Update personality files: TOOLS.md repo/paths, AGENTS.md security audit cmd
- Migrate HANDOFF.md architectural decisions to docs/architecture.md
- Tighten credentials/ dir to 700, add to .gitignore
- Add .claude/ and *.pid to .gitignore
- Various adapter, router, and session improvements from prior work

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MoltBot Service
2026-02-14 21:44:13 +00:00
parent d585c85081
commit 5928077646
35 changed files with 666 additions and 790 deletions

View File

@@ -12,9 +12,11 @@ import os
import shutil
import subprocess
import tempfile
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable
logger = logging.getLogger(__name__)
_invoke_log = logging.getLogger("echo-core.invoke")
@@ -31,7 +33,7 @@ _SESSIONS_FILE = SESSIONS_DIR / "active.json"
VALID_MODELS = {"haiku", "sonnet", "opus"}
DEFAULT_MODEL = "sonnet"
DEFAULT_TIMEOUT = 120 # seconds
DEFAULT_TIMEOUT = 300 # seconds
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
@@ -156,12 +158,20 @@ def _save_sessions(data: dict) -> None:
raise
def _run_claude(cmd: list[str], timeout: int) -> dict:
def _run_claude(
cmd: list[str],
timeout: int,
on_text: Callable[[str], None] | None = None,
) -> dict:
"""Run a Claude CLI command and return parsed output.
Expects ``--output-format stream-json --verbose``. Parses the newline-
delimited JSON stream, collecting every text block from ``assistant``
messages and metadata from the final ``result`` line.
If *on_text* is provided it is called with each intermediate text block
as soon as it arrives (before the process finishes), enabling real-time
streaming to adapters.
"""
if not shutil.which(CLAUDE_BIN):
raise FileNotFoundError(
@@ -169,59 +179,92 @@ def _run_claude(cmd: list[str], timeout: int) -> dict:
"Install: https://docs.anthropic.com/en/docs/claude-code"
)
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=_safe_env(),
cwd=PROJECT_ROOT,
)
# Watchdog thread: kill the process if it exceeds the timeout
timed_out = threading.Event()
def _watchdog():
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
timed_out.set()
try:
proc.kill()
except OSError:
pass
watchdog = threading.Thread(target=_watchdog, daemon=True)
watchdog.start()
# --- Parse stream-json output line by line ---
text_blocks: list[str] = []
result_obj: dict | None = None
intermediate_count = 0
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
env=_safe_env(),
cwd=PROJECT_ROOT,
)
except subprocess.TimeoutExpired:
for line in proc.stdout:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
msg_type = obj.get("type")
if msg_type == "assistant":
message = obj.get("message", {})
for block in message.get("content", []):
if block.get("type") == "text":
text = block.get("text", "").strip()
if text:
text_blocks.append(text)
if on_text:
try:
on_text(text)
intermediate_count += 1
except Exception:
logger.exception("on_text callback error")
elif msg_type == "result":
result_obj = obj
finally:
# Ensure process resources are cleaned up
proc.stdout.close()
try:
proc.wait(timeout=30)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
stderr_output = proc.stderr.read()
proc.stderr.close()
if timed_out.is_set():
raise TimeoutError(f"Claude CLI timed out after {timeout}s")
if proc.returncode != 0:
detail = proc.stderr[:500] or proc.stdout[:500]
logger.error("Claude CLI stdout: %s", proc.stdout[:1000])
logger.error("Claude CLI stderr: %s", proc.stderr[:1000])
stdout_tail = "\n".join(text_blocks[-3:]) if text_blocks else ""
detail = stderr_output[:500] or stdout_tail[:500]
logger.error("Claude CLI stderr: %s", stderr_output[:1000])
raise RuntimeError(
f"Claude CLI error (exit {proc.returncode}): {detail}"
)
# --- Parse stream-json output ---
text_blocks: list[str] = []
result_obj: dict | None = None
for line in proc.stdout.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
msg_type = obj.get("type")
if msg_type == "assistant":
# Extract text from content blocks
message = obj.get("message", {})
for block in message.get("content", []):
if block.get("type") == "text":
text = block.get("text", "").strip()
if text:
text_blocks.append(text)
elif msg_type == "result":
result_obj = obj
if result_obj is None:
raise RuntimeError(
"Failed to parse Claude CLI output: no result line in stream"
)
# Build a dict compatible with the old json output format
combined_text = "\n\n".join(text_blocks) if text_blocks else result_obj.get("result", "")
return {
@@ -232,6 +275,7 @@ def _run_claude(cmd: list[str], timeout: int) -> dict:
"cost_usd": result_obj.get("cost_usd", 0),
"duration_ms": result_obj.get("duration_ms", 0),
"num_turns": result_obj.get("num_turns", 0),
"intermediate_count": intermediate_count,
}
@@ -273,10 +317,14 @@ def start_session(
message: str,
model: str = DEFAULT_MODEL,
timeout: int = DEFAULT_TIMEOUT,
on_text: Callable[[str], None] | None = None,
) -> tuple[str, str]:
"""Start a new Claude CLI session for a channel.
Returns (response_text, session_id).
If *on_text* is provided, each intermediate Claude text block is passed
to the callback as soon as it arrives.
"""
if model not in VALID_MODELS:
raise ValueError(
@@ -297,7 +345,7 @@ def start_session(
]
_t0 = time.monotonic()
data = _run_claude(cmd, timeout)
data = _run_claude(cmd, timeout, on_text=on_text)
_elapsed_ms = int((time.monotonic() - _t0) * 1000)
for field in ("result", "session_id"):
@@ -342,8 +390,13 @@ def resume_session(
session_id: str,
message: str,
timeout: int = DEFAULT_TIMEOUT,
on_text: Callable[[str], None] | None = None,
) -> str:
"""Resume an existing Claude session by ID. Returns response text."""
"""Resume an existing Claude session by ID. Returns response text.
If *on_text* is provided, each intermediate Claude text block is passed
to the callback as soon as it arrives.
"""
# Find channel/model for logging
sessions = _load_sessions()
_log_channel = "?"
@@ -365,7 +418,7 @@ def resume_session(
]
_t0 = time.monotonic()
data = _run_claude(cmd, timeout)
data = _run_claude(cmd, timeout, on_text=on_text)
_elapsed_ms = int((time.monotonic() - _t0) * 1000)
if not data.get("result"):
@@ -407,13 +460,14 @@ def send_message(
message: str,
model: str = DEFAULT_MODEL,
timeout: int = DEFAULT_TIMEOUT,
on_text: Callable[[str], None] | None = None,
) -> str:
"""High-level convenience: auto start or resume based on channel state."""
session = get_active_session(channel_id)
if session is not None:
return resume_session(session["session_id"], message, timeout)
return resume_session(session["session_id"], message, timeout, on_text=on_text)
response_text, _session_id = start_session(
channel_id, message, model, timeout
channel_id, message, model, timeout, on_text=on_text
)
return response_text