superlocalmemory 3.7.7 → 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 (262) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +85 -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 -2
  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 +96 -12
  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/auth_middleware.py +28 -0
  116. package/src/superlocalmemory/infra/backup.py +12 -1
  117. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  118. package/src/superlocalmemory/infra/data_root.py +43 -4
  119. package/src/superlocalmemory/infra/event_bus.py +107 -24
  120. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  121. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  122. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  123. package/src/superlocalmemory/learning/cross_project.py +28 -19
  124. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  125. package/src/superlocalmemory/loops/__init__.py +56 -0
  126. package/src/superlocalmemory/loops/budget.py +58 -0
  127. package/src/superlocalmemory/loops/engine.py +164 -0
  128. package/src/superlocalmemory/loops/ledger.py +243 -0
  129. package/src/superlocalmemory/loops/models.py +152 -0
  130. package/src/superlocalmemory/loops/rules.py +52 -0
  131. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  132. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  133. package/src/superlocalmemory/mcp/profiles.py +103 -0
  134. package/src/superlocalmemory/mcp/server.py +21 -49
  135. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  136. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  137. package/src/superlocalmemory/mcp/tools_core.py +50 -5
  138. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  139. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  140. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  141. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  142. package/src/superlocalmemory/mesh/broker.py +237 -129
  143. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  144. package/src/superlocalmemory/optimize/NOTICE +1 -6
  145. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  146. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  147. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  148. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  149. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  150. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  151. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  152. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  153. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  154. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  155. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  156. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  157. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  158. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  159. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  160. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  161. package/src/superlocalmemory/retrieval/engine.py +168 -26
  162. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  163. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  164. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  165. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  166. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  167. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  168. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  169. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  170. package/src/superlocalmemory/server/api.py +21 -4
  171. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  172. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  173. package/src/superlocalmemory/server/recall_health.py +24 -3
  174. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  175. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  176. package/src/superlocalmemory/server/routes/agents.py +128 -38
  177. package/src/superlocalmemory/server/routes/backup.py +34 -10
  178. package/src/superlocalmemory/server/routes/behavioral.py +13 -12
  179. package/src/superlocalmemory/server/routes/brain.py +21 -5
  180. package/src/superlocalmemory/server/routes/chat.py +72 -16
  181. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  182. package/src/superlocalmemory/server/routes/config_api.py +436 -0
  183. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  184. package/src/superlocalmemory/server/routes/entity.py +9 -4
  185. package/src/superlocalmemory/server/routes/events.py +24 -8
  186. package/src/superlocalmemory/server/routes/evolution.py +135 -17
  187. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  188. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  189. package/src/superlocalmemory/server/routes/insights.py +3 -3
  190. package/src/superlocalmemory/server/routes/learning.py +14 -14
  191. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  192. package/src/superlocalmemory/server/routes/memories.py +221 -49
  193. package/src/superlocalmemory/server/routes/mesh.py +95 -15
  194. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  195. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  196. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  197. package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
  198. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  199. package/src/superlocalmemory/server/routes/stats.py +13 -6
  200. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  201. package/src/superlocalmemory/server/routes/v3_api.py +194 -81
  202. package/src/superlocalmemory/server/routes/ws.py +5 -2
  203. package/src/superlocalmemory/server/security_middleware.py +12 -5
  204. package/src/superlocalmemory/server/ui.py +30 -5
  205. package/src/superlocalmemory/server/unified_daemon.py +431 -75
  206. package/src/superlocalmemory/server/write_identity.py +38 -8
  207. package/src/superlocalmemory/storage/database.py +265 -53
  208. package/src/superlocalmemory/storage/migration_runner.py +53 -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/models.py +4 -0
  217. package/src/superlocalmemory/storage/schema.py +87 -0
  218. package/src/superlocalmemory/storage/schema_v32.py +0 -9
  219. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  220. package/src/superlocalmemory/trust/gate.py +49 -8
  221. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  222. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  223. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  224. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  225. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  226. package/src/superlocalmemory/ui/favicon.svg +35 -4
  227. package/src/superlocalmemory/ui/index.html +306 -173
  228. package/src/superlocalmemory/ui/js/brain.js +5 -20
  229. package/src/superlocalmemory/ui/js/core.js +47 -31
  230. package/src/superlocalmemory/ui/js/dashboard.js +314 -63
  231. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  232. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  233. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  234. package/src/superlocalmemory/ui/js/memories.js +15 -4
  235. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  236. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  237. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  238. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  239. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  240. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  241. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  242. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  243. package/src/superlocalmemory/ui/js/od-backup.js +780 -0
  244. package/src/superlocalmemory/ui/js/od-brain.js +779 -0
  245. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  246. package/src/superlocalmemory/ui/js/od-graph.js +593 -0
  247. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  248. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  249. package/src/superlocalmemory/ui/js/od-memories.js +887 -0
  250. package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
  251. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  252. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  253. package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
  254. package/src/superlocalmemory/ui/js/od-shell.js +593 -0
  255. package/src/superlocalmemory/ui/js/od-skills.js +573 -0
  256. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  257. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  258. package/src/superlocalmemory/ui/js/settings.js +2 -2
  259. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  260. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  261. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  262. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -0,0 +1,300 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """SLM v3.8.0 — MCP bounded-loop tools.
6
+
7
+ Exposes the ``superlocalmemory.loops`` engine over MCP so an agent connected
8
+ via MCP — not only the ``slm loop`` CLI or the ``/slm-loop`` command — can run
9
+ a gated, bounded loop and inspect its durable ledger. Bounded loops therefore
10
+ ship on three surfaces: CLI, plugin command, and MCP.
11
+
12
+ The loop's one invariant holds identically here: an INDEPENDENT gate decides
13
+ when the loop is done, never the agent's own claim. Over MCP the gate is an SLM
14
+ *recall*: the loop converges the first lap a memory matching ``gate_query``
15
+ becomes retrievable with confidence (the "verification lives in memory" model
16
+ the engine is designed around). That makes ``slm_loop_run`` a safe, shell-free
17
+ multi-agent coordination primitive — one agent waits, under strict bounds, for
18
+ a memory another agent will write into shared SLM.
19
+
20
+ Three tools:
21
+ * ``slm_loop_run`` — run one bounded, gate-verified loop to a terminal
22
+ outcome. Blocks (polling the gate) until the gate
23
+ passes or a bound trips. Every lap is persisted to
24
+ SLM memory (tag ``loop:<name>``) and shows on the
25
+ dashboard.
26
+ * ``slm_loop_history`` — list recorded runs for a loop name (read-only).
27
+ * ``slm_loop_show`` — show every lap of one run (read-only).
28
+
29
+ Fail-open: every tool body returns a dict; internal errors surface as
30
+ ``ok: False`` with a message, never a raised exception.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import logging
37
+ import time
38
+ from typing import Any, Callable
39
+
40
+ from mcp.types import ToolAnnotations
41
+
42
+ from superlocalmemory.loops import (
43
+ Bounds,
44
+ LapResult,
45
+ Verdict,
46
+ engine_backed_ledger,
47
+ run_bounded_loop,
48
+ )
49
+
50
+ logger = logging.getLogger("slm.mcp.tools_loops")
51
+
52
+ # ─── Exported tool name list (used by server.py + tests) ─────────────────────
53
+
54
+ _LOOP_TOOL_NAMES = (
55
+ "slm_loop_run",
56
+ "slm_loop_history",
57
+ "slm_loop_show",
58
+ )
59
+
60
+ # ─── Hard caps (a loop tool must never hang the daemon or spin unbounded) ────
61
+
62
+ _MAX_ITERATIONS = 200
63
+ _MAX_WALLCLOCK_S = 120.0
64
+ _MIN_POLL_S = 0.25
65
+ _MAX_NAME_CHARS = 128
66
+ _MAX_QUERY_CHARS = 2000
67
+
68
+
69
+ def _top_score(resp: Any) -> float:
70
+ """Highest result score in a RecallResponse (0.0 when there are none)."""
71
+ best = 0.0
72
+ for r in getattr(resp, "results", None) or []:
73
+ s = getattr(r, "score", None)
74
+ if s is None:
75
+ s = getattr(r, "relevance_score", 0.0) or 0.0
76
+ try:
77
+ best = max(best, float(s))
78
+ except (TypeError, ValueError):
79
+ continue
80
+ return best
81
+
82
+
83
+ def register_loop_tools(server, get_engine: Callable) -> None:
84
+ """Register the 3 bounded-loop tools on *server*.
85
+
86
+ *server* is duck-typed: must support the ``@server.tool()`` decorator.
87
+ Compatible with FastMCP, _FilteredServer, and the test mock server.
88
+ """
89
+
90
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False))
91
+ async def slm_loop_run(
92
+ name: str,
93
+ gate_query: str,
94
+ gate_min_score: float = 0.0,
95
+ max_iterations: int = 20,
96
+ max_wallclock_s: float = 15.0,
97
+ poll_interval_s: float = 1.0,
98
+ max_tokens: int = 0,
99
+ no_progress_window: int = 0,
100
+ ) -> dict:
101
+ """Run one bounded loop that finishes only when an INDEPENDENT gate passes.
102
+
103
+ The gate is an SLM recall of ``gate_query``: the loop converges on the
104
+ first lap that recall returns a confident match scoring at least
105
+ ``gate_min_score``. The agent cannot end the loop by asserting it is
106
+ done — only the gate can. The call BLOCKS (polling every
107
+ ``poll_interval_s``) until the gate passes or a bound trips, then
108
+ returns the outcome. Every lap is written to SLM memory (tag
109
+ ``loop:<name>``) and is visible on the dashboard.
110
+
111
+ Use it to wait, under strict bounds, for a verification or coordination
112
+ condition to hold — e.g. for another agent to record a "build passed"
113
+ memory in shared SLM.
114
+
115
+ Args:
116
+ name: Loop name, also the memory tag. 1–128 chars.
117
+ gate_query: Recall query the independent gate checks each lap.
118
+ gate_min_score: Minimum top-result score to pass (0.0 = any
119
+ confident hit above the evidence floor).
120
+ max_iterations: Hard cap on laps (1–200).
121
+ max_wallclock_s: Hard cap on wall-clock seconds (0 disables; capped at 120).
122
+ poll_interval_s: Seconds to wait between laps (minimum 0.25).
123
+ max_tokens: Optional token budget (0 disables).
124
+ no_progress_window: Halt after this many consecutive no-change laps
125
+ (0 disables). A pure watcher never "changes", so leave at 0
126
+ unless the runner reports progress.
127
+ """
128
+ try:
129
+ name = (name or "").strip()
130
+ if not name or len(name) > _MAX_NAME_CHARS:
131
+ return {"ok": False, "error": f"name must be 1–{_MAX_NAME_CHARS} chars"}
132
+ gate_query = (gate_query or "").strip()
133
+ if not gate_query or len(gate_query) > _MAX_QUERY_CHARS:
134
+ return {
135
+ "ok": False,
136
+ "error": f"gate_query must be 1–{_MAX_QUERY_CHARS} chars",
137
+ }
138
+
139
+ iters = max(1, min(int(max_iterations), _MAX_ITERATIONS))
140
+ wall = (
141
+ min(float(max_wallclock_s), _MAX_WALLCLOCK_S)
142
+ if max_wallclock_s and float(max_wallclock_s) > 0
143
+ else None
144
+ )
145
+ poll = max(float(poll_interval_s), _MIN_POLL_S)
146
+ tok = int(max_tokens) if max_tokens and int(max_tokens) > 0 else None
147
+ # 0 = disabled (a pure watcher never "changes", so no-progress must
148
+ # be off by default or it would halt before the gate can pass).
149
+ npw = (
150
+ int(no_progress_window)
151
+ if no_progress_window and int(no_progress_window) > 0
152
+ else 0
153
+ )
154
+ min_score = float(gate_min_score)
155
+
156
+ engine = get_engine()
157
+
158
+ def gate(lap: int) -> Verdict:
159
+ resp = engine.recall(gate_query, limit=3, fast=True)
160
+ results = getattr(resp, "results", None) or []
161
+ floored = bool(getattr(resp, "no_confident_match", False))
162
+ top = _top_score(resp)
163
+ passed = bool(results) and not floored and top >= min_score
164
+ return Verdict(
165
+ passed,
166
+ f"recall '{gate_query[:48]}': hits={len(results)} "
167
+ f"top={top:.3f} floor={floored}",
168
+ )
169
+
170
+ def runner(lap: int) -> LapResult:
171
+ # Watcher lap: no work of our own. Give the gate condition time
172
+ # to become true (e.g. another agent writing a memory) between
173
+ # polls. Bounded by poll interval so the wall-clock / iteration
174
+ # caps stay meaningful. The first lap checks immediately.
175
+ if lap > 1:
176
+ time.sleep(poll)
177
+ return LapResult(changed=False, tokens=0)
178
+
179
+ ledger = engine_backed_ledger(engine)
180
+
181
+ # The loop blocks (sleeps between laps); run it off the event loop.
182
+ # The engine's per-call WAL connection model makes this thread-safe.
183
+ outcome = await asyncio.to_thread(
184
+ run_bounded_loop,
185
+ name,
186
+ bounds=Bounds(
187
+ max_iterations=iters,
188
+ max_tokens=tok,
189
+ max_wallclock_s=wall,
190
+ no_progress_window=npw,
191
+ ),
192
+ runner=runner,
193
+ gate=gate,
194
+ ledger=ledger,
195
+ )
196
+ laps = await asyncio.to_thread(ledger.laps, outcome.run_id)
197
+ return {
198
+ "ok": True,
199
+ "status": outcome.status.value,
200
+ "reason": outcome.reason,
201
+ "passed": bool(outcome.ok),
202
+ "laps": outcome.laps,
203
+ "run_id": outcome.run_id,
204
+ "ledger": [
205
+ {
206
+ "lap": e.lap,
207
+ "decision": e.decision,
208
+ "passed": e.passed,
209
+ "detail": e.detail,
210
+ }
211
+ for e in laps
212
+ ],
213
+ "note": (
214
+ "The gate — an independent SLM recall — decided this "
215
+ "outcome; the agent's own done-claim never terminates a loop."
216
+ ),
217
+ }
218
+ except Exception as exc:
219
+ logger.exception("slm_loop_run failed (fail-open)")
220
+ return {"ok": False, "error": str(exc)}
221
+
222
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
223
+ async def slm_loop_history(name: str, limit: int = 20) -> dict:
224
+ """List recorded bounded-loop runs for a loop name, newest laps summarised.
225
+
226
+ Reads the durable SLM-backed ledger (the same rows ``slm loop history``
227
+ and the dashboard show). Read-only.
228
+
229
+ Args:
230
+ name: Loop name to list runs for.
231
+ limit: Maximum runs to return (1–200).
232
+ """
233
+ try:
234
+ name = (name or "").strip()
235
+ if not name:
236
+ return {"ok": False, "error": "name is required"}
237
+ lim = max(1, min(int(limit), 200))
238
+ engine = get_engine()
239
+ ledger = engine_backed_ledger(engine)
240
+
241
+ def _collect() -> list[dict]:
242
+ run_ids = ledger.runs(name)[:lim]
243
+ rows: list[dict] = []
244
+ for rid in run_ids:
245
+ laps = ledger.laps(rid)
246
+ last = laps[-1] if laps else None
247
+ rows.append(
248
+ {
249
+ "run_id": rid,
250
+ "laps": len(laps),
251
+ "final": last.decision if last else "unknown",
252
+ "ts": last.ts if last else "",
253
+ }
254
+ )
255
+ return rows
256
+
257
+ rows = await asyncio.to_thread(_collect)
258
+ return {"ok": True, "name": name, "count": len(rows), "runs": rows}
259
+ except Exception as exc:
260
+ logger.exception("slm_loop_history failed (fail-open)")
261
+ return {"ok": False, "error": str(exc)}
262
+
263
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
264
+ async def slm_loop_show(run_id: str, limit: int = 200) -> dict:
265
+ """Show every lap of one bounded-loop run, in order, from SLM memory.
266
+
267
+ Read-only. Each lap carries the gate verdict plus the agent's recorded
268
+ (advisory, never loop-terminating) done-claim.
269
+
270
+ Args:
271
+ run_id: Run identifier returned by ``slm_loop_run``.
272
+ limit: Maximum laps to return (1–1000).
273
+ """
274
+ try:
275
+ run_id = (run_id or "").strip()
276
+ if not run_id:
277
+ return {"ok": False, "error": "run_id is required"}
278
+ lim = max(1, min(int(limit), 1000))
279
+ engine = get_engine()
280
+ ledger = engine_backed_ledger(engine)
281
+
282
+ def _collect() -> list[dict]:
283
+ return [
284
+ {
285
+ "lap": e.lap,
286
+ "ts": e.ts,
287
+ "decision": e.decision,
288
+ "passed": e.passed,
289
+ "detail": e.detail,
290
+ "agent_claimed_done": e.agent_claimed_done,
291
+ "tokens": e.budget.get("tokens", 0),
292
+ }
293
+ for e in ledger.laps(run_id)[:lim]
294
+ ]
295
+
296
+ laps = await asyncio.to_thread(_collect)
297
+ return {"ok": True, "run_id": run_id, "count": len(laps), "laps": laps}
298
+ except Exception as exc:
299
+ logger.exception("slm_loop_show failed (fail-open)")
300
+ return {"ok": False, "error": str(exc)}
@@ -28,6 +28,103 @@ from mcp.types import ToolAnnotations
28
28
 
29
29
  logger = logging.getLogger(__name__)
30
30
 
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # M03: Simple circuit breaker for mesh_send
34
+ # ---------------------------------------------------------------------------
35
+ # Prevents runaway retries (~30s stall) when the daemon is dead.
36
+ # State machine: CLOSED → OPEN (after 3 consecutive failures)
37
+ # OPEN → HALF_OPEN (after 60s cooldown)
38
+ # HALF_OPEN → CLOSED (on one successful probe)
39
+ # HALF_OPEN → OPEN (on probe failure)
40
+
41
+ _CB_FAILURE_THRESHOLD = 3
42
+ _CB_COOLDOWN_SECONDS = 60
43
+
44
+ # Client-side mesh message cap (mirrors the broker's MAX_MESSAGE_SIZE).
45
+ MAX_MESSAGE_SIZE = 4096
46
+
47
+ _CB_STATE_CLOSED = "closed"
48
+ _CB_STATE_OPEN = "open"
49
+ _CB_STATE_HALF_OPEN = "half_open"
50
+
51
+
52
+ class _SendCircuitBreaker:
53
+ """Thread-safe circuit breaker scoped to mesh_send daemon calls."""
54
+
55
+ def __init__(self) -> None:
56
+ self._lock = threading.Lock()
57
+ self._state: str = _CB_STATE_CLOSED
58
+ self._failure_count: int = 0
59
+ self._opened_at: float = 0.0
60
+
61
+ def reset(self) -> None:
62
+ """Reset to CLOSED; used by tests for isolation."""
63
+ with self._lock:
64
+ self._state = _CB_STATE_CLOSED
65
+ self._failure_count = 0
66
+ self._opened_at = 0.0
67
+
68
+ def is_open(self) -> bool:
69
+ with self._lock:
70
+ if self._state == _CB_STATE_OPEN:
71
+ if time.monotonic() - self._opened_at >= _CB_COOLDOWN_SECONDS:
72
+ self._state = _CB_STATE_HALF_OPEN
73
+ return False # allow one probe
74
+ return True
75
+ return False
76
+
77
+ def allow_request(self) -> bool:
78
+ """Return True if the call should proceed; False if circuit is OPEN.
79
+
80
+ State transitions inside the lock prevent concurrent probe races:
81
+ - CLOSED → allow
82
+ - OPEN (cooldown not elapsed) → block
83
+ - OPEN (cooldown elapsed) → transition to HALF_OPEN, allow one probe,
84
+ and immediately re-enter OPEN so any concurrent call is blocked until
85
+ the probe result comes in via record_success / record_failure.
86
+ - HALF_OPEN → block (probe already dispatched and not yet resolved)
87
+ """
88
+ with self._lock:
89
+ if self._state == _CB_STATE_CLOSED:
90
+ return True
91
+ if self._state == _CB_STATE_OPEN:
92
+ if time.monotonic() - self._opened_at >= _CB_COOLDOWN_SECONDS:
93
+ # Grant exactly one probe by briefly entering HALF_OPEN then
94
+ # going back to OPEN. Subsequent callers are blocked until
95
+ # record_success or record_failure resolves the probe.
96
+ self._state = _CB_STATE_HALF_OPEN
97
+ return True
98
+ return False
99
+ # HALF_OPEN: probe is in flight — block until resolved
100
+ return False
101
+
102
+ def record_success(self) -> None:
103
+ with self._lock:
104
+ self._state = _CB_STATE_CLOSED
105
+ self._failure_count = 0
106
+
107
+ def record_failure(self) -> None:
108
+ with self._lock:
109
+ if self._state == _CB_STATE_HALF_OPEN:
110
+ # Probe failed — reopen immediately
111
+ self._state = _CB_STATE_OPEN
112
+ self._opened_at = time.monotonic()
113
+ return
114
+ self._failure_count += 1
115
+ if self._failure_count >= _CB_FAILURE_THRESHOLD:
116
+ self._state = _CB_STATE_OPEN
117
+ self._opened_at = time.monotonic()
118
+ logger.warning(
119
+ "mesh_send circuit breaker OPEN after %d consecutive failures; "
120
+ "fast-failing for %ds",
121
+ self._failure_count,
122
+ _CB_COOLDOWN_SECONDS,
123
+ )
124
+
125
+
126
+ _SEND_CIRCUIT = _SendCircuitBreaker()
127
+
31
128
  # Unique peer ID for this MCP server session
32
129
  _PEER_ID = str(uuid.uuid4())[:12]
33
130
  _SESSION_SUMMARY = ""
@@ -69,7 +166,15 @@ def _ensure_registered() -> None:
69
166
  "session_id": os.environ.get("CLAUDE_SESSION_ID", _PEER_ID),
70
167
  "summary": _SESSION_SUMMARY or "SLM MCP session",
71
168
  "project_path": _PROJECT_PATH,
72
- "agent_type": os.environ.get("CLAUDE_AGENT_TYPE", "claude_code"),
169
+ # Peer identity is the canonical SLM_AGENT_ID (same var memory
170
+ # attribution uses), so Antigravity/Hermes/Cursor/etc. show as
171
+ # themselves instead of collapsing to "claude_code". Fall back to the
172
+ # legacy CLAUDE_AGENT_TYPE, then a generic default.
173
+ "agent_type": (
174
+ os.environ.get("SLM_AGENT_ID")
175
+ or os.environ.get("CLAUDE_AGENT_TYPE")
176
+ or "claude_code"
177
+ ),
73
178
  })
74
179
  if result:
75
180
  # v3.6.12 (mesh-1): the broker mints its OWN peer_id (RegisterRequest has
@@ -174,12 +279,34 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
174
279
  - "project:/path/to/dir" (all sessions in that project directory)
175
280
  message: The message content (max 4KB — use file paths for large data)
176
281
  """
