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
@@ -13,6 +13,7 @@ from datetime import datetime
13
13
  try:
14
14
  from google.cloud import storage
15
15
  from google.cloud.exceptions import NotFound
16
+
16
17
  GCS_AVAILABLE = True
17
18
  except ImportError:
18
19
  GCS_AVAILABLE = False
@@ -26,6 +27,7 @@ def _apply_gcs_config(kwargs: Dict[str, Any], config: Optional[Dict[str, Any]])
26
27
  return
27
28
  try:
28
29
  from memvcs.core.config_loader import get_gcs_options_from_config
30
+
29
31
  opts = get_gcs_options_from_config(config)
30
32
  for key in ("project", "credentials_path", "credentials_info"):
31
33
  if opts.get(key) is not None:
@@ -36,18 +38,18 @@ def _apply_gcs_config(kwargs: Dict[str, Any], config: Optional[Dict[str, Any]])
36
38
 
37
39
  class GCSStorageAdapter(StorageAdapter):
38
40
  """Storage adapter for Google Cloud Storage."""
39
-
41
+
40
42
  def __init__(
41
43
  self,
42
44
  bucket: str,
43
45
  prefix: str = "",
44
46
  project: Optional[str] = None,
45
47
  credentials_path: Optional[str] = None,
46
- credentials_info: Optional[Dict[str, Any]] = None
48
+ credentials_info: Optional[Dict[str, Any]] = None,
47
49
  ):
48
50
  """
49
51
  Initialize GCS storage adapter.
50
-
52
+
51
53
  Args:
52
54
  bucket: GCS bucket name
53
55
  prefix: Key prefix for all operations
@@ -60,11 +62,11 @@ class GCSStorageAdapter(StorageAdapter):
60
62
  "google-cloud-storage is required for GCS. "
61
63
  "Install with: pip install agmem[cloud]"
62
64
  )
63
-
65
+
64
66
  self.bucket_name = bucket
65
- self.prefix = prefix.strip('/')
67
+ self.prefix = prefix.strip("/")
66
68
  self._lock_id = str(uuid.uuid4())
67
-
69
+
68
70
  # Build client: info dict > path > project > default
69
71
  if credentials_info:
70
72
  self.client = storage.Client.from_service_account_info(credentials_info)
@@ -74,32 +76,32 @@ class GCSStorageAdapter(StorageAdapter):
74
76
  self.client = storage.Client(project=project)
75
77
  else:
76
78
  self.client = storage.Client()
77
-
79
+
78
80
  self.bucket = self.client.bucket(bucket)
79
-
81
+
80
82
  @classmethod
81
- def from_url(cls, url: str, config: Optional[Dict[str, Any]] = None) -> 'GCSStorageAdapter':
83
+ def from_url(cls, url: str, config: Optional[Dict[str, Any]] = None) -> "GCSStorageAdapter":
82
84
  """
83
85
  Create adapter from GCS URL. Optional config supplies project,
84
86
  credentials_path (validated), or credentials_info from env JSON.
85
-
87
+
86
88
  Args:
