my-llm-footprint 0.1.0
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/bin/init.js +217 -0
- package/hooks/claude-stop.js +414 -0
- package/package.json +10 -0
package/bin/init.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* my-llm-footprint CLI
|
|
4
|
+
*
|
|
5
|
+
* Commands:
|
|
6
|
+
* init (default) — validates API key, writes config, installs Claude hook
|
|
7
|
+
* uninstall — removes the hook and config
|
|
8
|
+
*
|
|
9
|
+
* Node.js built-ins only. Requires Node 18+ for fetch().
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import os from 'os';
|
|
15
|
+
import readline from 'readline';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Paths
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const HOME = os.homedir();
|
|
23
|
+
const CONFIG_DIR = path.join(HOME, '.config', 'my-llm-footprint');
|
|
24
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
25
|
+
const CLAUDE_SETTINGS_PATH = path.join(HOME, '.claude', 'settings.json');
|
|
26
|
+
|
|
27
|
+
const ENDPOINT = process.env.LLM_FOOTPRINT_ENDPOINT || 'https://llm-usages.mattjones.wales';
|
|
28
|
+
|
|
29
|
+
// Resolve the absolute path of the hooks directory relative to this file.
|
|
30
|
+
// import.meta.url → file:///…/packages/my-llm-footprint/bin/init.js
|
|
31
|
+
const PACKAGE_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..');
|
|
32
|
+
const HOOK_SCRIPT = path.join(PACKAGE_ROOT, 'hooks', 'claude-stop.js');
|
|
33
|
+
const HOOK_COMMAND = `node ${HOOK_SCRIPT}`;
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Helpers
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Prompt the user for a single line of input.
|
|
41
|
+
* @param {string} question
|
|
42
|
+
* @returns {Promise<string>}
|
|
43
|
+
*/
|
|
44
|
+
function prompt(question) {
|
|
45
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
rl.question(question, (answer) => {
|
|
48
|
+
rl.close();
|
|
49
|
+
resolve(answer.trim());
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read and parse a JSON file, returning null on any error.
|
|
56
|
+
* @param {string} filePath
|
|
57
|
+
* @returns {object | null}
|
|
58
|
+
*/
|
|
59
|
+
function readJson(filePath) {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Write an object as pretty-printed JSON, creating parent dirs as needed.
|
|
69
|
+
* @param {string} filePath
|
|
70
|
+
* @param {object} data
|
|
71
|
+
*/
|
|
72
|
+
function writeJson(filePath, data) {
|
|
73
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
74
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// init command
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
async function runInit() {
|
|
82
|
+
console.log('my-llm-footprint setup\n');
|
|
83
|
+
|
|
84
|
+
const apiKey = await prompt('Enter your API key: ');
|
|
85
|
+
|
|
86
|
+
if (!apiKey) {
|
|
87
|
+
console.error('Usage: my-llm-footprint init');
|
|
88
|
+
console.error('An API key is required.');
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- Validate key ---
|
|
93
|
+
let userName;
|
|
94
|
+
try {
|
|
95
|
+
const res = await fetch(`${ENDPOINT}/api/v1/ingest/verify`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
98
|
+
signal: AbortSignal.timeout(15000),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
console.error(`Invalid API key (HTTP ${res.status}).`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @type {{ user: string }} — matches VerifyResponse from @my-llm-footprint/shared */
|
|
107
|
+
const body = await res.json();
|
|
108
|
+
userName = body.user ?? 'unknown';
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.error(`Failed to validate API key: ${err.message}`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(`API key validated — welcome, ${userName}!`);
|
|
115
|
+
|
|
116
|
+
// --- Write config ---
|
|
117
|
+
const config = { apiKey, endpoint: ENDPOINT };
|
|
118
|
+
writeJson(CONFIG_PATH, config);
|
|
119
|
+
|
|
120
|
+
// --- Update ~/.claude/settings.json ---
|
|
121
|
+
let settings = readJson(CLAUDE_SETTINGS_PATH) ?? {};
|
|
122
|
+
|
|
123
|
+
// Ensure hooks.Stop is an array
|
|
124
|
+
if (!settings.hooks) settings.hooks = {};
|
|
125
|
+
if (!Array.isArray(settings.hooks.Stop)) {
|
|
126
|
+
settings.hooks.Stop = settings.hooks.Stop ? [settings.hooks.Stop] : [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Check whether our hook is already present
|
|
130
|
+
const alreadyInstalled = settings.hooks.Stop.some((entry) => {
|
|
131
|
+
const hooks = Array.isArray(entry?.hooks) ? entry.hooks : [];
|
|
132
|
+
return hooks.some((h) => typeof h.command === 'string' && h.command.includes('my-llm-footprint'));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (alreadyInstalled) {
|
|
136
|
+
console.log('Hook already installed — skipping.');
|
|
137
|
+
} else {
|
|
138
|
+
settings.hooks.Stop.push({
|
|
139
|
+
hooks: [{ type: 'command', command: HOOK_COMMAND }],
|
|
140
|
+
});
|
|
141
|
+
writeJson(CLAUDE_SETTINGS_PATH, settings);
|
|
142
|
+
console.log('Claude Stop hook installed.');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- Done ---
|
|
146
|
+
console.log(`\nDone! Tracking usage for ${userName}.`);
|
|
147
|
+
console.log(`View your dashboard: ${ENDPOINT}/dashboard`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// uninstall command
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
function runUninstall() {
|
|
155
|
+
// Remove hook from settings
|
|
156
|
+
const settings = readJson(CLAUDE_SETTINGS_PATH);
|
|
157
|
+
if (settings?.hooks?.Stop && Array.isArray(settings.hooks.Stop)) {
|
|
158
|
+
const before = settings.hooks.Stop.length;
|
|
159
|
+
settings.hooks.Stop = settings.hooks.Stop.filter((entry) => {
|
|
160
|
+
const hooks = Array.isArray(entry?.hooks) ? entry.hooks : [];
|
|
161
|
+
return !hooks.some((h) => typeof h.command === 'string' && h.command.includes('my-llm-footprint'));
|
|
162
|
+
});
|
|
163
|
+
if (settings.hooks.Stop.length !== before) {
|
|
164
|
+
writeJson(CLAUDE_SETTINGS_PATH, settings);
|
|
165
|
+
console.log('Removed hook from ~/.claude/settings.json');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Remove config file
|
|
170
|
+
try {
|
|
171
|
+
fs.rmSync(CONFIG_PATH, { force: true });
|
|
172
|
+
console.log('Removed config file.');
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.error(`Could not remove config: ${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log('Uninstalled my-llm-footprint');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Entry point
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
const args = process.argv.slice(2);
|
|
185
|
+
const command = args[0];
|
|
186
|
+
|
|
187
|
+
if (command === '--help' || command === '-h') {
|
|
188
|
+
console.log(`my-llm-footprint — track your Claude AI usage
|
|
189
|
+
|
|
190
|
+
Usage:
|
|
191
|
+
my-llm-footprint [init] Set up tracking (default command)
|
|
192
|
+
my-llm-footprint uninstall Remove hook and config
|
|
193
|
+
my-llm-footprint --help Show this help
|
|
194
|
+
|
|
195
|
+
Description:
|
|
196
|
+
init Prompts for an API key, validates it, writes
|
|
197
|
+
~/.config/my-llm-footprint/config.json, and installs
|
|
198
|
+
a Claude Code Stop hook so every session is reported.
|
|
199
|
+
|
|
200
|
+
uninstall Removes the Stop hook from ~/.claude/settings.json
|
|
201
|
+
and deletes ~/.config/my-llm-footprint/config.json.
|
|
202
|
+
`);
|
|
203
|
+
process.exit(0);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!command || command === 'init') {
|
|
207
|
+
runInit().catch((err) => {
|
|
208
|
+
console.error(err.message);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
});
|
|
211
|
+
} else if (command === 'uninstall') {
|
|
212
|
+
runUninstall();
|
|
213
|
+
} else {
|
|
214
|
+
console.error(`Unknown command: ${command}`);
|
|
215
|
+
console.error('Run with --help for usage.');
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code hook: my-llm-footprint token reporter.
|
|
4
|
+
*
|
|
5
|
+
* Runs on every Claude Code Stop event.
|
|
6
|
+
* - Reads JSON from stdin (transcript_path, session_id)
|
|
7
|
+
* - Parses the JSONL transcript to extract token usage
|
|
8
|
+
* - POSTs raw counts to the configured API endpoint
|
|
9
|
+
* - Prints an energy/water summary to stderr
|
|
10
|
+
* - On failure: writes event to ~/.config/my-llm-footprint/pending.json
|
|
11
|
+
* - On success: flushes any pending events from the queue
|
|
12
|
+
*
|
|
13
|
+
* No external dependencies — Node.js built-ins only.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import os from 'os';
|
|
19
|
+
|
|
20
|
+
// --- EcoLogits-based energy/water constants ---
|
|
21
|
+
// Source: https://ecologits.ai/latest/methodology/llm_inference/
|
|
22
|
+
// GPU energy regression fitted to ML.ENERGY H100 benchmarks:
|
|
23
|
+
// gpu_energy_per_token (Wh) = α × e^(β × B) × P_active + γ
|
|
24
|
+
const GPU_ENERGY_ALPHA = 1.1665e-6;
|
|
25
|
+
const GPU_ENERGY_BETA = -0.01121;
|
|
26
|
+
const GPU_ENERGY_GAMMA = 4.0529e-5;
|
|
27
|
+
const BATCH_SIZE = 64;
|
|
28
|
+
const GPU_MEMORY_GB = 80;
|
|
29
|
+
const QUANTIZATION_BITS = 16;
|
|
30
|
+
const SERVER_POWER_KW = 1.2;
|
|
31
|
+
const SERVER_GPUS = 8;
|
|
32
|
+
|
|
33
|
+
// Default Anthropic provider config (USA datacenter)
|
|
34
|
+
const PUE = 1.11;
|
|
35
|
+
const WUE_ONSITE = 0.56; // L/kWh
|
|
36
|
+
const GRID_WUE_OFFSITE = 3.1321; // L/kWh
|
|
37
|
+
|
|
38
|
+
// Default model: Claude Sonnet-class (MoE, ~440B total, ~88B active)
|
|
39
|
+
const DEFAULT_TOTAL_PARAMS_B = 440;
|
|
40
|
+
const DEFAULT_ACTIVE_PARAMS_B = 88;
|
|
41
|
+
|
|
42
|
+
const PHONE_CHARGE_WH = 15.0;
|
|
43
|
+
const ML_PER_SIP = 20.0;
|
|
44
|
+
|
|
45
|
+
// --- Paths ---
|
|
46
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'my-llm-footprint');
|
|
47
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
48
|
+
const PENDING_PATH = path.join(CONFIG_DIR, 'pending.json');
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Transcript parsing
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse a JSONL transcript file and return an array of per-turn usage objects.
|
|
56
|
+
* Each object has: input, output, cache_create, cache_read, model.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} filePath
|
|
59
|
+
* @returns {{ input: number, output: number, cache_create: number, cache_read: number, model: string }[]}
|
|
60
|
+
*/
|
|
61
|
+
function parseTranscript(filePath) {
|
|
62
|
+
let raw;
|
|
63
|
+
try {
|
|
64
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const turns = [];
|
|
70
|
+
for (const line of raw.split('\n')) {
|
|
71
|
+
const trimmed = line.trim();
|
|
72
|
+
if (!trimmed) continue;
|
|
73
|
+
|
|
74
|
+
let entry;
|
|
75
|
+
try {
|
|
76
|
+
entry = JSON.parse(trimmed);
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (entry.type !== 'assistant') continue;
|
|
82
|
+
|
|
83
|
+
const msg = entry.message;
|
|
84
|
+
if (!msg || typeof msg !== 'object') continue;
|
|
85
|
+
|
|
86
|
+
const usage = msg.usage;
|
|
87
|
+
if (!usage) continue;
|
|
88
|
+
|
|
89
|
+
turns.push({
|
|
90
|
+
input: usage.input_tokens || 0,
|
|
91
|
+
output: usage.output_tokens || 0,
|
|
92
|
+
cache_create: usage.cache_creation_input_tokens || 0,
|
|
93
|
+
cache_read: usage.cache_read_input_tokens || 0,
|
|
94
|
+
model: msg.model || entry.model || 'unknown',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return turns;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Stats computation
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {{ input: number, output: number, cache_create: number, cache_read: number, model: string }[]} turns
|
|
107
|
+
*/
|
|
108
|
+
function computeStats(turns) {
|
|
109
|
+
let totalInput = 0;
|
|
110
|
+
let totalOutput = 0;
|
|
111
|
+
let totalCacheCreate = 0;
|
|
112
|
+
let totalCacheRead = 0;
|
|
113
|
+
|
|
114
|
+
for (const t of turns) {
|
|
115
|
+
totalInput += t.input;
|
|
116
|
+
totalOutput += t.output;
|
|
117
|
+
totalCacheCreate += t.cache_create;
|
|
118
|
+
totalCacheRead += t.cache_read;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const last = turns.length > 0 ? turns[turns.length - 1] : null;
|
|
122
|
+
|
|
123
|
+
const lastInput = last ? last.input : 0;
|
|
124
|
+
const lastOutput = last ? last.output : 0;
|
|
125
|
+
const lastCacheCreate = last ? last.cache_create : 0;
|
|
126
|
+
const lastCacheRead = last ? last.cache_read : 0;
|
|
127
|
+
|
|
128
|
+
// For energy/water calcs use combined input (input + cache tokens)
|
|
129
|
+
const totalInAll = totalInput + totalCacheCreate + totalCacheRead;
|
|
130
|
+
const lastInAll = lastInput + lastCacheCreate + lastCacheRead;
|
|
131
|
+
const totalTokens = totalInAll + totalOutput;
|
|
132
|
+
const lastTokens = lastInAll + lastOutput;
|
|
133
|
+
|
|
134
|
+
// EcoLogits-based calculations
|
|
135
|
+
function gpuCount(totalParamsB) {
|
|
136
|
+
const memGB = 1.2 * totalParamsB * QUANTIZATION_BITS / 8;
|
|
137
|
+
const raw = Math.ceil(memGB / GPU_MEMORY_GB);
|
|
138
|
+
return Math.pow(2, Math.ceil(Math.log2(Math.max(raw, 1))));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function energy(inp, out) {
|
|
142
|
+
const gpus = gpuCount(DEFAULT_TOTAL_PARAMS_B);
|
|
143
|
+
const whPerToken = GPU_ENERGY_ALPHA * Math.exp(GPU_ENERGY_BETA * BATCH_SIZE) * DEFAULT_ACTIVE_PARAMS_B + GPU_ENERGY_GAMMA;
|
|
144
|
+
const gpuEnergy = out * (whPerToken / 1000); // kWh
|
|
145
|
+
const tps = DEFAULT_ACTIVE_PARAMS_B < 30 ? 80 : DEFAULT_ACTIVE_PARAMS_B < 100 ? 50 : 30;
|
|
146
|
+
const latency = out / tps;
|
|
147
|
+
const srvEnergy = (latency / 3600) * SERVER_POWER_KW * (gpus / SERVER_GPUS) * (1 / BATCH_SIZE);
|
|
148
|
+
return PUE * (srvEnergy + gpus * gpuEnergy) * 1000; // Wh
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function water(tok, energyWh) {
|
|
152
|
+
const kwh = (energyWh !== undefined ? energyWh : energy(Math.round(tok * 0.8), tok - Math.round(tok * 0.8))) / 1000;
|
|
153
|
+
const liters = kwh * (WUE_ONSITE + PUE * GRID_WUE_OFFSITE);
|
|
154
|
+
return liters * 1000; // mL
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
turns: turns.length,
|
|
159
|
+
model: last ? last.model : 'unknown',
|
|
160
|
+
|
|
161
|
+
// Raw delta (last turn) — for API payload
|
|
162
|
+
deltaInput: lastInput,
|
|
163
|
+
deltaOutput: lastOutput,
|
|
164
|
+
deltaCacheCreate: lastCacheCreate,
|
|
165
|
+
deltaCacheRead: lastCacheRead,
|
|
166
|
+
|
|
167
|
+
// Cumulative raw — for API payload
|
|
168
|
+
cumulativeIn: totalInput,
|
|
169
|
+
cumulativeOut: totalOutput,
|
|
170
|
+
|
|
171
|
+
// For display
|
|
172
|
+
totalTokens,
|
|
173
|
+
totalInAll,
|
|
174
|
+
totalOutput,
|
|
175
|
+
lastTokens,
|
|
176
|
+
lastInAll,
|
|
177
|
+
lastOutput,
|
|
178
|
+
|
|
179
|
+
energySessionWh: energy(totalInAll, totalOutput),
|
|
180
|
+
energyLastWh: energy(lastInAll, lastOutput),
|
|
181
|
+
waterSessionMl: water(totalTokens, energy(totalInAll, totalOutput)),
|
|
182
|
+
waterLastMl: water(lastTokens, energy(lastInAll, lastOutput)),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Stderr output
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {ReturnType<typeof computeStats>} s
|
|
192
|
+
*/
|
|
193
|
+
function formatOutput(s) {
|
|
194
|
+
if (s.turns === 0) return '';
|
|
195
|
+
|
|
196
|
+
const lines = [];
|
|
197
|
+
|
|
198
|
+
lines.push(
|
|
199
|
+
`Energy ${s.energySessionWh.toFixed(4)} Wh session | ${s.energyLastWh.toFixed(4)} Wh last turn`,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
lines.push(
|
|
203
|
+
`Water ${s.waterSessionMl.toFixed(2)} mL session | ${s.waterLastMl.toFixed(2)} mL last turn`,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const turnWord = s.turns === 1 ? 'turn' : 'turns';
|
|
207
|
+
lines.push(
|
|
208
|
+
`Tokens ${s.totalTokens.toLocaleString()} total ` +
|
|
209
|
+
`(${s.totalInAll.toLocaleString()} in / ${s.totalOutput.toLocaleString()} out) ` +
|
|
210
|
+
`over ${s.turns} ${turnWord}`,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const phonePct = (s.energySessionWh / PHONE_CHARGE_WH) * 100;
|
|
214
|
+
const sips = s.waterSessionMl / ML_PER_SIP;
|
|
215
|
+
|
|
216
|
+
const comparisons = [];
|
|
217
|
+
comparisons.push(phonePct < 0.01 ? '<0.01% of a phone charge' : `${phonePct.toFixed(2)}% of a phone charge`);
|
|
218
|
+
comparisons.push(sips < 0.1 ? '<0.1 sips of water' : `~${sips.toFixed(1)} sips of water`);
|
|
219
|
+
|
|
220
|
+
lines.push(` = ${comparisons.join(' | ')}`);
|
|
221
|
+
|
|
222
|
+
return lines.join('\n');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Config
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @returns {{ apiKey: string, endpoint: string } | null}
|
|
231
|
+
*/
|
|
232
|
+
function readConfig() {
|
|
233
|
+
try {
|
|
234
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
235
|
+
const cfg = JSON.parse(raw);
|
|
236
|
+
if (cfg && cfg.apiKey && cfg.endpoint) return cfg;
|
|
237
|
+
} catch {
|
|
238
|
+
// Config not yet installed or unreadable — silent skip
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Pending queue
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* @returns {object[]}
|
|
249
|
+
*/
|
|
250
|
+
function readPending() {
|
|
251
|
+
try {
|
|
252
|
+
const raw = fs.readFileSync(PENDING_PATH, 'utf8');
|
|
253
|
+
const parsed = JSON.parse(raw);
|
|
254
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
255
|
+
} catch {
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* @param {object[]} queue
|
|
262
|
+
*/
|
|
263
|
+
function writePending(queue) {
|
|
264
|
+
try {
|
|
265
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
266
|
+
fs.writeFileSync(PENDING_PATH, JSON.stringify(queue, null, 2));
|
|
267
|
+
} catch {
|
|
268
|
+
// Best-effort
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// API call
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* POST a single event payload to the API.
|
|
278
|
+
*
|
|
279
|
+
* @param {{ apiKey: string, endpoint: string }} config
|
|
280
|
+
* @param {object} payload
|
|
281
|
+
* @returns {Promise<void>} Resolves on 2xx, rejects otherwise.
|
|
282
|
+
*/
|
|
283
|
+
async function postEvent(config, payload) {
|
|
284
|
+
const url = `${config.endpoint.replace(/\/$/, '')}/api/v1/ingest/events`;
|
|
285
|
+
|
|
286
|
+
const res = await fetch(url, {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
headers: {
|
|
289
|
+
'Content-Type': 'application/json',
|
|
290
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
291
|
+
},
|
|
292
|
+
body: JSON.stringify(payload),
|
|
293
|
+
signal: AbortSignal.timeout(10000),
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
if (!res.ok) {
|
|
297
|
+
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Flush pending queue
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Attempt to flush all pending events. Silently stops on first failure.
|
|
307
|
+
*
|
|
308
|
+
* @param {{ apiKey: string, endpoint: string }} config
|
|
309
|
+
*/
|
|
310
|
+
async function flushPending(config) {
|
|
311
|
+
const queue = readPending();
|
|
312
|
+
if (queue.length === 0) return;
|
|
313
|
+
|
|
314
|
+
const remaining = [];
|
|
315
|
+
for (const event of queue) {
|
|
316
|
+
try {
|
|
317
|
+
await postEvent(config, event);
|
|
318
|
+
} catch {
|
|
319
|
+
// Keep this and all subsequent events for next time
|
|
320
|
+
remaining.push(event);
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// If we drained everything, remove the file; otherwise write what's left
|
|
326
|
+
if (remaining.length === 0) {
|
|
327
|
+
try {
|
|
328
|
+
fs.unlinkSync(PENDING_PATH);
|
|
329
|
+
} catch {
|
|
330
|
+
writePending([]);
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
// Find where we stopped and preserve the rest
|
|
334
|
+
const failedIdx = queue.indexOf(remaining[0]);
|
|
335
|
+
writePending(failedIdx >= 0 ? queue.slice(failedIdx) : queue);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Main
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
async function main() {
|
|
344
|
+
// 1. Read stdin
|
|
345
|
+
let stdinData = '';
|
|
346
|
+
process.stdin.setEncoding('utf8');
|
|
347
|
+
for await (const chunk of process.stdin) {
|
|
348
|
+
stdinData += chunk;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let event;
|
|
352
|
+
try {
|
|
353
|
+
event = JSON.parse(stdinData);
|
|
354
|
+
} catch {
|
|
355
|
+
// Malformed input — nothing to do
|
|
356
|
+
process.exit(0);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const transcriptPath = event.transcript_path || '';
|
|
360
|
+
const sessionId = event.session_id || 'unknown';
|
|
361
|
+
|
|
362
|
+
// 2. Parse transcript
|
|
363
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
364
|
+
process.exit(0);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const turns = parseTranscript(transcriptPath);
|
|
368
|
+
|
|
369
|
+
// 3. Print energy/water summary to stderr (even without config)
|
|
370
|
+
if (turns.length > 0) {
|
|
371
|
+
const stats = computeStats(turns);
|
|
372
|
+
const output = formatOutput(stats);
|
|
373
|
+
if (output) {
|
|
374
|
+
process.stderr.write(`\n${output}\n`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 4. Read API config
|
|
378
|
+
const config = readConfig();
|
|
379
|
+
if (!config) {
|
|
380
|
+
// No config installed yet — just display the summary and exit
|
|
381
|
+
process.exit(0);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// 5. Build payload
|
|
385
|
+
const payload = {
|
|
386
|
+
session_id: sessionId,
|
|
387
|
+
provider: 'anthropic',
|
|
388
|
+
model: stats.model,
|
|
389
|
+
input_tokens: stats.deltaInput,
|
|
390
|
+
output_tokens: stats.deltaOutput,
|
|
391
|
+
cache_create: stats.deltaCacheCreate,
|
|
392
|
+
cache_read: stats.deltaCacheRead,
|
|
393
|
+
cumulative_in: stats.cumulativeIn,
|
|
394
|
+
cumulative_out: stats.cumulativeOut,
|
|
395
|
+
turn_number: stats.turns,
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// 6. POST to API
|
|
399
|
+
try {
|
|
400
|
+
await postEvent(config, payload);
|
|
401
|
+
// 7. On success: flush pending queue
|
|
402
|
+
await flushPending(config);
|
|
403
|
+
} catch {
|
|
404
|
+
// 6a. On failure: queue the event
|
|
405
|
+
const queue = readPending();
|
|
406
|
+
queue.push(payload);
|
|
407
|
+
writePending(queue);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
process.exit(0);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
main().catch(() => process.exit(0));
|