"""Normalise Claude Code stream-json lines into event records (raw lines are kept separately, verbatim).""" from __future__ import annotations import hashlib, json from .contracts import utcnow def _summ(s, n=240): s = (s or "").replace("\n", " ") return s if len(s) <= n else s[: n - 1] + "…" def normalise(line: str, run_id: str, seq: int, elapsed_s: float): raw = line.rstrip("\n") if not raw.strip(): return None base = {"event_id": f"{run_id}:{seq:05d}", "run_id": run_id, "seq": seq, "received_utc": utcnow(), "elapsed_s": round(elapsed_s, 3), "payload_sha256": hashlib.sha256(raw.encode()).hexdigest()} try: d = json.loads(raw) except Exception: # noqa: BLE001 return {**base, "event_type": "unparsed", "summary": _summ(raw), "session_id": None} t = d.get("type"); base["session_id"] = d.get("session_id"); base["event_type"] = t or "unknown"; base["subtype"] = d.get("subtype") if t == "system": base["summary"] = _summ(f"init model={d.get('model')} tools={len(d.get('tools', []))} cwd={d.get('cwd')} version={d.get('claude_code_version') or d.get('version')} permissionMode={d.get('permissionMode')}") base["status"] = "ok" elif t == "assistant": msg = d.get("message", {}); parts = [] for b in msg.get("content", []) or []: if b.get("type") == "text": parts.append("text:" + _summ(b.get("text"), 120)) elif b.get("type") == "tool_use": base["tool_name"] = b.get("name") inp = b.get("input", {}) key = inp.get("command") or inp.get("file_path") or inp.get("pattern") or json.dumps(inp)[:100] parts.append(f"tool_use:{b.get('name')}:{_summ(str(key), 1500)}") elif b.get("type") == "thinking": parts.append("thinking") base["summary"] = " | ".join(parts); base["usage"] = msg.get("usage"); base["status"] = msg.get("stop_reason") elif t == "user": msg = d.get("message", {}); parts = [] for b in msg.get("content", []) or []: if isinstance(b, dict) and b.get("type") == "tool_result": c = b.get("content"); txt = c if isinstance(c, str) else json.dumps(c)[:200] parts.append(("tool_result:error:" if b.get("is_error") else "tool_result:") + _summ(txt, 150)) if b.get("is_error"): base["error_class"] = "tool_error" base["summary"] = " | ".join(parts) or _summ(json.dumps(msg)[:200]); base["status"] = "error" if base.get("error_class") else "ok" elif t == "result": base["summary"] = _summ(f"{d.get('subtype')} cost={d.get('total_cost_usd')} turns={d.get('num_turns')} ms={d.get('duration_ms')} err={d.get('is_error')} :: {d.get('result', '')}") base["usage"] = {"total_cost_usd": d.get("total_cost_usd"), "usage": d.get("usage"), "modelUsage": d.get("modelUsage"), "num_turns": d.get("num_turns"), "duration_ms": d.get("duration_ms")} base["status"] = "error" if d.get("is_error") else "ok" if d.get("is_error") or (d.get("subtype") and d["subtype"] != "success"): base["error_class"] = d.get("subtype") else: base["summary"] = _summ(raw) return base def parse_result_line(line: str): try: d = json.loads(line) return d if d.get("type") == "result" else None except Exception: # noqa: BLE001 return None