Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
f507f61
feat: engine-native image references via kimi-file:// media resolver
7Sageer Aug 4, 2026
139edd9
fix(agent-core-v2): regenerate state manifest for media resolver rename
7Sageer Aug 4, 2026
f6555ff
feat(agent-core-v2): add audio MediaKind and tag/ref fold helpers to …
7Sageer Aug 4, 2026
c858d2f
fix(agent-core-v2): synthesize image path tag when degrading bare fil…
7Sageer Aug 4, 2026
0595bef
fix(agent-core-v2): scrub dangling alias re-exports in contract type …
7Sageer Aug 4, 2026
5308bdf
feat(transcript): project paired media tag+ref as single attachments …
7Sageer Aug 4, 2026
ca8f89c
fix(kimi-code): fall back to inline image when cache write fails afte…
7Sageer Aug 4, 2026
c6bc942
fix(agent-core-v2): pair media path tags with refs by adjacency and p…
7Sageer Aug 4, 2026
ff49849
fix(kap-server): fold media tag+ref pairs out of prompt snapshot proj…
7Sageer Aug 4, 2026
eda6fb6
fix(kap-server): list attachment-only prompts as empty user messages
7Sageer Aug 4, 2026
2c17de9
fix(kap-server): keep live attachment ids across transcript overlay a…
7Sageer Aug 4, 2026
83e90ce
fix(kap-server): keep promptAttachments off the legacy session event …
7Sageer Aug 4, 2026
91950a5
fix(kap-server): inherit the backfilled turn header on mid-turn termi…
7Sageer Aug 4, 2026
73711f3
refactor(agent-core-v2): move media ref contract out of kosong into a…
7Sageer Aug 4, 2026
ffbd6ed
feat(agent-core-v2): materialize prompt media into the session media dir
7Sageer Aug 5, 2026
399992d
Merge remote-tracking branch 'origin/main' into feat/image-file-ref
7Sageer Aug 5, 2026
22adff2
fix(agent-core-v2): take prompt media intake off the enqueue critical…
7Sageer Aug 5, 2026
a915f6e
fix(agent-core-v2): record the claiming ref in the media path-tag pai…
7Sageer Aug 5, 2026
ff8fc70
fix(transcript): fold upload pairs in user-slash turns and pin pairin…
7Sageer Aug 5, 2026
a1cb833
fix(kap-server): fold upload media tags out of the search index
7Sageer Aug 5, 2026
659dc1c
feat(node-sdk): re-export the media fold helpers and cover the v1 upl…
7Sageer Aug 5, 2026
f30f857
fix(kimi-code): fold upload pairs in replay/export and keep media tag…
7Sageer Aug 5, 2026
2ab4053
fix(kimi-code): drop the steer separator before a leading media tag
7Sageer Aug 5, 2026
9e15903
fix: clean staged media lifecycle
7Sageer Aug 5, 2026
1ae75ab
refactor(agent-core-v2): narrow the mediaRef root exports and drop a …
7Sageer Aug 5, 2026
c35ff8e
fix: keep staged media alive through turn
7Sageer Aug 5, 2026
21b32f4
fix(agent-core-v2): reject non-upload ids at the session media store
7Sageer Aug 6, 2026
c6ef016
fix(kap-server): project steered prompt content without leaking daemo…
7Sageer Aug 6, 2026
1b32e35
refactor: align daemon-ref naming and drop a duplicate re-export
7Sageer Aug 6, 2026
0944975
test(agent-core-v2): pin image abort rethrow, video canonical read-th…
7Sageer Aug 6, 2026
12b6c6b
fix(kimi-code): bind goal-steer staging leases to the running turn
7Sageer Aug 6, 2026
2bdce15
fix(kap-server): project prompt.queued content without leaking daemon…
7Sageer Aug 6, 2026
8096350
fix: keep compressed uploads retrievable and close the steer abort wi…
7Sageer Aug 6, 2026
1fb7a76
Merge remote-tracking branch 'origin/main' into feat/image-file-ref
7Sageer Aug 6, 2026
0e78803
perf(agent-core-v2): memoize inlined image parts across request steps
7Sageer Aug 6, 2026
dd59466
refactor: author media tag+ref pairs in the engine prompt intake
7Sageer Aug 6, 2026
f8a09c4
fix: decouple media persistence from prompt intake
7Sageer Aug 6, 2026
0a006fd
refactor(agent-core-v2): project the turn prompt in a single fold pass
7Sageer Aug 6, 2026
813aed2
Merge remote-tracking branch 'origin/main' into feat/image-file-ref
7Sageer Aug 6, 2026
501e86f
test: slim redundant media-ref coverage across layers
7Sageer Aug 6, 2026
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
6 changes: 6 additions & 0 deletions .changeset/clean-staged-media.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": patch
"@moonshot-ai/kimi-code-sdk": patch
---

