Commit Graph

16 Commits

Author SHA1 Message Date
teamchong 8fbd53ea3e dashboard: fix bogus savings math + emit compressed_chars
The dashboard headline was showing 0% saved because the math was
apples-to-oranges:
  - orig_chars = static slab + tool docs ONLY (~135 KB)
  - image_count = ALL images including reminders + tool_results (~33)
  - txtReplaced = 135k/4 = 33.7k tokens
  - imgTokensEst = 33 × 2500 = 82.5k tokens
  - extraText = max(0, 33.7k - 82.5k) = 0  ← clamped negative
  - baseline == paid → 'saved 0%' headline despite real savings happening

Two fixes:

1. Track 'compressedChars' separately from 'origChars'. It accumulates
   only when a block actually image-renders (passes the break-even gate
   AND any per-block min thresholds). Includes the static slab + all
   compressed reminders + all compressed tool_results.

2. Remove the Math.max(0,...) clamp in baselineCost(). Negative savings
   surface honestly — they're the signal that small-block compressions
   are cost-bleeding, which the break-even gate is supposed to prevent.

Wires:
  - TransformInfo.compressedChars (new field, mandatory).
  - All 4 image-render success paths accumulate (static slab + reminder
    + tool_result string branch + tool_result array branch).
  - TrackEvent.compressed_chars (new optional field, emitted when > 0).
  - dashboard.baselineCost() takes compressedChars now; clamp removed.
  - sessions.aggregateSessions() also uses compressed_chars + honest math.

229/229 tests passing. Bundle stays at 496 KB gzipped.

Post-restart, the dashboard 'tokens saved' will show real numbers
including negatives. Empirically expect ~1-5% net positive in
steady-state cache-hit traffic.
2026-05-18 23:01:12 -04:00
teamchong 97fdee8421 history: Variant C closed-tool-sequence collapse + honest per-session savings
Two distinct improvements in one slice:

1. History-image compression (Variant C — opt-in, default OFF).
   New src/core/history.ts module:
   - findClosedPrefixBoundary(): walks tool_use_id openSet, returns largest
     fully-closed prefix index. Mid-flight tool_use blocks bail the collapse.
   - blocksToText(): linearises content blocks for the history image. Drops
     thinking blocks (per Opus 4.7+ docs: only most-recent must be bit-perfect),
     renders tool_use args + tool_result inner text, emits [image] placeholder
     for nested images.
   - collapseHistory(): orchestrates. Applies isCompressionProfitable() gate.
     Returns a synthesized {role:'user', content:[text,image,text]} prepended
     message + the elided turns dropped from messages[].
   New TransformOptions: compressHistory (default false), historyKeepTail (4),
   historyMinPrefix (10). Wired through src/node.ts + src/worker.ts as
   COMPRESS_HISTORY / HISTORY_KEEP_TAIL / HISTORY_MIN_PREFIX env vars.
   Cache_control: history-image carries NO cache_control on this first cut.
   Static slab keeps its 1 breakpoint. Conservative — telemetry will reveal
   whether the cache-miss rate on history images warrants the round-3
   rebalance in a follow-up slice.
   29 new tests in tests/history.test.ts: closed-prefix detection (parallel
   tool calls, mid-flight, openSet straddling), block linearisation, full
   collapse flow, isCompressionProfitable gate firing on small histories.