87
89
  url: GCS URL (gs://bucket/prefix)
88
90
  config: Optional agmem config dict (cloud.gcs)
89
-
91
+
90
92
  Returns:
91
93
  GCSStorageAdapter instance
92
94
  """
93
- if not url.startswith('gs://'):
95
+ if not url.startswith("gs://"):
94
96
  raise ValueError(f"Invalid GCS URL: {url}")
95
97
  path = url[5:] # Remove 'gs://'
96
- parts = path.split('/', 1)
98
+ parts = path.split("/", 1)
97
99
  bucket = parts[0]
98
100
  prefix = parts[1] if len(parts) > 1 else ""
99
101
  kwargs: Dict[str, Any] = {"bucket": bucket, "prefix": prefix}
100
102
  _apply_gcs_config(kwargs, config)
101
103
  return cls(**kwargs)
102
-
104
+
103
105
  def _key(self, path: str) -> str:
104
106
  """Convert relative path to GCS key."""
105
107
  if not path:
@@ -107,53 +109,53 @@ class GCSStorageAdapter(StorageAdapter):
107
109
  if self.prefix:
108
110
  return f"{self.prefix}/{path}"
109
111
  return path
110
-
112
+
111
113
  def _path(self, key: str) -> str:
112
114
  """Convert GCS key to relative path."""
113
- if self.prefix and key.startswith(self.prefix + '/'):
114
- return key[len(self.prefix) + 1:]
115
+ if self.prefix and key.startswith(self.prefix + "/"):
116
+ return key[len(self.prefix) + 1 :]
115
117
  return key
116
-
118
+
117
119
  def read_file(self, path: str) -> bytes:
118
120
  """Read a file's contents from GCS."""
119
121
  key = self._key(path)
120
122
  blob = self.bucket.blob(key)
121
-
123
+
122
124
  try:
123
125
  return blob.download_as_bytes()
124
126
  except NotFound:
125
127
  raise StorageError(f"File not found: {path}")
126
128
  except Exception as e:
127
129
  raise StorageError(f"Error reading {path}: {e}")
128
-
130
+
129
131
  def write_file(self, path: str, data: bytes) -> None:
130
132
  """Write data to GCS."""
131
133
  key = self._key(path)
132
134
  blob = self.bucket.blob(key)
133
-
135
+
134
136
  try:
135
137
  blob.upload_from_string(data)
136
138
  except Exception as e:
137
139
  raise StorageError(f"Error writing {path}: {e}")
138
-
140
+
139
141
  def exists(self, path: str) -> bool:
140
142
  """Check if a key exists in GCS."""
141
143
  key = self._key(path)
142
144
  blob = self.bucket.blob(key)
143
-
145
+
144
146
  if blob.exists():
145
147
  return True
146
-
148
+
147
149
  # Check if it's a "directory"
148
- prefix = key + '/' if key else ''
150
+ prefix = key + "/" if key else ""
149
151
  blobs = list(self.bucket.list_blobs(prefix=prefix, max_results=1))
150
152
  return len(blobs) > 0
151
-
153
+
152
154
  def delete(self, path: str) -> bool:
153
155
  """Delete an object from GCS."""
154
156
  key = self._key(path)
155
157
  blob = self.bucket.blob(key)
156
-
158
+
157
159
  try:
158
160
  blob.delete()
159
161
  return True
@@ -161,80 +163,80 @@ class GCSStorageAdapter(StorageAdapter):
161
163
  return False
162
164
  except Exception as e:
163
165
  raise StorageError(f"Error deleting {path}: {e}")
164
-
166
+
165
167
  def list_dir(self, path: str = "") -> List[FileInfo]:
166
168
  """List contents of a "directory" in GCS."""
167
169
  prefix = self._key(path)
168
- if prefix and not prefix.endswith('/'):
169
- prefix += '/'
170
-
170
+ if prefix and not prefix.endswith("/"):
171
+ prefix += "/"
172
+
171
173
  result = []
172
174
  seen_dirs = set()
173
-
175
+
174
176
  try:
175
177
  # List with delimiter to get "directories"
176
- blobs = self.bucket.list_blobs(prefix=prefix, delimiter='/')
177
-
178
+ blobs = self.bucket.list_blobs(prefix=prefix, delimiter="/")
179
+
178
180
  # Process blobs (files)
179
181
  for blob in blobs:
180
182
  if blob.name == prefix:
181
183
  continue
182
-
183
- result.append(FileInfo(
184
- path=self._path(blob.name),
185
- size=blob.size or 0,
186
- modified=blob.updated.isoformat() if blob.updated else None,
187
- is_dir=False
188
- ))
189
-
184
+
185
+ result.append(
186
+ FileInfo(
187
+ path=self._path(blob.name),
188
+ size=blob.size or 0,
189
+ modified=blob.updated.isoformat() if blob.updated else None,
190
+ is_dir=False,
191
+ )
192
+ )
193
+
190
194
  # Process prefixes (directories)
191
195
  for dir_prefix in blobs.prefixes:
192
- dir_name = dir_prefix.rstrip('/').split('/')[-1]
196
+ dir_name = dir_prefix.rstrip("/").split("/")[-1]
193
197
  if dir_name not in seen_dirs:
194
198
  seen_dirs.add(dir_name)
195
- result.append(FileInfo(
196
- path=self._path(dir_prefix.rstrip('/')),
197
- size=0,
198
- is_dir=True
199
- ))
200
-
199
+ result.append(
200
+ FileInfo(path=self._path(dir_prefix.rstrip("/")), size=0, is_dir=True)
201
+ )
202
+
201
203
  except Exception as e:
202
204
  raise StorageError(f"Error listing {path}: {e}")
203
-
205
+
204
206
  return result
205
-
207
+
206
208
  def makedirs(self, path: str) -> None:
207
209
  """Create a "directory" in GCS (no-op, directories are implicit)."""
208
210
  pass
209
-
211
+
210
212
  def is_dir(self, path: str) -> bool:
211
213
  """Check if path is a "directory" in GCS."""
212
214
  key = self._key(path)
213
215
  if not key:
214
216
  return True # Root is always a directory
215
-
217
+
216
218
  # Check if there are any keys with this prefix
217
- prefix = key + '/'
219
+ prefix = key + "/"
218
220
  blobs = list(self.bucket.list_blobs(prefix=prefix, max_results=1))
219
221
  return len(blobs) > 0
220
-
222
+
221
223
  def acquire_lock(self, lock_name: str, timeout: int = 30) -> bool:
222
224
  """
223
225
  Acquire a distributed lock using GCS.
224
-
226
+
225
227
  Uses generation-based conditional updates for lock safety.
226
228
  """
227
229
  start_time = time.time()
228
230
  lock_key = self._key(f".locks/{lock_name}.lock")
229
231
  blob = self.bucket.blob(lock_key)
230
-
232
+
231
233
  while True:
232
234
  try:
233
235
  # Check if lock exists and is not stale
234
236
  if blob.exists():
235
237
  blob.reload()
236
238
  existing = blob.download_as_string().decode()
237
- parts = existing.split(':')
239
+ parts = existing.split(":")
238
240
  if len(parts) == 2:
239
241
  _, ts = parts
240
242
  if int(time.time()) - int(ts) < 300: # Lock is fresh
@@ -244,23 +246,23 @@ class GCSStorageAdapter(StorageAdapter):
244
246
  )
