@kitn.ai/ui
Version:
Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.
494 lines (484 loc) • 25.3 kB
text/typescript
import { z } from 'zod';
import type { Tool } from './types';
/**
* debug — diagnose common AI/UI (kai-*) integration failures.
*
* Given a `symptom` (prose) and/or a `snippet` (code), returns the likely
* cause + fix for the classic `kai-*` failure modes so a harness can
* self-correct without a human in the loop.
*
* Rule set sourced from:
* - apps/docs/src/content/docs/guides/for-ai-agents.mdx §"What agents most commonly get wrong"
* - context7.json `rules` array (5 contract rules)
*/
interface Rule {
id: string;
test: (text: string) => boolean;
title: string;
cause: string;
fix: string;
}
const RULES: Rule[] = [
{
// Rule 1 — array/object data set as an HTML attribute
// Source: for-ai-agents.mdx §1; context7.json rule 2
id: 'array-as-attribute',
test: (t) =>
/\b(messages|models|context|suggestions|triggers)\s*=\s*["']/.test(t),
title: 'Array/object prop set as an HTML attribute (silent failure)',
cause:
'An HTML attribute is always a string. Passing `messages`, `models`, `context`, ' +
'`suggestions`, or `triggers` as an HTML attribute silently fails — ' +
'the element receives a stringified value it cannot parse.',
fix:
'Set the property in JavaScript, not as an HTML attribute. ' +
'Only scalar props (`placeholder`, `loading`, `theme`) work as attributes.\n\n' +
'```js\n' +
"// ✅ Works — set messages in JavaScript as a property\n" +
"const chat = document.querySelector('kai-chat');\n" +
"chat.messages = [{ id: '1', role: 'assistant', content: 'Hello!' }];\n" +
'```\n\n' +
'```html\n' +
'<!-- ❌ Fails — messages cannot be an HTML attribute -->\n' +
'<kai-chat messages="[...]"></kai-chat>\n' +
'```',
},
{
// Rule 2 — in-place mutation → no re-render
// Source: for-ai-agents.mdx §3; context7.json rule 4
id: 'in-place-mutation',
test: (t) =>
/don'?t\s+update|doesn'?t\s+re.?render|no\s+re.?render|\.push\(|\bpush(?:es|ing)?\s+(?:to|into|onto)\b|mutate|in.place/.test(t),
title: 'In-place mutation does not trigger a re-render',
cause:
'Mutating an existing message object or array in place (e.g. `chat.messages.push(…)` ' +
'or `chat.messages[i].content = …`) does not trigger a re-render. ' +
'The element only reacts when it detects a new array/object reference.',
fix:
'Assign a NEW array (and a new object) on every change — never mutate in place.\n\n' +
'```js\n' +
'// ✅ Triggers re-render — new array + new object reference\n' +
'chat.messages = [\n' +
' ...chat.messages,\n' +
" { id: crypto.randomUUID(), role: 'user', content: userText },\n" +
'];\n\n' +
'// ✅ During streaming — replace with a new array + new object on every chunk\n' +
'chat.messages = chat.messages.map((m) =>\n' +
' m.id === assistantId ? { ...m, content: accumulated } : m\n' +
');\n\n' +
'// ❌ Does NOT trigger re-render\n' +
'chat.messages.push(newMsg);\n' +
'chat.messages[i].content = accumulated;\n' +
'```\n\n' +
'The ergonomic path: helpers in `@kitn.ai/ui/state` (`appendMessage`, `updateMessage`, `appendContent`) ' +
'and `createAssistantStream` handle the new-reference contract for you. ' +
'`useKaiChat` (React) and `createKaiChat` (Solid) own state entirely so mutation is never an option.\n\n' +
'// setMessages((m) => appendMessage(m, msg)) // new array, no footgun',
},
{
// Rule 3 — listening for events on a parent / wrong element
// Source: for-ai-agents.mdx §2; context7.json rule 3
id: 'event-bubbling',
test: (t) =>
/event.*not\s+fir|not\s+fir.*event|listen.*document|document.*listen|listen.*parent|parent.*listen|event.*bubbl/i.test(
t,
),
title: 'Listening for events on the wrong element (events are non-bubbling)',
cause:
'`kai-*` events are non-bubbling CustomEvents. Adding a listener to `document`, ' +
'`window`, or a parent container will never fire because the event does not bubble up.',
fix:
'Listen directly on the `kai-*` element. ' +
'The submit event is `kai-submit` with `event.detail.value`.\n\n' +
'```js\n' +
"const chat = document.querySelector('kai-chat');\n\n" +
"// ✅ Listen directly on the element\n" +
"chat.addEventListener('kai-submit', (e) => {\n" +
' console.log(e.detail.value); // the text the user typed\n' +
'});\n\n' +
"// ❌ Never fires — kai-submit does not bubble\n" +
"document.addEventListener('kai-submit', handler);\n" +
'```\n\n' +
'Common events: `kai-submit`, `kai-feedback`, `kai-model-change`, `kai-new-chat`, `kai-select`.',
},
{
// Rule 4 — wrong element prefix kitn-
// Source: context7.json rule 1
id: 'wrong-prefix',
test: (t) => /\bkitn-/.test(t),
title: 'Wrong element prefix `kitn-` (should be `kai-`)',
cause:
'The custom element prefix is `kai-` (e.g. `<kai-chat>`, `<kai-artifact>`). ' +
'`kitn-` is a legacy name (the register-all bundle is `kai.es.js`) — it is not a ' +
'registered element name. Using `<kitn-chat>` results in an unknown element that renders nothing.',
fix:
'Replace the `kitn-` prefix with `kai-` everywhere.\n\n' +
'```html\n' +
'<!-- ✅ Correct element prefix -->\n' +
'<kai-chat></kai-chat>\n' +
'<kai-artifact></kai-artifact>\n\n' +
'<!-- ❌ Wrong — kitn-chat is not an element (the bundle is kai.es.js) -->\n' +
'<kitn-chat></kitn-chat>\n' +
'```',
},
{
// Rule 6 — custom elements not registered / renders nothing (React #1 failure)
// Source: field-test reports; for-ai-agents.mdx §"Import order matters"
id: 'elements-not-registered',
test: (t) => {
// Core render-nothing / unregistered-element signals
if (
/renders?\s+nothing|nothing\s+renders?|not\s+registered|unregistered|not\s+upgraded|unknown\s+element|customElements\.get|undefined\s+element|no\s+shadow\s+root/.test(t)
)
return true;
// "empty" / "blank" / "doesn't render" / "won't render" only fire when
// a render/element/component context is also present to avoid false positives
if (
/\b(empty|blank)\b/.test(t) &&
/render|element|component|kai-|<[a-z]+-|shadow/.test(t)
)
return true;
if (
/doesn'?t\s+render|won'?t\s+render/.test(t) &&
/kai-|element|component|custom.?element/.test(t)
)
return true;
return false;
},
title: 'Custom elements not registered — renders nothing / empty box',
cause:
'The `@kitn.ai/ui/react` wrappers (and bare `<kai-*>` tags) do NOT register the ' +
'custom elements by themselves. Without the element-registration side-effect import, ' +
'`<kai-chat>` / `<Chat>` is an un-upgraded unknown element — an empty box. ' +
'`customElements.get(\'kai-chat\') === undefined`.',
fix:
'Import the elements bundle for its side effect BEFORE your first render — ' +
'it must run before the component mounts.\n\n' +
'```tsx\n' +
"import '@kitn.ai/ui/elements' // registers <kai-*> — REQUIRED, must come first\n" +
"import { Chat } from '@kitn.ai/ui/react'\n" +
"import '@kitn.ai/ui/theme.css'\n" +
'```\n\n' +
'In plain HTML: `import \'@kitn.ai/ui/elements\'` in your module script. ' +
'The import is a side effect — keep it even if your linter flags it as "unused".',
},
{
// Rule 7 — tsc errors inside node_modules/@kitn.ai/ui/src (SolidJS source pulled in)
// Source: field-test reports; packaging gap (tracked upstream)
id: 'tsc-source-pull',
test: (t) =>
/node_modules\/@kitn\.ai\/ui/.test(t) &&
/tsc|TS2786|cannot\s+be\s+used\s+as\s+a\s+jsx\s+component|Show\b|Portal\b|Dynamic\b|error\s+TS|type\s+error/.test(
t,
),
title: 'tsc errors inside node_modules/@kitn.ai/ui/src (SolidJS source compiled under React)',
cause:
'The package currently ships TypeScript/TSX source, and a type entry value-re-exports ' +
'from it, so the consumer\'s `tsc` resolves and compiles the library\'s SolidJS internals ' +
'(`src/ui/*.tsx`) under the app\'s React JSX config — `Show`/`Portal`/`Dynamic` aren\'t ' +
'React components, causing TS2786 / "cannot be used as a JSX component" errors. ' +
'`vite`/esbuild build fine (they strip types); only `tsc` breaks. ' +
'`skipLibCheck` does not help (these are `.tsx` source, not `.d.ts`).',
fix:
'Redirect the type resolution for that subpath in your tsconfig ' +
'(Vite ignores tsconfig `paths`, so runtime is unaffected):\n\n' +
'```jsonc\n' +
'// tsconfig (app)\n' +
'"baseUrl": ".",\n' +
'"paths": { "@kitn.ai/ui/elements": ["./src/stubs/kitn-elements.d.ts"] }\n' +
'```\n\n' +
'```ts\n' +
'// src/stubs/kitn-elements.d.ts\n' +
'export {}\n' +
'```\n\n' +
'(This is a known packaging gap being tracked upstream.)',
},
{
// Rule 8 — fetch('/api/chat') 404 in a Vite SPA (no server-side routes)
// Source: field-test reports; common scaffold confusion
id: 'vite-api-404',
test: (t) => {
// /api/chat 404
if (/\/api\/chat/.test(t) && /\b404\b|not\s+found/i.test(t)) return true;
// Vite + missing API route / route handler / POST
if (/\bvite\b/.test(t) && /api\s+route|route\s+handler|\bPOST\b.*not\s+work/.test(t)) return true;
// Next.js route handler used in a Vite app
if (/next\.?js.*route|route.*next\.?js/.test(t) && /\bvite\b/.test(t)) return true;
return false;
},
title: 'fetch(\'/api/chat\') 404 — Vite SPA has no server-side API routes',
cause:
'A plain Vite/CRA React SPA has no server — there are no `/api` routes. ' +
'A scaffolded Next.js route handler (`export async function POST`) does not run there, ' +
'so `fetch(\'/api/chat\')` 404s.',
fix:
'Either run the backend somewhere real, or skip it entirely for local dev:\n\n' +
'```ts\n' +
'// Option A — use Next.js where route handlers are supported\n' +
"// app/api/chat/route.ts: export async function POST(req) { ... }\n\n" +
'// Option B — add a Vite dev-server middleware/proxy\n' +
"// vite.config.ts: server: { proxy: { '/api': 'http://localhost:3001' } }\n\n" +
'// Option C — run a separate Express/Hono server\n' +
"// framework: 'express' in your harness config\n\n" +
'// Option D — zero-config local dev with mock integration (no backend needed)\n' +
"// Use `integration: 'mock'` in the scaffold tool\n" +
'```',
},
{
// Rule 9 — reduce bundle size / footprint / "how much does @kitn.ai/ui add"
// Source: dist/elements/<file>.js per-element exports; dist/autoloader.js
id: 'bundle-footprint',
test: (t) =>
/bundle\s*size|footprint|tree.?shak|how\s+much.*does.*@kitn|reduce.*import|import.*only.*element|per.?element\s+import|autoload|cdn.*no.?build|no.?build.*cdn/i.test(
t,
),
title: 'Reducing bundle footprint — three load modes',
cause:
'The default `import \'@kitn.ai/ui/elements\'` registers every `kai-*` element. ' +
'If your page uses only one or two elements, that pulls in the full ~119 KB gz bundle. ' +
'Two opt-in modes let you load only what you need.',
fix:
'**Mode 1 — register-all (default, SSR-safe):**\n' +
'Best for multi-element apps or any SSR/meta-framework. ' +
'Load once and every `kai-*` element is available.\n\n' +
'```js\n' +
"import '@kitn.ai/ui/elements'; // ~119 KB gz — registers everything\n" +
'```\n\n' +
'**Mode 2 — per-element import (tree-shaking, bundler apps):**\n' +
'Use `import \'@kitn.ai/ui/elements/<file>\'` to register only one element. ' +
'A bundler (Vite, webpack, Rollup) will tree-shake to just its chunks (~73 KB gz for `kai-chat` alone). ' +
'Client-only — do not use in SSR entry points.\n\n' +
'```js\n' +
"// Registers only <kai-chat> (~73 KB gz vs ~119 KB gz register-all)\n" +
"import '@kitn.ai/ui/elements/chat';\n\n" +
"// Other examples:\n" +
"import '@kitn.ai/ui/elements/code-block'; // <kai-code-block>\n" +
"import '@kitn.ai/ui/elements/confirm-card'; // <kai-confirm>\n" +
'```\n\n' +
'The file name is the element\'s source basename from `element-manifest.json` ' +
'(e.g. `kai-chat` → `chat`, `kai-confirm` → `confirm-card`).\n\n' +
'**Mode 3 — autoloader (no-build / CDN pages only):**\n' +
'Watches the DOM and dynamically imports each `kai-*` element\'s module on demand. ' +
'A page that uses only `<kai-chat>` never downloads the other elements. ' +
'It is a CDN / static-file tool — load it from a `<script type="module">` tag. ' +
'It is NOT importable through a bundler: Vite/webpack relocate it and the on-demand imports 404. ' +
'Client-only.\n\n' +
'```html\n' +
'<script type="module" src="https://cdn.jsdelivr.net/npm/@kitn.ai/ui@<version>/dist/elements/autoloader.js"></script>\n' +
'```\n\n' +
'In a BUNDLED app (Vite/webpack/Next) use Mode 1 or Mode 2 instead — not the autoloader.\n\n' +
'**SSR note:** use Mode 1 (register-all) in SSR apps — ' +
'per-element imports and the autoloader are client-only (they call DOM APIs at module eval). ' +
'Modes 1 & 2 are side-effect imports; keep them even if your linter flags them as "unused".',
},
{
// Rule 5 — SSR / server component / document is not defined
// Source: for-ai-agents.mdx (client-only import); context7.json rule 2 (property rule requires DOM)
id: 'ssr-server-component',
test: (t) => {
// Core SSR signals — always trigger
if (/\bssr\b|server\s+component|document\s+is\s+not\s+defined|window\s+is\s+not\s+defined|next\.?js.*server|server.*next\.?js/.test(t)) return true;
// "hydration" only triggers when a web-component / kai context is also present
if (/hydration/.test(t) && /kai|web.?component|custom.?element|<[a-z]+-/.test(t)) return true;
return false;
},
title: 'SSR / server-side rendering — element requires the browser DOM',
cause:
'`kai-*` elements are client-side web components. They require `document` and ' +
'`customElements` to register and render. Importing them in a server component ' +
'(Next.js App Router server component, Nuxt SSR, etc.) throws ' +
'"document is not defined" or silently produces no output.',
fix:
'Register the element on the client only. ' +
'Use your framework\'s "client-only" / island / dynamic-import pattern.\n\n' +
'```js\n' +
"// ✅ Plain HTML / vanilla — import in a <script type=\"module\">\n" +
"import '@kitn.ai/ui/elements';\n\n" +
'// ✅ Next.js App Router — mark the component with "use client"\n' +
"'use client';\n" +
"import '@kitn.ai/ui/elements';\n\n" +
'// ✅ Next.js — dynamic import with ssr: false\n' +
"import dynamic from 'next/dynamic';\n" +
"const KaiChat = dynamic(() => import('@kitn.ai/ui/elements').then(() => 'kai-chat'), { ssr: false });\n\n" +
'// ✅ React wrapper (already client-safe)\n' +
"import { Chat } from '@kitn.ai/ui/react';\n" +
'```',
},
{
// Rule 10 — toast() is the imperative API; there is no <kai-toast> to place
// Source: src/primitives/toast-store.ts (the `toast` fn + auto-mounted region)
id: 'toast-imperative',
test: (t) => {
// Placing a toast element by hand (the region auto-mounts — you never write it).
if (/<kai-toast(-region)?\b/.test(t)) return true;
// Asking how to show / trigger a toast or notification with kai context.
if (
/\btoast(s)?\b|notification|snackbar/i.test(t) &&
/how.*(show|raise|trigger|fire|display)|show.*toast|raise.*toast|trigger.*toast|toast.*(not|isn'?t|won'?t).*(show|appear|render)|where.*toast|add.*toast|kai-|@kitn/i.test(t)
)
return true;
return false;
},
title: 'Toast is an imperative call — `toast(\'…\')`, not a `<kai-toast>` you place',
cause:
'Toasts are raised IMPERATIVELY by calling `toast(message)` — there is no ' +
'`<kai-toast>` element you add to your markup. The first call lazily mounts ONE ' +
'`<kai-toast-region>` on `document.body` (a real, kit-styled, viewport-positioned ' +
'element) and every later toast feeds that same region. Trying to place a toast ' +
'element by hand, or looking for a `messages`/`toasts` prop to push into, is the ' +
'wrong model.',
fix:
'Import `toast` and call it. It is exported from BOTH the root `@kitn.ai/ui` and ' +
'the `@kitn.ai/ui/elements` bundle, so the web-components-only consumer gets it too. ' +
'It is SSR-safe (no DOM is touched until the first call on the client).\n\n' +
'```js\n' +
"import { toast } from '@kitn.ai/ui/elements'; // or '@kitn.ai/ui'\n\n" +
"// ✅ Fire-and-forget\n" +
"toast('Copied to clipboard');\n" +
"toast.success('Saved');\n\n" +
"// ✅ With an Undo action + an imperative handle\n" +
"const t = toast('Item deleted', {\n" +
" action: { label: 'Undo', onAction: () => restore() },\n" +
'});\n' +
"t.update({ message: 'Restored', variant: 'success' });\n" +
't.dismiss();\n' +
'```\n\n' +
'The auto-mounted `<kai-toast-region>` carries its own shadow root + kit styles — ' +
'do NOT add a `<kai-toast-region>` tag yourself unless you deliberately want a ' +
'second, declaratively-controlled region.',
},
{
// Rule 11 — dismissed cards are DEFERRED (reopenable stub), not deleted
// Source: src/primitives/card-recovery.ts (dismissRecovery) + the dismissed stub
id: 'card-dismiss-deferred',
test: (t) => {
const cardCtx = /\bcard(s)?\b|envelope|kai-card|kai-cards|kai-confirm|kai-choice|kai-tasks|kai-form|generative.?ui|resolution|dismissRecovery/i;
if (!cardCtx.test(t)) return false;
// dismiss / reopen / undo / disappear / filter-out signals in a card context.
return /dismiss|reopen|re-?open|\bundo\b|disappear|remove.*card|card.*(gone|remove|delete|vanish)|filter.*out|stub/i.test(t);
},
title: 'Dismissed cards are DEFERRED (a reopenable stub), not deleted',
cause:
'Dismissing a generative-UI card does NOT delete its envelope from history. The ' +
'card stamps a `{ kind: \'dismissed\' }` resolution onto its envelope and collapses ' +
'to a small reopenable stub ("Proposed: <title> — dismissed · Reopen"). If you ' +
'filter `dismissed` envelopes out of your cards array, the stub vanishes and the ' +
'user can never reopen it — and you lose the audit trail of what was proposed.',
fix:
'Keep dismissed envelopes in the array. Wire dismiss/reopen with `dismissRecovery()` ' +
'(from `@kitn.ai/ui`), which builds the `onDismiss`/`onReopen` half of a `CardPolicy` ' +
'over your store and can show a "Dismissed · Undo" toast via an injected adapter.\n\n' +
'```ts\n' +
"import { dismissRecovery } from '@kitn.ai/ui';\n" +
"import { toast } from '@kitn.ai/ui/elements';\n\n" +
'// Adapter: map dismissRecovery\'s toast shape onto the imperative toast().\n' +
'const toastAdapter = {\n' +
' show: ({ message, action, durationMs }) => {\n' +
" const handle = toast(message, {\n" +
' duration: durationMs,\n' +
' action: action && { label: action.label, onAction: action.onClick },\n' +
' });\n' +
' return { dismiss: handle.dismiss };\n' +
' },\n' +
'};\n\n' +
'const { onDismiss, onReopen } = dismissRecovery({\n' +
' get: () => cards, // your current envelopes\n' +
' set: (next) => setCards(next), // NEW array reference (never mutate in place)\n' +
' toast: toastAdapter,\n' +
'});\n' +
'// Pass these on the CardPolicy you hand to <kai-cards> / <kai-remote>.\n' +
'```\n\n' +
'`onDismiss` writes `dismissed` immutably (Undo restores the prior resolution); ' +
'`onReopen` clears it back to live (or stamps `expired` when the host says the card ' +
'is no longer reopenable). Never mutate the array in place — re-render needs a new ref.',
},
{
// Rule 12 — kai-compare contract: two candidates, JS data prop, stream both, terminal pick
// Source: src/elements/compare.tsx + src/components/response-compare-types.ts
id: 'compare-contract',
test: (t) => {
if (/<kai-compare\b|kai-compare-select|ResponseCompareData|response.?compare/i.test(t)) return true;
// "compare two responses / candidates / A vs B" with a kai/UI context.
if (
/compar(e|ing|ison)|side.by.side|a\/b|two\s+(responses|candidates|answers|completions)|dual.?response/i.test(t) &&
/kai-|@kitn|candidate|prefer(ence)?|chosen|reject/i.test(t)
)
return true;
return false;
},
title: '`kai-compare` — two candidates, `data` as a JS property, terminal pick',
cause:
'`<kai-compare>` shows EXACTLY two assistant candidates for one prompt and lets the ' +
'user pick the better one. The `data` value is an array/object, so it must be set as ' +
'a JS PROPERTY (never an HTML attribute). Both candidates can stream — but, like ' +
'`kai-chat`, that needs a NEW `data` reference per chunk (mutating in place will not ' +
're-render). The pick is a COMMIT (not a Submit): it fires once and the card collapses.',
fix:
'Set `data` in JS with two candidates, stream by reassigning a fresh `data` object ' +
'per chunk, and listen for `kai-compare-select` directly on the element.\n\n' +
'```ts\n' +
"import { toast } from '@kitn.ai/ui/elements';\n" +
"import type { ResponseCompareData, CompareSelection } from '@kitn.ai/ui';\n\n" +
"const el = document.querySelector('kai-compare')!;\n" +
'// data is a JS PROPERTY — exactly two candidates, each with a unique id.\n' +
'el.data = {\n' +
" prompt: 'Summarise the report',\n" +
' candidates: [\n' +
" { id: 'a', content: '', streaming: true },\n" +
" { id: 'b', content: '', streaming: true },\n" +
' ],\n' +
'} satisfies ResponseCompareData;\n\n' +
'// Stream BOTH columns: replace data with a NEW object per chunk.\n' +
"el.data = { ...el.data, candidates: [{ ...a, content: aText }, { ...b, content: bText }] };\n" +
'// Clear `streaming` on a candidate when it settles — the pick stays disabled\n' +
'// until BOTH have settled, then `kai-ready` fires.\n\n' +
"// Picking is terminal: emits { chosenId, rejectedIds, at } and collapses.\n" +
"el.addEventListener('kai-compare-select', (e) => {\n" +
' const { chosenId, rejectedIds } = (e as CustomEvent<CompareSelection>).detail;\n' +
' recordPreference({ prompt, chosen: chosenId, rejected: rejectedIds });\n' +
'});\n' +
'```\n\n' +
'A malformed definition (not two candidates, missing/duplicate ids) fires `kai-error` ' +
'instead. The event is non-bubbling — listen on the element, not on `document`.',
},
];
function buildText(matched: Rule[]): string {
if (matched.length === 0) {
return (
'No known failure pattern matched. Suggested next steps:\n\n' +
'1. Use the `component_reference` tool to look up the real API for the element ' +
'(prop names, event names, attribute vs. property distinction).\n' +
'2. Check the **Streaming recipe** in `llms-full.txt` ' +
'(`node_modules/@kitn.ai/ui/llms-full.txt` or https://ui.kitn.ai/llms-full.txt) ' +
'for correct streaming wiring.\n' +
'3. Paste `https://ui.kitn.ai/llms.txt` into your prompt for a compact orientation.'
);
}
const sections = matched.map((rule, i) => {
const n = i + 1;
return `## ${n}. ${rule.title}\n\n**Cause:** ${rule.cause}\n\n**Fix:** ${rule.fix}`;
});
const header =
matched.length === 1
? '1 likely cause found:\n\n'
: `${matched.length} likely causes found:\n\n`;
return header + sections.join('\n\n---\n\n');
}
export const debug: Tool = {
name: 'debug',
description:
'Diagnose common AI/UI (kai-*) integration issues. ' +
'Provide a `symptom` (prose description) and/or a `snippet` (code) ' +
'and receive the likely cause + fix for classic kai-* failure modes.',
inputSchema: z.object({
symptom: z.string().optional(),
snippet: z.string().optional(),
}),
handler: async (args) => {
const combined = `${(args.symptom as string | undefined) ?? ''} ${(args.snippet as string | undefined) ?? ''}`;
const matched = RULES.filter((rule) => rule.test(combined));
return {
content: [{ type: 'text', text: buildText(matched) }],
};
},
};