Ported formatToolStatus from pixel-agents-openclaw/transcriptParser.ts as _format_tool_status in Python

This commit is contained in:
null 2026-05-25 13:19:39 -05:00
parent 90a4abde30
commit 00bae96490
2 changed files with 249 additions and 36 deletions

View File

@ -4,8 +4,10 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os
from collections import deque from collections import deque
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@ -312,6 +314,205 @@ async def _fetch_runtime_ticker_items(
return items return items
def _parse_ts(ts: str | None) -> datetime | None:
if not ts:
return None
try:
return datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone(UTC).replace(tzinfo=None)
except ValueError:
return None
_BASH_CMD_MAX = 80
_TASK_DESC_MAX = 60
def _format_tool_status(tool_name: str, inp: dict[str, Any]) -> str:
"""Human-readable tool label — ported from pixel-agents-openclaw/transcriptParser.ts."""
def _base(p: Any) -> str:
return os.path.basename(str(p)) if p else ""
if tool_name == "Read":
return f"Reading {_base(inp.get('file_path', ''))}"
if tool_name == "Edit":
return f"Editing {_base(inp.get('file_path', ''))}"
if tool_name == "Write":
return f"Writing {_base(inp.get('file_path', ''))}"
if tool_name == "Bash":
cmd = str(inp.get("command", ""))
return f"Running: {cmd[:_BASH_CMD_MAX]}" if len(cmd) > _BASH_CMD_MAX else f"Running: {cmd}"
if tool_name == "Glob":
return "Searching files"
if tool_name == "Grep":
return "Searching code"
if tool_name == "WebFetch":
return "Fetching web content"
if tool_name == "WebSearch":
return "Searching the web"
if tool_name == "Task":
desc = str(inp.get("description", ""))
if desc:
return f"Subtask: {desc[:_TASK_DESC_MAX]}" if len(desc) > _TASK_DESC_MAX else f"Subtask: {desc}"
return "Running subtask"
if tool_name == "AskUserQuestion":
return "Waiting for input"
if tool_name == "EnterPlanMode":
return "Planning"
if tool_name == "NotebookEdit":
return "Editing notebook"
return f"Using {tool_name}"
def _tail_lines(path: Path, nbytes: int = 80_000) -> list[str]:
try:
with open(path, "rb") as fh:
fh.seek(0, 2)
size = fh.tell()
fh.seek(max(0, size - nbytes))
data = fh.read()
idx = data.find(b"\n")
if idx >= 0:
data = data[idx + 1:]
return data.decode("utf-8", errors="replace").splitlines()
except (OSError, PermissionError):
return []
def _extract_claude_ticker_items(path: Path, cutoff: datetime) -> list[ActivityTickerItem]:
items: list[ActivityTickerItem] = []
seen_msg_ids: set[str] = set()
for line in _tail_lines(path):
if not line.strip():
continue
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
if rec.get("type") != "assistant" or rec.get("isSidechain"):
continue
ts = _parse_ts(rec.get("timestamp"))
if ts is None or ts < cutoff:
continue
msg = rec.get("message") or {}
msg_id = msg.get("id")
if msg_id:
if msg_id in seen_msg_ids:
continue
seen_msg_ids.add(msg_id)
content = msg.get("content") if isinstance(msg.get("content"), list) else []
tool_blocks = [b for b in content if isinstance(b, dict) and b.get("type") == "tool_use"]
text_blocks = [b for b in content if isinstance(b, dict) and b.get("type") == "text"]
if tool_blocks:
# Emit one ticker item per tool call (most informative for live activity)
for block in tool_blocks:
tool_name = str(block.get("name") or "")
inp = block.get("input") or {}
if not isinstance(inp, dict):
inp = {}
label = _format_tool_status(tool_name, inp)
items.append(ActivityTickerItem(
id=uuid4(),
source="Claude Code",
message=label,
created_at=ts,
))
elif text_blocks:
# Text-only turn (no tools used) — show the response text
text = " ".join(
b.get("text", "").strip() for b in text_blocks if b.get("text", "").strip()
).strip()
if text:
items.append(ActivityTickerItem(
id=uuid4(),
source="Claude Code",
message=text[:200],
created_at=ts,
))
return items
def _extract_codex_ticker_items(path: Path, cutoff: datetime) -> list[ActivityTickerItem]:
items: list[ActivityTickerItem] = []
seen_turn_ids: set[str] = set()
for line in _tail_lines(path):
if not line.strip():
continue
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
ts = _parse_ts(rec.get("timestamp"))
if ts is None or ts < cutoff:
continue
payload = rec.get("payload") or {}
rec_type = rec.get("type")
if rec_type == "event_msg" and payload.get("type") == "task_complete":
turn_id = payload.get("turn_id") or ""
if turn_id and turn_id in seen_turn_ids:
continue
if turn_id:
seen_turn_ids.add(turn_id)
text = (payload.get("last_agent_message") or "").strip()
if text:
items.append(ActivityTickerItem(
id=uuid4(),
source="Codex",
message=text[:200],
created_at=ts,
))
elif rec_type == "response_item" and payload.get("role") == "assistant":
content = payload.get("content") or []
text = " ".join(
b.get("text", "").strip()
for b in content
if isinstance(b, dict) and b.get("type") == "output_text" and b.get("text", "").strip()
).strip()
if text:
items.append(ActivityTickerItem(
id=uuid4(),
source="Codex",
message=text[:200],
created_at=ts,
))
return items
def _fetch_local_session_items_sync(cutoff: datetime) -> list[ActivityTickerItem]:
items: list[ActivityTickerItem] = []
claude_root_env = os.environ.get("CLAUDE_PROJECTS_PATH", "").strip()
claude_root = Path(claude_root_env) if claude_root_env else Path.home() / ".claude" / "projects"
if claude_root.exists():
for path in claude_root.rglob("*.jsonl"):
try:
if datetime.utcfromtimestamp(path.stat().st_mtime) >= cutoff:
items.extend(_extract_claude_ticker_items(path, cutoff))
except (OSError, PermissionError):
pass
codex_root_env = os.environ.get("CODEX_SESSIONS_PATH", "").strip()
codex_root = Path(codex_root_env) if codex_root_env else Path.home() / ".codex" / "sessions"
if codex_root.exists():
for path in codex_root.rglob("*.jsonl"):
try:
if datetime.utcfromtimestamp(path.stat().st_mtime) >= cutoff:
items.extend(_extract_codex_ticker_items(path, cutoff))
except (OSError, PermissionError):
pass
return items
async def _fetch_local_session_ticker_items(cutoff: datetime) -> list[ActivityTickerItem]:
try:
return await asyncio.to_thread(_fetch_local_session_items_sync, cutoff)
except Exception: # noqa: BLE001
return []
@router.get("/ticker", response_model=list[ActivityTickerItem]) @router.get("/ticker", response_model=list[ActivityTickerItem])
async def get_activity_ticker( async def get_activity_ticker(
limit: int = Query(default=20, ge=1, le=50), limit: int = Query(default=20, ge=1, le=50),
@ -322,53 +523,62 @@ async def get_activity_ticker(
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False) board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
cutoff = utcnow() - timedelta(minutes=15) cutoff = utcnow() - timedelta(minutes=15)
statement = ( def _build_db_statement(since: datetime) -> Any:
select(ActivityEvent, Agent) stmt = (
.outerjoin(Agent, col(ActivityEvent.agent_id) == col(Agent.id)) select(ActivityEvent, Agent)
.outerjoin(Task, col(ActivityEvent.task_id) == col(Task.id)) .outerjoin(Agent, col(ActivityEvent.agent_id) == col(Agent.id))
.where(func.length(func.trim(col(ActivityEvent.message))) > 0) .outerjoin(Task, col(ActivityEvent.task_id) == col(Task.id))
.where(col(ActivityEvent.created_at) >= cutoff) .where(func.length(func.trim(col(ActivityEvent.message))) > 0)
.where(col(ActivityEvent.event_type) != "agent.heartbeat") .where(col(ActivityEvent.created_at) >= since)
.order_by(desc(col(ActivityEvent.created_at))) .order_by(desc(col(ActivityEvent.created_at)))
.limit(limit) .limit(limit)
)
if board_ids:
statement = statement.where(
or_(
col(ActivityEvent.board_id).in_(board_ids),
and_(
col(ActivityEvent.board_id).is_(None),
col(Task.board_id).in_(board_ids),
),
)
) )
else: if board_ids:
statement = statement.where(col(ActivityEvent.id).is_(None)) return stmt.where(
or_(
col(ActivityEvent.board_id).in_(board_ids),
and_(
col(ActivityEvent.board_id).is_(None),
col(Task.board_id).in_(board_ids),
),
)
)
return stmt.where(col(ActivityEvent.id).is_(None))
rows = (await session.exec(statement)).all() def _rows_to_items(rows: Any) -> list[ActivityTickerItem]:
db_items: list[ActivityTickerItem] = [] result: list[ActivityTickerItem] = []
for row in rows: for row in rows:
event: ActivityEvent = row[0] event: ActivityEvent = row[0]
agent: Agent | None = row[1] agent: Agent | None = row[1]
msg = (event.message or "").strip() msg = (event.message or "").strip()
if not msg: if not msg:
continue continue
db_items.append( result.append(ActivityTickerItem(
ActivityTickerItem(
id=event.id, id=event.id,
source=_ticker_source(event, agent), source=_ticker_source(event, agent),
message=msg[:200], message=msg[:200],
created_at=event.created_at, created_at=event.created_at,
) ))
) return result
rows = (await session.exec(_build_db_statement(cutoff))).all()
db_items = _rows_to_items(rows)
# If nothing recent, widen to 2 hours so the ticker is never blank
if not db_items:
fallback_cutoff = utcnow() - timedelta(hours=2)
rows = (await session.exec(_build_db_statement(fallback_cutoff))).all()
db_items = _rows_to_items(rows)
gw_rows = list((await session.exec( gw_rows = list((await session.exec(
select(Gateway).where(col(Gateway.organization_id) == ctx.organization.id) select(Gateway).where(col(Gateway.organization_id) == ctx.organization.id)
)).all()) )).all())
runtime_items = await _fetch_runtime_ticker_items(gw_rows, cutoff) runtime_items, local_items = await asyncio.gather(
_fetch_runtime_ticker_items(gw_rows, cutoff),
_fetch_local_session_ticker_items(cutoff),
)
all_items = db_items + runtime_items all_items = db_items + runtime_items + local_items
all_items.sort(key=lambda x: x.created_at, reverse=True) all_items.sort(key=lambda x: x.created_at, reverse=True)
return all_items[:limit] return all_items[:limit]

View File

@ -54,6 +54,8 @@ services:
CODEX_CREDENTIALS_PATH: /run/secrets/codex_credentials CODEX_CREDENTIALS_PATH: /run/secrets/codex_credentials
# Claude Code session JSONL files — read-only mount so the session viewer works. # Claude Code session JSONL files — read-only mount so the session viewer works.
CLAUDE_PROJECTS_PATH: /run/claude/projects CLAUDE_PROJECTS_PATH: /run/claude/projects
# Codex CLI session JSONL files — read-only mount for the activity ticker.
CODEX_SESSIONS_PATH: /run/codex/sessions
# AI provider API keys — seeded into provider_credentials on boot (optional). # AI provider API keys — seeded into provider_credentials on boot (optional).
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-} ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-}
@ -63,6 +65,7 @@ services:
- ${CLAUDE_CREDENTIALS_FILE:-/home/kaspa/.claude/.credentials.json}:/run/secrets/claude_credentials:ro - ${CLAUDE_CREDENTIALS_FILE:-/home/kaspa/.claude/.credentials.json}:/run/secrets/claude_credentials:ro
- ${CODEX_CREDENTIALS_FILE:-/home/kaspa/.codex/auth.json}:/run/secrets/codex_credentials:ro - ${CODEX_CREDENTIALS_FILE:-/home/kaspa/.codex/auth.json}:/run/secrets/codex_credentials:ro
- ${CLAUDE_PROJECTS_DIR:-/home/kaspa/.claude/projects}:/run/claude/projects:ro - ${CLAUDE_PROJECTS_DIR:-/home/kaspa/.claude/projects}:/run/claude/projects:ro
- ${CODEX_SESSIONS_DIR:-/home/kaspa/.codex/sessions}:/run/codex/sessions:ro
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy