@vimoxshah/tokenflow 1.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/CONTRIBUTING.md +84 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/Refresh & Open Dashboard.command +22 -0
- package/SECURITY.md +42 -0
- package/bin/tokenflow.js +1342 -0
- package/docs/architecture.md +193 -0
- package/docs/cli.md +390 -0
- package/docs/configuration.md +281 -0
- package/docs/creating-provider.md +262 -0
- package/docs/data-model.md +213 -0
- package/docs/getting-started.md +266 -0
- package/docs/live-mode.md +199 -0
- package/docs/media/architecture-hero.svg +86 -0
- package/docs/media/cost-editorial-dark.png +0 -0
- package/docs/media/health-terminal-light.png +0 -0
- package/docs/media/menubar-dark.png +0 -0
- package/docs/media/menubar-light.png +0 -0
- package/docs/media/models-terminal-dark.png +0 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/time-aurora-light.png +0 -0
- package/docs/providers.md +309 -0
- package/docs/skill.md +64 -0
- package/docs/troubleshooting.md +207 -0
- package/examples/config.example.yaml +92 -0
- package/examples/demo-data/README.md +38 -0
- package/examples/demo-data/sample-usage.csv +11 -0
- package/package.json +74 -0
- package/scripts/build-dmg.sh +33 -0
- package/scripts/build-menubar-app.sh +67 -0
- package/scripts/lint.js +111 -0
- package/scripts/validate-install.js +140 -0
- package/skills/tokenflow/SKILL.md +392 -0
- package/skills/tokenflow/examples/config.yaml +92 -0
- package/skills/tokenflow/examples/generic-mapping.json +26 -0
- package/skills/tokenflow/examples/session-transcript.md +191 -0
- package/skills/tokenflow/providers/adapter-template.js +135 -0
- package/skills/tokenflow/providers/detection-matrix.md +142 -0
- package/skills/tokenflow/schemas/config.schema.json +107 -0
- package/skills/tokenflow/schemas/normalized-record.json +63 -0
- package/src/analytics/aggregate.js +247 -0
- package/src/analytics/anomalies.js +222 -0
- package/src/analytics/capacity.js +278 -0
- package/src/analytics/comparison.js +96 -0
- package/src/analytics/dimensions.js +230 -0
- package/src/analytics/efficiency.js +138 -0
- package/src/analytics/forecast.js +202 -0
- package/src/analytics/index.js +327 -0
- package/src/analytics/insights.js +283 -0
- package/src/analytics/milestones.js +91 -0
- package/src/analytics/peak.js +106 -0
- package/src/analytics/productivity.js +166 -0
- package/src/analytics/token-usage.js +267 -0
- package/src/commands/diagnostics.js +88 -0
- package/src/commands/digest.js +155 -0
- package/src/commands/models-compare.js +96 -0
- package/src/core/budget.js +142 -0
- package/src/core/bundle.js +191 -0
- package/src/core/config.js +202 -0
- package/src/core/delivery.js +109 -0
- package/src/core/geo.js +99 -0
- package/src/core/ingest.js +457 -0
- package/src/core/interface-map.js +55 -0
- package/src/core/jsonl.js +124 -0
- package/src/core/live-status.js +417 -0
- package/src/core/model-map.js +157 -0
- package/src/core/notify.js +83 -0
- package/src/core/pricing.js +288 -0
- package/src/core/prompt-analytics.js +127 -0
- package/src/core/registry.js +107 -0
- package/src/core/restore.js +261 -0
- package/src/core/schedule.js +120 -0
- package/src/core/schema.js +316 -0
- package/src/core/sqlite.js +96 -0
- package/src/core/store.js +493 -0
- package/src/core/sync.js +151 -0
- package/src/core/units.js +147 -0
- package/src/core/validate.js +123 -0
- package/src/core/watch.js +287 -0
- package/src/core/yaml.js +209 -0
- package/src/export/bundler.js +107 -0
- package/src/export/csv.js +100 -0
- package/src/export/html-snapshot.js +101 -0
- package/src/export/menubar.js +158 -0
- package/src/index.js +18 -0
- package/src/providers/anthropic/index.js +294 -0
- package/src/providers/cline/index.js +120 -0
- package/src/providers/cursor/index.js +143 -0
- package/src/providers/generic/index.js +268 -0
- package/src/providers/git/index.js +188 -0
- package/src/providers/headroom/index.js +114 -0
- package/src/providers/hermes/index.js +299 -0
- package/src/providers/mock/index.js +117 -0
- package/src/providers/openai/index.js +370 -0
- package/src/providers/opencode/index.js +245 -0
- package/src/sdk.js +46 -0
- package/src/server/server.js +264 -0
- package/src/ui/app.js +2473 -0
- package/src/ui/charts.js +925 -0
- package/src/ui/index.html +42 -0
- package/src/ui/styles.css +644 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic importer — the escape hatch that keeps this project useful for
|
|
3
|
+
* providers nobody has written an adapter for yet.
|
|
4
|
+
*
|
|
5
|
+
* Accepts CSV / TSV / JSON / JSONL / SQLite, driven by a saved field mapping
|
|
6
|
+
* so the second import of the same export needs no configuration at all.
|
|
7
|
+
*
|
|
8
|
+
* A mapping lives at $TOKENFLOW_HOME/mappings/<name>.json:
|
|
9
|
+
*
|
|
10
|
+
* {
|
|
11
|
+
* "name": "openrouter-export",
|
|
12
|
+
* "format": "csv", // csv | tsv | json | jsonl | sqlite
|
|
13
|
+
* "files": ["~/Downloads/openrouter-*.csv"],
|
|
14
|
+
* "table": "usage", // sqlite only
|
|
15
|
+
* "timestampFormat": "iso", // iso | epoch_ms | epoch_s
|
|
16
|
+
* "defaults": { "client": "openrouter", "interface": "API" },
|
|
17
|
+
* "fields": {
|
|
18
|
+
* "timestamp": "created_at",
|
|
19
|
+
* "model": "model",
|
|
20
|
+
* "input_tokens": "prompt_tokens",
|
|
21
|
+
* "output_tokens": "completion_tokens",
|
|
22
|
+
* "cache_read_tokens": "cached_tokens",
|
|
23
|
+
* "estimated_cost": "cost_usd",
|
|
24
|
+
* "session_id": "generation_id"
|
|
25
|
+
* }
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* Unmapped token fields stay `null` — the importer never fills a gap with 0.
|
|
29
|
+
*/
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import os from 'node:os';
|
|
33
|
+
import { createProvider } from '../../core/registry.js';
|
|
34
|
+
import { readLines } from '../../core/jsonl.js';
|
|
35
|
+
import { paths } from '../../core/config.js';
|
|
36
|
+
import { MEASUREMENT } from '../../core/schema.js';
|
|
37
|
+
import { openReadOnly } from '../../core/sqlite.js';
|
|
38
|
+
|
|
39
|
+
export const MAPPABLE_FIELDS = [
|
|
40
|
+
'timestamp', 'model', 'provider', 'client', 'application', 'interface',
|
|
41
|
+
'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens',
|
|
42
|
+
'cache_refresh_tokens', 'reasoning_tokens', 'total_tokens',
|
|
43
|
+
'session_id', 'conversation_id', 'request_id',
|
|
44
|
+
'project', 'repository', 'git_branch', 'category', 'estimated_cost', 'user',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export function loadMappings(ctx) {
|
|
48
|
+
const dir = paths().mappings;
|
|
49
|
+
const out = [];
|
|
50
|
+
let files = [];
|
|
51
|
+
try {
|
|
52
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
53
|
+
} catch {
|
|
54
|
+
files = [];
|
|
55
|
+
}
|
|
56
|
+
for (const f of files) {
|
|
57
|
+
try {
|
|
58
|
+
const m = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
59
|
+
m.name = m.name || f.replace(/\.json$/, '');
|
|
60
|
+
out.push(m);
|
|
61
|
+
} catch { /* skip unreadable mapping */ }
|
|
62
|
+
}
|
|
63
|
+
for (const m of ctx?.config?.sources?.generic?.imports || []) out.push(m);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function expandGlobs(patterns) {
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const raw of patterns || []) {
|
|
70
|
+
const p = raw.startsWith('~') ? path.join(os.homedir(), raw.slice(1)) : raw;
|
|
71
|
+
if (!p.includes('*')) {
|
|
72
|
+
if (fs.existsSync(p)) out.push(p);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const dir = path.dirname(p);
|
|
76
|
+
const rx = new RegExp('^' + path.basename(p).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
|
|
77
|
+
try {
|
|
78
|
+
for (const f of fs.readdirSync(dir)) if (rx.test(f)) out.push(path.join(dir, f));
|
|
79
|
+
} catch { /* directory missing */ }
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export default createProvider({
|
|
85
|
+
id: 'generic',
|
|
86
|
+
name: 'Generic import (CSV / JSON / JSONL / SQLite)',
|
|
87
|
+
description: 'Import any usage export with a saved field mapping. Use this for providers without a dedicated adapter.',
|
|
88
|
+
measurement: MEASUREMENT.PRIMARY,
|
|
89
|
+
requires: ['a mapping in $TOKENFLOW_HOME/mappings/*.json'],
|
|
90
|
+
|
|
91
|
+
async detect(ctx) {
|
|
92
|
+
const maps = loadMappings(ctx);
|
|
93
|
+
if (!maps.length) {
|
|
94
|
+
return { available: false, detail: 'no import mappings defined (see `tokenflow import --help`)' };
|
|
95
|
+
}
|
|
96
|
+
const files = maps.flatMap((m) => expandGlobs(m.files));
|
|
97
|
+
return {
|
|
98
|
+
available: files.length > 0,
|
|
99
|
+
detail: `${maps.length} mapping(s), ${files.length} file(s)`,
|
|
100
|
+
paths: files,
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async discover(ctx) {
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const m of loadMappings(ctx)) {
|
|
107
|
+
for (const f of expandGlobs(m.files)) {
|
|
108
|
+
let stat;
|
|
109
|
+
try { stat = fs.statSync(f); } catch { continue; }
|
|
110
|
+
out.push({ key: `${m.name}:${path.basename(f)}`, path: f, stat, mapping: m });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async ingestFile(ref, ctx, emit) {
|
|
117
|
+
const m = ref.mapping;
|
|
118
|
+
const fmt = (m.format || inferFormat(ref.path)).toLowerCase();
|
|
119
|
+
let records = 0;
|
|
120
|
+
let malformed = 0;
|
|
121
|
+
let offset = ref.stat.size;
|
|
122
|
+
let seq = 0;
|
|
123
|
+
|
|
124
|
+
const push = (row) => {
|
|
125
|
+
const rec = mapRow(row, m, seq++);
|
|
126
|
+
if (!rec) { malformed++; return; }
|
|
127
|
+
emit(rec);
|
|
128
|
+
records++;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
if (fmt === 'jsonl') {
|
|
132
|
+
const res = readLines(ref.path, (line) => {
|
|
133
|
+
try { push(JSON.parse(line)); } catch { malformed++; }
|
|
134
|
+
}, { start: ref.start });
|
|
135
|
+
offset = res.offset;
|
|
136
|
+
} else if (fmt === 'json') {
|
|
137
|
+
const d = JSON.parse(fs.readFileSync(ref.path, 'utf8'));
|
|
138
|
+
const rows = Array.isArray(d) ? d : (d.records || d.data || d.usage || []);
|
|
139
|
+
for (const r of rows) push(r);
|
|
140
|
+
} else if (fmt === 'csv' || fmt === 'tsv') {
|
|
141
|
+
const rows = parseDelimited(fs.readFileSync(ref.path, 'utf8'), fmt === 'tsv' ? '\t' : ',');
|
|
142
|
+
for (const r of rows) push(r);
|
|
143
|
+
} else if (fmt === 'sqlite') {
|
|
144
|
+
const db = openReadOnly(ref.path);
|
|
145
|
+
try {
|
|
146
|
+
const sql = m.query || `SELECT * FROM "${String(m.table).replace(/"/g, '""')}"`;
|
|
147
|
+
for (const r of db.prepare(sql).all()) push(r);
|
|
148
|
+
} finally {
|
|
149
|
+
db.close();
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
throw new Error(`unsupported format "${fmt}" for ${ref.path}`);
|
|
153
|
+
}
|
|
154
|
+
return { offset, records, malformed };
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
/** Exposed for tests and for `tokenflow import --dry-run`. */
|
|
158
|
+
normalize(row, mapping) {
|
|
159
|
+
return mapRow(row, mapping, 0);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export function mapRow(row, m, seq) {
|
|
164
|
+
const f = m.fields || {};
|
|
165
|
+
const get = (name) => {
|
|
166
|
+
const src = f[name];
|
|
167
|
+
if (!src) return undefined;
|
|
168
|
+
if (typeof src === 'string') return row[src];
|
|
169
|
+
if (src && typeof src === 'object' && src.const !== undefined) return src.const;
|
|
170
|
+
return undefined;
|
|
171
|
+
};
|
|
172
|
+
const ts = normalizeTs(get('timestamp'), m.timestampFormat);
|
|
173
|
+
if (!ts) return null;
|
|
174
|
+
|
|
175
|
+
const out = {
|
|
176
|
+
id: m.idField && row[m.idField] ? `generic-${m.name}-${row[m.idField]}` : undefined,
|
|
177
|
+
timestamp: ts,
|
|
178
|
+
model: str(get('model')) ?? m.defaults?.model ?? null,
|
|
179
|
+
provider: str(get('provider')) ?? m.defaults?.provider ?? undefined,
|
|
180
|
+
client: str(get('client')) ?? m.defaults?.client ?? 'generic',
|
|
181
|
+
application: str(get('application')) ?? m.defaults?.application ?? m.name,
|
|
182
|
+
interface: str(get('interface')) ?? m.defaults?.interface ?? undefined,
|
|
183
|
+
interfaceSignals: [str(get('interface')), m.defaults?.interface, str(get('client')), m.defaults?.client],
|
|
184
|
+
session_id: str(get('session_id')),
|
|
185
|
+
conversation_id: str(get('conversation_id')),
|
|
186
|
+
request_id: str(get('request_id')),
|
|
187
|
+
project: str(get('project')) ?? m.defaults?.project ?? null,
|
|
188
|
+
repository: str(get('repository')),
|
|
189
|
+
git_branch: str(get('git_branch')),
|
|
190
|
+
category: str(get('category')),
|
|
191
|
+
user: str(get('user')) ?? m.defaults?.user ?? null,
|
|
192
|
+
measurement: m.measurement || MEASUREMENT.PRIMARY,
|
|
193
|
+
metadata: { import: m.name, ...(m.metadata || {}) },
|
|
194
|
+
};
|
|
195
|
+
for (const tf of ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'cache_refresh_tokens', 'reasoning_tokens']) {
|
|
196
|
+
out[tf] = numOrNull(get(tf));
|
|
197
|
+
}
|
|
198
|
+
const cost = numOrNull(get('estimated_cost'));
|
|
199
|
+
if (cost !== null) out.measured_cost = cost;
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function inferFormat(p) {
|
|
204
|
+
const e = path.extname(p).toLowerCase();
|
|
205
|
+
if (e === '.jsonl' || e === '.ndjson') return 'jsonl';
|
|
206
|
+
if (e === '.json') return 'json';
|
|
207
|
+
if (e === '.tsv') return 'tsv';
|
|
208
|
+
if (e === '.db' || e === '.sqlite' || e === '.sqlite3') return 'sqlite';
|
|
209
|
+
return 'csv';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function normalizeTs(v, format) {
|
|
213
|
+
if (v === undefined || v === null || v === '') return null;
|
|
214
|
+
if (format === 'epoch_ms' || (typeof v === 'number' && v > 1e11)) {
|
|
215
|
+
const d = new Date(Number(v));
|
|
216
|
+
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
217
|
+
}
|
|
218
|
+
if (format === 'epoch_s' || (typeof v === 'number' && v > 1e8)) {
|
|
219
|
+
const d = new Date(Number(v) * 1000);
|
|
220
|
+
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
221
|
+
}
|
|
222
|
+
const d = new Date(v);
|
|
223
|
+
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** RFC4180-ish delimited parser: quotes, escaped quotes, embedded newlines. */
|
|
227
|
+
export function parseDelimited(text, delim = ',') {
|
|
228
|
+
const rows = [];
|
|
229
|
+
let row = [];
|
|
230
|
+
let cell = '';
|
|
231
|
+
let q = false;
|
|
232
|
+
for (let i = 0; i < text.length; i++) {
|
|
233
|
+
const c = text[i];
|
|
234
|
+
if (q) {
|
|
235
|
+
if (c === '"') {
|
|
236
|
+
if (text[i + 1] === '"') { cell += '"'; i++; } else q = false;
|
|
237
|
+
} else cell += c;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (c === '"') { q = true; continue; }
|
|
241
|
+
if (c === delim) { row.push(cell); cell = ''; continue; }
|
|
242
|
+
if (c === '\n' || c === '\r') {
|
|
243
|
+
if (c === '\r' && text[i + 1] === '\n') i++;
|
|
244
|
+
row.push(cell); cell = '';
|
|
245
|
+
if (row.length > 1 || row[0] !== '') rows.push(row);
|
|
246
|
+
row = [];
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
cell += c;
|
|
250
|
+
}
|
|
251
|
+
if (cell !== '' || row.length) { row.push(cell); rows.push(row); }
|
|
252
|
+
if (!rows.length) return [];
|
|
253
|
+
const header = rows[0].map((h) => h.trim().replace(/^/, ''));
|
|
254
|
+
return rows.slice(1).map((r) => {
|
|
255
|
+
const o = {};
|
|
256
|
+
header.forEach((h, i) => { o[h] = r[i]; });
|
|
257
|
+
return o;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function str(v) {
|
|
262
|
+
return v === undefined || v === null || v === '' ? null : String(v);
|
|
263
|
+
}
|
|
264
|
+
function numOrNull(v) {
|
|
265
|
+
if (v === undefined || v === null || v === '') return null;
|
|
266
|
+
const n = Number(v);
|
|
267
|
+
return Number.isFinite(n) ? n : null;
|
|
268
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git activity — the correlation source.
|
|
3
|
+
*
|
|
4
|
+
* Emits one `activity` record per commit (files changed, insertions,
|
|
5
|
+
* deletions) so the Productivity section can put AI usage next to actual
|
|
6
|
+
* shipped work. It never claims causation: the analytics layer labels these
|
|
7
|
+
* relationships as correlations and refuses to compute one at all when the
|
|
8
|
+
* two series don't overlap in time.
|
|
9
|
+
*
|
|
10
|
+
* Repositories come from, in order:
|
|
11
|
+
* 1. config `sources.git.repos: [ ... ]`
|
|
12
|
+
* 2. config `sources.git.scanRoots: [ ... ]` (searched for .git, depth 3)
|
|
13
|
+
* 3. `sources.git.autoFromUsage: true` — the working directories already
|
|
14
|
+
* observed in ingested usage records, which means zero configuration for
|
|
15
|
+
* the common case where you code where you prompt
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import { execFileSync } from 'node:child_process';
|
|
21
|
+
import { createProvider } from '../../core/registry.js';
|
|
22
|
+
import { MEASUREMENT } from '../../core/schema.js';
|
|
23
|
+
import { paths } from '../../core/config.js';
|
|
24
|
+
import { readJson } from '../../core/store.js';
|
|
25
|
+
|
|
26
|
+
const SEP = '';
|
|
27
|
+
|
|
28
|
+
function expand(p) {
|
|
29
|
+
return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveRepos(ctx) {
|
|
33
|
+
const cfg = ctx?.config?.sources?.git || {};
|
|
34
|
+
const out = new Set();
|
|
35
|
+
for (const r of cfg.repos || []) if (isRepo(expand(r))) out.add(expand(r));
|
|
36
|
+
for (const root of cfg.scanRoots || []) {
|
|
37
|
+
for (const r of scan(expand(root), 3)) out.add(r);
|
|
38
|
+
}
|
|
39
|
+
if (cfg.autoFromUsage !== false) {
|
|
40
|
+
const known = readJson(path.join(paths().data, 'project-paths.json'), { paths: [] });
|
|
41
|
+
for (const p of known.paths || []) {
|
|
42
|
+
const r = repoRoot(p);
|
|
43
|
+
if (r) out.add(r);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return [...out];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isRepo(d) {
|
|
50
|
+
try {
|
|
51
|
+
return fs.statSync(path.join(d, '.git')).isDirectory() || fs.statSync(path.join(d, '.git')).isFile();
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function repoRoot(p) {
|
|
58
|
+
let d = p;
|
|
59
|
+
for (let i = 0; i < 8 && d && d !== '/'; i++) {
|
|
60
|
+
if (isRepo(d)) return d;
|
|
61
|
+
d = path.dirname(d);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function scan(root, depth) {
|
|
67
|
+
const out = [];
|
|
68
|
+
const stack = [[root, 0]];
|
|
69
|
+
while (stack.length) {
|
|
70
|
+
const [d, lvl] = stack.pop();
|
|
71
|
+
if (lvl > depth) continue;
|
|
72
|
+
if (isRepo(d)) { out.push(d); continue; }
|
|
73
|
+
let entries = [];
|
|
74
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { continue; }
|
|
75
|
+
for (const e of entries) {
|
|
76
|
+
if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') stack.push([path.join(d, e.name), lvl + 1]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default createProvider({
|
|
83
|
+
id: 'git',
|
|
84
|
+
name: 'Git activity (correlation)',
|
|
85
|
+
description: 'Commits, files changed and line churn per repository. Activity only — used to correlate AI usage with shipped work.',
|
|
86
|
+
measurement: MEASUREMENT.ACTIVITY,
|
|
87
|
+
requires: ['git on PATH', 'at least one repository configured or discoverable'],
|
|
88
|
+
|
|
89
|
+
async detect(ctx) {
|
|
90
|
+
try {
|
|
91
|
+
execFileSync('git', ['--version'], { stdio: 'ignore' });
|
|
92
|
+
} catch {
|
|
93
|
+
return { available: false, detail: 'git is not on PATH' };
|
|
94
|
+
}
|
|
95
|
+
const repos = resolveRepos(ctx);
|
|
96
|
+
if (!repos.length) {
|
|
97
|
+
return {
|
|
98
|
+
available: false,
|
|
99
|
+
detail: 'no repositories found — set sources.git.repos or sources.git.scanRoots in config.yaml',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return { available: true, detail: `${repos.length} repositor${repos.length === 1 ? 'y' : 'ies'}`, paths: repos };
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async fetchUsage(ctx, emit, sourceState) {
|
|
106
|
+
const repos = resolveRepos(ctx);
|
|
107
|
+
const cursor = sourceState?.cursor || {};
|
|
108
|
+
const author = ctx.config?.sources?.git?.author || null;
|
|
109
|
+
let records = 0;
|
|
110
|
+
const notes = [];
|
|
111
|
+
|
|
112
|
+
for (const repo of repos) {
|
|
113
|
+
const name = path.basename(repo);
|
|
114
|
+
const since = cursor[repo] || ctx.config?.sources?.git?.since || null;
|
|
115
|
+
const args = [
|
|
116
|
+
'-C', repo, 'log', '--no-merges', '--numstat', '--date=iso-strict',
|
|
117
|
+
`--pretty=format:C${SEP}%H${SEP}%aI${SEP}%an${SEP}%ae${SEP}%D${SEP}%s`,
|
|
118
|
+
];
|
|
119
|
+
if (since) args.push(`--since=${since}`);
|
|
120
|
+
if (author) args.push(`--author=${author}`);
|
|
121
|
+
let out;
|
|
122
|
+
try {
|
|
123
|
+
out = execFileSync('git', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
notes.push(`${name}: ${String(err.message).split('\n')[0]}`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
let cur = null;
|
|
129
|
+
let newest = since;
|
|
130
|
+
const flush = () => {
|
|
131
|
+
if (!cur) return;
|
|
132
|
+
emit({
|
|
133
|
+
id: `git-${cur.hash}`,
|
|
134
|
+
timestamp: cur.date,
|
|
135
|
+
model: null,
|
|
136
|
+
client: 'git',
|
|
137
|
+
application: 'Git',
|
|
138
|
+
interface: 'CLI',
|
|
139
|
+
input_tokens: null, output_tokens: null, cache_read_tokens: null,
|
|
140
|
+
cache_write_tokens: null, cache_refresh_tokens: null, reasoning_tokens: null,
|
|
141
|
+
project: name,
|
|
142
|
+
repository: name,
|
|
143
|
+
git_branch: cur.branch || null,
|
|
144
|
+
category: 'commit',
|
|
145
|
+
measurement: MEASUREMENT.ACTIVITY,
|
|
146
|
+
metadata: {
|
|
147
|
+
commit: cur.hash,
|
|
148
|
+
message: cur.subject,
|
|
149
|
+
author: cur.author,
|
|
150
|
+
files_changed: cur.files,
|
|
151
|
+
insertions: cur.ins,
|
|
152
|
+
deletions: cur.del,
|
|
153
|
+
repo_path: repo,
|
|
154
|
+
tokens_reported: false,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
records++;
|
|
158
|
+
cur = null;
|
|
159
|
+
};
|
|
160
|
+
for (const line of out.split('\n')) {
|
|
161
|
+
if (line.startsWith('C' + SEP)) {
|
|
162
|
+
flush();
|
|
163
|
+
const [, hash, date, an, ae, refs, subject] = line.split(SEP);
|
|
164
|
+
cur = { hash, date, author: an, email: ae, branch: parseRefs(refs), subject: subject || '', files: 0, ins: 0, del: 0 };
|
|
165
|
+
if (!newest || date > newest) newest = date;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!cur || !line.trim()) continue;
|
|
169
|
+
const m = line.split('\t');
|
|
170
|
+
if (m.length < 3) continue;
|
|
171
|
+
cur.files++;
|
|
172
|
+
if (m[0] !== '-') cur.ins += Number(m[0]) || 0;
|
|
173
|
+
if (m[1] !== '-') cur.del += Number(m[1]) || 0;
|
|
174
|
+
}
|
|
175
|
+
flush();
|
|
176
|
+
if (newest) cursor[repo] = newest;
|
|
177
|
+
}
|
|
178
|
+
return { records, cursor, notes };
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
function parseRefs(refs) {
|
|
183
|
+
if (!refs) return null;
|
|
184
|
+
const m = refs.match(/HEAD -> ([^,]+)/);
|
|
185
|
+
if (m) return m[1].trim();
|
|
186
|
+
const first = refs.split(',')[0].trim();
|
|
187
|
+
return first && !first.startsWith('tag:') ? first : null;
|
|
188
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headroom — local LLM gateway / prompt-compression proxy.
|
|
3
|
+
*
|
|
4
|
+
* This is an **overlay** source, and the distinction matters. The proxy sees
|
|
5
|
+
* the same requests that the Codex adapter already recorded from the client
|
|
6
|
+
* side. Adding both into one total would double-count every routed token, so
|
|
7
|
+
* overlay records are excluded from token totals by default
|
|
8
|
+
* (`analytics.includeOverlaySources: false`) and used for what only the
|
|
9
|
+
* gateway knows:
|
|
10
|
+
*
|
|
11
|
+
* - a **measured** dollar cost per request (`cost_usd`), as opposed to our
|
|
12
|
+
* own price-table estimate — which gives the Cost section an independent
|
|
13
|
+
* cross-check instead of a single unverifiable number
|
|
14
|
+
* - the compression delta (`before` -> `after` prompt tokens), i.e. tokens
|
|
15
|
+
* the client believed it sent versus tokens actually billed
|
|
16
|
+
*
|
|
17
|
+
* `input_tokens` is the post-compression count that was really sent. Output
|
|
18
|
+
* and cache are `null`: the savings log does not carry them.
|
|
19
|
+
*
|
|
20
|
+
* Source: ~/.headroom/savings_events.jsonl
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import { createProvider } from '../../core/registry.js';
|
|
26
|
+
import { readLines } from '../../core/jsonl.js';
|
|
27
|
+
import { MEASUREMENT } from '../../core/schema.js';
|
|
28
|
+
|
|
29
|
+
function dir(ctx) {
|
|
30
|
+
const c = ctx?.config?.sources?.headroom?.path;
|
|
31
|
+
if (c) return c.startsWith('~') ? path.join(os.homedir(), c.slice(1)) : c;
|
|
32
|
+
return path.join(ctx?.home || os.homedir(), '.headroom');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default createProvider({
|
|
36
|
+
id: 'headroom',
|
|
37
|
+
name: 'Headroom gateway (overlay)',
|
|
38
|
+
description: 'Measured per-request cost and prompt-compression savings from a local LLM proxy. Overlay: excluded from token totals to avoid double counting.',
|
|
39
|
+
measurement: MEASUREMENT.OVERLAY,
|
|
40
|
+
requires: ['~/.headroom/savings_events.jsonl'],
|
|
41
|
+
|
|
42
|
+
async detect(ctx) {
|
|
43
|
+
const f = path.join(dir(ctx), 'savings_events.jsonl');
|
|
44
|
+
if (!fs.existsSync(f)) return { available: false, detail: 'No ~/.headroom/savings_events.jsonl found' };
|
|
45
|
+
return { available: true, detail: 'overlay source — measured cost, excluded from token totals', paths: [f] };
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async discover(ctx) {
|
|
49
|
+
const d = dir(ctx);
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const name of ['savings_events.jsonl']) {
|
|
52
|
+
const f = path.join(d, name);
|
|
53
|
+
try {
|
|
54
|
+
out.push({ key: name, path: f, stat: fs.statSync(f) });
|
|
55
|
+
} catch { /* absent */ }
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async ingestFile(ref, ctx, emit) {
|
|
61
|
+
let records = 0;
|
|
62
|
+
let malformed = 0;
|
|
63
|
+
let seq = 0;
|
|
64
|
+
const res = readLines(
|
|
65
|
+
ref.path,
|
|
66
|
+
(line) => {
|
|
67
|
+
let d;
|
|
68
|
+
try {
|
|
69
|
+
d = JSON.parse(line);
|
|
70
|
+
} catch {
|
|
71
|
+
malformed++;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!d.ts && !d.timestamp) return;
|
|
75
|
+
const after = num(d.after);
|
|
76
|
+
emit({
|
|
77
|
+
id: `headroom-${d.ts || d.timestamp}-${seq++}`,
|
|
78
|
+
timestamp: d.ts || d.timestamp,
|
|
79
|
+
model: d.model || null,
|
|
80
|
+
gateway: 'headroom',
|
|
81
|
+
client: d.client || 'unknown',
|
|
82
|
+
application: d.client ? `${d.client} via Headroom` : 'Headroom gateway',
|
|
83
|
+
// The proxy log has no surface field of its own; the client name is
|
|
84
|
+
// the only surface evidence available.
|
|
85
|
+
interfaceSignals: [d.client],
|
|
86
|
+
input_tokens: after,
|
|
87
|
+
output_tokens: null,
|
|
88
|
+
cache_read_tokens: null,
|
|
89
|
+
cache_write_tokens: null,
|
|
90
|
+
cache_refresh_tokens: null,
|
|
91
|
+
reasoning_tokens: null,
|
|
92
|
+
measured_cost: d.cost_usd === undefined || d.cost_usd === null ? null : Number(d.cost_usd),
|
|
93
|
+
session_id: null,
|
|
94
|
+
request_id: null,
|
|
95
|
+
measurement: MEASUREMENT.OVERLAY,
|
|
96
|
+
metadata: {
|
|
97
|
+
prompt_tokens_before_compression: num(d.before),
|
|
98
|
+
prompt_tokens_after_compression: after,
|
|
99
|
+
tokens_saved: num(d.saved),
|
|
100
|
+
proxy_source: d.source ?? null,
|
|
101
|
+
pid: d.pid ?? null,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
records++;
|
|
105
|
+
},
|
|
106
|
+
{ start: ref.start },
|
|
107
|
+
);
|
|
108
|
+
return { offset: res.offset, records, malformed };
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
function num(v) {
|
|
113
|
+
return v === undefined || v === null ? null : Number(v);
|
|
114
|
+
}
|