agmem 0.1.1__py3-none-any.whl → 0.1.3__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 (100) hide show
  1. {agmem-0.1.1.dist-info → agmem-0.1.3.dist-info}/METADATA +157 -16
  2. agmem-0.1.3.dist-info/RECORD +105 -0
  3. memvcs/__init__.py +1 -1
  4. memvcs/cli.py +45 -31
  5. memvcs/commands/__init__.py +9 -9
  6. memvcs/commands/add.py +83 -76
  7. memvcs/commands/audit.py +59 -0
  8. memvcs/commands/blame.py +46 -53
  9. memvcs/commands/branch.py +13 -33
  10. memvcs/commands/checkout.py +27 -32
  11. memvcs/commands/clean.py +18 -23
  12. memvcs/commands/clone.py +11 -1
  13. memvcs/commands/commit.py +40 -39
  14. memvcs/commands/daemon.py +109 -76
  15. memvcs/commands/decay.py +77 -0
  16. memvcs/commands/diff.py +56 -57
  17. memvcs/commands/distill.py +90 -0
  18. memvcs/commands/federated.py +53 -0
  19. memvcs/commands/fsck.py +86 -61
  20. memvcs/commands/garden.py +40 -35
  21. memvcs/commands/gc.py +51 -0
  22. memvcs/commands/graph.py +41 -48
  23. memvcs/commands/init.py +16 -24
  24. memvcs/commands/log.py +25 -40
  25. memvcs/commands/merge.py +69 -27
  26. memvcs/commands/pack.py +129 -0
  27. memvcs/commands/prove.py +66 -0
  28. memvcs/commands/pull.py +31 -1
  29. memvcs/commands/push.py +4 -2
  30. memvcs/commands/recall.py +145 -0
  31. memvcs/commands/reflog.py +13 -22
  32. memvcs/commands/remote.py +1 -0
  33. memvcs/commands/repair.py +66 -0
  34. memvcs/commands/reset.py +23 -33
  35. memvcs/commands/resolve.py +130 -0
  36. memvcs/commands/resurrect.py +82 -0
  37. memvcs/commands/search.py +3 -4
  38. memvcs/commands/serve.py +2 -1
  39. memvcs/commands/show.py +66 -36
  40. memvcs/commands/stash.py +34 -34
  41. memvcs/commands/status.py +27 -35
  42. memvcs/commands/tag.py +23 -47
  43. memvcs/commands/test.py +30 -44
  44. memvcs/commands/timeline.py +111 -0
  45. memvcs/commands/tree.py +26 -27
  46. memvcs/commands/verify.py +110 -0
  47. memvcs/commands/when.py +115 -0
  48. memvcs/core/access_index.py +167 -0
  49. memvcs/core/audit.py +124 -0
  50. memvcs/core/config_loader.py +3 -1
  51. memvcs/core/consistency.py +214 -0
  52. memvcs/core/crypto_verify.py +280 -0
  53. memvcs/core/decay.py +185 -0
  54. memvcs/core/diff.py +158 -143
  55. memvcs/core/distiller.py +277 -0
  56. memvcs/core/encryption.py +169 -0
  57. memvcs/core/federated.py +86 -0
  58. memvcs/core/gardener.py +176 -145
  59. memvcs/core/hooks.py +48 -14
  60. memvcs/core/ipfs_remote.py +39 -0
  61. memvcs/core/knowledge_graph.py +135 -138
  62. memvcs/core/llm/__init__.py +10 -0
  63. memvcs/core/llm/anthropic_provider.py +50 -0
  64. memvcs/core/llm/base.py +27 -0
  65. memvcs/core/llm/factory.py +30 -0
  66. memvcs/core/llm/openai_provider.py +36 -0
  67. memvcs/core/merge.py +260 -170
  68. memvcs/core/objects.py +110 -101
  69. memvcs/core/pack.py +92 -0
  70. memvcs/core/pii_scanner.py +147 -146
  71. memvcs/core/privacy_budget.py +63 -0
  72. memvcs/core/refs.py +132 -115
  73. memvcs/core/remote.py +38 -0
  74. memvcs/core/repository.py +254 -164
  75. memvcs/core/schema.py +155 -113
  76. memvcs/core/staging.py +60 -65
  77. memvcs/core/storage/__init__.py +20 -18
  78. memvcs/core/storage/base.py +74 -70
  79. memvcs/core/storage/gcs.py +70 -68
  80. memvcs/core/storage/local.py +42 -40
  81. memvcs/core/storage/s3.py +105 -110
  82. memvcs/core/temporal_index.py +121 -0
  83. memvcs/core/test_runner.py +101 -93
  84. memvcs/core/trust.py +103 -0
  85. memvcs/core/vector_store.py +56 -36
  86. memvcs/core/zk_proofs.py +26 -0
  87. memvcs/integrations/mcp_server.py +1 -3
  88. memvcs/integrations/web_ui/server.py +25 -26
  89. memvcs/retrieval/__init__.py +22 -0
  90. memvcs/retrieval/base.py +54 -0
  91. memvcs/retrieval/pack.py +128 -0
  92. memvcs/retrieval/recaller.py +105 -0
  93. memvcs/retrieval/strategies.py +314 -0
  94. memvcs/utils/__init__.py +3 -3
  95. memvcs/utils/helpers.py +52 -52
  96. agmem-0.1.1.dist-info/RECORD +0 -67
  97. {agmem-0.1.1.dist-info → agmem-0.1.3.dist-info}/WHEEL +0 -0
  98. {agmem-0.1.1.dist-info → agmem-0.1.3.dist-info}/entry_points.txt +0 -0
  99. {agmem-0.1.1.dist-info → agmem-0.1.3.dist-info}/licenses/LICENSE +0 -0
  100. {agmem-0.1.1.dist-info → agmem-0.1.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,66 @@
1
+ """
2
+ agmem prove - Generate zero-knowledge proofs (stub).
3
+
4
+ Prove properties of memory (keyword, freshness) without revealing content.
5
+ """
6
+
7
+ import argparse
8
+ from pathlib import Path
9
+
10
+ from ..commands.base import require_repo
11
+ from ..core.zk_proofs import prove_keyword_containment, prove_memory_freshness
12
+
13
+
14
+ class ProveCommand:
15
+ """Generate zk proofs for memory properties."""
16
+
17
+ name = "prove"
18
+ help = "Prove a property of memory without revealing content (zk stub)"
19
+
20
+ @staticmethod
21
+ def add_arguments(parser: argparse.ArgumentParser):
22
+ parser.add_argument(
23
+ "--memory", "-m", required=True, help="Memory file path (under current/)"
24
+ )
25
+ parser.add_argument(
26
+ "--property",
27
+ "-p",
28
+ choices=["keyword", "freshness"],
29
+ required=True,
30
+ help="Property to prove",
31
+ )
32
+ parser.add_argument("--value", "-v", help="Value (e.g. keyword or ISO date)")
33
+ parser.add_argument("--output", "-o", help="Output proof file path")
34
+
35
+ @staticmethod
36
+ def execute(args) -> int:
37
+ repo, code = require_repo()
38
+ if code != 0:
39
+ return code
40
+
41
+ path = repo.current_dir / args.memory
42
+ if not path.exists():
43
+ print(f"Memory file not found: {args.memory}")
44
+ return 1
45
+
46
+ out = args.output or "proof.bin"
47
+ out_path = Path(out)
48
+ if not out_path.is_absolute():
49
+ out_path = repo.root / out_path
50
+
51
+ if args.property == "keyword":
52
+ if not args.value:
53
+ print("--value required for keyword (the keyword)")
54
+ return 1
55
+ ok = prove_keyword_containment(path, args.value, out_path)
56
+ else:
57
+ if not args.value:
58
+ print("--value required for freshness (ISO date)")
59
+ return 1
60
+ ok = prove_memory_freshness(path, args.value, out_path)
61
+
62
+ if not ok:
63
+ print("Proof generation not yet implemented (zk backend required).")
64
+ return 1
65
+ print(f"Proof written to {out_path}")
66
+ return 0
memvcs/commands/pull.py CHANGED
@@ -25,6 +25,11 @@ class PullCommand:
25
25
  nargs="?",
26
26
  help="Branch to pull (default: all)",
27
27
  )
