htmlgraph 0.25.0__py3-none-any.whl → 0.26.2__py3-none-any.whl

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 (41) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
  2. htmlgraph/.htmlgraph/agents.json +72 -0
  3. htmlgraph/.htmlgraph/htmlgraph.db +0 -0
  4. htmlgraph/__init__.py +1 -1
  5. htmlgraph/api/main.py +252 -47
  6. htmlgraph/api/templates/dashboard.html +11 -0
  7. htmlgraph/api/templates/partials/activity-feed.html +517 -8
  8. htmlgraph/cli.py +1 -1
  9. htmlgraph/config.py +173 -96
  10. htmlgraph/dashboard.html +632 -7237
  11. htmlgraph/db/schema.py +258 -9
  12. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  13. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  14. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  15. htmlgraph/hooks/cigs_pretool_enforcer.py +2 -2
  16. htmlgraph/hooks/concurrent_sessions.py +208 -0
  17. htmlgraph/hooks/context.py +88 -10
  18. htmlgraph/hooks/drift_handler.py +24 -20
  19. htmlgraph/hooks/event_tracker.py +264 -189
  20. htmlgraph/hooks/orchestrator.py +6 -4
  21. htmlgraph/hooks/orchestrator_reflector.py +4 -4
  22. htmlgraph/hooks/pretooluse.py +63 -36
  23. htmlgraph/hooks/prompt_analyzer.py +14 -25
  24. htmlgraph/hooks/session_handler.py +123 -69
  25. htmlgraph/hooks/state_manager.py +7 -4
  26. htmlgraph/hooks/subagent_stop.py +3 -2
  27. htmlgraph/hooks/validator.py +15 -11
  28. htmlgraph/operations/fastapi_server.py +2 -2
  29. htmlgraph/orchestration/headless_spawner.py +489 -16
  30. htmlgraph/orchestration/live_events.py +377 -0
  31. htmlgraph/server.py +100 -203
  32. htmlgraph-0.26.2.data/data/htmlgraph/dashboard.html +812 -0
  33. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/METADATA +1 -1
  34. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/RECORD +40 -32
  35. htmlgraph-0.25.0.data/data/htmlgraph/dashboard.html +0 -7417
  36. {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/styles.css +0 -0
  37. {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  38. {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  39. {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  40. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/WHEEL +0 -0
  41. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/entry_points.txt +0 -0
htmlgraph/db/schema.py CHANGED
@@ -24,7 +24,7 @@ Tables:
24
24
  import json
25
25
  import logging
26
26
  import sqlite3
27
- from datetime import datetime, timezone
27
+ from datetime import datetime, timedelta, timezone
28
28
  from pathlib import Path
29
29
  from typing import Any
30
30
 
@@ -103,6 +103,7 @@ class HtmlGraphDB:
103
103
  ("status", "TEXT DEFAULT 'recorded'"),
104
104
  ("created_at", "DATETIME DEFAULT CURRENT_TIMESTAMP"),
105
105
  ("updated_at", "DATETIME DEFAULT CURRENT_TIMESTAMP"),
106
+ ("model", "TEXT"),
106
107
  ]
107
108
 
108
109
  for col_name, col_type in migrations:
@@ -158,6 +159,8 @@ class HtmlGraphDB:
158
159
  ("features_worked_on", "TEXT"),
159
160
  ("metadata", "TEXT"),
160
161
  ("completed_at", "DATETIME"),
162
+ ("last_user_query_at", "DATETIME"),
163
+ ("last_user_query", "TEXT"),
161
164
  ]
162
165
 
163
166
  # Refresh columns after potential rename
@@ -220,6 +223,7 @@ class HtmlGraphDB:
220
223
  cost_tokens INTEGER DEFAULT 0,
221
224
  execution_duration_seconds REAL DEFAULT 0.0,
222
225
  status TEXT DEFAULT 'recorded',
226
+ model TEXT,
223
227
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
224
228
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
225
229
  FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE ON UPDATE CASCADE,
@@ -280,6 +284,8 @@ class HtmlGraphDB:
280
284
  is_subagent BOOLEAN DEFAULT FALSE,
281
285
  features_worked_on JSON,
282
286
  metadata JSON,
287
+ last_user_query_at DATETIME,
288
+ last_user_query TEXT,
283
289
  FOREIGN KEY (parent_session_id) REFERENCES sessions(session_id) ON DELETE SET NULL ON UPDATE CASCADE,
284
290
  FOREIGN KEY (parent_event_id) REFERENCES agent_events(event_id) ON DELETE SET NULL ON UPDATE CASCADE
285
291
  )
@@ -358,7 +364,22 @@ class HtmlGraphDB:
358
364
  )
