@rosh100yx/outlier 0.4.25 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -53
- package/bin/outlier.js +804 -115
- package/data/grid-factors.json +16 -3
- package/package.json +1 -1
- package/src/aggregate.ts +59 -0
- package/src/capabilities.ts +103 -58
- package/src/carbon.ts +66 -14
- package/src/cli.ts +290 -26
- package/src/economics.ts +66 -0
- package/src/emissions.ts +73 -0
- package/src/insights.ts +109 -0
- package/src/sources.ts +110 -0
package/data/grid-factors.json
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"vietnam": 681,
|
|
3
|
-
"
|
|
4
|
-
"
|
|
3
|
+
"india_average": 715,
|
|
4
|
+
"indonesia": 650,
|
|
5
|
+
"china": 581,
|
|
5
6
|
"singapore": 408,
|
|
6
|
-
"
|
|
7
|
+
"japan": 470,
|
|
8
|
+
"south_korea": 415,
|
|
9
|
+
"australia": 510,
|
|
10
|
+
"us_east": 380,
|
|
11
|
+
"us_west": 210,
|
|
12
|
+
"canada": 120,
|
|
13
|
+
"brazil": 95,
|
|
14
|
+
"uk": 210,
|
|
15
|
+
"germany": 350,
|
|
16
|
+
"france": 21.7,
|
|
17
|
+
"norway": 28,
|
|
18
|
+
"sweden": 41,
|
|
19
|
+
"global_average": 450
|
|
7
20
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rosh100yx/outlier",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "AI Code Governance & Capability Auditing for the Terminal. Measures AI reliance, context waste, and enforces local CI/CD policies.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"outlier": "bin/outlier.js"
|
package/src/aggregate.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// #8 Light team/fleet aggregation — local-first, no export.
|
|
2
|
+
//
|
|
3
|
+
// Each developer runs `outlier --json > me.json` and drops it in a shared folder (or CI
|
|
4
|
+
// collects them). `outlier aggregate <dir>` merges those JSON files into a team rollup.
|
|
5
|
+
// No machine talks to another; it only reads JSON the team already produced. This is the
|
|
6
|
+
// honest, minimal version of fleet view — real aggregation without a backend.
|
|
7
|
+
|
|
8
|
+
import { readdirSync, readFileSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
|
|
11
|
+
export interface TeamRollup {
|
|
12
|
+
developers: number;
|
|
13
|
+
avgAiPercent: number | null;
|
|
14
|
+
maxAiPercent: number | null;
|
|
15
|
+
totalEstUsd: number;
|
|
16
|
+
worstBlastRadius: string;
|
|
17
|
+
overLimit: number; // how many devs are over their AI cap
|
|
18
|
+
reachWriteDeploy: number; // total write/deploy-capable tools across the team
|
|
19
|
+
notes: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const BLAST_ORDER = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
|
|
23
|
+
|
|
24
|
+
export function aggregateDir(dir: string): TeamRollup {
|
|
25
|
+
const files = readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
26
|
+
const audits: any[] = [];
|
|
27
|
+
for (const f of files) {
|
|
28
|
+
try {
|
|
29
|
+
const j = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
|
|
30
|
+
if (j?.tool === 'outlier') audits.push(j);
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const aiPcts = audits.map(a => a.authorship?.aiPercent).filter((x: any) => typeof x === 'number');
|
|
35
|
+
const spends = audits.map(a => a.cost?.estUsd || 0);
|
|
36
|
+
const blasts = audits.map(a => a.reach?.blastRadius).filter(Boolean);
|
|
37
|
+
const overs = audits.filter(a => a.policy?.status === 'over').length;
|
|
38
|
+
const writeDeploy = audits.reduce((s, a) => s + (a.reach?.writeOrDeployCount || 0), 0);
|
|
39
|
+
|
|
40
|
+
const worst = blasts.reduce((m: string, b: string) =>
|
|
41
|
+
BLAST_ORDER.indexOf(b) > BLAST_ORDER.indexOf(m) ? b : m, 'LOW');
|
|
42
|
+
|
|
43
|
+
const notes: string[] = [];
|
|
44
|
+
if (aiPcts.length && Math.max(...aiPcts) > 70) notes.push('At least one developer is over 70% AI authorship.');
|
|
45
|
+
if (worst === 'HIGH' || worst === 'CRITICAL') notes.push(`Worst-case agent blast radius across the team is ${worst}.`);
|
|
46
|
+
if (overs > 0) notes.push(`${overs} developer(s) over their AI-authorship limit.`);
|
|
47
|
+
if (audits.length === 0) notes.push('No outlier --json files found in this folder.');
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
developers: audits.length,
|
|
51
|
+
avgAiPercent: aiPcts.length ? +(aiPcts.reduce((a: number, b: number) => a + b, 0) / aiPcts.length).toFixed(1) : null,
|
|
52
|
+
maxAiPercent: aiPcts.length ? Math.max(...aiPcts) : null,
|
|
53
|
+
totalEstUsd: +spends.reduce((a: number, b: number) => a + b, 0).toFixed(2),
|
|
54
|
+
worstBlastRadius: worst,
|
|
55
|
+
overLimit: overs,
|
|
56
|
+
reachWriteDeploy: writeDeploy,
|
|
57
|
+
notes,
|
|
58
|
+
};
|
|
59
|
+
}
|
package/src/capabilities.ts
CHANGED
|
@@ -1,76 +1,121 @@
|
|
|
1
1
|
import { homedir } from 'os';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
-
import { existsSync, readdirSync } from 'fs';
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
4
|
+
|
|
5
|
+
// Reach = what a tool can actually do to you if an agent (or a prompt injection) drives it.
|
|
6
|
+
export type Reach = 'read' | 'network' | 'model' | 'data' | 'write-local' | 'write-remote' | 'deploy' | 'exec' | 'money';
|
|
7
|
+
|
|
8
|
+
export interface ToolReach {
|
|
9
|
+
name: string;
|
|
10
|
+
reach: Reach;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type BlastRadius = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
|
4
14
|
|
|
5
15
|
export interface CapabilitiesStats {
|
|
6
|
-
mcps:
|
|
16
|
+
mcps: ToolReach[];
|
|
7
17
|
skills: string[];
|
|
18
|
+
subagents: number;
|
|
19
|
+
hooks: string[]; // lifecycle events with auto-firing hooks
|
|
8
20
|
hasOrchestration: boolean;
|
|
21
|
+
blastRadius: BlastRadius;
|
|
22
|
+
blastReasons: string[]; // plain-language reasons for the score
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Classify an MCP/tool by name into a reach category. Keyword-based, since names vary.
|
|
26
|
+
export function classifyReach(name: string): Reach {
|
|
27
|
+
const n = name.toLowerCase();
|
|
28
|
+
if (/(stripe|payment|infinity|kucoin|wallet|billing|payout)/.test(n)) return 'money';
|
|
29
|
+
if (/(shell|bash|exec|terminal|command|sandbox)/.test(n)) return 'exec';
|
|
30
|
+
if (/(cloudflare|vercel|netlify|modal|deploy|fly\.io|render|heroku|aws|gcp|azure)/.test(n)) return 'deploy';
|
|
31
|
+
if (/(github|gitlab|git|bitbucket)/.test(n)) return 'write-remote';
|
|
32
|
+
if (/(filesystem|file|fs|disk)/.test(n)) return 'write-local';
|
|
33
|
+
if (/(memory|supermemory|mem|obsidian|notion|airtable|coda|database|sql|store)/.test(n)) return 'data';
|
|
34
|
+
if (/(openrouter|ollama|openai|anthropic|llm|model|hugging)/.test(n)) return 'model';
|
|
35
|
+
if (/(exa|web|fetch|search|brave|browser|http|scrape)/.test(n)) return 'network';
|
|
36
|
+
return 'network'; // unknown tool: assume it can reach out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const REACH_RISK: Record<Reach, number> = {
|
|
40
|
+
read: 0, model: 1, network: 1, 'data': 2, 'write-local': 2, 'write-remote': 3, deploy: 3, exec: 4, money: 4,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function scoreBlast(reaches: Reach[]): { radius: BlastRadius; reasons: string[] } {
|
|
44
|
+
const reasons: string[] = [];
|
|
45
|
+
const has = (r: Reach) => reaches.includes(r);
|
|
46
|
+
if (has('money')) reasons.push('can move money');
|
|
47
|
+
if (has('exec')) reasons.push('can run shell commands');
|
|
48
|
+
if (has('deploy')) reasons.push('can deploy to production');
|
|
49
|
+
if (has('write-remote')) reasons.push('can push to your remote repos');
|
|
50
|
+
if (has('write-local')) reasons.push('can write your local files');
|
|
51
|
+
if (has('data')) reasons.push('can read/write your stored data');
|
|
52
|
+
const netCount = reaches.filter(r => r === 'network' || r === 'model').length;
|
|
53
|
+
if (netCount >= 3) reasons.push(`reaches ${netCount} external services`);
|
|
54
|
+
|
|
55
|
+
const max = reaches.reduce((m, r) => Math.max(m, REACH_RISK[r]), 0);
|
|
56
|
+
let radius: BlastRadius = 'LOW';
|
|
57
|
+
if (max >= 4) radius = 'CRITICAL';
|
|
58
|
+
else if (max >= 3) radius = 'HIGH';
|
|
59
|
+
else if (max >= 2) radius = 'MEDIUM';
|
|
60
|
+
return { radius, reasons };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readJson(path: string): any {
|
|
64
|
+
try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function countDir(path: string, ext = '.md'): number {
|
|
68
|
+
try { return readdirSync(path).filter(f => f.endsWith(ext)).length; } catch { return 0; }
|
|
9
69
|
}
|
|
10
70
|
|
|
11
71
|
export async function getCapabilitiesStats(repoPath: string = process.cwd(), homeDirPath: string = homedir()): Promise<CapabilitiesStats> {
|
|
12
|
-
const
|
|
13
|
-
mcps: [],
|
|
14
|
-
skills: [],
|
|
15
|
-
hasOrchestration: false
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
// Check for AGENTS.md (Orchestration Policy)
|
|
19
|
-
if (existsSync(join(repoPath, 'AGENTS.md'))) {
|
|
20
|
-
stats.hasOrchestration = true;
|
|
21
|
-
}
|
|
72
|
+
const mcpNames = new Set<string>();
|
|
22
73
|
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
74
|
+
// MCP servers — the agent's actual tool reach. Read from every place they live.
|
|
75
|
+
// Read MCP servers from every place agents declare them (Claude, Cursor, Gemini, project).
|
|
76
|
+
for (const cfg of [
|
|
77
|
+
join(homeDirPath, '.claude.json'),
|
|
78
|
+
join(homeDirPath, '.claude', 'settings.json'),
|
|
79
|
+
join(homeDirPath, '.cursor', 'mcp.json'),
|
|
80
|
+
join(homeDirPath, '.gemini', 'config', 'mcp_config.json'),
|
|
81
|
+
join(homeDirPath, '.gemini', 'settings.json'),
|
|
82
|
+
join(repoPath, '.mcp.json'),
|
|
83
|
+
join(repoPath, '.cursor', 'mcp.json'),
|
|
84
|
+
join(repoPath, '.claude', 'settings.json'),
|
|
85
|
+
]) {
|
|
86
|
+
const j = readJson(cfg);
|
|
87
|
+
if (j?.mcpServers) Object.keys(j.mcpServers).forEach(k => mcpNames.add(k));
|
|
32
88
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
const skills = readdirSync(geminiSkillsPath, { withFileTypes: true })
|
|
39
|
-
.filter(dirent => dirent.isDirectory())
|
|
40
|
-
.map(dirent => dirent.name);
|
|
41
|
-
|
|
42
|
-
// Only add if not already added (dedupe)
|
|
43
|
-
for (const skill of skills) {
|
|
44
|
-
if (!stats.skills.includes(skill)) stats.skills.push(skill);
|
|
45
|
-
}
|
|
46
|
-
} catch (e) {}
|
|
89
|
+
// Gemini-style MCP dir
|
|
90
|
+
const geminiMcp = join(homeDirPath, '.gemini', 'antigravity-cli', 'mcp');
|
|
91
|
+
if (existsSync(geminiMcp)) {
|
|
92
|
+
try { readdirSync(geminiMcp, { withFileTypes: true }).filter(d => d.isDirectory()).forEach(d => mcpNames.add(d.name)); } catch {}
|
|
47
93
|
}
|
|
48
94
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
} catch (e) {}
|
|
95
|
+
const mcps: ToolReach[] = [...mcpNames].map(name => ({ name, reach: classifyReach(name) }));
|
|
96
|
+
|
|
97
|
+
// Skills
|
|
98
|
+
const skills: string[] = [];
|
|
99
|
+
for (const p of [join(repoPath, '.agents', 'skills'), join(homeDirPath, '.claude', 'skills'), join(homeDirPath, '.gemini', 'skills')]) {
|
|
100
|
+
if (existsSync(p)) {
|
|
101
|
+
try { readdirSync(p, { withFileTypes: true }).filter(d => d.isDirectory()).forEach(d => { if (!skills.includes(d.name)) skills.push(d.name); }); } catch {}
|
|
102
|
+
}
|
|
58
103
|
}
|
|
59
104
|
|
|
60
|
-
//
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
stats.mcps.push(`plugin:${plugin}`);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
} catch (e) {}
|
|
105
|
+
// Sub-agents (Claude Code agent definitions)
|
|
106
|
+
const subagents = countDir(join(homeDirPath, '.claude', 'agents')) + countDir(join(repoPath, '.claude', 'agents'));
|
|
107
|
+
|
|
108
|
+
// Hooks — automation that fires on the developer's behalf (a real governance surface)
|
|
109
|
+
const hooks: string[] = [];
|
|
110
|
+
for (const cfg of [join(homeDirPath, '.claude', 'settings.json'), join(repoPath, '.claude', 'settings.json')]) {
|
|
111
|
+
const j = readJson(cfg);
|
|
112
|
+
if (j?.hooks) Object.keys(j.hooks).forEach(k => { if (!hooks.includes(k)) hooks.push(k); });
|
|
73
113
|
}
|
|
74
114
|
|
|
75
|
-
|
|
115
|
+
// Orchestration policy
|
|
116
|
+
const hasOrchestration = existsSync(join(repoPath, 'AGENTS.md')) || existsSync(join(repoPath, '.mcp.json'));
|
|
117
|
+
|
|
118
|
+
const { radius, reasons } = scoreBlast(mcps.map(m => m.reach));
|
|
119
|
+
|
|
120
|
+
return { mcps, skills, subagents, hooks, hasOrchestration, blastRadius: radius, blastReasons: reasons };
|
|
76
121
|
}
|
package/src/carbon.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { homedir } from 'os';
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { readFile, access, readdir } from 'fs/promises';
|
|
4
4
|
import gridFactors from '../data/grid-factors.json';
|
|
5
|
+
import { energyKwhByModel } from './emissions';
|
|
6
|
+
import { detectSources, provLabel, type Provenance } from './sources';
|
|
5
7
|
|
|
6
8
|
export interface CarbonStats {
|
|
7
9
|
totalTokens: number;
|
|
@@ -13,12 +15,15 @@ export interface CarbonStats {
|
|
|
13
15
|
localCo2Kg: number;
|
|
14
16
|
localRegion: string;
|
|
15
17
|
sessions: number;
|
|
16
|
-
estUsd: number;
|
|
17
|
-
costIsReal: boolean;
|
|
18
|
+
estUsd: number; // estimated spend in USD
|
|
19
|
+
costIsReal: boolean; // true if summed from the log's own cost field, false if estimated
|
|
20
|
+
tokenProvenance: Provenance;
|
|
21
|
+
carbonProvenance: Provenance;
|
|
22
|
+
sourceLabel: string; // e.g. "estimated · Claude Code transcripts"
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
export interface TokenLogParser {
|
|
21
|
-
parse(): Promise<{ total: number, output: number, cache: number, sessions: number, cost: number }>;
|
|
26
|
+
parse(): Promise<{ total: number, output: number, cache: number, sessions: number, cost: number, outputByModel: Record<string, number> }>;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export class ClaudeLogParser implements TokenLogParser {
|
|
@@ -40,6 +45,7 @@ export class ClaudeLogParser implements TokenLogParser {
|
|
|
40
45
|
if (files.length > 0) {
|
|
41
46
|
let total = 0, output = 0, cache = 0;
|
|
42
47
|
const sessions = new Set<string>();
|
|
48
|
+
const outputByModel: Record<string, number> = {};
|
|
43
49
|
for (const file of files) {
|
|
44
50
|
let text = '';
|
|
45
51
|
try { text = await readFile(join(projectDir, file), 'utf-8'); } catch { continue; }
|
|
@@ -47,7 +53,8 @@ export class ClaudeLogParser implements TokenLogParser {
|
|
|
47
53
|
if (!line.trim()) continue;
|
|
48
54
|
try {
|
|
49
55
|
const d = JSON.parse(line);
|
|
50
|
-
const
|
|
56
|
+
const msg = d.message || {};
|
|
57
|
+
const u = msg.usage || d.usage;
|
|
51
58
|
if (u) {
|
|
52
59
|
const inp = u.input_tokens || 0;
|
|
53
60
|
const out = u.output_tokens || 0;
|
|
@@ -56,13 +63,15 @@ export class ClaudeLogParser implements TokenLogParser {
|
|
|
56
63
|
total += inp + out + cr + cw;
|
|
57
64
|
output += out;
|
|
58
65
|
cache += cr;
|
|
66
|
+
const model = msg.model || 'default';
|
|
67
|
+
outputByModel[model] = (outputByModel[model] || 0) + out;
|
|
59
68
|
}
|
|
60
69
|
if (d.sessionId) sessions.add(d.sessionId);
|
|
61
70
|
} catch {}
|
|
62
71
|
}
|
|
63
72
|
}
|
|
64
73
|
// Standard transcripts carry no cost field; cost is estimated downstream.
|
|
65
|
-
return { total, output, cache, sessions: sessions.size, cost: 0 };
|
|
74
|
+
return { total, output, cache, sessions: sessions.size, cost: 0, outputByModel };
|
|
66
75
|
}
|
|
67
76
|
} catch {}
|
|
68
77
|
|
|
@@ -72,11 +81,12 @@ export class ClaudeLogParser implements TokenLogParser {
|
|
|
72
81
|
try {
|
|
73
82
|
await access(logPath);
|
|
74
83
|
} catch {
|
|
75
|
-
return { total: 0, output: 0, cache: 0, sessions: 0, cost: 0 };
|
|
84
|
+
return { total: 0, output: 0, cache: 0, sessions: 0, cost: 0, outputByModel: {} };
|
|
76
85
|
}
|
|
77
86
|
const text = await readFile(logPath, 'utf-8');
|
|
78
87
|
let total = 0, output = 0, cache = 0, cost = 0;
|
|
79
88
|
const sessions = new Set<string>();
|
|
89
|
+
const outputByModel: Record<string, number> = {};
|
|
80
90
|
for (const line of text.split('\n')) {
|
|
81
91
|
if (!line.trim()) continue;
|
|
82
92
|
try {
|
|
@@ -86,15 +96,17 @@ export class ClaudeLogParser implements TokenLogParser {
|
|
|
86
96
|
cache += data.cache_read || 0;
|
|
87
97
|
cost += data.cost_usd || 0;
|
|
88
98
|
if (data.session_id) sessions.add(data.session_id);
|
|
99
|
+
const model = data.model || 'default';
|
|
100
|
+
outputByModel[model] = (outputByModel[model] || 0) + (data.output_tokens || 0);
|
|
89
101
|
} catch {}
|
|
90
102
|
}
|
|
91
|
-
return { total, output, cache, sessions: sessions.size, cost };
|
|
103
|
+
return { total, output, cache, sessions: sessions.size, cost, outputByModel };
|
|
92
104
|
}
|
|
93
105
|
}
|
|
94
106
|
|
|
95
107
|
class CursorLogParser implements TokenLogParser {
|
|
96
108
|
async parse() {
|
|
97
|
-
return { total: 0, output: 0, cache: 0, sessions: 0, cost: 0 };
|
|
109
|
+
return { total: 0, output: 0, cache: 0, sessions: 0, cost: 0, outputByModel: {} };
|
|
98
110
|
}
|
|
99
111
|
}
|
|
100
112
|
|
|
@@ -105,6 +117,33 @@ function estimateUsd(output: number, cacheRead: number, total: number): number {
|
|
|
105
117
|
return (output / 1e6) * 9 + (cacheRead / 1e6) * 0.3 + (otherInput / 1e6) * 3;
|
|
106
118
|
}
|
|
107
119
|
|
|
120
|
+
// #6 CodeCarbon: if the developer runs CodeCarbon, it writes a real MEASURED
|
|
121
|
+
// emissions.csv (hardware energy, not a token estimate). Read the latest run.
|
|
122
|
+
// Columns (codecarbon >=2): timestamp,project_name,...,duration,emissions,...,energy_consumed,...
|
|
123
|
+
function readCodeCarbon(cwd: string, home: string): { energyKwh: number; co2Kg: number } | null {
|
|
124
|
+
const { readFileSync, existsSync } = require('fs');
|
|
125
|
+
const { join } = require('path');
|
|
126
|
+
for (const p of [join(cwd, 'emissions.csv'), join(home, '.codecarbon', 'emissions.csv')]) {
|
|
127
|
+
try {
|
|
128
|
+
if (!existsSync(p)) continue;
|
|
129
|
+
const lines = readFileSync(p, 'utf-8').trim().split('\n');
|
|
130
|
+
if (lines.length < 2) continue;
|
|
131
|
+
const header = lines[0].split(',');
|
|
132
|
+
const iEm = header.indexOf('emissions'); // kg CO2eq
|
|
133
|
+
const iEn = header.indexOf('energy_consumed'); // kWh
|
|
134
|
+
if (iEm === -1 && iEn === -1) continue;
|
|
135
|
+
let co2 = 0, kwh = 0;
|
|
136
|
+
for (const row of lines.slice(1)) {
|
|
137
|
+
const cols = row.split(',');
|
|
138
|
+
co2 += parseFloat(cols[iEm]) || 0;
|
|
139
|
+
kwh += parseFloat(cols[iEn]) || 0;
|
|
140
|
+
}
|
|
141
|
+
return { energyKwh: kwh, co2Kg: co2 };
|
|
142
|
+
} catch {}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
108
147
|
function getLocalGridFactor(): { region: string, factor: number } {
|
|
109
148
|
try {
|
|
110
149
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
@@ -114,13 +153,14 @@ function getLocalGridFactor(): { region: string, factor: number } {
|
|
|
114
153
|
if (tz.includes('Singapore')) return { region: 'Singapore', factor: gridFactors.singapore };
|
|
115
154
|
if (tz.includes('Calcutta') || tz.includes('Kolkata') || tz.includes('Asia/Kabul')) return { region: 'India', factor: gridFactors.india_average };
|
|
116
155
|
} catch (e) {}
|
|
117
|
-
return { region: 'Global Average', factor:
|
|
156
|
+
return { region: 'Global Average', factor: gridFactors.global_average };
|
|
118
157
|
}
|
|
119
158
|
|
|
120
159
|
export async function getCarbonStats(): Promise<CarbonStats> {
|
|
121
160
|
const parsers: TokenLogParser[] = [new ClaudeLogParser(), new CursorLogParser()];
|
|
122
|
-
|
|
161
|
+
|
|
123
162
|
let totalTokens = 0, outputTokens = 0, cacheReadTokens = 0, sessions = 0, loggedCost = 0;
|
|
163
|
+
const outputByModel: Record<string, number> = {};
|
|
124
164
|
|
|
125
165
|
for (const parser of parsers) {
|
|
126
166
|
const stats = await parser.parse();
|
|
@@ -129,10 +169,19 @@ export async function getCarbonStats(): Promise<CarbonStats> {
|
|
|
129
169
|
cacheReadTokens += stats.cache;
|
|
130
170
|
sessions += stats.sessions;
|
|
131
171
|
loggedCost += stats.cost;
|
|
172
|
+
for (const [m, out] of Object.entries(stats.outputByModel)) {
|
|
173
|
+
outputByModel[m] = (outputByModel[m] || 0) + out;
|
|
174
|
+
}
|
|
132
175
|
}
|
|
133
176
|
|
|
134
|
-
|
|
177
|
+
// Carbon: prefer CodeCarbon's measured hardware data; else model-aware estimate.
|
|
135
178
|
const localGrid = getLocalGridFactor();
|
|
179
|
+
const measured = readCodeCarbon(process.cwd(), homedir());
|
|
180
|
+
const energyKwh = measured ? measured.energyKwh : energyKwhByModel(outputByModel);
|
|
181
|
+
const measuredCo2 = measured ? measured.co2Kg : null;
|
|
182
|
+
|
|
183
|
+
// Source provenance for honest labelling in the UI.
|
|
184
|
+
const sources = detectSources();
|
|
136
185
|
|
|
137
186
|
// Prefer the log's own cost field (accurate); fall back to a rough token estimate.
|
|
138
187
|
const costIsReal = loggedCost > 0;
|
|
@@ -145,10 +194,13 @@ export async function getCarbonStats(): Promise<CarbonStats> {
|
|
|
145
194
|
energyKwh,
|
|
146
195
|
co2KgVietnam: (energyKwh * gridFactors.vietnam) / 1000,
|
|
147
196
|
co2KgFrance: (energyKwh * gridFactors.france) / 1000,
|
|
148
|
-
localCo2Kg: (energyKwh * localGrid.factor) / 1000,
|
|
149
|
-
localRegion: localGrid.region,
|
|
197
|
+
localCo2Kg: measuredCo2 !== null ? measuredCo2 : (energyKwh * localGrid.factor) / 1000,
|
|
198
|
+
localRegion: measured ? 'CodeCarbon (measured)' : localGrid.region,
|
|
150
199
|
sessions,
|
|
151
200
|
estUsd,
|
|
152
|
-
costIsReal
|
|
201
|
+
costIsReal,
|
|
202
|
+
tokenProvenance: sources.tokenSource.provenance,
|
|
203
|
+
carbonProvenance: sources.carbonSource.provenance,
|
|
204
|
+
sourceLabel: provLabel(sources.tokenSource)
|
|
153
205
|
};
|
|
154
206
|
}
|