agentpool 2.1.9__py3-none-any.whl → 2.5.0__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 (311) hide show
  1. acp/__init__.py +13 -4
  2. acp/acp_requests.py +20 -77
  3. acp/agent/connection.py +8 -0
  4. acp/agent/implementations/debug_server/debug_server.py +6 -2
  5. acp/agent/protocol.py +6 -0
  6. acp/bridge/README.md +15 -2
  7. acp/bridge/__init__.py +3 -2
  8. acp/bridge/__main__.py +60 -19
  9. acp/bridge/ws_server.py +173 -0
  10. acp/bridge/ws_server_cli.py +89 -0
  11. acp/client/connection.py +38 -29
  12. acp/client/implementations/default_client.py +3 -2
  13. acp/client/implementations/headless_client.py +2 -2
  14. acp/connection.py +2 -2
  15. acp/notifications.py +20 -50
  16. acp/schema/__init__.py +2 -0
  17. acp/schema/agent_responses.py +21 -0
  18. acp/schema/client_requests.py +3 -3
  19. acp/schema/session_state.py +63 -29
  20. acp/stdio.py +39 -9
  21. acp/task/supervisor.py +2 -2
  22. acp/transports.py +362 -2
  23. acp/utils.py +17 -4
  24. agentpool/__init__.py +6 -1
  25. agentpool/agents/__init__.py +2 -0
  26. agentpool/agents/acp_agent/acp_agent.py +407 -277
  27. agentpool/agents/acp_agent/acp_converters.py +196 -38
  28. agentpool/agents/acp_agent/client_handler.py +191 -26
  29. agentpool/agents/acp_agent/session_state.py +17 -6
  30. agentpool/agents/agent.py +607 -572
  31. agentpool/agents/agui_agent/__init__.py +0 -2
  32. agentpool/agents/agui_agent/agui_agent.py +176 -110
  33. agentpool/agents/agui_agent/agui_converters.py +0 -131
  34. agentpool/agents/agui_agent/helpers.py +3 -4
  35. agentpool/agents/base_agent.py +632 -17
  36. agentpool/agents/claude_code_agent/FORKING.md +191 -0
  37. agentpool/agents/claude_code_agent/__init__.py +13 -1
  38. agentpool/agents/claude_code_agent/claude_code_agent.py +1058 -291
  39. agentpool/agents/claude_code_agent/converters.py +74 -143
  40. agentpool/agents/claude_code_agent/history.py +474 -0
  41. agentpool/agents/claude_code_agent/models.py +77 -0
  42. agentpool/agents/claude_code_agent/static_info.py +100 -0
  43. agentpool/agents/claude_code_agent/usage.py +242 -0
  44. agentpool/agents/context.py +40 -0
  45. agentpool/agents/events/__init__.py +24 -0
  46. agentpool/agents/events/builtin_handlers.py +67 -1
  47. agentpool/agents/events/event_emitter.py +32 -2
  48. agentpool/agents/events/events.py +104 -3
  49. agentpool/agents/events/infer_info.py +145 -0
  50. agentpool/agents/events/processors.py +254 -0
  51. agentpool/agents/interactions.py +41 -6
  52. agentpool/agents/modes.py +67 -0
  53. agentpool/agents/slashed_agent.py +5 -4
  54. agentpool/agents/tool_call_accumulator.py +213 -0
  55. agentpool/agents/tool_wrapping.py +18 -6
  56. agentpool/common_types.py +56 -21
  57. agentpool/config_resources/__init__.py +38 -1
  58. agentpool/config_resources/acp_assistant.yml +2 -2
  59. agentpool/config_resources/agents.yml +3 -0
  60. agentpool/config_resources/agents_template.yml +1 -0
  61. agentpool/config_resources/claude_code_agent.yml +10 -6
  62. agentpool/config_resources/external_acp_agents.yml +2 -1
  63. agentpool/delegation/base_team.py +4 -30
  64. agentpool/delegation/pool.py +136 -289
  65. agentpool/delegation/team.py +58 -57
  66. agentpool/delegation/teamrun.py +51 -55
  67. agentpool/diagnostics/__init__.py +53 -0
  68. agentpool/diagnostics/lsp_manager.py +1593 -0
  69. agentpool/diagnostics/lsp_proxy.py +41 -0
  70. agentpool/diagnostics/lsp_proxy_script.py +229 -0
  71. agentpool/diagnostics/models.py +398 -0
  72. agentpool/functional/run.py +10 -4
  73. agentpool/mcp_server/__init__.py +0 -2
  74. agentpool/mcp_server/client.py +76 -32
  75. agentpool/mcp_server/conversions.py +54 -13
  76. agentpool/mcp_server/manager.py +34 -54
  77. agentpool/mcp_server/registries/official_registry_client.py +35 -1
  78. agentpool/mcp_server/tool_bridge.py +186 -139
  79. agentpool/messaging/__init__.py +0 -2
  80. agentpool/messaging/compaction.py +72 -197
  81. agentpool/messaging/connection_manager.py +11 -10
  82. agentpool/messaging/event_manager.py +5 -5
  83. agentpool/messaging/message_container.py +6 -30
  84. agentpool/messaging/message_history.py +99 -8
  85. agentpool/messaging/messagenode.py +52 -14
  86. agentpool/messaging/messages.py +54 -35
  87. agentpool/messaging/processing.py +12 -22
  88. agentpool/models/__init__.py +1 -1
  89. agentpool/models/acp_agents/base.py +6 -24
  90. agentpool/models/acp_agents/mcp_capable.py +126 -157
  91. agentpool/models/acp_agents/non_mcp.py +129 -95
  92. agentpool/models/agents.py +98 -76
  93. agentpool/models/agui_agents.py +1 -1
  94. agentpool/models/claude_code_agents.py +144 -19
  95. agentpool/models/file_parsing.py +0 -1
  96. agentpool/models/manifest.py +113 -50
  97. agentpool/prompts/conversion_manager.py +1 -1
  98. agentpool/prompts/prompts.py +5 -2
  99. agentpool/repomap.py +1 -1
  100. agentpool/resource_providers/__init__.py +11 -1
  101. agentpool/resource_providers/aggregating.py +56 -5
  102. agentpool/resource_providers/base.py +70 -4
  103. agentpool/resource_providers/codemode/code_executor.py +72 -5
  104. agentpool/resource_providers/codemode/helpers.py +2 -2
  105. agentpool/resource_providers/codemode/provider.py +64 -12
  106. agentpool/resource_providers/codemode/remote_mcp_execution.py +2 -2
  107. agentpool/resource_providers/codemode/remote_provider.py +9 -12
  108. agentpool/resource_providers/filtering.py +3 -1
  109. agentpool/resource_providers/mcp_provider.py +89 -12
  110. agentpool/resource_providers/plan_provider.py +228 -46
  111. agentpool/resource_providers/pool.py +7 -3
  112. agentpool/resource_providers/resource_info.py +111 -0
  113. agentpool/resource_providers/static.py +4 -2
  114. agentpool/sessions/__init__.py +4 -1
  115. agentpool/sessions/manager.py +33 -5
  116. agentpool/sessions/models.py +59 -6
  117. agentpool/sessions/protocol.py +28 -0
  118. agentpool/sessions/session.py +11 -55
  119. agentpool/skills/registry.py +13 -8
  120. agentpool/storage/manager.py +572 -49
  121. agentpool/talk/registry.py +4 -4
  122. agentpool/talk/talk.py +9 -10
  123. agentpool/testing.py +538 -20
  124. agentpool/tool_impls/__init__.py +6 -0
  125. agentpool/tool_impls/agent_cli/__init__.py +42 -0
  126. agentpool/tool_impls/agent_cli/tool.py +95 -0
  127. agentpool/tool_impls/bash/__init__.py +64 -0
  128. agentpool/tool_impls/bash/helpers.py +35 -0
  129. agentpool/tool_impls/bash/tool.py +171 -0
  130. agentpool/tool_impls/delete_path/__init__.py +70 -0
  131. agentpool/tool_impls/delete_path/tool.py +142 -0
  132. agentpool/tool_impls/download_file/__init__.py +80 -0
  133. agentpool/tool_impls/download_file/tool.py +183 -0
  134. agentpool/tool_impls/execute_code/__init__.py +55 -0
  135. agentpool/tool_impls/execute_code/tool.py +163 -0
  136. agentpool/tool_impls/grep/__init__.py +80 -0
  137. agentpool/tool_impls/grep/tool.py +200 -0
  138. agentpool/tool_impls/list_directory/__init__.py +73 -0
  139. agentpool/tool_impls/list_directory/tool.py +197 -0
  140. agentpool/tool_impls/question/__init__.py +42 -0
  141. agentpool/tool_impls/question/tool.py +127 -0
  142. agentpool/tool_impls/read/__init__.py +104 -0
  143. agentpool/tool_impls/read/tool.py +305 -0
  144. agentpool/tools/__init__.py +2 -1
  145. agentpool/tools/base.py +114 -34
  146. agentpool/tools/manager.py +57 -1
  147. agentpool/ui/base.py +2 -2
  148. agentpool/ui/mock_provider.py +2 -2
  149. agentpool/ui/stdlib_provider.py +2 -2
  150. agentpool/utils/file_watcher.py +269 -0
  151. agentpool/utils/identifiers.py +121 -0
  152. agentpool/utils/pydantic_ai_helpers.py +46 -0
  153. agentpool/utils/streams.py +616 -2
  154. agentpool/utils/subprocess_utils.py +155 -0
  155. agentpool/utils/token_breakdown.py +461 -0
  156. agentpool/vfs_registry.py +7 -2
  157. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/METADATA +41 -27
  158. agentpool-2.5.0.dist-info/RECORD +579 -0
  159. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/WHEEL +1 -1
  160. agentpool_cli/__main__.py +24 -0
  161. agentpool_cli/create.py +1 -1
  162. agentpool_cli/serve_acp.py +100 -21
  163. agentpool_cli/serve_agui.py +87 -0
  164. agentpool_cli/serve_opencode.py +119 -0
  165. agentpool_cli/ui.py +557 -0
  166. agentpool_commands/__init__.py +42 -5
  167. agentpool_commands/agents.py +75 -2
  168. agentpool_commands/history.py +62 -0
  169. agentpool_commands/mcp.py +176 -0
  170. agentpool_commands/models.py +56 -3
  171. agentpool_commands/pool.py +260 -0
  172. agentpool_commands/session.py +1 -1
  173. agentpool_commands/text_sharing/__init__.py +119 -0
  174. agentpool_commands/text_sharing/base.py +123 -0
  175. agentpool_commands/text_sharing/github_gist.py +80 -0
  176. agentpool_commands/text_sharing/opencode.py +462 -0
  177. agentpool_commands/text_sharing/paste_rs.py +59 -0
  178. agentpool_commands/text_sharing/pastebin.py +116 -0
  179. agentpool_commands/text_sharing/shittycodingagent.py +112 -0
  180. agentpool_commands/tools.py +57 -0
  181. agentpool_commands/utils.py +80 -30
  182. agentpool_config/__init__.py +30 -2
  183. agentpool_config/agentpool_tools.py +498 -0
  184. agentpool_config/builtin_tools.py +77 -22
  185. agentpool_config/commands.py +24 -1
  186. agentpool_config/compaction.py +258 -0
  187. agentpool_config/converters.py +1 -1
  188. agentpool_config/event_handlers.py +42 -0
  189. agentpool_config/events.py +1 -1
  190. agentpool_config/forward_targets.py +1 -4
  191. agentpool_config/jinja.py +3 -3
  192. agentpool_config/mcp_server.py +132 -6
  193. agentpool_config/nodes.py +1 -1
  194. agentpool_config/observability.py +44 -0
  195. agentpool_config/session.py +0 -3
  196. agentpool_config/storage.py +82 -38
  197. agentpool_config/task.py +3 -3
  198. agentpool_config/tools.py +11 -22
  199. agentpool_config/toolsets.py +109 -233
  200. agentpool_server/a2a_server/agent_worker.py +307 -0
  201. agentpool_server/a2a_server/server.py +23 -18
  202. agentpool_server/acp_server/acp_agent.py +234 -181
  203. agentpool_server/acp_server/commands/acp_commands.py +151 -156
  204. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +18 -17
  205. agentpool_server/acp_server/event_converter.py +651 -0
  206. agentpool_server/acp_server/input_provider.py +53 -10
  207. agentpool_server/acp_server/server.py +24 -90
  208. agentpool_server/acp_server/session.py +173 -331
  209. agentpool_server/acp_server/session_manager.py +8 -34
  210. agentpool_server/agui_server/server.py +3 -1
  211. agentpool_server/mcp_server/server.py +5 -2
  212. agentpool_server/opencode_server/.rules +95 -0
  213. agentpool_server/opencode_server/ENDPOINTS.md +401 -0
  214. agentpool_server/opencode_server/OPENCODE_UI_TOOLS_COMPLETE.md +202 -0
  215. agentpool_server/opencode_server/__init__.py +19 -0
  216. agentpool_server/opencode_server/command_validation.py +172 -0
  217. agentpool_server/opencode_server/converters.py +975 -0
  218. agentpool_server/opencode_server/dependencies.py +24 -0
  219. agentpool_server/opencode_server/input_provider.py +421 -0
  220. agentpool_server/opencode_server/models/__init__.py +250 -0
  221. agentpool_server/opencode_server/models/agent.py +53 -0
  222. agentpool_server/opencode_server/models/app.py +72 -0
  223. agentpool_server/opencode_server/models/base.py +26 -0
  224. agentpool_server/opencode_server/models/common.py +23 -0
  225. agentpool_server/opencode_server/models/config.py +37 -0
  226. agentpool_server/opencode_server/models/events.py +821 -0
  227. agentpool_server/opencode_server/models/file.py +88 -0
  228. agentpool_server/opencode_server/models/mcp.py +44 -0
  229. agentpool_server/opencode_server/models/message.py +179 -0
  230. agentpool_server/opencode_server/models/parts.py +323 -0
  231. agentpool_server/opencode_server/models/provider.py +81 -0
  232. agentpool_server/opencode_server/models/pty.py +43 -0
  233. agentpool_server/opencode_server/models/question.py +56 -0
  234. agentpool_server/opencode_server/models/session.py +111 -0
  235. agentpool_server/opencode_server/routes/__init__.py +29 -0
  236. agentpool_server/opencode_server/routes/agent_routes.py +473 -0
  237. agentpool_server/opencode_server/routes/app_routes.py +202 -0
  238. agentpool_server/opencode_server/routes/config_routes.py +302 -0
  239. agentpool_server/opencode_server/routes/file_routes.py +571 -0
  240. agentpool_server/opencode_server/routes/global_routes.py +94 -0
  241. agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
  242. agentpool_server/opencode_server/routes/message_routes.py +761 -0
  243. agentpool_server/opencode_server/routes/permission_routes.py +63 -0
  244. agentpool_server/opencode_server/routes/pty_routes.py +300 -0
  245. agentpool_server/opencode_server/routes/question_routes.py +128 -0
  246. agentpool_server/opencode_server/routes/session_routes.py +1276 -0
  247. agentpool_server/opencode_server/routes/tui_routes.py +139 -0
  248. agentpool_server/opencode_server/server.py +475 -0
  249. agentpool_server/opencode_server/state.py +151 -0
  250. agentpool_server/opencode_server/time_utils.py +8 -0
  251. agentpool_storage/__init__.py +12 -0
  252. agentpool_storage/base.py +184 -2
  253. agentpool_storage/claude_provider/ARCHITECTURE.md +433 -0
  254. agentpool_storage/claude_provider/__init__.py +42 -0
  255. agentpool_storage/claude_provider/provider.py +1089 -0
  256. agentpool_storage/file_provider.py +278 -15
  257. agentpool_storage/memory_provider.py +193 -12
  258. agentpool_storage/models.py +3 -0
  259. agentpool_storage/opencode_provider/ARCHITECTURE.md +386 -0
  260. agentpool_storage/opencode_provider/__init__.py +16 -0
  261. agentpool_storage/opencode_provider/helpers.py +414 -0
  262. agentpool_storage/opencode_provider/provider.py +895 -0
  263. agentpool_storage/project_store.py +325 -0
  264. agentpool_storage/session_store.py +26 -6
  265. agentpool_storage/sql_provider/__init__.py +4 -2
  266. agentpool_storage/sql_provider/models.py +48 -0
  267. agentpool_storage/sql_provider/sql_provider.py +269 -3
  268. agentpool_storage/sql_provider/utils.py +12 -13
  269. agentpool_storage/zed_provider/__init__.py +16 -0
  270. agentpool_storage/zed_provider/helpers.py +281 -0
  271. agentpool_storage/zed_provider/models.py +130 -0
  272. agentpool_storage/zed_provider/provider.py +442 -0
  273. agentpool_storage/zed_provider.py +803 -0
  274. agentpool_toolsets/__init__.py +0 -2
  275. agentpool_toolsets/builtin/__init__.py +2 -12
  276. agentpool_toolsets/builtin/code.py +96 -57
  277. agentpool_toolsets/builtin/debug.py +118 -48
  278. agentpool_toolsets/builtin/execution_environment.py +115 -230
  279. agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
  280. agentpool_toolsets/builtin/skills.py +9 -4
  281. agentpool_toolsets/builtin/subagent_tools.py +64 -51
  282. agentpool_toolsets/builtin/workers.py +4 -2
  283. agentpool_toolsets/composio_toolset.py +2 -2
  284. agentpool_toolsets/entry_points.py +3 -1
  285. agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
  286. agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
  287. agentpool_toolsets/fsspec_toolset/grep.py +99 -7
  288. agentpool_toolsets/fsspec_toolset/helpers.py +3 -2
  289. agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
  290. agentpool_toolsets/fsspec_toolset/toolset.py +500 -95
  291. agentpool_toolsets/mcp_discovery/__init__.py +5 -0
  292. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  293. agentpool_toolsets/mcp_discovery/toolset.py +511 -0
  294. agentpool_toolsets/mcp_run_toolset.py +87 -12
  295. agentpool_toolsets/notifications.py +33 -33
  296. agentpool_toolsets/openapi.py +3 -1
  297. agentpool_toolsets/search_toolset.py +3 -1
  298. agentpool-2.1.9.dist-info/RECORD +0 -474
  299. agentpool_config/resources.py +0 -33
  300. agentpool_server/acp_server/acp_tools.py +0 -43
  301. agentpool_server/acp_server/commands/spawn.py +0 -210
  302. agentpool_storage/text_log_provider.py +0 -275
  303. agentpool_toolsets/builtin/agent_management.py +0 -239
  304. agentpool_toolsets/builtin/chain.py +0 -288
  305. agentpool_toolsets/builtin/history.py +0 -36
  306. agentpool_toolsets/builtin/integration.py +0 -85
  307. agentpool_toolsets/builtin/tool_management.py +0 -90
  308. agentpool_toolsets/builtin/user_interaction.py +0 -52
  309. agentpool_toolsets/semantic_memory_toolset.py +0 -536
  310. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/entry_points.txt +0 -0
  311. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1089 @@
