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/README.md +40 -0
- mempalace/__init__.py +6 -0
- mempalace/__main__.py +5 -0
- mempalace/cli.py +811 -0
- mempalace/config.py +149 -0
- mempalace/convo_miner.py +415 -0
- mempalace/dialect.py +1075 -0
- mempalace/entity_detector.py +853 -0
- mempalace/entity_registry.py +639 -0
- mempalace/export.py +378 -0
- mempalace/general_extractor.py +521 -0
- mempalace/knowledge_graph.py +410 -0
- mempalace/layers.py +515 -0
- mempalace/mcp_server.py +873 -0
- mempalace/migrate.py +153 -0
- mempalace/miner.py +1285 -0
- mempalace/normalize.py +328 -0
- mempalace/onboarding.py +489 -0
- mempalace/palace_graph.py +225 -0
- mempalace/py.typed +0 -0
- mempalace/room_detector_local.py +310 -0
- mempalace/searcher.py +305 -0
- mempalace/spellcheck.py +269 -0
- mempalace/split_mega_files.py +309 -0
- mempalace/storage.py +807 -0
- mempalace/version.py +3 -0
- mempalace_code-1.0.0.dist-info/METADATA +489 -0
- mempalace_code-1.0.0.dist-info/RECORD +32 -0
- mempalace_code-1.0.0.dist-info/WHEEL +4 -0
- mempalace_code-1.0.0.dist-info/entry_points.txt +2 -0
- mempalace_code-1.0.0.dist-info/licenses/LICENSE +192 -0
- mempalace_code-1.0.0.dist-info/licenses/NOTICE +17 -0
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]))
|