sahara-memory 0.2.1__py3-none-any.whl
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.
- sahara/__init__.py +4 -0
- sahara/claude_desktop.py +238 -0
- sahara/cli.py +2658 -0
- sahara/config.py +266 -0
- sahara/cost_estimator.py +5 -0
- sahara/daemon.py +5 -0
- sahara/encryption.py +5 -0
- sahara/file_watcher.py +5 -0
- sahara/ignore_rules.py +5 -0
- sahara/library.py +224 -0
- sahara/mcp_server.py +347 -0
- sahara/models.py +173 -0
- sahara/notifier.py +5 -0
- sahara/s3_client.py +5 -0
- sahara/search/__init__.py +10 -0
- sahara/search/ask_engine.py +212 -0
- sahara/search/search_engine.py +340 -0
- sahara/search_engine.py +5 -0
- sahara/state_db.py +5 -0
- sahara/storage/__init__.py +25 -0
- sahara/storage/backend.py +95 -0
- sahara/storage/cost_estimator.py +271 -0
- sahara/storage/dual_write_backend.py +175 -0
- sahara/storage/lifecycle.py +204 -0
- sahara/storage/local_drive_client.py +321 -0
- sahara/storage/s3_client.py +701 -0
- sahara/storage/state_db.py +1404 -0
- sahara/sync/__init__.py +35 -0
- sahara/sync/daemon.py +553 -0
- sahara/sync/file_watcher.py +204 -0
- sahara/sync/ignore_rules.py +84 -0
- sahara/sync/sync_engine.py +1081 -0
- sahara/sync_engine.py +5 -0
- sahara/utils/__init__.py +37 -0
- sahara/utils/encryption.py +307 -0
- sahara/utils/hash.py +17 -0
- sahara/utils/notifier.py +130 -0
- sahara_memory-0.2.1.dist-info/METADATA +328 -0
- sahara_memory-0.2.1.dist-info/RECORD +42 -0
- sahara_memory-0.2.1.dist-info/WHEEL +4 -0
- sahara_memory-0.2.1.dist-info/entry_points.txt +2 -0
- sahara_memory-0.2.1.dist-info/licenses/LICENSE +21 -0
sahara/__init__.py
ADDED
sahara/claude_desktop.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Claude Desktop MCP configuration helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ClaudeDesktopInstallResult",
|
|
16
|
+
"detect_claude_config_path",
|
|
17
|
+
"install_claude_server",
|
|
18
|
+
"resolve_sahara_executable",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
_CONFIG_FILENAME = "claude_desktop_config.json"
|
|
22
|
+
_WINDOWS_PACKAGE_NAMES = (
|
|
23
|
+
"Claude_pzs8sxrjxfjjc",
|
|
24
|
+
"Anthropic.ClaudeDesktop_h6f0761",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ClaudeDesktopInstallResult:
|
|
30
|
+
"""Result of adding Sahara to Claude Desktop."""
|
|
31
|
+
|
|
32
|
+
config_path: Path
|
|
33
|
+
executable_path: Path
|
|
34
|
+
backup_path: Path | None
|
|
35
|
+
changed: bool
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def detect_claude_config_path(
|
|
39
|
+
*,
|
|
40
|
+
platform: str | None = None,
|
|
41
|
+
environ: Mapping[str, str] | None = None,
|
|
42
|
+
home: Path | None = None,
|
|
43
|
+
) -> Path:
|
|
44
|
+
"""Return the Claude Desktop config path for macOS or Windows."""
|
|
45
|
+
current_platform = platform or sys.platform
|
|
46
|
+
env = os.environ if environ is None else environ
|
|
47
|
+
user_home = home or Path.home()
|
|
48
|
+
|
|
49
|
+
if current_platform == "darwin":
|
|
50
|
+
return (
|
|
51
|
+
user_home
|
|
52
|
+
/ "Library"
|
|
53
|
+
/ "Application Support"
|
|
54
|
+
/ "Claude"
|
|
55
|
+
/ _CONFIG_FILENAME
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if current_platform == "win32":
|
|
59
|
+
msix_path = _detect_windows_msix_config(env)
|
|
60
|
+
if msix_path is not None:
|
|
61
|
+
return msix_path
|
|
62
|
+
|
|
63
|
+
appdata = env.get("APPDATA")
|
|
64
|
+
if not appdata:
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
"APPDATA is not set, so the Claude Desktop config path "
|
|
67
|
+
"cannot be detected."
|
|
68
|
+
)
|
|
69
|
+
return Path(appdata) / "Claude" / _CONFIG_FILENAME
|
|
70
|
+
|
|
71
|
+
raise RuntimeError(
|
|
72
|
+
"Claude Desktop local MCP installation is supported on macOS and Windows."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _detect_windows_msix_config(environ: Mapping[str, str]) -> Path | None:
|
|
77
|
+
local_appdata = environ.get("LOCALAPPDATA")
|
|
78
|
+
if not local_appdata:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
packages_dir = Path(local_appdata) / "Packages"
|
|
82
|
+
if not packages_dir.is_dir():
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
package_dirs: list[Path] = []
|
|
86
|
+
for name in _WINDOWS_PACKAGE_NAMES:
|
|
87
|
+
candidate = packages_dir / name
|
|
88
|
+
if candidate.is_dir():
|
|
89
|
+
package_dirs.append(candidate)
|
|
90
|
+
|
|
91
|
+
known = {path.name.casefold() for path in package_dirs}
|
|
92
|
+
try:
|
|
93
|
+
discovered = sorted(
|
|
94
|
+
(
|
|
95
|
+
path
|
|
96
|
+
for path in packages_dir.iterdir()
|
|
97
|
+
if path.is_dir()
|
|
98
|
+
and "claude" in path.name.casefold()
|
|
99
|
+
and path.name.casefold() not in known
|
|
100
|
+
),
|
|
101
|
+
key=lambda path: path.name.casefold(),
|
|
102
|
+
)
|
|
103
|
+
except OSError:
|
|
104
|
+
discovered = []
|
|
105
|
+
package_dirs.extend(discovered)
|
|
106
|
+
|
|
107
|
+
config_paths = [
|
|
108
|
+
package
|
|
109
|
+
/ "LocalCache"
|
|
110
|
+
/ "Roaming"
|
|
111
|
+
/ "Claude"
|
|
112
|
+
/ _CONFIG_FILENAME
|
|
113
|
+
for package in package_dirs
|
|
114
|
+
]
|
|
115
|
+
for config_path in config_paths:
|
|
116
|
+
if config_path.is_file():
|
|
117
|
+
return config_path
|
|
118
|
+
return config_paths[0] if config_paths else None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def resolve_sahara_executable(
|
|
122
|
+
executable: Path | None = None,
|
|
123
|
+
*,
|
|
124
|
+
argv0: str | None = None,
|
|
125
|
+
) -> Path:
|
|
126
|
+
"""Resolve the executable Claude Desktop should launch."""
|
|
127
|
+
if executable is not None:
|
|
128
|
+
candidate = executable.expanduser().resolve()
|
|
129
|
+
else:
|
|
130
|
+
invoked_as = argv0 or sys.argv[0]
|
|
131
|
+
located = shutil.which(invoked_as) or shutil.which("sahara")
|
|
132
|
+
candidate = Path(located or invoked_as).expanduser().resolve()
|
|
133
|
+
|
|
134
|
+
if not candidate.is_file():
|
|
135
|
+
raise RuntimeError(
|
|
136
|
+
f"Could not find the Sahara executable at {candidate}. "
|
|
137
|
+
"Reinstall Sahara or pass --executable with its absolute path."
|
|
138
|
+
)
|
|
139
|
+
return candidate
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def install_claude_server(
|
|
143
|
+
config_path: Path,
|
|
144
|
+
executable_path: Path,
|
|
145
|
+
*,
|
|
146
|
+
sahara_config_path: Path | None = None,
|
|
147
|
+
) -> ClaudeDesktopInstallResult:
|
|
148
|
+
"""Merge Sahara's stdio MCP server into Claude Desktop configuration."""
|
|
149
|
+
config_path = config_path.expanduser().resolve()
|
|
150
|
+
executable_path = executable_path.expanduser().resolve()
|
|
151
|
+
existing = _read_config(config_path)
|
|
152
|
+
|
|
153
|
+
mcp_servers = existing.get("mcpServers")
|
|
154
|
+
if mcp_servers is None:
|
|
155
|
+
mcp_servers = {}
|
|
156
|
+
existing["mcpServers"] = mcp_servers
|
|
157
|
+
elif not isinstance(mcp_servers, dict):
|
|
158
|
+
raise RuntimeError(
|
|
159
|
+
f"{config_path} has a non-object 'mcpServers' value. "
|
|
160
|
+
"Fix that value before installing Sahara."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
args: list[str] = []
|
|
164
|
+
if sahara_config_path is not None:
|
|
165
|
+
args.extend(
|
|
166
|
+
[
|
|
167
|
+
"--config",
|
|
168
|
+
str(sahara_config_path.expanduser().resolve()),
|
|
169
|
+
]
|
|
170
|
+
)
|
|
171
|
+
args.extend(["mcp", "serve", "--transport", "stdio"])
|
|
172
|
+
|
|
173
|
+
server_config = {
|
|
174
|
+
"command": str(executable_path),
|
|
175
|
+
"args": args,
|
|
176
|
+
}
|
|
177
|
+
changed = mcp_servers.get("sahara") != server_config
|
|
178
|
+
backup_path = None
|
|
179
|
+
if changed:
|
|
180
|
+
mcp_servers["sahara"] = server_config
|
|
181
|
+
if config_path.is_file():
|
|
182
|
+
backup_path = config_path.with_name(
|
|
183
|
+
f"{config_path.name}.sahara-backup"
|
|
184
|
+
)
|
|
185
|
+
shutil.copy2(config_path, backup_path)
|
|
186
|
+
_write_config(config_path, existing)
|
|
187
|
+
|
|
188
|
+
return ClaudeDesktopInstallResult(
|
|
189
|
+
config_path=config_path,
|
|
190
|
+
executable_path=executable_path,
|
|
191
|
+
backup_path=backup_path,
|
|
192
|
+
changed=changed,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _read_config(config_path: Path) -> dict[str, object]:
|
|
197
|
+
if not config_path.exists():
|
|
198
|
+
return {}
|
|
199
|
+
try:
|
|
200
|
+
content = config_path.read_text(encoding="utf-8")
|
|
201
|
+
except OSError as exc:
|
|
202
|
+
raise RuntimeError(f"Could not read {config_path}: {exc}") from exc
|
|
203
|
+
if not content.strip():
|
|
204
|
+
return {}
|
|
205
|
+
try:
|
|
206
|
+
parsed = json.loads(content)
|
|
207
|
+
except json.JSONDecodeError as exc:
|
|
208
|
+
raise RuntimeError(
|
|
209
|
+
f"{config_path} contains invalid JSON at line {exc.lineno}, "
|
|
210
|
+
f"column {exc.colno}. It was not changed."
|
|
211
|
+
) from exc
|
|
212
|
+
if not isinstance(parsed, dict):
|
|
213
|
+
raise RuntimeError(
|
|
214
|
+
f"{config_path} must contain a JSON object. It was not changed."
|
|
215
|
+
)
|
|
216
|
+
return parsed
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _write_config(config_path: Path, config: dict[str, object]) -> None:
|
|
220
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
temporary_path: Path | None = None
|
|
222
|
+
try:
|
|
223
|
+
with tempfile.NamedTemporaryFile(
|
|
224
|
+
"w",
|
|
225
|
+
encoding="utf-8",
|
|
226
|
+
dir=config_path.parent,
|
|
227
|
+
prefix=f".{config_path.name}.",
|
|
228
|
+
suffix=".tmp",
|
|
229
|
+
delete=False,
|
|
230
|
+
) as temporary:
|
|
231
|
+
json.dump(config, temporary, indent=2, ensure_ascii=True)
|
|
232
|
+
temporary.write("\n")
|
|
233
|
+
temporary_path = Path(temporary.name)
|
|
234
|
+
os.replace(temporary_path, config_path)
|
|
235
|
+
except OSError as exc:
|
|
236
|
+
if temporary_path is not None:
|
|
237
|
+
temporary_path.unlink(missing_ok=True)
|
|
238
|
+
raise RuntimeError(f"Could not write {config_path}: {exc}") from exc
|