hermes-plugin-qdrant-memory 0.2.0__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.
qdrant_memory/cli.py ADDED
@@ -0,0 +1,887 @@
1
+ """Hermes CLI commands. No background workers or LLM calls in maintenance mode."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import sys
7
+ from contextlib import ExitStack
8
+ from pathlib import Path
9
+
10
+ from .config import (
11
+ active_home,
12
+ backend_destination,
13
+ embedding_config,
14
+ ledger_namespace,
15
+ load_config,
16
+ )
17
+ from .embedding import build_embedder
18
+ from .inventory import (
19
+ ExportPublicationUnsupportedError,
20
+ ExportTargetExistsError,
21
+ memory_view,
22
+ write_export,
23
+ )
24
+ from .ledger import Ledger
25
+ from .migration import (
26
+ MigrationIncompleteError,
27
+ MigrationSupersededError,
28
+ migrate,
29
+ plan,
30
+ read_json_records,
31
+ read_qdrant_records,
32
+ source_client,
33
+ source_settings,
34
+ verify_collection,
35
+ verify_manifest,
36
+ )
37
+ from .models import Scope
38
+ from .ownership import WriterBusyError, WriterLease
39
+ from .progress import MigrationProgress, report
40
+ from .qdrant_store import (
41
+ IDENTITY_ID,
42
+ INDEXES,
43
+ CollectionCompatibilityError,
44
+ CollectionNotInitializedError,
45
+ QdrantStore,
46
+ build_client,
47
+ )
48
+ from .reset import (
49
+ ResetRecoveryRequiredError,
50
+ ScopeDeleteIncompleteError,
51
+ ScopeDeleteLegacyIntentError,
52
+ ScopeDeleteRecoveryRequiredError,
53
+ ScopeDeleteRefusedError,
54
+ begin_destination_reset,
55
+ begin_scope_delete,
56
+ decode_scope_delete_record,
57
+ finish_scope_delete,
58
+ has_pending_reset_file,
59
+ is_legacy_scope_delete,
60
+ parse_scope_delete_intent,
61
+ pending_scope_deletes,
62
+ pending_scope_deletes_file,
63
+ resolve_legacy_scope_delete,
64
+ resume_destination_reset,
65
+ scope_delete_intent,
66
+ scope_delete_key,
67
+ )
68
+ from .retry import safe_error
69
+ from .runtime import Runtime
70
+
71
+
72
+ class InitializationChoiceRequiredError(ValueError):
73
+ """Require an explicit existing-collection choice outside a terminal."""
74
+
75
+
76
+ class DeleteConfirmationRequiredError(ValueError):
77
+ """Require explicit confirmation before deleting a whole memory scope."""
78
+
79
+
80
+ class ScopeSelectionError(ValueError):
81
+ """Reject scope arguments that cannot identify a user-anchored scope."""
82
+
83
+
84
+ def existing_collection_action(args, collection):
85
+ """Confirm reuse by default; clearing requires an explicit user choice."""
86
+ action = getattr(args, "existing", None)
87
+ if action in {"use", "clear"}:
88
+ return action
89
+ if not sys.stdin.isatty():
90
+ raise InitializationChoiceRequiredError()
91
+ print(
92
+ f"Collection {collection!r} already exists. Use existing or clear ALL its data and rebuild? "
93
+ "[use/clear] (default: use): ",
94
+ end="",
95
+ file=sys.stderr,
96
+ flush=True,
97
+ )
98
+ answer = sys.stdin.readline()
99
+ if not answer:
100
+ raise InitializationChoiceRequiredError()
101
+ action = answer.strip().lower() or "use"
102
+ if action not in {"use", "clear"}:
103
+ raise InitializationChoiceRequiredError()
104
+ return action
105
+
106
+
107
+ def register_cli(subparser):
108
+ """Register maintenance and migration subcommands without starting a provider."""
109
+ commands = subparser.add_subparsers(dest="qdrant_command", required=True)
110
+ for name in ("status", "init", "stats", "doctor", "verify", "retry"):
111
+ parser = commands.add_parser(name)
112
+ parser.add_argument("--collection")
113
+ if name == "init":
114
+ parser.add_argument(
115
+ "--existing",
116
+ choices=["use", "clear"],
117
+ help="Existing collection: use after validation, or delete all data and rebuild",
118
+ )
119
+ parser.set_defaults(func=main)
120
+ parser = commands.add_parser("migrate")
121
+ parser.add_argument("source", choices=["mem0"])
122
+ group = parser.add_mutually_exclusive_group(required=True)
123
+ group.add_argument("--source-json", type=Path)
124
+ group.add_argument("--source-qdrant-collection")
125
+ parser.add_argument("--source-config", type=Path)
126
+ parser.add_argument("--target-collection")
127
+ parser.add_argument(
128
+ "--reembed", action="store_true", help="Re-embed source texts (the default)"
129
+ )
130
+ parser.add_argument(
131
+ "--reuse-vectors",
132
+ action="store_true",
133
+ help="Reserved: not implemented in 0.1; re-embedding is required",
134
+ )
135
+ for flag in ("resume", "retry-failed", "dry-run", "verify"):
136
+ parser.add_argument("--" + flag, action="store_true")
137
+ parser.add_argument("--oversize", choices=["reject", "truncate"], default="reject")
138
+ parser.add_argument(
139
+ "--quiet", action="store_true", help="Suppress migration progress on stderr"
140
+ )
141
+ parser.set_defaults(func=main)
142
+ parser = commands.add_parser("list", help="List stored memories without changing them")
143
+ parser.add_argument("--collection")
144
+ scope_arguments(parser)
145
+ parser.add_argument("--limit", type=int, default=200, help="Maximum memory records to return")
146
+ parser.set_defaults(func=main)
147
+ parser = commands.add_parser(
148
+ "export", help="Write stored memories as portable Mem0-importable JSON"
149
+ )
150
+ parser.add_argument("--collection")
151
+ scope_arguments(parser)
152
+ parser.add_argument("--output", type=Path, required=True, help="Export file path")
153
+ parser.add_argument("--force", action="store_true", help="Overwrite an existing export file")
154
+ parser.set_defaults(func=main)
155
+ parser = commands.add_parser("delete-all", help="Durably delete every memory in one scope")
156
+ parser.add_argument("--collection")
157
+ scope_arguments(parser)
158
+ parser.add_argument(
159
+ "--all-agents",
160
+ action="store_true",
161
+ help="Delete every agent scope of the user, including the no-agent scope",
162
+ )
163
+ parser.add_argument(
164
+ "--resolve-legacy",
165
+ choices=["single_agent", "all_agents"],
166
+ help="Disambiguate a pre-versioned deletion intent before resuming it",
167
+ )
168
+ parser.add_argument(
169
+ "--confirm",
170
+ action="store_true",
171
+ help="Authorize deleting every memory in the selected scope",
172
+ )
173
+ parser.add_argument(
174
+ "--dry-run", action="store_true", help="Report the scope count without deleting"
175
+ )
176
+ parser.set_defaults(func=main)
177
+ return commands
178
+
179
+
180
+ def scope_arguments(parser):
181
+ """Add optional user/agent scope selection shared by inventory commands."""
182
+ parser.add_argument("--user", help="Memory scope user ID")
183
+ parser.add_argument(
184
+ "--agent",
185
+ help="Memory scope agent ID; any string, including a literal '*', is a "
186
+ "valid agent id. Omit to select the profile-wide (no agent) scope",
187
+ )
188
+
189
+
190
+ def summary(manifest):
191
+ """Return manifest metadata without per-record identifiers and hashes."""
192
+ return {key: value for key, value in manifest.items() if key != "records"}
193
+
194
+
195
+ def local_status(cfg, home):
196
+ """Describe local configuration without contacting external services."""
197
+ return {
198
+ "provider": "qdrant-memory",
199
+ "mode": cfg["qdrant"]["mode"],
200
+ "collection": cfg["qdrant"]["collection"],
201
+ "llm_mode": cfg["llm"]["mode"],
202
+ "embedding": {k: embedding_config(cfg)[k] for k in ("provider", "model", "dimensions")},
203
+ "ledger_exists": (home / "qdrant-memory" / "state.db").exists(),
204
+ }
205
+
206
+
207
+ def migration_source(args, cfg, home):
208
+ """Read a migration source and close its client before opening the target."""
209
+ if args.reuse_vectors:
210
+ raise ValueError(
211
+ "Vector reuse is refused: Mem0 sources lack a trusted pipeline fingerprint; re-embed"
212
+ )
213
+ if args.source_json:
214
+ return (
215
+ read_json_records(args.source_json),
216
+ "mem0-json",
217
+ str(args.source_json.resolve()),
218
+ hashlib.sha256(args.source_json.read_bytes()).hexdigest(),
219
+ )
220
+ source_q = source_settings(cfg, home, args.source_config)
221
+ source_q["collection"] = args.source_qdrant_collection
222
+ if source_q["collection"] == cfg["qdrant"]["collection"] and backend_destination(
223
+ source_q
224
+ ) == backend_destination(cfg["qdrant"]):
225
+ raise ValueError("Source and target collections must be isolated")
226
+ client = source_client(source_q)
227
+ try:
228
+ records = list(read_qdrant_records(client, args.source_qdrant_collection))
229
+ return records, "mem0-qdrant", ledger_namespace({"qdrant": source_q}), None
230
+ finally:
231
+ client.close()
232
+
233
+
234
+ def verify_target(store, ledger):
235
+ """Verify target payloads and the latest destination-specific migration manifest."""
236
+ result = verify_collection(store)
237
+ manifests = [m for m in ledger.manifests() if m["target_collection"] == store.collection]
238
+ if manifests:
239
+ result["migration"] = verify_manifest(store, manifests[-1])
240
+ result["ok"] = result["ok"] and result["migration"]["ok"]
241
+ return result
242
+
243
+
244
+ def diagnostic_status(store, ledger, cfg, command):
245
+ """Report counts, ledger metrics and optional collection/index readiness."""
246
+ result = {
247
+ "points_count": store.count(),
248
+ "vector_dim": store.embedder.dimensions,
249
+ "ledger": ledger.stats(),
250
+ "metrics": ledger.metrics(),
251
+ "embedding_fingerprint": store.embedder.fingerprint,
252
+ }
253
+ if command in {"doctor", "init"}:
254
+ info = store.client.get_collection(store.collection)
255
+ index_ok = cfg["qdrant"]["mode"] == "embedded" or set(INDEXES) <= set(info.payload_schema)
256
+ result.update(
257
+ hermes_contract="MemoryProvider + scoped threads + ctx.llm",
258
+ embedding_probe="OK",
259
+ payload_indexes="not applicable to embedded"
260
+ if cfg["qdrant"]["mode"] == "embedded"
261
+ else index_ok,
262
+ llm_mode=cfg["llm"]["mode"],
263
+ ok=index_ok,
264
+ )
265
+ return result
266
+
267
+
268
+ def execute_command(args, runtime, source, progress=None):
269
+ """Dispatch a maintenance command using an initialized store and ledger."""
270
+ command = args.qdrant_command
271
+ if command == "migrate":
272
+ records, source_type, source_identifier, checksum = source
273
+ return summary(
274
+ migrate(
275
+ runtime,
276
+ records,
277
+ source_type,
278
+ source_identifier,
279
+ checksum,
280
+ args.resume,
281
+ args.retry_failed,
282
+ args.verify,
283
+ progress=progress,
284
+ )
285
+ )
286
+ if command == "list":
287
+ return inventory_list(runtime, args)
288
+ if command == "export":
289
+ return inventory_export(runtime, args)
290
+ if command == "delete-all":
291
+ return scoped_delete_all(runtime, args)
292
+ if command == "verify":
293
+ return verify_target(runtime.store, runtime.ledger)
294
+ if command == "retry":
295
+ # Raw events require trusted host LLM calls on the next provider startup.
296
+ runtime.ledger.retry_failed()
297
+ runtime.commit(r["idempotency_key"] for r in runtime.ledger.iter_rows("operations"))
298
+ return diagnostic_status(runtime.store, runtime.ledger, runtime.cfg, command)
299
+
300
+
301
+ def scope_from(args):
302
+ """Build an explicit memory scope from command arguments, if one is selected."""
303
+ user = getattr(args, "user", None)
304
+ agent = getattr(args, "agent", None)
305
+ if agent is not None and not user:
306
+ raise ScopeSelectionError("--agent requires --user; scopes are user-anchored")
307
+ if user is None:
308
+ return None
309
+ return Scope(user, agent)
310
+
311
+
312
+ def inventory_list(runtime, args):
313
+ """Return a bounded read-only inventory of stored memories and ledger backlog."""
314
+ scope = scope_from(args)
315
+ limit = int(args.limit)
316
+ if limit < 1:
317
+ raise ValueError("--limit must be at least 1")
318
+ total = runtime.store.count(scope)
319
+ memories = []
320
+ for point in runtime.store.scroll(scope):
321
+ memories.append(memory_view(point))
322
+ if len(memories) >= limit:
323
+ break
324
+ operations = runtime.ledger.stats()["operations"]
325
+ return {
326
+ "collection": runtime.store.collection,
327
+ "scope": scope.as_dict() if scope else None,
328
+ "count": {
329
+ "total": total,
330
+ "returned": len(memories),
331
+ "truncated": total > len(memories),
332
+ },
333
+ "ledger": {
334
+ "pending_operations": operations.get("PENDING", 0),
335
+ "failed_operations": operations.get("FAILED", 0),
336
+ },
337
+ "memories": memories,
338
+ }
339
+
340
+
341
+ def inventory_export(runtime, args):
342
+ """Stream every stored memory to a portable export accepted by migrate mem0."""
343
+ scope = scope_from(args)
344
+ count = write_export(
345
+ args.output, runtime.store.collection, runtime.store.scroll(scope), force=args.force
346
+ )
347
+ return {
348
+ "collection": runtime.store.collection,
349
+ "scope": scope.as_dict() if scope else None,
350
+ "count": count,
351
+ "output": str(args.output),
352
+ }
353
+
354
+
355
+ def scoped_delete_all(runtime, args):
356
+ """Durably delete stored memories and pending work for one scope or user.
357
+
358
+ The deletion records an explicit, versioned intent, fences every enumerated
359
+ point and prepared write, invalidates admitted-but-unprocessed turn events
360
+ for the scope, deletes the scope in filtered operations and clears the
361
+ intent only after every target scope is confirmed empty. ``--all-agents``
362
+ covers every agent scope of a user, including the no-agent scope. A literal
363
+ agent id ``'*'`` is an ordinary single scope and never selects all agents;
364
+ a pre-versioned intent that cannot distinguish the two is refused until
365
+ ``--resolve-legacy`` disambiguates it.
366
+ """
367
+ store, ledger = runtime.store, runtime.ledger
368
+ all_agents = bool(getattr(args, "all_agents", False))
369
+ agent = getattr(args, "agent", None)
370
+ user = getattr(args, "user", None)
371
+ resolve_legacy = getattr(args, "resolve_legacy", None)
372
+ if all_agents and agent is not None:
373
+ raise ScopeSelectionError("--all-agents cannot be combined with --agent")
374
+ if resolve_legacy and (all_agents or agent is not None):
375
+ raise ScopeSelectionError(
376
+ "--resolve-legacy cannot be combined with --agent or --all-agents"
377
+ )
378
+ if resolve_legacy and getattr(args, "dry_run", False):
379
+ raise ScopeSelectionError("--resolve-legacy cannot be combined with --dry-run")
380
+ if agent is not None and not user:
381
+ raise ScopeSelectionError("--agent requires --user; scopes are user-anchored")
382
+ pending = pending_scope_deletes(ledger)
383
+ resumed = False
384
+ intent_time = None
385
+ if pending:
386
+ if len(pending) > 1:
387
+ raise ScopeDeleteRefusedError()
388
+ recorded = decode_scope_delete_record(pending[0])
389
+ if is_legacy_scope_delete(recorded):
390
+ # Pre-versioned intents cannot distinguish a single-agent deletion
391
+ # of the literal agent '*' from an all-agents deletion. Never
392
+ # guess; the operator disambiguates explicitly.
393
+ if resolve_legacy is None:
394
+ user_value = recorded.get("user_id") if isinstance(recorded, dict) else None
395
+ raise ScopeDeleteLegacyIntentError(
396
+ user_value if isinstance(user_value, str) else ""
397
+ )
398
+ if not user:
399
+ raise ScopeSelectionError("--resolve-legacy requires --user")
400
+ if not isinstance(recorded, dict) or recorded.get("user_id") != user:
401
+ raise ScopeDeleteRefusedError()
402
+ if not args.confirm:
403
+ raise DeleteConfirmationRequiredError()
404
+ if resolve_legacy == "all_agents":
405
+ if recorded.get("agent_id", "*") != "*":
406
+ # A pre-versioned record with a concrete agent is not
407
+ # ambiguous: never widen it to an all-agents deletion.
408
+ raise ScopeDeleteRefusedError()
409
+ all_agents, agent = True, None
410
+ else:
411
+ if "agent_id" not in recorded:
412
+ # A pre-versioned record without an agent cannot be
413
+ # resolved as a single-agent deletion without guessing.
414
+ raise ScopeDeleteRefusedError()
415
+ all_agents = False
416
+ agent = recorded.get("agent_id")
417
+ intent_value = scope_delete_intent(user, agent, all_agents)
418
+ intent_time = resolve_legacy_scope_delete(ledger, pending[0]["scope"], intent_value)
419
+ else:
420
+ recorded_all, recorded_user, recorded_agent = parse_scope_delete_intent(recorded)
421
+ if (user is not None or all_agents) and (
422
+ (user is not None and recorded_user != user)
423
+ or recorded_all != all_agents
424
+ or (user is not None and not recorded_all and recorded_agent != agent)
425
+ ):
426
+ raise ScopeDeleteRefusedError()
427
+ user, all_agents, agent = recorded_user, recorded_all, recorded_agent
428
+ intent_value = scope_delete_intent(user, agent, all_agents)
429
+ resumed = True
430
+ elif resolve_legacy:
431
+ raise ScopeSelectionError("--resolve-legacy requires a pending legacy intent")
432
+ elif user is None:
433
+ if all_agents:
434
+ raise ScopeSelectionError("--all-agents requires --user; scopes are user-anchored")
435
+ raise ScopeDeleteRefusedError()
436
+ else:
437
+ intent_value = scope_delete_intent(user, agent, all_agents)
438
+ identity = store.client.retrieve(store.collection, ids=[IDENTITY_ID], with_payload=True)
439
+ if not identity or not identity[0].payload.get("_qdrant_memory_schema"):
440
+ raise CollectionCompatibilityError(
441
+ "Collection has no trusted qdrant-memory schema identity; refusing scoped deletion"
442
+ )
443
+ result = {
444
+ "collection": store.collection,
445
+ "scope": intent_value,
446
+ "resumed": resumed,
447
+ }
448
+ target_agents = _agents_with_work(store, ledger, user) if all_agents else None
449
+ if args.dry_run:
450
+ agents = target_agents if target_agents is not None else [agent]
451
+ counts = [
452
+ {"agent_id": agent_key, "count": store.count(Scope(user, agent_key))}
453
+ for agent_key in agents
454
+ ]
455
+ result.update(
456
+ dry_run=True,
457
+ would_delete=sum(item["count"] for item in counts),
458
+ scopes=counts,
459
+ )
460
+ return result
461
+ if not args.confirm:
462
+ raise DeleteConfirmationRequiredError()
463
+ if target_agents is None:
464
+ target_agents = [agent]
465
+ if intent_time is None:
466
+ intent_time = begin_scope_delete(ledger, intent_value)
467
+ scopes = [Scope(user, agent_key) for agent_key in target_agents]
468
+ fenced = {index: set() for index in range(len(scopes))}
469
+ for index, scope in enumerate(scopes):
470
+ for point in store.scroll(scope):
471
+ value = point.payload or {}
472
+ operation_key = ledger.enqueue_operation(
473
+ str(point.id),
474
+ "DELETE",
475
+ {"content_hash": value.get("content_hash", "")},
476
+ source_id=ledger.delete_fence_source(scope),
477
+ source_version=intent_time,
478
+ )
479
+ ledger.finish("operations", operation_key, "COMMITTED")
480
+ fenced[index].add(str(point.id))
481
+ # Prepared writes whose points are not stored yet must be fenced too;
482
+ # otherwise a later recovery could replay them and resurrect memories.
483
+ _fence_prepared_writes(ledger, scopes, fenced, intent_time)
484
+ fenced_total = sum(len(entries) for entries in fenced.values())
485
+ superseded_total = _supersede_scope_events(ledger, scopes)
486
+ deleted_total = 0
487
+ for scope in scopes:
488
+ before = store.count(scope)
489
+ store.delete_scope(scope)
490
+ if store.count(scope):
491
+ raise ScopeDeleteIncompleteError()
492
+ deleted_total += before
493
+ finish_scope_delete(ledger, scope_delete_key(intent_value))
494
+ result.update(
495
+ deleted=deleted_total,
496
+ fenced_operations=fenced_total,
497
+ superseded_events=superseded_total,
498
+ ok=True,
499
+ )
500
+ if not all_agents:
501
+ others = store.scopes_for_user(user)
502
+ others.pop(agent, None)
503
+ if others:
504
+ result["other_agent_scopes"] = others
505
+ return result
506
+
507
+
508
+ def _agents_with_work(store, ledger, user):
509
+ """Agent values with stored memories or pending/failed work for one user.
510
+
511
+ ``None`` is the no-agent scope; events keep their admitted scope under the
512
+ payload's ``scope`` key, operations carry it at the payload top level.
513
+ """
514
+ agents = set(store.scopes_for_user(user))
515
+ for status in ("PENDING", "FAILED"):
516
+ for table in ("operations", "events"):
517
+ for row in ledger.iter_rows(table, status):
518
+ try:
519
+ value = json.loads(row["payload_json"] or "{}")
520
+ except ValueError:
521
+ continue
522
+ scope_value = value.get("scope") if table == "events" else value
523
+ if not isinstance(scope_value, dict):
524
+ continue
525
+ if scope_value.get("user_id") != user:
526
+ continue
527
+ agents.add(scope_value.get("agent_id"))
528
+ return sorted(agents, key=repr)
529
+
530
+
531
+ def _fence_prepared_writes(ledger, scopes, fenced, intent_time):
532
+ """Fence prepared UPSERTs for every target scope in one bounded pass.
533
+
534
+ Per-scope rescans of pending work would cost O(scopes x rows) under
535
+ ``--all-agents``; one pass with per-scope dedupe keeps the cost linear in
536
+ the ledger size while the per-scope fence identity stays intact.
537
+ """
538
+ targets = {(scope.user_id, scope.agent_id): index for index, scope in enumerate(scopes)}
539
+ for status in ("PENDING", "FAILED"):
540
+ for row in ledger.iter_rows("operations", status):
541
+ if row["action"] != "UPSERT":
542
+ continue
543
+ try:
544
+ value = json.loads(row["payload_json"] or "{}")
545
+ except ValueError:
546
+ continue
547
+ index = targets.get((value.get("user_id"), value.get("agent_id")))
548
+ if index is None or row["point_id"] in fenced[index]:
549
+ continue
550
+ scope = scopes[index]
551
+ operation_key = ledger.enqueue_operation(
552
+ row["point_id"],
553
+ "DELETE",
554
+ {"content_hash": row["content_hash"] or value.get("content_hash", "")},
555
+ source_id=ledger.delete_fence_source(scope),
556
+ source_version=intent_time,
557
+ )
558
+ ledger.finish("operations", operation_key, "COMMITTED")
559
+ fenced[index].add(row["point_id"])
560
+
561
+
562
+ def _supersede_scope_events(ledger, scopes):
563
+ """Invalidate admitted-but-unprocessed turn events for the deleted scopes.
564
+
565
+ Events admitted after the deletion are unaffected: the provider cannot
566
+ start while the deletion intent is pending, and new events can only be
567
+ admitted once it is cleared. One bounded pass covers every target scope.
568
+ """
569
+ targets = {(scope.user_id, scope.agent_id) for scope in scopes}
570
+ superseded = 0
571
+ for status in ("PENDING", "FAILED"):
572
+ for row in ledger.iter_rows("events", status):
573
+ try:
574
+ value = json.loads(row["payload_json"] or "{}")
575
+ except ValueError:
576
+ continue
577
+ scope_value = value.get("scope")
578
+ if not isinstance(scope_value, dict):
579
+ continue
580
+ if (scope_value.get("user_id"), scope_value.get("agent_id")) not in targets:
581
+ continue
582
+ ledger.finish("events", row["event_id"], "SUPERSEDED")
583
+ superseded += 1
584
+ return superseded
585
+
586
+
587
+ def _pending_intent_error(intents):
588
+ """Return the fail-closed error for pending scoped-deletion intents.
589
+
590
+ Pre-versioned intents are ambiguous and require explicit operator
591
+ disambiguation; every other pending intent uses the standard resume
592
+ guidance.
593
+ """
594
+ for intent in intents:
595
+ recorded = decode_scope_delete_record(intent)
596
+ if is_legacy_scope_delete(recorded):
597
+ user = recorded.get("user_id") if isinstance(recorded, dict) else None
598
+ return ScopeDeleteLegacyIntentError(user if isinstance(user, str) else "")
599
+ return ScopeDeleteRecoveryRequiredError()
600
+
601
+
602
+ def run(args, home=None, *, config_loader=None, secret_reader=None):
603
+ """Run maintenance, with live stderr diagnostics for migrations only."""
604
+ if args.qdrant_command == "migrate":
605
+ with MigrationProgress(quiet=getattr(args, "quiet", False)) as progress:
606
+ return _run(
607
+ args, home, progress, config_loader=config_loader, secret_reader=secret_reader
608
+ )
609
+ return _run(args, home, config_loader=config_loader, secret_reader=secret_reader)
610
+
611
+
612
+ def _command_config(args, home, config_loader, progress):
613
+ """Resolve operator collection/size overrides before initializing services."""
614
+ overrides = {}
615
+ collection = getattr(args, "target_collection", None) or getattr(args, "collection", None)
616
+ if collection:
617
+ overrides["qdrant"] = {"collection": collection}
618
+ if getattr(args, "oversize", None):
619
+ overrides["limits"] = {"oversize_policy": args.oversize}
620
+ report(progress, "Loading configuration")
621
+ return (config_loader or load_config)(
622
+ home, overrides, resolve_secrets=args.qdrant_command != "status"
623
+ )
624
+
625
+
626
+ def _migration_input(args, cfg, home, progress):
627
+ """Read the explicitly selected operator source without opening the destination."""
628
+ if args.qdrant_command != "migrate":
629
+ return (None, None, None, None)
630
+ report(progress, "Reading source")
631
+ source = migration_source(args, cfg, home)
632
+ report(progress, "Source loaded", len(source[0]), len(source[0]))
633
+ return source
634
+
635
+
636
+ def _migration_preview(records, cfg, progress):
637
+ """Validate source records while keeping dry-run destination storage untouched."""
638
+ report(progress, "Validating source", 0, len(records))
639
+ planned = plan(records, cfg, progress=progress)
640
+ report(progress, "Dry run complete", len(planned), len(planned))
641
+ return {
642
+ "dry_run": True,
643
+ "source_unique_count": len(planned),
644
+ "target_collection": cfg["qdrant"]["collection"],
645
+ "target_ids": list(planned),
646
+ }
647
+
648
+
649
+ def _check_recovery(home, cfg, command):
650
+ """Require the matching operator command for pending destructive intents."""
651
+ namespace = ledger_namespace(cfg)
652
+ pending_reset = has_pending_reset_file(home, namespace)
653
+ if pending_reset and command != "init":
654
+ raise ResetRecoveryRequiredError()
655
+ pending_intents = pending_scope_deletes_file(home, namespace)
656
+ if pending_intents and command != "delete-all":
657
+ raise _pending_intent_error(pending_intents)
658
+ return pending_reset
659
+
660
+
661
+ def _open_embedder(command, cfg, resources, progress, secret_reader):
662
+ """Keep inventory/deletion independent of the embedding service."""
663
+ if command in {"list", "export", "delete-all"}:
664
+ return None
665
+ report(progress, "Initializing embedding client")
666
+ if secret_reader is None:
667
+ embedder = build_embedder(None, cfg)
668
+ else:
669
+ embedder = build_embedder(None, cfg, secret_reader=secret_reader)
670
+ if hasattr(embedder, "close"):
671
+ resources.callback(embedder.close)
672
+ return embedder
673
+
674
+
675
+ def _initialize_target(args, store, home, cfg, pending_reset, resources, progress):
676
+ """Initialize or explicitly reuse/reset a target and own its destination ledger."""
677
+ command = args.qdrant_command
678
+ action = "create"
679
+ ledger = None
680
+ if command == "init" and pending_reset:
681
+ # A prior explicit clear remains authorized until its durable intent clears.
682
+ action = "clear"
683
+ elif command == "init" and store.client.collection_exists(store.collection):
684
+ action = existing_collection_action(args, store.collection)
685
+ if action == "clear":
686
+ ledger = Ledger(home, ledger_namespace(cfg))
687
+ resources.callback(ledger.close)
688
+ if not pending_reset:
689
+ begin_destination_reset(ledger)
690
+ resume_destination_reset(store, ledger)
691
+ elif command in {"list", "export", "delete-all"}:
692
+ if not store.client.collection_exists(store.collection):
693
+ raise CollectionNotInitializedError(
694
+ "Collection does not exist; run hermes qdrant-memory init"
695
+ )
696
+ else:
697
+ report(progress, "Initializing target collection and probing embedding")
698
+ store.initialize(create=command in {"init", "migrate"})
699
+ if ledger is None:
700
+ ledger = Ledger(home, ledger_namespace(cfg))
701
+ resources.callback(ledger.close)
702
+ return ledger, action
703
+
704
+
705
+ def _init_result(result, store, action):
706
+ """Include payload verification in initialization readiness without hiding failures."""
707
+ result.update(collection=store.collection, action=action)
708
+ verification = verify_collection(store)
709
+ result["payload_verification"] = {
710
+ "ok": verification["ok"],
711
+ "checked": verification["checked"],
712
+ "invalid_count": len(verification["invalid_ids"]),
713
+ }
714
+ result["ok"] = result["ok"] and verification["ok"]
715
+ if not verification["ok"]:
716
+ result["message"] = (
717
+ "Collection contains incompatible memory payloads or vectors. "
718
+ "Choose another collection or explicitly clear and rebuild it."
719
+ )
720
+
721
+
722
+ def _report_migration_result(result, progress):
723
+ """Report incomplete or superseded migration outcomes distinctly."""
724
+ if result["processed"] != result["source_unique_count"]:
725
+ stage = "Migration incomplete; rerun with --resume --retry-failed"
726
+ elif result.get("superseded"):
727
+ stage = (
728
+ "Migration completed with superseded records; inspect manifest before a fresh import"
729
+ )
730
+ else:
731
+ stage = "Migration complete"
732
+ report(progress, stage, result["processed"], result["source_unique_count"])
733
+
734
+
735
+ def _run(args, home=None, progress=None, *, config_loader=None, secret_reader=None):
736
+ """Run maintenance with validated settings and reverse-order resource cleanup.
737
+
738
+ Dry runs validate records without opening a target collection or ledger.
739
+ Configuration, service and verification errors propagate to the CLI boundary.
740
+ """
741
+ home = Path(home or active_home())
742
+ command = args.qdrant_command
743
+ cfg = _command_config(args, home, config_loader, progress)
744
+ if command == "status":
745
+ return local_status(cfg, home)
746
+ source = _migration_input(args, cfg, home, progress)
747
+ if command == "migrate" and args.dry_run:
748
+ return _migration_preview(source[0], cfg, progress)
749
+ pending_reset = _check_recovery(home, cfg, command)
750
+ with ExitStack() as resources:
751
+ if command in {"init", "migrate", "retry", "delete-all"}:
752
+ report(progress, "Acquiring writer lock")
753
+ lease = WriterLease.for_config(home, cfg)
754
+ resources.callback(lease.close)
755
+ embedder = _open_embedder(command, cfg, resources, progress, secret_reader)
756
+ report(progress, "Connecting to target")
757
+ store = QdrantStore(build_client(cfg), cfg, embedder)
758
+ resources.callback(store.close)
759
+ ledger, action = _initialize_target(
760
+ args, store, home, cfg, pending_reset, resources, progress
761
+ )
762
+ result = execute_command(args, Runtime(cfg, store, ledger), source, progress)
763
+ if command == "init":
764
+ _init_result(result, store, action)
765
+ report(progress, "Closing resources")
766
+ if command == "migrate":
767
+ _report_migration_result(result, progress)
768
+ return result
769
+
770
+
771
+ def print_json(value, *, indent=None):
772
+ """Handle a downstream reader closing stdout without a shutdown traceback."""
773
+ try:
774
+ print(json.dumps(value, ensure_ascii=False, indent=indent), flush=True)
775
+ return True
776
+ except BrokenPipeError:
777
+ # Prevent Python's final stdout flush from failing on the same closed pipe.
778
+ descriptor = os.open(os.devnull, os.O_WRONLY)
779
+ try:
780
+ os.dup2(descriptor, sys.stdout.fileno())
781
+ finally:
782
+ os.close(descriptor)
783
+ return False
784
+
785
+
786
+ def main(args):
787
+ """Print a JSON result and return a nonzero exit code on sanitized failures."""
788
+ try:
789
+ result = run(args)
790
+ if not print_json(result, indent=2):
791
+ return 1
792
+ return 0 if result.get("ok", True) else 1
793
+ except Exception as exc:
794
+ error = safe_error(exc)
795
+ if isinstance(exc, MigrationSupersededError):
796
+ error.update(
797
+ code="migration_superseded",
798
+ message="Later user intent superseded migration records. Inspect the manifest. "
799
+ "Resuming this migration will not restore deleted memories. Only start a new "
800
+ "migration without --resume if you explicitly intend to import them again.",
801
+ )
802
+ elif isinstance(exc, MigrationIncompleteError):
803
+ error.update(
804
+ code="migration_incomplete",
805
+ message="Migration incomplete; rerun with --resume --retry-failed. Recover "
806
+ "failed work before reviewing any superseded conflict.",
807
+ )
808
+ elif isinstance(exc, WriterBusyError):
809
+ error.update(
810
+ code="writer_busy",
811
+ message="Another Hermes session or gateway owns the writer lock for this "
812
+ "profile and collection. Stop that runtime before init, migrate or retry, "
813
+ "then rerun the command. For an existing remote collection, doctor, stats "
814
+ "and verify can check readiness while the writer is running.",
815
+ )
816
+ elif isinstance(exc, CollectionNotInitializedError):
817
+ error.update(
818
+ code="collection_not_initialized",
819
+ message="Collection does not exist. Run hermes qdrant-memory init first.",
820
+ )
821
+ elif isinstance(exc, ResetRecoveryRequiredError):
822
+ error.update(
823
+ code="reset_recovery_required",
824
+ message="Rerun hermes qdrant-memory init before using this destination.",
825
+ )
826
+ elif isinstance(exc, DeleteConfirmationRequiredError):
827
+ error.update(
828
+ code="delete_confirmation_required",
829
+ message="Refusing to delete without explicit confirmation. Rerun "
830
+ "delete-all with --confirm to delete the selected scope, or with "
831
+ "--dry-run to preview its count.",
832
+ )
833
+ elif isinstance(exc, ScopeSelectionError):
834
+ error.update(code="scope_selection_error", message=str(exc))
835
+ elif isinstance(exc, ScopeDeleteRecoveryRequiredError):
836
+ error.update(
837
+ code="scope_delete_recovery_required",
838
+ message="An interrupted scoped deletion is pending. Rerun hermes "
839
+ "qdrant-memory delete-all with --confirm to resume it before using "
840
+ "this destination.",
841
+ )
842
+ elif isinstance(exc, ScopeDeleteLegacyIntentError):
843
+ error.update(code="scope_delete_legacy_intent", message=str(exc))
844
+ elif isinstance(exc, ScopeDeleteRefusedError):
845
+ error.update(
846
+ code="scope_delete_refused",
847
+ message="Scoped deletion refused. A pending scoped deletion must be "
848
+ "resumed with its recorded scope (one at a time), and --user is "
849
+ "required for a new scoped deletion.",
850
+ )
851
+ elif isinstance(exc, ScopeDeleteIncompleteError):
852
+ error.update(
853
+ code="scope_delete_incomplete",
854
+ message="Scoped deletion incomplete: the scope still contains points. "
855
+ "The durable intent remains; rerun delete-all --confirm to resume it.",
856
+ )
857
+ elif isinstance(exc, ExportTargetExistsError):
858
+ error.update(
859
+ code="export_target_exists",
860
+ message="Export target already exists; pass --force to overwrite it.",
861
+ )
862
+ elif isinstance(exc, ExportPublicationUnsupportedError):
863
+ error.update(
864
+ code="export_publication_unsupported",
865
+ message="The export target cannot be written with the atomic no-clobber "
866
+ "guarantee (hard links unavailable or the target is not writable). "
867
+ "Pass --force to write without that guarantee, or choose another target.",
868
+ )
869
+ elif isinstance(exc, InitializationChoiceRequiredError):
870
+ error.update(
871
+ code="existing_collection_choice_required",
872
+ message="Choose use or clear. For noninteractive init, pass --existing use "
873
+ "or --existing clear (deletes all collection data).",
874
+ )
875
+ elif isinstance(exc, CollectionCompatibilityError):
876
+ error.update(
877
+ code="collection_incompatible",
878
+ message="Collection is incompatible: a named dense vector with matching "
879
+ "dimensions, distance and a trusted embedding fingerprint is required. "
880
+ "Choose a different collection, or explicitly clear and rebuild with "
881
+ "hermes qdrant-memory init --existing clear (deletes all collection data).",
882
+ )
883
+ print_json({"error": error})
884
+ return 1
885
+
886
+
887
+ globals()["qdrant-memory_command"] = main