claude-memory-agent 2.2.2 → 2.2.4

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
@@ -108,6 +108,9 @@ from services.confidence import get_confidence_service
108
108
  # CLAUDE.md sync service
109
109
  from services.claude_md_sync import get_claude_md_sync
110
110
 
111
+ # Cross-session awareness
112
+ from services.session_awareness import get_session_awareness
113
+
111
114
  # Agent registry for dashboard
112
115
  from services.agent_registry import (
113
116
  AVAILABLE_AGENTS, AVAILABLE_MCPS, AVAILABLE_HOOKS,
@@ -293,6 +296,26 @@ async def lifespan(app: FastAPI):
293
296
 
294
297
  consolidation_task = asyncio.create_task(consolidation_loop())
295
298
 
299
+ # Start cross-session stale cleanup loop
300
+ async def session_cleanup_loop():
301
+ """Background: clean up stale/expired sessions."""
302
+ interval = app_config.SESSION_CLEANUP_INTERVAL_SECONDS
303
+ while True:
304
+ await asyncio.sleep(interval)
305
+ try:
306
+ awareness = get_session_awareness(db)
307
+ result = await awareness.cleanup_stale(
308
+ idle_minutes=app_config.SESSION_IDLE_THRESHOLD_MINUTES,
309
+ completed_minutes=app_config.SESSION_COMPLETED_THRESHOLD_MINUTES,
310
+ )
311
+ total = sum(result.values())
312
+ if total > 0:
313
+ logger.info(f"Session cleanup: {result}")
314
+ except Exception as e:
315
+ logger.debug(f"Session cleanup loop error: {e}")
316
+
317
+ session_cleanup_task = asyncio.create_task(session_cleanup_loop())
318
+
296
319
  # Collect DB stats for splash
297
320
  auth_stats = auth_service.get_stats()
298
321
  db_stats = None
@@ -339,7 +362,8 @@ async def lifespan(app: FastAPI):
339
362
  curator_task.cancel()
340
363
  precompute_task.cancel()
341
364
  consolidation_task.cancel()
342
- for task in [queue_task, curator_task, precompute_task, consolidation_task]:
365
+ session_cleanup_task.cancel()
366
+ for task in [queue_task, curator_task, precompute_task, consolidation_task, session_cleanup_task]:
343
367
  try:
344
368
  await task
345
369
  except asyncio.CancelledError:
@@ -1661,6 +1685,93 @@ async def _handle_bulk_review_by_type(query, params, session_id):
1661
1685
  )
1662
1686
 
1663
1687
 
