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.
- memd/__init__.py +7 -0
- memd/cli.py +699 -0
- memd/core/__init__.py +31 -0
- memd/core/schema.py +343 -0
- memd/engine/__init__.py +0 -0
- memd/engine/forward.py +1175 -0
- memd/engine/memory.py +3005 -0
- memd/harness/core.py +257 -0
- memd/harness/run.py +216 -0
- memd/harness/suites/__init__.py +0 -0
- memd/harness/suites/adversarial.py +113 -0
- memd/harness/suites/halumem_ops.py +54 -0
- memd/harness/suites/longmemeval_synthetic.py +221 -0
- memd/hosted/__init__.py +20 -0
- memd/hosted/app.py +258 -0
- memd/hosted/billing.py +909 -0
- memd/hosted/metering.py +460 -0
- memd/hosted/plans.py +138 -0
- memd/hosted/store.py +830 -0
- memd/index/__init__.py +3 -0
- memd/index/ann_usearch.py +1598 -0
- memd/index/sqlite_index.py +2413 -0
- memd/index/tantivy_lexical.py +886 -0
- memd/metrics.py +591 -0
- memd/pipeline/__init__.py +0 -0
- memd/pipeline/consolidation.py +174 -0
- memd/pipeline/embedder.py +370 -0
- memd/pipeline/extractor.py +567 -0
- memd/query/__init__.py +0 -0
- memd/query/dates.py +212 -0
- memd/query/fusion.py +90 -0
- memd/query/packing.py +671 -0
- memd/query/planner.py +76 -0
- memd/query/rerank.py +532 -0
- memd/sdk/__init__.py +3 -0
- memd/sdk/client.py +292 -0
- memd/server/__init__.py +0 -0
- memd/server/auth.py +423 -0
- memd/server/cluster.py +748 -0
- memd/server/http.py +976 -0
- memd/server/mcp_server.py +147 -0
- memd/storage/__init__.py +3 -0
- memd/storage/audit.py +504 -0
- memd/storage/crypto.py +1313 -0
- memd/storage/engine.py +5331 -0
- memd/storage/objectstore.py +597 -0
- memd/storage/replica.py +1040 -0
- memd/storage/s3store.py +1450 -0
- memd_engine-0.5.0.dist-info/METADATA +1230 -0
- memd_engine-0.5.0.dist-info/RECORD +54 -0
- memd_engine-0.5.0.dist-info/WHEEL +5 -0
- memd_engine-0.5.0.dist-info/entry_points.txt +2 -0
- memd_engine-0.5.0.dist-info/licenses/LICENSE +202 -0
- 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
|
memd/engine/__init__.py
ADDED
|
File without changes
|