From fe827a5978af8e00bcb48e1f6fc9640beb96d16b Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 1 Jul 2026 02:23:44 +0800 Subject: [PATCH 1/9] feat(agent-core): compress oversized images before sending to the model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downsample images to a 2000px longest-edge and per-image byte budget at the single prompt-ingestion chokepoint (the prompt/steer RPC) and on tool results (ReadMediaFile, MCP), so every client transport — CLI, web, desktop, ACP, SDK — is covered uniformly inside the core. PNG screenshots stay lossless and only degrade to JPEG when the byte budget cannot otherwise be met. Best-effort: the original image is sent unchanged if compression fails. --- .changeset/image-compression.md | 6 + packages/agent-core/package.json | 1 + packages/agent-core/src/agent/index.ts | 14 +- packages/agent-core/src/mcp/output.ts | 11 +- .../src/tools/builtin/file/read-media.ts | 15 +- .../src/tools/support/image-compress.ts | 324 +++++++++ .../agent/prompt-image-compression.test.ts | 68 ++ packages/agent-core/test/mcp/output.test.ts | 73 +- .../test/tools/image-compress.test.ts | 324 +++++++++ .../agent-core/test/tools/read-media.test.ts | 36 +- pnpm-lock.yaml | 655 ++++++++++++++++-- 11 files changed, 1419 insertions(+), 108 deletions(-) create mode 100644 .changeset/image-compression.md create mode 100644 packages/agent-core/src/tools/support/image-compress.ts create mode 100644 packages/agent-core/test/agent/prompt-image-compression.test.ts create mode 100644 packages/agent-core/test/tools/image-compress.test.ts diff --git a/.changeset/image-compression.md b/.changeset/image-compression.md new file mode 100644 index 000000000..b1406ec95 --- /dev/null +++ b/.changeset/image-compression.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Automatically compress oversized images before they reach the model. Whatever the source — pasted into the CLI, uploaded from the web/desktop client, sent over ACP, read via `ReadMediaFile`, or returned by an MCP tool — images are downsampled (longest edge ≤ 2000px) and re-encoded to fit a per-image byte budget, cutting vision-token cost and avoiding provider image-size errors. Screenshots stay lossless PNG and only degrade to JPEG when the byte budget cannot otherwise be met. Compression is best-effort: if it fails for any reason the original image is sent unchanged. Handling is centralized in the agent core (at the prompt ingestion point and on tool results), so every client transport is covered uniformly. diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index fc9a6efb3..93e53b731 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -69,6 +69,7 @@ "ajv-formats": "^3.0.1", "chokidar": "^4.0.3", "ignore": "^5.3.2", + "jimp": "^1.6.1", "js-yaml": "^4.1.1", "linkedom": "^0.18.12", "node-pty": "^1.1.0", diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index bead3466f..22c0ed534 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -18,6 +18,7 @@ import type { PreparedSystemPromptContext, ResolvedAgentProfile } from '../profi import type { ModelProvider } from '../session/provider-manager'; import type { SessionSubagentHost } from '../session/subagent-host'; import { noopTelemetryClient, type TelemetryClient } from '../telemetry'; +import { compressImageContentParts } from '../tools/support/image-compress'; import type { PromisableMethods } from '../utils/types'; import { BackgroundManager, BackgroundTaskPersistence } from './background'; import { @@ -285,14 +286,19 @@ export class Agent { get rpcMethods(): PromisableMethods { return { - prompt: (payload) => { - this.turn.prompt(payload.input); + prompt: async (payload) => { + // Single ingestion chokepoint: every client transport (CLI, web, + // desktop, ACP, SDK) submits prompts through this RPC, so compressing + // oversized images here — before the turn records or sends them — + // covers them all with one hook. Best effort: originals pass through on + // failure. + this.turn.prompt(await compressImageContentParts(payload.input)); }, runShellCommand: (payload) => this.tools.runShellCommand(payload.command, payload.commandId), cancelShellCommand: (payload) => this.tools.cancelShellCommand(payload.commandId), - steer: (payload) => { + steer: async (payload) => { this.telemetry.track('input_steer', { parts: payload.input.length }); - this.turn.steer(payload.input); + this.turn.steer(await compressImageContentParts(payload.input)); }, cancel: (payload) => { if (this.turn.hasActiveTurn) { diff --git a/packages/agent-core/src/mcp/output.ts b/packages/agent-core/src/mcp/output.ts index 464218cef..96cd1dcc5 100644 --- a/packages/agent-core/src/mcp/output.ts +++ b/packages/agent-core/src/mcp/output.ts @@ -21,6 +21,7 @@ import type { ContentPart } from '@moonshot-ai/kosong'; +import { compressImageContentParts } from '../tools/support/image-compress'; import type { MCPContentBlock, MCPToolResult } from './types'; // MCP servers can produce arbitrarily large outputs; cap what we feed back to @@ -130,10 +131,10 @@ export function convertMCPContentBlock(block: MCPContentBlock): ContentPart | nu * `mcp__github__create_pr`) — embedded into the `` * wrap when the result is media-only, so the model can attribute binary parts. */ -export function mcpResultToExecutableOutput( +export async function mcpResultToExecutableOutput( result: MCPToolResult, qualifiedToolName: string, -): { output: string | ContentPart[]; isError: boolean; truncated?: true } { +): Promise<{ output: string | ContentPart[]; isError: boolean; truncated?: true }> { const converted: ContentPart[] = []; for (const block of result.content) { const part = convertMCPContentBlock(block); @@ -143,7 +144,11 @@ export function mcpResultToExecutableOutput( } const wrapped = wrapMediaOnly(converted, qualifiedToolName); - const limited = applyOutputLimits(wrapped); + // Shrink oversized images BEFORE the per-part byte cap, so a large but + // compressible screenshot is downsampled and kept rather than dropped to a + // text notice. Best effort: parts that cannot be compressed pass through. + const compressed = await compressImageContentParts(wrapped); + const limited = applyOutputLimits(compressed); const output = collapseSingleText(limited.parts); return limited.truncated ? { output, isError: result.isError, truncated: true } diff --git a/packages/agent-core/src/tools/builtin/file/read-media.ts b/packages/agent-core/src/tools/builtin/file/read-media.ts index f21886974..e4045c7ce 100644 --- a/packages/agent-core/src/tools/builtin/file/read-media.ts +++ b/packages/agent-core/src/tools/builtin/file/read-media.ts @@ -30,6 +30,7 @@ import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; import { renderPrompt } from '../../../utils/render-prompt'; import { resolvePathAccessPath } from '../../policies/path-access'; import { MEDIA_SNIFF_BYTES, detectFileType, sniffImageDimensions } from '../../support/file-type'; +import { compressImageForModel } from '../../support/image-compress'; import { toInputJsonSchema } from '../../support/input-schema'; import { literalRulePattern, matchesPathRuleSubject } from '../../support/rule-match'; import type { WorkspaceConfig } from '../../support/workspace'; @@ -222,12 +223,17 @@ export class ReadMediaFileTool implements BuiltinTool { } const data = await this.kaos.readBytes(safePath); - const base64 = data.toString('base64'); let mediaPart: ContentPart; if (fileType.kind === 'image') { + // Shrink oversized images so a large screenshot neither wastes context + // tokens nor trips the provider's per-image byte ceiling. Best effort: + // on any failure compressImageForModel returns the original bytes, so + // the read still succeeds with the uncompressed image. + const compressed = await compressImageForModel(data, fileType.mimeType); + const base64 = Buffer.from(compressed.data).toString('base64'); mediaPart = { type: 'image_url', - imageUrl: { url: `data:${fileType.mimeType};base64,${base64}` }, + imageUrl: { url: `data:${compressed.mimeType};base64,${base64}` }, }; } else if (this.videoUploader !== undefined) { mediaPart = await this.videoUploader({ @@ -236,6 +242,7 @@ export class ReadMediaFileTool implements BuiltinTool { filename: safePath.split(/[\\/]/).at(-1), }); } else { + const base64 = data.toString('base64'); mediaPart = { type: 'video_url', videoUrl: { url: `data:${fileType.mimeType};base64,${base64}` }, @@ -246,6 +253,10 @@ export class ReadMediaFileTool implements BuiltinTool { const openText = `<${tag} path="${safePath}">`; const closeText = ``; + // The summary always reports the ORIGINAL pixel size and byte size: the + // model derives relative coordinates and scales them by the original + // dimensions, so it must see the pre-compression size even when the + // image_url above carries a downsampled copy. const dimensions = fileType.kind === 'image' ? sniffImageDimensions(data) : null; const systemText = buildSystemSummary({ diff --git a/packages/agent-core/src/tools/support/image-compress.ts b/packages/agent-core/src/tools/support/image-compress.ts new file mode 100644 index 000000000..fdb719b21 --- /dev/null +++ b/packages/agent-core/src/tools/support/image-compress.ts @@ -0,0 +1,324 @@ +/** + * Shrink oversized images before they reach the model. + * + * A multimodal request carries each image as a base64 data URL; an unbounded + * screenshot or photo wastes context tokens and can blow past the provider's + * per-image byte ceiling. This module downsamples and re-encodes such images + * so they fit a pixel + byte budget, while leaving already-small images + * untouched — the common case is a fast, codec-free pass-through. + * + * Design notes: + * - Pure JS (jimp), imported lazily so the codec is only paid for when an + * image actually needs work; startup and the fast path stay cheap. + * - Best effort: any decode/encode failure returns the original bytes + * unchanged (`changed: false`), so a compression problem never blocks a + * prompt. Callers simply send the original instead. + * - Only PNG and JPEG are re-encoded. GIF is passed through to preserve + * animation; WebP is passed through because the default jimp build ships no + * WebP codec. Unknown formats are passed through. + */ + +import type { ContentPart } from '@moonshot-ai/kosong'; + +import { sniffImageDimensions } from './file-type'; + +/** Longest-edge ceiling (px). Larger images are scaled down to fit. */ +export const MAX_IMAGE_EDGE_PX = 2000; + +/** + * Raw-byte budget for a single image. base64 inflates bytes by ~4/3, so a + * 3.75 MB raw payload stays under a 5 MB encoded ceiling. Tune to the active + * provider's per-image limit. + */ +export const IMAGE_BYTE_BUDGET = 3.75 * 1024 * 1024; + +/** Progressively lower JPEG quality until the payload fits the byte budget. */ +const JPEG_QUALITY_STEPS = [80, 60, 40, 20] as const; + +/** Last-ditch longest edge when the budget cannot be met at MAX_IMAGE_EDGE_PX. */ +const FALLBACK_EDGE_PX = 1000; + +/** Formats we can both decode and re-encode with the default jimp build. */ +const RECODABLE_MIME = new Set(['image/png', 'image/jpeg']); + +export interface CompressImageOptions { + /** Override the longest-edge ceiling (px). */ + readonly maxEdge?: number; + /** Override the raw-byte budget. */ + readonly byteBudget?: number; +} + +export interface CompressImageResult { + /** Bytes to send: the re-encoded image, or the original when unchanged. */ + readonly data: Uint8Array; + /** MIME of `data`. May differ from the input (e.g. png → jpeg). */ + readonly mimeType: string; + /** Pixel width of `data`; falls back to the input size when unknown. */ + readonly width: number; + /** Pixel height of `data`; falls back to the input size when unknown. */ + readonly height: number; + /** True only when `data` differs from the input bytes. */ + readonly changed: boolean; + readonly originalByteLength: number; + readonly finalByteLength: number; +} + +/** + * Downsample/re-encode `bytes` to fit the pixel + byte budget. + * + * Never throws: on any failure (unsupported format, decode error, a result + * that would be larger than the input) the original bytes are returned with + * `changed: false`. + */ +export async function compressImageForModel( + bytes: Uint8Array, + mimeType: string, + options: CompressImageOptions = {}, +): Promise { + const maxEdge = options.maxEdge ?? MAX_IMAGE_EDGE_PX; + const byteBudget = options.byteBudget ?? IMAGE_BYTE_BUDGET; + const normalizedMime = normalizeMime(mimeType); + const dims = sniffImageDimensions(bytes); + + const passthrough = (): CompressImageResult => ({ + data: bytes, + mimeType, + width: dims?.width ?? 0, + height: dims?.height ?? 0, + changed: false, + originalByteLength: bytes.length, + finalByteLength: bytes.length, + }); + + if (bytes.length === 0) return passthrough(); + // Only re-encode formats the codec handles; everything else passes through. + if (!RECODABLE_MIME.has(normalizedMime)) return passthrough(); + + // Fast path: already within both budgets — no codec load, no allocation. + const longestEdge = dims ? Math.max(dims.width, dims.height) : 0; + const withinBytes = bytes.length <= byteBudget; + const withinEdge = longestEdge > 0 && longestEdge <= maxEdge; + if (withinBytes && (withinEdge || longestEdge === 0)) return passthrough(); + + try { + const { Jimp } = await import('jimp'); + const image = await Jimp.fromBuffer(Buffer.from(bytes)); + const sourceIsPng = normalizedMime === 'image/png'; + + // Scale so the longest edge fits maxEdge (never enlarges). + fitWithinEdge(image, maxEdge); + + const encoded = await encodeWithinBudget(image, { + sourceIsPng, + byteBudget, + fallbackEdge: FALLBACK_EDGE_PX, + }); + + // Keep the result when it actually helps: fewer bytes, or fewer pixels + // (a smaller image costs fewer vision tokens even if the byte count is + // flat, as with near-solid graphics). Otherwise the re-encode bought us + // nothing — send the original. + const originalPixels = (dims?.width ?? 0) * (dims?.height ?? 0); + const finalPixels = encoded.width * encoded.height; + const shrankBytes = encoded.data.length < bytes.length; + const shrankPixels = originalPixels > 0 && finalPixels < originalPixels; + if (!shrankBytes && !shrankPixels) return passthrough(); + + return { + data: encoded.data, + mimeType: encoded.mimeType, + width: encoded.width, + height: encoded.height, + changed: true, + originalByteLength: bytes.length, + finalByteLength: encoded.data.length, + }; + } catch { + // Decode/encode failure — keep the original bytes. + return passthrough(); + } +} + +export interface CompressBase64Result { + readonly base64: string; + readonly mimeType: string; + readonly changed: boolean; + readonly originalByteLength: number; + readonly finalByteLength: number; +} + +/** + * Convenience wrapper for call sites that already hold base64 (MCP results, + * data URLs). Decodes, compresses, and re-encodes to base64. Best effort: + * returns the original base64 unchanged on any failure. + */ +export async function compressBase64ForModel( + base64: string, + mimeType: string, + options: CompressImageOptions = {}, +): Promise { + let bytes: Buffer; + try { + bytes = Buffer.from(base64, 'base64'); + } catch { + return { + base64, + mimeType, + changed: false, + originalByteLength: 0, + finalByteLength: 0, + }; + } + const result = await compressImageForModel(bytes, mimeType, options); + if (!result.changed) { + return { + base64, + mimeType, + changed: false, + originalByteLength: result.originalByteLength, + finalByteLength: result.finalByteLength, + }; + } + return { + base64: Buffer.from(result.data).toString('base64'), + mimeType: result.mimeType, + changed: true, + originalByteLength: result.originalByteLength, + finalByteLength: result.finalByteLength, + }; +} + +/** + * Compress any inline base64 image parts in a content-part list — the single + * helper used by the prompt-ingestion chokepoint (every client's images) and + * the MCP tool-result path. Image parts whose URL is not a `data:` URL (e.g. a + * remote http(s) image) are passed through, as are non-image parts. Best + * effort: a part that fails to compress is left unchanged. + */ +export async function compressImageContentParts( + parts: readonly ContentPart[], + options: CompressImageOptions = {}, +): Promise { + const out: ContentPart[] = []; + for (const part of parts) { + if (part.type === 'image_url') { + const parsed = parseImageDataUrl(part.imageUrl.url); + if (parsed !== null) { + const result = await compressBase64ForModel(parsed.base64, parsed.mimeType, options); + if (result.changed) { + out.push({ + type: 'image_url', + imageUrl: { ...part.imageUrl, url: `data:${result.mimeType};base64,${result.base64}` }, + }); + continue; + } + } + } + out.push(part); + } + return out; +} + +function parseImageDataUrl(url: string): { mimeType: string; base64: string } | null { + const match = /^data:([^;,]+);base64,(.*)$/s.exec(url); + if (match === null) return null; + return { mimeType: match[1]!, base64: match[2]! }; +} + +// ── internals ──────────────────────────────────────────────────────── + +/** The concrete jimp image instance type, derived from the lazily-loaded module. */ +type JimpImage = Awaited>; + +interface EncodedImage { + readonly data: Buffer; + readonly mimeType: string; + readonly width: number; + readonly height: number; +} + +interface EncodeOptions { + readonly sourceIsPng: boolean; + readonly byteBudget: number; + readonly fallbackEdge: number; +} + +/** + * Encode `image` (already fitted to the edge ceiling) under the byte budget. + * + * Strategy — prefer the source format so a downscaled screenshot stays lossless + * PNG (preserving text and transparency), and only fall back to lossy JPEG when + * PNG cannot meet the byte budget: + * - PNG source: PNG at the fitted size → a smaller PNG rescale → JPEG ladder. + * - JPEG source: JPEG quality ladder → a smaller JPEG rescale. + * + * Always returns the smallest buffer it produced, even if no attempt met the + * budget — the caller still gates on whether it actually helped. + */ +async function encodeWithinBudget(image: JimpImage, opts: EncodeOptions): Promise { + const { sourceIsPng, byteBudget, fallbackEdge } = opts; + let smallest: EncodedImage | null = null; + + const consider = (data: Buffer, mimeType: string): EncodedImage => { + const candidate: EncodedImage = { data, mimeType, width: image.width, height: image.height }; + if (smallest === null || candidate.data.length < smallest.data.length) { + smallest = candidate; + } + return candidate; + }; + + if (sourceIsPng) { + // Lossless PNG first: best for screenshots/UI (sharp text) and keeps alpha. + const png = await image.getBuffer('image/png', { deflateLevel: 9 }); + if (png.length <= byteBudget) return consider(png, 'image/png'); + consider(png, 'image/png'); + + // Over budget: a smaller PNG before going lossy. + if (fitWithinEdge(image, fallbackEdge)) { + const smallerPng = await image.getBuffer('image/png', { deflateLevel: 9 }); + if (smallerPng.length <= byteBudget) return consider(smallerPng, 'image/png'); + consider(smallerPng, 'image/png'); + } + + // Last resort: lossy JPEG ladder (drops transparency) to meet the budget. + for (const quality of JPEG_QUALITY_STEPS) { + const jpeg = await image.getBuffer('image/jpeg', { quality }); + if (jpeg.length <= byteBudget) return consider(jpeg, 'image/jpeg'); + consider(jpeg, 'image/jpeg'); + } + return smallest!; + } + + // JPEG source: quality ladder, then a smaller rescale at the lowest quality. + for (const quality of JPEG_QUALITY_STEPS) { + const jpeg = await image.getBuffer('image/jpeg', { quality }); + if (jpeg.length <= byteBudget) return consider(jpeg, 'image/jpeg'); + consider(jpeg, 'image/jpeg'); + } + if (fitWithinEdge(image, fallbackEdge)) { + const jpeg = await image.getBuffer('image/jpeg', { quality: JPEG_QUALITY_STEPS.at(-1) }); + consider(jpeg, 'image/jpeg'); + } + + return smallest!; +} + +/** + * Scale `image` so its longest edge is at most `edge`, preserving aspect + * ratio. No-op (returns false) when the image already fits. + */ +function fitWithinEdge(image: JimpImage, edge: number): boolean { + const longest = Math.max(image.width, image.height); + if (longest <= edge) return false; + const factor = edge / longest; + image.resize({ + w: Math.max(1, Math.round(image.width * factor)), + h: Math.max(1, Math.round(image.height * factor)), + }); + return true; +} + +function normalizeMime(mimeType: string): string { + const lower = mimeType.trim().toLowerCase(); + return lower === 'image/jpg' ? 'image/jpeg' : lower; +} diff --git a/packages/agent-core/test/agent/prompt-image-compression.test.ts b/packages/agent-core/test/agent/prompt-image-compression.test.ts new file mode 100644 index 000000000..8c210fcc2 --- /dev/null +++ b/packages/agent-core/test/agent/prompt-image-compression.test.ts @@ -0,0 +1,68 @@ +/** + * Prompt-RPC image compression — the single ingestion chokepoint. + * + * Every client transport (CLI, web, desktop, ACP, SDK) submits prompts through + * the `prompt` / `steer` RPC. This pins that an oversized image handed to that + * RPC is downsampled before it is recorded into history (and therefore before + * it is sent to the model), with a normal image left untouched. + */ + +import { Jimp } from 'jimp'; +import { describe, expect, it } from 'vitest'; + +import type { ContentPart } from '@moonshot-ai/kosong'; + +import { sniffImageDimensions } from '../../src/tools/support/file-type'; +import { testAgent } from './harness/agent'; + +async function pngDataUrl(width: number, height: number): Promise { + const buf = await new Jimp({ width, height, color: 0x3366ccff }).getBuffer('image/png'); + return `data:image/png;base64,${Buffer.from(buf).toString('base64')}`; +} + +function lastUserImagePart(history: readonly { role: string; content: readonly ContentPart[] }[]) { + for (let i = history.length - 1; i >= 0; i -= 1) { + const message = history[i]!; + if (message.role !== 'user') continue; + const image = message.content.find((part) => part.type === 'image_url'); + if (image?.type === 'image_url') return image; + } + return undefined; +} + +describe('prompt RPC image compression', () => { + it('downsamples an oversized image submitted through the prompt RPC', async () => { + const ctx = testAgent(); + ctx.configure(); + ctx.mockNextResponse({ type: 'text', text: 'ok' }); + + const url = await pngDataUrl(2600, 2600); + await ctx.rpc.prompt({ + input: [ + { type: 'text', text: 'what is in this image?' }, + { type: 'image_url', imageUrl: { url } }, + ], + }); + await ctx.untilTurnEnd(); + + const image = lastUserImagePart(ctx.agent.context.history); + expect(image).toBeDefined(); + const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(image!.imageUrl.url); + expect(match).not.toBeNull(); + const dims = sniffImageDimensions(Buffer.from(match![2]!, 'base64')); + expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(2000); + }); + + it('leaves a within-budget image untouched', async () => { + const ctx = testAgent(); + ctx.configure(); + ctx.mockNextResponse({ type: 'text', text: 'ok' }); + + const url = await pngDataUrl(48, 48); + await ctx.rpc.prompt({ input: [{ type: 'image_url', imageUrl: { url } }] }); + await ctx.untilTurnEnd(); + + const image = lastUserImagePart(ctx.agent.context.history); + expect(image?.imageUrl.url).toBe(url); + }); +}); diff --git a/packages/agent-core/test/mcp/output.test.ts b/packages/agent-core/test/mcp/output.test.ts index 496ab45dc..e2e742fce 100644 --- a/packages/agent-core/test/mcp/output.test.ts +++ b/packages/agent-core/test/mcp/output.test.ts @@ -1,9 +1,11 @@ import { ContentBlockSchema } from '@modelcontextprotocol/sdk/types.js'; import type { ContentPart } from '@moonshot-ai/kosong'; +import { Jimp } from 'jimp'; import { describe, expect, test } from 'vitest'; import { convertMCPContentBlock, mcpResultToExecutableOutput } from '../../src/mcp/output'; import type { MCPContentBlock, MCPToolResult } from '../../src/mcp/types'; +import { sniffImageDimensions } from '../../src/tools/support/file-type'; const MCP_OUTPUT_TRUNCATED_TEXT = '\n\n[Output truncated: exceeded 100000 character limit. ' + @@ -205,29 +207,32 @@ describe('mcpResultToExecutableOutput', () => { return { content, isError }; } - test('collapses a single text part into a plain string', () => { - const out = mcpResultToExecutableOutput(result([{ type: 'text', text: 'hello' }]), 'mcp__s__t'); + test('collapses a single text part into a plain string', async () => { + const out = await mcpResultToExecutableOutput( + result([{ type: 'text', text: 'hello' }]), + 'mcp__s__t', + ); expect(out).toEqual({ output: 'hello', isError: false }); }); - test('propagates isError=true on the success-shape return', () => { - const out = mcpResultToExecutableOutput( + test('propagates isError=true on the success-shape return', async () => { + const out = await mcpResultToExecutableOutput( result([{ type: 'text', text: 'oops' }], true), 'mcp__s__t', ); expect(out).toEqual({ output: 'oops', isError: true }); }); - test('returns an empty string when the content array is empty', () => { - const out = mcpResultToExecutableOutput(result([]), 'mcp__s__t'); + test('returns an empty string when the content array is empty', async () => { + const out = await mcpResultToExecutableOutput(result([]), 'mcp__s__t'); // No parts survive; collapseSingleText has nothing to collapse so the // ContentPart[] branch wins. An empty array is the model-visible signal // that the tool returned no content. expect(out).toEqual({ output: [], isError: false }); }); - test('drops unconvertible blocks and keeps the rest', () => { - const out = mcpResultToExecutableOutput( + test('drops unconvertible blocks and keeps the rest', async () => { + const out = await mcpResultToExecutableOutput( result([ { type: 'text', text: 'kept' }, { type: 'fancy_new_type', text: 'dropped' }, @@ -237,8 +242,8 @@ describe('mcpResultToExecutableOutput', () => { expect(out).toEqual({ output: 'kept', isError: false }); }); - test('wraps media-only output in mcp_tool_result tags using the qualified name', () => { - const out = mcpResultToExecutableOutput( + test('wraps media-only output in mcp_tool_result tags using the qualified name', async () => { + const out = await mcpResultToExecutableOutput( result([{ type: 'image', data: 'AAA', mimeType: 'image/png' }]), 'mcp__github__create_pr', ); @@ -250,8 +255,8 @@ describe('mcpResultToExecutableOutput', () => { ]); }); - test('does NOT wrap when a non-empty text part accompanies the media', () => { - const out = mcpResultToExecutableOutput( + test('does NOT wrap when a non-empty text part accompanies the media', async () => { + const out = await mcpResultToExecutableOutput( result([ { type: 'text', text: 'caption' }, { type: 'image', data: 'AAA', mimeType: 'image/png' }, @@ -264,8 +269,8 @@ describe('mcpResultToExecutableOutput', () => { ]); }); - test('an empty-text companion still triggers the wrap', () => { - const out = mcpResultToExecutableOutput( + test('an empty-text companion still triggers the wrap', async () => { + const out = await mcpResultToExecutableOutput( result([ { type: 'text', text: '' }, { type: 'image', data: 'AAA', mimeType: 'image/png' }, @@ -277,8 +282,8 @@ describe('mcpResultToExecutableOutput', () => { expect(parts.at(-1)).toEqual({ type: 'text', text: '' }); }); - test('truncates oversized text and merges the notice into the surviving text part', () => { - const out = mcpResultToExecutableOutput( + test('truncates oversized text and merges the notice into the surviving text part', async () => { + const out = await mcpResultToExecutableOutput( result([{ type: 'text', text: 'x'.repeat(100_001) }]), 'mcp__s__t', ); @@ -288,10 +293,12 @@ describe('mcpResultToExecutableOutput', () => { expect(out.truncated).toBe(true); }); - test('drops oversized binary parts in favor of a per-part notice without touching the text budget', () => { - // 14 MiB base64 ≈ 10.5 MiB raw — just above the 10 MiB per-part cap. + test('drops oversized binary parts in favor of a per-part notice without touching the text budget', async () => { + // 14 MiB base64 ≈ 10.5 MiB raw — just above the 10 MiB per-part cap. The + // bytes are not a real image, so compression fails over and the drop path + // still applies. const huge = 'x'.repeat(14 * 1024 * 1024); - const out = mcpResultToExecutableOutput( + const out = await mcpResultToExecutableOutput( result([{ type: 'image', data: huge, mimeType: 'image/png' }]), 'mcp__s__big', ); @@ -308,8 +315,8 @@ describe('mcpResultToExecutableOutput', () => { expect(out.truncated).toBe(true); }); - test('binary part within the per-part cap survives intact alongside oversized text', () => { - const out = mcpResultToExecutableOutput( + test('binary part within the per-part cap survives intact alongside oversized text', async () => { + const out = await mcpResultToExecutableOutput( result([ { type: 'text', text: 'A'.repeat(100_000) }, { type: 'image', data: 'B'.repeat(500_000), mimeType: 'image/png' }, @@ -324,4 +331,28 @@ describe('mcpResultToExecutableOutput', () => { ]); expect(out).not.toHaveProperty('truncated'); }); + + test('downsamples an oversized real image instead of leaving it full-size', async () => { + const big = Buffer.from( + await new Jimp({ width: 2600, height: 2600, color: 0x3366ccff }).getBuffer('image/png'), + ).toString('base64'); + + const out = await mcpResultToExecutableOutput( + result([{ type: 'image', data: big, mimeType: 'image/png' }]), + 'mcp__s__shot', + ); + + const parts = out.output as ContentPart[]; + const imagePart = parts.find((p) => p.type === 'image_url'); + expect(imagePart).toBeDefined(); + const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec( + (imagePart as { imageUrl: { url: string } }).imageUrl.url, + ); + expect(match).not.toBeNull(); + const dims = sniffImageDimensions(Buffer.from(match![2]!, 'base64')); + expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(2000); + // The image was compressed and kept, not dropped to a notice. + const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join(''); + expect(joined).not.toContain('image_url dropped'); + }); }); diff --git a/packages/agent-core/test/tools/image-compress.test.ts b/packages/agent-core/test/tools/image-compress.test.ts new file mode 100644 index 000000000..0c540842b --- /dev/null +++ b/packages/agent-core/test/tools/image-compress.test.ts @@ -0,0 +1,324 @@ +/** + * image-compress — downsample/re-encode oversized images for the model. + * + * Tests pin: + * - fast path: an image within both budgets passes through untouched + * (same byte reference, no re-encode) + * - dimension cap: an oversized image is scaled so its longest edge is + * exactly MAX_IMAGE_EDGE_PX, preserving aspect ratio + * - byte budget: an over-budget image walks the JPEG quality ladder and + * comes back as JPEG, strictly smaller than the input + * - alpha: a translucent PNG stays PNG when the budget allows, and only + * drops to JPEG as a last resort to meet a tiny budget + * - fallback: corrupt/empty bytes and non-recodable formats (GIF/WebP) + * return the original unchanged — never throws + * - invariant: `changed` implies the result is strictly smaller + * - base64 wrapper round-trips + * - performance: the fast path is codec-free; a large image compresses + * within a generous time bound + */ + +import { Jimp } from 'jimp'; +import { describe, expect, it } from 'vitest'; + +// eslint-disable-next-line import/no-unresolved +import { + compressBase64ForModel, + compressImageContentParts, + compressImageForModel, + IMAGE_BYTE_BUDGET, + MAX_IMAGE_EDGE_PX, +} from '../../src/tools/support/image-compress'; +// eslint-disable-next-line import/no-unresolved +import { sniffImageDimensions } from '../../src/tools/support/file-type'; + +// ── fixtures ───────────────────────────────────────────────────────── + +async function solidPng(width: number, height: number, color = 0x3366ccff): Promise { + const image = new Jimp({ width, height, color }); + return new Uint8Array(await image.getBuffer('image/png')); +} + +async function solidJpeg(width: number, height: number, color = 0x3366ccff): Promise { + const image = new Jimp({ width, height, color }); + return new Uint8Array(await image.getBuffer('image/jpeg', { quality: 90 })); +} + +async function translucentPng(width: number, height: number): Promise { + // Alpha 0x80 on every pixel → hasAlpha() is true. + const image = new Jimp({ width, height, color: 0x33_66_cc_80 }); + return new Uint8Array(await image.getBuffer('image/png')); +} + +/** High-entropy image whose PNG barely compresses — used to force the ladder. */ +async function noisePng(width: number, height: number, alpha = false): Promise { + const image = new Jimp({ width, height, color: 0x000000ff }); + const data = image.bitmap.data; + for (let i = 0; i < data.length; i += 4) { + // Deterministic pseudo-random bytes (no Math.random for stable fixtures). + // Distinct multipliers per channel keep entropy high so PNG barely shrinks. + data[i] = (i * 2_654_435_761) & 0xff; + data[i + 1] = (i * 40_503) & 0xff; + data[i + 2] = (i * 12_289) & 0xff; + data[i + 3] = alpha ? (i * 7 + 17) & 0xff : 0xff; + } + return new Uint8Array(await image.getBuffer('image/png')); +} + +async function decodeAlpha(bytes: Uint8Array): Promise { + const image = await Jimp.fromBuffer(Buffer.from(bytes)); + return image.hasAlpha(); +} + +// ── fast path ──────────────────────────────────────────────────────── + +describe('compressImageForModel — fast path', () => { + it('passes a within-budget image through untouched (same reference)', async () => { + const png = await solidPng(64, 64); + const result = await compressImageForModel(png, 'image/png'); + expect(result.changed).toBe(false); + expect(result.data).toBe(png); // identity: no copy, no re-encode + expect(result.mimeType).toBe('image/png'); + expect(result.width).toBe(64); + expect(result.height).toBe(64); + }); + + it('treats image/jpg as image/jpeg', async () => { + const jpeg = await solidJpeg(32, 32); + const result = await compressImageForModel(jpeg, 'image/jpg'); + expect(result.changed).toBe(false); + expect(result.data).toBe(jpeg); + }); +}); + +// ── dimension cap ──────────────────────────────────────────────────── + +describe('compressImageForModel — dimension cap', () => { + it('scales the longest edge down to MAX_IMAGE_EDGE_PX, preserving aspect', async () => { + const png = await solidPng(3000, 1500); + const result = await compressImageForModel(png, 'image/png'); + expect(result.changed).toBe(true); + expect(Math.max(result.width, result.height)).toBe(MAX_IMAGE_EDGE_PX); + // 3000x1500 → 2000x1000 (aspect 2:1 preserved). + expect(result.width).toBe(2000); + expect(result.height).toBe(1000); + const dims = sniffImageDimensions(result.data); + expect(dims).toEqual({ width: 2000, height: 1000 }); + }); + + it('respects a custom maxEdge', async () => { + const png = await solidPng(1600, 800); + const result = await compressImageForModel(png, 'image/png', { maxEdge: 800 }); + expect(result.changed).toBe(true); + expect(result.width).toBe(800); + expect(result.height).toBe(400); + }); + + it('keeps a downscaled opaque PNG lossless (no needless JPEG conversion)', async () => { + // A screenshot-like opaque PNG that only needs downscaling must stay PNG so + // sharp text is not degraded by JPEG artifacts. + const png = await solidPng(3000, 1500); + const result = await compressImageForModel(png, 'image/png'); + expect(result.changed).toBe(true); + expect(result.mimeType).toBe('image/png'); + expect(Math.max(result.width, result.height)).toBe(MAX_IMAGE_EDGE_PX); + }); +}); + +// ── byte budget ────────────────────────────────────────────────────── + +describe('compressImageForModel — byte budget', () => { + it('walks the JPEG ladder for an over-budget non-alpha image', async () => { + const png = await noisePng(900, 900); + const result = await compressImageForModel(png, 'image/png', { byteBudget: 8 * 1024 }); + expect(result.changed).toBe(true); + expect(result.mimeType).toBe('image/jpeg'); + expect(result.finalByteLength).toBeLessThan(result.originalByteLength); + }); + + it('keeps a translucent PNG as PNG when the budget allows', async () => { + const png = await translucentPng(2600, 2600); + const result = await compressImageForModel(png, 'image/png'); + expect(result.changed).toBe(true); + expect(result.mimeType).toBe('image/png'); + expect(Math.max(result.width, result.height)).toBe(MAX_IMAGE_EDGE_PX); + expect(await decodeAlpha(result.data)).toBe(true); + }); + + it('drops alpha to JPEG only as a last resort under a tiny budget', async () => { + const png = await noisePng(800, 800, /* alpha */ true); + const result = await compressImageForModel(png, 'image/png', { byteBudget: 4 * 1024 }); + expect(result.changed).toBe(true); + expect(result.mimeType).toBe('image/jpeg'); + expect(result.finalByteLength).toBeLessThan(result.originalByteLength); + }); +}); + +// ── fallback / robustness ──────────────────────────────────────────── + +describe('compressImageForModel — fallback', () => { + it('returns the original on corrupt bytes (never throws)', async () => { + // Valid PNG signature followed by garbage — decode will fail. + const corrupt = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4, 5]); + const result = await compressImageForModel(corrupt, 'image/png'); + expect(result.changed).toBe(false); + expect(result.data).toBe(corrupt); + }); + + it('passes empty buffers through', async () => { + const empty = new Uint8Array(0); + const result = await compressImageForModel(empty, 'image/png'); + expect(result.changed).toBe(false); + expect(result.data).toBe(empty); + }); + + it('passes GIF through (preserves animation)', async () => { + // Minimal GIF89a header — enough for the MIME guard to skip it. + const gif = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 1, 0, 1, 0]); + const result = await compressImageForModel(gif, 'image/gif'); + expect(result.changed).toBe(false); + expect(result.data).toBe(gif); + }); + + it('passes WebP through (no codec in the default build)', async () => { + const webp = new Uint8Array([ + 0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, + ]); + const result = await compressImageForModel(webp, 'image/webp'); + expect(result.changed).toBe(false); + expect(result.data).toBe(webp); + }); +}); + +// ── invariants ─────────────────────────────────────────────────────── + +describe('compressImageForModel — invariants', () => { + it('changed always yields a within-cap, decodable payload', async () => { + const cases: Uint8Array[] = [ + await solidPng(3000, 1500), + await noisePng(900, 900), + await translucentPng(2600, 2600), + ]; + for (const bytes of cases) { + const result = await compressImageForModel(bytes, 'image/png'); + expect(result.finalByteLength).toBe(result.data.length); + if (result.changed) { + // A change is only kept when it helped: fewer bytes or fewer pixels. + const original = sniffImageDimensions(bytes)!; + const shrankBytes = result.finalByteLength < result.originalByteLength; + const shrankPixels = result.width * result.height < original.width * original.height; + expect(shrankBytes || shrankPixels).toBe(true); + // Dimensions never exceed the cap after a change. + expect(Math.max(result.width, result.height)).toBeLessThanOrEqual(MAX_IMAGE_EDGE_PX); + // The result must decode. + expect(sniffImageDimensions(result.data)).not.toBeNull(); + } + } + }); +}); + +// ── base64 wrapper ─────────────────────────────────────────────────── + +describe('compressBase64ForModel', () => { + it('round-trips an over-sized image', async () => { + const png = await noisePng(700, 700); + const base64 = Buffer.from(png).toString('base64'); + const result = await compressBase64ForModel(base64, 'image/png', { byteBudget: 8 * 1024 }); + expect(result.changed).toBe(true); + expect(result.finalByteLength).toBeLessThan(result.originalByteLength); + // The re-encoded base64 still decodes to a valid image. + const dims = sniffImageDimensions(Buffer.from(result.base64, 'base64')); + expect(dims).not.toBeNull(); + }); + + it('returns the original base64 unchanged on the fast path', async () => { + const png = await solidPng(64, 64); + const base64 = Buffer.from(png).toString('base64'); + const result = await compressBase64ForModel(base64, 'image/png'); + expect(result.changed).toBe(false); + expect(result.base64).toBe(base64); + }); +}); + +// ── performance ────────────────────────────────────────────────────── + +describe('compressImageForModel — performance', () => { + it('fast path is codec-free and quick across many calls', async () => { + const png = await solidPng(200, 200); + const start = performance.now(); + for (let i = 0; i < 100; i += 1) { + const result = await compressImageForModel(png, 'image/png'); + expect(result.data).toBe(png); // proves no decode/encode happened + } + const elapsed = performance.now() - start; + // 100 metadata-only checks should be well under 100ms. + expect(elapsed).toBeLessThan(100); + }); + + it('compresses a large image within a generous time bound', async () => { + const png = await solidPng(3000, 2000); + const start = performance.now(); + const result = await compressImageForModel(png, 'image/png'); + const elapsed = performance.now() - start; + expect(result.changed).toBe(true); + expect(elapsed).toBeLessThan(5000); + }); + + it('exposes a sane default budget', () => { + expect(IMAGE_BYTE_BUDGET).toBeGreaterThan(0); + expect(MAX_IMAGE_EDGE_PX).toBe(2000); + }); +}); + +// ── content-part helper ────────────────────────────────────────────── + +describe('compressImageContentParts', () => { + function dataUrl(mime: string, bytes: Uint8Array): string { + return `data:${mime};base64,${Buffer.from(bytes).toString('base64')}`; + } + + it('compresses an oversized inline image part, leaving other parts untouched', async () => { + const big = await solidPng(2600, 2600); + const parts = [ + { type: 'text' as const, text: 'look at this' }, + { type: 'image_url' as const, imageUrl: { url: dataUrl('image/png', big) } }, + ]; + const out = await compressImageContentParts(parts); + + expect(out[0]).toEqual({ type: 'text', text: 'look at this' }); + const imagePart = out[1]; + if (imagePart?.type !== 'image_url') throw new Error('expected image_url'); + const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(imagePart.imageUrl.url); + expect(match).not.toBeNull(); + const dims = sniffImageDimensions(Buffer.from(match![2]!, 'base64')); + expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(MAX_IMAGE_EDGE_PX); + }); + + it('preserves the part identity for a within-budget image (no change)', async () => { + const small = await solidPng(48, 48); + const url = dataUrl('image/png', small); + const parts = [{ type: 'image_url' as const, imageUrl: { url } }]; + const out = await compressImageContentParts(parts); + expect(out[0]).toEqual({ type: 'image_url', imageUrl: { url } }); + }); + + it('leaves remote (non-data) image URLs untouched', async () => { + const parts = [ + { type: 'image_url' as const, imageUrl: { url: 'https://example.com/pic.png' } }, + ]; + const out = await compressImageContentParts(parts); + expect(out[0]).toEqual({ type: 'image_url', imageUrl: { url: 'https://example.com/pic.png' } }); + }); + + it('keeps an image part id when rewriting the compressed url', async () => { + const big = await solidPng(2600, 2600); + const parts = [ + { type: 'image_url' as const, imageUrl: { url: dataUrl('image/png', big), id: 'att-1' } }, + ]; + const out = await compressImageContentParts(parts); + const imagePart = out[0]; + if (imagePart?.type !== 'image_url') throw new Error('expected image_url'); + expect(imagePart.imageUrl.id).toBe('att-1'); + expect(imagePart.imageUrl.url).not.toBe(dataUrl('image/png', big)); + }); +}); diff --git a/packages/agent-core/test/tools/read-media.test.ts b/packages/agent-core/test/tools/read-media.test.ts index 59c53fd9f..8adc5b9ab 100644 --- a/packages/agent-core/test/tools/read-media.test.ts +++ b/packages/agent-core/test/tools/read-media.test.ts @@ -4,6 +4,7 @@ import type { Kaos } from '@moonshot-ai/kaos'; import type { ContentPart, ModelCapability } from '@moonshot-ai/kosong'; +import { Jimp } from 'jimp'; import { describe, expect, it, vi } from 'vitest'; import { ToolAccesses } from '../../src/loop'; @@ -12,7 +13,7 @@ import { ReadMediaFileInputSchema, ReadMediaFileTool, } from '../../src/tools/builtin/file/read-media'; -import { MEDIA_SNIFF_BYTES } from '../../src/tools/support/file-type'; +import { MEDIA_SNIFF_BYTES, sniffImageDimensions } from '../../src/tools/support/file-type'; import { createFakeKaos, PERMISSIVE_WORKSPACE } from './fixtures/fake-kaos'; import { executeTool } from './fixtures/execute-tool'; @@ -651,4 +652,37 @@ describe('ReadMediaFileTool', () => { '"/workspace/fake.png" is not a supported image or video file. Use Read for text files, or Bash or an MCP tool for other binary formats.', ); }); + + it('downsamples an oversized image but reports original dimensions', async () => { + const big = Buffer.from( + await new Jimp({ width: 2600, height: 2600, color: 0x3366ccff }).getBuffer('image/png'), + ); + expect(sniffImageDimensions(big)).toEqual({ width: 2600, height: 2600 }); + + const tool = makeReadMediaTool({ + stat: vi.fn().mockResolvedValue({ ...DEFAULT_STAT, stSize: big.length }), + readBytes: vi.fn().mockResolvedValue(big), + }); + + const result = await executeTool(tool, { + turnId: 't1', + toolCallId: 'c_big', + args: { path: '/workspace/big.png' }, + signal, + }); + + const parts = outputParts(result); + const url = (parts[2] as { imageUrl: { url: string } }).imageUrl.url; + const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(url); + expect(match).not.toBeNull(); + // The image actually sent to the model is downsampled to the edge cap. + const sentBytes = Buffer.from(match![2]!, 'base64'); + const sentDims = sniffImageDimensions(sentBytes); + expect(Math.max(sentDims!.width, sentDims!.height)).toBeLessThanOrEqual(2000); + + // The summary keeps the ORIGINAL size so coordinate mapping holds. + const systemText = (parts[0] as { text: string }).text; + expect(systemText).toContain('2600x2600'); + expect(systemText).toContain(`${String(big.length)} bytes`); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 858021436..a1e9adb06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,7 +59,7 @@ importers: version: 2.13.1 tsdown: specifier: 0.22.0 - version: 0.22.0(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.21.0)(typescript@6.0.2)(unrun@0.2.34)(vue-tsc@3.2.9(typescript@6.0.2)) + version: 0.22.0(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.21.0)(typescript@6.0.2)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(vue-tsc@3.2.9(typescript@6.0.2)) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -374,6 +374,9 @@ importers: ignore: specifier: ^5.3.2 version: 5.3.2 + jimp: + specifier: ^1.6.1 + version: 1.6.1 js-yaml: specifier: ^4.1.1 version: 4.1.1 @@ -889,6 +892,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@braidai/lang@1.1.2': resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} @@ -1625,6 +1631,118 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@jimp/core@1.6.1': + resolution: {integrity: sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==} + engines: {node: '>=18'} + + '@jimp/diff@1.6.1': + resolution: {integrity: sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ==} + engines: {node: '>=18'} + + '@jimp/file-ops@1.6.1': + resolution: {integrity: sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w==} + engines: {node: '>=18'} + + '@jimp/js-bmp@1.6.1': + resolution: {integrity: sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ==} + engines: {node: '>=18'} + + '@jimp/js-gif@1.6.1': + resolution: {integrity: sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w==} + engines: {node: '>=18'} + + '@jimp/js-jpeg@1.6.1': + resolution: {integrity: sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg==} + engines: {node: '>=18'} + + '@jimp/js-png@1.6.1': + resolution: {integrity: sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA==} + engines: {node: '>=18'} + + '@jimp/js-tiff@1.6.1': + resolution: {integrity: sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw==} + engines: {node: '>=18'} + + '@jimp/plugin-blit@1.6.1': + resolution: {integrity: sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ==} + engines: {node: '>=18'} + + '@jimp/plugin-blur@1.6.1': + resolution: {integrity: sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==} + engines: {node: '>=18'} + + '@jimp/plugin-circle@1.6.1': + resolution: {integrity: sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ==} + engines: {node: '>=18'} + + '@jimp/plugin-color@1.6.1': + resolution: {integrity: sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A==} + engines: {node: '>=18'} + + '@jimp/plugin-contain@1.6.1': + resolution: {integrity: sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg==} + engines: {node: '>=18'} + + '@jimp/plugin-cover@1.6.1': + resolution: {integrity: sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg==} + engines: {node: '>=18'} + + '@jimp/plugin-crop@1.6.1': + resolution: {integrity: sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg==} + engines: {node: '>=18'} + + '@jimp/plugin-displace@1.6.1': + resolution: {integrity: sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g==} + engines: {node: '>=18'} + + '@jimp/plugin-dither@1.6.1': + resolution: {integrity: sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew==} + engines: {node: '>=18'} + + '@jimp/plugin-fisheye@1.6.1': + resolution: {integrity: sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA==} + engines: {node: '>=18'} + + '@jimp/plugin-flip@1.6.1': + resolution: {integrity: sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA==} + engines: {node: '>=18'} + + '@jimp/plugin-hash@1.6.1': + resolution: {integrity: sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A==} + engines: {node: '>=18'} + + '@jimp/plugin-mask@1.6.1': + resolution: {integrity: sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A==} + engines: {node: '>=18'} + + '@jimp/plugin-print@1.6.1': + resolution: {integrity: sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w==} + engines: {node: '>=18'} + + '@jimp/plugin-quantize@1.6.1': + resolution: {integrity: sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww==} + engines: {node: '>=18'} + + '@jimp/plugin-resize@1.6.1': + resolution: {integrity: sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==} + engines: {node: '>=18'} + + '@jimp/plugin-rotate@1.6.1': + resolution: {integrity: sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg==} + engines: {node: '>=18'} + + '@jimp/plugin-threshold@1.6.1': + resolution: {integrity: sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q==} + engines: {node: '>=18'} + + '@jimp/types@1.6.1': + resolution: {integrity: sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w==} + engines: {node: '>=18'} + + '@jimp/utils@1.6.1': + resolution: {integrity: sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==} + engines: {node: '>=18'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2648,6 +2766,13 @@ packages: '@tanstack/virtual-core@3.14.0': resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tootallnate/once@2.0.1': resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} engines: {node: '>= 10'} @@ -2823,6 +2948,9 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@16.9.1': + resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} + '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} @@ -3159,6 +3287,9 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + any-base@1.1.0: + resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -3264,6 +3395,10 @@ packages: avvio@9.2.0: resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + await-to-js@3.0.0: + resolution: {integrity: sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==} + engines: {node: '>=6.0.0'} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -3310,6 +3445,9 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + bmp-ts@1.0.9: + resolution: {integrity: sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -4162,6 +4300,9 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + exif-parser@0.1.12: + resolution: {integrity: sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -4260,6 +4401,10 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} @@ -4407,6 +4552,9 @@ packages: resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} engines: {node: '>=20.20.0'} + gifwrap@0.10.1: + resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -4593,6 +4741,9 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + image-q@4.0.0: + resolution: {integrity: sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==} + import-lazy@4.0.0: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} @@ -4849,6 +5000,10 @@ packages: engines: {node: '>=10'} hasBin: true + jimp@1.6.1: + resolution: {integrity: sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==} + engines: {node: '>=18'} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -4863,6 +5018,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -5373,6 +5531,11 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -5595,6 +5758,9 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + omggif@1.0.10: + resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -5712,6 +5878,18 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parse-bmfont-ascii@1.0.6: + resolution: {integrity: sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==} + + parse-bmfont-binary@1.0.6: + resolution: {integrity: sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==} + + parse-bmfont-xml@1.1.6: + resolution: {integrity: sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==} + parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -5808,6 +5986,10 @@ packages: resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} hasBin: true + pixelmatch@5.3.0: + resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==} + hasBin: true + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -5820,6 +6002,14 @@ packages: resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} engines: {node: '>=10.4.0'} + pngjs@6.0.0: + resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} + engines: {node: '>=12.13.0'} + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -6374,6 +6564,10 @@ packages: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} + simple-xml-to-json@1.2.7: + resolution: {integrity: sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==} + engines: {node: '>=20.12.2'} + sinon@22.0.0: resolution: {integrity: sha512-sq/6DpdXOrLyfbKlXLg/Usc7xu8YXPeLkOFZRvA3bNUSA2lhbrZ06yuXbH1fkzBPCbz9O10+7hznzUsjaYNm0Q==} @@ -6545,6 +6739,10 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} @@ -6618,6 +6816,9 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + tinyexec@1.1.1: resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} engines: {node: '>=18'} @@ -6660,6 +6861,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + tokenx@1.3.0: resolution: {integrity: sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==} @@ -6792,6 +6997,10 @@ packages: uhyphen@0.2.0: resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + ulid@3.0.2: resolution: {integrity: sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==} hasBin: true @@ -6880,6 +7089,9 @@ packages: utf8-byte-length@1.0.5: resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + utif2@4.1.0: + resolution: {integrity: sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -7213,6 +7425,17 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml-parse-from-string@1.0.1: + resolution: {integrity: sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==} + + xml2js@0.5.0: + resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -7278,6 +7501,9 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -7605,6 +7831,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@borewit/text-codec@0.2.2': {} + '@braidai/lang@1.1.2': {} '@braintree/sanitize-url@6.0.4': @@ -8293,6 +8521,229 @@ snapshots: dependencies: minipass: 7.1.3 + '@jimp/core@1.6.1': + dependencies: + '@jimp/file-ops': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + await-to-js: 3.0.0 + exif-parser: 0.1.12 + file-type: 21.3.4 + mime: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@jimp/diff@1.6.1': + dependencies: + '@jimp/plugin-resize': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + pixelmatch: 5.3.0 + transitivePeerDependencies: + - supports-color + + '@jimp/file-ops@1.6.1': {} + + '@jimp/js-bmp@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + bmp-ts: 1.0.9 + transitivePeerDependencies: + - supports-color + + '@jimp/js-gif@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + gifwrap: 0.10.1 + omggif: 1.0.10 + transitivePeerDependencies: + - supports-color + + '@jimp/js-jpeg@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + jpeg-js: 0.4.4 + transitivePeerDependencies: + - supports-color + + '@jimp/js-png@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + pngjs: 7.0.0 + transitivePeerDependencies: + - supports-color + + '@jimp/js-tiff@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + utif2: 4.1.0 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-blit@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-blur@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/utils': 1.6.1 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-circle@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-color@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + tinycolor2: 1.6.0 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-contain@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/plugin-blit': 1.6.1 + '@jimp/plugin-resize': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-cover@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/plugin-crop': 1.6.1 + '@jimp/plugin-resize': 1.6.1 + '@jimp/types': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-crop@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-displace@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-dither@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + + '@jimp/plugin-fisheye@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-flip@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-hash@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/js-bmp': 1.6.1 + '@jimp/js-jpeg': 1.6.1 + '@jimp/js-png': 1.6.1 + '@jimp/js-tiff': 1.6.1 + '@jimp/plugin-color': 1.6.1 + '@jimp/plugin-resize': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + any-base: 1.1.0 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-mask@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-print@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/js-jpeg': 1.6.1 + '@jimp/js-png': 1.6.1 + '@jimp/plugin-blit': 1.6.1 + '@jimp/types': 1.6.1 + parse-bmfont-ascii: 1.0.6 + parse-bmfont-binary: 1.0.6 + parse-bmfont-xml: 1.1.6 + simple-xml-to-json: 1.2.7 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-quantize@1.6.1': + dependencies: + image-q: 4.0.0 + zod: 3.25.76 + + '@jimp/plugin-resize@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-rotate@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/plugin-crop': 1.6.1 + '@jimp/plugin-resize': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-threshold@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/plugin-color': 1.6.1 + '@jimp/plugin-hash': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/types@1.6.1': + dependencies: + zod: 3.25.76 + + '@jimp/utils@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + tinycolor2: 1.6.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -8463,11 +8914,6 @@ snapshots: '@mozilla/readability@0.6.0': {} - '@napi-rs/wasm-runtime@1.1.4': - dependencies: - '@tybys/wasm-util': 0.10.1 - optional: true - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -8728,14 +9174,6 @@ snapshots: '@rolldown/binding-openharmony-arm64@1.0.1': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': - dependencies: - '@napi-rs/wasm-runtime': 1.1.4 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) @@ -9095,6 +9533,15 @@ snapshots: '@tanstack/virtual-core@3.14.0': {} + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tootallnate/once@2.0.1': {} '@tybys/wasm-util@0.10.1': @@ -9303,6 +9750,8 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@16.9.1': {} + '@types/node@18.19.130': dependencies: undici-types: 5.26.5 @@ -9424,7 +9873,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.4': dependencies: @@ -9695,6 +10144,8 @@ snapshots: ansis@4.2.0: {} + any-base@1.1.0: {} + any-promise@1.3.0: {} app-builder-bin@5.0.0-alpha.10: {} @@ -9852,6 +10303,8 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + await-to-js@3.0.0: {} + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -9890,6 +10343,8 @@ snapshots: bluebird@3.7.2: {} + bmp-ts@1.0.9: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -10922,6 +11377,8 @@ snapshots: dependencies: eventsource-parser: 3.0.6 + exif-parser@0.1.12: {} + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -11063,6 +11520,15 @@ snapshots: fflate@0.8.2: {} + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + filelist@1.0.6: dependencies: minimatch: 5.1.9 @@ -11250,6 +11716,11 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + gifwrap@0.10.1: + dependencies: + image-q: 4.0.0 + omggif: 1.0.10 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -11490,6 +11961,10 @@ snapshots: ignore@5.3.2: {} + image-q@4.0.0: + dependencies: + '@types/node': 16.9.1 + import-lazy@4.0.0: {} import-meta-resolve@4.2.0: {} @@ -11713,6 +12188,38 @@ snapshots: filelist: 1.0.6 picocolors: 1.1.1 + jimp@1.6.1: + dependencies: + '@jimp/core': 1.6.1 + '@jimp/diff': 1.6.1 + '@jimp/js-bmp': 1.6.1 + '@jimp/js-gif': 1.6.1 + '@jimp/js-jpeg': 1.6.1 + '@jimp/js-png': 1.6.1 + '@jimp/js-tiff': 1.6.1 + '@jimp/plugin-blit': 1.6.1 + '@jimp/plugin-blur': 1.6.1 + '@jimp/plugin-circle': 1.6.1 + '@jimp/plugin-color': 1.6.1 + '@jimp/plugin-contain': 1.6.1 + '@jimp/plugin-cover': 1.6.1 + '@jimp/plugin-crop': 1.6.1 + '@jimp/plugin-displace': 1.6.1 + '@jimp/plugin-dither': 1.6.1 + '@jimp/plugin-fisheye': 1.6.1 + '@jimp/plugin-flip': 1.6.1 + '@jimp/plugin-hash': 1.6.1 + '@jimp/plugin-mask': 1.6.1 + '@jimp/plugin-print': 1.6.1 + '@jimp/plugin-quantize': 1.6.1 + '@jimp/plugin-resize': 1.6.1 + '@jimp/plugin-rotate': 1.6.1 + '@jimp/plugin-threshold': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + transitivePeerDependencies: + - supports-color + jiti@2.6.1: {} jju@1.4.0: {} @@ -11721,6 +12228,8 @@ snapshots: joycon@3.1.1: {} + jpeg-js@0.4.4: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -12355,6 +12864,8 @@ snapshots: mime@2.6.0: {} + mime@3.0.0: {} + mimic-fn@2.1.0: {} mimic-function@5.0.1: {} @@ -12559,6 +13070,8 @@ snapshots: obug@2.1.1: {} + omggif@1.0.10: {} + on-exit-leak-free@2.1.2: {} on-finished@2.4.1: @@ -12696,6 +13209,17 @@ snapshots: package-manager-detector@1.6.0: {} + pako@1.0.11: {} + + parse-bmfont-ascii@1.0.6: {} + + parse-bmfont-binary@1.0.6: {} + + parse-bmfont-xml@1.1.6: + dependencies: + xml-parse-from-string: 1.0.1 + xml2js: 0.5.0 + parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -12790,6 +13314,10 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 3.2.0 + pixelmatch@5.3.0: + dependencies: + pngjs: 6.0.0 + pkce-challenge@5.0.1: {} pkg-pr-new@0.0.75: {} @@ -12800,6 +13328,10 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + pngjs@6.0.0: {} + + pngjs@7.0.0: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -13123,31 +13655,6 @@ snapshots: transitivePeerDependencies: - oxc-resolver - rolldown@1.0.0-rc.12: - dependencies: - '@oxc-project/types': 0.122.0 - '@rolldown/pluginutils': 1.0.0-rc.12 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.12 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 - '@rolldown/binding-darwin-x64': 1.0.0-rc.12 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - optional: true - rolldown@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): dependencies: '@oxc-project/types': 0.122.0 @@ -13515,6 +14022,8 @@ snapshots: dependencies: semver: 7.7.4 + simple-xml-to-json@1.2.7: {} + sinon@22.0.0: dependencies: '@sinonjs/commons': 3.0.1 @@ -13713,6 +14222,10 @@ snapshots: strip-json-comments@5.0.3: {} + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + stylis@4.4.0: {} sumchecker@3.0.1: @@ -13795,6 +14308,8 @@ snapshots: tinybench@2.9.0: {} + tinycolor2@1.6.0: {} + tinyexec@1.1.1: {} tinyexec@1.1.2: {} @@ -13828,6 +14343,12 @@ snapshots: toidentifier@1.0.1: {} + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + tokenx@1.3.0: {} tough-cookie@5.1.2: @@ -13885,35 +14406,6 @@ snapshots: - oxc-resolver - vue-tsc - tsdown@0.22.0(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.21.0)(typescript@6.0.2)(unrun@0.2.34)(vue-tsc@3.2.9(typescript@6.0.2)): - dependencies: - ansis: 4.2.0 - cac: 7.0.0 - defu: 6.1.7 - empathic: 2.0.0 - hookable: 6.1.1 - import-without-cache: 0.4.0 - obug: 2.1.1 - picomatch: 4.0.4 - rolldown: 1.0.1 - rolldown-plugin-dts: 0.25.1(rolldown@1.0.1)(typescript@6.0.2)(vue-tsc@3.2.9(typescript@6.0.2)) - semver: 7.7.4 - tinyexec: 1.1.2 - tinyglobby: 0.2.16 - tree-kill: 1.2.2 - unconfig-core: 7.5.0 - optionalDependencies: - '@arethetypeswrong/core': 0.18.2 - publint: 0.3.18 - tsx: 4.21.0 - typescript: 6.0.2 - unrun: 0.2.34 - transitivePeerDependencies: - - '@ts-macro/tsc' - - '@typescript/native-preview' - - oxc-resolver - - vue-tsc - tslib@2.8.1: {} tsx@4.21.0: @@ -13981,6 +14473,8 @@ snapshots: uhyphen@0.2.0: {} + uint8array-extras@1.5.0: {} + ulid@3.0.2: {} unbox-primitive@1.1.0: @@ -14056,14 +14550,6 @@ snapshots: unpipe@1.0.0: {} - unrun@0.2.34: - dependencies: - rolldown: 1.0.0-rc.12 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - optional: true - unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): dependencies: rolldown: 1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) @@ -14084,6 +14570,10 @@ snapshots: utf8-byte-length@1.0.5: {} + utif2@4.1.0: + dependencies: + pako: 1.0.11 + util-deprecate@1.0.2: {} uuid@14.0.0: {} @@ -14434,6 +14924,15 @@ snapshots: xml-name-validator@5.0.0: optional: true + xml-parse-from-string@1.0.1: {} + + xml2js@0.5.0: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + xmlbuilder@15.1.1: {} xmlchars@2.2.0: @@ -14499,6 +14998,8 @@ snapshots: dependencies: zod: 4.3.6 + zod@3.25.76: {} + zod@4.3.6: {} zwitch@2.0.4: {} From 2d8a145305c0bf82579c58480549e90980f0932c Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 1 Jul 2026 12:28:39 +0800 Subject: [PATCH 2/9] fix(agent-core): serialize prompt/steer RPCs to avoid a turn-claim race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt/steer RPC handlers await image compression before turn.launch() synchronously claims the active turn, so two overlapping calls could both compress first — letting the faster-to-compress one win the turn and strand the other on agent_busy. Run these two RPCs through a per-agent serialization chain so they claim in submit order; cancel and the other RPCs stay immediate. --- packages/agent-core/src/agent/index.ts | 38 +++++++++++++++---- .../agent/prompt-image-compression.test.ts | 32 ++++++++++++++++ 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 22c0ed534..8df7f8ae5 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -93,6 +93,12 @@ export class Agent { readonly type: AgentType; private _kaos: Kaos; + // Serializes turn-claiming RPCs (prompt/steer). Their handlers `await` image + // compression before turn.launch() synchronously claims `activeTurn`; without + // this chain two overlapping calls could both compress first, letting the + // faster-to-compress one win the turn and strand the other on `agent_busy`. + private turnRpcTail: Promise = Promise.resolve(); + get kaos(): Kaos { return this._kaos; } @@ -284,22 +290,38 @@ export class Agent { return result; } + /** + * Run a turn-claiming RPC (`prompt` / `steer`) after any already-queued one, + * in invocation order, so the async compression step never interleaves with + * the synchronous turn-claim. Returns a promise for *this* call's completion + * (not the whole tail), and keeps the chain alive if an item rejects. Only + * `prompt`/`steer` join this queue — `cancel` and the rest stay immediate. + */ + private enqueueTurnRpc(work: () => Promise): Promise { + const run = this.turnRpcTail.then(work); + this.turnRpcTail = run.catch(() => undefined); + return run; + } + get rpcMethods(): PromisableMethods { return { - prompt: async (payload) => { + prompt: (payload) => // Single ingestion chokepoint: every client transport (CLI, web, // desktop, ACP, SDK) submits prompts through this RPC, so compressing // oversized images here — before the turn records or sends them — // covers them all with one hook. Best effort: originals pass through on - // failure. - this.turn.prompt(await compressImageContentParts(payload.input)); - }, + // failure. Serialized via enqueueTurnRpc so the compression `await` + // cannot race the turn-claim. + this.enqueueTurnRpc(async () => { + this.turn.prompt(await compressImageContentParts(payload.input)); + }), runShellCommand: (payload) => this.tools.runShellCommand(payload.command, payload.commandId), cancelShellCommand: (payload) => this.tools.cancelShellCommand(payload.commandId), - steer: async (payload) => { - this.telemetry.track('input_steer', { parts: payload.input.length }); - this.turn.steer(await compressImageContentParts(payload.input)); - }, + steer: (payload) => + this.enqueueTurnRpc(async () => { + this.telemetry.track('input_steer', { parts: payload.input.length }); + this.turn.steer(await compressImageContentParts(payload.input)); + }), cancel: (payload) => { if (this.turn.hasActiveTurn) { this.telemetry.track('cancel', { from: 'streaming' }); diff --git a/packages/agent-core/test/agent/prompt-image-compression.test.ts b/packages/agent-core/test/agent/prompt-image-compression.test.ts index 8c210fcc2..5f84aa12c 100644 --- a/packages/agent-core/test/agent/prompt-image-compression.test.ts +++ b/packages/agent-core/test/agent/prompt-image-compression.test.ts @@ -65,4 +65,36 @@ describe('prompt RPC image compression', () => { const image = lastUserImagePart(ctx.agent.context.history); expect(image?.imageUrl.url).toBe(url); }); + + it('serializes prompts so the first submitted claims the turn, not the fastest to compress', async () => { + const ctx = testAgent(); + ctx.configure(); + ctx.mockNextResponse({ type: 'text', text: 'ok' }); + + // ALPHA carries an oversized image (slow to compress via jimp); BRAVO is + // text-only (compresses in a single microtask). Fire ALPHA first, then + // BRAVO, without awaiting ALPHA. Without serialization BRAVO's turn.prompt + // would run first and win the turn; the RPC queue must keep submit order. + const imageUrl = await pngDataUrl(2600, 2600); + const alpha = ctx.rpc.prompt({ + input: [ + { type: 'text', text: 'ALPHA' }, + { type: 'image_url', imageUrl: { url: imageUrl } }, + ], + }); + const bravo = ctx.rpc.prompt({ input: [{ type: 'text', text: 'BRAVO' }] }); + await Promise.all([alpha, bravo]); + await ctx.untilTurnEnd(); + + const userTexts: string[] = []; + for (const message of ctx.agent.context.history) { + if (message.role !== 'user') continue; + for (const part of message.content) { + if (part.type === 'text') userTexts.push(part.text); + } + } + // ALPHA (submitted first) owns the turn; BRAVO was rejected as agent_busy. + expect(userTexts).toContain('ALPHA'); + expect(userTexts).not.toContain('BRAVO'); + }); }); From bc27ce59636289e7a5523432f5986107b5684798 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 1 Jul 2026 12:36:57 +0800 Subject: [PATCH 3/9] fix: update flake.nix pnpmDeps hash for the jimp dependency Adding jimp to the workspace changed pnpm-lock.yaml, so the pnpmDeps fixed-output hash was stale and the nix build failed. Update it to the value the CI nix build reported. --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 106d7a73d..5a5e0469d 100644 --- a/flake.nix +++ b/flake.nix @@ -152,7 +152,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-oratz8x67ZEJGTiNy+s4XaKe0TtpRKh63aIqkV79vvM="; + hash = "sha256-mqyi0VuPZwESZcdU5E8F3XUG99OH636knBfb8y6TQpw="; }; nativeBuildInputs = [ From 288c1a718bc1294906358d603c7518f005120554 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 1 Jul 2026 14:40:10 +0800 Subject: [PATCH 4/9] fix(agent-core): guard image compression against decompression bombs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tiny-byte, huge-dimension image (e.g. a solid 30000x30000 PNG) would be fully decoded into a multi-gigabyte bitmap by Jimp before any resize — an OOM vector the byte budget never catches. Skip compression when the sniffed pixel count exceeds MAX_DECODE_PIXELS (~100 MP), before the decode; oversized images pass through uncompressed as they did before compression existed. --- .../src/tools/support/image-compress.ts | 16 ++++++++++++++++ .../test/tools/image-compress.test.ts | 17 +++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/agent-core/src/tools/support/image-compress.ts b/packages/agent-core/src/tools/support/image-compress.ts index fdb719b21..18bc2f5c3 100644 --- a/packages/agent-core/src/tools/support/image-compress.ts +++ b/packages/agent-core/src/tools/support/image-compress.ts @@ -38,6 +38,18 @@ const JPEG_QUALITY_STEPS = [80, 60, 40, 20] as const; /** Last-ditch longest edge when the budget cannot be met at MAX_IMAGE_EDGE_PX. */ const FALLBACK_EDGE_PX = 1000; +/** + * Pixel-count ceiling above which we skip compression entirely. A tiny-byte, + * huge-dimension image (e.g. a solid 30000×30000 PNG) would otherwise be fully + * decoded into a multi-gigabyte bitmap by Jimp before any resize — a + * decompression-bomb OOM vector, since the byte budget alone never catches it. + * The header sniff gives us the dimensions without decoding, so we gate on them + * first. Set well above any legitimate photo/screenshot/scan (~100 MP); larger + * images pass through uncompressed, exactly as they did before compression + * existed. + */ +const MAX_DECODE_PIXELS = 100_000_000; + /** Formats we can both decode and re-encode with the default jimp build. */ const RECODABLE_MIME = new Set(['image/png', 'image/jpeg']); @@ -100,6 +112,10 @@ export async function compressImageForModel( const withinEdge = longestEdge > 0 && longestEdge <= maxEdge; if (withinBytes && (withinEdge || longestEdge === 0)) return passthrough(); + // Decompression-bomb guard: refuse to decode absurd pixel counts. The sniff + // above gave us the dimensions without decoding, so this costs nothing. + if (dims && dims.width * dims.height > MAX_DECODE_PIXELS) return passthrough(); + try { const { Jimp } = await import('jimp'); const image = await Jimp.fromBuffer(Buffer.from(bytes)); diff --git a/packages/agent-core/test/tools/image-compress.test.ts b/packages/agent-core/test/tools/image-compress.test.ts index 0c540842b..8e5d13ddb 100644 --- a/packages/agent-core/test/tools/image-compress.test.ts +++ b/packages/agent-core/test/tools/image-compress.test.ts @@ -188,6 +188,23 @@ describe('compressImageForModel — fallback', () => { expect(result.changed).toBe(false); expect(result.data).toBe(webp); }); + + it('skips compression for absurd pixel counts without decoding (bomb guard)', async () => { + // A PNG header advertising 30000×30000 (900 MP) with no pixel data. The + // dimension sniff reads the IHDR; the guard must pass through before Jimp + // is ever invoked, so this completes instantly with no multi-GB bitmap. + const header = Buffer.alloc(24); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(header, 0); + header.writeUInt32BE(13, 8); // IHDR chunk length + header.write('IHDR', 12, 'latin1'); + header.writeUInt32BE(30000, 16); + header.writeUInt32BE(30000, 20); + const bomb = new Uint8Array(header); + + const result = await compressImageForModel(bomb, 'image/png'); + expect(result.changed).toBe(false); + expect(result.data).toBe(bomb); // identity → Jimp was never called + }); }); // ── invariants ─────────────────────────────────────────────────────── From bbc783b04671a9c0abf7f8e90f7f5613bd0efadc Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 1 Jul 2026 14:54:30 +0800 Subject: [PATCH 5/9] fix(agent-core): cap decode byte size before compressing images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compression runs before downstream size caps (e.g. the 10MB MCP per-part limit), so a huge or invalid base64 image from an MCP tool was Buffer.from- decoded — and handed to Jimp — just to be dropped afterward. Add a MAX_DECODE_BYTES ceiling (64MB, overridable) checked before the base64 decode and before Jimp, the byte-side complement to the pixel-count guard; oversized payloads pass through uncompressed. --- .../src/tools/support/image-compress.ts | 31 +++++++++++++++++++ .../test/tools/image-compress.test.ts | 16 ++++++++++ 2 files changed, 47 insertions(+) diff --git a/packages/agent-core/src/tools/support/image-compress.ts b/packages/agent-core/src/tools/support/image-compress.ts index 18bc2f5c3..1300c9dda 100644 --- a/packages/agent-core/src/tools/support/image-compress.ts +++ b/packages/agent-core/src/tools/support/image-compress.ts @@ -50,6 +50,17 @@ const FALLBACK_EDGE_PX = 1000; */ const MAX_DECODE_PIXELS = 100_000_000; +/** + * Raw-byte ceiling above which compression is skipped rather than decoded. The + * byte budget bounds the *output*, but the compressor still has to load the + * *input* first: a huge base64 payload (e.g. an oversized or invalid image from + * an MCP tool) would be `Buffer.from`-decoded — and possibly handed to Jimp — + * before any downstream cap (like the 10 MB MCP per-part limit) can drop it. + * This bounds that input allocation. Set well above legitimate + * screenshots/photos; larger images pass through uncompressed. + */ +const MAX_DECODE_BYTES = 64 * 1024 * 1024; + /** Formats we can both decode and re-encode with the default jimp build. */ const RECODABLE_MIME = new Set(['image/png', 'image/jpeg']); @@ -58,6 +69,8 @@ export interface CompressImageOptions { readonly maxEdge?: number; /** Override the raw-byte budget. */ readonly byteBudget?: number; + /** Override the raw-byte ceiling above which compression is skipped. */ + readonly maxDecodeBytes?: number; } export interface CompressImageResult { @@ -89,6 +102,7 @@ export async function compressImageForModel( ): Promise { const maxEdge = options.maxEdge ?? MAX_IMAGE_EDGE_PX; const byteBudget = options.byteBudget ?? IMAGE_BYTE_BUDGET; + const maxDecodeBytes = options.maxDecodeBytes ?? MAX_DECODE_BYTES; const normalizedMime = normalizeMime(mimeType); const dims = sniffImageDimensions(bytes); @@ -115,6 +129,9 @@ export async function compressImageForModel( // Decompression-bomb guard: refuse to decode absurd pixel counts. The sniff // above gave us the dimensions without decoding, so this costs nothing. if (dims && dims.width * dims.height > MAX_DECODE_PIXELS) return passthrough(); + // Refuse to decode very large byte payloads (e.g. a huge or invalid image + // from an MCP tool) that would be loaded just to be dropped downstream. + if (bytes.length > maxDecodeBytes) return passthrough(); try { const { Jimp } = await import('jimp'); @@ -173,6 +190,20 @@ export async function compressBase64ForModel( mimeType: string, options: CompressImageOptions = {}, ): Promise { + // Skip very large payloads before allocating: base64 decodes to ~3/4 its + // length, so a payload whose decoded size would exceed the cap is passed + // through without the Buffer.from allocation (and without touching Jimp). + const maxDecodeBytes = options.maxDecodeBytes ?? MAX_DECODE_BYTES; + const approxBytes = Math.floor((base64.length * 3) / 4); + if (approxBytes > maxDecodeBytes) { + return { + base64, + mimeType, + changed: false, + originalByteLength: approxBytes, + finalByteLength: approxBytes, + }; + } let bytes: Buffer; try { bytes = Buffer.from(base64, 'base64'); diff --git a/packages/agent-core/test/tools/image-compress.test.ts b/packages/agent-core/test/tools/image-compress.test.ts index 8e5d13ddb..00dfd7c93 100644 --- a/packages/agent-core/test/tools/image-compress.test.ts +++ b/packages/agent-core/test/tools/image-compress.test.ts @@ -205,6 +205,14 @@ describe('compressImageForModel — fallback', () => { expect(result.changed).toBe(false); expect(result.data).toBe(bomb); // identity → Jimp was never called }); + + it('skips compression for payloads over the byte cap without decoding', async () => { + // Over the edge (so not the fast path), but capped by maxDecodeBytes. + const png = await solidPng(3000, 100); + const result = await compressImageForModel(png, 'image/png', { maxDecodeBytes: 64 }); + expect(result.changed).toBe(false); + expect(result.data).toBe(png); // passthrough → Jimp was never called + }); }); // ── invariants ─────────────────────────────────────────────────────── @@ -255,6 +263,14 @@ describe('compressBase64ForModel', () => { expect(result.changed).toBe(false); expect(result.base64).toBe(base64); }); + + it('skips a base64 payload over the byte cap without decoding', async () => { + const png = await solidPng(3000, 100); // over edge, would otherwise compress + const base64 = Buffer.from(png).toString('base64'); + const result = await compressBase64ForModel(base64, 'image/png', { maxDecodeBytes: 64 }); + expect(result.changed).toBe(false); + expect(result.base64).toBe(base64); // unchanged → not decoded + }); }); // ── performance ────────────────────────────────────────────────────── From 129bdebc90630eae95890b6d81b3be65f31e80d2 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 1 Jul 2026 15:36:08 +0800 Subject: [PATCH 6/9] refactor(agent-core): compress images at ingestion, not on the turn RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move image compression off the prompt/steer RPC path and back to each ingestion site (CLI paste, server upload resolution, ACP conversion; ReadMediaFile and MCP already compressed at their producers). Compressing on the RPC control path put an async step before the synchronous turn-claim, which spawned a series of races: prompt/steer interleaving, and — with a cancel arriving mid-compression — an ineffective abort that let a cancelled prompt launch anyway. Treating compression as a pure input-stage transform (done while the content part is built, before it ever enters the agent loop) removes those races structurally: rpc.prompt/steer are plain synchronous handlers again, and the serialization/cancel-window machinery is gone. Records stay compressed, resume stays consistent, and coverage degrades gracefully (a new client that skips compression just sends a larger image, as before this feature). --- .changeset/image-compression.md | 2 +- apps/kimi-code/package.json | 1 + .../src/tui/controllers/editor-keyboard.ts | 15 ++- .../editor-keyboard-image-paste.test.ts | 114 ++++++++++++++++++ packages/acp-adapter/package.json | 3 + packages/acp-adapter/src/convert.ts | 36 ++++++ packages/acp-adapter/src/session.ts | 4 +- packages/acp-adapter/test/convert.test.ts | 35 +++++- packages/agent-core/src/agent/index.ts | 42 ++----- packages/agent-core/src/index.ts | 17 +++ .../agent/prompt-image-compression.test.ts | 100 --------------- packages/node-sdk/src/index.ts | 15 +++ packages/server/package.json | 3 +- packages/server/src/routes/prompts.ts | 17 ++- packages/server/test/prompt.e2e.test.ts | 60 +++++++++ pnpm-lock.yaml | 10 ++ 16 files changed, 330 insertions(+), 144 deletions(-) create mode 100644 apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts delete mode 100644 packages/agent-core/test/agent/prompt-image-compression.test.ts diff --git a/.changeset/image-compression.md b/.changeset/image-compression.md index b1406ec95..abcfcc47c 100644 --- a/.changeset/image-compression.md +++ b/.changeset/image-compression.md @@ -3,4 +3,4 @@ "@moonshot-ai/kimi-code": minor --- -Automatically compress oversized images before they reach the model. Whatever the source — pasted into the CLI, uploaded from the web/desktop client, sent over ACP, read via `ReadMediaFile`, or returned by an MCP tool — images are downsampled (longest edge ≤ 2000px) and re-encoded to fit a per-image byte budget, cutting vision-token cost and avoiding provider image-size errors. Screenshots stay lossless PNG and only degrade to JPEG when the byte budget cannot otherwise be met. Compression is best-effort: if it fails for any reason the original image is sent unchanged. Handling is centralized in the agent core (at the prompt ingestion point and on tool results), so every client transport is covered uniformly. +Automatically compress oversized images before they reach the model. Whatever the source — pasted into the CLI, uploaded from the web/desktop client, sent over ACP, read via `ReadMediaFile`, or returned by an MCP tool — images are downsampled (longest edge ≤ 2000px) and re-encoded to fit a per-image byte budget, cutting vision-token cost and avoiding provider image-size errors. Screenshots stay lossless PNG and only degrade to JPEG when the byte budget cannot otherwise be met. Compression runs as an input-stage step at each ingestion point (while the content part is built), and guards against decompression bombs by skipping absurdly large pixel/byte payloads before decoding. Best-effort: if it fails for any reason the original image is sent unchanged. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index e81ead095..266a73347 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -94,6 +94,7 @@ "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "commander": "^13.1.0", + "jimp": "^1.6.1", "pathe": "^2.0.3", "postject": "1.0.0-alpha.6", "semver": "^7.7.4", diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index db3717f83..735fb0e0e 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -1,4 +1,5 @@ import type { Session } from '@moonshot-ai/kimi-code-sdk'; +import { compressImageForModel } from '@moonshot-ai/kimi-code-sdk'; import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image'; import { parseImageMeta } from '#/utils/image/image-mime'; @@ -360,7 +361,19 @@ export class EditorKeyboardController { const meta = parseImageMeta(media.bytes); if (meta === null) return false; - const attachment = this.imageStore.addImage(media.bytes, meta.mime, meta.width, meta.height); + // Compress at ingestion — a pure data step while building the attachment, so + // the stored bytes, the inline thumbnail, the `[image #N (W×H)]` placeholder, + // and the submitted image all agree, and the agent core only ever sees an + // already-compressed image. Best effort: originals pass through on failure. + const compressed = await compressImageForModel(media.bytes, meta.mime); + const attachment = compressed.changed + ? this.imageStore.addImage( + compressed.data, + compressed.mimeType, + compressed.width, + compressed.height, + ) + : this.imageStore.addImage(media.bytes, meta.mime, meta.width, meta.height); this.host.state.editor.insertTextAtCursor?.(`${attachment.placeholder} `); this.host.state.ui.requestRender(); this.host.track('shortcut_paste', { kind: 'image' }); diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts new file mode 100644 index 000000000..bbead65ec --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts @@ -0,0 +1,114 @@ +/** + * Clipboard image paste → attachment store, with ingestion-time compression. + * + * Tests pin: + * - an oversized pasted image is downsampled while building the attachment, + * so the stored bytes, the `[image #N (W×H)]` placeholder, and the eventual + * submitted image all agree on the compressed size + * - a within-budget paste is stored byte-for-byte (fast path) + */ + +import { Jimp } from 'jimp'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + EditorKeyboardController, + type EditorKeyboardHost, +} from '#/tui/controllers/editor-keyboard'; +import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; +import { parseImageMeta } from '#/utils/image/image-mime'; + +// vitest hoists vi.mock/vi.hoisted above the imports above, so the mock still +// applies to the editor-keyboard module that pulls in readClipboardMedia. +const { readClipboardMedia } = vi.hoisted(() => ({ readClipboardMedia: vi.fn() })); + +vi.mock('#/utils/clipboard/clipboard-image', async (importActual) => { + const actual = await importActual(); + return { ...actual, readClipboardMedia }; +}); + +interface PasteHarness { + readonly store: ImageAttachmentStore; + pasteImage(): Promise; +} + +function createPasteHarness(): PasteHarness { + const editor: Record unknown) | undefined> = {}; + const store = new ImageAttachmentStore(); + const host = { + state: { + editor, + activeDialog: null, + appState: { streamingPhase: 'idle', isCompacting: false }, + footer: { setTransientHint: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: undefined, + btwPanelController: { closeOrCancel: vi.fn(() => false) }, + track: vi.fn(), + showError: vi.fn(), + openUndoSelector: vi.fn(), + cancelRunningShellCommand: vi.fn(), + } as unknown as EditorKeyboardHost; + + const controller = new EditorKeyboardController(host, store); + controller.install(); + + return { + store, + async pasteImage() { + const handler = editor['onPasteImage']; + if (handler === undefined) throw new Error('onPasteImage handler not installed'); + await (handler as () => Promise)(); + }, + }; +} + +async function solidPng(width: number, height: number): Promise { + return new Uint8Array( + await new Jimp({ width, height, color: 0x3366ccff }).getBuffer('image/png'), + ); +} + +describe('clipboard image paste compression', () => { + beforeEach(() => { + readClipboardMedia.mockReset(); + }); + + it('downsamples an oversized pasted image before storing it', async () => { + const big = await solidPng(2600, 2600); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + expect(store.size()).toBe(1); + const att = store.get(1); + expect(att?.kind).toBe('image'); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + + // Stored metadata reflects the compressed size. + expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(2000); + expect(att.placeholder).toContain('2000×2000'); + + // The stored bytes decode to the compressed dimensions — the thumbnail and + // the submitted image both read from these bytes, so they cannot diverge. + const dims = parseImageMeta(att.bytes); + expect(dims).not.toBeNull(); + expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(2000); + }); + + it('stores a within-budget paste byte-for-byte', async () => { + const small = await solidPng(80, 80); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: small, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.width).toBe(80); + expect(att.height).toBe(80); + expect(att.bytes).toBe(small); // identity: no re-encode on the fast path + }); +}); diff --git a/packages/acp-adapter/package.json b/packages/acp-adapter/package.json index 2614905ac..affcb8cb2 100644 --- a/packages/acp-adapter/package.json +++ b/packages/acp-adapter/package.json @@ -44,5 +44,8 @@ "@moonshot-ai/agent-core": "workspace:^", "@moonshot-ai/kaos": "workspace:^", "@moonshot-ai/kimi-code-sdk": "workspace:^" + }, + "devDependencies": { + "jimp": "^1.6.1" } } diff --git a/packages/acp-adapter/src/convert.ts b/packages/acp-adapter/src/convert.ts index 782134046..ed67f1800 100644 --- a/packages/acp-adapter/src/convert.ts +++ b/packages/acp-adapter/src/convert.ts @@ -1,6 +1,7 @@ import type { ContentBlock, ToolCallContent } from '@agentclientprotocol/sdk'; import { log, + compressBase64ForModel, type PromptPart, type ToolInputDisplay, type ToolResultEvent, @@ -71,6 +72,41 @@ export function acpBlocksToPromptParts( return out; } +/** + * Shrink oversized inline images in a prompt-part list — the ACP ingestion + * point's input-stage compression, mirroring the CLI's paste-time and the + * server's upload-time step. Best effort: a part that cannot be compressed is + * passed through unchanged. + */ +export async function compressPromptImageParts( + parts: readonly PromptPart[], +): Promise { + const out: PromptPart[] = []; + for (const part of parts) { + if (part.type === 'image_url') { + const parsed = parseImageDataUrl(part.imageUrl.url); + if (parsed !== null) { + const result = await compressBase64ForModel(parsed.base64, parsed.mimeType); + if (result.changed) { + out.push({ + type: 'image_url', + imageUrl: { ...part.imageUrl, url: `data:${result.mimeType};base64,${result.base64}` }, + }); + continue; + } + } + } + out.push(part); + } + return out; +} + +function parseImageDataUrl(url: string): { mimeType: string; base64: string } | null { + const match = /^data:([^;,]+);base64,(.*)$/s.exec(url); + if (match === null) return null; + return { mimeType: match[1]!, base64: match[2]! }; +} + /** * Minimum-viable XML-attribute escaping for prompt-embedded resource * wrappers. The output is consumed by an LLM, not parsed by a canonical diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index d317dd2d4..23624919a 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -38,7 +38,7 @@ import { } from './builtin-commands'; import { buildSessionConfigOptions } from './config-options'; import { listModelsFromHarness } from './model-catalog'; -import { acpBlocksToPromptParts } from './convert'; +import { acpBlocksToPromptParts, compressPromptImageParts } from './convert'; import { acpToolCallId, assistantDeltaToSessionUpdate, @@ -715,7 +715,7 @@ export class AcpSession { * sees a JSON-RPC error rather than a hung request. */ async prompt(blocks: readonly ContentBlock[]): Promise { - const parts = acpBlocksToPromptParts(blocks); + const parts = await compressPromptImageParts(acpBlocksToPromptParts(blocks)); const sessionId = this.id; const conn = this.conn; diff --git a/packages/acp-adapter/test/convert.test.ts b/packages/acp-adapter/test/convert.test.ts index 593dc7135..2b4e84a2e 100644 --- a/packages/acp-adapter/test/convert.test.ts +++ b/packages/acp-adapter/test/convert.test.ts @@ -1,10 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ContentBlock } from '@agentclientprotocol/sdk'; +import { Jimp } from 'jimp'; import { log, type ToolInputDisplay } from '@moonshot-ai/kimi-code-sdk'; -import { acpBlocksToPromptParts, displayBlockToAcpContent } from '../src/convert'; +import { + acpBlocksToPromptParts, + compressPromptImageParts, + displayBlockToAcpContent, +} from '../src/convert'; const textBlock = (text: string): ContentBlock => ({ type: 'text', text }); const imageBlock = (data: string, mimeType: string): ContentBlock => ({ @@ -320,3 +325,31 @@ describe('displayBlockToAcpContent — plan_review branch (Phase 13.2)', () => { expect(displayBlockToAcpContent(cmd)).toBeNull(); }); }); + +describe('compressPromptImageParts', () => { + async function pngBase64(width: number, height: number): Promise { + const buf = await new Jimp({ width, height, color: 0x3366ccff }).getBuffer('image/png'); + return Buffer.from(buf).toString('base64'); + } + + it('downsamples an oversized inline image part', async () => { + const parts = acpBlocksToPromptParts([imageBlock(await pngBase64(2600, 2600), 'image/png')]); + const compressed = await compressPromptImageParts(parts); + + const part = compressed[0]; + if (part?.type !== 'image_url') throw new Error('expected an image_url part'); + const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(part.imageUrl.url); + expect(match).not.toBeNull(); + const decoded = await Jimp.fromBuffer(Buffer.from(match![2]!, 'base64')); + expect(Math.max(decoded.width, decoded.height)).toBeLessThanOrEqual(2000); + }); + + it('passes a within-budget image and text through unchanged', async () => { + const parts = acpBlocksToPromptParts([ + imageBlock(await pngBase64(32, 32), 'image/png'), + textBlock('hi'), + ]); + const compressed = await compressPromptImageParts(parts); + expect(compressed).toEqual(parts); + }); +}); diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 8df7f8ae5..bead3466f 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -18,7 +18,6 @@ import type { PreparedSystemPromptContext, ResolvedAgentProfile } from '../profi import type { ModelProvider } from '../session/provider-manager'; import type { SessionSubagentHost } from '../session/subagent-host'; import { noopTelemetryClient, type TelemetryClient } from '../telemetry'; -import { compressImageContentParts } from '../tools/support/image-compress'; import type { PromisableMethods } from '../utils/types'; import { BackgroundManager, BackgroundTaskPersistence } from './background'; import { @@ -93,12 +92,6 @@ export class Agent { readonly type: AgentType; private _kaos: Kaos; - // Serializes turn-claiming RPCs (prompt/steer). Their handlers `await` image - // compression before turn.launch() synchronously claims `activeTurn`; without - // this chain two overlapping calls could both compress first, letting the - // faster-to-compress one win the turn and strand the other on `agent_busy`. - private turnRpcTail: Promise = Promise.resolve(); - get kaos(): Kaos { return this._kaos; } @@ -290,38 +283,17 @@ export class Agent { return result; } - /** - * Run a turn-claiming RPC (`prompt` / `steer`) after any already-queued one, - * in invocation order, so the async compression step never interleaves with - * the synchronous turn-claim. Returns a promise for *this* call's completion - * (not the whole tail), and keeps the chain alive if an item rejects. Only - * `prompt`/`steer` join this queue — `cancel` and the rest stay immediate. - */ - private enqueueTurnRpc(work: () => Promise): Promise { - const run = this.turnRpcTail.then(work); - this.turnRpcTail = run.catch(() => undefined); - return run; - } - get rpcMethods(): PromisableMethods { return { - prompt: (payload) => - // Single ingestion chokepoint: every client transport (CLI, web, - // desktop, ACP, SDK) submits prompts through this RPC, so compressing - // oversized images here — before the turn records or sends them — - // covers them all with one hook. Best effort: originals pass through on - // failure. Serialized via enqueueTurnRpc so the compression `await` - // cannot race the turn-claim. - this.enqueueTurnRpc(async () => { - this.turn.prompt(await compressImageContentParts(payload.input)); - }), + prompt: (payload) => { + this.turn.prompt(payload.input); + }, runShellCommand: (payload) => this.tools.runShellCommand(payload.command, payload.commandId), cancelShellCommand: (payload) => this.tools.cancelShellCommand(payload.commandId), - steer: (payload) => - this.enqueueTurnRpc(async () => { - this.telemetry.track('input_steer', { parts: payload.input.length }); - this.turn.steer(await compressImageContentParts(payload.input)); - }), + steer: (payload) => { + this.telemetry.track('input_steer', { parts: payload.input.length }); + this.turn.steer(payload.input); + }, cancel: (payload) => { if (this.turn.hasActiveTurn) { this.telemetry.track('cancel', { from: 'streaming' }); diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 14dcec22a..8447a137f 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -44,6 +44,23 @@ export type { QuestionBackgroundTaskInfo, } from './agent/background'; export type { ToolServices } from './tools/support/services'; + +// Image compression — the input-stage helper each ingestion site (CLI paste, +// server upload resolution, ACP, ReadMediaFile, MCP) calls to shrink oversized +// images while constructing the content part. Re-exported from the package root +// so consumers (node-sdk, server) import it without a deep subpath. +export { + compressImageForModel, + compressBase64ForModel, + compressImageContentParts, + IMAGE_BYTE_BUDGET, + MAX_IMAGE_EDGE_PX, +} from './tools/support/image-compress'; +export type { + CompressImageOptions, + CompressImageResult, + CompressBase64Result, +} from './tools/support/image-compress'; export { SingleModelProvider } from './session/provider-manager'; export type { BearerTokenProvider, diff --git a/packages/agent-core/test/agent/prompt-image-compression.test.ts b/packages/agent-core/test/agent/prompt-image-compression.test.ts deleted file mode 100644 index 5f84aa12c..000000000 --- a/packages/agent-core/test/agent/prompt-image-compression.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Prompt-RPC image compression — the single ingestion chokepoint. - * - * Every client transport (CLI, web, desktop, ACP, SDK) submits prompts through - * the `prompt` / `steer` RPC. This pins that an oversized image handed to that - * RPC is downsampled before it is recorded into history (and therefore before - * it is sent to the model), with a normal image left untouched. - */ - -import { Jimp } from 'jimp'; -import { describe, expect, it } from 'vitest'; - -import type { ContentPart } from '@moonshot-ai/kosong'; - -import { sniffImageDimensions } from '../../src/tools/support/file-type'; -import { testAgent } from './harness/agent'; - -async function pngDataUrl(width: number, height: number): Promise { - const buf = await new Jimp({ width, height, color: 0x3366ccff }).getBuffer('image/png'); - return `data:image/png;base64,${Buffer.from(buf).toString('base64')}`; -} - -function lastUserImagePart(history: readonly { role: string; content: readonly ContentPart[] }[]) { - for (let i = history.length - 1; i >= 0; i -= 1) { - const message = history[i]!; - if (message.role !== 'user') continue; - const image = message.content.find((part) => part.type === 'image_url'); - if (image?.type === 'image_url') return image; - } - return undefined; -} - -describe('prompt RPC image compression', () => { - it('downsamples an oversized image submitted through the prompt RPC', async () => { - const ctx = testAgent(); - ctx.configure(); - ctx.mockNextResponse({ type: 'text', text: 'ok' }); - - const url = await pngDataUrl(2600, 2600); - await ctx.rpc.prompt({ - input: [ - { type: 'text', text: 'what is in this image?' }, - { type: 'image_url', imageUrl: { url } }, - ], - }); - await ctx.untilTurnEnd(); - - const image = lastUserImagePart(ctx.agent.context.history); - expect(image).toBeDefined(); - const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(image!.imageUrl.url); - expect(match).not.toBeNull(); - const dims = sniffImageDimensions(Buffer.from(match![2]!, 'base64')); - expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(2000); - }); - - it('leaves a within-budget image untouched', async () => { - const ctx = testAgent(); - ctx.configure(); - ctx.mockNextResponse({ type: 'text', text: 'ok' }); - - const url = await pngDataUrl(48, 48); - await ctx.rpc.prompt({ input: [{ type: 'image_url', imageUrl: { url } }] }); - await ctx.untilTurnEnd(); - - const image = lastUserImagePart(ctx.agent.context.history); - expect(image?.imageUrl.url).toBe(url); - }); - - it('serializes prompts so the first submitted claims the turn, not the fastest to compress', async () => { - const ctx = testAgent(); - ctx.configure(); - ctx.mockNextResponse({ type: 'text', text: 'ok' }); - - // ALPHA carries an oversized image (slow to compress via jimp); BRAVO is - // text-only (compresses in a single microtask). Fire ALPHA first, then - // BRAVO, without awaiting ALPHA. Without serialization BRAVO's turn.prompt - // would run first and win the turn; the RPC queue must keep submit order. - const imageUrl = await pngDataUrl(2600, 2600); - const alpha = ctx.rpc.prompt({ - input: [ - { type: 'text', text: 'ALPHA' }, - { type: 'image_url', imageUrl: { url: imageUrl } }, - ], - }); - const bravo = ctx.rpc.prompt({ input: [{ type: 'text', text: 'BRAVO' }] }); - await Promise.all([alpha, bravo]); - await ctx.untilTurnEnd(); - - const userTexts: string[] = []; - for (const message of ctx.agent.context.history) { - if (message.role !== 'user') continue; - for (const part of message.content) { - if (part.type === 'text') userTexts.push(part.text); - } - } - // ALPHA (submitted first) owns the turn; BRAVO was rejected as agent_busy. - expect(userTexts).toContain('ALPHA'); - expect(userTexts).not.toContain('BRAVO'); - }); -}); diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 243535685..22eb0eb98 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -67,6 +67,21 @@ export { loadRuntimeConfigSafe, resolveConfigPath } from '@moonshot-ai/agent-cor // outbound fetch honors HTTP_PROXY / HTTPS_PROXY / NO_PROXY. export { installGlobalProxyDispatcher } from '@moonshot-ai/agent-core'; +// Image compression — ingestion sites (e.g. the CLI's clipboard paste, the ACP +// adapter) shrink oversized images while constructing the content part, before +// it enters a prompt. Best effort: returns the original on any failure. +export { + compressImageForModel, + compressBase64ForModel, + IMAGE_BYTE_BUDGET, + MAX_IMAGE_EDGE_PX, +} from '@moonshot-ai/agent-core'; +export type { + CompressImageOptions, + CompressImageResult, + CompressBase64Result, +} from '@moonshot-ai/agent-core'; + // Experimental feature flags — types only. Resolved values come from // `KimiHarness.getExperimentalFeatures()` over RPC, not from a re-exported runtime value. export type { diff --git a/packages/server/package.json b/packages/server/package.json index 7f0aa2088..9b42998e1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -55,6 +55,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", - "@types/ws": "^8.18.0" + "@types/ws": "^8.18.0", + "jimp": "^1.6.1" } } diff --git a/packages/server/src/routes/prompts.ts b/packages/server/src/routes/prompts.ts index 4e10d8b06..6be5633bb 100644 --- a/packages/server/src/routes/prompts.ts +++ b/packages/server/src/routes/prompts.ts @@ -12,7 +12,7 @@ import { promptSteerResultSchema, type PromptSubmission, } from '@moonshot-ai/protocol'; -import { IPromptService, AuthModelNotResolvedError, AuthProvisioningRequiredError, AuthTokenMissingError, AuthTokenUnauthorizedError, PromptAlreadyCompletedError, PromptNotFoundError, SessionBusyError, SessionNotFoundError, FileNotFoundError, IFileStore, type IInstantiationService, type GetResult } from '@moonshot-ai/agent-core'; +import { IPromptService, AuthModelNotResolvedError, AuthProvisioningRequiredError, AuthTokenMissingError, AuthTokenUnauthorizedError, PromptAlreadyCompletedError, PromptNotFoundError, SessionBusyError, SessionNotFoundError, FileNotFoundError, IFileStore, compressImageForModel, type IInstantiationService, type GetResult } from '@moonshot-ai/agent-core'; import { z } from 'zod'; @@ -261,10 +261,21 @@ async function resolvePromptMediaFiles( const file = await store.get(part.source.file_id); assertMediaFile(file, part.type); const data = await readFile(file.blobPath); + // Compress the image while inlining it into the prompt (an input-stage data + // step, before the prompt reaches the agent core). The stored file keeps its + // original bytes; only the model-facing copy is shrunk. Best effort: a + // failure leaves the original bytes. Video is never re-encoded here. + let mediaType = file.meta.media_type; + let bytes: Uint8Array = data; + if (part.type === 'image') { + const compressed = await compressImageForModel(data, mediaType); + bytes = compressed.data; + mediaType = compressed.mimeType; + } const source = { kind: 'base64' as const, - media_type: file.meta.media_type, - data: data.toString('base64'), + media_type: mediaType, + data: Buffer.from(bytes).toString('base64'), }; content.push(part.type === 'video' ? { type: 'video', source } : { type: 'image', source }); changed = true; diff --git a/packages/server/test/prompt.e2e.test.ts b/packages/server/test/prompt.e2e.test.ts index c99fb9e2f..d63159824 100644 --- a/packages/server/test/prompt.e2e.test.ts +++ b/packages/server/test/prompt.e2e.test.ts @@ -28,6 +28,7 @@ import { join } from 'node:path'; import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket } from 'ws'; +import { Jimp } from 'jimp'; import type { Event, PromptSubmission } from '@moonshot-ai/protocol'; import { IEventService, IPromptService, PromptService } from '@moonshot-ai/agent-core'; @@ -421,6 +422,65 @@ describe('POST /api/v1/sessions/{sid}/prompts — submit validation (W7.2 / Chai ]); }); + it('compresses an oversized uploaded image when resolving the prompt, leaving the stored file intact', async () => { + let submitted: PromptSubmission | undefined; + const r = await bootDaemon([ + [ + IPromptService, + createPromptServiceOverride({ + submit: async (_sid, body) => { + submitted = body; + return { + prompt_id: 'prompt_from_stub', + user_message_id: 'msg_from_stub', + status: 'running', + content: body.content, + created_at: '2026-06-09T00:00:00.000Z', + }; + }, + }), + ], + ]); + const sid = await createSession(r); + + const bigPng = Buffer.from( + await new Jimp({ width: 2600, height: 2600, color: 0x3366ccff }).getBuffer('image/png'), + ); + const upload = buildMultipart({ + file: { fieldName: 'file', filename: 'big.png', contentType: 'image/png', data: bigPng }, + }); + const uploadRes = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/files', + payload: upload.body, + headers: { 'content-type': upload.contentType }, + }); + const uploadEnv = envelopeOf<{ id: string; media_type: string; size: number }>( + uploadRes.json(), + ); + expect(uploadEnv.code).toBe(0); + // The stored file keeps its ORIGINAL bytes — only the prompt copy is shrunk. + expect(uploadEnv.data?.size).toBe(bigPng.length); + + const res = await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${sid}/prompts`, + payload: { + content: [{ type: 'image', source: { kind: 'file', file_id: uploadEnv.data!.id } }], + }, + }); + expect(envelopeOf(res.json()).code).toBe(0); + + const part = submitted?.content[0]; + if (part?.type !== 'image' || part.source.kind !== 'base64') { + throw new Error('expected a resolved base64 image part'); + } + const sentBytes = Buffer.from(part.source.data, 'base64'); + const decoded = await Jimp.fromBuffer(sentBytes); + // The model-facing copy is downsampled to the edge cap. + expect(Math.max(decoded.width, decoded.height)).toBeLessThanOrEqual(2000); + }); + it('returns 40407 when prompt image file_id is unknown', async () => { let submitted = false; const r = await bootDaemon([ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1e9adb06..62ca84c1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: commander: specifier: ^13.1.0 version: 13.1.0 + jimp: + specifier: ^1.6.1 + version: 1.6.1 pathe: specifier: ^2.0.3 version: 2.0.3 @@ -338,6 +341,10 @@ importers: '@moonshot-ai/kimi-code-sdk': specifier: workspace:^ version: link:../node-sdk + devDependencies: + jimp: + specifier: ^1.6.1 + version: 1.6.1 packages/agent-core: dependencies: @@ -620,6 +627,9 @@ importers: '@types/ws': specifier: ^8.18.0 version: 8.18.1 + jimp: + specifier: ^1.6.1 + version: 1.6.1 packages/server-e2e: dependencies: From 29ac5f7078dc5d8ac57a1c72e9d132573ed3d066 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 1 Jul 2026 17:19:59 +0800 Subject: [PATCH 7/9] fix: compress inline base64 prompts and honor ACP cancels mid-compression Two contained ingestion-site follow-ups: - server: resolvePromptMediaFiles now also compresses images submitted as an inline `{ kind: 'base64' }` source, not just uploaded files, so the REST inline-base64 path gets the same downsampling. - acp-adapter: AcpSession tracks a pending-abort flag while prompt() awaits image compression (before any turn exists). A session/cancel in that window flips it, so the prompt returns `cancelled` instead of launching a turn the client already stopped. --- packages/acp-adapter/src/session.ts | 26 +++++++++++++- packages/acp-adapter/test/cancel.test.ts | 40 +++++++++++++++++++++ packages/server/src/routes/prompts.ts | 18 +++++++++- packages/server/test/prompt.e2e.test.ts | 46 ++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 2 deletions(-) diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 23624919a..09ace0b79 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -19,6 +19,7 @@ import { type KimiErrorPayload, type KimiHarness, type McpServerInfo, + type PromptPart, type QuestionAnswers, type QuestionRequest, type Session, @@ -147,6 +148,11 @@ export class AcpSession { */ private skillCommandMap: ReadonlyMap = new Map(); + // Set while `prompt()` awaits image compression, before any turn exists. A + // `session/cancel` in that window has no turn to abort, so it flips this flag + // and `prompt()` returns `cancelled` instead of launching the turn afterward. + private pendingPromptAbort: { aborted: boolean } | undefined = undefined; + /** * The most recent command palette advertised to the ACP client. Used by * `/help` so the response matches the client's `available_commands_update` @@ -268,6 +274,11 @@ export class AcpSession { * acceptable. */ async cancel(): Promise { + // If a prompt is mid-compression (no turn yet), mark it aborted so it does + // not launch once compression finishes. + if (this.pendingPromptAbort !== undefined) { + this.pendingPromptAbort.aborted = true; + } await this.session.cancel(); } @@ -715,7 +726,20 @@ export class AcpSession { * sees a JSON-RPC error rather than a hung request. */ async prompt(blocks: readonly ContentBlock[]): Promise { - const parts = await compressPromptImageParts(acpBlocksToPromptParts(blocks)); + // Compression happens before any turn exists, so honor a `session/cancel` + // that arrives during it: flip the flag from cancel() and bail out here + // rather than launching a turn the client already asked to stop. + const pending = { aborted: false }; + this.pendingPromptAbort = pending; + let parts: readonly PromptPart[]; + try { + parts = await compressPromptImageParts(acpBlocksToPromptParts(blocks)); + } finally { + this.pendingPromptAbort = undefined; + } + if (pending.aborted) { + return { stopReason: 'cancelled' }; + } const sessionId = this.id; const conn = this.conn; diff --git a/packages/acp-adapter/test/cancel.test.ts b/packages/acp-adapter/test/cancel.test.ts index 81f9bd4fc..4d2ea49e2 100644 --- a/packages/acp-adapter/test/cancel.test.ts +++ b/packages/acp-adapter/test/cancel.test.ts @@ -14,6 +14,7 @@ import { type WriteTextFileResponse, } from '@agentclientprotocol/sdk'; import { log, type KimiHarness, type Session } from '@moonshot-ai/kimi-code-sdk'; +import { Jimp } from 'jimp'; import { AcpServer } from '../src/server'; import { AUTHED_STATUS } from './_helpers/harness-stubs'; @@ -139,4 +140,43 @@ describe('AcpServer cancel', () => { expect.objectContaining({ sessionId: 'sess-erroring' }), ); }); + + it('returns cancelled without launching when cancel arrives during image compression', async () => { + let promptCalls = 0; + const fakeSession = { + id: 'sess-cancel-compress', + prompt: async () => { + promptCalls += 1; + return undefined; + }, + cancel: async () => undefined, + onEvent: () => () => undefined, + } as unknown as Session; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => fakeSession, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const { sessionId } = await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + // A solid 2600×2600 image is small in bytes but slow enough to compress + // that the cancel below reliably lands mid-compression, before any turn. + const data = Buffer.from( + await new Jimp({ width: 2600, height: 2600, color: 0x3366ccff }).getBuffer('image/png'), + ).toString('base64'); + + const promptP = client.prompt({ + sessionId, + prompt: [{ type: 'image', data, mimeType: 'image/png' }], + }); + await client.cancel({ sessionId }); + const res = await promptP; + + expect(res.stopReason).toBe('cancelled'); + expect(promptCalls).toBe(0); // the turn was never launched + }); }); diff --git a/packages/server/src/routes/prompts.ts b/packages/server/src/routes/prompts.ts index 6be5633bb..517e806f7 100644 --- a/packages/server/src/routes/prompts.ts +++ b/packages/server/src/routes/prompts.ts @@ -12,7 +12,7 @@ import { promptSteerResultSchema, type PromptSubmission, } from '@moonshot-ai/protocol'; -import { IPromptService, AuthModelNotResolvedError, AuthProvisioningRequiredError, AuthTokenMissingError, AuthTokenUnauthorizedError, PromptAlreadyCompletedError, PromptNotFoundError, SessionBusyError, SessionNotFoundError, FileNotFoundError, IFileStore, compressImageForModel, type IInstantiationService, type GetResult } from '@moonshot-ai/agent-core'; +import { IPromptService, AuthModelNotResolvedError, AuthProvisioningRequiredError, AuthTokenMissingError, AuthTokenUnauthorizedError, PromptAlreadyCompletedError, PromptNotFoundError, SessionBusyError, SessionNotFoundError, FileNotFoundError, IFileStore, compressImageForModel, compressBase64ForModel, type IInstantiationService, type GetResult } from '@moonshot-ai/agent-core'; import { z } from 'zod'; @@ -254,6 +254,22 @@ async function resolvePromptMediaFiles( let changed = false; const content: PromptSubmission['content'] = []; for (const part of body.content) { + // Inline base64 image: compress the payload in place. This is the same + // input-stage step as the file path below, for REST clients that submit an + // image as `{ source: { kind: 'base64' } }` instead of uploading a file. + if (part.type === 'image' && part.source.kind === 'base64') { + const compressed = await compressBase64ForModel(part.source.data, part.source.media_type); + if (compressed.changed) { + content.push({ + type: 'image', + source: { kind: 'base64', media_type: compressed.mimeType, data: compressed.base64 }, + }); + changed = true; + } else { + content.push(part); + } + continue; + } if ((part.type !== 'image' && part.type !== 'video') || part.source.kind !== 'file') { content.push(part); continue; diff --git a/packages/server/test/prompt.e2e.test.ts b/packages/server/test/prompt.e2e.test.ts index d63159824..df576e51d 100644 --- a/packages/server/test/prompt.e2e.test.ts +++ b/packages/server/test/prompt.e2e.test.ts @@ -481,6 +481,52 @@ describe('POST /api/v1/sessions/{sid}/prompts — submit validation (W7.2 / Chai expect(Math.max(decoded.width, decoded.height)).toBeLessThanOrEqual(2000); }); + it('compresses an inline base64 image submitted directly in the prompt', async () => { + let submitted: PromptSubmission | undefined; + const r = await bootDaemon([ + [ + IPromptService, + createPromptServiceOverride({ + submit: async (_sid, body) => { + submitted = body; + return { + prompt_id: 'prompt_from_stub', + user_message_id: 'msg_from_stub', + status: 'running', + content: body.content, + created_at: '2026-06-09T00:00:00.000Z', + }; + }, + }), + ], + ]); + const sid = await createSession(r); + + // Solid 2600×2600: over the edge cap but tiny in bytes, so it stays well + // under Fastify's inline-JSON limit yet still benefits from downscaling. + const base64 = Buffer.from( + await new Jimp({ width: 2600, height: 2600, color: 0x3366ccff }).getBuffer('image/png'), + ).toString('base64'); + + const res = await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${sid}/prompts`, + payload: { + content: [ + { type: 'image', source: { kind: 'base64', media_type: 'image/png', data: base64 } }, + ], + }, + }); + expect(envelopeOf(res.json()).code).toBe(0); + + const part = submitted?.content[0]; + if (part?.type !== 'image' || part.source.kind !== 'base64') { + throw new Error('expected a base64 image part'); + } + const decoded = await Jimp.fromBuffer(Buffer.from(part.source.data, 'base64')); + expect(Math.max(decoded.width, decoded.height)).toBeLessThanOrEqual(2000); + }); + it('returns 40407 when prompt image file_id is unknown', async () => { let submitted = false; const r = await bootDaemon([ From fa3e3b60bd19d83a0f8ef02e0d1ecbbaee715ccc Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 1 Jul 2026 18:31:00 +0800 Subject: [PATCH 8/9] fix(acp-adapter): cover all concurrent pre-turn prompts on cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pending-abort marker was a single session field, so with two `session/prompt` requests compressing large inline images at once the later one overwrote it and a `session/cancel` could mark only one — the other launched after the client had cancelled. Track a token per in-flight prompt in a set and flip them all on cancel so every pre-turn prompt is covered. --- packages/acp-adapter/src/session.ts | 22 +++++++------- packages/acp-adapter/test/cancel.test.ts | 38 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 09ace0b79..8338c8d89 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -148,10 +148,12 @@ export class AcpSession { */ private skillCommandMap: ReadonlyMap = new Map(); - // Set while `prompt()` awaits image compression, before any turn exists. A - // `session/cancel` in that window has no turn to abort, so it flips this flag - // and `prompt()` returns `cancelled` instead of launching the turn afterward. - private pendingPromptAbort: { aborted: boolean } | undefined = undefined; + // One token per in-flight `prompt()` that is still awaiting image compression + // (before any turn exists). A `session/cancel` in that window has no turn to + // abort, so it flips every token and each affected `prompt()` returns + // `cancelled` instead of launching. A set (not a single field) so concurrent + // prompts are all covered rather than only the most recent. + private readonly pendingPromptAborts = new Set<{ aborted: boolean }>(); /** * The most recent command palette advertised to the ACP client. Used by @@ -274,10 +276,10 @@ export class AcpSession { * acceptable. */ async cancel(): Promise { - // If a prompt is mid-compression (no turn yet), mark it aborted so it does - // not launch once compression finishes. - if (this.pendingPromptAbort !== undefined) { - this.pendingPromptAbort.aborted = true; + // If any prompt is mid-compression (no turn yet), mark them aborted so they + // do not launch once compression finishes. + for (const pending of this.pendingPromptAborts) { + pending.aborted = true; } await this.session.cancel(); } @@ -730,12 +732,12 @@ export class AcpSession { // that arrives during it: flip the flag from cancel() and bail out here // rather than launching a turn the client already asked to stop. const pending = { aborted: false }; - this.pendingPromptAbort = pending; + this.pendingPromptAborts.add(pending); let parts: readonly PromptPart[]; try { parts = await compressPromptImageParts(acpBlocksToPromptParts(blocks)); } finally { - this.pendingPromptAbort = undefined; + this.pendingPromptAborts.delete(pending); } if (pending.aborted) { return { stopReason: 'cancelled' }; diff --git a/packages/acp-adapter/test/cancel.test.ts b/packages/acp-adapter/test/cancel.test.ts index 4d2ea49e2..e741db135 100644 --- a/packages/acp-adapter/test/cancel.test.ts +++ b/packages/acp-adapter/test/cancel.test.ts @@ -179,4 +179,42 @@ describe('AcpServer cancel', () => { expect(res.stopReason).toBe('cancelled'); expect(promptCalls).toBe(0); // the turn was never launched }); + + it('cancels every prompt compressing concurrently, not just the most recent', async () => { + let promptCalls = 0; + const fakeSession = { + id: 'sess-cancel-concurrent', + prompt: async () => { + promptCalls += 1; + return undefined; + }, + cancel: async () => undefined, + onEvent: () => () => undefined, + } as unknown as Session; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => fakeSession, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const { sessionId } = await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + const data = Buffer.from( + await new Jimp({ width: 2600, height: 2600, color: 0x3366ccff }).getBuffer('image/png'), + ).toString('base64'); + const imageBlock = { type: 'image' as const, data, mimeType: 'image/png' }; + + // Two prompts compressing at once; a single cancel must cover both. + const p1 = client.prompt({ sessionId, prompt: [imageBlock] }); + const p2 = client.prompt({ sessionId, prompt: [imageBlock] }); + await client.cancel({ sessionId }); + const [r1, r2] = await Promise.all([p1, p2]); + + expect(r1.stopReason).toBe('cancelled'); + expect(r2.stopReason).toBe('cancelled'); + expect(promptCalls).toBe(0); + }); }); From ec5ce7230d1ae13c98a5f977e92084aaa8ebd4f3 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 1 Jul 2026 19:09:57 +0800 Subject: [PATCH 9/9] chore(node-sdk): declare jimp as a devDependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK re-exports the image compressor, whose lazy `import('jimp')` (inside the bundled agent-core code) is inlined into the published dist. jimp was resolved only transitively via agent-core, so declare it as an explicit build input here — matching the CLI — to make the bundling reliable rather than phantom. It stays a devDependency: jimp is bundled, not a runtime dependency. --- packages/node-sdk/package.json | 3 ++- pnpm-lock.yaml | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 8c428ecb6..21cd9bddd 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -66,6 +66,7 @@ "@moonshot-ai/kaos": "workspace:^", "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/kosong": "workspace:^", - "@types/yazl": "^2.4.6" + "@types/yazl": "^2.4.6", + "jimp": "^1.6.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62ca84c1e..c31ef2eb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -559,6 +559,9 @@ importers: '@types/yazl': specifier: ^2.4.6 version: 2.4.6 + jimp: + specifier: ^1.6.1 + version: 1.6.1 packages/oauth: dependencies: