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
@@ -0,0 +1,56 @@
1
+ """SuperLocalMemory bounded loops.
2
+
3
+ Agent loops that stop when an independent gate passes — not when the agent
4
+ says it is done — with the run's history persisted in SLM's durable memory.
5
+
6
+ Public API::
7
+
8
+ from superlocalmemory.loops import (
9
+ Bounds, Rung, Status, Verdict, LapResult, Outcome,
10
+ run_bounded_loop, InMemoryLedger, SLMMemoryLedger, open_engine_store,
11
+ )
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from superlocalmemory.loops.engine import run_bounded_loop
17
+ from superlocalmemory.loops.ledger import (
18
+ InMemoryLedger,
19
+ LedgerEntry,
20
+ LedgerStore,
21
+ SLMMemoryLedger,
22
+ engine_backed_ledger,
23
+ open_engine_store,
24
+ )
25
+ from superlocalmemory.loops.models import (
26
+ Bounds,
27
+ LapResult,
28
+ Outcome,
29
+ Rung,
30
+ Status,
31
+ Verdict,
32
+ )
33
+ from superlocalmemory.loops.rules import (
34
+ no_progress,
35
+ rung_requires_approval,
36
+ stop_condition_met,
37
+ )
38
+
39
+ __all__ = [
40
+ "Bounds",
41
+ "Rung",
42
+ "Status",
43
+ "Verdict",
44
+ "LapResult",
45
+ "Outcome",
46
+ "run_bounded_loop",
47
+ "LedgerEntry",
48
+ "LedgerStore",
49
+ "InMemoryLedger",
50
+ "SLMMemoryLedger",
51
+ "engine_backed_ledger",
52
+ "open_engine_store",
53
+ "no_progress",
54
+ "rung_requires_approval",
55
+ "stop_condition_met",
56
+ ]
@@ -0,0 +1,58 @@
1
+ """Cumulative budget accounting for a bounded-loop run.
2
+
3
+ The meter tracks laps, tokens, and wall-clock time and reports when any bound
4
+ in effect has been exceeded. It is intentionally tiny and side-effect-free
5
+ apart from its own internal counters, and the clock is injected so tests are
6
+ deterministic.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Callable
12
+
13
+ from superlocalmemory.loops.models import Bounds
14
+
15
+
16
+ class BudgetMeter:
17
+ """Accumulate spend across laps and answer "have we gone over?".
18
+
19
+ ``now`` is a zero-argument callable returning monotonic-ish seconds
20
+ (``time.monotonic`` in production, a fake in tests). The start time is
21
+ captured at construction so wall-clock enforcement needs no globals.
22
+ """
23
+
24
+ def __init__(self, now: Callable[[], float]) -> None:
25
+ self._now = now
26
+ self._start = now()
27
+ self._tokens = 0
28
+
29
+ def spend(self, tokens: int) -> None:
30
+ """Record token spend for a completed lap (negative values ignored)."""
31
+ if tokens > 0:
32
+ self._tokens += tokens
33
+
34
+ def exceeded(self, lap: int, bounds: Bounds) -> tuple[bool, str]:
35
+ """Return ``(tripped, reason)`` for the bounds checked before a lap runs.
36
+
37
+ Checked in priority order: iteration cap, token budget, wall-clock.
38
+ ``lap`` is the 1-based number of the lap about to run, so exceeding
39
+ ``max_iterations`` is reported when the (max+1)-th lap is attempted.
40
+ """
41
+ if lap > bounds.max_iterations:
42
+ return True, "max-iterations"
43
+ # >= so a budget of N never permits an (N+1)-th lap's worth of spend:
44
+ # once cumulative tokens reach the ceiling, the next lap is refused.
45
+ if bounds.max_tokens is not None and self._tokens >= bounds.max_tokens:
46
+ return True, "token-budget"
47
+ if bounds.max_wallclock_s is not None:
48
+ elapsed = self._now() - self._start
49
+ if elapsed > bounds.max_wallclock_s:
50
+ return True, "wallclock"
51
+ return False, ""
52
+
53
+ def snapshot(self) -> dict:
54
+ """Point-in-time spend, suitable for a ledger entry."""
55
+ return {
56
+ "tokens": self._tokens,
57
+ "wallclock_s": round(self._now() - self._start, 3),
58
+ }
@@ -0,0 +1,164 @@
1
+ """The bounded-loop orchestrator.
2
+
3
+ ``run_bounded_loop`` executes a loop for a single goal under a fixed set of
4
+ :class:`Bounds`. Its one non-negotiable invariant: **the independent gate
5
+ decides when the loop is finished — never the agent.** A runner's
6
+ ``agent_claimed_done`` flag is written to the ledger for audit and is never
7
+ read when deciding to terminate.
8
+
9
+ Each lap, in strict order:
10
+
11
+ 1. Poll the kill switch (highest priority — checked before any work).
12
+ 2. Check the budget bounds (iteration cap, tokens, wall-clock).
13
+ 3. Run the agent's proposer for one lap.
14
+ 4. Accumulate token spend.
15
+ 5. Ask the *independent* gate for a verdict.
16
+ 6. Decide: a passing gate (plus any required approval) ends the run; an
17
+ exhausted no-progress window halts it; otherwise continue.
18
+
19
+ Runner and gate are plain callables taking the 1-based lap number, so this
20
+ engine carries no subprocess, sandbox, or framework machinery — SLM loops
21
+ converge on a checkable memory/verification condition, and heavier isolation
22
+ belongs to the standalone bounded-loops engine, not here.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import time
29
+ import uuid
30
+ from datetime import datetime, timezone
31
+ from typing import Callable, Optional
32
+
33
+ from superlocalmemory.loops.budget import BudgetMeter
34
+ from superlocalmemory.loops.ledger import InMemoryLedger, LedgerEntry, LedgerStore
35
+ from superlocalmemory.loops.models import (
36
+ Bounds,
37
+ LapResult,
38
+ Outcome,
39
+ Rung,
40
+ Status,
41
+ Verdict,
42
+ )
43
+ from superlocalmemory.loops.rules import (
44
+ no_progress,
45
+ rung_requires_approval,
46
+ stop_condition_met,
47
+ )
48
+
49
+ RunnerFn = Callable[[int], LapResult]
50
+ GateFn = Callable[[int], Verdict]
51
+ ApproverFn = Callable[[Verdict], bool]
52
+ KillSwitchFn = Callable[[], bool]
53
+ ClockFn = Callable[[], str]
54
+
55
+ _KILL_ENV = "SLM_LOOP_KILL"
56
+
57
+
58
+ def _utc_now_iso() -> str:
59
+ return datetime.now(timezone.utc).isoformat()
60
+
61
+
62
+ def _env_killed() -> bool:
63
+ return bool(os.environ.get(_KILL_ENV))
64
+
65
+
66
+ def run_bounded_loop(
67
+ name: str,
68
+ *,
69
+ bounds: Bounds,
70
+ runner: RunnerFn,
71
+ gate: GateFn,
72
+ rung: Rung = Rung.L1,
73
+ ledger: Optional[LedgerStore] = None,
74
+ approver: Optional[ApproverFn] = None,
75
+ killswitch: Optional[KillSwitchFn] = None,
76
+ clock: Optional[ClockFn] = None,
77
+ monotonic: Optional[Callable[[], float]] = None,
78
+ run_id: Optional[str] = None,
79
+ ) -> Outcome:
80
+ """Run one bounded loop and return its :class:`Outcome`.
81
+
82
+ Only ``name``, ``bounds``, ``runner`` and ``gate`` are required. Every
83
+ other dependency is injected for determinism in tests; sensible defaults
84
+ (UTC clock, monotonic timer, env-var kill switch, in-memory ledger) apply
85
+ otherwise.
86
+ """
87
+ run_id = run_id or f"{name}-{uuid.uuid4().hex[:8]}"
88
+ ledger = ledger if ledger is not None else InMemoryLedger()
89
+ clock = clock or _utc_now_iso
90
+ monotonic = monotonic or time.monotonic
91
+ killswitch = killswitch or _env_killed
92
+ budget = BudgetMeter(monotonic)
93
+
94
+ lap_changes: list[bool] = []
95
+ lap = 0
96
+
97
+ def emit(decision: str, verdict: Verdict, result: LapResult | None = None) -> None:
98
+ ledger.record(
99
+ LedgerEntry(
100
+ run_id=run_id,
101
+ name=name,
102
+ lap=lap,
103
+ ts=clock(),
104
+ decision=decision,
105
+ passed=verdict.passed,
106
+ detail=verdict.detail,
107
+ # The agent's own claim is recorded for audit only — never used
108
+ # to terminate (see the loop invariant). Log is capped so a
109
+ # verbose runner can't bloat the ledger row.
110
+ agent_claimed_done=bool(result.agent_claimed_done) if result else False,
111
+ runner_log=(result.log or "")[:2000] if result else "",
112
+ budget=budget.snapshot(),
113
+ )
114
+ )
115
+
116
+ while True:
117
+ lap += 1
118
+
119
+ # 1. Kill switch — before any work (so laps reports completed laps).
120
+ if killswitch():
121
+ emit("killed", Verdict(False, "kill switch tripped"))
122
+ return Outcome(Status.KILLED, "killed", lap - 1, run_id)
123
+
124
+ # 2. Budget bounds — before running the agent.
125
+ tripped, why = budget.exceeded(lap, bounds)
126
+ if tripped:
127
+ emit("halt", Verdict(False, why))
128
+ return Outcome(Status.HALT, why, lap - 1, run_id)
129
+
130
+ # 3. Run the proposer for one lap.
131
+ try:
132
+ result = runner(lap)
133
+ except Exception as exc: # runner failure is a terminal ERROR
134
+ detail = f"runner error: {type(exc).__name__}: {exc}"
135
+ emit("error", Verdict(False, detail))
136
+ return Outcome(Status.ERROR, detail, lap, run_id)
137
+
138
+ # 4. Accumulate spend.
139
+ budget.spend(result.tokens)
140
+ lap_changes.append(result.changed)
141
+
142
+ # 5. Independent gate — agent's own claim is never consulted here.
143
+ try:
144
+ verdict = gate(lap)
145
+ except Exception as exc:
146
+ detail = f"gate error: {type(exc).__name__}: {exc}"
147
+ emit("error", Verdict(False, detail), result)
148
+ return Outcome(Status.ERROR, detail, lap, run_id)
149
+
150
+ # 6. Decide.
151
+ if stop_condition_met(verdict):
152
+ if rung_requires_approval(rung, bounds):
153
+ granted = bool(approver(verdict)) if approver is not None else False
154
+ if not granted:
155
+ emit("pause", verdict, result)
156
+ return Outcome(Status.PAUSE, "awaiting-approval", lap, run_id)
157
+ emit("done", verdict, result)
158
+ return Outcome(Status.DONE, "gate-passed", lap, run_id)
159
+
160
+ if no_progress(lap_changes, bounds.no_progress_window):
161
+ emit("halt", Verdict(False, "no-progress"), result)
162
+ return Outcome(Status.HALT, "no-progress", lap, run_id)
163
+
164
+ emit("continue", verdict, result)
@@ -0,0 +1,263 @@
1
+ """Durable, queryable ledger for bounded-loop runs.
2
+
3
+ Every lap produces one append-only :class:`LedgerEntry`. A ledger persists
4
+ those entries so a run can be inspected, resumed, and audited after the fact.
5
+
6
+ Two implementations ship:
7
+
8
+ * :class:`InMemoryLedger` — a dict-backed store used by tests and as a safe
9
+ fallback when no SLM data root is available.
10
+ * :class:`SLMMemoryLedger` — the real backend. It writes each lap through a
11
+ SuperLocalMemory engine so the ledger *is* memory: queryable via ``slm
12
+ recall``, visible in the dashboard, and resumable across sessions. This is
13
+ what makes SLM's take on bounded loops distinct — the loop's history lives
14
+ in the same durable store as everything else the agent remembers.
15
+
16
+ The engine-backed store mirrors the exact profile-scoped SQL contract the
17
+ shipped framework adapters already rely on, so it stays valid as the engine
18
+ evolves.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from dataclasses import asdict, dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any, Protocol, runtime_checkable
27
+
28
+ LEDGER_TAG = "slm-loop"
29
+ _LEDGER_IMPORTANCE = 2 # below ordinary user memories so laps never crowd recall
30
+ _SESSION_PREFIX = "loop:"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class LedgerEntry:
35
+ """One immutable row recording what happened on a single lap."""
36
+
37
+ run_id: str
38
+ name: str
39
+ lap: int
40
+ ts: str
41
+ decision: str # continue | done | halt | pause | killed | error
42
+ passed: bool
43
+ detail: str
44
+ # The runner's own claim (audit-only; never terminates the loop) + its log.
45
+ agent_claimed_done: bool = False
46
+ runner_log: str = ""
47
+ budget: dict[str, Any] = field(default_factory=dict)
48
+
49
+ def to_json(self) -> str:
50
+ return json.dumps(asdict(self), ensure_ascii=False, separators=(",", ":"))
51
+
52
+ @classmethod
53
+ def from_json(cls, text: str) -> "LedgerEntry | None":
54
+ try:
55
+ data = json.loads(text)
56
+ except (TypeError, json.JSONDecodeError):
57
+ return None
58
+ if not isinstance(data, dict) or "run_id" not in data or "lap" not in data:
59
+ return None
60
+ return cls(
61
+ run_id=str(data.get("run_id", "")),
62
+ name=str(data.get("name", "")),
63
+ lap=int(data.get("lap", 0)),
64
+ ts=str(data.get("ts", "")),
65
+ decision=str(data.get("decision", "")),
66
+ passed=bool(data.get("passed", False)),
67
+ detail=str(data.get("detail", "")),
68
+ agent_claimed_done=bool(data.get("agent_claimed_done", False)),
69
+ runner_log=str(data.get("runner_log", "")),
70
+ budget=data.get("budget") if isinstance(data.get("budget"), dict) else {},
71
+ )
72
+
73
+
74
+ @runtime_checkable
75
+ class LedgerStore(Protocol):
76
+ """Append-only audit trail keyed by run."""
77
+
78
+ def record(self, entry: LedgerEntry) -> None: ...
79
+ def laps(self, run_id: str) -> list[LedgerEntry]: ...
80
+ def runs(self, name: str) -> list[str]: ...
81
+
82
+
83
+ class InMemoryLedger:
84
+ """Process-local ledger. Never raises; ideal for tests and offline demos."""
85
+
86
+ def __init__(self) -> None:
87
+ self._by_run: dict[str, list[LedgerEntry]] = {}
88
+
89
+ def record(self, entry: LedgerEntry) -> None:
90
+ self._by_run.setdefault(entry.run_id, []).append(entry)
91
+
92
+ def laps(self, run_id: str) -> list[LedgerEntry]:
93
+ return list(self._by_run.get(run_id, ()))
94
+
95
+ def runs(self, name: str) -> list[str]:
96
+ seen: list[str] = []
97
+ for run_id, entries in self._by_run.items():
98
+ if entries and entries[0].name == name and run_id not in seen:
99
+ seen.append(run_id)
100
+ return seen
101
+
102
+
103
+ class SLMMemoryLedger:
104
+ """Ledger backed by a SuperLocalMemory engine store.
105
+
106
+ ``store`` is any object exposing ``add(content, *, session_id, metadata)``,
107
+ ``list_session(session_id)`` and ``list_prefix(prefix)`` — the small
108
+ contract :func:`open_engine_store` provides. Injecting it keeps this class
109
+ free of engine-construction concerns and trivially testable with a fake.
110
+ """
111
+
112
+ def __init__(self, store: Any) -> None:
113
+ self._store = store
114
+
115
+ @staticmethod
116
+ def _session_id(run_id: str) -> str:
117
+ return f"{_SESSION_PREFIX}{run_id}"
118
+
119
+ def record(self, entry: LedgerEntry) -> None:
120
+ self._store.add(
121
+ entry.to_json(),
122
+ session_id=self._session_id(entry.run_id),
123
+ metadata={
124
+ "integration": "slm-loop",
125
+ "loop_name": entry.name,
126
+ "loop_run_id": entry.run_id,
127
+ "loop_lap": entry.lap,
128
+ "loop_decision": entry.decision,
129
+ "tags": [LEDGER_TAG, f"loop:{entry.name}"],
130
+ "importance": _LEDGER_IMPORTANCE,
131
+ "project_name": "slm-loop",
132
+ },
133
+ )
134
+
135
+ def laps(self, run_id: str) -> list[LedgerEntry]:
136
+ rows = self._store.list_session(self._session_id(run_id))
137
+ entries = [LedgerEntry.from_json(r.get("content", "")) for r in rows]
138
+ return [e for e in entries if e is not None]
139
+
140
+ def runs(self, name: str) -> list[str]:
141
+ """Run ids for ``name``, newest run first.
142
+
143
+ ``list_prefix`` returns rows created_at DESC, so the first time a run's
144
+ id is seen it is its most-recent lap; ``slm loop history`` therefore
145
+ lists the most recent runs first.
146
+ """
147
+ rows = self._store.list_prefix(_SESSION_PREFIX)
148
+ ordered: list[str] = []
149
+ for row in rows:
150
+ entry = LedgerEntry.from_json(row.get("content", ""))
151
+ if entry is not None and entry.name == name and entry.run_id not in ordered:
152
+ ordered.append(entry.run_id)
153
+ return ordered
154
+
155
+
156
+ class _EngineLedgerStore:
157
+ """Minimal profile-scoped store over a SuperLocalMemory engine.
158
+
159
+ Uses the engine's non-blocking write-through path when available and
160
+ direct, escaped, profile-scoped reads. The ``store`` fallback preserves
161
+ compatibility with lightweight adapter/test engines that predate
162
+ ``store_fast``.
163
+ """
164
+
165
+ def __init__(self, engine: Any, *, owns_engine: bool = True) -> None:
166
+ self._engine = engine
167
+ # When False, this store does NOT own the engine's lifecycle (the
168
+ # caller — e.g. the MCP daemon — keeps it), so close() must not tear
169
+ # down a shared engine. open_engine_store() passes True (it built the
170
+ # engine); engine_backed_ledger() passes False (daemon-owned engine).
171
+ self._owns_engine = owns_engine
172
+
173
+ def add(self, content: str, *, session_id: str, metadata: dict) -> None:
174
+ # A loop ledger needs the durable parent row and immediate lexical
175
+ # recall, not synchronous embeddings/entity/graph enrichment. Loading
176
+ # the heavyweight embedding worker for every bounded-loop lap can stall
177
+ # the loop for the full worker timeout and consume ~1 GB for metadata.
178
+ # The write-through path persists the same session-scoped content in
179
+ # milliseconds; ordinary background enrichment can still promote it.
180
+ fast_metadata = {**metadata, "session_id": session_id}
181
+ store_fast = getattr(self._engine, "store_fast", None)
182
+ if callable(store_fast):
183
+ store_fast(
184
+ content,
185
+ metadata=fast_metadata,
186
+ index_external=False,
187
+ )
188
+ return
189
+
190
+ self._engine.store(
191
+ content,
192
+ session_id=session_id,
193
+ metadata=metadata,
194
+ )
195
+
196
+ def list_session(self, session_id: str) -> list[dict]:
197
+ # Cap the read: a bounded-loop run is capped at max_iterations laps, so
198
+ # a legitimate run is small; the LIMIT stops a pathologically long
199
+ # session_id from forcing an unbounded materialization on every
200
+ # `slm loop show` / history lookup.
201
+ rows = self._engine.db.execute(
202
+ "SELECT content, created_at FROM memories "
203
+ "WHERE profile_id=? AND session_id=? "
204
+ "ORDER BY created_at ASC, rowid ASC LIMIT 5000",
205
+ (self._engine.profile_id, session_id),
206
+ )
207
+ return [dict(row) for row in rows]
208
+
209
+ def list_prefix(self, prefix: str) -> list[dict]:
210
+ escaped = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
211
+ # Cap the scan so a long-lived, high-volume ledger can't force an
212
+ # unbounded read on `slm loop history`.
213
+ rows = self._engine.db.execute(
214
+ "SELECT content, created_at FROM memories "
215
+ "WHERE profile_id=? AND session_id LIKE ? ESCAPE '\\' "
216
+ "ORDER BY created_at DESC, rowid DESC LIMIT 5000",
217
+ (self._engine.profile_id, escaped + "%"),
218
+ )
219
+ return [dict(row) for row in rows]
220
+
221
+ def close(self) -> None:
222
+ if self._owns_engine:
223
+ self._engine.close()
224
+
225
+
226
+ def engine_backed_ledger(engine: Any) -> SLMMemoryLedger:
227
+ """Build an SLM-backed ledger over an ALREADY-OPEN engine.
228
+
229
+ Unlike :func:`open_engine_store`, this neither creates nor owns the engine —
230
+ the caller (e.g. the MCP daemon, which keeps one long-lived engine per
231
+ profile) retains full ownership and lifecycle. The returned ledger never
232
+ closes the engine, so it is safe to build one per tool call. The engine's
233
+ per-call, WAL-mode connection model makes the ledger's reads/writes safe
234
+ from a worker thread.
235
+ """
236
+ return SLMMemoryLedger(_EngineLedgerStore(engine, owns_engine=False))
237
+
238
+
239
+ def open_engine_store(db_path: str | Path) -> _EngineLedgerStore:
240
+ """Build an engine-backed ledger store rooted at ``db_path``.
241
+
242
+ Raises ``ImportError`` with an install hint if the SLM runtime is missing.
243
+ """
244
+ from dataclasses import replace
245
+
246
+ try:
247
+ from superlocalmemory.core.config import SLMConfig
248
+ from superlocalmemory.core.engine import MemoryEngine
249
+ from superlocalmemory.storage.models import Mode
250
+ except ImportError as exc: # pragma: no cover - defensive
251
+ raise ImportError(
252
+ "SuperLocalMemory runtime is required for the SLM-backed loop "
253
+ "ledger. Install it with: python -m pip install superlocalmemory."
254
+ ) from exc
255
+
256
+ path = Path(db_path).expanduser().resolve()
257
+ config = SLMConfig.for_mode(Mode.A, base_dir=path.parent)
258
+ config.db_path = path
259
+ config.forgetting = replace(config.forgetting, enabled=False)
260
+ config.retrieval.use_cross_encoder = False
261
+ engine = MemoryEngine(config)
262
+ engine.initialize()
263
+ return _EngineLedgerStore(engine)
@@ -0,0 +1,152 @@
1
+ """Immutable value types for SuperLocalMemory bounded loops.
2
+
3
+ A *bounded loop* is an agent loop that terminates when an independent gate
4
+ passes — never when the agent claims it is finished. This module holds the
5
+ pure data types the loop engine reasons over.
6
+
7
+ Design rules (kept deliberately strict):
8
+ * Standard-library imports only. No I/O, no framework, no side effects.
9
+ * Every dataclass is ``frozen=True`` — any attribute mutation raises
10
+ ``TypeError`` at runtime, so a lap result cannot be rewritten after the
11
+ gate has judged it.
12
+ * Timestamps are ISO-8601 strings supplied by the engine's clock, never
13
+ produced here with ``datetime.now()``.
14
+
15
+ This is SuperLocalMemory's own realization of the bounded-loop concept; the
16
+ loop-control discipline it encodes (gate-verified termination, enforced
17
+ bounds, an advisory-only agent claim) is a general practice, reimplemented
18
+ here against SLM's durable memory rather than a flat file.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from enum import Enum
25
+ from typing import Any, Optional
26
+
27
+
28
+ class Rung(str, Enum):
29
+ """Autonomy rung governing how much a human stays in the loop.
30
+
31
+ ``L1`` report — a human reads every verdict; the loop still exits on
32
+ a passing gate but nothing is auto-approved.
33
+ ``L2`` assisted — the agent acts but pauses for human approval before a
34
+ passing gate is accepted as DONE.
35
+ ``L3`` unattended — the agent acts autonomously; approval is derived from
36
+ the bounds alone.
37
+
38
+ Subclassing ``(str, Enum)`` makes ``Rung.L2 == "L2"`` true and
39
+ ``Rung("L2")`` reconstruct the member, so a rung round-trips through JSON
40
+ or a CLI argument without a hand-written lookup table.
41
+ """
42
+
43
+ L1 = "L1"
44
+ L2 = "L2"
45
+ L3 = "L3"
46
+
47
+
48
+ class Status(str, Enum):
49
+ """Terminal status of a bounded-loop run.
50
+
51
+ ``DONE`` — the gate passed and approval was granted or not required.
52
+ ``HALT`` — a safety bound tripped (iteration cap, no progress, budget).
53
+ ``PAUSE`` — the gate passed but required approval was not granted.
54
+ ``KILLED`` — an external kill switch tripped between laps.
55
+ ``ERROR`` — the runner or gate raised before a verdict was produced.
56
+ """
57
+
58
+ DONE = "DONE"
59
+ HALT = "HALT"
60
+ PAUSE = "PAUSE"
61
+ KILLED = "KILLED"
62
+ ERROR = "ERROR"
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Bounds:
67
+ """The safety envelope a loop runs inside.
68
+
69
+ ``max_iterations`` hard cap on laps; required and must be >= 1.
70
+ ``no_progress_window`` consecutive no-change laps that trigger a HALT.
71
+ ``max_tokens`` cumulative token budget across laps, or ``None``.
72
+ ``max_wallclock_s`` wall-clock ceiling in seconds, or ``None``.
73
+ ``require_approval`` ``True``/``False`` forces the approval posture;
74
+ ``None`` derives it from the rung (L1 -> no
75
+ approval, L2/L3 -> approval required).
76
+ """
77
+
78
+ max_iterations: int
79
+ no_progress_window: int = 3
80
+ max_tokens: Optional[int] = None
81
+ max_wallclock_s: Optional[float] = None
82
+ require_approval: Optional[bool] = None
83
+
84
+ def __post_init__(self) -> None:
85
+ if self.max_iterations < 1:
86
+ raise ValueError("max_iterations must be >= 1")
87
+ if self.no_progress_window < 0:
88
+ raise ValueError("no_progress_window must be >= 0")
89
+ if self.max_tokens is not None and self.max_tokens < 0:
90
+ raise ValueError("max_tokens must be >= 0 when set")
91
+ if self.max_wallclock_s is not None and self.max_wallclock_s < 0:
92
+ raise ValueError("max_wallclock_s must be >= 0 when set")
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class Verdict:
97
+ """The independent gate's judgement of a single lap.
98
+
99
+ ``passed`` True only when the gate mechanically confirmed the goal.
100
+ ``detail`` human-readable one-line summary (required, non-empty).
101
+ ``evidence`` structured gate output (counts, tails, diffs). Defaults to a
102
+ fresh dict per instance via ``default_factory`` so verdicts do
103
+ not share one mutable dict.
104
+
105
+ ``passed=True`` is necessary but not sufficient for the loop to exit; the
106
+ engine still consults the approval rung.
107
+ """
108
+
109
+ passed: bool
110
+ detail: str
111
+ evidence: dict[str, Any] = field(default_factory=dict)
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class LapResult:
116
+ """What the runner reports after proposing one lap of work.
117
+
118
+ ``changed`` True if the runner altered the workspace/state.
119
+ ``agent_claimed_done`` the agent's own "I am finished" signal. Recorded for
120
+ audit and **never** used to terminate the loop — the
121
+ gate is the sole authority.
122
+ ``tokens`` tokens spent this lap (0 when unknown).
123
+ ``log`` short runner log for the lap.
124
+ """
125
+
126
+ changed: bool
127
+ agent_claimed_done: bool = False
128
+ tokens: int = 0
129
+ log: str = ""
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class Outcome:
134
+ """The final result of a bounded-loop run.
135
+
136
+ ``status`` terminal status.
137
+ ``reason`` short machine-friendly explanation ("gate-passed",
138
+ "no-progress", "max-iterations", "awaiting-approval",
139
+ "killed", or a gate/runner error string).
140
+ ``laps`` number of laps executed at termination.
141
+ ``run_id`` identifier used to locate this run's ledger in SLM memory.
142
+ """
143
+
144
+ status: Status
145
+ reason: str
146
+ laps: int
147
+ run_id: str
148
+
149
+ @property
150
+ def ok(self) -> bool:
151
+ """True only for a DONE outcome — the single success state."""
152
+ return self.status is Status.DONE