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.
- hermes_plugin_qdrant_memory-0.2.0.dist-info/METADATA +495 -0
- hermes_plugin_qdrant_memory-0.2.0.dist-info/RECORD +29 -0
- hermes_plugin_qdrant_memory-0.2.0.dist-info/WHEEL +5 -0
- hermes_plugin_qdrant_memory-0.2.0.dist-info/entry_points.txt +5 -0
- hermes_plugin_qdrant_memory-0.2.0.dist-info/licenses/LICENSE +21 -0
- hermes_plugin_qdrant_memory-0.2.0.dist-info/top_level.txt +1 -0
- qdrant_memory/__init__.py +33 -0
- qdrant_memory/cli.py +887 -0
- qdrant_memory/config.py +313 -0
- qdrant_memory/config_schema.py +111 -0
- qdrant_memory/dedupe.py +26 -0
- qdrant_memory/embedding.py +114 -0
- qdrant_memory/extraction.py +90 -0
- qdrant_memory/inventory.py +153 -0
- qdrant_memory/ledger.py +686 -0
- qdrant_memory/mcp_server.py +57 -0
- qdrant_memory/migration.py +525 -0
- qdrant_memory/models.py +130 -0
- qdrant_memory/ownership.py +48 -0
- qdrant_memory/plugin.yaml +13 -0
- qdrant_memory/progress.py +89 -0
- qdrant_memory/provider.py +568 -0
- qdrant_memory/qdrant_store.py +332 -0
- qdrant_memory/reset.py +317 -0
- qdrant_memory/retry.py +51 -0
- qdrant_memory/runtime.py +314 -0
- qdrant_memory/standalone.py +203 -0
- qdrant_memory/tools.py +81 -0
- qdrant_memory/version.py +3 -0
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
|