superlocalmemory 4.0.10 → 4.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/.claude-plugin/marketplace.json +12 -2
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +40 -75
  4. package/package.json +6 -3
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +4 -4
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +2 -2
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +5 -5
  19. package/plugin/skills/slm-recall/SKILL.md +102 -15
  20. package/plugin/skills/slm-remember/SKILL.md +35 -3
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +29 -3
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/rules/AGENTS.md +16 -8
  25. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-loop/SKILL.md +2 -2
  30. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-profile/SKILL.md +5 -5
  32. package/plugin-src/skills/slm-recall/SKILL.md +102 -15
  33. package/plugin-src/skills/slm-remember/SKILL.md +35 -3
  34. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +29 -3
  36. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  37. package/pyproject.toml +1 -1
  38. package/src/superlocalmemory/__init__.py +1 -1
  39. package/src/superlocalmemory/cli/commands.py +357 -18
  40. package/src/superlocalmemory/cli/daemon.py +30 -0
  41. package/src/superlocalmemory/cli/db_migrate.py +71 -1
  42. package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
  43. package/src/superlocalmemory/cli/main.py +24 -2
  44. package/src/superlocalmemory/code_graph/database.py +44 -0
  45. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  46. package/src/superlocalmemory/core/admission.py +231 -11
  47. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  48. package/src/superlocalmemory/core/config.py +90 -11
  49. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  50. package/src/superlocalmemory/core/engine.py +140 -11
  51. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  52. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  53. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  54. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  55. package/src/superlocalmemory/core/mode_capability.py +111 -0
  56. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  57. package/src/superlocalmemory/core/projection_drain.py +380 -0
  58. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  59. package/src/superlocalmemory/core/recall_worker.py +6 -3
  60. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  61. package/src/superlocalmemory/core/scale_engine.py +16 -2
  62. package/src/superlocalmemory/core/score_contract.py +21 -1
  63. package/src/superlocalmemory/core/session_identity.py +85 -0
  64. package/src/superlocalmemory/core/status_contract.py +108 -0
  65. package/src/superlocalmemory/core/worker_pool.py +4 -4
  66. package/src/superlocalmemory/core/working_memory.py +288 -0
  67. package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
  68. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  69. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  70. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  71. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  72. package/src/superlocalmemory/encoding/type_router.py +12 -12
  73. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  74. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  75. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  76. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  77. package/src/superlocalmemory/learning/bandit.py +43 -0
  78. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  79. package/src/superlocalmemory/learning/database.py +60 -3
  80. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  81. package/src/superlocalmemory/learning/feedback.py +3 -1
  82. package/src/superlocalmemory/learning/outcomes.py +47 -16
  83. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  84. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  85. package/src/superlocalmemory/learning/pcos.py +291 -0
  86. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  87. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  88. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  89. package/src/superlocalmemory/mcp/profiles.py +14 -2
  90. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  91. package/src/superlocalmemory/mcp/tools_core.py +31 -3
  92. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  93. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  94. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
  96. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  97. package/src/superlocalmemory/retrieval/engine.py +106 -11
  98. package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
  99. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  100. package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
  101. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  102. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  103. package/src/superlocalmemory/server/api.py +24 -5
  104. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  105. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  106. package/src/superlocalmemory/server/recall_health.py +87 -10
  107. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  108. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  109. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  110. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  111. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  112. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  113. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  114. package/src/superlocalmemory/server/routes/memories.py +49 -7
  115. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  116. package/src/superlocalmemory/server/routes/v3_api.py +191 -15
  117. package/src/superlocalmemory/server/ui.py +20 -4
  118. package/src/superlocalmemory/server/unified_daemon.py +241 -7
  119. package/src/superlocalmemory/storage/_migration_internals.py +54 -2
  120. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  121. package/src/superlocalmemory/storage/database.py +477 -59
  122. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  123. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  124. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  125. package/src/superlocalmemory/storage/migration_runner.py +119 -0
  126. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
  127. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  128. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  129. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  130. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  131. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  132. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  133. package/src/superlocalmemory/storage/migrations.py +18 -2
  134. package/src/superlocalmemory/storage/models.py +40 -1
  135. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  136. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  137. package/src/superlocalmemory/storage/schema.py +35 -1
  138. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  139. package/src/superlocalmemory/trust/scorer.py +43 -1
  140. package/src/superlocalmemory/ui/index.html +9 -18
  141. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  142. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  143. package/src/superlocalmemory/ui/js/od-memories.js +19 -0
  144. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  145. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -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,
