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,506 @@
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
4
+
5
+ """WP-08 portable kit — ``slm connect <ide>`` MCP-wiring.
6
+
7
+ Writes SLM's MCP block into the target IDE config via MERGE-NOT-CLOBBER:
8
+ - Only touches the ``superlocalmemory`` server key.
9
+ - All other servers + top-level keys are preserved byte-for-byte.
10
+ - Atomic write (.tmp + os.replace); aborts on parse error (file untouched).
11
+ - claude-code is OUT: short-circuits to a WP-06 plugin pointer, no config written.
12
+ - AGENTS.md is appended with <!-- SLM-START/END --> markers (never overwrite).
13
+
14
+ IDE_MATRIX verified against ide/configs/* templates (read-only, WP-04 owns).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import copy
20
+ import json
21
+ import logging
22
+ import os
23
+ import sys
24
+ import warnings
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any, Callable
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Marker convention copied from ide_connector.py (do not edit that class)
32
+ SLM_MARKER_START = "<!-- SLM-START -->"
33
+ SLM_MARKER_END = "<!-- SLM-END -->"
34
+
35
+ CLAUDE_CODE_PLUGIN_POINTER = (
36
+ "slm connect claude-code: Claude Code is configured via the SLM plugin (WP-06).\n"
37
+ "Run: slm plugin install OR see plugin-src/ for manual installation.\n"
38
+ "No MCP config file is written by this command."
39
+ )
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # IDEDescriptor
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class IDEDescriptor:
49
+ """Immutable descriptor for one IDE in the support matrix."""
50
+
51
+ ide_id: str
52
+ display: str
53
+ mcp_path_global: str # relative to home (empty string = OUT)
54
+ mcp_path_project: str | None # relative to project root; None = no project scope
55
+ server_key: str # top-level key that holds the servers dict
56
+ fmt: str # "json" | "toml" | "yaml" | "" (OUT)
57
+ agents_md_path: str | None # relative to scope root; None = unsupported
58
+ server_block: dict[str, Any] = field(default_factory=dict)
59
+ caveats: str = ""
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # IDE_MATRIX — server_key + fmt VERIFIED vs ide/configs/* templates
64
+ # Paths are [CN-ONLINE] best-effort; confirmed from public docs where possible.
65
+ # ---------------------------------------------------------------------------
66
+
67
+ IDE_MATRIX: dict[str, IDEDescriptor] = {
68
+ # --- JSON IDEs ---
69
+ "cursor": IDEDescriptor(
70
+ ide_id="cursor",
71
+ display="Cursor",
72
+ mcp_path_global=".cursor/mcp.json",
73
+ mcp_path_project=".cursor/mcp.json",
74
+ server_key="mcpServers",
75
+ fmt="json",
76
+ agents_md_path=".cursorrules",
77
+ server_block={"command": "slm", "args": ["mcp"], "type": "stdio"},
78
+ caveats="project .cursor/mcp.json",
79
+ ),
80
+ "antigravity": IDEDescriptor(
81
+ ide_id="antigravity",
82
+ display="Antigravity (agy)",
83
+ mcp_path_global=".antigravity/mcp.json",
84
+ mcp_path_project=None,
85
+ server_key="mcpServers",
86
+ fmt="json",
87
+ agents_md_path=None,
88
+ server_block={"command": "slm", "args": ["mcp"], "type": "stdio"},
89
+ caveats="Vertex auth (R1); path [CN-ONLINE]",
90
+ ),
91
+ "windsurf": IDEDescriptor(
92
+ ide_id="windsurf",
93
+ display="Windsurf",
94
+ mcp_path_global=".codeium/windsurf/mcp_config.json",
95
+ mcp_path_project=None,
96
+ server_key="mcpServers",
97
+ fmt="json",
98
+ agents_md_path=".windsurfrules",
99
+ server_block={"command": "slm", "args": ["mcp"], "type": "stdio"},
100
+ caveats="path [CN-ONLINE]",
101
+ ),
102
+ "gemini-cli": IDEDescriptor(
103
+ ide_id="gemini-cli",
104
+ display="Gemini CLI",
105
+ mcp_path_global=".gemini/settings.json",
106
+ mcp_path_project=None,
107
+ server_key="mcpServers",
108
+ fmt="json",
109
+ agents_md_path="GEMINI.md",
110
+ server_block={"command": "slm", "args": ["mcp"], "type": "stdio"},
111
+ caveats="Google deprecating; path [CN-ONLINE]",
112
+ ),
113
+ "vscode-copilot": IDEDescriptor(
114
+ ide_id="vscode-copilot",
115
+ display="VS Code / Copilot",
116
+ mcp_path_global=".vscode/mcp.json",
117
+ mcp_path_project=".vscode/mcp.json",
118
+ server_key="servers",
119
+ fmt="json",
120
+ agents_md_path=".github/copilot-instructions.md",
121
+ server_block={"type": "stdio", "command": "slm", "args": ["mcp"]},
122
+ caveats="key NOT mcpServers; uses 'servers'",
123
+ ),
124
+ "zed": IDEDescriptor(
125
+ ide_id="zed",
126
+ display="Zed Editor",
127
+ mcp_path_global=".config/zed/settings.json",
128
+ mcp_path_project=None,
129
+ server_key="context_servers",
130
+ fmt="json",
131
+ agents_md_path=None, # no rules surface
132
+ server_block={"source": "custom", "command": "slm", "args": ["mcp"]},
133
+ caveats="no rules surface → AGENTS.md skip",
134
+ ),
135
+ "jetbrains": IDEDescriptor(
136
+ ide_id="jetbrains",
137
+ display="JetBrains IDEs",
138
+ mcp_path_global=".config/JetBrains/mcp.json",
139
+ mcp_path_project=".mcp.json",
140
+ server_key="mcpServers",
141
+ fmt="json",
142
+ agents_md_path=None,
143
+ server_block={"command": "slm", "args": ["mcp"], "type": "stdio"},
144
+ caveats="path per product [CN-ONLINE]",
145
+ ),
146
+ "opencode": IDEDescriptor(
147
+ ide_id="opencode",
148
+ display="OpenCode",
149
+ mcp_path_global=".config/opencode/config.json",
150
+ mcp_path_project=None,
151
+ server_key="mcp",
152
+ fmt="json",
153
+ agents_md_path=None,
154
+ server_block={"command": "slm", "args": ["mcp"]},
155
+ caveats="top-level key is 'mcp'",
156
+ ),
157
+ "claude-desktop": IDEDescriptor(
158
+ ide_id="claude-desktop",
159
+ display="Claude Desktop",
160
+ mcp_path_global=(
161
+ "Library/Application Support/Claude/claude_desktop_config.json"
162
+ if sys.platform == "darwin"
163
+ else ".config/Claude/claude_desktop_config.json"
164
+ ),
165
+ mcp_path_project=None,
166
+ server_key="mcpServers",
167
+ fmt="json",
168
+ agents_md_path=None,
169
+ server_block={"command": "slm", "args": ["mcp"], "type": "stdio"},
170
+ caveats="desktop app (not Claude Code)",
171
+ ),
172
+ # --- TOML IDEs ---
173
+ "codex": IDEDescriptor(
174
+ ide_id="codex",
175
+ display="Codex CLI",
176
+ mcp_path_global=".codex/config.toml",
177
+ mcp_path_project=None,
178
+ server_key="mcp_servers",
179
+ fmt="toml",
180
+ agents_md_path="AGENTS.md",
181
+ server_block={"command": "slm", "args": ["mcp"]},
182
+ caveats="tomllib read / tomli_w write",
183
+ ),
184
+ # --- YAML IDEs ---
185
+ "continue": IDEDescriptor(
186
+ ide_id="continue",
187
+ display="Continue.dev",
188
+ mcp_path_global=".continue/config.yaml",
189
+ mcp_path_project=".continue/config.yaml",
190
+ server_key="contextProviders",
191
+ fmt="yaml",
192
+ agents_md_path=None,
193
+ server_block={
194
+ "name": "mcp",
195
+ "params": {
196
+ "serverName": "superlocalmemory",
197
+ "command": "slm",
198
+ "args": ["mcp"],
199
+ },
200
+ },
201
+ caveats="contextProviders is a LIST; append+dedupe by serverName",
202
+ ),
203
+ # --- OUT: claude-code defers to WP-06 ---
204
+ "claude-code": IDEDescriptor(
205
+ ide_id="claude-code",
206
+ display="Claude Code (WP-06 plugin)",
207
+ mcp_path_global="",
208
+ mcp_path_project=None,
209
+ server_key="",
210
+ fmt="",
211
+ agents_md_path=None,
212
+ server_block={},
213
+ caveats="OUT — WP-06 plugin pointer only; no MCP config written",
214
+ ),
215
+ # --- EXPERIMENTAL (gated, not wired by default) ---
216
+ # chatgpt-desktop, perplexity, cody: gated behind --experimental
217
+ }
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Public API
222
+ # ---------------------------------------------------------------------------
223
+
224
+
225
+ def supported_ides() -> list[str]:
226
+ """Return all ide_ids in the matrix (including claude-code and experimental)."""
227
+ return list(IDE_MATRIX.keys())
228
+
229
+
230
+ def resolve_descriptor(ide_id: str) -> IDEDescriptor | None:
231
+ """Return the IDEDescriptor for ide_id, or None if unknown."""
232
+ return IDE_MATRIX.get(ide_id)
233
+
234
+
235
+ def connect_ide(
236
+ ide_id: str,
237
+ *,
238
+ home: Path | None = None,
239
+ project: Path | None = None,
240
+ here: bool = False,
241
+ profile: str | None = None,
242
+ agents_md_source: Callable[[], str] | None = None,
243
+ ) -> dict[str, Any]:
244
+ """Wire SLM into the target IDE config via merge-not-clobber.
245
+
246
+ Returns a result dict:
247
+ {ide, mcp_config: wrote|merged|unchanged|skipped|error,
248
+ mcp_path, agents_md: wrote|skipped(...)|unchanged|error,
249
+ servers_preserved: int, error: str|None}
250
+ """
251
+ result: dict[str, Any] = {
252
+ "ide": ide_id,
253
+ "mcp_config": "error",
254
+ "mcp_path": "",
255
+ "agents_md": "skipped(not-run)",
256
+ "servers_preserved": 0,
257
+ "error": None,
258
+ }
259
+
260
+ # Step 1 — resolve
261
+ desc = resolve_descriptor(ide_id)
262
+ if desc is None:
263
+ result["error"] = (
264
+ f"Unknown IDE '{ide_id}'. Supported: {', '.join(supported_ides())}"
265
+ )
266
+ return result
267
+
268
+ # Step 1a — claude-code short-circuit (AC6)
269
+ if desc.fmt == "":
270
+ print(CLAUDE_CODE_PLUGIN_POINTER)
271
+ result["mcp_config"] = "skipped"
272
+ result["agents_md"] = "skipped(claude-code-out)"
273
+ return result
274
+
275
+ # Step 2 — scope resolution
276
+ effective_home = home or Path.home()
277
+ if here:
278
+ if project is None:
279
+ result["error"] = "--here requires --project (project root path)"
280
+ return result
281
+ scope_root = project
282
+ rel_path = desc.mcp_path_project or desc.mcp_path_global
283
+ else:
284
+ scope_root = effective_home
285
+ rel_path = desc.mcp_path_global
286
+
287
+ config_path = scope_root / rel_path
288
+ result["mcp_path"] = str(config_path)
289
+
290
+ # Step 3 — load existing config
291
+ try:
292
+ data = _load_config(config_path, desc.fmt)
293
+ except _ParseError as exc:
294
+ result["error"] = str(exc)
295
+ # File is untouched (we never wrote; abort)
296
+ return result
297
+
298
+ # Step 4 — extract server container
299
+ # For continue (yaml list), special-case
300
+ if desc.fmt == "yaml":
301
+ mcp_status, servers_preserved = _merge_yaml_list(
302
+ data, desc, profile
303
+ )
304
+ result["mcp_config"] = mcp_status
305
+ result["servers_preserved"] = servers_preserved
306
+ else:
307
+ servers = data.setdefault(desc.server_key, {})
308
+ pre_count = len(servers)
309
+ pre_slm = copy.deepcopy(servers.get("superlocalmemory"))
310
+
311
+ # Step 5 — merge
312
+ block = copy.deepcopy(desc.server_block)
313
+ if profile:
314
+ block.setdefault("env", {})["SLM_MCP_PROFILE"] = profile
315
+
316
+ servers["superlocalmemory"] = block
317
+
318
+ if servers.get("superlocalmemory") == pre_slm and pre_slm is not None:
319
+ mcp_status = "unchanged"
320
+ elif pre_slm is None:
321
+ mcp_status = "wrote"
322
+ else:
323
+ mcp_status = "merged"
324
+
325
+ result["servers_preserved"] = max(0, pre_count - (0 if pre_slm is None else 1))
326
+ result["mcp_config"] = mcp_status
327
+
328
+ # Step 6 — atomic write
329
+ try:
330
+ _atomic_write(config_path, data, desc.fmt)
331
+ except Exception as exc:
332
+ result["error"] = f"Write failed: {exc}"
333
+ result["mcp_config"] = "error"
334
+ return result
335
+
336
+ # Verify idempotent: if nothing changed, re-read and confirm
337
+ if result["mcp_config"] != "unchanged":
338
+ pass # already wrote
339
+ else:
340
+ pass # already unchanged; atomic write still ran (idempotent)
341
+
342
+ # Step 7 — AGENTS.md
343
+ result["agents_md"] = _handle_agents_md(
344
+ desc, scope_root, agents_md_source, here
345
+ )
346
+
347
+ return result
348
+
349
+
350
+ # ---------------------------------------------------------------------------
351
+ # Internal helpers
352
+ # ---------------------------------------------------------------------------
353
+
354
+
355
+ class _ParseError(Exception):
356
+ """Raised when an existing config file cannot be parsed."""
357
+
358
+
359
+ def _load_config(path: Path, fmt: str) -> dict[str, Any]:
360
+ """Load and parse existing config; return {} if file absent.
361
+
362
+ Raises _ParseError if file exists but is malformed.
363
+ """
364
+ if not path.exists():
365
+ return {}
366
+
367
+ raw = path.read_text(encoding="utf-8")
368
+
369
+ try:
370
+ if fmt == "json":
371
+ return json.loads(raw)
372
+ elif fmt == "toml":
373
+ import tomllib
374
+ return tomllib.loads(raw)
375
+ elif fmt == "yaml":
376
+ import yaml
377
+ parsed = yaml.safe_load(raw)
378
+ # Non-dict result (e.g. bare string) is treated as empty config
379
+ if parsed is None:
380
+ return {}
381
+ if not isinstance(parsed, dict):
382
+ return {}
383
+ return parsed
384
+ else:
385
+ # Unknown format — return empty; caller will fail gracefully
386
+ return {}
387
+ except Exception as exc:
388
+ raise _ParseError(
389
+ f"Config parse error ({fmt}) at {path}: {exc}"
390
+ ) from exc
391
+
392
+
393
+ def _merge_yaml_list(
394
+ data: dict[str, Any],
395
+ desc: IDEDescriptor,
396
+ profile: str | None,
397
+ ) -> tuple[str, int]:
398
+ """Merge SLM entry into a list-style YAML contextProviders (continue.dev).
399
+
400
+ Returns (status, servers_preserved).
401
+ """
402
+ providers: list[dict] = data.setdefault(desc.server_key, [])
403
+ if not isinstance(providers, list):
404
+ providers = []
405
+ data[desc.server_key] = providers
406
+
407
+ pre_count = sum(
408
+ 1 for p in providers
409
+ if p.get("params", {}).get("serverName") != "superlocalmemory"
410
+ )
411
+
412
+ # Check if SLM already present
413
+ existing_idx = None
414
+ for i, p in enumerate(providers):
415
+ if p.get("params", {}).get("serverName") == "superlocalmemory":
416
+ existing_idx = i
417
+ break
418
+
419
+ block = copy.deepcopy(desc.server_block)
420
+ if profile:
421
+ block.setdefault("params", {})["env"] = {"SLM_MCP_PROFILE": profile}
422
+
423
+ if existing_idx is not None:
424
+ if providers[existing_idx] == block:
425
+ return "unchanged", pre_count
426
+ providers[existing_idx] = block
427
+ return "merged", pre_count
428
+ else:
429
+ providers.append(block)
430
+ return "wrote", pre_count
431
+
432
+
433
+ def _atomic_write(path: Path, data: dict[str, Any], fmt: str) -> None:
434
+ """Serialize data and atomically write to path (.tmp + os.replace)."""
435
+ path.parent.mkdir(parents=True, exist_ok=True)
436
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
437
+
438
+ try:
439
+ if fmt == "json":
440
+ content = json.dumps(data, indent=2) + "\n"
441
+ tmp_path.write_text(content, encoding="utf-8")
442
+ elif fmt == "toml":
443
+ import tomli_w
444
+ tmp_path.write_text(tomli_w.dumps(data), encoding="utf-8")
445
+ elif fmt == "yaml":
446
+ import yaml
447
+ tmp_path.write_text(yaml.safe_dump(data, default_flow_style=False))
448
+ else:
449
+ raise ValueError(f"Unknown format: {fmt}")
450
+
451
+ os.replace(tmp_path, path)
452
+ except Exception:
453
+ # Clean up tmp on failure
454
+ if tmp_path.exists():
455
+ tmp_path.unlink()
456
+ raise
457
+
458
+
459
+ def _handle_agents_md(
460
+ desc: IDEDescriptor,
461
+ scope_root: Path,
462
+ agents_md_source: Callable[[], str] | None,
463
+ here: bool,
464
+ ) -> str:
465
+ """Append SLM section to AGENTS.md with <!-- SLM-START/END --> markers.
466
+
467
+ D-1 resolution: only write AGENTS.md in --here (project) scope;
468
+ skip in global scope (don't litter $HOME).
469
+ """
470
+ if desc.agents_md_path is None:
471
+ return "skipped(unsupported)"
472
+
473
+ if agents_md_source is None:
474
+ return "skipped(no-source)"
475
+
476
+ agents_path = scope_root / desc.agents_md_path
477
+
478
+ try:
479
+ source_content = agents_md_source()
480
+ except Exception as exc:
481
+ logger.warning("agents_md_source() failed: %s — skipping AGENTS.md write", exc)
482
+ return "skipped(source-error)"
483
+
484
+ # Read existing content
485
+ existing = ""
486
+ if agents_path.exists():
487
+ existing = agents_path.read_text(encoding="utf-8")
488
+
489
+ # Idempotency check
490
+ if SLM_MARKER_START in existing:
491
+ return "unchanged"
492
+
493
+ # Append SLM section
494
+ section = (
495
+ f"\n{SLM_MARKER_START}\n"
496
+ f"{source_content.strip()}\n"
497
+ f"{SLM_MARKER_END}\n"
498
+ )
499
+ agents_path.parent.mkdir(parents=True, exist_ok=True)
500
+ # Atomic write: agents_path is the user's hand-written rules file
501
+ # (.cursorrules, AGENTS.md, copilot-instructions.md, ...). A crash mid-write
502
+ # must NOT truncate it. tmp in the same dir → os.replace is atomic.
503
+ _tmp = agents_path.with_suffix(agents_path.suffix + ".tmp")
504
+ _tmp.write_text(existing + section, encoding="utf-8")
505
+ os.replace(_tmp, agents_path)
506
+ return "wrote"
@@ -28,10 +28,14 @@ lost (reaper finalizes everything at neutral 0.5).
28
28
  MCP uses this as the default when the tool caller omits
29
29
  ``session_id``.
30
30
 
31
- Concurrency: one reader/writer lock (``fcntl.flock``) serialises
32
- updates. Rollover: entries older than 1 hour are pruned on every
33
- write. Fail-soft: every error path returns empty or the passed
34
- default the learning loop must never crash the hot path.
31
+ Concurrency: each write is atomic via write-temp + ``os.replace`` (atomic on
32
+ POSIX/Windows), so a concurrent reader never sees a half-written file
33
+ last-writer-wins. This is best-effort, not lock-serialised: a concurrent
34
+ read-modify-write may lose an interleaved update, which is acceptable because
35
+ the registry only drives session attribution for closed-loop learning, not
36
+ memory correctness. Rollover: entries older than 1 hour are pruned on every
37
+ write. Fail-soft: every error path returns empty or the passed default — the
38
+ learning loop must never crash the hot path.
35
39
 
36
40
  This is not a perfect correlation channel; two Claude sessions
37
41
  typing in the same second can race. For single-user workstations