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,174 @@
1
+ """
2
+ agmem commit - Save staged changes as a snapshot.
3
+ """
4
+
5
+ import argparse
6
+ from datetime import datetime
7
+
8
+ from ..commands.base import require_repo
9
+ from ..core.schema import SchemaValidator
10
+ from ..core.hooks import run_pre_commit_hooks
11
+
12
+
13
+ class CommitCommand:
14
+ """Create a commit from staged changes."""
15
+
16
+ name = 'commit'
17
+ help = 'Save staged changes as a memory snapshot'
18
+
19
+ @staticmethod
20
+ def add_arguments(parser: argparse.ArgumentParser):
21
+ parser.add_argument(
22
+ '-m', '--message',
23
+ required=True,
24
+ help='Commit message describing the changes'
25
+ )
26
+ parser.add_argument(
27
+ '--author',
28
+ help='Override default author'
29
+ )
30
+ parser.add_argument(
31
+ '--no-verify',
32
+ action='store_true',
33
+ help='Skip pre-commit hooks and schema validation'
34
+ )
35
+ parser.add_argument(
36
+ '--strict',
37
+ action='store_true',
38
+ help='Treat schema warnings as errors'
39
+ )
40
+ parser.add_argument(
41
+ '--run-tests',
42
+ action='store_true',
43
+ help='Run memory tests before committing'
44
+ )
45
+
46
+ @staticmethod
47
+ def _get_blob_hash(file_info) -> str:
48
+ """Get blob hash from StagedFile or dict (for hooks that pass either)."""
49
+ if hasattr(file_info, 'blob_hash'):
50
+ return file_info.blob_hash
51
+ if isinstance(file_info, dict):
52
+ return file_info.get('blob_hash') or file_info.get('hash') or ''
53
+ return ''
54
+
55
+ @staticmethod
56
+ def _validate_staged_files(repo, staged: dict, strict: bool) -> tuple:
57
+ """
58
+ Validate staged files for schema compliance.
59
+
60
+ Returns:
61
+ Tuple of (success, validation_results)
62
+ """
63
+ validation_results = {}
64
+ has_errors = False
65
+
66
+ for filepath, file_info in staged.items():
67
+ blob_hash = CommitCommand._get_blob_hash(file_info)
68
+ if not blob_hash:
69
+ continue
70
+
71
+ # Read content from object store
72
+ from ..core.objects import Blob
73
+ blob = Blob.load(repo.object_store, blob_hash)
74
+ if not blob:
75
+ continue
76
+
77
+ try:
78
+ content = blob.content.decode('utf-8')
79
+ except UnicodeDecodeError:
80
+ # Skip binary files
81
+ continue
82
+
83
+ # Validate the file
84
+ result = SchemaValidator.validate(content, filepath, strict=strict)
85
+ validation_results[filepath] = result
86
+
87
+ if not result.valid:
88
+ has_errors = True
89
+
90
+ return not has_errors, validation_results
91
+
92
+ @staticmethod
93
+ def _print_validation_results(results: dict) -> None:
94
+ """Print validation results in a readable format."""
95
+ for filepath, result in results.items():
96
+ if not result.valid or result.warnings:
97
+ print(f"\n {filepath}:")
98
+ for error in result.errors:
99
+ print(f" ✗ {error.field}: {error.message}")
100
+ for warning in result.warnings:
101
+ print(f" ⚠ {warning.field}: {warning.message}")
102
+
103
+ @staticmethod
104
+ def _run_hooks_and_validation(repo, staged: dict, args) -> tuple:
105
+ """Run pre-commit hooks and schema validation. Returns (success, error_code or 0)."""
106
+ hook_result = run_pre_commit_hooks(repo, staged)
107
+ if not hook_result.success:
108
+ print("Pre-commit hook failed:")
109
+ for error in hook_result.errors:
110
+ print(f" ✗ {error}")
111
+ print("\nUse --no-verify to bypass hooks (not recommended).")
112
+ return False, 1
113
+ valid, results = CommitCommand._validate_staged_files(repo, staged, strict=args.strict)
114
+ if not valid:
115
+ print("Schema validation failed:")
116
+ CommitCommand._print_validation_results(results)
117
+ print("\nFix the errors above or use --no-verify to bypass validation.")
118
+ return False, 1
119
+ if any(r.warnings for r in results.values()):
120
+ print("Schema validation warnings:")
121
+ CommitCommand._print_validation_results(results)
122
+ return True, 0
123
+
124
+ @staticmethod
125
+ def _run_memory_tests(repo) -> int:
126
+ """Run memory tests if available. Returns 1 on failure, 0 on success or skip."""
127
+ try:
128
+ from ..core.test_runner import TestRunner
129
+ test_runner = TestRunner(repo)
130
+ test_result = test_runner.run_all()
131
+ if not test_result.passed:
132
+ print("Memory tests failed:")
133
+ for failure in test_result.failures:
134
+ print(f" ✗ {failure.test_name}: {failure.message}")
135
+ print("\nFix failing tests before committing.")
136
+ return 1
137
+ print(f"Memory tests passed: {test_result.passed_count}/{test_result.total_count}")
138
+ except ImportError:
139
+ print("Warning: Test runner not available. Skipping tests.")
140
+ return 0
141
+
142
+ @staticmethod
143
+ def execute(args) -> int:
144
+ repo, code = require_repo()
145
+ if code != 0:
146
+ return code
147
+ staged = repo.staging.get_staged_files()
148
+ if not staged:
149
+ print("Error: No changes staged for commit.")
150
+ print("Run 'agmem add <file>' to stage changes first.")
151
+ return 1
152
+ if not args.no_verify:
153
+ ok, err = CommitCommand._run_hooks_and_validation(repo, staged, args)
154
+ if not ok:
155
+ return err
156
+ if args.run_tests:
157
+ if CommitCommand._run_memory_tests(repo) != 0:
158
+ return 1
159
+ metadata = {
160
+ 'files_changed': len(staged),
161
+ 'timestamp': datetime.utcnow().isoformat() + 'Z'
162
+ }
163
+ if args.author:
164
+ config = repo.get_config()
165
+ config['author']['name'] = args.author
166
+ repo.set_config(config)
167
+ try:
168
+ commit_hash = repo.commit(args.message, metadata)
169
+ print(f"[{repo.refs.get_current_branch() or 'HEAD'} {commit_hash[:8]}] {args.message}")
170
+ print(f" {len(staged)} file(s) changed")
171
+ return 0
172
+ except Exception as e:
173
+ print(f"Error creating commit: {e}")
174
+ return 1
@@ -0,0 +1,267 @@
1
+ """
2
+ agmem daemon - Auto-sync daemon for automatic commits.
3
+ """
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ import time
9
+ import signal
10
+ from pathlib import Path
11
+
12
+ from ..commands.base import require_repo
13
+
14
+
15
+ class DaemonCommand:
16
+ """Control the auto-sync daemon."""
17
+
18
+ name = 'daemon'
19
+ help = 'Start/stop the auto-sync daemon for automatic commits'
20
+
21
+ @staticmethod
22
+ def add_arguments(parser: argparse.ArgumentParser):
23
+ parser.add_argument(
24
+ 'action',
25
+ choices=['start', 'stop', 'status', 'run'],
26
+ help='Daemon action: start, stop, status, or run (foreground)'
27
+ )
28
+ parser.add_argument(
29
+ '--debounce',
30
+ type=int,
31
+ default=30,
32
+ help='Seconds to wait after changes before committing (default: 30)'
33
+ )
34
+ parser.add_argument(
35
+ '--pidfile',
36
+ help='PID file path (default: .mem/daemon.pid)'
37
+ )
38
+
39
+ @staticmethod
40
+ def execute(args) -> int:
41
+ repo, code = require_repo()
42
+ if code != 0:
43
+ return code
44
+
45
+ if args.pidfile:
46
+ pid_file = Path(args.pidfile).resolve()
47
+ try:
48
+ pid_file.relative_to(repo.root.resolve())
49
+ except ValueError:
50
+ print("Error: --pidfile must be under repository root")
51
+ return 1
52
+ else:
53
+ pid_file = repo.root / '.mem' / 'daemon.pid'
54
+
55
+ if args.action == 'start':
56
+ return DaemonCommand._start(repo, pid_file, args.debounce)
57
+ elif args.action == 'stop':
58
+ return DaemonCommand._stop(pid_file)
59
+ elif args.action == 'status':
60
+ return DaemonCommand._status(pid_file)
61
+ elif args.action == 'run':
62
+ return DaemonCommand._run(repo, args.debounce)
63
+
64
+ return 1
65
+
66
+ @staticmethod
67
+ def _start(repo, pid_file: Path, debounce: int) -> int:
68
+ """Start daemon in background."""
69
+ # Check if already running
70
+ if pid_file.exists():
71
+ pid = int(pid_file.read_text().strip())
72
+ try:
73
+ os.kill(pid, 0)
74
+ print(f"Daemon already running (PID: {pid})")
75
+ return 1
76
+ except OSError:
77
+ pid_file.unlink()
78
+
79
+ # Fork to background (Unix only)
80
+ if os.name != 'posix':
81
+ print("Background daemon not supported on this platform.")
82
+ print("Use 'agmem daemon run' to run in foreground.")
83
+ return 1
84
+
85
+ # First fork
86
+ try:
87
+ pid = os.fork()
88
+ if pid > 0:
89
+ # Parent exits
90
+ print(f"Daemon started (PID: {pid})")
91
+ return 0
92
+ except OSError as e:
93
+ print(f"Fork failed: {e}")
94
+ return 1
95
+
96
+ # Decouple from parent
97
+ os.setsid()
98
+ os.umask(0)
99
+
100
+ # Second fork
101
+ try:
102
+ pid = os.fork()
103
+ if pid > 0:
104
+ sys.exit(0)
105
+ except OSError:
106
+ sys.exit(1)
107
+
108
+ # Write PID file
109
+ pid_file.write_text(str(os.getpid()))
110
+
111
+ # Redirect stdio
112
+ sys.stdout.flush()
113
+ sys.stderr.flush()
114
+
115
+ # Run daemon
116
+ return DaemonCommand._run(repo, debounce, pid_file)
117
+
118
+ @staticmethod
119
+ def _stop(pid_file: Path) -> int:
120
+ """Stop running daemon."""
121
+ if not pid_file.exists():
122
+ print("No daemon running")
123
+ return 0
124
+
125
+ pid = int(pid_file.read_text().strip())
126
+
127
+ try:
128
+ os.kill(pid, signal.SIGTERM)
129
+ print(f"Stopped daemon (PID: {pid})")
130
+ pid_file.unlink()
131
+ return 0
132
+ except OSError as e:
133
+ print(f"Could not stop daemon: {e}")
134
+ pid_file.unlink()
135
+ return 1
136
+
137
+ @staticmethod
138
+ def _status(pid_file: Path) -> int:
139
+ """Show daemon status."""
140
+ if not pid_file.exists():
141
+ print("Daemon is not running")
142
+ return 0
143
+
144
+ pid = int(pid_file.read_text().strip())
145
+
146
+ try:
147
+ os.kill(pid, 0)
148
+ print(f"Daemon is running (PID: {pid})")
149
+ return 0
150
+ except OSError:
151
+ print("Daemon is not running (stale PID file)")
152
+ pid_file.unlink()
153
+ return 0
154
+
155
+ @staticmethod
156
+ def _run(repo, debounce: int, pid_file: Path = None) -> int:
157
+ """Run daemon in foreground."""
158
+ try:
159
+ from watchdog.observers import Observer
160
+ from watchdog.events import FileSystemEventHandler
161
+ except ImportError:
162
+ print("Daemon requires watchdog. Install with: pip install agmem[daemon]")
163
+ return 1
164
+
165
+ current_dir = repo.root / 'current'
166
+
167
+ if not current_dir.exists():
168
+ print("No current/ directory to watch")
169
+ return 1
170
+
171
+ class MemoryFileHandler(FileSystemEventHandler):
172
+ def __init__(self):
173
+ self.last_change = 0
174
+ self.pending = False
175
+
176
+ def on_any_event(self, event):
177
+ # Ignore .mem and hidden files
178
+ if '.mem' in event.src_path or '/.' in event.src_path:
179
+ return
180
+
181
+ # Ignore directories
182
+ if event.is_directory:
183
+ return
184
+
185
+ self.last_change = time.time()
186
+ self.pending = True
187
+
188
+ handler = MemoryFileHandler()
189
+ observer = Observer()
190
+ observer.schedule(handler, str(current_dir), recursive=True)
191
+ observer.start()
192
+
193
+ print(f"Watching {current_dir} (debounce: {debounce}s)")
194
+ print("Press Ctrl+C to stop")
195
+
196
+ # Handle signals
197
+ running = True
198
+
199
+ def signal_handler(signum, frame):
200
+ nonlocal running
201
+ running = False
202
+
203
+ signal.signal(signal.SIGTERM, signal_handler)
204
+ signal.signal(signal.SIGINT, signal_handler)
205
+
206
+ try:
207
+ while running:
208
+ time.sleep(1)
209
+
210
+ if handler.pending:
211
+ elapsed = time.time() - handler.last_change
212
+ if elapsed >= debounce:
213
+ # Auto-commit
214
+ DaemonCommand._auto_commit(repo)
215
+ handler.pending = False
216
+ finally:
217
+ observer.stop()
218
+ observer.join()
219
+
220
+ if pid_file and pid_file.exists():
221
+ pid_file.unlink()
222
+
223
+ print("Daemon stopped")
224
+ return 0
225
+
226
+ @staticmethod
227
+ def _auto_commit(repo):
228
+ """Perform automatic commit."""
229
+ from datetime import datetime
230
+
231
+ try:
232
+ # Check for changes
233
+ status = repo.get_status()
234
+
235
+ if not status.get('modified') and not status.get('untracked'):
236
+ return
237
+
238
+ # Stage all changes in current/ (validate path stays under current/)
239
+ current_dir = repo.root / 'current'
240
+ for memory_file in current_dir.glob('**/*'):
241
+ if memory_file.is_file() and '.mem' not in str(memory_file):
242
+ try:
243
+ rel_path = memory_file.relative_to(current_dir)
244
+ except ValueError:
245
+ continue
246
+ rel_str = str(rel_path)
247
+ if repo._path_under_current_dir(rel_str) is None:
248
+ continue
249
+ try:
250
+ repo.stage_file(rel_str)
251
+ except Exception:
252
+ pass
253
+
254
+ # Commit
255
+ timestamp = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
256
+ repo.commit(
257
+ f"auto: update memory state ({timestamp})",
258
+ {'auto_commit': True}
259
+ )
260
+
261
+ print(f"[{timestamp}] Auto-committed changes")
262
+
263
+ except Exception as e:
264
+ # Write conflict lock if there's an issue
265
+ conflict_file = repo.root / '.mem' / 'CONFLICT.lock'
266
+ conflict_file.write_text(f"Auto-commit failed: {e}")
267
+ print(f"Warning: Auto-commit failed, wrote CONFLICT.lock: {e}")
@@ -0,0 +1,157 @@
1
+ """
2
+ agmem diff - Show changes between commits, commit and working tree, etc.
3
+ """
4
+
5
+ import argparse
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from ..commands.base import require_repo
10
+ from ..core.diff import DiffEngine
11
+ from ..core.repository import Repository
12
+
13
+
14
+ class DiffCommand:
15
+ """Show differences between commits."""
16
+
17
+ name = 'diff'
18
+ help = 'Show changes between commits, commit and working tree, etc.'
19
+
20
+ @staticmethod
21
+ def add_arguments(parser: argparse.ArgumentParser):
22
+ parser.add_argument(
23
+ 'ref1',
24
+ nargs='?',
25
+ help='First commit/branch to compare'
26
+ )
27
+ parser.add_argument(
28
+ 'ref2',
29
+ nargs='?',
30
+ help='Second commit/branch to compare'
31
+ )
32
+ parser.add_argument(
33
+ '--cached', '--staged',
34
+ action='store_true',
35
+ help='Show changes staged for commit'
36
+ )
37
+ parser.add_argument(
38
+ '--stat',
39
+ action='store_true',
40
+ help='Show diffstat instead of full diff'
41
+ )
42
+
43
+ @staticmethod
44
+ def execute(args) -> int:
45
+ repo, code = require_repo()
46
+ if code != 0:
47
+ return code
48
+
49
+ engine = DiffEngine(repo.object_store)
50
+
51
+ # Determine what to diff
52
+ if args.cached:
53
+ # Diff staged changes against HEAD
54
+ head_commit = repo.get_head_commit()
55
+ if not head_commit:
56
+ print("No commits yet. Nothing to diff.")
57
+ return 0
58
+
59
+ staged_files = repo.staging.get_staged_files()
60
+ if not staged_files:
61
+ print("No staged changes.")
62
+ return 0
63
+
64
+ # Get staged content
65
+ working_files = {}
66
+ for path, sf in staged_files.items():
67
+ from ..core.objects import Blob
68
+ blob = Blob.load(repo.object_store, sf.blob_hash)
69
+ if blob:
70
+ working_files[path] = blob.content
71
+
72
+ tree_diff = engine.diff_working_dir(
73
+ head_commit.store(repo.object_store),
74
+ working_files
75
+ )
76
+
77
+ print(engine.format_diff(tree_diff, 'HEAD', 'staged'))
78
+ return 0
79
+
80
+ # Diff between two refs
81
+ if args.ref1 and args.ref2:
82
+ commit1 = repo.resolve_ref(args.ref1)
83
+ commit2 = repo.resolve_ref(args.ref2)
84
+
85
+ if not commit1:
86
+ print(f"Error: Unknown revision: {args.ref1}")
87
+ return 1
88
+ if not commit2:
89
+ print(f"Error: Unknown revision: {args.ref2}")
90
+ return 1
91
+
92
+ tree_diff = engine.diff_commits(commit1, commit2)
93
+
94
+ if args.stat:
95
+ print(f" {tree_diff.added_count} file(s) added")
96
+ print(f" {tree_diff.deleted_count} file(s) deleted")
97
+ print(f" {tree_diff.modified_count} file(s) modified")
98
+ else:
99
+ print(engine.format_diff(tree_diff, args.ref1, args.ref2))
100
+
101
+ return 0
102
+
103
+ # Diff working tree against a ref
104
+ if args.ref1:
105
+ commit_hash = repo.resolve_ref(args.ref1)
106
+ if not commit_hash:
107
+ print(f"Error: Unknown revision: {args.ref1}")
108
+ return 1
109
+
110
+ # Get working directory files
111
+ working_files = {}
112
+ for root, dirs, files in os.walk(repo.current_dir):
113
+ dirs[:] = [d for d in dirs if not d.startswith('.')]
114
+ for filename in files:
115
+ full_path = Path(root) / filename
116
+ rel_path = str(full_path.relative_to(repo.current_dir))
117
+ working_files[rel_path] = full_path.read_bytes()
118
+
119
+ tree_diff = engine.diff_working_dir(commit_hash, working_files)
120
+
121
+ if args.stat:
122
+ print(f" {tree_diff.added_count} file(s) added")
123
+ print(f" {tree_diff.deleted_count} file(s) deleted")
124
+ print(f" {tree_diff.modified_count} file(s) modified")
125
+ else:
126
+ print(engine.format_diff(tree_diff, args.ref1, 'working'))
127
+
128
+ return 0
129
+
130
+ # Default: diff working tree against HEAD
131
+ head_commit = repo.get_head_commit()
132
+ if not head_commit:
133
+ print("No commits yet. Nothing to diff.")
134
+ return 0
135
+
136
+ # Get working directory files
137
+ working_files = {}
138
+ for root, dirs, files in os.walk(repo.current_dir):
139
+ dirs[:] = [d for d in dirs if not d.startswith('.')]
140
+ for filename in files:
141
+ full_path = Path(root) / filename
142
+ rel_path = str(full_path.relative_to(repo.current_dir))
143
+ working_files[rel_path] = full_path.read_bytes()
144
+
145
+ tree_diff = engine.diff_working_dir(
146
+ head_commit.store(repo.object_store),
147
+ working_files
148
+ )
149
+
150
+ if args.stat:
151
+ print(f" {tree_diff.added_count} file(s) added")
152
+ print(f" {tree_diff.deleted_count} file(s) deleted")
153
+ print(f" {tree_diff.modified_count} file(s) modified")
154
+ else:
155
+ print(engine.format_diff(tree_diff, 'HEAD', 'working'))
156
+
157
+ return 0