1688
+ # --- Cross-Session Awareness Skills ---
1689
+
1690
+ async def _handle_session_register(query, params, session_id):
1691
+ awareness = get_session_awareness(db)
1692
+ return await awareness.register_session(
1693
+ session_id=session_id or params.get("session_id"),
1694
+ project_path=params.get("project_path", ""),
1695
+ goal=params.get("goal"),
1696
+ label=params.get("label"),
1697
+ )
1698
+
1699
+
1700
+ async def _handle_session_heartbeat(query, params, session_id):
1701
+ awareness = get_session_awareness(db)
1702
+ return await awareness.heartbeat(
1703
+ session_id=session_id or params.get("session_id"),
1704
+ project_path=params.get("project_path", ""),
1705
+ files_modified=params.get("files_modified"),
1706
+ current_goal=params.get("current_goal"),
1707
+ key_decisions=params.get("key_decisions"),
1708
+ summary=params.get("summary"),
1709
+ )
1710
+
1711
+
1712
+ async def _handle_session_deregister(query, params, session_id):
1713
+ awareness = get_session_awareness(db)
1714
+ return await awareness.deregister_session(
1715
+ session_id=session_id or params.get("session_id"),
1716
+ project_path=params.get("project_path", ""),
1717
+ final_summary=params.get("final_summary"),
1718
+ )
1719
+
1720
+
1721
+ async def _handle_get_active_sessions(query, params, session_id):
1722
+ awareness = get_session_awareness(db)
1723
+ sessions = await db.get_active_sessions(
1724
+ project_path=params.get("project_path", ""),
1725
+ exclude_session_id=params.get("exclude_session_id"),
1726
+ )
1727
+ return {"success": True, "sessions": sessions, "count": len(sessions)}
1728
+
1729
+
1730
+ async def _handle_session_activity_feed(query, params, session_id):
1731
+ awareness = get_session_awareness(db)
1732
+ return await awareness.get_activity_feed(
1733
+ project_path=params.get("project_path", ""),
1734
+ limit=params.get("limit", 20),
1735
+ since=params.get("since"),
1736
+ exclude_session_id=params.get("exclude_session_id"),
1737
+ )
1738
+
1739
+
1740
+ async def _handle_session_catchup(query, params, session_id):
1741
+ awareness = get_session_awareness(db)
1742
+ return await awareness.get_catchup(
1743
+ session_id=session_id or params.get("session_id"),
1744
+ project_path=params.get("project_path", ""),
1745
+ since=params.get("since"),
1746
+ )
1747
+
1748
+
1749
+ async def _handle_session_conflicts(query, params, session_id):
1750
+ awareness = get_session_awareness(db)
1751
+ return await awareness.check_conflicts(
1752
+ session_id=session_id or params.get("session_id"),
1753
+ project_path=params.get("project_path", ""),
1754
+ )
1755
+
1756
+
1757
+ async def _handle_session_post_activity(query, params, session_id):
1758
+ awareness = get_session_awareness(db)
1759
+ return await awareness.post_activity(
1760
+ session_id=session_id or params.get("session_id"),
1761
+ project_path=params.get("project_path", ""),
1762
+ event_type=params.get("event_type", "decision"),
1763
+ summary=params.get("summary", ""),
1764
+ files=params.get("files"),
1765
+ )
1766
+
1767
+
1768
+ async def _handle_session_append_file(query, params, session_id):
1769
+ return await db.append_file_modified(
1770
+ session_id=session_id or params.get("session_id"),
1771
+ file_path=params.get("file_path", ""),
1772
+ )
1773
+
1774
+
1664
1775
  # ============================================================
1665
1776
  # SKILL DISPATCH TABLE
1666
1777
  # ============================================================
@@ -1806,6 +1917,17 @@ SKILL_DISPATCH = {
1806
1917
  "suggest_session_reviews": _handle_suggest_session_reviews,
1807
1918
  "get_recent_sessions": _handle_get_recent_sessions,
1808
1919
  "bulk_review_by_type": _handle_bulk_review_by_type,
1920
+
1921
+ # Cross-Session Awareness
1922
+ "session_register": _handle_session_register,
1923
+ "session_heartbeat": _handle_session_heartbeat,
1924
+ "session_deregister": _handle_session_deregister,
1925
+ "get_active_sessions": _handle_get_active_sessions,
1926
+ "session_activity_feed": _handle_session_activity_feed,
1927
+ "session_catchup": _handle_session_catchup,
1928
+ "session_conflicts": _handle_session_conflicts,
1929
+ "session_post_activity": _handle_session_post_activity,
1930
+ "session_append_file": _handle_session_append_file,
1809
1931
  }
1810
1932
 
1811
1933
 
@@ -5230,6 +5352,129 @@ async def embeddings_migrate_binary_endpoint():
5230
5352
  return {"error": str(e)}
5231
5353
 
5232
5354
 
