htmlgraph 0.26.5__py3-none-any.whl → 0.26.7__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 +1 -1
- htmlgraph/__init__.py +1 -1
- htmlgraph/api/main.py +50 -10
- htmlgraph/api/templates/dashboard-redesign.html +608 -54
- htmlgraph/api/templates/partials/activity-feed.html +21 -0
- htmlgraph/api/templates/partials/features.html +81 -12
- htmlgraph/api/templates/partials/orchestration.html +35 -0
- htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/cli/.htmlgraph/agents.json +72 -0
- htmlgraph/cli/__init__.py +42 -0
- htmlgraph/cli/__main__.py +6 -0
- htmlgraph/cli/analytics.py +939 -0
- htmlgraph/cli/base.py +660 -0
- htmlgraph/cli/constants.py +206 -0
- htmlgraph/cli/core.py +856 -0
- htmlgraph/cli/main.py +143 -0
- htmlgraph/cli/models.py +462 -0
- htmlgraph/cli/templates/__init__.py +1 -0
- htmlgraph/cli/templates/cost_dashboard.py +398 -0
- htmlgraph/cli/work/__init__.py +159 -0
- htmlgraph/cli/work/features.py +567 -0
- htmlgraph/cli/work/orchestration.py +675 -0
- htmlgraph/cli/work/sessions.py +465 -0
- htmlgraph/cli/work/tracks.py +485 -0
- htmlgraph/dashboard.html +6414 -634
- htmlgraph/db/schema.py +8 -3
- htmlgraph/docs/ORCHESTRATION_PATTERNS.md +20 -13
- htmlgraph/docs/README.md +2 -3
- htmlgraph/hooks/event_tracker.py +355 -26
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/orchestrator.py +137 -71
- htmlgraph/hooks/orchestrator_reflector.py +23 -0
- htmlgraph/hooks/pretooluse.py +29 -6
- htmlgraph/hooks/session_handler.py +28 -0
- htmlgraph/hooks/session_summary.py +391 -0
- htmlgraph/hooks/subagent_detection.py +202 -0
- htmlgraph/hooks/subagent_stop.py +71 -12
- htmlgraph/hooks/validator.py +192 -79
- htmlgraph/operations/__init__.py +18 -0
- htmlgraph/operations/initialization.py +596 -0
- htmlgraph/operations/initialization.py.backup +228 -0
- htmlgraph/orchestration/__init__.py +16 -1
- htmlgraph/orchestration/claude_launcher.py +185 -0
- htmlgraph/orchestration/command_builder.py +71 -0
- htmlgraph/orchestration/headless_spawner.py +72 -1332
- htmlgraph/orchestration/plugin_manager.py +136 -0
- htmlgraph/orchestration/prompts.py +137 -0
- htmlgraph/orchestration/spawners/__init__.py +16 -0
- htmlgraph/orchestration/spawners/base.py +194 -0
- htmlgraph/orchestration/spawners/claude.py +170 -0
- htmlgraph/orchestration/spawners/codex.py +442 -0
- htmlgraph/orchestration/spawners/copilot.py +299 -0
- htmlgraph/orchestration/spawners/gemini.py +478 -0
- htmlgraph/orchestration/subprocess_runner.py +33 -0
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +620 -55
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +45 -12
- htmlgraph/transcript.py +16 -4
- htmlgraph-0.26.7.data/data/htmlgraph/dashboard.html +6592 -0
- {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/METADATA +1 -1
- {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/RECORD +68 -34
- {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -7256
- htmlgraph-0.26.5.data/data/htmlgraph/dashboard.html +0 -812
- {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Orchestrator Configuration Management
|
|
3
|
+
|
|
4
|
+
Provides configurable thresholds for delegation enforcement instead of hardcoded values.
|
|
5
|
+
Supports:
|
|
6
|
+
- Threshold configuration (exploration, circuit breaker)
|
|
7
|
+
- Time-based violation decay
|
|
8
|
+
- Rapid sequence collapsing
|
|
9
|
+
- CLI commands to view/edit config
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml # type: ignore[import-untyped]
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ThresholdsConfig(BaseModel):
|
|
21
|
+
"""Threshold configuration for orchestrator enforcement."""
|
|
22
|
+
|
|
23
|
+
exploration_calls: int = 5
|
|
24
|
+
"""How many consecutive Grep/Read/Glob calls before warning."""
|
|
25
|
+
|
|
26
|
+
circuit_breaker_violations: int = 3
|
|
27
|
+
"""How many violations before blocking all operations."""
|
|
28
|
+
|
|
29
|
+
violation_decay_seconds: int = 120
|
|
30
|
+
"""How old violations can be before they don't count (seconds)."""
|
|
31
|
+
|
|
32
|
+
rapid_sequence_window: int = 0
|
|
33
|
+
"""Time window for collapsing rapid violations (seconds). 0 = disabled."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AntiPatternsConfig(BaseModel):
|
|
37
|
+
"""Anti-pattern detection thresholds."""
|
|
38
|
+
|
|
39
|
+
consecutive_bash: int = 5
|
|
40
|
+
consecutive_edit: int = 4
|
|
41
|
+
consecutive_grep: int = 4
|
|
42
|
+
consecutive_read: int = 5
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ModeConfig(BaseModel):
|
|
46
|
+
"""Configuration for an enforcement mode."""
|
|
47
|
+
|
|
48
|
+
block_after_violations: bool = True
|
|
49
|
+
require_work_items: bool = True
|
|
50
|
+
warn_on_patterns: bool = True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ModesConfig(BaseModel):
|
|
54
|
+
"""All enforcement mode configurations."""
|
|
55
|
+
|
|
56
|
+
strict: ModeConfig = ModeConfig(
|
|
57
|
+
block_after_violations=True,
|
|
58
|
+
require_work_items=True,
|
|
59
|
+
warn_on_patterns=True,
|
|
60
|
+
)
|
|
61
|
+
moderate: ModeConfig = ModeConfig(
|
|
62
|
+
block_after_violations=False,
|
|
63
|
+
require_work_items=False,
|
|
64
|
+
warn_on_patterns=True,
|
|
65
|
+
)
|
|
66
|
+
guidance: ModeConfig = ModeConfig(
|
|
67
|
+
block_after_violations=False,
|
|
68
|
+
require_work_items=False,
|
|
69
|
+
warn_on_patterns=False,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class OrchestratorConfig(BaseModel):
|
|
74
|
+
"""Complete orchestrator configuration."""
|
|
75
|
+
|
|
76
|
+
thresholds: ThresholdsConfig = ThresholdsConfig()
|
|
77
|
+
anti_patterns: AntiPatternsConfig = AntiPatternsConfig()
|
|
78
|
+
modes: ModesConfig = ModesConfig()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_config_paths() -> list[Path]:
|
|
82
|
+
"""
|
|
83
|
+
Get list of config file paths to check (in priority order).
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
List of paths to check for config file
|
|
87
|
+
"""
|
|
88
|
+
return [
|
|
89
|
+
Path.cwd() / ".htmlgraph" / "orchestrator-config.yaml",
|
|
90
|
+
Path.home() / ".config" / "htmlgraph" / "orchestrator-config.yaml",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_orchestrator_config() -> OrchestratorConfig:
|
|
95
|
+
"""
|
|
96
|
+
Load orchestrator configuration from file or use defaults.
|
|
97
|
+
|
|
98
|
+
Checks multiple locations:
|
|
99
|
+
1. .htmlgraph/orchestrator-config.yaml (project-specific)
|
|
100
|
+
2. ~/.config/htmlgraph/orchestrator-config.yaml (user defaults)
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
OrchestratorConfig with loaded or default values
|
|
104
|
+
"""
|
|
105
|
+
for config_path in get_config_paths():
|
|
106
|
+
if config_path.exists():
|
|
107
|
+
try:
|
|
108
|
+
with open(config_path) as f:
|
|
109
|
+
data = yaml.safe_load(f)
|
|
110
|
+
if data:
|
|
111
|
+
return OrchestratorConfig(**data)
|
|
112
|
+
except Exception:
|
|
113
|
+
# If file is corrupted, continue to next location
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
# No valid config found, return defaults
|
|
117
|
+
return OrchestratorConfig()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def save_orchestrator_config(
|
|
121
|
+
config: OrchestratorConfig, path: Path | None = None
|
|
122
|
+
) -> None:
|
|
123
|
+
"""
|
|
124
|
+
Save orchestrator configuration to file.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
config: Configuration to save
|
|
128
|
+
path: Optional path to save to. If None, uses first config path.
|
|
129
|
+
"""
|
|
130
|
+
if path is None:
|
|
131
|
+
path = get_config_paths()[0]
|
|
132
|
+
|
|
133
|
+
# Ensure directory exists
|
|
134
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
|
|
136
|
+
# Convert to dict for YAML serialization
|
|
137
|
+
data = config.model_dump()
|
|
138
|
+
|
|
139
|
+
# Write YAML with comments
|
|
140
|
+
with open(path, "w") as f:
|
|
141
|
+
f.write("# HtmlGraph Orchestrator Configuration\n")
|
|
142
|
+
f.write("# Controls delegation enforcement behavior\n\n")
|
|
143
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def filter_recent_violations(
|
|
147
|
+
violations: list[dict[str, Any]], decay_seconds: int
|
|
148
|
+
) -> list[dict[str, Any]]:
|
|
149
|
+
"""
|
|
150
|
+
Filter violations to only include recent ones within decay window.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
violations: List of violation dicts with 'timestamp' field
|
|
154
|
+
decay_seconds: How old violations can be (in seconds)
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Filtered list of recent violations only
|
|
158
|
+
"""
|
|
159
|
+
cutoff = datetime.now(timezone.utc) - timedelta(seconds=decay_seconds)
|
|
160
|
+
|
|
161
|
+
recent = []
|
|
162
|
+
for v in violations:
|
|
163
|
+
try:
|
|
164
|
+
# Parse timestamp (handle both ISO format and timestamp float)
|
|
165
|
+
ts = v.get("timestamp")
|
|
166
|
+
if isinstance(ts, str):
|
|
167
|
+
violation_time = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
168
|
+
elif isinstance(ts, (int, float)):
|
|
169
|
+
violation_time = datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
170
|
+
else:
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
if violation_time > cutoff:
|
|
174
|
+
recent.append(v)
|
|
175
|
+
except Exception:
|
|
176
|
+
# Skip violations with invalid timestamps
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
return recent
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def collapse_rapid_sequences(
|
|
183
|
+
violations: list[dict[str, Any]], window_seconds: int
|
|
184
|
+
) -> list[dict[str, Any]]:
|
|
185
|
+
"""
|
|
186
|
+
Collapse violations within rapid sequence window to one.
|
|
187
|
+
|
|
188
|
+
This prevents "violation spam" when user makes multiple rapid mistakes.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
violations: List of violation dicts with 'timestamp' field
|
|
192
|
+
window_seconds: Time window for collapsing (seconds)
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Collapsed list where rapid sequences count as one
|
|
196
|
+
"""
|
|
197
|
+
if not violations:
|
|
198
|
+
return []
|
|
199
|
+
|
|
200
|
+
collapsed = [violations[0]]
|
|
201
|
+
|
|
202
|
+
for v in violations[1:]:
|
|
203
|
+
try:
|
|
204
|
+
# Get timestamps
|
|
205
|
+
last_ts = collapsed[-1].get("timestamp")
|
|
206
|
+
curr_ts = v.get("timestamp")
|
|
207
|
+
|
|
208
|
+
# Parse timestamps
|
|
209
|
+
if isinstance(last_ts, str):
|
|
210
|
+
last_time = datetime.fromisoformat(last_ts.replace("Z", "+00:00"))
|
|
211
|
+
elif isinstance(last_ts, (int, float)):
|
|
212
|
+
last_time = datetime.fromtimestamp(last_ts, tz=timezone.utc)
|
|
213
|
+
else:
|
|
214
|
+
collapsed.append(v)
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
if isinstance(curr_ts, str):
|
|
218
|
+
curr_time = datetime.fromisoformat(curr_ts.replace("Z", "+00:00"))
|
|
219
|
+
elif isinstance(curr_ts, (int, float)):
|
|
220
|
+
curr_time = datetime.fromtimestamp(curr_ts, tz=timezone.utc)
|
|
221
|
+
else:
|
|
222
|
+
collapsed.append(v)
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
# Only add if outside rapid sequence window
|
|
226
|
+
if (curr_time - last_time).total_seconds() > window_seconds:
|
|
227
|
+
collapsed.append(v)
|
|
228
|
+
except Exception:
|
|
229
|
+
# On error, include the violation
|
|
230
|
+
collapsed.append(v)
|
|
231
|
+
|
|
232
|
+
return collapsed
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def get_effective_violation_count(
|
|
236
|
+
violations: list[dict[str, Any]], config: OrchestratorConfig
|
|
237
|
+
) -> int:
|
|
238
|
+
"""
|
|
239
|
+
Get effective violation count after applying decay and collapsing.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
violations: Raw list of all violations
|
|
243
|
+
config: Configuration with thresholds
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Effective violation count (after decay and collapsing)
|
|
247
|
+
"""
|
|
248
|
+
# Apply time-based decay
|
|
249
|
+
recent = filter_recent_violations(
|
|
250
|
+
violations, config.thresholds.violation_decay_seconds
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Collapse rapid sequences
|
|
254
|
+
collapsed = collapse_rapid_sequences(
|
|
255
|
+
recent, config.thresholds.rapid_sequence_window
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return len(collapsed)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def get_config_value(config: OrchestratorConfig, key_path: str) -> Any:
|
|
262
|
+
"""
|
|
263
|
+
Get a config value by dot-separated path.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
config: Configuration object
|
|
267
|
+
key_path: Dot-separated path (e.g., "thresholds.exploration_calls")
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Value at that path
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
KeyError: If path doesn't exist
|
|
274
|
+
"""
|
|
275
|
+
parts = key_path.split(".")
|
|
276
|
+
value: Any = config
|
|
277
|
+
|
|
278
|
+
for part in parts:
|
|
279
|
+
if hasattr(value, part):
|
|
280
|
+
value = getattr(value, part)
|
|
281
|
+
else:
|
|
282
|
+
raise KeyError(f"Config path not found: {key_path}")
|
|
283
|
+
|
|
284
|
+
return value
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def set_config_value(config: OrchestratorConfig, key_path: str, value: Any) -> None:
|
|
288
|
+
"""
|
|
289
|
+
Set a config value by dot-separated path.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
config: Configuration object to modify
|
|
293
|
+
key_path: Dot-separated path (e.g., "thresholds.exploration_calls")
|
|
294
|
+
value: Value to set
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
KeyError: If path doesn't exist
|
|
298
|
+
"""
|
|
299
|
+
parts = key_path.split(".")
|
|
300
|
+
obj: Any = config
|
|
301
|
+
|
|
302
|
+
# Navigate to parent object
|
|
303
|
+
for part in parts[:-1]:
|
|
304
|
+
if hasattr(obj, part):
|
|
305
|
+
obj = getattr(obj, part)
|
|
306
|
+
else:
|
|
307
|
+
raise KeyError(f"Config path not found: {key_path}")
|
|
308
|
+
|
|
309
|
+
# Set the final attribute
|
|
310
|
+
final_key = parts[-1]
|
|
311
|
+
if hasattr(obj, final_key):
|
|
312
|
+
setattr(obj, final_key, value)
|
|
313
|
+
else:
|
|
314
|
+
raise KeyError(f"Config path not found: {key_path}")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def format_config_display(config: OrchestratorConfig) -> str:
|
|
318
|
+
"""
|
|
319
|
+
Format configuration for human-readable display.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
config: Configuration to format
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
Formatted string representation
|
|
326
|
+
"""
|
|
327
|
+
lines = [
|
|
328
|
+
"HtmlGraph Orchestrator Configuration",
|
|
329
|
+
"=" * 50,
|
|
330
|
+
"",
|
|
331
|
+
"Thresholds:",
|
|
332
|
+
f" exploration_calls: {config.thresholds.exploration_calls}",
|
|
333
|
+
f" circuit_breaker_violations: {config.thresholds.circuit_breaker_violations}",
|
|
334
|
+
f" violation_decay_seconds: {config.thresholds.violation_decay_seconds}",
|
|
335
|
+
f" rapid_sequence_window: {config.thresholds.rapid_sequence_window}",
|
|
336
|
+
"",
|
|
337
|
+
"Anti-patterns:",
|
|
338
|
+
f" consecutive_bash: {config.anti_patterns.consecutive_bash}",
|
|
339
|
+
f" consecutive_edit: {config.anti_patterns.consecutive_edit}",
|
|
340
|
+
f" consecutive_grep: {config.anti_patterns.consecutive_grep}",
|
|
341
|
+
f" consecutive_read: {config.anti_patterns.consecutive_read}",
|
|
342
|
+
"",
|
|
343
|
+
"Modes:",
|
|
344
|
+
" strict:",
|
|
345
|
+
f" block_after_violations: {config.modes.strict.block_after_violations}",
|
|
346
|
+
f" require_work_items: {config.modes.strict.require_work_items}",
|
|
347
|
+
f" warn_on_patterns: {config.modes.strict.warn_on_patterns}",
|
|
348
|
+
" moderate:",
|
|
349
|
+
f" block_after_violations: {config.modes.moderate.block_after_violations}",
|
|
350
|
+
f" require_work_items: {config.modes.moderate.require_work_items}",
|
|
351
|
+
f" warn_on_patterns: {config.modes.moderate.warn_on_patterns}",
|
|
352
|
+
" guidance:",
|
|
353
|
+
f" block_after_violations: {config.modes.guidance.block_after_violations}",
|
|
354
|
+
f" require_work_items: {config.modes.guidance.require_work_items}",
|
|
355
|
+
f" warn_on_patterns: {config.modes.guidance.warn_on_patterns}",
|
|
356
|
+
]
|
|
357
|
+
return "\n".join(lines)
|
htmlgraph/orchestrator_mode.py
CHANGED
|
@@ -8,10 +8,15 @@ State is persisted in .htmlgraph/orchestrator-mode.json
|
|
|
8
8
|
import json
|
|
9
9
|
from datetime import datetime, timezone
|
|
10
10
|
from pathlib import Path
|
|
11
|
-
from typing import Literal
|
|
11
|
+
from typing import Any, Literal
|
|
12
12
|
|
|
13
13
|
from pydantic import BaseModel
|
|
14
14
|
|
|
15
|
+
from htmlgraph.orchestrator_config import (
|
|
16
|
+
get_effective_violation_count,
|
|
17
|
+
load_orchestrator_config,
|
|
18
|
+
)
|
|
19
|
+
|
|
15
20
|
|
|
16
21
|
class OrchestratorMode(BaseModel):
|
|
17
22
|
"""Orchestrator mode state."""
|
|
@@ -41,9 +46,12 @@ class OrchestratorMode(BaseModel):
|
|
|
41
46
|
"""Timestamp of most recent violation."""
|
|
42
47
|
|
|
43
48
|
circuit_breaker_triggered: bool = False
|
|
44
|
-
"""Whether circuit breaker has been triggered (
|
|
49
|
+
"""Whether circuit breaker has been triggered (N+ violations, configurable)."""
|
|
50
|
+
|
|
51
|
+
violation_history: list[dict[str, Any]] = []
|
|
52
|
+
"""Full history of violations with timestamps for time-based decay."""
|
|
45
53
|
|
|
46
|
-
def to_dict(self) -> dict:
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
55
|
"""Convert to dict for JSON serialization."""
|
|
48
56
|
return {
|
|
49
57
|
"enabled": self.enabled,
|
|
@@ -59,10 +67,11 @@ class OrchestratorMode(BaseModel):
|
|
|
59
67
|
self.last_violation_at.isoformat() if self.last_violation_at else None
|
|
60
68
|
),
|
|
61
69
|
"circuit_breaker_triggered": self.circuit_breaker_triggered,
|
|
70
|
+
"violation_history": self.violation_history,
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
@classmethod
|
|
65
|
-
def from_dict(cls, data: dict) -> "OrchestratorMode":
|
|
74
|
+
def from_dict(cls, data: dict[str, Any]) -> "OrchestratorMode":
|
|
66
75
|
"""Create from dict loaded from JSON."""
|
|
67
76
|
activated_at = data.get("activated_at")
|
|
68
77
|
if activated_at:
|
|
@@ -88,6 +97,7 @@ class OrchestratorMode(BaseModel):
|
|
|
88
97
|
violations=data.get("violations", 0),
|
|
89
98
|
last_violation_at=last_violation_at,
|
|
90
99
|
circuit_breaker_triggered=data.get("circuit_breaker_triggered", False),
|
|
100
|
+
violation_history=data.get("violation_history", []),
|
|
91
101
|
)
|
|
92
102
|
|
|
93
103
|
|
|
@@ -220,7 +230,7 @@ class OrchestratorModeManager:
|
|
|
220
230
|
mode = self.load()
|
|
221
231
|
return not mode.disabled_by_user
|
|
222
232
|
|
|
223
|
-
def status(self) -> dict:
|
|
233
|
+
def status(self) -> dict[str, Any]:
|
|
224
234
|
"""
|
|
225
235
|
Get human-readable status.
|
|
226
236
|
|
|
@@ -242,19 +252,38 @@ class OrchestratorModeManager:
|
|
|
242
252
|
"circuit_breaker_triggered": mode.circuit_breaker_triggered,
|
|
243
253
|
}
|
|
244
254
|
|
|
245
|
-
def increment_violation(self) -> OrchestratorMode:
|
|
255
|
+
def increment_violation(self, tool: str | None = None) -> OrchestratorMode:
|
|
246
256
|
"""
|
|
247
257
|
Increment violation counter and update timestamp.
|
|
248
258
|
|
|
259
|
+
Uses configurable thresholds and time-based decay.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
tool: Optional tool name that caused violation
|
|
263
|
+
|
|
249
264
|
Returns:
|
|
250
265
|
Updated OrchestratorMode with incremented violations
|
|
251
266
|
"""
|
|
252
267
|
mode = self.load()
|
|
253
|
-
|
|
268
|
+
config = load_orchestrator_config()
|
|
269
|
+
|
|
270
|
+
# Add to violation history with timestamp
|
|
271
|
+
violation = {
|
|
272
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
273
|
+
"tool": tool,
|
|
274
|
+
}
|
|
275
|
+
mode.violation_history.append(violation)
|
|
276
|
+
|
|
277
|
+
# Calculate effective violation count with decay and collapsing
|
|
278
|
+
effective_count = get_effective_violation_count(mode.violation_history, config)
|
|
279
|
+
|
|
280
|
+
# Update counters
|
|
281
|
+
mode.violations = effective_count
|
|
254
282
|
mode.last_violation_at = datetime.now(timezone.utc)
|
|
255
283
|
|
|
256
|
-
# Trigger circuit breaker if threshold reached
|
|
257
|
-
|
|
284
|
+
# Trigger circuit breaker if threshold reached (configurable)
|
|
285
|
+
threshold = config.thresholds.circuit_breaker_violations
|
|
286
|
+
if effective_count >= threshold:
|
|
258
287
|
mode.circuit_breaker_triggered = True
|
|
259
288
|
|
|
260
289
|
self.save(mode)
|
|
@@ -271,6 +300,7 @@ class OrchestratorModeManager:
|
|
|
271
300
|
mode.violations = 0
|
|
272
301
|
mode.last_violation_at = None
|
|
273
302
|
mode.circuit_breaker_triggered = False
|
|
303
|
+
mode.violation_history = []
|
|
274
304
|
self.save(mode)
|
|
275
305
|
return mode
|
|
276
306
|
|
|
@@ -286,10 +316,13 @@ class OrchestratorModeManager:
|
|
|
286
316
|
|
|
287
317
|
def get_violation_count(self) -> int:
|
|
288
318
|
"""
|
|
289
|
-
Get current violation count.
|
|
319
|
+
Get current violation count (with time-based decay applied).
|
|
290
320
|
|
|
291
321
|
Returns:
|
|
292
|
-
|
|
322
|
+
Effective number of violations in current session
|
|
293
323
|
"""
|
|
294
324
|
mode = self.load()
|
|
295
|
-
|
|
325
|
+
config = load_orchestrator_config()
|
|
326
|
+
|
|
327
|
+
# Return effective count with decay and collapsing
|
|
328
|
+
return get_effective_violation_count(mode.violation_history, config)
|
htmlgraph/transcript.py
CHANGED
|
@@ -588,14 +588,26 @@ class TranscriptReader:
|
|
|
588
588
|
Returns:
|
|
589
589
|
List of TranscriptSession objects, newest first
|
|
590
590
|
"""
|
|
591
|
+
from datetime import timezone
|
|
592
|
+
|
|
593
|
+
def normalize_dt(dt: datetime | None) -> datetime:
|
|
594
|
+
"""Normalize datetime to UTC for comparison."""
|
|
595
|
+
if dt is None:
|
|
596
|
+
return datetime.min.replace(tzinfo=timezone.utc)
|
|
597
|
+
if dt.tzinfo is None:
|
|
598
|
+
# Assume naive datetimes are UTC
|
|
599
|
+
return dt.replace(tzinfo=timezone.utc)
|
|
600
|
+
return dt.astimezone(timezone.utc)
|
|
601
|
+
|
|
591
602
|
sessions: list[TranscriptSession] = []
|
|
592
603
|
|
|
593
604
|
for path in self.list_transcript_files(project_path):
|
|
594
605
|
session = self.read_transcript(path)
|
|
595
606
|
|
|
596
607
|
# Filter by time
|
|
597
|
-
if since and session.started_at
|
|
598
|
-
|
|
608
|
+
if since and session.started_at:
|
|
609
|
+
if normalize_dt(session.started_at) < normalize_dt(since):
|
|
610
|
+
continue
|
|
599
611
|
|
|
600
612
|
sessions.append(session)
|
|
601
613
|
|
|
@@ -604,8 +616,8 @@ class TranscriptReader:
|
|
|
604
616
|
if deduplicate and sessions:
|
|
605
617
|
sessions = self._deduplicate_context_snapshots(sessions)
|
|
606
618
|
|
|
607
|
-
# Sort by start time, newest first
|
|
608
|
-
sessions.sort(key=lambda s: s.started_at
|
|
619
|
+
# Sort by start time, newest first (normalize for comparison)
|
|
620
|
+
sessions.sort(key=lambda s: normalize_dt(s.started_at), reverse=True)
|
|
609
621
|
|
|
610
622
|
if limit:
|
|
611
623
|
sessions = sessions[:limit]
|