diff --git a/.changeset/clean-staged-media.md b/.changeset/clean-staged-media.md new file mode 100644 index 00000000000..375c6eff13a --- /dev/null +++ b/.changeset/clean-staged-media.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep pasted image and video attachments available in session history, and clean up temporary uploads automatically. diff --git a/.changeset/daemon-file-ref-drop-path.md b/.changeset/daemon-file-ref-drop-path.md new file mode 100644 index 00000000000..6f23742ef9c --- /dev/null +++ b/.changeset/daemon-file-ref-drop-path.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": minor +--- + +Daemon file references no longer persist a materialization path: the daemon-file URL builder takes only a file id, and the parsed reference no longer carries a `path` field. The display path is derived from the session media store at read time, so a session fork or home relocation can no longer stale a persisted reference. Urls with a legacy `?path=` query still parse. diff --git a/.changeset/sdk-upload-file.md b/.changeset/sdk-upload-file.md new file mode 100644 index 00000000000..c00f4dd97ca --- /dev/null +++ b/.changeset/sdk-upload-file.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": minor +--- + +Add `uploadFile` for uploading media to the engine's file store and referencing it from prompts, plus an optional `promptId` on prompt submissions for correlating them with turn-started events. Both require 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 21a272ef426..2a280209325 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -155,13 +155,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(); private skillCommandNames: ReadonlySet = new Set(); @@ -363,6 +366,16 @@ 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 + // (clipboard read + placeholder insert — compression and the daemon + // upload continue in the background off this path), so Enter cannot + // submit a draft that is still missing the pasted image. + 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)) { @@ -406,17 +419,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; } } @@ -541,6 +558,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/constant/media.ts b/apps/kimi-code/src/tui/constant/media.ts new file mode 100644 index 00000000000..d125258a913 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/media.ts @@ -0,0 +1,6 @@ +/** TUI-only daemon staging lifetimes for pasted media. */ + +export const IMAGE_STAGING_TTL_SECONDS = 60 * 60; +export const IMAGE_FILE_REF_MIN_REMAINING_MS = 60_000; +/** How long submit waits for a just-pasted image's background ingestion before falling back to the inline form. */ +export const IMAGE_INGESTION_SUBMIT_WAIT_MS = 2_000; 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 6926baa7551..532f291a56c 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, InlineSkillActivation } 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; readonly inlineSkillActivations?: readonly InlineSkillActivation[]; } @@ -41,6 +46,12 @@ export interface CacheHintHost { mountEditorReplacement(panel: Component & Focusable): void; restoreEditor(): void; restoreInputText(text: string): void; + /** + * A stashed submission going back to the editor releases its extraction's + * staged media with queue-recall semantics (consume retains, retire staged + * copies, rebase videos) — without this the retains/copies would leak. + */ + recallStashedMedia(text: string, extraction: ExtractionResult | undefined): void; showError(message: string): void; createNewSession(): Promise; sendNormalUserInput(text: string, preExtracted?: ExtractionResult): Promise; @@ -263,7 +274,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, inlineSkillActivations }; + const stash: StashedSubmit = { text, extraction, sessionId: host.session.id, inlineSkillActivations }; const cached = peekCacheHintConfig(); if (cached !== undefined) { const decision = evaluateCacheHint({ @@ -305,7 +316,7 @@ export class CacheHintController { // would reorder the conversation. if (this.idlePrompted) { if (this.lastDialogRestored) { - this.restoreStashedInput(stash.text); + this.restoreStashedInput(stash); } else { await this.releaseStashed(stash); } @@ -316,7 +327,7 @@ export class CacheHintController { // meanwhile, never send the stashed text into the wrong session — hand it // back to the editor instead. if (host.session?.id !== sessionId) { - this.restoreStashedInput(stash.text); + this.restoreStashedInput(stash); return; } // If a foreground operation (turn, /compact, …) started meanwhile, don't @@ -359,23 +370,27 @@ export class CacheHintController { } private async releaseToSendPath(stash: StashedSubmit): Promise { + const extraction = + stash.extraction !== undefined && this.host.state.appState.sessionId !== stash.sessionId + ? makeExtractionResendable(stash.extraction) + : stash.extraction; if (stash.inlineSkillActivations !== undefined && stash.inlineSkillActivations.length > 0) { - await this.host.sendInlineSkillUserInput( - stash.text, - stash.inlineSkillActivations, - stash.extraction, - ); + await this.host.sendInlineSkillUserInput(stash.text, stash.inlineSkillActivations, extraction); return; } - await this.host.sendNormalUserInput(stash.text, stash.extraction); + await this.host.sendNormalUserInput(stash.text, extraction); } /** Restore a stashed input to the editor, appending to anything already - * restored this cycle so earlier text is not overwritten. */ - private restoreStashedInput(text: string | undefined): void { - if (text === undefined) return; - this.restoredTexts.push(text); + * restored this cycle so earlier text is not overwritten, and release the + * stash's staged media with recall semantics — the restored draft still + * references its attachments, so retains are consumed (the next submit + * re-retains) and staged copies retire instead of leaking. */ + private restoreStashedInput(stash: StashedSubmit | undefined): void { + if (stash === undefined) return; + this.restoredTexts.push(stash.text); this.host.restoreInputText(this.restoredTexts.join('\n')); + this.host.recallStashedMedia(stash.text, stash.extraction); } private upstreamModelId(): string | undefined { @@ -441,7 +456,7 @@ export class CacheHintController { const { host } = this; const restoreInput = () => { this.lastDialogRestored = true; - this.restoreStashedInput(stashed?.text); + this.restoreStashedInput(stashed); }; switch (action) { case 'dismiss': @@ -492,7 +507,7 @@ export class CacheHintController { break; } this.lastDialogRestored = false; - if (stashed !== undefined) await this.releaseToSendPath(stashed); + 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 962065ca99e..0e071e3d9fe 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -1,4 +1,6 @@ -import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import { unlink } from 'node:fs/promises'; + +import type { FileMeta, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; import { compressImageForModel, persistOriginalImage, sessionMediaOriginalsDir } from '@moonshot-ai/kimi-code-sdk'; import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image'; @@ -13,9 +15,10 @@ import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE, } from '../constant/kimi-tui'; +import { IMAGE_STAGING_TTL_SECONDS } from '../constant/media'; 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 { extractInlineSkillActivations } from '../utils/inline-skill-tokens'; import type { PendingExit, QueuedMessage, SteerInputItem } from '../types'; import type { TUIState } from '../tui-state'; @@ -24,6 +27,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; /** @@ -43,6 +51,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; @@ -334,12 +343,21 @@ export class EditorKeyboardController { if (trimmed.length > 0) { // Queued items carry the parts extracted when they were submitted // (and were already capability-validated then). - textRun.push({ text: trimmed, parts: m.parts, imageAttachmentIds: m.imageAttachmentIds }); + textRun.push({ + text: trimmed, + parts: m.parts, + imageAttachmentIds: m.imageAttachmentIds, + stagingPaths: m.stagingPaths, + }); } } let editorExtraction: ReturnType | undefined; if (!editorIsBash && text.length > 0 && !editorHasInlineSkills && firstBundle === -1) { try { + // Synchronous path: an image still ingesting in the background + // extracts to its inline fallback here (no bounded wait like + // `sendNormalUserInput` — this handler cannot await without + // interleaving queue/draft edits). editorExtraction = extractMediaAttachments(text, this.imageStore); } catch (error) { // Cache copy failed (e.g. the pasted video's source vanished) — @@ -354,6 +372,7 @@ export class EditorKeyboardController { editorExtraction.imageAttachmentIds.length > 0 ? editorExtraction.imageAttachmentIds : undefined, + stagingPaths: editorExtraction.stagingPaths, }); } flushTextRun(); @@ -366,22 +385,30 @@ export class EditorKeyboardController { editorExtraction !== undefined && !host.validateMediaCapabilities(editorExtraction) ) { + host.releaseStagingMedia( + editorExtraction.imageAttachmentIds, + editorExtraction.stagingPaths, + ); + return; + } + 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); return; } host.state.queuedMessages = queued.filter( (m, index) => m.mode === 'bash' || (firstBundle !== -1 && index >= firstBundle), ); if (!editorIsBash && !editorHasInlineSkills && firstBundle === -1) editor.setText(''); - const session = host.session; - if (host.state.appState.model.trim().length === 0 || session === undefined) { - host.showError(LLM_NOT_SET_MESSAGE); - } else { - for (const run of runs) { - if (run.kind === 'text') { - host.steerMessage(session, run.items); - } else { - host.steerSkillActivation(session, run.skillName, run.skillArgs); - } + for (const run of runs) { + if (run.kind === 'text') { + host.steerMessage(session, run.items); + } else { + host.steerSkillActivation(session, run.skillName, run.skillArgs); } } } @@ -524,6 +551,45 @@ export class EditorKeyboardController { const meta = parseImageMeta(media.bytes); if (meta === null) return false; + + // Register the attachment and put its placeholder in the editor before + // any of the asynchronous ingestion work below. CustomEditor only holds + // keystrokes until this handler settles, so the callback returns right + // after the placeholder lands and ingestion continues in the background — + // typing never waits on compression or the daemon upload. Submit gives a + // pending ingestion a bounded wait (`pendingImageIngestions`) and falls + // back to the inline form when it has not finished. + 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' }); + + attachment.pending = this.finishClipboardImagePaste( + attachment, + media.bytes, + meta.mime, + meta.width, + meta.height, + ).catch((error: unknown) => { + // 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 @@ -535,7 +601,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: { @@ -550,34 +616,71 @@ 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 uploaded = 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: uploaded?.id, + fileExpiresAt: parseExpiry(uploaded), + }); + if (completed === undefined && uploaded !== undefined) { + await this.host.harness?.deleteFile(uploaded.id).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), run as part of the background ingestion — + * typing never waits on it, and submit only gives it the bounded + * `pendingImageIngestions` wait. Best effort: any failure returns undefined, + * so the attachment keeps no `fileId` and submit-time expansion falls back + * to the inline base64 form. + */ + private async uploadImageToDaemonFileStore( + bytes: Uint8Array, + mime: string, + ): Promise { + if (!this.host.engineV2) 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: IMAGE_STAGING_TTL_SECONDS, + }); + return meta; + } catch { + return undefined; + } } private async openExternalEditor(): Promise { @@ -621,3 +724,9 @@ export class EditorKeyboardController { } } } + +function parseExpiry(meta: FileMeta | undefined): number | undefined { + if (meta?.expires_at === undefined) return undefined; + const value = Date.parse(meta.expires_at); + return Number.isFinite(value) ? value : undefined; +} 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 9e73c92f207..e0fa8cf107d 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -119,6 +119,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; } @@ -319,6 +321,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); @@ -356,6 +359,7 @@ export class SessionEventHandler { } private handleTurnEnd(event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void { + this.host.handleTurnEnded?.(event); this.host.streamingUI.flushNow(); this.clearStepRetry(); if (event.reason === 'cancelled') { 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 00000000000..4d935aa5f9b --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/staging-leases.ts @@ -0,0 +1,303 @@ +/** + * `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 `