lionagi 0.14.8__py3-none-any.whl → 0.14.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. lionagi/_errors.py +120 -11
  2. lionagi/_types.py +0 -6
  3. lionagi/config.py +3 -1
  4. lionagi/fields/reason.py +1 -1
  5. lionagi/libs/concurrency/throttle.py +79 -0
  6. lionagi/libs/parse.py +2 -1
  7. lionagi/libs/unstructured/__init__.py +0 -0
  8. lionagi/libs/unstructured/pdf_to_image.py +45 -0
  9. lionagi/libs/unstructured/read_image_to_base64.py +33 -0
  10. lionagi/libs/validate/to_num.py +378 -0
  11. lionagi/libs/validate/xml_parser.py +203 -0
  12. lionagi/models/operable_model.py +8 -3
  13. lionagi/operations/flow.py +0 -1
  14. lionagi/protocols/generic/event.py +2 -0
  15. lionagi/protocols/generic/log.py +26 -10
  16. lionagi/protocols/operatives/step.py +1 -1
  17. lionagi/protocols/types.py +9 -1
  18. lionagi/service/__init__.py +22 -1
  19. lionagi/service/connections/api_calling.py +57 -2
  20. lionagi/service/connections/endpoint_config.py +1 -1
  21. lionagi/service/connections/header_factory.py +4 -2
  22. lionagi/service/connections/match_endpoint.py +10 -10
  23. lionagi/service/connections/providers/anthropic_.py +5 -2
  24. lionagi/service/connections/providers/claude_code_.py +13 -17
  25. lionagi/service/connections/providers/claude_code_cli.py +51 -16
  26. lionagi/service/connections/providers/exa_.py +5 -3
  27. lionagi/service/connections/providers/oai_.py +116 -81
  28. lionagi/service/connections/providers/ollama_.py +38 -18
  29. lionagi/service/connections/providers/perplexity_.py +36 -14
  30. lionagi/service/connections/providers/types.py +30 -0
  31. lionagi/service/hooks/__init__.py +25 -0
  32. lionagi/service/hooks/_types.py +52 -0
  33. lionagi/service/hooks/_utils.py +85 -0
  34. lionagi/service/hooks/hook_event.py +67 -0
  35. lionagi/service/hooks/hook_registry.py +221 -0
  36. lionagi/service/imodel.py +120 -34
  37. lionagi/service/third_party/claude_code.py +715 -0
  38. lionagi/service/third_party/openai_model_names.py +198 -0
  39. lionagi/service/third_party/pplx_models.py +16 -8
  40. lionagi/service/types.py +21 -0
  41. lionagi/session/branch.py +1 -4
  42. lionagi/tools/base.py +1 -3
  43. lionagi/tools/file/reader.py +1 -1
  44. lionagi/tools/memory/tools.py +2 -2
  45. lionagi/utils.py +12 -775
  46. lionagi/version.py +1 -1
  47. {lionagi-0.14.8.dist-info → lionagi-0.14.10.dist-info}/METADATA +6 -2
  48. {lionagi-0.14.8.dist-info → lionagi-0.14.10.dist-info}/RECORD +50 -40
  49. lionagi/service/connections/providers/_claude_code/__init__.py +0 -3
  50. lionagi/service/connections/providers/_claude_code/models.py +0 -244
  51. lionagi/service/connections/providers/_claude_code/stream_cli.py +0 -359
  52. lionagi/service/third_party/openai_models.py +0 -18241
  53. {lionagi-0.14.8.dist-info → lionagi-0.14.10.dist-info}/WHEEL +0 -0
  54. {lionagi-0.14.8.dist-info → lionagi-0.14.10.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,715 @@
1
+ # Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import codecs
9
+ import contextlib
10
+ import json
11
+ import logging
12
+ import shutil
13
+ from collections.abc import AsyncIterator, Callable
14
+ from dataclasses import dataclass
15
+ from dataclasses import field as datafield
16
+ from datetime import datetime, timezone
17
+ from functools import partial
18
+ from pathlib import Path
19
+ from textwrap import shorten
20
+ from typing import Any, Literal
21
+
22
+ from json_repair import repair_json
23
+ from pydantic import BaseModel, Field, field_validator, model_validator
24
+
25
+ from lionagi.libs.schema.as_readable import as_readable
26
+ from lionagi.utils import is_coro_func, is_import_installed
27
+
28
+ HAS_CLAUDE_CODE_SDK = is_import_installed("claude_code_sdk")
29
+ HAS_CLAUDE_CODE_CLI = False
30
+ CLAUDE_CLI = None
31
+
32
+ if (c := (shutil.which("claude") or "claude")) and shutil.which(c):
33
+ HAS_CLAUDE_CODE_CLI = True
34
+ CLAUDE_CLI = c
35
+
36
+ logging.basicConfig(level=logging.INFO)
37
+ log = logging.getLogger("claude-cli")
38
+
39
+ # --------------------------------------------------------------------------- constants
40
+ ClaudePermission = Literal[
41
+ "default",
42
+ "acceptEdits",
43
+ "bypassPermissions",
44
+ "dangerously-skip-permissions",
45
+ ]
46
+
47
+ CLAUDE_CODE_OPTION_PARAMS = {
48
+ "allowed_tools",
49
+ "max_thinking_tokens",
50
+ "mcp_tools",
51
+ "mcp_servers",
52
+ "permission_mode",
53
+ "continue_conversation",
54
+ "resume",
55
+ "max_turns",
56
+ "disallowed_tools",
57
+ "model",
58
+ "permission_prompt_tool_name",
59
+ "cwd",
60
+ "system_prompt",
61
+ "append_system_prompt",
62
+ }
63
+
64
+
65
+ __all__ = (
66
+ "ClaudeCodeRequest",
67
+ "ClaudeChunk",
68
+ "ClaudeSession",
69
+ "stream_claude_code_cli",
70
+ "stream_cc_sdk_events",
71
+ )
72
+
73
+
74
+ # --------------------------------------------------------------------------- request model
75
+ class ClaudeCodeRequest(BaseModel):
76
+ # -- conversational bits -------------------------------------------------
77
+ prompt: str = Field(description="The prompt for Claude Code")
78
+ system_prompt: str | None = None
79
+ append_system_prompt: str | None = None
80
+ max_turns: int | None = None
81
+ continue_conversation: bool = False
82
+ resume: str | None = None
83
+
84
+ # -- repo / workspace ----------------------------------------------------
85
+ repo: Path = Field(default_factory=Path.cwd, exclude=True)
86
+ ws: str | None = None # sub-directory under repo
87
+ add_dir: str | None = None # extra read-only mount
88
+ allowed_tools: list[str] | None = None
89
+
90
+ # -- runtime & safety ----------------------------------------------------
91
+ model: Literal["sonnet", "opus"] | str | None = "sonnet"
92
+ max_thinking_tokens: int | None = None
93
+ mcp_tools: list[str] = Field(default_factory=list)
94
+ mcp_servers: dict[str, Any] = Field(default_factory=dict)
95
+ mcp_config: str | Path | None = Field(None, exclude=True)
96
+ permission_mode: ClaudePermission | None = None
97
+ permission_prompt_tool_name: str | None = None
98
+ disallowed_tools: list[str] = Field(default_factory=list)
99
+
100
+ # -- internal use --------------------------------------------------------
101
+ auto_finish: bool = Field(
102
+ default=False,
103
+ description="Automatically finish the conversation after the first response",
104
+ )
105
+ verbose_output: bool = Field(default=False)
106
+ cli_display_theme: Literal["light", "dark"] = "dark"
107
+ cli_include_summary: bool = Field(default=False)
108
+
109
+ # ------------------------ validators & helpers --------------------------
110
+ @field_validator("permission_mode", mode="before")
111
+ def _norm_perm(cls, v):
112
+ if v in {
113
+ "dangerously-skip-permissions",
114
+ "--dangerously-skip-permissions",
115
+ }:
116
+ return "bypassPermissions"
117
+ return v
118
+
119
+ # Workspace path derived from repo + ws
120
+ def cwd(self) -> Path:
121
+ if not self.ws:
122
+ return self.repo
123
+
124
+ # Convert to Path object for proper validation
125
+ ws_path = Path(self.ws)
126
+
127
+ # Check for absolute paths or directory traversal attempts
128
+ if ws_path.is_absolute():
129
+ raise ValueError(
130
+ f"Workspace path must be relative, got absolute: {self.ws}"
131
+ )
132
+
133
+ if ".." in ws_path.parts:
134
+ raise ValueError(
135
+ f"Directory traversal detected in workspace path: {self.ws}"
136
+ )
137
+
138
+ # Resolve paths to handle symlinks and normalize
139
+ repo_resolved = self.repo.resolve()
140
+ result = (self.repo / ws_path).resolve()
141
+
142
+ # Ensure the resolved path is within the repository bounds
143
+ try:
144
+ result.relative_to(repo_resolved)
145
+ except ValueError:
146
+ raise ValueError(
147
+ f"Workspace path escapes repository bounds. "
148
+ f"Repository: {repo_resolved}, Workspace: {result}"
149
+ )
150
+
151
+ return result
152
+
153
+ @model_validator(mode="after")
154
+ def _check_perm_workspace(self):
155
+ if self.permission_mode == "bypassPermissions":
156
+ # Use secure path validation with resolved paths
157
+ repo_resolved = self.repo.resolve()
158
+ cwd_resolved = self.cwd().resolve()
159
+
160
+ # Check if cwd is within repo bounds using proper path methods
161
+ try:
162
+ cwd_resolved.relative_to(repo_resolved)
163
+ except ValueError:
164
+ raise ValueError(
165
+ f"With bypassPermissions, workspace must be within repository bounds. "
166
+ f"Repository: {repo_resolved}, Workspace: {cwd_resolved}"
167
+ )
168
+ return self
169
+
170
+ # ------------------------ CLI helpers -----------------------------------
171
+ def as_cmd_args(self) -> list[str]:
172
+ """Build argument list for the *Node* `claude` CLI."""
173
+ args: list[str] = ["-p", self.prompt, "--output-format", "stream-json"]
174
+ if self.allowed_tools:
175
+ args.append("--allowedTools")
176
+ for tool in self.allowed_tools:
177
+ args.append(f'"{tool}"')
178
+
179
+ if self.disallowed_tools:
180
+ args.append("--disallowedTools")
181
+ for tool in self.disallowed_tools:
182
+ args.append(f'"{tool}"')
183
+
184
+ if self.resume:
185
+ args += ["--resume", self.resume]
186
+ elif self.continue_conversation:
187
+ args.append("--continue")
188
+
189
+ if self.max_turns:
190
+ # +1 because CLI counts *pairs*
191
+ args += ["--max-turns", str(self.max_turns + 1)]
192
+
193
+ if self.permission_mode == "bypassPermissions":
194
+ args += ["--dangerously-skip-permissions"]
195
+
196
+ if self.add_dir:
197
+ args += ["--add-dir", self.add_dir]
198
+
199
+ if self.permission_prompt_tool_name:
200
+ args += [
201
+ "--permission-prompt-tool",
202
+ self.permission_prompt_tool_name,
203
+ ]
204
+
205
+ if self.mcp_config:
206
+ args += ["--mcp-config", f'"{self.mcp_config}"']
207
+
208
+ args += ["--model", self.model or "sonnet", "--verbose"]
209
+ return args
210
+
211
+ # ------------------------ SDK helpers -----------------------------------
212
+ def as_claude_options(self):
213
+ from claude_code_sdk import ClaudeCodeOptions
214
+
215
+ data = {
216
+ k: v
217
+ for k, v in self.model_dump(exclude_none=True).items()
218
+ if k in CLAUDE_CODE_OPTION_PARAMS
219
+ }
220
+ return ClaudeCodeOptions(**data)
221
+
222
+ # ------------------------ convenience constructor -----------------------
223
+ @classmethod
224
+ def create(
225
+ cls,
226
+ messages: list[dict[str, Any]],
227
+ resume: str | None = None,
228
+ continue_conversation: bool | None = None,
229
+ **kwargs,
230
+ ):
231
+ if not messages:
232
+ raise ValueError("messages may not be empty")
233
+
234
+ prompt = ""
235
+
236
+ # 1. if resume or continue_conversation, use the last message
237
+ if resume or continue_conversation:
238
+ continue_conversation = True
239
+ prompt = messages[-1]["content"]
240
+ if isinstance(prompt, (dict, list)):
241
+ prompt = json.dumps(prompt)
242
+
243
+ # 2. else, use entire messages except system message
244
+ else:
245
+ prompts = []
246
+ continue_conversation = False
247
+ for message in messages:
248
+ if message["role"] != "system":
249
+ content = message["content"]
250
+ prompts.append(
251
+ json.dumps(content)
252
+ if isinstance(content, (dict, list))
253
+ else content
254
+ )
255
+
256
+ prompt = "\n".join(prompts)
257
+
258
+ # 3. assemble the request data
259
+ data: dict[str, Any] = dict(
260
+ prompt=prompt,
261
+ resume=resume,
262
+ continue_conversation=bool(continue_conversation),
263
+ )
264
+
265
+ # 4. extract system prompt if available
266
+ if (messages[0]["role"] == "system") and (
267
+ resume or continue_conversation
268
+ ):
269
+ data["system_prompt"] = messages[0]["content"]
270
+ if kwargs.get("append_system_prompt"):
271
+ data["append_system_prompt"] = str(
272
+ kwargs.get("append_system_prompt")
273
+ )
274
+
275
+ data.update(kwargs)
276
+ return cls.model_validate(data, strict=False)
277
+
278
+
279
+ @dataclass
280
+ class ClaudeChunk:
281
+ """Low-level wrapper around every NDJSON object coming from the CLI."""
282
+
283
+ raw: dict[str, Any]
284
+ type: str
285
+ # convenience views
286
+ thinking: str | None = None
287
+ text: str | None = None
288
+ tool_use: dict[str, Any] | None = None
289
+ tool_result: dict[str, Any] | None = None
290
+
291
+
292
+ @dataclass
293
+ class ClaudeSession:
294
+ """Aggregated view of a whole CLI conversation."""
295
+
296
+ session_id: str | None = None
297
+ model: str | None = None
298
+
299
+ # chronological log
300
+ chunks: list[ClaudeChunk] = datafield(default_factory=list)
301
+
302
+ # materialised views
303
+ thinking_log: list[str] = datafield(default_factory=list)
304
+ messages: list[dict[str, Any]] = datafield(default_factory=list)
305
+ tool_uses: list[dict[str, Any]] = datafield(default_factory=list)
306
+ tool_results: list[dict[str, Any]] = datafield(default_factory=list)
307
+
308
+ # final summary
309
+ result: str = ""
310
+ usage: dict[str, Any] = datafield(default_factory=dict)
311
+ total_cost_usd: float | None = None
312
+ num_turns: int | None = None
313
+ duration_ms: int | None = None
314
+ duration_api_ms: int | None = None
315
+ is_error: bool = False
316
+ summary: dict | None = None
317
+
318
+ def populate_summary(self) -> None:
319
+ self.summary = _extract_summary(self)
320
+
321
+
322
+ def _extract_summary(session: ClaudeSession) -> dict[str, Any]:
323
+ tool_counts = {}
324
+ tool_details = []
325
+ file_operations = {"reads": [], "writes": [], "edits": []}
326
+ key_actions = []
327
+
328
+ # Process tool uses from the clean materialized view
329
+ for tool_use in session.tool_uses:
330
+ tool_name = tool_use.get("name", "unknown")
331
+ tool_input = tool_use.get("input", {})
332
+ tool_id = tool_use.get("id", "")
333
+
334
+ # Count tool usage
335
+ tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1
336
+
337
+ # Store detailed info
338
+ tool_details.append(
339
+ {"tool": tool_name, "id": tool_id, "input": tool_input}
340
+ )
341
+
342
+ # Categorize file operations and actions
343
+ if tool_name in ["Read", "read"]:
344
+ file_path = tool_input.get("file_path", "unknown")
345
+ file_operations["reads"].append(file_path)
346
+ key_actions.append(f"Read {file_path}")
347
+
348
+ elif tool_name in ["Write", "write"]:
349
+ file_path = tool_input.get("file_path", "unknown")
350
+ file_operations["writes"].append(file_path)
351
+ key_actions.append(f"Wrote {file_path}")
352
+
353
+ elif tool_name in ["Edit", "edit", "MultiEdit"]:
354
+ file_path = tool_input.get("file_path", "unknown")
355
+ file_operations["edits"].append(file_path)
356
+ key_actions.append(f"Edited {file_path}")
357
+
358
+ elif tool_name in ["Bash", "bash"]:
359
+ command = tool_input.get("command", "")
360
+ command_summary = (
361
+ command[:50] + "..." if len(command) > 50 else command
362
+ )
363
+ key_actions.append(f"Ran: {command_summary}")
364
+
365
+ elif tool_name in ["Glob", "glob"]:
366
+ pattern = tool_input.get("pattern", "")
367
+ key_actions.append(f"Searched files: {pattern}")
368
+
369
+ elif tool_name in ["Grep", "grep"]:
370
+ pattern = tool_input.get("pattern", "")
371
+ key_actions.append(f"Searched content: {pattern}")
372
+
373
+ elif tool_name in ["Task", "task"]:
374
+ description = tool_input.get("description", "")
375
+ key_actions.append(f"Spawned task: {description}")
376
+
377
+ elif tool_name.startswith("mcp__"):
378
+ # MCP tool usage - extract the operation type
379
+ operation = tool_name.replace("mcp__", "")
380
+ key_actions.append(f"MCP {operation}")
381
+
382
+ elif tool_name == "TodoWrite":
383
+ todos = tool_input.get("todos", [])
384
+ key_actions.append(f"Created {len(todos)} todos")
385
+
386
+ else:
387
+ key_actions.append(f"Used {tool_name}")
388
+
389
+ # Deduplicate key actions
390
+ key_actions = (
391
+ list(dict.fromkeys(key_actions))
392
+ if key_actions
393
+ else ["No specific actions detected"]
394
+ )
395
+
396
+ # Deduplicate file paths
397
+ for op_type in file_operations:
398
+ file_operations[op_type] = list(
399
+ dict.fromkeys(file_operations[op_type])
400
+ )
401
+
402
+ # Extract result summary (first 200 chars)
403
+ result_summary = (
404
+ (session.result[:200] + "...")
405
+ if len(session.result) > 200
406
+ else session.result
407
+ )
408
+
409
+ return {
410
+ "tool_counts": tool_counts,
411
+ "tool_details": tool_details,
412
+ "file_operations": file_operations,
413
+ "key_actions": key_actions,
414
+ "total_tool_calls": sum(tool_counts.values()),
415
+ "result_summary": result_summary,
416
+ "usage_stats": {
417
+ "total_cost_usd": session.total_cost_usd,
418
+ "num_turns": session.num_turns,
419
+ "duration_ms": session.duration_ms,
420
+ "duration_api_ms": session.duration_api_ms,
421
+ **session.usage,
422
+ },
423
+ }
424
+
425
+
426
+ async def _ndjson_from_cli(request: ClaudeCodeRequest):
427
+ """
428
+ Yields each JSON object emitted by the *claude-code* CLI.
429
+
430
+ • Robust against UTF-8 splits across chunks (incremental decoder).
431
+ • Robust against braces inside strings (uses json.JSONDecoder.raw_decode)
432
+ • Falls back to `json_repair.repair_json` when necessary.
433
+ """
434
+ workspace = request.cwd()
435
+ workspace.mkdir(parents=True, exist_ok=True)
436
+
437
+ proc = await asyncio.create_subprocess_exec(
438
+ CLAUDE_CLI,
439
+ *request.as_cmd_args(),
440
+ cwd=str(workspace),
441
+ stdout=asyncio.subprocess.PIPE,
442
+ stderr=asyncio.subprocess.PIPE,
443
+ )
444
+
445
+ decoder = codecs.getincrementaldecoder("utf-8")()
446
+ json_decoder = json.JSONDecoder()
447
+ buffer: str = "" # text buffer that may hold >1 JSON objects
448
+
449
+ try:
450
+ while True:
451
+ chunk = await proc.stdout.read(4096)
452
+ if not chunk:
453
+ break
454
+
455
+ # 1) decode *incrementally* so we never split multibyte chars
456
+ buffer += decoder.decode(chunk)
457
+
458
+ # 2) try to peel off as many complete JSON objs as possible
459
+ while buffer:
460
+ buffer = buffer.lstrip() # remove leading spaces/newlines
461
+ if not buffer:
462
+ break
463
+ try:
464
+ obj, idx = json_decoder.raw_decode(buffer)
465
+ yield obj
466
+ buffer = buffer[idx:] # keep remainder for next round
467
+ except json.JSONDecodeError:
468
+ # incomplete → need more bytes
469
+ break
470
+
471
+ # 3) flush any tail bytes in the incremental decoder
472
+ buffer += decoder.decode(b"", final=True)
473
+ buffer = buffer.strip()
474
+ if buffer:
475
+ try:
476
+ obj, idx = json_decoder.raw_decode(buffer)
477
+ yield obj
478
+ except json.JSONDecodeError:
479
+ try:
480
+ fixed = repair_json(buffer)
481
+ yield json.loads(fixed)
482
+ log.warning(
483
+ "Repaired malformed JSON fragment at stream end"
484
+ )
485
+ except Exception:
486
+ log.error(
487
+ "Skipped unrecoverable JSON tail: %.120s…", buffer
488
+ )
489
+
490
+ # 4) propagate non-zero exit code
491
+ if await proc.wait() != 0:
492
+ err = (await proc.stderr.read()).decode().strip()
493
+ raise RuntimeError(err or "CLI exited non-zero")
494
+
495
+ finally:
496
+ with contextlib.suppress(ProcessLookupError):
497
+ proc.terminate()
498
+ await proc.wait()
499
+
500
+
501
+ def _stream_claude_code(request: ClaudeCodeRequest):
502
+ from claude_code_sdk import query as sdk_query
503
+
504
+ return sdk_query(
505
+ prompt=request.prompt, options=request.as_claude_options()
506
+ )
507
+
508
+
509
+ def stream_cc_sdk_events(request: ClaudeCodeRequest):
510
+ if not HAS_CLAUDE_CODE_SDK:
511
+ raise RuntimeError(
512
+ "claude_code_sdk not installed (uv pip install lionagi[claude_code])"
513
+ )
514
+
515
+ return _stream_claude_code(request)
516
+
517
+
518
+ # --------------------------------------------------------------------------- SSE route
519
+ async def stream_cc_cli_events(request: ClaudeCodeRequest):
520
+ if not CLAUDE_CLI:
521
+ raise RuntimeError(
522
+ "Claude CLI binary not found (npm i -g @anthropic-ai/claude-code)"
523
+ )
524
+ async for obj in _ndjson_from_cli(request):
525
+ yield obj
526
+ yield {"type": "done"}
527
+
528
+
529
+ print_readable = partial(as_readable, md=True, display_str=True)
530
+
531
+
532
+ def _pp_system(sys_obj: dict[str, Any], theme) -> None:
533
+ txt = (
534
+ f"◼️ **Claude Code Session** \n"
535
+ f"- id: `{sys_obj.get('session_id', '?')}` \n"
536
+ f"- model: `{sys_obj.get('model', '?')}` \n"
537
+ f"- tools: {', '.join(sys_obj.get('tools', [])[:8])}"
538
+ + ("…" if len(sys_obj.get("tools", [])) > 8 else "")
539
+ )
540
+ print_readable(txt, border=False, theme=theme)
541
+
542
+
543
+ def _pp_thinking(thought: str, theme) -> None:
544
+ text = f"""
545
+ 🧠 Thinking:
546
+ {thought}
547
+ """
548
+ print_readable(text, border=True, theme=theme)
549
+
550
+
551
+ def _pp_assistant_text(text: str, theme) -> None:
552
+ txt = f"""
553
+ > 🗣️ Claude:
554
+ {text}
555
+ """
556
+ print_readable(txt, theme=theme)
557
+
558
+
559
+ def _pp_tool_use(tu: dict[str, Any], theme) -> None:
560
+ preview = shorten(str(tu["input"]).replace("\n", " "), 130)
561
+ body = f"- 🔧 Tool Use — {tu['name']}({tu['id']}) - input: {preview}"
562
+ print_readable(body, border=False, panel=False, theme=theme)
563
+
564
+
565
+ def _pp_tool_result(tr: dict[str, Any], theme) -> None:
566
+ body_preview = shorten(str(tr["content"]).replace("\n", " "), 130)
567
+ status = "ERR" if tr.get("is_error") else "OK"
568
+ body = f"- 📄 Tool Result({tr['tool_use_id']}) - {status}\n\n\tcontent: {body_preview}"
569
+ print_readable(body, border=False, panel=False, theme=theme)
570
+
571
+
572
+ def _pp_final(sess: ClaudeSession, theme) -> None:
573
+ usage = sess.usage or {}
574
+ txt = (
575
+ f"### ✅ Session complete - {datetime.now(timezone.utc).isoformat(timespec='seconds')} UTC\n"
576
+ f"**Result:**\n\n{sess.result or ''}\n\n"
577
+ f"- cost: **${sess.total_cost_usd:.4f}** \n"
578
+ f"- turns: **{sess.num_turns}** \n"
579
+ f"- duration: **{sess.duration_ms} ms** (API {sess.duration_api_ms} ms) \n"
580
+ f"- tokens in/out: {usage.get('input_tokens', 0)}/{usage.get('output_tokens', 0)}"
581
+ )
582
+ print_readable(txt, theme=theme)
583
+
584
+
585
+ # --------------------------------------------------------------------------- internal utils
586
+
587
+
588
+ async def _maybe_await(func, *args, **kw):
589
+ """Call func which may be sync or async."""
590
+ res = func(*args, **kw) if func else None
591
+ if is_coro_func(res):
592
+ await res
593
+
594
+
595
+ # --------------------------------------------------------------------------- main parser
596
+ async def stream_claude_code_cli( # noqa: C901 (complexity from branching is fine here)
597
+ request: ClaudeCodeRequest,
598
+ session: ClaudeSession = ClaudeSession(),
599
+ *,
600
+ on_system: Callable[[dict[str, Any]], None] | None = None,
601
+ on_thinking: Callable[[str], None] | None = None,
602
+ on_text: Callable[[str], None] | None = None,
603
+ on_tool_use: Callable[[dict[str, Any]], None] | None = None,
604
+ on_tool_result: Callable[[dict[str, Any]], None] | None = None,
605
+ on_final: Callable[[ClaudeSession], None] | None = None,
606
+ ) -> AsyncIterator[ClaudeChunk | dict | ClaudeSession]:
607
+ """
608
+ Consume the ND-JSON stream produced by ndjson_from_cli()
609
+ and return a fully-populated ClaudeSession.
610
+
611
+ If callbacks are omitted a default pretty-print is emitted.
612
+ """
613
+ stream = stream_cc_cli_events(request)
614
+ theme = request.cli_display_theme or "light"
615
+
616
+ async for obj in stream:
617
+ typ = obj.get("type", "unknown")
618
+ chunk = ClaudeChunk(raw=obj, type=typ)
619
+ session.chunks.append(chunk)
620
+
621
+ # ------------------------ SYSTEM -----------------------------------
622
+ if typ == "system":
623
+ data = obj
624
+ session.session_id = data.get("session_id", session.session_id)
625
+ session.model = data.get("model", session.model)
626
+ await _maybe_await(on_system, data)
627
+ if request.verbose_output:
628
+ _pp_system(data, theme)
629
+ yield data
630
+
631
+ # ------------------------ ASSISTANT --------------------------------
632
+ elif typ == "assistant":
633
+ msg = obj["message"]
634
+ session.messages.append(msg)
635
+
636
+ for blk in msg.get("content", []):
637
+ btype = blk.get("type")
638
+ if btype == "thinking":
639
+ thought = blk.get("thinking", "").strip()
640
+ chunk.thinking = thought
641
+ session.thinking_log.append(thought)
642
+ await _maybe_await(on_thinking, thought)
643
+ if request.verbose_output:
644
+ _pp_thinking(thought, theme)
645
+
646
+ elif btype == "text":
647
+ text = blk.get("text", "")
648
+ chunk.text = text
649
+ await _maybe_await(on_text, text)
650
+ if request.verbose_output:
651
+ _pp_assistant_text(text, theme)
652
+
653
+ elif btype == "tool_use":
654
+ tu = {
655
+ "id": blk["id"],
656
+ "name": blk["name"],
657
+ "input": blk["input"],
658
+ }
659
+ chunk.tool_use = tu
660
+ session.tool_uses.append(tu)
661
+ await _maybe_await(on_tool_use, tu)
662
+ if request.verbose_output:
663
+ _pp_tool_use(tu, theme)
664
+
665
+ elif btype == "tool_result":
666
+ tr = {
667
+ "tool_use_id": blk["tool_use_id"],
668
+ "content": blk["content"],
669
+ "is_error": blk.get("is_error", False),
670
+ }
671
+ chunk.tool_result = tr
672
+ session.tool_results.append(tr)
673
+ await _maybe_await(on_tool_result, tr)
674
+ if request.verbose_output:
675
+ _pp_tool_result(tr, theme)
676
+ yield chunk
677
+
678
+ # ------------------------ USER (tool_result containers) ------------
679
+ elif typ == "user":
680
+ msg = obj["message"]
681
+ session.messages.append(msg)
682
+ for blk in msg.get("content", []):
683
+ if blk.get("type") == "tool_result":
684
+ tr = {
685
+ "tool_use_id": blk["tool_use_id"],
686
+ "content": blk["content"],
687
+ "is_error": blk.get("is_error", False),
688
+ }
689
+ chunk.tool_result = tr
690
+ session.tool_results.append(tr)
691
+ await _maybe_await(on_tool_result, tr)
692
+ if request.verbose_output:
693
+ _pp_tool_result(tr, theme)
694
+ yield chunk
695
+
696
+ # ------------------------ RESULT -----------------------------------
697
+ elif typ == "result":
698
+ session.result = obj.get("result", "").strip()
699
+ session.usage = obj.get("usage", {})
700
+ session.total_cost_usd = obj.get("total_cost_usd")
701
+ session.num_turns = obj.get("num_turns")
702
+ session.duration_ms = obj.get("duration_ms")
703
+ session.duration_api_ms = obj.get("duration_api_ms")
704
+ session.is_error = obj.get("is_error", False)
705
+
706
+ # ------------------------ DONE -------------------------------------
707
+ elif typ == "done":
708
+ break
709
+
710
+ # final pretty print
711
+ await _maybe_await(on_final, session)
712
+ if request.verbose_output:
713
+ _pp_final(session, theme)
714
+
715
+ yield session