sygnal 6.2.0 → 6.3.0

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +3 -1
  3. package/dist/ai.cjs.js +36 -0
  4. package/dist/ai.cjs.js.map +1 -1
  5. package/dist/ai.esm.js +1 -1
  6. package/dist/astro/server.cjs.js +3 -3
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs +3 -3
  9. package/dist/astro/server.mjs.map +1 -1
  10. package/dist/diagnostics.cjs.js +164 -3
  11. package/dist/diagnostics.cjs.js.map +1 -1
  12. package/dist/diagnostics.esm.js +164 -3
  13. package/dist/diagnostics.esm.js.map +1 -1
  14. package/dist/guide/agent.md +7 -1
  15. package/dist/guide/ai-cards.md +319 -0
  16. package/dist/guide/ai-chat.md +4 -0
  17. package/dist/guide/ai-compose.md +881 -0
  18. package/dist/guide/ai-decisions.md +16 -0
  19. package/dist/guide/http.md +1 -0
  20. package/dist/index.cjs.js +5695 -347
  21. package/dist/index.cjs.js.map +1 -1
  22. package/dist/index.d.ts +638 -8
  23. package/dist/index.esm.js +5685 -347
  24. package/dist/index.esm.js.map +1 -1
  25. package/dist/sygnal.min.js +1 -1
  26. package/dist/sygnal.min.js.map +1 -1
  27. package/dist/vike/config/package.json +1 -1
  28. package/dist/vike/onRenderClient.cjs.js +3 -2
  29. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  30. package/dist/vike/onRenderClient.mjs +3 -2
  31. package/dist/vike/onRenderClient.mjs.map +1 -1
  32. package/llms.txt +6 -4
  33. package/package.json +2 -1
  34. package/src/ai.d.ts +642 -7
  35. package/src/ai.ts +6 -0
  36. package/src/collection.ts +2 -1
  37. package/src/core/instance.ts +6 -3
  38. package/src/core/markers/lazy.ts +7 -3
  39. package/src/core/markers/portal.ts +5 -2
  40. package/src/core/markers/suspense.ts +3 -2
  41. package/src/core/markers/transition.ts +6 -3
  42. package/src/core/registry.ts +9 -5
  43. package/src/cycle/dom/IsolateModule.ts +25 -22
  44. package/src/extra/ai/chat/behavior.ts +354 -81
  45. package/src/extra/ai/chat/driver.ts +7 -4
  46. package/src/extra/ai/compose/agent.ts +55 -0
  47. package/src/extra/ai/compose/ask.ts +147 -0
  48. package/src/extra/ai/compose/behavior.ts +183 -0
  49. package/src/extra/ai/compose/catalog.ts +211 -0
  50. package/src/extra/ai/compose/composed.ts +61 -0
  51. package/src/extra/ai/compose/decisions.ts +1649 -0
  52. package/src/extra/ai/compose/diag.ts +33 -0
  53. package/src/extra/ai/compose/index.ts +23 -0
  54. package/src/extra/ai/compose/layout.ts +441 -0
  55. package/src/extra/ai/compose/llm.ts +1120 -0
  56. package/src/extra/ai/compose/render.ts +159 -0
  57. package/src/extra/ai/compose/testing.ts +71 -0
  58. package/src/extra/ai/compose/types.ts +159 -0
  59. package/src/extra/ai/decide.ts +97 -1
  60. package/src/extra/ai/index.ts +6 -1
  61. package/src/extra/diagnostics/checks/next.ts +86 -2
  62. package/src/extra/diagnostics/codes.ts +54 -0
  63. package/src/extra/fetchDriver.ts +58 -27
  64. package/src/extra/lens.ts +15 -0
  65. package/src/extra/ssr.ts +3 -3
  66. package/src/extra/testing.ts +2 -0
  67. package/src/extra/widget.ts +3 -3
  68. package/src/index.d.ts +41 -0
  69. package/src/index.ts +8 -0
  70. package/src/lazy.ts +3 -2
  71. package/src/portal.ts +5 -2
  72. package/src/pragma/index.ts +2 -1
  73. package/src/suspense.ts +5 -2
  74. package/src/switchable.ts +2 -1
  75. package/src/transition.ts +5 -2
  76. package/src/vike/onRenderClient.ts +3 -2
package/src/ai.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  // Types for 'sygnal/ai' (PLAN-6). The implementation lives in src/extra/ai/ (D253).
2
- import type { FetchRequest, Behavior, BehaviorTarget, FieldErrors } from 'sygnal'
2
+ import type { FetchRequest, Behavior, BehaviorTarget, FieldErrors, RenderResult } from 'sygnal'
3
3
  import type { Stream } from 'xstream'
4
4
 
5
5
  /** A part of a chat message (AI SDK UIMessage-shaped, D252) */
6
6
  export type MessagePart =
7
7
  | { type: 'text'; text: string }
8
8
  | { type: 'reasoning'; text: string; /** provider data to send back with it (an Anthropic thinking signature) */ providerMetadata?: Record<string, Record<string, unknown>> }
9
- | { type: `tool-${string}`; toolCallId: string; state: string; input?: unknown; output?: unknown; errorText?: string; approval?: { id: string; approved?: boolean; reason?: string; [key: string]: unknown }; providerExecuted?: boolean }
9
+ | { type: `tool-${string}`; toolCallId: string; state: string; input?: unknown; output?: unknown; errorText?: string; approval?: { id: string; approved?: boolean; reason?: string; [key: string]: unknown }; providerExecuted?: boolean; /** PLAN-9 H-2: an `ask_user` call's card */ ui?: ChatCard }
10
10
  | { type: 'file'; mediaType: string; url: string; filename?: string }
11
11
  | { type: `source-${string}`; [key: string]: unknown }
12
12
  | { type: `data-${string}`; id?: string; data: unknown }