@@ -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
@@ -0,0 +1,111 @@
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
+ """What the running mode can do with a model, in words a user can act on.
6
+
7
+ WHY THIS EXISTS
8
+
9
+ Mode A runs with no language model at all. That is the point of it: nothing on
10
+ the store or recall path calls out to anything, and a summary is assembled
11
+ directly from the user's own notes rather than written.
12
+
13
+ The surfaces already reported *what* they did -- a summary came back labelled
14
+ "assembled directly from your own notes" -- but never *why*, and never what to do
15
+ about it. Someone looking at a plainer summary than they expected had no way to
16
+ learn that a written one needs a model and which modes have one. They would
17
+ reasonably conclude the feature was broken.
18
+
19
+ So this returns the mode, whether a model is available, and one sentence naming
20
+ the next step. Every model-backed surface returns the same block, so the
21
+ explanation is identical wherever it appears rather than reworded per pane.
22
+
23
+ WHAT IT IS NOT
24
+
25
+ It is not a capability gate. Nothing here decides whether a call is made; the
26
+ mode's configuration does that. This only describes the decision to the person
27
+ looking at the result.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ from typing import Any
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ #: Named so the message can say which modes to consider without hard-coding the
38
+ #: sentence at each call site.
39
+ _MODE_WITH_LOCAL_MODEL = "B"
40
+ _MODE_WITH_CLOUD_MODEL = "C"
41
+
42
+ _NO_MODEL_MESSAGE = (
43
+ "This mode runs entirely on your machine with no language model, so "
44
+ "summaries are assembled from your own notes rather than written. For "
45
+ f"written summaries and the other model-backed features, switch to Mode "
46
+ f"{_MODE_WITH_LOCAL_MODEL} (a local model) or Mode {_MODE_WITH_CLOUD_MODEL} "
47
+ "(your own cloud model) in Settings and connect one."
48
+ )
49
+
50
+ _MODEL_CONFIGURED_BUT_ABSENT = (
51
+ "This mode uses a language model, but none is reachable right now, so "
52
+ "results fall back to being assembled from your own notes. Check the model "
53
+ "settings, and that the local model server is running if you are using one."
54
+ )
55
+
56
+ _CLOUD_MODEL_HAS_NO_KEY = (
57
+ "This mode uses a hosted language model and no key has been set for it, so "
58
+ "summaries are assembled from your own notes rather than written. Run "
59
+ "`slm provider set` and supply your key, or switch to Mode "
60
+ f"{_MODE_WITH_LOCAL_MODEL} to use a model on this machine."
61
+ )
62
+
63
+ #: Providers that run on the machine and need no credential. Anything else is
64
+ #: a hosted service, and naming one without a key is not a configured model --
65
+ #: which is what this used to report, so the surfaces said everything was fine
66
+ #: while every model-backed feature was about to fall back.
67
+ _LOCAL_PROVIDERS = frozenset({
68
+ "ollama", "lmstudio", "llamacpp", "llama.cpp", "local", "vllm", "none", "",
69
+ })
70
+
71
+
72
+ def llm_capability(config: Any, *, llm_reachable: bool | None = None) -> dict:
73
+ """Describe this mode's model support for a user-facing surface.
74
+
75
+ ``llm_reachable`` lets a caller that already knows the answer pass it in --
76
+ a summary route has just tried and knows whether it worked, and asking again
77
+ would mean a second connection attempt to say something it already knows.
78
+ Left as None, availability is taken from configuration alone.
79
+ """
80
+ mode = ""
81
+ provider = ""
82
+ api_key = ""
83
+ try:
84
+ mode = str(getattr(getattr(config, "mode", None), "value", "") or "").upper()
85
+ llm = getattr(config, "llm", None)
86
+ provider = str(getattr(llm, "provider", "") or "")
87
+ api_key = str(getattr(llm, "api_key", "") or "").strip()
88
+ except Exception as exc: # noqa: BLE001 -- a description must not raise
89
+ logger.debug("mode capability: cannot read config: %s", exc)
90
+
91
+ mode_has_model = mode in (_MODE_WITH_LOCAL_MODEL, _MODE_WITH_CLOUD_MODEL)
92
+ needs_a_key = provider.lower() not in _LOCAL_PROVIDERS
93
+ missing_key = mode_has_model and bool(provider) and needs_a_key and not api_key
94
+ configured = bool(provider) and mode_has_model and not missing_key
95
+ available = configured if llm_reachable is None else bool(llm_reachable)
96
+
97
+ if not mode_has_model:
98
+ message = _NO_MODEL_MESSAGE
99
+ elif missing_key and llm_reachable is not True:
100
+ message = _CLOUD_MODEL_HAS_NO_KEY
101
+ elif not available:
102
+ message = _MODEL_CONFIGURED_BUT_ABSENT
103
+ else:
104
+ message = ""
105
+
106
+ return {
107
+ "mode": mode or "?",
108
+ "llm_available": bool(available),
109
+ "llm_provider": provider,
110
+ "message": message,
111
+ }