superlocalmemory 3.7.8 → 3.8.1

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 (280) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +129 -0
  4. package/README.md +205 -123
  5. package/package.json +12 -3
  6. package/plugin/.claude-plugin/plugin.json +2 -3
  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/rules/AGENTS.md +57 -18
  30. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  31. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  32. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  33. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  34. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  36. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  37. package/pyproject.toml +2 -1
  38. package/scripts/postinstall/validation.js +2 -0
  39. package/scripts/postinstall-interactive.js +74 -2
  40. package/src/superlocalmemory/__init__.py +1 -1
  41. package/src/superlocalmemory/access/__init__.py +3 -0
  42. package/src/superlocalmemory/access/rbac.py +477 -0
  43. package/src/superlocalmemory/cli/commands.py +228 -17
  44. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  45. package/src/superlocalmemory/cli/daemon.py +7 -0
  46. package/src/superlocalmemory/cli/loop_cmd.py +187 -0
  47. package/src/superlocalmemory/cli/main.py +49 -8
  48. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  49. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  50. package/src/superlocalmemory/cli/pending_store.py +49 -13
  51. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  52. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  53. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  54. package/src/superlocalmemory/cli/version_banner.py +17 -3
  55. package/src/superlocalmemory/compliance/audit.py +6 -0
  56. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  57. package/src/superlocalmemory/compliance/retention.py +176 -45
  58. package/src/superlocalmemory/core/backend_orchestrator.py +23 -59
  59. package/src/superlocalmemory/core/community_summary.py +267 -0
  60. package/src/superlocalmemory/core/config.py +216 -3
  61. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  62. package/src/superlocalmemory/core/context_cache.py +61 -18
  63. package/src/superlocalmemory/core/embedding_worker.py +21 -7
  64. package/src/superlocalmemory/core/embeddings.py +131 -46
  65. package/src/superlocalmemory/core/engine.py +41 -22
  66. package/src/superlocalmemory/core/engine_ingestion.py +359 -43
  67. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  68. package/src/superlocalmemory/core/entity_community.py +178 -0
  69. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  70. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  71. package/src/superlocalmemory/core/ingestion_command.py +134 -25
  72. package/src/superlocalmemory/core/injection.py +12 -7
  73. package/src/superlocalmemory/core/key_expander.py +138 -0
  74. package/src/superlocalmemory/core/maintenance.py +23 -0
  75. package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
  76. package/src/superlocalmemory/core/modes.py +1 -1
  77. package/src/superlocalmemory/core/mutations.py +2 -2
  78. package/src/superlocalmemory/core/pii.py +105 -0
  79. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  80. package/src/superlocalmemory/core/recall_pipeline.py +7 -3
  81. package/src/superlocalmemory/core/recall_worker.py +20 -6
  82. package/src/superlocalmemory/core/scale_engine.py +60 -1
  83. package/src/superlocalmemory/core/security_primitives.py +40 -2
  84. package/src/superlocalmemory/core/store_pipeline.py +186 -29
  85. package/src/superlocalmemory/core/worker_pool.py +21 -6
  86. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  87. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  88. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  89. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  90. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  91. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  92. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  93. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  94. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  95. package/src/superlocalmemory/evolution/triggers.py +22 -13
  96. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  97. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  98. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  99. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  100. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  101. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  102. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  103. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  104. package/src/superlocalmemory/hooks/portable_kit.py +148 -3
  105. package/src/superlocalmemory/infra/backup.py +12 -1
  106. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  107. package/src/superlocalmemory/infra/data_root.py +43 -4
  108. package/src/superlocalmemory/infra/event_bus.py +107 -24
  109. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  110. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  111. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  112. package/src/superlocalmemory/learning/cross_project.py +28 -19
  113. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  114. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  115. package/src/superlocalmemory/learning/reward.py +50 -0
  116. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  117. package/src/superlocalmemory/learning/source_quality.py +523 -1
  118. package/src/superlocalmemory/loops/__init__.py +56 -0
  119. package/src/superlocalmemory/loops/budget.py +58 -0
  120. package/src/superlocalmemory/loops/engine.py +164 -0
  121. package/src/superlocalmemory/loops/ledger.py +263 -0
  122. package/src/superlocalmemory/loops/models.py +152 -0
  123. package/src/superlocalmemory/loops/rules.py +52 -0
  124. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  125. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  126. package/src/superlocalmemory/mcp/profiles.py +103 -0
  127. package/src/superlocalmemory/mcp/server.py +32 -79
  128. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  129. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  130. package/src/superlocalmemory/mcp/tools_core.py +12 -4
  131. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  132. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  133. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  134. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  135. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  136. package/src/superlocalmemory/mesh/broker.py +237 -129
  137. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  138. package/src/superlocalmemory/optimize/NOTICE +1 -6
  139. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  140. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  141. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  142. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  143. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  144. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  145. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  146. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  147. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  148. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  149. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  150. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  151. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  152. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  153. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  154. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  155. package/src/superlocalmemory/retrieval/engine.py +221 -47
  156. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  157. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  158. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  159. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  160. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  161. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  162. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  163. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  164. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  165. package/src/superlocalmemory/server/api.py +4 -4
  166. package/src/superlocalmemory/server/config_file.py +90 -0
  167. package/src/superlocalmemory/server/origin.py +50 -0
  168. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  169. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  170. package/src/superlocalmemory/server/recall_health.py +24 -3
  171. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  172. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  173. package/src/superlocalmemory/server/routes/agents.py +128 -38
  174. package/src/superlocalmemory/server/routes/backup.py +317 -70
  175. package/src/superlocalmemory/server/routes/behavioral.py +349 -71
  176. package/src/superlocalmemory/server/routes/brain.py +69 -12
  177. package/src/superlocalmemory/server/routes/chat.py +10 -5
  178. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  179. package/src/superlocalmemory/server/routes/config_api.py +438 -0
  180. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  181. package/src/superlocalmemory/server/routes/entity.py +108 -26
  182. package/src/superlocalmemory/server/routes/events.py +24 -8
  183. package/src/superlocalmemory/server/routes/evolution.py +189 -68
  184. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  185. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  186. package/src/superlocalmemory/server/routes/insights.py +3 -3
  187. package/src/superlocalmemory/server/routes/learning.py +289 -118
  188. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  189. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  190. package/src/superlocalmemory/server/routes/memories.py +182 -57
  191. package/src/superlocalmemory/server/routes/mesh.py +200 -31
  192. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  193. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  194. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  195. package/src/superlocalmemory/server/routes/ratelimit.py +132 -0
  196. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  197. package/src/superlocalmemory/server/routes/stats.py +103 -158
  198. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  199. package/src/superlocalmemory/server/routes/token.py +3 -13
  200. package/src/superlocalmemory/server/routes/v3_api.py +247 -89
  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 +827 -72
  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 +132 -1
  208. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  209. package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
  210. package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
  211. package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
  212. package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
  213. package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
  214. package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
  215. package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
  216. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  217. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  218. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  219. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  220. package/src/superlocalmemory/storage/models.py +4 -0
  221. package/src/superlocalmemory/storage/schema.py +136 -1
  222. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  223. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  224. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  225. package/src/superlocalmemory/trust/gate.py +49 -8
  226. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  227. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  228. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  229. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  230. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  231. package/src/superlocalmemory/ui/favicon.svg +35 -4
  232. package/src/superlocalmemory/ui/index.html +303 -173
  233. package/src/superlocalmemory/ui/js/brain.js +5 -20
  234. package/src/superlocalmemory/ui/js/core.js +100 -41
  235. package/src/superlocalmemory/ui/js/dashboard.js +403 -65
  236. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  237. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  238. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  239. package/src/superlocalmemory/ui/js/memories.js +15 -4
  240. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  241. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  242. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  243. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  244. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  245. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  246. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  247. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  248. package/src/superlocalmemory/ui/js/od-backup.js +871 -0
  249. package/src/superlocalmemory/ui/js/od-brain.js +816 -0
  250. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  251. package/src/superlocalmemory/ui/js/od-graph.js +600 -0
  252. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  253. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  254. package/src/superlocalmemory/ui/js/od-memories.js +929 -0
  255. package/src/superlocalmemory/ui/js/od-mesh.js +553 -0
  256. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  257. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  258. package/src/superlocalmemory/ui/js/od-settings.js +1107 -0
  259. package/src/superlocalmemory/ui/js/od-shell.js +809 -0
  260. package/src/superlocalmemory/ui/js/od-skills.js +600 -0
  261. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  262. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  263. package/src/superlocalmemory/ui/js/settings.js +17 -3
  264. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  265. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  266. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  267. package/plugin-src/.mcp.json +0 -12
  268. package/plugin-src/agents/slm-memory-advisor.md +0 -44
  269. package/plugin-src/agents/slm-optimize-advisor.md +0 -38
  270. package/plugin-src/hooks/.gitkeep +0 -0
  271. package/plugin-src/hooks/hooks.json +0 -23
  272. package/plugin-src/manifest.json +0 -25
  273. package/plugin-src/requirements.txt +0 -1
  274. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  275. package/plugin-src/scripts/ensure-venv.bat +0 -122
  276. package/plugin-src/scripts/ensure-venv.sh +0 -105
  277. package/plugin-src/scripts/slm-launch +0 -23
  278. package/plugin-src/scripts/slm-launch.bat +0 -23
  279. package/plugin-src/settings.json +0 -16
  280. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -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
 
