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,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
|
+
}
|