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.
- opencode_log/__init__.py +3 -0
- opencode_log/cache.py +240 -0
- opencode_log/cli.py +565 -0
- opencode_log/markdown.py +197 -0
- opencode_log/models.py +131 -0
- opencode_log/normalizer.py +146 -0
- opencode_log/render.py +360 -0
- opencode_log/storage.py +271 -0
- opencode_log/templates/combined.html +345 -0
- opencode_log/templates/components/base_styles.css +126 -0
- opencode_log/templates/components/edit_diff_styles.css +76 -0
- opencode_log/templates/components/filter_styles.css +168 -0
- opencode_log/templates/components/global_styles.css +237 -0
- opencode_log/templates/components/message_styles.css +1057 -0
- opencode_log/templates/components/page_nav_styles.css +79 -0
- opencode_log/templates/components/project_card_styles.css +138 -0
- opencode_log/templates/components/pygments_styles.css +218 -0
- opencode_log/templates/components/search.html +774 -0
- opencode_log/templates/components/search_inline.html +29 -0
- opencode_log/templates/components/search_inline_script.html +3 -0
- opencode_log/templates/components/search_results_panel.html +10 -0
- opencode_log/templates/components/search_styles.css +371 -0
- opencode_log/templates/components/session_nav.html +39 -0
- opencode_log/templates/components/session_nav_styles.css +106 -0
- opencode_log/templates/components/timeline.html +493 -0
- opencode_log/templates/components/timeline_styles.css +151 -0
- opencode_log/templates/components/timezone_converter.js +115 -0
- opencode_log/templates/components/todo_styles.css +186 -0
- opencode_log/templates/index.html +308 -0
- opencode_log/templates/transcript.html +372 -0
- opencode_log-0.3.0.dist-info/METADATA +519 -0
- opencode_log-0.3.0.dist-info/RECORD +35 -0
- opencode_log-0.3.0.dist-info/WHEEL +4 -0
- opencode_log-0.3.0.dist-info/entry_points.txt +2 -0
- opencode_log-0.3.0.dist-info/licenses/LICENSE +21 -0
opencode_log/render.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
10
|
+
|
|
11
|
+
from .models import Message, Project, Session, format_local_time
|
|
12
|
+
from .storage import safe_slug
|
|
13
|
+
|
|
14
|
+
RENDER_VERSION = "0.4"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RenderedPart:
|
|
19
|
+
kind: str
|
|
20
|
+
html: str
|
|
21
|
+
css_class: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class RenderedMessage:
|
|
26
|
+
id: str
|
|
27
|
+
role: str
|
|
28
|
+
role_label: str
|
|
29
|
+
css_class: str
|
|
30
|
+
created_text: str
|
|
31
|
+
created_ms: int
|
|
32
|
+
mode: str | None
|
|
33
|
+
agent: str | None
|
|
34
|
+
model: str | None
|
|
35
|
+
provider: str | None
|
|
36
|
+
finish: str | None
|
|
37
|
+
error: str | None
|
|
38
|
+
token_text: str
|
|
39
|
+
cost_text: str
|
|
40
|
+
has_tool: bool
|
|
41
|
+
has_reasoning: bool
|
|
42
|
+
search_blob: str
|
|
43
|
+
parts: list[RenderedPart]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _env() -> Environment:
|
|
47
|
+
template_dir = Path(__file__).parent / "templates"
|
|
48
|
+
return Environment(
|
|
49
|
+
loader=FileSystemLoader(template_dir),
|
|
50
|
+
autoescape=select_autoescape(["html"]),
|
|
51
|
+
trim_blocks=True,
|
|
52
|
+
lstrip_blocks=True,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _json_code_block(data: Any) -> str:
|
|
57
|
+
text = json.dumps(data, ensure_ascii=False, indent=2)
|
|
58
|
+
return f"<pre>{html.escape(text)}</pre>"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _render_part(part: dict[str, Any]) -> RenderedPart:
|
|
62
|
+
part_type = str(part.get("type", "unknown"))
|
|
63
|
+
|
|
64
|
+
if part_type == "text":
|
|
65
|
+
text = part.get("text", "")
|
|
66
|
+
return RenderedPart(
|
|
67
|
+
kind="text",
|
|
68
|
+
css_class="part-text",
|
|
69
|
+
html=f"<pre>{html.escape(str(text))}</pre>",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if part_type == "reasoning":
|
|
73
|
+
text = part.get("text", "")
|
|
74
|
+
content = "<details class='collapsible-details'><summary>Reasoning</summary><pre>{}</pre></details>".format(
|
|
75
|
+
html.escape(str(text))
|
|
76
|
+
)
|
|
77
|
+
return RenderedPart(kind="reasoning", css_class="part-reasoning", html=content)
|
|
78
|
+
|
|
79
|
+
if part_type == "tool":
|
|
80
|
+
tool_name = str(part.get("tool", "tool"))
|
|
81
|
+
state = part.get("state") or {}
|
|
82
|
+
status = state.get("status", "unknown")
|
|
83
|
+
blocks = [
|
|
84
|
+
f"<details class='collapsible-details part-tool-block'><summary>Tool: {html.escape(tool_name)} ({html.escape(str(status))})</summary>"
|
|
85
|
+
]
|
|
86
|
+
for key in ("title", "metadata", "input", "output", "error"):
|
|
87
|
+
value = state.get(key)
|
|
88
|
+
if value in (None, "", {}, []):
|
|
89
|
+
continue
|
|
90
|
+
blocks.append(
|
|
91
|
+
f"<div><strong>{html.escape(key)}</strong>{_json_code_block(value)}</div>"
|
|
92
|
+
)
|
|
93
|
+
blocks.append("</details>")
|
|
94
|
+
return RenderedPart(kind="tool", css_class="part-tool", html="".join(blocks))
|
|
95
|
+
|
|
96
|
+
if part_type in {
|
|
97
|
+
"step-start",
|
|
98
|
+
"step-finish",
|
|
99
|
+
"compaction",
|
|
100
|
+
"agent",
|
|
101
|
+
"patch",
|
|
102
|
+
"file",
|
|
103
|
+
"subtask",
|
|
104
|
+
"snapshot",
|
|
105
|
+
"retry",
|
|
106
|
+
}:
|
|
107
|
+
return RenderedPart(
|
|
108
|
+
kind=part_type,
|
|
109
|
+
css_class=f"part-{part_type}",
|
|
110
|
+
html=(
|
|
111
|
+
f"<details class='collapsible-details'><summary>{html.escape(part_type)}</summary>"
|
|
112
|
+
f"{_json_code_block(part)}</details>"
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return RenderedPart(
|
|
117
|
+
kind=part_type,
|
|
118
|
+
css_class="part-unknown",
|
|
119
|
+
html=f"<details class='collapsible-details'><summary>{html.escape(part_type)}</summary>{_json_code_block(part)}</details>",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _token_text(message: Message) -> str:
|
|
124
|
+
parts = [
|
|
125
|
+
f"in {message.tokens_input:,}",
|
|
126
|
+
f"out {message.tokens_output:,}",
|
|
127
|
+
]
|
|
128
|
+
if message.tokens_reasoning:
|
|
129
|
+
parts.append(f"reason {message.tokens_reasoning:,}")
|
|
130
|
+
if message.tokens_cache_read:
|
|
131
|
+
parts.append(f"cache r {message.tokens_cache_read:,}")
|
|
132
|
+
if message.tokens_cache_write:
|
|
133
|
+
parts.append(f"cache w {message.tokens_cache_write:,}")
|
|
134
|
+
return " | ".join(parts)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _build_search_blob(message: Message) -> str:
|
|
138
|
+
chunks: list[str] = [
|
|
139
|
+
message.role,
|
|
140
|
+
message.mode or "",
|
|
141
|
+
message.agent or "",
|
|
142
|
+
message.model or "",
|
|
143
|
+
message.provider or "",
|
|
144
|
+
]
|
|
145
|
+
for part in message.parts:
|
|
146
|
+
part_type = str(part.get("type", ""))
|
|
147
|
+
chunks.append(part_type)
|
|
148
|
+
if part_type in {"text", "reasoning"}:
|
|
149
|
+
chunks.append(str(part.get("text", "")))
|
|
150
|
+
if part_type == "tool":
|
|
151
|
+
chunks.append(str(part.get("tool", "")))
|
|
152
|
+
state = part.get("state") or {}
|
|
153
|
+
if isinstance(state, dict):
|
|
154
|
+
for key in ("title", "status"):
|
|
155
|
+
chunks.append(str(state.get(key, "")))
|
|
156
|
+
return " ".join(chunks).lower()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _render_message(message: Message) -> RenderedMessage:
|
|
160
|
+
role = message.role
|
|
161
|
+
role_label = role.capitalize()
|
|
162
|
+
css_class = role if role in {"user", "assistant"} else "unknown"
|
|
163
|
+
|
|
164
|
+
rendered_parts = [_render_part(p) for p in message.parts]
|
|
165
|
+
kinds = {p.kind for p in rendered_parts}
|
|
166
|
+
created_ms = int(message.created_ms or 0)
|
|
167
|
+
|
|
168
|
+
return RenderedMessage(
|
|
169
|
+
id=message.id,
|
|
170
|
+
role=role,
|
|
171
|
+
role_label=role_label,
|
|
172
|
+
css_class=css_class,
|
|
173
|
+
created_text=format_local_time(message.created_ms),
|
|
174
|
+
created_ms=created_ms,
|
|
175
|
+
mode=message.mode,
|
|
176
|
+
agent=message.agent,
|
|
177
|
+
model=message.model,
|
|
178
|
+
provider=message.provider,
|
|
179
|
+
finish=message.finish,
|
|
180
|
+
error=message.error,
|
|
181
|
+
token_text=_token_text(message),
|
|
182
|
+
cost_text=f"{message.cost:.6f}",
|
|
183
|
+
has_tool="tool" in kinds,
|
|
184
|
+
has_reasoning="reasoning" in kinds,
|
|
185
|
+
search_blob=_build_search_blob(message),
|
|
186
|
+
parts=rendered_parts,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def session_render_signature(session: Session) -> str:
|
|
191
|
+
return "|".join(
|
|
192
|
+
[
|
|
193
|
+
RENDER_VERSION,
|
|
194
|
+
session.info.id,
|
|
195
|
+
str(session.info.updated_ms or 0),
|
|
196
|
+
str(session.message_count),
|
|
197
|
+
str(len(session.todos)),
|
|
198
|
+
str(len(session.diffs)),
|
|
199
|
+
str(session.total_input_tokens),
|
|
200
|
+
str(session.total_output_tokens),
|
|
201
|
+
f"{session.total_cost:.6f}",
|
|
202
|
+
]
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def combined_render_signature(project: Project, sessions: list[Session]) -> str:
|
|
207
|
+
session_sig = ",".join(session_render_signature(s) for s in sessions)
|
|
208
|
+
return f"{RENDER_VERSION}|combined|{project.id}|{session_sig}"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def index_render_signature(
|
|
212
|
+
projects: list[Project], sessions_by_project: dict[str, list[Session]]
|
|
213
|
+
) -> str:
|
|
214
|
+
blocks: list[str] = [RENDER_VERSION, "index"]
|
|
215
|
+
for project in sorted(projects, key=lambda p: p.id):
|
|
216
|
+
sessions = sessions_by_project.get(project.id, [])
|
|
217
|
+
if not sessions:
|
|
218
|
+
continue
|
|
219
|
+
blocks.append(project.id)
|
|
220
|
+
blocks.extend(session_render_signature(s) for s in sessions)
|
|
221
|
+
return "|".join(blocks)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def render_session_page(
|
|
225
|
+
session: Session,
|
|
226
|
+
out_file: Path,
|
|
227
|
+
project_name: str,
|
|
228
|
+
back_link: str,
|
|
229
|
+
session_links: list[dict[str, str]],
|
|
230
|
+
) -> None:
|
|
231
|
+
env = _env()
|
|
232
|
+
template = env.get_template("transcript.html")
|
|
233
|
+
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
|
|
235
|
+
html_content = template.render(
|
|
236
|
+
title=session.info.title,
|
|
237
|
+
project_name=project_name,
|
|
238
|
+
session=session,
|
|
239
|
+
session_created_text=format_local_time(session.info.created_ms),
|
|
240
|
+
session_updated_text=format_local_time(session.info.updated_ms),
|
|
241
|
+
rendered_messages=[_render_message(m) for m in session.messages],
|
|
242
|
+
session_links=session_links,
|
|
243
|
+
back_link=back_link,
|
|
244
|
+
)
|
|
245
|
+
out_file.write_text(html_content, encoding="utf-8")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def render_combined_page(
|
|
249
|
+
project: Project,
|
|
250
|
+
sessions: list[Session],
|
|
251
|
+
out_file: Path,
|
|
252
|
+
back_link: str,
|
|
253
|
+
) -> None:
|
|
254
|
+
env = _env()
|
|
255
|
+
template = env.get_template("combined.html")
|
|
256
|
+
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
257
|
+
|
|
258
|
+
rendered_sessions: list[dict[str, Any]] = []
|
|
259
|
+
session_links: list[dict[str, str]] = []
|
|
260
|
+
for session in sessions:
|
|
261
|
+
anchor = f"ses-{session.info.id}"
|
|
262
|
+
session_links.append(
|
|
263
|
+
{
|
|
264
|
+
"id": session.info.id,
|
|
265
|
+
"title": session.info.title,
|
|
266
|
+
"anchor": anchor,
|
|
267
|
+
"file": f"session-{session.info.id}.html",
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
rendered_sessions.append(
|
|
271
|
+
{
|
|
272
|
+
"info": session.info,
|
|
273
|
+
"anchor": anchor,
|
|
274
|
+
"created_text": format_local_time(session.info.created_ms),
|
|
275
|
+
"updated_text": format_local_time(session.info.updated_ms),
|
|
276
|
+
"rendered_messages": [_render_message(m) for m in session.messages],
|
|
277
|
+
"message_count": session.message_count,
|
|
278
|
+
"tool_call_count": session.tool_call_count,
|
|
279
|
+
"total_input_tokens": session.total_input_tokens,
|
|
280
|
+
"total_output_tokens": session.total_output_tokens,
|
|
281
|
+
"total_reasoning_tokens": session.total_reasoning_tokens,
|
|
282
|
+
"total_cost": session.total_cost,
|
|
283
|
+
}
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
html_content = template.render(
|
|
287
|
+
title=f"{project.display_name} - Combined Transcripts",
|
|
288
|
+
project=project,
|
|
289
|
+
sessions=rendered_sessions,
|
|
290
|
+
session_links=session_links,
|
|
291
|
+
back_link=back_link,
|
|
292
|
+
)
|
|
293
|
+
out_file.write_text(html_content, encoding="utf-8")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def render_index_page(
|
|
297
|
+
output_root: Path,
|
|
298
|
+
projects: list[Project],
|
|
299
|
+
sessions_by_project: dict[str, list[Session]],
|
|
300
|
+
) -> Path:
|
|
301
|
+
env = _env()
|
|
302
|
+
template = env.get_template("index.html")
|
|
303
|
+
|
|
304
|
+
project_cards: list[dict[str, Any]] = []
|
|
305
|
+
total_sessions = 0
|
|
306
|
+
total_messages = 0
|
|
307
|
+
total_cost = 0.0
|
|
308
|
+
total_in_tokens = 0
|
|
309
|
+
total_out_tokens = 0
|
|
310
|
+
|
|
311
|
+
for project in projects:
|
|
312
|
+
sessions = sessions_by_project.get(project.id, [])
|
|
313
|
+
if not sessions:
|
|
314
|
+
continue
|
|
315
|
+
total_sessions += len(sessions)
|
|
316
|
+
|
|
317
|
+
project_slug = f"{safe_slug(project.display_name)}-{project.id[:8]}"
|
|
318
|
+
base = f"projects/{project_slug}"
|
|
319
|
+
summary = {
|
|
320
|
+
"id": project.id,
|
|
321
|
+
"name": project.display_name,
|
|
322
|
+
"worktree": project.worktree,
|
|
323
|
+
"session_count": len(sessions),
|
|
324
|
+
"combined_link": f"{base}/combined_transcripts.html",
|
|
325
|
+
"sessions": [],
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
for s in sessions:
|
|
329
|
+
total_messages += s.message_count
|
|
330
|
+
total_cost += s.total_cost
|
|
331
|
+
total_in_tokens += s.total_input_tokens
|
|
332
|
+
total_out_tokens += s.total_output_tokens
|
|
333
|
+
summary["sessions"].append(
|
|
334
|
+
{
|
|
335
|
+
"id": s.info.id,
|
|
336
|
+
"title": s.info.title,
|
|
337
|
+
"updated": format_local_time(s.info.updated_ms),
|
|
338
|
+
"updated_ms": int(s.info.updated_ms or 0),
|
|
339
|
+
"message_count": s.message_count,
|
|
340
|
+
"tool_count": s.tool_call_count,
|
|
341
|
+
"cost": f"${s.total_cost:.6f}",
|
|
342
|
+
"token_summary": f"in {s.total_input_tokens:,} / out {s.total_output_tokens:,}",
|
|
343
|
+
"link": f"{base}/session-{s.info.id}.html",
|
|
344
|
+
}
|
|
345
|
+
)
|
|
346
|
+
project_cards.append(summary)
|
|
347
|
+
|
|
348
|
+
output_root.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
index_path = output_root / "index.html"
|
|
350
|
+
html_content = template.render(
|
|
351
|
+
title="OpenCode Log Index",
|
|
352
|
+
projects=project_cards,
|
|
353
|
+
total_projects=len(project_cards),
|
|
354
|
+
total_sessions=total_sessions,
|
|
355
|
+
total_messages=total_messages,
|
|
356
|
+
total_cost=f"${total_cost:.6f}",
|
|
357
|
+
total_token_summary=f"in {total_in_tokens:,} / out {total_out_tokens:,}",
|
|
358
|
+
)
|
|
359
|
+
index_path.write_text(html_content, encoding="utf-8")
|
|
360
|
+
return index_path
|
opencode_log/storage.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import dateparser
|
|
9
|
+
|
|
10
|
+
from .models import Message, Project, Session, SessionDiffItem, SessionInfo, TodoItem
|
|
11
|
+
from .normalizer import inspect_storage_schema, normalize_message
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .cache import CacheManager
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read_json_file(path: Path) -> Any | None:
|
|
18
|
+
if not path.exists():
|
|
19
|
+
return None
|
|
20
|
+
try:
|
|
21
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
22
|
+
return json.load(f)
|
|
23
|
+
except Exception:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_date_to_ms(text: str | None, end_of_day: bool = False) -> int | None:
|
|
28
|
+
if not text:
|
|
29
|
+
return None
|
|
30
|
+
dt = dateparser.parse(
|
|
31
|
+
text,
|
|
32
|
+
settings={"TIMEZONE": "UTC", "RETURN_AS_TIMEZONE_AWARE": True},
|
|
33
|
+
)
|
|
34
|
+
if dt is None:
|
|
35
|
+
return None
|
|
36
|
+
if end_of_day:
|
|
37
|
+
dt = dt.replace(hour=23, minute=59, second=59, microsecond=999000)
|
|
38
|
+
else:
|
|
39
|
+
dt = dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
40
|
+
return int(dt.timestamp() * 1000)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_projects(storage_dir: Path) -> list[Project]:
|
|
44
|
+
project_dir = storage_dir / "project"
|
|
45
|
+
projects: list[Project] = []
|
|
46
|
+
if not project_dir.exists():
|
|
47
|
+
return projects
|
|
48
|
+
|
|
49
|
+
for file in sorted(project_dir.glob("*.json")):
|
|
50
|
+
data = _read_json_file(file)
|
|
51
|
+
if not isinstance(data, dict):
|
|
52
|
+
continue
|
|
53
|
+
time_data = data.get("time") or {}
|
|
54
|
+
projects.append(
|
|
55
|
+
Project(
|
|
56
|
+
id=str(data.get("id", "")),
|
|
57
|
+
worktree=str(data.get("worktree", "")),
|
|
58
|
+
vcs=data.get("vcs"),
|
|
59
|
+
created_ms=time_data.get("created"),
|
|
60
|
+
updated_ms=time_data.get("updated"),
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
return projects
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _load_project_sessions(storage_dir: Path, project_id: str) -> list[SessionInfo]:
|
|
67
|
+
session_root = storage_dir / "session" / project_id
|
|
68
|
+
infos: list[SessionInfo] = []
|
|
69
|
+
if not session_root.exists():
|
|
70
|
+
return infos
|
|
71
|
+
|
|
72
|
+
for file in sorted(session_root.glob("ses_*.json")):
|
|
73
|
+
data = _read_json_file(file)
|
|
74
|
+
if not isinstance(data, dict):
|
|
75
|
+
continue
|
|
76
|
+
t = data.get("time") or {}
|
|
77
|
+
s = data.get("summary") or {}
|
|
78
|
+
infos.append(
|
|
79
|
+
SessionInfo(
|
|
80
|
+
id=str(data.get("id", "")),
|
|
81
|
+
project_id=str(data.get("projectID", project_id)),
|
|
82
|
+
directory=str(data.get("directory", "")),
|
|
83
|
+
title=str(data.get("title", "Untitled Session")),
|
|
84
|
+
slug=data.get("slug"),
|
|
85
|
+
version=data.get("version"),
|
|
86
|
+
created_ms=t.get("created"),
|
|
87
|
+
updated_ms=t.get("updated"),
|
|
88
|
+
additions=int(s.get("additions", 0) or 0),
|
|
89
|
+
deletions=int(s.get("deletions", 0) or 0),
|
|
90
|
+
files=int(s.get("files", 0) or 0),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
return infos
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _part_sort_key(part: dict[str, Any]) -> tuple[int, int, str]:
|
|
97
|
+
time_data = part.get("time") or {}
|
|
98
|
+
start = int(time_data.get("start", 0) or 0)
|
|
99
|
+
end = int(time_data.get("end", 0) or 0)
|
|
100
|
+
return (start, end, str(part.get("id", "")))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _load_parts_for_message(storage_dir: Path, message_id: str) -> list[dict[str, Any]]:
|
|
104
|
+
part_dir = storage_dir / "part" / message_id
|
|
105
|
+
if not part_dir.exists():
|
|
106
|
+
return []
|
|
107
|
+
|
|
108
|
+
items: list[dict[str, Any]] = []
|
|
109
|
+
for file in sorted(part_dir.glob("prt_*.json")):
|
|
110
|
+
part = _read_json_file(file)
|
|
111
|
+
if isinstance(part, dict):
|
|
112
|
+
items.append(part)
|
|
113
|
+
items.sort(key=_part_sort_key)
|
|
114
|
+
return items
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _load_messages(storage_dir: Path, session_id: str) -> list[Message]:
|
|
118
|
+
message_dir = storage_dir / "message" / session_id
|
|
119
|
+
if not message_dir.exists():
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
messages: list[Message] = []
|
|
123
|
+
for file in sorted(message_dir.glob("msg_*.json")):
|
|
124
|
+
data = _read_json_file(file)
|
|
125
|
+
if not isinstance(data, dict):
|
|
126
|
+
continue
|
|
127
|
+
m = normalize_message(
|
|
128
|
+
raw=data,
|
|
129
|
+
session_id=session_id,
|
|
130
|
+
parts=_load_parts_for_message(storage_dir, str(data.get("id", ""))),
|
|
131
|
+
)
|
|
132
|
+
messages.append(m)
|
|
133
|
+
|
|
134
|
+
messages.sort(key=lambda x: ((x.created_ms or 0), x.id))
|
|
135
|
+
return messages
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _load_todos(storage_dir: Path, session_id: str) -> list[TodoItem]:
|
|
139
|
+
todo_path = storage_dir / "todo" / f"{session_id}.json"
|
|
140
|
+
data = _read_json_file(todo_path)
|
|
141
|
+
if not isinstance(data, list):
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
items: list[TodoItem] = []
|
|
145
|
+
for row in data:
|
|
146
|
+
if not isinstance(row, dict):
|
|
147
|
+
continue
|
|
148
|
+
items.append(
|
|
149
|
+
TodoItem(
|
|
150
|
+
id=str(row.get("id", "")),
|
|
151
|
+
content=str(row.get("content", "")),
|
|
152
|
+
status=str(row.get("status", "pending")),
|
|
153
|
+
priority=str(row.get("priority", "medium")),
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
return items
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _load_session_diffs(storage_dir: Path, session_id: str) -> list[SessionDiffItem]:
|
|
160
|
+
diff_path = storage_dir / "session_diff" / f"{session_id}.json"
|
|
161
|
+
data = _read_json_file(diff_path)
|
|
162
|
+
if not isinstance(data, list):
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
items: list[SessionDiffItem] = []
|
|
166
|
+
for row in data:
|
|
167
|
+
if not isinstance(row, dict):
|
|
168
|
+
continue
|
|
169
|
+
items.append(
|
|
170
|
+
SessionDiffItem(
|
|
171
|
+
file=str(row.get("file", "")),
|
|
172
|
+
status=str(row.get("status", "modified")),
|
|
173
|
+
additions=int(row.get("additions", 0) or 0),
|
|
174
|
+
deletions=int(row.get("deletions", 0) or 0),
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
return items
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _session_in_range(
|
|
181
|
+
session: SessionInfo,
|
|
182
|
+
from_ms: int | None,
|
|
183
|
+
to_ms: int | None,
|
|
184
|
+
) -> bool:
|
|
185
|
+
if from_ms is None and to_ms is None:
|
|
186
|
+
return True
|
|
187
|
+
updated = session.updated_ms or session.created_ms or 0
|
|
188
|
+
if from_ms is not None and updated < from_ms:
|
|
189
|
+
return False
|
|
190
|
+
if to_ms is not None and updated > to_ms:
|
|
191
|
+
return False
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _message_in_range(message: Message, from_ms: int | None, to_ms: int | None) -> bool:
|
|
196
|
+
if from_ms is None and to_ms is None:
|
|
197
|
+
return True
|
|
198
|
+
ts = message.created_ms or 0
|
|
199
|
+
if from_ms is not None and ts < from_ms:
|
|
200
|
+
return False
|
|
201
|
+
if to_ms is not None and ts > to_ms:
|
|
202
|
+
return False
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def load_project_sessions(
|
|
207
|
+
storage_dir: Path,
|
|
208
|
+
project_id: str,
|
|
209
|
+
from_ms: int | None = None,
|
|
210
|
+
to_ms: int | None = None,
|
|
211
|
+
max_sessions: int | None = None,
|
|
212
|
+
cache_manager: "CacheManager | None" = None,
|
|
213
|
+
include_todos: bool = True,
|
|
214
|
+
include_diffs: bool = True,
|
|
215
|
+
) -> list[Session]:
|
|
216
|
+
result: list[Session] = []
|
|
217
|
+
infos = _load_project_sessions(storage_dir, project_id)
|
|
218
|
+
infos.sort(key=lambda x: ((x.updated_ms or x.created_ms or 0), x.id), reverse=True)
|
|
219
|
+
|
|
220
|
+
for info in infos:
|
|
221
|
+
if not _session_in_range(info, from_ms, to_ms):
|
|
222
|
+
continue
|
|
223
|
+
cached = (
|
|
224
|
+
cache_manager.get_session(info.id, info.updated_ms)
|
|
225
|
+
if cache_manager
|
|
226
|
+
else None
|
|
227
|
+
)
|
|
228
|
+
if cached is not None:
|
|
229
|
+
session_loaded = cached
|
|
230
|
+
else:
|
|
231
|
+
session_loaded = Session(
|
|
232
|
+
info=info,
|
|
233
|
+
messages=_load_messages(storage_dir, info.id),
|
|
234
|
+
todos=_load_todos(storage_dir, info.id),
|
|
235
|
+
diffs=_load_session_diffs(storage_dir, info.id),
|
|
236
|
+
)
|
|
237
|
+
if cache_manager:
|
|
238
|
+
cache_manager.set_session(session_loaded)
|
|
239
|
+
|
|
240
|
+
messages = session_loaded.messages
|
|
241
|
+
if from_ms is not None or to_ms is not None:
|
|
242
|
+
messages = [m for m in messages if _message_in_range(m, from_ms, to_ms)]
|
|
243
|
+
if not messages:
|
|
244
|
+
continue
|
|
245
|
+
result.append(
|
|
246
|
+
Session(
|
|
247
|
+
info=info,
|
|
248
|
+
messages=messages,
|
|
249
|
+
todos=session_loaded.todos if include_todos else [],
|
|
250
|
+
diffs=session_loaded.diffs if include_diffs else [],
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
if max_sessions is not None and len(result) >= max_sessions:
|
|
254
|
+
break
|
|
255
|
+
|
|
256
|
+
result.sort(
|
|
257
|
+
key=lambda s: (s.info.updated_ms or s.info.created_ms or 0, s.info.id),
|
|
258
|
+
reverse=True,
|
|
259
|
+
)
|
|
260
|
+
return result
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def safe_slug(text: str) -> str:
|
|
264
|
+
value = text.strip().lower()
|
|
265
|
+
value = re.sub(r"[^a-z0-9._-]+", "-", value)
|
|
266
|
+
value = re.sub(r"-+", "-", value).strip("-")
|
|
267
|
+
return value or "project"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def get_storage_schema_warnings(storage_dir: Path) -> list[str]:
|
|
271
|
+
return inspect_storage_schema(storage_dir)
|