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.
Files changed (97) hide show
  1. package/README.md +206 -206
  2. package/agent_card.py +186 -0
  3. package/bin/cli.js +327 -185
  4. package/bin/lib/banner.js +39 -0
  5. package/bin/lib/environment.js +166 -0
  6. package/bin/lib/installer.js +291 -0
  7. package/bin/lib/models.js +95 -0
  8. package/bin/lib/steps/advanced.js +101 -0
  9. package/bin/lib/steps/confirm.js +87 -0
  10. package/bin/lib/steps/model.js +57 -0
  11. package/bin/lib/steps/provider.js +65 -0
  12. package/bin/lib/steps/scope.js +59 -0
  13. package/bin/lib/steps/server.js +74 -0
  14. package/bin/lib/ui.js +75 -0
  15. package/bin/onboarding.js +164 -0
  16. package/bin/postinstall.js +35 -270
  17. package/config.py +103 -4
  18. package/dashboard.html +4902 -2689
  19. package/hooks/extract_memories.py +439 -0
  20. package/hooks/grounding-hook.py +422 -348
  21. package/hooks/pre_compact_hook.py +76 -0
  22. package/hooks/session_end.py +293 -192
  23. package/hooks/session_end_hook.py +149 -0
  24. package/hooks/session_start.py +227 -227
  25. package/hooks/stop_hook.py +372 -0
  26. package/install.py +972 -902
  27. package/main.py +5240 -2859
  28. package/mcp_server.py +451 -0
  29. package/package.json +58 -47
  30. package/requirements.txt +12 -8
  31. package/services/__init__.py +50 -50
  32. package/services/adaptive_ranker.py +272 -0
  33. package/services/agent_catalog.json +153 -0
  34. package/services/agent_registry.py +245 -730
  35. package/services/claude_md_sync.py +320 -4
  36. package/services/consolidation.py +417 -0
  37. package/services/curator.py +1606 -0
  38. package/services/database.py +4118 -2485
  39. package/services/embedding_pipeline.py +262 -0
  40. package/services/embeddings.py +493 -85
  41. package/services/memory_decay.py +408 -0
  42. package/services/native_memory_paths.py +86 -0
  43. package/services/native_memory_sync.py +496 -0
  44. package/services/response_manager.py +183 -0
  45. package/services/terminal_ui.py +199 -0
  46. package/services/tier_manager.py +235 -0
  47. package/services/websocket.py +26 -6
  48. package/skills/__init__.py +21 -1
  49. package/skills/confidence_tracker.py +441 -0
  50. package/skills/context.py +675 -0
  51. package/skills/curator.py +348 -0
  52. package/skills/search.py +444 -213
  53. package/skills/session_review.py +605 -0
  54. package/skills/store.py +484 -179
  55. package/terminal_dashboard.py +474 -0
  56. package/update_system.py +829 -817
  57. package/hooks/__pycache__/auto-detect-response.cpython-312.pyc +0 -0
  58. package/hooks/__pycache__/auto_capture.cpython-312.pyc +0 -0
  59. package/hooks/__pycache__/session_end.cpython-312.pyc +0 -0
  60. package/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
  61. package/services/__pycache__/__init__.cpython-312.pyc +0 -0
  62. package/services/__pycache__/agent_registry.cpython-312.pyc +0 -0
  63. package/services/__pycache__/auth.cpython-312.pyc +0 -0
  64. package/services/__pycache__/auto_inject.cpython-312.pyc +0 -0
  65. package/services/__pycache__/claude_md_sync.cpython-312.pyc +0 -0
  66. package/services/__pycache__/cleanup.cpython-312.pyc +0 -0
  67. package/services/__pycache__/compaction_flush.cpython-312.pyc +0 -0
  68. package/services/__pycache__/confidence.cpython-312.pyc +0 -0
  69. package/services/__pycache__/daily_log.cpython-312.pyc +0 -0
  70. package/services/__pycache__/database.cpython-312.pyc +0 -0
  71. package/services/__pycache__/embeddings.cpython-312.pyc +0 -0
  72. package/services/__pycache__/insights.cpython-312.pyc +0 -0
  73. package/services/__pycache__/llm_analyzer.cpython-312.pyc +0 -0
  74. package/services/__pycache__/memory_md_sync.cpython-312.pyc +0 -0
  75. package/services/__pycache__/retry_queue.cpython-312.pyc +0 -0
  76. package/services/__pycache__/timeline.cpython-312.pyc +0 -0
  77. package/services/__pycache__/vector_index.cpython-312.pyc +0 -0
  78. package/services/__pycache__/websocket.cpython-312.pyc +0 -0
  79. package/skills/__pycache__/__init__.cpython-312.pyc +0 -0
  80. package/skills/__pycache__/admin.cpython-312.pyc +0 -0
  81. package/skills/__pycache__/checkpoint.cpython-312.pyc +0 -0
  82. package/skills/__pycache__/claude_md.cpython-312.pyc +0 -0
  83. package/skills/__pycache__/cleanup.cpython-312.pyc +0 -0
  84. package/skills/__pycache__/grounding.cpython-312.pyc +0 -0
  85. package/skills/__pycache__/insights.cpython-312.pyc +0 -0
  86. package/skills/__pycache__/natural_language.cpython-312.pyc +0 -0
  87. package/skills/__pycache__/retrieve.cpython-312.pyc +0 -0
  88. package/skills/__pycache__/search.cpython-312.pyc +0 -0
  89. package/skills/__pycache__/state.cpython-312.pyc +0 -0
  90. package/skills/__pycache__/store.cpython-312.pyc +0 -0
  91. package/skills/__pycache__/summarize.cpython-312.pyc +0 -0
  92. package/skills/__pycache__/timeline.cpython-312.pyc +0 -0
  93. package/skills/__pycache__/verification.cpython-312.pyc +0 -0
  94. package/test_automation.py +0 -221
  95. package/test_complete.py +0 -338
  96. package/test_full.py +0 -322
  97. 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
+