htmlgraph 0.23.5__py3-none-any.whl → 0.24.0__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/__init__.py +5 -1
- htmlgraph/cigs/__init__.py +77 -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/tracker.py +317 -0
- htmlgraph/cli.py +325 -11
- htmlgraph/hooks/cigs_pretool_enforcer.py +350 -0
- htmlgraph/hooks/posttooluse.py +50 -2
- htmlgraph/hooks/task_enforcer.py +60 -4
- htmlgraph/models.py +14 -1
- htmlgraph/orchestration/headless_spawner.py +519 -21
- htmlgraph/orchestrator-system-prompt-optimized.txt +259 -53
- htmlgraph/reflection.py +442 -0
- htmlgraph/sdk.py +26 -9
- {htmlgraph-0.23.5.dist-info → htmlgraph-0.24.0.dist-info}/METADATA +2 -1
- {htmlgraph-0.23.5.dist-info → htmlgraph-0.24.0.dist-info}/RECORD +29 -17
- {htmlgraph-0.23.5.data → htmlgraph-0.24.0.data}/data/htmlgraph/dashboard.html +0 -0
- {htmlgraph-0.23.5.data → htmlgraph-0.24.0.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.23.5.data → htmlgraph-0.24.0.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.23.5.data → htmlgraph-0.24.0.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.23.5.data → htmlgraph-0.24.0.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.23.5.dist-info → htmlgraph-0.24.0.dist-info}/WHEEL +0 -0
- {htmlgraph-0.23.5.dist-info → htmlgraph-0.24.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CIGSPostToolAnalyzer for PostToolUse Hook Integration
|
|
3
|
+
|
|
4
|
+
Provides cost accounting and reinforcement messages for PostToolUse hook.
|
|
5
|
+
Integrates with ViolationTracker and CostCalculator to analyze actual costs
|
|
6
|
+
and generate appropriate feedback (positive reinforcement or cost accounting).
|
|
7
|
+
|
|
8
|
+
Design Reference:
|
|
9
|
+
- CIGS Design Doc: .htmlgraph/spikes/computational-imperative-guidance-system-design.md
|
|
10
|
+
- Part 2.4: PostToolUse Hook Enhancement
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from htmlgraph.cigs.cost import CostCalculator
|
|
17
|
+
from htmlgraph.cigs.messaging import PositiveReinforcementGenerator
|
|
18
|
+
from htmlgraph.cigs.models import TokenCost
|
|
19
|
+
from htmlgraph.cigs.tracker import ViolationTracker
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CIGSPostToolAnalyzer:
|
|
23
|
+
"""
|
|
24
|
+
Analyze tool execution results and provide cost-aware feedback.
|
|
25
|
+
|
|
26
|
+
Integrates with PostToolUse hook to:
|
|
27
|
+
1. Calculate actual cost using CostCalculator
|
|
28
|
+
2. Load prediction from ViolationTracker (if violation was recorded)
|
|
29
|
+
3. Determine if operation was delegation (Task, spawn_*)
|
|
30
|
+
4. Generate positive reinforcement for delegations
|
|
31
|
+
5. Generate cost accounting for direct executions
|
|
32
|
+
6. Update violation tracker with actual costs
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, graph_dir: Path | None = None):
|
|
36
|
+
"""
|
|
37
|
+
Initialize CIGSPostToolAnalyzer.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
graph_dir: Root directory for HtmlGraph (defaults to .htmlgraph)
|
|
41
|
+
"""
|
|
42
|
+
if graph_dir is None:
|
|
43
|
+
graph_dir = Path.cwd() / ".htmlgraph"
|
|
44
|
+
|
|
45
|
+
self.graph_dir = Path(graph_dir)
|
|
46
|
+
self.cost_calculator = CostCalculator()
|
|
47
|
+
self.tracker = ViolationTracker(self.graph_dir)
|
|
48
|
+
self.positive_gen = PositiveReinforcementGenerator()
|
|
49
|
+
|
|
50
|
+
def analyze(self, tool: str, params: dict, result: dict) -> dict[str, Any]:
|
|
51
|
+
"""
|
|
52
|
+
Analyze tool execution and provide feedback.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
tool: Tool that was executed (Read, Task, Edit, etc.)
|
|
56
|
+
params: Tool parameters
|
|
57
|
+
result: Tool execution result
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Hook response dict with feedback:
|
|
61
|
+
{
|
|
62
|
+
"hookSpecificOutput": {
|
|
63
|
+
"hookEventName": "PostToolUse",
|
|
64
|
+
"additionalContext": "Feedback message"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
"""
|
|
68
|
+
# Calculate actual cost
|
|
69
|
+
actual_cost = self.cost_calculator.calculate_actual_cost(tool, result)
|
|
70
|
+
|
|
71
|
+
# Determine if this was a delegation or direct execution
|
|
72
|
+
was_delegation = self._is_delegation(tool)
|
|
73
|
+
|
|
74
|
+
if was_delegation:
|
|
75
|
+
return self._positive_reinforcement(tool, actual_cost)
|
|
76
|
+
else:
|
|
77
|
+
return self._cost_accounting(tool, actual_cost)
|
|
78
|
+
|
|
79
|
+
def _is_delegation(self, tool: str) -> bool:
|
|
80
|
+
"""
|
|
81
|
+
Check if tool represents delegation.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
tool: Tool name
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
True if tool is a delegation operation
|
|
88
|
+
"""
|
|
89
|
+
return tool == "Task" or tool.startswith("spawn_")
|
|
90
|
+
|
|
91
|
+
def _positive_reinforcement(self, tool: str, cost: TokenCost) -> dict[str, Any]:
|
|
92
|
+
"""
|
|
93
|
+
Provide positive reinforcement for correct delegation.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
tool: Delegation tool used (Task, spawn_gemini, etc.)
|
|
97
|
+
cost: Actual token cost breakdown
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Hook response with positive reinforcement message
|
|
101
|
+
"""
|
|
102
|
+
# Get current session stats
|
|
103
|
+
violations = self.tracker.get_session_violations()
|
|
104
|
+
compliance_rate = self._calculate_compliance_rate(violations.total_violations)
|
|
105
|
+
session_waste = violations.total_waste_tokens
|
|
106
|
+
|
|
107
|
+
# Generate positive message
|
|
108
|
+
message = self.positive_gen.generate(
|
|
109
|
+
tool=tool,
|
|
110
|
+
cost_savings=cost.estimated_savings,
|
|
111
|
+
compliance_rate=compliance_rate,
|
|
112
|
+
session_waste=session_waste,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"hookSpecificOutput": {
|
|
117
|
+
"hookEventName": "PostToolUse",
|
|
118
|
+
"additionalContext": message,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
def _cost_accounting(self, tool: str, cost: TokenCost) -> dict[str, Any]:
|
|
123
|
+
"""
|
|
124
|
+
Account for cost of direct execution.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
tool: Tool that was executed directly
|
|
128
|
+
cost: Actual token cost breakdown
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Hook response with cost accounting message
|
|
132
|
+
"""
|
|
133
|
+
# Update violation tracker with actual cost
|
|
134
|
+
self.tracker.record_actual_cost(tool, cost)
|
|
135
|
+
|
|
136
|
+
# Get session violations for context
|
|
137
|
+
violations = self.tracker.get_session_violations()
|
|
138
|
+
|
|
139
|
+
# Check if this was a warned violation (look for recent violation with this tool)
|
|
140
|
+
has_violation = any(
|
|
141
|
+
v.tool == tool for v in violations.violations[-3:] if violations.violations
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if has_violation or violations.total_violations > 0:
|
|
145
|
+
# This was likely a warned violation - provide cost impact
|
|
146
|
+
message = self._generate_violation_cost_message(
|
|
147
|
+
tool, cost, violations.total_violations, violations.total_waste_tokens
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
# Allowed operation - just informational
|
|
151
|
+
message = f"Operation completed. Cost: {cost.total_tokens} tokens."
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
"hookSpecificOutput": {
|
|
155
|
+
"hookEventName": "PostToolUse",
|
|
156
|
+
"additionalContext": message,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
def _generate_violation_cost_message(
|
|
161
|
+
self, tool: str, cost: TokenCost, violation_count: int, total_waste: int
|
|
162
|
+
) -> str:
|
|
163
|
+
"""
|
|
164
|
+
Generate cost impact message for violation.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
tool: Tool that was executed
|
|
168
|
+
cost: Actual token cost
|
|
169
|
+
violation_count: Total violations in session
|
|
170
|
+
total_waste: Total wasted tokens in session
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Formatted cost impact message
|
|
174
|
+
"""
|
|
175
|
+
# Calculate optimal cost for this tool
|
|
176
|
+
optimal_cost = cost.subagent_tokens + cost.orchestrator_tokens
|
|
177
|
+
waste = cost.total_tokens - optimal_cost
|
|
178
|
+
|
|
179
|
+
# Calculate compliance rate
|
|
180
|
+
compliance_rate = self._calculate_compliance_rate(violation_count)
|
|
181
|
+
|
|
182
|
+
message_parts = [
|
|
183
|
+
"📊 Direct execution completed.",
|
|
184
|
+
"",
|
|
185
|
+
"**Cost Impact (Violation):**",
|
|
186
|
+
f"- Actual cost: {cost.total_tokens:,} tokens",
|
|
187
|
+
f"- If delegated: ~{optimal_cost:,} tokens",
|
|
188
|
+
f"- Waste: {waste:,} tokens ({(waste / cost.total_tokens * 100):.0f}% overhead)",
|
|
189
|
+
"",
|
|
190
|
+
"**Session Statistics:**",
|
|
191
|
+
f"- Violations: {violation_count}",
|
|
192
|
+
f"- Total waste: {total_waste:,} tokens",
|
|
193
|
+
f"- Delegation compliance: {compliance_rate:.0%}",
|
|
194
|
+
"",
|
|
195
|
+
"REFLECTION: Was this delegation worth the context cost?",
|
|
196
|
+
f"The same operation via Task() would have cost ~{optimal_cost:,} tokens",
|
|
197
|
+
"with full isolation of tactical details.",
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
return "\n".join(message_parts)
|
|
201
|
+
|
|
202
|
+
def _calculate_compliance_rate(self, violation_count: int) -> float:
|
|
203
|
+
"""
|
|
204
|
+
Calculate delegation compliance rate.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
violation_count: Number of violations
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Compliance rate from 0.0 to 1.0
|
|
211
|
+
"""
|
|
212
|
+
# For simplicity: (max_violations - actual) / max_violations
|
|
213
|
+
# where max_violations = 5 (violation rate saturates)
|
|
214
|
+
max_violations = 5
|
|
215
|
+
return max(0.0, 1.0 - (violation_count / max_violations))
|
|
216
|
+
|
|
217
|
+
def get_session_summary(self) -> dict[str, Any]:
|
|
218
|
+
"""
|
|
219
|
+
Get session summary for Stop hook.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Summary dict with metrics
|
|
223
|
+
"""
|
|
224
|
+
violations = self.tracker.get_session_violations()
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
"total_violations": violations.total_violations,
|
|
228
|
+
"violations_by_type": {
|
|
229
|
+
str(k): v for k, v in violations.violations_by_type.items()
|
|
230
|
+
},
|
|
231
|
+
"total_waste_tokens": violations.total_waste_tokens,
|
|
232
|
+
"circuit_breaker_triggered": violations.circuit_breaker_triggered,
|
|
233
|
+
"compliance_rate": violations.compliance_rate,
|
|
234
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ViolationTracker for CIGS (Computational Imperative Guidance System)
|
|
3
|
+
|
|
4
|
+
Tracks delegation violations in JSONL format, providing thread-safe access
|
|
5
|
+
to violation records and session metrics.
|
|
6
|
+
|
|
7
|
+
Reference: .htmlgraph/spikes/computational-imperative-guidance-system-design.md (Part 3)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
from htmlgraph.cigs.models import (
|
|
18
|
+
OperationClassification,
|
|
19
|
+
SessionViolationSummary,
|
|
20
|
+
TokenCost,
|
|
21
|
+
ViolationRecord,
|
|
22
|
+
ViolationType,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ViolationTracker:
|
|
27
|
+
"""
|
|
28
|
+
Thread-safe tracker for delegation violations.
|
|
29
|
+
|
|
30
|
+
Stores violations in JSONL format at `.htmlgraph/cigs/violations.jsonl`
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, graph_dir: Path | None = None):
|
|
34
|
+
"""
|
|
35
|
+
Initialize ViolationTracker.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
graph_dir: Root directory for HtmlGraph (defaults to .htmlgraph)
|
|
39
|
+
"""
|
|
40
|
+
if graph_dir is None:
|
|
41
|
+
graph_dir = Path.cwd() / ".htmlgraph"
|
|
42
|
+
|
|
43
|
+
self.graph_dir = Path(graph_dir)
|
|
44
|
+
self.cigs_dir = self.graph_dir / "cigs"
|
|
45
|
+
self.violations_file = self.cigs_dir / "violations.jsonl"
|
|
46
|
+
|
|
47
|
+
# Create directory if needed
|
|
48
|
+
self.cigs_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
# Thread-safe access
|
|
51
|
+
self._lock = threading.RLock()
|
|
52
|
+
|
|
53
|
+
# Current session ID (set externally or detect from environment)
|
|
54
|
+
self._session_id: str | None = self._detect_session_id()
|
|
55
|
+
|
|
56
|
+
def _detect_session_id(self) -> str | None:
|
|
57
|
+
"""Detect current session ID from environment or HtmlGraph session manager."""
|
|
58
|
+
# First check environment variable
|
|
59
|
+
if "HTMLGRAPH_SESSION_ID" in os.environ:
|
|
60
|
+
return os.environ["HTMLGRAPH_SESSION_ID"]
|
|
61
|
+
|
|
62
|
+
# Try to get from session manager if available
|
|
63
|
+
try:
|
|
64
|
+
from htmlgraph.session_manager import SessionManager
|
|
65
|
+
|
|
66
|
+
sm = SessionManager(self.graph_dir)
|
|
67
|
+
current = sm.get_active_session()
|
|
68
|
+
if current:
|
|
69
|
+
return str(current.id)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def record_violation(
|
|
76
|
+
self,
|
|
77
|
+
tool: str,
|
|
78
|
+
params: dict,
|
|
79
|
+
classification: OperationClassification,
|
|
80
|
+
predicted_waste: int,
|
|
81
|
+
) -> str:
|
|
82
|
+
"""
|
|
83
|
+
Record a violation.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
tool: Tool name (Read, Grep, Edit, etc.)
|
|
87
|
+
params: Tool parameters passed
|
|
88
|
+
classification: OperationClassification with context
|
|
89
|
+
predicted_waste: Predicted wasted tokens
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Violation ID
|
|
93
|
+
"""
|
|
94
|
+
with self._lock:
|
|
95
|
+
violation_id = f"viol-{uuid4().hex[:12]}"
|
|
96
|
+
timestamp = datetime.utcnow()
|
|
97
|
+
|
|
98
|
+
# Get current session
|
|
99
|
+
session_id = self._session_id or "unknown"
|
|
100
|
+
|
|
101
|
+
record = ViolationRecord(
|
|
102
|
+
id=violation_id,
|
|
103
|
+
session_id=session_id,
|
|
104
|
+
timestamp=timestamp,
|
|
105
|
+
tool=tool,
|
|
106
|
+
tool_params=params,
|
|
107
|
+
violation_type=self._classify_violation(tool, classification),
|
|
108
|
+
context_before=None,
|
|
109
|
+
should_have_delegated_to=classification.suggested_delegation,
|
|
110
|
+
actual_cost_tokens=classification.predicted_cost,
|
|
111
|
+
optimal_cost_tokens=classification.optimal_cost,
|
|
112
|
+
waste_tokens=predicted_waste,
|
|
113
|
+
warning_level=1,
|
|
114
|
+
was_warned=False,
|
|
115
|
+
warning_ignored=False,
|
|
116
|
+
agent="claude-code",
|
|
117
|
+
feature_id=None,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Append to JSONL file
|
|
121
|
+
self._append_violation(record)
|
|
122
|
+
|
|
123
|
+
return violation_id
|
|
124
|
+
|
|
125
|
+
def _classify_violation(
|
|
126
|
+
self, tool: str, classification: OperationClassification
|
|
127
|
+
) -> ViolationType:
|
|
128
|
+
"""Classify the violation type based on tool and context."""
|
|
129
|
+
if classification.is_exploration_sequence:
|
|
130
|
+
return ViolationType.EXPLORATION_SEQUENCE
|
|
131
|
+
|
|
132
|
+
# Map tools to violation types
|
|
133
|
+
if tool in ("Read", "Grep", "Glob"):
|
|
134
|
+
return ViolationType.DIRECT_EXPLORATION
|
|
135
|
+
elif tool in ("Edit", "Write", "NotebookEdit"):
|
|
136
|
+
return ViolationType.DIRECT_IMPLEMENTATION
|
|
137
|
+
elif tool == "Bash" and "git" in str(classification.reason).lower():
|
|
138
|
+
return ViolationType.DIRECT_GIT
|
|
139
|
+
elif tool == "Bash":
|
|
140
|
+
return ViolationType.DIRECT_TESTING
|
|
141
|
+
|
|
142
|
+
return ViolationType.DIRECT_EXPLORATION
|
|
143
|
+
|
|
144
|
+
def _append_violation(self, record: ViolationRecord) -> None:
|
|
145
|
+
"""Append violation record to JSONL file (thread-safe)."""
|
|
146
|
+
with self._lock:
|
|
147
|
+
try:
|
|
148
|
+
with open(self.violations_file, "a") as f:
|
|
149
|
+
f.write(json.dumps(record.to_dict()) + "\n")
|
|
150
|
+
except Exception as e:
|
|
151
|
+
# Log but don't crash on storage errors
|
|
152
|
+
print(f"Warning: Failed to record violation: {e}")
|
|
153
|
+
|
|
154
|
+
def get_session_violations(
|
|
155
|
+
self, session_id: str | None = None
|
|
156
|
+
) -> SessionViolationSummary:
|
|
157
|
+
"""
|
|
158
|
+
Get violations for current or specific session.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
session_id: Session ID (defaults to current session)
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
SessionViolationSummary with aggregated metrics
|
|
165
|
+
"""
|
|
166
|
+
if session_id is None:
|
|
167
|
+
session_id = self._session_id or "unknown"
|
|
168
|
+
|
|
169
|
+
with self._lock:
|
|
170
|
+
violations = self._load_violations()
|
|
171
|
+
|
|
172
|
+
# Filter to session
|
|
173
|
+
session_violations = [v for v in violations if v.session_id == session_id]
|
|
174
|
+
|
|
175
|
+
# Aggregate by type
|
|
176
|
+
violations_by_type = {}
|
|
177
|
+
for vtype in ViolationType:
|
|
178
|
+
count = sum(1 for v in session_violations if v.violation_type == vtype)
|
|
179
|
+
if count > 0:
|
|
180
|
+
violations_by_type[vtype] = count
|
|
181
|
+
|
|
182
|
+
# Calculate totals
|
|
183
|
+
total_violations = len(session_violations)
|
|
184
|
+
total_waste = sum(v.waste_tokens for v in session_violations)
|
|
185
|
+
circuit_breaker = total_violations >= 3
|
|
186
|
+
|
|
187
|
+
# Compliance rate: 1.0 = no violations, 0.0 = many violations
|
|
188
|
+
# For simplicity: (max_violations - actual) / max_violations
|
|
189
|
+
# where max_violations = 5 (violation rate saturates)
|
|
190
|
+
compliance_rate = max(0.0, 1.0 - (total_violations / 5.0))
|
|
191
|
+
|
|
192
|
+
return SessionViolationSummary(
|
|
193
|
+
session_id=session_id,
|
|
194
|
+
total_violations=total_violations,
|
|
195
|
+
violations_by_type=violations_by_type,
|
|
196
|
+
total_waste_tokens=total_waste,
|
|
197
|
+
circuit_breaker_triggered=circuit_breaker,
|
|
198
|
+
compliance_rate=compliance_rate,
|
|
199
|
+
violations=session_violations,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def get_recent_violations(self, sessions: int = 5) -> list[ViolationRecord]:
|
|
203
|
+
"""
|
|
204
|
+
Get violations from last N sessions.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
sessions: Number of sessions to include
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
List of violation records from recent sessions
|
|
211
|
+
"""
|
|
212
|
+
with self._lock:
|
|
213
|
+
violations = self._load_violations()
|
|
214
|
+
|
|
215
|
+
# Group by session and get N most recent
|
|
216
|
+
session_ids = set(v.session_id for v in violations)
|
|
217
|
+
recent_sessions = sorted(session_ids)[-sessions:]
|
|
218
|
+
|
|
219
|
+
return [v for v in violations if v.session_id in recent_sessions]
|
|
220
|
+
|
|
221
|
+
def record_actual_cost(self, tool: str, cost: TokenCost) -> None:
|
|
222
|
+
"""
|
|
223
|
+
Update violation with actual cost after execution.
|
|
224
|
+
|
|
225
|
+
This updates the most recent violation for the given tool.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
tool: Tool name
|
|
229
|
+
cost: TokenCost with actual metrics
|
|
230
|
+
"""
|
|
231
|
+
with self._lock:
|
|
232
|
+
violations = self._load_violations()
|
|
233
|
+
|
|
234
|
+
if not violations:
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
# Find most recent violation for this tool (in current session)
|
|
238
|
+
session_id = self._session_id or "unknown"
|
|
239
|
+
matching = [
|
|
240
|
+
v for v in violations if v.tool == tool and v.session_id == session_id
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
if not matching:
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
# Update most recent
|
|
247
|
+
latest = matching[-1]
|
|
248
|
+
latest.actual_cost_tokens = cost.total_tokens
|
|
249
|
+
latest.waste_tokens = cost.total_tokens - latest.optimal_cost_tokens
|
|
250
|
+
|
|
251
|
+
# Rewrite file (simple approach - replace entire file)
|
|
252
|
+
self._write_violations(violations)
|
|
253
|
+
|
|
254
|
+
def _write_violations(self, violations: list[ViolationRecord]) -> None:
|
|
255
|
+
"""Write violations back to JSONL file."""
|
|
256
|
+
with self._lock:
|
|
257
|
+
try:
|
|
258
|
+
with open(self.violations_file, "w") as f:
|
|
259
|
+
for v in violations:
|
|
260
|
+
f.write(json.dumps(v.to_dict()) + "\n")
|
|
261
|
+
except Exception as e:
|
|
262
|
+
print(f"Warning: Failed to write violations: {e}")
|
|
263
|
+
|
|
264
|
+
def _load_violations(self) -> list[ViolationRecord]:
|
|
265
|
+
"""Load all violations from JSONL file."""
|
|
266
|
+
violations: list[ViolationRecord] = []
|
|
267
|
+
|
|
268
|
+
if not self.violations_file.exists():
|
|
269
|
+
return violations
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
with open(self.violations_file) as f:
|
|
273
|
+
for line in f:
|
|
274
|
+
if not line.strip():
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
data = json.loads(line)
|
|
278
|
+
violations.append(ViolationRecord.from_dict(data))
|
|
279
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
280
|
+
print(f"Warning: Failed to parse violation record: {e}")
|
|
281
|
+
continue
|
|
282
|
+
except Exception as e:
|
|
283
|
+
print(f"Warning: Failed to load violations: {e}")
|
|
284
|
+
|
|
285
|
+
return violations
|
|
286
|
+
|
|
287
|
+
def get_session_waste(self) -> int:
|
|
288
|
+
"""
|
|
289
|
+
Get total wasted tokens for current session.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Total waste tokens
|
|
293
|
+
"""
|
|
294
|
+
summary = self.get_session_violations()
|
|
295
|
+
return summary.total_waste_tokens
|
|
296
|
+
|
|
297
|
+
def set_session_id(self, session_id: str) -> None:
|
|
298
|
+
"""
|
|
299
|
+
Set the current session ID.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
session_id: Session ID to use for subsequent violations
|
|
303
|
+
"""
|
|
304
|
+
self._session_id = session_id
|
|
305
|
+
|
|
306
|
+
def clear_session_file(self) -> None:
|
|
307
|
+
"""
|
|
308
|
+
Clear the violations file (useful for testing).
|
|
309
|
+
|
|
310
|
+
WARNING: This deletes all violation records!
|
|
311
|
+
"""
|
|
312
|
+
with self._lock:
|
|
313
|
+
try:
|
|
314
|
+
if self.violations_file.exists():
|
|
315
|
+
self.violations_file.unlink()
|
|
316
|
+
except Exception as e:
|
|
317
|
+
print(f"Warning: Failed to clear violations file: {e}")
|