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.
- {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/METADATA +144 -14
- {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/RECORD +48 -28
- memvcs/cli.py +10 -0
- memvcs/commands/add.py +6 -0
- memvcs/commands/audit.py +59 -0
- memvcs/commands/clone.py +7 -0
- memvcs/commands/daemon.py +45 -0
- memvcs/commands/distill.py +24 -0
- memvcs/commands/federated.py +59 -0
- memvcs/commands/fsck.py +31 -0
- memvcs/commands/garden.py +22 -0
- memvcs/commands/gc.py +66 -0
- memvcs/commands/merge.py +55 -1
- memvcs/commands/prove.py +66 -0
- memvcs/commands/pull.py +27 -0
- memvcs/commands/resolve.py +130 -0
- memvcs/commands/timeline.py +27 -0
- memvcs/commands/verify.py +74 -23
- memvcs/commands/when.py +27 -0
- memvcs/core/audit.py +124 -0
- memvcs/core/compression_pipeline.py +157 -0
- memvcs/core/consistency.py +9 -9
- memvcs/core/crypto_verify.py +291 -0
- memvcs/core/distiller.py +47 -29
- memvcs/core/encryption.py +169 -0
- memvcs/core/federated.py +147 -0
- memvcs/core/gardener.py +47 -29
- memvcs/core/ipfs_remote.py +200 -0
- memvcs/core/knowledge_graph.py +77 -5
- memvcs/core/llm/__init__.py +10 -0
- memvcs/core/llm/anthropic_provider.py +50 -0
- memvcs/core/llm/base.py +27 -0
- memvcs/core/llm/factory.py +30 -0
- memvcs/core/llm/openai_provider.py +36 -0
- memvcs/core/merge.py +36 -23
- memvcs/core/objects.py +39 -19
- memvcs/core/pack.py +278 -0
- memvcs/core/privacy_budget.py +63 -0
- memvcs/core/remote.py +229 -3
- memvcs/core/repository.py +82 -2
- memvcs/core/temporal_index.py +9 -0
- memvcs/core/trust.py +103 -0
- memvcs/core/vector_store.py +15 -1
- memvcs/core/zk_proofs.py +158 -0
- {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/WHEEL +0 -0
- {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/entry_points.txt +0 -0
- {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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
|
-
|
|
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=
|
|
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]:
|
memvcs/core/temporal_index.py
CHANGED
|
@@ -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
|
memvcs/core/vector_store.py
CHANGED
|
@@ -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
|
-
|
|
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(
|