Clean up staged image and video uploads across prompt submission, cancellation, and queue cleanup paths, with daemon-side expiry and deletion support.
5 changes: 5 additions & 0 deletions .changeset/sdk-prompt-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code-sdk": patch
---

Add an optional `promptId` to session prompt submissions, echoed back on the turn-started event so callers can correlate a submission with the turn it opens. Requires the v2 harness.
5 changes: 5 additions & 0 deletions .changeset/sdk-upload-file.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code-sdk": minor
---

Add `uploadFile` to the harness plus daemon file-reference helpers, so SDK consumers can upload media to the engine's file store and reference it by path in prompts. Requires the v2 harness.
40 changes: 32 additions & 8 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,16 @@ export class CustomEditor extends Editor {
* Alt-V on Windows — Ctrl-V is terminal-reserved there). Return
* `true` to consume the key (image was read and handled); return
* `false` to let the key fall through to the normal paste path.
* The callback may be async; pi-tui awaits it before dispatching
* the next keystroke.
* The callback may be async; CustomEditor queues subsequent keystrokes until
* it settles before dispatching them.
*/
public onPasteImage?: () => Promise<boolean>;

private consumingPaste = false;
private consumeBuffer = '';
/** Serialize paste callbacks so Enter/typing cannot overtake an image paste. */
private pasteInFlight = false;
private readonly pasteInputQueue: string[] = [];
private argumentHints: ReadonlyMap<string, string> = new Map();

setArgumentHints(hints: ReadonlyMap<string, string>): void {
Expand Down Expand Up @@ -325,6 +328,15 @@ export class CustomEditor extends Editor {
return;
}

// Clipboard reads are asynchronous. Queue every key received while a
// paste callback is in flight and replay it once the callback settles
// (placeholder insert + compression + daemon upload), so Enter cannot
// submit a half-built draft.
if (this.pasteInFlight) {
this.pasteInputQueue.push(normalized);
return;
}

// Any input other than a lone Escape breaks a pending double-Esc sequence,
// so the shortcut only fires for two consecutive Escape presses.
if (!matchesKey(normalized, Key.escape)) {
Expand Down Expand Up @@ -368,17 +380,21 @@ export class CustomEditor extends Editor {
this.onTextPaste?.();
super.handleInput.call(this, normalized);
};
void handler().then(
(handled) => {
this.pasteInFlight = true;
void handler()
.then((handled) => {
if (!handled) pasteAsText();
},
() => {
})
.catch(() => {
// A rejecting image-paste handler must not leak an unhandled
// rejection (the CLI turns those into a silent exit) — treat it
// the same as "no image available" and fall back to text paste.
pasteAsText();
},
);
})
.finally(() => {
this.pasteInFlight = false;
this.flushPasteInputQueue();
});
return;
}
}
Expand Down Expand Up @@ -503,6 +519,14 @@ export class CustomEditor extends Editor {
this.reopenAutocompleteAfterInput();
}

private flushPasteInputQueue(): void {
if (this.pasteInFlight) return;
const next = this.pasteInputQueue.shift();
if (next === undefined) return;
this.handleInput(next);
if (!this.pasteInFlight) this.flushPasteInputQueue();
}

