superlocalmemory 4.0.10 → 4.1.2

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.
Files changed (145) hide show
  1. package/.claude-plugin/marketplace.json +12 -2
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +40 -75
  4. package/package.json +6 -3
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +4 -4
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +2 -2
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +5 -5
  19. package/plugin/skills/slm-recall/SKILL.md +102 -15
  20. package/plugin/skills/slm-remember/SKILL.md +35 -3
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +29 -3
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/rules/AGENTS.md +16 -8
  25. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-loop/SKILL.md +2 -2
  30. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-profile/SKILL.md +5 -5
  32. package/plugin-src/skills/slm-recall/SKILL.md +102 -15
  33. package/plugin-src/skills/slm-remember/SKILL.md +35 -3
  34. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +29 -3
  36. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  37. package/pyproject.toml +1 -1
  38. package/src/superlocalmemory/__init__.py +1 -1
  39. package/src/superlocalmemory/cli/commands.py +357 -18
  40. package/src/superlocalmemory/cli/daemon.py +30 -0
  41. package/src/superlocalmemory/cli/db_migrate.py +71 -1
  42. package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
  43. package/src/superlocalmemory/cli/main.py +24 -2
  44. package/src/superlocalmemory/code_graph/database.py +44 -0
  45. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  46. package/src/superlocalmemory/core/admission.py +231 -11
  47. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  48. package/src/superlocalmemory/core/config.py +90 -11
  49. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  50. package/src/superlocalmemory/core/engine.py +140 -11
  51. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  52. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  53. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  54. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  55. package/src/superlocalmemory/core/mode_capability.py +111 -0
  56. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  57. package/src/superlocalmemory/core/projection_drain.py +380 -0
  58. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  59. package/src/superlocalmemory/core/recall_worker.py +6 -3
  60. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  61. package/src/superlocalmemory/core/scale_engine.py +16 -2
  62. package/src/superlocalmemory/core/score_contract.py +21 -1
  63. package/src/superlocalmemory/core/session_identity.py +85 -0
  64. package/src/superlocalmemory/core/status_contract.py +108 -0
  65. package/src/superlocalmemory/core/worker_pool.py +4 -4
  66. package/src/superlocalmemory/core/working_memory.py +288 -0
  67. package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
  68. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  69. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  70. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  71. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  72. package/src/superlocalmemory/encoding/type_router.py +12 -12
  73. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  74. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  75. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  76. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  77. package/src/superlocalmemory/learning/bandit.py +43 -0
  78. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  79. package/src/superlocalmemory/learning/database.py +60 -3
  80. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  81. package/src/superlocalmemory/learning/feedback.py +3 -1
  82. package/src/superlocalmemory/learning/outcomes.py +47 -16
  83. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  84. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  85. package/src/superlocalmemory/learning/pcos.py +291 -0
  86. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  87. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  88. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  89. package/src/superlocalmemory/mcp/profiles.py +14 -2
  90. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  91. package/src/superlocalmemory/mcp/tools_core.py +31 -3
  92. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  93. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  94. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
  96. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  97. package/src/superlocalmemory/retrieval/engine.py +106 -11
  98. package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
  99. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  100. package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
  101. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  102. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  103. package/src/superlocalmemory/server/api.py +24 -5
  104. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  105. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  106. package/src/superlocalmemory/server/recall_health.py +87 -10
  107. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  108. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  109. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  110. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  111. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  112. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  113. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  114. package/src/superlocalmemory/server/routes/memories.py +49 -7
  115. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  116. package/src/superlocalmemory/server/routes/v3_api.py +191 -15
  117. package/src/superlocalmemory/server/ui.py +20 -4
  118. package/src/superlocalmemory/server/unified_daemon.py +241 -7
  119. package/src/superlocalmemory/storage/_migration_internals.py +54 -2
  120. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  121. package/src/superlocalmemory/storage/database.py +477 -59
  122. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  123. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  124. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  125. package/src/superlocalmemory/storage/migration_runner.py +119 -0
  126. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
  127. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  128. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  129. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  130. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  131. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  132. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  133. package/src/superlocalmemory/storage/migrations.py +18 -2
  134. package/src/superlocalmemory/storage/models.py +40 -1
  135. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  136. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  137. package/src/superlocalmemory/storage/schema.py +35 -1
  138. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  139. package/src/superlocalmemory/trust/scorer.py +43 -1
  140. package/src/superlocalmemory/ui/index.html +9 -18
  141. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  142. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  143. package/src/superlocalmemory/ui/js/od-memories.js +19 -0
  144. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  145. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -0,0 +1,315 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Ask the local model server whether a model exists, before trusting it.
