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
memvcs/commands/test.py CHANGED
@@ -11,80 +11,66 @@ from ..core.test_runner import TestRunner, create_test_template
11
11
 
12
12
  class TestCommand:
13
13
  """Run memory regression tests."""
14
-
15
- name = 'test'
16
- help = 'Run memory regression tests to validate knowledge consistency'
17
-
14
+
15
+ name = "test"
16
+ help = "Run memory regression tests to validate knowledge consistency"
17
+
18
18
  @staticmethod
19
19
  def add_arguments(parser: argparse.ArgumentParser):
20
+ parser.add_argument("--branch", help="Run tests against a specific branch")
21
+ parser.add_argument("--tags", nargs="+", help="Filter tests by tags")
20
22
  parser.add_argument(
21
- '--branch',
22
- help='Run tests against a specific branch'
23
- )
24
- parser.add_argument(
25
- '--tags',
26
- nargs='+',
27
- help='Filter tests by tags'
23
+ "--init", action="store_true", help="Initialize tests directory with template"
28
24
  )
29
25
  parser.add_argument(
30
- '--init',
31
- action='store_true',
32
- help='Initialize tests directory with template'
26
+ "-v", "--verbose", action="store_true", help="Show detailed test output"
33
27
  )
34
- parser.add_argument(
35
- '-v', '--verbose',
36
- action='store_true',
37
- help='Show detailed test output'
38
- )
39
- parser.add_argument(
40
- '--fail-fast',
41
- action='store_true',
42
- help='Stop on first failure'
43
- )
44
-
28
+ parser.add_argument("--fail-fast", action="store_true", help="Stop on first failure")
29
+
45
30
  @staticmethod
46
31
  def execute(args) -> int:
47
32
  repo, code = require_repo()
48
33
  if code != 0:
49
34
  return code
50
-
35
+
51
36
  # Handle --init
52
37
  if args.init:
53
38
  return TestCommand._init_tests(repo)
54
-
39
+
55
40
  # Try to get vector store
56
41
  vector_store = None
57
42
  try:
58
43
  from ..core.vector_store import VectorStore
59
- vector_store = VectorStore(repo.root / '.mem')
44
+
45
+ vector_store = VectorStore(repo.root / ".mem")
60
46
  except ImportError:
61
47
  if args.verbose:
62
48
  print("Note: Vector store not available, using text-based tests")
63
49
  except Exception as e:
64
50
  if args.verbose:
65
51
  print(f"Note: Could not initialize vector store: {e}")
66
-
52
+
67
53
  # Create test runner
68
54
  runner = TestRunner(repo, vector_store)
69
-
55
+
70
56
  # Load and check for tests
71
57
  tests = runner.load_tests()
72
58
  if not tests:
73
59
  print("No tests found.")
74
60
  print("Create test files in tests/ directory or run 'agmem test --init'")
75
61
  return 0
76
-
62
+
77
63
  print(f"Running {len(tests)} tests...")
78
-
64
+
79
65
  # Run tests
80
66
  if args.branch:
81
67
  result = runner.run_for_branch(args.branch)
82
68
  else:
83
69
  result = runner.run_all(tags=args.tags)
84
-
70
+
85
71
  # Print results
86
72
  print()
87
-
73
+
88
74
  if result.failures:
89
75
  print("Failed tests:")
90
76
  for failure in result.failures:
@@ -97,11 +83,11 @@ class TestCommand:
97
83
  print(f" Got: {failure.actual[:100]}...")
98
84
  print(f" Error: {failure.message}")
99
85
  print()
100
-
86
+
101
87
  # Summary
102
88
  status = "PASSED" if result.passed else "FAILED"
103
89
  critical_failures = [f for f in result.failures if f.is_critical]
104
-
90
+
105
91
  print(f"{'='*50}")
106
92
  print(f"Results: {result.passed_count}/{result.total_count} tests passed")
107
93
  if critical_failures:
@@ -109,24 +95,24 @@ class TestCommand:
109
95
  print(f"Duration: {result.duration_ms}ms")
110
96
  print(f"Status: {status}")
111
97
  print(f"{'='*50}")
112
-
98
+
113
99
  return 0 if result.passed else 1
114
-
100
+
115
101
  @staticmethod
116
102
  def _init_tests(repo) -> int:
117
103
  """Initialize tests directory with template."""
118
- tests_dir = repo.root / 'tests'
104
+ tests_dir = repo.root / "tests"
119
105
  tests_dir.mkdir(exist_ok=True)
