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/merge.py ADDED
@@ -0,0 +1,474 @@
1
+ """
2
+ Merge functionality for agmem.
3
+
4
+ Implements memory-type-aware merging strategies with frontmatter support.
5
+ """
6
+
7
+ import json
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Optional, List, Dict, Any, Tuple
11
+ from dataclasses import dataclass
12
+ from enum import Enum
13
+
14
+ from .objects import ObjectStore, Commit, Tree, TreeEntry, Blob
15
+ from .repository import Repository
16
+ from .schema import FrontmatterParser, FrontmatterData, compare_timestamps
17
+
18
+
19
+ class MergeStrategy(Enum):
20
+ """Merge strategies for different memory types."""
21
+ EPISODIC = "episodic" # Append chronologically
22
+ SEMANTIC = "semantic" # Smart consolidation with conflict detection
23
+ PROCEDURAL = "procedural" # Prefer newer, validate compatibility
24
+
25
+
26
+ @dataclass
27
+ class Conflict:
28
+ """Represents a merge conflict."""
29
+ path: str
30
+ base_content: Optional[str]
31
+ ours_content: Optional[str]
32
+ theirs_content: Optional[str]
33
+ message: str
34
+
35
+
36
+ @dataclass
37
+ class MergeResult:
38
+ """Result of a merge operation."""
39
+ success: bool
40
+ commit_hash: Optional[str]
41
+ conflicts: List[Conflict]
42
+ message: str
43
+
44
+
45
+ class MergeEngine:
46
+ """Engine for merging memory branches."""
47
+
48
+ def __init__(self, repo: Repository):
49
+ self.repo = repo
50
+ self.object_store = repo.object_store
51
+
52
+ def detect_memory_type(self, filepath: str) -> MergeStrategy:
53
+ """
54
+ Detect the memory type from file path.
55
+
56
+ Args:
57
+ filepath: Path to the file
58
+
59
+ Returns:
60
+ MergeStrategy for this file type
61
+ """
62
+ path_lower = filepath.lower()
63
+
64
+ if 'episodic' in path_lower:
65
+ return MergeStrategy.EPISODIC
66
+ elif 'semantic' in path_lower:
67
+ return MergeStrategy.SEMANTIC
68
+ elif 'procedural' in path_lower or 'workflow' in path_lower:
69
+ return MergeStrategy.PROCEDURAL
70
+
71
+ # Default to semantic for unknown types
72
+ return MergeStrategy.SEMANTIC
73
+
74
+ def find_common_ancestor(self, commit1: str, commit2: str) -> Optional[str]:
75
+ """
76
+ Find the common ancestor of two commits.
77
+
78
+ Args:
79
+ commit1: First commit hash
80
+ commit2: Second commit hash
81
+
82
+ Returns:
83
+ Common ancestor commit hash or None
84
+ """
85
+ # Build ancestor chain for commit1
86
+ ancestors1 = set()
87
+ current = commit1
88
+
89
+ while current:
90
+ ancestors1.add(current)
91
+ commit = Commit.load(self.object_store, current)
92
+ if not commit or not commit.parents:
93
+ break
94
+ current = commit.parents[0] # Follow first parent
95
+
96
+ # Walk back from commit2 and find first common ancestor
97
+ current = commit2
98
+ while current:
99
+ if current in ancestors1:
100
+ return current
101
+
102
+ commit = Commit.load(self.object_store, current)
103
+ if not commit or not commit.parents:
104
+ break
105
+ current = commit.parents[0]
106
+
107
+ return None
108
+
109
+ def get_tree_files(self, tree_hash: str) -> Dict[str, str]:
110
+ """
111
+ Get all files in a tree.
112
+
113
+ Args:
114
+ tree_hash: Hash of tree object
115
+
116
+ Returns:
117
+ Dict mapping file paths to blob hashes
118
+ """
119
+ files = {}
120
+ tree = Tree.load(self.object_store, tree_hash)
121
+
122
+ if tree:
123
+ for entry in tree.entries:
124
+ path = entry.path + '/' + entry.name if entry.path else entry.name
125
+ files[path] = entry.hash
126
+
127
+ return files
128
+
129
+ def merge_episodic(self, base_content: Optional[str], ours_content: Optional[str],
130
+ theirs_content: Optional[str]) -> Tuple[str, bool]:
131
+ """
132
+ Merge episodic memory (append chronologically).
133
+
134
+ Returns:
135
+ Tuple of (merged_content, had_conflict)
136
+ """
137
+ # Episodic logs are append-only
138
+ parts = []
139
+
140
+ if base_content:
141
+ parts.append(base_content)
142
+
143
+ # Add ours if different from base
144
+ if ours_content and ours_content != base_content:
145
+ parts.append(ours_content)
146
+
147
+ # Add theirs if different from base and ours
148
+ if theirs_content and theirs_content != base_content and theirs_content != ours_content:
149
+ parts.append(theirs_content)
150
+
151
+ # Combine with clear separators
152
+ merged = '\n\n---\n\n'.join(parts)
153
+ return merged, False # Episodic never conflicts
154
+
155
+ def merge_semantic(self, base_content: Optional[str], ours_content: Optional[str],
156
+ theirs_content: Optional[str]) -> Tuple[str, bool]:
157
+ """
158
+ Merge semantic memory (smart consolidation).
159
+
160
+ Uses frontmatter timestamps for Last-Write-Wins when both sides have valid timestamps.
161
+ Falls back to conflict markers for manual review if:
162
+ - Neither has frontmatter
163
+ - Low confidence scores require review
164
+
165
+ Returns:
166
+ Tuple of (merged_content, had_conflict)
167
+ """
168
+ # If ours == theirs, no conflict
169
+ if ours_content == theirs_content:
170
+ return ours_content or '', False
171
+
172
+ # If one is same as base, use the other
173
+ if ours_content == base_content:
174
+ return theirs_content or '', False
175
+ if theirs_content == base_content:
176
+ return ours_content or '', False
177
+
178
+ # Both changed from base - try frontmatter-based resolution
179
+ ours_fm, ours_body = FrontmatterParser.parse(ours_content or '')
180
+ theirs_fm, theirs_body = FrontmatterParser.parse(theirs_content or '')
181
+
182
+ # Check if we can use Last-Write-Wins based on timestamps
183
+ if ours_fm and theirs_fm and ours_fm.last_updated and theirs_fm.last_updated:
184
+ # Check confidence scores - if either is low, require manual review
185
+ ours_confidence = ours_fm.confidence_score or 1.0
186
+ theirs_confidence = theirs_fm.confidence_score or 1.0
187
+
188
+ # If both have reasonable confidence (> 0.5), use Last-Write-Wins
189
+ if ours_confidence > 0.5 and theirs_confidence > 0.5:
190
+ comparison = compare_timestamps(ours_fm.last_updated, theirs_fm.last_updated)
191
+
192
+ if comparison > 0:
193
+ # Ours is newer
194
+ return ours_content or '', False
195
+ elif comparison < 0:
196
+ # Theirs is newer
197
+ return theirs_content or '', False
198
+ # Equal timestamps - fall through to conflict
199
+ else:
200
+ # Low confidence - add note in conflict for review
201
+ merged = f"""<<<<<<< OURS (confidence: {ours_confidence})
202
+ {ours_content}
203
+ =======
204
+ {theirs_content}
205
+ >>>>>>> THEIRS (confidence: {theirs_confidence})
206
+ """
207
+ return merged, True
208
+
209
+ # No frontmatter or timestamps - use conflict markers
210
+ merged = f"""<<<<<<< OURS
211
+ {ours_content}
212
+ =======
213
+ {theirs_content}
214
+ >>>>>>> THEIRS
215
+ """
216
+ return merged, True
217
+
218
+ def merge_procedural(self, base_content: Optional[str], ours_content: Optional[str],
219
+ theirs_content: Optional[str]) -> Tuple[str, bool]:
220
+ """
221
+ Merge procedural memory (prefer newer, validate).
222
+
223
+ Uses frontmatter timestamps to determine which version is newer.
224
+ Procedural memory is more likely to auto-resolve using Last-Write-Wins
225
+ since workflows typically should be replaced, not merged.
226
+
227
+ Returns:
228
+ Tuple of (merged_content, had_conflict)
229
+ """
230
+ # If ours == theirs, no conflict
231
+ if ours_content == theirs_content:
232
+ return ours_content or '', False
233
+
234
+ # If one is same as base, use the other
235
+ if ours_content == base_content:
236
+ return theirs_content or '', False
237
+ if theirs_content == base_content:
238
+ return ours_content or '', False
239
+
240
+ # Both changed - try to use frontmatter timestamps
241
+ ours_fm, _ = FrontmatterParser.parse(ours_content or '')
242
+ theirs_fm, _ = FrontmatterParser.parse(theirs_content or '')
243
+
244
+ # Use timestamps if available
245
+ if ours_fm and theirs_fm and ours_fm.last_updated and theirs_fm.last_updated:
246
+ comparison = compare_timestamps(ours_fm.last_updated, theirs_fm.last_updated)
247
+
248
+ if comparison > 0:
249
+ # Ours is newer - keep it
250
+ return ours_content or '', False
251
+ elif comparison < 0:
252
+ # Theirs is newer - use it
253
+ return theirs_content or '', False
254
+ # Equal timestamps - fall through to conflict
255
+
256
+ # No timestamps or equal - flag for manual review
257
+ merged = f"""<<<<<<< OURS (Current)
258
+ {ours_content}
259
+ =======
260
+ {theirs_content}
261
+ >>>>>>> THEIRS (Incoming)
262
+ """
263
+ return merged, True
264
+
265
+ def merge_files(self, base_files: Dict[str, str], ours_files: Dict[str, str],
266
+ theirs_files: Dict[str, str]) -> Tuple[Dict[str, str], List[Conflict]]:
267
+ """
268
+ Merge file sets from three trees.
269
+
270
+ Returns:
271
+ Tuple of (merged_files, conflicts)
272
+ """
273
+ merged = {}
274
+ conflicts = []
275
+
276
+ # Get all unique file paths
277
+ all_paths = set(base_files.keys()) | set(ours_files.keys()) | set(theirs_files.keys())
278
+
279
+ for path in all_paths:
280
+ base_hash = base_files.get(path)
281
+ ours_hash = ours_files.get(path)
282
+ theirs_hash = theirs_files.get(path)
283
+
284
+ # Get content
285
+ base_content = None
286
+ ours_content = None
287
+ theirs_content = None
288
+
289
+ if base_hash:
290
+ blob = Blob.load(self.object_store, base_hash)
291
+ if blob:
292
+ base_content = blob.content.decode('utf-8', errors='replace')
293
+
294
+ if ours_hash:
295
+ blob = Blob.load(self.object_store, ours_hash)
296
+ if blob:
297
+ ours_content = blob.content.decode('utf-8', errors='replace')
298
+
299
+ if theirs_hash:
300
+ blob = Blob.load(self.object_store, theirs_hash)
301
+ if blob:
302
+ theirs_content = blob.content.decode('utf-8', errors='replace')
303
+
304
+ # Determine merge strategy
305
+ strategy = self.detect_memory_type(path)
306
+
307
+ # Apply merge
308
+ if strategy == MergeStrategy.EPISODIC:
309
+ merged_content, had_conflict = self.merge_episodic(
310
+ base_content, ours_content, theirs_content
311
+ )
312
+ elif strategy == MergeStrategy.PROCEDURAL:
313
+ merged_content, had_conflict = self.merge_procedural(
314
+ base_content, ours_content, theirs_content
315
+ )
316
+ else: # SEMANTIC
317
+ merged_content, had_conflict = self.merge_semantic(
318
+ base_content, ours_content, theirs_content
319
+ )
320
+
321
+ # Store merged content
322
+ if merged_content is not None:
323
+ blob = Blob(content=merged_content.encode('utf-8'))
324
+ merged_hash = blob.store(self.object_store)
325
+ merged[path] = merged_hash
326
+
327
+ # Record conflict if any
328
+ if had_conflict:
329
+ conflicts.append(Conflict(
330
+ path=path,
331
+ base_content=base_content,
332
+ ours_content=ours_content,
333
+ theirs_content=theirs_content,
334
+ message=f"{strategy.value} merge conflict in {path}"
335
+ ))
336
+
337
+ return merged, conflicts
338
+
339
+ def merge(self, source_branch: str, target_branch: Optional[str] = None,
340
+ message: Optional[str] = None) -> MergeResult:
341
+ """
342
+ Merge source branch into target branch (or current branch).
343
+
344
+ Args:
345
+ source_branch: Branch to merge from
346
+ target_branch: Branch to merge into (None for current)
347
+ message: Merge commit message
348
+
349
+ Returns:
350
+ MergeResult with success status and conflicts
351
+ """
352
+ # Resolve branches
353
+ source_commit_hash = self.repo.resolve_ref(source_branch)
354
+ if not source_commit_hash:
355
+ return MergeResult(
356
+ success=False,
357
+ commit_hash=None,
358
+ conflicts=[],
359
+ message=f"Source branch not found: {source_branch}"
360
+ )
361
+
362
+ if target_branch:
363
+ target_commit_hash = self.repo.resolve_ref(target_branch)
364
+ if not target_commit_hash:
365
+ return MergeResult(
366
+ success=False,
367
+ commit_hash=None,
368
+ conflicts=[],
369
+ message=f"Target branch not found: {target_branch}"
370
+ )
371
+ else:
372
+ head = self.repo.refs.get_head()
373
+ if head['type'] == 'branch':
374
+ target_commit_hash = self.repo.refs.get_branch_commit(head['value'])
375
+ else:
376
+ target_commit_hash = head['value']
377
+
378
+ # Find common ancestor
379
+ ancestor_hash = self.find_common_ancestor(source_commit_hash, target_commit_hash)
380
+
381
+ if ancestor_hash == source_commit_hash:
382
+ # Already up to date
383
+ return MergeResult(
384
+ success=True,
385
+ commit_hash=target_commit_hash,
386
+ conflicts=[],
387
+ message="Already up to date"
388
+ )
389
+
390
+ if ancestor_hash == target_commit_hash:
391
+ # Fast-forward
392
+ if not target_branch:
393
+ target_branch = self.repo.refs.get_current_branch()
394
+
395
+ self.repo.refs.set_branch_commit(target_branch, source_commit_hash)
396
+
397
+ return MergeResult(
398
+ success=True,
399
+ commit_hash=source_commit_hash,
400
+ conflicts=[],
401
+ message=f"Fast-forward to {source_branch}"
402
+ )
403
+
404
+ # Three-way merge
405
+ # Get trees
406
+ ancestor_commit = Commit.load(self.object_store, ancestor_hash)
407
+ ours_commit = Commit.load(self.object_store, target_commit_hash)
408
+ theirs_commit = Commit.load(self.object_store, source_commit_hash)
409
+
410
+ base_files = self.get_tree_files(ancestor_commit.tree)
411
+ ours_files = self.get_tree_files(ours_commit.tree)
412
+ theirs_files = self.get_tree_files(theirs_commit.tree)
413
+
414
+ # Merge files
415
+ merged_files, conflicts = self.merge_files(base_files, ours_files, theirs_files)
416
+
417
+ if conflicts:
418
+ # Stage merged files for manual resolution
419
+ for path, hash_id in merged_files.items():
420
+ content = Blob.load(self.object_store, hash_id).content
421
+ self.repo.staging.add(path, hash_id, content)
422
+
423
+ return MergeResult(
424
+ success=False,
425
+ commit_hash=None,
426
+ conflicts=conflicts,
427
+ message=f"Merge conflict in {len(conflicts)} file(s). Resolve conflicts and commit."
428
+ )
429
+
430
+ # Create merge commit
431
+ # Build tree from merged files
432
+ entries = []
433
+ for path, hash_id in merged_files.items():
434
+ path_obj = Path(path)
435
+ entries.append(TreeEntry(
436
+ mode='100644',
437
+ obj_type='blob',
438
+ hash=hash_id,
439
+ name=path_obj.name,
440
+ path=str(path_obj.parent) if str(path_obj.parent) != '.' else ''
441
+ ))
442
+
443
+ tree = Tree(entries=entries)
444
+ tree_hash = tree.store(self.object_store)
445
+
446
+ merge_message = message or f"Merge branch '{source_branch}'"
447
+
448
+ merge_commit = Commit(
449
+ tree=tree_hash,
450
+ parents=[target_commit_hash, source_commit_hash],
451
+ author=self.repo.get_author(),
452
+ timestamp=datetime.utcnow().isoformat() + 'Z',
453
+ message=merge_message,
454
+ metadata={'merge': True, 'source_branch': source_branch}
455
+ )
456
+
457
+ merge_hash = merge_commit.store(self.object_store)
458
+
459
+ # Update target branch
460
+ if not target_branch:
461
+ target_branch = self.repo.refs.get_current_branch()
462
+
463
+ if target_branch:
464
+ self.repo.refs.set_branch_commit(target_branch, merge_hash)
465
+ else:
466
+ # Detached HEAD
467
+ self.repo.refs.set_head_detached(merge_hash)
468
+
469
+ return MergeResult(
470
+ success=True,
471
+ commit_hash=merge_hash,
472
+ conflicts=[],
473
+ message=f"Successfully merged {source_branch}"
474
+ )