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.
Files changed (67) hide show
  1. agmem-0.1.1.dist-info/METADATA +656 -0
  2. agmem-0.1.1.dist-info/RECORD +67 -0
  3. agmem-0.1.1.dist-info/WHEEL +5 -0
  4. agmem-0.1.1.dist-info/entry_points.txt +2 -0
  5. agmem-0.1.1.dist-info/licenses/LICENSE +21 -0
  6. agmem-0.1.1.dist-info/top_level.txt +1 -0
  7. memvcs/__init__.py +9 -0
  8. memvcs/cli.py +178 -0
  9. memvcs/commands/__init__.py +23 -0
  10. memvcs/commands/add.py +258 -0
  11. memvcs/commands/base.py +23 -0
  12. memvcs/commands/blame.py +169 -0
  13. memvcs/commands/branch.py +110 -0
  14. memvcs/commands/checkout.py +101 -0
  15. memvcs/commands/clean.py +76 -0
  16. memvcs/commands/clone.py +91 -0
  17. memvcs/commands/commit.py +174 -0
  18. memvcs/commands/daemon.py +267 -0
  19. memvcs/commands/diff.py +157 -0
  20. memvcs/commands/fsck.py +203 -0
  21. memvcs/commands/garden.py +107 -0
  22. memvcs/commands/graph.py +151 -0
  23. memvcs/commands/init.py +61 -0
  24. memvcs/commands/log.py +103 -0
  25. memvcs/commands/mcp.py +59 -0
  26. memvcs/commands/merge.py +88 -0
  27. memvcs/commands/pull.py +65 -0
  28. memvcs/commands/push.py +143 -0
  29. memvcs/commands/reflog.py +52 -0
  30. memvcs/commands/remote.py +51 -0
  31. memvcs/commands/reset.py +98 -0
  32. memvcs/commands/search.py +163 -0
  33. memvcs/commands/serve.py +54 -0
  34. memvcs/commands/show.py +125 -0
  35. memvcs/commands/stash.py +97 -0
  36. memvcs/commands/status.py +112 -0
  37. memvcs/commands/tag.py +117 -0
  38. memvcs/commands/test.py +132 -0
  39. memvcs/commands/tree.py +156 -0
  40. memvcs/core/__init__.py +21 -0
  41. memvcs/core/config_loader.py +245 -0
  42. memvcs/core/constants.py +12 -0
  43. memvcs/core/diff.py +380 -0
  44. memvcs/core/gardener.py +466 -0
  45. memvcs/core/hooks.py +151 -0
  46. memvcs/core/knowledge_graph.py +381 -0
  47. memvcs/core/merge.py +474 -0
  48. memvcs/core/objects.py +323 -0
  49. memvcs/core/pii_scanner.py +343 -0
  50. memvcs/core/refs.py +447 -0
  51. memvcs/core/remote.py +278 -0
  52. memvcs/core/repository.py +522 -0
  53. memvcs/core/schema.py +414 -0
  54. memvcs/core/staging.py +227 -0
  55. memvcs/core/storage/__init__.py +72 -0
  56. memvcs/core/storage/base.py +359 -0
  57. memvcs/core/storage/gcs.py +308 -0
  58. memvcs/core/storage/local.py +182 -0
  59. memvcs/core/storage/s3.py +369 -0
  60. memvcs/core/test_runner.py +371 -0
  61. memvcs/core/vector_store.py +313 -0
  62. memvcs/integrations/__init__.py +5 -0
  63. memvcs/integrations/mcp_server.py +267 -0
  64. memvcs/integrations/web_ui/__init__.py +1 -0
  65. memvcs/integrations/web_ui/server.py +352 -0
  66. memvcs/utils/__init__.py +9 -0
  67. memvcs/utils/helpers.py +178 -0