120
-
121
- template_file = tests_dir / 'example_tests.yaml'
122
-
106
+
107
+ template_file = tests_dir / "example_tests.yaml"
108
+
123
109
  if template_file.exists():
124
110
  print(f"Test template already exists: {template_file}")
125
111
  return 0
126
-
112
+
127
113
  template_file.write_text(create_test_template())
128
114
  print(f"Created test template: {template_file}")
129
115
  print("\nEdit this file to add your memory tests.")
130
116
  print("Run 'agmem test' to execute them.")
131
-
117
+
132
118
  return 0
@@ -0,0 +1,111 @@
1
+ """
2
+ agmem timeline - Show evolution of a specific memory file over time.
3
+ """
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from ..commands.base import require_repo
9
+ from ..core.objects import Commit, Tree, Blob
10
+
11
+
12
+ class TimelineCommand:
13
+ """Show evolution of a memory file (blame-style over time)."""
14
+
15
+ name = "timeline"
16
+ help = "Show evolution of a specific memory file over time"
17
+
18
+ @staticmethod
19
+ def add_arguments(parser: argparse.ArgumentParser):
20
+ parser.add_argument(
21
+ "file",
22
+ help="File to show timeline for (path relative to current/)",
23
+ )
24
+ parser.add_argument(
25
+ "--limit",
26
+ "-n",
27
+ type=int,
28
+ default=20,
29
+ help="Max commits to show (default: 20)",
30
+ )
31
+
32
+ @staticmethod
33
+ def execute(args) -> int:
34
+ repo, code = require_repo()
35
+ if code != 0:
36
+ return code
37
+
38
+ filepath = args.file.replace("current/", "").lstrip("/")
39
+
40
+ # Walk commit history
41
+ head = repo.refs.get_head()
42
+ commit_hash = (
43
+ repo.refs.get_branch_commit(head["value"])
44
+ if head["type"] == "branch"
45
+ else head.get("value")
46
+ )
47
+
48
+ history = []
49
+ seen = set()
50
+ while commit_hash and len(history) < args.limit:
51
+ if commit_hash in seen:
52
+ break
53
+ seen.add(commit_hash)
54
+
55
+ commit = Commit.load(repo.object_store, commit_hash)
56
+ if not commit:
57
+ break
58
+
59
+ tree = repo.get_commit_tree(commit_hash)
60
+ if not tree:
61
+ commit_hash = commit.parents[0] if commit.parents else None
62
+ continue
63
+
64
+ blob_hash = None
65
+ for entry in tree.entries:
66
+ path = entry.path + "/" + entry.name if entry.path else entry.name
67
+ if path == filepath:
68
+ blob_hash = entry.hash
69
+ break
70
+
71
+ if blob_hash:
72
+ blob = Blob.load(repo.object_store, blob_hash)
73
+ content = blob.content.decode("utf-8", errors="replace") if blob else ""
74
+ history.append(
75
+ {
76
+ "commit": commit_hash,
77
+ "timestamp": commit.timestamp,
78
+ "author": commit.author,
79
+ "message": commit.message,
80
+ "content": content,
81
+ }
82
+ )
83
+
84
+ commit_hash = commit.parents[0] if commit.parents else None
85
+
86
+ if not history:
87
+ print(f"File {filepath} not found in commit history.")
88
+ return 1
89
+
90
+ print(f"Timeline for {filepath}:")
91
+ print("=" * 60)
92
+ for i, h in enumerate(history):
93
+ print(f"\n[{i + 1}] {h['commit'][:8]} {h['timestamp']}")
94
+ print(f" {h['author']}")
95
+ print(f" {h['message'][:70]}")
96
+ if i > 0 and history[i - 1]["content"] != h["content"]:
97
+ prev_content = history[i - 1]["content"].encode()
98
+ curr_content = h["content"].encode()
99
+ # Simple line diff
100
+ prev_lines = prev_content.splitlines()
101
+ curr_lines = curr_content.splitlines()
102
+ for j, (a, b) in enumerate(zip(prev_lines, curr_lines)):
103
+ if a != b:
104
+ print(f" ... (changed at line {j + 1})")
105
+ break
106
+ else:
107
+ if len(prev_lines) != len(curr_lines):
108
+ print(f" ... (lines changed: {len(prev_lines)} -> {len(curr_lines)})")
109
+ print()
110
+
111
+ return 0
memvcs/commands/tree.py CHANGED
@@ -4,7 +4,7 @@ agmem tree - Show working directory or commit tree visually.
4
4
 
