1"""Normalise Claude Code stream-json lines into event records (raw lines are kept separately, verbatim)."""2from __future__ import annotations3import hashlib, json4from .contracts import utcnow56def _summ(s, n=240):7 s = (s or "").replace("\n", " ")8 return s if len(s) <= n else s[: n - 1] + "…"910def normalise(line: str, run_id: str, seq: int, elapsed_s: float):11 raw = line.rstrip("\n")12 if not raw.strip():13 return None14 base = {"event_id": f"{run_id}:{seq:05d}", "run_id": run_id, "seq": seq, "received_utc": utcnow(), "elapsed_s": round(elapsed_s, 3),15 "payload_sha256": hashlib.sha256(raw.encode()).hexdigest()}16 try:17 d = json.loads(raw)18 except Exception: # noqa: BLE00119 return {**base, "event_type": "unparsed", "summary": _summ(raw), "session_id": None}20 t = d.get("type"); base["session_id"] = d.get("session_id"); base["event_type"] = t or "unknown"; base["subtype"] = d.get("subtype")21 if t == "system":22 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')}")23 base["status"] = "ok"24 elif t == "assistant":25 msg = d.get("message", {}); parts = []26 for b in msg.get("content", []) or []:27 if b.get("type") == "text": parts.append("text:" + _summ(b.get("text"), 120))28 elif b.get("type") == "tool_use":29 base["tool_name"] = b.get("name")30 inp = b.get("input", {})31 key = inp.get("command") or inp.get("file_path") or inp.get("pattern") or json.dumps(inp)[:100]32 parts.append(f"tool_use:{b.get('name')}:{_summ(str(key), 1500)}")33 elif b.get("type") == "thinking": parts.append("thinking")34 base["summary"] = " | ".join(parts); base["usage"] = msg.get("usage"); base["status"] = msg.get("stop_reason")35 elif t == "user":36 msg = d.get("message", {}); parts = []37 for b in msg.get("content", []) or []:38 if isinstance(b, dict) and b.get("type") == "tool_result":39 c = b.get("content"); txt = c if isinstance(c, str) else json.dumps(c)[:200]40 parts.append(("tool_result:error:" if b.get("is_error") else "tool_result:") + _summ(txt, 150))41 if b.get("is_error"): base["error_class"] = "tool_error"42 base["summary"] = " | ".join(parts) or _summ(json.dumps(msg)[:200]); base["status"] = "error" if base.get("error_class") else "ok"43 elif t == "result":44 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', '')}")45 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")}46 base["status"] = "error" if d.get("is_error") else "ok"47 if d.get("is_error") or (d.get("subtype") and d["subtype"] != "success"):48 base["error_class"] = d.get("subtype")49 else:50 base["summary"] = _summ(raw)51 return base5253def parse_result_line(line: str):54 try:55 d = json.loads(line)56 return d if d.get("type") == "result" else None57 except Exception: # noqa: BLE00158 return None59