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/storage.py ADDED
@@ -0,0 +1,807 @@
1
+ """
2
+ storage.py — Pluggable storage backend for MemPalace
3
+ =====================================================
4
+
5
+ Provides a unified interface for drawer storage, abstracting away
6
+ the underlying vector database. Ships with LanceDB (default, crash-safe)
7
+ and ChromaDB (legacy) backends.
8
+
9
+ Usage:
10
+ from mempalace.storage import open_store
11
+
12
+ store = open_store("/path/to/palace") # auto-detect or create LanceDB
13
+ store = open_store("/path/to/palace", "lance") # explicit backend
14
+ store = open_store("/path/to/palace", "chroma") # legacy ChromaDB
15
+
16
+ The store object exposes a collection-like API that all MemPalace code
17
+ uses instead of calling ChromaDB/LanceDB directly.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import os
24
+ from abc import ABC, abstractmethod
25
+ from pathlib import Path
26
+ from typing import Any, Dict, List, Optional
27
+
28
+ logger = logging.getLogger("mempalace")
29
+
30
+
31
+ # ─── Abstract interface ────────────────────────────────────────────────────────
32
+
33
+
34
+ class DrawerStore(ABC):
35
+ """Minimal interface that every storage backend must implement."""
36
+
37
+ @abstractmethod
38
+ def count(self) -> int:
39
+ """Total number of drawers."""
40
+
41
+ @abstractmethod
42
+ def add(
43
+ self,
44
+ ids: List[str],
45
+ documents: List[str],
46
+ metadatas: List[Dict[str, Any]],
47
+ ) -> None:
48
+ """Insert new drawers. Raises on duplicate IDs."""
49
+
50
+ @abstractmethod
51
+ def upsert(
52
+ self,
53
+ ids: List[str],
54
+ documents: List[str],
55
+ metadatas: List[Dict[str, Any]],
56
+ ) -> None:
57
+ """Insert or update drawers."""
58
+
59
+ @abstractmethod
60
+ def get(
61
+ self,
62
+ ids: Optional[List[str]] = None,
63
+ where: Optional[Dict[str, Any]] = None,
64
+ include: Optional[List[str]] = None,
65
+ limit: int = 10000,
66
+ offset: int = 0,
67
+ ) -> Dict[str, List]:
68
+ """
69
+ Retrieve drawers by ID or metadata filter.
70
+
71
+ Returns dict with keys: ids, documents, metadatas
72
+ (each key present only if requested via `include` or always for ids).
73
+ """
74
+
75
+ @abstractmethod
76
+ def query(
77
+ self,
78
+ query_texts: List[str],
79
+ n_results: int = 5,
80
+ where: Optional[Dict[str, Any]] = None,
81
+ include: Optional[List[str]] = None,
82
+ ) -> Dict[str, List[List]]:
83
+ """
84
+ Semantic search. Returns nested lists (one per query text):
85
+ ids: [[id, ...]]
86
+ documents: [[doc, ...]]
87
+ metadatas: [[meta, ...]]
88
+ distances: [[dist, ...]]
89
+ """
90
+
91
+ @abstractmethod
92
+ def delete(self, ids: List[str]) -> None:
93
+ """Delete drawers by ID."""
94
+
95
+ @abstractmethod
96
+ def delete_wing(self, wing: str) -> int:
97
+ """Delete all drawers in a wing. Returns the count of deleted drawers."""
98
+
99
+ @abstractmethod
100
+ def count_by(self, column: str) -> Dict[str, int]:
101
+ """Return {value: count} for every distinct value in *column*."""
102
+
103
+ @abstractmethod
104
+ def count_by_pair(self, col_a: str, col_b: str) -> Dict[str, Dict[str, int]]:
105
+ """Return {a_value: {b_value: count}} for every (col_a, col_b) pair."""
106
+
107
+ def get_source_files(self, wing: str) -> Optional[set]:
108
+ """Return a set of all source_file values for a wing, or None if unsupported.
109
+
110
+ Returning None signals the caller to fall back to per-file file_already_mined()
111
+ checks. The base implementation returns None — override in backends that support
112
+ efficient bulk retrieval (LanceDB).
113
+ """
114
+ return None
115
+
116
+ def delete_by_source_file(self, source_file: str, wing: str) -> int:
117
+ """Delete all drawers for a given source_file within a wing. Returns deleted count."""
118
+ return 0
119
+
120
+ def get_source_file_hashes(self, wing: str) -> dict:
121
+ """Return {source_file: source_hash} for all drawers in wing.
122
+
123
+ Returns an empty dict if unsupported. Override in LanceDB backend.
124
+ """
125
+ return {}
126
+
127
+ def iter_all(self, where=None, batch_size=1000, include_vectors=False):
128
+ """Yield batches of drawers as lists of dicts. Streams without loading full table.
129
+
130
+ Each batch is a list of dicts with keys: id, text, and all metadata fields.
131
+ If include_vectors is True, a 'vector' key with the float list is also present.
132
+ """
133
+ raise NotImplementedError
134
+
135
+ def optimize(self) -> None:
136
+ """Merge Lance fragments and prune old versions. No-op on unsupported backends."""
137
+
138
+ def warmup(self) -> None:
139
+ """Force embedding model init so HuggingFace output appears before batch processing."""
140
+
141
+
142
+ # ─── LanceDB backend ──────────────────────────────────────────────────────────
143
+
144
+ _LANCE_TABLE = "mempalace_drawers"
145
+ DEFAULT_EMBED_MODEL = "all-MiniLM-L6-v2" # same model ChromaDB uses by default
146
+
147
+ # Single source of truth for metadata fields.
148
+ # Adding a new metadata column? Append ONE tuple here.
149
+ # Format: (field_name, arrow_type_tag, default_value)
150
+ # arrow_type_tag: "string" | "int32" | "float32"
151
+ _META_FIELD_SPEC: tuple = (
152
+ # Core metadata
153
+ ("wing", "string", ""),
154
+ ("room", "string", ""),
155
+ ("source_file", "string", ""),
156
+ ("chunk_index", "int32", 0),
157
+ ("added_by", "string", ""),
158
+ ("filed_at", "string", ""),
159
+ # Diary/graph fields
160
+ ("hall", "string", ""),
161
+ ("topic", "string", ""),
162
+ ("type", "string", ""),
163
+ ("agent", "string", ""),
164
+ ("date", "string", ""),
165
+ # Convo mining
166
+ ("ingest_mode", "string", ""),
167
+ ("extract_mode", "string", ""),
168
+ # Compression
169
+ ("compression_ratio", "float32", 0.0),
170
+ ("original_tokens", "int32", 0),
171
+ # Language detection
172
+ ("language", "string", ""),
173
+ # Symbol metadata
174
+ ("symbol_name", "string", ""),
175
+ ("symbol_type", "string", ""),
176
+ # Provenance (CODE-INCREMENTAL)
177
+ ("source_hash", "string", ""),
178
+ ("extractor_version", "string", ""),
179
+ ("chunker_strategy", "string", ""),
180
+ )
181
+
182
+ _META_KEYS: frozenset = frozenset(name for name, _, _ in _META_FIELD_SPEC)
183
+ _META_DEFAULTS: dict = {name: default for name, _, default in _META_FIELD_SPEC}
184
+
185
+
186
+ def _target_drawer_schema(dim: int):
187
+ """Return the canonical PyArrow schema for the drawers table.
188
+
189
+ Single source of truth — used by both the create-table and migrate-existing paths in
190
+ ``LanceStore._open_or_create()``. Any new column additions must be made here only.
191
+ """
192
+ import pyarrow as pa
193
+
194
+ _ARROW_TYPES = {"string": pa.string(), "int32": pa.int32(), "float32": pa.float32()}
195
+ fields = [
196
+ pa.field("id", pa.string()),
197
+ pa.field("text", pa.string()),
198
+ pa.field("vector", pa.list_(pa.float32(), dim)),
199
+ ]
200
+ for name, type_tag, _ in _META_FIELD_SPEC:
201
+ fields.append(pa.field(name, _ARROW_TYPES[type_tag]))
202
+ return pa.schema(fields)
203
+
204
+
205
+ def _sql_default_for_arrow_type(arrow_type) -> str:
206
+ """Map a PyArrow scalar type to its SQL literal default for ``add_columns()``.
207
+
208
+ Raises ``RuntimeError`` for unsupported types. In particular, ``pa.list_(...)``
209
+ (the vector column type) is not supported — the vector column must already exist in
210
+ the base schema; if it is missing the table is corrupt or unsupported.
211
+ """
212
+ import pyarrow as pa
213
+
214
+ if pa.types.is_string(arrow_type) or pa.types.is_large_string(arrow_type):
215
+ return "CAST('' AS string)"
216
+ if pa.types.is_int32(arrow_type):
217
+ return "0"
218
+ if pa.types.is_int64(arrow_type):
219
+ return "0"
220
+ if pa.types.is_float32(arrow_type):
221
+ return "0.0"
222
+ raise RuntimeError(
223
+ f"No SQL default defined for Arrow type {arrow_type!r}. "
224
+ "The vector column (list type) must already exist in the base schema — "
225
+ "if it is missing the table is corrupt or unsupported."
226
+ )
227
+
228
+
229
+ class LanceStore(DrawerStore):
230
+ """
231
+ Crash-safe drawer storage using LanceDB.
232
+
233
+ Data is stored in Lance columnar format with proper transactions —
234
+ an interrupted write does not corrupt the entire dataset.
235
+ """
236
+
237
+ def __init__(self, palace_path: str, create: bool = True, embed_model: Optional[str] = None):
238
+ import lancedb
239
+
240
+ self._model_name = embed_model or DEFAULT_EMBED_MODEL
241
+ self._db = lancedb.connect(os.path.join(palace_path, "lance"))
242
+ self._embedder = self._get_embedder()
243
+ self._table = self._open_or_create(create)
244
+
245
+ def _get_embedder(self):
246
+ """Load the sentence-transformers embedding model."""
247
+ from lancedb.embeddings import get_registry
248
+
249
+ return get_registry().get("sentence-transformers").create(name=self._model_name)
250
+
251
+ def _open_or_create(self, create: bool):
252
+ """Open existing table or create a new one, migrating schema if needed."""
253
+ dim = self._embedder.ndims()
254
+ target = _target_drawer_schema(dim)
255
+
256
+ # Try to open existing table first
257
+ _existing_table = None
258
+ try:
259
+ _existing_table = self._db.open_table(_LANCE_TABLE)
260
+ except Exception as e:
261
+ logger.debug("Table %r not found, will create: %s", _LANCE_TABLE, e)
262
+
263
+ if _existing_table is not None:
264
+ existing_names = set(_existing_table.schema.names)
265
+ missing_fields = [f for f in target if f.name not in existing_names]
266
+ if missing_fields:
267
+ cols_to_add = {f.name: _sql_default_for_arrow_type(f.type) for f in missing_fields}
268
+ logger.info(
269
+ "Migrating palace schema: adding columns %s",
270
+ sorted(cols_to_add),
271
+ )
272
+ _existing_table.add_columns(cols_to_add)
273
+ # Reload the handle so its schema reflects the updated on-disk table
274
+ _existing_table = self._db.open_table(_LANCE_TABLE)
275
+ reloaded_names = set(_existing_table.schema.names)
276
+ if not set(target.names) <= reloaded_names:
277
+ still_missing = set(target.names) - reloaded_names
278
+ raise RuntimeError(
279
+ f"Post-migration assertion failed — still missing columns: {still_missing}"
280
+ )
281
+ return _existing_table
282
+
283
+ if not create:
284
+ return None
285
+
286
+ return self._db.create_table(_LANCE_TABLE, schema=target)
287
+
288
+ def _embed(self, texts: List[str]) -> List[List[float]]:
289
+ """Generate embeddings for a list of texts."""
290
+ return self._embedder.compute_source_embeddings(texts)
291
+
292
+ @staticmethod
293
+ def _meta_defaults(meta: Dict[str, Any]) -> Dict[str, Any]:
294
+ """Fill in default values for metadata fields; drop unknown keys."""
295
+ # Start with defaults, overlay known keys from meta, drop unknowns
296
+ merged = dict(_META_DEFAULTS)
297
+ for k, v in meta.items():
298
+ if k in _META_DEFAULTS:
299
+ merged[k] = v
300
+ # Ensure numeric fields have correct types
301
+ merged["chunk_index"] = int(merged.get("chunk_index", 0))
302
+ merged["compression_ratio"] = float(merged.get("compression_ratio", 0.0))
303
+ merged["original_tokens"] = int(merged.get("original_tokens", 0))
304
+ return merged
305
+
306
+ def count(self) -> int:
307
+ if self._table is None:
308
+ return 0
309
+ return self._table.count_rows()
310
+
311
+ def add(self, ids, documents, metadatas):
312
+ if self._table is None:
313
+ raise RuntimeError("Table does not exist and create=False")
314
+
315
+ vectors = self._embed(documents)
316
+ rows = []
317
+ for id_, doc, meta, vec in zip(ids, documents, metadatas, vectors):
318
+ row = self._meta_defaults(meta)
319
+ row["id"] = id_
320
+ row["text"] = doc
321
+ row["vector"] = vec
322
+ rows.append(row)
323
+
324
+ self._table.add(rows)
325
+
326
+ def upsert(self, ids, documents, metadatas):
327
+ # LanceDB merge_insert for upsert
328
+ if self._table is None:
329
+ raise RuntimeError("Table does not exist and create=False")
330
+
331
+ vectors = self._embed(documents)
332
+ rows = []
333
+ for id_, doc, meta, vec in zip(ids, documents, metadatas, vectors):
334
+ row = self._meta_defaults(meta)
335
+ row["id"] = id_
336
+ row["text"] = doc
337
+ row["vector"] = vec
338
+ rows.append(row)
339
+
340
+ self._table.merge_insert(
341
+ "id"
342
+ ).when_matched_update_all().when_not_matched_insert_all().execute(rows)
343
+
344
+ def get(self, ids=None, where=None, include=None, limit=10000, offset=0):
345
+ if self._table is None:
346
+ return {"ids": [], "documents": [], "metadatas": []}
347
+
348
+ include = include or []
349
+
350
+ if ids is not None:
351
+ if not ids:
352
+ return {"ids": [], "documents": [], "metadatas": []}
353
+ # Fetch by explicit IDs
354
+ id_list = ", ".join(f"'{id_}'" for id_ in ids)
355
+ try:
356
+ results = self._table.search().where(f"id IN ({id_list})").limit(len(ids)).to_list()
357
+ except Exception:
358
+ results = []
359
+ elif where is not None:
360
+ sql = self._where_to_sql(where)
361
+ try:
362
+ results = self._table.search().where(sql).limit(limit).offset(offset).to_list()
363
+ except Exception:
364
+ results = []
365
+ else:
366
+ try:
367
+ results = self._table.search().limit(limit).offset(offset).to_list()
368
+ except Exception:
369
+ results = []
370
+
371
+ out_ids = [r["id"] for r in results]
372
+ out: Dict[str, List] = {"ids": out_ids}
373
+
374
+ if "documents" in include:
375
+ out["documents"] = [r["text"] for r in results]
376
+ if "metadatas" in include:
377
+ out["metadatas"] = [{k: r.get(k, "") for k in _META_KEYS} for r in results]
378
+
379
+ return out
380
+
381
+ def query(self, query_texts, n_results=5, where=None, include=None):
382
+ if self._table is None:
383
+ return {"ids": [[]], "documents": [[]], "metadatas": [[]], "distances": [[]]}
384
+
385
+ include = include or []
386
+ all_ids, all_docs, all_metas, all_dists = [], [], [], []
387
+
388
+ for text in query_texts:
389
+ vec = self._embed([text])[0]
390
+ q = self._table.search(vec).limit(n_results)
391
+ if where:
392
+ sql = self._where_to_sql(where)
393
+ q = q.where(sql)
394
+
395
+ try:
396
+ results = q.to_list()
397
+ except Exception:
398
+ results = []
399
+
400
+ ids = [r["id"] for r in results]
401
+ docs = [r["text"] for r in results]
402
+ metas = []
403
+ dists = []
404
+
405
+ for r in results:
406
+ metas.append({k: r.get(k, "") for k in _META_KEYS})
407
+ # LanceDB returns _distance (L2 distance)
408
+ dists.append(r.get("_distance", 0.0))
409
+
410
+ all_ids.append(ids)
411
+ all_docs.append(docs)
412
+ all_metas.append(metas)
413
+ all_dists.append(dists)
414
+
415
+ out: Dict[str, List[List]] = {"ids": all_ids}
416
+ if "documents" in include:
417
+ out["documents"] = all_docs
418
+ if "metadatas" in include:
419
+ out["metadatas"] = all_metas
420
+ if "distances" in include:
421
+ out["distances"] = all_dists
422
+
423
+ return out
424
+
425
+ def delete(self, ids):
426
+ if self._table is None:
427
+ return
428
+ if not ids:
429
+ return
430
+ id_list = ", ".join(f"'{id_}'" for id_ in ids)
431
+ self._table.delete(f"id IN ({id_list})")
432
+
433
+ def delete_wing(self, wing: str) -> int:
434
+ if self._table is None:
435
+ return 0
436
+ escaped = wing.replace("'", "''")
437
+ count = self._table.count_rows(f"wing = '{escaped}'")
438
+ if count == 0:
439
+ return 0
440
+ self._table.delete(f"wing = '{escaped}'")
441
+ return count
442
+
443
+ def delete_by_source_file(self, source_file: str, wing: str) -> int:
444
+ """Delete all drawers for a given source_file within a wing."""
445
+ if self._table is None:
446
+ return 0
447
+ escaped_file = source_file.replace("'", "''")
448
+ escaped_wing = wing.replace("'", "''")
449
+ count = self._table.count_rows(
450
+ f"source_file = '{escaped_file}' AND wing = '{escaped_wing}'"
451
+ )
452
+ if count == 0:
453
+ return 0
454
+ self._table.delete(f"source_file = '{escaped_file}' AND wing = '{escaped_wing}'")
455
+ return count
456
+
457
+ def get_source_file_hashes(self, wing: str) -> dict:
458
+ """Return {source_file: source_hash} for all drawers in wing.
459
+
460
+ Uses PyArrow column projection — no vector scan.
461
+ Deduplicates by taking the first hash per source_file.
462
+ Returns an empty dict if the table is empty or column is absent.
463
+ """
464
+ if self._table is None:
465
+ return {}
466
+ import pyarrow.compute as pc
467
+
468
+ try:
469
+ arrow_tbl = self._table.to_arrow().select(["source_file", "source_hash", "wing"])
470
+ except Exception:
471
+ # Table predates migration (source_hash column missing) — return empty
472
+ return {}
473
+ filtered = arrow_tbl.filter(pc.field("wing") == wing)
474
+ result: dict = {}
475
+ for sf, sh in zip(
476
+ filtered.column("source_file").to_pylist(),
477
+ filtered.column("source_hash").to_pylist(),
478
+ ):
479
+ if sf not in result:
480
+ result[sf] = sh
481
+ return result
482
+
483
+ def count_by(self, column: str) -> Dict[str, int]:
484
+ if self._table is None:
485
+ return {}
486
+ arrow_tbl = self._table.to_arrow().select([column])
487
+ result = arrow_tbl.group_by(column).aggregate([(column, "count")])
488
+ d = result.to_pydict()
489
+ return dict(zip(d[column], d[f"{column}_count"]))
490
+
491
+ def count_by_pair(self, col_a: str, col_b: str) -> Dict[str, Dict[str, int]]:
492
+ if self._table is None:
493
+ return {}
494
+ arrow_tbl = self._table.to_arrow().select([col_a, col_b])
495
+ result = arrow_tbl.group_by([col_a, col_b]).aggregate([(col_b, "count")])
496
+ d = result.to_pydict()
497
+ out: Dict[str, Dict[str, int]] = {}
498
+ for a, b, c in zip(d[col_a], d[col_b], d[f"{col_b}_count"]):
499
+ out.setdefault(a, {})[b] = c
500
+ return out
501
+
502
+ def get_source_files(self, wing: str) -> Optional[set]:
503
+ """Return the set of all source_file values already stored for *wing*.
504
+
505
+ Uses PyArrow column projection and filter — no vector scan required.
506
+ Returns an empty set if the table is empty or doesn't exist.
507
+ """
508
+ if self._table is None:
509
+ return set()
510
+ import pyarrow.compute as pc
511
+
512
+ arrow_tbl = self._table.to_arrow().select(["source_file", "wing"])
513
+ filtered = arrow_tbl.filter(pc.field("wing") == wing)
514
+ return set(filtered.column("source_file").to_pylist())
515
+
516
+ def iter_all(self, where=None, batch_size=1000, include_vectors=False):
517
+ """Yield batches of drawers as lists of dicts using PyArrow column projection.
518
+
519
+ Loads all non-vector columns via to_arrow() (no vector scan), applies an
520
+ optional PyArrow-level filter, then yields one list of dicts per batch.
521
+ """
522
+ if self._table is None:
523
+ return
524
+
525
+ meta_columns = ["id", "text"] + [name for name, _, _ in _META_FIELD_SPEC]
526
+ columns = meta_columns + (["vector"] if include_vectors else [])
527
+ # Only include columns that actually exist in the schema
528
+ existing = set(self._table.schema.names)
529
+ columns = [c for c in columns if c in existing]
530
+
531
+ try:
532
+ arrow_tbl = self._table.to_arrow().select(columns)
533
+ except Exception:
534
+ return
535
+
536
+ if where:
537
+ mask = self._where_to_arrow_mask(arrow_tbl, where)
538
+ if mask is not None:
539
+ arrow_tbl = arrow_tbl.filter(mask)
540
+
541
+ for batch in arrow_tbl.to_batches(max_chunksize=batch_size):
542
+ rows = batch.to_pydict()
543
+ n = len(rows["id"])
544
+ result = []
545
+ for i in range(n):
546
+ row = {col: rows[col][i] for col in rows}
547
+ result.append(row)
548
+ yield result
549
+
550
+ @staticmethod
551
+ def _where_to_arrow_mask(arrow_tbl, where):
552
+ """Recursively convert a where dict to a PyArrow boolean array for filtering.
553
+
554
+ Mirrors _where_to_sql semantics but operates on an in-memory Arrow table.
555
+ Supports $and, $or, and simple {field: value} equality clauses.
556
+ """
557
+ import pyarrow.compute as pc
558
+
559
+ if "$and" in where:
560
+ masks = [LanceStore._where_to_arrow_mask(arrow_tbl, sub) for sub in where["$and"]]
561
+ masks = [m for m in masks if m is not None]
562
+ if not masks:
563
+ return None
564
+ result = masks[0]
565
+ for m in masks[1:]:
566
+ result = pc.and_(result, m)
567
+ return result
568
+
569
+ if "$or" in where:
570
+ masks = [LanceStore._where_to_arrow_mask(arrow_tbl, sub) for sub in where["$or"]]
571
+ masks = [m for m in masks if m is not None]
572
+ if not masks:
573
+ return None
574
+ result = masks[0]
575
+ for m in masks[1:]:
576
+ result = pc.or_(result, m)
577
+ return result
578
+
579
+ parts = []
580
+ for key, value in where.items():
581
+ if key not in arrow_tbl.schema.names:
582
+ continue
583
+ col = arrow_tbl.column(key)
584
+ if isinstance(value, str):
585
+ parts.append(pc.equal(col, value))
586
+ elif isinstance(value, (int, float)):
587
+ parts.append(pc.equal(col, value))
588
+ if not parts:
589
+ return None
590
+ result = parts[0]
591
+ for p in parts[1:]:
592
+ result = pc.and_(result, p)
593
+ return result
594
+
595
+ def optimize(self) -> None:
596
+ """Merge Lance fragments and prune old versions (post-mining compaction)."""
597
+ if self._table is not None:
598
+ self._table.optimize()
599
+
600
+ def warmup(self) -> None:
601
+ """Embed a throwaway string to force model loading before batch processing."""
602
+ self._embed(["warmup"])
603
+
604
+ @staticmethod
605
+ def _where_to_sql(where: Dict[str, Any]) -> str:
606
+ """
607
+ Convert ChromaDB-style where filters to SQL WHERE clauses.
608
+
609
+ Supports:
610
+ {"wing": "foo"} → wing = 'foo'
611
+ {"$and": [{"wing": "a"}, {"room": "b"}]} → (wing = 'a') AND (room = 'b')
612
+ {"wing": {"$in": ["a", "b"]}} → wing IN ('a', 'b')
613
+ {"wing": {"$in": []}} → 1 = 0
614
+ {"wing": {"$in": ["a"]}} → wing = 'a' (single-element optimisation)
615
+ """
616
+ if "$and" in where:
617
+ clauses = [LanceStore._where_to_sql(sub) for sub in where["$and"]]
618
+ return " AND ".join(f"({c})" for c in clauses)
619
+ if "$or" in where:
620
+ clauses = [LanceStore._where_to_sql(sub) for sub in where["$or"]]
621
+ return " OR ".join(f"({c})" for c in clauses)
622
+
623
+ parts = []
624
+ for key, value in where.items():
625
+ if isinstance(value, str):
626
+ escaped = value.replace("'", "''")
627
+ parts.append(f"{key} = '{escaped}'")
628
+ elif isinstance(value, (int, float)):
629
+ parts.append(f"{key} = {value}")
630
+ elif isinstance(value, dict):
631
+ # Operator filters: {"field": {"$eq": val}} etc.
632
+ for op, val in value.items():
633
+ if op == "$eq":
634
+ if isinstance(val, (int, float)):
635
+ parts.append(f"{key} = {val}")
636
+ else:
637
+ escaped = str(val).replace("'", "''")
638
+ parts.append(f"{key} = '{escaped}'")
639
+ elif op == "$ne":
640
+ if isinstance(val, (int, float)):
641
+ parts.append(f"{key} != {val}")
642
+ else:
643
+ escaped = str(val).replace("'", "''")
644
+ parts.append(f"{key} != '{escaped}'")
645
+ elif op in ("$gt", "$gte", "$lt", "$lte"):
646
+ sql_op = {"$gt": ">", "$gte": ">=", "$lt": "<", "$lte": "<="}[op]
647
+ parts.append(f"{key} {sql_op} {val}")
648
+ elif op == "$in":
649
+ if not val:
650
+ parts.append("1 = 0")
651
+ elif len(val) == 1:
652
+ # Single-element optimisation — reuse $eq escaping logic
653
+ v = val[0]
654
+ if isinstance(v, (int, float)):
655
+ parts.append(f"{key} = {v}")
656
+ else:
657
+ escaped = str(v).replace("'", "''")
658
+ parts.append(f"{key} = '{escaped}'")
659
+ else:
660
+ first = val[0]
661
+ if isinstance(first, str):
662
+ if not all(isinstance(v, str) for v in val):
663
+ raise ValueError(
664
+ f"$in list for '{key}' must be all str or all numeric, not mixed"
665
+ )
666
+ items = ", ".join(
667
+ f"'{str(v).replace(chr(39), chr(39) * 2)}'" for v in val
668
+ )
669
+ elif isinstance(first, (int, float)):
670
+ if not all(isinstance(v, (int, float)) for v in val):
671
+ raise ValueError(
672
+ f"$in list for '{key}' must be all str or all numeric, not mixed"
673
+ )
674
+ items = ", ".join(str(v) for v in val)
675
+ else:
676
+ raise ValueError(
677
+ f"$in list for '{key}' contains unsupported type: {type(first)}"
678
+ )
679
+ parts.append(f"{key} IN ({items})")
680
+ else:
681
+ parts.append(f"{key} = '{value}'")
682
+
683
+ return " AND ".join(parts) if parts else "1=1"
684
+
685
+
686
+ # ─── ChromaDB backend (legacy) ────────────────────────────────────────────────
687
+
688
+
689
+ class ChromaStore(DrawerStore):
690
+ """
691
+ Legacy ChromaDB-backed storage. Kept for migration and compatibility.
692
+
693
+ WARNING: ChromaDB PersistentClient uses HNSW with no WAL.
694
+ An interrupted write can corrupt the entire collection.
695
+ """
696
+
697
+ def __init__(
698
+ self, palace_path: str, collection_name: str = "mempalace_drawers", create: bool = True
699
+ ):
700
+ import chromadb
701
+
702
+ self._client = chromadb.PersistentClient(path=palace_path)
703
+ if create:
704
+ self._col = self._client.get_or_create_collection(collection_name)
705
+ else:
706
+ try:
707
+ self._col = self._client.get_collection(collection_name)
708
+ except Exception:
709
+ self._col = None
710
+
711
+ def count(self) -> int:
712
+ if self._col is None:
713
+ return 0
714
+ return self._col.count()
715
+
716
+ def add(self, ids, documents, metadatas):
717
+ self._col.add(ids=ids, documents=documents, metadatas=metadatas)
718
+
719
+ def upsert(self, ids, documents, metadatas):
720
+ self._col.upsert(ids=ids, documents=documents, metadatas=metadatas)
721
+
722
+ def get(self, ids=None, where=None, include=None, limit=10000, offset=0):
723
+ kwargs: Dict[str, Any] = {}
724
+ if ids is not None:
725
+ kwargs["ids"] = ids
726
+ if where:
727
+ kwargs["where"] = where
728
+ if include:
729
+ kwargs["include"] = include
730
+ kwargs["limit"] = limit
731
+ if offset > 0:
732
+ kwargs["offset"] = offset
733
+ return self._col.get(**kwargs)
734
+
735
+ def query(self, query_texts, n_results=5, where=None, include=None):
736
+ kwargs: Dict[str, Any] = {
737
+ "query_texts": query_texts,
738
+ "n_results": n_results,
739
+ }
740
+ if where:
741
+ kwargs["where"] = where
742
+ if include:
743
+ kwargs["include"] = include
744
+ return self._col.query(**kwargs)
745
+
746
+ def delete(self, ids):
747
+ self._col.delete(ids=ids)
748
+
749
+ def delete_wing(self, wing: str) -> int:
750
+ if self._col is None:
751
+ return 0
752
+ results = self._col.get(where={"wing": wing})
753
+ ids = results.get("ids", [])
754
+ if not ids:
755
+ return 0
756
+ self._col.delete(ids=ids)
757
+ return len(ids)
758
+
759
+ def count_by(self, column: str) -> Dict[str, int]:
760
+ raise NotImplementedError("count_by not supported on deprecated ChromaStore")
761
+
762
+ def count_by_pair(self, col_a: str, col_b: str) -> Dict[str, Dict[str, int]]:
763
+ raise NotImplementedError("count_by_pair not supported on deprecated ChromaStore")
764
+
765
+
766
+ # ─── Store factory ─────────────────────────────────────────────────────────────
767
+
768
+
769
+ def _detect_backend(palace_path: str) -> str:
770
+ """Auto-detect which backend a palace uses based on directory contents."""
771
+ p = Path(palace_path)
772
+ if (p / "lance").exists():
773
+ return "lance"
774
+ if (p / "chroma.sqlite3").exists():
775
+ return "chroma"
776
+ # New palace — default to LanceDB
777
+ return "lance"
778
+
779
+
780
+ def open_store(
781
+ palace_path: str,
782
+ backend: Optional[str] = None,
783
+ collection_name: str = "mempalace_drawers",
784
+ create: bool = True,
785
+ embed_model: Optional[str] = None,
786
+ ) -> DrawerStore:
787
+ """
788
+ Open a drawer store. Auto-detects backend if not specified.
789
+
790
+ Args:
791
+ palace_path: Path to the palace data directory.
792
+ backend: "lance" or "chroma". None = auto-detect.
793
+ collection_name: Collection name (ChromaDB only).
794
+ create: Create table/collection if it doesn't exist.
795
+ embed_model: Embedding model name (LanceDB only). None = default.
796
+ """
797
+ os.makedirs(palace_path, exist_ok=True)
798
+
799
+ if backend is None:
800
+ backend = _detect_backend(palace_path)
801
+
802
+ if backend == "lance":
803
+ return LanceStore(palace_path, create=create, embed_model=embed_model)
804
+ elif backend == "chroma":
805
+ return ChromaStore(palace_path, collection_name=collection_name, create=create)
806
+ else:
807
+ raise ValueError(f"Unknown storage backend: {backend!r}. Use 'lance' or 'chroma'.")