245
247
  time.sleep(0.5)
246
248
  continue
247
-
249
+
248
250
  # Create/overwrite lock
249
251
  lock_data = f"{self._lock_id}:{int(time.time())}"
250
252
  blob.upload_from_string(lock_data)
251
-
253
+
252
254
  # Verify we own the lock
253
255
  time.sleep(0.1)
254
256
  blob.reload()
255
257
  content = blob.download_as_string().decode()
256
258
  if content.startswith(self._lock_id):
257
259
  return True
258
-
260
+
259
261
  # Someone else got it
260
262
  if time.time() - start_time >= timeout:
261
263
  raise LockError(f"Could not acquire lock '{lock_name}' within {timeout}s")
262
264
  time.sleep(0.5)
263
-
265
+
264
266
  except NotFound:
265
267
  # Lock doesn't exist, try to create it
266
268
  try:
@@ -273,12 +275,12 @@ class GCSStorageAdapter(StorageAdapter):
273
275
  time.sleep(0.5)
274
276
  except Exception as e:
275
277
  raise StorageError(f"Error acquiring lock: {e}")
276
-
278
+
277
279
  def release_lock(self, lock_name: str) -> None:
278
280
  """Release a distributed lock."""
279
281
  lock_key = self._key(f".locks/{lock_name}.lock")
280
282
  blob = self.bucket.blob(lock_key)
281
-
283
+
282
284
  try:
283
285
  # Only delete if we own the lock
