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.
- package/.claude-plugin/marketplace.json +17 -0
- package/README.md +187 -741
- package/package.json +12 -5
- package/plugin/.claude-plugin/plugin.json +20 -0
- package/plugin/.mcp.json +12 -0
- package/plugin/CLAUDE.md +43 -0
- package/plugin/_GENERATED.md +6 -0
- package/plugin/agents/slm-memory-advisor.md +43 -0
- package/plugin/agents/slm-optimize-advisor.md +38 -0
- package/plugin/hooks/hooks.json +14 -0
- package/plugin/requirements.txt +1 -0
- package/plugin/scripts/ensure-venv.bat +122 -0
- package/plugin/scripts/ensure-venv.sh +105 -0
- package/plugin/scripts/slm-launch +15 -0
- package/plugin/scripts/slm-launch.bat +17 -0
- package/plugin/settings.json +16 -0
- package/plugin/skills/slm-cache/SKILL.md +140 -0
- package/plugin/skills/slm-compress/SKILL.md +143 -0
- package/plugin/skills/slm-graph/SKILL.md +300 -0
- package/plugin/skills/slm-recall/SKILL.md +196 -0
- package/plugin/skills/slm-remember/SKILL.md +182 -0
- package/plugin/skills/slm-session/SKILL.md +207 -0
- package/plugin/skills/slm-status/SKILL.md +149 -0
- package/plugin-src/.mcp.json +12 -0
- package/plugin-src/agents/slm-memory-advisor.md +43 -0
- package/plugin-src/agents/slm-optimize-advisor.md +38 -0
- package/plugin-src/commands/slm-optimize.md +22 -0
- package/plugin-src/commands/slm-recall.md +16 -0
- package/plugin-src/commands/slm-remember.md +16 -0
- package/plugin-src/commands/slm-status.md +15 -0
- package/plugin-src/hooks/.gitkeep +0 -0
- package/plugin-src/hooks/hooks.json +14 -0
- package/plugin-src/manifest.json +25 -0
- package/plugin-src/requirements.txt +1 -0
- package/plugin-src/rules/AGENTS.md +90 -0
- package/plugin-src/rules/CLAUDE.md.fragment +43 -0
- package/plugin-src/scripts/ensure-venv.bat +122 -0
- package/plugin-src/scripts/ensure-venv.sh +105 -0
- package/plugin-src/scripts/slm-launch +15 -0
- package/plugin-src/scripts/slm-launch.bat +17 -0
- package/plugin-src/settings.json +16 -0
- package/plugin-src/skills/slm-cache/SKILL.md +140 -0
- package/plugin-src/skills/slm-compress/SKILL.md +143 -0
- package/plugin-src/skills/slm-graph/SKILL.md +300 -0
- package/plugin-src/skills/slm-recall/SKILL.md +196 -0
- package/plugin-src/skills/slm-remember/SKILL.md +182 -0
- package/plugin-src/skills/slm-session/SKILL.md +207 -0
- package/plugin-src/skills/slm-status/SKILL.md +149 -0
- package/pyproject.toml +6 -2
- package/scripts/__tests__/build-plugin.test.mjs +613 -0
- package/scripts/_savings_math.py +270 -0
- package/scripts/build-plugin.js +742 -0
- package/scripts/dogfood_savings.py +490 -0
- package/scripts/install-skills.ps1 +4 -334
- package/scripts/install-skills.sh +4 -435
- package/scripts/postinstall-interactive.js +0 -27
- package/scripts/postinstall.js +21 -2
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/_lazy_init.py +115 -0
- package/src/superlocalmemory/cli/commands.py +348 -39
- package/src/superlocalmemory/cli/main.py +47 -4
- package/src/superlocalmemory/cli/setup_wizard.py +20 -6
- package/src/superlocalmemory/core/config.py +79 -9
- package/src/superlocalmemory/core/embeddings.py +10 -5
- package/src/superlocalmemory/core/engine.py +2 -2
- package/src/superlocalmemory/hooks/claude_code_hooks.py +27 -3
- package/src/superlocalmemory/hooks/portable_kit.py +506 -0
- package/src/superlocalmemory/infra/cloud_backup.py +99 -23
- package/src/superlocalmemory/mcp/cli_fallback.py +602 -0
- package/src/superlocalmemory/mcp/server.py +75 -4
- package/src/superlocalmemory/mcp/tools_code_graph.py +3 -3
- package/src/superlocalmemory/mcp/tools_core.py +12 -4
- package/src/superlocalmemory/optimize/cache/boundary_store.py +25 -6
- package/src/superlocalmemory/optimize/cache/centroid_store.py +27 -4
- package/src/superlocalmemory/optimize/cache/manager.py +92 -6
- package/src/superlocalmemory/optimize/cache/semantic.py +20 -1
- package/src/superlocalmemory/optimize/compress/ccr.py +12 -0
- package/src/superlocalmemory/optimize/compress/router.py +46 -13
- package/src/superlocalmemory/optimize/config/schema.py +6 -0
- package/src/superlocalmemory/optimize/proxy/_helpers.py +111 -8
- package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +14 -4
- package/src/superlocalmemory/optimize/proxy/gemini_surface.py +23 -6
- package/src/superlocalmemory/optimize/proxy/openai_surface.py +10 -4
- package/src/superlocalmemory/optimize/proxy/server.py +11 -0
- package/src/superlocalmemory/optimize/proxy/vertex_surface.py +246 -0
- package/src/superlocalmemory/optimize/storage/db.py +30 -0
- package/src/superlocalmemory/server/recall_serializer.py +3 -1
- package/src/superlocalmemory/server/unified_daemon.py +24 -6
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +18 -0
- package/src/superlocalmemory/ui/css/neural-glass.css +5 -0
- package/src/superlocalmemory/ui/index.html +2 -2
- package/src/superlocalmemory/ui/js/core.js +98 -0
- package/src/superlocalmemory/ui/js/dashboard.js +8 -1
- package/src/superlocalmemory/ui/js/ide-status.js +16 -3
- package/src/superlocalmemory/ui/js/math-health.js +15 -3
- package/src/superlocalmemory/ui/js/optimize.js +18 -2
- package/src/superlocalmemory/ui/js/trust-dashboard.js +10 -1
- package/src/superlocalmemory.egg-info/PKG-INFO +189 -742
- package/src/superlocalmemory.egg-info/SOURCES.txt +6 -9
- package/src/superlocalmemory.egg-info/requires.txt +1 -0
- package/ide/skills/slm-build-graph/SKILL.md +0 -423
- package/ide/skills/slm-list-recent/SKILL.md +0 -348
- package/ide/skills/slm-recall/SKILL.md +0 -326
- package/ide/skills/slm-remember/SKILL.md +0 -194
- package/ide/skills/slm-show-patterns/SKILL.md +0 -224
- package/ide/skills/slm-status/SKILL.md +0 -363
- package/ide/skills/slm-switch-profile/SKILL.md +0 -442
- package/skills/slm-build-graph/SKILL.md +0 -423
- package/skills/slm-list-recent/SKILL.md +0 -348
- package/skills/slm-optimize/README.md +0 -55
- package/skills/slm-optimize/SKILL.md +0 -139
- package/skills/slm-recall/SKILL.md +0 -343
- package/skills/slm-remember/SKILL.md +0 -194
- package/skills/slm-show-patterns/SKILL.md +0 -224
- package/skills/slm-status/SKILL.md +0 -363
- package/skills/slm-switch-profile/SKILL.md +0 -442
- package/src/superlocalmemory/cli/doctor_cmd.py +0 -152
- package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +0 -423
- package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +0 -348
- package/src/superlocalmemory/skills/slm-recall/SKILL.md +0 -343
- package/src/superlocalmemory/skills/slm-remember/SKILL.md +0 -194
- package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +0 -224
- package/src/superlocalmemory/skills/slm-status/SKILL.md +0 -363
- 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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
93
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
122
|
-
store_path.chmod(0o600)
|
|
198
|
+
_atomic_write_creds(store_path, data)
|
|
123
199
|
deleted = True
|
|
124
|
-
except Exception:
|
|
125
|
-
|
|
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
|
|