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/miner.py ADDED
@@ -0,0 +1,1285 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ miner.py — Files everything into the palace.
4
+
5
+ Reads mempalace.yaml from the project directory to know the wing + rooms.
6
+ Routes each file to the right room based on content.
7
+ Stores verbatim chunks as drawers. No summaries. Ever.
8
+ """
9
+
10
+ import os
11
+ import re
12
+ import sys
13
+ import time
14
+ import hashlib
15
+ import fnmatch
16
+ from pathlib import Path
17
+ from datetime import datetime
18
+ from collections import defaultdict
19
+ from typing import Optional
20
+
21
+ from .storage import open_store
22
+ from .version import __version__
23
+
24
+ EXTENSION_LANG_MAP = {
25
+ ".py": "python",
26
+ ".js": "javascript",
27
+ ".jsx": "javascript",
28
+ ".ts": "typescript",
29
+ ".tsx": "typescript",
30
+ ".go": "go",
31
+ ".rs": "rust",
32
+ ".rb": "ruby",
33
+ ".java": "java",
34
+ ".sh": "shell",
35
+ ".sql": "sql",
36
+ ".md": "markdown",
37
+ ".txt": "text",
38
+ ".json": "json",
39
+ ".yaml": "yaml",
40
+ ".yml": "yaml",
41
+ ".toml": "toml",
42
+ ".html": "html",
43
+ ".css": "css",
44
+ ".csv": "csv",
45
+ }
46
+
47
+ SHEBANG_PATTERNS = [
48
+ (re.compile(r"python[0-9.]*"), "python"),
49
+ (re.compile(r"node"), "javascript"),
50
+ (re.compile(r"ruby"), "ruby"),
51
+ (re.compile(r"bash|sh|zsh"), "shell"),
52
+ (re.compile(r"perl"), "perl"),
53
+ ]
54
+
55
+ READABLE_EXTENSIONS = {
56
+ ".txt",
57
+ ".md",
58
+ ".py",
59
+ ".js",
60
+ ".ts",
61
+ ".jsx",
62
+ ".tsx",
63
+ ".json",
64
+ ".yaml",
65
+ ".yml",
66
+ ".html",
67
+ ".css",
68
+ ".java",
69
+ ".go",
70
+ ".rs",
71
+ ".rb",
72
+ ".sh",
73
+ ".csv",
74
+ ".sql",
75
+ ".toml",
76
+ }
77
+
78
+ SKIP_DIRS = {
79
+ ".git",
80
+ "node_modules",
81
+ "__pycache__",
82
+ ".venv",
83
+ "venv",
84
+ "env",
85
+ "dist",
86
+ "build",
87
+ ".next",
88
+ "coverage",
89
+ ".mempalace",
90
+ ".ruff_cache",
91
+ ".mypy_cache",
92
+ ".pytest_cache",
93
+ ".cache",
94
+ ".tox",
95
+ ".nox",
96
+ ".idea",
97
+ ".vscode",
98
+ ".ipynb_checkpoints",
99
+ ".eggs",
100
+ "htmlcov",
101
+ "target",
102
+ }
103
+
104
+ SKIP_FILENAMES = {
105
+ "mempalace.yaml",
106
+ "mempalace.yml",
107
+ "mempal.yaml",
108
+ "mempal.yml",
109
+ ".gitignore",
110
+ "package-lock.json",
111
+ }
112
+
113
+ MIN_CHUNK = 100 # chars — skip tiny fragments
114
+ TARGET_MIN = 400 # chars — merge threshold for small chunks
115
+ TARGET_MAX = 2500 # chars — ideal max for a logical unit
116
+ HARD_MAX = 4000 # chars — absolute max before forced split
117
+
118
+
119
+ def _detect_batch_size() -> int:
120
+ """Return an appropriate batch size based on the available compute device.
121
+
122
+ | Device | Batch | Reason |
123
+ |-------------------|-------|---------------------------------------------|
124
+ | CUDA | 256 | GPU VRAM handles larger batches efficiently |
125
+ | MPS (Apple Si) | 256 | Unified memory, similar capacity to CUDA |
126
+ | CPU (>4 GB RAM) | 128 | Proven default on MacBook |
127
+ | CPU (<=4 GB RAM) | 64 | Conservative for low-RAM devices |
128
+
129
+ Falls back to 128 on any detection failure.
130
+ """
131
+ try:
132
+ import torch
133
+
134
+ if torch.backends.mps.is_available():
135
+ return 256
136
+ if torch.cuda.is_available():
137
+ return 256
138
+ # CPU fallback — check available RAM via os.sysconf (no new dependency)
139
+ try:
140
+ mem_bytes = os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE")
141
+ return 128 if mem_bytes / (1024**3) > 4 else 64
142
+ except (AttributeError, ValueError, OSError):
143
+ return 128
144
+ except Exception:
145
+ return 128
146
+
147
+
148
+ BATCH_SIZE = _detect_batch_size()
149
+
150
+
151
+ # =============================================================================
152
+ # IGNORE MATCHING
153
+ # =============================================================================
154
+
155
+
156
+ class GitignoreMatcher:
157
+ """Lightweight matcher for one directory's .gitignore patterns."""
158
+
159
+ def __init__(self, base_dir: Path, rules: list):
160
+ self.base_dir = base_dir
161
+ self.rules = rules
162
+
163
+ @classmethod
164
+ def from_dir(cls, dir_path: Path):
165
+ gitignore_path = dir_path / ".gitignore"
166
+ if not gitignore_path.is_file():
167
+ return None
168
+
169
+ try:
170
+ lines = gitignore_path.read_text(encoding="utf-8", errors="replace").splitlines()
171
+ except Exception:
172
+ return None
173
+
174
+ rules = []
175
+ for raw_line in lines:
176
+ line = raw_line.strip()
177
+ if not line:
178
+ continue
179
+
180
+ if line.startswith("\\#") or line.startswith("\\!"):
181
+ line = line[1:]
182
+ elif line.startswith("#"):
183
+ continue
184
+
185
+ negated = line.startswith("!")
186
+ if negated:
187
+ line = line[1:]
188
+
189
+ anchored = line.startswith("/")
190
+ if anchored:
191
+ line = line.lstrip("/")
192
+
193
+ dir_only = line.endswith("/")
194
+ if dir_only:
195
+ line = line.rstrip("/")
196
+
197
+ if not line:
198
+ continue
199
+
200
+ rules.append(
201
+ {
202
+ "pattern": line,
203
+ "anchored": anchored,
204
+ "dir_only": dir_only,
205
+ "negated": negated,
206
+ }
207
+ )
208
+
209
+ if not rules:
210
+ return None
211
+
212
+ return cls(dir_path, rules)
213
+
214
+ def matches(self, path: Path, is_dir: bool = None):
215
+ try:
216
+ relative = path.relative_to(self.base_dir).as_posix().strip("/")
217
+ except ValueError:
218
+ return None
219
+
220
+ if not relative:
221
+ return None
222
+
223
+ if is_dir is None:
224
+ is_dir = path.is_dir()
225
+
226
+ ignored = None
227
+ for rule in self.rules:
228
+ if self._rule_matches(rule, relative, is_dir):
229
+ ignored = not rule["negated"]
230
+ return ignored
231
+
232
+ def _rule_matches(self, rule: dict, relative: str, is_dir: bool) -> bool:
233
+ pattern = rule["pattern"]
234
+ parts = relative.split("/")
235
+ pattern_parts = pattern.split("/")
236
+
237
+ if rule["dir_only"]:
238
+ target_parts = parts if is_dir else parts[:-1]
239
+ if not target_parts:
240
+ return False
241
+ if rule["anchored"] or len(pattern_parts) > 1:
242
+ return self._match_from_root(target_parts, pattern_parts)
243
+ return any(fnmatch.fnmatch(part, pattern) for part in target_parts)
244
+
245
+ if rule["anchored"] or len(pattern_parts) > 1:
246
+ return self._match_from_root(parts, pattern_parts)
247
+
248
+ return any(fnmatch.fnmatch(part, pattern) for part in parts)
249
+
250
+ def _match_from_root(self, target_parts: list, pattern_parts: list) -> bool:
251
+ def matches(path_index: int, pattern_index: int) -> bool:
252
+ if pattern_index == len(pattern_parts):
253
+ return True
254
+
255
+ if path_index == len(target_parts):
256
+ return all(part == "**" for part in pattern_parts[pattern_index:])
257
+
258
+ pattern_part = pattern_parts[pattern_index]
259
+ if pattern_part == "**":
260
+ return matches(path_index, pattern_index + 1) or matches(
261
+ path_index + 1, pattern_index
262
+ )
263
+
264
+ if not fnmatch.fnmatch(target_parts[path_index], pattern_part):
265
+ return False
266
+
267
+ return matches(path_index + 1, pattern_index + 1)
268
+
269
+ return matches(0, 0)
270
+
271
+
272
+ def load_gitignore_matcher(dir_path: Path, cache: dict):
273
+ """Load and cache one directory's .gitignore matcher."""
274
+ if dir_path not in cache:
275
+ cache[dir_path] = GitignoreMatcher.from_dir(dir_path)
276
+ return cache[dir_path]
277
+
278
+
279
+ def is_gitignored(path: Path, matchers: list, is_dir: bool = False) -> bool:
280
+ """Apply active .gitignore matchers in ancestor order; last match wins."""
281
+ ignored = False
282
+ for matcher in matchers:
283
+ decision = matcher.matches(path, is_dir=is_dir)
284
+ if decision is not None:
285
+ ignored = decision
286
+ return ignored
287
+
288
+
289
+ def should_skip_dir(dirname: str) -> bool:
290
+ """Skip known generated/cache directories before gitignore matching."""
291
+ return dirname in SKIP_DIRS or dirname.endswith(".egg-info")
292
+
293
+
294
+ def normalize_include_paths(include_ignored: list) -> set:
295
+ """Normalize comma-parsed include paths into project-relative POSIX strings."""
296
+ normalized = set()
297
+ for raw_path in include_ignored or []:
298
+ candidate = str(raw_path).strip().strip("/")
299
+ if candidate:
300
+ normalized.add(Path(candidate).as_posix())
301
+ return normalized
302
+
303
+
304
+ def is_exact_force_include(path: Path, project_path: Path, include_paths: set) -> bool:
305
+ """Return True when a path exactly matches an explicit include override."""
306
+ if not include_paths:
307
+ return False
308
+
309
+ try:
310
+ relative = path.relative_to(project_path).as_posix().strip("/")
311
+ except ValueError:
312
+ return False
313
+
314
+ return relative in include_paths
315
+
316
+
317
+ def is_force_included(path: Path, project_path: Path, include_paths: set) -> bool:
318
+ """Return True when a path or one of its ancestors/descendants was explicitly included."""
319
+ if not include_paths:
320
+ return False
321
+
322
+ try:
323
+ relative = path.relative_to(project_path).as_posix().strip("/")
324
+ except ValueError:
325
+ return False
326
+
327
+ if not relative:
328
+ return False
329
+
330
+ for include_path in include_paths:
331
+ if relative == include_path:
332
+ return True
333
+ if relative.startswith(f"{include_path}/"):
334
+ return True
335
+ if include_path.startswith(f"{relative}/"):
336
+ return True
337
+
338
+ return False
339
+
340
+
341
+ # =============================================================================
342
+ # CONFIG
343
+ # =============================================================================
344
+
345
+
346
+ def load_config(project_dir: str) -> dict:
347
+ """Load mempalace.yaml from project directory (falls back to mempal.yaml)."""
348
+ import yaml
349
+
350
+ config_path = Path(project_dir).expanduser().resolve() / "mempalace.yaml"
351
+ if not config_path.exists():
352
+ # Fallback to legacy name
353
+ legacy_path = Path(project_dir).expanduser().resolve() / "mempal.yaml"
354
+ if legacy_path.exists():
355
+ config_path = legacy_path
356
+ else:
357
+ print(f"ERROR: No mempalace.yaml found in {project_dir}")
358
+ print(f"Run: mempalace init {project_dir}")
359
+ sys.exit(1)
360
+ with open(config_path) as f:
361
+ return yaml.safe_load(f)
362
+
363
+
364
+ # =============================================================================
365
+ # FILE ROUTING — which room does this file belong to?
366
+ # =============================================================================
367
+
368
+
369
+ def detect_room(filepath: Path, content: str, rooms: list, project_path: Path) -> str:
370
+ """
371
+ Route a file to the right room.
372
+ Priority:
373
+ 1. Folder path matches a room name
374
+ 2. Filename matches a room name or keyword
375
+ 3. Content keyword scoring
376
+ 4. Fallback: "general"
377
+ """
378
+ relative = str(filepath.relative_to(project_path)).lower()
379
+ filename = filepath.stem.lower()
380
+ content_lower = content[:2000].lower()
381
+
382
+ # Priority 1: folder path matches room name or keywords
383
+ path_parts = relative.replace("\\", "/").split("/")
384
+ for part in path_parts[:-1]: # skip filename itself
385
+ for room in rooms:
386
+ candidates = [room["name"].lower()] + [k.lower() for k in room.get("keywords", [])]
387
+ if any(part == c or c in part or part in c for c in candidates):
388
+ return room["name"]
389
+
390
+ # Priority 2: filename matches room name
391
+ for room in rooms:
392
+ if room["name"].lower() in filename or filename in room["name"].lower():
393
+ return room["name"]
394
+
395
+ # Priority 3: keyword scoring from room keywords + name
396
+ scores = defaultdict(int)
397
+ for room in rooms:
398
+ keywords = room.get("keywords", []) + [room["name"]]
399
+ for kw in keywords:
400
+ count = content_lower.count(kw.lower())
401
+ scores[room["name"]] += count
402
+
403
+ if scores:
404
+ best = max(scores, key=scores.get)
405
+ if scores[best] > 0:
406
+ return best
407
+
408
+ return "general"
409
+
410
+
411
+ # =============================================================================
412
+ # CHUNKING — boundary regexes
413
+ # =============================================================================
414
+
415
+ # TypeScript / JavaScript structural boundaries
416
+ TS_BOUNDARY = re.compile(
417
+ r"^(?:"
418
+ r"export\s+(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum|const|let|var)\b"
419
+ r"|(?:async\s+)?function\s+\w+"
420
+ r"|class\s+\w+"
421
+ r"|interface\s+\w+"
422
+ r"|type\s+\w+\s*[=<]"
423
+ r"|enum\s+\w+"
424
+ r"|const\s+\w+\s*[:=]"
425
+ r"|let\s+\w+\s*[:=]"
426
+ r"|(?:describe|it|test|beforeEach|afterEach|beforeAll|afterAll)\s*\("
427
+ r"|module\.exports"
428
+ r"|exports\.\w+"
429
+ r")",
430
+ re.MULTILINE,
431
+ )
432
+
433
+ # Import block detection for TS/JS (group all imports together)
434
+ TS_IMPORT = re.compile(r"^(?:import\s|from\s|require\s*\()", re.MULTILINE)
435
+
436
+ # Python structural boundaries
437
+ PY_BOUNDARY = re.compile(
438
+ r"^(?:"
439
+ r"(?:async\s+)?def\s+\w+"
440
+ r"|class\s+\w+"
441
+ r"|@\w+"
442
+ r")",
443
+ re.MULTILINE,
444
+ )
445
+
446
+ # Go structural boundaries
447
+ GO_BOUNDARY = re.compile(
448
+ r"^(?:"
449
+ r"func\s+(?:\(.*?\)\s*)?\w+"
450
+ r"|type\s+\w+\s+(?:struct|interface)"
451
+ r"|var\s+\w+"
452
+ r"|const\s+\("
453
+ r")",
454
+ re.MULTILINE,
455
+ )
456
+
457
+ # Rust structural boundaries
458
+ RUST_BOUNDARY = re.compile(
459
+ r"^(?:"
460
+ r"(?:pub(?:\(crate\))?\s+)?(?:async\s+)?fn\s+\w+"
461
+ r"|(?:pub(?:\(crate\))?\s+)?(?:struct|enum|trait|impl|mod|type)\s+\w+"
462
+ r"|#\["
463
+ r")",
464
+ re.MULTILINE,
465
+ )
466
+
467
+ # Markdown heading boundaries
468
+ HEADING_MD = re.compile(r"^#{1,4}\s+.+", re.MULTILINE)
469
+
470
+
471
+ def get_boundary_pattern(language: str):
472
+ """Return the appropriate structural boundary regex for a language string or file extension."""
473
+ mapping = {
474
+ "python": PY_BOUNDARY,
475
+ ".py": PY_BOUNDARY,
476
+ "typescript": TS_BOUNDARY,
477
+ ".ts": TS_BOUNDARY,
478
+ ".tsx": TS_BOUNDARY,
479
+ "javascript": TS_BOUNDARY,
480
+ ".js": TS_BOUNDARY,
481
+ ".jsx": TS_BOUNDARY,
482
+ "go": GO_BOUNDARY,
483
+ ".go": GO_BOUNDARY,
484
+ "rust": RUST_BOUNDARY,
485
+ ".rs": RUST_BOUNDARY,
486
+ }
487
+ return mapping.get(language)
488
+
489
+
490
+ # =============================================================================
491
+ # LANGUAGE DETECTION
492
+ # =============================================================================
493
+
494
+
495
+ def detect_language(filepath: Path, content: str = "") -> str:
496
+ """
497
+ Detect the programming language for a file.
498
+
499
+ Resolution order:
500
+ 1. File extension lookup via EXTENSION_LANG_MAP.
501
+ 2. Shebang inspection on the first line (for extensionless files).
502
+ 3. Returns "unknown" if neither matches.
503
+ """
504
+ ext = filepath.suffix.lower()
505
+ if ext in EXTENSION_LANG_MAP:
506
+ return EXTENSION_LANG_MAP[ext]
507
+
508
+ # Shebang fallback — only for files with no recognized extension
509
+ first_line = content.split("\n")[0] if content else ""
510
+ if first_line.startswith("#!"):
511
+ # Strip "#!" and split by whitespace
512
+ parts = first_line[2:].strip().split()
513
+ if parts:
514
+ # #!/usr/bin/env python3 [-flags...] → env is parts[0], interpreter is parts[1]
515
+ # #!/usr/bin/python3 [-flags...] → interpreter basename is parts[0]
516
+ basename = parts[0].split("/")[-1]
517
+ if basename == "env" and len(parts) > 1:
518
+ interp = parts[1].split("/")[-1]
519
+ else:
520
+ interp = basename
521
+ for pattern, lang in SHEBANG_PATTERNS:
522
+ if pattern.fullmatch(interp):
523
+ return lang
524
+
525
+ return "unknown"
526
+
527
+
528
+ # =============================================================================
529
+ # SYMBOL EXTRACTION
530
+ # =============================================================================
531
+
532
+ # Per-language extraction patterns: list of (compiled_regex, symbol_type).
533
+ # Each regex has exactly one capture group for the symbol name.
534
+ # Ordered most-specific first within each language.
535
+
536
+ _PY_EXTRACT = [
537
+ (re.compile(r"^(?:async\s+)?def\s+(\w+)", re.MULTILINE), "function"),
538
+ (re.compile(r"^class\s+(\w+)", re.MULTILINE), "class"),
539
+ ]
540
+
541
+ _TS_EXTRACT = [
542
+ (re.compile(r"^export\s+default\s+(?:async\s+)?function\s+(\w+)", re.MULTILINE), "function"),
543
+ (re.compile(r"^export\s+default\s+class\s+(\w+)", re.MULTILINE), "class"),
544
+ (re.compile(r"^(?:export\s+)?(?:async\s+)?function\s+(\w+)", re.MULTILINE), "function"),
545
+ (re.compile(r"^(?:export\s+)?class\s+(\w+)", re.MULTILINE), "class"),
546
+ (re.compile(r"^(?:export\s+)?interface\s+(\w+)", re.MULTILINE), "interface"),
547
+ (re.compile(r"^(?:export\s+)?type\s+(\w+)\s*[=<]", re.MULTILINE), "type"),
548
+ (re.compile(r"^(?:export\s+)?enum\s+(\w+)", re.MULTILINE), "enum"),
549
+ (re.compile(r"^(?:export\s+)?const\s+(\w+)\s*[:=]", re.MULTILINE), "const"),
550
+ ]
551
+
552
+ _TS_IMPORT_RE = re.compile(r"^(?:import\s|from\s|require\s*\()", re.MULTILINE)
553
+
554
+ _GO_EXTRACT = [
555
+ (re.compile(r"^func\s+\(.*?\)\s+(\w+)", re.MULTILINE), "method"),
556
+ (re.compile(r"^func\s+(\w+)", re.MULTILINE), "function"),
557
+ (re.compile(r"^type\s+(\w+)\s+struct", re.MULTILINE), "struct"),
558
+ (re.compile(r"^type\s+(\w+)\s+interface", re.MULTILINE), "interface"),
559
+ ]
560
+
561
+ _RUST_EXTRACT = [
562
+ (re.compile(r"^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+(\w+)", re.MULTILINE), "function"),
563
+ (re.compile(r"^(?:pub(?:\([^)]*\))?\s+)?struct\s+(\w+)", re.MULTILINE), "struct"),
564
+ (re.compile(r"^(?:pub(?:\([^)]*\))?\s+)?enum\s+(\w+)", re.MULTILINE), "enum"),
565
+ (re.compile(r"^(?:pub(?:\([^)]*\))?\s+)?trait\s+(\w+)", re.MULTILINE), "trait"),
566
+ (re.compile(r"^(?:pub(?:\([^)]*\))?\s+)?impl(?:\s*<[^>]*>)?\s+(\w+)", re.MULTILINE), "impl"),
567
+ (re.compile(r"^(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)", re.MULTILINE), "mod"),
568
+ (re.compile(r"^(?:pub(?:\([^)]*\))?\s+)?type\s+(\w+)", re.MULTILINE), "type"),
569
+ ]
570
+
571
+ _LANG_EXTRACT_MAP = {
572
+ "python": _PY_EXTRACT,
573
+ "typescript": _TS_EXTRACT,
574
+ "javascript": _TS_EXTRACT,
575
+ "go": _GO_EXTRACT,
576
+ "rust": _RUST_EXTRACT,
577
+ }
578
+
579
+
580
+ def extract_symbol(content: str, language: str) -> tuple:
581
+ """
582
+ Extract the primary symbol defined in a code chunk.
583
+ Returns (symbol_name, symbol_type) or ("", "") if none found.
584
+ Non-code languages (markdown, text, json, yaml, unknown, etc.) return ("", "").
585
+ TS/JS import-only chunks return ("", "import").
586
+ """
587
+ patterns = _LANG_EXTRACT_MAP.get(language)
588
+ if patterns is None:
589
+ return ("", "")
590
+
591
+ # TS/JS: detect import-only chunks (first non-empty line starts with an import keyword)
592
+ is_import_chunk = False
593
+ if language in ("typescript", "javascript"):
594
+ first_non_empty = next((line for line in content.splitlines() if line.strip()), "")
595
+ is_import_chunk = bool(_TS_IMPORT_RE.match(first_non_empty.strip()))
596
+
597
+ for pattern, sym_type in patterns:
598
+ m = re.search(pattern, content)
599
+ if m:
600
+ return (m.group(1), sym_type)
601
+
602
+ return ("", "import") if is_import_chunk else ("", "")
603
+
604
+
605
+ # =============================================================================
606
+ # CHUNKING — strategies
607
+ # =============================================================================
608
+
609
+
610
+ def chunk_file(content: str, ext: str, source_file: str, language: str = None) -> list:
611
+ """Dispatcher — route to the right chunking strategy based on language."""
612
+ if language is None:
613
+ language = EXTENSION_LANG_MAP.get(ext, "unknown")
614
+
615
+ if language in ("python", "typescript", "javascript", "go", "rust"):
616
+ return chunk_code(content, language, source_file)
617
+ elif language in ("markdown", "text"):
618
+ return chunk_prose(content, source_file)
619
+ else:
620
+ return chunk_adaptive_lines(content, source_file)
621
+
622
+
623
+ def chunk_code(content: str, language: str, source_file: str) -> list:
624
+ """
625
+ Split code at structural boundaries (function/class/export declarations).
626
+ Groups imports. Attaches leading comments immediately adjacent to declarations.
627
+ Falls back to chunk_adaptive_lines() if no boundaries are detected.
628
+
629
+ `language` accepts canonical language strings ("python", "typescript") or
630
+ raw file extensions (".py", ".ts") for backward compatibility.
631
+ """
632
+ boundary = get_boundary_pattern(language)
633
+ if not boundary:
634
+ return chunk_adaptive_lines(content, source_file)
635
+
636
+ is_ts_js = language in ("typescript", "javascript", ".ts", ".tsx", ".js", ".jsx")
637
+
638
+ lines = content.split("\n")
639
+ boundaries = []
640
+ in_import_block = False
641
+
642
+ for i, line in enumerate(lines):
643
+ stripped = line.strip()
644
+ if not stripped:
645
+ if in_import_block:
646
+ in_import_block = False
647
+ continue
648
+
649
+ if TS_IMPORT.match(stripped) and is_ts_js:
650
+ if not in_import_block:
651
+ boundaries.append(("import", i))
652
+ in_import_block = True
653
+ continue
654
+ in_import_block = False
655
+
656
+ # For TS/JS, match against the original line so indented `const`/`let` inside
657
+ # function bodies (e.g., ` const x = ...`) don't trigger false boundaries.
658
+ # For Python/Go/Rust, match against the stripped line because methods are indented.
659
+ match_target = line if is_ts_js else stripped
660
+ if boundary.match(match_target):
661
+ # Look back for leading comments immediately adjacent (no blank-line gap).
662
+ comment_start = i
663
+ j = i - 1
664
+ while j >= 0:
665
+ prev = lines[j].strip()
666
+ if prev.startswith(("//", "/*", "*", "*/", "#", '"""', "'''", "/**")):
667
+ comment_start = j
668
+ j -= 1
669
+ else:
670
+ break # stop at blank lines or non-comment lines
671
+ boundaries.append(("decl", comment_start))
672
+
673
+ if not boundaries:
674
+ return chunk_adaptive_lines(content, source_file)
675
+
676
+ raw_chunks = []
677
+
678
+ # Preamble before the first boundary (file header, license, etc.)
679
+ if boundaries[0][1] > 0:
680
+ preamble = "\n".join(lines[: boundaries[0][1]]).strip()
681
+ if preamble:
682
+ raw_chunks.append(preamble)
683
+
684
+ for idx, (_kind, start) in enumerate(boundaries):
685
+ end = boundaries[idx + 1][1] if idx + 1 < len(boundaries) else len(lines)
686
+ text = "\n".join(lines[start:end]).strip()
687
+ if text:
688
+ raw_chunks.append(text)
689
+
690
+ # adaptive_merge_split handles final MIN_CHUNK filtering and merging of small pieces.
691
+ return adaptive_merge_split(raw_chunks, source_file)
692
+
693
+
694
+ def chunk_prose(content: str, source_file: str) -> list:
695
+ """
696
+ Split prose at markdown heading boundaries (#–####).
697
+ Falls back to paragraph chunking if no headings are found.
698
+ """
699
+ lines = content.split("\n")
700
+ heading_lines = [i for i, line in enumerate(lines) if HEADING_MD.match(line.strip())]
701
+
702
+ if not heading_lines:
703
+ # Paragraph fallback — let adaptive_merge_split handle MIN_CHUNK filtering.
704
+ paragraphs = [p.strip() for p in re.split(r"\n\s*\n", content) if p.strip()]
705
+ return adaptive_merge_split(paragraphs, source_file) if paragraphs else []
706
+
707
+ raw_chunks = []
708
+
709
+ # Preamble before the first heading
710
+ if heading_lines[0] > 0:
711
+ preamble = "\n".join(lines[: heading_lines[0]]).strip()
712
+ if preamble:
713
+ raw_chunks.append(preamble)
714
+
715
+ for idx, start in enumerate(heading_lines):
716
+ end = heading_lines[idx + 1] if idx + 1 < len(heading_lines) else len(lines)
717
+ section = "\n".join(lines[start:end]).strip()
718
+ if section:
719
+ raw_chunks.append(section)
720
+
721
+ return adaptive_merge_split(raw_chunks, source_file)
722
+
723
+
724
+ def chunk_adaptive_lines(content: str, source_file: str) -> list:
725
+ """
726
+ Fallback for files without dedicated structural patterns.
727
+ Split at blank lines with adaptive sizing.
728
+ """
729
+ blocks = re.split(r"\n\s*\n", content)
730
+ raw = [b.strip() for b in blocks if len(b.strip()) >= MIN_CHUNK]
731
+ if not raw:
732
+ if len(content.strip()) >= MIN_CHUNK:
733
+ return [{"content": content.strip(), "chunk_index": 0}]
734
+ return []
735
+ return adaptive_merge_split(raw, source_file)
736
+
737
+
738
+ def adaptive_merge_split(raw_chunks: list, source_file: str) -> list:
739
+ """
740
+ Post-process raw chunks:
741
+ - Merge adjacent small chunks (< TARGET_MIN) up to TARGET_MAX.
742
+ - Split oversized chunks (> HARD_MAX) at paragraph/line sub-boundaries.
743
+ Returns list of {"content": str, "chunk_index": int}.
744
+ """
745
+ if not raw_chunks:
746
+ return []
747
+
748
+ result = []
749
+ buffer = ""
750
+
751
+ for chunk in raw_chunks:
752
+ if len(chunk) > HARD_MAX:
753
+ if buffer.strip():
754
+ result.append(buffer.strip())
755
+ buffer = ""
756
+ result.extend(_split_oversized(chunk))
757
+ elif len(buffer) + len(chunk) + 2 <= TARGET_MAX:
758
+ buffer = f"{buffer}\n\n{chunk}" if buffer else chunk
759
+ else:
760
+ if buffer.strip():
761
+ result.append(buffer.strip())
762
+ buffer = chunk
763
+
764
+ if buffer.strip():
765
+ result.append(buffer.strip())
766
+
767
+ filtered = [text for text in result if len(text) >= MIN_CHUNK]
768
+ return [{"content": text, "chunk_index": i} for i, text in enumerate(filtered)]
769
+
770
+
771
+ def _split_oversized(text: str) -> list:
772
+ """Split a chunk exceeding HARD_MAX at the best available sub-boundary."""
773
+ # Try paragraph breaks first
774
+ parts = re.split(r"\n\s*\n", text)
775
+ if len(parts) > 1:
776
+ merged = []
777
+ buf = ""
778
+ for part in parts:
779
+ if len(buf) + len(part) + 2 <= TARGET_MAX:
780
+ buf = f"{buf}\n\n{part}" if buf else part
781
+ else:
782
+ if buf.strip():
783
+ merged.append(buf.strip())
784
+ buf = part
785
+ if buf.strip():
786
+ merged.append(buf.strip())
787
+ return merged
788
+
789
+ # Fall back to line-based splitting
790
+ lines_list = text.split("\n")
791
+ merged = []
792
+ buf = ""
793
+ for line in lines_list:
794
+ if len(buf) + len(line) + 1 <= TARGET_MAX:
795
+ buf = f"{buf}\n{line}" if buf else line
796
+ else:
797
+ if buf.strip():
798
+ merged.append(buf.strip())
799
+ buf = line
800
+ if buf.strip():
801
+ merged.append(buf.strip())
802
+ return merged
803
+
804
+
805
+ # =============================================================================
806
+ # INCREMENTAL MINING HELPERS
807
+ # =============================================================================
808
+
809
+
810
+ def _file_hash(path: Path) -> str:
811
+ """Return blake2b hex digest (32 chars) of raw file bytes."""
812
+ h = hashlib.blake2b(digest_size=16)
813
+ h.update(path.read_bytes())
814
+ return h.hexdigest()
815
+
816
+
817
+ def _bulk_existing_file_hashes(collection, wing: str) -> dict:
818
+ """Return {source_file: source_hash} for all drawers in wing.
819
+
820
+ Delegates to collection.get_source_file_hashes() (LanceDB column projection,
821
+ no vector scan). Returns an empty dict on unsupported backends or empty palace.
822
+ """
823
+ result = collection.get_source_file_hashes(wing)
824
+ return result if result is not None else {}
825
+
826
+
827
+ # =============================================================================
828
+ # PALACE — ChromaDB operations
829
+ # =============================================================================
830
+
831
+
832
+ def get_collection(palace_path: str):
833
+ """Open (or create) the drawer store for a palace."""
834
+ os.makedirs(palace_path, exist_ok=True)
835
+ return open_store(palace_path, create=True)
836
+
837
+
838
+ def file_already_mined(collection, source_file: str) -> bool:
839
+ """Fast check: has this file been filed before?"""
840
+ try:
841
+ results = collection.get(where={"source_file": source_file}, limit=1)
842
+ return len(results.get("ids", [])) > 0
843
+ except Exception:
844
+ return False
845
+
846
+
847
+ def add_drawer(
848
+ collection,
849
+ wing: str,
850
+ room: str,
851
+ content: str,
852
+ source_file: str,
853
+ chunk_index: int,
854
+ agent: str,
855
+ language: str = "unknown",
856
+ symbol_name: str = "",
857
+ symbol_type: str = "",
858
+ ):
859
+ """Add one drawer to the palace."""
860
+ drawer_id = f"drawer_{wing}_{room}_{hashlib.md5((source_file + str(chunk_index)).encode(), usedforsecurity=False).hexdigest()[:16]}"
861
+ try:
862
+ collection.add(
863
+ documents=[content],
864
+ ids=[drawer_id],
865
+ metadatas=[
866
+ {
867
+ "wing": wing,
868
+ "room": room,
869
+ "source_file": source_file,
870
+ "chunk_index": chunk_index,
871
+ "added_by": agent,
872
+ "filed_at": datetime.now().isoformat(),
873
+ "language": language,
874
+ "symbol_name": symbol_name,
875
+ "symbol_type": symbol_type,
876
+ }
877
+ ],
878
+ )
879
+ return True
880
+ except Exception as e:
881
+ if "already exists" in str(e).lower() or "duplicate" in str(e).lower():
882
+ return False
883
+ raise
884
+
885
+
886
+ # =============================================================================
887
+ # BATCH HELPERS
888
+ # =============================================================================
889
+
890
+
891
+ def _collect_specs_for_file(
892
+ filepath: Path,
893
+ project_path: Path,
894
+ collection,
895
+ wing: str,
896
+ rooms: list,
897
+ agent: str,
898
+ mined_files: Optional[set] = None,
899
+ source_hash: str = "",
900
+ ) -> list:
901
+ """Read, chunk, and prepare drawer specs for one file without writing.
902
+
903
+ Returns [] if the file is already mined, unreadable, or below MIN_CHUNK.
904
+ Each spec dict has keys: id, content, metadata.
905
+ IDs and filed_at timestamps are set at spec-creation time.
906
+
907
+ If *mined_files* is provided (a set of source_file strings pre-fetched for the
908
+ wing), membership is checked in O(1) instead of issuing a per-file LanceDB query.
909
+ Falls back to file_already_mined() when mined_files is None.
910
+
911
+ *source_hash* is the blake2b digest of the file bytes (computed once in mine()).
912
+ Stored verbatim on every drawer for incremental change detection.
913
+ """
914
+ source_file = str(filepath)
915
+ if mined_files is not None:
916
+ if source_file in mined_files:
917
+ return []
918
+ elif file_already_mined(collection, source_file):
919
+ return []
920
+
921
+ try:
922
+ content = filepath.read_text(encoding="utf-8", errors="replace")
923
+ except OSError:
924
+ return []
925
+
926
+ content = content.strip()
927
+ if len(content) < MIN_CHUNK:
928
+ return []
929
+
930
+ language = detect_language(filepath, content)
931
+ room = detect_room(filepath, content, rooms, project_path)
932
+ chunks = chunk_file(content, filepath.suffix.lower(), source_file, language=language)
933
+
934
+ specs = []
935
+ for chunk in chunks:
936
+ symbol_name, symbol_type = extract_symbol(chunk["content"], language)
937
+ drawer_id = f"drawer_{wing}_{room}_{hashlib.md5((source_file + str(chunk['chunk_index'])).encode(), usedforsecurity=False).hexdigest()[:16]}"
938
+ specs.append(
939
+ {
940
+ "id": drawer_id,
941
+ "content": chunk["content"],
942
+ "metadata": {
943
+ "wing": wing,
944
+ "room": room,
945
+ "source_file": source_file,
946
+ "chunk_index": chunk["chunk_index"],
947
+ "added_by": agent,
948
+ "filed_at": datetime.now().isoformat(),
949
+ "language": language,
950
+ "symbol_name": symbol_name,
951
+ "symbol_type": symbol_type,
952
+ "source_hash": source_hash,
953
+ "extractor_version": __version__,
954
+ "chunker_strategy": "regex_structural_v1",
955
+ },
956
+ }
957
+ )
958
+ return specs
959
+
960
+
961
+ def add_drawers_batch(collection, specs: list) -> int:
962
+ """Embed and upsert a batch of drawer specs. Idempotent: re-mining the same
963
+ file updates existing drawers in place instead of appending duplicates."""
964
+ if not specs:
965
+ return 0
966
+ collection.upsert(
967
+ ids=[s["id"] for s in specs],
968
+ documents=[s["content"] for s in specs],
969
+ metadatas=[s["metadata"] for s in specs],
970
+ )
971
+ return len(specs)
972
+
973
+
974
+ # =============================================================================
975
+ # PROCESS ONE FILE
976
+ # =============================================================================
977
+
978
+
979
+ def process_file(
980
+ filepath: Path,
981
+ project_path: Path,
982
+ collection,
983
+ wing: str,
984
+ rooms: list,
985
+ agent: str,
986
+ dry_run: bool,
987
+ ) -> int:
988
+ """Read, chunk, route, and file one file. Returns drawer count."""
989
+
990
+ if dry_run:
991
+ source_file = str(filepath)
992
+ try:
993
+ content = filepath.read_text(encoding="utf-8", errors="replace")
994
+ except OSError:
995
+ return 0
996
+ content = content.strip()
997
+ if len(content) < MIN_CHUNK:
998
+ return 0
999
+ language = detect_language(filepath, content)
1000
+ room = detect_room(filepath, content, rooms, project_path)
1001
+ chunks = chunk_file(content, filepath.suffix.lower(), source_file, language=language)
1002
+ print(f" [DRY RUN] {filepath.name} → room:{room} ({len(chunks)} drawers)")
1003
+ return len(chunks)
1004
+
1005
+ specs = _collect_specs_for_file(filepath, project_path, collection, wing, rooms, agent)
1006
+ return add_drawers_batch(collection, specs)
1007
+
1008
+
1009
+ # =============================================================================
1010
+ # SCAN PROJECT
1011
+ # =============================================================================
1012
+
1013
+
1014
+ def scan_project(
1015
+ project_dir: str,
1016
+ respect_gitignore: bool = True,
1017
+ include_ignored: list = None,
1018
+ ) -> list:
1019
+ """Return list of all readable file paths."""
1020
+ project_path = Path(project_dir).expanduser().resolve()
1021
+ files = []
1022
+ active_matchers = []
1023
+ matcher_cache = {}
1024
+ include_paths = normalize_include_paths(include_ignored)
1025
+
1026
+ for root, dirs, filenames in os.walk(project_path):
1027
+ root_path = Path(root)
1028
+
1029
+ if respect_gitignore:
1030
+ active_matchers = [
1031
+ matcher
1032
+ for matcher in active_matchers
1033
+ if root_path == matcher.base_dir or matcher.base_dir in root_path.parents
1034
+ ]
1035
+ current_matcher = load_gitignore_matcher(root_path, matcher_cache)
1036
+ if current_matcher is not None:
1037
+ active_matchers.append(current_matcher)
1038
+
1039
+ dirs[:] = [
1040
+ d
1041
+ for d in dirs
1042
+ if is_force_included(root_path / d, project_path, include_paths)
1043
+ or not should_skip_dir(d)
1044
+ ]
1045
+ if respect_gitignore and active_matchers:
1046
+ dirs[:] = [
1047
+ d
1048
+ for d in dirs
1049
+ if is_force_included(root_path / d, project_path, include_paths)
1050
+ or not is_gitignored(root_path / d, active_matchers, is_dir=True)
1051
+ ]
1052
+
1053
+ for filename in filenames:
1054
+ filepath = root_path / filename
1055
+ force_include = is_force_included(filepath, project_path, include_paths)
1056
+ exact_force_include = is_exact_force_include(filepath, project_path, include_paths)
1057
+
1058
+ if not force_include and filename in SKIP_FILENAMES:
1059
+ continue
1060
+ if filepath.suffix.lower() not in READABLE_EXTENSIONS and not exact_force_include:
1061
+ continue
1062
+ if respect_gitignore and active_matchers and not force_include:
1063
+ if is_gitignored(filepath, active_matchers, is_dir=False):
1064
+ continue
1065
+ files.append(filepath)
1066
+ return files
1067
+
1068
+
1069
+ # =============================================================================
1070
+ # MAIN: MINE
1071
+ # =============================================================================
1072
+
1073
+
1074
+ def mine(
1075
+ project_dir: str,
1076
+ palace_path: str,
1077
+ wing_override: str = None,
1078
+ agent: str = "mempalace",
1079
+ limit: int = 0,
1080
+ dry_run: bool = False,
1081
+ respect_gitignore: bool = True,
1082
+ include_ignored: list = None,
1083
+ incremental: bool = True,
1084
+ ):
1085
+ """Mine a project directory into the palace.
1086
+
1087
+ When *incremental* is True (default), only files whose content hash has changed
1088
+ since the last mine are re-chunked. Deleted files are swept after a full walk.
1089
+ Pass *incremental=False* (or --full from the CLI) to force a clean rebuild.
1090
+ """
1091
+
1092
+ project_path = Path(project_dir).expanduser().resolve()
1093
+ config = load_config(project_dir)
1094
+
1095
+ wing = wing_override or config["wing"]
1096
+ rooms = config.get("rooms", [{"name": "general", "description": "All project files"}])
1097
+
1098
+ files = scan_project(
1099
+ project_dir,
1100
+ respect_gitignore=respect_gitignore,
1101
+ include_ignored=include_ignored,
1102
+ )
1103
+ if limit > 0:
1104
+ files = files[:limit]
1105
+
1106
+ mine_start = time.time()
1107
+
1108
+ print(f"\n{'=' * 55}")
1109
+ print(" MemPalace Mine")
1110
+ print(f"{'=' * 55}")
1111
+ print(f" Wing: {wing}")
1112
+ print(f" Rooms: {', '.join(r['name'] for r in rooms)}")
1113
+ print(f" Files: {len(files)}")
1114
+ print(f" Palace: {palace_path}")
1115
+ if dry_run:
1116
+ print(" DRY RUN — nothing will be filed")
1117
+ if not incremental:
1118
+ print(" Mode: FULL REBUILD (--full)")
1119
+ if not respect_gitignore:
1120
+ print(" .gitignore: DISABLED")
1121
+ if include_ignored:
1122
+ print(f" Include: {', '.join(sorted(normalize_include_paths(include_ignored)))}")
1123
+ print(f"{'─' * 55}\n")
1124
+
1125
+ if not dry_run:
1126
+ print(" Loading embedding model...", flush=True)
1127
+ collection = get_collection(palace_path)
1128
+ collection.warmup()
1129
+ print(" Model ready.\n", flush=True)
1130
+ existing_hashes = _bulk_existing_file_hashes(collection, wing)
1131
+ else:
1132
+ collection = None
1133
+ existing_hashes = {}
1134
+
1135
+ total_drawers = 0
1136
+ files_skipped = 0
1137
+ room_counts = defaultdict(int)
1138
+ batch_buffer: list = []
1139
+ batch_num = 0
1140
+ walked_paths: set = set()
1141
+
1142
+ def flush_batch() -> None:
1143
+ nonlocal total_drawers, batch_num
1144
+ batch_num += 1
1145
+ count = len(batch_buffer)
1146
+ print(
1147
+ f" >> Embedding batch {batch_num} ({count} chunks)...",
1148
+ end="",
1149
+ flush=True,
1150
+ )
1151
+ t0 = time.time()
1152
+ total_drawers += add_drawers_batch(collection, batch_buffer)
1153
+ elapsed = time.time() - t0
1154
+ print(f" done ({elapsed:.1f}s)", flush=True)
1155
+ batch_buffer.clear()
1156
+
1157
+ try:
1158
+ for i, filepath in enumerate(files, 1):
1159
+ source_file = str(filepath)
1160
+ walked_paths.add(source_file)
1161
+
1162
+ if dry_run:
1163
+ drawers = process_file(
1164
+ filepath=filepath,
1165
+ project_path=project_path,
1166
+ collection=collection,
1167
+ wing=wing,
1168
+ rooms=rooms,
1169
+ agent=agent,
1170
+ dry_run=True,
1171
+ )
1172
+ total_drawers += drawers
1173
+ room = detect_room(filepath, "", rooms, project_path)
1174
+ room_counts[room] += 1
1175
+ continue
1176
+
1177
+ # Print scanning progress every 100 files so large repos aren't silent
1178
+ if i % 100 == 0 or i == 1:
1179
+ print(
1180
+ f" Scanning [{i:4}/{len(files)}]...",
1181
+ end="\r",
1182
+ flush=True,
1183
+ )
1184
+
1185
+ current_hash = _file_hash(filepath)
1186
+
1187
+ if incremental:
1188
+ stored_hash = existing_hashes.get(source_file, "")
1189
+ if stored_hash == current_hash and stored_hash != "":
1190
+ # File unchanged — skip
1191
+ files_skipped += 1
1192
+ continue
1193
+ # Hash mismatch or new file — delete old drawers then re-mine
1194
+ if source_file in existing_hashes:
1195
+ collection.delete_by_source_file(source_file, wing)
1196
+ else:
1197
+ # --full mode: unconditionally delete existing drawers and re-mine
1198
+ collection.delete_by_source_file(source_file, wing)
1199
+
1200
+ specs = _collect_specs_for_file(
1201
+ filepath,
1202
+ project_path,
1203
+ collection,
1204
+ wing,
1205
+ rooms,
1206
+ agent,
1207
+ mined_files=None,
1208
+ source_hash=current_hash,
1209
+ )
1210
+ if not specs:
1211
+ files_skipped += 1
1212
+ continue
1213
+
1214
+ room = specs[0]["metadata"]["room"]
1215
+ room_counts[room] += 1
1216
+ print(f" ✓ [{i:4}/{len(files)}] {filepath.name[:50]:50} +{len(specs)}")
1217
+
1218
+ batch_buffer.extend(specs)
1219
+ if len(batch_buffer) >= BATCH_SIZE:
1220
+ flush_batch()
1221
+
1222
+ if not dry_run:
1223
+ if batch_buffer:
1224
+ flush_batch()
1225
+
1226
+ # Stale-file sweep: remove drawers for files no longer on disk.
1227
+ # Only safe when the full file set was walked (limit == 0).
1228
+ if incremental and limit == 0:
1229
+ stale_paths = set(existing_hashes.keys()) - walked_paths
1230
+ for stale_path in stale_paths:
1231
+ collection.delete_by_source_file(stale_path, wing)
1232
+
1233
+ t0 = time.time()
1234
+ print(" >> Optimizing storage...", end="", flush=True)
1235
+ collection.optimize()
1236
+ print(f" done ({time.time() - t0:.1f}s)", flush=True)
1237
+ except KeyboardInterrupt:
1238
+ print("\n\n Interrupted. Flushing pending batch...", flush=True)
1239
+ if batch_buffer and not dry_run:
1240
+ flush_batch()
1241
+ print(f" {total_drawers} drawers filed before interrupt.")
1242
+
1243
+ elapsed = time.time() - mine_start
1244
+ mins, secs = divmod(int(elapsed), 60)
1245
+
1246
+ print(f"\n{'=' * 55}")
1247
+ print(" Done.")
1248
+ print(f" Files processed: {len(files) - files_skipped}")
1249
+ print(f" Files skipped (already filed): {files_skipped}")
1250
+ print(f" Drawers filed: {total_drawers}")
1251
+ print(f" Time: {mins}m {secs}s")
1252
+ print("\n By room:")
1253
+ for room, count in sorted(room_counts.items(), key=lambda x: x[1], reverse=True):
1254
+ print(f" {room:20} {count} files")
1255
+ print('\n Next: mempalace search "what you\'re looking for"')
1256
+ print(f"{'=' * 55}\n")
1257
+
1258
+
1259
+ # =============================================================================
1260
+ # STATUS
1261
+ # =============================================================================
1262
+
1263
+
1264
+ def status(palace_path: str):
1265
+ """Show what's been filed in the palace."""
1266
+ try:
1267
+ store = open_store(palace_path, create=False)
1268
+ except Exception:
1269
+ print(f"\n No palace found at {palace_path}")
1270
+ print(" Run: mempalace init <dir> then mempalace mine <dir>")
1271
+ return
1272
+
1273
+ # Count by wing and room
1274
+ total = store.count()
1275
+ wing_rooms = store.count_by_pair("wing", "room")
1276
+
1277
+ print(f"\n{'=' * 55}")
1278
+ print(f" MemPalace Status — {total} drawers")
1279
+ print(f"{'=' * 55}\n")
1280
+ for wing, rooms in sorted(wing_rooms.items()):
1281
+ print(f" WING: {wing}")
1282
+ for room, count in sorted(rooms.items(), key=lambda x: x[1], reverse=True):
1283
+ print(f" ROOM: {room:20} {count:5} drawers")
1284
+ print()
1285
+ print(f"{'=' * 55}\n")