claude-memory-agent 3.0.3 → 3.1.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.
@@ -1,16 +1,24 @@
1
1
  """
2
- Agent Registry - Loads agent/MCP/hook catalogs from JSON data file.
2
+ Agent Registry - Discovers real agents from disk + loads MCP/hook catalogs.
3
3
 
4
- Static display metadata lives in agent_catalog.json (~880 entries).
5
- Dynamic loading (hooks, MCPs) reads Claude Code settings files at runtime
6
- to determine which entries are truly configured.
4
+ Agents are discovered from ~/.claude/agents/ (global) and
5
+ <project>/.claude/agents/ (per-project) by scanning .md files with YAML
6
+ frontmatter. Enabled agents live in the root; disabled agents live in
7
+ _disabled/<category>/ subdirectories.
8
+
9
+ MCP and hook metadata still comes from agent_catalog.json; dynamic loading
10
+ reads Claude Code settings files at runtime to check configured status.
7
11
  """
8
12
 
9
13
  import json
10
14
  import logging
15
+ import re
16
+ import shutil
11
17
  from pathlib import Path
12
18
  from typing import List, Dict, Any, Optional
13
19
 
20
+ import yaml
21
+
14
22
  logger = logging.getLogger(__name__)
15
23
 
16
24
  # ---------------------------------------------------------------------------
@@ -24,8 +32,11 @@ _GLOBAL_SETTINGS_PATH = _HOME / ".claude" / "settings.json"
24
32
  _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
25
33
  _LOCAL_SETTINGS_PATH = _PROJECT_ROOT / ".claude" / "settings.local.json"
26
34
 
35
+ # Global agents directory
36
+ _GLOBAL_AGENTS_DIR = _HOME / ".claude" / "agents"
37
+
27
38
  # ---------------------------------------------------------------------------
28
- # Load static catalog from JSON
39
+ # Load static catalog from JSON (MCPs + hooks only now)
29
40
  # ---------------------------------------------------------------------------
30
41
  _CATALOG_PATH = Path(__file__).resolve().parent / "agent_catalog.json"
31
42
 
@@ -42,11 +53,249 @@ def _load_catalog() -> dict:
42
53
 
43
54
  _catalog = _load_catalog()
44
55
 
56
+ # Categories are now auto-generated from discovered agents, but keep catalog
57
+ # ones as fallback for MCPs/hooks
45
58
  AGENT_CATEGORIES = _catalog.get("categories", {})
46
- AVAILABLE_AGENTS = _catalog.get("agents", [])
47
59
  AVAILABLE_MCPS = _catalog.get("mcps", [])
48
60
  AVAILABLE_HOOKS = _catalog.get("hooks", [])
49
61
 
