From 4aedc1b47fcfdd6d57ab75798d270fd4b2b8adcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 30 Apr 2026 23:27:08 -0300 Subject: [PATCH] fix: ulid --- .../src/kiloclaw/KiloClawProvider.ts | 7 +++- .../src/kiloclaw/kilo-chat-client.ts | 19 ++++++++- packages/kilo-vscode/src/kiloclaw/types.ts | 1 - packages/kilo-vscode/src/kiloclaw/ulid.ts | 41 +++++++++++++++++++ .../webview-ui/kiloclaw/context/claw.tsx | 13 ------ .../src/kilocode/claw/kilo-chat-client.ts | 17 +++++++- 6 files changed, 80 insertions(+), 18 deletions(-) create mode 100644 packages/kilo-vscode/src/kiloclaw/ulid.ts diff --git a/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts b/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts index 27ffcba2ab5..54470d8af7d 100644 --- a/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts +++ b/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts @@ -19,6 +19,7 @@ import { watchFontSizeConfig } from "../kilo-provider/font-size" import { TokenManager } from "./token-manager" import { KiloChatApiError, KiloChatClient } from "./kilo-chat-client" import { EventServiceClient, WebSocketAuthError } from "./event-service-client" +import { ulid } from "./ulid" import type { ActionDeliveryFailedEvent, BotStatusEvent, @@ -206,7 +207,7 @@ export class KiloClawProvider implements vscode.Disposable { await this.loadMoreConversations() return case "kiloclaw.sendMessage": - await this.sendMessage(msg.conversationId, msg.content, msg.clientId, msg.inReplyToMessageId) + await this.sendMessage(msg.conversationId, msg.content, msg.inReplyToMessageId) return case "kiloclaw.editMessage": await this.editMessage(msg.conversationId, msg.messageId, msg.content) @@ -790,12 +791,14 @@ export class KiloClawProvider implements vscode.Disposable { private async sendMessage( conversationId: string, content: ContentBlock[], - clientId: string, inReplyToMessageId?: string, ): Promise { if (!this.chat) return if (!this.currentUserId) return + // kilo-chat validates clientId as a ULID (Crockford Base32); generate + // it here so the webview doesn't need to know the format. + const clientId = ulid() const pendingId = `pending-${clientId}` const optimistic: Message = { id: pendingId, diff --git a/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts b/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts index aad2983dd0d..51ddb59ee4d 100644 --- a/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts +++ b/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts @@ -28,11 +28,28 @@ export class KiloChatApiError extends Error { public readonly status: number, public readonly body: unknown, ) { - super(`KiloChat request failed: ${status}`) + super(`KiloChat request failed: ${status}${formatBodyDetail(body)}`) this.name = "KiloChatApiError" } } +function formatBodyDetail(body: unknown): string { + if (body === null || body === undefined) return "" + if (typeof body === "string") return ` - ${body}` + if (typeof body === "object") { + const err = (body as Record).error + if (typeof err === "string") return ` - ${err}` + // Fall back to a compact JSON dump so validation errors (zod issues, etc.) + // show up in the extension's Output channel without a separate logging hop. + try { + return ` - ${JSON.stringify(body)}` + } catch { + return "" + } + } + return "" +} + type HttpOpts = { method?: string body?: unknown diff --git a/packages/kilo-vscode/src/kiloclaw/types.ts b/packages/kilo-vscode/src/kiloclaw/types.ts index ce84d30d25f..826897facda 100644 --- a/packages/kilo-vscode/src/kiloclaw/types.ts +++ b/packages/kilo-vscode/src/kiloclaw/types.ts @@ -240,7 +240,6 @@ export type KiloClawInMessage = conversationId: string content: ContentBlock[] inReplyToMessageId?: string - clientId: string } | { type: "kiloclaw.editMessage"; conversationId: string; messageId: string; content: ContentBlock[] } | { type: "kiloclaw.deleteMessage"; conversationId: string; messageId: string } diff --git a/packages/kilo-vscode/src/kiloclaw/ulid.ts b/packages/kilo-vscode/src/kiloclaw/ulid.ts new file mode 100644 index 00000000000..a55bda569d3 --- /dev/null +++ b/packages/kilo-vscode/src/kiloclaw/ulid.ts @@ -0,0 +1,41 @@ +/** + * Minimal ULID generator for the extension host. + * + * Produces a 26-character Crockford Base32 identifier: 10 time chars + * followed by 16 random chars. The kilo-chat worker validates clientId + * as a ULID, so `generateClientId` must emit only Crockford-legal + * characters — `toString(36)` is NOT safe because base36 includes + * I, L, O, and U which are excluded from Crockford Base32. + */ + +// Crockford Base32 — no I, L, O, U (reduces transcription ambiguity). +const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +const ENCODING_LEN = ENCODING.length +const TIME_LEN = 10 +const RANDOM_LEN = 16 + +function encodeTime(ts: number): string { + let out = "" + let n = ts + for (let i = 0; i < TIME_LEN; i++) { + const mod = n % ENCODING_LEN + out = ENCODING[mod] + out + n = (n - mod) / ENCODING_LEN + } + return out +} + +function encodeRandom(): string { + const bytes = new Uint8Array(RANDOM_LEN) + globalThis.crypto.getRandomValues(bytes) + let out = "" + for (let i = 0; i < RANDOM_LEN; i++) { + out += ENCODING[bytes[i]! % ENCODING_LEN] + } + return out +} + +/** Generate a ULID at the current epoch. */ +export function ulid(): string { + return encodeTime(Date.now()) + encodeRandom() +} diff --git a/packages/kilo-vscode/webview-ui/kiloclaw/context/claw.tsx b/packages/kilo-vscode/webview-ui/kiloclaw/context/claw.tsx index a50725fe964..a0a1af1d8cf 100644 --- a/packages/kilo-vscode/webview-ui/kiloclaw/context/claw.tsx +++ b/packages/kilo-vscode/webview-ui/kiloclaw/context/claw.tsx @@ -86,18 +86,6 @@ export type ClawCtx = { const ClawContext = createContext() -function generateClientId(): string { - // Lightweight ULID-ish identifier. The server only uses this to correlate - // optimistic messages with their server-assigned id, so collisions only - // matter within a single send burst (never realistic). 26 alphanum chars. - const ts = Date.now().toString(36).padStart(10, "0") - let rand = "" - for (let i = 0; i < 16; i++) { - rand += Math.floor(Math.random() * 36).toString(36) - } - return (ts + rand).toUpperCase().slice(0, 26) -} - export function ClawProvider(props: { children: JSX.Element }) { const [phase, setPhase] = createSignal("loading") const [locale, setLocale] = createSignal(undefined) @@ -264,7 +252,6 @@ export function ClawProvider(props: { children: JSX.Element }) { conversationId, content, inReplyToMessageId, - clientId: generateClientId(), }), editMessage: (conversationId, messageId, content) => vscode.postMessage({ type: "kiloclaw.editMessage", conversationId, messageId, content }), diff --git a/packages/opencode/src/kilocode/claw/kilo-chat-client.ts b/packages/opencode/src/kilocode/claw/kilo-chat-client.ts index 0b82675b4e9..7b5c73d7391 100644 --- a/packages/opencode/src/kilocode/claw/kilo-chat-client.ts +++ b/packages/opencode/src/kilocode/claw/kilo-chat-client.ts @@ -28,11 +28,26 @@ export class KiloChatApiError extends Error { public readonly status: number, public readonly body: unknown, ) { - super(`KiloChat request failed: ${status}`) + super(`KiloChat request failed: ${status}${formatBodyDetail(body)}`) this.name = "KiloChatApiError" } } +function formatBodyDetail(body: unknown): string { + if (body === null || body === undefined) return "" + if (typeof body === "string") return ` - ${body}` + if (typeof body === "object") { + const err = (body as Record).error + if (typeof err === "string") return ` - ${err}` + try { + return ` - ${JSON.stringify(body)}` + } catch { + return "" + } + } + return "" +} + type HttpOpts = { method?: string body?: unknown