359
365
  """)
360
366
 
361
- # 8. TOOL_TRACES TABLE - Detailed tool execution tracing
367
+ # 8. LIVE_EVENTS TABLE - Real-time event streaming buffer
368
+ # Events are inserted here for WebSocket broadcasting, then auto-cleaned after broadcast
369
+ cursor.execute("""
370
+ CREATE TABLE IF NOT EXISTS live_events (
371
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
372
+ event_type TEXT NOT NULL,
373
+ event_data TEXT NOT NULL,
374
+ parent_event_id TEXT,
375
+ session_id TEXT,
376
+ spawner_type TEXT,
377
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
378
+ broadcast_at TIMESTAMP
379
+ )
380
+ """)
381
+
382
+ # 9. TOOL_TRACES TABLE - Detailed tool execution tracing
362
383
  cursor.execute("""
363
384
  CREATE TABLE IF NOT EXISTS tool_traces (
364
385
  tool_use_id TEXT PRIMARY KEY,
@@ -465,6 +486,9 @@ class HtmlGraphDB:
465
486
  "CREATE INDEX IF NOT EXISTS idx_tool_traces_tool_name ON tool_traces(tool_name, status)",
466
487
  "CREATE INDEX IF NOT EXISTS idx_tool_traces_status ON tool_traces(status, start_time DESC)",
467
488
  "CREATE INDEX IF NOT EXISTS idx_tool_traces_start_time ON tool_traces(start_time DESC)",
489
+ # live_events indexes - optimized for real-time WebSocket streaming
490
+ "CREATE INDEX IF NOT EXISTS idx_live_events_pending ON live_events(broadcast_at) WHERE broadcast_at IS NULL",
491
+ "CREATE INDEX IF NOT EXISTS idx_live_events_created ON live_events(created_at DESC)",
468
492
  ]
469
493
 
470
494
  for index_sql in indexes:
@@ -488,6 +512,7 @@ class HtmlGraphDB:
488
512
  cost_tokens: int = 0,
489
513
  execution_duration_seconds: float = 0.0,
490
514
  subagent_type: str | None = None,
515
+ model: str | None = None,
491
516
  ) -> bool:
492
517
  """
493
518
  Insert an agent event into the database.
@@ -511,6 +536,7 @@ class HtmlGraphDB:
511
536
  cost_tokens: Token usage estimate (optional)
512
537
  execution_duration_seconds: Execution time in seconds (optional)
513
538
  subagent_type: Subagent type for Task delegations (optional)
539
+ model: Claude model name (e.g., claude-haiku, claude-opus, claude-sonnet) (optional)
514
540
 
515
541
  Returns:
516
542
  True if insert successful, False otherwise
@@ -521,16 +547,16 @@ class HtmlGraphDB:
521
547
  try:
522
548
  cursor = self.connection.cursor() # type: ignore[union-attr]
523
549
  # Temporarily disable foreign key constraints to allow inserting
524
- # parent_event_id references that may not exist yet (will be created later)
525
- if parent_event_id:
526
- cursor.execute("PRAGMA foreign_keys=OFF")
550
+ # events even if parent_event_id or session_id don't exist yet
551
+ # (useful for cross-process event tracking where sessions are created asynchronously)
552
+ cursor.execute("PRAGMA foreign_keys=OFF")
527
553
  cursor.execute(
528
554
  """
529
555
  INSERT INTO agent_events
