superlocalmemory 3.6.13 → 3.6.14

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 (124) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/README.md +187 -741
  3. package/package.json +12 -5
  4. package/plugin/.claude-plugin/plugin.json +20 -0
  5. package/plugin/.mcp.json +12 -0
  6. package/plugin/CLAUDE.md +43 -0
  7. package/plugin/_GENERATED.md +6 -0
  8. package/plugin/agents/slm-memory-advisor.md +43 -0
  9. package/plugin/agents/slm-optimize-advisor.md +38 -0
  10. package/plugin/hooks/hooks.json +14 -0
  11. package/plugin/requirements.txt +1 -0
  12. package/plugin/scripts/ensure-venv.bat +122 -0
  13. package/plugin/scripts/ensure-venv.sh +105 -0
  14. package/plugin/scripts/slm-launch +15 -0
  15. package/plugin/scripts/slm-launch.bat +17 -0
  16. package/plugin/settings.json +16 -0
  17. package/plugin/skills/slm-cache/SKILL.md +140 -0
  18. package/plugin/skills/slm-compress/SKILL.md +143 -0
  19. package/plugin/skills/slm-graph/SKILL.md +300 -0
  20. package/plugin/skills/slm-recall/SKILL.md +196 -0
  21. package/plugin/skills/slm-remember/SKILL.md +182 -0
  22. package/plugin/skills/slm-session/SKILL.md +207 -0
  23. package/plugin/skills/slm-status/SKILL.md +149 -0
  24. package/plugin-src/.mcp.json +12 -0
  25. package/plugin-src/agents/slm-memory-advisor.md +43 -0
  26. package/plugin-src/agents/slm-optimize-advisor.md +38 -0
  27. package/plugin-src/commands/slm-optimize.md +22 -0
  28. package/plugin-src/commands/slm-recall.md +16 -0
  29. package/plugin-src/commands/slm-remember.md +16 -0
  30. package/plugin-src/commands/slm-status.md +15 -0
  31. package/plugin-src/hooks/.gitkeep +0 -0
  32. package/plugin-src/hooks/hooks.json +14 -0
  33. package/plugin-src/manifest.json +25 -0
  34. package/plugin-src/requirements.txt +1 -0
  35. package/plugin-src/rules/AGENTS.md +90 -0
  36. package/plugin-src/rules/CLAUDE.md.fragment +43 -0
  37. package/plugin-src/scripts/ensure-venv.bat +122 -0
  38. package/plugin-src/scripts/ensure-venv.sh +105 -0
  39. package/plugin-src/scripts/slm-launch +15 -0
  40. package/plugin-src/scripts/slm-launch.bat +17 -0
  41. package/plugin-src/settings.json +16 -0
  42. package/plugin-src/skills/slm-cache/SKILL.md +140 -0
  43. package/plugin-src/skills/slm-compress/SKILL.md +143 -0
  44. package/plugin-src/skills/slm-graph/SKILL.md +300 -0
  45. package/plugin-src/skills/slm-recall/SKILL.md +196 -0
  46. package/plugin-src/skills/slm-remember/SKILL.md +182 -0
  47. package/plugin-src/skills/slm-session/SKILL.md +207 -0
  48. package/plugin-src/skills/slm-status/SKILL.md +149 -0
  49. package/pyproject.toml +6 -2
  50. package/scripts/__tests__/build-plugin.test.mjs +613 -0
  51. package/scripts/_savings_math.py +270 -0
  52. package/scripts/build-plugin.js +742 -0
  53. package/scripts/dogfood_savings.py +490 -0
  54. package/scripts/install-skills.ps1 +4 -334
  55. package/scripts/install-skills.sh +4 -435
  56. package/scripts/postinstall-interactive.js +0 -27
  57. package/scripts/postinstall.js +21 -2
  58. package/src/superlocalmemory/__init__.py +1 -1
  59. package/src/superlocalmemory/cli/_lazy_init.py +115 -0
  60. package/src/superlocalmemory/cli/commands.py +348 -39
  61. package/src/superlocalmemory/cli/main.py +47 -4
  62. package/src/superlocalmemory/cli/setup_wizard.py +20 -6
  63. package/src/superlocalmemory/core/config.py +79 -9
  64. package/src/superlocalmemory/core/embeddings.py +10 -5
  65. package/src/superlocalmemory/core/engine.py +2 -2
  66. package/src/superlocalmemory/hooks/claude_code_hooks.py +27 -3
  67. package/src/superlocalmemory/hooks/portable_kit.py +506 -0
  68. package/src/superlocalmemory/infra/cloud_backup.py +99 -23
  69. package/src/superlocalmemory/mcp/cli_fallback.py +602 -0
  70. package/src/superlocalmemory/mcp/server.py +75 -4
  71. package/src/superlocalmemory/mcp/tools_code_graph.py +3 -3
  72. package/src/superlocalmemory/mcp/tools_core.py +12 -4
  73. package/src/superlocalmemory/optimize/cache/boundary_store.py +25 -6
  74. package/src/superlocalmemory/optimize/cache/centroid_store.py +27 -4
  75. package/src/superlocalmemory/optimize/cache/manager.py +92 -6
  76. package/src/superlocalmemory/optimize/cache/semantic.py +20 -1
  77. package/src/superlocalmemory/optimize/compress/ccr.py +12 -0
  78. package/src/superlocalmemory/optimize/compress/router.py +46 -13
  79. package/src/superlocalmemory/optimize/config/schema.py +6 -0
  80. package/src/superlocalmemory/optimize/proxy/_helpers.py +111 -8
  81. package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +14 -4
  82. package/src/superlocalmemory/optimize/proxy/gemini_surface.py +23 -6
  83. package/src/superlocalmemory/optimize/proxy/openai_surface.py +10 -4
  84. package/src/superlocalmemory/optimize/proxy/server.py +11 -0
  85. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +246 -0
  86. package/src/superlocalmemory/optimize/storage/db.py +30 -0
  87. package/src/superlocalmemory/server/recall_serializer.py +3 -1
  88. package/src/superlocalmemory/server/unified_daemon.py +24 -6
  89. package/src/superlocalmemory/ui/css/legacy-dashboard.css +18 -0
  90. package/src/superlocalmemory/ui/css/neural-glass.css +5 -0
  91. package/src/superlocalmemory/ui/index.html +2 -2
  92. package/src/superlocalmemory/ui/js/core.js +98 -0
  93. package/src/superlocalmemory/ui/js/dashboard.js +8 -1
  94. package/src/superlocalmemory/ui/js/ide-status.js +16 -3
  95. package/src/superlocalmemory/ui/js/math-health.js +15 -3
  96. package/src/superlocalmemory/ui/js/optimize.js +18 -2
  97. package/src/superlocalmemory/ui/js/trust-dashboard.js +10 -1
  98. package/src/superlocalmemory.egg-info/PKG-INFO +189 -742
  99. package/src/superlocalmemory.egg-info/SOURCES.txt +6 -9
  100. package/src/superlocalmemory.egg-info/requires.txt +1 -0
  101. package/ide/skills/slm-build-graph/SKILL.md +0 -423
  102. package/ide/skills/slm-list-recent/SKILL.md +0 -348
  103. package/ide/skills/slm-recall/SKILL.md +0 -326
  104. package/ide/skills/slm-remember/SKILL.md +0 -194
  105. package/ide/skills/slm-show-patterns/SKILL.md +0 -224
  106. package/ide/skills/slm-status/SKILL.md +0 -363
  107. package/ide/skills/slm-switch-profile/SKILL.md +0 -442
  108. package/skills/slm-build-graph/SKILL.md +0 -423
  109. package/skills/slm-list-recent/SKILL.md +0 -348
  110. package/skills/slm-optimize/README.md +0 -55
  111. package/skills/slm-optimize/SKILL.md +0 -139
  112. package/skills/slm-recall/SKILL.md +0 -343
  113. package/skills/slm-remember/SKILL.md +0 -194
  114. package/skills/slm-show-patterns/SKILL.md +0 -224
  115. package/skills/slm-status/SKILL.md +0 -363
  116. package/skills/slm-switch-profile/SKILL.md +0 -442
  117. package/src/superlocalmemory/cli/doctor_cmd.py +0 -152
  118. package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +0 -423
  119. package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +0 -348
  120. package/src/superlocalmemory/skills/slm-recall/SKILL.md +0 -343
  121. package/src/superlocalmemory/skills/slm-remember/SKILL.md +0 -194
  122. package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +0 -224
  123. package/src/superlocalmemory/skills/slm-status/SKILL.md +0 -363
  124. 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"