282
+ # Enforce the documented 4KB notification cap client-side too (the
283
+ # broker also caps, but fail fast without a round-trip).
284
+ if len(message.encode("utf-8")) > MAX_MESSAGE_SIZE:
285
+ return {"ok": False, "error": (
286
+ f"message too large (max {MAX_MESSAGE_SIZE} bytes) — "
287
+ "reference a file path instead")}
288
+ # M03: circuit breaker — fast-fail if daemon is repeatedly unreachable
289
+ if not _SEND_CIRCUIT.allow_request():
290
+ return {
291
+ "ok": False,
292
+ "error": (
293
+ "mesh_send circuit open: daemon unreachable after repeated failures. "
294
+ f"Retrying in {_CB_COOLDOWN_SECONDS}s."
295
+ ),
296
+ }
297
+
177
298
  await asyncio.to_thread(_ensure_registered)
178
299
  result = await asyncio.to_thread(
179
300
  _mesh_request, "POST", "/send",
180
301
  {"from_peer": _PEER_ID, "to_peer": to, "content": message},
181
302
  )
182
- return result or {"ok": False, "error": "Failed to send message"}
303
+ # Circuit tracks daemon-unreachable (None) only not valid broker errors
304
+ # like "recipient not found", which are application-level, not failures.
305
+ if result is None:
306
+ _SEND_CIRCUIT.record_failure()
307
+ return {"ok": False, "error": "Failed to send message"}
308
+ _SEND_CIRCUIT.record_success()
309
+ return result
183
310
 
184
311
  @server.tool()
185
312
  async def mesh_inbox() -> dict:
@@ -190,9 +317,11 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
190
317
  Messages auto-expire after 48 hours.
191
318
  """
192
319
  await asyncio.to_thread(_ensure_registered)
320
+ from urllib.parse import quote
193
321
  project = _PROJECT_PATH or _detect_project_path()
194
322
  messages = await asyncio.to_thread(
195
- _mesh_request, "GET", f"/inbox/{_PEER_ID}?project_path={project}",
323
+ _mesh_request, "GET",
324
+ f"/inbox/{_PEER_ID}?project_path={quote(project, safe='')}",
196
325
  )
197
326
  msg_list = (messages or {}).get("messages", [])
198
327
  # Auto-mark unread messages as read. v3.6.12 (failopen-2): use .get("id")
@@ -250,9 +379,16 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
250
379
  Before editing a shared file, check if another session has it locked.
251
380
 
252
381
  Args:
253
- file_path: Path to the file
382
+ file_path: Absolute path to the file
254
383
  action: "query" (check lock), "acquire" (lock file), "release" (unlock)
255
384
  """
385
+ # Require a non-empty absolute path; a relative/blank path is ambiguous
386
+ # and lets a caller probe arbitrary strings via the coordination store.
387
+ if not file_path or not (
388
+ file_path.startswith("/")
389
+ or (len(file_path) >= 3 and file_path[1] == ":")
390
+ ):
391
+ return {"ok": False, "error": "file_path must be a non-empty absolute path"}
256
392
  await asyncio.to_thread(_ensure_registered)
