ai-lens 0.8.120 → 0.8.121
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/CHANGELOG.md +3 -0
- package/README.md +3 -1
- package/cli/hooks.js +22 -3
- package/cli/status.js +54 -7
- package/client/capture.js +47 -7
- package/client/redact.js +4 -0
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
9775c12
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
History of changes to the `ai-lens` CLI package on npm. New entries go on top. Format: `## X.Y.Z — YYYY-MM-DD`, followed by user-facing bullets.
|
|
4
4
|
|
|
5
|
+
## 0.8.121 — 2026-07-21
|
|
6
|
+
- fix(codex): Codex token usage no longer double-counts — some Codex versions re-emit an identical usage record without advancing their own running counter, and those duplicates were inflating token totals; they are now dropped (including duplicates that land in a later session update)
|
|
7
|
+
|
|
5
8
|
## 0.8.120 — 2026-07-03
|
|
6
9
|
- fix: session events are no longer misattributed to an unrelated repository when a tool touches a file outside the project — project refinement now only follows genuine nested sub-repos, so the `projects` filter and per-project stats stay correct
|
|
7
10
|
- feat(codex): Codex sessions now report per-call token usage, matching Claude Code / Cursor granularity
|
package/README.md
CHANGED
|
@@ -345,13 +345,15 @@ Stored in `~/.ai-lens/`:
|
|
|
345
345
|
## Development
|
|
346
346
|
|
|
347
347
|
```bash
|
|
348
|
-
npm test # Run all tests (vitest
|
|
348
|
+
npm test # Run all tests (vitest)
|
|
349
349
|
npm run test:watch # Watch mode
|
|
350
350
|
npm run dev:dashboard # Dashboard dev server
|
|
351
351
|
```
|
|
352
352
|
|
|
353
353
|
Tests require PostgreSQL — set `DATABASE_URL` or use `docker compose up postgres -d` (test DB `ailens_test` is created automatically).
|
|
354
354
|
|
|
355
|
+
Test files run in parallel, each worker against its own database: `test/global-setup.js` migrates a template once and clones it into `ailens_test_w1..wN`. Set `AI_LENS_TEST_WORKERS` to change the worker count (default 6).
|
|
356
|
+
|
|
355
357
|
Architecture decisions are recorded under [`docs/adr/`](docs/adr/) — e.g. [ADR 0001 — Canonical metric predicates](docs/adr/0001-canonical-metric-predicates.md).
|
|
356
358
|
|
|
357
359
|
## Deployment
|
package/cli/hooks.js
CHANGED
|
@@ -733,6 +733,27 @@ export function getClaudeCodeToolConfig(projectRoot, label = 'Claude Code (proje
|
|
|
733
733
|
};
|
|
734
734
|
}
|
|
735
735
|
|
|
736
|
+
/**
|
|
737
|
+
* The per-machine Claude Code overlay as a tool config: same tool, but pointed at
|
|
738
|
+
* `<project>/.claude/settings.local.json`.
|
|
739
|
+
*
|
|
740
|
+
* This file is where `init` ACTUALLY writes project hooks (cli/init.js — gitignored,
|
|
741
|
+
* per-machine, HELP-1539), so anything that inspects "the project's Claude hooks" and
|
|
742
|
+
* reads only `settings.json` is looking at the wrong file. It has its own label
|
|
743
|
+
* because consumers key on `tool.name`: `status`'s capture test emits one `[<name>]`
|
|
744
|
+
* block per tested tool and the server parses those blocks into a Map — two entries
|
|
745
|
+
* sharing a name would silently overwrite each other.
|
|
746
|
+
*
|
|
747
|
+
* `Claude Code (project local overlay)` is the canonical string (already used by
|
|
748
|
+
* `cli/remove.js` and already mapped to the `claude_code` source in
|
|
749
|
+
* `server/models/client-reports.js`). Do not invent a variant.
|
|
750
|
+
*/
|
|
751
|
+
export function getClaudeCodeLocalToolConfig(projectRoot, label = 'Claude Code (project local overlay)', ctx = null) {
|
|
752
|
+
const base = getClaudeCodeToolConfig(projectRoot, label, ctx);
|
|
753
|
+
if (!base) return null;
|
|
754
|
+
return { ...base, configPath: claudeLocalSettingsPath(base), sharedConfig: true };
|
|
755
|
+
}
|
|
756
|
+
|
|
736
757
|
export function getCodexToolConfig(projectRoot, label = 'Codex (project)', ctx = null) {
|
|
737
758
|
const base = TOOL_CONFIGS.find(t => t.name === 'Codex');
|
|
738
759
|
if (!base) return null;
|
|
@@ -1226,9 +1247,7 @@ export function detectAiLensInstallScope(projectRoot) {
|
|
|
1226
1247
|
// (per-machine, gitignored); the committed `--use-repo-path` form lands in
|
|
1227
1248
|
// settings.json — check BOTH so neither install style is missed.
|
|
1228
1249
|
const claudeProject = getClaudeCodeToolConfig(projectRoot);
|
|
1229
|
-
const claudeProjectLocal =
|
|
1230
|
-
? { ...claudeProject, configPath: claudeLocalSettingsPath(claudeProject) }
|
|
1231
|
-
: null;
|
|
1250
|
+
const claudeProjectLocal = getClaudeCodeLocalToolConfig(projectRoot);
|
|
1232
1251
|
const projectTools = [
|
|
1233
1252
|
claudeProject,
|
|
1234
1253
|
claudeProjectLocal,
|
package/cli/status.js
CHANGED
|
@@ -7,7 +7,7 @@ import tls from 'node:tls';
|
|
|
7
7
|
|
|
8
8
|
import { TLS_TRUST_CODES, tlsCodeOf, tlsVerdictSummary, issuerName } from '../client/tls-trust.js';
|
|
9
9
|
|
|
10
|
-
import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, getCodexToolConfig, analyzeToolHooks, checkHooksDisabled, verifyCodexHookTrust, CAPTURE_PATH, TOOL_CONFIGS, isClaudeProjectDirCommand, analyzeClaudeLocalOverlay, extractProjectDirRelPath, globalClaudeHooksActive, CONHOST_HEADLESS_PREFIX_RE } from './hooks.js';
|
|
10
|
+
import { getVersionInfo, readLensConfig, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, getCodexToolConfig, getClaudeCodeLocalToolConfig, analyzeToolHooks, checkHooksDisabled, verifyCodexHookTrust, CAPTURE_PATH, TOOL_CONFIGS, isClaudeProjectDirCommand, isWrongPlatformProjectDirCommand, analyzeClaudeLocalOverlay, extractProjectDirRelPath, globalClaudeHooksActive, CONHOST_HEADLESS_PREFIX_RE } from './hooks.js';
|
|
11
11
|
import { DATA_DIR, PENDING_DIR, SENDING_DIR, SESSION_PATHS_DIR, LOG_PATH, CAPTURE_LOG_PATH, LAST_STATUS_REPORT_PATH, getGitIdentity, getMonitoredProjects } from '../client/config.js';
|
|
12
12
|
import { recoverMojibake } from '../client/mojibake-fix.js';
|
|
13
13
|
import { isProjectMonitored } from '../client/capture.js';
|
|
@@ -234,17 +234,49 @@ function validateHookCommandPaths(tool) {
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
/**
|
|
237
|
-
* Global tools (detectInstalledTools) plus project-level tools
|
|
238
|
-
*
|
|
237
|
+
* Global tools (detectInstalledTools) plus the project-level tools found under
|
|
238
|
+
* `projectRoot`. Status is assumed to be run from the project root where hooks live.
|
|
239
|
+
*
|
|
240
|
+
* Claude Code contributes up to TWO entries, and both halves matter:
|
|
241
|
+
*
|
|
242
|
+
* - `settings.local.json` (the OVERLAY) is where `init` actually writes project
|
|
243
|
+
* hooks — per-machine, gitignored (HELP-1539). Reading only `settings.json` is
|
|
244
|
+
* how this check used to report `no hook command found` for a perfectly healthy
|
|
245
|
+
* install, while the per-tool line in the SAME report said the overlay was fine.
|
|
246
|
+
*
|
|
247
|
+
* - the committed `settings.json` is tested only when its hooks can actually fire
|
|
248
|
+
* HERE. When `analyzeClaudeLocalOverlay` says an overlay is `applicable`, that
|
|
249
|
+
* means by definition "this file carries the other OS's syntax and never fires on
|
|
250
|
+
* this machine" — running such a command through the shell yields exit 127 and
|
|
251
|
+
* would report `capture failed` for someone whose capture works via the overlay.
|
|
252
|
+
* Testing a command the OS cannot execute proves nothing; skipping it makes this
|
|
253
|
+
* check agree with the per-tool line instead of contradicting it.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} [projectRoot] — defaults to cwd; a parameter so this is testable
|
|
256
|
+
* without chdir (it had no coverage precisely because it took none).
|
|
257
|
+
* @param {{platform?: string, tracked?: boolean}} [opts] — overlay-analysis
|
|
258
|
+
* overrides, forwarded to analyzeClaudeLocalOverlay (tests only).
|
|
239
259
|
*/
|
|
240
|
-
function getToolsForCaptureTest() {
|
|
260
|
+
export function getToolsForCaptureTest(projectRoot = process.cwd(), opts = {}) {
|
|
241
261
|
const global = detectInstalledTools();
|
|
242
|
-
const projectRoot = process.cwd();
|
|
243
262
|
const projectTools = [];
|
|
244
263
|
const cursorProject = getCursorToolConfig(projectRoot);
|
|
245
264
|
if (cursorProject && existsSync(cursorProject.configPath)) projectTools.push(cursorProject);
|
|
265
|
+
|
|
246
266
|
const claudeProject = getClaudeCodeToolConfig(projectRoot);
|
|
247
|
-
if (claudeProject && existsSync(claudeProject.configPath))
|
|
267
|
+
if (claudeProject && existsSync(claudeProject.configPath)) {
|
|
268
|
+
const overlay = analyzeClaudeLocalOverlay(claudeProject, opts);
|
|
269
|
+
// `applicable` ⇔ wrong-platform syntax AND git-tracked. The untracked variant is
|
|
270
|
+
// just as unrunnable, so check the command form directly too.
|
|
271
|
+
const wrongForm = isWrongPlatformProjectDirCommand(
|
|
272
|
+
extractHookCommand(claudeProject) ?? '', opts.platform ?? process.platform,
|
|
273
|
+
);
|
|
274
|
+
if (!overlay.applicable && !wrongForm) projectTools.push(claudeProject);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const claudeLocal = getClaudeCodeLocalToolConfig(projectRoot);
|
|
278
|
+
if (claudeLocal && existsSync(claudeLocal.configPath)) projectTools.push(claudeLocal);
|
|
279
|
+
|
|
248
280
|
const codexProject = getCodexToolConfig(projectRoot);
|
|
249
281
|
if (codexProject && existsSync(codexProject.configPath)) projectTools.push(codexProject);
|
|
250
282
|
return [...global, ...projectTools];
|
|
@@ -260,7 +292,22 @@ function checkCaptureRun(installedTools) {
|
|
|
260
292
|
}
|
|
261
293
|
|
|
262
294
|
if (toolCommands.length === 0) {
|
|
263
|
-
|
|
295
|
+
// Say WHERE we looked. Project hooks are discovered relative to cwd, so running
|
|
296
|
+
// status from outside the project produces this result on a perfectly healthy
|
|
297
|
+
// install — and the bare old wording ("no hook command found") read as "capture is
|
|
298
|
+
// dead". Three developers currently sit in exactly this state while their own
|
|
299
|
+
// report shows capture happening seconds earlier.
|
|
300
|
+
const looked = installedTools.map(t => t.configPath);
|
|
301
|
+
return {
|
|
302
|
+
ok: null,
|
|
303
|
+
summary: looked.length
|
|
304
|
+
? `no hook command found in the configs checked here (${looked.length}) — project hooks may live in another directory`
|
|
305
|
+
: 'no tool configs found to check',
|
|
306
|
+
detail: 'Could not find an AI Lens hook command in any tool config.\n'
|
|
307
|
+
+ `Checked:\n${looked.map(p => ` ${p}`).join('\n') || ' (none)'}\n\n`
|
|
308
|
+
+ 'Project hooks are looked up relative to the current directory. If your hooks are\n'
|
|
309
|
+
+ 'installed per-project, re-run `ai-lens status` from that project root.',
|
|
310
|
+
};
|
|
264
311
|
}
|
|
265
312
|
|
|
266
313
|
const defaultCaptureScript = join(homedir(), '.ai-lens', 'client', 'capture.js');
|
package/client/capture.js
CHANGED
|
@@ -401,9 +401,18 @@ function readNewTranscriptDelta(transcriptPath) {
|
|
|
401
401
|
const processable = buffer.slice(0, lastNewlineIndex).toString('utf-8');
|
|
402
402
|
const newOffset = startOffset + lastNewlineIndex + 1;
|
|
403
403
|
const nextState = { offset: newOffset, size: currentSize, mtime_ms: currentMtime };
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
404
|
+
// `extra` lets a caller persist source-specific cursor state (e.g. Codex's
|
|
405
|
+
// running cumulative token total, ANL-1343) atomically WITH the byte offset —
|
|
406
|
+
// so on a crash-before-commit both roll back together and nothing drifts.
|
|
407
|
+
const commitOffset = (extra) => writeTranscriptOffsetState(
|
|
408
|
+
offsetFile,
|
|
409
|
+
extra ? { ...nextState, ...extra } : nextState,
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
// `savedState` is the offset state as it stood BEFORE this delta (may carry
|
|
413
|
+
// extra fields a prior commit persisted). Exposed so a source-specific
|
|
414
|
+
// extractor can seed cross-delta state without re-reading the offset file.
|
|
415
|
+
return { lines: processable.split('\n'), commitOffset, savedState };
|
|
407
416
|
}
|
|
408
417
|
|
|
409
418
|
/**
|
|
@@ -563,12 +572,22 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
563
572
|
* commitOffset: function}}
|
|
564
573
|
*/
|
|
565
574
|
function extractNewCodexRecordsFromTranscript(transcriptPath, fallbackModel) {
|
|
566
|
-
const { lines, commitOffset } = readNewTranscriptDelta(transcriptPath);
|
|
575
|
+
const { lines, commitOffset, savedState } = readNewTranscriptDelta(transcriptPath);
|
|
567
576
|
if (!lines.length) return { calls: [], messages: [], commitOffset };
|
|
568
577
|
|
|
569
578
|
const calls = [];
|
|
570
579
|
const messages = [];
|
|
571
580
|
let currentModel = fallbackModel || null;
|
|
581
|
+
// Highest cumulative token total already counted for this transcript, seeded
|
|
582
|
+
// from the persisted offset state so it survives across Stop deltas. Codex
|
|
583
|
+
// sometimes re-emits an identical token_count without advancing its
|
|
584
|
+
// cumulative counter (`total_token_usage.total_tokens`); the duplicate carries
|
|
585
|
+
// a fresh timestamp, so its event_id differs and the server dedup never
|
|
586
|
+
// collapses it — double-counting tokens (ANL-1343). We drop any token_count
|
|
587
|
+
// whose cumulative does not advance past this max. The cumulative is monotonic
|
|
588
|
+
// and never resets (verified across compactions on 22k+ real records), so a
|
|
589
|
+
// single running max is a complete, false-positive-free signal.
|
|
590
|
+
let maxTotal = toNumberOrNull(savedState?.codex_last_total) ?? 0;
|
|
572
591
|
try {
|
|
573
592
|
for (const rawLine of lines) {
|
|
574
593
|
const line = rawLine && rawLine.trim();
|
|
@@ -647,6 +666,15 @@ function extractNewCodexRecordsFromTranscript(transcriptPath, fallbackModel) {
|
|
|
647
666
|
// record and identical across two concurrent Stop hooks reading the same
|
|
648
667
|
// bytes.
|
|
649
668
|
const cum = toNumberOrNull(info?.total_token_usage?.total_tokens);
|
|
669
|
+
// Phantom-duplicate guard (ANL-1343): skip a token_count whose cumulative
|
|
670
|
+
// total does not advance past the max already counted for this transcript.
|
|
671
|
+
// Every such record is a byte-identical re-emission (verified: 100% literal
|
|
672
|
+
// duplicates, 0 false positives). cum-less records (older Codex without a
|
|
673
|
+
// total) fall through unchanged and rely on the existing dedup.
|
|
674
|
+
if (cum != null) {
|
|
675
|
+
if (cum <= maxTotal) continue;
|
|
676
|
+
maxTotal = cum;
|
|
677
|
+
}
|
|
650
678
|
const uuid = `${ts || ''}:${cum != null ? cum : ''}`;
|
|
651
679
|
|
|
652
680
|
calls.push({
|
|
@@ -663,11 +691,14 @@ function extractNewCodexRecordsFromTranscript(transcriptPath, fallbackModel) {
|
|
|
663
691
|
}
|
|
664
692
|
} catch (err) {
|
|
665
693
|
captureLog({ msg: 'transcript-offset-error', stage: 'parse-codex', path: transcriptPath, error: err?.message });
|
|
666
|
-
commitOffset();
|
|
694
|
+
commitOffset({ codex_last_total: maxTotal });
|
|
667
695
|
return { calls: [], messages: [], commitOffset: () => {} };
|
|
668
696
|
}
|
|
669
697
|
|
|
670
|
-
|
|
698
|
+
// Persist the advanced cumulative max together with the byte offset, so the
|
|
699
|
+
// next Stop delta seeds `maxTotal` correctly and cross-delta duplicates are
|
|
700
|
+
// still caught. Deferred: the caller invokes this only AFTER spooling.
|
|
701
|
+
return { calls, messages, commitOffset: () => commitOffset({ codex_last_total: maxTotal }) };
|
|
671
702
|
}
|
|
672
703
|
|
|
673
704
|
// =============================================================================
|
|
@@ -690,10 +721,19 @@ export function detectSource(event) {
|
|
|
690
721
|
// Codex writes its rollout transcript as `.../sessions/.../rollout-<ts>-<id>.jsonl`
|
|
691
722
|
// (codex-rs session naming); Claude Code uses `~/.claude/projects/<dir>/<uuid>.jsonl`.
|
|
692
723
|
// The `rollout-*.jsonl` basename is the reliable Codex signal for SessionStart.
|
|
724
|
+
// But some Codex SessionStart events carry an EMPTY transcript_path (no rollout yet) and
|
|
725
|
+
// no turn_id, so they'd fall through to claude_code — which mislabeled the whole session
|
|
726
|
+
// downstream ([0132]). Those events carry `model: "gpt-5.x…"`; Claude Code never emits a
|
|
727
|
+
// `gpt-*` model, so it's a safe Codex tie-breaker — but ONLY for the claude-code-shaped
|
|
728
|
+
// envelope: Cursor also emits `gpt-*` models, so gate the model signal behind "no Cursor
|
|
729
|
+
// signal present" (conversation_id/cursor_version), which the Cursor branch below keys on.
|
|
693
730
|
const transcriptPath = typeof event.transcript_path === 'string' ? event.transcript_path : '';
|
|
731
|
+
const model = typeof event.model === 'string' ? event.model : '';
|
|
732
|
+
const gptModelNoCursor = /^gpt[-.]/i.test(model) && !event.conversation_id && !event.cursor_version;
|
|
694
733
|
if (event.codex_version || event.source === 'codex' || event.hook_source === 'codex'
|
|
695
734
|
|| typeof event.turn_id === 'string'
|
|
696
|
-
|| /(^|[\\/])rollout-[^\\/]*\.jsonl$/.test(transcriptPath)
|
|
735
|
+
|| /(^|[\\/])rollout-[^\\/]*\.jsonl$/.test(transcriptPath)
|
|
736
|
+
|| gptModelNoCursor) return 'codex';
|
|
697
737
|
if (event.conversation_id || event.cursor_version) return 'cursor';
|
|
698
738
|
if (event.session_id || event.transcript_path) return 'claude_code';
|
|
699
739
|
return 'unknown';
|
package/client/redact.js
CHANGED
|
@@ -90,6 +90,10 @@ const PATTERNS = [
|
|
|
90
90
|
// Meilisearch master/API key (mc_ + 32+ hex chars)
|
|
91
91
|
{ type: 'MEILISEARCH_KEY', re: /\bmc_[0-9a-f]{32,}\b/g },
|
|
92
92
|
|
|
93
|
+
// Cursor Admin API key (crsr_ + 64 hex chars as issued today; 32+ to stay
|
|
94
|
+
// ahead of a longer future format)
|
|
95
|
+
{ type: 'CURSOR_ADMIN_KEY', re: /\bcrsr_[0-9a-f]{32,}\b/g },
|
|
96
|
+
|
|
93
97
|
// Connection string password (://user:password@host) — redacts password only.
|
|
94
98
|
// Tightened: exclude JSON structural chars from the user/password classes so
|
|
95
99
|
// a stringified JSON payload (e.g. MCP `tool_response` kept as a single
|