memorysync-cli 1.0.2__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.
- memorysync_cli/__init__.py +13 -0
- memorysync_cli/__main__.py +9 -0
- memorysync_cli/_version.py +1 -0
- memorysync_cli/args.py +220 -0
- memorysync_cli/commands/__init__.py +6 -0
- memorysync_cli/commands/admin.py +354 -0
- memorysync_cli/commands/init.py +109 -0
- memorysync_cli/commands/memory.py +629 -0
- memorysync_cli/commands/source.py +132 -0
- memorysync_cli/commands/tooling.py +238 -0
- memorysync_cli/completions.py +150 -0
- memorysync_cli/config.py +147 -0
- memorysync_cli/credentials.py +259 -0
- memorysync_cli/errors.py +110 -0
- memorysync_cli/http.py +257 -0
- memorysync_cli/main.py +325 -0
- memorysync_cli/output.py +311 -0
- memorysync_cli/registry.json +612 -0
- memorysync_cli/registry.py +101 -0
- memorysync_cli-1.0.2.dist-info/METADATA +158 -0
- memorysync_cli-1.0.2.dist-info/RECORD +24 -0
- memorysync_cli-1.0.2.dist-info/WHEEL +4 -0
- memorysync_cli-1.0.2.dist-info/entry_points.txt +3 -0
- memorysync_cli-1.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Where the API key lives.
|
|
2
|
+
|
|
3
|
+
Three tiers, in order: the environment, then the OS keychain, then an encrypted
|
|
4
|
+
file readable only by the owner. Same order as the Node CLI, and reached through
|
|
5
|
+
the same OS tools, so a key stored by one CLI is found by the other.
|
|
6
|
+
|
|
7
|
+
Stated plainly, because it affects what a customer should expect:
|
|
8
|
+
|
|
9
|
+
macOS `security` real keychain
|
|
10
|
+
Linux `secret-tool` real keyring, when libsecret is installed
|
|
11
|
+
Windows encrypted file tier 3
|
|
12
|
+
|
|
13
|
+
Windows has no scriptable Credential Manager path that does not require a
|
|
14
|
+
dependency, so it lands on tier 3. That is a genuine difference rather than a
|
|
15
|
+
hedge, and it is the same on the Node CLI.
|
|
16
|
+
|
|
17
|
+
Tier 3 is not security theatre and is not claimed to be. The file is 0600 and the
|
|
18
|
+
contents are obfuscated with a key derived from the machine and user, which stops
|
|
19
|
+
a casual `cat` or a backup scraper. Anyone who can already run code as this user
|
|
20
|
+
can read it. The keychain is better, which is why it is tried first.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import base64
|
|
26
|
+
import getpass
|
|
27
|
+
import hashlib
|
|
28
|
+
import os
|
|
29
|
+
import platform
|
|
30
|
+
import shutil
|
|
31
|
+
import subprocess
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from .config import config_dir
|
|
35
|
+
|
|
36
|
+
SERVICE = "memorysync-cli"
|
|
37
|
+
ENV_VAR = "MEMORYSYNC_API_KEY"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _account(profile: str) -> str:
|
|
41
|
+
return f"{SERVICE}:{profile}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _keyfile() -> Path:
|
|
45
|
+
return config_dir() / "credentials"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _obfuscation_key() -> bytes:
|
|
49
|
+
"""A key derived from the machine and the user.
|
|
50
|
+
|
|
51
|
+
Not a password: there is nobody to prompt. It ties the file to this account so
|
|
52
|
+
that copying it to another machine yields nothing useful, which is the actual
|
|
53
|
+
threat for a CLI credential file.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
user = getpass.getuser()
|
|
57
|
+
except Exception: # noqa: BLE001 - no controlling terminal, or no passwd entry
|
|
58
|
+
user = os.environ.get("USERNAME") or os.environ.get("USER") or "unknown"
|
|
59
|
+
seed = f"{platform.node()}|{user}|{SERVICE}".encode("utf-8")
|
|
60
|
+
return hashlib.sha256(seed).digest()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _xor(data: bytes, key: bytes) -> bytes:
|
|
64
|
+
return bytes(byte ^ key[index % len(key)] for index, byte in enumerate(data))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Tier 2: the OS keychain
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _keychain_tool() -> str | None:
|
|
73
|
+
if platform.system() == "Darwin" and shutil.which("security"):
|
|
74
|
+
return "security"
|
|
75
|
+
if platform.system() == "Linux" and shutil.which("secret-tool"):
|
|
76
|
+
return "secret-tool"
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _keychain_get(profile: str) -> str | None:
|
|
81
|
+
tool = _keychain_tool()
|
|
82
|
+
if tool is None:
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
if tool == "security":
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
["security", "find-generic-password", "-a", _account(profile), "-s", SERVICE, "-w"],
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
timeout=10,
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
result = subprocess.run(
|
|
94
|
+
["secret-tool", "lookup", "service", SERVICE, "account", _account(profile)],
|
|
95
|
+
capture_output=True,
|
|
96
|
+
text=True,
|
|
97
|
+
timeout=10,
|
|
98
|
+
)
|
|
99
|
+
except (OSError, subprocess.SubprocessError):
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
value = (result.stdout or "").strip()
|
|
103
|
+
return value if result.returncode == 0 and value else None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _keychain_set(profile: str, key: str) -> bool:
|
|
107
|
+
tool = _keychain_tool()
|
|
108
|
+
if tool is None:
|
|
109
|
+
return False
|
|
110
|
+
try:
|
|
111
|
+
if tool == "security":
|
|
112
|
+
result = subprocess.run(
|
|
113
|
+
[
|
|
114
|
+
"security",
|
|
115
|
+
"add-generic-password",
|
|
116
|
+
"-a",
|
|
117
|
+
_account(profile),
|
|
118
|
+
"-s",
|
|
119
|
+
SERVICE,
|
|
120
|
+
"-w",
|
|
121
|
+
key,
|
|
122
|
+
# Replace an existing entry instead of failing, so `init
|
|
123
|
+
# --force` behaves the same on the second run.
|
|
124
|
+
"-U",
|
|
125
|
+
],
|
|
126
|
+
capture_output=True,
|
|
127
|
+
text=True,
|
|
128
|
+
timeout=10,
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
result = subprocess.run(
|
|
132
|
+
["secret-tool", "store", "--label", SERVICE, "service", SERVICE, "account", _account(profile)],
|
|
133
|
+
input=key,
|
|
134
|
+
capture_output=True,
|
|
135
|
+
text=True,
|
|
136
|
+
timeout=10,
|
|
137
|
+
)
|
|
138
|
+
return result.returncode == 0
|
|
139
|
+
except (OSError, subprocess.SubprocessError):
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _keychain_delete(profile: str) -> bool:
|
|
144
|
+
tool = _keychain_tool()
|
|
145
|
+
if tool is None:
|
|
146
|
+
return False
|
|
147
|
+
try:
|
|
148
|
+
if tool == "security":
|
|
149
|
+
result = subprocess.run(
|
|
150
|
+
["security", "delete-generic-password", "-a", _account(profile), "-s", SERVICE],
|
|
151
|
+
capture_output=True,
|
|
152
|
+
text=True,
|
|
153
|
+
timeout=10,
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
result = subprocess.run(
|
|
157
|
+
["secret-tool", "clear", "service", SERVICE, "account", _account(profile)],
|
|
158
|
+
capture_output=True,
|
|
159
|
+
text=True,
|
|
160
|
+
timeout=10,
|
|
161
|
+
)
|
|
162
|
+
return result.returncode == 0
|
|
163
|
+
except (OSError, subprocess.SubprocessError):
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Tier 3: an owner-only file
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _file_read(profile: str) -> str | None:
|
|
173
|
+
path = _keyfile()
|
|
174
|
+
try:
|
|
175
|
+
raw = path.read_text(encoding="utf-8")
|
|
176
|
+
except (FileNotFoundError, OSError):
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
for line in raw.splitlines():
|
|
180
|
+
name, _, blob = line.partition("=")
|
|
181
|
+
if name != profile or not blob:
|
|
182
|
+
continue
|
|
183
|
+
try:
|
|
184
|
+
return _xor(base64.b64decode(blob), _obfuscation_key()).decode("utf-8")
|
|
185
|
+
except Exception: # noqa: BLE001 - a corrupt line is a miss, not a crash
|
|
186
|
+
return None
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _file_write(profile: str, key: str | None) -> None:
|
|
191
|
+
path = _keyfile()
|
|
192
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
|
|
194
|
+
entries: dict[str, str] = {}
|
|
195
|
+
try:
|
|
196
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
197
|
+
name, _, blob = line.partition("=")
|
|
198
|
+
if name and blob:
|
|
199
|
+
entries[name] = blob
|
|
200
|
+
except (FileNotFoundError, OSError):
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
if key is None:
|
|
204
|
+
entries.pop(profile, None)
|
|
205
|
+
else:
|
|
206
|
+
entries[profile] = base64.b64encode(_xor(key.encode("utf-8"), _obfuscation_key())).decode("ascii")
|
|
207
|
+
|
|
208
|
+
# Written 0600 before any content lands, so there is no window where the file
|
|
209
|
+
# exists with default permissions.
|
|
210
|
+
descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
211
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
212
|
+
for name, blob in entries.items():
|
|
213
|
+
handle.write(f"{name}={blob}\n")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Public surface
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def read_key(profile: str = "default") -> str | None:
|
|
222
|
+
"""The key for a profile, from the highest-priority source that has one.
|
|
223
|
+
|
|
224
|
+
The environment wins so CI can inject a key without touching a keychain or
|
|
225
|
+
writing a file to a container image.
|
|
226
|
+
"""
|
|
227
|
+
from_env = os.environ.get(ENV_VAR)
|
|
228
|
+
if from_env:
|
|
229
|
+
return from_env.strip()
|
|
230
|
+
return _keychain_get(profile) or _file_read(profile)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def write_key(key: str, profile: str = "default") -> str:
|
|
234
|
+
"""Store a key and report which tier accepted it.
|
|
235
|
+
|
|
236
|
+
The tier is returned rather than logged so `init` can tell the user plainly
|
|
237
|
+
where their key went, instead of implying a keychain that was never reached.
|
|
238
|
+
"""
|
|
239
|
+
if _keychain_set(profile, key):
|
|
240
|
+
return "keychain"
|
|
241
|
+
_file_write(profile, key)
|
|
242
|
+
return "file"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def delete_key(profile: str = "default") -> None:
|
|
246
|
+
_keychain_delete(profile)
|
|
247
|
+
_file_write(profile, None)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def describe_storage() -> str:
|
|
251
|
+
"""Human description of where a key would be stored, for `doctor`."""
|
|
252
|
+
tool = _keychain_tool()
|
|
253
|
+
if tool == "security":
|
|
254
|
+
return "macOS keychain (security)"
|
|
255
|
+
if tool == "secret-tool":
|
|
256
|
+
return "Linux keyring (secret-tool)"
|
|
257
|
+
if platform.system() == "Windows":
|
|
258
|
+
return f"encrypted file, owner-only ({_keyfile()})"
|
|
259
|
+
return f"encrypted file, owner-only ({_keyfile()}) - no keychain tool found"
|
memorysync_cli/errors.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Failure taxonomy and exit codes.
|
|
2
|
+
|
|
3
|
+
The exit codes are a contract. A CI job or an agent loop branches on them, so they
|
|
4
|
+
have to match the Node CLI exactly.
|
|
5
|
+
|
|
6
|
+
They are not retyped here. The numbers come from ``registry.json``, generated from
|
|
7
|
+
the Node CLI's ``EXIT``, because a first draft of this module did retype them and
|
|
8
|
+
got three of them wrong: QUOTA 5, NETWORK 6, NOT_FOUND 4 against the real 4, 5, 6.
|
|
9
|
+
Nothing would have looked broken - every failure still exits non-zero - while a
|
|
10
|
+
script branching on 4 for "over quota" would quietly take that branch on a network
|
|
11
|
+
error instead. Numbers are the easiest thing to transcribe wrongly and the hardest
|
|
12
|
+
to spot, so they are derived.
|
|
13
|
+
|
|
14
|
+
The quota code earns its own value. The API degrades silently at a plan limit,
|
|
15
|
+
returning an empty result rather than an error, because a language model must not
|
|
16
|
+
narrate billing state to an end user. A distinct exit code is therefore the only
|
|
17
|
+
local signal that nothing was stored, so a script does not read a silent skip as
|
|
18
|
+
an empty database.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from .registry import exit_codes
|
|
24
|
+
|
|
25
|
+
_CODES = exit_codes()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Exit:
|
|
29
|
+
"""Process exit codes, mirroring ``EXIT`` in the Node CLI.
|
|
30
|
+
|
|
31
|
+
Read from the generated registry rather than declared, so the two CLIs cannot
|
|
32
|
+
disagree about what 4 means.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
OK: int = _CODES["OK"]
|
|
36
|
+
FAILURE: int = _CODES["FAILURE"]
|
|
37
|
+
USAGE: int = _CODES["USAGE"]
|
|
38
|
+
AUTH: int = _CODES["AUTH"]
|
|
39
|
+
QUOTA: int = _CODES["QUOTA"]
|
|
40
|
+
NETWORK: int = _CODES["NETWORK"]
|
|
41
|
+
NOT_FOUND: int = _CODES["NOT_FOUND"]
|
|
42
|
+
INTERRUPTED: int = _CODES["INTERRUPTED"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CliError(Exception):
|
|
46
|
+
"""An error carrying an exit code, a stable identifier and a next step.
|
|
47
|
+
|
|
48
|
+
``hint`` stays separate from ``message`` so agent mode can emit them as
|
|
49
|
+
distinct JSON fields instead of one blob a model has to split apart.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
message: str,
|
|
55
|
+
*,
|
|
56
|
+
exit_code: int = Exit.FAILURE,
|
|
57
|
+
code: str = "error",
|
|
58
|
+
hint: str | None = None,
|
|
59
|
+
request_id: str | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
super().__init__(message)
|
|
62
|
+
self.message = message
|
|
63
|
+
self.exit_code = exit_code
|
|
64
|
+
self.code = code
|
|
65
|
+
self.hint = hint
|
|
66
|
+
self.request_id = request_id
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict[str, object]:
|
|
69
|
+
payload: dict[str, object] = {"error": self.message, "code": self.code}
|
|
70
|
+
if self.hint:
|
|
71
|
+
payload["hint"] = self.hint
|
|
72
|
+
if self.request_id:
|
|
73
|
+
payload["request_id"] = self.request_id
|
|
74
|
+
return payload
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def usage_error(message: str, hint: str | None = None) -> CliError:
|
|
78
|
+
"""The command line was wrong. Never retryable."""
|
|
79
|
+
return CliError(message, exit_code=Exit.USAGE, code="usage_error", hint=hint)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def auth_error(message: str, hint: str | None = None) -> CliError:
|
|
83
|
+
return CliError(
|
|
84
|
+
message,
|
|
85
|
+
exit_code=Exit.AUTH,
|
|
86
|
+
code="auth_error",
|
|
87
|
+
hint=hint or "Run `memorysync init` to store a key, or set MEMORYSYNC_API_KEY.",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def quota_error(message: str, hint: str | None = None) -> CliError:
|
|
92
|
+
return CliError(
|
|
93
|
+
message,
|
|
94
|
+
exit_code=Exit.QUOTA,
|
|
95
|
+
code="quota_exceeded",
|
|
96
|
+
hint=hint or "Run `memorysync quota` to see usage and when the cycle resets.",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def network_error(message: str, hint: str | None = None) -> CliError:
|
|
101
|
+
return CliError(
|
|
102
|
+
message,
|
|
103
|
+
exit_code=Exit.NETWORK,
|
|
104
|
+
code="network_error",
|
|
105
|
+
hint=hint or "Run `memorysync doctor` to check connectivity.",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def not_found_error(message: str, hint: str | None = None) -> CliError:
|
|
110
|
+
return CliError(message, exit_code=Exit.NOT_FOUND, code="not_found", hint=hint)
|
memorysync_cli/http.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""The API client.
|
|
2
|
+
|
|
3
|
+
Built on ``urllib.request`` so the package keeps zero dependencies. Mem0's Python
|
|
4
|
+
CLI pulls in httpx; every dependency is code we would be putting on a customer's
|
|
5
|
+
machine that they cannot audit on our behalf, which matters more for a closed
|
|
6
|
+
source tool because nobody else is reading our lockfile.
|
|
7
|
+
|
|
8
|
+
The job here is turning HTTP outcomes into the failure taxonomy in ``errors.py``,
|
|
9
|
+
so a caller can branch on cause. Two cases deserve their own handling:
|
|
10
|
+
|
|
11
|
+
* 401/403 become AUTH, with a hint pointing at ``init`` rather than the raw body.
|
|
12
|
+
* A silent quota skip is not an HTTP error at all. Over a plan limit the API
|
|
13
|
+
answers 200 with an empty result, deliberately, so a model never repeats
|
|
14
|
+
billing state to an end user. The client therefore reads usage separately and
|
|
15
|
+
the commands decide what to say; see ``looks_like_silent_skip``.
|
|
16
|
+
|
|
17
|
+
Mirrors ``sdk/cli/src/http.mjs``, including which status maps to which exit code.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.parse
|
|
28
|
+
import urllib.request
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from ._version import __version__
|
|
32
|
+
from .errors import (
|
|
33
|
+
CliError,
|
|
34
|
+
Exit,
|
|
35
|
+
auth_error,
|
|
36
|
+
network_error,
|
|
37
|
+
not_found_error,
|
|
38
|
+
quota_error,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Header the API requires from key-authenticated callers to scope memory.
|
|
42
|
+
END_USER_HEADER = "X-End-User-ID"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ApiClient:
|
|
46
|
+
"""A thin, synchronous client over the MemorySync API."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
base_url: str,
|
|
52
|
+
api_key: str,
|
|
53
|
+
user: str | None = None,
|
|
54
|
+
project: str | None = None,
|
|
55
|
+
timeout: int = 60000,
|
|
56
|
+
verbose: bool = False,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.base_url = base_url.rstrip("/")
|
|
59
|
+
self.api_key = api_key
|
|
60
|
+
self.user = user
|
|
61
|
+
self.project = project
|
|
62
|
+
self.timeout = timeout
|
|
63
|
+
self.verbose = verbose
|
|
64
|
+
|
|
65
|
+
def headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
66
|
+
headers = {
|
|
67
|
+
"X-API-Key": self.api_key,
|
|
68
|
+
"Accept": "application/json",
|
|
69
|
+
# From the package version, never a literal. The Node CLI shipped
|
|
70
|
+
# 1.0.1 announcing itself as 1.0.0 because this was hardcoded.
|
|
71
|
+
"User-Agent": f"memorysync-cli-py/{__version__}",
|
|
72
|
+
}
|
|
73
|
+
# The API rejects key-authenticated memory calls without this, because
|
|
74
|
+
# memory is always scoped to (tenant, project, user) and storing
|
|
75
|
+
# everything under the key owner is not allowed. Commands validate it up
|
|
76
|
+
# front so the failure is a usage error, not a 400 from the server.
|
|
77
|
+
if self.user:
|
|
78
|
+
headers[END_USER_HEADER] = self.user
|
|
79
|
+
if self.project:
|
|
80
|
+
headers["X-Project-ID"] = self.project
|
|
81
|
+
if extra:
|
|
82
|
+
headers.update(extra)
|
|
83
|
+
return headers
|
|
84
|
+
|
|
85
|
+
def request(
|
|
86
|
+
self,
|
|
87
|
+
method: str,
|
|
88
|
+
path: str,
|
|
89
|
+
*,
|
|
90
|
+
body: Any = None,
|
|
91
|
+
query: dict[str, Any] | None = None,
|
|
92
|
+
timeout: int | None = None,
|
|
93
|
+
) -> Any:
|
|
94
|
+
url = self.base_url + path
|
|
95
|
+
if query:
|
|
96
|
+
pairs = {
|
|
97
|
+
key: str(value)
|
|
98
|
+
for key, value in query.items()
|
|
99
|
+
if value is not None and value != ""
|
|
100
|
+
}
|
|
101
|
+
if pairs:
|
|
102
|
+
url = f"{url}?{urllib.parse.urlencode(pairs)}"
|
|
103
|
+
|
|
104
|
+
payload = None
|
|
105
|
+
headers = self.headers()
|
|
106
|
+
if body is not None:
|
|
107
|
+
payload = json.dumps(body).encode("utf-8")
|
|
108
|
+
headers["Content-Type"] = "application/json"
|
|
109
|
+
|
|
110
|
+
limit_ms = timeout or self.timeout
|
|
111
|
+
started = time.monotonic()
|
|
112
|
+
request = urllib.request.Request(url, data=payload, headers=headers, method=method)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
with urllib.request.urlopen(request, timeout=limit_ms / 1000) as response:
|
|
116
|
+
text = response.read().decode("utf-8", errors="replace")
|
|
117
|
+
status = response.status
|
|
118
|
+
except urllib.error.HTTPError as error:
|
|
119
|
+
text = error.read().decode("utf-8", errors="replace")
|
|
120
|
+
status = error.code
|
|
121
|
+
except TimeoutError:
|
|
122
|
+
raise network_error(
|
|
123
|
+
f"Request to {path} timed out after {limit_ms}ms.",
|
|
124
|
+
"Raise it with --timeout, or check your connection with `memorysync doctor`.",
|
|
125
|
+
) from None
|
|
126
|
+
except urllib.error.URLError as error:
|
|
127
|
+
reason = getattr(error, "reason", error)
|
|
128
|
+
if isinstance(reason, TimeoutError):
|
|
129
|
+
raise network_error(
|
|
130
|
+
f"Request to {path} timed out after {limit_ms}ms.",
|
|
131
|
+
"Raise it with --timeout, or check your connection with `memorysync doctor`.",
|
|
132
|
+
) from None
|
|
133
|
+
raise network_error(f"Could not reach {self.base_url}: {reason}") from None
|
|
134
|
+
|
|
135
|
+
if self.verbose:
|
|
136
|
+
elapsed = int((time.monotonic() - started) * 1000)
|
|
137
|
+
print(f"{method} {path} -> {status} in {elapsed}ms", file=sys.stderr)
|
|
138
|
+
|
|
139
|
+
parsed: Any = None
|
|
140
|
+
if text:
|
|
141
|
+
try:
|
|
142
|
+
parsed = json.loads(text)
|
|
143
|
+
except ValueError:
|
|
144
|
+
parsed = {"raw": text}
|
|
145
|
+
|
|
146
|
+
if 200 <= status < 300:
|
|
147
|
+
return parsed
|
|
148
|
+
|
|
149
|
+
raise self._to_error(status, parsed)
|
|
150
|
+
|
|
151
|
+
def _to_error(self, status: int, payload: Any) -> CliError:
|
|
152
|
+
"""Map a failed response to the taxonomy.
|
|
153
|
+
|
|
154
|
+
The status alone is not enough for 429: a plan limit and a rate limit both
|
|
155
|
+
arrive as 429 but mean different things to a script. Only the first is
|
|
156
|
+
about the plan, and only the first is pointless to retry.
|
|
157
|
+
"""
|
|
158
|
+
detail = payload.get("detail") if isinstance(payload, dict) else None
|
|
159
|
+
inner = detail if isinstance(detail, dict) else None
|
|
160
|
+
message = (
|
|
161
|
+
(inner or {}).get("message")
|
|
162
|
+
or (detail if isinstance(detail, str) else None)
|
|
163
|
+
or (payload.get("message") if isinstance(payload, dict) else None)
|
|
164
|
+
or f"Request failed with status {status}."
|
|
165
|
+
)
|
|
166
|
+
code = (inner or {}).get("code")
|
|
167
|
+
|
|
168
|
+
if status in (401, 403):
|
|
169
|
+
return auth_error(message)
|
|
170
|
+
if status == 404:
|
|
171
|
+
return not_found_error(message)
|
|
172
|
+
if status == 429:
|
|
173
|
+
is_plan_limit = code == "limit_exceeded" or bool(
|
|
174
|
+
re.search(r"monthly limit", message, re.IGNORECASE)
|
|
175
|
+
)
|
|
176
|
+
if is_plan_limit:
|
|
177
|
+
return quota_error(message)
|
|
178
|
+
return CliError(
|
|
179
|
+
message,
|
|
180
|
+
exit_code=Exit.NETWORK,
|
|
181
|
+
code="rate_limited",
|
|
182
|
+
hint="Too many requests. Wait and retry.",
|
|
183
|
+
)
|
|
184
|
+
if status >= 500:
|
|
185
|
+
return CliError(
|
|
186
|
+
message,
|
|
187
|
+
exit_code=Exit.NETWORK,
|
|
188
|
+
code="server_error",
|
|
189
|
+
hint="The API failed. Retry, and check status if it persists.",
|
|
190
|
+
)
|
|
191
|
+
return CliError(message, exit_code=Exit.FAILURE, code=code or "api_error")
|
|
192
|
+
|
|
193
|
+
# -----------------------------------------------------------------------
|
|
194
|
+
# Named endpoints, so commands never carry raw paths
|
|
195
|
+
# -----------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def add_memory(self, body: dict) -> Any:
|
|
198
|
+
return self.request("POST", "/memory/add", body=body)
|
|
199
|
+
|
|
200
|
+
def query_memory(self, body: dict) -> Any:
|
|
201
|
+
return self.request("POST", "/memory/query", body=body)
|
|
202
|
+
|
|
203
|
+
def list_memories(self, tenant_id: str, user_id: str, limit: int | None = None) -> Any:
|
|
204
|
+
tenant = urllib.parse.quote(str(tenant_id), safe="")
|
|
205
|
+
user = urllib.parse.quote(str(user_id), safe="")
|
|
206
|
+
return self.request(
|
|
207
|
+
"GET",
|
|
208
|
+
f"/v1/memory/{tenant}/{user}/list",
|
|
209
|
+
query={"limit": limit} if limit else None,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def memory_status(self, numeric_id: str) -> Any:
|
|
213
|
+
return self.request("GET", f"/v1/memory/status/{urllib.parse.quote(str(numeric_id), safe='')}")
|
|
214
|
+
|
|
215
|
+
def forget(self, body: dict) -> Any:
|
|
216
|
+
return self.request("DELETE", "/memory/forget", body=body)
|
|
217
|
+
|
|
218
|
+
# There is deliberately no purge helper here.
|
|
219
|
+
#
|
|
220
|
+
# DELETE /memory/user/purge sits under /memory/ and reads as if it clears one
|
|
221
|
+
# end user's memories. It does neither: it ignores the end-user header and
|
|
222
|
+
# erases the account behind the credential, cascading to the password hash,
|
|
223
|
+
# every API key, memberships, auth providers and MFA. The Node CLI exposed it
|
|
224
|
+
# as purgeUser() and `delete --all` called it, which deleted a live account
|
|
225
|
+
# during a cleanup step.
|
|
226
|
+
#
|
|
227
|
+
# The CLI has no feature that needs account erasure, so the method is absent
|
|
228
|
+
# rather than merely unused - an available helper with a memory-shaped name is
|
|
229
|
+
# what caused the incident. Clearing a scope goes through forget() above.
|
|
230
|
+
|
|
231
|
+
def projects(self) -> Any:
|
|
232
|
+
return self.request("GET", "/org/projects")
|
|
233
|
+
|
|
234
|
+
def usage_summary(self) -> Any:
|
|
235
|
+
return self.request("GET", "/org/billing/usage-summary")
|
|
236
|
+
|
|
237
|
+
def current_plan(self) -> Any:
|
|
238
|
+
return self.request("GET", "/org/billing/current-plan")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def looks_like_silent_skip(payload: Any) -> bool:
|
|
242
|
+
"""Whether a 200 response is really a quota refusal.
|
|
243
|
+
|
|
244
|
+
The API degrades silently at a plan limit: `add` returns success having stored
|
|
245
|
+
nothing and `search` returns an empty list, on purpose, so an assistant never
|
|
246
|
+
narrates billing state to an end user. That design makes a refusal
|
|
247
|
+
indistinguishable from a genuinely empty result unless usage is read
|
|
248
|
+
separately, which is why the commands check rather than the client guessing.
|
|
249
|
+
"""
|
|
250
|
+
if not isinstance(payload, dict):
|
|
251
|
+
return False
|
|
252
|
+
if payload.get("status") == "skipped":
|
|
253
|
+
return True
|
|
254
|
+
memory_ids = payload.get("memory_ids")
|
|
255
|
+
if isinstance(memory_ids, list) and not memory_ids and "candidates_extracted" in payload:
|
|
256
|
+
return True
|
|
257
|
+
return False
|