@@ -18,7 +18,9 @@ from __future__ import annotations
18
18
 
19
19
  import json
20
20
  import logging
21
+ import os
21
22
  import sqlite3
23
+ import tempfile
22
24
  from datetime import datetime, UTC, timezone
23
25
  from pathlib import Path
24
26
  from typing import Any
@@ -29,42 +31,103 @@ MEMORY_DIR = Path.home() / ".superlocalmemory"
29
31
  DB_PATH = MEMORY_DIR / "memory.db"
30
32
  KEYRING_SERVICE = "superlocalmemory"
31
33
 
34
+ # Stage-9 fix: hoist NoKeyringError to module scope. Previously each function
35
+ # did `from keyring.errors import NoKeyringError` INSIDE the same try whose
36
+ # `import keyring` could fail — leaving the name unbound when the `except
37
+ # (ImportError, NoKeyringError)` tuple was evaluated, which raised
38
+ # UnboundLocalError on every keyring-free host (headless Linux / minimal
39
+ # Docker) and made the plaintext fallback unreachable. A sentinel subclass is
40
+ # used when keyring is absent so the except-tuple is always valid and simply
41
+ # never matches.
42
+ try:
43
+ from keyring.errors import NoKeyringError as _NoKeyringError
44
+ except Exception: # keyring not installed at all
45
+ class _NoKeyringError(Exception):
46
+ """Sentinel — keyring unavailable; this is never raised."""
47
+
32
48
  # ---------------------------------------------------------------------------
