claude-memory-agent 3.0.3 → 3.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/mcp_proxy.py CHANGED
@@ -1,16 +1,18 @@
1
1
  """Slim MCP proxy for Claude Memory.
2
2
 
3
- Thin adapter that exposes 3 unified tools over stdio JSON-RPC,
3
+ Thin adapter that exposes 4 unified tools over stdio JSON-RPC,
4
4
  forwarding all work to the HTTP backend (main.py on port 8102).
5
5
 
6
6
  NO embedding model loaded. NO database connection. Just HTTP calls.
7
7
 
8
8
  Tools:
9
- memory_ask - Unified search (replaces memory_search, memory_search_patterns,
10
- memory_context, memory_get_project, memory_active_sessions,
11
- memory_session_catchup)
12
- memory_store - Unified store (replaces memory_store, memory_store_pattern,
13
- memory_store_project)
9
+ memory_ask - Unified search (replaces memory_search, memory_search_patterns,
10
+ memory_context, memory_get_project, memory_active_sessions,
11
+ memory_session_catchup)
12
+ memory_store - Unified store (replaces memory_store, memory_store_pattern,
13
+ memory_store_project)
14
+ memory_resume - Complete catch-up package for session recovery after context
15
+ clear (goal, decisions, entities, workflows, memories, soul)
14
16
  memory_status - Quick stats + project info (replaces memory_stats, memory_dashboard)
15
17
 
16
18
  Usage:
@@ -180,6 +182,12 @@ async def memory_ask(
180
182
  elif label == "sessions":
181
183
  results["active_sessions"] = result.get("sessions", [])
182
184
 
185
+ # -- Soul context enrichment (lightweight DB read) --
186
+ if project_path:
187
+ soul_data = await _rest_get("/api/soul/brief", {"project_path": project_path})
188
+ if soul_data and soul_data.get("brief"):
189
+ results["soul_context"] = {"brief": soul_data["brief"]}
190
+
183
191
  results["success"] = bool(results.get("memories") or results.get("patterns"))
184
192
  return json.dumps(results, default=str)
185
193
 
@@ -262,6 +270,85 @@ async def memory_store(
262
270
  return json.dumps(result or {"error": "Memory agent unavailable"}, default=str)
263
271
 
264
272
 
273
+ @mcp_server.tool()
274
+ async def memory_resume(
275
+ project_path: Optional[str] = None,
276
+ session_id: Optional[str] = None,
277
+ ) -> str:
278
+ """Resume after context clear — get complete catch-up package in one call.
279
+
280
+ Call this when starting a fresh session or after context was compacted.
281
+ Returns: goal, decisions, entity registry, learned workflows,
282
+ recent memories, and soul brief — everything needed to continue working.
283
+
284
+ Args:
285
+ project_path: Project path to resume context for
286
+ session_id: Optional specific session ID (uses latest for project if omitted)
287
+ """
288
+ result = await _rest_post("/api/session/resume", {
289
+ "session_id": session_id or "",
290
+ "project_path": project_path or "",
291
+ })
292
+
293
+ if not result:
294
+ return json.dumps({
295
+ "success": False,
296
+ "error": "Memory agent unavailable - is main.py running on port 8102?",
297
+ })
298
+
299
+ # Format for readability
300
+ output: Dict[str, Any] = {"success": True}
301
+
302
+ # Session state
303
+ state = result.get("session_state")
304
+ if state and isinstance(state, dict):
305
+ output["goal"] = state.get("current_goal", "")
306
+ output["decisions"] = state.get("decisions_summary", "")
307
+ output["entity_registry"] = state.get("entity_registry", {})
308
+ output["pending"] = state.get("pending_questions", [])
309
+
310
+ # Checkpoint
311
+ cp = result.get("checkpoint")
312
+ if cp and isinstance(cp, dict):
313
+ output["checkpoint"] = {
314
+ "summary": cp.get("summary", ""),
315
+ "key_facts": cp.get("key_facts", []),
316
+ "decisions": cp.get("decisions", []),
317
+ }
318
+
319
+ # Workflows
320
+ workflows = result.get("workflows")
321
+ if workflows and isinstance(workflows, list):
322
+ output["workflows"] = [
323
+ {
324
+ "name": wf.get("name", ""),
325
+ "commands": wf.get("commands", []),
326
+ "steps": wf.get("steps", []),
327
+ "success_count": wf.get("success_count", 0),
328
+ }
329
+ for wf in workflows[:10]
330
+ ]
331
+
332
+ # Recent memories (compact)
333
+ memories = result.get("memories")
334
+ if memories and isinstance(memories, list):
335
+ output["recent_memories"] = [
336
+ {
337
+ "content": m.get("content", "")[:200],
338
+ "type": m.get("type", ""),
339
+ "importance": m.get("importance", 5),
340
+ }
341
+ for m in memories[:10]
342
+ ]
343
+
344
+ # Soul brief
345
+ soul = result.get("soul_brief")
346
+ if soul:
347
+ output["soul_brief"] = soul
348
+
349
+ return json.dumps(output, default=str)
350
+
351
+
265
352
  @mcp_server.tool()
266
353
  async def memory_status(
267
354
  project_path: Optional[str] = None,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-memory-agent",
3
- "version": "3.0.3",
4
- "description": "Persistent semantic memory system for Claude Code sessions with anti-hallucination grounding",
3
+ "version": "3.2.0",
4
+ "description": "Persistent semantic memory for Claude Code with session recovery, self-reported learning, soul layer, and anti-hallucination grounding",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
@@ -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)