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/main.py CHANGED
@@ -111,11 +111,15 @@ from services.claude_md_sync import get_claude_md_sync
111
111
  # Cross-session awareness
112
112
  from services.session_awareness import get_session_awareness
113
113
 
114
+ # Soul layer — persistent personality and learning
115
+ from services.soul import SoulService
116
+
114
117
  # Agent registry for dashboard
115
118
  from services.agent_registry import (
116
119
  AVAILABLE_AGENTS, AVAILABLE_MCPS, AVAILABLE_HOOKS,
117
120
  AGENT_CATEGORIES, get_agents_by_category, get_agent_by_id,
118
- load_configured_hooks, load_configured_mcps
121
+ load_configured_hooks, load_configured_mcps,
122
+ discover_agents, discover_categories, find_agent_by_id, toggle_agent
119
123
  )
120
124
 
121
125
  load_dotenv()
@@ -186,6 +190,7 @@ metrics = OperationMetrics()
186
190
 
187
191
  # Initialize services
188
192
  db = DatabaseService()
193
+ soul_service = SoulService(db)
189
194
  from config import config as _cfg
190
195
  embeddings = EmbeddingService(
191
196
  provider_type=_cfg.EMBEDDING_PROVIDER,
@@ -1772,6 +1777,37 @@ async def _handle_session_append_file(query, params, session_id):
1772
1777
  )
1773
1778
 
1774
1779
 
1780
+ # ============================================================
1781
+ # SOUL LAYER SKILL HANDLERS
1782
+ # ============================================================
1783
+
1784
+ async def _handle_soul_brief(query, params, session_id):
1785
+ project_path = params.get("project_path", "")
1786
+ brief = await soul_service.generate_soul_brief(project_path)
1787
+ return {"success": True, "brief": brief}
1788
+
1789
+ async def _handle_soul_capture(query, params, session_id):
1790
+ sid = params.get("session_id") or session_id or ""
1791
+ project_path = params.get("project_path", "")
1792
+ fragment_type = params.get("fragment_type", "")
1793
+ content = params.get("content", "")
1794
+ if not fragment_type or not content:
1795
+ return {"success": False, "error": "fragment_type and content required"}
1796
+ frag_id = await soul_service.capture_soul_fragment(
1797
+ session_id=sid,
1798
+ fragment_type=fragment_type,
1799
+ content=content,
1800
+ project_path=project_path,
1801
+ )
1802
+ return {"success": frag_id is not None, "fragment_id": frag_id}
1803
+
1804
+ async def _handle_soul_integrate(query, params, session_id):
1805
+ sid = params.get("session_id") or session_id or ""
1806
+ project_path = params.get("project_path", "")
1807
+ result = await soul_service.run_soul_integration(sid, project_path)
1808
+ return {"success": True, **result}
1809
+
1810
+
1775
1811
  # ============================================================
1776
1812
  # SKILL DISPATCH TABLE
1777
1813
  # ============================================================
@@ -1928,6 +1964,11 @@ SKILL_DISPATCH = {
1928
1964
  "session_conflicts": _handle_session_conflicts,
1929
1965
  "session_post_activity": _handle_session_post_activity,
1930
1966
  "session_append_file": _handle_session_append_file,
1967
+
1968
+ # Soul Layer
1969
+ "soul_brief": _handle_soul_brief,
1970
+ "soul_capture": _handle_soul_capture,
1971
+ "soul_integrate": _handle_soul_integrate,
1931
1972
  }
1932
1973
 
1933
1974
 
@@ -4362,17 +4403,41 @@ async def api_mark_insight_applied(insight_id: int):
4362
4403
  # ============= Agent Configuration API =============
4363
4404
 
4364
4405
  @app.get("/api/agents")
4365
- async def get_all_agents():
4366
- """Get all available agents with categories."""
4406
+ async def get_all_agents(project_path: Optional[str] = None):
4407
+ """Get all discovered agents from disk with categories."""
4408
+ agents = discover_agents(project_path)
4409
+ categories = discover_categories(agents)
4410
+ enabled_count = sum(1 for a in agents if a["enabled"])
4367
4411
  return {
4368
4412
  "success": True,
4369
- "agents": AVAILABLE_AGENTS,
4370
- "categories": AGENT_CATEGORIES,
4371
- "by_category": get_agents_by_category(),
4372
- "total": len(AVAILABLE_AGENTS)
4413
+ "agents": agents,
4414
+ "categories": categories,
4415
+ "by_category": get_agents_by_category(agents),
4416
+ "total": len(agents),
4417
+ "enabled": enabled_count,
4373
4418
  }
