htmlgraph 0.9.3__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 +173 -17
- htmlgraph/__init__.pyi +123 -0
- htmlgraph/agent_detection.py +127 -0
- htmlgraph/agent_registry.py +45 -30
- htmlgraph/agents.py +160 -107
- htmlgraph/analytics/__init__.py +9 -2
- htmlgraph/analytics/cli.py +190 -51
- 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 +192 -100
- 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 +190 -14
- htmlgraph/analytics_index.py +135 -51
- 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 +208 -0
- htmlgraph/bounded_paths.py +539 -0
- htmlgraph/builders/__init__.py +14 -0
- htmlgraph/builders/base.py +118 -29
- htmlgraph/builders/bug.py +150 -0
- htmlgraph/builders/chore.py +119 -0
- htmlgraph/builders/epic.py +150 -0
- htmlgraph/builders/feature.py +31 -6
- htmlgraph/builders/insight.py +195 -0
- htmlgraph/builders/metric.py +217 -0
- htmlgraph/builders/pattern.py +202 -0
- htmlgraph/builders/phase.py +162 -0
- htmlgraph/builders/spike.py +52 -19
- htmlgraph/builders/track.py +148 -72
- 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 +18 -0
- htmlgraph/collections/base.py +415 -98
- htmlgraph/collections/bug.py +53 -0
- htmlgraph/collections/chore.py +53 -0
- htmlgraph/collections/epic.py +53 -0
- htmlgraph/collections/feature.py +12 -26
- htmlgraph/collections/insight.py +100 -0
- htmlgraph/collections/metric.py +92 -0
- htmlgraph/collections/pattern.py +97 -0
- htmlgraph/collections/phase.py +53 -0
- htmlgraph/collections/session.py +194 -0
- htmlgraph/collections/spike.py +56 -16
- htmlgraph/collections/task_delegation.py +241 -0
- htmlgraph/collections/todo.py +511 -0
- htmlgraph/collections/traces.py +487 -0
- htmlgraph/config/cost_models.json +56 -0
- htmlgraph/config.py +190 -0
- htmlgraph/context_analytics.py +344 -0
- htmlgraph/converter.py +216 -28
- htmlgraph/cost_analysis/__init__.py +5 -0
- htmlgraph/cost_analysis/analyzer.py +438 -0
- htmlgraph/dashboard.html +2406 -307
- 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 +19 -2
- htmlgraph/deploy.py +142 -125
- htmlgraph/deployment_models.py +474 -0
- 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 +182 -27
- htmlgraph/error_handler.py +544 -0
- htmlgraph/event_log.py +100 -52
- htmlgraph/event_migration.py +13 -4
- htmlgraph/exceptions.py +49 -0
- htmlgraph/file_watcher.py +101 -28
- htmlgraph/find_api.py +75 -63
- htmlgraph/git_events.py +145 -63
- htmlgraph/graph.py +1122 -106
- 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 +45 -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 +1314 -0
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/hooks-config.example.json +12 -0
- htmlgraph/hooks/installer.py +343 -0
- htmlgraph/hooks/orchestrator.py +674 -0
- htmlgraph/hooks/orchestrator_reflector.py +223 -0
- htmlgraph/hooks/post-checkout.sh +28 -0
- htmlgraph/hooks/post-commit.sh +24 -0
- htmlgraph/hooks/post-merge.sh +26 -0
- htmlgraph/hooks/post_tool_use_failure.py +273 -0
- htmlgraph/hooks/post_tool_use_handler.py +257 -0
- htmlgraph/hooks/posttooluse.py +408 -0
- htmlgraph/hooks/pre-commit.sh +94 -0
- htmlgraph/hooks/pre-push.sh +28 -0
- htmlgraph/hooks/pretooluse.py +819 -0
- 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 +255 -0
- htmlgraph/hooks/task_validator.py +177 -0
- htmlgraph/hooks/validator.py +628 -0
- htmlgraph/ids.py +41 -27
- htmlgraph/index.d.ts +286 -0
- htmlgraph/learning.py +767 -0
- htmlgraph/mcp_server.py +69 -23
- htmlgraph/models.py +1586 -87
- 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/task_coordination.py +343 -0
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
- htmlgraph/orchestrator.py +669 -0
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +328 -0
- htmlgraph/orchestrator_validator.py +133 -0
- htmlgraph/parallel.py +646 -0
- htmlgraph/parser.py +160 -35
- htmlgraph/path_query.py +608 -0
- htmlgraph/pattern_matcher.py +636 -0
- htmlgraph/planning.py +147 -52
- htmlgraph/pydantic_models.py +476 -0
- htmlgraph/quality_gates.py +350 -0
- htmlgraph/query_builder.py +109 -72
- 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/routing.py +8 -19
- htmlgraph/scripts/deploy.py +1 -2
- 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 +685 -180
- htmlgraph/services/__init__.py +10 -0
- htmlgraph/services/claiming.py +199 -0
- htmlgraph/session_hooks.py +300 -0
- htmlgraph/session_manager.py +1392 -175
- htmlgraph/session_registry.py +587 -0
- htmlgraph/session_state.py +436 -0
- htmlgraph/session_warning.py +201 -0
- htmlgraph/sessions/__init__.py +23 -0
- htmlgraph/sessions/handoff.py +756 -0
- htmlgraph/setup.py +34 -17
- htmlgraph/spike_index.py +143 -0
- htmlgraph/sync_docs.py +12 -15
- htmlgraph/system_prompts.py +450 -0
- htmlgraph/templates/AGENTS.md.template +366 -0
- htmlgraph/templates/CLAUDE.md.template +97 -0
- htmlgraph/templates/GEMINI.md.template +87 -0
- htmlgraph/templates/orchestration-view.html +350 -0
- htmlgraph/track_builder.py +146 -15
- htmlgraph/track_manager.py +69 -21
- htmlgraph/transcript.py +890 -0
- htmlgraph/transcript_analytics.py +699 -0
- htmlgraph/types.py +323 -0
- htmlgraph/validation.py +115 -0
- htmlgraph/watch.py +8 -5
- htmlgraph/work_type_utils.py +3 -2
- {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2406 -307
- htmlgraph-0.27.5.data/data/htmlgraph/templates/AGENTS.md.template +366 -0
- htmlgraph-0.27.5.data/data/htmlgraph/templates/CLAUDE.md.template +97 -0
- htmlgraph-0.27.5.data/data/htmlgraph/templates/GEMINI.md.template +87 -0
- {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +97 -64
- htmlgraph-0.27.5.dist-info/RECORD +337 -0
- {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -2688
- htmlgraph/sdk.py +0 -709
- htmlgraph-0.9.3.dist-info/RECORD +0 -61
- {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
htmlgraph/models.py
CHANGED
|
@@ -7,18 +7,26 @@ These models provide:
|
|
|
7
7
|
- Lightweight context generation for AI agents
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
from datetime import datetime
|
|
11
|
-
from typing import Any, Literal
|
|
10
|
+
from datetime import datetime, timezone
|
|
12
11
|
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
13
15
|
from pydantic import BaseModel, Field
|
|
14
16
|
|
|
15
17
|
|
|
18
|
+
def utc_now() -> datetime:
|
|
19
|
+
"""Return current time as UTC-aware datetime."""
|
|
20
|
+
return datetime.now(timezone.utc)
|
|
21
|
+
|
|
22
|
+
|
|
16
23
|
class WorkType(str, Enum):
|
|
17
24
|
"""
|
|
18
25
|
Classification of work/activity type for events and sessions.
|
|
19
26
|
|
|
20
27
|
Used to differentiate exploratory work from implementation work in analytics.
|
|
21
28
|
"""
|
|
29
|
+
|
|
22
30
|
FEATURE = "feature-implementation"
|
|
23
31
|
SPIKE = "spike-investigation"
|
|
24
32
|
BUG_FIX = "bug-fix"
|
|
@@ -38,6 +46,7 @@ class SpikeType(str, Enum):
|
|
|
38
46
|
- RISK: Identify and assess project risks
|
|
39
47
|
- GENERAL: Uncategorized investigation
|
|
40
48
|
"""
|
|
49
|
+
|
|
41
50
|
TECHNICAL = "technical"
|
|
42
51
|
ARCHITECTURAL = "architectural"
|
|
43
52
|
RISK = "risk"
|
|
@@ -53,6 +62,7 @@ class MaintenanceType(str, Enum):
|
|
|
53
62
|
- PERFECTIVE: Improve performance, usability, maintainability
|
|
54
63
|
- PREVENTIVE: Prevent future problems (refactoring, tech debt)
|
|
55
64
|
"""
|
|
65
|
+
|
|
56
66
|
CORRECTIVE = "corrective"
|
|
57
67
|
ADAPTIVE = "adaptive"
|
|
58
68
|
PERFECTIVE = "perfective"
|
|
@@ -72,7 +82,7 @@ class Step(BaseModel):
|
|
|
72
82
|
status = "✅" if self.completed else "⏳"
|
|
73
83
|
agent_attr = f' data-agent="{self.agent}"' if self.agent else ""
|
|
74
84
|
completed_attr = f' data-completed="{str(self.completed).lower()}"'
|
|
75
|
-
return f
|
|
85
|
+
return f"<li{completed_attr}{agent_attr}>{status} {self.description}</li>"
|
|
76
86
|
|
|
77
87
|
def to_context(self) -> str:
|
|
78
88
|
"""Lightweight context for AI agents."""
|
|
@@ -98,7 +108,11 @@ class Edge(BaseModel):
|
|
|
98
108
|
|
|
99
109
|
def to_html(self, base_path: str = "") -> str:
|
|
100
110
|
"""Convert edge to HTML anchor element."""
|
|
101
|
-
href =
|
|
111
|
+
href = (
|
|
112
|
+
f"{base_path}{self.target_id}.html"
|
|
113
|
+
if not self.target_id.endswith(".html")
|
|
114
|
+
else f"{base_path}{self.target_id}"
|
|
115
|
+
)
|
|
102
116
|
attrs = [f'href="{href}"', f'data-relationship="{self.relationship}"']
|
|
103
117
|
|
|
104
118
|
if self.since:
|
|
@@ -108,7 +122,7 @@ class Edge(BaseModel):
|
|
|
108
122
|
attrs.append(f'data-{key}="{value}"')
|
|
109
123
|
|
|
110
124
|
title = self.title or self.target_id
|
|
111
|
-
return f
|
|
125
|
+
return f"<a {' '.join(attrs)}>{title}</a>"
|
|
112
126
|
|
|
113
127
|
def to_context(self) -> str:
|
|
114
128
|
"""Lightweight context for AI agents."""
|
|
@@ -137,7 +151,9 @@ class Node(BaseModel):
|
|
|
137
151
|
id: str
|
|
138
152
|
title: str
|
|
139
153
|
type: str = "node"
|
|
140
|
-
status: Literal[
|
|
154
|
+
status: Literal[
|
|
155
|
+
"todo", "in-progress", "blocked", "done", "active", "ended", "stale"
|
|
156
|
+
] = "todo"
|
|
141
157
|
priority: Literal["low", "medium", "high", "critical"] = "medium"
|
|
142
158
|
created: datetime = Field(default_factory=datetime.now)
|
|
143
159
|
updated: datetime = Field(default_factory=datetime.now)
|
|
@@ -153,18 +169,58 @@ class Node(BaseModel):
|
|
|
153
169
|
# Vertical integration: Track/Spec/Plan relationships
|
|
154
170
|
track_id: str | None = None # Which track this feature belongs to
|
|
155
171
|
plan_task_id: str | None = None # Which plan task this feature implements
|
|
156
|
-
spec_requirements: list[str] = Field(
|
|
172
|
+
spec_requirements: list[str] = Field(
|
|
173
|
+
default_factory=list
|
|
174
|
+
) # Which spec requirements this satisfies
|
|
157
175
|
|
|
158
176
|
# Handoff context fields for agent-to-agent transitions
|
|
159
177
|
handoff_required: bool = False # Whether this node needs to be handed off
|
|
160
178
|
previous_agent: str | None = None # Agent who previously worked on this
|
|
161
|
-
handoff_reason: str | None =
|
|
179
|
+
handoff_reason: str | None = (
|
|
180
|
+
None # Reason for handoff (e.g., blocked, requires different expertise)
|
|
181
|
+
)
|
|
162
182
|
handoff_notes: str | None = None # Detailed handoff context/decisions
|
|
163
183
|
handoff_timestamp: datetime | None = None # When the handoff was created
|
|
164
184
|
|
|
165
185
|
# Capability-based routing (Phase 3: Agent Routing & Capabilities)
|
|
166
|
-
required_capabilities: list[str] = Field(
|
|
167
|
-
|
|
186
|
+
required_capabilities: list[str] = Field(
|
|
187
|
+
default_factory=list
|
|
188
|
+
) # Capabilities needed for this task
|
|
189
|
+
capability_tags: list[str] = Field(
|
|
190
|
+
default_factory=list
|
|
191
|
+
) # Flexible tags for advanced matching
|
|
192
|
+
|
|
193
|
+
# Context tracking (aggregated from sessions)
|
|
194
|
+
# These are updated when sessions report context usage for this feature
|
|
195
|
+
context_tokens_used: int = 0 # Total context tokens attributed to this feature
|
|
196
|
+
context_peak_tokens: int = 0 # Highest context usage in any session
|
|
197
|
+
context_cost_usd: float = 0.0 # Total cost attributed to this feature
|
|
198
|
+
context_sessions: list[str] = Field(
|
|
199
|
+
default_factory=list
|
|
200
|
+
) # Session IDs that reported context
|
|
201
|
+
|
|
202
|
+
# Auto-spike metadata (for transition spike generation)
|
|
203
|
+
spike_subtype: (
|
|
204
|
+
Literal[
|
|
205
|
+
"session-init",
|
|
206
|
+
"transition",
|
|
207
|
+
"conversation-init",
|
|
208
|
+
"planning",
|
|
209
|
+
"investigation",
|
|
210
|
+
]
|
|
211
|
+
| None
|
|
212
|
+
) = None
|
|
213
|
+
auto_generated: bool = False # True if auto-created by SessionManager
|
|
214
|
+
session_id: str | None = None # Session that created/owns this spike
|
|
215
|
+
from_feature_id: str | None = (
|
|
216
|
+
None # For transition spikes: feature we transitioned from
|
|
217
|
+
)
|
|
218
|
+
to_feature_id: str | None = (
|
|
219
|
+
None # For transition spikes: feature we transitioned to
|
|
220
|
+
)
|
|
221
|
+
model_name: str | None = (
|
|
222
|
+
None # Model that worked on this (e.g., "claude-sonnet-4-5")
|
|
223
|
+
)
|
|
168
224
|
|
|
169
225
|
def model_post_init(self, __context: Any) -> None:
|
|
170
226
|
"""Lightweight validation for required fields."""
|
|
@@ -173,6 +229,16 @@ class Node(BaseModel):
|
|
|
173
229
|
if not self.title or not str(self.title).strip():
|
|
174
230
|
raise ValueError("Node.title must be non-empty")
|
|
175
231
|
|
|
232
|
+
# Validate auto-spike metadata
|
|
233
|
+
if self.spike_subtype and self.type != "spike":
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"spike_subtype can only be set on spike nodes, got type='{self.type}'"
|
|
236
|
+
)
|
|
237
|
+
if self.auto_generated and not self.session_id:
|
|
238
|
+
raise ValueError("auto_generated spikes must have session_id set")
|
|
239
|
+
if self.spike_subtype == "transition" and not self.from_feature_id:
|
|
240
|
+
raise ValueError("transition spikes must have from_feature_id set")
|
|
241
|
+
|
|
176
242
|
@property
|
|
177
243
|
def completion_percentage(self) -> int:
|
|
178
244
|
"""Calculate completion percentage from steps."""
|
|
@@ -203,18 +269,77 @@ class Node(BaseModel):
|
|
|
203
269
|
if edge.relationship not in self.edges:
|
|
204
270
|
self.edges[edge.relationship] = []
|
|
205
271
|
self.edges[edge.relationship].append(edge)
|
|
206
|
-
self.updated =
|
|
272
|
+
self.updated = utc_now()
|
|
207
273
|
|
|
208
274
|
def complete_step(self, index: int, agent: str | None = None) -> bool:
|
|
209
275
|
"""Mark a step as completed."""
|
|
210
276
|
if 0 <= index < len(self.steps):
|
|
211
277
|
self.steps[index].completed = True
|
|
212
278
|
self.steps[index].agent = agent
|
|
213
|
-
self.steps[index].timestamp =
|
|
214
|
-
self.updated =
|
|
279
|
+
self.steps[index].timestamp = utc_now()
|
|
280
|
+
self.updated = utc_now()
|
|
215
281
|
return True
|
|
216
282
|
return False
|
|
217
283
|
|
|
284
|
+
def record_context_usage(
|
|
285
|
+
self,
|
|
286
|
+
session_id: str,
|
|
287
|
+
tokens_used: int,
|
|
288
|
+
peak_tokens: int = 0,
|
|
289
|
+
cost_usd: float = 0.0,
|
|
290
|
+
) -> None:
|
|
291
|
+
"""
|
|
292
|
+
Record context usage from a session working on this feature.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
session_id: Session that used context
|
|
296
|
+
tokens_used: Total tokens attributed to this feature
|
|
297
|
+
peak_tokens: Peak context usage during this work
|
|
298
|
+
cost_usd: Cost attributed to this feature
|
|
299
|
+
"""
|
|
300
|
+
# Track session if not already recorded
|
|
301
|
+
if session_id not in self.context_sessions:
|
|
302
|
+
self.context_sessions.append(session_id)
|
|
303
|
+
|
|
304
|
+
# Update aggregates
|
|
305
|
+
self.context_tokens_used += tokens_used
|
|
306
|
+
self.context_peak_tokens = max(self.context_peak_tokens, peak_tokens)
|
|
307
|
+
self.context_cost_usd += cost_usd
|
|
308
|
+
self.updated = utc_now()
|
|
309
|
+
|
|
310
|
+
def context_stats(self) -> dict:
|
|
311
|
+
"""
|
|
312
|
+
Get context usage statistics for this feature.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Dictionary with context usage metrics
|
|
316
|
+
"""
|
|
317
|
+
return {
|
|
318
|
+
"tokens_used": self.context_tokens_used,
|
|
319
|
+
"peak_tokens": self.context_peak_tokens,
|
|
320
|
+
"cost_usd": self.context_cost_usd,
|
|
321
|
+
"sessions": len(self.context_sessions),
|
|
322
|
+
"session_ids": self.context_sessions,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
def to_dict(self) -> dict:
|
|
326
|
+
"""
|
|
327
|
+
Convert Node to dictionary format.
|
|
328
|
+
|
|
329
|
+
This is a convenience alias for Pydantic's model_dump() method,
|
|
330
|
+
providing a more discoverable API for serialization.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
dict: Dictionary representation of the Node with all fields
|
|
334
|
+
|
|
335
|
+
Example:
|
|
336
|
+
>>> feature = sdk.features.create("My Feature").save()
|
|
337
|
+
>>> data = feature.to_dict()
|
|
338
|
+
>>> print(data['title'])
|
|
339
|
+
'My Feature'
|
|
340
|
+
"""
|
|
341
|
+
return self.model_dump()
|
|
342
|
+
|
|
218
343
|
def to_html(self, stylesheet_path: str = "../styles.css") -> str:
|
|
219
344
|
"""
|
|
220
345
|
Convert node to full HTML document.
|
|
@@ -242,21 +367,23 @@ class Node(BaseModel):
|
|
|
242
367
|
</ul>
|
|
243
368
|
</section>''')
|
|
244
369
|
if edge_sections:
|
|
245
|
-
edges_html = f
|
|
370
|
+
edges_html = f"""
|
|
246
371
|
<nav data-graph-edges>{"".join(edge_sections)}
|
|
247
|
-
</nav>
|
|
372
|
+
</nav>"""
|
|
248
373
|
|
|
249
374
|
# Build steps HTML
|
|
250
375
|
steps_html = ""
|
|
251
376
|
if self.steps:
|
|
252
|
-
step_items = "\n ".join(
|
|
253
|
-
|
|
377
|
+
step_items = "\n ".join(
|
|
378
|
+
step.to_html() for step in self.steps
|
|
379
|
+
)
|
|
380
|
+
steps_html = f"""
|
|
254
381
|
<section data-steps>
|
|
255
382
|
<h3>Implementation Steps</h3>
|
|
256
383
|
<ol>
|
|
257
384
|
{step_items}
|
|
258
385
|
</ol>
|
|
259
|
-
</section>
|
|
386
|
+
</section>"""
|
|
260
387
|
|
|
261
388
|
# Build properties HTML
|
|
262
389
|
props_html = ""
|
|
@@ -265,23 +392,27 @@ class Node(BaseModel):
|
|
|
265
392
|
for key, value in self.properties.items():
|
|
266
393
|
unit = ""
|
|
267
394
|
if isinstance(value, dict) and "value" in value:
|
|
268
|
-
unit =
|
|
269
|
-
|
|
395
|
+
unit = (
|
|
396
|
+
f' data-unit="{value.get("unit", "")}"'
|
|
397
|
+
if value.get("unit")
|
|
398
|
+
else ""
|
|
399
|
+
)
|
|
400
|
+
display = f"{value['value']} {value.get('unit', '')}".strip()
|
|
270
401
|
val = value["value"]
|
|
271
402
|
else:
|
|
272
403
|
display = str(value)
|
|
273
404
|
val = value
|
|
274
405
|
prop_items.append(
|
|
275
|
-
f
|
|
406
|
+
f"<dt>{key.replace('_', ' ').title()}</dt>\n"
|
|
276
407
|
f' <dd data-key="{key}" data-value="{val}"{unit}>{display}</dd>'
|
|
277
408
|
)
|
|
278
|
-
props_html = f
|
|
409
|
+
props_html = f"""
|
|
279
410
|
<section data-properties>
|
|
280
411
|
<h3>Properties</h3>
|
|
281
412
|
<dl>
|
|
282
413
|
{chr(10).join(prop_items)}
|
|
283
414
|
</dl>
|
|
284
|
-
</section>
|
|
415
|
+
</section>"""
|
|
285
416
|
|
|
286
417
|
# Build handoff HTML
|
|
287
418
|
handoff_html = ""
|
|
@@ -292,33 +423,39 @@ class Node(BaseModel):
|
|
|
292
423
|
if self.handoff_reason:
|
|
293
424
|
handoff_attrs.append(f'data-reason="{self.handoff_reason}"')
|
|
294
425
|
if self.handoff_timestamp:
|
|
295
|
-
handoff_attrs.append(
|
|
426
|
+
handoff_attrs.append(
|
|
427
|
+
f'data-timestamp="{self.handoff_timestamp.isoformat()}"'
|
|
428
|
+
)
|
|
296
429
|
|
|
297
430
|
attrs_str = " ".join(handoff_attrs)
|
|
298
|
-
handoff_section = f
|
|
431
|
+
handoff_section = f"""
|
|
299
432
|
<section data-handoff{f" {attrs_str}" if attrs_str else ""}>
|
|
300
|
-
<h3>Handoff Context</h3>
|
|
433
|
+
<h3>Handoff Context</h3>"""
|
|
301
434
|
|
|
302
435
|
if self.previous_agent:
|
|
303
|
-
handoff_section +=
|
|
436
|
+
handoff_section += (
|
|
437
|
+
f"\n <p><strong>From:</strong> {self.previous_agent}</p>"
|
|
438
|
+
)
|
|
304
439
|
|
|
305
440
|
if self.handoff_reason:
|
|
306
|
-
handoff_section += f
|
|
441
|
+
handoff_section += f"\n <p><strong>Reason:</strong> {self.handoff_reason}</p>"
|
|
307
442
|
|
|
308
443
|
if self.handoff_notes:
|
|
309
|
-
handoff_section +=
|
|
444
|
+
handoff_section += (
|
|
445
|
+
f"\n <p><strong>Notes:</strong> {self.handoff_notes}</p>"
|
|
446
|
+
)
|
|
310
447
|
|
|
311
|
-
handoff_section +=
|
|
448
|
+
handoff_section += "\n </section>"
|
|
312
449
|
handoff_html = handoff_section
|
|
313
450
|
|
|
314
451
|
# Build content HTML
|
|
315
452
|
content_html = ""
|
|
316
453
|
if self.content:
|
|
317
|
-
content_html = f
|
|
454
|
+
content_html = f"""
|
|
318
455
|
<section data-content>
|
|
319
456
|
<h3>Description</h3>
|
|
320
457
|
{self.content}
|
|
321
|
-
</section>
|
|
458
|
+
</section>"""
|
|
322
459
|
|
|
323
460
|
# Build required capabilities HTML
|
|
324
461
|
capabilities_html = ""
|
|
@@ -331,16 +468,20 @@ class Node(BaseModel):
|
|
|
331
468
|
for tag in self.capability_tags:
|
|
332
469
|
cap_items.append(f'<li data-tag="{tag}" class="tag">{tag}</li>')
|
|
333
470
|
if cap_items:
|
|
334
|
-
capabilities_html = f
|
|
471
|
+
capabilities_html = f"""
|
|
335
472
|
<section data-required-capabilities>
|
|
336
473
|
<h3>Required Capabilities</h3>
|
|
337
474
|
<ul>
|
|
338
475
|
{chr(10).join(cap_items)}
|
|
339
476
|
</ul>
|
|
340
|
-
</section>
|
|
477
|
+
</section>"""
|
|
341
478
|
|
|
342
479
|
# Agent attribute
|
|
343
|
-
agent_attr =
|
|
480
|
+
agent_attr = (
|
|
481
|
+
f' data-agent-assigned="{self.agent_assigned}"'
|
|
482
|
+
if self.agent_assigned
|
|
483
|
+
else ""
|
|
484
|
+
)
|
|
344
485
|
if self.claimed_at:
|
|
345
486
|
agent_attr += f' data-claimed-at="{self.claimed_at.isoformat()}"'
|
|
346
487
|
if self.claimed_by_session:
|
|
@@ -349,6 +490,50 @@ class Node(BaseModel):
|
|
|
349
490
|
# Track ID attribute
|
|
350
491
|
track_attr = f' data-track-id="{self.track_id}"' if self.track_id else ""
|
|
351
492
|
|
|
493
|
+
# Context tracking attributes
|
|
494
|
+
context_attr = ""
|
|
495
|
+
if self.context_tokens_used > 0:
|
|
496
|
+
context_attr += f' data-context-tokens="{self.context_tokens_used}"'
|
|
497
|
+
if self.context_peak_tokens > 0:
|
|
498
|
+
context_attr += f' data-context-peak="{self.context_peak_tokens}"'
|
|
499
|
+
if self.context_cost_usd > 0:
|
|
500
|
+
context_attr += f' data-context-cost="{self.context_cost_usd:.4f}"'
|
|
501
|
+
|
|
502
|
+
# Auto-spike metadata attributes
|
|
503
|
+
auto_spike_attr = ""
|
|
504
|
+
if self.spike_subtype:
|
|
505
|
+
auto_spike_attr += f' data-spike-subtype="{self.spike_subtype}"'
|
|
506
|
+
if self.auto_generated:
|
|
507
|
+
auto_spike_attr += (
|
|
508
|
+
f' data-auto-generated="{str(self.auto_generated).lower()}"'
|
|
509
|
+
)
|
|
510
|
+
if self.session_id:
|
|
511
|
+
auto_spike_attr += f' data-session-id="{self.session_id}"'
|
|
512
|
+
if self.from_feature_id:
|
|
513
|
+
auto_spike_attr += f' data-from-feature-id="{self.from_feature_id}"'
|
|
514
|
+
if self.to_feature_id:
|
|
515
|
+
auto_spike_attr += f' data-to-feature-id="{self.to_feature_id}"'
|
|
516
|
+
if self.model_name:
|
|
517
|
+
auto_spike_attr += f' data-model-name="{self.model_name}"'
|
|
518
|
+
|
|
519
|
+
# Build context usage section
|
|
520
|
+
context_html = ""
|
|
521
|
+
if self.context_tokens_used > 0 or self.context_sessions:
|
|
522
|
+
context_html = f"""
|
|
523
|
+
<section data-context-tracking>
|
|
524
|
+
<h3>Context Usage</h3>
|
|
525
|
+
<dl>
|
|
526
|
+
<dt>Total Tokens</dt>
|
|
527
|
+
<dd>{self.context_tokens_used:,}</dd>
|
|
528
|
+
<dt>Peak Tokens</dt>
|
|
529
|
+
<dd>{self.context_peak_tokens:,}</dd>
|
|
530
|
+
<dt>Total Cost</dt>
|
|
531
|
+
<dd>${self.context_cost_usd:.4f}</dd>
|
|
532
|
+
<dt>Sessions</dt>
|
|
533
|
+
<dd>{len(self.context_sessions)}</dd>
|
|
534
|
+
</dl>
|
|
535
|
+
</section>"""
|
|
536
|
+
|
|
352
537
|
return f'''<!DOCTYPE html>
|
|
353
538
|
<html lang="en">
|
|
354
539
|
<head>
|
|
@@ -364,7 +549,7 @@ class Node(BaseModel):
|
|
|
364
549
|
data-status="{self.status}"
|
|
365
550
|
data-priority="{self.priority}"
|
|
366
551
|
data-created="{self.created.isoformat()}"
|
|
367
|
-
data-updated="{self.updated.isoformat()}"{agent_attr}{track_attr}>
|
|
552
|
+
data-updated="{self.updated.isoformat()}"{agent_attr}{track_attr}{context_attr}{auto_spike_attr}>
|
|
368
553
|
|
|
369
554
|
<header>
|
|
370
555
|
<h1>{self.title}</h1>
|
|
@@ -373,11 +558,7 @@ class Node(BaseModel):
|
|
|
373
558
|
<span class="badge priority-{self.priority}">{self.priority.title()} Priority</span>
|
|
374
559
|
</div>
|
|
375
560
|
</header>
|
|
376
|
-
|
|
377
|
-
{edges_html}{props_html}{capabilities_html}{steps_html}{content_html}
|
|
378
|
-
=======
|
|
379
|
-
{edges_html}{handoff_html}{props_html}{steps_html}{content_html}
|
|
380
|
-
>>>>>>> origin/dev
|
|
561
|
+
{edges_html}{handoff_html}{props_html}{capabilities_html}{context_html}{steps_html}{content_html}
|
|
381
562
|
</article>
|
|
382
563
|
</body>
|
|
383
564
|
</html>
|
|
@@ -413,7 +594,9 @@ class Node(BaseModel):
|
|
|
413
594
|
|
|
414
595
|
if self.steps:
|
|
415
596
|
completed = sum(1 for s in self.steps if s.completed)
|
|
416
|
-
lines.append(
|
|
597
|
+
lines.append(
|
|
598
|
+
f"Progress: {completed}/{len(self.steps)} steps ({self.completion_percentage}%)"
|
|
599
|
+
)
|
|
417
600
|
|
|
418
601
|
# Blocking dependencies
|
|
419
602
|
blocked_by = self.edges.get("blocked_by", [])
|
|
@@ -435,16 +618,14 @@ class Node(BaseModel):
|
|
|
435
618
|
edges = {}
|
|
436
619
|
for rel_type, edge_list in data["edges"].items():
|
|
437
620
|
edges[rel_type] = [
|
|
438
|
-
Edge(**e) if isinstance(e, dict) else e
|
|
439
|
-
for e in edge_list
|
|
621
|
+
Edge(**e) if isinstance(e, dict) else e for e in edge_list
|
|
440
622
|
]
|
|
441
623
|
data["edges"] = edges
|
|
442
624
|
|
|
443
625
|
# Convert step dicts to Step objects
|
|
444
626
|
if "steps" in data:
|
|
445
627
|
data["steps"] = [
|
|
446
|
-
Step(**s) if isinstance(s, dict) else s
|
|
447
|
-
for s in data["steps"]
|
|
628
|
+
Step(**s) if isinstance(s, dict) else s for s in data["steps"]
|
|
448
629
|
]
|
|
449
630
|
|
|
450
631
|
return cls(**data)
|
|
@@ -471,6 +652,75 @@ class Spike(Node):
|
|
|
471
652
|
data["type"] = "spike"
|
|
472
653
|
super().__init__(**data)
|
|
473
654
|
|
|
655
|
+
def to_html(self, stylesheet_path: str = "../styles.css") -> str:
|
|
656
|
+
"""
|
|
657
|
+
Convert spike to HTML document with spike-specific fields.
|
|
658
|
+
|
|
659
|
+
Overrides Node.to_html() to include findings and decision sections.
|
|
660
|
+
"""
|
|
661
|
+
# Build findings section
|
|
662
|
+
findings_html = ""
|
|
663
|
+
if self.findings:
|
|
664
|
+
findings_html = f"""
|
|
665
|
+
<section data-findings>
|
|
666
|
+
<h3>Findings</h3>
|
|
667
|
+
<div class="findings-content">
|
|
668
|
+
{self.findings}
|
|
669
|
+
</div>
|
|
670
|
+
</section>"""
|
|
671
|
+
|
|
672
|
+
# Build decision section
|
|
673
|
+
decision_html = ""
|
|
674
|
+
if self.decision:
|
|
675
|
+
decision_html = f"""
|
|
676
|
+
<section data-decision>
|
|
677
|
+
<h3>Decision</h3>
|
|
678
|
+
<p>{self.decision}</p>
|
|
679
|
+
</section>"""
|
|
680
|
+
|
|
681
|
+
# Build spike metadata section
|
|
682
|
+
spike_meta_html = f"""
|
|
683
|
+
<section data-spike-metadata>
|
|
684
|
+
<h3>Spike Metadata</h3>
|
|
685
|
+
<dl>
|
|
686
|
+
<dt>Type</dt>
|
|
687
|
+
<dd>{self.spike_type.value.title()}</dd>"""
|
|
688
|
+
|
|
689
|
+
if self.timebox_hours:
|
|
690
|
+
spike_meta_html += f"""
|
|
691
|
+
<dt>Timebox</dt>
|
|
692
|
+
<dd>{self.timebox_hours} hours</dd>"""
|
|
693
|
+
|
|
694
|
+
spike_meta_html += """
|
|
695
|
+
</dl>
|
|
696
|
+
</section>"""
|
|
697
|
+
|
|
698
|
+
# Get base HTML from Node and insert spike-specific sections
|
|
699
|
+
# We need to call Node's to_html() but inject our sections
|
|
700
|
+
# Strategy: Get base HTML, then insert our sections before closing article tag
|
|
701
|
+
|
|
702
|
+
# Call parent's to_html to get base structure
|
|
703
|
+
base_html = super().to_html(stylesheet_path)
|
|
704
|
+
|
|
705
|
+
# Insert spike sections before </article>
|
|
706
|
+
spike_sections = f"{spike_meta_html}{findings_html}{decision_html}"
|
|
707
|
+
html_with_findings = base_html.replace(
|
|
708
|
+
"</article>", f"{spike_sections}\n </article>"
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
# Add spike-specific attributes to article tag
|
|
712
|
+
spike_attrs = f' data-spike-type="{self.spike_type.value}"'
|
|
713
|
+
if self.timebox_hours:
|
|
714
|
+
spike_attrs += f' data-timebox-hours="{self.timebox_hours}"'
|
|
715
|
+
|
|
716
|
+
# Insert spike attributes into article tag
|
|
717
|
+
html_with_attrs = html_with_findings.replace(
|
|
718
|
+
f'data-updated="{self.updated.isoformat()}"',
|
|
719
|
+
f'data-updated="{self.updated.isoformat()}"{spike_attrs}',
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
return html_with_attrs
|
|
723
|
+
|
|
474
724
|
|
|
475
725
|
class Chore(Node):
|
|
476
726
|
"""
|
|
@@ -490,6 +740,158 @@ class Chore(Node):
|
|
|
490
740
|
super().__init__(**data)
|
|
491
741
|
|
|
492
742
|
|
|
743
|
+
class ContextSnapshot(BaseModel):
|
|
744
|
+
"""
|
|
745
|
+
A snapshot of context window usage at a point in time.
|
|
746
|
+
|
|
747
|
+
Used to track how context is consumed across sessions, features,
|
|
748
|
+
and activities. Enables analytics for context efficiency.
|
|
749
|
+
|
|
750
|
+
The snapshot captures data from Claude Code's status line JSON input.
|
|
751
|
+
"""
|
|
752
|
+
|
|
753
|
+
timestamp: datetime = Field(default_factory=datetime.now)
|
|
754
|
+
|
|
755
|
+
# Token usage in current context window
|
|
756
|
+
input_tokens: int = 0
|
|
757
|
+
output_tokens: int = 0
|
|
758
|
+
cache_creation_tokens: int = 0
|
|
759
|
+
cache_read_tokens: int = 0
|
|
760
|
+
|
|
761
|
+
# Context window capacity
|
|
762
|
+
context_window_size: int = 200000
|
|
763
|
+
|
|
764
|
+
# Cumulative totals for the session
|
|
765
|
+
total_input_tokens: int = 0
|
|
766
|
+
total_output_tokens: int = 0
|
|
767
|
+
|
|
768
|
+
# Cost tracking
|
|
769
|
+
cost_usd: float = 0.0
|
|
770
|
+
|
|
771
|
+
# Optional context for what triggered this snapshot
|
|
772
|
+
trigger: str | None = None # "activity", "feature_switch", "session_start", etc.
|
|
773
|
+
feature_id: str | None = None # Feature being worked on at this moment
|
|
774
|
+
|
|
775
|
+
@property
|
|
776
|
+
def current_tokens(self) -> int:
|
|
777
|
+
"""Total tokens in current context window."""
|
|
778
|
+
return self.input_tokens + self.cache_creation_tokens + self.cache_read_tokens
|
|
779
|
+
|
|
780
|
+
@property
|
|
781
|
+
def usage_percent(self) -> float:
|
|
782
|
+
"""Context window usage as a percentage."""
|
|
783
|
+
if self.context_window_size == 0:
|
|
784
|
+
return 0.0
|
|
785
|
+
return (self.current_tokens / self.context_window_size) * 100
|
|
786
|
+
|
|
787
|
+
@classmethod
|
|
788
|
+
def from_claude_input(
|
|
789
|
+
cls, data: dict, trigger: str | None = None, feature_id: str | None = None
|
|
790
|
+
) -> "ContextSnapshot":
|
|
791
|
+
"""
|
|
792
|
+
Create a ContextSnapshot from Claude Code status line JSON input.
|
|
793
|
+
|
|
794
|
+
Args:
|
|
795
|
+
data: JSON input from Claude Code (contains context_window, cost, etc.)
|
|
796
|
+
trigger: What triggered this snapshot
|
|
797
|
+
feature_id: Current feature being worked on
|
|
798
|
+
|
|
799
|
+
Returns:
|
|
800
|
+
ContextSnapshot instance
|
|
801
|
+
"""
|
|
802
|
+
context = data.get("context_window", {})
|
|
803
|
+
usage = context.get("current_usage") or {}
|
|
804
|
+
cost = data.get("cost", {})
|
|
805
|
+
|
|
806
|
+
return cls(
|
|
807
|
+
input_tokens=usage.get("input_tokens", 0),
|
|
808
|
+
output_tokens=usage.get("output_tokens", 0),
|
|
809
|
+
cache_creation_tokens=usage.get("cache_creation_input_tokens", 0),
|
|
810
|
+
cache_read_tokens=usage.get("cache_read_input_tokens", 0),
|
|
811
|
+
context_window_size=context.get("context_window_size", 200000),
|
|
812
|
+
total_input_tokens=context.get("total_input_tokens", 0),
|
|
813
|
+
total_output_tokens=context.get("total_output_tokens", 0),
|
|
814
|
+
cost_usd=cost.get("total_cost_usd", 0.0),
|
|
815
|
+
trigger=trigger,
|
|
816
|
+
feature_id=feature_id,
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
def to_dict(self) -> dict:
|
|
820
|
+
"""Convert to dictionary for serialization."""
|
|
821
|
+
return {
|
|
822
|
+
"ts": self.timestamp.isoformat(),
|
|
823
|
+
"in": self.input_tokens,
|
|
824
|
+
"out": self.output_tokens,
|
|
825
|
+
"cache_create": self.cache_creation_tokens,
|
|
826
|
+
"cache_read": self.cache_read_tokens,
|
|
827
|
+
"window": self.context_window_size,
|
|
828
|
+
"total_in": self.total_input_tokens,
|
|
829
|
+
"total_out": self.total_output_tokens,
|
|
830
|
+
"cost": self.cost_usd,
|
|
831
|
+
"trigger": self.trigger,
|
|
832
|
+
"feature": self.feature_id,
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
@classmethod
|
|
836
|
+
def from_dict(cls, data: dict) -> "ContextSnapshot":
|
|
837
|
+
"""Create from dictionary."""
|
|
838
|
+
return cls(
|
|
839
|
+
timestamp=datetime.fromisoformat(data["ts"]) if "ts" in data else utc_now(),
|
|
840
|
+
input_tokens=data.get("in", 0),
|
|
841
|
+
output_tokens=data.get("out", 0),
|
|
842
|
+
cache_creation_tokens=data.get("cache_create", 0),
|
|
843
|
+
cache_read_tokens=data.get("cache_read", 0),
|
|
844
|
+
context_window_size=data.get("window", 200000),
|
|
845
|
+
total_input_tokens=data.get("total_in", 0),
|
|
846
|
+
total_output_tokens=data.get("total_out", 0),
|
|
847
|
+
cost_usd=data.get("cost", 0.0),
|
|
848
|
+
trigger=data.get("trigger"),
|
|
849
|
+
feature_id=data.get("feature"),
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
class ErrorEntry(BaseModel):
|
|
854
|
+
"""
|
|
855
|
+
An error record for session error tracking and debugging.
|
|
856
|
+
|
|
857
|
+
Stored inline within Session nodes for error analysis and debugging.
|
|
858
|
+
"""
|
|
859
|
+
|
|
860
|
+
timestamp: datetime = Field(default_factory=datetime.now)
|
|
861
|
+
error_type: str # Exception class name (ValueError, FileNotFoundError, etc.)
|
|
862
|
+
message: str # Error message
|
|
863
|
+
traceback: str | None = None # Full traceback for debugging
|
|
864
|
+
tool: str | None = None # Tool that caused the error (Edit, Bash, etc.)
|
|
865
|
+
context: str | None = None # Additional context information
|
|
866
|
+
session_id: str | None = None # Session ID for cross-referencing
|
|
867
|
+
locals_dump: str | None = None # JSON-serialized local variables at error point
|
|
868
|
+
stack_frames: list[dict[str, Any]] | None = (
|
|
869
|
+
None # Structured stack frame information
|
|
870
|
+
)
|
|
871
|
+
command_args: dict[str, Any] | None = None # Command arguments being executed
|
|
872
|
+
display_level: str = "minimal" # Display level: minimal, verbose, or debug
|
|
873
|
+
|
|
874
|
+
def to_html(self) -> str:
|
|
875
|
+
"""Convert error to HTML details element."""
|
|
876
|
+
attrs = [
|
|
877
|
+
f'data-ts="{self.timestamp.isoformat()}"',
|
|
878
|
+
f'data-error-type="{self.error_type}"',
|
|
879
|
+
]
|
|
880
|
+
if self.tool:
|
|
881
|
+
attrs.append(f'data-tool="{self.tool}"')
|
|
882
|
+
|
|
883
|
+
summary = f"<span class='error-type'>{self.error_type}</span>: {self.message}"
|
|
884
|
+
details = ""
|
|
885
|
+
if self.traceback:
|
|
886
|
+
details = f"<pre class='traceback'>{self.traceback}</pre>"
|
|
887
|
+
|
|
888
|
+
return f"<details class='error-item' {' '.join(attrs)}><summary>{summary}</summary>{details}</details>"
|
|
889
|
+
|
|
890
|
+
def to_context(self) -> str:
|
|
891
|
+
"""Lightweight context for AI agents."""
|
|
892
|
+
return f"[{self.timestamp.strftime('%H:%M:%S')}] ERROR {self.error_type}: {self.message}"
|
|
893
|
+
|
|
894
|
+
|
|
493
895
|
class ActivityEntry(BaseModel):
|
|
494
896
|
"""
|
|
495
897
|
A lightweight activity log entry for high-frequency events.
|
|
@@ -504,8 +906,15 @@ class ActivityEntry(BaseModel):
|
|
|
504
906
|
success: bool = True
|
|
505
907
|
feature_id: str | None = None # Link to feature this activity belongs to
|
|
506
908
|
drift_score: float | None = None # 0.0-1.0 alignment score
|
|
507
|
-
parent_activity_id: str | None =
|
|
508
|
-
|
|
909
|
+
parent_activity_id: str | None = (
|
|
910
|
+
None # Link to parent activity (e.g., Skill invocation)
|
|
911
|
+
)
|
|
912
|
+
payload: dict[str, Any] | None = (
|
|
913
|
+
None # Optional rich payload for significant events
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
# Context tracking (optional, captured when available)
|
|
917
|
+
context_tokens: int | None = None # Tokens in context when this activity occurred
|
|
509
918
|
|
|
510
919
|
def to_html(self) -> str:
|
|
511
920
|
"""Convert activity to HTML list item."""
|
|
@@ -522,8 +931,10 @@ class ActivityEntry(BaseModel):
|
|
|
522
931
|
attrs.append(f'data-drift="{self.drift_score:.2f}"')
|
|
523
932
|
if self.parent_activity_id:
|
|
524
933
|
attrs.append(f'data-parent="{self.parent_activity_id}"')
|
|
934
|
+
if self.context_tokens is not None:
|
|
935
|
+
attrs.append(f'data-context-tokens="{self.context_tokens}"')
|
|
525
936
|
|
|
526
|
-
return f
|
|
937
|
+
return f"<li {' '.join(attrs)}>{self.summary}</li>"
|
|
527
938
|
|
|
528
939
|
def to_context(self) -> str:
|
|
529
940
|
"""Lightweight context for AI agents."""
|
|
@@ -559,10 +970,18 @@ class Session(BaseModel):
|
|
|
559
970
|
worked_on: list[str] = Field(default_factory=list) # Feature IDs
|
|
560
971
|
continued_from: str | None = None # Previous session ID
|
|
561
972
|
|
|
562
|
-
#
|
|
973
|
+
# Parent session context (for nested Task() calls)
|
|
974
|
+
parent_session: str | None = None # Parent session ID
|
|
975
|
+
parent_activity: str | None = None # Parent activity ID
|
|
976
|
+
nesting_depth: int = 0 # Depth of nesting (0 = top-level)
|
|
977
|
+
|
|
978
|
+
# Handoff context (Phase 2 Feature 3: Cross-Session Continuity)
|
|
563
979
|
handoff_notes: str | None = None
|
|
564
980
|
recommended_next: str | None = None
|
|
565
981
|
blockers: list[str] = Field(default_factory=list)
|
|
982
|
+
recommended_context: list[str] = Field(
|
|
983
|
+
default_factory=list
|
|
984
|
+
) # File paths to keep context for
|
|
566
985
|
|
|
567
986
|
# High-frequency activity log
|
|
568
987
|
activity_log: list[ActivityEntry] = Field(default_factory=list)
|
|
@@ -571,26 +990,168 @@ class Session(BaseModel):
|
|
|
571
990
|
primary_work_type: str | None = None # WorkType enum value
|
|
572
991
|
work_breakdown: dict[str, int] | None = None # {work_type: event_count}
|
|
573
992
|
|
|
993
|
+
# Conversation tracking (for conversation-level auto-spikes)
|
|
994
|
+
last_conversation_id: str | None = None # Last external conversation ID
|
|
995
|
+
|
|
996
|
+
# Context tracking (Phase N: Context Analytics)
|
|
997
|
+
context_snapshots: list[ContextSnapshot] = Field(default_factory=list)
|
|
998
|
+
peak_context_tokens: int = 0 # High water mark for context usage
|
|
999
|
+
total_tokens_generated: int = 0 # Cumulative output tokens
|
|
1000
|
+
total_cost_usd: float = 0.0 # Cumulative cost for session
|
|
1001
|
+
context_by_feature: dict[str, int] = Field(
|
|
1002
|
+
default_factory=dict
|
|
1003
|
+
) # {feature_id: tokens}
|
|
1004
|
+
|
|
1005
|
+
# Claude Code transcript integration
|
|
1006
|
+
transcript_id: str | None = None # Claude Code session UUID (from JSONL)
|
|
1007
|
+
transcript_path: str | None = None # Path to source JSONL file
|
|
1008
|
+
transcript_synced_at: datetime | None = None # Last sync timestamp
|
|
1009
|
+
transcript_git_branch: str | None = None # Git branch from transcript
|
|
1010
|
+
|
|
1011
|
+
# Pattern detection (inline storage to avoid file bloat)
|
|
1012
|
+
detected_patterns: list[dict[str, Any]] = Field(default_factory=list)
|
|
1013
|
+
"""
|
|
1014
|
+
Patterns detected during this session.
|
|
1015
|
+
|
|
1016
|
+
Format:
|
|
1017
|
+
{
|
|
1018
|
+
"sequence": ["Bash", "Read", "Edit"],
|
|
1019
|
+
"pattern_type": "neutral", # or "optimal", "anti_pattern"
|
|
1020
|
+
"detection_count": 3,
|
|
1021
|
+
"first_detected": "2026-01-02T10:00:00",
|
|
1022
|
+
"last_detected": "2026-01-02T10:30:00"
|
|
1023
|
+
}
|
|
1024
|
+
"""
|
|
1025
|
+
|
|
1026
|
+
# Error handling (Phase 1B)
|
|
1027
|
+
error_log: list[ErrorEntry] = Field(default_factory=list)
|
|
1028
|
+
"""Error records for this session with full tracebacks for debugging."""
|
|
1029
|
+
|
|
574
1030
|
def add_activity(self, entry: ActivityEntry) -> None:
|
|
575
1031
|
"""Add an activity entry to the log."""
|
|
576
1032
|
self.activity_log.append(entry)
|
|
577
1033
|
self.event_count += 1
|
|
578
|
-
self.last_activity =
|
|
1034
|
+
self.last_activity = utc_now()
|
|
579
1035
|
|
|
580
1036
|
# Track features worked on
|
|
581
1037
|
if entry.feature_id and entry.feature_id not in self.worked_on:
|
|
582
1038
|
self.worked_on.append(entry.feature_id)
|
|
583
1039
|
|
|
1040
|
+
def add_error(
|
|
1041
|
+
self,
|
|
1042
|
+
error_type: str,
|
|
1043
|
+
message: str,
|
|
1044
|
+
traceback: str | None = None,
|
|
1045
|
+
tool: str | None = None,
|
|
1046
|
+
context: str | None = None,
|
|
1047
|
+
) -> None:
|
|
1048
|
+
"""
|
|
1049
|
+
Add an error entry to the error log.
|
|
1050
|
+
|
|
1051
|
+
Args:
|
|
1052
|
+
error_type: Exception class name (ValueError, FileNotFoundError, etc.)
|
|
1053
|
+
message: Error message
|
|
1054
|
+
traceback: Full traceback for debugging
|
|
1055
|
+
tool: Tool that caused the error (Edit, Bash, etc.)
|
|
1056
|
+
context: Additional context information
|
|
1057
|
+
"""
|
|
1058
|
+
error = ErrorEntry(
|
|
1059
|
+
error_type=error_type,
|
|
1060
|
+
message=message,
|
|
1061
|
+
traceback=traceback,
|
|
1062
|
+
tool=tool,
|
|
1063
|
+
context=context,
|
|
1064
|
+
session_id=self.id,
|
|
1065
|
+
)
|
|
1066
|
+
self.error_log.append(error)
|
|
1067
|
+
|
|
584
1068
|
def end(self) -> None:
|
|
585
1069
|
"""Mark session as ended."""
|
|
586
1070
|
self.status = "ended"
|
|
587
|
-
self.ended_at =
|
|
1071
|
+
self.ended_at = utc_now()
|
|
1072
|
+
|
|
1073
|
+
def record_context(
|
|
1074
|
+
self, snapshot: ContextSnapshot, sample_interval: int = 10
|
|
1075
|
+
) -> None:
|
|
1076
|
+
"""
|
|
1077
|
+
Record a context snapshot for analytics.
|
|
1078
|
+
|
|
1079
|
+
Args:
|
|
1080
|
+
snapshot: ContextSnapshot to record
|
|
1081
|
+
sample_interval: Only store every Nth snapshot to avoid bloat
|
|
1082
|
+
|
|
1083
|
+
Updates:
|
|
1084
|
+
- peak_context_tokens if current exceeds previous peak
|
|
1085
|
+
- total_tokens_generated from cumulative output
|
|
1086
|
+
- total_cost_usd from snapshot
|
|
1087
|
+
- context_by_feature if feature_id is set
|
|
1088
|
+
- context_snapshots (sampled)
|
|
1089
|
+
"""
|
|
1090
|
+
# Update peak context
|
|
1091
|
+
current_tokens = snapshot.current_tokens
|
|
1092
|
+
if current_tokens > self.peak_context_tokens:
|
|
1093
|
+
self.peak_context_tokens = current_tokens
|
|
1094
|
+
|
|
1095
|
+
# Update totals
|
|
1096
|
+
self.total_tokens_generated = snapshot.total_output_tokens
|
|
1097
|
+
self.total_cost_usd = snapshot.cost_usd
|
|
1098
|
+
|
|
1099
|
+
# Track context by feature
|
|
1100
|
+
if snapshot.feature_id:
|
|
1101
|
+
prev = self.context_by_feature.get(snapshot.feature_id, 0)
|
|
1102
|
+
# Use delta from last snapshot with same feature
|
|
1103
|
+
self.context_by_feature[snapshot.feature_id] = max(prev, current_tokens)
|
|
1104
|
+
|
|
1105
|
+
# Sample snapshots to avoid bloat (every Nth or on significant events)
|
|
1106
|
+
should_sample = (
|
|
1107
|
+
len(self.context_snapshots) == 0
|
|
1108
|
+
or len(self.context_snapshots) % sample_interval == 0
|
|
1109
|
+
or snapshot.trigger in ("session_start", "session_end", "feature_switch")
|
|
1110
|
+
or current_tokens > self.peak_context_tokens * 0.9 # Near peak
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
if should_sample:
|
|
1114
|
+
self.context_snapshots.append(snapshot)
|
|
1115
|
+
|
|
1116
|
+
def context_stats(self) -> dict:
|
|
1117
|
+
"""
|
|
1118
|
+
Get context usage statistics for this session.
|
|
1119
|
+
|
|
1120
|
+
Returns:
|
|
1121
|
+
Dictionary with context usage metrics
|
|
1122
|
+
"""
|
|
1123
|
+
if not self.context_snapshots:
|
|
1124
|
+
return {
|
|
1125
|
+
"peak_tokens": self.peak_context_tokens,
|
|
1126
|
+
"total_output": self.total_tokens_generated,
|
|
1127
|
+
"total_cost": self.total_cost_usd,
|
|
1128
|
+
"by_feature": self.context_by_feature,
|
|
1129
|
+
"snapshots": 0,
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
# Calculate averages and trends
|
|
1133
|
+
tokens_over_time = [s.current_tokens for s in self.context_snapshots]
|
|
1134
|
+
avg_tokens = (
|
|
1135
|
+
sum(tokens_over_time) / len(tokens_over_time) if tokens_over_time else 0
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
return {
|
|
1139
|
+
"peak_tokens": self.peak_context_tokens,
|
|
1140
|
+
"avg_tokens": int(avg_tokens),
|
|
1141
|
+
"total_output": self.total_tokens_generated,
|
|
1142
|
+
"total_cost": self.total_cost_usd,
|
|
1143
|
+
"by_feature": self.context_by_feature,
|
|
1144
|
+
"snapshots": len(self.context_snapshots),
|
|
1145
|
+
"peak_percent": (self.peak_context_tokens / 200000) * 100
|
|
1146
|
+
if self.context_snapshots
|
|
1147
|
+
else 0,
|
|
1148
|
+
}
|
|
588
1149
|
|
|
589
1150
|
def get_events(
|
|
590
1151
|
self,
|
|
591
1152
|
limit: int | None = 100,
|
|
592
1153
|
offset: int = 0,
|
|
593
|
-
events_dir: str = ".htmlgraph/events"
|
|
1154
|
+
events_dir: str = ".htmlgraph/events",
|
|
594
1155
|
) -> list[dict]:
|
|
595
1156
|
"""
|
|
596
1157
|
Get events for this session from JSONL event log.
|
|
@@ -610,6 +1171,7 @@ class Session(BaseModel):
|
|
|
610
1171
|
... print(f"{evt['event_id']}: {evt['tool']}")
|
|
611
1172
|
"""
|
|
612
1173
|
from htmlgraph.event_log import JsonlEventLog
|
|
1174
|
+
|
|
613
1175
|
event_log = JsonlEventLog(events_dir)
|
|
614
1176
|
return event_log.get_session_events(self.id, limit=limit, offset=offset)
|
|
615
1177
|
|
|
@@ -619,7 +1181,7 @@ class Session(BaseModel):
|
|
|
619
1181
|
feature_id: str | None = None,
|
|
620
1182
|
since: Any = None,
|
|
621
1183
|
limit: int | None = 100,
|
|
622
|
-
events_dir: str = ".htmlgraph/events"
|
|
1184
|
+
events_dir: str = ".htmlgraph/events",
|
|
623
1185
|
) -> list[dict]:
|
|
624
1186
|
"""
|
|
625
1187
|
Query events for this session with filters.
|
|
@@ -640,13 +1202,14 @@ class Session(BaseModel):
|
|
|
640
1202
|
>>> feature_events = session.query_events(feature_id='feat-123')
|
|
641
1203
|
"""
|
|
642
1204
|
from htmlgraph.event_log import JsonlEventLog
|
|
1205
|
+
|
|
643
1206
|
event_log = JsonlEventLog(events_dir)
|
|
644
1207
|
return event_log.query_events(
|
|
645
1208
|
session_id=self.id,
|
|
646
1209
|
tool=tool,
|
|
647
1210
|
feature_id=feature_id,
|
|
648
1211
|
since=since,
|
|
649
|
-
limit=limit
|
|
1212
|
+
limit=limit,
|
|
650
1213
|
)
|
|
651
1214
|
|
|
652
1215
|
def event_stats(self, events_dir: str = ".htmlgraph/events") -> dict:
|
|
@@ -664,28 +1227,30 @@ class Session(BaseModel):
|
|
|
664
1227
|
"""
|
|
665
1228
|
events = self.get_events(limit=None, events_dir=events_dir)
|
|
666
1229
|
|
|
667
|
-
by_tool = {}
|
|
668
|
-
by_feature = {}
|
|
1230
|
+
by_tool: dict[str, int] = {}
|
|
1231
|
+
by_feature: dict[str, int] = {}
|
|
669
1232
|
|
|
670
1233
|
for evt in events:
|
|
671
1234
|
# Count by tool
|
|
672
|
-
tool = evt.get(
|
|
1235
|
+
tool = evt.get("tool", "Unknown")
|
|
673
1236
|
by_tool[tool] = by_tool.get(tool, 0) + 1
|
|
674
1237
|
|
|
675
1238
|
# Count by feature
|
|
676
|
-
feature = evt.get(
|
|
1239
|
+
feature = evt.get("feature_id")
|
|
677
1240
|
if feature:
|
|
678
1241
|
by_feature[feature] = by_feature.get(feature, 0) + 1
|
|
679
1242
|
|
|
680
1243
|
return {
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1244
|
+
"total_events": len(events),
|
|
1245
|
+
"by_tool": by_tool,
|
|
1246
|
+
"by_feature": by_feature,
|
|
1247
|
+
"tools_used": len(by_tool),
|
|
1248
|
+
"features_worked": len(by_feature),
|
|
686
1249
|
}
|
|
687
1250
|
|
|
688
|
-
def calculate_work_breakdown(
|
|
1251
|
+
def calculate_work_breakdown(
|
|
1252
|
+
self, events_dir: str = ".htmlgraph/events"
|
|
1253
|
+
) -> dict[str, int]:
|
|
689
1254
|
"""
|
|
690
1255
|
Calculate distribution of work types from events.
|
|
691
1256
|
|
|
@@ -708,7 +1273,9 @@ class Session(BaseModel):
|
|
|
708
1273
|
|
|
709
1274
|
return breakdown
|
|
710
1275
|
|
|
711
|
-
def calculate_primary_work_type(
|
|
1276
|
+
def calculate_primary_work_type(
|
|
1277
|
+
self, events_dir: str = ".htmlgraph/events"
|
|
1278
|
+
) -> str | None:
|
|
712
1279
|
"""
|
|
713
1280
|
Determine primary work type based on event distribution.
|
|
714
1281
|
|
|
@@ -727,6 +1294,62 @@ class Session(BaseModel):
|
|
|
727
1294
|
# Return work type with most events
|
|
728
1295
|
return max(breakdown, key=breakdown.get) # type: ignore
|
|
729
1296
|
|
|
1297
|
+
def cleanup_missing_references(self, graph_dir: str | Path) -> dict[str, Any]:
|
|
1298
|
+
"""
|
|
1299
|
+
Remove references to deleted/missing work items from worked_on list.
|
|
1300
|
+
|
|
1301
|
+
This fixes session data integrity issues where worked_on contains IDs
|
|
1302
|
+
that no longer exist (deleted spikes, removed features, etc.).
|
|
1303
|
+
|
|
1304
|
+
Args:
|
|
1305
|
+
graph_dir: Path to .htmlgraph directory
|
|
1306
|
+
|
|
1307
|
+
Returns:
|
|
1308
|
+
Dict with cleanup statistics: {
|
|
1309
|
+
"removed": [...], # List of removed IDs
|
|
1310
|
+
"kept": [...], # List of valid IDs that were kept
|
|
1311
|
+
"removed_count": int,
|
|
1312
|
+
"kept_count": int
|
|
1313
|
+
}
|
|
1314
|
+
"""
|
|
1315
|
+
graph_path = Path(graph_dir)
|
|
1316
|
+
removed = []
|
|
1317
|
+
kept = []
|
|
1318
|
+
|
|
1319
|
+
# Check each work item in worked_on
|
|
1320
|
+
for item_id in self.worked_on:
|
|
1321
|
+
# Determine work item type from ID prefix
|
|
1322
|
+
if item_id.startswith("feat-") or item_id.startswith("feature-"):
|
|
1323
|
+
file_path = graph_path / "features" / f"{item_id}.html"
|
|
1324
|
+
elif item_id.startswith("bug-"):
|
|
1325
|
+
file_path = graph_path / "bugs" / f"{item_id}.html"
|
|
1326
|
+
elif item_id.startswith("spk-") or item_id.startswith("spike-"):
|
|
1327
|
+
file_path = graph_path / "spikes" / f"{item_id}.html"
|
|
1328
|
+
elif item_id.startswith("chore-"):
|
|
1329
|
+
file_path = graph_path / "chores" / f"{item_id}.html"
|
|
1330
|
+
elif item_id.startswith("epic-"):
|
|
1331
|
+
file_path = graph_path / "epics" / f"{item_id}.html"
|
|
1332
|
+
else:
|
|
1333
|
+
# Unknown type, keep it
|
|
1334
|
+
kept.append(item_id)
|
|
1335
|
+
continue
|
|
1336
|
+
|
|
1337
|
+
# Check if file exists
|
|
1338
|
+
if file_path.exists():
|
|
1339
|
+
kept.append(item_id)
|
|
1340
|
+
else:
|
|
1341
|
+
removed.append(item_id)
|
|
1342
|
+
|
|
1343
|
+
# Update worked_on with only valid references
|
|
1344
|
+
self.worked_on = kept
|
|
1345
|
+
|
|
1346
|
+
return {
|
|
1347
|
+
"removed": removed,
|
|
1348
|
+
"kept": kept,
|
|
1349
|
+
"removed_count": len(removed),
|
|
1350
|
+
"kept_count": len(kept),
|
|
1351
|
+
}
|
|
1352
|
+
|
|
730
1353
|
def to_html(self, stylesheet_path: str = "../styles.css") -> str:
|
|
731
1354
|
"""Convert session to HTML document with inline activity log."""
|
|
732
1355
|
# Build edges HTML for worked_on features
|
|
@@ -739,13 +1362,13 @@ class Session(BaseModel):
|
|
|
739
1362
|
f'<li><a href="../features/{fid}.html" data-relationship="worked-on">{fid}</a></li>'
|
|
740
1363
|
for fid in self.worked_on
|
|
741
1364
|
)
|
|
742
|
-
edge_sections.append(f
|
|
1365
|
+
edge_sections.append(f"""
|
|
743
1366
|
<section data-edge-type="worked-on">
|
|
744
1367
|
<h3>Worked On:</h3>
|
|
745
1368
|
<ul>
|
|
746
1369
|
{feature_links}
|
|
747
1370
|
</ul>
|
|
748
|
-
</section>
|
|
1371
|
+
</section>""")
|
|
749
1372
|
|
|
750
1373
|
if self.continued_from:
|
|
751
1374
|
edge_sections.append(f'''
|
|
@@ -756,65 +1379,203 @@ class Session(BaseModel):
|
|
|
756
1379
|
</ul>
|
|
757
1380
|
</section>''')
|
|
758
1381
|
|
|
759
|
-
edges_html = f
|
|
1382
|
+
edges_html = f"""
|
|
760
1383
|
<nav data-graph-edges>{"".join(edge_sections)}
|
|
761
|
-
</nav>
|
|
1384
|
+
</nav>"""
|
|
762
1385
|
|
|
763
1386
|
# Build handoff HTML
|
|
764
1387
|
handoff_html = ""
|
|
765
|
-
if
|
|
766
|
-
|
|
1388
|
+
if (
|
|
1389
|
+
self.handoff_notes
|
|
1390
|
+
or self.recommended_next
|
|
1391
|
+
or self.blockers
|
|
1392
|
+
or self.recommended_context
|
|
1393
|
+
):
|
|
1394
|
+
handoff_section = """
|
|
767
1395
|
<section data-handoff>
|
|
768
|
-
<h3>Handoff Context</h3>
|
|
1396
|
+
<h3>Handoff Context</h3>"""
|
|
769
1397
|
|
|
770
1398
|
if self.handoff_notes:
|
|
771
|
-
handoff_section += f
|
|
1399
|
+
handoff_section += f"\n <p data-handoff-notes><strong>Notes:</strong> {self.handoff_notes}</p>"
|
|
772
1400
|
|
|
773
1401
|
if self.recommended_next:
|
|
774
|
-
handoff_section += f
|
|
1402
|
+
handoff_section += f"\n <p data-recommended-next><strong>Recommended Next:</strong> {self.recommended_next}</p>"
|
|
775
1403
|
|
|
776
1404
|
if self.blockers:
|
|
777
1405
|
blockers_items = "\n ".join(
|
|
778
1406
|
f"<li>{blocker}</li>" for blocker in self.blockers
|
|
779
1407
|
)
|
|
780
|
-
handoff_section += f
|
|
1408
|
+
handoff_section += f"""
|
|
781
1409
|
<div data-blockers>
|
|
782
1410
|
<strong>Blockers:</strong>
|
|
783
1411
|
<ul>
|
|
784
1412
|
{blockers_items}
|
|
785
1413
|
</ul>
|
|
786
|
-
</div>
|
|
1414
|
+
</div>"""
|
|
787
1415
|
|
|
788
|
-
|
|
1416
|
+
if self.recommended_context:
|
|
1417
|
+
context_items = "\n ".join(
|
|
1418
|
+
f"<li>{file_path}</li>" for file_path in self.recommended_context
|
|
1419
|
+
)
|
|
1420
|
+
handoff_section += f"""
|
|
1421
|
+
<div data-recommended-context>
|
|
1422
|
+
<strong>Recommended Context:</strong>
|
|
1423
|
+
<ul>
|
|
1424
|
+
{context_items}
|
|
1425
|
+
</ul>
|
|
1426
|
+
</div>"""
|
|
1427
|
+
|
|
1428
|
+
handoff_section += "\n </section>"
|
|
789
1429
|
handoff_html = handoff_section
|
|
790
1430
|
|
|
791
1431
|
# Build activity log HTML
|
|
792
1432
|
activity_html = ""
|
|
793
1433
|
if self.activity_log:
|
|
794
1434
|
# Show most recent first (reversed)
|
|
1435
|
+
# NOTE: Previously limited to last 100 entries, but this caused data loss
|
|
1436
|
+
# for pattern detection and analytics. Now stores all entries.
|
|
795
1437
|
log_items = "\n ".join(
|
|
796
|
-
entry.to_html()
|
|
1438
|
+
entry.to_html()
|
|
1439
|
+
for entry in reversed(self.activity_log) # All entries
|
|
797
1440
|
)
|
|
798
|
-
activity_html = f
|
|
1441
|
+
activity_html = f"""
|
|
799
1442
|
<section data-activity-log>
|
|
800
1443
|
<h3>Activity Log ({self.event_count} events)</h3>
|
|
801
1444
|
<ol reversed>
|
|
802
1445
|
{log_items}
|
|
803
1446
|
</ol>
|
|
804
|
-
</section>
|
|
1447
|
+
</section>"""
|
|
805
1448
|
|
|
806
1449
|
# Build attributes
|
|
807
1450
|
subagent_attr = f' data-is-subagent="{str(self.is_subagent).lower()}"'
|
|
808
|
-
commit_attr =
|
|
809
|
-
|
|
810
|
-
|
|
1451
|
+
commit_attr = (
|
|
1452
|
+
f' data-start-commit="{self.start_commit}"' if self.start_commit else ""
|
|
1453
|
+
)
|
|
1454
|
+
ended_attr = (
|
|
1455
|
+
f' data-ended-at="{self.ended_at.isoformat()}"' if self.ended_at else ""
|
|
1456
|
+
)
|
|
1457
|
+
primary_work_type_attr = (
|
|
1458
|
+
f' data-primary-work-type="{self.primary_work_type}"'
|
|
1459
|
+
if self.primary_work_type
|
|
1460
|
+
else ""
|
|
1461
|
+
)
|
|
1462
|
+
# Parent session attributes
|
|
1463
|
+
parent_session_attrs = ""
|
|
1464
|
+
if self.parent_session:
|
|
1465
|
+
parent_session_attrs += f' data-parent-session="{self.parent_session}"'
|
|
1466
|
+
if self.parent_activity:
|
|
1467
|
+
parent_session_attrs += f' data-parent-activity="{self.parent_activity}"'
|
|
1468
|
+
if self.nesting_depth > 0:
|
|
1469
|
+
parent_session_attrs += f' data-nesting-depth="{self.nesting_depth}"'
|
|
811
1470
|
|
|
812
1471
|
# Serialize work_breakdown as JSON if present
|
|
813
1472
|
import json
|
|
1473
|
+
|
|
814
1474
|
work_breakdown_attr = ""
|
|
815
1475
|
if self.work_breakdown:
|
|
816
1476
|
work_breakdown_json = json.dumps(self.work_breakdown)
|
|
817
|
-
work_breakdown_attr = f
|
|
1477
|
+
work_breakdown_attr = f" data-work-breakdown='{work_breakdown_json}'"
|
|
1478
|
+
|
|
1479
|
+
# Context tracking attributes
|
|
1480
|
+
context_attrs = ""
|
|
1481
|
+
if self.peak_context_tokens > 0:
|
|
1482
|
+
context_attrs += f' data-peak-context="{self.peak_context_tokens}"'
|
|
1483
|
+
if self.total_tokens_generated > 0:
|
|
1484
|
+
context_attrs += f' data-total-output="{self.total_tokens_generated}"'
|
|
1485
|
+
if self.total_cost_usd > 0:
|
|
1486
|
+
context_attrs += f' data-total-cost="{self.total_cost_usd:.4f}"'
|
|
1487
|
+
if self.context_by_feature:
|
|
1488
|
+
context_by_feature_json = json.dumps(self.context_by_feature)
|
|
1489
|
+
context_attrs += f" data-context-by-feature='{context_by_feature_json}'"
|
|
1490
|
+
|
|
1491
|
+
# Transcript integration attributes
|
|
1492
|
+
transcript_attrs = ""
|
|
1493
|
+
if self.transcript_id:
|
|
1494
|
+
transcript_attrs += f' data-transcript-id="{self.transcript_id}"'
|
|
1495
|
+
if self.transcript_path:
|
|
1496
|
+
transcript_attrs += f' data-transcript-path="{self.transcript_path}"'
|
|
1497
|
+
if self.transcript_synced_at:
|
|
1498
|
+
transcript_attrs += (
|
|
1499
|
+
f' data-transcript-synced="{self.transcript_synced_at.isoformat()}"'
|
|
1500
|
+
)
|
|
1501
|
+
if self.transcript_git_branch:
|
|
1502
|
+
transcript_attrs += (
|
|
1503
|
+
f' data-transcript-branch="{self.transcript_git_branch}"'
|
|
1504
|
+
)
|
|
1505
|
+
|
|
1506
|
+
# Build context summary section
|
|
1507
|
+
context_html = ""
|
|
1508
|
+
if self.peak_context_tokens > 0 or self.context_snapshots:
|
|
1509
|
+
context_html = f"""
|
|
1510
|
+
<section data-context-tracking>
|
|
1511
|
+
<h3>Context Usage</h3>
|
|
1512
|
+
<dl>
|
|
1513
|
+
<dt>Peak Context</dt>
|
|
1514
|
+
<dd>{self.peak_context_tokens:,} tokens ({self.peak_context_tokens * 100 // 200000}%)</dd>
|
|
1515
|
+
<dt>Total Output</dt>
|
|
1516
|
+
<dd>{self.total_tokens_generated:,} tokens</dd>
|
|
1517
|
+
<dt>Total Cost</dt>
|
|
1518
|
+
<dd>${self.total_cost_usd:.4f}</dd>
|
|
1519
|
+
<dt>Snapshots</dt>
|
|
1520
|
+
<dd>{len(self.context_snapshots)}</dd>
|
|
1521
|
+
</dl>
|
|
1522
|
+
</section>"""
|
|
1523
|
+
|
|
1524
|
+
# Build detected patterns section
|
|
1525
|
+
patterns_html = ""
|
|
1526
|
+
if self.detected_patterns:
|
|
1527
|
+
patterns_html = f"""
|
|
1528
|
+
<section data-detected-patterns>
|
|
1529
|
+
<h3>Detected Patterns ({len(self.detected_patterns)})</h3>
|
|
1530
|
+
<table class="patterns-table">
|
|
1531
|
+
<thead>
|
|
1532
|
+
<tr>
|
|
1533
|
+
<th>Sequence</th>
|
|
1534
|
+
<th>Type</th>
|
|
1535
|
+
<th>Count</th>
|
|
1536
|
+
<th>First/Last Detected</th>
|
|
1537
|
+
</tr>
|
|
1538
|
+
</thead>
|
|
1539
|
+
<tbody>"""
|
|
1540
|
+
|
|
1541
|
+
for pattern in self.detected_patterns:
|
|
1542
|
+
seq_str = " → ".join(pattern.get("sequence", []))
|
|
1543
|
+
pattern_type = pattern.get("pattern_type", "neutral")
|
|
1544
|
+
count = pattern.get("detection_count", 0)
|
|
1545
|
+
first = pattern.get("first_detected", "")
|
|
1546
|
+
last = pattern.get("last_detected", "")
|
|
1547
|
+
|
|
1548
|
+
patterns_html += f"""
|
|
1549
|
+
<tr data-pattern-type="{pattern_type}">
|
|
1550
|
+
<td class="sequence">{seq_str}</td>
|
|
1551
|
+
<td><span class="badge pattern-{pattern_type}">{pattern_type}</span></td>
|
|
1552
|
+
<td>{count}</td>
|
|
1553
|
+
<td>{first} / {last}</td>
|
|
1554
|
+
</tr>"""
|
|
1555
|
+
|
|
1556
|
+
patterns_html += """
|
|
1557
|
+
</tbody>
|
|
1558
|
+
</table>
|
|
1559
|
+
</section>"""
|
|
1560
|
+
|
|
1561
|
+
# Build error log section
|
|
1562
|
+
error_html = ""
|
|
1563
|
+
if self.error_log:
|
|
1564
|
+
error_items = "\n ".join(
|
|
1565
|
+
error.to_html() for error in self.error_log
|
|
1566
|
+
)
|
|
1567
|
+
error_html = f"""
|
|
1568
|
+
<section data-error-log>
|
|
1569
|
+
<h3>Errors ({len(self.error_log)})</h3>
|
|
1570
|
+
<div class="error-log">
|
|
1571
|
+
{error_items}
|
|
1572
|
+
</div>
|
|
1573
|
+
<style>
|
|
1574
|
+
.error-item {{ margin: 10px 0; padding: 10px; border-left: 3px solid #ff6b6b; }}
|
|
1575
|
+
.error-type {{ font-weight: bold; color: #ff6b6b; }}
|
|
1576
|
+
.traceback {{ background: #f5f5f5; padding: 10px; overflow-x: auto; font-size: 0.9em; margin-top: 5px; }}
|
|
1577
|
+
</style>
|
|
1578
|
+
</section>"""
|
|
818
1579
|
|
|
819
1580
|
title = self.title or f"Session {self.id}"
|
|
820
1581
|
|
|
@@ -834,7 +1595,7 @@ class Session(BaseModel):
|
|
|
834
1595
|
data-agent="{self.agent}"
|
|
835
1596
|
data-started-at="{self.started_at.isoformat()}"
|
|
836
1597
|
data-last-activity="{self.last_activity.isoformat()}"
|
|
837
|
-
data-event-count="{self.event_count}"{subagent_attr}{commit_attr}{ended_attr}{primary_work_type_attr}{work_breakdown_attr}>
|
|
1598
|
+
data-event-count="{self.event_count}"{subagent_attr}{commit_attr}{ended_attr}{primary_work_type_attr}{work_breakdown_attr}{context_attrs}{transcript_attrs}{parent_session_attrs}>
|
|
838
1599
|
|
|
839
1600
|
<header>
|
|
840
1601
|
<h1>{title}</h1>
|
|
@@ -844,7 +1605,7 @@ class Session(BaseModel):
|
|
|
844
1605
|
<span class="badge">{self.event_count} events</span>
|
|
845
1606
|
</div>
|
|
846
1607
|
</header>
|
|
847
|
-
{edges_html}{handoff_html}{activity_html}
|
|
1608
|
+
{edges_html}{handoff_html}{context_html}{error_html}{patterns_html}{activity_html}
|
|
848
1609
|
</article>
|
|
849
1610
|
</body>
|
|
850
1611
|
</html>
|
|
@@ -885,6 +1646,11 @@ class Session(BaseModel):
|
|
|
885
1646
|
ActivityEntry(**e) if isinstance(e, dict) else e
|
|
886
1647
|
for e in data["activity_log"]
|
|
887
1648
|
]
|
|
1649
|
+
if "context_snapshots" in data:
|
|
1650
|
+
data["context_snapshots"] = [
|
|
1651
|
+
ContextSnapshot.from_dict(s) if isinstance(s, dict) else s
|
|
1652
|
+
for s in data["context_snapshots"]
|
|
1653
|
+
]
|
|
888
1654
|
return cls(**data)
|
|
889
1655
|
|
|
890
1656
|
|
|
@@ -925,3 +1691,736 @@ class Graph(BaseModel):
|
|
|
925
1691
|
def to_context(self) -> str:
|
|
926
1692
|
"""Generate lightweight context for all nodes."""
|
|
927
1693
|
return "\n\n".join(node.to_context() for node in self.nodes.values())
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
class Pattern(Node):
|
|
1697
|
+
"""Learned workflow pattern for agent optimization.
|
|
1698
|
+
|
|
1699
|
+
Stores detected tool sequences that are either optimal patterns
|
|
1700
|
+
to encourage or anti-patterns to avoid.
|
|
1701
|
+
"""
|
|
1702
|
+
|
|
1703
|
+
pattern_type: Literal["optimal", "anti-pattern", "neutral"] = "neutral"
|
|
1704
|
+
sequence: list[str] = Field(default_factory=list) # ["Bash", "Edit", "Read"]
|
|
1705
|
+
|
|
1706
|
+
# Detection metrics
|
|
1707
|
+
detection_count: int = 0
|
|
1708
|
+
success_rate: float = 0.0 # 0.0-1.0
|
|
1709
|
+
avg_duration_seconds: float = 0.0
|
|
1710
|
+
|
|
1711
|
+
# Sessions where detected
|
|
1712
|
+
detected_in_sessions: list[str] = Field(default_factory=list)
|
|
1713
|
+
|
|
1714
|
+
# Recommendation
|
|
1715
|
+
recommendation: str | None = None
|
|
1716
|
+
|
|
1717
|
+
# Trend
|
|
1718
|
+
first_detected: datetime | None = None
|
|
1719
|
+
last_detected: datetime | None = None
|
|
1720
|
+
detection_trend: Literal["increasing", "stable", "decreasing"] = "stable"
|
|
1721
|
+
|
|
1722
|
+
def __init__(self, **data: Any):
|
|
1723
|
+
# Ensure type is always "pattern"
|
|
1724
|
+
data["type"] = "pattern"
|
|
1725
|
+
super().__init__(**data)
|
|
1726
|
+
|
|
1727
|
+
def to_html(self, stylesheet_path: str = "../styles.css") -> str:
|
|
1728
|
+
"""Convert pattern to HTML document with pattern-specific fields."""
|
|
1729
|
+
# Build pattern sequence HTML
|
|
1730
|
+
sequence_html = ""
|
|
1731
|
+
if self.sequence:
|
|
1732
|
+
sequence_items = " → ".join(self.sequence)
|
|
1733
|
+
sequence_html = f"""
|
|
1734
|
+
<section data-pattern-sequence>
|
|
1735
|
+
<h3>Tool Sequence</h3>
|
|
1736
|
+
<p class="sequence">{sequence_items}</p>
|
|
1737
|
+
</section>"""
|
|
1738
|
+
|
|
1739
|
+
# Build pattern metrics HTML
|
|
1740
|
+
metrics_html = f"""
|
|
1741
|
+
<section data-pattern-metrics>
|
|
1742
|
+
<h3>Pattern Metrics</h3>
|
|
1743
|
+
<dl>
|
|
1744
|
+
<dt>Detection Count</dt>
|
|
1745
|
+
<dd>{self.detection_count}</dd>
|
|
1746
|
+
<dt>Success Rate</dt>
|
|
1747
|
+
<dd>{self.success_rate:.1%}</dd>
|
|
1748
|
+
<dt>Avg Duration</dt>
|
|
1749
|
+
<dd>{self.avg_duration_seconds:.1f}s</dd>
|
|
1750
|
+
</dl>
|
|
1751
|
+
</section>"""
|
|
1752
|
+
|
|
1753
|
+
# Build detected sessions HTML
|
|
1754
|
+
detected_sessions_html = ""
|
|
1755
|
+
if self.detected_in_sessions:
|
|
1756
|
+
session_links = "\n ".join(
|
|
1757
|
+
f'<li><a href="../sessions/{sid}.html">{sid}</a></li>'
|
|
1758
|
+
for sid in self.detected_in_sessions
|
|
1759
|
+
)
|
|
1760
|
+
detected_sessions_html = f"""
|
|
1761
|
+
<section data-detected-sessions>
|
|
1762
|
+
<h3>Detected In Sessions</h3>
|
|
1763
|
+
<ul>
|
|
1764
|
+
{session_links}
|
|
1765
|
+
</ul>
|
|
1766
|
+
</section>"""
|
|
1767
|
+
|
|
1768
|
+
# Build recommendation HTML
|
|
1769
|
+
recommendation_html = ""
|
|
1770
|
+
if self.recommendation:
|
|
1771
|
+
recommendation_html = f"""
|
|
1772
|
+
<section data-recommendation>
|
|
1773
|
+
<h3>Recommendation</h3>
|
|
1774
|
+
<p>{self.recommendation}</p>
|
|
1775
|
+
</section>"""
|
|
1776
|
+
|
|
1777
|
+
# Build trend HTML
|
|
1778
|
+
trend_html = ""
|
|
1779
|
+
if self.first_detected or self.last_detected:
|
|
1780
|
+
trend_html = """
|
|
1781
|
+
<section data-trend>
|
|
1782
|
+
<h3>Trend Analysis</h3>
|
|
1783
|
+
<dl>"""
|
|
1784
|
+
if self.first_detected:
|
|
1785
|
+
trend_html += f"""
|
|
1786
|
+
<dt>First Detected</dt>
|
|
1787
|
+
<dd>{self.first_detected.strftime("%Y-%m-%d %H:%M")}</dd>"""
|
|
1788
|
+
if self.last_detected:
|
|
1789
|
+
trend_html += f"""
|
|
1790
|
+
<dt>Last Detected</dt>
|
|
1791
|
+
<dd>{self.last_detected.strftime("%Y-%m-%d %H:%M")}</dd>"""
|
|
1792
|
+
trend_html += f"""
|
|
1793
|
+
<dt>Detection Trend</dt>
|
|
1794
|
+
<dd class="trend-{self.detection_trend}">{self.detection_trend.title()}</dd>
|
|
1795
|
+
</dl>
|
|
1796
|
+
</section>"""
|
|
1797
|
+
|
|
1798
|
+
# Build pattern-specific attributes
|
|
1799
|
+
pattern_attrs = f' data-pattern-type="{self.pattern_type}"'
|
|
1800
|
+
pattern_attrs += f' data-detection-count="{self.detection_count}"'
|
|
1801
|
+
pattern_attrs += f' data-success-rate="{self.success_rate:.2f}"'
|
|
1802
|
+
pattern_attrs += f' data-detection-trend="{self.detection_trend}"'
|
|
1803
|
+
if self.sequence:
|
|
1804
|
+
import json
|
|
1805
|
+
|
|
1806
|
+
sequence_json = json.dumps(self.sequence)
|
|
1807
|
+
pattern_attrs += f" data-sequence='{sequence_json}'"
|
|
1808
|
+
|
|
1809
|
+
return f'''<!DOCTYPE html>
|
|
1810
|
+
<html lang="en">
|
|
1811
|
+
<head>
|
|
1812
|
+
<meta charset="UTF-8">
|
|
1813
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1814
|
+
<meta name="htmlgraph-version" content="1.0">
|
|
1815
|
+
<title>{self.title}</title>
|
|
1816
|
+
<link rel="stylesheet" href="{stylesheet_path}">
|
|
1817
|
+
</head>
|
|
1818
|
+
<body>
|
|
1819
|
+
<article id="{self.id}"
|
|
1820
|
+
data-type="{self.type}"
|
|
1821
|
+
data-status="{self.status}"
|
|
1822
|
+
data-priority="{self.priority}"
|
|
1823
|
+
data-created="{self.created.isoformat()}"
|
|
1824
|
+
data-updated="{self.updated.isoformat()}"{pattern_attrs}>
|
|
1825
|
+
|
|
1826
|
+
<header>
|
|
1827
|
+
<h1>{self.title}</h1>
|
|
1828
|
+
<div class="metadata">
|
|
1829
|
+
<span class="badge status-{self.status}">{self.status.replace("-", " ").title()}</span>
|
|
1830
|
+
<span class="badge pattern-{self.pattern_type}">{self.pattern_type.title()}</span>
|
|
1831
|
+
</div>
|
|
1832
|
+
</header>
|
|
1833
|
+
{sequence_html}{metrics_html}{detected_sessions_html}{recommendation_html}{trend_html}
|
|
1834
|
+
</article>
|
|
1835
|
+
</body>
|
|
1836
|
+
</html>
|
|
1837
|
+
'''
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
class SessionInsight(Node):
|
|
1841
|
+
"""Session analysis and health metrics.
|
|
1842
|
+
|
|
1843
|
+
Stores efficiency scores, detected issues, and recommendations
|
|
1844
|
+
for a specific session.
|
|
1845
|
+
"""
|
|
1846
|
+
|
|
1847
|
+
session_id: str = ""
|
|
1848
|
+
insight_type: Literal["health", "recommendation", "anomaly"] = "health"
|
|
1849
|
+
|
|
1850
|
+
# Health metrics
|
|
1851
|
+
efficiency_score: float = 0.0 # 0.0-1.0
|
|
1852
|
+
retry_rate: float = 0.0
|
|
1853
|
+
context_rebuild_count: int = 0
|
|
1854
|
+
tool_diversity: float = 0.0
|
|
1855
|
+
error_recovery_rate: float = 0.0
|
|
1856
|
+
overall_health_score: float = 0.0
|
|
1857
|
+
|
|
1858
|
+
# Detections
|
|
1859
|
+
issues_detected: list[str] = Field(default_factory=list)
|
|
1860
|
+
patterns_matched: list[str] = Field(default_factory=list) # Pattern IDs
|
|
1861
|
+
anti_patterns_matched: list[str] = Field(default_factory=list)
|
|
1862
|
+
|
|
1863
|
+
# Recommendations
|
|
1864
|
+
recommendations: list[str] = Field(default_factory=list)
|
|
1865
|
+
|
|
1866
|
+
# Metadata
|
|
1867
|
+
analyzed_at: datetime | None = None
|
|
1868
|
+
|
|
1869
|
+
def __init__(self, **data: Any):
|
|
1870
|
+
# Ensure type is always "session-insight"
|
|
1871
|
+
data["type"] = "session-insight"
|
|
1872
|
+
super().__init__(**data)
|
|
1873
|
+
|
|
1874
|
+
def to_html(self, stylesheet_path: str = "../styles.css") -> str:
|
|
1875
|
+
"""Convert session insight to HTML document with insight-specific fields."""
|
|
1876
|
+
# Build health metrics HTML
|
|
1877
|
+
metrics_html = f"""
|
|
1878
|
+
<section data-health-metrics>
|
|
1879
|
+
<h3>Health Metrics</h3>
|
|
1880
|
+
<dl>
|
|
1881
|
+
<dt>Efficiency Score</dt>
|
|
1882
|
+
<dd>{self.efficiency_score:.2f}</dd>
|
|
1883
|
+
<dt>Retry Rate</dt>
|
|
1884
|
+
<dd>{self.retry_rate:.1%}</dd>
|
|
1885
|
+
<dt>Context Rebuild Count</dt>
|
|
1886
|
+
<dd>{self.context_rebuild_count}</dd>
|
|
1887
|
+
<dt>Tool Diversity</dt>
|
|
1888
|
+
<dd>{self.tool_diversity:.2f}</dd>
|
|
1889
|
+
<dt>Error Recovery Rate</dt>
|
|
1890
|
+
<dd>{self.error_recovery_rate:.1%}</dd>
|
|
1891
|
+
<dt>Overall Health Score</dt>
|
|
1892
|
+
<dd class="health-score">{self.overall_health_score:.2f}</dd>
|
|
1893
|
+
</dl>
|
|
1894
|
+
</section>"""
|
|
1895
|
+
|
|
1896
|
+
# Build issues detected HTML
|
|
1897
|
+
issues_html = ""
|
|
1898
|
+
if self.issues_detected:
|
|
1899
|
+
issues_items = "\n ".join(
|
|
1900
|
+
f"<li>{issue}</li>" for issue in self.issues_detected
|
|
1901
|
+
)
|
|
1902
|
+
issues_html = f"""
|
|
1903
|
+
<section data-issues-detected>
|
|
1904
|
+
<h3>Issues Detected</h3>
|
|
1905
|
+
<ul>
|
|
1906
|
+
{issues_items}
|
|
1907
|
+
</ul>
|
|
1908
|
+
</section>"""
|
|
1909
|
+
|
|
1910
|
+
# Build patterns matched HTML
|
|
1911
|
+
patterns_html = ""
|
|
1912
|
+
if self.patterns_matched or self.anti_patterns_matched:
|
|
1913
|
+
patterns_section = """
|
|
1914
|
+
<section data-patterns-matched>
|
|
1915
|
+
<h3>Patterns Matched</h3>"""
|
|
1916
|
+
|
|
1917
|
+
if self.patterns_matched:
|
|
1918
|
+
pattern_links = "\n ".join(
|
|
1919
|
+
f'<li><a href="../patterns/{pid}.html" data-pattern-type="optimal">{pid}</a></li>'
|
|
1920
|
+
for pid in self.patterns_matched
|
|
1921
|
+
)
|
|
1922
|
+
patterns_section += f"""
|
|
1923
|
+
<div data-optimal-patterns>
|
|
1924
|
+
<h4>Optimal Patterns:</h4>
|
|
1925
|
+
<ul>
|
|
1926
|
+
{pattern_links}
|
|
1927
|
+
</ul>
|
|
1928
|
+
</div>"""
|
|
1929
|
+
|
|
1930
|
+
if self.anti_patterns_matched:
|
|
1931
|
+
anti_pattern_links = "\n ".join(
|
|
1932
|
+
f'<li><a href="../patterns/{pid}.html" data-pattern-type="anti-pattern">{pid}</a></li>'
|
|
1933
|
+
for pid in self.anti_patterns_matched
|
|
1934
|
+
)
|
|
1935
|
+
patterns_section += f"""
|
|
1936
|
+
<div data-anti-patterns>
|
|
1937
|
+
<h4>Anti-Patterns:</h4>
|
|
1938
|
+
<ul>
|
|
1939
|
+
{anti_pattern_links}
|
|
1940
|
+
</ul>
|
|
1941
|
+
</div>"""
|
|
1942
|
+
|
|
1943
|
+
patterns_section += """
|
|
1944
|
+
</section>"""
|
|
1945
|
+
patterns_html = patterns_section
|
|
1946
|
+
|
|
1947
|
+
# Build recommendations HTML
|
|
1948
|
+
recommendations_html = ""
|
|
1949
|
+
if self.recommendations:
|
|
1950
|
+
rec_items = "\n ".join(
|
|
1951
|
+
f"<li>{rec}</li>" for rec in self.recommendations
|
|
1952
|
+
)
|
|
1953
|
+
recommendations_html = f"""
|
|
1954
|
+
<section data-recommendations>
|
|
1955
|
+
<h3>Recommendations</h3>
|
|
1956
|
+
<ul>
|
|
1957
|
+
{rec_items}
|
|
1958
|
+
</ul>
|
|
1959
|
+
</section>"""
|
|
1960
|
+
|
|
1961
|
+
# Build session link HTML
|
|
1962
|
+
session_link_html = ""
|
|
1963
|
+
if self.session_id:
|
|
1964
|
+
session_link_html = f"""
|
|
1965
|
+
<section data-session-link>
|
|
1966
|
+
<h3>Related Session</h3>
|
|
1967
|
+
<p><a href="../sessions/{self.session_id}.html">{self.session_id}</a></p>
|
|
1968
|
+
</section>"""
|
|
1969
|
+
|
|
1970
|
+
# Build insight-specific attributes
|
|
1971
|
+
import json
|
|
1972
|
+
|
|
1973
|
+
insight_attrs = (
|
|
1974
|
+
f' data-session-id="{self.session_id}"' if self.session_id else ""
|
|
1975
|
+
)
|
|
1976
|
+
insight_attrs += f' data-insight-type="{self.insight_type}"'
|
|
1977
|
+
insight_attrs += f' data-efficiency-score="{self.efficiency_score:.2f}"'
|
|
1978
|
+
insight_attrs += f' data-retry-rate="{self.retry_rate:.2f}"'
|
|
1979
|
+
insight_attrs += f' data-overall-health="{self.overall_health_score:.2f}"'
|
|
1980
|
+
|
|
1981
|
+
if self.analyzed_at:
|
|
1982
|
+
insight_attrs += f' data-analyzed-at="{self.analyzed_at.isoformat()}"'
|
|
1983
|
+
|
|
1984
|
+
if self.issues_detected:
|
|
1985
|
+
issues_json = json.dumps(self.issues_detected)
|
|
1986
|
+
insight_attrs += f" data-issues='{issues_json}'"
|
|
1987
|
+
|
|
1988
|
+
return f'''<!DOCTYPE html>
|
|
1989
|
+
<html lang="en">
|
|
1990
|
+
<head>
|
|
1991
|
+
<meta charset="UTF-8">
|
|
1992
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1993
|
+
<meta name="htmlgraph-version" content="1.0">
|
|
1994
|
+
<title>{self.title}</title>
|
|
1995
|
+
<link rel="stylesheet" href="{stylesheet_path}">
|
|
1996
|
+
</head>
|
|
1997
|
+
<body>
|
|
1998
|
+
<article id="{self.id}"
|
|
1999
|
+
data-type="{self.type}"
|
|
2000
|
+
data-status="{self.status}"
|
|
2001
|
+
data-priority="{self.priority}"
|
|
2002
|
+
data-created="{self.created.isoformat()}"
|
|
2003
|
+
data-updated="{self.updated.isoformat()}"{insight_attrs}>
|
|
2004
|
+
|
|
2005
|
+
<header>
|
|
2006
|
+
<h1>{self.title}</h1>
|
|
2007
|
+
<div class="metadata">
|
|
2008
|
+
<span class="badge status-{self.status}">{self.status.replace("-", " ").title()}</span>
|
|
2009
|
+
<span class="badge insight-{self.insight_type}">{self.insight_type.title()}</span>
|
|
2010
|
+
<span class="badge health-score">Health: {self.overall_health_score:.2f}</span>
|
|
2011
|
+
</div>
|
|
2012
|
+
</header>
|
|
2013
|
+
{session_link_html}{metrics_html}{issues_html}{patterns_html}{recommendations_html}
|
|
2014
|
+
</article>
|
|
2015
|
+
</body>
|
|
2016
|
+
</html>
|
|
2017
|
+
'''
|
|
2018
|
+
|
|
2019
|
+
|
|
2020
|
+
class AggregatedMetric(Node):
|
|
2021
|
+
"""Time-aggregated metrics across sessions.
|
|
2022
|
+
|
|
2023
|
+
Stores weekly/monthly aggregated metrics for trend analysis.
|
|
2024
|
+
"""
|
|
2025
|
+
|
|
2026
|
+
metric_type: Literal["efficiency", "context_usage", "tool_distribution"] = (
|
|
2027
|
+
"efficiency"
|
|
2028
|
+
)
|
|
2029
|
+
scope: Literal["session", "feature", "track", "agent"] = "session"
|
|
2030
|
+
scope_id: str | None = None
|
|
2031
|
+
|
|
2032
|
+
# Time window
|
|
2033
|
+
period: Literal["daily", "weekly", "monthly"] = "weekly"
|
|
2034
|
+
period_start: datetime | None = None
|
|
2035
|
+
period_end: datetime | None = None
|
|
2036
|
+
|
|
2037
|
+
# Metrics
|
|
2038
|
+
metric_values: dict[str, float] = Field(default_factory=dict)
|
|
2039
|
+
percentiles: dict[str, float] = Field(
|
|
2040
|
+
default_factory=dict
|
|
2041
|
+
) # {"p50": 0.8, "p90": 0.9}
|
|
2042
|
+
|
|
2043
|
+
# Trend
|
|
2044
|
+
trend_direction: Literal["improving", "stable", "declining"] = "stable"
|
|
2045
|
+
trend_strength: float = 0.0 # 0.0-1.0
|
|
2046
|
+
vs_previous_period_pct: float = 0.0
|
|
2047
|
+
|
|
2048
|
+
# Data source
|
|
2049
|
+
sessions_in_period: list[str] = Field(default_factory=list)
|
|
2050
|
+
data_points_count: int = 0
|
|
2051
|
+
|
|
2052
|
+
def __init__(self, **data: Any):
|
|
2053
|
+
# Ensure type is always "aggregated-metric"
|
|
2054
|
+
data["type"] = "aggregated-metric"
|
|
2055
|
+
super().__init__(**data)
|
|
2056
|
+
|
|
2057
|
+
def to_html(self, stylesheet_path: str = "../styles.css") -> str:
|
|
2058
|
+
"""Convert aggregated metric to HTML document with metric-specific fields."""
|
|
2059
|
+
# Build metric overview HTML
|
|
2060
|
+
overview_html = f"""
|
|
2061
|
+
<section data-metric-overview>
|
|
2062
|
+
<h3>Metric Overview</h3>
|
|
2063
|
+
<dl>
|
|
2064
|
+
<dt>Metric Type</dt>
|
|
2065
|
+
<dd>{self.metric_type.replace("_", " ").title()}</dd>
|
|
2066
|
+
<dt>Scope</dt>
|
|
2067
|
+
<dd>{self.scope.title()}</dd>"""
|
|
2068
|
+
|
|
2069
|
+
if self.scope_id:
|
|
2070
|
+
overview_html += f"""
|
|
2071
|
+
<dt>Scope ID</dt>
|
|
2072
|
+
<dd>{self.scope_id}</dd>"""
|
|
2073
|
+
|
|
2074
|
+
overview_html += f"""
|
|
2075
|
+
<dt>Period</dt>
|
|
2076
|
+
<dd>{self.period.title()}</dd>"""
|
|
2077
|
+
|
|
2078
|
+
if self.period_start:
|
|
2079
|
+
overview_html += f"""
|
|
2080
|
+
<dt>Period Start</dt>
|
|
2081
|
+
<dd>{self.period_start.strftime("%Y-%m-%d %H:%M")}</dd>"""
|
|
2082
|
+
|
|
2083
|
+
if self.period_end:
|
|
2084
|
+
overview_html += f"""
|
|
2085
|
+
<dt>Period End</dt>
|
|
2086
|
+
<dd>{self.period_end.strftime("%Y-%m-%d %H:%M")}</dd>"""
|
|
2087
|
+
|
|
2088
|
+
overview_html += """
|
|
2089
|
+
</dl>
|
|
2090
|
+
</section>"""
|
|
2091
|
+
|
|
2092
|
+
# Build metric values HTML
|
|
2093
|
+
values_html = ""
|
|
2094
|
+
if self.metric_values:
|
|
2095
|
+
value_items = "\n ".join(
|
|
2096
|
+
f"<dt>{k.replace('_', ' ').title()}</dt>\n <dd>{v:.4f}</dd>"
|
|
2097
|
+
for k, v in self.metric_values.items()
|
|
2098
|
+
)
|
|
2099
|
+
values_html = f"""
|
|
2100
|
+
<section data-metric-values>
|
|
2101
|
+
<h3>Metric Values</h3>
|
|
2102
|
+
<dl>
|
|
2103
|
+
{value_items}
|
|
2104
|
+
</dl>
|
|
2105
|
+
</section>"""
|
|
2106
|
+
|
|
2107
|
+
# Build percentiles HTML
|
|
2108
|
+
percentiles_html = ""
|
|
2109
|
+
if self.percentiles:
|
|
2110
|
+
percentile_items = "\n ".join(
|
|
2111
|
+
f"<dt>{k}</dt>\n <dd>{v:.4f}</dd>"
|
|
2112
|
+
for k, v in self.percentiles.items()
|
|
2113
|
+
)
|
|
2114
|
+
percentiles_html = f"""
|
|
2115
|
+
<section data-percentiles>
|
|
2116
|
+
<h3>Percentiles</h3>
|
|
2117
|
+
<dl>
|
|
2118
|
+
{percentile_items}
|
|
2119
|
+
</dl>
|
|
2120
|
+
</section>"""
|
|
2121
|
+
|
|
2122
|
+
# Build trend HTML
|
|
2123
|
+
trend_html = f"""
|
|
2124
|
+
<section data-trend>
|
|
2125
|
+
<h3>Trend Analysis</h3>
|
|
2126
|
+
<dl>
|
|
2127
|
+
<dt>Direction</dt>
|
|
2128
|
+
<dd class="trend-{self.trend_direction}">{self.trend_direction.title()}</dd>
|
|
2129
|
+
<dt>Strength</dt>
|
|
2130
|
+
<dd>{self.trend_strength:.1%}</dd>
|
|
2131
|
+
<dt>vs Previous Period</dt>
|
|
2132
|
+
<dd class="{"positive" if self.vs_previous_period_pct > 0 else "negative"}">{self.vs_previous_period_pct:+.1f}%</dd>
|
|
2133
|
+
</dl>
|
|
2134
|
+
</section>"""
|
|
2135
|
+
|
|
2136
|
+
# Build sessions HTML
|
|
2137
|
+
sessions_html = ""
|
|
2138
|
+
if self.sessions_in_period:
|
|
2139
|
+
session_links = "\n ".join(
|
|
2140
|
+
f'<li><a href="../sessions/{sid}.html">{sid}</a></li>'
|
|
2141
|
+
for sid in self.sessions_in_period[:20] # Limit to first 20
|
|
2142
|
+
)
|
|
2143
|
+
more_sessions = ""
|
|
2144
|
+
if len(self.sessions_in_period) > 20:
|
|
2145
|
+
more_sessions = f"\n <li>... and {len(self.sessions_in_period) - 20} more</li>"
|
|
2146
|
+
|
|
2147
|
+
sessions_html = f"""
|
|
2148
|
+
<section data-sessions>
|
|
2149
|
+
<h3>Sessions in Period ({len(self.sessions_in_period)})</h3>
|
|
2150
|
+
<ul>
|
|
2151
|
+
{session_links}{more_sessions}
|
|
2152
|
+
</ul>
|
|
2153
|
+
</section>"""
|
|
2154
|
+
|
|
2155
|
+
# Build data source HTML
|
|
2156
|
+
data_source_html = f"""
|
|
2157
|
+
<section data-data-source>
|
|
2158
|
+
<h3>Data Source</h3>
|
|
2159
|
+
<dl>
|
|
2160
|
+
<dt>Data Points</dt>
|
|
2161
|
+
<dd>{self.data_points_count}</dd>
|
|
2162
|
+
<dt>Sessions Analyzed</dt>
|
|
2163
|
+
<dd>{len(self.sessions_in_period)}</dd>
|
|
2164
|
+
</dl>
|
|
2165
|
+
</section>"""
|
|
2166
|
+
|
|
2167
|
+
# Build metric-specific attributes
|
|
2168
|
+
import json
|
|
2169
|
+
|
|
2170
|
+
metric_attrs = f' data-metric-type="{self.metric_type}"'
|
|
2171
|
+
metric_attrs += f' data-scope="{self.scope}"'
|
|
2172
|
+
if self.scope_id:
|
|
2173
|
+
metric_attrs += f' data-scope-id="{self.scope_id}"'
|
|
2174
|
+
metric_attrs += f' data-period="{self.period}"'
|
|
2175
|
+
metric_attrs += f' data-trend-direction="{self.trend_direction}"'
|
|
2176
|
+
metric_attrs += f' data-trend-strength="{self.trend_strength:.2f}"'
|
|
2177
|
+
metric_attrs += f' data-data-points="{self.data_points_count}"'
|
|
2178
|
+
|
|
2179
|
+
if self.period_start:
|
|
2180
|
+
metric_attrs += f' data-period-start="{self.period_start.isoformat()}"'
|
|
2181
|
+
if self.period_end:
|
|
2182
|
+
metric_attrs += f' data-period-end="{self.period_end.isoformat()}"'
|
|
2183
|
+
|
|
2184
|
+
if self.metric_values:
|
|
2185
|
+
values_json = json.dumps(self.metric_values)
|
|
2186
|
+
metric_attrs += f" data-values='{values_json}'"
|
|
2187
|
+
|
|
2188
|
+
return f'''<!DOCTYPE html>
|
|
2189
|
+
<html lang="en">
|
|
2190
|
+
<head>
|
|
2191
|
+
<meta charset="UTF-8">
|
|
2192
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2193
|
+
<meta name="htmlgraph-version" content="1.0">
|
|
2194
|
+
<title>{self.title}</title>
|
|
2195
|
+
<link rel="stylesheet" href="{stylesheet_path}">
|
|
2196
|
+
</head>
|
|
2197
|
+
<body>
|
|
2198
|
+
<article id="{self.id}"
|
|
2199
|
+
data-type="{self.type}"
|
|
2200
|
+
data-status="{self.status}"
|
|
2201
|
+
data-priority="{self.priority}"
|
|
2202
|
+
data-created="{self.created.isoformat()}"
|
|
2203
|
+
data-updated="{self.updated.isoformat()}"{metric_attrs}>
|
|
2204
|
+
|
|
2205
|
+
<header>
|
|
2206
|
+
<h1>{self.title}</h1>
|
|
2207
|
+
<div class="metadata">
|
|
2208
|
+
<span class="badge status-{self.status}">{self.status.replace("-", " ").title()}</span>
|
|
2209
|
+
<span class="badge metric-{self.metric_type}">{self.metric_type.replace("_", " ").title()}</span>
|
|
2210
|
+
<span class="badge trend-{self.trend_direction}">{self.trend_direction.title()}</span>
|
|
2211
|
+
</div>
|
|
2212
|
+
</header>
|
|
2213
|
+
{overview_html}{values_html}{percentiles_html}{trend_html}{data_source_html}{sessions_html}
|
|
2214
|
+
</article>
|
|
2215
|
+
</body>
|
|
2216
|
+
</html>
|
|
2217
|
+
'''
|
|
2218
|
+
|
|
2219
|
+
|
|
2220
|
+
class Todo(BaseModel):
|
|
2221
|
+
"""
|
|
2222
|
+
A persistent todo item for AI agent task tracking.
|
|
2223
|
+
|
|
2224
|
+
Unlike ephemeral in-context todos (TodoWrite), this model:
|
|
2225
|
+
- Persists to `.htmlgraph/todos/` as HTML files
|
|
2226
|
+
- Links to sessions and features
|
|
2227
|
+
- Enables learning from task patterns across sessions
|
|
2228
|
+
- Provides full audit trail of agent work decomposition
|
|
2229
|
+
|
|
2230
|
+
Matches TodoWrite format with content and activeForm fields.
|
|
2231
|
+
"""
|
|
2232
|
+
|
|
2233
|
+
id: str
|
|
2234
|
+
content: str # The imperative form (e.g., "Run tests")
|
|
2235
|
+
active_form: str # The present continuous form (e.g., "Running tests")
|
|
2236
|
+
status: Literal["pending", "in_progress", "completed"] = "pending"
|
|
2237
|
+
|
|
2238
|
+
# Timestamps
|
|
2239
|
+
created: datetime = Field(default_factory=datetime.now)
|
|
2240
|
+
updated: datetime = Field(default_factory=datetime.now)
|
|
2241
|
+
started_at: datetime | None = None
|
|
2242
|
+
completed_at: datetime | None = None
|
|
2243
|
+
|
|
2244
|
+
# Context linking
|
|
2245
|
+
session_id: str | None = None # Session where this todo was created
|
|
2246
|
+
feature_id: str | None = None # Feature this todo belongs to
|
|
2247
|
+
parent_todo_id: str | None = None # For nested/sub-todos
|
|
2248
|
+
|
|
2249
|
+
# Agent tracking
|
|
2250
|
+
agent: str | None = None # Agent that created this todo
|
|
2251
|
+
completed_by: str | None = None # Agent that completed it
|
|
2252
|
+
|
|
2253
|
+
# Metadata
|
|
2254
|
+
priority: int = 0 # Order within a list (0 = first)
|
|
2255
|
+
duration_seconds: float | None = None # How long it took to complete
|
|
2256
|
+
|
|
2257
|
+
def start(self) -> "Todo":
|
|
2258
|
+
"""Mark todo as in progress."""
|
|
2259
|
+
self.status = "in_progress"
|
|
2260
|
+
self.started_at = utc_now()
|
|
2261
|
+
self.updated = utc_now()
|
|
2262
|
+
return self
|
|
2263
|
+
|
|
2264
|
+
def complete(self, agent: str | None = None) -> "Todo":
|
|
2265
|
+
"""Mark todo as completed."""
|
|
2266
|
+
self.status = "completed"
|
|
2267
|
+
self.completed_at = utc_now()
|
|
2268
|
+
self.completed_by = agent
|
|
2269
|
+
self.updated = utc_now()
|
|
2270
|
+
|
|
2271
|
+
# Calculate duration if started
|
|
2272
|
+
if self.started_at:
|
|
2273
|
+
self.duration_seconds = (
|
|
2274
|
+
self.completed_at - self.started_at
|
|
2275
|
+
).total_seconds()
|
|
2276
|
+
|
|
2277
|
+
return self
|
|
2278
|
+
|
|
2279
|
+
def to_html(self, stylesheet_path: str = "../styles.css") -> str:
|
|
2280
|
+
"""Convert todo to HTML document."""
|
|
2281
|
+
# Status emoji
|
|
2282
|
+
status_emoji = {
|
|
2283
|
+
"pending": "⏳",
|
|
2284
|
+
"in_progress": "🔄",
|
|
2285
|
+
"completed": "✅",
|
|
2286
|
+
}.get(self.status, "⏳")
|
|
2287
|
+
|
|
2288
|
+
# Build attributes
|
|
2289
|
+
# Escape quotes in content for HTML attributes
|
|
2290
|
+
escaped_content = self.content.replace('"', """)
|
|
2291
|
+
escaped_active_form = self.active_form.replace('"', """)
|
|
2292
|
+
|
|
2293
|
+
attrs = [
|
|
2294
|
+
f'data-status="{self.status}"',
|
|
2295
|
+
f'data-priority="{self.priority}"',
|
|
2296
|
+
f'data-created="{self.created.isoformat()}"',
|
|
2297
|
+
f'data-updated="{self.updated.isoformat()}"',
|
|
2298
|
+
f'data-todo-content="{escaped_content}"',
|
|
2299
|
+
f'data-todo-active-form="{escaped_active_form}"',
|
|
2300
|
+
]
|
|
2301
|
+
|
|
2302
|
+
if self.session_id:
|
|
2303
|
+
attrs.append(f'data-session-id="{self.session_id}"')
|
|
2304
|
+
if self.feature_id:
|
|
2305
|
+
attrs.append(f'data-feature-id="{self.feature_id}"')
|
|
2306
|
+
if self.parent_todo_id:
|
|
2307
|
+
attrs.append(f'data-parent-todo-id="{self.parent_todo_id}"')
|
|
2308
|
+
if self.agent:
|
|
2309
|
+
attrs.append(f'data-agent="{self.agent}"')
|
|
2310
|
+
if self.started_at:
|
|
2311
|
+
attrs.append(f'data-started-at="{self.started_at.isoformat()}"')
|
|
2312
|
+
if self.completed_at:
|
|
2313
|
+
attrs.append(f'data-completed-at="{self.completed_at.isoformat()}"')
|
|
2314
|
+
if self.completed_by:
|
|
2315
|
+
attrs.append(f'data-completed-by="{self.completed_by}"')
|
|
2316
|
+
if self.duration_seconds is not None:
|
|
2317
|
+
attrs.append(f'data-duration="{self.duration_seconds:.1f}"')
|
|
2318
|
+
|
|
2319
|
+
attrs_str = " ".join(attrs)
|
|
2320
|
+
|
|
2321
|
+
# Build links section
|
|
2322
|
+
links_html = ""
|
|
2323
|
+
if self.session_id or self.feature_id or self.parent_todo_id:
|
|
2324
|
+
links_section = """
|
|
2325
|
+
<section data-links>
|
|
2326
|
+
<h3>Related</h3>
|
|
2327
|
+
<ul>"""
|
|
2328
|
+
if self.session_id:
|
|
2329
|
+
links_section += f'\n <li><a href="../sessions/{self.session_id}.html">Session: {self.session_id}</a></li>'
|
|
2330
|
+
if self.feature_id:
|
|
2331
|
+
links_section += f'\n <li><a href="../features/{self.feature_id}.html">Feature: {self.feature_id}</a></li>'
|
|
2332
|
+
if self.parent_todo_id:
|
|
2333
|
+
links_section += f'\n <li><a href="{self.parent_todo_id}.html">Parent: {self.parent_todo_id}</a></li>'
|
|
2334
|
+
links_section += """
|
|
2335
|
+
</ul>
|
|
2336
|
+
</section>"""
|
|
2337
|
+
links_html = links_section
|
|
2338
|
+
|
|
2339
|
+
return f'''<!DOCTYPE html>
|
|
2340
|
+
<html lang="en">
|
|
2341
|
+
<head>
|
|
2342
|
+
<meta charset="UTF-8">
|
|
2343
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2344
|
+
<meta name="htmlgraph-version" content="1.0">
|
|
2345
|
+
<title>{status_emoji} {self.content}</title>
|
|
2346
|
+
<link rel="stylesheet" href="{stylesheet_path}">
|
|
2347
|
+
</head>
|
|
2348
|
+
<body>
|
|
2349
|
+
<article id="{self.id}"
|
|
2350
|
+
data-type="todo"
|
|
2351
|
+
{attrs_str}>
|
|
2352
|
+
|
|
2353
|
+
<header>
|
|
2354
|
+
<h1>{status_emoji} {self.content}</h1>
|
|
2355
|
+
<div class="metadata">
|
|
2356
|
+
<span class="badge status-{self.status}">{self.status.replace("_", " ").title()}</span>
|
|
2357
|
+
</div>
|
|
2358
|
+
</header>
|
|
2359
|
+
|
|
2360
|
+
<section data-content>
|
|
2361
|
+
<h3>Task</h3>
|
|
2362
|
+
<p><strong>Content:</strong> {self.content}</p>
|
|
2363
|
+
<p><strong>Active Form:</strong> {self.active_form}</p>
|
|
2364
|
+
</section>
|
|
2365
|
+
{links_html}
|
|
2366
|
+
</article>
|
|
2367
|
+
</body>
|
|
2368
|
+
</html>
|
|
2369
|
+
'''
|
|
2370
|
+
|
|
2371
|
+
def to_context(self) -> str:
|
|
2372
|
+
"""Lightweight context for AI agents."""
|
|
2373
|
+
status_marker = {
|
|
2374
|
+
"pending": "[ ]",
|
|
2375
|
+
"in_progress": "[~]",
|
|
2376
|
+
"completed": "[x]",
|
|
2377
|
+
}.get(self.status, "[ ]")
|
|
2378
|
+
|
|
2379
|
+
return f"{status_marker} {self.content}"
|
|
2380
|
+
|
|
2381
|
+
def to_todowrite_format(self) -> dict[str, str]:
|
|
2382
|
+
"""Convert to TodoWrite format for compatibility."""
|
|
2383
|
+
return {
|
|
2384
|
+
"content": self.content,
|
|
2385
|
+
"status": self.status,
|
|
2386
|
+
"activeForm": self.active_form,
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
@classmethod
|
|
2390
|
+
def from_todowrite(
|
|
2391
|
+
cls,
|
|
2392
|
+
todo_dict: dict[str, str],
|
|
2393
|
+
todo_id: str,
|
|
2394
|
+
session_id: str | None = None,
|
|
2395
|
+
feature_id: str | None = None,
|
|
2396
|
+
agent: str | None = None,
|
|
2397
|
+
priority: int = 0,
|
|
2398
|
+
) -> "Todo":
|
|
2399
|
+
"""
|
|
2400
|
+
Create a Todo from TodoWrite format.
|
|
2401
|
+
|
|
2402
|
+
Args:
|
|
2403
|
+
todo_dict: Dict with 'content', 'status', 'activeForm' keys
|
|
2404
|
+
todo_id: Unique ID for this todo
|
|
2405
|
+
session_id: Current session ID
|
|
2406
|
+
feature_id: Feature this todo belongs to
|
|
2407
|
+
agent: Agent creating this todo
|
|
2408
|
+
priority: Order in the list
|
|
2409
|
+
|
|
2410
|
+
Returns:
|
|
2411
|
+
Todo instance
|
|
2412
|
+
"""
|
|
2413
|
+
status = todo_dict.get("status", "pending")
|
|
2414
|
+
if status not in ("pending", "in_progress", "completed"):
|
|
2415
|
+
status = "pending"
|
|
2416
|
+
|
|
2417
|
+
return cls(
|
|
2418
|
+
id=todo_id,
|
|
2419
|
+
content=todo_dict.get("content", ""),
|
|
2420
|
+
active_form=todo_dict.get("activeForm", todo_dict.get("content", "")),
|
|
2421
|
+
status=status, # type: ignore
|
|
2422
|
+
session_id=session_id,
|
|
2423
|
+
feature_id=feature_id,
|
|
2424
|
+
agent=agent,
|
|
2425
|
+
priority=priority,
|
|
2426
|
+
)
|