htmlgraph 0.25.0__py3-none-any.whl → 0.26.1__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.
- htmlgraph/__init__.py +1 -1
- htmlgraph/api/main.py +193 -45
- htmlgraph/api/templates/dashboard.html +11 -0
- htmlgraph/api/templates/partials/activity-feed.html +458 -8
- htmlgraph/dashboard.html +41 -0
- htmlgraph/db/schema.py +254 -4
- htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/hooks/.htmlgraph/agents.json +72 -0
- htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
- htmlgraph/hooks/cigs_pretool_enforcer.py +2 -2
- htmlgraph/hooks/concurrent_sessions.py +208 -0
- htmlgraph/hooks/context.py +57 -10
- htmlgraph/hooks/drift_handler.py +24 -20
- htmlgraph/hooks/event_tracker.py +204 -177
- htmlgraph/hooks/orchestrator.py +6 -4
- htmlgraph/hooks/orchestrator_reflector.py +4 -4
- htmlgraph/hooks/pretooluse.py +3 -6
- htmlgraph/hooks/prompt_analyzer.py +14 -25
- htmlgraph/hooks/session_handler.py +123 -69
- htmlgraph/hooks/state_manager.py +7 -4
- htmlgraph/hooks/validator.py +15 -11
- htmlgraph/orchestration/headless_spawner.py +322 -15
- htmlgraph/orchestration/live_events.py +377 -0
- {htmlgraph-0.25.0.data → htmlgraph-0.26.1.data}/data/htmlgraph/dashboard.html +41 -0
- {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.1.dist-info}/METADATA +1 -1
- {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.1.dist-info}/RECORD +32 -27
- {htmlgraph-0.25.0.data → htmlgraph-0.26.1.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.25.0.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.25.0.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.25.0.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.1.dist-info}/WHEEL +0 -0
- {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.1.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.
|
|
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
|
|
@@ -529,8 +555,8 @@ class HtmlGraphDB:
|
|
|
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,6 +572,7 @@ 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
|
|
@@ -1329,6 +1356,229 @@ class HtmlGraphDB:
|
|
|
1329
1356
|
logger.error(f"Error querying tool traces: {e}")
|
|
1330
1357
|
return []
|
|
1331
1358
|
|
|
1359
|
+
def update_session_activity(self, session_id: str, user_query: str) -> None:
|
|
1360
|
+
"""
|
|
1361
|
+
Update session with latest user query activity.
|
|
1362
|
+
|
|
1363
|
+
Args:
|
|
1364
|
+
session_id: Session ID to update
|
|
1365
|
+
user_query: The user query text (will be truncated to 200 chars)
|
|
1366
|
+
"""
|
|
1367
|
+
if not self.connection:
|
|
1368
|
+
self.connect()
|
|
1369
|
+
|
|
1370
|
+
try:
|
|
1371
|
+
cursor = self.connection.cursor() # type: ignore[union-attr]
|
|
1372
|
+
cursor.execute(
|
|
1373
|
+
"""
|
|
1374
|
+
UPDATE sessions
|
|
1375
|
+
SET last_user_query_at = ?, last_user_query = ?
|
|
1376
|
+
WHERE session_id = ?
|
|
1377
|
+
""",
|
|
1378
|
+
(
|
|
1379
|
+
datetime.now(timezone.utc).isoformat(),
|
|
1380
|
+
user_query[:200] if user_query else None,
|
|
1381
|
+
session_id,
|
|
1382
|
+
),
|
|
1383
|
+
)
|
|
1384
|
+
self.connection.commit() # type: ignore[union-attr]
|
|
1385
|
+
except sqlite3.Error as e:
|
|
1386
|
+
logger.error(f"Error updating session activity: {e}")
|
|
1387
|
+
|
|
1388
|
+
def get_concurrent_sessions(
|
|
1389
|
+
self, current_session_id: str, minutes: int = 30
|
|
1390
|
+
) -> list[dict[str, Any]]:
|
|
1391
|
+
"""
|
|
1392
|
+
Get other sessions active in the last N minutes.
|
|
1393
|
+
|
|
1394
|
+
Args:
|
|
1395
|
+
current_session_id: Current session ID to exclude from results
|
|
1396
|
+
minutes: Time window in minutes (default: 30)
|
|
1397
|
+
|
|
1398
|
+
Returns:
|
|
1399
|
+
List of concurrent session dictionaries
|
|
1400
|
+
"""
|
|
1401
|
+
if not self.connection:
|
|
1402
|
+
self.connect()
|
|
1403
|
+
|
|
1404
|
+
try:
|
|
1405
|
+
cursor = self.connection.cursor() # type: ignore[union-attr]
|
|
1406
|
+
cutoff = (
|
|
1407
|
+
datetime.now(timezone.utc) - timedelta(minutes=minutes)
|
|
1408
|
+
).isoformat()
|
|
1409
|
+
cursor.execute(
|
|
1410
|
+
"""
|
|
1411
|
+
SELECT session_id, agent_assigned, created_at, last_user_query_at,
|
|
1412
|
+
last_user_query, status
|
|
1413
|
+
FROM sessions
|
|
1414
|
+
WHERE session_id != ?
|
|
1415
|
+
AND status = 'active'
|
|
1416
|
+
AND (last_user_query_at > ? OR created_at > ?)
|
|
1417
|
+
ORDER BY last_user_query_at DESC
|
|
1418
|
+
""",
|
|
1419
|
+
(current_session_id, cutoff, cutoff),
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
rows = cursor.fetchall()
|
|
1423
|
+
return [dict(row) for row in rows]
|
|
1424
|
+
except sqlite3.Error as e:
|
|
1425
|
+
logger.error(f"Error querying concurrent sessions: {e}")
|
|
1426
|
+
return []
|
|
1427
|
+
|
|
1428
|
+
def insert_live_event(
|
|
1429
|
+
self,
|
|
1430
|
+
event_type: str,
|
|
1431
|
+
event_data: dict[str, Any],
|
|
1432
|
+
parent_event_id: str | None = None,
|
|
1433
|
+
session_id: str | None = None,
|
|
1434
|
+
spawner_type: str | None = None,
|
|
1435
|
+
) -> int | None:
|
|
1436
|
+
"""
|
|
1437
|
+
Insert a live event for real-time WebSocket streaming.
|
|
1438
|
+
|
|
1439
|
+
These events are temporary and should be cleaned up after broadcast.
|
|
1440
|
+
|
|
1441
|
+
Args:
|
|
1442
|
+
event_type: Type of live event (spawner_start, spawner_phase, spawner_complete, etc.)
|
|
1443
|
+
event_data: Event payload as dictionary (will be JSON serialized)
|
|
1444
|
+
parent_event_id: Parent event ID for hierarchical linking (optional)
|
|
1445
|
+
session_id: Session this event belongs to (optional)
|
|
1446
|
+
spawner_type: Spawner type (gemini, codex, copilot) if applicable (optional)
|
|
1447
|
+
|
|
1448
|
+
Returns:
|
|
1449
|
+
Live event ID if successful, None otherwise
|
|
1450
|
+
"""
|
|
1451
|
+
if not self.connection:
|
|
1452
|
+
self.connect()
|
|
1453
|
+
|
|
1454
|
+
try:
|
|
1455
|
+
cursor = self.connection.cursor() # type: ignore[union-attr]
|
|
1456
|
+
cursor.execute(
|
|
1457
|
+
"""
|
|
1458
|
+
INSERT INTO live_events
|
|
1459
|
+
(event_type, event_data, parent_event_id, session_id, spawner_type)
|
|
1460
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1461
|
+
""",
|
|
1462
|
+
(
|
|
1463
|
+
event_type,
|
|
1464
|
+
json.dumps(event_data),
|
|
1465
|
+
parent_event_id,
|
|
1466
|
+
session_id,
|
|
1467
|
+
spawner_type,
|
|
1468
|
+
),
|
|
1469
|
+
)
|
|
1470
|
+
self.connection.commit() # type: ignore[union-attr]
|
|
1471
|
+
return cursor.lastrowid
|
|
1472
|
+
except sqlite3.Error as e:
|
|
1473
|
+
logger.error(f"Error inserting live event: {e}")
|
|
1474
|
+
return None
|
|
1475
|
+
|
|
1476
|
+
def get_pending_live_events(self, limit: int = 100) -> list[dict[str, Any]]:
|
|
1477
|
+
"""
|
|
1478
|
+
Get live events that haven't been broadcast yet.
|
|
1479
|
+
|
|
1480
|
+
Args:
|
|
1481
|
+
limit: Maximum number of events to return
|
|
1482
|
+
|
|
1483
|
+
Returns:
|
|
1484
|
+
List of pending live event dictionaries
|
|
1485
|
+
"""
|
|
1486
|
+
if not self.connection:
|
|
1487
|
+
self.connect()
|
|
1488
|
+
|
|
1489
|
+
try:
|
|
1490
|
+
cursor = self.connection.cursor() # type: ignore[union-attr]
|
|
1491
|
+
cursor.execute(
|
|
1492
|
+
"""
|
|
1493
|
+
SELECT id, event_type, event_data, parent_event_id, session_id,
|
|
1494
|
+
spawner_type, created_at
|
|
1495
|
+
FROM live_events
|
|
1496
|
+
WHERE broadcast_at IS NULL
|
|
1497
|
+
ORDER BY created_at ASC
|
|
1498
|
+
LIMIT ?
|
|
1499
|
+
""",
|
|
1500
|
+
(limit,),
|
|
1501
|
+
)
|
|
1502
|
+
|
|
1503
|
+
rows = cursor.fetchall()
|
|
1504
|
+
events = []
|
|
1505
|
+
for row in rows:
|
|
1506
|
+
event = dict(row)
|
|
1507
|
+
# Parse JSON event_data
|
|
1508
|
+
if event.get("event_data"):
|
|
1509
|
+
try:
|
|
1510
|
+
event["event_data"] = json.loads(event["event_data"])
|
|
1511
|
+
except json.JSONDecodeError:
|
|
1512
|
+
pass
|
|
1513
|
+
events.append(event)
|
|
1514
|
+
return events
|
|
1515
|
+
except sqlite3.Error as e:
|
|
1516
|
+
logger.error(f"Error fetching pending live events: {e}")
|
|
1517
|
+
return []
|
|
1518
|
+
|
|
1519
|
+
def mark_live_events_broadcast(self, event_ids: list[int]) -> bool:
|
|
1520
|
+
"""
|
|
1521
|
+
Mark live events as broadcast (sets broadcast_at timestamp).
|
|
1522
|
+
|
|
1523
|
+
Args:
|
|
1524
|
+
event_ids: List of live event IDs to mark as broadcast
|
|
1525
|
+
|
|
1526
|
+
Returns:
|
|
1527
|
+
True if successful, False otherwise
|
|
1528
|
+
"""
|
|
1529
|
+
if not self.connection or not event_ids:
|
|
1530
|
+
return False
|
|
1531
|
+
|
|
1532
|
+
try:
|
|
1533
|
+
cursor = self.connection.cursor() # type: ignore[union-attr]
|
|
1534
|
+
placeholders = ",".join("?" for _ in event_ids)
|
|
1535
|
+
cursor.execute(
|
|
1536
|
+
f"""
|
|
1537
|
+
UPDATE live_events
|
|
1538
|
+
SET broadcast_at = CURRENT_TIMESTAMP
|
|
1539
|
+
WHERE id IN ({placeholders})
|
|
1540
|
+
""",
|
|
1541
|
+
event_ids,
|
|
1542
|
+
)
|
|
1543
|
+
self.connection.commit() # type: ignore[union-attr]
|
|
1544
|
+
return True
|
|
1545
|
+
except sqlite3.Error as e:
|
|
1546
|
+
logger.error(f"Error marking live events as broadcast: {e}")
|
|
1547
|
+
return False
|
|
1548
|
+
|
|
1549
|
+
def cleanup_old_live_events(self, max_age_minutes: int = 5) -> int:
|
|
1550
|
+
"""
|
|
1551
|
+
Delete live events that have been broadcast and are older than max_age_minutes.
|
|
1552
|
+
|
|
1553
|
+
Args:
|
|
1554
|
+
max_age_minutes: Maximum age in minutes for broadcast events
|
|
1555
|
+
|
|
1556
|
+
Returns:
|
|
1557
|
+
Number of deleted events
|
|
1558
|
+
"""
|
|
1559
|
+
if not self.connection:
|
|
1560
|
+
self.connect()
|
|
1561
|
+
|
|
1562
|
+
try:
|
|
1563
|
+
cursor = self.connection.cursor() # type: ignore[union-attr]
|
|
1564
|
+
cutoff = (
|
|
1565
|
+
datetime.now(timezone.utc) - timedelta(minutes=max_age_minutes)
|
|
1566
|
+
).isoformat()
|
|
1567
|
+
cursor.execute(
|
|
1568
|
+
"""
|
|
1569
|
+
DELETE FROM live_events
|
|
1570
|
+
WHERE broadcast_at IS NOT NULL
|
|
1571
|
+
AND created_at < ?
|
|
1572
|
+
""",
|
|
1573
|
+
(cutoff,),
|
|
1574
|
+
)
|
|
1575
|
+
deleted_count = cursor.rowcount
|
|
1576
|
+
self.connection.commit() # type: ignore[union-attr]
|
|
1577
|
+
return deleted_count
|
|
1578
|
+
except sqlite3.Error as e:
|
|
1579
|
+
logger.error(f"Error cleaning up old live events: {e}")
|
|
1580
|
+
return 0
|
|
1581
|
+
|
|
1332
1582
|
def close(self) -> None:
|
|
1333
1583
|
"""Clean up database connection."""
|
|
1334
1584
|
self.disconnect()
|
|
@@ -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
|
+
]
|