28
+ parser.add_argument(
29
+ "--yes",
30
+ action="store_true",
31
+ help="Accept conditionally trusted remote commits without prompting",
32
+ )
28
33
 
29
34
  @staticmethod
30
35
  def execute(args) -> int:
@@ -37,7 +42,9 @@ class PullCommand:
37
42
 
38
43
  remote = Remote(repo.root, args.remote)
39
44
  if not remote.get_remote_url():
40
- print(f"Error: Remote '{args.remote}' has no URL. Set with: agmem remote add {args.remote} <url>")
45
+ print(
46
+ f"Error: Remote '{args.remote}' has no URL. Set with: agmem remote add {args.remote} <url>"
47
+ )
41
48
  return 1
42
49
 
43
50
  try:
@@ -49,7 +56,30 @@ class PullCommand:
49
56
  remote_ref = f"{args.remote}/{current_branch}"
50
57
  remote_hash = repo.resolve_ref(remote_ref)
51
58
  if remote_hash:
59
+ from memvcs.core.crypto_verify import verify_commit_optional
60
+
61
+ verify_commit_optional(
62
+ repo.object_store, remote_hash, mem_dir=repo.mem_dir, strict=False
63
+ )
64
+ # Trust check: block or require confirmation for untrusted/conditional
65
+ from memvcs.core.objects import Commit
66
+ from memvcs.core.trust import find_verifying_key, get_trust_level
67
+
68
+ remote_commit = Commit.load(repo.object_store, remote_hash)
69
+ if remote_commit and remote_commit.metadata:
70
+ key_pem = find_verifying_key(repo.mem_dir, remote_commit.metadata)
71
+ if key_pem is not None:
72
+ level = get_trust_level(repo.mem_dir, key_pem)
73
+ if level == "untrusted":
74
+ print(f"Pull blocked: remote commit signed by untrusted key.")
75
+ return 1
76
+ if level == "conditional" and not getattr(args, "yes", False):
77
+ print(
78
+ "Remote commit from conditionally trusted key. Use --yes to merge."
79
+ )
80
+ return 1
52
81
  from memvcs.core.merge import MergeEngine
