superlocalmemory 4.0.8 → 4.0.10

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 (147) hide show
  1. package/CHANGELOG.md +221 -1
  2. package/README.md +6 -6
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/scripts/ensure-venv.sh +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 +1 -1
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +1 -1
  19. package/plugin/skills/slm-recall/SKILL.md +1 -1
  20. package/plugin/skills/slm-remember/SKILL.md +1 -1
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +1 -1
  23. package/plugin/skills/slm-status/SKILL.md +3 -3
  24. package/plugin-src/rules/AGENTS.md +1 -1
  25. package/plugin-src/skills/slm-status/SKILL.md +2 -2
  26. package/pyproject.toml +1 -1
  27. package/scripts/postinstall.js +4 -0
  28. package/src/superlocalmemory/__init__.py +1 -1
  29. package/src/superlocalmemory/cli/_lazy_init.py +1 -1
  30. package/src/superlocalmemory/cli/commands.py +164 -11
  31. package/src/superlocalmemory/cli/db_migrate.py +0 -2
  32. package/src/superlocalmemory/cli/gdpr_io.py +1 -1
  33. package/src/superlocalmemory/cli/main.py +7 -7
  34. package/src/superlocalmemory/cli/service_installer.py +2 -1
  35. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  36. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  37. package/src/superlocalmemory/core/config.py +41 -7
  38. package/src/superlocalmemory/core/context_cache.py +0 -2
  39. package/src/superlocalmemory/core/engine.py +371 -63
  40. package/src/superlocalmemory/core/evidence_bundle.py +3 -1
  41. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  42. package/src/superlocalmemory/core/install_detector.py +131 -0
  43. package/src/superlocalmemory/core/maintenance.py +44 -6
  44. package/src/superlocalmemory/core/memory_health.py +266 -0
  45. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  46. package/src/superlocalmemory/core/operation_request.py +1 -1
  47. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  48. package/src/superlocalmemory/core/progressive_abstraction.py +1 -1
  49. package/src/superlocalmemory/core/security_primitives.py +3 -6
  50. package/src/superlocalmemory/core/store_pipeline.py +172 -29
  51. package/src/superlocalmemory/core/topic_signature.py +0 -2
  52. package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
  53. package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
  54. package/src/superlocalmemory/encoding/cognitive_consolidator.py +15 -1
  55. package/src/superlocalmemory/encoding/graph_builder.py +2 -2
  56. package/src/superlocalmemory/encoding/scene_builder.py +8 -2
  57. package/src/superlocalmemory/hooks/adapter_base.py +0 -2
  58. package/src/superlocalmemory/hooks/context_payload.py +0 -2
  59. package/src/superlocalmemory/hooks/portable_kit.py +8 -8
  60. package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
  61. package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
  62. package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
  63. package/src/superlocalmemory/infra/backup.py +44 -8
  64. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
  65. package/src/superlocalmemory/learning/arm_catalog.py +0 -2
  66. package/src/superlocalmemory/learning/bandit.py +0 -2
  67. package/src/superlocalmemory/learning/bandit_cache.py +0 -2
  68. package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
  69. package/src/superlocalmemory/learning/ensemble.py +0 -2
  70. package/src/superlocalmemory/learning/labeler.py +0 -2
  71. package/src/superlocalmemory/learning/legacy_migration.py +0 -2
  72. package/src/superlocalmemory/learning/model_cache.py +0 -2
  73. package/src/superlocalmemory/learning/ranker.py +0 -2
  74. package/src/superlocalmemory/learning/reward_archive.py +6 -1
  75. package/src/superlocalmemory/learning/reward_proxy.py +0 -2
  76. package/src/superlocalmemory/learning/signal_worker.py +0 -2
  77. package/src/superlocalmemory/math/fisher.py +1 -1
  78. package/src/superlocalmemory/math/hopfield.py +4 -1
  79. package/src/superlocalmemory/math/langevin.py +1 -1
  80. package/src/superlocalmemory/math/sheaf.py +7 -3
  81. package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
  82. package/src/superlocalmemory/mcp/profiles.py +1 -1
  83. package/src/superlocalmemory/mcp/server.py +2 -2
  84. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  85. package/src/superlocalmemory/mcp/tools_active.py +56 -0
  86. package/src/superlocalmemory/mcp/tools_core.py +41 -40
  87. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  88. package/src/superlocalmemory/optimize/cache/manager.py +2 -2
  89. package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
  90. package/src/superlocalmemory/optimize/compress/router.py +1 -1
  91. package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
  92. package/src/superlocalmemory/optimize/proxy/server.py +1 -1
  93. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
  94. package/src/superlocalmemory/optimize/storage/db.py +2 -2
  95. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  96. package/src/superlocalmemory/retrieval/ann_index.py +9 -2
  97. package/src/superlocalmemory/retrieval/bm25_channel.py +6 -10
  98. package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
  99. package/src/superlocalmemory/retrieval/engine.py +272 -43
  100. package/src/superlocalmemory/retrieval/entity_channel.py +8 -2
  101. package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
  102. package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
  103. package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
  104. package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
  105. package/src/superlocalmemory/retrieval/reranker.py +3 -3
  106. package/src/superlocalmemory/retrieval/scope_policy.py +22 -1
  107. package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
  108. package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
  109. package/src/superlocalmemory/retrieval/strategy.py +94 -0
  110. package/src/superlocalmemory/retrieval/temporal_channel.py +179 -10
  111. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
  112. package/src/superlocalmemory/retrieval/vector_store.py +151 -10
  113. package/src/superlocalmemory/server/api.py +6 -1
  114. package/src/superlocalmemory/server/asset_versions.py +171 -0
  115. package/src/superlocalmemory/server/recall_serializer.py +10 -0
  116. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  117. package/src/superlocalmemory/server/routes/brain.py +2 -2
  118. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  119. package/src/superlocalmemory/server/routes/entity.py +13 -1
  120. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  121. package/src/superlocalmemory/server/routes/prewarm.py +4 -4
  122. package/src/superlocalmemory/server/routes/v3_api.py +2 -2
  123. package/src/superlocalmemory/server/ui.py +8 -1
  124. package/src/superlocalmemory/server/unified_daemon.py +570 -16
  125. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  126. package/src/superlocalmemory/storage/_schema_version.py +46 -3
  127. package/src/superlocalmemory/storage/backup.py +531 -0
  128. package/src/superlocalmemory/storage/database.py +139 -34
  129. package/src/superlocalmemory/storage/embedding_codec.py +129 -0
  130. package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
  131. package/src/superlocalmemory/storage/migration_runner.py +153 -2
  132. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  133. package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
  134. package/src/superlocalmemory/storage/migrations.py +15 -1
  135. package/src/superlocalmemory/storage/models.py +7 -0
  136. package/src/superlocalmemory/storage/quantized_store.py +4 -2
  137. package/src/superlocalmemory/storage/schema.py +98 -0
  138. package/src/superlocalmemory/summaries/base.py +1 -1
  139. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  140. package/src/superlocalmemory/summaries/session_summary.py +1 -1
  141. package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
  142. package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
  143. package/src/superlocalmemory/ui/index.html +1 -1
  144. package/src/superlocalmemory/ui/js/core.js +1 -1
  145. package/src/superlocalmemory/ui/js/od-brain.js +1 -1
  146. package/src/superlocalmemory/ui/js/od-memories.js +190 -1
  147. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
