opencode-log 0.3.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 (35) hide show
  1. opencode_log/__init__.py +3 -0
  2. opencode_log/cache.py +240 -0
  3. opencode_log/cli.py +565 -0
  4. opencode_log/markdown.py +197 -0
  5. opencode_log/models.py +131 -0
  6. opencode_log/normalizer.py +146 -0
  7. opencode_log/render.py +360 -0
  8. opencode_log/storage.py +271 -0
  9. opencode_log/templates/combined.html +345 -0
  10. opencode_log/templates/components/base_styles.css +126 -0
  11. opencode_log/templates/components/edit_diff_styles.css +76 -0
  12. opencode_log/templates/components/filter_styles.css +168 -0
  13. opencode_log/templates/components/global_styles.css +237 -0
  14. opencode_log/templates/components/message_styles.css +1057 -0
  15. opencode_log/templates/components/page_nav_styles.css +79 -0
  16. opencode_log/templates/components/project_card_styles.css +138 -0
  17. opencode_log/templates/components/pygments_styles.css +218 -0
  18. opencode_log/templates/components/search.html +774 -0
  19. opencode_log/templates/components/search_inline.html +29 -0
  20. opencode_log/templates/components/search_inline_script.html +3 -0
  21. opencode_log/templates/components/search_results_panel.html +10 -0
  22. opencode_log/templates/components/search_styles.css +371 -0
  23. opencode_log/templates/components/session_nav.html +39 -0
  24. opencode_log/templates/components/session_nav_styles.css +106 -0
  25. opencode_log/templates/components/timeline.html +493 -0
  26. opencode_log/templates/components/timeline_styles.css +151 -0
  27. opencode_log/templates/components/timezone_converter.js +115 -0
  28. opencode_log/templates/components/todo_styles.css +186 -0
  29. opencode_log/templates/index.html +308 -0
  30. opencode_log/templates/transcript.html +372 -0
  31. opencode_log-0.3.0.dist-info/METADATA +519 -0
  32. opencode_log-0.3.0.dist-info/RECORD +35 -0
  33. opencode_log-0.3.0.dist-info/WHEEL +4 -0
  34. opencode_log-0.3.0.dist-info/entry_points.txt +2 -0
  35. opencode_log-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.3.0"
