@unoverse-platform/studio 0.1.115 → 0.1.117

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 (72) hide show
  1. package/local/catalog.ts +3 -0
  2. package/local/plugin.ts +12 -9
  3. package/local/scaffold.d.mts +1 -0
  4. package/local/scaffold.mjs +12 -0
  5. package/package.json +1 -1
  6. package/screens/AgentChain.tsx +232 -0
  7. package/screens/AgentsView.tsx +85 -6
  8. package/screens/SkillsView.tsx +21 -3
  9. package/screens/TemplatesLibraryView.tsx +32 -3
  10. package/screens/TemplatesView.tsx +2 -2
  11. package/screens/index.ts +4 -1
  12. package/src/host.ts +5 -0
  13. package/src/index.css +11 -0
  14. package/vendor/base/src/agent-mcp/discovery.ts +5 -2
  15. package/vendor/base/src/agent-mcp/index.ts +3 -1
  16. package/vendor/base/src/agent-mcp/invoke.ts +199 -25
  17. package/vendor/base/src/agent-mcp/progressive.ts +124 -0
  18. package/vendor/base/src/agent-mcp/tools.ts +18 -11
  19. package/vendor/base/src/agent-mcp/types.ts +8 -0
  20. package/vendor/base/src/auth/agentSwitch.ts +52 -0
  21. package/vendor/base/src/auth/agentToken.ts +142 -0
  22. package/vendor/base/src/auth/auth.ts +7 -0
  23. package/vendor/base/src/definitions/load/expand/inherit.ts +1 -1
  24. package/vendor/base/src/definitions/load/expand/refs.ts +1 -1
  25. package/vendor/base/src/definitions/load/expand/walk.ts +1 -1
  26. package/vendor/base/src/definitions/load/load.ts +6 -1
  27. package/vendor/base/src/definitions/registry/agentFolders.ts +113 -0
  28. package/vendor/base/src/definitions/registry/catalog.ts +1 -1
  29. package/vendor/base/src/definitions/registry/objects.ts +109 -5
  30. package/vendor/base/src/definitions/registry/pipelines.ts +24 -5
  31. package/vendor/base/src/definitions/registry/taskStarts.ts +76 -0
  32. package/vendor/base/src/definitions/registry/taskUri.ts +67 -0
  33. package/vendor/base/src/http/uwsFetchAdapter.ts +39 -3
  34. package/vendor/base/src/items/collect.ts +10 -0
  35. package/vendor/base/src/items/publish.ts +10 -0
  36. package/vendor/base/src/lint/design/index.mjs +14 -3
  37. package/vendor/base/src/lint/design/vocabulary.mjs +1 -1
  38. package/vendor/base/src/lint/objects/index.d.mts +1 -0
  39. package/vendor/base/src/lint/objects/index.mjs +121 -3
  40. package/vendor/base/src/lint/objects/rules.d.mts +3 -0
  41. package/vendor/base/src/lint/objects/rules.mjs +106 -7
  42. package/vendor/base/src/manifests/compose.ts +3 -0
  43. package/vendor/base/src/manifests/executor/callback.ts +3 -2
  44. package/vendor/base/src/manifests/executor/memory.ts +27 -0
  45. package/vendor/base/src/manifests/executor/toolBridge.ts +79 -13
  46. package/vendor/base/src/manifests/marketplaceMcp.ts +20 -0
  47. package/vendor/base/src/manifests/runtime/auth/oauth.ts +43 -10
  48. package/vendor/base/src/manifests/runtime/events.ts +1 -1
  49. package/vendor/base/src/manifests/runtime/http/request.ts +47 -4
  50. package/vendor/base/src/manifests/runtime/loops/poll.ts +27 -5
  51. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +59 -24
  52. package/vendor/base/src/manifests/runtime/usage.ts +13 -5
  53. package/vendor/base/src/platform/agentBudget.ts +65 -0
  54. package/vendor/base/src/platform/agentCalendar.ts +87 -0
  55. package/vendor/base/src/platform/inbound.ts +67 -0
  56. package/vendor/base/src/platform/taskChanged.ts +25 -0
  57. package/vendor/base/src/platform/tokenCost.ts +414 -0
  58. package/vendor/base/src/security/permissions.ts +10 -2
  59. package/vendor/sdk/appEngine.ts +42 -1
  60. package/vendor/sdk/lib/core/actions.ts +10 -0
  61. package/vendor/sdk/lib/core/apps.ts +2 -2
  62. package/vendor/sdk/lib/core/client.ts +11 -0
  63. package/vendor/sdk/lib/core/store.ts +1 -1
  64. package/vendor/sdk/lib/core/types.ts +2 -0
  65. package/vendor/sdk/lib/dom/app.ts +3 -2
  66. package/vendor/sdk/lib/dom/component.ts +38 -1
  67. package/vendor/sdk/lib/dom/element.ts +23 -0
  68. package/vendor/sdk/lib/dom/markdown.ts +6 -2
  69. package/vendor/sdk/lib/dom/primitives.ts +97 -6
  70. package/vendor/sdk/lib/voiceService.ts +3 -1
  71. package/vendor/sdk/main.ts +1 -1
  72. package/vendor/sdk/pageInterfaces.ts +109 -8