530
556
  (event_id, agent_id, event_type, session_id, tool_name,
531
557
  input_summary, output_summary, context, parent_agent_id,
532
- parent_event_id, cost_tokens, execution_duration_seconds, subagent_type)
533
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
558
+ parent_event_id, cost_tokens, execution_duration_seconds, subagent_type, model)
559
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
534
560
  """,
535
561
  (
536
562
  event_id,
@@ -546,11 +572,11 @@ class HtmlGraphDB:
546
572
  cost_tokens,
547
573
  execution_duration_seconds,
548
574
  subagent_type,
575
+ model,
549
576
  ),
550
577
  )
551
578
  # Re-enable foreign key constraints
552
- if parent_event_id:
553
- cursor.execute("PRAGMA foreign_keys=ON")
579
+ cursor.execute("PRAGMA foreign_keys=ON")
554
580
  self.connection.commit() # type: ignore[union-attr]
555
581
  return True
556
582
  except sqlite3.IntegrityError as e:
@@ -1329,6 +1355,229 @@ class HtmlGraphDB:
1329
1355
  logger.error(f"Error querying tool traces: {e}")
1330
1356
  return []
1331
1357
 
1358
+ def update_session_activity(self, session_id: str, user_query: str) -> None:
1359
+ """
1360
+ Update session with latest user query activity.
1361
+
1362
+ Args:
1363
+ session_id: Session ID to update
1364
+ user_query: The user query text (will be truncated to 200 chars)
1365
+ """
1366
+ if not self.connection:
1367
+ self.connect()
1368
+
1369
+ try:
1370
+ cursor = self.connection.cursor() # type: ignore[union-attr]
1371
+ cursor.execute(
1372
+ """
1373
+ UPDATE sessions
1374
+ SET last_user_query_at = ?, last_user_query = ?
1375
+ WHERE session_id = ?
1376
+ """,
1377
+ (
1378
+ datetime.now(timezone.utc).isoformat(),
1379
+ user_query[:200] if user_query else None,
1380
+ session_id,
1381
+ ),
1382
+ )
1383
+ self.connection.commit() # type: ignore[union-attr]
1384
+ except sqlite3.Error as e:
1385
+ logger.error(f"Error updating session activity: {e}")
1386
+
1387
+ def get_concurrent_sessions(
1388
+ self, current_session_id: str, minutes: int = 30
1389
+ ) -> list[dict[str, Any]]:
1390
+ """
1391
+ Get other sessions active in the last N minutes.
1392
+
1393
+ Args:
1394
+ current_session_id: Current session ID to exclude from results
1395
+ minutes: Time window in minutes (default: 30)
1396
+
1397
+ Returns:
1398
+ List of concurrent session dictionaries
1399
+ """
1400
+ if not self.connection:
1401
+ self.connect()
1402
+
1403
+ try:
1404
+ cursor = self.connection.cursor() # type: ignore[union-attr]
1405
+ cutoff = (
1406
+ datetime.now(timezone.utc) - timedelta(minutes=minutes)
1407
+ ).isoformat()
1408
+ cursor.execute(
1409
+ """
1410
+ SELECT session_id, agent_assigned, created_at, last_user_query_at,
1411
+ last_user_query, status
1412
+ FROM sessions
1413
+ WHERE session_id != ?
1414
+ AND status = 'active'
1415
+ AND (last_user_query_at > ? OR created_at > ?)
1416
+ ORDER BY last_user_query_at DESC
1417
+ """,
1418
+ (current_session_id, cutoff, cutoff),
1419
+ )
1420
+
1421
+ rows = cursor.fetchall()
1422
+ return [dict(row) for row in rows]
1423
+ except sqlite3.Error as e:
1424
+ logger.error(f"Error querying concurrent sessions: {e}")
1425
+ return []
1426
+
1427
+ def insert_live_event(
1428
+ self,
1429
+ event_type: str,
1430
+ event_data: dict[str, Any],
1431
+ parent_event_id: str | None = None,
1432
+ session_id: str | None = None,
1433
+ spawner_type: str | None = None,
1434
+ ) -> int | None:
1435
+ """
1436
+ Insert a live event for real-time WebSocket streaming.
1437
+
1438
+ These events are temporary and should be cleaned up after broadcast.
1439
+
1440
+ Args:
1441
+ event_type: Type of live event (spawner_start, spawner_phase, spawner_complete, etc.)
1442
+ event_data: Event payload as dictionary (will be JSON serialized)
1443
+ parent_event_id: Parent event ID for hierarchical linking (optional)
1444
+ session_id: Session this event belongs to (optional)
1445
+ spawner_type: Spawner type (gemini, codex, copilot) if applicable (optional)
1446
+
1447
+ Returns:
1448
+ Live event ID if successful, None otherwise
1449
+ """
1450
+ if not self.connection:
1451
+ self.connect()
1452
+
1453
+ try:
1454
+ cursor = self.connection.cursor() # type: ignore[union-attr]
1455
+ cursor.execute(
1456
+ """
1457
+ INSERT INTO live_events
1458
+ (event_type, event_data, parent_event_id, session_id, spawner_type)
1459
+ VALUES (?, ?, ?, ?, ?)
1460
+ """,
1461
+ (
1462
+ event_type,
1463
+ json.dumps(event_data),
1464
+ parent_event_id,
1465
+ session_id,
1466
+ spawner_type,
1467
+ ),
1468
+ )
1469
+ self.connection.commit() # type: ignore[union-attr]
1470
+ return cursor.lastrowid
1471
+ except sqlite3.Error as e:
1472
+ logger.error(f"Error inserting live event: {e}")
1473
+ return None
1474
+
1475
+ def get_pending_live_events(self, limit: int = 100) -> list[dict[str, Any]]:
1476
+ """
1477
+ Get live events that haven't been broadcast yet.
1478
+
1479
+ Args:
1480
+ limit: Maximum number of events to return
1481
+
1482
+ Returns:
1483
+ List of pending live event dictionaries
1484
+ """
1485
+ if not self.connection:
1486
+ self.connect()
1487
+
1488
+ try:
1489
+ cursor = self.connection.cursor() # type: ignore[union-attr]
1490
+ cursor.execute(
1491
+ """
1492
+ SELECT id, event_type, event_data, parent_event_id, session_id,
1493
+ spawner_type, created_at
1494
+ FROM live_events
1495
+ WHERE broadcast_at IS NULL
1496
+ ORDER BY created_at ASC
1497
+ LIMIT ?
1498
+ """,
1499
+ (limit,),
1500
+ )
1501
+
1502
+ rows = cursor.fetchall()
1503
+ events = []
1504
+ for row in rows:
1505
+ event = dict(row)
1506
+ # Parse JSON event_data
1507
+ if event.get("event_data"):
1508
+ try:
1509
+ event["event_data"] = json.loads(event["event_data"])
1510
+ except json.JSONDecodeError:
1511
+ pass
1512
+ events.append(event)
1513
+ return events
1514
+ except sqlite3.Error as e:
1515
+ logger.error(f"Error fetching pending live events: {e}")
1516
+ return []
1517
+
1518
+ def mark_live_events_broadcast(self, event_ids: list[int]) -> bool:
1519
+ """
1520
+ Mark live events as broadcast (sets broadcast_at timestamp).
1521
+
1522
+ Args:
1523
+ event_ids: List of live event IDs to mark as broadcast
1524
+
1525
+ Returns:
1526
+ True if successful, False otherwise
1527
+ """
1528
+ if not self.connection or not event_ids:
1529
+ return False
1530
+
1531
+ try:
1532
+ cursor = self.connection.cursor() # type: ignore[union-attr]
1533
+ placeholders = ",".join("?" for _ in event_ids)
1534
+ cursor.execute(
1535
+ f"""
1536
+ UPDATE live_events
1537
+ SET broadcast_at = CURRENT_TIMESTAMP
1538
+ WHERE id IN ({placeholders})
1539
+ """,
1540
+ event_ids,
1541
+ )
1542
+ self.connection.commit() # type: ignore[union-attr]
1543
+ return True
1544
+ except sqlite3.Error as e:
1545
+ logger.error(f"Error marking live events as broadcast: {e}")
1546
+ return False
1547
+
1548
+ def cleanup_old_live_events(self, max_age_minutes: int = 5) -> int:
1549
+ """
1550
+ Delete live events that have been broadcast and are older than max_age_minutes.
1551
+
1552
+ Args:
1553
+ max_age_minutes: Maximum age in minutes for broadcast events
1554
+
1555
+ Returns:
1556
+ Number of deleted events
1557
+ """
1558
+ if not self.connection:
1559
+ self.connect()
1560
+
1561
+ try:
1562
+ cursor = self.connection.cursor() # type: ignore[union-attr]
1563
+ cutoff = (
1564
+ datetime.now(timezone.utc) - timedelta(minutes=max_age_minutes)
1565
+ ).isoformat()
1566
+ cursor.execute(
1567
+ """
1568
+ DELETE FROM live_events
1569
+ WHERE broadcast_at IS NOT NULL
1570
+ AND created_at < ?
1571
+ """,
1572
+ (cutoff,),
1573
+ )
1574
+ deleted_count = cursor.rowcount
1575
+ self.connection.commit() # type: ignore[union-attr]
1576
+ return deleted_count
1577
+ except sqlite3.Error as e:
1578
+ logger.error(f"Error cleaning up old live events: {e}")
1579
+ return 0
1580
+
1332
1581
  def close(self) -> None:
1333
1582
  """Clean up database connection."""
1334
1583
  self.disconnect()
@@ -0,0 +1,6 @@
1
+ {
2
+ "dismissed_at": null,
3
+ "dismissed_by": null,
4
+ "session_id": null,
5
+ "show_count": 121
6
+ }
@@ -0,0 +1,72 @@
1
+ {
2
+ "version": "1.0",
3
+ "updated": "2026-01-10T08:55:56.503507",
4
+ "agents": {
5
+ "claude": {
6
+ "id": "claude",
7
+ "name": "Claude",
8
+ "capabilities": [
9
+ "python",
10
+ "javascript",
11
+ "typescript",
12
+ "html",
13
+ "css",
14
+ "code-review",
15
+ "testing",
16
+ "documentation",
17
+ "debugging",
18
+ "refactoring",
19
+ "architecture",
20
+ "api-design"
21
+ ],
22
+ "max_parallel_tasks": 3,
23
+ "preferred_complexity": [
24
+ "low",
25
+ "medium",
26
+ "high",
27
+ "very-high"
28
+ ],
29
+ "active": true,
30
+ "metadata": {}
31
+ },
32
+ "gemini": {
33
+ "id": "gemini",
34
+ "name": "Gemini",
35
+ "capabilities": [
36
+ "python",
37
+ "data-analysis",
38
+ "documentation",
39
+ "testing",
40
+ "code-review",
41
+ "javascript"
42
+ ],
43
+ "max_parallel_tasks": 2,
44
+ "preferred_complexity": [
45
+ "low",
46
+ "medium",
47
+ "high"
48
+ ],
49
+ "active": true,
50
+ "metadata": {}
51
+ },
52
+ "codex": {
53
+ "id": "codex",
54
+ "name": "Codex",
55
+ "capabilities": [
56
+ "python",
57
+ "javascript",
58
+ "debugging",
59
+ "testing",
60
+ "code-generation",
61
+ "documentation"
62
+ ],
63
+ "max_parallel_tasks": 2,
64
+ "preferred_complexity": [
65
+ "low",
66
+ "medium"
67
+ ],
68
+ "active": true,
69
+ "metadata": {}
70
+ }
71
+ }
72
+ }
Binary file
@@ -87,7 +87,7 @@ class CIGSPreToolEnforcer:
87
87
 
88
88
  return graph_dir
89
89
 
90
- def enforce(self, tool: str, params: dict) -> dict[str, Any]:
90
+ def enforce(self, tool: str, params: dict[str, Any]) -> dict[str, Any]:
91
91
  """
