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.
Files changed (118) hide show
  1. sessionwiki/__init__.py +74 -0
  2. sessionwiki/__main__.py +6 -0
  3. sessionwiki/_frontmatter.py +118 -0
  4. sessionwiki/_system_pages.py +44 -0
  5. sessionwiki/adapter_config.py +124 -0
  6. sessionwiki/adapters/__init__.py +134 -0
  7. sessionwiki/adapters/base.py +191 -0
  8. sessionwiki/adapters/claude_code.py +77 -0
  9. sessionwiki/adapters/codex_cli.py +226 -0
  10. sessionwiki/adapters/contrib/__init__.py +1 -0
  11. sessionwiki/adapters/contrib/chatgpt.py +252 -0
  12. sessionwiki/adapters/contrib/copilot_chat.py +121 -0
  13. sessionwiki/adapters/contrib/copilot_cli.py +115 -0
  14. sessionwiki/adapters/contrib/cursor.py +100 -0
  15. sessionwiki/adapters/contrib/gemini_cli.py +74 -0
  16. sessionwiki/adapters/contrib/obsidian.py +121 -0
  17. sessionwiki/adapters/opencode.py +169 -0
  18. sessionwiki/adapters/status.py +58 -0
  19. sessionwiki/adapters/zcode.py +45 -0
  20. sessionwiki/audit.py +174 -0
  21. sessionwiki/backlinks.py +224 -0
  22. sessionwiki/build.py +2786 -0
  23. sessionwiki/cache.py +401 -0
  24. sessionwiki/candidates.py +309 -0
  25. sessionwiki/categories.py +158 -0
  26. sessionwiki/changelog_timeline.py +422 -0
  27. sessionwiki/cli.py +1687 -0
  28. sessionwiki/compare.py +404 -0
  29. sessionwiki/completion.py +191 -0
  30. sessionwiki/confidence.py +179 -0
  31. sessionwiki/config.py +74 -0
  32. sessionwiki/config_schedule.py +77 -0
  33. sessionwiki/context_md.py +182 -0
  34. sessionwiki/convert.py +1746 -0
  35. sessionwiki/docs_pages.py +725 -0
  36. sessionwiki/doctor.py +113 -0
  37. sessionwiki/exporters.py +680 -0
  38. sessionwiki/freshness.py +153 -0
  39. sessionwiki/graph.py +1058 -0
  40. sessionwiki/graphify_bridge.py +509 -0
  41. sessionwiki/ingest_queue.py +101 -0
  42. sessionwiki/lifecycle.py +167 -0
  43. sessionwiki/link_checker.py +148 -0
  44. sessionwiki/lint/__init__.py +170 -0
  45. sessionwiki/lint/rules/__init__.py +69 -0
  46. sessionwiki/lint/rules/_helpers.py +119 -0
  47. sessionwiki/lint/rules/claim_verification.py +44 -0
  48. sessionwiki/lint/rules/content_freshness.py +41 -0
  49. sessionwiki/lint/rules/contradiction_detection.py +45 -0
  50. sessionwiki/lint/rules/duplicate_detection.py +164 -0
  51. sessionwiki/lint/rules/entity_consistency.py +29 -0
  52. sessionwiki/lint/rules/frontmatter_completeness.py +46 -0
  53. sessionwiki/lint/rules/frontmatter_count_consistency.py +61 -0
  54. sessionwiki/lint/rules/frontmatter_validity.py +71 -0
  55. sessionwiki/lint/rules/index_sync.py +68 -0
  56. sessionwiki/lint/rules/link_integrity.py +34 -0
  57. sessionwiki/lint/rules/orphan_detection.py +43 -0
  58. sessionwiki/lint/rules/stale_candidates.py +59 -0
  59. sessionwiki/lint/rules/stale_reference_detection.py +42 -0
  60. sessionwiki/lint/rules/summary_accuracy.py +36 -0
  61. sessionwiki/lint/rules/tags_topics_convention.py +58 -0
  62. sessionwiki/lint/rules/tools_consistency.py +65 -0
  63. sessionwiki/lock.py +126 -0
  64. sessionwiki/log_reader.py +129 -0
  65. sessionwiki/manifest.py +132 -0
  66. sessionwiki/mcp/__init__.py +29 -0
  67. sessionwiki/mcp/__main__.py +5 -0
  68. sessionwiki/mcp/server.py +1061 -0
  69. sessionwiki/models_page.py +291 -0
  70. sessionwiki/obsidian_output.py +208 -0
  71. sessionwiki/opencode/__init__.py +1 -0
  72. sessionwiki/opencode/autosync.py +102 -0
  73. sessionwiki/opencode/export.py +419 -0
  74. sessionwiki/opencode/install.py +152 -0
  75. sessionwiki/pipeline.py +150 -0
  76. sessionwiki/project_topics.py +213 -0
  77. sessionwiki/py.typed +0 -0
  78. sessionwiki/quarantine.py +273 -0
  79. sessionwiki/queue.py +29 -0
  80. sessionwiki/references.py +224 -0
  81. sessionwiki/render/__init__.py +20 -0
  82. sessionwiki/render/css.py +988 -0
  83. sessionwiki/render/docs_css.py +463 -0
  84. sessionwiki/render/js.py +1337 -0
  85. sessionwiki/schema.py +270 -0
  86. sessionwiki/search_facets.py +150 -0
  87. sessionwiki/search_tree.py +184 -0
  88. sessionwiki/serve.py +91 -0
  89. sessionwiki/session_export.py +333 -0
  90. sessionwiki/skill_installer.py +112 -0
  91. sessionwiki/sync/__init__.py +13 -0
  92. sessionwiki/sync/status.py +120 -0
  93. sessionwiki/synth/__init__.py +14 -0
  94. sessionwiki/synth/agent_delegate.py +327 -0
  95. sessionwiki/synth/base.py +125 -0
  96. sessionwiki/synth/cli_helpers.py +98 -0
  97. sessionwiki/synth/estimate.py +146 -0
  98. sessionwiki/synth/ollama.py +379 -0
  99. sessionwiki/synth/openai_compat.py +325 -0
  100. sessionwiki/synth/pipeline.py +1489 -0
  101. sessionwiki/tag_utils.py +62 -0
  102. sessionwiki/tags.py +359 -0
  103. sessionwiki/vault.py +347 -0
  104. sessionwiki/viz_heatmap.py +305 -0
  105. sessionwiki/viz_tokens.py +429 -0
  106. sessionwiki/viz_tools.py +241 -0
  107. sessionwiki/watch.py +154 -0
  108. sessionwiki/zcode/__init__.py +43 -0
  109. sessionwiki/zcode/autosync.py +150 -0
  110. sessionwiki/zcode/export.py +568 -0
  111. sessionwiki/zcode/install.py +386 -0
  112. sessionwiki-2.0.0.dist-info/METADATA +303 -0
  113. sessionwiki-2.0.0.dist-info/RECORD +118 -0
  114. sessionwiki-2.0.0.dist-info/WHEEL +5 -0
  115. sessionwiki-2.0.0.dist-info/entry_points.txt +2 -0
  116. sessionwiki-2.0.0.dist-info/licenses/LICENSE +22 -0
  117. sessionwiki-2.0.0.dist-info/licenses/NOTICE +14 -0
  118. 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())