crabcode 0.1.6__py3-none-win_amd64.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 (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,287 @@
1
+ """Schedule parsing and next-run calculation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Iterable
7
+
8
+ from crabcode_core.schedule.models import ScheduleType
9
+
10
+
11
+ _MONTH_NAMES = {
12
+ "jan": 1,
13
+ "feb": 2,
14
+ "mar": 3,
15
+ "apr": 4,
16
+ "may": 5,
17
+ "jun": 6,
18
+ "jul": 7,
19
+ "aug": 8,
20
+ "sep": 9,
21
+ "oct": 10,
22
+ "nov": 11,
23
+ "dec": 12,
24
+ }
25
+ _WEEKDAY_NAMES = {
26
+ "sun": 0,
27
+ "mon": 1,
28
+ "tue": 2,
29
+ "wed": 3,
30
+ "thu": 4,
31
+ "fri": 5,
32
+ "sat": 6,
33
+ }
34
+
35
+
36
+ def utc_now() -> datetime:
37
+ return datetime.now(timezone.utc)
38
+
39
+
40
+ def parse_interval(value: str) -> int:
41
+ """Parse a positive interval such as ``30m`` or ``2h`` into seconds."""
42
+ raw = str(value).strip().lower()
43
+ if not raw:
44
+ raise ValueError("interval cannot be empty")
45
+
46
+ multipliers = {"s": 1, "m": 60, "h": 3600, "d": 86400}
47
+ unit = raw[-1]
48
+ number = raw[:-1] if unit in multipliers else raw
49
+ multiplier = multipliers.get(unit, 1)
50
+ try:
51
+ amount = int(number)
52
+ except ValueError as exc:
53
+ raise ValueError(f"invalid interval: {value!r}") from exc
54
+ seconds = amount * multiplier
55
+ if seconds <= 0:
56
+ raise ValueError("interval must be greater than zero")
57
+ return seconds
58
+
59
+
60
+ def parse_iso_timestamp(value: str) -> datetime:
61
+ """Parse an aware ISO 8601 timestamp and normalize it to UTC."""
62
+ raw = str(value).strip()
63
+ if raw.endswith(("Z", "z")):
64
+ raw = f"{raw[:-1]}+00:00"
65
+ try:
66
+ parsed = datetime.fromisoformat(raw)
67
+ except ValueError as exc:
68
+ raise ValueError(f"invalid ISO timestamp: {value!r}") from exc
69
+ if parsed.tzinfo is None:
70
+ raise ValueError("timestamp must include timezone information")
71
+ return parsed.astimezone(timezone.utc)
72
+
73
+
74
+ def _parse_atom(
75
+ value: str,
76
+ *,
77
+ minimum: int,
78
+ maximum: int,
79
+ names: dict[str, int] | None,
80
+ ) -> int:
81
+ lowered = value.strip().lower()
82
+ if names and lowered in names:
83
+ result = names[lowered]
84
+ else:
85
+ try:
86
+ result = int(lowered)
87
+ except ValueError as exc:
88
+ raise ValueError(f"invalid cron value: {value!r}") from exc
89
+ if result < minimum or result > maximum:
90
+ raise ValueError(
91
+ f"cron value {result} is outside the range {minimum}-{maximum}"
92
+ )
93
+ return result
94
+
95
+
96
+ def _expand_cron_field(
97
+ expression: str,
98
+ *,
99
+ minimum: int,
100
+ maximum: int,
101
+ names: dict[str, int] | None = None,
102
+ normalize_weekday: bool = False,
103
+ ) -> tuple[set[int], bool]:
104
+ """Expand one cron field and report whether it is unrestricted."""
105
+ raw = expression.strip().lower()
106
+ if not raw:
107
+ raise ValueError("cron field cannot be empty")
108
+ values: set[int] = set()
109
+ unrestricted = raw == "*"
110
+
111
+ for component in raw.split(","):
112
+ if not component:
113
+ raise ValueError(f"invalid cron field: {expression!r}")
114
+ base, separator, step_text = component.partition("/")
115
+ if separator:
116
+ try:
117
+ step = int(step_text)
118
+ except ValueError as exc:
119
+ raise ValueError(f"invalid cron step: {step_text!r}") from exc
120
+ if step <= 0:
121
+ raise ValueError("cron step must be greater than zero")
122
+ else:
123
+ step = 1
124
+
125
+ if base == "*":
126
+ start, end = minimum, maximum
127
+ elif "-" in base:
128
+ start_text, end_text = base.split("-", 1)
129
+ start = _parse_atom(
130
+ start_text,
131
+ minimum=minimum,
132
+ maximum=maximum,
133
+ names=names,
134
+ )
135
+ end = _parse_atom(
136
+ end_text,
137
+ minimum=minimum,
138
+ maximum=maximum,
139
+ names=names,
140
+ )
141
+ if end < start:
142
+ raise ValueError(f"cron range must be ascending: {base!r}")
143
+ else:
144
+ start = _parse_atom(
145
+ base,
146
+ minimum=minimum,
147
+ maximum=maximum,
148
+ names=names,
149
+ )
150
+ # ``5/10`` means every ten units beginning at five.
151
+ end = maximum if separator else start
152
+
153
+ values.update(range(start, end + 1, step))
154
+
155
+ if normalize_weekday and 7 in values:
156
+ values.discard(7)
157
+ values.add(0)
158
+ if not values:
159
+ raise ValueError(f"cron field has no values: {expression!r}")
160
+ return values, unrestricted
161
+
162
+
163
+ def _cron_fields(expression: str) -> tuple[
164
+ set[int], set[int], set[int], set[int], set[int], bool, bool
165
+ ]:
166
+ parts = str(expression).split()
167
+ if len(parts) != 5:
168
+ raise ValueError("cron expression must contain exactly five fields")
169
+ minutes, _ = _expand_cron_field(parts[0], minimum=0, maximum=59)
170
+ hours, _ = _expand_cron_field(parts[1], minimum=0, maximum=23)
171
+ days, day_unrestricted = _expand_cron_field(parts[2], minimum=1, maximum=31)
172
+ months, _ = _expand_cron_field(
173
+ parts[3], minimum=1, maximum=12, names=_MONTH_NAMES
174
+ )
175
+ weekdays, weekday_unrestricted = _expand_cron_field(
176
+ parts[4],
177
+ minimum=0,
178
+ maximum=7,
179
+ names=_WEEKDAY_NAMES,
180
+ normalize_weekday=True,
181
+ )
182
+ return (
183
+ minutes,
184
+ hours,
185
+ days,
186
+ months,
187
+ weekdays,
188
+ day_unrestricted,
189
+ weekday_unrestricted,
190
+ )
191
+
192
+
193
+ def next_cron_run(expression: str, after: datetime | None = None) -> datetime:
194
+ """Return the next UTC run for a standard five-field cron expression.
195
+
196
+ Day-of-month and day-of-week use the traditional cron OR rule when both
197
+ fields are restricted.
198
+ """
199
+ (
200
+ minutes,
201
+ hours,
202
+ days,
203
+ months,
204
+ weekdays,
205
+ day_unrestricted,
206
+ weekday_unrestricted,
207
+ ) = _cron_fields(expression)
208
+
209
+ base = after or utc_now()
210
+ if base.tzinfo is None:
211
+ base = base.replace(tzinfo=timezone.utc)
212
+ base = base.astimezone(timezone.utc)
213
+ day_start = base.replace(hour=0, minute=0, second=0, microsecond=0)
214
+
215
+ # Iterate by calendar day, then only through the allowed hour/minute
216
+ # combinations. This keeps sparse expressions (for example February
217
+ # 29th) cheap and prevents malformed schedules from blocking an event loop
218
+ # while scanning millions of individual minutes.
219
+ for offset in range(366 * 8 + 1):
220
+ current_day = day_start + timedelta(days=offset)
221
+ cron_weekday = (current_day.weekday() + 1) % 7
222
+ day_matches = current_day.day in days
223
+ weekday_matches = cron_weekday in weekdays
224
+ if day_unrestricted:
225
+ calendar_day_matches = weekday_matches
226
+ elif weekday_unrestricted:
227
+ calendar_day_matches = day_matches
228
+ else:
229
+ calendar_day_matches = day_matches or weekday_matches
230
+
231
+ if (
232
+ current_day.month not in months
233
+ or not calendar_day_matches
234
+ ):
235
+ continue
236
+ for hour in sorted(hours):
237
+ for minute in sorted(minutes):
238
+ candidate = current_day.replace(hour=hour, minute=minute)
239
+ if candidate > base:
240
+ return candidate
241
+ raise ValueError("cron expression has no run time within the next eight years")
242
+
243
+
244
+ def compute_next_run(
245
+ schedule: str,
246
+ schedule_type: ScheduleType | str,
247
+ *,
248
+ now: datetime | None = None,
249
+ previous_run: datetime | None = None,
250
+ ) -> str:
251
+ """Validate a schedule and return its next UTC ISO timestamp."""
252
+ current = now or utc_now()
253
+ if current.tzinfo is None:
254
+ current = current.replace(tzinfo=timezone.utc)
255
+ current = current.astimezone(timezone.utc)
256
+ kind = ScheduleType(schedule_type)
257
+
258
+ if kind == ScheduleType.ONCE:
259
+ raw = str(schedule).strip()
260
+ if raw.startswith("+"):
261
+ return (current + timedelta(seconds=parse_interval(raw[1:]))).isoformat()
262
+ return parse_iso_timestamp(raw).isoformat()
263
+
264
+ if kind == ScheduleType.INTERVAL:
265
+ seconds = parse_interval(schedule)
266
+ anchor = previous_run or current
267
+ if anchor.tzinfo is None:
268
+ anchor = anchor.replace(tzinfo=timezone.utc)
269
+ candidate = anchor.astimezone(timezone.utc) + timedelta(seconds=seconds)
270
+ if candidate <= current:
271
+ missed = int((current - candidate).total_seconds() // seconds) + 1
272
+ candidate += timedelta(seconds=missed * seconds)
273
+ return candidate.isoformat()
274
+
275
+ return next_cron_run(schedule, after=current).isoformat()
276
+
277
+
278
+ def earliest_timestamp(values: Iterable[str | None]) -> str | None:
279
+ """Return the earliest valid ISO timestamp from *values*."""
280
+ parsed: list[datetime] = []
281
+ for value in values:
282
+ if value:
283
+ try:
284
+ parsed.append(parse_iso_timestamp(value))
285
+ except ValueError:
286
+ continue
287
+ return min(parsed).isoformat() if parsed else None
@@ -0,0 +1,12 @@
1
+ from crabcode_core.session.meta_db import SessionMetaStore
2
+ from crabcode_core.session.storage import SessionStorage, generate_session_id
3
+
4
+ __all__ = ["CoreSession", "SessionMetaStore", "SessionStorage", "generate_session_id"]
5
+
6
+
7
+ def __getattr__(name: str):
8
+ if name == "CoreSession":
9
+ from crabcode_core.events import CoreSession
10
+
11
+ return CoreSession
12
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,141 @@
1
+ """Session export — convert session transcripts to Markdown or JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+ from crabcode_core.logging_utils import get_logger
10
+ from crabcode_core.session.storage import SessionStorage
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ def _format_ts(ts: Any) -> str:
16
+ if isinstance(ts, (int, float)) and ts > 0:
17
+ return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
18
+ if isinstance(ts, str) and ts:
19
+ return ts[:19]
20
+ return "unknown"
21
+
22
+
23
+ def export_markdown(session_id: str, cwd: str) -> str:
24
+ """Export a session transcript as Markdown."""
25
+ storage = SessionStorage(cwd, session_id)
26
+ raw_messages = storage.load_messages(full_history=True)
27
+ meta = storage.meta
28
+
29
+ if not raw_messages and not meta:
30
+ cross = SessionStorage.from_session_id(session_id)
31
+ if cross:
32
+ storage = cross
33
+ raw_messages = storage.load_messages(full_history=True)
34
+ meta = storage.meta
35
+
36
+ lines: list[str] = []
37
+
38
+ title = meta.get("title", "") or f"Session {session_id[:8]}"
39
+ lines.append(f"# {title}")
40
+ lines.append("")
41
+
42
+ header_parts: list[str] = []
43
+ if meta.get("model"):
44
+ header_parts.append(f"**Model:** {meta['model']}")
45
+ if meta.get("provider"):
46
+ header_parts.append(f"**Provider:** {meta['provider']}")
47
+ if meta.get("created_at"):
48
+ header_parts.append(f"**Created:** {_format_ts(meta['created_at'])}")
49
+ if meta.get("cwd"):
50
+ header_parts.append(f"**Project:** `{meta['cwd']}`")
51
+ if meta.get("tokens_used"):
52
+ header_parts.append(f"**Tokens:** {meta['tokens_used']:,}")
53
+ if header_parts:
54
+ lines.append(" | ".join(header_parts))
55
+ lines.append("")
56
+ lines.append("---")
57
+ lines.append("")
58
+
59
+ for raw in raw_messages:
60
+ role = raw.get("type", "user")
61
+ content = raw.get("content", "")
62
+ timestamp = raw.get("timestamp", "")
63
+
64
+ if role == "user":
65
+ lines.append(f"## User")
66
+ elif role == "assistant":
67
+ lines.append(f"## Assistant")
68
+ else:
69
+ lines.append(f"## {role}")
70
+
71
+ if timestamp:
72
+ lines.append(f"*{_format_ts(timestamp)}*")
73
+ lines.append("")
74
+
75
+ text = _extract_text(content)
76
+ tool_uses = _extract_tool_uses(content)
77
+
78
+ if text:
79
+ lines.append(text)
80
+ lines.append("")
81
+
82
+ for tool in tool_uses:
83
+ lines.append(f"<details><summary>Tool: {tool['name']}</summary>")
84
+ lines.append("")
85
+ lines.append("```json")
86
+ lines.append(json.dumps(tool.get("input", {}), indent=2, ensure_ascii=False))
87
+ lines.append("```")
88
+ lines.append("")
89
+ lines.append("</details>")
90
+ lines.append("")
91
+
92
+ return "\n".join(lines)
93
+
94
+
95
+ def export_json(session_id: str, cwd: str) -> str:
96
+ """Export a session transcript as formatted JSON."""
97
+ storage = SessionStorage(cwd, session_id)
98
+ raw_messages = storage.load_messages(full_history=True)
99
+ meta = storage.meta
100
+
101
+ if not raw_messages and not meta:
102
+ cross = SessionStorage.from_session_id(session_id)
103
+ if cross:
104
+ storage = cross
105
+ raw_messages = storage.load_messages(full_history=True)
106
+ meta = storage.meta
107
+
108
+ data = {
109
+ "session_id": session_id,
110
+ "meta": meta,
111
+ "messages": raw_messages,
112
+ }
113
+ return json.dumps(data, indent=2, ensure_ascii=False)
114
+
115
+
116
+ def _extract_text(content: Any) -> str:
117
+ if isinstance(content, str):
118
+ return content
119
+ if isinstance(content, list):
120
+ parts: list[str] = []
121
+ for block in content:
122
+ if isinstance(block, dict):
123
+ if block.get("type") == "text" and "text" in block:
124
+ parts.append(block["text"])
125
+ elif block.get("type") == "thinking" and "thinking" in block:
126
+ parts.append(f"*Thinking: {block['thinking'][:200]}...*")
127
+ return "\n\n".join(parts)
128
+ return ""
129
+
130
+
131
+ def _extract_tool_uses(content: Any) -> list[dict[str, Any]]:
132
+ if not isinstance(content, list):
133
+ return []
134
+ tools: list[dict[str, Any]] = []
135
+ for block in content:
136
+ if isinstance(block, dict) and block.get("type") == "tool_use":
137
+ tools.append({
138
+ "name": block.get("name", "unknown"),
139
+ "input": block.get("input", {}),
140
+ })
141
+ return tools