claude-memory-agent 2.1.0 → 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 (91) hide show
  1. package/bin/cli.js +11 -1
  2. package/bin/lib/banner.js +39 -0
  3. package/bin/lib/environment.js +166 -0
  4. package/bin/lib/installer.js +291 -0
  5. package/bin/lib/models.js +95 -0
  6. package/bin/lib/steps/advanced.js +101 -0
  7. package/bin/lib/steps/confirm.js +87 -0
  8. package/bin/lib/steps/model.js +57 -0
  9. package/bin/lib/steps/provider.js +65 -0
  10. package/bin/lib/steps/scope.js +59 -0
  11. package/bin/lib/steps/server.js +74 -0
  12. package/bin/lib/ui.js +75 -0
  13. package/bin/onboarding.js +164 -0
  14. package/bin/postinstall.js +22 -257
  15. package/config.py +103 -4
  16. package/dashboard.html +697 -27
  17. package/hooks/extract_memories.py +439 -0
  18. package/hooks/pre_compact_hook.py +76 -0
  19. package/hooks/session_end_hook.py +149 -0
  20. package/hooks/stop_hook.py +372 -0
  21. package/install.py +85 -32
  22. package/main.py +1636 -892
  23. package/mcp_server.py +451 -0
  24. package/package.json +14 -3
  25. package/requirements.txt +12 -8
  26. package/services/adaptive_ranker.py +272 -0
  27. package/services/agent_catalog.json +153 -0
  28. package/services/agent_registry.py +245 -730
  29. package/services/claude_md_sync.py +320 -4
  30. package/services/consolidation.py +417 -0
  31. package/services/database.py +586 -105
  32. package/services/embedding_pipeline.py +262 -0
  33. package/services/embeddings.py +493 -85
  34. package/services/memory_decay.py +408 -0
  35. package/services/native_memory_paths.py +86 -0
  36. package/services/native_memory_sync.py +496 -0
  37. package/services/response_manager.py +183 -0
  38. package/services/terminal_ui.py +199 -0
  39. package/services/tier_manager.py +235 -0
  40. package/services/websocket.py +26 -6
  41. package/skills/search.py +136 -61
  42. package/skills/session_review.py +210 -23
  43. package/skills/store.py +125 -18
  44. package/terminal_dashboard.py +474 -0
  45. package/hooks/__pycache__/auto-detect-response.cpython-312.pyc +0 -0
  46. package/hooks/__pycache__/auto_capture.cpython-312.pyc +0 -0
  47. package/hooks/__pycache__/grounding-hook.cpython-312.pyc +0 -0
  48. package/hooks/__pycache__/session_end.cpython-312.pyc +0 -0
  49. package/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
  50. package/services/__pycache__/__init__.cpython-312.pyc +0 -0
  51. package/services/__pycache__/agent_registry.cpython-312.pyc +0 -0
  52. package/services/__pycache__/auth.cpython-312.pyc +0 -0
  53. package/services/__pycache__/auto_inject.cpython-312.pyc +0 -0
  54. package/services/__pycache__/claude_md_sync.cpython-312.pyc +0 -0
  55. package/services/__pycache__/cleanup.cpython-312.pyc +0 -0
  56. package/services/__pycache__/compaction_flush.cpython-312.pyc +0 -0
  57. package/services/__pycache__/confidence.cpython-312.pyc +0 -0
  58. package/services/__pycache__/curator.cpython-312.pyc +0 -0
  59. package/services/__pycache__/daily_log.cpython-312.pyc +0 -0
  60. package/services/__pycache__/database.cpython-312.pyc +0 -0
  61. package/services/__pycache__/embeddings.cpython-312.pyc +0 -0
  62. package/services/__pycache__/insights.cpython-312.pyc +0 -0
  63. package/services/__pycache__/llm_analyzer.cpython-312.pyc +0 -0
  64. package/services/__pycache__/memory_md_sync.cpython-312.pyc +0 -0
  65. package/services/__pycache__/retry_queue.cpython-312.pyc +0 -0
  66. package/services/__pycache__/timeline.cpython-312.pyc +0 -0
  67. package/services/__pycache__/vector_index.cpython-312.pyc +0 -0
  68. package/services/__pycache__/websocket.cpython-312.pyc +0 -0
  69. package/skills/__pycache__/__init__.cpython-312.pyc +0 -0
  70. package/skills/__pycache__/admin.cpython-312.pyc +0 -0
  71. package/skills/__pycache__/checkpoint.cpython-312.pyc +0 -0
  72. package/skills/__pycache__/claude_md.cpython-312.pyc +0 -0
  73. package/skills/__pycache__/cleanup.cpython-312.pyc +0 -0
  74. package/skills/__pycache__/confidence_tracker.cpython-312.pyc +0 -0
  75. package/skills/__pycache__/context.cpython-312.pyc +0 -0
  76. package/skills/__pycache__/curator.cpython-312.pyc +0 -0
  77. package/skills/__pycache__/grounding.cpython-312.pyc +0 -0
  78. package/skills/__pycache__/insights.cpython-312.pyc +0 -0
  79. package/skills/__pycache__/natural_language.cpython-312.pyc +0 -0
  80. package/skills/__pycache__/retrieve.cpython-312.pyc +0 -0
  81. package/skills/__pycache__/search.cpython-312.pyc +0 -0
  82. package/skills/__pycache__/session_review.cpython-312.pyc +0 -0
  83. package/skills/__pycache__/state.cpython-312.pyc +0 -0
  84. package/skills/__pycache__/store.cpython-312.pyc +0 -0
  85. package/skills/__pycache__/summarize.cpython-312.pyc +0 -0
  86. package/skills/__pycache__/timeline.cpython-312.pyc +0 -0
  87. package/skills/__pycache__/verification.cpython-312.pyc +0 -0
  88. package/test_automation.py +0 -221
  89. package/test_complete.py +0 -338
  90. package/test_full.py +0 -322
  91. package/verify_db.py +0 -134