@@ -146,7 +159,10 @@ def cmd_session(args: Namespace) -> None:
146
159
  def dispatch(args: Namespace) -> None:
147
160
  """Route CLI command to the appropriate handler."""
148
161
  # Auto-install/upgrade hooks on version change (single file read, ~0.1ms)
149
- if args.command not in ("hooks", "codex", "init", "mcp"):
162
+ if (
163
+ args.command not in ("hooks", "codex", "init", "mcp")
164
+ and not getattr(args, "dry_run", False)
165
+ ):
150
166
  try:
151
167
  from superlocalmemory.hooks.claude_code_hooks import auto_install_if_needed
152
168
  auto_install_if_needed()
@@ -201,6 +217,8 @@ def dispatch(args: Namespace) -> None:
201
217
  "context": _cmd_context_dispatch,
202
218
  # V3.4.22 LLD-06 additive schema migrations
203
219
  "db": _cmd_db_dispatch,
220
+ # V3.7.9 M-03 — terminal mesh inspection
221
+ "mesh": _cmd_mesh_dispatch,
204
222
  # V3.4.22 Stage 8 SB-5 — MASTER-PLAN §8 escape hatches.
205
223
  "disable": _cmd_escape_disable,
206
224
  "enable": _cmd_escape_enable,
@@ -218,6 +236,8 @@ def dispatch(args: Namespace) -> None:
218
236
  "compress": _cmd_compress,
