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,9 @@
1
+ """
2
+ Exporters for extracting chat data from various IDE/tool storage formats.
3
+ """
4
+
5
+ from .cursor import CursorExporter
6
+
7
+ __all__ = [
8
+ "CursorExporter",
9
+ ]
@@ -0,0 +1,166 @@
1
+ """SQLite / cursorDiskKV reading methods for ``CursorExporter``.
2
+
3
+ A mixin of the database-reading and KV→conversation-building methods, lifted out
4
+ of ``CursorExporter`` so the class file stays focused. ``_read_sqlite_db`` and
5
+ ``_read_cursor_disk_kv`` open a SQLite file passed in by path; the rest transform
6
+ the resulting composer/bubble dicts into conversation structures, delegating the
7
+ per-message shape work to ``cursor_parse``. None read ``self.storage_path``.
8
+
9
+ The methods stay methods (not free functions) so callers that access them as
10
+ class/instance attributes — including tests that build a bare exporter with
11
+ ``__new__`` and the ``export_all`` orchestrator that stubs ``_read_cursor_disk_kv``
12
+ and ``_build_conversations_from_kv`` on the instance — keep resolving to the same
13
+ objects via the assembled class's MRO. Moved byte-for-byte from ``cursor.py``.
14
+ """
15
+
16
+ import json
17
+ import logging
18
+ import sqlite3
19
+ from pathlib import Path
20
+ from typing import Any, Dict, List, Tuple
21
+
22
+ from . import cursor_parse
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class CursorKVMixin:
28
+ """SQLite key-value reading + KV→conversation building methods."""
29
+
30
+ def _read_sqlite_db(self, db_path: Path) -> Dict[str, Any]:
31
+ """Read key-value pairs from a Cursor SQLite database."""
32
+ data = {}
33
+
34
+ try:
35
+ conn = sqlite3.connect(str(db_path))
36
+ cursor = conn.cursor()
37
+
38
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
39
+ tables = [row[0] for row in cursor.fetchall()]
40
+
41
+ if "ItemTable" in tables:
42
+ cursor.execute("SELECT key, value FROM ItemTable")
43
+ for key, value in cursor.fetchall():
44
+ if value and isinstance(value, str):
45
+ try:
46
+ if value.strip().startswith(("{", "[")):
47
+ data[key] = json.loads(value)
48
+ else:
49
+ data[key] = value
50
+ except json.JSONDecodeError:
51
+ data[key] = value
52
+ else:
53
+ data[key] = value
54
+
55
+ conn.close()
56
+ except sqlite3.Error:
57
+ pass
58
+ except Exception:
59
+ logger.warning("Failed to read SQLite database at %s", db_path, exc_info=True)
60
+
61
+ return data
62
+
63
+ def _read_cursor_disk_kv(self, db_path: Path) -> Tuple[Dict[str, Any], Dict[str, Any]]:
64
+ """
65
+ Read conversation data from Cursor's cursorDiskKV table.
66
+
67
+ Returns:
68
+ Tuple of (composers, bubbles)
69
+ """
70
+ composers = {}
71
+ bubbles = {}
72
+
73
+ try:
74
+ conn = sqlite3.connect(str(db_path))
75
+ cursor = conn.cursor()
76
+
77
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'")
78
+ if not cursor.fetchone():
79
+ conn.close()
80
+ return composers, bubbles
81
+
82
+ cursor.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'")
83
+ for key, value in cursor.fetchall():
84
+ try:
85
+ composer_id = key.replace("composerData:", "")
86
+ data = json.loads(value)
87
+ composers[composer_id] = data
88
+ except (json.JSONDecodeError, Exception):
89
+ pass
90
+
91
+ cursor.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'")
92
+ for key, value in cursor.fetchall():
93
+ try:
94
+ parts = key.split(":")
95
+ if len(parts) >= 3:
96
+ bubble_id = parts[2]
97
+ composer_id = parts[1]
98
+ data = json.loads(value)
99
+ data["_composerId"] = composer_id
100
+ bubbles[f"{composer_id}:{bubble_id}"] = data
101
+ except (json.JSONDecodeError, Exception):
102
+ pass
103
+
104
+ conn.close()
105
+
106
+ except sqlite3.Error:
107
+ pass
108
+ except Exception:
109
+ logger.warning("Failed to read cursorDiskKV from %s", db_path, exc_info=True)
110
+
111
+ return composers, bubbles
112
+
113
+ def _build_conversations_from_kv(
114
+ self,
115
+ composers: Dict[str, Any],
116
+ bubbles: Dict[str, Any]
117
+ ) -> List[Dict[str, Any]]:
118
+ """Build conversation structures from composer and bubble data."""
119
+ conversations = []
120
+
121
+ for composer_id, composer in composers.items():
122
+ headers = composer.get("fullConversationHeadersOnly", [])
123
+ if not headers:
124
+ continue
125
+
126
+ messages = []
127
+ for idx, header in enumerate(headers):
128
+ bubble_id = header.get("bubbleId")
129
+ if not bubble_id:
130
+ continue
131
+
132
+ bubble = bubbles.get(f"{composer_id}:{bubble_id}", {})
133
+ messages.append(
134
+ self._build_kv_message(bubble_id, bubble, header, idx)
135
+ )
136
+
137
+ conversations.append({
138
+ "id": composer_id,
139
+ "title": composer.get("name", "Untitled"),
140
+ "created_at": composer.get("createdAt"),
141
+ "updated_at": composer.get("lastUpdatedAt"),
142
+ "messages": messages,
143
+ "metadata": self._build_kv_metadata(composer),
144
+ })
145
+
146
+ return conversations
147
+
148
+ @staticmethod
149
+ def _kv_role(bubble: Dict[str, Any], header: Dict[str, Any]) -> str:
150
+ """Map a Cursor bubble/header ``type`` (1/2) onto a canonical role."""
151
+ return cursor_parse.kv_role(bubble, header)
152
+
153
+ @staticmethod
154
+ def _build_kv_message(
155
+ bubble_id: str,
156
+ bubble: Dict[str, Any],
157
+ header: Dict[str, Any],
158
+ idx: int,
159
+ ) -> Dict[str, Any]:
160
+ """Build one conversation message from a composer header + its bubble."""
161
+ return cursor_parse.build_kv_message(bubble_id, bubble, header, idx)
162
+
163
+ @staticmethod
164
+ def _build_kv_metadata(composer: Dict[str, Any]) -> Dict[str, Any]:
165
+ """Extract the conversation metadata block from a composer record."""
166
+ return cursor_parse.build_kv_metadata(composer)
@@ -0,0 +1,149 @@
1
+ """Composer/chat-blob parsing methods for ``CursorExporter``.
2
+
3
+ A mixin of the stateless ``_parse_*`` / ``_normalize_role`` /
4
+ ``_content_blocks_from_content`` / ``_extract_chat_data`` methods, lifted out of
5
+ ``CursorExporter`` so the class file stays focused. These read no instance state
6
+ (no ``self.storage_path``) — they transform loose composer/chat blobs into
7
+ conversation dicts, delegating the per-message shape work to ``cursor_parse``.
8
+
9
+ The methods stay methods (not free functions) so callers that access them as
10
+ class/instance attributes — including tests that build a bare exporter with
11
+ ``__new__`` and the ``export_all`` orchestrator that stubs them on the instance —
12
+ keep resolving to the same objects via the assembled class's MRO. Moved
13
+ byte-for-byte from ``cursor.py``.
14
+ """
15
+
16
+ import re
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ from . import cursor_parse
20
+
21
+
22
+ class CursorParseMixin:
23
+ """Stateless composer/chat-blob → conversation parsing methods."""
24
+
25
+ def _extract_chat_data(self, db_data: Dict[str, Any]) -> Dict[str, Any]:
26
+ """Extract chat-related data from the database dump."""
27
+ chat_data = {}
28
+
29
+ chat_patterns = [
30
+ r".*chat.*",
31
+ r".*composer.*",
32
+ r".*conversation.*",
33
+ r".*aiContext.*",
34
+ r".*ai\..*",
35
+ r".*cursor\..*",
36
+ r".*workbench\.panel\.aichat.*",
37
+ r".*workbench\.panel\.chat.*",
38
+ ]
39
+
40
+ for key, value in db_data.items():
41
+ key_lower = key.lower()
42
+ for pattern in chat_patterns:
43
+ if re.match(pattern, key_lower, re.IGNORECASE):
44
+ chat_data[key] = value
45
+ break
46
+
47
+ return chat_data
48
+
49
+ def _parse_composer_data(self, data: Any) -> List[Dict[str, Any]]:
50
+ """Parse Cursor's composer/chat data into conversations."""
51
+ if not data:
52
+ return []
53
+
54
+ if isinstance(data, dict):
55
+ return self._parse_composer_dict(data)
56
+
57
+ if isinstance(data, list):
58
+ return self._parse_conversation_items(data)
59
+
60
+ return []
61
+
62
+ def _parse_composer_dict(self, data: Dict[str, Any]) -> List[Dict[str, Any]]:
63
+ """Parse a dict-shaped composer blob: keyed conversation collections,
64
+ falling back to treating the dict itself as a single conversation."""
65
+ conversations: List[Dict[str, Any]] = []
66
+
67
+ for key in ["tabs", "conversations", "chats", "composerData", "allTabs"]:
68
+ if key in data:
69
+ conversations.extend(self._parse_keyed_collection(data[key]))
70
+
71
+ if not conversations and ("messages" in data or "bubbles" in data or "conversation" in data):
72
+ conv = self._parse_single_conversation(data)
73
+ if conv:
74
+ conversations.append(conv)
75
+
76
+ return conversations
77
+
78
+ def _parse_keyed_collection(self, items: Any) -> List[Dict[str, Any]]:
79
+ """Parse one keyed collection (list of conversations or id->conversation
80
+ dict) into a list of conversations."""
81
+ if isinstance(items, list):
82
+ return self._parse_conversation_items(items)
83
+ if isinstance(items, dict):
84
+ conversations: List[Dict[str, Any]] = []
85
+ for conv_id, item in items.items():
86
+ conv = self._parse_single_conversation(item, conv_id)
87
+ if conv:
88
+ conversations.append(conv)
89
+ return conversations
90
+ return []
91
+
92
+ def _parse_conversation_items(self, items: List[Any]) -> List[Dict[str, Any]]:
93
+ """Parse a plain list of conversation/tab structures, dropping any that
94
+ don't yield a conversation."""
95
+ conversations: List[Dict[str, Any]] = []
96
+ for item in items:
97
+ conv = self._parse_single_conversation(item)
98
+ if conv:
99
+ conversations.append(conv)
100
+ return conversations
101
+
102
+ def _parse_single_conversation(self, data: Any, conv_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
103
+ """Parse a single conversation/tab structure."""
104
+ if not isinstance(data, dict):
105
+ return None
106
+
107
+ conversation = {
108
+ "id": conv_id or data.get("id") or data.get("tabId") or data.get("conversationId"),
109
+ "title": data.get("title") or data.get("name") or "Untitled",
110
+ "created_at": data.get("createdAt") or data.get("created_at") or data.get("timestamp"),
111
+ "updated_at": data.get("updatedAt") or data.get("updated_at"),
112
+ "messages": [],
113
+ "raw_data": data,
114
+ }
115
+
116
+ messages = []
117
+ for msg_key in ["messages", "bubbles", "conversation", "chatMessages", "chat_messages"]:
118
+ if msg_key in data:
119
+ msg_data = data[msg_key]
120
+ if isinstance(msg_data, list):
121
+ messages = msg_data
122
+ break
123
+ elif isinstance(msg_data, dict) and "messages" in msg_data:
124
+ messages = msg_data["messages"]
125
+ break
126
+
127
+ for i, msg in enumerate(messages):
128
+ parsed_msg = self._parse_message(msg, i)
129
+ if parsed_msg:
130
+ conversation["messages"].append(parsed_msg)
131
+
132
+ if conversation["messages"] or conversation["id"]:
133
+ return conversation
134
+
135
+ return None
136
+
137
+ def _parse_message(self, msg: Any, index: int = 0) -> Optional[Dict[str, Any]]:
138
+ """Parse a single message from Cursor's format."""
139
+ return cursor_parse.parse_message(msg, index)
140
+
141
+ @staticmethod
142
+ def _normalize_role(msg: Dict[str, Any]) -> Optional[str]:
143
+ """Map Cursor's role/type/sender aliases onto canonical role names."""
144
+ return cursor_parse.normalize_role(msg)
145
+
146
+ @staticmethod
147
+ def _content_blocks_from_content(raw_content: Any) -> List[Dict[str, Any]]:
148
+ """Build text content blocks from a message's raw ``content`` field."""
149
+ return cursor_parse.content_blocks_from_content(raw_content)