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
@@ -191,17 +191,138 @@ def _batch_delete_by_ids(
191
191
  for start in range(0, len(ids), _BATCH_SIZE):
192
192
  batch = ids[start:start + _BATCH_SIZE]
193
193
  ph = ",".join("?" * len(batch))
194
+ endpoints = _endpoints_of(db, table, id_col, batch)
194
195
  with db.transaction():
195
196
  db.execute(
196
197
  f"DELETE FROM {table} WHERE {id_col} IN ({ph})",
197
198
  tuple(batch),
198
199
  )
200
+ # Inside the same transaction as the delete. The graph also lives in
201
+ # a second store, which no SQLite transaction can reach, so the
202
+ # durable record of "these facts need re-projecting" has to be as
203
+ # durable as the delete itself. Without it the second store keeps
204
+ # serving edges this function just removed, and a search walks a hop
205
+ # that no longer exists.
206
+ _enqueue_reprojection(db, endpoints)
199
207
  removed += len(batch)
200
208
  if start + _BATCH_SIZE < len(ids):
201
209
  time.sleep(_BATCH_YIELD_S)
202
210
  return removed
203
211
 
204
212
 
213
+ #: The two columns naming an edge's ends, per table. ``association_edges``
214
+ #: calls them ``source_fact_id``/``target_fact_id``, so a helper that assumed
215
+ #: ``source_id``/``target_id`` raised there, returned nothing, and every
216
+ #: association-edge deletion went unannounced to the graph store -- silently,
217
+ #: because the failure looked exactly like "this table has no endpoints".
218
+ _ENDPOINT_COLUMNS: dict[str, tuple[str, str]] = {
219
+ "graph_edges": ("source_id", "target_id"),
220
+ "association_edges": ("source_fact_id", "target_fact_id"),
221
+ }
222
+
223
+
224
+ def _endpoints_of(
225
+ db: "DatabaseManager", table: str, id_col: str, ids: list,
226
+ ) -> list[tuple[str, str]]:
227
+ """``(fact_id, profile_id)`` for both ends of the rows about to be deleted.
228
+
229
+ Read before the delete, because afterwards there is nothing to read. Only
230
+ the ends that are facts matter: an entity id here is projected as part of
231
+ whichever facts reference it, and those facts are re-derived anyway.
232
+ """
233
+ if not ids:
234
+ return []
235
+ columns = _ENDPOINT_COLUMNS.get(table)
236
+ if columns is None:
237
+ # A table nobody has named the endpoints of is one this pass must not
238
+ # guess at. Loudly, because guessing wrong is what produced the silent
239
+ # hole above.
240
+ logger.warning(
241
+ "prune: %s has no declared endpoint columns, so its deletions "
242
+ "cannot be announced to the graph projection", table,
243
+ )
244
+ return []
245
+ ph = ",".join("?" * len(ids))
246
+ try:
247
+ rows = db.execute(
248
+ f"SELECT {columns[0]}, {columns[1]}, profile_id FROM {table} "
249
+ f"WHERE {id_col} IN ({ph})",
250
+ tuple(ids),
251
+ )
252
+ except Exception as exc: # noqa: BLE001
253
+ logger.warning("prune: cannot read endpoints from %s: %s", table, exc)
254
+ return []
255
+ seen: dict[tuple[str, str], None] = {}
256
+ for row in rows:
257
+ record = dict(row)
258
+ profile_id = str(record.get("profile_id") or "default")
259
+ for column in columns:
260
+ value = record.get(column)
261
+ if value:
262
+ seen[(str(value), profile_id)] = None
263
+ return list(seen)
264
+
265
+
266
+ def _enqueue_reprojection(
267
+ db: "DatabaseManager", endpoints: list[tuple[str, str]],
268
+ ) -> None:
269
+ """Queue an upsert for each fact whose edges just changed.
270
+
271
+ The queue coalesces on fact id and the worker re-reads the fact's current
272
+ edges from SQLite, so queueing an endpoint twice, or queueing one whose
273
+ edges were already correct, costs one row and converges on the same answer.
274
+ An id that is an entity rather than a fact is filtered out here rather than
275
+ left for the worker, which would otherwise spend a lookup discovering the
276
+ same thing on every cycle.
277
+ """
278
+ if not endpoints:
279
+ return
280
+ try:
281
+ from superlocalmemory.storage import projection_outbox
282
+ except Exception as exc: # noqa: BLE001
283
+ logger.debug("prune: no projection queue module: %s", exc)
284
+ return
285
+ try:
286
+ if not projection_outbox.is_available(db):
287
+ return
288
+ except Exception as exc: # noqa: BLE001
289
+ logger.debug("prune: projection queue unavailable: %s", exc)
290
+ return
291
+ by_profile: dict[str, list[str]] = {}
292
+ for fact_id, profile_id in endpoints:
293
+ by_profile.setdefault(profile_id, []).append(fact_id)
294
+ for profile_id, fact_ids in by_profile.items():
295
+ ph = ",".join("?" * len(fact_ids))
296
+ try:
297
+ rows = db.execute(
298
+ f"SELECT fact_id FROM atomic_facts WHERE profile_id = ? "
299
+ f"AND fact_id IN ({ph})",
300
+ (profile_id, *fact_ids),
301
+ )
302
+ real = [dict(row)["fact_id"] for row in rows]
303
+ except Exception as exc: # noqa: BLE001
304
+ logger.debug("prune: cannot confirm endpoints are facts: %s", exc)
305
+ continue
306
+ if not real:
307
+ continue
308
+ # Deliberately allowed to raise. This runs inside the same transaction
309
+ # as the delete, so a failure here rolls the delete back and the batch
310
+ # is simply retried on the next pass -- nothing is lost and nothing
311
+ # diverges.
312
+ #
313
+ # Swallowing it, as this once did, committed the delete with no record
314
+ # that the graph needed telling. The queue would then be empty, which
315
+ # is exactly what "the graph is up to date" looks like, and the graph
316
+ # would serve a link the store had removed with nothing anywhere
317
+ # recording that it happened. That is the failure this queue exists to
318
+ # make impossible, and it is the module's own stated policy: "a
319
+ # durability mechanism that silently degrades to best-effort is the
320
+ # defect it exists to remove."
321
+ projection_outbox.enqueue_many(
322
+ db, real, profile_id, op=projection_outbox.OP_UPSERT,
323
+ )
324
+
325
+
205
326
  def _remove_orphan_edges_batched(
206
327
  db: "DatabaseManager",
207
328
  profile_id: str,
@@ -31,6 +31,16 @@ if TYPE_CHECKING:
31
31
 
32
32
  logger = logging.getLogger(__name__)
33
33
 
34
+
35
+ class _ConsolidationDisabled(Exception):
36
+ """Internal signal: consolidation is switched off, so skip its block.
37
+
38
+ A private exception rather than restructuring the surrounding try/except:
39
+ the block's job is to keep one optional maintenance step from taking the
40
+ whole pass down with it, and that guarantee should not be weakened to
41
+ express "deliberately skipped". Caught immediately below, never propagated.
42
+ """
43
+
34
44
  # Backfill constants
35
45
  _BACKFILL_BURN_IN_STEPS = 50
36
46
  _LANGEVIN_DIM = 8
@@ -638,9 +648,12 @@ def run_maintenance(
638
648
  logger.warning("Entity summary consolidation failed: %s", exc)
639
649
 
640
650
  # 4. Fact consolidation (v3.8.4 concurrency-safe path via DatabaseManager).
641
- # Merges clusters of warm/cold atomic facts about the same entity into a
642
- # single consolidated fact, archives the originals (NEVER deletes them),
643
- # and records provenance in fact_consolidations.
651
+ # Groups warm/cold atomic facts that share an entity and writes ONE
652
+ # DISPLAY summary per cluster into consolidated_summaries, with provenance
653
+ # in fact_consolidations. It does not write to atomic_facts and does not
654
+ # archive the source facts — until 4.0.10 it did both, which put 1,195
655
+ # model-written rows into the retrieval corpus and left 528 genuine
656
+ # memories archived out of normal recall.
644
657
  #
645
658
  # Uses the DatabaseManager path so LLM calls happen OUTSIDE the write lock:
646
659
  # - Discover clusters in a short memory_read() (no write lock held).
@@ -652,20 +665,45 @@ def run_maintenance(
652
665
  try:
653
666
  from superlocalmemory.core.fact_consolidator import consolidate_facts
654
667
 
668
+ # The documented off-switch has to actually switch something off.
669
+ # ConsolidationConfig.enabled has existed since Phase 5 and this call
670
+ # site never read it, so a user who ran `slm config` to turn
671
+ # consolidation off got consolidation anyway — for four months, on
672
+ # every maintenance pass. -2 is a third distinguishable value, kept
673
+ # apart from 0 (nothing to merge) and -1 (the step failed), so a
674
+ # deliberately disabled step is never mistaken for either.
675
+ _consolidation = getattr(config, "consolidation", None)
676
+ if _consolidation is not None and not getattr(_consolidation, "enabled", True):
677
+ counts["facts_consolidated"] = -2
678
+ logger.debug("Fact consolidation disabled by configuration")
679
+ raise _ConsolidationDisabled
680
+
655
681
  fc_stats = consolidate_facts(
656
682
  db,
657
683
  profile_id=profile_id,
658
- max_clusters=getattr(config, "max_consolidation_clusters", 20),
684
+ # Read from ConsolidationConfig, with the old SLMConfig-level name
685
+ # as the fallback. `getattr(config, "max_consolidation_clusters")`
686
+ # alone never resolved — SLMConfig has no such attribute — so the
687
+ # default was the only value this had ever used.
688
+ max_clusters=int(
689
+ getattr(_consolidation, "max_consolidation_clusters", None)
690
+ or getattr(config, "max_consolidation_clusters", None)
691
+ or 20
692
+ ),
659
693
  dry_run=False,
660
694
  config=config,
661
695
  )
662
696
  counts["facts_consolidated"] = fc_stats.get("consolidated", 0)
663
697
  if fc_stats.get("consolidated", 0) > 0:
664
698
  logger.info(
665
- "Fact consolidation: %d clusters merged, %d facts archived",
699
+ "Fact consolidation: %d display summaries over %d facts "
700
+ "(%d clusters refused)",
666
701
  fc_stats.get("consolidated", 0),
667
- fc_stats.get("facts_archived", 0),
702
+ fc_stats.get("facts_summarized", 0),
703
+ fc_stats.get("rejected", 0),
668
704
  )
705
+ except _ConsolidationDisabled:
706
+ pass
669
707
  except Exception as exc:
670
708
  # WARNING, not debug, and a distinguishable count. Leaving this at debug
671
709
  # with facts_consolidated=0 made a failing consolidation report exactly
@@ -67,6 +67,16 @@ class MaintenanceScheduler:
67
67
  self._initial_gc_timer = threading.Timer(90.0, self._initial_cache_gc)
68
68
  self._initial_gc_timer.daemon = True
69
69
  self._initial_gc_timer.start()
70
+ # An upgrade arrives with whatever backlog the previous version left.
71
+ # Waiting a full interval for the first graph-metrics pass would mean
72
+ # half an hour of ranking memories as though they had no position in the
73
+ # graph, on exactly the store that just gained the fix. Staggered behind
74
+ # the cache GC so the two never contend for the write lock.
75
+ self._initial_metrics_timer = threading.Timer(
76
+ 150.0, self._initial_graph_metrics,
77
+ )
78
+ self._initial_metrics_timer.daemon = True
79
+ self._initial_metrics_timer.start()
70
80
  logger.info(
71
81
  "Maintenance scheduler started (interval=%dm)",
72
82
  self._config.forgetting.scheduler_interval_minutes,
@@ -86,6 +96,29 @@ class MaintenanceScheduler:
86
96
  except Exception as exc:
87
97
  logger.debug("Startup activation-cache GC skipped: %s", exc)
88
98
 
99
+ def _initial_graph_metrics(self) -> None:
100
+ """One-shot catch-up so an upgrade does not rank on stale metrics."""
101
+ if not self._running:
102
+ return
103
+ try:
104
+ from superlocalmemory.core.graph_metrics import (
105
+ compute_graph_metrics,
106
+ metrics_are_stale,
107
+ )
108
+ for profile_id in self._profile_ids():
109
+ stale, why = metrics_are_stale(self._db, profile_id)
110
+ if not stale:
111
+ continue
112
+ report = compute_graph_metrics(self._db, profile_id)
113
+ if report.ok:
114
+ logger.info(
115
+ "Graph metrics at startup (%s): %s", why, report.summary(),
116
+ )
117
+ else:
118
+ logger.warning("Graph metrics at startup: %s", report.summary())
119
+ except Exception as exc:
120
+ logger.debug("Startup graph metrics skipped: %s", exc)
121
+
89
122
  def stop(self) -> None:
90
123
  """Stop the scheduler. Idempotent."""
91
124
  self._running = False
@@ -96,6 +129,10 @@ class MaintenanceScheduler:
96
129
  if _gc_timer is not None:
97
130
  _gc_timer.cancel()
98
131
  self._initial_gc_timer = None
132
+ _metrics_timer = getattr(self, "_initial_metrics_timer", None)
133
+ if _metrics_timer is not None:
134
+ _metrics_timer.cancel()
135
+ self._initial_metrics_timer = None
99
136
  logger.info("Maintenance scheduler stopped")
100
137
 
101
138
  def _schedule_next(self) -> None:
@@ -106,6 +143,47 @@ class MaintenanceScheduler:
106
143
  self._timer.daemon = True
107
144
  self._timer.start()
108
145
 
146
+ #: Consecutive failures of one step before it stops being a hiccup.
147
+ _ESCALATE_AFTER = 3
148
+
149
+ def _record_step(self, step: str, ok: bool, detail: str = "") -> None:
150
+ """Remember whether a maintenance step worked, and say so when it has
151
+ stopped working."""
152
+ counts = getattr(self, "_step_failures", None)
153
+ if counts is None:
154
+ counts = self._step_failures = {}
155
+ if ok:
156
+ if counts.pop(step, 0):
157
+ logger.info("maintenance: %s is working again", step)
158
+ return
159
+ counts[step] = counts.get(step, 0) + 1
160
+ if counts[step] >= self._ESCALATE_AFTER:
161
+ logger.error(
162
+ "maintenance: %s has failed %d cycles in a row (%s). This is "
163
+ "not a transient failure; the work it does is not being done.",
164
+ step, counts[step], detail or "no detail",
165
+ )
166
+ else:
167
+ logger.warning("maintenance: %s failed (%s)", step, detail or "")
168
+
169
+ def failing_steps(self) -> dict[str, int]:
170
+ """Steps that have failed on consecutive cycles, and how many.
171
+
172
+ Read by the status surfaces, so an operator can see a persistently
173
+ broken maintenance step instead of having to find it in the log.
174
+ """
175
+ return dict(getattr(self, "_step_failures", {}) or {})
176
+
177
+ def _note_step_outcomes(self) -> None:
178
+ """Escalate anything still failing after this cycle's steps."""
179
+ failing = self.failing_steps()
180
+ if failing:
181
+ logger.warning(
182
+ "maintenance: %d step(s) still failing: %s",
183
+ len(failing),
184
+ ", ".join(f"{name} x{count}" for name, count in sorted(failing.items())),
185
+ )
186
+
109
187
  def _run(self) -> None:
110
188
  """Execute maintenance + auto-backup check, then schedule next run."""
111
189
  if not self._running:
@@ -183,6 +261,66 @@ class MaintenanceScheduler:
183
261
  except Exception as exc:
184
262
  logger.debug("Graph pruning skipped for %s: %s", profile_id, exc)
185
263
 
264
+ # Pruning the graph orphans the lineage of every edge it removed,
265
+ # and nothing had ever deleted from that table — on a real store it
266
+ # had grown to 39% rows describing edges that no longer existed.
267
+ # This runs immediately after so the rows the pass just orphaned are
268
+ # collected in the same pass.
269
+ try:
270
+ from superlocalmemory.storage.lineage_retention import (
271
+ prune_orphan_lineage,
272
+ )
273
+ report = prune_orphan_lineage(self._db, profile_id=profile_id)
274
+ if report.total:
275
+ logger.info(
276
+ "Lineage retention for %s: %d row(s) removed (%s)",
277
+ profile_id, report.total, report.deleted,
278
+ )
279
+ except Exception as exc:
280
+ logger.debug("Lineage retention skipped for %s: %s", profile_id, exc)
281
+
282
+ # Structural metrics. Recall multiplies a candidate's activation by
283
+ # its PageRank at every hop and biases it toward its query seeds'
284
+ # communities, and both numbers live in fact_importance -- so a
285
+ # memory missing from that table is found by the walk and then
286
+ # ranked as though it had no position in the graph.
287
+ #
288
+ # Nothing scheduled this. It ran only when a consolidation happened
289
+ # to fire or someone called the HTTP endpoint by hand, and on the
290
+ # author's store that meant one run in nine days: 1,036 of 4,034
291
+ # visible memories had no score and no community, and the newest
292
+ # four days of memories had none at all. This runs after pruning so
293
+ # it describes the graph that pruning left behind.
294
+ try:
295
+ from superlocalmemory.core.graph_metrics import (
296
+ compute_graph_metrics,
297
+ metrics_are_stale,
298
+ )
299
+ stale, why = metrics_are_stale(self._db, profile_id)
300
+ if stale:
301
+ backend = None
302
+ try:
303
+ from superlocalmemory.core.backend_orchestrator import (
304
+ get_orchestrator,
305
+ )
306
+ orchestrator = get_orchestrator()
307
+ if orchestrator is not None:
308
+ backend = orchestrator.get_graph_backend()
309
+ except Exception: # noqa: BLE001 -- in-process is the default anyway
310
+ backend = None
311
+ report = compute_graph_metrics(
312
+ self._db, profile_id, backend=backend,
313
+ )
314
+ if report.ok:
315
+ logger.info("Graph metrics (%s): %s", why, report.summary())
316
+ else:
317
+ logger.warning("Graph metrics: %s", report.summary())
318
+ else:
319
+ logger.debug("Graph metrics up to date for %s", profile_id)
320
+ self._record_step("graph metrics", True)
321
+ except Exception as exc: # noqa: BLE001
322
+ self._record_step("graph metrics", False, str(exc))
323
+
186
324
  # Lifecycle evaluation must cover every stored profile, not only
187
325
  # whichever profile was active when the engine started.
188
326
  try:
@@ -201,6 +339,73 @@ class MaintenanceScheduler:
201
339
  except Exception as exc:
202
340
  logger.debug("Core-block recompile skipped for %s: %s", profile_id, exc)
203
341
 
342
+ # Re-read what is filed as a plan. The one-time pass runs as a
343
+ # migration; the rule it uses keeps getting sharper, and a completed
344
+ # migration is never replayed — so without this the store drifts
345
+ # further from the rule with every release and nothing repairs it. The
346
+ # pass is a pure function of the text and idempotent, so this is a no-op
347
+ # once the store has converged.
348
+ #
349
+ # Once per cycle, not once per profile: it reads the whole table in one
350
+ # sweep with no profile predicate, so running it per profile did the
351
+ # identical global work N times over and took the write lock N times to
352
+ # do it. It also takes and releases that lock per batch, so a memory
353
+ # being saved waits for one batch rather than the whole sweep.
354
+ #
355
+ # An earlier version reached for `_conn` and then `.connection`, and the
356
+ # database manager has neither, so the guard was always False and this
357
+ # had never run once -- the store drifted further from the rule with
358
+ # every release while a block that looks like it repairs that sat here
359
+ # doing nothing.
360
+ try:
361
+ from superlocalmemory.storage.migrations import (
362
+ M048_upcoming_holds_only_what_is_upcoming as _reclassify,
363
+ )
364
+ _reclassify.apply(open_connection=self._db.raw_connection)
365
+ self._record_step("re-reading plans", True)
366
+ except Exception as exc: # noqa: BLE001
367
+ self._record_step("re-reading plans", False, str(exc))
368
+
369
+ # Anything that has failed on several cycles running is not a blip.
370
+ # Every step here logs and continues, which is right -- one broken step
371
+ # must not stop the rest -- but it meant a step that had been failing
372
+ # for a week looked exactly like one that had just hiccupped, and the
373
+ # daemon still reported itself healthy throughout.
374
+ self._note_step_outcomes()
375
+
376
+ # Retention. Three tables had a pruner each, written and wired
377
+ # separately; the fourth unbounded table was found by reading a
378
+ # disk-usage report and the fifth by reading the fourth. The policy for
379
+ # every append-shaped table now lives in one registry and this enforces
380
+ # all of them, so a table added without a policy is something the test
381
+ # suite can see rather than something a person has to remember.
382
+ #
383
+ # Once per cycle, not per profile: every rule is keyed either on a row's
384
+ # own age or on whether its referent still exists, and neither is
385
+ # profile-scoped. Placed after the per-profile work so it sweeps rows
386
+ # that pass orphaned -- pruning the graph and demoting tiers is what
387
+ # leaves a lineage or temporal row without a referent.
388
+ # In pieces, each taking and releasing the write lock. Entering
389
+ # ``raw_connection`` is what takes that lock, so entering it once for
390
+ # the whole sweep held it for the whole sweep: measured at 1,480 ms and
391
+ # 123,888 rows on a 1 GB store, which is most of the budget a save is
392
+ # allowed, spent waiting.
393
+ try:
394
+ from superlocalmemory.storage.retention_policy import (
395
+ run_retention_bounded,
396
+ )
397
+ removed = run_retention_bounded(self._db.raw_connection)
398
+ if removed:
399
+ logger.info(
400
+ "Retention: %s",
401
+ ", ".join(
402
+ f"{table} -{count}" for table, count in sorted(removed.items())
403
+ ),
404
+ )
405
+ self._record_step("retention", True)
406
+ except Exception as exc: # noqa: BLE001
407
+ self._record_step("retention", False, str(exc))
408
+
204
409
  # V3.4.10: Check if auto-backup is due
205
410
  try:
206
411
  from superlocalmemory.infra.backup import BackupManager