clawd-code-sdk 1.0.0__tar.gz → 1.0.2__tar.gz

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 (76) hide show
  1. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/PKG-INFO +1 -1
  2. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/pyproject.toml +1 -1
  3. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/__init__.py +4 -0
  4. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/__init__.py +17 -0
  5. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/base.py +1 -0
  6. clawd_code_sdk-1.0.2/src/clawd_code_sdk/models/content_blocks.py +146 -0
  7. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/messages.py +121 -11
  8. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/storage/helpers.py +0 -39
  9. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/storage/models.py +47 -94
  10. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/storage/replay.py +85 -196
  11. clawd_code_sdk-1.0.2/tests/e2e/test_storage_parsing.py +26 -0
  12. clawd_code_sdk-1.0.0/src/clawd_code_sdk/models/content_blocks.py +0 -97
  13. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/.gitignore +0 -0
  14. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/LICENSE +0 -0
  15. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/README.md +0 -0
  16. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/_bundled/.gitignore +0 -0
  17. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/_errors.py +0 -0
  18. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/_internal/__init__.py +0 -0
  19. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/_internal/message_parser.py +0 -0
  20. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/_internal/query.py +0 -0
  21. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/_internal/transport/__init__.py +0 -0
  22. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/_internal/transport/subprocess_cli.py +0 -0
  23. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/_version.py +0 -0
  24. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/anthropic_types.py +0 -0
  25. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/client.py +0 -0
  26. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/list_sessions.py +0 -0
  27. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/mcp_utils.py +0 -0
  28. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/agents.py +0 -0
  29. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/control.py +0 -0
  30. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/hooks.py +0 -0
  31. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/input_types.py +0 -0
  32. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/mcp.py +0 -0
  33. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/options.py +0 -0
  34. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/output_types.py +0 -0
  35. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/permissions.py +0 -0
  36. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/prompt_requests.py +0 -0
  37. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/prompts.py +0 -0
  38. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/server_info.py +0 -0
  39. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/settings.py +0 -0
  40. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/system_messages.py +0 -0
  41. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/models/thinking.py +0 -0
  42. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/py.typed +0 -0
  43. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/query.py +0 -0
  44. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/session.py +0 -0
  45. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/storage/ARCHITECTURE.md +0 -0
  46. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/storage/__init__.py +0 -0
  47. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/src/clawd_code_sdk/usage.py +0 -0
  48. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/__init__.py +0 -0
  49. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/conftest.py +0 -0
  50. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/e2e/__init__.py +0 -0
  51. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/e2e/test_agents_and_settings.py +0 -0
  52. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/e2e/test_dynamic_control.py +0 -0
  53. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/e2e/test_hook_events.py +0 -0
  54. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/e2e/test_hooks.py +0 -0
  55. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/e2e/test_include_partial_messages.py +0 -0
  56. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/e2e/test_mcp_tools.py +0 -0
  57. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/e2e/test_sdk_mcp_tools.py +0 -0
  58. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/e2e/test_slash_commands.py +0 -0
  59. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/e2e/test_stderr_callback.py +0 -0
  60. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/e2e/test_structured_output.py +0 -0
  61. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/e2e/test_subagent_invocation.py +0 -0
  62. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/e2e/test_tool_permissions.py +0 -0
  63. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/mcp_server.py +0 -0
  64. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/mock_claude_server.py +0 -0
  65. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/test_changelog.py +0 -0
  66. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/test_client.py +0 -0
  67. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/test_errors.py +0 -0
  68. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/test_image.png +0 -0
  69. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/test_integration.py +0 -0
  70. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/test_message_parser.py +0 -0
  71. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/test_sdk_mcp_integration.py +0 -0
  72. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/test_session.py +0 -0
  73. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/test_streaming_client.py +0 -0
  74. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/test_subprocess_buffering.py +0 -0
  75. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/test_tool_callbacks.py +0 -0
  76. {clawd_code_sdk-1.0.0 → clawd_code_sdk-1.0.2}/tests/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clawd-code-sdk
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: Python SDK for Claude Code
5
5
  Project-URL: Documentation, https://github.com/phil65/claude-agent-sdk-python
6
6
  Project-URL: Homepage, https://github.com/phil65/claude-agent-sdk-python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "clawd-code-sdk"
