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,629 @@
|
|
|
1
|
+
"""The memory commands: add, search, list, get, update, delete, import, export.
|
|
2
|
+
|
|
3
|
+
Each returns ``{"data": ..., "text": callable}`` - structured output plus a lazy
|
|
4
|
+
human renderer - so ``main`` decides the format and these never print. That is
|
|
5
|
+
what lets every command support every output format, which Mem0's do not.
|
|
6
|
+
|
|
7
|
+
Mirrors ``sdk/cli/src/commands/memory.mjs``, including the two places where the
|
|
8
|
+
API's silent degradation has to be turned back into something a terminal user can
|
|
9
|
+
act on: over a plan limit ``add`` returns 200 having stored nothing and ``search``
|
|
10
|
+
returns an empty list, on purpose, so an assistant never narrates billing state to
|
|
11
|
+
an end user. From a shell that silence is unhelpful, so usage is checked and the
|
|
12
|
+
result becomes a distinct exit code.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ..errors import quota_error, usage_error
|
|
24
|
+
from ..http import ApiClient
|
|
25
|
+
from ..output import render_table, style
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _require_user(ctx: dict) -> str:
|
|
29
|
+
user = (ctx.get("settings") or {}).get("user")
|
|
30
|
+
if not user:
|
|
31
|
+
raise usage_error(
|
|
32
|
+
"This command needs an end user.",
|
|
33
|
+
"Pass --user <id>, or set a default with `memorysync init --user <id>`. "
|
|
34
|
+
"Memory is always scoped to a user, so there is no sensible default.",
|
|
35
|
+
)
|
|
36
|
+
return str(user)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _numeric_id(value: Any, label: str = "memory id") -> str:
|
|
40
|
+
"""Accept ``m_123`` or ``123``; the API's numeric routes need the digits."""
|
|
41
|
+
text = str(value or "").strip()
|
|
42
|
+
body = text[2:] if text.startswith("m_") else text
|
|
43
|
+
if not body.isdigit():
|
|
44
|
+
raise usage_error(f'"{text}" is not a {label}.', "Expected something like m_60632.")
|
|
45
|
+
return body
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_json_flag(raw: Any, label: str) -> dict | None:
|
|
49
|
+
if raw is None or raw is False:
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
parsed = json.loads(raw)
|
|
53
|
+
except ValueError as error:
|
|
54
|
+
raise usage_error(f"--{label} is not valid JSON: {error}") from None
|
|
55
|
+
if not isinstance(parsed, dict):
|
|
56
|
+
raise usage_error(f"--{label} must be a JSON object.")
|
|
57
|
+
return parsed
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_limit(value: Any, fallback: int, maximum: int) -> int:
|
|
61
|
+
if value is None or value is False:
|
|
62
|
+
return fallback
|
|
63
|
+
try:
|
|
64
|
+
parsed = int(str(value))
|
|
65
|
+
except ValueError:
|
|
66
|
+
raise usage_error(f"--limit must be a whole number between 1 and {maximum}.") from None
|
|
67
|
+
if parsed < 1 or parsed > maximum:
|
|
68
|
+
raise usage_error(f"--limit must be a whole number between 1 and {maximum}.")
|
|
69
|
+
return parsed
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _read_stdin() -> str | None:
|
|
73
|
+
"""Text piped in, or None when stdin is a terminal.
|
|
74
|
+
|
|
75
|
+
Checked with isatty first: reading unconditionally would hang waiting for
|
|
76
|
+
input that is never coming, which is worse than a usage error.
|
|
77
|
+
"""
|
|
78
|
+
if sys.stdin is None or sys.stdin.isatty():
|
|
79
|
+
return None
|
|
80
|
+
data = sys.stdin.read().strip()
|
|
81
|
+
return data or None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _normalize_memory(raw: Any) -> dict:
|
|
85
|
+
"""One output shape regardless of which route served the record.
|
|
86
|
+
|
|
87
|
+
The API returns several shapes across routes; normalising here is what makes
|
|
88
|
+
``-o json`` stable and comparable between the two CLIs.
|
|
89
|
+
"""
|
|
90
|
+
if not isinstance(raw, dict):
|
|
91
|
+
return {"id": None, "text": None}
|
|
92
|
+
|
|
93
|
+
identifier = raw.get("memory_id")
|
|
94
|
+
if identifier is None and raw.get("id") is not None:
|
|
95
|
+
identifier = f"m_{raw['id']}"
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
"id": identifier,
|
|
99
|
+
"text": raw.get("text") or raw.get("raw_text") or raw.get("content"),
|
|
100
|
+
"score": raw.get("score"),
|
|
101
|
+
"source": raw.get("source"),
|
|
102
|
+
"tags": raw.get("tags"),
|
|
103
|
+
"metadata": raw.get("metadata"),
|
|
104
|
+
"created_at": raw.get("created_at"),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _usage_used_up(api: ApiClient, metric: str) -> bool:
|
|
109
|
+
"""Whether the named metric has no headroom left.
|
|
110
|
+
|
|
111
|
+
Called only when a response was empty, because it costs a request and an empty
|
|
112
|
+
result is usually just an empty result.
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
usage = api.usage_summary()
|
|
116
|
+
except Exception: # noqa: BLE001 - a failed usage read must not mask the result
|
|
117
|
+
return False
|
|
118
|
+
if not isinstance(usage, dict):
|
|
119
|
+
return False
|
|
120
|
+
for entry in usage.get("metrics") or []:
|
|
121
|
+
if not isinstance(entry, dict) or entry.get("metric") != metric:
|
|
122
|
+
continue
|
|
123
|
+
limit = entry.get("limit")
|
|
124
|
+
used = entry.get("used")
|
|
125
|
+
if isinstance(limit, (int, float)) and isinstance(used, (int, float)):
|
|
126
|
+
return limit > 0 and used >= limit
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _resolve_tenant(api: ApiClient) -> str:
|
|
131
|
+
"""The tenant id, which the v1 list route needs in its path.
|
|
132
|
+
|
|
133
|
+
Derived from the project listing rather than asked for: a user should not have
|
|
134
|
+
to know their internal tenant id to list their own memories.
|
|
135
|
+
"""
|
|
136
|
+
projects = api.projects()
|
|
137
|
+
first = projects[0] if isinstance(projects, list) and projects else None
|
|
138
|
+
if isinstance(projects, dict):
|
|
139
|
+
entries = projects.get("projects") or []
|
|
140
|
+
first = entries[0] if entries else None
|
|
141
|
+
if not isinstance(first, dict) or not first.get("tenant_id"):
|
|
142
|
+
from ..errors import not_found_error
|
|
143
|
+
|
|
144
|
+
raise not_found_error(
|
|
145
|
+
"Could not determine the tenant for this key.",
|
|
146
|
+
"Run `memorysync doctor` to check the credential.",
|
|
147
|
+
)
|
|
148
|
+
return str(first["tenant_id"])
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# add
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def add(ctx: dict) -> dict:
|
|
157
|
+
user = _require_user(ctx)
|
|
158
|
+
flags, positionals, api = ctx["flags"], ctx["positionals"], ctx["api"]
|
|
159
|
+
|
|
160
|
+
text = " ".join(positionals).strip() or None
|
|
161
|
+
if flags.get("file"):
|
|
162
|
+
try:
|
|
163
|
+
text = Path(flags["file"]).read_text(encoding="utf-8").strip()
|
|
164
|
+
except OSError as error:
|
|
165
|
+
raise usage_error(f"Could not read {flags['file']}: {error}") from None
|
|
166
|
+
if not text:
|
|
167
|
+
text = _read_stdin()
|
|
168
|
+
if not text:
|
|
169
|
+
raise usage_error(
|
|
170
|
+
"Nothing to store.",
|
|
171
|
+
"Give text as an argument, use --file, or pipe it in.",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
metadata = _parse_json_flag(flags.get("metadata"), "metadata")
|
|
175
|
+
body: dict[str, Any] = {"text": text, "source": flags.get("source") or "cli"}
|
|
176
|
+
if metadata:
|
|
177
|
+
body["metadata"] = metadata
|
|
178
|
+
if flags.get("dedupe") is False:
|
|
179
|
+
body["deduplicate"] = False
|
|
180
|
+
|
|
181
|
+
if flags.get("dry_run"):
|
|
182
|
+
data = {
|
|
183
|
+
"would_store": text,
|
|
184
|
+
"source": body["source"],
|
|
185
|
+
"metadata": metadata,
|
|
186
|
+
"user": user,
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
"data": data,
|
|
190
|
+
"text": lambda: "\n".join(
|
|
191
|
+
[
|
|
192
|
+
style.yellow("Dry run. Nothing was stored."),
|
|
193
|
+
f"{style.dim('user ')} {user}",
|
|
194
|
+
f"{style.dim('source ')} {body['source']}",
|
|
195
|
+
f"{style.dim('text ')} {text}",
|
|
196
|
+
]
|
|
197
|
+
),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
created = api.add_memory(body)
|
|
201
|
+
memory = _normalize_memory(created)
|
|
202
|
+
|
|
203
|
+
if not memory["id"] and _usage_used_up(api, "add_requests"):
|
|
204
|
+
raise quota_error("Your plan's add limit is used up, so nothing was stored.")
|
|
205
|
+
|
|
206
|
+
status = "created"
|
|
207
|
+
if flags.get("wait") and memory["id"]:
|
|
208
|
+
settled = _wait_for_searchable(api, memory["id"], ctx["settings"]["timeout"])
|
|
209
|
+
status = (
|
|
210
|
+
"searchable"
|
|
211
|
+
if settled.get("searchable")
|
|
212
|
+
else settled.get("processing_status") or "processing"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
payload = {**memory, "status": status}
|
|
216
|
+
return {
|
|
217
|
+
"data": payload,
|
|
218
|
+
"text": lambda: "\n".join(
|
|
219
|
+
line
|
|
220
|
+
for line in [
|
|
221
|
+
f"{style.green('Stored')} {style.bold(memory['id'] or '(no id)')}",
|
|
222
|
+
f"{style.dim('extracted')} {memory['text']}" if memory["text"] else None,
|
|
223
|
+
f"{style.dim('state ')} {status}" if flags.get("wait") else None,
|
|
224
|
+
]
|
|
225
|
+
if line
|
|
226
|
+
),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _wait_for_searchable(api: ApiClient, memory_id: str, timeout_ms: int) -> dict:
|
|
231
|
+
"""Poll ingestion until the memory is searchable or time runs out.
|
|
232
|
+
|
|
233
|
+
Capped at two minutes regardless of --timeout, because a longer wait is almost
|
|
234
|
+
always a stuck job rather than a slow one.
|
|
235
|
+
"""
|
|
236
|
+
deadline = time.monotonic() + min(timeout_ms, 120000) / 1000
|
|
237
|
+
last: dict = {}
|
|
238
|
+
while time.monotonic() < deadline:
|
|
239
|
+
try:
|
|
240
|
+
last = api.memory_status(_numeric_id(memory_id)) or {}
|
|
241
|
+
except Exception: # noqa: BLE001 - keep polling through a transient failure
|
|
242
|
+
last = {}
|
|
243
|
+
if last.get("searchable"):
|
|
244
|
+
return last
|
|
245
|
+
if last.get("processing_status") == "failed":
|
|
246
|
+
return last
|
|
247
|
+
time.sleep(1.5)
|
|
248
|
+
return last or {"searchable": False, "processing_status": "timeout"}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
# search
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def search(ctx: dict) -> dict:
|
|
257
|
+
_require_user(ctx)
|
|
258
|
+
flags, positionals, api = ctx["flags"], ctx["positionals"], ctx["api"]
|
|
259
|
+
|
|
260
|
+
query = " ".join(positionals).strip()
|
|
261
|
+
if not query:
|
|
262
|
+
raise usage_error("Nothing to search for.", 'memorysync search "what to look for"')
|
|
263
|
+
|
|
264
|
+
limit = _parse_limit(flags.get("limit"), 5, 50)
|
|
265
|
+
body: dict[str, Any] = {"query": query, "k": limit}
|
|
266
|
+
if flags.get("rerank") is False:
|
|
267
|
+
body["rerank"] = False
|
|
268
|
+
|
|
269
|
+
response = api.query_memory(body) or {}
|
|
270
|
+
memories = [_normalize_memory(item) for item in (response.get("memories") or [])]
|
|
271
|
+
|
|
272
|
+
if not memories and _usage_used_up(api, "retrieval_requests"):
|
|
273
|
+
raise quota_error(
|
|
274
|
+
"Your plan's retrieval limit is used up, so no results were returned."
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if flags.get("context"):
|
|
278
|
+
data: Any = {"context": response.get("context") or "", "count": len(memories)}
|
|
279
|
+
else:
|
|
280
|
+
data = memories
|
|
281
|
+
|
|
282
|
+
def render() -> str:
|
|
283
|
+
if flags.get("context"):
|
|
284
|
+
return response.get("context") or style.dim("(no context assembled)")
|
|
285
|
+
if not memories:
|
|
286
|
+
return style.dim(f'No memories matched "{query}".')
|
|
287
|
+
rows = [
|
|
288
|
+
[
|
|
289
|
+
item["id"] or "-",
|
|
290
|
+
"-" if item.get("score") is None else f"{float(item['score']):.3f}",
|
|
291
|
+
item.get("text") or "",
|
|
292
|
+
]
|
|
293
|
+
for item in memories
|
|
294
|
+
]
|
|
295
|
+
table = render_table(["id", "score", "memory"], rows, [14, 7, 78])
|
|
296
|
+
latency = response.get("latency_ms")
|
|
297
|
+
suffix = f" in {round(latency)}ms" if latency else ""
|
|
298
|
+
plural = "" if len(memories) == 1 else "s"
|
|
299
|
+
return f"{table}\n{style.dim(f'{len(memories)} result{plural}{suffix}')}"
|
|
300
|
+
|
|
301
|
+
return {"data": data, "text": render}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
# list, get
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def list_memories(ctx: dict) -> dict:
|
|
310
|
+
user = _require_user(ctx)
|
|
311
|
+
flags, api = ctx["flags"], ctx["api"]
|
|
312
|
+
limit = _parse_limit(flags.get("limit"), 20, 1000)
|
|
313
|
+
|
|
314
|
+
tenant = _resolve_tenant(api)
|
|
315
|
+
response = api.list_memories(tenant, user, limit) or {}
|
|
316
|
+
memories = [_normalize_memory(item) for item in (response.get("memories") or [])]
|
|
317
|
+
|
|
318
|
+
def render() -> str:
|
|
319
|
+
if not memories:
|
|
320
|
+
return style.dim(f"No memories stored for {user}.")
|
|
321
|
+
rows = [
|
|
322
|
+
[
|
|
323
|
+
item["id"] or "-",
|
|
324
|
+
(item.get("created_at") or "")[:10],
|
|
325
|
+
item.get("source") or "-",
|
|
326
|
+
item.get("text") or "",
|
|
327
|
+
]
|
|
328
|
+
for item in memories
|
|
329
|
+
]
|
|
330
|
+
table = render_table(["id", "created", "source", "memory"], rows, [14, 10, 12, 66])
|
|
331
|
+
total = response.get("total", len(memories))
|
|
332
|
+
return f"{table}\n{style.dim(f'{len(memories)} of {total}')}"
|
|
333
|
+
|
|
334
|
+
return {"data": memories, "text": render}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def get(ctx: dict) -> dict:
|
|
338
|
+
_require_user(ctx)
|
|
339
|
+
positionals, api = ctx["positionals"], ctx["api"]
|
|
340
|
+
if not positionals:
|
|
341
|
+
raise usage_error("Which memory?", "memorysync get m_60632")
|
|
342
|
+
|
|
343
|
+
status = api.memory_status(_numeric_id(positionals[0])) or {}
|
|
344
|
+
|
|
345
|
+
def render() -> str:
|
|
346
|
+
return "\n".join(
|
|
347
|
+
f"{style.dim(key.ljust(24))} {'-' if value is None else value}"
|
|
348
|
+
for key, value in status.items()
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
return {"data": status, "text": render}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
# delete
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def delete_memories(ctx: dict) -> dict:
|
|
360
|
+
"""Delete by id, or every memory for one end user with --all.
|
|
361
|
+
|
|
362
|
+
`--all` means every memory belonging to that one user, and nothing beyond it.
|
|
363
|
+
It resolves ids and deletes them through `forget`, the same memory-scoped route
|
|
364
|
+
the id form uses.
|
|
365
|
+
|
|
366
|
+
It must never call ``DELETE /memory/user/purge``. Despite living under
|
|
367
|
+
``/memory/`` and reading as end-user scoped, that route ignores the end user
|
|
368
|
+
and erases the account behind the credential - password hash, every API key,
|
|
369
|
+
memberships, auth providers, MFA, and every end user underneath. The Node CLI
|
|
370
|
+
did call it, and it deleted a live account during a routine cleanup step. There
|
|
371
|
+
is no CLI feature that wants account erasure, so no client method reaches it.
|
|
372
|
+
"""
|
|
373
|
+
user = _require_user(ctx)
|
|
374
|
+
flags, positionals, api = ctx["flags"], ctx["positionals"], ctx["api"]
|
|
375
|
+
ids = [value for value in positionals if value]
|
|
376
|
+
|
|
377
|
+
if not flags.get("all") and not ids:
|
|
378
|
+
raise usage_error("Nothing to delete.", "Give one or more memory ids, or pass --all.")
|
|
379
|
+
if flags.get("all") and ids:
|
|
380
|
+
raise usage_error(
|
|
381
|
+
"Pass either memory ids or --all, not both.",
|
|
382
|
+
"They mean different things and combining them is ambiguous.",
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Two-step by default, matching the MCP delete tools. Without --yes this shows
|
|
386
|
+
# what would go and stops, so an ambiguous command cannot clear a scope.
|
|
387
|
+
confirmed = bool(flags.get("yes")) and not flags.get("dry_run")
|
|
388
|
+
|
|
389
|
+
if flags.get("all"):
|
|
390
|
+
tenant = _resolve_tenant(api)
|
|
391
|
+
existing = api.list_memories(tenant, user, 0) or {}
|
|
392
|
+
all_ids = []
|
|
393
|
+
for item in existing.get("memories") or []:
|
|
394
|
+
raw = item.get("id") if isinstance(item, dict) else None
|
|
395
|
+
if raw is None and isinstance(item, dict):
|
|
396
|
+
raw = item.get("memory_id")
|
|
397
|
+
if raw in (None, ""):
|
|
398
|
+
continue
|
|
399
|
+
all_ids.append(int(_numeric_id(raw)))
|
|
400
|
+
|
|
401
|
+
if not confirmed:
|
|
402
|
+
plural = "y" if len(all_ids) == 1 else "ies"
|
|
403
|
+
return {
|
|
404
|
+
"data": {"would_delete": all_ids, "count": len(all_ids), "user": user, "confirmed": False},
|
|
405
|
+
"text": lambda: "\n".join(
|
|
406
|
+
[
|
|
407
|
+
style.yellow(f"Dry run. Would delete all {len(all_ids)} memor{plural} for {user}."),
|
|
408
|
+
style.dim("Only this end user's memories. The account and its API keys are untouched."),
|
|
409
|
+
style.dim("Nothing was deleted. Re-run with --yes to confirm."),
|
|
410
|
+
]
|
|
411
|
+
),
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if not all_ids:
|
|
415
|
+
return {
|
|
416
|
+
"data": {"deleted": [], "count": 0, "user": user},
|
|
417
|
+
"text": lambda: style.dim(f"No memories stored for {user}. Nothing to delete."),
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
purged = api.forget({"memory_ids": all_ids, "dry_run": False})
|
|
421
|
+
removed = purged if isinstance(purged, list) else (purged or {}).get("memory_ids") or all_ids
|
|
422
|
+
plural = "y" if len(removed) == 1 else "ies"
|
|
423
|
+
return {
|
|
424
|
+
"data": {"deleted": removed, "count": len(removed), "user": user},
|
|
425
|
+
"text": lambda: f"{style.green('Deleted')} all {len(removed)} memor{plural} for {user}",
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
memory_ids = [int(_numeric_id(value)) for value in ids]
|
|
429
|
+
|
|
430
|
+
if not confirmed:
|
|
431
|
+
preview = api.forget({"memory_ids": memory_ids, "dry_run": True})
|
|
432
|
+
would = preview if isinstance(preview, list) else (preview or {}).get("memory_ids") or []
|
|
433
|
+
plural = "y" if len(would) == 1 else "ies"
|
|
434
|
+
return {
|
|
435
|
+
"data": {"would_delete": would, "count": len(would), "user": user, "confirmed": False},
|
|
436
|
+
"text": lambda: "\n".join(
|
|
437
|
+
line
|
|
438
|
+
for line in [
|
|
439
|
+
style.yellow(f"Dry run. Would delete {len(would)} memor{plural}."),
|
|
440
|
+
style.dim(" " + ", ".join(f"m_{i}" for i in would)) if would else None,
|
|
441
|
+
style.dim("Nothing was deleted. Re-run with --yes to confirm."),
|
|
442
|
+
]
|
|
443
|
+
if line
|
|
444
|
+
),
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
deleted = api.forget({"memory_ids": memory_ids, "dry_run": False})
|
|
448
|
+
removed = deleted if isinstance(deleted, list) else (deleted or {}).get("memory_ids") or []
|
|
449
|
+
plural = "y" if len(removed) == 1 else "ies"
|
|
450
|
+
return {
|
|
451
|
+
"data": {"deleted": removed, "count": len(removed), "user": user},
|
|
452
|
+
"text": lambda: f"{style.green('Deleted')} {len(removed)} memor{plural}",
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
# update, import, export
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def update(ctx: dict) -> dict:
|
|
462
|
+
_require_user(ctx)
|
|
463
|
+
flags, positionals, api = ctx["flags"], ctx["positionals"], ctx["api"]
|
|
464
|
+
if not positionals:
|
|
465
|
+
raise usage_error("Which memory?", 'memorysync update m_60632 "new text"')
|
|
466
|
+
|
|
467
|
+
identifier = _numeric_id(positionals[0])
|
|
468
|
+
text = " ".join(positionals[1:]).strip() or None
|
|
469
|
+
metadata = _parse_json_flag(flags.get("metadata"), "metadata")
|
|
470
|
+
|
|
471
|
+
if not text and metadata is None:
|
|
472
|
+
raise usage_error(
|
|
473
|
+
"Nothing to change.",
|
|
474
|
+
"Give replacement text, or --metadata with a JSON object.",
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
body: dict[str, Any] = {}
|
|
478
|
+
if text:
|
|
479
|
+
body["text"] = text
|
|
480
|
+
if metadata is not None:
|
|
481
|
+
body["metadata"] = metadata
|
|
482
|
+
|
|
483
|
+
updated = api.request("PATCH", f"/memory/{identifier}", body=body) or {}
|
|
484
|
+
memory = _normalize_memory(updated)
|
|
485
|
+
return {
|
|
486
|
+
"data": memory,
|
|
487
|
+
"text": lambda: f"{style.green('Updated')} {style.bold(memory['id'] or f'm_{identifier}')}",
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def import_memories(ctx: dict) -> dict:
|
|
492
|
+
"""Bulk load from JSON or JSONL, validating the whole file first.
|
|
493
|
+
|
|
494
|
+
Validation happens before anything is sent so a bad row on line 400 does not
|
|
495
|
+
leave 399 memories already stored, which is the difference between a retryable
|
|
496
|
+
failure and a partial import someone has to reconcile by hand.
|
|
497
|
+
"""
|
|
498
|
+
flags, positionals, api = ctx["flags"], ctx["positionals"], ctx["api"]
|
|
499
|
+
user = _require_user(ctx)
|
|
500
|
+
|
|
501
|
+
source_path = flags.get("file") or (positionals[0] if positionals else None)
|
|
502
|
+
if not source_path:
|
|
503
|
+
raise usage_error("Which file?", "memorysync import memories.jsonl --user alice")
|
|
504
|
+
|
|
505
|
+
try:
|
|
506
|
+
raw = Path(source_path).read_text(encoding="utf-8")
|
|
507
|
+
except OSError as error:
|
|
508
|
+
raise usage_error(f"Could not read {source_path}: {error}") from None
|
|
509
|
+
|
|
510
|
+
records: list[dict] = []
|
|
511
|
+
errors: list[str] = []
|
|
512
|
+
|
|
513
|
+
stripped = raw.strip()
|
|
514
|
+
if stripped.startswith("["):
|
|
515
|
+
try:
|
|
516
|
+
parsed = json.loads(stripped)
|
|
517
|
+
except ValueError as error:
|
|
518
|
+
raise usage_error(f"{source_path} is not valid JSON: {error}") from None
|
|
519
|
+
if not isinstance(parsed, list):
|
|
520
|
+
raise usage_error(f"{source_path} must contain a JSON array.")
|
|
521
|
+
candidates = list(enumerate(parsed, start=1))
|
|
522
|
+
else:
|
|
523
|
+
candidates = []
|
|
524
|
+
for number, line in enumerate(stripped.splitlines(), start=1):
|
|
525
|
+
if not line.strip():
|
|
526
|
+
continue
|
|
527
|
+
try:
|
|
528
|
+
candidates.append((number, json.loads(line)))
|
|
529
|
+
except ValueError as error:
|
|
530
|
+
errors.append(f"line {number}: {error}")
|
|
531
|
+
|
|
532
|
+
for number, entry in candidates:
|
|
533
|
+
if not isinstance(entry, dict):
|
|
534
|
+
errors.append(f"line {number}: expected an object")
|
|
535
|
+
continue
|
|
536
|
+
text = entry.get("text") or entry.get("memory") or entry.get("content")
|
|
537
|
+
if not text:
|
|
538
|
+
errors.append(f"line {number}: no text, memory or content field")
|
|
539
|
+
continue
|
|
540
|
+
record: dict[str, Any] = {"text": str(text), "source": entry.get("source") or "import"}
|
|
541
|
+
if isinstance(entry.get("metadata"), dict):
|
|
542
|
+
record["metadata"] = entry["metadata"]
|
|
543
|
+
records.append(record)
|
|
544
|
+
|
|
545
|
+
if errors:
|
|
546
|
+
raise usage_error(
|
|
547
|
+
f"{source_path} has {len(errors)} invalid row(s); nothing was imported.",
|
|
548
|
+
"; ".join(errors[:5]) + ("; ..." if len(errors) > 5 else ""),
|
|
549
|
+
)
|
|
550
|
+
if not records:
|
|
551
|
+
raise usage_error(f"{source_path} contained no records.")
|
|
552
|
+
|
|
553
|
+
if flags.get("dry_run"):
|
|
554
|
+
return {
|
|
555
|
+
"data": {"would_import": len(records), "user": user},
|
|
556
|
+
"text": lambda: style.yellow(
|
|
557
|
+
f"Dry run. {len(records)} record(s) validated. Nothing was imported."
|
|
558
|
+
),
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
stored, skipped = [], 0
|
|
562
|
+
for record in records:
|
|
563
|
+
created = api.add_memory(record)
|
|
564
|
+
memory = _normalize_memory(created)
|
|
565
|
+
if memory["id"]:
|
|
566
|
+
stored.append(memory["id"])
|
|
567
|
+
else:
|
|
568
|
+
skipped += 1
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
"data": {"imported": stored, "count": len(stored), "skipped": skipped, "user": user},
|
|
572
|
+
"text": lambda: "\n".join(
|
|
573
|
+
[
|
|
574
|
+
f"{style.green('Imported')} {len(stored)} of {len(records)}",
|
|
575
|
+
style.dim(f" {skipped} skipped as duplicate or low value") if skipped else "",
|
|
576
|
+
]
|
|
577
|
+
).strip(),
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def export_memories(ctx: dict) -> dict:
|
|
582
|
+
user = _require_user(ctx)
|
|
583
|
+
flags, api = ctx["flags"], ctx["api"]
|
|
584
|
+
|
|
585
|
+
fmt = (flags.get("format") or "json").lower()
|
|
586
|
+
if fmt not in {"json", "jsonl", "csv"}:
|
|
587
|
+
raise usage_error(
|
|
588
|
+
f'Unknown export format "{fmt}".',
|
|
589
|
+
"One of: json, jsonl, csv.",
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
tenant = _resolve_tenant(api)
|
|
593
|
+
response = api.list_memories(tenant, user, 0) or {}
|
|
594
|
+
memories = [_normalize_memory(item) for item in (response.get("memories") or [])]
|
|
595
|
+
|
|
596
|
+
serialized = _serialize_export(memories, fmt)
|
|
597
|
+
|
|
598
|
+
if flags.get("out"):
|
|
599
|
+
Path(flags["out"]).write_text(serialized, encoding="utf-8")
|
|
600
|
+
|
|
601
|
+
def render() -> str:
|
|
602
|
+
if flags.get("out"):
|
|
603
|
+
plural = "y" if len(memories) == 1 else "ies"
|
|
604
|
+
return f"{style.green('Exported')} {len(memories)} memor{plural} to {flags['out']}"
|
|
605
|
+
return serialized
|
|
606
|
+
|
|
607
|
+
return {"data": memories, "text": render}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _serialize_export(memories: list[dict], fmt: str) -> str:
|
|
611
|
+
if fmt == "json":
|
|
612
|
+
return json.dumps(memories, indent=2) + "\n"
|
|
613
|
+
if fmt == "jsonl":
|
|
614
|
+
if not memories:
|
|
615
|
+
return ""
|
|
616
|
+
return "\n".join(json.dumps(item) for item in memories) + "\n"
|
|
617
|
+
|
|
618
|
+
# CSV, quoted by hand rather than through the csv module so the output is
|
|
619
|
+
# identical to the Node CLI's, which also writes it directly.
|
|
620
|
+
def escape(value: Any) -> str:
|
|
621
|
+
text = "" if value is None else str(value)
|
|
622
|
+
if any(character in text for character in [',', '"', "\n"]):
|
|
623
|
+
return '"' + text.replace('"', '""') + '"'
|
|
624
|
+
return text
|
|
625
|
+
|
|
626
|
+
headers = ["id", "text", "source", "created_at"]
|
|
627
|
+
lines = [",".join(headers)]
|
|
628
|
+
lines.extend(",".join(escape(item.get(header)) for header in headers) for item in memories)
|
|
629
|
+
return "\n".join(lines) + "\n"
|