memvcs/core/refs.py ADDED
@@ -0,0 +1,447 @@
1
+ """
2
+ Reference management for agmem.
3
+
4
+ Manages HEAD, branches, tags, stash, and reflog.
5
+ """
6
+
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Optional, List, Dict
10
+ from datetime import datetime
11
+
12
+ # Minimum length for partial commit hash; full SHA-256 hex is 64 chars
13
+ COMMIT_HASH_MIN_LEN = 4
14
+ COMMIT_HASH_MAX_LEN = 64
15
+ COMMIT_HASH_HEX_CHARS = set('0123456789abcdef')
16
+
17
+
18
+ def _safe_ref_name(name: str) -> bool:
19
+ """Return True if name is safe for single-component ref (reflog, HEAD file). No slashes."""
20
+ if not name or name in ('.', '..'):
21
+ return False
22
+ if '/' in name or '\\' in name or '\0' in name:
23
+ return False
24
+ return True
25
+
26
+
27
+ def _ref_path_under_root(name: str, base_dir: Path) -> bool:
28
+ """Return True if name is a valid ref name and (base_dir / name) stays under base_dir (Git-style)."""
29
+ if not name or name in ('.', '..') or '\0' in name or '\\' in name:
30
+ return False
31
+ try:
32
+ resolved = (base_dir / name).resolve()
33
+ base_resolved = base_dir.resolve()
34
+ return resolved == base_resolved or base_resolved in resolved.parents
35
+ except (ValueError, RuntimeError):
36
+ return False
37
+
38
+
39
+ def _valid_commit_hash(candidate: str) -> bool:
40
+ """Return True if candidate looks like a commit hash (hex, within length)."""
41
+ if not candidate or len(candidate) < COMMIT_HASH_MIN_LEN or len(candidate) > COMMIT_HASH_MAX_LEN:
42
+ return False
43
+ return all(c in COMMIT_HASH_HEX_CHARS for c in candidate.lower())
44
+
45
+
46
+ class RefsManager:
47
+ """Manages references (HEAD, branches, tags)."""
48
+
49
+ def __init__(self, mem_dir: Path):
50
+ self.mem_dir = Path(mem_dir)
51
+ self.refs_dir = self.mem_dir / 'refs'
52
+ self.heads_dir = self.refs_dir / 'heads'
53
+ self.tags_dir = self.refs_dir / 'tags'
54
+ self.remotes_dir = self.refs_dir / 'remotes'
55
+ self.head_file = self.mem_dir / 'HEAD'
56
+ self.stash_file = self.mem_dir / 'stash'
57
+ self.reflog_dir = self.mem_dir / 'logs'
58
+ self._ensure_directories()
59
+
60
+ def _ensure_directories(self):
61
+ """Create reference directories."""
62
+ self.heads_dir.mkdir(parents=True, exist_ok=True)
63
+ self.tags_dir.mkdir(parents=True, exist_ok=True)
64
+ self.remotes_dir.mkdir(parents=True, exist_ok=True)
65
+
66
+ def init_head(self, branch_name: str = 'main'):
67
+ """Initialize HEAD to point to a branch."""
68
+ if not _ref_path_under_root(branch_name, self.heads_dir):
69
+ raise ValueError(f"Invalid branch name: {branch_name!r}")
70
+ self.head_file.write_text(f'ref: refs/heads/{branch_name}\n')
71
+ branch_file = self.heads_dir / branch_name
72
+ if not branch_file.exists():
73
+ branch_file.parent.mkdir(parents=True, exist_ok=True)
74
+ branch_file.write_text('')
75
+
76
+ def get_head(self) -> Dict[str, str]:
77
+ """
78
+ Get current HEAD reference.
79
+
80
+ Returns:
81
+ Dict with 'type' ('branch' or 'commit') and 'value'
82
+ """
83
+ if not self.head_file.exists():
84
+ return {'type': 'branch', 'value': 'main'}
85
+
86
+ content = self.head_file.read_text().strip()
87
+
88
+ if content.startswith('ref: '):
89
+ # Points to a branch (e.g. refs/heads/main or refs/heads/feature/test)
90
+ ref_path = content[5:].strip()
91
+ branch_name = ref_path[len('refs/heads/'):] if ref_path.startswith('refs/heads/') else ref_path.split('/')[-1]
92
+ return {'type': 'branch', 'value': branch_name}
93
+ elif content:
94
+ # Detached HEAD - points directly to commit
95
+ return {'type': 'commit', 'value': content}
96
+
97
+ return {'type': 'branch', 'value': 'main'}
98
+
99
+ def set_head_branch(self, branch_name: str):
100
+ """Set HEAD to point to a branch."""
101
+ if not _ref_path_under_root(branch_name, self.heads_dir):
102
+ raise ValueError(f"Invalid branch name: {branch_name!r}")
103
+ self.head_file.write_text(f'ref: refs/heads/{branch_name}\n')
104
+
105
+ def set_head_detached(self, commit_hash: str):
106
+ """Set HEAD to point directly to a commit (detached)."""
107
+ self.head_file.write_text(f'{commit_hash}\n')
108
+
109
+ def get_branch_commit(self, branch_name: str) -> Optional[str]:
110
+ """Get the commit hash for a branch."""
111
+ if not _ref_path_under_root(branch_name, self.heads_dir):
112
+ return None
113
+ branch_file = self.heads_dir / branch_name
114
+ if branch_file.exists():
115
+ content = branch_file.read_text().strip()
116
+ return content if content else None
117
+ return None
118
+
119
+ def set_branch_commit(self, branch_name: str, commit_hash: str):
120
+ """Set the commit hash for a branch."""
121
+ if not _ref_path_under_root(branch_name, self.heads_dir):
122
+ raise ValueError(f"Invalid branch name: {branch_name!r}")
123
+ branch_file = self.heads_dir / branch_name
124
+ branch_file.parent.mkdir(parents=True, exist_ok=True)
125
+ branch_file.write_text(f'{commit_hash}\n')
126
+
127
+ def create_branch(self, branch_name: str, commit_hash: Optional[str] = None) -> bool:
128
+ """
129
+ Create a new branch.
130
+
131
+ Args:
132
+ branch_name: Name of the new branch
133
+ commit_hash: Commit to point to (None for current HEAD)
134
+
135
+ Returns:
136
+ True if created, False if branch already exists
137
+ """
138
+ if not _ref_path_under_root(branch_name, self.heads_dir):
139
+ raise ValueError(f"Invalid branch name: {branch_name!r}")
140
+ branch_file = self.heads_dir / branch_name
141
+ if branch_file.exists():
142
+ return False
143
+
144
+ branch_file.parent.mkdir(parents=True, exist_ok=True)
145
+
146
+ if commit_hash is None:
147
+ # Point to current HEAD commit
148
+ head = self.get_head()
149
+ if head['type'] == 'branch':
150
+ commit_hash = self.get_branch_commit(head['value'])
151
+ else:
152
+ commit_hash = head['value']
153
+
154
+ branch_file.write_text(f'{commit_hash}\n' if commit_hash else '')
155
+ return True
156
+
157
+ def delete_branch(self, branch_name: str) -> bool:
158
+ """
159
+ Delete a branch.
160
+
161
+ Returns:
162
+ True if deleted, False if branch doesn't exist
163
+ """
164
+ if not _ref_path_under_root(branch_name, self.heads_dir):
165
+ return False
166
+ branch_file = self.heads_dir / branch_name
167
+ if branch_file.exists():
168
+ branch_file.unlink()
169
+ return True
170
+ return False
171
+
172
+ def list_branches(self) -> List[str]:
173
+ """List all branch names (supports nested names like feature/test)."""
174
+ if not self.heads_dir.exists():
175
+ return []
176
+ branches = []
177
+ for p in self.heads_dir.rglob('*'):
178
+ if p.is_file():
179
+ branches.append(str(p.relative_to(self.heads_dir)))
180
+ return sorted(branches)
181
+
182
+ def branch_exists(self, branch_name: str) -> bool:
183
+ """Check if a branch exists."""
184
+ if not _ref_path_under_root(branch_name, self.heads_dir):
185
+ return False
186
+ return (self.heads_dir / branch_name).exists()
187
+
188
+ def get_current_branch(self) -> Optional[str]:
189
+ """Get the name of the current branch, or None if detached."""
190
+ head = self.get_head()
191
+ if head['type'] == 'branch':
192
+ return head['value']
193
+ return None
194
+
195
+ def is_detached(self) -> bool:
196
+ """Check if HEAD is detached."""
197
+ head = self.get_head()
198
+ return head['type'] == 'commit'
199
+
200
+ # Tag management
201
+
202
+ def create_tag(self, tag_name: str, commit_hash: str, message: str = '') -> bool:
203
+ """
204
+ Create a new tag.
205
+
206
+ Args:
207
+ tag_name: Name of the tag
208
+ commit_hash: Commit to tag
209
+ message: Optional tag message
210
+
211
+ Returns:
212
+ True if created, False if tag already exists
213
+ """
214
+ if not _ref_path_under_root(tag_name, self.tags_dir):
215
+ raise ValueError(f"Invalid tag name: {tag_name!r}")
216
+ tag_file = self.tags_dir / tag_name
217
+ if tag_file.exists():
218
+ return False
219
+
220
+ tag_file.parent.mkdir(parents=True, exist_ok=True)
221
+ tag_file.write_text(f'{commit_hash}\n')
222
+ return True
223
+
224
+ def delete_tag(self, tag_name: str) -> bool:
225
+ """
226
+ Delete a tag.
227
+
228
+ Returns:
229
+ True if deleted, False if tag doesn't exist
230
+ """
231
+ if not _ref_path_under_root(tag_name, self.tags_dir):
232
+ return False
233
+ tag_file = self.tags_dir / tag_name
234
+ if tag_file.exists():
235
+ tag_file.unlink()
236
+ return True
237
+ return False
238
+
239
+ def get_tag_commit(self, tag_name: str) -> Optional[str]:
240
+ """Get the commit hash for a tag."""
241
+ if not _ref_path_under_root(tag_name, self.tags_dir):
242
+ return None
243
+ tag_file = self.tags_dir / tag_name
244
+ if tag_file.exists():
245
+ return tag_file.read_text().strip()
246
+ return None
247
+
248
+ def list_tags(self) -> List[str]:
249
+ """List all tag names (supports nested names)."""
250
+ if not self.tags_dir.exists():
251
+ return []
252
+ tags = []
253
+ for p in self.tags_dir.rglob('*'):
254
+ if p.is_file():
255
+ tags.append(str(p.relative_to(self.tags_dir)))
256
+ return sorted(tags)
257
+
258
+ def tag_exists(self, tag_name: str) -> bool:
259
+ """Check if a tag exists."""
260
+ if not _ref_path_under_root(tag_name, self.tags_dir):
261
+ return False
262
+ return (self.tags_dir / tag_name).exists()
263
+
264
+ def get_remote_branch_commit(self, remote_name: str, branch_name: str) -> Optional[str]:
265
+ """Get commit hash for a remote-tracking branch (e.g. refs/remotes/origin/main)."""
266
+ if not _safe_ref_name(remote_name) or not _ref_path_under_root(branch_name, self.heads_dir):
267
+ return None
268
+ remote_refs = self.remotes_dir / remote_name
269
+ ref_file = remote_refs / branch_name
270
+ if ref_file.exists() and ref_file.is_file():
271
+ content = ref_file.read_text().strip()
272
+ return content if content else None
273
+ return None
274
+
275
+ def set_remote_branch_commit(self, remote_name: str, branch_name: str, commit_hash: str) -> None:
276
+ """Set remote-tracking branch (e.g. after fetch)."""
277
+ if not _safe_ref_name(remote_name):
278
+ raise ValueError(f"Invalid remote name: {remote_name!r}")
279
+ remote_refs = self.remotes_dir / remote_name
280
+ if not _ref_path_under_root(branch_name, remote_refs):
281
+ raise ValueError(f"Invalid branch name: {branch_name!r}")
282
+ ref_file = self.remotes_dir / remote_name / branch_name
283
+ ref_file.parent.mkdir(parents=True, exist_ok=True)
284
+ ref_file.write_text(commit_hash + '\n')
285
+
286
+ def resolve_ref(self, ref: str, object_store=None) -> Optional[str]:
287
+ """
288
+ Resolve a reference to a commit hash.
289
+
290
+ Supports:
291
+ - Branch names
292
+ - Tag names
293
+ - Partial commit hashes
294
+ - HEAD
295
+ - HEAD~n (nth parent)
296
+
297
+ Args:
298
+ ref: Reference to resolve
299
+
300
+ Returns:
301
+ Commit hash or None if not found
302
+ """
303
+ ref = ref.strip()
304
+
305
+ # Handle HEAD
306
+ if ref == 'HEAD':
307
+ head = self.get_head()
308
+ if head['type'] == 'commit':
309
+ return head['value']
310
+ else:
311
+ return self.get_branch_commit(head['value'])
312
+
313
+ # Handle HEAD~n
314
+ if ref.startswith('HEAD~'):
315
+ try:
316
+ n = int(ref[5:])
317
+ if n < 0:
318
+ return None
319
+ head = self.get_head()
320
+ if head['type'] == 'branch':
321
+ commit_hash = self.get_branch_commit(head['value'])
322
+ else:
323
+ commit_hash = head['value']
324
+ if not commit_hash:
325
+ return None
326
+ # Walk back n parents when object_store is available
327
+ if object_store is not None and n > 0:
328
+ from .objects import Commit
329
+ for _ in range(n):
330
+ commit = Commit.load(object_store, commit_hash)
331
+ if not commit or not commit.parents:
332
+ return None
333
+ commit_hash = commit.parents[0]
334
+ return commit_hash
335
+ except ValueError:
336
+ return None
337
+
338
+ # Check branches
339
+ if self.branch_exists(ref):
340
+ return self.get_branch_commit(ref)
341
+
342
+ # Check remote-tracking refs (e.g. origin/main)
343
+ if '/' in ref:
344
+ parts = ref.split('/', 1)
345
+ if len(parts) == 2:
346
+ remote_name, branch_name = parts
347
+ if _safe_ref_name(remote_name) and _ref_path_under_root(branch_name, self.heads_dir):
348
+ remote_hash = self.get_remote_branch_commit(remote_name, branch_name)
349
+ if remote_hash:
350
+ return remote_hash
351
+
352
+ # Check tags
353
+ if self.tag_exists(ref):
354
+ return self.get_tag_commit(ref)
355
+
356
+ # Check stash refs (stash@{n})
357
+ if ref.startswith('stash@'):
358
+ if ref == 'stash':
359
+ return self.get_stash_commit(0)
360
+ if ref.startswith('stash@{') and ref.endswith('}'):
361
+ try:
362
+ n = int(ref[7:-1])
363
+ return self.get_stash_commit(n)
364
+ except ValueError:
365
+ pass
366
+
367
+ # Assume it's a commit hash (full or partial); validate to avoid path/injection
368
+ return ref if _valid_commit_hash(ref) else None
369
+
370
+ # Reflog - log of HEAD changes
371
+ def append_reflog(self, ref_name: str, old_hash: str, new_hash: str, message: str):
372
+ """Append entry to reflog."""
373
+ if not _safe_ref_name(ref_name):
374
+ raise ValueError(f"Invalid ref name for reflog: {ref_name!r}")
375
+ self.reflog_dir.mkdir(parents=True, exist_ok=True)
376
+ log_file = self.reflog_dir / ref_name
377
+ timestamp = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.%fZ')
378
+ line = f"{new_hash} {old_hash} {timestamp} {message}\n"
379
+ with open(log_file, 'a') as f:
380
+ f.write(line)
381
+
382
+ def get_reflog(self, ref_name: str = 'HEAD', max_count: int = 20) -> List[Dict]:
383
+ """Get reflog entries for a reference."""
384
+ if not _safe_ref_name(ref_name):
385
+ return []
386
+ log_file = self.reflog_dir / ref_name
387
+ if not log_file.exists():
388
+ return []
389
+ entries = []
390
+ for line in reversed(log_file.read_text().strip().split('\n')):
391
+ if not line:
392
+ continue
393
+ parts = line.split(' ', 3)
394
+ if len(parts) >= 4:
395
+ entries.append({
396
+ 'hash': parts[0],
397
+ 'old_hash': parts[1],
398
+ 'timestamp': parts[2],
399
+ 'message': parts[3]
400
+ })
401
+ if len(entries) >= max_count:
402
+ break
403
+ return entries
404
+
405
+ # Stash - stack of stashed changes
406
+ def stash_push(self, commit_hash: str, message: str = '') -> int:
407
+ """Push a commit onto the stash stack. Returns stash index."""
408
+ stashes = self._load_stash_list()
409
+ stashes.insert(0, {'hash': commit_hash, 'message': message or 'WIP'})
410
+ self._save_stash_list(stashes)
411
+ return 0
412
+
413
+ def stash_pop(self, index: int = 0) -> Optional[str]:
414
+ """Pop stash at index. Returns commit hash or None."""
415
+ stashes = self._load_stash_list()
416
+ if index >= len(stashes):
417
+ return None
418
+ entry = stashes.pop(index)
419
+ self._save_stash_list(stashes)
420
+ return entry['hash']
421
+
422
+ def stash_list(self) -> List[Dict]:
423
+ """List all stashes."""
424
+ return self._load_stash_list()
425
+
426
+ def get_stash_commit(self, index: int) -> Optional[str]:
427
+ """Get commit hash for stash at index."""
428
+ stashes = self._load_stash_list()
429
+ if 0 <= index < len(stashes):
430
+ return stashes[index]['hash']
431
+ return None
432
+
433
+ def _load_stash_list(self) -> List[Dict]:
434
+ """Load stash list from disk."""
435
+ if not self.stash_file.exists():
436
+ return []
437
+ try:
438
+ import json
439
+ data = json.loads(self.stash_file.read_text())
440
+ return data if isinstance(data, list) else []
441
+ except (json.JSONDecodeError, TypeError):
442
+ return []
443
+
444
+ def _save_stash_list(self, stashes: List[Dict]):
445
+ """Save stash list to disk."""
446
+ import json
447
+ self.stash_file.write_text(json.dumps(stashes, indent=2))