superlocalmemory 3.7.8 → 3.8.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 (260) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +69 -0
  4. package/README.md +199 -29
  5. package/package.json +4 -2
  6. package/plugin/.claude-plugin/plugin.json +2 -2
  7. package/plugin/CLAUDE.md +8 -8
  8. package/plugin/agents/slm-governance-advisor.md +80 -0
  9. package/plugin/agents/slm-loop-runner.md +71 -0
  10. package/plugin/agents/slm-memory-advisor.md +10 -5
  11. package/plugin/agents/slm-optimize-advisor.md +9 -3
  12. package/plugin/commands/slm-loop.md +31 -0
  13. package/plugin/hooks/hooks.json +79 -0
  14. package/plugin/requirements.txt +1 -1
  15. package/plugin/scripts/slm-launch +46 -7
  16. package/plugin/settings.json +9 -0
  17. package/plugin/skills/slm-cache/SKILL.md +9 -1
  18. package/plugin/skills/slm-compress/SKILL.md +8 -1
  19. package/plugin/skills/slm-governance/SKILL.md +248 -0
  20. package/plugin/skills/slm-graph/SKILL.md +17 -3
  21. package/plugin/skills/slm-loop/SKILL.md +99 -0
  22. package/plugin/skills/slm-mesh/SKILL.md +282 -0
  23. package/plugin/skills/slm-profile/SKILL.md +148 -0
  24. package/plugin/skills/slm-recall/SKILL.md +46 -10
  25. package/plugin/skills/slm-remember/SKILL.md +48 -1
  26. package/plugin/skills/slm-scope/SKILL.md +176 -0
  27. package/plugin/skills/slm-session/SKILL.md +24 -1
  28. package/plugin/skills/slm-status/SKILL.md +18 -1
  29. package/plugin-src/agents/slm-governance-advisor.md +80 -0
  30. package/plugin-src/agents/slm-loop-runner.md +71 -0
  31. package/plugin-src/agents/slm-memory-advisor.md +10 -5
  32. package/plugin-src/agents/slm-optimize-advisor.md +9 -3
  33. package/plugin-src/commands/slm-loop.md +31 -0
  34. package/plugin-src/hooks/hooks.json +79 -0
  35. package/plugin-src/manifest.json +7 -2
  36. package/plugin-src/requirements.txt +1 -1
  37. package/plugin-src/rules/AGENTS.md +57 -18
  38. package/plugin-src/rules/CLAUDE.md.fragment +8 -8
  39. package/plugin-src/scripts/slm-launch +46 -7
  40. package/plugin-src/settings.json +9 -0
  41. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  42. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  43. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  44. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  45. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  46. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  47. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  48. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  49. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  50. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  51. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  52. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  53. package/pyproject.toml +1 -1
  54. package/scripts/postinstall/validation.js +2 -0
  55. package/scripts/postinstall-interactive.js +74 -2
  56. package/src/superlocalmemory/__init__.py +1 -1
  57. package/src/superlocalmemory/access/__init__.py +3 -0
  58. package/src/superlocalmemory/access/rbac.py +477 -0
  59. package/src/superlocalmemory/cli/commands.py +94 -10
  60. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  61. package/src/superlocalmemory/cli/loop_cmd.py +192 -0
  62. package/src/superlocalmemory/cli/main.py +39 -4
  63. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  64. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  65. package/src/superlocalmemory/cli/pending_store.py +49 -13
  66. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  67. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  68. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  69. package/src/superlocalmemory/compliance/audit.py +6 -0
  70. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  71. package/src/superlocalmemory/compliance/retention.py +176 -45
  72. package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
  73. package/src/superlocalmemory/core/community_summary.py +267 -0
  74. package/src/superlocalmemory/core/config.py +216 -3
  75. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  76. package/src/superlocalmemory/core/context_cache.py +61 -18
  77. package/src/superlocalmemory/core/embedding_worker.py +17 -2
  78. package/src/superlocalmemory/core/embeddings.py +12 -1
  79. package/src/superlocalmemory/core/engine.py +17 -1
  80. package/src/superlocalmemory/core/engine_ingestion.py +29 -0
  81. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  82. package/src/superlocalmemory/core/entity_community.py +178 -0
  83. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  84. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  85. package/src/superlocalmemory/core/key_expander.py +138 -0
  86. package/src/superlocalmemory/core/maintenance.py +23 -0
  87. package/src/superlocalmemory/core/modes.py +1 -1
  88. package/src/superlocalmemory/core/mutations.py +2 -2
  89. package/src/superlocalmemory/core/pii.py +105 -0
  90. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  91. package/src/superlocalmemory/core/recall_pipeline.py +2 -0
  92. package/src/superlocalmemory/core/recall_worker.py +20 -6
  93. package/src/superlocalmemory/core/scale_engine.py +60 -1
  94. package/src/superlocalmemory/core/security_primitives.py +40 -2
  95. package/src/superlocalmemory/core/store_pipeline.py +35 -11
  96. package/src/superlocalmemory/core/worker_pool.py +21 -6
  97. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  98. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  99. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  100. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  101. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  102. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  103. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  104. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  105. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  106. package/src/superlocalmemory/evolution/triggers.py +22 -13
  107. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  108. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  109. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  110. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  111. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  112. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  113. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  114. package/src/superlocalmemory/hooks/portable_kit.py +114 -1
  115. package/src/superlocalmemory/infra/backup.py +12 -1
  116. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  117. package/src/superlocalmemory/infra/data_root.py +43 -4
  118. package/src/superlocalmemory/infra/event_bus.py +107 -24
  119. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  120. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  121. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  122. package/src/superlocalmemory/learning/cross_project.py +28 -19
  123. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  124. package/src/superlocalmemory/loops/__init__.py +56 -0
  125. package/src/superlocalmemory/loops/budget.py +58 -0
  126. package/src/superlocalmemory/loops/engine.py +164 -0
  127. package/src/superlocalmemory/loops/ledger.py +243 -0
  128. package/src/superlocalmemory/loops/models.py +152 -0
  129. package/src/superlocalmemory/loops/rules.py +52 -0
  130. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  131. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  132. package/src/superlocalmemory/mcp/profiles.py +103 -0
  133. package/src/superlocalmemory/mcp/server.py +21 -49
  134. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  135. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  136. package/src/superlocalmemory/mcp/tools_core.py +8 -1
  137. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  138. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  139. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  140. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  141. package/src/superlocalmemory/mesh/broker.py +237 -129
  142. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  143. package/src/superlocalmemory/optimize/NOTICE +1 -6
  144. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  145. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  146. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  147. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  148. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  149. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  150. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  151. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  152. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  153. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  154. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  155. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  156. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  157. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  158. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  159. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  160. package/src/superlocalmemory/retrieval/engine.py +168 -26
  161. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  162. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  163. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  164. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  165. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  166. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  167. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  168. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  169. package/src/superlocalmemory/server/api.py +4 -4
  170. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  171. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  172. package/src/superlocalmemory/server/recall_health.py +24 -3
  173. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  174. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  175. package/src/superlocalmemory/server/routes/agents.py +128 -38
  176. package/src/superlocalmemory/server/routes/backup.py +34 -10
  177. package/src/superlocalmemory/server/routes/behavioral.py +13 -12
  178. package/src/superlocalmemory/server/routes/brain.py +21 -5
  179. package/src/superlocalmemory/server/routes/chat.py +10 -5
  180. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  181. package/src/superlocalmemory/server/routes/config_api.py +436 -0
  182. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  183. package/src/superlocalmemory/server/routes/entity.py +9 -4
  184. package/src/superlocalmemory/server/routes/events.py +24 -8
  185. package/src/superlocalmemory/server/routes/evolution.py +135 -17
  186. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  187. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  188. package/src/superlocalmemory/server/routes/insights.py +3 -3
  189. package/src/superlocalmemory/server/routes/learning.py +14 -14
  190. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  191. package/src/superlocalmemory/server/routes/memories.py +182 -57
  192. package/src/superlocalmemory/server/routes/mesh.py +95 -15
  193. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  194. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  195. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  196. package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
  197. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  198. package/src/superlocalmemory/server/routes/stats.py +13 -6
  199. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  200. package/src/superlocalmemory/server/routes/v3_api.py +183 -69
  201. package/src/superlocalmemory/server/routes/ws.py +5 -2
  202. package/src/superlocalmemory/server/security_middleware.py +12 -5
  203. package/src/superlocalmemory/server/ui.py +20 -5
  204. package/src/superlocalmemory/server/unified_daemon.py +384 -56
  205. package/src/superlocalmemory/server/write_identity.py +38 -8
  206. package/src/superlocalmemory/storage/database.py +265 -53
  207. package/src/superlocalmemory/storage/migration_runner.py +53 -0
  208. package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
  209. package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
  210. package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
  211. package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
  212. package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
  213. package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
  214. package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
  215. package/src/superlocalmemory/storage/models.py +4 -0
  216. package/src/superlocalmemory/storage/schema.py +87 -0
  217. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  218. package/src/superlocalmemory/trust/gate.py +49 -8
  219. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  220. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  221. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  222. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  223. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  224. package/src/superlocalmemory/ui/favicon.svg +35 -4
  225. package/src/superlocalmemory/ui/index.html +306 -173
  226. package/src/superlocalmemory/ui/js/brain.js +5 -20
  227. package/src/superlocalmemory/ui/js/core.js +47 -31
  228. package/src/superlocalmemory/ui/js/dashboard.js +314 -63
  229. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  230. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  231. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  232. package/src/superlocalmemory/ui/js/memories.js +15 -4
  233. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  234. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  235. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  236. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  237. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  238. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  239. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  240. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  241. package/src/superlocalmemory/ui/js/od-backup.js +780 -0
  242. package/src/superlocalmemory/ui/js/od-brain.js +779 -0
  243. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  244. package/src/superlocalmemory/ui/js/od-graph.js +593 -0
  245. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  246. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  247. package/src/superlocalmemory/ui/js/od-memories.js +887 -0
  248. package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
  249. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  250. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  251. package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
  252. package/src/superlocalmemory/ui/js/od-shell.js +593 -0
  253. package/src/superlocalmemory/ui/js/od-skills.js +573 -0
  254. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  255. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  256. package/src/superlocalmemory/ui/js/settings.js +2 -2
  257. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  258. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  259. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  260. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -0,0 +1,477 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 — RBAC / teams (C3)