62
+ # ---------------------------------------------------------------------------
63
+ # Category metadata (colors + icons for known categories)
64
+ # ---------------------------------------------------------------------------
65
+ _CATEGORY_META = {
66
+ "design": {"name": "Design", "color": "#f778ba", "icon": "palette"},
67
+ "engineering": {"name": "Engineering", "color": "#58a6ff", "icon": "code"},
68
+ "marketing": {"name": "Marketing", "color": "#f0883e", "icon": "bullhorn"},
69
+ "product": {"name": "Product", "color": "#a371f7", "icon": "lightbulb"},
70
+ "project-management": {"name": "Project Management", "color": "#3fb950", "icon": "tasks"},
71
+ "spatial-computing": {"name": "Spatial Computing", "color": "#79c0ff", "icon": "cube"},
72
+ "specialized": {"name": "Specialized", "color": "#d2a8ff", "icon": "star"},
73
+ "support": {"name": "Support", "color": "#56d364", "icon": "headset"},
74
+ "testing": {"name": "Testing", "color": "#e3b341", "icon": "vial"},
75
+ }
76
+
77
+ # Color palette for agent frontmatter colors
78
+ _COLOR_MAP = {
79
+ "blue": "#58a6ff",
80
+ "green": "#3fb950",
81
+ "red": "#f85149",
82
+ "purple": "#a371f7",
83
+ "orange": "#f0883e",
84
+ "yellow": "#e3b341",
85
+ "pink": "#f778ba",
86
+ "cyan": "#79c0ff",
87
+ "teal": "#2ea043",
88
+ }
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Agent discovery from disk
93
+ # ---------------------------------------------------------------------------
94
+ def _parse_frontmatter(file_path: Path) -> Optional[Dict[str, Any]]:
95
+ """Parse YAML frontmatter from an agent .md file."""
96
+ try:
97
+ text = file_path.read_text(encoding="utf-8")
98
+ except Exception as e:
99
+ logger.warning(f"Could not read {file_path}: {e}")
100
+ return None
101
+
102
+ # Match --- delimited frontmatter
103
+ match = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL)
104
+ if not match:
105
+ return None
106
+
107
+ try:
108
+ data = yaml.safe_load(match.group(1))
109
+ if not isinstance(data, dict):
110
+ return None
111
+ return data
112
+ except yaml.YAMLError as e:
113
+ logger.warning(f"Invalid YAML frontmatter in {file_path}: {e}")
114
+ return None
115
+
116
+
117
+ def _infer_category(filename_stem: str) -> str:
118
+ """Infer category from filename prefix like 'engineering-senior-developer'."""
119
+ known = [
120
+ "design", "engineering", "marketing", "product",
121
+ "project-management", "spatial-computing", "specialized",
122
+ "support", "testing",
123
+ ]
124
+ for cat in known:
125
+ if filename_stem.startswith(cat):
126
+ return cat
127
+ # Try first word as category
128
+ parts = filename_stem.split("-")
129
+ if parts:
130
+ return parts[0]
131
+ return "uncategorized"
132
+
133
+
134
+ def _resolve_color(color_str: Optional[str]) -> str:
135
+ """Resolve a color name or hex value to a hex color."""
136
+ if not color_str:
137
+ return "#8b949e"
138
+ if color_str.startswith("#"):
139
+ return color_str
140
+ return _COLOR_MAP.get(color_str.lower(), "#8b949e")
141
+
142
+
143
+ def _parse_agent_file(
144
+ file_path: Path,
145
+ enabled: bool,
146
+ scope: str,
147
+ category: Optional[str] = None,
148
+ base_dir: Optional[Path] = None,
149
+ ) -> Optional[Dict[str, Any]]:
150
+ """Parse a single agent .md file into an agent dict."""
151
+ fm = _parse_frontmatter(file_path)
152
+ if fm is None:
153
+ # Minimal entry for files without valid frontmatter
154
+ fm = {}
155
+
156
+ stem = file_path.stem
157
+ agent_id = stem
158
+
159
+ name = fm.get("name", stem.replace("-", " ").replace("_", " ").title())
160
+ description = fm.get("description", "").replace("\\n", " ").strip()
161
+ color = _resolve_color(fm.get("color"))
162
+
163
+ if not category:
164
+ category = _infer_category(stem)
165
+
166
+ return {
167
+ "id": agent_id,
168
+ "name": name,
169
+ "description": description,
170
+ "color": color,
171
+ "category": category,
172
+ "enabled": enabled,
173
+ "scope": scope,
174
+ "file_path": str(file_path),
175
+ "filename": file_path.name,
176
+ "base_dir": str(base_dir or file_path.parent),
177
+ }
178
+
179
+
180
+ def _scan_agent_dir(base_dir: Path, scope: str) -> List[Dict[str, Any]]:
181
+ """Scan an agents directory for enabled and disabled agents."""
182
+ agents = []
183
+ if not base_dir.exists():
184
+ return agents
185
+
186
+ # Enabled agents: .md files directly in base_dir
187
+ for f in sorted(base_dir.glob("*.md")):
188
+ if f.name.lower() in ("readme.md", "readme.txt"):
189
+ continue
190
+ agent = _parse_agent_file(
191
+ f, enabled=True, scope=scope, base_dir=base_dir
192
+ )
193
+ if agent:
194
+ agents.append(agent)
195
+
196
+ # Disabled agents: .md files in _disabled/ subdirectories
197
+ disabled_dir = base_dir / "_disabled"
198
+ if disabled_dir.exists():
199
+ for f in sorted(disabled_dir.rglob("*.md")):
200
+ if f.name.lower() in ("readme.md", "readme.txt"):
201
+ continue
202
+ # Category = parent dir name if it's a subdirectory of _disabled
203
+ if f.parent != disabled_dir:
204
+ category = f.parent.name
205
+ else:
206
+ category = _infer_category(f.stem)
207
+ agent = _parse_agent_file(
208
+ f, enabled=False, scope=scope,
209
+ category=category, base_dir=base_dir,
210
+ )
211
+ if agent:
212
+ agents.append(agent)
213
+
214
+ return agents
215
+
216
+
217
+ def discover_agents(project_path: Optional[str] = None) -> List[Dict[str, Any]]:
218
+ """Discover all agents from global and project directories.
219
+
220
+ Returns a list of agent dicts with id, name, description, color,
221
+ category, enabled, scope, file_path, filename, base_dir.
222
+ """
223
+ agents = []
224
+
225
+ # 1. Global agents: ~/.claude/agents/
226
+ agents += _scan_agent_dir(_GLOBAL_AGENTS_DIR, scope="global")
227
+
228
+ # 2. Project agents: <project>/.claude/agents/
229
+ if project_path:
230
+ project_dir = Path(project_path) / ".claude" / "agents"
231
+ agents += _scan_agent_dir(project_dir, scope="project")
232
+
233
+ return agents
234
+
235
+
236
+ def discover_categories(agents: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
237
+ """Build categories dict from discovered agents."""
238
+ cats: Dict[str, Dict[str, Any]] = {}
239
+ for agent in agents:
240
+ cat_key = agent["category"]
241
+ if cat_key not in cats:
242
+ meta = _CATEGORY_META.get(cat_key, {
243
+ "name": cat_key.replace("-", " ").replace("_", " ").title(),
244
+ "color": "#8b949e",
245
+ "icon": "circle",
246
+ })
247
+ cats[cat_key] = meta
248
+ return cats
249
+
250
+
251
+ def find_agent_by_id(
252
+ agent_id: str, project_path: Optional[str] = None
253
+ ) -> Optional[Dict[str, Any]]:
254
+ """Find a specific agent by ID across all directories."""
255
+ for agent in discover_agents(project_path):
256
+ if agent["id"] == agent_id:
257
+ return agent
258
+ return None
259
+
260
+
261
+ def toggle_agent(agent_id: str, enabled: bool, project_path: Optional[str] = None) -> Dict[str, Any]:
262
+ """Toggle an agent between enabled and disabled state.
263
+
264
+ - Enable: move from _disabled/<category>/ to agents/ root
265
+ - Disable: move from agents/ root to _disabled/<category>/
266
+
267
+ Returns the updated agent dict.
268
+ """
269
+ agent = find_agent_by_id(agent_id, project_path)
270
+ if not agent:
271
+ raise FileNotFoundError(f"Agent '{agent_id}' not found")
272
+
273
+ src = Path(agent["file_path"])
274
+ base = Path(agent["base_dir"])
275
+
276
+ if enabled and not agent["enabled"]:
277
+ # Move from _disabled/<category>/ to agents/ root
278
+ dest = base / agent["filename"]
279
+ dest.parent.mkdir(parents=True, exist_ok=True)
280
+ shutil.move(str(src), str(dest))
281
+ agent["file_path"] = str(dest)
282
+ agent["enabled"] = True
283
+ elif not enabled and agent["enabled"]:
284
+ # Move from agents/ root to _disabled/<category>/
285
+ category = agent["category"]
286
+ category_dir = base / "_disabled" / category
287
+ category_dir.mkdir(parents=True, exist_ok=True)
288
+ dest = category_dir / agent["filename"]
289
+ shutil.move(str(src), str(dest))
290
+ agent["file_path"] = str(dest)
291
+ agent["enabled"] = False
292
+
293
+ return agent
294
+
295
+
296
+ # Backward-compatible: AVAILABLE_AGENTS as lazy discovery result
297
+ AVAILABLE_AGENTS = discover_agents()
298
+
50
299
 
51
300
  # ---------------------------------------------------------------------------
52
301
  # Helper: derive a hook ID from its command string
@@ -249,10 +498,12 @@ def load_configured_mcps(
249
498
  # Helper functions (unchanged interface)
250
499
  # ---------------------------------------------------------------------------
251
500
 
252
- def get_agents_by_category():
501
+ def get_agents_by_category(agents: Optional[List[Dict[str, Any]]] = None):
253
502
  """Group agents by category."""
503
+ if agents is None:
504
+ agents = discover_agents()
254
505
  result = {}
255
- for agent in AVAILABLE_AGENTS:
506
+ for agent in agents:
256
507
  cat = agent["category"]
257
508
  if cat not in result:
258
509
  result[cat] = []
@@ -262,7 +513,4 @@ def get_agents_by_category():
262
513
 
263
514
  def get_agent_by_id(agent_id: str):
264
515
  """Get agent by ID."""
265
- for agent in AVAILABLE_AGENTS:
266
- if agent["id"] == agent_id:
267
- return agent
268
- return None
516
+ return find_agent_by_id(agent_id)
@@ -1234,6 +1234,43 @@ class DatabaseService:
1234
1234
  cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_activity_project ON session_activity(project_path, timestamp DESC)")
1235
1235
  cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_activity_session ON session_activity(session_id, timestamp DESC)")
1236
1236
 
1237
+ # ============================================================
1238
+ # SOUL LAYER TABLES
1239
+ # ============================================================
1240
+
1241
+ # Soul state - persistent synthesized understanding per project
1242
+ cursor.execute("""
1243
+ CREATE TABLE IF NOT EXISTS soul_state (
1244
+ project_path TEXT PRIMARY KEY,
1245
+ soul_brief TEXT DEFAULT '',
1246
+ user_model TEXT DEFAULT '{}',
1247
+ project_understanding TEXT DEFAULT '{}',
1248
+ success_journal TEXT DEFAULT '[]',
1249
+ blind_spots TEXT DEFAULT '[]',
1250
+ tool_preferences TEXT DEFAULT '{}',
1251
+ last_integrated_at TEXT,
1252
+ integration_count INTEGER DEFAULT 0,
1253
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
1254
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
1255
+ )
1256
+ """)
1257
+
1258
+ # Soul fragments - staging area for session observations
1259
+ cursor.execute("""
1260
+ CREATE TABLE IF NOT EXISTS soul_fragments (
1261
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1262
+ session_id TEXT NOT NULL,
1263
+ project_path TEXT NOT NULL,
1264
+ fragment_type TEXT NOT NULL,
1265
+ content TEXT NOT NULL,
1266
+ raw_source TEXT DEFAULT '',
1267
+ captured_at TEXT DEFAULT CURRENT_TIMESTAMP,
1268
+ integrated BOOLEAN DEFAULT 0
1269
+ )
1270
+ """)
1271
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_soul_fragments_session ON soul_fragments(session_id)")
1272
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_soul_fragments_project ON soul_fragments(project_path, integrated)")
1273
+
1237
1274
  self.conn.commit()
1238
1275
 
1239
1276
  def _serialize_embedding(self, embedding: List[float]) -> str:
@@ -3347,6 +3384,155 @@ class DatabaseService:
3347
3384
  for row in cursor.fetchall()
3348
3385
  ]
3349
3386
 
3387
+ # ============================================================
3388
+ # SOUL LAYER METHODS
3389
+ # ============================================================
3390
+
3391
+ async def get_soul_state(self, project_path: str) -> Optional[Dict[str, Any]]:
3392
+ """Get the soul state for a project. Returns None if no state exists."""
3393
+ try:
3394
+ with self.get_connection() as conn:
3395
+ cursor = conn.cursor()
3396
+ cursor.execute(
3397
+ "SELECT * FROM soul_state WHERE project_path = ?",
3398
+ (project_path,)
3399
+ )
3400
+ row = cursor.fetchone()
3401
+ return dict(row) if row else None
3402
+ except Exception as e:
3403
+ logger.warning(f"Failed to get soul state: {e}")
3404
+ return None
3405
+
3406
+ async def upsert_soul_state(self, project_path: str, updates: Dict[str, Any]) -> bool:
3407
+ """Create or update the soul state for a project.
3408
+
3409
+ Args:
3410
+ project_path: Project path (primary key)
3411
+ updates: Dict of column->value pairs to set
3412
+ """
3413
+ try:
3414
+ with self.get_connection() as conn:
3415
+ cursor = conn.cursor()
3416
+ # Check if exists
3417
+ cursor.execute(
3418
+ "SELECT 1 FROM soul_state WHERE project_path = ?",
3419
+ (project_path,)
3420
+ )
3421
+ exists = cursor.fetchone() is not None
3422
+
3423
+ if exists:
3424
+ # Build UPDATE dynamically from updates dict
3425
+ allowed_cols = {
3426
+ 'soul_brief', 'user_model', 'project_understanding',
3427
+ 'success_journal', 'blind_spots', 'tool_preferences',
3428
+ 'last_integrated_at', 'integration_count'
3429
+ }
3430
+ set_parts = []
3431
+ values = []
3432
+ for col, val in updates.items():
3433
+ if col in allowed_cols:
3434
+ set_parts.append(f"{col} = ?")
3435
+ values.append(val)
3436
+ if not set_parts:
3437
+ return True
3438
+ set_parts.append("updated_at = CURRENT_TIMESTAMP")
3439
+ values.append(project_path)
3440
+ sql = f"UPDATE soul_state SET {', '.join(set_parts)} WHERE project_path = ?"
3441
+ cursor.execute(sql, values)
3442
+ else:
3443
+ # INSERT with defaults + any provided updates
3444
+ cols = ['project_path']
3445
+ vals = [project_path]
3446
+ allowed_cols = {
3447
+ 'soul_brief', 'user_model', 'project_understanding',
3448
+ 'success_journal', 'blind_spots', 'tool_preferences',
3449
+ 'last_integrated_at', 'integration_count'
3450
+ }
3451
+ for col, val in updates.items():
3452
+ if col in allowed_cols:
3453
+ cols.append(col)
3454
+ vals.append(val)
3455
+ placeholders = ', '.join(['?'] * len(cols))
3456
+ sql = f"INSERT INTO soul_state ({', '.join(cols)}) VALUES ({placeholders})"
3457
+ cursor.execute(sql, vals)
3458
+
3459
+ conn.commit()
3460
+ return True
3461
+ except Exception as e:
3462
+ logger.error(f"Failed to upsert soul state: {e}")
3463
+ return False
3464
+
3465
+ async def insert_soul_fragment(
3466
+ self, session_id: str, project_path: str,
3467
+ fragment_type: str, content: str, raw_source: str = ""
3468
+ ) -> Optional[int]:
3469
+ """Insert a soul fragment into the staging table."""
3470
+ try:
3471
+ with self.get_connection() as conn:
3472
+ cursor = conn.cursor()
3473
+ cursor.execute(
3474
+ """INSERT INTO soul_fragments
3475
+ (session_id, project_path, fragment_type, content, raw_source)
3476
+ VALUES (?, ?, ?, ?, ?)""",
3477
+ (session_id, project_path, fragment_type, content, raw_source)
3478
+ )
3479
+ conn.commit()
3480
+ return cursor.lastrowid
3481
+ except Exception as e:
3482
+ logger.warning(f"Failed to insert soul fragment: {e}")
3483
+ return None
3484
+
3485
+ async def get_session_fragments(
3486
+ self, session_id: str, integrated: Optional[bool] = None
3487
+ ) -> List[Dict[str, Any]]:
3488
+ """Get all soul fragments for a session."""
3489
+ try:
3490
+ with self.get_connection() as conn:
3491
+ cursor = conn.cursor()
3492
+ if integrated is not None:
3493
+ cursor.execute(
3494
+ "SELECT * FROM soul_fragments WHERE session_id = ? AND integrated = ? ORDER BY captured_at",
3495
+ (session_id, int(integrated))
3496
+ )
3497
+ else:
3498
+ cursor.execute(
3499
+ "SELECT * FROM soul_fragments WHERE session_id = ? ORDER BY captured_at",
3500
+ (session_id,)
3501
+ )
3502
+ return [dict(row) for row in cursor.fetchall()]
3503
+ except Exception as e:
3504
+ logger.warning(f"Failed to get session fragments: {e}")
3505
+ return []
3506
+
3507
+ async def get_unintegrated_fragments(self, project_path: str) -> List[Dict[str, Any]]:
3508
+ """Get all unintegrated soul fragments for a project."""
3509
+ try:
3510
+ with self.get_connection() as conn:
3511
+ cursor = conn.cursor()
3512
+ cursor.execute(
3513
+ "SELECT * FROM soul_fragments WHERE project_path = ? AND integrated = 0 ORDER BY captured_at",
3514
+ (project_path,)
3515
+ )
3516
+ return [dict(row) for row in cursor.fetchall()]
3517
+ except Exception as e:
3518
+ logger.warning(f"Failed to get unintegrated fragments: {e}")
3519
+ return []
3520
+
3521
+ async def mark_fragments_integrated(self, session_id: str) -> int:
3522
+ """Mark all fragments for a session as integrated. Returns count."""
3523
+ try:
3524
+ with self.get_connection() as conn:
3525
+ cursor = conn.cursor()
3526
+ cursor.execute(
3527
+ "UPDATE soul_fragments SET integrated = 1 WHERE session_id = ? AND integrated = 0",
3528
+ (session_id,)
3529
+ )
3530
+ conn.commit()
3531
+ return cursor.rowcount
3532
+ except Exception as e:
3533
+ logger.warning(f"Failed to mark fragments integrated: {e}")
3534
+ return 0
3535
+
3350
3536
  # ============================================================
3351
3537
  # GENERIC QUERY METHOD
3352
3538
  # ============================================================