33
49
  # Credential management (OS keychain)
34
50
  # ---------------------------------------------------------------------------
35
51
 
36
52
 
37
53
  def _get_credential_store() -> Path:
38
- """Fallback encrypted credential file for systems without a keychain."""
54
+ """Fallback plaintext-local-file credential store for systems without a keychain."""
39
55
  return MEMORY_DIR / ".credentials.json"
40
56
 
41
57
 
58
+ def _atomic_write_creds(store_path: Path, data: dict) -> None:
59
+ """Write credential data atomically at 0o600 from creation.
60
+
61
+ Uses os.open with O_CREAT|O_WRONLY|O_TRUNC|O_NOFOLLOW and mode 0o600 so
62
+ the file is never world-readable — not even for an instant. The payload
63
+ is written to a temp file in the SAME directory (guaranteeing same device),
64
+ fsynced, and then renamed into place via os.replace() so the old store
65
+ survives any crash between write and rename (atomic on POSIX).
66
+
67
+ Mirrors the pattern used in optimize/proxy/capture.py:111-112.
68
+ """
69
+ parent = store_path.parent
70
+ parent.mkdir(parents=True, exist_ok=True)
71
+ # Ensure the directory itself is private — 0o700 (owner rwx only)
72
+ try:
73
+ os.chmod(parent, 0o700)
74
+ except OSError:
75
+ pass # Best-effort; existing dirs may be fine already
76
+
77
+ payload = json.dumps(data).encode()
78
+
79
+ # Write to a temp file in the SAME directory (same filesystem → atomic rename)
80
+ tmp_fd, tmp_name = tempfile.mkstemp(dir=parent, prefix=".creds-")
81
+ try:
82
+ # Re-open with 0o600; mkstemp already creates with 0o600 on POSIX.
83
+ # Use os.open with O_NOFOLLOW on the temp name to refuse symlink tricks.
84
+ os.close(tmp_fd)
85
+ flags = os.O_WRONLY | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
86
+ fd = os.open(tmp_name, flags, 0o600)
87
+ try:
88
+ os.write(fd, payload)
89
+ os.fsync(fd)
90
+ finally:
91
+ os.close(fd)
92
+ os.replace(tmp_name, store_path)
93
+ except Exception:
94
+ # Clean up the temp file if rename failed; let caller log & re-raise
95
+ try:
96
+ os.unlink(tmp_name)
97
+ except FileNotFoundError:
98
+ pass
99
+ raise
100
+
101
+
42
102
  def _store_credential(key: str, value: str) -> bool:
43
103
  """Store a credential in the OS keychain (macOS/Windows/Linux).
44
104
 
45
- Falls back to an encrypted local file on headless Linux or if
46
- the keyring backend is unavailable.
105
+ Falls back to a plaintext local file (owner read/write only, 0o600) on
106
+ headless Linux or if the keyring backend is unavailable.
47
107
  """
