create-kai 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,8 +15,17 @@ history + the kit's local mock: a project that streams a reply on the first
15
15
 
16
16
  **First slice of v1.** A `ready` framework has been scaffolded, installed from
17
17
  the registry, built by its own build script, and driven in a browser — a message
18
- sent, and a reply streaming into `<kai-thread>`. Remaining frameworks, layouts
19
- and gateways are declared in the tables but not offered.
18
+ sent, and a reply streaming into `<kai-thread>`. Remaining frameworks, layouts,
19
+ features and gateways are declared in the tables but not offered.
20
+
21
+ **The prompt offers only what scaffolds, and states the rest.** One surface runs
22
+ today — the hand-composed workspace, with conversation history — so the CLI says
23
+ so rather than showing a menu of six and refusing five of them after the last
24
+ question. Where an axis has one possible answer it is stated, not asked, and
25
+ `--features <id>` for anything unavailable fails immediately with what IS
26
+ available, before a file is written. `test/menu-honesty.test.ts` drives every
27
+ option the prompt offers through `generate()`, so a menu item that cannot be
28
+ scaffolded fails a test rather than a user.
20
29
 
21
30
  **`create-kai --list` is the roster.** This paragraph used to name which
22
31
  frameworks were ready, and it was wrong within a day of each one landing — so
package/dist/index.js CHANGED
@@ -789,6 +789,29 @@ ${import_picocolors2.default.gray(d2)} ${t}
789
789
 
