Skip to content
11 changes: 11 additions & 0 deletions .changeset/lan-remote-control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@moonshot-ai/kimi-code": patch
"@moonshot-ai/vis-server": patch
---

feat(vis): show LAN URLs when binding to 0.0.0.0 for remote control

When vis-server binds to 0.0.0.0 or :: (all interfaces), the startup
banner and CLI output now display the actual LAN IP addresses that
other devices on the same network can use to connect. This enables
lan-range remote control from phones, tablets, or other machines.
10 changes: 10 additions & 0 deletions .changeset/prefer-readmediafile-video.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@moonshot-ai/kimi-code": patch
---

feat(agent): prefer ReadMediaFile for video analysis in system prompt

Updated the system prompt (`system.md`) to explicitly guide the AI to
use `ReadMediaFile` directly for video analysis, rather than writing
Python scripts to extract frames. This leverages the built-in video
input capability of supported models and avoids unnecessary overhead.
7 changes: 7 additions & 0 deletions apps/kimi-code/src/cli/sub/vis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface StartedVisServer {
readonly port: number;
readonly host: string;
readonly url: string;
readonly lanUrls?: string[];
readonly close: () => Promise<void>;
}

Expand Down Expand Up @@ -86,6 +87,12 @@ export async function handleVis(deps: VisDeps, opts: VisOptions): Promise<void>
: `${server.url}sessions/${encodeURIComponent(opts.sessionId)}`;

deps.stdout.write(`kimi vis is running at ${server.url}\n`);
if (server.lanUrls !== undefined && server.lanUrls.length > 0) {
deps.stdout.write(`LAN access:\n`);
for (const lanUrl of server.lanUrls) {
deps.stdout.write(` ${lanUrl}\n`);
}
}
deps.stdout.write('Press Ctrl-C to stop.\n');

