memd-engine 0.5.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.
Files changed (54) hide show
  1. memd/__init__.py +7 -0
  2. memd/cli.py +699 -0
  3. memd/core/__init__.py +31 -0
  4. memd/core/schema.py +343 -0
  5. memd/engine/__init__.py +0 -0
  6. memd/engine/forward.py +1175 -0
  7. memd/engine/memory.py +3005 -0
  8. memd/harness/core.py +257 -0
  9. memd/harness/run.py +216 -0
  10. memd/harness/suites/__init__.py +0 -0
  11. memd/harness/suites/adversarial.py +113 -0
  12. memd/harness/suites/halumem_ops.py +54 -0
  13. memd/harness/suites/longmemeval_synthetic.py +221 -0
  14. memd/hosted/__init__.py +20 -0
  15. memd/hosted/app.py +258 -0
  16. memd/hosted/billing.py +909 -0
  17. memd/hosted/metering.py +460 -0
  18. memd/hosted/plans.py +138 -0
  19. memd/hosted/store.py +830 -0
  20. memd/index/__init__.py +3 -0
  21. memd/index/ann_usearch.py +1598 -0
  22. memd/index/sqlite_index.py +2413 -0
  23. memd/index/tantivy_lexical.py +886 -0
  24. memd/metrics.py +591 -0
  25. memd/pipeline/__init__.py +0 -0
  26. memd/pipeline/consolidation.py +174 -0
  27. memd/pipeline/embedder.py +370 -0
  28. memd/pipeline/extractor.py +567 -0
  29. memd/query/__init__.py +0 -0
  30. memd/query/dates.py +212 -0
  31. memd/query/fusion.py +90 -0
  32. memd/query/packing.py +671 -0
  33. memd/query/planner.py +76 -0
  34. memd/query/rerank.py +532 -0
  35. memd/sdk/__init__.py +3 -0
  36. memd/sdk/client.py +292 -0
  37. memd/server/__init__.py +0 -0
  38. memd/server/auth.py +423 -0
  39. memd/server/cluster.py +748 -0
  40. memd/server/http.py +976 -0
  41. memd/server/mcp_server.py +147 -0
  42. memd/storage/__init__.py +3 -0
  43. memd/storage/audit.py +504 -0
  44. memd/storage/crypto.py +1313 -0
  45. memd/storage/engine.py +5331 -0
  46. memd/storage/objectstore.py +597 -0
  47. memd/storage/replica.py +1040 -0
  48. memd/storage/s3store.py +1450 -0
  49. memd_engine-0.5.0.dist-info/METADATA +1230 -0
  50. memd_engine-0.5.0.dist-info/RECORD +54 -0
  51. memd_engine-0.5.0.dist-info/WHEEL +5 -0
  52. memd_engine-0.5.0.dist-info/entry_points.txt +2 -0
  53. memd_engine-0.5.0.dist-info/licenses/LICENSE +202 -0
  54. memd_engine-0.5.0.dist-info/top_level.txt +1 -0
