tokenwatch-sdk 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # TokenWatch
2
2
 
3
+ **[Website](https://jkhusanovpn.github.io/tokenwatch/)** · **[npm](https://www.npmjs.com/package/tokenwatch-sdk)**
4
+
3
5
  **Know where your LLM money goes.** Zero-config cost & quality monitor for indie AI builders: one-line SDK, local dashboard, per-feature/per-customer attribution, and a budget kill-switch so an agent loop can never surprise you with a 5-figure bill.
4
6
 
5
7
  - No proxy in your request path — your calls go straight to the provider, telemetry is sent async on the side
@@ -49,6 +51,18 @@ import { track } from 'tokenwatch';
49
51
  track({ model: 'claude-fable-5', inputTokens: 1200, outputTokens: 400, feature: 'batch-job', customerId: 'acme' });
50
52
  ```
51
53
 
54
+ ### Track your coding agents too
55
+
56
+ Claude Code and Codex CLI make API calls you can't wrap — but they write session logs. `tokenwatch watch` tails them (read-only, no proxy, no agent config):
57
+
58
+ ```bash
59
+ npx tokenwatch-sdk serve --watch --backfill # server + agent-log watcher in one process
60
+ # or separately:
61
+ npx tokenwatch-sdk watch --backfill # Claude Code (~/.claude) + Codex (~/.codex)
62
+ ```
63
+
64
+ Spend appears per agent (`claude-code`, `codex`) and per project. Note: if you're on a subscription plan, the dollar figures are API-equivalent value, not your actual bill. Antigravity doesn't expose local session logs yet — open an issue if you know a way in.
65
+
52
66
  ### Budgets & alerts
53
67
 
54
68
  Set a monthly budget in the dashboard (or `POST /v1/settings`). At 80% and 100% TokenWatch fires your webhook; at 100% `enforceBudget: true` makes wrapped calls throw `BudgetExceededError` instead of burning money.
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { mkdirSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join, dirname } from 'node:path';
5
5
  import { startServer } from './server.js';
6
+ import { startWatch } from './watch.js';
6
7
  const args = process.argv.slice(2);
7
8
  const wantsHelp = args.includes('--help') || args.includes('-h');
8
9
  const command = wantsHelp ? 'help' : args[0] && !args[0].startsWith('-') ? args[0] : 'serve';
@@ -10,18 +11,43 @@ function flag(name) {
10
11
  const i = args.indexOf(`--${name}`);
11
12
  return i >= 0 ? args[i + 1] : undefined;
12
13
  }
14
+ const has = (name) => args.includes(`--${name}`);
13
15
  if (command === 'serve') {
14
16
  const port = Number(flag('port') ?? process.env.TOKENWATCH_PORT ?? process.env.PORT ?? 4318);
15
17
  const dbPath = flag('db') ?? process.env.TOKENWATCH_DB ?? join(homedir(), '.tokenwatch', 'tokenwatch.db');
16
18
  mkdirSync(dirname(dbPath), { recursive: true });
17
19
  startServer({ port, dbPath });
20
+ if (has('watch')) {
21
+ void startWatch({
22
+ endpoint: `http://localhost:${port}`,
23
+ apiKey: process.env.TOKENWATCH_API_KEY,
24
+ backfill: has('backfill'),
25
+ once: false,
26
+ intervalMs: Number(flag('interval') ?? 5000),
27
+ });
28
+ }
29
+ }
30
+ else if (command === 'watch') {
31
+ void startWatch({
32
+ endpoint: (flag('endpoint') ?? process.env.TOKENWATCH_URL ?? 'http://localhost:4318').replace(/\/$/, ''),
33
+ apiKey: process.env.TOKENWATCH_API_KEY,
34
+ backfill: has('backfill'),
35
+ once: has('once'),
36
+ intervalMs: Number(flag('interval') ?? 5000),
37
+ });
18
38
  }
