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.
- htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/.htmlgraph/agents.json +72 -0
- htmlgraph/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/__init__.py +1 -1
- htmlgraph/api/main.py +252 -47
- htmlgraph/api/templates/dashboard.html +11 -0
- htmlgraph/api/templates/partials/activity-feed.html +517 -8
- htmlgraph/cli.py +1 -1
- htmlgraph/config.py +173 -96
- htmlgraph/dashboard.html +632 -7237
- htmlgraph/db/schema.py +258 -9
- 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 +88 -10
- htmlgraph/hooks/drift_handler.py +24 -20
- htmlgraph/hooks/event_tracker.py +264 -189
- htmlgraph/hooks/orchestrator.py +6 -4
- htmlgraph/hooks/orchestrator_reflector.py +4 -4
- htmlgraph/hooks/pretooluse.py +63 -36
- htmlgraph/hooks/prompt_analyzer.py +14 -25
- htmlgraph/hooks/session_handler.py +123 -69
- htmlgraph/hooks/state_manager.py +7 -4
- htmlgraph/hooks/subagent_stop.py +3 -2
- htmlgraph/hooks/validator.py +15 -11
- htmlgraph/operations/fastapi_server.py +2 -2
- htmlgraph/orchestration/headless_spawner.py +489 -16
- htmlgraph/orchestration/live_events.py +377 -0
- htmlgraph/server.py +100 -203
- htmlgraph-0.26.2.data/data/htmlgraph/dashboard.html +812 -0
- {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/METADATA +1 -1
- {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/RECORD +40 -32
- htmlgraph-0.25.0.data/data/htmlgraph/dashboard.html +0 -7417
- {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/WHEEL +0 -0
- {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/entry_points.txt +0 -0
htmlgraph/hooks/event_tracker.py
CHANGED
|
@@ -5,12 +5,17 @@ Reusable event tracking logic for hook integrations.
|
|
|
5
5
|
Provides session management, drift detection, activity logging, and SQLite persistence.
|
|
6
6
|
|
|
7
7
|
Public API:
|
|
8
|
-
track_event(hook_type: str, tool_input: dict) -> dict
|
|
8
|
+
track_event(hook_type: str, tool_input: dict[str, Any]) -> dict
|
|
9
9
|
Main entry point for tracking hook events (PostToolUse, Stop, UserPromptSubmit)
|
|
10
10
|
|
|
11
11
|
Events are recorded to both:
|
|
12
12
|
- HTML files via SessionManager (existing)
|
|
13
13
|
- SQLite database via HtmlGraphDB (new - for dashboard queries)
|
|
14
|
+
|
|
15
|
+
Parent-child event linking:
|
|
16
|
+
- Database is the single source of truth for parent-child linking
|
|
17
|
+
- UserQuery events are stored in agent_events table with tool_name='UserQuery'
|
|
18
|
+
- get_parent_user_query() queries database for most recent UserQuery in session
|
|
14
19
|
"""
|
|
15
20
|
|
|
16
21
|
import json
|
|
@@ -20,7 +25,7 @@ import subprocess
|
|
|
20
25
|
import sys
|
|
21
26
|
from datetime import datetime, timedelta, timezone
|
|
22
27
|
from pathlib import Path
|
|
23
|
-
from typing import Any, cast
|
|
28
|
+
from typing import Any, cast # noqa: F401
|
|
24
29
|
|
|
25
30
|
from htmlgraph.db.schema import HtmlGraphDB
|
|
26
31
|
from htmlgraph.ids import generate_id
|
|
@@ -28,18 +33,50 @@ from htmlgraph.session_manager import SessionManager
|
|
|
28
33
|
|
|
29
34
|
# Drift classification queue (stored in session directory)
|
|
30
35
|
DRIFT_QUEUE_FILE = "drift-queue.json"
|
|
31
|
-
# Active parent activity tracker (for Skill/Task invocations)
|
|
32
|
-
PARENT_ACTIVITY_FILE = "parent-activity.json"
|
|
33
|
-
# UserQuery event tracker (for parent-child linking) - DEPRECATED (use session-scoped files)
|
|
34
|
-
USER_QUERY_EVENT_FILE = "user-query-event.json"
|
|
35
36
|
|
|
36
37
|
|
|
37
|
-
def
|
|
38
|
-
"""
|
|
39
|
-
|
|
38
|
+
def get_model_from_status_cache(session_id: str | None = None) -> str | None:
|
|
39
|
+
"""
|
|
40
|
+
Read current model from SQLite model_cache table.
|
|
41
|
+
|
|
42
|
+
The status line script writes model info to the model_cache table.
|
|
43
|
+
This allows hooks to know which Claude model is currently running,
|
|
44
|
+
even though hooks don't receive model info directly from Claude Code.
|
|
40
45
|
|
|
46
|
+
Args:
|
|
47
|
+
session_id: Unused, kept for backward compatibility.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Model display name (e.g., "Opus 4.5", "Sonnet", "Haiku") or None if not found.
|
|
51
|
+
"""
|
|
52
|
+
import sqlite3
|
|
41
53
|
|
|
42
|
-
|
|
54
|
+
try:
|
|
55
|
+
# Try project database first
|
|
56
|
+
db_path = Path.cwd() / ".htmlgraph" / "htmlgraph.db"
|
|
57
|
+
if not db_path.exists():
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
conn = sqlite3.connect(str(db_path), timeout=1.0)
|
|
61
|
+
cursor = conn.cursor()
|
|
62
|
+
|
|
63
|
+
# Check if model_cache table exists and has data
|
|
64
|
+
cursor.execute("SELECT model FROM model_cache WHERE id = 1 LIMIT 1")
|
|
65
|
+
row = cursor.fetchone()
|
|
66
|
+
conn.close()
|
|
67
|
+
|
|
68
|
+
if row and row[0] and row[0] != "Claude":
|
|
69
|
+
return str(row[0])
|
|
70
|
+
return str(row[0]) if row else None
|
|
71
|
+
|
|
72
|
+
except Exception:
|
|
73
|
+
# Table doesn't exist or read error - silently fail
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_drift_config() -> dict[str, Any]:
|
|
43
80
|
"""Load drift configuration from plugin config or project .claude directory."""
|
|
44
81
|
config_paths = [
|
|
45
82
|
Path(__file__).parent.parent.parent.parent.parent
|
|
@@ -80,110 +117,46 @@ def load_drift_config() -> dict:
|
|
|
80
117
|
}
|
|
81
118
|
|
|
82
119
|
|
|
83
|
-
def
|
|
84
|
-
"""Load the active parent activity state."""
|
|
85
|
-
path = graph_dir / PARENT_ACTIVITY_FILE
|
|
86
|
-
if path.exists():
|
|
87
|
-
try:
|
|
88
|
-
with open(path) as f:
|
|
89
|
-
data = cast(dict[Any, Any], json.load(f))
|
|
90
|
-
# Clean up stale parent activities (older than 5 minutes)
|
|
91
|
-
if data.get("timestamp"):
|
|
92
|
-
ts = datetime.fromisoformat(data["timestamp"])
|
|
93
|
-
# Use timezone-aware datetime for comparison
|
|
94
|
-
now = datetime.now(timezone.utc)
|
|
95
|
-
# Ensure ts is timezone-aware (handle both formats)
|
|
96
|
-
if ts.tzinfo is None:
|
|
97
|
-
ts = ts.replace(tzinfo=timezone.utc)
|
|
98
|
-
if now - ts > timedelta(minutes=5):
|
|
99
|
-
return {}
|
|
100
|
-
return data
|
|
101
|
-
except Exception:
|
|
102
|
-
pass
|
|
103
|
-
return {}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def save_parent_activity(
|
|
107
|
-
graph_dir: Path, parent_id: str | None, tool: str | None = None
|
|
108
|
-
) -> None:
|
|
109
|
-
"""Save the active parent activity state."""
|
|
110
|
-
path = graph_dir / PARENT_ACTIVITY_FILE
|
|
111
|
-
try:
|
|
112
|
-
if parent_id:
|
|
113
|
-
with open(path, "w") as f:
|
|
114
|
-
json.dump(
|
|
115
|
-
{
|
|
116
|
-
"parent_id": parent_id,
|
|
117
|
-
"tool": tool,
|
|
118
|
-
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
119
|
-
},
|
|
120
|
-
f,
|
|
121
|
-
)
|
|
122
|
-
else:
|
|
123
|
-
# Clear parent activity
|
|
124
|
-
path.unlink(missing_ok=True)
|
|
125
|
-
except Exception as e:
|
|
126
|
-
print(f"Warning: Could not save parent activity: {e}", file=sys.stderr)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def load_user_query_event(graph_dir: Path, session_id: str) -> str | None:
|
|
120
|
+
def get_parent_user_query(db: HtmlGraphDB, session_id: str) -> str | None:
|
|
130
121
|
"""
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
Session-scoped: Each session maintains its own parent context via
|
|
134
|
-
user-query-event-{SESSION_ID}.json to support multiple concurrent
|
|
135
|
-
Claude windows in the same project.
|
|
136
|
-
"""
|
|
137
|
-
path = get_user_query_event_file(graph_dir, session_id)
|
|
138
|
-
if path.exists():
|
|
139
|
-
try:
|
|
140
|
-
with open(path) as f:
|
|
141
|
-
data = cast(dict[Any, Any], json.load(f))
|
|
142
|
-
# Clean up stale UserQuery events (older than 10 minutes)
|
|
143
|
-
if data.get("timestamp"):
|
|
144
|
-
ts = datetime.fromisoformat(data["timestamp"])
|
|
145
|
-
now = datetime.now(timezone.utc)
|
|
146
|
-
if ts.tzinfo is None:
|
|
147
|
-
ts = ts.replace(tzinfo=timezone.utc)
|
|
148
|
-
# UserQuery events expire after 10 minutes (conversation turn boundary)
|
|
149
|
-
# This allows tool calls up to 10 minutes after a user query to be linked as children
|
|
150
|
-
if now - ts > timedelta(minutes=10):
|
|
151
|
-
return None
|
|
152
|
-
return data.get("event_id")
|
|
153
|
-
except Exception:
|
|
154
|
-
pass
|
|
155
|
-
return None
|
|
122
|
+
Get the most recent UserQuery event_id for this session from database.
|
|
156
123
|
|
|
124
|
+
This is the primary method for parent-child event linking.
|
|
125
|
+
Database is the single source of truth - no file-based state.
|
|
157
126
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
"""
|
|
162
|
-
Save the active UserQuery event ID for parent-child linking.
|
|
127
|
+
Args:
|
|
128
|
+
db: HtmlGraphDB instance
|
|
129
|
+
session_id: Session ID to query
|
|
163
130
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
Claude windows in the same project.
|
|
131
|
+
Returns:
|
|
132
|
+
event_id of the most recent UserQuery event, or None if not found
|
|
167
133
|
"""
|
|
168
|
-
path = get_user_query_event_file(graph_dir, session_id)
|
|
169
134
|
try:
|
|
170
|
-
if
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
135
|
+
if db.connection is None:
|
|
136
|
+
return None
|
|
137
|
+
cursor = db.connection.cursor()
|
|
138
|
+
cursor.execute(
|
|
139
|
+
"""
|
|
140
|
+
SELECT event_id FROM agent_events
|
|
141
|
+
WHERE session_id = ? AND tool_name = 'UserQuery'
|
|
142
|
+
ORDER BY timestamp DESC
|
|
143
|
+
LIMIT 1
|
|
144
|
+
""",
|
|
145
|
+
(session_id,),
|
|
146
|
+
)
|
|
147
|
+
row = cursor.fetchone()
|
|
148
|
+
if row:
|
|
149
|
+
return str(row[0])
|
|
150
|
+
return None
|
|
182
151
|
except Exception as e:
|
|
183
|
-
print(
|
|
152
|
+
print(
|
|
153
|
+
f"Debug: Database query for UserQuery failed: {e}",
|
|
154
|
+
file=sys.stderr,
|
|
155
|
+
)
|
|
156
|
+
return None
|
|
184
157
|
|
|
185
158
|
|
|
186
|
-
def load_drift_queue(graph_dir: Path, max_age_hours: int = 48) -> dict:
|
|
159
|
+
def load_drift_queue(graph_dir: Path, max_age_hours: int = 48) -> dict[str, Any]:
|
|
187
160
|
"""
|
|
188
161
|
Load the drift queue from file and clean up stale entries.
|
|
189
162
|
|
|
@@ -232,7 +205,7 @@ def load_drift_queue(graph_dir: Path, max_age_hours: int = 48) -> dict:
|
|
|
232
205
|
return {"activities": [], "last_classification": None}
|
|
233
206
|
|
|
234
207
|
|
|
235
|
-
def save_drift_queue(graph_dir: Path, queue: dict) -> None:
|
|
208
|
+
def save_drift_queue(graph_dir: Path, queue: dict[str, Any]) -> None:
|
|
236
209
|
"""Save the drift queue to file."""
|
|
237
210
|
queue_path = graph_dir / DRIFT_QUEUE_FILE
|
|
238
211
|
try:
|
|
@@ -266,7 +239,9 @@ def clear_drift_queue_activities(graph_dir: Path) -> None:
|
|
|
266
239
|
print(f"Warning: Could not clear drift queue: {e}", file=sys.stderr)
|
|
267
240
|
|
|
268
241
|
|
|
269
|
-
def add_to_drift_queue(
|
|
242
|
+
def add_to_drift_queue(
|
|
243
|
+
graph_dir: Path, activity: dict[str, Any], config: dict[str, Any]
|
|
244
|
+
) -> dict[str, Any]:
|
|
270
245
|
"""Add a high-drift activity to the queue."""
|
|
271
246
|
max_age_hours = config.get("queue", {}).get("max_age_hours", 48)
|
|
272
247
|
queue = load_drift_queue(graph_dir, max_age_hours=max_age_hours)
|
|
@@ -289,7 +264,9 @@ def add_to_drift_queue(graph_dir: Path, activity: dict, config: dict) -> dict:
|
|
|
289
264
|
return queue
|
|
290
265
|
|
|
291
266
|
|
|
292
|
-
def should_trigger_classification(
|
|
267
|
+
def should_trigger_classification(
|
|
268
|
+
queue: dict[str, Any], config: dict[str, Any]
|
|
269
|
+
) -> bool:
|
|
293
270
|
"""Check if we should trigger auto-classification."""
|
|
294
271
|
drift_config = config.get("drift_detection", {})
|
|
295
272
|
|
|
@@ -316,7 +293,7 @@ def should_trigger_classification(queue: dict, config: dict) -> bool:
|
|
|
316
293
|
return True
|
|
317
294
|
|
|
318
295
|
|
|
319
|
-
def build_classification_prompt(queue: dict, feature_id: str) -> str:
|
|
296
|
+
def build_classification_prompt(queue: dict[str, Any], feature_id: str) -> str:
|
|
320
297
|
"""Build the prompt for the classification agent."""
|
|
321
298
|
activities = queue.get("activities", [])
|
|
322
299
|
|
|
@@ -368,41 +345,108 @@ def resolve_project_path(cwd: str | None = None) -> str:
|
|
|
368
345
|
return start_dir
|
|
369
346
|
|
|
370
347
|
|
|
371
|
-
def
|
|
348
|
+
def detect_model_from_hook_input(hook_input: dict[str, Any]) -> str | None:
|
|
349
|
+
"""
|
|
350
|
+
Detect the Claude model from hook input data.
|
|
351
|
+
|
|
352
|
+
Checks in order of priority:
|
|
353
|
+
1. Task() model parameter (if tool_name == 'Task')
|
|
354
|
+
2. HTMLGRAPH_MODEL environment variable (set by hooks)
|
|
355
|
+
3. ANTHROPIC_MODEL or CLAUDE_MODEL environment variables
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
hook_input: Hook input dict containing tool_name and tool_input
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Model name (e.g., 'claude-opus', 'claude-sonnet', 'claude-haiku') or None
|
|
362
|
+
"""
|
|
363
|
+
# Get tool info
|
|
364
|
+
tool_name_value: Any = hook_input.get("tool_name", "") or hook_input.get("name", "")
|
|
365
|
+
tool_name = tool_name_value if isinstance(tool_name_value, str) else ""
|
|
366
|
+
tool_input_value: Any = hook_input.get("tool_input", {}) or hook_input.get(
|
|
367
|
+
"input", {}
|
|
368
|
+
)
|
|
369
|
+
tool_input = tool_input_value if isinstance(tool_input_value, dict) else {}
|
|
370
|
+
|
|
371
|
+
# 1. Check for Task() model parameter first
|
|
372
|
+
if tool_name == "Task" and "model" in tool_input:
|
|
373
|
+
model_value: Any = tool_input.get("model")
|
|
374
|
+
if model_value and isinstance(model_value, str):
|
|
375
|
+
model = model_value.strip().lower()
|
|
376
|
+
if model:
|
|
377
|
+
if not model.startswith("claude-"):
|
|
378
|
+
model = f"claude-{model}"
|
|
379
|
+
return cast(str, model)
|
|
380
|
+
|
|
381
|
+
# 2. Check environment variables (set by PreToolUse hook)
|
|
382
|
+
for env_var in ["HTMLGRAPH_MODEL", "ANTHROPIC_MODEL", "CLAUDE_MODEL"]:
|
|
383
|
+
value = os.environ.get(env_var)
|
|
384
|
+
if value and isinstance(value, str):
|
|
385
|
+
model = value.strip()
|
|
386
|
+
if model:
|
|
387
|
+
return model
|
|
388
|
+
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def detect_agent_from_environment() -> tuple[str, str | None]:
|
|
372
393
|
"""
|
|
373
|
-
Detect the agent/model name from environment variables.
|
|
394
|
+
Detect the agent/model name from environment variables and status cache.
|
|
374
395
|
|
|
375
|
-
Checks multiple
|
|
396
|
+
Checks multiple sources in order of priority:
|
|
376
397
|
1. HTMLGRAPH_AGENT - Explicit agent name set by user
|
|
377
398
|
2. HTMLGRAPH_SUBAGENT_TYPE - For subagent sessions
|
|
378
|
-
3.
|
|
379
|
-
4.
|
|
380
|
-
5.
|
|
399
|
+
3. HTMLGRAPH_PARENT_AGENT - Parent agent context
|
|
400
|
+
4. HTMLGRAPH_MODEL - Model name (e.g., claude-haiku, claude-opus)
|
|
401
|
+
5. CLAUDE_MODEL - Model name if exposed by Claude Code
|
|
402
|
+
6. ANTHROPIC_MODEL - Alternative model env var
|
|
403
|
+
7. Status line cache (model only) - ~/.cache/claude-code/status-{session_id}.json
|
|
381
404
|
|
|
382
405
|
Falls back to 'claude-code' if no environment variable is set.
|
|
383
406
|
|
|
384
407
|
Returns:
|
|
385
|
-
|
|
408
|
+
Tuple of (agent_id, model_name). Model name may be None if not detected.
|
|
386
409
|
"""
|
|
387
|
-
# Check
|
|
388
|
-
|
|
410
|
+
# Check for explicit agent name first
|
|
411
|
+
agent_id = None
|
|
412
|
+
env_vars_agent = [
|
|
389
413
|
"HTMLGRAPH_AGENT",
|
|
390
414
|
"HTMLGRAPH_SUBAGENT_TYPE",
|
|
415
|
+
"HTMLGRAPH_PARENT_AGENT",
|
|
416
|
+
]
|
|
417
|
+
|
|
418
|
+
for var in env_vars_agent:
|
|
419
|
+
value = os.environ.get(var)
|
|
420
|
+
if value and value.strip():
|
|
421
|
+
agent_id = value.strip()
|
|
422
|
+
break
|
|
423
|
+
|
|
424
|
+
# Check for model name separately
|
|
425
|
+
model_name = None
|
|
426
|
+
env_vars_model = [
|
|
427
|
+
"HTMLGRAPH_MODEL",
|
|
391
428
|
"CLAUDE_MODEL",
|
|
392
429
|
"ANTHROPIC_MODEL",
|
|
393
|
-
"HTMLGRAPH_PARENT_AGENT",
|
|
394
430
|
]
|
|
395
431
|
|
|
396
|
-
for var in
|
|
432
|
+
for var in env_vars_model:
|
|
397
433
|
value = os.environ.get(var)
|
|
398
434
|
if value and value.strip():
|
|
399
|
-
|
|
435
|
+
model_name = value.strip()
|
|
436
|
+
break
|
|
437
|
+
|
|
438
|
+
# Fallback: Try to read model from status line cache
|
|
439
|
+
if not model_name:
|
|
440
|
+
model_name = get_model_from_status_cache()
|
|
441
|
+
|
|
442
|
+
# Default fallback for agent_id
|
|
443
|
+
if not agent_id:
|
|
444
|
+
agent_id = "claude-code"
|
|
400
445
|
|
|
401
|
-
|
|
402
|
-
return "claude-code"
|
|
446
|
+
return agent_id, model_name
|
|
403
447
|
|
|
404
448
|
|
|
405
|
-
def extract_file_paths(tool_input: dict, tool_name: str) -> list[str]:
|
|
449
|
+
def extract_file_paths(tool_input: dict[str, Any], tool_name: str) -> list[str]:
|
|
406
450
|
"""Extract file paths from tool input based on tool type."""
|
|
407
451
|
paths = []
|
|
408
452
|
|
|
@@ -427,7 +471,7 @@ def extract_file_paths(tool_input: dict, tool_name: str) -> list[str]:
|
|
|
427
471
|
|
|
428
472
|
|
|
429
473
|
def format_tool_summary(
|
|
430
|
-
tool_name: str, tool_input: dict, tool_result: dict | None = None
|
|
474
|
+
tool_name: str, tool_input: dict[str, Any], tool_result: dict | None = None
|
|
431
475
|
) -> str:
|
|
432
476
|
"""Format a human-readable summary of the tool call."""
|
|
433
477
|
if tool_name == "Read":
|
|
@@ -491,13 +535,14 @@ def record_event_to_sqlite(
|
|
|
491
535
|
db: HtmlGraphDB,
|
|
492
536
|
session_id: str,
|
|
493
537
|
tool_name: str,
|
|
494
|
-
tool_input: dict,
|
|
495
|
-
tool_response: dict,
|
|
538
|
+
tool_input: dict[str, Any],
|
|
539
|
+
tool_response: dict[str, Any],
|
|
496
540
|
is_error: bool,
|
|
497
541
|
file_paths: list[str] | None = None,
|
|
498
542
|
parent_event_id: str | None = None,
|
|
499
543
|
agent_id: str | None = None,
|
|
500
544
|
subagent_type: str | None = None,
|
|
545
|
+
model: str | None = None,
|
|
501
546
|
) -> str | None:
|
|
502
547
|
"""
|
|
503
548
|
Record a tool call event to SQLite database for dashboard queries.
|
|
@@ -513,6 +558,7 @@ def record_event_to_sqlite(
|
|
|
513
558
|
parent_event_id: Parent event ID if this is a child event
|
|
514
559
|
agent_id: Agent identifier (optional)
|
|
515
560
|
subagent_type: Subagent type for Task delegations (optional)
|
|
561
|
+
model: Claude model name (e.g., claude-haiku, claude-opus) (optional)
|
|
516
562
|
|
|
517
563
|
Returns:
|
|
518
564
|
event_id if successful, None otherwise
|
|
@@ -523,7 +569,7 @@ def record_event_to_sqlite(
|
|
|
523
569
|
|
|
524
570
|
# Build output summary from tool response
|
|
525
571
|
output_summary = ""
|
|
526
|
-
if isinstance(tool_response, dict):
|
|
572
|
+
if isinstance(tool_response, dict): # type: ignore[arg-type]
|
|
527
573
|
if is_error:
|
|
528
574
|
output_summary = tool_response.get("error", "error")[:200]
|
|
529
575
|
else:
|
|
@@ -556,6 +602,7 @@ def record_event_to_sqlite(
|
|
|
556
602
|
parent_event_id=parent_event_id,
|
|
557
603
|
cost_tokens=0,
|
|
558
604
|
subagent_type=subagent_type,
|
|
605
|
+
model=model,
|
|
559
606
|
)
|
|
560
607
|
|
|
561
608
|
if success:
|
|
@@ -573,7 +620,7 @@ def record_delegation_to_sqlite(
|
|
|
573
620
|
from_agent: str,
|
|
574
621
|
to_agent: str,
|
|
575
622
|
task_description: str,
|
|
576
|
-
task_input: dict,
|
|
623
|
+
task_input: dict[str, Any],
|
|
577
624
|
) -> str | None:
|
|
578
625
|
"""
|
|
579
626
|
Record a Task() delegation to agent_collaboration table.
|
|
@@ -619,7 +666,7 @@ def record_delegation_to_sqlite(
|
|
|
619
666
|
return None
|
|
620
667
|
|
|
621
668
|
|
|
622
|
-
def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
669
|
+
def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
|
|
623
670
|
"""
|
|
624
671
|
Track a hook event and log it to HtmlGraph (both HTML files and SQLite).
|
|
625
672
|
|
|
@@ -647,26 +694,79 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
647
694
|
# Initialize SQLite database for event recording
|
|
648
695
|
db = None
|
|
649
696
|
try:
|
|
650
|
-
|
|
697
|
+
from htmlgraph.config import get_database_path
|
|
698
|
+
|
|
699
|
+
db = HtmlGraphDB(str(get_database_path()))
|
|
651
700
|
except Exception as e:
|
|
652
701
|
print(f"Warning: Could not initialize SQLite database: {e}", file=sys.stderr)
|
|
653
702
|
# Continue without SQLite (graceful degradation)
|
|
654
703
|
|
|
655
|
-
# Detect agent from environment
|
|
656
|
-
detected_agent = detect_agent_from_environment()
|
|
704
|
+
# Detect agent and model from environment
|
|
705
|
+
detected_agent, detected_model = detect_agent_from_environment()
|
|
657
706
|
|
|
658
|
-
#
|
|
659
|
-
|
|
660
|
-
if
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
707
|
+
# Also try to detect model from hook input (more specific than environment)
|
|
708
|
+
model_from_input = detect_model_from_hook_input(hook_input)
|
|
709
|
+
if model_from_input:
|
|
710
|
+
detected_model = model_from_input
|
|
711
|
+
|
|
712
|
+
active_session = None
|
|
713
|
+
|
|
714
|
+
# Check if we're in a subagent context (environment variables set by spawner router)
|
|
715
|
+
# This MUST be checked BEFORE using get_active_session() to avoid attributing
|
|
716
|
+
# subagent events to the parent orchestrator session
|
|
717
|
+
subagent_type = os.environ.get("HTMLGRAPH_SUBAGENT_TYPE")
|
|
718
|
+
parent_session_id = os.environ.get("HTMLGRAPH_PARENT_SESSION")
|
|
719
|
+
|
|
720
|
+
if subagent_type and parent_session_id:
|
|
721
|
+
# We're in a subagent - create or get subagent session
|
|
722
|
+
# Use deterministic session ID based on parent + subagent type
|
|
723
|
+
subagent_session_id = f"{parent_session_id}-{subagent_type}"
|
|
724
|
+
|
|
725
|
+
# Check if subagent session already exists
|
|
726
|
+
existing = manager.session_converter.load(subagent_session_id)
|
|
727
|
+
if existing:
|
|
728
|
+
active_session = existing
|
|
729
|
+
print(
|
|
730
|
+
f"Debug: Using existing subagent session: {subagent_session_id}",
|
|
731
|
+
file=sys.stderr,
|
|
667
732
|
)
|
|
668
|
-
|
|
669
|
-
|
|
733
|
+
else:
|
|
734
|
+
# Create new subagent session with parent link
|
|
735
|
+
try:
|
|
736
|
+
active_session = manager.start_session(
|
|
737
|
+
session_id=subagent_session_id,
|
|
738
|
+
agent=f"{subagent_type}-spawner",
|
|
739
|
+
is_subagent=True,
|
|
740
|
+
parent_session_id=parent_session_id,
|
|
741
|
+
title=f"{subagent_type.capitalize()} Subagent",
|
|
742
|
+
)
|
|
743
|
+
print(
|
|
744
|
+
f"Debug: Created subagent session: {subagent_session_id} "
|
|
745
|
+
f"(parent: {parent_session_id})",
|
|
746
|
+
file=sys.stderr,
|
|
747
|
+
)
|
|
748
|
+
except Exception as e:
|
|
749
|
+
print(
|
|
750
|
+
f"Warning: Could not create subagent session: {e}",
|
|
751
|
+
file=sys.stderr,
|
|
752
|
+
)
|
|
753
|
+
return {"continue": True}
|
|
754
|
+
|
|
755
|
+
# Override detected agent for subagent context
|
|
756
|
+
detected_agent = f"{subagent_type}-spawner"
|
|
757
|
+
else:
|
|
758
|
+
# Normal orchestrator/parent context - use global session cache
|
|
759
|
+
active_session = manager.get_active_session()
|
|
760
|
+
if not active_session:
|
|
761
|
+
# No active HtmlGraph session yet; start one (stable internal id).
|
|
762
|
+
try:
|
|
763
|
+
active_session = manager.start_session(
|
|
764
|
+
session_id=None,
|
|
765
|
+
agent=detected_agent,
|
|
766
|
+
title=f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
|
767
|
+
)
|
|
768
|
+
except Exception:
|
|
769
|
+
return {"continue": True}
|
|
670
770
|
|
|
671
771
|
active_session_id = active_session.id
|
|
672
772
|
|
|
@@ -732,6 +832,7 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
732
832
|
tool_response={"content": "Agent stopped"},
|
|
733
833
|
is_error=False,
|
|
734
834
|
agent_id=detected_agent,
|
|
835
|
+
model=detected_model,
|
|
735
836
|
)
|
|
736
837
|
except Exception as e:
|
|
737
838
|
print(f"Warning: Could not track stop: {e}", file=sys.stderr)
|
|
@@ -749,10 +850,11 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
749
850
|
session_id=active_session_id, tool="UserQuery", summary=f'"{preview}"'
|
|
750
851
|
)
|
|
751
852
|
|
|
752
|
-
# Record to SQLite if available
|
|
753
|
-
|
|
853
|
+
# Record to SQLite if available
|
|
854
|
+
# UserQuery event is stored in database - no file-based state needed
|
|
855
|
+
# Subsequent tool calls query database for parent via get_parent_user_query()
|
|
754
856
|
if db:
|
|
755
|
-
|
|
857
|
+
record_event_to_sqlite(
|
|
756
858
|
db=db,
|
|
757
859
|
session_id=active_session_id,
|
|
758
860
|
tool_name="UserQuery",
|
|
@@ -760,14 +862,9 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
760
862
|
tool_response={"content": "Query received"},
|
|
761
863
|
is_error=False,
|
|
762
864
|
agent_id=detected_agent,
|
|
865
|
+
model=detected_model,
|
|
763
866
|
)
|
|
764
867
|
|
|
765
|
-
# Store the UserQuery event_id for subsequent tool calls to use as parent
|
|
766
|
-
if user_query_event_id:
|
|
767
|
-
save_user_query_event(
|
|
768
|
-
graph_dir, active_session_id, user_query_event_id
|
|
769
|
-
)
|
|
770
|
-
|
|
771
868
|
except Exception as e:
|
|
772
869
|
print(f"Warning: Could not track query: {e}", file=sys.stderr)
|
|
773
870
|
return {"continue": True}
|
|
@@ -792,7 +889,7 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
792
889
|
summary = format_tool_summary(tool_name, tool_input_data, tool_response)
|
|
793
890
|
|
|
794
891
|
# Determine success
|
|
795
|
-
if isinstance(tool_response, dict):
|
|
892
|
+
if isinstance(tool_response, dict): # type: ignore[arg-type]
|
|
796
893
|
success_field = tool_response.get("success")
|
|
797
894
|
if isinstance(success_field, bool):
|
|
798
895
|
is_error = not success_field
|
|
@@ -818,37 +915,20 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
818
915
|
warning_threshold = drift_settings.get("warning_threshold") or 0.7
|
|
819
916
|
auto_classify_threshold = drift_settings.get("auto_classify_threshold") or 0.85
|
|
820
917
|
|
|
821
|
-
# Determine parent activity context
|
|
822
|
-
parent_activity_state = load_parent_activity(graph_dir)
|
|
918
|
+
# Determine parent activity context using database-only lookup
|
|
823
919
|
parent_activity_id = None
|
|
824
920
|
|
|
825
|
-
#
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
# This is set by PreToolUse hook when Task() spawns a subagent
|
|
837
|
-
env_parent = os.environ.get("HTMLGRAPH_PARENT_EVENT")
|
|
838
|
-
if env_parent:
|
|
839
|
-
parent_activity_id = env_parent
|
|
840
|
-
# Next, check for UserQuery event as parent (for prompt-based grouping)
|
|
841
|
-
# UserQuery takes priority over parent_activity_json to ensure each conversation turn
|
|
842
|
-
# has its tool calls properly grouped together
|
|
843
|
-
else:
|
|
844
|
-
user_query_event_id = load_user_query_event(
|
|
845
|
-
graph_dir, active_session_id
|
|
846
|
-
)
|
|
847
|
-
if user_query_event_id:
|
|
848
|
-
parent_activity_id = user_query_event_id
|
|
849
|
-
# Fall back to parent-activity.json only if no UserQuery event (backward compatibility)
|
|
850
|
-
elif parent_activity_state.get("parent_id"):
|
|
851
|
-
parent_activity_id = parent_activity_state["parent_id"]
|
|
921
|
+
# Check environment variable FIRST for cross-process parent linking
|
|
922
|
+
# This is set by PreToolUse hook when Task() spawns a subagent
|
|
923
|
+
env_parent = os.environ.get("HTMLGRAPH_PARENT_EVENT") or os.environ.get(
|
|
924
|
+
"HTMLGRAPH_PARENT_QUERY_EVENT"
|
|
925
|
+
)
|
|
926
|
+
if env_parent:
|
|
927
|
+
parent_activity_id = env_parent
|
|
928
|
+
# Query database for most recent UserQuery event as parent
|
|
929
|
+
# Database is the single source of truth for parent-child linking
|
|
930
|
+
elif db:
|
|
931
|
+
parent_activity_id = get_parent_user_query(db, active_session_id)
|
|
852
932
|
|
|
853
933
|
# Track the activity
|
|
854
934
|
nudge = None
|
|
@@ -882,6 +962,7 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
882
962
|
parent_event_id=parent_activity_id, # Link to parent event
|
|
883
963
|
agent_id=detected_agent,
|
|
884
964
|
subagent_type=task_subagent_type,
|
|
965
|
+
model=detected_model,
|
|
885
966
|
)
|
|
886
967
|
|
|
887
968
|
# If this was a Task() delegation, also record to agent_collaboration
|
|
@@ -897,12 +978,6 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
897
978
|
task_input=tool_input_data,
|
|
898
979
|
)
|
|
899
980
|
|
|
900
|
-
# If this was a parent tool, save its ID for subsequent activities
|
|
901
|
-
if is_parent_tool and result:
|
|
902
|
-
save_parent_activity(graph_dir, result.id, tool_name)
|
|
903
|
-
# If this tool finished a parent context (e.g., Task completed), clear it
|
|
904
|
-
# We'll clear parent context after 5 minutes automatically (see load_parent_activity)
|
|
905
|
-
|
|
906
981
|
# Check for drift and handle accordingly
|
|
907
982
|
# Skip drift detection for child activities (they inherit parent's context)
|
|
908
983
|
if result and hasattr(result, "drift_score") and not parent_activity_id:
|