219
237
  "proxy": _cmd_proxy,
220
238
  "help-optimize": _cmd_help_optimize,
239
+ # V3.8.0 bounded loops (gate-verified agent loops, SLM-backed ledger)
240
+ "loop": _cmd_loop,
221
241
  }
222
242
  handler = handlers.get(args.command)
223
243
  if handler:
@@ -345,6 +365,11 @@ def cmd_serve(args: Namespace) -> None:
345
365
  else:
346
366
  from superlocalmemory.infra.data_root import state_path
347
367
  print(f"Failed to start daemon. Check {state_path('logs', 'daemon.log')}")
368
+ # INT-H-02: exit non-zero so callers can detect the failure. The plugin
369
+ # launcher (plugin/scripts/slm-launch) guards on this exit code and
370
+ # refuses to open a direct MCP writer against a broken daemon; without
371
+ # the non-zero exit that guard was a dead no-op.
372
+ sys.exit(1)
348
373
 
349
374
 
350
375
  # -- Ingestion Adapters (V3.4.3) ------------------------------------------
@@ -610,6 +635,8 @@ def cmd_config(args: Namespace) -> None:
610
635
  elif action == "set":
611
636
  _ALLOWED_CONFIG_KEYS = {
612
637
  "evolution.enabled", "evolution.backend", "evolution.max_evolutions_per_cycle",
638
+ "evolution.mutation_model", "evolution.verify_model",
639
+ "evolution.confirm_model",
613
640
  "mesh_enabled", "daemon_idle_timeout", "entity_compilation_enabled",
614
641
  "graph_backend", "vector_backend", "scale_engine_state",
615
642
  "scope.default_scope", "scope.recall_include_global",
@@ -677,6 +704,31 @@ def cmd_config(args: Namespace) -> None:
677
704
  print(f"Error: {message}")
678
705
  sys.exit(1)
679
706
 
707
+ _MODEL_KEYS = {
708
+ "evolution.mutation_model", "evolution.verify_model",
709
+ "evolution.confirm_model",
710
+ }
711
+ if key in _MODEL_KEYS:
712
+ from superlocalmemory.evolution.model_selection import _MODEL_ALIASES
713
+
714
+ accepted = set(_MODEL_ALIASES) | {"", "auto"}
715
+ sval = str(parsed_value)
716
+ if sval not in accepted:
717
+ allowed = ", ".join(["auto", *sorted(_MODEL_ALIASES)])
718
+ message = f"{key} must be one of: {allowed}"
719
+ if use_json:
720
+ from superlocalmemory.cli.json_output import json_print
721
+ json_print("config", error={
722
+ "code": "INVALID_VALUE", "message": message,
723
+ })
724
+ else:
725
+ print(f"Error: {message}")
726
+ sys.exit(1)
727
+ # Normalise the "auto" sentinel to the empty string the
728
+ # resolver treats as "pick the cheapest for the backend".
729
+ if sval == "auto":
730
+ parsed_value = ""
731
+
680
732
  if key.startswith("scope."):
681
733
  from superlocalmemory.cli.daemon import daemon_request, is_daemon_running
682
734
 
@@ -727,6 +779,18 @@ def cmd_config(args: Namespace) -> None:
727
779
  })
728
780
  else:
729
781
  print(f"{key}: {old_value} -> {parsed_value}")
782
+ if key == "evolution.enabled" and parsed_value is True:
783
+ print(
784
+ "\n⚠ Skill evolution is now ON. It makes background "
785
+ "LLM calls during consolidation\n"
786
+ " (capped at 10 calls/cycle, 3 cycles/day). It defaults "
787
+ "to the lowest-cost model\n"
788
+ " for your backend (Claude → Haiku, Ollama → "
789
+ "local/free).\n"
790
+ " Pick models: slm config set evolution.mutation_model "
791
+ "<auto|haiku|sonnet|ollama>\n"
792
+ " Turn off: slm config set evolution.enabled false"
793
+ )
730
794
 
731
795
  else:
732
796
  if use_json:
@@ -897,16 +961,22 @@ def _agents_md_source_factory():
897
961
  Gracefully skips if absent — never fails the MCP write.
