tokenwatch-sdk 0.1.0 → 0.2.1

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.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import './suppress-warnings.js';
package/dist/cli.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import './suppress-warnings.js';
2
3
  import { mkdirSync } from 'node:fs';
3
4
  import { homedir } from 'node:os';
4
5
  import { join, dirname } from 'node:path';
5
6
  import { startServer } from './server.js';
7
+ import { startWatch } from './watch.js';
6
8
  const args = process.argv.slice(2);
7
9
  const wantsHelp = args.includes('--help') || args.includes('-h');
8
10
  const command = wantsHelp ? 'help' : args[0] && !args[0].startsWith('-') ? args[0] : 'serve';
@@ -10,18 +12,43 @@ function flag(name) {
10
12
  const i = args.indexOf(`--${name}`);
11
13
  return i >= 0 ? args[i + 1] : undefined;
12
14
  }
15
+ const has = (name) => args.includes(`--${name}`);
13
16
  if (command === 'serve') {
14
17
  const port = Number(flag('port') ?? process.env.TOKENWATCH_PORT ?? process.env.PORT ?? 4318);
15
18
  const dbPath = flag('db') ?? process.env.TOKENWATCH_DB ?? join(homedir(), '.tokenwatch', 'tokenwatch.db');
16
19
  mkdirSync(dirname(dbPath), { recursive: true });
17
20
  startServer({ port, dbPath });
21
+ if (has('watch')) {
22
+ void startWatch({
23
+ endpoint: `http://localhost:${port}`,
24
+ apiKey: process.env.TOKENWATCH_API_KEY,
25
+ backfill: has('backfill'),
26
+ once: false,
27
+ intervalMs: Number(flag('interval') ?? 5000),
28
+ });
29
+ }
30
+ }
31
+ else if (command === 'watch') {
32
+ void startWatch({
33
+ endpoint: (flag('endpoint') ?? process.env.TOKENWATCH_URL ?? 'http://localhost:4318').replace(/\/$/, ''),
34
+ apiKey: process.env.TOKENWATCH_API_KEY,
35
+ backfill: has('backfill'),
36
+ once: has('once'),
37
+ intervalMs: Number(flag('interval') ?? 5000),
38
+ });
18
39
  }
19
40
  else {
20
41
  console.log(`tokenwatch — LLM cost & quality monitor
21
42
 
22
43
  Usage:
23
- tokenwatch serve [--port 4318] [--db ~/.tokenwatch/tokenwatch.db]
44
+ tokenwatch serve [--port 4318] [--db ~/.tokenwatch/tokenwatch.db] [--watch] [--backfill]
45
+ tokenwatch watch [--endpoint http://localhost:4318] [--backfill] [--once] [--interval 5000]
46
+
47
+ watch ingests usage from coding-agent session logs (read-only, no proxy):
48
+ Claude Code ~/.claude/projects/**/*.jsonl
49
+ Codex CLI ~/.codex/sessions/**/*.jsonl
50
+ --backfill processes existing history; default tracks new usage only.
24
51
 
25
52
  Env:
26
- TOKENWATCH_PORT, TOKENWATCH_DB, TOKENWATCH_API_KEY (require bearer auth for ingestion)`);
53
+ TOKENWATCH_PORT, TOKENWATCH_DB, TOKENWATCH_URL, TOKENWATCH_API_KEY`);
27
54
  }
package/dist/server.d.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import { Hono } from 'hono';
2
- import { DatabaseSync } from 'node:sqlite';
3
2
  export declare function createApp(dbPath: string): {
4
3
  app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
5
- db: DatabaseSync;
4
+ db: import("node:sqlite").DatabaseSync;
6
5
  };
7
6
  export declare function startServer(opts: {
8
7
  port: number;
package/dist/server.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { Hono } from 'hono';
2
2
  import { serve } from '@hono/node-server';
3
- import { DatabaseSync } from 'node:sqlite';
3
+ import { createRequire } from 'node:module';
4
4
  import { computeCostUsd } from './pricing.js';
5
5
  import { dashboardHtml } from './dashboard.js';
6
+ // Lazy-require node:sqlite so its ExperimentalWarning fires after our suppressor
7
+ // (static `import 'node:sqlite'` emits the warning during module linking).
8
+ const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite');
6
9
  const SCHEMA = `
7
10
  CREATE TABLE IF NOT EXISTS events (
8
11
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -167,6 +170,15 @@ export function createApp(dbPath) {
167
170
  export function startServer(opts) {
168
171
  const { app } = createApp(opts.dbPath);
169
172
  const server = serve({ fetch: app.fetch, port: opts.port });
173
+ server.on?.('error', (err) => {
174
+ if (err?.code === 'EADDRINUSE') {
175
+ console.error(`\nTokenWatch: port ${opts.port} is already in use.\n` +
176
+ `Probably another TokenWatch is running — open http://localhost:${opts.port} to check,\n` +
177
+ `or start on a different port: tokenwatch serve --port ${opts.port + 1}\n`);
178
+ process.exit(1);
179
+ }
180
+ throw err;
181
+ });
170
182
  console.log(`TokenWatch running → http://localhost:${opts.port} (db: ${opts.dbPath})`);
171
183
  return server;
172
184
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ // Must be imported before any module that loads node:sqlite.
2
+ // node:sqlite is stable for our use; hide the scary first-run ExperimentalWarning.
3
+ const originalEmitWarning = process.emitWarning.bind(process);
4
+ process.emitWarning = ((warning, ...rest) => {
5
+ const text = typeof warning === 'string' ? warning : warning?.message ?? '';
6
+ if (text.includes('SQLite is an experimental feature'))
7
+ return;
8
+ originalEmitWarning(warning, ...rest);
9
+ });
10
+ export {};
@@ -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.1",
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",