5
5
  import argparse
6
6
  from pathlib import Path
7
- from typing import Optional
7
+ from typing import List, Optional
8
8
 
9
9
  from ..commands.base import require_repo
10
10
  from ..core.objects import Commit, Tree
@@ -18,7 +18,7 @@ def _build_tree_lines(
18
18
  show_hidden: bool = False,
19
19
  depth_limit: Optional[int] = None,
20
20
  current_depth: int = 0,
21
- ) -> list[str]:
21
+ ) -> List[str]:
22
22
  """Build tree lines for a directory."""
23
23
  lines = []
24
24
  if depth_limit is not None and current_depth >= depth_limit:
@@ -27,33 +27,32 @@ def _build_tree_lines(
27
27
  entries = sorted(base_path.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
28
28
  except PermissionError:
29
29
  return [f"{prefix}└── [permission denied]"]
30
-
30
+
31
31
  if not show_hidden:
32
32
  entries = [e for e in entries if not e.name.startswith(".")]
33
-
33
+
34
34
  for i, entry in enumerate(entries):
35
35
  is_last_entry = i == len(entries) - 1
36
36
  connector = "└── " if is_last_entry else "├── "
37
37
  lines.append(f"{prefix}{connector}{entry.name}")
38
-
38
+
39
39
  if entry.is_dir():
40
40
  extension = " " if is_last_entry else "│ "
41
41
  sub_prefix = prefix + extension
42
42
  lines.extend(
43
43
  _build_tree_lines(
44
- entry, sub_prefix, is_last_entry, show_hidden,
45
- depth_limit, current_depth + 1
44
+ entry, sub_prefix, is_last_entry, show_hidden, depth_limit, current_depth + 1
46
45
  )
47
46
  )
48
-
47
+
49
48
  return lines
50
49
 
51
50
 
52
- def _build_tree_from_entries(entries: list) -> list[str]:
51
+ def _build_tree_from_entries(entries: list) -> List[str]:
53
52
  """Build tree lines from commit tree entries (flat path/name/hash)."""
54
53
  # Build nested dict: {dir: {subdir: {file: hash}}}
55
54
  root: dict = {}
56
-
55
+
57
56
  for path, name, hash_id in entries:
58
57
  parts = (path.split("/") if path else []) + [name]
59
58
  current = root
@@ -65,8 +64,8 @@ def _build_tree_from_entries(entries: list) -> list[str]:
65
64
  if part not in current:
66
65
  current[part] = {}
67
66
  current = current[part]
68
-
69
- def _render(node: dict, prefix: str = "") -> list[str]:
67
+
68
+ def _render(node: dict, prefix: str = "") -> List[str]:
70
69
  lines = []
71
70
  # Directories first, then files; alphabetically within each
72
71
  items = sorted(node.items(), key=lambda x: (not isinstance(x[1], dict), x[0].lower()))
@@ -80,16 +79,16 @@ def _build_tree_from_entries(entries: list) -> list[str]:
80
79
  else:
81
80
  lines.append(f"{prefix}{conn}{key} ({val[:8]})")
82
81
  return lines
83
-
82
+
84
83
  return _render(root)
85
84
 
86
85
 
87
86
  class TreeCommand:
88
87
  """Show directory tree visually."""
89
-
88
+
90
89
  name = "tree"
91
90
  help = "Show working directory or commit tree visually"
92
-
91
+
93
92
  @staticmethod
94
93
  def add_arguments(parser: argparse.ArgumentParser):
95
94
  parser.add_argument(
@@ -99,17 +98,19 @@ class TreeCommand:
99
98
  help="Commit/branch to show (default: working directory)",
100
99
  )
101
100
  parser.add_argument(
102
- "-a", "--all",
101
+ "-a",
102
+ "--all",
103
103
  action="store_true",
104
104
  help="Show hidden files",
105
105
  )
106
106
  parser.add_argument(
107
- "-L", "--depth",
107
+ "-L",
108
+ "--depth",
108
109
  type=int,
109
110
  default=None,
110
111
  help="Limit depth of tree",
111
112
  )
112
-
113
+
113
114
  @staticmethod
114
115
  def execute(args) -> int:
115
116
  repo, code = require_repo()
@@ -122,19 +123,19 @@ class TreeCommand:
122
123
  if not commit_hash:
123
124
  print(f"Error: Unknown revision: {args.ref}")
124
125
  return 1
125
-
126
+
126
127
  commit = Commit.load(repo.object_store, commit_hash)
127
128
  if not commit:
128
129
  print(f"Error: Commit not found: {args.ref}")
129
130
  return 1
130
-
131
+
131
132
  tree = Tree.load(repo.object_store, commit.tree)
132
133
  if not tree:
133
134
  print(f"Error: Tree not found for {args.ref}")
134
135
  return 1
135
-
136
+
136
137
  entries = [(e.path, e.name, e.hash) for e in tree.entries]
137
-
138
+
138
139
  print(f"📁 {args.ref} ({commit_hash[:8]})")
139
140
  print("│")
140
141
  for line in _build_tree_from_entries(entries):
@@ -145,12 +146,10 @@ class TreeCommand:
145
146
  if not current_dir.exists():
146
147
  print("Error: current/ directory not found.")
147
148
  return 1
148
-
149
+
149
150
  print(f"📁 current/ (working directory)")
150
151
  print("│")
151
- for line in _build_tree_lines(
152
- current_dir, "", True, args.all, args.depth, 0
153
- ):
152
+ for line in _build_tree_lines(current_dir, "", True, args.all, args.depth, 0):
154
153
  print(line)
155
-
154
+
156
155
  return 0
@@ -0,0 +1,110 @@
1
+ """
2
+ agmem verify - Belief consistency and cryptographic commit verification.
3
+
4
+ Scans semantic memories for logical contradictions; optionally verifies commit Merkle/signatures.
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 VerifyCommand:
15
+ """Verify belief consistency and/or cryptographic integrity of commits."""
16
+
17
+ name = "verify"
18
+ help = "Scan semantic memories for contradictions; optionally verify commit signatures"
19
+
20
+ @staticmethod
21
+ def add_arguments(parser: argparse.ArgumentParser):
22
+ parser.add_argument(
23
+ "--consistency",
24
+ "-c",
25
+ action="store_true",
26
+ help="Check semantic memories for contradictions",
27
+ )
28
+ parser.add_argument(
29
+ "--crypto",
30
+ action="store_true",
31
+ help="Verify Merkle tree and signatures for commits",
32
+ )
33
+ parser.add_argument(
34
+ "--ref",
35
+ metavar="REF",
36
+ help="Commit or ref to verify (with --crypto); default HEAD",
37
+ )
38
+ parser.add_argument(
39
+ "--llm",
40
+ action="store_true",
41
+ help="Use LLM for triple extraction (requires OpenAI)",
42
+ )
43
+
44
+ @staticmethod
45
+ def _run_crypto_verify(repo, ref: str = None) -> int:
46
+ """Run cryptographic verification. Returns 0 if all OK, 1 on failure."""
47
+ from ..core.crypto_verify import verify_commit, load_public_key
48
+
49
+ if ref:
50
+ commit_hash = repo.resolve_ref(ref)
51
+ if not commit_hash:
52
+ print(f"Ref not found: {ref}")
53
+ return 1
54
+ else:
55
+ head = repo.refs.get_head()
56
+ if head["type"] == "branch":
57
+ commit_hash = repo.refs.get_branch_commit(head["value"])
58
+ else:
59
+ commit_hash = head.get("value")
60
+ if not commit_hash:
61
+ print("No commit to verify (empty repo).")
62
+ return 0
63
+ pub = load_public_key(repo.mem_dir)
64
+ ok, err = verify_commit(
65
+ repo.object_store, commit_hash, public_key_pem=pub, mem_dir=repo.mem_dir
66
+ )
67
+ if ok:
68
+ print(f"Commit {commit_hash[:8]} verified (Merkle + signature OK).")
69
+ return 0
70
+ print(f"Commit {commit_hash[:8]} verification failed: {err}")
71
+ return 1
72
+
73
+ @staticmethod
74
+ def execute(args) -> int:
75
+ repo, code = require_repo()
76
+ if code != 0:
77
+ return code
78
+
79
+ run_consistency = args.consistency
80
+ run_crypto = args.crypto
81
+ if not run_consistency and not run_crypto:
82
+ run_consistency = True
83
+
84
+ exit_code = 0
85
+
86
+ if run_crypto:
87
+ if VerifyCommand._run_crypto_verify(repo, args.ref) != 0:
88
+ exit_code = 1
89
+
90
+ if run_consistency:
91
+ checker = ConsistencyChecker(repo, llm_provider="openai" if args.llm else None)
92
+ result = checker.check(use_llm=args.llm)
93
+
94
+ print(f"Checked {result.files_checked} semantic file(s)")
95
+ if result.valid:
96
+ print("No contradictions found.")
97
+ else:
98
+ exit_code = 1
99
+ print(f"\nFound {len(result.contradictions)} contradiction(s):")
100
+ for i, c in enumerate(result.contradictions, 1):
101
+ print(f"\n[{i}] {c.reason}")
102
+ print(
103
+ f" {c.triple1.source}:{c.triple1.line}: {c.triple1.subject} {c.triple1.predicate} {c.triple1.obj}"
104
+ )
105
+ print(
106
+ f" {c.triple2.source}:{c.triple2.line}: {c.triple2.subject} {c.triple2.predicate} {c.triple2.obj}"
107
+ )
108
+ print("\nUse 'agmem repair --strategy confidence' to attempt auto-fix.")
109
+
110
+ return exit_code
@@ -0,0 +1,115 @@
1
+ """
2
+ agmem when - Find when a specific fact was learned.
3
+ """
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from ..commands.base import require_repo
9
+ from ..core.objects import Commit, Tree, Blob
10
+
11
+
12
+ class WhenCommand:
13
+ """Find when a fact was learned in memory history."""
14
+
15
+ name = "when"
16
+ help = "Find when a specific fact was learned"
17
+
18
+ @staticmethod
19
+ def add_arguments(parser: argparse.ArgumentParser):
20
+ parser.add_argument(
21
+ "fact",
22
+ nargs="?",
23
+ help="Fact or text to search for (e.g., 'user prefers dark mode')",
24
+ )
25
+ parser.add_argument(
26
+ "--file",
27
+ "-f",
28
+ help="Limit search to specific file (e.g., semantic/preferences.md)",
29
+ )
30
+ parser.add_argument(
31
+ "--limit",
32
+ "-n",
33
+ type=int,
34
+ default=10,
35
+ help="Max commits to report (default: 10)",
36
+ )
37
+
38
+ @staticmethod
39
+ def execute(args) -> int:
40
+ repo, code = require_repo()
41
+ if code != 0:
42
+ return code
43
+
44
+ if not args.fact:
45
+ print("Error: Fact to search for is required.")
46
+ print('Usage: agmem when "fact to find" [--file path]')
47
+ return 1
48
+
49
+ fact_lower = args.fact.lower()
50
+ file_filter = args.file.replace("current/", "").lstrip("/") if args.file else None
51
+
52
+ # Walk commit history from HEAD
53
+ head = repo.refs.get_head()
54
+ commit_hash = (
55
+ repo.refs.get_branch_commit(head["value"])
56
+ if head["type"] == "branch"
57
+ else head.get("value")
58
+ )
59
+
60
+ found = []
61
+ seen = set()
62
+ while commit_hash and len(found) < args.limit:
63
+ if commit_hash in seen:
64
+ break
65
+ seen.add(commit_hash)
66
+
67
+ commit = Commit.load(repo.object_store, commit_hash)
68
+ if not commit:
69
+ break
70
+
71
+ tree = repo.get_commit_tree(commit_hash)
72
+ if not tree:
73
+ commit_hash = commit.parents[0] if commit.parents else None
74
+ continue
75
+
76
+ # Check each file in tree
77
+ for entry in tree.entries:
78
+ path = entry.path + "/" + entry.name if entry.path else entry.name
79
+ if file_filter and path != file_filter:
80
+ continue
81
+ if entry.obj_type != "blob":
82
+ continue
83
+ blob = Blob.load(repo.object_store, entry.hash)
84
+ if not blob:
85
+ continue
86
+ try:
87
+ content = blob.content.decode("utf-8", errors="replace")
88
+ except Exception:
89
+ continue
90
+ if fact_lower in content.lower():
91
+ found.append(
92
+ {
93
+ "commit": commit_hash,
94
+ "path": path,
95
+ "timestamp": commit.timestamp,
96
+ "author": commit.author,
97
+ "message": commit.message,
98
+ }
99
+ )
100
+ break # One match per commit
101
+
102
+ commit_hash = commit.parents[0] if commit.parents else None
103
+
104
+ if not found:
105
+ scope = f" in {file_filter}" if file_filter else ""
106
+ print(f'No commits found containing "{args.fact}"{scope}')
107
+ return 0
108
+
109
+ print(f'Fact "{args.fact}" found in {len(found)} commit(s):')
110
+ print()
111
+ for i, m in enumerate(found, 1):
112
+ print(f"[{i}] {m['commit'][:8]} {m['timestamp']} - {m['path']}")
113
+ print(f" {m['message'][:60]}")
114
+ print()
115
+ return 0