ai-control-center 1.15.2

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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +584 -0
  3. package/bin/aicc.js +772 -0
  4. package/lib/actions/approve.js +71 -0
  5. package/lib/actions/assign-project.js +132 -0
  6. package/lib/actions/browser-test.js +64 -0
  7. package/lib/actions/cleanup.js +174 -0
  8. package/lib/actions/debug.js +298 -0
  9. package/lib/actions/deploy.js +1229 -0
  10. package/lib/actions/fix-bug.js +134 -0
  11. package/lib/actions/new-feature.js +255 -0
  12. package/lib/actions/reject.js +307 -0
  13. package/lib/actions/review.js +706 -0
  14. package/lib/actions/status.js +47 -0
  15. package/lib/agents/browser-qa-agent.js +611 -0
  16. package/lib/agents/payment-agent.js +116 -0
  17. package/lib/agents/suggestion-agent.js +88 -0
  18. package/lib/cli.js +303 -0
  19. package/lib/config.js +243 -0
  20. package/lib/hub/hub-server.js +440 -0
  21. package/lib/hub/project-poller.js +75 -0
  22. package/lib/hub/skill-registry.js +89 -0
  23. package/lib/hub/state-aggregator.js +204 -0
  24. package/lib/index.js +471 -0
  25. package/lib/init/doctor.js +523 -0
  26. package/lib/init/presets.js +222 -0
  27. package/lib/init/skill-fetcher.js +77 -0
  28. package/lib/init/wizard.js +973 -0
  29. package/lib/integrations/codex-runner.js +128 -0
  30. package/lib/integrations/github-actions.js +248 -0
  31. package/lib/integrations/github-reporter.js +229 -0
  32. package/lib/integrations/screenshot-store.js +102 -0
  33. package/lib/openclaw/bridge.js +650 -0
  34. package/lib/openclaw/generate-skill.js +235 -0
  35. package/lib/openclaw/openclaw.json +64 -0
  36. package/lib/orchestrator/autonomous-loop.js +429 -0
  37. package/lib/orchestrator/thread-triggers.js +63 -0
  38. package/lib/roleplay/agent-messenger.js +75 -0
  39. package/lib/roleplay/discussion-threads.js +303 -0
  40. package/lib/roleplay/health-monitor.js +121 -0
  41. package/lib/roleplay/pm-agent.js +513 -0
  42. package/lib/roleplay/roleplay-config.js +25 -0
  43. package/lib/roleplay/room.js +164 -0
  44. package/lib/shared/action-runner.js +2330 -0
  45. package/lib/shared/event-bus.js +185 -0
  46. package/lib/slack/bot.js +378 -0
  47. package/lib/telegram/bot.js +416 -0
  48. package/lib/telegram/commands.js +1267 -0
  49. package/lib/telegram/keyboards.js +113 -0
  50. package/lib/telegram/notifications.js +247 -0
  51. package/lib/twitch/bot.js +354 -0
  52. package/lib/twitch/commands.js +302 -0
  53. package/lib/twitch/notifications.js +63 -0
  54. package/lib/utils/achievements.js +191 -0
  55. package/lib/utils/activity-log.js +182 -0
  56. package/lib/utils/agent-leaderboard.js +119 -0
  57. package/lib/utils/audit-logger.js +232 -0
  58. package/lib/utils/codebase-context.js +288 -0
  59. package/lib/utils/codebase-indexer.js +381 -0
  60. package/lib/utils/config-schema.js +230 -0
  61. package/lib/utils/context-compressor.js +172 -0
  62. package/lib/utils/correlation.js +63 -0
  63. package/lib/utils/cost-tracker.js +423 -0
  64. package/lib/utils/cron-scheduler.js +53 -0
  65. package/lib/utils/db-adapter.js +293 -0
  66. package/lib/utils/display.js +272 -0
  67. package/lib/utils/errors.js +116 -0
  68. package/lib/utils/format.js +134 -0
  69. package/lib/utils/intent-engine.js +464 -0
  70. package/lib/utils/mcp-client.js +238 -0
  71. package/lib/utils/model-ab-test.js +164 -0
  72. package/lib/utils/notify.js +122 -0
  73. package/lib/utils/persona-loader.js +80 -0
  74. package/lib/utils/pipeline-lock.js +73 -0
  75. package/lib/utils/pipeline.js +214 -0
  76. package/lib/utils/plugin-runner.js +234 -0
  77. package/lib/utils/rate-limiter.js +84 -0
  78. package/lib/utils/rbac.js +74 -0
  79. package/lib/utils/runner.js +1809 -0
  80. package/lib/utils/security.js +191 -0
  81. package/lib/utils/self-healer.js +144 -0
  82. package/lib/utils/skill-loader.js +255 -0
  83. package/lib/utils/spinner.js +132 -0
  84. package/lib/utils/stage-queue.js +50 -0
  85. package/lib/utils/state-machine.js +89 -0
  86. package/lib/utils/status-bar.js +327 -0
  87. package/lib/utils/token-estimator.js +101 -0
  88. package/lib/utils/ux-analyzer.js +101 -0
  89. package/lib/utils/webhook-emitter.js +83 -0
  90. package/lib/web/public/css/styles.css +417 -0
  91. package/lib/web/public/dark-mode.js +44 -0
  92. package/lib/web/public/hub/kanban.html +206 -0
  93. package/lib/web/public/index.html +45 -0
  94. package/lib/web/public/js/app.js +71 -0
  95. package/lib/web/public/js/ask.js +110 -0
  96. package/lib/web/public/js/dashboard.js +165 -0
  97. package/lib/web/public/js/deploy.js +72 -0
  98. package/lib/web/public/js/feature.js +79 -0
  99. package/lib/web/public/js/health.js +65 -0
  100. package/lib/web/public/js/logs.js +93 -0
  101. package/lib/web/public/js/review.js +123 -0
  102. package/lib/web/public/js/ws-client.js +82 -0
  103. package/lib/web/public/office/css/office.css +678 -0
  104. package/lib/web/public/office/index.html +148 -0
  105. package/lib/web/public/office/js/achievements-ui.js +117 -0
  106. package/lib/web/public/office/js/character.js +1056 -0
  107. package/lib/web/public/office/js/chat-bubbles.js +177 -0
  108. package/lib/web/public/office/js/cost-overlay.js +123 -0
  109. package/lib/web/public/office/js/day-night.js +68 -0
  110. package/lib/web/public/office/js/effects.js +632 -0
  111. package/lib/web/public/office/js/engine.js +146 -0
  112. package/lib/web/public/office/js/feature-ticket.js +216 -0
  113. package/lib/web/public/office/js/hub-client.js +60 -0
  114. package/lib/web/public/office/js/main.js +1757 -0
  115. package/lib/web/public/office/js/office-layout.js +1524 -0
  116. package/lib/web/public/office/js/pathfinding.js +144 -0
  117. package/lib/web/public/office/js/pixel-sprites.js +1454 -0
  118. package/lib/web/public/office/js/progress-bars.js +117 -0
  119. package/lib/web/public/office/js/replay.js +191 -0
  120. package/lib/web/public/office/js/sound-effects.js +91 -0
  121. package/lib/web/public/office/js/sprite-renderer.js +211 -0
  122. package/lib/web/public/office/js/stamina-system.js +89 -0
  123. package/lib/web/public/office/js/ui.js +107 -0
  124. package/lib/web/public/onboarding/index.html +243 -0
  125. package/lib/web/public/timeline/index.html +195 -0
  126. package/lib/web/routes/api.js +499 -0
  127. package/lib/web/routes/logs.js +20 -0
  128. package/lib/web/routes/metrics.js +99 -0
  129. package/lib/web/server.js +183 -0
  130. package/lib/web/ws/handler.js +65 -0
  131. package/package.json +67 -0
  132. package/templates/agent-architect.md +69 -0
  133. package/templates/agent-gemini-pm.md +49 -0
  134. package/templates/agent-gemini-reviewer.md +52 -0
  135. package/templates/copilot-instructions.md +36 -0
  136. package/templates/pipelines/mobile.json +27 -0
  137. package/templates/pipelines/nodejs-api.json +27 -0
  138. package/templates/pipelines/python.json +27 -0
  139. package/templates/pipelines/react.json +27 -0
  140. package/templates/pipelines/salesforce.json +27 -0
  141. package/templates/role-gemini.md +97 -0
  142. package/templates/skill-architect.md +114 -0
  143. package/templates/skill-browser-qa.md +50 -0
  144. package/templates/skill-bug-from-qa.md +58 -0
  145. package/templates/skill-chatbot.md +93 -0
  146. package/templates/skill-implement.md +78 -0
  147. package/templates/skill-openclaw.md +174 -0
  148. package/templates/skill-payment.md +110 -0
  149. package/templates/skill-pm-spec.md +77 -0
  150. package/templates/skill-requirement-capture.md +97 -0
  151. package/templates/skill-review.md +108 -0
  152. package/templates/skill-reviewer-qa.md +44 -0
  153. package/templates/skill-suggestion.md +45 -0
  154. package/templates/skill-template.md +142 -0
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Context Compressor — reduces prompt sizes for stateless AI calls (Gemini).
3
+ *
4
+ * Gemini is stateless — every call re-sends the full context. This module
5
+ * creates compact context digests per stage to avoid sending the full
6
+ * codebase tree, README, and git history every time.
7
+ *
8
+ * Cache is in-memory (Map) and resets on pipeline start.
9
+ */
10
+ import { execSync } from 'child_process';
11
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
12
+ import { resolve, relative, extname } from 'path';
13
+ import { getRootDir } from './pipeline.js';
14
+
15
+ // In-memory cache — keyed by featureId, reset on new pipeline
16
+ const _cache = new Map();
17
+
18
+ /**
19
+ * Reset the context cache (call when starting a new pipeline run).
20
+ */
21
+ export function resetContextCache() {
22
+ _cache.clear();
23
+ }
24
+
25
+ /**
26
+ * Get a cached value or compute and store it.
27
+ */
28
+ function cached(featureId, key, computeFn) {
29
+ const cacheKey = `${featureId}:${key}`;
30
+ if (_cache.has(cacheKey)) return _cache.get(cacheKey);
31
+ const value = computeFn();
32
+ _cache.set(cacheKey, value);
33
+ return value;
34
+ }
35
+
36
+ /**
37
+ * Generate a compact file tree listing (names + sizes only, no content).
38
+ */
39
+ function compactFileTree(root) {
40
+ const SKIP = new Set(['node_modules', '.git', '.ai-workflow', 'dist', 'build', 'coverage', '.next']);
41
+ const SOURCE_EXTS = new Set(['.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.java', '.cls', '.trigger', '.vue', '.svelte']);
42
+ const lines = [];
43
+
44
+ function walk(dir, depth = 0) {
45
+ if (depth > 4) return; // max 4 levels deep
46
+ try {
47
+ const entries = readdirSync(dir).sort();
48
+ for (const entry of entries) {
49
+ if (entry.startsWith('.') && depth === 0 && !entry.startsWith('.claude')) continue;
50
+ if (SKIP.has(entry)) continue;
51
+ const fullPath = resolve(dir, entry);
52
+ const stat = statSync(fullPath);
53
+ if (stat.isDirectory()) {
54
+ lines.push(`${' '.repeat(depth)}${entry}/`);
55
+ walk(fullPath, depth + 1);
56
+ } else if (SOURCE_EXTS.has(extname(entry))) {
57
+ const sizeKb = (stat.size / 1024).toFixed(1);
58
+ lines.push(`${' '.repeat(depth)}${entry} (${sizeKb}KB)`);
59
+ }
60
+ }
61
+ } catch { /* permission or read errors */ }
62
+ }
63
+
64
+ walk(root);
65
+ return lines.join('\n');
66
+ }
67
+
68
+ /**
69
+ * Get git diff for changed files only (for review stage).
70
+ */
71
+ function getGitDiff(root) {
72
+ try {
73
+ const diff = execSync('git diff HEAD~1 --stat --no-color 2>/dev/null || git diff --cached --stat --no-color 2>/dev/null || echo "No diff available"', {
74
+ cwd: root, encoding: 'utf8', maxBuffer: 50 * 1024,
75
+ });
76
+ return diff.trim();
77
+ } catch {
78
+ return 'No git diff available';
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Get the full diff content for changed files.
84
+ */
85
+ function getGitDiffContent(root) {
86
+ try {
87
+ const diff = execSync('git diff HEAD~1 --no-color 2>/dev/null || git diff --cached --no-color 2>/dev/null || echo "No diff available"', {
88
+ cwd: root, encoding: 'utf8', maxBuffer: 200 * 1024,
89
+ });
90
+ return diff.trim().slice(0, 50000); // cap at 50KB
91
+ } catch {
92
+ return 'No git diff available';
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Get list of relevant source files for a feature (no content, just paths).
98
+ */
99
+ function getRelevantFileList(root) {
100
+ try {
101
+ const files = execSync('git ls-files --others --cached --exclude-standard 2>/dev/null | head -100', {
102
+ cwd: root, encoding: 'utf8',
103
+ });
104
+ return files.trim();
105
+ } catch {
106
+ return compactFileTree(root);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Compress context for a specific pipeline stage.
112
+ *
113
+ * @param {string} stage - 'pm', 'review', 'arch', 'impl'
114
+ * @param {string} featureId - Current feature ID
115
+ * @param {string} fullContext - The original full context string
116
+ * @returns {string} Compressed context
117
+ */
118
+ export function compressForStage(stage, featureId, fullContext) {
119
+ const root = getRootDir();
120
+ const originalLength = fullContext.length;
121
+
122
+ let compressed;
123
+
124
+ switch (stage) {
125
+ case 'pm':
126
+ case 'spec': {
127
+ // PM spec: only include feature description + relevant file list (not full tree)
128
+ const fileList = cached(featureId, 'fileList', () => getRelevantFileList(root));
129
+ compressed = `## Project Structure (file list only)\n${fileList}\n\n` +
130
+ fullContext.replace(/## (Full |Complete )?(Codebase |Source )?(Tree|Directory|Structure)[\s\S]*?(?=## |$)/gi, '')
131
+ .replace(/## Git (History|Log)[\s\S]*?(?=## |$)/gi, '')
132
+ .replace(/## README[\s\S]*?(?=## |$)/gi, '');
133
+ break;
134
+ }
135
+
136
+ case 'review': {
137
+ // Review: only include changed files diff (not full codebase tree)
138
+ const diffStat = cached(featureId, 'diffStat', () => getGitDiff(root));
139
+ const diffContent = cached(featureId, 'diffContent', () => getGitDiffContent(root));
140
+ compressed = `## Changed Files\n${diffStat}\n\n## Diff\n${diffContent}\n\n` +
141
+ fullContext.replace(/## (Full |Complete )?(Codebase |Source )?(Tree|Directory|Structure)[\s\S]*?(?=## |$)/gi, '')
142
+ .replace(/## Git (History|Log)[\s\S]*?(?=## |$)/gi, '');
143
+ break;
144
+ }
145
+
146
+ case 'arch':
147
+ case 'architect': {
148
+ // Architecture: include compact tree but skip git history
149
+ const tree = cached(featureId, 'compactTree', () => compactFileTree(root));
150
+ compressed = `## Project Structure (compact)\n${tree}\n\n` +
151
+ fullContext.replace(/## Git (History|Log)[\s\S]*?(?=## |$)/gi, '');
152
+ break;
153
+ }
154
+
155
+ default:
156
+ compressed = fullContext;
157
+ }
158
+
159
+ // Remove excessive whitespace
160
+ compressed = compressed.replace(/\n{3,}/g, '\n\n').trim();
161
+
162
+ const compressedTokens = Math.ceil(compressed.length / 4);
163
+ const originalTokens = Math.ceil(originalLength / 4);
164
+ const reduction = originalLength > 0 ? Math.round((1 - compressed.length / originalLength) * 100) : 0;
165
+
166
+ if (reduction > 5) {
167
+ // Only log if meaningful reduction achieved
168
+ console.log(`[SYSTEM] Context compressed: ${originalTokens.toLocaleString()} → ${compressedTokens.toLocaleString()} tokens (${reduction}% reduction)`);
169
+ }
170
+
171
+ return compressed;
172
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Correlation ID system for distributed tracing.
3
+ */
4
+
5
+ import { randomUUID } from 'crypto';
6
+
7
+ let currentCorrelationId = null;
8
+
9
+ export function generateCorrelationId() {
10
+ return randomUUID();
11
+ }
12
+
13
+ export function withCorrelation(id, fn) {
14
+ const previous = currentCorrelationId;
15
+ currentCorrelationId = id;
16
+ try {
17
+ return fn();
18
+ } finally {
19
+ currentCorrelationId = previous;
20
+ }
21
+ }
22
+
23
+ export function getCorrelationId() {
24
+ return currentCorrelationId;
25
+ }
26
+
27
+ export function setCorrelationId(id) {
28
+ currentCorrelationId = id;
29
+ }
30
+
31
+ export function clearCorrelationId() {
32
+ currentCorrelationId = null;
33
+ }
34
+
35
+ export function correlatedLog(level, message, meta) {
36
+ const prefix = currentCorrelationId ? `[PIPELINE:${currentCorrelationId}]` : '[PIPELINE]';
37
+ const logFn = console[level] || console.log;
38
+ if (meta !== undefined) {
39
+ logFn(`${prefix} ${message}`, meta);
40
+ } else {
41
+ logFn(`${prefix} ${message}`);
42
+ }
43
+ }
44
+
45
+ export function buildTraceTimeline(correlationId, events) {
46
+ if (!events || events.length === 0) {
47
+ return { correlationId, startedAt: null, completedAt: null, durationMs: 0, stages: [] };
48
+ }
49
+
50
+ const sorted = [...events].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
51
+ const startedAt = sorted[0].timestamp;
52
+ const completedAt = sorted[sorted.length - 1].timestamp;
53
+ const durationMs = new Date(completedAt) - new Date(startedAt);
54
+
55
+ const stages = sorted.map((event) => ({
56
+ name: event.name || event.stage,
57
+ timestamp: event.timestamp,
58
+ duration: event.duration || null,
59
+ status: event.status || 'completed',
60
+ }));
61
+
62
+ return { correlationId, startedAt, completedAt, durationMs, stages };
63
+ }
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Cost Tracker — tracks AI usage and estimated costs per feature per provider.
3
+ *
4
+ * Stores usage data in .ai-workflow/costs.jsonl (append-only JSONL format).
5
+ * Each line is a JSON object with: timestamp, provider, model, stage, featureId,
6
+ * inputTokens, outputTokens, estimatedCost.
7
+ *
8
+ * Token estimation: since CLI tools don't always return token counts,
9
+ * we estimate from prompt length (Ć·4) and response length (Ć·4).
10
+ *
11
+ * Pricing is based on published per-1M-token rates (June 2025).
12
+ */
13
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'fs';
14
+ import { resolve } from 'path';
15
+ import { bus } from '../shared/event-bus.js';
16
+ import { ErrorCodes, TokenBudgetError } from './errors.js';
17
+ import { getWorkflowDir } from './pipeline.js';
18
+
19
+ // ─── Pricing Table (per 1M tokens, USD) ──────────────────────────────────────
20
+
21
+ const PRICING = {
22
+ // Gemini
23
+ 'gemini-2.5-pro': { input: 1.25, output: 10.00 },
24
+ 'gemini-2.5-flash': { input: 0.15, output: 0.60 },
25
+ 'gemini-2.0-flash': { input: 0.10, output: 0.40 },
26
+ // Claude
27
+ 'claude-sonnet-4-6': { input: 3.00, output: 15.00 },
28
+ 'claude-haiku-4-5-20251001': { input: 0.80, output: 4.00 },
29
+ // Copilot (GitHub-hosted — technically free/included in subscription,
30
+ // but we track "equivalent" cost for comparison)
31
+ 'claude-sonnet-4.6': { input: 3.00, output: 15.00 },
32
+ 'claude-haiku-4.5': { input: 0.80, output: 4.00 },
33
+ 'claude-opus-4.6': { input: 15.00, output: 75.00 },
34
+ // Ollama (local — zero cost but track usage)
35
+ 'ollama': { input: 0.00, output: 0.00 },
36
+ // OpenClaw (uses Copilot under the hood)
37
+ 'openclaw': { input: 0.00, output: 0.00 },
38
+ };
39
+
40
+ // Fallback pricing for unknown models
41
+ const DEFAULT_PRICING = { input: 1.00, output: 5.00 };
42
+
43
+ // ─── Core Functions ──────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Get the cost file path.
47
+ */
48
+ function getCostFile() {
49
+ const dir = getWorkflowDir();
50
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
51
+ return resolve(dir, 'costs.jsonl');
52
+ }
53
+
54
+ /**
55
+ * Estimate token count from text length.
56
+ * Rough approximation: 1 token ā‰ˆ 4 characters for English text.
57
+ */
58
+ function estimateTokens(text) {
59
+ if (!text) return 0;
60
+ return Math.ceil(String(text).length / 4);
61
+ }
62
+
63
+ /**
64
+ * Get pricing for a model.
65
+ */
66
+ function getModelPricing(model) {
67
+ if (!model) return DEFAULT_PRICING;
68
+ // Try exact match first
69
+ if (PRICING[model]) return PRICING[model];
70
+ // Try partial match (e.g. "gemini-2.5-pro-latest" → "gemini-2.5-pro")
71
+ const key = Object.keys(PRICING).find(k => model.includes(k) || k.includes(model));
72
+ return key ? PRICING[key] : DEFAULT_PRICING;
73
+ }
74
+
75
+ /**
76
+ * Calculate estimated cost for a usage entry.
77
+ */
78
+ function calculateCost(model, inputTokens, outputTokens) {
79
+ const pricing = getModelPricing(model);
80
+ const inputCost = (inputTokens / 1_000_000) * pricing.input;
81
+ const outputCost = (outputTokens / 1_000_000) * pricing.output;
82
+ return Math.round((inputCost + outputCost) * 1_000_000) / 1_000_000; // 6 decimal places
83
+ }
84
+
85
+ /**
86
+ * Track an AI usage event.
87
+ *
88
+ * @param {object} params
89
+ * @param {string} params.provider - 'gemini' | 'claude' | 'copilot' | 'ollama' | 'openclaw'
90
+ * @param {string} params.model - Specific model name
91
+ * @param {string} params.stage - Pipeline stage: 'pm' | 'architect' | 'implement' | 'review' | 'ask' | 'chat'
92
+ * @param {string} params.featureId - Feature identifier (optional)
93
+ * @param {number} params.inputTokens - Input token count (or estimated)
94
+ * @param {number} params.outputTokens - Output token count (or estimated)
95
+ * @param {string} params.promptText - Raw prompt text (for estimation if tokens not provided)
96
+ * @param {string} params.responseText - Raw response text (for estimation if tokens not provided)
97
+ * @param {number} params.durationMs - Call duration in milliseconds (optional)
98
+ */
99
+ export function trackUsage(params) {
100
+ try {
101
+ const {
102
+ provider = 'unknown',
103
+ model = 'unknown',
104
+ stage = 'unknown',
105
+ featureId = null,
106
+ promptText = '',
107
+ responseText = '',
108
+ durationMs = null,
109
+ } = params;
110
+
111
+ const inputTokens = params.inputTokens || estimateTokens(promptText);
112
+ const outputTokens = params.outputTokens || estimateTokens(responseText);
113
+ const estimatedCost = calculateCost(model, inputTokens, outputTokens);
114
+
115
+ const entry = {
116
+ ts: new Date().toISOString(),
117
+ provider,
118
+ model,
119
+ stage,
120
+ featureId,
121
+ inputTokens,
122
+ outputTokens,
123
+ estimatedCost,
124
+ durationMs,
125
+ };
126
+
127
+ const costFile = getCostFile();
128
+ appendFileSync(costFile, JSON.stringify(entry) + '\n', 'utf8');
129
+
130
+ return entry;
131
+ } catch {
132
+ // Non-fatal — never crash the pipeline for cost tracking
133
+ return null;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Read all cost entries from the JSONL file.
139
+ *
140
+ * @param {object} filters
141
+ * @param {string} filters.featureId - Filter by feature
142
+ * @param {string} filters.provider - Filter by provider
143
+ * @param {string} filters.stage - Filter by stage
144
+ * @param {number} filters.last - Only last N entries
145
+ * @returns {Array} Parsed cost entries
146
+ */
147
+ export function getCostEntries(filters = {}) {
148
+ try {
149
+ const costFile = getCostFile();
150
+ if (!existsSync(costFile)) return [];
151
+
152
+ let entries = readFileSync(costFile, 'utf8')
153
+ .split('\n')
154
+ .filter(Boolean)
155
+ .map(line => {
156
+ try { return JSON.parse(line); }
157
+ catch { return null; }
158
+ })
159
+ .filter(Boolean);
160
+
161
+ if (filters.featureId) entries = entries.filter(e => e.featureId === filters.featureId);
162
+ if (filters.provider) entries = entries.filter(e => e.provider === filters.provider);
163
+ if (filters.stage) entries = entries.filter(e => e.stage === filters.stage);
164
+ if (filters.last) entries = entries.slice(-filters.last);
165
+
166
+ return entries;
167
+ } catch {
168
+ return [];
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Get cost summary — total and per-provider breakdown.
174
+ *
175
+ * @param {string} featureId - Optional: get costs for a specific feature
176
+ * @returns {object} Summary with totals and per-provider/per-model breakdown
177
+ */
178
+ export function getCostSummary(featureId = null) {
179
+ const entries = getCostEntries(featureId ? { featureId } : {});
180
+
181
+ const summary = {
182
+ totalCost: 0,
183
+ totalInputTokens: 0,
184
+ totalOutputTokens: 0,
185
+ totalCalls: entries.length,
186
+ byProvider: {},
187
+ byModel: {},
188
+ byStage: {},
189
+ byFeature: {},
190
+ };
191
+
192
+ for (const entry of entries) {
193
+ summary.totalCost += entry.estimatedCost || 0;
194
+ summary.totalInputTokens += entry.inputTokens || 0;
195
+ summary.totalOutputTokens += entry.outputTokens || 0;
196
+
197
+ // By provider
198
+ if (!summary.byProvider[entry.provider]) {
199
+ summary.byProvider[entry.provider] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0 };
200
+ }
201
+ summary.byProvider[entry.provider].calls++;
202
+ summary.byProvider[entry.provider].cost += entry.estimatedCost || 0;
203
+ summary.byProvider[entry.provider].inputTokens += entry.inputTokens || 0;
204
+ summary.byProvider[entry.provider].outputTokens += entry.outputTokens || 0;
205
+
206
+ // By model
207
+ if (!summary.byModel[entry.model]) {
208
+ summary.byModel[entry.model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0 };
209
+ }
210
+ summary.byModel[entry.model].calls++;
211
+ summary.byModel[entry.model].cost += entry.estimatedCost || 0;
212
+ summary.byModel[entry.model].inputTokens += entry.inputTokens || 0;
213
+ summary.byModel[entry.model].outputTokens += entry.outputTokens || 0;
214
+
215
+ // By stage
216
+ if (!summary.byStage[entry.stage]) {
217
+ summary.byStage[entry.stage] = { calls: 0, cost: 0 };
218
+ }
219
+ summary.byStage[entry.stage].calls++;
220
+ summary.byStage[entry.stage].cost += entry.estimatedCost || 0;
221
+
222
+ // By feature (only if featureId present)
223
+ if (entry.featureId) {
224
+ if (!summary.byFeature[entry.featureId]) {
225
+ summary.byFeature[entry.featureId] = { calls: 0, cost: 0 };
226
+ }
227
+ summary.byFeature[entry.featureId].calls++;
228
+ summary.byFeature[entry.featureId].cost += entry.estimatedCost || 0;
229
+ }
230
+ }
231
+
232
+ // Round totals
233
+ summary.totalCost = Math.round(summary.totalCost * 1_000_000) / 1_000_000;
234
+ for (const p of Object.values(summary.byProvider)) p.cost = Math.round(p.cost * 1_000_000) / 1_000_000;
235
+ for (const m of Object.values(summary.byModel)) m.cost = Math.round(m.cost * 1_000_000) / 1_000_000;
236
+ for (const s of Object.values(summary.byStage)) s.cost = Math.round(s.cost * 1_000_000) / 1_000_000;
237
+ for (const f of Object.values(summary.byFeature)) f.cost = Math.round(f.cost * 1_000_000) / 1_000_000;
238
+
239
+ return summary;
240
+ }
241
+
242
+ /**
243
+ * Format cost summary for display (terminal or Telegram).
244
+ *
245
+ * @param {object} summary - From getCostSummary()
246
+ * @param {string} format - 'text' | 'html'
247
+ * @returns {string}
248
+ */
249
+ export function formatCostSummary(summary, format = 'text') {
250
+ if (!summary || summary.totalCalls === 0) {
251
+ return format === 'html'
252
+ ? '<i>No AI usage tracked yet.</i>'
253
+ : 'No AI usage tracked yet.';
254
+ }
255
+
256
+ if (format === 'html') {
257
+ let html = `šŸ’° <b>AI Cost Summary</b>\n\n`;
258
+ html += `Total: <b>$${summary.totalCost.toFixed(4)}</b> across ${summary.totalCalls} calls\n`;
259
+ html += `Tokens: ${summary.totalInputTokens.toLocaleString()} in / ${summary.totalOutputTokens.toLocaleString()} out\n\n`;
260
+
261
+ html += `<b>By Provider:</b>\n`;
262
+ for (const [provider, data] of Object.entries(summary.byProvider)) {
263
+ html += ` ${provider}: $${data.cost.toFixed(4)} (${data.calls} calls)\n`;
264
+ }
265
+
266
+ if (Object.keys(summary.byModel).length > 1) {
267
+ html += `\n<b>By Model:</b>\n`;
268
+ for (const [model, data] of Object.entries(summary.byModel)) {
269
+ html += ` ${model}: $${data.cost.toFixed(4)} (${data.calls} calls)\n`;
270
+ }
271
+ }
272
+
273
+ if (Object.keys(summary.byStage).length > 0) {
274
+ html += `\n<b>By Stage:</b>\n`;
275
+ for (const [stage, data] of Object.entries(summary.byStage)) {
276
+ html += ` ${stage}: $${data.cost.toFixed(4)} (${data.calls} calls)\n`;
277
+ }
278
+ }
279
+
280
+ return html;
281
+ }
282
+
283
+ // Plain text format
284
+ let text = `\nšŸ’° AI Cost Summary\n${'─'.repeat(40)}\n`;
285
+ text += `Total: $${summary.totalCost.toFixed(4)} across ${summary.totalCalls} calls\n`;
286
+ text += `Tokens: ${summary.totalInputTokens.toLocaleString()} in / ${summary.totalOutputTokens.toLocaleString()} out\n\n`;
287
+
288
+ text += `By Provider:\n`;
289
+ for (const [provider, data] of Object.entries(summary.byProvider)) {
290
+ text += ` ${provider.padEnd(10)} $${data.cost.toFixed(4).padStart(8)} (${data.calls} calls)\n`;
291
+ }
292
+
293
+ text += `\nBy Model:\n`;
294
+ for (const [model, data] of Object.entries(summary.byModel)) {
295
+ text += ` ${model.padEnd(30)} $${data.cost.toFixed(4).padStart(8)} (${data.calls} calls)\n`;
296
+ }
297
+
298
+ text += `\nBy Stage:\n`;
299
+ for (const [stage, data] of Object.entries(summary.byStage)) {
300
+ text += ` ${stage.padEnd(15)} $${data.cost.toFixed(4).padStart(8)} (${data.calls} calls)\n`;
301
+ }
302
+
303
+ return text;
304
+ }
305
+
306
+ // ─── Token Budget Per Pipeline Run ────────────────────────────────────────────
307
+
308
+ const DEFAULT_TOKEN_BUDGET = 100_000;
309
+ const DEFAULT_COST_BUDGET = 2.00;
310
+
311
+ /**
312
+ * Token budget tracker for a single pipeline run.
313
+ * Tracks cumulative token usage and emits warnings/events at thresholds.
314
+ */
315
+ export class TokenBudget {
316
+ constructor(featureId, options = {}) {
317
+ this.featureId = featureId;
318
+ this.maxTokens = options.maxTokens || DEFAULT_TOKEN_BUDGET;
319
+ this.maxCost = options.maxCost || DEFAULT_COST_BUDGET;
320
+ this.usedTokens = 0;
321
+ this.estimatedCost = 0;
322
+ this._warned80 = false;
323
+ this._exceeded = false;
324
+ this.savedByCheckpoints = 0;
325
+ }
326
+
327
+ /**
328
+ * Track token usage from a completed AI call.
329
+ */
330
+ track(inputTokens, outputTokens, cost = 0) {
331
+ const total = (inputTokens || 0) + (outputTokens || 0);
332
+ this.usedTokens += total;
333
+ this.estimatedCost += cost;
334
+
335
+ if (!this._warned80 && this.percentUsed() >= 80) {
336
+ this._warned80 = true;
337
+ const msg = `[COST] Token budget 80% used (${this.usedTokens.toLocaleString()}/${this.maxTokens.toLocaleString()}) for ${this.featureId}`;
338
+ console.log(msg);
339
+ try {
340
+ bus.emitEvent('token_budget_warning', {
341
+ featureId: this.featureId,
342
+ used: this.usedTokens,
343
+ limit: this.maxTokens,
344
+ percent: this.percentUsed(),
345
+ message: `āš ļø <b>Token budget 80% used</b>\n\n${this.usedTokens.toLocaleString()} / ${this.maxTokens.toLocaleString()} tokens for <code>${this.featureId}</code>\n\nEstimated cost: $${this.estimatedCost.toFixed(4)}`,
346
+ });
347
+ } catch { /* non-fatal */ }
348
+ }
349
+
350
+ if (!this._exceeded && this.isExceeded()) {
351
+ this._exceeded = true;
352
+ try {
353
+ bus.emitEvent('token_budget_exceeded', {
354
+ featureId: this.featureId,
355
+ used: this.usedTokens,
356
+ limit: this.maxTokens,
357
+ message: `šŸ›‘ <b>Token budget exceeded</b>\n\n${this.usedTokens.toLocaleString()} / ${this.maxTokens.toLocaleString()} tokens for <code>${this.featureId}</code>\n\nPipeline paused. Use /approve to continue or /reject to stop.`,
358
+ });
359
+ } catch { /* non-fatal */ }
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Record tokens saved by loading from checkpoint instead of calling AI.
365
+ */
366
+ trackCheckpointSaving(estimatedTokens) {
367
+ this.savedByCheckpoints += estimatedTokens;
368
+ }
369
+
370
+ remaining() {
371
+ return Math.max(0, this.maxTokens - this.usedTokens);
372
+ }
373
+
374
+ percentUsed() {
375
+ return Math.round((this.usedTokens / this.maxTokens) * 100);
376
+ }
377
+
378
+ isExceeded() {
379
+ return this.usedTokens >= this.maxTokens || this.estimatedCost >= this.maxCost;
380
+ }
381
+
382
+ toJSON() {
383
+ return {
384
+ used: this.usedTokens,
385
+ limit: this.maxTokens,
386
+ percent: this.percentUsed(),
387
+ estimatedCost: Math.round(this.estimatedCost * 1_000_000) / 1_000_000,
388
+ maxCost: this.maxCost,
389
+ remaining: this.remaining(),
390
+ exceeded: this.isExceeded(),
391
+ savedByCheckpoints: this.savedByCheckpoints,
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Format budget as a visual gauge for display.
397
+ */
398
+ formatGauge() {
399
+ const pct = this.percentUsed();
400
+ const filled = Math.round(pct / 10);
401
+ const empty = 10 - filled;
402
+ const bar = '='.repeat(filled) + (filled < 10 ? '>' : '') + ' '.repeat(Math.max(0, empty - 1));
403
+ return `[${bar}] ${pct}% ($${this.estimatedCost.toFixed(2)} / $${this.maxCost.toFixed(2)})`;
404
+ }
405
+ }
406
+
407
+ // Active budget instance per feature
408
+ const _activeBudgets = new Map();
409
+
410
+ export function getOrCreateBudget(featureId, config = {}) {
411
+ if (!_activeBudgets.has(featureId)) {
412
+ _activeBudgets.set(featureId, new TokenBudget(featureId, config));
413
+ }
414
+ return _activeBudgets.get(featureId);
415
+ }
416
+
417
+ export function resetBudget(featureId) {
418
+ _activeBudgets.delete(featureId);
419
+ }
420
+
421
+ export function getActiveBudget(featureId) {
422
+ return _activeBudgets.get(featureId) || null;
423
+ }