@unoverse-platform/studio 0.1.115 → 0.1.116

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 (37) hide show
  1. package/local/catalog.ts +3 -0
  2. package/local/plugin.ts +12 -9
  3. package/package.json +1 -1
  4. package/screens/AgentChain.tsx +216 -0
  5. package/screens/AgentsView.tsx +45 -6
  6. package/screens/SkillsView.tsx +21 -3
  7. package/screens/index.ts +3 -0
  8. package/vendor/base/src/agent-mcp/discovery.ts +4 -1
  9. package/vendor/base/src/agent-mcp/invoke.ts +155 -25
  10. package/vendor/base/src/agent-mcp/tools.ts +17 -10
  11. package/vendor/base/src/agent-mcp/types.ts +8 -0
  12. package/vendor/base/src/auth/agentSwitch.ts +52 -0
  13. package/vendor/base/src/auth/agentToken.ts +142 -0
  14. package/vendor/base/src/auth/auth.ts +7 -0
  15. package/vendor/base/src/definitions/load/load.ts +6 -1
  16. package/vendor/base/src/definitions/registry/agentFolders.ts +113 -0
  17. package/vendor/base/src/definitions/registry/objects.ts +16 -0
  18. package/vendor/base/src/definitions/registry/pipelines.ts +13 -5
  19. package/vendor/base/src/definitions/registry/taskStarts.ts +76 -0
  20. package/vendor/base/src/definitions/registry/taskUri.ts +67 -0
  21. package/vendor/base/src/lint/design/index.mjs +10 -2
  22. package/vendor/base/src/lint/objects/index.d.mts +1 -0
  23. package/vendor/base/src/lint/objects/index.mjs +120 -2
  24. package/vendor/base/src/lint/objects/rules.mjs +16 -5
  25. package/vendor/base/src/manifests/executor/toolBridge.ts +10 -3
  26. package/vendor/base/src/manifests/marketplaceMcp.ts +20 -0
  27. package/vendor/base/src/manifests/runtime/http/request.ts +4 -1
  28. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +58 -23
  29. package/vendor/base/src/manifests/runtime/usage.ts +13 -5
  30. package/vendor/base/src/platform/agentBudget.ts +65 -0
  31. package/vendor/base/src/platform/agentCalendar.ts +87 -0
  32. package/vendor/base/src/platform/taskChanged.ts +25 -0
  33. package/vendor/base/src/platform/tokenCost.ts +395 -0
  34. package/vendor/base/src/security/permissions.ts +10 -2
  35. package/vendor/sdk/appEngine.ts +42 -1
  36. package/vendor/sdk/lib/dom/markdown.ts +6 -2
  37. package/vendor/sdk/pageInterfaces.ts +109 -8
