@pjmendonca/devflow 1.13.1 → 1.18.0
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.
- package/.claude/commands/agent.md +1 -1
- package/.claude/commands/bugfix.md +21 -0
- package/.claude/commands/checkpoint.md +0 -1
- package/.claude/commands/collab.md +0 -1
- package/.claude/commands/costs.md +88 -18
- package/.claude/commands/devflow.md +26 -0
- package/.claude/commands/handoff.md +0 -1
- package/.claude/commands/memory.md +0 -1
- package/.claude/commands/pair.md +0 -1
- package/.claude/commands/review.md +27 -0
- package/.claude/commands/route.md +0 -1
- package/.claude/commands/swarm.md +0 -1
- package/.claude/commands/validate.md +55 -0
- package/.claude/hooks/session-notification.sh +44 -0
- package/.claude/hooks/session-startup.sh +427 -0
- package/.claude/hooks/session-stop.sh +38 -0
- package/.claude/hooks/session_tracker.py +272 -0
- package/.claude/settings.json +38 -0
- package/.claude/skills/costs/SKILL.md +156 -0
- package/.claude/skills/validate/SKILL.md +101 -0
- package/CHANGELOG.md +254 -0
- package/README.md +207 -10
- package/bin/devflow-install.js +2 -1
- package/bin/devflow.js +5 -2
- package/lib/constants.js +0 -1
- package/lib/exec-python.js +1 -1
- package/package.json +1 -2
- package/tooling/.automation/.checkpoint_lock +1 -0
- package/tooling/.automation/agents/architect.md +19 -0
- package/tooling/.automation/agents/ba.md +19 -0
- package/tooling/.automation/agents/maintainer.md +19 -0
- package/tooling/.automation/agents/pm.md +19 -0
- package/tooling/.automation/agents/reviewer.md +1 -1
- package/tooling/.automation/agents/writer.md +19 -0
- package/tooling/.automation/benchmarks/benchmark_20251230_100119.json +314 -0
- package/tooling/.automation/benchmarks/benchmark_20251230_100216.json +314 -0
- package/tooling/.automation/costs/config.json +31 -0
- package/tooling/.automation/costs/sessions/2025-12-29_20251229_164128.json +22 -0
- package/tooling/.automation/memory/knowledge/kg_integration-test.json +707 -1
- package/tooling/.automation/memory/knowledge/kg_test-story.json +3273 -2
- package/tooling/.automation/memory/shared/shared_integration-test.json +181 -1
- package/tooling/.automation/memory/shared/shared_test-story.json +721 -1
- package/tooling/.automation/memory/shared/shared_test.json +1254 -0
- package/tooling/.automation/memory/shared/shared_validation-check.json +227 -0
- package/tooling/.automation/overrides/templates/architect/cloud-native.yaml +5 -5
- package/tooling/.automation/overrides/templates/architect/enterprise-architect.yaml +23 -5
- package/tooling/.automation/overrides/templates/architect/pragmatic-minimalist.yaml +24 -6
- package/tooling/.automation/overrides/templates/ba/agile-storyteller.yaml +4 -4
- package/tooling/.automation/overrides/templates/ba/domain-expert.yaml +4 -4
- package/tooling/.automation/overrides/templates/ba/requirements-engineer.yaml +4 -4
- package/tooling/.automation/overrides/templates/dev/performance-engineer.yaml +18 -0
- package/tooling/.automation/overrides/templates/dev/rapid-prototyper.yaml +19 -1
- package/tooling/.automation/overrides/templates/dev/security-focused.yaml +18 -0
- package/tooling/.automation/overrides/templates/dev/user-advocate.yaml +54 -0
- package/tooling/.automation/overrides/templates/maintainer/devops-maintainer.yaml +4 -4
- package/tooling/.automation/overrides/templates/maintainer/legacy-steward.yaml +4 -4
- package/tooling/.automation/overrides/templates/maintainer/oss-maintainer.yaml +4 -4
- package/tooling/.automation/overrides/templates/maintainer/reliability-engineer.yaml +55 -0
- package/tooling/.automation/overrides/templates/pm/agile-pm.yaml +4 -4
- package/tooling/.automation/overrides/templates/pm/hybrid-delivery.yaml +3 -3
- package/tooling/.automation/overrides/templates/pm/traditional-pm.yaml +4 -4
- package/tooling/.automation/overrides/templates/reviewer/quick-sanity.yaml +18 -0
- package/tooling/.automation/overrides/templates/reviewer/thorough-critic.yaml +18 -0
- package/tooling/.automation/overrides/templates/sm/agile-coach.yaml +2 -2
- package/tooling/.automation/overrides/templates/sm/startup-pm.yaml +3 -3
- package/tooling/.automation/overrides/templates/writer/api-documentarian.yaml +5 -5
- package/tooling/.automation/overrides/templates/writer/docs-as-code.yaml +4 -4
- package/tooling/.automation/overrides/templates/writer/user-guide-author.yaml +5 -5
- package/tooling/.automation/validation/history/2025-12-29_val_002a28c1.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_01273bb1.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_03369914.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_07a449ba.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_0df1f0a2.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_10ff3d34.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_110771d7.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_13f3a7f9.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_17ba9d21.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_22247089.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_227ea6a4.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_2335d5ae.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_246824bb.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_28b4b9cd.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_2abd12cc.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_2c801b2f.json +59 -0
- package/tooling/.automation/validation/history/2025-12-29_val_2c8cfa8e.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_2ce76eb0.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_30351948.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_30eb7229.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_34df0e77.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_376e4d6a.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_3a4e8a1a.json +59 -0
- package/tooling/.automation/validation/history/2025-12-29_val_3b77a628.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_3ea4e1cf.json +59 -0
- package/tooling/.automation/validation/history/2025-12-29_val_44aacdb4.json +59 -0
- package/tooling/.automation/validation/history/2025-12-29_val_457ddfa8.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_45af6238.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_4735dba1.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_486b203c.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_49dc56cd.json +59 -0
- package/tooling/.automation/validation/history/2025-12-29_val_4d863d6d.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_5149a808.json +59 -0
- package/tooling/.automation/validation/history/2025-12-29_val_52e0bb43.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_585d6319.json +59 -0
- package/tooling/.automation/validation/history/2025-12-29_val_5b2d859a.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_635a7081.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_64df4905.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_70634cee.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_714553f9.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_7f7bfdbf.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_7faad91d.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_81821f8f.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_8249f3c9.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_8422b50f.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_8446c134.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_879f4e26.json +59 -0
- package/tooling/.automation/validation/history/2025-12-29_val_8b6d5bd7.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_8c5cd787.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_91d20bc7.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_958a12b7.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_95d91108.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_980dbb74.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_9e40c79b.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_9f499b7c.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_9f7c3b57.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_a30d5bd4.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_a6eb09c7.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_a86f7b83.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_ad5347e1.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_b0a5a993.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_bcb0192e.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_bf3c9aaa.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_c461ff88.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_c4f4e258.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_c7f0fa6d.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_c911b0e6.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_cc581964.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_cdd5a33b.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_cfd42495.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_d1c7a4ee.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_d2280d0e.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_d2a6ff69.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_d8c53ab2.json +59 -0
- package/tooling/.automation/validation/history/2025-12-29_val_d9c1247a.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_d9d58569.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_dabb4fd9.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_dd8fe359.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_decdffc9.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_e3a95476.json +59 -0
- package/tooling/.automation/validation/history/2025-12-29_val_e776dfca.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_ea70969f.json +59 -0
- package/tooling/.automation/validation/history/2025-12-29_val_ef41ea95.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_f384f9b1.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_f8adc38c.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_fa40b69e.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_fc538d54.json +41 -0
- package/tooling/.automation/validation/history/2025-12-29_val_fe814665.json +32 -0
- package/tooling/.automation/validation/history/2025-12-29_val_ffea4b12.json +32 -0
- package/tooling/.automation/validation/history/2025-12-30_val_02d001e5.json +59 -0
- package/tooling/.automation/validation/history/2025-12-30_val_0b8966dc.json +32 -0
- package/tooling/.automation/validation/history/2025-12-30_val_15455fbf.json +59 -0
- package/tooling/.automation/validation/history/2025-12-30_val_157e34b9.json +32 -0
- package/tooling/.automation/validation/history/2025-12-30_val_28d1d933.json +32 -0
- package/tooling/.automation/validation/history/2025-12-30_val_3442a52c.json +32 -0
- package/tooling/.automation/validation/history/2025-12-30_val_37f1ce1e.json +32 -0
- package/tooling/.automation/validation/history/2025-12-30_val_4f1d8a93.json +32 -0
- package/tooling/.automation/validation/history/2025-12-30_val_56ff1de3.json +32 -0
- package/tooling/.automation/validation/history/2025-12-30_val_664fd4e2.json +41 -0
- package/tooling/.automation/validation/history/2025-12-30_val_66afb0a7.json +32 -0
- package/tooling/.automation/validation/history/2025-12-30_val_7634663c.json +41 -0
- package/tooling/.automation/validation/history/2025-12-30_val_8ea830c3.json +41 -0
- package/tooling/.automation/validation/history/2025-12-30_val_998957c2.json +32 -0
- package/tooling/.automation/validation/history/2025-12-30_val_a52177db.json +32 -0
- package/tooling/.automation/validation/history/2025-12-30_val_a5b65a63.json +32 -0
- package/tooling/.automation/validation/history/2025-12-30_val_ae391d0e.json +32 -0
- package/tooling/.automation/validation/history/2025-12-30_val_c7895339.json +41 -0
- package/tooling/.automation/validation/history/2025-12-30_val_ca416593.json +41 -0
- package/tooling/.automation/validation/history/2025-12-30_val_cee19422.json +32 -0
- package/tooling/.automation/validation/history/2025-12-30_val_ddd4f4e6.json +32 -0
- package/tooling/.automation/validation/history/2025-12-30_val_f2e1394b.json +32 -0
- package/tooling/.automation/validation/history/2025-12-30_val_f4a7fa06.json +41 -0
- package/tooling/.automation/validation/history/2025-12-30_val_ffea3369.json +32 -0
- package/tooling/.automation/validation-config.yaml +103 -0
- package/tooling/completions/DevflowCompletion.ps1 +21 -21
- package/tooling/completions/_run-story +3 -3
- package/tooling/completions/run-story-completion.bash +8 -8
- package/tooling/docs/DOC-STANDARD.md +14 -14
- package/tooling/docs/templates/migration-spec.md +4 -4
- package/tooling/scripts/context_checkpoint.py +5 -15
- package/tooling/scripts/cost_dashboard.py +610 -13
- package/tooling/scripts/create-persona.py +1 -12
- package/tooling/scripts/create-persona.sh +44 -44
- package/tooling/scripts/lib/__init__.py +12 -1
- package/tooling/scripts/lib/agent_handoff.py +11 -2
- package/tooling/scripts/lib/agent_router.py +31 -10
- package/tooling/scripts/lib/colors.py +106 -0
- package/tooling/scripts/lib/context_monitor.py +766 -0
- package/tooling/scripts/lib/cost_config.py +229 -10
- package/tooling/scripts/lib/cost_display.py +20 -45
- package/tooling/scripts/lib/cost_tracker.py +462 -15
- package/tooling/scripts/lib/currency_converter.py +28 -5
- package/tooling/scripts/lib/pair_programming.py +102 -3
- package/tooling/scripts/lib/personality_system.py +949 -0
- package/tooling/scripts/lib/platform.py +55 -0
- package/tooling/scripts/lib/shared_memory.py +9 -3
- package/tooling/scripts/lib/swarm_orchestrator.py +514 -75
- package/tooling/scripts/lib/validation_loop.py +1014 -0
- package/tooling/scripts/memory_summarize.py +9 -2
- package/tooling/scripts/new-doc.py +2 -9
- package/tooling/scripts/personalize_agent.py +1 -12
- package/tooling/scripts/rollback-migration.sh +60 -60
- package/tooling/scripts/run-collab.ps1 +16 -16
- package/tooling/scripts/run-collab.py +88 -53
- package/tooling/scripts/run-collab.sh +4 -4
- package/tooling/scripts/run-story.py +278 -20
- package/tooling/scripts/run-story.sh +3 -3
- package/tooling/scripts/setup-checkpoint-service.py +2 -9
- package/tooling/scripts/tech-debt-tracker.py +1 -12
- package/tooling/scripts/test_adversarial_swarm.py +452 -0
- package/tooling/scripts/update_version.py +48 -2
- package/tooling/scripts/validate-overrides.py +1 -10
- package/tooling/scripts/validate-overrides.sh +40 -40
- package/tooling/scripts/validate_loop.py +162 -0
- package/tooling/scripts/validate_setup.py +2 -30
- package/.claude/skills/init/SKILL.md +0 -496
- package/bin/devflow-init.js +0 -10
- package/tooling/scripts/init-project-workflow.ps1 +0 -651
- package/tooling/scripts/init-project-workflow.py +0 -70
- package/tooling/scripts/init-project-workflow.sh +0 -746
|
@@ -13,6 +13,7 @@ Usage:
|
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
import json
|
|
16
|
+
import re
|
|
16
17
|
import sys
|
|
17
18
|
import threading
|
|
18
19
|
import uuid
|
|
@@ -89,6 +90,37 @@ DEFAULT_THRESHOLDS = {
|
|
|
89
90
|
"stop": 1.00, # 100% - Auto-stop
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
# Cache for model pricing lookups (model_lower -> pricing dict)
|
|
94
|
+
_pricing_cache: dict[str, dict[str, float]] = {}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _get_pricing(model: str) -> tuple[dict[str, float], bool]:
|
|
98
|
+
"""Get pricing for a model with caching.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
model: Model name
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Tuple of (pricing dict, is_default) where is_default indicates if
|
|
105
|
+
sonnet default pricing was used for an unknown model.
|
|
106
|
+
"""
|
|
107
|
+
model_lower = model.lower()
|
|
108
|
+
|
|
109
|
+
# Check cache first
|
|
110
|
+
if model_lower in _pricing_cache:
|
|
111
|
+
return _pricing_cache[model_lower], False
|
|
112
|
+
|
|
113
|
+
# Search for matching pricing
|
|
114
|
+
for key, price in PRICING.items():
|
|
115
|
+
if key in model_lower or model_lower in key:
|
|
116
|
+
_pricing_cache[model_lower] = price
|
|
117
|
+
return price, False
|
|
118
|
+
|
|
119
|
+
# Cache and return default
|
|
120
|
+
_pricing_cache[model_lower] = PRICING["sonnet"]
|
|
121
|
+
return PRICING["sonnet"], True
|
|
122
|
+
|
|
123
|
+
|
|
92
124
|
# Storage paths
|
|
93
125
|
PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
|
|
94
126
|
COSTS_DIR = PROJECT_ROOT / "tooling" / ".automation" / "costs"
|
|
@@ -100,11 +132,11 @@ class CostEntry:
|
|
|
100
132
|
"""Single cost entry for a Claude API call."""
|
|
101
133
|
|
|
102
134
|
timestamp: str
|
|
103
|
-
agent: str
|
|
104
135
|
model: str
|
|
105
136
|
input_tokens: int
|
|
106
137
|
output_tokens: int
|
|
107
138
|
cost_usd: float
|
|
139
|
+
agent: str = "unknown" # Optional for backwards compatibility
|
|
108
140
|
|
|
109
141
|
def to_dict(self) -> dict:
|
|
110
142
|
return asdict(self)
|
|
@@ -245,8 +277,22 @@ class CostTracker:
|
|
|
245
277
|
Calculated cost in USD
|
|
246
278
|
|
|
247
279
|
Raises:
|
|
248
|
-
CalculationError: If token counts are invalid
|
|
280
|
+
CalculationError: If token counts are invalid or non-numeric
|
|
249
281
|
"""
|
|
282
|
+
# Validate input types
|
|
283
|
+
try:
|
|
284
|
+
input_tokens = int(input_tokens)
|
|
285
|
+
output_tokens = int(output_tokens)
|
|
286
|
+
except (TypeError, ValueError) as e:
|
|
287
|
+
error_msg = f"Token counts must be numeric: {e}"
|
|
288
|
+
if ENHANCED_ERRORS:
|
|
289
|
+
raise create_error(
|
|
290
|
+
ErrorCode.INVALID_TOKENS,
|
|
291
|
+
context=ErrorContext(operation="calculating cost", model=model),
|
|
292
|
+
custom_message=error_msg,
|
|
293
|
+
) from e
|
|
294
|
+
raise CalculationError(error_msg) from e
|
|
295
|
+
|
|
250
296
|
# Validate inputs
|
|
251
297
|
if input_tokens < 0:
|
|
252
298
|
error_msg = f"Invalid input_tokens: {input_tokens}. Token count cannot be negative."
|
|
@@ -268,18 +314,11 @@ class CostTracker:
|
|
|
268
314
|
)
|
|
269
315
|
raise CalculationError(error_msg)
|
|
270
316
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
# Find pricing
|
|
274
|
-
pricing = None
|
|
275
|
-
for key, price in PRICING.items():
|
|
276
|
-
if key in model_lower or model_lower in key:
|
|
277
|
-
pricing = price
|
|
278
|
-
break
|
|
317
|
+
# Get pricing with caching
|
|
318
|
+
pricing, is_default = _get_pricing(model)
|
|
279
319
|
|
|
280
|
-
if
|
|
281
|
-
#
|
|
282
|
-
pricing = PRICING["sonnet"]
|
|
320
|
+
if is_default:
|
|
321
|
+
# Warn about using default pricing for unknown model
|
|
283
322
|
warning_msg = (
|
|
284
323
|
f"Unknown model '{model}'. Using sonnet pricing as default. "
|
|
285
324
|
f"Supported models: {', '.join(k for k in PRICING.keys() if not k.startswith('claude-'))}"
|
|
@@ -592,6 +631,416 @@ class CostTracker:
|
|
|
592
631
|
"by_model": by_model,
|
|
593
632
|
}
|
|
594
633
|
|
|
634
|
+
@staticmethod
|
|
635
|
+
def get_subscription_usage(billing_period_days: int = 30) -> dict:
|
|
636
|
+
"""
|
|
637
|
+
Get subscription usage statistics for the billing period.
|
|
638
|
+
|
|
639
|
+
Args:
|
|
640
|
+
billing_period_days: Number of days in the billing period (default: 30)
|
|
641
|
+
|
|
642
|
+
Returns:
|
|
643
|
+
Dictionary with:
|
|
644
|
+
- total_tokens: Total tokens used in the billing period
|
|
645
|
+
- total_input_tokens: Input tokens used
|
|
646
|
+
- total_output_tokens: Output tokens used
|
|
647
|
+
- total_sessions: Number of sessions
|
|
648
|
+
- total_cost_usd: Total cost in USD
|
|
649
|
+
"""
|
|
650
|
+
sessions = CostTracker.get_historical_sessions(days=billing_period_days)
|
|
651
|
+
|
|
652
|
+
total_input = sum(s.total_input_tokens for s in sessions)
|
|
653
|
+
total_output = sum(s.total_output_tokens for s in sessions)
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
"total_tokens": total_input + total_output,
|
|
657
|
+
"total_input_tokens": total_input,
|
|
658
|
+
"total_output_tokens": total_output,
|
|
659
|
+
"total_sessions": len(sessions),
|
|
660
|
+
"total_cost_usd": round(sum(s.total_cost_usd for s in sessions), 2),
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
@staticmethod
|
|
664
|
+
def get_subscription_percentage(token_limit: int, billing_period_days: int = 30) -> dict:
|
|
665
|
+
"""
|
|
666
|
+
Calculate subscription usage percentage.
|
|
667
|
+
|
|
668
|
+
Args:
|
|
669
|
+
token_limit: Total tokens allowed in the subscription billing period
|
|
670
|
+
billing_period_days: Number of days in the billing period
|
|
671
|
+
|
|
672
|
+
Returns:
|
|
673
|
+
Dictionary with:
|
|
674
|
+
- percentage: Usage percentage (0-100+, can exceed 100 if over limit)
|
|
675
|
+
- used_tokens: Total tokens used
|
|
676
|
+
- remaining_tokens: Tokens remaining (can be negative if over)
|
|
677
|
+
- limit_tokens: The configured limit
|
|
678
|
+
- status: "ok", "warning", "critical", or "exceeded"
|
|
679
|
+
"""
|
|
680
|
+
if token_limit <= 0:
|
|
681
|
+
return {
|
|
682
|
+
"percentage": 0,
|
|
683
|
+
"used_tokens": 0,
|
|
684
|
+
"remaining_tokens": 0,
|
|
685
|
+
"limit_tokens": 0,
|
|
686
|
+
"status": "not_configured",
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
usage = CostTracker.get_subscription_usage(billing_period_days)
|
|
690
|
+
used_tokens = usage["total_tokens"]
|
|
691
|
+
percentage = (used_tokens / token_limit) * 100
|
|
692
|
+
remaining = token_limit - used_tokens
|
|
693
|
+
|
|
694
|
+
# Determine status
|
|
695
|
+
if percentage >= 100:
|
|
696
|
+
status = "exceeded"
|
|
697
|
+
elif percentage >= 90:
|
|
698
|
+
status = "critical"
|
|
699
|
+
elif percentage >= 75:
|
|
700
|
+
status = "warning"
|
|
701
|
+
else:
|
|
702
|
+
status = "ok"
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
"percentage": round(percentage, 2),
|
|
706
|
+
"used_tokens": used_tokens,
|
|
707
|
+
"remaining_tokens": remaining,
|
|
708
|
+
"limit_tokens": token_limit,
|
|
709
|
+
"status": status,
|
|
710
|
+
"billing_period_days": billing_period_days,
|
|
711
|
+
"total_sessions": usage["total_sessions"],
|
|
712
|
+
"total_cost_usd": usage["total_cost_usd"],
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
@staticmethod
|
|
716
|
+
def get_usage_projection(token_limit: int, billing_period_days: int = 30) -> dict:
|
|
717
|
+
"""
|
|
718
|
+
Calculate usage projection and forecast when limit will be reached.
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
token_limit: Total tokens allowed in the subscription billing period
|
|
722
|
+
billing_period_days: Number of days in the billing period
|
|
723
|
+
|
|
724
|
+
Returns:
|
|
725
|
+
Dictionary with projection data including days until limit reached.
|
|
726
|
+
"""
|
|
727
|
+
if token_limit <= 0:
|
|
728
|
+
return {
|
|
729
|
+
"daily_average": 0,
|
|
730
|
+
"days_until_limit": None,
|
|
731
|
+
"projected_end_usage": 0,
|
|
732
|
+
"on_track": True,
|
|
733
|
+
"message": "Subscription tracking not configured",
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
sessions = CostTracker.get_historical_sessions(days=billing_period_days)
|
|
737
|
+
if not sessions:
|
|
738
|
+
return {
|
|
739
|
+
"daily_average": 0,
|
|
740
|
+
"days_until_limit": None,
|
|
741
|
+
"projected_end_usage": 0,
|
|
742
|
+
"on_track": True,
|
|
743
|
+
"message": "No usage data available",
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
# Calculate tokens used and days elapsed
|
|
747
|
+
total_tokens = sum(s.total_tokens for s in sessions)
|
|
748
|
+
|
|
749
|
+
# Find the earliest session to calculate actual days of usage
|
|
750
|
+
earliest = min(datetime.fromisoformat(s.start_time) for s in sessions)
|
|
751
|
+
days_elapsed = max(1, (datetime.now() - earliest).days + 1)
|
|
752
|
+
|
|
753
|
+
# Daily average based on actual usage period
|
|
754
|
+
daily_average = total_tokens / days_elapsed
|
|
755
|
+
|
|
756
|
+
# Project to end of billing period
|
|
757
|
+
projected_end_usage = daily_average * billing_period_days
|
|
758
|
+
|
|
759
|
+
# Calculate days until limit reached
|
|
760
|
+
if daily_average > 0:
|
|
761
|
+
remaining_tokens = token_limit - total_tokens
|
|
762
|
+
days_until_limit = remaining_tokens / daily_average if remaining_tokens > 0 else 0
|
|
763
|
+
else:
|
|
764
|
+
days_until_limit = None
|
|
765
|
+
|
|
766
|
+
# Determine if on track to stay within limit
|
|
767
|
+
on_track = projected_end_usage <= token_limit
|
|
768
|
+
|
|
769
|
+
# Generate message
|
|
770
|
+
if days_until_limit is not None and days_until_limit <= 0:
|
|
771
|
+
message = "Limit already exceeded"
|
|
772
|
+
elif days_until_limit is not None and days_until_limit < 7:
|
|
773
|
+
message = f"At current rate, limit reached in {days_until_limit:.0f} days"
|
|
774
|
+
elif not on_track:
|
|
775
|
+
overage_pct = ((projected_end_usage / token_limit) - 1) * 100
|
|
776
|
+
message = f"Projected to exceed limit by {overage_pct:.0f}%"
|
|
777
|
+
else:
|
|
778
|
+
message = "On track to stay within limit"
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
"daily_average": round(daily_average),
|
|
782
|
+
"days_elapsed": days_elapsed,
|
|
783
|
+
"days_until_limit": round(days_until_limit, 1)
|
|
784
|
+
if days_until_limit is not None
|
|
785
|
+
else None,
|
|
786
|
+
"projected_end_usage": round(projected_end_usage),
|
|
787
|
+
"on_track": on_track,
|
|
788
|
+
"message": message,
|
|
789
|
+
"total_tokens": total_tokens,
|
|
790
|
+
"token_limit": token_limit,
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
@staticmethod
|
|
794
|
+
def get_model_efficiency() -> dict:
|
|
795
|
+
"""
|
|
796
|
+
Calculate model efficiency metrics (cost per output token).
|
|
797
|
+
|
|
798
|
+
Returns:
|
|
799
|
+
Dictionary with efficiency data per model.
|
|
800
|
+
"""
|
|
801
|
+
sessions = CostTracker.get_historical_sessions(days=30)
|
|
802
|
+
|
|
803
|
+
model_stats = {}
|
|
804
|
+
for session in sessions:
|
|
805
|
+
for entry in session.entries:
|
|
806
|
+
model = entry.model
|
|
807
|
+
if model not in model_stats:
|
|
808
|
+
model_stats[model] = {
|
|
809
|
+
"input_tokens": 0,
|
|
810
|
+
"output_tokens": 0,
|
|
811
|
+
"total_cost": 0,
|
|
812
|
+
"calls": 0,
|
|
813
|
+
}
|
|
814
|
+
model_stats[model]["input_tokens"] += entry.input_tokens
|
|
815
|
+
model_stats[model]["output_tokens"] += entry.output_tokens
|
|
816
|
+
model_stats[model]["total_cost"] += entry.cost_usd
|
|
817
|
+
model_stats[model]["calls"] += 1
|
|
818
|
+
|
|
819
|
+
# Calculate efficiency metrics
|
|
820
|
+
efficiency = {}
|
|
821
|
+
for model, stats in model_stats.items():
|
|
822
|
+
output_tokens = stats["output_tokens"]
|
|
823
|
+
total_cost = stats["total_cost"]
|
|
824
|
+
|
|
825
|
+
# Cost per 1K output tokens (the "value" metric)
|
|
826
|
+
cost_per_1k_output = (total_cost / output_tokens * 1000) if output_tokens > 0 else 0
|
|
827
|
+
|
|
828
|
+
# Output/Input ratio (how much output per input)
|
|
829
|
+
output_input_ratio = (
|
|
830
|
+
stats["output_tokens"] / stats["input_tokens"] if stats["input_tokens"] > 0 else 0
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
efficiency[model] = {
|
|
834
|
+
"cost_per_1k_output": round(cost_per_1k_output, 4),
|
|
835
|
+
"output_input_ratio": round(output_input_ratio, 2),
|
|
836
|
+
"total_cost": round(total_cost, 4),
|
|
837
|
+
"total_output_tokens": output_tokens,
|
|
838
|
+
"total_input_tokens": stats["input_tokens"],
|
|
839
|
+
"total_calls": stats["calls"],
|
|
840
|
+
"avg_output_per_call": round(output_tokens / stats["calls"])
|
|
841
|
+
if stats["calls"] > 0
|
|
842
|
+
else 0,
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
# Sort by cost efficiency (lowest cost per output token first)
|
|
846
|
+
sorted_efficiency = dict(
|
|
847
|
+
sorted(efficiency.items(), key=lambda x: x[1]["cost_per_1k_output"])
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
return sorted_efficiency
|
|
851
|
+
|
|
852
|
+
@staticmethod
|
|
853
|
+
def get_daily_usage(days: int = 30) -> list[dict]:
|
|
854
|
+
"""
|
|
855
|
+
Get daily token usage breakdown for trends.
|
|
856
|
+
|
|
857
|
+
Args:
|
|
858
|
+
days: Number of days to retrieve
|
|
859
|
+
|
|
860
|
+
Returns:
|
|
861
|
+
List of daily usage dictionaries sorted by date.
|
|
862
|
+
"""
|
|
863
|
+
sessions = CostTracker.get_historical_sessions(days=days)
|
|
864
|
+
|
|
865
|
+
daily = {}
|
|
866
|
+
for session in sessions:
|
|
867
|
+
date_str = session.start_time[:10]
|
|
868
|
+
if date_str not in daily:
|
|
869
|
+
daily[date_str] = {
|
|
870
|
+
"date": date_str,
|
|
871
|
+
"tokens": 0,
|
|
872
|
+
"input_tokens": 0,
|
|
873
|
+
"output_tokens": 0,
|
|
874
|
+
"cost_usd": 0,
|
|
875
|
+
"sessions": 0,
|
|
876
|
+
}
|
|
877
|
+
daily[date_str]["tokens"] += session.total_tokens
|
|
878
|
+
daily[date_str]["input_tokens"] += session.total_input_tokens
|
|
879
|
+
daily[date_str]["output_tokens"] += session.total_output_tokens
|
|
880
|
+
daily[date_str]["cost_usd"] += session.total_cost_usd
|
|
881
|
+
daily[date_str]["sessions"] += 1
|
|
882
|
+
|
|
883
|
+
# Sort by date
|
|
884
|
+
return sorted(daily.values(), key=lambda x: x["date"])
|
|
885
|
+
|
|
886
|
+
@staticmethod
|
|
887
|
+
def get_story_rankings(days: int = 30, limit: int = 10) -> list[dict]:
|
|
888
|
+
"""
|
|
889
|
+
Get stories ranked by token consumption.
|
|
890
|
+
|
|
891
|
+
Args:
|
|
892
|
+
days: Number of days to look back
|
|
893
|
+
limit: Maximum number of stories to return
|
|
894
|
+
|
|
895
|
+
Returns:
|
|
896
|
+
List of stories sorted by total tokens (descending).
|
|
897
|
+
"""
|
|
898
|
+
sessions = CostTracker.get_historical_sessions(days=days)
|
|
899
|
+
|
|
900
|
+
stories = {}
|
|
901
|
+
for session in sessions:
|
|
902
|
+
story = session.story_key
|
|
903
|
+
if story not in stories:
|
|
904
|
+
stories[story] = {
|
|
905
|
+
"story_key": story,
|
|
906
|
+
"total_tokens": 0,
|
|
907
|
+
"total_cost_usd": 0,
|
|
908
|
+
"sessions": 0,
|
|
909
|
+
}
|
|
910
|
+
stories[story]["total_tokens"] += session.total_tokens
|
|
911
|
+
stories[story]["total_cost_usd"] += session.total_cost_usd
|
|
912
|
+
stories[story]["sessions"] += 1
|
|
913
|
+
|
|
914
|
+
# Sort by tokens and limit
|
|
915
|
+
ranked = sorted(stories.values(), key=lambda x: -x["total_tokens"])
|
|
916
|
+
return ranked[:limit]
|
|
917
|
+
|
|
918
|
+
@staticmethod
|
|
919
|
+
def get_api_rate_stats(days: int = 7) -> dict:
|
|
920
|
+
"""
|
|
921
|
+
Get API call rate statistics.
|
|
922
|
+
|
|
923
|
+
Args:
|
|
924
|
+
days: Number of days to analyze
|
|
925
|
+
|
|
926
|
+
Returns:
|
|
927
|
+
Dictionary with call rate statistics.
|
|
928
|
+
"""
|
|
929
|
+
sessions = CostTracker.get_historical_sessions(days=days)
|
|
930
|
+
|
|
931
|
+
if not sessions:
|
|
932
|
+
return {
|
|
933
|
+
"total_calls": 0,
|
|
934
|
+
"calls_per_day": 0,
|
|
935
|
+
"calls_per_hour": 0,
|
|
936
|
+
"peak_hour": None,
|
|
937
|
+
"peak_day": None,
|
|
938
|
+
"hourly_distribution": {},
|
|
939
|
+
"daily_distribution": {},
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
# Collect all call timestamps
|
|
943
|
+
hourly = {} # hour -> count
|
|
944
|
+
daily = {} # date -> count
|
|
945
|
+
total_calls = 0
|
|
946
|
+
|
|
947
|
+
for session in sessions:
|
|
948
|
+
for entry in session.entries:
|
|
949
|
+
total_calls += 1
|
|
950
|
+
try:
|
|
951
|
+
ts = datetime.fromisoformat(entry.timestamp)
|
|
952
|
+
hour = ts.hour
|
|
953
|
+
date_str = ts.strftime("%Y-%m-%d")
|
|
954
|
+
|
|
955
|
+
hourly[hour] = hourly.get(hour, 0) + 1
|
|
956
|
+
daily[date_str] = daily.get(date_str, 0) + 1
|
|
957
|
+
except (ValueError, TypeError):
|
|
958
|
+
continue
|
|
959
|
+
|
|
960
|
+
# Calculate averages
|
|
961
|
+
actual_days = len(daily) if daily else 1
|
|
962
|
+
calls_per_day = total_calls / actual_days
|
|
963
|
+
calls_per_hour = total_calls / (actual_days * 24)
|
|
964
|
+
|
|
965
|
+
# Find peaks
|
|
966
|
+
peak_hour = max(hourly, key=hourly.get) if hourly else None
|
|
967
|
+
peak_day = max(daily, key=daily.get) if daily else None
|
|
968
|
+
|
|
969
|
+
return {
|
|
970
|
+
"total_calls": total_calls,
|
|
971
|
+
"calls_per_day": round(calls_per_day, 1),
|
|
972
|
+
"calls_per_hour": round(calls_per_hour, 2),
|
|
973
|
+
"peak_hour": peak_hour,
|
|
974
|
+
"peak_hour_calls": hourly.get(peak_hour, 0) if peak_hour is not None else 0,
|
|
975
|
+
"peak_day": peak_day,
|
|
976
|
+
"peak_day_calls": daily.get(peak_day, 0) if peak_day else 0,
|
|
977
|
+
"hourly_distribution": hourly,
|
|
978
|
+
"daily_distribution": daily,
|
|
979
|
+
"days_analyzed": actual_days,
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
@staticmethod
|
|
983
|
+
def get_period_comparison(current_days: int = 30) -> dict:
|
|
984
|
+
"""
|
|
985
|
+
Compare current period vs previous period.
|
|
986
|
+
|
|
987
|
+
Args:
|
|
988
|
+
current_days: Number of days in current period
|
|
989
|
+
|
|
990
|
+
Returns:
|
|
991
|
+
Dictionary with current and previous period stats and deltas.
|
|
992
|
+
"""
|
|
993
|
+
# Current period
|
|
994
|
+
current_sessions = CostTracker.get_historical_sessions(days=current_days)
|
|
995
|
+
current_tokens = sum(s.total_tokens for s in current_sessions)
|
|
996
|
+
current_cost = sum(s.total_cost_usd for s in current_sessions)
|
|
997
|
+
|
|
998
|
+
# Previous period (load sessions from current_days to 2*current_days ago)
|
|
999
|
+
all_sessions = CostTracker.get_historical_sessions(days=current_days * 2)
|
|
1000
|
+
cutoff = datetime.now().timestamp() - (current_days * 24 * 60 * 60)
|
|
1001
|
+
|
|
1002
|
+
previous_sessions = [
|
|
1003
|
+
s for s in all_sessions if datetime.fromisoformat(s.start_time).timestamp() < cutoff
|
|
1004
|
+
]
|
|
1005
|
+
previous_tokens = sum(s.total_tokens for s in previous_sessions)
|
|
1006
|
+
previous_cost = sum(s.total_cost_usd for s in previous_sessions)
|
|
1007
|
+
|
|
1008
|
+
# Calculate deltas
|
|
1009
|
+
token_delta = current_tokens - previous_tokens
|
|
1010
|
+
cost_delta = current_cost - previous_cost
|
|
1011
|
+
|
|
1012
|
+
token_delta_pct = (
|
|
1013
|
+
((current_tokens / previous_tokens) - 1) * 100
|
|
1014
|
+
if previous_tokens > 0
|
|
1015
|
+
else (100 if current_tokens > 0 else 0)
|
|
1016
|
+
)
|
|
1017
|
+
cost_delta_pct = (
|
|
1018
|
+
((current_cost / previous_cost) - 1) * 100
|
|
1019
|
+
if previous_cost > 0
|
|
1020
|
+
else (100 if current_cost > 0 else 0)
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
return {
|
|
1024
|
+
"current_period": {
|
|
1025
|
+
"days": current_days,
|
|
1026
|
+
"tokens": current_tokens,
|
|
1027
|
+
"cost_usd": round(current_cost, 2),
|
|
1028
|
+
"sessions": len(current_sessions),
|
|
1029
|
+
},
|
|
1030
|
+
"previous_period": {
|
|
1031
|
+
"days": current_days,
|
|
1032
|
+
"tokens": previous_tokens,
|
|
1033
|
+
"cost_usd": round(previous_cost, 2),
|
|
1034
|
+
"sessions": len(previous_sessions),
|
|
1035
|
+
},
|
|
1036
|
+
"delta": {
|
|
1037
|
+
"tokens": token_delta,
|
|
1038
|
+
"tokens_pct": round(token_delta_pct, 1),
|
|
1039
|
+
"cost_usd": round(cost_delta, 2),
|
|
1040
|
+
"cost_pct": round(cost_delta_pct, 1),
|
|
1041
|
+
},
|
|
1042
|
+
}
|
|
1043
|
+
|
|
595
1044
|
|
|
596
1045
|
def parse_token_usage(output: str) -> Optional[tuple[int, int]]:
|
|
597
1046
|
"""
|
|
@@ -611,8 +1060,6 @@ def parse_token_usage(output: str) -> Optional[tuple[int, int]]:
|
|
|
611
1060
|
the exact split. Returns None in this case to avoid inaccurate estimates.
|
|
612
1061
|
The caller should handle this appropriately.
|
|
613
1062
|
"""
|
|
614
|
-
import re
|
|
615
|
-
|
|
616
1063
|
# Pattern 1: Explicit input/output tokens (most accurate)
|
|
617
1064
|
input_match = re.search(r"input[_\s]*tokens?[:\s]+(\d+)", output, re.IGNORECASE)
|
|
618
1065
|
output_match = re.search(r"output[_\s]*tokens?[:\s]+(\d+)", output, re.IGNORECASE)
|
|
@@ -28,6 +28,8 @@ Exchange Rate Notice:
|
|
|
28
28
|
"""
|
|
29
29
|
|
|
30
30
|
import json
|
|
31
|
+
import os
|
|
32
|
+
import tempfile
|
|
31
33
|
import threading
|
|
32
34
|
from pathlib import Path
|
|
33
35
|
from typing import Optional
|
|
@@ -128,8 +130,10 @@ class CurrencyConverter:
|
|
|
128
130
|
if "display_currencies" in config:
|
|
129
131
|
self.display_currencies = config["display_currencies"]
|
|
130
132
|
|
|
131
|
-
except
|
|
132
|
-
print(f"Warning:
|
|
133
|
+
except json.JSONDecodeError as e:
|
|
134
|
+
print(f"Warning: Invalid JSON in currency config: {e}")
|
|
135
|
+
except OSError as e:
|
|
136
|
+
print(f"Warning: Could not read currency config: {e}")
|
|
133
137
|
|
|
134
138
|
def convert(self, amount_usd: float, currency: str) -> float:
|
|
135
139
|
"""
|
|
@@ -237,14 +241,33 @@ class CurrencyConverter:
|
|
|
237
241
|
]
|
|
238
242
|
|
|
239
243
|
def save_config(self, config_path: Path):
|
|
240
|
-
"""Save current rates to config file.
|
|
244
|
+
"""Save current rates to config file (atomic write).
|
|
245
|
+
|
|
246
|
+
Uses a temporary file and rename to prevent corruption on write failure.
|
|
247
|
+
"""
|
|
241
248
|
config = {
|
|
242
249
|
"currency_rates": self.rates,
|
|
243
250
|
"display_currencies": self.display_currencies,
|
|
244
251
|
}
|
|
245
252
|
|
|
246
|
-
|
|
247
|
-
|
|
253
|
+
# Ensure parent directory exists
|
|
254
|
+
config_path = Path(config_path)
|
|
255
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
|
|
257
|
+
# Write to temp file first, then atomic rename
|
|
258
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
259
|
+
suffix=".tmp", prefix=config_path.stem, dir=config_path.parent
|
|
260
|
+
)
|
|
261
|
+
try:
|
|
262
|
+
with os.fdopen(fd, "w") as f:
|
|
263
|
+
json.dump(config, f, indent=2)
|
|
264
|
+
# Atomic rename (works on POSIX, best-effort on Windows)
|
|
265
|
+
os.replace(tmp_path, config_path)
|
|
266
|
+
except Exception:
|
|
267
|
+
# Clean up temp file on failure
|
|
268
|
+
if os.path.exists(tmp_path):
|
|
269
|
+
os.unlink(tmp_path)
|
|
270
|
+
raise
|
|
248
271
|
|
|
249
272
|
|
|
250
273
|
# Thread-safe global converter storage
|