ai-lens 0.8.51 → 0.8.52
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/.commithash +1 -1
- package/README.md +121 -60
- package/cli/hooks.js +12 -3
- package/cli/init.js +42 -14
- package/client/capture.js +448 -37
- package/client/codex.js +47 -0
- package/client/config.js +15 -2
- package/client/token-usage.js +47 -0
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
8553371
|
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# AI Lens
|
|
2
2
|
|
|
3
|
-
Analytics for AI coding sessions. Captures hook events from Claude Code
|
|
3
|
+
Analytics for AI coding sessions. Captures hook events from Claude Code, Cursor,
|
|
4
|
+
and Codex, normalizes them to a unified format, queues locally, and ships to a
|
|
5
|
+
centralized server with a web dashboard and MCP integration.
|
|
4
6
|
|
|
5
7
|
```
|
|
6
8
|
Hook fires → capture.js → normalize → queue.jsonl → sender.js → POST /api/events → server → dashboard
|
|
@@ -19,6 +21,7 @@ This will:
|
|
|
19
21
|
2. Copy client files to `~/.ai-lens/client/`
|
|
20
22
|
3. Configure hooks in `~/.claude/settings.json` and/or `~/.cursor/hooks.json`
|
|
21
23
|
4. Start the Codex watcher for user-level and project-local Codex sessions
|
|
24
|
+
5. Register the MCP server for in-editor analytics (optional)
|
|
22
25
|
|
|
23
26
|
Re-running is safe — it updates outdated hooks and skips current ones.
|
|
24
27
|
|
|
@@ -27,19 +30,38 @@ Re-running is safe — it updates outdated hooks and skips current ones.
|
|
|
27
30
|
To write hooks into the project directory (`.cursor/hooks.json` and `.claude/settings.json`) instead of global `~/.cursor/` and `~/.claude/`, run from the project root:
|
|
28
31
|
|
|
29
32
|
```bash
|
|
30
|
-
npx
|
|
33
|
+
npx -y ai-lens init --server https://ai-lens.rantsports.com --no-mcp --project-hooks
|
|
31
34
|
```
|
|
32
35
|
|
|
33
36
|
Add `--use-repo-path` to run `capture.js` directly from the package (repo or npx cache) instead of copying to `~/.ai-lens/client/`. Useful when the repo is next to the workspace.
|
|
34
37
|
|
|
35
|
-
|
|
38
|
+
### CLI commands
|
|
36
39
|
|
|
37
|
-
|
|
40
|
+
```bash
|
|
41
|
+
npx ai-lens init # Setup wizard — detect tools, install hooks, configure MCP
|
|
42
|
+
npx ai-lens status # Run health checks and generate a diagnostic report
|
|
43
|
+
npx ai-lens remove # Remove hooks, client files, and MCP config
|
|
44
|
+
npx ai-lens version # Show installed version
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### CLI options
|
|
48
|
+
|
|
49
|
+
| Flag | Description |
|
|
50
|
+
|------|-------------|
|
|
51
|
+
| `--server URL` | Server URL (default: `http://localhost:3000`) |
|
|
52
|
+
| `--yes`, `-y` | Non-interactive mode, accept all defaults |
|
|
53
|
+
| `--no-mcp` | Skip MCP server registration |
|
|
54
|
+
| `--mcp-scope SCOPE` | MCP scope: `user` (default), `local`, or `project` |
|
|
55
|
+
| `--projects LIST` | Comma-separated project paths to monitor (default: all) |
|
|
56
|
+
| `--project-hooks` | Write hooks into project directory instead of global config |
|
|
57
|
+
| `--use-repo-path` | Run capture.js from package path instead of copying to `~/.ai-lens/client/` |
|
|
58
|
+
|
|
59
|
+
### Environment variables (client)
|
|
38
60
|
|
|
39
61
|
```bash
|
|
40
62
|
# In your shell profile (~/.zshrc, ~/.bashrc)
|
|
41
|
-
export AI_LENS_SERVER_URL=
|
|
42
|
-
export AI_LENS_PROJECTS="~/
|
|
63
|
+
export AI_LENS_SERVER_URL=https://ai-lens.rantsports.com
|
|
64
|
+
export AI_LENS_PROJECTS="~/meta/, ~/meta-cursor/" # optional, default: all
|
|
43
65
|
```
|
|
44
66
|
|
|
45
67
|
<details>
|
|
@@ -96,47 +118,94 @@ docker compose up -d
|
|
|
96
118
|
```
|
|
97
119
|
|
|
98
120
|
Starts three containers:
|
|
99
|
-
- **nginx** — reverse proxy with basic auth, port `13300`
|
|
100
|
-
- **app** — Node.js Express server with dashboard
|
|
101
|
-
- **postgres** — PostgreSQL 16 database
|
|
102
121
|
|
|
103
|
-
|
|
122
|
+
| Container | Image | Purpose |
|
|
123
|
+
|-----------|-------|---------|
|
|
124
|
+
| **app** | `ai-lens/app` | API server + web dashboard (port 3000) |
|
|
125
|
+
| **postgres** | `postgres:16-alpine` | PostgreSQL 16 database |
|
|
126
|
+
| **analyzer** | `ai-lens/analyzer` | Background session analyzer (needs `claude login`) |
|
|
127
|
+
|
|
128
|
+
Dashboard: https://ai-lens.rantsports.com
|
|
104
129
|
|
|
105
|
-
|
|
130
|
+
Images are stored in ECR (`267996409571.dkr.ecr.eu-north-1.amazonaws.com/ai-lens/`) and mirrored to GHCR (`ghcr.io/r-ms/ai-lens/`) on every push to `main`.
|
|
106
131
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
|
110
|
-
|
|
132
|
+
### Environment variables (server)
|
|
133
|
+
|
|
134
|
+
| Variable | Default | Description |
|
|
135
|
+
|----------|---------|-------------|
|
|
136
|
+
| `PORT` | `3000` | Server port |
|
|
137
|
+
| `DATABASE_URL` | _(required)_ | PostgreSQL connection string |
|
|
138
|
+
| `POSTGRES_PASSWORD` | `ailens` | PostgreSQL password (docker compose) |
|
|
139
|
+
| `ANALYSIS_INTERVAL` | `3600` | Seconds between analysis runs |
|
|
140
|
+
| `AI_LENS_ADMIN_SECRET` | _(none)_ | Admin secret for auth token management |
|
|
141
|
+
| `OPENAI_API_KEY` | _(none)_ | OpenAI API key for FAISS vector search; text search works without it |
|
|
142
|
+
| `TEAMS_CONFIG` | _(none)_ | JSON config for team definitions |
|
|
143
|
+
| `CORS_ALLOWED_ORIGINS` | _(none)_ | Allowed CORS origins |
|
|
144
|
+
|
|
145
|
+
#### Auth0 SSO
|
|
146
|
+
|
|
147
|
+
| Variable | Description |
|
|
148
|
+
|----------|-------------|
|
|
149
|
+
| `AUTH0_DOMAIN` | Auth0 tenant domain |
|
|
150
|
+
| `AUTH0_CLIENT_ID` | Auth0 SPA client ID |
|
|
151
|
+
| `AUTH0_AUDIENCE` | Auth0 API audience identifier |
|
|
152
|
+
| `AUTH0_ALLOWED_DOMAIN` | Restrict login to a specific email domain |
|
|
153
|
+
| `AUTH0_CLI_CLIENT_ID` | Auth0 Native app client ID for device code flow |
|
|
154
|
+
| `MCP_SERVER_URL` | Public server URL for MCP OAuth callbacks |
|
|
155
|
+
|
|
156
|
+
Without Auth0, the server uses git email headers for identity (personal mode).
|
|
111
157
|
|
|
112
158
|
### Local development
|
|
113
159
|
|
|
114
160
|
```bash
|
|
115
161
|
docker compose up postgres -d
|
|
116
|
-
npm install
|
|
162
|
+
npm install
|
|
117
163
|
DATABASE_URL=postgresql://ailens:ailens@localhost:5432/ailens npm start
|
|
118
164
|
```
|
|
119
165
|
|
|
120
|
-
The server now requires PostgreSQL via `DATABASE_URL`. The old SQLite fallback is no longer supported.
|
|
121
|
-
|
|
122
166
|
## Dashboard
|
|
123
167
|
|
|
124
|
-
React + TypeScript SPA with
|
|
168
|
+
React + TypeScript SPA with:
|
|
169
|
+
- Organization-wide KPIs and adoption trends
|
|
170
|
+
- Team and developer breakdowns
|
|
171
|
+
- Session timelines with tool usage
|
|
172
|
+
- AI-generated session and team analyses
|
|
173
|
+
- Token usage by model
|
|
174
|
+
- MCP server and skill distribution
|
|
175
|
+
- Knowledge base and recurring problems
|
|
125
176
|
|
|
126
177
|
```bash
|
|
127
178
|
cd dashboard
|
|
128
179
|
npm install
|
|
129
180
|
npm run dev # Vite dev server with HMR (proxies API to localhost:3000)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
Production build (served by Express as static files):
|
|
133
|
-
|
|
134
|
-
```bash
|
|
135
|
-
npm run build:dashboard
|
|
181
|
+
npm run build # Production build (served by Express as static files)
|
|
136
182
|
```
|
|
137
183
|
|
|
138
184
|
Tech: Vite, Tailwind CSS, Nivo charts, TanStack Query, react-router-dom.
|
|
139
185
|
|
|
186
|
+
## MCP Tools
|
|
187
|
+
|
|
188
|
+
When MCP is enabled during `npx ai-lens init`, these tools become available inside Claude Code and Cursor:
|
|
189
|
+
|
|
190
|
+
| Tool | Description |
|
|
191
|
+
|------|-------------|
|
|
192
|
+
| `who_am_i` | Identify yourself by git email — returns your developer profile and team(s) |
|
|
193
|
+
| `get_overview` | Organization-wide KPIs: active developers, adoption rate, AI hours, MCP and skill distribution |
|
|
194
|
+
| `list_teams` | List all teams with member counts, adoption rate, and AI hours |
|
|
195
|
+
| `get_team` | Team detail: KPIs, members, tasks, activity trend, MCP and skill distribution |
|
|
196
|
+
| `get_team_analysis` | AI-generated team analysis: achievements, recurring problems, recommendations |
|
|
197
|
+
| `get_developer` | Developer profile: sessions, AI hours, tasks, MCP and skill usage, team comparison |
|
|
198
|
+
| `get_mcp_distribution` | MCP server usage across the organization |
|
|
199
|
+
| `get_chain` | Session chain with compact event timeline, plan mode segments, and timing |
|
|
200
|
+
| `get_events` | Full event data for specific event IDs |
|
|
201
|
+
| `get_chain_analysis` | AI-generated chain analysis: tasks, problems, tool errors, unanswered questions |
|
|
202
|
+
| `request_analysis` | Manually trigger analysis for a specific session chain |
|
|
203
|
+
| `get_token_usage` | Token usage statistics grouped by model (input/output/cache tokens) |
|
|
204
|
+
| `knowhow_search` | Search the team knowledge base built from session analyses |
|
|
205
|
+
| `knowhow_update` | Add or update a knowledge base entry |
|
|
206
|
+
| `export_developer_tips` | Export personalized tips as a Markdown document |
|
|
207
|
+
| `search` | Natural language search across sessions, tasks, and projects |
|
|
208
|
+
|
|
140
209
|
## API
|
|
141
210
|
|
|
142
211
|
### `POST /api/events`
|
|
@@ -144,7 +213,7 @@ Tech: Vite, Tailwind CSS, Nivo charts, TanStack Query, react-router-dom.
|
|
|
144
213
|
Batch insert events. Deduplicates by `event_id` (ON CONFLICT DO NOTHING) — safe to re-send.
|
|
145
214
|
|
|
146
215
|
```
|
|
147
|
-
Headers: X-Developer-Git-Email, X-Developer-Name,
|
|
216
|
+
Headers: X-Developer-Git-Email, X-Developer-Name, X-Auth-Token
|
|
148
217
|
Body: [{ source, session_id, type, project_path, timestamp, data, raw, event_id }]
|
|
149
218
|
Response: { received, skipped, deduplicated }
|
|
150
219
|
```
|
|
@@ -163,38 +232,36 @@ List all developers.
|
|
|
163
232
|
|
|
164
233
|
### `GET /api/dashboard/*`
|
|
165
234
|
|
|
166
|
-
Aggregate endpoints for dashboard charts
|
|
235
|
+
Aggregate endpoints for dashboard charts: overview, teams, developers, tokens, MCP distribution, developer activity, knowledge base, problems, company/team/developer analyses.
|
|
167
236
|
|
|
168
237
|
## Event Types
|
|
169
238
|
|
|
170
239
|
| Type | Source | Description |
|
|
171
240
|
|------|--------|-------------|
|
|
172
|
-
| `SessionStart` |
|
|
173
|
-
| `SessionEnd` |
|
|
174
|
-
| `UserPromptSubmit` |
|
|
175
|
-
| `PostToolUse` |
|
|
176
|
-
| `PostToolUseFailure` |
|
|
177
|
-
| `Stop` |
|
|
178
|
-
| `PreCompact` |
|
|
241
|
+
| `SessionStart` | All | Session opened |
|
|
242
|
+
| `SessionEnd` | All | Session closed |
|
|
243
|
+
| `UserPromptSubmit` | All | User sent a prompt |
|
|
244
|
+
| `PostToolUse` | All | Tool execution completed |
|
|
245
|
+
| `PostToolUseFailure` | All | Tool execution failed |
|
|
246
|
+
| `Stop` | All | Agent stopped |
|
|
247
|
+
| `PreCompact` | All | Context compaction triggered |
|
|
179
248
|
| `PlanModeStart` | Claude Code | Entered plan mode |
|
|
180
249
|
| `PlanModeEnd` | Claude Code | Exited plan mode (plan content in raw payload) |
|
|
181
|
-
| `SubagentStart` |
|
|
182
|
-
| `SubagentStop` |
|
|
250
|
+
| `SubagentStart` | All | Subagent spawned |
|
|
251
|
+
| `SubagentStop` | All | Subagent finished |
|
|
183
252
|
| `FileEdit` | Cursor | File edited |
|
|
184
253
|
| `ShellExecution` | Cursor | Shell command executed |
|
|
185
254
|
| `MCPExecution` | Cursor | MCP tool executed |
|
|
186
|
-
| `AgentResponse` | Cursor | Agent response |
|
|
255
|
+
| `AgentResponse` | Cursor | Agent response (includes token usage in raw payload) |
|
|
187
256
|
| `AgentThought` | Cursor | Agent reasoning |
|
|
188
257
|
|
|
189
|
-
##
|
|
258
|
+
## Supported Tools
|
|
190
259
|
|
|
191
|
-
|
|
|
192
|
-
|
|
193
|
-
|
|
|
194
|
-
|
|
|
195
|
-
|
|
|
196
|
-
| `AI_LENS_AUTH_TOKEN` | `collector:secret-collector-token-2026-ai-lens` | Client auth (`user:password`) |
|
|
197
|
-
| `AI_LENS_PROJECTS` | _(all)_ | Comma-separated project paths to monitor (`~` supported) |
|
|
260
|
+
| Tool | Hook mechanism |
|
|
261
|
+
|------|---------------|
|
|
262
|
+
| **Claude Code** | Hooks via `~/.claude/settings.json` |
|
|
263
|
+
| **Cursor** | Hooks via `~/.cursor/hooks.json` |
|
|
264
|
+
| **Codex** | File watcher on `~/.codex` and project-local `.codex` directories |
|
|
198
265
|
|
|
199
266
|
## Client Data
|
|
200
267
|
|
|
@@ -203,41 +270,35 @@ Stored in `~/.ai-lens/`:
|
|
|
203
270
|
| File | Purpose |
|
|
204
271
|
|------|---------|
|
|
205
272
|
| `client/` | Installed client files (capture.js, sender.js, config.js) |
|
|
273
|
+
| `config.json` | Server URL, auth token, project list |
|
|
206
274
|
| `queue.jsonl` | Pending events |
|
|
207
275
|
| `queue.sending.jsonl` | Events being sent (atomic rename as mutex) |
|
|
208
276
|
| `sender.log` | Sender activity log |
|
|
277
|
+
| `capture.log` | Capture drop log (normalization failures, write errors) |
|
|
209
278
|
| `session-paths.json` | Session-to-project path cache |
|
|
210
279
|
|
|
211
280
|
## Development
|
|
212
281
|
|
|
213
282
|
```bash
|
|
214
|
-
npm test # Run all tests (vitest,
|
|
283
|
+
npm test # Run all tests (vitest, 683 tests)
|
|
215
284
|
npm run test:watch # Watch mode
|
|
216
285
|
npm run dev:dashboard # Dashboard dev server
|
|
217
286
|
```
|
|
218
287
|
|
|
219
|
-
Tests use
|
|
288
|
+
Tests require PostgreSQL — set `DATABASE_URL` or use `docker compose up postgres -d` (test DB `ailens_test` is created automatically).
|
|
220
289
|
|
|
221
290
|
## Deployment
|
|
222
291
|
|
|
223
292
|
GitLab CI (`.gitlab-ci.yml`) on push to `main`:
|
|
224
293
|
|
|
225
|
-
1. `
|
|
226
|
-
2. `
|
|
227
|
-
3.
|
|
228
|
-
|
|
229
|
-
## Data Migration
|
|
230
|
-
|
|
231
|
-
Sync local SQLite data to a remote PostgreSQL server:
|
|
232
|
-
|
|
233
|
-
```bash
|
|
234
|
-
node scripts/sync-to-remote.js # Default remote
|
|
235
|
-
node scripts/sync-to-remote.js http://custom:13300 # Custom URL
|
|
236
|
-
```
|
|
294
|
+
1. **build-app** — builds `ai-lens/app` Docker image, pushes to ECR + GHCR
|
|
295
|
+
2. **build-analyzer** — builds `ai-lens/analyzer` Docker image, pushes to ECR + GHCR
|
|
296
|
+
3. **deploy** — zero-downtime rolling deploy to production (scale up new container, health check, remove old)
|
|
237
297
|
|
|
238
|
-
|
|
298
|
+
Build jobs trigger only when relevant files change (Dockerfile, server/**, dashboard/**, etc.).
|
|
239
299
|
|
|
240
300
|
## Requirements
|
|
241
301
|
|
|
242
302
|
- Node.js 20+
|
|
243
303
|
- Docker + Docker Compose (for production deployment)
|
|
304
|
+
- PostgreSQL 16 (for local development without Docker)
|
package/cli/hooks.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync, unlinkSync, chmodSync } from 'node:fs';
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync, unlinkSync, chmodSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
@@ -145,7 +145,16 @@ export function cursorCaptureCommand(useTilde = false, customPath = null) {
|
|
|
145
145
|
// Client file installation
|
|
146
146
|
// ---------------------------------------------------------------------------
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Enumerate every .js file that ships with the package's client/ directory.
|
|
150
|
+
* Dynamic discovery (rather than a hardcoded CLIENT_FILES list) guarantees
|
|
151
|
+
* that any new client/*.js file added to the repo is automatically installed,
|
|
152
|
+
* so a forgotten list entry can never produce a broken install that crashes
|
|
153
|
+
* every hook with ERR_MODULE_NOT_FOUND on a missing sibling import.
|
|
154
|
+
*/
|
|
155
|
+
export function listClientFiles(sourceDir = join(__dirname, '..', 'client')) {
|
|
156
|
+
return readdirSync(sourceDir).filter(f => f.endsWith('.js')).sort();
|
|
157
|
+
}
|
|
149
158
|
|
|
150
159
|
/**
|
|
151
160
|
* Copy client/ files from the package source to ~/.ai-lens/client/.
|
|
@@ -155,7 +164,7 @@ export function installClientFiles() {
|
|
|
155
164
|
const sourceDir = join(__dirname, '..', 'client');
|
|
156
165
|
mkdirSync(CLIENT_INSTALL_DIR, { recursive: true });
|
|
157
166
|
|
|
158
|
-
for (const file of
|
|
167
|
+
for (const file of listClientFiles(sourceDir)) {
|
|
159
168
|
copyFileSync(join(sourceDir, file), join(CLIENT_INSTALL_DIR, file));
|
|
160
169
|
}
|
|
161
170
|
|
package/cli/init.js
CHANGED
|
@@ -67,7 +67,7 @@ function getJson(url) {
|
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function postJson(url, body) {
|
|
70
|
+
function postJson(url, body, timeoutMs = 15_000) {
|
|
71
71
|
return new Promise((resolve, reject) => {
|
|
72
72
|
const parsed = new URL(url);
|
|
73
73
|
const isHttps = parsed.protocol === 'https:';
|
|
@@ -82,7 +82,7 @@ function postJson(url, body) {
|
|
|
82
82
|
'Content-Type': 'application/json',
|
|
83
83
|
'Content-Length': Buffer.byteLength(data),
|
|
84
84
|
},
|
|
85
|
-
timeout:
|
|
85
|
+
timeout: timeoutMs,
|
|
86
86
|
};
|
|
87
87
|
const req = requestFn(options, (res) => {
|
|
88
88
|
let buf = '';
|
|
@@ -107,7 +107,7 @@ function postJson(url, body) {
|
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
function postForm(url, params) {
|
|
110
|
+
function postForm(url, params, timeoutMs = 15_000) {
|
|
111
111
|
return new Promise((resolve, reject) => {
|
|
112
112
|
const parsed = new URL(url);
|
|
113
113
|
const isHttps = parsed.protocol === 'https:';
|
|
@@ -122,7 +122,7 @@ function postForm(url, params) {
|
|
|
122
122
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
123
123
|
'Content-Length': Buffer.byteLength(data),
|
|
124
124
|
},
|
|
125
|
-
timeout:
|
|
125
|
+
timeout: timeoutMs,
|
|
126
126
|
};
|
|
127
127
|
const req = requestFn(options, (res) => {
|
|
128
128
|
let buf = '';
|
|
@@ -146,6 +146,26 @@ function sleep(ms) {
|
|
|
146
146
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
function isTransientNetError(err) {
|
|
150
|
+
const msg = `${err && err.code ? err.code : ''} ${err && err.message ? err.message : err}`;
|
|
151
|
+
return /ECONNRESET|EPIPE|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ECONNREFUSED|socket hang up/i.test(String(msg));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Retry fn on flaky TLS/proxy resets (common during long Auth0 device polls). */
|
|
155
|
+
async function withRetries(fn, { attempts = 5, baseDelayMs = 2000 } = {}) {
|
|
156
|
+
let lastErr;
|
|
157
|
+
for (let i = 1; i <= attempts; i++) {
|
|
158
|
+
try {
|
|
159
|
+
return await fn();
|
|
160
|
+
} catch (err) {
|
|
161
|
+
lastErr = err;
|
|
162
|
+
if (!isTransientNetError(err) || i === attempts) throw err;
|
|
163
|
+
await sleep(baseDelayMs * i);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
throw lastErr;
|
|
167
|
+
}
|
|
168
|
+
|
|
149
169
|
function startCodexWatcher(watcherPath) {
|
|
150
170
|
if (!existsSync(watcherPath)) return false;
|
|
151
171
|
if (process.env.AI_LENS_TEST_NO_DETACHED_SPAWN === '1') return true;
|
|
@@ -219,17 +239,24 @@ async function deviceCodeAuth(serverUrl) {
|
|
|
219
239
|
while (Date.now() < deadline) {
|
|
220
240
|
await sleep(interval * 1000);
|
|
221
241
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
242
|
+
// Auth0 may keep this request open longer than a typical HTTP call; 15s caused false "Request timed out".
|
|
243
|
+
const tokenResp = await withRetries(
|
|
244
|
+
() => postForm(`https://${domain}/oauth/token`, {
|
|
245
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
246
|
+
client_id: cliClientId,
|
|
247
|
+
device_code,
|
|
248
|
+
}, 120_000),
|
|
249
|
+
{ attempts: 6, baseDelayMs: 2000 },
|
|
250
|
+
);
|
|
227
251
|
|
|
228
252
|
if (tokenResp.status === 200 && tokenResp.data.id_token) {
|
|
229
253
|
// 5. Exchange JWT for personal token
|
|
230
|
-
const result = await
|
|
231
|
-
|
|
232
|
-
|
|
254
|
+
const result = await withRetries(
|
|
255
|
+
() => postJson(`${serverUrl}/api/auth/device-token`, {
|
|
256
|
+
jwt: tokenResp.data.id_token,
|
|
257
|
+
}, 120_000),
|
|
258
|
+
{ attempts: 5, baseDelayMs: 2000 },
|
|
259
|
+
);
|
|
233
260
|
if (!result?.token) throw new Error('Server returned no token — contact your admin');
|
|
234
261
|
return result;
|
|
235
262
|
}
|
|
@@ -510,10 +537,11 @@ export default async function init() {
|
|
|
510
537
|
try {
|
|
511
538
|
authResult = await deviceCodeAuth(serverUrl);
|
|
512
539
|
} catch (err) {
|
|
513
|
-
|
|
540
|
+
const msg = (err && err.message) ? err.message : String(err);
|
|
541
|
+
if (msg.includes('not configured')) {
|
|
514
542
|
warn(` Auth not configured on server — personal mode (events sent via git identity)`);
|
|
515
543
|
} else {
|
|
516
|
-
warn(` Authentication failed: ${
|
|
544
|
+
warn(` Authentication failed: ${msg}`);
|
|
517
545
|
warn(` Run "npx -y ai-lens init" again later to authenticate`);
|
|
518
546
|
}
|
|
519
547
|
}
|
package/client/capture.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* normalizes to unified event format, appends to queue, spawns sender if needed.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, renameSync, realpathSync } from 'node:fs';
|
|
10
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, renameSync, realpathSync, statSync, openSync, readSync, closeSync, readdirSync } from 'node:fs';
|
|
11
11
|
import { join, dirname } from 'node:path';
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
SENDING_DIR,
|
|
20
20
|
DEDUP_DIR,
|
|
21
21
|
SESSION_PATHS_DIR,
|
|
22
|
+
TRANSCRIPT_OFFSETS_DIR,
|
|
22
23
|
CAPTURE_LOG_PATH,
|
|
23
24
|
LOG_MAX_AGE_DAYS,
|
|
24
25
|
SENDER_BACKOFF_PATH,
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
isCodexEnabled,
|
|
32
33
|
} from './config.js';
|
|
33
34
|
import { isLockStale, isSenderBackoffActive } from './sender.js';
|
|
35
|
+
import { toNumberOrNull, buildTokenUsageRaw } from './token-usage.js';
|
|
34
36
|
// Soft import — redact.js may not exist on older client installs
|
|
35
37
|
let redactObject = (o) => o;
|
|
36
38
|
try {
|
|
@@ -154,6 +156,287 @@ function truncateToolResult(result, toolName) {
|
|
|
154
156
|
return result;
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Read the persisted byte offset + size + mtime for a transcript file.
|
|
161
|
+
* Returns null on any error (caller treats as "start from 0").
|
|
162
|
+
*/
|
|
163
|
+
function readTranscriptOffsetState(offsetFile) {
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(readFileSync(offsetFile, 'utf-8'));
|
|
166
|
+
if (
|
|
167
|
+
parsed &&
|
|
168
|
+
typeof parsed.offset === 'number' &&
|
|
169
|
+
typeof parsed.size === 'number' &&
|
|
170
|
+
typeof parsed.mtime_ms === 'number'
|
|
171
|
+
) {
|
|
172
|
+
return parsed;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Atomically persist the byte offset + size + mtime for a transcript file.
|
|
182
|
+
* Mirrors the tmp+rename pattern used by cacheSessionPath.
|
|
183
|
+
*/
|
|
184
|
+
function writeTranscriptOffsetState(offsetFile, state) {
|
|
185
|
+
ensureDataDir();
|
|
186
|
+
const tmp = offsetFile + '.tmp.' + process.pid;
|
|
187
|
+
try {
|
|
188
|
+
writeFileSync(tmp, JSON.stringify(state));
|
|
189
|
+
try {
|
|
190
|
+
renameSync(tmp, offsetFile);
|
|
191
|
+
} catch {
|
|
192
|
+
try { unlinkSync(tmp); } catch {}
|
|
193
|
+
}
|
|
194
|
+
} catch { /* best-effort: a missed offset write means we re-read one Stop worth */ }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Run stale-offset cleanup at most once per 24h per machine. Each unique
|
|
198
|
+
// transcript path produces a persistent offset file; without this pass the
|
|
199
|
+
// directory grows unbounded over months. We rate-limit via the mtime of a
|
|
200
|
+
// marker file so the common path of every Stop hook is a single statSync.
|
|
201
|
+
const TRANSCRIPT_OFFSETS_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
202
|
+
|
|
203
|
+
function maybeCleanStaleTranscriptOffsets() {
|
|
204
|
+
const marker = join(TRANSCRIPT_OFFSETS_DIR, '.last_cleanup');
|
|
205
|
+
let shouldRun = true;
|
|
206
|
+
try {
|
|
207
|
+
const st = statSync(marker);
|
|
208
|
+
if (Date.now() - st.mtimeMs < TRANSCRIPT_OFFSETS_CLEANUP_INTERVAL_MS) {
|
|
209
|
+
shouldRun = false;
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
// Marker missing — first run on this machine, proceed.
|
|
213
|
+
}
|
|
214
|
+
if (!shouldRun) return;
|
|
215
|
+
|
|
216
|
+
// Touch the marker FIRST so concurrent Stop hooks within the same second
|
|
217
|
+
// don't both kick off the readdir scan. ensureDataDir() in case the dir
|
|
218
|
+
// doesn't exist yet on a fresh install.
|
|
219
|
+
ensureDataDir();
|
|
220
|
+
try {
|
|
221
|
+
writeFileSync(marker, '');
|
|
222
|
+
} catch {
|
|
223
|
+
// If we can't write the marker we still try the scan, but the rate limit
|
|
224
|
+
// won't take effect — which is acceptable best-effort behavior.
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let entries = [];
|
|
228
|
+
try {
|
|
229
|
+
entries = readdirSync(TRANSCRIPT_OFFSETS_DIR);
|
|
230
|
+
} catch {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const name of entries) {
|
|
235
|
+
if (name.startsWith('.')) continue; // skip .last_cleanup and other dotfiles
|
|
236
|
+
let originalPath;
|
|
237
|
+
try {
|
|
238
|
+
originalPath = decodeURIComponent(name);
|
|
239
|
+
} catch {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
if (!existsSync(originalPath)) {
|
|
244
|
+
unlinkSync(join(TRANSCRIPT_OFFSETS_DIR, name));
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
// Ignore per-entry errors so one stuck file doesn't block the pass.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Incrementally read only the NEW bytes appended to a Claude Code transcript
|
|
254
|
+
* since the last time this function was called for that transcript, and
|
|
255
|
+
* return ONE entry per real (non-synthetic) assistant API call in the delta.
|
|
256
|
+
*
|
|
257
|
+
* Each entry corresponds to a single Anthropic API call: a single assistant
|
|
258
|
+
* line in the JSONL with its own usage record. A single user->agent turn can
|
|
259
|
+
* produce many of these (tool-use loops), and the caller is expected to emit
|
|
260
|
+
* one unified TokenUsage event per entry — symmetric with Cursor's per-call
|
|
261
|
+
* AgentResponse rows and Codex's per-call TokenUsage rows.
|
|
262
|
+
*
|
|
263
|
+
* Returns { calls, commitOffset }:
|
|
264
|
+
* - `calls` is Array<{usage, model, timestamp, uuid}>:
|
|
265
|
+
* • `usage` has Anthropic-named keys (input_tokens, output_tokens,
|
|
266
|
+
* cache_read_input_tokens, cache_creation_input_tokens) for
|
|
267
|
+
* buildTokenUsageRaw.
|
|
268
|
+
* • `model` is the model from that specific assistant line.
|
|
269
|
+
* • `timestamp` is the assistant line's own ISO timestamp if present, else
|
|
270
|
+
* null (caller falls back to the Stop event's timestamp).
|
|
271
|
+
* • `uuid` is the assistant line's `uuid` field (Claude Code transcripts
|
|
272
|
+
* always include one). Used by the caller to derive a stable event_id
|
|
273
|
+
* so concurrent Stop hook invocations can't double-emit a single API call.
|
|
274
|
+
* - `commitOffset` is a no-arg function the caller MUST invoke ONLY after
|
|
275
|
+
* successfully writing every TokenUsage event derived from `calls` into
|
|
276
|
+
* the spool. If spool writes fail (or the caller never calls it), the
|
|
277
|
+
* transcript cursor stays put and the next Stop re-reads the same delta —
|
|
278
|
+
* the server-side ON CONFLICT on the deterministic event_id then dedups
|
|
279
|
+
* whichever rows did land. This is what makes the pipeline crash-safe:
|
|
280
|
+
* we never mark transcript bytes "consumed" until we know the rows they
|
|
281
|
+
* produced are durably queued.
|
|
282
|
+
*
|
|
283
|
+
* Returns an empty `calls` array (and a no-op `commitOffset`) if nothing new
|
|
284
|
+
* in the delta or no real assistant lines are found.
|
|
285
|
+
*
|
|
286
|
+
* Partial-line handling: a Stop hook can fire while Claude Code is mid-write,
|
|
287
|
+
* so the delta may end before a trailing '\n'. We detect this, ignore the
|
|
288
|
+
* partial tail, and persist the offset at the byte right after the LAST
|
|
289
|
+
* complete '\n' so the next Stop re-reads the partial line once it's finished.
|
|
290
|
+
*/
|
|
291
|
+
function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
292
|
+
// Run the rate-limited stale-offset cleanup at most once per 24h. Wrapped in
|
|
293
|
+
// try/catch so a cleanup failure never prevents the token read.
|
|
294
|
+
try { maybeCleanStaleTranscriptOffsets(); } catch { /* best-effort */ }
|
|
295
|
+
|
|
296
|
+
// Default: no-op commit. Callers always receive a function so they never
|
|
297
|
+
// have to null-check before invoking.
|
|
298
|
+
const noopCommit = () => {};
|
|
299
|
+
|
|
300
|
+
if (!transcriptPath || typeof transcriptPath !== 'string') {
|
|
301
|
+
return { calls: [], commitOffset: noopCommit };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const offsetFile = join(TRANSCRIPT_OFFSETS_DIR, encodeURIComponent(transcriptPath));
|
|
305
|
+
|
|
306
|
+
let currentSize = 0;
|
|
307
|
+
let currentMtime = 0;
|
|
308
|
+
try {
|
|
309
|
+
const st = statSync(transcriptPath);
|
|
310
|
+
currentSize = st.size;
|
|
311
|
+
currentMtime = st.mtimeMs;
|
|
312
|
+
} catch (err) {
|
|
313
|
+
captureLog({ msg: 'transcript-offset-error', stage: 'stat', path: transcriptPath, error: err?.message });
|
|
314
|
+
return { calls: [], commitOffset: noopCommit };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const savedState = readTranscriptOffsetState(offsetFile);
|
|
318
|
+
let startOffset = 0;
|
|
319
|
+
if (savedState) {
|
|
320
|
+
// Detect rotation/truncation: file shrank below our saved cursor, or file
|
|
321
|
+
// didn't grow but the mtime changed (content rewrite in place).
|
|
322
|
+
if (currentSize < savedState.offset) {
|
|
323
|
+
startOffset = 0;
|
|
324
|
+
} else if (currentSize === savedState.offset && currentMtime !== savedState.mtime_ms) {
|
|
325
|
+
startOffset = 0;
|
|
326
|
+
} else {
|
|
327
|
+
startOffset = savedState.offset;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Nothing new — just a stat, super cheap.
|
|
332
|
+
if (currentSize === startOffset) {
|
|
333
|
+
return { calls: [], commitOffset: noopCommit };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let buffer = null;
|
|
337
|
+
try {
|
|
338
|
+
const length = currentSize - startOffset;
|
|
339
|
+
const fd = openSync(transcriptPath, 'r');
|
|
340
|
+
try {
|
|
341
|
+
buffer = Buffer.alloc(length);
|
|
342
|
+
readSync(fd, buffer, 0, length, startOffset);
|
|
343
|
+
} finally {
|
|
344
|
+
closeSync(fd);
|
|
345
|
+
}
|
|
346
|
+
} catch (err) {
|
|
347
|
+
captureLog({ msg: 'transcript-offset-error', stage: 'read', path: transcriptPath, error: err?.message });
|
|
348
|
+
// Read failed before we got any bytes — leave the cursor unchanged so the
|
|
349
|
+
// next Stop retries. (Old behavior advanced past the unread bytes; that
|
|
350
|
+
// silently dropped data on transient I/O errors.)
|
|
351
|
+
return { calls: [], commitOffset: noopCommit };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (!buffer || buffer.length === 0) {
|
|
355
|
+
// Defensive: shouldn't happen because of the currentSize === startOffset
|
|
356
|
+
// early return above. Don't advance the cursor.
|
|
357
|
+
return { calls: [], commitOffset: noopCommit };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Find the LAST complete newline. CRITICAL: search the BYTE buffer, not a
|
|
361
|
+
// decoded string — string indices use UTF-16 code units, but file offsets
|
|
362
|
+
// are byte-based. Mixing the two corrupts startOffset on non-ASCII content
|
|
363
|
+
// (Russian transcripts, emoji, etc.) and causes lines to be replayed on the
|
|
364
|
+
// next Stop, double-counting tokens.
|
|
365
|
+
//
|
|
366
|
+
// 0x0A is the byte value of '\n' (always single-byte in UTF-8 — '\n' is
|
|
367
|
+
// ASCII, so this byte never appears inside a multi-byte sequence).
|
|
368
|
+
const lastNewlineIndex = buffer.lastIndexOf(0x0A);
|
|
369
|
+
if (lastNewlineIndex === -1) {
|
|
370
|
+
// The entire chunk is a partial trailing line — leave the cursor unchanged
|
|
371
|
+
// so the next Stop re-reads the line once it's finished.
|
|
372
|
+
return { calls: [], commitOffset: noopCommit };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Process only the bytes up to (and including) the last '\n'. The new
|
|
376
|
+
// persisted offset points at the byte AFTER that newline so any partial
|
|
377
|
+
// trailing line is replayed on the next Stop. Decode just the processable
|
|
378
|
+
// slice as UTF-8 once for line iteration.
|
|
379
|
+
const processable = buffer.slice(0, lastNewlineIndex).toString('utf-8');
|
|
380
|
+
const newOffset = startOffset + lastNewlineIndex + 1;
|
|
381
|
+
const nextState = { offset: newOffset, size: currentSize, mtime_ms: currentMtime };
|
|
382
|
+
const commitOffset = () => writeTranscriptOffsetState(offsetFile, nextState);
|
|
383
|
+
|
|
384
|
+
// Iterate lines in the processable region. Defensive parse-tolerance is
|
|
385
|
+
// still useful for the rare case where a single line in the middle is
|
|
386
|
+
// malformed (we skip it rather than dropping the whole delta).
|
|
387
|
+
const calls = [];
|
|
388
|
+
try {
|
|
389
|
+
const lines = processable.split('\n');
|
|
390
|
+
for (const rawLine of lines) {
|
|
391
|
+
const line = rawLine && rawLine.trim();
|
|
392
|
+
if (!line) continue;
|
|
393
|
+
let parsed;
|
|
394
|
+
try {
|
|
395
|
+
parsed = JSON.parse(line);
|
|
396
|
+
} catch {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (parsed?.type !== 'assistant') continue;
|
|
400
|
+
const message = parsed?.message;
|
|
401
|
+
if (!message || typeof message !== 'object') continue;
|
|
402
|
+
const model = message.model;
|
|
403
|
+
// Skip <synthetic> internal placeholder entries — they carry all-zero
|
|
404
|
+
// usage and would not show up on billing.
|
|
405
|
+
if (!model || model === '<synthetic>') continue;
|
|
406
|
+
const usage = message.usage;
|
|
407
|
+
if (!usage || typeof usage !== 'object') continue;
|
|
408
|
+
// Capture each call as its own entry — the caller emits one unified
|
|
409
|
+
// TokenUsage event per entry, matching Cursor/Codex per-call granularity.
|
|
410
|
+
calls.push({
|
|
411
|
+
usage: {
|
|
412
|
+
input_tokens: toNumberOrNull(usage.input_tokens) ?? 0,
|
|
413
|
+
output_tokens: toNumberOrNull(usage.output_tokens) ?? 0,
|
|
414
|
+
cache_read_input_tokens: toNumberOrNull(usage.cache_read_input_tokens) ?? 0,
|
|
415
|
+
cache_creation_input_tokens: toNumberOrNull(usage.cache_creation_input_tokens) ?? 0,
|
|
416
|
+
},
|
|
417
|
+
model,
|
|
418
|
+
timestamp: typeof parsed.timestamp === 'string' ? parsed.timestamp : null,
|
|
419
|
+
uuid: typeof parsed.uuid === 'string' ? parsed.uuid : null,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
} catch (err) {
|
|
423
|
+
captureLog({ msg: 'transcript-offset-error', stage: 'parse', path: transcriptPath, error: err?.message });
|
|
424
|
+
// On a parse-loop exception, advance the cursor to the last complete
|
|
425
|
+
// newline so we don't spin re-reading the same bytes. There are no calls
|
|
426
|
+
// to spool in this path, so it's safe to commit immediately (no risk of
|
|
427
|
+
// losing events).
|
|
428
|
+
commitOffset();
|
|
429
|
+
return { calls: [], commitOffset: noopCommit };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// IMPORTANT: do NOT persist the offset here. The caller is responsible for
|
|
433
|
+
// invoking `commitOffset` only AFTER it has successfully written every
|
|
434
|
+
// TokenUsage event derived from `calls` to the spool. Advancing the cursor
|
|
435
|
+
// eagerly (the previous behavior) would silently drop rows whenever a
|
|
436
|
+
// writeToSpool() failed after we'd already "consumed" the transcript bytes.
|
|
437
|
+
return { calls, commitOffset };
|
|
438
|
+
}
|
|
439
|
+
|
|
157
440
|
// =============================================================================
|
|
158
441
|
// Source Detection
|
|
159
442
|
// =============================================================================
|
|
@@ -315,6 +598,19 @@ const PLAN_MODE_TOOLS = {
|
|
|
315
598
|
ExitPlanMode: 'PlanModeEnd',
|
|
316
599
|
};
|
|
317
600
|
|
|
601
|
+
/**
|
|
602
|
+
* Normalize a Claude Code hook event into one or more unified events.
|
|
603
|
+
*
|
|
604
|
+
* Returns an array — almost every hook produces a single primary event, but
|
|
605
|
+
* Stop and SubagentStop additionally read NEW assistant API calls from the
|
|
606
|
+
* transcript and emit one TokenUsage event per call (one assistant line in
|
|
607
|
+
* the transcript = one API call = one row in the events table). This keeps
|
|
608
|
+
* row granularity symmetric with Cursor (one AgentResponse per API call) and
|
|
609
|
+
* Codex (one TokenUsage per token_count record).
|
|
610
|
+
*
|
|
611
|
+
* Returns an empty array for hooks that should be silently dropped (e.g. a
|
|
612
|
+
* non-plan-mode PreToolUse).
|
|
613
|
+
*/
|
|
318
614
|
function normalizeClaudeCode(event) {
|
|
319
615
|
const sessionId = event.session_id;
|
|
320
616
|
const hookType = event.hook_event_name || event.hook;
|
|
@@ -342,7 +638,7 @@ function normalizeClaudeCode(event) {
|
|
|
342
638
|
const preToolName = event.tool_name || event.tool || 'unknown';
|
|
343
639
|
// Only capture PreToolUse for plan mode tools; skip others to avoid duplicating PostToolUse
|
|
344
640
|
if (!PLAN_MODE_TOOLS[preToolName]) {
|
|
345
|
-
return
|
|
641
|
+
return [];
|
|
346
642
|
}
|
|
347
643
|
type = PLAN_MODE_TOOLS[preToolName];
|
|
348
644
|
data = { tool: preToolName };
|
|
@@ -417,7 +713,7 @@ function normalizeClaudeCode(event) {
|
|
|
417
713
|
data.permission_mode = event.permission_mode;
|
|
418
714
|
}
|
|
419
715
|
|
|
420
|
-
|
|
716
|
+
const primary = {
|
|
421
717
|
event_id: null,
|
|
422
718
|
source: 'claude_code',
|
|
423
719
|
session_id: sessionId,
|
|
@@ -427,6 +723,46 @@ function normalizeClaudeCode(event) {
|
|
|
427
723
|
data,
|
|
428
724
|
raw: event,
|
|
429
725
|
};
|
|
726
|
+
|
|
727
|
+
// For Stop and SubagentStop, also read the transcript delta and emit one
|
|
728
|
+
// TokenUsage event per real assistant API call. This is what makes Claude
|
|
729
|
+
// Code's row-per-call granularity match Cursor and Codex.
|
|
730
|
+
//
|
|
731
|
+
// Claude Code's Stop hook passes the path as `transcript_path`, but
|
|
732
|
+
// SubagentStop uses `agent_transcript_path` (see Claude Code hook docs and
|
|
733
|
+
// test/fixtures/claude-code-events/subagent-stop.json). Check both so
|
|
734
|
+
// subagent token usage is not silently dropped in production.
|
|
735
|
+
if (hookType === 'Stop' || hookType === 'SubagentStop') {
|
|
736
|
+
const transcriptPath = event.transcript_path || event.agent_transcript_path;
|
|
737
|
+
const { calls, commitOffset } = extractNewClaudeApiCallsFromTranscript(transcriptPath);
|
|
738
|
+
const tokenEvents = calls.map(call => ({
|
|
739
|
+
event_id: null, // assigned in main() from a stable hash of call.uuid
|
|
740
|
+
source: 'claude_code',
|
|
741
|
+
session_id: sessionId,
|
|
742
|
+
type: 'TokenUsage',
|
|
743
|
+
project_path: projectPath,
|
|
744
|
+
timestamp: call.timestamp || timestamp,
|
|
745
|
+
data: {
|
|
746
|
+
model: call.model,
|
|
747
|
+
input_tokens: call.usage.input_tokens,
|
|
748
|
+
output_tokens: call.usage.output_tokens,
|
|
749
|
+
},
|
|
750
|
+
raw: buildTokenUsageRaw({ source_uuid: call.uuid }, call.usage, call.model),
|
|
751
|
+
}));
|
|
752
|
+
const result = [primary, ...tokenEvents];
|
|
753
|
+
// Attach the commit callback as a non-enumerable property on the returned
|
|
754
|
+
// array so it survives through normalizeEvent() without leaking into
|
|
755
|
+
// iteration (for...of, .map, writeToSpool's {...spread}) or into tests
|
|
756
|
+
// that assert on events.length / events[0].
|
|
757
|
+
Object.defineProperty(result, 'commitTranscriptOffset', {
|
|
758
|
+
value: commitOffset,
|
|
759
|
+
enumerable: false,
|
|
760
|
+
writable: false,
|
|
761
|
+
});
|
|
762
|
+
return result;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return [primary];
|
|
430
766
|
}
|
|
431
767
|
|
|
432
768
|
// =============================================================================
|
|
@@ -498,6 +834,7 @@ function normalizeCursor(event) {
|
|
|
498
834
|
}
|
|
499
835
|
|
|
500
836
|
let data = {};
|
|
837
|
+
let raw = event;
|
|
501
838
|
switch (hookName) {
|
|
502
839
|
case 'sessionStart':
|
|
503
840
|
data = { workspace_roots: event.workspace_roots };
|
|
@@ -565,6 +902,7 @@ function normalizeCursor(event) {
|
|
|
565
902
|
break;
|
|
566
903
|
case 'afterAgentResponse':
|
|
567
904
|
data = { text: truncate(event.text || '', TRUNCATION_LIMITS.agentResponse) };
|
|
905
|
+
raw = buildTokenUsageRaw(event, event.usage || null, event.model || null);
|
|
568
906
|
break;
|
|
569
907
|
case 'afterAgentThought':
|
|
570
908
|
data = {
|
|
@@ -595,7 +933,7 @@ function normalizeCursor(event) {
|
|
|
595
933
|
data.permission_mode = event.permission_mode;
|
|
596
934
|
}
|
|
597
935
|
|
|
598
|
-
return {
|
|
936
|
+
return [{
|
|
599
937
|
event_id: null,
|
|
600
938
|
source: 'cursor',
|
|
601
939
|
session_id: sessionId,
|
|
@@ -603,20 +941,27 @@ function normalizeCursor(event) {
|
|
|
603
941
|
project_path: projectPath,
|
|
604
942
|
timestamp,
|
|
605
943
|
data,
|
|
606
|
-
raw
|
|
607
|
-
};
|
|
944
|
+
raw,
|
|
945
|
+
}];
|
|
608
946
|
}
|
|
609
947
|
|
|
610
948
|
// =============================================================================
|
|
611
949
|
// Normalize (dispatcher)
|
|
612
950
|
// =============================================================================
|
|
613
951
|
|
|
952
|
+
/**
|
|
953
|
+
* Normalize a raw hook event into an array of unified events. Almost every
|
|
954
|
+
* hook produces a single primary event, but Claude Code Stop/SubagentStop
|
|
955
|
+
* additionally emit one TokenUsage event per API call read from the
|
|
956
|
+
* transcript delta. Returns an empty array for hooks that should be silently
|
|
957
|
+
* dropped (e.g. non-plan-mode PreToolUse) or an unrecognized source.
|
|
958
|
+
*/
|
|
614
959
|
export function normalizeEvent(event) {
|
|
615
960
|
const source = detectSource(event);
|
|
616
961
|
switch (source) {
|
|
617
962
|
case 'claude_code': return normalizeClaudeCode(event);
|
|
618
963
|
case 'cursor': return normalizeCursor(event);
|
|
619
|
-
default: return
|
|
964
|
+
default: return [];
|
|
620
965
|
}
|
|
621
966
|
}
|
|
622
967
|
|
|
@@ -740,69 +1085,135 @@ async function main() {
|
|
|
740
1085
|
process.exit(0);
|
|
741
1086
|
}
|
|
742
1087
|
|
|
743
|
-
const
|
|
744
|
-
if (!
|
|
1088
|
+
const events = normalizeEvent(event);
|
|
1089
|
+
if (!events || events.length === 0 || !events[0].session_id) {
|
|
745
1090
|
logDrop('normalize_failed', { hook: event.hook_event_name });
|
|
746
1091
|
process.exit(0);
|
|
747
1092
|
}
|
|
748
1093
|
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
|
|
1094
|
+
// The primary event (always at index 0) carries the canonical session/type
|
|
1095
|
+
// for project-filter, identity, and dedup decisions. Additional events
|
|
1096
|
+
// (Claude Code per-call TokenUsage rows) inherit the same project, identity,
|
|
1097
|
+
// and git metadata from the primary, since they all originate from the same
|
|
1098
|
+
// hook invocation on the same machine.
|
|
1099
|
+
const primary = events[0];
|
|
752
1100
|
|
|
753
|
-
// Filter by monitored projects (if configured)
|
|
1101
|
+
// Filter by monitored projects (if configured) — based on the primary event.
|
|
1102
|
+
// If the primary is filtered out, drop the entire batch (the per-call events
|
|
1103
|
+
// share the same project_path).
|
|
754
1104
|
const monitored = getMonitoredProjects();
|
|
755
|
-
let projectPath =
|
|
1105
|
+
let projectPath = primary.project_path;
|
|
756
1106
|
try { projectPath = realpathSync(projectPath); } catch {}
|
|
757
1107
|
if (monitored && projectPath && !monitored.some(p => pathContains(p, projectPath))) {
|
|
758
1108
|
// Fallback: for Cursor multi-root workspaces, check if any raw workspace_roots entry matches
|
|
759
1109
|
const roots = Array.isArray(event.workspace_roots) ? event.workspace_roots : [];
|
|
760
1110
|
const resolvedRoots = roots.map(r => { try { return realpathSync(r); } catch { return r; } });
|
|
761
1111
|
if (!resolvedRoots.some(root => monitored.some(p => pathContains(p, root)))) {
|
|
762
|
-
logDrop('project_filter', { type:
|
|
1112
|
+
logDrop('project_filter', { type: primary.type, source: primary.source, session_id: primary.session_id, project_path: primary.project_path, monitored });
|
|
763
1113
|
process.exit(0);
|
|
764
1114
|
}
|
|
765
1115
|
}
|
|
766
1116
|
|
|
767
1117
|
// Resolve identity: git first, then fall back to event payload (e.g. Cursor's user_email)
|
|
768
1118
|
// When auth token is present, server resolves developer from token — email is optional
|
|
769
|
-
const identity = getGitIdentity(
|
|
1119
|
+
const identity = getGitIdentity(primary.project_path);
|
|
770
1120
|
const token = getAuthToken();
|
|
771
1121
|
const hasAuthToken = typeof token === 'string' && token.startsWith('ailens_dev_');
|
|
772
1122
|
const resolved = resolveIdentity(identity, event, hasAuthToken);
|
|
773
1123
|
if (!resolved.proceed) {
|
|
774
|
-
logDrop('no_email', { type:
|
|
1124
|
+
logDrop('no_email', { type: primary.type, session_id: primary.session_id });
|
|
775
1125
|
process.exit(0);
|
|
776
1126
|
}
|
|
777
1127
|
|
|
778
1128
|
// Deduplicate consecutive identical event types (e.g. repeated Stop from idle sessions).
|
|
1129
|
+
// Only the PRIMARY event participates in this check; per-call TokenUsage events
|
|
1130
|
+
// have their own stable event_id (derived from the assistant line uuid), so
|
|
1131
|
+
// server-side ON CONFLICT(event_id) handles their dedup naturally.
|
|
779
1132
|
// Placed after project_filter and no_email checks so dropped events don't poison the cache.
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
// is lost but checkDuplicate will still return false on the next attempt (allowing retry).
|
|
783
|
-
if (checkDuplicate(unified.session_id, unified.source, unified.type)) {
|
|
784
|
-
logDrop('duplicate', { type: unified.type, session_id: unified.session_id });
|
|
1133
|
+
if (checkDuplicate(primary.session_id, primary.source, primary.type)) {
|
|
1134
|
+
logDrop('duplicate', { type: primary.type, session_id: primary.session_id });
|
|
785
1135
|
process.exit(0);
|
|
786
1136
|
}
|
|
787
|
-
unified.developer_email = resolved.email;
|
|
788
|
-
unified.developer_name = resolved.name;
|
|
789
1137
|
|
|
790
|
-
// Attach git metadata
|
|
791
|
-
const gitMeta = getGitMetadata(
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1138
|
+
// Attach git metadata once — every event in the batch shares it.
|
|
1139
|
+
const gitMeta = getGitMetadata(primary.project_path);
|
|
1140
|
+
|
|
1141
|
+
// Assign event_ids:
|
|
1142
|
+
// - Primary: deterministic from stdin hash (so Cursor + Claude Code firing
|
|
1143
|
+
// the same hook compute the same id and dedup at ON CONFLICT).
|
|
1144
|
+
// - Per-call TokenUsage: stable hash of the assistant line's uuid (so two
|
|
1145
|
+
// concurrent Stop hooks reading the same lines compute the same id and
|
|
1146
|
+
// dedup at the same UNIQUE constraint).
|
|
1147
|
+
primary.event_id = deterministicEventId(input);
|
|
1148
|
+
for (let i = 1; i < events.length; i++) {
|
|
1149
|
+
const ev = events[i];
|
|
1150
|
+
const sourceUuid = ev.raw && ev.raw.source_uuid;
|
|
1151
|
+
if (sourceUuid) {
|
|
1152
|
+
ev.event_id = deterministicEventId(`claude_code:tokenusage:${sourceUuid}`);
|
|
1153
|
+
} else {
|
|
1154
|
+
// Fallback: stdin hash + per-event index. Should never be needed because
|
|
1155
|
+
// Claude Code transcripts always include uuid per record.
|
|
1156
|
+
ev.event_id = deterministicEventId(`${input}:tokenusage:${i}`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
795
1159
|
|
|
796
|
-
// Write to spool
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1160
|
+
// Write every event in the batch to the spool, attaching shared metadata.
|
|
1161
|
+
let primaryWritten = false;
|
|
1162
|
+
let allTokenUsageWritten = true;
|
|
1163
|
+
for (const ev of events) {
|
|
1164
|
+
ev.developer_email = resolved.email;
|
|
1165
|
+
ev.developer_name = resolved.name;
|
|
1166
|
+
ev.git_remote = gitMeta.git_remote;
|
|
1167
|
+
ev.git_branch = gitMeta.git_branch;
|
|
1168
|
+
ev.git_commit = gitMeta.git_commit;
|
|
1169
|
+
try {
|
|
1170
|
+
writeToSpool(ev);
|
|
1171
|
+
if (ev === primary) primaryWritten = true;
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
captureLog({ msg: 'queue-write-failed', error: err.message, type: ev.type, session_id: ev.session_id });
|
|
1174
|
+
// If the primary failed, propagate the failure so the hook exits non-zero
|
|
1175
|
+
// (and dedup is NOT committed). If a per-call event failed, log and keep
|
|
1176
|
+
// going — losing one TokenUsage row is better than dropping the whole
|
|
1177
|
+
// turn, but we'll also refuse to commit the transcript offset below so
|
|
1178
|
+
// the next Stop re-reads the same delta (server-side dedup on event_id
|
|
1179
|
+
// handles the already-succeeded rows).
|
|
1180
|
+
if (ev === primary) process.exit(1);
|
|
1181
|
+
if (ev.type === 'TokenUsage') allTokenUsageWritten = false;
|
|
1182
|
+
}
|
|
802
1183
|
}
|
|
803
1184
|
|
|
804
|
-
// Commit dedup
|
|
805
|
-
|
|
1185
|
+
// Commit dedup AND the transcript offset together, only if every event in
|
|
1186
|
+
// the batch (primary + every per-call TokenUsage) made it into the spool.
|
|
1187
|
+
//
|
|
1188
|
+
// Why gate dedup on allTokenUsageWritten too:
|
|
1189
|
+
// If primary spooled but a per-call TokenUsage write failed, committing
|
|
1190
|
+
// dedup would cause the next back-to-back idle Stop to be dropped as a
|
|
1191
|
+
// duplicate — and since that drop happens BEFORE the transcript re-read,
|
|
1192
|
+
// the missing TokenUsage row would stay lost until some other event type
|
|
1193
|
+
// resets the dedup cache. By deferring dedup until every per-call write
|
|
1194
|
+
// succeeds, we guarantee the next Stop (even another idle Stop in the
|
|
1195
|
+
// same session) will re-read the delta and retry the failed rows. The
|
|
1196
|
+
// primary Stop gets spooled twice in that scenario, but its deterministic
|
|
1197
|
+
// event_id hits server-side ON CONFLICT — idempotent.
|
|
1198
|
+
//
|
|
1199
|
+
// Why gate the transcript offset on the same condition:
|
|
1200
|
+
// Advancing the cursor marks those transcript bytes "consumed". If any
|
|
1201
|
+
// TokenUsage row derived from them is missing from the spool, we must
|
|
1202
|
+
// re-read those bytes on the next Stop. Server-side ON CONFLICT on the
|
|
1203
|
+
// deterministic event_id (hashed from the assistant line's uuid) dedups
|
|
1204
|
+
// whichever rows did land, and the missing row(s) finally get through.
|
|
1205
|
+
const batchFullySpooled = primaryWritten && allTokenUsageWritten;
|
|
1206
|
+
if (batchFullySpooled) {
|
|
1207
|
+
commitDedup(primary.session_id, primary.source, primary.type);
|
|
1208
|
+
if (typeof events.commitTranscriptOffset === 'function') {
|
|
1209
|
+
try {
|
|
1210
|
+
events.commitTranscriptOffset();
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
captureLog({ msg: 'transcript-offset-commit-failed', error: err?.message });
|
|
1213
|
+
// Not fatal: on the next Stop we'll just re-read the same delta.
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
806
1217
|
|
|
807
1218
|
// Always try to spawn sender — atomic rename in sender handles dedup
|
|
808
1219
|
try {
|
package/client/codex.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
|
+
import { toNumberOrNull } from './token-usage.js';
|
|
4
5
|
|
|
5
6
|
const TRUNCATION_LIMITS = {
|
|
6
7
|
toolInput: { command: 500, old_string: 200, new_string: 200, default: 200 },
|
|
@@ -179,6 +180,7 @@ function streamStateFor(state, streamKey) {
|
|
|
179
180
|
projectPath: null,
|
|
180
181
|
pendingCalls: new Map(),
|
|
181
182
|
hasActivity: false,
|
|
183
|
+
model: null,
|
|
182
184
|
});
|
|
183
185
|
}
|
|
184
186
|
return state.streams.get(streamKey);
|
|
@@ -188,6 +190,20 @@ function sessionTimestamp(record) {
|
|
|
188
190
|
return record?.timestamp || new Date().toISOString();
|
|
189
191
|
}
|
|
190
192
|
|
|
193
|
+
function buildCodexTokenUsageRaw(record, last, model) {
|
|
194
|
+
const inputTokens = toNumberOrNull(last?.input_tokens);
|
|
195
|
+
const outputTokens = toNumberOrNull(last?.output_tokens);
|
|
196
|
+
const cachedInputTokens = toNumberOrNull(last?.cached_input_tokens);
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
...record,
|
|
200
|
+
...(model ? { model } : {}),
|
|
201
|
+
...(inputTokens != null ? { input_tokens: inputTokens } : {}),
|
|
202
|
+
...(outputTokens != null ? { output_tokens: outputTokens } : {}),
|
|
203
|
+
...(cachedInputTokens != null ? { cache_read_tokens: cachedInputTokens } : {}),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
191
207
|
function buildUnifiedEvent(stream, type, timestamp, data, raw) {
|
|
192
208
|
if (!stream.sessionId || !stream.projectPath) return null;
|
|
193
209
|
return {
|
|
@@ -313,6 +329,7 @@ export function normalizeCodexSessionEntries(record, state, streamKey = 'default
|
|
|
313
329
|
stream.rawSessionId = sessionId;
|
|
314
330
|
stream.projectPath = cwd;
|
|
315
331
|
stream.hasActivity = false;
|
|
332
|
+
stream.model = null;
|
|
316
333
|
events.push(buildUnifiedEvent(
|
|
317
334
|
stream,
|
|
318
335
|
'SessionStart',
|
|
@@ -338,8 +355,38 @@ export function normalizeCodexSessionEntries(record, state, streamKey = 'default
|
|
|
338
355
|
return events.filter(Boolean);
|
|
339
356
|
}
|
|
340
357
|
|
|
358
|
+
if (record?.type === 'turn_context') {
|
|
359
|
+
const nextModel = record?.payload?.model;
|
|
360
|
+
if (nextModel) stream.model = nextModel;
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
|
|
341
364
|
if (!stream.sessionId || !stream.projectPath) return [];
|
|
342
365
|
|
|
366
|
+
if (record?.type === 'event_msg' && record?.payload?.type === 'token_count') {
|
|
367
|
+
const info = record?.payload?.info;
|
|
368
|
+
if (!info) return [];
|
|
369
|
+
const last = info.last_token_usage;
|
|
370
|
+
if (!last) return [];
|
|
371
|
+
const inputTokens = toNumberOrNull(last.input_tokens);
|
|
372
|
+
const outputTokens = toNumberOrNull(last.output_tokens);
|
|
373
|
+
if (inputTokens == null && outputTokens == null) return [];
|
|
374
|
+
stream.hasActivity = true;
|
|
375
|
+
return [buildUnifiedEvent(
|
|
376
|
+
stream,
|
|
377
|
+
'TokenUsage',
|
|
378
|
+
sessionTimestamp(record),
|
|
379
|
+
{
|
|
380
|
+
input_tokens: inputTokens,
|
|
381
|
+
output_tokens: outputTokens,
|
|
382
|
+
cached_input_tokens: toNumberOrNull(last.cached_input_tokens),
|
|
383
|
+
reasoning_output_tokens: toNumberOrNull(last.reasoning_output_tokens),
|
|
384
|
+
model: stream.model || null,
|
|
385
|
+
},
|
|
386
|
+
buildCodexTokenUsageRaw(record, last, stream.model),
|
|
387
|
+
)].filter(Boolean);
|
|
388
|
+
}
|
|
389
|
+
|
|
343
390
|
if (record?.type === 'response_item') {
|
|
344
391
|
const payload = record.payload || {};
|
|
345
392
|
|
package/client/config.js
CHANGED
|
@@ -17,13 +17,25 @@ export const CURRENT_STORAGE_VERSION = 1;
|
|
|
17
17
|
export const QUEUE_PATH = join(DATA_DIR, 'queue.jsonl');
|
|
18
18
|
export const SENDING_PATH = join(DATA_DIR, 'queue.sending.jsonl');
|
|
19
19
|
export const SESSION_PATHS_DIR = join(DATA_DIR, 'session-paths');
|
|
20
|
+
export const TRANSCRIPT_OFFSETS_DIR = join(DATA_DIR, 'transcript-offsets');
|
|
20
21
|
export const GIT_REMOTES_DIR = join(DATA_DIR, 'git-remotes');
|
|
21
22
|
export const LOG_PATH = join(DATA_DIR, 'sender.log');
|
|
22
23
|
export const CAPTURE_LOG_PATH = join(DATA_DIR, 'capture.log');
|
|
23
24
|
export const SENDER_BACKOFF_PATH = join(DATA_DIR, 'sender-backoff.json');
|
|
24
25
|
export const LOG_MAX_AGE_DAYS = 30;
|
|
25
26
|
const GIT_ROOT_CACHE = new Map();
|
|
26
|
-
|
|
27
|
+
// Pipe stderr (instead of inheriting it) so that "fatal: not a git repository"
|
|
28
|
+
// and similar messages from git invocations in non-repo paths don't leak to
|
|
29
|
+
// the parent terminal. Every caller wraps the runner in try/catch and discards
|
|
30
|
+
// errors; otherwise stderr bytes would print to the user's terminal regardless.
|
|
31
|
+
//
|
|
32
|
+
// The stdio override is applied AFTER spreading caller options so a caller
|
|
33
|
+
// cannot accidentally re-enable stderr inheritance by passing { stdio: 'inherit' }.
|
|
34
|
+
// Tests that need a custom runner should use _setGitRunner, not options.stdio.
|
|
35
|
+
function _runGit(args, options) {
|
|
36
|
+
return childProcess.execFileSync('git', args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
37
|
+
}
|
|
38
|
+
let _gitRunner = _runGit;
|
|
27
39
|
|
|
28
40
|
export function log(fields) {
|
|
29
41
|
const entry = { ts: new Date().toISOString(), ...fields };
|
|
@@ -64,7 +76,7 @@ export function _clearGitCache() { GIT_ROOT_CACHE.clear(); }
|
|
|
64
76
|
/** @internal Test helper: override the git runner. */
|
|
65
77
|
export function _setGitRunner(fn) { _gitRunner = fn; }
|
|
66
78
|
/** @internal Test helper: restore the default git runner. */
|
|
67
|
-
export function _resetGitRunner() { _gitRunner =
|
|
79
|
+
export function _resetGitRunner() { _gitRunner = _runGit; }
|
|
68
80
|
|
|
69
81
|
export const DEFAULT_SERVER_URL = 'http://localhost:3000';
|
|
70
82
|
|
|
@@ -74,6 +86,7 @@ export function ensureDataDir() {
|
|
|
74
86
|
mkdirSync(SENDING_DIR, { recursive: true });
|
|
75
87
|
mkdirSync(DEDUP_DIR, { recursive: true });
|
|
76
88
|
mkdirSync(SESSION_PATHS_DIR, { recursive: true });
|
|
89
|
+
mkdirSync(TRANSCRIPT_OFFSETS_DIR, { recursive: true });
|
|
77
90
|
mkdirSync(GIT_REMOTES_DIR, { recursive: true });
|
|
78
91
|
}
|
|
79
92
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared token-usage helpers used by both Claude Code (capture.js) and
|
|
3
|
+
* Codex (codex.js) normalization paths. Extracted to keep the two in sync
|
|
4
|
+
* and avoid silent divergence between the two tracking pipelines.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Return `value` if it is a finite number, otherwise `null`.
|
|
9
|
+
* Used to guard JSON fields before copying them into raw payloads —
|
|
10
|
+
* protects against `NaN`, `undefined`, strings, etc. sneaking through.
|
|
11
|
+
*/
|
|
12
|
+
export function toNumberOrNull(value) {
|
|
13
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Lift Anthropic-style usage fields from `usage` onto a shallow clone of
|
|
18
|
+
* `event`, renaming cache keys to the short form used by the dashboard SQL:
|
|
19
|
+
*
|
|
20
|
+
* cache_read_input_tokens -> cache_read_tokens
|
|
21
|
+
* cache_creation_input_tokens -> cache_write_tokens
|
|
22
|
+
*
|
|
23
|
+
* Returns `event` unchanged if both `usage` and `model` are falsy, so callers
|
|
24
|
+
* can pass this through blindly without checking for presence first.
|
|
25
|
+
*
|
|
26
|
+
* Used by Cursor afterAgentResponse (where usage comes directly from the hook
|
|
27
|
+
* payload) and by Claude Code's per-call TokenUsage emission (where each call
|
|
28
|
+
* is one assistant line in the transcript). Codex has its own OpenAI-shaped
|
|
29
|
+
* helper because the field names differ.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} event Base object to clone (the hook event for Cursor,
|
|
32
|
+
* a synthetic minimal carrier for Claude Code's
|
|
33
|
+
* per-call events).
|
|
34
|
+
* @param {object|null} usage Usage object with Anthropic-named keys.
|
|
35
|
+
* @param {string|null} model Model name from the assistant line.
|
|
36
|
+
*/
|
|
37
|
+
export function buildTokenUsageRaw(event, usage, model) {
|
|
38
|
+
if (!usage && !model) return event;
|
|
39
|
+
return {
|
|
40
|
+
...event,
|
|
41
|
+
...(model ? { model } : {}),
|
|
42
|
+
...(toNumberOrNull(usage?.input_tokens) != null ? { input_tokens: usage.input_tokens } : {}),
|
|
43
|
+
...(toNumberOrNull(usage?.output_tokens) != null ? { output_tokens: usage.output_tokens } : {}),
|
|
44
|
+
...(toNumberOrNull(usage?.cache_read_input_tokens) != null ? { cache_read_tokens: usage.cache_read_input_tokens } : {}),
|
|
45
|
+
...(toNumberOrNull(usage?.cache_creation_input_tokens) != null ? { cache_write_tokens: usage.cache_creation_input_tokens } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|