82
+
53
83
  merge_engine = MergeEngine(repo)
54
84
  try:
55
85
  result = merge_engine.merge(remote_ref)
memvcs/commands/push.py CHANGED
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
 
9
9
  class MemoryConflictError(Exception):
10
10
  """Exception raised when push fails due to conflicts."""
11
+
11
12
  pass
12
13
 
13
14
 
@@ -31,7 +32,8 @@ class PushCommand:
31
32
  help="Branch to push (default: current)",
32
33
  )
33
34
  parser.add_argument(
34
- "--force", "-f",
35
+ "--force",
36
+ "-f",
35
37
  action="store_true",
36
38
  help="Force push (WARNING: may overwrite remote changes)",
37
39
  )
@@ -53,7 +55,7 @@ class PushCommand:
53
55
 
54
56
  remote = Remote(repo.root, args.remote)
55
57
  remote_url = remote.get_remote_url()
56
-
58
+
57
59
  if not remote_url:
58
60
  print(f"Error: Remote '{args.remote}' has no URL.")
59
61
  print(f"Set with: agmem remote add {args.remote} <url>")
@@ -0,0 +1,145 @@
1
+ """
2
+ agmem recall - Context-aware retrieval with pluggable strategies.
3
+ """
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from ..commands.base import require_repo
11
+ from ..core.access_index import AccessIndex
12
+ from ..retrieval import RecallEngine
13
+
14
+
15
+ def _is_vector_unavailable_error(exc: Exception) -> bool:
16
+ """True if exception indicates vector deps are missing."""
17
+ msg = str(exc).lower()
18
+ return any(
19
+ key in msg
20
+ for key in ("sqlite-vec", "sentence-transformers", "vector search", "agmem[vector]")
21
+ )
22
+
23
+
24
+ class RecallCommand:
25
+ """Context-aware recall with pluggable strategies."""
26
+
27
+ name = "recall"
28
+ help = "Recall curated memories for the current task (context-aware retrieval)"
29
+
30
+ @staticmethod
31
+ def add_arguments(parser: argparse.ArgumentParser):
32
+ parser.add_argument(
33
+ "--context",
34
+ "-c",
35
+ default="",
36
+ help="Current task description (used for embedding similarity)",
37
+ )
38
+ parser.add_argument(
39
+ "--strategy",
40
+ "-s",
41
+ choices=["recency", "importance", "similarity", "hybrid"],
42
+ default="hybrid",
43
+ help="Recall strategy (default: hybrid)",
44
+ )
45
+ parser.add_argument(
46
+ "--limit",
47
+ "-n",
48
+ type=int,
49
+ default=10,
50
+ help="Max chunks to return (default: 10)",
51
+ )
52
+ parser.add_argument(
53
+ "--exclude",
54
+ "-e",
55
+ action="append",
56
+ default=[],
57
+ help="Tags/paths to exclude (e.g., experiment/*); repeatable",
58
+ )
59
+ parser.add_argument(
60
+ "--no-cache",
61
+ action="store_true",
62
+ help="Disable recall cache",
63
+ )
64
+ parser.add_argument(
65
+ "--format",
66
+ "-f",
67
+ choices=["json", "text"],
68
+ default="json",
69
+ help="Output format (default: json)",
70
+ )
71
+
72
+ @staticmethod
73
+ def execute(args) -> int:
74
+ repo, code = require_repo()
75
+ if code != 0:
76
+ return code
77
+
78
+ vector_store = None
79
+ try:
80
+ from ..core.vector_store import VectorStore
81
+
82
+ vector_store = VectorStore(repo.mem_dir)
83
+ except ImportError:
84
+ if args.strategy in ("similarity", "hybrid"):
85
+ print(
86
+ "Error: Strategy '{}' requires agmem[vector]. "
87
+ "Install with: pip install agmem[vector]".format(args.strategy),
88
+ file=sys.stderr,
89
+ )
90
+ return 1
91
+ if args.strategy == "hybrid":
92
+ args.strategy = "recency"
93
+ print("Note: Falling back to recency (vector store not available)")
94
+
95
+ access_index = AccessIndex(repo.mem_dir)
96
+ engine = RecallEngine(
97
+ repo=repo,
98
+ vector_store=vector_store,
99
+ access_index=access_index,
100
+ use_cache=not args.no_cache,
101
+ )
102
+
103
+ try:
104
+ results = engine.recall(
105
+ context=args.context,
106
+ limit=args.limit,
107
+ strategy=args.strategy,
108
+ exclude=args.exclude,
109
+ )
110
+ except Exception as e:
111
+ if _is_vector_unavailable_error(e):
112
+ if args.strategy in ("similarity", "hybrid"):
113
+ print(
114
+ "Error: Vector search unavailable. Try --strategy recency or importance.",
115
+ file=sys.stderr,
116
+ )
117
+ return 1
118
+ engine = RecallEngine(
119
+ repo=repo,
120
+ vector_store=None,
121
+ access_index=access_index,
122
+ use_cache=not args.no_cache,
123
+ )
124
+ results = engine.recall(
125
+ context=args.context,
126
+ limit=args.limit,
127
+ strategy="recency",
128
+ exclude=args.exclude,
129
+ )
130
+ else:
131
+ raise
132
+
133
+ if args.format == "json":
134
+ output = [r.to_dict() for r in results]
135
+ print(json.dumps(output, indent=2))
136
+ else:
137
+ for r in results:
138
+ print(f"\n--- {r.path} (score: {r.relevance_score:.4f}) ---")
139
+ print(r.content[:500] + ("..." if len(r.content) > 500 else ""))
140
+ if r.importance is not None:
141
+ print(f"(importance: {r.importance})")
142
+
143
+ if vector_store and hasattr(vector_store, "close"):
144
+ vector_store.close()
145
+ return 0
memvcs/commands/reflog.py CHANGED
@@ -11,42 +11,33 @@ from ..core.repository import Repository
11
11
 
