=== session s3 transcript ===
[tool_use Read]
{"file_path":"src/core/render.ts"}
[tool_result]
return m;
}

/**
 * Tee the response body so we can scan for the usage block AND the per-event
 * char counts for honest output measurement. Returns the un-touched response
 * to forward to the client + a Promise that resolves to the parsed Usage and
 * a Promise that resolves to the measurement (both `undefined` when we can't
 * extract them — e.g. unknown content-type, 5xx, no body).
 *
 * Streaming responses are scanned to EOF (not the old 64 KiB cap) because the
 * final `output_tokens` lives in the `message_delta` at the end of the stream
 * and `redacted_thinking` blocks can appear anywhere. The scanner is cheap
 * (regex-free incremental SSE parser) and the tee back-pressure is no worse
 * than the previous "drain to /dev/null in the background" path.
 *
 * For upstream 4xx responses, we tee the body to capture up to `ERROR_BODY_MAX`
 * chars so the host can log what Anthropic actually rejected. 5xx still bails.
 */
function teeForUsage(res: Response): {
  response: Response;
  usagePromise: Promise<Usage | undefined>;
  errorBodyPromise: Promise<string | undefined>;
  measurementPromise: Promise<OutputMeasurement | undefined>;
} {
  // No body at all: nothing to extract on either path.
  if (!res.body) {
    return {
      response: res,
      usagePromise: Promise.resolve(undefined),
      errorBodyPromise: Promise.resolve(undefined),
      measurementPromise: Promise.resolve(undefined),
    };
  }
  // 4xx: tee for the error body but skip usage scanning entirely.
  if (res.status >= 400 && res.status < 500) {
    const [forClient, forUs] = res.body.tee();
    const errorBodyPromise = (async (): Promise<string | undefined> => {
      const reader = forUs.getReader();
      const decoder = new TextDecoder();
      let out = '';
      try {
        while (out.length < ERROR_BODY_MAX) {
          const { done, value } = await reader.read();
          if (done) break;
          out += decoder.decode(value, { stream: true });
        }
        out += decoder.decode();
        // Drain the rest so the tee buffer doesn't hold the stream open.
        while (true) {
          const { done } = await reader.read();
          if (done) break;
        }
      } catch {
        /* client may have aborted; whatever we got is fine */
      }
      return out.length > ERROR_BODY_MAX ? out.slice(0, ERROR_BODY_MAX) : out;
    })();
    return {
      response: new Response(forClient, {
        status: res.status,
        statusText: res.statusText,
        headers: res.headers,
      }),
      usagePromise: Promise.resolve(undefined),
      errorBodyPromise,
      measurementPromise: Promise.resolve(undefined),
    };
  }
  // 5xx: skip both (the host already synthesizes an error message).
  if (res.status >= 500) {
    return {
      response: res,
      usagePromise: Promise.resolve(undefined),
      errorBodyPromise: Promise.resolve(undefined),
      measurementPromise: Promise.resolve(undefined),
    };
  }
  const ct = (res.headers.get('content-type') ?? '').toLowerCase();
  const [forClient, forUs] = res.body.tee();

  // Single scan resolves both usage and measurement together. We expose them
  // as separate promises (resolved from the same shared payload) so callers
  // can stay readable; both wait on the same underlying read loop.
  const scanResult = (async (): Promise<{
    usage: Usage | undefined;
    measurement: OutputMeasurement | undefined;
  }> => {
    const reader = forUs.getReader();
    const decoder = new TextDecoder();
    let buf = '';

    try {
      if (ct.includes('text/event-stream')) {
        // SSE: walk every event to EOF. Final output_tokens is in message_delta
        // (last event before message_stop); redacted_thinking blocks can appear
        // anywhere. Tee back-pressure is bounded by the slower-reader's buffer,
        // which here is whichever of the proxy/scanner falls behind — both run
        // at network speed in practice.
        const m: OutputMeasurement = {
          textChars: 0,
          thinkingChars: 0,
          toolUseChars: 0,
          redactedBlockCount: 0,
        };
        const state: { usage: Usage | undefined } = { usage: undefined };
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          buf += decoder.decode(value, { stream: true });
          // Drain every complete event (terminated by a blank line per SSE spec).
          let evEnd: number;
          while ((evEnd = buf.indexOf('\n\n')) >= 
[assistant]
Reviewed src/core/render.ts.
[user]
set LEASE_TTL_MS to 2400 to start.
[assistant]
LEASE_TTL_MS=2400 committed.
[tool_use Read]
{"file_path":"src/core/proxy.ts"}
[tool_result]
.path,
        status: t.status,
        compressed: t.compressed === true,
        cc_added: t.compressed === true ? 1 : undefined,
        input_tokens: t.input_tokens,
        output_tokens: t.output_tokens,
        cache_create: t.cache_create_tokens,
        cache_read: t.cache_read_tokens,
        actual_input: haveUsage ? round1(actualInputEff) : undefined,
        baseline_input:
          haveBaseline && haveUsage ? round1(baselineInputEff) : undefined,
      };
      this.recent.push(row);
    }
  }

  // ---- HTTP handlers ------------------------------------------------------

  /**
   * Per-session "what's happening right now" payload backing the
   * `SessionSummary` panel. Scopes the dollar-weighted savings ratio + the
   * per-bucket char attribution + the passthrough-reason histogram to the
   * most-recently-active session (tracked via `info.firstUserSha8`) so the
   * top-of-dashboard headline reflects the live session rather than stale
   * lifetime aggregates from a previous run.
   *
   * Returns `{ sessionId: null, message: 'no active session yet' }` when no
   * events have been received yet (or the first events were all untagged —
   * cold start, probe failures) — `update()` only sets `currentSessionId`
   * when a `firstUserSha8`-tagged event lands. The client renders a stale
   * panel rather than zeroes when a session goes idle (NO `lastSeen >
   * threshold` check here; see comment in `update()` for the rationale).
   */
  serveCurrentSessionJson(): Response {
    if (!this.currentSessionId) {
      return jsonResponse({
        sessionId: null,
        message: 'no active session yet',
      });
    }
    const s = this.sessions.get(this.currentSessionId);
    if (!s) {
      return jsonResponse({ sessionId: null, message: 'no active session yet' });
    }
    // Headline payload: the same dollar-weighted baseline/actual input
    // accumulators the global Totals block exposes, plus the honest
    // denominator (`baselineMeasuredCount` — only requests that carried a
    // baseline measurement). We ALSO ship the ALL-rows session bill
    // (`allActualInputWeighted` + `allOutputWeighted`) so the Svelte panel
    // can compute saved-% against the full session bill instead of just
    // the measured slice — matching the global `saved_pct_of_all_spend`
    // math but scoped to one session. The Svelte panel does the dollar
    // conversion itself so we don't round-trip pricing through the wire.
    return jsonResponse({
      sessionId: s.sessionId,
      baselineInputWeighted: s.baselineInputWeighted,
      actualInputWeighted: s.actualInputWeighted,
      baselineMeasuredCount: s.baselineMeasuredCount,
      allActualInputWeighted: s.allActualInputWeighted,
      allOutputWeighted: s.allOutputWeighted,
    });
  }

  serveStats(): Response {
    // Two headline numbers, derived from the same per-event accumulators:
    //
    //   saved_pct_input_only = Σ saved / Σ baseline_input_eff × 100
    //     What the proxy actually saved on the slice it can move (input).
    //     Numerator = input tokens we didn't pay for (cache-aware).
    //     Denominator = input tokens we WOULD have paid (cache-aware).
    //     Output is excluded because the proxy doesn't touch it.
    //
    //   saved_pct_of_total_bill = Σ saved / Σ (baseline_input + output × 5) × 100
    //     What share of the TOTAL bill the proxy saved. Honest counter to the
    //     input-only number: on output-heavy sessions (long thinking blocks,
    //     big edits) the percentage shrinks because output dominates.
    //
    //   token_equivalent_total = Σ (actual_input + output × 5)
    //     What Anthropic's weekly limit actually meters — input × 1.0 +
    //     output × 5.0 (the same ratio as the per-MTok price card). This is
    //     the number that moves your "%% used this week" indicator.
    const baseline = this.totals.baselineInputWeighted;
    const actual = this.totals.actualInputWeighted;
    const output = this.totals.outputWeighted; // already × OUTPUT_TOKEN_RATE
    const saved = baseline - actual;
    const pctInput = baseline > 0 ? (saved / baseline) * 100 : 0;
    const baselineTotal = baseline + output;
    const actualTotal = actual + output;
    const pctTotal = baselineTotal > 0 ? (saved / baselineTotal) * 100 : 0;

    // Share-of-all-spend: honest denominator. The numerator can only credit
    // savings against rows where we have a probe baseline (otherwise it's
    // estimation), but the denominator MUST include every request the user
    // actually paid for — including passthrough rows, probe-failed rows,
    // and untransformed turns the gate said no to. Otherwise the headline
    // answers "did pxpipe help on the rows where it ran" instead of
    // "did pxpipe move my real bill". The first is a cherry-pick.
    const allBaselineEquiv = this.totals.allBaselineEquivalentWeighted;
    const allActual = this.totals.allActualInputWeighted;
    const allOutput = this.totals.allOutputWeighted;
    // Denominator = counterfactual all-rows bill: what the user would have
    // paid with no pxpipe. Bounded ratio at 100%; a single cold-miss
    // compressed request on an otherwise empty session shows ~99% saved,
    // not 280%.
    const allCounterfactualBill = allBaselineEquiv + allOutput;
    const pctAllSpend =
      allCounterfactualBill > 0 ? (saved / allCounterfactualBill) * 100 : 0;

    // Direct observed split — actual $ per request, partitioned by which
    // path ran. Token-equivalent (input × 1.0 + cache_create × 1.25 +
    // cache_read × 0.10 + output × 5) → $ at the assumed Opus 4.7 input
    // rate. Same $/Mtok rate is applied to both buckets, so the bias from
    // the rate assumption cancels in the delta. Selection bias from the
    // gate is NOT cancelled — the operator interprets that via the
    // sample-count caveat below.
    const compressedTokenEquiv =
      this.totals.compressedActualInputWeighted +
      this.totals.compressedOutputWeighted;
    const passthroughTokenEquiv =
      this.totals.passthroughActualInputWeighted +
      this.totals.passthroughOutputWeighted;
    const compressedActualUsd =
      (compressedTokenEquiv * ASSUMED_INPUT_USD_PER_MTOK) / 1e6;
    const passthroughActualUsd =
      (passthroughTokenEquiv * ASSUMED_INPUT_USD_PER_MTOK) / 1e6;
    const compressedAvgUsd =
      this.totals.compressedPaidRequests > 0
        ? compressedActualUsd / this.totals.compressedPaidRequests
        : 0;
    const passthroughAvgUsd =
      this.totals.passthroughPaidRequests > 0
        ? passthroughActualUsd / this.totals.passthroughPaidRequests
        : 0;
    // Sufficient-sample threshold is a soft heuristic. 20 paid requests per
    // bucket is enough to see a real effect on Opus 4.7 traffic (a single
    // big cold-miss can dominate at lower n). Below this, the UI shows the
    // bucket numbers but hides the delta and surfaces "small sample".
    const SUFFICIENT = 20;
    const splitSufficient =
      this.totals.compressedPaidRequests >= SUFFICIENT &&
      this.totals.passthroughPaidRequests >= SUFFICIENT;
    const splitDeltaUsd = compressedAvgUsd - passthroughAvgUsd;

    const uptimeSec = Date.now() / 1000 - this.totals.startedAt;
    const payload = {
      requests: this.totals.requests,
      compressed_requests: this.totals.compressedRequests,
      baseline_input_weighted: Math.round(baseline),
      actual_input_weighted: Math.round(actual),
      saved_input_tokens: Math.round(saved),
      // saved_pct kept for back-compat with existing dashboard HTML; it is
      // the input-only number. New code should read saved_pct_input_only.
      saved_pct: round1(pctInput),
      saved_pct_input_only: round1(pctInput),
      saved_pct_of_total_bill: round1(pctTotal),
      // Honest "share of total bill saved" — measured-rows numerator over
      // ALL paid requests in the denominator (compressed + passthrough +
      // probe-failed). This is the number users actually want when they
      // ask "is pxpipe helping". Negative when flap-pollution from
      // passthrough turns exceeds the collapse win on measured turns.
      saved_pct_of_all_spend: round1(pctAllSpend),
      all_baseline_equivalent_weighted: Math.round(allBaselineEquiv),
      all_actual_input_weighted: Math.round(allActual),
      all_output_weighted: Math.round(allOutput),
      all_usage_requests: this.totals.allUsageRequests,
      // Direct observed split — replaces "share of spend saved" as the
      // headline. Total actual $ and average $/req per path, plus a delta
      // gated on `split_sufficient_sample`. No counterfactual: each
      // bucket is what each path actually billed.
      compressed_paid_requests: this.totals.compressedPaidRequests,
      passthrough_paid_requests: this.totals.passthroughPaidRequests,
      compressed_actual_usd: round4(compressedActualUsd),
      passthrough_actual_usd: round4(passthroughActualUsd),
      compressed_avg_usd_per_request: round4(compressedAvgUsd),
      passthrough_avg_usd_per_request: round4(passthroughAvgUsd),
      compressed_minus_passthrough_avg_usd: round4(splitDeltaUsd),
      split_sufficient_sample: splitSufficient,
      split_min_sample_per_bucket: SUFFICIENT,
      saved_usd: round4((saved * ASSUMED_INPUT_USD_PER_MTOK) / 1e6),
      output_weighted: Math.round(output),
      baseline_token_equivalent: Math.round(baselineTotal),
      actual_token_equivalent: Math.round(actualTotal),
      pricing_assumptions: {
        input_per_mtok: ASSUMED_INPUT_USD_PER_MTOK,
        output_multiplier: OUTPUT_TOKEN_RATE,
        cache_write_5m_multiplier: 1.25,
        cache_write_1h_multiplier: 2.0,
        cache_read_multiplier: 0.1,
        source: 'docs.anthropic.com/en/docs/about-claude/pricing (verified 2026-05-19)',
      },
      // Honest output measurement — char counts from the SSE/JSON scanner,
      // independent of Anthropic's `usage.output_tokens`. Surfaces the gap
      // the May-2026 weekly-meter audit hypothesized (redacted_thinking adds
      // billed tokens that we can't see). `events_with_measurement` lets the
      // operator weigh how representative the numbers are; when it's near
      // `requests`, the gap is real. When it's 0, the scanner never landed
      // (5xx-heavy session, no /v1/messages traffic).
      measured_text_chars: this.totals.textCharsMeasured,
      measured_thinking_chars: this.totals.thinkingCharsMeasured,
      measured_tool_use_chars: this.totals.toolUseCharsMeasured,
      measured_redacted_block_count: this.totals.redactedBlockCountMeasured,
      events_with_measurement: this.totals.eventsWithMeasurement,
      uptime_sec: uptimeSec,
      compression_enabled: this.compressionEnabled,
    };
    return new Response(JSON.stringify(payload, null, 2), {
      headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*' },
    });
  }

  serveRecent(): Response {
    const latest = this.images[this.images.length - 1];
    const payload = {
      recent: this.recent,
      has_preview: latest !== undefined,
      preview_meta: latest?.meta ?? '',
      // Image ids currently resident in the ring. The dashboard uses this to
      // tell which RecentRow.img_id values still resolve — older ones have
      // been evicted and their "view" button should be disabled.
      image_ids: this.images.map((im) => im.id),
    };
    return new Response(JSON.stringify(payload), {
      headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*' },
    });
  }

  /** GET /proxy-latest-png[?id=N] — raw PNG from the image ring. With no id
   *  (or an unknown id) the latest render is returned; ?id=N pulls that
   *  specific image while it's still in the ring. 404 once evicted. */
  servePng(id?: number): Response {
    // Cropping is done client-side via CSS (object-position + overflow:hidden).
    // Python decoded the PNG to crop server-side; we skip that to avoid
    // pulling a PNG decoder back in — the CSS approach renders identically.
    const entry =
      id !== undefined
        ? this.images.find((im) => im.id === id)
        : this.images[this.images.length - 1];
    if (!entry) {
      return new Response('no image yet', { status: 404 });
    }
    return new Response(entry.png as unknown as BodyInit, {
      headers: { 'content-type': 'image/png', 'cache-control': 'no-cache' },
    });
  }

  /** GET /api/image-source[?id=N] — the source text the PNG was rendered
   *  from, so the operator can see the text → image conversion side by side.
   *  Falls back to the latest image; 404 if evicted or text wasn't captured. */
  serveImageSource(id?: number): Response {
    const entry =
      id !== undefined
        ? this.images.find((im) => im.id === id)
        : this.images[this.images.length - 1];
    if (!entry || entry.sourceText === undefined) {
      return new Response(JSON.stringify({ error: 'no source text' }), {
        status: 404,
        headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*' },
      });
    }
    return new Response(
      JSON.stringify({ id: entry.id, meta: entry.meta, source_text: entry.sourceText }),
      { headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*' } },
    );
  }

  serveHtml(port: number): Response {
    return htmlResponse(renderPage(port));
  }

  /** GET /fragments/<name> — server-rendered htmx fragments. Each one reuses
   *  the corresponding JSON endpoint's payload (via Response.json()) so the
   *  HTML and JSON surfaces can't drift apart. */
  async serveFragment(name: string, url: URL, port: number): Promise<Response> {
    switch (name) {
      case 'toggle':
        return htmlResponse(renderToggleFragment(this.compressionEnabled));
      case 'session-summary': {
        const cur = (await this.serveCurrentSessionJson().json()) as CurrentSessionPayload;
        return htmlResponse(renderSessionSummaryFragment(cur));
      }
      case 'header': {
        const s = (await this.serveStats().json()) as StatsPayload;
        return htmlResponse(renderHeaderFragment(s, port));
      }
      case 'recent': {
        const r = (await this.serveRecent().json()) as RecentPayload;
        return htmlResponse(renderRecentFragment(r));
     
[assistant]
Reviewed src/core/proxy.ts.
[user]
p95 regressed, bump LEASE_TTL_MS to 9600.
[assistant]
LEASE_TTL_MS changed 2400 -> 9600.
[tool_use Read]
{"file_path":"src/core/render.ts"}
[tool_result]
 `_ephemeral` parameter is preserved for call-
  // site compatibility but is now a no-op.
  return {
    type: 'image',
    source: { type: 'base64', media_type: 'image/png', data: pngB64 },
  };
}

/** Render a long text blob to one or more PNG image blocks. Helper for the
 *  per-message compressions (reminders, tool_results) — no cache_control on
 *  these (Anthropic caps at 4 breakpoints; the system+tools image already
 *  anchors the cacheable prefix).
 *
 *  Also returns the total `droppedChars` across all rendered images plus the
 *  merged codepoint→count map so the caller can fold both into the request's
 *  `info.droppedChars` / `info.droppedCodepointsTop`. */

// --- paging / truncation -------------------------------------------------
//
// Anthropic's API caps a request at 100 images. A single huge tool_result
// (find over a big tree, multi-MB log dump) can blow that cap by itself.
// To keep the request valid AND not waste tokens on dozens of bottom-of-log
// images, we truncate the source text before render with a marker that
// tells the model what was elided.

/** Visual rows a single input line will consume after soft-wrap at `cols`. */
function lineRows(line: string, cols: number): number {
  return Math.max(1, Math.ceil(line.length / cols));
}

/** Count the visual rows `text` will consume after soft-wrap at `cols`.
 *  Wrap-the-line reflow breaks the render row at every ↵, so both `\n` and
 *  the ↵ sentinel end a counted line — and the ↵ glyph occupies a cell on
 *  the line it terminates. */
function countVisualRows(text: string, cols: number): number {
  let rows = 0;
  let lineStart = 0;
  const len = text.length;
  for (let i = 0; i <= len; i++) {
    const cc = i < len ? text.charCodeAt(i) : -1;
    const isSentinel = cc === 0x21b5 /* ↵ */;
    if (i === len || cc === 10 /* \n */ || isSentinel) {
      // ↵ renders as a glyph on the line it ends — count it in the length.
      const lineLen = (isSentinel ? i + 1 : i) - lineStart;
      rows += Math.max(1, Math.ceil(lineLen / cols));
      lineStart = i + 1;
    }
  }
  return rows;
}

/** Estimate how many images `text` will render to at the given column width.
 *  Counts soft-wrapped visual rows, which is what render.ts actually budgets
 *  against. Exported for tests + the paging gate.
 *
 *  `numCols` (default 1) packs that many text columns side-by-side per
 *  image — must match the `multiCol` setting wired through to the renderer
 *  for the math to predict the actual image count. */
export function estimateImageCount(
  textOrLen: string | number,
  cols: number,
  numCols: number = 1,
  maxCharsPerImage: number = READABLE_CHARS_PER_IMAGE,
): number {
  const n = Math.max(1, numCols | 0);
  const readableLinesPerCol = Math.max(1, Math.floor(READABLE_CHARS_PER_IMAGE / Math.max(1, cols)));
  const linesPerImage = Math.min(LINES_PER_IMAGE, readableLinesPerCol) * n;
  const charBudget = Math.max(1, maxCharsPerImage * n);
  if (typeof textOrLen === 'number') {
    // Back-compat shim — numeric arg gets the looser chars-based estimate.
    return Math.max(1, Math.ceil(textOrLen / charBudget));
  }
  const rows = countVisualRows(textOrLen, cols);
  return Math.max(
    1,
    Math.ceil(rows / linesPerImage),
    Math.ceil(textOrLen.length / charBudget),
  );
}

/** Classify content so we can pick a truncation strategy. Cheap heuristics on
 *  the first ~4 KiB. Returns:
 *    - `'structured'`: JSON/YAML/diff markers at the top. Truncate tail.
 *    - `'log'`: ≥30% of lines start with a log level or timestamp. Truncate middle.
 *    - `'other'`: prose, file dumps, etc. Truncate middle.
 *  Exported for tests. */
export function classifyContent(text: string): 'structured' | 'log' | 'other' {
  const head = text.slice(0, 4096);
  const trimmed = head.trimStart();
  if (trimmed.startsWith('{') && /^\{\s*("|\})/.test(trimmed)) return 'structured';
  if (trimmed.startsWith('[') && /^\[\s*("|\{|\[|-?\d|true\b|false\b|null\b|\])/.test(trimmed))
    return 'structured';
  if (trimmed.startsWith('---\n') || trimmed.startsWith('---\r\n')) return 'structured';
  if (trimmed.startsWith('diff --git ') || /^---\s+\S/.test(trimmed)) return 'structured';
  const lines = head.split('\n').slice(0, 40).filter((l) => l.length > 0);
  if (lines.length < 4) return 'other';
  const LOG_LINE =
    /^(\[?(DEBUG|INFO|WARN|WARNING|ERROR|TRACE|FATAL)\]?\b|\d{4}-\d{2}-\d{2}[T ]?|\d{2}:\d{2}:\d{2}\b)/;
  let logHits = 0;
  for (const line of lines) if (LOG_LINE.test(line)) logHits++;
  if (logHits / lines.length >= 0.3) return 'log';
  return 'other';
}

/** Build the paging marker text. The model sees this verbatim INSIDE the
 *  rendered image so it can reason about what was elided. */
function buildPagingMarker(args: {
  originalChars: number;
  originalLines: number;
  originalEstImages: number;
  shownHeadLines: number;
  shownTailLines: number;
  omittedLines: number;
  omittedChars: number;
}): string {
  const tailNote =
    args.shownTailLines > 0
      ? ` Showing first ${args.shownHeadLines} lines and last ${args.shownTailLines} lines.`
      : ` Showing first ${args.shownHeadLines} lines (tail elided).`;
  return (
    `\n\n[ pxpipe paging: omitted ${args.omittedLines.toLocaleString('en-US')} lines ` +
    `(${args.omittedChars.toLocaleString('en-US')} chars) of content here. ` +
    `Original length: ${args.originalChars.toLocaleString('en-US')} chars ` +
    `(${args.originalLines.toLocaleString('en-US')} lines, ~${args.originalEstImages} images).` +
    `${tailNote} ]\n\n`
  );
}

/** Truncate `text` so it renders to roughly `maxImages` images at the given
 *  `cols`. Picks head/tail split based on `classifyContent`. Budget measured
 *  in visual rows (what render.ts actually slices on). Returns the truncated
 *  text (with paging marker embedded) and the count of chars omitted. If
 *  `text` already fits, returns unchanged with `omittedChars: 0`. Exported
 *  for tests. */
export function truncateForBudget(
  text: string,
  maxImages: number,
  cols: number,
  numCols: number = 1,
  maxCharsPerImage: number = DENSE_CONTENT_CHARS_PER_IMAGE,
): { text: string; omittedChars: number; truncated: boolean } {
  const n = Math.max(1, numCols | 0);
  const estImages = estimateImageCount(text, cols, n, maxCharsPerImage);
  if (estImages <= maxImages) return { text, omittedChars: 0, truncated: false };
  const readableLinesPerCol = Math.max(1, Math.floor(READABLE_CHARS_PER_IMAGE / Math.max(1, cols)));
  const totalRowBudget = Math.max(8, maxImages * Math.min(LINES_PER_IMAGE, readableLinesPerCol) * n - 6);
  const totalCharBudget = Math.max(128, maxImages * maxCharsPerImage * n - 512);
  const shape = classifyContent(text);
  // Reflowed text uses NL_SENTINEL (↵ U+21B5) as line separator instead of \n.
  // Split on whichever delimiter the text uses so we can truncate at logical
  // line boundaries rather than treating the entire reflowed blob as one line.
  const nlChar = text.indexOf('\n') >= 0 ? '\n' : NL_SENTINEL;
  const lines = text.split(nlChar);
  const originalLines = lines.length;
  const originalChars = text.length;

  if (shape === 'structured') {
    let rows = 0;
    let chars = 0;
    let cut = 0;
    for (let i = 0; i < lines.length; i++) {
      const r = lineRows(lines[i]!, cols);
      const c = lines[i]!.length + (i > 0 ? 1 : 0);
      if (rows + r > totalRowBudget || chars + c > totalCharBudget) break;
      rows += r;
      chars += c;
      cut = i + 1;
    }
    if (cut === 0) cut = 1;
    const head = lines.slice(0, cut).join(nlChar);
    const omitted = originalChars - head.length;
    return {
      text:
        head +
        buildPagingMarker({
          originalChars,
          originalLines,
          originalEstImages: estImages,
          shownHeadLines: cut,
          shownTailLines: 0,
          omittedLines: originalLines - cut,
          omittedChars: omitted,
        }),
      omittedChars: omitted,
      truncated: true,
    };
  }

  // log / other: 60% head, 40% tail.
  const headRowBudget = Math.floor(totalRowBudget * 0.6);
  const tailRowBudget = totalRowBudget - headRowBudget;
  const headCharBudget = Math.floor(totalCharBudget * 0.6);
  const tailCharBudget = totalCharBudget - headCharBudget;
  let headRows = 0;
  let headChars = 0;
  let headCut = 0;
  for (let i = 0; i < lines.length; i++) {
    const r = lineRows(lines[i]!, cols);
    const c = lines[i]!.length + (i > 0 ? 1 : 0);
    if (headRows + r > headRowBudget || headChars + c > headCharBudget) break;
    headRows += r;
    headChars += c;
    headCut = i + 1;
  }
  if (headCut === 0) headCut = 1;
  let tailRows = 0;
  let tailChars = 0;
  let tailStart = lines.length;
  for (let i = lines.length - 1; i >= headCut; i--) {
    const r = lineRows(lines[i]!, cols);
    const c = lines[i]!.length + (i < lines.length - 1 ? 1 : 0);
    if (tailRows + r > tailRowBudget || tailChars + c > tailCharBudget) break;
    tailRows += r;
    tailChars += c;
    tailStart = i;
  }
  if (tailStart <= headCut || tailStart >= lines.length) {
    const head = lines.slice(0, headCut).join(nlChar);
    const omitted = originalChars - head.length;
    return {
      text:
        head +
        buildPagingMarker({
          originalChars,
          originalLines,
          originalEstImages: estImages,
          shownHeadLines: headCut,
          shownTailLines: 0,
          omittedLines: originalLines - headCut,
          omittedChars: omitted,
        }),
      omittedChars: omitted,
      truncated: true,
    };
  }
  const headText = lines.slice(0, headCut).join(nlChar);
  const tailText = lines.slice(tailStart).join(nlChar);
  const shownChars = headText.length + tailText.length;
  const omitted = originalChars - shownChars;
  return {
    text:
      headText +
      buildPagingMarker({
        originalChars,
        originalLines,
        originalEstImages: estImages,
        shownHeadLines: headCut,
        shownTailLines: lines.length - tailStart,
        omittedLines: originalLines - headCut - (lines.length - tailStart),
        omittedChars: omitted,
      }) +
      tailText,
    omittedChars: omitted,
    truncated: true,
  };
}

async function textToImageBlocks(
  text: string,
  cols: number,
  numCols: number = 1,
  /** Shrink the rendered canvas width to the longest actual wrapped line.
   *  Default `true` (matches the `isCompressionProfitable` default and is
   *  correct for per-block content: tool_result, reminder, history). Set
   *  `false` for the system slab path, which fills the full configured
   *  `cols` deliberately for multi-col packing. */
  shrinkWidth: boolean = true,
): Promise<{
  blocks: ImageBlock[];
  droppedChars: number;
  droppedCodepoints: Map<number, number>;
  /** Total pixel area across the rendered images (`Σ width × height`).
   *  Lets the caller accumulate `info.imagePixels` for the empirical
   *  px/token regression. */
  pixels: number;
}> {
  // Width-shrink at the renderer entry so the renderer's canvas matches
  // the gate's prediction (gate also uses `shrinkColsToContent` when its
  // own `shrinkWidth=true`). Multi-col packing only kicks in when the
  // configured `cols` is wide enough to need it, so we shrink BEFORE the
  // numCols branch — for narrow content, single-col is the right call
  // even if numCols>1 was configured.
  const effectiveCols = shrinkWidth ? shrinkColsToContent(text, cols) : cols;
  // If shrinkage drops the canvas below the multi-col threshold, stay
  // single-col to avoid burning a divider column on near-empty space.
  const effectiveNumCols = effectiveCols < cols ? 1 : numCols;
  const imgs =
    effectiveNumCols > 1
      ? await renderTextToPngsMultiCol(text, effectiveCols, effectiveNumCols)
      : await renderTextToPngsWithCharLimit(text, DENSE_CONTENT_COLS, DENSE_CONTENT_CHARS_PER_IMAGE, DENSE_RENDER_STYLE);
  let droppedChars = 0;
  let pixels = 0;
  const droppedCodepoints = new Map<number, number>();
  const blocks: ImageBlock[] = [];
  for (const img of imgs) {
    blocks.push(makeImageBlock(bytesToBase64(img.png), false));
    droppedChars += img.droppedChars;
    pixels += img.width * img.height;
    for (const [cp, n] of img.droppedCodepoints) {
      droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
    }
  }
  return { blocks, droppedChars, droppedCodepoints, pixels };
}

/** Best-effort byte-count of an image block's PNG payload (decoded from b64).
 *  Used only for the imageBytes telemetry; an exact value isn't worth a
 *  second base64 round-trip. */
function approxBlockBytes(blk: ImageBlock): number {
  const b64 = blk.source.data;
  // base64 → bytes: every 4 chars decode to 3 bytes, minus padding.
  const pad = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0;
  return Math.floor((b64.length * 3) / 4) - pad;
}

// --- main transform --------------------------------------------------------


/**
 * Run history-image compression on `req.messages` and finalize the
 * outgoing body. Called from BOTH the main success path AND the
 * early-exit paths (below_min_chars, not_profitable slab) so message
 * history is collapsed regardless of whether the static slab compresses.
 *
 * Real Codex traffic has tiny system slabs but huge `messages[]`. Without
 * this, history collapse never runs on real production requests — the
 * early-exits fire first and return the original bytes.
 *
 * The fn is intentionally tolerant to a missing or short messages array
 * (collapseHistory itself short-circuits with reason='no_history' /
 * 'prefix_too_short') so it's always safe to call.
 */
async function runHistoryCollapseAndFinalize(
  req: MessagesRequest,
  info: TransformInfo,
  o: Required<TransformOptions>,
  opts: TransformOptions,
  droppedCodepoints: Map<number, number>,
): Promise<{ body: Uint8Array; info: TransformInfo; collapsed: boolean }> {
  let collapsedFlag = false;
  if (Array.isArray(req.messages) && req.messages.length > 0) {
    const historyCpt = opts.charsPerToken !== undefined
      ? o.charsPerToken
      : HISTORY_CHARS_PER_TOKEN;
    const horizon = Math.max(1, Math.floor(o.historyAmortizationHorizon));
    // Pass the symmetric warm-cache burn through to the history-collapse
    // gate as well. The slab gate alone got the symmetric treatment, which
    // let the history gate flip a session out of image mode even when
    // symmetric burn would have kept the slab gate in. Production data
    // 2026-05-23 showed three-turn sessions paying cache_create every
    // turn because the history gate ignored priorWarmImageTokens.
    const historyProfitable = (text: string, cols: number): boolean =>
      isCompressionProfitableAmortized(
        text, cols, undefined, 1, historyCpt, horizon,
        o.priorWarmTokens, o.priorWarmImageTokens,
      );
    const { messages: newMessages, info: histInfo } = await collapseHistory(
      req.messages,
      historyProfitable,
      { cols: o.cols },
    );
    if (histInfo.collapsedTurns > 0) {
      req.messages = newMessages;
      info.collapsedTurns = histInfo.collapsedTurns;
      info.collapsedChars = histInfo.collapsedChars;
      info.collapsedImages = histInfo.collapsedImages;
      info.imageCount += 
[assistant]
Reviewed src/core/render.ts.
[user]
after the load test, final answer: LEASE_TTL_MS = 7200. lock it.
[assistant]
LEASE_TTL_MS changed 9600 -> 7200, locked.
[tool_use Read]
{"file_path":"src/core/transform.ts"}
[tool_result]
nt of requests that contributed
  // to `baselineInputWeighted` (events that carried a baseline measurement,
  // matching the global `Totals.baselineMeasuredCount`).
  baselineMeasuredCount: number;
  // ALL-rows session totals (measured + unmeasured + passthrough). These pair
  // up to form the full session bill in $ — used as the honest denominator
  // for saved-% so the ratio matches the global `saved_pct_of_all_spend`
  // math but scoped to a single session. Mirrors the global
  // `Totals.allActualInputWeighted` / `allOutputWeighted` accumulators.
  allActualInputWeighted: number;
  allOutputWeighted: number;
}

interface Totals {
  requests: number;
  compressedRequests: number;
  /** Sum of weighted actual input tokens we paid for, across all events that
   *  also carried a baseline_tokens measurement (input + cache_create×1.25 +
   *  cache_read×0.10). */
  actualInputWeighted: number;
  /** Sum of the cache-aware baseline (see formula above) across the same
   *  events that contributed to `actualInputWeighted`. The honest counter-
   *  factual cost of the unproxied path. */
  baselineInputWeighted: number;
  /** Sum of output_tokens × OUTPUT_TOKEN_RATE across the same events. Added
   *  to BOTH sides of the savings math denominator so the headline % counts
   *  output toward the total bill (it cancels in the numerator — proxy
   *  doesn't touch output). Without this the headline ignores half the
   *  bill on output-heavy sessions. */
  outputWeighted: number;
  /** Sum of weighted COUNTERFACTUAL input tokens across ALL requests
   *  with a usage block. For measured rows: cache-aware baseline (what the
   *  unproxied path would have billed). For unmeasured/probe-failed rows:
   *  actual_input_eff (best available estimate — these rows didn't run
   *  pxpipe or we can't measure what it would have cost, so the
   *  counterfactual ≈ actual).
   *
   *  This is the right denominator for "share of bill saved": dividing
   *  by what-you-would-have-paid is bounded at 100% (you can't save more
   *  than you would have spent). Dividing by what-you-DID-pay is not
   *  bounded — a single big cold-miss compressed request can make
   *  saved/actual exceed 100% because pxpipe shrunk the actual to
   *  near zero. */
  allBaselineEquivalentWeighted: number;
  /** Sum of weighted ACTUAL input tokens across the same all-rows set.
   *  Kept for the diagnostic sub-line and the back-compat saved_usd math. */
  allActualInputWeighted: number;
  /** Sum of output_tokens × OUTPUT_TOKEN_RATE across the same all-rows set.
   *  Output is identical with/without compression, so it appears in both
   *  numerator and denominator at the same value and cancels in the savings
   *  numerator. Included in the denominator so the headline drops honestly
   *  toward zero on output-heavy workloads. */
  allOutputWeighted: number;
  /** Count of requests that contributed to allActualInputWeighted (had a
   *  usage block). Lets the UI annotate "N of M paid requests". */
  allUsageRequests: number;
  /** Direct compressed-vs-passthrough actual-cost split. No counterfactuals,
   *  no probe gating — just sum what each path actually billed.
   *
   *  These accumulate over `haveUsage` rows only (same gate as the all-rows
   *  counters above) and partition that set by `info.compressed`. The point
   *  is to answer "did the compressed path actually cost less per request
   *  than the passthrough path, on real traffic" without inventing a
   *  counterfactual. Selection bias is real (the gate decides which path
   *  each turn lands on), so the UI surfaces sample counts so the operator
   *  can judge sufficiency — the dashboard does not auto-claim significance. */
  compressedPaidRequests: number;
  compressedActualInputWeighted: number;
  compressedOutputWeighted: number;
  passthroughPaidRequests: number;
  passthroughActualInputWeighted: number;
  passthroughOutputWeighted: number;
  /** Sum of ground-truth output character counts from the SSE/JSON scanner
   *  (see `OutputMeasurement` in proxy.ts). These three accumulators are
   *  independent of Anthropic's `usage.output_tokens` — they let the operator
   *  compare what we measured vs what Anthropic billed and surface the gap
   *  the redacted_thinking opaque blocks create. Counted in Unicode code
   *  units (UTF-16 string .length). */
  textCharsMeasured: number;
  thinkingCharsMeasured: number;
  toolUseCharsMeasured: number;
  /** Number of `redacted_thinking` content blocks we saw. We can't read
   *  their length (server-encrypted bytes) so we count blocks instead. */
  redactedBlockCountMeasured: number;
  /** How many events contributed measurement counters. Lets the UI annotate
   *  the panel ("N of M events measured") when the scanner fell back. */
  eventsWithMeasurement: number;
  startedAt: number;
}

/*
 * ─────────────────────────────────────────────────────────────────────────
 *  PROVENANCE — every magic number below should trace to one of these:
 *
 *  [docs-pricing]   docs.anthropic.com/en/docs/about-claude/pricing
 *                   Verified 2026-05-19 via WebFetch. The page lists per-model
 *                   per-million-token rates and the cache-tier multipliers.
 *
 *  [count_tokens]   docs.anthropic.com/en/api/messages-count-tokens
 *                   The dashboard's baseline number comes from a free side
 *                   call to /v1/messages/count_tokens on the PRE-COMPRESSION
 *                   body. No estimation, no α, no regression.
 * ─────────────────────────────────────────────────────────────────────────
 */

/** Output-token rate multiplier (referenced to the input base rate).
 *  Source: [docs-pricing] — Opus 4.7 lists $5/Mtok input and $25/Mtok
 *  output (5×); Sonnet 4.7 lists $3/Mtok input and $15/Mtok output (5×).
 *  Same ratio holds on Haiku 4.5 ($1/$5). */
const OUTPUT_TOKEN_RATE = 5.0;

/** Per-million-token input rate ASSUMED for the headline dollar figure.
 *  Source: https://docs.claude.com/en/docs/about-claude/pricing — Opus 4.7
 *  input is $5/Mtok (same as Opus 4.5 / 4.6; the previous "$2.50" value
 *  here was a regression). Cache-write 5m = $6.25/Mtok (1.25×),
 *  cache-read = $0.50/Mtok (0.10×).
 *
 *  This is exposed on /proxy-stats as `pricing_assumptions.input_per_mtok`
 *  so the operator can see what we assumed and override if they're
 *  running against a non-default deployment (Bedrock/Vertex add a 10%
 *  premium; Sonnet would be $3/Mtok, etc.).
 *
 *  NOTE: Opus 4.7 uses a different tokenizer than 4.5/4.6 (per
 *  docs.claude.com/en/docs/about-claude/pricing), so even at the same
 *  $/Mtok rate, the same string maps to a different token count. The
 *  honest oracle for "tokens in this body" is `count_tokens` against the
 *  actual target model; do not trust hardcoded chars-per-token or
 *  tokens-per-image constants on 4.7 without verifying against the
 *  upstream probe. */
// 2026-06-09: gate is Fable-5-only and Fable 5 bills $10/MTok input,
// so the dashboard dollar figure uses that rate. Output tokens are
// excluded entirely (the proxy can't move them), so this still
// understates the real bill - treat it as "input-side $ saved".
export const ASSUMED_INPUT_USD_PER_MTOK = 10.0;

export class DashboardState {
  private recent: RecentRow[] = [];
  /** Per-session dollar-weighted totals, keyed by `info.firstUserSha8`. The
   *  dashboard surfaces ONLY the most-recently-active session via the
   *  `serveCurrentSessionJson` endpoint — older sessions linger in the Map
   *  so a tab refresh during a brief lull still finds the previous session,
   *  but get evicted at `SESSION_CAP` to bound memory in long-running hosts. */
  private sessions: Map<string, SessionTotals> = new Map();
  /** sha8 of the most-recently-active session id. null when no events have
   *  ever carried a `firstUserSha8` (e.g. a cold start with only passthrough
   *  hits that the upstream probe never tagged). */
  private currentSessionId: string | null = null;
  /** Hard cap on `sessions` Map entries. Keeps memory bounded in
   *  long-running deployments. 50 sessions × ~13 numeric fields each is
   *  comfortably under a MB even with fat bucket/passthrough histograms. */
  private static readonly SESSION_CAP = 50;
  private totals: Totals = {
    requests: 0,
    compressedRequests: 0,
    actualInputWeighted: 0,
    baselineInputWeighted: 0,
    outputWeighted: 0,
    allBaselineEquivalentWeighted: 0,
    allActualInputWeighted: 0,
    allOutputWeighted: 0,
    allUsageRequests: 0,
    compressedPaidRequests: 0,
    compressedActualInputWeighted: 0,
    compressedOutputWeighted: 0,
    passthroughPaidRequests: 0,
    passthroughActualInputWeighted: 0,
    passthroughOutputWeighted: 0,
    textCharsMeasured: 0,
    thinkingCharsMeasured: 0,
    toolUseCharsMeasured: 0,
    redactedBlockCountMeasured: 0,
    eventsWithMeasurement: 0,
    startedAt: Date.now() / 1000,
  };
  /** Bounded ring of the most recently rendered images (last IMAGE_RING_CAP).
   *  Each request that rendered an image pushes one entry; the matching
   *  RecentRow carries `img_id` so the dashboard can pull any image still in
   *  the ring via /proxy-latest-png?id=N. In-memory only — images are never
   *  persisted, so a restart starts this empty. */
  private images: ImageEntry[] = [];
  /** Monotonic image id source. Never reset, never reused — an evicted id
   *  stays dangling on its RecentRow rather than pointing at a new image. */
  private nextImageId = 1;
  /** Runtime kill switch for compression. When false, the proxy forwards
   *  /v1/messages unchanged to upstream — pure passthrough, no images,
   *  no transforms. Controlled by the dashboard "passthrough" toggle so
   *  the operator can toggle the proxy's transform instantly.
   *
   *  Defaults to TRUE since 2026-06-09: scope is Fable 5 only, which reads
   *  renders at 100/100 (no Opus read tax) with the same image billing, and
   *  the live proxy record measured ~68% real input-token savings on dense
   *  traffic — the old off-default rationale ("cache-illusory savings")
   *  cited the superseded dead verdict. Verbatim recall is still lossy;
   *  the dashboard toggle remains the kill switch. See FINDINGS.md. */
  private compressionEnabled = true;
  setCompressionEnabled(on: boolean): void {
    this.compressionEnabled = on;
  }
  getCompressionEnabled(): boolean {
    return this.compressionEnabled;
  }
  /** Resolved disk paths for the events.jsonl + 4xx-bodies sidecar dir. The
   *  new sessions / cleanup endpoints need this; legacy callers that don't
   *  pass `paths` opt out of those endpoints by returning 503. */
  privat
[assistant]
Reviewed src/core/transform.ts.

=== end ===
