agmem 0.1.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.
- agmem-0.1.1.dist-info/METADATA +656 -0
- agmem-0.1.1.dist-info/RECORD +67 -0
- agmem-0.1.1.dist-info/WHEEL +5 -0
- agmem-0.1.1.dist-info/entry_points.txt +2 -0
- agmem-0.1.1.dist-info/licenses/LICENSE +21 -0
- agmem-0.1.1.dist-info/top_level.txt +1 -0
- memvcs/__init__.py +9 -0
- memvcs/cli.py +178 -0
- memvcs/commands/__init__.py +23 -0
- memvcs/commands/add.py +258 -0
- memvcs/commands/base.py +23 -0
- memvcs/commands/blame.py +169 -0
- memvcs/commands/branch.py +110 -0
- memvcs/commands/checkout.py +101 -0
- memvcs/commands/clean.py +76 -0
- memvcs/commands/clone.py +91 -0
- memvcs/commands/commit.py +174 -0
- memvcs/commands/daemon.py +267 -0
- memvcs/commands/diff.py +157 -0
- memvcs/commands/fsck.py +203 -0
- memvcs/commands/garden.py +107 -0
- memvcs/commands/graph.py +151 -0
- memvcs/commands/init.py +61 -0
- memvcs/commands/log.py +103 -0
- memvcs/commands/mcp.py +59 -0
- memvcs/commands/merge.py +88 -0
- memvcs/commands/pull.py +65 -0
- memvcs/commands/push.py +143 -0
- memvcs/commands/reflog.py +52 -0
- memvcs/commands/remote.py +51 -0
- memvcs/commands/reset.py +98 -0
- memvcs/commands/search.py +163 -0
- memvcs/commands/serve.py +54 -0
- memvcs/commands/show.py +125 -0
- memvcs/commands/stash.py +97 -0
- memvcs/commands/status.py +112 -0
- memvcs/commands/tag.py +117 -0
- memvcs/commands/test.py +132 -0
- memvcs/commands/tree.py +156 -0
- memvcs/core/__init__.py +21 -0
- memvcs/core/config_loader.py +245 -0
- memvcs/core/constants.py +12 -0
- memvcs/core/diff.py +380 -0
- memvcs/core/gardener.py +466 -0
- memvcs/core/hooks.py +151 -0
- memvcs/core/knowledge_graph.py +381 -0
- memvcs/core/merge.py +474 -0
- memvcs/core/objects.py +323 -0
- memvcs/core/pii_scanner.py +343 -0
- memvcs/core/refs.py +447 -0
- memvcs/core/remote.py +278 -0
- memvcs/core/repository.py +522 -0
- memvcs/core/schema.py +414 -0
- memvcs/core/staging.py +227 -0
- memvcs/core/storage/__init__.py +72 -0
- memvcs/core/storage/base.py +359 -0
- memvcs/core/storage/gcs.py +308 -0
- memvcs/core/storage/local.py +182 -0
- memvcs/core/storage/s3.py +369 -0
- memvcs/core/test_runner.py +371 -0
- memvcs/core/vector_store.py +313 -0
- memvcs/integrations/__init__.py +5 -0
- memvcs/integrations/mcp_server.py +267 -0
- memvcs/integrations/web_ui/__init__.py +1 -0
- memvcs/integrations/web_ui/server.py +352 -0
- memvcs/utils/__init__.py +9 -0
- memvcs/utils/helpers.py +178 -0
memvcs/core/remote.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Remote sync for agmem - file-based push/pull/clone.
|
|
3
|
+
|
|
4
|
+
Supports file:// URLs for local or mounted directories.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Set
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
from .objects import ObjectStore, Commit, Tree, Blob, _valid_object_hash
|
|
14
|
+
from .refs import RefsManager, _ref_path_under_root
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_remote_url(url: str) -> Path:
|
|
18
|
+
"""Parse remote URL to local path. Supports file:// only. Rejects path traversal."""
|
|
19
|
+
parsed = urlparse(url)
|
|
20
|
+
if parsed.scheme == "file" or parsed.scheme == "":
|
|
21
|
+
path = parsed.path or parsed.netloc or url
|
|
22
|
+
resolved = Path(path).resolve()
|
|
23
|
+
if not resolved.exists():
|
|
24
|
+
raise ValueError(f"Remote path does not exist: {resolved}")
|
|
25
|
+
if not resolved.is_dir():
|
|
26
|
+
raise ValueError(f"Remote path is not a directory: {resolved}")
|
|
27
|
+
return resolved
|
|
28
|
+
raise ValueError(f"Unsupported remote URL scheme: {parsed.scheme}. Use file://")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _collect_objects_from_commit(store: ObjectStore, commit_hash: str) -> Set[str]:
|
|
32
|
+
"""Recursively collect all object hashes reachable from a commit."""
|
|
33
|
+
seen = set()
|
|
34
|
+
todo = [commit_hash]
|
|
35
|
+
|
|
36
|
+
while todo:
|
|
37
|
+
h = todo.pop()
|
|
38
|
+
if h in seen:
|
|
39
|
+
continue
|
|
40
|
+
seen.add(h)
|
|
41
|
+
|
|
42
|
+
# Try commit
|
|
43
|
+
content = store.retrieve(h, "commit")
|
|
44
|
+
if content:
|
|
45
|
+
data = json.loads(content)
|
|
46
|
+
todo.extend(data.get("parents", []))
|
|
47
|
+
if "tree" in data:
|
|
48
|
+
todo.append(data["tree"])
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
# Try tree
|
|
52
|
+
content = store.retrieve(h, "tree")
|
|
53
|
+
if content:
|
|
54
|
+
data = json.loads(content)
|
|
55
|
+
for e in data.get("entries", []):
|
|
56
|
+
if "hash" in e:
|
|
57
|
+
todo.append(e["hash"])
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
# Blob - no follow
|
|
61
|
+
|
|
62
|
+
return seen
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _list_local_objects(objects_dir: Path) -> Set[str]:
|
|
66
|
+
"""List all object hashes in a .mem/objects directory."""
|
|
67
|
+
hashes = set()
|
|
68
|
+
for obj_type in ["blob", "tree", "commit"]:
|
|
69
|
+
type_dir = objects_dir / obj_type
|
|
70
|
+
if not type_dir.exists():
|
|
71
|
+
continue
|
|
72
|
+
for prefix_dir in type_dir.iterdir():
|
|
73
|
+
if prefix_dir.is_dir():
|
|
74
|
+
for suffix_file in prefix_dir.iterdir():
|
|
75
|
+
hash_id = prefix_dir.name + suffix_file.name
|
|
76
|
+
hashes.add(hash_id)
|
|
77
|
+
return hashes
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _get_object_path(objects_dir: Path, hash_id: str) -> Optional[Path]:
|
|
81
|
+
"""Get path for an object. Returns path if found, else None. Validates hash_id."""
|
|
82
|
+
if not _valid_object_hash(hash_id):
|
|
83
|
+
return None
|
|
84
|
+
for otype in ["blob", "tree", "commit"]:
|
|
85
|
+
p = objects_dir / otype / hash_id[:2] / hash_id[2:]
|
|
86
|
+
if p.exists():
|
|
87
|
+
return p
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _copy_object(src_dir: Path, dst_dir: Path, hash_id: str) -> bool:
|
|
92
|
+
"""Copy a single object. Returns True if copied. Validates hash_id."""
|
|
93
|
+
if not _valid_object_hash(hash_id):
|
|
94
|
+
return False
|
|
95
|
+
src = _get_object_path(src_dir, hash_id)
|
|
96
|
+
if not src or not src.exists():
|
|
97
|
+
return False
|
|
98
|
+
# Infer type from path (e.g. .../blob/xx/yy)
|
|
99
|
+
obj_type = src.parent.parent.name
|
|
100
|
+
dst = dst_dir / obj_type / hash_id[:2] / hash_id[2:]
|
|
101
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
if not dst.exists() or dst.stat().st_size != src.stat().st_size:
|
|
103
|
+
shutil.copy2(src, dst)
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Remote:
|
|
109
|
+
"""Remote repository for push/pull operations."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, repo_path: Path, name: str = "origin"):
|
|
112
|
+
self.repo_path = Path(repo_path)
|
|
113
|
+
self.mem_dir = self.repo_path / ".mem"
|
|
114
|
+
self.objects_dir = self.mem_dir / "objects"
|
|
115
|
+
self.name = name
|
|
116
|
+
self._config = self._load_config()
|
|
117
|
+
|
|
118
|
+
def _load_config(self) -> dict:
|
|
119
|
+
config_file = self.mem_dir / "config.json"
|
|
120
|
+
if config_file.exists():
|
|
121
|
+
return json.loads(config_file.read_text())
|
|
122
|
+
return {}
|
|
123
|
+
|
|
124
|
+
def _save_config(self, config: dict):
|
|
125
|
+
config_file = self.mem_dir / "config.json"
|
|
126
|
+
config_file.write_text(json.dumps(config, indent=2))
|
|
127
|
+
|
|
128
|
+
def get_remote_url(self) -> Optional[str]:
|
|
129
|
+
"""Get remote URL for the given name."""
|
|
130
|
+
remotes = self._config.get("remotes", {})
|
|
131
|
+
return remotes.get(self.name, {}).get("url")
|
|
132
|
+
|
|
133
|
+
def set_remote_url(self, url: str):
|
|
134
|
+
"""Set remote URL."""
|
|
135
|
+
if "remotes" not in self._config:
|
|
136
|
+
self._config["remotes"] = {}
|
|
137
|
+
if self.name not in self._config["remotes"]:
|
|
138
|
+
self._config["remotes"][self.name] = {}
|
|
139
|
+
self._config["remotes"][self.name]["url"] = url
|
|
140
|
+
self._save_config(self._config)
|
|
141
|
+
|
|
142
|
+
def push(self, branch: Optional[str] = None) -> str:
|
|
143
|
+
"""
|
|
144
|
+
Push objects and refs to remote.
|
|
145
|
+
Returns status message.
|
|
146
|
+
"""
|
|
147
|
+
url = self.get_remote_url()
|
|
148
|
+
if not url:
|
|
149
|
+
raise ValueError(f"Remote '{self.name}' has no URL configured")
|
|
150
|
+
|
|
151
|
+
remote_path = parse_remote_url(url)
|
|
152
|
+
remote_mem = remote_path / ".mem"
|
|
153
|
+
remote_objects = remote_mem / "objects"
|
|
154
|
+
remote_refs = remote_mem / "refs"
|
|
155
|
+
|
|
156
|
+
if not remote_path.exists():
|
|
157
|
+
raise ValueError(f"Remote path does not exist: {remote_path}")
|
|
158
|
+
|
|
159
|
+
remote_mem.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
remote_objects.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
(remote_refs / "heads").mkdir(parents=True, exist_ok=True)
|
|
162
|
+
(remote_refs / "tags").mkdir(parents=True, exist_ok=True)
|
|
163
|
+
|
|
164
|
+
refs = RefsManager(self.mem_dir)
|
|
165
|
+
store = ObjectStore(self.objects_dir)
|
|
166
|
+
|
|
167
|
+
# Collect objects to push
|
|
168
|
+
to_push = set()
|
|
169
|
+
for b in refs.list_branches():
|
|
170
|
+
if branch and b != branch:
|
|
171
|
+
continue
|
|
172
|
+
ch = refs.get_branch_commit(b)
|
|
173
|
+
if ch:
|
|
174
|
+
to_push.update(_collect_objects_from_commit(store, ch))
|
|
175
|
+
for t in refs.list_tags():
|
|
176
|
+
ch = refs.get_tag_commit(t)
|
|
177
|
+
if ch:
|
|
178
|
+
to_push.update(_collect_objects_from_commit(store, ch))
|
|
179
|
+
|
|
180
|
+
remote_has = _list_local_objects(remote_objects)
|
|
181
|
+
missing = to_push - remote_has
|
|
182
|
+
|
|
183
|
+
# Copy objects
|
|
184
|
+
copied = 0
|
|
185
|
+
for h in missing:
|
|
186
|
+
if _copy_object(self.objects_dir, remote_objects, h):
|
|
187
|
+
copied += 1
|
|
188
|
+
|
|
189
|
+
# Copy refs (validate names so remote path stays under refs/heads and refs/tags)
|
|
190
|
+
remote_heads = remote_refs / "heads"
|
|
191
|
+
remote_tags_dir = remote_refs / "tags"
|
|
192
|
+
for b in refs.list_branches():
|
|
193
|
+
if branch and b != branch:
|
|
194
|
+
continue
|
|
195
|
+
if not _ref_path_under_root(b, remote_heads):
|
|
196
|
+
continue
|
|
197
|
+
ch = refs.get_branch_commit(b)
|
|
198
|
+
if ch:
|
|
199
|
+
(remote_heads / b).parent.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
(remote_heads / b).write_text(ch + "\n")
|
|
201
|
+
for t in refs.list_tags():
|
|
202
|
+
if not _ref_path_under_root(t, remote_tags_dir):
|
|
203
|
+
continue
|
|
204
|
+
ch = refs.get_tag_commit(t)
|
|
205
|
+
if ch:
|
|
206
|
+
(remote_tags_dir / t).parent.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
(remote_tags_dir / t).write_text(ch + "\n")
|
|
208
|
+
|
|
209
|
+
return f"Pushed {copied} object(s) to {self.name}"
|
|
210
|
+
|
|
211
|
+
def fetch(self, branch: Optional[str] = None) -> str:
|
|
212
|
+
"""
|
|
213
|
+
Fetch objects and refs from remote into local.
|
|
214
|
+
Returns status message.
|
|
215
|
+
"""
|
|
216
|
+
url = self.get_remote_url()
|
|
217
|
+
if not url:
|
|
218
|
+
raise ValueError(f"Remote '{self.name}' has no URL configured")
|
|
219
|
+
|
|
220
|
+
remote_path = parse_remote_url(url)
|
|
221
|
+
remote_objects = remote_path / ".mem" / "objects"
|
|
222
|
+
remote_refs = remote_path / ".mem" / "refs"
|
|
223
|
+
|
|
224
|
+
if not remote_objects.exists():
|
|
225
|
+
raise ValueError(f"Remote is not an agmem repository: {remote_path}")
|
|
226
|
+
|
|
227
|
+
refs = RefsManager(self.mem_dir)
|
|
228
|
+
remote_store = ObjectStore(remote_objects)
|
|
229
|
+
|
|
230
|
+
# Collect remote refs to fetch (traverse remote's objects)
|
|
231
|
+
to_fetch = set()
|
|
232
|
+
heads_dir = remote_refs / "heads"
|
|
233
|
+
if heads_dir.exists():
|
|
234
|
+
for f in heads_dir.rglob("*"):
|
|
235
|
+
if f.is_file():
|
|
236
|
+
branch_name = str(f.relative_to(heads_dir))
|
|
237
|
+
if branch is not None and branch_name != branch:
|
|
238
|
+
continue
|
|
239
|
+
ch = f.read_text().strip()
|
|
240
|
+
if ch and _valid_object_hash(ch):
|
|
241
|
+
to_fetch.update(_collect_objects_from_commit(remote_store, ch))
|
|
242
|
+
tags_dir = remote_refs / "tags"
|
|
243
|
+
if tags_dir.exists() and branch is None:
|
|
244
|
+
for f in tags_dir.rglob("*"):
|
|
245
|
+
if f.is_file():
|
|
246
|
+
ch = f.read_text().strip()
|
|
247
|
+
if ch and _valid_object_hash(ch):
|
|
248
|
+
to_fetch.update(_collect_objects_from_commit(remote_store, ch))
|
|
249
|
+
|
|
250
|
+
local_has = _list_local_objects(self.objects_dir)
|
|
251
|
+
missing = to_fetch - local_has
|
|
252
|
+
|
|
253
|
+
copied = 0
|
|
254
|
+
for h in missing:
|
|
255
|
+
if _copy_object(remote_objects, self.objects_dir, h):
|
|
256
|
+
copied += 1
|
|
257
|
+
|
|
258
|
+
# Update remote-tracking refs (refs/remotes/<name>/<branch>), not local heads
|
|
259
|
+
if heads_dir.exists():
|
|
260
|
+
for f in heads_dir.rglob("*"):
|
|
261
|
+
if f.is_file():
|
|
262
|
+
branch_name = str(f.relative_to(heads_dir))
|
|
263
|
+
if not _ref_path_under_root(branch_name, refs.heads_dir):
|
|
264
|
+
continue
|
|
265
|
+
ch = f.read_text().strip()
|
|
266
|
+
if ch:
|
|
267
|
+
refs.set_remote_branch_commit(self.name, branch_name, ch)
|
|
268
|
+
if tags_dir.exists():
|
|
269
|
+
for f in tags_dir.rglob("*"):
|
|
270
|
+
if f.is_file():
|
|
271
|
+
tag_name = str(f.relative_to(tags_dir))
|
|
272
|
+
if not _ref_path_under_root(tag_name, refs.tags_dir):
|
|
273
|
+
continue
|
|
274
|
+
ch = f.read_text().strip()
|
|
275
|
+
if ch:
|
|
276
|
+
refs.create_tag(tag_name, ch)
|
|
277
|
+
|
|
278
|
+
return f"Fetched {copied} object(s) from {self.name}"
|