opencode_log/cache.py ADDED
@@ -0,0 +1,240 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .models import Message, Session, SessionDiffItem, SessionInfo, TodoItem
8
+
9
+ CACHE_VERSION = 2
10
+
11
+
12
+ def _session_to_dict(session: Session) -> dict[str, Any]:
13
+ info = session.info
14
+ return {
15
+ "info": {
16
+ "id": info.id,
17
+ "project_id": info.project_id,
18
+ "directory": info.directory,
19
+ "title": info.title,
20
+ "slug": info.slug,
21
+ "version": info.version,
22
+ "created_ms": info.created_ms,
23
+ "updated_ms": info.updated_ms,
24
+ "additions": info.additions,
25
+ "deletions": info.deletions,
26
+ "files": info.files,
27
+ },
28
+ "messages": [
29
+ {
30
+ "id": m.id,
31
+ "session_id": m.session_id,
32
+ "role": m.role,
33
+ "created_ms": m.created_ms,
34
+ "completed_ms": m.completed_ms,
35
+ "model": m.model,
36
+ "provider": m.provider,
37
+ "mode": m.mode,
38
+ "agent": m.agent,
39
+ "cost": m.cost,
40
+ "tokens_input": m.tokens_input,
41
+ "tokens_output": m.tokens_output,
42
+ "tokens_reasoning": m.tokens_reasoning,
43
+ "tokens_cache_read": m.tokens_cache_read,
44
+ "tokens_cache_write": m.tokens_cache_write,
45
+ "finish": m.finish,
46
+ "error": m.error,
47
+ "parts": m.parts,
48
+ }
49
+ for m in session.messages
50
+ ],
51
+ "todos": [
52
+ {
53
+ "id": item.id,
54
+ "content": item.content,
55
+ "status": item.status,
56
+ "priority": item.priority,
57
+ }
58
+ for item in session.todos
59
+ ],
60
+ "diffs": [
61
+ {
62
+ "file": item.file,
63
+ "status": item.status,
64
+ "additions": item.additions,
65
+ "deletions": item.deletions,
66
+ }
67
+ for item in session.diffs
68
+ ],
69
+ }
70
+
71
+
72
+ def _session_from_dict(data: dict[str, Any]) -> Session | None:
73
+ info_data = data.get("info")
74
+ if not isinstance(info_data, dict):
75
+ return None
76
+
77
+ info = SessionInfo(
78
+ id=str(info_data.get("id", "")),
79
+ project_id=str(info_data.get("project_id", "")),
80
+ directory=str(info_data.get("directory", "")),
81
+ title=str(info_data.get("title", "Untitled Session")),
82
+ slug=info_data.get("slug"),
83
+ version=info_data.get("version"),
84
+ created_ms=info_data.get("created_ms"),
85
+ updated_ms=info_data.get("updated_ms"),
86
+ additions=int(info_data.get("additions", 0) or 0),
87
+ deletions=int(info_data.get("deletions", 0) or 0),
88
+ files=int(info_data.get("files", 0) or 0),
89
+ )
90
+
91
+ raw_messages = data.get("messages")
92
+ if not isinstance(raw_messages, list):
93
+ return Session(info=info, messages=[])
94
+
95
+ messages: list[Message] = []
96
+ for item in raw_messages:
97
+ if not isinstance(item, dict):
98
+ continue
99
+ raw_parts = item.get("parts")
100
+ parts = raw_parts if isinstance(raw_parts, list) else []
101
+ messages.append(
102
+ Message(
103
+ id=str(item.get("id", "")),
104
+ session_id=str(item.get("session_id", info.id)),
105
+ role=str(item.get("role", "unknown")),
106
+ created_ms=item.get("created_ms"),
107
+ completed_ms=item.get("completed_ms"),
108
+ model=item.get("model"),
109
+ provider=item.get("provider"),
110
+ mode=item.get("mode"),
111
+ agent=item.get("agent"),
112
+ cost=float(item.get("cost", 0.0) or 0.0),
113
+ tokens_input=int(item.get("tokens_input", 0) or 0),
114
+ tokens_output=int(item.get("tokens_output", 0) or 0),
115
+ tokens_reasoning=int(item.get("tokens_reasoning", 0) or 0),
116
+ tokens_cache_read=int(item.get("tokens_cache_read", 0) or 0),
117
+ tokens_cache_write=int(item.get("tokens_cache_write", 0) or 0),
118
+ finish=item.get("finish"),
119
+ error=item.get("error"),
120
+ parts=parts,
121
+ )
122
+ )
123
+ todos: list[TodoItem] = []
124
+ for item in data.get("todos", []):
125
+ if not isinstance(item, dict):
126
+ continue
127
+ todos.append(
128
+ TodoItem(
129
+ id=str(item.get("id", "")),
130
+ content=str(item.get("content", "")),
131
+ status=str(item.get("status", "pending")),
132
+ priority=str(item.get("priority", "medium")),
133
+ )
134
+ )
135
+
136
+ diffs: list[SessionDiffItem] = []
137
+ for item in data.get("diffs", []):
138
+ if not isinstance(item, dict):
139
+ continue
140
+ diffs.append(
141
+ SessionDiffItem(
142
+ file=str(item.get("file", "")),
143
+ status=str(item.get("status", "modified")),
144
+ additions=int(item.get("additions", 0) or 0),
145
+ deletions=int(item.get("deletions", 0) or 0),
146
+ )
147
+ )
148
+
149
+ return Session(info=info, messages=messages, todos=todos, diffs=diffs)
150
+
151
+
152
+ class CacheManager:
153
+ def __init__(self, cache_dir: Path):
154
+ self.cache_dir = cache_dir
155
+ self.sessions_dir = cache_dir / "sessions"
156
+ self.state_path = cache_dir / "state.json"
157
+ self.data: dict[str, Any] = {
158
+ "version": CACHE_VERSION,
159
+ "session_cache": {},
160
+ "render_cache": {},
161
+ }
162
+ self._load()
163
+
164
+ def _load(self) -> None:
165
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
166
+ self.sessions_dir.mkdir(parents=True, exist_ok=True)
167
+ if not self.state_path.exists():
168
+ return
169
+ try:
170
+ payload = json.loads(self.state_path.read_text(encoding="utf-8"))
171
+ if isinstance(payload, dict) and payload.get("version") == CACHE_VERSION:
172
+ self.data = payload
173
+ except Exception:
174
+ self.data = {
175
+ "version": CACHE_VERSION,
176
+ "session_cache": {},
177
+ "render_cache": {},
178
+ }
179
+
180
+ def save(self) -> None:
181
+ self.state_path.write_text(
182
+ json.dumps(self.data, ensure_ascii=False, indent=2),
183
+ encoding="utf-8",
184
+ )
185
+
186
+ def clear(self) -> None:
187
+ self.data = {
188
+ "version": CACHE_VERSION,
189
+ "session_cache": {},
190
+ "render_cache": {},
191
+ }
192
+ self.save()
193
+
194
+ def get_session(self, session_id: str, updated_ms: int | None) -> Session | None:
195
+ entries = self.data.get("session_cache")
196
+ if not isinstance(entries, dict):
197
+ return None
198
+ entry = entries.get(session_id)
199
+ if not isinstance(entry, dict):
200
+ return None
201
+ if entry.get("updated_ms") != updated_ms:
202
+ return None
203
+ path = self.sessions_dir / f"{session_id}.json"
204
+ if not path.exists():
205
+ return None
206
+ try:
207
+ raw = json.loads(path.read_text(encoding="utf-8"))
208
+ if not isinstance(raw, dict):
209
+ return None
210
+ return _session_from_dict(raw)
211
+ except Exception:
212
+ return None
213
+
214
+ def set_session(self, session: Session) -> None:
215
+ path = self.sessions_dir / f"{session.info.id}.json"
216
+ path.write_text(
217
+ json.dumps(_session_to_dict(session), ensure_ascii=False),
218
+ encoding="utf-8",
219
+ )
220
+ entries = self.data.setdefault("session_cache", {})
221
+ if isinstance(entries, dict):
222
+ entries[session.info.id] = {
223
+ "updated_ms": session.info.updated_ms,
224
+ }
225
+
226
+ def should_render(self, key: str, signature: str, out_path: Path) -> bool:
227
+ cache = self.data.get("render_cache")
228
+ if not isinstance(cache, dict):
229
+ return True
230
+ existing = cache.get(key)
231
+ if not out_path.exists():
232
+ return True
233
+ if not isinstance(existing, dict):
234
+ return True
235
+ return existing.get("signature") != signature
236
+
237
+ def mark_rendered(self, key: str, signature: str) -> None:
238
+ cache = self.data.setdefault("render_cache", {})
239
+ if isinstance(cache, dict):
240
+ cache[key] = {"signature": signature}