package/skills/search.py CHANGED
@@ -7,85 +7,132 @@ from services.embeddings import EmbeddingService
7
7
  logger = logging.getLogger(__name__)
8
8
 
9
9
 
10
- async def _enrich_with_graph_context(db: DatabaseService, results: list) -> list:
10
+ async def _enrich_with_graph_context(db: DatabaseService, results: list, max_related_items: int = 2) -> list:
11
11
  """
12
- Enrich search results with relationship context.
13
- This helps Claude understand the causal chains and related knowledge.
12
+ Enrich search results with relationship context using batch queries.
13
+
14
+ Uses batch DB queries (IN clauses) instead of per-result sequential queries,
15
+ providing 5-10x speedup on the enrichment phase for typical 10-result sets.
14
16
 
15
17
  Args:
16
18
  db: Database service instance
17
19
  results: List of search results to enrich
20
+ max_related_items: Max related items per relationship type (caps response size)
18
21
 
19
22
  Returns:
20
23
  List of enriched results with graph context added
21
24
  """
25
+ import asyncio
26
+
27
+ if not results:
28
+ return results
29
+
30
+ # Collect all memory IDs that need enrichment
31
+ all_ids = [r.get('id') for r in results if r.get('id')]
32
+ if not all_ids:
33
+ return results
34
+
35
+ # Categorize IDs by type for targeted batch queries
36
+ error_ids = [r['id'] for r in results if r.get('id') and r.get('type') == 'error']
37
+ decision_ids = [r['id'] for r in results if r.get('id') and r.get('type') == 'decision']
38
+ causal_ids = [r['id'] for r in results if r.get('id') and r.get('type') in ('error', 'decision', 'code')]
39
+
40
+ # Run all batch queries in parallel
41
+ batch_tasks = {}
42
+
43
+ # Batch: fixes for errors (incoming 'fixes' relationships)
44
+ if error_ids:
45
+ batch_tasks['fixes'] = db.get_related_memories_batch(error_ids, 'fixes', direction='incoming')
46
+
47
+ # Batch: supports for decisions (incoming 'supports' relationships)
48
+ if decision_ids:
49
+ batch_tasks['supports'] = db.get_related_memories_batch(decision_ids, 'supports', direction='incoming')
50
+ batch_tasks['caused'] = db.get_related_memories_batch(decision_ids, 'caused_by', direction='outgoing')
51
+
52
+ # Batch: contradictions for all results
53
+ batch_tasks['contradictions'] = db.find_contradictions_batch(all_ids)
54
+
55
+ # Causal chains still need per-ID traversal (recursive), but run in parallel
56
+ causal_futures = {}
57
+ for mid in causal_ids:
58
+ causal_futures[mid] = db.get_causal_chain(mid, max_depth=3)
59
+
60
+ # Gather all batch results
61
+ batch_results = {}
62
+ try:
63
+ if batch_tasks:
64
+ keys = list(batch_tasks.keys())
65
+ values = await asyncio.gather(*batch_tasks.values(), return_exceptions=True)
66
+ for k, v in zip(keys, values):
67
+ batch_results[k] = v if not isinstance(v, Exception) else {}
68
+ except Exception as e:
69
+ logger.warning(f"Batch graph enrichment failed: {e}")
70
+ batch_results = {}
71
+
72
+ # Gather causal chain results in parallel
73
+ causal_results = {}
74
+ if causal_futures:
75
+ try:
76
+ keys = list(causal_futures.keys())
77
+ values = await asyncio.gather(*causal_futures.values(), return_exceptions=True)
78
+ for k, v in zip(keys, values):
79
+ causal_results[k] = v if not isinstance(v, Exception) else None
80
+ except Exception as e:
81
+ logger.debug(f"Causal chain batch failed: {e}")
82
+
83
+ # Assemble enriched results
22
84
  enriched = []