4
+
5
+ """Role-based access control for the memory tenancy model.
6
+
7
+ The tenancy boundary is the *profile* (individual / team / company). This
8
+ engine adds real, persisted user identity and role grants on top of that
9
+ boundary, following the Cognee reference model (Tenant -> User -> Role).
10
+
11
+ Design principles
12
+ -----------------
13
+ * **Additive / self-hosting-correct.** With zero users the daemon stays
14
+ single-operator: the machine operator (install token / daemon capability
15
+ over loopback) is the implicit *owner* with every permission, so individual
16
+ use has no login friction. Once an org creates users + memberships from the
17
+ dashboard, logged-in users are enforced against their role.
18
+ * **Enforced, not decorative.** ``has_permission`` is called from the write /
19
+ delete / share / manage routes. This module is the single source of truth
20
+ for the role -> permission matrix.
21
+ * **No plaintext secrets.** Passwords are hashed with ``hashlib.scrypt`` and a
22
+ per-user random salt; session tokens are stored only as SHA-256 hashes and
23
+ compared in constant time.
24
+
25
+ All state lives in memory.db (tables from migration M024). Connections are
26
+ short-lived (opened per call) with a bounded busy timeout, matching the mesh
27
+ broker's concurrency-safe pattern.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import hashlib
33
+ import hmac
34
+ import logging
35
+ import secrets
36
+ import sqlite3
37
+ import uuid
38
+ from datetime import datetime, timedelta, timezone
39
+ from enum import Enum
40
+ from pathlib import Path
41
+
42
+ logger = logging.getLogger("superlocalmemory.access.rbac")
43
+
44
+ _BUSY_TIMEOUT_MS = 4000
45
+ _SESSION_TTL_HOURS = 12
46
+
47
+ # scrypt cost parameters (OWASP-recommended interactive-login range).
48
+ _SCRYPT_N = 2 ** 14
49
+ _SCRYPT_R = 8
50
+ _SCRYPT_P = 1
51
+ _SCRYPT_DKLEN = 32
52
+
53
+
54
+ class Role(str, Enum):
55
+ ADMIN = "admin"
56
+ MEMBER = "member"
57
+ VIEWER = "viewer"
58
+
59
+
60
+ class Permission(str, Enum):
61
+ READ = "read"
62
+ WRITE = "write"
63
+ DELETE = "delete"
64
+ SHARE = "share"
65
+ MANAGE = "manage" # manage users, roles, profile settings
66
+
67
+
68
+ # Role -> permission set. This is the single source of truth for authorization.
69
+ _ROLE_PERMISSIONS: dict[Role, frozenset[Permission]] = {
70
+ Role.ADMIN: frozenset(Permission),
71
+ Role.MEMBER: frozenset({Permission.READ, Permission.WRITE, Permission.SHARE}),
72
+ Role.VIEWER: frozenset({Permission.READ}),
73
+ }
74
+
75
+
76
+ def permissions_for_role(role: Role) -> frozenset[Permission]:
77
+ return _ROLE_PERMISSIONS.get(role, frozenset())
78
+
79
+
80
+ class RbacError(ValueError):
81
+ """Raised for invalid RBAC operations (bad role, duplicate user, ...)."""
82
+
83
+
84
+ def _now() -> str:
85
+ return datetime.now(timezone.utc).isoformat()
86
+
87
+
88
+ def _uid() -> str:
89
+ return uuid.uuid4().hex[:16]
90
+
91
+
92
+ def _hash_password(password: str) -> str:
93
+ """scrypt$N$r$p$salt_hex$dk_hex — self-describing so params can evolve."""
94
+ if not password or len(password) < 8:
95
+ raise RbacError("Password must be at least 8 characters.")
96
+ salt = secrets.token_bytes(16)
97
+ dk = hashlib.scrypt(
98
+ password.encode("utf-8"), salt=salt,
99
+ n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_DKLEN,
100
+ )
101
+ return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${dk.hex()}"
102
+
103
+
104
+ def _verify_password(password: str, stored: str) -> bool:
105
+ try:
106
+ scheme, n, r, p, salt_hex, dk_hex = stored.split("$")
107
+ if scheme != "scrypt":
108
+ return False
109
+ dk = hashlib.scrypt(
110
+ password.encode("utf-8"), salt=bytes.fromhex(salt_hex),
111
+ n=int(n), r=int(r), p=int(p), dklen=len(bytes.fromhex(dk_hex)),
112
+ )
113
+ return hmac.compare_digest(dk.hex(), dk_hex)
114
+ except (ValueError, TypeError):
115
+ return False
116
+
117
+
118
+ def _hash_token(raw_token: str) -> str:
119
+ return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
120
+
121
+
122
+ # Constant dummy hash so verify_credentials spends ~equal time on unknown and
123
+ # known usernames (defeats the username-enumeration timing oracle). Computed
124
+ # once at import.
125
+ _DUMMY_PASSWORD_HASH = _hash_password("__slm_dummy_sentinel__never_a_real_pw__")
126
+
127
+
128
+ class RbacEngine:
129
+ """Persisted user / membership / session store with role enforcement."""
130
+
131
+ def __init__(self, db_path: str | Path) -> None:
132
+ self._db_path = str(db_path)
133
+
134
+ # -- connection -------------------------------------------------------
135
+
136
+ def _conn(self) -> sqlite3.Connection:
137
+ conn = sqlite3.connect(self._db_path, timeout=_BUSY_TIMEOUT_MS / 1000)
138
+ conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
139
+ conn.row_factory = sqlite3.Row
140
+ return conn
141
+
142
+ # -- users ------------------------------------------------------------
143
+
144
+ def user_count(self) -> int:
145
+ conn = self._conn()
146
+ try:
147
+ return int(conn.execute("SELECT COUNT(*) FROM rbac_users").fetchone()[0])
148
+ finally:
149
+ conn.close()
150
+
151
+ def create_user(self, username: str, password: str, display_name: str = "",
152
+ created_by: str = "owner") -> dict:
153
+ username = (username or "").strip()
154
+ if not username:
155
+ raise RbacError("Username is required.")
156
+ pw_hash = _hash_password(password)
157
+ user_id = _uid()
158
+ conn = self._conn()
159
+ try:
160
+ # The UNIQUE(username) constraint is the source of truth; the SELECT
161
+ # is a fast path. Two concurrent creates can both pass the SELECT, so
162
+ # the loser's INSERT hits the constraint — convert that to RbacError
163
+ # (409) instead of leaking a raw IntegrityError as a 500.
164
+ exists = conn.execute(
165
+ "SELECT 1 FROM rbac_users WHERE username=?", (username,)
166
+ ).fetchone()
167
+ if exists:
168
+ raise RbacError(f"User '{username}' already exists.")
169
+ try:
170
+ conn.execute(
171
+ "INSERT INTO rbac_users (user_id, username, display_name, "
172
+ "password_hash, status, created_at, created_by) "
173
+ "VALUES (?, ?, ?, ?, 'active', ?, ?)",
174
+ (user_id, username, display_name or username, pw_hash,
175
+ _now(), created_by),
176
+ )
177
+ conn.commit()
178
+ except sqlite3.IntegrityError:
179
+ raise RbacError(f"User '{username}' already exists.")
180
+ finally:
181
+ conn.close()
182
+ logger.info("RBAC: created user '%s' (%s)", username, user_id)
183
+ return {"user_id": user_id, "username": username,
184
+ "display_name": display_name or username, "status": "active"}
185
+
186
+ def set_password(self, user_id: str, password: str) -> None:
187
+ pw_hash = _hash_password(password)
188
+ conn = self._conn()
189
+ try:
190
+ cur = conn.execute(
191
+ "UPDATE rbac_users SET password_hash=? WHERE user_id=?",
192
+ (pw_hash, user_id),
193
+ )
194
+ if cur.rowcount == 0:
195
+ raise RbacError("User not found.")
196
+ # A password change invalidates every existing session.
197
+ conn.execute("DELETE FROM rbac_sessions WHERE user_id=?", (user_id,))
198
+ conn.commit()
199
+ finally:
200
+ conn.close()
201
+
202
+ def set_status(self, user_id: str, status: str) -> None:
203
+ if status not in ("active", "disabled"):
204
+ raise RbacError("status must be 'active' or 'disabled'.")
205
+ conn = self._conn()
206
+ try:
207
+ cur = conn.execute(
208
+ "UPDATE rbac_users SET status=? WHERE user_id=?", (status, user_id)
209
+ )
210
+ if cur.rowcount == 0:
211
+ raise RbacError("User not found.")
212
+ if status == "disabled":
213
+ conn.execute("DELETE FROM rbac_sessions WHERE user_id=?", (user_id,))
214
+ conn.commit()
215
+ finally:
216
+ conn.close()
217
+
218
+ def delete_user(self, user_id: str) -> None:
219
+ conn = self._conn()
220
+ try:
221
+ conn.execute("DELETE FROM rbac_sessions WHERE user_id=?", (user_id,))
222
+ conn.execute("DELETE FROM rbac_memberships WHERE user_id=?", (user_id,))
223
+ cur = conn.execute("DELETE FROM rbac_users WHERE user_id=?", (user_id,))
224
+ if cur.rowcount == 0:
225
+ raise RbacError("User not found.")
226
+ conn.commit()
227
+ finally:
228
+ conn.close()
229
+
230
+ def get_user(self, user_id: str) -> dict | None:
231
+ conn = self._conn()
232
+ try:
233
+ row = conn.execute(
234
+ "SELECT user_id, username, display_name, status, created_at, created_by "
235
+ "FROM rbac_users WHERE user_id=?", (user_id,)
236
+ ).fetchone()
237
+ return dict(row) if row else None
238
+ finally:
239
+ conn.close()
240
+
241
+ def list_users(self) -> list[dict]:
242
+ conn = self._conn()
243
+ try:
244
+ rows = conn.execute(
245
+ "SELECT user_id, username, display_name, status, created_at, created_by "
246
+ "FROM rbac_users ORDER BY username"
247
+ ).fetchall()
248
+ return [dict(r) for r in rows]
249
+ finally:
250
+ conn.close()
251
+
252
+ # -- credentials / sessions ------------------------------------------
253
+
254
+ def verify_credentials(self, username: str, password: str) -> dict | None:
255
+ conn = self._conn()
256
+ try:
257
+ row = conn.execute(
258
+ "SELECT user_id, username, display_name, password_hash, status "
259
+ "FROM rbac_users WHERE username=?", ((username or "").strip(),)
260
+ ).fetchone()
261
+ finally:
262
+ conn.close()
263
+ if not row or row["status"] != "active":
264
+ # Run a verify against a constant dummy hash so an unknown/disabled
265
+ # username costs the same wall-time as a wrong password — no timing
266
+ # oracle for username enumeration.
267
+ _verify_password(password, _DUMMY_PASSWORD_HASH)
268
+ return None
269
+ if not _verify_password(password, row["password_hash"]):
270
+ return None
271
+ return {"user_id": row["user_id"], "username": row["username"],
272
+ "display_name": row["display_name"]}
273
+
274
+ def create_session(self, user_id: str, ttl_hours: int = _SESSION_TTL_HOURS) -> str:
275
+ """Mint an opaque session token; only its hash is stored. Returns the
276
+ raw token (shown to the client once)."""
277
+ raw = secrets.token_urlsafe(32)
278
+ now = datetime.now(timezone.utc)
279
+ expires = (now + timedelta(hours=ttl_hours)).isoformat()
280
+ conn = self._conn()
281
+ try:
282
+ conn.execute(
283
+ "INSERT INTO rbac_sessions (token_hash, user_id, created_at, "
284
+ "expires_at, last_seen) VALUES (?, ?, ?, ?, ?)",
285
+ (_hash_token(raw), user_id, now.isoformat(), expires, now.isoformat()),
286
+ )
287
+ conn.commit()
288
+ finally:
289
+ conn.close()
290
+ return raw
291
+
292
+ def resolve_session(self, raw_token: str) -> dict | None:
293
+ """Return the active user for a session token, or None if invalid /
294
+ expired / disabled. Updates last_seen. Constant-time by hash lookup."""
295
+ if not raw_token:
296
+ return None
297
+ token_hash = _hash_token(raw_token)
298
+ now = datetime.now(timezone.utc)
299
+ conn = self._conn()
300
+ try:
301
+ row = conn.execute(
302
+ "SELECT s.user_id, s.expires_at, s.last_seen, "
303
+ "u.username, u.display_name, u.status "
304
+ "FROM rbac_sessions s JOIN rbac_users u ON u.user_id = s.user_id "
305
+ "WHERE s.token_hash=?", (token_hash,)
306
+ ).fetchone()
307
+ if not row:
308
+ return None
309
+ try:
310
+ expired = datetime.fromisoformat(row["expires_at"]) <= now
311
+ except ValueError:
312
+ expired = True
313
+ if expired or row["status"] != "active":
314
+ conn.execute("DELETE FROM rbac_sessions WHERE token_hash=?", (token_hash,))
315
+ conn.commit()
316
+ return None
317
+ # Debounce last_seen: only write when it is stale (>60s), so a burst
318
+ # of authenticated requests does not serialize through the single
319
+ # SQLite writer on every call.
320
+ stale = True
321
+ try:
322
+ stale = (now - datetime.fromisoformat(row["last_seen"])).total_seconds() > 60
323
+ except (ValueError, TypeError, KeyError):
324
+ stale = True
325
+ if stale:
326
+ conn.execute(
327
+ "UPDATE rbac_sessions SET last_seen=? WHERE token_hash=?",
328
+ (now.isoformat(), token_hash),
329
+ )
330
+ conn.commit()
331
+ return {"user_id": row["user_id"], "username": row["username"],
332
+ "display_name": row["display_name"]}
333
+ finally:
334
+ conn.close()
335
+
336
+ def revoke_session(self, raw_token: str) -> None:
337
+ conn = self._conn()
338
+ try:
339
+ conn.execute("DELETE FROM rbac_sessions WHERE token_hash=?",
340
+ (_hash_token(raw_token),))
341
+ conn.commit()
342
+ finally:
343
+ conn.close()
344
+
345
+ def purge_expired_sessions(self) -> int:
346
+ conn = self._conn()
347
+ try:
348
+ cur = conn.execute(
349
+ "DELETE FROM rbac_sessions WHERE expires_at <= ?", (_now(),)
350
+ )
351
+ conn.commit()
352
+ return cur.rowcount or 0
353
+ finally:
354
+ conn.close()
355
+
356
+ # -- memberships ------------------------------------------------------
357
+
358
+ def set_membership(self, profile_id: str, user_id: str, role: str,
359
+ added_by: str = "owner") -> dict:
360
+ try:
361
+ role_enum = Role(role)
362
+ except ValueError:
363
+ raise RbacError(f"Invalid role '{role}'. Use admin/member/viewer.")
364
+ conn = self._conn()
365
+ try:
366
+ if not conn.execute(
367
+ "SELECT 1 FROM rbac_users WHERE user_id=?", (user_id,)
368
+ ).fetchone():
369
+ raise RbacError("User not found.")
370
+ conn.execute(
371
+ "INSERT INTO rbac_memberships (profile_id, user_id, role, added_at, added_by) "
372
+ "VALUES (?, ?, ?, ?, ?) "
373
+ "ON CONFLICT(profile_id, user_id) DO UPDATE SET role=excluded.role, "
374
+ "added_at=excluded.added_at, added_by=excluded.added_by",
375
+ (profile_id, user_id, role_enum.value, _now(), added_by),
376
+ )
377
+ conn.commit()
378
+ finally:
379
+ conn.close()
380
+ return {"profile_id": profile_id, "user_id": user_id, "role": role_enum.value}
381
+
382
+ def remove_membership(self, profile_id: str, user_id: str) -> None:
383
+ conn = self._conn()
384
+ try:
385
+ conn.execute(
386
+ "DELETE FROM rbac_memberships WHERE profile_id=? AND user_id=?",
387
+ (profile_id, user_id),
388
+ )
389
+ conn.commit()
390
+ finally:
391
+ conn.close()
392
+
393
+ def get_role(self, user_id: str, profile_id: str) -> Role | None:
394
+ conn = self._conn()
395
+ try:
396
+ row = conn.execute(
397
+ "SELECT role FROM rbac_memberships WHERE user_id=? AND profile_id=?",
398
+ (user_id, profile_id),
399
+ ).fetchone()
400
+ finally:
401
+ conn.close()
402
+ if not row:
403
+ return None
404
+ try:
405
+ return Role(row["role"])
406
+ except ValueError:
407
+ return None
408
+
409
+ def list_members(self, profile_id: str) -> list[dict]:
410
+ conn = self._conn()
411
+ try:
412
+ rows = conn.execute(
413
+ "SELECT m.user_id, u.username, u.display_name, m.role, m.added_at "
414
+ "FROM rbac_memberships m JOIN rbac_users u ON u.user_id = m.user_id "
415
+ "WHERE m.profile_id=? ORDER BY u.username", (profile_id,)
416
+ ).fetchall()
417
+ return [dict(r) for r in rows]
418
+ finally:
419
+ conn.close()
420
+
421
+ def list_user_profiles(self, user_id: str) -> list[dict]:
422
+ conn = self._conn()
423
+ try:
424
+ rows = conn.execute(
425
+ "SELECT profile_id, role FROM rbac_memberships WHERE user_id=?",
426
+ (user_id,)
427
+ ).fetchall()
428
+ return [dict(r) for r in rows]
429
+ finally:
430
+ conn.close()
431
+
432
+ # -- policy settings --------------------------------------------------
433
+
434
+ def get_policy(self, key: str, default: str = "") -> str:
435
+ conn = self._conn()
436
+ try:
437
+ row = conn.execute(
438
+ "SELECT value FROM rbac_settings WHERE key=?", (key,)
439
+ ).fetchone()
440
+ return row["value"] if row else default
441
+ finally:
442
+ conn.close()
443
+
444
+ def set_policy(self, key: str, value: str) -> None:
445
+ conn = self._conn()
446
+ try:
447
+ conn.execute(
448
+ "INSERT INTO rbac_settings (key, value) VALUES (?, ?) "
449
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
450
+ (key, value),
451
+ )
452
+ conn.commit()
453
+ finally:
454
+ conn.close()
455
+
456
+ def require_login(self) -> bool:
457
+ """Company mode: mutations require a valid user session (no owner
458
+ bypass). Default off (personal / single-operator use)."""
459
+ return self.get_policy("require_login", "0") == "1"
460
+
461
+ def set_require_login(self, enabled: bool) -> None:
462
+ self.set_policy("require_login", "1" if enabled else "0")
463
+
464
+ # -- authorization ----------------------------------------------------
465
+
466
+ def has_permission(self, user_id: str, profile_id: str,
467
+ permission: Permission | str) -> bool:
468
+ """True iff the user's role on the profile grants ``permission``.
469
+
470
+ A user with no membership on the profile has NO access (deny-by-default).
471
+ The implicit machine owner is handled by the caller, not here.
472
+ """
473
+ perm = permission if isinstance(permission, Permission) else Permission(permission)
474
+ role = self.get_role(user_id, profile_id)
475
+ if role is None:
476
+ return False
477
+ return perm in permissions_for_role(role)
@@ -40,6 +40,14 @@ def _cmd_db_dispatch(args: Namespace) -> None:
40
40
  sys.exit(2)
41
41
 
42
42
 
43
+ def _cmd_mesh_dispatch(args: Namespace) -> None:
44
+ """Route ``slm mesh ...`` inspection subcommands (M-03)."""
45
+ from superlocalmemory.cli.mesh_cmd import cmd_mesh
46
+ rc = cmd_mesh(args)
47
+ if rc:
48
+ sys.exit(rc)
49
+
50
+
43
51
  def _cmd_escape_disable(args: Namespace) -> None:
44
52
  from superlocalmemory.cli.escape_hatch import cmd_disable
45
53
  cmd_disable(args)
@@ -98,6 +106,11 @@ def _cmd_help_optimize(args: Namespace) -> None:
98
106
  cmd_help_optimize(args)
99
107
 
100
108
 
109
+ def _cmd_loop(args: Namespace) -> None:
110
+ from superlocalmemory.cli.loop_cmd import cmd_loop
111
+ cmd_loop(args)
112
+
113
+
101
114
  # ---- end SLM v3.6 Optimize dispatch functions ----
102
115
 
103
116
 
@@ -201,6 +214,8 @@ def dispatch(args: Namespace) -> None:
201
214
  "context": _cmd_context_dispatch,
202
215
  # V3.4.22 LLD-06 additive schema migrations
203
216
  "db": _cmd_db_dispatch,
217
+ # V3.7.9 M-03 — terminal mesh inspection
218
+ "mesh": _cmd_mesh_dispatch,
204
219
  # V3.4.22 Stage 8 SB-5 — MASTER-PLAN §8 escape hatches.
205
220
  "disable": _cmd_escape_disable,
206
221
  "enable": _cmd_escape_enable,
@@ -218,6 +233,8 @@ def dispatch(args: Namespace) -> None:
218
233
  "compress": _cmd_compress,
219
234
  "proxy": _cmd_proxy,
220
235
  "help-optimize": _cmd_help_optimize,
236
+ # V3.8.0 bounded loops (gate-verified agent loops, SLM-backed ledger)
237
+ "loop": _cmd_loop,
221
238
  }
222
239
  handler = handlers.get(args.command)
223
240
  if handler:
@@ -345,6 +362,11 @@ def cmd_serve(args: Namespace) -> None:
345
362
  else:
346
363
  from superlocalmemory.infra.data_root import state_path
347
364
  print(f"Failed to start daemon. Check {state_path('logs', 'daemon.log')}")
365
+ # INT-H-02: exit non-zero so callers can detect the failure. The plugin
366
+ # launcher (plugin/scripts/slm-launch) guards on this exit code and
367
+ # refuses to open a direct MCP writer against a broken daemon; without
368
+ # the non-zero exit that guard was a dead no-op.
369
+ sys.exit(1)
348
370
 
349
371
 
350
372
  # -- Ingestion Adapters (V3.4.3) ------------------------------------------
@@ -610,6 +632,8 @@ def cmd_config(args: Namespace) -> None:
610
632
  elif action == "set":
611
633
  _ALLOWED_CONFIG_KEYS = {
612
634
  "evolution.enabled", "evolution.backend", "evolution.max_evolutions_per_cycle",
635
+ "evolution.mutation_model", "evolution.verify_model",
636
+ "evolution.confirm_model",
613
637
  "mesh_enabled", "daemon_idle_timeout", "entity_compilation_enabled",
614
638
  "graph_backend", "vector_backend", "scale_engine_state",
615
639
  "scope.default_scope", "scope.recall_include_global",
@@ -677,6 +701,31 @@ def cmd_config(args: Namespace) -> None:
677
701
  print(f"Error: {message}")
678
702
  sys.exit(1)
679
703
 
704
+ _MODEL_KEYS = {
705
+ "evolution.mutation_model", "evolution.verify_model",
706
+ "evolution.confirm_model",
707
+ }
708
+ if key in _MODEL_KEYS:
709
+ from superlocalmemory.evolution.model_selection import _MODEL_ALIASES
710
+
711
+ accepted = set(_MODEL_ALIASES) | {"", "auto"}
712
+ sval = str(parsed_value)
713
+ if sval not in accepted:
714
+ allowed = ", ".join(["auto", *sorted(_MODEL_ALIASES)])
715
+ message = f"{key} must be one of: {allowed}"
716
+ if use_json:
717
+ from superlocalmemory.cli.json_output import json_print
718
+ json_print("config", error={
719
+ "code": "INVALID_VALUE", "message": message,
720
+ })
721
+ else:
722
+ print(f"Error: {message}")
723
+ sys.exit(1)
724
+ # Normalise the "auto" sentinel to the empty string the
725
+ # resolver treats as "pick the cheapest for the backend".
726
+ if sval == "auto":
727
+ parsed_value = ""
728
+
680
729
  if key.startswith("scope."):
681
730
  from superlocalmemory.cli.daemon import daemon_request, is_daemon_running
682
731
 
@@ -727,6 +776,18 @@ def cmd_config(args: Namespace) -> None:
727
776
  })
728
777
  else:
729
778
  print(f"{key}: {old_value} -> {parsed_value}")
779
+ if key == "evolution.enabled" and parsed_value is True:
780
+ print(
781
+ "\n⚠ Skill evolution is now ON. It makes background "
782
+ "LLM calls during consolidation\n"
783
+ " (capped at 10 calls/cycle, 3 cycles/day). It defaults "
784
+ "to the lowest-cost model\n"
785
+ " for your backend (Claude → Haiku, Ollama → "
786
+ "local/free).\n"
787
+ " Pick models: slm config set evolution.mutation_model "
788
+ "<auto|haiku|sonnet|ollama>\n"
789
+ " Turn off: slm config set evolution.enabled false"
790
+ )
730
791
 
731
792
  else:
732
793
  if use_json:
@@ -1271,10 +1332,12 @@ def cmd_recall(args: Namespace) -> None:
1271
1332
  scope_qs += f"&include_global={str(include_global).lower()}"
1272
1333
  if include_shared is not None:
1273
1334
  scope_qs += f"&include_shared={str(include_shared).lower()}"
1335
+ _window = getattr(args, "window", "") or ""
1336
+ window_qs = f"&window={quote(_window)}" if _window else ""
1274
1337
  result = daemon_request(
1275
1338
  "GET",
1276
1339
  f"/recall?q={quote(args.query)}&limit={args.limit}"
1277
- f"&session_id={quote(session_id)}{fast_qs}{scope_qs}",
1340
+ f"&session_id={quote(session_id)}{fast_qs}{scope_qs}{window_qs}",
1278
1341
  )
1279
1342
  if result and "results" in result:
1280
1343
  # Format daemon response same as engine response
@@ -1294,8 +1357,10 @@ def cmd_recall(args: Namespace) -> None:
1294
1357
  for i, r in enumerate(result["results"], 1):
1295
1358
  print(f" {i}. [{r['score']:.2f}] {r['content']}")
1296
1359
  return
1297
- except Exception:
1298
- pass # Fall through to direct engine
1360
+ except Exception as _exc: # noqa: BLE001
1361
+ logger.warning(
1362
+ "Daemon recall failed, falling back to direct engine: %s", _exc
1363
+ )
1299
1364
 
1300
1365
  from superlocalmemory.core.config import SLMConfig
1301
1366
  from superlocalmemory.core.engine import MemoryEngine
@@ -1310,6 +1375,7 @@ def cmd_recall(args: Namespace) -> None:
1310
1375
  fast=getattr(args, "fast", False),
1311
1376
  include_global=include_global,
1312
1377
  include_shared=include_shared,
1378
+ window=getattr(args, "window", "") or None,
1313
1379
  )
1314
1380
  except Exception as exc:
1315
1381
  if use_json:
@@ -1394,12 +1460,32 @@ def cmd_forget(args: Namespace) -> None:
1394
1460
  from superlocalmemory.core.config import SLMConfig
1395
1461
 
1396
1462
  use_json = getattr(args, 'json', False)
1463
+ dry_run = getattr(args, 'dry_run', False)
1464
+ raw_query = getattr(args, 'query', None)
1465
+
1466
+ # F3: `query` is optional so `slm forget --dry-run` can preview every memory.
1467
+ # Deletion ALWAYS requires an explicit query — a bare `slm forget` must never
1468
+ # mass-delete. In dry-run mode a missing query means "match all" (preview).
1469
+ if raw_query is None and not dry_run:
1470
+ msg = (
1471
+ "A query is required to delete. Preview everything with "
1472
+ "'slm forget --dry-run', or delete matches with 'slm forget <query>'."
1473
+ )
1474
+ if use_json:
1475
+ from superlocalmemory.cli.json_output import json_print
1476
+ json_print("forget", error={"code": "QUERY_REQUIRED", "message": msg})
1477
+ else:
1478
+ print(msg)
1479
+ sys.exit(2)
1480
+
1481
+ query_lower = "" if raw_query is None else raw_query.lower()
1482
+ query_label = raw_query if raw_query is not None else "*all*"
1483
+
1397
1484
  try:
1398
1485
  config = SLMConfig.load()
1399
1486
  engine = MemoryEngine(config)
1400
1487
  engine.initialize()
1401
1488
  facts = engine._db.get_all_facts(engine.profile_id)
1402
- query_lower = args.query.lower()
1403
1489
  matches = [f for f in facts if query_lower in f.content.lower()]
1404
1490
  except Exception as exc:
1405
1491
  if use_json:
@@ -1408,8 +1494,6 @@ def cmd_forget(args: Namespace) -> None:
1408
1494
  sys.exit(1)
1409
1495
  raise
1410
1496
 
1411
- dry_run = getattr(args, 'dry_run', False)
1412
-
1413
1497
  def delete_fact_authorized_for_cli(fact_id: str) -> None:
1414
1498
  from superlocalmemory.core.engine_ingestion import local_trusted_actor_id
1415
1499
  from superlocalmemory.core.mutations import delete_fact_authorized
@@ -1450,12 +1534,12 @@ def cmd_forget(args: Namespace) -> None:
1450
1534
  "matches": match_items,
1451
1535
  "hint": "Add --yes to confirm deletion",
1452
1536
  }, next_actions=[
1453
- {"command": f"slm forget '{args.query}' --json --yes", "description": "Confirm deletion"},
1537
+ {"command": f"slm forget '{query_label}' --json --yes", "description": "Confirm deletion"},
1454
1538
  ])
1455
1539
  return
1456
1540
 
1457
1541
  if not matches:
1458
- print(f"No memories matching '{args.query}'")
1542
+ print(f"No memories matching '{query_label}'")
1459
1543
  return
1460
1544
  print(f"Found {len(matches)} matching memories:")
1461
1545
  for f in matches[:10]:
@@ -1835,8 +1919,8 @@ def cmd_health(args: Namespace) -> None:
1835
1919
 
1836
1920
  print("Math Layer Health:")
1837
1921
  print(f" Total facts: {len(facts)}")
1838
- print(f" Fisher-Rao indexed: {fisher_count}/{len(facts)}")
1839
- print(f" Langevin positioned: {langevin_count}/{len(facts)}")
1922
+ print(f" Math layer indexed: {fisher_count}/{len(facts)}")
1923
+ print(f" Lifecycle positioned: {langevin_count}/{len(facts)}")
1840
1924
  print(f" Mode: {config.mode.value.upper()}")
1841
1925
 
1842
1926