htmlgraph 0.24.2__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 +20 -1
- htmlgraph/agent_detection.py +26 -10
- htmlgraph/analytics/cross_session.py +4 -3
- htmlgraph/analytics/work_type.py +52 -16
- htmlgraph/analytics_index.py +51 -19
- htmlgraph/api/__init__.py +3 -0
- htmlgraph/api/main.py +2263 -0
- htmlgraph/api/static/htmx.min.js +1 -0
- htmlgraph/api/static/style-redesign.css +1344 -0
- htmlgraph/api/static/style.css +1079 -0
- htmlgraph/api/templates/dashboard-redesign.html +812 -0
- htmlgraph/api/templates/dashboard.html +794 -0
- htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
- htmlgraph/api/templates/partials/activity-feed.html +1020 -0
- htmlgraph/api/templates/partials/agents-redesign.html +317 -0
- htmlgraph/api/templates/partials/agents.html +317 -0
- htmlgraph/api/templates/partials/event-traces.html +373 -0
- htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
- htmlgraph/api/templates/partials/features.html +509 -0
- htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
- htmlgraph/api/templates/partials/metrics.html +346 -0
- htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
- htmlgraph/api/templates/partials/orchestration.html +163 -0
- htmlgraph/api/templates/partials/spawners.html +375 -0
- htmlgraph/atomic_ops.py +560 -0
- htmlgraph/builders/base.py +55 -1
- htmlgraph/builders/bug.py +17 -2
- htmlgraph/builders/chore.py +17 -2
- htmlgraph/builders/epic.py +17 -2
- htmlgraph/builders/feature.py +25 -2
- htmlgraph/builders/phase.py +17 -2
- htmlgraph/builders/spike.py +27 -2
- htmlgraph/builders/track.py +14 -0
- htmlgraph/cigs/__init__.py +4 -0
- htmlgraph/cigs/reporter.py +818 -0
- htmlgraph/cli.py +1427 -401
- htmlgraph/cli_commands/__init__.py +1 -0
- htmlgraph/cli_commands/feature.py +195 -0
- htmlgraph/cli_framework.py +115 -0
- htmlgraph/collections/__init__.py +2 -0
- htmlgraph/collections/base.py +21 -0
- htmlgraph/collections/session.py +189 -0
- htmlgraph/collections/spike.py +7 -1
- htmlgraph/collections/task_delegation.py +236 -0
- htmlgraph/collections/traces.py +482 -0
- htmlgraph/config.py +113 -0
- htmlgraph/converter.py +41 -0
- htmlgraph/cost_analysis/__init__.py +5 -0
- htmlgraph/cost_analysis/analyzer.py +438 -0
- htmlgraph/dashboard.html +3356 -492
- htmlgraph-0.24.2.data/data/htmlgraph/dashboard.html → htmlgraph/dashboard.html.backup +2246 -248
- htmlgraph/dashboard.html.bak +7181 -0
- htmlgraph/dashboard.html.bak2 +7231 -0
- htmlgraph/dashboard.html.bak3 +7232 -0
- htmlgraph/db/__init__.py +38 -0
- htmlgraph/db/queries.py +790 -0
- htmlgraph/db/schema.py +1584 -0
- htmlgraph/deploy.py +26 -27
- htmlgraph/docs/API_REFERENCE.md +841 -0
- htmlgraph/docs/HTTP_API.md +750 -0
- htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
- htmlgraph/docs/ORCHESTRATION_PATTERNS.md +710 -0
- htmlgraph/docs/README.md +533 -0
- htmlgraph/docs/version_check.py +3 -1
- htmlgraph/error_handler.py +544 -0
- htmlgraph/event_log.py +2 -0
- 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/__init__.py +8 -0
- htmlgraph/hooks/bootstrap.py +169 -0
- htmlgraph/hooks/cigs_pretool_enforcer.py +2 -2
- htmlgraph/hooks/concurrent_sessions.py +208 -0
- htmlgraph/hooks/context.py +318 -0
- htmlgraph/hooks/drift_handler.py +525 -0
- htmlgraph/hooks/event_tracker.py +496 -79
- htmlgraph/hooks/orchestrator.py +6 -4
- htmlgraph/hooks/orchestrator_reflector.py +4 -4
- htmlgraph/hooks/post_tool_use_handler.py +257 -0
- htmlgraph/hooks/pretooluse.py +473 -6
- htmlgraph/hooks/prompt_analyzer.py +637 -0
- htmlgraph/hooks/session_handler.py +637 -0
- htmlgraph/hooks/state_manager.py +504 -0
- htmlgraph/hooks/subagent_stop.py +309 -0
- htmlgraph/hooks/task_enforcer.py +39 -0
- htmlgraph/hooks/validator.py +15 -11
- htmlgraph/models.py +111 -15
- htmlgraph/operations/fastapi_server.py +230 -0
- htmlgraph/orchestration/headless_spawner.py +344 -29
- htmlgraph/orchestration/live_events.py +377 -0
- htmlgraph/pydantic_models.py +476 -0
- htmlgraph/quality_gates.py +350 -0
- htmlgraph/repo_hash.py +511 -0
- htmlgraph/sdk.py +348 -10
- htmlgraph/server.py +194 -0
- htmlgraph/session_hooks.py +300 -0
- htmlgraph/session_manager.py +131 -1
- htmlgraph/session_registry.py +587 -0
- htmlgraph/session_state.py +436 -0
- htmlgraph/system_prompts.py +449 -0
- htmlgraph/templates/orchestration-view.html +350 -0
- htmlgraph/track_builder.py +19 -0
- htmlgraph/validation.py +115 -0
- htmlgraph-0.26.1.data/data/htmlgraph/dashboard.html +7458 -0
- {htmlgraph-0.24.2.dist-info → htmlgraph-0.26.1.dist-info}/METADATA +91 -64
- {htmlgraph-0.24.2.dist-info → htmlgraph-0.26.1.dist-info}/RECORD +112 -46
- {htmlgraph-0.24.2.data → htmlgraph-0.26.1.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.24.2.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.24.2.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.24.2.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.24.2.dist-info → htmlgraph-0.26.1.dist-info}/WHEEL +0 -0
- {htmlgraph-0.24.2.dist-info → htmlgraph-0.26.1.dist-info}/entry_points.txt +0 -0
htmlgraph/hooks/event_tracker.py
CHANGED
|
@@ -2,11 +2,20 @@
|
|
|
2
2
|
HtmlGraph Event Tracker Module
|
|
3
3
|
|
|
4
4
|
Reusable event tracking logic for hook integrations.
|
|
5
|
-
Provides session management, drift detection, and
|
|
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
|
+
|
|
11
|
+
Events are recorded to both:
|
|
12
|
+
- HTML files via SessionManager (existing)
|
|
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
|
|
10
19
|
"""
|
|
11
20
|
|
|
12
21
|
import json
|
|
@@ -14,19 +23,60 @@ import os
|
|
|
14
23
|
import re
|
|
15
24
|
import subprocess
|
|
16
25
|
import sys
|
|
17
|
-
from datetime import datetime, timedelta
|
|
26
|
+
from datetime import datetime, timedelta, timezone
|
|
18
27
|
from pathlib import Path
|
|
19
|
-
from typing import Any, cast
|
|
28
|
+
from typing import Any, cast # noqa: F401
|
|
20
29
|
|
|
30
|
+
from htmlgraph.db.schema import HtmlGraphDB
|
|
31
|
+
from htmlgraph.ids import generate_id
|
|
21
32
|
from htmlgraph.session_manager import SessionManager
|
|
22
33
|
|
|
23
34
|
# Drift classification queue (stored in session directory)
|
|
24
35
|
DRIFT_QUEUE_FILE = "drift-queue.json"
|
|
25
|
-
# Active parent activity tracker (for Skill/Task invocations)
|
|
26
|
-
PARENT_ACTIVITY_FILE = "parent-activity.json"
|
|
27
36
|
|
|
28
37
|
|
|
29
|
-
def
|
|
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.
|
|
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
|
|
53
|
+
|
|
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]:
|
|
30
80
|
"""Load drift configuration from plugin config or project .claude directory."""
|
|
31
81
|
config_paths = [
|
|
32
82
|
Path(__file__).parent.parent.parent.parent.parent
|
|
@@ -67,48 +117,46 @@ def load_drift_config() -> dict:
|
|
|
67
117
|
}
|
|
68
118
|
|
|
69
119
|
|
|
70
|
-
def
|
|
71
|
-
"""
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
data = cast(dict[Any, Any], json.load(f))
|
|
77
|
-
# Clean up stale parent activities (older than 5 minutes)
|
|
78
|
-
if data.get("timestamp"):
|
|
79
|
-
ts = datetime.fromisoformat(data["timestamp"])
|
|
80
|
-
if datetime.now() - ts > timedelta(minutes=5):
|
|
81
|
-
return {}
|
|
82
|
-
return data
|
|
83
|
-
except Exception:
|
|
84
|
-
pass
|
|
85
|
-
return {}
|
|
120
|
+
def get_parent_user_query(db: HtmlGraphDB, session_id: str) -> str | None:
|
|
121
|
+
"""
|
|
122
|
+
Get the most recent UserQuery event_id for this session from database.
|
|
123
|
+
|
|
124
|
+
This is the primary method for parent-child event linking.
|
|
125
|
+
Database is the single source of truth - no file-based state.
|
|
86
126
|
|
|
127
|
+
Args:
|
|
128
|
+
db: HtmlGraphDB instance
|
|
129
|
+
session_id: Session ID to query
|
|
87
130
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
"""Save the active parent activity state."""
|
|
92
|
-
path = graph_dir / PARENT_ACTIVITY_FILE
|
|
131
|
+
Returns:
|
|
132
|
+
event_id of the most recent UserQuery event, or None if not found
|
|
133
|
+
"""
|
|
93
134
|
try:
|
|
94
|
-
if
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
107
151
|
except Exception as e:
|
|
108
|
-
print(
|
|
152
|
+
print(
|
|
153
|
+
f"Debug: Database query for UserQuery failed: {e}",
|
|
154
|
+
file=sys.stderr,
|
|
155
|
+
)
|
|
156
|
+
return None
|
|
109
157
|
|
|
110
158
|
|
|
111
|
-
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]:
|
|
112
160
|
"""
|
|
113
161
|
Load the drift queue from file and clean up stale entries.
|
|
114
162
|
|
|
@@ -157,7 +205,7 @@ def load_drift_queue(graph_dir: Path, max_age_hours: int = 48) -> dict:
|
|
|
157
205
|
return {"activities": [], "last_classification": None}
|
|
158
206
|
|
|
159
207
|
|
|
160
|
-
def save_drift_queue(graph_dir: Path, queue: dict) -> None:
|
|
208
|
+
def save_drift_queue(graph_dir: Path, queue: dict[str, Any]) -> None:
|
|
161
209
|
"""Save the drift queue to file."""
|
|
162
210
|
queue_path = graph_dir / DRIFT_QUEUE_FILE
|
|
163
211
|
try:
|
|
@@ -191,7 +239,9 @@ def clear_drift_queue_activities(graph_dir: Path) -> None:
|
|
|
191
239
|
print(f"Warning: Could not clear drift queue: {e}", file=sys.stderr)
|
|
192
240
|
|
|
193
241
|
|
|
194
|
-
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]:
|
|
195
245
|
"""Add a high-drift activity to the queue."""
|
|
196
246
|
max_age_hours = config.get("queue", {}).get("max_age_hours", 48)
|
|
197
247
|
queue = load_drift_queue(graph_dir, max_age_hours=max_age_hours)
|
|
@@ -199,7 +249,7 @@ def add_to_drift_queue(graph_dir: Path, activity: dict, config: dict) -> dict:
|
|
|
199
249
|
|
|
200
250
|
queue["activities"].append(
|
|
201
251
|
{
|
|
202
|
-
"timestamp": datetime.now().isoformat(),
|
|
252
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
203
253
|
"tool": activity.get("tool"),
|
|
204
254
|
"summary": activity.get("summary"),
|
|
205
255
|
"file_paths": activity.get("file_paths", []),
|
|
@@ -214,7 +264,9 @@ def add_to_drift_queue(graph_dir: Path, activity: dict, config: dict) -> dict:
|
|
|
214
264
|
return queue
|
|
215
265
|
|
|
216
266
|
|
|
217
|
-
def should_trigger_classification(
|
|
267
|
+
def should_trigger_classification(
|
|
268
|
+
queue: dict[str, Any], config: dict[str, Any]
|
|
269
|
+
) -> bool:
|
|
218
270
|
"""Check if we should trigger auto-classification."""
|
|
219
271
|
drift_config = config.get("drift_detection", {})
|
|
220
272
|
|
|
@@ -241,7 +293,7 @@ def should_trigger_classification(queue: dict, config: dict) -> bool:
|
|
|
241
293
|
return True
|
|
242
294
|
|
|
243
295
|
|
|
244
|
-
def build_classification_prompt(queue: dict, feature_id: str) -> str:
|
|
296
|
+
def build_classification_prompt(queue: dict[str, Any], feature_id: str) -> str:
|
|
245
297
|
"""Build the prompt for the classification agent."""
|
|
246
298
|
activities = queue.get("activities", [])
|
|
247
299
|
|
|
@@ -293,7 +345,108 @@ def resolve_project_path(cwd: str | None = None) -> str:
|
|
|
293
345
|
return start_dir
|
|
294
346
|
|
|
295
347
|
|
|
296
|
-
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]:
|
|
393
|
+
"""
|
|
394
|
+
Detect the agent/model name from environment variables and status cache.
|
|
395
|
+
|
|
396
|
+
Checks multiple sources in order of priority:
|
|
397
|
+
1. HTMLGRAPH_AGENT - Explicit agent name set by user
|
|
398
|
+
2. HTMLGRAPH_SUBAGENT_TYPE - For subagent sessions
|
|
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
|
|
404
|
+
|
|
405
|
+
Falls back to 'claude-code' if no environment variable is set.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
Tuple of (agent_id, model_name). Model name may be None if not detected.
|
|
409
|
+
"""
|
|
410
|
+
# Check for explicit agent name first
|
|
411
|
+
agent_id = None
|
|
412
|
+
env_vars_agent = [
|
|
413
|
+
"HTMLGRAPH_AGENT",
|
|
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",
|
|
428
|
+
"CLAUDE_MODEL",
|
|
429
|
+
"ANTHROPIC_MODEL",
|
|
430
|
+
]
|
|
431
|
+
|
|
432
|
+
for var in env_vars_model:
|
|
433
|
+
value = os.environ.get(var)
|
|
434
|
+
if value and value.strip():
|
|
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"
|
|
445
|
+
|
|
446
|
+
return agent_id, model_name
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def extract_file_paths(tool_input: dict[str, Any], tool_name: str) -> list[str]:
|
|
297
450
|
"""Extract file paths from tool input based on tool type."""
|
|
298
451
|
paths = []
|
|
299
452
|
|
|
@@ -318,7 +471,7 @@ def extract_file_paths(tool_input: dict, tool_name: str) -> list[str]:
|
|
|
318
471
|
|
|
319
472
|
|
|
320
473
|
def format_tool_summary(
|
|
321
|
-
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
|
|
322
475
|
) -> str:
|
|
323
476
|
"""Format a human-readable summary of the tool call."""
|
|
324
477
|
if tool_name == "Read":
|
|
@@ -366,13 +519,156 @@ def format_tool_summary(
|
|
|
366
519
|
url = tool_input.get("url", "")[:40]
|
|
367
520
|
return f"WebFetch: {url}"
|
|
368
521
|
|
|
522
|
+
elif tool_name == "UserQuery":
|
|
523
|
+
# Extract the actual prompt text from the tool_input
|
|
524
|
+
prompt = str(tool_input.get("prompt", ""))
|
|
525
|
+
preview = prompt[:100].replace("\n", " ")
|
|
526
|
+
if len(prompt) > 100:
|
|
527
|
+
preview += "..."
|
|
528
|
+
return preview
|
|
529
|
+
|
|
369
530
|
else:
|
|
370
531
|
return f"{tool_name}: {str(tool_input)[:50]}"
|
|
371
532
|
|
|
372
533
|
|
|
373
|
-
def
|
|
534
|
+
def record_event_to_sqlite(
|
|
535
|
+
db: HtmlGraphDB,
|
|
536
|
+
session_id: str,
|
|
537
|
+
tool_name: str,
|
|
538
|
+
tool_input: dict[str, Any],
|
|
539
|
+
tool_response: dict[str, Any],
|
|
540
|
+
is_error: bool,
|
|
541
|
+
file_paths: list[str] | None = None,
|
|
542
|
+
parent_event_id: str | None = None,
|
|
543
|
+
agent_id: str | None = None,
|
|
544
|
+
subagent_type: str | None = None,
|
|
545
|
+
model: str | None = None,
|
|
546
|
+
) -> str | None:
|
|
374
547
|
"""
|
|
375
|
-
|
|
548
|
+
Record a tool call event to SQLite database for dashboard queries.
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
db: HtmlGraphDB instance
|
|
552
|
+
session_id: Session ID from HtmlGraph
|
|
553
|
+
tool_name: Name of the tool called
|
|
554
|
+
tool_input: Tool input parameters
|
|
555
|
+
tool_response: Tool response/result
|
|
556
|
+
is_error: Whether the tool call resulted in an error
|
|
557
|
+
file_paths: File paths affected by the tool
|
|
558
|
+
parent_event_id: Parent event ID if this is a child event
|
|
559
|
+
agent_id: Agent identifier (optional)
|
|
560
|
+
subagent_type: Subagent type for Task delegations (optional)
|
|
561
|
+
model: Claude model name (e.g., claude-haiku, claude-opus) (optional)
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
event_id if successful, None otherwise
|
|
565
|
+
"""
|
|
566
|
+
try:
|
|
567
|
+
event_id = generate_id("event")
|
|
568
|
+
input_summary = format_tool_summary(tool_name, tool_input, tool_response)
|
|
569
|
+
|
|
570
|
+
# Build output summary from tool response
|
|
571
|
+
output_summary = ""
|
|
572
|
+
if isinstance(tool_response, dict): # type: ignore[arg-type]
|
|
573
|
+
if is_error:
|
|
574
|
+
output_summary = tool_response.get("error", "error")[:200]
|
|
575
|
+
else:
|
|
576
|
+
# Extract summary from response
|
|
577
|
+
content = tool_response.get("content", tool_response.get("output", ""))
|
|
578
|
+
if isinstance(content, str):
|
|
579
|
+
output_summary = content[:200]
|
|
580
|
+
elif isinstance(content, list):
|
|
581
|
+
output_summary = f"{len(content)} items"
|
|
582
|
+
else:
|
|
583
|
+
output_summary = "success"
|
|
584
|
+
|
|
585
|
+
# Build context metadata
|
|
586
|
+
context = {
|
|
587
|
+
"file_paths": file_paths or [],
|
|
588
|
+
"tool_input_keys": list(tool_input.keys()),
|
|
589
|
+
"is_error": is_error,
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
# Insert event to SQLite
|
|
593
|
+
success = db.insert_event(
|
|
594
|
+
event_id=event_id,
|
|
595
|
+
agent_id=agent_id or "claude-code",
|
|
596
|
+
event_type="tool_call",
|
|
597
|
+
session_id=session_id,
|
|
598
|
+
tool_name=tool_name,
|
|
599
|
+
input_summary=input_summary,
|
|
600
|
+
output_summary=output_summary,
|
|
601
|
+
context=context,
|
|
602
|
+
parent_event_id=parent_event_id,
|
|
603
|
+
cost_tokens=0,
|
|
604
|
+
subagent_type=subagent_type,
|
|
605
|
+
model=model,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
if success:
|
|
609
|
+
return event_id
|
|
610
|
+
return None
|
|
611
|
+
|
|
612
|
+
except Exception as e:
|
|
613
|
+
print(f"Warning: Could not record event to SQLite: {e}", file=sys.stderr)
|
|
614
|
+
return None
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def record_delegation_to_sqlite(
|
|
618
|
+
db: HtmlGraphDB,
|
|
619
|
+
session_id: str,
|
|
620
|
+
from_agent: str,
|
|
621
|
+
to_agent: str,
|
|
622
|
+
task_description: str,
|
|
623
|
+
task_input: dict[str, Any],
|
|
624
|
+
) -> str | None:
|
|
625
|
+
"""
|
|
626
|
+
Record a Task() delegation to agent_collaboration table.
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
db: HtmlGraphDB instance
|
|
630
|
+
session_id: Session ID from HtmlGraph
|
|
631
|
+
from_agent: Agent delegating the task (usually 'orchestrator' or 'claude-code')
|
|
632
|
+
to_agent: Target subagent type (e.g., 'general-purpose', 'researcher')
|
|
633
|
+
task_description: Task description/prompt
|
|
634
|
+
task_input: Full task input parameters
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
handoff_id if successful, None otherwise
|
|
638
|
+
"""
|
|
639
|
+
try:
|
|
640
|
+
handoff_id = generate_id("handoff")
|
|
641
|
+
|
|
642
|
+
# Build context with task input
|
|
643
|
+
context = {
|
|
644
|
+
"task_input_keys": list(task_input.keys()),
|
|
645
|
+
"model": task_input.get("model"),
|
|
646
|
+
"temperature": task_input.get("temperature"),
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
# Insert delegation record
|
|
650
|
+
success = db.insert_collaboration(
|
|
651
|
+
handoff_id=handoff_id,
|
|
652
|
+
from_agent=from_agent,
|
|
653
|
+
to_agent=to_agent,
|
|
654
|
+
session_id=session_id,
|
|
655
|
+
handoff_type="delegation",
|
|
656
|
+
reason=task_description[:200],
|
|
657
|
+
context=context,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
if success:
|
|
661
|
+
return handoff_id
|
|
662
|
+
return None
|
|
663
|
+
|
|
664
|
+
except Exception as e:
|
|
665
|
+
print(f"Warning: Could not record delegation to SQLite: {e}", file=sys.stderr)
|
|
666
|
+
return None
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
|
|
670
|
+
"""
|
|
671
|
+
Track a hook event and log it to HtmlGraph (both HTML files and SQLite).
|
|
376
672
|
|
|
377
673
|
Args:
|
|
378
674
|
hook_type: Type of hook event ("PostToolUse", "Stop", "UserPromptSubmit")
|
|
@@ -388,13 +684,29 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
388
684
|
# Load drift configuration
|
|
389
685
|
drift_config = load_drift_config()
|
|
390
686
|
|
|
391
|
-
# Initialize SessionManager
|
|
687
|
+
# Initialize SessionManager and SQLite DB
|
|
392
688
|
try:
|
|
393
689
|
manager = SessionManager(graph_dir)
|
|
394
690
|
except Exception as e:
|
|
395
691
|
print(f"Warning: Could not initialize SessionManager: {e}", file=sys.stderr)
|
|
396
692
|
return {"continue": True}
|
|
397
693
|
|
|
694
|
+
# Initialize SQLite database for event recording
|
|
695
|
+
db = None
|
|
696
|
+
try:
|
|
697
|
+
db = HtmlGraphDB(str(graph_dir / "index.sqlite"))
|
|
698
|
+
except Exception as e:
|
|
699
|
+
print(f"Warning: Could not initialize SQLite database: {e}", file=sys.stderr)
|
|
700
|
+
# Continue without SQLite (graceful degradation)
|
|
701
|
+
|
|
702
|
+
# Detect agent and model from environment
|
|
703
|
+
detected_agent, detected_model = detect_agent_from_environment()
|
|
704
|
+
|
|
705
|
+
# Also try to detect model from hook input (more specific than environment)
|
|
706
|
+
model_from_input = detect_model_from_hook_input(hook_input)
|
|
707
|
+
if model_from_input:
|
|
708
|
+
detected_model = model_from_input
|
|
709
|
+
|
|
398
710
|
# Get active session ID
|
|
399
711
|
active_session = manager.get_active_session()
|
|
400
712
|
if not active_session:
|
|
@@ -402,7 +714,7 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
402
714
|
try:
|
|
403
715
|
active_session = manager.start_session(
|
|
404
716
|
session_id=None,
|
|
405
|
-
agent=
|
|
717
|
+
agent=detected_agent,
|
|
406
718
|
title=f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
|
407
719
|
)
|
|
408
720
|
except Exception:
|
|
@@ -410,6 +722,50 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
410
722
|
|
|
411
723
|
active_session_id = active_session.id
|
|
412
724
|
|
|
725
|
+
# Ensure session exists in SQLite database (for foreign key constraints)
|
|
726
|
+
if db:
|
|
727
|
+
try:
|
|
728
|
+
# Get attributes safely - MagicMock objects can cause SQLite binding errors
|
|
729
|
+
# When getattr is called on a MagicMock, it returns another MagicMock, not the default
|
|
730
|
+
def safe_getattr(obj: Any, attr: str, default: Any) -> Any:
|
|
731
|
+
"""Get attribute safely, returning default for MagicMock/invalid values."""
|
|
732
|
+
try:
|
|
733
|
+
val = getattr(obj, attr, default)
|
|
734
|
+
# Check if it's a mock object (has _mock_name attribute)
|
|
735
|
+
if hasattr(val, "_mock_name"):
|
|
736
|
+
return default
|
|
737
|
+
return val
|
|
738
|
+
except Exception:
|
|
739
|
+
return default
|
|
740
|
+
|
|
741
|
+
is_subagent_raw = safe_getattr(active_session, "is_subagent", False)
|
|
742
|
+
is_subagent = (
|
|
743
|
+
bool(is_subagent_raw) if isinstance(is_subagent_raw, bool) else False
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
transcript_id = safe_getattr(active_session, "transcript_id", None)
|
|
747
|
+
transcript_path = safe_getattr(active_session, "transcript_path", None)
|
|
748
|
+
# Ensure strings or None, not mock objects
|
|
749
|
+
if transcript_id is not None and not isinstance(transcript_id, str):
|
|
750
|
+
transcript_id = None
|
|
751
|
+
if transcript_path is not None and not isinstance(transcript_path, str):
|
|
752
|
+
transcript_path = None
|
|
753
|
+
|
|
754
|
+
db.insert_session(
|
|
755
|
+
session_id=active_session_id,
|
|
756
|
+
agent_assigned=safe_getattr(active_session, "agent", None)
|
|
757
|
+
or detected_agent,
|
|
758
|
+
is_subagent=is_subagent,
|
|
759
|
+
transcript_id=transcript_id,
|
|
760
|
+
transcript_path=transcript_path,
|
|
761
|
+
)
|
|
762
|
+
except Exception as e:
|
|
763
|
+
# Session may already exist, that's OK - continue
|
|
764
|
+
print(
|
|
765
|
+
f"Debug: Could not insert session to SQLite (may already exist): {e}",
|
|
766
|
+
file=sys.stderr,
|
|
767
|
+
)
|
|
768
|
+
|
|
413
769
|
# Handle different hook types
|
|
414
770
|
if hook_type == "Stop":
|
|
415
771
|
# Session is ending - track stop event
|
|
@@ -417,6 +773,19 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
417
773
|
manager.track_activity(
|
|
418
774
|
session_id=active_session_id, tool="Stop", summary="Agent stopped"
|
|
419
775
|
)
|
|
776
|
+
|
|
777
|
+
# Record to SQLite if available
|
|
778
|
+
if db:
|
|
779
|
+
record_event_to_sqlite(
|
|
780
|
+
db=db,
|
|
781
|
+
session_id=active_session_id,
|
|
782
|
+
tool_name="Stop",
|
|
783
|
+
tool_input={},
|
|
784
|
+
tool_response={"content": "Agent stopped"},
|
|
785
|
+
is_error=False,
|
|
786
|
+
agent_id=detected_agent,
|
|
787
|
+
model=detected_model,
|
|
788
|
+
)
|
|
420
789
|
except Exception as e:
|
|
421
790
|
print(f"Warning: Could not track stop: {e}", file=sys.stderr)
|
|
422
791
|
return {"continue": True}
|
|
@@ -432,6 +801,22 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
432
801
|
manager.track_activity(
|
|
433
802
|
session_id=active_session_id, tool="UserQuery", summary=f'"{preview}"'
|
|
434
803
|
)
|
|
804
|
+
|
|
805
|
+
# Record to SQLite if available
|
|
806
|
+
# UserQuery event is stored in database - no file-based state needed
|
|
807
|
+
# Subsequent tool calls query database for parent via get_parent_user_query()
|
|
808
|
+
if db:
|
|
809
|
+
record_event_to_sqlite(
|
|
810
|
+
db=db,
|
|
811
|
+
session_id=active_session_id,
|
|
812
|
+
tool_name="UserQuery",
|
|
813
|
+
tool_input={"prompt": prompt},
|
|
814
|
+
tool_response={"content": "Query received"},
|
|
815
|
+
is_error=False,
|
|
816
|
+
agent_id=detected_agent,
|
|
817
|
+
model=detected_model,
|
|
818
|
+
)
|
|
819
|
+
|
|
435
820
|
except Exception as e:
|
|
436
821
|
print(f"Warning: Could not track query: {e}", file=sys.stderr)
|
|
437
822
|
return {"continue": True}
|
|
@@ -456,7 +841,7 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
456
841
|
summary = format_tool_summary(tool_name, tool_input_data, tool_response)
|
|
457
842
|
|
|
458
843
|
# Determine success
|
|
459
|
-
if isinstance(tool_response, dict):
|
|
844
|
+
if isinstance(tool_response, dict): # type: ignore[arg-type]
|
|
460
845
|
success_field = tool_response.get("success")
|
|
461
846
|
if isinstance(success_field, bool):
|
|
462
847
|
is_error = not success_field
|
|
@@ -479,26 +864,23 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
479
864
|
|
|
480
865
|
# Get drift thresholds from config
|
|
481
866
|
drift_settings = drift_config.get("drift_detection", {})
|
|
482
|
-
warning_threshold = drift_settings.get("warning_threshold"
|
|
483
|
-
auto_classify_threshold = drift_settings.get("auto_classify_threshold"
|
|
867
|
+
warning_threshold = drift_settings.get("warning_threshold") or 0.7
|
|
868
|
+
auto_classify_threshold = drift_settings.get("auto_classify_threshold") or 0.85
|
|
484
869
|
|
|
485
|
-
# Determine parent activity context
|
|
486
|
-
parent_activity_state = load_parent_activity(graph_dir)
|
|
870
|
+
# Determine parent activity context using database-only lookup
|
|
487
871
|
parent_activity_id = None
|
|
488
872
|
|
|
489
|
-
#
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
if parent_activity_state.get("parent_id"):
|
|
501
|
-
parent_activity_id = parent_activity_state["parent_id"]
|
|
873
|
+
# Check environment variable FIRST for cross-process parent linking
|
|
874
|
+
# This is set by PreToolUse hook when Task() spawns a subagent
|
|
875
|
+
env_parent = os.environ.get("HTMLGRAPH_PARENT_EVENT") or os.environ.get(
|
|
876
|
+
"HTMLGRAPH_PARENT_QUERY_EVENT"
|
|
877
|
+
)
|
|
878
|
+
if env_parent:
|
|
879
|
+
parent_activity_id = env_parent
|
|
880
|
+
# Query database for most recent UserQuery event as parent
|
|
881
|
+
# Database is the single source of truth for parent-child linking
|
|
882
|
+
elif db:
|
|
883
|
+
parent_activity_id = get_parent_user_query(db, active_session_id)
|
|
502
884
|
|
|
503
885
|
# Track the activity
|
|
504
886
|
nudge = None
|
|
@@ -512,11 +894,41 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
512
894
|
parent_activity_id=parent_activity_id,
|
|
513
895
|
)
|
|
514
896
|
|
|
515
|
-
#
|
|
516
|
-
if
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
897
|
+
# Record to SQLite if available
|
|
898
|
+
if db:
|
|
899
|
+
# Extract subagent_type for Task delegations
|
|
900
|
+
task_subagent_type = None
|
|
901
|
+
if tool_name == "Task":
|
|
902
|
+
task_subagent_type = tool_input_data.get(
|
|
903
|
+
"subagent_type", "general-purpose"
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
record_event_to_sqlite(
|
|
907
|
+
db=db,
|
|
908
|
+
session_id=active_session_id,
|
|
909
|
+
tool_name=tool_name,
|
|
910
|
+
tool_input=tool_input_data,
|
|
911
|
+
tool_response=tool_response,
|
|
912
|
+
is_error=is_error,
|
|
913
|
+
file_paths=file_paths if file_paths else None,
|
|
914
|
+
parent_event_id=parent_activity_id, # Link to parent event
|
|
915
|
+
agent_id=detected_agent,
|
|
916
|
+
subagent_type=task_subagent_type,
|
|
917
|
+
model=detected_model,
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
# If this was a Task() delegation, also record to agent_collaboration
|
|
921
|
+
if tool_name == "Task" and db:
|
|
922
|
+
subagent = tool_input_data.get("subagent_type", "general-purpose")
|
|
923
|
+
description = tool_input_data.get("description", "")
|
|
924
|
+
record_delegation_to_sqlite(
|
|
925
|
+
db=db,
|
|
926
|
+
session_id=active_session_id,
|
|
927
|
+
from_agent=detected_agent,
|
|
928
|
+
to_agent=subagent,
|
|
929
|
+
task_description=description,
|
|
930
|
+
task_input=tool_input_data,
|
|
931
|
+
)
|
|
520
932
|
|
|
521
933
|
# Check for drift and handle accordingly
|
|
522
934
|
# Skip drift detection for child activities (they inherit parent's context)
|
|
@@ -524,7 +936,10 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
|
|
|
524
936
|
drift_score = result.drift_score
|
|
525
937
|
feature_id = getattr(result, "feature_id", "unknown")
|
|
526
938
|
|
|
527
|
-
|
|
939
|
+
# Skip drift detection if no score available
|
|
940
|
+
if drift_score is None:
|
|
941
|
+
pass # No active features - can't calculate drift
|
|
942
|
+
elif drift_score >= auto_classify_threshold:
|
|
528
943
|
# High drift - add to classification queue
|
|
529
944
|
queue = add_to_drift_queue(
|
|
530
945
|
graph_dir,
|
|
@@ -598,7 +1013,9 @@ Task tool with subagent_type="general-purpose", model="haiku", prompt:
|
|
|
598
1013
|
Or manually create a work item in .htmlgraph/ (bug, feature, spike, or chore)."""
|
|
599
1014
|
|
|
600
1015
|
# Mark classification as triggered
|
|
601
|
-
queue["last_classification"] = datetime.now(
|
|
1016
|
+
queue["last_classification"] = datetime.now(
|
|
1017
|
+
timezone.utc
|
|
1018
|
+
).isoformat()
|
|
602
1019
|
save_drift_queue(graph_dir, queue)
|
|
603
1020
|
else:
|
|
604
1021
|
nudge = f"Drift detected ({drift_score:.2f}): Activity queued for classification ({len(queue['activities'])}/{drift_settings.get('min_activities_before_classify', 3)} needed)."
|