From 0c4c5c7758292da28af0f4456922a44ce5565778 Mon Sep 17 00:00:00 2001 From: Alper Gocen Date: Sat, 2 May 2026 01:48:02 +0300 Subject: [PATCH 1/2] Fix optimization UI and use GPT 5.4 mini suggestions --- .env.example | 3 +- README.md | 27 +++-- actuators.js | 4 +- analysis.js | 4 +- docker-compose.yml | 6 +- llm-analysis.js | 235 +++++++++++++++++++++++++++++-------- package.json | 3 +- pricing.js | 1 + public/app.js | 123 +++++++++++++------ public/index.html | 2 +- server.js | 23 ++-- setup.sh | 8 +- tests/actuators.test.js | 12 ++ tests/llm-analysis.test.js | 67 +++++++++++ 14 files changed, 399 insertions(+), 119 deletions(-) create mode 100644 tests/actuators.test.js create mode 100644 tests/llm-analysis.test.js diff --git a/.env.example b/.env.example index 53c1030..56d61ff 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ -ANTHROPIC_API_KEY=sk-ant-... +OPENAI_API_KEY=sk-... +OPENAI_ANALYSIS_MODEL=gpt-5.4-mini diff --git a/README.md b/README.md index c75ab14..810ad58 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Group sessions by project. Click into any project to see every session that ran - Per-session table with session name (first user message), model, all token buckets + cost ### πŸ”¬ Analysis β€” LLM-powered waste detection -Every 2 hours, Claude Haiku analyses your projects using your existing OAuth session β€” **no API key needed**. +Every 2 hours, GPT 5.4 mini analyzes your projects using aggregated token statistics. Set `OPENAI_API_KEY` to enable LLM recommendations; without it, the dashboard falls back to heuristic analysis. **Detected patterns:** - πŸ”΄ Context bloat β€” loading huge context for minimal output @@ -62,7 +62,7 @@ The dashboard never sends a full payload twice. Only changed sessions are pushed **Projects** β€” every project as a card with tokens, cost, composition bar and per-category breakdown ![Projects](public/screenshots/projects.jpg) -**Analysis** β€” Haiku-powered waste detection, per-project findings with severity, impact $ and apply button +**Analysis** β€” GPT-powered waste detection, per-project findings with severity, impact $ and apply button ![Analysis](public/screenshots/analysis.jpg) **Charts** β€” daily token/cost bar chart (Today / 7 Days / 30 Days) and context file size inventory @@ -109,15 +109,18 @@ npm run dev ## LLM Analysis setup (optional but recommended) -The Analysis page works out of the box using **Claude Code's OAuth session** β€” the same login as your `claude` CLI. No API key needed. +The Analysis page uses OpenAI's Responses API with `gpt-5.4-mini` by default. -If the analysis shows `βš™ Heuristic` instead of `✦ Haiku`, check that `claude` CLI is authenticated: +Create `.env` from the example and set your API key: ```bash -claude --version # should respond without prompting for login +cp .env.example .env +# edit .env: +# OPENAI_API_KEY=sk-... +# OPENAI_ANALYSIS_MODEL=gpt-5.4-mini ``` -The tool shells out to the bundled `claude.exe` binary in `node_modules/@anthropic-ai/claude-code/bin/` with `HOME` pointing at your host home directory, so it picks up your existing OAuth tokens automatically. +If the analysis shows `βš™ Heuristic` instead of `✦ gpt-5.4-mini`, the OpenAI key is missing or the analysis request failed. --- @@ -143,11 +146,11 @@ The tool shells out to the bundled `claude.exe` binary in `node_modules/@anthrop β”‚ Chart.js for charts β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -Every 2h: claude.exe (Haiku, OAuth) ──► per-project analysis +Every 2h: OpenAI Responses API (gpt-5.4-mini) ──► per-project analysis results cached, delta-pushed via WebSocket ``` -**Data never leaves your machine.** Session files are read locally. The only outbound traffic is the Haiku analysis call β€” which sends only aggregated stats (no conversation content). +Session files are read locally. The only outbound analysis traffic sends aggregated stats (no conversation content) to OpenAI when `OPENAI_API_KEY` is configured. --- @@ -174,14 +177,14 @@ File watching uses polling inside Docker (`USE_POLLING=true`) because Mac's FUSE | File | Purpose | |---|---| -| `.env` | `ANTHROPIC_API_KEY` if you prefer API key over OAuth | -| `pricing.js` | Token rates per model β€” update when Anthropic changes pricing | +| `.env` | `OPENAI_API_KEY` and optional `OPENAI_ANALYSIS_MODEL` | +| `pricing.js` | Token rates per model β€” update when providers change pricing | | `docker-compose.yml` | Port, volume mounts, polling interval | | `com.agent-optimization.plist` | macOS LaunchAgent definition | ### Supported models (pricing) -Claude Opus 4/3.5/3 Β· Claude Sonnet 4/3.5/3 Β· Claude Haiku 4/3.5/3 Β· GPT-5 Β· GPT-4o Β· GPT-4.1 Β· o1 Β· o3 Β· o4-mini Β· Codex-mini +Claude Opus 4/3.5/3 Β· Claude Sonnet 4/3.5/3 Β· Claude Haiku 4/3.5/3 Β· GPT-5 Β· GPT-5.4 mini Β· GPT-4o Β· GPT-4.1 Β· o1 Β· o3 Β· o4-mini Β· Codex-mini Unknown models fall back to Sonnet-tier pricing with a `β‰ˆ` indicator. @@ -218,7 +221,7 @@ docker compose up -d --build # rebuilds image with new code - **Server:** Express + `ws` WebSocket + `chokidar` file watcher - **Frontend:** Vanilla JS, Chart.js β€” no build step, no framework - **Container:** Docker / OrbStack, `node:22-alpine` -- **Analysis:** `@anthropic-ai/claude-code` (bundled `claude.exe`, OAuth auth) +- **Analysis:** OpenAI Responses API (`gpt-5.4-mini` by default) --- diff --git a/actuators.js b/actuators.js index b3bc4ea..71256a4 100644 --- a/actuators.js +++ b/actuators.js @@ -145,7 +145,7 @@ function extractDiffSnippet(before, after) { function previewOverpoweredModel(projectPath, sessions) { // Choose target tier based on what was actually flagged. Default to Sonnet. const flagged = sessions.filter(s => - /opus|gpt-5|^o1$/i.test(s.model || '') && + (/opus|^o1$/i.test(s.model || '') || (/gpt-5/i.test(s.model || '') && !/mini/i.test(s.model || ''))) && (s.output || 0) < 2000 && (s.total || 0) < 100_000 ); if (!flagged.length) { @@ -254,7 +254,7 @@ export function previewActuator(findingId, project, sessions) { case 'fragmented-sessions': return behavioral('Fragmenting comes from CLI usage patterns. Tracking only.'); default: - return { actionable: false, reason: 'Unknown finding type.' }; + return behavioral('This LLM recommendation is advisory and has no safe file-level automation yet. Tracking only.'); } } diff --git a/analysis.js b/analysis.js index 09b3930..a4343cc 100644 --- a/analysis.js +++ b/analysis.js @@ -9,9 +9,9 @@ const SEVERITY_ORDER = { critical: 3, high: 2, medium: 1, low: 0 }; const tier = (model) => { if (!model) return 'unknown'; + if (/haiku|mini/i.test(model)) return 'cheap'; if (/opus|gpt-5|^o1$/i.test(model)) return 'premium'; if (/sonnet|gpt-4o$|gpt-4\.1$|^o3$/i.test(model)) return 'mid'; - if (/haiku|mini/i.test(model)) return 'cheap'; return 'mid'; }; @@ -52,7 +52,7 @@ function findCacheInefficiency(sessions) { } function findExpensiveTinyTasks(sessions) { - // Premium-tier sessions that did very little work β€” Sonnet/Haiku would have sufficed. + // Premium-tier sessions that did very little work β€” a mid/cheap model would have sufficed. const out = []; for (const s of sessions) { if (tier(s.model) !== 'premium') continue; diff --git a/docker-compose.yml b/docker-compose.yml index 55d88d6..1d1bbb1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ -## Copy .env.example to .env and add your ANTHROPIC_API_KEY to enable LLM analysis. -## Without it the app works fine but uses heuristic rules instead of Haiku. +## Copy .env.example to .env and add your OPENAI_API_KEY to enable LLM analysis. +## Without it the app works fine but uses heuristic rules instead of GPT analysis. services: agent-optimization: @@ -12,6 +12,8 @@ services: environment: - HOST_HOME=/host-home - USE_POLLING=true + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - OPENAI_ANALYSIS_MODEL=${OPENAI_ANALYSIS_MODEL:-gpt-5.4-mini} volumes: # Full home dir at same logical path so actuator-written paths resolve correctly - /Users/${USER}:/host-home:cached diff --git a/llm-analysis.js b/llm-analysis.js index 0d21283..1643a47 100644 --- a/llm-analysis.js +++ b/llm-analysis.js @@ -1,28 +1,82 @@ -// LLM-powered analysis using Claude Code's OAuth session. -// Shells out to the bundled claude.exe binary β€” no API key required. -// Uses the same Max subscription auth as the CLI. +// LLM-powered analysis using OpenAI. +// Sends aggregated project/session stats only, not conversation content. -import { spawn } from 'child_process'; -import { createRequire } from 'module'; import fs from 'fs'; import path from 'path'; import os from 'os'; +import { fileURLToPath } from 'url'; +const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); const HOME = process.env.HOST_HOME || os.homedir(); const CACHE_FILE = path.join(HOME, '.agent-optimization', 'llm-cache.json'); -const MODEL = 'claude-haiku-4-5-20251001'; + +function loadProjectEnv() { + const envPath = path.join(MODULE_DIR, '.env'); + if (!fs.existsSync(envPath)) return; + for (const raw of fs.readFileSync(envPath, 'utf8').split('\n')) { + let line = raw.trim(); + if (!line || line.startsWith('#')) continue; + if (line.startsWith('export ')) line = line.slice('export '.length).trim(); + const idx = line.indexOf('='); + if (idx <= 0) continue; + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim().replace(/^['"]|['"]$/g, ''); + if (key && process.env[key] === undefined) process.env[key] = value; + } +} + +loadProjectEnv(); + +export const ANALYSIS_MODEL = + (process.env.OPENAI_ANALYSIS_MODEL || process.env.OPENAI_MODEL || 'gpt-5.4-mini').trim() || + 'gpt-5.4-mini'; const MAX_CONCURRENT = 4; const CACHE_TTL_MS = 2 * 60 * 60 * 1000; -// Resolve bundled claude binary -const require = createRequire(import.meta.url); -const CLAUDE_BIN = (() => { - try { - return require.resolve('@anthropic-ai/claude-code/bin/claude.exe'); - } catch { - return process.env.CLAUDE_BIN || 'claude'; - } -})(); +const OPENAI_RESPONSES_URL = + (process.env.OPENAI_BASE_URL || 'https://api.openai.com').replace(/\/$/, '') + + '/v1/responses'; + +export const SUPPORTED_FINDING_IDS = [ + 'context-bloat', + 'cache-inefficiency', + 'overpowered-model', + 'output-heavy', + 'reasoning-waste', + 'fragmented-sessions', +]; + +const FINDING_ID_ALIASES = { + context_bloat: 'context-bloat', + cache_inefficiency: 'cache-inefficiency', + cache_misuse: 'cache-inefficiency', + cache_rewrite: 'cache-inefficiency', + overpowered_model: 'overpowered-model', + premium_model: 'overpowered-model', + output_heavy: 'output-heavy', + verbose_output: 'output-heavy', + reasoning_waste: 'reasoning-waste', + fragmented_sessions: 'fragmented-sessions', +}; + +const FINDING_SCHEMA = { + type: 'array', + minItems: 0, + maxItems: 4, + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'title', 'severity', 'summary', 'impact', 'recommendation'], + properties: { + id: { type: 'string', enum: SUPPORTED_FINDING_IDS }, + title: { type: 'string', maxLength: 80 }, + severity: { type: 'string', enum: ['high', 'medium', 'low'] }, + summary: { type: 'string', maxLength: 320 }, + impact: { type: 'string', maxLength: 160 }, + recommendation: { type: 'string', maxLength: 260 }, + }, + }, +}; // ---------- File-based cache ---------- @@ -40,7 +94,7 @@ loadCache(); function projectFingerprint(proj) { const c = proj.cost?.total || 0; - return `${proj.sessionCount}|${Math.round((proj.total || 0) / 1e6)}|${c.toFixed(1)}`; + return `${ANALYSIS_MODEL}|${proj.sessionCount}|${Math.round((proj.total || 0) / 1e6)}|${c.toFixed(1)}`; } // ---------- Prompt ---------- @@ -114,47 +168,115 @@ Top sessions by cost: ${topSessions || ' none'} Return ONLY a JSON array (0–4 findings). Empty array [] if usage looks healthy. +Use ONLY these exact id values: + - context-bloat + - cache-inefficiency + - overpowered-model + - output-heavy + - reasoning-waste + - fragmented-sessions Each object: -{"id":"snake_case","title":"max 8 words","severity":"high|medium|low","summary":"1-2 sentences on observed pattern","impact":"specific $ estimate","recommendation":"one concrete action"} +{"id":"one exact id from the list","title":"max 8 words","severity":"high|medium|low","summary":"1-2 sentences on observed pattern","impact":"specific $ estimate","recommendation":"one concrete action"} No markdown, no explanation. Just the JSON array.`; } -// ---------- Claude CLI call ---------- - -function runClaude(prompt) { - return new Promise((resolve, reject) => { - const child = spawn(CLAUDE_BIN, [ - '--print', - '--model', MODEL, - '--output-format', 'text', - ], { - env: { ...process.env, HOME }, - timeout: 60_000, - }); - - let stdout = ''; - let stderr = ''; - child.stdout.on('data', d => stdout += d.toString()); - child.stderr.on('data', d => stderr += d.toString()); - child.stdin.write(prompt); - child.stdin.end(); - child.on('close', code => { - if (code === 0) resolve(stdout.trim()); - else reject(new Error(stderr.slice(0, 200) || `exit ${code}`)); - }); - child.on('error', reject); +// ---------- OpenAI Responses API call ---------- + +export function buildOpenAIRequest(prompt, model = ANALYSIS_MODEL) { + return { + model, + input: prompt, + store: false, + max_output_tokens: 1200, + instructions: + 'Return only JSON that matches the schema. Use the provided finding ids exactly.', + text: { + format: { + type: 'json_schema', + name: 'optimization_findings', + strict: true, + schema: FINDING_SCHEMA, + }, + }, + }; +} + +export function responseText(payload) { + if (typeof payload?.output_text === 'string') return payload.output_text.trim(); + for (const item of payload?.output || []) { + for (const content of item.content || []) { + if (typeof content.text === 'string') return content.text.trim(); + } + } + return ''; +} + +async function runOpenAI(prompt) { + const apiKey = (process.env.OPENAI_API_KEY || '').trim(); + if (!apiKey) throw new Error('OPENAI_API_KEY is not set'); + + const response = await fetch(OPENAI_RESPONSES_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildOpenAIRequest(prompt)), + signal: AbortSignal.timeout(60_000), }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const detail = payload.error?.message || response.statusText || `HTTP ${response.status}`; + throw new Error(detail.slice(0, 240)); + } + + return responseText(payload); +} + +export function extractImpactCost(impact) { + const match = String(impact || '').match(/\$([0-9,]+(?:\.[0-9]+)?)/); + return match ? parseFloat(match[1].replace(/,/g, '')) : 0; +} + +function normalizeFindingId(id) { + const raw = String(id || '').trim(); + const key = raw.toLowerCase().replace(/\s+/g, '-'); + const aliasKey = key.replace(/-/g, '_'); + return FINDING_ID_ALIASES[aliasKey] || key; +} + +export function normalizeLLMFinding(finding) { + if (!finding || typeof finding !== 'object') return null; + const id = normalizeFindingId(finding.id); + if (!SUPPORTED_FINDING_IDS.includes(id)) return null; + + const severity = String(finding.severity || 'low').toLowerCase(); + const wastedCost = extractImpactCost(finding.impact); + return { + id, + title: String(finding.title || id).slice(0, 100), + severity: ['high', 'medium', 'low'].includes(severity) ? severity : 'low', + summary: String(finding.summary || '').slice(0, 500), + impact: String(finding.impact || '').slice(0, 240), + recommendation: String(finding.recommendation || '').slice(0, 500), + examples: [], + metric: { wastedCost }, + }; } function parseFindings(raw) { try { - const arr = JSON.parse(raw); - if (Array.isArray(arr)) return arr; + const parsed = JSON.parse(raw); + const arr = Array.isArray(parsed) ? parsed : parsed?.findings; + if (Array.isArray(arr)) { + return arr.map(normalizeLLMFinding).filter(Boolean).slice(0, 4); + } } catch {} // Strip possible markdown code fence const m = raw.match(/\[[\s\S]*?\]/); if (m) { - try { return JSON.parse(m[0]); } catch {} + try { return JSON.parse(m[0]).map(normalizeLLMFinding).filter(Boolean).slice(0, 4); } catch {} } return []; } @@ -169,7 +291,7 @@ async function analyzeProjectWithLLM(proj, sessions) { return { findings: cached.findings, fromCache: true }; } - const raw = await runClaude(buildPrompt(proj, sessions)); + const raw = await runOpenAI(buildPrompt(proj, sessions)); const findings = parseFindings(raw); llmCache[cacheKey] = { findings, ts: new Date().toISOString() }; saveCache(); @@ -180,8 +302,21 @@ async function analyzeProjectWithLLM(proj, sessions) { export async function runLLMAnalysis(projects, sessionsByProject) { const results = {}; + const meta = { + enabled: !!(process.env.OPENAI_API_KEY || '').trim(), + model: ANALYSIS_MODEL, + done: 0, + cached: 0, + errors: 0, + }; + results.__meta = meta; + + if (!meta.enabled) { + console.log(` [llm] skipped β€” OPENAI_API_KEY is not set (model ${ANALYSIS_MODEL})`); + return results; + } + const queue = [...projects]; - let done = 0, cached = 0, errors = 0; async function worker() { while (queue.length) { @@ -195,17 +330,17 @@ export async function runLLMAnalysis(projects, sessionsByProject) { try { const r = await analyzeProjectWithLLM(proj, sessions); results[proj.project] = r; - if (r.fromCache) cached++; - else done++; + if (r.fromCache) meta.cached++; + else meta.done++; } catch (e) { console.error(` [llm] ${proj.projectLabel || proj.project}: ${e.message}`); results[proj.project] = { findings: [], error: e.message }; - errors++; + meta.errors++; } } } await Promise.all(Array.from({ length: MAX_CONCURRENT }, worker)); - console.log(` [llm] done β€” ${done} new Β· ${cached} cached Β· ${errors} errors`); + console.log(` [llm] ${ANALYSIS_MODEL} β€” ${meta.done} new Β· ${meta.cached} cached Β· ${meta.errors} errors`); return results; } diff --git a/package.json b/package.json index 03c89b8..e1d510f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "node server.js", - "start": "node server.js" + "start": "node server.js", + "test": "node --test" }, "dependencies": { "@anthropic-ai/claude-code": "^2.1.123", diff --git a/pricing.js b/pricing.js index 8ad8b6a..c0874d2 100644 --- a/pricing.js +++ b/pricing.js @@ -14,6 +14,7 @@ const PRICING = [ { match: /3-?haiku/i, input: 0.25, output: 1.25, cacheRead: 0.03, cacheCreate: 0.30 }, // ---- OpenAI ---- + { match: /gpt-5(?:\.\d+)?-mini|gpt-5.*mini/i, input: 0.15, output: 0.60, cacheRead: 0.075, cacheCreate: 0 }, { match: /^gpt-5|gpt5/i, input: 2.50, output: 10.00, cacheRead: 0.25, cacheCreate: 0 }, { match: /gpt-4\.1-mini/i, input: 0.40, output: 1.60, cacheRead: 0.10, cacheCreate: 0 }, { match: /gpt-4\.1/i, input: 2.00, output: 8.00, cacheRead: 0.50, cacheCreate: 0 }, diff --git a/public/app.js b/public/app.js index 09203b5..639247c 100644 --- a/public/app.js +++ b/public/app.js @@ -421,7 +421,9 @@ function renderAnalysis(report) { updateNextRunCountdown(); // LLM / heuristic badge - document.getElementById('llm-badge').hidden = !report.llmPowered; + const llmBadge = document.getElementById('llm-badge'); + llmBadge.textContent = report.llmModel ? `✦ ${report.llmModel}` : '✦ GPT'; + llmBadge.hidden = !report.llmPowered; document.getElementById('heuristic-badge').hidden = !!report.llmPowered; // Tags @@ -441,7 +443,10 @@ function renderAnalysis(report) { ? withFindings : withFindings.filter(p => (p.sources || []).includes(analysisSourceFilter)); if (!visible.length) { - wrap.innerHTML = '
All clear β€” no wasteful patterns detected across your projects. πŸŽ‰
'; + const label = analysisSourceFilter === 'all' ? 'projects' : `${analysisSourceFilter} projects`; + wrap.innerHTML = `
No ${label} with findings.
`; + const countEl = document.getElementById('ana-tab-count'); + if (countEl) countEl.textContent = '0 projects'; return; } // Update tab count @@ -464,12 +469,14 @@ function renderAnalysis(report) { function renderAnaProject(p, openFirst) { const name = p.projectLabel || deriveProjectLabel(p.project); + const safeName = escapeHtml(name); + const safeProject = escapeHtml(p.project); const initial = name.replace(/\s+\[.*$/, '').trim().charAt(0).toUpperCase(); const srcPills = (p.sources || []).map(s => `${s}` ).join(''); const llmTag = (p.llmAnalyzed || p.llmCached) - ? `${p.llmCached ? '✦ cached' : '✦ haiku'}` : ''; + ? `${p.llmCached ? '✦ cached' : `✦ ${escapeHtml(p.llmModel || 'GPT')}`}` : ''; const wastePct = p.totalCost > 0 ? (p.wastedCost / p.totalCost * 100) : 0; const findingsHtml = p.findings.map(f => renderFinding(f, p.project)).join(''); const modelMix = p.modelMix.slice(0, 6).map(m => ` @@ -485,8 +492,8 @@ function renderAnaProject(p, openFirst) {
${initial}
-

${name} ${srcPills}${llmTag}

-
${p.project}
+

${safeName} ${srcPills}${llmTag}

+
${safeProject}
@@ -571,12 +578,12 @@ function renderFinding(f, projectKey) { return `
- ${f.severity} - ${f.title} + ${escapeHtml(f.severity)} + ${escapeHtml(f.title)}
-
${f.summary}
-
${f.impact}
-
${f.recommendation}
+
${escapeHtml(f.summary)}
+
${escapeHtml(f.impact)}
+
${escapeHtml(f.recommendation)}
${actionHtml}
${banner} ${examples ? `
Sample sessions
${examples}` : ''} @@ -653,6 +660,26 @@ function showToast({ title, body, durationMs = 6000 }) { }, durationMs); } +async function readJsonOrThrow(response) { + const data = await response.json().catch(() => ({})); + if (!response.ok || data?.error) { + const message = data.detail || data.error || response.statusText || 'Request failed'; + throw new Error(message); + } + return data; +} + +function showErrorModal(title, error) { + showModal(` + + `); +} + const RECOMMENDATION_CHECKLISTS = { 'context-bloat': [ 'Trim CLAUDE.md / AGENTS.md to essentials', @@ -818,15 +845,18 @@ document.addEventListener('click', async (e) => { } // Modal "track from now" or "commit" handlers if (e.target?.dataset?.action === 'track-only') { - const project = decodeURIComponent(e.target.dataset.project); - const findingId = e.target.dataset.finding; - const r = await fetch('/api/analysis/apply', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ project, findingId }), // no token = behavioral apply - }); - const data = await r.json(); - if (data.ok) { + const btn = e.target; + const project = decodeURIComponent(btn.dataset.project); + const findingId = btn.dataset.finding; + btn.disabled = true; + btn.textContent = 'Tracking…'; + try { + const r = await fetch('/api/analysis/apply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project, findingId }), // no token = behavioral apply + }); + const data = await readJsonOrThrow(r); showAppliedModal({ findingTitle: data.finding.title, findingId, @@ -835,20 +865,26 @@ document.addEventListener('click', async (e) => { }); const r2 = await fetch('/api/analysis'); renderAnalysis(await r2.json()); + } catch (err) { + showErrorModal('Could not track suggestion', err); + } finally { + btn.disabled = false; + btn.textContent = 'Track from now'; } return; } if (e.target?.dataset?.action === 'commit') { - const token = e.target.dataset.token; - e.target.disabled = true; - e.target.textContent = 'Applying…'; - const r = await fetch('/api/analysis/apply', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token }), - }); - const data = await r.json(); - if (data.ok) { + const btn = e.target; + const token = btn.dataset.token; + btn.disabled = true; + btn.textContent = 'Applying…'; + try { + const r = await fetch('/api/analysis/apply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + const data = await readJsonOrThrow(r); showAppliedModal({ findingTitle: data.finding.title, findingId: data.finding.id, @@ -858,6 +894,10 @@ document.addEventListener('click', async (e) => { }); const r2 = await fetch('/api/analysis'); renderAnalysis(await r2.json()); + } catch (err) { + showErrorModal('Could not apply change', err); + btn.disabled = false; + btn.textContent = 'Apply change'; } return; } @@ -876,20 +916,29 @@ document.addEventListener('click', async (e) => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project, findingId }), }); - const preview = await r.json(); + const preview = await readJsonOrThrow(r); showPreviewModal(preview, project, findingId); + } catch (err) { + showErrorModal('Could not preview suggestion', err); } finally { btn.disabled = false; btn.textContent = 'Apply suggestion β†’'; } } else if (action === 'unapply') { - await fetch('/api/analysis/unapply', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ project, findingId }), - }); - const r2 = await fetch('/api/analysis'); - renderAnalysis(await r2.json()); + btn.disabled = true; + try { + await readJsonOrThrow(await fetch('/api/analysis/unapply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project, findingId }), + })); + const r2 = await fetch('/api/analysis'); + renderAnalysis(await r2.json()); + } catch (err) { + showErrorModal('Could not undo suggestion', err); + } finally { + btn.disabled = false; + } } }); diff --git a/public/index.html b/public/index.html index 44f20b1..748acf4 100644 --- a/public/index.html +++ b/public/index.html @@ -247,7 +247,7 @@

Sessions in this project

Optimization Analysis

- +

Auto-runs every 2 hours Β· scans every project's sessions for wasteful patterns

diff --git a/server.js b/server.js index 48f37e6..d0ad5e5 100644 --- a/server.js +++ b/server.js @@ -15,7 +15,7 @@ import { previewActuator, commitActuator } from './actuators.js'; const HOME = process.env.HOST_HOME || os.homedir(); const CLAUDE_DIR = path.join(HOME, '.claude', 'projects'); const CODEX_DIR = path.join(HOME, '.codex', 'sessions'); -const PORT = 4317; +const PORT = Number(process.env.PORT || 4317); // ---------- Parsers ---------- @@ -356,6 +356,10 @@ for (const s of cache.sessions) lastSessionsBySig.set(s.id, sessionSig(s)); app.use(express.static('public')); +app.get('/favicon.ico', (req, res) => { + res.status(204).end(); +}); + app.get('/api/data', (req, res) => { res.json(cache); }); @@ -512,8 +516,8 @@ async function generateAnalysis() { const report = runAnalysis(cache.sessions); report.nextRunAt = new Date(Date.now() + TWO_HOURS_MS).toISOString(); - // LLM enrichment β€” replace heuristic findings with Haiku analysis. - // Uses Claude Code's OAuth session via bundled claude.exe. No API key needed. + // LLM enrichment β€” replace heuristic findings with GPT analysis when configured. + // Sends aggregated stats only; falls back to heuristics when OPENAI_API_KEY is absent. { const sessionsByProject = new Map(); for (const s of cache.sessions) { @@ -522,21 +526,26 @@ async function generateAnalysis() { sessionsByProject.get(key).push(s); } const llmResults = await runLLMAnalysis(report.projects, sessionsByProject); + const llmMeta = llmResults.__meta || {}; + let llmProjectCount = 0; for (const proj of report.projects) { const r = llmResults[proj.project]; if (r && !r.skipped && !r.error && r.findings?.length >= 0) { proj.findings = r.findings; proj.llmAnalyzed = !r.fromCache; proj.llmCached = !!r.fromCache; - // Recalculate wastedCost from LLM findings (use impact as text β€” no numeric metric) + proj.llmModel = llmMeta.model; + llmProjectCount++; + // Recalculate wastedCost from normalized LLM finding metrics. proj.wastedCost = proj.findings.reduce((a, f) => { - const match = (f.impact || '').match(/\$([0-9,]+(\.[0-9]+)?)/); - return a + (match ? parseFloat(match[1].replace(/,/g, '')) : 0); + return a + (f.metric?.wastedCost ?? f.metric?.savings ?? f.metric?.cost ?? 0); }, 0); } } report.projects.sort((a, b) => b.wastedCost - a.wastedCost); - report.llmPowered = true; + report.llmPowered = !!llmMeta.enabled && llmProjectCount > 0; + report.llmProvider = llmMeta.enabled ? 'openai' : null; + report.llmModel = llmMeta.model; } const realized = reconcileApplied(report); diff --git a/setup.sh b/setup.sh index 44eec2a..f6dd34f 100755 --- a/setup.sh +++ b/setup.sh @@ -31,11 +31,11 @@ echo "βœ“ Docker is running ($(docker context show))" # 2. Build & start with compose # Check for API key -if [ ! -f "$SCRIPT_DIR/.env" ] || ! grep -q "sk-ant-" "$SCRIPT_DIR/.env" 2>/dev/null; then +if [ ! -f "$SCRIPT_DIR/.env" ] || ! grep -q "OPENAI_API_KEY=.*sk-" "$SCRIPT_DIR/.env" 2>/dev/null; then echo "" - echo "⚠ No ANTHROPIC_API_KEY found in .env" - echo " β†’ Get a key at https://console.anthropic.com/settings/keys" - echo " β†’ Add to $SCRIPT_DIR/.env: ANTHROPIC_API_KEY=sk-ant-..." + echo "⚠ No OPENAI_API_KEY found in .env" + echo " β†’ Add to $SCRIPT_DIR/.env: OPENAI_API_KEY=sk-..." + echo " β†’ Optional: OPENAI_ANALYSIS_MODEL=gpt-5.4-mini" echo " β†’ App will use heuristic fallback until key is set" echo "" fi diff --git a/tests/actuators.test.js b/tests/actuators.test.js new file mode 100644 index 0000000..26a4ac9 --- /dev/null +++ b/tests/actuators.test.js @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { previewActuator } from '../actuators.js'; + +test('unknown LLM recommendation ids are treated as track-only advice', () => { + const preview = previewActuator('duplicate_expensive_models', '/tmp/project', []); + + assert.equal(preview.actionable, false); + assert.equal(preview.behavioral, true); + assert.match(preview.reason, /LLM recommendation/i); +}); diff --git a/tests/llm-analysis.test.js b/tests/llm-analysis.test.js new file mode 100644 index 0000000..745de46 --- /dev/null +++ b/tests/llm-analysis.test.js @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + ANALYSIS_MODEL, + buildOpenAIRequest, + extractImpactCost, + normalizeLLMFinding, + responseText, +} from '../llm-analysis.js'; + +test('uses GPT 5.4 mini for optimization suggestions by default', () => { + assert.equal(ANALYSIS_MODEL, 'gpt-5.4-mini'); +}); + +test('OpenAI request asks for strict structured findings', () => { + const body = buildOpenAIRequest('analyze this'); + + assert.equal(body.model, 'gpt-5.4-mini'); + assert.equal(body.input, 'analyze this'); + assert.equal(body.store, false); + assert.equal(body.text.format.type, 'json_schema'); + assert.equal(body.text.format.strict, true); + assert.equal( + body.text.format.schema.items.properties.id.enum.includes('overpowered-model'), + true, + ); +}); + +test('normalizes LLM finding ids and attaches numeric savings metric', () => { + const finding = normalizeLLMFinding({ + id: 'overpowered_model', + title: 'Premium model used', + severity: 'HIGH', + summary: 'Short tasks used a premium model.', + impact: 'About $12.50 can be saved.', + recommendation: 'Pin a smaller model.', + }); + + assert.equal(finding.id, 'overpowered-model'); + assert.equal(finding.severity, 'high'); + assert.deepEqual(finding.metric, { wastedCost: 12.5 }); +}); + +test('rejects unsupported LLM finding ids', () => { + assert.equal(normalizeLLMFinding({ id: 'made_up', title: 'Nope' }), null); +}); + +test('extracts the first dollar amount from impact text', () => { + assert.equal(extractImpactCost('β‰ˆ $1,234.56 wasted this week'), 1234.56); + assert.equal(extractImpactCost('no dollar estimate'), 0); +}); + +test('extracts text from Responses API payloads', () => { + assert.equal(responseText({ output_text: '[{"id":"x"}]' }), '[{"id":"x"}]'); + assert.equal( + responseText({ + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: '[]' }], + }, + ], + }), + '[]', + ); +}); From 28364bbaf8bd6b7448e0231e5fcd56a67fb15ccb Mon Sep 17 00:00:00 2001 From: Alper Gocen Date: Mon, 4 May 2026 14:05:32 +0300 Subject: [PATCH 2/2] Add Excel export workbook --- README.md | 8 + excel-export.js | 990 +++++++++++++++++++++++++++++++++++++ public/app.js | 83 ++++ public/index.html | 45 ++ public/style.css | 76 ++- server.js | 100 ++-- tests/excel-export.test.js | 179 +++++++ 7 files changed, 1417 insertions(+), 64 deletions(-) create mode 100644 excel-export.js create mode 100644 tests/excel-export.test.js diff --git a/README.md b/README.md index 810ad58..c63098f 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,14 @@ Each finding includes a **projected savings estimate**, a concrete recommendatio - **Claude context files** β€” horizontal bar chart of every `CLAUDE.md` across your projects, sized by byte count - **Codex context files** β€” same for `AGENTS.md` +### πŸ“€ Export β€” Excel-ready workbook + +Download a multi-sheet `.xlsx` from the Export tab or directly from `http://localhost:4317/api/export.xlsx`. + +- Summary, Sessions, Projects, Daily Usage +- Analysis Projects, Findings, Finding Examples, Applied Actions +- Context Files, Wire Stats, and a sheet dictionary + ### ⚑ Delta WebSocket updates The dashboard never sends a full payload twice. Only changed sessions are pushed on each update β€” typically **99%+ bandwidth reduction** vs naive full-refresh, visible in the footer. diff --git a/excel-export.js b/excel-export.js new file mode 100644 index 0000000..24e21d4 --- /dev/null +++ b/excel-export.js @@ -0,0 +1,990 @@ +import fs from 'fs'; +import path from 'path'; + +const SHEET_DESCRIPTIONS = { + Summary: 'Export metadata and top-level token/cost totals.', + Sessions: 'One row per Claude Code or Codex session.', + Projects: 'Project-level rollups derived from all sessions.', + 'Daily Usage': 'Daily source-level token and cost rollups.', + 'Analysis Projects': 'Project-level optimization analysis blocks.', + Findings: 'Flattened analysis findings and applied state.', + 'Finding Examples': 'Example sessions attached to each analysis finding.', + 'Applied Actions': 'Suggestions marked applied and their realized savings state.', + 'Context Files': 'CLAUDE.md and AGENTS.md inventory used by charts.', + 'Wire Stats': 'Dashboard WebSocket delta traffic telemetry.', + Dictionary: 'Workbook sheet guide.', +}; + +const TOKEN_KEYS = ['input', 'output', 'cacheRead', 'cacheCreate', 'reasoning', 'total']; +const COST_KEYS = ['input', 'output', 'cacheRead', 'cacheCreate', 'total']; + +function normalizeProjectKey(project) { + return (project || 'unknown').replace(/\/+/g, '/').replace(/\/$/, ''); +} + +function deriveProjectLabel(rawPath) { + if (!rawPath || rawPath === 'unknown') return rawPath || 'unknown'; + const p = rawPath.replace(/\/+/g, '/'); + const wtMatch = p.match(/^(.+?)\/claude\/worktrees\/([^/]+)\/([^/]+)\/[0-9a-f]{6,}$/); + if (wtMatch) { + const root = wtMatch[1].split('/').filter(Boolean).slice(-2).join('-'); + const branch = `${wtMatch[2]}-${wtMatch[3]}`; + return `${root} [${branch}]`; + } + return p.split('/').filter(Boolean).pop() || p; +} + +function n(value) { + return Number.isFinite(Number(value)) ? Number(value) : 0; +} + +function yesNo(value) { + return value ? 'yes' : 'no'; +} + +function isoDate(value) { + if (!value) return ''; + const d = value instanceof Date ? value : new Date(value); + return Number.isNaN(d.getTime()) ? '' : d; +} + +function jsonValue(value) { + if (value === undefined || value === null) return ''; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +export function makeExportFilename(date = new Date()) { + const stamp = date.toISOString().slice(0, 16).replace(/[-:T]/g, ''); + return `agent-optimization-export-${stamp}.xlsx`; +} + +export function buildProjects(sessions) { + const map = new Map(); + for (const s of sessions || []) { + const key = normalizeProjectKey(s.project); + if (!map.has(key)) { + map.set(key, { + project: key, + projectLabel: s.projectLabel || deriveProjectLabel(key), + sources: new Set(), + models: new Set(), + sessions: 0, + input: 0, + output: 0, + cacheRead: 0, + cacheCreate: 0, + reasoning: 0, + total: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheCreate: 0, total: 0 }, + firstTs: null, + lastTs: null, + sizeBytes: 0, + }); + } + const p = map.get(key); + p.sessions++; + if (s.source) p.sources.add(s.source); + if (s.model) p.models.add(s.model); + for (const key of TOKEN_KEYS) p[key] += n(s[key]); + for (const key of COST_KEYS) p.cost[key] += n(s.cost?.[key]); + p.sizeBytes += n(s.sizeBytes); + if (s.firstTs && (!p.firstTs || s.firstTs < p.firstTs)) p.firstTs = s.firstTs; + if (s.lastTs && (!p.lastTs || s.lastTs > p.lastTs)) p.lastTs = s.lastTs; + } + return [...map.values()].sort((a, b) => b.cost.total - a.cost.total); +} + +export function buildDailyUsage(sessions) { + const map = new Map(); + for (const s of sessions || []) { + const ts = s.lastTs || s.firstTs || (s.mtime ? new Date(s.mtime).toISOString() : null); + const day = ts ? String(ts).slice(0, 10) : 'unknown'; + const source = s.source || 'unknown'; + const key = `${day}::${source}`; + if (!map.has(key)) { + map.set(key, { + date: day, + source, + sessions: 0, + input: 0, + output: 0, + cacheRead: 0, + cacheCreate: 0, + reasoning: 0, + total: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheCreate: 0, total: 0 }, + }); + } + const row = map.get(key); + row.sessions++; + for (const key of TOKEN_KEYS) row[key] += n(s[key]); + for (const key of COST_KEYS) row.cost[key] += n(s.cost?.[key]); + } + return [...map.values()].sort((a, b) => { + const d = String(a.date).localeCompare(String(b.date)); + return d || String(a.source).localeCompare(String(b.source)); + }); +} + +export function collectContextFiles(home, sessions, fsImpl = fs) { + const seen = new Set(); + const results = []; + + const addFile = (full, label, type, source) => { + if (seen.has(full)) return; + try { + const stat = fsImpl.statSync(full); + if (!stat.isFile()) return; + seen.add(full); + const content = fsImpl.readFileSync(full, 'utf8'); + results.push({ + path: full, + label, + bytes: stat.size, + lines: content.split('\n').length, + type, + source, + }); + } catch {} + }; + + const scanDir = (dir, label) => { + addFile(path.join(dir, 'CLAUDE.md'), label, 'CLAUDE.md', 'claude'); + addFile(path.join(dir, '.claude', 'CLAUDE.md'), label, 'CLAUDE.md', 'claude'); + addFile(path.join(dir, 'AGENTS.md'), label, 'AGENTS.md', 'codex'); + addFile(path.join(dir, '.codex', 'AGENTS.md'), label, 'AGENTS.md', 'codex'); + }; + + scanDir(home, 'Global (~)'); + scanDir(path.join(home, '.claude'), 'Global Claude (~/.claude)'); + scanDir(path.join(home, '.codex'), 'Global Codex (~/.codex)'); + + const roots = [path.join(home, 'Desktop'), path.join(home, 'Documents'), home]; + for (const root of roots) { + try { + for (const entry of fsImpl.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + const dir = path.join(root, entry.name); + scanDir(dir, entry.name); + try { + for (const sub of fsImpl.readdirSync(dir, { withFileTypes: true })) { + if (!sub.isDirectory() || sub.name.startsWith('.')) continue; + scanDir(path.join(dir, sub.name), `${entry.name}/${sub.name}`); + } + } catch {} + } + } catch {} + } + + for (const s of sessions || []) { + const p = s.project; + if (!p || p === 'unknown') continue; + try { + if (fsImpl.existsSync(p)) scanDir(p, s.projectLabel || path.basename(p)); + } catch {} + } + + results.sort((a, b) => b.bytes - a.bytes); + return { + claude: results.filter(r => r.source === 'claude'), + codex: results.filter(r => r.source === 'codex'), + all: results, + }; +} + +const col = (header, key, type = 'text', width = 18) => ({ header, key, type, width }); +const metricCol = (header, key, type = 'integer') => col(header, key, type, 14); +const costCol = (header, key) => col(header, key, 'currency', 14); + +function summaryRows({ cache, analysis, applied, contextFiles, wireStats, home, generatedAt }) { + const totals = cache?.summary?.totals || {}; + const claude = cache?.summary?.claude || {}; + const codex = cache?.summary?.codex || {}; + const appliedEntries = Object.values(applied || {}); + const ctxCount = (contextFiles?.all || []).length; + return [ + { section: 'Export', metric: 'Generated at', value: generatedAt, notes: 'Local dashboard export timestamp.' }, + { section: 'Export', metric: 'Home scanned', value: home || '', notes: 'Session roots are resolved from this home directory.' }, + { section: 'Sessions', metric: 'Total sessions', value: n(totals.sessions), notes: '' }, + { section: 'Sessions', metric: 'Total tokens', value: n(totals.total), notes: '' }, + { section: 'Cost', metric: 'Total estimated USD', value: n(totals.cost?.total), notes: 'Based on pricing.js model rates.' }, + { section: 'Claude Code', metric: 'Sessions', value: n(claude.sessions), notes: `${n(claude.total).toLocaleString()} tokens` }, + { section: 'Claude Code', metric: 'Estimated USD', value: n(claude.cost?.total), notes: '' }, + { section: 'Codex', metric: 'Sessions', value: n(codex.sessions), notes: `${n(codex.total).toLocaleString()} tokens` }, + { section: 'Codex', metric: 'Estimated USD', value: n(codex.cost?.total), notes: '' }, + { section: 'Analysis', metric: 'Generated at', value: analysis?.generatedAt ? isoDate(analysis.generatedAt) : '', notes: analysis?.llmPowered ? 'LLM-powered report' : 'Heuristic or unavailable report' }, + { section: 'Analysis', metric: 'Model', value: analysis?.llmModel || '', notes: analysis?.llmProvider || '' }, + { section: 'Analysis', metric: 'Estimated waste USD', value: n(analysis?.summary?.totalWasted), notes: `${n(analysis?.summary?.projects)} projects analyzed` }, + { section: 'Applied', metric: 'Applied suggestions', value: appliedEntries.length, notes: '' }, + { section: 'Context', metric: 'Context files', value: ctxCount, notes: `${n(contextFiles?.claude?.length)} Claude Β· ${n(contextFiles?.codex?.length)} Codex` }, + { section: 'Wire', metric: 'Patch updates sent', value: n(wireStats?.patchSent), notes: `${n(wireStats?.savedPct) * 100}% saved vs full payloads` }, + ]; +} + +function sessionRows(sessions) { + return (sessions || []).map(s => ({ + source: s.source || '', + sessionId: s.id || '', + sessionName: s.name || '', + projectLabel: s.projectLabel || deriveProjectLabel(s.project), + project: normalizeProjectKey(s.project), + file: s.file || '', + model: s.model || '', + input: n(s.input), + output: n(s.output), + cacheRead: n(s.cacheRead), + cacheCreate: n(s.cacheCreate), + reasoning: n(s.reasoning), + total: n(s.total), + costInput: n(s.cost?.input), + costOutput: n(s.cost?.output), + costCacheRead: n(s.cost?.cacheRead), + costCacheCreate: n(s.cost?.cacheCreate), + costTotal: n(s.cost?.total), + pricingLabel: s.cost?.rates?.label || s.model || '', + pricingFallback: yesNo(s.cost?.fallback), + rateInput: n(s.cost?.rates?.input), + rateOutput: n(s.cost?.rates?.output), + rateCacheRead: n(s.cost?.rates?.cacheRead), + rateCacheCreate: n(s.cost?.rates?.cacheCreate), + messages: n(s.messages), + firstTs: isoDate(s.firstTs), + lastTs: isoDate(s.lastTs), + fileMtime: s.mtime ? new Date(s.mtime) : '', + sizeBytes: n(s.sizeBytes), + })); +} + +function projectRows(projects) { + return (projects || []).map(p => ({ + projectLabel: p.projectLabel || deriveProjectLabel(p.project), + project: p.project, + sources: [...(p.sources || [])].join(', '), + models: [...(p.models || [])].join(', '), + sessions: n(p.sessions), + input: n(p.input), + output: n(p.output), + cacheRead: n(p.cacheRead), + cacheCreate: n(p.cacheCreate), + reasoning: n(p.reasoning), + total: n(p.total), + costInput: n(p.cost?.input), + costOutput: n(p.cost?.output), + costCacheRead: n(p.cost?.cacheRead), + costCacheCreate: n(p.cost?.cacheCreate), + costTotal: n(p.cost?.total), + firstTs: isoDate(p.firstTs), + lastTs: isoDate(p.lastTs), + sizeBytes: n(p.sizeBytes), + })); +} + +function analysisProjectRows(analysis) { + return (analysis?.projects || []).map(p => ({ + projectLabel: p.projectLabel || deriveProjectLabel(p.project), + project: p.project, + sources: (p.sources || []).join(', '), + sessions: n(p.sessionCount), + totalCost: n(p.totalCost), + wastedCost: n(p.wastedCost), + wastePct: n(p.totalCost) > 0 ? n(p.wastedCost) / n(p.totalCost) : 0, + findings: n(p.findings?.length), + llmModel: p.llmModel || '', + llmAnalyzed: yesNo(p.llmAnalyzed), + llmCached: yesNo(p.llmCached), + modelMix: (p.modelMix || []) + .map(m => `${m.model} (${n(m.sessions)} sessions, ${n(m.tokens)} tokens, $${n(m.cost).toFixed(4)})`) + .join('; '), + })); +} + +function findingRows(analysis) { + const rows = []; + for (const p of analysis?.projects || []) { + for (const f of p.findings || []) { + rows.push({ + projectLabel: p.projectLabel || deriveProjectLabel(p.project), + project: p.project, + findingId: f.id || '', + title: f.title || '', + severity: f.severity || '', + summary: f.summary || '', + impact: f.impact || '', + recommendation: f.recommendation || '', + metricWastedCost: n(f.metric?.wastedCost), + metricSavings: n(f.metric?.savings), + metricCost: n(f.metric?.cost), + metricTokens: n(f.metric?.tokens), + metricCount: n(f.metric?.count), + applied: yesNo(f.applied), + appliedAt: isoDate(f.applied?.appliedAt), + baselineCost: n(f.applied?.baselineCost), + realizedSavedCost: n(f.applied?.realizedSavedCost), + resolved: yesNo(f.applied?.resolved), + }); + } + } + return rows; +} + +function findingExampleRows(analysis) { + const rows = []; + for (const p of analysis?.projects || []) { + for (const f of p.findings || []) { + for (const ex of f.examples || []) { + rows.push({ + projectLabel: p.projectLabel || deriveProjectLabel(p.project), + project: p.project, + findingId: f.id || '', + sessionId: ex.sessionId || '', + sessionName: ex.sessionName || '', + model: ex.model || '', + lastTs: isoDate(ex.lastTs), + ratio: ex.ratio || '', + outputShare: ex.outputShare || '', + cacheCreate: n(ex.cacheCreate), + cacheRead: n(ex.cacheRead), + output: n(ex.output), + reasoning: n(ex.reasoning), + total: n(ex.total), + messages: n(ex.messages), + wastedCost: n(ex.wastedCost), + savings: n(ex.savings), + cacheReadCost: n(ex.cacheReadCost), + outputCost: n(ex.outputCost), + cost: n(ex.cost), + exampleJson: jsonValue(ex), + }); + } + } + } + return rows; +} + +function appliedRows(applied) { + return Object.values(applied || {}).map(a => ({ + project: a.project || '', + findingId: a.findingId || '', + appliedAt: isoDate(a.appliedAt), + actuated: yesNo(a.actuated), + baselineTitle: a.baseline?.title || '', + baselineWastedCost: n(a.baseline?.wastedCost), + baselineSeverity: a.baseline?.severity || '', + baselineSessionIds: (a.baseline?.sessionIds || []).join(', '), + realizedSavedCost: n(a.realized?.savedCost), + baselineCost: n(a.realized?.baselineCost), + currentCost: n(a.realized?.currentCost), + lastChecked: isoDate(a.realized?.lastChecked), + resolved: yesNo(a.realized?.resolved), + actuationJson: jsonValue(a.actuation), + })).sort((a, b) => String(b.appliedAt).localeCompare(String(a.appliedAt))); +} + +function contextRows(contextFiles) { + return (contextFiles?.all || []).map(f => ({ + source: f.source || '', + type: f.type || '', + label: f.label || '', + path: f.path || '', + bytes: n(f.bytes), + lines: n(f.lines), + })); +} + +function wireRows(wireStats) { + return [{ + patchSent: n(wireStats?.patchSent), + fullSent: n(wireStats?.fullSent), + patchBytes: n(wireStats?.patchBytes), + wouldHaveBeenBytes: n(wireStats?.wouldHaveBeenBytes), + initBytes: n(wireStats?.initBytes), + savedBytes: n(wireStats?.savedBytes), + savedPct: n(wireStats?.savedPct), + uptimeSec: n(wireStats?.uptimeSec), + }]; +} + +function dictionaryRows(sheets) { + return sheets.map(sheet => ({ + sheet: sheet.name, + rows: sheet.rows.length, + columns: sheet.columns.length, + description: SHEET_DESCRIPTIONS[sheet.name] || '', + })); +} + +export function buildExportModel({ + cache, + analysis, + applied = {}, + contextFiles = { all: [], claude: [], codex: [] }, + wireStats = {}, + home = '', + generatedAt = new Date(), +} = {}) { + const sessions = cache?.sessions || []; + const projects = buildProjects(sessions); + const dailyUsage = buildDailyUsage(sessions); + const sheets = [ + { + name: 'Summary', + columns: [ + col('Section', 'section', 'text', 16), + col('Metric', 'metric', 'text', 24), + col('Value', 'value', 'auto', 24), + col('Notes', 'notes', 'wrap', 56), + ], + rows: summaryRows({ cache, analysis, applied, contextFiles, wireStats, home, generatedAt }), + }, + { + name: 'Sessions', + columns: [ + col('Source', 'source', 'text', 10), + col('Session ID', 'sessionId', 'text', 36), + col('Session Name', 'sessionName', 'wrap', 42), + col('Project Label', 'projectLabel', 'text', 28), + col('Project Path', 'project', 'wrap', 52), + col('File', 'file', 'wrap', 52), + col('Model', 'model', 'text', 22), + metricCol('Input', 'input'), + metricCol('Output', 'output'), + metricCol('Cache Read', 'cacheRead'), + metricCol('Cache Write', 'cacheCreate'), + metricCol('Reasoning', 'reasoning'), + metricCol('Total', 'total'), + costCol('Cost Input', 'costInput'), + costCol('Cost Output', 'costOutput'), + costCol('Cost Cache Read', 'costCacheRead'), + costCol('Cost Cache Write', 'costCacheCreate'), + costCol('Cost Total', 'costTotal'), + col('Pricing Label', 'pricingLabel', 'text', 24), + col('Fallback Pricing', 'pricingFallback', 'text', 14), + costCol('Rate Input / 1M', 'rateInput'), + costCol('Rate Output / 1M', 'rateOutput'), + costCol('Rate Cache Read / 1M', 'rateCacheRead'), + costCol('Rate Cache Write / 1M', 'rateCacheCreate'), + metricCol('Messages', 'messages'), + col('First Activity', 'firstTs', 'date', 20), + col('Last Activity', 'lastTs', 'date', 20), + col('File Modified', 'fileMtime', 'date', 20), + metricCol('Size Bytes', 'sizeBytes'), + ], + rows: sessionRows(sessions), + }, + { + name: 'Projects', + columns: [ + col('Project Label', 'projectLabel', 'text', 28), + col('Project Path', 'project', 'wrap', 54), + col('Sources', 'sources', 'text', 16), + col('Models', 'models', 'wrap', 42), + metricCol('Sessions', 'sessions'), + metricCol('Input', 'input'), + metricCol('Output', 'output'), + metricCol('Cache Read', 'cacheRead'), + metricCol('Cache Write', 'cacheCreate'), + metricCol('Reasoning', 'reasoning'), + metricCol('Total', 'total'), + costCol('Cost Input', 'costInput'), + costCol('Cost Output', 'costOutput'), + costCol('Cost Cache Read', 'costCacheRead'), + costCol('Cost Cache Write', 'costCacheCreate'), + costCol('Cost Total', 'costTotal'), + col('First Activity', 'firstTs', 'date', 20), + col('Last Activity', 'lastTs', 'date', 20), + metricCol('Size Bytes', 'sizeBytes'), + ], + rows: projectRows(projects), + }, + { + name: 'Daily Usage', + columns: [ + col('Date', 'date', 'text', 14), + col('Source', 'source', 'text', 10), + metricCol('Sessions', 'sessions'), + metricCol('Input', 'input'), + metricCol('Output', 'output'), + metricCol('Cache Read', 'cacheRead'), + metricCol('Cache Write', 'cacheCreate'), + metricCol('Reasoning', 'reasoning'), + metricCol('Total', 'total'), + costCol('Cost Input', 'costInput'), + costCol('Cost Output', 'costOutput'), + costCol('Cost Cache Read', 'costCacheRead'), + costCol('Cost Cache Write', 'costCacheCreate'), + costCol('Cost Total', 'costTotal'), + ], + rows: dailyUsage.map(d => ({ + ...d, + costInput: n(d.cost?.input), + costOutput: n(d.cost?.output), + costCacheRead: n(d.cost?.cacheRead), + costCacheCreate: n(d.cost?.cacheCreate), + costTotal: n(d.cost?.total), + })), + }, + { + name: 'Analysis Projects', + columns: [ + col('Project Label', 'projectLabel', 'text', 28), + col('Project Path', 'project', 'wrap', 54), + col('Sources', 'sources', 'text', 16), + metricCol('Sessions', 'sessions'), + costCol('Total Cost', 'totalCost'), + costCol('Wasted Cost', 'wastedCost'), + col('Waste %', 'wastePct', 'percent', 12), + metricCol('Findings', 'findings'), + col('LLM Model', 'llmModel', 'text', 22), + col('LLM Analyzed', 'llmAnalyzed', 'text', 14), + col('LLM Cached', 'llmCached', 'text', 12), + col('Model Mix', 'modelMix', 'wrap', 64), + ], + rows: analysisProjectRows(analysis), + }, + { + name: 'Findings', + columns: [ + col('Project Label', 'projectLabel', 'text', 28), + col('Project Path', 'project', 'wrap', 54), + col('Finding ID', 'findingId', 'text', 22), + col('Title', 'title', 'wrap', 34), + col('Severity', 'severity', 'text', 12), + col('Summary', 'summary', 'wrap', 58), + col('Impact', 'impact', 'wrap', 42), + col('Recommendation', 'recommendation', 'wrap', 64), + costCol('Metric Wasted Cost', 'metricWastedCost'), + costCol('Metric Savings', 'metricSavings'), + costCol('Metric Cost', 'metricCost'), + metricCol('Metric Tokens', 'metricTokens'), + metricCol('Metric Count', 'metricCount'), + col('Applied', 'applied', 'text', 10), + col('Applied At', 'appliedAt', 'date', 20), + costCol('Baseline Cost', 'baselineCost'), + costCol('Realized Saved', 'realizedSavedCost'), + col('Resolved', 'resolved', 'text', 10), + ], + rows: findingRows(analysis), + }, + { + name: 'Finding Examples', + columns: [ + col('Project Label', 'projectLabel', 'text', 28), + col('Project Path', 'project', 'wrap', 54), + col('Finding ID', 'findingId', 'text', 22), + col('Session ID', 'sessionId', 'text', 36), + col('Session Name', 'sessionName', 'wrap', 42), + col('Model', 'model', 'text', 22), + col('Last Activity', 'lastTs', 'date', 20), + col('Ratio', 'ratio', 'text', 12), + col('Output Share', 'outputShare', 'text', 14), + metricCol('Cache Write', 'cacheCreate'), + metricCol('Cache Read', 'cacheRead'), + metricCol('Output', 'output'), + metricCol('Reasoning', 'reasoning'), + metricCol('Total', 'total'), + metricCol('Messages', 'messages'), + costCol('Wasted Cost', 'wastedCost'), + costCol('Savings', 'savings'), + costCol('Cache Read Cost', 'cacheReadCost'), + costCol('Output Cost', 'outputCost'), + costCol('Cost', 'cost'), + col('Example JSON', 'exampleJson', 'wrap', 70), + ], + rows: findingExampleRows(analysis), + }, + { + name: 'Applied Actions', + columns: [ + col('Project Path', 'project', 'wrap', 54), + col('Finding ID', 'findingId', 'text', 22), + col('Applied At', 'appliedAt', 'date', 20), + col('Actuated', 'actuated', 'text', 10), + col('Baseline Title', 'baselineTitle', 'wrap', 34), + costCol('Baseline Wasted Cost', 'baselineWastedCost'), + col('Baseline Severity', 'baselineSeverity', 'text', 14), + col('Baseline Session IDs', 'baselineSessionIds', 'wrap', 54), + costCol('Realized Saved', 'realizedSavedCost'), + costCol('Baseline Cost', 'baselineCost'), + costCol('Current Cost', 'currentCost'), + col('Last Checked', 'lastChecked', 'date', 20), + col('Resolved', 'resolved', 'text', 10), + col('Actuation JSON', 'actuationJson', 'wrap', 70), + ], + rows: appliedRows(applied), + }, + { + name: 'Context Files', + columns: [ + col('Source', 'source', 'text', 10), + col('Type', 'type', 'text', 14), + col('Label', 'label', 'text', 30), + col('Path', 'path', 'wrap', 62), + metricCol('Bytes', 'bytes'), + metricCol('Lines', 'lines'), + ], + rows: contextRows(contextFiles), + }, + { + name: 'Wire Stats', + columns: [ + metricCol('Patch Sent', 'patchSent'), + metricCol('Full Sent', 'fullSent'), + metricCol('Patch Bytes', 'patchBytes'), + metricCol('Would Have Been Bytes', 'wouldHaveBeenBytes'), + metricCol('Init Bytes', 'initBytes'), + metricCol('Saved Bytes', 'savedBytes'), + col('Saved %', 'savedPct', 'percent', 12), + metricCol('Uptime Sec', 'uptimeSec'), + ], + rows: wireRows(wireStats), + }, + ]; + + sheets.push({ + name: 'Dictionary', + columns: [ + col('Sheet', 'sheet', 'text', 22), + metricCol('Rows', 'rows'), + metricCol('Columns', 'columns'), + col('Description', 'description', 'wrap', 62), + ], + rows: dictionaryRows(sheets), + }); + + return { sheets }; +} + +function sanitizeSheetName(name, used) { + let base = String(name || 'Sheet').replace(/[\[\]:*?/\\]/g, ' ').trim() || 'Sheet'; + base = base.slice(0, 31); + let out = base; + let i = 2; + while (used.has(out)) { + const suffix = ` ${i++}`; + out = base.slice(0, 31 - suffix.length) + suffix; + } + used.add(out); + return out; +} + +function columnName(index) { + let n = index + 1; + let out = ''; + while (n > 0) { + const rem = (n - 1) % 26; + out = String.fromCharCode(65 + rem) + out; + n = Math.floor((n - 1) / 26); + } + return out; +} + +function xmlEscape(value) { + return String(value ?? '') + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, ' ') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function excelDate(value) { + const d = value instanceof Date ? value : new Date(value); + if (Number.isNaN(d.getTime())) return null; + return d.getTime() / 86400000 + 25569; +} + +function styleFor(type, value) { + if (type === 'integer') return 2; + if (type === 'currency') return 3; + if (type === 'percent') return 4; + if (type === 'date') return 5; + if (type === 'wrap') return 6; + if (type === 'auto' && typeof value === 'number') return Number.isInteger(value) ? 2 : 3; + if (type === 'auto' && value instanceof Date) return 5; + return 0; +} + +function cellXml(ref, value, type, style) { + if (value === undefined || value === null || value === '') { + return style ? `` : ''; + } + + if (type === 'date' || value instanceof Date) { + const serial = excelDate(value); + if (serial !== null) return `${serial}`; + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return `${value}`; + } + + if (typeof value === 'boolean') { + return `${value ? 1 : 0}`; + } + + const text = xmlEscape(value); + return `${text}`; +} + +function sheetXml(sheet) { + const columns = sheet.columns || []; + const rows = sheet.rows || []; + const widthXml = columns.map((c, i) => + `` + ).join(''); + const header = `${columns.map((c, i) => + cellXml(`${columnName(i)}1`, c.header, 'text', 1) + ).join('')}`; + const body = rows.map((row, rowIdx) => { + const r = rowIdx + 2; + const cells = columns.map((c, colIdx) => { + const value = typeof c.value === 'function' ? c.value(row) : row[c.key]; + const style = styleFor(c.type, value); + return cellXml(`${columnName(colIdx)}${r}`, value, c.type, style); + }).join(''); + return `${cells}`; + }).join(''); + + const lastCol = columnName(Math.max(columns.length - 1, 0)); + const lastRow = Math.max(rows.length + 1, 1); + const filter = rows.length ? `` : ''; + + return ` + + + + + ${widthXml} + ${header}${body} + ${filter} + +`; +} + +function stylesXml() { + return ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; +} + +function workbookXml(sheetNames) { + const sheetXml = sheetNames.map((name, i) => + `` + ).join(''); + return ` + + + ${sheetXml} +`; +} + +function workbookRels(sheetCount) { + const sheetRels = Array.from({ length: sheetCount }, (_, i) => + `` + ).join(''); + return ` + + ${sheetRels} + +`; +} + +function rootRels() { + return ` + + + + +`; +} + +function contentTypes(sheetCount) { + const sheets = Array.from({ length: sheetCount }, (_, i) => + `` + ).join(''); + return ` + + + + + + ${sheets} + + +`; +} + +function coreProps(generatedAt) { + const iso = (generatedAt instanceof Date ? generatedAt : new Date(generatedAt)).toISOString(); + return ` + + Agent Optimization Export + Agent Optimization + Agent Optimization + ${iso} + ${iso} +`; +} + +function appProps(sheetNames) { + const names = sheetNames.map(name => `${xmlEscape(name)}`).join(''); + return ` + + Agent Optimization + 0 + false + Worksheets${sheetNames.length} + ${names} + + false + false + false + 16.0300 +`; +} + +function makeCrcTable() { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[i] = c >>> 0; + } + return table; +} + +const CRC_TABLE = makeCrcTable(); + +function crc32(buffer) { + let crc = 0xffffffff; + for (let i = 0; i < buffer.length; i++) { + crc = CRC_TABLE[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function dosDateTime(date = new Date()) { + const year = Math.max(1980, date.getFullYear()); + const dosTime = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2); + const dosDate = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); + return { dosTime, dosDate }; +} + +function createZip(entries, date = new Date()) { + const localParts = []; + const centralParts = []; + let offset = 0; + const { dosTime, dosDate } = dosDateTime(date); + + for (const entry of entries) { + const name = Buffer.from(entry.name, 'utf8'); + const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data, 'utf8'); + const crc = crc32(data); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0, 6); + local.writeUInt16LE(0, 8); + local.writeUInt16LE(dosTime, 10); + local.writeUInt16LE(dosDate, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(0, 28); + localParts.push(local, name, data); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(dosTime, 12); + central.writeUInt16LE(dosDate, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(0, 38); + central.writeUInt32LE(offset, 42); + centralParts.push(central, name); + offset += local.length + name.length + data.length; + } + + const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralSize, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([...localParts, ...centralParts, end]); +} + +export function buildExcelExport(input = {}) { + const generatedAt = input.generatedAt || new Date(); + const model = buildExportModel({ ...input, generatedAt }); + const usedNames = new Set(); + const sheets = model.sheets.map(sheet => ({ + ...sheet, + safeName: sanitizeSheetName(sheet.name, usedNames), + })); + const sheetNames = sheets.map(s => s.safeName); + const entries = [ + { name: '[Content_Types].xml', data: contentTypes(sheets.length) }, + { name: '_rels/.rels', data: rootRels() }, + { name: 'docProps/core.xml', data: coreProps(generatedAt) }, + { name: 'docProps/app.xml', data: appProps(sheetNames) }, + { name: 'xl/workbook.xml', data: workbookXml(sheetNames) }, + { name: 'xl/_rels/workbook.xml.rels', data: workbookRels(sheets.length) }, + { name: 'xl/styles.xml', data: stylesXml() }, + ...sheets.map((sheet, i) => ({ + name: `xl/worksheets/sheet${i + 1}.xml`, + data: sheetXml(sheet), + })), + ]; + return createZip(entries, generatedAt); +} diff --git a/public/app.js b/public/app.js index 639247c..ffa2bfd 100644 --- a/public/app.js +++ b/public/app.js @@ -38,6 +38,20 @@ const COLORS = { let currentFilter = 'all'; let currentCache = null; +const EXPORT_SHEETS = [ + ['Summary', 'Export metadata and top-level totals'], + ['Sessions', 'Every Claude Code and Codex session'], + ['Projects', 'Project rollups with token and cost totals'], + ['Daily Usage', 'Daily source-level token and cost rollups'], + ['Analysis Projects', 'Optimization analysis by project'], + ['Findings', 'Flattened waste findings and recommendations'], + ['Finding Examples', 'Sample sessions behind each finding'], + ['Applied Actions', 'Tracked applied suggestions and savings'], + ['Context Files', 'CLAUDE.md and AGENTS.md inventory'], + ['Wire Stats', 'Live delta traffic telemetry'], + ['Dictionary', 'Sheet guide and row counts'], +]; + function setKpiCost(elId, usd) { const el = document.getElementById(elId); if (!el) return; @@ -195,6 +209,8 @@ function render(cache, animate = false) { const m = hash.match(/^#\/projects\/(.+)$/); if (m) renderProjectDetail(decodeURIComponent(m[1])); else renderProjectsList(buildProjects(cache.sessions)); + } else if (hash.startsWith('#/export')) { + renderExportPanel(); } } @@ -465,6 +481,8 @@ function renderAnalysis(report) { else proj.setAttribute('open', ''); }); }); + + if ((location.hash || '').startsWith('#/export')) renderExportPanel(); } function renderAnaProject(p, openFirst) { @@ -960,6 +978,64 @@ document.querySelectorAll('.chip[data-pfilter]').forEach(btn => { }); }); +// ===== Export view ===== + +function renderExportPanel() { + if (!currentCache) return; + const projects = buildProjects(currentCache.sessions || []); + const findings = (analysisCache?.projects || []).reduce((sum, p) => sum + (p.findings?.length || 0), 0); + const totalCost = currentCache.summary?.totals?.cost?.total || 0; + + document.getElementById('export-sessions').textContent = fmt(currentCache.summary?.totals?.sessions || 0); + document.getElementById('export-projects').textContent = fmt(projects.length); + document.getElementById('export-findings').textContent = fmt(findings); + document.getElementById('export-cost').textContent = fmtUSD(totalCost); + document.getElementById('export-cost').title = fmtUSDFull(totalCost); + + const updated = document.getElementById('export-updated'); + if (updated) { + const date = new Date(currentCache.ts || Date.now()); + updated.textContent = `Last scan ${date.toLocaleString('tr-TR')}`; + } + + const grid = document.getElementById('export-sheet-grid'); + if (grid) { + grid.innerHTML = EXPORT_SHEETS.map(([name, desc], idx) => ` +
+ ${String(idx + 1).padStart(2, '0')} +
+

${escapeHtml(name)}

+

${escapeHtml(desc)}

+
+
+ `).join(''); + } +} + +document.getElementById('export-refresh')?.addEventListener('click', async (e) => { + const btn = e.currentTarget; + btn.disabled = true; + btn.textContent = 'Refreshing...'; + try { + const r = await fetch('/api/refresh'); + render(await r.json(), true); + renderExportPanel(); + } catch (err) { + showErrorModal('Could not refresh export data', err); + } finally { + btn.disabled = false; + btn.textContent = 'Refresh scan'; + } +}); + +document.getElementById('export-download')?.addEventListener('click', () => { + showToast({ + title: 'Excel export', + body: 'Preparing the workbook with all current dashboard data.', + durationMs: 3500, + }); +}); + // ===== Routing ===== function route() { @@ -967,6 +1043,7 @@ function route() { const overview = document.getElementById('view-overview'); const projectsView = document.getElementById('view-projects'); const analysisView = document.getElementById('view-analysis'); + const exportView = document.getElementById('view-export'); const listWrap = document.getElementById('projects-list-wrap'); const detail = document.getElementById('project-detail'); @@ -975,6 +1052,7 @@ function route() { overview.hidden = true; projectsView.hidden = true; analysisView.hidden = true; + exportView.hidden = true; document.getElementById('view-charts').hidden = true; if (hash.startsWith('#/projects')) { @@ -1003,6 +1081,11 @@ function route() { document.getElementById('view-charts').hidden = false; document.querySelector('.tab[data-route="charts"]').classList.add('active'); loadCharts(); + } else if (hash.startsWith('#/export')) { + exportView.hidden = false; + document.querySelector('.tab[data-route="export"]').classList.add('active'); + if (!analysisCache) fetch('/api/analysis').then(r => r.json()).then(renderAnalysis).catch(() => {}); + renderExportPanel(); } else { overview.hidden = false; document.querySelector('.tab[data-route="overview"]').classList.add('active'); diff --git a/public/index.html b/public/index.html index 748acf4..761c085 100644 --- a/public/index.html +++ b/public/index.html @@ -35,6 +35,7 @@

Agent Optimization

Projects Analysis Charts + Export
@@ -300,6 +301,50 @@

Optimizatio
+
+
+
+
+

Excel Export

+

Workbook-ready data from sessions, projects, analysis, context files and telemetry

+
+
+ + Download XLSX +
+
+
+
+
Sessions
+
β€”
+
+
+
Projects
+
β€”
+
+
+
Findings
+
β€”
+
+
+
Estimated Cost
+
β€”
+
+
+
+ +
+
+
+

Workbook Sheets

+

Flattened for Excel filters, pivots and imports

+
+ β€” +
+
+
+
+