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.
- agmem-0.1.1.dist-info/METADATA +656 -0
- agmem-0.1.1.dist-info/RECORD +67 -0
- agmem-0.1.1.dist-info/WHEEL +5 -0
- agmem-0.1.1.dist-info/entry_points.txt +2 -0
- agmem-0.1.1.dist-info/licenses/LICENSE +21 -0
- agmem-0.1.1.dist-info/top_level.txt +1 -0
- memvcs/__init__.py +9 -0
- memvcs/cli.py +178 -0
- memvcs/commands/__init__.py +23 -0
- memvcs/commands/add.py +258 -0
- memvcs/commands/base.py +23 -0
- memvcs/commands/blame.py +169 -0
- memvcs/commands/branch.py +110 -0
- memvcs/commands/checkout.py +101 -0
- memvcs/commands/clean.py +76 -0
- memvcs/commands/clone.py +91 -0
- memvcs/commands/commit.py +174 -0
- memvcs/commands/daemon.py +267 -0
- memvcs/commands/diff.py +157 -0
- memvcs/commands/fsck.py +203 -0
- memvcs/commands/garden.py +107 -0
- memvcs/commands/graph.py +151 -0
- memvcs/commands/init.py +61 -0
- memvcs/commands/log.py +103 -0
- memvcs/commands/mcp.py +59 -0
- memvcs/commands/merge.py +88 -0
- memvcs/commands/pull.py +65 -0
- memvcs/commands/push.py +143 -0
- memvcs/commands/reflog.py +52 -0
- memvcs/commands/remote.py +51 -0
- memvcs/commands/reset.py +98 -0
- memvcs/commands/search.py +163 -0
- memvcs/commands/serve.py +54 -0
- memvcs/commands/show.py +125 -0
- memvcs/commands/stash.py +97 -0
- memvcs/commands/status.py +112 -0
- memvcs/commands/tag.py +117 -0
- memvcs/commands/test.py +132 -0
- memvcs/commands/tree.py +156 -0
- memvcs/core/__init__.py +21 -0
- memvcs/core/config_loader.py +245 -0
- memvcs/core/constants.py +12 -0
- memvcs/core/diff.py +380 -0
- memvcs/core/gardener.py +466 -0
- memvcs/core/hooks.py +151 -0
- memvcs/core/knowledge_graph.py +381 -0
- memvcs/core/merge.py +474 -0
- memvcs/core/objects.py +323 -0
- memvcs/core/pii_scanner.py +343 -0
- memvcs/core/refs.py +447 -0
- memvcs/core/remote.py +278 -0
- memvcs/core/repository.py +522 -0
- memvcs/core/schema.py +414 -0
- memvcs/core/staging.py +227 -0
- memvcs/core/storage/__init__.py +72 -0
- memvcs/core/storage/base.py +359 -0
- memvcs/core/storage/gcs.py +308 -0
- memvcs/core/storage/local.py +182 -0
- memvcs/core/storage/s3.py +369 -0
- memvcs/core/test_runner.py +371 -0
- memvcs/core/vector_store.py +313 -0
- memvcs/integrations/__init__.py +5 -0
- memvcs/integrations/mcp_server.py +267 -0
- memvcs/integrations/web_ui/__init__.py +1 -0
- memvcs/integrations/web_ui/server.py +352 -0
- memvcs/utils/__init__.py +9 -0
- memvcs/utils/helpers.py +178 -0
memvcs/commands/merge.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem merge - Join two or more development histories together.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from ..commands.base import require_repo
|
|
8
|
+
from ..core.merge import MergeEngine
|
|
9
|
+
from ..core.repository import Repository
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MergeCommand:
|
|
13
|
+
"""Merge branches."""
|
|
14
|
+
|
|
15
|
+
name = 'merge'
|
|
16
|
+
help = 'Join two or more development histories together'
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
'branch',
|
|
22
|
+
help='Branch to merge into current branch'
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
'-m', '--message',
|
|
26
|
+
help='Merge commit message'
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
'--no-commit',
|
|
30
|
+
action='store_true',
|
|
31
|
+
help='Perform merge but do not commit'
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
'--abort',
|
|
35
|
+
action='store_true',
|
|
36
|
+
help='Abort the current merge'
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def execute(args) -> int:
|
|
41
|
+
repo, code = require_repo()
|
|
42
|
+
if code != 0:
|
|
43
|
+
return code
|
|
44
|
+
|
|
45
|
+
# Abort merge
|
|
46
|
+
if args.abort:
|
|
47
|
+
# TODO: Implement merge abort
|
|
48
|
+
print("Merge abort not yet implemented")
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
# Check if we're on a branch
|
|
52
|
+
current_branch = repo.refs.get_current_branch()
|
|
53
|
+
if not current_branch:
|
|
54
|
+
print("Error: Not currently on any branch.")
|
|
55
|
+
print("Cannot merge when HEAD is detached.")
|
|
56
|
+
return 1
|
|
57
|
+
|
|
58
|
+
# Check if trying to merge current branch
|
|
59
|
+
if args.branch == current_branch:
|
|
60
|
+
print(f"Error: Cannot merge '{args.branch}' into itself")
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
# Check if branch exists
|
|
64
|
+
if not repo.refs.branch_exists(args.branch):
|
|
65
|
+
print(f"Error: Branch '{args.branch}' not found.")
|
|
66
|
+
return 1
|
|
67
|
+
|
|
68
|
+
# Perform merge
|
|
69
|
+
engine = MergeEngine(repo)
|
|
70
|
+
result = engine.merge(args.branch, message=args.message)
|
|
71
|
+
|
|
72
|
+
if result.success:
|
|
73
|
+
print(f"Merge successful: {result.message}")
|
|
74
|
+
if result.commit_hash:
|
|
75
|
+
print(f" Commit: {result.commit_hash[:8]}")
|
|
76
|
+
return 0
|
|
77
|
+
else:
|
|
78
|
+
print(f"Merge failed: {result.message}")
|
|
79
|
+
|
|
80
|
+
if result.conflicts:
|
|
81
|
+
print()
|
|
82
|
+
print("Conflicts:")
|
|
83
|
+
for conflict in result.conflicts:
|
|
84
|
+
print(f" {conflict.path}")
|
|
85
|
+
print()
|
|
86
|
+
print("Resolve conflicts and run 'agmem commit' to complete the merge.")
|
|
87
|
+
|
|
88
|
+
return 1
|
memvcs/commands/pull.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem pull - Pull memory from remote.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PullCommand:
|
|
10
|
+
"""Pull memory from remote repository."""
|
|
11
|
+
|
|
12
|
+
name = "pull"
|
|
13
|
+
help = "Pull memory from remote repository"
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"remote",
|
|
19
|
+
nargs="?",
|
|
20
|
+
default="origin",
|
|
21
|
+
help="Remote name (default: origin)",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"branch",
|
|
25
|
+
nargs="?",
|
|
26
|
+
help="Branch to pull (default: all)",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def execute(args) -> int:
|
|
31
|
+
from memvcs.commands.base import require_repo
|
|
32
|
+
from memvcs.core.remote import Remote
|
|
33
|
+
|
|
34
|
+
repo, code = require_repo()
|
|
35
|
+
if code != 0:
|
|
36
|
+
return code
|
|
37
|
+
|
|
38
|
+
remote = Remote(repo.root, args.remote)
|
|
39
|
+
if not remote.get_remote_url():
|
|
40
|
+
print(f"Error: Remote '{args.remote}' has no URL. Set with: agmem remote add {args.remote} <url>")
|
|
41
|
+
return 1
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
msg = remote.fetch(branch=args.branch)
|
|
45
|
+
print(msg)
|
|
46
|
+
# Merge fetched refs into current branch
|
|
47
|
+
current_branch = repo.refs.get_current_branch()
|
|
48
|
+
if current_branch is not None:
|
|
49
|
+
remote_ref = f"{args.remote}/{current_branch}"
|
|
50
|
+
remote_hash = repo.resolve_ref(remote_ref)
|
|
51
|
+
if remote_hash:
|
|
52
|
+
from memvcs.core.merge import MergeEngine
|
|
53
|
+
merge_engine = MergeEngine(repo)
|
|
54
|
+
try:
|
|
55
|
+
result = merge_engine.merge(remote_ref)
|
|
56
|
+
if result.success:
|
|
57
|
+
print(f"Merged {remote_ref} into {current_branch}.")
|
|
58
|
+
else:
|
|
59
|
+
print("Merge had conflicts. Resolve and commit.")
|
|
60
|
+
except Exception as e:
|
|
61
|
+
print(f"Merge note: {e}")
|
|
62
|
+
return 0
|
|
63
|
+
except ValueError as e:
|
|
64
|
+
print(f"Error: {e}")
|
|
65
|
+
return 1
|
memvcs/commands/push.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem push - Push memory to remote with auto-rebase support.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MemoryConflictError(Exception):
|
|
10
|
+
"""Exception raised when push fails due to conflicts."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PushCommand:
|
|
15
|
+
"""Push memory repository to remote with auto-rebase."""
|
|
16
|
+
|
|
17
|
+
name = "push"
|
|
18
|
+
help = "Push memory to remote repository"
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"remote",
|
|
24
|
+
nargs="?",
|
|
25
|
+
default="origin",
|
|
26
|
+
help="Remote name (default: origin)",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"branch",
|
|
30
|
+
nargs="?",
|
|
31
|
+
help="Branch to push (default: current)",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--force", "-f",
|
|
35
|
+
action="store_true",
|
|
36
|
+
help="Force push (WARNING: may overwrite remote changes)",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--no-rebase",
|
|
40
|
+
action="store_true",
|
|
41
|
+
help="Don't attempt auto-rebase on conflicts",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def execute(args) -> int:
|
|
46
|
+
from memvcs.commands.base import require_repo
|
|
47
|
+
from memvcs.core.remote import Remote
|
|
48
|
+
from memvcs.core.merge import MergeEngine
|
|
49
|
+
|
|
50
|
+
repo, code = require_repo()
|
|
51
|
+
if code != 0:
|
|
52
|
+
return code
|
|
53
|
+
|
|
54
|
+
remote = Remote(repo.root, args.remote)
|
|
55
|
+
remote_url = remote.get_remote_url()
|
|
56
|
+
|
|
57
|
+
if not remote_url:
|
|
58
|
+
print(f"Error: Remote '{args.remote}' has no URL.")
|
|
59
|
+
print(f"Set with: agmem remote add {args.remote} <url>")
|
|
60
|
+
return 1
|
|
61
|
+
|
|
62
|
+
# Get current branch
|
|
63
|
+
branch = args.branch or repo.refs.get_current_branch()
|
|
64
|
+
if not branch:
|
|
65
|
+
print("Error: Not on a branch and no branch specified")
|
|
66
|
+
return 1
|
|
67
|
+
|
|
68
|
+
# Force push warning
|
|
69
|
+
if args.force:
|
|
70
|
+
print("WARNING: Force push may overwrite remote changes!")
|
|
71
|
+
local_hash = repo.refs.get_branch_commit(branch)
|
|
72
|
+
try:
|
|
73
|
+
msg = remote.push(branch=branch)
|
|
74
|
+
print(msg)
|
|
75
|
+
return 0
|
|
76
|
+
except ValueError as e:
|
|
77
|
+
print(f"Error: {e}")
|
|
78
|
+
return 1
|
|
79
|
+
|
|
80
|
+
# Auto-rebase workflow
|
|
81
|
+
if not args.no_rebase:
|
|
82
|
+
# Fetch remote state
|
|
83
|
+
try:
|
|
84
|
+
print(f"Fetching from {args.remote}...")
|
|
85
|
+
remote.fetch()
|
|
86
|
+
except Exception as e:
|
|
87
|
+
print(f"Note: Could not fetch remote ({e}), attempting direct push...")
|
|
88
|
+
|
|
89
|
+
# Check if we're behind remote
|
|
90
|
+
local_hash = repo.refs.get_branch_commit(branch)
|
|
91
|
+
remote_branch = f"{args.remote}/{branch}"
|
|
92
|
+
remote_hash = repo.resolve_ref(remote_branch)
|
|
93
|
+
|
|
94
|
+
if remote_hash and remote_hash != local_hash:
|
|
95
|
+
# Check if we can fast-forward or need rebase
|
|
96
|
+
merge_engine = MergeEngine(repo)
|
|
97
|
+
ancestor = merge_engine.find_common_ancestor(local_hash, remote_hash)
|
|
98
|
+
|
|
99
|
+
if ancestor == local_hash:
|
|
100
|
+
# We're behind - need to pull first
|
|
101
|
+
print("Local is behind remote. Pull first with: agmem pull")
|
|
102
|
+
return 1
|
|
103
|
+
|
|
104
|
+
elif ancestor != remote_hash:
|
|
105
|
+
# Diverged - need to merge/rebase
|
|
106
|
+
print("Local and remote have diverged.")
|
|
107
|
+
print("Attempting auto-merge...")
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
result = merge_engine.merge(remote_branch)
|
|
111
|
+
|
|
112
|
+
if result.success:
|
|
113
|
+
print(f"Auto-merged with {remote_branch}")
|
|
114
|
+
# Update local hash after merge
|
|
115
|
+
local_hash = repo.refs.get_branch_commit(branch)
|
|
116
|
+
else:
|
|
117
|
+
print("Auto-merge failed with conflicts:")
|
|
118
|
+
for conflict in result.conflicts:
|
|
119
|
+
print(f" - {conflict.path}")
|
|
120
|
+
print("\nResolve conflicts, commit, and try again.")
|
|
121
|
+
print("Or use --force to overwrite (not recommended).")
|
|
122
|
+
raise MemoryConflictError(result.message)
|
|
123
|
+
|
|
124
|
+
except MemoryConflictError:
|
|
125
|
+
return 1
|
|
126
|
+
except Exception as e:
|
|
127
|
+
print(f"Merge failed: {e}")
|
|
128
|
+
print("Use --no-rebase to skip auto-merge or --force to overwrite")
|
|
129
|
+
return 1
|
|
130
|
+
|
|
131
|
+
# Push
|
|
132
|
+
try:
|
|
133
|
+
msg = remote.push(branch=branch)
|
|
134
|
+
print(msg)
|
|
135
|
+
return 0
|
|
136
|
+
except ValueError as e:
|
|
137
|
+
error_msg = str(e)
|
|
138
|
+
if "non-fast-forward" in error_msg.lower() or "rejected" in error_msg.lower():
|
|
139
|
+
print("Push rejected: remote has changes you don't have.")
|
|
140
|
+
print("Run 'agmem pull' first, or use --force to overwrite.")
|
|
141
|
+
return 1
|
|
142
|
+
print(f"Error: {e}")
|
|
143
|
+
return 1
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem reflog - Show reference history (Git-like).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..commands.base import require_repo
|
|
9
|
+
from ..core.repository import Repository
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ReflogCommand:
|
|
13
|
+
"""Show reflog - history of HEAD changes."""
|
|
14
|
+
|
|
15
|
+
name = 'reflog'
|
|
16
|
+
help = 'Show reference log (history of HEAD changes)'
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
20
|
+
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'
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def execute(args) -> int:
|
|
35
|
+
repo, code = require_repo()
|
|
36
|
+
if code != 0:
|
|
37
|
+
return code
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
entries = repo.refs.get_reflog(args.ref, args.max_count)
|
|
41
|
+
|
|
42
|
+
if not entries:
|
|
43
|
+
print("No reflog entries found.")
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
for e in entries:
|
|
47
|
+
h = e['hash'][:8]
|
|
48
|
+
ts = e.get('timestamp', '')[:19]
|
|
49
|
+
msg = e.get('message', '')
|
|
50
|
+
print(f"{h} {ts} {msg}")
|
|
51
|
+
|
|
52
|
+
return 0
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem remote - Manage remote URLs.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RemoteCommand:
|
|
10
|
+
"""Manage remote repository URLs."""
|
|
11
|
+
|
|
12
|
+
name = "remote"
|
|
13
|
+
help = "Manage remote URLs (add, set-url, show)"
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
17
|
+
subparsers = parser.add_subparsers(dest="remote_action", required=True)
|
|
18
|
+
add_p = subparsers.add_parser("add", help="Add a remote")
|
|
19
|
+
add_p.add_argument("name", help="Remote name (e.g. origin)")
|
|
20
|
+
add_p.add_argument("url", help="Remote URL (e.g. file:///path)")
|
|
21
|
+
set_p = subparsers.add_parser("set-url", help="Set remote URL")
|
|
22
|
+
set_p.add_argument("name", help="Remote name")
|
|
23
|
+
set_p.add_argument("url", help="New URL")
|
|
24
|
+
subparsers.add_parser("show", help="Show remotes")
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def execute(args) -> int:
|
|
28
|
+
from memvcs.commands.base import require_repo
|
|
29
|
+
from memvcs.core.remote import Remote
|
|
30
|
+
|
|
31
|
+
repo, code = require_repo()
|
|
32
|
+
if code != 0:
|
|
33
|
+
return code
|
|
34
|
+
|
|
35
|
+
remote = Remote(repo.root, getattr(args, "name", "origin"))
|
|
36
|
+
|
|
37
|
+
if args.remote_action == "add" or args.remote_action == "set-url":
|
|
38
|
+
r = Remote(repo.root, args.name)
|
|
39
|
+
r.set_remote_url(args.url)
|
|
40
|
+
print(f"Remote '{args.name}' set to {args.url}")
|
|
41
|
+
elif args.remote_action == "show":
|
|
42
|
+
import json
|
|
43
|
+
config = json.loads((repo.root / ".mem" / "config.json").read_text())
|
|
44
|
+
remotes = config.get("remotes", {})
|
|
45
|
+
if remotes:
|
|
46
|
+
for name, info in remotes.items():
|
|
47
|
+
print(f"{name}\t{info.get('url', '')}")
|
|
48
|
+
else:
|
|
49
|
+
print("No remotes configured.")
|
|
50
|
+
|
|
51
|
+
return 0
|
memvcs/commands/reset.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem reset - Reset current HEAD to the specified state.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..commands.base import require_repo
|
|
9
|
+
from ..core.repository import Repository
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ResetCommand:
|
|
13
|
+
"""Reset current HEAD to the specified state."""
|
|
14
|
+
|
|
15
|
+
name = 'reset'
|
|
16
|
+
help = 'Reset current HEAD to the specified state'
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
'commit',
|
|
22
|
+
nargs='?',
|
|
23
|
+
default='HEAD',
|
|
24
|
+
help='Commit to reset to (default: HEAD)'
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
'--soft',
|
|
28
|
+
action='store_true',
|
|
29
|
+
help='Reset HEAD but keep staged changes'
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
'--mixed',
|
|
33
|
+
action='store_true',
|
|
34
|
+
help='Reset HEAD and unstaged changes (default)'
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
'--hard',
|
|
38
|
+
action='store_true',
|
|
39
|
+
help='Reset HEAD, index, and working tree'
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def execute(args) -> int:
|
|
44
|
+
# Find repository
|
|
45
|
+
repo, code = require_repo()
|
|
46
|
+
if code != 0:
|
|
47
|
+
return code
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Determine mode
|
|
51
|
+
if args.soft:
|
|
52
|
+
mode = 'soft'
|
|
53
|
+
elif args.hard:
|
|
54
|
+
mode = 'hard'
|
|
55
|
+
else:
|
|
56
|
+
mode = 'mixed'
|
|
57
|
+
|
|
58
|
+
# Resolve commit
|
|
59
|
+
commit_hash = repo.resolve_ref(args.commit)
|
|
60
|
+
if not commit_hash:
|
|
61
|
+
print(f"Error: Unknown revision: {args.commit}")
|
|
62
|
+
return 1
|
|
63
|
+
|
|
64
|
+
# Get current branch
|
|
65
|
+
current_branch = repo.refs.get_current_branch()
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
if mode == 'soft':
|
|
69
|
+
# Just move HEAD
|
|
70
|
+
if current_branch:
|
|
71
|
+
repo.refs.set_branch_commit(current_branch, commit_hash)
|
|
72
|
+
else:
|
|
73
|
+
repo.refs.set_head_detached(commit_hash)
|
|
74
|
+
print(f"HEAD is now at {commit_hash[:8]}")
|
|
75
|
+
|
|
76
|
+
elif mode == 'mixed':
|
|
77
|
+
# Move HEAD and clear staging
|
|
78
|
+
if current_branch:
|
|
79
|
+
repo.refs.set_branch_commit(current_branch, commit_hash)
|
|
80
|
+
else:
|
|
81
|
+
repo.refs.set_head_detached(commit_hash)
|
|
82
|
+
|
|
83
|
+
# Keep staged files but mark them as unstaged
|
|
84
|
+
# (In a full implementation, we'd restore the tree state)
|
|
85
|
+
print(f"HEAD is now at {commit_hash[:8]}")
|
|
86
|
+
print("Staged changes have been unstaged.")
|
|
87
|
+
|
|
88
|
+
elif mode == 'hard':
|
|
89
|
+
# Move HEAD, clear staging, and restore working tree
|
|
90
|
+
repo.checkout(commit_hash, force=True)
|
|
91
|
+
print(f"HEAD is now at {commit_hash[:8]}")
|
|
92
|
+
print("Working tree has been reset.")
|
|
93
|
+
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
except Exception as e:
|
|
97
|
+
print(f"Error during reset: {e}")
|
|
98
|
+
return 1
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agmem search - Semantic search over memory.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _is_vector_unavailable_error(exc: Exception) -> bool:
|
|
11
|
+
"""True if the exception indicates vector deps are missing (fall back to text search)."""
|
|
12
|
+
msg = str(exc).lower()
|
|
13
|
+
return any(
|
|
14
|
+
key in msg for key in ("sqlite-vec", "sentence-transformers", "vector search")
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _first_line_containing(content: str, query: str, max_len: int = 200) -> str:
|
|
19
|
+
"""Return the first line that contains query (lowercased), trimmed to max_len, or empty string."""
|
|
20
|
+
for line in content.splitlines():
|
|
21
|
+
if query in line.lower():
|
|
22
|
+
return line.strip()[:max_len]
|
|
23
|
+
return ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SearchCommand:
|
|
27
|
+
"""Semantic search over memory files."""
|
|
28
|
+
|
|
29
|
+
name = "search"
|
|
30
|
+
help = "Search memory (semantic with agmem[vector], else plain text)"
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def add_arguments(parser: argparse.ArgumentParser):
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"query",
|
|
36
|
+
nargs="?",
|
|
37
|
+
default="",
|
|
38
|
+
help="Search query for semantic search",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--limit", "-n",
|
|
42
|
+
type=int,
|
|
43
|
+
default=10,
|
|
44
|
+
help="Maximum results to return (default: 10)",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--rebuild",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="Rebuild vector index from current/ before searching",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--index-only",
|
|
53
|
+
action="store_true",
|
|
54
|
+
help="Only build/rebuild index, do not search",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def execute(args) -> int:
|
|
59
|
+
from memvcs.commands.base import require_repo
|
|
60
|
+
from memvcs.core.repository import Repository
|
|
61
|
+
|
|
62
|
+
repo, code = require_repo()
|
|
63
|
+
if code != 0:
|
|
64
|
+
return code
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
from memvcs.core.vector_store import VectorStore
|
|
68
|
+
|
|
69
|
+
store = VectorStore(repo.mem_dir)
|
|
70
|
+
except ImportError:
|
|
71
|
+
return SearchCommand._text_search(repo, args)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
if args.rebuild or args.index_only:
|
|
75
|
+
count = store.rebuild_index(repo.current_dir)
|
|
76
|
+
print(f"Indexed {count} file(s).")
|
|
77
|
+
if args.index_only:
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
# Lazy index on first search if index is empty
|
|
81
|
+
if args.query and not store.db_path.exists():
|
|
82
|
+
print("Building index on first search...")
|
|
83
|
+
count = store.index_directory(repo.current_dir)
|
|
84
|
+
print(f"Indexed {count} file(s).\n")
|
|
85
|
+
|
|
86
|
+
if not args.query:
|
|
87
|
+
if not args.index_only:
|
|
88
|
+
print("Usage: agmem search <query> [--limit N] [--rebuild]")
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
results = store.search(args.query, limit=args.limit)
|
|
92
|
+
|
|
93
|
+
if not results:
|
|
94
|
+
print(f"No results for '{args.query}'.")
|
|
95
|
+
print("Try --rebuild to index current/ files.")
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
for path, snippet, distance in results:
|
|
99
|
+
print(f"\n--- {path} (distance: {distance:.4f}) ---")
|
|
100
|
+
print(snippet)
|
|
101
|
+
print()
|
|
102
|
+
|
|
103
|
+
return 0
|
|
104
|
+
except Exception as e:
|
|
105
|
+
if _is_vector_unavailable_error(e):
|
|
106
|
+
return SearchCommand._text_search(repo, args)
|
|
107
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
108
|
+
return 1
|
|
109
|
+
finally:
|
|
110
|
+
store.close()
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _text_search(repo, args) -> int:
|
|
114
|
+
"""Plain text search when vector store is not available."""
|
|
115
|
+
if args.index_only or args.rebuild:
|
|
116
|
+
print("Note: Install agmem[vector] for index/rebuild. Using plain text search.")
|
|
117
|
+
if not args.query:
|
|
118
|
+
print("Usage: agmem search <query> [--limit N]")
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
query_lower = args.query.lower()
|
|
122
|
+
results = []
|
|
123
|
+
current_dir = repo.current_dir
|
|
124
|
+
if not current_dir.exists():
|
|
125
|
+
print("No current/ directory.")
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
for ext in ("*.md", "*.txt"):
|
|
129
|
+
for f in current_dir.rglob(ext):
|
|
130
|
+
if not f.is_file():
|
|
131
|
+
continue
|
|
132
|
+
try:
|
|
133
|
+
content = f.read_text(encoding="utf-8", errors="replace")
|
|
134
|
+
except Exception:
|
|
135
|
+
continue
|
|
136
|
+
if query_lower not in content.lower():
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
rel = str(f.relative_to(current_dir))
|
|
140
|
+
except ValueError:
|
|
141
|
+
continue
|
|
142
|
+
# Snippet: line containing query
|
|
143
|
+
for line in content.splitlines():
|
|
144
|
+
if query_lower in line.lower():
|
|
145
|
+
snippet = line.strip()[:200]
|
|
146
|
+
break
|
|
147
|
+
else:
|
|
148
|
+
snippet = content[:200].strip()
|
|
149
|
+
results.append((rel, snippet, 0.0))
|
|
150
|
+
if len(results) >= args.limit:
|
|
151
|
+
break
|
|
152
|
+
if len(results) >= args.limit:
|
|
153
|
+
break
|
|
154
|
+
|
|
155
|
+
if not results:
|
|
156
|
+
print(f"No results for '{args.query}'.")
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
for path, snippet, _ in results:
|
|
160
|
+
print(f"\n--- {path} ---")
|
|
161
|
+
print(snippet)
|
|
162
|
+
print()
|
|
163
|
+
return 0
|