12
12
  class ReflogCommand:
13
13
  """Show reflog - history of HEAD changes."""
14
-
15
- name = 'reflog'
16
- help = 'Show reference log (history of HEAD changes)'
17
-
14
+
15
+ name = "reflog"
16
+ help = "Show reference log (history of HEAD changes)"
17
+
18
18
  @staticmethod
19
19
  def add_arguments(parser: argparse.ArgumentParser):
20
+ parser.add_argument("ref", nargs="?", default="HEAD", help="Reference to show log for")
20
21
  parser.add_argument(
21
- 'ref',
22
- nargs='?',
23
- default='HEAD',
24
- help='Reference to show log for'
25
- )
26
- parser.add_argument(
27
- '-n', '--max-count',
28
- type=int,
29
- default=20,
30
- help='Maximum number of entries'
22
+ "-n", "--max-count", type=int, default=20, help="Maximum number of entries"
31
23
  )
32
-
24
+
33
25
  @staticmethod
34
26
  def execute(args) -> int:
35
27
  repo, code = require_repo()
36
28
  if code != 0:
37
29
  return code
38
30
 
39
-
40
31
  entries = repo.refs.get_reflog(args.ref, args.max_count)
41
-
32
+
42
33
  if not entries:
43
34
  print("No reflog entries found.")