19
39
  else {
20
40
  console.log(`tokenwatch — LLM cost & quality monitor
21
41
 
22
42
  Usage:
23
- tokenwatch serve [--port 4318] [--db ~/.tokenwatch/tokenwatch.db]
43
+ tokenwatch serve [--port 4318] [--db ~/.tokenwatch/tokenwatch.db] [--watch] [--backfill]
44
+ tokenwatch watch [--endpoint http://localhost:4318] [--backfill] [--once] [--interval 5000]
45
+
46
+ watch ingests usage from coding-agent session logs (read-only, no proxy):
47
+ Claude Code ~/.claude/projects/**/*.jsonl
48
+ Codex CLI ~/.codex/sessions/**/*.jsonl
49
+ --backfill processes existing history; default tracks new usage only.
24
50
 
25
51
  Env:
26
- TOKENWATCH_PORT, TOKENWATCH_DB, TOKENWATCH_API_KEY (require bearer auth for ingestion)`);
52
+ TOKENWATCH_PORT, TOKENWATCH_DB, TOKENWATCH_URL, TOKENWATCH_API_KEY`);
27
53
  }
@@ -0,0 +1,9 @@
1
+ export interface WatchOptions {
2
+ endpoint: string;
3
+ apiKey?: string;
4
+ backfill: boolean;
5
+ once: boolean;
6
+ intervalMs: number;
7
+ statePath?: string;
8
+ }
9
+ export declare function startWatch(opts: WatchOptions): Promise<void>;
package/dist/watch.js ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * `tokenwatch watch` — ingest usage from coding-agent session logs (read-only).
3
+ * Supported: Claude Code (~/.claude/projects), OpenAI Codex CLI (~/.codex/sessions).
4
+ * No proxy, no config inside the agents: we tail their JSONL transcripts.
5
+ */
6
+ import { readdirSync, statSync, openSync, readSync, closeSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
7
+ import { join, basename, dirname, relative, sep } from 'node:path';
8
+ import { homedir } from 'node:os';
9
+ import { findPrice } from './pricing.js';
10
+ const seen = new Set();
11
+ function parseClaudeLine(line, ctx) {
12
+ if (!line.includes('"assistant"') || !line.includes('"usage"'))
13
+ return null;
14
+ let o;
15
+ try {
16
+ o = JSON.parse(line);
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ if (o?.type !== 'assistant')
22
+ return null;
23
+ const m = o.message;
24
+ const u = m?.usage;
25
+ if (!u)
26
+ return null;
27
+ const id = m.id ?? o.uuid;
28
+ if (id) {
29
+ if (seen.has(id))
30
+ return null;
31
+ seen.add(id);
32
+ }
33
+ const model = m.model ?? 'unknown';
34
+ if (model.includes('synthetic'))
35
+ return null;
36
+ const p = findPrice(model);
37
+ const inT = u.input_tokens || 0;
38
+ const outT = u.output_tokens || 0;
39
+ const cacheRead = u.cache_read_input_tokens || 0;
40
+ const cc5 = u.cache_creation ? (u.cache_creation.ephemeral_5m_input_tokens || 0) : (u.cache_creation_input_tokens || 0);
41
+ const cc1 = u.cache_creation ? (u.cache_creation.ephemeral_1h_input_tokens || 0) : 0;
42
+ // Anthropic cache pricing: read 0.1x, 5m write 1.25x, 1h write 2x of input price.
43
+ const costUsd = p ? (inT * p.input + outT * p.output + cacheRead * p.input * 0.1 + cc5 * p.input * 1.25 + cc1 * p.input * 2) / 1e6 : 0;
44
+ return {
45
+ ts: o.timestamp ? Date.parse(o.timestamp) : Date.now(),
46
+ model,
47
+ provider: 'anthropic',
48
+ inputTokens: inT + cacheRead + cc5 + cc1,
49
+ outputTokens: outT,
50
+ costUsd,
51
+ feature: 'claude-code',
52
+ customerId: ctx.project,
53
+ };
54
+ }
55
+ function parseCodexLine(line, ctx) {
56
+ if (!line.includes('"model"') && !line.includes('token_count') && !line.includes('"cwd"'))
57
+ return null;
58
+ let o;
59
+ try {
60
+ o = JSON.parse(line);
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ const pl = o?.payload;
66
+ if (!pl)
67
+ return null;
68
+ if (typeof pl.model === 'string' && pl.model)
69
+ ctx.model = pl.model;
70
+ if (typeof pl.cwd === 'string' && pl.cwd)
71
+ ctx.cwd = basename(pl.cwd);
72
+ if (pl.type !== 'token_count')
73
+ return null;
74
+ const u = pl.info?.last_token_usage;
75
+ if (!u)
76
+ return null;
77
+ const inT = u.input_tokens || 0;
78
+ const cached = u.cached_input_tokens || 0;
79
+ const outT = u.output_tokens || 0;
80
+ if (!inT && !outT)
81
+ return null;
82
+ const model = ctx.model ?? 'gpt-5.5';
83
+ const p = findPrice(model);
84
+ // OpenAI cached input is ~0.1x of input price; cached is a subset of input_tokens.
85
+ const costUsd = p ? Math.max(0, ((inT - cached) * p.input + cached * p.input * 0.1 + outT * p.output) / 1e6) : 0;
86
+ return {
87
+ ts: o.timestamp ? Date.parse(o.timestamp) : Date.now(),
88
+ model,
89
+ provider: 'openai',
90
+ inputTokens: inT,
91
+ outputTokens: outT,
92
+ costUsd,
93
+ feature: 'codex',
94
+ customerId: ctx.cwd,
95
+ };
96
+ }
97
+ const SOURCES = [
98
+ { name: 'claude-code', root: join(homedir(), '.claude', 'projects'), parse: parseClaudeLine, projectFromPath: true },
99
+ { name: 'codex', root: join(homedir(), '.codex', 'sessions'), parse: parseCodexLine, projectFromPath: false },
100
+ ];
101
+ function* findJsonl(dir) {
102
+ let entries;
103
+ try {
104
+ entries = readdirSync(dir, { withFileTypes: true });
105
+ }
106
+ catch {
107
+ return;
108
+ }
109
+ for (const e of entries) {
110
+ const p = join(dir, e.name);
111
+ if (e.isDirectory())
112
+ yield* findJsonl(p);
113
+ else if (e.name.endsWith('.jsonl'))
114
+ yield p;
115
+ }
116
+ }
117
+ export async function startWatch(opts) {
118
+ const statePath = opts.statePath ?? join(homedir(), '.tokenwatch', 'watch-state.json');
119
+ mkdirSync(dirname(statePath), { recursive: true });
120
+ let state = { offsets: {}, seen: [] };
121
+ if (existsSync(statePath)) {
122
+ try {
123
+ state = JSON.parse(readFileSync(statePath, 'utf8'));
124
+ }
125
+ catch { /* fresh start */ }
126
+ }
127
+ for (const id of state.seen)
128
+ seen.add(id);
129
+ const ctxByFile = new Map();
130
+ const active = SOURCES.filter((s) => existsSync(s.root));
131
+ if (active.length === 0) {
132
+ console.error('tokenwatch watch: no supported agent logs found (~/.claude/projects, ~/.codex/sessions)');
133
+ return;
134
+ }
135
+ console.log(`watching: ${active.map((s) => s.name).join(', ')} → ${opts.endpoint}${opts.backfill ? ' (backfill)' : ''}`);
136
+ const scan = async () => {
137
+ const events = [];
138
+ for (const src of active) {
139
+ for (const file of findJsonl(src.root)) {
140
+ let size;
141
+ try {
142
+ size = statSync(file).size;
143
+ }
144
+ catch {
145
+ continue;
146
+ }
147
+ let offset = state.offsets[file];
148
+ if (offset === undefined) {
149
+ offset = opts.backfill ? 0 : size;
150
+ state.offsets[file] = offset;
151
+ if (offset === size)
152
+ continue;
153
+ }
154
+ if (size <= offset)
155
+ continue;
156
+ let ctx = ctxByFile.get(file);
157
+ if (!ctx) {
158
+ ctx = { project: src.projectFromPath ? relative(src.root, file).split(sep)[0] : undefined };
159
+ ctxByFile.set(file, ctx);
160
+ // When starting mid-file, recover per-file context (model/cwd) from the head.
161
+ if (offset > 0 && src.name === 'codex') {
162
+ try {
163
+ const headFd = openSync(file, 'r');
164
+ const head = Buffer.alloc(Math.min(65536, size));
165
+ readSync(headFd, head, 0, head.length, 0);
166
+ closeSync(headFd);
167
+ for (const l of head.toString('utf8').split('\n').slice(0, 80))
168
+ src.parse(l, ctx);
169
+ }
170
+ catch { /* best effort */ }
171
+ }
172
+ }
173
+ let text;
174
+ try {
175
+ const fd = openSync(file, 'r');
176
+ const buf = Buffer.alloc(size - offset);
177
+ readSync(fd, buf, 0, buf.length, offset);
178
+ closeSync(fd);
179
+ text = buf.toString('utf8');
180
+ }
181
+ catch {
182
+ continue;
183
+ }
184
+ const lastNl = text.lastIndexOf('\n');
185
+ if (lastNl === -1)
186
+ continue;
187
+ for (const line of text.slice(0, lastNl).split('\n')) {
188
+ const ev = src.parse(line, ctx);
189
+ if (ev)
190
+ events.push(ev);
191
+ }
192
+ state.offsets[file] = offset + Buffer.byteLength(text.slice(0, lastNl + 1), 'utf8');
193
+ }
194
+ }
195
+ if (events.length > 0) {
196
+ const headers = { 'content-type': 'application/json' };
197
+ if (opts.apiKey)
198
+ headers.authorization = `Bearer ${opts.apiKey}`;
199
+ for (let i = 0; i < events.length; i += 500) {
200
+ const res = await fetch(`${opts.endpoint}/v1/events`, {
201
+ method: 'POST',
202
+ headers,
203
+ body: JSON.stringify({ events: events.slice(i, i + 500) }),
204
+ });
205
+ if (!res.ok)
206
+ throw new Error(`ingest failed: HTTP ${res.status}`);
207
+ }
208
+ const total = events.reduce((s, e) => s + e.costUsd, 0);
209
+ console.log(`+${events.length} events ($${total.toFixed(2)} API-value) ingested`);
210
+ }
211
+ state.seen = [...seen].slice(-20000);
212
+ writeFileSync(statePath, JSON.stringify(state));
213
+ };
214
+ await scan();
215
+ if (!opts.once) {
216
+ // eslint-disable-next-line no-constant-condition
217
+ while (true) {
218
+ await new Promise((r) => setTimeout(r, opts.intervalMs));
219
+ await scan().catch((err) => console.error('tokenwatch watch:', err?.message ?? err));
220
+ }
221
+ }
222
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenwatch-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Zero-config LLM cost & quality monitor for indie AI builders. One-line SDK, local dashboard, budget kill-switch.",
5
5
  "type": "module",
6
6
  "license": "MIT",