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/bin/cli.js +312 -136
- package/bin/postinstall.js +14 -0
- package/config.py +6 -0
- package/main.py +246 -1
- package/mcp_server.py +54 -0
- package/package.json +1 -1
- package/services/database.py +306 -0
- package/services/session_awareness.py +181 -0
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
|
-
|
|
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
package/services/database.py
CHANGED
|
@@ -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)}
|