5355
+ # ============================================================
5356
+ # CROSS-SESSION AWARENESS REST API
5357
+ # ============================================================
5358
+
5359
+ @app.post("/api/sessions/register")
5360
+ async def api_session_register(request: Request):
5361
+ """Register an active session."""
5362
+ try:
5363
+ body = await request.json()
5364
+ awareness = get_session_awareness(db)
5365
+ return await awareness.register_session(
5366
+ session_id=body.get("session_id", ""),
5367
+ project_path=body.get("project_path", ""),
5368
+ goal=body.get("goal"),
5369
+ label=body.get("label"),
5370
+ )
5371
+ except Exception as e:
5372
+ logger.error(f"Session register failed: {e}")
5373
+ return {"success": False, "error": str(e)}
5374
+
5375
+
5376
+ @app.post("/api/sessions/heartbeat")
5377
+ async def api_session_heartbeat(request: Request):
5378
+ """Update session heartbeat, return siblings + conflicts."""
5379
+ try:
5380
+ body = await request.json()
5381
+ awareness = get_session_awareness(db)
5382
+ return await awareness.heartbeat(
5383
+ session_id=body.get("session_id", ""),
5384
+ project_path=body.get("project_path", ""),
5385
+ files_modified=body.get("files_modified"),
5386
+ current_goal=body.get("current_goal"),
5387
+ key_decisions=body.get("key_decisions"),
5388
+ summary=body.get("summary"),
5389
+ )
5390
+ except Exception as e:
5391
+ logger.error(f"Session heartbeat failed: {e}")
5392
+ return {"success": False, "error": str(e)}
5393
+
5394
+
5395
+ @app.post("/api/sessions/deregister")
5396
+ async def api_session_deregister(request: Request):
5397
+ """Mark session as completed."""
5398
+ try:
5399
+ body = await request.json()
5400
+ awareness = get_session_awareness(db)
5401
+ return await awareness.deregister_session(
5402
+ session_id=body.get("session_id", ""),
5403
+ project_path=body.get("project_path", ""),
5404
+ final_summary=body.get("final_summary"),
5405
+ )
5406
+ except Exception as e:
5407
+ logger.error(f"Session deregister failed: {e}")
5408
+ return {"success": False, "error": str(e)}
5409
+
5410
+
5411
+ @app.get("/api/sessions/active")
5412
+ async def api_active_sessions(project_path: str = "", exclude_session_id: Optional[str] = None):
5413
+ """List active sessions for a project."""
5414
+ try:
5415
+ sessions = await db.get_active_sessions(project_path, exclude_session_id)
5416
+ return {"success": True, "sessions": sessions, "count": len(sessions)}
5417
+ except Exception as e:
5418
+ logger.error(f"Get active sessions failed: {e}")
5419
+ return {"success": False, "error": str(e)}
5420
+
5421
+
5422
+ @app.get("/api/sessions/activity-feed")
5423
+ async def api_session_activity_feed(
5424
+ project_path: str = "", limit: int = 20,
5425
+ since: Optional[str] = None, exclude_session_id: Optional[str] = None
5426
+ ):
5427
+ """Get recent cross-session activity events."""
5428
+ try:
5429
+ awareness = get_session_awareness(db)
5430
+ return await awareness.get_activity_feed(project_path, limit, since, exclude_session_id)
5431
+ except Exception as e:
5432
+ logger.error(f"Activity feed failed: {e}")
5433
+ return {"success": False, "error": str(e)}
5434
+
5435
+
5436
+ @app.get("/api/sessions/catch-up")
5437
+ async def api_session_catchup(
5438
+ session_id: str = "", project_path: str = "", since: Optional[str] = None
5439
+ ):
5440
+ """What happened since timestamp, grouped by session."""
5441
+ try:
5442
+ awareness = get_session_awareness(db)
5443
+ return await awareness.get_catchup(session_id, project_path, since)
5444
+ except Exception as e:
5445
+ logger.error(f"Session catch-up failed: {e}")
5446
+ return {"success": False, "error": str(e)}
5447
+
5448
+
5449
+ @app.get("/api/sessions/conflicts")
5450
+ async def api_session_conflicts(session_id: str = "", project_path: str = ""):
5451
+ """Check file conflicts for a session."""
5452
+ try:
5453
+ awareness = get_session_awareness(db)
5454
+ return await awareness.check_conflicts(session_id, project_path)
5455
+ except Exception as e:
5456
+ logger.error(f"Session conflicts failed: {e}")
5457
+ return {"success": False, "error": str(e)}
5458
+
5459
+
5460
+ @app.post("/api/sessions/activity")
5461
+ async def api_post_session_activity(request: Request):
5462
+ """Post an event to the cross-session activity feed."""
5463
+ try:
5464
+ body = await request.json()
5465
+ awareness = get_session_awareness(db)
5466
+ return await awareness.post_activity(
5467
+ session_id=body.get("session_id", ""),
5468
+ project_path=body.get("project_path", ""),
5469
+ event_type=body.get("event_type", "decision"),
5470
+ summary=body.get("summary", ""),
5471
+ files=body.get("files"),
5472
+ )
5473
+ except Exception as e:
5474
+ logger.error(f"Post session activity failed: {e}")
5475
+ return {"success": False, "error": str(e)}
5476
+
5477
+
5233
5478
  if __name__ == "__main__":
5234
5479
  import uvicorn
