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 ADDED
@@ -0,0 +1,7 @@
1
+ """engRAM - high-security, fully offline, encrypted vector memory for AI agents."""
2
+
3
+ __version__ = "1.8.1"
4
+
5
+ from . import offline_guard as _og
6
+
7
+ _og.activate_from_env()
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