44
35
  return 0
45
-
36
+
46
37
  for e in entries:
47
- h = e['hash'][:8]
48
- ts = e.get('timestamp', '')[:19]
49
- msg = e.get('message', '')
38
+ h = e["hash"][:8]
39
+ ts = e.get("timestamp", "")[:19]
40
+ msg = e.get("message", "")
50
41
  print(f"{h} {ts} {msg}")
51
-
42
+
52
43
  return 0
memvcs/commands/remote.py CHANGED
@@ -40,6 +40,7 @@ class RemoteCommand:
40
40
  print(f"Remote '{args.name}' set to {args.url}")
41
41
  elif args.remote_action == "show":
42
42
  import json
43
+
43
44
  config = json.loads((repo.root / ".mem" / "config.json").read_text())
44
45
  remotes = config.get("remotes", {})
45
46
  if remotes:
@@ -0,0 +1,66 @@
1
+ """
2
+ agmem repair - Auto-fix belief contradictions.
3
+
4
+ Repairs semantic memory contradictions using configurable strategy.
5
+ """
6
+
7
+ import argparse
8
+ from pathlib import Path
9
+
10
+ from ..commands.base import require_repo
11
+ from ..core.consistency import ConsistencyChecker, ConsistencyResult
12
+
13
+
14
+ class RepairCommand:
15
+ """Repair belief contradictions in semantic memories."""
16
+
17
+ name = "repair"
18
+ help = "Auto-fix contradictions using confidence scores"
19
+
20
+ @staticmethod
21
+ def add_arguments(parser: argparse.ArgumentParser):
22
+ parser.add_argument(
23
+ "--strategy",
24
+ "-s",
25
+ choices=["confidence", "recency", "llm"],
26
+ default="confidence",
27
+ help="Repair strategy (default: confidence)",
28
+ )
29
+ parser.add_argument(
30
+ "--dry-run",
31
+ action="store_true",
32
+ help="Show what would be fixed without making changes",
33
+ )
34
+
35
+ @staticmethod
36
+ def execute(args) -> int:
37
+ repo, code = require_repo()
38
+ if code != 0:
39
+ return code
40
+
41
+ checker = ConsistencyChecker(repo, llm_provider="openai")
42
+ result = checker.repair(strategy=args.strategy)
43
+
44
+ if result.valid:
45
+ print("No contradictions to repair.")
46
+ return 0
47
+
48
+ if args.dry_run:
49
+ print(f"Would repair {len(result.contradictions)} contradiction(s):")
50
+ for c in result.contradictions:
51
+ print(
52
+ f" - {c.triple1.source}:{c.triple1.line} vs {c.triple2.source}:{c.triple2.line}"
53
+ )
54
+ print("\nRun without --dry-run to apply repairs.")
55
+ return 0
56
+
57
+ # Repair: keep higher-confidence triple, flag the other
58
+ # For now we only report - full repair would modify files
59
+ print(f"Found {len(result.contradictions)} contradiction(s).")
60
+ print("Manual repair required - edit the semantic files to resolve.")
61
+ for c in result.contradictions:
62
+ keep = c.triple1 if c.triple1.confidence >= c.triple2.confidence else c.triple2
63
+ drop = c.triple2 if keep is c.triple1 else c.triple1
64
+ print(f" Keep: {keep.source}:{keep.line} (confidence {keep.confidence})")
65
+ print(f" Review: {drop.source}:{drop.line} (confidence {drop.confidence})")
66
+ return 1
memvcs/commands/reset.py CHANGED
@@ -11,34 +11,25 @@ from ..core.repository import Repository
11
11
 