790
790
  `);
791
791
  };
792
+ var M2 = { message: (t = "", { symbol: n = import_picocolors2.default.gray(o) } = {}) => {
793
+ const r2 = [`${import_picocolors2.default.gray(o)}`];
794
+ if (t) {
795
+ const [i, ...s] = t.split(`
796
+ `);
797
+ r2.push(`${n} ${i}`, ...s.map((c) => `${import_picocolors2.default.gray(o)} ${c}`));
798
+ }
799
+ process.stdout.write(`${r2.join(`
800
+ `)}
801
+ `);
802
+ }, info: (t) => {
803
+ M2.message(t, { symbol: import_picocolors2.default.blue(q) });
804
+ }, success: (t) => {
805
+ M2.message(t, { symbol: import_picocolors2.default.green(D) });
806
+ }, step: (t) => {
807
+ M2.message(t, { symbol: import_picocolors2.default.green(C) });
808
+ }, warn: (t) => {
809
+ M2.message(t, { symbol: import_picocolors2.default.yellow(U) });
810
+ }, warning: (t) => {
811
+ M2.warn(t);
812
+ }, error: (t) => {
813
+ M2.message(t, { symbol: import_picocolors2.default.red(K2) });
814
+ } };
792
815
  var J2 = `${import_picocolors2.default.gray(o)} `;
793
816
  var Y2 = ({ indicator: t = "dots" } = {}) => {
794
817
  const n = V2 ? ["\u25D2", "\u25D0", "\u25D3", "\u25D1"] : ["\u2022", "o", "O", "0"], r2 = V2 ? 80 : 120, i = process.env.CI === "true";
@@ -856,7 +879,13 @@ var openai = {
856
879
  // Both model and tools come from the browser. \`tools\` is undefined unless the
857
880
  // front end declared any, and JSON.stringify drops it, so the same handler
858
881
  // serves a tool archetype and a plain chat.
859
- const { model, messages, tools } = await readChatRequest(request);
882
+ let chatBody: ChatRequestBody;
883
+ try {
884
+ chatBody = await readChatRequest(request);
885
+ } catch (error) {
886
+ return toChatErrorResponse(error);
887
+ }
888
+ const { model, messages, tools } = chatBody;
860
889
 
861
890
  const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
862
891
  method: 'POST',
@@ -1223,7 +1252,13 @@ function reframeToOpenAISse(body: ReadableStream<Uint8Array>): ReadableStream<Ui
1223
1252
  async function chatHandler(request: Request): Promise<Response> {
1224
1253
  // Both model and tools come from the browser. \`tools\` arrives in OpenAI
1225
1254
  // function form and is converted to this API's shape below.
1226
- const { model, messages, tools } = await readChatRequest(request);
1255
+ let chatBody: ChatRequestBody;
1256
+ try {
1257
+ chatBody = await readChatRequest(request);
1258
+ } catch (error) {
1259
+ return toChatErrorResponse(error);
1260
+ }
1261
+ const { model, messages, tools } = chatBody;
1227
1262
  const { system, messages: anthropicMessages } = toAnthropicBody(messages);
1228
1263
  const anthropicTools = toAnthropicTools(tools);
1229
1264
 
@@ -1353,7 +1388,13 @@ var openrouter = {
1353
1388
  webRoute: `async function chatHandler(request: Request): Promise<Response> {
1354
1389
  // tools is undefined unless the front end sent one; JSON.stringify drops it,
1355
1390
  // so the same handler serves a tool archetype and a plain chat.
1356
- const { model, messages, tools } = await readChatRequest(request);
1391
+ let chatBody: ChatRequestBody;
1392
+ try {
1393
+ chatBody = await readChatRequest(request);
1394
+ } catch (error) {
1395
+ return toChatErrorResponse(error);
1396
+ }
1397
+ const { model, messages, tools } = chatBody;
1357
1398
 
1358
1399
  const upstream = await fetch('https://openrouter.ai/api/v1/chat/completions', {
1359
1400
  method: 'POST',
@@ -1582,7 +1623,13 @@ const FINISH_REASONS: Record<string, string> = {
1582
1623
  };
1583
1624
 
1584
1625
  async function chatHandler(request: Request): Promise<Response> {
1585
- const { messages, tools } = await readChatRequest(request);
1626
+ let chatBody: ChatRequestBody;
1627
+ try {
1628
+ chatBody = await readChatRequest(request);
1629
+ } catch (error) {
1630
+ return toChatErrorResponse(error);
1631
+ }
1632
+ const { messages, tools } = chatBody;
1586
1633
  const toolSet = toToolSet(tools);
1587
1634
  const prompt = toModelMessages(messages);
1588
1635
 
@@ -1833,7 +1880,13 @@ const agent = createReactAgent({
1833
1880
 
1834
1881
  // Stream a compiled LangGraph agent to the browser as OpenAI-format SSE.
1835
1882
  async function chatHandler(request: Request): Promise<Response> {
1836
- const { messages } = await readChatRequest(request);
1883
+ let chatBody: ChatRequestBody;
1884
+ try {
1885
+ chatBody = await readChatRequest(request);
1886
+ } catch (error) {
1887
+ return toChatErrorResponse(error);
1888
+ }
1889
+ const { messages } = chatBody;
1837
1890
 
1838
1891
  // agent.stream() coerces plain {role, content} objects into BaseMessage
1839
1892
  // instances itself, including OpenAI-shaped tool_calls, so the wire messages
@@ -1952,9 +2005,44 @@ import type { OpenAIWireMessage } from '@kitn.ai/ui/wire';
1952
2005
  */
1953
2006
  type ChatRequestBody = { messages: OpenAIWireMessage[] };
1954
2007
 
2008
+ class ChatRequestError extends Error {
2009
+ constructor(readonly status: number, message: string) { super(message); }
2010
+ }
2011
+
2012
+ /** Narrow the JSON body once, at the edge. A bare GET, a malformed body, or a
2013
+ * missing messages array is a ChatRequestError with a status \u2014 NEVER an
2014
+ * unhandled SyntaxError (findings F-10). */
2015
+ async function readChatRequest(request: Request): Promise<ChatRequestBody> {
2016
+ if (request.method !== 'POST') {
2017
+ throw new ChatRequestError(405, \`Method \${request.method} not allowed \u2014 POST /api/chat.\`);
2018
+ }
2019
+ let parsed: unknown;
2020
+ try { parsed = await request.json(); } catch {
2021
+ throw new ChatRequestError(400, 'Request body is not valid JSON.');
2022
+ }
2023
+ const body = parsed as ChatRequestBody;
2024
+ if (!Array.isArray(body?.messages)) {
2025
+ throw new ChatRequestError(400, 'Request body must carry a messages array.');
2026
+ }
2027
+ return body;
2028
+ }
2029
+
2030
+ /** Map a guard rejection to the Response its status demands; rethrow anything
2031
+ * else \u2014 an unexpected error should be loud, not laundered into a 400. */
2032
+ function toChatErrorResponse(error: unknown): Response {
2033
+ if (error instanceof ChatRequestError) return Response.json({ error: error.message }, { status: error.status });
2034
+ throw error;
2035
+ }
2036
+
1955
2037
  export default {
1956
2038
  async fetch(req: Request, env: Env): Promise<Response> {
1957
- const { messages } = (await req.json()) as ChatRequestBody;
2039
+ let chatBody: ChatRequestBody;
2040
+ try {
2041
+ chatBody = await readChatRequest(req);
2042
+ } catch (error) {
2043
+ return toChatErrorResponse(error);
2044
+ }
2045
+ const { messages } = chatBody;
1958
2046
 
1959
2047
  let nativeStream: ReadableStream<Uint8Array>;
1960
2048
  try {
@@ -2029,7 +2117,13 @@ export default {
2029
2117
  },
2030
2118
  webRoute: `async function chatHandler(request: Request): Promise<Response> {
2031
2119
  // Proxy Workers AI over its OpenAI-compatible HTTP endpoint, token server-side.
2032
- const { messages } = await readChatRequest(request);
2120
+ let chatBody: ChatRequestBody;
2121
+ try {
2122
+ chatBody = await readChatRequest(request);
2123
+ } catch (error) {
2124
+ return toChatErrorResponse(error);
2125
+ }
2126
+ const { messages } = chatBody;
2033
2127
 
2034
2128
  const upstream = await fetch(
2035
2129
  \`https://api.cloudflare.com/client/v4/accounts/\${process.env.CF_ACCOUNT_ID}/ai/v1/chat/completions\`,
@@ -2123,7 +2217,13 @@ var ollama = {
2123
2217
  // The model is pinned here, not sent by the browser. tools IS forwarded:
2124
2218
  // it is undefined unless the front end declared any, and JSON.stringify
2125
2219
  // drops it, so the same handler serves a tool archetype and a plain chat.
2126
- const { messages, tools } = await readChatRequest(request);
2220
+ let chatBody: ChatRequestBody;
2221
+ try {
2222
+ chatBody = await readChatRequest(request);
2223
+ } catch (error) {
2224
+ return toChatErrorResponse(error);
2225
+ }
2226
+ const { messages, tools } = chatBody;
2127
2227
 
2128
2228
  const upstream = await fetch('http://localhost:11434/v1/chat/completions', {
2129
2229
  method: 'POST',
@@ -2220,7 +2320,13 @@ const mastra = new MastraClient({ baseUrl: MASTRA_URL });
2220
2320
 
2221
2321
  // Proxy a Mastra agent to the browser as OpenAI-format SSE.
2222
2322
  async function chatHandler(request: Request): Promise<Response> {
2223
- const { messages } = await readChatRequest(request);
2323
+ let chatBody: ChatRequestBody;
2324
+ try {
2325
+ chatBody = await readChatRequest(request);
2326
+ } catch (error) {
2327
+ return toChatErrorResponse(error);
2328
+ }
2329
+ const { messages } = chatBody;
2224
2330
 
2225
2331
  // MastraClient's Agent.stream() takes AI-SDK CoreMessage[], not the OpenAI
2226
2332
  // wire format: each message has ONE literal role (not a union) and content
@@ -2387,9 +2493,64 @@ import type { OpenAIWireMessage } from '@kitn.ai/ui/wire';
2387
2493
  const app = express();
2388
2494
  app.use(express.json());
2389
2495
 
2496
+ class ChatRequestError extends Error {
2497
+ constructor(readonly status: number, message: string) { super(message); }
2498
+ }
2499
+
2500
+ /** Narrow the JSON body once, at the edge. A bare GET or a missing messages
2501
+ * array is a ChatRequestError with a status \u2014 NEVER an unhandled throw
2502
+ * (findings F-10). Malformed JSON is rejected upstream by express.json(),
2503
+ * which the error handler below turns into a JSON 400. */
2504
+ function readChatRequest(req: express.Request): { messages: OpenAIWireMessage[] } {
2505
+ if (req.method !== 'POST') {
2506
+ throw new ChatRequestError(405, \`Method \${req.method} not allowed \u2014 POST /api/chat.\`);
2507
+ }
2508
+ const body = (req.body ?? {}) as { messages?: OpenAIWireMessage[] };
2509
+ if (!Array.isArray(body.messages)) {
2510
+ throw new ChatRequestError(400, 'Request body must carry a messages array.');
2511
+ }
2512
+ return body as { messages: OpenAIWireMessage[] };
2513
+ }
2514
+
2515
+ /** Map a guard rejection to the response its status demands; rethrow anything
2516
+ * else \u2014 an unexpected error should be loud, not laundered into a 400. */
2517
+ function toChatErrorResponse(error: unknown, res: express.Response): void {
2518
+ if (error instanceof ChatRequestError) {
2519
+ res.status(error.status).json({ error: { message: error.message } });
2520
+ return;
2521
+ }
2522
+ throw error;
2523
+ }
2524
+
2525
+ // express.json() rejects a malformed body at the MIDDLEWARE level, before any
2526
+ // handler runs \u2014 without this it surfaces as Express's default HTML error page.
2527
+ app.use(
2528
+ (
2529
+ err: { status?: number; message?: string },
2530
+ _req: express.Request,
2531
+ res: express.Response,
2532
+ _next: express.NextFunction,
2533
+ ) => {
2534
+ res.status(err?.status ?? 500).json({ error: { message: err?.message ?? 'Request failed.' } });
2535
+ },
2536
+ );
2537
+
2538
+ // A bare GET is a 405 with the method named, not Express's default 404 \u2014
2539
+ // the resource exists, the verb is wrong.
2540
+ app.get('/api/chat', (_req, res) => {
2541
+ res.status(405).json({ error: { message: 'Method not allowed \u2014 POST /api/chat.' } });
2542
+ });
2543
+
2390
2544
  // POST /api/chat: bridge a Pi RPC session to the browser as SSE.
2391
2545
  app.post('/api/chat', (req, res) => {
2392
- const { messages } = req.body as { messages: OpenAIWireMessage[] };
2546
+ let chatBody: { messages: OpenAIWireMessage[] };
2547
+ try {
2548
+ chatBody = readChatRequest(req);
2549
+ } catch (error) {
2550
+ toChatErrorResponse(error, res);
2551
+ return;
2552
+ }
2553
+ const { messages } = chatBody;
2393
2554
  const last = messages.at(-1)?.content;
2394
2555
  // \`content\` is a plain string until the turn carries an attachment, at which
2395
2556
  // point it is an ARRAY of content parts. Pi's RPC mode takes a TEXT prompt and
@@ -2600,24 +2761,86 @@ var mock = {
2600
2761
  streamFormat: "native",
2601
2762
  envVars: [],
2602
2763
  routeTemplates: {},
2603
- streamMapping: "No backend and no provider, but a real wire. createMockResponder() from @kitn.ai/ui/state yields canned SSE frames and the scaffold reads them with readOpenAIStream from @kitn.ai/ui/wire, on the same code path a real route's response takes: same reader, same part folding, same abort handling. The frames carry the OpenAI chat-completions shape because the mock stands in for your /api/chat ROUTE, not for a provider (every other integration here re-frames its provider to that shape server-side), so going live replaces ONE expression: mockResponse(value) becomes the awaited response from your own chat route, and nothing else in the handler changes. Nothing here can be mistaken for a real turn: the stream opens with a ': kai-mock' SSE comment, every frame carries a _kai_mock field naming createMockResponder, model reports as 'kai-mock' (no provider serves it), and usage is all zeros.",
2604
- runNote: "No backend or API key needed: the reply is generated in the browser and parsed by the same reader a real route feeds. Run the front-end as-is; swap `integration` for a real provider (openai, anthropic, openrouter, ollama) when ready, and the emitted handler differs by one expression.",
2764
+ webRoute: `import { createMockResponder } from '@kitn.ai/ui/state';
2765
+
2766
+ // The kit's own mock responder \u2014 no provider, no key, no upstream. MODULE
2767
+ // scope, not per-request: the responder owns the cursor into its canned
2768
+ // replies, so rebuilding it per turn would answer with the first one forever.
2769
+ const mockResponse = createMockResponder();
2770
+
2771
+ async function chatHandler(request: Request): Promise<Response> {
2772
+ // The responder cycles its replies whatever you send, so the prompt is a
2773
+ // courtesy \u2014 but the body is still read exactly the way a real route reads
2774
+ // it, so swapping this handler for a provider's changes nothing upstream of
2775
+ // this file.
2776
+ let chatBody: ChatRequestBody;
2777
+ try {
2778
+ chatBody = await readChatRequest(request);
2779
+ } catch (error) {
2780
+ return toChatErrorResponse(error);
2781
+ }
2782
+ const { messages } = chatBody;
2783
+ const last = [...messages].reverse().find((m) => m.role === 'user');
2784
+ const prompt = last && typeof last.content === 'string' ? last.content : '';
2785
+
2786
+ // createMockResponder() yields COMPLETE OpenAI chat-completions SSE frames \u2014
2787
+ // the same strings the front end's local preview streams \u2014 so this route only
2788
+ // writes them out verbatim. No framing is built here; the browser parses them
2789
+ // with readOpenAIStream from @kitn.ai/ui/wire, exactly as it would a real
2790
+ // route's response. Every frame is marked as a mock (a ': kai-mock' banner, a
2791
+ // _kai_mock field, model 'kai-mock', zero usage).
2792
+ const encoder = new TextEncoder();
2793
+ let open = true;
2794
+ const body = new ReadableStream<Uint8Array>({
2795
+ async start(controller) {
2796
+ for await (const frame of mockResponse(prompt)) {
2797
+ // The browser hangs up when the user asks a new question mid-answer;
2798
+ // stop producing frames rather than writing into a cancelled stream.
2799
+ if (!open) return;
2800
+ controller.enqueue(encoder.encode(frame));
2801
+ }
2802
+ controller.close();
2803
+ },
2804
+ cancel() {
2805
+ open = false;
2806
+ },
2807
+ });
2808
+
2809
+ return new Response(body, {
2810
+ status: 200,
2811
+ headers: {
2812
+ 'Content-Type': 'text/event-stream; charset=utf-8',
2813
+ // no-transform stops a proxy buffering the stream into one blob.
2814
+ 'Cache-Control': 'no-cache, no-transform',
2815
+ Connection: 'keep-alive',
2816
+ // A proxy in front of a preview build would otherwise buffer the whole
2817
+ // stream and the reply would land all at once instead of streaming.
2818
+ 'X-Accel-Buffering': 'no',
2819
+ },
2820
+ });
2821
+ }`,
2822
+ streamMapping: "No backend and no provider, but a real wire. createMockResponder() from @kitn.ai/ui/state yields canned SSE frames and the scaffold reads them with readOpenAIStream from @kitn.ai/ui/wire, on the same code path a real route's response takes: same reader, same part folding, same abort handling. The frames carry the OpenAI chat-completions shape because the mock stands in for your /api/chat ROUTE, not for a provider (every other integration here re-frames its provider to that shape server-side), so going live replaces ONE expression: mockResponse(value) becomes the awaited response from your own chat route, and nothing else in the handler changes. Nothing here can be mistaken for a real turn: the stream opens with a ': kai-mock' SSE comment, every frame carries a _kai_mock field naming createMockResponder, model reports as 'kai-mock' (no provider serves it), and usage is all zeros. Block (2) serves the same frames over HTTP as an OPTIONAL route, so the /api/chat seam can be stood up before any provider exists.",
2823
+ runNote: "No backend or API key needed: the reply is generated in the browser and parsed by the same reader a real route feeds. Run the front-end as-is; swap `integration` for a real provider (openai, anthropic, openrouter, ollama) when ready, and the emitted handler differs by one expression. Block (2) is the OPTIONAL server half: the same mock frames served over HTTP, for standing up the /api/chat seam before any provider exists.",
2605
2824
  docsSlug: "integrations/mock",
2606
- // Nothing: there is no HTTP request at all. The frames are produced in-process
2607
- // by createMockResponder(), so there is no body to forward a model or a tools
2608
- // array on, and no route that would read one.
2825
+ // Nothing: the front end makes no HTTP request at all, and the optional route
2826
+ // reads only `messages` and only as a courtesy prompt the responder is free
2827
+ // to ignore. There is no model to pick and no tools array anything would run.
2609
2828
  forwardsFromClient: [],
2610
- // Nothing to install: there is no route to install anything for.
2829
+ // Nothing to install: the route's one import is the kit itself, which every
2830
+ // scaffold already depends on (registry.test.ts pins deps.npm to the route's
2831
+ // imports minus @kitn.ai/ui).
2611
2832
  deps: { npm: [], pip: [] },
2612
- // The other 'frontend-safe' entry, and the only one that is true by absence:
2613
- // no routeTemplates, no webRoute, no envVars. There is no request, no upstream
2614
- // and no secret, so there is nothing a public bundle could give away. This is
2615
- // the one place where "declares nothing" genuinely means safe — which is
2616
- // precisely why it still has to SAY so rather than be left blank.
2833
+ // The other 'frontend-safe' entry. The route now exists (G-04) but changes
2834
+ // nothing here: it holds no credential, sends no auth header and reaches no
2835
+ // upstream, so there is still nothing a public bundle could give away. The
2836
+ // schema's own refinement re-checks that claim against the route source.
2617
2837
  keyExposure: "frontend-safe",
2618
- // Nothing at all, and here that is the literal truth rather than a shorthand:
2619
- // there is no route, no upstream and no process. This is the "No backend"
2620
- // group of create-kai's gateway prompt all by itself.
2838
+ // Nothing to supply out of band: no upstream, no process, no runtime. The
2839
+ // route is optional the emitted app streams locally without it which is
2840
+ // what keeps mock in the "No backend" group of create-kai's gateway prompt
2841
+ // (listGatewayGroups derives that from frontend-safe + outOfBand 'none', not
2842
+ // from the absence of a route, precisely because this route is one nobody
2843
+ // NEEDS).
2621
2844
  outOfBand: "none"
2622
2845
  };
2623
2846
  var mock_default = mock;
@@ -2652,9 +2875,41 @@ var archetypes = [
2652
2875
  defaultPlacement: "side",
2653
2876
  docsSlug: "examples/agentic-assistant"
2654
2877
  },
2878
+ /**
2879
+ * THE FIRST OFFICIAL BLOCK (recast spec 2026-08-20 § 3b; F-16).
2880
+ *
2881
+ * This preset used to carry `kai-artifact` + `kai-resizable` — an UNWIRED
2882
+ * artifact split — while omitting the conversation rail, the one thing its
2883
+ * name promised (rung-3 finding F-16). It now names the workspace BLOCK: the
2884
+ * chat-agnostic `kai-workspace` layout shell, a WIRED `kai-conversations`
2885
+ * rail, `kai-chat` in the main region, and the `@kitn.ai/ui/state` thread
2886
+ * helpers (bindThreadMessages / createThreadSessions / createSaveScheduler /
2887
+ * parseStoredThread) with the persistence POLICY left in consumer-owned
2888
+ * lines. `examples/apps/workspace/` is the block's reference implementation.
2889
+ *
2890
+ * The artifact split did not vanish — it moved to `artifact-split` below, so
2891
+ * its renderer branch keeps its cells in `verify:scaffold`.
2892
+ */
2655
2893
  {
2656
2894
  id: "workspace",
2657
2895
  title: "Agentic workspace",
2896
+ components: ["kai-chat", "kai-workspace", "kai-conversations"],
2897
+ defaultPlacement: "full-page",
2898
+ docsSlug: "examples/workspace"
2899
+ },
2900
+ /**
2901
+ * THE PRESET THAT KEEPS A CAPABILITY ON THE AXIS — the same reason the
2902
+ * `attachments` preset exists (see its comment below): `listCapabilityGroups`
2903
+ * derives the gate's surface axis from this table, so when the recast moved
2904
+ * the artifact pair off `workspace`, this entry is what kept
2905
+ * `kai-artifact`+`kai-resizable` compiling in `verify:scaffold` instead of
2906
+ * silently losing every cell. The pair is ONE capability because `isArtifactSplit`
2907
+ * in scaffold.ts requires both: a split with no preview pane is an empty
2908
+ * panel, and a preview with no split has nowhere to sit.
2909
+ */
2910
+ {
2911
+ id: "artifact-split",
2912
+ title: "Artifact preview split",
2658
2913
  components: ["kai-chat", "kai-artifact", "kai-resizable"],
2659
2914
  defaultPlacement: "side",
2660
2915
  docsSlug: "examples/workspace"
@@ -2765,9 +3020,33 @@ var CHAT_REQUEST_BODY_DECL = [
2765
3020
  ` tools?: unknown[];`,
2766
3021
  `};`,
2767
3022
  ``,
2768
- `/** Narrow the JSON body once, at the edge. */`,
3023
+ `class ChatRequestError extends Error {`,
3024
+ ` constructor(readonly status: number, message: string) { super(message); }`,
3025
+ `}`,
3026
+ ``,
3027
+ `/** Narrow the JSON body once, at the edge. A bare GET, a malformed body, or a`,
3028
+ ` * missing messages array is a ChatRequestError with a status \u2014 NEVER an`,
3029
+ ` * unhandled SyntaxError: one killed a Vite dev server (findings F-10). */`,
2769
3030
  `async function readChatRequest(request: Request): Promise<ChatRequestBody> {`,
2770
- ` return (await request.json()) as ChatRequestBody;`,
3031
+ ` if (request.method !== 'POST') {`,
3032
+ ` throw new ChatRequestError(405, \`Method \${request.method} not allowed \u2014 POST /api/chat.\`);`,
3033
+ ` }`,
3034
+ ` let parsed: unknown;`,
3035
+ ` try { parsed = await request.json(); } catch {`,
3036
+ ` throw new ChatRequestError(400, 'Request body is not valid JSON.');`,
3037
+ ` }`,
3038
+ ` const body = parsed as ChatRequestBody;`,
3039
+ ` if (!Array.isArray(body?.messages)) {`,
3040
+ ` throw new ChatRequestError(400, 'Request body must carry a messages array.');`,
3041
+ ` }`,
3042
+ ` return body;`,
3043
+ `}`,
3044
+ ``,
3045
+ `/** Map a guard rejection to the Response its status demands; rethrow anything`,
3046
+ ` * else \u2014 an unexpected error should be loud, not laundered into a 400. */`,
3047
+ `function toChatErrorResponse(error: unknown): Response {`,
3048
+ ` if (error instanceof ChatRequestError) return Response.json({ error: error.message }, { status: error.status });`,
3049
+ ` throw error;`,
2771
3050
  `}`
2772
3051
  ];
2773
3052
  var CONTENT_PARTS_DECL = [
@@ -2865,6 +3144,11 @@ function rendererComponents() {
2865
3144
  }
2866
3145
  return known;
2867
3146
  }
3147
+ function wirableGateways(framework) {
3148
+ return listGateways().filter(
3149
+ (g2) => g2.wired && wirableGateway(g2.integration.id, framework) === null
3150
+ );
3151
+ }
2868
3152
  function listGateways() {
2869
3153
  const all = listIntegrations();
2870
3154
  const mock2 = all.filter((i) => i.id === "mock");
@@ -2926,13 +3210,16 @@ function getFeature(id) {
2926
3210
  var DEFAULT_FEATURES = FEATURES.filter((f) => f.default).map(
2927
3211
  (f) => f.id
2928
3212
  );
3213
+ var GENERATED_SURFACES_WIRED = false;
3214
+ var GENERATED_SURFACE_GAP = "generated feature surfaces are not wired in this release \u2014 no such project has been run end to end, so emitting one would hand back an app nobody has seen work. The composed workspace is the surface that runs today";
2929
3215
  var COMPOSED_ONLY = /* @__PURE__ */ new Set(["conversations"]);
2930
3216
  function featureEmit(feature, framework) {
2931
3217
  if (COMPOSED_ONLY.has(feature.id)) {
2932
3218
  return framework.composedWorkspace ? "composed" : "unavailable";
2933
3219
  }
2934
3220
  const known = rendererComponents();
2935
- return feature.components.every((c) => known.has(c)) ? "renderer" : "unavailable";
3221
+ if (!feature.components.every((c) => known.has(c))) return "unavailable";
3222
+ return GENERATED_SURFACES_WIRED ? "renderer" : "unavailable";
2936
3223
  }
2937
3224
  function featureUnavailableReason(feature, framework) {
2938
3225
  if (featureEmit(feature, framework) !== "unavailable") return null;
@@ -2941,11 +3228,17 @@ function featureUnavailableReason(feature, framework) {
2941
3228
  }
2942
3229
  const known = rendererComponents();
2943
3230
  const missing = feature.components.filter((c) => !known.has(c));
2944
- return `feature '${feature.id}' cannot be emitted for ${framework.label}: no renderer branches on ${missing.join(" / ")}, so the emitted project would compile and run without the feature in it. Compose those components in a kit archetype and this resolves itself.`;
3231
+ if (missing.length > 0) {
3232
+ return `feature '${feature.id}' cannot be emitted for ${framework.label}: no renderer branches on ${missing.join(" / ")}, so the emitted project would compile and run without the feature in it. Compose those components in a kit archetype and this resolves itself.`;
3233
+ }
3234
+ return `feature '${feature.id}' cannot be emitted: ${GENERATED_SURFACE_GAP}.`;
2945
3235
  }
2946
3236
  function availableFeatures(framework) {
2947
3237
  return FEATURES.filter((f) => featureEmit(f, framework) !== "unavailable");
2948
3238
  }
3239
+ function composedWorkspaceFeatures(framework) {
3240
+ return FEATURES.filter((f) => featureEmit(f, framework) === "composed");
3241
+ }
2949
3242
  function resolveSurface(featureIds, framework) {
2950
3243
  const chosen = [];
2951
3244
  for (const id of featureIds) {
@@ -2956,7 +3249,7 @@ function resolveSurface(featureIds, framework) {
2956
3249
  chosen.push(feature);
2957
3250
  }
2958
3251
  const composed = chosen.filter((f) => featureEmit(f, framework) === "composed");
2959
- if (composed.length > 0) {
3252
+ if (composed.length > 0 || chosen.length === 0 && !GENERATED_SURFACES_WIRED) {
2960
3253
  if (chosen.length > composed.length) {
2961
3254
  const extra = chosen.filter((f) => !composed.includes(f)).map((f) => f.id);
2962
3255
  return {
@@ -2964,10 +3257,30 @@ function resolveSurface(featureIds, framework) {
2964
3257
  reason: `'${composed.map((f) => f.id).join("', '")}' comes from the hand-composed workspace template, which this release cannot combine with generated features ('${extra.join("', '")}'). Pick one or the other.`
2965
3258
  };
2966
3259
  }
2967
- return { ok: true, surface: { kind: "composed", features: featureIds } };
3260
+ const emitted = composedWorkspaceFeatures(framework);
3261
+ if (emitted.length === 0) {
3262
+ return {
3263
+ ok: false,
3264
+ reason: `'${framework.label}' has no composed workspace starter, and ${GENERATED_SURFACE_GAP}. There is no surface to emit for it.`
3265
+ };
3266
+ }
3267
+ const asked = new Set(featureIds);
3268
+ return {
3269
+ ok: true,
3270
+ surface: {
3271
+ kind: "composed",
3272
+ features: emitted.map((f) => f.id),
3273
+ unasked: emitted.filter((f) => !asked.has(f.id)).map((f) => f.id)
3274
+ }
3275
+ };
2968
3276
  }
2969
- const components = ["kai-chat", ...new Set(chosen.flatMap((f) => f.components))];
2970
- return { ok: true, surface: { kind: "generated", features: featureIds, components } };
3277
+ return {
3278
+ ok: true,
3279
+ surface: { kind: "generated", features: featureIds, components: surfaceComponents(chosen) }
3280
+ };
3281
+ }
3282
+ function surfaceComponents(chosen) {
3283
+ return ["kai-chat", ...new Set(chosen.flatMap((f) => f.components))];
2971
3284
  }
2972
3285
 
2973
3286
  // src/routes.ts
@@ -3456,6 +3769,75 @@ function validateProjectName(name) {
3456
3769
  return null;
3457
3770
  }
3458
3771
 
3772
+ // src/layouts.ts
3773
+ var LAYOUTS = [
3774
+ {
3775
+ id: "full-screen",
3776
+ label: "Full-screen app",
3777
+ hint: "the chat is the page",
3778
+ placement: "full-page",
3779
+ status: "ready"
3780
+ },
3781
+ {
3782
+ id: "widget",
3783
+ label: "Embedded widget",
3784
+ hint: "the chat sits on top of an existing page",
3785
+ placement: "docked-widget",
3786
+ // A widget has no composed-workspace starter — it is a generated surface at
3787
+ // `docked-widget` placement — so it lands on the same unwired path the
3788
+ // generated features do. Offered once that path has been run.
3789
+ status: "planned",
3790
+ note: "needs the generated-surface path, not wired in this release"
3791
+ }
3792
+ ];
3793
+ function getLayout(id) {
3794
+ return LAYOUTS.find((l2) => l2.id === id);
3795
+ }
3796
+ function readyLayouts() {
3797
+ return LAYOUTS.filter((l2) => l2.status === "ready");
3798
+ }
3799
+
3800
+ // src/axes.ts
3801
+ function decideAxis(axis) {
3802
+ if (axis.options.length === 0) return { ask: false, only: null, statement: null };
3803
+ if (axis.options.length > 1) return { ask: true, only: null, statement: null };
3804
+ const only = axis.options[0];
3805
+ const statement = axis.because.length > 0 ? `${only.label} \u2014 ${only.hint}; ${axis.because}` : null;
3806
+ return { ask: false, only, statement };
3807
+ }
3808
+ function layoutAxis() {
3809
+ const options = readyLayouts().map((l2) => ({ id: l2.id, label: l2.label, hint: l2.hint }));
3810
+ return {
3811
+ id: "layout",
3812
+ label: "Layout",
3813
+ question: "Where does the chat live?",
3814
+ options,
3815
+ because: "the only layout this release can scaffold, and `--list` shows the rest"
3816
+ };
3817
+ }
3818
+ function gatewayAxis(framework) {
3819
+ const options = wirableGateways(framework).map((g2) => ({
3820
+ id: g2.integration.id,
3821
+ label: g2.integration.id === "mock" ? "None" : g2.integration.title,
3822
+ hint: g2.integration.id === "mock" ? "local mock, no key, no backend" : `${g2.integration.envVars.join(", ")} \u2014 a server route is scaffolded for you`
3823
+ }));
3824
+ return {
3825
+ id: "gateway",
3826
+ label: "Backend",
3827
+ question: "Wire a model gateway?",
3828
+ options,
3829
+ because: `the only gateway wirable for ${framework.label} in this release. A keyed gateway needs a server route and this framework declares no destination for one, so \`--list --json\` is where to see which do`
3830
+ };
3831
+ }
3832
+ async function answerAxis(axis, opts, io) {
3833
+ if (opts.override !== void 0) return opts.override;
3834
+ const decision = decideAxis(axis);
3835
+ if (opts.nonInteractive) return decision.only?.id ?? opts.fallback;
3836
+ if (decision.ask) return io.ask(axis, opts.initialValue ?? opts.fallback);
3837
+ if (decision.statement) io.state(axis.label, decision.statement);
3838
+ return decision.only?.id ?? opts.fallback;
3839
+ }
3840
+
3459
3841
  // src/generate.ts
3460
3842
  import { cp, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3461
3843
  import { existsSync } from "node:fs";
@@ -3957,10 +4339,9 @@ async function generate(plan, options = {}) {
3957
4339
  const surface = resolveSurface(plan.featureIds, framework);
3958
4340
  if (!surface.ok) throw new Error(`create-kai: ${surface.reason}`);
3959
4341
  if (surface.surface.kind === "generated") {
3960
- throw new Error(
3961
- "create-kai: generated feature surfaces are not wired in this release \u2014 the composed workspace (conversation history) is the path that runs today"
3962
- );
4342
+ throw new Error(`create-kai: ${GENERATED_SURFACE_GAP}`);
3963
4343
  }
4344
+ const emittedFeatures = surface.surface.features;
3964
4345
  const templateRoot = options.templateRoot ?? defaultTemplateRoot();
3965
4346
  const templateDir = path.join(templateRoot, framework.templateDir);
3966
4347
  if (!existsSync(templateDir)) {
@@ -3995,7 +4376,7 @@ async function generate(plan, options = {}) {
3995
4376
  const routeFiles = await writeGateway(plan, framework, integration, out, thread);
3996
4377
  await writeFile(
3997
4378
  path.join(out, "kai.json"),
3998
- stringifyKaiJson(buildKaiJson(plan, framework)),
4379
+ stringifyKaiJson(buildKaiJson({ ...plan, featureIds: emittedFeatures }, framework)),
3999
4380
  "utf8"
4000
4381
  );
4001
4382
  await writeFile(
@@ -4180,34 +4561,6 @@ async function listFiles(dir, prefix = "") {
4180
4561
  return out.sort();
4181
4562
  }
4182
4563
 
4183
- // src/layouts.ts
4184
- var LAYOUTS = [
4185
- {
4186
- id: "full-screen",
4187
- label: "Full-screen app",
4188
- hint: "the chat is the page",
4189
- placement: "full-page",
4190
- status: "ready"
4191
- },
4192
- {
4193
- id: "widget",
4194
- label: "Embedded widget",
4195
- hint: "the chat sits on top of an existing page",
4196
- placement: "docked-widget",
4197
- // A widget has no composed-workspace starter — it is a generated surface at
4198
- // `docked-widget` placement — so it lands on the same unwired path the
4199
- // generated features do. Offered once that path has been run.
4200
- status: "planned",
4201
- note: "needs the generated-surface path, not wired in this release"
4202
- }
4203
- ];
4204
- function getLayout(id) {
4205
- return LAYOUTS.find((l2) => l2.id === id);
4206
- }
4207
- function readyLayouts() {
4208
- return LAYOUTS.filter((l2) => l2.status === "ready");
4209
- }
4210
-
4211
4564
  // src/pm.ts
4212
4565
  var KNOWN = {
4213
4566
  npm: { name: "npm", install: ["npm", "install"], run: "npm run dev" },
@@ -4222,7 +4575,10 @@ function detectPackageManager(userAgent = process.env.npm_config_user_agent) {
4222
4575
  }
4223
4576
 
4224
4577
  // src/index.ts
4225
- var DEFAULT_KIT_RANGE = "^0.25.2";
4578
+ var OFFERABLE_FEATURE_IDS = [
4579
+ ...new Set(readyFrameworks().flatMap((f) => availableFeatures(f).map((feature) => feature.id)))
4580
+ ];
4581
+ var DEFAULT_KIT_RANGE = "^0.27.0";
4226
4582
  var HELP = `
4227
4583
  ${import_picocolors3.default.bold("create-kai")} \u2014 scaffold a runnable @kitn.ai/ui chat app
4228
4584
 
@@ -4232,7 +4588,7 @@ ${import_picocolors3.default.bold("create-kai")} \u2014 scaffold a runnable @kit
4232
4588
  Options
4233
4589
  --framework <id> ${readyFrameworks().map((f) => f.id).join(", ")}
4234
4590
  --layout <id> ${readyLayouts().map((l2) => l2.id).join(", ")}
4235
- --features <a,b> ${FEATURES.map((f) => f.id).join(", ")} (or 'none')
4591
+ --features <a,b> ${OFFERABLE_FEATURE_IDS.join(", ")} (or 'none')
4236
4592
  --gateway <id> none${[...WIRED_GATEWAYS].filter((g2) => g2 !== "mock").map((g2) => `, ${g2}`).join("")}
4237
4593
  --kit <spec> @kitn.ai/ui spec to pin (default ${DEFAULT_KIT_RANGE})
4238
4594
  -y, --yes accept every default (zero-config: React + full-screen + mock)
@@ -4253,7 +4609,7 @@ async function main() {
4253
4609
  return 0;
4254
4610
  }
4255
4611
  if (args.version) {
4256
- console.log("0.1.4");
4612
+ console.log("0.2.1");
4257
4613
  return 0;
4258
4614
  }
4259
4615
  if (args.list) {
@@ -4291,45 +4647,70 @@ async function main() {
4291
4647
  `'${framework.id}' is not scaffoldable yet (${framework.note ?? "no template"}). Available: ${readyFrameworks().map((f) => f.id).join(", ")}`
4292
4648
  );
4293
4649
  }
4294
- const layoutId = args.layout ?? (nonInteractive ? ZERO_CONFIG.layout : await ask(
4295
- ve({
4296
- message: "Where does the chat live?",
4297
- initialValue: ZERO_CONFIG.layout,
4298
- options: readyLayouts().map((l2) => ({ value: l2.id, label: l2.label, hint: l2.hint }))
4299
- })
4300
- ));
4650
+ const layouts = layoutAxis();
4651
+ if (layouts.options.length === 0) {
4652
+ return fail("no layout in this release can be scaffolded \u2014 this build is broken");
4653
+ }
4654
+ const layoutId = await answerAxis(
4655
+ layouts,
4656
+ { override: args.layout, nonInteractive, fallback: ZERO_CONFIG.layout },
4657
+ clackAxisIo
4658
+ );
4301
4659
  const layout = getLayout(layoutId);
4302
4660
  if (!layout) return fail(`unknown layout '${layoutId}'`);
4303
4661
  if (layout.status !== "ready") {
4304
- return fail(`layout '${layout.id}' is not scaffoldable yet (${layout.note ?? "no template"})`);
4662
+ return fail(
4663
+ `layout '${layout.id}' is not scaffoldable yet (${layout.note ?? "no template"}). Available: ${layouts.options.map((l2) => l2.id).join(", ")}`
4664
+ );
4305
4665
  }
4306
4666
  const offered = availableFeatures(framework);
4307
- const featureIds = args.features ?? (nonInteractive ? DEFAULT_FEATURES.filter((id) => offered.some((f) => f.id === id)) : await ask(
4667
+ const included = composedWorkspaceFeatures(framework);
4668
+ const optional = offered.filter((f) => !included.includes(f));
4669
+ const withheld = FEATURES.filter((f) => !offered.includes(f));
4670
+ const featureIds = args.features ?? (nonInteractive || optional.length === 0 ? DEFAULT_FEATURES.filter((id) => offered.some((f) => f.id === id)) : await ask(
4308
4671
  fe({
4309
4672
  message: "Which features?",
4310
4673
  required: false,
4311
- initialValues: DEFAULT_FEATURES.filter((id) => offered.some((f) => f.id === id)),
4312
- options: offered.map((f) => ({ value: f.id, label: f.label, hint: f.hint }))
4674
+ initialValues: DEFAULT_FEATURES.filter((id) => optional.some((f) => f.id === id)),
4675
+ options: optional.map((f) => ({ value: f.id, label: f.label, hint: f.hint }))
4313
4676
  })
4314
4677
  ));
4315
4678
  for (const id of featureIds) {
4316
- if (!getFeature(id)) return fail(`unknown feature '${id}'`);
4679
+ const feature = getFeature(id);
4680
+ if (!feature) {
4681
+ return fail(`unknown feature '${id}'. Available: ${featureList(offered)}`);
4682
+ }
4683
+ const unavailable = featureUnavailableReason(feature, framework);
4684
+ if (unavailable) {
4685
+ return fail(`${unavailable}
4686
+ Available for ${framework.label}: ${featureList(offered)}`);
4687
+ }
4688
+ }
4689
+ const surface = resolveSurface(featureIds, framework);
4690
+ if (!surface.ok) return fail(surface.reason);
4691
+ if (surface.surface.kind === "composed" && surface.surface.unasked.length > 0) {
4692
+ stated(
4693
+ "Also included",
4694
+ `${surface.surface.unasked.join(", ")} \u2014 the ${framework.label} starter is one reviewed tree, so it comes as a whole`
4695
+ );
4696
+ }
4697
+ if (!nonInteractive) {
4698
+ if (optional.length === 0 && included.length > 0) {
4699
+ stated("Features", included.map((f) => `${f.label} \u2014 ${f.hint}`).join("; "));
4700
+ }
4701
+ if (withheld.length > 0) {
4702
+ stated(
4703
+ "Not offered yet",
4704
+ `${withheld.map((f) => f.id).join(", ")} \u2014 ${import_picocolors3.default.dim("see `--list` for the whole table")}`
4705
+ );
4706
+ }
4317
4707
  }
4318
4708
  const gateways = listGateways();
4319
- const wired = gateways.filter(
4320
- (g2) => g2.wired && wirableGateway(g2.integration.id, framework) === null
4709
+ const gatewayId = normalizeGateway(args.gateway) ?? await answerAxis(
4710
+ gatewayAxis(framework),
4711
+ { nonInteractive, fallback: ZERO_CONFIG.gateway, initialValue: ZERO_CONFIG.gateway },
4712
+ clackAxisIo
4321
4713
  );
4322
- const gatewayId = normalizeGateway(args.gateway) ?? (nonInteractive || wired.length === 1 ? ZERO_CONFIG.gateway : await ask(
4323
- ve({
4324
- message: "Wire a model gateway?",
4325
- initialValue: ZERO_CONFIG.gateway,
4326
- options: wired.map((g2) => ({
4327
- value: g2.integration.id,
4328
- label: g2.integration.id === "mock" ? "None" : g2.integration.title,
4329
- hint: g2.integration.id === "mock" ? "local mock, no key, no backend" : `${g2.integration.envVars.join(", ")} \u2014 a server route is scaffolded for you`
4330
- }))
4331
- })
4332
- ));
4333
4714
  if (!gateways.some((g2) => g2.integration.id === gatewayId)) {
4334
4715
  return fail(`unknown gateway '${gatewayId}'`);
4335
4716
  }
@@ -4348,7 +4729,7 @@ async function main() {
4348
4729
  // which kit the CLI was built against, which stays true when `--kit` sends
4349
4730
  // the dependency somewhere else — the emitted files are this version's shape
4350
4731
  // whatever the project ends up installing.
4351
- kitBuiltAgainst: "0.25.2"
4732
+ kitBuiltAgainst: "0.27.0"
4352
4733
  };
4353
4734
  const spinner = Y2();
4354
4735
  spinner.start("Scaffolding");
@@ -4405,6 +4786,22 @@ function fail(message) {
4405
4786
  xe(import_picocolors3.default.red(message));
4406
4787
  return 1;
4407
4788
  }
4789
+ function stated(label, value) {
4790
+ M2.info(`${import_picocolors3.default.dim(`${label}:`)} ${value}`);
4791
+ }
4792
+ var clackAxisIo = {
4793
+ ask: (axis, initialValue) => ask(
4794
+ ve({
4795
+ message: axis.question,
4796
+ initialValue,
4797
+ options: axis.options.map((o2) => ({ value: o2.id, label: o2.label, hint: o2.hint }))
4798
+ })
4799
+ ),
4800
+ state: stated
4801
+ };
4802
+ function featureList(features) {
4803
+ return features.length > 0 ? features.map((f) => f.id).join(", ") : "none";
4804
+ }
4408
4805
  function run(command, cwd) {
4409
4806
  return new Promise((resolve) => {
4410
4807
  const child = spawn(command[0], command.slice(1), { cwd, stdio: "ignore", shell: false });
@@ -4414,12 +4811,12 @@ function run(command, cwd) {
4414
4811
  }
4415
4812
  function printMatrix(asJson) {
4416
4813
  const matrix = {
4417
- cli: "0.1.4",
4814
+ cli: "0.2.1",
4418
4815
  kit: DEFAULT_KIT_RANGE,
4419
4816
  // The same pair `kai.json` carries. An agent reading this to decide what to
4420
4817
  // install gets the range; one reasoning about which kit the templates match
4421
4818
  // gets the exact version, without having to parse the range for a floor.
4422
- kitBuiltAgainst: "0.25.2",
4819
+ kitBuiltAgainst: "0.27.0",
4423
4820
  frameworks: FRAMEWORKS.map((f) => ({
4424
4821
  id: f.id,
4425
4822
  label: f.label,
@@ -4433,7 +4830,16 @@ function printMatrix(asJson) {
4433
4830
  ...f.note ? { note: f.note } : {}
4434
4831
  })),
4435
4832
  layouts: LAYOUTS.map((l2) => ({ id: l2.id, status: l2.status, ...l2.note ? { note: l2.note } : {} })),
4436
- features: FEATURES.map((f) => ({ id: f.id, components: f.components, default: f.default })),
4833
+ features: FEATURES.map((f) => ({
4834
+ id: f.id,
4835
+ components: f.components,
4836
+ default: f.default,
4837
+ // Derived, never restated — and the field the six-item menu needed: the
4838
+ // ready frameworks whose prompt actually offers this feature. An empty
4839
+ // array means the row is catalogued and cannot be scaffolded by anything,
4840
+ // which is what an agent reading this has to be able to see.
4841
+ frameworks: readyFrameworks().filter((framework) => availableFeatures(framework).includes(f)).map((framework) => framework.id)
4842
+ })),
4437
4843
  gateways: listGateways().map((g2) => ({
4438
4844
  id: g2.integration.id,
4439
4845
  title: g2.integration.title,
@@ -4461,6 +4867,12 @@ function printMatrix(asJson) {
4461
4867
  for (const l2 of matrix.layouts) {
4462
4868
  console.log(` ${mark(l2.status === "ready")} ${l2.id.padEnd(16)}${l2.note ?? ""}`);
4463
4869
  }
4870
+ console.log(import_picocolors3.default.bold("\nFeatures"));
4871
+ for (const f of matrix.features) {
4872
+ console.log(
4873
+ ` ${mark(f.frameworks.length > 0)} ${f.id.padEnd(16)}${f.frameworks.length > 0 ? "" : "not scaffoldable in this release"}`
4874
+ );
4875
+ }
4464
4876
  console.log(import_picocolors3.default.bold("\nGateways"));
4465
4877
  for (const g2 of matrix.gateways) {
4466
4878
  console.log(` ${mark(g2.wired)} ${g2.id.padEnd(16)}${g2.envVars.join(", ")}`);
@@ -19,6 +19,13 @@ interface ThreadViewProps {
19
19
  * is that same channel repeated: a NEW array reference per chunk, which is why
20
20
  * mutating the existing array in place would render nothing.
21
21
  *
22
+ * The full rule is BOTH halves, and streaming only satisfies them by accident:
23
+ * the fresh array is what notifies, and a new object for the message that changed
24
+ * is what makes the change visible (the row list is a reference-keyed `<For>`).
25
+ * `createAssistantStream` rebuilds the streaming message as a new object every
26
+ * delta, so streaming gets the second half for free — editing a message or a
27
+ * conversation title by hand does not. See the framework guide.
28
+ *
22
29
  * This component just bakes the per-message actions onto the assistant turns and
23
30
  * wires the custom `speak` action to the browser's speech synthesis. `copy` (and
24
31
  * the feedback votes) are handled inside the element.
@@ -19,6 +19,13 @@ interface ThreadViewProps {
19
19
  * is that same channel repeated: a NEW array reference per chunk, which is why
20
20
  * mutating the existing array in place would render nothing.
21
21
  *
22
+ * The full rule is BOTH halves, and streaming only satisfies them by accident:
23
+ * the fresh array is what notifies, and a new object for the message that changed
24
+ * is what makes the change visible (the row list is a reference-keyed `<For>`).
25
+ * `createAssistantStream` rebuilds the streaming message as a new object every
26
+ * delta, so streaming gets the second half for free — editing a message or a
27
+ * conversation title by hand does not. See the framework guide.
28
+ *
22
29
  * This component just bakes the per-message actions onto the assistant turns and
23
30
  * wires the custom `speak` action to the browser's speech synthesis. `copy` (and
24
31
  * the feedback votes) are handled inside the element.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-kai",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Scaffold a runnable @kitn.ai/ui chat app. `npm create kai@latest`",
6
6
  "license": "MIT",