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
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
split_mega_files.py — Split concatenated transcript files into per-session files
|
|
4
|
+
=================================================================================
|
|
5
|
+
|
|
6
|
+
Scans a directory for .txt files that contain multiple Claude Code sessions
|
|
7
|
+
(identified by "Claude Code v" headers). Splits each into individual files
|
|
8
|
+
named with: date, time, people detected, and subject from first prompt.
|
|
9
|
+
|
|
10
|
+
Distinguishes true session starts from mid-session context restores
|
|
11
|
+
(which show "Ctrl+E to show X previous messages").
|
|
12
|
+
|
|
13
|
+
Output files are written to --output-dir (default: same dir as source).
|
|
14
|
+
Original files are renamed with .mega_backup extension (not deleted).
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python3 split_mega_files.py # scan ~/Desktop/transcripts
|
|
18
|
+
python3 split_mega_files.py --source ~/Desktop/transcripts # explicit source
|
|
19
|
+
python3 split_mega_files.py --dry-run # show what would happen
|
|
20
|
+
python3 split_mega_files.py --min-sessions 2 # only files with 2+ sessions
|
|
21
|
+
|
|
22
|
+
By: Ben, 2026-03-30
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
HOME = Path.home()
|
|
32
|
+
LUMI_DIR = Path(os.environ.get("MEMPALACE_SOURCE_DIR", str(HOME / "Desktop/transcripts")))
|
|
33
|
+
|
|
34
|
+
# People we know about (for name detection in content)
|
|
35
|
+
# Loaded from ~/.mempalace/known_names.json if it exists, otherwise generic fallback.
|
|
36
|
+
_KNOWN_NAMES_PATH = HOME / ".mempalace" / "known_names.json"
|
|
37
|
+
_FALLBACK_KNOWN_PEOPLE = ["Alice", "Ben", "Riley", "Max", "Sam", "Devon", "Jordan"]
|
|
38
|
+
_KNOWN_NAMES_CACHE = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_known_names_config(force_reload: bool = False):
|
|
42
|
+
"""Load and cache the optional known-names config file."""
|
|
43
|
+
global _KNOWN_NAMES_CACHE
|
|
44
|
+
|
|
45
|
+
if force_reload:
|
|
46
|
+
_KNOWN_NAMES_CACHE = None
|
|
47
|
+
|
|
48
|
+
if _KNOWN_NAMES_CACHE is not None:
|
|
49
|
+
return _KNOWN_NAMES_CACHE
|
|
50
|
+
|
|
51
|
+
if _KNOWN_NAMES_PATH.exists():
|
|
52
|
+
try:
|
|
53
|
+
_KNOWN_NAMES_CACHE = json.loads(_KNOWN_NAMES_PATH.read_text())
|
|
54
|
+
return _KNOWN_NAMES_CACHE
|
|
55
|
+
except (json.JSONDecodeError, OSError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
_KNOWN_NAMES_CACHE = None
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load_known_people() -> list:
|
|
63
|
+
"""Load known names from config file, falling back to a generic list."""
|
|
64
|
+
data = _load_known_names_config()
|
|
65
|
+
if isinstance(data, list):
|
|
66
|
+
return data
|
|
67
|
+
if isinstance(data, dict):
|
|
68
|
+
return data.get("names", [])
|
|
69
|
+
return list(_FALLBACK_KNOWN_PEOPLE)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
KNOWN_PEOPLE = _load_known_people()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_username_map() -> dict:
|
|
76
|
+
"""Load username-to-name mapping from config file."""
|
|
77
|
+
data = _load_known_names_config()
|
|
78
|
+
if isinstance(data, dict):
|
|
79
|
+
return data.get("username_map", {})
|
|
80
|
+
return {}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_true_session_start(lines, idx):
|
|
84
|
+
"""
|
|
85
|
+
True session start: 'Claude Code v' header NOT followed by 'Ctrl+E'/'previous messages'
|
|
86
|
+
within the next 6 lines (those are context restores, not new sessions).
|
|
87
|
+
"""
|
|
88
|
+
nearby = "".join(lines[idx : idx + 6])
|
|
89
|
+
return "Ctrl+E" not in nearby and "previous messages" not in nearby
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def find_session_boundaries(lines):
|
|
93
|
+
"""Return list of line indices where true new sessions begin."""
|
|
94
|
+
boundaries = []
|
|
95
|
+
for i, line in enumerate(lines):
|
|
96
|
+
if "Claude Code v" in line and is_true_session_start(lines, i):
|
|
97
|
+
boundaries.append(i)
|
|
98
|
+
return boundaries
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def extract_timestamp(lines):
|
|
102
|
+
"""
|
|
103
|
+
Find the first timestamp line: ⏺ H:MM AM/PM Weekday, Month DD, YYYY
|
|
104
|
+
Returns (datetime_str, iso_str) or (None, None).
|
|
105
|
+
"""
|
|
106
|
+
ts_pattern = re.compile(r"⏺\s+(\d{1,2}:\d{2}\s+[AP]M)\s+\w+,\s+(\w+)\s+(\d{1,2}),\s+(\d{4})")
|
|
107
|
+
months = {
|
|
108
|
+
"January": "01",
|
|
109
|
+
"February": "02",
|
|
110
|
+
"March": "03",
|
|
111
|
+
"April": "04",
|
|
112
|
+
"May": "05",
|
|
113
|
+
"June": "06",
|
|
114
|
+
"July": "07",
|
|
115
|
+
"August": "08",
|
|
116
|
+
"September": "09",
|
|
117
|
+
"October": "10",
|
|
118
|
+
"November": "11",
|
|
119
|
+
"December": "12",
|
|
120
|
+
}
|
|
121
|
+
for line in lines[:50]:
|
|
122
|
+
m = ts_pattern.search(line)
|
|
123
|
+
if m:
|
|
124
|
+
time_str, month, day, year = m.groups()
|
|
125
|
+
mon = months.get(month, "00")
|
|
126
|
+
day_z = day.zfill(2)
|
|
127
|
+
time_safe = time_str.replace(":", "").replace(" ", "")
|
|
128
|
+
iso = f"{year}-{mon}-{day_z}"
|
|
129
|
+
human = f"{year}-{mon}-{day_z}_{time_safe}"
|
|
130
|
+
return human, iso
|
|
131
|
+
return None, None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def extract_people(lines):
|
|
135
|
+
"""
|
|
136
|
+
Detect people mentioned as speakers or by name in first 100 lines.
|
|
137
|
+
Returns sorted list of detected names.
|
|
138
|
+
"""
|
|
139
|
+
found = set()
|
|
140
|
+
text = "".join(lines[:100])
|
|
141
|
+
|
|
142
|
+
# Speaker tags: "Alice:", "Ben:", etc.
|
|
143
|
+
for person in KNOWN_PEOPLE:
|
|
144
|
+
if re.search(rf"\b{person}\b", text, re.IGNORECASE):
|
|
145
|
+
found.add(person)
|
|
146
|
+
|
|
147
|
+
# Working directory username hint — map to known people if configured
|
|
148
|
+
dir_match = re.search(r"/Users/(\w+)/", text)
|
|
149
|
+
if dir_match:
|
|
150
|
+
username = dir_match.group(1)
|
|
151
|
+
# User can map usernames to names in ~/.mempalace/known_names.json
|
|
152
|
+
# under a "username_map" key, e.g. {"username_map": {"jdoe": "John"}}
|
|
153
|
+
username_map = _load_username_map()
|
|
154
|
+
if username in username_map:
|
|
155
|
+
found.add(username_map[username])
|
|
156
|
+
|
|
157
|
+
return sorted(found)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def extract_subject(lines):
|
|
161
|
+
"""
|
|
162
|
+
Find the first meaningful user prompt (> line that isn't a shell command).
|
|
163
|
+
Returns cleaned, filename-safe subject string.
|
|
164
|
+
"""
|
|
165
|
+
skip_patterns = re.compile(
|
|
166
|
+
r"^(\.\/|cd |ls |python|bash|git |cat |source |export |claude|./activate)"
|
|
167
|
+
)
|
|
168
|
+
for line in lines:
|
|
169
|
+
if line.startswith("> "):
|
|
170
|
+
prompt = line[2:].strip()
|
|
171
|
+
if prompt and not skip_patterns.match(prompt) and len(prompt) > 5:
|
|
172
|
+
# Clean for filename
|
|
173
|
+
subject = re.sub(r"[^\w\s-]", "", prompt)
|
|
174
|
+
subject = re.sub(r"\s+", "-", subject.strip())
|
|
175
|
+
return subject[:60]
|
|
176
|
+
return "session"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def split_file(filepath, output_dir, dry_run=False):
|
|
180
|
+
"""
|
|
181
|
+
Split a single mega-file into per-session files.
|
|
182
|
+
Returns list of output paths written (or would be written if dry_run).
|
|
183
|
+
"""
|
|
184
|
+
path = Path(filepath)
|
|
185
|
+
lines = path.read_text(errors="replace").splitlines(keepends=True)
|
|
186
|
+
|
|
187
|
+
boundaries = find_session_boundaries(lines)
|
|
188
|
+
if len(boundaries) < 2:
|
|
189
|
+
return [] # Not a mega-file
|
|
190
|
+
|
|
191
|
+
# Add sentinel at end
|
|
192
|
+
boundaries.append(len(lines))
|
|
193
|
+
|
|
194
|
+
out_dir = Path(output_dir) if output_dir else path.parent
|
|
195
|
+
written = []
|
|
196
|
+
|
|
197
|
+
for i, (start, end) in enumerate(zip(boundaries, boundaries[1:])):
|
|
198
|
+
chunk = lines[start:end]
|
|
199
|
+
if len(chunk) < 10:
|
|
200
|
+
continue # Skip tiny fragments
|
|
201
|
+
|
|
202
|
+
ts_human, ts_iso = extract_timestamp(chunk)
|
|
203
|
+
people = extract_people(chunk)
|
|
204
|
+
subject = extract_subject(chunk)
|
|
205
|
+
|
|
206
|
+
# Build filename: SOURCESTEM__DATE_TIME_People_subject.txt
|
|
207
|
+
# Source stem prefix prevents collisions when multiple mega-files
|
|
208
|
+
# produce sessions with the same timestamp/people/subject.
|
|
209
|
+
ts_part = ts_human or f"part{i + 1:02d}"
|
|
210
|
+
people_part = "-".join(people[:3]) if people else "unknown"
|
|
211
|
+
src_stem = re.sub(r"[^\w-]", "_", path.stem)[:40]
|
|
212
|
+
name = f"{src_stem}__{ts_part}_{people_part}_{subject}.txt"
|
|
213
|
+
# Sanitize
|
|
214
|
+
name = re.sub(r"[^\w\.\-]", "_", name)
|
|
215
|
+
name = re.sub(r"_+", "_", name)
|
|
216
|
+
|
|
217
|
+
out_path = out_dir / name
|
|
218
|
+
|
|
219
|
+
if dry_run:
|
|
220
|
+
print(f" [{i + 1}/{len(boundaries) - 1}] {name} ({len(chunk)} lines)")
|
|
221
|
+
else:
|
|
222
|
+
out_path.write_text("".join(chunk))
|
|
223
|
+
print(f" ✓ {name} ({len(chunk)} lines)")
|
|
224
|
+
|
|
225
|
+
written.append(out_path)
|
|
226
|
+
|
|
227
|
+
return written
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main():
|
|
231
|
+
parser = argparse.ArgumentParser(
|
|
232
|
+
description="Split concatenated transcript mega-files into per-session files"
|
|
233
|
+
)
|
|
234
|
+
parser.add_argument(
|
|
235
|
+
"--source",
|
|
236
|
+
type=str,
|
|
237
|
+
default=None,
|
|
238
|
+
help="Source directory (default: MEMPALACE_SOURCE_DIR or ~/Desktop/transcripts)",
|
|
239
|
+
)
|
|
240
|
+
parser.add_argument(
|
|
241
|
+
"--output-dir", type=str, default=None, help="Output directory (default: same as source)"
|
|
242
|
+
)
|
|
243
|
+
parser.add_argument(
|
|
244
|
+
"--min-sessions",
|
|
245
|
+
type=int,
|
|
246
|
+
default=2,
|
|
247
|
+
help="Only split files with at least N sessions (default: 2)",
|
|
248
|
+
)
|
|
249
|
+
parser.add_argument(
|
|
250
|
+
"--dry-run", action="store_true", help="Show what would happen without writing files"
|
|
251
|
+
)
|
|
252
|
+
parser.add_argument(
|
|
253
|
+
"--file",
|
|
254
|
+
type=str,
|
|
255
|
+
default=None,
|
|
256
|
+
help="Split a single specific file instead of scanning dir",
|
|
257
|
+
)
|
|
258
|
+
args = parser.parse_args()
|
|
259
|
+
|
|
260
|
+
src_dir = Path(args.source) if args.source else LUMI_DIR
|
|
261
|
+
output_dir = args.output_dir or None # None = same dir as file
|
|
262
|
+
|
|
263
|
+
if args.file:
|
|
264
|
+
files = [Path(args.file)]
|
|
265
|
+
else:
|
|
266
|
+
files = sorted(src_dir.glob("*.txt"))
|
|
267
|
+
|
|
268
|
+
mega_files = []
|
|
269
|
+
for f in files:
|
|
270
|
+
lines = f.read_text(errors="replace").splitlines(keepends=True)
|
|
271
|
+
boundaries = find_session_boundaries(lines)
|
|
272
|
+
if len(boundaries) >= args.min_sessions:
|
|
273
|
+
mega_files.append((f, len(boundaries)))
|
|
274
|
+
|
|
275
|
+
if not mega_files:
|
|
276
|
+
print(f"No mega-files found in {src_dir} (min {args.min_sessions} sessions).")
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
print(f"\n{'=' * 60}")
|
|
280
|
+
print(f" Mega-file splitter — {'DRY RUN' if args.dry_run else 'SPLITTING'}")
|
|
281
|
+
print(f"{'=' * 60}")
|
|
282
|
+
print(f" Source: {src_dir}")
|
|
283
|
+
print(f" Output: {output_dir or 'same dir as source'}")
|
|
284
|
+
print(f" Mega-files: {len(mega_files)}")
|
|
285
|
+
print(f"{'─' * 60}\n")
|
|
286
|
+
|
|
287
|
+
total_written = 0
|
|
288
|
+
for f, n_sessions in mega_files:
|
|
289
|
+
print(f" {f.name} ({n_sessions} sessions, {f.stat().st_size // 1024}KB)")
|
|
290
|
+
written = split_file(f, output_dir, dry_run=args.dry_run)
|
|
291
|
+
total_written += len(written)
|
|
292
|
+
|
|
293
|
+
if not args.dry_run and written:
|
|
294
|
+
backup = f.with_suffix(".mega_backup")
|
|
295
|
+
f.rename(backup)
|
|
296
|
+
print(f" → Original renamed to {backup.name}\n")
|
|
297
|
+
else:
|
|
298
|
+
print()
|
|
299
|
+
|
|
300
|
+
print(f"{'─' * 60}")
|
|
301
|
+
if args.dry_run:
|
|
302
|
+
print(f" DRY RUN — would create {total_written} files from {len(mega_files)} mega-files")
|
|
303
|
+
else:
|
|
304
|
+
print(f" Done — created {total_written} files from {len(mega_files)} mega-files")
|
|
305
|
+
print()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
if __name__ == "__main__":
|
|
309
|
+
main()
|