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 +22 -0
- sup_mem/archival.py +165 -0
- sup_mem/backends/__init__.py +37 -0
- sup_mem/backends/base.py +78 -0
- sup_mem/backends/qdrant.py +317 -0
- sup_mem/backends/sqlite_fts.py +706 -0
- sup_mem/capture.py +198 -0
- sup_mem/cli.py +179 -0
- sup_mem/commands.py +731 -0
- sup_mem/config.py +497 -0
- sup_mem/embedding/__init__.py +47 -0
- sup_mem/embedding/base.py +62 -0
- sup_mem/embedding/detect.py +122 -0
- sup_mem/embedding/providers.py +310 -0
- sup_mem/hook/__init__.py +1 -0
- sup_mem/hook/pre_compact.py +47 -0
- sup_mem/hook/session_start.py +39 -0
- sup_mem/hook/stop.py +65 -0
- sup_mem/hook/user_prompt_submit.py +205 -0
- sup_mem/ledger.py +448 -0
- sup_mem/maintenance.py +315 -0
- sup_mem/manifest.py +93 -0
- sup_mem/mcp/__init__.py +1 -0
- sup_mem/mcp/server.py +127 -0
- sup_mem/migrate.py +134 -0
- sup_mem/models.py +53 -0
- sup_mem/provenance.py +244 -0
- sup_mem/py.typed +0 -0
- sup_mem/ranking.py +50 -0
- sup_mem/registration.py +188 -0
- sup_mem/service.py +217 -0
- sup_mem/status.py +205 -0
- sup_mem-0.7.0.dist-info/METADATA +252 -0
- sup_mem-0.7.0.dist-info/RECORD +37 -0
- sup_mem-0.7.0.dist-info/WHEEL +4 -0
- sup_mem-0.7.0.dist-info/entry_points.txt +6 -0
- sup_mem-0.7.0.dist-info/licenses/LICENSE +21 -0
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"]
|
sup_mem/backends/base.py
ADDED
|
@@ -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()
|