ai-lens 0.8.119 → 0.8.120
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 +5 -0
- package/client/capture.js +274 -49
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
bf6f7d9
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
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.120 — 2026-07-03
|
|
6
|
+
- 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
|
+
- feat(codex): Codex sessions now report per-call token usage, matching Claude Code / Cursor granularity
|
|
8
|
+
- feat(codex): Codex sessions now capture per-message assistant text for dialogue and search
|
|
9
|
+
|
|
5
10
|
## 0.8.119 — 2026-07-02
|
|
6
11
|
- feat: team-memory injection now fires only on genuinely relevant prompts — the trigger matches distinctive terms of the memory title instead of an absolute score, cutting noise ~20x while keeping real matches
|
|
7
12
|
- feat: the full team memory pool is delivered to the session cache (previously most team-tier memories could never surface), server-gated to clients on this version and newer
|
package/client/capture.js
CHANGED
|
@@ -297,45 +297,20 @@ function maybeCleanStaleTranscriptOffsets() {
|
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
300
|
+
* Read the NEW bytes appended to a JSONL transcript since the last call for
|
|
301
|
+
* this path, returning the complete newline-terminated lines in that delta.
|
|
302
|
+
* The intricate byte-offset / rotation / partial-line handling lives HERE, in
|
|
303
|
+
* one place, shared by the Claude Code (assistant-line) and Codex (token_count)
|
|
304
|
+
* token extractors so the two can't silently diverge.
|
|
303
305
|
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
* Returns { calls, commitOffset }:
|
|
311
|
-
* - `calls` is Array<{usage, model, timestamp, uuid}>:
|
|
312
|
-
* • `usage` has Anthropic-named keys (input_tokens, output_tokens,
|
|
313
|
-
* cache_read_input_tokens, cache_creation_input_tokens) for
|
|
314
|
-
* buildTokenUsageRaw.
|
|
315
|
-
* • `model` is the model from that specific assistant line.
|
|
316
|
-
* • `timestamp` is the assistant line's own ISO timestamp if present, else
|
|
317
|
-
* null (caller falls back to the Stop event's timestamp).
|
|
318
|
-
* • `uuid` is the assistant line's `uuid` field (Claude Code transcripts
|
|
319
|
-
* always include one). Used by the caller to derive a stable event_id
|
|
320
|
-
* so concurrent Stop hook invocations can't double-emit a single API call.
|
|
321
|
-
* - `commitOffset` is a no-arg function the caller MUST invoke ONLY after
|
|
322
|
-
* successfully writing every TokenUsage event derived from `calls` into
|
|
323
|
-
* the spool. If spool writes fail (or the caller never calls it), the
|
|
324
|
-
* transcript cursor stays put and the next Stop re-reads the same delta —
|
|
325
|
-
* the server-side ON CONFLICT on the deterministic event_id then dedups
|
|
326
|
-
* whichever rows did land. This is what makes the pipeline crash-safe:
|
|
327
|
-
* we never mark transcript bytes "consumed" until we know the rows they
|
|
328
|
-
* produced are durably queued.
|
|
329
|
-
*
|
|
330
|
-
* Returns an empty `calls` array (and a no-op `commitOffset`) if nothing new
|
|
331
|
-
* in the delta or no real assistant lines are found.
|
|
332
|
-
*
|
|
333
|
-
* Partial-line handling: a Stop hook can fire while Claude Code is mid-write,
|
|
334
|
-
* so the delta may end before a trailing '\n'. We detect this, ignore the
|
|
335
|
-
* partial tail, and persist the offset at the byte right after the LAST
|
|
336
|
-
* complete '\n' so the next Stop re-reads the partial line once it's finished.
|
|
306
|
+
* Returns { lines, commitOffset }:
|
|
307
|
+
* - `lines` — raw JSONL lines in the processable region (empty on nothing-new
|
|
308
|
+
* or any I/O failure).
|
|
309
|
+
* - `commitOffset` — a no-arg fn the caller MUST invoke ONLY after it has
|
|
310
|
+
* durably consumed every line (advancing the cursor eagerly would drop rows
|
|
311
|
+
* if a later spool write fails).
|
|
337
312
|
*/
|
|
338
|
-
function
|
|
313
|
+
function readNewTranscriptDelta(transcriptPath) {
|
|
339
314
|
// Run the rate-limited stale-offset cleanup at most once per 24h. Wrapped in
|
|
340
315
|
// try/catch so a cleanup failure never prevents the token read.
|
|
341
316
|
try { maybeCleanStaleTranscriptOffsets(); } catch { /* best-effort */ }
|
|
@@ -345,7 +320,7 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
345
320
|
const noopCommit = () => {};
|
|
346
321
|
|
|
347
322
|
if (!transcriptPath || typeof transcriptPath !== 'string') {
|
|
348
|
-
return {
|
|
323
|
+
return { lines: [], commitOffset: noopCommit };
|
|
349
324
|
}
|
|
350
325
|
|
|
351
326
|
const offsetFile = join(TRANSCRIPT_OFFSETS_DIR, encodeURIComponent(transcriptPath));
|
|
@@ -358,7 +333,7 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
358
333
|
currentMtime = st.mtimeMs;
|
|
359
334
|
} catch (err) {
|
|
360
335
|
captureLog({ msg: 'transcript-offset-error', stage: 'stat', path: transcriptPath, error: err?.message });
|
|
361
|
-
return {
|
|
336
|
+
return { lines: [], commitOffset: noopCommit };
|
|
362
337
|
}
|
|
363
338
|
|
|
364
339
|
const savedState = readTranscriptOffsetState(offsetFile);
|
|
@@ -377,7 +352,7 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
377
352
|
|
|
378
353
|
// Nothing new — just a stat, super cheap.
|
|
379
354
|
if (currentSize === startOffset) {
|
|
380
|
-
return {
|
|
355
|
+
return { lines: [], commitOffset: noopCommit };
|
|
381
356
|
}
|
|
382
357
|
|
|
383
358
|
let buffer = null;
|
|
@@ -395,13 +370,13 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
395
370
|
// Read failed before we got any bytes — leave the cursor unchanged so the
|
|
396
371
|
// next Stop retries. (Old behavior advanced past the unread bytes; that
|
|
397
372
|
// silently dropped data on transient I/O errors.)
|
|
398
|
-
return {
|
|
373
|
+
return { lines: [], commitOffset: noopCommit };
|
|
399
374
|
}
|
|
400
375
|
|
|
401
376
|
if (!buffer || buffer.length === 0) {
|
|
402
377
|
// Defensive: shouldn't happen because of the currentSize === startOffset
|
|
403
378
|
// early return above. Don't advance the cursor.
|
|
404
|
-
return {
|
|
379
|
+
return { lines: [], commitOffset: noopCommit };
|
|
405
380
|
}
|
|
406
381
|
|
|
407
382
|
// Find the LAST complete newline. CRITICAL: search the BYTE buffer, not a
|
|
@@ -416,7 +391,7 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
416
391
|
if (lastNewlineIndex === -1) {
|
|
417
392
|
// The entire chunk is a partial trailing line — leave the cursor unchanged
|
|
418
393
|
// so the next Stop re-reads the line once it's finished.
|
|
419
|
-
return {
|
|
394
|
+
return { lines: [], commitOffset: noopCommit };
|
|
420
395
|
}
|
|
421
396
|
|
|
422
397
|
// Process only the bytes up to (and including) the last '\n'. The new
|
|
@@ -428,12 +403,36 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
428
403
|
const nextState = { offset: newOffset, size: currentSize, mtime_ms: currentMtime };
|
|
429
404
|
const commitOffset = () => writeTranscriptOffsetState(offsetFile, nextState);
|
|
430
405
|
|
|
406
|
+
return { lines: processable.split('\n'), commitOffset };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Incrementally read only the NEW bytes appended to a Claude Code transcript
|
|
411
|
+
* since the last time this function was called for that transcript, and
|
|
412
|
+
* return ONE entry per real (non-synthetic) assistant API call in the delta.
|
|
413
|
+
*
|
|
414
|
+
* Each entry corresponds to a single Anthropic API call: a single assistant
|
|
415
|
+
* line in the JSONL with its own usage record. A single user->agent turn can
|
|
416
|
+
* produce many of these (tool-use loops), and the caller is expected to emit
|
|
417
|
+
* one unified TokenUsage event per entry — symmetric with Cursor's per-call
|
|
418
|
+
* AgentResponse rows and Codex's per-call TokenUsage rows.
|
|
419
|
+
*
|
|
420
|
+
* Returns { calls, commitOffset } (see readNewTranscriptDelta for the
|
|
421
|
+
* commit-offset crash-safety contract): `calls` is Array<{usage, model,
|
|
422
|
+
* timestamp, uuid}> with Anthropic-named usage keys for buildTokenUsageRaw,
|
|
423
|
+
* the model + ISO timestamp from that specific assistant line, and the line's
|
|
424
|
+
* `uuid` (used by the caller to derive a stable event_id so concurrent Stop
|
|
425
|
+
* hook invocations can't double-emit a single API call).
|
|
426
|
+
*/
|
|
427
|
+
function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
428
|
+
const { lines, commitOffset } = readNewTranscriptDelta(transcriptPath);
|
|
429
|
+
if (!lines.length) return { calls: [], commitOffset };
|
|
430
|
+
|
|
431
431
|
// Iterate lines in the processable region. Defensive parse-tolerance is
|
|
432
432
|
// still useful for the rare case where a single line in the middle is
|
|
433
433
|
// malformed (we skip it rather than dropping the whole delta).
|
|
434
434
|
const calls = [];
|
|
435
435
|
try {
|
|
436
|
-
const lines = processable.split('\n');
|
|
437
436
|
for (const rawLine of lines) {
|
|
438
437
|
const line = rawLine && rawLine.trim();
|
|
439
438
|
if (!line) continue;
|
|
@@ -493,7 +492,7 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
493
492
|
// to spool in this path, so it's safe to commit immediately (no risk of
|
|
494
493
|
// losing events).
|
|
495
494
|
commitOffset();
|
|
496
|
-
return { calls: [], commitOffset:
|
|
495
|
+
return { calls: [], commitOffset: () => {} };
|
|
497
496
|
}
|
|
498
497
|
|
|
499
498
|
// IMPORTANT: do NOT persist the offset here. The caller is responsible for
|
|
@@ -504,6 +503,173 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
504
503
|
return { calls, commitOffset };
|
|
505
504
|
}
|
|
506
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Codex analogue of extractNewClaudeApiCallsFromTranscript: read the NEW bytes
|
|
508
|
+
* appended to a Codex rollout transcript (`rollout-*.jsonl`) since the last
|
|
509
|
+
* call and, in a SINGLE delta pass, return both the per-call token usage
|
|
510
|
+
* (`calls`) and the assistant's dialogue text (`messages`).
|
|
511
|
+
*
|
|
512
|
+
* A single pass is mandatory: `readNewTranscriptDelta` advances a per-path byte
|
|
513
|
+
* cursor that is committed only after the caller spools everything, so a second
|
|
514
|
+
* independent read would re-consume the same bytes. Claude's extractor folds
|
|
515
|
+
* text into each `call` (usage + content share one assistant line); Codex keeps
|
|
516
|
+
* them separate because usage and text live in DIFFERENT interleaved records.
|
|
517
|
+
*
|
|
518
|
+
* TOKEN USAGE (`calls`) — Codex records per-call usage as `event_msg`/
|
|
519
|
+
* `token_count` records whose `payload.info.last_token_usage` is the DELTA for
|
|
520
|
+
* that single call (the sibling `total_token_usage` is the running cumulative
|
|
521
|
+
* sum). The rollout is OpenAI-shaped, so field names differ from Claude's and
|
|
522
|
+
* we normalize them to the Anthropic convention that buildTokenUsageRaw + the
|
|
523
|
+
* dashboard SQL expect:
|
|
524
|
+
* - Codex `cached_input_tokens` ⊆ `input_tokens` (a subset), so we split them
|
|
525
|
+
* into non-overlapping `input_tokens` (fresh) + `cache_read_input_tokens`
|
|
526
|
+
* (cached), matching Claude where `input_tokens` excludes the cache read.
|
|
527
|
+
* - Codex has no prompt-cache-creation notion → `cache_creation_input_tokens`
|
|
528
|
+
* is always 0.
|
|
529
|
+
* - `output_tokens` already includes `reasoning_output_tokens`, mirroring
|
|
530
|
+
* Claude's output convention, so it passes through unchanged.
|
|
531
|
+
* Records whose call delta is all-zero (input+output == 0, e.g. the residual
|
|
532
|
+
* token_count emitted around a compaction) are skipped so we never spool an
|
|
533
|
+
* empty TokenUsage row.
|
|
534
|
+
*
|
|
535
|
+
* ASSISTANT TEXT (`messages`, ANL-1239) — the assistant's visible dialogue
|
|
536
|
+
* lives in `response_item`/`message` records with `role === 'assistant'`; we
|
|
537
|
+
* concatenate their `content[]` `output_text` blocks. The dedup key is
|
|
538
|
+
* `${timestamp}:${payload.id | sha(text)}`: current Codex writes a durable
|
|
539
|
+
* `msg_…` id on every assistant message (hard version cutoff ~2026-06-26; older
|
|
540
|
+
* rollouts have none), so we prefer it and fall back to a text fingerprint —
|
|
541
|
+
* mirroring how the token path synthesizes a stable key from records that lack
|
|
542
|
+
* a per-record uuid. The
|
|
543
|
+
* caller emits these as AssistantText events, metric-neutral server-side
|
|
544
|
+
* (ASSISTANT_CONTENT_TYPES). NOTE: there is deliberately no AssistantThinking
|
|
545
|
+
* analogue (unlike Claude). Current Codex emits no plaintext reasoning: the
|
|
546
|
+
* full chain-of-thought lives only in `response_item`/`reasoning`
|
|
547
|
+
* `encrypted_content`, and the short plaintext `summary` those records once
|
|
548
|
+
* carried was dropped ~2026-04 (verified on real rollouts: 91% of Feb reasoning
|
|
549
|
+
* records had a summary, 0% from April on). Since AI Lens captures live/current
|
|
550
|
+
* Codex, no recoverable thinking text exists.
|
|
551
|
+
*
|
|
552
|
+
* The model isn't on the token_count / message record; it lives on the nearest
|
|
553
|
+
* preceding `turn_context` record. We track the last-seen turn_context model
|
|
554
|
+
* within the delta and fall back to `fallbackModel` (the Stop event's model)
|
|
555
|
+
* when the governing turn_context landed in an earlier delta.
|
|
556
|
+
*
|
|
557
|
+
* @param {string} transcriptPath Path to the Codex rollout JSONL.
|
|
558
|
+
* @param {string|null} fallbackModel Model from the Stop event, used when no
|
|
559
|
+
* turn_context precedes a record in this
|
|
560
|
+
* delta.
|
|
561
|
+
* @returns {{calls: Array<{usage, model, timestamp, uuid}>,
|
|
562
|
+
* messages: Array<{text, model, timestamp, uuid}>,
|
|
563
|
+
* commitOffset: function}}
|
|
564
|
+
*/
|
|
565
|
+
function extractNewCodexRecordsFromTranscript(transcriptPath, fallbackModel) {
|
|
566
|
+
const { lines, commitOffset } = readNewTranscriptDelta(transcriptPath);
|
|
567
|
+
if (!lines.length) return { calls: [], messages: [], commitOffset };
|
|
568
|
+
|
|
569
|
+
const calls = [];
|
|
570
|
+
const messages = [];
|
|
571
|
+
let currentModel = fallbackModel || null;
|
|
572
|
+
try {
|
|
573
|
+
for (const rawLine of lines) {
|
|
574
|
+
const line = rawLine && rawLine.trim();
|
|
575
|
+
if (!line) continue;
|
|
576
|
+
let parsed;
|
|
577
|
+
try {
|
|
578
|
+
parsed = JSON.parse(line);
|
|
579
|
+
} catch {
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
// A turn_context record carries the model for the records that follow it.
|
|
583
|
+
if (parsed?.type === 'turn_context' && typeof parsed.payload?.model === 'string' && parsed.payload.model) {
|
|
584
|
+
currentModel = parsed.payload.model;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const payload = parsed?.payload;
|
|
588
|
+
if (!payload) continue;
|
|
589
|
+
const ts = typeof parsed.timestamp === 'string' ? parsed.timestamp : null;
|
|
590
|
+
|
|
591
|
+
// Assistant dialogue text — response_item/message with role 'assistant'.
|
|
592
|
+
if (parsed.type === 'response_item' && payload.type === 'message' && payload.role === 'assistant') {
|
|
593
|
+
let text = '';
|
|
594
|
+
if (Array.isArray(payload.content)) {
|
|
595
|
+
for (const block of payload.content) {
|
|
596
|
+
if (!block || typeof block !== 'object') continue;
|
|
597
|
+
// Assistant content is 'output_text'; accept 'text' too, defensively.
|
|
598
|
+
if ((block.type === 'output_text' || block.type === 'text') && typeof block.text === 'string' && block.text) {
|
|
599
|
+
text += (text ? '\n' : '') + block.text;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (text) {
|
|
604
|
+
// Stable per-message dedup key: `${timestamp}:${id | sha(text)}`.
|
|
605
|
+
// Current Codex writes a durable `msg_…` id on every assistant
|
|
606
|
+
// message (verified: 100% from the 2026-06-26 build onward; older
|
|
607
|
+
// rollouts, ≤ mid-May in local data, carry none — a hard version
|
|
608
|
+
// cutoff, not a gradual mix). Live capture only ever sees current
|
|
609
|
+
// rollouts, so the id is normally present; the text-hash fallback is
|
|
610
|
+
// defensive insurance for a pre-cutoff / downgraded client. Either
|
|
611
|
+
// way the key is unique per record and identical across two Stop
|
|
612
|
+
// hooks reading the same bytes (main() re-hashes source_uuid into the
|
|
613
|
+
// event_id). Prefer the durable id so the key stays human-traceable.
|
|
614
|
+
const idPart = typeof payload.id === 'string' && payload.id
|
|
615
|
+
? payload.id
|
|
616
|
+
: createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
617
|
+
const uuid = `${ts || ''}:${idPart}`;
|
|
618
|
+
messages.push({
|
|
619
|
+
text,
|
|
620
|
+
model: currentModel,
|
|
621
|
+
timestamp: ts,
|
|
622
|
+
uuid,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (payload.type !== 'token_count') continue;
|
|
629
|
+
const info = payload.info;
|
|
630
|
+
const last = info?.last_token_usage;
|
|
631
|
+
if (!last || typeof last !== 'object') continue;
|
|
632
|
+
|
|
633
|
+
const rawInput = toNumberOrNull(last.input_tokens) ?? 0;
|
|
634
|
+
const cached = toNumberOrNull(last.cached_input_tokens) ?? 0;
|
|
635
|
+
const output = toNumberOrNull(last.output_tokens) ?? 0;
|
|
636
|
+
// Skip all-zero deltas (residual token_count around a compaction).
|
|
637
|
+
if (rawInput + output <= 0) continue;
|
|
638
|
+
|
|
639
|
+
// cached_input_tokens is a SUBSET of input_tokens — split into
|
|
640
|
+
// non-overlapping fresh-input + cache-read to match Claude's convention.
|
|
641
|
+
const cacheRead = Math.min(cached, rawInput);
|
|
642
|
+
const freshInput = Math.max(0, rawInput - cacheRead);
|
|
643
|
+
|
|
644
|
+
// Stable per-call fingerprint for the dedup event_id. The token_count
|
|
645
|
+
// record has no per-record uuid, so we key on the record timestamp + the
|
|
646
|
+
// cumulative total (monotonic across calls) — unique per token_count
|
|
647
|
+
// record and identical across two concurrent Stop hooks reading the same
|
|
648
|
+
// bytes.
|
|
649
|
+
const cum = toNumberOrNull(info?.total_token_usage?.total_tokens);
|
|
650
|
+
const uuid = `${ts || ''}:${cum != null ? cum : ''}`;
|
|
651
|
+
|
|
652
|
+
calls.push({
|
|
653
|
+
usage: {
|
|
654
|
+
input_tokens: freshInput,
|
|
655
|
+
output_tokens: output,
|
|
656
|
+
cache_read_input_tokens: cacheRead,
|
|
657
|
+
cache_creation_input_tokens: 0,
|
|
658
|
+
},
|
|
659
|
+
model: currentModel,
|
|
660
|
+
timestamp: ts,
|
|
661
|
+
uuid: uuid === ':' ? null : uuid,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
} catch (err) {
|
|
665
|
+
captureLog({ msg: 'transcript-offset-error', stage: 'parse-codex', path: transcriptPath, error: err?.message });
|
|
666
|
+
commitOffset();
|
|
667
|
+
return { calls: [], messages: [], commitOffset: () => {} };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return { calls, messages, commitOffset };
|
|
671
|
+
}
|
|
672
|
+
|
|
507
673
|
// =============================================================================
|
|
508
674
|
// Source Detection
|
|
509
675
|
// =============================================================================
|
|
@@ -687,7 +853,14 @@ function refineProjectPath(event, projectPath, sessionId) {
|
|
|
687
853
|
const filePath = extractFilePath(toolInput);
|
|
688
854
|
if (!filePath) return projectPath;
|
|
689
855
|
const gitRoot = findGitRoot(filePath);
|
|
690
|
-
|
|
856
|
+
// Refine ONLY downward — into the current project itself or a real sub-repo
|
|
857
|
+
// of it (nested repos like etl-pipelines/bk-reports-automation/). Never flip
|
|
858
|
+
// to an unrelated repo just because its path string happens to be longer:
|
|
859
|
+
// a session running in one project that touches a file in a sibling/foreign
|
|
860
|
+
// repo would otherwise get misattributed there (and mis-filtered against the
|
|
861
|
+
// `projects` config). `pathContains` handles the nesting check plus Windows
|
|
862
|
+
// backslash/drive-letter/case normalization for free.
|
|
863
|
+
if (gitRoot && (!projectPath || pathContains(projectPath, gitRoot))) {
|
|
691
864
|
if (sessionId) cacheSessionPath(sessionId, gitRoot);
|
|
692
865
|
return gitRoot;
|
|
693
866
|
}
|
|
@@ -1369,7 +1542,7 @@ function normalizeCodex(event) {
|
|
|
1369
1542
|
projectPath = refineProjectPath({ tool_input: codexToolInput(event), input: codexToolInput(event) }, projectPath, sessionId);
|
|
1370
1543
|
}
|
|
1371
1544
|
|
|
1372
|
-
|
|
1545
|
+
const primary = {
|
|
1373
1546
|
event_id: null,
|
|
1374
1547
|
source: 'codex',
|
|
1375
1548
|
session_id: sessionId,
|
|
@@ -1378,7 +1551,59 @@ function normalizeCodex(event) {
|
|
|
1378
1551
|
timestamp,
|
|
1379
1552
|
data,
|
|
1380
1553
|
raw: event,
|
|
1381
|
-
}
|
|
1554
|
+
};
|
|
1555
|
+
|
|
1556
|
+
// On Stop (end of a Codex agent turn), read the rollout delta once and emit
|
|
1557
|
+
// one TokenUsage event per real API call PLUS one AssistantText event per
|
|
1558
|
+
// assistant message (ANL-1239) — giving Codex the same row-per-call
|
|
1559
|
+
// granularity as Claude Code and Cursor. The rollout path arrives as
|
|
1560
|
+
// `transcript_path` (the same field detectSource keys on for `rollout-*`).
|
|
1561
|
+
if (hookType === 'Stop') {
|
|
1562
|
+
const transcriptPath = event.transcript_path || null;
|
|
1563
|
+
const fallbackModel = typeof event.model === 'string' ? event.model : null;
|
|
1564
|
+
const { calls, messages, commitOffset } = extractNewCodexRecordsFromTranscript(transcriptPath, fallbackModel);
|
|
1565
|
+
const tokenEvents = calls.map(call => ({
|
|
1566
|
+
event_id: null, // assigned in main() from a stable hash of call.uuid
|
|
1567
|
+
source: 'codex',
|
|
1568
|
+
session_id: sessionId,
|
|
1569
|
+
type: 'TokenUsage',
|
|
1570
|
+
project_path: projectPath,
|
|
1571
|
+
timestamp: call.timestamp || timestamp,
|
|
1572
|
+
data: {
|
|
1573
|
+
model: call.model,
|
|
1574
|
+
input_tokens: call.usage.input_tokens,
|
|
1575
|
+
output_tokens: call.usage.output_tokens,
|
|
1576
|
+
},
|
|
1577
|
+
raw: buildTokenUsageRaw({ source_uuid: call.uuid }, call.usage, call.model),
|
|
1578
|
+
}));
|
|
1579
|
+
// Assistant dialogue text (ANL-1239). Stored for dialogue/search;
|
|
1580
|
+
// metric-neutral server-side (ASSISTANT_CONTENT_TYPES). data.text is
|
|
1581
|
+
// truncated for display; raw.text keeps the full text (redacted
|
|
1582
|
+
// server-side). Codex has NO plaintext thinking, so there is no
|
|
1583
|
+
// AssistantThinking counterpart (unlike the Claude Code Stop path).
|
|
1584
|
+
const contentEvents = messages.map(msg => ({
|
|
1585
|
+
event_id: null, // assigned in main() from a stable hash of msg.uuid
|
|
1586
|
+
source: 'codex',
|
|
1587
|
+
session_id: sessionId,
|
|
1588
|
+
type: 'AssistantText',
|
|
1589
|
+
project_path: projectPath,
|
|
1590
|
+
timestamp: msg.timestamp || timestamp,
|
|
1591
|
+
data: { text: truncate(msg.text, TRUNCATION_LIMITS.agentResponse), model: msg.model },
|
|
1592
|
+
raw: { source_uuid: msg.uuid, model: msg.model, text: msg.text },
|
|
1593
|
+
}));
|
|
1594
|
+
const result = [primary, ...tokenEvents, ...contentEvents];
|
|
1595
|
+
// Attach the commit callback as a non-enumerable property so it survives
|
|
1596
|
+
// through normalizeEvent()/writeToSpool without leaking into iteration or
|
|
1597
|
+
// length assertions — mirrors the Claude Code Stop path.
|
|
1598
|
+
Object.defineProperty(result, 'commitTranscriptOffset', {
|
|
1599
|
+
value: commitOffset,
|
|
1600
|
+
enumerable: false,
|
|
1601
|
+
writable: false,
|
|
1602
|
+
});
|
|
1603
|
+
return result;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
return [primary];
|
|
1382
1607
|
}
|
|
1383
1608
|
|
|
1384
1609
|
// =============================================================================
|
|
@@ -2142,7 +2367,7 @@ async function main() {
|
|
|
2142
2367
|
: ev.type === 'AssistantThinking' ? 'assistantthinking'
|
|
2143
2368
|
: 'tokenusage';
|
|
2144
2369
|
if (sourceUuid) {
|
|
2145
|
-
ev.event_id = deterministicEventId(
|
|
2370
|
+
ev.event_id = deterministicEventId(`${ev.source}:${kind}:${sourceUuid}`);
|
|
2146
2371
|
} else {
|
|
2147
2372
|
// Fallback: stdin hash + per-event index. Should never be needed because
|
|
2148
2373
|
// Claude Code transcripts always include uuid per record.
|