agmem 0.1.2__py3-none-any.whl → 0.1.4__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.
- {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/METADATA +144 -14
- {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/RECORD +48 -28
- memvcs/cli.py +10 -0
- memvcs/commands/add.py +6 -0
- memvcs/commands/audit.py +59 -0
- memvcs/commands/clone.py +7 -0
- memvcs/commands/daemon.py +45 -0
- memvcs/commands/distill.py +24 -0
- memvcs/commands/federated.py +59 -0
- memvcs/commands/fsck.py +31 -0
- memvcs/commands/garden.py +22 -0
- memvcs/commands/gc.py +66 -0
- memvcs/commands/merge.py +55 -1
- memvcs/commands/prove.py +66 -0
- memvcs/commands/pull.py +27 -0
- memvcs/commands/resolve.py +130 -0
- memvcs/commands/timeline.py +27 -0
- memvcs/commands/verify.py +74 -23
- memvcs/commands/when.py +27 -0
- memvcs/core/audit.py +124 -0
- memvcs/core/compression_pipeline.py +157 -0
- memvcs/core/consistency.py +9 -9
- memvcs/core/crypto_verify.py +291 -0
- memvcs/core/distiller.py +47 -29
- memvcs/core/encryption.py +169 -0
- memvcs/core/federated.py +147 -0
- memvcs/core/gardener.py +47 -29
- memvcs/core/ipfs_remote.py +200 -0
- memvcs/core/knowledge_graph.py +77 -5
- memvcs/core/llm/__init__.py +10 -0
- memvcs/core/llm/anthropic_provider.py +50 -0
- memvcs/core/llm/base.py +27 -0
- memvcs/core/llm/factory.py +30 -0
- memvcs/core/llm/openai_provider.py +36 -0
- memvcs/core/merge.py +36 -23
- memvcs/core/objects.py +39 -19
- memvcs/core/pack.py +278 -0
- memvcs/core/privacy_budget.py +63 -0
- memvcs/core/remote.py +229 -3
- memvcs/core/repository.py +82 -2
- memvcs/core/temporal_index.py +9 -0
- memvcs/core/trust.py +103 -0
- memvcs/core/vector_store.py +15 -1
- memvcs/core/zk_proofs.py +158 -0
- {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/WHEEL +0 -0
- {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/entry_points.txt +0 -0
- {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {agmem-0.1.2.dist-info → agmem-0.1.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem federated - Federated memory collaboration.
|
|
3
|
+
|
|
4
|
+
Push local summaries to coordinator; pull merged summaries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
|
|
9
|
+
from ..commands.base import require_repo
|
|
10
|
+
from ..core.federated import get_federated_config, produce_local_summary, push_updates, pull_merged
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FederatedCommand:
|
|
14
|
+
"""Federated memory collaboration with coordinator."""
|
|
15
|
+
|
|
16
|
+
name = "federated"
|
|
17
|
+
help = "Push/pull federated summaries (coordinator must be configured)"
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"action",
|
|
23
|
+
choices=["push", "pull"],
|
|
24
|
+
help="Push local summary or pull merged from coordinator",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def execute(args) -> int:
|
|
29
|
+
repo, code = require_repo()
|
|
30
|
+
if code != 0:
|
|
31
|
+
return code
|
|
32
|
+
|
|
33
|
+
cfg = get_federated_config(repo.root)
|
|
34
|
+
if not cfg:
|
|
35
|
+
print(
|
|
36
|
+
"Federated collaboration not enabled. Set federated.enabled and coordinator_url in config."
|
|
37
|
+
)
|
|
38
|
+
return 1
|
|
39
|
+
|
|
40
|
+
if args.action == "push":
|
|
41
|
+
summary = produce_local_summary(
|
|
42
|
+
repo.root,
|
|
43
|
+
cfg["memory_types"],
|
|
44
|
+
use_dp=cfg.get("use_dp", False),
|
|
45
|
+
dp_epsilon=cfg.get("dp_epsilon") or 0.1,
|
|
46
|
+
dp_delta=cfg.get("dp_delta") or 1e-5,
|
|
47
|
+
)
|
|
48
|
+
msg = push_updates(repo.root, summary)
|
|
49
|
+
print(msg)
|
|
50
|
+
return 0 if "Pushed" in msg else 1
|
|
51
|
+
else:
|
|
52
|
+
data = pull_merged(repo.root)
|
|
53
|
+
if data is None:
|
|
54
|
+
print("Pull failed or coordinator unavailable.")
|
|
55
|
+
return 1
|
|
56
|
+
print("Merged summary from coordinator:")
|
|
57
|
+
for k, v in (data or {}).items():
|
|
58
|
+
print(f" {k}: {v}")
|
|
59
|
+
return 0
|
memvcs/commands/fsck.py
CHANGED
|
@@ -66,6 +66,10 @@ class FsckCommand:
|
|
|
66
66
|
issues_found += ref_issues
|
|
67
67
|
issues_fixed += ref_fixed
|
|
68
68
|
|
|
69
|
+
# Cryptographic verification (Merkle + signature)
|
|
70
|
+
crypto_issues = FsckCommand._check_crypto(repo, args.verbose)
|
|
71
|
+
issues_found += crypto_issues
|
|
72
|
+
|
|
69
73
|
# Print summary
|
|
70
74
|
print()
|
|
71
75
|
print("=" * 40)
|
|
@@ -195,3 +199,30 @@ class FsckCommand:
|
|
|
195
199
|
print(f" Found {issues} ref issues")
|
|
196
200
|
|
|
197
201
|
return issues, 0
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _check_crypto(repo, verbose: bool) -> int:
|
|
205
|
+
"""Verify Merkle/signature on branch tips. Returns number of issues."""
|
|
206
|
+
print("\nChecking commit signatures...")
|
|
207
|
+
try:
|
|
208
|
+
from ..core.crypto_verify import verify_commit, load_public_key
|
|
209
|
+
except ImportError:
|
|
210
|
+
if verbose:
|
|
211
|
+
print(" Crypto verification not available")
|
|
212
|
+
return 0
|
|
213
|
+
issues = 0
|
|
214
|
+
pub = load_public_key(repo.mem_dir)
|
|
215
|
+
for branch in repo.refs.list_branches():
|
|
216
|
+
ch = repo.refs.get_branch_commit(branch)
|
|
217
|
+
if not ch:
|
|
218
|
+
continue
|
|
219
|
+
ok, err = verify_commit(repo.object_store, ch, public_key_pem=pub, mem_dir=repo.mem_dir)
|
|
220
|
+
if not ok:
|
|
221
|
+
issues += 1
|
|
222
|
+
if verbose:
|
|
223
|
+
print(f" {branch} ({ch[:8]}): {err}")
|
|
224
|
+
if issues == 0:
|
|
225
|
+
print(" Commit signatures consistent")
|
|
226
|
+
else:
|
|
227
|
+
print(f" Found {issues} commit(s) with verification issues")
|
|
228
|
+
return issues
|
memvcs/commands/garden.py
CHANGED
|
@@ -38,6 +38,11 @@ class GardenCommand:
|
|
|
38
38
|
help="LLM provider for summarization (default: none)",
|
|
39
39
|
)
|
|
40
40
|
parser.add_argument("--model", help="LLM model to use (e.g., gpt-3.5-turbo)")
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--private",
|
|
43
|
+
action="store_true",
|
|
44
|
+
help="Use differential privacy (spend epsilon from budget)",
|
|
45
|
+
)
|
|
41
46
|
|
|
42
47
|
@staticmethod
|
|
43
48
|
def execute(args) -> int:
|
|
@@ -45,12 +50,29 @@ class GardenCommand:
|
|
|
45
50
|
if code != 0:
|
|
46
51
|
return code
|
|
47
52
|
|
|
53
|
+
use_dp = getattr(args, "private", False)
|
|
54
|
+
dp_epsilon = None
|
|
55
|
+
dp_delta = None
|
|
56
|
+
if use_dp:
|
|
57
|
+
from ..core.privacy_budget import load_budget, spend_epsilon
|
|
58
|
+
|
|
59
|
+
spent, max_eps, delta = load_budget(repo.mem_dir)
|
|
60
|
+
epsilon_cost = 0.1
|
|
61
|
+
if not spend_epsilon(repo.mem_dir, epsilon_cost):
|
|
62
|
+
print(f"Privacy budget exceeded (spent {spent:.2f}, max {max_eps}).")
|
|
63
|
+
return 1
|
|
64
|
+
dp_epsilon = 0.05
|
|
65
|
+
dp_delta = delta
|
|
66
|
+
|
|
48
67
|
# Build config
|
|
49
68
|
config = GardenerConfig(
|
|
50
69
|
threshold=args.threshold,
|
|
51
70
|
auto_commit=not args.no_commit,
|
|
52
71
|
llm_provider=args.llm if args.llm != "none" else None,
|
|
53
72
|
llm_model=args.model,
|
|
73
|
+
use_dp=use_dp,
|
|
74
|
+
dp_epsilon=dp_epsilon,
|
|
75
|
+
dp_delta=dp_delta,
|
|
54
76
|
)
|
|
55
77
|
|
|
56
78
|
# Create gardener
|
memvcs/commands/gc.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem gc - Garbage collection.
|
|
3
|
+
|
|
4
|
+
Remove unreachable objects; optionally repack.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
|
|
9
|
+
from ..commands.base import require_repo
|
|
10
|
+
from ..core.pack import run_gc, run_repack
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GcCommand:
|
|
14
|
+
"""Garbage collect unreachable objects."""
|
|
15
|
+
|
|
16
|
+
name = "gc"
|
|
17
|
+
help = "Remove unreachable objects (garbage collection)"
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--dry-run",
|
|
23
|
+
action="store_true",
|
|
24
|
+
help="Report what would be removed without deleting",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--prune-days",
|
|
28
|
+
type=int,
|
|
29
|
+
default=90,
|
|
30
|
+
metavar="N",
|
|
31
|
+
help="Consider reflog entries within N days (default 90)",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--repack",
|
|
35
|
+
action="store_true",
|
|
36
|
+
help="After GC, pack reachable loose objects into a pack file",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def execute(args) -> int:
|
|
41
|
+
repo, code = require_repo()
|
|
42
|
+
if code != 0:
|
|
43
|
+
return code
|
|
44
|
+
|
|
45
|
+
gc_prune_days = getattr(args, "prune_days", 90)
|
|
46
|
+
deleted, freed = run_gc(
|
|
47
|
+
repo.mem_dir,
|
|
48
|
+
repo.object_store,
|
|
49
|
+
gc_prune_days=gc_prune_days,
|
|
50
|
+
dry_run=args.dry_run,
|
|
51
|
+
)
|
|
52
|
+
if args.dry_run:
|
|
53
|
+
print(f"Would remove {deleted} unreachable object(s) ({freed} bytes).")
|
|
54
|
+
else:
|
|
55
|
+
print(f"Removed {deleted} unreachable object(s) ({freed} bytes reclaimed).")
|
|
56
|
+
|
|
57
|
+
if getattr(args, "repack", False) and not args.dry_run:
|
|
58
|
+
packed, repack_freed = run_repack(
|
|
59
|
+
repo.mem_dir,
|
|
60
|
+
repo.object_store,
|
|
61
|
+
gc_prune_days=gc_prune_days,
|
|
62
|
+
dry_run=False,
|
|
63
|
+
)
|
|
64
|
+
if packed > 0:
|
|
65
|
+
print(f"Packed {packed} object(s) into pack file ({repack_freed} bytes from loose).")
|
|
66
|
+
return 0
|
memvcs/commands/merge.py
CHANGED
|
@@ -23,6 +23,11 @@ class MergeCommand:
|
|
|
23
23
|
"--no-commit", action="store_true", help="Perform merge but do not commit"
|
|
24
24
|
)
|
|
25
25
|
parser.add_argument("--abort", action="store_true", help="Abort the current merge")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--yes",
|
|
28
|
+
action="store_true",
|
|
29
|
+
help="Accept conditionally trusted branch commits without prompting",
|
|
30
|
+
)
|
|
26
31
|
|
|
27
32
|
@staticmethod
|
|
28
33
|
def execute(args) -> int:
|
|
@@ -53,6 +58,24 @@ class MergeCommand:
|
|
|
53
58
|
print(f"Error: Branch '{args.branch}' not found.")
|
|
54
59
|
return 1
|
|
55
60
|
|
|
61
|
+
# Trust check for branch tip (may be from another agent)
|
|
62
|
+
other_commit_hash = repo.refs.get_branch_commit(args.branch)
|
|
63
|
+
if other_commit_hash:
|
|
64
|
+
from ..core.objects import Commit
|
|
65
|
+
from ..core.trust import find_verifying_key, get_trust_level
|
|
66
|
+
|
|
67
|
+
other_commit = Commit.load(repo.object_store, other_commit_hash)
|
|
68
|
+
if other_commit and other_commit.metadata:
|
|
69
|
+
key_pem = find_verifying_key(repo.mem_dir, other_commit.metadata)
|
|
70
|
+
if key_pem is not None:
|
|
71
|
+
level = get_trust_level(repo.mem_dir, key_pem)
|
|
72
|
+
if level == "untrusted":
|
|
73
|
+
print(f"Merge blocked: branch '{args.branch}' signed by untrusted key.")
|
|
74
|
+
return 1
|
|
75
|
+
if level == "conditional" and not getattr(args, "yes", False):
|
|
76
|
+
print("Branch signed by conditionally trusted key. Use --yes to merge.")
|
|
77
|
+
return 1
|
|
78
|
+
|
|
56
79
|
# Perform merge
|
|
57
80
|
engine = MergeEngine(repo)
|
|
58
81
|
result = engine.merge(args.branch, message=args.message)
|
|
@@ -61,16 +84,47 @@ class MergeCommand:
|
|
|
61
84
|
print(f"Merge successful: {result.message}")
|
|
62
85
|
if result.commit_hash:
|
|
63
86
|
print(f" Commit: {result.commit_hash[:8]}")
|
|
87
|
+
try:
|
|
88
|
+
from ..core.audit import append_audit
|
|
89
|
+
|
|
90
|
+
append_audit(
|
|
91
|
+
repo.mem_dir, "merge", {"branch": args.branch, "commit": result.commit_hash}
|
|
92
|
+
)
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
64
95
|
return 0
|
|
65
96
|
else:
|
|
66
97
|
print(f"Merge failed: {result.message}")
|
|
67
98
|
|
|
68
99
|
if result.conflicts:
|
|
100
|
+
# Persist conflicts for agmem resolve
|
|
101
|
+
try:
|
|
102
|
+
import json
|
|
103
|
+
|
|
104
|
+
merge_dir = repo.mem_dir / "merge"
|
|
105
|
+
merge_dir.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
conflicts_data = [
|
|
107
|
+
{
|
|
108
|
+
"path": c.path,
|
|
109
|
+
"message": c.message,
|
|
110
|
+
"memory_type": getattr(c, "memory_type", None),
|
|
111
|
+
"payload": getattr(c, "payload", None),
|
|
112
|
+
"ours_content": c.ours_content,
|
|
113
|
+
"theirs_content": c.theirs_content,
|
|
114
|
+
"base_content": c.base_content,
|
|
115
|
+
}
|
|
116
|
+
for c in result.conflicts
|
|
117
|
+
]
|
|
118
|
+
(merge_dir / "conflicts.json").write_text(json.dumps(conflicts_data, indent=2))
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
69
121
|
print()
|
|
70
122
|
print("Conflicts:")
|
|
71
123
|
for conflict in result.conflicts:
|
|
72
124
|
print(f" {conflict.path}")
|
|
73
125
|
print()
|
|
74
|
-
print(
|
|
126
|
+
print(
|
|
127
|
+
"Resolve conflicts with 'agmem resolve' or edit files and run 'agmem commit'."
|
|
128
|
+
)
|
|
75
129
|
|
|
76
130
|
return 1
|
memvcs/commands/prove.py
ADDED
|
@@ -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, mem_dir=repo.mem_dir)
|
|
61
|
+
|
|
62
|
+
if not ok:
|
|
63
|
+
print("Proof generation failed (keyword not in file, or signing key not set for freshness).")
|
|
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:
|
|
@@ -51,6 +56,28 @@ class PullCommand:
|
|
|
51
56
|
remote_ref = f"{args.remote}/{current_branch}"
|
|
52
57
|
remote_hash = repo.resolve_ref(remote_ref)
|
|
53
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
|
|
54
81
|
from memvcs.core.merge import MergeEngine
|
|
55
82
|
|
|
56
83
|
merge_engine = MergeEngine(repo)
|
|
@@ -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
|
memvcs/commands/timeline.py
CHANGED
|
@@ -28,6 +28,18 @@ class TimelineCommand:
|
|
|
28
28
|
default=20,
|
|
29
29
|
help="Max commits to show (default: 20)",
|
|
30
30
|
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--from",
|
|
33
|
+
dest="from_ts",
|
|
34
|
+
metavar="ISO",
|
|
35
|
+
help="Start of time range (ISO 8601, e.g. 2025-01-01)",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--to",
|
|
39
|
+
dest="to_ts",
|
|
40
|
+
metavar="ISO",
|
|
41
|
+
help="End of time range (ISO 8601)",
|
|
42
|
+
)
|
|
31
43
|
|
|
32
44
|
@staticmethod
|
|
33
45
|
def execute(args) -> int:
|
|
@@ -36,6 +48,17 @@ class TimelineCommand:
|
|
|
36
48
|
return code
|
|
37
49
|
|
|
38
50
|
filepath = args.file.replace("current/", "").lstrip("/")
|
|
51
|
+
from_ts = getattr(args, "from_ts", None)
|
|
52
|
+
to_ts = getattr(args, "to_ts", None)
|
|
53
|
+
commits_in_range = None
|
|
54
|
+
if from_ts and to_ts:
|
|
55
|
+
try:
|
|
56
|
+
from ..core.temporal_index import TemporalIndex
|
|
57
|
+
ti = TemporalIndex(repo.mem_dir, repo.object_store)
|
|
58
|
+
range_entries = ti.range_query(from_ts, to_ts)
|
|
59
|
+
commits_in_range = {ch for _, ch in range_entries}
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
39
62
|
|
|
40
63
|
# Walk commit history
|
|
41
64
|
head = repo.refs.get_head()
|
|
@@ -51,6 +74,10 @@ class TimelineCommand:
|
|
|
51
74
|
if commit_hash in seen:
|
|
52
75
|
break
|
|
53
76
|
seen.add(commit_hash)
|
|
77
|
+
if commits_in_range is not None and commit_hash not in commits_in_range:
|
|
78
|
+
commit = Commit.load(repo.object_store, commit_hash)
|
|
79
|
+
commit_hash = commit.parents[0] if commit and commit.parents else None
|
|
80
|
+
continue
|
|
54
81
|
|
|
55
82
|
commit = Commit.load(repo.object_store, commit_hash)
|
|
56
83
|
if not commit:
|