6
+
7
+ Mode B runs on Ollama, and a user picking a model there picks it by typing a
8
+ name. Two things then go wrong silently.
9
+
10
+ **The name is wrong.** Nothing happens at the moment of the mistake; the config
11
+ saves, the daemon starts, and every write quietly falls back to a worse path.
12
+ The error a user eventually sees is unrelated to what they did.
13
+
14
+ **The name is right and the shape is different.** Every embedding model emits a
15
+ vector of a fixed width. `nomic-embed-text` emits 768 numbers,
16
+ `mxbai-embed-large` emits 1024. Vectors of different widths cannot be compared,
17
+ so a store holding both answers similarity questions with noise — and it does so
18
+ without failing, because nothing in a similarity search knows the difference
19
+ between a bad answer and a good one. **This is the single silent-and-catastrophic
20
+ change a user can make**, so it is refused rather than warned about, and the
21
+ refusal says the one command that would make it safe.
22
+
23
+ There are two Ollama roles and they are separate models with separate names:
24
+ the embedding model (`embedding.ollama_model`) turns text into vectors, and the
25
+ generation model (`llm.model` when the provider is Ollama) writes summaries.
26
+ They are validated independently because a machine can perfectly well have one
27
+ and not the other.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ import sqlite3
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+
37
+ __all__ = [
38
+ "EMBEDDING",
39
+ "GENERATION",
40
+ "OllamaProbe",
41
+ "DimensionChange",
42
+ "validate_ollama_model",
43
+ "stored_embedding_dimension",
44
+ "check_embedding_model_change",
45
+ "same_embedding_model",
46
+ ]
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+ EMBEDDING = "embedding"
51
+ GENERATION = "generation"
52
+
53
+ DEFAULT_BASE_URL = "http://localhost:11434"
54
+
55
+ #: Long enough for a cold model load, short enough that a wedged server is not
56
+ #: mistaken for a slow one.
57
+ _CONNECT_TIMEOUT = 3.0
58
+ _RESPONSE_TIMEOUT = 60.0
59
+
60
+ _PROBE_TEXT = "superlocalmemory model probe"
61
+ _PROBE_PROMPT = "Reply with the single word: ok"
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class OllamaProbe:
66
+ """What the server said when asked about one model."""
67
+
68
+ ok: bool
69
+ message: str
70
+ dimension: int | None = None
71
+
72
+ def __bool__(self) -> bool: # pragma: no cover - convenience only
73
+ return self.ok
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class DimensionChange:
78
+ """A model change that would put two vector widths in one store."""
79
+
80
+ allowed: bool
81
+ message: str
82
+ stored_dimension: int | None = None
83
+ new_dimension: int | None = None
84
+
85
+
86
+ def same_embedding_model(left: str, right: str) -> bool:
87
+ """True when two names are two spellings of one model.
88
+
89
+ ``nomic-ai/nomic-embed-text-v1.5`` is the HuggingFace name and
90
+ ``nomic-embed-text`` is the Ollama pull name for the same weights. Warning
91
+ about a dimension change between them would be a false alarm, and a false
92
+ alarm about the one thing that is genuinely dangerous is how a real warning
93
+ gets ignored.
94
+ """
95
+ def base(name: str) -> str:
96
+ stem = name.strip().lower().rsplit("/", 1)[-1]
97
+ # An Ollama tag is PART OF THE IDENTITY, not packaging.
98
+ # ``qwen3-embedding:0.6b`` emits 1024 numbers and ``qwen3-embedding:8b``
99
+ # emits 4096, and stripping the tag made this function call them the
100
+ # same model — which then skipped the width check that exists to stop
101
+ # exactly that swap. Only ``:latest`` means "whatever the bare name
102
+ # means", so only that one is dropped.
103
+ if stem.endswith(":latest"):
104
+ stem = stem[: -len(":latest")]
105
+ # A HuggingFace revision suffix on an otherwise identical name is
106
+ # packaging; ``nomic-embed-text-v1.5`` and ``nomic-embed-text`` are the
107
+ # same weights under two registries' conventions.
108
+ if ":" not in stem:
109
+ for suffix in ("-v1.5", "-v1", "-v2"):
110
+ if stem.endswith(suffix):
111
+ stem = stem[: -len(suffix)]
112
+ return stem
113
+
114
+ return base(left) == base(right)
115
+
116
+
117
+ def validate_ollama_model(
118
+ model_name: str,
119
+ role: str = EMBEDDING,
120
+ *,
121
+ base_url: str = DEFAULT_BASE_URL,
122
+ timeout: float = _RESPONSE_TIMEOUT,
123
+ ) -> OllamaProbe:
124
+ """Ask the server to actually use the model, and report what happened.
125
+
126
+ Listing the installed models is not enough: a name can be present and the
127
+ model still fail to load. So this runs the smallest real request for the
128
+ role and reads the answer, which is also the only way to learn an embedding
129
+ model's width.
130
+ """
131
+ if role not in (EMBEDDING, GENERATION):
132
+ raise ValueError(f"role must be {EMBEDDING!r} or {GENERATION!r}, got {role!r}")
133
+ name = (model_name or "").strip()
134
+ if not name:
135
+ return OllamaProbe(False, "No model name given.")
136
+
137
+ try:
138
+ import httpx
139
+ except ImportError: # pragma: no cover - httpx is a hard dependency
140
+ return OllamaProbe(False, "httpx is not installed, so Ollama cannot be reached.")
141
+
142
+ url = base_url.rstrip("/")
143
+ request_timeout = httpx.Timeout(timeout, connect=_CONNECT_TIMEOUT)
144
+
145
+ try:
146
+ if role == EMBEDDING:
147
+ response = httpx.post(
148
+ f"{url}/api/embed",
149
+ json={"model": name, "input": [_PROBE_TEXT]},
150
+ timeout=request_timeout,
151
+ )
152
+ else:
153
+ response = httpx.post(
154
+ f"{url}/api/generate",
155
+ json={"model": name, "prompt": _PROBE_PROMPT, "stream": False},
156
+ timeout=request_timeout,
157
+ )
158
+ except httpx.ConnectError:
159
+ return OllamaProbe(
160
+ False,
161
+ f"Ollama is not running at {url}. Start it with: ollama serve",
162
+ )
163
+ except httpx.TimeoutException:
164
+ return OllamaProbe(
165
+ False,
166
+ f"Ollama did not answer within {timeout:.0f}s. The model may still be "
167
+ f"downloading — check with: ollama list",
168
+ )
169
+ except Exception as exc: # noqa: BLE001 - the message is the product here
170
+ return OllamaProbe(False, f"Could not reach Ollama at {url}: {exc}")
171
+
172
+ if response.status_code == 404 or _looks_missing(response):
173
+ return OllamaProbe(
174
+ False,
175
+ f"Ollama has no model called {name!r}. Run: ollama pull {name}",
176
+ )
177
+ if role == EMBEDDING and _refuses_embeddings(response):
178
+ return OllamaProbe(
179
+ False,
180
+ f"{name!r} is not an embedding model — the server refused the "
181
+ f"request. Pick a model built for embeddings, such as "
182
+ f"nomic-embed-text.",
183
+ )
184
+ if response.status_code != 200:
185
+ return OllamaProbe(
186
+ False,
187
+ f"Ollama answered {response.status_code} for {name!r}: "
188
+ f"{response.text[:200]}",
189
+ )
190
+
191
+ try:
192
+ payload = response.json()
193
+ except ValueError:
194
+ return OllamaProbe(False, f"Ollama returned something that is not JSON for {name!r}.")
195
+
196
+ if role == EMBEDDING:
197
+ vectors = payload.get("embeddings") or []
198
+ if not vectors or not isinstance(vectors[0], list) or not vectors[0]:
199
+ return OllamaProbe(
200
+ False,
201
+ f"{name!r} answered but returned no vector, so it is not an "
202
+ f"embedding model. Pick one built for embeddings, such as "
203
+ f"nomic-embed-text.",
204
+ )
205
+ width = len(vectors[0])
206
+ return OllamaProbe(True, f"{name} emits {width}-dimensional vectors.", width)
207
+
208
+ text = str(payload.get("response") or "").strip()
209
+ if not text:
210
+ return OllamaProbe(
211
+ False,
212
+ f"{name!r} answered but wrote nothing, so it cannot be used to "
213
+ f"generate summaries.",
214
+ )
215
+ return OllamaProbe(True, f"{name} answered a probe prompt.")
216
+
217
+
218
+ def _refuses_embeddings(response: object) -> bool:
219
+ """A generation-only model answers an embedding request with a refusal.
220
+
221
+ The server's own wording is about its build flags and means nothing to
222
+ somebody who typed a chat model's name into an embedding field.
223
+ """
224
+ status = getattr(response, "status_code", 0)
225
+ try:
226
+ lowered = response.text.lower() # type: ignore[attr-defined]
227
+ except Exception: # pragma: no cover - defensive
228
+ lowered = ""
229
+ if status == 501:
230
+ return True
231
+ return "does not support embed" in lowered or "not support embeddings" in lowered
232
+
233
+
234
+ def _looks_missing(response: object) -> bool:
235
+ """Ollama reports an unknown model as a 400 with a body that says so."""
236
+ try:
237
+ body = response.text # type: ignore[attr-defined]
238
+ except Exception: # pragma: no cover - defensive
239
+ return False
240
+ lowered = body.lower()
241
+ return "not found" in lowered and "model" in lowered
242
+
243
+
244
+ def stored_embedding_dimension(db_path: str | Path) -> int | None:
245
+ """The vector width this store already holds, or None if it holds none."""
246
+ path = Path(db_path)
247
+ if not path.exists():
248
+ return None
249
+ try:
250
+ conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
251
+ except sqlite3.Error:
252
+ return None
253
+ try:
254
+ row = conn.execute(
255
+ "SELECT dimension FROM embedding_metadata "
256
+ "WHERE dimension IS NOT NULL AND dimension > 0 LIMIT 1"
257
+ ).fetchone()
258
+ except sqlite3.Error:
259
+ return None
260
+ finally:
261
+ conn.close()
262
+ return int(row[0]) if row else None
263
+
264
+
265
+ def check_embedding_model_change(
266
+ new_model: str,
267
+ *,
268
+ db_path: str | Path,
269
+ current_model: str = "",
270
+ base_url: str = DEFAULT_BASE_URL,
271
+ ) -> DimensionChange:
272
+ """Decide whether switching the embedding model is safe for this store.
273
+
274
+ Refuses rather than warns. A warning printed into a log is not a decision,
275
+ and the outcome of getting this wrong is a store whose similarity search is
276
+ quietly meaningless.
277
+ """
278
+ # The width is asked for FIRST, always. Recognising two names as the same
279
+ # model may soften the message, but it must never stand in for measuring —
280
+ # a name that merely looks familiar is exactly how a different-width model
281
+ # would get through.
282
+ probe = validate_ollama_model(new_model, EMBEDDING, base_url=base_url)
283
+ if not probe.ok:
284
+ return DimensionChange(False, probe.message)
285
+
286
+ familiar = bool(current_model) and same_embedding_model(current_model, new_model)
287
+ stored = stored_embedding_dimension(db_path)
288
+ if familiar and (stored is None or stored == probe.dimension):
289
+ return DimensionChange(
290
+ True,
291
+ f"{new_model} is another name for the model already in use, and it "
292
+ f"emits the same {probe.dimension}-dimensional vectors.",
293
+ stored,
294
+ probe.dimension,
295
+ )
296
+ if stored is None:
297
+ return DimensionChange(
298
+ True,
299
+ f"{probe.message} This store holds no vectors yet, so nothing has to "
300
+ f"be rebuilt.",
301
+ None,
302
+ probe.dimension,
303
+ )
304
+ if stored == probe.dimension:
305
+ return DimensionChange(True, probe.message, stored, probe.dimension)
306
+
307
+ return DimensionChange(
308
+ False,
309
+ f"{new_model} emits {probe.dimension}-dimensional vectors and this store "
310
+ f"holds {stored}-dimensional ones. Vectors of different widths cannot be "
311
+ f"compared, so every memory already stored would become unfindable by "
312
+ f"meaning. Rebuild them first with: slm db migrate",
313
+ stored,
314
+ probe.dimension,
315
+ )
@@ -0,0 +1,380 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """The worker that carries queued facts into CozoDB and LanceDB.
6
+
7
+ One writer, one direction: SQLite is canonical, the projections are derived, and
8
+ this is the only thing that writes them during normal operation. Making it the
9
+ sole writer is what removes the class of bug this replaced — a projection write
10
+ attempted inline on the store path, on whichever thread happened to be storing,
11
+ with its failure swallowed into a debug log.
12
+
13
+ WHAT IT PROJECTS IS WHAT RECALL CAN RETURN
14
+ ------------------------------------------
15
+ Not "lifecycle in (active, warm)", which is what the inline sync used. That
16
+ predicate was wrong twice over: it dropped ``cold``, which is a live tier that
17
+ recall answers from, and it never withdrew a fact that had since been archived,
18
+ leaving forgotten memories offered as candidates.
19
+
20
+ The filter is ``visible_fact_clause()`` — the same predicate every read path
21
+ uses to decide whether a row may be shown as a memory at all. Deriving the
22
+ projection from that clause means the two cannot drift.
23
+
24
+ BUT VISIBILITY GOVERNS CANDIDACY, NOT ADJACENCY
25
+ -----------------------------------------------
26
+ The SQLite channel does not treat those as one thing, and neither may this. Its
27
+ entity map filters on visibility — a withheld row must never enter it, because
28
+ it carries its whole cluster's pooled entity list and out-ranks real memories.
29
+ Its edge walk filters on scope alone, with no visibility predicate, so it
30
+ traverses edges into withheld and archived facts and lets hydration drop them at
31
+ the end.
32
+
33
+ So a hidden fact loses its entity bridge and its vector and KEEPS its edges. An
34
+ earlier version of this module deleted the edges too, and on a real store that
35
+ left 32 visible facts with a smaller adjacency here than in SQLite — every
36
+ missing endpoint quarantined. A graph that answers differently from the walk it
37
+ replaces is not a projection of it.
38
+
39
+ FAILURE IS LOUD
40
+ ---------------
41
+ A projection write that raises leaves its row queued with the attempt counted
42
+ and the error recorded. Nothing is dropped, and the queue depth is a health
43
+ metric, so a projection that has stopped keeping up is visible instead of
44
+ silent. That is the entire point of the mechanism and it is why nothing in this
45
+ module catches an exception and continues as though it had not happened.
46
+
47
+ AFTER A BULK IMPORT, THE QUEUE IS NOT CLEARED — IT IS DRAINED
48
+ -------------------------------------------------------------
49
+ A promotion builds the whole projection from SQLite in one pass, which satisfies
50
+ every row queued before it started. Deleting those rows on that basis would need
51
+ a watermark: a timestamp taken before the import read its snapshot, with
52
+ everything older discarded. That is one off-by-one away from throwing out a
53
+ projection nobody will ever write again, in the exact mechanism whose only job
54
+ is to make that impossible.
55
+
56
+ So nothing is discarded. The worker re-projects the backlog, which is idempotent
57
+ and lands on the same graph. It costs the import's work once more, in the
58
+ background, off the hot path — a price worth paying for a rule with no edge case
59
+ in it. Queue depth right after a promotion is therefore high and falling, which
60
+ is the truth; ``stalled`` is the number that indicates trouble.
61
+
62
+ A ROW THAT KEEPS FAILING MUST NOT BLOCK THE ONES BEHIND IT
63
+ ----------------------------------------------------------
64
+ The queue is claimed in ``attempts, revision`` order, so a fact whose
65
+ projection is genuinely impossible — a malformed embedding, an id Cozo refuses
66
+ — sinks to the back after its first failure and healthy work continues past it.
67
+ Ordering by revision alone would let one poisoned row starve every fact behind
68
+ it, which is the failure mode that makes queues look like outages.
69
+ """
70
+
71
+ from __future__ import annotations
72
+
73
+ import logging
74
+ import threading
75
+ from dataclasses import dataclass, field
76
+ from typing import Any, Callable
77
+
78
+ from superlocalmemory.storage import projection_outbox
79
+
80
+ logger = logging.getLogger(__name__)
81
+
82
+ #: Rows per pass. Large enough that a backlog clears in few passes, small enough
83
+ #: that a pass cannot hold the drain thread past a shutdown request for long.
84
+ DEFAULT_BATCH = 200
85
+
86
+ #: How long the worker waits before looking again when nothing woke it. A store
87
+ #: signals the worker directly, so this is only the safety net for an enqueue
88
+ #: that arrived from another process — a CLI write, or a second daemon.
89
+ IDLE_INTERVAL_SECONDS = 2.0
90
+
91
+ #: Attempts after which a row is reported at warning level rather than debug.
92
+ #: It keeps being retried; the change is that it stops being quiet about it.
93
+ LOUD_AFTER_ATTEMPTS = 3
94
+
95
+
96
+ @dataclass
97
+ class DrainResult:
98
+ """What one pass did. Every field is a number an operator can act on."""
99
+
100
+ projected: int = 0
101
+ removed: int = 0
102
+ failed: int = 0
103
+ skipped: int = 0
104
+ superseded: int = 0
105
+ errors: list[str] = field(default_factory=list)
106
+
107
+ @property
108
+ def handled(self) -> int:
109
+ return self.projected + self.removed + self.skipped + self.superseded
110
+
111
+ def as_dict(self) -> dict[str, Any]:
112
+ return {
113
+ "projected": self.projected,
114
+ "removed": self.removed,
115
+ "failed": self.failed,
116
+ "skipped": self.skipped,
117
+ "superseded": self.superseded,
118
+ }
119
+
120
+
121
+ class ProjectionDrain:
122
+ """Applies queued facts to the graph and vector projections.
123
+
124
+ Takes accessors rather than backends so it always sees the current ones: a
125
+ promotion or a rollback swaps them underneath a long-lived worker, and a
126
+ reference captured at construction would keep writing into the store that
127
+ was just replaced.
128
+ """
129
+
130
+ def __init__(
131
+ self,
132
+ db: Any,
133
+ graph_backend: Callable[[], Any],
134
+ vector_backend: Callable[[], Any],
135
+ ) -> None:
136
+ self._db = db
137
+ self._graph = graph_backend
138
+ self._vector = vector_backend
139
+ self._wake = threading.Event()
140
+ self._stop = threading.Event()
141
+ self._thread: threading.Thread | None = None
142
+ self._pass_lock = threading.Lock()
143
+
144
+ # ------------------------------------------------------------------
145
+ # Lifecycle
146
+ # ------------------------------------------------------------------
147
+
148
+ def start(self) -> bool:
149
+ """Begin draining in the background. Idempotent."""
150
+ if self._thread is not None and self._thread.is_alive():
151
+ return False
152
+ self._stop.clear()
153
+ self._thread = threading.Thread(
154
+ target=self._run, name="slm-projection-drain", daemon=True,
155
+ )
156
+ self._thread.start()
157
+ logger.info("projection drain started")
158
+ return True
159
+
160
+ def stop(self, timeout: float = 5.0) -> None:
161
+ """Ask the worker to finish its pass and exit."""
162
+ self._stop.set()
163
+ self._wake.set()
164
+ thread = self._thread
165
+ if thread is not None and thread.is_alive():
166
+ thread.join(timeout=timeout)
167
+ self._thread = None
168
+
169
+ @property
170
+ def running(self) -> bool:
171
+ return self._thread is not None and self._thread.is_alive()
172
+
173
+ def notify(self) -> None:
174
+ """Tell the worker there is something to do.
175
+
176
+ Called after a store commits. Cheap enough to call on every write, and
177
+ it is what keeps the gap between "remembered" and "in the graph" at
178
+ milliseconds instead of the idle interval.
179
+ """
180
+ self._wake.set()
181
+
182
+ def _run(self) -> None:
183
+ while not self._stop.is_set():
184
+ self._wake.wait(timeout=IDLE_INTERVAL_SECONDS)
185
+ self._wake.clear()
186
+ if self._stop.is_set():
187
+ break
188
+ try:
189
+ # Keep going while a pass is filling its batch: a backlog
190
+ # should drain continuously rather than one batch per tick.
191
+ while not self._stop.is_set():
192
+ result = self.drain_once()
193
+ if result.handled + result.failed < DEFAULT_BATCH:
194
+ break
195
+ except Exception as exc: # pragma: no cover - worker must not die
196
+ # A worker that exits on an unexpected error would leave the
197
+ # queue growing with nothing draining it and no thread left to
198
+ # report why. Log it and stay alive; the rows are still queued.
199
+ logger.error("projection drain pass failed: %s", exc, exc_info=True)
200
+ logger.info("projection drain stopped")
201
+
202
+ # ------------------------------------------------------------------
203
+ # One pass
204
+ # ------------------------------------------------------------------
205
+
206
+ def drain_once(self, limit: int = DEFAULT_BATCH) -> DrainResult:
207
+ """Apply up to ``limit`` queued facts. Safe to call directly.
208
+
209
+ Returns without touching a row when no projection is open. The rows are
210
+ the pending work for whenever one is, and discarding them would be
211
+ throwing away the only record of which facts still need projecting.
212
+ """
213
+ result = DrainResult()
214
+ graph, vector = self._graph(), self._vector()
215
+ if graph is None and vector is None:
216
+ return result
217
+
218
+ with self._pass_lock:
219
+ for row in projection_outbox.claim_batch(self._db, limit=limit):
220
+ self._apply_row(row, graph, vector, result)
221
+ return result
222
+
223
+ def _apply_row(
224
+ self, row: dict[str, Any], graph: Any, vector: Any, result: DrainResult,
225
+ ) -> None:
226
+ fact_id = row["fact_id"]
227
+ revision = row["revision"]
228
+ try:
229
+ if row["op"] == projection_outbox.OP_DELETE:
230
+ self._remove(fact_id, graph, vector)
231
+ outcome = "removed"
232
+ else:
233
+ outcome = self._project(fact_id, graph, vector)
234
+ except Exception as exc:
235
+ attempts = projection_outbox.record_failure(self._db, fact_id, str(exc))
236
+ result.failed += 1
237
+ result.errors.append(f"{fact_id[:12]}: {exc}")
238
+ log = logger.warning if attempts >= LOUD_AFTER_ATTEMPTS else logger.debug
239
+ log(
240
+ "projection failed for %s after %d attempt(s): %s",
241
+ fact_id[:12], attempts, exc,
242
+ )
243
+ return
244
+
245
+ if projection_outbox.resolve(self._db, fact_id, revision):
246
+ setattr(result, outcome, getattr(result, outcome) + 1)
247
+ else:
248
+ # The fact was written again while this projection was in flight,
249
+ # so a newer intent is queued. Counting it as done would report
250
+ # work that still has to happen.
251
+ result.superseded += 1
252
+
253
+ # ------------------------------------------------------------------
254
+ # The projections themselves
255
+ # ------------------------------------------------------------------
256
+
257
+ def _project(self, fact_id: str, graph: Any, vector: Any) -> str:
258
+ """Bring one fact's projection up to date. Returns the outcome name."""
259
+ state = self._visibility(fact_id)
260
+ if state == "absent":
261
+ # An entity id, or a fact hard-deleted since it was queued. There
262
+ # is nothing to project and nothing to remove.
263
+ return "skipped"
264
+ if state == "hidden":
265
+ # Archived or withheld. It must stop being offered as a candidate —
266
+ # but its EDGES stay. The SQLite entity map filters on visibility
267
+ # and its edge walk does not, so deleting a hidden fact's edges
268
+ # would leave every visible fact that neighboured it with a smaller
269
+ # adjacency here than in SQLite, and the two graphs would answer
270
+ # differently. Measured on a real store before this was split: 32
271
+ # visible facts had lost edges, every missing endpoint quarantined.
272
+ self._remove(fact_id, graph, vector)
273
+ return "removed"
274
+
275
+ fact = self._db.get_fact(fact_id)
276
+ if fact is None:
277
+ return "skipped"
278
+ if graph is not None:
279
+ self._project_graph(fact, graph)
280
+ if vector is not None:
281
+ self._project_vector(fact, vector)
282
+ return "projected"
283
+
284
+ def _visibility(self, fact_id: str) -> str:
285
+ """``visible``, ``hidden`` or ``absent`` for one id."""
286
+ rows = self._db.execute(
287
+ "SELECT 1 FROM atomic_facts WHERE fact_id = ?", (fact_id,),
288
+ )
289
+ if not rows:
290
+ return "absent"
291
+ visible = self._db.execute(
292
+ "SELECT 1 FROM atomic_facts WHERE fact_id = ?"
293
+ + self._db.visible_fact_clause(),
294
+ (fact_id,),
295
+ )
296
+ return "visible" if visible else "hidden"
297
+
298
+ def _project_graph(self, fact: Any, graph: Any) -> None:
299
+ """Replace this fact's node, entity bridge and edges in the graph.
300
+
301
+ ``remove_fact`` first, so a re-projection cannot leave an entity link
302
+ or an edge that SQLite no longer has. Replace-then-write is what makes
303
+ a replay idempotent.
304
+ """
305
+ profile_id = getattr(fact, "profile_id", "default") or "default"
306
+ graph.remove_fact(fact.fact_id)
307
+ entities = list(getattr(fact, "canonical_entities", []) or [])
308
+ for entity_id in entities:
309
+ rows = self._db.execute(
310
+ "SELECT canonical_name, entity_type, fact_count FROM canonical_entities "
311
+ "WHERE entity_id = ? AND profile_id = ?",
312
+ (entity_id, profile_id),
313
+ )
314
+ if not rows:
315
+ continue
316
+ entity = dict(rows[0])
317
+ graph.add_entity(
318
+ entity_id,
319
+ entity.get("canonical_name") or entity_id,
320
+ entity.get("entity_type") or "concept",
321
+ {"fact_count": int(entity.get("fact_count") or 0)},
322
+ profile_id,
323
+ )
324
+ graph.add_fact_entities(fact.fact_id, entities, profile_id)
325
+ for row in self._db.execute(
326
+ "SELECT source_id, target_id, edge_type, weight FROM graph_edges "
327
+ "WHERE profile_id = ? AND (source_id = ? OR target_id = ?)",
328
+ (profile_id, fact.fact_id, fact.fact_id),
329
+ ):
330
+ edge = dict(row)
331
+ graph.add_edge(
332
+ edge["source_id"], edge["target_id"],
333
+ edge.get("edge_type") or "related",
334
+ float(edge.get("weight") or 1.0), profile_id=profile_id,
335
+ )
336
+
337
+ def _project_vector(self, fact: Any, vector: Any) -> None:
338
+ """Write this fact's embedding to the vector store.
339
+
340
+ A fact with no embedding yet is not an error: ingestion is
341
+ queryable-first, so the vector arrives with enrichment and the update
342
+ that writes it queues the fact again.
343
+ """
344
+ embedding = getattr(fact, "embedding", None)
345
+ if not embedding:
346
+ return
347
+ lifecycle = getattr(fact, "lifecycle", None)
348
+ tier = getattr(lifecycle, "value", lifecycle) or "active"
349
+ vector.add_vectors(
350
+ [fact.fact_id], [embedding], [tier],
351
+ getattr(fact, "profile_id", "default") or "default",
352
+ )
353
+
354
+ def _remove(self, fact_id: str, graph: Any, vector: Any) -> None:
355
+ """Take one fact out of both projections entirely, edges included.
356
+
357
+ For a fact that is genuinely gone. Its edges go too, because a hard
358
+ delete cascades them out of SQLite as well, so keeping them here would
359
+ be the projection holding an adjacency the store no longer has.
360
+ """
361
+ if graph is not None:
362
+ graph.remove_fact(fact_id)
363
+ if vector is not None:
364
+ vector.remove_vector(fact_id)
365
+
366
+ def _withdraw_candidacy(self, fact_id: str, graph: Any, vector: Any) -> None:
367
+ """Stop offering a fact, without changing the graph's shape.
368
+
369
+ For a fact that still exists but may no longer be returned. It leaves
370
+ the entity bridge and the vector — the two things that put a fact into a
371
+ result set — and leaves its edges alone, because those are still in
372
+ ``graph_edges`` and the walk this projection replaces still follows them.
373
+ """
374
+ if graph is not None:
375
+ withdraw = getattr(graph, "remove_fact_candidacy", None)
376
+ # A backend without the narrower call is better served by the full
377
+ # removal than by silently leaving a withheld fact recallable.
378
+ (withdraw or graph.remove_fact)(fact_id)
379
+ if vector is not None:
380
+ vector.remove_vector(fact_id)