thread-archive 0.0.2__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 (132) hide show
  1. thread_archive/__init__.py +34 -0
  2. thread_archive/_api.py +2235 -0
  3. thread_archive/_config.py +126 -0
  4. thread_archive/_importers/__init__.py +81 -0
  5. thread_archive/_importers/_continuation.py +136 -0
  6. thread_archive/_importers/_cursor.py +103 -0
  7. thread_archive/_importers/_events.py +244 -0
  8. thread_archive/_importers/_line_stream.py +193 -0
  9. thread_archive/_importers/_read.py +82 -0
  10. thread_archive/_importers/_result.py +17 -0
  11. thread_archive/_importers/_sidecar.py +113 -0
  12. thread_archive/_importers/_state.py +240 -0
  13. thread_archive/_importers/_titles.py +179 -0
  14. thread_archive/_importers/antigravity.py +254 -0
  15. thread_archive/_importers/claude_code.py +293 -0
  16. thread_archive/_importers/claude_science.py +330 -0
  17. thread_archive/_importers/cloth.py +20 -0
  18. thread_archive/_importers/codex.py +324 -0
  19. thread_archive/_importers/cowork.py +107 -0
  20. thread_archive/_importers/cursor.py +432 -0
  21. thread_archive/_importers/exports.py +389 -0
  22. thread_archive/_importers/grok.py +600 -0
  23. thread_archive/_importers/opencode.py +538 -0
  24. thread_archive/_knowledge/__init__.py +67 -0
  25. thread_archive/_knowledge/_claims.py +116 -0
  26. thread_archive/_knowledge/_community.py +75 -0
  27. thread_archive/_knowledge/graph.py +208 -0
  28. thread_archive/_knowledge/materialize.py +212 -0
  29. thread_archive/_knowledge/write.py +438 -0
  30. thread_archive/_launchd.py +167 -0
  31. thread_archive/_mcp/__init__.py +1 -0
  32. thread_archive/_mcp/librarian.py +151 -0
  33. thread_archive/_mcp/server.py +307 -0
  34. thread_archive/_retrieval/__init__.py +280 -0
  35. thread_archive/_retrieval/_classify.py +80 -0
  36. thread_archive/_retrieval/_codex.py +157 -0
  37. thread_archive/_retrieval/_context.py +123 -0
  38. thread_archive/_retrieval/_extract.py +184 -0
  39. thread_archive/_retrieval/embed.py +186 -0
  40. thread_archive/_retrieval/format.py +149 -0
  41. thread_archive/_retrieval/fts.py +538 -0
  42. thread_archive/_retrieval/rank.py +228 -0
  43. thread_archive/_retrieval/read.py +908 -0
  44. thread_archive/_retrieval/rerank.py +143 -0
  45. thread_archive/_retrieval/vectors.py +611 -0
  46. thread_archive/_scripts/__init__.py +1 -0
  47. thread_archive/_scripts/backfill_recompute.py +328 -0
  48. thread_archive/_scripts/backfill_reconcile.py +296 -0
  49. thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
  50. thread_archive/_scripts/recover_dropped_events.py +190 -0
  51. thread_archive/_scripts/repair_grok_tool_names.py +118 -0
  52. thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
  53. thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
  54. thread_archive/_setup/__init__.py +12 -0
  55. thread_archive/_setup/clients.py +89 -0
  56. thread_archive/_setup/wizard.py +474 -0
  57. thread_archive/_store/__init__.py +45 -0
  58. thread_archive/_store/_base.py +173 -0
  59. thread_archive/_store/_defaults.py +57 -0
  60. thread_archive/_store/_types.py +38 -0
  61. thread_archive/_store/models.py +370 -0
  62. thread_archive/_store/schema.py +84 -0
  63. thread_archive/_thread_import/__init__.py +40 -0
  64. thread_archive/_thread_import/api.py +78 -0
  65. thread_archive/_thread_import/event_builder.py +810 -0
  66. thread_archive/_thread_import/exporters/__init__.py +9 -0
  67. thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
  68. thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
  69. thread_archive/_thread_import/exporters/cursor.py +582 -0
  70. thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
  71. thread_archive/_thread_import/parsers/__init__.py +191 -0
  72. thread_archive/_thread_import/parsers/base.py +698 -0
  73. thread_archive/_thread_import/parsers/chatgpt.py +722 -0
  74. thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
  75. thread_archive/_thread_import/parsers/claude.py +443 -0
  76. thread_archive/_thread_import/parsers/claude_code.py +1035 -0
  77. thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
  78. thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
  79. thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
  80. thread_archive/_thread_import/parsers/config/__init__.py +26 -0
  81. thread_archive/_thread_import/parsers/config/base.py +220 -0
  82. thread_archive/_thread_import/parsers/cursor.py +538 -0
  83. thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
  84. thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
  85. thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
  86. thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
  87. thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
  88. thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
  89. thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
  90. thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
  91. thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
  92. thread_archive/_thread_import/parsers/types/__init__.py +56 -0
  93. thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
  94. thread_archive/_thread_import/parsers/types/claude.py +120 -0
  95. thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
  96. thread_archive/_thread_import/parsers/types/cursor.py +109 -0
  97. thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
  98. thread_archive/_thread_import/parsers/validators/base.py +168 -0
  99. thread_archive/_thread_import/parsers/validators/content.py +80 -0
  100. thread_archive/_thread_import/parsers/validators/referential.py +57 -0
  101. thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
  102. thread_archive/_thread_import/parsers/validators/types.py +87 -0
  103. thread_archive/_thread_import/schemas/__init__.py +231 -0
  104. thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
  105. thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
  106. thread_archive/_thread_import/schemas/claude/v1.json +188 -0
  107. thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
  108. thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
  109. thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
  110. thread_archive/_thread_import/timestamps.py +57 -0
  111. thread_archive/_thread_import/tool_names.py +48 -0
  112. thread_archive/_truth/__init__.py +40 -0
  113. thread_archive/_truth/jsonl_log.py +2340 -0
  114. thread_archive/_truth/repair.py +322 -0
  115. thread_archive/_watcher/__init__.py +57 -0
  116. thread_archive/_watcher/base.py +65 -0
  117. thread_archive/_watcher/daemon.py +289 -0
  118. thread_archive/_watcher/export_drop.py +203 -0
  119. thread_archive/_watcher/exthost.py +316 -0
  120. thread_archive/_watcher/lazy.py +117 -0
  121. thread_archive/_watcher/sources.py +616 -0
  122. thread_archive/_web/__init__.py +15 -0
  123. thread_archive/_web/server.py +299 -0
  124. thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
  125. thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
  126. thread_archive/_web/static/index.html +13 -0
  127. thread_archive/cli.py +746 -0
  128. thread_archive-0.0.2.dist-info/METADATA +296 -0
  129. thread_archive-0.0.2.dist-info/RECORD +132 -0
  130. thread_archive-0.0.2.dist-info/WHEEL +4 -0
  131. thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
  132. thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,432 @@