257
393
  result = await asyncio.to_thread(
258
394
  _mesh_request, "POST", "/lock",
@@ -170,7 +170,7 @@ def register_optimize_tools(server) -> None:
170
170
  "ok": False, "content": None, "size_bytes": 0,
171
171
  "error": "ccr_id must be a UUID4",
172
172
  }
173
- original = CCRStore.get_instance().retrieve(ccr_id)
173
+ original = CCRStore.get_instance().retrieve(ccr_id, tenant_id=_tenant())
174
174
  if original is None:
175
175
  return {
176
176
  "ok": False, "content": None, "size_bytes": 0,
@@ -257,13 +257,16 @@ def register_optimize_tools(server) -> None:
257
257
  cache_key = hashlib.sha256(f"mcpkv:{tenant}:{key}".encode()).hexdigest()
258
258
  norm_tid = _normalize_tenant_id(tenant)
259
259
 
260
- blob = CacheDB.get_default().get_value(cache_key, norm_tid)
260
+ db = CacheDB.get_default()
261
+ blob = db.get_value(cache_key, norm_tid)
261
262
  if blob is None:
262
263
  with _kv_lock:
263
264
  _kv_misses += 1
265
+ db.kv_counter_incr("kv_misses") # M2: durable across restarts
264
266
  return {"ok": True, "hit": False, "value": None, "note": None}
265
267
  with _kv_lock:
266
268
  _kv_hits += 1
269
+ db.kv_counter_incr("kv_hits") # M2: durable across restarts
267
270
  return {"ok": True, "hit": True, "value": blob.decode("utf-8"), "note": None}
268
271
 
269
272
  except Exception as exc:
@@ -277,14 +280,18 @@ def register_optimize_tools(server) -> None:
277
280
  async def slm_optimize_stats() -> dict:
278
281
  """Return compression and cache statistics.
279
282
 
280
- Proxy/compress stats are daemon-persisted (accurate across restarts).
281
- KV stats are in-module counters for this MCP process session only.
283
+ Proxy/compress AND KV stats are daemon-persisted (accurate across
284
+ restarts). KV counters fall back to this session's in-memory tally if
285
+ the persisted counters can't be read.
282
286
  """
283
287
  try:
284
- snap = CacheDB.get_default().metrics_load()
288
+ db = CacheDB.get_default()
289
+ snap = db.metrics_load()
290
+ # M2: prefer the durable KV counters; fall back to session counters.
291
+ persisted = db.kv_counters_load()
285
292
  with _kv_lock:
286
- kv_h = _kv_hits
287
- kv_m = _kv_misses
293
+ kv_h = persisted.get("kv_hits", _kv_hits)
294
+ kv_m = persisted.get("kv_misses", _kv_misses)
288
295
  return {
289
296
  "ok": True,
290
297
  "compress_runs": snap.compress_runs,
@@ -297,7 +304,7 @@ def register_optimize_tools(server) -> None:
297
304
  "CCR entry count not tracked per-session; "
298
305
  "see daemon /api/v1/metrics"
299
306
  ),
300
- "note": "proxy stats are daemon-persisted; kv stats are this session only",
307
+ "note": "proxy and kv stats are daemon-persisted across restarts",
301
308
  }
302
309
  except Exception as exc:
303
310
  logger.error("slm_optimize_stats failed (fail-open): %s", exc)