Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/announce-image-compression-readback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include the SDK in the release changeset

This PR adds new public exports from @moonshot-ai/kimi-code-sdk (buildImageCompressionCaption, persistOriginalImage, sessionMediaOriginalsDir, and the caption input type), but the changeset only selects the CLI package. The repo's changeset rules require selecting @moonshot-ai/kimi-code-sdk when an internal capability is exposed through the SDK; otherwise the next release will not version/changelog/publish the SDK package containing these new symbols for npm consumers.

Useful? React with 👍 / 👎.

---

Never compress images silently. Every image ingestion point (ReadMediaFile, MCP tool results, clipboard paste, REST upload/inline base64, ACP) now places a caption next to a compressed image stating the original vs. delivered dimensions, byte size, and format, and preserves the original bytes for readback: uploads point at the stored file, and in-memory images are saved into the session's `media-originals` directory (size-capped, removed with the session; a shared temp-dir cache is the fallback when no session is known). ReadMediaFile gains a `region` parameter (crop in original-image pixel coordinates, delivered at full fidelity) and a `full_resolution` flag (skip downscaling, with an explicit error when the file exceeds the per-image byte limit), so the model can zoom into fine detail instead of degrading silently.
18 changes: 17 additions & 1 deletion apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Session } from '@moonshot-ai/kimi-code-sdk';
import { compressImageForModel } from '@moonshot-ai/kimi-code-sdk';
import { compressImageForModel, persistOriginalImage, sessionMediaOriginalsDir } from '@moonshot-ai/kimi-code-sdk';

