moolmesh 1.4.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.
- hub/__init__.py +4 -0
- hub/__main__.py +7 -0
- hub/adapters/__init__.py +3 -0
- hub/adapters/base.py +27 -0
- hub/adapters/claude_adapter.py +238 -0
- hub/adapters/codex_adapter.py +227 -0
- hub/adapters/opencode_adapter.py +143 -0
- hub/adapters/qwen_adapter.py +201 -0
- hub/analyzers/__init__.py +19 -0
- hub/analyzers/base.py +37 -0
- hub/analyzers/cross_provider.py +58 -0
- hub/analyzers/efficiency.py +141 -0
- hub/analyzers/file_ops.py +106 -0
- hub/analyzers/qa.py +188 -0
- hub/analyzers/session_timeline.py +73 -0
- hub/analyzers/summary.py +126 -0
- hub/analyzers/user_messages.py +71 -0
- hub/backfill.py +22 -0
- hub/batch_reporter.py +316 -0
- hub/cache/__init__.py +0 -0
- hub/cache/event_store.py +717 -0
- hub/cache/git_store.py +928 -0
- hub/cli.py +614 -0
- hub/colors.py +37 -0
- hub/config.py +275 -0
- hub/correlation/__init__.py +1 -0
- hub/correlation/linker.py +162 -0
- hub/daemon.py +132 -0
- hub/dashboard/__init__.py +0 -0
- hub/dashboard/server.py +901 -0
- hub/dashboard/static/analytics.html +480 -0
- hub/dashboard/static/dashboard.html +772 -0
- hub/dashboard/static/projects.html +503 -0
- hub/dashboard/static/timeline.html +1033 -0
- hub/digests/__init__.py +4 -0
- hub/digests/engine.py +245 -0
- hub/digests/llm.py +181 -0
- hub/digests/stats.py +166 -0
- hub/digests/template.py +284 -0
- hub/discovery.py +433 -0
- hub/git_utils.py +151 -0
- hub/harvesters/__init__.py +4 -0
- hub/harvesters/git_harvester.py +279 -0
- hub/harvesters/github_harvester.py +225 -0
- hub/integrations/__init__.py +23 -0
- hub/integrations/github_client.py +265 -0
- hub/integrations/ollama_client.py +88 -0
- hub/integrations/openai_compat_client.py +81 -0
- hub/log.py +62 -0
- hub/mcp_server.py +363 -0
- hub/models/__init__.py +17 -0
- hub/models/base.py +114 -0
- hub/models/claude.py +57 -0
- hub/models/codex.py +54 -0
- hub/models/opencode.py +40 -0
- hub/models/qwen.py +59 -0
- hub/parsers/__init__.py +3 -0
- hub/parsers/base.py +29 -0
- hub/parsers/claude_parser.py +167 -0
- hub/parsers/codex_parser.py +247 -0
- hub/parsers/opencode_parser.py +237 -0
- hub/parsers/qwen_parser.py +191 -0
- hub/renderers/__init__.py +3 -0
- hub/renderers/markdown.py +97 -0
- hub/watchers/__init__.py +3 -0
- hub/watchers/base.py +149 -0
- hub/watchers/claude_watcher.py +63 -0
- hub/watchers/codex_watcher.py +58 -0
- hub/watchers/kqueue_watcher.py +94 -0
- hub/watchers/opencode_watcher.py +57 -0
- hub/watchers/polling_watcher.py +70 -0
- hub/watchers/qwen_watcher.py +63 -0
- moolmesh-1.4.0.dist-info/METADATA +25 -0
- moolmesh-1.4.0.dist-info/RECORD +78 -0
- moolmesh-1.4.0.dist-info/WHEEL +5 -0
- moolmesh-1.4.0.dist-info/entry_points.txt +2 -0
- moolmesh-1.4.0.dist-info/licenses/LICENSE +21 -0
- moolmesh-1.4.0.dist-info/top_level.txt +1 -0
hub/__init__.py
ADDED
hub/__main__.py
ADDED
hub/adapters/__init__.py
ADDED
hub/adapters/base.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Abstract base adapter for converting provider entries to unified models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from hub.models.base import UnifiedEvent, UnifiedMessage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseAdapter(ABC):
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def to_unified(self, entry: Any, project: str) -> UnifiedMessage | None:
|
|
15
|
+
"""Convert a provider-specific entry to UnifiedMessage.
|
|
16
|
+
|
|
17
|
+
Returns None if the entry should be skipped.
|
|
18
|
+
"""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def to_event(self, entry: Any, project: str) -> UnifiedEvent | None:
|
|
23
|
+
"""Convert a provider-specific entry to a lightweight UnifiedEvent for SSE.
|
|
24
|
+
|
|
25
|
+
Returns None if the entry should be skipped.
|
|
26
|
+
"""
|
|
27
|
+
...
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Adapter converting Claude entries to unified models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from hub.adapters.base import BaseAdapter
|
|
8
|
+
from hub.models.base import (
|
|
9
|
+
MessageRole,
|
|
10
|
+
Provider,
|
|
11
|
+
TokenUsage,
|
|
12
|
+
ToolCall,
|
|
13
|
+
UnifiedEvent,
|
|
14
|
+
UnifiedMessage,
|
|
15
|
+
)
|
|
16
|
+
from hub.models.claude import ClaudeContentBlock, ClaudeEntry, ClaudeUsage
|
|
17
|
+
|
|
18
|
+
# Types we skip during adaptation
|
|
19
|
+
_SKIP_TYPES = frozenset({"file-history-snapshot"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClaudeAdapter(BaseAdapter):
|
|
23
|
+
|
|
24
|
+
def to_unified(self, entry: ClaudeEntry, project: str) -> UnifiedMessage | None:
|
|
25
|
+
if entry.type in _SKIP_TYPES:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
role = self._map_role(entry)
|
|
29
|
+
if role is None:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
tool_calls = self._extract_tool_calls(entry.content_blocks)
|
|
33
|
+
tokens = self._map_usage(entry.usage)
|
|
34
|
+
timestamp = self._parse_timestamp(entry.timestamp)
|
|
35
|
+
|
|
36
|
+
return UnifiedMessage(
|
|
37
|
+
id=entry.uuid,
|
|
38
|
+
provider=Provider.CLAUDE,
|
|
39
|
+
session_id=entry.session_id,
|
|
40
|
+
project=project,
|
|
41
|
+
role=role,
|
|
42
|
+
text=entry.content_text,
|
|
43
|
+
tool_calls=tool_calls,
|
|
44
|
+
timestamp=timestamp,
|
|
45
|
+
model=entry.model,
|
|
46
|
+
tokens=tokens,
|
|
47
|
+
parent_id=entry.parent_uuid,
|
|
48
|
+
is_sidechain=entry.is_sidechain,
|
|
49
|
+
cwd=entry.cwd,
|
|
50
|
+
raw=entry.raw,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def to_event(self, entry: ClaudeEntry, project: str) -> UnifiedEvent | None:
|
|
54
|
+
if entry.type in _SKIP_TYPES:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
role = self._map_role(entry)
|
|
58
|
+
if role is None:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
summary = self._summarize(entry)
|
|
62
|
+
tokens = None
|
|
63
|
+
if entry.usage:
|
|
64
|
+
tokens = {
|
|
65
|
+
"input": entry.usage.input_tokens,
|
|
66
|
+
"output": entry.usage.output_tokens,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
tool_name = None
|
|
70
|
+
file_path = None
|
|
71
|
+
for block in entry.content_blocks:
|
|
72
|
+
if block.type == "tool_use" and block.tool_name:
|
|
73
|
+
tool_name = block.tool_name
|
|
74
|
+
if block.tool_input:
|
|
75
|
+
file_path = (
|
|
76
|
+
block.tool_input.get("file_path")
|
|
77
|
+
or block.tool_input.get("path")
|
|
78
|
+
or block.tool_input.get("command", "")[:80]
|
|
79
|
+
)
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
return UnifiedEvent(
|
|
83
|
+
provider=Provider.CLAUDE,
|
|
84
|
+
project=project,
|
|
85
|
+
event_type=role.value,
|
|
86
|
+
timestamp=entry.timestamp,
|
|
87
|
+
summary=summary,
|
|
88
|
+
session_id=entry.session_id,
|
|
89
|
+
tokens=tokens,
|
|
90
|
+
tool_name=tool_name,
|
|
91
|
+
file_path=str(file_path) if file_path else None,
|
|
92
|
+
model=entry.model,
|
|
93
|
+
cwd=entry.cwd or None,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _map_role(self, entry: ClaudeEntry) -> MessageRole | None:
|
|
97
|
+
match entry.type:
|
|
98
|
+
case "user":
|
|
99
|
+
return MessageRole.USER
|
|
100
|
+
case "assistant":
|
|
101
|
+
# Check if this is primarily a tool_use or tool_result message
|
|
102
|
+
has_tool_use = any(
|
|
103
|
+
b.type == "tool_use" for b in entry.content_blocks
|
|
104
|
+
)
|
|
105
|
+
has_tool_result = any(
|
|
106
|
+
b.type == "tool_result" for b in entry.content_blocks
|
|
107
|
+
)
|
|
108
|
+
has_thinking = any(
|
|
109
|
+
b.type == "thinking" for b in entry.content_blocks
|
|
110
|
+
)
|
|
111
|
+
has_text = bool(entry.content_text.strip())
|
|
112
|
+
|
|
113
|
+
if has_tool_result and not has_text:
|
|
114
|
+
return MessageRole.TOOL_RESULT
|
|
115
|
+
if has_tool_use and not has_text:
|
|
116
|
+
return MessageRole.TOOL_USE
|
|
117
|
+
if has_thinking and not has_text and not has_tool_use:
|
|
118
|
+
return MessageRole.THINKING
|
|
119
|
+
return MessageRole.ASSISTANT
|
|
120
|
+
case "system":
|
|
121
|
+
return MessageRole.SYSTEM
|
|
122
|
+
case "summary":
|
|
123
|
+
return MessageRole.SUMMARY
|
|
124
|
+
case _:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def _extract_tool_calls(
|
|
128
|
+
self, blocks: list[ClaudeContentBlock]
|
|
129
|
+
) -> list[ToolCall]:
|
|
130
|
+
calls: list[ToolCall] = []
|
|
131
|
+
for block in blocks:
|
|
132
|
+
if block.type != "tool_use" or not block.tool_name:
|
|
133
|
+
continue
|
|
134
|
+
input_data = block.tool_input or {}
|
|
135
|
+
fp = input_data.get("file_path") or input_data.get("path")
|
|
136
|
+
op_type = self._infer_operation_type(block.tool_name, input_data)
|
|
137
|
+
calls.append(
|
|
138
|
+
ToolCall(
|
|
139
|
+
name=block.tool_name,
|
|
140
|
+
input_data=input_data,
|
|
141
|
+
tool_id=block.tool_id,
|
|
142
|
+
file_path=fp,
|
|
143
|
+
operation_type=op_type,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
return calls
|
|
147
|
+
|
|
148
|
+
def _map_usage(self, usage: ClaudeUsage | None) -> TokenUsage | None:
|
|
149
|
+
if usage is None:
|
|
150
|
+
return None
|
|
151
|
+
return TokenUsage(
|
|
152
|
+
input_tokens=usage.input_tokens,
|
|
153
|
+
output_tokens=usage.output_tokens,
|
|
154
|
+
cache_creation=usage.cache_creation_input_tokens,
|
|
155
|
+
cache_read=usage.cache_read_input_tokens,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _summarize(self, entry: ClaudeEntry) -> str:
|
|
159
|
+
"""Produce a human-readable one-liner for the event feed."""
|
|
160
|
+
match entry.type:
|
|
161
|
+
case "user":
|
|
162
|
+
text = entry.content_text.strip().replace("\n", " ")
|
|
163
|
+
return text[:120] if text else "[empty user message]"
|
|
164
|
+
case "assistant":
|
|
165
|
+
# Prioritize tool_use summaries
|
|
166
|
+
for block in entry.content_blocks:
|
|
167
|
+
if block.type == "tool_use" and block.tool_name:
|
|
168
|
+
brief = self._brief_tool_args(
|
|
169
|
+
block.tool_name, block.tool_input
|
|
170
|
+
)
|
|
171
|
+
return f"{block.tool_name}: {brief}"
|
|
172
|
+
if block.type == "thinking" and block.thinking:
|
|
173
|
+
return f"[thinking] {block.thinking[:100]}"
|
|
174
|
+
text = entry.content_text.strip().replace("\n", " ")
|
|
175
|
+
return text[:120] if text else "[assistant response]"
|
|
176
|
+
case "system":
|
|
177
|
+
sub = f" ({entry.subtype})" if entry.subtype else ""
|
|
178
|
+
return f"[system{sub}]"
|
|
179
|
+
case "summary":
|
|
180
|
+
return "[context summary]"
|
|
181
|
+
case _:
|
|
182
|
+
return f"[{entry.type}]"
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _brief_tool_args(tool_name: str, input_data: dict | None) -> str:
|
|
186
|
+
if not input_data:
|
|
187
|
+
return ""
|
|
188
|
+
match tool_name:
|
|
189
|
+
case "Bash":
|
|
190
|
+
cmd = input_data.get("command", "")
|
|
191
|
+
return cmd[:80]
|
|
192
|
+
case "Read":
|
|
193
|
+
return input_data.get("file_path", "")[:80]
|
|
194
|
+
case "Write" | "Edit" | "MultiEdit":
|
|
195
|
+
return input_data.get("file_path", "")[:80]
|
|
196
|
+
case "Glob":
|
|
197
|
+
return input_data.get("pattern", "")[:80]
|
|
198
|
+
case "Grep":
|
|
199
|
+
return input_data.get("pattern", "")[:80]
|
|
200
|
+
case "Agent":
|
|
201
|
+
return input_data.get("description", "")[:80]
|
|
202
|
+
case "WebSearch" | "WebFetch":
|
|
203
|
+
return input_data.get("query", input_data.get("url", ""))[:80]
|
|
204
|
+
case _:
|
|
205
|
+
# Generic: show first key=value
|
|
206
|
+
for k, v in input_data.items():
|
|
207
|
+
return f"{k}={str(v)[:60]}"
|
|
208
|
+
return ""
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _infer_operation_type(tool_name: str, input_data: dict) -> str | None:
|
|
212
|
+
match tool_name:
|
|
213
|
+
case "Read" | "Glob" | "Grep":
|
|
214
|
+
return "read"
|
|
215
|
+
case "Write":
|
|
216
|
+
return "create"
|
|
217
|
+
case "Edit" | "MultiEdit":
|
|
218
|
+
return "modify"
|
|
219
|
+
case "Bash":
|
|
220
|
+
cmd = input_data.get("command", "")
|
|
221
|
+
if any(k in cmd for k in ("rm ", "rm\t", "rmdir")):
|
|
222
|
+
return "delete"
|
|
223
|
+
if any(k in cmd for k in ("mkdir", "touch", "cp ", "mv ")):
|
|
224
|
+
return "create"
|
|
225
|
+
return "exec"
|
|
226
|
+
case "Agent":
|
|
227
|
+
return "exec"
|
|
228
|
+
case _:
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _parse_timestamp(ts: str) -> datetime | None:
|
|
233
|
+
if not ts:
|
|
234
|
+
return None
|
|
235
|
+
try:
|
|
236
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
237
|
+
except (ValueError, TypeError):
|
|
238
|
+
return None
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Adapter converting Codex entries to unified models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from hub.adapters.base import BaseAdapter
|
|
9
|
+
from hub.models.base import (
|
|
10
|
+
MessageRole,
|
|
11
|
+
Provider,
|
|
12
|
+
TokenUsage,
|
|
13
|
+
ToolCall,
|
|
14
|
+
UnifiedEvent,
|
|
15
|
+
UnifiedMessage,
|
|
16
|
+
)
|
|
17
|
+
from hub.models.codex import CodexEntry
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CodexAdapter(BaseAdapter):
|
|
21
|
+
|
|
22
|
+
def to_unified(self, entry: CodexEntry, project: str) -> UnifiedMessage | None:
|
|
23
|
+
role = self._map_role(entry)
|
|
24
|
+
if role is None:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
tool_calls = self._extract_tool_calls(entry)
|
|
28
|
+
timestamp = self._parse_timestamp(entry.timestamp)
|
|
29
|
+
text = self._extract_text(entry)
|
|
30
|
+
|
|
31
|
+
tokens = self._extract_tokens(entry)
|
|
32
|
+
|
|
33
|
+
return UnifiedMessage(
|
|
34
|
+
id=entry.session_id or entry.timestamp,
|
|
35
|
+
provider=Provider.CODEX,
|
|
36
|
+
session_id=entry.session_id,
|
|
37
|
+
project=project,
|
|
38
|
+
role=role,
|
|
39
|
+
text=text,
|
|
40
|
+
tool_calls=tool_calls,
|
|
41
|
+
timestamp=timestamp,
|
|
42
|
+
model=entry.model_provider or None,
|
|
43
|
+
tokens=tokens,
|
|
44
|
+
cwd=entry.cwd or None,
|
|
45
|
+
raw=entry.raw,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def to_event(self, entry: CodexEntry, project: str) -> UnifiedEvent | None:
|
|
49
|
+
role = self._map_role(entry)
|
|
50
|
+
if role is None:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
summary = self._summarize(entry)
|
|
54
|
+
tool_name = None
|
|
55
|
+
file_path = None
|
|
56
|
+
|
|
57
|
+
if entry.function_call:
|
|
58
|
+
tool_name = entry.function_call.name
|
|
59
|
+
# Try to extract command from arguments
|
|
60
|
+
try:
|
|
61
|
+
args = json.loads(entry.function_call.arguments)
|
|
62
|
+
file_path = (
|
|
63
|
+
args.get("command", "")[:80]
|
|
64
|
+
or args.get("file_path", "")[:80]
|
|
65
|
+
)
|
|
66
|
+
except (json.JSONDecodeError, TypeError):
|
|
67
|
+
file_path = entry.function_call.arguments[:80]
|
|
68
|
+
|
|
69
|
+
tokens_dict = None
|
|
70
|
+
if entry.event_type == "token_count" and entry.token_total > 0:
|
|
71
|
+
tokens_dict = {
|
|
72
|
+
"input": entry.token_input,
|
|
73
|
+
"output": entry.token_output,
|
|
74
|
+
"cached_input": entry.token_cached_input,
|
|
75
|
+
"reasoning": entry.token_reasoning,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return UnifiedEvent(
|
|
79
|
+
provider=Provider.CODEX,
|
|
80
|
+
project=project,
|
|
81
|
+
event_type=role.value,
|
|
82
|
+
timestamp=entry.timestamp,
|
|
83
|
+
summary=summary,
|
|
84
|
+
session_id=entry.session_id or None,
|
|
85
|
+
tokens=tokens_dict,
|
|
86
|
+
tool_name=tool_name,
|
|
87
|
+
file_path=file_path if file_path else None,
|
|
88
|
+
cwd=entry.cwd or None,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def _map_role(self, entry: CodexEntry) -> MessageRole | None:
|
|
92
|
+
match entry.event_type:
|
|
93
|
+
case "session_meta":
|
|
94
|
+
return MessageRole.SYSTEM
|
|
95
|
+
case "event_msg":
|
|
96
|
+
# Some event_msg entries contain system prompts, not user input
|
|
97
|
+
text = entry.event_msg_text
|
|
98
|
+
if text and self._is_system_prompt(text):
|
|
99
|
+
return MessageRole.SYSTEM
|
|
100
|
+
return MessageRole.USER
|
|
101
|
+
case "token_count":
|
|
102
|
+
return MessageRole.SUMMARY
|
|
103
|
+
case "response_item":
|
|
104
|
+
match entry.payload_type:
|
|
105
|
+
case "message":
|
|
106
|
+
# "developer" role = system instructions for the model
|
|
107
|
+
if entry.role == "developer":
|
|
108
|
+
return MessageRole.SYSTEM
|
|
109
|
+
if entry.role == "user":
|
|
110
|
+
# Check if user message is actually a system prompt
|
|
111
|
+
if entry.text and self._is_system_prompt(entry.text):
|
|
112
|
+
return MessageRole.SYSTEM
|
|
113
|
+
return MessageRole.USER
|
|
114
|
+
return MessageRole.ASSISTANT
|
|
115
|
+
case "function_call":
|
|
116
|
+
return MessageRole.TOOL_USE
|
|
117
|
+
case "function_call_output":
|
|
118
|
+
return MessageRole.TOOL_RESULT
|
|
119
|
+
case "reasoning":
|
|
120
|
+
return MessageRole.THINKING
|
|
121
|
+
case _:
|
|
122
|
+
return None
|
|
123
|
+
case _:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _is_system_prompt(text: str) -> bool:
|
|
128
|
+
"""Detect system/developer prompts that are not real user input.
|
|
129
|
+
|
|
130
|
+
These patterns appear in Codex sessions as event_msg or response_item
|
|
131
|
+
with role 'user' but are actually system-injected instructions.
|
|
132
|
+
"""
|
|
133
|
+
stripped = text.strip()
|
|
134
|
+
# XML-style system blocks
|
|
135
|
+
if stripped.startswith("<") and any(
|
|
136
|
+
stripped.startswith(f"<{tag}")
|
|
137
|
+
for tag in ("permissions", "skills_instructions", "environment_context",
|
|
138
|
+
"system", "instructions", "tool_instructions")
|
|
139
|
+
):
|
|
140
|
+
return True
|
|
141
|
+
# Very long messages (>2000 chars) starting with common system patterns
|
|
142
|
+
if len(stripped) > 2000 and any(
|
|
143
|
+
stripped.startswith(prefix)
|
|
144
|
+
for prefix in ("You are ", "You have ", "The following ", "## ")
|
|
145
|
+
):
|
|
146
|
+
return True
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _extract_tokens(entry: CodexEntry) -> TokenUsage | None:
|
|
151
|
+
if entry.event_type != "token_count" or entry.token_total == 0:
|
|
152
|
+
return None
|
|
153
|
+
return TokenUsage(
|
|
154
|
+
input_tokens=entry.token_input,
|
|
155
|
+
output_tokens=entry.token_output + entry.token_reasoning,
|
|
156
|
+
cache_creation=0,
|
|
157
|
+
cache_read=entry.token_cached_input,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def _extract_text(self, entry: CodexEntry) -> str:
|
|
161
|
+
if entry.event_msg_text:
|
|
162
|
+
return entry.event_msg_text
|
|
163
|
+
if entry.text:
|
|
164
|
+
return entry.text
|
|
165
|
+
if entry.reasoning_text:
|
|
166
|
+
return entry.reasoning_text
|
|
167
|
+
if entry.function_call:
|
|
168
|
+
return f"{entry.function_call.name}({entry.function_call.arguments[:200]})"
|
|
169
|
+
if entry.function_output:
|
|
170
|
+
return entry.function_output.output[:500]
|
|
171
|
+
return ""
|
|
172
|
+
|
|
173
|
+
def _extract_tool_calls(self, entry: CodexEntry) -> list[ToolCall]:
|
|
174
|
+
if not entry.function_call:
|
|
175
|
+
return []
|
|
176
|
+
try:
|
|
177
|
+
args = json.loads(entry.function_call.arguments)
|
|
178
|
+
except (json.JSONDecodeError, TypeError):
|
|
179
|
+
args = {"raw": entry.function_call.arguments}
|
|
180
|
+
return [
|
|
181
|
+
ToolCall(
|
|
182
|
+
name=entry.function_call.name,
|
|
183
|
+
input_data=args,
|
|
184
|
+
tool_id=entry.function_call.call_id,
|
|
185
|
+
operation_type="exec",
|
|
186
|
+
)
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
def _summarize(self, entry: CodexEntry) -> str:
|
|
190
|
+
match entry.event_type:
|
|
191
|
+
case "session_meta":
|
|
192
|
+
return f"[session start] cwd={entry.cwd} v{entry.cli_version}"
|
|
193
|
+
case "token_count":
|
|
194
|
+
return f"[tokens] in={entry.token_input:,} out={entry.token_output:,} cached={entry.token_cached_input:,} reasoning={entry.token_reasoning:,}"
|
|
195
|
+
case "event_msg":
|
|
196
|
+
text = entry.event_msg_text.strip().replace("\n", " ")
|
|
197
|
+
return text[:120] if text else "[user input]"
|
|
198
|
+
case "response_item":
|
|
199
|
+
match entry.payload_type:
|
|
200
|
+
case "message":
|
|
201
|
+
text = entry.text.strip().replace("\n", " ")
|
|
202
|
+
return text[:120] if text else "[message]"
|
|
203
|
+
case "function_call":
|
|
204
|
+
if entry.function_call:
|
|
205
|
+
name = entry.function_call.name
|
|
206
|
+
args_brief = entry.function_call.arguments[:80]
|
|
207
|
+
return f"{name}: {args_brief}"
|
|
208
|
+
return "[function call]"
|
|
209
|
+
case "function_call_output":
|
|
210
|
+
if entry.function_output:
|
|
211
|
+
return f"[output] {entry.function_output.output[:100]}"
|
|
212
|
+
return "[function output]"
|
|
213
|
+
case "reasoning":
|
|
214
|
+
return f"[reasoning] {entry.reasoning_text[:100]}"
|
|
215
|
+
case _:
|
|
216
|
+
return f"[{entry.payload_type}]"
|
|
217
|
+
case _:
|
|
218
|
+
return f"[{entry.event_type}]"
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _parse_timestamp(ts: str) -> datetime | None:
|
|
222
|
+
if not ts:
|
|
223
|
+
return None
|
|
224
|
+
try:
|
|
225
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
226
|
+
except (ValueError, TypeError):
|
|
227
|
+
return None
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Adapter converting OpenCode entries to unified models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from hub.adapters.base import BaseAdapter
|
|
8
|
+
from hub.models.base import (
|
|
9
|
+
MessageRole, Provider, TokenUsage, ToolCall, UnifiedEvent, UnifiedMessage,
|
|
10
|
+
)
|
|
11
|
+
from hub.models.opencode import OpenCodeEntry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OpenCodeAdapter(BaseAdapter):
|
|
15
|
+
|
|
16
|
+
def to_unified(self, entry: OpenCodeEntry, project: str) -> UnifiedMessage | None:
|
|
17
|
+
role = self._map_role(entry)
|
|
18
|
+
if role is None:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
tool_calls = self._extract_tool_calls(entry)
|
|
22
|
+
timestamp = self._parse_timestamp(entry.timestamp)
|
|
23
|
+
tokens = self._extract_tokens(entry)
|
|
24
|
+
|
|
25
|
+
return UnifiedMessage(
|
|
26
|
+
id=entry.message_id or entry.session_id,
|
|
27
|
+
provider=Provider.OPENCODE,
|
|
28
|
+
session_id=entry.session_id,
|
|
29
|
+
project=project,
|
|
30
|
+
role=role,
|
|
31
|
+
text=entry.text,
|
|
32
|
+
tool_calls=tool_calls,
|
|
33
|
+
timestamp=timestamp,
|
|
34
|
+
model=entry.model_id or None,
|
|
35
|
+
tokens=tokens,
|
|
36
|
+
cwd=entry.cwd or None,
|
|
37
|
+
raw=entry.raw,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def to_event(self, entry: OpenCodeEntry, project: str) -> UnifiedEvent | None:
|
|
41
|
+
role = self._map_role(entry)
|
|
42
|
+
if role is None:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
tool_name = None
|
|
46
|
+
file_path = None
|
|
47
|
+
if entry.tool_call:
|
|
48
|
+
tool_name = entry.tool_call.name
|
|
49
|
+
file_path = (entry.tool_call.input_data.get("path", "")
|
|
50
|
+
or entry.tool_call.input_data.get("command", ""))[:80]
|
|
51
|
+
|
|
52
|
+
text = entry.text.strip().replace("\n", " ")
|
|
53
|
+
summary = text[:120] if text else f"[{entry.part_type}]"
|
|
54
|
+
|
|
55
|
+
parsed_ts = self._parse_timestamp(entry.timestamp)
|
|
56
|
+
ts_str = parsed_ts.isoformat() if parsed_ts else str(entry.timestamp)
|
|
57
|
+
|
|
58
|
+
return UnifiedEvent(
|
|
59
|
+
provider=Provider.OPENCODE,
|
|
60
|
+
project=project,
|
|
61
|
+
event_type=role.value,
|
|
62
|
+
timestamp=ts_str,
|
|
63
|
+
summary=summary,
|
|
64
|
+
session_id=entry.session_id or None,
|
|
65
|
+
tool_name=tool_name,
|
|
66
|
+
file_path=file_path if file_path else None,
|
|
67
|
+
cwd=entry.cwd or None,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def _map_role(self, entry: OpenCodeEntry) -> MessageRole | None:
|
|
71
|
+
match entry.part_type:
|
|
72
|
+
case "text":
|
|
73
|
+
if entry.role == "user":
|
|
74
|
+
return MessageRole.USER
|
|
75
|
+
return MessageRole.ASSISTANT
|
|
76
|
+
case "reasoning":
|
|
77
|
+
return MessageRole.THINKING
|
|
78
|
+
case "tool" | "file" | "patch":
|
|
79
|
+
return MessageRole.TOOL_USE
|
|
80
|
+
case "step-finish":
|
|
81
|
+
return MessageRole.SUMMARY
|
|
82
|
+
case "compaction":
|
|
83
|
+
return MessageRole.SUMMARY
|
|
84
|
+
case _:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _extract_tokens(entry: OpenCodeEntry) -> TokenUsage | None:
|
|
89
|
+
if entry.part_type != "step-finish":
|
|
90
|
+
return None
|
|
91
|
+
total = entry.token_input + entry.token_output + entry.token_reasoning
|
|
92
|
+
if total == 0:
|
|
93
|
+
return None
|
|
94
|
+
return TokenUsage(
|
|
95
|
+
input_tokens=entry.token_input,
|
|
96
|
+
output_tokens=entry.token_output + entry.token_reasoning,
|
|
97
|
+
cache_creation=entry.token_cache_write,
|
|
98
|
+
cache_read=entry.token_cache_read,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _extract_tool_calls(self, entry: OpenCodeEntry) -> list[ToolCall]:
|
|
102
|
+
if not entry.tool_call:
|
|
103
|
+
return []
|
|
104
|
+
return [
|
|
105
|
+
ToolCall(
|
|
106
|
+
name=entry.tool_call.name,
|
|
107
|
+
input_data=entry.tool_call.input_data,
|
|
108
|
+
tool_id=entry.tool_call.tool_id,
|
|
109
|
+
output_data=entry.tool_call.output_data or None,
|
|
110
|
+
file_path=entry.files_affected[0] if entry.files_affected else None,
|
|
111
|
+
operation_type=self._classify_operation(entry.tool_call.name),
|
|
112
|
+
)
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _classify_operation(tool_name: str) -> str:
|
|
117
|
+
match tool_name:
|
|
118
|
+
case "read" | "file_read":
|
|
119
|
+
return "read"
|
|
120
|
+
case "write" | "file_edit" | "edit":
|
|
121
|
+
return "write"
|
|
122
|
+
case "bash":
|
|
123
|
+
return "exec"
|
|
124
|
+
case "glob" | "list" | "grep":
|
|
125
|
+
return "search"
|
|
126
|
+
case _:
|
|
127
|
+
return "other"
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _parse_timestamp(ts: str | int | float) -> datetime | None:
|
|
131
|
+
if not ts:
|
|
132
|
+
return None
|
|
133
|
+
if isinstance(ts, (int, float)):
|
|
134
|
+
try:
|
|
135
|
+
if ts > 1e12:
|
|
136
|
+
ts = ts / 1000
|
|
137
|
+
return datetime.fromtimestamp(ts, tz=datetime.now().astimezone().tzinfo)
|
|
138
|
+
except (ValueError, TypeError, OSError):
|
|
139
|
+
return None
|
|
140
|
+
try:
|
|
141
|
+
return datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
|
|
142
|
+
except (ValueError, TypeError):
|
|
143
|
+
return None
|