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.
- acp/__init__.py +13 -4
- acp/acp_requests.py +20 -77
- acp/agent/connection.py +8 -0
- acp/agent/implementations/debug_server/debug_server.py +6 -2
- acp/agent/protocol.py +6 -0
- acp/bridge/README.md +15 -2
- acp/bridge/__init__.py +3 -2
- acp/bridge/__main__.py +60 -19
- acp/bridge/ws_server.py +173 -0
- acp/bridge/ws_server_cli.py +89 -0
- acp/client/connection.py +38 -29
- acp/client/implementations/default_client.py +3 -2
- acp/client/implementations/headless_client.py +2 -2
- acp/connection.py +2 -2
- acp/notifications.py +20 -50
- acp/schema/__init__.py +2 -0
- acp/schema/agent_responses.py +21 -0
- acp/schema/client_requests.py +3 -3
- acp/schema/session_state.py +63 -29
- acp/stdio.py +39 -9
- acp/task/supervisor.py +2 -2
- acp/transports.py +362 -2
- acp/utils.py +17 -4
- agentpool/__init__.py +6 -1
- agentpool/agents/__init__.py +2 -0
- agentpool/agents/acp_agent/acp_agent.py +407 -277
- agentpool/agents/acp_agent/acp_converters.py +196 -38
- agentpool/agents/acp_agent/client_handler.py +191 -26
- agentpool/agents/acp_agent/session_state.py +17 -6
- agentpool/agents/agent.py +607 -572
- agentpool/agents/agui_agent/__init__.py +0 -2
- agentpool/agents/agui_agent/agui_agent.py +176 -110
- agentpool/agents/agui_agent/agui_converters.py +0 -131
- agentpool/agents/agui_agent/helpers.py +3 -4
- agentpool/agents/base_agent.py +632 -17
- agentpool/agents/claude_code_agent/FORKING.md +191 -0
- agentpool/agents/claude_code_agent/__init__.py +13 -1
- agentpool/agents/claude_code_agent/claude_code_agent.py +1058 -291
- agentpool/agents/claude_code_agent/converters.py +74 -143
- agentpool/agents/claude_code_agent/history.py +474 -0
- agentpool/agents/claude_code_agent/models.py +77 -0
- agentpool/agents/claude_code_agent/static_info.py +100 -0
- agentpool/agents/claude_code_agent/usage.py +242 -0
- agentpool/agents/context.py +40 -0
- agentpool/agents/events/__init__.py +24 -0
- agentpool/agents/events/builtin_handlers.py +67 -1
- agentpool/agents/events/event_emitter.py +32 -2
- agentpool/agents/events/events.py +104 -3
- agentpool/agents/events/infer_info.py +145 -0
- agentpool/agents/events/processors.py +254 -0
- agentpool/agents/interactions.py +41 -6
- agentpool/agents/modes.py +67 -0
- agentpool/agents/slashed_agent.py +5 -4
- agentpool/agents/tool_call_accumulator.py +213 -0
- agentpool/agents/tool_wrapping.py +18 -6
- agentpool/common_types.py +56 -21
- agentpool/config_resources/__init__.py +38 -1
- agentpool/config_resources/acp_assistant.yml +2 -2
- agentpool/config_resources/agents.yml +3 -0
- agentpool/config_resources/agents_template.yml +1 -0
- agentpool/config_resources/claude_code_agent.yml +10 -6
- agentpool/config_resources/external_acp_agents.yml +2 -1
- agentpool/delegation/base_team.py +4 -30
- agentpool/delegation/pool.py +136 -289
- agentpool/delegation/team.py +58 -57
- agentpool/delegation/teamrun.py +51 -55
- agentpool/diagnostics/__init__.py +53 -0
- agentpool/diagnostics/lsp_manager.py +1593 -0
- agentpool/diagnostics/lsp_proxy.py +41 -0
- agentpool/diagnostics/lsp_proxy_script.py +229 -0
- agentpool/diagnostics/models.py +398 -0
- agentpool/functional/run.py +10 -4
- agentpool/mcp_server/__init__.py +0 -2
- agentpool/mcp_server/client.py +76 -32
- agentpool/mcp_server/conversions.py +54 -13
- agentpool/mcp_server/manager.py +34 -54
- agentpool/mcp_server/registries/official_registry_client.py +35 -1
- agentpool/mcp_server/tool_bridge.py +186 -139
- agentpool/messaging/__init__.py +0 -2
- agentpool/messaging/compaction.py +72 -197
- agentpool/messaging/connection_manager.py +11 -10
- agentpool/messaging/event_manager.py +5 -5
- agentpool/messaging/message_container.py +6 -30
- agentpool/messaging/message_history.py +99 -8
- agentpool/messaging/messagenode.py +52 -14
- agentpool/messaging/messages.py +54 -35
- agentpool/messaging/processing.py +12 -22
- agentpool/models/__init__.py +1 -1
- agentpool/models/acp_agents/base.py +6 -24
- agentpool/models/acp_agents/mcp_capable.py +126 -157
- agentpool/models/acp_agents/non_mcp.py +129 -95
- agentpool/models/agents.py +98 -76
- agentpool/models/agui_agents.py +1 -1
- agentpool/models/claude_code_agents.py +144 -19
- agentpool/models/file_parsing.py +0 -1
- agentpool/models/manifest.py +113 -50
- agentpool/prompts/conversion_manager.py +1 -1
- agentpool/prompts/prompts.py +5 -2
- agentpool/repomap.py +1 -1
- agentpool/resource_providers/__init__.py +11 -1
- agentpool/resource_providers/aggregating.py +56 -5
- agentpool/resource_providers/base.py +70 -4
- agentpool/resource_providers/codemode/code_executor.py +72 -5
- agentpool/resource_providers/codemode/helpers.py +2 -2
- agentpool/resource_providers/codemode/provider.py +64 -12
- agentpool/resource_providers/codemode/remote_mcp_execution.py +2 -2
- agentpool/resource_providers/codemode/remote_provider.py +9 -12
- agentpool/resource_providers/filtering.py +3 -1
- agentpool/resource_providers/mcp_provider.py +89 -12
- agentpool/resource_providers/plan_provider.py +228 -46
- agentpool/resource_providers/pool.py +7 -3
- agentpool/resource_providers/resource_info.py +111 -0
- agentpool/resource_providers/static.py +4 -2
- agentpool/sessions/__init__.py +4 -1
- agentpool/sessions/manager.py +33 -5
- agentpool/sessions/models.py +59 -6
- agentpool/sessions/protocol.py +28 -0
- agentpool/sessions/session.py +11 -55
- agentpool/skills/registry.py +13 -8
- agentpool/storage/manager.py +572 -49
- agentpool/talk/registry.py +4 -4
- agentpool/talk/talk.py +9 -10
- agentpool/testing.py +538 -20
- agentpool/tool_impls/__init__.py +6 -0
- agentpool/tool_impls/agent_cli/__init__.py +42 -0
- agentpool/tool_impls/agent_cli/tool.py +95 -0
- agentpool/tool_impls/bash/__init__.py +64 -0
- agentpool/tool_impls/bash/helpers.py +35 -0
- agentpool/tool_impls/bash/tool.py +171 -0
- agentpool/tool_impls/delete_path/__init__.py +70 -0
- agentpool/tool_impls/delete_path/tool.py +142 -0
- agentpool/tool_impls/download_file/__init__.py +80 -0
- agentpool/tool_impls/download_file/tool.py +183 -0
- agentpool/tool_impls/execute_code/__init__.py +55 -0
- agentpool/tool_impls/execute_code/tool.py +163 -0
- agentpool/tool_impls/grep/__init__.py +80 -0
- agentpool/tool_impls/grep/tool.py +200 -0
- agentpool/tool_impls/list_directory/__init__.py +73 -0
- agentpool/tool_impls/list_directory/tool.py +197 -0
- agentpool/tool_impls/question/__init__.py +42 -0
- agentpool/tool_impls/question/tool.py +127 -0
- agentpool/tool_impls/read/__init__.py +104 -0
- agentpool/tool_impls/read/tool.py +305 -0
- agentpool/tools/__init__.py +2 -1
- agentpool/tools/base.py +114 -34
- agentpool/tools/manager.py +57 -1
- agentpool/ui/base.py +2 -2
- agentpool/ui/mock_provider.py +2 -2
- agentpool/ui/stdlib_provider.py +2 -2
- agentpool/utils/file_watcher.py +269 -0
- agentpool/utils/identifiers.py +121 -0
- agentpool/utils/pydantic_ai_helpers.py +46 -0
- agentpool/utils/streams.py +616 -2
- agentpool/utils/subprocess_utils.py +155 -0
- agentpool/utils/token_breakdown.py +461 -0
- agentpool/vfs_registry.py +7 -2
- {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/METADATA +41 -27
- agentpool-2.5.0.dist-info/RECORD +579 -0
- {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/WHEEL +1 -1
- agentpool_cli/__main__.py +24 -0
- agentpool_cli/create.py +1 -1
- agentpool_cli/serve_acp.py +100 -21
- agentpool_cli/serve_agui.py +87 -0
- agentpool_cli/serve_opencode.py +119 -0
- agentpool_cli/ui.py +557 -0
- agentpool_commands/__init__.py +42 -5
- agentpool_commands/agents.py +75 -2
- agentpool_commands/history.py +62 -0
- agentpool_commands/mcp.py +176 -0
- agentpool_commands/models.py +56 -3
- agentpool_commands/pool.py +260 -0
- agentpool_commands/session.py +1 -1
- agentpool_commands/text_sharing/__init__.py +119 -0
- agentpool_commands/text_sharing/base.py +123 -0
- agentpool_commands/text_sharing/github_gist.py +80 -0
- agentpool_commands/text_sharing/opencode.py +462 -0
- agentpool_commands/text_sharing/paste_rs.py +59 -0
- agentpool_commands/text_sharing/pastebin.py +116 -0
- agentpool_commands/text_sharing/shittycodingagent.py +112 -0
- agentpool_commands/tools.py +57 -0
- agentpool_commands/utils.py +80 -30
- agentpool_config/__init__.py +30 -2
- agentpool_config/agentpool_tools.py +498 -0
- agentpool_config/builtin_tools.py +77 -22
- agentpool_config/commands.py +24 -1
- agentpool_config/compaction.py +258 -0
- agentpool_config/converters.py +1 -1
- agentpool_config/event_handlers.py +42 -0
- agentpool_config/events.py +1 -1
- agentpool_config/forward_targets.py +1 -4
- agentpool_config/jinja.py +3 -3
- agentpool_config/mcp_server.py +132 -6
- agentpool_config/nodes.py +1 -1
- agentpool_config/observability.py +44 -0
- agentpool_config/session.py +0 -3
- agentpool_config/storage.py +82 -38
- agentpool_config/task.py +3 -3
- agentpool_config/tools.py +11 -22
- agentpool_config/toolsets.py +109 -233
- agentpool_server/a2a_server/agent_worker.py +307 -0
- agentpool_server/a2a_server/server.py +23 -18
- agentpool_server/acp_server/acp_agent.py +234 -181
- agentpool_server/acp_server/commands/acp_commands.py +151 -156
- agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +18 -17
- agentpool_server/acp_server/event_converter.py +651 -0
- agentpool_server/acp_server/input_provider.py +53 -10
- agentpool_server/acp_server/server.py +24 -90
- agentpool_server/acp_server/session.py +173 -331
- agentpool_server/acp_server/session_manager.py +8 -34
- agentpool_server/agui_server/server.py +3 -1
- agentpool_server/mcp_server/server.py +5 -2
- agentpool_server/opencode_server/.rules +95 -0
- agentpool_server/opencode_server/ENDPOINTS.md +401 -0
- agentpool_server/opencode_server/OPENCODE_UI_TOOLS_COMPLETE.md +202 -0
- agentpool_server/opencode_server/__init__.py +19 -0
- agentpool_server/opencode_server/command_validation.py +172 -0
- agentpool_server/opencode_server/converters.py +975 -0
- agentpool_server/opencode_server/dependencies.py +24 -0
- agentpool_server/opencode_server/input_provider.py +421 -0
- agentpool_server/opencode_server/models/__init__.py +250 -0
- agentpool_server/opencode_server/models/agent.py +53 -0
- agentpool_server/opencode_server/models/app.py +72 -0
- agentpool_server/opencode_server/models/base.py +26 -0
- agentpool_server/opencode_server/models/common.py +23 -0
- agentpool_server/opencode_server/models/config.py +37 -0
- agentpool_server/opencode_server/models/events.py +821 -0
- agentpool_server/opencode_server/models/file.py +88 -0
- agentpool_server/opencode_server/models/mcp.py +44 -0
- agentpool_server/opencode_server/models/message.py +179 -0
- agentpool_server/opencode_server/models/parts.py +323 -0
- agentpool_server/opencode_server/models/provider.py +81 -0
- agentpool_server/opencode_server/models/pty.py +43 -0
- agentpool_server/opencode_server/models/question.py +56 -0
- agentpool_server/opencode_server/models/session.py +111 -0
- agentpool_server/opencode_server/routes/__init__.py +29 -0
- agentpool_server/opencode_server/routes/agent_routes.py +473 -0
- agentpool_server/opencode_server/routes/app_routes.py +202 -0
- agentpool_server/opencode_server/routes/config_routes.py +302 -0
- agentpool_server/opencode_server/routes/file_routes.py +571 -0
- agentpool_server/opencode_server/routes/global_routes.py +94 -0
- agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
- agentpool_server/opencode_server/routes/message_routes.py +761 -0
- agentpool_server/opencode_server/routes/permission_routes.py +63 -0
- agentpool_server/opencode_server/routes/pty_routes.py +300 -0
- agentpool_server/opencode_server/routes/question_routes.py +128 -0
- agentpool_server/opencode_server/routes/session_routes.py +1276 -0
- agentpool_server/opencode_server/routes/tui_routes.py +139 -0
- agentpool_server/opencode_server/server.py +475 -0
- agentpool_server/opencode_server/state.py +151 -0
- agentpool_server/opencode_server/time_utils.py +8 -0
- agentpool_storage/__init__.py +12 -0
- agentpool_storage/base.py +184 -2
- agentpool_storage/claude_provider/ARCHITECTURE.md +433 -0
- agentpool_storage/claude_provider/__init__.py +42 -0
- agentpool_storage/claude_provider/provider.py +1089 -0
- agentpool_storage/file_provider.py +278 -15
- agentpool_storage/memory_provider.py +193 -12
- agentpool_storage/models.py +3 -0
- agentpool_storage/opencode_provider/ARCHITECTURE.md +386 -0
- agentpool_storage/opencode_provider/__init__.py +16 -0
- agentpool_storage/opencode_provider/helpers.py +414 -0
- agentpool_storage/opencode_provider/provider.py +895 -0
- agentpool_storage/project_store.py +325 -0
- agentpool_storage/session_store.py +26 -6
- agentpool_storage/sql_provider/__init__.py +4 -2
- agentpool_storage/sql_provider/models.py +48 -0
- agentpool_storage/sql_provider/sql_provider.py +269 -3
- agentpool_storage/sql_provider/utils.py +12 -13
- agentpool_storage/zed_provider/__init__.py +16 -0
- agentpool_storage/zed_provider/helpers.py +281 -0
- agentpool_storage/zed_provider/models.py +130 -0
- agentpool_storage/zed_provider/provider.py +442 -0
- agentpool_storage/zed_provider.py +803 -0
- agentpool_toolsets/__init__.py +0 -2
- agentpool_toolsets/builtin/__init__.py +2 -12
- agentpool_toolsets/builtin/code.py +96 -57
- agentpool_toolsets/builtin/debug.py +118 -48
- agentpool_toolsets/builtin/execution_environment.py +115 -230
- agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
- agentpool_toolsets/builtin/skills.py +9 -4
- agentpool_toolsets/builtin/subagent_tools.py +64 -51
- agentpool_toolsets/builtin/workers.py +4 -2
- agentpool_toolsets/composio_toolset.py +2 -2
- agentpool_toolsets/entry_points.py +3 -1
- agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
- agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
- agentpool_toolsets/fsspec_toolset/grep.py +99 -7
- agentpool_toolsets/fsspec_toolset/helpers.py +3 -2
- agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
- agentpool_toolsets/fsspec_toolset/toolset.py +500 -95
- agentpool_toolsets/mcp_discovery/__init__.py +5 -0
- agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
- agentpool_toolsets/mcp_discovery/toolset.py +511 -0
- agentpool_toolsets/mcp_run_toolset.py +87 -12
- agentpool_toolsets/notifications.py +33 -33
- agentpool_toolsets/openapi.py +3 -1
- agentpool_toolsets/search_toolset.py +3 -1
- agentpool-2.1.9.dist-info/RECORD +0 -474
- agentpool_config/resources.py +0 -33
- agentpool_server/acp_server/acp_tools.py +0 -43
- agentpool_server/acp_server/commands/spawn.py +0 -210
- agentpool_storage/text_log_provider.py +0 -275
- agentpool_toolsets/builtin/agent_management.py +0 -239
- agentpool_toolsets/builtin/chain.py +0 -288
- agentpool_toolsets/builtin/history.py +0 -36
- agentpool_toolsets/builtin/integration.py +0 -85
- agentpool_toolsets/builtin/tool_management.py +0 -90
- agentpool_toolsets/builtin/user_interaction.py +0 -52
- agentpool_toolsets/semantic_memory_toolset.py +0 -536
- {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/entry_points.txt +0 -0
- {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
|