import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image';
import { parseImageMeta } from '#/utils/image/image-mime';
Expand Down Expand Up @@ -407,13 +407,29 @@ export class EditorKeyboardController {
// 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.
// When compression changed the bytes, the original is persisted (into the
// session's media-originals dir when known, else the temp-dir fallback)
// and recorded on the attachment, so submit-time expansion can announce
// the compression and point the model at the full-fidelity copy.
const compressed = await compressImageForModel(media.bytes, meta.mime);
const sessionDir = this.host.session?.summary?.sessionDir;
const attachment = compressed.changed
? this.imageStore.addImage(
compressed.data,
compressed.mimeType,
compressed.width,
compressed.height,
{
path: await persistOriginalImage(
media.bytes,
meta.mime,
sessionDir === undefined ? {} : { dir: sessionMediaOriginalsDir(sessionDir) },
),
width: meta.width,
height: meta.height,
byteLength: media.bytes.length,
mime: meta.mime,
},
)
: this.imageStore.addImage(media.bytes, meta.mime, meta.width, meta.height);
this.host.state.editor.insertTextAtCursor?.(`${attachment.placeholder} `);
Expand Down
31 changes: 29 additions & 2 deletions apps/kimi-code/src/tui/utils/image-attachment-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
* (640×480)]` / `[video #2 sample.mov]`). The placeholder is what the
* user sees in the input field; on submit, `extractMediaAttachments`
* walks the text and expands image placeholders to image content parts
* and video placeholders to file-path tags for `ReadMediaFile`.
* (preceded by a compression caption when paste-time compression shrank
* the bytes — see `ImageAttachment.original`) and video placeholders to
* file-path tags for `ReadMediaFile`.
*
* Scope is per-`KimiTUI` instance. Reloads (`/new`, `/clear`,
* session switch) call `clear()` so ids restart from 1 and stale
Expand All @@ -15,13 +17,31 @@
* `--resume` wouldn't know how to materialize the files anyway.
*/

export interface ImageAttachmentOriginal {
/**
* Where the pre-compression bytes were persisted for readback
* (ReadMediaFile + region); null when persistence failed.
*/
readonly path: string | null;
readonly width: number;
readonly height: number;
readonly byteLength: number;
readonly mime: string;
}

export interface ImageAttachment {
readonly id: number;
readonly kind: 'image';
readonly bytes: Uint8Array;
readonly mime: string;
readonly width: number;
readonly height: number;
/**
* Pre-compression original, recorded when paste-time compression changed
* the bytes. Drives the compression caption emitted on submit so the model
* knows it received a downsampled copy. Absent for untouched pastes.
*/
readonly original?: ImageAttachmentOriginal | undefined;
/** Rendered placeholder string, e.g. `[image #1 (640×480)]`. */
readonly placeholder: string;
}
Expand All @@ -43,7 +63,13 @@ export class ImageAttachmentStore {
private nextId = 1;
private readonly byId = new Map<number, MediaAttachment>();

addImage(bytes: Uint8Array, mime: string, width: number, height: number): ImageAttachment {
addImage(
bytes: Uint8Array,
mime: string,
width: number,
height: number,
original?: ImageAttachmentOriginal,
): ImageAttachment {
const id = this.nextId;
this.nextId += 1;
const attachment: ImageAttachment = {
Expand All @@ -53,6 +79,7 @@ export class ImageAttachmentStore {
mime,
width,
height,
original,
placeholder: formatPlaceholder(id, width, height),
};
this.byId.set(id, attachment);
Expand Down
26 changes: 26 additions & 0 deletions apps/kimi-code/src/tui/utils/image-placeholder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
*/

import type { PromptPart } from '@moonshot-ai/kimi-code-sdk';
import { buildImageCompressionCaption } from '@moonshot-ai/kimi-code-sdk';

import type {
ImageAttachment,
Expand Down Expand Up @@ -66,6 +67,11 @@ export function extractMediaAttachments(
pushText(parts, mediaText);
videoAttachmentIds.push(id);
} else {
// Paste-time compression is announced next to the image so the model
// knows it received a downsampled copy and where the original lives.
if (attachment.original !== undefined) {
pushText(parts, captionForCompressedImage(attachment));
}
parts.push(imagePartForAttachment(attachment));
imageAttachmentIds.push(id);
}
Expand Down Expand Up @@ -109,6 +115,26 @@ function imagePartForAttachment(att: ImageAttachment): PromptPart {
};
}

function captionForCompressedImage(att: ImageAttachment): string {
const original = att.original;
if (original === undefined) return '';
return buildImageCompressionCaption({
original: {
width: original.width,
height: original.height,
byteLength: original.byteLength,
mimeType: original.mime,
},
final: {
width: att.width,
height: att.height,
byteLength: att.bytes.length,
mimeType: att.mime,
},
originalPath: original.path,
});
}

function tagTextForVideo(att: VideoAttachment): string {
return formatMediaTag('video', att.sourcePath);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@
* - 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)
* - the pre-compression original is persisted and recorded on the
* attachment, so the submitted prompt can announce the compression and
* point the model at the full-fidelity bytes
* - a within-budget paste is stored byte-for-byte (fast path), with no
* original recorded
*/

import { mkdtemp, readFile, rm, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { Jimp } from 'jimp';
import { beforeEach, describe, expect, it, vi } from 'vitest';

Expand All @@ -32,7 +40,7 @@ interface PasteHarness {
pasteImage(): Promise<void>;
}

function createPasteHarness(): PasteHarness {
function createPasteHarness(options: { sessionDir?: string } = {}): PasteHarness {
const editor: Record<string, ((...args: never[]) => unknown) | undefined> = {
setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown,
};
Expand All @@ -45,7 +53,10 @@ function createPasteHarness(): PasteHarness {
footer: { setTransientHint: vi.fn() },
ui: { requestRender: vi.fn() },
},
session: undefined,
session:
options.sessionDir === undefined
? undefined
: { summary: { sessionDir: options.sessionDir } },
btwPanelController: { closeOrCancel: vi.fn(() => false) },
track: vi.fn(),
showError: vi.fn(),
Expand Down Expand Up @@ -100,6 +111,45 @@ describe('clipboard image paste compression', () => {
expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(2000);
});

it('records and persists the pre-compression original for an oversized paste', async () => {
const big = await solidPng(2600, 2600);
readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, 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.original).toBeDefined();
expect(att.original?.width).toBe(2600);
expect(att.original?.height).toBe(2600);
expect(att.original?.byteLength).toBe(big.length);
expect(att.original?.mime).toBe('image/png');

// The original bytes are readable back from the persisted path.
expect(att.original?.path).not.toBeNull();
const persisted = await readFile(att.original!.path!);
expect(new Uint8Array(persisted)).toEqual(big);
await unlink(att.original!.path!).catch(() => undefined);
});

it('persists the original into the session media-originals dir when the session is known', async () => {
const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-paste-session-'));
const big = await solidPng(2600, 2600);
readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' });

const { store, pasteImage } = createPasteHarness({ sessionDir });
await pasteImage();

const att = store.get(1);
if (att?.kind !== 'image') throw new Error('expected image attachment');
expect(att.original?.path).not.toBeNull();
expect(att.original!.path!.startsWith(join(sessionDir, 'media-originals'))).toBe(true);
const persisted = await readFile(att.original!.path!);
expect(new Uint8Array(persisted)).toEqual(big);
await rm(sessionDir, { recursive: true, force: true });
});

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' });
Expand All @@ -112,5 +162,6 @@ describe('clipboard image paste compression', () => {
expect(att.width).toBe(80);
expect(att.height).toBe(80);
expect(att.bytes).toBe(small); // identity: no re-encode on the fast path
expect(att.original).toBeUndefined();
});
});
48 changes: 48 additions & 0 deletions apps/kimi-code/test/tui/input/image-placeholder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,52 @@ describe('extractMediaAttachments', () => {
expect(r.videoAttachmentIds).toEqual([1]);
expect(r.parts).toEqual([{ type: 'text', text: '<video path="/tmp/sample.mp4"></video>' }]);
});

it('inserts a compression caption before an image that was compressed at paste time', () => {
const store = new ImageAttachmentStore();
const att = store.addImage(new Uint8Array([1, 2, 3]), 'image/png', 2000, 2000, {
path: '/tmp/kimi-code-original-images/abc.png',
width: 2600,
height: 2600,
byteLength: 123456,
mime: 'image/png',
});

const r = extractMediaAttachments(`look ${att.placeholder}`, store);

expect(r.parts).toHaveLength(2);
const caption = r.parts[0];
if (caption?.type !== 'text') throw new Error('expected leading text part');
expect(caption.text).toContain('Image compressed');
expect(caption.text).toContain('2600x2600');
expect(caption.text).toContain('/tmp/kimi-code-original-images/abc.png');
expect(r.parts[1]).toEqual({
type: 'image_url',
imageUrl: { url: 'data:image/png;base64,AQID' },
});
});

it('notes an unpreserved original when persistence failed at paste time', () => {
const store = new ImageAttachmentStore();
const att = store.addImage(new Uint8Array([1]), 'image/png', 2000, 2000, {
path: null,
width: 2600,
height: 2600,
byteLength: 123456,
mime: 'image/png',
});

const r = extractMediaAttachments(att.placeholder, store);

const caption = r.parts[0];
if (caption?.type !== 'text') throw new Error('expected leading text part');
expect(caption.text).toMatch(/not preserved/i);
});

it('adds no caption for an uncompressed image attachment', () => {
const { store, placeholder } = storeWith(new Uint8Array([0xaa]));
const r = extractMediaAttachments(placeholder, store);
expect(r.parts).toHaveLength(1);
expect(r.parts[0]?.type).toBe('image_url');
});
});
32 changes: 32 additions & 0 deletions packages/acp-adapter/src/convert.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { ContentBlock, ToolCallContent } from '@agentclientprotocol/sdk';
import {
log,
buildImageCompressionCaption,
compressBase64ForModel,
persistOriginalImage,
type PromptPart,
type ToolInputDisplay,
type ToolResultEvent,
Expand Down Expand Up @@ -77,9 +79,16 @@ export function acpBlocksToPromptParts(
* 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.
*
* Compression is never silent: a re-encoded image gains a caption text part
* immediately before it stating what the original was, and the original bytes
* are persisted (into `originalsDir` — typically the session's
* media-originals dir — or the shared temp-dir fallback) so the model can
* read fine detail back via ReadMediaFile + region.
*/
export async function compressPromptImageParts(
parts: readonly PromptPart[],
options: { readonly originalsDir?: string | undefined } = {},
): Promise<PromptPart[]> {
const out: PromptPart[] = [];
for (const part of parts) {
Expand All @@ -88,6 +97,29 @@ export async function compressPromptImageParts(
if (parsed !== null) {
const result = await compressBase64ForModel(parsed.base64, parsed.mimeType);
if (result.changed) {
const originalPath = await persistOriginalImage(
Buffer.from(parsed.base64, 'base64'),
parsed.mimeType,
options.originalsDir === undefined ? {} : { dir: options.originalsDir },
);
out.push({
type: 'text',
text: buildImageCompressionCaption({
original: {
width: result.originalWidth,
height: result.originalHeight,
byteLength: result.originalByteLength,
mimeType: parsed.mimeType,
},
final: {
width: result.width,
height: result.height,
byteLength: result.finalByteLength,
mimeType: result.mimeType,
},
originalPath,
}),
});
out.push({
type: 'image_url',
imageUrl: { ...part.imageUrl, url: `data:${result.mimeType};base64,${result.base64}` },
Expand Down
7 changes: 6 additions & 1 deletion packages/acp-adapter/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import {
ErrorCodes,
log,
sessionMediaOriginalsDir,
type ApprovalRequest,
type ApprovalResponse,
type BackgroundTaskInfo,
Expand Down Expand Up @@ -735,7 +736,11 @@ export class AcpSession {
this.pendingPromptAborts.add(pending);
let parts: readonly PromptPart[];
try {
parts = await compressPromptImageParts(acpBlocksToPromptParts(blocks));
const sessionDir = this.session.summary?.sessionDir;
parts = await compressPromptImageParts(acpBlocksToPromptParts(blocks), {
originalsDir:
sessionDir === undefined ? undefined : sessionMediaOriginalsDir(sessionDir),
});
} finally {
this.pendingPromptAborts.delete(pending);
}
Expand Down
Loading
Loading