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.
Files changed (78) hide show
  1. hub/__init__.py +4 -0
  2. hub/__main__.py +7 -0
  3. hub/adapters/__init__.py +3 -0
  4. hub/adapters/base.py +27 -0
  5. hub/adapters/claude_adapter.py +238 -0
  6. hub/adapters/codex_adapter.py +227 -0
  7. hub/adapters/opencode_adapter.py +143 -0
  8. hub/adapters/qwen_adapter.py +201 -0
  9. hub/analyzers/__init__.py +19 -0
  10. hub/analyzers/base.py +37 -0
  11. hub/analyzers/cross_provider.py +58 -0
  12. hub/analyzers/efficiency.py +141 -0
  13. hub/analyzers/file_ops.py +106 -0
  14. hub/analyzers/qa.py +188 -0
  15. hub/analyzers/session_timeline.py +73 -0
  16. hub/analyzers/summary.py +126 -0
  17. hub/analyzers/user_messages.py +71 -0
  18. hub/backfill.py +22 -0
  19. hub/batch_reporter.py +316 -0
  20. hub/cache/__init__.py +0 -0
  21. hub/cache/event_store.py +717 -0
  22. hub/cache/git_store.py +928 -0
  23. hub/cli.py +614 -0
  24. hub/colors.py +37 -0
  25. hub/config.py +275 -0
  26. hub/correlation/__init__.py +1 -0
  27. hub/correlation/linker.py +162 -0
  28. hub/daemon.py +132 -0
  29. hub/dashboard/__init__.py +0 -0
  30. hub/dashboard/server.py +901 -0
  31. hub/dashboard/static/analytics.html +480 -0
  32. hub/dashboard/static/dashboard.html +772 -0
  33. hub/dashboard/static/projects.html +503 -0
  34. hub/dashboard/static/timeline.html +1033 -0
  35. hub/digests/__init__.py +4 -0
  36. hub/digests/engine.py +245 -0
  37. hub/digests/llm.py +181 -0
  38. hub/digests/stats.py +166 -0
  39. hub/digests/template.py +284 -0
  40. hub/discovery.py +433 -0
  41. hub/git_utils.py +151 -0
  42. hub/harvesters/__init__.py +4 -0
  43. hub/harvesters/git_harvester.py +279 -0
  44. hub/harvesters/github_harvester.py +225 -0
  45. hub/integrations/__init__.py +23 -0
  46. hub/integrations/github_client.py +265 -0
  47. hub/integrations/ollama_client.py +88 -0
  48. hub/integrations/openai_compat_client.py +81 -0
  49. hub/log.py +62 -0
  50. hub/mcp_server.py +363 -0
  51. hub/models/__init__.py +17 -0
  52. hub/models/base.py +114 -0
  53. hub/models/claude.py +57 -0
  54. hub/models/codex.py +54 -0
  55. hub/models/opencode.py +40 -0
  56. hub/models/qwen.py +59 -0
  57. hub/parsers/__init__.py +3 -0
  58. hub/parsers/base.py +29 -0
  59. hub/parsers/claude_parser.py +167 -0
  60. hub/parsers/codex_parser.py +247 -0
  61. hub/parsers/opencode_parser.py +237 -0
  62. hub/parsers/qwen_parser.py +191 -0
  63. hub/renderers/__init__.py +3 -0
  64. hub/renderers/markdown.py +97 -0
  65. hub/watchers/__init__.py +3 -0
  66. hub/watchers/base.py +149 -0
  67. hub/watchers/claude_watcher.py +63 -0
  68. hub/watchers/codex_watcher.py +58 -0
  69. hub/watchers/kqueue_watcher.py +94 -0
  70. hub/watchers/opencode_watcher.py +57 -0
  71. hub/watchers/polling_watcher.py +70 -0
  72. hub/watchers/qwen_watcher.py +63 -0
  73. moolmesh-1.4.0.dist-info/METADATA +25 -0
  74. moolmesh-1.4.0.dist-info/RECORD +78 -0
  75. moolmesh-1.4.0.dist-info/WHEEL +5 -0
  76. moolmesh-1.4.0.dist-info/entry_points.txt +2 -0
  77. moolmesh-1.4.0.dist-info/licenses/LICENSE +21 -0
  78. moolmesh-1.4.0.dist-info/top_level.txt +1 -0
hub/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """MoolMesh — the context mesh for AI coding agents."""
2
+
3
+ __version__ = "1.4.0"
4
+ USER_AGENT = f"moolmesh/{__version__}"
hub/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow `python -m hub` as a fallback entry point."""
2
+
3
+ from hub.cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -0,0 +1,3 @@
1
+ from .base import BaseAdapter
2
+
3
+ __all__ = ["BaseAdapter"]
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