@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.
Files changed (101) hide show
  1. package/CONTRIBUTING.md +84 -0
  2. package/LICENSE +21 -0
  3. package/README.md +250 -0
  4. package/Refresh & Open Dashboard.command +22 -0
  5. package/SECURITY.md +42 -0
  6. package/bin/tokenflow.js +1342 -0
  7. package/docs/architecture.md +193 -0
  8. package/docs/cli.md +390 -0
  9. package/docs/configuration.md +281 -0
  10. package/docs/creating-provider.md +262 -0
  11. package/docs/data-model.md +213 -0
  12. package/docs/getting-started.md +266 -0
  13. package/docs/live-mode.md +199 -0
  14. package/docs/media/architecture-hero.svg +86 -0
  15. package/docs/media/cost-editorial-dark.png +0 -0
  16. package/docs/media/health-terminal-light.png +0 -0
  17. package/docs/media/menubar-dark.png +0 -0
  18. package/docs/media/menubar-light.png +0 -0
  19. package/docs/media/models-terminal-dark.png +0 -0
  20. package/docs/media/overview-aurora-dark.png +0 -0
  21. package/docs/media/time-aurora-light.png +0 -0
  22. package/docs/providers.md +309 -0
  23. package/docs/skill.md +64 -0
  24. package/docs/troubleshooting.md +207 -0
  25. package/examples/config.example.yaml +92 -0
  26. package/examples/demo-data/README.md +38 -0
  27. package/examples/demo-data/sample-usage.csv +11 -0
  28. package/package.json +74 -0
  29. package/scripts/build-dmg.sh +33 -0
  30. package/scripts/build-menubar-app.sh +67 -0
  31. package/scripts/lint.js +111 -0
  32. package/scripts/validate-install.js +140 -0
  33. package/skills/tokenflow/SKILL.md +392 -0
  34. package/skills/tokenflow/examples/config.yaml +92 -0
  35. package/skills/tokenflow/examples/generic-mapping.json +26 -0
  36. package/skills/tokenflow/examples/session-transcript.md +191 -0
  37. package/skills/tokenflow/providers/adapter-template.js +135 -0
  38. package/skills/tokenflow/providers/detection-matrix.md +142 -0
  39. package/skills/tokenflow/schemas/config.schema.json +107 -0
  40. package/skills/tokenflow/schemas/normalized-record.json +63 -0
  41. package/src/analytics/aggregate.js +247 -0
  42. package/src/analytics/anomalies.js +222 -0
  43. package/src/analytics/capacity.js +278 -0
  44. package/src/analytics/comparison.js +96 -0
  45. package/src/analytics/dimensions.js +230 -0
  46. package/src/analytics/efficiency.js +138 -0
  47. package/src/analytics/forecast.js +202 -0
  48. package/src/analytics/index.js +327 -0
  49. package/src/analytics/insights.js +283 -0
  50. package/src/analytics/milestones.js +91 -0
  51. package/src/analytics/peak.js +106 -0
  52. package/src/analytics/productivity.js +166 -0
  53. package/src/analytics/token-usage.js +267 -0
  54. package/src/commands/diagnostics.js +88 -0
  55. package/src/commands/digest.js +155 -0
  56. package/src/commands/models-compare.js +96 -0
  57. package/src/core/budget.js +142 -0
  58. package/src/core/bundle.js +191 -0
  59. package/src/core/config.js +202 -0
  60. package/src/core/delivery.js +109 -0
  61. package/src/core/geo.js +99 -0
  62. package/src/core/ingest.js +457 -0
  63. package/src/core/interface-map.js +55 -0
  64. package/src/core/jsonl.js +124 -0
  65. package/src/core/live-status.js +417 -0
  66. package/src/core/model-map.js +157 -0
  67. package/src/core/notify.js +83 -0
  68. package/src/core/pricing.js +288 -0
  69. package/src/core/prompt-analytics.js +127 -0
  70. package/src/core/registry.js +107 -0
  71. package/src/core/restore.js +261 -0
  72. package/src/core/schedule.js +120 -0
  73. package/src/core/schema.js +316 -0
  74. package/src/core/sqlite.js +96 -0
  75. package/src/core/store.js +493 -0
  76. package/src/core/sync.js +151 -0
  77. package/src/core/units.js +147 -0
  78. package/src/core/validate.js +123 -0
  79. package/src/core/watch.js +287 -0
  80. package/src/core/yaml.js +209 -0
  81. package/src/export/bundler.js +107 -0
  82. package/src/export/csv.js +100 -0
  83. package/src/export/html-snapshot.js +101 -0
  84. package/src/export/menubar.js +158 -0
  85. package/src/index.js +18 -0
  86. package/src/providers/anthropic/index.js +294 -0
  87. package/src/providers/cline/index.js +120 -0
  88. package/src/providers/cursor/index.js +143 -0
  89. package/src/providers/generic/index.js +268 -0
  90. package/src/providers/git/index.js +188 -0
  91. package/src/providers/headroom/index.js +114 -0
  92. package/src/providers/hermes/index.js +299 -0
  93. package/src/providers/mock/index.js +117 -0
  94. package/src/providers/openai/index.js +370 -0
  95. package/src/providers/opencode/index.js +245 -0
  96. package/src/sdk.js +46 -0
  97. package/src/server/server.js +264 -0
  98. package/src/ui/app.js +2473 -0
  99. package/src/ui/charts.js +925 -0
  100. package/src/ui/index.html +42 -0
  101. 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
+ }