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/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")
|