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.
- thread_archive/__init__.py +34 -0
- thread_archive/_api.py +2235 -0
- thread_archive/_config.py +126 -0
- thread_archive/_importers/__init__.py +81 -0
- thread_archive/_importers/_continuation.py +136 -0
- thread_archive/_importers/_cursor.py +103 -0
- thread_archive/_importers/_events.py +244 -0
- thread_archive/_importers/_line_stream.py +193 -0
- thread_archive/_importers/_read.py +82 -0
- thread_archive/_importers/_result.py +17 -0
- thread_archive/_importers/_sidecar.py +113 -0
- thread_archive/_importers/_state.py +240 -0
- thread_archive/_importers/_titles.py +179 -0
- thread_archive/_importers/antigravity.py +254 -0
- thread_archive/_importers/claude_code.py +293 -0
- thread_archive/_importers/claude_science.py +330 -0
- thread_archive/_importers/cloth.py +20 -0
- thread_archive/_importers/codex.py +324 -0
- thread_archive/_importers/cowork.py +107 -0
- thread_archive/_importers/cursor.py +432 -0
- thread_archive/_importers/exports.py +389 -0
- thread_archive/_importers/grok.py +600 -0
- thread_archive/_importers/opencode.py +538 -0
- thread_archive/_knowledge/__init__.py +67 -0
- thread_archive/_knowledge/_claims.py +116 -0
- thread_archive/_knowledge/_community.py +75 -0
- thread_archive/_knowledge/graph.py +208 -0
- thread_archive/_knowledge/materialize.py +212 -0
- thread_archive/_knowledge/write.py +438 -0
- thread_archive/_launchd.py +167 -0
- thread_archive/_mcp/__init__.py +1 -0
- thread_archive/_mcp/librarian.py +151 -0
- thread_archive/_mcp/server.py +307 -0
- thread_archive/_retrieval/__init__.py +280 -0
- thread_archive/_retrieval/_classify.py +80 -0
- thread_archive/_retrieval/_codex.py +157 -0
- thread_archive/_retrieval/_context.py +123 -0
- thread_archive/_retrieval/_extract.py +184 -0
- thread_archive/_retrieval/embed.py +186 -0
- thread_archive/_retrieval/format.py +149 -0
- thread_archive/_retrieval/fts.py +538 -0
- thread_archive/_retrieval/rank.py +228 -0
- thread_archive/_retrieval/read.py +908 -0
- thread_archive/_retrieval/rerank.py +143 -0
- thread_archive/_retrieval/vectors.py +611 -0
- thread_archive/_scripts/__init__.py +1 -0
- thread_archive/_scripts/backfill_recompute.py +328 -0
- thread_archive/_scripts/backfill_reconcile.py +296 -0
- thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
- thread_archive/_scripts/recover_dropped_events.py +190 -0
- thread_archive/_scripts/repair_grok_tool_names.py +118 -0
- thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
- thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
- thread_archive/_setup/__init__.py +12 -0
- thread_archive/_setup/clients.py +89 -0
- thread_archive/_setup/wizard.py +474 -0
- thread_archive/_store/__init__.py +45 -0
- thread_archive/_store/_base.py +173 -0
- thread_archive/_store/_defaults.py +57 -0
- thread_archive/_store/_types.py +38 -0
- thread_archive/_store/models.py +370 -0
- thread_archive/_store/schema.py +84 -0
- thread_archive/_thread_import/__init__.py +40 -0
- thread_archive/_thread_import/api.py +78 -0
- thread_archive/_thread_import/event_builder.py +810 -0
- thread_archive/_thread_import/exporters/__init__.py +9 -0
- thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
- thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
- thread_archive/_thread_import/exporters/cursor.py +582 -0
- thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
- thread_archive/_thread_import/parsers/__init__.py +191 -0
- thread_archive/_thread_import/parsers/base.py +698 -0
- thread_archive/_thread_import/parsers/chatgpt.py +722 -0
- thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
- thread_archive/_thread_import/parsers/claude.py +443 -0
- thread_archive/_thread_import/parsers/claude_code.py +1035 -0
- thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
- thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
- thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
- thread_archive/_thread_import/parsers/config/__init__.py +26 -0
- thread_archive/_thread_import/parsers/config/base.py +220 -0
- thread_archive/_thread_import/parsers/cursor.py +538 -0
- thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
- thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
- thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
- thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
- thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
- thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
- thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
- thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
- thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
- thread_archive/_thread_import/parsers/types/__init__.py +56 -0
- thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
- thread_archive/_thread_import/parsers/types/claude.py +120 -0
- thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
- thread_archive/_thread_import/parsers/types/cursor.py +109 -0
- thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
- thread_archive/_thread_import/parsers/validators/base.py +168 -0
- thread_archive/_thread_import/parsers/validators/content.py +80 -0
- thread_archive/_thread_import/parsers/validators/referential.py +57 -0
- thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
- thread_archive/_thread_import/parsers/validators/types.py +87 -0
- thread_archive/_thread_import/schemas/__init__.py +231 -0
- thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
- thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
- thread_archive/_thread_import/schemas/claude/v1.json +188 -0
- thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
- thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
- thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
- thread_archive/_thread_import/timestamps.py +57 -0
- thread_archive/_thread_import/tool_names.py +48 -0
- thread_archive/_truth/__init__.py +40 -0
- thread_archive/_truth/jsonl_log.py +2340 -0
- thread_archive/_truth/repair.py +322 -0
- thread_archive/_watcher/__init__.py +57 -0
- thread_archive/_watcher/base.py +65 -0
- thread_archive/_watcher/daemon.py +289 -0
- thread_archive/_watcher/export_drop.py +203 -0
- thread_archive/_watcher/exthost.py +316 -0
- thread_archive/_watcher/lazy.py +117 -0
- thread_archive/_watcher/sources.py +616 -0
- thread_archive/_web/__init__.py +15 -0
- thread_archive/_web/server.py +299 -0
- thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
- thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
- thread_archive/_web/static/index.html +13 -0
- thread_archive/cli.py +746 -0
- thread_archive-0.0.2.dist-info/METADATA +296 -0
- thread_archive-0.0.2.dist-info/RECORD +132 -0
- thread_archive-0.0.2.dist-info/WHEEL +4 -0
- thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
- 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}")
|