284
286
  if blob.exists():
@@ -287,18 +289,18 @@ class GCSStorageAdapter(StorageAdapter):
287
289
  blob.delete()
288
290
  except Exception:
289
291
  pass # Ignore errors on release
290
-
292
+
291
293
  def is_locked(self, lock_name: str) -> bool:
292
294
  """Check if a lock is currently held."""
293
295
  lock_key = self._key(f".locks/{lock_name}.lock")
294
296
  blob = self.bucket.blob(lock_key)
295
-
297
+
296
298
  try:
297
299
  if not blob.exists():
298
300
  return False
299
-
301
+
300
302
  content = blob.download_as_string().decode()
301
- parts = content.split(':')
303
+ parts = content.split(":")
302
304
  if len(parts) == 2:
303
305
  _, ts = parts
304
306
  # Lock is valid if less than 5 minutes old
@@ -14,17 +14,17 @@ from .base import StorageAdapter, StorageError, LockError, FileInfo
14
14
 
15
15
  class LocalStorageAdapter(StorageAdapter):
16
16
  """Storage adapter for local filesystem."""
17
-
17
+
18
18
  def __init__(self, root_path: str):
19
19
  """
20
20
  Initialize local storage adapter.
21
-
21
+
22
22
  Args:
23
23
  root_path: Root directory for storage
24
24
  """
25
25
  self.root = Path(root_path).resolve()
26
26
  self._locks: dict = {} # Active lock file handles
27
-
27
+
28
28
  def _resolve_path(self, path: str) -> Path:
29
29
  """Resolve a relative path to absolute path within root."""
30
30
  if not path:
@@ -34,7 +34,7 @@ class LocalStorageAdapter(StorageAdapter):
34
34
  if not str(resolved).startswith(str(self.root)):
35
35
  raise StorageError(f"Path '{path}' is outside storage root")
36
36
  return resolved
37
-
37
+
38
38
  def read_file(self, path: str) -> bytes:
39
39
  """Read a file's contents."""
40
40
  resolved = self._resolve_path(path)
@@ -44,7 +44,7 @@ class LocalStorageAdapter(StorageAdapter):
44
44
  raise StorageError(f"File not found: {path}")
45
45
  except IOError as e:
46
46
  raise StorageError(f"Error reading file {path}: {e}")
47
-
47
+
48
48
  def write_file(self, path: str, data: bytes) -> None:
49
49
  """Write data to a file."""
50
50
  resolved = self._resolve_path(path)
@@ -53,12 +53,12 @@ class LocalStorageAdapter(StorageAdapter):
53
53
  resolved.write_bytes(data)
54
54
  except IOError as e:
55
55
  raise StorageError(f"Error writing file {path}: {e}")
56
-
56
+
57
57
  def exists(self, path: str) -> bool:
58
58
  """Check if a path exists."""
59
59
  resolved = self._resolve_path(path)
60
60
  return resolved.exists()
61
-
61
+
62
62
  def delete(self, path: str) -> bool:
63
63
  """Delete a file."""
64
64
  resolved = self._resolve_path(path)
@@ -72,78 +72,80 @@ class LocalStorageAdapter(StorageAdapter):
72
72
  return False
73
73
  except IOError as e:
74
74
  raise StorageError(f"Error deleting {path}: {e}")
75
-
75
+
76
76
  def list_dir(self, path: str = "") -> List[FileInfo]:
77
77
  """List contents of a directory."""
78
78
  resolved = self._resolve_path(path)
79
-
79
+
80
80
  if not resolved.exists():
81
81
  return []
82
-
82
+
83
83
  if not resolved.is_dir():
84
84
  raise StorageError(f"Not a directory: {path}")
85
-
85
+
86
86
  result = []
87
87
  for item in resolved.iterdir():
88
88
  try:
89
89
  stat = item.stat()
90
90
  rel_path = str(item.relative_to(self.root))
