agmem 0.1.2__py3-none-any.whl → 0.1.4__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 (48) hide show
  1. {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/METADATA +144 -14
  2. {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/RECORD +48 -28
  3. memvcs/cli.py +10 -0
  4. memvcs/commands/add.py +6 -0
  5. memvcs/commands/audit.py +59 -0
  6. memvcs/commands/clone.py +7 -0
  7. memvcs/commands/daemon.py +45 -0
  8. memvcs/commands/distill.py +24 -0
  9. memvcs/commands/federated.py +59 -0
  10. memvcs/commands/fsck.py +31 -0
  11. memvcs/commands/garden.py +22 -0
  12. memvcs/commands/gc.py +66 -0
  13. memvcs/commands/merge.py +55 -1
  14. memvcs/commands/prove.py +66 -0
  15. memvcs/commands/pull.py +27 -0
  16. memvcs/commands/resolve.py +130 -0
  17. memvcs/commands/timeline.py +27 -0
  18. memvcs/commands/verify.py +74 -23
  19. memvcs/commands/when.py +27 -0
  20. memvcs/core/audit.py +124 -0
  21. memvcs/core/compression_pipeline.py +157 -0
  22. memvcs/core/consistency.py +9 -9
  23. memvcs/core/crypto_verify.py +291 -0
  24. memvcs/core/distiller.py +47 -29
  25. memvcs/core/encryption.py +169 -0
  26. memvcs/core/federated.py +147 -0
  27. memvcs/core/gardener.py +47 -29
  28. memvcs/core/ipfs_remote.py +200 -0
  29. memvcs/core/knowledge_graph.py +77 -5
  30. memvcs/core/llm/__init__.py +10 -0
  31. memvcs/core/llm/anthropic_provider.py +50 -0
  32. memvcs/core/llm/base.py +27 -0
  33. memvcs/core/llm/factory.py +30 -0
  34. memvcs/core/llm/openai_provider.py +36 -0
  35. memvcs/core/merge.py +36 -23
  36. memvcs/core/objects.py +39 -19
  37. memvcs/core/pack.py +278 -0
  38. memvcs/core/privacy_budget.py +63 -0
  39. memvcs/core/remote.py +229 -3
  40. memvcs/core/repository.py +82 -2
  41. memvcs/core/temporal_index.py +9 -0
  42. memvcs/core/trust.py +103 -0
  43. memvcs/core/vector_store.py +15 -1
  44. memvcs/core/zk_proofs.py +158 -0
  45. {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/WHEEL +0 -0
  46. {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/entry_points.txt +0 -0
  47. {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/licenses/LICENSE +0 -0
  48. {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/top_level.txt +0 -0
memvcs/core/remote.py CHANGED
@@ -1,19 +1,24 @@
1
1
  """
2
- Remote sync for agmem - file-based push/pull/clone.
2
+ Remote sync for agmem - file-based and cloud (S3/GCS) push/pull/clone.
3
3
 
4
- Supports file:// URLs for local or mounted directories.
4
+ Supports file:// URLs and s3:///gs:// with optional distributed locking.
5
5
  """
6
6
 
7
7
  import json
8
8
  import shutil
9
9
  from pathlib import Path
10
- from typing import Optional, Set
10
+ from typing import Optional, Set, Any
11
11
  from urllib.parse import urlparse
12
12
 
13
13
  from .objects import ObjectStore, Commit, Tree, Blob, _valid_object_hash
14
14
  from .refs import RefsManager, _ref_path_under_root
15
15
 
16
16
 
17
+ def _is_cloud_remote(url: str) -> bool:
18
+ """Return True if URL is S3 or GCS (use storage adapter + optional lock)."""
19
+ return url.startswith("s3://") or url.startswith("gs://")
20
+
21
+
17
22
  def parse_remote_url(url: str) -> Path:
18
23
  """Parse remote URL to local path. Supports file:// only. Rejects path traversal."""
19
24
  parsed = urlparse(url)
@@ -62,6 +67,50 @@ def _collect_objects_from_commit(store: ObjectStore, commit_hash: str) -> Set[st
62
67
  return seen
63
68
 
64
69
 
70
+ def _read_object_from_adapter(adapter: Any, hash_id: str) -> Optional[tuple]:
71
+ """Read object from storage adapter. Returns (obj_type, content_bytes) or None."""
72
+ import zlib
73
+ for obj_type in ["commit", "tree", "blob", "tag"]:
74
+ rel = f".mem/objects/{obj_type}/{hash_id[:2]}/{hash_id[2:]}"
75
+ if not adapter.exists(rel):
76
+ continue
77
+ try:
78
+ raw = adapter.read_file(rel)
79
+ full = zlib.decompress(raw)
80
+ null_idx = full.index(b"\0")
81
+ content = full[null_idx + 1:]
82
+ return (obj_type, content)
83
+ except Exception:
84
+ continue
85
+ return None
86
+
87
+
88
+ def _collect_objects_from_commit_remote(adapter: Any, commit_hash: str) -> Set[str]:
89
+ """Collect object hashes reachable from a commit when reading from storage adapter."""
90
+ seen = set()
91
+ todo = [commit_hash]
92
+ while todo:
93
+ h = todo.pop()
94
+ if h in seen:
95
+ continue
96
+ seen.add(h)
97
+ pair = _read_object_from_adapter(adapter, h)
98
+ if pair is None:
99
+ continue
100
+ obj_type, content = pair
101
+ if obj_type == "commit":
102
+ data = json.loads(content)
103
+ todo.extend(data.get("parents", []))
104
+ if "tree" in data:
105
+ todo.append(data["tree"])
106
+ elif obj_type == "tree":
107
+ data = json.loads(content)
108
+ for e in data.get("entries", []):
109
+ if "hash" in e:
110
+ todo.append(e["hash"])
111
+ return seen
112
+
113
+
65
114
  def _list_local_objects(objects_dir: Path) -> Set[str]:
66
115
  """List all object hashes in a .mem/objects directory."""
67
116
  hashes = set()
@@ -139,6 +188,113 @@ class Remote:
139
188
  self._config["remotes"][self.name]["url"] = url
140
189
  self._save_config(self._config)
141
190
 
191
+ def _push_via_storage(self, adapter: Any, branch: Optional[str] = None) -> str:
192
+ """Push objects and refs via storage adapter. Caller must hold lock if needed."""
193
+ refs = RefsManager(self.mem_dir)
194
+ store = ObjectStore(self.objects_dir)
195
+ to_push = set()
196
+ for b in refs.list_branches():
197
+ if branch and b != branch:
198
+ continue
199
+ ch = refs.get_branch_commit(b)
200
+ if ch:
201
+ to_push.update(_collect_objects_from_commit(store, ch))
202
+ for t in refs.list_tags():
203
+ ch = refs.get_tag_commit(t)
204
+ if ch:
205
+ to_push.update(_collect_objects_from_commit(store, ch))
206
+ copied = 0
207
+ for h in to_push:
208
+ obj_type = None
209
+ for otype in ["blob", "tree", "commit", "tag"]:
210
+ p = self.objects_dir / otype / h[:2] / h[2:]
211
+ if p.exists():
212
+ obj_type = otype
213
+ break
214
+ if not obj_type:
215
+ continue
216
+ rel = f".mem/objects/{obj_type}/{h[:2]}/{h[2:]}"
217
+ if not adapter.exists(rel):
218
+ try:
219
+ data = p.read_bytes()
220
+ adapter.makedirs(f".mem/objects/{obj_type}/{h[:2]}")
221
+ adapter.write_file(rel, data)
222
+ copied += 1
223
+ except Exception:
224
+ pass
225
+ for b in refs.list_branches():
226
+ if branch and b != branch:
227
+ continue
228
+ ch = refs.get_branch_commit(b)
229
+ if ch and _ref_path_under_root(b, refs.heads_dir):
230
+ parent = str(Path(b).parent)
231
+ if parent != ".":
232
+ adapter.makedirs(f".mem/refs/heads/{parent}")
233
+ adapter.write_file(f".mem/refs/heads/{b}", (ch + "\n").encode())
234
+ for t in refs.list_tags():
235
+ ch = refs.get_tag_commit(t)
236
+ if ch and _ref_path_under_root(t, refs.tags_dir):
237
+ parent = str(Path(t).parent)
238
+ if parent != ".":
239
+ adapter.makedirs(f".mem/refs/tags/{parent}")
240
+ adapter.write_file(f".mem/refs/tags/{t}", (ch + "\n").encode())
241
+ try:
242
+ from .audit import append_audit
243
+ append_audit(self.mem_dir, "push", {"remote": self.name, "branch": branch, "copied": copied})
244
+ except Exception:
245
+ pass
246
+ return f"Pushed {copied} object(s) to {self.name}"
247
+
248
+ def _fetch_via_storage(self, adapter: Any, branch: Optional[str] = None) -> str:
249
+ """Fetch objects and refs via storage adapter. Caller must hold lock if needed."""
250
+ to_fetch = set()
251
+ try:
252
+ heads = adapter.list_dir(".mem/refs/heads")
253
+ for fi in heads:
254
+ if fi.is_dir:
255
+ continue
256
+ branch_name = fi.path.replace(".mem/refs/heads/", "").replace("\\", "/").strip("/")
257
+ if branch and branch_name != branch:
258
+ continue
259
+ data = adapter.read_file(fi.path)
260
+ ch = data.decode().strip()
261
+ if ch and _valid_object_hash(ch):
262
+ to_fetch.update(_collect_objects_from_commit_remote(adapter, ch))
263
+ tags = adapter.list_dir(".mem/refs/tags")
264
+ for fi in tags:
265
+ if fi.is_dir:
266
+ continue
267
+ data = adapter.read_file(fi.path)
268
+ ch = data.decode().strip()
269
+ if ch and _valid_object_hash(ch):
270
+ to_fetch.update(_collect_objects_from_commit_remote(adapter, ch))
271
+ except Exception:
272
+ pass
273
+ if not to_fetch:
274
+ return f"Fetched 0 object(s) from {self.name}"
275
+ local_has = _list_local_objects(self.objects_dir)
276
+ missing = to_fetch - local_has
277
+ copied = 0
278
+ for h in missing:
279
+ for otype in ["blob", "tree", "commit", "tag"]:
280
+ rel = f".mem/objects/{otype}/{h[:2]}/{h[2:]}"
281
+ if adapter.exists(rel):
282
+ try:
283
+ data = adapter.read_file(rel)
284
+ p = self.objects_dir / otype / h[:2] / h[2:]
285
+ p.parent.mkdir(parents=True, exist_ok=True)
286
+ p.write_bytes(data)
287
+ copied += 1
288
+ except Exception:
289
+ pass
290
+ break
291
+ try:
292
+ from .audit import append_audit
293
+ append_audit(self.mem_dir, "fetch", {"remote": self.name, "branch": branch, "copied": copied})
294
+ except Exception:
295
+ pass
296
+ return f"Fetched {copied} object(s) from {self.name}"
297
+
142
298
  def push(self, branch: Optional[str] = None) -> str:
143
299
  """
144
300
  Push objects and refs to remote.
@@ -148,6 +304,22 @@ class Remote:
148
304
  if not url:
149
305
  raise ValueError(f"Remote '{self.name}' has no URL configured")
150
306
 
307
+ if _is_cloud_remote(url):
308
+ try:
309
+ from .storage import get_adapter
310
+ from .storage.base import LockError
311
+ adapter = get_adapter(url, self._config)
312
+ lock_name = "agmem-push"
313
+ adapter.acquire_lock(lock_name, 30)
314
+ try:
315
+ return self._push_via_storage(adapter, branch)
316
+ finally:
317
+ adapter.release_lock(lock_name)
318
+ except LockError as e:
319
+ raise ValueError(f"Could not acquire remote lock: {e}") from e
320
+ except Exception as e:
321
+ raise ValueError(f"Push to cloud failed: {e}") from e
322
+
151
323
  remote_path = parse_remote_url(url)
152
324
  remote_mem = remote_path / ".mem"
153
325
  remote_objects = remote_mem / "objects"
@@ -164,6 +336,28 @@ class Remote:
164
336
  refs = RefsManager(self.mem_dir)
165
337
  store = ObjectStore(self.objects_dir)
166
338
 
339
+ # Push conflict detection: remote tip must be ancestor of local tip (non-fast-forward reject)
340
+ remote_heads = remote_refs / "heads"
341
+ for b in refs.list_branches():
342
+ if branch and b != branch:
343
+ continue
344
+ local_ch = refs.get_branch_commit(b)
345
+ if not local_ch:
346
+ continue
347
+ remote_branch_file = remote_heads / b
348
+ if remote_branch_file.exists():
349
+ remote_ch = remote_branch_file.read_text().strip()
350
+ if remote_ch and _valid_object_hash(remote_ch):
351
+ from .merge import MergeEngine
352
+ from .repository import Repository
353
+
354
+ repo = Repository(self.repo_path)
355
+ engine = MergeEngine(repo)
356
+ if not engine.find_common_ancestor(remote_ch, local_ch) == remote_ch:
357
+ raise ValueError(
358
+ "Push rejected: remote has diverged. Pull and merge first."
359
+ )
360
+
167
361
  # Collect objects to push
168
362
  to_push = set()
169
363
  for b in refs.list_branches():
@@ -206,6 +400,14 @@ class Remote:
206
400
  (remote_tags_dir / t).parent.mkdir(parents=True, exist_ok=True)
207
401
  (remote_tags_dir / t).write_text(ch + "\n")
208
402
 
403
+ try:
404
+ from .audit import append_audit
405
+
406
+ append_audit(
407
+ self.mem_dir, "push", {"remote": self.name, "branch": branch, "copied": copied}
408
+ )
409
+ except Exception:
410
+ pass
209
411
  return f"Pushed {copied} object(s) to {self.name}"
210
412
 
211
413
  def fetch(self, branch: Optional[str] = None) -> str:
@@ -217,6 +419,22 @@ class Remote:
217
419
  if not url:
218
420
  raise ValueError(f"Remote '{self.name}' has no URL configured")
219
421
 
422
+ if _is_cloud_remote(url):
423
+ try:
424
+ from .storage import get_adapter
425
+ from .storage.base import LockError
426
+ adapter = get_adapter(url, self._config)
427
+ lock_name = "agmem-fetch"
428
+ adapter.acquire_lock(lock_name, 30)
429
+ try:
430
+ return self._fetch_via_storage(adapter, branch)
431
+ finally:
432
+ adapter.release_lock(lock_name)
433
+ except LockError as e:
434
+ raise ValueError(f"Could not acquire remote lock: {e}") from e
435
+ except Exception as e:
436
+ raise ValueError(f"Fetch from cloud failed: {e}") from e
437
+
220
438
  remote_path = parse_remote_url(url)
221
439
  remote_objects = remote_path / ".mem" / "objects"
222
440
  remote_refs = remote_path / ".mem" / "refs"
@@ -275,4 +493,12 @@ class Remote:
275
493
  if ch:
276
494
  refs.create_tag(tag_name, ch)
277
495
 
496
+ try:
497
+ from .audit import append_audit
498
+
499
+ append_audit(
500
+ self.mem_dir, "fetch", {"remote": self.name, "branch": branch, "copied": copied}
501
+ )
502
+ except Exception:
503
+ pass
278
504
  return f"Fetched {copied} object(s) from {self.name}"
memvcs/core/repository.py CHANGED
@@ -36,7 +36,23 @@ class Repository:
36
36
 
37
37
  def _init_components(self):
38
38
  """Initialize repository components."""
39
- self.object_store = ObjectStore(self.mem_dir / "objects")
39
+ encryptor = None
40
+ try:
41
+ config = self.get_config()
42
+ if config.get("encryption", {}).get("enabled"):
43
+ from .encryption import (
44
+ load_encryption_config,
45
+ ObjectStoreEncryptor,
46
+ get_key_from_env_or_cache,
47
+ )
48
+
49
+ if load_encryption_config(self.mem_dir):
50
+ encryptor = ObjectStoreEncryptor(
51
+ lambda: get_key_from_env_or_cache(self.mem_dir)
52
+ )
53
+ except Exception:
54
+ pass
55
+ self.object_store = ObjectStore(self.mem_dir / "objects", encryptor=encryptor)
40
56
  self.staging = StagingArea(self.mem_dir)
41
57
  self.refs = RefsManager(self.mem_dir)
42
58
 
@@ -96,6 +112,14 @@ class Repository:
96
112
  # Initialize HEAD
97
113
  repo.refs.init_head("main")
98
114
 
115
+ # Tamper-evident audit
116
+ try:
117
+ from .audit import append_audit
118
+
119
+ append_audit(repo.mem_dir, "init", {"author": author_name, "branch": "main"})
120
+ except Exception:
121
+ pass
122
+
99
123
  return repo
100
124
 
101
125
  def is_valid_repo(self) -> bool:
@@ -115,6 +139,12 @@ class Repository:
115
139
  def set_config(self, config: Dict[str, Any]):
116
140
  """Set repository configuration."""
117
141
  self.config_file.write_text(json.dumps(config, indent=2))
142
+ try:
143
+ from .audit import append_audit
144
+
145
+ append_audit(self.mem_dir, "config_change", {})
146
+ except Exception:
147
+ pass
118
148
 
119
149
  def get_author(self) -> str:
120
150
  """Get the configured author string."""
@@ -298,6 +328,30 @@ class Repository:
298
328
  head_commit = self.get_head_commit()
299
329
  parents = [head_commit.store(self.object_store)] if head_commit else []
300
330
 
331
+ # Cryptographic verification: Merkle root + optional signing (private key from env)
332
+ meta = dict(metadata or {})
333
+ try:
334
+ from .crypto_verify import (
335
+ _collect_blob_hashes_from_tree,
336
+ build_merkle_tree,
337
+ load_private_key_from_env,
338
+ sign_merkle_root,
339
+ ED25519_AVAILABLE,
340
+ )
341
+ from .objects import Tree
342
+
343
+ tree = Tree.load(self.object_store, tree_hash)
344
+ if tree:
345
+ blobs = _collect_blob_hashes_from_tree(self.object_store, tree_hash)
346
+ merkle_root = build_merkle_tree(blobs)
347
+ meta["merkle_root"] = merkle_root
348
+ if ED25519_AVAILABLE:
349
+ private_pem = load_private_key_from_env()
350
+ if private_pem:
351
+ meta["signature"] = sign_merkle_root(merkle_root, private_pem)
352
+ except Exception:
353
+ pass
354
+
301
355
  # Create commit
302
356
  commit = Commit(
303
357
  tree=tree_hash,
@@ -305,7 +359,7 @@ class Repository:
305
359
  author=self.get_author(),
306
360
  timestamp=datetime.utcnow().isoformat() + "Z",
307
361
  message=message,
308
- metadata=metadata or {},
362
+ metadata=meta,
309
363
  )
310
364
  commit_hash = commit.store(self.object_store)
311
365
 
@@ -313,6 +367,14 @@ class Repository:
313
367
  old_hash = parents[0] if parents else "0" * 64
314
368
  self.refs.append_reflog("HEAD", old_hash, commit_hash, f"commit: {message}")
315
369
 
370
+ # Audit
371
+ try:
372
+ from .audit import append_audit
373
+
374
+ append_audit(self.mem_dir, "commit", {"commit": commit_hash, "message": message})
375
+ except Exception:
376
+ pass
377
+
316
378
  # Update HEAD
317
379
  head = self.refs.get_head()
318
380
  if head["type"] == "branch":
@@ -354,6 +416,16 @@ class Repository:
354
416
  if not tree:
355
417
  raise ValueError(f"Reference not found: {ref}")
356
418
 
419
+ # Cryptographic verification: reject if Merkle/signature invalid
420
+ try:
421
+ from .crypto_verify import verify_commit_optional
422
+
423
+ verify_commit_optional(
424
+ self.object_store, commit_hash, mem_dir=self.mem_dir, strict=False
425
+ )
426
+ except ValueError as e:
427
+ raise ValueError(str(e))
428
+
357
429
  # Check for uncommitted changes
358
430
  if not force:
359
431
  staged = self.staging.get_staged_files()
@@ -377,6 +449,14 @@ class Repository:
377
449
  # Clear staging
378
450
  self.staging.clear()
379
451
 
452
+ # Audit
453
+ try:
454
+ from .audit import append_audit
455
+
456
+ append_audit(self.mem_dir, "checkout", {"ref": ref, "commit": commit_hash})
457
+ except Exception:
458
+ pass
459
+
380
460
  return commit_hash
381
461
 
382
462
  def get_status(self) -> Dict[str, Any]:
@@ -110,3 +110,12 @@ class TemporalIndex:
110
110
  if idx == 0:
111
111
  return None # All commits are after the requested time
112
112
  return timeline[idx - 1][1]
113
+
114
+ def range_query(self, start_str: str, end_str: str) -> List[Tuple[datetime, str]]:
115
+ """Return (timestamp, commit_hash) entries in [start, end]. For range queries."""
116
+ start_dt = _parse_iso_timestamp(start_str)
117
+ end_dt = _parse_iso_timestamp(end_str)
118
+ if not start_dt or not end_dt:
119
+ return []
120
+ timeline = self._build_commit_timeline()
121
+ return [(t, h) for t, h in timeline if start_dt <= t <= end_dt]
memvcs/core/trust.py ADDED
@@ -0,0 +1,103 @@
1
+ """
2
+ Multi-agent trust and identity model for agmem.
3
+
4
+ Trust store: map public keys to levels (full | conditional | untrusted).
5
+ Used on pull/merge to decide auto-merge, prompt, or block.
6
+ """
7
+
8
+ import hashlib
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Optional, Dict, List, Any, Union
12
+
13
+ TRUST_LEVELS = ("full", "conditional", "untrusted")
14
+
15
+
16
+ def _trust_dir(mem_dir: Path) -> Path:
17
+ return mem_dir / "trust"
18
+
19
+
20
+ def _trust_file(mem_dir: Path) -> Path:
21
+ return _trust_dir(mem_dir) / "trust.json"
22
+
23
+
24
+ def _key_id(public_key_pem: bytes) -> str:
25
+ """Stable id for a public key (hash of PEM)."""
26
+ return hashlib.sha256(public_key_pem).hexdigest()[:16]
27
+
28
+
29
+ def _ensure_bytes(pem: Union[bytes, str]) -> bytes:
30
+ """Normalize PEM to bytes for hashing/serialization."""
31
+ return pem.encode("utf-8") if isinstance(pem, str) else pem
32
+
33
+
34
+ def load_trust_store(mem_dir: Path) -> List[Dict[str, Any]]:
35
+ """Load trust store: list of { key_id, public_key_pem, level }."""
36
+ path = _trust_file(mem_dir)
37
+ if not path.exists():
38
+ return []
39
+ try:
40
+ data = json.loads(path.read_text())
41
+ return data.get("entries", [])
42
+ except Exception:
43
+ return []
44
+
45
+
46
+ def get_trust_level(mem_dir: Path, public_key_pem: Union[bytes, str]) -> Optional[str]:
47
+ """Get trust level for a public key. Returns 'full'|'conditional'|'untrusted' or None if unknown."""
48
+ pem_b = _ensure_bytes(public_key_pem)
49
+ kid = _key_id(pem_b)
50
+ key_pem_str = pem_b.decode("utf-8")
51
+ for e in load_trust_store(mem_dir):
52
+ entry_id = e.get("key_id") or _key_id((e.get("public_key_pem") or "").encode())
53
+ if entry_id == kid:
54
+ return e.get("level")
55
+ if e.get("public_key_pem") == key_pem_str:
56
+ return e.get("level")
57
+ return None
58
+
59
+
60
+ # Reasonable upper bound for PEM to avoid DoS (typical Ed25519 public PEM ~120 bytes)
61
+ _MAX_PEM_BYTES = 8192
62
+
63
+
64
+ def set_trust(mem_dir: Path, public_key_pem: Union[bytes, str], level: str) -> None:
65
+ """Set trust level for a public key. level: full | conditional | untrusted."""
66
+ if level not in TRUST_LEVELS:
67
+ raise ValueError(f"level must be one of {TRUST_LEVELS}")
68
+ pem_b = _ensure_bytes(public_key_pem)
69
+ if len(pem_b) > _MAX_PEM_BYTES:
70
+ raise ValueError("Public key PEM exceeds maximum size")
71
+ kid = _key_id(pem_b)
72
+ key_pem_str = pem_b.decode("utf-8")
73
+ _trust_dir(mem_dir).mkdir(parents=True, exist_ok=True)
74
+ entries = load_trust_store(mem_dir)
75
+ entries = [
76
+ e
77
+ for e in entries
78
+ if (e.get("key_id") or _key_id((e.get("public_key_pem") or "").encode())) != kid
79
+ ]
80
+ entries.append({"key_id": kid, "public_key_pem": key_pem_str, "level": level})
81
+ _trust_file(mem_dir).write_text(json.dumps({"entries": entries}, indent=2))
82
+
83
+
84
+ def find_verifying_key(mem_dir: Path, commit_metadata: Dict[str, Any]) -> Optional[bytes]:
85
+ """
86
+ Try each key in trust store to verify the commit's signature.
87
+ commit_metadata should have merkle_root and signature.
88
+ Returns public_key_pem of first key that verifies, or None.
89
+ """
90
+ from .crypto_verify import verify_signature
91
+
92
+ root = commit_metadata.get("merkle_root")
93
+ sig = commit_metadata.get("signature")
94
+ if not root or not sig:
95
+ return None
96
+ for e in load_trust_store(mem_dir):
97
+ pem = e.get("public_key_pem")
98
+ if not pem:
99
+ continue
100
+ pem_b = _ensure_bytes(pem)
101
+ if verify_signature(root, sig, pem_b):
102
+ return pem_b
103
+ return None
@@ -56,6 +56,19 @@ class VectorStore:
56
56
  "On macOS, try: brew install python (for Homebrew SQLite)"
57
57
  ) from e
58
58
 
59
+ def _device(self) -> str:
60
+ """Return device for embeddings: cuda/mps/cpu. GPU acceleration when available."""
61
+ try:
62
+ import torch
63
+
64
+ if torch.cuda.is_available():
65
+ return "cuda"
66
+ if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
67
+ return "mps"
68
+ except ImportError:
69
+ pass
70
+ return "cpu"
71
+
59
72
  def _get_model(self):
60
73
  """Lazy-load the sentence-transformers model."""
61
74
  if self._model is not None:
@@ -64,7 +77,8 @@ class VectorStore:
64
77
  try:
65
78
  from sentence_transformers import SentenceTransformer
66
79
 
67
- self._model = SentenceTransformer("all-MiniLM-L6-v2")
80
+ device = self._device()
81
+ self._model = SentenceTransformer("all-MiniLM-L6-v2", device=device)
68
82
  return self._model
69
83
  except ImportError as e:
70
84
  raise ImportError(