898
962
  """
899
963
  from pathlib import Path
964
+ import sysconfig
900
965
 
901
966
  # Resolve relative to the package root (src/superlocalmemory/../../)
902
967
  _pkg_root = Path(__file__).resolve().parents[3]
903
- _agents_src = _pkg_root / "plugin-src" / "rules" / "AGENTS.md"
968
+ _candidates = (
969
+ _pkg_root / "plugin-src" / "rules" / "AGENTS.md",
970
+ Path(sysconfig.get_path("data")) / "share" / "superlocalmemory"
971
+ / "portable-kit" / "rules" / "AGENTS.md",
972
+ )
904
973
 
905
974
  def _read() -> str | None:
906
- if _agents_src.exists():
907
- return _agents_src.read_text(encoding="utf-8")
975
+ for candidate in _candidates:
976
+ if candidate.exists():
977
+ return candidate.read_text(encoding="utf-8")
908
978
  logger.warning(
909
- "WP-05 AGENTS.md not found at %s — skipping AGENTS.md write", _agents_src
979
+ "WP-05 AGENTS.md is not bundled — skipping AGENTS.md write"
910
980
  )
911
981
  return None
912
982
 
@@ -947,9 +1017,10 @@ def cmd_connect(args: Namespace) -> None:
947
1017
  here=here,
948
1018
  profile=profile,
949
1019
  agents_md_source=_agents_md_source_factory(),
1020
+ dry_run=getattr(args, "dry_run", False),
950
1021
  )
951
1022
 
952
- if not result.get("error"):
1023
+ if not result.get("error") and not getattr(args, "dry_run", False):
953
1024
  from superlocalmemory.infra.local_diagnostics import record_operation
954
1025
 
955
1026
  record_operation("activation", client=ide_arg)
@@ -968,6 +1039,7 @@ def cmd_connect(args: Namespace) -> None:
968
1039
  sys.exit(1)
969
1040
 
970
1041
  status_sym = {"wrote": "[+]", "merged": "[~]", "unchanged": "[=]",
1042
+ "would_write": "[~]",
971
1043
  "skipped": "[s]", "error": "[!]"}.get(
972
1044
  result["mcp_config"], "[?]"
973
1045
  )
@@ -996,6 +1068,7 @@ def cmd_connect(args: Namespace) -> None:
996
1068
  from superlocalmemory.hooks.ide_connector import IDEConnector
997
1069
 
998
1070
  connector = IDEConnector()
1071
+ dry_run = bool(getattr(args, "dry_run", False))
999
1072
 
1000
1073
  if getattr(args, 'json', False):
1001
1074
  from superlocalmemory.cli.json_output import json_print
@@ -1007,6 +1080,14 @@ def cmd_connect(args: Namespace) -> None:
1007
1080
  elif getattr(args, "ide", None):
1008
1081
  success = connector.connect(args.ide)
1009
1082
  json_print("connect", data={"ide": args.ide, "connected": success})
1083
+ elif dry_run:
1084
+ json_print(
1085
+ "connect",
1086
+ data={
1087
+ "dry_run": True,
1088
+ "would_configure": connector.get_status(),
1089
+ },
1090
+ )
1010
1091
  else:
1011
1092
  json_print("connect", data={"results": connector.connect_all()},
1012
1093
  next_actions=[
@@ -1020,6 +1101,12 @@ def cmd_connect(args: Namespace) -> None:
1020
1101
  mark = "[+]" if s["installed"] else "[-]"
1021
1102
  print(f" {mark} {s['name']:20s} {s['config_path']}")
1022
1103
  return
1104
+ if dry_run:
1105
+ status = connector.get_status()
1106
+ print("Dry run — no IDE, hook, config, or SLM data files were written.")
1107
+ for item in status:
1108
+ print(f" would inspect/configure: {item['name']} ({item['config_path']})")
1109
+ return
1023
1110
  if getattr(args, "ide", None):
1024
1111
  success = connector.connect(args.ide)
1025
1112
  print(f"{'Connected' if success else 'Failed'}: {args.ide}")
@@ -1271,10 +1358,12 @@ def cmd_recall(args: Namespace) -> None:
1271
1358
  scope_qs += f"&include_global={str(include_global).lower()}"
1272
1359
  if include_shared is not None:
1273
1360
  scope_qs += f"&include_shared={str(include_shared).lower()}"
1361
+ _window = getattr(args, "window", "") or ""
1362
+ window_qs = f"&window={quote(_window)}" if _window else ""
1274
1363
  result = daemon_request(
1275
1364
  "GET",
1276
1365
  f"/recall?q={quote(args.query)}&limit={args.limit}"
1277
- f"&session_id={quote(session_id)}{fast_qs}{scope_qs}",
1366
+ f"&session_id={quote(session_id)}{fast_qs}{scope_qs}{window_qs}",
1278
1367
  )
1279
1368
  if result and "results" in result:
1280
1369
  # Format daemon response same as engine response
@@ -1294,8 +1383,10 @@ def cmd_recall(args: Namespace) -> None:
1294
1383
  for i, r in enumerate(result["results"], 1):
1295
1384
  print(f" {i}. [{r['score']:.2f}] {r['content']}")
1296
1385
  return
1297
- except Exception:
1298
- pass # Fall through to direct engine
1386
+ except Exception as _exc: # noqa: BLE001
1387
+ logger.warning(
1388
+ "Daemon recall failed, falling back to direct engine: %s", _exc
1389
+ )
1299
1390
 
1300
1391
  from superlocalmemory.core.config import SLMConfig
1301
1392
  from superlocalmemory.core.engine import MemoryEngine
@@ -1310,6 +1401,7 @@ def cmd_recall(args: Namespace) -> None:
1310
1401
  fast=getattr(args, "fast", False),
1311
1402
  include_global=include_global,
1312
1403
  include_shared=include_shared,
1404
+ window=getattr(args, "window", "") or None,
1313
1405
  )
1314
1406
  except Exception as exc:
1315
1407
  if use_json:
@@ -1394,12 +1486,32 @@ def cmd_forget(args: Namespace) -> None:
1394
1486
  from superlocalmemory.core.config import SLMConfig
1395
1487
 
1396
1488
  use_json = getattr(args, 'json', False)
1489
+ dry_run = getattr(args, 'dry_run', False)
1490
+ raw_query = getattr(args, 'query', None)
1491
+
1492
+ # F3: `query` is optional so `slm forget --dry-run` can preview every memory.
1493
+ # Deletion ALWAYS requires an explicit query — a bare `slm forget` must never
1494
+ # mass-delete. In dry-run mode a missing query means "match all" (preview).
1495
+ if raw_query is None and not dry_run:
1496
+ msg = (
1497
+ "A query is required to delete. Preview everything with "
1498
+ "'slm forget --dry-run', or delete matches with 'slm forget <query>'."
1499
+ )
1500
+ if use_json:
1501
+ from superlocalmemory.cli.json_output import json_print
1502
+ json_print("forget", error={"code": "QUERY_REQUIRED", "message": msg})
1503
+ else:
1504
+ print(msg)
1505
+ sys.exit(2)
1506
+
1507
+ query_lower = "" if raw_query is None else raw_query.lower()
1508
+ query_label = raw_query if raw_query is not None else "*all*"
1509
+
1397
1510
  try:
1398
1511
  config = SLMConfig.load()
1399
1512
  engine = MemoryEngine(config)
1400
1513
  engine.initialize()
1401
1514
  facts = engine._db.get_all_facts(engine.profile_id)
1402
- query_lower = args.query.lower()
1403
1515
  matches = [f for f in facts if query_lower in f.content.lower()]
1404
1516
  except Exception as exc:
1405
1517
  if use_json:
@@ -1408,8 +1520,6 @@ def cmd_forget(args: Namespace) -> None:
1408
1520
  sys.exit(1)
1409
1521
  raise
1410
1522
 
1411
- dry_run = getattr(args, 'dry_run', False)
1412
-
1413
1523
  def delete_fact_authorized_for_cli(fact_id: str) -> None:
1414
1524
  from superlocalmemory.core.engine_ingestion import local_trusted_actor_id
1415
1525
  from superlocalmemory.core.mutations import delete_fact_authorized
@@ -1450,12 +1560,12 @@ def cmd_forget(args: Namespace) -> None:
1450
1560
  "matches": match_items,
1451
1561
  "hint": "Add --yes to confirm deletion",
1452
1562
  }, next_actions=[
1453
- {"command": f"slm forget '{args.query}' --json --yes", "description": "Confirm deletion"},
1563
+ {"command": f"slm forget '{query_label}' --json --yes", "description": "Confirm deletion"},
1454
1564
  ])
1455
1565
  return
1456
1566
 
1457
1567
  if not matches:
1458
- print(f"No memories matching '{args.query}'")
1568
+ print(f"No memories matching '{query_label}'")
1459
1569
  return
1460
1570
  print(f"Found {len(matches)} matching memories:")
1461
1571
  for f in matches[:10]:
@@ -1479,16 +1589,86 @@ def cmd_forget(args: Namespace) -> None:
1479
1589
 
1480
1590
  def cmd_delete(args: Namespace) -> None:
1481
1591
  """Delete a specific memory by exact fact ID."""
1592
+ import urllib.parse
1593
+
1594
+ from superlocalmemory.cli.daemon import daemon_request, is_daemon_running
1482
1595
  from superlocalmemory.core.config import SLMConfig
1483
1596
  from superlocalmemory.core.engine import MemoryEngine
1484
1597
 
1485
1598
  use_json = getattr(args, 'json', False)
1599
+ fact_id = args.fact_id.strip()
1600
+ if is_daemon_running():
1601
+ path = "/api/memories/" + urllib.parse.quote(fact_id, safe="")
1602
+ confirmed = getattr(args, "yes", False)
1603
+ content = ""
1604
+ if not confirmed:
1605
+ detail = daemon_request(
1606
+ "GET",
1607
+ "/api/facts/" + urllib.parse.quote(fact_id, safe=""),
1608
+ )
1609
+ if not isinstance(detail, dict):
1610
+ if use_json:
1611
+ from superlocalmemory.cli.json_output import json_print
1612
+ json_print("delete", error={
1613
+ "code": "DAEMON_MUTATION_FAILED",
1614
+ "message": "Resident daemon could not resolve the memory.",
1615
+ })
1616
+ sys.exit(1)
1617
+ raise RuntimeError("Resident daemon could not resolve the memory.")
1618
+ content = str(detail.get("content") or "")
1619
+ if use_json:
1620
+ from superlocalmemory.cli.json_output import json_print
1621
+ json_print("delete", data={
1622
+ "fact_id": fact_id,
1623
+ "content": content[:120],
1624
+ "deleted": False,
1625
+ "hint": "Add --yes to confirm deletion",
1626
+ }, next_actions=[
1627
+ {
1628
+ "command": f"slm delete {fact_id} --json --yes",
1629
+ "description": "Confirm deletion",
1630
+ },
1631
+ ])
1632
+ return
1633
+ print(f"Memory: {content[:120]}")
1634
+ confirmed = input("Delete this memory? [y/N] ").strip().lower() in (
1635
+ "y", "yes",
1636
+ )
1637
+ if not confirmed:
1638
+ print("Cancelled.")
1639
+ return
1640
+
1641
+ result = daemon_request("DELETE", path)
1642
+ if not isinstance(result, dict) or not result.get("success"):
1643
+ if use_json:
1644
+ from superlocalmemory.cli.json_output import json_print
1645
+ json_print("delete", error={
1646
+ "code": "DAEMON_MUTATION_FAILED",
1647
+ "message": "Resident daemon rejected the delete operation.",
1648
+ })
1649
+ sys.exit(1)
1650
+ raise RuntimeError("Resident daemon rejected the delete operation.")
1651
+ if use_json:
1652
+ from superlocalmemory.cli.json_output import json_print
1653
+ json_print(
1654
+ "delete",
1655
+ data={"deleted": fact_id, "content": content[:120]},
1656
+ next_actions=[
1657
+ {
1658
+ "command": "slm list --json",
1659
+ "description": "Verify remaining memories",
1660
+ },
1661
+ ],
1662
+ )
1663
+ else:
1664
+ print(f"Deleted: {fact_id}")
1665
+ return
1666
+
1486
1667
  try:
1487
1668
  config = SLMConfig.load()
1488
1669
  engine = MemoryEngine(config)
1489
1670
  engine.initialize()
1490
1671
 
1491
- fact_id = args.fact_id.strip()
1492
1672
  rows = engine._db.execute(
1493
1673
  "SELECT content FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
1494
1674
  (fact_id, engine.profile_id),
@@ -1558,6 +1738,9 @@ def cmd_delete(args: Namespace) -> None:
1558
1738
 
1559
1739
  def cmd_update(args: Namespace) -> None:
1560
1740
  """Update the content of a specific memory by exact fact ID."""
1741
+ import urllib.parse
1742
+
1743
+ from superlocalmemory.cli.daemon import daemon_request, is_daemon_running
1561
1744
  from superlocalmemory.core.config import SLMConfig
1562
1745
  from superlocalmemory.core.engine import MemoryEngine
1563
1746
 
@@ -1573,6 +1756,34 @@ def cmd_update(args: Namespace) -> None:
1573
1756
  print("Error: content cannot be empty")
1574
1757
  return
1575
1758
 
1759
+ if is_daemon_running():
1760
+ path = "/api/memories/" + urllib.parse.quote(fact_id, safe="")
1761
+ result = daemon_request("PATCH", path, {"content": new_content})
1762
+ if not isinstance(result, dict) or not result.get("success"):
1763
+ if use_json:
1764
+ from superlocalmemory.cli.json_output import json_print
1765
+ json_print("update", error={
1766
+ "code": "DAEMON_MUTATION_FAILED",
1767
+ "message": "Resident daemon rejected the update operation.",
1768
+ })
1769
+ sys.exit(1)
1770
+ raise RuntimeError("Resident daemon rejected the update operation.")
1771
+ if use_json:
1772
+ from superlocalmemory.cli.json_output import json_print
1773
+ json_print("update", data={
1774
+ "fact_id": fact_id,
1775
+ "new_content": new_content[:120],
1776
+ }, next_actions=[
1777
+ {
1778
+ "command": "slm list --json",
1779
+ "description": "List recent memories",
1780
+ },
1781
+ ])
1782
+ else:
1783
+ print(f"New: {new_content[:100]}")
1784
+ print(f"Updated: {fact_id}")
1785
+ return
1786
+
1576
1787
  try:
1577
1788
  config = SLMConfig.load()
1578
1789
  engine = MemoryEngine(config)
@@ -1835,8 +2046,8 @@ def cmd_health(args: Namespace) -> None:
1835
2046
 
1836
2047
  print("Math Layer Health:")
1837
2048
  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)}")
2049
+ print(f" Math layer indexed: {fisher_count}/{len(facts)}")
2050
+ print(f" Lifecycle positioned: {langevin_count}/{len(facts)}")
1840
2051
  print(f" Mode: {config.mode.value.upper()}")
1841
2052
 
1842
2053
 
@@ -118,8 +118,13 @@ def cmd_compress_prose(args: Namespace) -> None:
118
118
  cfg = store.get()
119
119
 
120
120
  fields: dict = {"compress_prose": (value == "on")}
121
- if value == "on" and not cfg.compress_enabled:
122
- fields["compress_enabled"] = True
121
+ if value == "on":
122
+ # Prose (Layer 2) only fires in aggressive mode, so turning it on sets a
123
+ # COHERENT state — it can never be left enabled-but-inert in safe mode.
124
+ if not cfg.compress_enabled:
125
+ fields["compress_enabled"] = True
126
+ if cfg.compress_mode != "aggressive":
127
+ fields["compress_mode"] = "aggressive"
123
128
 
124
129
  try:
125
130
  cfg = dataclasses.replace(cfg, **fields)
@@ -129,13 +134,18 @@ def cmd_compress_prose(args: Namespace) -> None:
129
134
  sys.exit(1)
130
135
 
131
136
  if use_json:
132
- print(json.dumps({"status": "ok", "compress_prose": value == "on"}))
137
+ print(json.dumps({
138
+ "status": "ok",
139
+ "compress_prose": value == "on",
140
+ "compress_mode": cfg.compress_mode,
141
+ }))
133
142
  return
134
143
 
135
144
  print(f"Prose compression (LLMLingua-2): {'ENABLED' if value == 'on' else 'DISABLED'}.")
136
145
  if value == "on":
137
- print(" Requires: compress_mode=aggressive and llmlingua package installed.")
138
- print(" Run: slm compress mode aggressive (if not already set)")
139
- if value == "on" and "compress_enabled" in fields:
140
- print(" (also enabled global compress)")
146
+ if "compress_mode" in fields:
147
+ print(" Compression mode set to 'aggressive' (required for Layer 2).")
148
+ print(" Requires the llmlingua package for lossy prose compression.")
149
+ if "compress_enabled" in fields:
150
+ print(" (also enabled global compression)")
141
151
  print("Daemon hot-reload: active within 2s.")
@@ -267,6 +267,13 @@ def daemon_request(
267
267
  if capability is not None:
268
268
  headers["X-SLM-Daemon-Capability"] = capability
269
269
  headers["X-SLM-Target-Instance"] = descriptor.instance_id
270
+ # Daemon ownership proves that this CLI targets the local instance; it
271
+ # does not replace a dashboard user's profile-scoped authorization in
272
+ # governed workspaces. The user opts in by supplying an explicit
273
+ # session through the process environment (never logged or persisted).
274
+ user_session = os.environ.get("SLM_USER_SESSION", "").strip()
275
+ if user_session:
276
+ headers["X-SLM-User-Session"] = user_session
270
277
  req = urllib.request.Request(url, data=data, headers=headers, method=method)
271
278
  resp = urllib.request.urlopen(req, timeout=timeout_seconds)
272
279
  return json.loads(resp.read().decode())
@@ -0,0 +1,187 @@
1
+ """``slm loop`` — inspect and demonstrate bounded loops backed by SLM memory.
2
+
3
+ Subcommands:
4
+
5
+ * ``slm loop demo`` run a built-in, keyless convergence demo (stub
6
+ proposer + deterministic gate) and persist every
7
+ lap to SLM memory. Proves the engine + ledger
8
+ end to end without a credentialed agent.
9
+ * ``slm loop history [--name]`` list recorded loop runs from SLM memory.
10
+ * ``slm loop show <run_id>`` show every lap of one run.
11
+
12
+ The durable value here is that a loop's history lives in the same SLM data
13
+ root as everything else the agent remembers — queryable via ``slm recall`` and
14
+ visible in the dashboard. Reads and the demo run through an in-process engine
15
+ store rooted at the active data root (``SLM_DATA_DIR`` or
16
+ ``~/.superlocalmemory``); point ``SLM_DATA_DIR`` elsewhere to sandbox a run.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from argparse import Namespace
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from superlocalmemory.infra.data_root import canonical_data_root
27
+ from superlocalmemory.loops import (
28
+ Bounds,
29
+ LapResult,
30
+ SLMMemoryLedger,
31
+ Verdict,
32
+ open_engine_store,
33
+ run_bounded_loop,
34
+ )
35
+
36
+
37
+ def _data_root() -> Path:
38
+ return canonical_data_root()
39
+
40
+
41
+ class _FailOpenLedger:
42
+ """Wrap a ledger so a memory write hiccup never aborts a running loop.
43
+
44
+ A ledger is observability; the loop's correctness comes from the gate.
45
+ Write errors are counted and surfaced after the run rather than raised
46
+ mid-flight, so we never silently pretend they did not happen.
47
+ """
48
+
49
+ def __init__(self, inner: Any) -> None:
50
+ self._inner = inner
51
+ self.write_errors: list[str] = []
52
+
53
+ def record(self, entry: Any) -> None:
54
+ try:
55
+ self._inner.record(entry)
56
+ except Exception as exc: # pragma: no cover - defensive path
57
+ self.write_errors.append(f"lap {getattr(entry, 'lap', '?')}: {exc}")
58
+
59
+ def laps(self, run_id: str) -> list:
60
+ return self._inner.laps(run_id)
61
+
62
+ def runs(self, name: str) -> list:
63
+ return self._inner.runs(name)
64
+
65
+
66
+ def _open_ledger() -> tuple[SLMMemoryLedger, Any]:
67
+ store = open_engine_store(_data_root() / "memory.db")
68
+ return SLMMemoryLedger(store), store
69
+
70
+
71
+ def cmd_loop(args: Namespace) -> None:
72
+ action = getattr(args, "loop_command", None)
73
+ if action == "demo":
74
+ _cmd_demo(args)
75
+ elif action == "history":
76
+ _cmd_history(args)
77
+ elif action == "show":
78
+ _cmd_show(args)
79
+ else:
80
+ print("Usage: slm loop {demo|history|show} [options]")
81
+
82
+
83
+ def _cmd_demo(args: Namespace) -> None:
84
+ """Run the convergence demo: the gate fails twice, then passes on lap 3."""
85
+ iterations = int(getattr(args, "iterations", 10) or 10)
86
+ as_json = bool(getattr(args, "json", False))
87
+ pass_on = 3
88
+
89
+ ledger, store = _open_ledger()
90
+ guarded = _FailOpenLedger(ledger)
91
+ try:
92
+ outcome = run_bounded_loop(
93
+ "convergence-demo",
94
+ bounds=Bounds(max_iterations=iterations),
95
+ runner=lambda lap: LapResult(changed=True, tokens=8),
96
+ gate=lambda lap: Verdict(lap >= pass_on, f"demo gate: lap {lap}"),
97
+ ledger=guarded,
98
+ )
99
+ laps = guarded.laps(outcome.run_id)
100
+ finally:
101
+ store.close()
102
+
103
+ if as_json:
104
+ print(json.dumps({
105
+ "status": outcome.status.value,
106
+ "reason": outcome.reason,
107
+ "laps": outcome.laps,
108
+ "run_id": outcome.run_id,
109
+ "ledger": [
110
+ {"lap": e.lap, "decision": e.decision, "passed": e.passed}
111
+ for e in laps
112
+ ],
113
+ "write_errors": guarded.write_errors,
114
+ }, indent=2))
115
+ return
116
+
117
+ mark = "✓" if outcome.ok else "✗"
118
+ print(f"{mark} [{outcome.status.value}] {outcome.reason} (laps: {outcome.laps})")
119
+ for e in laps:
120
+ gate = "gate-pass" if e.passed else "gate-fail"
121
+ print(f" lap {e.lap}: {e.decision:<8} {gate} {e.detail}")
122
+ print(f"run_id: {outcome.run_id} (recall with tag loop:convergence-demo)")
123
+ if guarded.write_errors:
124
+ print(f"WARNING: {len(guarded.write_errors)} ledger write error(s): "
125
+ f"{guarded.write_errors[0]}")
126
+
127
+
128
+ def _cmd_history(args: Namespace) -> None:
129
+ name = getattr(args, "name", None) or "convergence-demo"
130
+ as_json = bool(getattr(args, "json", False))
131
+ ledger, store = _open_ledger()
132
+ try:
133
+ run_ids = ledger.runs(name)
134
+ rows = []
135
+ for rid in run_ids:
136
+ laps = ledger.laps(rid)
137
+ last = laps[-1] if laps else None
138
+ rows.append({
139
+ "run_id": rid,
140
+ "laps": len(laps),
141
+ "final": last.decision if last else "unknown",
142
+ "ts": last.ts if last else "",
143
+ })
144
+ finally:
145
+ store.close()
146
+
147
+ if as_json:
148
+ print(json.dumps({"name": name, "runs": rows}, indent=2))
149
+ return
150
+ if not rows:
151
+ print(f"No recorded runs for loop '{name}'.")
152
+ return
153
+ print(f"Runs for loop '{name}':")
154
+ for r in rows:
155
+ print(f" {r['run_id']:<28} laps={r['laps']:<3} final={r['final']:<8} {r['ts']}")
156
+
157
+
158
+ def _cmd_show(args: Namespace) -> None:
159
+ run_id = getattr(args, "run_id", None)
160
+ as_json = bool(getattr(args, "json", False))
161
+ if not run_id:
162
+ print("Usage: slm loop show <run_id>")
163
+ return
164
+ ledger, store = _open_ledger()
165
+ try:
166
+ laps = ledger.laps(run_id)
167
+ finally:
168
+ store.close()
169
+
170
+ if as_json:
171
+ print(json.dumps({
172
+ "run_id": run_id,
173
+ "laps": [
174
+ {"lap": e.lap, "ts": e.ts, "decision": e.decision,
175
+ "passed": e.passed, "detail": e.detail, "budget": e.budget}
176
+ for e in laps
177
+ ],
178
+ }, indent=2))
179
+ return
180
+ if not laps:
181
+ print(f"No ledger entries for run '{run_id}'.")
182
+ return
183
+ print(f"Loop run {run_id} ({laps[0].name}):")
184
+ for e in laps:
185
+ gate = "gate-pass" if e.passed else "gate-fail"
186
+ print(f" lap {e.lap}: {e.decision:<8} {gate} {e.detail} "
187
+ f"[tokens={e.budget.get('tokens', 0)}]")