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
@@ -173,4 +173,4 @@ to review the impact. See `slm-remember` for the full deletion discipline.
173
173
 
174
174
  ---
175
175
 
176
- *SuperLocalMemory v4.0.4 · Qualixar · AGPL-3.0-or-later*
176
+ *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
@@ -121,7 +121,14 @@ remember(content="...", session_id=session_id, tags="auth,decision", project="my
121
121
  ```
122
122
 
123
123
  This attribution is what allows the ranker to learn which recalls led to useful
124
- outcomes for this project.
124
+ outcomes for this project. It also gives the session a small working set, so
125
+ successive recalls in one conversation build on what the earlier ones surfaced
126
+ instead of each starting cold.
127
+
128
+ **Do not synthesise a session id.** An id beginning `http:`, `mcp:`, `cli:` or
129
+ `probe:` is read as a synthetic per-request label rather than a conversation and
130
+ is deliberately excluded from that working set. Use the one `session_init`
131
+ returned, unchanged, for the whole session.
125
132
 
126
133
  ---
127
134
 
@@ -177,6 +184,23 @@ never attributed to a project or agent. Over many sessions this compounds:
177
184
  projects where lifecycle is respected have measurably better retrieval quality
178
185
  than projects where session_init is skipped.
179
186
 
187
+ Within a single session it compounds faster. The session's working set holds the
188
+ memories its recalls have already surfaced, and later recalls rank those higher,
189
+ so a long conversation converges on the material it is actually about.
190
+
191
+ ### Closing the loop explicitly
192
+
193
+ Engagement signals say a memory was *shown*. `report_outcome` says it was
194
+ *right*:
195
+
196
+ ```
197
+ report_outcome(memory_ids="<ids you actually used>", outcome="success")
198
+ ```
199
+
200
+ Send it when a recalled memory changed what you did, and send `failure` when a
201
+ confidently-returned memory turned out to be wrong — that is the only signal
202
+ that stops a stale memory from being promoted. See the `slm-recall` skill.
203
+
180
204
  ---
181
205
 
182
206
  ## CLI fallback (when MCP is unavailable)
@@ -198,7 +222,9 @@ slm doctor [--json] # preflight check including daemon and embedding worker
198
222
  | Mistake | Consequence | Fix |
199
223
  |---------|-------------|-----|
200
224
  | Calling `session_init` twice in one session | Two session IDs; signals split across them | Call once; store the returned ID |
201
- | Omitting `session_id` from `recall` / `remember` | No learning attribution | Always pass the stored `session_id` |
225
+ | Omitting `session_id` from `recall` / `remember` | No learning attribution, and every turn starts cold | Always pass the stored `session_id` |
226
+ | Inventing a `session_id` such as `mcp:agent` or `http:1234` | Read as synthetic, excluded from the working set | Use the id `session_init` returned |
227
+ | Never reporting an outcome | Ranking cannot tell a useful memory from a merely returned one | `report_outcome` after a recall that changed what you did |
202
228
  | Never calling `close_session` | Temporal summaries not written | Call at end of each meaningful work unit |
203
229
  | Calling `close_session` without a `session_id` when no prior writes exist | Returns error "No session_id found" | Pass the explicit `session_id` from `session_init` |
204
230
 
@@ -227,4 +253,4 @@ explicitly and call `recall` with `include_global`/`include_shared` after
227
253
 
228
254
  ---
229
255
 
230
- *SuperLocalMemory v4.0.4 · Qualixar · AGPL-3.0-or-later*
256
+ *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
@@ -163,4 +163,4 @@ multi-profile setup. To switch the active profile, see `slm-profile`.
163
163
 
164
164
  ---
165
165
 
166
- SuperLocalMemory v4.0.4 · Qualixar · AGPL-3.0-or-later
166
+ SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "4.0.9"
3
+ version = "4.1.0"
4
4
  description = "Local-first agent memory with auditable hybrid retrieval"
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-or-later"
@@ -32,7 +32,7 @@ if "OMP_NUM_THREADS" not in os.environ:
32
32
  os.environ["OMP_NUM_THREADS"] = "2"
33
33
  # ---------------------------------------------------------------------------
34
34
 
35
- __version__ = "4.0.9"
35
+ __version__ = "4.1.0"
36
36
 
37
37
  _REQUIRED_VERSIONS = {
38
38
  "sentence_transformers": "5.3.0",
@@ -21,6 +21,27 @@ from pathlib import Path
21
21
  logger = logging.getLogger(__name__)
22
22
 
23
23
 
24
+ def _cli_projection_queue_depth(db_path: object) -> int:
25
+ """Facts queued for the graph and vector projections, read straight from disk.
26
+
27
+ The no-daemon branch of ``slm status`` has no engine, so it opens the store
28
+ read-only rather than building one. A store that predates the queue reports
29
+ zero, which is the truth for it.
30
+ """
31
+ import sqlite3
32
+
33
+ from superlocalmemory.core.status_contract import projection_queue_depth
34
+
35
+ try:
36
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
37
+ except sqlite3.Error:
38
+ return 0
39
+ try:
40
+ return projection_queue_depth(conn)
41
+ finally:
42
+ conn.close()
43
+
44
+
24
45
  def _daemon_unavailable(command: str, use_json: bool) -> None:
25
46
  """Exit a mutation client without opening a process-local writer.
26
47
 
@@ -70,14 +91,127 @@ def _cmd_db_dispatch(args: Namespace) -> None:
70
91
  if sub == "reembed":
71
92
  _cmd_db_reembed(args)
72
93
  return
94
+ if sub == "regraph":
95
+ _cmd_db_regraph(args)
96
+ return
73
97
  print(
74
98
  "Usage: slm db migrate [--status] [--dry-run] "
75
99
  "| slm db scale <action> "
100
+ "| slm db regraph [--check] [--profile NAME] "
76
101
  "| slm db reembed [--missing-only] [--all-profiles] [--limit N]"
77
102
  )
78
103
  sys.exit(2)
79
104
 
80
105
 
106
+ def _cmd_db_regraph(args: Namespace) -> None:
107
+ """Re-derive the graph copy of your memories from the store.
108
+
109
+ Your memories live in one place; a second copy of the connections between
110
+ them is kept in a graph store so that searching them is fast. The two are
111
+ kept in step by a queue. If the queue is empty and the two still disagree
112
+ -- after a crash, a disk problem, or a copy restored from elsewhere --
113
+ nothing notices, because "the queue is empty" is what "in step" looks like.
114
+
115
+ This is how you say so out loud. ``--check`` reports the difference and
116
+ changes nothing; without it, every memory is queued to be re-derived and
117
+ the background worker rebuilds the copy from the store, which is the one
118
+ that is authoritative.
119
+ """
120
+ from superlocalmemory.core.config import SLMConfig
121
+ from superlocalmemory.storage import projection_outbox
122
+ from superlocalmemory.storage.database import DatabaseManager
123
+ from superlocalmemory.storage.logical_edges import count_logical_edges
124
+
125
+ config = SLMConfig.load()
126
+ db = DatabaseManager(config.db_path)
127
+ wanted = str(getattr(args, "profile", "") or "").strip()
128
+ try:
129
+ rows = db.execute(
130
+ "SELECT DISTINCT profile_id FROM atomic_facts ORDER BY profile_id"
131
+ )
132
+ profiles = [dict(r)["profile_id"] for r in rows]
133
+ except Exception as exc: # noqa: BLE001
134
+ print(f"[slm] could not list workspaces: {exc}")
135
+ sys.exit(1)
136
+ if wanted:
137
+ profiles = [p for p in profiles if p == wanted]
138
+ if not profiles:
139
+ print(f"[slm] no workspace named {wanted!r}")
140
+ sys.exit(1)
141
+
142
+ backend = None
143
+ try:
144
+ from superlocalmemory.core.backend_orchestrator import get_orchestrator
145
+
146
+ orchestrator = get_orchestrator()
147
+ backend = orchestrator.get_graph_backend() if orchestrator else None
148
+ except Exception as exc: # noqa: BLE001
149
+ print(f"[slm] no graph copy is configured on this store ({exc}).")
150
+ return
151
+ if backend is None:
152
+ print("[slm] no graph copy is configured on this store; nothing to do.")
153
+ return
154
+
155
+ total_queued = 0
156
+ for profile_id in profiles:
157
+ with db.raw_connection() as conn:
158
+ stored = count_logical_edges(conn, profile_id)
159
+ try:
160
+ result = backend._db.run(
161
+ "?[count(a)] := *edge{from_id: a, profile_id: $pid}",
162
+ {"pid": profile_id},
163
+ )
164
+ copied = int(result.values.tolist()[0][0]) if len(result) else 0
165
+ except Exception as exc: # noqa: BLE001
166
+ print(f" {profile_id}: the graph copy could not be read ({exc})")
167
+ continue
168
+ drift = abs(copied - stored) / float(stored) if stored else 0.0
169
+ pending = 0
170
+ try:
171
+ pending_rows = db.execute(
172
+ "SELECT COUNT(*) AS c FROM projection_outbox WHERE profile_id = ?",
173
+ (profile_id,),
174
+ )
175
+ pending = int(dict(pending_rows[0])["c"]) if pending_rows else 0
176
+ except Exception: # noqa: BLE001
177
+ pending = -1
178
+ print(
179
+ f" {profile_id}: {stored} connections stored, {copied} in the "
180
+ f"graph copy ({drift:.1%} apart), {pending} waiting to be applied"
181
+ )
182
+ if getattr(args, "check", False):
183
+ continue
184
+
185
+ fact_rows = db.execute(
186
+ "SELECT fact_id FROM atomic_facts WHERE profile_id = ?", (profile_id,)
187
+ )
188
+ fact_ids = [dict(r)["fact_id"] for r in fact_rows]
189
+ if not fact_ids:
190
+ continue
191
+ if not projection_outbox.is_available(db):
192
+ print(" (this store has no queue to rebuild through)")
193
+ continue
194
+ for start in range(0, len(fact_ids), 500):
195
+ projection_outbox.enqueue_many(
196
+ db, fact_ids[start:start + 500], profile_id,
197
+ op=projection_outbox.OP_UPSERT,
198
+ )
199
+ total_queued += len(fact_ids)
200
+ print(f" {profile_id}: queued {len(fact_ids)} memories to re-derive")
201
+
202
+ db.close()
203
+ if getattr(args, "check", False):
204
+ return
205
+ if total_queued:
206
+ print(
207
+ f"[slm] {total_queued} memories queued. The background worker "
208
+ f"rebuilds the copy from the store; watch `slm status` until the "
209
+ f"waiting count reaches zero."
210
+ )
211
+ else:
212
+ print("[slm] nothing to re-derive.")
213
+
214
+
81
215
  def _cmd_db_reembed(args: Namespace) -> None:
82
216
  """Backfill NULL embeddings on atomic_facts.
83
217
 
@@ -271,7 +405,7 @@ def _cmd_loop(args: Namespace) -> None:
271
405
 
272
406
 
273
407
  def _cmd_ops(args: Namespace) -> None:
274
- """Wave-3: operational recovery & admin remediation commands."""
408
+ """Operational recovery & admin remediation commands."""
275
409
  from superlocalmemory.cli.ops_cmd import cmd_ops
276
410
  cmd_ops(args)
277
411
 
@@ -416,7 +550,7 @@ def dispatch(args: Namespace) -> None:
416
550
  "loop": _cmd_loop,
417
551
  # V3.8.2 super-help — grouped overview of every command + topics
418
552
  "help": cmd_help,
419
- # Wave-3: operational recovery & admin remediation
553
+ # Operational recovery & admin remediation
420
554
  "ops": _cmd_ops,
421
555
  # V4.0.6: GDPR subject-rights CLI (Art.15/17/20)
422
556
  "gdpr": _cmd_gdpr_dispatch,
@@ -424,7 +558,21 @@ def dispatch(args: Namespace) -> None:
424
558
  }
425
559
  handler = handlers.get(args.command)
426
560
  if handler:
427
- handler(args)
561
+ from superlocalmemory.cli.daemon import DaemonRefused
562
+
563
+ try:
564
+ handler(args)
565
+ except DaemonRefused as refusal:
566
+ # One place, because a command added next year will not remember to
567
+ # do this. A refusal is an answer: say so and stop, rather than
568
+ # printing a traceback or -- worse -- letting a command fall back to
569
+ # writing locally what the workspace has just declined.
570
+ print(
571
+ f"[slm] {refusal}. This workspace requires authentication; "
572
+ "set SLM_USER_SESSION or log in, then try again.",
573
+ flush=True,
574
+ )
575
+ sys.exit(1)
428
576
  else:
429
577
  print(f"Unknown command: {args.command}")
430
578
  sys.exit(1)
@@ -821,7 +969,13 @@ def cmd_config(args: Namespace) -> None:
821
969
  "evolution.mutation_model", "evolution.verify_model",
822
970
  "evolution.confirm_model",
823
971
  "mesh_enabled", "daemon_idle_timeout", "entity_compilation_enabled",
824
- "graph_backend", "vector_backend", "scale_engine_state",
972
+ "graph_backend", "vector_backend",
973
+ # scale_engine_state is deliberately NOT settable. It is a record of
974
+ # what has been done to the store, not a preference: writing
975
+ # "promoted" by hand makes the daemon skip projecting a store that
976
+ # was never projected, and writing "local_core" onto a promoted one
977
+ # makes it project again over live backends. A real installation was
978
+ # found declaring two backends it had never built.
825
979
  "scope.default_scope", "scope.recall_include_global",
826
980
  "scope.recall_include_shared",
827
981
  }
@@ -1339,7 +1493,7 @@ def cmd_migrate(args: Namespace) -> None:
1339
1493
 
1340
1494
  def cmd_list(args: Namespace) -> None:
1341
1495
  """List recent memories chronologically."""
1342
- from superlocalmemory.core.config import SLMConfig
1496
+ from superlocalmemory.core.config import CANONICAL_LIST_LIMIT, SLMConfig
1343
1497
  from superlocalmemory.core.engine import MemoryEngine
1344
1498
 
1345
1499
  use_json = getattr(args, 'json', False)
@@ -1348,10 +1502,10 @@ def cmd_list(args: Namespace) -> None:
1348
1502
  engine = MemoryEngine(config)
1349
1503
  engine.initialize()
1350
1504
 
1351
- limit = getattr(args, "limit", 20)
1352
- facts = engine._db.get_all_facts(engine.profile_id)
1353
- facts.sort(key=lambda f: f.created_at or "", reverse=True)
1354
- facts = facts[:limit]
1505
+ limit = getattr(args, "limit", CANONICAL_LIST_LIMIT)
1506
+ # The query already returns newest-first; pushing the bound into SQL
1507
+ # keeps this from deserializing the whole table to show twenty rows.
1508
+ facts = engine._db.get_all_facts(engine.profile_id, limit=limit)
1355
1509
  except Exception as exc:
1356
1510
  if use_json:
1357
1511
  from superlocalmemory.cli.json_output import json_print
@@ -1879,6 +2033,9 @@ def cmd_review_correction(args: Namespace) -> None:
1879
2033
  def cmd_status(args: Namespace) -> None:
1880
2034
  """Show system status."""
1881
2035
  from superlocalmemory.core.config import SLMConfig
2036
+ # Same source the other two status surfaces read, so all three cannot
2037
+ # disagree about which version answered.
2038
+ from superlocalmemory.server.routes.helpers import SLM_VERSION
1882
2039
 
1883
2040
  config = SLMConfig.load()
1884
2041
  daemon_status = None
@@ -1903,7 +2060,10 @@ def cmd_status(args: Namespace) -> None:
1903
2060
 
1904
2061
  if daemon_status is not None:
1905
2062
  data = {
1906
- "mode": str(daemon_status.get("mode", "unknown")).upper(),
2063
+ # Lowercase is what the daemon, the HTTP surface and MCP all
2064
+ # report. Uppercasing it here made one field disagree across
2065
+ # surfaces for anyone comparing the two answers.
2066
+ "mode": str(daemon_status.get("mode", "unknown")),
1907
2067
  "provider": daemon_status.get("provider", "none"),
1908
2068
  "profile": daemon_status["profile"],
1909
2069
  "base_dir": daemon_status.get("base_dir", str(config.base_dir)),
@@ -1915,6 +2075,10 @@ def cmd_status(args: Namespace) -> None:
1915
2075
  "profile_generation": int(
1916
2076
  daemon_status.get("profile_generation", 0)
1917
2077
  ),
2078
+ "version": SLM_VERSION,
2079
+ "projection_queue_depth": int(
2080
+ daemon_status.get("projection_queue_depth", 0)
2081
+ ),
1918
2082
  }
1919
2083
  json_print("status", data=data, next_actions=[
1920
2084
  {"command": "slm health --json", "description": "Check math layer health"},
@@ -1965,7 +2129,7 @@ def cmd_status(args: Namespace) -> None:
1965
2129
  pass
1966
2130
 
1967
2131
  data = {
1968
- "mode": config.mode.value.upper(),
2132
+ "mode": config.mode.value,
1969
2133
  "provider": config.llm.provider or "none",
1970
2134
  "profile": config.active_profile,
1971
2135
  "base_dir": str(config.base_dir),
@@ -1975,6 +2139,8 @@ def cmd_status(args: Namespace) -> None:
1975
2139
  "entity_count": entity_count,
1976
2140
  "edge_count": edge_count,
1977
2141
  "profile_generation": 0,
2142
+ "version": SLM_VERSION,
2143
+ "projection_queue_depth": _cli_projection_queue_depth(config.db_path),
1978
2144
  }
1979
2145
  json_print("status", data=data, next_actions=[
1980
2146
  {"command": "slm health --json", "description": "Check math layer health"},
@@ -2068,7 +2234,7 @@ def cmd_health(args: Namespace) -> None:
2068
2234
  "total_facts": len(facts),
2069
2235
  "similarity_indexed": fisher_count,
2070
2236
  "lifecycle_positioned": langevin_count,
2071
- "mode": config.mode.value.upper(),
2237
+ "mode": config.mode.value,
2072
2238
  }, next_actions=[
2073
2239
  {"command": "slm status --json", "description": "Check system status"},
2074
2240
  {"command": "slm recall '<query>' --json", "description": "Test retrieval"},
@@ -2567,14 +2733,22 @@ def cmd_doctor(args: Namespace) -> None:
2567
2733
  "pip install " + " ".join(core_modules[m] for m in missing))
2568
2734
 
2569
2735
  # 3. Search deps
2736
+ #
2737
+ # Presence only. Importing these executes torch, which cost ~4 s of every
2738
+ # doctor run — a quarter of the whole command — to learn something the
2739
+ # embedding-worker check below already proves by actually running them.
2740
+ # find_spec answers "is it installed" without executing a line of it.
2741
+ # A package that is present but broken on import is caught by check 7,
2742
+ # which spawns the worker and waits for it to answer.
2570
2743
  search_mods = {"sentence_transformers": "sentence-transformers", "torch": "torch",
2571
2744
  "sklearn": "scikit-learn"}
2572
2745
  search_ok = []
2573
2746
  for mod, pkg in search_mods.items():
2574
2747
  try:
2575
- __import__(mod)
2576
- search_ok.append(mod)
2577
- except Exception: # dependency import may fail after module discovery
2748
+ import importlib.util as _ilu
2749
+ if _ilu.find_spec(mod) is not None:
2750
+ search_ok.append(mod)
2751
+ except Exception: # a broken meta-path finder must not fail the check
2578
2752
  pass
2579
2753
  if len(search_ok) == len(search_mods):
2580
2754
  _check("Search deps", "PASS", "sentence-transformers, torch, sklearn")
@@ -2761,19 +2935,74 @@ def cmd_doctor(args: Namespace) -> None:
2761
2935
  try:
2762
2936
  from superlocalmemory.storage.memory_write import memory_read
2763
2937
 
2938
+ # integrity_check reads every page. On a 610 MB store that is
2939
+ # 10-15 s — over half of this command — and it ran on every
2940
+ # invocation, including the ones a user makes twice in a row while
2941
+ # fixing something else. quick_check walks the same B-trees and
2942
+ # catches structural damage; what it skips is page-level checksum
2943
+ # work that only finds hardware bit rot, which surfaces as read
2944
+ # errors long before anyone runs a doctor.
2945
+ #
2946
+ # The trade is named rather than hidden: the output says which
2947
+ # check ran, and --deep runs the exhaustive one.
2948
+ deep = bool(getattr(args, "deep", False))
2949
+ pragma = "integrity_check" if deep else "quick_check"
2764
2950
  with memory_read(db_path) as conn:
2765
- result = conn.execute("PRAGMA integrity_check").fetchone()
2951
+ result = conn.execute(f"PRAGMA {pragma}").fetchone()
2766
2952
  if result and result[0] == "ok":
2767
2953
  size_mb = db_path.stat().st_size / (1024 * 1024)
2768
- _check("Database", "PASS", f"OK ({size_mb:.2f} MB)")
2954
+ detail = f"OK ({size_mb:.2f} MB, {pragma})"
2955
+ if not deep:
2956
+ detail += " — run `slm doctor --deep` for a full page scan"
2957
+ _check("Database", "PASS", detail)
2769
2958
  else:
2770
- _check("Database", "FAIL", f"integrity check: {result}",
2959
+ _check("Database", "FAIL", f"{pragma}: {result}",
2771
2960
  "Backup and recreate database")
2772
2961
  except Exception as exc:
2773
2962
  _check("Database", "FAIL", str(exc))
2774
2963
  else:
2775
2964
  _check("Database", "PASS", "not yet created (will initialize on first use)")
2776
2965
 
2966
+ # 10b. Projection queue. The graph and the vectors live in other storage
2967
+ # engines, and a queue that stops draining is the one failure that produces
2968
+ # no error at all: the memory is safely in SQLite, nothing raises, and
2969
+ # recall quietly stops finding it. Depth alone cannot distinguish a busy
2970
+ # queue from a wedged one, so the check keys on attempts — a row that has
2971
+ # been tried and refused is a defect with a fact id attached.
2972
+ if db_path.exists():
2973
+ try:
2974
+ from superlocalmemory.storage.memory_write import memory_read
2975
+ from superlocalmemory.storage.projection_outbox import DEPTH_SQL
2976
+
2977
+ with memory_read(db_path) as conn:
2978
+ depth = int(conn.execute(DEPTH_SQL).fetchone()[0])
2979
+ stalled = [
2980
+ row[0] for row in conn.execute(
2981
+ "SELECT fact_id FROM projection_outbox "
2982
+ "WHERE attempts >= 3 ORDER BY attempts DESC LIMIT 5"
2983
+ )
2984
+ ]
2985
+ if stalled:
2986
+ _check(
2987
+ "Projection queue", "FAIL",
2988
+ f"{len(stalled)}+ memories refused by the graph or vector "
2989
+ f"store (e.g. {', '.join(f[:12] for f in stalled)})",
2990
+ "Check `slm logs` for the projection error, then restart "
2991
+ "the daemon to retry",
2992
+ )
2993
+ elif depth:
2994
+ _check(
2995
+ "Projection queue", "PASS",
2996
+ f"{depth} memory/memories queued — the worker drains these "
2997
+ "in the background",
2998
+ )
2999
+ else:
3000
+ _check("Projection queue", "PASS", "empty (graph is up to date)")
3001
+ except Exception:
3002
+ # No table means a store that predates the queue. Nothing is
3003
+ # pending on it by definition, so this is not a finding.
3004
+ _check("Projection queue", "PASS", "not applicable to this store")
3005
+
2777
3006
  # 11. PEP 668 advisory: detect EXTERNALLY-MANAGED marker and
2778
3007
  # recommend pipx when the system Python is managed by the OS package
2779
3008
  # manager (e.g. Homebrew, Debian/Ubuntu, Fedora 38+).
@@ -2949,6 +3178,48 @@ def cmd_doctor(args: Namespace) -> None:
2949
3178
  f"enabled [{_surface_str}] {_stats}",
2950
3179
  )
2951
3180
 
3181
+ # Memory answer-ability.
3182
+ #
3183
+ # Every other check here asks whether a component is installed. None of
3184
+ # them asked the only question that matters to the owner: can my memories
3185
+ # actually be found? One machine ran for months with 43.7% of its store
3186
+ # unreachable while doctor reported everything green, because the reachable
3187
+ # share was never counted.
3188
+ memory_health_data: dict | None = None
3189
+ try:
3190
+ from superlocalmemory.core.config import SLMConfig as _HealthCfg
3191
+ from superlocalmemory.core.memory_health import describe, measure
3192
+ _h = measure(_HealthCfg.load().db_path)
3193
+ memory_health_data = {
3194
+ "live_facts": _h.live_facts,
3195
+ "findable_by_meaning": _h.findable_by_meaning,
3196
+ "missing_vector": _h.missing_vector,
3197
+ "withheld_summaries": _h.withheld_summaries,
3198
+ "display_summaries": _h.display_summaries,
3199
+ "hidden_by_forgetting": _h.hidden_by_forgetting,
3200
+ "inconsistently_hidden": _h.inconsistently_hidden,
3201
+ "reachability": round(_h.reachability, 4),
3202
+ "healthy": _h.healthy,
3203
+ "unavailable": list(_h.unavailable),
3204
+ "summary": describe(_h),
3205
+ }
3206
+ _detail = " ".join(describe(_h))
3207
+ if _h.healthy:
3208
+ _check("Memory answer-ability", "PASS", _detail)
3209
+ elif _h.inconsistently_hidden or _h.reachability < 0.9:
3210
+ _check(
3211
+ "Memory answer-ability", "FAIL", _detail,
3212
+ fix="slm restart",
3213
+ )
3214
+ else:
3215
+ _check("Memory answer-ability", "WARN", _detail,
3216
+ fix="slm db reembed --missing-only")
3217
+ except Exception as _mh_exc: # noqa: BLE001 — a report must not break doctor
3218
+ _check(
3219
+ "Memory answer-ability", "WARN",
3220
+ f"could not be measured: {_mh_exc}",
3221
+ )
3222
+
2952
3223
  # Summary
2953
3224
  if use_json:
2954
3225
  from superlocalmemory.cli.json_output import json_print
@@ -2959,6 +3230,7 @@ def cmd_doctor(args: Namespace) -> None:
2959
3230
  json_print("doctor", data={
2960
3231
  "checks": checks,
2961
3232
  "summary": {"passed": passed, "warned": warned, "failed": failed},
3233
+ "memory_health": memory_health_data,
2962
3234
  }, next_actions=next_actions)
2963
3235
  else:
2964
3236
  print(f"\nSummary: {passed} passed, {warned} warnings, {failed} failed")
@@ -3344,7 +3616,12 @@ def cmd_profile(args: Namespace) -> None:
3344
3616
  if action in ("switch", "create"):
3345
3617
  from superlocalmemory.core.admission import gate_cli_mutation
3346
3618
  from superlocalmemory.core.operation_request import OperationKind
3347
- gate_cli_mutation(OperationKind.PROFILE_SWITCH)
3619
+ # The role that decides this is the one held on the workspace being
3620
+ # switched to or created, not on whichever one happens to be active.
3621
+ gate_cli_mutation(
3622
+ OperationKind.PROFILE_SWITCH,
3623
+ profile=str(getattr(args, "name", "") or ""),
3624
+ )
3348
3625
 
3349
3626
  from superlocalmemory.core.config import SLMConfig
3350
3627
  from superlocalmemory.storage.database import DatabaseManager
@@ -3858,7 +4135,7 @@ def cmd_session_context(args: Namespace) -> None:
3858
4135
  json_print("session-context", data={
3859
4136
  "context": context,
3860
4137
  "memory_count": len(inj_mems),
3861
- "mode": config.mode.value.upper(),
4138
+ "mode": config.mode.value,
3862
4139
  }, next_actions=[
3863
4140
  {"command": "slm recall --json <query>", "description": "Search memories"},
3864
4141
  ])
@@ -3946,6 +4223,7 @@ def cmd_observe(args: Namespace) -> None:
3946
4223
 
3947
4224
  # V3.3.28: Route through daemon (singleton engine, single embedding worker).
3948
4225
  # This is the P0 fix for the memory blast incident of April 7, 2026.
4226
+ from superlocalmemory.cli.daemon import DaemonRefused
3949
4227
  try:
3950
4228
  from superlocalmemory.cli.daemon import is_daemon_running, daemon_request, ensure_daemon
3951
4229
  if is_daemon_running() or ensure_daemon():
@@ -3959,6 +4237,16 @@ def cmd_observe(args: Namespace) -> None:
3959
4237
  reason = result.get("reason", "no patterns matched")
3960
4238
  print(f"Not captured: {reason}")
3961
4239
  return
4240
+ except DaemonRefused as refusal:
4241
+ # The workspace declined this write. The fallback below exists for a
4242
+ # daemon that is not running -- not for one that answered no.
4243
+ print(
4244
+ f"[slm] Not captured: {refusal}. "
4245
+ "This workspace requires authentication; set SLM_USER_SESSION or "
4246
+ "log in, then try again.",
4247
+ flush=True,
4248
+ )
4249
+ sys.exit(1)
3962
4250
  except Exception:
3963
4251
  pass # Fall through to direct engine
3964
4252
 
@@ -322,6 +322,25 @@ def _get_port() -> int:
322
322
  return _DEFAULT_PORT
323
323
 
324
324
 
325
+ class DaemonRefused(RuntimeError):
326
+ """The daemon answered, and the answer was no.
327
+
328
+ Raised for HTTP 401 and 403 only. Distinct from ``daemon_request``
329
+ returning ``None``, which means the daemon could not be reached or did not
330
+ answer usefully. Callers that fall back to a direct engine write MUST let
331
+ this propagate or exit on it: falling back after a refusal performs, as the
332
+ machine owner, exactly the write the workspace just declined.
333
+ """
334
+
335
+ def __init__(self, status: int, path: str = "") -> None:
336
+ self.status = int(status)
337
+ self.path = path
338
+ super().__init__(
339
+ f"the daemon refused this request (HTTP {status})"
340
+ + (f" for {path}" if path else "")
341
+ )
342
+
343
+
325
344
  def daemon_request(
326
345
  method: str,
327
346
  path: str,
@@ -376,6 +395,7 @@ def daemon_request(
376
395
  return {key: value for key, value in legacy.items() if key != "_legacy_port"}
377
396
  port = int(legacy["_legacy_port"])
378
397
  try:
398
+ import urllib.error
379
399
  import urllib.request
380
400
  url = f"http://127.0.0.1:{port}{path}"
381
401
  data = json.dumps(body).encode() if body else None
@@ -393,6 +413,16 @@ def daemon_request(
393
413
  req = urllib.request.Request(url, data=data, headers=headers, method=method)
394
414
  resp = urllib.request.urlopen(req, timeout=timeout_seconds)
395
415
  return json.loads(resp.read().decode())
416
+ except urllib.error.HTTPError as exc:
417
+ # A refusal is an answer, not a failure to get one. Returning None here
418
+ # made "you are not allowed to do this" indistinguishable from "the
419
+ # daemon is not running", and every caller that falls back to a local
420
+ # engine write treated the first as the second -- so a workspace that
421
+ # required a login refused the write over HTTP and then performed it
422
+ # locally as the machine owner.
423
+ if exc.code in (401, 403):
424
+ raise DaemonRefused(exc.code, path) from exc
425
+ return None
396
426
  except Exception:
397
427
  return None
398
428