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.
@@ -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"