92
92
  Enforce CIGS delegation rules with escalating guidance.
93
93
 
@@ -223,7 +223,7 @@ class CIGSPreToolEnforcer:
223
223
  }
224
224
  }
225
225
 
226
- def _is_sdk_operation(self, tool: str, params: dict) -> bool:
226
+ def _is_sdk_operation(self, tool: str, params: dict[str, Any]) -> bool:
227
227
  """Check if operation is an SDK operation (always allowed)."""
228
228
  if tool != "Bash":
229
229
  return False
@@ -0,0 +1,208 @@
1
+ """
2
+ Concurrent Session Detection and Formatting.
3
+
4
+ Provides utilities to detect other active sessions and format them
5
+ for injection into the orchestrator's context at session start.
6
+ """
7
+
8
+ from datetime import datetime, timedelta, timezone
9
+ from typing import Any
10
+
11
+ from htmlgraph.db.schema import HtmlGraphDB
12
+
13
+
14
+ def get_concurrent_sessions(
15
+ db: HtmlGraphDB,
16
+ current_session_id: str,
17
+ minutes: int = 30,
18
+ ) -> list[dict[str, Any]]:
19
+ """
20
+ Get other sessions that are currently active.
21
+
22
+ Args:
23
+ db: Database connection
24
+ current_session_id: Current session to exclude
25
+ minutes: Look back window for activity
26
+
27
+ Returns:
28
+ List of concurrent session dicts with id, agent_id, last_user_query, etc.
29
+ """
30
+ if not db.connection:
31
+ db.connect()
32
+
33
+ try:
34
+ cursor = db.connection.cursor() # type: ignore[union-attr]
35
+ # Use datetime format that matches database (without timezone)
36
+ cutoff = (datetime.now(timezone.utc) - timedelta(minutes=minutes)).strftime(
37
+ "%Y-%m-%d %H:%M:%S"
38
+ )
39
+
40
+ cursor.execute(
41
+ """
42
+ SELECT
43
+ session_id as id,
44
+ agent_assigned as agent_id,
45
+ created_at,
46
+ status,
47
+ (SELECT input_summary FROM agent_events
48
+ WHERE session_id = sessions.session_id
49
+ ORDER BY timestamp DESC LIMIT 1) as last_user_query,
50
+ (SELECT timestamp FROM agent_events
51
+ WHERE session_id = sessions.session_id
52
+ ORDER BY timestamp DESC LIMIT 1) as last_user_query_at
53
+ FROM sessions
54
+ WHERE status = 'active'
55
+ AND session_id != ?
56
+ AND created_at > ?
57
+ ORDER BY created_at DESC
58
+ """,
59
+ (current_session_id, cutoff),
60
+ )
61
+
62
+ rows = cursor.fetchall()
63
+ return [dict(row) for row in rows]
64
+ except Exception: # pragma: no cover
65
+ # Gracefully handle database errors
66
+ return []
67
+
68
+
69
+ def format_concurrent_sessions_markdown(sessions: list[dict[str, Any]]) -> str:
70
+ """
71
+ Format concurrent sessions as markdown for context injection.
72
+
73
+ Args:
74
+ sessions: List of session dicts from get_concurrent_sessions
75
+
76
+ Returns:
77
+ Markdown formatted string for system prompt injection
78
+ """
79
+ if not sessions:
80
+ return ""
81
+
82
+ lines = ["## Concurrent Sessions (Active Now)", ""]
83
+
84
+ for session in sessions:
85
+ session_id = session.get("id", "unknown")
86
+ session_id = session_id[:12] if len(session_id) > 12 else session_id
87
+ agent = session.get("agent_id", "unknown")
88
+ query = session.get("last_user_query", "No recent query")
89
+ last_active = session.get("last_user_query_at")
90
+
91
+ # Calculate time ago
92
+ time_ago = "unknown"
93
+ if last_active:
94
+ try:
95
+ last_dt = datetime.fromisoformat(
96
+ last_active.replace("Z", "+00:00")
97
+ if isinstance(last_active, str)
98
+ else last_active
99
+ )
100
+ delta = datetime.now(timezone.utc) - last_dt
101
+ if delta.total_seconds() < 60:
102
+ time_ago = "just now"
103
+ elif delta.total_seconds() < 3600:
104
+ time_ago = f"{int(delta.total_seconds() // 60)} min ago"
105
+ else:
106
+ time_ago = f"{int(delta.total_seconds() // 3600)} hours ago"
107
+ except (ValueError, TypeError, AttributeError):
108
+ time_ago = "unknown"
109
+
110
+ # Truncate query for display
111
+ query_display = (
112
+ query[:50] + "..." if query and len(query) > 50 else (query or "Unknown")
113
+ )
114
+
115
+ lines.append(f'- **{session_id}** ({agent}): "{query_display}" - {time_ago}')
116
+
117
+ lines.append("")
118
+ lines.append("*Coordinate with concurrent sessions to avoid duplicate work.*")
119
+ lines.append("")
120
+
121
+ return "\n".join(lines)
122
+
123
+
124
+ def get_recent_completed_sessions(
125
+ db: HtmlGraphDB,
126
+ hours: int = 24,
127
+ limit: int = 5,
128
+ ) -> list[dict[str, Any]]:
129
+ """
130
+ Get recently completed sessions for handoff context.
131
+
132
+ Args:
133
+ db: Database connection
134
+ hours: Look back window
135
+ limit: Maximum sessions to return
136
+
137
+ Returns:
138
+ List of recently completed session dicts
139
+ """
140
+ if not db.connection:
141
+ db.connect()
142
+
143
+ try:
144
+ cursor = db.connection.cursor() # type: ignore[union-attr]
145
+ # Use datetime format that matches database (without timezone)
146
+ cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).strftime(
147
+ "%Y-%m-%d %H:%M:%S"
148
+ )
149
+ cursor.execute(
150
+ """
151
+ SELECT session_id as id, agent_assigned as agent_id, created_at as started_at,
152
+ completed_at, total_events,
153
+ (SELECT input_summary FROM agent_events
154
+ WHERE session_id = sessions.session_id
155
+ ORDER BY timestamp DESC LIMIT 1) as last_user_query
156
+ FROM sessions
157
+ WHERE status = 'completed'
158
+ AND completed_at > ?
159
+ ORDER BY completed_at DESC
160
+ LIMIT ?
161
+ """,
162
+ (cutoff, limit),
163
+ )
164
+ rows = cursor.fetchall()
165
+ return [dict(row) for row in rows]
166
+ except Exception: # pragma: no cover
167
+ # Gracefully handle database errors
168
+ return []
169
+
170
+
171
+ def format_recent_work_markdown(sessions: list[dict[str, Any]]) -> str:
172
+ """
173
+ Format recently completed sessions as markdown.
174
+
175
+ Args:
176
+ sessions: List of completed session dicts
177
+
178
+ Returns:
179
+ Markdown formatted string
180
+ """
181
+ if not sessions:
182
+ return ""
183
+
184
+ lines = ["## Recent Work (Last 24 Hours)", ""]
185
+
186
+ for session in sessions:
187
+ session_id = session.get("id", "unknown")
188
+ session_id = session_id[:12] if len(session_id) > 12 else session_id
189
+ query = session.get("last_user_query", "No query recorded")
190
+ total_events = session.get("total_events") or 0
191
+
192
+ query_display = (
193
+ query[:60] + "..." if query and len(query) > 60 else (query or "Unknown")
194
+ )
195
+
196
+ lines.append(f"- `{session_id}`: {query_display} ({total_events} events)")
197
+
198
+ lines.append("")
199
+
200
+ return "\n".join(lines)
201
+
202
+
203
+ __all__ = [
204
+ "get_concurrent_sessions",
205
+ "format_concurrent_sessions_markdown",
206
+ "get_recent_completed_sessions",
207
+ "format_recent_work_markdown",
208
+ ]