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/orchestrator.py
CHANGED
|
@@ -21,7 +21,7 @@ Enforcement Levels:
|
|
|
21
21
|
- guidance: ALLOWS but provides warnings and suggestions
|
|
22
22
|
|
|
23
23
|
Public API:
|
|
24
|
-
- enforce_orchestrator_mode(tool: str, params: dict) -> dict
|
|
24
|
+
- enforce_orchestrator_mode(tool: str, params: dict[str, Any]) -> dict
|
|
25
25
|
Main entry point for hook scripts. Returns hook response dict.
|
|
26
26
|
"""
|
|
27
27
|
|
|
@@ -93,7 +93,9 @@ def add_to_tool_history(tool: str) -> None:
|
|
|
93
93
|
save_tool_history(history)
|
|
94
94
|
|
|
95
95
|
|
|
96
|
-
def is_allowed_orchestrator_operation(
|
|
96
|
+
def is_allowed_orchestrator_operation(
|
|
97
|
+
tool: str, params: dict[str, Any]
|
|
98
|
+
) -> tuple[bool, str, str]:
|
|
97
99
|
"""
|
|
98
100
|
Check if operation is allowed for orchestrators.
|
|
99
101
|
|
|
@@ -268,7 +270,7 @@ def is_allowed_orchestrator_operation(tool: str, params: dict) -> tuple[bool, st
|
|
|
268
270
|
return True, "Allowed in guidance mode", "guidance-allowed"
|
|
269
271
|
|
|
270
272
|
|
|
271
|
-
def create_task_suggestion(tool: str, params: dict) -> str:
|
|
273
|
+
def create_task_suggestion(tool: str, params: dict[str, Any]) -> str:
|
|
272
274
|
"""
|
|
273
275
|
Create Task tool suggestion based on blocked operation.
|
|
274
276
|
|
|
@@ -370,7 +372,7 @@ def create_task_suggestion(tool: str, params: dict) -> str:
|
|
|
370
372
|
)
|
|
371
373
|
|
|
372
374
|
|
|
373
|
-
def enforce_orchestrator_mode(tool: str, params: dict) -> dict:
|
|
375
|
+
def enforce_orchestrator_mode(tool: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
374
376
|
"""
|
|
375
377
|
Enforce orchestrator mode rules.
|
|
376
378
|
|
|
@@ -22,7 +22,7 @@ Usage:
|
|
|
22
22
|
"""
|
|
23
23
|
|
|
24
24
|
import re
|
|
25
|
-
from typing import TypedDict
|
|
25
|
+
from typing import Any, TypedDict
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
class HookSpecificOutput(TypedDict):
|
|
@@ -93,7 +93,7 @@ def is_python_execution(command: str) -> bool:
|
|
|
93
93
|
return False
|
|
94
94
|
|
|
95
95
|
|
|
96
|
-
def should_reflect(hook_input: dict) -> tuple[bool, str]:
|
|
96
|
+
def should_reflect(hook_input: dict[str, Any]) -> tuple[bool, str]:
|
|
97
97
|
"""
|
|
98
98
|
Check if we should show reflection prompt.
|
|
99
99
|
|
|
@@ -156,7 +156,7 @@ Ask yourself:
|
|
|
156
156
|
Continue, but consider delegation for similar future tasks."""
|
|
157
157
|
|
|
158
158
|
|
|
159
|
-
def orchestrator_reflect(tool_input: dict) -> dict:
|
|
159
|
+
def orchestrator_reflect(tool_input: dict[str, Any]) -> dict[str, Any]:
|
|
160
160
|
"""
|
|
161
161
|
Main API function for orchestrator reflection.
|
|
162
162
|
|
|
@@ -184,7 +184,7 @@ def orchestrator_reflect(tool_input: dict) -> dict:
|
|
|
184
184
|
should_show, command_preview = should_reflect(tool_input)
|
|
185
185
|
|
|
186
186
|
# Build response
|
|
187
|
-
response: dict = {"continue": True}
|
|
187
|
+
response: dict[str, Any] = {"continue": True}
|
|
188
188
|
|
|
189
189
|
if should_show:
|
|
190
190
|
reflection = build_reflection_message(command_preview)
|
htmlgraph/hooks/pretooluse.py
CHANGED
|
@@ -243,8 +243,6 @@ def create_task_parent_event(
|
|
|
243
243
|
Parent event_id if successful, None otherwise
|
|
244
244
|
"""
|
|
245
245
|
try:
|
|
246
|
-
from pathlib import Path
|
|
247
|
-
|
|
248
246
|
if not db.connection:
|
|
249
247
|
db.connect()
|
|
250
248
|
|
|
@@ -252,13 +250,12 @@ def create_task_parent_event(
|
|
|
252
250
|
subagent_type = extract_subagent_type(tool_input)
|
|
253
251
|
prompt = str(tool_input.get("prompt", ""))[:200]
|
|
254
252
|
|
|
255
|
-
# Load UserQuery event ID for parent-child linking
|
|
256
|
-
graph_dir = Path.cwd() / ".htmlgraph"
|
|
253
|
+
# Load UserQuery event ID for parent-child linking from database
|
|
257
254
|
user_query_event_id = None
|
|
258
255
|
try:
|
|
259
|
-
from htmlgraph.hooks.event_tracker import
|
|
256
|
+
from htmlgraph.hooks.event_tracker import get_parent_user_query
|
|
260
257
|
|
|
261
|
-
user_query_event_id =
|
|
258
|
+
user_query_event_id = get_parent_user_query(db, session_id)
|
|
262
259
|
except Exception:
|
|
263
260
|
pass
|
|
264
261
|
|
|
@@ -343,8 +340,6 @@ def create_start_event(
|
|
|
343
340
|
"""
|
|
344
341
|
tool_use_id = None
|
|
345
342
|
try:
|
|
346
|
-
from pathlib import Path
|
|
347
|
-
|
|
348
343
|
tool_use_id = generate_tool_use_id()
|
|
349
344
|
trace_id = os.environ.get("HTMLGRAPH_TRACE_ID", tool_use_id)
|
|
350
345
|
start_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
|
@@ -352,9 +347,10 @@ def create_start_event(
|
|
|
352
347
|
# Sanitize input before storing
|
|
353
348
|
sanitized_input = sanitize_tool_input(tool_input)
|
|
354
349
|
|
|
355
|
-
# Connect to database (use project's .htmlgraph/
|
|
356
|
-
|
|
357
|
-
|
|
350
|
+
# Connect to database (use project's .htmlgraph/htmlgraph.db, not home directory)
|
|
351
|
+
from htmlgraph.config import get_database_path
|
|
352
|
+
|
|
353
|
+
db_path = str(get_database_path())
|
|
358
354
|
db = HtmlGraphDB(db_path)
|
|
359
355
|
|
|
360
356
|
# Ensure session exists (create placeholder if needed)
|
|
@@ -367,10 +363,19 @@ def create_start_event(
|
|
|
367
363
|
|
|
368
364
|
cursor = db.connection.cursor() # type: ignore[union-attr]
|
|
369
365
|
|
|
366
|
+
# Get UserQuery event ID for ALL tool calls (links conversation turns)
|
|
367
|
+
user_query_event_id = None
|
|
368
|
+
try:
|
|
369
|
+
from htmlgraph.hooks.event_tracker import get_parent_user_query
|
|
370
|
+
|
|
371
|
+
user_query_event_id = get_parent_user_query(db, session_id)
|
|
372
|
+
except Exception:
|
|
373
|
+
pass
|
|
374
|
+
|
|
370
375
|
# Check if this is a Task() call for parent event creation
|
|
371
|
-
|
|
376
|
+
task_parent_event_id = None
|
|
372
377
|
if tool_name == "Task":
|
|
373
|
-
|
|
378
|
+
task_parent_event_id = create_task_parent_event(
|
|
374
379
|
db, tool_input, session_id, start_time
|
|
375
380
|
)
|
|
376
381
|
|
|
@@ -379,6 +384,11 @@ def create_start_event(
|
|
|
379
384
|
|
|
380
385
|
event_id = f"evt-{str(uuid.uuid4())[:8]}"
|
|
381
386
|
|
|
387
|
+
# Determine parent: Task() uses task_parent_event, others use UserQuery
|
|
388
|
+
parent_event_id = (
|
|
389
|
+
task_parent_event_id if tool_name == "Task" else user_query_event_id
|
|
390
|
+
)
|
|
391
|
+
|
|
382
392
|
cursor.execute(
|
|
383
393
|
"""
|
|
384
394
|
INSERT INTO agent_events
|
|
@@ -395,10 +405,17 @@ def create_start_event(
|
|
|
395
405
|
json.dumps(sanitized_input)[:500], # Truncate for summary
|
|
396
406
|
session_id,
|
|
397
407
|
"recorded",
|
|
398
|
-
parent_event_id, # Link to
|
|
408
|
+
parent_event_id, # Link to UserQuery or Task parent
|
|
399
409
|
),
|
|
400
410
|
)
|
|
401
411
|
|
|
412
|
+
# Export Bash event as parent for child processes (e.g., spawner executables)
|
|
413
|
+
if tool_name == "Bash":
|
|
414
|
+
os.environ["HTMLGRAPH_PARENT_EVENT"] = event_id
|
|
415
|
+
logger.debug(
|
|
416
|
+
f"Exported HTMLGRAPH_PARENT_EVENT={event_id} for Bash tool call"
|
|
417
|
+
)
|
|
418
|
+
|
|
402
419
|
# Also insert into tool_traces for correlation (if table exists)
|
|
403
420
|
try:
|
|
404
421
|
cursor.execute(
|
|
@@ -453,36 +470,46 @@ async def run_event_tracing(
|
|
|
453
470
|
Event tracing response: {"hookSpecificOutput": {"tool_use_id": "...", ...}}
|
|
454
471
|
"""
|
|
455
472
|
try:
|
|
473
|
+
from htmlgraph.hooks.context import HookContext
|
|
474
|
+
|
|
456
475
|
loop = asyncio.get_event_loop()
|
|
457
476
|
tool_name = tool_input.get("name", "") or tool_input.get("tool_name", "")
|
|
458
|
-
session_id = get_current_session_id()
|
|
459
477
|
|
|
460
|
-
#
|
|
461
|
-
|
|
462
|
-
logger.debug("No session ID found, skipping event tracing")
|
|
463
|
-
return {}
|
|
478
|
+
# Use HookContext to properly extract session_id (same as UserPromptSubmit)
|
|
479
|
+
context = HookContext.from_input(tool_input)
|
|
464
480
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
None,
|
|
468
|
-
create_start_event,
|
|
469
|
-
tool_name,
|
|
470
|
-
tool_input,
|
|
471
|
-
session_id,
|
|
472
|
-
)
|
|
481
|
+
try:
|
|
482
|
+
session_id = context.session_id
|
|
473
483
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
484
|
+
# Skip if no session ID
|
|
485
|
+
if not session_id or session_id == "unknown":
|
|
486
|
+
logger.debug("No session ID found, skipping event tracing")
|
|
487
|
+
return {}
|
|
477
488
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
489
|
+
# Run in thread pool since it involves I/O
|
|
490
|
+
tool_use_id = await loop.run_in_executor(
|
|
491
|
+
None,
|
|
492
|
+
create_start_event,
|
|
493
|
+
tool_name,
|
|
494
|
+
tool_input,
|
|
495
|
+
session_id,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
if tool_use_id:
|
|
499
|
+
# Store in environment for PostToolUse correlation
|
|
500
|
+
os.environ["HTMLGRAPH_TOOL_USE_ID"] = tool_use_id
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
"hookSpecificOutput": {
|
|
504
|
+
"tool_use_id": tool_use_id,
|
|
505
|
+
"additionalContext": f"Event tracing started: {tool_use_id}",
|
|
506
|
+
}
|
|
482
507
|
}
|
|
483
|
-
}
|
|
484
508
|
|
|
485
|
-
|
|
509
|
+
return {}
|
|
510
|
+
finally:
|
|
511
|
+
# Ensure context resources are properly closed
|
|
512
|
+
context.close()
|
|
486
513
|
except Exception:
|
|
487
514
|
# Graceful degradation - allow on error
|
|
488
515
|
return {}
|
|
@@ -15,14 +15,11 @@ The module is designed to be reusable across different hook implementations,
|
|
|
15
15
|
with graceful degradation if dependencies are unavailable.
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
import json
|
|
19
18
|
import logging
|
|
20
|
-
import os
|
|
21
19
|
import re
|
|
22
20
|
import uuid
|
|
23
21
|
from datetime import datetime, timezone
|
|
24
|
-
from
|
|
25
|
-
from typing import Any, Optional
|
|
22
|
+
from typing import Any
|
|
26
23
|
|
|
27
24
|
from htmlgraph.hooks.context import HookContext
|
|
28
25
|
|
|
@@ -276,7 +273,7 @@ def get_session_violation_count(context: HookContext) -> tuple[int, int]:
|
|
|
276
273
|
return 0, 0
|
|
277
274
|
|
|
278
275
|
|
|
279
|
-
def get_active_work_item(context: HookContext) ->
|
|
276
|
+
def get_active_work_item(context: HookContext) -> dict[str, Any] | None:
|
|
280
277
|
"""
|
|
281
278
|
Query HtmlGraph for active feature/spike.
|
|
282
279
|
|
|
@@ -299,15 +296,18 @@ def get_active_work_item(context: HookContext) -> Optional[dict[str, Any]]:
|
|
|
299
296
|
|
|
300
297
|
sdk = SDK()
|
|
301
298
|
work_item = sdk.get_active_work_item()
|
|
302
|
-
|
|
299
|
+
if work_item is None:
|
|
300
|
+
return None
|
|
301
|
+
# Convert ActiveWorkItem TypedDict to dict
|
|
302
|
+
return dict(work_item) if hasattr(work_item, "__iter__") else work_item # type: ignore
|
|
303
303
|
except Exception as e:
|
|
304
304
|
logger.debug(f"Could not get active work item: {e}")
|
|
305
305
|
return None
|
|
306
306
|
|
|
307
307
|
|
|
308
308
|
def generate_guidance(
|
|
309
|
-
classification: dict[str, Any], active_work:
|
|
310
|
-
) ->
|
|
309
|
+
classification: dict[str, Any], active_work: dict[str, Any] | None, prompt: str
|
|
310
|
+
) -> str | None:
|
|
311
311
|
"""
|
|
312
312
|
Generate workflow guidance based on classification and context.
|
|
313
313
|
|
|
@@ -447,7 +447,7 @@ def generate_guidance(
|
|
|
447
447
|
|
|
448
448
|
|
|
449
449
|
def generate_cigs_guidance(
|
|
450
|
-
cigs_intent: dict, violation_count: int, waste_tokens: int
|
|
450
|
+
cigs_intent: dict[str, Any], violation_count: int, waste_tokens: int
|
|
451
451
|
) -> str:
|
|
452
452
|
"""
|
|
453
453
|
Generate CIGS-specific guidance for detected violations.
|
|
@@ -521,13 +521,13 @@ def generate_cigs_guidance(
|
|
|
521
521
|
return "\n".join(guidance_parts)
|
|
522
522
|
|
|
523
523
|
|
|
524
|
-
def create_user_query_event(context: HookContext, prompt: str) ->
|
|
524
|
+
def create_user_query_event(context: HookContext, prompt: str) -> str | None:
|
|
525
525
|
"""
|
|
526
526
|
Create UserQuery event in HtmlGraph database.
|
|
527
527
|
|
|
528
528
|
Records the user prompt as a UserQuery event that serves as the parent
|
|
529
|
-
for subsequent tool calls and delegations.
|
|
530
|
-
|
|
529
|
+
for subsequent tool calls and delegations. Database is the single source
|
|
530
|
+
of truth - no file-based state is used.
|
|
531
531
|
|
|
532
532
|
Args:
|
|
533
533
|
context: HookContext with session and database access
|
|
@@ -548,7 +548,6 @@ def create_user_query_event(context: HookContext, prompt: str) -> Optional[str]:
|
|
|
548
548
|
"""
|
|
549
549
|
try:
|
|
550
550
|
session_id = context.session_id
|
|
551
|
-
graph_dir = context.graph_dir
|
|
552
551
|
|
|
553
552
|
if not session_id or session_id == "unknown":
|
|
554
553
|
logger.debug("No valid session ID for UserQuery event")
|
|
@@ -556,9 +555,6 @@ def create_user_query_event(context: HookContext, prompt: str) -> Optional[str]:
|
|
|
556
555
|
|
|
557
556
|
# Create UserQuery event in database
|
|
558
557
|
try:
|
|
559
|
-
from htmlgraph.db.schema import HtmlGraphDB
|
|
560
|
-
from htmlgraph.hooks.event_tracker import save_user_query_event
|
|
561
|
-
|
|
562
558
|
db = context.database
|
|
563
559
|
|
|
564
560
|
# Ensure session exists in database before creating event
|
|
@@ -589,6 +585,8 @@ def create_user_query_event(context: HookContext, prompt: str) -> Optional[str]:
|
|
|
589
585
|
input_summary = prompt[:200]
|
|
590
586
|
|
|
591
587
|
# Insert UserQuery event into agent_events
|
|
588
|
+
# Database is the single source of truth for parent-child linking
|
|
589
|
+
# Subsequent tool calls query database via get_parent_user_query()
|
|
592
590
|
success = db.insert_event(
|
|
593
591
|
event_id=user_query_event_id,
|
|
594
592
|
agent_id="user",
|
|
@@ -606,15 +604,6 @@ def create_user_query_event(context: HookContext, prompt: str) -> Optional[str]:
|
|
|
606
604
|
logger.warning("Failed to insert UserQuery event into database")
|
|
607
605
|
return None
|
|
608
606
|
|
|
609
|
-
# Save event ID to session-scoped file for subsequent tool calls
|
|
610
|
-
# This is used by PreToolUse hook to link Task delegations
|
|
611
|
-
try:
|
|
612
|
-
save_user_query_event(graph_dir, session_id, user_query_event_id)
|
|
613
|
-
logger.debug(f"Saved UserQuery event file: {user_query_event_id}")
|
|
614
|
-
except Exception as e:
|
|
615
|
-
# If saving to file fails, still continue (database insert succeeded)
|
|
616
|
-
logger.warning(f"Could not save UserQuery event file: {e}")
|
|
617
|
-
|
|
618
607
|
logger.info(f"Created UserQuery event: {user_query_event_id}")
|
|
619
608
|
return user_query_event_id
|
|
620
609
|
|
|
@@ -89,7 +89,7 @@ def init_or_get_session(context: HookContext) -> Any | None:
|
|
|
89
89
|
return None
|
|
90
90
|
|
|
91
91
|
|
|
92
|
-
def handle_session_start(context: HookContext, session: Any | None) -> dict:
|
|
92
|
+
def handle_session_start(context: HookContext, session: Any | None) -> dict[str, Any]:
|
|
93
93
|
"""
|
|
94
94
|
Initialize HtmlGraph tracking for the session.
|
|
95
95
|
|
|
@@ -99,6 +99,7 @@ def handle_session_start(context: HookContext, session: Any | None) -> dict:
|
|
|
99
99
|
- Builds feature context string
|
|
100
100
|
- Records session start event
|
|
101
101
|
- Creates conversation-init spike if new conversation
|
|
102
|
+
- Injects concurrent session and recent work context
|
|
102
103
|
|
|
103
104
|
Args:
|
|
104
105
|
context: HookContext with project and graph directory information
|
|
@@ -110,6 +111,7 @@ def handle_session_start(context: HookContext, session: Any | None) -> dict:
|
|
|
110
111
|
"continue": True,
|
|
111
112
|
"hookSpecificOutput": {
|
|
112
113
|
"sessionFeatureContext": str with feature context,
|
|
114
|
+
"sessionContext": optional concurrent/recent work context,
|
|
113
115
|
"versionInfo": optional version check result
|
|
114
116
|
}
|
|
115
117
|
}
|
|
@@ -118,6 +120,7 @@ def handle_session_start(context: HookContext, session: Any | None) -> dict:
|
|
|
118
120
|
"continue": True,
|
|
119
121
|
"hookSpecificOutput": {
|
|
120
122
|
"sessionFeatureContext": "",
|
|
123
|
+
"sessionContext": "",
|
|
121
124
|
"versionInfo": None,
|
|
122
125
|
},
|
|
123
126
|
}
|
|
@@ -201,10 +204,61 @@ Activity will be attributed to these features based on file patterns and keyword
|
|
|
201
204
|
except Exception as e:
|
|
202
205
|
context.log("debug", f"Could not check version: {e}")
|
|
203
206
|
|
|
207
|
+
# Build concurrent session context
|
|
208
|
+
try:
|
|
209
|
+
from htmlgraph.hooks.concurrent_sessions import (
|
|
210
|
+
format_concurrent_sessions_markdown,
|
|
211
|
+
format_recent_work_markdown,
|
|
212
|
+
get_concurrent_sessions,
|
|
213
|
+
get_recent_completed_sessions,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
db = context.database
|
|
217
|
+
|
|
218
|
+
# Get concurrent sessions (other active windows)
|
|
219
|
+
concurrent = get_concurrent_sessions(db, context.session_id, minutes=30)
|
|
220
|
+
concurrent_md = format_concurrent_sessions_markdown(concurrent)
|
|
221
|
+
|
|
222
|
+
# Get recent completed work
|
|
223
|
+
recent = get_recent_completed_sessions(db, hours=24, limit=5)
|
|
224
|
+
recent_md = format_recent_work_markdown(recent)
|
|
225
|
+
|
|
226
|
+
# Build session context
|
|
227
|
+
session_context = ""
|
|
228
|
+
if concurrent_md:
|
|
229
|
+
session_context += concurrent_md + "\n"
|
|
230
|
+
if recent_md:
|
|
231
|
+
session_context += recent_md + "\n"
|
|
232
|
+
|
|
233
|
+
if session_context:
|
|
234
|
+
output["hookSpecificOutput"]["sessionContext"] = session_context.strip()
|
|
235
|
+
context.log(
|
|
236
|
+
"info",
|
|
237
|
+
f"Injected context: {len(concurrent)} concurrent, {len(recent)} recent",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
except ImportError:
|
|
241
|
+
context.log("debug", "Concurrent session module not available")
|
|
242
|
+
except Exception as e:
|
|
243
|
+
context.log("warning", f"Failed to get concurrent session context: {e}")
|
|
244
|
+
|
|
245
|
+
# Update session with user's current query (if available from hook input)
|
|
246
|
+
try:
|
|
247
|
+
user_query = context.hook_input.get("prompt", "")
|
|
248
|
+
if user_query and session:
|
|
249
|
+
context.session_manager.track_activity(
|
|
250
|
+
session_id=session.id,
|
|
251
|
+
tool="UserQuery",
|
|
252
|
+
summary=user_query[:100],
|
|
253
|
+
payload={"query_length": len(user_query)},
|
|
254
|
+
)
|
|
255
|
+
except Exception as e:
|
|
256
|
+
context.log("warning", f"Failed to update session activity: {e}")
|
|
257
|
+
|
|
204
258
|
return output
|
|
205
259
|
|
|
206
260
|
|
|
207
|
-
def handle_session_end(context: HookContext) -> dict:
|
|
261
|
+
def handle_session_end(context: HookContext) -> dict[str, Any]:
|
|
208
262
|
"""
|
|
209
263
|
Close session gracefully and record final metrics.
|
|
210
264
|
|
|
@@ -233,76 +287,75 @@ def handle_session_end(context: HookContext) -> dict:
|
|
|
233
287
|
session = context.session_manager.get_active_session()
|
|
234
288
|
if not session:
|
|
235
289
|
context.log("debug", "No active session to close")
|
|
236
|
-
|
|
290
|
+
else:
|
|
291
|
+
# Capture handoff context if provided
|
|
292
|
+
handoff_notes = context.hook_input.get("handoff_notes") or os.environ.get(
|
|
293
|
+
"HTMLGRAPH_HANDOFF_NOTES"
|
|
294
|
+
)
|
|
295
|
+
recommended_next = context.hook_input.get(
|
|
296
|
+
"recommended_next"
|
|
297
|
+
) or os.environ.get("HTMLGRAPH_HANDOFF_RECOMMEND")
|
|
298
|
+
blockers_raw = context.hook_input.get("blockers") or os.environ.get(
|
|
299
|
+
"HTMLGRAPH_HANDOFF_BLOCKERS"
|
|
300
|
+
)
|
|
237
301
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
"HTMLGRAPH_HANDOFF_RECOMMEND"
|
|
244
|
-
)
|
|
245
|
-
blockers_raw = context.hook_input.get("blockers") or os.environ.get(
|
|
246
|
-
"HTMLGRAPH_HANDOFF_BLOCKERS"
|
|
247
|
-
)
|
|
302
|
+
blockers = None
|
|
303
|
+
if isinstance(blockers_raw, str):
|
|
304
|
+
blockers = [b.strip() for b in blockers_raw.split(",") if b.strip()]
|
|
305
|
+
elif isinstance(blockers_raw, list):
|
|
306
|
+
blockers = [str(b).strip() for b in blockers_raw if str(b).strip()]
|
|
248
307
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
308
|
+
if handoff_notes or recommended_next or blockers:
|
|
309
|
+
try:
|
|
310
|
+
context.session_manager.set_session_handoff(
|
|
311
|
+
session_id=session.id,
|
|
312
|
+
handoff_notes=handoff_notes,
|
|
313
|
+
recommended_next=recommended_next,
|
|
314
|
+
blockers=blockers,
|
|
315
|
+
)
|
|
316
|
+
context.log("info", "Session handoff recorded")
|
|
317
|
+
except Exception as e:
|
|
318
|
+
context.log("warning", f"Could not set handoff: {e}")
|
|
319
|
+
output["status"] = "partial"
|
|
320
|
+
|
|
321
|
+
# Link transcript if external session ID provided
|
|
322
|
+
external_session_id = context.hook_input.get(
|
|
323
|
+
"session_id"
|
|
324
|
+
) or os.environ.get("CLAUDE_SESSION_ID")
|
|
325
|
+
if external_session_id:
|
|
326
|
+
try:
|
|
327
|
+
from htmlgraph.transcript import TranscriptReader
|
|
328
|
+
|
|
329
|
+
reader = TranscriptReader()
|
|
330
|
+
transcript = reader.read_session(external_session_id)
|
|
331
|
+
if transcript:
|
|
332
|
+
context.session_manager.link_transcript(
|
|
333
|
+
session_id=session.id,
|
|
334
|
+
transcript_id=external_session_id,
|
|
335
|
+
transcript_path=str(transcript.path),
|
|
336
|
+
git_branch=transcript.git_branch
|
|
337
|
+
if hasattr(transcript, "git_branch")
|
|
338
|
+
else None,
|
|
339
|
+
)
|
|
340
|
+
context.log("info", "Transcript linked to session")
|
|
341
|
+
except ImportError:
|
|
342
|
+
context.log("debug", "Transcript reader not available")
|
|
343
|
+
except Exception as e:
|
|
344
|
+
context.log("warning", f"Could not link transcript: {e}")
|
|
345
|
+
output["status"] = "partial"
|
|
254
346
|
|
|
255
|
-
|
|
347
|
+
# Record session end activity
|
|
256
348
|
try:
|
|
257
|
-
context.session_manager.
|
|
349
|
+
context.session_manager.track_activity(
|
|
258
350
|
session_id=session.id,
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
blockers=blockers,
|
|
351
|
+
tool="SessionEnd",
|
|
352
|
+
summary="Session ended",
|
|
262
353
|
)
|
|
263
|
-
context.log("info", "Session handoff recorded")
|
|
264
354
|
except Exception as e:
|
|
265
|
-
context.log("warning", f"Could not
|
|
355
|
+
context.log("warning", f"Could not track session end: {e}")
|
|
266
356
|
output["status"] = "partial"
|
|
267
357
|
|
|
268
|
-
|
|
269
|
-
external_session_id = context.hook_input.get("session_id") or os.environ.get(
|
|
270
|
-
"CLAUDE_SESSION_ID"
|
|
271
|
-
)
|
|
272
|
-
if external_session_id:
|
|
273
|
-
try:
|
|
274
|
-
from htmlgraph.transcript import TranscriptReader
|
|
275
|
-
|
|
276
|
-
reader = TranscriptReader()
|
|
277
|
-
transcript = reader.read_session(external_session_id)
|
|
278
|
-
if transcript:
|
|
279
|
-
context.session_manager.link_transcript(
|
|
280
|
-
session_id=session.id,
|
|
281
|
-
transcript_id=external_session_id,
|
|
282
|
-
transcript_path=str(transcript.path),
|
|
283
|
-
git_branch=transcript.git_branch
|
|
284
|
-
if hasattr(transcript, "git_branch")
|
|
285
|
-
else None,
|
|
286
|
-
)
|
|
287
|
-
context.log("info", "Transcript linked to session")
|
|
288
|
-
except ImportError:
|
|
289
|
-
context.log("debug", "Transcript reader not available")
|
|
290
|
-
except Exception as e:
|
|
291
|
-
context.log("warning", f"Could not link transcript: {e}")
|
|
292
|
-
output["status"] = "partial"
|
|
293
|
-
|
|
294
|
-
# Record session end activity
|
|
295
|
-
try:
|
|
296
|
-
context.session_manager.track_activity(
|
|
297
|
-
session_id=session.id,
|
|
298
|
-
tool="SessionEnd",
|
|
299
|
-
summary="Session ended",
|
|
300
|
-
)
|
|
301
|
-
except Exception as e:
|
|
302
|
-
context.log("warning", f"Could not track session end: {e}")
|
|
303
|
-
output["status"] = "partial"
|
|
304
|
-
|
|
305
|
-
context.log("info", f"Session closed: {session.id}")
|
|
358
|
+
context.log("info", f"Session closed: {session.id}")
|
|
306
359
|
|
|
307
360
|
except ImportError:
|
|
308
361
|
context.log("error", "SessionManager not available")
|
|
@@ -507,7 +560,8 @@ def _get_latest_pypi_version() -> str | None:
|
|
|
507
560
|
)
|
|
508
561
|
with urllib.request.urlopen(req, timeout=5) as response:
|
|
509
562
|
data = json.loads(response.read().decode())
|
|
510
|
-
|
|
563
|
+
version: str | None = data.get("info", {}).get("version")
|
|
564
|
+
return version
|
|
511
565
|
except Exception:
|
|
512
566
|
return None
|
|
513
567
|
|
|
@@ -565,13 +619,13 @@ def _cleanup_temp_files(graph_dir: Path) -> None:
|
|
|
565
619
|
logger.debug(f"Could not clean up {path}: {e}")
|
|
566
620
|
else:
|
|
567
621
|
# Handle single file
|
|
568
|
-
|
|
622
|
+
file_path: Path = graph_dir / pattern
|
|
569
623
|
try:
|
|
570
|
-
if
|
|
571
|
-
|
|
572
|
-
logger.debug(f"Cleaned up: {
|
|
624
|
+
if file_path.exists():
|
|
625
|
+
file_path.unlink()
|
|
626
|
+
logger.debug(f"Cleaned up: {file_path}")
|
|
573
627
|
except Exception as e:
|
|
574
|
-
logger.debug(f"Could not clean up {
|
|
628
|
+
logger.debug(f"Could not clean up {file_path}: {e}")
|
|
575
629
|
|
|
576
630
|
|
|
577
631
|
__all__ = [
|
htmlgraph/hooks/state_manager.py
CHANGED
|
@@ -25,6 +25,7 @@ import os
|
|
|
25
25
|
import tempfile
|
|
26
26
|
from datetime import datetime, timedelta
|
|
27
27
|
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
28
29
|
|
|
29
30
|
logger = logging.getLogger(__name__)
|
|
30
31
|
|
|
@@ -61,7 +62,7 @@ class ParentActivityTracker:
|
|
|
61
62
|
"""Ensure .htmlgraph directory exists."""
|
|
62
63
|
self.graph_dir.mkdir(parents=True, exist_ok=True)
|
|
63
64
|
|
|
64
|
-
def load(self, max_age_minutes: int = 5) -> dict:
|
|
65
|
+
def load(self, max_age_minutes: int = 5) -> dict[str, Any]:
|
|
65
66
|
"""
|
|
66
67
|
Load parent activity state.
|
|
67
68
|
|
|
@@ -334,7 +335,7 @@ class DriftQueueManager:
|
|
|
334
335
|
"""Ensure .htmlgraph directory exists."""
|
|
335
336
|
self.graph_dir.mkdir(parents=True, exist_ok=True)
|
|
336
337
|
|
|
337
|
-
def load(self, max_age_hours: int = 48) -> dict:
|
|
338
|
+
def load(self, max_age_hours: int = 48) -> dict[str, Any]:
|
|
338
339
|
"""
|
|
339
340
|
Load drift queue and filter by age.
|
|
340
341
|
|
|
@@ -393,7 +394,7 @@ class DriftQueueManager:
|
|
|
393
394
|
logger.warning(f"Error loading drift queue: {e}")
|
|
394
395
|
return {"activities": [], "last_classification": None}
|
|
395
396
|
|
|
396
|
-
def save(self, queue: dict) -> None:
|
|
397
|
+
def save(self, queue: dict[str, Any]) -> None:
|
|
397
398
|
"""
|
|
398
399
|
Save drift queue to file.
|
|
399
400
|
|
|
@@ -425,7 +426,9 @@ class DriftQueueManager:
|
|
|
425
426
|
except Exception as e:
|
|
426
427
|
logger.error(f"Unexpected error saving drift queue: {e}")
|
|
427
428
|
|
|
428
|
-
def add_activity(
|
|
429
|
+
def add_activity(
|
|
430
|
+
self, activity: dict[str, Any], timestamp: datetime | None = None
|
|
431
|
+
) -> None:
|
|
429
432
|
"""
|
|
430
433
|
Add activity to drift queue.
|
|
431
434
|
|
htmlgraph/hooks/subagent_stop.py
CHANGED
|
@@ -231,9 +231,10 @@ def handle_subagent_stop(hook_input: dict[str, Any]) -> dict[str, Any]:
|
|
|
231
231
|
|
|
232
232
|
# Get project directory and database path
|
|
233
233
|
try:
|
|
234
|
+
from htmlgraph.config import get_database_path
|
|
235
|
+
|
|
234
236
|
cwd = hook_input.get("cwd", os.getcwd())
|
|
235
|
-
|
|
236
|
-
db_path = str(graph_dir / "index.sqlite")
|
|
237
|
+
db_path = str(get_database_path(cwd))
|
|
237
238
|
|
|
238
239
|
if not Path(db_path).exists():
|
|
239
240
|
logger.warning(f"Database not found: {db_path}")
|