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