4374
4419
 
4375
4420
 
4421
+ @app.post("/api/agents/{agent_id}/toggle")
4422
+ async def toggle_agent_endpoint(agent_id: str, request: Request):
4423
+ """Toggle an agent between enabled/disabled by moving files on disk."""
4424
+ try:
4425
+ body = await request.json()
4426
+ enabled = body.get("enabled", False)
4427
+ project_path = body.get("project_path")
4428
+
4429
+ agent = toggle_agent(agent_id, enabled, project_path)
4430
+ return {
4431
+ "success": True,
4432
+ "agent": agent,
4433
+ }
4434
+ except FileNotFoundError as e:
4435
+ return {"success": False, "error": str(e)}
4436
+ except Exception as e:
4437
+ logger.error(f"Error toggling agent {agent_id}: {e}")
4438
+ return {"success": False, "error": str(e)}
4439
+
4440
+
4376
4441
  @app.get("/api/mcps")
4377
4442
  async def get_all_mcps():
4378
4443
  """Get all available MCP servers with live configured status."""
@@ -4435,7 +4500,8 @@ async def get_project_config(project_path: str):
4435
4500
  (project_path,)
4436
4501
  )
4437
4502
 
4438
- # Build agent status map (enabled/disabled)
4503
+ # Build agent status map from disk discovery
4504
+ live_agents = discover_agents(project_path)
4439
4505
  agent_status = {}
4440
4506
  for config in (agent_configs or []):
4441
4507
  agent_status[config['agent_id']] = {
@@ -4444,12 +4510,12 @@ async def get_project_config(project_path: str):
4444
4510
  'settings': json.loads(config['settings']) if config['settings'] else {}
4445
4511
  }
4446
4512
 
4447
- # Fill in defaults for unconfigured agents
4448
- for agent in AVAILABLE_AGENTS:
4513
+ # Fill in defaults for agents not in DB config
4514
+ for agent in live_agents:
4449
4515
  if agent['id'] not in agent_status:
4450
4516
  agent_status[agent['id']] = {
4451
- 'enabled': agent['default_enabled'],
4452
- 'priority': agent['priority'],
4517
+ 'enabled': agent['enabled'],
4518
+ 'priority': 5,
4453
4519
  'settings': {}
4454
4520
  }
4455
4521
 
@@ -4502,7 +4568,7 @@ async def get_project_config(project_path: str):
4502
4568
  "hooks": hook_status,
