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
@@ -0,0 +1,522 @@
1
+ """
2
+ Main repository class for agmem.
3
+
4
+ Coordinates object storage, staging area, and references.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import shutil
10
+ from pathlib import Path
11
+ from typing import Optional, List, Dict, Any
12
+ from datetime import datetime
13
+
14
+ from .constants import MEMORY_TYPES
15
+ from .config_loader import load_agmem_config
16
+ from .objects import ObjectStore, Blob, Tree, TreeEntry, Commit
17
+ from .staging import StagingArea
18
+ from .refs import RefsManager
19
+
20
+
21
+ class Repository:
22
+ """Main repository class coordinating all agmem operations."""
23
+
24
+ def __init__(self, path: Path):
25
+ self.root = Path(path).resolve()
26
+ self.mem_dir = self.root / '.mem'
27
+ self.current_dir = self.root / 'current'
28
+ self.config_file = self.mem_dir / 'config.json'
29
+
30
+ self.object_store: Optional[ObjectStore] = None
31
+ self.staging: Optional[StagingArea] = None
32
+ self.refs: Optional[RefsManager] = None
33
+
34
+ if self.is_valid_repo():
35
+ self._init_components()
36
+
37
+ def _init_components(self):
38
+ """Initialize repository components."""
39
+ self.object_store = ObjectStore(self.mem_dir / 'objects')
40
+ self.staging = StagingArea(self.mem_dir)
41
+ self.refs = RefsManager(self.mem_dir)
42
+
43
+ @classmethod
44
+ def init(cls, path: Path, author_name: str = 'Agent', author_email: str = 'agent@example.com') -> 'Repository':
45
+ """
46
+ Initialize a new repository.
47
+
48
+ Args:
49
+ path: Directory to initialize repository in
50
+ author_name: Default author name
51
+ author_email: Default author email
52
+
53
+ Returns:
54
+ Initialized Repository instance
55
+ """
56
+ repo = cls(path)
57
+
58
+ if repo.is_valid_repo():
59
+ raise ValueError(f"Repository already exists at {path}")
60
+
61
+ # Create directory structure
62
+ repo.mem_dir.mkdir(parents=True, exist_ok=True)
63
+ repo.current_dir.mkdir(parents=True, exist_ok=True)
64
+
65
+ for mem_type in MEMORY_TYPES:
66
+ (repo.current_dir / mem_type).mkdir(parents=True, exist_ok=True)
67
+
68
+ # Create object store directories
69
+ (repo.mem_dir / 'objects').mkdir(parents=True, exist_ok=True)
70
+
71
+ # Create staging directory
72
+ (repo.mem_dir / 'staging').mkdir(parents=True, exist_ok=True)
73
+
74
+ # Create refs directories
75
+ (repo.mem_dir / 'refs' / 'heads').mkdir(parents=True, exist_ok=True)
76
+ (repo.mem_dir / 'refs' / 'tags').mkdir(parents=True, exist_ok=True)
77
+
78
+ # Create config
79
+ config = {
80
+ 'author': {
81
+ 'name': author_name,
82
+ 'email': author_email
83
+ },
84
+ 'core': {
85
+ 'default_branch': 'main',
86
+ 'compression': True,
87
+ 'gc_prune_days': 90
88
+ },
89
+ 'memory': {
90
+ 'auto_summarize': True,
91
+ 'summarizer_model': 'default',
92
+ 'max_episode_size': 1024 * 1024, # 1MB
93
+ 'consolidation_threshold': 100 # Episodes before consolidation
94
+ }
95
+ }
96
+ repo.config_file.write_text(json.dumps(config, indent=2))
97
+
98
+ # Initialize components
99
+ repo._init_components()
100
+
101
+ # Initialize HEAD
102
+ repo.refs.init_head('main')
103
+
104
+ return repo
105
+
106
+ def is_valid_repo(self) -> bool:
107
+ """Check if this is a valid repository."""
108
+ return (
109
+ self.mem_dir.exists() and
110
+ self.config_file.exists() and
111
+ (self.mem_dir / 'objects').exists()
112
+ )
113
+
114
+ def get_config(self) -> Dict[str, Any]:
115
+ """Get repository configuration."""
116
+ if self.config_file.exists():
117
+ return json.loads(self.config_file.read_text())
118
+ return {}
119
+
120
+ def set_config(self, config: Dict[str, Any]):
121
+ """Set repository configuration."""
122
+ self.config_file.write_text(json.dumps(config, indent=2))
123
+
124
+ def get_author(self) -> str:
125
+ """Get the configured author string."""
126
+ config = self.get_config()
127
+ author = config.get('author', {})
128
+ name = author.get('name', 'Agent')
129
+ email = author.get('email', 'agent@example.com')
130
+ return f"{name} <{email}>"
131
+
132
+ def get_agmem_config(self) -> Dict[str, Any]:
133
+ """Get merged agmem config (user + repo). Use for cloud and PII settings."""
134
+ return load_agmem_config(self.root)
135
+
136
+ def get_head_commit(self) -> Optional[Commit]:
137
+ """Get the current HEAD commit object."""
138
+ if not self.refs:
139
+ return None
140
+
141
+ head = self.refs.get_head()
142
+ if head['type'] == 'branch':
143
+ commit_hash = self.refs.get_branch_commit(head['value'])
144
+ else:
145
+ commit_hash = head['value']
146
+
147
+ if commit_hash:
148
+ return Commit.load(self.object_store, commit_hash)
149
+ return None
150
+
151
+ def get_commit_tree(self, commit_hash: str) -> Optional[Tree]:
152
+ """Get the tree for a specific commit."""
153
+ commit = Commit.load(self.object_store, commit_hash)
154
+ if commit:
155
+ return Tree.load(self.object_store, commit.tree)
156
+ return None
157
+
158
+ def resolve_ref(self, ref: str) -> Optional[str]:
159
+ """Resolve a reference (branch, tag, HEAD, HEAD~n, commit hash) to a commit hash."""
160
+ if not self.refs:
161
+ return None
162
+ return self.refs.resolve_ref(ref, self.object_store)
163
+
164
+ def _path_under_current_dir(self, relative_path: str) -> Optional[Path]:
165
+ """Resolve path under current/; return None if it escapes (path traversal)."""
166
+ try:
167
+ resolved = (self.current_dir / relative_path).resolve()
168
+ resolved.relative_to(self.current_dir.resolve())
169
+ return resolved
170
+ except ValueError:
171
+ return None
172
+
173
+ def stage_file(self, filepath: str, content: Optional[bytes] = None) -> str:
174
+ """
175
+ Stage a file for commit.
176
+
177
+ Args:
178
+ filepath: Path relative to current/ directory
179
+ content: File content (if None, reads from current/)
180
+
181
+ Returns:
182
+ Blob hash of staged content
183
+
184
+ Raises:
185
+ FileNotFoundError: If file does not exist
186
+ ValueError: If filepath escapes current/ (path traversal)
187
+ """
188
+ if content is None:
189
+ full_path = self._path_under_current_dir(filepath)
190
+ if full_path is None:
191
+ raise ValueError(f"Path escapes current directory: {filepath}")
192
+ if not full_path.exists():
193
+ raise FileNotFoundError(f"File not found: {filepath}")
194
+ content = full_path.read_bytes()
195
+
196
+ # Store as blob
197
+ blob = Blob(content=content)
198
+ blob_hash = blob.store(self.object_store)
199
+
200
+ # Add to staging area
201
+ self.staging.add(filepath, blob_hash, content)
202
+
203
+ return blob_hash
204
+
205
+ def _build_tree_from_staged(self) -> str:
206
+ """Build and store tree from staged files. Returns tree hash."""
207
+ staged_files = self.staging.get_staged_files()
208
+ entries = []
209
+ for path, sf in staged_files.items():
210
+ path_obj = Path(path)
211
+ entries.append(TreeEntry(
212
+ mode=oct(sf.mode)[2:],
213
+ obj_type='blob',
214
+ hash=sf.blob_hash,
215
+ name=path_obj.name,
216
+ path=str(path_obj.parent) if str(path_obj.parent) != '.' else ''
217
+ ))
218
+ tree = Tree(entries=entries)
219
+ return tree.store(self.object_store)
220
+
221
+ def _restore_tree_to_current_dir(self, tree: Tree) -> None:
222
+ """Clear current dir and restore files from tree."""
223
+ for item in self.current_dir.iterdir():
224
+ if item.is_dir():
225
+ shutil.rmtree(item)
226
+ else:
227
+ item.unlink()
228
+ for mem_type in MEMORY_TYPES:
229
+ (self.current_dir / mem_type).mkdir(exist_ok=True)
230
+ current_resolved = self.current_dir.resolve()
231
+ for entry in tree.entries:
232
+ # Prevent path traversal: ensure entry path stays under current/
233
+ try:
234
+ filepath = (self.current_dir / entry.path / entry.name).resolve()
235
+ filepath.relative_to(current_resolved)
236
+ except (ValueError, RuntimeError):
237
+ continue
238
+ blob = Blob.load(self.object_store, entry.hash)
239
+ if blob:
240
+ filepath.parent.mkdir(parents=True, exist_ok=True)
241
+ filepath.write_bytes(blob.content)
242
+
243
+ def stage_directory(self, dirpath: str = '') -> Dict[str, str]:
244
+ """
245
+ Stage all files in a directory.
246
+
247
+ Args:
248
+ dirpath: Directory path relative to current/ (empty for all)
249
+
250
+ Returns:
251
+ Dict mapping file paths to blob hashes
252
+ """
253
+ target_dir = self.current_dir / dirpath if dirpath else self.current_dir
254
+ staged = {}
255
+
256
+ for root, dirs, files in os.walk(target_dir):
257
+ # Skip hidden directories
258
+ dirs[:] = [d for d in dirs if not d.startswith('.')]
259
+
260
+ for filename in files:
261
+ full_path = Path(root) / filename
262
+ rel_path = full_path.relative_to(self.current_dir)
263
+
264
+ content = full_path.read_bytes()
265
+ blob_hash = self.stage_file(str(rel_path), content)
266
+ staged[str(rel_path)] = blob_hash
267
+
268
+ return staged
269
+
270
+ def commit(self, message: str, metadata: Optional[Dict[str, Any]] = None) -> str:
271
+ """
272
+ Create a commit from staged changes.
273
+
274
+ Args:
275
+ message: Commit message
276
+ metadata: Additional metadata
277
+
278
+ Returns:
279
+ Commit hash
280
+ """
281
+ staged_files = self.staging.get_staged_files()
282
+
283
+ if not staged_files:
284
+ raise ValueError("No changes staged for commit")
285
+
286
+ tree_hash = self._build_tree_from_staged()
287
+
288
+ # Get parent commit
289
+ head_commit = self.get_head_commit()
290
+ parents = [head_commit.store(self.object_store)] if head_commit else []
291
+
292
+ # Create commit
293
+ commit = Commit(
294
+ tree=tree_hash,
295
+ parents=parents,
296
+ author=self.get_author(),
297
+ timestamp=datetime.utcnow().isoformat() + 'Z',
298
+ message=message,
299
+ metadata=metadata or {}
300
+ )
301
+ commit_hash = commit.store(self.object_store)
302
+
303
+ # Reflog: record HEAD change
304
+ old_hash = parents[0] if parents else '0' * 64
305
+ self.refs.append_reflog('HEAD', old_hash, commit_hash, f'commit: {message}')
306
+
307
+ # Update HEAD
308
+ head = self.refs.get_head()
309
+ if head['type'] == 'branch':
310
+ self.refs.set_branch_commit(head['value'], commit_hash)
311
+ else:
312
+ self.refs.set_head_detached(commit_hash)
313
+
314
+ # Clear staging area
315
+ self.staging.clear()
316
+
317
+ return commit_hash
318
+
319
+ def checkout(self, ref: str, force: bool = False) -> str:
320
+ """
321
+ Checkout a commit or branch.
322
+
323
+ Args:
324
+ ref: Branch name, tag name, or commit hash
325
+ force: Whether to discard uncommitted changes
326
+
327
+ Returns:
328
+ Commit hash that was checked out
329
+ """
330
+ # Get current HEAD for reflog
331
+ old_head = self.refs.get_head()
332
+ old_hash = None
333
+ if old_head['type'] == 'branch':
334
+ old_hash = self.refs.get_branch_commit(old_head['value'])
335
+ else:
336
+ old_hash = old_head.get('value')
337
+
338
+ # Resolve reference
339
+ commit_hash = self.resolve_ref(ref)
340
+ if not commit_hash:
341
+ raise ValueError(f"Reference not found: {ref}")
342
+
343
+ # Validate that the resolved ref is a valid commit
344
+ tree = self.get_commit_tree(commit_hash)
345
+ if not tree:
346
+ raise ValueError(f"Reference not found: {ref}")
347
+
348
+ # Check for uncommitted changes
349
+ if not force:
350
+ staged = self.staging.get_staged_files()
351
+ if staged:
352
+ raise ValueError(
353
+ "You have uncommitted changes. "
354
+ "Commit them or use --force to discard."
355
+ )
356
+
357
+ self._restore_tree_to_current_dir(tree)
358
+
359
+ # Reflog: record HEAD change
360
+ if old_hash and old_hash != commit_hash:
361
+ self.refs.append_reflog('HEAD', old_hash, commit_hash, f'checkout: moving to {ref}')
362
+
363
+ # Update HEAD
364
+ if self.refs.branch_exists(ref):
365
+ self.refs.set_head_branch(ref)
366
+ else:
367
+ self.refs.set_head_detached(commit_hash)
368
+
369
+ # Clear staging
370
+ self.staging.clear()
371
+
372
+ return commit_hash
373
+
374
+ def get_status(self) -> Dict[str, Any]:
375
+ """
376
+ Get repository status.
377
+
378
+ Returns:
379
+ Status dictionary with staged, modified, untracked files
380
+ """
381
+ staged = self.staging.get_staged_files()
382
+
383
+ # Compare current directory with HEAD
384
+ head_commit = self.get_head_commit()
385
+ head_files = {}
386
+
387
+ if head_commit:
388
+ tree = Tree.load(self.object_store, head_commit.tree)
389
+ if tree:
390
+ for entry in tree.entries:
391
+ path = entry.path + '/' + entry.name if entry.path else entry.name
392
+ head_files[path] = entry.hash
393
+
394
+ # Check working directory
395
+ modified = []
396
+ untracked = []
397
+
398
+ for root, dirs, files in os.walk(self.current_dir):
399
+ dirs[:] = [d for d in dirs if not d.startswith('.')]
400
+
401
+ for filename in files:
402
+ full_path = Path(root) / filename
403
+ rel_path = str(full_path.relative_to(self.current_dir))
404
+
405
+ if rel_path not in staged:
406
+ content = full_path.read_bytes()
407
+ blob = Blob(content=content)
408
+ blob_hash = blob.store(self.object_store)
409
+
410
+ if rel_path in head_files:
411
+ if head_files[rel_path] != blob_hash:
412
+ modified.append(rel_path)
413
+ else:
414
+ untracked.append(rel_path)
415
+
416
+ # Check for deleted files
417
+ deleted = []
418
+ for path in head_files:
419
+ full_path = self.current_dir / path
420
+ if not full_path.exists() and path not in staged:
421
+ deleted.append(path)
422
+
423
+ return {
424
+ 'staged': list(staged.keys()),
425
+ 'modified': modified,
426
+ 'untracked': untracked,
427
+ 'deleted': deleted,
428
+ 'head': self.refs.get_head(),
429
+ 'branch': self.refs.get_current_branch()
430
+ }
431
+
432
+ def get_log(self, max_count: int = 10) -> List[Dict[str, Any]]:
433
+ """
434
+ Get commit history.
435
+
436
+ Args:
437
+ max_count: Maximum number of commits to return
438
+
439
+ Returns:
440
+ List of commit info dictionaries
441
+ """
442
+ commits = []
443
+ commit_hash = None
444
+
445
+ # Get starting commit
446
+ head = self.refs.get_head()
447
+ if head['type'] == 'branch':
448
+ commit_hash = self.refs.get_branch_commit(head['value'])
449
+ else:
450
+ commit_hash = head['value']
451
+
452
+ # Walk back through parents
453
+ while commit_hash and len(commits) < max_count:
454
+ commit = Commit.load(self.object_store, commit_hash)
455
+ if not commit:
456
+ break
457
+
458
+ commits.append({
459
+ 'hash': commit_hash,
460
+ 'short_hash': commit_hash[:8],
461
+ 'message': commit.message,
462
+ 'author': commit.author,
463
+ 'timestamp': commit.timestamp,
464
+ 'parents': commit.parents
465
+ })
466
+
467
+ # Follow first parent (linear history for now)
468
+ commit_hash = commit.parents[0] if commit.parents else None
469
+
470
+ return commits
471
+
472
+ def stash_create(self, message: str = '') -> Optional[str]:
473
+ """
474
+ Stash current changes (staged + modified + untracked) and reset to HEAD.
475
+ Returns stash commit hash or None if nothing to stash.
476
+ """
477
+ status = self.get_status()
478
+ if not status['staged'] and not status['modified'] and not status['untracked']:
479
+ return None
480
+
481
+ # Stage everything
482
+ self.stage_directory()
483
+ staged = self.staging.get_staged_files()
484
+ if not staged:
485
+ return None
486
+
487
+ # Create stash commit (parent = HEAD)
488
+ head_commit = self.get_head_commit()
489
+ parents = [head_commit.store(self.object_store)] if head_commit else []
490
+
491
+ tree_hash = self._build_tree_from_staged()
492
+
493
+ stash_commit = Commit(
494
+ tree=tree_hash,
495
+ parents=parents,
496
+ author=self.get_author(),
497
+ timestamp=datetime.utcnow().isoformat() + 'Z',
498
+ message=message or 'WIP on ' + (self.refs.get_current_branch() or 'HEAD'),
499
+ metadata={'stash': True}
500
+ )
501
+ stash_hash = stash_commit.store(self.object_store)
502
+
503
+ self.refs.stash_push(stash_hash, message)
504
+ self.staging.clear()
505
+
506
+ head_hash = self.resolve_ref('HEAD')
507
+ if head_hash:
508
+ tree = self.get_commit_tree(head_hash)
509
+ if tree:
510
+ self._restore_tree_to_current_dir(tree)
511
+
512
+ return stash_hash
513
+
514
+ def stash_pop(self, index: int = 0) -> Optional[str]:
515
+ """Apply stash at index and remove from stash list."""
516
+ stash_hash = self.refs.stash_pop(index)
517
+ if not stash_hash:
518
+ return None
519
+ tree = self.get_commit_tree(stash_hash)
520
+ if tree:
521
+ self._restore_tree_to_current_dir(tree)
522
+ return stash_hash