@@ -0,0 +1,414 @@
1
+ /**
2
+ * TOKEN USAGE AND ITS COST: raw usage from any model, read into one shape, and priced in USD per
3
+ * 1M tokens (or per minute for voice). One table for the platform: Canvas and Signal show it, and
4
+ * an Agent's budget is counted with it (docs/architecture/signal/AGENTS.md § 9). Moved here from
5
+ * Canvas (`pages/dashboard/tokenConverters.js`) so the server reads the same prices.
6
+ */
7
+
8
+ /** A raw usage block as a vendor sent it. */
9
+ type RawUsage = any;
10
+ /** What a model costs, per 1M tokens (or per minute). */
11
+ export interface ModelPricing {
12
+ input?: number; cachedInput?: number; cacheWrite?: number; output?: number; perMinute?: number;
13
+ audioInput?: number; cachedAudioInput?: number; audioOutput?: number; imageInput?: number; cachedImageInput?: number;
14
+ }
15
+
16
+ /**
17
+ * Parse token usage based on model type
18
+ * @param {string} model - The model name
19
+ * @param {object} usage - Raw usage object from AI model
20
+ * @returns {object|null} Parsed usage with standardized fields
21
+ */
22
+ export const parseTokenUsage = (model: string, usage: RawUsage): any => {
23
+ if (!usage) return null;
24
+
25
+ // Handle string usage (JSON stored as string)
26
+ if (typeof usage === "string") {
27
+ try {
28
+ usage = JSON.parse(usage);
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ const parsed = parseByVendor(model, usage);
35
+ // A VOICE CALL'S LENGTH, timed by the platform on every call. Per-minute vendors bill on it.
36
+ return parsed && typeof usage.seconds === "number" ? { ...parsed, seconds: usage.seconds } : parsed;
37
+ };
38
+
39
+ const parseByVendor = (model: string, usage: RawUsage): any => {
40
+ if (model.startsWith("gpt-")) return parseOpenAI(usage);
41
+ if (model.startsWith("claude-")) return parseClaude(usage);
42
+ if (model.startsWith("amazon.")) return parseBedrock(usage);
43
+ if (model.startsWith("us.anthropic.")) return parseBedrockClaude(usage);
44
+ if (model.startsWith("anthropic.")) return parseBedrockClaude(usage);
45
+
46
+ return parseGeneric(usage);
47
+ };
48
+
49
+ /**
50
+ * OpenAI Models (GPT-4, GPT-5, etc.)
51
+ * Handles both Chat Completions API and Responses API
52
+ */
53
+ const parseOpenAI = (usage: RawUsage) => {
54
+ // Handle string usage (JSON stored as string)
55
+ if (typeof usage === "string") {
56
+ try {
57
+ usage = JSON.parse(usage);
58
+ } catch {
59
+ return { total: 0, input: 0, output: 0, reasoning: 0, cached: 0, breakdown: {} };
60
+ }
61
+ }
62
+
63
+ // Responses API format (GPT-5) or generic input_tokens format
64
+ if ("input_tokens" in usage) {
65
+ // TWO SPELLINGS, ONE WORD APART. Responses says `input_tokens_details`; the REALTIME API says
66
+ // `input_token_details`, singular. Reading only the plural made every voice call report zero
67
+ // reasoning and zero cached — silently, since the totals beside them were right (confirmed
68
+ // against a live response.done, 2026-08-12).
69
+ const inDetails = usage.input_tokens_details || usage.input_token_details || {};
70
+ const outDetails = usage.output_tokens_details || usage.output_token_details || {};
71
+ const cachedTokens = inDetails.cached_tokens || 0;
72
+ // WRITTEN TO THE CACHE, the other part of input with its own price (GPT-5.6 bills a cache write
73
+ // above a fresh token). Like cached, it is counted inside `input_tokens`.
74
+ const cacheWriteTokens = inDetails.cache_write_tokens || 0;
75
+ const reasoningTokens = outDetails.reasoning_tokens || 0;
76
+ // AUDIO IS PRICED DIFFERENTLY from text, so voice cost cannot be read off a total alone.
77
+ const audioIn = inDetails.audio_tokens || 0;
78
+ const audioOut = outDetails.audio_tokens || 0;
79
+ const imageIn = inDetails.image_tokens || 0;
80
+
81
+ /**
82
+ * HOW THE CACHED TOKENS SPLIT ACROSS MODALITIES, which is what makes them priceable.
83
+ *
84
+ * A cached token costs $0.40 against $32 for fresh audio or $4 for fresh text, so getting
85
+ * this wrong is most of the bill. `cached_tokens` alone does not say which modality it came
86
+ * from, and `cached_tokens_details` — which does — is absent from every row written before
87
+ * the collector stopped dropping it, and from any vendor that never sends it.
88
+ *
89
+ * So: use the details when present, and otherwise APPORTION BY SHARE. Proportional is a
90
+ * genuine estimate rather than a fact, but it is far closer than the alternative of billing
91
+ * every cached token at the full fresh rate, which overcharged a real call by ~45%.
92
+ */
93
+ const cachedTotal = inDetails.cached_tokens || 0;
94
+ const cachedDetails = inDetails.cached_tokens_details;
95
+ const freshTotal = (inDetails.text_tokens || 0) + audioIn + imageIn;
96
+ const share = (n: number) => (freshTotal > 0 ? Math.round((cachedTotal * n) / freshTotal) : 0);
97
+ const cachedTextIn = cachedDetails ? cachedDetails.text_tokens || 0 : share(inDetails.text_tokens || 0);
98
+ const cachedAudioIn = cachedDetails ? cachedDetails.audio_tokens || 0 : share(audioIn);
99
+ const cachedImageIn = cachedDetails ? cachedDetails.image_tokens || 0 : share(imageIn);
100
+
101
+ return {
102
+ total: usage.total_tokens || (usage.input_tokens || 0) + (usage.output_tokens || 0),
103
+ input: usage.input_tokens || 0,
104
+ output: usage.output_tokens || 0,
105
+ reasoning: reasoningTokens,
106
+ cached: cachedTokens,
107
+ cacheWrite: cacheWriteTokens,
108
+ breakdown: {
109
+ input: usage.input_tokens || 0,
110
+ cached: cachedTokens,
111
+ ...(cacheWriteTokens ? { cacheWrite: cacheWriteTokens } : {}),
112
+ reasoning: reasoningTokens,
113
+ completion: (usage.output_tokens || 0) - reasoningTokens,
114
+ // Present only on a voice call; omitted rather than shown as 0 for text models, so a
115
+ // zero in the panel always means "no audio" rather than "not measured".
116
+ ...(audioIn || audioOut ? { audioIn, audioOut } : {}),
117
+ },
118
+ /**
119
+ * THE PER-MODALITY SPLIT, for pricing. Only present when the vendor sent one.
120
+ *
121
+ * CACHED IS A SUBSET, not a fourth category: `cached_tokens_details` breaks the cached
122
+ * count down by modality, so a cached audio token is already counted in `audio_tokens`.
123
+ * Subtracting it is what stops it being billed twice, once at $32 and again at $0.40.
124
+ */
125
+ ...(audioIn || audioOut || inDetails.text_tokens !== undefined
126
+ ? {
127
+ modality: {
128
+ textIn: Math.max(0, (inDetails.text_tokens || 0) - cachedTextIn),
129
+ cachedTextIn,
130
+ audioIn: Math.max(0, audioIn - cachedAudioIn),
131
+ cachedAudioIn,
132
+ imageIn: Math.max(0, imageIn - cachedImageIn),
133
+ cachedImageIn,
134
+ textOut: outDetails.text_tokens || 0,
135
+ audioOut,
136
+ },
137
+ }
138
+ : {}),
139
+ };
140
+ }
141
+
142
+ // Chat Completions API format (GPT-4, GPT-3.5)
143
+ const cachedTokens = usage.prompt_tokens_details?.cached_tokens || 0;
144
+ const input = usage.prompt_tokens || usage.promptTokens || 0;
145
+ const output = usage.completion_tokens || usage.completionTokens || 0;
146
+
147
+ return {
148
+ total: usage.total_tokens || usage.totalTokens || input + output,
149
+ input,
150
+ output,
151
+ reasoning: usage.reasoning_tokens || 0,
152
+ cached: cachedTokens,
153
+ breakdown: {
154
+ prompt: input,
155
+ cached: cachedTokens,
156
+ reasoning: usage.reasoning_tokens || 0,
157
+ completion: output,
158
+ },
159
+ };
160
+ };
161
+
162
+ /**
163
+ * Claude Models (Anthropic)
164
+ */
165
+ const parseClaude = (usage: RawUsage) => {
166
+ return {
167
+ total: (usage.input_tokens || 0) + (usage.output_tokens || 0),
168
+ input: usage.input_tokens || 0,
169
+ output: usage.output_tokens || 0,
170
+ reasoning: 0,
171
+ breakdown: {
172
+ input: usage.input_tokens || 0,
173
+ output: usage.output_tokens || 0,
174
+ },
175
+ };
176
+ };
177
+
178
+ /**
179
+ * Bedrock Models (AWS)
180
+ */
181
+ const parseBedrock = (usage: RawUsage) => {
182
+ return {
183
+ total: (usage.inputTokens || 0) + (usage.outputTokens || 0),
184
+ input: usage.inputTokens || 0,
185
+ output: usage.outputTokens || 0,
186
+ reasoning: 0,
187
+ breakdown: {
188
+ input: usage.inputTokens || 0,
189
+ output: usage.outputTokens || 0,
190
+ },
191
+ };
192
+ };
193
+
194
+ /**
195
+ * Bedrock Claude Models
196
+ * Handles both native Bedrock format (inputTokens) and OpenAI-style format (prompt_tokens)
197
+ */
198
+ const parseBedrockClaude = (usage: RawUsage) => {
199
+ const input = usage.input_tokens || usage.inputTokens || usage.prompt_tokens || 0;
200
+ const output = usage.output_tokens || usage.outputTokens || usage.completion_tokens || 0;
201
+ return {
202
+ total: usage.total_tokens || usage.totalTokens || input + output,
203
+ input,
204
+ output,
205
+ reasoning: 0,
206
+ breakdown: { input, output },
207
+ };
208
+ };
209
+
210
+ /**
211
+ * Generic fallback for unknown models
212
+ */
213
+ const parseGeneric = (usage: RawUsage) => {
214
+ const input = usage.input_tokens || usage.inputTokens || usage.prompt_tokens || 0;
215
+ const output = usage.output_tokens || usage.outputTokens || usage.completion_tokens || 0;
216
+ // CACHED IS A SUBSET OF INPUT, as OpenAI reports it, and every OpenAI-compatible vendor (Z.AI
217
+ // GLM) copies that shape. Dropping it priced every cached token at the fresh rate: a GLM Agent's
218
+ // turn cost about three times what Z.AI bills.
219
+ const cached = usage.prompt_tokens_details?.cached_tokens || usage.input_tokens_details?.cached_tokens || 0;
220
+
221
+ return {
222
+ total: usage.total_tokens || input + output,
223
+ input,
224
+ output,
225
+ reasoning: 0,
226
+ cached,
227
+ breakdown: { input, cached, output },
228
+ };
229
+ };
230
+
231
+ /**
232
+ * Calculate cost based on model and parsed usage
233
+ * @param {string} model - The model name
234
+ * @param {object} parsedUsage - Parsed usage object
235
+ * @returns {string} Cost in USD (formatted to 4 decimal places)
236
+ */
237
+ export const calculateTokenCost = (model: string, parsedUsage: any): string => {
238
+ if (!parsedUsage) return "0.0000";
239
+
240
+ const pricing = getModelPricing(model);
241
+ if (!pricing) return "0.0000";
242
+
243
+ // PER MINUTE, for vendors that bill a voice call on its length rather than its tokens.
244
+ if (pricing.perMinute && parsedUsage.seconds) {
245
+ return ((parsedUsage.seconds / 60) * pricing.perMinute).toFixed(4);
246
+ }
247
+
248
+ const per = (tokens: number | undefined, rate: number | undefined) => ((tokens || 0) / 1000000) * (rate || 0);
249
+
250
+ /**
251
+ * PER-MODALITY, when the model prices that way AND the usage block carried the split.
252
+ *
253
+ * Realtime bills audio at up to 8x text ($32 against $4 per 1M in), so a voice call priced on
254
+ * flat input/output is not approximately right, it is wrong by most of the bill. Both
255
+ * conditions are required: a model with no audio rates, or a usage block with no split, falls
256
+ * through to the flat path below and behaves exactly as it always did.
257
+ */
258
+ if (pricing.audioInput && parsedUsage.modality) {
259
+ const m = parsedUsage.modality;
260
+ return (
261
+ per(m.textIn, pricing.input) +
262
+ per(m.cachedTextIn, pricing.cachedInput ?? pricing.input) +
263
+ per(m.audioIn, pricing.audioInput) +
264
+ per(m.cachedAudioIn, pricing.cachedAudioInput ?? pricing.audioInput) +
265
+ per(m.imageIn, pricing.imageInput ?? pricing.input) +
266
+ per(m.cachedImageIn, pricing.cachedImageInput ?? pricing.imageInput ?? pricing.input) +
267
+ per(m.textOut, pricing.output) +
268
+ per(m.audioOut, pricing.audioOutput ?? pricing.output)
269
+ ).toFixed(4);
270
+ }
271
+
272
+ // Cached input and cache writes are parts of input, each billed at its own rate where the vendor
273
+ // has one; the rest is fresh.
274
+ const cached = Math.min(parsedUsage.cached || 0, parsedUsage.input || 0);
275
+ const written = Math.min(parsedUsage.cacheWrite || 0, (parsedUsage.input || 0) - cached);
276
+ const inputCost =
277
+ per((parsedUsage.input || 0) - cached - written, pricing.input) +
278
+ per(cached, pricing.cachedInput ?? pricing.input) +
279
+ per(written, pricing.cacheWrite ?? pricing.input);
280
+ const outputCost = per(parsedUsage.output, pricing.output);
281
+
282
+ return (inputCost + outputCost).toFixed(4);
283
+ };
284
+
285
+ /**
286
+ * Get pricing for a model (per million tokens)
287
+ */
288
+ export const getModelPricing = (model: string): ModelPricing => {
289
+ const pricing: Record<string, ModelPricing> = {
290
+ // GPT-5.6 family (Sol/Terra/Luna): standard, short context (up to 272K input), verified against
291
+ // developers.openai.com/api/docs/pricing 2026-10-11. Cached input and cache writes have their own
292
+ // rates; without them every cached token was billed fresh.
293
+ "gpt-5.6": { input: 4.0, cachedInput: 0.4, cacheWrite: 5.0, output: 20.0 }, // alias of gpt-5.6-sol
294
+ "gpt-5.6-sol": { input: 4.0, cachedInput: 0.4, cacheWrite: 5.0, output: 20.0 },
295
+ "gpt-5.6-terra": { input: 2.0, cachedInput: 0.2, cacheWrite: 2.5, output: 12.0 },
296
+ "gpt-5.6-luna": { input: 0.2, cachedInput: 0.02, cacheWrite: 0.25, output: 1.2 },
297
+
298
+ // Z.AI GLM — verified against docs.z.ai/guides/overview/pricing 2026-10-09
299
+ "glm-5.3": { input: 1.4, cachedInput: 0.26, output: 4.4 },
300
+ "glm-5.3-flash": { input: 0.15, cachedInput: 0.03, output: 0.5 },
301
+ "glm-5.2": { input: 1.4, cachedInput: 0.26, output: 4.4 },
302
+
303
+ /**
304
+ * GPT-Realtime family — PRICED PER MODALITY, verified against the OpenAI model pages
305
+ * 2026-08-12 (developers.openai.com/api/docs/models/gpt-realtime-2.1{,-mini}).
306
+ *
307
+ * `input`/`output` here are the TEXT rates, and pricing a voice call at them — which is
308
+ * what this table did — understates it by up to 8x, because a call is almost entirely
309
+ * AUDIO tokens: $32/$64 against $4/$24 on the pro model. The label said "(text tokens)",
310
+ * so the limitation was known; nothing downstream acted on it.
311
+ *
312
+ * `calculateTokenCost` uses these per-modality fields when the usage block carries the
313
+ * split, and falls back to flat input/output when it does not. Every other model in this
314
+ * table is unaffected.
315
+ */
316
+ "gpt-realtime-2.1": {
317
+ input: 4.0, cachedInput: 0.4, output: 24.0,
318
+ audioInput: 32.0, cachedAudioInput: 0.4, audioOutput: 64.0,
319
+ imageInput: 5.0, cachedImageInput: 0.5,
320
+ },
321
+ "gpt-realtime-2.1-mini": {
322
+ input: 0.6, cachedInput: 0.06, output: 2.4,
323
+ audioInput: 10.0, cachedAudioInput: 0.3, audioOutput: 20.0,
324
+ imageInput: 0.8, cachedImageInput: 0.08,
325
+ },
326
+
327
+ /**
328
+ * xAI GROK VOICE — the vendor bills per MINUTE, not per token: $0.08/min audio plus
329
+ * $0.004 per text input (docs.x.ai/developers/pricing, 2026-08-15). The realtime
330
+ * `response.done.usage` carries only flat input/output/total token counts — no
331
+ * audio/text split, no duration — so a per-minute price cannot be computed from what
332
+ * this table is given.
333
+ *
334
+ * The platform now times every call and records `seconds`, so a call is priced at the
335
+ * vendor's $0.08/min. The per-token rates below are an ESTIMATE kept only for rows written
336
+ * before calls were timed. The $0.004 text-input charge is not included; it is small beside
337
+ * the minutes.
338
+ */
339
+ "grok-voice-think-fast-2.0": { perMinute: 0.08, input: 4.0, output: 8.0 },
340
+ "grok-voice-latest": { perMinute: 0.08, input: 4.0, output: 8.0 },
341
+ "grok-voice-think-fast-1.0": { perMinute: 0.08, input: 4.0, output: 8.0 },
342
+
343
+ /**
344
+ * OpenAI GPT-LIVE — billed per second at $0.05/min (developers.openai.com/api/docs/models/
345
+ * gpt-live-1). Priced on the `seconds` the platform times; it reports no tokens of its own.
346
+ */
347
+ "gpt-live-1": { perMinute: 0.05 },
348
+
349
+ // GPT-5.x family (older, kept for historical executions)
350
+ "gpt-5.2": { input: 1.75, output: 14.0 },
351
+ "gpt-5": { input: 1.25, output: 10.0 },
352
+ "gpt-5-mini": { input: 0.25, output: 2.0 },
353
+ "gpt-5-nano": { input: 0.05, output: 0.4 },
354
+ "gpt-5-pro": { input: 15.0, output: 120.0 },
355
+ "gpt-5-chat-latest": { input: 1.25, output: 10.0 },
356
+ "gpt-5-codex": { input: 1.25, output: 10.0 },
357
+
358
+ // GPT-4.1 family
359
+ "gpt-4.1": { input: 2.0, output: 8.0 },
360
+ "gpt-4.1-mini": { input: 0.4, output: 1.6 },
361
+
362
+ // GPT-4o family
363
+ "gpt-4o": { input: 2.5, output: 10.0 },
364
+ "gpt-4o-mini": { input: 0.15, output: 0.6 },
365
+
366
+ // GPT-3.5 family
367
+ "gpt-3.5-turbo": { input: 0.5, output: 1.5 },
368
+ "gpt-3.5-turbo-16k": { input: 1.0, output: 2.0 },
369
+
370
+ // Claude models
371
+ "claude-3-opus": { input: 15.0, output: 75.0 },
372
+ "claude-3-sonnet": { input: 3.0, output: 15.0 },
373
+ "claude-3-haiku": { input: 0.25, output: 1.25 },
374
+
375
+ // Bedrock Claude 4.5 (latest)
376
+ "us.anthropic.claude-opus-4-5-20250514-v1:0": { input: 5.0, output: 25.0 },
377
+ "us.anthropic.claude-sonnet-4-5-20250514-v1:0": { input: 3.0, output: 15.0 },
378
+ "us.anthropic.claude-haiku-4-5-20251001-v1:0": { input: 1.0, output: 5.0 },
379
+
380
+ // Bedrock Claude 4
381
+ "us.anthropic.claude-sonnet-4-20250514-v1:0": { input: 3.0, output: 15.0 },
382
+
383
+ // Bedrock Claude 3.5
384
+ "us.anthropic.claude-3-5-sonnet-20241022-v2:0": { input: 3.0, output: 15.0 },
385
+ "us.anthropic.claude-3-5-haiku-20241022-v1:0": { input: 0.25, output: 1.25 },
386
+
387
+ // Bedrock Claude 3
388
+ "anthropic.claude-3-sonnet-20240229-v1:0": { input: 3.0, output: 15.0 },
389
+ "anthropic.claude-3-haiku-20240307-v1:0": { input: 0.25, output: 1.25 },
390
+
391
+ // AWS Nova
392
+ "amazon.nova-sonic-v1:0": { input: 3.4, output: 13.6 },
393
+ "amazon.nova-2-sonic-v1:0": { input: 3.4, output: 13.6 },
394
+
395
+ // Embedding models
396
+ "text-embedding-ada-002": { input: 0.1, output: 0 },
397
+ "text-embedding-3-small": { input: 0.02, output: 0 },
398
+ "text-embedding-3-large": { input: 0.13, output: 0 },
399
+ };
400
+
401
+ // Try exact match first
402
+ if (pricing[model]) return pricing[model];
403
+
404
+ // Try partial match for Bedrock models
405
+ for (const [key, value] of Object.entries(pricing)) {
406
+ if (model.includes(key) || key.includes(model)) {
407
+ return value;
408
+ }
409
+ }
410
+
411
+ // Default fallback - use Claude Sonnet pricing as reasonable default
412
+ console.warn(`Unknown model pricing: ${model}, using default $3/$15 per 1M tokens`);
413
+ return { input: 3.0, output: 15.0 };
414
+ };
@@ -53,7 +53,15 @@ export const PERMISSIONS = [
53
53
  * what widens it. A route table would be the wrong shape for that, which is why memory
54
54
  * shares this vocabulary and not the policy table.
55
55
  */
56
- "memory:read-all",
56
+ "signal:read-all",
57
+
58
+ /**
59
+ * Add a person to Signal, or add to one already held, WITHOUT reading anyone
60
+ * (docs/architecture/signal/AGENTS.md § 7a). Apart from `signal:read-all` so a caller that finds
61
+ * leads, an Agent above all, never needs to read every contact and user to save one. The Signal
62
+ * Save node answers only whether it was saved, never the record.
63
+ */
64
+ "signal:write",
57
65
  ] as const;
58
66
 
59
67
  export type Permission = (typeof PERMISSIONS)[number];
@@ -68,7 +76,7 @@ const AUTHOR_GRANTS: Permission[] = ["workflow:author", "marketplace:publish"];
68
76
 
69
77
  export const ROLE_GRANTS: Record<string, readonly Permission[]> = {
70
78
  author: AUTHOR_GRANTS,
71
- admin: [...AUTHOR_GRANTS, "credentials:manage", "memory:read-all"],
79
+ admin: [...AUTHOR_GRANTS, "credentials:manage", "signal:read-all", "signal:write"],
72
80
  };
73
81
 
74
82
  /**
@@ -65,6 +65,7 @@ export function mountAppEngine(el: HTMLElement, first: AppConfig): AppEngine {
65
65
  let voiceKey = "";
66
66
  let app: AppMount | null = null;
67
67
  let page: { destroy(): void } | null = null;
68
+ let pageFor: unknown[] = [];
68
69
  let lastWidth: string | undefined;
69
70
  let stopStore = () => {};
70
71
  let stopVoice = () => {};
@@ -126,9 +127,36 @@ export function mountAppEngine(el: HTMLElement, first: AppConfig): AppEngine {
126
127
  : inertVoice();
127
128
  stopVoice = voice.subscribe(syncConnection);
128
129
  app?.update({ voice });
130
+ voiceListeners.forEach((l) => l());
129
131
  mountPage();
130
132
  };
131
133
 
134
+ // THE PAGE'S VOICE STAYS ONE OBJECT. The real service is swapped underneath when the app's
135
+ // settings arrive, so the page's templates (and the task each already asked for) are kept.
136
+ const voiceListeners = new Set<() => void>();
137
+ const pageVoice: VoiceService = {
138
+ get state() { return voice.state; },
139
+ subscribe(listener) {
140
+ let stop = voice.subscribe(listener);
141
+ let bound = voice;
142
+ const rebind = () => {
143
+ if (bound === voice) return;
144
+ stop();
145
+ bound = voice;
146
+ stop = voice.subscribe(listener);
147
+ listener();
148
+ };
149
+ voiceListeners.add(rebind);
150
+ return () => { voiceListeners.delete(rebind); stop(); };
151
+ },
152
+ startCall: (start) => voice.startCall(start),
153
+ joinCall: (workflowId) => voice.joinCall(workflowId),
154
+ unlock: () => voice.unlock(),
155
+ endCall: () => voice.endCall(),
156
+ toggleMute: () => voice.toggleMute(),
157
+ destroy: () => {},
158
+ };
159
+
132
160
  /**
133
161
  * THE CONNECTION, open once the user is known AND there is something to hear from: the app's
134
162
  * own workflow, a call, or a task the page is in. Nothing is held open while a visitor reads.
@@ -172,6 +200,7 @@ export function mountAppEngine(el: HTMLElement, first: AppConfig): AppEngine {
172
200
  lastWidth = undefined;
173
201
  syncConnection();
174
202
  syncVoice();
203
+ mountPage();
175
204
  const orgFromId = appId.includes("/") ? appId.split("/")[0] : null;
176
205
  if (orgFromId) loadTheme(`${orgFromId}/light`, token);
177
206
  client
@@ -224,6 +253,14 @@ export function mountAppEngine(el: HTMLElement, first: AppConfig): AppEngine {
224
253
  });
225
254
  };
226
255
 
256
+ // THE TASK A PAGE TEMPLATE CARRIES (docs/design/lifecycle-hooks.md): the server runs the YAML in
257
+ // the template's folder as this viewer and answers with the template's fields.
258
+ const runTask = async (uri: string, phase: "onStart", input?: Record<string, unknown>): Promise<Record<string, unknown>> => {
259
+ const res = (await client.callTool("template_task", { uri, phase, ...(input ? { input } : {}) })) as { content?: Array<{ text?: string }> };
260
+ const text = res?.content?.[0]?.text;
261
+ return text ? (JSON.parse(text) as Record<string, unknown>) : {};
262
+ };
263
+
227
264
  // A PAGE TEMPLATE'S TASK, called at its own workflow's trigger with the user's token.
228
265
  // `metadata.task` names the task its Task Runner loads; the submit carries the answers.
229
266
  const startTask = async (b: Binding, task: string, submit?: Record<string, unknown>) => {
@@ -322,9 +359,13 @@ export function mountAppEngine(el: HTMLElement, first: AppConfig): AppEngine {
322
359
  el.append(...app.nodes);
323
360
  };
324
361
  // The page's own templates, drawn into its elements by this one runtime.
362
+ /** Drawn again only when what the page reads from changes: the org, the client, the store. */
325
363
  const mountPage = () => {
364
+ const wanted = [appId.split("/")[0], client, store];
365
+ if (page && wanted.every((v, i) => v === pageFor[i])) return;
366
+ pageFor = wanted;
326
367
  page?.destroy();
327
- page = mountPageInterfaces({ org: appId.split("/")[0], client, voice, appStore: store, toServer, startTask });
368
+ page = mountPageInterfaces({ org: appId.split("/")[0], client, voice: pageVoice, appStore: store, toServer, startTask, runTask });
328
369
  };
329
370
 
330
371
  const watchStore = () => {
@@ -22,6 +22,8 @@ export interface ActionContext {
22
22
  /** Where APP state lives, when it is not `store`: a template a website page draws keeps its
23
23
  * own slice in its own store and shares the app's state with everything on the page. */
24
24
  appStore?: ComponentStore;
25
+ /** Print what is on screen (host-provided; the `print` action). Resolves when it is done. */
26
+ print?: () => void | Promise<void>;
25
27
  }
26
28
 
27
29
  /** Resolve a value: a `"{{path}}"` template reads from `scope` (dot-path); anything
@@ -103,6 +105,14 @@ export function dispatchAction(
103
105
  if (ctx.nodeId) ctx.store.apply({ type: "COMPONENT_DATA", chatId: ctx.chatId, nodeId: ctx.nodeId, data: { [field]: scope.text } });
104
106
  else (ctx.appStore ?? ctx.store).mergeAppState({ [field]: scope.text });
105
107
  }
108
+ } else if (spec.type === "print") {
109
+ // PRINT asks the host to print what is on screen (`ctx.print`; the web host waits for a still
110
+ // screen first, dom/element.ts printWhenStill). `then` runs once the print is done. A host
111
+ // that cannot print does nothing.
112
+ if (!ctx.print) return;
113
+ const then = spec.then;
114
+ void Promise.resolve(ctx.print()).then(() => { if (then) dispatchAction(then, scope, ctx); });
115
+ return;
106
116
  } else {
107
117
  // SERVER — narrow the payload by `send` if given, else forward the whole scope.
108
118
  const payload = spec.send ? Object.fromEntries(spec.send.map((k) => [k, scope[k]])) : scope;
@@ -57,7 +57,7 @@ export const cssWidth = (w: unknown): string | undefined => (typeof w === "strin
57
57
  *
58
58
  * ONE function for every caller, because the slot frame once had its own copy without the
59
59
  * `flex` word: beside a full-width app it rendered the task surface in a zero-width frame
60
- * the moment the org's `flex` token went (SAB's comparison, blank, 2026-09-07).
60
+ * the moment the org's `flex` token went (a bank's comparison, blank, 2026-09-07).
61
61
  */
62
62
  export function placeWidthStyle(declared: unknown, appSize?: Record<string, string>): Record<string, unknown> | null {
63
63
  const width = cssWidth(typeof declared === "string" ? (appSize?.[declared] ?? declared) : undefined);
@@ -377,7 +377,7 @@ export function selectPointers(store: ComponentStore, node: UnoverseNode, scope?
377
377
  // THE MOST RECENT WINS (the `limit: 1` law) for EVERY capped select, not only a state
378
378
  // select. A slot pinned to a component by `type` with `limit: 1` kept the OLDEST card:
379
379
  // the sort lived inside the `where` branch, so a name-pinned cap sliced arrival order
380
- // (SAB voice, two Account Agent cards, 2026-09-10). A cap means "the newest N"; an
380
+ // (a bank's voice app, two Account Agent cards, 2026-09-10). A cap means "the newest N"; an
381
381
  // uncapped flow keeps arrival order.
382
382
  if (sel.where || sel.limit != null) pointers = [...pointers].sort((a, b) => store.touchOf(b) - store.touchOf(a));
383
383
  if (sel.limit != null) pointers = pointers.slice(0, sel.limit);
@@ -129,6 +129,17 @@ export class UnoverseClient {
129
129
  return this.withSession(() => this.client.callTool({ name, arguments: args }));
130
130
  }
131
131
 
132
+ /**
133
+ * THE TASK A COMPONENT CARRIES (docs/design/lifecycle-hooks.md), run as this viewer: the server
134
+ * runs the hook in the component's own folder with the instance's fields as `props` and answers
135
+ * with the fields it filled. A component with no hook for the phase answers `{}`.
136
+ */
137
+ async runTask(uri: string, phase: "onStart" | "onEnterView", props?: Record<string, unknown>, state?: string): Promise<Record<string, unknown>> {
138
+ const res = (await this.callTool("template_task", { uri, phase, ...(props ? { props } : {}), ...(state ? { state } : {}) })) as { content?: Array<{ text?: string }> };
139
+ const text = res?.content?.[0]?.text;
140
+ return text ? (JSON.parse(text) as Record<string, unknown>) : {};
141
+ }
142
+
132
143
 
133
144
  /** resources/read a definition by URI (e.g. unoverse://components/card). Cached. A hash on
134
145
  * the address names the state it opens in (`stateOfUri`) and is never sent to the server. */
@@ -493,7 +493,7 @@ export class ComponentStore {
493
493
  // recomputed order on EVERY update, which is stable only while one thing is updating.
494
494
  // A held Agent task has two: the task's face ticking through its steps, and the
495
495
  // reply resuming once the result lands. They alternate, so the bottom slot alternated
496
- // with them and the pair visibly swapped places on screen (SAB Account Agent,
496
+ // with them and the pair visibly swapped places on screen (a bank's account agent,
497
497
  // 2026-09-08). The case it was written for is served by arrival order too: text
498
498
  // placed before a form stays above it.
499
499
  this.merge(key, this.shieldGuestFace(key, event.data));
@@ -42,6 +42,8 @@ export interface UnoverseNode {
42
42
  * content grows (new turn or streaming text), unless the user has scrolled up. Generic
43
43
  * scroll-container behavior, opt-in via data (e.g. the conversation state's scroll box). */
44
44
  autoScroll?: boolean;
45
+ /** Drag to move around this box's overflow, keeping the centre when its content changes size. */
46
+ pan?: boolean;
45
47
  /** Box: a sticky bar (its own `position: sticky` + `top` in the design definition) that stays HIDDEN while
46
48
  * the element before it (its previous sibling — e.g. a hero image) is on screen, then
47
49
  * fades in once that sibling scrolls away. Overlays the content (no flow gap). Generic
@@ -29,7 +29,7 @@ import { guestHeldPointers } from "../transition/guestHold";
29
29
  import { keyframesCss, styleToCss, type CssStyle } from "../style";
30
30
  import type { ActionHandler } from "../actions";
31
31
  import { mountComponent, type ComponentMount, type DefinitionSource } from "./component";
32
- import { setChildren, setStyle } from "./element";
32
+ import { printWhenStill, setChildren, setStyle } from "./element";
33
33
  import { isolatedRoot } from "./isolate";
34
34
  import { mountQueue } from "./mounted";
35
35
  import { regionView, type Leaf, type Region, type SlotCompute } from "./region";
@@ -273,6 +273,7 @@ export function mountApp(options: AppOptions): AppMount {
273
273
  chatId: "",
274
274
  nodeId: "",
275
275
  sendToServer: o.onAction ? (t, payload) => o.onAction!(t, payload, meta) : undefined,
276
+ print: printWhenStill,
276
277
  });
277
278
  };
278
279
 
@@ -298,7 +299,7 @@ export function mountApp(options: AppOptions): AppMount {
298
299
 
299
300
  const leaf = (pointer: string, extraData?: Data, fill?: boolean) => {
300
301
  const { chatId, nodeId } = store.split(pointer);
301
- const opts = { client: o.client, store, chatId, nodeId, onAction: dispatch, theme, extraData, fill, doc };
302
+ const opts = { client: o.client, store, chatId, nodeId, onAction: dispatch, theme, extraData, fill, doc, appStore: store };
302
303
  // Keyed by the INSTANCE: one re-delivered later keeps its elements (no flicker).
303
304
  return { key: nodeId, make: (): Leaf => mountComponent(opts), refresh: (l: Leaf) => (l as ComponentMount).update(opts) };
304
305
  };