sessionwiki 2.0.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.
- sessionwiki/__init__.py +74 -0
- sessionwiki/__main__.py +6 -0
- sessionwiki/_frontmatter.py +118 -0
- sessionwiki/_system_pages.py +44 -0
- sessionwiki/adapter_config.py +124 -0
- sessionwiki/adapters/__init__.py +134 -0
- sessionwiki/adapters/base.py +191 -0
- sessionwiki/adapters/claude_code.py +77 -0
- sessionwiki/adapters/codex_cli.py +226 -0
- sessionwiki/adapters/contrib/__init__.py +1 -0
- sessionwiki/adapters/contrib/chatgpt.py +252 -0
- sessionwiki/adapters/contrib/copilot_chat.py +121 -0
- sessionwiki/adapters/contrib/copilot_cli.py +115 -0
- sessionwiki/adapters/contrib/cursor.py +100 -0
- sessionwiki/adapters/contrib/gemini_cli.py +74 -0
- sessionwiki/adapters/contrib/obsidian.py +121 -0
- sessionwiki/adapters/opencode.py +169 -0
- sessionwiki/adapters/status.py +58 -0
- sessionwiki/adapters/zcode.py +45 -0
- sessionwiki/audit.py +174 -0
- sessionwiki/backlinks.py +224 -0
- sessionwiki/build.py +2786 -0
- sessionwiki/cache.py +401 -0
- sessionwiki/candidates.py +309 -0
- sessionwiki/categories.py +158 -0
- sessionwiki/changelog_timeline.py +422 -0
- sessionwiki/cli.py +1687 -0
- sessionwiki/compare.py +404 -0
- sessionwiki/completion.py +191 -0
- sessionwiki/confidence.py +179 -0
- sessionwiki/config.py +74 -0
- sessionwiki/config_schedule.py +77 -0
- sessionwiki/context_md.py +182 -0
- sessionwiki/convert.py +1746 -0
- sessionwiki/docs_pages.py +725 -0
- sessionwiki/doctor.py +113 -0
- sessionwiki/exporters.py +680 -0
- sessionwiki/freshness.py +153 -0
- sessionwiki/graph.py +1058 -0
- sessionwiki/graphify_bridge.py +509 -0
- sessionwiki/ingest_queue.py +101 -0
- sessionwiki/lifecycle.py +167 -0
- sessionwiki/link_checker.py +148 -0
- sessionwiki/lint/__init__.py +170 -0
- sessionwiki/lint/rules/__init__.py +69 -0
- sessionwiki/lint/rules/_helpers.py +119 -0
- sessionwiki/lint/rules/claim_verification.py +44 -0
- sessionwiki/lint/rules/content_freshness.py +41 -0
- sessionwiki/lint/rules/contradiction_detection.py +45 -0
- sessionwiki/lint/rules/duplicate_detection.py +164 -0
- sessionwiki/lint/rules/entity_consistency.py +29 -0
- sessionwiki/lint/rules/frontmatter_completeness.py +46 -0
- sessionwiki/lint/rules/frontmatter_count_consistency.py +61 -0
- sessionwiki/lint/rules/frontmatter_validity.py +71 -0
- sessionwiki/lint/rules/index_sync.py +68 -0
- sessionwiki/lint/rules/link_integrity.py +34 -0
- sessionwiki/lint/rules/orphan_detection.py +43 -0
- sessionwiki/lint/rules/stale_candidates.py +59 -0
- sessionwiki/lint/rules/stale_reference_detection.py +42 -0
- sessionwiki/lint/rules/summary_accuracy.py +36 -0
- sessionwiki/lint/rules/tags_topics_convention.py +58 -0
- sessionwiki/lint/rules/tools_consistency.py +65 -0
- sessionwiki/lock.py +126 -0
- sessionwiki/log_reader.py +129 -0
- sessionwiki/manifest.py +132 -0
- sessionwiki/mcp/__init__.py +29 -0
- sessionwiki/mcp/__main__.py +5 -0
- sessionwiki/mcp/server.py +1061 -0
- sessionwiki/models_page.py +291 -0
- sessionwiki/obsidian_output.py +208 -0
- sessionwiki/opencode/__init__.py +1 -0
- sessionwiki/opencode/autosync.py +102 -0
- sessionwiki/opencode/export.py +419 -0
- sessionwiki/opencode/install.py +152 -0
- sessionwiki/pipeline.py +150 -0
- sessionwiki/project_topics.py +213 -0
- sessionwiki/py.typed +0 -0
- sessionwiki/quarantine.py +273 -0
- sessionwiki/queue.py +29 -0
- sessionwiki/references.py +224 -0
- sessionwiki/render/__init__.py +20 -0
- sessionwiki/render/css.py +988 -0
- sessionwiki/render/docs_css.py +463 -0
- sessionwiki/render/js.py +1337 -0
- sessionwiki/schema.py +270 -0
- sessionwiki/search_facets.py +150 -0
- sessionwiki/search_tree.py +184 -0
- sessionwiki/serve.py +91 -0
- sessionwiki/session_export.py +333 -0
- sessionwiki/skill_installer.py +112 -0
- sessionwiki/sync/__init__.py +13 -0
- sessionwiki/sync/status.py +120 -0
- sessionwiki/synth/__init__.py +14 -0
- sessionwiki/synth/agent_delegate.py +327 -0
- sessionwiki/synth/base.py +125 -0
- sessionwiki/synth/cli_helpers.py +98 -0
- sessionwiki/synth/estimate.py +146 -0
- sessionwiki/synth/ollama.py +379 -0
- sessionwiki/synth/openai_compat.py +325 -0
- sessionwiki/synth/pipeline.py +1489 -0
- sessionwiki/tag_utils.py +62 -0
- sessionwiki/tags.py +359 -0
- sessionwiki/vault.py +347 -0
- sessionwiki/viz_heatmap.py +305 -0
- sessionwiki/viz_tokens.py +429 -0
- sessionwiki/viz_tools.py +241 -0
- sessionwiki/watch.py +154 -0
- sessionwiki/zcode/__init__.py +43 -0
- sessionwiki/zcode/autosync.py +150 -0
- sessionwiki/zcode/export.py +568 -0
- sessionwiki/zcode/install.py +386 -0
- sessionwiki-2.0.0.dist-info/METADATA +303 -0
- sessionwiki-2.0.0.dist-info/RECORD +118 -0
- sessionwiki-2.0.0.dist-info/WHEEL +5 -0
- sessionwiki-2.0.0.dist-info/entry_points.txt +2 -0
- sessionwiki-2.0.0.dist-info/licenses/LICENSE +22 -0
- sessionwiki-2.0.0.dist-info/licenses/NOTICE +14 -0
- sessionwiki-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Export ZCode sessions from db.sqlite into Claude-style JSONL transcripts.
|
|
3
|
+
|
|
4
|
+
The exporter is the boundary between ZCode's live SQLite store and the
|
|
5
|
+
sessionwiki converter:
|
|
6
|
+
|
|
7
|
+
~/.zcode/cli/db/db.sqlite
|
|
8
|
+
-> ~/.llmwiki/zcode/<project-slug>/<session-id>.jsonl
|
|
9
|
+
-> sessionwiki sync (adapter "zcode")
|
|
10
|
+
|
|
11
|
+
It is deliberately read-only and incremental. The DB is opened with
|
|
12
|
+
``mode=ro`` and a busy timeout; incremental state is keyed by
|
|
13
|
+
``session.time_updated`` so a second run with no changes writes nothing.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
sessionwiki zcode export --project demo_project --dry-run
|
|
17
|
+
sessionwiki zcode export --project demo_project
|
|
18
|
+
sessionwiki zcode export --since 2026-09-01
|
|
19
|
+
|
|
20
|
+
The standalone ``scripts/zcode_export.py`` wrapper remains for existing
|
|
21
|
+
hooks and cron entries.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import sqlite3
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, Sequence
|
|
32
|
+
|
|
33
|
+
from sessionwiki.session_export import ( # shared SQLite → JSONL plumbing
|
|
34
|
+
SchemaError,
|
|
35
|
+
as_int as _as_int,
|
|
36
|
+
as_text as _as_text,
|
|
37
|
+
connect_readonly,
|
|
38
|
+
delete_export_file as _delete_export_file,
|
|
39
|
+
iso_from_ms,
|
|
40
|
+
load_state,
|
|
41
|
+
parse_since,
|
|
42
|
+
row_get as _row_get,
|
|
43
|
+
safe_filename as _safe_filename,
|
|
44
|
+
save_state,
|
|
45
|
+
usage_from_tokens as _usage,
|
|
46
|
+
)
|
|
47
|
+
from sessionwiki.session_export import project_slug_for_directory as _project_slug
|
|
48
|
+
from sessionwiki.session_export import validate_schema as _validate_schema
|
|
49
|
+
from sessionwiki.session_export import write_jsonl as _write_jsonl
|
|
50
|
+
|
|
51
|
+
DEFAULT_DB = Path.home() / ".zcode" / "cli" / "db" / "db.sqlite"
|
|
52
|
+
DEFAULT_OUT_DIR = Path.home() / ".llmwiki" / "zcode"
|
|
53
|
+
|
|
54
|
+
# Columns the exporter actually depends on. ``title``, ``slug`` and
|
|
55
|
+
# ``permission`` are optional metadata, so a schema that drops them still
|
|
56
|
+
# exports conversation data instead of failing.
|
|
57
|
+
REQUIRED_COLUMNS = {
|
|
58
|
+
"session": {"id", "directory", "task_type", "time_created", "time_updated"},
|
|
59
|
+
"message": {"id", "session_id", "time_created", "data", "sequence"},
|
|
60
|
+
"part": {"id", "message_id", "session_id", "time_created", "data", "sequence"},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
SERVICE_PART_TYPES = {"reasoning", "step-start", "step-finish", "timeline", "compaction"}
|
|
64
|
+
MODEL_ONLY_VISIBILITY = {"model-only", "hidden"}
|
|
65
|
+
def _zcode_workspace_rule(path: Path, home: Path) -> str:
|
|
66
|
+
"""``~/.zcode/workspace/<name>`` → ``workspace_<name>``; ``draft-*`` → ``workspace_drafts``."""
|
|
67
|
+
try:
|
|
68
|
+
parts = path.relative_to(home / ".zcode" / "workspace").parts
|
|
69
|
+
except ValueError:
|
|
70
|
+
return ""
|
|
71
|
+
if not parts:
|
|
72
|
+
return ""
|
|
73
|
+
workspace = "drafts" if parts[0].startswith("draft-") else parts[0]
|
|
74
|
+
return f"workspace_{workspace}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def project_slug_for_directory(
|
|
78
|
+
directory: str,
|
|
79
|
+
home: Path | None = None,
|
|
80
|
+
project_roots: Sequence[str | Path] | None = None,
|
|
81
|
+
) -> str:
|
|
82
|
+
"""Map a ZCode working directory to a stable project slug.
|
|
83
|
+
|
|
84
|
+
Generic rules live in :func:`sessionwiki.session_export.project_slug_for_directory`;
|
|
85
|
+
ZCode adds one: sessions under ``~/.zcode/workspace/<name>`` become
|
|
86
|
+
``workspace_<name>``, and one-off ``draft-*`` workspaces collapse into
|
|
87
|
+
``workspace_drafts``.
|
|
88
|
+
"""
|
|
89
|
+
return _project_slug(
|
|
90
|
+
directory, home, project_roots, extra_rule=_zcode_workspace_rule
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def validate_schema(con: sqlite3.Connection) -> None:
|
|
95
|
+
"""Fail clearly when ZCode changes the columns we depend on."""
|
|
96
|
+
_validate_schema(con, REQUIRED_COLUMNS, label="ZCode")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _context(data: dict[str, Any]) -> tuple[str, str]:
|
|
100
|
+
context = data.get("contextSnapshot") or {}
|
|
101
|
+
environment = context.get("envInfo") or {}
|
|
102
|
+
return str(environment.get("cwd") or ""), str(environment.get("gitBranch") or "")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_service_message(data: dict[str, Any]) -> bool:
|
|
106
|
+
metadata = data.get("metadata") or {}
|
|
107
|
+
if data.get("visibility") in MODEL_ONLY_VISIBILITY:
|
|
108
|
+
return True
|
|
109
|
+
if metadata.get("visibility") in MODEL_ONLY_VISIBILITY:
|
|
110
|
+
return True
|
|
111
|
+
origin = metadata.get("originMeta") or {}
|
|
112
|
+
if origin.get("source") == "background_task":
|
|
113
|
+
return True
|
|
114
|
+
if (data.get("originMeta") or {}).get("source") == "background_task":
|
|
115
|
+
return True
|
|
116
|
+
return metadata.get("source") == "background_task"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _fallback_user_text(data: dict[str, Any]) -> str:
|
|
120
|
+
metadata = data.get("metadata") or {}
|
|
121
|
+
for key in ("conversationInputIntent", "inputIntent"):
|
|
122
|
+
intent = metadata.get(key) or {}
|
|
123
|
+
text = intent.get("text")
|
|
124
|
+
if isinstance(text, str) and text.strip():
|
|
125
|
+
return text.strip()
|
|
126
|
+
return ""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _file_placeholder(part: dict[str, Any]) -> str:
|
|
130
|
+
filename = str(part.get("filename") or "")
|
|
131
|
+
if not filename:
|
|
132
|
+
url = str(part.get("url") or "")
|
|
133
|
+
filename = Path(url).name if url else ""
|
|
134
|
+
if not filename:
|
|
135
|
+
filename = str(part.get("mime") or "attachment")
|
|
136
|
+
mime = str(part.get("mime") or "")
|
|
137
|
+
kind = "image" if mime.startswith("image/") else "file"
|
|
138
|
+
return f"[{kind}: {filename}]"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _model(data: dict[str, Any]) -> str:
|
|
142
|
+
model = data.get("modelId") or data.get("model") or "unknown"
|
|
143
|
+
if isinstance(model, dict):
|
|
144
|
+
model = model.get("modelId") or model.get("id") or "unknown"
|
|
145
|
+
return str(model)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _permission_mode(session: sqlite3.Row) -> str:
|
|
149
|
+
raw = _row_get(session, "permission", "")
|
|
150
|
+
if not raw:
|
|
151
|
+
return ""
|
|
152
|
+
try:
|
|
153
|
+
value = json.loads(raw) if isinstance(raw, str) else raw
|
|
154
|
+
except (TypeError, json.JSONDecodeError):
|
|
155
|
+
return ""
|
|
156
|
+
if isinstance(value, dict):
|
|
157
|
+
return str(value.get("mode") or "")
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def session_to_records(con: sqlite3.Connection, session: sqlite3.Row) -> list[dict[str, Any]]:
|
|
162
|
+
"""Convert one ZCode session into the Claude-style record sequence."""
|
|
163
|
+
session_id = str(session["id"])
|
|
164
|
+
directory = str(_row_get(session, "directory", "") or "")
|
|
165
|
+
title = str(_row_get(session, "title", "") or "")
|
|
166
|
+
slug = str(_row_get(session, "slug", "") or session_id)
|
|
167
|
+
permission_mode = _permission_mode(session)
|
|
168
|
+
|
|
169
|
+
messages = con.execute(
|
|
170
|
+
"select id, time_created, data, sequence from message"
|
|
171
|
+
" where session_id = ? order by sequence, time_created",
|
|
172
|
+
(session_id,),
|
|
173
|
+
).fetchall()
|
|
174
|
+
|
|
175
|
+
records: list[dict[str, Any]] = []
|
|
176
|
+
cwd = directory
|
|
177
|
+
git_branch = ""
|
|
178
|
+
|
|
179
|
+
for message in messages:
|
|
180
|
+
try:
|
|
181
|
+
data = json.loads(message["data"])
|
|
182
|
+
except (TypeError, json.JSONDecodeError):
|
|
183
|
+
continue
|
|
184
|
+
if not isinstance(data, dict) or _is_service_message(data):
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
role = data.get("role")
|
|
188
|
+
message_time = _as_int(data.get("time", {}).get("created")) or _as_int(message["time_created"])
|
|
189
|
+
context_cwd, context_branch = _context(data)
|
|
190
|
+
if context_cwd:
|
|
191
|
+
cwd = context_cwd
|
|
192
|
+
if context_branch and not git_branch:
|
|
193
|
+
git_branch = context_branch
|
|
194
|
+
|
|
195
|
+
part_rows = con.execute(
|
|
196
|
+
"select id, time_created, data, sequence from part"
|
|
197
|
+
" where message_id = ? order by sequence, time_created",
|
|
198
|
+
(message["id"],),
|
|
199
|
+
).fetchall()
|
|
200
|
+
|
|
201
|
+
if role == "user":
|
|
202
|
+
text_parts: list[str] = []
|
|
203
|
+
for part_row in part_rows:
|
|
204
|
+
try:
|
|
205
|
+
part = json.loads(part_row["data"])
|
|
206
|
+
except (TypeError, json.JSONDecodeError):
|
|
207
|
+
continue
|
|
208
|
+
if not isinstance(part, dict):
|
|
209
|
+
continue
|
|
210
|
+
part_type = part.get("type")
|
|
211
|
+
if part_type == "text":
|
|
212
|
+
text = str(part.get("text") or "").strip()
|
|
213
|
+
if text:
|
|
214
|
+
text_parts.append(text)
|
|
215
|
+
elif part_type == "file":
|
|
216
|
+
text_parts.append(_file_placeholder(part))
|
|
217
|
+
content = "\n\n".join(text_parts).strip()
|
|
218
|
+
if not content:
|
|
219
|
+
content = _fallback_user_text(data)
|
|
220
|
+
if content:
|
|
221
|
+
records.append(
|
|
222
|
+
{
|
|
223
|
+
"type": "user",
|
|
224
|
+
"timestamp": iso_from_ms(message_time),
|
|
225
|
+
"sessionId": session_id,
|
|
226
|
+
"cwd": cwd,
|
|
227
|
+
"message": {"role": "user", "content": content},
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
if role != "assistant":
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
blocks: list[dict[str, Any]] = []
|
|
236
|
+
tool_results: list[dict[str, Any]] = []
|
|
237
|
+
tool_result_time = message_time
|
|
238
|
+
for part_row in part_rows:
|
|
239
|
+
try:
|
|
240
|
+
part = json.loads(part_row["data"])
|
|
241
|
+
except (TypeError, json.JSONDecodeError):
|
|
242
|
+
continue
|
|
243
|
+
if not isinstance(part, dict):
|
|
244
|
+
continue
|
|
245
|
+
part_type = part.get("type")
|
|
246
|
+
if part_type == "text":
|
|
247
|
+
text = str(part.get("text") or "").strip()
|
|
248
|
+
if text:
|
|
249
|
+
blocks.append({"type": "text", "text": text})
|
|
250
|
+
elif part_type == "file":
|
|
251
|
+
blocks.append({"type": "text", "text": _file_placeholder(part)})
|
|
252
|
+
elif part_type == "tool":
|
|
253
|
+
state = part.get("state") or {}
|
|
254
|
+
if not isinstance(state, dict):
|
|
255
|
+
state = {}
|
|
256
|
+
tool_input = state.get("input")
|
|
257
|
+
if not isinstance(tool_input, dict):
|
|
258
|
+
tool_input = {"value": tool_input} if tool_input is not None else {}
|
|
259
|
+
call_id = str(part.get("callID") or part_row["id"])
|
|
260
|
+
blocks.append(
|
|
261
|
+
{
|
|
262
|
+
"type": "tool_use",
|
|
263
|
+
"id": call_id,
|
|
264
|
+
"name": str(part.get("tool") or "Tool"),
|
|
265
|
+
"input": tool_input,
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
output = _as_text(state.get("output")).strip()
|
|
269
|
+
if output:
|
|
270
|
+
tool_results.append(
|
|
271
|
+
{
|
|
272
|
+
"type": "tool_result",
|
|
273
|
+
"tool_use_id": call_id,
|
|
274
|
+
"content": output,
|
|
275
|
+
"is_error": state.get("status") in {"error", "failed"},
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
tool_result_time = max(tool_result_time, _as_int(part_row["time_created"]))
|
|
279
|
+
elif part_type in SERVICE_PART_TYPES:
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
if blocks:
|
|
283
|
+
records.append(
|
|
284
|
+
{
|
|
285
|
+
"type": "assistant",
|
|
286
|
+
"timestamp": iso_from_ms(message_time),
|
|
287
|
+
"sessionId": session_id,
|
|
288
|
+
"cwd": cwd,
|
|
289
|
+
"message": {
|
|
290
|
+
"role": "assistant",
|
|
291
|
+
"model": _model(data),
|
|
292
|
+
"content": blocks,
|
|
293
|
+
"usage": _usage(data),
|
|
294
|
+
},
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
if tool_results:
|
|
298
|
+
records.append(
|
|
299
|
+
{
|
|
300
|
+
"type": "user",
|
|
301
|
+
"timestamp": iso_from_ms(tool_result_time),
|
|
302
|
+
"sessionId": session_id,
|
|
303
|
+
"cwd": cwd,
|
|
304
|
+
"message": {"role": "user", "content": tool_results},
|
|
305
|
+
}
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if not any(record["type"] in {"user", "assistant"} for record in records):
|
|
309
|
+
return []
|
|
310
|
+
|
|
311
|
+
init: dict[str, Any] = {
|
|
312
|
+
"type": "init",
|
|
313
|
+
"sessionId": session_id,
|
|
314
|
+
"cwd": cwd,
|
|
315
|
+
"timestamp": iso_from_ms(session["time_created"]),
|
|
316
|
+
"slug": slug,
|
|
317
|
+
}
|
|
318
|
+
if title:
|
|
319
|
+
init["title"] = title
|
|
320
|
+
if git_branch:
|
|
321
|
+
init["gitBranch"] = git_branch
|
|
322
|
+
if permission_mode:
|
|
323
|
+
init["permissionMode"] = permission_mode
|
|
324
|
+
return [init, *records]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _connect(db_path: Path) -> sqlite3.Connection:
|
|
328
|
+
return connect_readonly(db_path, label="ZCode")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def schema_version(con: sqlite3.Connection) -> int | None:
|
|
332
|
+
"""Return ZCode's max ``schema_migration.id`` when the table exists."""
|
|
333
|
+
try:
|
|
334
|
+
row = con.execute("select max(id) from schema_migration").fetchone()
|
|
335
|
+
except sqlite3.Error:
|
|
336
|
+
return None
|
|
337
|
+
if row is None or row[0] is None:
|
|
338
|
+
return None
|
|
339
|
+
try:
|
|
340
|
+
return int(row[0])
|
|
341
|
+
except (TypeError, ValueError):
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def run_export(
|
|
346
|
+
db_path: Path,
|
|
347
|
+
out_dir: Path,
|
|
348
|
+
state_file: Path | None = None,
|
|
349
|
+
since: int | None = None,
|
|
350
|
+
project: str | None = None,
|
|
351
|
+
include_subagents: bool = False,
|
|
352
|
+
dry_run: bool = False,
|
|
353
|
+
project_roots: Sequence[str | Path] | None = None,
|
|
354
|
+
prune_stale: bool = True,
|
|
355
|
+
) -> dict[str, Any]:
|
|
356
|
+
"""Export matching sessions and return counters for the CLI summary."""
|
|
357
|
+
out_dir = Path(out_dir).expanduser()
|
|
358
|
+
state_file = Path(state_file).expanduser() if state_file else out_dir / ".export-state.json"
|
|
359
|
+
stats: dict[str, Any] = {
|
|
360
|
+
"total_sessions": 0,
|
|
361
|
+
"selected": 0,
|
|
362
|
+
"exported": 0,
|
|
363
|
+
"unchanged": 0,
|
|
364
|
+
"empty": 0,
|
|
365
|
+
"errors": 0,
|
|
366
|
+
"written": 0,
|
|
367
|
+
"skipped_subagents": 0,
|
|
368
|
+
"skipped_project": 0,
|
|
369
|
+
"skipped_since": 0,
|
|
370
|
+
"stale": 0,
|
|
371
|
+
"stale_deleted": 0,
|
|
372
|
+
"schema_version": None,
|
|
373
|
+
"files": [],
|
|
374
|
+
}
|
|
375
|
+
state = load_state(state_file)
|
|
376
|
+
sessions_state = state.setdefault("sessions", {})
|
|
377
|
+
state_dirty = False
|
|
378
|
+
project_text = project.lower() if project else None
|
|
379
|
+
since_ms = int(since) if since is not None else None
|
|
380
|
+
seen_sessions: set[str] = set()
|
|
381
|
+
|
|
382
|
+
con = _connect(db_path)
|
|
383
|
+
try:
|
|
384
|
+
validate_schema(con)
|
|
385
|
+
stats["schema_version"] = schema_version(con)
|
|
386
|
+
sessions = con.execute("select * from session order by time_created, id").fetchall()
|
|
387
|
+
for session in sessions:
|
|
388
|
+
stats["total_sessions"] += 1
|
|
389
|
+
session_id = str(session["id"])
|
|
390
|
+
seen_sessions.add(session_id)
|
|
391
|
+
task_type = str(_row_get(session, "task_type", "") or "")
|
|
392
|
+
if not include_subagents and (task_type == "subagent_child" or _row_get(session, "parent_id")):
|
|
393
|
+
stats["skipped_subagents"] += 1
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
directory = str(_row_get(session, "directory", "") or "")
|
|
397
|
+
slug = project_slug_for_directory(directory, project_roots=project_roots)
|
|
398
|
+
effective_time = max(
|
|
399
|
+
_as_int(_row_get(session, "time_updated")),
|
|
400
|
+
_as_int(_row_get(session, "time_created")),
|
|
401
|
+
)
|
|
402
|
+
if since_ms is not None and effective_time < since_ms:
|
|
403
|
+
stats["skipped_since"] += 1
|
|
404
|
+
continue
|
|
405
|
+
if project_text and project_text not in f"{directory} {slug}".lower():
|
|
406
|
+
stats["skipped_project"] += 1
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
stats["selected"] += 1
|
|
410
|
+
out_path = out_dir / slug / f"{_safe_filename(session_id)}.jsonl"
|
|
411
|
+
previous = sessions_state.get(session_id)
|
|
412
|
+
previous_time = previous.get("time_updated") if isinstance(previous, dict) else previous
|
|
413
|
+
current_time = _as_int(_row_get(session, "time_updated"))
|
|
414
|
+
if previous_time == current_time and out_path.exists():
|
|
415
|
+
stats["unchanged"] += 1
|
|
416
|
+
continue
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
records = session_to_records(con, session)
|
|
420
|
+
except Exception as exc: # noqa: BLE001 - one bad session must not stop the run
|
|
421
|
+
stats["errors"] += 1
|
|
422
|
+
print(f"error: {session_id}: {exc}", file=sys.stderr)
|
|
423
|
+
continue
|
|
424
|
+
if not records:
|
|
425
|
+
stats["empty"] += 1
|
|
426
|
+
continue
|
|
427
|
+
|
|
428
|
+
stats["exported"] += 1
|
|
429
|
+
stats["files"].append(out_path)
|
|
430
|
+
if dry_run:
|
|
431
|
+
continue
|
|
432
|
+
try:
|
|
433
|
+
_write_jsonl(out_path, records)
|
|
434
|
+
except OSError as exc:
|
|
435
|
+
stats["errors"] += 1
|
|
436
|
+
print(f"error: {session_id}: {exc}", file=sys.stderr)
|
|
437
|
+
continue
|
|
438
|
+
stats["written"] += 1
|
|
439
|
+
new_rel = str(out_path.relative_to(out_dir))
|
|
440
|
+
if isinstance(previous, dict):
|
|
441
|
+
old_rel = previous.get("path")
|
|
442
|
+
if old_rel and old_rel != new_rel:
|
|
443
|
+
_delete_export_file(out_dir, str(old_rel))
|
|
444
|
+
sessions_state[session_id] = {
|
|
445
|
+
"time_updated": current_time,
|
|
446
|
+
"path": new_rel,
|
|
447
|
+
"project": slug,
|
|
448
|
+
}
|
|
449
|
+
state_dirty = True
|
|
450
|
+
finally:
|
|
451
|
+
con.close()
|
|
452
|
+
|
|
453
|
+
if (
|
|
454
|
+
prune_stale
|
|
455
|
+
and not dry_run
|
|
456
|
+
and project_text is None
|
|
457
|
+
and since_ms is None
|
|
458
|
+
):
|
|
459
|
+
stale_ids = sorted(set(sessions_state) - seen_sessions)
|
|
460
|
+
for session_id in stale_ids:
|
|
461
|
+
entry = sessions_state.pop(session_id, None)
|
|
462
|
+
stats["stale"] += 1
|
|
463
|
+
state_dirty = True
|
|
464
|
+
if isinstance(entry, dict):
|
|
465
|
+
relative = entry.get("path")
|
|
466
|
+
if relative and _delete_export_file(out_dir, str(relative)):
|
|
467
|
+
stats["stale_deleted"] += 1
|
|
468
|
+
|
|
469
|
+
if state_dirty and not dry_run:
|
|
470
|
+
save_state(state_file, state)
|
|
471
|
+
return stats
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
475
|
+
parser = argparse.ArgumentParser(
|
|
476
|
+
description="Export ZCode sessions to Claude-style JSONL for sessionwiki.",
|
|
477
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
478
|
+
epilog=(
|
|
479
|
+
"Examples:\n"
|
|
480
|
+
" %(prog)s --project demo_project --dry-run\n"
|
|
481
|
+
" %(prog)s --project demo_project\n"
|
|
482
|
+
" %(prog)s --since 2026-09-01\n"
|
|
483
|
+
),
|
|
484
|
+
)
|
|
485
|
+
parser.add_argument("--db", type=Path, default=DEFAULT_DB, help="path to ZCode db.sqlite")
|
|
486
|
+
parser.add_argument("--out-dir", type=Path, default=DEFAULT_OUT_DIR, help="JSONL output directory")
|
|
487
|
+
parser.add_argument("--state-file", type=Path, default=None, help="incremental state file")
|
|
488
|
+
parser.add_argument("--since", default=None, help="only sessions updated on/after YYYY-MM-DD")
|
|
489
|
+
parser.add_argument("--project", default=None, help="case-insensitive project path or slug substring")
|
|
490
|
+
parser.add_argument(
|
|
491
|
+
"--project-root",
|
|
492
|
+
dest="project_roots",
|
|
493
|
+
action="append",
|
|
494
|
+
type=Path,
|
|
495
|
+
default=None,
|
|
496
|
+
help="treat this directory as a project root (repeatable)",
|
|
497
|
+
)
|
|
498
|
+
parser.add_argument("--include-subagents", action="store_true", help="include subagent_child sessions")
|
|
499
|
+
parser.add_argument(
|
|
500
|
+
"--keep-stale",
|
|
501
|
+
action="store_true",
|
|
502
|
+
help="do not remove JSONL for sessions deleted from the database",
|
|
503
|
+
)
|
|
504
|
+
parser.add_argument(
|
|
505
|
+
"--strict",
|
|
506
|
+
action="store_true",
|
|
507
|
+
help="exit 1 when any session fails, even if others exported",
|
|
508
|
+
)
|
|
509
|
+
parser.add_argument("--dry-run", action="store_true", help="show counters without writing files")
|
|
510
|
+
return parser
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def main(argv: list[str] | None = None) -> int:
|
|
514
|
+
parser = build_parser()
|
|
515
|
+
args = parser.parse_args(argv)
|
|
516
|
+
try:
|
|
517
|
+
since_ms = parse_since(args.since)
|
|
518
|
+
except ValueError as exc:
|
|
519
|
+
parser.error(str(exc))
|
|
520
|
+
|
|
521
|
+
try:
|
|
522
|
+
stats = run_export(
|
|
523
|
+
db_path=args.db,
|
|
524
|
+
out_dir=args.out_dir,
|
|
525
|
+
state_file=args.state_file,
|
|
526
|
+
since=since_ms,
|
|
527
|
+
project=args.project,
|
|
528
|
+
include_subagents=args.include_subagents,
|
|
529
|
+
dry_run=args.dry_run,
|
|
530
|
+
project_roots=args.project_roots,
|
|
531
|
+
prune_stale=not args.keep_stale,
|
|
532
|
+
)
|
|
533
|
+
except (SchemaError, sqlite3.Error, OSError, RuntimeError) as exc:
|
|
534
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
535
|
+
return 1
|
|
536
|
+
|
|
537
|
+
print("ZCode export")
|
|
538
|
+
print(f" db: {args.db}")
|
|
539
|
+
print(f" out: {args.out_dir}")
|
|
540
|
+
if stats.get("schema_version") is not None:
|
|
541
|
+
print(f" schema: {stats['schema_version']}")
|
|
542
|
+
print(f" sessions: {stats['total_sessions']} total, {stats['selected']} selected")
|
|
543
|
+
print(
|
|
544
|
+
" skipped: "
|
|
545
|
+
f"{stats['skipped_subagents']} subagent, "
|
|
546
|
+
f"{stats['skipped_project']} project, "
|
|
547
|
+
f"{stats['skipped_since']} since"
|
|
548
|
+
)
|
|
549
|
+
print(
|
|
550
|
+
f" exported: {stats['exported']}, unchanged: {stats['unchanged']}, "
|
|
551
|
+
f"empty: {stats['empty']}, errors: {stats['errors']}"
|
|
552
|
+
)
|
|
553
|
+
if args.dry_run:
|
|
554
|
+
print(" dry-run: no files written")
|
|
555
|
+
else:
|
|
556
|
+
print(f" written: {stats['written']} files")
|
|
557
|
+
if stats.get("stale"):
|
|
558
|
+
print(
|
|
559
|
+
f" stale: {stats['stale']} session(s) pruned, "
|
|
560
|
+
f"{stats['stale_deleted']} file(s) removed"
|
|
561
|
+
)
|
|
562
|
+
if stats["errors"] and (args.strict or stats["written"] == 0):
|
|
563
|
+
return 1
|
|
564
|
+
return 0
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
if __name__ == "__main__":
|
|
568
|
+
raise SystemExit(main())
|