5235
5480
  uvicorn.run(
package/mcp_server.py CHANGED
@@ -445,6 +445,60 @@ async def memory_sync_native(
445
445
  return json.dumps({"success": False, "error": str(e)})
446
446
 
447
447
 
448
+ # ── Cross-Session Awareness Tools ────────────────────────────────────────
449
+
450
+ @mcp_server.tool()
451
+ async def memory_active_sessions(
452
+ ctx: Context,
453
+ project_path: str,
454
+ exclude_session_id: Optional[str] = None,
455
+ ) -> str:
456
+ """List active parallel Claude Code sessions for a project.
457
+
458
+ Use this to see what other sessions are currently working on,
459
+ what files they've modified, and detect potential conflicts.
460
+
461
+ Args:
462
+ project_path: Project path to check
463
+ exclude_session_id: Optional session ID to exclude from results
464
+ """
465
+ app = _get_app(ctx)
466
+ sessions = await app.db.get_active_sessions(project_path, exclude_session_id)
467
+ return json.dumps({
468
+ "success": True,
469
+ "sessions": sessions,
470
+ "count": len(sessions),
471
+ }, default=str)
472
+
473
+
474
+ @mcp_server.tool()
475
+ async def memory_session_catchup(
476
+ ctx: Context,
477
+ project_path: str,
478
+ session_id: Optional[str] = None,
479
+ since: Optional[str] = None,
480
+ ) -> str:
481
+ """Get a catch-up summary of what other sessions did.
482
+
483
+ Returns recent cross-session activity grouped by session,
484
+ including file changes, decisions, and goals.
485
+
486
+ Args:
487
+ project_path: Project path to check
488
+ session_id: Current session ID (to exclude own events)
489
+ since: ISO timestamp to get events after (optional)
490
+ """
491
+ app = _get_app(ctx)
492
+ from services.session_awareness import get_session_awareness
493
+ awareness = get_session_awareness(app.db)
494
+ result = await awareness.get_catchup(
495
+ session_id=session_id or "",
496
+ project_path=project_path,
497
+ since=since,
498
+ )
499
+ return json.dumps(result, default=str)
500
+
501
+
448
502
  # ── Entry Point ─────────────────────────────────────────────────────────
449
503
 
450
504
  if __name__ == "__main__":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-memory-agent",
3
- "version": "2.2.2",
3
+ "version": "2.2.4",
4
4
  "description": "Persistent semantic memory system for Claude Code sessions with anti-hallucination grounding",
5
5
  "keywords": [
6
6
  "claude",
@@ -1193,6 +1193,46 @@ class DatabaseService:
1193
1193
  # Migration: Add last_flush_at column to session_state if it doesn't exist
1194
1194
  safe_add_column("session_state", "last_flush_at", "TEXT")
1195
1195
 
1196
+ # ============================================================
1197
+ # CROSS-SESSION AWARENESS TABLES
1198
+ # ============================================================
1199
+
1200
+ # Active sessions - tracks currently running Claude Code sessions
1201
+ cursor.execute("""
1202
+ CREATE TABLE IF NOT EXISTS active_sessions (
1203
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1204
+ session_id TEXT UNIQUE NOT NULL,
1205
+ project_path TEXT NOT NULL,
1206
+ start_time TEXT DEFAULT (datetime('now')),
1207
+ last_heartbeat TEXT DEFAULT (datetime('now')),
1208
+ current_goal TEXT,
1209
+ status TEXT DEFAULT 'active' CHECK(status IN ('active','idle','completed')),
1210
+ files_modified TEXT DEFAULT '[]',
1211
+ key_decisions TEXT DEFAULT '[]',
1212
+ brief_summary TEXT DEFAULT '',
1213
+ session_label TEXT
1214
+ )
1215
+ """)
1216
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_active_sessions_project ON active_sessions(project_path, status)")
1217
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_active_sessions_heartbeat ON active_sessions(last_heartbeat)")
1218
+
1219
+ # Session activity feed - cross-session event stream
1220
+ cursor.execute("""
1221
+ CREATE TABLE IF NOT EXISTS session_activity (
1222
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1223
+ session_id TEXT NOT NULL,
1224
+ project_path TEXT NOT NULL,
1225
+ event_type TEXT NOT NULL CHECK(event_type IN (
1226
+ 'file_change','decision','error_fix','goal_change','session_start','session_end'
1227
+ )),
1228
+ summary TEXT NOT NULL,
1229
+ files TEXT DEFAULT '[]',
1230
+ timestamp TEXT DEFAULT (datetime('now'))
1231
+ )
1232
+ """)
1233
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_activity_project ON session_activity(project_path, timestamp DESC)")
1234
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_activity_session ON session_activity(session_id, timestamp DESC)")
1235
+
1196
1236
  self.conn.commit()
1197
1237
 
1198
1238
  def _serialize_embedding(self, embedding: List[float]) -> str:
@@ -4116,3 +4156,269 @@ class DatabaseService:
4116
4156
  "most_connected_memories": most_connected,
4117
4157
  "project_path": project_path
4118
4158
  }
4159
+
4160
+ # ============================================================
4161
+ # CROSS-SESSION AWARENESS METHODS
4162
+ # ============================================================
4163
+
4164
+ async def register_active_session(
4165
+ self, session_id: str, project_path: str,
4166
+ goal: Optional[str] = None, label: Optional[str] = None
4167
+ ) -> Dict[str, Any]:
4168
+ """Register a new active session or update if already exists."""
4169
+ project_path = normalize_path(project_path)
4170
+ with self.get_connection() as conn:
4171
+ cursor = conn.cursor()
4172
+ cursor.execute("""
4173
+ INSERT INTO active_sessions (session_id, project_path, current_goal, session_label)
4174
+ VALUES (?, ?, ?, ?)
4175
+ ON CONFLICT(session_id) DO UPDATE SET
4176
+ status = 'active',
4177
+ last_heartbeat = datetime('now'),
4178
+ current_goal = COALESCE(excluded.current_goal, active_sessions.current_goal),
4179
+ session_label = COALESCE(excluded.session_label, active_sessions.session_label)
4180
+ """, (session_id, project_path, goal, label))
4181
+ conn.commit()
4182
+ return {"success": True, "session_id": session_id}
4183
+
4184
+ async def heartbeat_session(
4185
+ self, session_id: str,
4186
+ files_modified: Optional[List[str]] = None,
4187
+ current_goal: Optional[str] = None,
4188
+ key_decisions: Optional[List[str]] = None,
4189
+ summary: Optional[str] = None
4190
+ ) -> Dict[str, Any]:
4191
+ """Update session heartbeat and optional metadata."""
4192
+ with self.get_connection() as conn:
4193
+ cursor = conn.cursor()
4194
+ updates = ["last_heartbeat = datetime('now')", "status = 'active'"]
4195
+ params = []
4196
+ if files_modified is not None:
4197
+ updates.append("files_modified = ?")
4198
+ params.append(json.dumps(files_modified))
4199
+ if current_goal is not None:
4200
+ updates.append("current_goal = ?")
4201
+ params.append(current_goal)
4202
+ if key_decisions is not None:
4203
+ updates.append("key_decisions = ?")
4204
+ params.append(json.dumps(key_decisions))
4205
+ if summary is not None:
4206
+ updates.append("brief_summary = ?")
4207
+ params.append(summary)
4208
+ params.append(session_id)
4209
+ cursor.execute(
4210
+ f"UPDATE active_sessions SET {', '.join(updates)} WHERE session_id = ?",
4211
+ params
4212
+ )
4213
+ conn.commit()
4214
+ return {"success": True, "updated": cursor.rowcount > 0}
4215
+
4216
+ async def deregister_session(self, session_id: str) -> Dict[str, Any]:
4217
+ """Mark a session as completed."""
4218
+ with self.get_connection() as conn:
4219
+ cursor = conn.cursor()
4220
+ cursor.execute(
4221
+ "UPDATE active_sessions SET status = 'completed', last_heartbeat = datetime('now') WHERE session_id = ?",
4222
+ (session_id,)
4223
+ )
4224
+ conn.commit()
4225
+ return {"success": True, "deregistered": cursor.rowcount > 0}
4226
+
4227
+ async def get_active_sessions(
4228
+ self, project_path: str, exclude_session_id: Optional[str] = None
4229
+ ) -> List[Dict[str, Any]]:
4230
+ """Get active/idle sibling sessions for a project."""
4231
+ project_path = normalize_path(project_path)
4232
+ with self.get_connection() as conn:
4233
+ cursor = conn.cursor()
4234
+ if exclude_session_id:
4235
+ cursor.execute("""
4236
+ SELECT session_id, project_path, start_time, last_heartbeat,
4237
+ current_goal, status, files_modified, key_decisions,
4238
+ brief_summary, session_label
4239
+ FROM active_sessions
4240
+ WHERE project_path = ? AND status IN ('active', 'idle')
4241
+ AND session_id != ?
4242
+ ORDER BY last_heartbeat DESC
4243
+ """, (project_path, exclude_session_id))
4244
+ else:
4245
+ cursor.execute("""
4246
+ SELECT session_id, project_path, start_time, last_heartbeat,
4247
+ current_goal, status, files_modified, key_decisions,
4248
+ brief_summary, session_label
4249
+ FROM active_sessions
4250
+ WHERE project_path = ? AND status IN ('active', 'idle')
4251
+ ORDER BY last_heartbeat DESC
4252
+ """, (project_path,))
4253
+ rows = cursor.fetchall()
4254
+ return [
4255
+ {
4256
+ "session_id": r["session_id"],
4257
+ "project_path": r["project_path"],
4258
+ "start_time": r["start_time"],
4259
+ "last_heartbeat": r["last_heartbeat"],
4260
+ "current_goal": r["current_goal"],
4261
+ "status": r["status"],
4262
+ "files_modified": json.loads(r["files_modified"] or "[]"),
4263
+ "key_decisions": json.loads(r["key_decisions"] or "[]"),
4264
+ "brief_summary": r["brief_summary"],
4265
+ "session_label": r["session_label"],
4266
+ }
4267
+ for r in rows
4268
+ ]
4269
+
4270
+ async def post_session_activity(
4271
+ self, session_id: str, project_path: str,
4272
+ event_type: str, summary: str, files: Optional[List[str]] = None
4273
+ ) -> Dict[str, Any]:
4274
+ """Post an event to the cross-session activity feed."""
4275
+ project_path = normalize_path(project_path)
4276
+ with self.get_connection() as conn:
4277
+ cursor = conn.cursor()
4278
+ cursor.execute("""
4279
+ INSERT INTO session_activity (session_id, project_path, event_type, summary, files)
4280
+ VALUES (?, ?, ?, ?, ?)
4281
+ """, (session_id, project_path, event_type, summary, json.dumps(files or [])))
4282
+ conn.commit()
4283
+ return {"success": True, "id": cursor.lastrowid}
4284
+
4285
+ async def get_session_activity_feed(
4286
+ self, project_path: str, limit: int = 20,
4287
+ since: Optional[str] = None, exclude_session_id: Optional[str] = None
4288
+ ) -> List[Dict[str, Any]]:
4289
+ """Get recent cross-session activity events."""
4290
+ project_path = normalize_path(project_path)
4291
+ with self.get_connection() as conn:
4292
+ cursor = conn.cursor()
4293
+ query = "SELECT * FROM session_activity WHERE project_path = ?"
4294
+ params: list = [project_path]
4295
+ if since:
4296
+ query += " AND timestamp > ?"
4297
+ params.append(since)
4298
+ if exclude_session_id:
4299
+ query += " AND session_id != ?"
4300
+ params.append(exclude_session_id)
4301
+ query += " ORDER BY timestamp DESC LIMIT ?"
4302
+ params.append(limit)
4303
+ cursor.execute(query, params)
4304
+ rows = cursor.fetchall()
4305
+ return [
4306
+ {
4307
+ "id": r["id"],
4308
+ "session_id": r["session_id"],
4309
+ "project_path": r["project_path"],
4310
+ "event_type": r["event_type"],
4311
+ "summary": r["summary"],
4312
+ "files": json.loads(r["files"] or "[]"),
4313
+ "timestamp": r["timestamp"],
4314
+ }
4315
+ for r in rows
4316
+ ]
4317
+
4318
+ async def detect_file_conflicts(
4319
+ self, session_id: str, project_path: str
4320
+ ) -> List[Dict[str, Any]]:
4321
+ """Detect file conflicts between this session and active siblings."""
4322
+ project_path = normalize_path(project_path)
4323
+ with self.get_connection() as conn:
4324
+ cursor = conn.cursor()
4325
+ # Get this session's files
4326
+ cursor.execute(
4327
+ "SELECT files_modified FROM active_sessions WHERE session_id = ?",
4328
+ (session_id,)
4329
+ )
4330
+ row = cursor.fetchone()
4331
+ if not row:
4332
+ return []
4333
+ my_files = set(json.loads(row["files_modified"] or "[]"))
4334
+ if not my_files:
4335
+ return []
4336
+
4337
+ # Get sibling sessions' files
4338
+ cursor.execute("""
4339
+ SELECT session_id, files_modified, session_label, current_goal
4340
+ FROM active_sessions
4341
+ WHERE project_path = ? AND status IN ('active', 'idle')
4342
+ AND session_id != ?
4343
+ """, (project_path, session_id))
4344
+ siblings = cursor.fetchall()
4345
+
4346
+ conflicts = []
4347
+ for sib in siblings:
4348
+ sib_files = set(json.loads(sib["files_modified"] or "[]"))
4349
+ overlap = my_files & sib_files
4350
+ if overlap:
4351
+ conflicts.append({
4352
+ "session_id": sib["session_id"],
4353
+ "session_label": sib["session_label"],
4354
+ "current_goal": sib["current_goal"],
4355
+ "conflicting_files": sorted(overlap),
4356
+ })
4357
+ return conflicts
4358
+
4359
+ async def cleanup_stale_sessions(
4360
+ self, idle_minutes: int = 10, completed_minutes: int = 30
4361
+ ) -> Dict[str, Any]:
4362
+ """Mark stale sessions as idle/completed and clean up old activity."""
4363
+ with self.get_connection() as conn:
4364
+ cursor = conn.cursor()
4365
+ # Mark active sessions with no heartbeat as idle
4366
+ cursor.execute("""
4367
+ UPDATE active_sessions SET status = 'idle'
4368
+ WHERE status = 'active'
4369
+ AND last_heartbeat < datetime('now', ? || ' minutes')
4370
+ """, (f"-{idle_minutes}",))
4371
+ marked_idle = cursor.rowcount
4372
+
4373
+ # Mark idle sessions as completed after longer timeout
4374
+ cursor.execute("""
4375
+ UPDATE active_sessions SET status = 'completed'
4376
+ WHERE status = 'idle'
4377
+ AND last_heartbeat < datetime('now', ? || ' minutes')
4378
+ """, (f"-{completed_minutes}",))
4379
+ marked_completed = cursor.rowcount
4380
+
4381
+ # Delete old completed sessions (older than 24h)
4382
+ cursor.execute("""
4383
+ DELETE FROM active_sessions
4384
+ WHERE status = 'completed'
4385
+ AND last_heartbeat < datetime('now', '-24 hours')
4386
+ """)
4387
+ deleted_sessions = cursor.rowcount
4388
+
4389
+ # Delete old activity events (older than configured max age)
4390
+ cursor.execute("""
4391
+ DELETE FROM session_activity
4392
+ WHERE timestamp < datetime('now', '-24 hours')
4393
+ """)
4394
+ deleted_events = cursor.rowcount
4395
+
4396
+ conn.commit()
4397
+ return {
4398
+ "marked_idle": marked_idle,
4399
+ "marked_completed": marked_completed,
4400
+ "deleted_sessions": deleted_sessions,
4401
+ "deleted_events": deleted_events,
4402
+ }
4403
+
4404
+ async def append_file_modified(self, session_id: str, file_path: str) -> Dict[str, Any]:
4405
+ """Atomically append a file to a session's files_modified list."""
4406
+ file_path = normalize_path(file_path)
4407
+ with self.get_connection() as conn:
4408
+ cursor = conn.cursor()
4409
+ cursor.execute(
4410
+ "SELECT files_modified FROM active_sessions WHERE session_id = ?",
4411
+ (session_id,)
4412
+ )
4413
+ row = cursor.fetchone()
4414
+ if not row:
4415
+ return {"success": False, "error": "Session not found"}
4416
+ files = json.loads(row["files_modified"] or "[]")
4417
+ if file_path not in files:
4418
+ files.append(file_path)
4419
+ cursor.execute(
4420
+ "UPDATE active_sessions SET files_modified = ? WHERE session_id = ?",
4421
+ (json.dumps(files), session_id)
4422
+ )
4423
+ conn.commit()
4424
+ return {"success": True, "files_count": len(files)}