1
+ """Cursor (`state.vscdb`) DB scan + per-composer import + assembler.
2
+
3
+ A single live SQLite DB holds many conversations (composers + their bubbles). We
4
+ open it read-only, extract every composer, and run the per-composer importer
5
+ (incremental skip via the import_state cursor on ``lastUpdatedAt``). Pure
6
+ ``_cursor_*`` / ``_build_cursor_messages`` helpers copied verbatim; orchestration
7
+ rewired onto our store + ``assemble_events``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Any, Optional
18
+
19
+ from thread_archive._thread_import import DefaultEventBuilder
20
+
21
+ from .._store import ImportState, get_session
22
+ from ._events import assemble_events
23
+ from ._state import create_thread, get_import_state, get_thread_by_source, upsert_import_state
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ @dataclass
29
+ class CursorImportResult:
30
+ events_created: int
31
+ thread_id: int
32
+ is_new_thread: bool
33
+
34
+
35
+ @dataclass
36
+ class CursorDbScanResult:
37
+ composers_processed: int
38
+ composers_imported: int
39
+ events_created: int
40
+ composers_failed: int = 0
41
+ errors: list[str] = field(default_factory=list)
42
+
43
+
44
+ def import_cursor_db(db_path) -> CursorDbScanResult:
45
+ """Open a Cursor ``state.vscdb`` and import every composer in it."""
46
+ import sqlite3
47
+
48
+ db_path = Path(db_path)
49
+ composers: dict[str, dict[str, Any]] = {}
50
+ bubbles: dict[str, dict[str, Any]] = {}
51
+
52
+ # Cursor's state.vscdb is live — open read-only (never lock a DB the editor
53
+ # owns) with a busy timeout.
54
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
55
+ try:
56
+ conn.execute("PRAGMA busy_timeout=5000")
57
+ if not conn.execute(
58
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'"
59
+ ).fetchone():
60
+ return CursorDbScanResult(0, 0, 0)
61
+
62
+ for key, value in conn.execute(
63
+ "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'"
64
+ ):
65
+ composer_id = key.replace("composerData:", "")
66
+ try:
67
+ composers[composer_id] = json.loads(value)
68
+ except (json.JSONDecodeError, TypeError) as e:
69
+ # A corrupt composer blob must not vanish on a silent `continue`.
70
+ # Log it and preserve a stub thread carrying the raw + error so a
71
+ # failed conversation is visible in the archive, not silently lost.
72
+ logger.warning(
73
+ "import_cursor_db: composer %s failed to parse; preserving stub: %s",
74
+ composer_id[:8], e,
75
+ )
76
+ try:
77
+ _import_cursor_error_stub(composer_id, value, {}, e)
78
+ except Exception:
79
+ logger.exception(
80
+ "import_cursor_db: composer %s parse-stub failed", composer_id[:8]
81
+ )
82
+ continue
83
+
84
+ for key, value in conn.execute(
85
+ "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'"
86
+ ):
87
+ parts = key.split(":")
88
+ if len(parts) < 3:
89
+ logger.warning("import_cursor_db: malformed bubble key %r; skipping", key)
90
+ continue
91
+ composer_id, bubble_id = parts[1], parts[2]
92
+ try:
93
+ data = json.loads(value)
94
+ data["_composerId"] = composer_id
95
+ except (json.JSONDecodeError, TypeError) as e:
96
+ # A corrupt bubble must not vanish on a silent `continue`. Keep a raw
97
+ # stub: if a header references it, the unknown-bubble path
98
+ # (_cursor_to_normalized) preserves it as a `message` event.
99
+ logger.warning(
100
+ "import_cursor_db: bubble %s failed to parse; keeping raw stub: %s", key, e
101
+ )
102
+ data = {
103
+ "_composerId": composer_id,
104
+ "_parse_error": str(e),
105
+ "_raw": value,
106
+ "type": None,
107
+ }
108
+ bubbles[f"{composer_id}:{bubble_id}"] = data
109
+ finally:
110
+ conn.close()
111
+
112
+ summary = CursorDbScanResult(0, 0, 0)
113
+ for composer_id, composer_data in composers.items():
114
+ summary.composers_processed += 1
115
+ composer_bubbles = {
116
+ k: v for k, v in bubbles.items() if k.startswith(f"{composer_id}:")
117
+ }
118
+ try:
119
+ result = import_cursor_from_payload(
120
+ composer_id=composer_id, composer_data=composer_data, bubbles=composer_bubbles
121
+ )
122
+ if result.events_created > 0:
123
+ summary.composers_imported += 1
124
+ summary.events_created += result.events_created
125
+ except Exception as e:
126
+ # One composer blowing up must not skip it silently. Log the traceback,
127
+ # count it out to the watcher's health, AND preserve a stub thread carrying
128
+ # its id + raw + error so the failed conversation stays visible in the
129
+ # archive and re-exportable.
130
+ logger.exception(
131
+ "import_cursor_db: composer %s failed; preserving stub", composer_id[:8]
132
+ )
133
+ summary.composers_failed += 1
134
+ summary.errors.append(f"composer {composer_id[:8]}: {e}")
135
+ try:
136
+ _import_cursor_error_stub(composer_id, composer_data, composer_bubbles, e)
137
+ except Exception:
138
+ logger.exception(
139
+ "import_cursor_db: composer %s stub also failed", composer_id[:8]
140
+ )
141
+ return summary
142
+
143
+
144
+ def _import_cursor_error_stub(
145
+ composer_id: str, composer_data: Any, bubbles: dict[str, Any], error: Exception
146
+ ) -> None:
147
+ """Preserve a composer that failed to load/import as its own stub thread.
148
+
149
+ A corrupt (unparseable JSON) or blow-up composer must not vanish behind a
150
+ silent ``continue`` on the load or a logged-and-dropped ``skipping`` on the
151
+ import. Keep a stub thread carrying the raw payload + error under a distinct
152
+ ``:import-error`` source id, so the failure is visible in the archive and
153
+ re-exportable while the real composer stays unimported and retryable on the
154
+ next scan (its ``import_state`` watermark never advanced). Idempotent via the
155
+ builder's dedup_key.
156
+ """
157
+ stub_source_id = f"{composer_id}:import-error"
158
+ raw: dict[str, Any] = {
159
+ "composer_id": composer_id,
160
+ "error": str(error),
161
+ "composer_data": composer_data,
162
+ "bubble_keys": sorted(bubbles.keys()),
163
+ }
164
+ message = {
165
+ "role": "cursor_import_error",
166
+ "created_at": None,
167
+ "content_text": f"[cursor import failed for {composer_id}: {error}]",
168
+ "content_blocks": [{"type": "cursor_raw", "data": raw}],
169
+ "provider_message_id": stub_source_id,
170
+ "provider_data": {"provider": "cursor", "role": "unknown", "import_error": str(error)},
171
+ }
172
+ with get_session() as s:
173
+ existing = get_thread_by_source(s, "cursor", stub_source_id)
174
+ if existing and existing.id is not None:
175
+ thread_id = existing.id
176
+ else:
177
+ name = composer_data.get("name") if isinstance(composer_data, dict) else None
178
+ title = f"{name or 'Cursor Conversation'} (import error)"
179
+ if len(title) > 100:
180
+ title = title[:97] + "..."
181
+ thread_id = create_thread(s, source="cursor", source_id=stub_source_id, title=title)
182
+ assemble_events(s, thread_id, [message], DefaultEventBuilder())
183
+ s.commit()
184
+
185
+
186
+ def import_cursor_from_payload(
187
+ *, composer_id: str, composer_data: dict[str, Any], bubbles: dict[str, Any], session=None
188
+ ) -> CursorImportResult:
189
+ """Import one Cursor composer (atomic per-composer transaction when no session)."""
190
+ if session is not None:
191
+ return _run_cursor(session, composer_id, composer_data, bubbles)
192
+ with get_session() as s:
193
+ result = _run_cursor(s, composer_id, composer_data, bubbles)
194
+ s.commit()
195
+ return result
196
+
197
+
198
+ def _run_cursor(session, composer_id, composer_data, bubbles) -> CursorImportResult:
199
+ source_id = composer_id
200
+ import_state = get_import_state(session, "cursor", source_id)
201
+
202
+ if _cursor_composer_unchanged(import_state, composer_data):
203
+ return CursorImportResult(0, (import_state.thread_id or 0) if import_state else 0, False)
204
+
205
+ messages = _build_cursor_messages(composer_id, composer_data, bubbles)
206
+ if not messages:
207
+ return CursorImportResult(0, 0, False)
208
+
209
+ thread_id, is_new_thread = _cursor_resolve_thread(session, import_state, source_id, composer_data)
210
+
211
+ start_index = import_state.last_line_count if import_state else 0
212
+ new_messages = messages[start_index:]
213
+ if not new_messages:
214
+ return CursorImportResult(0, thread_id, is_new_thread)
215
+
216
+ normalized = [_cursor_to_normalized(m) for m in new_messages]
217
+ # cross_pass_dedup: a full re-scan of an already-imported composer (import_state
218
+ # reset, or a manual migration re-run) must be a no-op even against rows the
219
+ # dedup_key can't see — e.g. the March 2026 backfill seeded from Postgres carries
220
+ # NULL dedup_key, so a re-import stacked duplicate events. The message-level
221
+ # (content + timestamp) existence check skips any turn already present regardless
222
+ # of dedup_key, mirroring the claude-code continuation guard.
223
+ events_created, _ = assemble_events(
224
+ session, thread_id, normalized, DefaultEventBuilder(), cross_pass_dedup=True
225
+ )
226
+ upsert_import_state(
227
+ session,
228
+ source="cursor",
229
+ source_id=source_id,
230
+ thread_id=thread_id,
231
+ last_line_count=len(messages),
232
+ last_file_size=0,
233
+ last_message_uuid=messages[-1].get("id") if messages else None,
234
+ )
235
+ return CursorImportResult(events_created, thread_id, is_new_thread)
236
+
237
+
238
+ def _cursor_composer_unchanged(import_state: Optional[ImportState], composer_data: dict[str, Any]) -> bool:
239
+ if not (import_state and import_state.last_import_at):
240
+ return False
241
+ last_updated_ms = composer_data.get("lastUpdatedAt", 0)
242
+ last_import_ms = import_state.last_import_at.timestamp() * 1000
243
+ return last_updated_ms <= last_import_ms
244
+
245
+
246
+ def _cursor_resolve_thread(session, import_state, source_id, composer_data) -> tuple[int, bool]:
247
+ if import_state and import_state.thread_id:
248
+ return import_state.thread_id, False
249
+ existing = get_thread_by_source(session, "cursor", source_id)
250
+ if existing and existing.id is not None:
251
+ return existing.id, False
252
+ title = composer_data.get("name") or "Cursor Conversation"
253
+ if len(title) > 100:
254
+ title = title[:97] + "..."
255
+ thread_id = create_thread(session, source="cursor", source_id=source_id, title=title)
256
+ return thread_id, True
257
+
258
+
259
+ def _build_cursor_messages(
260
+ composer_id: str, composer_data: dict[str, Any], all_bubbles: dict[str, Any]
261
+ ) -> list[dict[str, Any]]:
262
+ """Assemble Cursor composer + bubbles into the normalized message shape."""
263
+ messages: list[dict[str, Any]] = []
264
+ headers = composer_data.get("fullConversationHeadersOnly", [])
265
+ if not headers:
266
+ return messages
267
+
268
+ for idx, header in enumerate(headers):
269
+ if not isinstance(header, dict):
270
+ continue
271
+ bubble_id = header.get("bubbleId")
272
+ if not bubble_id:
273
+ continue
274
+ bubble = all_bubbles.get(f"{composer_id}:{bubble_id}", {})
275
+ bubble_type = bubble.get("type") or header.get("type")
276
+ if bubble_type == 1:
277
+ role = "user"
278
+ elif bubble_type == 2:
279
+ role = "assistant"
280
+ else:
281
+ role = "unknown"
282
+
283
+ message: dict[str, Any] = {
284
+ "id": bubble_id,
285
+ "role": role,
286
+ "content": bubble.get("text", ""),
287
+ "created_at": bubble.get("createdAt"),
288
+ "index": idx,
289
+ }
290
+ # An unmodeled bubble type keeps its raw payload + resolved type so the
291
+ # normalized mapping can preserve the whole turn (content/thinking/tool/raw)
292
+ # rather than drop it — see _cursor_to_normalized's unknown branch.
293
+ if role == "unknown":
294
+ message["bubble_type"] = bubble_type
295
+ if bubble:
296
+ message["raw"] = bubble
297
+
298
+ thinking = bubble.get("thinking")
299
+ if thinking and isinstance(thinking, dict):
300
+ thinking_text = thinking.get("text", "")
301
+ if thinking_text:
302
+ message["thinking"] = thinking_text
303
+
304
+ tool_data = bubble.get("toolFormerData")
305
+ if tool_data and isinstance(tool_data, dict):
306
+ # ``params`` is Cursor's resolved arg dict; ``rawArgs`` the model's raw
307
+ # JSON-string args. Prefer params, fall back to parsed rawArgs. ``result``
308
+ # is the tool output. Capturing input+result lets us emit the tool_use +
309
+ # tool_execution events the builder produces — dropping them would lose
310
+ # every tool call on a re-parse.
311
+ tool_input = tool_data.get("params")
312
+ if tool_input is None:
313
+ raw = tool_data.get("rawArgs")
314
+ if isinstance(raw, str):
315
+ try:
316
+ tool_input = json.loads(raw)
317
+ except json.JSONDecodeError:
318
+ tool_input = raw
319
+ else:
320
+ tool_input = raw
321
+ message["tool_call"] = {
322
+ "name": tool_data.get("name", "unknown"),
323
+ "tool_id": tool_data.get("tool"),
324
+ "call_id": tool_data.get("toolCallId"),
325
+ "status": tool_data.get("status"),
326
+ "input": tool_input,
327
+ "result": tool_data.get("result"),
328
+ }
329
+ messages.append(message)
330
+ return messages
331
+
332
+
333
+ def _cursor_iso(ts: Any) -> Optional[str]:
334
+ if isinstance(ts, (int, float)):
335
+ try:
336
+ secs = ts / 1000 if ts > 1e12 else ts
337
+ return datetime.fromtimestamp(secs, tz=timezone.utc).isoformat()
338
+ except (ValueError, OSError):
339
+ return None
340
+ if isinstance(ts, str) and ts:
341
+ s = ts[:-1] + "+00:00" if ts.endswith("Z") else ts
342
+ try:
343
+ dt = datetime.fromisoformat(s)
344
+ except ValueError:
345
+ return None
346
+ if dt.tzinfo is None:
347
+ dt = dt.replace(tzinfo=timezone.utc)
348
+ return dt.isoformat()
349
+ return None
350
+
351
+
352
+ def _cursor_tool_blocks(tc: dict[str, Any]) -> list[dict[str, Any]]:
353
+ """The tool_use (+ tool_result) blocks for a Cursor ``tool_call``. Shared by the
354
+ assistant and unknown-bubble mappings so a tool call is preserved either way."""
355
+ call_id = tc.get("call_id")
356
+ name = tc.get("name", "unknown")
357
+ blocks: list[dict[str, Any]] = [{
358
+ "type": "tool_use",
359
+ "tool_call_id": call_id,
360
+ "name": name,
361
+ "input": tc.get("input") or {},
362
+ }]
363
+ result = tc.get("result")
364
+ if result is not None:
365
+ content = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)
366
+ blocks.append({
367
+ "type": "tool_result",
368
+ "tool_use_id": call_id,
369
+ "name": name,
370
+ "content": content,
371
+ "is_error": tc.get("status") == "error",
372
+ })
373
+ return blocks
374
+
375
+
376
+ def _cursor_to_normalized(msg: dict[str, Any]) -> dict[str, Any]:
377
+ """Map one Cursor message into the canonical NormalizedMessage shape."""
378
+ role = msg.get("role", "")
379
+ created_at = _cursor_iso(msg.get("created_at"))
380
+ pmid = msg.get("id", "")
381
+ if role == "user":
382
+ return {
383
+ "role": "user",
384
+ "created_at": created_at,
385
+ "content_text": msg.get("content", ""),
386
+ "content_blocks": [],
387
+ "provider_message_id": pmid,
388
+ "provider_data": {"provider": "cursor", "role": "user"},
389
+ }
390
+ if role == "assistant":
391
+ blocks: list[dict[str, Any]] = []
392
+ if msg.get("thinking"):
393
+ blocks.append({"type": "thinking", "text": msg["thinking"]})
394
+ if msg.get("content"):
395
+ blocks.append({"type": "text", "text": msg["content"]})
396
+ tc = msg.get("tool_call")
397
+ if tc and isinstance(tc, dict):
398
+ blocks.extend(_cursor_tool_blocks(tc))
399
+ return {
400
+ "role": "assistant",
401
+ "created_at": created_at,
402
+ "content_text": "",
403
+ "content_blocks": blocks,
404
+ "provider_message_id": pmid,
405
+ "provider_data": {"provider": "cursor", "model": "cursor"},
406
+ }
407
+ # An unmodeled bubble type. The shared builder preserves any non-user/assistant/
408
+ # system role as a `message` event (role + content + content_blocks), so carry the
409
+ # bubble's content/thinking/tool_call + full raw here rather than dropping the turn —
410
+ # the old bare {"role": role} silently discarded every one.
411
+ role = role or "unknown"
412
+ unknown_blocks: list[dict[str, Any]] = []
413
+ if msg.get("thinking"):
414
+ unknown_blocks.append({"type": "thinking", "text": msg["thinking"]})
415
+ tc = msg.get("tool_call")
416
+ if tc and isinstance(tc, dict):
417
+ unknown_blocks.extend(_cursor_tool_blocks(tc))
418
+ provider_data: dict[str, Any] = {"provider": "cursor", "role": role}
419
+ raw = msg.get("raw")
420
+ if raw:
421
+ provider_data["raw"] = raw
422
+ unknown_blocks.append(
423
+ {"type": "cursor_raw", "bubble_type": msg.get("bubble_type"), "data": raw}
424
+ )
425
+ return {
426
+ "role": role,
427
+ "created_at": created_at,
428
+ "content_text": msg.get("content", ""),
429
+ "content_blocks": unknown_blocks,
430
+ "provider_message_id": pmid,
431
+ "provider_data": provider_data,
432
+ }