4503
4569
  "stats": {
4504
4570
  "enabled_agents": sum(1 for a in agent_status.values() if a['enabled']),
4505
- "total_agents": len(AVAILABLE_AGENTS),
4571
+ "total_agents": len(live_agents),
4506
4572
  "enabled_mcps": sum(1 for m in mcp_status.values() if m.get('enabled')),
4507
4573
  "total_mcps": len(live_mcps),
4508
4574
  "configured_mcps": sum(1 for m in mcp_status.values() if m.get('configured')),
@@ -5645,6 +5711,60 @@ async def api_post_session_activity(request: Request):
5645
5711
  return {"success": False, "error": str(e)}
5646
5712
 
5647
5713
 
5714
+ # ============= Soul Layer REST Endpoints =============
5715
+
5716
+
5717
+ @app.get("/api/soul/brief")
5718
+ async def api_soul_brief(project_path: str = ""):
5719
+ """Get the soul brief for a project."""
5720
+ try:
5721
+ brief = await soul_service.generate_soul_brief(project_path)
5722
+ return {"success": True, "brief": brief}
5723
+ except Exception as e:
5724
+ logger.error(f"Soul brief failed: {e}")
5725
+ return {"success": False, "error": str(e), "brief": ""}
5726
+
5727
+
5728
+ @app.post("/api/soul/capture")
5729
+ async def api_soul_capture(request: Request):
5730
+ """Capture a soul fragment from a session response."""
5731
+ try:
5732
+ body = await request.json()
5733
+ session_id = body.get("session_id", "")
5734
+ project_path = body.get("project_path", "")
5735
+ fragment_type = body.get("fragment_type", "")
5736
+ content = body.get("content", "")
5737
+
5738
+ if not fragment_type or not content:
5739
+ return {"success": False, "error": "fragment_type and content required"}
5740
+
5741
+ frag_id = await soul_service.capture_soul_fragment(
5742
+ session_id=session_id,
5743
+ fragment_type=fragment_type,
5744
+ content=content,
5745
+ project_path=project_path,
5746
+ )
5747
+ return {"success": frag_id is not None, "fragment_id": frag_id}
5748
+ except Exception as e:
5749
+ logger.error(f"Soul capture failed: {e}")
5750
+ return {"success": False, "error": str(e)}
5751
+
5752
+
5753
+ @app.post("/api/soul/integrate")
5754
+ async def api_soul_integrate(request: Request):
5755
+ """Run soul integration for a session — merges fragments into soul_state."""
5756
+ try:
5757
+ body = await request.json()
5758
+ session_id = body.get("session_id", "")
5759
+ project_path = body.get("project_path", "")
5760
+
5761
+ result = await soul_service.run_soul_integration(session_id, project_path)
5762
+ return {"success": True, **result}
5763
+ except Exception as e:
5764
+ logger.error(f"Soul integration failed: {e}")
5765
+ return {"success": False, "error": str(e)}
5766
+
5767
+
5648
5768
  # ============= Aggregated Grounding Context (v2) =============
5649
5769
 
5650
5770
 
@@ -5810,6 +5930,395 @@ async def api_grounding_context(request: Request):
5810
5930
  }
5811
5931
 
5812
5932
 
