@vimoxshah/tokenflow 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +84 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/Refresh & Open Dashboard.command +22 -0
- package/SECURITY.md +42 -0
- package/bin/tokenflow.js +1342 -0
- package/docs/architecture.md +193 -0
- package/docs/cli.md +390 -0
- package/docs/configuration.md +281 -0
- package/docs/creating-provider.md +262 -0
- package/docs/data-model.md +213 -0
- package/docs/getting-started.md +266 -0
- package/docs/live-mode.md +199 -0
- package/docs/media/architecture-hero.svg +86 -0
- package/docs/media/cost-editorial-dark.png +0 -0
- package/docs/media/health-terminal-light.png +0 -0
- package/docs/media/menubar-dark.png +0 -0
- package/docs/media/menubar-light.png +0 -0
- package/docs/media/models-terminal-dark.png +0 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/time-aurora-light.png +0 -0
- package/docs/providers.md +309 -0
- package/docs/skill.md +64 -0
- package/docs/troubleshooting.md +207 -0
- package/examples/config.example.yaml +92 -0
- package/examples/demo-data/README.md +38 -0
- package/examples/demo-data/sample-usage.csv +11 -0
- package/package.json +74 -0
- package/scripts/build-dmg.sh +33 -0
- package/scripts/build-menubar-app.sh +67 -0
- package/scripts/lint.js +111 -0
- package/scripts/validate-install.js +140 -0
- package/skills/tokenflow/SKILL.md +392 -0
- package/skills/tokenflow/examples/config.yaml +92 -0
- package/skills/tokenflow/examples/generic-mapping.json +26 -0
- package/skills/tokenflow/examples/session-transcript.md +191 -0
- package/skills/tokenflow/providers/adapter-template.js +135 -0
- package/skills/tokenflow/providers/detection-matrix.md +142 -0
- package/skills/tokenflow/schemas/config.schema.json +107 -0
- package/skills/tokenflow/schemas/normalized-record.json +63 -0
- package/src/analytics/aggregate.js +247 -0
- package/src/analytics/anomalies.js +222 -0
- package/src/analytics/capacity.js +278 -0
- package/src/analytics/comparison.js +96 -0
- package/src/analytics/dimensions.js +230 -0
- package/src/analytics/efficiency.js +138 -0
- package/src/analytics/forecast.js +202 -0
- package/src/analytics/index.js +327 -0
- package/src/analytics/insights.js +283 -0
- package/src/analytics/milestones.js +91 -0
- package/src/analytics/peak.js +106 -0
- package/src/analytics/productivity.js +166 -0
- package/src/analytics/token-usage.js +267 -0
- package/src/commands/diagnostics.js +88 -0
- package/src/commands/digest.js +155 -0
- package/src/commands/models-compare.js +96 -0
- package/src/core/budget.js +142 -0
- package/src/core/bundle.js +191 -0
- package/src/core/config.js +202 -0
- package/src/core/delivery.js +109 -0
- package/src/core/geo.js +99 -0
- package/src/core/ingest.js +457 -0
- package/src/core/interface-map.js +55 -0
- package/src/core/jsonl.js +124 -0
- package/src/core/live-status.js +417 -0
- package/src/core/model-map.js +157 -0
- package/src/core/notify.js +83 -0
- package/src/core/pricing.js +288 -0
- package/src/core/prompt-analytics.js +127 -0
- package/src/core/registry.js +107 -0
- package/src/core/restore.js +261 -0
- package/src/core/schedule.js +120 -0
- package/src/core/schema.js +316 -0
- package/src/core/sqlite.js +96 -0
- package/src/core/store.js +493 -0
- package/src/core/sync.js +151 -0
- package/src/core/units.js +147 -0
- package/src/core/validate.js +123 -0
- package/src/core/watch.js +287 -0
- package/src/core/yaml.js +209 -0
- package/src/export/bundler.js +107 -0
- package/src/export/csv.js +100 -0
- package/src/export/html-snapshot.js +101 -0
- package/src/export/menubar.js +158 -0
- package/src/index.js +18 -0
- package/src/providers/anthropic/index.js +294 -0
- package/src/providers/cline/index.js +120 -0
- package/src/providers/cursor/index.js +143 -0
- package/src/providers/generic/index.js +268 -0
- package/src/providers/git/index.js +188 -0
- package/src/providers/headroom/index.js +114 -0
- package/src/providers/hermes/index.js +299 -0
- package/src/providers/mock/index.js +117 -0
- package/src/providers/openai/index.js +370 -0
- package/src/providers/opencode/index.js +245 -0
- package/src/sdk.js +46 -0
- package/src/server/server.js +264 -0
- package/src/ui/app.js +2473 -0
- package/src/ui/charts.js +925 -0
- package/src/ui/index.html +42 -0
- package/src/ui/styles.css +644 -0
|
@@ -0,0 +1,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).
|