91
-
92
- result.append(FileInfo(
93
- path=rel_path,
94
- size=stat.st_size if not item.is_dir() else 0,
95
- modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
96
- is_dir=item.is_dir()
97
- ))
91
+
92
+ result.append(
93
+ FileInfo(
94
+ path=rel_path,
95
+ size=stat.st_size if not item.is_dir() else 0,
96
+ modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
97
+ is_dir=item.is_dir(),
98
+ )
99
+ )
98
100
  except IOError:
99
101
  # Skip files we can't stat
100
102
  continue
101
-
103
+
102
104
  return result
103
-
105
+
104
106
  def makedirs(self, path: str) -> None:
105
107
  """Create directory and any necessary parent directories."""
106
108
  resolved = self._resolve_path(path)
107
109
  resolved.mkdir(parents=True, exist_ok=True)
108
-
110
+
109
111
  def is_dir(self, path: str) -> bool:
110
112
  """Check if path is a directory."""
111
113
  resolved = self._resolve_path(path)
112
114
  return resolved.is_dir()
113
-
115
+
114
116
  def acquire_lock(self, lock_name: str, timeout: int = 30) -> bool:
115
117
  """
116
118
  Acquire a file-based lock.
117
-
119
+
118
120
  Uses fcntl for POSIX systems.
119
121
  """
120
- lock_path = self.root / '.locks' / f'{lock_name}.lock'
122
+ lock_path = self.root / ".locks" / f"{lock_name}.lock"
121
123
  lock_path.parent.mkdir(parents=True, exist_ok=True)
122
-
124
+
123
125
  start_time = time.time()
124
-
126
+
125
127
  while True:
126
128
  try:
127
129
  # Open or create lock file
128
- lock_file = open(lock_path, 'w')
129
-
130
+ lock_file = open(lock_path, "w")
131
+
130
132
  # Try to acquire exclusive lock
131
133
  fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
132
-
134
+
133
135
  # Write our PID to the lock file
134
136
  lock_file.write(str(os.getpid()))
135
137
  lock_file.flush()
136
-
138
+
137
139
  # Keep handle open to maintain lock
138
140
  self._locks[lock_name] = lock_file
139
141
  return True
140
-
142
+
141
143
  except (IOError, OSError):
142
144
  # Lock is held by another process
143
145
  if time.time() - start_time >= timeout:
144
146
  raise LockError(f"Could not acquire lock '{lock_name}' within {timeout}s")
145
147
  time.sleep(0.1)
146
-
148
+
147
149
  def release_lock(self, lock_name: str) -> None:
148
150
  """Release a file-based lock."""
149
151
  if lock_name in self._locks:
@@ -153,30 +155,30 @@ class LocalStorageAdapter(StorageAdapter):
153
155
  lock_file.close()
154
156
  except (IOError, OSError):
155
157
  pass
156
-
158
+
157
159
  # Try to remove lock file
158
- lock_path = self.root / '.locks' / f'{lock_name}.lock'
160
+ lock_path = self.root / ".locks" / f"{lock_name}.lock"
159
161
  try:
160
162
  lock_path.unlink()
161
163
  except (IOError, OSError):
162
164
  pass
163
-
165
+
164
166
  def is_locked(self, lock_name: str) -> bool:
165
167
  """Check if a lock is currently held."""
166
- lock_path = self.root / '.locks' / f'{lock_name}.lock'
167
-
168
+ lock_path = self.root / ".locks" / f"{lock_name}.lock"
169
+
168
170
  if not lock_path.exists():
169
171
  return False
170
-
172
+
171
173
  try:
172
174
  # Try to acquire lock briefly
173
- with open(lock_path, 'w') as f:
175
+ with open(lock_path, "w") as f:
174
176
  fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
175
177
  fcntl.flock(f.fileno(), fcntl.LOCK_UN)
176
178
  return False # Lock is free
177
179
  except (IOError, OSError):
178
180
  return True # Lock is held
179
-
181
+
180
182
  def get_root(self) -> Path:
181
183
  """Get the root path of this storage."""
182
184
  return self.root