@bongos/core 1.19.317 → 1.19.318

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/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.317",
6
- "core_contract": "1.19.317",
7
- "source_commit": "47d557d1d8a73aac8e99002c7a282f7bab39a700",
5
+ "core_version": "1.19.318",
6
+ "core_contract": "1.19.318",
7
+ "source_commit": "01d0a25617f7ca9212b310552724d7eb8c183a32",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-23T22:06:51.573Z",
9
+ "built_at": "2026-08-23T22:11:19.148Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 323,
13
13
  "agent_docs_stubbed": 22,
14
- "functional_verbatim": 1436,
14
+ "functional_verbatim": 1438,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1781,
20
- "tree_sha256": "1a2430cfe83da4de17388e4663512e1eac2d35a6269e0f124f26e7004d09f0a8",
19
+ "file_count": 1783,
20
+ "tree_sha256": "24fa1a6a45d9a8ad89550dcdf06b82e5aeffa56776dcd6666a1be6524d9d4a6e",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2987,7 +2987,7 @@
2987
2987
  {
2988
2988
  "path": "modules/economy/cost.js",
2989
2989
  "mode": "0000644",
2990
- "sha256": "5d125e8b884ebfeb5687ee6748c49bf752d5757114ede98d0cabf2eee4a38b76"
2990
+ "sha256": "01bc7725fe16388971fd4def2544d0de9af6b3a522973df7ef86386737933d40"
2991
2991
  },
