@bli-cockpit/telemetry-core 0.1.46 → 0.1.48

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.
@@ -528,6 +528,39 @@ export declare const CodexSessionAttributionSchema: z.ZodObject<{
528
528
  signals: z.ZodDefault<z.ZodArray<z.ZodString>>;
529
529
  session_file_hash_sha256: z.ZodOptional<z.ZodString>;
530
530
  session_file_byte_size: z.ZodOptional<z.ZodNumber>;
531
+ session_facts: z.ZodOptional<z.ZodObject<{
532
+ extractor_version: z.ZodString;
533
+ source: z.ZodEnum<{
534
+ codex: "codex";
535
+ claude_code: "claude_code";
536
+ }>;
537
+ usage_by_model: z.ZodRecord<z.ZodString, z.ZodObject<{
538
+ input_tokens: z.ZodNumber;
539
+ output_tokens: z.ZodNumber;
540
+ cache_read_input_tokens: z.ZodNumber;
541
+ cache_creation_input_tokens: z.ZodNumber;
542
+ }, z.core.$strict>>;
543
+ models: z.ZodArray<z.ZodString>;
544
+ input_tokens: z.ZodNumber;
545
+ output_tokens: z.ZodNumber;
546
+ cache_read_tokens: z.ZodNumber;
547
+ cache_creation_tokens: z.ZodNumber;
548
+ total_tokens: z.ZodNumber;
549
+ record_count: z.ZodNumber;
550
+ turn_count: z.ZodNumber;
551
+ user_prompt_count: z.ZodNumber;
552
+ assistant_response_count: z.ZodNumber;
553
+ tool_calls_by_name: z.ZodRecord<z.ZodString, z.ZodNumber>;
554
+ tool_call_count: z.ZodNumber;
555
+ first_timestamp: z.ZodNullable<z.ZodString>;
556
+ last_timestamp: z.ZodNullable<z.ZodString>;
557
+ wall_duration_seconds: z.ZodNullable<z.ZodNumber>;
558
+ transcript_bytes: z.ZodNumber;
559
+ transcript_lines: z.ZodNumber;
560
+ transcript_files: z.ZodNumber;
561
+ truncated_models: z.ZodNumber;
562
+ truncated_tool_names: z.ZodNumber;
563
+ }, z.core.$strict>>;
531
564
  session_file_mtime: z.ZodOptional<z.ZodString>;
532
565
  repo_fingerprint: z.ZodOptional<z.ZodString>;
533
566
  worktree_fingerprint: z.ZodOptional<z.ZodString>;
@@ -599,6 +632,39 @@ export declare const CodexSessionAttributionReportRequestSchema: z.ZodObject<{
599
632
  signals: z.ZodDefault<z.ZodArray<z.ZodString>>;
600
633
  session_file_hash_sha256: z.ZodOptional<z.ZodString>;
601
634
  session_file_byte_size: z.ZodOptional<z.ZodNumber>;
635
+ session_facts: z.ZodOptional<z.ZodObject<{
636
+ extractor_version: z.ZodString;
637
+ source: z.ZodEnum<{
638
+ codex: "codex";
639
+ claude_code: "claude_code";
640
+ }>;
641
+ usage_by_model: z.ZodRecord<z.ZodString, z.ZodObject<{
642
+ input_tokens: z.ZodNumber;
643
+ output_tokens: z.ZodNumber;
644
+ cache_read_input_tokens: z.ZodNumber;
645
+ cache_creation_input_tokens: z.ZodNumber;
646
+ }, z.core.$strict>>;
647
+ models: z.ZodArray<z.ZodString>;
648
+ input_tokens: z.ZodNumber;
649
+ output_tokens: z.ZodNumber;
650
+ cache_read_tokens: z.ZodNumber;
651
+ cache_creation_tokens: z.ZodNumber;
652
+ total_tokens: z.ZodNumber;
653
+ record_count: z.ZodNumber;
654
+ turn_count: z.ZodNumber;
655
+ user_prompt_count: z.ZodNumber;
656
+ assistant_response_count: z.ZodNumber;
657
+ tool_calls_by_name: z.ZodRecord<z.ZodString, z.ZodNumber>;
658
+ tool_call_count: z.ZodNumber;
659
+ first_timestamp: z.ZodNullable<z.ZodString>;
660
+ last_timestamp: z.ZodNullable<z.ZodString>;
661
+ wall_duration_seconds: z.ZodNullable<z.ZodNumber>;
662
+ transcript_bytes: z.ZodNumber;
663
+ transcript_lines: z.ZodNumber;
664
+ transcript_files: z.ZodNumber;
665
+ truncated_models: z.ZodNumber;
666
+ truncated_tool_names: z.ZodNumber;
667
+ }, z.core.$strict>>;
602
668
  session_file_mtime: z.ZodOptional<z.ZodString>;
603
669
  repo_fingerprint: z.ZodOptional<z.ZodString>;
604
670
  worktree_fingerprint: z.ZodOptional<z.ZodString>;
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { CaptureProvenanceSchema, IsoDateTimeSchema, NonEmptyStringSchema, Sha256Schema, } from "./common.js";
3
3
  import { RawEvidencePointerSchema } from "./privacy.js";
4
4
  import { RawEvidenceRedactionMetadataSchema } from "./secret-guards.js";
5
+ import { SessionFactsSchema } from "./session-facts.js";
5
6
  /**
6
7
  * Chunked raw-evidence upload wire contract.
7
8
  *
@@ -285,6 +286,16 @@ export const CodexSessionAttributionSchema = z
285
286
  signals: z.array(SafeLabelSchema).max(20).default([]),
286
287
  session_file_hash_sha256: Sha256Schema.optional(),
287
288
  session_file_byte_size: z.number().int().nonnegative().optional(),
289
+ /**
290
+ * Everything deterministic about the transcript, counted by the collector
291
+ * while the file was in its hands (BLI-4341). Token totals per model, turn
292
+ * and tool counts, first/last timestamps, size. Metadata only.
293
+ *
294
+ * Optional because a CLI published before this change sends none, and its
295
+ * uploads must keep validating; the server extractor is the safety net for
296
+ * exactly those.
297
+ */
298
+ session_facts: SessionFactsSchema.optional(),
288
299
  session_file_mtime: IsoDateTimeSchema.optional(),
289
300
  repo_fingerprint: SafeLabelSchema.optional(),
290
301
  worktree_fingerprint: SafeLabelSchema.optional(),
package/dist/index.d.ts CHANGED
@@ -19,6 +19,7 @@ export * from "./paths.js";
19
19
  export * from "./privacy.js";
20
20
  export * from "./risk-flags.js";
21
21
  export * from "./secret-guards.js";
22
+ export * from "./session-facts.js";
22
23
  export * from "./source-adapter.js";
23
24
  export * from "./ticket-id.js";
24
25
  export * from "./trace-sink.js";
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ export * from "./paths.js";
19
19
  export * from "./privacy.js";
20
20
  export * from "./risk-flags.js";
21
21
  export * from "./secret-guards.js";
22
+ export * from "./session-facts.js";
22
23
  export * from "./source-adapter.js";
23
24
  export * from "./ticket-id.js";
24
25
  export * from "./trace-sink.js";
@@ -93,8 +93,8 @@ export declare const MEMORY_HOOK_BUCKET_PATTERN: RegExp;
93
93
  export declare const MemoryHookStatsFileSchema: z.ZodObject<{
94
94
  schema_version: z.ZodLiteral<"memory-hook-stats.v1">;
95
95
  buckets: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodEnum<{
96
- "session-start": "session-start";
97
96
  prompt: "prompt";
97
+ "session-start": "session-start";
98
98
  stop: "stop";
99
99
  }> & z.core.$partial, z.ZodObject<{
100
100
  runs: z.ZodNumber;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Plain-text reads of one transcript record.
3
+ *
4
+ * Moved here from `@bli-cockpit/harvest-analysis` `extract/turn-text.ts`
5
+ * (BLI-4341) so the collector and the server ask the same question of a record
6
+ * ("did this turn carry any text?") with one implementation. The extractor now
7
+ * re-exports these; there is no second copy to drift.
8
+ *
9
+ * Nothing here leaves the process: callers use the text to decide a COUNT.
10
+ */
11
+ export declare function claudeUserTurnText(record: Record<string, unknown>): string | null;
12
+ export declare function codexUserTurnText(payload: Record<string, unknown>): string | null;
13
+ export declare function claudeAssistantText(record: Record<string, unknown>): string | null;
14
+ export declare function codexAssistantText(payload: Record<string, unknown>): string | null;
15
+ export declare function textFromPlainTextBlocks(value: unknown): string | null;
16
+ export declare function normalizeTurnText(value: string): string;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Plain-text reads of one transcript record.
3
+ *
4
+ * Moved here from `@bli-cockpit/harvest-analysis` `extract/turn-text.ts`
5
+ * (BLI-4341) so the collector and the server ask the same question of a record
6
+ * ("did this turn carry any text?") with one implementation. The extractor now
7
+ * re-exports these; there is no second copy to drift.
8
+ *
9
+ * Nothing here leaves the process: callers use the text to decide a COUNT.
10
+ */
11
+ const TEXT_BLOCK_TYPES = new Set(["text", "input_text", "output_text"]);
12
+ const NON_TEXT_BLOCK_TYPES = new Set([
13
+ "tool_result",
14
+ "tool_use",
15
+ "function_call",
16
+ "function_call_output",
17
+ ]);
18
+ export function claudeUserTurnText(record) {
19
+ const message = record["message"];
20
+ return (textFromPlainTextBlocks(message) ??
21
+ textFromPlainTextBlocks(record["content"]) ??
22
+ textFromPlainTextBlocks(record["text"]) ??
23
+ textFromPlainTextBlocks(record["prompt"]));
24
+ }
25
+ export function codexUserTurnText(payload) {
26
+ return (textFromPlainTextBlocks(payload["content"]) ??
27
+ textFromPlainTextBlocks(payload["text"]) ??
28
+ textFromPlainTextBlocks(payload["message"]));
29
+ }
30
+ export function claudeAssistantText(record) {
31
+ const message = record["message"];
32
+ return textFromPlainTextBlocks(message);
33
+ }
34
+ export function codexAssistantText(payload) {
35
+ return (textFromPlainTextBlocks(payload["content"]) ??
36
+ textFromPlainTextBlocks(payload["text"]) ??
37
+ textFromPlainTextBlocks(payload["message"]));
38
+ }
39
+ export function textFromPlainTextBlocks(value) {
40
+ if (typeof value === "string")
41
+ return value;
42
+ if (!value || typeof value !== "object")
43
+ return null;
44
+ if (Array.isArray(value)) {
45
+ const text = value
46
+ .map((item) => textFromPlainTextBlocks(item))
47
+ .filter((item) => Boolean(item))
48
+ .join("\n");
49
+ return text || null;
50
+ }
51
+ const record = value;
52
+ const blockType = typeof record["type"] === "string" ? record["type"] : null;
53
+ if (blockType && NON_TEXT_BLOCK_TYPES.has(blockType))
54
+ return null;
55
+ if (blockType && !TEXT_BLOCK_TYPES.has(blockType))
56
+ return null;
57
+ const direct = textFromPlainTextBlocks(record["text"]) ?? textFromPlainTextBlocks(record["content"]);
58
+ return direct;
59
+ }
60
+ export function normalizeTurnText(value) {
61
+ return value.toLowerCase().replace(/\s+/g, " ").trim();
62
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Session facts: everything deterministic about one transcript, counted once,
3
+ * where the file already is.
4
+ *
5
+ * BLI-4341 part A. Until now the only place a session's token totals were
6
+ * computed was the server: `@bli-cockpit/harvest-analysis` downloaded the
7
+ * transcript back out of Storage and summed it (`det.v5`). The collector holds
8
+ * the same bytes at harvest time and had already read every line, so the
9
+ * download was pure re-work, and a session with no analysis run had no token
10
+ * count at all.
11
+ *
12
+ * This module is the ONE implementation of those rules. The collector feeds it
13
+ * at upload; the server extractor feeds it the same records during its own
14
+ * pass, so both sides count identically by construction rather than by review.
15
+ * `det.v6` is `det.v5`'s rules with one owner.
16
+ *
17
+ * Contract:
18
+ * - Pure. No filesystem, no network, no clock. Content in, numbers out.
19
+ * - METADATA ONLY on the way out: token counts, turn counts, tool NAMES and
20
+ * their counts, timestamps, byte/line sizes. Text is read in-process to
21
+ * decide whether a turn counted; it is never returned.
22
+ * - Two feeds, one rulebook: `observeSessionFactsRecord` for a caller that
23
+ * is already parsing the JSONL (the server extractor), and
24
+ * `computeSessionFacts` for a caller holding whole file contents (the
25
+ * collector).
26
+ *
27
+ * Counting rules, both verified against real transcripts:
28
+ * - Claude Code writes PER-TURN usage on each assistant record, repeated
29
+ * across the several records of one API request, so usage is summed with a
30
+ * dedupe on `message.id` (fallback `requestId`).
31
+ * - Codex writes a CUMULATIVE `total_token_usage` on every `token_count`
32
+ * event, so the last one wins, attributed to the `turn_context` model. Its
33
+ * `input_tokens` includes cached input and its `output_tokens` already
34
+ * includes reasoning, so input is normalized to fresh-only, matching the
35
+ * Anthropic-shaped usage the price table expects.
36
+ */
37
+ import { z } from "zod";
38
+ /** Bump when a counting rule changes, never when a field is added. */
39
+ export declare const SESSION_FACTS_VERSION = "det.v6";
40
+ /** How many distinct models and tool names one session may report. */
41
+ export declare const SESSION_FACTS_MAX_MODELS = 50;
42
+ export declare const SESSION_FACTS_MAX_TOOL_NAMES = 200;
43
+ export type SessionFactsSource = "codex" | "claude_code";
44
+ export interface SessionFactsUsage {
45
+ input_tokens: number;
46
+ output_tokens: number;
47
+ cache_read_input_tokens: number;
48
+ cache_creation_input_tokens: number;
49
+ }
50
+ export interface SessionFacts {
51
+ /** The rules that produced these numbers. */
52
+ extractor_version: string;
53
+ source: SessionFactsSource;
54
+ usage_by_model: Record<string, SessionFactsUsage>;
55
+ models: string[];
56
+ input_tokens: number;
57
+ output_tokens: number;
58
+ cache_read_tokens: number;
59
+ cache_creation_tokens: number;
60
+ total_tokens: number;
61
+ /** JSONL records that parsed. Unreadable lines are counted in `transcript_lines` only. */
62
+ record_count: number;
63
+ /** User prompts plus assistant responses; the two are also reported apart. */
64
+ turn_count: number;
65
+ user_prompt_count: number;
66
+ assistant_response_count: number;
67
+ tool_calls_by_name: Record<string, number>;
68
+ tool_call_count: number;
69
+ first_timestamp: string | null;
70
+ last_timestamp: string | null;
71
+ wall_duration_seconds: number | null;
72
+ transcript_bytes: number;
73
+ transcript_lines: number;
74
+ /** Main plus any sidecars the caller fed in. */
75
+ transcript_files: number;
76
+ /** Distinct models dropped by the cap. A truncation that says nothing is a silent failure. */
77
+ truncated_models: number;
78
+ /** Distinct tool names dropped by the cap. */
79
+ truncated_tool_names: number;
80
+ }
81
+ export interface SessionFactsAccumulator {
82
+ source: SessionFactsSource;
83
+ usage_by_model: Record<string, SessionFactsUsage>;
84
+ seen_usage_ids: Set<string>;
85
+ record_count: number;
86
+ user_prompt_count: number;
87
+ assistant_response_count: number;
88
+ tool_calls_by_name: Record<string, number>;
89
+ first_timestamp_ms: number | null;
90
+ last_timestamp_ms: number | null;
91
+ transcript_bytes: number;
92
+ transcript_lines: number;
93
+ transcript_files: number;
94
+ /** Codex only: the newest cumulative total and the model to attribute it to. */
95
+ codex_total_usage: Record<string, unknown> | null;
96
+ codex_model_hint: string | null;
97
+ }
98
+ export declare function newSessionFactsAccumulator(source: SessionFactsSource): SessionFactsAccumulator;
99
+ /** One transcript file's size, for callers that never hold its whole text. */
100
+ export declare function observeSessionFactsFile(acc: SessionFactsAccumulator, file: {
101
+ bytes: number;
102
+ lines: number;
103
+ }): void;
104
+ /** One parsed JSONL record. The caller owns parsing; this owns the rules. */
105
+ export declare function observeSessionFactsRecord(acc: SessionFactsAccumulator, record: Record<string, unknown>): void;
106
+ /**
107
+ * OpenAI semantics differ from the Anthropic-shaped usage the price table
108
+ * assumes: `input_tokens` INCLUDES `cached_input_tokens` and `output_tokens`
109
+ * already INCLUDES `reasoning_output_tokens`. Normalizing input to fresh-only
110
+ * is what keeps the list-price equivalent from inflating several-fold.
111
+ */
112
+ export declare function codexUsageFromCumulativeTotal(total: Record<string, unknown>): SessionFactsUsage;
113
+ export declare function finalizeSessionFacts(acc: SessionFactsAccumulator): SessionFacts;
114
+ /**
115
+ * One whole file into an existing accumulator, for a caller that has the text
116
+ * but is not already walking its records (a Claude subagent sidecar, say).
117
+ * Returns the line total so the caller can report what it read.
118
+ */
119
+ export declare function observeSessionFactsContent(acc: SessionFactsAccumulator, content: string): {
120
+ lines: number;
121
+ };
122
+ /**
123
+ * The whole-file door: give it every file of one session (the Claude main plus
124
+ * its subagent sidecars, or the one Codex rollout) and it returns the facts.
125
+ * Unparseable lines are counted in `transcript_lines` and skipped, exactly as
126
+ * the server extractor skips them.
127
+ */
128
+ export declare function computeSessionFacts(options: {
129
+ source: SessionFactsSource;
130
+ contents: readonly string[];
131
+ }): SessionFacts;
132
+ export declare const SessionFactsUsageSchema: z.ZodObject<{
133
+ input_tokens: z.ZodNumber;
134
+ output_tokens: z.ZodNumber;
135
+ cache_read_input_tokens: z.ZodNumber;
136
+ cache_creation_input_tokens: z.ZodNumber;
137
+ }, z.core.$strict>;
138
+ /**
139
+ * The wire shape. Optional wherever it rides an envelope: a CLI published
140
+ * before this change sends no block at all, and its uploads must still
141
+ * validate.
142
+ */
143
+ export declare const SessionFactsSchema: z.ZodObject<{
144
+ extractor_version: z.ZodString;
145
+ source: z.ZodEnum<{
146
+ codex: "codex";
147
+ claude_code: "claude_code";
148
+ }>;
149
+ usage_by_model: z.ZodRecord<z.ZodString, z.ZodObject<{
150
+ input_tokens: z.ZodNumber;
151
+ output_tokens: z.ZodNumber;
152
+ cache_read_input_tokens: z.ZodNumber;
153
+ cache_creation_input_tokens: z.ZodNumber;
154
+ }, z.core.$strict>>;
155
+ models: z.ZodArray<z.ZodString>;
156
+ input_tokens: z.ZodNumber;
157
+ output_tokens: z.ZodNumber;
158
+ cache_read_tokens: z.ZodNumber;
159
+ cache_creation_tokens: z.ZodNumber;
160
+ total_tokens: z.ZodNumber;
161
+ record_count: z.ZodNumber;
162
+ turn_count: z.ZodNumber;
163
+ user_prompt_count: z.ZodNumber;
164
+ assistant_response_count: z.ZodNumber;
165
+ tool_calls_by_name: z.ZodRecord<z.ZodString, z.ZodNumber>;
166
+ tool_call_count: z.ZodNumber;
167
+ first_timestamp: z.ZodNullable<z.ZodString>;
168
+ last_timestamp: z.ZodNullable<z.ZodString>;
169
+ wall_duration_seconds: z.ZodNullable<z.ZodNumber>;
170
+ transcript_bytes: z.ZodNumber;
171
+ transcript_lines: z.ZodNumber;
172
+ transcript_files: z.ZodNumber;
173
+ truncated_models: z.ZodNumber;
174
+ truncated_tool_names: z.ZodNumber;
175
+ }, z.core.$strict>;
176
+ export type SessionFactsWire = z.infer<typeof SessionFactsSchema>;
177
+ export { claudeAssistantText, claudeUserTurnText, codexAssistantText, codexUserTurnText, normalizeTurnText, textFromPlainTextBlocks, } from "./session-facts-text.js";
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Session facts: everything deterministic about one transcript, counted once,
3
+ * where the file already is.
4
+ *
5
+ * BLI-4341 part A. Until now the only place a session's token totals were
6
+ * computed was the server: `@bli-cockpit/harvest-analysis` downloaded the
7
+ * transcript back out of Storage and summed it (`det.v5`). The collector holds
8
+ * the same bytes at harvest time and had already read every line, so the
9
+ * download was pure re-work, and a session with no analysis run had no token
10
+ * count at all.
11
+ *
12
+ * This module is the ONE implementation of those rules. The collector feeds it
13
+ * at upload; the server extractor feeds it the same records during its own
14
+ * pass, so both sides count identically by construction rather than by review.
15
+ * `det.v6` is `det.v5`'s rules with one owner.
16
+ *
17
+ * Contract:
18
+ * - Pure. No filesystem, no network, no clock. Content in, numbers out.
19
+ * - METADATA ONLY on the way out: token counts, turn counts, tool NAMES and
20
+ * their counts, timestamps, byte/line sizes. Text is read in-process to
21
+ * decide whether a turn counted; it is never returned.
22
+ * - Two feeds, one rulebook: `observeSessionFactsRecord` for a caller that
23
+ * is already parsing the JSONL (the server extractor), and
24
+ * `computeSessionFacts` for a caller holding whole file contents (the
25
+ * collector).
26
+ *
27
+ * Counting rules, both verified against real transcripts:
28
+ * - Claude Code writes PER-TURN usage on each assistant record, repeated
29
+ * across the several records of one API request, so usage is summed with a
30
+ * dedupe on `message.id` (fallback `requestId`).
31
+ * - Codex writes a CUMULATIVE `total_token_usage` on every `token_count`
32
+ * event, so the last one wins, attributed to the `turn_context` model. Its
33
+ * `input_tokens` includes cached input and its `output_tokens` already
34
+ * includes reasoning, so input is normalized to fresh-only, matching the
35
+ * Anthropic-shaped usage the price table expects.
36
+ */
37
+ import { z } from "zod";
38
+ import { claudeAssistantText, claudeUserTurnText, codexAssistantText, codexUserTurnText, } from "./session-facts-text.js";
39
+ /** Bump when a counting rule changes, never when a field is added. */
40
+ export const SESSION_FACTS_VERSION = "det.v6";
41
+ /** How many distinct models and tool names one session may report. */
42
+ export const SESSION_FACTS_MAX_MODELS = 50;
43
+ export const SESSION_FACTS_MAX_TOOL_NAMES = 200;
44
+ const MAX_MODEL_NAME_CHARS = 200;
45
+ const MAX_TOOL_NAME_CHARS = 120;
46
+ export function newSessionFactsAccumulator(source) {
47
+ return {
48
+ source,
49
+ usage_by_model: {},
50
+ seen_usage_ids: new Set(),
51
+ record_count: 0,
52
+ user_prompt_count: 0,
53
+ assistant_response_count: 0,
54
+ tool_calls_by_name: {},
55
+ first_timestamp_ms: null,
56
+ last_timestamp_ms: null,
57
+ transcript_bytes: 0,
58
+ transcript_lines: 0,
59
+ transcript_files: 0,
60
+ codex_total_usage: null,
61
+ codex_model_hint: null,
62
+ };
63
+ }
64
+ /** One transcript file's size, for callers that never hold its whole text. */
65
+ export function observeSessionFactsFile(acc, file) {
66
+ acc.transcript_files += 1;
67
+ acc.transcript_bytes += Math.max(0, Math.trunc(file.bytes));
68
+ acc.transcript_lines += Math.max(0, Math.trunc(file.lines));
69
+ }
70
+ /** One parsed JSONL record. The caller owns parsing; this owns the rules. */
71
+ export function observeSessionFactsRecord(acc, record) {
72
+ acc.record_count += 1;
73
+ const ms = parseTimestampMs(record["timestamp"]);
74
+ if (ms != null) {
75
+ acc.first_timestamp_ms =
76
+ acc.first_timestamp_ms == null ? ms : Math.min(acc.first_timestamp_ms, ms);
77
+ acc.last_timestamp_ms =
78
+ acc.last_timestamp_ms == null ? ms : Math.max(acc.last_timestamp_ms, ms);
79
+ }
80
+ if (acc.source === "claude_code")
81
+ observeClaudeRecord(acc, record);
82
+ else
83
+ observeCodexRecord(acc, record);
84
+ }
85
+ function observeClaudeRecord(acc, record) {
86
+ const type = typeof record["type"] === "string" ? record["type"] : "";
87
+ if (type === "user") {
88
+ if (record["isMeta"] === true)
89
+ return;
90
+ const text = claudeUserTurnText(record);
91
+ if (text?.trim())
92
+ acc.user_prompt_count += 1;
93
+ return;
94
+ }
95
+ if (type !== "assistant")
96
+ return;
97
+ const message = record["message"];
98
+ if (!message || typeof message !== "object")
99
+ return;
100
+ const msg = message;
101
+ const model = typeof msg["model"] === "string" ? msg["model"] : null;
102
+ const usage = msg["usage"];
103
+ // One API request emits several records repeating the same usage; the id is
104
+ // what makes summing them safe.
105
+ const usageId = (typeof msg["id"] === "string" && msg["id"]) ||
106
+ (typeof record["requestId"] === "string" && record["requestId"]) ||
107
+ null;
108
+ if (model && usage && typeof usage === "object" && usageId && !acc.seen_usage_ids.has(usageId)) {
109
+ acc.seen_usage_ids.add(usageId);
110
+ addClaudeUsage(acc, model, usage);
111
+ }
112
+ if (claudeAssistantText(record)?.trim())
113
+ acc.assistant_response_count += 1;
114
+ const content = msg["content"];
115
+ if (!Array.isArray(content))
116
+ return;
117
+ for (const block of content) {
118
+ if (!block || typeof block !== "object")
119
+ continue;
120
+ const b = block;
121
+ if (b["type"] !== "tool_use")
122
+ continue;
123
+ countTool(acc, typeof b["name"] === "string" ? b["name"] : "unknown");
124
+ }
125
+ }
126
+ function observeCodexRecord(acc, record) {
127
+ const type = String(record["type"] ?? "");
128
+ const payload = record["payload"];
129
+ const p = payload && typeof payload === "object" ? payload : {};
130
+ if (type === "turn_context") {
131
+ if (typeof p["model"] === "string")
132
+ acc.codex_model_hint = p["model"];
133
+ return;
134
+ }
135
+ if (type === "response_item") {
136
+ const itemType = String(p["type"] ?? "");
137
+ if ((itemType === "message" || itemType === "user_message") && p["role"] === "user") {
138
+ if (codexUserTurnText(p)?.trim())
139
+ acc.user_prompt_count += 1;
140
+ return;
141
+ }
142
+ if (itemType === "message" && p["role"] === "assistant") {
143
+ if (codexAssistantText(p)?.trim())
144
+ acc.assistant_response_count += 1;
145
+ return;
146
+ }
147
+ if (itemType === "function_call") {
148
+ countTool(acc, typeof p["name"] === "string" ? p["name"] : "unknown");
149
+ }
150
+ return;
151
+ }
152
+ if (type !== "event_msg")
153
+ return;
154
+ if (String(p["type"] ?? "") !== "token_count")
155
+ return;
156
+ const info = p["info"];
157
+ if (!info || typeof info !== "object")
158
+ return;
159
+ const total = info["total_token_usage"];
160
+ // Cumulative: the last event of the session is the session total.
161
+ if (total && typeof total === "object") {
162
+ acc.codex_total_usage = total;
163
+ }
164
+ }
165
+ function countTool(acc, name) {
166
+ const key = name.slice(0, MAX_TOOL_NAME_CHARS) || "unknown";
167
+ acc.tool_calls_by_name[key] = (acc.tool_calls_by_name[key] ?? 0) + 1;
168
+ }
169
+ function addClaudeUsage(acc, model, usage) {
170
+ const key = model.slice(0, MAX_MODEL_NAME_CHARS);
171
+ const current = acc.usage_by_model[key] ?? emptyUsage();
172
+ current.input_tokens += num(usage["input_tokens"]);
173
+ current.output_tokens += num(usage["output_tokens"]);
174
+ current.cache_read_input_tokens += num(usage["cache_read_input_tokens"]);
175
+ current.cache_creation_input_tokens += num(usage["cache_creation_input_tokens"]);
176
+ acc.usage_by_model[key] = current;
177
+ }
178
+ /**
179
+ * OpenAI semantics differ from the Anthropic-shaped usage the price table
180
+ * assumes: `input_tokens` INCLUDES `cached_input_tokens` and `output_tokens`
181
+ * already INCLUDES `reasoning_output_tokens`. Normalizing input to fresh-only
182
+ * is what keeps the list-price equivalent from inflating several-fold.
183
+ */
184
+ export function codexUsageFromCumulativeTotal(total) {
185
+ const input = num(total["input_tokens"]);
186
+ const cached = num(total["cached_input_tokens"]);
187
+ return {
188
+ input_tokens: Math.max(0, input - cached),
189
+ output_tokens: num(total["output_tokens"]),
190
+ cache_read_input_tokens: cached,
191
+ cache_creation_input_tokens: 0,
192
+ };
193
+ }
194
+ export function finalizeSessionFacts(acc) {
195
+ // A Codex session whose usage never named a model is left uncounted rather
196
+ // than filed under a guess: det.v5 did the same, and a token total on the
197
+ // wrong model is money attributed to the wrong place.
198
+ if (acc.codex_total_usage && acc.codex_model_hint) {
199
+ acc.usage_by_model[acc.codex_model_hint.slice(0, MAX_MODEL_NAME_CHARS)] =
200
+ codexUsageFromCumulativeTotal(acc.codex_total_usage);
201
+ }
202
+ const usageEntries = Object.entries(acc.usage_by_model).sort((a, b) => usageTotal(b[1]) - usageTotal(a[1]) || a[0].localeCompare(b[0]));
203
+ const keptUsage = usageEntries.slice(0, SESSION_FACTS_MAX_MODELS);
204
+ const toolEntries = Object.entries(acc.tool_calls_by_name).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
205
+ const keptTools = toolEntries.slice(0, SESSION_FACTS_MAX_TOOL_NAMES);
206
+ const usage_by_model = Object.fromEntries(keptUsage);
207
+ const tool_calls_by_name = Object.fromEntries(keptTools);
208
+ const totals = keptUsage.reduce((sum, [, usage]) => ({
209
+ input_tokens: sum.input_tokens + usage.input_tokens,
210
+ output_tokens: sum.output_tokens + usage.output_tokens,
211
+ cache_read_input_tokens: sum.cache_read_input_tokens + usage.cache_read_input_tokens,
212
+ cache_creation_input_tokens: sum.cache_creation_input_tokens + usage.cache_creation_input_tokens,
213
+ }), emptyUsage());
214
+ const first = acc.first_timestamp_ms;
215
+ const last = acc.last_timestamp_ms;
216
+ return {
217
+ extractor_version: SESSION_FACTS_VERSION,
218
+ source: acc.source,
219
+ usage_by_model,
220
+ models: keptUsage.map(([model]) => model).sort((a, b) => a.localeCompare(b)),
221
+ input_tokens: totals.input_tokens,
222
+ output_tokens: totals.output_tokens,
223
+ cache_read_tokens: totals.cache_read_input_tokens,
224
+ cache_creation_tokens: totals.cache_creation_input_tokens,
225
+ total_tokens: usageTotal(totals),
226
+ record_count: acc.record_count,
227
+ turn_count: acc.user_prompt_count + acc.assistant_response_count,
228
+ user_prompt_count: acc.user_prompt_count,
229
+ assistant_response_count: acc.assistant_response_count,
230
+ tool_calls_by_name,
231
+ tool_call_count: keptTools.reduce((sum, [, count]) => sum + count, 0),
232
+ first_timestamp: first == null ? null : new Date(first).toISOString(),
233
+ last_timestamp: last == null ? null : new Date(last).toISOString(),
234
+ wall_duration_seconds: first == null || last == null ? null : Math.max(0, Math.round((last - first) / 1000)),
235
+ transcript_bytes: acc.transcript_bytes,
236
+ transcript_lines: acc.transcript_lines,
237
+ transcript_files: acc.transcript_files,
238
+ truncated_models: usageEntries.length - keptUsage.length,
239
+ truncated_tool_names: toolEntries.length - keptTools.length,
240
+ };
241
+ }
242
+ /**
243
+ * One whole file into an existing accumulator, for a caller that has the text
244
+ * but is not already walking its records (a Claude subagent sidecar, say).
245
+ * Returns the line total so the caller can report what it read.
246
+ */
247
+ export function observeSessionFactsContent(acc, content) {
248
+ let lines = 0;
249
+ for (const line of content.split("\n")) {
250
+ if (!line.trim())
251
+ continue;
252
+ lines += 1;
253
+ let record;
254
+ try {
255
+ record = JSON.parse(line);
256
+ }
257
+ catch {
258
+ // Silent by design: the parser's message quotes the offending line,
259
+ // which is transcript content. The line still counts toward the file's
260
+ // line total, so the loss is visible as a number.
261
+ continue;
262
+ }
263
+ if (!record || typeof record !== "object" || Array.isArray(record))
264
+ continue;
265
+ observeSessionFactsRecord(acc, record);
266
+ }
267
+ observeSessionFactsFile(acc, { bytes: byteLength(content), lines });
268
+ return { lines };
269
+ }
270
+ /**
271
+ * The whole-file door: give it every file of one session (the Claude main plus
272
+ * its subagent sidecars, or the one Codex rollout) and it returns the facts.
273
+ * Unparseable lines are counted in `transcript_lines` and skipped, exactly as
274
+ * the server extractor skips them.
275
+ */
276
+ export function computeSessionFacts(options) {
277
+ const acc = newSessionFactsAccumulator(options.source);
278
+ for (const content of options.contents) {
279
+ observeSessionFactsContent(acc, content);
280
+ }
281
+ return finalizeSessionFacts(acc);
282
+ }
283
+ function usageTotal(usage) {
284
+ return (usage.input_tokens +
285
+ usage.output_tokens +
286
+ usage.cache_read_input_tokens +
287
+ usage.cache_creation_input_tokens);
288
+ }
289
+ function emptyUsage() {
290
+ return {
291
+ input_tokens: 0,
292
+ output_tokens: 0,
293
+ cache_read_input_tokens: 0,
294
+ cache_creation_input_tokens: 0,
295
+ };
296
+ }
297
+ function byteLength(value) {
298
+ return typeof Buffer === "undefined"
299
+ ? new TextEncoder().encode(value).length
300
+ : Buffer.byteLength(value, "utf8");
301
+ }
302
+ function parseTimestampMs(value) {
303
+ if (typeof value !== "string")
304
+ return null;
305
+ const ms = Date.parse(value);
306
+ return Number.isFinite(ms) ? ms : null;
307
+ }
308
+ function num(value) {
309
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
310
+ }
311
+ const TokenCountSchema = z.number().int().nonnegative().max(1_000_000_000_000);
312
+ export const SessionFactsUsageSchema = z
313
+ .object({
314
+ input_tokens: TokenCountSchema,
315
+ output_tokens: TokenCountSchema,
316
+ cache_read_input_tokens: TokenCountSchema,
317
+ cache_creation_input_tokens: TokenCountSchema,
318
+ })
319
+ .strict();
320
+ /**
321
+ * The wire shape. Optional wherever it rides an envelope: a CLI published
322
+ * before this change sends no block at all, and its uploads must still
323
+ * validate.
324
+ */
325
+ export const SessionFactsSchema = z
326
+ .object({
327
+ extractor_version: z.string().trim().min(1).max(40),
328
+ source: z.enum(["codex", "claude_code"]),
329
+ usage_by_model: z
330
+ .record(z.string().min(1).max(MAX_MODEL_NAME_CHARS), SessionFactsUsageSchema)
331
+ .refine((value) => Object.keys(value).length <= SESSION_FACTS_MAX_MODELS, { message: "too many models" }),
332
+ models: z.array(z.string().min(1).max(MAX_MODEL_NAME_CHARS)).max(SESSION_FACTS_MAX_MODELS),
333
+ input_tokens: TokenCountSchema,
334
+ output_tokens: TokenCountSchema,
335
+ cache_read_tokens: TokenCountSchema,
336
+ cache_creation_tokens: TokenCountSchema,
337
+ total_tokens: TokenCountSchema,
338
+ record_count: z.number().int().nonnegative(),
339
+ turn_count: z.number().int().nonnegative(),
340
+ user_prompt_count: z.number().int().nonnegative(),
341
+ assistant_response_count: z.number().int().nonnegative(),
342
+ tool_calls_by_name: z
343
+ .record(z.string().min(1).max(MAX_TOOL_NAME_CHARS), z.number().int().nonnegative())
344
+ .refine((value) => Object.keys(value).length <= SESSION_FACTS_MAX_TOOL_NAMES, { message: "too many tool names" }),
345
+ tool_call_count: z.number().int().nonnegative(),
346
+ first_timestamp: z.string().datetime({ offset: true }).nullable(),
347
+ last_timestamp: z.string().datetime({ offset: true }).nullable(),
348
+ wall_duration_seconds: z.number().int().nonnegative().nullable(),
349
+ transcript_bytes: z.number().int().nonnegative(),
350
+ transcript_lines: z.number().int().nonnegative(),
351
+ transcript_files: z.number().int().nonnegative(),
352
+ truncated_models: z.number().int().nonnegative(),
353
+ truncated_tool_names: z.number().int().nonnegative(),
354
+ })
355
+ .strict();
356
+ export { claudeAssistantText, claudeUserTurnText, codexAssistantText, codexUserTurnText, normalizeTurnText, textFromPlainTextBlocks, } from "./session-facts-text.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/telemetry-core",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,11 +17,13 @@
17
17
  },
18
18
  "./memory-experience": {
19
19
  "types": "./dist/memory-experience.d.ts",
20
- "import": "./dist/memory-experience.js"
20
+ "import": "./dist/memory-experience.js",
21
+ "default": "./dist/memory-experience.js"
21
22
  },
22
23
  "./evidence-storage-encoding": {
23
24
  "types": "./dist/evidence-storage-encoding.d.ts",
24
- "import": "./dist/evidence-storage-encoding.js"
25
+ "import": "./dist/evidence-storage-encoding.js",
26
+ "default": "./dist/evidence-storage-encoding.js"
25
27
  }
26
28
  },
27
29
  "publishConfig": {