12
12
  class ResetCommand:
13
13
  """Reset current HEAD to the specified state."""
14
-
15
- name = 'reset'
16
- help = 'Reset current HEAD to the specified state'
17
-
14
+
15
+ name = "reset"
16
+ help = "Reset current HEAD to the specified state"
17
+
18
18
  @staticmethod
19
19
  def add_arguments(parser: argparse.ArgumentParser):
20
20
  parser.add_argument(
21
- 'commit',
22
- nargs='?',
23
- default='HEAD',
24
- help='Commit to reset to (default: HEAD)'
21
+ "commit", nargs="?", default="HEAD", help="Commit to reset to (default: HEAD)"
25
22
  )
26
23
  parser.add_argument(
27
- '--soft',
28
- action='store_true',
29
- help='Reset HEAD but keep staged changes'
24
+ "--soft", action="store_true", help="Reset HEAD but keep staged changes"
30
25
  )
31
26
  parser.add_argument(
32
- '--mixed',
33
- action='store_true',
34
- help='Reset HEAD and unstaged changes (default)'
27
+ "--mixed", action="store_true", help="Reset HEAD and unstaged changes (default)"
35
28
  )
36
29
  parser.add_argument(
37
- '--hard',
38
- action='store_true',
39
- help='Reset HEAD, index, and working tree'
30
+ "--hard", action="store_true", help="Reset HEAD, index, and working tree"
40
31
  )
41
-
32
+
42
33
  @staticmethod
43
34
  def execute(args) -> int:
44
35
  # Find repository
@@ -46,53 +37,52 @@ class ResetCommand:
46
37
  if code != 0:
47
38
  return code
48
39
 
49
-
50
40
  # Determine mode
51
41
  if args.soft:
52
- mode = 'soft'
42
+ mode = "soft"
53
43
  elif args.hard:
54
- mode = 'hard'
44
+ mode = "hard"
55
45
  else:
56
- mode = 'mixed'
57
-
46
+ mode = "mixed"
47
+
58
48
  # Resolve commit
59
49
  commit_hash = repo.resolve_ref(args.commit)
60
50
  if not commit_hash:
61
51
  print(f"Error: Unknown revision: {args.commit}")
62
52
  return 1
63
-
53
+
64
54
  # Get current branch
65
55
  current_branch = repo.refs.get_current_branch()
66
-
56
+
67
57
  try:
68
- if mode == 'soft':
58
+ if mode == "soft":
69
59
  # Just move HEAD
70
60
  if current_branch:
71
61
  repo.refs.set_branch_commit(current_branch, commit_hash)
72
62
  else:
73
63
  repo.refs.set_head_detached(commit_hash)
74
64
  print(f"HEAD is now at {commit_hash[:8]}")
75
-
76
- elif mode == 'mixed':
65
+
66
+ elif mode == "mixed":
77
67
  # Move HEAD and clear staging
78
68
  if current_branch:
79
69
  repo.refs.set_branch_commit(current_branch, commit_hash)
80
70
  else:
81
71
  repo.refs.set_head_detached(commit_hash)
82
-
72
+
83
73
  # Keep staged files but mark them as unstaged
84
74
  # (In a full implementation, we'd restore the tree state)
85
75
  print(f"HEAD is now at {commit_hash[:8]}")
86
76
  print("Staged changes have been unstaged.")
87
-
88
- elif mode == 'hard':
77
+
78
+ elif mode == "hard":
89
79
  # Move HEAD, clear staging, and restore working tree
90
80
  repo.checkout(commit_hash, force=True)
91
81
  print(f"HEAD is now at {commit_hash[:8]}")
92
82
  print("Working tree has been reset.")
93
-
83
+
94
84
  return 0
95
-
85
+
96
86
  except Exception as e:
97
87
  print(f"Error during reset: {e}")
98
88
  return 1