@@ -121,7 +121,7 @@ export interface Decision<Q extends Questions = Questions, R = never> {
121
121
  type FetchRequestObject = Extract<FetchRequest, { url: string }>
122
122
 
123
123
  /** What `decide()` / `decide.openai()` take: the decision, plus any `makeFetchDriver` request keys */
124
- export type DecideOptions<Q extends Questions> = Omit<FetchRequestObject, 'url' | 'json' | 'body'> & {
124
+ export type DecideOptions<Q extends Questions> = Omit<FetchRequestObject, 'url' | 'json' | 'body' | 'batch' | 'combine' | 'split'> & {
125
125
  /** Default '/api/decide' (the app's own route, which holds the API key) */
126
126
  url?: string
127
127
  /** The decision model, e.g. 'jev-latest' (TypeSafe), 'nimble' (Ollama), 'gpt-6-luna' (OpenAI) */
@@ -131,6 +131,23 @@ export type DecideOptions<Q extends Questions> = Omit<FetchRequestObject, 'url'
131
131
  questions: Q
132
132
  /** Base64 PNG / JPEG / WebP files shared by every question (models that read images only) */
133
133
  images?: readonly string[]
134
+ /**
135
+ * D472: more questions than this go as parallel sub-requests (a fetch-driver `batch`), their
136
+ * replies merged into one (`answers` by name, `usage` summed). Default 64 (Ollama's limit)
137
+ */
138
+ maxQuestions?: number
139
+ /**
140
+ * D472: split the same way when the estimated prompt of one question (the state plus every
141
+ * question of the request, as Ollama counts it; the estimate runs high) passes this. Default:
142
+ * none; a request a server rejects as too large is halved anyway (`split`)
143
+ */
144
+ maxPromptTokens?: number
145
+ /**
146
+ * false: one request whatever its size, and no halving on a "too large" rejection. Default: on
147
+ * (a request under the limits is sent as is; a 400 / 413 that names tokens, fields, context, a
148
+ * limit or "too long / many / large" sends it again in halves, at most 3 deep)
149
+ */
150
+ split?: boolean
134
151
  /** As a resource: keep the previous `data` on a new request */
135
152
  keepPrevious?: boolean
136
153
  /** As a resource: refetch every this many ms */
@@ -140,10 +157,15 @@ export type DecideOptions<Q extends Questions> = Omit<FetchRequestObject, 'url'
140
157
  }
141
158
 
142
159
  /** A decision as a `makeFetchDriver` request (POST JSON); its reply is a `Decision<Q, R>` */
143
- export type DecideRequest<Q extends Questions = Questions, R = never> = Omit<DecideOptions<Q>, 'model' | 'state' | 'questions' | 'images' | 'url'> & {
160
+ export type DecideRequest<Q extends Questions = Questions, R = never> = Omit<DecideOptions<Q>, 'model' | 'state' | 'questions' | 'images' | 'url' | 'maxQuestions' | 'maxPromptTokens' | 'split'> & {
144
161
  url: string
145
162
  method: 'POST'
163
+ /** the whole decision (what a test fake answers); over the limits, the parts go in `batch` */
146
164
  json: Record<string, unknown>
165
+ /** D472: the parts of a decision over the limits (sent in parallel, merged by `combine`) */
166
+ batch?: ReadonlyArray<{ url: string; method: 'POST'; json: Record<string, unknown>; parse?: (response: Response) => any }>
167
+ combine?: (bodies: any[], requests: any[]) => any
168
+ split?: (request: any, error: Error & { status?: number; body?: any }) => any[] | null
147
169
  /** Type only: the reply's shape (`Decision<Q, R>`); never set */
148
170
  readonly __decision?: Decision<Q, R>
149
171
  }
@@ -490,6 +512,11 @@ export type ChatRequest<OUT = unknown> = {
490
512
  * `ok` gets the validated `value`, a failure is the `error` reply with `issues`
491
513
  */
492
514
  output?: ChatOutputSchema<OUT>;
515
+ /**
516
+ * `false`: the transport still sends `output`'s JSON Schema, but the driver doesn't read the reply:
517
+ * `ok` has the `text` and no `value` (the caller parses it, e.g. leniently; compose.llm() does)
518
+ */
519
+ parse?: false;
493
520
  /** The request's key for `latest` and `abort` (default: `ok`, else `error`) */
494
521
  key?: string;
495
522
  /** A new request aborts the sender's request under the same key (default true) */
@@ -522,7 +549,7 @@ export type ChatRequest<OUT = unknown> = {
522
549
  }
523
550
 
524
551
  /** Stops the sender's requests under a key (`{ abort: 'reply' }`, `{ abort: true, key: 'reply' }`), or all of them (`{ abort: true }`) */
525
- export type ChatAbort = { abort: string | true; key?: string }
552
+ export type ChatAbort = { abort: string | readonly string[] | true; key?: string }
526
553
 
527
554
  /** `delta` action data: the reply so far */
528
555
  export type ChatDelta = {
@@ -733,6 +760,16 @@ export interface ChatActions {
733
760
  DENY: any
734
761
  /** The turn is over (a host entry `'assistant.DONE'` runs after the behavior's) */
735
762
  DONE: ChatDone
763
+ /**
764
+ * PLAN-9 H-1: answer an open client tool call (`clientTools`, or `ask_user`): an
765
+ * `input-available` client part of the last message (else nothing happens, SYG685). The next
766
+ * request goes once no call is open. The AI SDK's `addToolOutput`
767
+ */
768
+ TOOL_OUTPUT: { toolCallId: string; output: unknown } | { toolCallId: string; errorText: string }
769
+ /** PLAN-9 H-1: a client tool call opened (a host entry `'assistant.TOOL_CALL'` runs after it, once per call; the AI SDK's `onToolCall`) */
770
+ TOOL_CALL: ChatCall
771
+ /** PLAN-9 H-2: a result part answered a card (`compose.answer(props, value)`, heard through CHILD.select): the call's output is `{ part, answer }` */
772
+ ANSWER: ComposeAnswer
736
773
  /** Internal: the prompt field's input */
737
774
  PROMPT: string
738
775
  }
@@ -759,8 +796,13 @@ export interface ChatOptions {
759
796
  * user-entered text named; G-631)
760
797
  */
761
798
  instructions?: string
762
- /** A model id for the requests (overrides the transport's) */
763
- model?: string
799
+ /**
800
+ * A model id for the requests (overrides the transport's), or a function of the host's state
801
+ * that returns one, resolved per request: `model: (state) => state.chatModel` lets an app's model
802
+ * picker switch models with one chat behavior. The default card backend (`ask` without
803
+ * `backend`) uses it too
804
+ */
805
+ model?: string | ((state: any) => string | undefined)
764
806
  /**
765
807
  * Whose `agent` declarations become tools: by default the host's and its shown descendants'
766
808
  * (D249); `false`: none; `[Comp, …]`: only these components' (in the host's subtree)
@@ -770,6 +812,13 @@ export interface ChatOptions {
770
812
  maxSteps?: number
771
813
  /** Extra request keys for the transport */
772
814
  transportOptions?: Record<string, unknown>
815
+ /**
816
+ * PLAN-9 H-1: tools the app answers itself: sent with the agent tools (a name in both: the
817
+ * client tool, SYG684); their calls stay `input-available` until `TOOL_OUTPUT` answers them
818
+ */
819
+ clientTools?: Record<string, ChatClientTool>
820
+ /** PLAN-9 H-2: the `ask_user` tool: the model says what it needs, and the app shows a card built from the catalog */
821
+ ask?: ChatAskOptions
773
822
  }
774
823
 
775
824
  /**
@@ -1220,3 +1269,589 @@ export interface McpAppDriverOptions {
1220
1269
  * run(WeatherCard, { MCP: makeMcpAppDriver() })
1221
1270
  */
1222
1271
  export function makeMcpAppDriver(options?: McpAppDriverOptions): (sink$: Stream<McpAppRequest>) => McpAppSource
1272
+
1273
+ // ---- PLAN-9 compose ------------------------------------------------------------------------------
1274
+ // (worker A: catalogs, layouts, render, the compose behavior, composeWith; B and C add their
1275
+ // sub-sections below)
1276
+
1277
+ /** A record for a record param: `from: (state) => records` (the value becomes the node key `part:value`) */
1278
+ export interface CatalogRecord<V extends string | number = string | number> {
1279
+ value: V
1280
+ name?: string
1281
+ aliases?: string[]
1282
+ description?: string
1283
+ }
1284
+
1285
+ /** A part's (or container's) param declaration */
1286
+ export type CatalogParam =
1287
+ /** an enum: the model picks one option (decisions: a choice, `not_stated` = the default; LLM: an enum) */
1288
+ | { options: Record<string, string>; default?: string; label?: string; ask?: string }
1289
+ /** a number (LLM: a number; decisions: a bin, which names the vague words it covers) */
1290
+ | { type: 'number'; min?: number; max?: number; default?: number; bins?: Record<string | number, string>; label?: string }
1291
+ /** free text: LLM only (a decision model keeps the default; `required` with no default makes the part LLM-only) */
1292
+ | { type: 'text'; maxLength?: number; default?: string; required?: boolean; label?: string }
1293
+ /** a list of short texts: LLM only */
1294
+ | { type: 'list'; items?: { maxLength?: number }; min?: number; max?: number; required?: boolean; default?: readonly string[]; label?: string }
1295
+ /** a record: one node per record (at most one per part) */
1296
+ | { from: (state: any) => ReadonlyArray<CatalogRecord>; label?: string }
1297
+
1298
+ /** A param's value type */
1299
+ export type CatalogParamValue<P> =
1300
+ P extends { from: (state: any) => ReadonlyArray<{ value: infer V }> } ? V
1301
+ : P extends { options: infer O } ? keyof O & string
1302
+ : P extends { type: 'number' } ? number
1303
+ : P extends { type: 'text' } ? string
1304
+ : P extends { type: 'list' } ? string[]
1305
+ : unknown
1306
+
1307
+ /** The params of a part or container declaration, as a layout or tree holds them (all optional: defaults are left out) */
1308
+ export type CatalogParamsOf<E> = E extends { params: infer PS } ? { [K in keyof PS]?: CatalogParamValue<PS[K]> } : Record<string, never>
1309
+
1310
+ /** A node of a layout */
1311
+ export interface LayoutNode {
1312
+ part: string
1313
+ /** only the non-default values */
1314
+ params?: Record<string, unknown>
1315
+ /** a container's slots: node keys in order */
1316
+ slots?: Record<string, string[]>
1317
+ }
1318
+
1319
+ /**
1320
+ * A composition's layout: a node map whose keys are identities (the part id; `part:recordValue`;
1321
+ * repeats `~2`, `~3`; containers by id: `row`, `row~2`). Plain JSON state.
1322
+ */
1323
+ export interface Layout {
1324
+ root: 'page'
1325
+ nodes: Record<string, LayoutNode>
1326
+ }
1327
+
1328
+ /** A nested tree (what a model writes, what tests use), not tied to a catalog */
1329
+ export type ComposeTree = Array<{ part: string; params?: Record<string, unknown>; children?: ComposeTree | Record<string, ComposeTree> }>
1330
+
1331
+ type CatalogLens = { get(state: any): any; set?(state: any, value: any): any }
1332
+
1333
+ /** A part declaration */
1334
+ export interface CatalogPartDecl<STATE = any> {
1335
+ /** a Sygnal component (a child of the host: CHILD.select, lenses, EVENTS and context work) */
1336
+ component?: any
1337
+ /** or a view function, called inline with the props (and `state` read through the lens) */
1338
+ render?: (props: any) => any
1339
+ /** fixed props (params go over them), or `(params, state) => props` */
1340
+ props?: Record<string, any> | ((params: Record<string, any>, state: STATE) => Record<string, any>)
1341
+ /** all a model sees of it: "Noun (what it shows)" */
1342
+ description: string
1343
+ /** its state: a key of the host's state, a lens, or `(node, key) => lens` (a record part: `byId(…)`) */
1344
+ state?: string | CatalogLens | ((node: LayoutNode & { params: Record<string, any> }, key: string) => string | CatalogLens)
1345
+ /** state kept in the composition (`slice.local[nodeKey]`), starting as this */
1346
+ local?: unknown
1347
+ params?: Record<string, CatalogParam>
1348
+ /** where it may go: `'page'`, `'container'` (any slot) or `'container:slot'` (default `['page']`) */
1349
+ in?: string | readonly string[]
1350
+ /** alternatives: the first part of a group in a layout wins */
1351
+ group?: string
1352
+ /** how often one identity may appear (default 1) */
1353
+ maxUses?: number
1354
+ /** always on the page (added by the finishing step; not offered to a model) */
1355
+ required?: boolean
1356
+ pin?: 'start' | 'end'
1357
+ /** parts added right after it */
1358
+ companions?: readonly string[]
1359
+ category?: string
1360
+ /** a part that answers a card (`compose.answer(props, value)`) */
1361
+ result?: boolean
1362
+ }
1363
+
1364
+ /** What a container's view gets */
1365
+ export interface ContainerView {
1366
+ key: string
1367
+ node: LayoutNode
1368
+ /** its params, defaults filled */
1369
+ params: Record<string, any>
1370
+ /** each slot's vnodes */
1371
+ slots: Record<string, any[]>
1372
+ /** every child in order */
1373
+ children: Array<{ key: string; node: LayoutNode; part: CatalogPart | CatalogContainer; slot: string; vnode: any }>
1374
+ /** `slice.ui[key]` */
1375
+ ui: Record<string, any>
1376
+ /** data attributes to spread on an element: a click on it sets `ui[key][name] = value` (the behavior's CONTROL) */
1377
+ control(name: string, value?: unknown): Record<string, string>
1378
+ scope?: string
1379
+ frozen: boolean
1380
+ }
1381
+
1382
+ /** A container declaration: a view function with named slots (the model never chooses one) */
1383
+ export interface CatalogContainerDecl {
1384
+ /** slot name → description (default `{ default: null }`) */
1385
+ slots?: Record<string, string | null>
1386
+ /** fewer filled slots: unwrapped */
1387
+ minSlots?: number
1388
+ /** children per slot: fewer than `min` unwraps it, more than `max` overflow after it (a row splits) */
1389
+ min?: number
1390
+ max?: number
1391
+ /** only parts in its slots */
1392
+ accepts?: 'leaves'
1393
+ /** its order is fixed (no rows inside) */
1394
+ arranged?: boolean
1395
+ direction?: 'row' | 'column'
1396
+ description?: string
1397
+ /** where it may go (default `['page']`) */
1398
+ in?: string | readonly string[]
1399
+ params?: Record<string, CatalogParam>
1400
+ /** a view function, never a component (SYG161) */
1401
+ render?: (view: ContainerView) => any
1402
+ }
1403
+
1404
+ /** defineCatalog's declaration */
1405
+ export interface CatalogDecl {
1406
+ parts: Record<string, CatalogPartDecl>
1407
+ /** `page` and `row` are built in (override their render; `row: false` opts out, as `rows: false` does) */
1408
+ containers?: Record<string, CatalogContainerDecl | false>
1409
+ /** the page's label */
1410
+ description?: string
1411
+ /** `{ id: description }`, named by parts' `category` (a prefilter for large catalogs) */
1412
+ categories?: Record<string, string>
1413
+ /** false: no built-in row (canonical; `containers: { row: false }` too) */
1414
+ rows?: boolean
1415
+ /** quoted in order questions and the LLM prompt */
1416
+ guidance?: string
1417
+ /**
1418
+ * maxParts (default 12): parts on the page. maxDepth (default 4): how deep a container may sit,
1419
+ * the page's children at depth 1; a deeper one moves to the page
1420
+ */
1421
+ limits?: { maxParts?: number; maxDepth?: number }
1422
+ /** app-written few-shot cases (LLM) */
1423
+ examples?: readonly unknown[]
1424
+ }
1425
+
1426
+ /** A normalized part */
1427
+ export interface CatalogPart {
1428
+ id: string
1429
+ kind: 'part'
1430
+ description: string
1431
+ params: Record<string, CatalogParam & { kind: 'options' | 'number' | 'text' | 'list' | 'record' }>
1432
+ /** the record param's name */
1433
+ record?: string
1434
+ /** 'container:slot' entries */
1435
+ in: string[]
1436
+ group?: string
1437
+ maxUses: number
1438
+ required: boolean
1439
+ pin?: 'start' | 'end'
1440
+ companions: string[]
1441
+ category?: string
1442
+ result: boolean
1443
+ decl: CatalogPartDecl
1444
+ [key: string]: any
1445
+ }
1446
+
1447
+ /** A normalized container */
1448
+ export interface CatalogContainer {
1449
+ id: string
1450
+ kind: 'container'
1451
+ slots: Record<string, string | null>
1452
+ slotNames: string[]
1453
+ minSlots?: number
1454
+ min?: number
1455
+ max?: number
1456
+ accepts?: 'leaves'
1457
+ arranged: boolean
1458
+ direction?: 'row' | 'column'
1459
+ description?: string
1460
+ in: string[]
1461
+ params: Record<string, CatalogParam>
1462
+ render: (view: ContainerView) => any
1463
+ decl: CatalogContainerDecl
1464
+ }
1465
+
1466
+ /** One selectable option: a part, or a record part once per record (`part:value`) */
1467
+ export interface CatalogOption { option: string; part: string; params?: Record<string, unknown>; description: string }
1468
+
1469
+ type CatalogPartIds<D> = D extends { parts: infer P } ? keyof P & string : string
1470
+ type CatalogContainerIds<D> = (D extends { containers: infer C } ? { [K in keyof C & string]: C[K] extends false ? never : K }[keyof C & string] : never) | 'row'
1471
+ type CatalogContainerOf<D, C> = D extends { containers: infer CS } ? C extends keyof CS ? CS[C] : {} : {}
1472
+ type CatalogSlotsOf<D, C> = CatalogContainerOf<D, C> extends { slots: infer S } ? keyof S & string : 'default'
1473
+ type CatalogPartOf<D, P> = D extends { parts: infer PS } ? P extends keyof PS ? PS[P] : never : never
1474
+
1475
+ /** A tree node for a catalog: a part with its params, or a container with its children */
1476
+ export type CatalogTreeNode<D> =
1477
+ | { [P in CatalogPartIds<D>]: { part: P; params?: CatalogParamsOf<CatalogPartOf<D, P>> } }[CatalogPartIds<D>]
1478
+ | { [C in CatalogContainerIds<D>]: { part: C; params?: CatalogParamsOf<CatalogContainerOf<D, C>>; children?: CatalogTreeNode<D>[] | { [S in CatalogSlotsOf<D, C>]?: CatalogTreeNode<D>[] } } }[CatalogContainerIds<D>]
1479
+
1480
+ /** A nested tree for a catalog (part ids, params and slots checked); `ComposeTree` for an untyped one */
1481
+ export type CatalogTree<D> = 0 extends (1 & D) ? ComposeTree : CatalogTreeNode<D>[]
1482
+
1483
+ /** catalog.render's options */
1484
+ export interface ComposeRenderOptions {
1485
+ /** several compositions in one host (one per chat card): instance keys become `scope/nodeKey`; parts get `scope` */
1486
+ scope?: string
1487
+ /** an answered card: wrapped in `<fieldset disabled>`; parts get `frozen` */
1488
+ frozen?: boolean
1489
+ /** a result part's answer: `answer.value` goes to the part whose key is `answer.node` */
1490
+ answer?: { node: string; value: unknown } | null
1491
+ /** the slice's path in the host state (for `local` parts; default: the host key whose value is the slice) */
1492
+ at?: string | ReadonlyArray<string | number>
1493
+ }
1494
+
1495
+ /** What catalog.render renders: a compose slice, or anything with a layout */
1496
+ export type ComposeRenderable = { layout: Layout | null; preview?: Layout | null; ui?: Record<string, Record<string, any>>; local?: Record<string, any> }
1497
+
1498
+ /** A catalog (defineCatalog's result); D is the declaration, for typed trees */
1499
+ export interface Catalog<D = CatalogDecl> {
1500
+ /** (type only) the declaration */
1501
+ readonly __decl?: D
1502
+ parts: Record<string, CatalogPart>
1503
+ containers: Record<string, CatalogContainer>
1504
+ get(id: string): CatalogPart | CatalogContainer | undefined
1505
+ /** every selectable option for this state */
1506
+ options(state: any): CatalogOption[]
1507
+ /** a record part's records (validated: SYG252) */
1508
+ records(part: string | CatalogPart, state: any): CatalogRecord[]
1509
+ /** the deterministic finishing step (idempotent) */
1510
+ finish(layout: Layout | null | undefined, state?: any): Layout
1511
+ /**
1512
+ * The composition as vnodes for the host's own view: `slice.preview ?? slice.layout`, each part a
1513
+ * child of the host (keyed by its node key), containers through their view functions.
1514
+ */
1515
+ render(slice: ComposeRenderable | null | undefined, state: any, options?: ComposeRenderOptions): any
1516
+ description?: string
1517
+ categories: Record<string, string>
1518
+ rows: boolean
1519
+ guidance?: string
1520
+ limits: { maxParts: number; maxDepth: number }
1521
+ examples: unknown[]
1522
+ }
1523
+
1524
+ /**
1525
+ * PLAN-9 K-1: a catalog of prepared parts a model composes from, one declaration for both
1526
+ * backends. `catalog.render(state.page, state)` renders a composition.
1527
+ */
1528
+ export function defineCatalog<const D extends CatalogDecl>(decl: D): Catalog<D>
1529
+
1530
+ /** fromTree(catalog, tree, previous?): a tree → a layout, reusing `previous`'s keys (same params, then same identity) */
1531
+ export function fromTree<D>(catalog: Catalog<D>, tree: CatalogTree<D>, previous?: Layout | null): Layout
1532
+ /** toTree(layout): the nested tree (keys dropped) */
1533
+ export function toTree(layout: Layout | null | undefined): ComposeTree
1534
+ /** removeNode(layout, key): the layout without that node and its subtree (not finished) */
1535
+ export function removeNode(layout: Layout, key: string): Layout
1536
+
1537
+ /** The reply action names a backend's requests carry */
1538
+ export interface ComposeNames { ok: string; error: string; delta: string; key: string }
1539
+
1540
+ /** A composition backend (`compose.decisions()`, `compose.llm()`, or your own) */
1541
+ export interface ComposeBackend {
1542
+ /** the sink its requests go to: 'HTTP' (decisions) or the LLM sink */
1543
+ sink: string
1544
+ start(a: { catalog: Catalog<any>; state: any; prompt: string; previous: Layout | null; names: ComposeNames }): { request: unknown; pending: unknown }
1545
+ /**
1546
+ * a layout (done), `layout: null, unavailable: true` (nothing fits), `request` + `pending` (another
1547
+ * step), or `error`. `keyed: true`: the layout and preview already carry the previous layout's
1548
+ * keys (an edit), so the behavior doesn't re-key them by identity
1549
+ */
1550
+ reply(a: { catalog: Catalog<any>; state: any; pending: any; reply: any; names: ComposeNames }):
1551
+ { layout?: Layout | null; unavailable?: true; preview?: Layout; request?: unknown; pending?: unknown; decisions?: unknown; error?: string; keyed?: boolean }
1552
+ /** `keyed` (on reply too): the layout already has the previous layout's keys (an edit applied to it): the behavior doesn't re-key it */
1553
+ delta?(a: { catalog: Catalog<any>; state: any; pending: any; delta: any }): { preview?: Layout; keyed?: boolean }
1554
+ /** the reply body that leads to `target` (composeWith) */
1555
+ fake(a: { catalog: Catalog<any>; state: any; pending: any; target: ComposeTree }): unknown
1556
+ }
1557
+
1558
+ export type ComposeStatus = 'idle' | 'composing' | 'preview' | 'done' | 'unavailable' | 'error'
1559
+
1560
+ /** The compose behavior's slice: `state.page` for `uses = { page: compose(...) }` */
1561
+ export interface ComposeState {
1562
+ prompt: string
1563
+ status: ComposeStatus
1564
+ /** the last finished layout (kept on an error and when nothing fits) */
1565
+ layout: Layout | null
1566
+ /** the layout being built (rendered instead while set) */
1567
+ preview: Layout | null
1568
+ /** the backend's step (plain JSON) */
1569
+ pending: unknown
1570
+ /** what the backend explains (decisions: its answers; LLM: its plan) */
1571
+ decisions: unknown
1572
+ error: string | null
1573
+ /** container state per node key (CONTROL) */
1574
+ ui: Record<string, Record<string, any>>
1575
+ /** `local` parts' state per node key */
1576
+ local: Record<string, any>
1577
+ }
1578
+
1579
+ /** The compose behavior's actions, as `'<key>.<ACTION>'` on the host */
1580
+ export interface ComposeActions {
1581
+ /** compose (an edit of the current layout unless `fresh`): a prompt, `{ prompt, fresh }`, or the form's submit (the slice's prompt) */
1582
+ COMPOSE: string | { prompt: string; fresh?: boolean } | Event | undefined
1583
+ /** set the layout (finished first): restore, undo, the app's own edits; null clears */
1584
+ SET_LAYOUT: Layout | null
1585
+ /** remove a node (by key) and its subtree */
1586
+ REMOVE: string
1587
+ /** a container control: `ui[key][name] = value` */
1588
+ CONTROL: { key: string; name: string; value: unknown }
1589
+ /** a composition finished (a host entry `'page.DONE'` runs after the behavior's) */
1590
+ DONE: Layout
1591
+ /** Internal: the prompt field's input */
1592
+ PROMPT: string
1593
+ }
1594
+
1595
+ export interface ComposeOptions {
1596
+ /** `compose.decisions({ … })`, `compose.llm({ … })`, or your own ComposeBackend */
1597
+ backend: ComposeBackend
1598
+ /** the form whose submit composes */
1599
+ form?: BehaviorTarget
1600
+ /** the prompt field (its input sets `prompt`) */
1601
+ prompt?: BehaviorTarget
1602
+ /** the request lane (default 'compose'): a new prompt aborts the step in flight */
1603
+ key?: string
1604
+ }
1605
+
1606
+ /** A result part's PARENT value (`compose.answer(props, value)`) */
1607
+ export interface ComposeAnswer<V = unknown> { scope: string | undefined; node: string | undefined; value: V }
1608
+
1609
+ /**
1610
+ * PLAN-9 C-1: compose a page from a catalog. `uses = { page: compose(catalog, { backend:
1611
+ * compose.decisions({ model }), form: '.ask', prompt: '.prompt' }) }`, rendered with
1612
+ * `catalog.render(state.page, state)`.
1613
+ */
1614
+ export const compose: {
1615
+ (catalog: Catalog<any>, options: ComposeOptions): Behavior<ComposeState, ComposeActions, {}, ComposeOptions>
1616
+ /** a decision-model backend (decide() on HTTP) */
1617
+ decisions(options?: ComposeDecisionsOptions): ComposeBackend
1618
+ /** a chat-LLM backend (structured output on the LLM sink) */
1619
+ llm(options?: ComposeLlmOptions): ComposeBackend
1620
+ /** a result part's PARENT value: `{ scope, node, value }` */
1621
+ answer<V>(props: { scope?: string; node?: string }, value: V): ComposeAnswer<V>
1622
+ /**
1623
+ * PLAN-9 C-2: the `agent.actions` entry that lets an agent (a chat assistant) compose the page:
1624
+ * `actions: { ...compose.action('page') }` offers `<agent name>_page_compose({ prompt, fresh? })`;
1625
+ * its result waits (`until`) for the composition to finish, so its `state` (the `read`) shows the
1626
+ * new page (`read: (s) => ({ page: compose.summary(s.page) })`). `timeout` default 60 s
1627
+ */
1628
+ action<K extends string = 'page'>(key?: K, options?: { description?: string; timeout?: number }): Record<`${K}.COMPOSE`, AgentAction<any, { prompt: string; fresh?: boolean }>>
1629
+ /** PLAN-9 C-2: a composition as an agent's `read` shows it: its status, the layout as a tree, the error */
1630
+ summary(slice: ComposeState | null | undefined): { status: ComposeStatus; page: ComposeTree; error?: string } | undefined
1631
+ }
1632
+
1633
+ /**
1634
+ * PLAN-9 T-1: answer the pending composition of `catalog` so that it becomes `tree`, whatever the
1635
+ * backend (each step through the backend's fake reply). `stream: n` streams an LLM reply in n chunks.
1636
+ */
1637
+ export function composeWith<D>(t: RenderResult<any>, catalog: Catalog<D>, tree: CatalogTree<D>, options?: { stream?: number | boolean }): Promise<void>
1638
+
1639
+ // PLAN-9 B: compose.decisions
1640
+ /** `compose.decisions(options)`: the decision-model backend (`decide()` on the HTTP sink) */
1641
+ export interface ComposeDecisionsOptions {
1642
+ /** the decision endpoint (default '/api/decide': the app's own route, which holds the API key) */
1643
+ url?: string
1644
+ /** the decision model ('jev-latest', 'nimble', …); without one: SYG681 (dev) */
1645
+ model?: string
1646
+ /** the request lane (`key` of every `decide()` request; default: the behavior's, 'compose') */
1647
+ key?: string
1648
+ /**
1649
+ * rows: the mean side-by-side probability two groups of parts need to join one row
1650
+ * (average-linkage clustering of the `beside_` answers; default 0.25, measured on Jev; 0.5 suits nimble)
1651
+ */
1652
+ rowMean?: number
1653
+ /**
1654
+ * ask which part `categories` the request is about first, then only about their parts (one more
1655
+ * call, about half the input tokens; measured on Jev's 50-part ops console: the same accuracy).
1656
+ * 'auto' (default): on when the catalog has categories and more than 60 parts to choose from
1657
+ * (30 with `profile: 'small'`); `true`: always (with categories); `false`: never
1658
+ */
1659
+ prefilter?: boolean | 'auto'
1660
+ /**
1661
+ * the most options one choice question may have (default 255; 25 when `url` looks like Ollama;
1662
+ * at least 4): every larger choice is split into chunks that repeat its head options
1663
+ */
1664
+ maxOptions?: number
1665
+ /**
1666
+ * 'small': for a local ~9B decision model such as Ollama's nimble, which favours the first of two
1667
+ * options: pairwise order questions are asked in both orders, and near-ties follow the order in
1668
+ * which the request names the parts (G-755); at most 8 parts per composition (`maxParts`); the
1669
+ * category prefilter from 30 parts
1670
+ */
1671
+ profile?: 'small'
1672
+ /**
1673
+ * the most parts one composition selects (the most probable kept; required parts and companions
1674
+ * come on top). Default: the catalog's `limits.maxParts` (12); 8 with `profile: 'small'`. The
1675
+ * catalog's limit still caps the page
1676
+ */
1677
+ maxParts?: number
1678
+ /** passed to every `decide()` request (D472): see `DecideOptions`. Default 64 */
1679
+ maxQuestions?: number
1680
+ /** passed to every `decide()` request (D472): see `DecideOptions`. Default: none */
1681
+ maxPromptTokens?: number
1682
+ /** passed to every `decide()` request (D472): false sends each step as one request, never halved */
1683
+ split?: boolean
1684
+ }
1685
+ /**
1686
+ * the decisions backend: plain-JSON `pending` steps (`'categories'`, `'select'`, `'layout'`; for an
1687
+ * edit of a previous layout, B-4: `'edit'`, then `'where'` when something moves or is added)
1688
+ */
1689
+ export interface ComposeDecisionsBackend {
1690
+ sink: 'HTTP'
1691
+ kind: 'decisions'
1692
+ start(a: { catalog: any; state: any; prompt: string; previous: any; names: { ok: string; error: string; delta?: string; key?: string } }): { request: DecideRequest<any>; pending: any }
1693
+ reply(a: { catalog: any; state: any; pending: any; reply: any; names: { ok: string; error: string; delta?: string; key?: string } }): {
1694
+ layout?: any; unavailable?: true; preview?: any; request?: DecideRequest<any>; pending?: any
1695
+ /** a reply that can't be read (no `answers`, or every answer outside its options: SYG682); the behavior's status 'error' */
1696
+ error?: string
1697
+ /**
1698
+ * the raw answers per step (`{ select, layout }`, plus `categories` with `prefilter`; an edit:
1699
+ * `{ edit, where }`), for an explain panel: question name → `{ choice, probabilities }`. Names
1700
+ * an explain panel can rely on (`<p>` a part id, `<k>` an element of the layout step):
1701
+ * select: `use_<p>` (omit / use), `count_<p>`, `pick_<group>`, `rec_<p>__<n>` (the n-th record
1702
+ * slot, n = 1..4: none or a record), `param_<p>__<param>` (not_stated = the default), `row`;
1703
+ * layout: `parent_<k>`, `beside_<k>`, `first_<a>_vs_<b>`, `left_<a>_vs_<b>`, `same_<a>_vs_<b>`,
1704
+ * `gap_<x>_in_<p>`; categories: `cat_<category>`; edit: `remove_<node>`, `set_<node>__<param>`,
1705
+ * `add_<p>`, `moved_<n>`; where: `where_<node>`. A choice split by `maxOptions` shows as its
1706
+ * chunks, `<name>__c<k>` (each repeats the head options: not_stated, keep, none, omit)
1707
+ */
1708
+ decisions?: Record<string, Record<string, any>>
1709
+ /** an edit's layout and preview keep the previous layout's keys (the behavior uses them as they are) */
1710
+ keyed?: boolean
1711
+ }
1712
+ /** the decision reply that makes the pending step read as `target` (composeWith); throws when the encoding can't reach it */
1713
+ fake(a: { catalog: any; state: any; pending: any; target: any[] }): Decision<any>
1714
+ }
1715
+
1716
+ // PLAN-9 C: compose.llm ------------------------------------------------------------------------
1717
+
1718
+ /**
1719
+ * `compose.llm(options?)`: the GenAI LLM backend of `compose` (one structured-output request on
1720
+ * a makeChatDriver() sink; a streamed preview). The request carries the catalog's JSON Schema
1721
+ * (`asked_for`, then the layout tree), the system prompt (the catalog as text, its `guidance`,
1722
+ * `examples` as few-shot turns) and `delta` when streaming. Other keys go on the request as is
1723
+ * (transport options such as `body: { temperature: 0 }`). Requests carry `parse: false`: the reply
1724
+ * text is read leniently here (prose or a code fence around the JSON), and text with no layout in
1725
+ * it is the behavior's status 'error' (SYG683), not the driver's FAILED.
1726
+ *
1727
+ * B-4: with a current layout, COMPOSE is an edit. The request sends the page (`n1: part params`,
1728
+ * one node per line) and the change; the model returns operations positioned by named places
1729
+ * (`add` / `remove` / `move` / `replace` / `setParams`; `top`, `bottom`, `above n2`, `left of n3`,
1730
+ * …), applied to the current layout itself: every node they don't replace or remove keeps its key
1731
+ * (a param change too; another record is another node), a replacement takes the old node's place,
1732
+ * and the node a place names never moves. No operation: `unavailable`, the layout stays.
1733
+ *
1734
+ * compose(catalog, { backend: compose.llm({ model: 'gpt-5-mini' }) })
1735
+ */
1736
+ export interface ComposeLlmOptions {
1737
+ /** The model id (default: the transport's), or a function of the host's state that returns one, resolved per request */
1738
+ model?: string | ((state: any) => string | undefined)
1739
+ /** The chat driver's sink (default 'LLM') */
1740
+ sink?: string
1741
+ /** The request key (default: the behavior's key) */
1742
+ key?: string
1743
+ /** Stream a preview of the complete parts so far (default true) */
1744
+ stream?: boolean
1745
+ /** Ask for the `asked_for` plan before the tree (default true; false when there are examples) */
1746
+ plan?: boolean
1747
+ /** More instructions after the rules, or a function of the system prompt returning the one to send */
1748
+ instructions?: string | ((prompt: string) => string)
1749
+ /** Few-shot examples (default: the catalog's): `[prompt, tree, asked_for?]` or `{ prompt, tree, asked_for? }` */
1750
+ examples?: ReadonlyArray<readonly [string, ReadonlyArray<ComposeLlmTreeNode>, ReadonlyArray<string>?] | { prompt: string; tree: ReadonlyArray<ComposeLlmTreeNode>; asked_for?: ReadonlyArray<string> }>
1751
+ /** Transport options, sent on the request as is */
1752
+ [option: string]: unknown
1753
+ }
1754
+
1755
+ /** A node of a layout tree as `examples` take it (the contract's Tree) */
1756
+ export type ComposeLlmTreeNode = { part: string; params?: Record<string, unknown>; children?: ReadonlyArray<ComposeLlmTreeNode> | Record<string, ReadonlyArray<ComposeLlmTreeNode>> }
1757
+
1758
+ /** What `compose.llm()` puts in the slice's `decisions`: the model's plan and what the repair fixed */
1759
+ export interface ComposeLlmDecisions {
1760
+ /** Each thing the request asks for, in the model's words (with `plan`) */
1761
+ asked_for?: string[]
1762
+ /** What the repair changed: `alias`, `duplicate`, `param`, `structure`, `unknownPart`, `required`, `invalid`; `op` for an edit's operations */
1763
+ repairs?: Array<{ kind: string; detail: string }>
1764
+ /** An edit: the operations as applied, with layout keys (an add's `node` is the new key) */
1765
+ ops?: Array<{ op: 'add' | 'remove' | 'move' | 'replace' | 'setParams'; node: string; part?: string; params?: Record<string, unknown>; where?: string; moved?: string }>
1766
+ }
1767
+
1768
+ // ---- PLAN-9 H: chat client tools and ask ---------------------------------------------------------
1769
+
1770
+ /** PLAN-9 H-1: a tool the app answers (`chat({ clientTools: { name: tool } })`) */
1771
+ export interface ChatClientTool {
1772
+ description: string
1773
+ /** The input's JSON Schema, or a Standard Schema with a JSON Schema form (its input is validated: a failure closes the call with the issues, so the model can repair it) */
1774
+ inputSchema: JsonSchemaObject | AgentSchema<any>
1775
+ }
1776
+
1777
+ /** PLAN-9 H-2: `chat({ ask })` */
1778
+ export interface ChatAskOptions {
1779
+ /** The parts a card is built from; parts with `result: true` answer it (none: SYG686) */
1780
+ catalog: Catalog<any>
1781
+ /** What composes the card from the call's `need` and the app state: `compose.llm()` (default, on the chat's sink) or `compose.decisions()` */
1782
+ backend?: ComposeBackend
1783
+ /** More guidance on when and how to ask, added to the `ask_user` tool's description */
1784
+ instructions?: string
1785
+ /** PLAN-9 H-3: the tool's input is the card (the layout schema; LLM only, for providers that enforce tool schemas): no compose round trip */
1786
+ direct?: boolean
1787
+ }
1788
+
1789
+ /** 'composing' (queued, or its backend step is out) | 'open' (answerable) | 'answered' | 'failed' (the call closed with "could not show a card: …") | 'cancelled' (STOP, a new message) */
1790
+ export type ChatCardStatus = 'composing' | 'open' | 'answered' | 'failed' | 'cancelled'
1791
+
1792
+ /** PLAN-9 H-2: an `ask_user` call's card, on its tool part (`part.ui`); render it with `askCard()` */
1793
+ export interface ChatCard {
1794
+ status: ChatCardStatus
1795
+ /** The card (a decision backend's preview while it composes) */
1796
+ layout: Layout | null
1797
+ /** State of the card's `local` parts, per node key */
1798
+ local: Record<string, any>
1799
+ /** Container state per node key (CONTROL) */
1800
+ ui: Record<string, Record<string, any>>
1801
+ /** The answer: the result part's node, its part id, the value */
1802
+ answer: { node: string; part: string | null; value: unknown } | null
1803
+ /** The backend's step while its request is out */
1804
+ pending?: unknown
1805
+ /**
1806
+ * What the backend explains, as a compose slice's `decisions`: its last reply's (`compose.llm()`:
1807
+ * ComposeLlmDecisions; `compose.decisions()`: the answers per step, see ComposeDecisionsBackend).
1808
+ * Absent until a reply carries some; kept (and persisted) with the card, for an explain panel
1809
+ */
1810
+ decisions?: unknown
1811
+ }
1812
+
1813
+ /** A tool part of a chat message */
1814
+ export type ChatToolPart = Extract<MessagePart, { toolCallId: string }>
1815
+
1816
+ /**
1817
+ * PLAN-9 H-1: the `input-available` tool parts of the last assistant message: the calls waiting for
1818
+ * the app's answer (and agent calls while they run: status 'streaming')
1819
+ */
1820
+ export function openToolCalls(state: ChatState): ChatToolPart[]
1821
+
1822
+ /**
1823
+ * PLAN-9 H-2: the card of an `ask_user` tool part, for the host's view: `catalog.render(part.ui,
1824
+ * state, { scope: part.toolCallId, frozen, answer })`, frozen unless the card is open, `local`
1825
+ * written through a lens to the part. `key`: the chat's key in `uses` (default 'assistant'). Null
1826
+ * while the card has no layout.
1827
+ */
1828
+ export function askCard(catalog: Catalog<any>, state: any, part: ChatToolPart, options?: { key?: string }): any
1829
+
1830
+ /** PLAN-9 M-1: `<Composed>`'s props */
1831
+ export interface ComposedProps {
1832
+ /** the catalog (defineCatalog's result); missing: SYG451, nothing rendered */
1833
+ catalog: Catalog<any>
1834
+ /**
1835
+ * Where the composition is in the host's state: a key (default 'page') or a path
1836
+ * (['boards', 0, 'page']); with `card`, the chat's key (default 'assistant'). Not a composition
1837
+ * (an object with `layout`): SYG451, nothing rendered
1838
+ */
1839
+ from?: string | ReadonlyArray<string | number>
1840
+ /** an `ask_user` tool part: its card, as askCard() renders it (scope, frozen and answer from the part) */
1841
+ card?: ChatToolPart
1842
+ /** as catalog.render's options (they override the card's) */
1843
+ scope?: string
1844
+ frozen?: boolean
1845
+ answer?: { node: string; value: unknown } | null
1846
+ key?: string | number
1847
+ }
1848
+
1849
+ /**
1850
+ * PLAN-9 M-1 (D466): `<Composed catalog={catalog} from="page" />` renders the composition at `from`
1851
+ * in the host's state, as `{catalog.render(state.page, state)}` in the view does: the parts are the
1852
+ * host's children (same keys, CHILD.select, lenses). Differences: it reads the host's own state (a
1853
+ * composition computed in the view needs render()), and a container view or part `props()` that
1854
+ * throws replaces only the composition with the host's `onError` (SYG408), where render() shows an
1855
+ * empty `.compose-error` element (SYG406).
1856
+ */
1857
+ export function Composed(props: ComposedProps): JSX.Element