mempalace-code 1.0.0__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.
mempalace/normalize.py ADDED
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ normalize.py — Convert any chat export format to MemPalace transcript format.
4
+
5
+ Supported:
6
+ - Plain text with > markers (pass through)
7
+ - Claude.ai JSON export
8
+ - ChatGPT conversations.json
9
+ - Claude Code JSONL
10
+ - OpenAI Codex CLI JSONL
11
+ - Slack JSON export
12
+ - Plain text (pass through for paragraph chunking)
13
+
14
+ No API key. No internet. Everything local.
15
+ """
16
+
17
+ import json
18
+ import os
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+
23
+ def normalize(filepath: str) -> str:
24
+ """
25
+ Load a file and normalize to transcript format if it's a chat export.
26
+ Plain text files pass through unchanged.
27
+ """
28
+ try:
29
+ with open(filepath, "r", encoding="utf-8", errors="replace") as f:
30
+ content = f.read()
31
+ except OSError as e:
32
+ raise IOError(f"Could not read {filepath}: {e}")
33
+
34
+ if not content.strip():
35
+ return content
36
+
37
+ # Already has > markers — pass through
38
+ lines = content.split("\n")
39
+ if sum(1 for line in lines if line.strip().startswith(">")) >= 3:
40
+ return content
41
+
42
+ # Try JSON normalization
43
+ ext = Path(filepath).suffix.lower()
44
+ if ext in (".json", ".jsonl") or content.strip()[:1] in ("{", "["):
45
+ normalized = _try_normalize_json(content)
46
+ if normalized:
47
+ return normalized
48
+
49
+ return content
50
+
51
+
52
+ def _try_normalize_json(content: str) -> Optional[str]:
53
+ """Try all known JSON chat schemas."""
54
+
55
+ normalized = _try_claude_code_jsonl(content)
56
+ if normalized:
57
+ return normalized
58
+
59
+ normalized = _try_codex_jsonl(content)
60
+ if normalized:
61
+ return normalized
62
+
63
+ try:
64
+ data = json.loads(content)
65
+ except json.JSONDecodeError:
66
+ return None
67
+
68
+ for parser in (_try_claude_ai_json, _try_chatgpt_json, _try_slack_json):
69
+ normalized = parser(data)
70
+ if normalized:
71
+ return normalized
72
+
73
+ return None
74
+
75
+
76
+ def _try_claude_code_jsonl(content: str) -> Optional[str]:
77
+ """Claude Code JSONL sessions."""
78
+ lines = [line.strip() for line in content.strip().split("\n") if line.strip()]
79
+ messages = []
80
+ for line in lines:
81
+ try:
82
+ entry = json.loads(line)
83
+ except json.JSONDecodeError:
84
+ continue
85
+ if not isinstance(entry, dict):
86
+ continue
87
+ msg_type = entry.get("type", "")
88
+ message = entry.get("message", {})
89
+ if msg_type in ("human", "user"):
90
+ text = _extract_content(message.get("content", ""))
91
+ if text:
92
+ messages.append(("user", text))
93
+ elif msg_type == "assistant":
94
+ text = _extract_content(message.get("content", ""))
95
+ if text:
96
+ messages.append(("assistant", text))
97
+ if len(messages) >= 2:
98
+ return _messages_to_transcript(messages)
99
+ return None
100
+
101
+
102
+ def _try_codex_jsonl(content: str) -> Optional[str]:
103
+ """OpenAI Codex CLI sessions (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl).
104
+
105
+ Uses only event_msg entries (user_message / agent_message) which represent
106
+ the canonical conversation turns. response_item entries are skipped because
107
+ they include synthetic context injections and duplicate the real messages.
108
+ """
109
+ lines = [line.strip() for line in content.strip().split("\n") if line.strip()]
110
+ messages = []
111
+ has_session_meta = False
112
+ for line in lines:
113
+ try:
114
+ entry = json.loads(line)
115
+ except json.JSONDecodeError:
116
+ continue
117
+ if not isinstance(entry, dict):
118
+ continue
119
+
120
+ entry_type = entry.get("type", "")
121
+ if entry_type == "session_meta":
122
+ has_session_meta = True
123
+ continue
124
+
125
+ if entry_type != "event_msg":
126
+ continue
127
+
128
+ payload = entry.get("payload", {})
129
+ if not isinstance(payload, dict):
130
+ continue
131
+
132
+ payload_type = payload.get("type", "")
133
+ msg = payload.get("message")
134
+ if not isinstance(msg, str):
135
+ continue
136
+ text = msg.strip()
137
+ if not text:
138
+ continue
139
+
140
+ if payload_type == "user_message":
141
+ messages.append(("user", text))
142
+ elif payload_type == "agent_message":
143
+ messages.append(("assistant", text))
144
+
145
+ if len(messages) >= 2 and has_session_meta:
146
+ return _messages_to_transcript(messages)
147
+ return None
148
+
149
+
150
+ def _try_claude_ai_json(data) -> Optional[str]:
151
+ """Claude.ai JSON export: flat messages list or privacy export with chat_messages."""
152
+ if isinstance(data, dict):
153
+ data = data.get("messages", data.get("chat_messages", []))
154
+ if not isinstance(data, list):
155
+ return None
156
+
157
+ # Privacy export: array of conversation objects with chat_messages inside each
158
+ if data and isinstance(data[0], dict) and "chat_messages" in data[0]:
159
+ all_messages = []
160
+ for convo in data:
161
+ if not isinstance(convo, dict):
162
+ continue
163
+ chat_msgs = convo.get("chat_messages", [])
164
+ for item in chat_msgs:
165
+ if not isinstance(item, dict):
166
+ continue
167
+ role = item.get("role", "")
168
+ text = _extract_content(item.get("content", ""))
169
+ if role in ("user", "human") and text:
170
+ all_messages.append(("user", text))
171
+ elif role in ("assistant", "ai") and text:
172
+ all_messages.append(("assistant", text))
173
+ if len(all_messages) >= 2:
174
+ return _messages_to_transcript(all_messages)
175
+ return None
176
+
177
+ # Flat messages list
178
+ messages = []
179
+ for item in data:
180
+ if not isinstance(item, dict):
181
+ continue
182
+ role = item.get("role", "")
183
+ text = _extract_content(item.get("content", ""))
184
+ if role in ("user", "human") and text:
185
+ messages.append(("user", text))
186
+ elif role in ("assistant", "ai") and text:
187
+ messages.append(("assistant", text))
188
+ if len(messages) >= 2:
189
+ return _messages_to_transcript(messages)
190
+ return None
191
+
192
+
193
+ def _try_chatgpt_json(data) -> Optional[str]:
194
+ """ChatGPT conversations.json with mapping tree."""
195
+ if not isinstance(data, dict) or "mapping" not in data:
196
+ return None
197
+ mapping = data["mapping"]
198
+ messages = []
199
+ # Find root: prefer node with parent=None AND no message (synthetic root)
200
+ root_id = None
201
+ fallback_root = None
202
+ for node_id, node in mapping.items():
203
+ if node.get("parent") is None:
204
+ if node.get("message") is None:
205
+ root_id = node_id
206
+ break
207
+ elif fallback_root is None:
208
+ fallback_root = node_id
209
+ if not root_id:
210
+ root_id = fallback_root
211
+ if root_id:
212
+ current_id = root_id
213
+ visited = set()
214
+ while current_id and current_id not in visited:
215
+ visited.add(current_id)
216
+ node = mapping.get(current_id, {})
217
+ msg = node.get("message")
218
+ if msg:
219
+ role = msg.get("author", {}).get("role", "")
220
+ content = msg.get("content", {})
221
+ parts = content.get("parts", []) if isinstance(content, dict) else []
222
+ text = " ".join(str(p) for p in parts if isinstance(p, str) and p).strip()
223
+ if role == "user" and text:
224
+ messages.append(("user", text))
225
+ elif role == "assistant" and text:
226
+ messages.append(("assistant", text))
227
+ children = node.get("children", [])
228
+ current_id = children[0] if children else None
229
+ if len(messages) >= 2:
230
+ return _messages_to_transcript(messages)
231
+ return None
232
+
233
+
234
+ def _try_slack_json(data) -> Optional[str]:
235
+ """
236
+ Slack channel export: [{"type": "message", "user": "...", "text": "..."}]
237
+ Optimized for 2-person DMs. In channels with 3+ people, alternating
238
+ speakers are labeled user/assistant to preserve the exchange structure.
239
+ """
240
+ if not isinstance(data, list):
241
+ return None
242
+ messages = []
243
+ seen_users = {}
244
+ last_role = None
245
+ for item in data:
246
+ if not isinstance(item, dict) or item.get("type") != "message":
247
+ continue
248
+ user_id = item.get("user", item.get("username", ""))
249
+ text = item.get("text", "").strip()
250
+ if not text or not user_id:
251
+ continue
252
+ if user_id not in seen_users:
253
+ # Alternate roles so exchange chunking works with any number of speakers
254
+ if not seen_users:
255
+ seen_users[user_id] = "user"
256
+ elif last_role == "user":
257
+ seen_users[user_id] = "assistant"
258
+ else:
259
+ seen_users[user_id] = "user"
260
+ last_role = seen_users[user_id]
261
+ messages.append((seen_users[user_id], text))
262
+ if len(messages) >= 2:
263
+ return _messages_to_transcript(messages)
264
+ return None
265
+
266
+
267
+ def _extract_content(content) -> str:
268
+ """Pull text from content — handles str, list of blocks, or dict."""
269
+ if isinstance(content, str):
270
+ return content.strip()
271
+ if isinstance(content, list):
272
+ parts = []
273
+ for item in content:
274
+ if isinstance(item, str):
275
+ parts.append(item)
276
+ elif isinstance(item, dict) and item.get("type") == "text":
277
+ parts.append(item.get("text", ""))
278
+ return " ".join(parts).strip()
279
+ if isinstance(content, dict):
280
+ return content.get("text", "").strip()
281
+ return ""
282
+
283
+
284
+ def _messages_to_transcript(messages: list, spellcheck: bool = True) -> str:
285
+ """Convert [(role, text), ...] to transcript format with > markers."""
286
+ if spellcheck:
287
+ try:
288
+ from mempalace.spellcheck import spellcheck_user_text
289
+
290
+ _fix = spellcheck_user_text
291
+ except ImportError:
292
+ _fix = None
293
+ else:
294
+ _fix = None
295
+
296
+ lines = []
297
+ i = 0
298
+ while i < len(messages):
299
+ role, text = messages[i]
300
+ if role == "user":
301
+ if _fix is not None:
302
+ text = _fix(text)
303
+ lines.append(f"> {text}")
304
+ if i + 1 < len(messages) and messages[i + 1][0] == "assistant":
305
+ lines.append(messages[i + 1][1])
306
+ i += 2
307
+ else:
308
+ i += 1
309
+ else:
310
+ lines.append(text)
311
+ i += 1
312
+ lines.append("")
313
+ return "\n".join(lines)
314
+
315
+
316
+ if __name__ == "__main__":
317
+ import sys
318
+
319
+ if len(sys.argv) < 2:
320
+ print("Usage: python normalize.py <filepath>")
321
+ sys.exit(1)
322
+ filepath = sys.argv[1]
323
+ result = normalize(filepath)
324
+ quote_count = sum(1 for line in result.split("\n") if line.strip().startswith(">"))
325
+ print(f"\nFile: {os.path.basename(filepath)}")
326
+ print(f"Normalized: {len(result)} chars | {quote_count} user turns detected")
327
+ print("\n--- Preview (first 20 lines) ---")
328
+ print("\n".join(result.split("\n")[:20]))