@@ -118,6 +118,54 @@ def _record_correction_candidate(
118
118
  logger.warning("Correction candidate not recorded for %s: %s", successor_fact_id, exc)
119
119
 
120
120
 
121
+ #: Entity id pattern for the per-profile placeholder a dated fact with no
122
+ #: resolved entity points its temporal event at. Per profile, not shared, so
123
+ #: profile deletion cascades it away with everything else.
124
+ _UNRESOLVED_ENTITY_PREFIX = "__slm_unresolved__:"
125
+
126
+
127
+ def _ensure_unresolved_entity(db: Any, profile_id: str) -> str | None:
128
+ """Return the placeholder entity id for ``profile_id``, creating it once.
129
+
130
+ ``temporal_events.entity_id`` is NOT NULL with a foreign key to
131
+ ``canonical_entities``, so an event for a fact with no recognised entity
132
+ needs something real to reference. This is that something.
133
+
134
+ It is deliberately inert as an entity:
135
+
136
+ * Its ``canonical_name`` is empty, and the only query that resolves an
137
+ entity by name (``retrieval/temporal_channel.py``, the "events for
138
+ entity X" lookup) is driven by names extracted from the user's
139
+ question, which are never empty.
140
+ * Nothing derives entity links from ``temporal_events``. The entity
141
+ channel builds its map from ``atomic_facts.canonical_entities_json``,
142
+ which this does not touch — so the placeholder cannot become the kind
143
+ of entity that links a quarter of the store and flattens entity
144
+ proximity. That failure mode is real (on the author's store 'State'
145
+ links 1,388 facts) and this is specifically shaped not to add to it.
146
+ * ``fact_count`` stays 0. It is a hook for a foreign key, not a concept.
147
+
148
+ Returns None if the row cannot be created, in which case the caller skips
149
+ the temporal event rather than failing the write — a missing temporal row
150
+ is a degraded memory, an aborted remember is a lost one.
151
+ """
152
+ entity_id = f"{_UNRESOLVED_ENTITY_PREFIX}{profile_id}"
153
+ try:
154
+ db.execute(
155
+ "INSERT OR IGNORE INTO canonical_entities "
156
+ "(entity_id, profile_id, canonical_name, entity_type, fact_count) "
157
+ "VALUES (?, ?, '', 'unresolved', 0)",
158
+ (entity_id, profile_id),
159
+ )
160
+ return entity_id
161
+ except Exception as exc: # noqa: BLE001 — never fail a write over this
162
+ logger.debug(
163
+ "temporal placeholder entity unavailable for %s: %s",
164
+ profile_id, exc,
165
+ )
166
+ return None
167
+
168
+
121
169
  def _record_fact_entity_association(
122
170
  db: DatabaseManager,
123
171
  *,
@@ -311,8 +359,13 @@ def enrich_fact(
311
359
  # Vector dual-write helper (P1-2 / embeddings-vector-01)
312
360
  # ---------------------------------------------------------------------------
313
361
 
314
- def _upsert_fact_vectors(fact, profile_id, ann_index, vector_store, embedder=None):
315
- """Dual-write a fact's embedding to the ANN index + sqlite-vec store.
362
+ def _upsert_fact_vectors(fact, profile_id, ann_index, vector_store, embedder=None) -> bool:
363
+ """Dual-write a fact's embedding to the sqlite-vec projection and the ANN index.
364
+
365
+ Write order mirrors _attach_vector: the sqlite-vec projection is attempted
366
+ first because that is what a meaning-based search reads. The ANN index is
367
+ updated only when the projection accepts the vector. Returns True when the
368
+ fact is now findable by meaning (the projection accepted the vector).
316
369
 
317
370
  Embeds on-demand when the fact has no embedding (e.g. consolidated
318
371
  summary facts created without one), so UPDATE/SUPERSEDE and consolidated
@@ -325,18 +378,44 @@ def _upsert_fact_vectors(fact, profile_id, ann_index, vector_store, embedder=Non
325
378
  except Exception as _emb_exc: # pragma: no cover - defensive
326
379
  _reraise_materialization_deferral(_emb_exc)
327
380
  logger.debug("on-demand embed failed for %s: %s", fact.fact_id, _emb_exc)
328
- return
381
+ return False
329
382
  if not getattr(fact, "embedding", None):
330
- return
331
- if ann_index:
332
- ann_index.add(fact.fact_id, fact.embedding)
333
- # V3.2: VectorStore upsert (sqlite-vec) -- dual-write (Rule 12)
383
+ return False
384
+
385
+ projected = False
386
+ # Projection first: what a meaning-based search reads.
334
387
  if vector_store and getattr(vector_store, "available", False):
335
- vector_store.upsert(
336
- fact_id=fact.fact_id,
337
- profile_id=profile_id,
338
- embedding=fact.embedding,
339
- )
388
+ try:
389
+ projected = bool(vector_store.upsert(
390
+ fact_id=fact.fact_id,
391
+ profile_id=profile_id,
392
+ embedding=fact.embedding,
393
+ ))
394
+ if not projected:
395
+ logger.warning(
396
+ "vector projection refused for %s — stored but not "
397
+ "findable by meaning", fact.fact_id[:12],
398
+ )
399
+ except Exception as _vs_exc:
400
+ logger.warning(
401
+ "vector projection failed for %s (%s: %s) — stored but not "
402
+ "findable by meaning",
403
+ fact.fact_id[:12], type(_vs_exc).__name__, _vs_exc,
404
+ )
405
+
406
+ # ANN index only when the projection accepted the vector so both
407
+ # representations stay consistent. Writing ANN without a vector-store
408
+ # entry creates a ghost that disappears on restart (ANN is rebuilt from
409
+ # the vector store at startup).
410
+ if projected and ann_index:
411
+ try:
412
+ ann_index.add(fact.fact_id, fact.embedding)
413
+ except Exception:
414
+ logger.warning(
415
+ "in-memory index rejected %s", fact.fact_id[:12], exc_info=True,
416
+ )
417
+
418
+ return projected
340
419
 
341
420
 
342
421
  class _TombstoneReadError(Exception):
@@ -741,6 +820,15 @@ def run_store(
741
820
  materialization_progress["relational_started"] = True
742
821
 
743
822
  is_queryable_promotion = fact.fact_id in queryable_ids
823
+ # When a promoted fact is being materialised the embedding column is
824
+ # written in a separate step AFTER the projection attempt so that the
825
+ # two representations stay in the correct order: projection first,
826
+ # canonical column second (mirroring _attach_vector's invariant).
827
+ # Writing the column before the projection and then failing the
828
+ # projection leaves the fact with embedding IS NOT NULL but no entry
829
+ # in fact_embeddings — invisible to meaning-search and invisible to
830
+ # every repair pass that selects WHERE embedding IS NULL.
831
+ _deferred_canonical_embedding = None # set below if promotion path taken
744
832
  if is_queryable_promotion:
745
833
  db.update_fact(fact.fact_id, {
746
834
  "content": fact.content,
@@ -757,15 +845,22 @@ def run_store(
757
845
  "access_count": fact.access_count,
758
846
  "source_turn_ids_json": fact.source_turn_ids,
759
847
  "session_id": fact.session_id,
760
- "embedding": fact.embedding,
761
- "fisher_mean": fact.fisher_mean,
762
- "fisher_variance": fact.fisher_variance,
848
+ # embedding / fisher fields omitted here — written after
849
+ # the projection attempt below so canonical is never ahead
850
+ # of the projection.
763
851
  "lifecycle": fact.lifecycle,
764
852
  "langevin_position": fact.langevin_position,
765
853
  "emotional_valence": fact.emotional_valence,
766
854
  "emotional_arousal": fact.emotional_arousal,
767
855
  "signal_type": fact.signal_type,
768
856
  })
857
+ if fact.embedding:
858
+ _deferred_canonical_embedding = (
859
+ fact.fact_id,
860
+ fact.embedding,
861
+ fact.fisher_mean,
862
+ fact.fisher_variance,
863
+ )
769
864
  if consolidator:
770
865
  try:
771
866
  action = consolidator.consolidate(
@@ -793,6 +888,9 @@ def run_store(
793
888
  target_id = action.existing_fact_id
794
889
  if is_queryable_promotion and target_id:
795
890
  db.delete_fact(fact.fact_id)
891
+ # The original promoted fact was deleted; its deferred
892
+ # canonical embedding write must not happen.
893
+ _deferred_canonical_embedding = None
796
894
  existing_fact = db.get_fact(target_id) if target_id else None
797
895
  if existing_fact is None:
798
896
  continue
@@ -838,9 +936,31 @@ def run_store(
838
936
  if materialization_progress is not None:
839
937
  materialization_progress["fact_ids"] = tuple(stored_ids)
840
938
 
841
- # Dual-write embedding to ANN index + vector store (embed on-demand if
842
- # a consolidated ADD fact arrived without one). See _upsert_fact_vectors.
939
+ # Projection first (vector store then ANN index, on-demand embed for
940
+ # consolidated facts that arrived without one).
843
941
  _upsert_fact_vectors(fact, profile_id, ann_index, vector_store, embedder)
942
+
943
+ # Canonical embedding write: deferred to here from the queryable-
944
+ # promotion block above so the projection is always attempted first.
945
+ # The column is written regardless of whether the projection accepted
946
+ # the vector (the vector is real data; withholding it would force
947
+ # every repair pass to re-compute a model call that the same
948
+ # installation condition would refuse again anyway).
949
+ if _deferred_canonical_embedding is not None:
950
+ _dc_fid, _dc_emb, _dc_fmean, _dc_fvar = _deferred_canonical_embedding
951
+ try:
952
+ db.update_fact(_dc_fid, {
953
+ "embedding": _dc_emb,
954
+ "fisher_mean": _dc_fmean,
955
+ "fisher_variance": _dc_fvar,
956
+ })
957
+ except Exception as _dc_exc:
958
+ logger.warning(
959
+ "canonical embedding write failed for %s: %s",
960
+ _dc_fid[:12], _dc_exc,
961
+ )
962
+ _deferred_canonical_embedding = None
963
+
844
964
  # Phase 2: Generate contextual description (after consolidator, before graph_builder)
845
965
  if context_generator:
846
966
  try:
@@ -959,12 +1079,39 @@ def run_store(
959
1079
  if scene_builder:
960
1080
  scene_builder.assign_to_scene(fact, profile_id)
961
1081
 
962
- # Populate temporal_events for temporal retrieval
1082
+ # Populate temporal_events for temporal retrieval.
1083
+ #
1084
+ # A DATE IS A DATE WHETHER OR NOT AN ENTITY WAS RESOLVED. This used to
1085
+ # read `if fact.canonical_entities and has_dates`, so a fact carrying a
1086
+ # perfectly good date but no recognised entity got no temporal row at
1087
+ # all. Measured on the author's store: 967 of 3,894 genuine facts have
1088
+ # no canonical entity (24.8%), and 958 of those have no temporal event
1089
+ # either — a quarter of the store that the temporal channel could only
1090
+ # reach through its created_at recency fallback, i.e. by being recent
1091
+ # rather than by being about the right time.
1092
+ #
1093
+ # The date-window query does not need the entity: it joins
1094
+ # temporal_events to atomic_facts and nothing else
1095
+ # (retrieval/temporal_channel.py). Only the "events for entity X"
1096
+ # lookup joins canonical_entities, and that lookup is correctly
1097
+ # uninterested in a fact with no entity.
1098
+ #
1099
+ # temporal_events.entity_id is NOT NULL with an FK to
1100
+ # canonical_entities, so an entity-less event needs a row to point at.
1101
+ # It gets a per-profile sentinel rather than a shared one, and the
1102
+ # sentinel is invisible to ranking: the entity channel builds its map
1103
+ # from atomic_facts.canonical_entities_json, which is untouched here, so
1104
+ # this cannot create the kind of entity that links a quarter of the
1105
+ # store and destroys entity proximity.
963
1106
  has_dates = (fact.observation_date or fact.referenced_date
964
1107
  or fact.interval_start)
965
- if fact.canonical_entities and has_dates:
1108
+ if has_dates:
966
1109
  from superlocalmemory.storage.models import TemporalEvent
967
- for eid in fact.canonical_entities:
1110
+ entity_ids = list(fact.canonical_entities)
1111
+ if not entity_ids:
1112
+ sentinel = _ensure_unresolved_entity(db, profile_id)
1113
+ entity_ids = [sentinel] if sentinel else []
1114
+ for eid in entity_ids:
968
1115
  event = TemporalEvent(
969
1116
  event_id=_ingestion_effect_id(
970
1117
  ingestion_operation_id,
@@ -1139,15 +1286,11 @@ def run_store_fact_direct(
1139
1286
  )
1140
1287
  fact.canonical_entities = list(canonical.values())
1141
1288
  db.store_fact(fact)
1142
- if fact.embedding and ann_index:
1143
- ann_index.add(fact.fact_id, fact.embedding)
1144
- # V3.2: VectorStore upsert (dual-write)
1145
- if fact.embedding and vector_store and vector_store.available:
1146
- vector_store.upsert(
1147
- fact_id=fact.fact_id,
1148
- profile_id=profile_id,
1149
- embedding=fact.embedding,
1150
- )
1289
+ # Projection first (vector store), ANN only on success — matching
1290
+ # _attach_vector's invariant. The return value (projected) is not used
1291
+ # here because run_store_fact_direct is a fire-and-forget path with no
1292
+ # receipt to update; the caller is responsible for any enrichment status.
1293
+ _upsert_fact_vectors(fact, profile_id, ann_index, vector_store)
1151
1294
  if graph_builder:
1152
1295
  graph_builder.build_edges(fact, profile_id)
1153
1296
  # The graph projection must run after GraphBuilder: syncing immediately
@@ -4,8 +4,6 @@
4
4
 
5
5
  """Topic signature — deterministic, Unicode-safe, ReDoS-resistant 16-char hex.
6
6
 
7
- LLD reference: `.backup/active-brain/lld/LLD-01-context-cache-and-hot-path-hooks.md`
8
- Section 4.2.
9
7
 
10
8
  Hot-path contract:
11
9
  - stdlib-only imports (no third-party packages).
@@ -15,6 +15,7 @@ from superlocalmemory.core.transactions.owners import (
15
15
  OwnerResult,
16
16
  )
17
17
  from superlocalmemory.core.transactions.service import MemoryTransactionService
18
+ from superlocalmemory.storage.embedding_codec import decode_embedding
18
19
 
19
20
  REQUIRED_ADMISSION_OWNERS: tuple[str, ...] = ("bm25", "temporal", "vector")
20
21
 
@@ -438,10 +439,16 @@ class VectorOwner(_FactScopedOwner):
438
439
  for row in rows:
439
440
  record = dict(row)
440
441
  fid = record.get("fact_id")
441
- embedding = record.get("embedding")
442
- if fid is None or not embedding:
442
+ raw_embedding = record.get("embedding")
443
+ if fid is None or not raw_embedding:
443
444
  continue
444
- result[fid] = _fingerprint("vector", str(embedding))
445
+ # Decode so TEXT and BLOB rows produce the same fingerprint for the
446
+ # same embedding values. A ValueError here (corrupt buffer) must
447
+ # propagate — a bad fingerprint is worse than no fingerprint.
448
+ decoded = decode_embedding(raw_embedding, fact_id=fid)
449
+ if decoded is None:
450
+ continue
451
+ result[fid] = _fingerprint("vector", str(decoded))
445
452
  return result
446
453
 
447
454
  def _table_present(
@@ -496,11 +503,11 @@ class VectorOwner(_FactScopedOwner):
496
503
  raw = dict(rows[0]).get("embedding")
497
504
  if not raw:
498
505
  return False
499
- try:
500
- embedding = json.loads(raw)
501
- except (TypeError, json.JSONDecodeError):
502
- return False
503
- if not isinstance(embedding, list) or not embedding:
506
+ # decode_embedding accepts both TEXT (JSON) and BLOB (float32 binary);
507
+ # ValueError for corrupt data propagates — a heal that silently
508
+ # returns False on corruption is indistinguishable from "not present".
509
+ embedding = decode_embedding(raw, fact_id=fact_id)
510
+ if not embedding:
504
511
  return False
505
512
  ok = self._vector_store.upsert(
506
513
  fact_id=fact_id, profile_id=context.profile_id, embedding=embedding,
@@ -29,12 +29,14 @@ from __future__ import annotations
29
29
 
30
30
  import json
31
31
  import logging
32
+ import sqlite3
32
33
  from typing import TYPE_CHECKING
33
34
 
34
35
  import numpy as np
35
36
  from numpy.typing import NDArray
36
37
 
37
38
  from superlocalmemory.core.config import QuantizationConfig
39
+ from superlocalmemory.storage.embedding_codec import decode_embedding
38
40
 
39
41
  if TYPE_CHECKING:
40
42
  from superlocalmemory.math.ebbinghaus import EbbinghausCurve
@@ -262,7 +264,13 @@ class EAPScheduler:
262
264
  Tries embedding_metadata -> fact_embeddings (vec0 table).
263
265
  Falls back to atomic_facts.embedding JSON column.
264
266
  """
265
- # Try atomic_facts.embedding (JSON column)
267
+ # Try atomic_facts.embedding (TEXT JSON or BLOB float32).
268
+ # Two-stage error handling:
269
+ # - DB query failures (any exception from execute) → log + return None.
270
+ # - Format errors from decode_embedding (ValueError) → propagate.
271
+ # A corrupt embedding must NOT be silently collapsed to None because
272
+ # the caller cannot distinguish "embedding absent" from "data lost".
273
+ raw = None
266
274
  try:
267
275
  rows = self._db.execute(
268
276
  "SELECT embedding FROM atomic_facts WHERE fact_id = ?",
@@ -270,14 +278,17 @@ class EAPScheduler:
270
278
  )
271
279
  if rows:
272
280
  raw = dict(rows[0]).get("embedding")
273
- if raw and raw != "null":
274
- data = json.loads(raw) if isinstance(raw, str) else raw
275
- if data:
276
- return np.array(data, dtype=np.float64)
277
- except Exception as exc:
281
+ except Exception as exc: # noqa: BLE001 — isolates DB failure from decode failure
278
282
  logger.debug(
279
283
  "Could not load embedding from atomic_facts for %s: %s",
280
284
  fact_id, exc,
281
285
  )
286
+ return None
287
+
288
+ if raw and raw != "null":
289
+ # ValueError from decode_embedding propagates intentionally.
290
+ vec = decode_embedding(raw, fact_id=fact_id)
291
+ if vec:
292
+ return np.array(vec, dtype=np.float64)
282
293
 
283
294
  return None
@@ -327,6 +327,17 @@ class CognitiveConsolidator:
327
327
  AND r.lifecycle_zone IN ('warm', 'cold')
328
328
  AND r.retention_score < ?
329
329
  AND f.lifecycle != 'forgotten'
330
+ -- Withheld rows are not candidates, and this is not tidiness.
331
+ -- 304 of them remain warm/cold in atomic_facts after 4.0.10
332
+ -- withholds them, and each still carries its cluster's POOLED
333
+ -- canonical_entities_json. CCQ clusters on entity overlap, so one
334
+ -- withheld summary naming ten entities joins a cluster of real
335
+ -- memories, contributes model prose to the gist, and then this
336
+ -- pass archives every source in the cluster -- including the real
337
+ -- memories, at scores M043's restore would not bring back.
338
+ -- Exactly the damage this release exists to stop, on a path the
339
+ -- release did not otherwise touch.
340
+ AND COALESCE(f.quarantined, 0) = 0
330
341
  AND f.fact_id NOT IN (
331
342
  SELECT je.value
332
343
  FROM ccq_consolidated_blocks ccb,
@@ -494,7 +505,10 @@ class CognitiveConsolidator:
494
505
  f"SELECT fact_id, content, importance, confidence, "
495
506
  f" canonical_entities_json "
496
507
  f"FROM atomic_facts "
497
- f"WHERE fact_id IN ({placeholders}) AND profile_id = ?",
508
+ f"WHERE fact_id IN ({placeholders}) AND profile_id = ? "
509
+ # Belt and braces with the identify query above: a cluster assembled
510
+ # before a row was withheld must not contribute its text to a gist.
511
+ f" AND COALESCE(quarantined, 0) = 0",
498
512
  (*cluster.fact_ids, profile_id),
499
513
  )
500
514
 
@@ -9,7 +9,7 @@ last 50 memories; this version searches ALL facts per canonical entity.
9
9
 
10
10
  Edge types: ENTITY (shared entity, weight 1.0), TEMPORAL (exp-decay,
11
11
  1-week window), SEMANTIC (ANN cosine > 0.7), CAUSAL (causal markers,
12
- weight 0.8). CONTRADICTION exposed for external Sheaf module (Wave 4).
12
+ weight 0.8). CONTRADICTION exposed for external Sheaf module (added in v3.5).
13
13
 
14
14
  Part of Qualixar | Author: Varun Pratap Bhardwaj
15
15
  License: AGPL-3.0-or-later
@@ -101,7 +101,7 @@ class GraphBuilder:
101
101
  self, fact_id_a: str, fact_id_b: str, profile_id: str,
102
102
  severity: float = 1.0,
103
103
  ) -> GraphEdge:
104
- """Add a contradiction edge. Called by Sheaf module (Wave 4)."""
104
+ """Add a contradiction edge. Called by the Sheaf module (introduced in v3.5)."""
105
105
  edge = GraphEdge(
106
106
  profile_id=profile_id,
107
107
  source_id=fact_id_a,
@@ -15,6 +15,8 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
15
15
  from __future__ import annotations
16
16
 
17
17
  import json
18
+
19
+ from superlocalmemory.storage.embedding_codec import decode_embedding
18
20
  import logging
19
21
  from datetime import UTC, datetime
20
22
 
@@ -357,8 +359,12 @@ class SceneBuilder:
357
359
  embedding = None
358
360
  if raw_embedding:
359
361
  try:
360
- embedding = json.loads(raw_embedding)
361
- except (TypeError, ValueError, json.JSONDecodeError):
362
+ embedding = decode_embedding(
363
+ raw_embedding, fact_id=str(data.get("scene_id", "<scene>")),
364
+ )
365
+ except ValueError:
366
+ # A malformed stored value, not an absent one. Scenes are an
367
+ # optimisation, so one bad row must not sink the batch.
362
368
  embedding = None
363
369
  result[str(data["scene_id"])] = embedding
364
370
  return result
@@ -4,8 +4,6 @@
4
4
 
5
5
  """Cross-platform adapter base — shared Protocol and atomic write primitive.
6
6
 
7
- LLD reference: ``.backup/active-brain/lld/LLD-05-cross-platform-adapters.md``
8
- Sections: 2 (component catalog), 4.4 (atomic write), 9.3 (sync log contract).
9
7
 
10
8
  Every adapter (Cursor, Antigravity, Copilot) implements the ``Adapter``
11
9
  Protocol. The shared ``_atomic_write`` primitive enforces the hard-rule
@@ -4,8 +4,6 @@
4
4
 
5
5
  """Shared content builder — single source for every adapter body.
6
6
 
7
- LLD reference: ``.backup/active-brain/lld/LLD-05-cross-platform-adapters.md``
8
- Section 3 (Content Builder). One builder → five formatters. Every string is
9
7
  passed through ``redact_secrets`` before entering the dataclass, so no
10
8
  adapter ever writes an unredacted secret.
11
9
 
@@ -2,16 +2,16 @@
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
3
  # Part of SuperLocalMemory V3 | https://qualixar.com
4
4
 
5
- """WP-08 portable kit — ``slm connect <ide>`` MCP-wiring.
5
+ """Portable kit — ``slm connect <ide>`` MCP-wiring.
6
6
 
7
7
  Writes SLM's MCP block into the target IDE config via MERGE-NOT-CLOBBER:
8
8
  - Only touches the ``superlocalmemory`` server key.
9
9
  - All other servers + top-level keys are preserved byte-for-byte.
10
10
  - Atomic write (.tmp + os.replace); aborts on parse error (file untouched).
11
- - claude-code is OUT: short-circuits to a WP-06 plugin pointer, no config written.
11
+ - claude-code is OUT: short-circuits to a plugin pointer, no config written.
12
12
  - AGENTS.md is appended with <!-- SLM-START/END --> markers (never overwrite).
13
13
 
14
- IDE_MATRIX verified against ide/configs/* templates (read-only, WP-04 owns).
14
+ IDE_MATRIX verified against ide/configs/* templates (read-only; configs are the authoritative source).
15
15
  """
16
16
 
17
17
  from __future__ import annotations
@@ -39,7 +39,7 @@ SLM_MARKER_END = "<!-- SLM-END -->"
39
39
  VALID_TRANSPORTS: frozenset[str] = frozenset({"stdio", "http", "http-mcp-remote"})
40
40
 
41
41
  CLAUDE_CODE_PLUGIN_POINTER = (
42
- "slm connect claude-code: Claude Code is configured via the SLM plugin (WP-06).\n"
42
+ "slm connect claude-code: Claude Code is configured via the SLM plugin (see plugin/ directory).\n"
43
43
  "Run: slm plugin install OR see plugin-src/ for manual installation.\n"
44
44
  "No MCP config file is written by this command."
45
45
  )
@@ -206,17 +206,17 @@ IDE_MATRIX: dict[str, IDEDescriptor] = {
206
206
  },
207
207
  caveats="contextProviders is a LIST; append+dedupe by serverName",
208
208
  ),
209
- # --- OUT: claude-code defers to WP-06 ---
209
+ # --- OUT: claude-code defers to the plugin installer ---
210
210
  "claude-code": IDEDescriptor(
211
211
  ide_id="claude-code",
212
- display="Claude Code (WP-06 plugin)",
212
+ display="Claude Code (plugin)",
213
213
  mcp_path_global="",
214
214
  mcp_path_project=None,
215
215
  server_key="",
216
216
  fmt="",
217
217
  agents_md_path=None,
218
218
  server_block={},
219
- caveats="OUT — WP-06 plugin pointer only; no MCP config written",
219
+ caveats="OUT — plugin pointer only; no MCP config written",
220
220
  ),
221
221
  # --- EXPERIMENTAL (gated, not wired by default) ---
222
222
  # chatgpt-desktop, perplexity, cody: gated behind --experimental
@@ -702,7 +702,7 @@ if __name__ == "__main__": # pragma: no cover
702
702
  metavar="IDE",
703
703
  help=(
704
704
  "IDE ids to connect. Pass 'all' to connect every supported IDE "
705
- "(excluding claude-code which uses the WP-06 plugin)."
705
+ "(excluding claude-code which uses the plugin installer)."
706
706
  ),
707
707
  )
708
708
  _parser.add_argument("--home", help="Override home directory (test hook).")
@@ -4,8 +4,6 @@
4
4
 
5
5
  """PostToolUse async:true hook — fire-and-forget prewarm via stdlib urllib.
6
6
 
7
- LLD reference: `.backup/active-brain/lld/LLD-01-context-cache-and-hot-path-hooks.md`
8
- Section 4.4.
9
7
 
10
8
  HARD RULES (enforced by tests):
11
9
  - stdlib only — ``urllib.request``, NOT ``httpx`` / ``requests``.
@@ -4,8 +4,6 @@
4
4
 
5
5
  """Authentication primitives for the /internal/prewarm daemon route.
6
6
 
7
- LLD reference: `.backup/active-brain/lld/LLD-01-context-cache-and-hot-path-hooks.md`
8
- Section 4.5.
9
7
 
10
8
  Four gates, applied in order, BEFORE any engine work:
11
9
  1. Loopback-only — client address must be 127.0.0.1 / ::1.
@@ -4,8 +4,6 @@
4
4
 
5
5
  """UserPromptSubmit hook — Python fallback (compiled binary preferred).
6
6
 
7
- LLD reference: `.backup/active-brain/lld/LLD-01-context-cache-and-hot-path-hooks.md`
8
- Section 4.3.
9
7
 
10
8
  HARD RULES (enforced by tests):
11
9
  - stdlib-only imports at module load (SLM modules delayed-imported).
@@ -16,6 +16,7 @@ import hashlib
16
16
  import json
17
17
  import logging
18
18
  import shutil
19
+ import tempfile
19
20
  import sqlite3
20
21
  import time
21
22
  import uuid
@@ -735,15 +736,50 @@ class BackupManager:
735
736
  target = self.db_path.parent / target_name
736
737
 
737
738
  try:
738
- self.create_backup(label="pre-restore")
739
+ # Stage the source OUTSIDE backup_dir before anything else runs.
740
+ #
741
+ # create_backup() below calls _enforce_retention(), which globs this
742
+ # same directory and unlinks the oldest files — including, when the
743
+ # backup being restored is the oldest, the source itself. A plain
744
+ # sqlite3.connect() on that now-missing path RECREATES it as an
745
+ # empty database, which is then copied over the live store while the
746
+ # call returns True, leaving a zero-byte file under the original
747
+ # name so a second attempt also appears to succeed.
748
+ #
749
+ # Reproduced before this fix: 11 snapshots, max_backups=10, a
750
+ # 500-fact store restored to 0 tables, restore_backup() -> True.
751
+ with tempfile.TemporaryDirectory(prefix="slm-restore-") as staging:
752
+ staged = Path(staging) / filename
753
+
754
+ # mode=ro fails loudly on a missing file instead of creating one.
755
+ src_ro = sqlite3.connect(f"file:{backup_path}?mode=ro", uri=True)
756
+ try:
757
+ if not [r[0] for r in src_ro.execute(
758
+ "SELECT name FROM sqlite_master WHERE type='table'")]:
759
+ logger.error(
760
+ "Restore rejected: %s contains no tables — refusing to "
761
+ "overwrite %s with an empty database",
762
+ filename, target_name,
763
+ )
764
+ return False
765
+ staged_dst = sqlite3.connect(str(staged))
766
+ try:
767
+ src_ro.backup(staged_dst)
768
+ finally:
769
+ staged_dst.close()
770
+ finally:
771
+ src_ro.close()
739
772
 
740
- src = sqlite3.connect(str(backup_path))
741
- dst = sqlite3.connect(str(target))
742
- try:
743
- src.backup(dst)
744
- finally:
745
- dst.close()
746
- src.close()
773
+ self.create_backup(label="pre-restore")
774
+
775
+ # Restore from the staged copy, which retention cannot reach.
776
+ src = sqlite3.connect(f"file:{staged}?mode=ro", uri=True)
777
+ dst = sqlite3.connect(str(target))
778
+ try:
779
+ src.backup(dst)
780
+ finally:
781
+ dst.close()
782
+ src.close()
747
783
 
748
784
  logger.info("Restored: %s -> %s", filename, target.name)
749
785