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/searcher.py ADDED
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ searcher.py — Find anything. Exact words.
4
+
5
+ Semantic search against the palace.
6
+ Returns verbatim text — the actual words, never summaries.
7
+ """
8
+
9
+ import fnmatch
10
+ import logging
11
+ from pathlib import Path
12
+
13
+ from .storage import open_store
14
+
15
+ logger = logging.getLogger("mempalace_mcp")
16
+
17
+
18
+ class SearchError(Exception):
19
+ """Raised when search cannot proceed (e.g. no palace found)."""
20
+
21
+
22
+ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5):
23
+ """
24
+ Search the palace. Returns verbatim drawer content.
25
+ Optionally filter by wing (project) or room (aspect).
26
+ """
27
+ try:
28
+ store = open_store(palace_path, create=False)
29
+ except Exception:
30
+ print(f"\n No palace found at {palace_path}")
31
+ print(" Run: mempalace init <dir> then mempalace mine <dir>")
32
+ raise SearchError(f"No palace found at {palace_path}")
33
+
34
+ # Build where filter
35
+ where = {}
36
+ if wing and room:
37
+ where = {"$and": [{"wing": wing}, {"room": room}]}
38
+ elif wing:
39
+ where = {"wing": wing}
40
+ elif room:
41
+ where = {"room": room}
42
+
43
+ try:
44
+ kwargs = {
45
+ "query_texts": [query],
46
+ "n_results": n_results,
47
+ "include": ["documents", "metadatas", "distances"],
48
+ }
49
+ if where:
50
+ kwargs["where"] = where
51
+
52
+ results = store.query(**kwargs)
53
+
54
+ except Exception as e:
55
+ print(f"\n Search error: {e}")
56
+ raise SearchError(f"Search error: {e}") from e
57
+
58
+ docs = results["documents"][0]
59
+ metas = results["metadatas"][0]
60
+ dists = results["distances"][0]
61
+
62
+ if not docs:
63
+ print(f'\n No results found for: "{query}"')
64
+ return
65
+
66
+ print(f"\n{'=' * 60}")
67
+ print(f' Results for: "{query}"')
68
+ if wing:
69
+ print(f" Wing: {wing}")
70
+ if room:
71
+ print(f" Room: {room}")
72
+ print(f"{'=' * 60}\n")
73
+
74
+ for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1):
75
+ similarity = round(1 - dist, 3)
76
+ source = Path(meta.get("source_file", "?")).name
77
+ wing_name = meta.get("wing", "?")
78
+ room_name = meta.get("room", "?")
79
+
80
+ print(f" [{i}] {wing_name} / {room_name}")
81
+ print(f" Source: {source}")
82
+ print(f" Match: {similarity}")
83
+ print()
84
+ # Print the verbatim text, indented
85
+ for line in doc.strip().split("\n"):
86
+ print(f" {line}")
87
+ print()
88
+ print(f" {'─' * 56}")
89
+
90
+ print()
91
+
92
+
93
+ def search_memories(
94
+ query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5
95
+ ) -> dict:
96
+ """
97
+ Programmatic search — returns a dict instead of printing.
98
+ Used by the MCP server and other callers that need data.
99
+ """
100
+ try:
101
+ store = open_store(palace_path, create=False)
102
+ except Exception as e:
103
+ logger.error("No palace found at %s: %s", palace_path, e)
104
+ return {
105
+ "error": "No palace found",
106
+ "hint": "Run: mempalace init <dir> && mempalace mine <dir>",
107
+ }
108
+
109
+ # Build where filter
110
+ where = {}
111
+ if wing and room:
112
+ where = {"$and": [{"wing": wing}, {"room": room}]}
113
+ elif wing:
114
+ where = {"wing": wing}
115
+ elif room:
116
+ where = {"room": room}
117
+
118
+ try:
119
+ kwargs = {
120
+ "query_texts": [query],
121
+ "n_results": n_results,
122
+ "include": ["documents", "metadatas", "distances"],
123
+ }
124
+ if where:
125
+ kwargs["where"] = where
126
+
127
+ results = store.query(**kwargs)
128
+ except Exception as e:
129
+ return {"error": f"Search error: {e}"}
130
+
131
+ docs = results["documents"][0]
132
+ metas = results["metadatas"][0]
133
+ dists = results["distances"][0]
134
+
135
+ hits = []
136
+ for doc, meta, dist in zip(docs, metas, dists):
137
+ hits.append(
138
+ {
139
+ "text": doc,
140
+ "wing": meta.get("wing", "unknown"),
141
+ "room": meta.get("room", "unknown"),
142
+ "source_file": Path(meta.get("source_file", "?")).name,
143
+ "symbol_name": meta.get("symbol_name", "") or "",
144
+ "symbol_type": meta.get("symbol_type", "") or "",
145
+ "language": meta.get("language", "") or "",
146
+ "similarity": round(1 - dist, 3),
147
+ }
148
+ )
149
+
150
+ return {
151
+ "query": query,
152
+ "filters": {"wing": wing, "room": room},
153
+ "results": hits,
154
+ }
155
+
156
+
157
+ SUPPORTED_LANGUAGES = {
158
+ "python",
159
+ "go",
160
+ "typescript",
161
+ "javascript",
162
+ "rust",
163
+ "java",
164
+ "cpp",
165
+ "c",
166
+ "shell",
167
+ "ruby",
168
+ # web
169
+ "html",
170
+ "css",
171
+ # data / query
172
+ "sql",
173
+ }
174
+
175
+ VALID_SYMBOL_TYPES = {
176
+ "function",
177
+ "class",
178
+ "method",
179
+ "struct",
180
+ "interface",
181
+ }
182
+
183
+
184
+ def code_search(
185
+ palace_path: str,
186
+ query: str,
187
+ language: str = None,
188
+ symbol_name: str = None,
189
+ symbol_type: str = None,
190
+ file_glob: str = None,
191
+ wing: str = None,
192
+ n_results: int = 10,
193
+ ) -> dict:
194
+ """
195
+ Code-optimized semantic search. Returns symbol name, type, language, and
196
+ full file path per hit.
197
+
198
+ Filters applied in two stages:
199
+ 1. LanceDB where clause (pre-query): wing, language, symbol_type.
200
+ 2. Python post-filter: symbol_name (case-insensitive substring),
201
+ file_glob (fnmatch against the stored source_file path).
202
+
203
+ Over-fetches n_results*3 (capped at 150) to compensate for post-filter
204
+ discard, then truncates to n_results.
205
+ """
206
+ if language is not None:
207
+ language = language.lower()
208
+ if language not in SUPPORTED_LANGUAGES:
209
+ return {
210
+ "error": f"Unsupported language: {language!r}",
211
+ "supported_languages": sorted(SUPPORTED_LANGUAGES),
212
+ }
213
+
214
+ if symbol_type is not None:
215
+ symbol_type = symbol_type.lower()
216
+ if symbol_type not in VALID_SYMBOL_TYPES:
217
+ return {
218
+ "error": f"Invalid symbol_type: {symbol_type!r}",
219
+ "valid_symbol_types": sorted(VALID_SYMBOL_TYPES),
220
+ }
221
+
222
+ n_results = max(1, min(50, n_results))
223
+
224
+ try:
225
+ store = open_store(palace_path, create=False)
226
+ except Exception as e:
227
+ logger.error("No palace found at %s: %s", palace_path, e)
228
+ return {
229
+ "error": "No palace found",
230
+ "hint": "Run: mempalace init <dir> && mempalace mine <dir>",
231
+ }
232
+
233
+ # Build LanceDB where clause for pre-query filtering
234
+ conditions = []
235
+ if wing:
236
+ conditions.append({"wing": wing})
237
+ if language:
238
+ conditions.append({"language": language})
239
+ if symbol_type:
240
+ conditions.append({"symbol_type": symbol_type})
241
+
242
+ where = None
243
+ if len(conditions) > 1:
244
+ where = {"$and": conditions}
245
+ elif len(conditions) == 1:
246
+ where = conditions[0]
247
+
248
+ fetch_count = min(n_results * 3, 150)
249
+
250
+ try:
251
+ kwargs = {
252
+ "query_texts": [query],
253
+ "n_results": fetch_count,
254
+ "include": ["documents", "metadatas", "distances"],
255
+ }
256
+ if where:
257
+ kwargs["where"] = where
258
+
259
+ results = store.query(**kwargs)
260
+ except Exception as e:
261
+ return {"error": f"Search error: {e}"}
262
+
263
+ docs = results["documents"][0]
264
+ metas = results["metadatas"][0]
265
+ dists = results["distances"][0]
266
+
267
+ hits = []
268
+ for doc, meta, dist in zip(docs, metas, dists):
269
+ sym_name = meta.get("symbol_name", "") or ""
270
+ src_file = meta.get("source_file", "") or ""
271
+
272
+ if symbol_name and symbol_name.lower() not in sym_name.lower():
273
+ continue
274
+
275
+ if file_glob and not fnmatch.fnmatch(src_file, file_glob):
276
+ continue
277
+
278
+ hits.append(
279
+ {
280
+ "text": doc,
281
+ "wing": meta.get("wing", "unknown"),
282
+ "room": meta.get("room", "unknown"),
283
+ "source_file": src_file,
284
+ "symbol_name": sym_name,
285
+ "symbol_type": meta.get("symbol_type", "") or "",
286
+ "language": meta.get("language", "") or "",
287
+ "line_range": None,
288
+ "similarity": round(1 - dist, 3),
289
+ }
290
+ )
291
+
292
+ if len(hits) >= n_results:
293
+ break
294
+
295
+ return {
296
+ "query": query,
297
+ "filters": {
298
+ "language": language,
299
+ "symbol_name": symbol_name,
300
+ "symbol_type": symbol_type,
301
+ "file_glob": file_glob,
302
+ "wing": wing,
303
+ },
304
+ "results": hits,
305
+ }
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ spellcheck.py — Spell-correct user messages before palace filing.
4
+
5
+ Preserves:
6
+ - Technical terms (words with digits, hyphens, underscores)
7
+ - CamelCase and ALL_CAPS identifiers
8
+ - Known entity names (from EntityRegistry if available)
9
+ - URLs and file paths
10
+ - Words shorter than 3 chars (common abbreviations, pronouns, etc.)
11
+ - Proper nouns already capitalized in context
12
+
13
+ Corrects:
14
+ - Genuine typos in lowercase, flowing text
15
+ - Common fat-finger words (3am → 3am, knoe → know)
16
+
17
+ Usage:
18
+ from mempalace.spellcheck import spellcheck_user_text
19
+ corrected = spellcheck_user_text("lsresdy knoe the question befor")
20
+ # → "already know the question before" (best effort)
21
+ """
22
+
23
+ import re
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+ # Lazy-load autocorrect — not everyone has it installed
28
+ _speller = None
29
+ _autocorrect_available = None
30
+
31
+ # System word list — loaded once, used to skip already-valid words
32
+ _system_words: Optional[set] = None
33
+ _SYSTEM_DICT = Path("/usr/share/dict/words")
34
+
35
+
36
+ def _get_speller():
37
+ global _speller, _autocorrect_available
38
+ if _autocorrect_available is None:
39
+ try:
40
+ from autocorrect import Speller
41
+
42
+ _speller = Speller(lang="en")
43
+ _autocorrect_available = True
44
+ except ImportError:
45
+ _autocorrect_available = False
46
+ return _speller if _autocorrect_available else None
47
+
48
+
49
+ def _get_system_words() -> set:
50
+ """Load /usr/share/dict/words once and cache it."""
51
+ global _system_words
52
+ if _system_words is None:
53
+ if _SYSTEM_DICT.exists():
54
+ with open(_SYSTEM_DICT) as f:
55
+ _system_words = {w.strip().lower() for w in f if w.strip()}
56
+ else:
57
+ _system_words = set()
58
+ return _system_words
59
+
60
+
61
+ # ─────────────────────────────────────────────────────────────────────────────
62
+ # Patterns that mark a token as "don't touch this"
63
+ # ─────────────────────────────────────────────────────────────────────────────
64
+
65
+ # Matches any token with a digit anywhere in it: 3am, bge-large-v1.5, top-10
66
+ _HAS_DIGIT = re.compile(r"\d")
67
+
68
+ # CamelCase: ChromaDB, MemPalace, LongMemEval
69
+ _IS_CAMEL = re.compile(r"[A-Z][a-z]+[A-Z]")
70
+
71
+ # ALL_CAPS or all-caps with underscores: NDCG, R@5, MAX_RESULTS
72
+ _IS_ALLCAPS = re.compile(r"^[A-Z_@#$%^&*()+=\[\]{}|<>?.:/\\]+$")
73
+
74
+ # Technical token: contains hyphens or underscores (bge-large, train_test)
75
+ _IS_TECHNICAL = re.compile(r"[-_]")
76
+
77
+ # URL-like or file-path-like
78
+ _IS_URL = re.compile(r"https?://|www\.|/Users/|~/|\.[a-z]{2,4}$", re.IGNORECASE)
79
+
80
+ # Code fences, markdown, or emoji-heavy
81
+ _IS_CODE_OR_EMOJI = re.compile(r"[`*_#{}[\]\\]")
82
+
83
+ # Very short tokens — skip (I, a, ok, my, etc. — also avoids ambiguous 3-char typos
84
+ # like "kno" which autocorrect resolves as "no" rather than "know")
85
+ _MIN_LENGTH = 4
86
+
87
+
88
+ def _should_skip(token: str, known_names: set) -> bool:
89
+ """Return True if this token should be left as-is."""
90
+ if len(token) < _MIN_LENGTH:
91
+ return True
92
+ if _HAS_DIGIT.search(token):
93
+ return True
94
+ if _IS_CAMEL.search(token):
95
+ return True
96
+ if _IS_ALLCAPS.match(token):
97
+ return True
98
+ if _IS_TECHNICAL.search(token):
99
+ return True
100
+ if _IS_URL.search(token):
101
+ return True
102
+ if _IS_CODE_OR_EMOJI.search(token):
103
+ return True
104
+ # Known proper names (entity registry)
105
+ if token.lower() in known_names:
106
+ return True
107
+ return False
108
+
109
+
110
+ # ─────────────────────────────────────────────────────────────────────────────
111
+ # Load known entity names from registry (optional, best-effort)
112
+ # ─────────────────────────────────────────────────────────────────────────────
113
+
114
+
115
+ def _load_known_names() -> set:
116
+ """Pull all registered names from EntityRegistry. Returns empty set on failure."""
117
+ try:
118
+ from mempalace.entity_registry import EntityRegistry
119
+
120
+ reg = EntityRegistry.load()
121
+ names = set()
122
+ for entity in reg._data.get("entities", {}).values():
123
+ names.add(entity.get("canonical", "").lower())
124
+ for alias in entity.get("aliases", []):
125
+ names.add(alias.lower())
126
+ return names
127
+ except Exception:
128
+ return set()
129
+
130
+
131
+ # ─────────────────────────────────────────────────────────────────────────────
132
+ # Edit distance — used to guard against over-aggressive autocorrect
133
+ # ─────────────────────────────────────────────────────────────────────────────
134
+
135
+
136
+ def _edit_distance(a: str, b: str) -> int:
137
+ """Levenshtein distance between two strings."""
138
+ if a == b:
139
+ return 0
140
+ if not a:
141
+ return len(b)
142
+ if not b:
143
+ return len(a)
144
+ prev = list(range(len(b) + 1))
145
+ for i, ca in enumerate(a, 1):
146
+ curr = [i]
147
+ for j, cb in enumerate(b, 1):
148
+ curr.append(min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (ca != cb)))
149
+ prev = curr
150
+ return prev[-1]
151
+
152
+
153
+ # ─────────────────────────────────────────────────────────────────────────────
154
+ # Core correction
155
+ # ─────────────────────────────────────────────────────────────────────────────
156
+
157
+ # Split on word boundaries but keep punctuation attached to tokens
158
+ _TOKEN_RE = re.compile(r"(\S+)")
159
+
160
+
161
+ def spellcheck_user_text(text: str, known_names: Optional[set] = None) -> str:
162
+ """
163
+ Spell-correct a user message.
164
+
165
+ Args:
166
+ text: Raw user message text.
167
+ known_names: Set of lowercase names/terms to preserve. If None,
168
+ attempts to load from EntityRegistry automatically.
169
+
170
+ Returns:
171
+ Corrected text. Falls back to original if autocorrect not installed.
172
+ """
173
+ speller = _get_speller()
174
+ if speller is None:
175
+ return text # autocorrect not installed — pass through unchanged
176
+
177
+ if known_names is None:
178
+ known_names = _load_known_names()
179
+
180
+ # Process token by token, preserving all whitespace
181
+ sys_words = _get_system_words()
182
+
183
+ def _fix(match):
184
+ token = match.group(0)
185
+ # Strip trailing punctuation for checking, reattach after
186
+ stripped = token.rstrip(".,!?;:'\")")
187
+ punct = token[len(stripped) :]
188
+
189
+ if not stripped or _should_skip(stripped, known_names):
190
+ return token
191
+
192
+ # Only correct lowercase words (capitalized words are likely proper nouns)
193
+ if stripped[0].isupper():
194
+ return token
195
+
196
+ # Skip words that are already valid English — prevents "coherently" → "inherently"
197
+ if stripped.lower() in sys_words:
198
+ return token
199
+
200
+ corrected = speller(stripped)
201
+
202
+ # Guard: don't apply if corrected word is too different from original.
203
+ # Extra safety net for words not in the system dict but also not typos.
204
+ if corrected != stripped:
205
+ dist = _edit_distance(stripped, corrected)
206
+ max_edits = 2 if len(stripped) <= 7 else 3
207
+ if dist > max_edits:
208
+ return token
209
+
210
+ return corrected + punct
211
+
212
+ return _TOKEN_RE.sub(_fix, text)
213
+
214
+
215
+ def spellcheck_transcript_line(line: str) -> str:
216
+ """
217
+ Spell-correct a single transcript line.
218
+ Only touches lines that start with '>' (user turns).
219
+ Assistant turns are never modified.
220
+ """
221
+ stripped = line.lstrip()
222
+ if not stripped.startswith(">"):
223
+ return line
224
+
225
+ # '> actual message here'
226
+ prefix_len = len(line) - len(stripped) + 2 # '> '
227
+ message = line[prefix_len:]
228
+ if not message.strip():
229
+ return line
230
+
231
+ corrected = spellcheck_user_text(message)
232
+ return line[:prefix_len] + corrected
233
+
234
+
235
+ def spellcheck_transcript(content: str) -> str:
236
+ """
237
+ Spell-correct all user turns in a full transcript.
238
+ Only lines starting with '>' are touched.
239
+ """
240
+ lines = content.split("\n")
241
+ return "\n".join(spellcheck_transcript_line(line) for line in lines)
242
+
243
+
244
+ # ─────────────────────────────────────────────────────────────────────────────
245
+ # Quick test
246
+ # ─────────────────────────────────────────────────────────────────────────────
247
+
248
+ if __name__ == "__main__":
249
+ test_cases = [
250
+ "lsresdy knoe the question befor",
251
+ "isn't there meny diferent benchmarks tesing questions?",
252
+ "also can you pleese spell chekc my questions befroe storing",
253
+ "it's realy hard for me to writte coherently at 3am",
254
+ "Mempalace cant be fine-tunned if you alredy kno the question",
255
+ # Should NOT change these:
256
+ "ChromaDB bge-large-en-v1.5 NDCG@10 R@5",
257
+ "Riley picked up Sam from school",
258
+ "hybrid_v4 top-k=50 longmemeval_bench.py",
259
+ ]
260
+
261
+ print("Spell-check test\n" + "=" * 50)
262
+ for msg in test_cases:
263
+ result = spellcheck_user_text(msg, known_names={"riley", "sam", "mempalace"})
264
+ changed = " ← CHANGED" if result != msg else ""
265
+ print(f"\nIN: {msg}")
266
+ if result != msg:
267
+ print(f"OUT: {result}{changed}")
268
+ else:
269
+ print("OUT: (unchanged)")