@bli-cockpit/cli 0.2.113 → 0.2.115

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.
@@ -1,3 +1,4 @@
1
+ import { finalizeSessionFacts, newSessionFactsAccumulator, observeSessionFactsContent, observeSessionFactsFile, } from "@bli-cockpit/telemetry-core";
1
2
  import crypto from "node:crypto";
2
3
  import { existsSync } from "node:fs";
3
4
  import fs from "node:fs/promises";
@@ -11,9 +12,15 @@ import { CLAUDE_SESSION_MAX_FILE_BYTES, } from "./claude-attribution-types.js";
11
12
  * Turns one discovered session into an attribution verdict: read (or, for an
12
13
  * oversized main, stream) its signals, score them against the known
13
14
  * worktrees, and validate its sidecars against the winning worktree.
15
+ *
16
+ * The same pass counts the session's deterministic facts (BLI-4341) so the
17
+ * upload carries its own token totals. Only files that belong to this session
18
+ * are counted: a sidecar rejected for a cwd mismatch is another worktree's
19
+ * work and must not inflate this session's numbers.
14
20
  */
15
21
  export async function attributeOneSession(session, worktrees, collectionRoots) {
16
22
  const fileName = path.basename(session.mainFile);
23
+ const factsAcc = newSessionFactsAccumulator("claude_code");
17
24
  const base = {
18
25
  file_path: session.mainFile,
19
26
  file_name: fileName,
@@ -29,6 +36,7 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
29
36
  oversized_lines_skipped: 0,
30
37
  sidecars_capped: session.sidecarsCapped,
31
38
  sidecar_files: [],
39
+ session_facts: null,
32
40
  };
33
41
  if (session.mainByteSize === 0) {
34
42
  return skippedResult(base, "empty_file");
@@ -40,7 +48,7 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
40
48
  // bytes themselves are not uploaded until the collection ceiling changes.
41
49
  let streamed;
42
50
  try {
43
- streamed = await streamMainSignals(session.mainFile);
51
+ streamed = await streamMainSignals(session.mainFile, factsAcc);
44
52
  }
45
53
  catch (error) {
46
54
  // A read race on one oversized main must not abort the whole scan; honor
@@ -55,6 +63,10 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
55
63
  return skippedResult(base, "file_read_failed");
56
64
  }
57
65
  signals = streamed.signals;
66
+ observeSessionFactsFile(factsAcc, {
67
+ bytes: streamed.byteSize,
68
+ lines: streamed.signals.line_count,
69
+ });
58
70
  base.content_hash_sha256 = streamed.contentHash;
59
71
  base.byte_size = streamed.byteSize;
60
72
  base.main_file_oversized = true;
@@ -80,7 +92,11 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
80
92
  if (content.trim() === "") {
81
93
  return skippedResult(base, "empty_file");
82
94
  }
83
- signals = extractClaudeSessionSignals(content);
95
+ signals = extractClaudeSessionSignals(content, factsAcc);
96
+ observeSessionFactsFile(factsAcc, {
97
+ bytes: raw.byteLength,
98
+ lines: signals.line_count,
99
+ });
84
100
  }
85
101
  const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
86
102
  if (metaSessionId) {
@@ -116,7 +132,7 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
116
132
  if (isSchemaDriftSuspected(signals))
117
133
  extraSignals.push("schema_drift_suspected");
118
134
  const sidecarFiles = isRawEvidenceUploadableAttributionState(outcome.state, outcome.worktree !== null) && outcome.worktree
119
- ? await collectSidecarDiagnostics(session.sidecars, outcome.worktree)
135
+ ? await collectSidecarDiagnostics(session.sidecars, outcome.worktree, factsAcc)
120
136
  : session.sidecars.map((sidecar) => ({
121
137
  local_path: sidecar.local_path,
122
138
  file_name: sidecar.file_name,
@@ -126,6 +142,7 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
126
142
  }));
127
143
  return {
128
144
  ...base,
145
+ session_facts: finalizeSessionFacts(factsAcc),
129
146
  state: outcome.state,
130
147
  reason: outcome.reason,
131
148
  signals: [...outcome.signals, ...extraSignals],
@@ -143,7 +160,7 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
143
160
  * oversized sidecar is recorded with a reason and the session stays
144
161
  * harvestable.
145
162
  */
146
- async function collectSidecarDiagnostics(sidecars, worktree) {
163
+ async function collectSidecarDiagnostics(sidecars, worktree, facts) {
147
164
  const out = [];
148
165
  for (const sidecar of sidecars) {
149
166
  const entry = {
@@ -181,6 +198,9 @@ async function collectSidecarDiagnostics(sidecars, worktree) {
181
198
  out.push({ ...entry, skipped_reason: "sidecar_cwd_mismatch" });
182
199
  continue;
183
200
  }
201
+ // Accepted: this subagent's turns are part of this session, so its tokens
202
+ // are too. Counted only now, after the cwd check has cleared it.
203
+ observeSessionFactsContent(facts, content);
184
204
  out.push({
185
205
  ...entry,
186
206
  byte_size: raw.byteLength,
@@ -1,3 +1,4 @@
1
+ import { observeSessionFactsRecord, } from "@bli-cockpit/telemetry-core";
1
2
  import crypto from "node:crypto";
2
3
  import { createReadStream } from "node:fs";
3
4
  import { StringDecoder } from "node:string_decoder";
@@ -7,15 +8,18 @@ import { CONTENT_RECORD_TYPES, MAX_LINE_BUFFER_BYTES, SCHEMA_DRIFT_MIN_HIT_RATE,
7
8
  * `cwd`, `gitBranch`, `sessionId`, and `prRepository` (pr-link records).
8
9
  * Structurally identical to the Codex extractor, with the allowlist enforced
9
10
  * by construction — no generic record walk that could surface message bodies.
11
+ *
12
+ * Pass a session-facts accumulator to have the SAME parse feed the upload-time
13
+ * deterministic count (BLI-4341); without one the pass is unchanged.
10
14
  */
11
- export function extractClaudeSessionSignals(content) {
12
- const accumulator = createSignalAccumulator();
15
+ export function extractClaudeSessionSignals(content, facts) {
16
+ const accumulator = createSignalAccumulator(facts);
13
17
  for (const line of content.split("\n")) {
14
18
  accumulator.processLine(line);
15
19
  }
16
20
  return accumulator.finalize();
17
21
  }
18
- function createSignalAccumulator() {
22
+ function createSignalAccumulator(facts) {
19
23
  const sessionIds = new Set();
20
24
  const cwds = new Set();
21
25
  const branches = new Set();
@@ -41,9 +45,12 @@ function createSignalAccumulator() {
41
45
  parseErrorCount += 1;
42
46
  return;
43
47
  }
44
- if (!record || typeof record !== "object")
48
+ if (!record || typeof record !== "object" || Array.isArray(record))
45
49
  return;
46
50
  const entry = record;
51
+ // Counting is not reading: the facts module returns totals, never text.
52
+ if (facts)
53
+ observeSessionFactsRecord(facts, entry);
47
54
  const type = typeof entry["type"] === "string" ? entry["type"] : "";
48
55
  const cwd = stringOrNull(entry["cwd"]);
49
56
  const sessionId = stringOrNull(entry["sessionId"]);
@@ -96,8 +103,8 @@ export function isSchemaDriftSuspected(signals) {
96
103
  return true;
97
104
  return signals.envelope_field_hit_rate < SCHEMA_DRIFT_MIN_HIT_RATE;
98
105
  }
99
- export async function streamMainSignals(filePath) {
100
- const accumulator = createSignalAccumulator();
106
+ export async function streamMainSignals(filePath, facts) {
107
+ const accumulator = createSignalAccumulator(facts);
101
108
  const hash = crypto.createHash("sha256");
102
109
  let oversizedLinesSkipped = 0;
103
110
  let byteSize = 0;
@@ -1,4 +1,4 @@
1
- import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
1
+ import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, } from "@bli-cockpit/telemetry-core";
2
2
  /**
3
3
  * The shapes and tuning constants `claude-attribution.ts` and its siblings
4
4
  * (discovery, score, signals) all agree on. Kept in one module so none of
@@ -173,6 +173,7 @@ async function attributeOneSession(file, worktrees, collectionRoots) {
173
173
  session_file_mtime_ms: file.mtimeMs,
174
174
  byte_size: file.byteSize,
175
175
  content_hash_sha256: null,
176
+ session_facts: null,
176
177
  };
177
178
  let read;
178
179
  try {
@@ -191,6 +192,7 @@ async function attributeOneSession(file, worktrees, collectionRoots) {
191
192
  }
192
193
  base.content_hash_sha256 = read.contentHashSha256;
193
194
  base.byte_size = read.byteSize;
195
+ base.session_facts = read.facts;
194
196
  const signals = read.signals;
195
197
  const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
196
198
  if (metaSessionId) {
@@ -11,7 +11,13 @@
11
11
  * disk (sessions run to hundreds of megabytes, so the file is never held in
12
12
  * memory and its sha256 is taken on the way past), and
13
13
  * `extractCodexSessionSignals` takes content already in hand.
14
+ *
15
+ * The same streaming pass also counts the session's deterministic facts
16
+ * (BLI-4341): token totals per model, turn and tool counts, timestamps, size.
17
+ * The bytes are already in hand and already parsed, so counting them here
18
+ * costs one function call per record and saves the server a download.
14
19
  */
20
+ import { finalizeSessionFacts, newSessionFactsAccumulator, observeSessionFactsFile, observeSessionFactsRecord, } from "@bli-cockpit/telemetry-core";
15
21
  import { createReadStream } from "node:fs";
16
22
  import crypto from "node:crypto";
17
23
  import { StringDecoder } from "node:string_decoder";
@@ -63,8 +69,10 @@ export async function readCodexMetadataSignals(filePath) {
63
69
  lineBuffer += rest;
64
70
  if (lineBuffer.trim())
65
71
  absorbCodexSessionLine(state, lineBuffer);
72
+ observeSessionFactsFile(state.facts, { bytes: byteSize, lines: state.lineCount });
66
73
  return {
67
74
  signals: codexSignalsFromState(state),
75
+ facts: finalizeSessionFacts(state.facts),
68
76
  contentHashSha256: hash.digest("hex"),
69
77
  byteSize,
70
78
  };
@@ -86,6 +94,7 @@ function makeSignalExtractionState() {
86
94
  repositoryUrls: new Set(),
87
95
  lineCount: 0,
88
96
  parseErrorCount: 0,
97
+ facts: newSessionFactsAccumulator("codex"),
89
98
  };
90
99
  }
91
100
  function absorbCodexSessionLine(state, line) {
@@ -105,8 +114,11 @@ function absorbCodexSessionLine(state, line) {
105
114
  state.parseErrorCount += 1;
106
115
  return;
107
116
  }
108
- if (!record || typeof record !== "object")
117
+ if (!record || typeof record !== "object" || Array.isArray(record))
109
118
  return;
119
+ // Every record feeds the facts count; only the two metadata types below feed
120
+ // attribution.
121
+ observeSessionFactsRecord(state.facts, record);
110
122
  const type = record.type;
111
123
  if (type !== "session_meta" && type !== "turn_context")
112
124
  return;
@@ -43,6 +43,7 @@ function normalizeCodexResult(result) {
43
43
  worktree: result.worktree,
44
44
  cwd_basename: result.cwd_basename,
45
45
  cwd_hash: result.cwd_hash,
46
+ session_facts: result.session_facts,
46
47
  };
47
48
  }
48
49
  function normalizeClaudeResult(result) {
@@ -61,6 +62,7 @@ function normalizeClaudeResult(result) {
61
62
  worktree: result.worktree,
62
63
  cwd_basename: result.cwd_basename,
63
64
  cwd_hash: result.cwd_hash,
65
+ session_facts: result.session_facts,
64
66
  };
65
67
  }
66
68
  /**
@@ -154,6 +156,10 @@ function sessionReportEntry(result, context) {
154
156
  : {}),
155
157
  session_file_byte_size: result.byte_size,
156
158
  session_file_mtime: result.session_file_mtime,
159
+ // Every session arrives counted (BLI-4341). Absent only when the file
160
+ // could not be read at all, which is the one case the server's extraction
161
+ // pass still has to cover.
162
+ ...(result.session_facts ? { session_facts: result.session_facts } : {}),
157
163
  ...(result.worktree
158
164
  ? {
159
165
  repo_fingerprint: result.worktree.repo_fingerprint,
@@ -15,9 +15,22 @@ export function renderLadder(ladder, dim) {
15
15
  }
16
16
  if (reasons.length > 8)
17
17
  lines.push(dim(` ${reasons.length - 8} more reasons not shown`));
18
+ // BLI-4343: deferred is its OWN count. A row waiting on a corpus pass is
19
+ // pending and not stuck, and the two look identical without this line.
20
+ if ((ladder.deferred ?? 0) > 0) {
21
+ const soonest = ladder.soonest_defer_until;
22
+ const when = typeof soonest === "string" ? `, soonest retry ${soonest}` : "";
23
+ lines.push(dim(` ${ladder.deferred} row(s) deferred, waiting on another pass${when}`));
24
+ }
18
25
  if ((ladder.stale_leases ?? 0) > 0) {
19
26
  lines.push(dim(` ${ladder.stale_leases} stale lease(s) waiting to be taken over`));
20
27
  }
28
+ // BLI-4352: work no rung status can show. A session whose four rungs all
29
+ // succeeded can still be carrying the old token-weight session label, and
30
+ // the re-label run is steered by this number.
31
+ if ((ladder.labels_v1_remaining ?? 0) > 0) {
32
+ lines.push(dim(` ${ladder.labels_v1_remaining} session label(s) still on the old provenance`));
33
+ }
21
34
  if (ladder.oldest_pending_session_id) {
22
35
  lines.push(dim(` oldest pending session ${ladder.oldest_pending_session_id}`));
23
36
  }
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.113");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.115");
19
19
  return 0;
20
20
  }
21
21
 
@@ -108,6 +108,16 @@ export async function runAttributedWorktreeSync(options) {
108
108
  now,
109
109
  claudePriorDurablePointers: worktreePass.claudePriorDurablePointers,
110
110
  });
111
+ // BLI-4341: every session should arrive counted. Say how many did, and say
112
+ // it on the success path too: a silent drop from "all" to "some" is exactly
113
+ // the kind of decay nobody notices.
114
+ const sessionsCounted = sessions.filter((session) => session.session_facts).length;
115
+ console.error("[session-sync] session facts counted at upload", JSON.stringify({
116
+ reason: sessionsCounted === sessions.length ? "counted_at_upload" : "some_sessions_unreadable",
117
+ sessions: sessions.length,
118
+ counted: sessionsCounted,
119
+ tokens_total: sessions.reduce((total, session) => total + (session.session_facts?.total_tokens ?? 0), 0),
120
+ }));
111
121
  const delivery = await reportSessionsAndAdvanceCursors({
112
122
  run: options,
113
123
  paths,
@@ -43,7 +43,7 @@ export async function runUsage(command, io) {
43
43
  if (!email)
44
44
  return failAgentDoor(door, "[usage]", "caller_email_unavailable", "The paired session has no email. Sign in again or pass --person <email>.");
45
45
  body.people = (body.people ?? []).filter((person) => person.email?.toLowerCase() === email.toLowerCase());
46
- body.coverage = { sessions_labelled: body.people.reduce((total, person) => total + (person.sessions_labelled ?? 0), 0), sessions_extracted: body.people.reduce((total, person) => total + person.sessions_extracted, 0), sessions_observed: body.people.reduce((total, person) => total + person.sessions_observed, 0) };
46
+ body.coverage = { sessions_labelled: body.people.reduce((total, person) => total + (person.sessions_labelled ?? 0), 0), sessions_extracted: body.people.reduce((total, person) => total + person.sessions_extracted, 0), sessions_observed: body.people.reduce((total, person) => total + person.sessions_observed, 0), sessions_counted_at_upload: body.people.reduce((total, person) => total + (person.sessions_counted_at_upload ?? 0), 0), sessions_counted_by_server: body.people.reduce((total, person) => total + (person.sessions_counted_by_server ?? 0), 0), sessions_counted_by_ladder: body.people.reduce((total, person) => total + (person.sessions_counted_by_ladder ?? 0), 0) };
47
47
  }
48
48
  if (command.byRepo && body.people?.some((person) => !Array.isArray(person.repos))) {
49
49
  return failAgentDoor(door, "[usage]", "repo_grouping_unavailable", "The dashboard returned person totals without project rows. Deploy the dashboard project split before using --by-repo.");
@@ -104,6 +104,11 @@ export async function runUsage(command, io) {
104
104
  writeLine(io.stdout, "");
105
105
  writeLine(io.stdout, body.api_list_price_equivalent_label ?? "API list-price equivalent (not actual spend)");
106
106
  writeLine(io.stdout, `${body.coverage?.sessions_extracted ?? 0} of ${body.coverage?.sessions_observed ?? 0} sessions extracted`);
107
+ // BLI-4341: a session should arrive counted. This line is how a person sees
108
+ // whether that is happening, or whether something else is doing the work.
109
+ // BLI-4351 added the third counter: the ladder, counting out of Storage the
110
+ // sessions that arrived before the collector counted anything.
111
+ writeLine(io.stdout, `${body.coverage?.sessions_counted_at_upload ?? 0} of ${body.coverage?.sessions_observed ?? 0} sessions counted at upload, ${body.coverage?.sessions_counted_by_server ?? 0} by server backfill, ${body.coverage?.sessions_counted_by_ladder ?? 0} by the ladder`);
107
112
  if (command.bySubject)
108
113
  writeLine(io.stdout, `${(body.people ?? []).reduce((n, person) => n + (person.sessions_summarized ?? 0), 0)} of ${body.coverage?.sessions_observed ?? 0} sessions summarized`);
109
114
  if (command.byTopic)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.113",
3
+ "version": "0.2.115",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,8 +27,8 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.31",
31
- "@bli-cockpit/mcp": "0.1.41",
32
- "@bli-cockpit/telemetry-core": "0.1.47"
30
+ "@bli-cockpit/memory-mcp": "0.1.32",
31
+ "@bli-cockpit/mcp": "0.1.43",
32
+ "@bli-cockpit/telemetry-core": "0.1.48"
33
33
  }
34
34
  }