Skip to content

Commit efdf8a1

Browse files
authored
feat(vis): faithful wire.jsonl rendering + built-in kimi vis command (#788)
* feat: polish vis * feat: add 'kimi vis' command for session visualization * fix(vis): drop metadata app_version/resumed removed upstream #786 stopped recording resume version metadata, so those fields no longer exist on the metadata wire record. The vis-into-typecheck wiring caught the stale field reads after merging main; drop them from the metadata headline. * fix(vis): drop unnecessary return-await in startVisServer oxlint typescript-eslint(return-await) flags returning an awaited promise outside try/catch; return the promise directly. * fix(vis): green CI — tolerate unbuilt embedded asset + bump nix pnpmDeps hash - handleVis: wrap the embedded-SPA dynamic import in try/catch. The value module is generated at build time (prebuild); in contexts without a build (tests run pnpm test, not build) only the .d.ts type stub exists, so the runtime import throws. Tolerate it and fall back to filesystem serving. - flake.nix: update the fetchPnpmDeps hash after adding the vis-web / vis-server / vite-plugin-singlefile dependencies. * refactor(vis): drop redundant alwaysBundle in tsdown config #775's single-entry build (codeSplitting: false) already bundles everything not declared in dependencies/peerDependencies. hono / @hono/node-server (transitive via vis-server) and @moonshot-ai/vis-server (a devDependency) are all undeclared there, so they bundle by default — the explicit alwaysBundle was redundant. Verified the emitted main.mjs is still fully self-contained and 'kimi vis' serves. * fix(vis): address review — context-token resets, IPv6 url, marker-safe indexing - contextTokens now mirrors agent-core on lifecycle records: 0 on context.clear, tokensAfter on context.apply_compaction (was only updated from step.end.usage, leaving a stale live fill after a clear/compaction). - start.ts brackets IPv6 hosts in the returned url (http://[::1]:port/); hostForUrl moved to config.ts and shared with the startup banner. - compaction slice + micro-compaction blanking now index over real history entries only, so synthetic undo/clear UI markers no longer offset agent-core's compactedCount / cutoff. * chore: add changeset for kimi vis command * fix(vis): cross-platform single-file build + history-count micro clamp - build-vis-asset.mjs sets VIS_SINGLEFILE via the spawn env and runs 'vite build' directly (cross-platform), instead of the POSIX-inline-env 'build:single' script that broke on Windows cmd; removed the now-unused build:single script. Fixes the win32 build path (the asset generator runs in the kimi-code prebuild + native bundle). - context.undo now clamps the micro-compaction cutoff by history-entry count (excluding synthetic undo/clear markers) instead of messages.length, mirroring agent-core undo() -> microCompaction.reset(_history.length); a surviving marker no longer leaves the cutoff one too high and wrongly blanks a later-appended tool result. * fix(vis): run the single-file build through a shell for Windows pnpm The win32 native binary is built on Windows runners (.github/workflows/_native-build.yml), which run this generator. pnpm's launcher there is pnpm.cmd, which a bare argv exec can't resolve without a shell. Use execSync with a single command string so the platform shell (cmd on Windows) resolves the shim; a command string (not an args array) avoids the args+shell deprecation. Args are static. * fix(vis): show model-facing tool result content in the context view agent-core normalizes tool results via toolResultOutputForModel before they enter history (error -> '<system>ERROR: ...' prefix, empty -> '<system>Tool output is empty.' sentinel). The projector was using the raw ev.result.output, so the Context tab's model view showed content the model never saw for failed/empty tool calls. Replicate that normalization (the upstream helper is module-private) so the projected tool message matches what the model received.
1 parent 7b5b818 commit efdf8a1

60 files changed

Lines changed: 3194 additions & 906 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/add-kimi-vis-command.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": minor
3+
---
4+
5+
Add a built-in `kimi vis` command that launches the session visualizer in your browser, pointed at your local sessions. Supports `--port`/`--host`, `--no-open`, and `kimi vis <sessionId>` deep-links.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
node_modules/
22
dist/
3+
dist-single/
34
dist-native/
45
.tmp-api-extractor/
56
coverage/

apps/kimi-code/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
11
# Copied from packages/kimi-core at build time
22
agents/
3+
4+
# Generated at build time by scripts/build-vis-asset.mjs.
5+
# Only the ~150KB base64 VALUE file is ignored; the committed `.d.ts` stub
6+
# next to it keeps `#/generated/vis-web-asset` type-resolvable on a fresh
7+
# clone (before any build has produced the `.ts`).
8+
src/generated/vis-web-asset.ts

apps/kimi-code/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,16 @@
3636
"#/tui/theme": "./src/tui/theme/index.ts",
3737
"#/*": [
3838
"./src/*.ts",
39-
"./src/*/index.ts"
39+
"./src/*/index.ts",
40+
"./src/*.d.ts"
4041
]
4142
},
4243
"publishConfig": {
4344
"access": "public",
4445
"provenance": true
4546
},
4647
"scripts": {
48+
"prebuild": "node scripts/build-vis-asset.mjs",
4749
"build": "tsdown",
4850
"catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json",
4951
"smoke": "node scripts/smoke.mjs",
@@ -77,6 +79,8 @@
7779
"@moonshot-ai/kimi-code-sdk": "workspace:^",
7880
"@moonshot-ai/kimi-telemetry": "workspace:^",
7981
"@moonshot-ai/migration-legacy": "workspace:^",
82+
"@moonshot-ai/vis-server": "workspace:^",
83+
"@moonshot-ai/vis-web": "workspace:*",
8084
"@types/semver": "^7.7.0",
8185
"@types/yazl": "^2.4.6",
8286
"chalk": "^5.4.1",
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Builds the vis web single-file bundle, gzips it, and writes a generated
2+
// TS module that embeds it as base64 so tsdown can later bundle it into
3+
// dist/main.mjs (works identically for the npm package and the native SEA
4+
// binary).
5+
import { execSync } from 'node:child_process';
6+
import { gzipSync } from 'node:zlib';
7+
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
8+
import { dirname, join, resolve } from 'node:path';
9+
import { fileURLToPath } from 'node:url';
10+
11+
const here = dirname(fileURLToPath(import.meta.url));
12+
const repoRoot = resolve(here, '..', '..', '..');
13+
const visWeb = join(repoRoot, 'apps', 'vis', 'web');
14+
const out = join(here, '..', 'src', 'generated', 'vis-web-asset.ts');
15+
16+
console.log('[build-vis-asset] building vis web single-file bundle…');
17+
try {
18+
// Run vite with VIS_SINGLEFILE set on the spawn so the build is
19+
// cross-platform (Node sets the env, not a POSIX-only inline-env shell
20+
// prefix). `pnpm --filter X exec` runs in X's package dir, so vite picks up
21+
// vis-web's vite.config.ts, which gates the single-file output on
22+
// `process.env.VIS_SINGLEFILE === '1'`.
23+
// execSync runs through the platform shell, which is required on Windows:
24+
// pnpm's launcher is `pnpm.cmd`, which a bare argv exec cannot resolve (no
25+
// PATHEXT without a shell). The win32 native binary IS built on Windows
26+
// runners (.github/workflows/_native-build.yml), which run this generator.
27+
// A single command string (not an args array) avoids the args+shell
28+
// deprecation; the command is static (no injection surface).
29+
execSync('pnpm --filter @moonshot-ai/vis-web exec vite build', {
30+
stdio: 'inherit',
31+
cwd: repoRoot,
32+
env: { ...process.env, VIS_SINGLEFILE: '1' },
33+
});
34+
} catch (err) {
35+
throw new Error(
36+
`[build-vis-asset] failed to run the vis-web single-file build via pnpm (is pnpm on PATH?): ${err instanceof Error ? err.message : String(err)}`,
37+
);
38+
}
39+
40+
const html = readFileSync(join(visWeb, 'dist-single', 'index.html'));
41+
if (html.length < 1024 || !html.toString('utf8', 0, 256).toLowerCase().includes('<!doctype html')) {
42+
throw new Error(
43+
`[build-vis-asset] dist-single/index.html looks invalid (${html.length} bytes) — the web build may have failed`,
44+
);
45+
}
46+
const b64 = gzipSync(html, { level: 9 }).toString('base64');
47+
48+
mkdirSync(dirname(out), { recursive: true });
49+
writeFileSync(
50+
out,
51+
`// GENERATED by scripts/build-vis-asset.mjs — do not edit.\n` +
52+
`export const VIS_WEB_GZIP_B64 = ${JSON.stringify(b64)};\n`,
53+
);
54+
console.log(`[build-vis-asset] wrote ${out} (${(b64.length / 1024).toFixed(0)} KB base64)`);

apps/kimi-code/scripts/native/01-bundle.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@ import { run } from './exec.mjs';
66
const requireFromScript = createRequire(import.meta.url);
77
const tsdownCliPath = requireFromScript.resolve('tsdown/run');
88
const checkBundlePath = resolve(import.meta.dirname, 'check-bundle.mjs');
9+
const buildVisAssetPath = resolve(import.meta.dirname, '..', 'build-vis-asset.mjs');
910

1011
export async function runBundleStep() {
12+
// Generate the embedded `kimi vis` web asset before bundling. The native
13+
// tsdown run here never goes through the npm `prebuild` lifecycle, so the
14+
// generated module must be produced explicitly first or the bundle would
15+
// miss it (npm builds get it via the `prebuild` script).
16+
await run(process.execPath, [buildVisAssetPath]);
1117
await run(process.execPath, [tsdownCliPath, '--config', 'tsdown.native.config.ts']);
1218
await run(process.execPath, [checkBundlePath]);
1319
}

apps/kimi-code/src/cli/commands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { registerDoctorCommand } from './sub/doctor';
88
import { registerExportCommand } from './sub/export';
99
import { registerLoginCommand } from './sub/login';
1010
import { registerProviderCommand } from './sub/provider';
11+
import { registerVisCommand } from './sub/vis';
1112

1213
export type MainCommandHandler = (opts: CLIOptions) => void;
1314
export type MigrateCommandHandler = () => void;
@@ -80,6 +81,7 @@ export function createProgram(
8081
registerAcpCommand(program);
8182
registerLoginCommand(program);
8283
registerDoctorCommand(program);
84+
registerVisCommand(program);
8385
registerMigrateCommand(program, onMigrate);
8486
program
8587
.command('upgrade')

apps/kimi-code/src/cli/sub/vis.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* `kimi vis` sub-command.
3+
*
4+
* CLI glue only: resolves the kimi home, starts the in-process session
5+
* visualizer server (auto-picking a free port by default), prints the URL,
6+
* optionally opens the browser (with an optional session deep-link), then
7+
* waits for Ctrl-C and shuts the server down. The visualizer server itself
8+
* lives in `@moonshot-ai/vis-server`.
9+
*/
10+
11+
import type { Command } from 'commander';
12+
13+
import { createCliTelemetryBootstrap } from '#/cli/telemetry';
14+
import { openUrl } from '#/utils/open-url';
15+
16+
interface WritableLike {
17+
write(chunk: string): boolean;
18+
}
19+
20+
export interface StartedVisServer {
21+
readonly port: number;
22+
readonly host: string;
23+
readonly url: string;
24+
readonly close: () => Promise<void>;
25+
}
26+
27+
export interface StartVisServerArgs {
28+
readonly homeDir: string;
29+
readonly port: number;
30+
readonly host?: string;
31+
readonly webAsset?: { gzipped: Uint8Array };
32+
}
33+
34+
export interface VisDeps {
35+
readonly getHomeDir: () => string;
36+
readonly startVisServer: (opts: StartVisServerArgs) => Promise<StartedVisServer>;
37+
readonly openUrl: (url: string) => Promise<void>;
38+
readonly waitForShutdown: () => Promise<void>;
39+
readonly stdout: WritableLike;
40+
readonly stderr: WritableLike;
41+
readonly exit: (code: number) => never;
42+
}
43+
44+
export interface VisOptions {
45+
readonly open: boolean;
46+
readonly port?: number;
47+
readonly host?: string;
48+
readonly sessionId?: string;
49+
}
50+
51+
export async function handleVis(deps: VisDeps, opts: VisOptions): Promise<void> {
52+
const homeDir = deps.getHomeDir();
53+
54+
// Lazily load the embedded single-file SPA so normal `kimi` startup never
55+
// pays for it. The module is generated at build time (prebuild). When running
56+
// from source without a build — e.g. tests — the generated value module is
57+
// absent and the dynamic import throws; in that case the server falls back to
58+
// its own static `public/` directory.
59+
let webAsset: { gzipped: Uint8Array } | undefined;
60+
try {
61+
const { VIS_WEB_GZIP_B64 } = await import('#/generated/vis-web-asset');
62+
if (VIS_WEB_GZIP_B64.length > 0) {
63+
webAsset = { gzipped: new Uint8Array(Buffer.from(VIS_WEB_GZIP_B64, 'base64')) };
64+
}
65+
} catch {
66+
// Embedded asset not generated in this context — fall back to filesystem.
67+
}
68+
69+
let server: StartedVisServer;
70+
try {
71+
server = await deps.startVisServer({
72+
homeDir,
73+
port: opts.port ?? 0,
74+
...(opts.host === undefined ? {} : { host: opts.host }),
75+
...(webAsset === undefined ? {} : { webAsset }),
76+
});
77+
} catch (error) {
78+
const msg = error instanceof Error ? error.message : String(error);
79+
deps.stderr.write(`Failed to start kimi vis: ${msg}\n`);
80+
return deps.exit(1);
81+
}
82+
83+
const target =
84+
opts.sessionId === undefined
85+
? server.url
86+
: `${server.url}sessions/${encodeURIComponent(opts.sessionId)}`;
87+
88+
deps.stdout.write(`kimi vis is running at ${server.url}\n`);
89+
deps.stdout.write('Press Ctrl-C to stop.\n');
90+
91+
if (opts.open) {
92+
try {
93+
await deps.openUrl(target);
94+
} catch {
95+
deps.stderr.write(`Could not open a browser; visit ${target} manually.\n`);
96+
}
97+
}
98+
99+
await deps.waitForShutdown();
100+
await server.close();
101+
}
102+
103+
export function registerVisCommand(parent: Command, overrides?: Partial<VisDeps>): void {
104+
parent
105+
.command('vis')
106+
.description('Launch the session visualizer in your browser.')
107+
.option('--port <number>', 'Port to bind. Default: auto-pick a free port.')
108+
.option('--host <host>', 'Host to bind. Default: 127.0.0.1.')
109+
.option('--no-open', 'Do not open the browser automatically.')
110+
.argument('[sessionId]', 'Open directly to this session.')
111+
.action(
112+
async (
113+
sessionId: string | undefined,
114+
options: { port?: string; host?: string; open?: boolean },
115+
) => {
116+
const port = options.port === undefined ? undefined : Number.parseInt(options.port, 10);
117+
await handleVis(createDefaultVisDeps(overrides), {
118+
open: options.open !== false,
119+
...(port === undefined || Number.isNaN(port) ? {} : { port }),
120+
...(options.host === undefined ? {} : { host: options.host }),
121+
...(sessionId === undefined ? {} : { sessionId }),
122+
});
123+
},
124+
);
125+
}
126+
127+
function createDefaultVisDeps(overrides: Partial<VisDeps> = {}): VisDeps {
128+
return {
129+
getHomeDir: overrides.getHomeDir ?? (() => createCliTelemetryBootstrap().homeDir),
130+
startVisServer:
131+
overrides.startVisServer ??
132+
(async (opts) => {
133+
// Dynamic import keeps the vis server (and Hono) out of the hot path.
134+
const { startVisServer } = await import('@moonshot-ai/vis-server/start');
135+
return startVisServer(opts);
136+
}),
137+
// `openUrl` is a synchronous fire-and-forget; adapt it to the async dep.
138+
openUrl:
139+
overrides.openUrl ??
140+
(async (url: string) => {
141+
openUrl(url);
142+
}),
143+
waitForShutdown: overrides.waitForShutdown ?? waitForSigint,
144+
stdout: overrides.stdout ?? process.stdout,
145+
stderr: overrides.stderr ?? process.stderr,
146+
exit: overrides.exit ?? ((code: number) => process.exit(code)),
147+
};
148+
}
149+
150+
function waitForSigint(): Promise<void> {
151+
return new Promise<void>((resolve) => {
152+
const onSig = (): void => {
153+
process.off('SIGINT', onSig);
154+
resolve();
155+
};
156+
process.on('SIGINT', onSig);
157+
});
158+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export declare const VIS_WEB_GZIP_B64: string;

apps/kimi-code/test/cli/options.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ describe('CLI options parsing', () => {
347347
'acp',
348348
'login',
349349
'doctor',
350+
'vis',
350351
'migrate',
351352
'upgrade',
352353
]);

0 commit comments

Comments
 (0)