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/models.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Stable payload, identity, size and source-version contracts."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import unicodedata
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
|
|
11
|
+
NAMESPACE = uuid.UUID("0e039c91-9509-57b6-877a-e878e1d61d93")
|
|
12
|
+
UTC_OFFSET = "+00:00"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def now():
|
|
16
|
+
"""Return the current UTC instant as an RFC3339 string."""
|
|
17
|
+
return datetime.now(UTC).isoformat().replace(UTC_OFFSET, "Z")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def digest(value):
|
|
21
|
+
"""Hash canonical JSON for durable identity and verification."""
|
|
22
|
+
return hashlib.sha256(
|
|
23
|
+
json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode()
|
|
24
|
+
).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize(text):
|
|
28
|
+
"""Normalize Unicode and whitespace without changing stored display text."""
|
|
29
|
+
return " ".join(unicodedata.normalize("NFKC", text).split())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def content_hash(text):
|
|
33
|
+
"""Hash normalized text for exact scoped duplicate detection."""
|
|
34
|
+
return "sha256:" + hashlib.sha256(normalize(text).encode()).hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Scope:
|
|
39
|
+
"""Identify a memory partition by user and optional agent identity."""
|
|
40
|
+
|
|
41
|
+
user_id: str
|
|
42
|
+
agent_id: str | None
|
|
43
|
+
|
|
44
|
+
def as_dict(self):
|
|
45
|
+
"""Return the payload fields used by every scope check."""
|
|
46
|
+
return {"user_id": self.user_id, "agent_id": self.agent_id}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def point_id(scope, source, source_id):
|
|
50
|
+
"""Derive a deterministic UUID from scope, source and source identity."""
|
|
51
|
+
return str(
|
|
52
|
+
uuid.uuid5(
|
|
53
|
+
NAMESPACE,
|
|
54
|
+
json.dumps([scope.user_id, scope.agent_id, source, source_id], ensure_ascii=False),
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def timestamp(value):
|
|
60
|
+
"""Normalize a timestamp to UTC, treating naive values as UTC."""
|
|
61
|
+
if value is None:
|
|
62
|
+
return now()
|
|
63
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", UTC_OFFSET))
|
|
64
|
+
if parsed.tzinfo is None:
|
|
65
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
66
|
+
return parsed.astimezone(UTC).isoformat().replace(UTC_OFFSET, "Z")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def payload(
|
|
70
|
+
text,
|
|
71
|
+
scope,
|
|
72
|
+
source,
|
|
73
|
+
session_id="",
|
|
74
|
+
category=None,
|
|
75
|
+
importance=0.5,
|
|
76
|
+
metadata=None,
|
|
77
|
+
cloud_origin=None,
|
|
78
|
+
created_at=None,
|
|
79
|
+
updated_at=None,
|
|
80
|
+
):
|
|
81
|
+
"""Build a validated schema-v1 payload with provenance and stable text hashing."""
|
|
82
|
+
if not isinstance(text, str) or not normalize(text):
|
|
83
|
+
raise ValueError("Memory text must be nonempty")
|
|
84
|
+
if (
|
|
85
|
+
not isinstance(importance, (float, int))
|
|
86
|
+
or not math.isfinite(importance)
|
|
87
|
+
or not 0 <= importance <= 1
|
|
88
|
+
):
|
|
89
|
+
raise ValueError("Importance must be between zero and one")
|
|
90
|
+
category = category or []
|
|
91
|
+
if not isinstance(category, list) or not all(isinstance(x, str) for x in category):
|
|
92
|
+
raise ValueError("category must be an array of strings")
|
|
93
|
+
if metadata is not None and not isinstance(metadata, dict):
|
|
94
|
+
raise ValueError("metadata must be an object")
|
|
95
|
+
return {
|
|
96
|
+
"text": text,
|
|
97
|
+
**scope.as_dict(),
|
|
98
|
+
"source": source,
|
|
99
|
+
"session_id": session_id or None,
|
|
100
|
+
"category": category,
|
|
101
|
+
"importance": importance,
|
|
102
|
+
"metadata": metadata or {},
|
|
103
|
+
"cloud_origin": cloud_origin,
|
|
104
|
+
"created_at": timestamp(created_at),
|
|
105
|
+
"updated_at": timestamp(updated_at),
|
|
106
|
+
"content_hash": content_hash(text),
|
|
107
|
+
"schema_version": 1,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def enforce_limits(value, limits):
|
|
112
|
+
"""Enforce UTF-8 byte limits, rejecting oversize data unless truncation is explicit."""
|
|
113
|
+
value = dict(value)
|
|
114
|
+
if len(value["text"].encode()) > limits["max_text_bytes"]:
|
|
115
|
+
if limits["oversize_policy"] != "truncate":
|
|
116
|
+
raise ValueError("Memory exceeds max_text_bytes")
|
|
117
|
+
value["text"] = (
|
|
118
|
+
value["text"].encode()[: limits["max_text_bytes"]].decode("utf-8", errors="ignore")
|
|
119
|
+
)
|
|
120
|
+
if not normalize(value["text"]):
|
|
121
|
+
raise ValueError("Memory text must be nonempty after truncation")
|
|
122
|
+
value["content_hash"] = content_hash(value["text"])
|
|
123
|
+
value["metadata"] = {**value["metadata"], "truncated": True}
|
|
124
|
+
for key, bound in (("metadata", "max_metadata_bytes"), (None, "max_payload_bytes")):
|
|
125
|
+
if (
|
|
126
|
+
len(json.dumps(value[key] if key else value, ensure_ascii=False).encode())
|
|
127
|
+
> limits[bound]
|
|
128
|
+
):
|
|
129
|
+
raise ValueError(f"Memory exceeds {bound}")
|
|
130
|
+
return value
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Exclusive profile/destination writer ownership across provider reloads."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .models import digest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WriterBusyError(RuntimeError):
|
|
10
|
+
"""Report exclusive writer ownership without disclosing destination details."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WriterLease:
|
|
14
|
+
"""Hold an OS lock until every owned service and worker has finished."""
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def for_config(cls, home, cfg):
|
|
18
|
+
"""Exclude writers to the same physical destination across mode/URL aliases."""
|
|
19
|
+
from .config import backend_destination
|
|
20
|
+
|
|
21
|
+
q = cfg["qdrant"]
|
|
22
|
+
destination = backend_destination(q)
|
|
23
|
+
if q["mode"] == "embedded":
|
|
24
|
+
# Keep the writer lock identity stable across plugin upgrades.
|
|
25
|
+
destination = (destination[0], destination[-1])
|
|
26
|
+
return cls(home, [destination, q["collection"]])
|
|
27
|
+
|
|
28
|
+
def __init__(self, home, namespace):
|
|
29
|
+
"""Acquire without waiting so a replacement cannot overlap an old writer."""
|
|
30
|
+
# Imported lazily so provider discovery and registration stays free of
|
|
31
|
+
# runtime dependencies before Hermes has prepared them.
|
|
32
|
+
import portalocker
|
|
33
|
+
|
|
34
|
+
directory = Path(home) / "qdrant-memory"
|
|
35
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
36
|
+
descriptor = os.open(
|
|
37
|
+
directory / (digest(namespace) + ".writer.lock"), os.O_CREAT | os.O_RDWR, 0o600
|
|
38
|
+
)
|
|
39
|
+
self.file = os.fdopen(descriptor, "a")
|
|
40
|
+
try:
|
|
41
|
+
portalocker.lock(self.file, portalocker.LOCK_EX | portalocker.LOCK_NB)
|
|
42
|
+
except portalocker.LockException as exc:
|
|
43
|
+
self.file.close()
|
|
44
|
+
raise WriterBusyError("Another writer still owns this profile destination") from exc
|
|
45
|
+
|
|
46
|
+
def close(self):
|
|
47
|
+
"""Release ownership only after the owner's other cleanup callbacks."""
|
|
48
|
+
self.file.close()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
name: qdrant-memory
|
|
2
|
+
version: 0.2.0
|
|
3
|
+
description: Durable native memory with Qdrant and Hermes LLM extraction
|
|
4
|
+
manifest_version: 2
|
|
5
|
+
author: Kang Liu
|
|
6
|
+
license: MIT
|
|
7
|
+
homepage: https://github.com/cnkang/hermes-plugin-qdrant-memory
|
|
8
|
+
tags: [memory, qdrant, vector-search]
|
|
9
|
+
python_dependencies:
|
|
10
|
+
- "qdrant-client>=1.15,<2"
|
|
11
|
+
- "httpx>=0.28,<1"
|
|
12
|
+
- "portalocker>=3.2,<4"
|
|
13
|
+
provides_hooks: []
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Bounded, content-free migration diagnostics on stderr."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from threading import Event, Lock, Thread
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def report(progress, stage, completed=None, total=None):
|
|
9
|
+
"""Notify an optional observer without including source data or credentials."""
|
|
10
|
+
if progress is not None:
|
|
11
|
+
try:
|
|
12
|
+
progress(stage, completed, total)
|
|
13
|
+
except Exception:
|
|
14
|
+
# Diagnostics must not change the outcome of durable work.
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MigrationProgress:
|
|
19
|
+
"""Show phase changes and periodic waiting messages even during blocked I/O."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, quiet=False, interval=10.0):
|
|
22
|
+
"""Configure a reporter; resolve stderr at construction for CLI capture."""
|
|
23
|
+
self.quiet = quiet
|
|
24
|
+
self.interval = interval
|
|
25
|
+
self.stream = sys.stderr
|
|
26
|
+
self.started = self.changed = time.monotonic()
|
|
27
|
+
self.printed = float("-inf")
|
|
28
|
+
self.stage = "Starting migration"
|
|
29
|
+
self.completed = self.total = None
|
|
30
|
+
self.lock = Lock()
|
|
31
|
+
self.stop = Event()
|
|
32
|
+
self.thread = None
|
|
33
|
+
|
|
34
|
+
def __enter__(self):
|
|
35
|
+
"""Start the waiting reporter before configuration or external calls."""
|
|
36
|
+
self(self.stage)
|
|
37
|
+
if not self.quiet:
|
|
38
|
+
self.thread = Thread(target=self._heartbeat, daemon=True)
|
|
39
|
+
self.thread.start()
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def __exit__(self, exc_type, exc, traceback):
|
|
43
|
+
"""Stop and join the reporter on success, failure, or interruption."""
|
|
44
|
+
self.stop.set()
|
|
45
|
+
if self.thread is not None:
|
|
46
|
+
self.thread.join()
|
|
47
|
+
if exc_type is not None:
|
|
48
|
+
try:
|
|
49
|
+
self("Migration stopped; see error output or rerun with --resume")
|
|
50
|
+
except Exception:
|
|
51
|
+
# Preserve the original migration failure after reporter shutdown.
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
def __call__(self, stage, completed=None, total=None):
|
|
55
|
+
"""Throttle record updates, but always show phase starts and completions."""
|
|
56
|
+
with self.lock:
|
|
57
|
+
changed = stage != self.stage
|
|
58
|
+
self.stage, self.completed, self.total = stage, completed, total
|
|
59
|
+
self.changed = time.monotonic()
|
|
60
|
+
if (
|
|
61
|
+
changed
|
|
62
|
+
or self.changed - self.printed >= 1
|
|
63
|
+
or (total is not None and completed == total)
|
|
64
|
+
):
|
|
65
|
+
self._write()
|
|
66
|
+
|
|
67
|
+
def _write(self, waiting=False):
|
|
68
|
+
"""Flush one safe line; a closed diagnostic stream must not abort writes."""
|
|
69
|
+
if self.quiet:
|
|
70
|
+
return
|
|
71
|
+
current = time.monotonic()
|
|
72
|
+
counts = "" if self.total is None else f" {self.completed}/{self.total}"
|
|
73
|
+
idle = f"; waiting, no progress for {current - self.changed:.0f}s" if waiting else ""
|
|
74
|
+
try:
|
|
75
|
+
print(
|
|
76
|
+
f"[qdrant-memory +{current - self.started:.0f}s] {self.stage}{counts}{idle}",
|
|
77
|
+
file=self.stream,
|
|
78
|
+
flush=True,
|
|
79
|
+
)
|
|
80
|
+
except (OSError, ValueError):
|
|
81
|
+
self.quiet = True
|
|
82
|
+
self.printed = current
|
|
83
|
+
|
|
84
|
+
def _heartbeat(self):
|
|
85
|
+
"""Report inactivity without claiming that a stalled request is progressing."""
|
|
86
|
+
while not self.stop.wait(self.interval):
|
|
87
|
+
with self.lock:
|
|
88
|
+
if time.monotonic() - self.printed >= self.interval:
|
|
89
|
+
self._write(waiting=True)
|