48
108
  # Try OS keychain first (macOS Keychain, Windows Credential Locker, Linux SecretService)
49
109
  try:
50
110
  import keyring
51
- from keyring.errors import NoKeyringError
52
111
  keyring.set_password(KEYRING_SERVICE, key, value)
53
112
  return True
54
- except (ImportError, NoKeyringError):
113
+ except (ImportError, _NoKeyringError):
55
114
  pass # No keyring backend — use fallback
56
115
  except Exception as exc:
57
116
  logger.debug("Keyring store failed, using fallback: %s", exc)
58
117
 
59
- # Fallback: local file with restricted permissions (0600)
118
+ # Fallback: plaintext local file, atomic write at 0o600 from creation
60
119
  try:
61
120
  store_path = _get_credential_store()
62
- existing = {}
121
+ existing: dict = {}
63
122
  if store_path.exists():
64
- existing = json.loads(store_path.read_text())
123
+ try:
124
+ existing = json.loads(store_path.read_text())
125
+ except json.JSONDecodeError as exc:
126
+ logger.warning(
127
+ "Credential store corrupt (JSON decode error), starting fresh: %s", exc
128
+ )
65
129
  existing[key] = value
66
- store_path.write_text(json.dumps(existing))
67
- store_path.chmod(0o600) # Owner read/write only
130
+ _atomic_write_creds(store_path, existing)
68
131
  return True
69
132
  except Exception as exc:
70
133
  logger.warning("Failed to store credential '%s': %s", key, exc)
@@ -76,21 +139,28 @@ def _get_credential(key: str) -> str | None:
76
139
  # Try OS keychain first
77
140
  try:
78
141
  import keyring
79
- from keyring.errors import NoKeyringError
80
142
  val = keyring.get_password(KEYRING_SERVICE, key)
81
143
  if val is not None:
82
144
  return val
83
- except (ImportError, NoKeyringError):
145
+ except (ImportError, _NoKeyringError):
84
146
  pass
85
147
  except Exception:
86
148
  pass
87
149
 
88
- # Fallback: local file
150
+ # Fallback: plaintext local file
89
151
  try:
90
152
  store_path = _get_credential_store()
91
153
  if store_path.exists():
92
- data = json.loads(store_path.read_text())
93
- return data.get(key)
154
+ try:
155
+ data = json.loads(store_path.read_text())
156
+ return data.get(key)
157
+ except json.JSONDecodeError as exc:
158
+ logger.warning(
159
+ "Credential store corrupt (JSON decode error), cannot read key '%s': %s",
160
+ key,
161
+ exc,
162
+ )
163
+ return None
94
164
  except Exception:
95
165
  pass
96
166
 
@@ -103,26 +173,32 @@ def _delete_credential(key: str) -> bool:
103
173
 
104
174
  try:
105
175
  import keyring
106
- from keyring.errors import NoKeyringError
107
176
  keyring.delete_password(KEYRING_SERVICE, key)
108
177
  deleted = True
109
- except (ImportError, NoKeyringError):
178
+ except (ImportError, _NoKeyringError):
110
179
  pass
111
180
  except Exception:
112
181
  pass
113
182
 
114
- # Also clean from fallback
183
+ # Also clean from fallback store — atomic write at 0o600
115
184
  try:
116
185
  store_path = _get_credential_store()
117
186
  if store_path.exists():
118
- data = json.loads(store_path.read_text())
187
+ try:
188
+ data = json.loads(store_path.read_text())
189
+ except json.JSONDecodeError as exc:
190
+ logger.warning(
191
+ "Credential store corrupt (JSON decode error) during delete of '%s': %s",
192
+ key,
193
+ exc,
194
+ )
195
+ return deleted
119
196
  if key in data:
120
197
  del data[key]
121
- store_path.write_text(json.dumps(data))
122
- store_path.chmod(0o600)
198
+ _atomic_write_creds(store_path, data)
123
199
  deleted = True
124
- except Exception:
125
- pass
200
+ except Exception as exc:
201
+ logger.warning("Failed to delete credential '%s' from fallback store: %s", key, exc)
126
202
 
127
203
  return deleted
128
204