3
- version = "1.0.0"
3
+ version = "1.0.2"
4
4
  description = "Python SDK for Claude Code"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -37,6 +37,8 @@ from .models import (
37
37
  CommandHookHandler,
38
38
  OnUserQuestion,
39
39
  ContentBlock,
40
+ ImageBlock,
41
+ ImageSource,
40
42
  HookCallback,
41
43
  HookContext,
42
44
  HookHandler,
@@ -168,7 +170,9 @@ __all__ = [
168
170
  "HookMatcher",
169
171
  "HookMatcherConfig",
170
172
  "Hooks",
173
+ "ImageBlock",
171
174
  "ImageMediaType",
175
+ "ImageSource",
172
176
  "InitSystemMessage",
173
177
  "InvalidRequestError",
174
178
  "ListSessionsOptions",
@@ -206,6 +206,8 @@ from .output_types import (
206
206
  )
207
207
  from .content_blocks import (
208
208
  ContentBlock,
209
+ ImageBlock,
210
+ ImageSource,
209
211
  TextBlock,
210
212
  ThinkingBlock,
211
213
  ToolResultBlock,
@@ -318,6 +320,19 @@ def _message_discriminator(data: Any) -> str:
318
320
  return str(type(data).__name__)
319
321
 
320
322
 
323
+ MessageUnion = (
324
+ UserMessage
325
+ | AssistantMessage
326
+ | ResultMessage
327
+ | StreamEvent
328
+ | RateLimitMessage
329
+ | ToolProgressMessage
330
+ | ToolUseSummaryMessage
331
+ | AuthStatusMessage
332
+ | PromptSuggestionMessage
333
+ | SystemMessageUnion
334
+ )
335
+
321
336
  Message = Annotated[
322
337
  Annotated[UserMessage, Tag("user")]
323
338
  | Annotated[AssistantMessage, Tag("assistant")]
@@ -427,7 +442,9 @@ __all__ = [
427
442
  "HookSpecificOutput",
428
443
  "HookStartedSystemMessage",
429
444
  "Hooks",
445
+ "ImageBlock",
430
446
  "ImageMediaType",
447
+ "ImageSource",
431
448
  "InitSystemMessage",
432
449
  "InstructionsLoadedHookInput",
433
450
  "JSONRPCError",
@@ -79,4 +79,5 @@ class ClaudeCodeBaseModel(BaseModel):
79
79
  populate_by_name=True,
80
80
  alias_generator=to_camel,
81
81
  extra="forbid" if IS_DEV else "ignore",
82
+ defer_build=True,
82
83
  )
@@ -0,0 +1,146 @@
1
+ """Content block types shared by both wire-format messages and JSONL storage.
2
+
3
+ Claude Code uses the same content block schema on the wire (SDK ↔ CLI JSON
4
+ messages) and in persisted JSONL session transcripts. A single set of models
5
+ therefore serves both purposes, avoiding a redundant conversion layer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from typing import TYPE_CHECKING, Annotated, Any, Literal
12
+
13
+ from pydantic import BaseModel, ConfigDict, Discriminator, TypeAdapter
14
+
15
+ from clawd_code_sdk.models import ToolInput
16
+ from clawd_code_sdk.models.base import ClaudeCodeBaseModel, StopReason, ToolName
17
+
18
+
19
+ if TYPE_CHECKING:
20
+ from clawd_code_sdk.anthropic_types import ToolResultContentBlock
21
+
22
+
23
+ # =============================================================================
24
+ # Content block types
25
+ # =============================================================================
26
+
27
+
28
+ class _ContentBlockBase(BaseModel):
29
+ """Shared base for all content block types."""
30
+
31
+ # extra="allow": storage JSONL includes all union fields on every block
32
+ # with null for fields belonging to other block types.
33
+ model_config = ConfigDict(extra="allow", defer_build=True)
34
+
35
+
36
+ class TextBlock(_ContentBlockBase):
37
+ """Text content block."""
38
+
39
+ type: Literal["text"] = "text"
40
+ text: str
41
+
42
+
43
+ class ThinkingBlock(_ContentBlockBase):
44
+ """Thinking/reasoning content block."""
45
+
46
+ type: Literal["thinking"] = "thinking"
47
+ thinking: str
48
+ signature: str = ""
49
+
50
+
51
+ class ToolUseBlock(_ContentBlockBase):
52
+ """Tool use content block."""
53
+
54
+ type: Literal["tool_use"] = "tool_use"
55
+ id: str = ""
56
+ name: ToolName | str = ""
57
+ input: ToolInput | dict[str, Any] = {}
58
+ caller: dict[str, str] | None = None
59
+
60
+
61
+ class ToolResultBlock(_ContentBlockBase):
62
+ """Tool result content block."""
63
+
64
+ type: Literal["tool_result"] = "tool_result"
65
+ tool_use_id: str = ""
66
+ content: str | list[dict[str, Any]] | None = None # BetaContentBlock
67
+ is_error: bool | None = None
68
+
69
+ def get_parsed_content(self) -> list[ToolResultContentBlock] | str | None:
70
+ from clawd_code_sdk.anthropic_types import validate_tool_result_content
71
+
72
+ if self.content is None or isinstance(self.content, str):
73
+ return self.content
74
+ # Validate list content against Anthropic SDK types
75
+ return validate_tool_result_content(self.content)
76
+
77
+ def extract_text(self) -> str:
78
+ """Extract text content from this tool result."""
79
+ if self.content is None:
80
+ return ""
81
+ if isinstance(self.content, str):
82
+ return self.content
83
+ text_parts = [
84
+ tc.get("text", "")
85
+ for tc in self.content
86
+ if isinstance(tc, dict) and tc.get("type") == "text"
87
+ ]
88
+ return "\n".join(text_parts)
89
+
90
+
91
+ class ImageSource(_ContentBlockBase):
92
+ """Base64-encoded image source data."""
93
+
94
+ type: Literal["base64"]
95
+ media_type: str
96
+ data: str
97
+
98
+
99
+ class ImageBlock(_ContentBlockBase):
100
+ """Image content block (storage-only, not emitted on the wire)."""
101
+
102
+ type: Literal["image"] = "image"
103
+ source: ImageSource
104
+
105
+
106
+ # =============================================================================
107
+ # Unions and adapters
108
+ # =============================================================================
109
+
110
+ ContentBlock = Annotated[
111
+ TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock | ImageBlock,
112
+ Discriminator("type"),
113
+ ]
114
+
115
+ content_block_adapter = TypeAdapter[ContentBlock](ContentBlock)
116
+
117
+
118
+ # =============================================================================
119
+ # Message-level models
120
+ # =============================================================================
121
+
122
+
123
+ class MessageParam(ClaudeCodeBaseModel):
124
+ """Replacement for Anthropic MessageParam which serializes to our own content blocks."""
125
+
126
+ content: Sequence[ContentBlock] | str
127
+ role: Literal["user", "assistant"]
128
+ model_config = ConfigDict(extra="allow")
129
+
130
+
131
+ class AssistantMessageContent(ClaudeCodeBaseModel):
132
+ """Assistant message payload mirroring ``anthropic.types.beta.BetaMessage``.
133
+
134
+ Uses our own ``ContentBlock`` types instead of the Anthropic SDK's
135
+ ``BetaContentBlock`` variants. Extra fields from the wire format
136
+ (e.g. ``container``, ``context_management``) are preserved via ``extra="allow"``.
137
+ """
138
+
139
+ id: str
140
+ type: Literal["message"] = "message"
141
+ role: Literal["assistant"] = "assistant"
142
+ content: Sequence[ContentBlock]
143
+ model: str
144
+ stop_reason: StopReason | None = None
145
+ stop_sequence: str | None = None
146
+ model_config = ConfigDict(extra="allow")
@@ -4,9 +4,22 @@ from __future__ import annotations
4
4
 
5
5
  from collections.abc import Sequence
6
6
  import re
7
- from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict
8
-
9
- from anthropic.types import RawMessageStreamEvent
7
+ from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict, get_args
8
+
9
+ from anthropic.types.beta import (
10
+ BetaInputJSONDelta,
11
+ BetaMessageDeltaUsage,
12
+ BetaRawContentBlockDeltaEvent,
13
+ BetaRawContentBlockStartEvent,
14
+ BetaRawMessageDeltaEvent,
15
+ BetaRawMessageStreamEvent,
16
+ BetaTextBlock as ATextBlock,
17
+ BetaTextDelta,
18
+ BetaThinkingBlock as AThinkingBlock,
19
+ BetaThinkingDelta,
20
+ BetaToolUseBlock as AToolUseBlock,
21
+ )
22
+ from anthropic.types.beta.beta_raw_message_delta_event import Delta as BetaRawMessageDelta
10
23
  from pydantic import BaseModel, ConfigDict
11
24
 
12
25
  from clawd_code_sdk._errors import (
@@ -28,9 +41,7 @@ from clawd_code_sdk.models.output_types import ToolUseResult
28
41
 
29
42
 
30
43
  if TYPE_CHECKING:
31
- from clawd_code_sdk.models.content_blocks import (
32
- ContentBlock,
33
- )
44
+ from clawd_code_sdk.models.content_blocks import ContentBlock
34
45
 
35
46
 
36
47
  # Message types
@@ -66,6 +77,16 @@ OverAgeDisabledReason = Literal[
66
77
  "no_limits_configured",
67
78
  "unknown",
68
79
  ]
80
+ _AnthropicStopReason = Literal[
81
+ "end_turn", "max_tokens", "stop_sequence", "tool_use", "pause_turn", "refusal"
82
+ ]
83
+
84
+
85
+ def _coerce_stop_reason(value: str | None) -> _AnthropicStopReason | None:
86
+ """Coerce a stored stop_reason string to the Anthropic SDK literal type."""
87
+ if value is not None and value in get_args(_AnthropicStopReason):
88
+ return value # type: ignore[return-value]
89
+ return None
69
90
 
70
91
 
71
92
  class SDKSessionInfo(BaseModel):
@@ -313,25 +334,114 @@ class StreamEvent(BaseMessage):
313
334
  """Stream event for partial message updates during streaming."""
314
335
 
315
336
  type: Literal["stream_event"] = "stream_event"
316
- event: RawMessageStreamEvent
337
+ event: BetaRawMessageStreamEvent
317
338
  parent_tool_use_id: str | None = None
318
339
 
319
340
  @classmethod
320
341
  def block_stop(cls, *, index: int, session_id: str, uuid: str) -> StreamEvent:
321
342
  """Create a synthetic content_block_stop StreamEvent."""
322
- from anthropic.types import RawContentBlockStopEvent
343
+ from anthropic.types.beta import BetaRawContentBlockStopEvent
323
344
 
324
- stop_event = RawContentBlockStopEvent(type="content_block_stop", index=index)
345
+ stop_event = BetaRawContentBlockStopEvent(type="content_block_stop", index=index)
325
346
  return StreamEvent(event=stop_event, session_id=session_id, uuid=uuid)
326
347
 
327
348
  @classmethod
328
349
  def message_stop(cls, *, session_id: str, uuid: str) -> StreamEvent:
329
350
  """Create a synthetic message_stop StreamEvent."""
330
- from anthropic.types import RawMessageStopEvent
351
+ from anthropic.types.beta import BetaRawMessageStopEvent
331
352
 
332
- stop_event = RawMessageStopEvent(type="message_stop")
353
+ stop_event = BetaRawMessageStopEvent(type="message_stop")
333
354
  return StreamEvent(event=stop_event, session_id=session_id, uuid=uuid)
334
355
 
356
+ @classmethod
357
+ def block_start_text(cls, *, index: int, session_id: str, uuid: str) -> StreamEvent:
358
+ """Create a synthetic content_block_start StreamEvent for a text block."""
359
+ content_block = ATextBlock(type="text", text="")
360
+ start_event = BetaRawContentBlockStartEvent(
361
+ type="content_block_start", index=index, content_block=content_block
362
+ )
363
+ return StreamEvent(event=start_event, session_id=session_id, uuid=uuid)
364
+
365
+ @classmethod
366
+ def block_start_thinking(cls, *, index: int, session_id: str, uuid: str) -> StreamEvent:
367
+ """Create a synthetic content_block_start StreamEvent for a thinking block."""
368
+ content_block = AThinkingBlock(type="thinking", thinking="", signature="")
369
+ start_event = BetaRawContentBlockStartEvent(
370
+ type="content_block_start", index=index, content_block=content_block
371
+ )
372
+ return StreamEvent(event=start_event, session_id=session_id, uuid=uuid)
373
+
374
+ @classmethod
375
+ def block_start_tool_use(
376
+ cls, *, tool_use_id: str, name: str, index: int, session_id: str, uuid: str
377
+ ) -> StreamEvent:
378
+ """Create a synthetic content_block_start StreamEvent for a tool_use block."""
379
+ content_block = AToolUseBlock(type="tool_use", id=tool_use_id, name=name, input={})
380
+ start_event = BetaRawContentBlockStartEvent(
381
+ type="content_block_start", index=index, content_block=content_block
382
+ )
383
+ return StreamEvent(event=start_event, session_id=session_id, uuid=uuid)
384
+
385
+ @classmethod
386
+ def block_text_delta(cls, *, text: str, index: int, session_id: str, uuid: str) -> StreamEvent:
387
+ """Create a synthetic content_block_delta StreamEvent with full block content."""
388
+ delta_event = BetaRawContentBlockDeltaEvent(
389
+ type="content_block_delta",
390
+ index=index,
391
+ delta=BetaTextDelta(type="text_delta", text=text),
392
+ )
393
+ return StreamEvent(event=delta_event, session_id=session_id, uuid=uuid)
394
+
395
+ @classmethod
396
+ def block_thinking_delta(
397
+ cls,
398
+ *,
399
+ thinking: str,
400
+ index: int,
401
+ session_id: str,
402
+ uuid: str,
403
+ ) -> StreamEvent:
404
+ """Create a synthetic content_block_delta StreamEvent with full block content."""
405
+ delta_event = BetaRawContentBlockDeltaEvent(
406
+ type="content_block_delta",
407
+ index=index,
408
+ delta=BetaThinkingDelta(type="thinking_delta", thinking=thinking),
409
+ )
410
+ return StreamEvent(event=delta_event, session_id=session_id, uuid=uuid)
411
+
412
+ @classmethod
413
+ def block_tool_json_delta(
414
+ cls,
415
+ *,
416
+ partial_json: str,
417
+ index: int,
418
+ session_id: str,
419
+ uuid: str,
420
+ ) -> StreamEvent:
421
+ """Create a synthetic content_block_delta StreamEvent with full block content."""
422
+ delta_event = BetaRawContentBlockDeltaEvent(
423
+ type="content_block_delta",
424
+ index=index,
425
+ delta=BetaInputJSONDelta(type="input_json_delta", partial_json=partial_json),
426
+ )
427
+ return StreamEvent(event=delta_event, session_id=session_id, uuid=uuid)
428
+
429
+ @classmethod
430
+ def message_delta(
431
+ cls,
432
+ *,
433
+ stop_reason: str | None,
434
+ session_id: str,
435
+ uuid: str,
436
+ ) -> StreamEvent:
437
+ """Create a synthetic message_delta StreamEvent."""
438
+ usage = BetaMessageDeltaUsage(output_tokens=0)
439
+ delta = BetaRawMessageDelta(
440
+ stop_reason=_coerce_stop_reason(stop_reason), stop_sequence=None
441
+ )
442
+ delta_event = BetaRawMessageDeltaEvent(type="message_delta", delta=delta, usage=usage)
443
+ return StreamEvent(event=delta_event, session_id=session_id, uuid=uuid)
444
+
335
445
 
336
446
  class ToolProgressMessage(BaseMessage):
337
447
  """Progress update for a running tool."""
@@ -284,42 +284,3 @@ def extract_title(session_path: Path, max_chars: int = 60) -> str | None:
284
284
  # walk(root)
285
285
 
286
286
  # return result
287
-
288
-
289
- if __name__ == "__main__":
290
- import sys
291
-
292
- projects_dir = get_claude_projects_dir()
293
- if not projects_dir.exists():
294
- print(f"No projects directory found at {projects_dir}")
295
- sys.exit(1)
296
-
297
- total_entries = 0
298
- total_sessions = 0
299
- errors = 0
300
-
301
- for project_dir in sorted(projects_dir.iterdir()):
302
- if not project_dir.is_dir():
303
- continue
304
- session_files = sorted(
305
- project_dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True
306
- )
307
- if not session_files:
308
- continue
309
- project_path = decode_project_path(project_dir.name)
310
- print(f"\nProject: {project_path} ({len(session_files)} sessions)")
311
- for session_file in session_files:
312
- total_sessions += 1
313
- try:
314
- entries = read_session(session_file)
315
- total_entries += len(entries)
316
- title = extract_title(session_file) or "(no title)"
317
- print(f" {session_file.stem}: {len(entries)} entries - {title}")
318
- except Exception as e:
319
- errors += 1
320
- print(f" {session_file.stem}: ERROR - {e}")
321
-
322
- print("\n--- Summary ---")
323
- print(f"Sessions: {total_sessions}")
324
- print(f"Total entries: {total_entries}")
325
- print(f"Errors: {errors}")
@@ -16,12 +16,24 @@ See ARCHITECTURE.md for detailed documentation of the storage format.
16
16
  from __future__ import annotations
17
17
 
18
18
  from collections.abc import Sequence
19
- from typing import Annotated, Any, Literal, assert_never
19
+ from typing import TYPE_CHECKING, Annotated, Any, Literal
20
20
 
21
21
  from pydantic import BaseModel, Discriminator, Field, Tag
22
22
 
23
23
  from clawd_code_sdk.models import ToolUseResult
24
24
  from clawd_code_sdk.models.base import ClaudeCodeBaseModel, StopReason
25
+ from clawd_code_sdk.models.content_blocks import (
26
+ ContentBlock,
27
+ ImageBlock,
28
+ ImageSource,
29
+ TextBlock,
30
+ ToolResultBlock,
31
+ )
32
+ from clawd_code_sdk.models.messages import Usage
33
+
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Iterable
25
37
 
26
38
 
27
39
  # See https://github.com/daaain/claude-code-log/blob/main/claude_code_log/models.py
@@ -30,97 +42,33 @@ UserType = Literal["external", "internal"]
30
42
  MCPToolCallStatus = Literal["started", "completed", "failed"]
31
43
 
32
44
 
33
- class ClaudeTextBlock(BaseModel):
34
- """Text content block."""
35
-
36
- type: Literal["text"]
37
- text: str
38
-
39
-
40
- class ClaudeToolUseBlock(BaseModel):
41
- """Tool use content block."""
42
-
43
- type: Literal["tool_use"]
44
- id: str
45
- name: str
46
- input: dict[str, Any]
47
-
48
-
49
- class ClaudeToolResultBlock(BaseModel):
50
- """Tool result content block."""
51
-
52
- type: Literal["tool_result"]
53
- tool_use_id: str
54
- content: list[dict[str, Any]] | str | None = None
55
- is_error: bool | None = None
56
-
57
- def extract_text(self) -> str:
58
- """Extract text content from this tool result."""
59
- match self.content:
60
- case None:
61
- return ""
62
- case str():
63
- return self.content
64
- case list():
65
- text_parts = [
66
- tc.get("text", "")
67
- for tc in self.content
68
- if isinstance(tc, dict) and tc.get("type") == "text"
69
- ]
70
- return "\n".join(text_parts)
71
- case _ as unreachable:
72
- assert_never(unreachable)
73
-
74
-
75
- class ClaudeThinkingBlock(BaseModel):
76
- """Thinking/reasoning content block."""
77
-
78
- type: Literal["thinking"]
79
- thinking: str
80
- signature: str | None = None
45
+ class ClaudeUsage(Usage):
46
+ """Token usage from Claude API response, with additional storage fields."""
81
47
 
82
-
83
- class ClaudeImageSource(BaseModel):
84
- """Base64-encoded image source data."""
85
-
86
- type: Literal["base64"]
87
- media_type: str
88
- data: str
89
-
90
-
91
- class ClaudeImageBlock(BaseModel):
92
- """Image content block."""
93
-
94
- type: Literal["image"]
95
- source: ClaudeImageSource
96
-
97
-
98
- ClaudeContentBlock = Annotated[
99
- ClaudeTextBlock
100
- | ClaudeToolUseBlock
101
- | ClaudeToolResultBlock
102
- | ClaudeThinkingBlock
103
- | ClaudeImageBlock,
104
- Field(discriminator="type"),
105
- ]
106
- """Discriminated union of all content block types in message content arrays."""
107
-
108
-
109
- class ClaudeUsage(BaseModel):
110
- """Token usage from Claude API response."""
111
-
112
- input_tokens: int = 0
113
- output_tokens: int = 0
114
- cache_creation_input_tokens: int = 0
115
- cache_read_input_tokens: int = 0
116
48
  service_tier: str | None = None
117
49
  server_tool_use: dict[str, Any] | None = None
118
50
 
119
- def add(self, other: ClaudeUsage) -> None:
120
- self.input_tokens += other.input_tokens
121
- self.output_tokens += other.output_tokens
122
- self.cache_creation_input_tokens += other.cache_creation_input_tokens
123
- self.cache_read_input_tokens += other.cache_read_input_tokens
51
+ @classmethod
52
+ def from_entries(cls, entries: Iterable[ClaudeJSONLEntry]) -> ClaudeUsage:
53
+ """Extract deduplicated aggregate token usage from stored entries.
54
+
55
+ Storage duplicates usage data across all content-block entries that
56
+ share the same API ``message.id``. This deduplicates by ``message.id``
57
+ and sums across all unique API calls.
58
+ """
59
+ seen_ids: set[str] = set()
60
+ total = cls()
61
+ for entry in entries:
62
+ if not isinstance(entry, ClaudeAssistantEntry):
63
+ continue
64
+ msg = entry.message
65
+ if not isinstance(msg, ClaudeApiMessage):
66
+ continue
67
+ if msg.id in seen_ids:
68
+ continue
69
+ seen_ids.add(msg.id)
70
+ total.accumulate(msg.usage)
71
+ return total
124
72
 
125
73
 
126
74
  # =============================================================================
@@ -135,7 +83,7 @@ class ClaudeApiMessage(BaseModel):
135
83
  id: str
136
84
  type: Literal["message"] = "message"
137
85
  role: Literal["assistant"]
138
- content: str | Sequence[ClaudeContentBlock]
86
+ content: str | Sequence[ContentBlock]
139
87
  stop_reason: StopReason | None = None
140
88
  stop_sequence: str | None = None
141
89
  usage: ClaudeUsage = Field(default_factory=ClaudeUsage)
@@ -145,7 +93,7 @@ class ClaudeUserMessage(BaseModel):
145
93
  """User message content."""
146
94
 
147
95
  role: Literal["user"]
148
- content: str | Sequence[ClaudeContentBlock]
96
+ content: str | Sequence[ContentBlock]
149
97
  usage: ClaudeUsage | None = None
150
98
  """Usage info (for type compatibility with ClaudeApiMessage)."""
151
99
 
@@ -197,6 +145,13 @@ class ClaudeUserEntry(ClaudeMessageEntryBase):
197
145
  list[ToolUseResult | dict[str, Any]] | ToolUseResult | dict[str, Any] | str | None
198
146
  ) = None
199
147
 
148
+ @property
149
+ def is_tool_result(self) -> bool:
150
+ """Whether this is a synthetic tool_result entry (vs. an actual user prompt)."""
151
+ if isinstance(self.message.content, str):
152
+ return False
153
+ return all(b.type == "tool_result" for b in self.message.content)
154
+
200
155
 
201
156
  class ClaudeAssistantEntry(ClaudeMessageEntryBase):
202
157
  """Assistant message entry."""
@@ -217,12 +172,10 @@ class ClaudeDocumentContent(ClaudeCodeBaseModel):
217
172
  """Document content block."""
218
173
 
219
174
  type: Literal["document"]
220
- source: ClaudeImageSource
175
+ source: ImageSource
221
176
 
222
177
 
223
- ClaudeQueueContent = (
224
- str | ClaudeTextBlock | ClaudeImageBlock | ClaudeDocumentContent | ClaudeToolResultBlock
225
- )
178
+ type ClaudeQueueContent = str | TextBlock | ImageBlock | ClaudeDocumentContent | ToolResultBlock
226
179
 
227
180
 
228
181
  class ClaudeEnqueueOperation(ClaudeCodeBaseModel):