if (opts.open) {
Expand Down
17 changes: 17 additions & 0 deletions apps/vis/server/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
import os from 'node:os';

export function isAllInterfaces(host: string): boolean {
return host === '0.0.0.0' || host === '::';
}

export function getLocalNetworkAddresses(port: number): string[] {
const addresses: string[] = [];
for (const [, ifaceList] of Object.entries(os.networkInterfaces())) {
for (const iface of ifaceList ?? []) {
if (!iface.internal && iface.family === 'IPv4') {
addresses.push(`http://${iface.address}:${port}/`);
Comment on lines +13 to +14

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 Advertise IPv6 addresses when binding to the IPv6 wildcard

For --host :: on an IPv6-only host or LAN, this filter discards every reachable address, so lanUrls is empty even though the server is listening and the changeset explicitly promises support for ::. Include non-internal IPv6 interfaces and format them with brackets in URL authorities; IPv4 URLs can still be included when dual-stack connectivity is available.

Useful? React with 👍 / 👎.

}
}
}
return addresses;
}

/** Resolve KIMI_CODE_HOME (env > ~/.kimi-code). */
export function resolveKimiCodeHome(): string {
Expand Down
4 changes: 2 additions & 2 deletions apps/vis/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { formatStartupBanner } from './startup-banner';
async function main(): Promise<void> {
const host = resolveHost();
const authToken = resolveVisAuthToken(host);
const { port } = await startVisServer({ host, authToken });
const { port, lanUrls } = await startVisServer({ host, authToken });
process.stdout.write(
formatStartupBanner({ authToken, host, kimiCodeHome: KIMI_CODE_HOME, port }),
formatStartupBanner({ authToken, host, kimiCodeHome: KIMI_CODE_HOME, port, lanUrls }),
);
}

Expand Down
4 changes: 3 additions & 1 deletion apps/vis/server/src/start.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { serve } from '@hono/node-server';

import { createApp } from './app';
import { hostForUrl, resolveHost, resolveKimiCodeHome, resolvePort, resolveVisAuthToken } from './config';
import { getLocalNetworkAddresses, hostForUrl, isAllInterfaces, resolveHost, resolveKimiCodeHome, resolvePort, resolveVisAuthToken } from './config';
import type { WebAsset } from './lib/web-asset';

export interface StartVisServerOptions {
Expand All @@ -18,6 +18,7 @@ export interface StartedVisServer {
readonly port: number;
readonly host: string;
readonly url: string;
readonly lanUrls?: string[];
readonly close: () => Promise<void>;
}

Expand All @@ -36,6 +37,7 @@ export async function startVisServer(
port: info.port,
host,
url: `http://${hostForUrl(host)}:${info.port}/`,
lanUrls: isAllInterfaces(host) ? getLocalNetworkAddresses(info.port) : undefined,

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 an authentication path in advertised LAN URLs

When binding to 0.0.0.0 or ::, resolveVisAuthToken requires a token, but lanUrls is generated using only the port. A browser on another device has neither a token parameter nor the server's existing local-storage value, so every /api request receives 401 and the newly advertised URL is not usable as printed. Include the token in a fragment/query understood by apps/vis/web/src/api.ts, or print explicit authentication instructions alongside each LAN URL.

Useful? React with 👍 / 👎.

close: () =>
new Promise<void>((done, fail) => {
server.close((err?: Error) => (err ? fail(err) : done()));
Expand Down
13 changes: 10 additions & 3 deletions apps/vis/server/src/startup-banner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ export interface StartupBannerOptions {
readonly host: string;
readonly kimiCodeHome: string;
readonly port: number;
readonly lanUrls?: string[];
}

export function formatStartupBanner(options: StartupBannerOptions): string {
const authStatus = options.authToken === undefined ? 'auth=disabled' : 'auth=required';
return (
let banner =
`[vis-server] listening on http://${hostForUrl(options.host)}:${String(options.port)} ` +
`(${authStatus}, KIMI_CODE_HOME=${options.kimiCodeHome})\n`
);
`(${authStatus}, KIMI_CODE_HOME=${options.kimiCodeHome})\n`;
if (options.lanUrls !== undefined && options.lanUrls.length > 0) {
banner +=
`[vis-server] LAN access:\n` +
options.lanUrls.map((url) => ` - ${url}`).join('\n') +
'\n';
}
return banner;
}
38 changes: 37 additions & 1 deletion apps/vis/server/test/lib/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { hostForUrl } from '../../src/config';
import { hostForUrl, isAllInterfaces, getLocalNetworkAddresses } from '../../src/config';

describe('hostForUrl', () => {
it('brackets a bare IPv6 literal for use in a URL', () => {
Expand All @@ -18,3 +18,39 @@ describe('hostForUrl', () => {
expect(hostForUrl('[::1]')).toBe('[::1]');
});
});

describe('isAllInterfaces', () => {
it('returns true for 0.0.0.0', () => {
expect(isAllInterfaces('0.0.0.0')).toBe(true);
});

it('returns true for ::', () => {
expect(isAllInterfaces('::')).toBe(true);
});

it('returns false for loopback hosts', () => {
expect(isAllInterfaces('127.0.0.1')).toBe(false);
expect(isAllInterfaces('localhost')).toBe(false);
expect(isAllInterfaces('::1')).toBe(false);
});
});

describe('getLocalNetworkAddresses', () => {
it('returns non-empty array of IPv4 URLs for a valid port', () => {
const addresses = getLocalNetworkAddresses(3001);
expect(addresses.length).toBeGreaterThan(0);
Comment on lines +40 to +41

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 Stub network interfaces instead of requiring an IPv4 adapter

This test fails on valid IPv6-only, loopback-only, or network-isolated CI environments because os.networkInterfaces() is allowed to contain no non-internal IPv4 address. Mock the network-interface result and assert deterministic filtering/formatting rather than requiring the machine running Vitest to expose a particular adapter.

Useful? React with 👍 / 👎.

for (const addr of addresses) {
expect(addr).toMatch(/^http:\/\/\d+\.\d+\.\d+\.\d+:3001\/$/);
}
});

it('returns different URLs for different ports', () => {
const addrs3001 = getLocalNetworkAddresses(3001);
const addrs8080 = getLocalNetworkAddresses(8080);
expect(addrs3001.length).toBe(addrs8080.length);
for (let i = 0; i < addrs3001.length; i++) {
expect(addrs3001[i]).toContain(':3001/');
expect(addrs8080[i]).toContain(':8080/');
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ The user may ask you to research on certain topics, process or generate certain
- Understand the user's requirements thoroughly, ask for clarification before you start if needed.
- Make plans before doing deep or wide research, to ensure you are always on track.
- Search on the Internet if possible, with carefully-designed search queries to improve efficiency and accuracy.
- Use proper tools or shell commands or Python packages to process or generate images, videos, PDFs, docs, spreadsheets, presentations, or other multimedia files. Detect if there are already such tools in the environment. If you have to install third-party tools/packages, you MUST ensure that they are installed in a virtual/isolated environment.
- Use proper tools or shell commands or Python packages to process or generate images, videos, PDFs, docs, spreadsheets, presentations, or other multimedia files. For video analysis, prefer `ReadMediaFile` directly over writing Python scripts to extract frames. Detect if there are already such tools in the environment. If you have to install third-party tools/packages, you MUST ensure that they are installed in a virtual/isolated environment.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Update the system prompt used by the default CLI

This changes only the agent-core-v2 template, but the normal kimi and kimi -p paths still use the SDK-backed v1 harness (apps/kimi-code/src/cli/prompt-session.ts:4-10), which loads packages/agent-core/src/profile/default/system.md through packages/agent-core/src/profile/default.ts:6-16; that template remains unchanged. Consequently, the new video instruction applies only to experimental v2 and web sessions, while the default CLI—the primary user-visible path described by this changeset—continues preferring the old behavior. Mirror the instruction into the legacy profile template as well.

AGENTS.md reference: AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

- Once you generate or edit any images, videos or other media files, try to read it again before proceed, to ensure that the content is as expected.
- Avoid installing or deleting anything to/from outside of the current working directory. If you have to do so, ask the user for confirmation.

Expand Down
26 changes: 25 additions & 1 deletion packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import { parseHTML as rawParseHTML } from 'linkedom';
import { Agent, type Dispatcher } from 'undici';

import { isProxyConfigured, makeNoProxyMatcher, resolveNoProxy } from '#/_base/utils/proxy';
import {
MODEL_ACCEPTED_IMAGE_MIMES,
normalizeImageMime,
} from '#/agent/media/image-format-policy';

import { HttpFetchError, type UrlFetcher, type UrlFetchResult } from '../tools/fetch-url-types';

Expand Down Expand Up @@ -109,6 +113,27 @@ export class LocalFetchURLProvider implements UrlFetcher {
}
}

const contentType = (response.headers.get('content-type') ?? '').toLowerCase();

// Handle accepted image formats directly — convert to base64 data URL
// so the model can view them inline.
const normalizedMime = normalizeImageMime(contentType);
if (MODEL_ACCEPTED_IMAGE_MIMES.has(normalizedMime)) {
Comment on lines +120 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Verify fetched image bytes before assigning the MIME

When an origin labels AVIF, BMP, or arbitrary bytes as image/png, this branch trusts the header and rebuilds the payload as a PNG data URL even though the bytes are authoritative. Providers then reject the malformed or unsupported image, and because the tool result is persisted, subsequent requests can keep failing. Use the existing magic-byte detection/resolveEffectiveImageMime policy before accepting and canonicalizing the image; the mirrored legacy provider has the same defect.

Useful? React with 👍 / 👎.

const buffer = await response.arrayBuffer();
if (buffer.byteLength > this.maxBytes) {
throw new Error(
`Response body too large: ${String(buffer.byteLength)} bytes exceeds maxBytes (${String(this.maxBytes)}).`,
Comment on lines +122 to +125

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce image delivery limits before returning fetched images

For a valid supported image between the media delivery ceiling and DEFAULT_MAX_BYTES—for example, a 5 MiB PNG—this check permits the original bytes to be embedded unchanged because maxBytes defaults to 10 MiB. The media path elsewhere enforces a 3.75 MiB hard per-image ceiling and normally compresses reads to a much smaller byte and edge budget; bypassing those limits can make the next provider request fail and leave the oversized part in session history. Run fetched images through the same compression and maximum-edge policy before constructing the data URL in both engine implementations.

Useful? React with 👍 / 👎.

);
}
const base64 = Buffer.from(buffer).toString('base64');
const dataUrl = `data:${normalizedMime};base64,${base64}`;
return {
content: `Fetched image (${normalizedMime}).`,
kind: 'image',
imageUrl: dataUrl,
};
}

const body = await response.text();

const actualBytes = Buffer.byteLength(body, 'utf8');
Expand All @@ -118,7 +143,6 @@ export class LocalFetchURLProvider implements UrlFetcher {
);
}

const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
if (contentType.startsWith('text/plain') || contentType.startsWith('text/markdown')) {
return { content: body, kind: 'passthrough' };
}
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
* - `extracted` — the body was an HTML page; only the main article text
* was extracted and returned.
*/
export type UrlFetchKind = 'passthrough' | 'extracted';
export type UrlFetchKind = 'passthrough' | 'extracted' | 'image';

export interface UrlFetchResult {
readonly content: string;
readonly kind: UrlFetchKind;
/** When the fetched URL is an image, the image data as a base64 data URL. */
readonly imageUrl?: string;
}

export interface UrlFetcher {
Expand Down
14 changes: 13 additions & 1 deletion packages/agent-core-v2/src/app/web/tools/fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,19 @@ export class FetchURLTool implements BuiltinTool<FetchURLInput> {
{ toolCallId, signal }: ExecutableToolContext,
): Promise<ExecutableToolResult> {
try {
const { content, kind } = await this.fetcher.fetch(args.url, { toolCallId, signal });
const { content, kind, imageUrl } = await this.fetcher.fetch(args.url, { toolCallId, signal });

// When the fetcher returns an image, emit it as an image_url content part
// so the model can see it directly.
if (kind === 'image' && imageUrl !== undefined) {
return {
isError: false,
output: [
{ type: 'text', text: content },
{ type: 'image_url', image_url: { url: imageUrl } },
],
};
}

if (!content) {
return {
Expand Down
31 changes: 21 additions & 10 deletions packages/agent-core-v2/test/app/web/tools/fetch-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,32 +90,43 @@ describe('FetchURLTool abort signal', () => {
});

describe('FetchURLTool output note', () => {
async function runKind(kind: UrlFetchResult['kind']): Promise<string> {
async function runKind(kind: UrlFetchResult['kind'], imageUrl?: string): Promise<ExecutableToolResult> {
const fetch = vi
.fn<UrlFetcher['fetch']>()
.mockResolvedValue({ content: 'BODY', kind } satisfies UrlFetchResult);
.mockResolvedValue({ content: 'BODY', kind, imageUrl } satisfies UrlFetchResult);
const tool = new FetchURLTool({ fetch });
const result = await execute(tool, 'https://example.com', new AbortController().signal);
expect(result.isError).toBe(false);
if (typeof result.output !== 'string') throw new Error('expected string output');
return result.output;
return execute(tool, 'https://example.com', new AbortController().signal);
}

it('puts the passthrough note and citation reminder at the front of output', async () => {
const output = await runKind('passthrough');
expect(output).toBe(
const result = await runKind('passthrough');
expect(result.isError).toBe(false);
if (typeof result.output !== 'string') throw new Error('expected string output');
expect(result.output).toBe(
'The returned content is the full response body, returned verbatim. ' +
'If you use it in your answer, cite this page as a markdown link, e.g. [title](url).\n\nBODY',
);
});

it('puts the extracted note and citation reminder at the front of output', async () => {
const output = await runKind('extracted');
expect(output).toBe(
const result = await runKind('extracted');
expect(result.isError).toBe(false);
if (typeof result.output !== 'string') throw new Error('expected string output');
expect(result.output).toBe(
'The returned content is the main text extracted from the page. ' +
'If you use it in your answer, cite this page as a markdown link, e.g. [title](url).\n\nBODY',
);
});

it('returns image as ContentPart[] when kind is image', async () => {
const result = await runKind('image', 'data:image/png;base64,abc123');
expect(result.isError).toBe(false);
if (typeof result.output === 'string') throw new Error('expected ContentPart[] output');
expect(result.output).toEqual([
{ type: 'text', text: 'BODY' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,abc123' } },
]);
});
});

describe('LocalFetchURLProvider abort signal', () => {
Expand Down
22 changes: 18 additions & 4 deletions packages/agent-core/src/tools/builtin/web/fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ import DESCRIPTION from './fetch-url.md?raw';
* - `extracted` — the body was an HTML page; only the main article text
* was extracted and returned.
*/
export type UrlFetchKind = 'passthrough' | 'extracted';
export type UrlFetchKind = 'passthrough' | 'extracted' | 'image';

export interface UrlFetchResult {
/** The text handed to the LLM. */
/** The text handed to the LLM, or a description when the result is an image. */
content: string;
/** Whether `content` is a verbatim passthrough or extracted main text. */
/** Whether `content` is a verbatim passthrough, extracted main text, or an image. */
kind: UrlFetchKind;
/** When the fetched URL is an image, the image data as a base64 data URL. */
imageUrl?: string;
}

export interface UrlFetcher {
Expand Down Expand Up @@ -89,7 +91,19 @@ export class FetchURLTool implements BuiltinTool<FetchURLInput> {
}: ExecutableToolContext,
): Promise<ExecutableToolResult> {
try {
const { content, kind } = await this.fetcher.fetch(args.url, { toolCallId });
const { content, kind, imageUrl } = await this.fetcher.fetch(args.url, { toolCallId });

// When the fetcher returns an image, emit it as an image_url content part
// so the model can see it directly.
if (kind === 'image' && imageUrl !== undefined) {
return {
isError: false,
output: [
{ type: 'text', text: content },
{ type: 'image_url', image_url: { url: imageUrl } },
],
};
}

if (!content) {
return {
Expand Down
Loading