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.
- {agmem-0.1.1.dist-info → agmem-0.1.3.dist-info}/METADATA +157 -16
- agmem-0.1.3.dist-info/RECORD +105 -0
- memvcs/__init__.py +1 -1
- memvcs/cli.py +45 -31
- memvcs/commands/__init__.py +9 -9
- memvcs/commands/add.py +83 -76
- memvcs/commands/audit.py +59 -0
- memvcs/commands/blame.py +46 -53
- memvcs/commands/branch.py +13 -33
- memvcs/commands/checkout.py +27 -32
- memvcs/commands/clean.py +18 -23
- memvcs/commands/clone.py +11 -1
- memvcs/commands/commit.py +40 -39
- memvcs/commands/daemon.py +109 -76
- memvcs/commands/decay.py +77 -0
- memvcs/commands/diff.py +56 -57
- memvcs/commands/distill.py +90 -0
- memvcs/commands/federated.py +53 -0
- memvcs/commands/fsck.py +86 -61
- memvcs/commands/garden.py +40 -35
- memvcs/commands/gc.py +51 -0
- memvcs/commands/graph.py +41 -48
- memvcs/commands/init.py +16 -24
- memvcs/commands/log.py +25 -40
- memvcs/commands/merge.py +69 -27
- memvcs/commands/pack.py +129 -0
- memvcs/commands/prove.py +66 -0
- memvcs/commands/pull.py +31 -1
- memvcs/commands/push.py +4 -2
- memvcs/commands/recall.py +145 -0
- memvcs/commands/reflog.py +13 -22
- memvcs/commands/remote.py +1 -0
- memvcs/commands/repair.py +66 -0
- memvcs/commands/reset.py +23 -33
- memvcs/commands/resolve.py +130 -0
- memvcs/commands/resurrect.py +82 -0
- memvcs/commands/search.py +3 -4
- memvcs/commands/serve.py +2 -1
- memvcs/commands/show.py +66 -36
- memvcs/commands/stash.py +34 -34
- memvcs/commands/status.py +27 -35
- memvcs/commands/tag.py +23 -47
- memvcs/commands/test.py +30 -44
- memvcs/commands/timeline.py +111 -0
- memvcs/commands/tree.py +26 -27
- memvcs/commands/verify.py +110 -0
- memvcs/commands/when.py +115 -0
- memvcs/core/access_index.py +167 -0
- memvcs/core/audit.py +124 -0
- memvcs/core/config_loader.py +3 -1
- memvcs/core/consistency.py +214 -0
- memvcs/core/crypto_verify.py +280 -0
- memvcs/core/decay.py +185 -0
- memvcs/core/diff.py +158 -143
- memvcs/core/distiller.py +277 -0
- memvcs/core/encryption.py +169 -0
- memvcs/core/federated.py +86 -0
- memvcs/core/gardener.py +176 -145
- memvcs/core/hooks.py +48 -14
- memvcs/core/ipfs_remote.py +39 -0
- memvcs/core/knowledge_graph.py +135 -138
- memvcs/core/llm/__init__.py +10 -0
- memvcs/core/llm/anthropic_provider.py +50 -0
- memvcs/core/llm/base.py +27 -0
- memvcs/core/llm/factory.py +30 -0
- memvcs/core/llm/openai_provider.py +36 -0
- memvcs/core/merge.py +260 -170
- memvcs/core/objects.py +110 -101
- memvcs/core/pack.py +92 -0
- memvcs/core/pii_scanner.py +147 -146
- memvcs/core/privacy_budget.py +63 -0
- memvcs/core/refs.py +132 -115
- memvcs/core/remote.py +38 -0
- memvcs/core/repository.py +254 -164
- memvcs/core/schema.py +155 -113
- memvcs/core/staging.py +60 -65
- memvcs/core/storage/__init__.py +20 -18
- memvcs/core/storage/base.py +74 -70
- memvcs/core/storage/gcs.py +70 -68
- memvcs/core/storage/local.py +42 -40
- memvcs/core/storage/s3.py +105 -110
- memvcs/core/temporal_index.py +121 -0
- memvcs/core/test_runner.py +101 -93
- memvcs/core/trust.py +103 -0
- memvcs/core/vector_store.py +56 -36
- memvcs/core/zk_proofs.py +26 -0
- memvcs/integrations/mcp_server.py +1 -3
- memvcs/integrations/web_ui/server.py +25 -26
- memvcs/retrieval/__init__.py +22 -0
- memvcs/retrieval/base.py +54 -0
- memvcs/retrieval/pack.py +128 -0
- memvcs/retrieval/recaller.py +105 -0
- memvcs/retrieval/strategies.py +314 -0
- memvcs/utils/__init__.py +3 -3
- memvcs/utils/helpers.py +52 -52
- agmem-0.1.1.dist-info/RECORD +0 -67
- {agmem-0.1.1.dist-info → agmem-0.1.3.dist-info}/WHEEL +0 -0
- {agmem-0.1.1.dist-info → agmem-0.1.3.dist-info}/entry_points.txt +0 -0
- {agmem-0.1.1.dist-info → agmem-0.1.3.dist-info}/licenses/LICENSE +0 -0
- {agmem-0.1.1.dist-info → agmem-0.1.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cryptographic commit verification for agmem.
|
|
3
|
+
|
|
4
|
+
Merkle tree over commit blobs, optional Ed25519 signing of Merkle root.
|
|
5
|
+
Verification on checkout, pull, and via verify/fsck.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional, List, Tuple, Any, Dict
|
|
14
|
+
|
|
15
|
+
from .objects import ObjectStore, Tree, Commit
|
|
16
|
+
|
|
17
|
+
# Ed25519 via cryptography (optional)
|
|
18
|
+
try:
|
|
19
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
20
|
+
Ed25519PrivateKey,
|
|
21
|
+
Ed25519PublicKey,
|
|
22
|
+
)
|
|
23
|
+
from cryptography.exceptions import InvalidSignature
|
|
24
|
+
from cryptography.hazmat.primitives import serialization
|
|
25
|
+
|
|
26
|
+
ED25519_AVAILABLE = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
ED25519_AVAILABLE = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _collect_blob_hashes_from_tree(store: ObjectStore, tree_hash: str) -> List[str]:
|
|
32
|
+
"""Recursively collect all blob hashes from a tree. Returns sorted list for deterministic Merkle."""
|
|
33
|
+
tree = Tree.load(store, tree_hash)
|
|
34
|
+
if not tree:
|
|
35
|
+
return []
|
|
36
|
+
blobs: List[str] = []
|
|
37
|
+
for entry in tree.entries:
|
|
38
|
+
if entry.obj_type == "blob":
|
|
39
|
+
blobs.append(entry.hash)
|
|
40
|
+
elif entry.obj_type == "tree":
|
|
41
|
+
blobs.extend(_collect_blob_hashes_from_tree(store, entry.hash))
|
|
42
|
+
return sorted(blobs)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _merkle_hash(data: bytes) -> str:
|
|
46
|
+
"""SHA-256 hash for Merkle tree nodes."""
|
|
47
|
+
return hashlib.sha256(data).hexdigest()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_merkle_tree(blob_hashes: List[str]) -> str:
|
|
51
|
+
"""
|
|
52
|
+
Build balanced binary Merkle tree from blob hashes.
|
|
53
|
+
Leaves are hashes of blob hashes (as hex strings); internal nodes hash(left_hex || right_hex).
|
|
54
|
+
Returns root hash (hex).
|
|
55
|
+
"""
|
|
56
|
+
if not blob_hashes:
|
|
57
|
+
return _merkle_hash(b"empty")
|
|
58
|
+
# Leaves: hash each blob hash string to fixed-size leaf
|
|
59
|
+
layer = [_merkle_hash(h.encode()) for h in blob_hashes]
|
|
60
|
+
while len(layer) > 1:
|
|
61
|
+
next_layer = []
|
|
62
|
+
for i in range(0, len(layer), 2):
|
|
63
|
+
left = layer[i]
|
|
64
|
+
right = layer[i + 1] if i + 1 < len(layer) else layer[i]
|
|
65
|
+
combined = (left + right).encode()
|
|
66
|
+
next_layer.append(_merkle_hash(combined))
|
|
67
|
+
layer = next_layer
|
|
68
|
+
return layer[0]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_merkle_root_for_commit(store: ObjectStore, commit_hash: str) -> Optional[str]:
|
|
72
|
+
"""Build Merkle root for a commit's tree. Returns None if commit/tree missing."""
|
|
73
|
+
commit = Commit.load(store, commit_hash)
|
|
74
|
+
if not commit:
|
|
75
|
+
return None
|
|
76
|
+
blobs = _collect_blob_hashes_from_tree(store, commit.tree)
|
|
77
|
+
return build_merkle_tree(blobs)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def merkle_proof(blob_hashes: List[str], target_blob_hash: str) -> Optional[List[Tuple[str, str]]]:
|
|
81
|
+
"""
|
|
82
|
+
Produce Merkle proof for a blob: list of (sibling_hash, "L"|"R") from leaf to root.
|
|
83
|
+
Returns None if target not in list.
|
|
84
|
+
"""
|
|
85
|
+
if target_blob_hash not in blob_hashes:
|
|
86
|
+
return None
|
|
87
|
+
layer = [_merkle_hash(h.encode()) for h in sorted(blob_hashes)]
|
|
88
|
+
leaf_index = sorted(blob_hashes).index(target_blob_hash)
|
|
89
|
+
proof: List[Tuple[str, str]] = []
|
|
90
|
+
idx = leaf_index
|
|
91
|
+
while len(layer) > 1:
|
|
92
|
+
next_layer = []
|
|
93
|
+
for i in range(0, len(layer), 2):
|
|
94
|
+
left = layer[i]
|
|
95
|
+
right = layer[i + 1] if i + 1 < len(layer) else layer[i]
|
|
96
|
+
combined = (left + right).encode()
|
|
97
|
+
parent = _merkle_hash(combined)
|
|
98
|
+
next_layer.append(parent)
|
|
99
|
+
# If current idx is in this pair, record sibling and advance index
|
|
100
|
+
pair_idx = i // 2
|
|
101
|
+
if idx == i:
|
|
102
|
+
proof.append((right, "R"))
|
|
103
|
+
idx = pair_idx
|
|
104
|
+
elif idx == i + 1:
|
|
105
|
+
proof.append((left, "L"))
|
|
106
|
+
idx = pair_idx
|
|
107
|
+
layer = next_layer
|
|
108
|
+
return proof if proof else []
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def verify_merkle_proof(blob_hash: str, proof: List[Tuple[str, str]], expected_root: str) -> bool:
|
|
112
|
+
"""Verify a Merkle proof for a blob against expected root."""
|
|
113
|
+
current = _merkle_hash(blob_hash.encode())
|
|
114
|
+
for sibling, side in proof:
|
|
115
|
+
if side == "L":
|
|
116
|
+
current = _merkle_hash((sibling + current).encode())
|
|
117
|
+
else:
|
|
118
|
+
current = _merkle_hash((current + sibling).encode())
|
|
119
|
+
return current == expected_root
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --- Signing (Ed25519) ---
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _keys_dir(mem_dir: Path) -> Path:
|
|
126
|
+
return mem_dir / "keys"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_signing_key_paths(mem_dir: Path) -> Tuple[Path, Path]:
|
|
130
|
+
"""Return (private_key_path, public_key_path). Private may not exist (env-only)."""
|
|
131
|
+
kd = _keys_dir(mem_dir)
|
|
132
|
+
return (kd / "private.pem", kd / "public.pem")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def ensure_keys_dir(mem_dir: Path) -> Path:
|
|
136
|
+
"""Ensure .mem/keys exists; return keys dir."""
|
|
137
|
+
kd = _keys_dir(mem_dir)
|
|
138
|
+
kd.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
return kd
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def generate_keypair(mem_dir: Path) -> Tuple[bytes, bytes]:
|
|
143
|
+
"""Generate Ed25519 keypair. Returns (private_pem, public_pem). Requires cryptography."""
|
|
144
|
+
if not ED25519_AVAILABLE:
|
|
145
|
+
raise RuntimeError(
|
|
146
|
+
"Signing requires 'cryptography'; install with: pip install cryptography"
|
|
147
|
+
)
|
|
148
|
+
private_key = Ed25519PrivateKey.generate()
|
|
149
|
+
public_key = private_key.public_key()
|
|
150
|
+
private_pem = private_key.private_bytes(
|
|
151
|
+
encoding=serialization.Encoding.PEM,
|
|
152
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
153
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
154
|
+
)
|
|
155
|
+
public_pem = public_key.public_bytes(
|
|
156
|
+
encoding=serialization.Encoding.PEM,
|
|
157
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
158
|
+
)
|
|
159
|
+
return (private_pem, public_pem)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def save_public_key(mem_dir: Path, public_pem: bytes) -> Path:
|
|
163
|
+
"""Save public key to .mem/keys/public.pem. Returns path."""
|
|
164
|
+
ensure_keys_dir(mem_dir)
|
|
165
|
+
path = _keys_dir(mem_dir) / "public.pem"
|
|
166
|
+
path.write_bytes(public_pem)
|
|
167
|
+
return path
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def load_public_key(mem_dir: Path) -> Optional[bytes]:
|
|
171
|
+
"""Load public key PEM from .mem/keys/public.pem or config. Returns None if not found."""
|
|
172
|
+
path = _keys_dir(mem_dir) / "public.pem"
|
|
173
|
+
if path.exists():
|
|
174
|
+
return path.read_bytes()
|
|
175
|
+
config_file = mem_dir / "config.json"
|
|
176
|
+
if config_file.exists():
|
|
177
|
+
try:
|
|
178
|
+
config = json.loads(config_file.read_text())
|
|
179
|
+
return config.get("signing", {}).get("public_key_pem")
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def load_private_key_from_env() -> Optional[bytes]:
|
|
186
|
+
"""Load private key PEM from env AGMEM_SIGNING_PRIVATE_KEY (or path in AGMEM_SIGNING_PRIVATE_KEY_FILE)."""
|
|
187
|
+
pem = os.environ.get("AGMEM_SIGNING_PRIVATE_KEY")
|
|
188
|
+
if pem:
|
|
189
|
+
return pem.encode() if isinstance(pem, str) else pem
|
|
190
|
+
path = os.environ.get("AGMEM_SIGNING_PRIVATE_KEY_FILE")
|
|
191
|
+
if path and os.path.isfile(path):
|
|
192
|
+
return Path(path).read_bytes()
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def sign_merkle_root(root_hex: str, private_key_pem: bytes) -> str:
|
|
197
|
+
"""Sign Merkle root (hex string). Returns signature as hex."""
|
|
198
|
+
if not ED25519_AVAILABLE:
|
|
199
|
+
raise RuntimeError("Signing requires 'cryptography'")
|
|
200
|
+
key = serialization.load_pem_private_key(private_key_pem, password=None)
|
|
201
|
+
if not isinstance(key, Ed25519PrivateKey):
|
|
202
|
+
raise TypeError("Ed25519 private key required")
|
|
203
|
+
sig = key.sign(root_hex.encode())
|
|
204
|
+
return sig.hex()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def verify_signature(root_hex: str, signature_hex: str, public_key_pem: bytes) -> bool:
|
|
208
|
+
"""Verify signature of Merkle root. Returns True if valid."""
|
|
209
|
+
if not ED25519_AVAILABLE:
|
|
210
|
+
return False
|
|
211
|
+
try:
|
|
212
|
+
key = serialization.load_pem_public_key(public_key_pem)
|
|
213
|
+
if not isinstance(key, Ed25519PublicKey):
|
|
214
|
+
return False
|
|
215
|
+
key.verify(bytes.fromhex(signature_hex), root_hex.encode())
|
|
216
|
+
return True
|
|
217
|
+
except InvalidSignature:
|
|
218
|
+
return False
|
|
219
|
+
except Exception:
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def verify_commit(
|
|
224
|
+
store: ObjectStore,
|
|
225
|
+
commit_hash: str,
|
|
226
|
+
public_key_pem: Optional[bytes] = None,
|
|
227
|
+
*,
|
|
228
|
+
mem_dir: Optional[Path] = None,
|
|
229
|
+
) -> Tuple[bool, Optional[str]]:
|
|
230
|
+
"""
|
|
231
|
+
Verify commit: rebuild Merkle tree from blobs, compare root to stored, verify signature.
|
|
232
|
+
Returns (verified, error_message). verified=True means OK; False + message means tampered or unverified.
|
|
233
|
+
If public_key_pem is None and mem_dir is set, load from mem_dir.
|
|
234
|
+
"""
|
|
235
|
+
commit = Commit.load(store, commit_hash)
|
|
236
|
+
if not commit:
|
|
237
|
+
return (False, "commit not found")
|
|
238
|
+
stored_root = (commit.metadata or {}).get("merkle_root")
|
|
239
|
+
stored_sig = (commit.metadata or {}).get("signature")
|
|
240
|
+
if not stored_root:
|
|
241
|
+
return (False, "commit has no merkle_root (unverified)")
|
|
242
|
+
computed_root = build_merkle_root_for_commit(store, commit_hash)
|
|
243
|
+
if not computed_root:
|
|
244
|
+
return (False, "could not build Merkle tree (missing tree/blobs)")
|
|
245
|
+
if not hmac.compare_digest(computed_root, stored_root):
|
|
246
|
+
return (False, "merkle_root mismatch (commit tampered)")
|
|
247
|
+
if not stored_sig:
|
|
248
|
+
return (True, None) # Root matches; no signature (legacy)
|
|
249
|
+
pub = public_key_pem
|
|
250
|
+
if not pub and mem_dir:
|
|
251
|
+
pub = load_public_key(mem_dir)
|
|
252
|
+
if not pub:
|
|
253
|
+
return (False, "signature present but no public key configured")
|
|
254
|
+
if isinstance(pub, str):
|
|
255
|
+
pub = pub.encode()
|
|
256
|
+
if not verify_signature(stored_root, stored_sig, pub):
|
|
257
|
+
return (False, "signature verification failed")
|
|
258
|
+
return (True, None)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def verify_commit_optional(
|
|
262
|
+
store: ObjectStore,
|
|
263
|
+
commit_hash: str,
|
|
264
|
+
mem_dir: Optional[Path] = None,
|
|
265
|
+
*,
|
|
266
|
+
strict: bool = False,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""
|
|
269
|
+
Verify commit; if strict=True raise on failure. If strict=False, only raise on tamper (root mismatch).
|
|
270
|
+
Unverified (no merkle_root) is OK when not strict.
|
|
271
|
+
"""
|
|
272
|
+
ok, err = verify_commit(store, commit_hash, None, mem_dir=mem_dir)
|
|
273
|
+
if ok:
|
|
274
|
+
return
|
|
275
|
+
if not err:
|
|
276
|
+
return
|
|
277
|
+
if "tampered" in err or "mismatch" in err or "signature verification failed" in err:
|
|
278
|
+
raise ValueError(f"Commit verification failed: {err}")
|
|
279
|
+
if strict:
|
|
280
|
+
raise ValueError(f"Commit verification failed: {err}")
|
memvcs/core/decay.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Decay engine - memory decay and forgetting for agmem.
|
|
3
|
+
|
|
4
|
+
Mimics human forgetting: irrelevant details fade, important ones strengthen.
|
|
5
|
+
Ebbinghaus-inspired time decay + retrieval-induced enhancement.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import shutil
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Dict, Any, Optional, Tuple
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, timedelta
|
|
14
|
+
|
|
15
|
+
from .constants import MEMORY_TYPES
|
|
16
|
+
from .access_index import AccessIndex
|
|
17
|
+
from .objects import Commit
|
|
18
|
+
from .schema import FrontmatterParser
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class DecayConfig:
|
|
23
|
+
"""Configuration for decay engine."""
|
|
24
|
+
|
|
25
|
+
episodic_half_life_days: int = 30
|
|
26
|
+
semantic_min_importance: float = 0.3
|
|
27
|
+
access_count_threshold: int = 2
|
|
28
|
+
forgetting_dir: str = "forgetting"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class DecayCandidate:
|
|
33
|
+
"""A memory candidate for decay (archiving)."""
|
|
34
|
+
|
|
35
|
+
path: str
|
|
36
|
+
memory_type: str
|
|
37
|
+
importance: float
|
|
38
|
+
last_access_days: Optional[float]
|
|
39
|
+
access_count: int
|
|
40
|
+
decay_score: float
|
|
41
|
+
reason: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DecayEngine:
|
|
45
|
+
"""Computes decay scores and archives low-importance memories."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, repo: Any, config: Optional[DecayConfig] = None):
|
|
48
|
+
self.repo = repo
|
|
49
|
+
self.config = config or DecayConfig()
|
|
50
|
+
self.access_index = AccessIndex(repo.mem_dir)
|
|
51
|
+
self.forgetting_dir = repo.mem_dir / self.config.forgetting_dir
|
|
52
|
+
self.current_dir = repo.current_dir
|
|
53
|
+
|
|
54
|
+
def _get_importance(self, path: str, content: str) -> float:
|
|
55
|
+
"""Get importance from frontmatter or default."""
|
|
56
|
+
fm, _ = FrontmatterParser.parse(content)
|
|
57
|
+
if fm and fm.importance is not None:
|
|
58
|
+
return float(fm.importance)
|
|
59
|
+
if fm and fm.confidence_score is not None:
|
|
60
|
+
return float(fm.confidence_score)
|
|
61
|
+
return 0.5
|
|
62
|
+
|
|
63
|
+
def _get_access_info(self, path: str) -> Tuple[int, Optional[float]]:
|
|
64
|
+
"""Get access count and days since last access."""
|
|
65
|
+
counts = self.access_index.get_access_counts_by_path()
|
|
66
|
+
count = counts.get(path, 0)
|
|
67
|
+
recent = self.access_index.get_recent_accesses(limit=1, path=path)
|
|
68
|
+
if not recent:
|
|
69
|
+
return count, None
|
|
70
|
+
ts_str = recent[0].get("timestamp", "")
|
|
71
|
+
if not ts_str:
|
|
72
|
+
return count, None
|
|
73
|
+
try:
|
|
74
|
+
if ts_str.endswith("Z"):
|
|
75
|
+
ts_str = ts_str[:-1] + "+00:00"
|
|
76
|
+
last = datetime.fromisoformat(ts_str)
|
|
77
|
+
days = (datetime.utcnow() - last.replace(tzinfo=None)).total_seconds() / 86400
|
|
78
|
+
return count, days
|
|
79
|
+
except Exception:
|
|
80
|
+
return count, None
|
|
81
|
+
|
|
82
|
+
def compute_decay_score(
|
|
83
|
+
self,
|
|
84
|
+
path: str,
|
|
85
|
+
content: str,
|
|
86
|
+
memory_type: str,
|
|
87
|
+
) -> DecayCandidate:
|
|
88
|
+
"""
|
|
89
|
+
Compute decay score for a memory.
|
|
90
|
+
|
|
91
|
+
Higher score = more likely to decay (archive).
|
|
92
|
+
Time decay: importance * 0.5^(days/half_life) when never accessed.
|
|
93
|
+
Retrieval-induced enhancement: access boosts strength (lower decay).
|
|
94
|
+
"""
|
|
95
|
+
importance = self._get_importance(path, content)
|
|
96
|
+
access_count, last_access_days = self._get_access_info(path)
|
|
97
|
+
|
|
98
|
+
decay_score = 0.0
|
|
99
|
+
reason = ""
|
|
100
|
+
|
|
101
|
+
if "episodic" in memory_type.lower():
|
|
102
|
+
half_life = self.config.episodic_half_life_days
|
|
103
|
+
if last_access_days is not None:
|
|
104
|
+
decay_score = 1.0 - (importance * math.pow(0.5, last_access_days / half_life))
|
|
105
|
+
if access_count < self.config.access_count_threshold:
|
|
106
|
+
decay_score += 0.2
|
|
107
|
+
reason = f"episodic: {last_access_days:.0f}d since access, imp={importance:.2f}"
|
|
108
|
+
else:
|
|
109
|
+
decay_score = 0.5
|
|
110
|
+
reason = "episodic: never accessed"
|
|
111
|
+
else:
|
|
112
|
+
if importance < self.config.semantic_min_importance:
|
|
113
|
+
decay_score = 1.0 - importance
|
|
114
|
+
reason = f"semantic: low importance {importance:.2f}"
|
|
115
|
+
elif (
|
|
116
|
+
access_count < self.config.access_count_threshold
|
|
117
|
+
and last_access_days
|
|
118
|
+
and last_access_days > 60
|
|
119
|
+
):
|
|
120
|
+
decay_score = 0.4
|
|
121
|
+
reason = "semantic: rarely accessed"
|
|
122
|
+
|
|
123
|
+
return DecayCandidate(
|
|
124
|
+
path=path,
|
|
125
|
+
memory_type=memory_type,
|
|
126
|
+
importance=importance,
|
|
127
|
+
last_access_days=last_access_days,
|
|
128
|
+
access_count=access_count,
|
|
129
|
+
decay_score=decay_score,
|
|
130
|
+
reason=reason,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def get_decay_candidates(self) -> List[DecayCandidate]:
|
|
134
|
+
"""Get list of memories that would be archived (dry-run)."""
|
|
135
|
+
candidates = []
|
|
136
|
+
if not self.current_dir.exists():
|
|
137
|
+
return candidates
|
|
138
|
+
|
|
139
|
+
for subdir in MEMORY_TYPES:
|
|
140
|
+
dir_path = self.current_dir / subdir
|
|
141
|
+
if not dir_path.exists():
|
|
142
|
+
continue
|
|
143
|
+
for f in dir_path.rglob("*"):
|
|
144
|
+
if not f.is_file() or f.suffix.lower() not in (".md", ".txt"):
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
rel_path = str(f.relative_to(self.current_dir))
|
|
148
|
+
content = f.read_text(encoding="utf-8", errors="replace")
|
|
149
|
+
except Exception:
|
|
150
|
+
continue
|
|
151
|
+
cand = self.compute_decay_score(rel_path, content, subdir)
|
|
152
|
+
if cand.decay_score > 0.5:
|
|
153
|
+
candidates.append(cand)
|
|
154
|
+
|
|
155
|
+
candidates.sort(key=lambda x: x.decay_score, reverse=True)
|
|
156
|
+
return candidates
|
|
157
|
+
|
|
158
|
+
def apply_decay(self, candidates: Optional[List[DecayCandidate]] = None) -> int:
|
|
159
|
+
"""
|
|
160
|
+
Archive low-importance memories to .mem/forgetting/.
|
|
161
|
+
|
|
162
|
+
Returns count of files archived.
|
|
163
|
+
"""
|
|
164
|
+
if candidates is None:
|
|
165
|
+
candidates = self.get_decay_candidates()
|
|
166
|
+
self.forgetting_dir.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
ts = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
|
168
|
+
archive_sub = self.forgetting_dir / ts
|
|
169
|
+
archive_sub.mkdir(exist_ok=True)
|
|
170
|
+
count = 0
|
|
171
|
+
for cand in candidates:
|
|
172
|
+
if cand.decay_score <= 0.5:
|
|
173
|
+
continue
|
|
174
|
+
src = self.current_dir / cand.path
|
|
175
|
+
if not src.exists():
|
|
176
|
+
continue
|
|
177
|
+
try:
|
|
178
|
+
safe_name = cand.path.replace("/", "_").replace("..", "_")
|
|
179
|
+
dest = (archive_sub / safe_name).resolve()
|
|
180
|
+
dest.relative_to(self.forgetting_dir.resolve())
|
|
181
|
+
shutil.move(str(src), str(dest))
|
|
182
|
+
count += 1
|
|
183
|
+
except (ValueError, Exception):
|
|
184
|
+
continue
|
|
185
|
+
return count
|