85
+ fixes_map = batch_results.get('fixes', {})
86
+ supports_map = batch_results.get('supports', {})
87
+ caused_map = batch_results.get('caused', {})
88
+ contradictions_map = batch_results.get('contradictions', {})
89
+
23
90
  for result in results:
24
91
  memory_id = result.get('id')
25
92
  if not memory_id:
26
93
  enriched.append(result)
27
94
  continue
28
95
 
29
- # Create enriched copy
30
96
  enriched_result = dict(result)
31
-
32
97
  memory_type = result.get('type', '')
33
98
 
34
- # For errors: find what fixes them
35
- if memory_type == 'error':
36
- try:
37
- fixes = await db.get_related_memories(memory_id, 'fixes', direction='incoming', depth=1)
38
- if fixes:
39
- enriched_result['known_fixes'] = [
40
- {'id': f['id'], 'content': f['content'][:200], 'outcome': f.get('outcome')}
41
- for f in fixes
42
- ]
43
- except Exception as e:
44
- logger.debug(f"Failed to get fixes for memory {memory_id}: {e}")
45
-
46
- # For decisions: find rationale and consequences
99
+ # Errors: known fixes
100
+ if memory_type == 'error' and memory_id in fixes_map:
101
+ fixes = fixes_map[memory_id]
102
+ if fixes:
103
+ enriched_result['known_fixes'] = [
104
+ {'id': f['id'], 'content': f['content'][:200], 'outcome': f.get('outcome')}
105
+ for f in fixes[:max_related_items]
106
+ ]
107
+
108
+ # Decisions: rationale and consequences
47
109
  if memory_type == 'decision':
