superlocalmemory 4.0.9 → 4.1.0

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 (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +7 -7
  4. package/package.json +4 -2
  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 +308 -20
  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 +26 -4
  44. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  45. package/src/superlocalmemory/code_graph/database.py +44 -0
  46. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  47. package/src/superlocalmemory/core/admission.py +231 -11
  48. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  49. package/src/superlocalmemory/core/config.py +90 -11
  50. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  51. package/src/superlocalmemory/core/engine.py +140 -11
  52. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  53. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  54. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  55. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  56. package/src/superlocalmemory/core/maintenance.py +44 -6
  57. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  58. package/src/superlocalmemory/core/memory_health.py +266 -0
  59. package/src/superlocalmemory/core/mode_capability.py +111 -0
  60. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  61. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  62. package/src/superlocalmemory/core/operation_request.py +1 -1
  63. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  64. package/src/superlocalmemory/core/projection_drain.py +380 -0
  65. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  66. package/src/superlocalmemory/core/recall_worker.py +6 -3
  67. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  68. package/src/superlocalmemory/core/scale_engine.py +16 -2
  69. package/src/superlocalmemory/core/score_contract.py +21 -1
  70. package/src/superlocalmemory/core/session_identity.py +85 -0
  71. package/src/superlocalmemory/core/status_contract.py +108 -0
  72. package/src/superlocalmemory/core/store_pipeline.py +78 -3
  73. package/src/superlocalmemory/core/worker_pool.py +4 -4
  74. package/src/superlocalmemory/core/working_memory.py +288 -0
  75. package/src/superlocalmemory/encoding/cognitive_consolidator.py +51 -7
  76. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  77. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  78. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  79. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  80. package/src/superlocalmemory/encoding/type_router.py +12 -12
  81. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  82. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  83. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  84. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  85. package/src/superlocalmemory/learning/bandit.py +43 -0
  86. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  87. package/src/superlocalmemory/learning/database.py +60 -3
  88. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  89. package/src/superlocalmemory/learning/feedback.py +3 -1
  90. package/src/superlocalmemory/learning/outcomes.py +47 -16
  91. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  92. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  93. package/src/superlocalmemory/learning/pcos.py +291 -0
  94. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  95. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  96. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  97. package/src/superlocalmemory/mcp/profiles.py +14 -2
  98. package/src/superlocalmemory/mcp/server.py +1 -1
  99. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  100. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  101. package/src/superlocalmemory/mcp/tools_core.py +71 -42
  102. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  103. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  104. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  105. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  106. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -11
  107. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  108. package/src/superlocalmemory/retrieval/engine.py +106 -11
  109. package/src/superlocalmemory/retrieval/entity_channel.py +217 -257
  110. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  111. package/src/superlocalmemory/retrieval/scope_policy.py +42 -1
  112. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  113. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  114. package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
  115. package/src/superlocalmemory/retrieval/vector_store.py +63 -0
  116. package/src/superlocalmemory/server/api.py +26 -2
  117. package/src/superlocalmemory/server/asset_versions.py +171 -0
  118. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  119. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  120. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  121. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  122. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  123. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  124. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  125. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  126. package/src/superlocalmemory/server/routes/entity.py +13 -1
  127. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  128. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  129. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  130. package/src/superlocalmemory/server/routes/memories.py +49 -7
  131. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  132. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  133. package/src/superlocalmemory/server/routes/v3_api.py +193 -17
  134. package/src/superlocalmemory/server/ui.py +24 -1
  135. package/src/superlocalmemory/server/unified_daemon.py +292 -9
  136. package/src/superlocalmemory/storage/_migration_internals.py +35 -0
  137. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  138. package/src/superlocalmemory/storage/database.py +598 -82
  139. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  140. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  141. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  142. package/src/superlocalmemory/storage/migration_runner.py +130 -0
  143. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  144. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  145. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  146. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  147. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  148. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  149. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  150. package/src/superlocalmemory/storage/migrations.py +18 -2
  151. package/src/superlocalmemory/storage/models.py +40 -1
  152. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  153. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  154. package/src/superlocalmemory/storage/schema.py +110 -1
  155. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  156. package/src/superlocalmemory/summaries/base.py +1 -1
  157. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  158. package/src/superlocalmemory/trust/scorer.py +43 -1
  159. package/src/superlocalmemory/ui/index.html +10 -19
  160. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  161. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  162. package/src/superlocalmemory/ui/js/od-memories.js +209 -1
  163. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
  164. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  165. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -281,7 +281,7 @@ class OperationPolicyRegistry:
281
281
  allowed_transports=_ALL_TRANSPORTS,
282
282
  audit_level="full",
283
283
  ),
284
- # Operational recovery & admin remediation (Wave-3 resilience slice)
284
+ # Operational recovery & admin remediation (resilience slice)
285
285
  # OPS_INSPECT: read-only listing of failed/stuck/degraded ops.
286
286
  # Allowed over all transports so dashboard, MCP, and CLI all work.
287
287
  OperationKind.OPS_INSPECT: OperationPolicy(
@@ -51,7 +51,7 @@ class OperationKind(str, Enum):
51
51
  SCHEMA_MIGRATE = "schema_migrate"
52
52
  VECTOR_MIGRATE = "vector_migrate"
53
53
  EVOLVE_SKILL = "evolve_skill"
54
- # Operational recovery & admin remediation (V4 Wave-3 resilience slice)
54
+ # Operational recovery & admin remediation (V4 resilience slice)
55
55
  OPS_INSPECT = "ops_inspect" # List failed/stuck/degraded operations (OWNER/ADMIN)
56
56
  OPS_RESOLVE = "ops_resolve" # Retry/force-reconcile/cancel an operation (OWNER/ADMIN)
57
57
 
@@ -1,7 +1,7 @@
1
1
  # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
3
 
4
- """Operational Recovery & Admin Remediation helpers (Wave-3 resilience slice).
4
+ """Operational Recovery & Admin Remediation helpers (resilience slice).
5
5
 
6
6
  Provides two primary functions used by HTTP endpoints, MCP tools, and CLI:
7
7
 
@@ -22,7 +22,7 @@ Design constraints (NON-NEGOTIABLE):
22
22
  - Additive & backward-compatible — healthy-path unaffected
23
23
  - Immutable return dicts; explicit error handling; no silent swallowing
24
24
 
25
- Part of SuperLocalMemory V4 | Wave-3: Operational Recovery
25
+ Part of SuperLocalMemory V4 | Operational Recovery
26
26
  """
27
27
 
28
28
  from __future__ import annotations
@@ -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)