memd/core/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ from memd.core.schema import (
2
+ ExtractorInfo,
3
+ Kind,
4
+ LinkType,
5
+ MemoryRecord,
6
+ Provenance,
7
+ Scope,
8
+ Source,
9
+ TimeAxis,
10
+ now_ms,
11
+ records_from_jsonl,
12
+ records_to_jsonl,
13
+ ulid_new,
14
+ ulid_ts_ms,
15
+ )
16
+
17
+ __all__ = [
18
+ "ExtractorInfo",
19
+ "Kind",
20
+ "LinkType",
21
+ "MemoryRecord",
22
+ "Provenance",
23
+ "Scope",
24
+ "Source",
25
+ "TimeAxis",
26
+ "now_ms",
27
+ "records_from_jsonl",
28
+ "records_to_jsonl",
29
+ "ulid_new",
30
+ "ulid_ts_ms",
31
+ ]
memd/core/schema.py ADDED
@@ -0,0 +1,343 @@
1
+ """memd.core.schema - the memory record model.
2
+
3
+ A memory is an immutable, provenance-carrying, bitemporal record.
4
+ Two lanes share one schema: raw events (verbatim capture) and derived
5
+ records (facts, links, procedures, summaries, pins).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import secrets
12
+ import time as _time
13
+ from dataclasses import dataclass, field, replace
14
+ from enum import IntEnum
15
+ from typing import Any, Iterable, Sequence
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # ULID: 48-bit ms timestamp + 80 random bits, Crockford base32, sortable.
19
+
20
+ _B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
21
+
22
+
23
+ def ulid_new(ts_ms: int | None = None) -> str:
24
+ if ts_ms is None:
25
+ ts_ms = int(_time.time() * 1000)
26
+ val = (ts_ms & 0xFFFFFFFFFFFF) << 80
27
+ val |= int.from_bytes(secrets.token_bytes(10), "big")
28
+ out = []
29
+ for shift in range(125, -5, -5):
30
+ out.append(_B32[(val >> shift) & 0x1F])
31
+ return "".join(out)
32
+
33
+
34
+ def ulid_ts_ms(u: str) -> int:
35
+ v = 0
36
+ for c in u[:10]:
37
+ v = (v << 5) | _B32.index(c)
38
+ return v
39
+
40
+
41
+ def now_ms() -> int:
42
+ return int(_time.time() * 1000)
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Trust tiers: user > agent > tool > web > import.
47
+
48
+
49
+ class Source(IntEnum):
50
+ USER = 5
51
+ AGENT = 4
52
+ TOOL = 3
53
+ WEB = 2
54
+ IMPORT = 1
55
+
56
+ @classmethod
57
+ def parse(cls, v: Any) -> "Source":
58
+ if isinstance(v, cls):
59
+ return v
60
+ s = str(v).strip().upper()
61
+ try:
62
+ return cls[s]
63
+ except KeyError:
64
+ raise ValueError(f"unknown source {v!r}; expected one of {[m.name for m in cls]}")
65
+
66
+
67
+ class Kind:
68
+ RAW_EVENT = "raw_event"
69
+ FACT = "fact"
70
+ LINK = "link"
71
+ PROCEDURE = "procedure"
72
+ SUMMARY = "summary"
73
+ PIN = "pin"
74
+ ALL = (RAW_EVENT, FACT, LINK, PROCEDURE, SUMMARY, PIN)
75
+
76
+
77
+ class LinkType:
78
+ SAME_ENTITY = "same_entity"
79
+ CAUSED_BY = "caused_by"
80
+ REFERS_TO = "refers_to"
81
+ FOLLOWS = "follows"
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Scope: hierarchical tenancy inside a namespace. A query at scope S sees
86
+ # records whose scope is S or an ancestor of S (session -> user -> agent -> org).
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class Scope:
91
+ org: str | None = None
92
+ agent: str | None = None
93
+ user: str | None = None
94
+ session: str | None = None
95
+
96
+ def normalized(self) -> "Scope":
97
+ return self
98
+
99
+ def contains(self, other: "Scope") -> bool:
100
+ """True if `other` (a record's scope) is visible from `self` (the query
101
+ scope). A record binds only the components it sets; the query must
102
+ match those or leave them unconstrained. So a session query sees its
103
+ session + ancestors; a user query sees all that user's sessions.
104
+
105
+ `session` is the exception, and it is a security-relevant one. The
106
+ symmetric "either side unset means match" rule treated an UNSET user
107
+ on the record as "belongs to everyone", so a record captured with only
108
+ a session id - `add(content, session_id=...)` with no user_id, the most
109
+ natural way an agent logs a turn before it knows who it is talking to -
110
+ was returned to EVERY other user's scoped query in the namespace.
111
+ Reproduced: a record written under session s_bob came back from
112
+ `search(..., user_id="alice")`.
113
+
114
+ Session is the narrowest level in the hierarchy, so binding it can only
115
+ ever restrict. A record bound to a session is therefore visible to that
116
+ session, or - when the record also names its owning user - to that
117
+ user. It is never visible to a different user, and never to a query
118
+ that names no one.
119
+ """
120
+ for f in ("org", "agent", "user"):
121
+ mine = getattr(self, f)
122
+ theirs = getattr(other, f)
123
+ if mine is not None and theirs is not None and mine != theirs:
124
+ return False
125
+ # session narrows, as it always did: a query naming a session does
126
+ # not see a different session's records
127
+ if (self.session is not None and other.session is not None
128
+ and self.session != other.session):
129
+ return False
130
+ # ...and a session-bound record that names NO owning user is private
131
+ # to that session. This is the half that was missing: such a record
132
+ # was treated as belonging to everyone.
133
+ if other.session is not None and other.user is None:
134
+ if (self.session != other.session
135
+ and (self.user is not None or self.session is not None)):
136
+ # An entirely unconstrained query (embedded single-user mode,
137
+ # admin sweeps) still sees everything - it asserts no
138
+ # restriction rather than claiming an identity.
139
+ return False
140
+ return True
141
+
142
+ def is_within(self, ancestor: "Scope") -> bool:
143
+ return ancestor.contains(self)
144
+
145
+ def to_dict(self) -> dict[str, str]:
146
+ return {k: v for k, v in self.__dict__.items() if v is not None}
147
+
148
+ @classmethod
149
+ def from_dict(cls, d: dict | None) -> "Scope":
150
+ d = d or {}
151
+ return cls(org=d.get("org"), agent=d.get("agent"), user=d.get("user"), session=d.get("session"))
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Provenance & time.
156
+
157
+
158
+ @dataclass
159
+ class ExtractorInfo:
160
+ model: str
161
+ prompt_version: str
162
+
163
+ def to_dict(self) -> dict:
164
+ return {"model": self.model, "prompt_version": self.prompt_version}
165
+
166
+ @classmethod
167
+ def from_dict(cls, d: dict | None) -> "ExtractorInfo | None":
168
+ if not d:
169
+ return None
170
+ return cls(model=d.get("model", ""), prompt_version=d.get("prompt_version", ""))
171
+
172
+
173
+ @dataclass
174
+ class Provenance:
175
+ source: Source
176
+ actor_id: str | None = None
177
+ session_id: str | None = None
178
+ lineage: list[str] = field(default_factory=list) # record ids this was derived from
179
+ extractor: ExtractorInfo | None = None
180
+
181
+ def to_dict(self) -> dict:
182
+ return {
183
+ "source": self.source.name.lower(),
184
+ "actor_id": self.actor_id,
185
+ "session_id": self.session_id,
186
+ "lineage": list(self.lineage),
187
+ "extractor": self.extractor.to_dict() if self.extractor else None,
188
+ }
189
+
190
+ @classmethod
191
+ def from_dict(cls, d: dict | None) -> "Provenance":
192
+ d = d or {}
193
+ ex = ExtractorInfo.from_dict(d.get("extractor"))
194
+ lineage = d.get("lineage") or []
195
+ src = d.get("source", "import")
196
+ return cls(
197
+ source=src if isinstance(src, Source) else Source.parse(src),
198
+ actor_id=d.get("actor_id"),
199
+ session_id=d.get("session_id"),
200
+ lineage=[str(x) for x in lineage],
201
+ extractor=ex,
202
+ )
203
+
204
+
205
+ @dataclass
206
+ class TimeAxis:
207
+ t_event: int # when it happened / is true about the world
208
+ t_ingested: int # when we learned it
209
+ valid_from: int | None = None # fact validity window start
210
+ invalidated_at: int | None = None # fact validity window end (supersedence)
211
+ superseded_by: str | None = None # id of replacing record
212
+
213
+ def to_dict(self) -> dict:
214
+ return {
215
+ "t_event": self.t_event,
216
+ "t_ingested": self.t_ingested,
217
+ "valid_from": self.valid_from,
218
+ "invalidated_at": self.invalidated_at,
219
+ "superseded_by": self.superseded_by,
220
+ }
221
+
222
+ @classmethod
223
+ def from_dict(cls, d: dict | None) -> "TimeAxis":
224
+ d = d or {}
225
+ now = now_ms()
226
+ return cls(
227
+ t_event=int(d.get("t_event", now)),
228
+ t_ingested=int(d.get("t_ingested", now)),
229
+ valid_from=d.get("valid_from"),
230
+ invalidated_at=d.get("invalidated_at"),
231
+ superseded_by=d.get("superseded_by"),
232
+ )
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # The record itself.
237
+
238
+
239
+ @dataclass
240
+ class MemoryRecord:
241
+ id: str
242
+ namespace: str
243
+ kind: str
244
+ content: str
245
+ scope: Scope
246
+ provenance: Provenance
247
+ time: TimeAxis
248
+ entity_keys: list[str] = field(default_factory=list)
249
+ embedding_version: str | None = None
250
+ meta: dict[str, Any] = field(default_factory=dict)
251
+ deleted: bool = False # tombstone flag (read path filters synchronously)
252
+
253
+ @staticmethod
254
+ def create(
255
+ namespace: str,
256
+ kind: str,
257
+ content: str,
258
+ scope: Scope | None = None,
259
+ source: Source | str = Source.USER,
260
+ actor_id: str | None = None,
261
+ session_id: str | None = None,
262
+ lineage: Sequence[str] = (),
263
+ extractor: ExtractorInfo | None = None,
264
+ entity_keys: Sequence[str] = (),
265
+ t_event: int | None = None,
266
+ valid_from: int | None = None,
267
+ embedding_version: str | None = None,
268
+ meta: dict[str, Any] | None = None,
269
+ record_id: str | None = None,
270
+ ) -> "MemoryRecord":
271
+ now = now_ms()
272
+ prov = Provenance(
273
+ source=source if isinstance(source, Source) else Source.parse(source),
274
+ actor_id=actor_id,
275
+ session_id=session_id,
276
+ lineage=list(lineage),
277
+ extractor=extractor,
278
+ )
279
+ ta = TimeAxis(t_event=t_event if t_event is not None else now, t_ingested=now, valid_from=valid_from)
280
+ return MemoryRecord(
281
+ id=record_id or ulid_new(now),
282
+ namespace=namespace,
283
+ kind=kind,
284
+ content=content,
285
+ scope=scope or Scope(),
286
+ provenance=prov,
287
+ time=ta,
288
+ entity_keys=list(entity_keys),
289
+ embedding_version=embedding_version,
290
+ meta=meta or {},
291
+ )
292
+
293
+ def to_dict(self) -> dict[str, Any]:
294
+ return {
295
+ "id": self.id,
296
+ "namespace": self.namespace,
297
+ "kind": self.kind,
298
+ "content": self.content,
299
+ "scope": self.scope.to_dict(),
300
+ "provenance": self.provenance.to_dict(),
301
+ "time": self.time.to_dict(),
302
+ "entity_keys": list(self.entity_keys),
303
+ "embedding_version": self.embedding_version,
304
+ "meta": self.meta,
305
+ "deleted": self.deleted,
306
+ }
307
+
308
+ @classmethod
309
+ def from_dict(cls, d: dict[str, Any]) -> "MemoryRecord":
310
+ return cls(
311
+ id=d["id"],
312
+ namespace=d["namespace"],
313
+ kind=d["kind"],
314
+ content=d["content"],
315
+ scope=Scope.from_dict(d.get("scope")),
316
+ provenance=Provenance.from_dict(d.get("provenance")),
317
+ time=TimeAxis.from_dict(d.get("time")),
318
+ entity_keys=list(d.get("entity_keys") or []),
319
+ embedding_version=d.get("embedding_version"),
320
+ meta=dict(d.get("meta") or {}),
321
+ deleted=bool(d.get("deleted", False)),
322
+ )
323
+
324
+ def with_tombstone(self, at_ms: int | None = None) -> "MemoryRecord":
325
+ return replace(self, deleted=True, time=replace(self.time, invalidated_at=at_ms or now_ms()))
326
+
327
+ @property
328
+ def trust(self) -> int:
329
+ return int(self.provenance.source)
330
+
331
+
332
+ def records_to_jsonl(records: Iterable[MemoryRecord]) -> bytes:
333
+ lines = [r.to_dict() for r in records]
334
+ return ("\n".join(json.dumps(l, separators=(",", ":")) for l in lines) + "\n").encode()
335
+
336
+
337
+ def records_from_jsonl(data: bytes) -> list[MemoryRecord]:
338
+ out = []
339
+ for line in data.decode().splitlines():
340
+ line = line.strip()
341
+ if line:
342
+ out.append(MemoryRecord.from_dict(json.loads(line)))
343
+ return out
File without changes