2. Per-session savings: honest formula (fixes task #63).
   Previous src/sessions.ts computed saved = orig_chars - image_bytes — a
   nonsense comparison of chars to PNG bytes, then clamped via Math.max(0,...).
   Result: charsSaved/tokensSavedEst was 0 for every session including ones
   with hundreds of MB of compressed traffic.
   New formula mirrors src/dashboard.ts:
     textTokens  = orig_chars / 4
     imageTokens = image_count × 2500
     tokensSaved = textTokens − imageTokens  (CAN be negative)
   Negatives are NOT clamped — they surface the cost-bleed on small-block
   compressions that the per-block break-even check is supposed to prevent.
   2 new tests: positive-net case, all-negative case (must report negative).

229/229 tests passing. Bundle ~500 KB gzipped.
2026-05-18 22:46:13 -04:00
teamchong 04b1526bd5 transform: large-tool_result paging + adaptive break-even derivation
Two related improvements:

1. Per-tool_result paging / truncation. When a single tool_result would
   render to more than maxImagesPerToolResult images (default 10), the
   source text is truncated head+marker+tail BEFORE rendering. Classifier
   picks the truncation strategy:
     - structured (JSON/YAML/diff): head-only, tail is repeating elements
     - log (timestamps / [LEVEL] prefixes): 60% head + 40% tail
     - other (prose, dumps): same head/tail split
   The marker tells the model what was elided so it can reason about the
   gap: '[ pixelpipe paging: omitted N lines (K chars) ... ]'. Keeps
   requests under Anthropic's 100-image-per-request cap even when a single
   tool dumps a multi-megabyte log.

2. Adaptive CHARS_PER_IMAGE. Previously hardcoded at 14_100 (Unifont 10px
   @ cell 5×11). Now derived at module load from ATLAS_CELL_H + render.ts
   constants. When gen-atlas regenerates with a different font/size, the
   break-even threshold automatically updates: at Cozette 4×7 the chars/
   image jumps from 14.1k → 22.2k and the break-even drops from ~10k → ~3k.
   Single source of truth in src/core/.

Wires:
- TransformOptions.maxImagesPerToolResult (default 10)
- TransformInfo.truncatedToolResults + omittedChars
- TrackEvent.truncated_tool_results + omitted_chars (emit when > 0)
- Two new exported helpers: estimateImageCount, classifyContent, truncateForBudget
- Paging fires before image-encoding at both tool_result branches
  (string content and array-of-text-blocks content)

Tests: +28 paging-specific (paging.test.ts), 199/199 total green.
Bundle delta: negligible (~3 KB raw, ~1 KB gzipped). 488 KB dist gzipped.

Closes the paging-implementer worktree merge that was deferred earlier.
2026-05-18 22:38:15 -04:00
teamchong e7489ca1fb transform: per-block compression break-even check + honest dashboard
URGENT production-cost fix. Empirical data (orig_chars=169779, image_count=88)
showed we were paying ~220k image tokens to replace ~42k text tokens — a
net LOSS of ~178k tokens per request. Dashboard hid this via Math.max(0,...).

Two changes ship together:

1. isCompressionProfitable(textLen) gate in transform.ts. Before any
   per-block image encode (static slab, reminder, tool_result), compute:
     estImages × TOKENS_PER_IMAGE  vs  textLen / CHARS_PER_TOKEN
   Compress only when the image side wins. Constants:
     CHARS_PER_IMAGE  = 14100  (Unifont 10px, cell 5×11, 100 cols)
     CHARS_PER_TOKEN  = 4
     TOKENS_PER_IMAGE = 2500   (empirical, history-researcher round 3)
   Result: blocks under ~10k chars stay as text. Static slab still
   compresses (always above break-even).

2. Coarse thresholds bumped as fast-path:
     minReminderChars   1000 → 5000
     minToolResultChars 5000 → 10000
   (Cover the obvious-no cases without invoking the precise check.)

3. Dashboard honesty: removed Math.max(0,...) clamp in baselineCost().
   Negative savings now display as negative (no more hidden cost-bleed).

4. Telemetry: passthrough_reasons.below_threshold + .not_profitable
   counters surface how often each gate fires.

After restart, dashboard 'saved' should jump from clamped-0 to a real
positive number (~1-3% on aggregate; static slab compression is the
only block consistently profitable at our current cell).
2026-05-18 22:13:10 -04:00
teamchong f199640bdc ship Unifont @ 10px multilingual rendering + 400 diagnosis fixes
- Switch font: JetBrains Mono @ 15px → Unifont 16.0.04 @ 10px. Sparse
  atlas with double-width EAW dispatch for CJK. Full BMP profile
  (35,501 glyphs) covers Latin/Cyrillic/Greek/Hebrew/Arabic/CJK/Hangul
  plus Letterlike, Misc Technical, Block Elements, Geometric Shapes,
  Misc Symbols, Dingbats, Enclosed Alphanumerics, all box-drawing.
- Fix cell-height bug (was hardcoded 9; now probed from font metrics
  via Math.ceil(maxAscent+maxDescent)). Previously clipped the top
  2 rows off every CJK glyph since the original Unifont ship.
- Expand tabs to spaces with 4-column tab stops, EAW-aware. Was the
  silent-drop source for 99.6% of dropped chars in production.
- Add dropped_codepoints_top telemetry (top 20 per event, only emitted
  when drops > 0) so future coverage gaps surface in events.jsonl.
- Raise minToolResultChars 2000→5000 and minReminderChars 1000→2000
  to stop net-losing tokens on small compressions, per ~2,500 tok/img
  empirical measurement (N=33 cold-miss events).
- Fix dashboard per-image-cost formula: was pngBytes/375 (~190 tok),
  now imageCount * 2500 (the measured empirical rate). Reduces the
  inflated "tokens saved" headline to honest numbers.
- Schema-stub fix in compressSchemas path: preserveSchemaShell()
  retains type / properties / required / enum / oneOf / anyOf / $ref /
  numeric constraints recursively (depth 20), strips only descriptions
  and metadata. Prevents 400 errors on first-tool-call requests.
- Capture full gzipped 4xx body + per-event req_body_sha8 for upstream
  error diagnosis. Sidecar to ~/.pixelpipe/4xx-bodies/ when over 32 KB
  inline cap. Workers-safe via CompressionStream + Web Crypto.
- scripts/restart.sh: pnpm-aware one-command kill+rebuild+start with
  graceful SIGTERM→SIGKILL escalation, build-failure abort, port-in-use
  precheck, --no-build flag for fast iteration. Shell-tested.
- Bundle 1.58 MB gzipped on Node. 91/91 vitest + 4/4 shell tests.
2026-05-18 21:28:25 -04:00
teamchong be372a0c3a legacy: drop python reference oracle (live savings confirmed at 98.7%)
HANDOFF item #4 was "Delete legacy/python/ — but only after #1 confirms
live savings." Item #1 (live cache-rate validation) now passes: over 232
compressed POST /v1/messages calls in a 46-minute real-traffic window,
cache hit rate by tokens is 98.7%, clearing the HANDOFF 60% bar by 38
points. The Python reference has done its job.

Removes (1831 deletions):
  legacy/python/proxy.py        (975)  full Python proxy implementation
  legacy/python/diff_transform.py (213)  body-diff parity oracle
  legacy/python/cli.js          (170)  shim
  legacy/python/visual_compare.py (150) PNG-diff parity oracle
  legacy/python/diff_render.py  (136)  render-only parity oracle
  legacy/python/gen_atlas.py    ( 75)  atlas generator (TS port owns this now
                                       via scripts/gen-atlas.ts)
  legacy/python/install.js      ( 50)  installer shim
  legacy/README.md              ( 35)

Comment / doc updates to remove references:
  README.md            drops the "## Legacy" section
  CLAUDE.md            drops the "## Parity oracle" section + tree entry
  src/core/transform.ts  header comment no longer cites legacy/python
  src/dashboard.ts     header comment notes the formulas were ported,
                       not where from
  docs/TRANSFORM_INFO.md edits land in the separate TRANSFORM_INFO commit

The byte-output determinism the Python oracle existed to guard against
is now pinned by tests/render.test.ts ("renders identical input to byte-
identical output (determinism = cacheability)") and the systemSha8
fingerprint in every JSONL row. If a future change ever breaks
determinism, the test will catch it before merge, and the
production-deployed cache hit rate will catch it within minutes after.

Gates: typecheck clean, 38 tests pass, build clean dist/node.js.
2026-05-18 17:56:56 -04:00
teamchong e219b37cd5 transform: KNOWN_STATIC_TAGS allowlist (silence <types> canary)
The canary in splitStaticDynamic flags tag-shaped blocks in the static
slab that aren't in DYNAMIC_BLOCK_TAGS — designed to catch a new
Claude Code release that ships a per-turn tag we'd accidentally bake
into the cached image. In production it has been firing on `<types>`
for 93% of real requests (125 of 134 in the user's events log), which
is noise: `<types>` is part of Claude Code's built-in tool
documentation, not a per-turn rotation.

Add a second list, KNOWN_STATIC_TAGS, that mirrors DYNAMIC_BLOCK_TAGS
in shape. The unknown-tag computation now subtracts BOTH lists, so a
tag is only "unknown" when it's in neither the per-turn set nor the
known-static set. Seed KNOWN_STATIC_TAGS with ['types'].

Updates the warn log in src/node.ts to mention both options so a
reviewer of a NEW unknown tag knows which list to extend.

Adds a regression test pinning that <types> in the static slab no
longer produces info.unknownStaticTags.
2026-05-18 17:41:51 -04:00
teamchong a5f61ba6ee transform: compress large tool_result text content across user messages
Tool results (Bash output, Read file contents, etc.) accumulate as the
session grows. Once produced they're static — same bytes turn after turn
— but the model gets billed for them on every call until they fall out
of cache. Image-rendering them at the source compounds the savings
across the whole conversation.

Walks ALL user messages (not just the first — that's the reminder-
compression scope) and rewrites tool_result blocks whose content is at
or above minToolResultChars (default 2000, matching legacy/python/proxy.py):

  - content: string  → replace with image block(s) (whole content goes)
  - content: array   → walk inner blocks, replace each TextBlock ≥ threshold
                       with image blocks; leave ImageBlocks and short text
                       alone
  - is_error: true   → leave the whole block alone. Anthropic rejects
                       images inside is_error tool_results
                       (`tool_result with is_error=true cannot contain images`).

Like reminder compression these images carry NO cache_control — the
system+tools image is our only breakpoint. The savings are 65-73% of
input tokens per compressed block on Opus 4.7.

Wire-up:
  - src/core/transform.ts: TransformOptions.compressToolResults (default
    true) + minToolResultChars (default 2000). TransformInfo.toolResultImgs
    counter. Reuses textToImageBlocks / approxBlockBytes from commit 3.
  - src/core/tracker.ts: TrackEvent.tool_result_imgs flows from the
    transform info.
  - src/worker.ts: COMPRESS_TOOL_RESULTS + MIN_TOOL_RESULT_CHARS env vars.
  - src/node.ts: CliOpts fields, parseCli cases (--no-tool-results,
    --min-tool-result-chars), help text, env-var summary, `tr+N` in the
    per-request console line when tool_result_imgs > 0, and tool_results=
    in the startup config summary.

Tests:
  - "compresses large tool_result text content across user messages"
    confirms info.toolResultImgs ≥ 1, the tool_result block's content
    becomes an array of image blocks, and those images carry no
    cache_control.
  - "leaves is_error tool_results untouched (Anthropic forbids images
    there)" confirms info.toolResultImgs is 0 and the content stays a
    plain string when is_error: true.
2026-05-18 17:35:57 -04:00
teamchong 40037568e2 transform: compress <system-reminder> blocks in the first user message
Claude Code re-injects the same long <system-reminder> blocks (CLAUDE.md
hints, task-tools reminders, the dynamic skills list, etc.) into the
FIRST user message on every turn. They're text, so they're not part of
the system+tools image we already cache — they hit the wire fresh every
turn. This rewrite renders any reminder block ≥ minReminderChars (1000
by default, matching legacy/python/proxy.py) to PNG images placed inside
the user message content array, just like the static slab.

Why: the system+tools image is the only cache_control breakpoint we're
allowed (Anthropic caps at 4 and Claude Code uses the others). Per-block
reminder images therefore carry NO cache_control — they're per-turn
content that the model OCRs without prefix-cache benefit. The win is
token cost, not cache: reminders are 65-73% cheaper as PNG than as text,
and a typical turn has 1-3 of them.

Layout inside the first user message after compression:
  [intro text]                       ← static (helps OCR framing)
  [image block(s)]                   ← static; LAST has cache_control
                                        ↑ cache breakpoint (the only one)
  [End of rendered context.]         ← static text closer for the image
  [processed existing content]       ← per-turn (incl. reminder images)

Wire-up:
  - src/core/transform.ts: new TransformOptions.compressReminders (default
    true) + minReminderChars (default 1000). Two new helpers,
    textToImageBlocks() (wraps renderTextToPngs + makeImageBlock with
    no cache_control) and approxBlockBytes() (b64 → byte-count for the
    imageBytes telemetry, no second base64 round-trip). The placement
    ='user' branch now walks `existing` and rewrites long reminders.
  - src/core/tracker.ts: TrackEvent.reminder_imgs flows from
    TransformInfo.reminderImgs through toTrackEvent so `pixelpipe stats`
    and the dashboard can attribute compression contributions.
  - src/worker.ts: COMPRESS_REMINDERS + MIN_REMINDER_CHARS env vars.
  - src/node.ts: matching CliOpts, parseCli cases (--no-reminders,
    --min-reminder-chars), help text, env-var summary, and `rem+N` in
    the per-request console line when reminder_imgs > 0.

Tests:
  - "compresses long <system-reminder> blocks in the first user message"
    confirms info.reminderImgs ≥ 1, the reminder text is gone from
    user-content text blocks, the user's actual prompt survives, and the
    new reminder image blocks carry NO cache_control.
  - "leaves short <system-reminder> blocks alone (below minReminderChars)"
    confirms info.reminderImgs is 0 and the reminder text passes through
    as text.
2026-05-18 17:29:56 -04:00
teamchong ba13bf3a42 dashboard: live HTML + JSON endpoints in the Node host
Ports the live dashboard from legacy/python/proxy.py into TypeScript.
Node-only by design — the Workers host doesn't expose a dashboard (use
Workers Logs / Logpush for that).

New module src/dashboard.ts:
  - DashboardState keeps a 50-entry ring buffer of recent requests, plus
    running totals for the savings-vs-baseline computation (same formula
    as the Python: input + cache_create*1.25 + cache_read*0.10). One
    latest PNG is held in memory for the preview pane.
  - Replay-on-startup: reads the tail of ~/.pixelpipe/events.jsonl so the
    UI doesn't show an empty table after a process restart. Cumulative
    totals are NOT replayed (file may have rotated; double-counting beats
    starting from zero badly here? — no, starting fresh is the lesser
    evil after rotation, hence "best-effort replay of the ring buffer
    only").
  - Four HTTP handlers: serveHtml (the inline dashboard page),
    serveStats (JSON aggregate), serveRecent (JSON ring buffer),
    servePng (the latest preview image). dashboardPath() classifies a
    path so the Node server can intercept before the proxy handler.
  - Inline HTML uses CSS object-position to crop the preview at the
    image's native resolution — no server-side PNG decode needed.

Plumbing changes:
  - src/core/transform.ts: capture info.firstImagePng / firstImageWidth /
    firstImageHeight after rendering. NOT persisted to JSONL; toTrackEvent
    drops them. Memory cost is bounded (only the latest image survives,
    the dashboard replaces on every update).
  - src/node.ts: import DashboardState, instantiate, seed via replay,
    call dashboard.update(e) inside onRequest BEFORE tracker.emit (the
    tracker strips firstImagePng). The HTTP handler intercepts the four
    dashboard paths and answers from DashboardState; everything else
    falls through to the proxy.
  - src/node.ts / src/worker.ts: bump the CLI/env default for PLACEMENT
    from 'system' to 'user' so the runtime path matches transform's
    default (e0950ef changed only the library default). Without this, the
    CLI's ?? 'system' fallback would silently override the new default
    and re-introduce the 400 we just fixed.

Open the dashboard at http://127.0.0.1:<port>/ once the proxy is running.
2026-05-18 17:20:03 -04:00
teamchong e0950efa6f transform: default to placement='user' and stamp ttl='1h' on image cache_control
Two protocol-correctness fixes that have to ship together.

1. **Default placement: 'system' → 'user'.** Anthropic's `system` field is
   typed as `string | TextBlock[]` — image blocks there come back as
   `400 system.N.type: Input should be 'text'`. Images must go into a
   user-message content array.

2. **ttl='1h' on the image's cache_control.** Anthropic enforces the rule
   "ttl='1h' must not come after ttl='5m'" in processing order
   (tools → system → messages). Claude Code marks its own user-message
   content with ttl='1h'; if we leave ttl unset it defaults to '5m', our
   block lands first in processing order, and the request 400s the moment
   Claude Code's own 1h breakpoint shows up later in the conversation.

The existing image-placement tests now look in messages[0].content instead
of out.system, and a new test pins the ttl='1h' invariant directly.
2026-05-18 17:11:56 -04:00
teamchong 102a0966f5 transform: add determinism test + unknown-tag canary
Two cheap guardrails based on architectural review:

1. Determinism test: renders the same input twice and asserts byte
   equality of the produced PNGs. Without this guarantee, identical
   system prompts on consecutive turns would yield different image
   bytes, silently destroying the cache hit rate that justifies the
   whole proxy. The test currently passes — locking it in so a future
   refactor can't regress it without noticing.

2. Unknown-tag canary: splitStaticDynamic now sniffs the *static* slab
   for any <tag>...</tag>-shaped block whose name isn't in
   DYNAMIC_BLOCK_TAGS. Surfaced via info.unknownStaticTags and emitted
   on the tracker event as unknown_static_tags. If Claude Code ships a
   new per-turn tag in a future release, it'll show up in logs within
   hours instead of being silently baked into the cached image (where
   it would tank cache hit rate with no error).

Also adds tracker.ts (Tracker interface, TrackEvent shape,
JsonLogTracker, noopTracker, toTrackEvent normalizer). Hosts will wire
these in the next commit.
2026-05-18 16:05:48 -04:00
teamchong 99559c463b transform: add sha8 fingerprints (system/claude_md/first_user)
Three short hashes carried in TransformInfo:
- systemSha8: sha256[0..8] of the actual image-bound text. Stable
  across turns proves cache_control is doing its job. If it drifts
  unexpectedly, that's a cache leak to investigate.
- claudeMdSha8: hash of just the CLAUDE.md slab (heuristic header
  detection). Lets us bucket requests by project even when cwd is
  absent or anonymized.
- firstUserSha8: hash of first user message (4 KiB cap). Rough
  thread id since the wire protocol has none.

All async via Web Crypto so Node + Workers share one impl. Hashing is
in parallel via Promise.all; ~0.1ms overhead.

+2 tests (systemSha8 invariant under dynamic-block change,
firstUserSha8 format).
2026-05-18 15:59:23 -04:00
teamchong 364373618f transform: extract env fields (cwd, branch, platform, today) into info.env
New EnvFields type and extractEnvFields() helper. Parses the <env>
block Claude Code injects, plus 'On branch' / 'Branch:' lines that
appear in <git_status> or context blocks. Read-only — telemetry signal
only, never mutates the request.

Per-project compression ratios, repeat-CWD detection, today's-date
drift checks all become possible from logs now.

+2 tests (full env block extraction, no-env-no-info).
2026-05-18 15:57:34 -04:00
teamchong 84b4a3a59a transform: split static/dynamic system text for cache stability
Claude Code injects per-turn blocks (<env>, <context>, <git_status>,
<directoryStructure>, <system-reminder>) into the system prompt.
Previously those got rendered into the image alongside the static
slab (CLAUDE.md, agent defs, tool docs), which kills Anthropic's
prompt cache: image bytes drift turn-to-turn → 0% cache hits.

Now we split:
  - static (cacheable)   → image with cache_control: ephemeral
  - dynamic (per-turn)   → plain text block AFTER the image
  - billing line         → also AFTER the image (was BEFORE, also a
                           cache-killer — bug fix as a side effect)

Net effect: stable cache key on the big slab, model still sees cwd /
branch / today's date. Removes the need for users to pass
--exclude-dynamic-system-prompt-sections to claude.

TransformInfo now carries staticChars / dynamicChars / dynamicBlockCount
for downstream telemetry.

Tests: 8 existing pass unchanged + 3 new (env survives as text after
image, cache_control on image only, all-dynamic input is pass-through).
2026-05-18 15:55:21 -04:00
teamchong f91c149d05 Port to TypeScript with dual Node + Cloudflare Workers targets
Runtime is pure Web Standard APIs (fetch, Request, Response, Uint8Array,
CompressionStream, crypto.subtle, btoa) — zero runtime dependencies.

Core (src/core/, runs identically on Node 18+ and Workers):
  - atlas.ts     auto-generated 9x15 JBM glyph atlas, base64-inlined (17KB)
  - png.ts       minimal grayscale PNG encoder via CompressionStream
  - render.ts    text → packed PNG, soft-wraps at 100 cols, ≤1568px tall
  - transform.ts request body rewriter (system + tool docs → image blocks)
  - proxy.ts     fetch-handler that transforms then forwards to Anthropic
  - types.ts     Anthropic Messages API types we touch

Adapters:
  - src/node.ts     node:http server + CLI flag parsing
  - src/worker.ts   export default { fetch } for wrangler dev/deploy

Build tooling:
  - scripts/gen-atlas.ts   @napi-rs/canvas → atlas.ts (build-time only)
  - scripts/build.mjs      esbuild Node bundle; wrangler handles Worker
  - tsconfig.json          strict, ES2022, Workers types

Tests (vitest): 8 passing — PNG signature, base64 round-trip, single +
multi-image renders, transform no-op + compress paths, billing-line strip,
tool fold + stub.

E2E smoke: 16K char system → 2 PNGs (36KB) → mock upstream, 34ms.

Next: replace docs, then verify byte-output parity against legacy/python.
2026-05-18 15:33:23 -04:00