@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,281 @@
1
+ # Configuration reference
2
+
3
+ Everything Tokenflow knows about your setup lives in one file:
4
+
5
+ ```
6
+ ~/.tokenflow/config.yaml
7
+ ```
8
+
9
+ `tokenflow setup` writes it for you by detecting what is on the machine, so most people never
10
+ edit it. This page is the complete reference for when you do. `tokenflow config show` prints the
11
+ *effective* config (defaults merged with your file), and `tokenflow config path` prints the data
12
+ home.
13
+
14
+ Nothing here is ever transmitted anywhere. There are no keys, tokens, or accounts to configure —
15
+ if a field looks like it wants a credential, you are reading the wrong project.
16
+
17
+ ---
18
+
19
+ ## The whole file, annotated
20
+
21
+ ```yaml
22
+ version: 1
23
+
24
+ # Timezone used to bucket a timestamp into a day and an hour. Set it explicitly
25
+ # if you travel, or if you want the numbers to match a colleague's. null = this
26
+ # machine's current zone at ingest time.
27
+ timezone: Asia/Kolkata # null | any IANA zone
28
+
29
+ # Stamped onto every record. Useful later if several people's exports are ever
30
+ # merged; ignored entirely if you are the only user.
31
+ identity:
32
+ user: null # null = the OS username
33
+ machine: null # null = the hostname
34
+ team: null
35
+
36
+ # Which adapters are enabled. Empty means "every adapter that detects".
37
+ # Manage with: tokenflow provider add|remove <id>
38
+ providers:
39
+ - anthropic
40
+ - openai
41
+ - opencode
42
+ - hermes
43
+ - cline
44
+ - cursor
45
+ - headroom
46
+ - git
47
+
48
+ # Per-adapter options. Anything omitted falls back to that adapter's default
49
+ # locations, so this section is usually short.
50
+ sources:
51
+ anthropic:
52
+ # Claude Code / Agent SDK. Several homes are normal: one per client, or a
53
+ # separate profile per company.
54
+ paths: ["~/.claude", "~/.claude-work"]
55
+ openai:
56
+ paths: ["~/.codex"] # Codex CLI / IDE / Desktop
57
+ opencode:
58
+ db: "~/.local/share/opencode/opencode.db" # $XDG_DATA_HOME-aware
59
+ hermes:
60
+ db: "~/.hermes/state.db" # $HERMES_HOME honoured
61
+ cline:
62
+ path: "~/.cline/data/sessions"
63
+ cursor:
64
+ db: "~/.cursor/ai-tracking/ai-code-tracking.db"
65
+ headroom:
66
+ path: "~/.headroom" # gateway overlay: measured cost, tokens excluded
67
+ git:
68
+ # Correlate usage with shipped work. Both are optional.
69
+ scanRoots: ["~/code", "~/work"] # directories to scan for repositories
70
+ repos: [] # or list them explicitly
71
+ autoFromUsage: true # also use working dirs seen in usage records
72
+
73
+ store:
74
+ keepRaw: true # keep request-level records (Data Explorer + full export)
75
+ rawRetentionDays: null # null = keep forever; a number prunes older shards
76
+
77
+ analytics:
78
+ # A gateway/proxy log describes traffic a client adapter already counted.
79
+ # Including it double-counts tokens; excluding it still surfaces its measured
80
+ # cost. Leave false unless you know you want the other behaviour.
81
+ includeOverlaySources: false
82
+ # Only used for sources that do not carry a session id of their own.
83
+ minSessionGapMinutes: 30
84
+
85
+ # Your own model classification rules, evaluated before the built-ins. Use this
86
+ # when a gateway renames models, or for an in-house model the built-ins can't know.
87
+ modelMappings:
88
+ - match: "^acme-" # regex against the model name
89
+ provider: acme
90
+ label: Acme
91
+ family: Acme v2
92
+
93
+ # Force an interface for a client the adapter can't classify from its own signals.
94
+ # Values: CLI | Desktop | IDE | Web | API | Unknown
95
+ interfaceOverrides:
96
+ my-wrapper-script: CLI
97
+
98
+ ui:
99
+ skin: aurora # aurora | terminal | editorial
100
+ mode: dark # dark | light
101
+ port: 7799 # the dashboard binds 127.0.0.1 on this port
102
+ defaultRange: all # all | 7d | 30d | 90d | mtd
103
+ defaultFrom: null # e.g. "2026-03-14" — a floor for the default view only
104
+ ```
105
+
106
+ ---
107
+
108
+ ## The fields that actually change behaviour
109
+
110
+ ### `timezone`
111
+
112
+ A day boundary is a decision, not a fact. Tokens are bucketed into `date` and `hour` **at ingest
113
+ time** using this zone, and the value is stored on the record — so changing it later requires
114
+ `tokenflow refresh --full` to take effect on history. Set it if you want stable days across
115
+ travel.
116
+
117
+ ### `providers`
118
+
119
+ An empty list means "auto": every adapter that detects gets used. Naming them explicitly is
120
+ faster (no detection work for tools you don't have) and predictable. `tokenflow providers` shows
121
+ what is detected, what is enabled, and *why* something is not available.
122
+
123
+ ### `sources.<id>.paths`
124
+
125
+ Adapters look in conventional locations. Point them elsewhere when your tool is installed
126
+ somewhere unusual, or when you keep several profiles:
127
+
128
+ ```yaml
129
+ sources:
130
+ anthropic:
131
+ paths: ["~/.claude", "~/.claude-clientA", "/Volumes/work/.claude"]
132
+ ```
133
+
134
+ A path that does not exist is skipped silently — listing a machine's worth of possibilities is
135
+ fine and costs nothing.
136
+
137
+ ### `analytics.includeOverlaySources`
138
+
139
+ Set this to `true` only if you *want* gateway traffic counted twice — for example when the
140
+ gateway is your only record of a tool that writes no logs of its own. The default (`false`) keeps
141
+ tokens honest and still reports the gateway's measured cost separately.
142
+
143
+ ### `store.rawRetentionDays`
144
+
145
+ Aggregates (the cube, sessions, activity) are tiny and kept forever. The request-level shards are
146
+ what grow. Set a number of days if you want them pruned; the aggregates already computed are not
147
+ affected, so your history stays in the charts even after the raw rows are gone. `keepRaw: false`
148
+ skips writing them entirely — the Data Explorer and the full CSV export then have nothing to
149
+ show, so prefer a retention window over turning it off.
150
+
151
+ ### `ui.skin` / `ui.mode`
152
+
153
+ The default look. A choice made in the dashboard header is remembered in the browser and wins
154
+ over this. Series colours belong to the **mode**, not the skin, and were validated for
155
+ colour-blind separation against every skin's chart surface — which is why changing the look can
156
+ never change what a colour means.
157
+
158
+ ### `limits` — declared capacity caps
159
+
160
+ TokenFlow never invents vendor quota data. A limit exists because you wrote it here, and the
161
+ engine evaluates it against measured consumption (Live tab, `tokenflow capacity`, menu bar):
162
+
163
+ ```yaml
164
+ limits:
165
+ - id: anthropic-monthly # required, unique
166
+ provider: anthropic # optional cube filters — provider | model | project
167
+ scope: month # day | week | month (your local calendar)
168
+ metric: tokens # tokens | input | output | requests | cost
169
+ cap: 120000000 # tokens, or dollars when metric is cost
170
+ warnAt: 0.8 # optional warn threshold (default 0.8)
171
+ ```
172
+
173
+ From each limit the engine derives: consumption in the window, % used, remaining,
174
+ today's hourly pace, a trailing-7-day daily pace, projected exhaustion ETA and
175
+ the exact reset instant in your timezone. Invalid definitions are reported via
176
+ `tokenflow capacity --json` (`invalid`) and by the dashboard — never silently
177
+ ignored. See [live-mode.md](live-mode.md) for semantics.
178
+
179
+ ### `watch` — the background refresher
180
+
181
+ ```yaml
182
+ watch:
183
+ intervalSeconds: 120 # between incremental refresh passes
184
+ notifications: false # OS notifications on limit crossings / new anomalies
185
+ staleAfterSeconds: 600 # when live surfaces should call the data stale
186
+ ```
187
+
188
+ The watcher only runs while you started it (`tokenflow watch`); nothing is
189
+ installed or auto-started. `notifications: true` is what `--notify` overrides
190
+ per run.
191
+
192
+ ### `ui.menubarMode`
193
+
194
+ Default display mode for `tokenflow status --bar` and the menu-bar plugin:
195
+ `auto` (most urgent signal), `tokens`, `cost`, or `limit`.
196
+
197
+ ### `map.showMyLocation`
198
+
199
+ Off by default — and it is the only geography feature that touches the network.
200
+ When set to `true`, the dashboard's Global activity map resolves THIS machine's
201
+ public IP once via an HTTPS lookup (ipapi.co), caches the derived place
202
+ (city/region/country/coordinates) in `$TOKENFLOW_HOME/data/geo-cache.json`,
203
+ and re-resolves at most monthly. The IP itself is never stored, never logged,
204
+ never displayed. Leave it unset (or `false`) and TokenFlow performs no lookup
205
+ at all: the map then shows only vendor-published provider regions. There is
206
+ no per-request IP capture anywhere in the product.
207
+
208
+ ---
209
+
210
+ ## Pricing is separate
211
+
212
+ Rates live in `~/.tokenflow/pricing.json`, not in `config.yaml`, because they are data rather
213
+ than configuration — and because the built-in table ships with provenance:
214
+
215
+ ```bash
216
+ tokenflow pricing # what is priced, from which source
217
+ tokenflow pricing --sources # every rate's origin, fetch date and confidence
218
+ tokenflow pricing --set "my-model=3,15,0.3,3.75" # input,output[,cacheRead[,cacheWrite]] per 1M
219
+ ```
220
+
221
+ Your overrides always beat the built-in table. Anything with no rate stays visibly unpriced —
222
+ never a plausible-looking `$0`.
223
+
224
+ ---
225
+
226
+ ## Environment variables
227
+
228
+ | Variable | Effect |
229
+ |---|---|
230
+ | `TOKENFLOW_HOME` | move the whole data home (config, pricing, data, cache) |
231
+ | `TOKENFLOW_DEMO=1` | enable the synthetic demo adapter |
232
+ | `AI_USAGE_HOME` | legacy alias for `TOKENFLOW_HOME`, still honoured |
233
+
234
+ Running several isolated datasets is just several homes:
235
+
236
+ ```bash
237
+ TOKENFLOW_HOME=~/.tokenflow-work tokenflow refresh
238
+ TOKENFLOW_HOME=~/.tokenflow-work tokenflow dashboard --port 7801
239
+ ```
240
+
241
+ If `~/.tokenflow` does not exist but `~/.ai-usage-dashboard` does, the older directory is used —
242
+ an install from before the rename keeps its ingested history instead of silently starting over.
243
+
244
+ ---
245
+
246
+ ## Moving a setup to another machine
247
+
248
+ ```bash
249
+ # old machine
250
+ tokenflow config export backup.json # config + pricing + mappings, no usage data
251
+ tokenflow export --csv --all # the usage data, if you want it too
252
+
253
+ # new machine
254
+ tokenflow config import backup.json
255
+ tokenflow restore tokenflow-usage-2026-08-20.csv --yes
256
+ ```
257
+
258
+ The CSV carries counts and identifiers only — prompts, conversations and source code never leave
259
+ the machine that produced them. See [cli.md § restore](cli.md#restore-filecsv).
260
+
261
+ ---
262
+
263
+ ## Where everything lives
264
+
265
+ ```
266
+ ~/.tokenflow/
267
+ config.yaml this file
268
+ pricing.json your rate overrides
269
+ preferences.json dashboard state (filters, tab, theme)
270
+ mappings/ saved generic-import field mappings
271
+ providers/ your own adapters (*.js) — loaded automatically
272
+ data/
273
+ records/ YYYY-MM.jsonl, request-level facts
274
+ cube.json the pre-aggregated table the dashboard loads
275
+ sessions.json one row per session
276
+ activity.json daily work-activity rollup
277
+ state.json per-file ingest offsets — this is what makes refresh incremental
278
+ cache/
279
+ ```
280
+
281
+ Delete the directory and Tokenflow knows nothing. Nothing lives anywhere else.
@@ -0,0 +1,262 @@
1
+ # Creating a provider
2
+
3
+ An adapter parses; the engine does everything else. That split is why a new adapter is ~80 lines
4
+ and why the analytics layer never sees a vendor-specific shape.
5
+
6
+ The engine handles: model → vendor classification, interface classification, timezone resolution,
7
+ cost estimation, id assignment, dedup bookkeeping, shard writes, and the cube / session /
8
+ activity rollups. **Do not do any of that in an adapter.**
9
+
10
+ ## The contract
11
+
12
+ ```ts
13
+ interface UsageProvider {
14
+ id: string; // lowercase slug, unique
15
+ name: string; // human name shown in `tokenflow providers`
16
+ description?: string;
17
+ measurement?: 'primary' | 'overlay' | 'activity'; // default 'primary'
18
+ requires?: string[]; // human prerequisites, shown on failure
19
+
20
+ /** Can this adapter run here? Reads nothing but existence/metadata. */
21
+ detect(ctx): Promise<{ available: boolean; detail?: string; paths?: string[] }>;
22
+
23
+ // ---- EITHER a file-based source: implement both -------------------------
24
+ discover?(ctx): Promise<Array<{ key: string; path: string; stat: fs.Stats }>>;
25
+ ingestFile?(ref, ctx, emit): Promise<{ offset: number; records?: number; malformed?: number }>;
26
+
27
+ // ---- OR a fetch-based source (SQLite, API, generator) -------------------
28
+ fetchUsage?(ctx, emit, sourceState): Promise<{ cursor?: any; records?: number; notes?: string[] }>;
29
+
30
+ /** Optional: exposed for tests and dry-run importers. */
31
+ normalize?(raw: unknown, opts?: unknown): object;
32
+ getMetadata?(): object; // provided by createProvider()
33
+ }
34
+ ```
35
+
36
+ `createProvider()` validates this up front, so a broken adapter fails at load with a readable
37
+ message instead of halfway through a multi-gigabyte ingest.
38
+
39
+ ## Minimal file-based adapter
40
+
41
+ ```js
42
+ import fs from 'node:fs';
43
+ import path from 'node:path';
44
+ import os from 'node:os';
45
+ import { createProvider, readLines, walk, MEASUREMENT } from 'tokenflow/sdk';
46
+
47
+ const root = (ctx) => ctx?.config?.sources?.['my-provider']?.path
48
+ || path.join(ctx?.home || os.homedir(), '.my-tool', 'logs');
49
+
50
+ export default createProvider({
51
+ id: 'my-provider',
52
+ name: 'My AI Provider',
53
+ description: 'Per-request usage from My Tool.',
54
+ measurement: MEASUREMENT.PRIMARY,
55
+ requires: ['~/.my-tool/logs/*.jsonl'],
56
+
57
+ async detect(ctx) {
58
+ const dir = root(ctx);
59
+ if (!fs.existsSync(dir)) return { available: false, detail: `no ${dir}` };
60
+ return { available: true, detail: dir, paths: [dir] };
61
+ },
62
+
63
+ async discover(ctx) {
64
+ return walk(root(ctx), (name) => name.endsWith('.jsonl')).map((p) => ({
65
+ key: path.relative(root(ctx), p), // stable identity for incremental state
66
+ path: p,
67
+ stat: fs.statSync(p),
68
+ }));
69
+ },
70
+
71
+ async ingestFile(ref, ctx, emit) {
72
+ let records = 0;
73
+ let malformed = 0;
74
+ const res = readLines(ref.path, (line) => {
75
+ let o;
76
+ try { o = JSON.parse(line); } catch { malformed++; return; }
77
+ if (!o.usage) return;
78
+ emit({
79
+ timestamp: o.created_at,
80
+ model: o.model,
81
+ input_tokens: o.usage.prompt_tokens,
82
+ cache_read_tokens: o.usage.cached_tokens ?? null,
83
+ cache_write_tokens: null,
84
+ output_tokens: o.usage.completion_tokens,
85
+ session_id: o.conversation_id,
86
+ request_id: o.id,
87
+ project: o.cwd ? path.basename(o.cwd) : null,
88
+ interfaceSignals: [o.client, o.surface],
89
+ });
90
+ records++;
91
+ }, {
92
+ start: ref.start, // ALWAYS resume from here
93
+ must: ['"usage"'], // cheap prefilter: skip lines that cannot match
94
+ });
95
+ return { offset: res.offset, records, malformed };
96
+ },
97
+ });
98
+ ```
99
+
100
+ ## The five rules
101
+
102
+ ### 1. Resume from `ref.start` and return `offset`
103
+
104
+ ```js
105
+ const res = readLines(ref.path, handler, { start: ref.start });
106
+ return { offset: res.offset };
107
+ ```
108
+
109
+ This is what makes refresh incremental and exact. `readLines` returns the offset of the last
110
+ **complete** line, so a partially-written trailing line is never consumed. Ignoring `ref.start`
111
+ means every refresh re-emits the whole file.
112
+
113
+ ### 2. Unreported is `null`, not `0`
114
+
115
+ ```js
116
+ cache_write_tokens: o.usage.cache_write ?? null, // right
117
+ cache_write_tokens: o.usage.cache_write || 0, // WRONG — the linter fails the build
118
+ ```
119
+
120
+ If the source *does* report zero, pass `0`. The difference is the entire point of the schema.
121
+
122
+ ### 3. Convert to the exclusive convention
123
+
124
+ `input_tokens` must be **fresh** prompt tokens. If your vendor's `input_tokens` includes cached
125
+ tokens (OpenAI's does), subtract:
126
+
127
+ ```js
128
+ const fresh = input === null ? null : cached === null ? input : Math.max(0, input - cached);
129
+ ```
130
+
131
+ `cache_refresh_tokens` must be a subset of `cache_write_tokens`, and `reasoning_tokens` a subset
132
+ of `output_tokens`. `validateUsage` rejects records that break this.
133
+
134
+ ### 4. Never infer the interface from the model
135
+
136
+ Pass `interfaceSignals: [...]` — an ordered, strongest-first list of **surface** fields
137
+ (`entrypoint`, `originator`, `source`, an IDE marker, a client name). The engine classifies them.
138
+ With no signal the interface is `Unknown`, which is the correct answer.
139
+
140
+ Pass `interface: 'CLI'` directly only when your source *is* the surface (the git adapter does).
141
+
142
+ ### 5. Watch for re-reported usage
143
+
144
+ If your source streams progress updates that re-report a growing total, summing them will inflate
145
+ everything. Check whether a series of usage events for one logical call is monotonically growing
146
+ (→ take the maximum) or genuinely disjoint (→ sum). Both real adapters in this repo needed the
147
+ former; one was inflating by 45x before it was fixed. Record the event count in `metadata` so the
148
+ reconstruction is auditable.
149
+
150
+ ## Fetch-based adapters
151
+
152
+ For SQLite, APIs, or generators, implement `fetchUsage` and keep a cursor:
153
+
154
+ ```js
155
+ async fetchUsage(ctx, emit, sourceState) {
156
+ const cursor = sourceState?.cursor || { lastId: 0 };
157
+ const db = openReadOnly(dbPath(ctx)); // snapshots + never writes
158
+ try {
159
+ for (const row of db.prepare('SELECT * FROM usage WHERE id > ? ORDER BY id').all(cursor.lastId)) {
160
+ emit({ timestamp: new Date(row.ts).toISOString(), model: row.model, /* … */ });
161
+ cursor.lastId = row.id;
162
+ }
163
+ } finally {
164
+ db.close();
165
+ }
166
+ return { cursor, notes: [] };
167
+ }
168
+ ```
169
+
170
+ The cursor is persisted by the engine, so the next run only reads new rows.
171
+
172
+ ## Per-file adapter state
173
+
174
+ `ref.state` is a plain object persisted per source file across refreshes. Use it for header
175
+ context that a mid-file resume would otherwise lose — Codex keeps the session metadata there,
176
+ because `session_meta` is on line 1 and a resumed read starts at line 40,000.
177
+
178
+ ```js
179
+ async ingestFile(ref, ctx, emit) {
180
+ const s = ref.state; // survives between refreshes
181
+ if (d.type === 'session_meta') { s.sessionId = d.payload.session_id; return; }
182
+ // …
183
+ }
184
+ ```
185
+
186
+ ## Testing your adapter
187
+
188
+ Put a small, realistic fixture in `test/fixtures/` — include the awkward cases: a re-reported
189
+ usage series, a row with a missing field, a synthetic/zero row.
190
+
191
+ ```js
192
+ import my from '../src/providers/my-provider/index.js';
193
+ import { ingestFixtureAsync } from './helpers.js';
194
+ import { validateUsage } from '../src/core/validate.js';
195
+
196
+ test('my-provider: token semantics', async () => {
197
+ const { records, result } = await ingestFixtureAsync(my, 'my-provider.jsonl');
198
+ assert.equal(records.length, 2);
199
+ assert.equal(records[0].input_tokens, 6000, 'fresh input excludes the cached portion');
200
+ assert.equal(records[0].cache_write_tokens, null, 'unreported stays null');
201
+ for (const r of records) assert.ok(validateUsage(r).ok);
202
+ });
203
+
204
+ test('my-provider: re-reading the same bytes emits nothing new', async () => {
205
+ const first = await ingestFixtureAsync(my, 'my-provider.jsonl');
206
+ const again = await ingestFixtureAsync(my, 'my-provider.jsonl', {
207
+ start: first.result.offset, state: first.state,
208
+ });
209
+ assert.equal(again.records.length, 0);
210
+ });
211
+ ```
212
+
213
+ That second test is the one that catches the expensive class of bug.
214
+
215
+ ## Installing it
216
+
217
+ **For yourself** — drop it in `$TOKENFLOW_HOME/providers/my-provider.js`. Loaded on the next run;
218
+ a user adapter shadows a built-in with the same id.
219
+
220
+ **For everyone** — add `src/providers/my-provider/index.js`, a fixture, and tests, then:
221
+
222
+ ```bash
223
+ npm test && npm run lint && npm run validate
224
+ ```
225
+
226
+ Please document, in the adapter's header comment, what each vendor field means and which
227
+ convention it follows. That header is the most valuable part of an adapter.
228
+
229
+ ## SDK surface
230
+
231
+ ```js
232
+ import {
233
+ createProvider, registerProvider, listProviders,
234
+ normalizeUsage, validateUsage, validateProvider,
235
+ createRecord, computeTotal, dateParts, hashId,
236
+ MEASUREMENT, INTERFACE, interfaceClass,
237
+ classifyModel, classifyInterface,
238
+ readLines, readJsonLines, walk,
239
+ openReadOnly, tables, columns, sqliteAvailable,
240
+ registerAnalytics,
241
+ } from 'tokenflow/sdk';
242
+ ```
243
+
244
+ ## Adding analytics instead
245
+
246
+ If you want a new *metric* rather than a new source, register an analytics plugin — no core
247
+ changes either:
248
+
249
+ ```js
250
+ import { registerAnalytics } from 'tokenflow/sdk';
251
+
252
+ registerAnalytics({
253
+ id: 'carbon',
254
+ section: 'sustainability',
255
+ title: 'Estimated energy',
256
+ compute({ rows, ix, totals, sessions, range }) {
257
+ return { kwh: totals.total * 0.0000003, basis: 'estimated', note: '...' };
258
+ },
259
+ });
260
+ ```
261
+
262
+ The result appears at `view.plugins.carbon`. See [architecture.md](architecture.md#plugin-points).