2992
2992
  {
2993
2993
  "path": "modules/economy/credits.js",
@@ -3972,7 +3972,7 @@
3972
3972
  {
3973
3973
  "path": "modules/lifecycle/publish-reconciler.js",
3974
3974
  "mode": "0000644",
3975
- "sha256": "8f08c6d58db41ba247aaf5b04b5a8988365b469e96c0ad04c336f272ef308c4e"
3975
+ "sha256": "28c61d55fe2b492f0fce4c99b60e88f0cbeb542c47c04482558818d65628e0d7"
3976
3976
  },
3977
3977
  {
3978
3978
  "path": "modules/lifecycle/routes/analytics.js",
@@ -4642,12 +4642,12 @@
4642
4642
  {
4643
4643
  "path": "package-lock.json",
4644
4644
  "mode": "0000644",
4645
- "sha256": "1bdb7a2ab4583036c92b3108244d5c30dc8aa6314ba28eb2339f3712deec806a"
4645
+ "sha256": "509e4a76d5eb6f21fd8c926c6bf7ec9f1938d62ed4055558e2d3911858166b38"
4646
4646
  },
4647
4647
  {
4648
4648
  "path": "package.json",
4649
4649
  "mode": "0000644",
4650
- "sha256": "2bbe8915c29c3f2cde9ef26aa885ddeb123f32caea804ed3146090147fa68a4d"
4650
+ "sha256": "5e3c53bb9627f4dcf747e5e7bc8fa7fb0e136554f04d96081137ca1bc05654ad"
4651
4651
  },
4652
4652
  {
4653
4653
  "path": "public-docs/index.html",
@@ -5672,7 +5672,7 @@
5672
5672
  {
5673
5673
  "path": "scripts/gds/run-unit-tests.js",
5674
5674
  "mode": "0000644",
5675
- "sha256": "e2e3cd6299f63b838ed5a78121eedcb44e76b3cc62b622581cd537c154b1d8ae"
5675
+ "sha256": "c96e50e533f4e20dba91c6a26ac6efd2c6ad33ffcdc856eeb46af21a17e67975"
5676
5676
  },
5677
5677
  {
5678
5678
  "path": "scripts/gds/sandbox-stage.js",
@@ -5832,7 +5832,12 @@
5832
5832
  {
5833
5833
  "path": "scripts/gds/session-cost.js",
5834
5834
  "mode": "0000644",
5835
- "sha256": "52d6c931fa0d7213632a365984ed360cb8df2d369762dbb417d0c0a1b74145a6"
5835
+ "sha256": "fdf3b9d8e0089078da550ac65d0c012583cc21103a668deecf481383a73c9da1"
5836
+ },
5837
+ {
5838
+ "path": "scripts/gds/session-digest-build.js",
5839
+ "mode": "0000644",
5840
+ "sha256": "95e5c21533f67588fd02fae311cef8985d3eee58ad3217fcb6e9998e1f4da965"
5836
5841
  },
5837
5842
  {
5838
5843
  "path": "scripts/gds/session-digest.js",
@@ -5867,7 +5872,7 @@
5867
5872
  {
5868
5873
  "path": "scripts/gds/ship.js",
5869
5874
  "mode": "0000644",
5870
- "sha256": "f3005175abc0ba20e958381df6aee02402267e9956da466386656dd4e86ad8cf"
5875
+ "sha256": "8f2c0ca63abae081cccfa96059ae07b008bd108f85866a248075cb4a1b7e025e"
5871
5876
  },
5872
5877
  {
5873
5878
  "path": "scripts/gds/skill-preflight.js",
@@ -6327,7 +6332,7 @@
6327
6332
  {
6328
6333
  "path": "src/module-api.js",
6329
6334
  "mode": "0000644",
6330
- "sha256": "7301568d229fc28265cf1ad41b4d568923cc18d1f7098ef5403c971ed531839a"
6335
+ "sha256": "af9d10cbaf1d6320df892f4d0e1403c0f5f1bb6e8c314695e89477690ba7fc85"
6331
6336
  },
6332
6337
  {
6333
6338
  "path": "src/module-loader/catalog.js",
@@ -8287,7 +8292,7 @@
8287
8292
  {
8288
8293
  "path": "tests/publish_reconciler.mjs",
8289
8294
  "mode": "0000644",
8290
- "sha256": "09c5e96eadbfd99f26579fbf67b2ccdf39c6c04415a2596e9dbd4644be8d3ebe"
8295
+ "sha256": "5a9ea0bf7b4d776a6f9036b8bd060fef76335533274ed748e9b9d742da6960d3"
8291
8296
  },
8292
8297
  {
8293
8298
  "path": "tests/rank_gate.mjs",
@@ -8507,7 +8512,12 @@
8507
8512
  {
8508
8513
  "path": "tests/session_cost.mjs",
8509
8514
  "mode": "0000644",
8510
- "sha256": "6531fd14dbfc025543c4855c326746c126cfcc94173b9a724e56f74a989a1d4e"
8515
+ "sha256": "5f330f79eee8c39dc589d892ba5fa6835906e7b61ff5fe52aec3285721e84876"
8516
+ },
8517
+ {
8518
+ "path": "tests/session_cost_row_raise.mjs",
8519
+ "mode": "0000644",
8520
+ "sha256": "74a106432222df7d8e845ec409bd2e1d49aff18bf80cdeff032cfe91b1d6dc3f"
8511
8521
  },
8512
8522
  {
8513
8523
  "path": "tests/session_digest.mjs",
@@ -8527,7 +8537,7 @@
8527
8537
  {
8528
8538
  "path": "tests/session_records.mjs",
8529
8539
  "mode": "0000644",
8530
- "sha256": "c33e4f5bb635eb523bc7014fe8cfad343b7ab7753822644ce987cd419520c748"
8540
+ "sha256": "8c62fe0bec07aea4c30f84d296eb7b40c9550606aacf6cb63f9644ca34f12069"
8531
8541
  },
8532
8542
  {
8533
8543
  "path": "tests/session_reward_on_land.mjs",
@@ -105,23 +105,51 @@ async function logCost({
105
105
  outputUsd = null,
106
106
  // task #385 / migration 181: which skill invocation incurred this cost.
107
107
  skillName = null,
108
+ // task 1003217 — opt-in MONOTONIC RAISE on (source, source_ref) conflict.
109
+ //
110
+ // Default false, so every existing caller keeps the plain insert-once
111
+ // idempotency it relies on. The session reconciler below sets it true: a
112
+ // long session's digest GROWS on every ship, and a cost row written from an
113
+ // early digest must be allowed to catch up, or the session is billed at its
114
+ // first hour forever (the bug this task fixes).
115
+ //
116
+ // Safe because the update SETS an absolute amount rather than adding to one:
117
+ // re-running with the same figure is a no-op, so there is no double-count
118
+ // path, and the WHERE guard means a row can only ever move UP.
119
+ raiseOnConflict = false,
108
120
  }) {
109
121
  // V3.R11 (#91): builder_id is the per-builder attribution column added by
110
122
  // migration 046. Routes default it to the authed builder, so the only
111
123
  // legitimately-null builder_id is for historical rows or system-injected
112
124
  // costs (infra, domain, etc.) that don't belong to any one person.
125
+ // Monotonic raise (opt-in) vs the historical insert-once. `recorded_at` moves
126
+ // with the amount so the reconciler's "digest is newer than the priced row"
127
+ // predicate settles instead of re-firing every sweep.
128
+ const conflictAction = raiseOnConflict
129
+ ? `DO UPDATE SET
130
+ amount_usd = EXCLUDED.amount_usd,
131
+ description = EXCLUDED.description,
132
+ fresh_input_usd = EXCLUDED.fresh_input_usd,
133
+ cache_read_usd = EXCLUDED.cache_read_usd,
134
+ cache_write_usd = EXCLUDED.cache_write_usd,
135
+ output_usd = EXCLUDED.output_usd,
136
+ recorded_at = EXCLUDED.recorded_at
137
+ WHERE EXCLUDED.amount_usd > cost_log.amount_usd`
138
+ : 'DO NOTHING';
113
139
  const { rows } = await pool.query(
114
140
  `INSERT INTO cost_log (task_id, amount_usd, category, description, source, source_ref, recorded_at, builder_id,
115
141
  fresh_input_usd, cache_read_usd, cache_write_usd, output_usd, skill_name)
116
142
  VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, now()), $8, $9, $10, $11, $12, $13)
117
143
  ON CONFLICT (source, source_ref)
118
144
  WHERE source IS NOT NULL AND source_ref IS NOT NULL
119
- DO NOTHING
145
+ ${conflictAction}
120
146
  RETURNING id, recorded_at`,
121
147
  [taskId, amountUsd, category, description, source, sourceRef, recordedAt, builderId,
122
148
  freshInputUsd, cacheReadUsd, cacheWriteUsd, outputUsd, skillName || null]
123
149
  );
124
- return rows[0] ?? null; // null when ON CONFLICT skipped the insert
150
+ // null when the conflict clause skipped the write (DO NOTHING, or a raise that
151
+ // would not have moved the row upward).
152
+ return rows[0] ?? null;
125
153
  }
126
154
 
127
155
  // task 1366 (System 1) — server-side session-cost reconciler.
@@ -144,25 +172,45 @@ async function logCost({
144
172
  // first crack at posting (avoids racing it). 15m default.
145
173
  // - limit : per-sweep cap (runaway guard; there are normally few).
146
174
  //
147
- // The NOT EXISTS checks BOTH the interactive key (session_id) AND the subagent
148
- // key (session_id || ':sub') — session-cost.js splits a captured session into
149
- // those two rows (task 1344), so a session the client captured even partially
150
- // is skipped entirely (we only have the combined model_usage, so we can't split
151
- // it; better to defer to the client's split than to add a duplicate total).
152
- // Returns { scanned, reconciled, totalUsd, unknownModels } for logging.
175
+ // A session the CLIENT split into interactive + subagent rows (session_id ||
176
+ // ':sub', task 1344) is skipped entirely — we hold only the combined
177
+ // model_usage, so we cannot reproduce that split and should defer to it.
178
+ //
179
+ // task 1003217 — WHY THIS NO LONGER SKIPS EVERY PRICED SESSION. It used to
180
+ // exclude any session that had a cost row at all. But `session_records` is
181
+ // upserted on EVERY ship, so a session that ships twelve tasks has a digest
182
+ // from its FIRST ship, and this sweep priced that partial digest ~15 minutes
183
+ // later and then never looked again — logCost's DO NOTHING silently dropped
184
+ // every subsequent, larger figure. Measured on prod: a 9.3-hour session was
185
+ // billed at its first 56 minutes, and across four audited sessions the ledger
186
+ // captured 14%, 14%, 16% and 54% of real spend. Now a session is revisited
187
+ // while its digest is NEWER than the row we priced from it, and logCost raises
188
+ // the row monotonically. `recorded_at` advances on each raise, so a settled
189
+ // session stops matching and the sweep converges.
190
+ //
191
+ // Historical rows are NOT re-priced downward: llm-pricing.js is explicit that a
192
+ // paid row records what was charged, and ADR 0054 paid credits from it. The
193
+ // raise is upward-only, so pre-fix inflated rows stay as they are — expect a
194
+ // visible step change in cost-per-ship at deploy rather than a rewritten past.
195
+ // Returns { scanned, reconciled, raised, totalUsd, unknownModels } for logging.
153
196
  async function reconcileSessionCosts({ olderThanMinutes = 15, limit = 500 } = {}) {
154
197
  const mins = Number.isFinite(Number(olderThanMinutes)) ? Number(olderThanMinutes) : 15;
155
198
  const lim = Number.isFinite(Number(limit)) ? Number(limit) : 500;
156
199
  const { rows } = await pool.query(
157
- `SELECT sr.session_id, sr.builder_id, sr.model_usage
200
+ `SELECT sr.session_id, sr.builder_id, sr.model_usage,
201
+ c.amount_usd AS prior_usd
158
202
  FROM session_records sr
203
+ LEFT JOIN cost_log c
204
+ ON c.source = 'session:interactive'
205
+ AND c.source_ref = sr.session_id
159
206
  WHERE sr.model_usage IS NOT NULL
160
207
  AND sr.uploaded_at < now() - ($1 || ' minutes')::interval
161
208
  AND NOT EXISTS (
162
- SELECT 1 FROM cost_log c
163
- WHERE c.source_ref = sr.session_id
164
- OR c.source_ref = sr.session_id || ':sub'
209
+ SELECT 1 FROM cost_log s
210
+ WHERE s.source_ref = sr.session_id || ':sub'
165
211
  )
212
+ -- Never priced, or the digest has grown since we priced it.
213
+ AND (c.id IS NULL OR c.recorded_at < sr.uploaded_at)
166
214
  ORDER BY sr.uploaded_at DESC
167
215
  LIMIT $2`,
168
216
  [String(mins), lim]
@@ -170,6 +218,7 @@ async function reconcileSessionCosts({ olderThanMinutes = 15, limit = 500 } = {}
170
218
 
171
219
  const round4 = (n) => Math.round((Number(n) || 0) * 10000) / 10000;
172
220
  let reconciled = 0;
221
+ let raised = 0;
173
222
  let totalUsd = 0;
174
223
  const unknownModels = new Set();
175
224
  for (const r of rows) {
@@ -177,34 +226,50 @@ async function reconcileSessionCosts({ olderThanMinutes = 15, limit = 500 } = {}
177
226
  for (const m of comp.unknown_models) unknownModels.add(m);
178
227
  const amount = round4(comp.total_usd);
179
228
  if (!(amount > 0)) continue; // nothing priceable on this row — skip
229
+ const prior = round4(r.prior_usd);
230
+ // A row already at or above this figure needs no write: the digest grew but
231
+ // the price did not (or the stored row predates a pricing fix and is higher,
232
+ // which we deliberately leave alone).
233
+ if (prior > 0 && amount <= prior) continue;
180
234
  const id8 = String(r.session_id).slice(0, 8);
181
235
  const unkNote = comp.unknown_models.length
182
236
  ? ` [unpriced model(s) priced as opus: ${comp.unknown_models.join(',')}]`
183
237
  : '';
238
+ const provenance = prior > 0
239
+ ? `raised from $${prior.toFixed(4)} as the session grew`
240
+ : 'reconciled from digest; client cost-hook did not post';
184
241
  const inserted = await logCost({
185
242
  amountUsd: amount,
186
243
  category: 'api',
187
244
  source: 'session:interactive',
188
- // SAME key the client would use → ON CONFLICT DO NOTHING prevents any
189
- // double-count if the client also posts later.
245
+ // SAME key the client would use, so a client post and this sweep can
246
+ // never both land as separate rows.
190
247
  sourceRef: r.session_id,
191
248
  builderId: r.builder_id,
192
- description: `session ${id8} — $${amount.toFixed(4)} (reconciled from digest; client cost-hook did not post)${unkNote}`.slice(0, 1000),
249
+ // Upward-only: catches a long session up as it ships more tasks, and can
250
+ // never lower a row that has already been paid from.
251
+ raiseOnConflict: true,
252
+ description: `session ${id8} — $${amount.toFixed(4)} (${provenance})${unkNote}`.slice(0, 1000),
193
253
  freshInputUsd: round4(comp.fresh_input_usd),
194
254
  cacheReadUsd: round4(comp.cache_read_usd),
195
255
  cacheWriteUsd: round4(comp.cache_write_usd),
196
256
  outputUsd: round4(comp.output_usd),
197
257
  });
198
- // inserted === null means ON CONFLICT skipped it (client raced in between the
199
- // SELECT and the INSERT) — expected + idempotent, not counted.
258
+ // inserted === null means the conflict clause skipped the write (the client
259
+ // raced in between the SELECT and the INSERT, or the raise would not have
260
+ // moved the row up) — expected + idempotent, not counted.
200
261
  if (inserted) {
201
- reconciled++;
202
- totalUsd += amount;
262
+ if (prior > 0) raised++;
263
+ else reconciled++;
264
+ // Bill only the INCREMENT on a raise, so this figure stays a true
265
+ // "spend recorded by this sweep" rather than re-counting the whole row.
266
+ totalUsd += amount - prior;
203
267
  }
204
268
  }
205
269
  return {
206
270
  scanned: rows.length,
207
271
  reconciled,
272
+ raised,
208
273
  totalUsd: round4(totalUsd),
209
274
  unknownModels: Array.from(unknownModels),
210
275
  };
@@ -804,12 +804,17 @@ async function runCostSweep(deps = {}) {
804
804
  if (typeof reconciler.reconcileSessionCosts !== 'function') return { skipped: 'unsupported' };
805
805
  try {
806
806
  const cost = await reconciler.reconcileSessionCosts();
807
- if (cost && cost.reconciled > 0) {
807
+ // task 1003217: `raised` counts long sessions caught up as their digest grew.
808
+ // It is reported alongside `reconciled` — a sweep that only raised rows did
809
+ // real ledger work and must not log silence.
810
+ const raised = Number(cost && cost.raised) || 0;
811
+ if (cost && (cost.reconciled > 0 || raised > 0)) {
808
812
  const unk = cost.unknownModels && cost.unknownModels.length
809
813
  ? ` [unpriced→opus: ${cost.unknownModels.join(',')}]`
810
814
  : '';
815
+ const raisedNote = raised > 0 ? `, raised ${raised}` : '';
811
816
  logFn(
812
- `[publish-reconciler] session-cost: scanned ${cost.scanned}, reconciled ${cost.reconciled} cost_log row(s) totalling $${Number(cost.totalUsd || 0).toFixed(4)}${unk}`
817
+ `[publish-reconciler] session-cost: scanned ${cost.scanned}, reconciled ${cost.reconciled}${raisedNote} cost_log row(s) totalling $${Number(cost.totalUsd || 0).toFixed(4)}${unk}`
813
818
  );
814
819
  }
815
820
  return cost;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.317",
3
+ "version": "1.19.318",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.317",
9
+ "version": "1.19.318",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.317",
3
+ "version": "1.19.318",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -104,7 +104,14 @@ const INTEGRATION = new Set([
104
104
  'government_board_membership_db',
105
105
  'government_fold_bench', 'government_live_proof', 'linkify_refs', 'memory_isolation',
106
106
  'memory_pull', 'needs_rebase', 'parallel-load', 'profile_rollup_consent', 'reward_mode_cost_plus_only',
107
- 'scouting_rollup_fanout', 'session_records', 'setup_smoke',
107
+ 'scouting_rollup_fanout', 'session_records',
108
+ // task 1003217: the cost-row RAISE against real SQL. Every claim in it IS the
109
+ // SQL — an ON CONFLICT DO UPDATE guarded to move a row only upward, and a
110
+ // revisit predicate comparing cost_log.recorded_at to the digest upload. The
111
+ // DB-free siblings (session_records, session_cost) cover the transcript
112
+ // de-duplication half.
113
+ 'session_cost_row_raise',
114
+ 'setup_smoke',
108
115
  'status-probe',
109
116
  ]);
110
117
 
@@ -146,9 +146,19 @@ function roundTiers(t) {
146
146
  }
147
147
 
148
148
  // Sum usage per model across the transcript, then price each model's total.
149
+ //
150
+ // task 1003217 — usage is deduped by `message.id`. Claude Code writes one
151
+ // transcript line per content block of an assistant message (thinking / text /
152
+ // tool_use) and repeats the SAME `message.usage` on every one of them, so
153
+ // summing per line priced a single API call two or three times (measured 2.22x
154
+ // inflation over 12 real transcripts). The evidence — including the cache-chain
155
+ // identity that proves a line-group is one call — is written up above
156
+ // buildSessionDigest in ./session-digest-build.js. A transcript whose messages
157
+ // carry no `id` degrades to the old per-line behaviour.
149
158
  function priceTranscript(transcriptPath) {
150
159
  const raw = fs.readFileSync(transcriptPath, 'utf8');
151
160
  const perModel = new Map(); // model -> {input, output, read, c5, c1h}
161
+ const seenMessageIds = new Set();
152
162
  for (const line of raw.split('\n')) {
153
163
  if (!line.trim()) continue;
154
164
  let o;
@@ -159,6 +169,12 @@ function priceTranscript(transcriptPath) {
159
169
  // generated messages (compaction, interrupts) — no real API call, usage is
160
170
  // a zero placeholder. Pricing them would mislabel them as an unknown model.
161
171
  if (!usage || !msg.model || msg.model === '<synthetic>') continue;
172
+ // One API call, many lines — price it once (task 1003217).
173
+ const msgId = typeof msg.id === 'string' && msg.id ? msg.id : null;
174
+ if (msgId !== null) {
175
+ if (seenMessageIds.has(msgId)) continue;
176
+ seenMessageIds.add(msgId);
177
+ }
162
178
  const m = perModel.get(msg.model) || { input: 0, output: 0, read: 0, c5: 0, c1h: 0 };
163
179
  m.input += Number(usage.input_tokens) || 0;
164
180
  m.output += Number(usage.output_tokens) || 0;
@@ -0,0 +1,261 @@
1
+ // scripts/gds/session-digest-build.js — the PURE transcript→digest builder.
2
+ //
3
+ // Extracted from scripts/gds/ship.js (task 1003217, serving the de-monolith goal
4
+ // of task 1003203: nothing over 1,500 lines). ship.js was 4,646 lines and this
5
+ // block — snippetOf + buildSessionDigest and their two constants — is pure,
6
+ // separately tested, and shared: ship.js uploads a digest mid-ship and
7
+ // session-digest.js uploads the final one on the SessionEnd seam.
8
+ //
9
+ // It has NO dependency on ship.js, which is what makes the split safe: ship.js
10
+ // imports and re-exports this, so its public surface (and tests/session_records.mjs)
11
+ // is unchanged, and the existing session-digest.js -> ship.js import cannot become
12
+ // a cycle.
13
+ //
14
+ // NB the prose above deliberately says "imports", not the r-e-q-u-i-r-e call
15
+ // spelled with './ship' — the fitness require-graph (scripts/gds/fitness-ratchets.js
16
+ // requireGraph) scans raw file text and does NOT strip comments, so naming that
17
+ // call here invented a phantom session-digest-build <-> ship cycle and tripped the
18
+ // require_cycle_count ratchet. Same trap the curated-set parser in run-unit-tests.js
19
+ // warns about for quoted words in ITS comments.
20
+ //
21
+ // The transcript-grade secret scrubber + its ADR-0022 internals live in
22
+ // modules/security/secret-scrub.js — extracted (task 1211 / GDS-V4 R72) so the
23
+ // public-mirror export pipeline can reuse the ONE copy of the secret shapes
24
+ // without requiring ship.js (whose grader / Discord / sound / session deps are
25
+ // absent in a bare CI job). See that module for the recall-first design (a missed
26
+ // secret is a compromise; over-redaction is cosmetic).
27
+ const { scrubSecrets } = require('../../modules/security/secret-scrub');
28
+
29
+ // snippetOf — short, single-line, scrubbed excerpt for footgun context.
30
+ function snippetOf(text, extraSecrets, max = 160) {
31
+ if (typeof text !== 'string' || text.length === 0) return undefined;
32
+ // Drop NULs first: Postgres rejects a literal NUL in jsonb, so ONE of them anywhere in
33
+ // ONE snippet made the whole digest unstorable — POST /sessions answered 500 and
34
+ // the session never paid. UTF-16LE tool output (`wsl --list` is the observed
35
+ // case) is full of them, and \s below does NOT match NUL, so it must go here.
36
+ const flat = text.replace(/\0/g, '').replace(/\s+/g, ' ').trim();
37
+ if (!flat) return undefined;
38
+ const cut = flat.length > max ? flat.slice(0, max) : flat;
39
+ const scrubbed = scrubSecrets(cut, extraSecrets);
40
+ return scrubbed || undefined;
41
+ }
42
+
43
+ // buildSessionDigest — turn parsed Claude Code transcript entries into
44
+ // { startedAt, endedAt, durationSeconds, totalTokens, metrics, detail }.
45
+ // Pure (no I/O) + exported for unit tests. `detail` is per-turn metadata + short
46
+ // scrubbed snippets, capped at maxTurns (head+tail kept when over).
47
+ // Harness guardrail retries (task 959) — an is_error tool_result that is NOT real
48
+ // friction but the agent harness rejecting a tool call: an Edit/Write before the
49
+ // file was Read ("File has not been read yet"), or a foreground sleep ("Blocked:
50
+ // sleep"). These were counted in tool_error_count, inflating it and skewing the
51
+ // BFG's order=tool_errors ranking (top sessions ranked on benign retries). They
52
+ // now go into a separate harness_retry_count so tool_error_count stays NET — and
53
+ // because the sessions/search ordering reads metrics->>'tool_error_count'
54
+ // (src/bongos/db.js), that ordering automatically uses the net figure with no route
55
+ // change. (ADR 0027 lineage — the 6D session evaluator.)
56
+ const HARNESS_RETRY_RE =
57
+ /File has not been read yet|Read it first before (?:writing|editing)|Blocked:\s*sleep|sleep is blocked in (?:the )?foreground/i;
58
+
59
+ // task 1676 — the family bucket unlabeled token-bearing turns are folded into so
60
+ // the cost-plus reward (ADR 0054) prices the real spend instead of rounding to $0.
61
+ // A concrete, priceable model id (llm-pricing.js maps it to the opus family by
62
+ // substring) — NOT a fabricated dollar figure; it carries the REAL token counts
63
+ // ship.js already parsed, priced server-side like any other bucket.
64
+ const UNATTRIBUTED_MODEL = 'claude-opus-4-8';
65
+
66
+ // task 1003217 — ONE API CALL IS MANY TRANSCRIPT LINES.
67
+ //
68
+ // Claude Code writes a separate transcript line per content block of a single
69
+ // assistant message (thinking / text / tool_use), and EVERY one of those lines
70
+ // repeats the SAME `message.usage` object. Summing usage per line therefore
71
+ // counted one API call's tokens two or three times: measured 2.22x inflation
72
+ // across 12 real transcripts in this project (2,357 usage-bearing lines,
73
+ // naive cache_read 551,095,738 vs deduped 248,555,962).
74
+ //
75
+ // `message.id` is the API call's identity — the usage tuples are byte-identical
76
+ // across lines sharing an id. The proof that a line-GROUP is one call is the
77
+ // cache chain: cache_read[n] == cache_read[n-1] + cache_creation[n-1] closes on
78
+ // 99.1% of links after de-duplication (the residue is compaction boundaries)
79
+ // and holds for none of them before it.
80
+ //
81
+ // This mattered because ADR 0054 pays round(true_cost_usd x 1.20) from these
82
+ // very numbers, so the inflation flowed into cost-plus rewards.
83
+ //
84
+ // `assistant_turns` is deliberately left as the LINE count (existing consumers
85
+ // order on it); `api_calls` is the new honest step count. A transcript whose
86
+ // messages carry no `id` degrades to exactly the old behaviour.
87
+ function buildSessionDigest(entries, opts = {}) {
88
+ const extra = opts.extraSecrets || [];
89
+ const MAX_TURNS = opts.maxTurns || 4000;
90
+ const seenMessageIds = new Set();
91
+ const m = {
92
+ assistant_turns: 0,
93
+ api_calls: 0, // distinct message.id — the real number of API round-trips
94
+ user_turns: 0,
95
+ tool_use_count: 0,
96
+ tool_result_count: 0,
97
+ tool_error_count: 0, // NET real errors (harness guardrail retries excluded)
98
+ harness_retry_count: 0, // benign harness guardrail retries (task 959)
99
+ tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
100
+ model_usage: {}, // per-model token buckets for the cost-plus reward (task 1005)
101
+ // task 1676: tokens from turns with NO model id (not '<synthetic>'). Folded
102
+ // into a priceable bucket after the loop, then stripped from the digest.
103
+ _unlabeled: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
104
+ };
105
+ const detail = [];
106
+ let firstTs = null;
107
+ let lastTs = null;
108
+ let i = 0;
109
+ for (const e of Array.isArray(entries) ? entries : []) {
110
+ if (!e || typeof e !== 'object') continue;
111
+ const ts = typeof e.timestamp === 'string' ? e.timestamp : null;
112
+ if (ts) {
113
+ if (!firstTs) firstTs = ts;
114
+ lastTs = ts;
115
+ }
116
+ const msg = e.message;
117
+ if (e.type === 'assistant' && msg) {
118
+ m.assistant_turns++;
119
+ // Dedup usage per API call (see the note above buildSessionDigest). A
120
+ // repeat line contributes ZERO tokens but still gets its detail row, so
121
+ // the per-turn record stays faithful and summing detail stays correct.
122
+ const msgId = typeof msg.id === 'string' && msg.id ? msg.id : null;
123
+ const dupUsage = msgId !== null && seenMessageIds.has(msgId);
124
+ if (msgId !== null) seenMessageIds.add(msgId);
125
+ if (!dupUsage) m.api_calls++;
126
+ const u = dupUsage ? {} : (msg.usage || {});
127
+ const tin = u.input_tokens || 0;
128
+ const tout = u.output_tokens || 0;
129
+ const tcr = u.cache_read_input_tokens || 0;
130
+ const tcc = u.cache_creation_input_tokens || 0;
131
+ m.tokens.input += tin;
132
+ m.tokens.output += tout;
133
+ m.tokens.cache_read += tcr;
134
+ m.tokens.cache_creation += tcc;
135
+ // task 1005: per-model bucket for the cost-plus token reward, reusing the
136
+ // tin/tout/tcr/tcc already parsed above (no second pass). Skip synthetic
137
+ // turns (a grader 429 etc. emits no real model id AND no real spend).
138
+ const model = msg.model;
139
+ if (model === '<synthetic>') {
140
+ // synthetic: no real spend — exclude from BOTH model_usage and the
141
+ // task-1676 unlabeled-remainder fold below (don't price phantom tokens).
142
+ } else if (model) {
143
+ const mu = m.model_usage[model] || (m.model_usage[model] =
144
+ { input: 0, output: 0, cache_read: 0, cache_creation: 0 });
145
+ mu.input += tin; mu.output += tout; mu.cache_read += tcr; mu.cache_creation += tcc;
146
+ } else {
147
+ // task 1676: a token-bearing turn with NO model id. Previously dropped
148
+ // from model_usage (→ total_tokens>0 but priced $0 on the dev-box/CI
149
+ // path; ADR 0097). Accumulate the unlabeled tokens so the digest can
150
+ // attribute them to a real, priceable family bucket after the loop.
151
+ m._unlabeled.input += tin; m._unlabeled.output += tout;
152
+ m._unlabeled.cache_read += tcr; m._unlabeled.cache_creation += tcc;
153
+ }
154
+ const tools = [];
155
+ let textLen = 0;
156
+ let firstText = '';
157
+ if (Array.isArray(msg.content)) {
158
+ for (const c of msg.content) {
159
+ if (c && c.type === 'tool_use') {
160
+ m.tool_use_count++;
161
+ tools.push(String(c.name || '?'));
162
+ } else if (c && c.type === 'text' && typeof c.text === 'string') {
163
+ textLen += c.text.length;
164
+ if (!firstText) firstText = c.text;
165
+ }
166
+ }
167
+ }
168
+ const row = { i, role: 'assistant', ts, in: tin, out: tout, cache_read: tcr, cache_creation: tcc };
169
+ // Marks a line that shares its API call with an earlier one, so a reader
170
+ // can tell a genuinely-zero-usage turn from a de-duplicated repeat.
171
+ if (dupUsage) row.dup = 1;
172
+ if (tools.length) row.tools = tools;
173
+ if (textLen) row.text_len = textLen;
174
+ const snip = snippetOf(firstText, extra);
175
+ if (snip) row.snippet = snip;
176
+ detail.push(row);
177
+ i++;
178
+ } else if (e.type === 'user' && msg) {
179
+ m.user_turns++;
180
+ let results = 0;
181
+ let errors = 0;
182
+ let harnessRetries = 0;
183
+ let firstResult = '';
184
+ if (Array.isArray(msg.content)) {
185
+ for (const c of msg.content) {
186
+ if (c && c.type === 'tool_result') {
187
+ results++;
188
+ m.tool_result_count++;
189
+ const text = typeof c.content === 'string'
190
+ ? c.content
191
+ : Array.isArray(c.content)
192
+ ? c.content.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join(' ')
193
+ : '';
194
+ if (c.is_error) {
195
+ // Split benign harness guardrail retries out of the net error count
196
+ // so order=tool_errors ranks on real friction (task 959).
197
+ if (HARNESS_RETRY_RE.test(text)) {
198
+ harnessRetries++;
199
+ m.harness_retry_count++;
200
+ } else {
201
+ errors++;
202
+ m.tool_error_count++;
203
+ }
204
+ }
205
+ if (!firstResult) firstResult = text;
206
+ }
207
+ }
208
+ }
209
+ const row = { i, role: 'user', ts };
210
+ if (results) row.tool_results = results;
211
+ if (errors) row.tool_errors = errors;
212
+ if (harnessRetries) row.harness_retries = harnessRetries;
213
+ const snip = snippetOf(firstResult, extra);
214
+ if (snip) row.snippet = snip;
215
+ detail.push(row);
216
+ i++;
217
+ }
218
+ }
219
+ m.total_tokens = m.tokens.input + m.tokens.output + m.tokens.cache_read + m.tokens.cache_creation;
220
+ // task 1676 — cost-plus reward zero-out fix. The cost-plus session reward
221
+ // (ADR 0054) prices `model_usage` server-side. model_usage is keyed on msg.model
222
+ // and SKIPS any token-bearing turn whose model id is missing. On the dev-box/CI
223
+ // agent path that gap was the difference between total_tokens>0 (so the digest
224
+ // passes the route's empty-session guard + gets stored) and model_usage={} (so
225
+ // priceModelUsage returns $0 → 0 drachmae) — ADR 0097's "upload no priced model
226
+ // usage" symptom. Fold the unlabeled remainder (NOT synthetic turns) into a real,
227
+ // priceable family bucket so the cost-plus reward reflects the true spend rather
228
+ // than silently dropping to $0. opus is the conservative family for an unlabeled
229
+ // turn (matches llm-pricing.js's unknown-model fallback) — this instance runs
230
+ // opus by default, and pricing an unlabeled turn as opus never UNDER-pays.
231
+ const un = m._unlabeled;
232
+ if (un.input + un.output + un.cache_read + un.cache_creation > 0) {
233
+ const fb = m.model_usage[UNATTRIBUTED_MODEL] || (m.model_usage[UNATTRIBUTED_MODEL] =
234
+ { input: 0, output: 0, cache_read: 0, cache_creation: 0 });
235
+ fb.input += un.input; fb.output += un.output;
236
+ fb.cache_read += un.cache_read; fb.cache_creation += un.cache_creation;
237
+ }
238
+ delete m._unlabeled; // scratch — never persisted to the digest
239
+ let boundedDetail = detail;
240
+ if (detail.length > MAX_TURNS) {
241
+ const head = Math.floor(MAX_TURNS * 0.7);
242
+ const tail = MAX_TURNS - head;
243
+ boundedDetail = detail.slice(0, head).concat(detail.slice(detail.length - tail));
244
+ m.detail_truncated = true;
245
+ m.detail_total_turns = detail.length;
246
+ }
247
+ let durationSeconds = null;
248
+ if (firstTs && lastTs) {
249
+ const d = (Date.parse(lastTs) - Date.parse(firstTs)) / 1000;
250
+ if (Number.isFinite(d) && d >= 0) durationSeconds = Math.round(d);
251
+ }
252
+ return {
253
+ startedAt: firstTs,
254
+ endedAt: lastTs,
255
+ durationSeconds,
256
+ totalTokens: m.total_tokens,
257
+ metrics: m,
258
+ detail: boundedDetail,
259
+ };
260
+ }
261
+ module.exports = { snippetOf, buildSessionDigest, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL };
@@ -386,205 +386,14 @@ const {
386
386
  redactHighEntropy, collectEnvSecrets,
387
387
  } = require('../../modules/security/secret-scrub');
388
388
 
389
- // snippetOf — short, single-line, scrubbed excerpt for footgun context.
390
- function snippetOf(text, extraSecrets, max = 160) {
391
- if (typeof text !== 'string' || text.length === 0) return undefined;
392
- // Drop NULs first: Postgres rejects a literal NUL in jsonb, so ONE of them anywhere in
393
- // ONE snippet made the whole digest unstorable — POST /sessions answered 500 and
394
- // the session never paid. UTF-16LE tool output (`wsl --list` is the observed
395
- // case) is full of them, and \s below does NOT match NUL, so it must go here.
396
- const flat = text.replace(/\0/g, '').replace(/\s+/g, ' ').trim();
397
- if (!flat) return undefined;
398
- const cut = flat.length > max ? flat.slice(0, max) : flat;
399
- const scrubbed = scrubSecrets(cut, extraSecrets);
400
- return scrubbed || undefined;
401
- }
389
+ // snippetOf + buildSessionDigest moved to ./session-digest-build (task 1003217,
390
+ // serving the de-monolith goal of task 1003203) — pure, separately tested, and
391
+ // shared with session-digest.js. Re-exported below so this file's public surface
392
+ // and tests/session_records.mjs are unchanged.
393
+ const {
394
+ snippetOf, buildSessionDigest, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL,
395
+ } = require('./session-digest-build');
402
396
 
403
- // buildSessionDigest — turn parsed Claude Code transcript entries into
404
- // { startedAt, endedAt, durationSeconds, totalTokens, metrics, detail }.
405
- // Pure (no I/O) + exported for unit tests. `detail` is per-turn metadata + short
406
- // scrubbed snippets, capped at maxTurns (head+tail kept when over).
407
- // Harness guardrail retries (task 959) — an is_error tool_result that is NOT real
408
- // friction but the agent harness rejecting a tool call: an Edit/Write before the
409
- // file was Read ("File has not been read yet"), or a foreground sleep ("Blocked:
410
- // sleep"). These were counted in tool_error_count, inflating it and skewing the
411
- // BFG's order=tool_errors ranking (top sessions ranked on benign retries). They
412
- // now go into a separate harness_retry_count so tool_error_count stays NET — and
413
- // because the sessions/search ordering reads metrics->>'tool_error_count'
414
- // (src/bongos/db.js), that ordering automatically uses the net figure with no route
415
- // change. (ADR 0027 lineage — the 6D session evaluator.)
416
- const HARNESS_RETRY_RE =
417
- /File has not been read yet|Read it first before (?:writing|editing)|Blocked:\s*sleep|sleep is blocked in (?:the )?foreground/i;
418
-
419
- // task 1676 — the family bucket unlabeled token-bearing turns are folded into so
420
- // the cost-plus reward (ADR 0054) prices the real spend instead of rounding to $0.
421
- // A concrete, priceable model id (llm-pricing.js maps it to the opus family by
422
- // substring) — NOT a fabricated dollar figure; it carries the REAL token counts
423
- // ship.js already parsed, priced server-side like any other bucket.
424
- const UNATTRIBUTED_MODEL = 'claude-opus-4-8';
425
-
426
- function buildSessionDigest(entries, opts = {}) {
427
- const extra = opts.extraSecrets || [];
428
- const MAX_TURNS = opts.maxTurns || 4000;
429
- const m = {
430
- assistant_turns: 0,
431
- user_turns: 0,
432
- tool_use_count: 0,
433
- tool_result_count: 0,
434
- tool_error_count: 0, // NET real errors (harness guardrail retries excluded)
435
- harness_retry_count: 0, // benign harness guardrail retries (task 959)
436
- tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
437
- model_usage: {}, // per-model token buckets for the cost-plus reward (task 1005)
438
- // task 1676: tokens from turns with NO model id (not '<synthetic>'). Folded
439
- // into a priceable bucket after the loop, then stripped from the digest.
440
- _unlabeled: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
441
- };
442
- const detail = [];
443
- let firstTs = null;
444
- let lastTs = null;
445
- let i = 0;
446
- for (const e of Array.isArray(entries) ? entries : []) {
447
- if (!e || typeof e !== 'object') continue;
448
- const ts = typeof e.timestamp === 'string' ? e.timestamp : null;
449
- if (ts) {
450
- if (!firstTs) firstTs = ts;
451
- lastTs = ts;
452
- }
453
- const msg = e.message;
454
- if (e.type === 'assistant' && msg) {
455
- m.assistant_turns++;
456
- const u = msg.usage || {};
457
- const tin = u.input_tokens || 0;
458
- const tout = u.output_tokens || 0;
459
- const tcr = u.cache_read_input_tokens || 0;
460
- const tcc = u.cache_creation_input_tokens || 0;
461
- m.tokens.input += tin;
462
- m.tokens.output += tout;
463
- m.tokens.cache_read += tcr;
464
- m.tokens.cache_creation += tcc;
465
- // task 1005: per-model bucket for the cost-plus token reward, reusing the
466
- // tin/tout/tcr/tcc already parsed above (no second pass). Skip synthetic
467
- // turns (a grader 429 etc. emits no real model id AND no real spend).
468
- const model = msg.model;
469
- if (model === '<synthetic>') {
470
- // synthetic: no real spend — exclude from BOTH model_usage and the
471
- // task-1676 unlabeled-remainder fold below (don't price phantom tokens).
472
- } else if (model) {
473
- const mu = m.model_usage[model] || (m.model_usage[model] =
474
- { input: 0, output: 0, cache_read: 0, cache_creation: 0 });
475
- mu.input += tin; mu.output += tout; mu.cache_read += tcr; mu.cache_creation += tcc;
476
- } else {
477
- // task 1676: a token-bearing turn with NO model id. Previously dropped
478
- // from model_usage (→ total_tokens>0 but priced $0 on the dev-box/CI
479
- // path; ADR 0097). Accumulate the unlabeled tokens so the digest can
480
- // attribute them to a real, priceable family bucket after the loop.
481
- m._unlabeled.input += tin; m._unlabeled.output += tout;
482
- m._unlabeled.cache_read += tcr; m._unlabeled.cache_creation += tcc;
483
- }
484
- const tools = [];
485
- let textLen = 0;
486
- let firstText = '';
487
- if (Array.isArray(msg.content)) {
488
- for (const c of msg.content) {
489
- if (c && c.type === 'tool_use') {
490
- m.tool_use_count++;
491
- tools.push(String(c.name || '?'));
492
- } else if (c && c.type === 'text' && typeof c.text === 'string') {
493
- textLen += c.text.length;
494
- if (!firstText) firstText = c.text;
495
- }
496
- }
497
- }
498
- const row = { i, role: 'assistant', ts, in: tin, out: tout, cache_read: tcr, cache_creation: tcc };
499
- if (tools.length) row.tools = tools;
500
- if (textLen) row.text_len = textLen;
501
- const snip = snippetOf(firstText, extra);
502
- if (snip) row.snippet = snip;
503
- detail.push(row);
504
- i++;
505
- } else if (e.type === 'user' && msg) {
506
- m.user_turns++;
507
- let results = 0;
508
- let errors = 0;
509
- let harnessRetries = 0;
510
- let firstResult = '';
511
- if (Array.isArray(msg.content)) {
512
- for (const c of msg.content) {
513
- if (c && c.type === 'tool_result') {
514
- results++;
515
- m.tool_result_count++;
516
- const text = typeof c.content === 'string'
517
- ? c.content
518
- : Array.isArray(c.content)
519
- ? c.content.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join(' ')
520
- : '';
521
- if (c.is_error) {
522
- // Split benign harness guardrail retries out of the net error count
523
- // so order=tool_errors ranks on real friction (task 959).
524
- if (HARNESS_RETRY_RE.test(text)) {
525
- harnessRetries++;
526
- m.harness_retry_count++;
527
- } else {
528
- errors++;
529
- m.tool_error_count++;
530
- }
531
- }
532
- if (!firstResult) firstResult = text;
533
- }
534
- }
535
- }
536
- const row = { i, role: 'user', ts };
537
- if (results) row.tool_results = results;
538
- if (errors) row.tool_errors = errors;
539
- if (harnessRetries) row.harness_retries = harnessRetries;
540
- const snip = snippetOf(firstResult, extra);
541
- if (snip) row.snippet = snip;
542
- detail.push(row);
543
- i++;
544
- }
545
- }
546
- m.total_tokens = m.tokens.input + m.tokens.output + m.tokens.cache_read + m.tokens.cache_creation;
547
- // task 1676 — cost-plus reward zero-out fix. The cost-plus session reward
548
- // (ADR 0054) prices `model_usage` server-side. model_usage is keyed on msg.model
549
- // and SKIPS any token-bearing turn whose model id is missing. On the dev-box/CI
550
- // agent path that gap was the difference between total_tokens>0 (so the digest
551
- // passes the route's empty-session guard + gets stored) and model_usage={} (so
552
- // priceModelUsage returns $0 → 0 drachmae) — ADR 0097's "upload no priced model
553
- // usage" symptom. Fold the unlabeled remainder (NOT synthetic turns) into a real,
554
- // priceable family bucket so the cost-plus reward reflects the true spend rather
555
- // than silently dropping to $0. opus is the conservative family for an unlabeled
556
- // turn (matches llm-pricing.js's unknown-model fallback) — this instance runs
557
- // opus by default, and pricing an unlabeled turn as opus never UNDER-pays.
558
- const un = m._unlabeled;
559
- if (un.input + un.output + un.cache_read + un.cache_creation > 0) {
560
- const fb = m.model_usage[UNATTRIBUTED_MODEL] || (m.model_usage[UNATTRIBUTED_MODEL] =
561
- { input: 0, output: 0, cache_read: 0, cache_creation: 0 });
562
- fb.input += un.input; fb.output += un.output;
563
- fb.cache_read += un.cache_read; fb.cache_creation += un.cache_creation;
564
- }
565
- delete m._unlabeled; // scratch — never persisted to the digest
566
- let boundedDetail = detail;
567
- if (detail.length > MAX_TURNS) {
568
- const head = Math.floor(MAX_TURNS * 0.7);
569
- const tail = MAX_TURNS - head;
570
- boundedDetail = detail.slice(0, head).concat(detail.slice(detail.length - tail));
571
- m.detail_truncated = true;
572
- m.detail_total_turns = detail.length;
573
- }
574
- let durationSeconds = null;
575
- if (firstTs && lastTs) {
576
- const d = (Date.parse(lastTs) - Date.parse(firstTs)) / 1000;
577
- if (Number.isFinite(d) && d >= 0) durationSeconds = Math.round(d);
578
- }
579
- return {
580
- startedAt: firstTs,
581
- endedAt: lastTs,
582
- durationSeconds,
583
- totalTokens: m.total_tokens,
584
- metrics: m,
585
- detail: boundedDetail,
586
- };
587
- }
588
397
 
589
398
  // classifyUploadSkip — PURE decision for whether the session upload must skip,
590
399
  // and the clear, actionable reason why. Separated from I/O so it is unit-tested
package/src/module-api.js CHANGED
@@ -1061,8 +1061,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1061
1061
  // landed since 1.19.315 with no explicit bump. run 32669347711. (task 1002620)
1062
1062
  // 1.19.317 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1063
1063
  // landed since 1.19.316 with no explicit bump. run 32669571610. (task 1002620)
1064
+ // 1.19.318 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1065
+ // landed since 1.19.317 with no explicit bump. run 32669798745. (task 1002620)
1064
1066
  // ---------------------------------------------------------------------------
1065
- const CORE_VERSION = '1.19.317'; // CI auto-patch carrier (ADR 0161); see changelog above
1067
+ const CORE_VERSION = '1.19.318'; // CI auto-patch carrier (ADR 0161); see changelog above
1066
1068
 
1067
1069
  // A namespaced logger so a module's log lines are attributable + consistent.
1068
1070
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -1695,3 +1695,41 @@ setTimeout(() => {
1695
1695
  console.log(`\npublish reconciler (task 1134 + 1915) tests: ${passed} passed, ${failed} failed`);
1696
1696
  process.exit(failed > 0 ? 1 : 0);
1697
1697
  }, 100);
1698
+
1699
+ // --- task 1003217: a sweep that only RAISED rows must not log silence --------
1700
+
1701
+ test('1003217 runCostSweep reports raised rows (a raise-only sweep still logs)', async () => {
1702
+ const logs = [];
1703
+ const deps = {
1704
+ db: { reconcileSessionCosts: async () => ({ scanned: 12, reconciled: 0, raised: 3, totalUsd: 45, unknownModels: [] }) },
1705
+ log: (m) => logs.push(m),
1706
+ error: () => {},
1707
+ };
1708
+ const r = await reconciler.runCostSweep(deps);
1709
+ assert.equal(r.raised, 3, 'the raised count passes through');
1710
+ assert.equal(logs.length, 1, 'a sweep that raised 3 rows logged — the old guard was reconciled>0 only');
1711
+ assert.match(logs[0], /raised 3/, 'and the raise is named in the line');
1712
+ });
1713
+
1714
+ test('1003217 a sweep that did nothing at all still logs nothing', async () => {
1715
+ const logs = [];
1716
+ const deps = {
1717
+ db: { reconcileSessionCosts: async () => ({ scanned: 12, reconciled: 0, raised: 0, totalUsd: 0, unknownModels: [] }) },
1718
+ log: (m) => logs.push(m),
1719
+ error: () => {},
1720
+ };
1721
+ await reconciler.runCostSweep(deps);
1722
+ assert.equal(logs.length, 0, 'no ledger work → no line, as before');
1723
+ });
1724
+
1725
+ test('1003217 an older deps bundle with no `raised` key still logs on reconciled', async () => {
1726
+ const logs = [];
1727
+ const deps = {
1728
+ db: { reconcileSessionCosts: async () => ({ scanned: 2, reconciled: 2, totalUsd: 3, unknownModels: [] }) },
1729
+ log: (m) => logs.push(m),
1730
+ error: () => {},
1731
+ };
1732
+ await reconciler.runCostSweep(deps);
1733
+ assert.equal(logs.length, 1);
1734
+ assert.doesNotMatch(logs[0], /raised/, 'no phantom "raised 0" noise when the key is absent');
1735
+ });
@@ -131,3 +131,67 @@ test('1344 addComponents / addTiers / roundTiers are pure and additive', () => {
131
131
  const rounded = sc.roundTiers({ fresh_input_usd: 1.000049, cache_read_usd: 0, cache_write_usd: 0, output_usd: 0 });
132
132
  assert.equal(rounded.fresh_input_usd, 1.0); // round to 4dp
133
133
  });
134
+
135
+ // ---------- task 1003217: price one API call once, not once per line ----------
136
+ //
137
+ // A single assistant message is written to the transcript as several lines (one
138
+ // per content block: thinking / text / tool_use), each repeating the SAME
139
+ // message.usage. priceTranscript must dedup on message.id. Measured 2.22x
140
+ // over-pricing on 12 real transcripts before this fix — and ADR 0054 pays
141
+ // round(true_cost_usd x 1.20) off this number.
142
+ function splitLine(id, model, usage, block) {
143
+ return JSON.stringify({ message: { id, role: 'assistant', model, usage, content: [block] } });
144
+ }
145
+
146
+ test('1003217 priceTranscript charges a split assistant message ONCE', () => {
147
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-dedup-'));
148
+ const p = path.join(dir, 'split.jsonl');
149
+ const usage = { output_tokens: M }; // opus-5 output = $25/M
150
+ fs.writeFileSync(p, [
151
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'thinking', thinking: 'hmm' }),
152
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'text', text: 'ok' }),
153
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'tool_use', name: 'Bash' }),
154
+ ].join('\n'));
155
+ const r = sc.priceTranscript(p);
156
+ assert.equal(r.total, 25, 'one call at 1M opus output = $25, NOT $75');
157
+ });
158
+
159
+ test('1003217 priceTranscript still charges distinct message ids separately', () => {
160
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-dedup2-'));
161
+ const p = path.join(dir, 'three.jsonl');
162
+ const usage = { output_tokens: M };
163
+ fs.writeFileSync(p, [
164
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'text', text: 'a' }),
165
+ splitLine('msg_2', 'claude-opus-5', usage, { type: 'text', text: 'b' }),
166
+ splitLine('msg_3', 'claude-opus-5', usage, { type: 'text', text: 'c' }),
167
+ ].join('\n'));
168
+ assert.equal(sc.priceTranscript(p).total, 75, 'three real calls = $75');
169
+ });
170
+
171
+ test('1003217 priceTranscript keeps the tier split consistent with the deduped total', () => {
172
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-dedup3-'));
173
+ const p = path.join(dir, 'tiers.jsonl');
174
+ const usage = { output_tokens: M, cache_read_input_tokens: M };
175
+ fs.writeFileSync(p, [
176
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'text', text: 'a' }),
177
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'tool_use', name: 'Bash' }),
178
+ ].join('\n'));
179
+ const r = sc.priceTranscript(p);
180
+ // opus-5: output $25/M, cache_read $0.50/M → $25.50 for ONE call.
181
+ assert.equal(r.total, 25.5);
182
+ assert.equal(r.tiers.output_usd, 25);
183
+ assert.equal(r.tiers.cache_read_usd, 0.5);
184
+ const tierSum = r.tiers.fresh_input_usd + r.tiers.cache_read_usd
185
+ + r.tiers.cache_write_usd + r.tiers.output_usd;
186
+ assert.ok(Math.abs(tierSum - r.total) < 1e-9, 'the four tiers must still sum to total');
187
+ });
188
+
189
+ test('1003217 a transcript with no message.id prices per line (unchanged legacy path)', () => {
190
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-dedup4-'));
191
+ const p = path.join(dir, 'noid.jsonl');
192
+ fs.writeFileSync(p, [
193
+ JSON.stringify({ message: { role: 'assistant', model: 'claude-opus-5', usage: { output_tokens: M } } }),
194
+ JSON.stringify({ message: { role: 'assistant', model: 'claude-opus-5', usage: { output_tokens: M } } }),
195
+ ].join('\n'));
196
+ assert.equal(sc.priceTranscript(p).total, 50, 'no id to dedup on → both lines charged');
197
+ });
@@ -0,0 +1,139 @@
1
+ // tests/session_cost_row_raise.mjs — task 1003217 (real DB).
2
+ //
3
+ // BUG A: a long session's cost row froze at the FIRST ship. `session_records`
4
+ // is upserted on every ship, so a twelve-task session has a digest from its
5
+ // first ship; the reconciler priced that partial digest ~15 min later, and
6
+ // logCost's `ON CONFLICT DO NOTHING` then silently dropped every later, larger
7
+ // figure. Prod evidence: a 9.3-hour session billed at its first 56 minutes;
8
+ // across four audited sessions the ledger held 14%, 14%, 16% and 54% of real
9
+ // spend.
10
+ //
11
+ // What this pins, against real SQL because every claim here IS the SQL:
12
+ // • a never-priced session still gets its row (the original task-1366 job),
13
+ // • a session whose digest GREW is revisited and its row RAISED,
14
+ // • the raise is monotonic — a lower figure never lowers a paid row,
15
+ // • the sweep CONVERGES: once settled, a second run books nothing,
16
+ // • totalUsd bills only the INCREMENT on a raise, never the whole row again,
17
+ // • a client-split session (source_ref || ':sub') is still left alone.
18
+ //
19
+ // Real-DB integration test: self-skips without a reachable Postgres.
20
+ // Run: DATABASE_URL=postgres:///bongos_test node --test tests/session_cost_row_raise.mjs
21
+
22
+ import { test } from 'node:test';
23
+ import assert from 'node:assert/strict';
24
+ import { createRequire } from 'node:module';
25
+ const require = createRequire(import.meta.url);
26
+ const { Pool } = require('pg');
27
+
28
+ const probe = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
29
+ let live = true;
30
+ try { await probe.query('SELECT 1 FROM cost_log LIMIT 1'); } catch (e) {
31
+ console.log(`session_cost_row_raise.mjs SKIPPED — no reachable Postgres with the GDS schema (${e.code || e.message}).`);
32
+ live = false;
33
+ }
34
+ await probe.end().catch(() => {});
35
+
36
+ const B = 90000411; // fixture builder
37
+ const S1 = 'sess-raise-0000000000000001'; // grows across ships
38
+ const S2 = 'sess-raise-0000000000000002'; // client-split, must be untouched
39
+ const M = 1_000_000;
40
+
41
+ // opus-5 cache_read is $0.50/M, so cache_read tokens make a clean, exact figure.
42
+ const digest = (cacheReadM) => ({ 'claude-opus-5': { input: 0, output: 0, cache_read: cacheReadM * M, cache_creation: 0 } });
43
+
44
+ if (live) {
45
+ const cost = require('../modules/economy/cost.js');
46
+ const pool = cost.pool || new Pool({ connectionString: process.env.DATABASE_URL || undefined });
47
+ const q = (sql, p) => pool.query(sql, p).then((r) => r.rows);
48
+
49
+ const seedDigest = async (sid, cacheReadM, uploadedAgoMin) => q(
50
+ `INSERT INTO session_records (session_id, builder_id, model_usage, uploaded_at)
51
+ VALUES ($1,$2,$3::jsonb, now() - ($4 || ' minutes')::interval)
52
+ ON CONFLICT (session_id) DO UPDATE SET
53
+ model_usage = EXCLUDED.model_usage, uploaded_at = EXCLUDED.uploaded_at`,
54
+ [sid, B, JSON.stringify(digest(cacheReadM)), String(uploadedAgoMin)]
55
+ );
56
+ const row = async (sid) => (await q(
57
+ `SELECT amount_usd, cache_read_usd, description, recorded_at FROM cost_log
58
+ WHERE source='session:interactive' AND source_ref=$1`, [sid]))[0];
59
+ const rowCount = async (sid) => Number((await q(
60
+ `SELECT count(*) n FROM cost_log WHERE source_ref LIKE $1 || '%'`, [sid]))[0].n);
61
+ // Backdate the priced row, modelling production: the sweep prices an early
62
+ // partial digest, and the session keeps shipping for hours afterwards. The
63
+ // revisit predicate is `cost_log.recorded_at < session_records.uploaded_at`,
64
+ // so the row must genuinely predate the digest growth for a raise to be due.
65
+ const backdateRow = async (sid, minsAgo) => q(
66
+ `UPDATE cost_log SET recorded_at = now() - ($2 || ' minutes')::interval
67
+ WHERE source='session:interactive' AND source_ref=$1`, [sid, String(minsAgo)]);
68
+
69
+ const cleanup = async () => {
70
+ await q(`DELETE FROM cost_log WHERE source_ref LIKE 'sess-raise-%'`);
71
+ await q(`DELETE FROM session_records WHERE session_id LIKE 'sess-raise-%'`);
72
+ await q(`DELETE FROM builders WHERE id=$1`, [B]);
73
+ };
74
+
75
+ await cleanup();
76
+ await q(`INSERT INTO builders (id, github_id, github_login, display_name, rank)
77
+ VALUES ($1, 990000411, 'fixture-raise','fixture-raise','xenos')
78
+ ON CONFLICT (id) DO NOTHING`, [B]);
79
+
80
+ test('1003217 a never-priced session gets its row (the original task-1366 job)', async () => {
81
+ await seedDigest(S1, 10, 30); // 10M cache_read = $5.00
82
+ const r = await cost.reconcileSessionCosts({ olderThanMinutes: 15 });
83
+ assert.ok(r.scanned >= 1, 'the unpriced session is scanned');
84
+ const got = await row(S1);
85
+ assert.equal(Number(got.amount_usd), 5, 'priced at $5.00');
86
+ assert.match(got.description, /client cost-hook did not post/, 'first write keeps its provenance wording');
87
+ });
88
+
89
+ test('1003217 the row is RAISED when the digest grows (the frozen-row bug)', async () => {
90
+ // Priced an hour ago, off the first ship's partial digest...
91
+ await backdateRow(S1, 60);
92
+ // ...then the session shipped more tasks: the digest is replaced with the
93
+ // fuller transcript figure and uploaded_at moves forward.
94
+ await seedDigest(S1, 100, 20); // 100M cache_read = $50.00
95
+ const r = await cost.reconcileSessionCosts({ olderThanMinutes: 15 });
96
+ assert.equal(r.raised, 1, 'exactly one row raised');
97
+ const got = await row(S1);
98
+ assert.equal(Number(got.amount_usd), 50, 'row caught up from $5 to $50');
99
+ assert.equal(Number(got.cache_read_usd), 50, 'the tier split is rewritten too, not left stale');
100
+ assert.match(got.description, /raised from \$5\.0000 as the session grew/, 'the raise is legible in the ledger');
101
+ // Only the increment is billed as this sweep's spend.
102
+ assert.ok(Math.abs(r.totalUsd - 45) < 1e-6, `totalUsd should be the $45 increment, got ${r.totalUsd}`);
103
+ assert.equal(await rowCount(S1), 1, 'still ONE row — a raise must never fork a second');
104
+ });
105
+
106
+ test('1003217 the sweep converges: a settled session books nothing on the next run', async () => {
107
+ const r = await cost.reconcileSessionCosts({ olderThanMinutes: 15 });
108
+ assert.equal(r.raised, 0, 'nothing left to raise');
109
+ assert.equal(r.reconciled, 0, 'nothing left to insert');
110
+ assert.equal(Number((await row(S1)).amount_usd), 50, 'the row is unchanged');
111
+ });
112
+
113
+ test('1003217 the raise is MONOTONIC — a smaller figure never lowers a paid row', async () => {
114
+ // This is the "do not re-price history" guarantee: after the dedup fix
115
+ // (bug B) a re-priced old session computes LOWER, and llm-pricing.js is
116
+ // explicit that a paid row records what was charged. It must hold.
117
+ await backdateRow(S1, 60);
118
+ await seedDigest(S1, 4, 20); // would be $2.00 — far below the $50 on file
119
+ const r = await cost.reconcileSessionCosts({ olderThanMinutes: 15 });
120
+ assert.equal(r.raised, 0, 'a downward move is not a raise');
121
+ assert.equal(Number((await row(S1)).amount_usd), 50, 'the row held at $50');
122
+ assert.ok(Math.abs(r.totalUsd) < 1e-9, 'and nothing was billed');
123
+ });
124
+
125
+ test('1003217 a client-split session (:sub) is still left entirely alone', async () => {
126
+ await seedDigest(S2, 10, 30);
127
+ await q(`INSERT INTO cost_log (amount_usd, category, source, source_ref, builder_id)
128
+ VALUES (1.23,'api','session:subagent',$1 || ':sub',$2)`, [S2, B]);
129
+ const before = await rowCount(S2);
130
+ await cost.reconcileSessionCosts({ olderThanMinutes: 15 });
131
+ assert.equal(await rowCount(S2), before, 'no interactive row invented alongside the client split');
132
+ assert.equal(await row(S2), undefined, 'specifically: no session:interactive row for it');
133
+ });
134
+
135
+ test('cleanup', async () => {
136
+ await cleanup();
137
+ if (!cost.pool) await pool.end().catch(() => {});
138
+ });
139
+ }
@@ -405,3 +405,75 @@ test('a failed/empty subagent spawn digests to 0 tokens despite 1 assistant turn
405
405
  console.log('');
406
406
  console.log(`Session-records (6D.1) tests: ${passed} passed, ${failed} failed`);
407
407
  if (failed > 0) process.exit(1);
408
+
409
+ // ---------- task 1003217: one API call is many transcript lines ----------
410
+ //
411
+ // Claude Code writes a separate line per content block of one assistant message
412
+ // (thinking / text / tool_use) and repeats the SAME message.usage on each. The
413
+ // digest must price the call ONCE. Measured 2.22x inflation on real transcripts
414
+ // before this fix.
415
+ function splitCallFixture() {
416
+ const usage = {
417
+ input_tokens: 2,
418
+ output_tokens: 799,
419
+ cache_read_input_tokens: 63182,
420
+ cache_creation_input_tokens: 3227,
421
+ };
422
+ const mk = (content) => ({
423
+ type: 'assistant',
424
+ timestamp: '2026-08-21T19:23:47.000Z',
425
+ message: { id: 'msg_01SPLIT', model: 'claude-opus-5', usage, content },
426
+ });
427
+ return [
428
+ mk([{ type: 'thinking', thinking: 'weighing it up' }]),
429
+ mk([{ type: 'text', text: 'Here is the plan.' }]),
430
+ mk([{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }]),
431
+ ];
432
+ }
433
+
434
+ test('1003217 buildSessionDigest prices one split assistant message ONCE, not per line', () => {
435
+ const d = buildSessionDigest(splitCallFixture());
436
+ // 3 transcript lines, 1 API call.
437
+ assert.equal(d.metrics.assistant_turns, 3, 'assistant_turns stays the LINE count for existing consumers');
438
+ assert.equal(d.metrics.api_calls, 1, 'api_calls is the honest round-trip count');
439
+ assert.equal(d.metrics.tokens.output, 799, 'output counted once, not 3x');
440
+ assert.equal(d.metrics.tokens.cache_read, 63182, 'cache_read counted once, not 3x');
441
+ assert.equal(d.metrics.tokens.cache_creation, 3227);
442
+ assert.equal(d.metrics.tokens.input, 2);
443
+ assert.equal(d.totalTokens, 2 + 799 + 63182 + 3227);
444
+ assert.deepEqual(d.metrics.model_usage['claude-opus-5'], {
445
+ input: 2, output: 799, cache_read: 63182, cache_creation: 3227,
446
+ }, 'model_usage — the cost-plus reward basis (ADR 0054) — must not be inflated');
447
+ // The tool_use on the third line is still seen.
448
+ assert.equal(d.metrics.tool_use_count, 1);
449
+ });
450
+
451
+ test('1003217 the detail rows still sum to the true usage (repeats zeroed + flagged)', () => {
452
+ const d = buildSessionDigest(splitCallFixture());
453
+ const asst = d.detail.filter((r) => r.role === 'assistant');
454
+ assert.equal(asst.length, 3, 'every line keeps its detail row');
455
+ const sum = (k) => asst.reduce((a, r) => a + (r[k] || 0), 0);
456
+ assert.equal(sum('cache_read'), 63182, 'summing detail must not double-count');
457
+ assert.equal(sum('out'), 799);
458
+ assert.equal(asst.filter((r) => r.dup === 1).length, 2, 'the two repeat lines are flagged dup');
459
+ assert.equal(asst[0].dup, undefined, 'the first line of the call is not a dup');
460
+ });
461
+
462
+ test('1003217 distinct message ids are still counted separately', () => {
463
+ const usage = { input_tokens: 0, output_tokens: 10, cache_read_input_tokens: 100, cache_creation_input_tokens: 0 };
464
+ const mk = (id) => ({
465
+ type: 'assistant',
466
+ timestamp: '2026-08-21T19:23:47.000Z',
467
+ message: { id, model: 'claude-opus-5', usage, content: [{ type: 'text', text: 'x' }] },
468
+ });
469
+ const d = buildSessionDigest([mk('msg_A'), mk('msg_B'), mk('msg_C')]);
470
+ assert.equal(d.metrics.api_calls, 3);
471
+ assert.equal(d.metrics.tokens.cache_read, 300, 'three real calls = three times the usage');
472
+ });
473
+
474
+ test('1003217 a transcript with no message.id degrades to the old per-line behaviour', () => {
475
+ const d = buildSessionDigest(fixture()); // fixture() messages carry no id
476
+ assert.equal(d.metrics.tokens.input, 300, 'unchanged from the pre-fix expectation');
477
+ assert.equal(d.metrics.tokens.output, 130);
478
+ assert.equal(d.metrics.api_calls, 2, 'with no id to dedup on, every line is its own call');
479
+ });