5933
+ # ============= Session Recovery System (v3.2.0) =============
5934
+
5935
+
5936
+ @app.post("/api/workflow/capture")
5937
+ async def api_workflow_capture(request: Request):
5938
+ """Capture or upsert a workflow (name + steps + commands).
5939
+
5940
+ If same workflow name exists for this project, increments success_count and merges commands.
5941
+ """
5942
+ try:
5943
+ body = await request.json()
5944
+ name = body.get("name", "").strip()
5945
+ if not name:
5946
+ return {"success": False, "error": "name required"}
5947
+
5948
+ workflow_id = await db.store_workflow_knowledge(
5949
+ name=name,
5950
+ steps=body.get("steps"),
5951
+ commands=body.get("commands"),
5952
+ tags=body.get("tags"),
5953
+ project_path=body.get("project_path") or None,
5954
+ )
5955
+ return {"success": workflow_id > 0, "workflow_id": workflow_id}
5956
+ except Exception as e:
5957
+ logger.error(f"Workflow capture failed: {e}")
5958
+ return {"success": False, "error": str(e)}
5959
+
5960
+
5961
+ @app.post("/api/session/brain-dump")
5962
+ async def api_session_brain_dump(request: Request):
5963
+ """Aggregate full session state for writing to MEMORY.md before compaction.
5964
+
5965
+ Runs parallel queries: session_state, latest checkpoint, recent memories,
5966
+ workflow_knowledge, and soul brief.
5967
+
5968
+ Body:
5969
+ session_id: str
5970
+ project_path: str
5971
+ """
5972
+ try:
5973
+ body = await request.json()
5974
+ except Exception:
5975
+ body = {}
5976
+
5977
+ session_id = body.get("session_id", "")
5978
+ project_path = body.get("project_path", "")
5979
+
5980
+ result = {
5981
+ "success": True,
5982
+ "timestamp": datetime.now().isoformat(),
5983
+ "session_id": session_id,
5984
+ }
5985
+
5986
+ tasks_dict = {}
5987
+
5988
+ # Session state
5989
+ if session_id:
5990
+ async def _get_state():
5991
+ try:
5992
+ cursor = db.conn.cursor()
5993
+ cursor.execute(
5994
+ "SELECT * FROM session_state WHERE session_id = ?",
5995
+ (session_id,)
5996
+ )
5997
+ row = cursor.fetchone()
5998
+ if row:
5999
+ return {
6000
+ "current_goal": row["current_goal"],
6001
+ "entity_registry": json.loads(row["entity_registry"]) if row["entity_registry"] else {},
6002
+ "decisions_summary": row["decisions_summary"],
6003
+ "pending_questions": json.loads(row["pending_questions"]) if row["pending_questions"] else [],
6004
+ }
6005
+ return None
6006
+ except Exception:
6007
+ return None
6008
+ tasks_dict["state"] = _get_state()
6009
+
6010
+ # Latest checkpoint
6011
+ if session_id:
6012
+ tasks_dict["checkpoint"] = db.get_latest_checkpoint(session_id)
6013
+
6014
+ # Recent memories (last 10 high-importance)
6015
+ if project_path:
6016
+ async def _get_recent_memories():
6017
+ try:
6018
+ cursor = db.conn.cursor()
6019
+ cursor.execute(
6020
+ """SELECT content, type, importance, tags, created_at
6021
+ FROM memories
6022
+ WHERE project_path = ? AND importance >= 6
6023
+ ORDER BY created_at DESC LIMIT 10""",
6024
+ (project_path,)
6025
+ )
6026
+ return [dict(r) for r in cursor.fetchall()]
6027
+ except Exception:
6028
+ return []
6029
+ tasks_dict["memories"] = _get_recent_memories()
6030
+
6031
+ # Workflow knowledge
6032
+ tasks_dict["workflows"] = db.get_workflow_knowledge(
6033
+ project_path=project_path, limit=10
6034
+ )
6035
+
6036
+ # Soul brief
6037
+ if project_path:
6038
+ async def _get_soul():
6039
+ try:
6040
+ return await soul_service.generate_soul_brief(project_path)
6041
+ except Exception:
6042
+ return ""
6043
+ tasks_dict["soul"] = _get_soul()
6044
+
6045
+ # Run all in parallel
6046
+ keys = list(tasks_dict.keys())
6047
+ gathered = await asyncio.gather(
6048
+ *[tasks_dict[k] for k in keys],
6049
+ return_exceptions=True,
6050
+ )
6051
+
6052
+ for k, v in zip(keys, gathered):
6053
+ if isinstance(v, Exception):
6054
+ result[k] = None
6055
+ else:
6056
+ result[k] = v
6057
+
6058
+ return result
6059
+
6060
+
6061
+ @app.post("/api/session/resume")
6062
+ async def api_session_resume(request: Request):
6063
+ """Complete catch-up package for resuming a session after context clear.
6064
+
6065
+ Returns: goal, decisions, entity registry, learned workflows,
6066
+ recent memories, and soul brief — all in one call.
6067
+
6068
+ Body:
6069
+ session_id: str (optional — uses latest for project if missing)
6070
+ project_path: str
6071
+ """
6072
+ try:
6073
+ body = await request.json()
6074
+ except Exception:
6075
+ body = {}
6076
+
6077
+ session_id = body.get("session_id", "")
6078
+ project_path = body.get("project_path", "")
6079
+
6080
+ result = {"success": True}
6081
+
6082
+ tasks_dict = {}
6083
+
6084
+ # Session state — try by ID first, fall back to latest for project
6085
+ async def _get_state():
6086
+ try:
6087
+ if session_id:
6088
+ cursor = db.conn.cursor()
6089
+ cursor.execute(
6090
+ "SELECT * FROM session_state WHERE session_id = ?",
6091
+ (session_id,)
6092
+ )
6093
+ row = cursor.fetchone()
6094
+ if row:
6095
+ return {
6096
+ "session_id": row["session_id"],
6097
+ "current_goal": row["current_goal"],
6098
+ "entity_registry": json.loads(row["entity_registry"]) if row["entity_registry"] else {},
6099
+ "decisions_summary": row["decisions_summary"],
6100
+ "pending_questions": json.loads(row["pending_questions"]) if row["pending_questions"] else [],
6101
+ }
6102
+ if project_path:
6103
+ return await db.get_latest_session_for_project(project_path)
6104
+ return None
6105
+ except Exception:
6106
+ return None
6107
+ tasks_dict["session_state"] = _get_state()
6108
+
6109
+ # Latest checkpoint
6110
+ async def _get_checkpoint():
6111
+ try:
6112
+ if session_id:
6113
+ return await db.get_latest_checkpoint(session_id)
6114
+ if project_path:
6115
+ state = await db.get_latest_session_for_project(project_path)
6116
+ if state:
6117
+ return await db.get_latest_checkpoint(state["session_id"])
6118
+ return None
6119
+ except Exception:
6120
+ return None
6121
+ tasks_dict["checkpoint"] = _get_checkpoint()
6122
+
6123
+ # Workflow knowledge
6124
+ tasks_dict["workflows"] = db.get_workflow_knowledge(
6125
+ project_path=project_path, limit=10
6126
+ )
6127
+
6128
+ # Recent high-importance memories
6129
+ async def _get_memories():
6130
+ try:
6131
+ cursor = db.conn.cursor()
6132
+ if project_path:
6133
+ cursor.execute(
6134
+ """SELECT id, content, type, importance, tags, created_at
6135
+ FROM memories
6136
+ WHERE project_path = ? AND importance >= 5
6137
+ ORDER BY importance DESC, created_at DESC
6138
+ LIMIT 15""",
6139
+ (project_path,)
6140
+ )
6141
+ else:
6142
+ cursor.execute(
6143
+ """SELECT id, content, type, importance, tags, created_at
6144
+ FROM memories
6145
+ WHERE importance >= 6
6146
+ ORDER BY importance DESC, created_at DESC
6147
+ LIMIT 10"""
6148
+ )
6149
+ return [dict(r) for r in cursor.fetchall()]
6150
+ except Exception:
6151
+ return []
6152
+ tasks_dict["memories"] = _get_memories()
6153
+
6154
+ # Soul brief
6155
+ async def _get_soul():
6156
+ try:
6157
+ if project_path:
6158
+ return await soul_service.generate_soul_brief(project_path)
6159
+ return ""
6160
+ except Exception:
6161
+ return ""
6162
+ tasks_dict["soul_brief"] = _get_soul()
6163
+
6164
+ # Run all in parallel
6165
+ keys = list(tasks_dict.keys())
6166
+ gathered = await asyncio.gather(
6167
+ *[tasks_dict[k] for k in keys],
6168
+ return_exceptions=True,
6169
+ )
6170
+
6171
+ for k, v in zip(keys, gathered):
6172
+ result[k] = v if not isinstance(v, Exception) else None
6173
+
6174
+ return result
6175
+
6176
+
6177
+ @app.post("/api/grounding-context/rich")
6178
+ async def api_grounding_context_rich(request: Request):
6179
+ """Rich multi-section grounding context for fresh sessions (~500-800 tokens).
6180
+
6181
+ Used by grounding hook when detecting a fresh/resumed session.
6182
+ Includes soul brief, checkpoint summary, workflows, entities, pending items.
6183
+
6184
+ Body:
6185
+ session_id: str
6186
+ project_path: str
6187
+
6188
+ Returns:
6189
+ {"success": true, "context": "[MEM:RESUME] ...", "token_estimate": N}
6190
+ """
6191
+ try:
6192
+ body = await request.json()
6193
+ except Exception:
6194
+ body = {}
6195
+
6196
+ session_id = body.get("session_id", "")
6197
+ project_path = body.get("project_path", "")
6198
+
6199
+ tasks_dict = {}
6200
+
6201
+ # Session state (by ID or latest for project)
6202
+ async def _get_state():
6203
+ try:
6204
+ if session_id:
6205
+ cursor = db.conn.cursor()
6206
+ cursor.execute(
6207
+ "SELECT * FROM session_state WHERE session_id = ?",
6208
+ (session_id,)
6209
+ )
6210
+ row = cursor.fetchone()
6211
+ if row:
6212
+ return {
6213
+ "current_goal": row["current_goal"],
6214
+ "entity_registry": json.loads(row["entity_registry"]) if row["entity_registry"] else {},
6215
+ "decisions_summary": row["decisions_summary"],
6216
+ "pending_questions": json.loads(row["pending_questions"]) if row["pending_questions"] else [],
6217
+ }
6218
+ if project_path:
6219
+ return await db.get_latest_session_for_project(project_path)
6220
+ return None
6221
+ except Exception:
6222
+ return None
6223
+ tasks_dict["state"] = _get_state()
6224
+
6225
+ # Latest checkpoint
6226
+ async def _get_checkpoint():
6227
+ try:
6228
+ if session_id:
6229
+ return await db.get_latest_checkpoint(session_id)
6230
+ if project_path:
6231
+ st = await db.get_latest_session_for_project(project_path)
6232
+ if st:
6233
+ return await db.get_latest_checkpoint(st["session_id"])
6234
+ return None
6235
+ except Exception:
6236
+ return None
6237
+ tasks_dict["checkpoint"] = _get_checkpoint()
6238
+
6239
+ # Workflow knowledge (top 5)
6240
+ tasks_dict["workflows"] = db.get_workflow_knowledge(
6241
+ project_path=project_path, limit=5
6242
+ )
6243
+
6244
+ # Soul brief
6245
+ async def _get_soul():
6246
+ try:
6247
+ if project_path:
6248
+ return await soul_service.generate_soul_brief(project_path)
6249
+ return ""
6250
+ except Exception:
6251
+ return ""
6252
+ tasks_dict["soul"] = _get_soul()
6253
+
6254
+ # Run all in parallel
6255
+ keys = list(tasks_dict.keys())
6256
+ gathered = await asyncio.gather(
6257
+ *[tasks_dict[k] for k in keys],
6258
+ return_exceptions=True,
6259
+ )
6260
+ results = {}
6261
+ for k, v in zip(keys, gathered):
6262
+ results[k] = v if not isinstance(v, Exception) else None
6263
+
6264
+ # -- Build rich context (budget ~550 tokens / 2200 chars) --
6265
+ sections = []
6266
+
6267
+ # Soul brief (cap 100 tokens)
6268
+ soul = results.get("soul") or ""
6269
+ if soul:
6270
+ sections.append(f"## Soul\n{soul[:400]}")
6271
+
6272
+ # Goal & decisions
6273
+ state = results.get("state")
6274
+ if state and isinstance(state, dict):
6275
+ goal = state.get("current_goal", "")
6276
+ if goal:
6277
+ sections.append(f"## Goal\n{goal[:150]}")
6278
+ decisions = state.get("decisions_summary", "")
6279
+ if decisions:
6280
+ sections.append(f"## Decisions\n{decisions[:300]}")
6281
+ pending = state.get("pending_questions") or state.get("pending_items") or []
6282
+ if pending:
6283
+ items = [f"- {p[:60]}" for p in pending[:5]]
6284
+ sections.append(f"## Pending\n" + "\n".join(items))
6285
+ entities = state.get("entity_registry") or {}
6286
+ if entities:
6287
+ ent_lines = [f"- {k}: {v[:40]}" for k, v in list(entities.items())[:8]]
6288
+ sections.append(f"## Entities\n" + "\n".join(ent_lines))
6289
+
6290
+ # Checkpoint summary
6291
+ cp = results.get("checkpoint")
6292
+ if cp and isinstance(cp, dict):
6293
+ summary = cp.get("summary", "")
6294
+ if summary:
6295
+ sections.append(f"## Last Checkpoint\n{summary[:200]}")
6296
+
6297
+ # Workflows
6298
+ workflows = results.get("workflows") or []
6299
+ if workflows:
6300
+ wf_lines = []
6301
+ for wf in workflows[:5]:
6302
+ cmds = wf.get("commands", [])
6303
+ cmd_str = " -> ".join(cmds[:3]) if cmds else ""
6304
+ wf_lines.append(f"- **{wf['name']}**: {cmd_str[:80]}")
6305
+ sections.append(f"## Workflows\n" + "\n".join(wf_lines))
6306
+
6307
+ if sections:
6308
+ context = "[MEM:RESUME]\n" + "\n\n".join(sections)
6309
+ # Hard cap at ~2200 chars to stay within token budget
6310
+ if len(context) > 2200:
6311
+ context = context[:2200] + "\n..."
6312
+ else:
6313
+ context = ""
6314
+
6315
+ return {
6316
+ "success": True,
6317
+ "context": context,
6318
+ "token_estimate": len(context.split()),
6319
+ }
6320
+
6321
+
5813
6322
  if __name__ == "__main__":
5814
6323
  import uvicorn
5815
6324
  uvicorn.run(