@@ -0,0 +1,395 @@
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; 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
+ const reasoningTokens = outDetails.reasoning_tokens || 0;
73
+ // AUDIO IS PRICED DIFFERENTLY from text, so voice cost cannot be read off a total alone.
74
+ const audioIn = inDetails.audio_tokens || 0;
75
+ const audioOut = outDetails.audio_tokens || 0;
76
+ const imageIn = inDetails.image_tokens || 0;
77
+
78
+ /**
79
+ * HOW THE CACHED TOKENS SPLIT ACROSS MODALITIES, which is what makes them priceable.
80
+ *
81
+ * A cached token costs $0.40 against $32 for fresh audio or $4 for fresh text, so getting
82
+ * this wrong is most of the bill. `cached_tokens` alone does not say which modality it came
83
+ * from, and `cached_tokens_details` — which does — is absent from every row written before
84
+ * the collector stopped dropping it, and from any vendor that never sends it.
85
+ *
86
+ * So: use the details when present, and otherwise APPORTION BY SHARE. Proportional is a
87
+ * genuine estimate rather than a fact, but it is far closer than the alternative of billing
88
+ * every cached token at the full fresh rate, which overcharged a real call by ~45%.
89
+ */
90
+ const cachedTotal = inDetails.cached_tokens || 0;
91
+ const cachedDetails = inDetails.cached_tokens_details;
92
+ const freshTotal = (inDetails.text_tokens || 0) + audioIn + imageIn;
93
+ const share = (n: number) => (freshTotal > 0 ? Math.round((cachedTotal * n) / freshTotal) : 0);
94
+ const cachedTextIn = cachedDetails ? cachedDetails.text_tokens || 0 : share(inDetails.text_tokens || 0);
95
+ const cachedAudioIn = cachedDetails ? cachedDetails.audio_tokens || 0 : share(audioIn);
96
+ const cachedImageIn = cachedDetails ? cachedDetails.image_tokens || 0 : share(imageIn);
97
+
98
+ return {
99
+ total: usage.total_tokens || (usage.input_tokens || 0) + (usage.output_tokens || 0),
100
+ input: usage.input_tokens || 0,
101
+ output: usage.output_tokens || 0,
102
+ reasoning: reasoningTokens,
103
+ cached: cachedTokens,
104
+ breakdown: {
105
+ input: usage.input_tokens || 0,
106
+ cached: cachedTokens,
107
+ reasoning: reasoningTokens,
108
+ completion: (usage.output_tokens || 0) - reasoningTokens,
109
+ // Present only on a voice call; omitted rather than shown as 0 for text models, so a
110
+ // zero in the panel always means "no audio" rather than "not measured".
111
+ ...(audioIn || audioOut ? { audioIn, audioOut } : {}),
112
+ },
113
+ /**
114
+ * THE PER-MODALITY SPLIT, for pricing. Only present when the vendor sent one.
115
+ *
116
+ * CACHED IS A SUBSET, not a fourth category: `cached_tokens_details` breaks the cached
117
+ * count down by modality, so a cached audio token is already counted in `audio_tokens`.
118
+ * Subtracting it is what stops it being billed twice, once at $32 and again at $0.40.
119
+ */
120
+ ...(audioIn || audioOut || inDetails.text_tokens !== undefined
121
+ ? {
122
+ modality: {
123
+ textIn: Math.max(0, (inDetails.text_tokens || 0) - cachedTextIn),
124
+ cachedTextIn,
125
+ audioIn: Math.max(0, audioIn - cachedAudioIn),
126
+ cachedAudioIn,
127
+ imageIn: Math.max(0, imageIn - cachedImageIn),
128
+ cachedImageIn,
129
+ textOut: outDetails.text_tokens || 0,
130
+ audioOut,
131
+ },
132
+ }
133
+ : {}),
134
+ };
135
+ }
136
+
137
+ // Chat Completions API format (GPT-4, GPT-3.5)
138
+ const cachedTokens = usage.prompt_tokens_details?.cached_tokens || 0;
139
+ const input = usage.prompt_tokens || usage.promptTokens || 0;
140
+ const output = usage.completion_tokens || usage.completionTokens || 0;
141
+
142
+ return {
143
+ total: usage.total_tokens || usage.totalTokens || input + output,
144
+ input,
145
+ output,
146
+ reasoning: usage.reasoning_tokens || 0,
147
+ cached: cachedTokens,
148
+ breakdown: {
149
+ prompt: input,
150
+ cached: cachedTokens,
151
+ reasoning: usage.reasoning_tokens || 0,
152
+ completion: output,
153
+ },
154
+ };
155
+ };
156
+
157
+ /**
158
+ * Claude Models (Anthropic)
159
+ */
160
+ const parseClaude = (usage: RawUsage) => {
161
+ return {
162
+ total: (usage.input_tokens || 0) + (usage.output_tokens || 0),
163
+ input: usage.input_tokens || 0,
164
+ output: usage.output_tokens || 0,
165
+ reasoning: 0,
166
+ breakdown: {
167
+ input: usage.input_tokens || 0,
168
+ output: usage.output_tokens || 0,
169
+ },
170
+ };
171
+ };
172
+
173
+ /**
174
+ * Bedrock Models (AWS)
175
+ */
176
+ const parseBedrock = (usage: RawUsage) => {
177
+ return {
178
+ total: (usage.inputTokens || 0) + (usage.outputTokens || 0),
179
+ input: usage.inputTokens || 0,
180
+ output: usage.outputTokens || 0,
181
+ reasoning: 0,
182
+ breakdown: {
183
+ input: usage.inputTokens || 0,
184
+ output: usage.outputTokens || 0,
185
+ },
186
+ };
187
+ };
188
+
189
+ /**
190
+ * Bedrock Claude Models
191
+ * Handles both native Bedrock format (inputTokens) and OpenAI-style format (prompt_tokens)
192
+ */
193
+ const parseBedrockClaude = (usage: RawUsage) => {
194
+ const input = usage.input_tokens || usage.inputTokens || usage.prompt_tokens || 0;
195
+ const output = usage.output_tokens || usage.outputTokens || usage.completion_tokens || 0;
196
+ return {
197
+ total: usage.total_tokens || usage.totalTokens || input + output,
198
+ input,
199
+ output,
200
+ reasoning: 0,
201
+ breakdown: { input, output },
202
+ };
203
+ };
204
+
205
+ /**
206
+ * Generic fallback for unknown models
207
+ */
208
+ const parseGeneric = (usage: RawUsage) => {
209
+ const input = usage.input_tokens || usage.inputTokens || usage.prompt_tokens || 0;
210
+ const output = usage.output_tokens || usage.outputTokens || usage.completion_tokens || 0;
211
+
212
+ return {
213
+ total: usage.total_tokens || input + output,
214
+ input,
215
+ output,
216
+ reasoning: 0,
217
+ breakdown: { input, output },
218
+ };
219
+ };
220
+
221
+ /**
222
+ * Calculate cost based on model and parsed usage
223
+ * @param {string} model - The model name
224
+ * @param {object} parsedUsage - Parsed usage object
225
+ * @returns {string} Cost in USD (formatted to 4 decimal places)
226
+ */
227
+ export const calculateTokenCost = (model: string, parsedUsage: any): string => {
228
+ if (!parsedUsage) return "0.0000";
229
+
230
+ const pricing = getModelPricing(model);
231
+ if (!pricing) return "0.0000";
232
+
233
+ // PER MINUTE, for vendors that bill a voice call on its length rather than its tokens.
234
+ if (pricing.perMinute && parsedUsage.seconds) {
235
+ return ((parsedUsage.seconds / 60) * pricing.perMinute).toFixed(4);
236
+ }
237
+
238
+ const per = (tokens: number | undefined, rate: number | undefined) => ((tokens || 0) / 1000000) * (rate || 0);
239
+
240
+ /**
241
+ * PER-MODALITY, when the model prices that way AND the usage block carried the split.
242
+ *
243
+ * Realtime bills audio at up to 8x text ($32 against $4 per 1M in), so a voice call priced on
244
+ * flat input/output is not approximately right, it is wrong by most of the bill. Both
245
+ * conditions are required: a model with no audio rates, or a usage block with no split, falls
246
+ * through to the flat path below and behaves exactly as it always did.
247
+ */
248
+ if (pricing.audioInput && parsedUsage.modality) {
249
+ const m = parsedUsage.modality;
250
+ return (
251
+ per(m.textIn, pricing.input) +
252
+ per(m.cachedTextIn, pricing.cachedInput ?? pricing.input) +
253
+ per(m.audioIn, pricing.audioInput) +
254
+ per(m.cachedAudioIn, pricing.cachedAudioInput ?? pricing.audioInput) +
255
+ per(m.imageIn, pricing.imageInput ?? pricing.input) +
256
+ per(m.cachedImageIn, pricing.cachedImageInput ?? pricing.imageInput ?? pricing.input) +
257
+ per(m.textOut, pricing.output) +
258
+ per(m.audioOut, pricing.audioOutput ?? pricing.output)
259
+ ).toFixed(4);
260
+ }
261
+
262
+ const inputCost = per(parsedUsage.input, pricing.input);
263
+ const outputCost = per(parsedUsage.output, pricing.output);
264
+
265
+ return (inputCost + outputCost).toFixed(4);
266
+ };
267
+
268
+ /**
269
+ * Get pricing for a model (per million tokens)
270
+ */
271
+ export const getModelPricing = (model: string): ModelPricing => {
272
+ const pricing: Record<string, ModelPricing> = {
273
+ // GPT-5.6 family (Sol/Terra/Luna) — verified against the OpenAI pricing page 2026-08-07
274
+ "gpt-5.6": { input: 5.0, output: 30.0 }, // alias of gpt-5.6-sol
275
+ "gpt-5.6-sol": { input: 5.0, output: 30.0 },
276
+ "gpt-5.6-terra": { input: 2.0, output: 12.0 },
277
+ "gpt-5.6-luna": { input: 0.2, output: 1.2 },
278
+
279
+ // Z.AI GLM — verified against docs.z.ai/guides/overview/pricing 2026-10-09
280
+ "glm-5.3": { input: 1.4, cachedInput: 0.26, output: 4.4 },
281
+ "glm-5.3-flash": { input: 0.15, cachedInput: 0.03, output: 0.5 },
282
+ "glm-5.2": { input: 1.4, cachedInput: 0.26, output: 4.4 },
283
+
284
+ /**
285
+ * GPT-Realtime family — PRICED PER MODALITY, verified against the OpenAI model pages
286
+ * 2026-08-12 (developers.openai.com/api/docs/models/gpt-realtime-2.1{,-mini}).
287
+ *
288
+ * `input`/`output` here are the TEXT rates, and pricing a voice call at them — which is
289
+ * what this table did — understates it by up to 8x, because a call is almost entirely
290
+ * AUDIO tokens: $32/$64 against $4/$24 on the pro model. The label said "(text tokens)",
291
+ * so the limitation was known; nothing downstream acted on it.
292
+ *
293
+ * `calculateTokenCost` uses these per-modality fields when the usage block carries the
294
+ * split, and falls back to flat input/output when it does not. Every other model in this
295
+ * table is unaffected.
296
+ */
297
+ "gpt-realtime-2.1": {
298
+ input: 4.0, cachedInput: 0.4, output: 24.0,
299
+ audioInput: 32.0, cachedAudioInput: 0.4, audioOutput: 64.0,
300
+ imageInput: 5.0, cachedImageInput: 0.5,
301
+ },
302
+ "gpt-realtime-2.1-mini": {
303
+ input: 0.6, cachedInput: 0.06, output: 2.4,
304
+ audioInput: 10.0, cachedAudioInput: 0.3, audioOutput: 20.0,
305
+ imageInput: 0.8, cachedImageInput: 0.08,
306
+ },
307
+
308
+ /**
309
+ * xAI GROK VOICE — the vendor bills per MINUTE, not per token: $0.08/min audio plus
310
+ * $0.004 per text input (docs.x.ai/developers/pricing, 2026-08-15). The realtime
311
+ * `response.done.usage` carries only flat input/output/total token counts — no
312
+ * audio/text split, no duration — so a per-minute price cannot be computed from what
313
+ * this table is given.
314
+ *
315
+ * The platform now times every call and records `seconds`, so a call is priced at the
316
+ * vendor's $0.08/min. The per-token rates below are an ESTIMATE kept only for rows written
317
+ * before calls were timed. The $0.004 text-input charge is not included; it is small beside
318
+ * the minutes.
319
+ */
320
+ "grok-voice-think-fast-2.0": { perMinute: 0.08, input: 4.0, output: 8.0 },
321
+ "grok-voice-latest": { perMinute: 0.08, input: 4.0, output: 8.0 },
322
+ "grok-voice-think-fast-1.0": { perMinute: 0.08, input: 4.0, output: 8.0 },
323
+
324
+ /**
325
+ * OpenAI GPT-LIVE — billed per second at $0.05/min (developers.openai.com/api/docs/models/
326
+ * gpt-live-1). Priced on the `seconds` the platform times; it reports no tokens of its own.
327
+ */
328
+ "gpt-live-1": { perMinute: 0.05 },
329
+
330
+ // GPT-5.x family (older, kept for historical executions)
331
+ "gpt-5.2": { input: 1.75, output: 14.0 },
332
+ "gpt-5": { input: 1.25, output: 10.0 },
333
+ "gpt-5-mini": { input: 0.25, output: 2.0 },
334
+ "gpt-5-nano": { input: 0.05, output: 0.4 },
335
+ "gpt-5-pro": { input: 15.0, output: 120.0 },
336
+ "gpt-5-chat-latest": { input: 1.25, output: 10.0 },
337
+ "gpt-5-codex": { input: 1.25, output: 10.0 },
338
+
339
+ // GPT-4.1 family
340
+ "gpt-4.1": { input: 2.0, output: 8.0 },
341
+ "gpt-4.1-mini": { input: 0.4, output: 1.6 },
342
+
343
+ // GPT-4o family
344
+ "gpt-4o": { input: 2.5, output: 10.0 },
345
+ "gpt-4o-mini": { input: 0.15, output: 0.6 },
346
+
347
+ // GPT-3.5 family
348
+ "gpt-3.5-turbo": { input: 0.5, output: 1.5 },
349
+ "gpt-3.5-turbo-16k": { input: 1.0, output: 2.0 },
350
+
351
+ // Claude models
352
+ "claude-3-opus": { input: 15.0, output: 75.0 },
353
+ "claude-3-sonnet": { input: 3.0, output: 15.0 },
354
+ "claude-3-haiku": { input: 0.25, output: 1.25 },
355
+
356
+ // Bedrock Claude 4.5 (latest)
357
+ "us.anthropic.claude-opus-4-5-20250514-v1:0": { input: 5.0, output: 25.0 },
358
+ "us.anthropic.claude-sonnet-4-5-20250514-v1:0": { input: 3.0, output: 15.0 },
359
+ "us.anthropic.claude-haiku-4-5-20251001-v1:0": { input: 1.0, output: 5.0 },
360
+
361
+ // Bedrock Claude 4
362
+ "us.anthropic.claude-sonnet-4-20250514-v1:0": { input: 3.0, output: 15.0 },
363
+
364
+ // Bedrock Claude 3.5
365
+ "us.anthropic.claude-3-5-sonnet-20241022-v2:0": { input: 3.0, output: 15.0 },
366
+ "us.anthropic.claude-3-5-haiku-20241022-v1:0": { input: 0.25, output: 1.25 },
367
+
368
+ // Bedrock Claude 3
369
+ "anthropic.claude-3-sonnet-20240229-v1:0": { input: 3.0, output: 15.0 },
370
+ "anthropic.claude-3-haiku-20240307-v1:0": { input: 0.25, output: 1.25 },
371
+
372
+ // AWS Nova
373
+ "amazon.nova-sonic-v1:0": { input: 3.4, output: 13.6 },
374
+ "amazon.nova-2-sonic-v1:0": { input: 3.4, output: 13.6 },
375
+
376
+ // Embedding models
377
+ "text-embedding-ada-002": { input: 0.1, output: 0 },
378
+ "text-embedding-3-small": { input: 0.02, output: 0 },
379
+ "text-embedding-3-large": { input: 0.13, output: 0 },
380
+ };
381
+
382
+ // Try exact match first
383
+ if (pricing[model]) return pricing[model];
384
+
385
+ // Try partial match for Bedrock models
386
+ for (const [key, value] of Object.entries(pricing)) {
387
+ if (model.includes(key) || key.includes(model)) {
388
+ return value;
389
+ }
390
+ }
391
+
392
+ // Default fallback - use Claude Sonnet pricing as reasonable default
393
+ console.warn(`Unknown model pricing: ${model}, using default $3/$15 per 1M tokens`);
394
+ return { input: 3.0, output: 15.0 };
395
+ };
@@ -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 = () => {
@@ -143,10 +143,14 @@ function externalLink(env: Env): Rec {
143
143
  const { props, children: kids } = t as El;
144
144
  const theme = env.theme();
145
145
  const { s } = prose(theme);
146
- attrs = setProps(el, { ...props, target: "_blank", rel: "noopener noreferrer" }, attrs);
146
+ // A LINK TO THE SAME SITE (a path, `/signal/person/?id=…`) is the page's own next view: it
147
+ // opens in place, without the external glyph. Any other address opens in a new tab.
148
+ const href = typeof (props as { href?: unknown }).href === "string" ? String((props as { href?: unknown }).href) : "";
149
+ const ownSite = href.startsWith("/") && !href.startsWith("//");
150
+ attrs = setProps(el, ownSite ? { ...props } : { ...props, target: "_blank", rel: "noopener noreferrer" }, attrs);
147
151
  setStyle(el, env.inheritLink() ? { ...s("link"), color: "inherit" } : s("link"));
148
152
  const li = (theme.icons?.externalLink ?? {}) as Parameters<typeof svgOf>[1];
149
- if (!li.children?.length) arrow = null;
153
+ if (ownSite || !li.children?.length) arrow = null;
150
154
  else if (arrow?.glyph !== li) arrow = { glyph: li, svg: svgOf(env.doc, li, s("linkIcon")) };
151
155
  children(env, el, recs, kids);
152
156
  if (arrow) el.appendChild(arrow.svg);