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,389 @@
1
+ """Bulk provider-export import: claude.ai + xAI (Grok) account exports.
2
+
3
+ A manual, downloaded **account export** is a different shape from the live CLI
4
+ stores the watcher tails — a ZIP (or unzipped batch directory) of *all* a user's
5
+ conversations. This module loads such a bundle and imports each conversation as its
6
+ own thread, reusing the shared parse → :func:`assemble_events` write path.
7
+
8
+ - **claude.ai** (Settings → Account → Export Data): a ZIP with ``conversations.json``
9
+ (+ ``memories``/``projects``/``users``) at the root, or the newer
10
+ ``data-…-batch-NNNN/`` directory form. Threads land as ``source='claude'``.
11
+ - **xAI/Grok** (Settings → Data Controls → Download Your Data): a tree with
12
+ ``…/export_data/<uuid>/prod-grok-backend.json``. Threads land as ``source='grok'``
13
+ (the conversation UUIDs are disjoint from the Grok-CLI session UUIDs).
14
+
15
+ The loaders below are pure file-shape readers; the vendored ``ClaudeParser``
16
+ does the claude.ai normalization. (ChatGPT exports are out of scope — the
17
+ parser is vendored but unwired.)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import zipfile
25
+ from dataclasses import dataclass
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ from thread_archive._thread_import import DefaultEventBuilder
31
+ from thread_archive._thread_import.parsers.claude import ClaudeParser
32
+
33
+ from .._store import get_session
34
+ from ._events import assemble_events
35
+ from ._state import (
36
+ create_thread,
37
+ discard_new_thread,
38
+ get_thread_by_source,
39
+ set_thread_models_from_events,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ @dataclass
46
+ class ExportImportResult:
47
+ processed: int = 0
48
+ imported: int = 0
49
+ skipped: int = 0
50
+ events_created: int = 0
51
+ # Conversations that raised mid-import and were preserved as a stub thread
52
+ # (raw + error) rather than silently skipped — see _import_conversation_stub.
53
+ errored: int = 0
54
+
55
+
56
+ # ── classification ───────────────────────────────────────────────────────────
57
+
58
+ _XAI_MARKER = "prod-grok-backend.json"
59
+
60
+
61
+ def classify_export(path: Path) -> Optional[str]:
62
+ """``'claude'`` | ``'xai'`` | None for an export ZIP or directory."""
63
+ path = Path(path)
64
+ if path.is_dir():
65
+ if (path / "conversations.json").exists():
66
+ return "claude"
67
+ if any(True for _ in path.rglob(_XAI_MARKER)):
68
+ return "xai"
69
+ return None
70
+ if zipfile.is_zipfile(path):
71
+ with zipfile.ZipFile(path, "r") as zf:
72
+ names = zf.namelist()
73
+ if any(n.rsplit("/", 1)[-1] == _XAI_MARKER for n in names):
74
+ return "xai"
75
+ if any(n.rsplit("/", 1)[-1] == "conversations.json" for n in names):
76
+ return "claude"
77
+ return None
78
+
79
+
80
+ # ── claude.ai bundle loaders ─────────────────────────────────────────────────
81
+
82
+
83
+ def _load_claude_export(path: Path) -> dict:
84
+ if path.is_dir():
85
+ return _load_claude_export_from_dir(path)
86
+ with zipfile.ZipFile(path, "r") as zf:
87
+ conversations = json.loads(zf.read("conversations.json"))
88
+ try:
89
+ memories_raw = json.loads(zf.read("memories.json"))
90
+ memories = memories_raw[0] if isinstance(memories_raw, list) and memories_raw else {}
91
+ except (KeyError, json.JSONDecodeError):
92
+ memories = {}
93
+ try:
94
+ projects = json.loads(zf.read("projects.json"))
95
+ except (KeyError, json.JSONDecodeError):
96
+ projects = []
97
+ try:
98
+ users = json.loads(zf.read("users.json"))
99
+ except (KeyError, json.JSONDecodeError):
100
+ users = []
101
+ return {"conversations": conversations, "memories": memories, "projects": projects, "users": users}
102
+
103
+
104
+ def _load_claude_export_from_dir(root: Path) -> dict:
105
+ conversations_path = root / "conversations.json"
106
+ if not conversations_path.exists():
107
+ raise FileNotFoundError(f"Batch directory missing conversations.json: {root}")
108
+ conversations = json.loads(conversations_path.read_text(encoding="utf-8"))
109
+ users_path = root / "users.json"
110
+ users = json.loads(users_path.read_text(encoding="utf-8")) if users_path.exists() else []
111
+
112
+ memories: dict = {}
113
+ memories_path = root / "memories.json"
114
+ if memories_path.exists():
115
+ raw = json.loads(memories_path.read_text(encoding="utf-8"))
116
+ if isinstance(raw, list) and raw and isinstance(raw[0], dict):
117
+ memories = raw[0]
118
+ elif isinstance(raw, dict):
119
+ memories = raw
120
+
121
+ projects: list = []
122
+ projects_dir = root / "projects"
123
+ projects_file = root / "projects.json"
124
+ if projects_dir.is_dir():
125
+ for project_file in sorted(projects_dir.glob("*.json")):
126
+ try:
127
+ projects.append(json.loads(project_file.read_text(encoding="utf-8")))
128
+ except (json.JSONDecodeError, OSError) as e:
129
+ logger.warning("skipping malformed project file %s: %s", project_file, e)
130
+ elif projects_file.exists():
131
+ projects = json.loads(projects_file.read_text(encoding="utf-8"))
132
+
133
+ return {"conversations": conversations, "memories": memories, "projects": projects, "users": users}
134
+
135
+
136
+ def import_claude_ai_export(
137
+ path, *, force: bool = False, limit: Optional[int] = None
138
+ ) -> ExportImportResult:
139
+ """Import a claude.ai account export (ZIP or batch dir). Threads → source='claude'."""
140
+ path = Path(path)
141
+ if not path.exists():
142
+ raise FileNotFoundError(f"Export not found: {path}")
143
+
144
+ bundle = _load_claude_export(path)
145
+ parser = ClaudeParser()
146
+ builder = DefaultEventBuilder()
147
+ result = ExportImportResult()
148
+
149
+ non_empty = [c for c in bundle["conversations"] if c.get("chat_messages")]
150
+ non_empty.sort(key=lambda c: c.get("updated_at") or c.get("created_at") or "", reverse=True)
151
+ if limit:
152
+ non_empty = non_empty[:limit]
153
+ result.processed = len(non_empty)
154
+
155
+ for conv in non_empty:
156
+ source_id = conv.get("uuid", "")
157
+ title = conv.get("name") or "Untitled"
158
+ single = {
159
+ "conversations": [conv],
160
+ "memories": bundle["memories"],
161
+ "projects": bundle["projects"],
162
+ "users": bundle["users"],
163
+ }
164
+ result = _import_one(
165
+ result, parser_messages=lambda s=single: parser.parse_export(s),
166
+ source="claude", source_id=source_id, title=title,
167
+ source_metadata={"provider": "claude", "surface": "web"},
168
+ builder=builder, force=force, raw=conv,
169
+ )
170
+ return result
171
+
172
+
173
+ # ── xAI (Grok) export loaders ────────────────────────────────────────────────
174
+
175
+
176
+ def _load_xai_export(path: Path) -> list:
177
+ conversations: list = []
178
+ if path.is_dir():
179
+ payloads = sorted(p for p in path.rglob(_XAI_MARKER) if p.is_file())
180
+ if not payloads:
181
+ raise FileNotFoundError(f"xAI export missing {_XAI_MARKER}: {path}")
182
+ for payload in payloads:
183
+ data = json.loads(payload.read_text(encoding="utf-8"))
184
+ conversations.extend(data.get("conversations") or [])
185
+ return conversations
186
+ with zipfile.ZipFile(path, "r") as zf:
187
+ names = sorted(n for n in zf.namelist() if n.rsplit("/", 1)[-1] == _XAI_MARKER)
188
+ if not names:
189
+ raise FileNotFoundError(f"xAI export ZIP missing {_XAI_MARKER}: {path}")
190
+ for name in names:
191
+ data = json.loads(zf.read(name))
192
+ conversations.extend(data.get("conversations") or [])
193
+ return conversations
194
+
195
+
196
+ def _xai_parse_time(v) -> Optional[datetime]:
197
+ if isinstance(v, dict):
198
+ inner = v.get("$date")
199
+ if isinstance(inner, dict):
200
+ inner = inner.get("$numberLong")
201
+ if inner is None:
202
+ return None
203
+ try:
204
+ return datetime.fromtimestamp(int(inner) / 1000, tz=timezone.utc)
205
+ except (TypeError, ValueError, OSError):
206
+ return None
207
+ if isinstance(v, (int, float)):
208
+ try:
209
+ return datetime.fromtimestamp(v / 1000 if v > 1e11 else v, tz=timezone.utc)
210
+ except (ValueError, OSError):
211
+ return None
212
+ if isinstance(v, str):
213
+ try:
214
+ return datetime.fromisoformat(v.replace("Z", "+00:00"))
215
+ except ValueError:
216
+ return None
217
+ return None
218
+
219
+
220
+ def _xai_conversation_messages(bundle: dict) -> list[dict]:
221
+ conv = bundle.get("conversation") or {}
222
+ fallback_ts = _xai_parse_time(conv.get("create_time")) or datetime.now(timezone.utc)
223
+ responses = [r.get("response") or {} for r in bundle.get("responses", [])]
224
+ responses.sort(key=lambda r: _xai_parse_time(r.get("create_time")) or fallback_ts)
225
+ messages: list[dict] = []
226
+ for resp in responses:
227
+ text_str = (resp.get("message") or "").strip()
228
+ sender = (resp.get("sender") or "").lower()
229
+ created = _xai_parse_time(resp.get("create_time")) or fallback_ts
230
+ provider_data: dict = {"provider": "grok"}
231
+ if sender != "human" and resp.get("model"):
232
+ provider_data["model"] = resp["model"]
233
+
234
+ if text_str:
235
+ role = "user" if sender == "human" else "assistant"
236
+ content_text = text_str
237
+ content_blocks: list[dict] = [{"type": "text", "text": text_str}]
238
+ else:
239
+ # A response with no message text still carries a real turn — an
240
+ # image/attachment/generated-media or tool-only turn. Preserve it rather
241
+ # than dropping it (the old `continue` lost every one). Route it through a
242
+ # generic role so the shared builder keeps it as a `message` event with
243
+ # the full raw response — the user-role builder discards a text-less turn,
244
+ # so plain "user"/"assistant" wouldn't reliably survive. The sender + raw
245
+ # ride along in provider_data too.
246
+ role = f"grok_{sender or 'unknown'}"
247
+ content_text = ""
248
+ content_blocks = [{"type": "grok_raw", "data": resp}]
249
+ provider_data["sender"] = sender or None
250
+ provider_data["raw"] = resp
251
+ messages.append({
252
+ "role": role,
253
+ "content_text": content_text,
254
+ "content_blocks": content_blocks,
255
+ "created_at": created.isoformat(),
256
+ "provider_message_id": resp.get("_id", ""),
257
+ "provider_data": provider_data,
258
+ })
259
+ return messages
260
+
261
+
262
+ def import_xai_export(
263
+ path, *, force: bool = False, limit: Optional[int] = None
264
+ ) -> ExportImportResult:
265
+ """Import an xAI/Grok account export (ZIP or dir). Threads → source='grok'."""
266
+ path = Path(path)
267
+ if not path.exists():
268
+ raise FileNotFoundError(f"Export not found: {path}")
269
+
270
+ conversations = _load_xai_export(path)
271
+ builder = DefaultEventBuilder()
272
+ result = ExportImportResult()
273
+
274
+ non_empty = [c for c in conversations if c.get("responses")]
275
+ non_empty.sort(
276
+ key=lambda c: (c.get("conversation") or {}).get("modify_time")
277
+ or (c.get("conversation") or {}).get("create_time") or "",
278
+ reverse=True,
279
+ )
280
+ if limit:
281
+ non_empty = non_empty[:limit]
282
+ result.processed = len(non_empty)
283
+
284
+ for bundle in non_empty:
285
+ conv = bundle.get("conversation") or {}
286
+ source_id = conv.get("id", "")
287
+ title = conv.get("title") or "Untitled"
288
+ result = _import_one(
289
+ result, parser_messages=lambda b=bundle: _xai_conversation_messages(b),
290
+ source="grok", source_id=source_id, title=title,
291
+ source_metadata={"provider": "grok", "surface": "web"},
292
+ builder=builder, force=force, raw=bundle,
293
+ )
294
+ return result
295
+
296
+
297
+ # ── shared per-conversation write path ───────────────────────────────────────
298
+
299
+
300
+ def _import_conversation_stub(
301
+ source: str, source_id: str, title: str, source_metadata: dict,
302
+ builder: DefaultEventBuilder, raw, error: Exception,
303
+ ) -> None:
304
+ """Preserve a conversation that failed to import as its own stub thread.
305
+
306
+ Carries the raw payload + error under a distinct ``:import-error`` source id, so
307
+ the failure is visible in the archive and re-exportable, while the real
308
+ ``source_id`` stays unimported and retryable (a plain re-run without ``force``
309
+ won't mistake the stub for a successful import). Idempotent via the builder's
310
+ dedup_key.
311
+ """
312
+ stub_source_id = f"{source_id}:import-error"
313
+ message = {
314
+ "role": f"{source}_import_error",
315
+ "created_at": None,
316
+ "content_text": f"[{source} import failed for {source_id}: {error}]",
317
+ "content_blocks": [{"type": "import_error", "error": str(error), "data": raw}],
318
+ "provider_message_id": stub_source_id,
319
+ "provider_data": {"provider": source, "import_error": str(error)},
320
+ }
321
+ with get_session() as s:
322
+ existing = get_thread_by_source(s, source, stub_source_id)
323
+ if existing and existing.id is not None:
324
+ thread_id = existing.id
325
+ else:
326
+ stub_title = f"{title or 'Untitled'} (import error)"
327
+ thread_id = create_thread(
328
+ s, source=source, source_id=stub_source_id,
329
+ title=stub_title[:100], source_metadata={**source_metadata, "import_error": True},
330
+ )
331
+ assemble_events(s, thread_id, [message], builder)
332
+ s.commit()
333
+
334
+
335
+ def _import_one(
336
+ result: ExportImportResult, *, parser_messages, source: str, source_id: str,
337
+ title: str, source_metadata: dict, builder: DefaultEventBuilder, force: bool,
338
+ raw=None,
339
+ ) -> ExportImportResult:
340
+ """Import one export conversation into its own thread (atomic per conversation)."""
341
+ if not source_id:
342
+ result.skipped += 1
343
+ return result
344
+ try:
345
+ with get_session() as s:
346
+ existing = get_thread_by_source(s, source, source_id)
347
+ if existing and not force:
348
+ result.skipped += 1
349
+ return result
350
+ messages = parser_messages()
351
+ if not messages:
352
+ result.skipped += 1
353
+ return result
354
+ thread_id = create_thread(
355
+ s, source=source, source_id=source_id,
356
+ title=title[:100] if title else None, source_metadata=source_metadata,
357
+ )
358
+ n, _ = assemble_events(s, thread_id, messages, builder)
359
+ if n == 0:
360
+ # Row AND staged truth record — no ghost threads/<id>.jsonl on commit.
361
+ discard_new_thread(s, thread_id)
362
+ result.skipped += 1
363
+ else:
364
+ set_thread_models_from_events(s, thread_id)
365
+ result.imported += 1
366
+ result.events_created += n
367
+ s.commit()
368
+ except Exception as e: # noqa: BLE001 — one bad conversation must not stop the export
369
+ # Log the full traceback (a one-line warning hid the cause) and preserve a
370
+ # stub thread carrying the raw conversation + error, so a bad conversation is
371
+ # visible in the archive and re-exportable instead of silently skipped.
372
+ logger.exception("export import error for %s:%s", source, source_id)
373
+ try:
374
+ _import_conversation_stub(source, source_id, title, source_metadata, builder, raw, e)
375
+ result.errored += 1
376
+ except Exception:
377
+ logger.exception("export stub also failed for %s:%s", source, source_id)
378
+ result.skipped += 1
379
+ return result
380
+
381
+
382
+ def import_export(path, *, force: bool = False) -> ExportImportResult:
383
+ """Auto-classify a claude.ai / xAI export and import it. Raises on unknown shape."""
384
+ kind = classify_export(Path(path))
385
+ if kind == "claude":
386
+ return import_claude_ai_export(path, force=force)
387
+ if kind == "xai":
388
+ return import_xai_export(path, force=force)
389
+ raise ValueError(f"Unrecognized export (not claude.ai or xAI): {path}")