@@ -0,0 +1,130 @@
1
+ """
2
+ agmem resolve - Structured conflict resolution.
3
+
4
+ Resolve merge conflicts with ours/theirs/both choices; record in audit.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from ..commands.base import require_repo
13
+
14
+
15
+ def _path_under_current(path_str: str, current_dir: Path) -> Optional[Path]:
16
+ """Resolve path under current_dir; return None if it escapes (path traversal)."""
17
+ try:
18
+ full = (current_dir / path_str).resolve()
19
+ full.relative_to(current_dir.resolve())
20
+ return full
21
+ except (ValueError, RuntimeError):
22
+ return None
23
+
24
+
25
+ def _resolved_content(choice: str, ours: str, theirs: str) -> str:
26
+ """Return content for choice: ours, theirs, or both (merged)."""
27
+ if choice == "ours":
28
+ return ours
29
+ if choice == "theirs":
30
+ return theirs
31
+ return ours.rstrip() + "\n\n--- merged ---\n\n" + theirs
32
+
33
+
34
+ class ResolveCommand:
35
+ """Resolve merge conflicts interactively (ours/theirs/both)."""
36
+
37
+ name = "resolve"
38
+ help = "Resolve merge conflicts (ours / theirs / both)"
39
+
40
+ @staticmethod
41
+ def add_arguments(parser: argparse.ArgumentParser):
42
+ parser.add_argument(
43
+ "path",
44
+ nargs="?",
45
+ help="Conflict path to resolve (or resolve all if omitted)",
46
+ )
47
+ parser.add_argument(
48
+ "--ours",
49
+ action="store_true",
50
+ help="Resolve with our version",
51
+ )
52
+ parser.add_argument(
53
+ "--theirs",
54
+ action="store_true",
55
+ help="Resolve with their version",
56
+ )
57
+ parser.add_argument(
58
+ "--both",
59
+ action="store_true",
60
+ help="Keep both (append theirs after ours with separator)",
61
+ )
62
+
63
+ @staticmethod
64
+ def execute(args) -> int:
65
+ repo, code = require_repo()
66
+ if code != 0:
67
+ return code
68
+
69
+ merge_dir = repo.mem_dir / "merge"
70
+ conflicts_file = merge_dir / "conflicts.json"
71
+ if not conflicts_file.exists():
72
+ print("No unresolved conflicts.")
73
+ return 0
74
+
75
+ try:
76
+ conflicts = json.loads(conflicts_file.read_text())
77
+ except Exception:
78
+ print("Could not read conflicts file.")
79
+ return 1
80
+
81
+ if not conflicts:
82
+ print("No unresolved conflicts.")
83
+ conflicts_file.unlink(missing_ok=True)
84
+ return 0
85
+
86
+ choice = None
87
+ if args.ours:
88
+ choice = "ours"
89
+ elif args.theirs:
90
+ choice = "theirs"
91
+ elif args.both:
92
+ choice = "both"
93
+
94
+ resolved = 0
95
+ remaining = []
96
+ for c in conflicts:
97
+ path = c.get("path", "")
98
+ if args.path and path != args.path:
99
+ remaining.append(c)
100
+ continue
101
+ if choice is None:
102
+ print(f"Conflict: {path}")
103
+ print(" Use: agmem resolve <path> --ours | --theirs | --both")
104
+ remaining.append(c)
105
+ continue
106
+ ours_content = c.get("ours_content") or ""
107
+ theirs_content = c.get("theirs_content") or ""
108
+ full_path = _path_under_current(path, repo.current_dir)
109
+ if full_path is None:
110
+ print(f"Error: Conflict path escapes repository: {path}")
111
+ remaining.append(c)
112
+ continue
113
+ content = _resolved_content(choice, ours_content, theirs_content)
114
+ full_path.parent.mkdir(parents=True, exist_ok=True)
115
+ full_path.write_text(content, encoding="utf-8")
116
+ resolved += 1
117
+ try:
118
+ from ..core.audit import append_audit
119
+
120
+ append_audit(repo.mem_dir, "resolve", {"path": path, "choice": choice})
121
+ except Exception:
122
+ pass
123
+
124
+ if remaining:
125
+ conflicts_file.write_text(json.dumps(remaining, indent=2))
126
+ else:
127
+ conflicts_file.unlink(missing_ok=True)
128
+ if resolved:
129
+ print(f"Resolved {resolved} conflict(s). Stage and commit to complete.")
130
+ return 0