engram-memory-vault 1.8.1__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.
- engram/__init__.py +7 -0
- engram/acl.py +91 -0
- engram/audit.py +54 -0
- engram/bench.py +88 -0
- engram/cli.py +968 -0
- engram/crypto.py +229 -0
- engram/dash.py +672 -0
- engram/data/hermes-plugin/__init__.py +310 -0
- engram/data/hermes-plugin/plugin.yaml +7 -0
- engram/data/starter.mpack +0 -0
- engram/embed.py +140 -0
- engram/longmemeval.py +281 -0
- engram/models/bge-small-en-v1.5-int8/model_quantized.onnx +0 -0
- engram/models/bge-small-en-v1.5-int8/tokenizer.json +30672 -0
- engram/offline_guard.py +52 -0
- engram/packs.py +240 -0
- engram/platforms.py +144 -0
- engram/salience.py +128 -0
- engram/selftest.py +74 -0
- engram/server.py +292 -0
- engram/session.py +106 -0
- engram/store.py +287 -0
- engram/vault.py +796 -0
- engram/vaultfile.py +258 -0
- engram/vindex.py +124 -0
- engram_memory_vault-1.8.1.dist-info/METADATA +296 -0
- engram_memory_vault-1.8.1.dist-info/RECORD +31 -0
- engram_memory_vault-1.8.1.dist-info/WHEEL +5 -0
- engram_memory_vault-1.8.1.dist-info/entry_points.txt +2 -0
- engram_memory_vault-1.8.1.dist-info/licenses/LICENSE +21 -0
- engram_memory_vault-1.8.1.dist-info/top_level.txt +1 -0
engram/__init__.py
ADDED
engram/acl.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Per-caller namespace access control + vault-adjacent settings.
|
|
2
|
+
|
|
3
|
+
Config lives NEXT to the vault as `<vault>.config.json` (it contains no
|
|
4
|
+
secrets - only ACLs and preferences). `packs/*` namespaces are ALWAYS
|
|
5
|
+
read-only for every caller, including "*". Violations are hard errors.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from .crypto import CryptoError
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG = {
|
|
16
|
+
"callers": {
|
|
17
|
+
"*": {"default_namespace": "main", "grants": {"*": "rw"}},
|
|
18
|
+
},
|
|
19
|
+
"settings": {
|
|
20
|
+
"auto_lock_minutes": 30,
|
|
21
|
+
"include_packs_in_search": True,
|
|
22
|
+
"unlock_tool_enabled": False,
|
|
23
|
+
"duplicate_threshold": 0.97,
|
|
24
|
+
"index_precision": "f32",
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AclError(CryptoError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class VaultConfig:
|
|
35
|
+
callers: dict = field(default_factory=lambda: DEFAULT_CONFIG["callers"].copy())
|
|
36
|
+
settings: dict = field(default_factory=lambda: DEFAULT_CONFIG["settings"].copy())
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def path_for(vault_path: str) -> str:
|
|
40
|
+
return vault_path + ".config.json"
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def load(cls, vault_path: str) -> "VaultConfig":
|
|
44
|
+
p = cls.path_for(vault_path)
|
|
45
|
+
if not os.path.exists(p):
|
|
46
|
+
return cls()
|
|
47
|
+
data = json.loads(open(p).read())
|
|
48
|
+
cfg = cls()
|
|
49
|
+
cfg.callers = data.get("callers", cfg.callers)
|
|
50
|
+
cfg.settings = {**cfg.settings, **data.get("settings", {})}
|
|
51
|
+
return cfg
|
|
52
|
+
|
|
53
|
+
def save(self, vault_path: str) -> None:
|
|
54
|
+
with open(self.path_for(vault_path), "w") as f:
|
|
55
|
+
json.dump({"callers": self.callers, "settings": self.settings}, f, indent=2)
|
|
56
|
+
|
|
57
|
+
# -- ACL ---------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _caller_entry(self, caller: str) -> dict:
|
|
60
|
+
entry = self.callers.get(caller) or self.callers.get("*")
|
|
61
|
+
if entry is None:
|
|
62
|
+
raise AclError(f"Caller {caller!r} has no access to this vault")
|
|
63
|
+
return entry
|
|
64
|
+
|
|
65
|
+
def default_namespace(self, caller: str) -> str:
|
|
66
|
+
return self._caller_entry(caller).get("default_namespace", "main")
|
|
67
|
+
|
|
68
|
+
def grant_for(self, caller: str, namespace: str) -> str:
|
|
69
|
+
"""Returns 'rw', 'ro', or raises AclError. packs/* is always ro."""
|
|
70
|
+
entry = self._caller_entry(caller)
|
|
71
|
+
grants = entry.get("grants", {})
|
|
72
|
+
grant = grants.get(namespace)
|
|
73
|
+
if grant is None:
|
|
74
|
+
for pattern, g in grants.items():
|
|
75
|
+
if pattern.endswith("*") and namespace.startswith(pattern[:-1]):
|
|
76
|
+
grant = g
|
|
77
|
+
break
|
|
78
|
+
if grant is None:
|
|
79
|
+
grant = grants.get("*")
|
|
80
|
+
if grant is None:
|
|
81
|
+
raise AclError(
|
|
82
|
+
f"Caller {caller!r} is not granted access to namespace {namespace!r}")
|
|
83
|
+
if namespace.startswith("packs/"):
|
|
84
|
+
return "ro" # pack namespaces are immutable for everyone
|
|
85
|
+
return grant
|
|
86
|
+
|
|
87
|
+
def check(self, caller: str, namespace: str, write: bool) -> None:
|
|
88
|
+
grant = self.grant_for(caller, namespace)
|
|
89
|
+
if write and grant != "rw":
|
|
90
|
+
raise AclError(
|
|
91
|
+
f"Caller {caller!r} has read-only access to namespace {namespace!r}")
|
engram/audit.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Hash-chained, tamper-evident audit log (stored inside the sealed payload).
|
|
2
|
+
|
|
3
|
+
Each entry's hash covers the previous entry's hash, so any edit, deletion,
|
|
4
|
+
or reordering of history breaks the chain at a detectable point.
|
|
5
|
+
A failure to write the audit entry fails the operation (fail-fast), never
|
|
6
|
+
the other way around.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
GENESIS = "GENESIS"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _entry_hash(prev_hash: str, ts: float, caller: str, op: str, detail: str) -> str:
|
|
18
|
+
body = json.dumps(
|
|
19
|
+
{"prev": prev_hash, "ts": ts, "caller": caller, "op": op, "detail": detail},
|
|
20
|
+
sort_keys=True, separators=(",", ":"),
|
|
21
|
+
).encode()
|
|
22
|
+
return hashlib.sha256(body).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def append(conn, caller: str, op: str, detail: str) -> str:
|
|
26
|
+
row = conn.execute("SELECT hash FROM audit ORDER BY seq DESC LIMIT 1").fetchone()
|
|
27
|
+
prev = row["hash"] if row else GENESIS
|
|
28
|
+
ts = time.time()
|
|
29
|
+
h = _entry_hash(prev, ts, caller, op, detail)
|
|
30
|
+
conn.execute(
|
|
31
|
+
"INSERT INTO audit (ts, caller, op, detail, prev_hash, hash) VALUES (?,?,?,?,?,?)",
|
|
32
|
+
(ts, caller, op, detail, prev, h),
|
|
33
|
+
)
|
|
34
|
+
return h
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def head(conn) -> str:
|
|
38
|
+
row = conn.execute("SELECT hash FROM audit ORDER BY seq DESC LIMIT 1").fetchone()
|
|
39
|
+
return row["hash"] if row else GENESIS
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def verify(conn) -> tuple[bool, int, str]:
|
|
43
|
+
"""Walk the chain. Returns (ok, entries_checked, message)."""
|
|
44
|
+
prev = GENESIS
|
|
45
|
+
n = 0
|
|
46
|
+
for row in conn.execute("SELECT * FROM audit ORDER BY seq"):
|
|
47
|
+
if row["prev_hash"] != prev:
|
|
48
|
+
return False, n, f"chain break at seq {row['seq']}: prev_hash mismatch"
|
|
49
|
+
want = _entry_hash(prev, row["ts"], row["caller"], row["op"], row["detail"])
|
|
50
|
+
if row["hash"] != want:
|
|
51
|
+
return False, n, f"chain break at seq {row['seq']}: entry hash mismatch"
|
|
52
|
+
prev = row["hash"]
|
|
53
|
+
n += 1
|
|
54
|
+
return True, n, f"audit chain intact ({n} entries)"
|
engram/bench.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Performance + RAM benchmark against the seed pack and a synthetic corpus.
|
|
2
|
+
|
|
3
|
+
Reports embed/store/search latencies (p50/p95) and peak RSS, and checks the
|
|
4
|
+
8GB-baseline budgets: search p95 < 100ms at the synthetic scale, total RSS
|
|
5
|
+
< 1GB at 100k records with default settings.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import random
|
|
10
|
+
import resource
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from .vindex import build_index
|
|
17
|
+
|
|
18
|
+
WORDS = ("report vault memory agent record office data schedule market key "
|
|
19
|
+
"index search secure backup ledger review batch upload form note").split()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _rss_mb() -> float:
|
|
23
|
+
rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
24
|
+
return rss / (1024 * 1024) if sys.platform == "darwin" else rss / 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _pct(xs: list[float], p: float) -> float:
|
|
28
|
+
xs = sorted(xs)
|
|
29
|
+
return xs[min(len(xs) - 1, int(len(xs) * p))]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run(vault, synthetic_n: int = 20_000, queries: int = 50) -> dict:
|
|
33
|
+
rng = random.Random(42)
|
|
34
|
+
out: dict = {"synthetic_records": synthetic_n}
|
|
35
|
+
|
|
36
|
+
# 1) real embed+store latency on a small sample
|
|
37
|
+
t_store = []
|
|
38
|
+
for i in range(20):
|
|
39
|
+
text = f"benchmark memory {i}: " + " ".join(rng.choices(WORDS, k=10))
|
|
40
|
+
t0 = time.perf_counter()
|
|
41
|
+
vault.store(text, caller="bench", namespace="bench", tags=["bench"])
|
|
42
|
+
t_store.append((time.perf_counter() - t0) * 1000)
|
|
43
|
+
out["store_ms_p50"] = round(_pct(t_store, 0.50), 1)
|
|
44
|
+
out["store_ms_p95"] = round(_pct(t_store, 0.95), 1)
|
|
45
|
+
|
|
46
|
+
# 2) synthetic vector corpus at scale (index-only: isolates search speed)
|
|
47
|
+
dim = int(vault.header.model["dim"])
|
|
48
|
+
mat = np.random.default_rng(42).standard_normal((synthetic_n, dim)).astype(np.float32)
|
|
49
|
+
mat /= np.linalg.norm(mat, axis=1, keepdims=True)
|
|
50
|
+
t0 = time.perf_counter()
|
|
51
|
+
idx = build_index(dim, list(range(1, synthetic_n + 1)), mat,
|
|
52
|
+
precision=vault.config.settings.get("index_precision", "f32"),
|
|
53
|
+
force=None)
|
|
54
|
+
out["index_build_s"] = round(time.perf_counter() - t0, 2)
|
|
55
|
+
out["index_kind"] = idx.kind
|
|
56
|
+
|
|
57
|
+
t_search = []
|
|
58
|
+
for _ in range(queries):
|
|
59
|
+
q = mat[rng.randrange(synthetic_n)]
|
|
60
|
+
t0 = time.perf_counter()
|
|
61
|
+
idx.search(q, 10)
|
|
62
|
+
t_search.append((time.perf_counter() - t0) * 1000)
|
|
63
|
+
out["vector_search_ms_p50"] = round(_pct(t_search, 0.50), 2)
|
|
64
|
+
out["vector_search_ms_p95"] = round(_pct(t_search, 0.95), 2)
|
|
65
|
+
|
|
66
|
+
# 3) full hybrid search on the live vault (embed + vector + FTS + fuse)
|
|
67
|
+
t_hybrid = []
|
|
68
|
+
for _ in range(20):
|
|
69
|
+
t0 = time.perf_counter()
|
|
70
|
+
vault.search("benchmark memory " + rng.choice(WORDS), caller="bench", top_k=8)
|
|
71
|
+
t_hybrid.append((time.perf_counter() - t0) * 1000)
|
|
72
|
+
out["hybrid_search_ms_p50"] = round(_pct(t_hybrid, 0.50), 1)
|
|
73
|
+
out["hybrid_search_ms_p95"] = round(_pct(t_hybrid, 0.95), 1)
|
|
74
|
+
|
|
75
|
+
out["peak_rss_mb"] = round(_rss_mb(), 0)
|
|
76
|
+
out["budgets"] = {
|
|
77
|
+
"vector_search_p95_under_100ms": out["vector_search_ms_p95"] < 100,
|
|
78
|
+
"rss_under_1gb": out["peak_rss_mb"] < 1024,
|
|
79
|
+
}
|
|
80
|
+
# clean up bench records
|
|
81
|
+
rows = vault.db.conn.execute(
|
|
82
|
+
"SELECT id, ikey FROM records WHERE ns = 'bench'").fetchall()
|
|
83
|
+
for r in rows:
|
|
84
|
+
vault.db.delete(r["id"], shred=False)
|
|
85
|
+
vault.index.remove(r["ikey"])
|
|
86
|
+
vault._id_by_ikey.pop(r["ikey"], None)
|
|
87
|
+
vault.save()
|
|
88
|
+
return out
|