1
+ """Claude Code storage provider.
2
+
3
+ This module implements storage compatible with Claude Code's filesystem format,
4
+ enabling interoperability between agentpool and Claude Code.
5
+
6
+ Key features:
7
+ - JSONL-based conversation logs per project
8
+ - Multi-agent support (main + sub-agents)
9
+ - Message ancestry tracking
10
+ - Conversation forking and branching
11
+
12
+ See ARCHITECTURE.md for detailed documentation of the storage format.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from datetime import datetime
18
+ from decimal import Decimal
19
+ import json
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
22
+
23
+ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
24
+ from pydantic.alias_generators import to_camel
25
+ from pydantic_ai import RunUsage
26
+ from pydantic_ai.messages import (
27
+ ModelRequest,
28
+ ModelResponse,
29
+ TextPart,
30
+ ThinkingPart,
31
+ ToolCallPart,
32
+ ToolReturnPart,
33
+ UserPromptPart,
34
+ )
35
+ from pydantic_ai.usage import RequestUsage
36
+
37
+ from agentpool.common_types import MessageRole
38
+ from agentpool.log import get_logger
39
+ from agentpool.messaging import ChatMessage, TokenCost
40
+ from agentpool.utils.now import get_now
41
+ from agentpool_storage.base import StorageProvider
42
+ from agentpool_storage.models import TokenUsage
43
+
44
+
45
+ if TYPE_CHECKING:
46
+ from collections.abc import Sequence
47
+
48
+ from pydantic_ai import FinishReason
49
+
50
+ from agentpool_config.session import SessionQuery
51
+ from agentpool_config.storage import ClaudeStorageConfig
52
+ from agentpool_storage.models import ConversationData, MessageData, QueryFilters, StatsFilters
53
+
54
+ logger = get_logger(__name__)
55
+
56
+
57
+ # Claude JSONL message types
58
+
59
+ StopReason = Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"] | None
60
+ ContentType = Literal["text", "tool_use", "tool_result", "thinking"]
61
+ MessageType = Literal[
62
+ "user", "assistant", "queue-operation", "system", "summary", "file-history-snapshot"
63
+ ]
64
+ UserType = Literal["external", "internal"]
65
+
66
+
67
+ class ClaudeBaseModel(BaseModel):
68
+ """Base class for Claude history models."""
69
+
70
+ model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
71
+
72
+
73
+ class ClaudeUsage(BaseModel):
74
+ """Token usage from Claude API response."""
75
+
76
+ input_tokens: int = 0
77
+ output_tokens: int = 0
78
+ cache_creation_input_tokens: int = 0
79
+ cache_read_input_tokens: int = 0
80
+
81
+
82
+ class ClaudeMessageContent(BaseModel):
83
+ """Content block in Claude message.
84
+
85
+ Supports: text, tool_use, tool_result, thinking blocks.
86
+ """
87
+
88
+ type: ContentType
89
+ # For text blocks
90
+ text: str | None = None
91
+ # For tool_use blocks
92
+ id: str | None = None
93
+ name: str | None = None
94
+ input: dict[str, Any] | None = None
95
+ # For tool_result blocks
96
+ tool_use_id: str | None = None
97
+ content: list[dict[str, Any]] | str | None = None # Can be array or string
98
+ is_error: bool | None = None
99
+ # For thinking blocks
100
+ thinking: str | None = None
101
+ signature: str | None = None
102
+
103
+
104
+ class ClaudeApiMessage(BaseModel):
105
+ """Claude API message structure."""
106
+
107
+ model: str
108
+ id: str
109
+ type: Literal["message"] = "message"
110
+ role: Literal["assistant"]
111
+ content: str | list[ClaudeMessageContent]
112
+ stop_reason: StopReason = None
113
+ usage: ClaudeUsage = Field(default_factory=ClaudeUsage)
114
+
115
+
116
+ class ClaudeUserMessage(BaseModel):
117
+ """User message content."""
118
+
119
+ role: Literal["user"]
120
+ content: str | list[ClaudeMessageContent]
121
+
122
+
123
+ class ClaudeMessageEntryBase(ClaudeBaseModel):
124
+ """Base for user/assistant message entries."""
125
+
126
+ uuid: str
127
+ parent_uuid: str | None = None
128
+ session_id: str = Field(alias="sessionId")
129
+ timestamp: str
130
+ message: ClaudeApiMessage | ClaudeUserMessage
131
+
132
+ # Context (NOT USED directly)
133
+ cwd: str = ""
134
+ git_branch: str = ""
135
+ version: str = ""
136
+
137
+ # Metadata (NOT USED)
138
+ user_type: UserType = "external"
139
+ is_sidechain: bool = False
140
+ request_id: str | None = None
141
+ agent_id: str | None = None
142
+ # toolUseResult can be list, dict, or string (error message)
143
+ tool_use_result: list[dict[str, Any]] | dict[str, Any] | str | None = None
144
+
145
+ model_config = ConfigDict(populate_by_name=True)
146
+
147
+
148
+ class ClaudeUserEntry(ClaudeMessageEntryBase):
149
+ """User message entry."""
150
+
151
+ type: Literal["user"]
152
+
153
+
154
+ class ClaudeAssistantEntry(ClaudeMessageEntryBase):
155
+ """Assistant message entry."""
156
+
157
+ type: Literal["assistant"]
158
+
159
+
160
+ class ClaudeQueueOperationEntry(ClaudeBaseModel):
161
+ """Queue operation entry (not a message)."""
162
+
163
+ type: Literal["queue-operation"]
164
+ session_id: str
165
+ timestamp: str
166
+ operation: str
167
+
168
+ model_config = ConfigDict(populate_by_name=True)
169
+
170
+
171
+ class ClaudeSystemEntry(ClaudeBaseModel):
172
+ """System message entry (context, prompts, etc.)."""
173
+
174
+ type: Literal["system"]
175
+ uuid: str
176
+ parent_uuid: str | None = None
177
+ session_id: str
178
+ timestamp: str
179
+ content: str
180
+ subtype: str | None = None
181
+ slug: str | None = None
182
+ level: int | str | None = None
183
+ is_meta: bool = False
184
+ logical_parent_uuid: str | None = None
185
+ compact_metadata: dict[str, Any] | None = None
186
+ # Common fields
187
+ cwd: str = ""
188
+ git_branch: str = ""
189
+ version: str = ""
190
+ user_type: UserType = "external"
191
+ is_sidechain: bool = False
192
+
193
+ model_config = ConfigDict(populate_by_name=True)
194
+
195
+
196
+ class ClaudeSummaryEntry(ClaudeBaseModel):
197
+ """Summary entry (conversation summary)."""
198
+
199
+ type: Literal["summary"]
200
+ leaf_uuid: str
201
+ summary: str
202
+
203
+ model_config = ConfigDict(populate_by_name=True)
204
+
205
+
206
+ class ClaudeFileHistoryEntry(ClaudeBaseModel):
207
+ """File history snapshot entry."""
208
+
209
+ type: Literal["file-history-snapshot"]
210
+ message_id: str
211
+ snapshot: dict[str, Any]
212
+ is_snapshot_update: bool = False
213
+
214
+ model_config = ConfigDict(populate_by_name=True)
215
+
216
+
217
+ # Discriminated union for all entry types
218
+ ClaudeJSONLEntry = Annotated[
219
+ ClaudeUserEntry
220
+ | ClaudeAssistantEntry
221
+ | ClaudeQueueOperationEntry
222
+ | ClaudeSystemEntry
223
+ | ClaudeSummaryEntry
224
+ | ClaudeFileHistoryEntry,
225
+ Field(discriminator="type"),
226
+ ]
227
+
228
+
229
+ class ClaudeStorageProvider(StorageProvider):
230
+ """Storage provider that reads/writes Claude Code's native format.
231
+
232
+ Claude stores conversations as JSONL files in:
233
+ - ~/.claude/projects/{path-encoded-project-name}/{session-id}.jsonl
234
+
235
+ Each line is a JSON object representing a message in the conversation.
236
+
237
+ ## Fields NOT currently used from Claude format:
238
+ - `isSidechain`: Whether message is on a side branch
239
+ - `userType`: Type of user ("external", etc.)
240
+ - `cwd`: Working directory at time of message
241
+ - `gitBranch`: Git branch at time of message
242
+ - `version`: Claude CLI version
243
+ - `requestId`: API request ID
244
+ - `agentId`: Agent identifier for subagents
245
+ - `toolUseResult`: Detailed tool result content (we extract text only)
246
+ - `parentUuid`: Parent message for threading (we use flat history)
247
+
248
+ ## Additional Claude data not handled:
249
+ - `~/.claude/todos/`: Todo lists per session
250
+ - `~/.claude/plans/`: Markdown plan files
251
+ - `~/.claude/skills/`: Custom skills
252
+ - `~/.claude/history.jsonl`: Command/prompt history
253
+ """
254
+
255
+ can_load_history = True
256
+
257
+ def __init__(self, config: ClaudeStorageConfig) -> None:
258
+ """Initialize Claude storage provider.
259
+
260
+ Args:
261
+ config: Configuration for the provider
262
+ """
263
+ super().__init__(config)
264
+ self.base_path = Path(config.path).expanduser()
265
+ self.projects_path = self.base_path / "projects"
266
+ self._ensure_dirs()
267
+
268
+ def _ensure_dirs(self) -> None:
269
+ """Ensure required directories exist."""
270
+ self.projects_path.mkdir(parents=True, exist_ok=True)
271
+
272
+ def _encode_project_path(self, path: str) -> str:
273
+ """Encode a project path to Claude's format.
274
+
275
+ Claude encodes paths by replacing / with - and prepending -.
276
+ Example: /home/user/project -> -home-user-project
277
+ """
278
+ return path.replace("/", "-")
279
+
280
+ def _decode_project_path(self, encoded: str) -> str:
281
+ """Decode a Claude project path back to filesystem path.
282
+
283
+ Example: -home-user-project -> /home/user/project
284
+ """
285
+ if encoded.startswith("-"):
286
+ encoded = encoded[1:]
287
+ return "/" + encoded.replace("-", "/")
288
+
289
+ def _get_project_dir(self, project_path: str) -> Path:
290
+ """Get the directory for a project's conversations."""
291
+ encoded = self._encode_project_path(project_path)
292
+ return self.projects_path / encoded
293
+
294
+ def _list_sessions(self, project_path: str | None = None) -> list[tuple[str, Path]]:
295
+ """List all sessions, optionally filtered by project.
296
+
297
+ Returns:
298
+ List of (session_id, file_path) tuples
299
+ """
300
+ sessions = []
301
+ if project_path:
302
+ project_dir = self._get_project_dir(project_path)
303
+ if project_dir.exists():
304
+ for f in project_dir.glob("*.jsonl"):
305
+ session_id = f.stem
306
+ sessions.append((session_id, f))
307
+ else:
308
+ for project_dir in self.projects_path.iterdir():
309
+ if project_dir.is_dir():
310
+ for f in project_dir.glob("*.jsonl"):
311
+ session_id = f.stem
312
+ sessions.append((session_id, f))
313
+ return sessions
314
+
315
+ def _read_session(self, session_path: Path) -> list[ClaudeJSONLEntry]:
316
+ """Read all entries from a session file."""
317
+ entries: list[ClaudeJSONLEntry] = []
318
+ if not session_path.exists():
319
+ return entries
320
+
321
+ adapter = TypeAdapter[Any](ClaudeJSONLEntry)
322
+ with session_path.open("r", encoding="utf-8") as f:
323
+ for raw_line in f:
324
+ stripped = raw_line.strip()
325
+ if not stripped:
326
+ continue
327
+ try:
328
+ data = json.loads(stripped)
329
+ entry = adapter.validate_python(data)
330
+ entries.append(entry)
331
+ except (json.JSONDecodeError, ValueError) as e:
332
+ logger.warning(
333
+ "Failed to parse JSONL line", path=str(session_path), error=str(e)
334
+ )
335
+ return entries
336
+
337
+ def _write_entry(self, session_path: Path, entry: ClaudeJSONLEntry) -> None:
338
+ """Append an entry to a session file."""
339
+ session_path.parent.mkdir(parents=True, exist_ok=True)
340
+ with session_path.open("a", encoding="utf-8") as f:
341
+ f.write(entry.model_dump_json(by_alias=True) + "\n")
342
+
343
+ def _build_tool_id_mapping(self, entries: list[ClaudeJSONLEntry]) -> dict[str, str]:
344
+ """Build a mapping from tool_call_id to tool_name from assistant entries."""
345
+ mapping: dict[str, str] = {}
346
+ for entry in entries:
347
+ if not isinstance(entry, ClaudeAssistantEntry):
348
+ continue
349
+ msg = entry.message
350
+ if not isinstance(msg.content, list):
351
+ continue
352
+ for block in msg.content:
353
+ if block.type == "tool_use" and block.id and block.name:
354
+ mapping[block.id] = block.name
355
+ return mapping
356
+
357
+ def _entry_to_chat_message(
358
+ self,
359
+ entry: ClaudeJSONLEntry,
360
+ conversation_id: str,
361
+ tool_id_mapping: dict[str, str] | None = None,
362
+ ) -> ChatMessage[str] | None:
363
+ """Convert a Claude JSONL entry to a ChatMessage.
364
+
365
+ Reconstructs pydantic-ai ModelRequest/ModelResponse objects and stores
366
+ them in the messages field for full fidelity.
367
+
368
+ Args:
369
+ entry: The JSONL entry to convert
370
+ conversation_id: ID for the conversation
371
+ tool_id_mapping: Optional mapping from tool_call_id to tool_name
372
+ for resolving tool names in ToolReturnPart
373
+
374
+ Returns None for non-message entries (queue-operation, summary, etc.).
375
+ """
376
+ # Only handle user/assistant entries with messages
377
+ if not isinstance(entry, (ClaudeUserEntry, ClaudeAssistantEntry)):
378
+ return None
379
+
380
+ message = entry.message
381
+
382
+ # Parse timestamp
383
+ try:
384
+ timestamp = datetime.fromisoformat(entry.timestamp.replace("Z", "+00:00"))
385
+ except (ValueError, AttributeError):
386
+ timestamp = get_now()
387
+
388
+ # Extract display content (text only for UI)
389
+ content = self._extract_text_content(message)
390
+
391
+ # Build pydantic-ai message
392
+ pydantic_message = self._build_pydantic_message(
393
+ entry, message, timestamp, tool_id_mapping or {}
394
+ )
395
+
396
+ # Extract token usage and cost
397
+ cost_info = None
398
+ model = None
399
+ finish_reason = None
400
+ if isinstance(entry, ClaudeAssistantEntry) and isinstance(message, ClaudeApiMessage):
401
+ usage = message.usage
402
+ input_tokens = (
403
+ usage.input_tokens
404
+ + usage.cache_read_input_tokens
405
+ + usage.cache_creation_input_tokens
406
+ )
407
+ output_tokens = usage.output_tokens
408
+
409
+ if input_tokens or output_tokens:
410
+ cost_info = TokenCost(
411
+ token_usage=RunUsage(
412
+ input_tokens=input_tokens,
413
+ output_tokens=output_tokens,
414
+ ),
415
+ total_cost=Decimal(0), # Claude doesn't store cost directly
416
+ )
417
+ model = message.model
418
+ finish_reason = message.stop_reason
419
+
420
+ return ChatMessage[str](
421
+ content=content,
422
+ conversation_id=conversation_id,
423
+ role=entry.type,
424
+ message_id=entry.uuid,
425
+ name="claude" if isinstance(entry, ClaudeAssistantEntry) else None,
426
+ model_name=model,
427
+ cost_info=cost_info,
428
+ timestamp=timestamp,
429
+ parent_id=entry.parent_uuid,
430
+ messages=[pydantic_message] if pydantic_message else [],
431
+ provider_details={"finish_reason": finish_reason} if finish_reason else {},
432
+ )
433
+
434
+ def _extract_text_content(self, message: ClaudeApiMessage | ClaudeUserMessage) -> str:
435
+ """Extract text content from a Claude message for display.
436
+
437
+ Only extracts text and thinking blocks, not tool calls/results.
438
+ """
439
+ msg_content = message.content
440
+ if isinstance(msg_content, str):
441
+ return msg_content
442
+
443
+ text_parts: list[str] = []
444
+ for part in msg_content:
445
+ if part.type == "text" and part.text:
446
+ text_parts.append(part.text)
447
+ elif part.type == "thinking" and part.thinking:
448
+ # Include thinking in display content
449
+ text_parts.append(f"<thinking>\n{part.thinking}\n</thinking>")
450
+ return "\n".join(text_parts)
451
+
452
+ def _build_pydantic_message(
453
+ self,
454
+ entry: ClaudeUserEntry | ClaudeAssistantEntry,
455
+ message: ClaudeApiMessage | ClaudeUserMessage,
456
+ timestamp: datetime,
457
+ tool_id_mapping: dict[str, str],
458
+ ) -> ModelRequest | ModelResponse | None:
459
+ """Build a pydantic-ai ModelRequest or ModelResponse from Claude data.
460
+
461
+ Args:
462
+ entry: The entry being converted
463
+ message: The message content
464
+ timestamp: Parsed timestamp
465
+ tool_id_mapping: Mapping from tool_call_id to tool_name
466
+ """
467
+ msg_content = message.content
468
+
469
+ if isinstance(entry, ClaudeUserEntry):
470
+ # Build ModelRequest with user prompt parts
471
+ parts: list[UserPromptPart | ToolReturnPart] = []
472
+
473
+ if isinstance(msg_content, str):
474
+ parts.append(UserPromptPart(content=msg_content, timestamp=timestamp))
475
+ else:
476
+ for block in msg_content:
477
+ if block.type == "text" and block.text:
478
+ parts.append(UserPromptPart(content=block.text, timestamp=timestamp))
479
+ elif block.type == "tool_result" and block.tool_use_id:
480
+ # Reconstruct tool return - look up tool name from mapping
481
+ tool_content = self._extract_tool_result_content(block)
482
+ tool_name = tool_id_mapping.get(block.tool_use_id, "")
483
+ parts.append(
484
+ ToolReturnPart(
485
+ tool_name=tool_name,
486
+ content=tool_content,
487
+ tool_call_id=block.tool_use_id,
488
+ timestamp=timestamp,
489
+ )
490
+ )
491
+
492
+ return ModelRequest(parts=parts, timestamp=timestamp) if parts else None
493
+
494
+ # Build ModelResponse for assistant
495
+ if not isinstance(message, ClaudeApiMessage):
496
+ return None
497
+
498
+ response_parts: list[TextPart | ToolCallPart | ThinkingPart] = []
499
+ usage = RequestUsage(
500
+ input_tokens=message.usage.input_tokens,
501
+ output_tokens=message.usage.output_tokens,
502
+ cache_read_tokens=message.usage.cache_read_input_tokens,
503
+ cache_write_tokens=message.usage.cache_creation_input_tokens,
504
+ )
505
+
506
+ if isinstance(msg_content, str):
507
+ response_parts.append(TextPart(content=msg_content))
508
+ else:
509
+ for block in msg_content:
510
+ if block.type == "text" and block.text:
511
+ response_parts.append(TextPart(content=block.text))
512
+ elif block.type == "thinking" and block.thinking:
513
+ response_parts.append(
514
+ ThinkingPart(
515
+ content=block.thinking,
516
+ signature=block.signature,
517
+ )
518
+ )
519
+ elif block.type == "tool_use" and block.id and block.name:
520
+ response_parts.append(
521
+ ToolCallPart(
522
+ tool_name=block.name,
523
+ args=block.input or {},
524
+ tool_call_id=block.id,
525
+ )
526
+ )
527
+
528
+ if not response_parts:
529
+ return None
530
+
531
+ return ModelResponse(
532
+ parts=response_parts,
533
+ usage=usage,
534
+ model_name=message.model,
535
+ timestamp=timestamp,
536
+ )
537
+
538
+ def _extract_tool_result_content(self, block: ClaudeMessageContent) -> str:
539
+ """Extract content from a tool_result block."""
540
+ if block.content is None:
541
+ return ""
542
+ if isinstance(block.content, str):
543
+ return block.content
544
+ # List of content dicts
545
+ text_parts = [
546
+ tc.get("text", "")
547
+ for tc in block.content
548
+ if isinstance(tc, dict) and tc.get("type") == "text"
549
+ ]
550
+ return "\n".join(text_parts)
551
+
552
+ def _chat_message_to_entry(
553
+ self,
554
+ message: ChatMessage[str],
555
+ session_id: str,
556
+ parent_uuid: str | None = None,
557
+ cwd: str | None = None,
558
+ ) -> ClaudeUserEntry | ClaudeAssistantEntry:
559
+ """Convert a ChatMessage to a Claude JSONL entry."""
560
+ import uuid
561
+
562
+ msg_uuid = message.message_id or str(uuid.uuid4())
563
+ timestamp = (message.timestamp or get_now()).isoformat().replace("+00:00", "Z")
564
+
565
+ # Build entry based on role
566
+ if message.role == "user":
567
+ user_msg = ClaudeUserMessage(role="user", content=message.content)
568
+ return ClaudeUserEntry(
569
+ type="user",
570
+ uuid=msg_uuid,
571
+ parent_uuid=parent_uuid,
572
+ sessionId=session_id,
573
+ timestamp=timestamp,
574
+ message=user_msg,
575
+ cwd=cwd or "",
576
+ version="agentpool",
577
+ user_type="external",
578
+ is_sidechain=False,
579
+ )
580
+
581
+ # Assistant message
582
+ content_blocks = [ClaudeMessageContent(type="text", text=message.content)]
583
+ usage = ClaudeUsage()
584
+ if message.cost_info:
585
+ usage = ClaudeUsage(
586
+ input_tokens=message.cost_info.token_usage.input_tokens,
587
+ output_tokens=message.cost_info.token_usage.output_tokens,
588
+ )
589
+ assistant_msg = ClaudeApiMessage(
590
+ model=message.model_name or "unknown",
591
+ id=f"msg_{msg_uuid[:20]}",
592
+ role="assistant",
593
+ content=content_blocks,
594
+ usage=usage,
595
+ )
596
+ return ClaudeAssistantEntry(
597
+ type="assistant",
598
+ uuid=msg_uuid,
599
+ parent_uuid=parent_uuid,
600
+ sessionId=session_id,
601
+ timestamp=timestamp,
602
+ message=assistant_msg,
603
+ cwd=cwd or "",
604
+ version="agentpool",
605
+ user_type="external",
606
+ is_sidechain=False,
607
+ )
608
+
609
+ async def filter_messages(self, query: SessionQuery) -> list[ChatMessage[str]]:
610
+ """Filter messages based on query."""
611
+ messages: list[ChatMessage[str]] = []
612
+
613
+ # Determine which sessions to search
614
+ sessions = self._list_sessions()
615
+
616
+ for session_id, session_path in sessions:
617
+ # Filter by conversation/session name if specified
618
+ if query.name and session_id != query.name:
619
+ continue
620
+
621
+ entries = self._read_session(session_path)
622
+ tool_mapping = self._build_tool_id_mapping(entries)
623
+
624
+ for entry in entries:
625
+ msg = self._entry_to_chat_message(entry, session_id, tool_mapping)
626
+ if msg is None:
627
+ continue
628
+
629
+ # Apply filters
630
+ if query.agents and msg.name not in query.agents:
631
+ continue
632
+
633
+ cutoff = query.get_time_cutoff()
634
+ if query.since and cutoff and msg.timestamp and msg.timestamp < cutoff:
635
+ continue
636
+
637
+ if query.until and msg.timestamp:
638
+ until_dt = datetime.fromisoformat(query.until)
639
+ if msg.timestamp > until_dt:
640
+ continue
641
+
642
+ if query.contains and query.contains not in msg.content:
643
+ continue
644
+
645
+ if query.roles and msg.role not in query.roles:
646
+ continue
647
+
648
+ messages.append(msg)
649
+
650
+ if query.limit and len(messages) >= query.limit:
651
+ return messages
652
+
653
+ return messages
654
+
655
+ async def log_message(
656
+ self,
657
+ *,
658
+ message_id: str,
659
+ conversation_id: str,
660
+ content: str,
661
+ role: str,
662
+ name: str | None = None,
663
+ parent_id: str | None = None,
664
+ cost_info: TokenCost | None = None,
665
+ model: str | None = None,
666
+ response_time: float | None = None,
667
+ provider_name: str | None = None,
668
+ provider_response_id: str | None = None,
669
+ messages: str | None = None,
670
+ finish_reason: FinishReason | None = None,
671
+ ) -> None:
672
+ """Log a message to Claude format.
673
+
674
+ Note: conversation_id should be in format "project_path:session_id"
675
+ or just "session_id" (will use default project).
676
+ """
677
+ # Parse conversation_id
678
+ if ":" in conversation_id:
679
+ project_path, session_id = conversation_id.split(":", 1)
680
+ else:
681
+ project_path = "/tmp"
682
+ session_id = conversation_id
683
+
684
+ # Build ChatMessage for conversion
685
+ chat_message = ChatMessage[str](
686
+ content=content,
687
+ conversation_id=conversation_id,
688
+ role=cast(MessageRole, role),
689
+ message_id=message_id,
690
+ name=name,
691
+ model_name=model,
692
+ cost_info=cost_info,
693
+ response_time=response_time,
694
+ parent_id=parent_id,
695
+ )
696
+
697
+ # Convert to entry and write
698
+ entry = self._chat_message_to_entry(
699
+ chat_message,
700
+ session_id=session_id,
701
+ parent_uuid=parent_id,
702
+ cwd=project_path,
703
+ )
704
+
705
+ session_path = self._get_project_dir(project_path) / f"{session_id}.jsonl"
706
+ self._write_entry(session_path, entry)
707
+
708
+ async def log_conversation(
709
+ self,
710
+ *,
711
+ conversation_id: str,
712
+ node_name: str,
713
+ start_time: datetime | None = None,
714
+ ) -> None:
715
+ """Log a conversation start.
716
+
717
+ In Claude format, conversations are implicit (created when first message is written).
718
+ This is a no-op but could be extended to create an initial entry.
719
+ """
720
+
721
+ async def get_conversations(
722
+ self,
723
+ filters: QueryFilters,
724
+ ) -> list[tuple[ConversationData, Sequence[ChatMessage[str]]]]:
725
+ """Get filtered conversations with their messages."""
726
+ from agentpool_storage.models import ConversationData as ConvData
727
+
728
+ result: list[tuple[ConvData, Sequence[ChatMessage[str]]]] = []
729
+ sessions = self._list_sessions()
730
+
731
+ for session_id, session_path in sessions:
732
+ entries = self._read_session(session_path)
733
+ if not entries:
734
+ continue
735
+
736
+ tool_mapping = self._build_tool_id_mapping(entries)
737
+
738
+ # Build messages
739
+ messages: list[ChatMessage[str]] = []
740
+ first_timestamp: datetime | None = None
741
+ total_tokens = 0
742
+
743
+ for entry in entries:
744
+ msg = self._entry_to_chat_message(entry, session_id, tool_mapping)
745
+ if msg is None:
746
+ continue
747
+
748
+ messages.append(msg)
749
+
750
+ if first_timestamp is None and msg.timestamp:
751
+ first_timestamp = msg.timestamp
752
+
753
+ if msg.cost_info:
754
+ total_tokens += msg.cost_info.token_usage.total_tokens
755
+
756
+ if not messages:
757
+ continue
758
+
759
+ # Apply filters
760
+ if filters.agent_name and not any(m.name == filters.agent_name for m in messages):
761
+ continue
762
+
763
+ if filters.since and first_timestamp and first_timestamp < filters.since:
764
+ continue
765
+
766
+ if filters.query and not any(filters.query in m.content for m in messages):
767
+ continue
768
+
769
+ # Build MessageData list
770
+ msg_data_list: list[MessageData] = []
771
+ for msg in messages:
772
+ msg_data: MessageData = {
773
+ "role": msg.role,
774
+ "content": msg.content,
775
+ "timestamp": (msg.timestamp or get_now()).isoformat(),
776
+ "parent_id": msg.parent_id,
777
+ "model": msg.model_name,
778
+ "name": msg.name,
779
+ "token_usage": TokenUsage(
780
+ total=msg.cost_info.token_usage.total_tokens if msg.cost_info else 0,
781
+ prompt=msg.cost_info.token_usage.input_tokens if msg.cost_info else 0,
782
+ completion=msg.cost_info.token_usage.output_tokens if msg.cost_info else 0,
783
+ )
784
+ if msg.cost_info
785
+ else None,
786
+ "cost": float(msg.cost_info.total_cost) if msg.cost_info else None,
787
+ "response_time": msg.response_time,
788
+ }
789
+ msg_data_list.append(msg_data)
790
+
791
+ token_usage_data: TokenUsage | None = (
792
+ {"total": total_tokens, "prompt": 0, "completion": 0} if total_tokens else None
793
+ )
794
+ conv_data = ConvData(
795
+ id=session_id,
796
+ agent=messages[0].name or "claude",
797
+ title=None,
798
+ start_time=(first_timestamp or get_now()).isoformat(),
799
+ messages=msg_data_list,
800
+ token_usage=token_usage_data,
801
+ )
802
+
803
+ result.append((conv_data, messages))
804
+
805
+ if filters.limit and len(result) >= filters.limit:
806
+ break
807
+
808
+ return result
809
+
810
+ async def get_conversation_stats(
811
+ self,
812
+ filters: StatsFilters,
813
+ ) -> dict[str, dict[str, Any]]:
814
+ """Get conversation statistics."""
815
+ from collections import defaultdict
816
+
817
+ stats: dict[str, dict[str, Any]] = defaultdict(
818
+ lambda: {"total_tokens": 0, "messages": 0, "models": set()}
819
+ )
820
+
821
+ sessions = self._list_sessions()
822
+
823
+ for _session_id, session_path in sessions:
824
+ entries = self._read_session(session_path)
825
+
826
+ for entry in entries:
827
+ if not isinstance(entry, ClaudeAssistantEntry):
828
+ continue
829
+
830
+ if not isinstance(entry.message, ClaudeApiMessage):
831
+ continue
832
+
833
+ api_msg = entry.message
834
+ model = api_msg.model
835
+ usage = api_msg.usage
836
+ total_tokens = (
837
+ usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens
838
+ )
839
+
840
+ try:
841
+ timestamp = datetime.fromisoformat(entry.timestamp.replace("Z", "+00:00"))
842
+ except (ValueError, AttributeError):
843
+ timestamp = get_now()
844
+
845
+ # Apply time filter
846
+ if timestamp < filters.cutoff:
847
+ continue
848
+
849
+ # Group by specified criterion
850
+ match filters.group_by:
851
+ case "model":
852
+ key = model
853
+ case "hour":
854
+ key = timestamp.strftime("%Y-%m-%d %H:00")
855
+ case "day":
856
+ key = timestamp.strftime("%Y-%m-%d")
857
+ case _:
858
+ key = "claude" # Default agent grouping
859
+
860
+ stats[key]["messages"] += 1
861
+ stats[key]["total_tokens"] += total_tokens
862
+ stats[key]["models"].add(model)
863
+
864
+ # Convert sets to lists for JSON serialization
865
+ for value in stats.values():
866
+ value["models"] = list(value["models"])
867
+
868
+ return dict(stats)
869
+
870
+ async def reset(
871
+ self,
872
+ *,
873
+ agent_name: str | None = None,
874
+ hard: bool = False,
875
+ ) -> tuple[int, int]:
876
+ """Reset storage.
877
+
878
+ Warning: This will delete Claude conversation files!
879
+ """
880
+ conv_count = 0
881
+ msg_count = 0
882
+
883
+ sessions = self._list_sessions()
884
+
885
+ for _session_id, session_path in sessions:
886
+ entries = self._read_session(session_path)
887
+ msg_count += len([
888
+ e for e in entries if isinstance(e, (ClaudeUserEntry, ClaudeAssistantEntry))
889
+ ])
890
+ conv_count += 1
891
+
892
+ if hard or not agent_name:
893
+ session_path.unlink(missing_ok=True)
894
+
895
+ return conv_count, msg_count
896
+
897
+ async def get_conversation_counts(
898
+ self,
899
+ *,
900
+ agent_name: str | None = None,
901
+ ) -> tuple[int, int]:
902
+ """Get counts of conversations and messages."""
903
+ conv_count = 0
904
+ msg_count = 0
905
+
906
+ sessions = self._list_sessions()
907
+
908
+ for _session_id, session_path in sessions:
909
+ entries = self._read_session(session_path)
910
+ message_entries = [
911
+ e for e in entries if isinstance(e, (ClaudeUserEntry, ClaudeAssistantEntry))
912
+ ]
913
+
914
+ if message_entries:
915
+ conv_count += 1
916
+ msg_count += len(message_entries)
917
+
918
+ return conv_count, msg_count
919
+
920
+ async def get_conversation_messages(
921
+ self,
922
+ conversation_id: str,
923
+ *,
924
+ include_ancestors: bool = False,
925
+ ) -> list[ChatMessage[str]]:
926
+ """Get all messages for a conversation.
927
+
928
+ Args:
929
+ conversation_id: Session ID (conversation ID in Claude format)
930
+ include_ancestors: If True, traverse parent_uuid chain to include
931
+ messages from ancestor conversations
932
+
933
+ Returns:
934
+ List of messages ordered by timestamp
935
+ """
936
+ # Find the session file
937
+ sessions = self._list_sessions()
938
+ session_path = None
939
+ for sid, spath in sessions:
940
+ if sid == conversation_id:
941
+ session_path = spath
942
+ break
943
+
944
+ if not session_path:
945
+ return []
946
+
947
+ # Read entries and convert to messages
948
+ entries = self._read_session(session_path)
949
+ tool_mapping = self._build_tool_id_mapping(entries)
950
+
951
+ messages: list[ChatMessage[str]] = []
952
+ for entry in entries:
953
+ msg = self._entry_to_chat_message(entry, conversation_id, tool_mapping)
954
+ if msg:
955
+ messages.append(msg)
956
+
957
+ # Sort by timestamp
958
+ messages.sort(key=lambda m: m.timestamp or get_now())
959
+
960
+ if not include_ancestors or not messages:
961
+ return messages
962
+
963
+ # Get ancestor chain if first message has parent_id
964
+ first_msg = messages[0]
965
+ if first_msg.parent_id:
966
+ ancestors = await self.get_message_ancestry(first_msg.parent_id)
967
+ return ancestors + messages
968
+
969
+ return messages
970
+
971
+ async def get_message(self, message_id: str) -> ChatMessage[str] | None:
972
+ """Get a single message by ID.
973
+
974
+ Args:
975
+ message_id: UUID of the message
976
+
977
+ Returns:
978
+ The message if found, None otherwise
979
+ """
980
+ # Search all sessions for the message
981
+ sessions = self._list_sessions()
982
+
983
+ for session_id, session_path in sessions:
984
+ entries = self._read_session(session_path)
985
+ tool_mapping = self._build_tool_id_mapping(entries)
986
+
987
+ for entry in entries:
988
+ if (
989
+ isinstance(entry, (ClaudeUserEntry, ClaudeAssistantEntry))
990
+ and entry.uuid == message_id
991
+ ):
992
+ return self._entry_to_chat_message(entry, session_id, tool_mapping)
993
+
994
+ return None
995
+
996
+ async def get_message_ancestry(self, message_id: str) -> list[ChatMessage[str]]:
997
+ """Get the ancestry chain of a message.
998
+
999
+ Traverses parent_uuid chain to build full history.
1000
+
1001
+ Args:
1002
+ message_id: UUID of the message
1003
+
1004
+ Returns:
1005
+ List of messages from oldest ancestor to the specified message
1006
+ """
1007
+ ancestors: list[ChatMessage[str]] = []
1008
+ current_id: str | None = message_id
1009
+
1010
+ while current_id:
1011
+ msg = await self.get_message(current_id)
1012
+ if not msg:
1013
+ break
1014
+ ancestors.append(msg)
1015
+ current_id = msg.parent_id
1016
+
1017
+ # Reverse to get oldest first
1018
+ ancestors.reverse()
1019
+ return ancestors
1020
+
1021
+ async def fork_conversation(
1022
+ self,
1023
+ *,
1024
+ source_conversation_id: str,
1025
+ new_conversation_id: str,
1026
+ fork_from_message_id: str | None = None,
1027
+ new_agent_name: str | None = None,
1028
+ ) -> str | None:
1029
+ """Fork a conversation at a specific point.
1030
+
1031
+ Creates a new session file. The fork point message_id is returned
1032
+ so callers can set it as parent_uuid for new messages.
1033
+
1034
+ Args:
1035
+ source_conversation_id: Source session ID
1036
+ new_conversation_id: New session ID
1037
+ fork_from_message_id: UUID to fork from. If None, forks from last message
1038
+ new_agent_name: Not used in Claude format (no agent metadata in sessions)
1039
+
1040
+ Returns:
1041
+ The UUID of the fork point message
1042
+ """
1043
+ # Find source session
1044
+ sessions = self._list_sessions()
1045
+ source_path = None
1046
+ for sid, spath in sessions:
1047
+ if sid == source_conversation_id:
1048
+ source_path = spath
1049
+ break
1050
+
1051
+ if not source_path:
1052
+ msg = f"Source conversation not found: {source_conversation_id}"
1053
+ raise ValueError(msg)
1054
+
1055
+ # Read source entries
1056
+ entries = self._read_session(source_path)
1057
+
1058
+ # Find fork point
1059
+ fork_point_id: str | None = None
1060
+ if fork_from_message_id:
1061
+ # Verify message exists
1062
+ found = False
1063
+ for entry in entries:
1064
+ if (
1065
+ isinstance(entry, (ClaudeUserEntry, ClaudeAssistantEntry))
1066
+ and entry.uuid == fork_from_message_id
1067
+ ):
1068
+ found = True
1069
+ fork_point_id = fork_from_message_id
1070
+ break
1071
+ if not found:
1072
+ err = f"Message {fork_from_message_id} not found in conversation"
1073
+ raise ValueError(err)
1074
+ else:
1075
+ # Find last message
1076
+ message_entries = [
1077
+ e for e in entries if isinstance(e, (ClaudeUserEntry, ClaudeAssistantEntry))
1078
+ ]
1079
+ if message_entries:
1080
+ fork_point_id = message_entries[-1].uuid
1081
+
1082
+ # Create new session file (empty for now - will be populated when messages added)
1083
+ # Determine project from source path structure
1084
+ project_name = source_path.parent.name
1085
+ new_path = self.projects_path / project_name / f"{new_conversation_id}.jsonl"
1086
+ new_path.parent.mkdir(parents=True, exist_ok=True)
1087
+ new_path.touch()
1088
+
1089
+ return fork_point_id