superlocalmemory 3.6.13 → 3.6.15

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 (147) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/CHANGELOG.md +28 -0
  3. package/README.md +189 -740
  4. package/package.json +12 -5
  5. package/plugin/.claude-plugin/plugin.json +20 -0
  6. package/plugin/.mcp.json +12 -0
  7. package/plugin/CLAUDE.md +44 -0
  8. package/plugin/_GENERATED.md +6 -0
  9. package/plugin/agents/slm-memory-advisor.md +44 -0
  10. package/plugin/agents/slm-optimize-advisor.md +38 -0
  11. package/plugin/hooks/hooks.json +14 -0
  12. package/plugin/requirements.txt +1 -0
  13. package/plugin/scripts/ensure-venv.bat +122 -0
  14. package/plugin/scripts/ensure-venv.sh +105 -0
  15. package/plugin/scripts/slm-launch +15 -0
  16. package/plugin/scripts/slm-launch.bat +17 -0
  17. package/plugin/settings.json +16 -0
  18. package/plugin/skills/slm-cache/SKILL.md +140 -0
  19. package/plugin/skills/slm-compress/SKILL.md +143 -0
  20. package/plugin/skills/slm-graph/SKILL.md +300 -0
  21. package/plugin/skills/slm-recall/SKILL.md +204 -0
  22. package/plugin/skills/slm-remember/SKILL.md +194 -0
  23. package/plugin/skills/slm-session/SKILL.md +207 -0
  24. package/plugin/skills/slm-status/SKILL.md +149 -0
  25. package/plugin-src/.mcp.json +12 -0
  26. package/plugin-src/agents/slm-memory-advisor.md +44 -0
  27. package/plugin-src/agents/slm-optimize-advisor.md +38 -0
  28. package/plugin-src/commands/slm-optimize.md +22 -0
  29. package/plugin-src/commands/slm-recall.md +16 -0
  30. package/plugin-src/commands/slm-remember.md +16 -0
  31. package/plugin-src/commands/slm-status.md +15 -0
  32. package/plugin-src/hooks/.gitkeep +0 -0
  33. package/plugin-src/hooks/hooks.json +14 -0
  34. package/plugin-src/manifest.json +25 -0
  35. package/plugin-src/requirements.txt +1 -0
  36. package/plugin-src/rules/AGENTS.md +91 -0
  37. package/plugin-src/rules/CLAUDE.md.fragment +44 -0
  38. package/plugin-src/scripts/ensure-venv.bat +122 -0
  39. package/plugin-src/scripts/ensure-venv.sh +105 -0
  40. package/plugin-src/scripts/slm-launch +15 -0
  41. package/plugin-src/scripts/slm-launch.bat +17 -0
  42. package/plugin-src/settings.json +16 -0
  43. package/plugin-src/skills/slm-cache/SKILL.md +140 -0
  44. package/plugin-src/skills/slm-compress/SKILL.md +143 -0
  45. package/plugin-src/skills/slm-graph/SKILL.md +300 -0
  46. package/plugin-src/skills/slm-recall/SKILL.md +204 -0
  47. package/plugin-src/skills/slm-remember/SKILL.md +194 -0
  48. package/plugin-src/skills/slm-session/SKILL.md +207 -0
  49. package/plugin-src/skills/slm-status/SKILL.md +149 -0
  50. package/pyproject.toml +6 -2
  51. package/scripts/__tests__/build-plugin.test.mjs +613 -0
  52. package/scripts/_savings_math.py +270 -0
  53. package/scripts/build-plugin.js +742 -0
  54. package/scripts/dogfood_savings.py +490 -0
  55. package/scripts/install-skills.ps1 +4 -334
  56. package/scripts/install-skills.sh +4 -435
  57. package/scripts/postinstall-interactive.js +0 -27
  58. package/scripts/postinstall.js +21 -2
  59. package/src/superlocalmemory/__init__.py +1 -1
  60. package/src/superlocalmemory/cli/_lazy_init.py +115 -0
  61. package/src/superlocalmemory/cli/commands.py +439 -41
  62. package/src/superlocalmemory/cli/main.py +92 -4
  63. package/src/superlocalmemory/cli/setup_wizard.py +47 -6
  64. package/src/superlocalmemory/core/backend_orchestrator.py +12 -8
  65. package/src/superlocalmemory/core/config.py +194 -9
  66. package/src/superlocalmemory/core/embeddings.py +10 -5
  67. package/src/superlocalmemory/core/engine.py +76 -5
  68. package/src/superlocalmemory/core/fact_consolidator.py +20 -3
  69. package/src/superlocalmemory/core/platform_utils.py +8 -0
  70. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  71. package/src/superlocalmemory/core/recall_worker.py +7 -0
  72. package/src/superlocalmemory/core/store_pipeline.py +23 -1
  73. package/src/superlocalmemory/core/worker_pool.py +14 -2
  74. package/src/superlocalmemory/hooks/claude_code_hooks.py +27 -3
  75. package/src/superlocalmemory/hooks/portable_kit.py +506 -0
  76. package/src/superlocalmemory/hooks/session_registry.py +8 -4
  77. package/src/superlocalmemory/infra/cloud_backup.py +99 -23
  78. package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -2
  79. package/src/superlocalmemory/mcp/_pool_adapter.py +15 -6
  80. package/src/superlocalmemory/mcp/cli_fallback.py +602 -0
  81. package/src/superlocalmemory/mcp/server.py +75 -4
  82. package/src/superlocalmemory/mcp/tools_code_graph.py +3 -3
  83. package/src/superlocalmemory/mcp/tools_core.py +37 -4
  84. package/src/superlocalmemory/mcp/tools_v3.py +6 -1
  85. package/src/superlocalmemory/mcp/tools_v33.py +8 -4
  86. package/src/superlocalmemory/optimize/cache/boundary_store.py +25 -6
  87. package/src/superlocalmemory/optimize/cache/centroid_store.py +27 -4
  88. package/src/superlocalmemory/optimize/cache/manager.py +92 -6
  89. package/src/superlocalmemory/optimize/cache/semantic.py +20 -1
  90. package/src/superlocalmemory/optimize/compress/ccr.py +12 -0
  91. package/src/superlocalmemory/optimize/compress/router.py +46 -13
  92. package/src/superlocalmemory/optimize/config/schema.py +6 -0
  93. package/src/superlocalmemory/optimize/proxy/_helpers.py +111 -8
  94. package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +14 -4
  95. package/src/superlocalmemory/optimize/proxy/gemini_surface.py +23 -6
  96. package/src/superlocalmemory/optimize/proxy/openai_surface.py +10 -4
  97. package/src/superlocalmemory/optimize/proxy/server.py +11 -0
  98. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +246 -0
  99. package/src/superlocalmemory/optimize/storage/db.py +30 -0
  100. package/src/superlocalmemory/retrieval/bm25_channel.py +12 -2
  101. package/src/superlocalmemory/retrieval/engine.py +36 -3
  102. package/src/superlocalmemory/retrieval/entity_channel.py +5 -5
  103. package/src/superlocalmemory/retrieval/hopfield_channel.py +10 -2
  104. package/src/superlocalmemory/retrieval/semantic_channel.py +10 -2
  105. package/src/superlocalmemory/server/recall_serializer.py +3 -1
  106. package/src/superlocalmemory/server/unified_daemon.py +156 -16
  107. package/src/superlocalmemory/storage/database.py +215 -43
  108. package/src/superlocalmemory/storage/migration_runner.py +17 -1
  109. package/src/superlocalmemory/storage/migrations/M016_add_scope_support.py +120 -0
  110. package/src/superlocalmemory/storage/models.py +10 -0
  111. package/src/superlocalmemory/storage/schema.py +15 -10
  112. package/src/superlocalmemory/ui/css/legacy-dashboard.css +18 -0
  113. package/src/superlocalmemory/ui/css/neural-glass.css +5 -0
  114. package/src/superlocalmemory/ui/index.html +2 -2
  115. package/src/superlocalmemory/ui/js/core.js +98 -0
  116. package/src/superlocalmemory/ui/js/dashboard.js +8 -1
  117. package/src/superlocalmemory/ui/js/ide-status.js +16 -3
  118. package/src/superlocalmemory/ui/js/math-health.js +15 -3
  119. package/src/superlocalmemory/ui/js/optimize.js +18 -2
  120. package/src/superlocalmemory/ui/js/trust-dashboard.js +10 -1
  121. package/src/superlocalmemory.egg-info/PKG-INFO +191 -741
  122. package/src/superlocalmemory.egg-info/SOURCES.txt +7 -9
  123. package/src/superlocalmemory.egg-info/requires.txt +1 -0
  124. package/ide/skills/slm-build-graph/SKILL.md +0 -423
  125. package/ide/skills/slm-list-recent/SKILL.md +0 -348
  126. package/ide/skills/slm-recall/SKILL.md +0 -326
  127. package/ide/skills/slm-remember/SKILL.md +0 -194
  128. package/ide/skills/slm-show-patterns/SKILL.md +0 -224
  129. package/ide/skills/slm-status/SKILL.md +0 -363
  130. package/ide/skills/slm-switch-profile/SKILL.md +0 -442
  131. package/skills/slm-build-graph/SKILL.md +0 -423
  132. package/skills/slm-list-recent/SKILL.md +0 -348
  133. package/skills/slm-optimize/README.md +0 -55
  134. package/skills/slm-optimize/SKILL.md +0 -139
  135. package/skills/slm-recall/SKILL.md +0 -343
  136. package/skills/slm-remember/SKILL.md +0 -194
  137. package/skills/slm-show-patterns/SKILL.md +0 -224
  138. package/skills/slm-status/SKILL.md +0 -363
  139. package/skills/slm-switch-profile/SKILL.md +0 -442
  140. package/src/superlocalmemory/cli/doctor_cmd.py +0 -152
  141. package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +0 -423
  142. package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +0 -348
  143. package/src/superlocalmemory/skills/slm-recall/SKILL.md +0 -343
  144. package/src/superlocalmemory/skills/slm-remember/SKILL.md +0 -194
  145. package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +0 -224
  146. package/src/superlocalmemory/skills/slm-status/SKILL.md +0 -363
  147. package/src/superlocalmemory/skills/slm-switch-profile/SKILL.md +0 -442
