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..c63098f 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
@@ -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.
@@ -62,7 +70,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

-**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

**Charts** β daily token/cost bar chart (Today / 7 Days / 30 Days) and context file size inventory
@@ -109,15 +117,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 +154,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 +185,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 +229,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/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/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..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();
}
}
@@ -421,7 +437,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 +459,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
@@ -460,16 +481,20 @@ function renderAnalysis(report) {
else proj.setAttribute('open', '');
});
});
+
+ if ((location.hash || '').startsWith('#/export')) renderExportPanel();
}
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 +510,8 @@ function renderAnaProject(p, openFirst) {
${initial}
@@ -571,12 +596,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 +678,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(`
+
+
β οΈ
+
${escapeHtml(title)}
+
${escapeHtml(error?.message || String(error || 'Something went wrong.'))}
+
+
+ `);
+}
+
const RECOMMENDATION_CHECKLISTS = {
'context-bloat': [
'Trim CLAUDE.md / AGENTS.md to essentials',
@@ -818,15 +863,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 +883,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 +912,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 +934,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;
+ }
}
});
@@ -911,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() {
@@ -918,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');
@@ -926,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')) {
@@ -954,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 44f20b1..761c085 100644
--- a/public/index.html
+++ b/public/index.html
@@ -35,6 +35,7 @@
Agent Optimization
Projects
Analysis
Charts
+
Export
@@ -247,7 +248,7 @@
Sessions in this project
Optimization Analysis
- β¦ Haiku
+ β¦ GPT
β Heuristic
Auto-runs every 2 hours Β· scans every project's sessions for wasteful patterns
@@ -300,6 +301,50 @@
Optimizatio
+
+
+
+
+
Excel Export
+
Workbook-ready data from sessions, projects, analysis, context files and telemetry
+
+
+
+
+
+
+
+
+
+
Workbook Sheets
+
Flattened for Excel filters, pivots and imports
+
+
β
+
+
+
+
+