diff --git a/.changeset/clean-staged-media.md b/.changeset/clean-staged-media.md new file mode 100644 index 0000000000..d6857e0d65 --- /dev/null +++ b/.changeset/clean-staged-media.md @@ -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. diff --git a/.changeset/sdk-prompt-id.md b/.changeset/sdk-prompt-id.md new file mode 100644 index 0000000000..701129802c --- /dev/null +++ b/.changeset/sdk-prompt-id.md @@ -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. diff --git a/.changeset/sdk-upload-file.md b/.changeset/sdk-upload-file.md new file mode 100644 index 0000000000..aa81bf54f4 --- /dev/null +++ b/.changeset/sdk-upload-file.md @@ -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. diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 51958dbe96..9c7b8f6002 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -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; 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 = new Map(); setArgumentHints(hints: ReadonlyMap): void { @@ -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)) { @@ -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; } } @@ -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(); diff --git a/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts b/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts index 4a1aa4626c..d8104febe8 100644 --- a/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts +++ b/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts @@ -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 { @@ -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({ @@ -342,7 +347,11 @@ export class CacheHintController { private async releaseStashed(stash: StashedSubmit): Promise { 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; } @@ -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. */ diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 55df80609b..9eebe4348f 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -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'; @@ -14,8 +16,8 @@ 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'; @@ -23,6 +25,11 @@ 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; /** @@ -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): void; @@ -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 | undefined; @@ -300,6 +313,7 @@ export class EditorKeyboardController { editorExtraction.imageAttachmentIds.length > 0 ? editorExtraction.imageAttachmentIds : undefined, + stagingPaths: editorExtraction.stagingPaths, }); } @@ -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(); @@ -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 { // 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 @@ -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: { @@ -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 { + 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 { diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 0eff25feb1..3286bcab6d 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -118,6 +118,8 @@ export interface SessionEventHost { updateTerminalTitle(): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; shiftQueuedMessage(): QueuedMessage | undefined; + handleTurnStarted?(event: TurnStartedEvent): void; + handleTurnEnded?(event: TurnEndedEvent): void; readonly btwPanelController: BtwPanelController; readonly tasksBrowserController: TasksBrowserController; } @@ -316,6 +318,7 @@ export class SessionEventHandler { // --------------------------------------------------------------------------- private handleTurnBegin(event: TurnStartedEvent): void { + this.host.handleTurnStarted?.(event); this.currentTurnHasAssistantText = false; if (event.origin?.kind === 'plugin_command') { this.pluginCommandTurns.set(String(event.turnId), event.origin.pluginId); @@ -353,6 +356,7 @@ export class SessionEventHandler { } private handleTurnEnd(event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void { + this.host.handleTurnEnded?.(event); this.host.streamingUI.flushNow(); if (event.reason === 'cancelled') { this.markActiveAgentSwarmsCancelled(); diff --git a/apps/kimi-code/src/tui/controllers/staging-leases.ts b/apps/kimi-code/src/tui/controllers/staging-leases.ts new file mode 100644 index 0000000000..8fceadbc81 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/staging-leases.ts @@ -0,0 +1,262 @@ +/** + * `StagingLeaseTracker` — owns the lifecycle of staged prompt media (daemon + * uploads + local cache copies) between submission and the session that + * consumes it. + * + * A paste/upload edge stages media before the prompt exists. The two staged + * forms age differently once the consuming turn ends: + * + * - Daemon uploads become garbage — the engine materialized its own session + * copy at intake — so the turn-end release deletes them. + * - Local cache copies may still be referenced by persisted history: a v1 + * video degrade writes its `