htmlgraph 0.20.1__py3-none-any.whl → 0.27.5__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 +51 -1
- htmlgraph/__init__.pyi +123 -0
- htmlgraph/agent_detection.py +26 -10
- htmlgraph/agent_registry.py +2 -1
- htmlgraph/analytics/__init__.py +8 -1
- htmlgraph/analytics/cli.py +86 -20
- htmlgraph/analytics/cost_analyzer.py +391 -0
- htmlgraph/analytics/cost_monitor.py +664 -0
- htmlgraph/analytics/cost_reporter.py +675 -0
- htmlgraph/analytics/cross_session.py +617 -0
- htmlgraph/analytics/dependency.py +10 -6
- htmlgraph/analytics/pattern_learning.py +771 -0
- htmlgraph/analytics/session_graph.py +707 -0
- htmlgraph/analytics/strategic/__init__.py +80 -0
- htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
- htmlgraph/analytics/strategic/pattern_detector.py +876 -0
- htmlgraph/analytics/strategic/preference_manager.py +709 -0
- htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
- htmlgraph/analytics/work_type.py +67 -27
- htmlgraph/analytics_index.py +53 -20
- htmlgraph/api/__init__.py +3 -0
- htmlgraph/api/cost_alerts_websocket.py +416 -0
- htmlgraph/api/main.py +2498 -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 +1366 -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 +1100 -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 +578 -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 +198 -0
- htmlgraph/api/templates/partials/spawners.html +375 -0
- htmlgraph/api/templates/partials/work-items.html +613 -0
- htmlgraph/api/websocket.py +538 -0
- htmlgraph/archive/__init__.py +24 -0
- htmlgraph/archive/bloom.py +234 -0
- htmlgraph/archive/fts.py +297 -0
- htmlgraph/archive/manager.py +583 -0
- htmlgraph/archive/search.py +244 -0
- htmlgraph/atomic_ops.py +560 -0
- htmlgraph/attribute_index.py +2 -1
- htmlgraph/bounded_paths.py +539 -0
- htmlgraph/builders/base.py +57 -2
- htmlgraph/builders/bug.py +19 -3
- htmlgraph/builders/chore.py +19 -3
- htmlgraph/builders/epic.py +19 -3
- htmlgraph/builders/feature.py +27 -3
- htmlgraph/builders/insight.py +2 -1
- htmlgraph/builders/metric.py +2 -1
- htmlgraph/builders/pattern.py +2 -1
- htmlgraph/builders/phase.py +19 -3
- htmlgraph/builders/spike.py +29 -3
- htmlgraph/builders/track.py +42 -1
- htmlgraph/cigs/__init__.py +81 -0
- htmlgraph/cigs/autonomy.py +385 -0
- htmlgraph/cigs/cost.py +475 -0
- htmlgraph/cigs/messages_basic.py +472 -0
- htmlgraph/cigs/messaging.py +365 -0
- htmlgraph/cigs/models.py +771 -0
- htmlgraph/cigs/pattern_storage.py +427 -0
- htmlgraph/cigs/patterns.py +503 -0
- htmlgraph/cigs/posttool_analyzer.py +234 -0
- htmlgraph/cigs/reporter.py +818 -0
- htmlgraph/cigs/tracker.py +317 -0
- htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/cli/.htmlgraph/agents.json +72 -0
- htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/cli/__init__.py +42 -0
- htmlgraph/cli/__main__.py +6 -0
- htmlgraph/cli/analytics.py +1424 -0
- htmlgraph/cli/base.py +685 -0
- htmlgraph/cli/constants.py +206 -0
- htmlgraph/cli/core.py +954 -0
- htmlgraph/cli/main.py +147 -0
- htmlgraph/cli/models.py +475 -0
- htmlgraph/cli/templates/__init__.py +1 -0
- htmlgraph/cli/templates/cost_dashboard.py +399 -0
- htmlgraph/cli/work/__init__.py +239 -0
- htmlgraph/cli/work/browse.py +115 -0
- htmlgraph/cli/work/features.py +568 -0
- htmlgraph/cli/work/orchestration.py +676 -0
- htmlgraph/cli/work/report.py +728 -0
- htmlgraph/cli/work/sessions.py +466 -0
- htmlgraph/cli/work/snapshot.py +559 -0
- htmlgraph/cli/work/tracks.py +486 -0
- 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 +197 -14
- htmlgraph/collections/bug.py +2 -1
- htmlgraph/collections/chore.py +2 -1
- htmlgraph/collections/epic.py +2 -1
- htmlgraph/collections/feature.py +2 -1
- htmlgraph/collections/insight.py +2 -1
- htmlgraph/collections/metric.py +2 -1
- htmlgraph/collections/pattern.py +2 -1
- htmlgraph/collections/phase.py +2 -1
- htmlgraph/collections/session.py +194 -0
- htmlgraph/collections/spike.py +13 -2
- htmlgraph/collections/task_delegation.py +241 -0
- htmlgraph/collections/todo.py +14 -1
- htmlgraph/collections/traces.py +487 -0
- htmlgraph/config/cost_models.json +56 -0
- htmlgraph/config.py +190 -0
- htmlgraph/context_analytics.py +2 -1
- htmlgraph/converter.py +116 -7
- htmlgraph/cost_analysis/__init__.py +5 -0
- htmlgraph/cost_analysis/analyzer.py +438 -0
- htmlgraph/dashboard.html +2246 -248
- htmlgraph/dashboard.html.backup +6592 -0
- 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 +1788 -0
- htmlgraph/decorators.py +317 -0
- htmlgraph/dependency_models.py +2 -1
- 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 +717 -0
- htmlgraph/docs/README.md +532 -0
- htmlgraph/docs/__init__.py +77 -0
- htmlgraph/docs/docs_version.py +55 -0
- htmlgraph/docs/metadata.py +93 -0
- htmlgraph/docs/migrations.py +232 -0
- htmlgraph/docs/template_engine.py +143 -0
- htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
- htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
- htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
- htmlgraph/docs/templates/base_agents.md.j2 +78 -0
- htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
- htmlgraph/docs/version_check.py +163 -0
- htmlgraph/edge_index.py +2 -1
- htmlgraph/error_handler.py +544 -0
- htmlgraph/event_log.py +86 -37
- htmlgraph/event_migration.py +2 -1
- htmlgraph/file_watcher.py +12 -8
- htmlgraph/find_api.py +2 -1
- htmlgraph/git_events.py +67 -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/__init__.py +8 -0
- htmlgraph/hooks/bootstrap.py +169 -0
- htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
- htmlgraph/hooks/concurrent_sessions.py +208 -0
- htmlgraph/hooks/context.py +350 -0
- htmlgraph/hooks/drift_handler.py +525 -0
- htmlgraph/hooks/event_tracker.py +790 -99
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/installer.py +5 -1
- htmlgraph/hooks/orchestrator.py +327 -76
- htmlgraph/hooks/orchestrator_reflector.py +31 -4
- htmlgraph/hooks/post_tool_use_failure.py +32 -7
- htmlgraph/hooks/post_tool_use_handler.py +257 -0
- htmlgraph/hooks/posttooluse.py +92 -19
- htmlgraph/hooks/pretooluse.py +527 -7
- htmlgraph/hooks/prompt_analyzer.py +637 -0
- htmlgraph/hooks/session_handler.py +668 -0
- htmlgraph/hooks/session_summary.py +395 -0
- htmlgraph/hooks/state_manager.py +504 -0
- htmlgraph/hooks/subagent_detection.py +202 -0
- htmlgraph/hooks/subagent_stop.py +369 -0
- htmlgraph/hooks/task_enforcer.py +99 -4
- htmlgraph/hooks/validator.py +212 -91
- htmlgraph/ids.py +2 -1
- htmlgraph/learning.py +125 -100
- htmlgraph/mcp_server.py +2 -1
- htmlgraph/models.py +217 -18
- htmlgraph/operations/README.md +62 -0
- htmlgraph/operations/__init__.py +79 -0
- htmlgraph/operations/analytics.py +339 -0
- htmlgraph/operations/bootstrap.py +289 -0
- htmlgraph/operations/events.py +244 -0
- htmlgraph/operations/fastapi_server.py +231 -0
- htmlgraph/operations/hooks.py +350 -0
- htmlgraph/operations/initialization.py +597 -0
- htmlgraph/operations/initialization.py.backup +228 -0
- htmlgraph/operations/server.py +303 -0
- htmlgraph/orchestration/__init__.py +58 -0
- htmlgraph/orchestration/claude_launcher.py +179 -0
- htmlgraph/orchestration/command_builder.py +72 -0
- htmlgraph/orchestration/headless_spawner.py +281 -0
- htmlgraph/orchestration/live_events.py +377 -0
- htmlgraph/orchestration/model_selection.py +327 -0
- htmlgraph/orchestration/plugin_manager.py +140 -0
- htmlgraph/orchestration/prompts.py +137 -0
- htmlgraph/orchestration/spawner_event_tracker.py +383 -0
- htmlgraph/orchestration/spawners/__init__.py +16 -0
- htmlgraph/orchestration/spawners/base.py +194 -0
- htmlgraph/orchestration/spawners/claude.py +173 -0
- htmlgraph/orchestration/spawners/codex.py +435 -0
- htmlgraph/orchestration/spawners/copilot.py +294 -0
- htmlgraph/orchestration/spawners/gemini.py +471 -0
- htmlgraph/orchestration/subprocess_runner.py +36 -0
- htmlgraph/{orchestration.py → orchestration/task_coordination.py} +16 -8
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
- htmlgraph/orchestrator.py +2 -1
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +115 -4
- htmlgraph/parallel.py +2 -1
- htmlgraph/parser.py +86 -6
- htmlgraph/path_query.py +608 -0
- htmlgraph/pattern_matcher.py +636 -0
- htmlgraph/pydantic_models.py +476 -0
- htmlgraph/quality_gates.py +350 -0
- htmlgraph/query_builder.py +2 -1
- htmlgraph/query_composer.py +509 -0
- htmlgraph/reflection.py +443 -0
- htmlgraph/refs.py +344 -0
- htmlgraph/repo_hash.py +512 -0
- htmlgraph/repositories/__init__.py +292 -0
- htmlgraph/repositories/analytics_repository.py +455 -0
- htmlgraph/repositories/analytics_repository_standard.py +628 -0
- htmlgraph/repositories/feature_repository.py +581 -0
- htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
- htmlgraph/repositories/feature_repository_memory.py +607 -0
- htmlgraph/repositories/feature_repository_sqlite.py +858 -0
- htmlgraph/repositories/filter_service.py +620 -0
- htmlgraph/repositories/filter_service_standard.py +445 -0
- htmlgraph/repositories/shared_cache.py +621 -0
- htmlgraph/repositories/shared_cache_memory.py +395 -0
- htmlgraph/repositories/track_repository.py +552 -0
- htmlgraph/repositories/track_repository_htmlfile.py +619 -0
- htmlgraph/repositories/track_repository_memory.py +508 -0
- htmlgraph/repositories/track_repository_sqlite.py +711 -0
- htmlgraph/sdk/__init__.py +398 -0
- htmlgraph/sdk/__init__.pyi +14 -0
- htmlgraph/sdk/analytics/__init__.py +19 -0
- htmlgraph/sdk/analytics/engine.py +155 -0
- htmlgraph/sdk/analytics/helpers.py +178 -0
- htmlgraph/sdk/analytics/registry.py +109 -0
- htmlgraph/sdk/base.py +484 -0
- htmlgraph/sdk/constants.py +216 -0
- htmlgraph/sdk/core.pyi +308 -0
- htmlgraph/sdk/discovery.py +120 -0
- htmlgraph/sdk/help/__init__.py +12 -0
- htmlgraph/sdk/help/mixin.py +699 -0
- htmlgraph/sdk/mixins/__init__.py +15 -0
- htmlgraph/sdk/mixins/attribution.py +113 -0
- htmlgraph/sdk/mixins/mixin.py +410 -0
- htmlgraph/sdk/operations/__init__.py +12 -0
- htmlgraph/sdk/operations/mixin.py +427 -0
- htmlgraph/sdk/orchestration/__init__.py +17 -0
- htmlgraph/sdk/orchestration/coordinator.py +203 -0
- htmlgraph/sdk/orchestration/spawner.py +204 -0
- htmlgraph/sdk/planning/__init__.py +19 -0
- htmlgraph/sdk/planning/bottlenecks.py +93 -0
- htmlgraph/sdk/planning/mixin.py +211 -0
- htmlgraph/sdk/planning/parallel.py +186 -0
- htmlgraph/sdk/planning/queue.py +210 -0
- htmlgraph/sdk/planning/recommendations.py +87 -0
- htmlgraph/sdk/planning/smart_planning.py +319 -0
- htmlgraph/sdk/session/__init__.py +19 -0
- htmlgraph/sdk/session/continuity.py +57 -0
- htmlgraph/sdk/session/handoff.py +110 -0
- htmlgraph/sdk/session/info.py +309 -0
- htmlgraph/sdk/session/manager.py +103 -0
- htmlgraph/sdk/strategic/__init__.py +26 -0
- htmlgraph/sdk/strategic/mixin.py +563 -0
- htmlgraph/server.py +295 -107
- htmlgraph/session_hooks.py +300 -0
- htmlgraph/session_manager.py +285 -3
- htmlgraph/session_registry.py +587 -0
- htmlgraph/session_state.py +436 -0
- htmlgraph/session_warning.py +2 -1
- htmlgraph/sessions/__init__.py +23 -0
- htmlgraph/sessions/handoff.py +756 -0
- htmlgraph/system_prompts.py +450 -0
- htmlgraph/templates/orchestration-view.html +350 -0
- htmlgraph/track_builder.py +33 -1
- htmlgraph/track_manager.py +38 -0
- htmlgraph/transcript.py +18 -5
- htmlgraph/validation.py +115 -0
- htmlgraph/watch.py +2 -1
- htmlgraph/work_type_utils.py +2 -1
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2246 -248
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +95 -64
- htmlgraph-0.27.5.dist-info/RECORD +337 -0
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -4839
- htmlgraph/sdk.py +0 -2359
- htmlgraph-0.20.1.dist-info/RECORD +0 -118
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HtmlGraph Hooks State Manager
|
|
3
|
+
|
|
4
|
+
Unified state file management for hook operations:
|
|
5
|
+
- Parent activity tracking (for Skill/Task context)
|
|
6
|
+
- User query event tracking (for parent-child linking)
|
|
7
|
+
- Drift queue management (for auto-classification)
|
|
8
|
+
|
|
9
|
+
This module provides file-based state persistence with:
|
|
10
|
+
- Atomic writes (write to temp, then rename)
|
|
11
|
+
- File locking to prevent concurrent writes
|
|
12
|
+
- Error handling for missing/corrupted files
|
|
13
|
+
- Age-based filtering and cleanup
|
|
14
|
+
- Comprehensive logging
|
|
15
|
+
|
|
16
|
+
File Locations (.htmlgraph/):
|
|
17
|
+
- parent-activity.json: Current parent context (Skill/Task invocation)
|
|
18
|
+
- user-query-event-{SESSION_ID}.json: UserQuery event ID for session
|
|
19
|
+
- drift-queue.json: Classification queue for high-drift activities
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import tempfile
|
|
26
|
+
from datetime import datetime, timedelta
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ParentActivityTracker:
|
|
34
|
+
"""
|
|
35
|
+
Tracks the active parent activity context for Skill/Task invocations.
|
|
36
|
+
|
|
37
|
+
Parent context allows child tool calls to link to their parent Skill/Task.
|
|
38
|
+
Parent activities automatically expire after 5 minutes of inactivity.
|
|
39
|
+
|
|
40
|
+
File: parent-activity.json (single entry)
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"parent_id": "evt-xyz123",
|
|
44
|
+
"tool": "Task",
|
|
45
|
+
"timestamp": "2025-01-10T12:34:56Z"
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, graph_dir: Path):
|
|
51
|
+
"""
|
|
52
|
+
Initialize parent activity tracker.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
graph_dir: Path to .htmlgraph directory
|
|
56
|
+
"""
|
|
57
|
+
self.graph_dir = Path(graph_dir)
|
|
58
|
+
self.file_path = self.graph_dir / "parent-activity.json"
|
|
59
|
+
self._ensure_graph_dir()
|
|
60
|
+
|
|
61
|
+
def _ensure_graph_dir(self) -> None:
|
|
62
|
+
"""Ensure .htmlgraph directory exists."""
|
|
63
|
+
self.graph_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
def load(self, max_age_minutes: int = 5) -> dict[str, Any]:
|
|
66
|
+
"""
|
|
67
|
+
Load parent activity state.
|
|
68
|
+
|
|
69
|
+
Automatically filters out stale parent activities older than max_age_minutes.
|
|
70
|
+
This allows long-running parent contexts (like Tasks) to timeout naturally.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
max_age_minutes: Maximum age in minutes before activity is considered stale
|
|
74
|
+
(default: 5 minutes)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Parent activity dict with keys: parent_id, tool, timestamp
|
|
78
|
+
Empty dict if file missing or stale
|
|
79
|
+
"""
|
|
80
|
+
if not self.file_path.exists():
|
|
81
|
+
return {}
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
with open(self.file_path) as f:
|
|
85
|
+
data: dict[str, object] = json.load(f)
|
|
86
|
+
|
|
87
|
+
# Validate timestamp and check if stale
|
|
88
|
+
if data.get("timestamp"):
|
|
89
|
+
ts = datetime.fromisoformat(data["timestamp"]) # type: ignore[arg-type]
|
|
90
|
+
age = datetime.now() - ts
|
|
91
|
+
if age > timedelta(minutes=max_age_minutes):
|
|
92
|
+
logger.debug(
|
|
93
|
+
f"Parent activity stale ({age.total_seconds():.0f}s > {max_age_minutes}min)"
|
|
94
|
+
)
|
|
95
|
+
return {}
|
|
96
|
+
|
|
97
|
+
logger.debug(f"Loaded parent activity: {data.get('parent_id')}")
|
|
98
|
+
return data # type: ignore[return-value]
|
|
99
|
+
|
|
100
|
+
except json.JSONDecodeError:
|
|
101
|
+
logger.warning("Corrupted parent-activity.json, returning empty state")
|
|
102
|
+
return {}
|
|
103
|
+
except (ValueError, KeyError, OSError) as e:
|
|
104
|
+
logger.warning(f"Error loading parent activity: {e}")
|
|
105
|
+
return {}
|
|
106
|
+
|
|
107
|
+
def save(self, parent_id: str, tool: str) -> None:
|
|
108
|
+
"""
|
|
109
|
+
Save parent activity context.
|
|
110
|
+
|
|
111
|
+
Creates or updates parent-activity.json with the current parent context.
|
|
112
|
+
Uses atomic write to prevent corruption from concurrent access.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
parent_id: Event ID of parent activity (e.g., "evt-xyz123")
|
|
116
|
+
tool: Tool name that created parent context (e.g., "Task", "Skill")
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
data = {
|
|
120
|
+
"parent_id": parent_id,
|
|
121
|
+
"tool": tool,
|
|
122
|
+
"timestamp": datetime.now().isoformat(),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Atomic write: write to temp file, then rename
|
|
126
|
+
with tempfile.NamedTemporaryFile(
|
|
127
|
+
mode="w",
|
|
128
|
+
dir=self.graph_dir,
|
|
129
|
+
delete=False,
|
|
130
|
+
suffix=".json",
|
|
131
|
+
) as tmp:
|
|
132
|
+
json.dump(data, tmp)
|
|
133
|
+
tmp_path = tmp.name
|
|
134
|
+
|
|
135
|
+
# Atomic rename
|
|
136
|
+
os.replace(tmp_path, self.file_path)
|
|
137
|
+
logger.debug(f"Saved parent activity: {parent_id} (tool={tool})")
|
|
138
|
+
|
|
139
|
+
except OSError as e:
|
|
140
|
+
logger.warning(f"Could not save parent activity: {e}")
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.error(f"Unexpected error saving parent activity: {e}")
|
|
143
|
+
|
|
144
|
+
def clear(self) -> None:
|
|
145
|
+
"""
|
|
146
|
+
Delete parent activity file.
|
|
147
|
+
|
|
148
|
+
Clears the parent context, causing subsequent tool calls to not link
|
|
149
|
+
to a parent activity.
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
self.file_path.unlink(missing_ok=True)
|
|
153
|
+
logger.debug("Cleared parent activity")
|
|
154
|
+
except OSError as e:
|
|
155
|
+
logger.warning(f"Could not clear parent activity: {e}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class UserQueryEventTracker:
|
|
159
|
+
"""
|
|
160
|
+
Tracks the active UserQuery event ID for parent-child linking.
|
|
161
|
+
|
|
162
|
+
Each session maintains its own UserQuery event context to support
|
|
163
|
+
multiple concurrent Claude windows in the same project.
|
|
164
|
+
|
|
165
|
+
UserQuery events expire after 2 minutes (conversation turn boundary),
|
|
166
|
+
allowing natural grouping of tool calls by conversation turn.
|
|
167
|
+
|
|
168
|
+
File: user-query-event-{SESSION_ID}.json (single entry)
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"event_id": "evt-abc456",
|
|
172
|
+
"timestamp": "2025-01-10T12:34:56Z"
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def __init__(self, graph_dir: Path):
|
|
178
|
+
"""
|
|
179
|
+
Initialize user query event tracker.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
graph_dir: Path to .htmlgraph directory
|
|
183
|
+
"""
|
|
184
|
+
self.graph_dir = Path(graph_dir)
|
|
185
|
+
self._ensure_graph_dir()
|
|
186
|
+
|
|
187
|
+
def _ensure_graph_dir(self) -> None:
|
|
188
|
+
"""Ensure .htmlgraph directory exists."""
|
|
189
|
+
self.graph_dir.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
|
|
191
|
+
def _get_file_path(self, session_id: str) -> Path:
|
|
192
|
+
"""Get session-specific user query event file path."""
|
|
193
|
+
return self.graph_dir / f"user-query-event-{session_id}.json"
|
|
194
|
+
|
|
195
|
+
def load(self, session_id: str, max_age_minutes: int = 2) -> str | None:
|
|
196
|
+
"""
|
|
197
|
+
Load active UserQuery event ID for a session.
|
|
198
|
+
|
|
199
|
+
Automatically filters out stale events older than max_age_minutes.
|
|
200
|
+
This creates natural conversation turn boundaries when queries timeout.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
session_id: Session ID (e.g., "sess-xyz789")
|
|
204
|
+
max_age_minutes: Maximum age in minutes before event is considered stale
|
|
205
|
+
(default: 2 minutes for conversation turns)
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Event ID string (e.g., "evt-abc456") or None if missing/stale
|
|
209
|
+
"""
|
|
210
|
+
file_path = self._get_file_path(session_id)
|
|
211
|
+
if not file_path.exists():
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
with open(file_path) as f:
|
|
216
|
+
data: dict[str, object] = json.load(f)
|
|
217
|
+
|
|
218
|
+
# Validate timestamp and check if stale
|
|
219
|
+
if data.get("timestamp"):
|
|
220
|
+
ts = datetime.fromisoformat(data["timestamp"]) # type: ignore[arg-type]
|
|
221
|
+
age = datetime.now() - ts
|
|
222
|
+
if age > timedelta(minutes=max_age_minutes):
|
|
223
|
+
logger.debug(
|
|
224
|
+
f"UserQuery event stale ({age.total_seconds():.0f}s > {max_age_minutes}min)"
|
|
225
|
+
)
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
event_id = data.get("event_id")
|
|
229
|
+
logger.debug(f"Loaded UserQuery event: {event_id}")
|
|
230
|
+
return event_id # type: ignore[return-value]
|
|
231
|
+
|
|
232
|
+
except json.JSONDecodeError:
|
|
233
|
+
logger.warning(f"Corrupted user-query-event file for {session_id}")
|
|
234
|
+
return None
|
|
235
|
+
except (ValueError, KeyError, OSError) as e:
|
|
236
|
+
logger.warning(f"Error loading UserQuery event for {session_id}: {e}")
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
def save(self, session_id: str, event_id: str) -> None:
|
|
240
|
+
"""
|
|
241
|
+
Save UserQuery event ID for a session.
|
|
242
|
+
|
|
243
|
+
Creates or updates the session-specific user query event file.
|
|
244
|
+
Uses atomic write to prevent corruption from concurrent access.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
session_id: Session ID (e.g., "sess-xyz789")
|
|
248
|
+
event_id: Event ID to save (e.g., "evt-abc456")
|
|
249
|
+
"""
|
|
250
|
+
file_path = self._get_file_path(session_id)
|
|
251
|
+
try:
|
|
252
|
+
data = {
|
|
253
|
+
"event_id": event_id,
|
|
254
|
+
"timestamp": datetime.now().isoformat(),
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
# Atomic write: write to temp file, then rename
|
|
258
|
+
with tempfile.NamedTemporaryFile(
|
|
259
|
+
mode="w",
|
|
260
|
+
dir=self.graph_dir,
|
|
261
|
+
delete=False,
|
|
262
|
+
suffix=".json",
|
|
263
|
+
) as tmp:
|
|
264
|
+
json.dump(data, tmp)
|
|
265
|
+
tmp_path = tmp.name
|
|
266
|
+
|
|
267
|
+
# Atomic rename
|
|
268
|
+
os.replace(tmp_path, file_path)
|
|
269
|
+
logger.debug(f"Saved UserQuery event: {event_id} (session={session_id})")
|
|
270
|
+
|
|
271
|
+
except OSError as e:
|
|
272
|
+
logger.warning(f"Could not save UserQuery event for {session_id}: {e}")
|
|
273
|
+
except Exception as e:
|
|
274
|
+
logger.error(
|
|
275
|
+
f"Unexpected error saving UserQuery event for {session_id}: {e}"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def clear(self, session_id: str) -> None:
|
|
279
|
+
"""
|
|
280
|
+
Delete UserQuery event file for a session.
|
|
281
|
+
|
|
282
|
+
Clears the session's UserQuery context, allowing a new conversation turn
|
|
283
|
+
to begin without inheriting the previous turn's parent context.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
session_id: Session ID to clear
|
|
287
|
+
"""
|
|
288
|
+
file_path = self._get_file_path(session_id)
|
|
289
|
+
try:
|
|
290
|
+
file_path.unlink(missing_ok=True)
|
|
291
|
+
logger.debug(f"Cleared UserQuery event for {session_id}")
|
|
292
|
+
except OSError as e:
|
|
293
|
+
logger.warning(f"Could not clear UserQuery event for {session_id}: {e}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class DriftQueueManager:
|
|
297
|
+
"""
|
|
298
|
+
Manages the drift classification queue for high-drift activities.
|
|
299
|
+
|
|
300
|
+
The drift queue accumulates activities that exceed the auto-classification
|
|
301
|
+
threshold, triggering classification when thresholds are met.
|
|
302
|
+
|
|
303
|
+
Activities are automatically filtered by age to prevent indefinite accumulation.
|
|
304
|
+
|
|
305
|
+
File: drift-queue.json
|
|
306
|
+
```json
|
|
307
|
+
{
|
|
308
|
+
"activities": [
|
|
309
|
+
{
|
|
310
|
+
"timestamp": "2025-01-10T12:34:56Z",
|
|
311
|
+
"tool": "Read",
|
|
312
|
+
"summary": "Read: /path/to/file.py",
|
|
313
|
+
"file_paths": ["/path/to/file.py"],
|
|
314
|
+
"drift_score": 0.87,
|
|
315
|
+
"feature_id": "feat-xyz123"
|
|
316
|
+
}
|
|
317
|
+
],
|
|
318
|
+
"last_classification": "2025-01-10T12:30:00Z"
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
def __init__(self, graph_dir: Path):
|
|
324
|
+
"""
|
|
325
|
+
Initialize drift queue manager.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
graph_dir: Path to .htmlgraph directory
|
|
329
|
+
"""
|
|
330
|
+
self.graph_dir = Path(graph_dir)
|
|
331
|
+
self.file_path = self.graph_dir / "drift-queue.json"
|
|
332
|
+
self._ensure_graph_dir()
|
|
333
|
+
|
|
334
|
+
def _ensure_graph_dir(self) -> None:
|
|
335
|
+
"""Ensure .htmlgraph directory exists."""
|
|
336
|
+
self.graph_dir.mkdir(parents=True, exist_ok=True)
|
|
337
|
+
|
|
338
|
+
def load(self, max_age_hours: int = 48) -> dict[str, Any]:
|
|
339
|
+
"""
|
|
340
|
+
Load drift queue and filter by age.
|
|
341
|
+
|
|
342
|
+
Automatically removes activities older than max_age_hours.
|
|
343
|
+
This prevents the queue from growing indefinitely over time.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
max_age_hours: Maximum age in hours before activities are removed
|
|
347
|
+
(default: 48 hours)
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
Queue dict with keys: activities (list), last_classification (timestamp)
|
|
351
|
+
Returns default empty queue if file missing
|
|
352
|
+
"""
|
|
353
|
+
if not self.file_path.exists():
|
|
354
|
+
return {"activities": [], "last_classification": None}
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
with open(self.file_path) as f:
|
|
358
|
+
queue: dict[str, object] = json.load(f)
|
|
359
|
+
|
|
360
|
+
# Filter out stale activities
|
|
361
|
+
cutoff_time = datetime.now() - timedelta(hours=max_age_hours)
|
|
362
|
+
original_count = len(queue.get("activities", [])) # type: ignore[arg-type]
|
|
363
|
+
|
|
364
|
+
fresh_activities = []
|
|
365
|
+
for activity in queue.get("activities", []): # type: ignore[attr-defined]
|
|
366
|
+
try:
|
|
367
|
+
activity_time = datetime.fromisoformat(
|
|
368
|
+
activity.get("timestamp", "")
|
|
369
|
+
)
|
|
370
|
+
if activity_time >= cutoff_time:
|
|
371
|
+
fresh_activities.append(activity)
|
|
372
|
+
except (ValueError, TypeError):
|
|
373
|
+
# Keep activities with invalid timestamps to avoid data loss
|
|
374
|
+
fresh_activities.append(activity)
|
|
375
|
+
|
|
376
|
+
# Update queue if we removed stale entries
|
|
377
|
+
if len(fresh_activities) < original_count:
|
|
378
|
+
queue["activities"] = fresh_activities
|
|
379
|
+
self.save(queue)
|
|
380
|
+
removed = original_count - len(fresh_activities)
|
|
381
|
+
logger.info(
|
|
382
|
+
f"Cleaned {removed} stale drift queue entries (older than {max_age_hours}h)"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
logger.debug(
|
|
386
|
+
f"Loaded drift queue: {len(fresh_activities)} recent activities"
|
|
387
|
+
)
|
|
388
|
+
return queue
|
|
389
|
+
|
|
390
|
+
except json.JSONDecodeError:
|
|
391
|
+
logger.warning("Corrupted drift-queue.json, returning empty queue")
|
|
392
|
+
return {"activities": [], "last_classification": None}
|
|
393
|
+
except (ValueError, KeyError, OSError) as e:
|
|
394
|
+
logger.warning(f"Error loading drift queue: {e}")
|
|
395
|
+
return {"activities": [], "last_classification": None}
|
|
396
|
+
|
|
397
|
+
def save(self, queue: dict[str, Any]) -> None:
|
|
398
|
+
"""
|
|
399
|
+
Save drift queue to file.
|
|
400
|
+
|
|
401
|
+
Persists the queue with all activities and classification metadata.
|
|
402
|
+
Uses atomic write to prevent corruption from concurrent access.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
queue: Queue dict with activities and last_classification timestamp
|
|
406
|
+
"""
|
|
407
|
+
try:
|
|
408
|
+
# Atomic write: write to temp file, then rename
|
|
409
|
+
with tempfile.NamedTemporaryFile(
|
|
410
|
+
mode="w",
|
|
411
|
+
dir=self.graph_dir,
|
|
412
|
+
delete=False,
|
|
413
|
+
suffix=".json",
|
|
414
|
+
) as tmp:
|
|
415
|
+
json.dump(queue, tmp, indent=2, default=str)
|
|
416
|
+
tmp_path = tmp.name
|
|
417
|
+
|
|
418
|
+
# Atomic rename
|
|
419
|
+
os.replace(tmp_path, self.file_path)
|
|
420
|
+
logger.debug(
|
|
421
|
+
f"Saved drift queue: {len(queue.get('activities', []))} activities"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
except OSError as e:
|
|
425
|
+
logger.warning(f"Could not save drift queue: {e}")
|
|
426
|
+
except Exception as e:
|
|
427
|
+
logger.error(f"Unexpected error saving drift queue: {e}")
|
|
428
|
+
|
|
429
|
+
def add_activity(
|
|
430
|
+
self, activity: dict[str, Any], timestamp: datetime | None = None
|
|
431
|
+
) -> None:
|
|
432
|
+
"""
|
|
433
|
+
Add activity to drift queue.
|
|
434
|
+
|
|
435
|
+
Appends a high-drift activity to the queue for later classification.
|
|
436
|
+
Timestamp defaults to current time if not provided.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
activity: Activity dict with keys: tool, summary, file_paths, drift_score, feature_id
|
|
440
|
+
timestamp: Activity timestamp (defaults to now)
|
|
441
|
+
"""
|
|
442
|
+
if timestamp is None:
|
|
443
|
+
timestamp = datetime.now()
|
|
444
|
+
|
|
445
|
+
queue = self.load()
|
|
446
|
+
queue["activities"].append(
|
|
447
|
+
{
|
|
448
|
+
"timestamp": timestamp.isoformat(),
|
|
449
|
+
"tool": activity.get("tool"),
|
|
450
|
+
"summary": activity.get("summary"),
|
|
451
|
+
"file_paths": activity.get("file_paths", []),
|
|
452
|
+
"drift_score": activity.get("drift_score"),
|
|
453
|
+
"feature_id": activity.get("feature_id"),
|
|
454
|
+
}
|
|
455
|
+
)
|
|
456
|
+
self.save(queue)
|
|
457
|
+
logger.debug(
|
|
458
|
+
f"Added activity to drift queue (drift_score={activity.get('drift_score')})"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def clear(self) -> None:
|
|
462
|
+
"""
|
|
463
|
+
Delete drift queue file.
|
|
464
|
+
|
|
465
|
+
Removes the entire drift queue, typically after classification completes.
|
|
466
|
+
"""
|
|
467
|
+
try:
|
|
468
|
+
self.file_path.unlink(missing_ok=True)
|
|
469
|
+
logger.debug("Cleared drift queue")
|
|
470
|
+
except OSError as e:
|
|
471
|
+
logger.warning(f"Could not clear drift queue: {e}")
|
|
472
|
+
|
|
473
|
+
def clear_activities(self) -> None:
|
|
474
|
+
"""
|
|
475
|
+
Clear activities from queue while preserving last_classification timestamp.
|
|
476
|
+
|
|
477
|
+
Called after successful classification to remove processed activities
|
|
478
|
+
while keeping track of when the last classification occurred.
|
|
479
|
+
"""
|
|
480
|
+
try:
|
|
481
|
+
queue = {
|
|
482
|
+
"activities": [],
|
|
483
|
+
"last_classification": datetime.now().isoformat(),
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
# Preserve existing last_classification if this file already exists
|
|
487
|
+
if self.file_path.exists():
|
|
488
|
+
try:
|
|
489
|
+
with open(self.file_path) as f:
|
|
490
|
+
existing = json.load(f)
|
|
491
|
+
if existing.get("last_classification"):
|
|
492
|
+
queue["last_classification"] = existing[
|
|
493
|
+
"last_classification"
|
|
494
|
+
]
|
|
495
|
+
except Exception:
|
|
496
|
+
pass
|
|
497
|
+
|
|
498
|
+
self.save(queue)
|
|
499
|
+
logger.debug(
|
|
500
|
+
"Cleared drift queue activities (preserved classification timestamp)"
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
except Exception as e:
|
|
504
|
+
logger.error(f"Error clearing drift queue activities: {e}")
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Subagent Context Detection for Orchestrator Mode
|
|
3
|
+
|
|
4
|
+
This module provides utilities to detect when code is executing within a
|
|
5
|
+
delegated subagent context (spawned via Task() tool) vs. the main orchestrator.
|
|
6
|
+
|
|
7
|
+
Key Problem:
|
|
8
|
+
PreToolUse hooks (orchestrator-enforce.py, validator.py) enforce delegation
|
|
9
|
+
rules that block direct tool use in strict mode. However, subagents MUST use
|
|
10
|
+
tools directly - that's the delegated work. Without context detection, subagents
|
|
11
|
+
get blocked, making strict orchestrator mode unusable.
|
|
12
|
+
|
|
13
|
+
Solution:
|
|
14
|
+
Detect subagent context via multiple signals:
|
|
15
|
+
1. Environment variables set by Claude Code when spawning Task() subagents
|
|
16
|
+
2. Session state markers in database
|
|
17
|
+
3. Parent session tracking
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
from htmlgraph.hooks.subagent_detection import is_subagent_context
|
|
21
|
+
|
|
22
|
+
if is_subagent_context():
|
|
23
|
+
# Allow direct tool use - this is delegated work
|
|
24
|
+
return {"continue": True}
|
|
25
|
+
else:
|
|
26
|
+
# Enforce delegation rules - this is orchestrator
|
|
27
|
+
return enforce_delegation(tool, params)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_subagent_context() -> bool:
|
|
36
|
+
"""
|
|
37
|
+
Check if we're executing within a delegated subagent (spawned via Task()).
|
|
38
|
+
|
|
39
|
+
Detection Strategy (in priority order):
|
|
40
|
+
1. CLAUDE_SUBAGENT_ID environment variable (set by Task() spawner)
|
|
41
|
+
2. CLAUDE_PARENT_SESSION_ID environment variable (set by Task() spawner)
|
|
42
|
+
3. Session state marker in database (is_subagent flag)
|
|
43
|
+
4. Active session has parent_session_id set
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True if executing in subagent context, False if orchestrator context
|
|
47
|
+
|
|
48
|
+
Note:
|
|
49
|
+
- Gracefully degrades if detection mechanisms fail (returns False)
|
|
50
|
+
- False positives are safe (allow direct tool use)
|
|
51
|
+
- False negatives would break subagents (must be avoided)
|
|
52
|
+
"""
|
|
53
|
+
# Check 1: Direct environment variable from Task() spawner
|
|
54
|
+
if os.getenv("CLAUDE_SUBAGENT_ID"):
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
# Check 2: Parent session ID indicates we're a subagent
|
|
58
|
+
if os.getenv("CLAUDE_PARENT_SESSION_ID"):
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
# Check 3: Session state marker in database
|
|
62
|
+
try:
|
|
63
|
+
session_state = _load_session_state()
|
|
64
|
+
if session_state.get("is_subagent", False):
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
# Check 4: Session has parent_session_id
|
|
68
|
+
if session_state.get("parent_session_id"):
|
|
69
|
+
return True
|
|
70
|
+
except Exception:
|
|
71
|
+
# Graceful degradation - if we can't check, assume NOT subagent
|
|
72
|
+
# This is safe because it only allows stricter enforcement
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
# Check 5: Query database for active session with parent_session_id
|
|
76
|
+
try:
|
|
77
|
+
if _has_parent_session_in_db():
|
|
78
|
+
return True
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load_session_state() -> dict[str, Any]:
|
|
86
|
+
"""
|
|
87
|
+
Load session state from .htmlgraph/session-state.json.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Session state dict, or empty dict if not found
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
# Find .htmlgraph directory
|
|
94
|
+
graph_dir = _find_graph_dir()
|
|
95
|
+
if not graph_dir:
|
|
96
|
+
return {}
|
|
97
|
+
|
|
98
|
+
state_file = graph_dir / "session-state.json"
|
|
99
|
+
if not state_file.exists():
|
|
100
|
+
return {}
|
|
101
|
+
|
|
102
|
+
import json
|
|
103
|
+
|
|
104
|
+
result: dict[str, Any] = json.loads(state_file.read_text())
|
|
105
|
+
return result
|
|
106
|
+
except Exception:
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _has_parent_session_in_db() -> bool:
|
|
111
|
+
"""
|
|
112
|
+
Check if current session has a parent_session_id in database.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
True if session is a subagent (has parent), False otherwise
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
graph_dir = _find_graph_dir()
|
|
119
|
+
if not graph_dir:
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
db_path = graph_dir / "htmlgraph.db"
|
|
123
|
+
if not db_path.exists():
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
import sqlite3
|
|
127
|
+
|
|
128
|
+
# Get current session ID from environment or database
|
|
129
|
+
|
|
130
|
+
# We need hook_input to create context, but we don't have it here
|
|
131
|
+
# Fall back to environment check
|
|
132
|
+
session_id = os.getenv("HTMLGRAPH_SESSION_ID") or os.getenv("CLAUDE_SESSION_ID")
|
|
133
|
+
|
|
134
|
+
if not session_id:
|
|
135
|
+
# Try to get most recent session from database
|
|
136
|
+
conn = sqlite3.connect(str(db_path), timeout=1.0)
|
|
137
|
+
cursor = conn.cursor()
|
|
138
|
+
cursor.execute("""
|
|
139
|
+
SELECT session_id FROM sessions
|
|
140
|
+
WHERE status = 'active'
|
|
141
|
+
ORDER BY created_at DESC
|
|
142
|
+
LIMIT 1
|
|
143
|
+
""")
|
|
144
|
+
row = cursor.fetchone()
|
|
145
|
+
if row:
|
|
146
|
+
session_id = row[0]
|
|
147
|
+
conn.close()
|
|
148
|
+
|
|
149
|
+
if not session_id:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
# Check if this session has a parent
|
|
153
|
+
conn = sqlite3.connect(str(db_path), timeout=1.0)
|
|
154
|
+
cursor = conn.cursor()
|
|
155
|
+
cursor.execute(
|
|
156
|
+
"""
|
|
157
|
+
SELECT parent_session_id FROM sessions
|
|
158
|
+
WHERE session_id = ?
|
|
159
|
+
""",
|
|
160
|
+
(session_id,),
|
|
161
|
+
)
|
|
162
|
+
row = cursor.fetchone()
|
|
163
|
+
conn.close()
|
|
164
|
+
|
|
165
|
+
if row and row[0]:
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _find_graph_dir() -> Path | None:
|
|
175
|
+
"""
|
|
176
|
+
Find .htmlgraph directory starting from current working directory.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Path to .htmlgraph directory, or None if not found
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
cwd = Path.cwd()
|
|
183
|
+
graph_dir = cwd / ".htmlgraph"
|
|
184
|
+
|
|
185
|
+
if graph_dir.exists():
|
|
186
|
+
return graph_dir
|
|
187
|
+
|
|
188
|
+
# Search up to 3 parent directories
|
|
189
|
+
for parent in [cwd.parent, cwd.parent.parent, cwd.parent.parent.parent]:
|
|
190
|
+
candidate = parent / ".htmlgraph"
|
|
191
|
+
if candidate.exists():
|
|
192
|
+
return candidate
|
|
193
|
+
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
__all__ = [
|
|
201
|
+
"is_subagent_context",
|
|
202
|
+
]
|