import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
import type { Message, ToolCall } from "../types";
import { asRecord, normalizeKimiFunctionName, partialSuffixOverlapAny } from "./coercion";
import dialectPrompt from "./kimi.md" with { type: "text" };
import { assistantTranscriptParts, collectToolResultRun, messageContentText, stringifyJson } from "./rendering";
import type {
DialectDefinition,
DialectRenderOptions,
DialectToolResult,
InbandScanEvent,
InbandScanner,
InbandScannerOptions,
} from "./types";
export const KIMI_SECTION_BEGIN = "<|tool_calls_section_begin|>";
export const KIMI_SECTION_END = "<|tool_calls_section_end|>";
export const KIMI_CALL_BEGIN = "<|tool_call_begin|>";
export const KIMI_CALL_END = "<|tool_call_end|>";
export const KIMI_ARG_BEGIN = "<|tool_call_argument_begin|>";
const TOKENS = [KIMI_SECTION_BEGIN, KIMI_SECTION_END, KIMI_CALL_BEGIN, KIMI_CALL_END, KIMI_ARG_BEGIN] as const;
const THINK_OPEN = "";
const THINK_CLOSE = "";
const TOKENS_THINK = [
KIMI_SECTION_BEGIN,
KIMI_SECTION_END,
KIMI_CALL_BEGIN,
KIMI_CALL_END,
KIMI_ARG_BEGIN,
THINK_OPEN,
] as const;
type State = "outside" | "section" | "header" | "args" | "thinking";
export class KimiInbandScanner implements InbandScanner {
#buffer = "";
#state: State = "outside";
#id = "";
#name = "";
#rawBlock = "";
#thinking = "";
readonly #parseThinking: boolean;
constructor(options: InbandScannerOptions = {}) {
this.#parseThinking = options.parseThinking !== false;
}
feed(text: string): InbandScanEvent[] {
if (text.length === 0) return [];
this.#buffer += text;
return this.#consume(false);
}
flush(): InbandScanEvent[] {
return this.#consume(true);
}
#consume(final: boolean): InbandScanEvent[] {
const events: InbandScanEvent[] = [];
while (this.#buffer.length > 0) {
if (this.#state === "outside") {
if (!this.#consumeOutside(final, events)) break;
continue;
}
if (this.#state === "thinking") {
if (!this.#consumeThinking(final, events)) break;
continue;
}
if (this.#state === "section") {
if (!this.#consumeSection(final)) break;
continue;
}
if (this.#state === "header") {
if (!this.#consumeHeader(final, events)) break;
continue;
}
if (!this.#consumeArgs(final, events)) break;
}
if (final && this.#state === "thinking") this.#endThinking(events);
return events;
}
#consumeOutside(final: boolean, events: InbandScanEvent[]): boolean {
const tokenStart = this.#nextTokenIndex();
const thinkStart = this.#parseThinking ? this.#buffer.indexOf(THINK_OPEN) : -1;
let start = tokenStart;
if (thinkStart !== -1 && (start === -1 || thinkStart < start)) start = thinkStart;
if (start === -1) {
const tags = this.#parseThinking ? TOKENS_THINK : TOKENS;
const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, tags);
const emitEnd = this.#buffer.length - hold;
if (emitEnd > 0) events.push({ type: "text", text: this.#buffer.slice(0, emitEnd) });
this.#buffer = this.#buffer.slice(emitEnd);
return false;
}
if (start > 0) events.push({ type: "text", text: this.#buffer.slice(0, start) });
this.#buffer = this.#buffer.slice(start);
if (this.#parseThinking && this.#buffer.startsWith(THINK_OPEN)) {
this.#buffer = this.#buffer.slice(THINK_OPEN.length);
this.#thinking = "";
events.push({ type: "thinkingStart" });
this.#state = "thinking";
return true;
}
const token = this.#tokenAtStart();
if (!token) return false;
this.#buffer = this.#buffer.slice(token.length);
if (token === KIMI_SECTION_BEGIN) this.#state = "section";
else events.push({ type: "text", text: token });
return true;
}
#consumeThinking(final: boolean, events: InbandScanEvent[]): boolean {
const close = this.#buffer.indexOf(THINK_CLOSE);
if (close === -1) {
const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, [THINK_CLOSE]);
this.#emitThinking(this.#buffer.slice(0, this.#buffer.length - hold), events);
this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
if (final) {
this.#endThinking(events);
this.#state = "outside";
}
return false;
}
this.#emitThinking(this.#buffer.slice(0, close), events);
this.#buffer = this.#buffer.slice(close + THINK_CLOSE.length);
this.#endThinking(events);
this.#state = "outside";
return true;
}
#emitThinking(delta: string, events: InbandScanEvent[]): void {
if (delta.length === 0) return;
this.#thinking += delta;
events.push({ type: "thinkingDelta", delta });
}
#endThinking(events: InbandScanEvent[]): void {
events.push({ type: "thinkingEnd", thinking: this.#thinking });
this.#thinking = "";
this.#state = "outside";
}
#consumeSection(final: boolean): boolean {
this.#skipWhitespace();
if (this.#buffer.length === 0) return false;
const token = this.#tokenAtStart();
if (token === KIMI_SECTION_END) {
this.#buffer = this.#buffer.slice(KIMI_SECTION_END.length);
this.#state = "outside";
return true;
}
if (token === KIMI_CALL_BEGIN) {
this.#buffer = this.#buffer.slice(KIMI_CALL_BEGIN.length);
this.#state = "header";
return true;
}
if (token) {
this.#buffer = this.#buffer.slice(token.length);
return true;
}
if (!final && partialSuffixOverlapAny(this.#buffer, TOKENS) === this.#buffer.length) return false;
this.#buffer = this.#buffer.slice(1);
return true;
}
#consumeHeader(final: boolean, events: InbandScanEvent[]): boolean {
const sep = this.#buffer.indexOf(KIMI_ARG_BEGIN);
if (sep === -1) {
if (final) this.#dropBufferedCall();
return false;
}
const rawHeader = this.#buffer.slice(0, sep);
this.#id = rawHeader.trim();
this.#name = normalizeKimiFunctionName(this.#id);
this.#rawBlock = `${KIMI_CALL_BEGIN}${rawHeader}${KIMI_ARG_BEGIN}`;
events.push({ type: "toolStart", id: this.#id, name: this.#name });
this.#buffer = this.#buffer.slice(sep + KIMI_ARG_BEGIN.length);
this.#state = "args";
return true;
}
#consumeArgs(final: boolean, events: InbandScanEvent[]): boolean {
const end = this.#buffer.indexOf(KIMI_CALL_END);
if (end === -1) {
if (final) this.#dropBufferedCall();
return false;
}
const rawArgsBlock = this.#buffer.slice(0, end);
const rawArgs = rawArgsBlock.trim();
events.push({
type: "toolEnd",
id: this.#id,
name: this.#name,
arguments: this.#parseArgs(rawArgs),
rawBlock: `${this.#rawBlock}${rawArgsBlock}${KIMI_CALL_END}`,
});
this.#buffer = this.#buffer.slice(end + KIMI_CALL_END.length);
this.#resetCall();
this.#state = "section";
return true;
}
#parseArgs(rawArgs: string): Record {
if (rawArgs.length === 0) return {};
try {
return asRecord(parseJsonWithRepair(rawArgs));
} catch {
return {};
}
}
#nextTokenIndex(): number {
let best = -1;
for (const token of TOKENS) {
const index = this.#buffer.indexOf(token);
if (index !== -1 && (best === -1 || index < best)) best = index;
}
return best;
}
#tokenAtStart(): string | undefined {
for (const token of TOKENS) {
if (this.#buffer.startsWith(token)) return token;
}
return undefined;
}
#skipWhitespace(): void {
let i = 0;
while (i < this.#buffer.length && isWhitespace(this.#buffer.charCodeAt(i))) i++;
if (i > 0) this.#buffer = this.#buffer.slice(i);
}
#dropBufferedCall(): void {
this.#buffer = "";
this.#resetCall();
this.#state = "outside";
}
#resetCall(): void {
this.#id = "";
this.#name = "";
this.#rawBlock = "";
}
}
function isWhitespace(cp: number): boolean {
return cp === 0x20 || cp === 0x09 || cp === 0x0a || cp === 0x0d || cp === 0x0b || cp === 0x0c;
}
function renderToolCall(call: ToolCall, _options?: DialectRenderOptions): string {
return kimiInvocation(call, 0);
}
function kimiInvocation(call: ToolCall, index: number): string {
return `${KIMI_CALL_BEGIN}${kimiCallId(call.name, call.id, index)}${KIMI_ARG_BEGIN}${stringifyJson(call.arguments)}${KIMI_CALL_END}`;
}
function renderAssistantToolCalls(calls: readonly ToolCall[], _options?: DialectRenderOptions): string {
if (calls.length === 0) return "";
const body = calls.map((call, index) => kimiInvocation(call, index)).join("");
return `${KIMI_SECTION_BEGIN}${body}${KIMI_SECTION_END}`;
}
function renderToolResults(results: readonly DialectToolResult[], _options?: DialectRenderOptions): string {
return results
.map(result =>
kimiTurn(
"system",
result.name,
`## Return of ${kimiCallId(result.name, result.id, result.index)}\n${result.text}`,
),
)
.join("");
}
function renderThinking(text: string): string {
if (!text) return "";
return `${THINK_OPEN}\n${text}\n${THINK_CLOSE}`;
}
function renderTranscript(messages: readonly Message[], _options?: DialectRenderOptions): string {
let out = "";
for (let i = 0; i < messages.length; ) {
const message = messages[i]!;
if (message.role === "assistant") {
const parts = assistantTranscriptParts(message);
out += kimiTurn(
"assistant",
"assistant",
`${renderThinking(parts.thinking)}${parts.text}${renderAssistantToolCalls(parts.toolCalls)}`,
);
i++;
continue;
}
if (message.role === "toolResult") {
const run = collectToolResultRun(messages, i);
out += renderToolResults(run.results);
i = run.next;
continue;
}
const name = message.role === "developer" ? "system" : message.role;
const role = message.role === "developer" ? "system" : message.role;
out += kimiTurn(role, name, messageContentText(message.content));
i++;
}
return out;
}
function kimiCallId(name: string, id: string, index: number): string {
const trimmed = id.trim();
return trimmed.startsWith("functions.") ? trimmed : `functions.${name}:${index}`;
}
function kimiTurn(role: "assistant" | "system" | "user", name: string, body: string): string {
return `<|im_${role}|>${name}<|im_middle|>${body}<|im_end|>`;
}
const definition: DialectDefinition = {
dialect: "kimi",
prompt: dialectPrompt,
createScanner: options => new KimiInbandScanner(options),
renderToolCall,
renderAssistantToolCalls,
renderToolResults,
renderThinking,
renderTranscript,
};
export default definition;