private reopenAutocompleteAfterInput(): void {
if (this.isShowingAutocomplete()) return;
const { line, col } = this.getCursor();
Expand Down
17 changes: 13 additions & 4 deletions apps/kimi-code/src/tui/controllers/cache-hint-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,18 @@ import type { AppState } from '../types';
import type { TUIState } from '../tui-state';
import { evaluateCacheHint } from '../utils/cache-hint';
import { formatErrorMessage } from '../utils/event-payload';
import type { ExtractionResult } from '../utils/image-placeholder';
import {
makeExtractionResendable,
type ExtractionResult,
} from '../utils/image-placeholder';

/** A swallowed submit: the raw text plus its media extraction (done before
* the dialog so pasted attachments survive a later store clear). */
interface StashedSubmit {
readonly text: string;
readonly extraction?: ExtractionResult;
/** Session that owned any daemon refs inside {@link extraction}. */
readonly sessionId: string;
}

export interface CacheHintHost {
Expand Down Expand Up @@ -253,7 +258,7 @@ export class CacheHintController {
// Coarse floor: configured cache durations are 10min+, so anything
// fresher than a minute can never hint.
if (Date.now() - this.lastActivityAt < 60_000) return false;
const stash: StashedSubmit = { text, extraction };
const stash: StashedSubmit = { text, extraction, sessionId: host.session.id };
const cached = peekCacheHintConfig();
if (cached !== undefined) {
const decision = evaluateCacheHint({
Expand Down Expand Up @@ -342,7 +347,11 @@ export class CacheHintController {
private async releaseStashed(stash: StashedSubmit): Promise<void> {
this.releasingStashed = true;
try {
await this.host.sendNormalUserInput(stash.text, stash.extraction);
const extraction =
stash.extraction !== undefined && this.host.state.appState.sessionId !== stash.sessionId
? makeExtractionResendable(stash.extraction)
: stash.extraction;
await this.host.sendNormalUserInput(stash.text, extraction);
} finally {
this.releasingStashed = false;
}
Expand Down Expand Up @@ -470,7 +479,7 @@ export class CacheHintController {
break;
}
this.lastDialogRestored = false;
if (stashed !== undefined) await host.sendNormalUserInput(stashed.text, stashed.extraction);
if (stashed !== undefined) await this.releaseStashed(stashed);
}

/** Bounded wait for the engine to flip `isCompacting` after a compact RPC. */
Expand Down
164 changes: 129 additions & 35 deletions apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { unlink } from 'node:fs/promises';

import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
import { compressImageForModel, persistOriginalImage, sessionMediaOriginalsDir } from '@moonshot-ai/kimi-code-sdk';

Expand All @@ -14,15 +16,20 @@ import {
NO_ACTIVE_SESSION_MESSAGE,
} from '../constant/kimi-tui';
import { formatErrorMessage } from '../utils/event-payload';
import type { ImageAttachmentStore } from '../utils/image-attachment-store';
import { extractMediaAttachments } from '../utils/image-placeholder';
import type { ImageAttachment, ImageAttachmentStore } from '../utils/image-attachment-store';
import { extractMediaAttachments, imageExtensionForMime } from '../utils/image-placeholder';
import type { PendingExit, QueuedMessage, SteerInputItem } from '../types';
import type { TUIState } from '../tui-state';
import type { BtwPanelController } from './btw-panel';

export interface EditorKeyboardHost {
state: TUIState;
session: Session | undefined;
/**
* True when the TUI runs on the agent-core-v2 engine (startup-selected).
* Gates the paste-time upload to the daemon file store; the v1 engine has
* no file store and keeps the submit-time inline base64 form.
*/
readonly engineV2: boolean;
cancelInFlight: (() => void) | undefined;
/**
Expand All @@ -40,6 +47,7 @@ export interface EditorKeyboardHost {
imageAttachmentIds: readonly number[];
videoAttachmentIds: readonly number[];
}): boolean;
releaseStagingMedia(imageAttachmentIds: readonly number[], paths: readonly string[]): void;
recallLastQueued(): QueuedMessage | undefined;
showError(msg: string): void;
track(event: string, props?: Record<string, unknown>): void;
Expand Down Expand Up @@ -280,7 +288,12 @@ export class EditorKeyboardController {
if (trimmed.length > 0) {
// Queued items carry the parts extracted when they were submitted
// (and were already capability-validated then).
items.push({ text: trimmed, parts: m.parts, imageAttachmentIds: m.imageAttachmentIds });
items.push({
text: trimmed,
parts: m.parts,
imageAttachmentIds: m.imageAttachmentIds,
stagingPaths: m.stagingPaths,
});
}
}
let editorExtraction: ReturnType<typeof extractMediaAttachments> | undefined;
Expand All @@ -300,6 +313,7 @@ export class EditorKeyboardController {
editorExtraction.imageAttachmentIds.length > 0
? editorExtraction.imageAttachmentIds
: undefined,
stagingPaths: editorExtraction.stagingPaths,
});
}

Expand All @@ -311,16 +325,24 @@ export class EditorKeyboardController {
editorExtraction !== undefined &&
!host.validateMediaCapabilities(editorExtraction)
) {
host.releaseStagingMedia(
editorExtraction.imageAttachmentIds,
editorExtraction.stagingPaths,
);
return;
}
host.state.queuedMessages = queued.filter((m) => m.mode === 'bash');
if (!editorIsBash) editor.setText('');
const session = host.session;
if (host.state.appState.model.trim().length === 0 || session === undefined) {
host.releaseStagingMedia(
editorExtraction?.imageAttachmentIds ?? [],
editorExtraction?.stagingPaths ?? [],
);
host.showError(LLM_NOT_SET_MESSAGE);
} else {
host.steerMessage(session, items);
return;
}
host.state.queuedMessages = queued.filter((m) => m.mode === 'bash');
if (!editorIsBash) editor.setText('');
host.steerMessage(session, items);
}
host.updateQueueDisplay();
host.state.ui.requestRender();
Expand Down Expand Up @@ -459,6 +481,44 @@ export class EditorKeyboardController {

const meta = parseImageMeta(media.bytes);
if (meta === null) return false;

// Register the attachment and put its placeholder in the editor before
// doing any of the asynchronous ingestion work below. CustomEditor holds
// subsequent keystrokes while this handler is in flight, so Enter cannot
// submit a draft that is still missing the pasted image.
const attachment = 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' });

try {
await this.finishClipboardImagePaste(
attachment,
media.bytes,
meta.mime,
meta.width,
meta.height,
);
} catch (error) {
// The raw attachment and its already-visible placeholder are still a
// valid inline fallback when optional ingestion work fails.
this.host.showError(`Failed to process pasted image: ${formatErrorMessage(error)}`);
}
return true;
}

private async finishClipboardImagePaste(
attachment: ImageAttachment,
originalBytes: Uint8Array,
originalMime: string,
originalWidth: number,
originalHeight: number,
): Promise<void> {
// 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
Expand All @@ -470,7 +530,7 @@ export class EditorKeyboardController {
// The edge cap comes from the host harness's [image] config (resolved per
// paste so a config reload applies immediately); hosts without a harness
// use the env/built-in default.
const compressed = await compressImageForModel(media.bytes, meta.mime, {
const compressed = await compressImageForModel(originalBytes, originalMime, {
maxEdge: this.host.harness?.imageLimits?.maxEdgePx(),
telemetry: {
client: {
Expand All @@ -485,34 +545,68 @@ export class EditorKeyboardController {
// compressor reports display space (EXIF orientation applied) — the space
// the sent image, the caption, and ReadMediaFile region readback share —
// while parseImageMeta reads the raw pre-rotation header.
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: compressed.originalWidth,
height: compressed.originalHeight,
byteLength: media.bytes.length,
mime: meta.mime,
},
)
: this.imageStore.addImage(
media.bytes,
meta.mime,
compressed.width || meta.width,
compressed.height || meta.height,
);
this.host.state.editor.insertTextAtCursor?.(`${attachment.placeholder} `);
// Persist the original BEFORE minting a daemon upload: when persistence
// fails the whole ingestion is abandoned, and an upload minted earlier
// would be orphaned (never attached, never deleted).
const original = compressed.changed
? {
path: await persistOriginalImage(
originalBytes,
originalMime,
sessionDir === undefined ? {} : { dir: sessionMediaOriginalsDir(sessionDir) },
),
width: compressed.originalWidth,
height: compressed.originalHeight,
byteLength: originalBytes.length,
mime: originalMime,
}
: undefined;
// v2 only: upload the final bytes to the daemon file store so submit-time
// expansion emits a `kimi-file://` reference instead of inline base64.
const fileId = await this.uploadImageToDaemonFileStore(
compressed.changed ? compressed.data : originalBytes,
compressed.changed ? compressed.mimeType : originalMime,
);
const completed = this.imageStore.completeImage(attachment, {
bytes: compressed.changed ? compressed.data : originalBytes,
mime: compressed.changed ? compressed.mimeType : originalMime,
width: compressed.width || originalWidth,
height: compressed.height || originalHeight,
original,
fileId,
});
if (completed === undefined && fileId !== undefined) {
await this.host.harness?.deleteFile(fileId).catch(() => undefined);
}
if (completed === undefined && original !== undefined && original.path !== null) {
await unlink(original.path).catch(() => undefined);
}
this.host.state.ui.requestRender();
this.host.track('shortcut_paste', { kind: 'image' });
return true;
}

/**
* Paste-time upload of the final image bytes to the engine's daemon file
* store (agent-core-v2 only). Best effort: any failure returns undefined,
* so the attachment keeps no `fileId` and submit-time expansion falls back
* to the inline base64 form — the paste never blocks on the upload.
*/
private async uploadImageToDaemonFileStore(
bytes: Uint8Array,
mime: string,
): Promise<string | undefined> {
if (this.host.engineV2 !== true) return undefined;
const harness = this.host.harness;
if (harness === undefined) return undefined;
try {
const meta = await harness.uploadFile(bytes, {
name: `pasted-image.${imageExtensionForMime(mime)}`,
mimeType: mime,
expiresInSec: 60 * 60,
});
return meta.id;
} catch {
return undefined;
}
}

private async openExternalEditor(): Promise<void> {
Expand Down
Loading