claude-memory-agent 2.0.1 → 2.2.0
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.
- package/README.md +206 -206
- package/agent_card.py +186 -0
- package/bin/cli.js +327 -185
- package/bin/lib/banner.js +39 -0
- package/bin/lib/environment.js +166 -0
- package/bin/lib/installer.js +291 -0
- package/bin/lib/models.js +95 -0
- package/bin/lib/steps/advanced.js +101 -0
- package/bin/lib/steps/confirm.js +87 -0
- package/bin/lib/steps/model.js +57 -0
- package/bin/lib/steps/provider.js +65 -0
- package/bin/lib/steps/scope.js +59 -0
- package/bin/lib/steps/server.js +74 -0
- package/bin/lib/ui.js +75 -0
- package/bin/onboarding.js +164 -0
- package/bin/postinstall.js +35 -270
- package/config.py +103 -4
- package/dashboard.html +4902 -2689
- package/hooks/extract_memories.py +439 -0
- package/hooks/grounding-hook.py +422 -348
- package/hooks/pre_compact_hook.py +76 -0
- package/hooks/session_end.py +293 -192
- package/hooks/session_end_hook.py +149 -0
- package/hooks/session_start.py +227 -227
- package/hooks/stop_hook.py +372 -0
- package/install.py +972 -902
- package/main.py +5240 -2859
- package/mcp_server.py +451 -0
- package/package.json +58 -47
- package/requirements.txt +12 -8
- package/services/__init__.py +50 -50
- package/services/adaptive_ranker.py +272 -0
- package/services/agent_catalog.json +153 -0
- package/services/agent_registry.py +245 -730
- package/services/claude_md_sync.py +320 -4
- package/services/consolidation.py +417 -0
- package/services/curator.py +1606 -0
- package/services/database.py +4118 -2485
- package/services/embedding_pipeline.py +262 -0
- package/services/embeddings.py +493 -85
- package/services/memory_decay.py +408 -0
- package/services/native_memory_paths.py +86 -0
- package/services/native_memory_sync.py +496 -0
- package/services/response_manager.py +183 -0
- package/services/terminal_ui.py +199 -0
- package/services/tier_manager.py +235 -0
- package/services/websocket.py +26 -6
- package/skills/__init__.py +21 -1
- package/skills/confidence_tracker.py +441 -0
- package/skills/context.py +675 -0
- package/skills/curator.py +348 -0
- package/skills/search.py +444 -213
- package/skills/session_review.py +605 -0
- package/skills/store.py +484 -179
- package/terminal_dashboard.py +474 -0
- package/update_system.py +829 -817
- package/hooks/__pycache__/auto-detect-response.cpython-312.pyc +0 -0
- package/hooks/__pycache__/auto_capture.cpython-312.pyc +0 -0
- package/hooks/__pycache__/session_end.cpython-312.pyc +0 -0
- package/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
- package/services/__pycache__/__init__.cpython-312.pyc +0 -0
- package/services/__pycache__/agent_registry.cpython-312.pyc +0 -0
- package/services/__pycache__/auth.cpython-312.pyc +0 -0
- package/services/__pycache__/auto_inject.cpython-312.pyc +0 -0
- package/services/__pycache__/claude_md_sync.cpython-312.pyc +0 -0
- package/services/__pycache__/cleanup.cpython-312.pyc +0 -0
- package/services/__pycache__/compaction_flush.cpython-312.pyc +0 -0
- package/services/__pycache__/confidence.cpython-312.pyc +0 -0
- package/services/__pycache__/daily_log.cpython-312.pyc +0 -0
- package/services/__pycache__/database.cpython-312.pyc +0 -0
- package/services/__pycache__/embeddings.cpython-312.pyc +0 -0
- package/services/__pycache__/insights.cpython-312.pyc +0 -0
- package/services/__pycache__/llm_analyzer.cpython-312.pyc +0 -0
- package/services/__pycache__/memory_md_sync.cpython-312.pyc +0 -0
- package/services/__pycache__/retry_queue.cpython-312.pyc +0 -0
- package/services/__pycache__/timeline.cpython-312.pyc +0 -0
- package/services/__pycache__/vector_index.cpython-312.pyc +0 -0
- package/services/__pycache__/websocket.cpython-312.pyc +0 -0
- package/skills/__pycache__/__init__.cpython-312.pyc +0 -0
- package/skills/__pycache__/admin.cpython-312.pyc +0 -0
- package/skills/__pycache__/checkpoint.cpython-312.pyc +0 -0
- package/skills/__pycache__/claude_md.cpython-312.pyc +0 -0
- package/skills/__pycache__/cleanup.cpython-312.pyc +0 -0
- package/skills/__pycache__/grounding.cpython-312.pyc +0 -0
- package/skills/__pycache__/insights.cpython-312.pyc +0 -0
- package/skills/__pycache__/natural_language.cpython-312.pyc +0 -0
- package/skills/__pycache__/retrieve.cpython-312.pyc +0 -0
- package/skills/__pycache__/search.cpython-312.pyc +0 -0
- package/skills/__pycache__/state.cpython-312.pyc +0 -0
- package/skills/__pycache__/store.cpython-312.pyc +0 -0
- package/skills/__pycache__/summarize.cpython-312.pyc +0 -0
- package/skills/__pycache__/timeline.cpython-312.pyc +0 -0
- package/skills/__pycache__/verification.cpython-312.pyc +0 -0
- package/test_automation.py +0 -221
- package/test_complete.py +0 -338
- package/test_full.py +0 -322
- package/verify_db.py +0 -134
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Memory Decay Service - Type-based lifespan management for memories.
|
|
2
|
+
|
|
3
|
+
Implements automatic decay of memory relevance based on type-specific lifespans,
|
|
4
|
+
access patterns, and age. Permanent types (decision, preference, code) never decay.
|
|
5
|
+
Temporary types (session, chunk, error) decay over configurable lifespans.
|
|
6
|
+
|
|
7
|
+
Decay formula:
|
|
8
|
+
relevance_score = base_score * decay_multiplier * access_boost
|
|
9
|
+
decay_multiplier = max(0, 1 - (age_days / lifespan_days)) [non-permanent only]
|
|
10
|
+
access_boost = 1 + (0.1 * min(access_count, 10)) [caps at 2x]
|
|
11
|
+
"""
|
|
12
|
+
import logging
|
|
13
|
+
from datetime import datetime, timedelta
|
|
14
|
+
from typing import Dict, Any, Optional, List
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Type-based lifespans in days. None means permanent (never decays).
|
|
19
|
+
DECAY_LIFESPANS = {
|
|
20
|
+
"decision": None, # permanent - architectural choices persist
|
|
21
|
+
"preference": None, # permanent - user preferences persist
|
|
22
|
+
"code": None, # permanent - reusable patterns persist
|
|
23
|
+
"error": 90, # 3 months - errors become stale
|
|
24
|
+
"session": 7, # 1 week - session context is ephemeral
|
|
25
|
+
"chunk": 30, # 1 month - general chunks decay moderately
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Default lifespan for unknown types
|
|
29
|
+
DEFAULT_LIFESPAN_DAYS = 30
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MemoryDecayService:
|
|
33
|
+
"""Manages memory decay based on type-specific lifespans and access patterns.
|
|
34
|
+
|
|
35
|
+
Permanent types (decision, preference, code) are never decayed.
|
|
36
|
+
Temporary types have configurable lifespans after which they are archived.
|
|
37
|
+
Frequently accessed memories resist decay through an access boost.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, db, archive_threshold: float = 0.1):
|
|
41
|
+
"""Initialize the decay service.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
db: DatabaseService instance (synchronous sqlite3 connection via db.conn)
|
|
45
|
+
archive_threshold: Minimum decay score before archiving (default 0.1)
|
|
46
|
+
"""
|
|
47
|
+
self.db = db
|
|
48
|
+
self.archive_threshold = archive_threshold
|
|
49
|
+
|
|
50
|
+
def calculate_decay_score(self, memory: dict) -> float:
|
|
51
|
+
"""Calculate the current relevance score for a memory based on decay.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
memory: Dict with keys: type, created_at, access_count, importance, confidence
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Float relevance score. 1.0 = fully relevant, 0.0 = fully decayed.
|
|
58
|
+
Permanent types always return 1.0.
|
|
59
|
+
"""
|
|
60
|
+
memory_type = memory.get("type", "chunk")
|
|
61
|
+
lifespan = DECAY_LIFESPANS.get(memory_type, DEFAULT_LIFESPAN_DAYS)
|
|
62
|
+
|
|
63
|
+
# Permanent types never decay
|
|
64
|
+
if lifespan is None:
|
|
65
|
+
return 1.0
|
|
66
|
+
|
|
67
|
+
# Calculate age in days
|
|
68
|
+
created_at = memory.get("created_at")
|
|
69
|
+
if not created_at:
|
|
70
|
+
return 1.0
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
created_dt = datetime.fromisoformat(
|
|
74
|
+
created_at.replace('Z', '+00:00')
|
|
75
|
+
).replace(tzinfo=None)
|
|
76
|
+
age_days = (datetime.now() - created_dt).total_seconds() / 86400.0
|
|
77
|
+
except (ValueError, TypeError, AttributeError):
|
|
78
|
+
return 1.0
|
|
79
|
+
|
|
80
|
+
# Decay multiplier: linear decay from 1.0 to 0.0 over the lifespan
|
|
81
|
+
decay_multiplier = max(0.0, 1.0 - (age_days / lifespan))
|
|
82
|
+
|
|
83
|
+
# Access boost: frequently accessed memories resist decay
|
|
84
|
+
# Caps at 2x boost (access_count=10 gives 1 + 0.1*10 = 2.0)
|
|
85
|
+
access_count = memory.get("access_count", 0) or 0
|
|
86
|
+
access_boost = 1.0 + (0.1 * min(access_count, 10))
|
|
87
|
+
|
|
88
|
+
# Final score: decay_multiplier * access_boost, capped at 1.0
|
|
89
|
+
score = min(decay_multiplier * access_boost, 1.0)
|
|
90
|
+
|
|
91
|
+
return round(score, 4)
|
|
92
|
+
|
|
93
|
+
async def apply_decay(self, update_tiers: bool = True) -> Dict[str, Any]:
|
|
94
|
+
"""Run decay across all non-permanent memories and archive those below threshold.
|
|
95
|
+
|
|
96
|
+
Optionally combines with tier evaluation in a single pass for efficiency.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
update_tiers: If True, also evaluate and update memory tiers
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Dict with statistics about the decay run.
|
|
103
|
+
"""
|
|
104
|
+
cursor = self.db.conn.cursor()
|
|
105
|
+
|
|
106
|
+
# Only process types with finite lifespans
|
|
107
|
+
decayable_types = [
|
|
108
|
+
mtype for mtype, lifespan in DECAY_LIFESPANS.items()
|
|
109
|
+
if lifespan is not None
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
if not decayable_types:
|
|
113
|
+
return {
|
|
114
|
+
"success": True,
|
|
115
|
+
"memories_evaluated": 0,
|
|
116
|
+
"memories_archived": 0,
|
|
117
|
+
"message": "No decayable types configured"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# Fetch all non-permanent memories
|
|
121
|
+
placeholders = ",".join("?" * len(decayable_types))
|
|
122
|
+
cursor.execute(f"""
|
|
123
|
+
SELECT id, type, content, embedding, project_path, session_id,
|
|
124
|
+
importance, access_count, decay_factor, metadata,
|
|
125
|
+
confidence, created_at, last_accessed, tier, tier_changed_at
|
|
126
|
+
FROM memories
|
|
127
|
+
WHERE type IN ({placeholders})
|
|
128
|
+
""", decayable_types)
|
|
129
|
+
|
|
130
|
+
rows = cursor.fetchall()
|
|
131
|
+
|
|
132
|
+
evaluated = 0
|
|
133
|
+
archived = 0
|
|
134
|
+
updated = 0
|
|
135
|
+
tier_changes = 0
|
|
136
|
+
scores_by_type = {}
|
|
137
|
+
|
|
138
|
+
# Lazy-load tier manager if tier updates requested
|
|
139
|
+
tier_manager = None
|
|
140
|
+
if update_tiers:
|
|
141
|
+
try:
|
|
142
|
+
from services.tier_manager import TierManager
|
|
143
|
+
tier_manager = TierManager(self.db)
|
|
144
|
+
except ImportError:
|
|
145
|
+
logger.warning("TierManager not available, skipping tier updates")
|
|
146
|
+
|
|
147
|
+
for row in rows:
|
|
148
|
+
evaluated += 1
|
|
149
|
+
memory_dict = dict(row)
|
|
150
|
+
decay_score = self.calculate_decay_score(memory_dict)
|
|
151
|
+
memory_type = row["type"]
|
|
152
|
+
|
|
153
|
+
# Track scores by type for stats
|
|
154
|
+
if memory_type not in scores_by_type:
|
|
155
|
+
scores_by_type[memory_type] = {
|
|
156
|
+
"count": 0,
|
|
157
|
+
"total_score": 0.0,
|
|
158
|
+
"archived": 0,
|
|
159
|
+
"min_score": 1.0,
|
|
160
|
+
"max_score": 0.0
|
|
161
|
+
}
|
|
162
|
+
type_stats = scores_by_type[memory_type]
|
|
163
|
+
type_stats["count"] += 1
|
|
164
|
+
type_stats["total_score"] += decay_score
|
|
165
|
+
type_stats["min_score"] = min(type_stats["min_score"], decay_score)
|
|
166
|
+
type_stats["max_score"] = max(type_stats["max_score"], decay_score)
|
|
167
|
+
|
|
168
|
+
if decay_score < self.archive_threshold:
|
|
169
|
+
# Archive the memory
|
|
170
|
+
try:
|
|
171
|
+
cursor.execute("""
|
|
172
|
+
INSERT INTO memory_archive
|
|
173
|
+
(original_id, type, content, embedding, project_path, session_id,
|
|
174
|
+
importance, access_count, decay_factor, metadata,
|
|
175
|
+
archive_reason, relevance_score_at_archive)
|
|
176
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'decay_expired', ?)
|
|
177
|
+
""", (
|
|
178
|
+
row["id"], row["type"], row["content"], row["embedding"],
|
|
179
|
+
row["project_path"], row["session_id"],
|
|
180
|
+
row["importance"], row["access_count"],
|
|
181
|
+
row["decay_factor"], row["metadata"],
|
|
182
|
+
decay_score
|
|
183
|
+
))
|
|
184
|
+
|
|
185
|
+
# Delete from active memories
|
|
186
|
+
cursor.execute("DELETE FROM memories WHERE id = ?", (row["id"],))
|
|
187
|
+
archived += 1
|
|
188
|
+
type_stats["archived"] += 1
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.error(f"Failed to archive memory {row['id']}: {e}")
|
|
191
|
+
else:
|
|
192
|
+
# Combined update: decay_factor + tier in single UPDATE
|
|
193
|
+
new_tier = None
|
|
194
|
+
if tier_manager:
|
|
195
|
+
new_tier = tier_manager.evaluate_tier(memory_dict)
|
|
196
|
+
old_tier = row.get('tier') or 'hot'
|
|
197
|
+
if new_tier != old_tier:
|
|
198
|
+
tier_changes += 1
|
|
199
|
+
|
|
200
|
+
if new_tier:
|
|
201
|
+
cursor.execute("""
|
|
202
|
+
UPDATE memories
|
|
203
|
+
SET decay_factor = ?, tier = ?, tier_changed_at = ?
|
|
204
|
+
WHERE id = ?
|
|
205
|
+
""", (decay_score, new_tier, datetime.now().isoformat(), row["id"]))
|
|
206
|
+
else:
|
|
207
|
+
cursor.execute("""
|
|
208
|
+
UPDATE memories
|
|
209
|
+
SET decay_factor = ?
|
|
210
|
+
WHERE id = ?
|
|
211
|
+
""", (decay_score, row["id"]))
|
|
212
|
+
updated += 1
|
|
213
|
+
|
|
214
|
+
self.db.conn.commit()
|
|
215
|
+
|
|
216
|
+
# Build average scores
|
|
217
|
+
for type_stats in scores_by_type.values():
|
|
218
|
+
if type_stats["count"] > 0:
|
|
219
|
+
type_stats["avg_score"] = round(
|
|
220
|
+
type_stats["total_score"] / type_stats["count"], 4
|
|
221
|
+
)
|
|
222
|
+
del type_stats["total_score"]
|
|
223
|
+
|
|
224
|
+
result = {
|
|
225
|
+
"success": True,
|
|
226
|
+
"memories_evaluated": evaluated,
|
|
227
|
+
"memories_archived": archived,
|
|
228
|
+
"memories_updated": updated,
|
|
229
|
+
"archive_threshold": self.archive_threshold,
|
|
230
|
+
"scores_by_type": scores_by_type,
|
|
231
|
+
"timestamp": datetime.now().isoformat()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if update_tiers:
|
|
235
|
+
result["tier_changes"] = tier_changes
|
|
236
|
+
|
|
237
|
+
return result
|
|
238
|
+
|
|
239
|
+
async def boost_on_access(self, memory_id: int) -> Dict[str, Any]:
|
|
240
|
+
"""Called when a memory is accessed. Increments access_count and updates last_accessed.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
memory_id: ID of the memory being accessed
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Dict with updated access stats
|
|
247
|
+
"""
|
|
248
|
+
cursor = self.db.conn.cursor()
|
|
249
|
+
|
|
250
|
+
cursor.execute("""
|
|
251
|
+
UPDATE memories
|
|
252
|
+
SET access_count = COALESCE(access_count, 0) + 1,
|
|
253
|
+
last_accessed = datetime('now')
|
|
254
|
+
WHERE id = ?
|
|
255
|
+
""", (memory_id,))
|
|
256
|
+
|
|
257
|
+
self.db.conn.commit()
|
|
258
|
+
|
|
259
|
+
if cursor.rowcount == 0:
|
|
260
|
+
return {"success": False, "error": f"Memory {memory_id} not found"}
|
|
261
|
+
|
|
262
|
+
# Fetch updated stats
|
|
263
|
+
cursor.execute("""
|
|
264
|
+
SELECT id, type, access_count, last_accessed, created_at, importance, confidence
|
|
265
|
+
FROM memories WHERE id = ?
|
|
266
|
+
""", (memory_id,))
|
|
267
|
+
row = cursor.fetchone()
|
|
268
|
+
|
|
269
|
+
if not row:
|
|
270
|
+
return {"success": False, "error": f"Memory {memory_id} not found after update"}
|
|
271
|
+
|
|
272
|
+
decay_score = self.calculate_decay_score(dict(row))
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
"success": True,
|
|
276
|
+
"memory_id": memory_id,
|
|
277
|
+
"access_count": row["access_count"],
|
|
278
|
+
"last_accessed": row["last_accessed"],
|
|
279
|
+
"current_decay_score": decay_score
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async def get_decay_stats(self) -> Dict[str, Any]:
|
|
283
|
+
"""Return statistics on decayed, active, and permanent memories.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Dict with comprehensive decay statistics
|
|
287
|
+
"""
|
|
288
|
+
cursor = self.db.conn.cursor()
|
|
289
|
+
|
|
290
|
+
# Count by type
|
|
291
|
+
cursor.execute("""
|
|
292
|
+
SELECT type, COUNT(*) as count
|
|
293
|
+
FROM memories
|
|
294
|
+
GROUP BY type
|
|
295
|
+
""")
|
|
296
|
+
type_counts = {row["type"]: row["count"] for row in cursor.fetchall()}
|
|
297
|
+
|
|
298
|
+
# Separate permanent vs decayable
|
|
299
|
+
permanent_types = [
|
|
300
|
+
mtype for mtype, lifespan in DECAY_LIFESPANS.items()
|
|
301
|
+
if lifespan is None
|
|
302
|
+
]
|
|
303
|
+
decayable_types = [
|
|
304
|
+
mtype for mtype, lifespan in DECAY_LIFESPANS.items()
|
|
305
|
+
if lifespan is not None
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
permanent_count = sum(
|
|
309
|
+
type_counts.get(t, 0) for t in permanent_types
|
|
310
|
+
)
|
|
311
|
+
decayable_count = sum(
|
|
312
|
+
type_counts.get(t, 0) for t in decayable_types
|
|
313
|
+
)
|
|
314
|
+
# Unknown types also count as decayable
|
|
315
|
+
known_types = set(DECAY_LIFESPANS.keys())
|
|
316
|
+
unknown_count = sum(
|
|
317
|
+
count for t, count in type_counts.items() if t not in known_types
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Get archived count (from decay)
|
|
321
|
+
cursor.execute("""
|
|
322
|
+
SELECT COUNT(*) as count
|
|
323
|
+
FROM memory_archive
|
|
324
|
+
WHERE archive_reason = 'decay_expired'
|
|
325
|
+
""")
|
|
326
|
+
archived_by_decay = cursor.fetchone()["count"]
|
|
327
|
+
|
|
328
|
+
# Calculate decay scores for all decayable memories
|
|
329
|
+
at_risk = 0 # Score between threshold and 0.3
|
|
330
|
+
healthy = 0 # Score above 0.3
|
|
331
|
+
|
|
332
|
+
if decayable_types:
|
|
333
|
+
placeholders = ",".join("?" * len(decayable_types))
|
|
334
|
+
cursor.execute(f"""
|
|
335
|
+
SELECT type, access_count, created_at, importance, confidence
|
|
336
|
+
FROM memories
|
|
337
|
+
WHERE type IN ({placeholders})
|
|
338
|
+
""", decayable_types)
|
|
339
|
+
|
|
340
|
+
for row in cursor.fetchall():
|
|
341
|
+
score = self.calculate_decay_score(dict(row))
|
|
342
|
+
if score < 0.3:
|
|
343
|
+
at_risk += 1
|
|
344
|
+
else:
|
|
345
|
+
healthy += 1
|
|
346
|
+
|
|
347
|
+
# Lifespan info for reference
|
|
348
|
+
lifespan_info = {}
|
|
349
|
+
for mtype, lifespan in DECAY_LIFESPANS.items():
|
|
350
|
+
lifespan_info[mtype] = {
|
|
351
|
+
"lifespan_days": lifespan if lifespan is not None else "permanent",
|
|
352
|
+
"active_count": type_counts.get(mtype, 0)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
"total_memories": sum(type_counts.values()),
|
|
357
|
+
"permanent_count": permanent_count,
|
|
358
|
+
"decayable_count": decayable_count + unknown_count,
|
|
359
|
+
"archived_by_decay": archived_by_decay,
|
|
360
|
+
"at_risk_count": at_risk,
|
|
361
|
+
"healthy_count": healthy,
|
|
362
|
+
"archive_threshold": self.archive_threshold,
|
|
363
|
+
"type_details": lifespan_info,
|
|
364
|
+
"type_counts": type_counts,
|
|
365
|
+
"timestamp": datetime.now().isoformat()
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def calculate_search_decay_multiplier(memory: dict) -> float:
|
|
370
|
+
"""Calculate a decay multiplier suitable for search ranking.
|
|
371
|
+
|
|
372
|
+
This is a standalone function that can be called from search_similar()
|
|
373
|
+
without needing the full MemoryDecayService instance.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
memory: Dict with at least 'type', 'created_at', 'access_count'
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Float multiplier between 0.0 and 1.0. Permanent types return 1.0.
|
|
380
|
+
"""
|
|
381
|
+
memory_type = memory.get("type", "chunk")
|
|
382
|
+
lifespan = DECAY_LIFESPANS.get(memory_type, DEFAULT_LIFESPAN_DAYS)
|
|
383
|
+
|
|
384
|
+
# Permanent types: no decay penalty
|
|
385
|
+
if lifespan is None:
|
|
386
|
+
return 1.0
|
|
387
|
+
|
|
388
|
+
# Calculate age
|
|
389
|
+
created_at = memory.get("created_at")
|
|
390
|
+
if not created_at:
|
|
391
|
+
return 1.0
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
created_dt = datetime.fromisoformat(
|
|
395
|
+
created_at.replace('Z', '+00:00')
|
|
396
|
+
).replace(tzinfo=None)
|
|
397
|
+
age_days = (datetime.now() - created_dt).total_seconds() / 86400.0
|
|
398
|
+
except (ValueError, TypeError, AttributeError):
|
|
399
|
+
return 1.0
|
|
400
|
+
|
|
401
|
+
# Linear decay
|
|
402
|
+
decay_multiplier = max(0.0, 1.0 - (age_days / lifespan))
|
|
403
|
+
|
|
404
|
+
# Access boost
|
|
405
|
+
access_count = memory.get("access_count", 0) or 0
|
|
406
|
+
access_boost = 1.0 + (0.1 * min(access_count, 10))
|
|
407
|
+
|
|
408
|
+
return min(decay_multiplier * access_boost, 1.0)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Native Memory Paths - Maps project paths to Claude Code's auto memory directories.
|
|
2
|
+
|
|
3
|
+
Claude Code stores per-project auto memory at:
|
|
4
|
+
~/.claude/projects/<slug>/memory/MEMORY.md
|
|
5
|
+
|
|
6
|
+
The slug is derived from the project's absolute path:
|
|
7
|
+
C:\\xampp\\htdocs\\server -> C--xampp-htdocs-server
|
|
8
|
+
D:\\Desktop-Projects\\foo -> D--Desktop-Projects-foo
|
|
9
|
+
|
|
10
|
+
Algorithm (reverse-engineered from existing directories):
|
|
11
|
+
- Drive colon+separator (:\\ or :/) becomes --
|
|
12
|
+
- All remaining separators (\\ or /) become -
|
|
13
|
+
- Spaces become -
|
|
14
|
+
"""
|
|
15
|
+
import logging
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import List
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
CLAUDE_DIR = Path.home() / ".claude"
|
|
22
|
+
PROJECTS_DIR = CLAUDE_DIR / "projects"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def project_path_to_slug(project_path: str) -> str:
|
|
26
|
+
"""Convert an absolute project path to Claude Code's slug format.
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
C:\\xampp\\htdocs\\server -> C--xampp-htdocs-server
|
|
30
|
+
D:\\Desktop-Projects\\foo -> D--Desktop-Projects-foo
|
|
31
|
+
C:\\Users\\moham\\Desktop\\X -> C--Users-moham-Desktop-X
|
|
32
|
+
"""
|
|
33
|
+
# Normalize to forward slashes
|
|
34
|
+
p = project_path.replace("\\", "/").rstrip("/")
|
|
35
|
+
|
|
36
|
+
# Handle drive letter: C:/ -> C--
|
|
37
|
+
if len(p) >= 2 and p[1] == ":":
|
|
38
|
+
drive = p[0]
|
|
39
|
+
rest = p[2:].lstrip("/")
|
|
40
|
+
slug = f"{drive}--{rest}"
|
|
41
|
+
else:
|
|
42
|
+
slug = p.lstrip("/")
|
|
43
|
+
|
|
44
|
+
# Replace remaining separators and spaces with -
|
|
45
|
+
slug = slug.replace("/", "-").replace(" ", "-")
|
|
46
|
+
|
|
47
|
+
return slug
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_native_memory_dir(project_path: str) -> Path:
|
|
51
|
+
"""Return the native auto memory directory for a project.
|
|
52
|
+
|
|
53
|
+
Returns ~/.claude/projects/<slug>/memory/
|
|
54
|
+
"""
|
|
55
|
+
slug = project_path_to_slug(project_path)
|
|
56
|
+
return PROJECTS_DIR / slug / "memory"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_native_memory_md(project_path: str) -> Path:
|
|
60
|
+
"""Return the path to the native MEMORY.md for a project."""
|
|
61
|
+
return get_native_memory_dir(project_path) / "MEMORY.md"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def list_native_memory_files(project_path: str) -> List[Path]:
|
|
65
|
+
"""List all markdown files in a project's native memory directory.
|
|
66
|
+
|
|
67
|
+
Returns MEMORY.md first (if exists), then topic files alphabetically.
|
|
68
|
+
"""
|
|
69
|
+
mem_dir = get_native_memory_dir(project_path)
|
|
70
|
+
if not mem_dir.exists():
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
memory_md = mem_dir / "MEMORY.md"
|
|
74
|
+
files = []
|
|
75
|
+
|
|
76
|
+
if memory_md.exists():
|
|
77
|
+
files.append(memory_md)
|
|
78
|
+
|
|
79
|
+
# Add topic files (any .md that isn't MEMORY.md)
|
|
80
|
+
for f in sorted(mem_dir.glob("*.md")):
|
|
81
|
+
if f.name != "MEMORY.md":
|
|
82
|
+
files.append(f)
|
|
83
|
+
|
|
84
|
+
return files
|
|
85
|
+
|
|
86
|
+
|