@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
package/src/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library entry point. Everything the CLI and dashboard use is exported here,
|
|
3
|
+
* so the platform can be embedded in another tool without shelling out.
|
|
4
|
+
*/
|
|
5
|
+
export * from './core/schema.js';
|
|
6
|
+
export * from './core/validate.js';
|
|
7
|
+
export * from './core/registry.js';
|
|
8
|
+
export * from './core/config.js';
|
|
9
|
+
export * from './core/pricing.js';
|
|
10
|
+
export * from './core/model-map.js';
|
|
11
|
+
export * from './core/interface-map.js';
|
|
12
|
+
export { refresh, enrich, walk } from './core/ingest.js';
|
|
13
|
+
export { Store, encodeRecord, decodeRecord } from './core/store.js';
|
|
14
|
+
export { buildBundle, queryRecords } from './core/bundle.js';
|
|
15
|
+
export * from './analytics/index.js';
|
|
16
|
+
export * from './export/csv.js';
|
|
17
|
+
export { buildSnapshot } from './export/html-snapshot.js';
|
|
18
|
+
export { startServer } from './server/server.js';
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic — Claude Code / Claude Agent SDK session transcripts.
|
|
3
|
+
*
|
|
4
|
+
* Source: <claude-home>/projects/<slugified-cwd>/<sessionId>.jsonl
|
|
5
|
+
* Homes are auto-discovered: $CLAUDE_CONFIG_DIR, ~/.claude, ~/.config/claude,
|
|
6
|
+
* and any ~/.claude-* sibling that contains a `projects/` directory (people
|
|
7
|
+
* who run several accounts keep them side by side).
|
|
8
|
+
*
|
|
9
|
+
* ## Two correctness traps this adapter exists to handle
|
|
10
|
+
*
|
|
11
|
+
* 1. **Streaming snapshots.** A single assistant message is written to the
|
|
12
|
+
* transcript several times as it streams. Every line carries the SAME
|
|
13
|
+
* `requestId` + `message.id` and a *growing* `output_tokens`, with input and
|
|
14
|
+
* cache counts constant. Summing them inflates output by 3-5x. The correct
|
|
15
|
+
* value is the final snapshot, so we group by (requestId, message.id) and
|
|
16
|
+
* keep the per-field maximum.
|
|
17
|
+
*
|
|
18
|
+
* A group can straddle the end of the file, because a live session is still
|
|
19
|
+
* being written. Rather than defer it (which would silently drop the last
|
|
20
|
+
* record of every settled file, forever), the group is emitted at EOF and
|
|
21
|
+
* its emitted totals are remembered as that file's `tails`. If the file
|
|
22
|
+
* later grows and the same (requestId, message.id) reappears with larger
|
|
23
|
+
* counts, only the DELTA is emitted. So the record is complete, never
|
|
24
|
+
* duplicated, and never double counted.
|
|
25
|
+
*
|
|
26
|
+
* 2. **Synthetic messages.** Locally generated assistant entries have
|
|
27
|
+
* `requestId: null` and an all-zero usage block. They are not API calls;
|
|
28
|
+
* counting them would add fake zero-token requests and skew "requests" and
|
|
29
|
+
* "tokens per request". They are skipped and counted separately.
|
|
30
|
+
*
|
|
31
|
+
* Token semantics (already the exclusive convention the schema wants):
|
|
32
|
+
* input_tokens fresh prompt tokens, EXCLUDING cache
|
|
33
|
+
* cache_read_input_tokens -> cache_read_tokens
|
|
34
|
+
* cache_creation_input_tokens -> cache_write_tokens
|
|
35
|
+
* cache_creation.ephemeral_1h -> cache_refresh_tokens (subset of writes)
|
|
36
|
+
* output_tokens_details.thinking_tokens -> reasoning_tokens (subset of output)
|
|
37
|
+
*/
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import os from 'node:os';
|
|
41
|
+
import { createProvider } from '../../core/registry.js';
|
|
42
|
+
import { readLines } from '../../core/jsonl.js';
|
|
43
|
+
import { walk } from '../../core/ingest.js';
|
|
44
|
+
import { MEASUREMENT } from '../../core/schema.js';
|
|
45
|
+
|
|
46
|
+
const USAGE_MARK = '"usage"';
|
|
47
|
+
/** Groups this far behind the read head cannot still be open. */
|
|
48
|
+
const GROUP_FLUSH_LAG = 2 << 20;
|
|
49
|
+
|
|
50
|
+
export function candidateHomes(ctx) {
|
|
51
|
+
const configured = ctx?.config?.sources?.anthropic?.paths;
|
|
52
|
+
if (Array.isArray(configured) && configured.length) return configured.map(expand);
|
|
53
|
+
const home = ctx?.home || os.homedir();
|
|
54
|
+
const out = [];
|
|
55
|
+
if (process.env.CLAUDE_CONFIG_DIR) out.push(...process.env.CLAUDE_CONFIG_DIR.split(path.delimiter));
|
|
56
|
+
out.push(path.join(home, '.claude'), path.join(home, '.config', 'claude'));
|
|
57
|
+
// Sibling homes: ~/.claude-work, ~/.claude-personal, ...
|
|
58
|
+
try {
|
|
59
|
+
for (const e of fs.readdirSync(home, { withFileTypes: true })) {
|
|
60
|
+
if (!e.isDirectory() && !e.isSymbolicLink()) continue;
|
|
61
|
+
if (!/^\.claude([-.].+)?$/.test(e.name)) continue;
|
|
62
|
+
out.push(path.join(home, e.name));
|
|
63
|
+
}
|
|
64
|
+
} catch { /* home unreadable: fall through to the explicit list */ }
|
|
65
|
+
return [...new Set(out)].filter((d) => {
|
|
66
|
+
try {
|
|
67
|
+
return fs.statSync(path.join(d, 'projects')).isDirectory();
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function expand(p) {
|
|
75
|
+
return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** "-Users-me-projects-checkout-api" -> "checkout-api" */
|
|
79
|
+
export function projectFromSlug(slug, cwd) {
|
|
80
|
+
const base = cwd || slug.replace(/^-/, '/').replace(/-/g, '/');
|
|
81
|
+
const parts = String(cwd || base).split('/').filter(Boolean);
|
|
82
|
+
if (!parts.length) return null;
|
|
83
|
+
const last = parts[parts.length - 1];
|
|
84
|
+
// A temp dir tells us nothing useful about the project.
|
|
85
|
+
if (/^(T|tmp|temp)$/i.test(last) && parts.length > 1) return parts[parts.length - 2];
|
|
86
|
+
return last;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default createProvider({
|
|
90
|
+
id: 'anthropic',
|
|
91
|
+
name: 'Anthropic (Claude Code / Agent SDK)',
|
|
92
|
+
description: 'Per-request token usage from Claude Code and Claude Agent SDK session transcripts.',
|
|
93
|
+
measurement: MEASUREMENT.PRIMARY,
|
|
94
|
+
requires: ['A Claude Code home directory with a projects/ folder'],
|
|
95
|
+
|
|
96
|
+
async detect(ctx) {
|
|
97
|
+
const homes = candidateHomes(ctx);
|
|
98
|
+
if (!homes.length) {
|
|
99
|
+
return { available: false, detail: 'No Claude Code home found (looked for ~/.claude*/projects)' };
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
available: true,
|
|
103
|
+
detail: homes.map((h) => shortHome(h, ctx)).join(', '),
|
|
104
|
+
paths: homes,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async discover(ctx) {
|
|
109
|
+
const files = [];
|
|
110
|
+
for (const home of candidateHomes(ctx)) {
|
|
111
|
+
const label = path.basename(home);
|
|
112
|
+
const root = path.join(home, 'projects');
|
|
113
|
+
for (const f of walk(root, (n) => n.endsWith('.jsonl'))) {
|
|
114
|
+
let stat;
|
|
115
|
+
try { stat = fs.statSync(f); } catch { continue; }
|
|
116
|
+
if (!stat.size) continue;
|
|
117
|
+
files.push({ key: `${label}:${path.relative(root, f)}`, path: f, stat, home, label });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return files;
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async ingestFile(ref, ctx, emit) {
|
|
124
|
+
/** @type {Map<string, {rec:object, start:number}>} */
|
|
125
|
+
const open = new Map();
|
|
126
|
+
/** Totals already emitted for a group that straddled a previous EOF. */
|
|
127
|
+
const tails = ref.state.tails || {};
|
|
128
|
+
let records = 0;
|
|
129
|
+
let malformed = 0;
|
|
130
|
+
let synthetic = 0;
|
|
131
|
+
|
|
132
|
+
const FIELDS = ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'cache_refresh_tokens', 'reasoning_tokens'];
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Emit a group.
|
|
136
|
+
*
|
|
137
|
+
* One logical message can be split across two flushes: once because it fell
|
|
138
|
+
* far behind the read head, once at EOF. `carried` holds the absolute totals
|
|
139
|
+
* already emitted for a key, so a reappearance emits only the delta and the
|
|
140
|
+
* two halves sum to the true maximum rather than to their sum.
|
|
141
|
+
*/
|
|
142
|
+
const carried = { ...tails };
|
|
143
|
+
const flush = (key, remember = false) => {
|
|
144
|
+
const g = open.get(key);
|
|
145
|
+
if (!g) return;
|
|
146
|
+
open.delete(key);
|
|
147
|
+
const rec = g.rec;
|
|
148
|
+
const base = carried[key];
|
|
149
|
+
|
|
150
|
+
const absolute = {};
|
|
151
|
+
for (const f of FIELDS) {
|
|
152
|
+
const prev = base ? (base[f] ?? 0) : 0;
|
|
153
|
+
absolute[f] = rec[f] === null ? prev : Math.max(prev, rec[f]);
|
|
154
|
+
}
|
|
155
|
+
if (remember) carried[key] = absolute;
|
|
156
|
+
|
|
157
|
+
if (base) {
|
|
158
|
+
let any = false;
|
|
159
|
+
for (const f of FIELDS) {
|
|
160
|
+
if (rec[f] === null) continue;
|
|
161
|
+
rec[f] = Math.max(0, rec[f] - (base[f] ?? 0));
|
|
162
|
+
if (rec[f] > 0) any = true;
|
|
163
|
+
}
|
|
164
|
+
rec.metadata = { ...rec.metadata, continuation_of: key };
|
|
165
|
+
if (!any) return; // nothing new in this group
|
|
166
|
+
}
|
|
167
|
+
emit(rec);
|
|
168
|
+
records++;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const res = readLines(
|
|
172
|
+
ref.path,
|
|
173
|
+
(line, offsetAfter) => {
|
|
174
|
+
const lineStart = offsetAfter - Buffer.byteLength(line) - 1;
|
|
175
|
+
let d;
|
|
176
|
+
try {
|
|
177
|
+
d = JSON.parse(line);
|
|
178
|
+
} catch {
|
|
179
|
+
malformed++;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (d.type !== 'assistant') return;
|
|
183
|
+
const msg = d.message;
|
|
184
|
+
const u = msg && msg.usage;
|
|
185
|
+
if (!u) return;
|
|
186
|
+
|
|
187
|
+
const inTok = n(u.input_tokens);
|
|
188
|
+
const outTok = n(u.output_tokens);
|
|
189
|
+
const crTok = n(u.cache_read_input_tokens);
|
|
190
|
+
const cwTok = n(u.cache_creation_input_tokens);
|
|
191
|
+
|
|
192
|
+
// Synthetic / locally generated entry: no API call happened. Claude Code
|
|
193
|
+
// marks these either by omitting requestId with an all-zero usage block,
|
|
194
|
+
// or by naming the model `<synthetic>` outright.
|
|
195
|
+
if (msg.model === '<synthetic>' || (!d.requestId && !inTok && !outTok && !crTok && !cwTok)) {
|
|
196
|
+
synthetic++;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const key = `${d.requestId || 'na'}|${msg.id || d.uuid}`;
|
|
201
|
+
const cwd = d.cwd || null;
|
|
202
|
+
const partial = {
|
|
203
|
+
timestamp: d.timestamp,
|
|
204
|
+
model: msg.model || null,
|
|
205
|
+
input_tokens: inTok,
|
|
206
|
+
output_tokens: outTok,
|
|
207
|
+
cache_read_tokens: crTok,
|
|
208
|
+
cache_write_tokens: cwTok,
|
|
209
|
+
cache_refresh_tokens: u.cache_creation ? n(u.cache_creation.ephemeral_1h_input_tokens) : null,
|
|
210
|
+
reasoning_tokens: u.output_tokens_details ? n(u.output_tokens_details.thinking_tokens) : null,
|
|
211
|
+
session_id: d.sessionId || null,
|
|
212
|
+
conversation_id: d.sessionId || null,
|
|
213
|
+
request_id: d.requestId || null,
|
|
214
|
+
project: projectFromSlug(ref.key, cwd),
|
|
215
|
+
repository: projectFromSlug(ref.key, cwd),
|
|
216
|
+
git_branch: d.gitBranch || null,
|
|
217
|
+
category: d.isSidechain ? 'subagent' : 'main',
|
|
218
|
+
client: 'claude-code',
|
|
219
|
+
application: 'Claude Code',
|
|
220
|
+
interfaceSignals: [d.entrypoint, d.userType === 'external' ? null : d.userType],
|
|
221
|
+
metadata: {
|
|
222
|
+
cwd,
|
|
223
|
+
version: d.version || null,
|
|
224
|
+
entrypoint: d.entrypoint || null,
|
|
225
|
+
service_tier: u.service_tier ?? null,
|
|
226
|
+
speed: u.speed ?? null,
|
|
227
|
+
stop_reason: msg.stop_reason ?? null,
|
|
228
|
+
sidechain: !!d.isSidechain,
|
|
229
|
+
home: ref.label,
|
|
230
|
+
cache_write_5m: u.cache_creation ? n(u.cache_creation.ephemeral_5m_input_tokens) : null,
|
|
231
|
+
web_search_requests: u.server_tool_use ? n(u.server_tool_use.web_search_requests) : null,
|
|
232
|
+
web_fetch_requests: u.server_tool_use ? n(u.server_tool_use.web_fetch_requests) : null,
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const g = open.get(key);
|
|
237
|
+
if (!g) {
|
|
238
|
+
open.set(key, { rec: partial, start: lineStart });
|
|
239
|
+
} else {
|
|
240
|
+
// Keep the largest snapshot of every field; output grows as it streams.
|
|
241
|
+
const r = g.rec;
|
|
242
|
+
r.output_tokens = maxN(r.output_tokens, partial.output_tokens);
|
|
243
|
+
r.input_tokens = maxN(r.input_tokens, partial.input_tokens);
|
|
244
|
+
r.cache_read_tokens = maxN(r.cache_read_tokens, partial.cache_read_tokens);
|
|
245
|
+
r.cache_write_tokens = maxN(r.cache_write_tokens, partial.cache_write_tokens);
|
|
246
|
+
r.cache_refresh_tokens = maxN(r.cache_refresh_tokens, partial.cache_refresh_tokens);
|
|
247
|
+
r.reasoning_tokens = maxN(r.reasoning_tokens, partial.reasoning_tokens);
|
|
248
|
+
r.timestamp = partial.timestamp || r.timestamp;
|
|
249
|
+
r.metadata.stop_reason = partial.metadata.stop_reason ?? r.metadata.stop_reason;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Anything far enough behind the read head is provably finished.
|
|
253
|
+
for (const [k, v] of open) {
|
|
254
|
+
// Far enough behind the read head that it is almost certainly done —
|
|
255
|
+
// but remember it, in case a sidechain interleaves it back in.
|
|
256
|
+
if (k !== key && offsetAfter - v.start > GROUP_FLUSH_LAG) flush(k, true);
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
{ start: ref.start, must: [USAGE_MARK] },
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// Emit whatever is still open, and remember what we emitted for each of
|
|
263
|
+
// those groups. If the file grows and one of them continues, only the
|
|
264
|
+
// delta will be emitted next time (see `flush`).
|
|
265
|
+
// Only groups still open at EOF can continue in a later append, so only
|
|
266
|
+
// those are worth persisting. Groups flushed mid-read are finished.
|
|
267
|
+
const openKeys = [...open.keys()];
|
|
268
|
+
for (const key of openKeys) flush(key, true);
|
|
269
|
+
const nextTails = {};
|
|
270
|
+
for (const key of openKeys) if (carried[key]) nextTails[key] = carried[key];
|
|
271
|
+
ref.state.tails = nextTails;
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
offset: res.offset,
|
|
275
|
+
records,
|
|
276
|
+
malformed,
|
|
277
|
+
openAtEof: openKeys.length,
|
|
278
|
+
synthetic,
|
|
279
|
+
};
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
function shortHome(h, ctx) {
|
|
284
|
+
const home = ctx?.home || os.homedir();
|
|
285
|
+
return h.startsWith(home) ? '~' + h.slice(home.length) : h;
|
|
286
|
+
}
|
|
287
|
+
function n(v) {
|
|
288
|
+
return v === undefined || v === null ? null : Number(v);
|
|
289
|
+
}
|
|
290
|
+
function maxN(a, b) {
|
|
291
|
+
if (a === null) return b;
|
|
292
|
+
if (b === null) return a;
|
|
293
|
+
return Math.max(a, b);
|
|
294
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cline CLI — session records.
|
|
3
|
+
*
|
|
4
|
+
* Cline's session files record which provider/model was used and when, but
|
|
5
|
+
* they contain **no token accounting at all**. That is exactly the case the
|
|
6
|
+
* schema's missing-value contract exists for: every token field is `null`
|
|
7
|
+
* ("the source does not report this"), the record is marked
|
|
8
|
+
* `measurement: activity`, and the dashboard counts these sessions in activity
|
|
9
|
+
* metrics while explicitly excluding them from token totals — rather than
|
|
10
|
+
* quietly adding a session's worth of zeros and dragging every average down.
|
|
11
|
+
*
|
|
12
|
+
* Source: ~/.cline/data/sessions/<id>/<id>.json
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import { createProvider } from '../../core/registry.js';
|
|
18
|
+
import { MEASUREMENT } from '../../core/schema.js';
|
|
19
|
+
|
|
20
|
+
function root(ctx) {
|
|
21
|
+
const configured = ctx?.config?.sources?.cline?.path;
|
|
22
|
+
if (configured) return configured.startsWith('~') ? path.join(os.homedir(), configured.slice(1)) : configured;
|
|
23
|
+
return path.join(ctx?.home || os.homedir(), '.cline', 'data', 'sessions');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default createProvider({
|
|
27
|
+
id: 'cline',
|
|
28
|
+
name: 'Cline CLI',
|
|
29
|
+
description: 'Session-level AI activity (model, provider, duration). Cline does not log token counts.',
|
|
30
|
+
measurement: MEASUREMENT.ACTIVITY,
|
|
31
|
+
requires: ['~/.cline/data/sessions'],
|
|
32
|
+
|
|
33
|
+
async detect(ctx) {
|
|
34
|
+
const d = root(ctx);
|
|
35
|
+
try {
|
|
36
|
+
const n = fs.readdirSync(d).length;
|
|
37
|
+
return { available: n > 0, detail: `${n} session directories · no token fields reported by this source`, paths: [d] };
|
|
38
|
+
} catch {
|
|
39
|
+
return { available: false, detail: 'No ~/.cline/data/sessions directory found' };
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async discover(ctx) {
|
|
44
|
+
const d = root(ctx);
|
|
45
|
+
const out = [];
|
|
46
|
+
let dirs = [];
|
|
47
|
+
try {
|
|
48
|
+
dirs = fs.readdirSync(d, { withFileTypes: true });
|
|
49
|
+
} catch {
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
for (const e of dirs) {
|
|
53
|
+
if (!e.isDirectory()) continue;
|
|
54
|
+
const f = path.join(d, e.name, `${e.name}.json`);
|
|
55
|
+
let stat;
|
|
56
|
+
try { stat = fs.statSync(f); } catch { continue; }
|
|
57
|
+
out.push({ key: e.name, path: f, stat });
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
async ingestFile(ref, ctx, emit) {
|
|
63
|
+
let d;
|
|
64
|
+
try {
|
|
65
|
+
d = JSON.parse(fs.readFileSync(ref.path, 'utf8'));
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return { offset: ref.stat.size, records: 0, malformed: 1 };
|
|
68
|
+
}
|
|
69
|
+
const ts = d.started_at || d.ended_at;
|
|
70
|
+
if (!ts) return { offset: ref.stat.size, records: 0 };
|
|
71
|
+
|
|
72
|
+
const project = d.workspace_root || d.cwd ? path.basename(d.workspace_root || d.cwd) : null;
|
|
73
|
+
const git = d.metadata?.git || {};
|
|
74
|
+
const messages = countMessages(ref.path.replace(/\.json$/, '.messages.json'));
|
|
75
|
+
|
|
76
|
+
emit({
|
|
77
|
+
timestamp: ts,
|
|
78
|
+
model: d.model || null,
|
|
79
|
+
// `provider: "cline"` in the file names the *client*, not the model
|
|
80
|
+
// vendor — the vendor is derived from the model string.
|
|
81
|
+
client: 'cline',
|
|
82
|
+
application: 'Cline CLI',
|
|
83
|
+
interfaceSignals: [d.source, d.interactive ? 'cli' : null],
|
|
84
|
+
// Every token field stays null: not available, not zero.
|
|
85
|
+
input_tokens: null,
|
|
86
|
+
output_tokens: null,
|
|
87
|
+
cache_read_tokens: null,
|
|
88
|
+
cache_write_tokens: null,
|
|
89
|
+
cache_refresh_tokens: null,
|
|
90
|
+
reasoning_tokens: null,
|
|
91
|
+
session_id: d.session_id || ref.key,
|
|
92
|
+
project,
|
|
93
|
+
repository: git.repo || project,
|
|
94
|
+
git_branch: git.branch || null,
|
|
95
|
+
category: d.status || null,
|
|
96
|
+
duration_ms: d.started_at && d.ended_at ? new Date(d.ended_at).getTime() - new Date(d.started_at).getTime() : null,
|
|
97
|
+
measurement: MEASUREMENT.ACTIVITY,
|
|
98
|
+
metadata: {
|
|
99
|
+
cwd: d.workspace_root || d.cwd || null,
|
|
100
|
+
status: d.status ?? null,
|
|
101
|
+
exit_code: d.exit_code ?? null,
|
|
102
|
+
team: d.team_name ?? null,
|
|
103
|
+
agent_messages: messages.assistant,
|
|
104
|
+
total_messages: messages.total,
|
|
105
|
+
tokens_reported: false,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
return { offset: ref.stat.size, records: 1 };
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
function countMessages(f) {
|
|
113
|
+
try {
|
|
114
|
+
const d = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
115
|
+
const msgs = Array.isArray(d.messages) ? d.messages : [];
|
|
116
|
+
return { total: msgs.length, assistant: msgs.filter((m) => m.role === 'assistant').length };
|
|
117
|
+
} catch {
|
|
118
|
+
return { total: null, assistant: null };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor — AI code-authorship tracking database.
|
|
3
|
+
*
|
|
4
|
+
* Cursor does not expose token counts locally, but it does record what the AI
|
|
5
|
+
* actually *did*: every AI-authored code hash (with model, file, and
|
|
6
|
+
* conversation) and per-commit authorship attribution (AI vs human lines).
|
|
7
|
+
*
|
|
8
|
+
* That makes it the productivity-correlation source: it measures work output,
|
|
9
|
+
* which is the thing token counts are so often wrongly assumed to prove. These
|
|
10
|
+
* records are `measurement: activity` with all token fields null, so they can
|
|
11
|
+
* never leak into a token total.
|
|
12
|
+
*
|
|
13
|
+
* Source: ~/.cursor/ai-tracking/ai-code-tracking.db (read-only, via node:sqlite)
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import { createProvider } from '../../core/registry.js';
|
|
19
|
+
import { MEASUREMENT } from '../../core/schema.js';
|
|
20
|
+
import { openReadOnly } from '../../core/sqlite.js';
|
|
21
|
+
|
|
22
|
+
function dbPath(ctx) {
|
|
23
|
+
const c = ctx?.config?.sources?.cursor?.db;
|
|
24
|
+
if (c) return c.startsWith('~') ? path.join(os.homedir(), c.slice(1)) : c;
|
|
25
|
+
return path.join(ctx?.home || os.homedir(), '.cursor', 'ai-tracking', 'ai-code-tracking.db');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default createProvider({
|
|
29
|
+
id: 'cursor',
|
|
30
|
+
name: 'Cursor (AI code activity)',
|
|
31
|
+
description: 'AI-authored edits and per-commit AI/human line attribution. Activity only — Cursor does not log tokens locally.',
|
|
32
|
+
measurement: MEASUREMENT.ACTIVITY,
|
|
33
|
+
requires: ['~/.cursor/ai-tracking/ai-code-tracking.db', 'Node 22.5+ (node:sqlite)'],
|
|
34
|
+
|
|
35
|
+
async detect(ctx) {
|
|
36
|
+
const f = dbPath(ctx);
|
|
37
|
+
if (!fs.existsSync(f)) return { available: false, detail: 'No Cursor ai-code-tracking.db found' };
|
|
38
|
+
return { available: true, detail: 'activity source — AI-authored edits + commit attribution', paths: [f] };
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async fetchUsage(ctx, emit, sourceState) {
|
|
42
|
+
const f = dbPath(ctx);
|
|
43
|
+
const db = openReadOnly(f);
|
|
44
|
+
let records = 0;
|
|
45
|
+
const notes = [];
|
|
46
|
+
const cursor = sourceState?.cursor || { edits: 0, commits: 0 };
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const edits = db.prepare(
|
|
50
|
+
`SELECT hash, source, fileExtension, fileName, requestId, conversationId, timestamp, createdAt, model
|
|
51
|
+
FROM ai_code_hashes WHERE createdAt > ? ORDER BY createdAt ASC LIMIT 200000`,
|
|
52
|
+
).all(cursor.edits || 0);
|
|
53
|
+
|
|
54
|
+
for (const r of edits) {
|
|
55
|
+
const ts = r.timestamp || r.createdAt;
|
|
56
|
+
if (!ts) continue;
|
|
57
|
+
const project = projectOf(r.fileName);
|
|
58
|
+
emit({
|
|
59
|
+
id: `cursor-edit-${r.hash}-${r.createdAt}`,
|
|
60
|
+
timestamp: new Date(Number(ts)).toISOString(),
|
|
61
|
+
// `default` / null are Cursor's own placeholders; do not guess a
|
|
62
|
+
// model. The PROVIDER however is certain — this row came from
|
|
63
|
+
// Cursor's own tracking DB — so providerHint attributes unmatched
|
|
64
|
+
// models to cursor instead of an anonymous "unknown" bucket.
|
|
65
|
+
model: r.model && r.model !== 'default' ? r.model : null,
|
|
66
|
+
providerHint: 'cursor',
|
|
67
|
+
client: 'cursor',
|
|
68
|
+
application: 'Cursor',
|
|
69
|
+
interfaceSignals: ['cursor'],
|
|
70
|
+
input_tokens: null, output_tokens: null, cache_read_tokens: null,
|
|
71
|
+
cache_write_tokens: null, cache_refresh_tokens: null, reasoning_tokens: null,
|
|
72
|
+
conversation_id: r.conversationId || null,
|
|
73
|
+
session_id: r.conversationId || null,
|
|
74
|
+
request_id: r.requestId || null,
|
|
75
|
+
project,
|
|
76
|
+
repository: project,
|
|
77
|
+
category: `ai-edit:${r.source || 'unknown'}`,
|
|
78
|
+
measurement: MEASUREMENT.ACTIVITY,
|
|
79
|
+
metadata: {
|
|
80
|
+
file: r.fileName || null,
|
|
81
|
+
ext: r.fileExtension || null,
|
|
82
|
+
attribution_source: r.source || null,
|
|
83
|
+
tokens_reported: false,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
records++;
|
|
87
|
+
cursor.edits = Math.max(cursor.edits || 0, Number(r.createdAt));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const commits = db.prepare(
|
|
91
|
+
`SELECT commitHash, branchName, scoredAt, commitDate, commitMessage, linesAdded, linesDeleted,
|
|
92
|
+
composerLinesAdded, tabLinesAdded, humanLinesAdded, v2AiPercentage
|
|
93
|
+
FROM scored_commits WHERE scoredAt > ? ORDER BY scoredAt ASC LIMIT 100000`,
|
|
94
|
+
).all(cursor.commits || 0);
|
|
95
|
+
|
|
96
|
+
for (const r of commits) {
|
|
97
|
+
const ts = r.commitDate ? Date.parse(r.commitDate) : Number(r.scoredAt);
|
|
98
|
+
if (!ts || Number.isNaN(ts)) continue;
|
|
99
|
+
emit({
|
|
100
|
+
id: `cursor-commit-${r.commitHash}-${r.branchName}`,
|
|
101
|
+
timestamp: new Date(ts).toISOString(),
|
|
102
|
+
model: null,
|
|
103
|
+
client: 'cursor',
|
|
104
|
+
application: 'Cursor',
|
|
105
|
+
interfaceSignals: ['cursor'],
|
|
106
|
+
input_tokens: null, output_tokens: null, cache_read_tokens: null,
|
|
107
|
+
cache_write_tokens: null, cache_refresh_tokens: null, reasoning_tokens: null,
|
|
108
|
+
git_branch: r.branchName || null,
|
|
109
|
+
category: 'commit',
|
|
110
|
+
measurement: MEASUREMENT.ACTIVITY,
|
|
111
|
+
metadata: {
|
|
112
|
+
commit: r.commitHash,
|
|
113
|
+
message: r.commitMessage || null,
|
|
114
|
+
lines_added: nOrNull(r.linesAdded),
|
|
115
|
+
lines_deleted: nOrNull(r.linesDeleted),
|
|
116
|
+
ai_lines_added: nOrNull(r.composerLinesAdded),
|
|
117
|
+
tab_lines_added: nOrNull(r.tabLinesAdded),
|
|
118
|
+
human_lines_added: nOrNull(r.humanLinesAdded),
|
|
119
|
+
ai_percentage: r.v2AiPercentage === null || r.v2AiPercentage === undefined ? null : Number(r.v2AiPercentage),
|
|
120
|
+
tokens_reported: false,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
records++;
|
|
124
|
+
cursor.commits = Math.max(cursor.commits || 0, Number(r.scoredAt));
|
|
125
|
+
}
|
|
126
|
+
if (edits.length === 200000) notes.push('edit batch hit the 200k row cap — run refresh again to continue');
|
|
127
|
+
} finally {
|
|
128
|
+
db.close();
|
|
129
|
+
}
|
|
130
|
+
return { records, cursor, notes };
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
function projectOf(file) {
|
|
135
|
+
if (!file) return null;
|
|
136
|
+
const parts = String(file).split('/').filter(Boolean);
|
|
137
|
+
const i = parts.findIndex((p) => p === 'projects' || p === 'repos' || p === 'src' || p === 'work');
|
|
138
|
+
if (i >= 0 && parts[i + 1]) return parts[i + 1];
|
|
139
|
+
return parts.length > 1 ? parts[parts.length - 2] : null;
|
|
140
|
+
}
|
|
141
|
+
function nOrNull(v) {
|
|
142
|
+
return v === null || v === undefined ? null : Number(v);
|
|
143
|
+
}
|