agmem 0.1.1__py3-none-any.whl → 0.1.2__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 (80) hide show
  1. {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/METADATA +20 -3
  2. agmem-0.1.2.dist-info/RECORD +86 -0
  3. memvcs/__init__.py +1 -1
  4. memvcs/cli.py +35 -31
  5. memvcs/commands/__init__.py +9 -9
  6. memvcs/commands/add.py +77 -76
  7. memvcs/commands/blame.py +46 -53
  8. memvcs/commands/branch.py +13 -33
  9. memvcs/commands/checkout.py +27 -32
  10. memvcs/commands/clean.py +18 -23
  11. memvcs/commands/clone.py +4 -1
  12. memvcs/commands/commit.py +40 -39
  13. memvcs/commands/daemon.py +81 -76
  14. memvcs/commands/decay.py +77 -0
  15. memvcs/commands/diff.py +56 -57
  16. memvcs/commands/distill.py +74 -0
  17. memvcs/commands/fsck.py +55 -61
  18. memvcs/commands/garden.py +28 -37
  19. memvcs/commands/graph.py +41 -48
  20. memvcs/commands/init.py +16 -24
  21. memvcs/commands/log.py +25 -40
  22. memvcs/commands/merge.py +16 -28
  23. memvcs/commands/pack.py +129 -0
  24. memvcs/commands/pull.py +4 -1
  25. memvcs/commands/push.py +4 -2
  26. memvcs/commands/recall.py +145 -0
  27. memvcs/commands/reflog.py +13 -22
  28. memvcs/commands/remote.py +1 -0
  29. memvcs/commands/repair.py +66 -0
  30. memvcs/commands/reset.py +23 -33
  31. memvcs/commands/resurrect.py +82 -0
  32. memvcs/commands/search.py +3 -4
  33. memvcs/commands/serve.py +2 -1
  34. memvcs/commands/show.py +66 -36
  35. memvcs/commands/stash.py +34 -34
  36. memvcs/commands/status.py +27 -35
  37. memvcs/commands/tag.py +23 -47
  38. memvcs/commands/test.py +30 -44
  39. memvcs/commands/timeline.py +111 -0
  40. memvcs/commands/tree.py +26 -27
  41. memvcs/commands/verify.py +59 -0
  42. memvcs/commands/when.py +115 -0
  43. memvcs/core/access_index.py +167 -0
  44. memvcs/core/config_loader.py +3 -1
  45. memvcs/core/consistency.py +214 -0
  46. memvcs/core/decay.py +185 -0
  47. memvcs/core/diff.py +158 -143
  48. memvcs/core/distiller.py +277 -0
  49. memvcs/core/gardener.py +164 -132
  50. memvcs/core/hooks.py +48 -14
  51. memvcs/core/knowledge_graph.py +134 -138
  52. memvcs/core/merge.py +248 -171
  53. memvcs/core/objects.py +95 -96
  54. memvcs/core/pii_scanner.py +147 -146
  55. memvcs/core/refs.py +132 -115
  56. memvcs/core/repository.py +174 -164
  57. memvcs/core/schema.py +155 -113
  58. memvcs/core/staging.py +60 -65
  59. memvcs/core/storage/__init__.py +20 -18
  60. memvcs/core/storage/base.py +74 -70
  61. memvcs/core/storage/gcs.py +70 -68
  62. memvcs/core/storage/local.py +42 -40
  63. memvcs/core/storage/s3.py +105 -110
  64. memvcs/core/temporal_index.py +112 -0
  65. memvcs/core/test_runner.py +101 -93
  66. memvcs/core/vector_store.py +41 -35
  67. memvcs/integrations/mcp_server.py +1 -3
  68. memvcs/integrations/web_ui/server.py +25 -26
  69. memvcs/retrieval/__init__.py +22 -0
  70. memvcs/retrieval/base.py +54 -0
  71. memvcs/retrieval/pack.py +128 -0
  72. memvcs/retrieval/recaller.py +105 -0
  73. memvcs/retrieval/strategies.py +314 -0
  74. memvcs/utils/__init__.py +3 -3
  75. memvcs/utils/helpers.py +52 -52
  76. agmem-0.1.1.dist-info/RECORD +0 -67
  77. {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/WHEEL +0 -0
  78. {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/entry_points.txt +0 -0
  79. {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/licenses/LICENSE +0 -0
  80. {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/top_level.txt +0 -0
memvcs/commands/daemon.py CHANGED
@@ -14,34 +14,34 @@ from ..commands.base import require_repo
14
14
 
15
15
  class DaemonCommand:
16
16
  """Control the auto-sync daemon."""
17
-
18
- name = 'daemon'
19
- help = 'Start/stop the auto-sync daemon for automatic commits'
20
-
17
+
18
+ name = "daemon"
19
+ help = "Start/stop the auto-sync daemon for automatic commits"
20
+
21
21
  @staticmethod
22
22
  def add_arguments(parser: argparse.ArgumentParser):
23
23
  parser.add_argument(
24
- 'action',
25
- choices=['start', 'stop', 'status', 'run'],
26
- help='Daemon action: start, stop, status, or run (foreground)'
24
+ "action",
25
+ choices=["start", "stop", "status", "run"],
26
+ help="Daemon action: start, stop, status, or run (foreground)",
27
27
  )
28
28
  parser.add_argument(
29
- '--debounce',
29
+ "--debounce",
30
30
  type=int,
31
31
  default=30,
32
- help='Seconds to wait after changes before committing (default: 30)'
32
+ help="Seconds to wait after changes before committing (default: 30)",
33
33
  )
34
+ parser.add_argument("--pidfile", help="PID file path (default: .mem/daemon.pid)")
34
35
  parser.add_argument(
35
- '--pidfile',
36
- help='PID file path (default: .mem/daemon.pid)'
36
+ "--distill", action="store_true", help="Run distillation pipeline nightly (with daemon)"
37
37
  )
38
-
38
+
39
39
  @staticmethod
40
40
  def execute(args) -> int:
41
41
  repo, code = require_repo()
42
42
  if code != 0:
43
43
  return code
44
-
44
+
45
45
  if args.pidfile:
46
46
  pid_file = Path(args.pidfile).resolve()
47
47
  try:
@@ -50,21 +50,21 @@ class DaemonCommand:
50
50
  print("Error: --pidfile must be under repository root")
51
51
  return 1
52
52
  else:
53
- pid_file = repo.root / '.mem' / 'daemon.pid'
54
-
55
- if args.action == 'start':
56
- return DaemonCommand._start(repo, pid_file, args.debounce)
57
- elif args.action == 'stop':
53
+ pid_file = repo.root / ".mem" / "daemon.pid"
54
+
55
+ if args.action == "start":
56
+ return DaemonCommand._start(repo, pid_file, args.debounce, args.distill)
57
+ elif args.action == "stop":
58
58
  return DaemonCommand._stop(pid_file)
59
- elif args.action == 'status':
59
+ elif args.action == "status":
60
60
  return DaemonCommand._status(pid_file)
61
- elif args.action == 'run':
62
- return DaemonCommand._run(repo, args.debounce)
63
-
61
+ elif args.action == "run":
62
+ return DaemonCommand._run(repo, args.debounce, pid_file=pid_file, distill=args.distill)
63
+
64
64
  return 1
65
-
65
+
66
66
  @staticmethod
67
- def _start(repo, pid_file: Path, debounce: int) -> int:
67
+ def _start(repo, pid_file: Path, debounce: int, distill: bool = False) -> int:
68
68
  """Start daemon in background."""
69
69
  # Check if already running
70
70
  if pid_file.exists():
@@ -75,13 +75,13 @@ class DaemonCommand:
75
75
  return 1
76
76
  except OSError:
77
77
  pid_file.unlink()
78
-
78
+
79
79
  # Fork to background (Unix only)
80
- if os.name != 'posix':
80
+ if os.name != "posix":
81
81
  print("Background daemon not supported on this platform.")
82
82
  print("Use 'agmem daemon run' to run in foreground.")
83
83
  return 1
84
-
84
+
85
85
  # First fork
86
86
  try:
87
87
  pid = os.fork()
@@ -92,11 +92,11 @@ class DaemonCommand:
92
92
  except OSError as e:
93
93
  print(f"Fork failed: {e}")
94
94
  return 1
95
-
95
+
96
96
  # Decouple from parent
97
97
  os.setsid()
98
98
  os.umask(0)
99
-
99
+
100
100
  # Second fork
101
101
  try:
102
102
  pid = os.fork()
@@ -104,26 +104,26 @@ class DaemonCommand:
104
104
  sys.exit(0)
105
105
  except OSError:
106
106
  sys.exit(1)
107
-
107
+
108
108
  # Write PID file
109
109
  pid_file.write_text(str(os.getpid()))
110
-
110
+
111
111
  # Redirect stdio
112
112
  sys.stdout.flush()
113
113
  sys.stderr.flush()
114
-
115
- # Run daemon
116
- return DaemonCommand._run(repo, debounce, pid_file)
117
-
114
+
115
+ # Run daemon in background
116
+ return DaemonCommand._run(repo, debounce, pid_file, distill)
117
+
118
118
  @staticmethod
119
119
  def _stop(pid_file: Path) -> int:
120
120
  """Stop running daemon."""
121
121
  if not pid_file.exists():
122
122
  print("No daemon running")
123
123
  return 0
124
-
124
+
125
125
  pid = int(pid_file.read_text().strip())
126
-
126
+
127
127
  try:
128
128
  os.kill(pid, signal.SIGTERM)
129
129
  print(f"Stopped daemon (PID: {pid})")
@@ -133,16 +133,16 @@ class DaemonCommand:
133
133
  print(f"Could not stop daemon: {e}")
134
134
  pid_file.unlink()
135
135
  return 1
136
-
136
+
137
137
  @staticmethod
138
138
  def _status(pid_file: Path) -> int:
139
139
  """Show daemon status."""
140
140
  if not pid_file.exists():
141
141
  print("Daemon is not running")
142
142
  return 0
143
-
143
+
144
144
  pid = int(pid_file.read_text().strip())
145
-
145
+
146
146
  try:
147
147
  os.kill(pid, 0)
148
148
  print(f"Daemon is running (PID: {pid})")
@@ -151,9 +151,9 @@ class DaemonCommand:
151
151
  print("Daemon is not running (stale PID file)")
152
152
  pid_file.unlink()
153
153
  return 0
154
-
154
+
155
155
  @staticmethod
156
- def _run(repo, debounce: int, pid_file: Path = None) -> int:
156
+ def _run(repo, debounce: int, pid_file: Path = None, distill: bool = False) -> int:
157
157
  """Run daemon in foreground."""
158
158
  try:
159
159
  from watchdog.observers import Observer
@@ -161,84 +161,92 @@ class DaemonCommand:
161
161
  except ImportError:
162
162
  print("Daemon requires watchdog. Install with: pip install agmem[daemon]")
163
163
  return 1
164
-
165
- current_dir = repo.root / 'current'
166
-
164
+
165
+ current_dir = repo.root / "current"
166
+
167
167
  if not current_dir.exists():
168
168
  print("No current/ directory to watch")
169
169
  return 1
170
-
170
+
171
171
  class MemoryFileHandler(FileSystemEventHandler):
172
172
  def __init__(self):
173
173
  self.last_change = 0
174
174
  self.pending = False
175
-
175
+
176
176
  def on_any_event(self, event):
177
177
  # Ignore .mem and hidden files
178
- if '.mem' in event.src_path or '/.' in event.src_path:
178
+ if ".mem" in event.src_path or "/." in event.src_path:
179
179
  return
180
-
180
+
181
181
  # Ignore directories
182
182
  if event.is_directory:
183
183
  return
184
-
184
+
185
185
  self.last_change = time.time()
186
186
  self.pending = True
187
-
187
+
188
188
  handler = MemoryFileHandler()
189
189
  observer = Observer()
190
190
  observer.schedule(handler, str(current_dir), recursive=True)
191
191
  observer.start()
192
-
192
+
193
193
  print(f"Watching {current_dir} (debounce: {debounce}s)")
194
194
  print("Press Ctrl+C to stop")
195
-
195
+
196
196
  # Handle signals
197
197
  running = True
198
-
198
+
199
199
  def signal_handler(signum, frame):
200
200
  nonlocal running
201
201
  running = False
202
-
202
+
203
203
  signal.signal(signal.SIGTERM, signal_handler)
204
204
  signal.signal(signal.SIGINT, signal_handler)
205
-
205
+
206
206
  try:
207
207
  while running:
208
208
  time.sleep(1)
209
-
209
+
210
210
  if handler.pending:
211
211
  elapsed = time.time() - handler.last_change
212
212
  if elapsed >= debounce:
213
213
  # Auto-commit
214
214
  DaemonCommand._auto_commit(repo)
215
+ if distill:
216
+ try:
217
+ from ..core.distiller import Distiller, DistillerConfig
218
+
219
+ d = Distiller(repo, DistillerConfig(create_safety_branch=False))
220
+ d.run()
221
+ except Exception:
222
+ pass
215
223
  handler.pending = False
216
224
  finally:
217
225
  observer.stop()
218
226
  observer.join()
219
-
227
+
220
228
  if pid_file and pid_file.exists():
221
229
  pid_file.unlink()
222
-
230
+
223
231
  print("Daemon stopped")
224
232
  return 0
225
-
233
+
226
234
  @staticmethod
227
235
  def _auto_commit(repo):
228
236
  """Perform automatic commit."""
229
237
  from datetime import datetime
230
-
238
+
231
239
  try:
232
240
  # Check for changes
233
241
  status = repo.get_status()
234
-
235
- if not status.get('modified') and not status.get('untracked'):
242
+
243
+ if not status.get("modified") and not status.get("untracked"):
236
244
  return
237
-
245
+
238
246
  # Stage all changes in current/ (validate path stays under current/)
239
- current_dir = repo.root / 'current'
240
- for memory_file in current_dir.glob('**/*'):
241
- if memory_file.is_file() and '.mem' not in str(memory_file):
247
+ current_dir = repo.root / "current"
248
+ for memory_file in current_dir.glob("**/*"):
249
+ if memory_file.is_file() and ".mem" not in str(memory_file):
242
250
  try:
243
251
  rel_path = memory_file.relative_to(current_dir)
244
252
  except ValueError:
@@ -250,18 +258,15 @@ class DaemonCommand:
250
258
  repo.stage_file(rel_str)
251
259
  except Exception:
252
260
  pass
253
-
261
+
254
262
  # Commit
255
- timestamp = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
256
- repo.commit(
257
- f"auto: update memory state ({timestamp})",
258
- {'auto_commit': True}
259
- )
260
-
263
+ timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
264
+ repo.commit(f"auto: update memory state ({timestamp})", {"auto_commit": True})
265
+
261
266
  print(f"[{timestamp}] Auto-committed changes")
262
-
267
+
263
268
  except Exception as e:
264
269
  # Write conflict lock if there's an issue
265
- conflict_file = repo.root / '.mem' / 'CONFLICT.lock'
270
+ conflict_file = repo.root / ".mem" / "CONFLICT.lock"
266
271
  conflict_file.write_text(f"Auto-commit failed: {e}")
267
272
  print(f"Warning: Auto-commit failed, wrote CONFLICT.lock: {e}")
@@ -0,0 +1,77 @@
1
+ """
2
+ agmem decay - Memory decay and forgetting.
3
+
4
+ Archives low-importance, rarely-accessed memories.
5
+ """
6
+
7
+ import argparse
8
+ from pathlib import Path
9
+
10
+ from ..commands.base import require_repo
11
+ from ..core.decay import DecayEngine, DecayConfig
12
+
13
+
14
+ class DecayCommand:
15
+ """Memory decay - archive low-importance memories."""
16
+
17
+ name = "decay"
18
+ help = "Archive low-importance, old episodic memories (decay/forgetting)"
19
+
20
+ @staticmethod
21
+ def add_arguments(parser: argparse.ArgumentParser):
22
+ parser.add_argument(
23
+ "--dry-run",
24
+ action="store_true",
25
+ help="Preview what would be archived without making changes",
26
+ )
27
+ parser.add_argument(
28
+ "--apply",
29
+ action="store_true",
30
+ help="Actually archive the memories",
31
+ )
32
+
33
+ @staticmethod
34
+ def execute(args) -> int:
35
+ repo, code = require_repo()
36
+ if code != 0:
37
+ return code
38
+
39
+ config = DecayConfig()
40
+ repo_config = repo.get_config()
41
+ decay_cfg = repo_config.get("decay", {})
42
+ if decay_cfg:
43
+ config.episodic_half_life_days = decay_cfg.get(
44
+ "episodic_half_life_days", config.episodic_half_life_days
45
+ )
46
+ config.semantic_min_importance = decay_cfg.get(
47
+ "semantic_min_importance", config.semantic_min_importance
48
+ )
49
+ config.access_count_threshold = decay_cfg.get(
50
+ "access_count_threshold", config.access_count_threshold
51
+ )
52
+
53
+ engine = DecayEngine(repo, config)
54
+ candidates = engine.get_decay_candidates()
55
+
56
+ if not candidates:
57
+ print("No memories eligible for decay.")
58
+ return 0
59
+
60
+ print(f"Found {len(candidates)} memory(ies) eligible for decay:")
61
+ for c in candidates[:20]:
62
+ print(f" - {c.path} (score: {c.decay_score:.2f}) - {c.reason}")
63
+ if len(candidates) > 20:
64
+ print(f" ... and {len(candidates) - 20} more")
65
+
66
+ if args.dry_run:
67
+ print("\nDry run - no changes made. Use --apply to archive.")
68
+ return 0
69
+
70
+ if not args.apply:
71
+ print("\nUse --apply to archive these memories.")
72
+ return 0
73
+
74
+ count = engine.apply_decay(candidates)
75
+ print(f"\nArchived {count} memory(ies) to .mem/forgetting/")
76
+ print("Use 'agmem resurrect <path>' to restore.")
77
+ return 0
memvcs/commands/diff.py CHANGED
@@ -13,33 +13,35 @@ from ..core.repository import Repository
13
13
 
14
14
  class DiffCommand:
15
15
  """Show differences between commits."""
16
-
17
- name = 'diff'
18
- help = 'Show changes between commits, commit and working tree, etc.'
19
-
16
+
17
+ name = "diff"
18
+ help = "Show changes between commits, commit and working tree, etc."
19
+
20
20
  @staticmethod
21
21
  def add_arguments(parser: argparse.ArgumentParser):
22
+ parser.add_argument("ref1", nargs="?", help="First commit/branch to compare")
23
+ parser.add_argument("ref2", nargs="?", help="Second commit/branch to compare")
22
24
  parser.add_argument(
23
- 'ref1',
24
- nargs='?',
25
- help='First commit/branch to compare'
25
+ "--cached", "--staged", action="store_true", help="Show changes staged for commit"
26
26
  )
27
27
  parser.add_argument(
28
- 'ref2',
29
- nargs='?',
30
- help='Second commit/branch to compare'
28
+ "--stat", action="store_true", help="Show diffstat instead of full diff"
31
29
  )
32
30
  parser.add_argument(
33
- '--cached', '--staged',
34
- action='store_true',
35
- help='Show changes staged for commit'
31
+ "--from",
32
+ "--from-ref",
33
+ dest="from_ref",
34
+ metavar="REF",
35
+ help="Start ref for diff (supports ISO dates)",
36
36
  )
37
37
  parser.add_argument(
38
- '--stat',
39
- action='store_true',
40
- help='Show diffstat instead of full diff'
38
+ "--to",
39
+ "--to-ref",
40
+ dest="to_ref",
41
+ metavar="REF",
42
+ help="End ref for diff (supports ISO dates)",
41
43
  )
42
-
44
+
43
45
  @staticmethod
44
46
  def execute(args) -> int:
45
47
  repo, code = require_repo()
@@ -47,7 +49,7 @@ class DiffCommand:
47
49
  return code
48
50
 
49
51
  engine = DiffEngine(repo.object_store)
50
-
52
+
51
53
  # Determine what to diff
52
54
  if args.cached:
53
55
  # Diff staged changes against HEAD
@@ -55,103 +57,100 @@ class DiffCommand:
55
57
  if not head_commit:
56
58
  print("No commits yet. Nothing to diff.")
57
59
  return 0
58
-
60
+
59
61
  staged_files = repo.staging.get_staged_files()
60
62
  if not staged_files:
61
63
  print("No staged changes.")
62
64
  return 0
63
-
65
+
64
66
  # Get staged content
65
67
  working_files = {}
66
68
  for path, sf in staged_files.items():
67
69
  from ..core.objects import Blob
70
+
68
71
  blob = Blob.load(repo.object_store, sf.blob_hash)
69
72
  if blob:
70
73
  working_files[path] = blob.content
71
-
72
- tree_diff = engine.diff_working_dir(
73
- head_commit.store(repo.object_store),
74
- working_files
75
- )
76
-
77
- print(engine.format_diff(tree_diff, 'HEAD', 'staged'))
74
+
75
+ tree_diff = engine.diff_working_dir(head_commit.store(repo.object_store), working_files)
76
+
77
+ print(engine.format_diff(tree_diff, "HEAD", "staged"))
78
78
  return 0
79
-
80
- # Diff between two refs
81
- if args.ref1 and args.ref2:
82
- commit1 = repo.resolve_ref(args.ref1)
83
- commit2 = repo.resolve_ref(args.ref2)
84
-
79
+
80
+ # Diff between two refs (--from/--to override ref1/ref2)
81
+ ref1 = getattr(args, "from_ref", None) or args.ref1
82
+ ref2 = getattr(args, "to_ref", None) or args.ref2
83
+ if ref1 and ref2:
84
+ commit1 = repo.resolve_ref(ref1)
85
+ commit2 = repo.resolve_ref(ref2)
86
+
85
87
  if not commit1:
86
- print(f"Error: Unknown revision: {args.ref1}")
88
+ print(f"Error: Unknown revision: {ref1}")
87
89
  return 1
88
90
  if not commit2:
89
- print(f"Error: Unknown revision: {args.ref2}")
91
+ print(f"Error: Unknown revision: {ref2}")
90
92
  return 1
91
-
93
+
92
94
  tree_diff = engine.diff_commits(commit1, commit2)
93
-
95
+
94
96
  if args.stat:
95
97
  print(f" {tree_diff.added_count} file(s) added")
96
98
  print(f" {tree_diff.deleted_count} file(s) deleted")
97
99
  print(f" {tree_diff.modified_count} file(s) modified")
98
100
  else:
99
- print(engine.format_diff(tree_diff, args.ref1, args.ref2))
100
-
101
+ print(engine.format_diff(tree_diff, ref1, ref2))
102
+
101
103
  return 0
102
-
104
+
103
105
  # Diff working tree against a ref
104
106
  if args.ref1:
105
107
  commit_hash = repo.resolve_ref(args.ref1)
106
108
  if not commit_hash:
107
109
  print(f"Error: Unknown revision: {args.ref1}")
108
110
  return 1
109
-
111
+
110
112
  # Get working directory files
111
113
  working_files = {}
112
114
  for root, dirs, files in os.walk(repo.current_dir):
113
- dirs[:] = [d for d in dirs if not d.startswith('.')]
115
+ dirs[:] = [d for d in dirs if not d.startswith(".")]
114
116
  for filename in files:
115
117
  full_path = Path(root) / filename
116
118
  rel_path = str(full_path.relative_to(repo.current_dir))
117
119
  working_files[rel_path] = full_path.read_bytes()
118
-
120
+
119
121
  tree_diff = engine.diff_working_dir(commit_hash, working_files)
120
-
122
+
121
123
  if args.stat:
122
124
  print(f" {tree_diff.added_count} file(s) added")
123
125
  print(f" {tree_diff.deleted_count} file(s) deleted")
124
126
  print(f" {tree_diff.modified_count} file(s) modified")
125
127
  else:
126
- print(engine.format_diff(tree_diff, args.ref1, 'working'))
127
-
128
+ print(engine.format_diff(tree_diff, args.ref1, "working"))
129
+
128
130
  return 0
129
-
131
+
130
132
  # Default: diff working tree against HEAD
131
133
  head_commit = repo.get_head_commit()
132
134
  if not head_commit:
133
135
  print("No commits yet. Nothing to diff.")
134
136
  return 0
135
-
137
+
136
138
  # Get working directory files
137
139
  working_files = {}
138
140
  for root, dirs, files in os.walk(repo.current_dir):
139
- dirs[:] = [d for d in dirs if not d.startswith('.')]
141
+ dirs[:] = [d for d in dirs if not d.startswith(".")]
140
142
  for filename in files:
141
143
  full_path = Path(root) / filename
142
144
  rel_path = str(full_path.relative_to(repo.current_dir))
143
145
  working_files[rel_path] = full_path.read_bytes()
144
-
145
- tree_diff = engine.diff_working_dir(
146
- head_commit.store(repo.object_store),
147
- working_files
148
- )
149
-
146
+
147
+ tree_diff = engine.diff_working_dir(head_commit.store(repo.object_store), working_files)
148
+
150
149
  if args.stat:
151
150
  print(f" {tree_diff.added_count} file(s) added")
152
151
  print(f" {tree_diff.deleted_count} file(s) deleted")
153
152
  print(f" {tree_diff.modified_count} file(s) modified")
154
153
  else:
155
- print(engine.format_diff(tree_diff, 'HEAD', 'working'))
156
-
154
+ print(engine.format_diff(tree_diff, "HEAD", "working"))
155
+
157
156
  return 0
@@ -0,0 +1,74 @@
1
+ """
2
+ agmem distill - Episodic-to-semantic distillation pipeline.
3
+
4
+ Converts session logs into compact facts (memory consolidation).
5
+ """
6
+
7
+ import argparse
8
+ from pathlib import Path
9
+
10
+ from ..commands.base import require_repo
11
+ from ..core.distiller import Distiller, DistillerConfig, DistillerResult
12
+
13
+
14
+ class DistillCommand:
15
+ """Episodic-to-semantic distillation."""
16
+
17
+ name = "distill"
18
+ help = "Convert episodic logs into semantic facts (memory consolidation)"
19
+
20
+ @staticmethod
21
+ def add_arguments(parser: argparse.ArgumentParser):
22
+ parser.add_argument(
23
+ "--source",
24
+ "-s",
25
+ default="episodic",
26
+ help="Source directory (default: episodic)",
27
+ )
28
+ parser.add_argument(
29
+ "--target",
30
+ "-t",
31
+ default="semantic/consolidated",
32
+ help="Target directory (default: semantic/consolidated)",
33
+ )
34
+ parser.add_argument(
35
+ "--model",
36
+ "-m",
37
+ help="LLM model for extraction (e.g., gpt-4)",
38
+ )
39
+ parser.add_argument(
40
+ "--no-branch",
41
+ action="store_true",
42
+ help="Do not create safety branch",
43
+ )
44
+
45
+ @staticmethod
46
+ def execute(args) -> int:
47
+ repo, code = require_repo()
48
+ if code != 0:
49
+ return code
50
+
51
+ config = DistillerConfig(
52
+ source_dir=args.source,
53
+ target_dir=args.target,
54
+ create_safety_branch=not args.no_branch,
55
+ )
56
+ distiller = Distiller(repo, config)
57
+
58
+ result = distiller.run(
59
+ source=args.source,
60
+ target=args.target,
61
+ model=args.model,
62
+ )
63
+
64
+ print(f"Distiller completed:")
65
+ print(f" Clusters processed: {result.clusters_processed}")
66
+ print(f" Facts extracted: {result.facts_extracted}")
67
+ print(f" Episodes archived: {result.episodes_archived}")
68
+ if result.branch_created:
69
+ print(f" Branch created: {result.branch_created}")
70
+ if result.commit_hash:
71
+ print(f" Commit: {result.commit_hash[:8]}")
72
+ print(f"\n{result.message}")
73
+
74
+ return 0 if result.success else 1