@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
@@ -0,0 +1,370 @@
1
+ /**
2
+ * OpenAI — Codex CLI / Codex IDE / Codex Desktop rollout transcripts.
3
+ *
4
+ * Source: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<id>.jsonl
5
+ * (+ ~/.codex/archived_sessions)
6
+ *
7
+ * ## Token semantics — the important difference from Anthropic
8
+ *
9
+ * Codex reports OpenAI's convention, where `input_tokens` is INCLUSIVE of
10
+ * `cached_input_tokens`. Copying it straight into the schema would count every
11
+ * cached prompt token twice: once as fresh input and once as cache read. So:
12
+ *
13
+ * input_tokens = input_tokens - cached_input_tokens (fresh only)
14
+ * cache_read_tokens = cached_input_tokens
15
+ * cache_write_tokens = cache_write_input_tokens, or **null** on older
16
+ * sessions that never emitted the field — not 0, because
17
+ * "the CLI didn't report it" and "no cache was written"
18
+ * are different facts
19
+ * reasoning_tokens = reasoning_output_tokens (a subset of output_tokens)
20
+ *
21
+ * ## One record per turn, and why the obvious reading is wrong
22
+ *
23
+ * Codex emits a `token_count` event repeatedly while a turn runs, and
24
+ * `last_token_usage` is **re-reported as the turn's context grows** — 27k, then
25
+ * 29k, then 30k, ... all describing the same growing conversation. Its
26
+ * `total_token_usage` is simply a running SUM of those re-reports, so it is not
27
+ * a usable cumulative counter: on this corpus it over-reports a heavy agent day
28
+ * by ~45x (82.8B claimed vs 1.8B real), and one 2.5-minute subagent session
29
+ * "spends" 7.5B tokens.
30
+ *
31
+ * The reliable structure is the same streaming-snapshot pattern Anthropic's
32
+ * transcripts have. Within a turn, the `last_token_usage` series is split into
33
+ * monotonically non-decreasing RUNS. A run is one growing context; a DROP means
34
+ * a new one (a context compaction, or a fresh call). The turn's usage is the
35
+ * sum of each run's maximum.
36
+ *
37
+ * That reduces to the identity for simple sessions (one event per turn -> the
38
+ * event itself) and was cross-checked against an independent gateway billing
39
+ * log, which agreed to within the same order of magnitude where the naive
40
+ * reading was off by 45x.
41
+ *
42
+ * `metadata.token_count_events` and `metadata.usage_segments` are recorded on
43
+ * every turn so this reconstruction is auditable rather than a hidden fudge.
44
+ *
45
+ * A `model_provider` that is not a known model vendor is recorded as a
46
+ * **gateway** (a proxy/router) rather than as the vendor, so "who served this"
47
+ * and "who made this model" stay separate dimensions.
48
+ */
49
+ import fs from 'node:fs';
50
+ import path from 'node:path';
51
+ import os from 'node:os';
52
+ import { createProvider } from '../../core/registry.js';
53
+ import { readLines } from '../../core/jsonl.js';
54
+ import { walk } from '../../core/ingest.js';
55
+ import { MEASUREMENT } from '../../core/schema.js';
56
+
57
+ const MARKS = ['"token_count"', '"session_meta"', '"thread_settings_applied"', '"turn_context"', '"task_started"', '"task_complete"'];
58
+ const VENDORS = new Set(['openai', 'anthropic', 'google', 'gemini', 'deepseek', 'zai', 'z.ai', 'xai', 'mistral', 'azure', 'bedrock', 'vertex', 'ollama']);
59
+
60
+ const USAGE_FIELDS = ['input_tokens', 'cached_input_tokens', 'cache_write_input_tokens', 'output_tokens', 'reasoning_output_tokens'];
61
+
62
+ export function codexHomes(ctx) {
63
+ const configured = ctx?.config?.sources?.openai?.paths;
64
+ if (Array.isArray(configured) && configured.length) return configured.map(expand);
65
+ const home = ctx?.home || os.homedir();
66
+ const out = [];
67
+ if (process.env.CODEX_HOME) out.push(process.env.CODEX_HOME);
68
+ out.push(path.join(home, '.codex'));
69
+ return [...new Set(out)].filter((d) => {
70
+ try {
71
+ return fs.statSync(path.join(d, 'sessions')).isDirectory();
72
+ } catch {
73
+ return false;
74
+ }
75
+ });
76
+ }
77
+
78
+ function expand(p) {
79
+ return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
80
+ }
81
+
82
+ /**
83
+ * Accumulate a turn's `last_token_usage` series into disjoint totals.
84
+ *
85
+ * Fields are tracked independently but segmented by `total_tokens`, because
86
+ * that is the value whose monotonicity tells us whether we are still looking at
87
+ * the same growing context.
88
+ *
89
+ * A field that never appears in ANY event stays `null` — "the CLI didn't report
90
+ * cache writes" is a different fact from "no cache was written".
91
+ */
92
+ export class TurnUsage {
93
+ constructor() {
94
+ this.acc = Object.fromEntries(USAGE_FIELDS.map((f) => [f, 0]));
95
+ this.seen = Object.fromEntries(USAGE_FIELDS.map((f) => [f, false]));
96
+ this.run = null;
97
+ this.prevTotal = -1;
98
+ this.events = 0;
99
+ this.segments = 0;
100
+ }
101
+
102
+ add(last) {
103
+ if (!last) return;
104
+ const total = num(last.total_tokens);
105
+ this.events++;
106
+ if (this.run === null || total >= this.prevTotal) {
107
+ if (this.run === null) {
108
+ this.run = Object.fromEntries(USAGE_FIELDS.map((f) => [f, 0]));
109
+ this.segments++;
110
+ }
111
+ for (const f of USAGE_FIELDS) {
112
+ const v = last[f];
113
+ if (v === undefined || v === null) continue;
114
+ this.seen[f] = true;
115
+ this.run[f] = Math.max(this.run[f], Number(v));
116
+ }
117
+ } else {
118
+ // The context shrank: the previous run is finished.
119
+ this.closeRun();
120
+ this.segments++;
121
+ this.run = Object.fromEntries(USAGE_FIELDS.map((f) => [f, 0]));
122
+ for (const f of USAGE_FIELDS) {
123
+ const v = last[f];
124
+ if (v === undefined || v === null) continue;
125
+ this.seen[f] = true;
126
+ this.run[f] = Number(v);
127
+ }
128
+ }
129
+ this.prevTotal = total;
130
+ }
131
+
132
+ closeRun() {
133
+ if (!this.run) return;
134
+ for (const f of USAGE_FIELDS) this.acc[f] += this.run[f];
135
+ this.run = null;
136
+ }
137
+
138
+ /** @returns {Record<string, number|null>} */
139
+ finish() {
140
+ this.closeRun();
141
+ /** @type {Record<string, number|null>} */
142
+ const out = {};
143
+ for (const f of USAGE_FIELDS) out[f] = this.seen[f] ? this.acc[f] : null;
144
+ return out;
145
+ }
146
+
147
+ toJSON() {
148
+ return { acc: this.acc, seen: this.seen, run: this.run, prevTotal: this.prevTotal, events: this.events, segments: this.segments };
149
+ }
150
+
151
+ static from(o) {
152
+ const t = new TurnUsage();
153
+ if (!o) return t;
154
+ Object.assign(t, { acc: o.acc || t.acc, seen: o.seen || t.seen, run: o.run ?? null, prevTotal: o.prevTotal ?? -1, events: o.events || 0, segments: o.segments || 0 });
155
+ return t;
156
+ }
157
+ }
158
+
159
+ export default createProvider({
160
+ id: 'openai',
161
+ name: 'OpenAI (Codex CLI / IDE / Desktop)',
162
+ description: 'Per-turn token usage from Codex rollout transcripts, including gateway-routed models.',
163
+ measurement: MEASUREMENT.PRIMARY,
164
+ requires: ['~/.codex/sessions (Codex CLI 0.1x+)'],
165
+
166
+ async detect(ctx) {
167
+ const homes = codexHomes(ctx);
168
+ if (!homes.length) return { available: false, detail: 'No ~/.codex/sessions directory found' };
169
+ return { available: true, detail: homes.join(', '), paths: homes };
170
+ },
171
+
172
+ async discover(ctx) {
173
+ const files = [];
174
+ for (const home of codexHomes(ctx)) {
175
+ for (const sub of ['sessions', 'archived_sessions']) {
176
+ const root = path.join(home, sub);
177
+ if (!fs.existsSync(root)) continue;
178
+ for (const f of walk(root, (n) => n.endsWith('.jsonl'))) {
179
+ let stat;
180
+ try { stat = fs.statSync(f); } catch { continue; }
181
+ if (!stat.size) continue;
182
+ files.push({ key: `${sub}:${path.relative(root, f)}`, path: f, stat });
183
+ }
184
+ }
185
+ }
186
+ return files;
187
+ },
188
+
189
+ async ingestFile(ref, ctx, emit) {
190
+ const s = ref.state;
191
+ let records = 0;
192
+ let malformed = 0;
193
+
194
+ /** The turn currently being accumulated. */
195
+ let turn = s.openTurn
196
+ ? { id: s.openTurn.id, firstTs: s.openTurn.firstTs, lastTs: s.openTurn.lastTs, usage: TurnUsage.from(s.openTurn.usage), continued: true }
197
+ : null;
198
+
199
+ const newTurn = (id, ts) => ({ id: id || null, firstTs: ts, lastTs: ts, usage: new TurnUsage(), continued: false });
200
+
201
+ const flushTurn = () => {
202
+ if (!turn || !turn.usage.events) { turn = null; return; }
203
+ const u = turn.usage.finish();
204
+ const routeProvider = (s.routeProvider || '').toLowerCase();
205
+ const isVendor = VENDORS.has(routeProvider);
206
+ const project = s.cwd ? path.basename(s.cwd) : null;
207
+ const inTotal = u.input_tokens;
208
+ const cached = u.cached_input_tokens;
209
+ // OpenAI's input_tokens INCLUDES cached_input_tokens; the schema wants
210
+ // them mutually exclusive.
211
+ const fresh = inTotal === null ? null : cached === null ? inTotal : Math.max(0, inTotal - cached);
212
+ if (!fresh && !cached && !u.output_tokens && !u.cache_write_input_tokens) { turn = null; return; }
213
+ emit({
214
+ timestamp: turn.lastTs,
215
+ model: s.model || null,
216
+ providerHint: isVendor ? routeProvider : null,
217
+ gateway: routeProvider && !isVendor ? routeProvider : null,
218
+ input_tokens: fresh,
219
+ cache_read_tokens: cached,
220
+ cache_write_tokens: u.cache_write_input_tokens,
221
+ output_tokens: u.output_tokens,
222
+ reasoning_tokens: u.reasoning_output_tokens,
223
+ cache_refresh_tokens: null,
224
+ session_id: s.sessionId || ref.key,
225
+ conversation_id: s.threadId || null,
226
+ request_id: turn.id,
227
+ project,
228
+ repository: project,
229
+ category: s.threadSource === 'subagent' ? 'subagent' : 'main',
230
+ client: 'codex',
231
+ application: 'Codex',
232
+ interfaceSignals: [s.source, s.originator, s.threadSource],
233
+ duration_ms: s.lastDuration ?? null,
234
+ metadata: {
235
+ cwd: s.cwd || null,
236
+ cli_version: s.cliVersion || null,
237
+ originator: s.originator || null,
238
+ source: s.source || null,
239
+ agent_role: s.agentRole || null,
240
+ reasoning_effort: s.effort || null,
241
+ service_tier: s.tier || null,
242
+ context_window: s.contextWindow ?? null,
243
+ time_to_first_token_ms: s.lastTtft ?? null,
244
+ plan_type: s.planType ?? null,
245
+ token_count_events: turn.usage.events,
246
+ usage_segments: turn.usage.segments,
247
+ turn_continued: turn.continued || undefined,
248
+ },
249
+ });
250
+ records++;
251
+ s.lastDuration = null;
252
+ s.lastTtft = null;
253
+ turn = null;
254
+ };
255
+
256
+ const res = readLines(
257
+ ref.path,
258
+ (line) => {
259
+ let d;
260
+ try {
261
+ d = JSON.parse(line);
262
+ } catch {
263
+ malformed++;
264
+ return;
265
+ }
266
+ const p = d.payload || {};
267
+
268
+ if (d.type === 'session_meta') {
269
+ s.sessionId = p.session_id || p.id || null;
270
+ s.threadId = p.id || null;
271
+ s.cwd = p.cwd || s.cwd || null;
272
+ s.originator = p.originator || s.originator || null;
273
+ s.source = flattenSource(p.source) || s.source || null;
274
+ s.agentRole = agentRole(p.source) || s.agentRole || null;
275
+ s.threadSource = p.thread_source || s.threadSource || null;
276
+ s.cliVersion = p.cli_version || s.cliVersion || null;
277
+ s.routeProvider = p.model_provider || s.routeProvider || null;
278
+ if (p.model) s.model = p.model;
279
+ return;
280
+ }
281
+ if (d.type === 'turn_context') {
282
+ if (p.cwd) s.cwd = p.cwd;
283
+ if (p.model) s.model = p.model;
284
+ if (p.turn_id && (!turn || turn.id !== p.turn_id)) {
285
+ flushTurn();
286
+ turn = newTurn(p.turn_id, d.timestamp);
287
+ }
288
+ return;
289
+ }
290
+ if (d.type === 'event_msg' && p.type === 'thread_settings_applied') {
291
+ const t = p.thread_settings || {};
292
+ if (t.model) s.model = t.model;
293
+ if (t.model_provider_id) s.routeProvider = t.model_provider_id;
294
+ if (t.cwd) s.cwd = t.cwd;
295
+ if (t.reasoning_effort) s.effort = t.reasoning_effort;
296
+ if (t.service_tier) s.tier = t.service_tier;
297
+ return;
298
+ }
299
+ if (d.type === 'event_msg' && p.type === 'task_started') {
300
+ if (!turn || turn.id !== p.turn_id) {
301
+ flushTurn();
302
+ turn = newTurn(p.turn_id, d.timestamp);
303
+ }
304
+ if (p.model_context_window) s.contextWindow = p.model_context_window;
305
+ return;
306
+ }
307
+ if (d.type === 'event_msg' && p.type === 'task_complete') {
308
+ s.lastDuration = p.duration_ms ?? null;
309
+ s.lastTtft = p.time_to_first_token_ms ?? null;
310
+ if (turn && (turn.id === null || !p.turn_id || turn.id === p.turn_id)) flushTurn();
311
+ return;
312
+ }
313
+ if (!(d.type === 'event_msg' && p.type === 'token_count')) return;
314
+
315
+ const info = p.info || {};
316
+ const last = info.last_token_usage || info.total_token_usage;
317
+ if (!last) return;
318
+ if (info.model_context_window) s.contextWindow = info.model_context_window;
319
+ if (p.rate_limits?.plan_type) s.planType = p.rate_limits.plan_type;
320
+
321
+ // Sessions from older builds have no turn markers; treat each event as
322
+ // its own turn rather than dropping the usage.
323
+ if (!turn) turn = newTurn(null, d.timestamp);
324
+ turn.usage.add(last);
325
+ turn.lastTs = d.timestamp;
326
+ if (turn.id === null) flushTurn();
327
+ },
328
+ { start: ref.start, must: MARKS },
329
+ );
330
+
331
+ // A turn still open at EOF keeps accumulating on the next refresh, so no
332
+ // partial record is written for it and none is written twice.
333
+ s.openTurn = turn && turn.usage.events
334
+ ? { id: turn.id, firstTs: turn.firstTs, lastTs: turn.lastTs, usage: turn.usage.toJSON() }
335
+ : null;
336
+ if (turn && !turn.id) flushTurn();
337
+
338
+ return { offset: res.offset, records, malformed, openTurn: s.openTurn ? 1 : 0 };
339
+ },
340
+ });
341
+
342
+ function flattenSource(src) {
343
+ if (!src) return null;
344
+ if (typeof src === 'string') return src;
345
+ if (typeof src === 'object') {
346
+ const k = Object.keys(src)[0];
347
+ if (!k) return null;
348
+ const v = src[k];
349
+ if (typeof v === 'string') return `${k}:${v}`;
350
+ if (v && typeof v === 'object') {
351
+ const inner = Object.values(v)[0];
352
+ return typeof inner === 'string' ? `${k}:${inner}` : k;
353
+ }
354
+ return k;
355
+ }
356
+ return null;
357
+ }
358
+
359
+ /** "luna_worker" from a subagent thread_spawn descriptor, when present. */
360
+ function agentRole(src) {
361
+ try {
362
+ return src?.subagent?.thread_spawn?.agent_role ?? src?.subagent?.other ?? null;
363
+ } catch {
364
+ return null;
365
+ }
366
+ }
367
+
368
+ function num(v) {
369
+ return v === undefined || v === null ? 0 : Number(v);
370
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * OpenCode — opencode session/message database.
3
+ *
4
+ * Source: <data-home>/opencode/opencode.db (SQLite; $XDG_DATA_HOME-aware,
5
+ * so ~/.local/share/opencode/opencode.db by default). Read-only, via
6
+ * node:sqlite, read from a snapshot like every SQLite adapter here.
7
+ *
8
+ * ## What this source is
9
+ *
10
+ * One row per message in `message`, with a JSON `data` column. Assistant rows
11
+ * carry the usage block the provider returned:
12
+ *
13
+ * data.tokens = { input, output, reasoning, cache: { read, write }, total }
14
+ * data.modelID / data.providerID / data.cost / data.time.{created,completed}
15
+ *
16
+ * The `session` table adds directory, agent and parent (subagent) links; the
17
+ * `project` table adds the worktree.
18
+ *
19
+ * ## Token semantics (verified against a live corpus)
20
+ *
21
+ * input fresh prompt tokens, EXCLUSIVE of cache reads. Confirmed
22
+ * empirically: cache_read regularly exceeds input on long
23
+ * sessions, which is impossible under an inclusive convention,
24
+ * and data.tokens.total == input+output+reasoning+read+write
25
+ * exactly, i.e. the fields are disjoint addends.
26
+ * output generated tokens. `reasoning` is USUALLY a subset of output
27
+ * (OpenAI/Anthropic convention) but some providers behind the
28
+ * gateway report it additively — 5% of a real corpus had
29
+ * reasoning > output. Where that happens the only reading that
30
+ * satisfies the schema's subset invariant without losing tokens
31
+ * is output += reasoning, with reasoning kept as the subset.
32
+ * cache.read -> cache_read_tokens
33
+ * cache.write -> cache_write_tokens
34
+ *
35
+ * ## The incremental trap: rows are UPDATED in place
36
+ *
37
+ * An assistant row is inserted when the request starts and its tokens are
38
+ * revised as the response finalises — on a real corpus every single
39
+ * token-bearing row had time_updated != time_created. A naive
40
+ * "time_created > cursor" cursor would freeze the first, partial snapshot
41
+ * forever. So this adapter uses the same tails mechanism as the Anthropic
42
+ * adapter: re-read a recent window ordered by time_updated, remember per
43
+ * message what has already been emitted, and emit only the DELTA when a
44
+ * message's totals grew. Each emission carries the message's own
45
+ * time.created, so usage stays attributed to the request that produced it.
46
+ */
47
+ import fs from 'node:fs';
48
+ import path from 'node:path';
49
+ import os from 'node:os';
50
+ import { createProvider } from '../../core/registry.js';
51
+ import { MEASUREMENT, hashId } from '../../core/schema.js';
52
+ import { openReadOnly, sqliteAvailable } from '../../core/sqlite.js';
53
+
54
+ const ROW_CAP = 500000;
55
+ /** How far behind the watermark to re-read, to catch in-place updates. */
56
+ const OVERLAP_MS = 7 * 24 * 60 * 60 * 1000;
57
+ /** Emitted-tail entries older than this, relative to the watermark, are pruned. */
58
+ const TAIL_TTL_MS = 30 * 24 * 60 * 60 * 1000;
59
+
60
+ const FIELDS = ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'reasoning_tokens'];
61
+
62
+ /** providerID values that ARE the model vendor; anything else routes through a gateway. */
63
+ const VENDORS = new Set([
64
+ 'anthropic', 'openai', 'google', 'gemini', 'deepseek', 'xai', 'groq',
65
+ 'mistral', 'azure', 'bedrock', 'vertex', 'ollama', 'amazon', 'moonshot',
66
+ 'alibaba', 'qwen', 'zai', 'github-copilot',
67
+ ]);
68
+
69
+ export function dbPath(ctx) {
70
+ const c = ctx?.config?.sources?.opencode;
71
+ const p = c?.db || (Array.isArray(c?.paths) ? c.paths[0] : null);
72
+ if (p) return expand(p);
73
+ const dataHome = process.env.XDG_DATA_HOME || path.join(ctx?.home || os.homedir(), '.local', 'share');
74
+ return path.join(dataHome, 'opencode', 'opencode.db');
75
+ }
76
+
77
+ function expand(p) {
78
+ return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
79
+ }
80
+
81
+ export default createProvider({
82
+ id: 'opencode',
83
+ name: 'OpenCode',
84
+ description: 'Per-request token usage from the opencode message database.',
85
+ measurement: MEASUREMENT.PRIMARY,
86
+ requires: ['~/.local/share/opencode/opencode.db', 'Node 22.5+ (node:sqlite)'],
87
+
88
+ async detect(ctx) {
89
+ if (!sqliteAvailable()) return { available: false, detail: 'node:sqlite unavailable — needs Node 22.5+' };
90
+ const f = dbPath(ctx);
91
+ if (!fs.existsSync(f)) {
92
+ return { available: false, detail: 'No opencode.db found (looked in $XDG_DATA_HOME/opencode)' };
93
+ }
94
+ return { available: true, detail: shortPath(f, ctx), paths: [f] };
95
+ },
96
+
97
+ async fetchUsage(ctx, emit, sourceState) {
98
+ const f = dbPath(ctx);
99
+ const db = openReadOnly(f);
100
+ let records = 0;
101
+ let skipped = 0;
102
+ let updated = 0;
103
+ const notes = [];
104
+
105
+ try {
106
+ const cursor = sourceState.cursor || { updated: 0 };
107
+ // Tails: per-message totals already emitted. Keyed by message id, they
108
+ // make re-reading the overlap window idempotent (delta 0 => no record).
109
+ const tails = sourceState.tails || (sourceState.tails = {});
110
+
111
+ const rows = db.prepare(
112
+ `SELECT m.id AS msg_id, m.session_id AS session_id, m.time_created AS time_created,
113
+ m.time_updated AS time_updated, m.data AS data,
114
+ s.directory AS directory, s.parent_id AS parent_id, s.agent AS agent,
115
+ s.version AS cli_version, p.worktree AS worktree
116
+ FROM message m
117
+ LEFT JOIN session s ON s.id = m.session_id
118
+ LEFT JOIN project p ON p.id = s.project_id
119
+ WHERE json_extract(m.data,'$.role') = 'assistant'
120
+ AND json_extract(m.data,'$.tokens') IS NOT NULL
121
+ AND m.time_updated > ?
122
+ ORDER BY m.time_updated ASC
123
+ LIMIT ?`,
124
+ ).all(Math.max(0, (cursor.updated || 0) - OVERLAP_MS), ROW_CAP);
125
+ if (rows.length === ROW_CAP) notes.push('row cap hit — run refresh again to continue');
126
+
127
+ let watermark = cursor.updated || 0;
128
+
129
+ for (const r of rows) {
130
+ watermark = Math.max(watermark, Number(r.time_updated) || 0);
131
+ let d;
132
+ try { d = JSON.parse(r.data); } catch { continue; }
133
+ const t = d.tokens || {};
134
+ const created = Number(r.time_created);
135
+ if (!created) continue;
136
+
137
+ const base = {
138
+ input_tokens: n(t.input),
139
+ output_tokens: n(t.output),
140
+ cache_read_tokens: n(t.cache?.read),
141
+ cache_write_tokens: n(t.cache?.write),
142
+ reasoning_tokens: n(t.reasoning),
143
+ };
144
+ // Some providers report reasoning additively rather than as a subset
145
+ // of output. Folding keeps the subset invariant without losing tokens.
146
+ if (base.reasoning_tokens !== null && base.output_tokens !== null && base.reasoning_tokens > base.output_tokens) {
147
+ base.output_tokens += base.reasoning_tokens;
148
+ }
149
+
150
+ // An assistant row with no counts at all is an aborted request, not a
151
+ // zero-cost API call. Skip it; if tokens arrive later the row's
152
+ // time_updated advances and the next pass picks it up fresh.
153
+ if (FIELDS.every((k) => !base[k])) { skipped++; continue; }
154
+
155
+ const key = String(r.msg_id || `${r.session_id}:${created}`);
156
+ const prev = tails[key];
157
+ const delta = {};
158
+ let any = false;
159
+ for (const k of FIELDS) {
160
+ const was = prev ? (prev.f[k] ?? 0) : 0;
161
+ const now = base[k] ?? 0;
162
+ delta[k] = Math.max(0, now - was);
163
+ if (delta[k] > 0) any = true;
164
+ }
165
+ // Remember what this message accounts for, whether or not it grew —
166
+ // an unchanged re-read must stay a no-op forever. The emission index
167
+ // keeps each delta's record id distinct and stable across runs.
168
+ const emissionIndex = prev ? (prev.n || 0) : 0;
169
+ tails[key] = { u: Number(r.time_updated) || created, f: { ...base }, n: emissionIndex + 1 };
170
+ if (prev && !any) continue;
171
+ updated++;
172
+
173
+ const providerID = str(d.providerID)?.toLowerCase() || null;
174
+ const isVendor = providerID && VENDORS.has(providerID);
175
+ const project = projectOf(r.worktree || r.directory);
176
+ const completed = n(d.time?.completed);
177
+
178
+ emit({
179
+ id: hashId('opencode', key, '#', emissionIndex),
180
+ timestamp: new Date(created).toISOString(),
181
+ model: str(d.modelID),
182
+ gateway: providerID && !isVendor ? providerID : null,
183
+ ...pick(delta),
184
+ session_id: r.session_id || null,
185
+ conversation_id: r.session_id || null,
186
+ request_id: r.msg_id || null,
187
+ project,
188
+ repository: project,
189
+ category: r.parent_id ? 'subagent' : (str(r.agent) || 'main'),
190
+ client: 'opencode',
191
+ application: 'OpenCode',
192
+ interfaceSignals: [],
193
+ duration_ms: completed !== null && completed >= created ? completed - created : null,
194
+ metadata: {
195
+ cwd: str(r.directory),
196
+ provider_id: providerID,
197
+ mode: str(d.mode),
198
+ agent: str(r.agent),
199
+ finish: str(d.finish),
200
+ cli_version: str(r.cli_version),
201
+ reported_total: n(t.total),
202
+ ...(prev ? { continuation_of: key } : {}),
203
+ },
204
+ });
205
+ records++;
206
+ }
207
+
208
+ // Bound the tail state: anything this far behind the watermark cannot
209
+ // reappear inside the overlap window, so its emitted totals are final.
210
+ const floor = watermark - TAIL_TTL_MS;
211
+ for (const k of Object.keys(tails)) if ((tails[k].u || 0) < floor) delete tails[k];
212
+
213
+ cursor.updated = watermark;
214
+ sourceState.cursor = cursor;
215
+ } finally {
216
+ db.close();
217
+ }
218
+ return { records, cursor: sourceState.cursor, notes };
219
+ },
220
+ });
221
+
222
+ function pick(delta) {
223
+ const out = {};
224
+ for (const k of FIELDS) out[k] = delta[k];
225
+ return out;
226
+ }
227
+
228
+ function projectOf(dir) {
229
+ if (!dir) return null;
230
+ const parts = String(dir).split('/').filter(Boolean);
231
+ return parts.length ? parts[parts.length - 1] : null;
232
+ }
233
+
234
+ function shortPath(p, ctx) {
235
+ const home = ctx?.home || os.homedir();
236
+ return p.startsWith(home) ? '~' + p.slice(home.length) : p;
237
+ }
238
+
239
+ function n(v) {
240
+ return v === undefined || v === null ? null : Number(v);
241
+ }
242
+
243
+ function str(v) {
244
+ return v === undefined || v === null || v === '' ? null : String(v);
245
+ }
package/src/sdk.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The provider SDK — the minimum surface a community adapter needs.
3
+ *
4
+ * import { createProvider, registerProvider, normalizeUsage, validateUsage }
5
+ * from 'tokenflow/sdk';
6
+ *
7
+ * See docs/creating-provider.md for the full contract.
8
+ */
9
+ export { createProvider, registerProvider, getProvider, listProviders } from './core/registry.js';
10
+ export { validateUsage, validateProvider } from './core/validate.js';
11
+ export {
12
+ createRecord, computeTotal, dateParts, hashId,
13
+ MEASUREMENT, INTERFACE, INTERFACE_ORDER, interfaceClass,
14
+ BILLABLE_TOKEN_FIELDS, BREAKDOWN_TOKEN_FIELDS, TOKEN_FIELDS,
15
+ } from './core/schema.js';
16
+ export { classifyModel, BUILTIN_MODEL_RULES } from './core/model-map.js';
17
+ export { classifyInterface } from './core/interface-map.js';
18
+ export { readLines, readJsonLines } from './core/jsonl.js';
19
+ export { walk, enrich } from './core/ingest.js';
20
+ export { openReadOnly, tables, columns, sqliteAvailable } from './core/sqlite.js';
21
+ export { registerAnalytics, listAnalytics } from './analytics/index.js';
22
+ import { enrich } from './core/ingest.js';
23
+
24
+ /**
25
+ * Normalize a partial record without running a full ingest — handy in adapter
26
+ * unit tests and in `--dry-run` importers.
27
+ * @param {object} partial
28
+ * @param {{provider:{id:string,name:string,measurement?:string}, tz?:string|null, priceBook?:object,
29
+ * rules?:object[], config?:object, user?:string|null, machine?:string|null,
30
+ * seq?:number, fileRef?:object|null}} opt
31
+ */
32
+ export function normalizeUsage(partial, opt) {
33
+ return enrich(partial, {
34
+ ctx: {
35
+ tz: opt.tz ?? null,
36
+ priceBook: opt.priceBook ?? { lookup: () => null },
37
+ rules: opt.rules,
38
+ config: opt.config ?? {},
39
+ user: opt.user ?? null,
40
+ machine: opt.machine ?? null,
41
+ },
42
+ provider: opt.provider,
43
+ seq: opt.seq ?? 0,
44
+ fileRef: opt.fileRef ?? null,
45
+ });
46
+ }