@@ -0,0 +1,602 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # WP-09: MCP→CLI fallback adapter (ships INERT — DQ-2=B)
4
+
5
+ """MCP→CLI fallback adapter for SuperLocalMemory.
6
+
7
+ When an MCP tool call fails, this module re-executes the equivalent
8
+ ``slm <verb> --json`` command as a subprocess and reshapes the result
9
+ into the tool's expected MCP envelope shape.
10
+
11
+ Key design decisions:
12
+ - NEVER raises: every code path returns dict|None.
13
+ - shell=False always: argv is always a list.
14
+ - Only verified CLI verbs used: see TOOL_CLI_MAP (all argv[0] values are
15
+ confirmed subparsers in cli/main.py).
16
+ - Non-backed tools (no CLI equivalent) return {success:False,
17
+ fallback:"unavailable"} immediately without spawning a subprocess.
18
+ - Ships INERT (DQ-2=B): nothing calls this module yet. Wiring to
19
+ tools_core.py except-branches is deferred to a future audited diff.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import logging
26
+ import os
27
+ import shutil
28
+ import subprocess
29
+ import sys
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import Any, Callable
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Data structures
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class CliVerb:
43
+ """Describes how to call ``slm <verb>`` for a specific MCP tool.
44
+
45
+ Attributes:
46
+ argv: The base argv after the ``slm`` binary, e.g. ``["recall"]``
47
+ or ``["list", "-n"]``. NEVER includes ``--json`` (added at
48
+ call time) or the ``slm`` binary itself.
49
+ build_args: Callable that takes the MCP tool's ``args`` dict and
50
+ returns extra argv tokens (positional values, flags).
51
+ Must return a list; never raises.
52
+ reshape: Callable that takes the ``data`` field from a successful
53
+ CLI JSON envelope and returns a dict in the MCP tool's
54
+ return shape. ``via`` and ``degraded`` are added by the
55
+ caller — do not include them here.
56
+ """
57
+
58
+ argv: list[str]
59
+ build_args: Callable[[dict[str, Any]], list[str]]
60
+ reshape: Callable[[dict[str, Any]], dict[str, Any]]
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Helper builders (keep anonymous lambdas minimal; use named functions for
65
+ # anything non-trivial to aid testing and readability)
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ def _no_args(_: dict[str, Any]) -> list[str]:
70
+ """No extra args needed."""
71
+ return []
72
+
73
+
74
+ def _build_recall(args: dict[str, Any]) -> list[str]:
75
+ extra: list[str] = []
76
+ query = args.get("query", "")
77
+ if query:
78
+ extra.append(str(query))
79
+ limit = args.get("limit")
80
+ if limit is not None:
81
+ extra += ["--limit", str(int(limit))]
82
+ return extra
83
+
84
+
85
+ def _reshape_recall(data: dict[str, Any]) -> dict[str, Any]:
86
+ return {
87
+ "results": data.get("results", []),
88
+ "count": data.get("count", 0),
89
+ "query": data.get("query", ""),
90
+ }
91
+
92
+
93
+ def _build_remember(args: dict[str, Any]) -> list[str]:
94
+ content = args.get("content", "")
95
+ extra: list[str] = [str(content)] if content else []
96
+ tags = args.get("tags", "")
97
+ if tags:
98
+ extra += ["--tags", str(tags)]
99
+ return extra
100
+
101
+
102
+ def _reshape_remember(data: dict[str, Any]) -> dict[str, Any]:
103
+ # Handle both sync {fact_ids, count} and async {queued, pending_id}
104
+ if "fact_ids" in data or "count" in data:
105
+ return {
106
+ "fact_ids": data.get("fact_ids", []),
107
+ "count": data.get("count", 0),
108
+ }
109
+ return {
110
+ "queued": data.get("queued", False),
111
+ "pending_id": data.get("pending_id", ""),
112
+ }
113
+
114
+
115
+ def _build_list_recent(args: dict[str, Any]) -> list[str]:
116
+ limit = args.get("limit", 20)
117
+ return ["-n", str(int(limit))]
118
+
119
+
120
+ def _build_delete_memory(args: dict[str, Any]) -> list[str]:
121
+ fact_id = args.get("fact_id", "")
122
+ return [str(fact_id), "--yes"] if fact_id else ["--yes"]
123
+
124
+
125
+ def _build_update_memory(args: dict[str, Any]) -> list[str]:
126
+ fact_id = args.get("fact_id", "")
127
+ content = args.get("content", "")
128
+ extra: list[str] = []
129
+ if fact_id:
130
+ extra.append(str(fact_id))
131
+ if content:
132
+ extra.append(str(content))
133
+ return extra
134
+
135
+
136
+ def _build_forget(args: dict[str, Any]) -> list[str]:
137
+ query = args.get("query", "")
138
+ return [str(query), "--yes"] if query else ["--yes"]
139
+
140
+
141
+ def _build_consolidate_cognitive(_: dict[str, Any]) -> list[str]:
142
+ return ["--cognitive"]
143
+
144
+
145
+ def _build_set_mode(args: dict[str, Any]) -> list[str]:
146
+ mode = args.get("mode", "")
147
+ return [str(mode)] if mode else []
148
+
149
+
150
+ def _build_profile_switch(args: dict[str, Any]) -> list[str]:
151
+ profile_id = args.get("profile_id", "")
152
+ extra: list[str] = ["switch"]
153
+ if profile_id:
154
+ extra.append(str(profile_id))
155
+ return extra
156
+
157
+
158
+ def _build_trace(args: dict[str, Any]) -> list[str]:
159
+ query = args.get("query", "")
160
+ extra = [str(query)] if query else []
161
+ limit = args.get("limit")
162
+ if limit is not None:
163
+ extra += ["--limit", str(int(limit))]
164
+ return extra
165
+
166
+
167
+ def _passthrough(data: dict[str, Any]) -> dict[str, Any]:
168
+ """Return data unchanged — reshaping not required for this tool."""
169
+ return dict(data)
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # TOOL_CLI_MAP — ONLY verbs verified present in cli/main.py
174
+ # Verified via: grep "sub\.add_parser" cli/main.py
175
+ # ---------------------------------------------------------------------------
176
+
177
+ TOOL_CLI_MAP: dict[str, CliVerb] = {
178
+ # recall + search alias (recall has aliases=["search"] in argparse)
179
+ "recall": CliVerb(
180
+ argv=["recall"],
181
+ build_args=_build_recall,
182
+ reshape=_reshape_recall,
183
+ ),
184
+ "search": CliVerb(
185
+ argv=["search"],
186
+ build_args=_build_recall,
187
+ reshape=_reshape_recall,
188
+ ),
189
+ # remember — handles both sync {fact_ids,count} and async {queued,pending_id}
190
+ "remember": CliVerb(
191
+ argv=["remember"],
192
+ build_args=_build_remember,
193
+ reshape=_reshape_remember,
194
+ ),
195
+ # list_recent → slm list -n <limit>
196
+ "list_recent": CliVerb(
197
+ argv=["list"],
198
+ build_args=_build_list_recent,
199
+ reshape=_passthrough,
200
+ ),
201
+ # delete_memory → slm delete <fact_id> --yes
202
+ "delete_memory": CliVerb(
203
+ argv=["delete"],
204
+ build_args=_build_delete_memory,
205
+ reshape=_passthrough,
206
+ ),
207
+ # update_memory → slm update <fact_id> <content>
208
+ "update_memory": CliVerb(
209
+ argv=["update"],
210
+ build_args=_build_update_memory,
211
+ reshape=_passthrough,
212
+ ),
213
+ # get_status → slm status
214
+ "get_status": CliVerb(
215
+ argv=["status"],
216
+ build_args=_no_args,
217
+ reshape=_passthrough,
218
+ ),
219
+ # forget → slm forget <query> --yes
220
+ "forget": CliVerb(
221
+ argv=["forget"],
222
+ build_args=_build_forget,
223
+ reshape=_passthrough,
224
+ ),
225
+ # consolidate_cognitive → slm consolidate --cognitive
226
+ "consolidate_cognitive": CliVerb(
227
+ argv=["consolidate"],
228
+ build_args=_build_consolidate_cognitive,
229
+ reshape=_passthrough,
230
+ ),
231
+ # get_soft_prompts → slm soft-prompts
232
+ "get_soft_prompts": CliVerb(
233
+ argv=["soft-prompts"],
234
+ build_args=_no_args,
235
+ reshape=_passthrough,
236
+ ),
237
+ # set_mode → slm mode <m>
238
+ "set_mode": CliVerb(
239
+ argv=["mode"],
240
+ build_args=_build_set_mode,
241
+ reshape=_passthrough,
242
+ ),
243
+ # get_mode → slm mode (no argument = get current)
244
+ "get_mode": CliVerb(
245
+ argv=["mode"],
246
+ build_args=_no_args,
247
+ reshape=_passthrough,
248
+ ),
249
+ # recall_trace → slm trace <query>
250
+ "recall_trace": CliVerb(
251
+ argv=["trace"],
252
+ build_args=_build_trace,
253
+ reshape=_passthrough,
254
+ ),
255
+ # quantize → slm quantize
256
+ "quantize": CliVerb(
257
+ argv=["quantize"],
258
+ build_args=_no_args,
259
+ reshape=_passthrough,
260
+ ),
261
+ # health → slm health
262
+ "health": CliVerb(
263
+ argv=["health"],
264
+ build_args=_no_args,
265
+ reshape=_passthrough,
266
+ ),
267
+ # slm_optimize_stats → slm optimize status (degraded)
268
+ "slm_optimize_stats": CliVerb(
269
+ argv=["optimize", "status"],
270
+ build_args=_no_args,
271
+ reshape=_passthrough,
272
+ ),
273
+ # slm_cache_get → slm cache status (degraded — no key-level get in CLI)
274
+ "slm_cache_get": CliVerb(
275
+ argv=["cache", "status"],
276
+ build_args=_no_args,
277
+ reshape=_passthrough,
278
+ ),
279
+ # compact_memories → slm compress status (degraded)
280
+ "compact_memories": CliVerb(
281
+ argv=["compress", "status"],
282
+ build_args=_no_args,
283
+ reshape=_passthrough,
284
+ ),
285
+ # switch_profile → slm profile switch <profile_id>
286
+ "switch_profile": CliVerb(
287
+ argv=["profile"],
288
+ build_args=_build_profile_switch,
289
+ reshape=_passthrough,
290
+ ),
291
+ }
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # NON_BACKED_TOOLS — tools with no viable CLI fallback
296
+ # Reasons:
297
+ # - fetch: no single-fact CLI fetch verb
298
+ # - code-graph (20): CLI verbs only exist under slm compress (toggles), not query
299
+ # - slm_retrieve/slm_cache_set: cache set/key retrieval not exposed in CLI
300
+ # - observe: lacks --json output (argparse exit or no structured output)
301
+ # - evolve_skill/skill_health/skill_lineage: learning tools, no CLI equivalent
302
+ # - get_version: --version is plain text, not --json envelope
303
+ # - all 8 mesh: P2P daemon-backed, no CLI equivalent
304
+ # - learning/assertion: no CLI equivalent
305
+ # - session_init/close_session/run_maintenance: daemon-implicit lifecycle
306
+ # - report_feedback/report_outcome: no CLI equivalent
307
+ # - get_lifecycle_status/set_retention_policy: no CLI equivalent
308
+ # - get_behavioral_patterns/audit_trail: no CLI equivalent
309
+ # - consistency_check/get_retention_stats: no CLI equivalent
310
+ # - backup_status/memory_used/get_learned_patterns/correct_pattern: no CLI equivalent
311
+ # - get_attribution/build_graph/core_memory/reap_processes: no CLI equivalent
312
+ # - slm_compress: no CLI read equivalent (compress is write-only control)
313
+ # ---------------------------------------------------------------------------
314
+
315
+ NON_BACKED_TOOLS: frozenset[str] = frozenset({
316
+ # Fetch — no single-fact CLI verb
317
+ "fetch",
318
+ # Code-graph tools (20)
319
+ "build_code_graph",
320
+ "update_code_graph",
321
+ "get_blast_radius",
322
+ "get_review_context",
323
+ "query_graph",
324
+ "semantic_search_code",
325
+ "list_graph_stats",
326
+ "find_large_functions",
327
+ "list_flows",
328
+ "get_flow",
329
+ "get_affected_flows",
330
+ "list_communities",
331
+ "get_community",
332
+ "get_architecture_overview",
333
+ "detect_changes",
334
+ "refactor_preview",
335
+ "apply_refactor",
336
+ "code_memory_search",
337
+ "code_entity_history",
338
+ "enrich_blast_radius",
339
+ # Optimize tools without usable CLI read path
340
+ "slm_retrieve",
341
+ "slm_cache_set",
342
+ "slm_compress",
343
+ # observe/evolve lack --json or reliable structured output
344
+ "observe",
345
+ "evolve_skill",
346
+ # Version is plain text
347
+ "get_version",
348
+ # Mesh P2P tools (8)
349
+ "mesh_summary",
350
+ "mesh_peers",
351
+ "mesh_send",
352
+ "mesh_inbox",
353
+ "mesh_state",
354
+ "mesh_lock",
355
+ "mesh_events",
356
+ "mesh_status",
357
+ # Learning / assertion tools
358
+ "log_tool_event",
359
+ "get_assertions",
360
+ "reinforce_assertion",
361
+ "contradict_assertion",
362
+ # Evolution tools
363
+ "skill_health",
364
+ "skill_lineage",
365
+ # Session lifecycle (daemon-implicit)
366
+ "session_init",
367
+ "close_session",
368
+ "run_maintenance",
369
+ # Feedback / reporting
370
+ "report_feedback",
371
+ "report_outcome",
372
+ # V28 tools — no CLI equivalent
373
+ "get_lifecycle_status",
374
+ "set_retention_policy",
375
+ "get_behavioral_patterns",
376
+ "audit_trail",
377
+ # V3 tools without clean CLI fallback
378
+ "consistency_check",
379
+ "get_retention_stats",
380
+ # Core extras without CLI read equivalent
381
+ "backup_status",
382
+ "memory_used",
383
+ "get_learned_patterns",
384
+ "correct_pattern",
385
+ "get_attribution",
386
+ "build_graph",
387
+ "core_memory",
388
+ "reap_processes",
389
+ })
390
+
391
+
392
+ # ---------------------------------------------------------------------------
393
+ # Internal helpers
394
+ # ---------------------------------------------------------------------------
395
+
396
+
397
+ def _resolve_slm(slm_path: str | None = None) -> str | None:
398
+ """Resolve the ``slm`` CLI binary path.
399
+
400
+ Probe order:
401
+ 1. Explicit ``slm_path`` argument (from caller).
402
+ 2. ``SLM_CLI_PATH`` environment variable.
403
+ 3. ``shutil.which("slm")`` — PATH lookup.
404
+ 4. venv-adjacent ``bin/slm`` (same Python prefix).
405
+
406
+ Returns the first valid path string found, or None if not found.
407
+ Never raises.
408
+ """
409
+ # 1. Explicit override from caller
410
+ if slm_path:
411
+ return slm_path
412
+
413
+ # 2. Environment variable
414
+ env_path = os.environ.get("SLM_CLI_PATH", "").strip()
415
+ if env_path:
416
+ return env_path
417
+
418
+ # 3. PATH lookup
419
+ which_result = shutil.which("slm")
420
+ if which_result:
421
+ return which_result
422
+
423
+ # 4. Venv-adjacent: look for slm next to the current Python interpreter
424
+ python_bin = Path(sys.executable)
425
+ candidate = python_bin.parent / "slm"
426
+ if candidate.exists():
427
+ return str(candidate)
428
+
429
+ return None
430
+
431
+
432
+ def _parse_json_from_stdout(stdout: str) -> dict[str, Any] | None:
433
+ """Parse JSON from subprocess stdout, handling banner text prefix.
434
+
435
+ The SLM CLI emits JSON on stdout (banner goes to stderr). However,
436
+ some environments or older versions may emit banner text before the
437
+ JSON on stdout. We use brace-scanning to find the first ``{`` and
438
+ attempt to parse from there.
439
+
440
+ Returns dict if parsed successfully, None otherwise. Never raises.
441
+ """
442
+ if not stdout:
443
+ return None
444
+
445
+ # Fast path: entire stdout is valid JSON
446
+ try:
447
+ parsed = json.loads(stdout)
448
+ if isinstance(parsed, dict):
449
+ return parsed
450
+ except (json.JSONDecodeError, ValueError):
451
+ pass
452
+
453
+ # Brace-scan fallback: find first '{' and try parsing from there
454
+ brace_idx = stdout.find("{")
455
+ if brace_idx == -1:
456
+ return None
457
+
458
+ try:
459
+ parsed = json.loads(stdout[brace_idx:])
460
+ if isinstance(parsed, dict):
461
+ return parsed
462
+ except (json.JSONDecodeError, ValueError):
463
+ pass
464
+
465
+ return None
466
+
467
+
468
+ def _run_slm(
469
+ argv: list[str],
470
+ timeout: float,
471
+ ) -> dict[str, Any] | None:
472
+ """Run ``slm`` as a subprocess and parse the JSON envelope from stdout.
473
+
474
+ Args:
475
+ argv: Full command including the slm binary path, e.g.
476
+ ["/usr/local/bin/slm", "recall", "hello", "--json"].
477
+ timeout: Subprocess timeout in seconds.
478
+
479
+ Returns:
480
+ Parsed dict from stdout JSON, or None on timeout / OSError /
481
+ non-JSON output. Never raises.
482
+ """
483
+ try:
484
+ result = subprocess.run(
485
+ argv,
486
+ capture_output=True,
487
+ text=True,
488
+ timeout=timeout,
489
+ shell=False,
490
+ check=False,
491
+ )
492
+ except subprocess.TimeoutExpired:
493
+ logger.warning("slm CLI timed out after %.1fs (argv=%r)", timeout, argv)
494
+ return None
495
+ except OSError as exc:
496
+ logger.warning("slm CLI OSError: %s (argv=%r)", exc, argv)
497
+ return None
498
+
499
+ return _parse_json_from_stdout(result.stdout)
500
+
501
+
502
+ # ---------------------------------------------------------------------------
503
+ # Internal routing helpers (keep fallback_via_cli under 50 lines)
504
+ # ---------------------------------------------------------------------------
505
+
506
+
507
+ def _unavailable(reason: str, tool_name: str) -> dict[str, Any]:
508
+ """Return a clean unavailable envelope."""
509
+ return {
510
+ "success": False,
511
+ "fallback": "unavailable",
512
+ "reason": reason,
513
+ "tool": tool_name,
514
+ }
515
+
516
+
517
+ def _invoke_backed(
518
+ verb: CliVerb,
519
+ safe_args: dict[str, Any],
520
+ tool_name: str,
521
+ slm_bin: str,
522
+ timeout: float,
523
+ ) -> dict[str, Any] | None:
524
+ """Run the CLI subprocess for a backed tool and return the shaped result.
525
+
526
+ Returns shaped dict on success, None if subprocess failed, or an
527
+ error dict if CLI returned success=False.
528
+ """
529
+ try:
530
+ extra = verb.build_args(safe_args)
531
+ except Exception: # noqa: BLE001
532
+ extra = []
533
+
534
+ full_argv: list[str] = [slm_bin, *verb.argv, *extra, "--json"]
535
+ envelope = _run_slm(full_argv, timeout)
536
+ if envelope is None:
537
+ logger.warning("fallback_via_cli: subprocess returned no valid JSON for %r", tool_name)
538
+ return None
539
+
540
+ if not envelope.get("success", False):
541
+ return {
542
+ "success": False,
543
+ "error": envelope.get("error", {}),
544
+ "via": "cli_fallback",
545
+ "tool": tool_name,
546
+ }
547
+
548
+ data = envelope.get("data", {})
549
+ if not isinstance(data, dict):
550
+ data = {}
551
+ try:
552
+ shaped = verb.reshape(data)
553
+ except Exception: # noqa: BLE001
554
+ shaped = dict(data)
555
+
556
+ shaped["via"] = "cli_fallback"
557
+ shaped["degraded"] = True
558
+ return shaped
559
+
560
+
561
+ # ---------------------------------------------------------------------------
562
+ # Public API
563
+ # ---------------------------------------------------------------------------
564
+
565
+
566
+ def fallback_via_cli(
567
+ tool_name: Any,
568
+ args: Any,
569
+ *,
570
+ timeout: float = 20.0,
571
+ slm_path: str | None = None,
572
+ ) -> dict[str, Any] | None:
573
+ """Attempt to execute an MCP tool via the ``slm`` CLI as a fallback.
574
+
575
+ Called from an MCP tool's except branch when the normal code path fails.
576
+ NEVER raises — all exceptions are caught internally.
577
+
578
+ Returns:
579
+ - dict (tool MCP shape + ``via:"cli_fallback"`` + ``degraded:True``)
580
+ on success.
581
+ - ``{success:False, fallback:"unavailable"}`` for non-backed / unmapped.
582
+ - ``None`` when backed but subprocess failed (caller surfaces MCP error).
583
+ """
584
+ if not isinstance(tool_name, str):
585
+ return _unavailable("invalid_tool_name", "")
586
+ safe_args: dict[str, Any] = args if isinstance(args, dict) else {}
587
+
588
+ if tool_name in NON_BACKED_TOOLS:
589
+ logger.debug("fallback_via_cli: %r is NON_BACKED", tool_name)
590
+ return _unavailable("no_cli_verb", tool_name)
591
+
592
+ verb = TOOL_CLI_MAP.get(tool_name)
593
+ if verb is None:
594
+ logger.debug("fallback_via_cli: %r not in TOOL_CLI_MAP", tool_name)
595
+ return _unavailable("unmapped", tool_name)
596
+
597
+ slm_bin = _resolve_slm(slm_path)
598
+ if slm_bin is None:
599
+ logger.warning("fallback_via_cli: slm binary not found — cannot fallback %r", tool_name)
600
+ return None
601
+
602
+ return _invoke_backed(verb, safe_args, tool_name, slm_bin, timeout)
@@ -135,6 +135,73 @@ _all_tools = _os_reg.environ.get("SLM_MCP_ALL_TOOLS") == "1"
135
135
  # exactly the tools they invoke. Falls back to _ESSENTIAL_TOOLS when unset.
136
136
  _user_allowlist_str = _os_reg.environ.get("SLM_MCP_TOOLS", "").strip()
137
137
 
138
+ # ---------------------------------------------------------------------------
139
+ # v3.6.14 WP-01: Named profile definitions
140
+ # ---------------------------------------------------------------------------
141
+
142
+ _PROFILE_CORE = frozenset({ # 14
143
+ "remember", "recall", "search", "fetch", "list_recent", "update_memory", "forget",
144
+ "session_init", "close_session",
145
+ "slm_compress", "slm_retrieve", "slm_cache_set", "slm_cache_get", "slm_optimize_stats",
146
+ })
147
+ _PROFILE_CODE = _PROFILE_CORE | frozenset({ # 20
148
+ "build_code_graph", "get_blast_radius", "query_graph",
149
+ "semantic_search_code", "get_review_context", "detect_changes",
150
+ })
151
+ _PROFILE_FULL_MESH = frozenset({ # 8
152
+ "mesh_summary", "mesh_peers", "mesh_send", "mesh_inbox",
153
+ "mesh_state", "mesh_lock", "mesh_events", "mesh_status",
154
+ })
155
+ _PROFILE_FULL = frozenset({ # 30 base — EXPLICIT literal, NOT runtime _ESSENTIAL_TOOLS (OQ-2)
156
+ "remember", "recall", "search", "fetch", "list_recent", "delete_memory", "update_memory",
157
+ "get_status", "session_init", "observe", "close_session", "report_feedback", "forget",
158
+ "run_maintenance", "consolidate_cognitive", "get_soft_prompts", "set_mode", "report_outcome",
159
+ "log_tool_event", "get_assertions", "reinforce_assertion", "contradict_assertion",
160
+ "evolve_skill", "skill_health", "skill_lineage",
161
+ "slm_compress", "slm_retrieve", "slm_cache_set", "slm_cache_get", "slm_optimize_stats",
162
+ }) | _PROFILE_FULL_MESH # 38
163
+ _PROFILE_POWER = _PROFILE_FULL | frozenset({ # 50
164
+ "get_version", "get_mode", "health", "consistency_check", "recall_trace",
165
+ "get_lifecycle_status", "set_retention_policy", "compact_memories",
166
+ "get_behavioral_patterns", "audit_trail", "quantize", "get_retention_stats",
167
+ })
168
+ _PROFILE_MESH = _PROFILE_FULL_MESH # 8
169
+
170
+ _PROFILE_DEFINITIONS: dict[str, frozenset[str]] = {
171
+ "core": _PROFILE_CORE,
172
+ "code": _PROFILE_CODE,
173
+ "full": _PROFILE_FULL,
174
+ "power": _PROFILE_POWER,
175
+ "mesh": _PROFILE_MESH,
176
+ } # "whole" intentionally absent — maps to raw server (D-2 LOCKED)
177
+
178
+ _profile = _os_reg.environ.get("SLM_MCP_PROFILE", "").strip().lower()
179
+
180
+
181
+ def _resolve_profile_allowed(
182
+ profile: str,
183
+ definitions: dict[str, frozenset[str]],
184
+ essential: frozenset[str],
185
+ ) -> frozenset[str] | None:
186
+ """Resolve a profile name to its allowed tool frozenset, or None for raw-server routes.
187
+
188
+ Returns:
189
+ None — for "" (no selection) or "whole" (raw server, all tools).
190
+ frozenset — the profile's tool set for known profiles.
191
+ essential — fail-open fallback for unknown non-empty profiles (+ warning).
192
+ """
193
+ if not profile or profile == "whole":
194
+ return None
195
+ if profile in definitions:
196
+ return definitions[profile]
197
+ logger.warning(
198
+ "SLM_MCP_PROFILE=%r is not a recognised profile "
199
+ "(valid: %s, whole); falling back to essential tools.",
200
+ profile,
201
+ ", ".join(sorted(definitions)),
202
+ )
203
+ return essential
204
+
138
205
 
139
206
  class _FilteredServer:
140
207
  """Wraps FastMCP to only register essential tools.
@@ -161,14 +228,18 @@ class _FilteredServer:
161
228
  return getattr(self._server, name)
162
229
 
163
230
 
164
- # Choose registration target (precedence: ALL > user allowlist > essential)
231
+ # Choose registration target (precedence: ALL > user allowlist > profile > essential)
165
232
  if _all_tools:
166
- _target = server
233
+ _target = server # tier1 (unchanged)
167
234
  elif _user_allowlist_str:
168
235
  _user_allowlist = frozenset(t.strip() for t in _user_allowlist_str.split(",") if t.strip())
169
- _target = _FilteredServer(server, _user_allowlist)
236
+ _target = _FilteredServer(server, _user_allowlist) # tier2 (unchanged)
237
+ elif _profile == "whole":
238
+ _target = server # NEW raw-server
239
+ elif _profile:
240
+ _target = _FilteredServer(server, _resolve_profile_allowed(_profile, _PROFILE_DEFINITIONS, _ESSENTIAL_TOOLS)) # NEW
170
241
  else:
171
- _target = _FilteredServer(server, _ESSENTIAL_TOOLS)
242
+ _target = _FilteredServer(server, _ESSENTIAL_TOOLS) # default (unchanged)
172
243
 
173
244
  from superlocalmemory.mcp.tools_core import register_core_tools
174
245
  from superlocalmemory.mcp.tools_v28 import register_v28_tools