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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
  2. htmlgraph/.htmlgraph/agents.json +72 -0
  3. htmlgraph/.htmlgraph/htmlgraph.db +0 -0
  4. htmlgraph/__init__.py +1 -1
  5. htmlgraph/api/main.py +252 -47
  6. htmlgraph/api/templates/dashboard.html +11 -0
  7. htmlgraph/api/templates/partials/activity-feed.html +517 -8
  8. htmlgraph/cli.py +1 -1
  9. htmlgraph/config.py +173 -96
  10. htmlgraph/dashboard.html +632 -7237
  11. htmlgraph/db/schema.py +258 -9
  12. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  13. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  14. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  15. htmlgraph/hooks/cigs_pretool_enforcer.py +2 -2
  16. htmlgraph/hooks/concurrent_sessions.py +208 -0
  17. htmlgraph/hooks/context.py +88 -10
  18. htmlgraph/hooks/drift_handler.py +24 -20
  19. htmlgraph/hooks/event_tracker.py +264 -189
  20. htmlgraph/hooks/orchestrator.py +6 -4
  21. htmlgraph/hooks/orchestrator_reflector.py +4 -4
  22. htmlgraph/hooks/pretooluse.py +63 -36
  23. htmlgraph/hooks/prompt_analyzer.py +14 -25
  24. htmlgraph/hooks/session_handler.py +123 -69
  25. htmlgraph/hooks/state_manager.py +7 -4
  26. htmlgraph/hooks/subagent_stop.py +3 -2
  27. htmlgraph/hooks/validator.py +15 -11
  28. htmlgraph/operations/fastapi_server.py +2 -2
  29. htmlgraph/orchestration/headless_spawner.py +489 -16
  30. htmlgraph/orchestration/live_events.py +377 -0
  31. htmlgraph/server.py +100 -203
  32. htmlgraph-0.26.2.data/data/htmlgraph/dashboard.html +812 -0
  33. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/METADATA +1 -1
  34. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/RECORD +40 -32
  35. htmlgraph-0.25.0.data/data/htmlgraph/dashboard.html +0 -7417
  36. {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/styles.css +0 -0
  37. {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  38. {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  39. {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  40. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/WHEEL +0 -0
  41. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/entry_points.txt +0 -0
@@ -37,6 +37,7 @@ class HookContext:
37
37
  session_id: Unique session identifier for this execution
38
38
  agent_id: Agent/tool that's executing (e.g., 'claude-code', 'codex')
39
39
  hook_input: Raw hook input data from Claude Code
40
+ model_name: Specific Claude model name (e.g., 'claude-haiku', 'claude-opus', 'claude-sonnet')
40
41
  _session_manager: Cached SessionManager instance (lazy-loaded)
41
42
  _database: Cached HtmlGraphDB instance (lazy-loaded)
42
43
  """
@@ -45,18 +46,20 @@ class HookContext:
45
46
  graph_dir: Path
46
47
  session_id: str
47
48
  agent_id: str
48
- hook_input: dict
49
+ hook_input: dict[str, Any]
50
+ model_name: str | None = field(default=None, repr=False)
49
51
  _session_manager: Any | None = field(default=None, repr=False)
50
52
  _database: Any | None = field(default=None, repr=False)
51
53
 
52
54
  @classmethod
53
- def from_input(cls, hook_input: dict) -> "HookContext":
55
+ def from_input(cls, hook_input: dict[str, Any]) -> "HookContext":
54
56
  """
55
57
  Create HookContext from raw hook input.
56
58
 
57
59
  Performs automatic environment resolution:
58
60
  - Extracts session_id from hook_input
59
61
  - Detects agent_id from environment or hook_input
62
+ - Detects model_name (e.g., claude-haiku, claude-opus, claude-sonnet)
60
63
  - Resolves project directory via bootstrap
61
64
  - Initializes graph directory
62
65
 
@@ -79,7 +82,7 @@ class HookContext:
79
82
  ...
80
83
  }
81
84
  context = HookContext.from_input(hook_input)
82
- logger.info(f"Session: {context.session_id}, Agent: {context.agent_id}")
85
+ logger.info(f"Session: {context.session_id}, Agent: {context.agent_id}, Model: {context.model_name}")
83
86
  ```
84
87
  """
85
88
  # Import bootstrap locally to avoid circular imports
@@ -88,8 +91,69 @@ class HookContext:
88
91
  resolve_project_dir,
89
92
  )
90
93
 
91
- # Extract session ID from hook input
92
- session_id = hook_input.get("session_id", "unknown")
94
+ # Resolve project directory first
95
+ project_dir = resolve_project_dir()
96
+ graph_dir = get_graph_dir(project_dir)
97
+
98
+ # Extract session ID with multiple fallbacks
99
+ # Priority order:
100
+ # 1. hook_input["session_id"] (if Claude Code passes it)
101
+ # 2. hook_input["sessionId"] (camelCase variant)
102
+ # 3. HTMLGRAPH_SESSION_ID environment variable
103
+ # 4. CLAUDE_SESSION_ID environment variable
104
+ # 5. Most recent active session from database (NEW)
105
+ # 6. "unknown" as last resort
106
+ #
107
+ # NOTE: We intentionally do NOT use SessionManager.get_active_session()
108
+ # as a fallback because the "active session" is stored in a global file
109
+ # (.htmlgraph/session.json) that's shared across all Claude windows.
110
+ # Using it would cause cross-window event contamination where tool calls
111
+ # from Window B get linked to UserQuery events from Window A.
112
+ #
113
+ # However, we DO query the database by status='active' and created_at,
114
+ # which is different because it retrieves the most recent session that
115
+ # was explicitly marked as active (e.g., by SessionStart hook), without
116
+ # relying on a shared global agent state file.
117
+ session_id = (
118
+ hook_input.get("session_id")
119
+ or hook_input.get("sessionId")
120
+ or os.environ.get("HTMLGRAPH_SESSION_ID")
121
+ or os.environ.get("CLAUDE_SESSION_ID")
122
+ )
123
+
124
+ # Fallback: Query database for most recent active session
125
+ # This solves the issue where PostToolUse hooks don't receive session_id
126
+ # in hook_input, but SessionStart hook already created a session in the database.
127
+ if not session_id:
128
+ db_path = graph_dir / "htmlgraph.db"
129
+ if db_path.exists():
130
+ try:
131
+ import sqlite3
132
+
133
+ conn = sqlite3.connect(str(db_path), timeout=1.0)
134
+ cursor = conn.cursor()
135
+ cursor.execute("""
136
+ SELECT session_id FROM sessions
137
+ WHERE status = 'active'
138
+ ORDER BY created_at DESC
139
+ LIMIT 1
140
+ """)
141
+ row = cursor.fetchone()
142
+ conn.close()
143
+ if row:
144
+ session_id = row[0]
145
+ logger.info(f"Resolved session_id from database: {session_id}")
146
+ except Exception as e:
147
+ logger.warning(f"Failed to query active session from database: {e}")
148
+
149
+ # Final fallback to "unknown" if database query fails
150
+ if not session_id:
151
+ session_id = "unknown"
152
+ logger.warning(
153
+ "Could not resolve session_id from hook_input, environment, or database. "
154
+ "Events will not be linked to parent UserQuery. "
155
+ "For multi-window support, set HTMLGRAPH_SESSION_ID env var."
156
+ )
93
157
 
94
158
  # Detect agent ID (priority order)
95
159
  # 1. Explicit agent_id in hook input
@@ -102,15 +166,28 @@ class HookContext:
102
166
  or os.environ.get("CLAUDE_AGENT_NICKNAME", "unknown")
103
167
  )
104
168
 
105
- # Resolve project directory with fallbacks
106
- project_dir = resolve_project_dir()
169
+ # Detect model name (priority order)
170
+ # 1. Explicit model_name in hook input
171
+ # 2. CLAUDE_MODEL environment variable
172
+ # 3. HTMLGRAPH_MODEL environment variable
173
+ # 4. Status line cache (from ~/.cache/claude-code/status-{session_id}.json)
174
+ # 5. None (not available)
175
+ model_name = (
176
+ hook_input.get("model_name")
177
+ or hook_input.get("model")
178
+ or os.environ.get("CLAUDE_MODEL")
179
+ or os.environ.get("HTMLGRAPH_MODEL")
180
+ )
107
181
 
108
- # Get or create graph directory
109
- graph_dir = get_graph_dir(project_dir)
182
+ # Fallback: Try status line cache if model not detected yet
183
+ if not model_name and session_id and session_id != "unknown":
184
+ from htmlgraph.hooks.event_tracker import get_model_from_status_cache
185
+
186
+ model_name = get_model_from_status_cache(session_id)
110
187
 
111
188
  logger.info(
112
189
  f"Initializing hook context: session={session_id}, "
113
- f"agent={agent_id}, project={project_dir}"
190
+ f"agent={agent_id}, model={model_name}, project={project_dir}"
114
191
  )
115
192
 
116
193
  return cls(
@@ -119,6 +196,7 @@ class HookContext:
119
196
  session_id=session_id,
120
197
  agent_id=agent_id,
121
198
  hook_input=hook_input,
199
+ model_name=model_name,
122
200
  )
123
201
 
124
202
  @property
@@ -25,10 +25,9 @@ import os
25
25
  import subprocess
26
26
  from datetime import datetime, timedelta
27
27
  from pathlib import Path
28
- from typing import Any, Optional
28
+ from typing import Any
29
29
 
30
30
  from htmlgraph.hooks.context import HookContext
31
- from htmlgraph.hooks.state_manager import DriftQueueManager
32
31
 
33
32
  logger = logging.getLogger(__name__)
34
33
 
@@ -113,7 +112,7 @@ DEFAULT_DRIFT_CONFIG = {
113
112
  }
114
113
 
115
114
 
116
- def load_drift_config(graph_dir: Path) -> dict:
115
+ def load_drift_config(graph_dir: Path) -> dict[str, Any]:
117
116
  """
118
117
  Load drift configuration from project or fallback to defaults.
119
118
 
@@ -151,7 +150,7 @@ def load_drift_config(graph_dir: Path) -> dict:
151
150
  if config_path.exists() and config_path.is_file():
152
151
  try:
153
152
  with open(config_path) as f:
154
- config = json.load(f)
153
+ config: dict[str, Any] = json.load(f)
155
154
  logger.debug(f"Loaded drift config from {config_path}")
156
155
  return config
157
156
  except json.JSONDecodeError as e:
@@ -163,7 +162,9 @@ def load_drift_config(graph_dir: Path) -> dict:
163
162
  return DEFAULT_DRIFT_CONFIG
164
163
 
165
164
 
166
- def detect_drift(activity_result: dict, config: dict) -> tuple[float, Optional[str]]:
165
+ def detect_drift(
166
+ activity_result: dict[str, Any], config: dict[str, Any]
167
+ ) -> tuple[float, str | None]:
167
168
  """
168
169
  Calculate drift score from activity result and check thresholds.
169
170
 
@@ -201,9 +202,7 @@ def detect_drift(activity_result: dict, config: dict) -> tuple[float, Optional[s
201
202
  drift_score = getattr(activity_result, "drift_score", 0.0) or 0.0
202
203
  feature_id = getattr(activity_result, "feature_id", None)
203
204
 
204
- logger.debug(
205
- f"Drift detected: score={drift_score:.2f}, feature={feature_id}"
206
- )
205
+ logger.debug(f"Drift detected: score={drift_score:.2f}, feature={feature_id}")
207
206
 
208
207
  return (drift_score, feature_id)
209
208
 
@@ -211,9 +210,9 @@ def detect_drift(activity_result: dict, config: dict) -> tuple[float, Optional[s
211
210
  def handle_high_drift(
212
211
  context: HookContext,
213
212
  drift_score: float,
214
- queue: dict,
215
- config: dict,
216
- ) -> Optional[str]:
213
+ queue: dict[str, Any],
214
+ config: dict[str, Any],
215
+ ) -> str | None:
217
216
  """
218
217
  Generate nudge message for high-drift activities.
219
218
 
@@ -255,7 +254,6 @@ def handle_high_drift(
255
254
  return None
256
255
 
257
256
  # Get queue size for nudge message
258
- queue_manager = DriftQueueManager(context.graph_dir)
259
257
  min_activities = drift_config.get("min_activities_before_classify", 3)
260
258
  current_count = len(queue.get("activities", []))
261
259
 
@@ -275,9 +273,9 @@ def handle_high_drift(
275
273
 
276
274
  def trigger_auto_classification(
277
275
  context: HookContext,
278
- queue: dict,
276
+ queue: dict[str, Any],
279
277
  feature_id: str,
280
- config: dict,
278
+ config: dict[str, Any],
281
279
  ) -> bool:
282
280
  """
283
281
  Check if auto-classification should be triggered.
@@ -344,7 +342,7 @@ def trigger_auto_classification(
344
342
  return True
345
343
 
346
344
 
347
- def build_classification_prompt(queue: dict, feature_id: str) -> str:
345
+ def build_classification_prompt(queue: dict[str, Any], feature_id: str) -> str:
348
346
  """
349
347
  Build structured prompt for auto-classification agent.
350
348
 
@@ -423,8 +421,8 @@ Create the work item now using Write tool."""
423
421
 
424
422
 
425
423
  def run_headless_classification(
426
- context: HookContext, prompt: str, config: dict
427
- ) -> tuple[bool, Optional[str]]:
424
+ context: HookContext, prompt: str, config: dict[str, Any]
425
+ ) -> tuple[bool, str | None]:
428
426
  """
429
427
  Attempt to run auto-classification via headless claude subprocess.
430
428
 
@@ -460,7 +458,14 @@ def run_headless_classification(
460
458
 
461
459
  try:
462
460
  result = subprocess.run(
463
- ["claude", "-p", prompt, "--model", model, "--dangerously-skip-permissions"],
461
+ [
462
+ "claude",
463
+ "-p",
464
+ prompt,
465
+ "--model",
466
+ model,
467
+ "--dangerously-skip-permissions",
468
+ ],
464
469
  capture_output=True,
465
470
  text=True,
466
471
  timeout=120,
@@ -497,8 +502,7 @@ def run_headless_classification(
497
502
  except FileNotFoundError:
498
503
  logger.error("claude command not found")
499
504
  nudge = (
500
- "HIGH DRIFT - claude not available. "
501
- "Please classify manually in .htmlgraph/"
505
+ "HIGH DRIFT - claude not available. Please classify manually in .htmlgraph/"
502
506
  )
503
507
  return (False, nudge)
504
508
  except Exception as e: