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.
@@ -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()