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.
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/bin/aicc.js +772 -0
- package/lib/actions/approve.js +71 -0
- package/lib/actions/assign-project.js +132 -0
- package/lib/actions/browser-test.js +64 -0
- package/lib/actions/cleanup.js +174 -0
- package/lib/actions/debug.js +298 -0
- package/lib/actions/deploy.js +1229 -0
- package/lib/actions/fix-bug.js +134 -0
- package/lib/actions/new-feature.js +255 -0
- package/lib/actions/reject.js +307 -0
- package/lib/actions/review.js +706 -0
- package/lib/actions/status.js +47 -0
- package/lib/agents/browser-qa-agent.js +611 -0
- package/lib/agents/payment-agent.js +116 -0
- package/lib/agents/suggestion-agent.js +88 -0
- package/lib/cli.js +303 -0
- package/lib/config.js +243 -0
- package/lib/hub/hub-server.js +440 -0
- package/lib/hub/project-poller.js +75 -0
- package/lib/hub/skill-registry.js +89 -0
- package/lib/hub/state-aggregator.js +204 -0
- package/lib/index.js +471 -0
- package/lib/init/doctor.js +523 -0
- package/lib/init/presets.js +222 -0
- package/lib/init/skill-fetcher.js +77 -0
- package/lib/init/wizard.js +973 -0
- package/lib/integrations/codex-runner.js +128 -0
- package/lib/integrations/github-actions.js +248 -0
- package/lib/integrations/github-reporter.js +229 -0
- package/lib/integrations/screenshot-store.js +102 -0
- package/lib/openclaw/bridge.js +650 -0
- package/lib/openclaw/generate-skill.js +235 -0
- package/lib/openclaw/openclaw.json +64 -0
- package/lib/orchestrator/autonomous-loop.js +429 -0
- package/lib/orchestrator/thread-triggers.js +63 -0
- package/lib/roleplay/agent-messenger.js +75 -0
- package/lib/roleplay/discussion-threads.js +303 -0
- package/lib/roleplay/health-monitor.js +121 -0
- package/lib/roleplay/pm-agent.js +513 -0
- package/lib/roleplay/roleplay-config.js +25 -0
- package/lib/roleplay/room.js +164 -0
- package/lib/shared/action-runner.js +2330 -0
- package/lib/shared/event-bus.js +185 -0
- package/lib/slack/bot.js +378 -0
- package/lib/telegram/bot.js +416 -0
- package/lib/telegram/commands.js +1267 -0
- package/lib/telegram/keyboards.js +113 -0
- package/lib/telegram/notifications.js +247 -0
- package/lib/twitch/bot.js +354 -0
- package/lib/twitch/commands.js +302 -0
- package/lib/twitch/notifications.js +63 -0
- package/lib/utils/achievements.js +191 -0
- package/lib/utils/activity-log.js +182 -0
- package/lib/utils/agent-leaderboard.js +119 -0
- package/lib/utils/audit-logger.js +232 -0
- package/lib/utils/codebase-context.js +288 -0
- package/lib/utils/codebase-indexer.js +381 -0
- package/lib/utils/config-schema.js +230 -0
- package/lib/utils/context-compressor.js +172 -0
- package/lib/utils/correlation.js +63 -0
- package/lib/utils/cost-tracker.js +423 -0
- package/lib/utils/cron-scheduler.js +53 -0
- package/lib/utils/db-adapter.js +293 -0
- package/lib/utils/display.js +272 -0
- package/lib/utils/errors.js +116 -0
- package/lib/utils/format.js +134 -0
- package/lib/utils/intent-engine.js +464 -0
- package/lib/utils/mcp-client.js +238 -0
- package/lib/utils/model-ab-test.js +164 -0
- package/lib/utils/notify.js +122 -0
- package/lib/utils/persona-loader.js +80 -0
- package/lib/utils/pipeline-lock.js +73 -0
- package/lib/utils/pipeline.js +214 -0
- package/lib/utils/plugin-runner.js +234 -0
- package/lib/utils/rate-limiter.js +84 -0
- package/lib/utils/rbac.js +74 -0
- package/lib/utils/runner.js +1809 -0
- package/lib/utils/security.js +191 -0
- package/lib/utils/self-healer.js +144 -0
- package/lib/utils/skill-loader.js +255 -0
- package/lib/utils/spinner.js +132 -0
- package/lib/utils/stage-queue.js +50 -0
- package/lib/utils/state-machine.js +89 -0
- package/lib/utils/status-bar.js +327 -0
- package/lib/utils/token-estimator.js +101 -0
- package/lib/utils/ux-analyzer.js +101 -0
- package/lib/utils/webhook-emitter.js +83 -0
- package/lib/web/public/css/styles.css +417 -0
- package/lib/web/public/dark-mode.js +44 -0
- package/lib/web/public/hub/kanban.html +206 -0
- package/lib/web/public/index.html +45 -0
- package/lib/web/public/js/app.js +71 -0
- package/lib/web/public/js/ask.js +110 -0
- package/lib/web/public/js/dashboard.js +165 -0
- package/lib/web/public/js/deploy.js +72 -0
- package/lib/web/public/js/feature.js +79 -0
- package/lib/web/public/js/health.js +65 -0
- package/lib/web/public/js/logs.js +93 -0
- package/lib/web/public/js/review.js +123 -0
- package/lib/web/public/js/ws-client.js +82 -0
- package/lib/web/public/office/css/office.css +678 -0
- package/lib/web/public/office/index.html +148 -0
- package/lib/web/public/office/js/achievements-ui.js +117 -0
- package/lib/web/public/office/js/character.js +1056 -0
- package/lib/web/public/office/js/chat-bubbles.js +177 -0
- package/lib/web/public/office/js/cost-overlay.js +123 -0
- package/lib/web/public/office/js/day-night.js +68 -0
- package/lib/web/public/office/js/effects.js +632 -0
- package/lib/web/public/office/js/engine.js +146 -0
- package/lib/web/public/office/js/feature-ticket.js +216 -0
- package/lib/web/public/office/js/hub-client.js +60 -0
- package/lib/web/public/office/js/main.js +1757 -0
- package/lib/web/public/office/js/office-layout.js +1524 -0
- package/lib/web/public/office/js/pathfinding.js +144 -0
- package/lib/web/public/office/js/pixel-sprites.js +1454 -0
- package/lib/web/public/office/js/progress-bars.js +117 -0
- package/lib/web/public/office/js/replay.js +191 -0
- package/lib/web/public/office/js/sound-effects.js +91 -0
- package/lib/web/public/office/js/sprite-renderer.js +211 -0
- package/lib/web/public/office/js/stamina-system.js +89 -0
- package/lib/web/public/office/js/ui.js +107 -0
- package/lib/web/public/onboarding/index.html +243 -0
- package/lib/web/public/timeline/index.html +195 -0
- package/lib/web/routes/api.js +499 -0
- package/lib/web/routes/logs.js +20 -0
- package/lib/web/routes/metrics.js +99 -0
- package/lib/web/server.js +183 -0
- package/lib/web/ws/handler.js +65 -0
- package/package.json +67 -0
- package/templates/agent-architect.md +69 -0
- package/templates/agent-gemini-pm.md +49 -0
- package/templates/agent-gemini-reviewer.md +52 -0
- package/templates/copilot-instructions.md +36 -0
- package/templates/pipelines/mobile.json +27 -0
- package/templates/pipelines/nodejs-api.json +27 -0
- package/templates/pipelines/python.json +27 -0
- package/templates/pipelines/react.json +27 -0
- package/templates/pipelines/salesforce.json +27 -0
- package/templates/role-gemini.md +97 -0
- package/templates/skill-architect.md +114 -0
- package/templates/skill-browser-qa.md +50 -0
- package/templates/skill-bug-from-qa.md +58 -0
- package/templates/skill-chatbot.md +93 -0
- package/templates/skill-implement.md +78 -0
- package/templates/skill-openclaw.md +174 -0
- package/templates/skill-payment.md +110 -0
- package/templates/skill-pm-spec.md +77 -0
- package/templates/skill-requirement-capture.md +97 -0
- package/templates/skill-review.md +108 -0
- package/templates/skill-reviewer-qa.md +44 -0
- package/templates/skill-suggestion.md +45 -0
- package/templates/skill-template.md +142 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twitch push notifications — sends channel messages on pipeline events.
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to the pipeline event bus and posts important status changes
|
|
5
|
+
* as chat messages in the configured Twitch channel.
|
|
6
|
+
*/
|
|
7
|
+
import { truncate } from './bot.js';
|
|
8
|
+
|
|
9
|
+
// Debounce: avoid duplicate notifications on rapid status.json writes
|
|
10
|
+
let lastNotifiedStage = null;
|
|
11
|
+
let lastNotifyTime = 0;
|
|
12
|
+
const DEBOUNCE_MS = 3000;
|
|
13
|
+
|
|
14
|
+
export function setupNotifications(bot, bus) {
|
|
15
|
+
// Pipeline events (explicit actions triggered by aicc commands)
|
|
16
|
+
bus.on('pipeline-event', ({ event, data }) => {
|
|
17
|
+
let msg = data.message || null;
|
|
18
|
+
|
|
19
|
+
if (!msg) {
|
|
20
|
+
const messages = {
|
|
21
|
+
feature_created: `New feature started: ${data.description?.slice(0, 60) || data.feature}`,
|
|
22
|
+
feature_approved: `Feature approved: ${data.feature}`,
|
|
23
|
+
feature_rejected: `Feature rejected: ${data.reason?.slice(0, 60) || 'no reason'}`,
|
|
24
|
+
deploy_success: `Deploy successful!`,
|
|
25
|
+
deploy_failed: `Deploy failed: ${data.error?.slice(0, 80) || 'unknown error'}`,
|
|
26
|
+
review_complete: `Code review complete for ${data.feature}`,
|
|
27
|
+
tasks_ready: `Tasks ready — ready to implement.`,
|
|
28
|
+
};
|
|
29
|
+
msg = messages[event] || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (msg) {
|
|
33
|
+
bot.say(truncate(msg));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Status changes (from file watcher on status.json)
|
|
38
|
+
bus.on('status', ({ status, previousStage }) => {
|
|
39
|
+
if (!status.stage || status.stage === previousStage) return;
|
|
40
|
+
if (status.stage === lastNotifiedStage && Date.now() - lastNotifyTime < DEBOUNCE_MS) return;
|
|
41
|
+
|
|
42
|
+
lastNotifiedStage = status.stage;
|
|
43
|
+
lastNotifyTime = Date.now();
|
|
44
|
+
|
|
45
|
+
const feature = status.current_feature ? ` [${status.current_feature}]` : '';
|
|
46
|
+
|
|
47
|
+
const stageMessages = {
|
|
48
|
+
spec_complete: `Spec complete${feature}`,
|
|
49
|
+
arch_complete: `Architecture complete — tasks ready${feature}`,
|
|
50
|
+
implementation_complete: `Implementation complete${feature} — use !review`,
|
|
51
|
+
implementation_failed: `Implementation failed${feature}: ${(status.error || '').slice(0, 80)}`,
|
|
52
|
+
review_complete: `Review complete${feature} — use !approve or !reject`,
|
|
53
|
+
approved: `Feature approved${feature} — use !deploy`,
|
|
54
|
+
rejected: `Feature rejected${feature} — fixes needed`,
|
|
55
|
+
deployed: `Deployment complete!${feature}`,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const msg = stageMessages[status.stage];
|
|
59
|
+
if (msg) {
|
|
60
|
+
bot.say(truncate(msg));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
const STATS_DIR = '.ai-workflow';
|
|
5
|
+
const STATS_FILE = join(STATS_DIR, 'stats.json');
|
|
6
|
+
const ACHIEVEMENTS_FILE = join(STATS_DIR, 'achievements.json');
|
|
7
|
+
|
|
8
|
+
export const ACHIEVEMENTS = {
|
|
9
|
+
FIRST_FEATURE: { id: 'first-feature', name: 'First Steps', emoji: '🎯', description: 'Submit your first feature', condition: (stats) => stats.features >= 1 },
|
|
10
|
+
TEN_FEATURES: { id: 'ten-features', name: 'Feature Factory', emoji: '🏭', description: 'Submit 10 features', condition: (stats) => stats.features >= 10 },
|
|
11
|
+
FIFTY_FEATURES: { id: 'fifty-features', name: 'Feature Machine', emoji: '⚡', description: 'Submit 50 features', condition: (stats) => stats.features >= 50 },
|
|
12
|
+
FIRST_DEPLOY: { id: 'first-deploy', name: 'Ship It!', emoji: '🚀', description: 'Complete your first deploy', condition: (stats) => stats.deploys >= 1 },
|
|
13
|
+
FIRST_REVIEW: { id: 'first-review', name: 'Code Inspector', emoji: '🔍', description: 'Complete your first review', condition: (stats) => stats.reviews >= 1 },
|
|
14
|
+
COST_SAVER: { id: 'cost-saver', name: 'Budget Hawk', emoji: '💰', description: 'Complete a feature under $0.10', condition: (stats) => stats.cheapestFeature < 0.10 },
|
|
15
|
+
SPEED_DEMON: { id: 'speed-demon', name: 'Speed Demon', emoji: '⏱️', description: 'Complete a feature in under 60 seconds', condition: (stats) => stats.fastestFeature < 60000 },
|
|
16
|
+
NIGHT_OWL: { id: 'night-owl', name: 'Night Owl', emoji: '🦉', description: 'Run a pipeline after midnight', condition: (stats) => stats.nightRuns >= 1 },
|
|
17
|
+
MULTI_MODEL: { id: 'multi-model', name: 'Model Mixer', emoji: '🧪', description: 'Use 3 different AI models', condition: (stats) => stats.modelsUsed >= 3 },
|
|
18
|
+
STREAK_3: { id: 'streak-3', name: 'On a Roll', emoji: '🔥', description: '3 successful pipelines in a row', condition: (stats) => stats.currentStreak >= 3 },
|
|
19
|
+
STREAK_10: { id: 'streak-10', name: 'Unstoppable', emoji: '💎', description: '10 successful pipelines in a row', condition: (stats) => stats.currentStreak >= 10 },
|
|
20
|
+
HUNDRED_PIPELINES: { id: 'hundred-pipelines', name: 'Centurion', emoji: '🏛️', description: 'Run 100 pipelines', condition: (stats) => stats.totalPipelines >= 100 },
|
|
21
|
+
PERFECT_REVIEW: { id: 'perfect-review', name: 'Perfectionist', emoji: '✨', description: 'Get a review with no issues', condition: (stats) => stats.perfectReviews >= 1 },
|
|
22
|
+
TEAM_PLAYER: { id: 'team-player', name: 'Team Player', emoji: '🤝', description: 'Use webhooks or Slack integration', condition: (stats) => stats.integrationsUsed >= 1 },
|
|
23
|
+
CUSTOMIZER: { id: 'customizer', name: 'Make It Yours', emoji: '🎨', description: 'Create a custom persona', condition: (stats) => stats.customPersonas >= 1 },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function getDefaultStats() {
|
|
27
|
+
return {
|
|
28
|
+
features: 0,
|
|
29
|
+
deploys: 0,
|
|
30
|
+
reviews: 0,
|
|
31
|
+
totalPipelines: 0,
|
|
32
|
+
currentStreak: 0,
|
|
33
|
+
bestStreak: 0,
|
|
34
|
+
cheapestFeature: Infinity,
|
|
35
|
+
fastestFeature: Infinity,
|
|
36
|
+
nightRuns: 0,
|
|
37
|
+
modelsUsed: 0,
|
|
38
|
+
modelsSet: [],
|
|
39
|
+
perfectReviews: 0,
|
|
40
|
+
integrationsUsed: 0,
|
|
41
|
+
customPersonas: 0,
|
|
42
|
+
totalCost: 0,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getStats() {
|
|
47
|
+
if (!existsSync(STATS_DIR)) {
|
|
48
|
+
mkdirSync(STATS_DIR, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
if (!existsSync(STATS_FILE)) {
|
|
51
|
+
const defaults = getDefaultStats();
|
|
52
|
+
writeFileSync(STATS_FILE, JSON.stringify(defaults, null, 2));
|
|
53
|
+
return defaults;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(readFileSync(STATS_FILE, 'utf-8'));
|
|
57
|
+
} catch {
|
|
58
|
+
return getDefaultStats();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function updateStats(update) {
|
|
63
|
+
const stats = getStats();
|
|
64
|
+
Object.assign(stats, update);
|
|
65
|
+
if (!existsSync(STATS_DIR)) {
|
|
66
|
+
mkdirSync(STATS_DIR, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
writeFileSync(STATS_FILE, JSON.stringify(stats, null, 2));
|
|
69
|
+
return stats;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function checkAchievements(stats) {
|
|
73
|
+
const unlocked = getUnlockedAchievements();
|
|
74
|
+
const newlyUnlocked = [];
|
|
75
|
+
for (const achievement of Object.values(ACHIEVEMENTS)) {
|
|
76
|
+
if (!unlocked.includes(achievement.id) && achievement.condition(stats)) {
|
|
77
|
+
newlyUnlocked.push(achievement);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return newlyUnlocked;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getUnlockedAchievements() {
|
|
84
|
+
if (!existsSync(ACHIEVEMENTS_FILE)) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const data = JSON.parse(readFileSync(ACHIEVEMENTS_FILE, 'utf-8'));
|
|
89
|
+
return data.map((a) => a.id);
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function unlockAchievement(achievementId) {
|
|
96
|
+
if (!existsSync(STATS_DIR)) {
|
|
97
|
+
mkdirSync(STATS_DIR, { recursive: true });
|
|
98
|
+
}
|
|
99
|
+
let achievements = [];
|
|
100
|
+
if (existsSync(ACHIEVEMENTS_FILE)) {
|
|
101
|
+
try {
|
|
102
|
+
achievements = JSON.parse(readFileSync(ACHIEVEMENTS_FILE, 'utf-8'));
|
|
103
|
+
} catch {
|
|
104
|
+
achievements = [];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!achievements.some((a) => a.id === achievementId)) {
|
|
108
|
+
achievements.push({ id: achievementId, unlockedAt: new Date().toISOString() });
|
|
109
|
+
writeFileSync(ACHIEVEMENTS_FILE, JSON.stringify(achievements, null, 2));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function formatAchievementNotification(achievement) {
|
|
114
|
+
return `🏆 Achievement Unlocked: ${achievement.emoji} ${achievement.name} - ${achievement.description}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function formatAchievementList(unlockedIds) {
|
|
118
|
+
const lines = ['🏆 Achievements\n'];
|
|
119
|
+
for (const achievement of Object.values(ACHIEVEMENTS)) {
|
|
120
|
+
const isUnlocked = unlockedIds.includes(achievement.id);
|
|
121
|
+
const status = isUnlocked ? '✅' : '🔒';
|
|
122
|
+
const display = isUnlocked
|
|
123
|
+
? `${status} ${achievement.emoji} ${achievement.name} - ${achievement.description}`
|
|
124
|
+
: `${status} ??? - ${achievement.description}`;
|
|
125
|
+
lines.push(display);
|
|
126
|
+
}
|
|
127
|
+
const total = Object.values(ACHIEVEMENTS).length;
|
|
128
|
+
const unlocked = unlockedIds.length;
|
|
129
|
+
lines.push(`\n${unlocked}/${total} Achievements Unlocked`);
|
|
130
|
+
return lines.join('\n');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function trackPipelineCompletion(result) {
|
|
134
|
+
const { action, duration, cost, model, success, reviewIssues } = result;
|
|
135
|
+
const stats = getStats();
|
|
136
|
+
|
|
137
|
+
stats.totalPipelines += 1;
|
|
138
|
+
|
|
139
|
+
if (action === 'feature' || action === 'implement') {
|
|
140
|
+
stats.features += 1;
|
|
141
|
+
}
|
|
142
|
+
if (action === 'deploy') {
|
|
143
|
+
stats.deploys += 1;
|
|
144
|
+
}
|
|
145
|
+
if (action === 'review') {
|
|
146
|
+
stats.reviews += 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (success) {
|
|
150
|
+
stats.currentStreak += 1;
|
|
151
|
+
if (stats.currentStreak > stats.bestStreak) {
|
|
152
|
+
stats.bestStreak = stats.currentStreak;
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
stats.currentStreak = 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (cost !== undefined && cost < stats.cheapestFeature) {
|
|
159
|
+
stats.cheapestFeature = cost;
|
|
160
|
+
}
|
|
161
|
+
if (duration !== undefined && duration < stats.fastestFeature) {
|
|
162
|
+
stats.fastestFeature = duration;
|
|
163
|
+
}
|
|
164
|
+
if (cost !== undefined) {
|
|
165
|
+
stats.totalCost += cost;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const hour = new Date().getHours();
|
|
169
|
+
if (hour >= 0 && hour < 6) {
|
|
170
|
+
stats.nightRuns += 1;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (model && !stats.modelsSet.includes(model)) {
|
|
174
|
+
stats.modelsSet.push(model);
|
|
175
|
+
stats.modelsUsed = stats.modelsSet.length;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (reviewIssues === 0) {
|
|
179
|
+
stats.perfectReviews += 1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
updateStats(stats);
|
|
183
|
+
|
|
184
|
+
const newAchievements = checkAchievements(stats);
|
|
185
|
+
const notifications = [];
|
|
186
|
+
for (const achievement of newAchievements) {
|
|
187
|
+
unlockAchievement(achievement.id);
|
|
188
|
+
notifications.push(formatAchievementNotification(achievement));
|
|
189
|
+
}
|
|
190
|
+
return notifications;
|
|
191
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { appendFileSync, mkdirSync, existsSync } from 'fs';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { getConfig } from '../config.js';
|
|
5
|
+
import { spinner } from './spinner.js';
|
|
6
|
+
import { statusBar } from './status-bar.js';
|
|
7
|
+
|
|
8
|
+
// All labels exactly 8 ASCII chars — perfect column alignment guaranteed.
|
|
9
|
+
// NO emoji — emoji are double-width in terminals and break alignment.
|
|
10
|
+
const AI_STYLES = {
|
|
11
|
+
CLAUDE: { color: 'magenta', label: 'CLAUDE ' },
|
|
12
|
+
GEMINI: { color: 'cyan', label: 'GEMINI ' },
|
|
13
|
+
COPILOT: { color: 'blue', label: 'COPILOT ' },
|
|
14
|
+
PIPELINE: { color: 'yellow', label: 'PIPELINE' },
|
|
15
|
+
SYSTEM: { color: 'white', label: 'SYSTEM ' },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Single-width chars only — no emoji
|
|
19
|
+
const STATUS_CHAR = {
|
|
20
|
+
success: chalk.green('✓'),
|
|
21
|
+
error: chalk.red('✗'),
|
|
22
|
+
warn: chalk.yellow('~'),
|
|
23
|
+
info: chalk.dim('·'),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const STATUS_TEXT = {
|
|
27
|
+
success: 'SUCCESS',
|
|
28
|
+
error: 'ERROR ',
|
|
29
|
+
warn: 'WARN ',
|
|
30
|
+
info: 'INFO ',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ─── Session Log File ─────────────────────────────────────────────────────────
|
|
34
|
+
// Captures ALL agent output to a plain-text file, including lines suppressed
|
|
35
|
+
// by the terminal spinner. Use to audit what each AI actually did.
|
|
36
|
+
|
|
37
|
+
let _logFile = null; // absolute path to current session log
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Strip ANSI escape codes for clean plain-text log files.
|
|
41
|
+
*/
|
|
42
|
+
function stripAnsi(str) {
|
|
43
|
+
// eslint-disable-next-line no-control-regex
|
|
44
|
+
return str.replace(/\x1B\[[0-9;]*[mGKHF]/g, '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Write a line to the session log file (plain text, no ANSI).
|
|
49
|
+
* Never throws — logging must not break the main flow.
|
|
50
|
+
*/
|
|
51
|
+
function writeLog(agent, type, message, isRaw = false) {
|
|
52
|
+
if (!_logFile) return;
|
|
53
|
+
try {
|
|
54
|
+
const now = new Date();
|
|
55
|
+
const date = now.toLocaleDateString('en-CA'); // YYYY-MM-DD
|
|
56
|
+
const time = now.toLocaleTimeString('en-AU', { hour12: false }); // HH:MM:SS
|
|
57
|
+
const label = (AI_STYLES[agent]?.label || 'SYSTEM ');
|
|
58
|
+
const status = isRaw ? 'RAW ' : (STATUS_TEXT[type] || STATUS_TEXT.info);
|
|
59
|
+
const clean = stripAnsi(String(message)).trim();
|
|
60
|
+
appendFileSync(_logFile, `[${date} ${time}] [${label}] [${status}] ${clean}\n`, 'utf8');
|
|
61
|
+
} catch { /* non-fatal */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Initialize a new session log file.
|
|
66
|
+
* Call once at startup. Creates .ai-workflow/logs/session-YYYYMMDD-HHMMSS.log
|
|
67
|
+
*
|
|
68
|
+
* @param {string} workflowDir — absolute path to .ai-workflow/
|
|
69
|
+
*/
|
|
70
|
+
export function initSessionLog(workflowDir) {
|
|
71
|
+
try {
|
|
72
|
+
const logsDir = resolve(workflowDir, 'logs');
|
|
73
|
+
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
|
|
74
|
+
|
|
75
|
+
const now = new Date();
|
|
76
|
+
const stamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19); // 2026-02-24T22-38-25
|
|
77
|
+
_logFile = resolve(logsDir, `session-${stamp}.log`);
|
|
78
|
+
|
|
79
|
+
appendFileSync(_logFile,
|
|
80
|
+
`${'='.repeat(72)}\n` +
|
|
81
|
+
`${(() => { try { return getConfig().name; } catch { return 'AI Control Center'; } })()} — Session Log\n` +
|
|
82
|
+
`Started: ${now.toLocaleString('en-AU')}\n` +
|
|
83
|
+
`Log file: ${_logFile}\n` +
|
|
84
|
+
`${'='.repeat(72)}\n\n`,
|
|
85
|
+
'utf8'
|
|
86
|
+
);
|
|
87
|
+
// Tell the status bar which log file to show in the info row
|
|
88
|
+
statusBar.setLogFile(_logFile);
|
|
89
|
+
} catch { /* non-fatal */ }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Returns the current session log file path (or null if not initialised).
|
|
94
|
+
*/
|
|
95
|
+
export function getSessionLogFile() {
|
|
96
|
+
return _logFile;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Write a large multi-line block to the session log FILE ONLY (not terminal).
|
|
101
|
+
* Use for full AI outputs, deploy JSON, fix guides — anything too large for
|
|
102
|
+
* the terminal but important to preserve in the audit log.
|
|
103
|
+
*
|
|
104
|
+
* Format in log file:
|
|
105
|
+
* ── COPILOT OUTPUT ──────────────────
|
|
106
|
+
* line 1
|
|
107
|
+
* line 2
|
|
108
|
+
* ────────────────────────────────────
|
|
109
|
+
*/
|
|
110
|
+
export function logRawBlock(agent, label, content) {
|
|
111
|
+
if (!_logFile || !content) return;
|
|
112
|
+
try {
|
|
113
|
+
const now = new Date();
|
|
114
|
+
const date = now.toLocaleDateString('en-CA');
|
|
115
|
+
const time = now.toLocaleTimeString('en-AU', { hour12: false });
|
|
116
|
+
const header = `[${date} ${time}] [${(AI_STYLES[agent]?.label || 'SYSTEM ')}] ── ${label} ${'─'.repeat(Math.max(0, 40 - label.length))}\n`;
|
|
117
|
+
const body = stripAnsi(String(content)).split('\n').map(l => ` ${l}`).join('\n');
|
|
118
|
+
const footer = `[${date} ${time}] [${(AI_STYLES[agent]?.label || 'SYSTEM ')}] ${'─'.repeat(48)}\n`;
|
|
119
|
+
appendFileSync(_logFile, `${header}${body}\n${footer}\n`, 'utf8');
|
|
120
|
+
} catch { /* non-fatal */ }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function timestamp() {
|
|
124
|
+
return chalk.dim(new Date().toLocaleTimeString('en-AU', { hour12: false }));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Log a milestone activity line.
|
|
129
|
+
* Automatically pauses/resumes the spinner so lines never mix.
|
|
130
|
+
* Also writes to session log file.
|
|
131
|
+
*
|
|
132
|
+
* Format: 12:34:56 GEMINI ✓ message
|
|
133
|
+
*/
|
|
134
|
+
export function logActivity(ai, message, type = 'info') {
|
|
135
|
+
const style = AI_STYLES[ai] || AI_STYLES.SYSTEM;
|
|
136
|
+
const ts = timestamp();
|
|
137
|
+
const label = chalk[style.color].bold(style.label);
|
|
138
|
+
const status = STATUS_CHAR[type] || STATUS_CHAR.info;
|
|
139
|
+
|
|
140
|
+
spinner.pause();
|
|
141
|
+
console.log(` ${ts} ${label} ${status} ${message}`);
|
|
142
|
+
spinner.resume();
|
|
143
|
+
|
|
144
|
+
writeLog(ai, type, message);
|
|
145
|
+
// Keep info row up to date with latest milestone
|
|
146
|
+
statusBar.setLastEvent(ai, message);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Log a raw subprocess output line to the session log FILE ONLY.
|
|
151
|
+
* Raw streaming output (every stdout/stderr line from Gemini/Copilot/Claude)
|
|
152
|
+
* is too noisy for the terminal. Only milestone events via logActivity()
|
|
153
|
+
* are shown in the terminal. Full detail is always in the session log.
|
|
154
|
+
*/
|
|
155
|
+
export function logActivityLine(ai, line) {
|
|
156
|
+
if (line.trim()) writeLog(ai, 'info', line, true);
|
|
157
|
+
// Intentionally no terminal output — use logActivity() for milestones.
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Section header — full-width right-fill adapts to terminal.
|
|
162
|
+
* Format: ◆ Title ─────────────────────────────
|
|
163
|
+
*/
|
|
164
|
+
export function printActivityHeader(title) {
|
|
165
|
+
const cols = process.stdout.columns || 80;
|
|
166
|
+
const used = 5 + title.length + 2; // ' ◆ ' + title + ' '
|
|
167
|
+
const right = chalk.dim('─'.repeat(Math.max(0, cols - used)));
|
|
168
|
+
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log(` ${chalk.cyan.bold('◆')} ${chalk.bold.white(title)} ${right}`);
|
|
171
|
+
|
|
172
|
+
writeLog('SYSTEM', 'info', `=== ${title} ===`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Section footer — full-width divider.
|
|
177
|
+
*/
|
|
178
|
+
export function printActivityFooter() {
|
|
179
|
+
const cols = process.stdout.columns || 80;
|
|
180
|
+
console.log(chalk.dim('─'.repeat(cols)));
|
|
181
|
+
console.log('');
|
|
182
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { getCostEntries } from './cost-tracker.js';
|
|
2
|
+
|
|
3
|
+
export function getLeaderboard(limit = 10) {
|
|
4
|
+
const entries = getCostEntries();
|
|
5
|
+
if (entries.length === 0) return [];
|
|
6
|
+
|
|
7
|
+
const stats = {};
|
|
8
|
+
for (const entry of entries) {
|
|
9
|
+
const key = `${entry.provider || 'unknown'}:${entry.model || 'default'}`;
|
|
10
|
+
if (!stats[key]) {
|
|
11
|
+
stats[key] = {
|
|
12
|
+
provider: entry.provider || 'unknown',
|
|
13
|
+
model: entry.model || 'default',
|
|
14
|
+
calls: 0,
|
|
15
|
+
totalInputTokens: 0,
|
|
16
|
+
totalOutputTokens: 0,
|
|
17
|
+
totalCost: 0,
|
|
18
|
+
errors: 0,
|
|
19
|
+
totalDurationMs: 0,
|
|
20
|
+
stages: new Set(),
|
|
21
|
+
features: new Set(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const s = stats[key];
|
|
25
|
+
s.calls++;
|
|
26
|
+
s.totalInputTokens += entry.inputTokens || 0;
|
|
27
|
+
s.totalOutputTokens += entry.outputTokens || 0;
|
|
28
|
+
s.totalCost += entry.estimatedCost || 0;
|
|
29
|
+
s.totalDurationMs += entry.durationMs || 0;
|
|
30
|
+
if (entry.error) s.errors++;
|
|
31
|
+
if (entry.stage) s.stages.add(entry.stage);
|
|
32
|
+
if (entry.featureId) s.features.add(entry.featureId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return Object.values(stats)
|
|
36
|
+
.map(s => ({
|
|
37
|
+
provider: s.provider,
|
|
38
|
+
model: s.model,
|
|
39
|
+
calls: s.calls,
|
|
40
|
+
totalTokens: s.totalInputTokens + s.totalOutputTokens,
|
|
41
|
+
totalCost: s.totalCost,
|
|
42
|
+
avgCostPerCall: s.calls > 0 ? s.totalCost / s.calls : 0,
|
|
43
|
+
errors: s.errors,
|
|
44
|
+
errorRate: s.calls > 0 ? (s.errors / s.calls * 100) : 0,
|
|
45
|
+
avgDurationMs: s.calls > 0 ? s.totalDurationMs / s.calls : 0,
|
|
46
|
+
stages: Array.from(s.stages),
|
|
47
|
+
features: s.features.size,
|
|
48
|
+
reliability: s.calls > 0 ? ((s.calls - s.errors) / s.calls * 100) : 100,
|
|
49
|
+
costEfficiency: s.totalCost > 0 ? (s.totalInputTokens + s.totalOutputTokens) / s.totalCost : 0,
|
|
50
|
+
}))
|
|
51
|
+
.sort((a, b) => a.avgCostPerCall - b.avgCostPerCall)
|
|
52
|
+
.slice(0, limit);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatLeaderboard(entries) {
|
|
56
|
+
if (entries.length === 0) return '📊 No usage data yet. Run some pipelines first!';
|
|
57
|
+
|
|
58
|
+
const lines = ['🏆 AI Model Leaderboard\n'];
|
|
59
|
+
|
|
60
|
+
const bestValue = entries.reduce((best, e) => e.avgCostPerCall < best.avgCostPerCall ? e : best, entries[0]);
|
|
61
|
+
const mostReliable = entries.reduce((best, e) => e.reliability > best.reliability ? e : best, entries[0]);
|
|
62
|
+
const mostUsed = entries.reduce((best, e) => e.calls > best.calls ? e : best, entries[0]);
|
|
63
|
+
|
|
64
|
+
entries.forEach((e, i) => {
|
|
65
|
+
const badges = [];
|
|
66
|
+
if (e === bestValue) badges.push('💰 Best Value');
|
|
67
|
+
if (e === mostReliable && e.calls > 2) badges.push('🛡️ Most Reliable');
|
|
68
|
+
if (e === mostUsed) badges.push('⭐ Most Used');
|
|
69
|
+
|
|
70
|
+
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `#${i + 1}`;
|
|
71
|
+
lines.push(
|
|
72
|
+
`${medal} ${e.provider}/${e.model}` +
|
|
73
|
+
(badges.length > 0 ? ` ${badges.join(' ')}` : '')
|
|
74
|
+
);
|
|
75
|
+
lines.push(
|
|
76
|
+
` ${e.calls} calls · $${e.avgCostPerCall.toFixed(4)}/call · ` +
|
|
77
|
+
`${e.reliability.toFixed(0)}% reliability · ${Math.round(e.avgDurationMs / 1000)}s avg`
|
|
78
|
+
);
|
|
79
|
+
lines.push(
|
|
80
|
+
` ${e.totalTokens.toLocaleString()} tokens · $${e.totalCost.toFixed(4)} total · ` +
|
|
81
|
+
`${e.features} features`
|
|
82
|
+
);
|
|
83
|
+
lines.push('');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return lines.join('\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function formatLeaderboardHTML(entries) {
|
|
90
|
+
if (entries.length === 0) return '<p>No usage data yet.</p>';
|
|
91
|
+
|
|
92
|
+
return `
|
|
93
|
+
<div class="leaderboard">
|
|
94
|
+
<h3>🏆 AI Model Leaderboard</h3>
|
|
95
|
+
<table style="width:100%;border-collapse:collapse;font-size:12px">
|
|
96
|
+
<tr style="border-bottom:1px solid #334155">
|
|
97
|
+
<th style="text-align:left;padding:6px">#</th>
|
|
98
|
+
<th style="text-align:left;padding:6px">Model</th>
|
|
99
|
+
<th style="text-align:right;padding:6px">Calls</th>
|
|
100
|
+
<th style="text-align:right;padding:6px">$/Call</th>
|
|
101
|
+
<th style="text-align:right;padding:6px">Reliability</th>
|
|
102
|
+
<th style="text-align:right;padding:6px">Avg Time</th>
|
|
103
|
+
<th style="text-align:right;padding:6px">Total $</th>
|
|
104
|
+
</tr>
|
|
105
|
+
${entries.map((e, i) => `
|
|
106
|
+
<tr style="border-bottom:1px solid #1e293b">
|
|
107
|
+
<td style="padding:6px">${i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : i + 1}</td>
|
|
108
|
+
<td style="padding:6px">${e.provider}/${e.model}</td>
|
|
109
|
+
<td style="text-align:right;padding:6px">${e.calls}</td>
|
|
110
|
+
<td style="text-align:right;padding:6px">$${e.avgCostPerCall.toFixed(4)}</td>
|
|
111
|
+
<td style="text-align:right;padding:6px;color:${e.reliability > 95 ? '#22c55e' : e.reliability > 80 ? '#f59e0b' : '#ef4444'}">${e.reliability.toFixed(0)}%</td>
|
|
112
|
+
<td style="text-align:right;padding:6px">${Math.round(e.avgDurationMs / 1000)}s</td>
|
|
113
|
+
<td style="text-align:right;padding:6px">$${e.totalCost.toFixed(4)}</td>
|
|
114
|
+
</tr>
|
|
115
|
+
`).join('')}
|
|
116
|
+
</table>
|
|
117
|
+
</div>
|
|
118
|
+
`;
|
|
119
|
+
}
|