48
- try:
49
- # What supports this decision
50
- supports = await db.get_related_memories(memory_id, 'supports', direction='incoming', depth=1)
51
- if supports:
52
- enriched_result['rationale'] = [
53
- {'id': s['id'], 'content': s['content'][:200]}
54
- for s in supports
55
- ]
56
- except Exception as e:
57
- logger.debug(f"Failed to get supports for memory {memory_id}: {e}")
58
-
59
- try:
60
- # What this decision caused
61
- caused = await db.get_related_memories(memory_id, 'caused_by', direction='outgoing', depth=1)
62
- if caused:
63
- enriched_result['consequences'] = [
64
- {'id': c['id'], 'content': c['content'][:200]}
65
- for c in caused
66
- ]
67
- except Exception as e:
68
- logger.debug(f"Failed to get consequences for memory {memory_id}: {e}")
69
-
70
- # For all types: find contradictions (critical for anti-hallucination)
71
- try:
72
- contradictions = await db.find_contradictions(memory_id)
73
- if contradictions:
74
- enriched_result['contradictions'] = [
110
+ supports = supports_map.get(memory_id, [])
111
+ if supports:
112
+ enriched_result['rationale'] = [
113
+ {'id': s['id'], 'content': s['content'][:200]}
114
+ for s in supports[:max_related_items]
115
+ ]
116
+ caused = caused_map.get(memory_id, [])
117
+ if caused:
118
+ enriched_result['consequences'] = [
75
119
  {'id': c['id'], 'content': c['content'][:200]}
76
- for c in contradictions
120
+ for c in caused[:max_related_items]
77
121
  ]
78
- except Exception as e:
79
- logger.debug(f"Failed to get contradictions for memory {memory_id}: {e}")
80
122
 
81
- # For errors and decisions: include causal chain
82
- if memory_type in ['error', 'decision', 'code']:
83
- try:
84
- chain = await db.get_causal_chain(memory_id, max_depth=3)
85
- if chain and (chain.get('causes') or chain.get('fixes') or chain.get('root_causes')):
86
- enriched_result['causal_chain'] = chain
87
- except Exception as e:
88
- logger.debug(f"Failed to get causal chain for memory {memory_id}: {e}")
123
+ # All types: contradictions
124
+ contradictions = contradictions_map.get(memory_id, [])
125
+ if contradictions:
126
+ enriched_result['contradictions'] = [
127
+ {'id': c['id'], 'content': c['content'][:200]}
128
+ for c in contradictions[:max_related_items]
129
+ ]
130
+
131
+ # Causal chains
132
+ if memory_type in ('error', 'decision', 'code') and memory_id in causal_results:
133
+ chain = causal_results[memory_id]
134
+ if chain and (chain.get('causes') or chain.get('fixes') or chain.get('root_causes')):
135
+ enriched_result['causal_chain'] = chain
89
136
 
90
137
  enriched.append(enriched_result)
91
138
 
@@ -112,7 +159,9 @@ async def semantic_search(
112
159
  current_context: Optional[Dict[str, Any]] = None,
113
160
  auto_detect_context: bool = True,
114
161
  # Graph enrichment
115
- include_graph: bool = True
162
+ include_graph: bool = True,
163
+ # Adaptive ranking
164
+ temperature: Optional[float] = None
116
165
  ) -> Dict[str, Any]:
117
166
  """
118
167
  Search memories using semantic similarity with context filters.
@@ -190,7 +239,9 @@ async def semantic_search(
190
239
  include_superseded=include_superseded,
191
240
  include_unreliable=include_unreliable,
192
241
  outcome_status=outcome_status,
193
- current_context=search_context
242
+ current_context=search_context,
243
+ query_text=query,
244
+ temperature=temperature
194
245
  )
195
246
  else:
196
247
  # Fallback to keyword search
@@ -217,6 +268,18 @@ async def semantic_search(
217
268
  logger.warning(f"Failed to enrich with graph context: {e}")
218
269
  # Continue with unenriched results
219
270
 
271
+ # Promote accessed memories if their tier should be higher (fire-and-forget)
272
+ if results:
273
+ try:
274
+ from services.tier_manager import TierManager
275
+ tier_mgr = TierManager(db)
276
+ for r in results:
277
+ mem_id = r.get('id')
278
+ if mem_id:
279
+ await tier_mgr.promote_on_access(mem_id)
280
+ except Exception as e:
281
+ logger.debug(f"Tier promotion on access failed (non-fatal): {e}")
282
+
220
283
  return {
221
284
  "success": True,
222
285
  "query": query,
@@ -237,7 +300,8 @@ async def semantic_search(
237
300
  },
238
301
  "context_aware": search_context is not None,
239
302
  "detected_context": detected_context,
240
- "threshold": threshold if search_method == "semantic" else None
303
+ "threshold": threshold if search_method == "semantic" else None,
304
+ "temperature": temperature
241
305
  }
242
306
 
243
307
 
@@ -302,7 +366,7 @@ async def get_project_context(
302
366
  embeddings: EmbeddingService,
303
367
  project_path: str,
304
368
  query: Optional[str] = None,
305
- limit: int = 10
369
+ limit: int = 5
306
370
  ) -> Dict[str, Any]:
307
371
  """
308
372
  Get all relevant context for a project.
@@ -314,7 +378,7 @@ async def get_project_context(
314
378
  embeddings: Embedding service instance
315
379
  project_path: Path to the project
316
380
  query: Optional query to filter relevant memories
317
- limit: Max memories to return
381
+ limit: Max memories to return (default reduced to 5 for response size)
318
382
 
319
383
  Returns:
320
384
  Dict with project info and relevant memories
@@ -358,6 +422,17 @@ async def get_project_context(
358
422
  limit=limit
359
423
  )
360
424
 
425
+ # Enrich all result lists with graph context (relationships, contradictions, causal chains)
426
+ try:
427
+ if decisions:
428
+ decisions = await _enrich_with_graph_context(db, decisions)
429
+ if patterns:
430
+ patterns = await _enrich_with_graph_context(db, patterns)
431
+ if relevant:
432
+ relevant = await _enrich_with_graph_context(db, relevant)
433
+ except Exception as e:
434
+ logger.warning(f"Failed to enrich project context with graph: {e}")
435
+
361
436
  return {
362
437
  "success": True,
363
438
  "project": project,
@@ -10,6 +10,36 @@ from services.database import DatabaseService
10
10
  from services.embeddings import EmbeddingService
11
11
 
12
12
 
13
+ async def _get_session_time_window(
14
+ db: DatabaseService,
15
+ session_id: str
16
+ ) -> Optional[Dict[str, str]]:
17
+ """
18
+ Look up a session's time window from the session_state table.
19
+
20
+ Returns dict with 'started_at' and 'ended_at' if found, None otherwise.
21
+ """
22
+ session_row = await db.execute_query(
23
+ """
24
+ SELECT created_at, updated_at, project_path, current_goal
25
+ FROM session_state
26
+ WHERE session_id = ?
27
+ AND session_id NOT LIKE '{%}'
28
+ LIMIT 1
29
+ """,
30
+ [session_id]
31
+ )
32
+ if session_row:
33
+ row = session_row[0]
34
+ return {
35
+ "started_at": row.get("created_at"),
36
+ "ended_at": row.get("updated_at") or row.get("created_at"),
37
+ "project_path": row.get("project_path"),
38
+ "current_goal": row.get("current_goal")
39
+ }
40
+ return None
41
+
42
+
13
43
  async def get_session_memories(
14
44
  db: DatabaseService,
15
45
  session_id: str,
@@ -19,6 +49,12 @@ async def get_session_memories(
19
49
  """
20
50
  Get all memories created in a specific session for review.
21
51
 
52
+ Uses two strategies:
53
+ 1. Direct match: memories WHERE session_id = ? (when memories have session_ids)
54
+ 2. Time-window match: finds the session in session_state and matches memories
55
+ created within that session's time window (fallback for when memories
56
+ lack session_ids)
57
+
22
58
  Args:
23
59
  db: Database service instance
24
60
  session_id: Session identifier to filter memories
@@ -34,7 +70,7 @@ async def get_session_memories(
34
70
  "error": "session_id is required"
35
71
  }
36
72
 
37
- # Get memories for this session
73
+ # Strategy 1: Direct session_id match on memories table
38
74
  memories_query = """
39
75
  SELECT
40
76
  id, type, content, project_path, project_name,
@@ -45,33 +81,58 @@ async def get_session_memories(
45
81
  ORDER BY created_at DESC
46
82
  LIMIT ?
47
83
  """
48
-
49
84
  memories = await db.execute_query(memories_query, [session_id, limit])
50
85
 
86
+ # Strategy 2: Time-window fallback using session_state table
87
+ match_method = "session_id"
88
+ if not memories:
89
+ time_window = await _get_session_time_window(db, session_id)
90
+ if time_window:
91
+ tw_query = """
92
+ SELECT
93
+ id, type, content, project_path, project_name,
94
+ outcome, success, outcome_status, confidence,
95
+ importance, tags, created_at
96
+ FROM memories
97
+ WHERE created_at >= ?
98
+ AND created_at <= ?
99
+ """
100
+ tw_params = [time_window["started_at"], time_window["ended_at"]]
101
+
102
+ # If the session has a project_path, filter by it
103
+ if time_window.get("project_path"):
104
+ tw_query += " AND project_path = ?"
105
+ tw_params.append(time_window["project_path"])
106
+
107
+ tw_query += " ORDER BY created_at DESC LIMIT ?"
108
+ tw_params.append(limit)
109
+
110
+ memories = await db.execute_query(tw_query, tw_params)
111
+ match_method = "time_window"
112
+
51
113
  result = {
52
114
  "success": True,
53
115
  "session_id": session_id,
54
116
  "memories": memories or [],
55
- "memory_count": len(memories) if memories else 0
117
+ "memory_count": len(memories) if memories else 0,
118
+ "match_method": match_method
56
119
  }
57
120
 
58
121
  # Optionally include patterns
59
- if include_patterns:
122
+ if include_patterns and memories:
123
+ min_time = min(m.get("created_at", "") for m in memories)
124
+ max_time = max(m.get("created_at", "") for m in memories)
60
125
  patterns_query = """
61
126
  SELECT
62
127
  id, name, problem_type, solution,
63
128
  success_count, failure_count, created_at
64
129
  FROM patterns
65
- WHERE created_at >= (
66
- SELECT MIN(created_at) FROM memories WHERE session_id = ?
67
- )
68
- AND created_at <= (
69
- SELECT MAX(created_at) FROM memories WHERE session_id = ?
70
- )
130
+ WHERE created_at >= ?
131
+ AND created_at <= ?
71
132
  ORDER BY created_at DESC
72
133
  LIMIT ?
73
134
  """
74
- patterns = await db.execute_query(patterns_query, [session_id, session_id, limit])
135
+ patterns = await db.execute_query(patterns_query, [min_time, max_time, limit])
75
136
  result["patterns"] = patterns or []
76
137
  result["pattern_count"] = len(patterns) if patterns else 0
77
138
 
@@ -319,6 +380,10 @@ async def get_recent_sessions(
319
380
  """
320
381
  Get recent sessions with memory counts for review selection.
321
382
 
383
+ Uses two strategies and merges results:
384
+ 1. Sessions from session_state table (with memory counts via time-window matching)
385
+ 2. Sessions derived from memories table (when memories have session_ids)
386
+
322
387
  Args:
323
388
  db: Database service instance
324
389
  project_path: Optional filter by project
@@ -327,40 +392,162 @@ async def get_recent_sessions(
327
392
  Returns:
328
393
  Dict with session list
329
394
  """
330
- query = """
395
+ seen_session_ids = set()
396
+ all_sessions = []
397
+
398
+ # Strategy 1: Get sessions from session_state table
399
+ # Filter out JSON blob rows (session_id starts with '{') which are state dumps
400
+ state_query = """
401
+ SELECT
402
+ s.session_id,
403
+ s.project_path,
404
+ s.current_goal,
405
+ s.created_at as started_at,
406
+ s.updated_at as ended_at
407
+ FROM session_state s
408
+ WHERE s.session_id NOT LIKE '{%}'
409
+ """
410
+ state_params = []
411
+
412
+ if project_path:
413
+ state_query += " AND s.project_path = ?"
414
+ state_params.append(project_path)
415
+
416
+ state_query += """
417
+ ORDER BY COALESCE(s.updated_at, s.created_at) DESC
418
+ LIMIT ?
419
+ """
420
+ state_params.append(limit * 2) # Fetch extra to account for filtering
421
+
422
+ state_sessions = await db.execute_query(state_query, state_params)
423
+
424
+ for ss in (state_sessions or []):
425
+ sid = ss.get("session_id")
426
+ if not sid or sid in seen_session_ids:
427
+ continue
428
+
429
+ started_at = ss.get("started_at")
430
+ ended_at = ss.get("ended_at") or started_at
431
+ sess_project = ss.get("project_path")
432
+
433
+ # Count memories in this session's time window
434
+ count_query = """
435
+ SELECT
436
+ COUNT(*) as memory_count,
437
+ SUM(CASE WHEN outcome_status = 'success' THEN 1 ELSE 0 END) as success_count,
438
+ SUM(CASE WHEN outcome_status = 'failed' THEN 1 ELSE 0 END) as failed_count,
439
+ SUM(CASE WHEN outcome_status = 'pending' OR outcome_status IS NULL THEN 1 ELSE 0 END) as pending_count,
440
+ AVG(confidence) as avg_confidence,
441
+ MAX(project_path) as project_path,
442
+ MAX(project_name) as project_name
443
+ FROM memories
444
+ WHERE created_at >= ?
445
+ AND created_at <= ?
446
+ """
447
+ count_params = [started_at, ended_at]
448
+
449
+ if sess_project:
450
+ count_query += " AND project_path = ?"
451
+ count_params.append(sess_project)
452
+
453
+ counts = await db.execute_query(count_query, count_params)
454
+ count_row = counts[0] if counts else {}
455
+
456
+ memory_count = count_row.get("memory_count", 0) or 0
457
+
458
+ # Also check if any memories reference this session_id directly
459
+ direct_count_result = await db.execute_query(
460
+ "SELECT COUNT(*) as cnt FROM memories WHERE session_id = ?",
461
+ [sid]
462
+ )
463
+ direct_count = (direct_count_result[0].get("cnt", 0) if direct_count_result else 0) or 0
464
+ memory_count = max(memory_count, direct_count)
465
+
466
+ session_entry = {
467
+ "session_id": sid,
468
+ "memory_count": memory_count,
469
+ "started_at": started_at,
470
+ "ended_at": ended_at,
471
+ "project_path": count_row.get("project_path") or sess_project,
472
+ "project_name": count_row.get("project_name"),
473
+ "current_goal": ss.get("current_goal"),
474
+ "success_count": count_row.get("success_count", 0) or 0,
475
+ "failed_count": count_row.get("failed_count", 0) or 0,
476
+ "pending_count": count_row.get("pending_count", 0) or 0,
477
+ "avg_confidence": count_row.get("avg_confidence", 0.5) or 0.5,
478
+ "source": "session_state"
479
+ }
480
+
481
+ all_sessions.append(session_entry)
482
+ seen_session_ids.add(sid)
483
+
484
+ # Strategy 2: Also get sessions derived from memories table
485
+ # (for when memories have explicit session_ids not in session_state)
486
+ mem_query = """
331
487
  SELECT
332
488
  session_id,
333
489
  COUNT(*) as memory_count,
334
490
  MIN(created_at) as started_at,
335
491
  MAX(created_at) as ended_at,
336
- project_path,
337
- project_name,
492
+ MAX(project_path) as project_path,
493
+ MAX(project_name) as project_name,
338
494
  SUM(CASE WHEN outcome_status = 'success' THEN 1 ELSE 0 END) as success_count,
339
495
  SUM(CASE WHEN outcome_status = 'failed' THEN 1 ELSE 0 END) as failed_count,
340
- SUM(CASE WHEN outcome_status = 'pending' THEN 1 ELSE 0 END) as pending_count,
496
+ SUM(CASE WHEN outcome_status = 'pending' OR outcome_status IS NULL THEN 1 ELSE 0 END) as pending_count,
341
497
  AVG(confidence) as avg_confidence
342
498
  FROM memories
343
499
  WHERE session_id IS NOT NULL
500
+ AND session_id != ''
344
501
  """
345
- params = []
502
+ mem_params = []
346
503
 
347
504
  if project_path:
348
- query += " AND project_path = ?"
349
- params.append(project_path)
505
+ mem_query += " AND project_path = ?"
506
+ mem_params.append(project_path)
350
507
 
351
- query += """
508
+ mem_query += """
352
509
  GROUP BY session_id
353
510
  ORDER BY MAX(created_at) DESC
354
511
  LIMIT ?
355
512
  """
356
- params.append(limit)
513
+ mem_params.append(limit)
514
+
515
+ mem_sessions = await db.execute_query(mem_query, mem_params)
516
+
517
+ for ms in (mem_sessions or []):
518
+ sid = ms.get("session_id")
519
+ if not sid or sid in seen_session_ids:
520
+ continue
521
+
522
+ session_entry = {
523
+ "session_id": sid,
524
+ "memory_count": ms.get("memory_count", 0) or 0,
525
+ "started_at": ms.get("started_at"),
526
+ "ended_at": ms.get("ended_at"),
527
+ "project_path": ms.get("project_path"),
528
+ "project_name": ms.get("project_name"),
529
+ "current_goal": None,
530
+ "success_count": ms.get("success_count", 0) or 0,
531
+ "failed_count": ms.get("failed_count", 0) or 0,
532
+ "pending_count": ms.get("pending_count", 0) or 0,
533
+ "avg_confidence": ms.get("avg_confidence", 0.5) or 0.5,
534
+ "source": "memories"
535
+ }
536
+
537
+ all_sessions.append(session_entry)
538
+ seen_session_ids.add(sid)
357
539
 
358
- sessions = await db.execute_query(query, params)
540
+ # Sort by most recent activity and apply limit
541
+ all_sessions.sort(
542
+ key=lambda s: s.get("ended_at") or s.get("started_at") or "",
543
+ reverse=True
544
+ )
545
+ all_sessions = all_sessions[:limit]
359
546
 
360
547
  return {
361
548
  "success": True,
362
- "sessions": sessions or [],
363
- "count": len(sessions) if sessions else 0
549
+ "sessions": all_sessions,
550
+ "count": len(all_sessions)
364
551
  }
365
552
 
366
553