sup-mem 0.7.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.
sup_mem/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """sup-mem: a self-hosted, pluggable global memory layer for Claude.
2
+
3
+ Two front-doors (an automatic hook + explicit MCP tools) over one pluggable backend (I1).
4
+
5
+ Keep this module import-light: the per-prompt hook imports from the package on the hot path
6
+ (I2), so we import only stdlib-backed modules here (``config``/``models``) and NEVER touch
7
+ ``backends`` or ``embedding`` at import time.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from importlib.metadata import PackageNotFoundError, version
13
+
14
+ from sup_mem.config import Config, load_config
15
+ from sup_mem.models import Hit, MemoryRecord
16
+
17
+ try:
18
+ __version__ = version("sup-mem")
19
+ except PackageNotFoundError: # running from a source tree without an install
20
+ __version__ = "0.0.0"
21
+
22
+ __all__ = ["Config", "Hit", "MemoryRecord", "__version__", "load_config"]
sup_mem/archival.py ADDED
@@ -0,0 +1,165 @@
1
+ """Archival policy engine (docs/PHASE9-ARCHIVAL.md) — the three regimes of A2.
2
+
3
+ 1. Steady state: structurally-outdated versions (superseded past the window) and stale
4
+ quarantined memories move to the cold tier — evidence, never age (A1).
5
+ 2. Main pressure: over ``archival.main_max_mb``, decay candidates move most-useless-first
6
+ (ledger-ranked) until the cap is met — or candidates run out, in which case we STOP and
7
+ say so rather than archive evidently-useful memories.
8
+ 3. Archive pressure: over ``archival.archive_max_mb``, the oldest archived rows are deleted
9
+ forever, FIFO, chain-audited.
10
+
11
+ The mechanics live on the sqlite backend; this module only decides *what* and *in which
12
+ order*. Backends without archival support are reported as such, never guessed at.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from datetime import UTC, datetime, timedelta
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ if TYPE_CHECKING:
21
+ from sup_mem.config import Config
22
+
23
+ _MB = 1024 * 1024
24
+
25
+
26
+ def _cutoff(days: int) -> str:
27
+ return (datetime.now(UTC) - timedelta(days=days)).isoformat()
28
+
29
+
30
+ def _stale_quarantined_ids(config: Config) -> list[str]:
31
+ """Quarantined per the L3 rule AND unreferenced past the quarantine window."""
32
+ if not config.ledger_db_path.exists():
33
+ return []
34
+ from sup_mem.ledger import Ledger
35
+
36
+ threshold = _cutoff(config.archival.quarantined_after_days)
37
+ out: list[str] = []
38
+ with Ledger(config.ledger_db_path) as ledger:
39
+ for s in ledger.all_stats():
40
+ quarantined = (
41
+ s["contradicted"] >= config.ledger.quarantine_contradictions
42
+ and s["contradicted"] > s["referenced"]
43
+ )
44
+ stale = not s["last_referenced"] or s["last_referenced"] < threshold
45
+ if quarantined and stale:
46
+ out.append(str(s["memory_id"]))
47
+ return out
48
+
49
+
50
+ def _rank_by_uselessness(candidates: list[dict[str, Any]], config: Config) -> list[dict[str, Any]]:
51
+ """Most-useless first (A1): evidenced-useless → no-evidence → (useful excluded upstream)."""
52
+ stats: dict[str, dict[str, Any]] = {}
53
+ if config.ledger_db_path.exists():
54
+ from sup_mem.ledger import Ledger
55
+
56
+ with Ledger(config.ledger_db_path) as ledger:
57
+ stats = {s["memory_id"]: s for s in ledger.all_stats()}
58
+
59
+ recent_ref_cutoff = _cutoff(config.archival.decay_min_age_days)
60
+ eligible: list[dict[str, Any]] = []
61
+ for cand in candidates:
62
+ s = stats.get(cand["id"], {})
63
+ last_ref = str(s.get("last_referenced", ""))
64
+ if last_ref and last_ref >= recent_ref_cutoff:
65
+ continue # referenced recently → never pressure-archived (A1)
66
+ cand = dict(cand)
67
+ cand["_referenced"] = int(s.get("referenced", 0))
68
+ cand["_ignored"] = int(s.get("ignored", 0))
69
+ cand["_injected"] = int(s.get("injected", 0))
70
+ eligible.append(cand)
71
+
72
+ def key(c: dict[str, Any]) -> tuple[int, int, int, int, str]:
73
+ evidenced_useless = c["_injected"] > 0 and c["_referenced"] == 0
74
+ return (
75
+ 0 if evidenced_useless else 1, # proven-useless first, no-evidence after
76
+ c["_referenced"], # fewer references = more useless
77
+ -c["_ignored"], # more ignored-despite-chances first
78
+ -c["_injected"],
79
+ c["recorded_at"], # oldest first as the final tiebreak
80
+ )
81
+
82
+ return sorted(eligible, key=key)
83
+
84
+
85
+ def run_archival(config: Config, *, dry_run: bool = False) -> dict[str, Any]:
86
+ from sup_mem.backends import get_backend
87
+ from sup_mem.backends.sqlite_fts import SqliteFtsBackend
88
+
89
+ backend = get_backend(config)
90
+ if not isinstance(backend, SqliteFtsBackend):
91
+ backend.close()
92
+ return {"supported": False, "note": "archival requires the sqlite_fts backend"}
93
+
94
+ report: dict[str, Any] = {
95
+ "supported": True,
96
+ "steady": [],
97
+ "pressure": [],
98
+ "purged": [],
99
+ "note": "",
100
+ }
101
+ try:
102
+ report["sizes_before"] = backend.db_sizes()
103
+
104
+ # Regime 1 — steady state: structural + proven-harmful tiers.
105
+ steady_ids = list(
106
+ dict.fromkeys(
107
+ backend.superseded_before(_cutoff(config.archival.superseded_after_days))
108
+ + _stale_quarantined_ids(config)
109
+ )
110
+ )
111
+ if dry_run:
112
+ report["steady"] = steady_ids
113
+ elif steady_ids:
114
+ report["steady"] = backend.archive_versions(steady_ids)
115
+
116
+ # Regime 2 — main pressure: most-useless-first until under the cap.
117
+ main_cap = int(config.archival.main_max_mb * _MB)
118
+ if main_cap > 0:
119
+ size = backend.db_sizes()["main"] if not dry_run else report["sizes_before"]["main"]
120
+ if size > main_cap:
121
+ ranked = _rank_by_uselessness(
122
+ backend.live_candidates(
123
+ _cutoff(config.archival.decay_min_age_days), config.archival.keep_tag
124
+ ),
125
+ config,
126
+ )
127
+ if dry_run:
128
+ report["pressure"] = [c["id"] for c in ranked]
129
+ report["note"] = (
130
+ f"main tier over cap ({size / _MB:.1f} > "
131
+ f"{config.archival.main_max_mb} MB); would archive up to "
132
+ f"{len(ranked)} candidates, most-useless first"
133
+ )
134
+ else:
135
+ while ranked and backend.db_sizes()["main"] > main_cap:
136
+ batch, ranked = ranked[:25], ranked[25:]
137
+ report["pressure"].extend(
138
+ backend.archive_versions([c["id"] for c in batch])
139
+ )
140
+ backend.compact()
141
+ if backend.db_sizes()["main"] > main_cap:
142
+ report["note"] = (
143
+ "main tier still over cap after archiving every eligible "
144
+ "candidate — refusing to archive evidently-useful memories; "
145
+ "raise archival.main_max_mb"
146
+ )
147
+
148
+ # Regime 3 — archive pressure: FIFO permanent deletion (chain-audited).
149
+ archive_cap = int(config.archival.archive_max_mb * _MB)
150
+ if archive_cap > 0 and not dry_run:
151
+ report["purged"] = backend.purge_archive_fifo(archive_cap)
152
+ elif archive_cap > 0 and dry_run:
153
+ over = backend.db_sizes()["archive"] - archive_cap
154
+ if over > 0:
155
+ report["note"] += (
156
+ f" archive tier over cap by {over / _MB:.1f} MB; oldest archived rows "
157
+ "would be DELETED FOREVER (FIFO)"
158
+ )
159
+
160
+ if not dry_run:
161
+ backend.compact()
162
+ report["sizes_after"] = backend.db_sizes()
163
+ return report
164
+ finally:
165
+ backend.close()
@@ -0,0 +1,37 @@
1
+ """Pluggable storage backends behind one interface (I6).
2
+
3
+ ``get_backend`` is the single construction point used by the hook, the MCP server and the CLI.
4
+ Backend modules are imported LAZILY here so that:
5
+ * the zero-optional-deps default install never imports ``qdrant_client`` / ``fastembed`` (I8),
6
+ * the hook's hot path only pays for the backend it actually uses (I2).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from sup_mem.backends.base import MemoryBackend
15
+ from sup_mem.config import Config
16
+
17
+
18
+ def get_backend(config: Config) -> MemoryBackend:
19
+ """Construct the configured backend. Raises ``ValueError`` for an unknown name."""
20
+ name = config.backend
21
+ if name == "sqlite_fts":
22
+ from sup_mem.backends.sqlite_fts import SqliteFtsBackend
23
+
24
+ return SqliteFtsBackend(config)
25
+ if name == "qdrant":
26
+ from sup_mem.backends.qdrant import QdrantBackend
27
+
28
+ return QdrantBackend(config)
29
+ if name == "pgvector":
30
+ # Documented v1 stub (§14): the interface is reserved; not implemented.
31
+ raise NotImplementedError(
32
+ "The pgvector backend is a documented v1 stub (§14). Use 'sqlite_fts' or 'qdrant'."
33
+ )
34
+ raise ValueError(f"Unknown backend {name!r}. Expected one of: sqlite_fts, qdrant, pgvector.")
35
+
36
+
37
+ __all__ = ["get_backend"]
@@ -0,0 +1,78 @@
1
+ """The one interface every backend implements (HANDOVER §6.1, I6).
2
+
3
+ Everything above this line — the hook, the MCP server, the manifest — depends ONLY on this
4
+ abstract class. Adding a new backend must not require touching any of them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ from sup_mem.models import Hit, Metadata
14
+
15
+ # Called as progress(done, total) during a reindex so the CLI can render a bar.
16
+ ProgressCallback = Callable[[int, int], None]
17
+
18
+
19
+ class MemoryBackend(ABC):
20
+ """Shared storage for both front-doors (the hook and the MCP tools), I1."""
21
+
22
+ @abstractmethod
23
+ def store(self, text: str, metadata: Metadata | None = None) -> str:
24
+ """Persist a memory and return its id. Idempotent on ``(text, source)`` where the
25
+ backend can manage it (``source`` is read from ``metadata['source']``)."""
26
+
27
+ @abstractmethod
28
+ def search(self, query: str, k: int, threshold: float, as_of: str | None = None) -> list[Hit]:
29
+ """Return up to ``k`` hits with ``score >= threshold``, best first.
30
+
31
+ ``score`` MUST be normalized to 0..1 so ``threshold`` is portable across backends
32
+ (§6.1): BM25 backends squash their rank; cosine backends map naturally.
33
+
34
+ ``as_of`` (ISO timestamp) asks for transaction-time-of-record results — "what was
35
+ live in the store at that instant" (PHASE8 T2). Backends without version history
36
+ MUST raise ``ValueError`` for a non-None ``as_of`` rather than silently returning
37
+ current results (T6).
38
+ """
39
+
40
+ @abstractmethod
41
+ def manifest(self, max_topics: int) -> list[str]:
42
+ """Return a compact topic index, never longer than ``max_topics`` (§6.7)."""
43
+
44
+ @abstractmethod
45
+ def health(self) -> dict[str, Any]:
46
+ """Liveness + config summary.
47
+
48
+ Keys: ``backend`` (str), ``count`` (int), ``embedding`` (``{provider, model, dim}``
49
+ for vector backends, else ``None``), plus any backend-specific extras.
50
+ """
51
+
52
+ @abstractmethod
53
+ def reindex(self, progress: ProgressCallback | None = None) -> None:
54
+ """Re-embed / rebuild the store. No-op for lexical backends; required for vector (I7)."""
55
+
56
+ @abstractmethod
57
+ def fetch(self, memory_ids: list[str]) -> dict[str, str]:
58
+ """Return ``{id: text}`` for the ids that exist. Used by the outcome ledger's
59
+ attribution (docs/PHASE6-LOOP.md, L5); missing ids are simply omitted."""
60
+
61
+ @property
62
+ def hook_safe(self) -> bool:
63
+ """Whether the per-prompt hook may call ``search()`` without loading a model (I2).
64
+
65
+ Lexical backends and remote-embedder vector backends are safe; an in-process embedder
66
+ (fastembed) is not, so the hook skips Tier-2 for it. Defaults to True.
67
+ """
68
+ return True
69
+
70
+ # --- Lifecycle (optional to override) -------------------------------------------------
71
+ def close(self) -> None: # noqa: B027
72
+ """Release resources (DB handles, clients). Optional override; default no-op."""
73
+
74
+ def __enter__(self) -> MemoryBackend:
75
+ return self
76
+
77
+ def __exit__(self, *exc: object) -> None:
78
+ self.close()
@@ -0,0 +1,317 @@
1
+ """Opt-in vector backend: Qdrant + a pluggable embedder (HANDOVER §6.3, I7).
2
+
3
+ Embedding happens via the configured provider (§6.4). The per-prompt hook only calls this
4
+ backend when ``hook_safe`` is true (a remote/warm embedder), so the short-lived hook never
5
+ loads a model (I2); fastembed is used from the warm MCP server or batch paths.
6
+
7
+ I7 (model-consistency) is a hard contract: the ``(provider, model, dim)`` that wrote the store
8
+ is persisted as a reserved meta point INSIDE Qdrant, so it travels with the vectors. Any
9
+ attempt to read/write with a different model raises with a `reindex` remediation.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import uuid
16
+ from datetime import UTC, datetime
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from sup_mem.backends.base import MemoryBackend, ProgressCallback
20
+ from sup_mem.embedding import get_embedder
21
+ from sup_mem.embedding.base import (
22
+ Embedder,
23
+ EmbeddingError,
24
+ EmbeddingMeta,
25
+ provider_is_hook_safe,
26
+ )
27
+ from sup_mem.models import Hit, Metadata
28
+
29
+ if TYPE_CHECKING:
30
+ from sup_mem.config import Config
31
+
32
+ _NAMESPACE = uuid.UUID("6f9619ff-8b86-d011-b42d-00cf4fc964ff") # stable id namespace
33
+ _META_ID = str(uuid.uuid5(_NAMESPACE, "__sup_mem_meta__"))
34
+ _META_FLAG = "__cm_meta__"
35
+ _WORD_RE = re.compile(r"\w", re.UNICODE)
36
+ _REINDEX_HINT = "Run `sup-mem reindex` to re-embed the store with the current model."
37
+
38
+
39
+ def _now_iso() -> str:
40
+ return datetime.now(UTC).isoformat()
41
+
42
+
43
+ def _point_id(text: str, source: str) -> str:
44
+ return str(uuid.uuid5(_NAMESPACE, f"{source}\x00{text}"))
45
+
46
+
47
+ def _valid_point_id(value: str) -> bool:
48
+ if value.isdigit():
49
+ return True
50
+ try:
51
+ uuid.UUID(value)
52
+ except ValueError:
53
+ return False
54
+ return True
55
+
56
+
57
+ class QdrantBackend(MemoryBackend):
58
+ def __init__(self, config: Config) -> None:
59
+ from qdrant_client import QdrantClient
60
+ from qdrant_client import models as qm
61
+
62
+ self._config = config
63
+ self._qm = qm
64
+ self._collection = config.qdrant.collection
65
+ # Our API surface (collections/upsert/query_points/scroll/count) is stable across
66
+ # server versions, so skip the client's strict minor-version compatibility warning.
67
+ self._client = QdrantClient(url=config.qdrant.url, check_compatibility=False)
68
+ self._embedder_cache: Embedder | None = None
69
+ self._stored_meta: EmbeddingMeta | None = None
70
+
71
+ # -- embedder + I7 --------------------------------------------------------------------
72
+ def _embedder(self) -> Embedder:
73
+ if self._embedder_cache is None:
74
+ self._embedder_cache = get_embedder(self._config)
75
+ return self._embedder_cache
76
+
77
+ @property
78
+ def hook_safe(self) -> bool:
79
+ # Decided from config alone — never constructs the embedder (I2).
80
+ return provider_is_hook_safe(self._config.embedding.provider)
81
+
82
+ def _read_stored_meta(self) -> EmbeddingMeta | None:
83
+ if self._stored_meta is not None:
84
+ return self._stored_meta
85
+ points = self._client.retrieve(self._collection, ids=[_META_ID], with_payload=True)
86
+ if not points:
87
+ return None
88
+ payload = points[0].payload or {}
89
+ self._stored_meta = EmbeddingMeta.from_dict(payload)
90
+ return self._stored_meta
91
+
92
+ def _configured_identity(self) -> tuple[str, str]:
93
+ from sup_mem.embedding import providers
94
+
95
+ provider = self._config.embedding.provider
96
+ spec = providers.SPEC_BY_NAME.get(provider)
97
+ model = self._config.embedding.model or (spec.default_model if spec else "")
98
+ return provider, model
99
+
100
+ def _verify_consistency(self) -> None:
101
+ """Raise if the configured model differs from what wrote the store (I7)."""
102
+ stored = self._read_stored_meta()
103
+ if stored is None:
104
+ return
105
+ provider, model = self._configured_identity()
106
+ if (stored.provider, stored.model) != (provider, model):
107
+ raise EmbeddingError(
108
+ f"Embedding-model mismatch (I7): store was written with "
109
+ f"'{stored.provider}/{stored.model}' but config selects '{provider}/{model}'. "
110
+ f"Vectors from different models are not comparable. {_REINDEX_HINT}"
111
+ )
112
+
113
+ def check_consistency(self) -> None:
114
+ """Public I7 check for `doctor` — no-op if the store is empty/uninitialized."""
115
+ if not self._client.collection_exists(self._collection):
116
+ return
117
+ self._verify_consistency()
118
+
119
+ # -- collection lifecycle -------------------------------------------------------------
120
+ def _create_collection(self, dim: int) -> None:
121
+ qm = self._qm
122
+ hnsw = self._config.qdrant.hnsw
123
+ quant = None
124
+ if self._config.qdrant.quantization:
125
+ quant = qm.ScalarQuantization(
126
+ scalar=qm.ScalarQuantizationConfig(type=qm.ScalarType.INT8, always_ram=True)
127
+ )
128
+ self._client.create_collection(
129
+ collection_name=self._collection,
130
+ vectors_config=qm.VectorParams(size=dim, distance=qm.Distance.COSINE),
131
+ hnsw_config=qm.HnswConfigDiff(m=hnsw.m, ef_construct=hnsw.ef_construct),
132
+ quantization_config=quant,
133
+ )
134
+
135
+ def _write_meta(self, meta: EmbeddingMeta) -> None:
136
+ qm = self._qm
137
+ payload = {**meta.as_dict(), _META_FLAG: True}
138
+ self._client.upsert(
139
+ self._collection,
140
+ points=[qm.PointStruct(id=_META_ID, vector=[0.0] * meta.dim, payload=payload)],
141
+ )
142
+ self._stored_meta = meta
143
+
144
+ def _ensure_writable(self) -> Embedder:
145
+ embedder = self._embedder()
146
+ if not self._client.collection_exists(self._collection):
147
+ meta = embedder.meta
148
+ self._create_collection(meta.dim)
149
+ self._write_meta(meta)
150
+ else:
151
+ self._verify_consistency()
152
+ return embedder
153
+
154
+ def _not_meta(self) -> Any:
155
+ qm = self._qm
156
+ return qm.Filter(
157
+ must_not=[qm.FieldCondition(key=_META_FLAG, match=qm.MatchValue(value=True))]
158
+ )
159
+
160
+ def initialize(self) -> EmbeddingMeta:
161
+ """Create the collection (if needed) and record the embedding meta (I7); returns it.
162
+
163
+ Called by `sup-mem setup` — constructs the embedder to learn the vector `dim`.
164
+ """
165
+ return self._ensure_writable().meta
166
+
167
+ # -- writes ---------------------------------------------------------------------------
168
+ def store(self, text: str, metadata: Metadata | None = None) -> str:
169
+ text = text.strip()
170
+ meta = dict(metadata or {})
171
+ source = str(meta.get("source", ""))
172
+ embedder = self._ensure_writable()
173
+ vector = embedder.embed_query(text)
174
+ point_id = _point_id(text, source)
175
+ now = _now_iso()
176
+ payload = {
177
+ "text": text,
178
+ "metadata": meta,
179
+ "source": source,
180
+ "created_at": now,
181
+ "updated_at": now,
182
+ }
183
+ self._client.upsert(
184
+ self._collection,
185
+ points=[self._qm.PointStruct(id=point_id, vector=vector, payload=payload)],
186
+ )
187
+ return point_id
188
+
189
+ # -- reads ----------------------------------------------------------------------------
190
+ def search(self, query: str, k: int, threshold: float, as_of: str | None = None) -> list[Hit]:
191
+ if as_of is not None:
192
+ # No version history here — refusing beats silently answering with "now" (T6).
193
+ raise ValueError(
194
+ "bitemporal (--as-of) recall requires the sqlite_fts backend; "
195
+ "the qdrant backend stores only current versions."
196
+ )
197
+ if k <= 0 or not _WORD_RE.search(query):
198
+ return []
199
+ if not self._client.collection_exists(self._collection):
200
+ return []
201
+ embedder = self._embedder()
202
+ self._verify_consistency()
203
+ vector = embedder.embed_query(query)
204
+ result = self._client.query_points(
205
+ self._collection,
206
+ query=vector,
207
+ limit=k,
208
+ query_filter=self._not_meta(),
209
+ search_params=self._qm.SearchParams(hnsw_ef=self._config.qdrant.hnsw.ef),
210
+ with_payload=True,
211
+ )
212
+ hits: list[Hit] = []
213
+ for point in result.points:
214
+ score = max(0.0, min(1.0, float(point.score))) # cosine → clamp to 0..1 (§6.1)
215
+ if score < threshold:
216
+ continue
217
+ payload = point.payload or {}
218
+ hits.append(
219
+ Hit(
220
+ id=str(point.id),
221
+ text=str(payload.get("text", "")),
222
+ score=score,
223
+ metadata=dict(payload.get("metadata", {})),
224
+ )
225
+ )
226
+ return hits
227
+
228
+ def fetch(self, memory_ids: list[str]) -> dict[str, str]:
229
+ # Qdrant point ids must be unsigned ints or UUIDs; anything else cannot exist in the
230
+ # collection, so it is "omitted" by definition rather than sent (the server 400s on it).
231
+ valid = [mid for mid in memory_ids if _valid_point_id(mid)]
232
+ if not valid or not self._client.collection_exists(self._collection):
233
+ return {}
234
+ points = self._client.retrieve(self._collection, ids=valid, with_payload=True)
235
+ return {
236
+ str(point.id): str((point.payload or {}).get("text", ""))
237
+ for point in points
238
+ if not (point.payload or {}).get(_META_FLAG)
239
+ }
240
+
241
+ # -- introspection --------------------------------------------------------------------
242
+ def _iter_payloads(self) -> Any:
243
+ offset = None
244
+ while True:
245
+ points, offset = self._client.scroll(
246
+ self._collection,
247
+ scroll_filter=self._not_meta(),
248
+ with_payload=True,
249
+ with_vectors=False,
250
+ limit=256,
251
+ offset=offset,
252
+ )
253
+ yield from points
254
+ if offset is None:
255
+ break
256
+
257
+ def manifest(self, max_topics: int) -> list[str]:
258
+ if max_topics <= 0 or not self._client.collection_exists(self._collection):
259
+ return []
260
+ from collections import Counter
261
+
262
+ counter: Counter[str] = Counter()
263
+ for point in self._iter_payloads():
264
+ meta = (point.payload or {}).get("metadata", {})
265
+ topic = meta.get("topic")
266
+ if isinstance(topic, str) and topic.strip():
267
+ counter[topic.strip()] += 1
268
+ tags = meta.get("tags", [])
269
+ if isinstance(tags, str):
270
+ tags = [t.strip() for t in tags.split(",")]
271
+ if isinstance(tags, list):
272
+ for tag in tags:
273
+ if str(tag).strip():
274
+ counter[str(tag).strip()] += 1
275
+ return [topic for topic, _ in counter.most_common(max_topics)]
276
+
277
+ def health(self) -> dict[str, Any]:
278
+ exists = self._client.collection_exists(self._collection)
279
+ count = 0
280
+ stored = None
281
+ if exists:
282
+ count = int(
283
+ self._client.count(
284
+ self._collection, count_filter=self._not_meta(), exact=True
285
+ ).count
286
+ )
287
+ stored = self._read_stored_meta()
288
+ return {
289
+ "backend": "qdrant",
290
+ "count": count,
291
+ "embedding": stored.as_dict() if stored else None,
292
+ "revision": f"{count}:{stored.model if stored else ''}",
293
+ "url": self._config.qdrant.url,
294
+ "collection": self._collection,
295
+ }
296
+
297
+ def reindex(self, progress: ProgressCallback | None = None) -> None:
298
+ """Re-embed every memory with the CURRENT model and update stored meta (I7)."""
299
+ if not self._client.collection_exists(self._collection):
300
+ return
301
+ embedder = self._embedder()
302
+ meta = embedder.meta
303
+ points = list(self._iter_payloads())
304
+ total = len(points)
305
+ for done, point in enumerate(points, 1):
306
+ payload = point.payload or {}
307
+ vector = embedder.embed_query(str(payload.get("text", "")))
308
+ self._client.upsert(
309
+ self._collection,
310
+ points=[self._qm.PointStruct(id=point.id, vector=vector, payload=payload)],
311
+ )
312
+ if progress is not None:
313
+ progress(done, total)
314
+ self._write_meta(meta) # store now matches the current model
315
+
316
+ def close(self) -> None:
317
+ self._client.close()