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