@unoverse-platform/studio 0.1.97 → 0.1.98

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 (118) hide show
  1. package/bin/studio.mjs +2 -2
  2. package/local/catalog.ts +5 -1
  3. package/local/nodes.ts +28 -41
  4. package/local/plugin.ts +71 -13
  5. package/local/sample-identity/brand.yaml +24 -0
  6. package/local/sample-identity/organisation.yaml +14 -0
  7. package/local/sample-identity/purpose.yaml +14 -0
  8. package/local/sample-identity/story.yaml +18 -0
  9. package/local/scaffold.mjs +43 -23
  10. package/local/source.ts +3 -8
  11. package/package.json +1 -1
  12. package/screens/AgentsView.tsx +173 -0
  13. package/screens/ComponentsView.tsx +2 -2
  14. package/screens/EmptyLibrary.tsx +1 -1
  15. package/screens/IdentityView.tsx +3 -3
  16. package/screens/PromptBlocksView.tsx +1 -1
  17. package/screens/TemplatesLibraryView.tsx +91 -6
  18. package/screens/TemplatesView.tsx +24 -9
  19. package/screens/index.ts +2 -0
  20. package/src/App.tsx +8 -3
  21. package/src/host.ts +1 -14
  22. package/vendor/base/src/agent-mcp/discovery.ts +76 -18
  23. package/vendor/base/src/agent-mcp/handoff.ts +11 -31
  24. package/vendor/base/src/agent-mcp/index.ts +5 -5
  25. package/vendor/base/src/agent-mcp/invoke.ts +37 -19
  26. package/vendor/base/src/agent-mcp/lean.ts +19 -0
  27. package/vendor/base/src/agent-mcp/memoryContext.ts +15 -9
  28. package/vendor/base/src/agent-mcp/memoryIngest.ts +8 -3
  29. package/vendor/base/src/agent-mcp/rowComponents.ts +184 -72
  30. package/vendor/base/src/agent-mcp/tools.ts +136 -7
  31. package/vendor/base/src/agent-mcp/types.ts +47 -10
  32. package/vendor/base/src/definitions/load/compile/component.ts +59 -4
  33. package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
  34. package/vendor/base/src/definitions/load/compile/template.ts +19 -15
  35. package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
  36. package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
  37. package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
  38. package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
  39. package/vendor/base/src/definitions/load/load.ts +37 -28
  40. package/vendor/base/src/definitions/model.ts +8 -12
  41. package/vendor/base/src/definitions/registry/agents.ts +111 -0
  42. package/vendor/base/src/definitions/registry/catalog.ts +28 -12
  43. package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
  44. package/vendor/base/src/definitions/registry/homes.ts +69 -5
  45. package/vendor/base/src/definitions/registry/objects.ts +413 -0
  46. package/vendor/base/src/definitions/registry/orgDocuments.ts +69 -29
  47. package/vendor/base/src/definitions/registry/orgPack.ts +11 -11
  48. package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
  49. package/vendor/base/src/definitions/theme.ts +1 -2
  50. package/vendor/base/src/items/collect.ts +29 -25
  51. package/vendor/base/src/items/publish.ts +30 -6
  52. package/vendor/base/src/lint/context.mjs +17 -0
  53. package/vendor/base/src/lint/design/file.mjs +199 -2
  54. package/vendor/base/src/lint/design/index.mjs +66 -73
  55. package/vendor/base/src/lint/design/vocabulary.mjs +5 -2
  56. package/vendor/base/src/lint/design/walk.mjs +14 -4
  57. package/vendor/base/src/lint/index.mjs +15 -9
  58. package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
  59. package/vendor/base/src/lint/nodes/index.mjs +5 -0
  60. package/vendor/base/src/lint/nodes/node.mjs +157 -7
  61. package/vendor/base/src/lint/objects/index.d.mts +32 -0
  62. package/vendor/base/src/lint/objects/index.mjs +324 -0
  63. package/vendor/base/src/lint/objects/rules.d.mts +44 -0
  64. package/vendor/base/src/lint/objects/rules.mjs +446 -0
  65. package/vendor/base/src/lint/prompts/index.d.mts +34 -0
  66. package/vendor/base/src/lint/prompts/index.mjs +198 -0
  67. package/vendor/base/src/manifests/compose.ts +80 -9
  68. package/vendor/base/src/manifests/executor/callback.ts +15 -4
  69. package/vendor/base/src/manifests/executor/context.ts +9 -1
  70. package/vendor/base/src/manifests/executor/memory.ts +83 -3
  71. package/vendor/base/src/manifests/executor/session.ts +50 -0
  72. package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
  73. package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
  74. package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
  75. package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
  76. package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
  77. package/vendor/base/src/manifests/runtime/context.ts +18 -6
  78. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
  79. package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
  80. package/vendor/base/src/manifests/runtime/events.ts +39 -3
  81. package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
  82. package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
  83. package/vendor/base/src/manifests/runtime/index.ts +3 -2
  84. package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
  85. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
  86. package/vendor/base/src/manifests/runtime/usage.ts +10 -0
  87. package/vendor/base/src/paths.ts +16 -8
  88. package/vendor/base/src/platform/audioManager.ts +32 -0
  89. package/vendor/base/src/platform/executionContext.ts +14 -0
  90. package/vendor/base/src/platform/liveInput.ts +52 -0
  91. package/vendor/base/src/plugins/discovery.ts +1 -1
  92. package/vendor/base/src/template/SafeExpression.ts +6 -0
  93. package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
  94. package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
  95. package/vendor/sdk/appEngine.tsx +30 -11
  96. package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
  97. package/vendor/sdk/lib/app.tsx +70 -95
  98. package/vendor/sdk/lib/connection.tsx +47 -57
  99. package/vendor/sdk/lib/core/actions.ts +6 -0
  100. package/vendor/sdk/lib/core/apps.ts +66 -81
  101. package/vendor/sdk/lib/core/client.ts +5 -14
  102. package/vendor/sdk/lib/core/connection.ts +30 -0
  103. package/vendor/sdk/lib/core/index.ts +2 -2
  104. package/vendor/sdk/lib/core/store.ts +90 -15
  105. package/vendor/sdk/lib/core/types.ts +9 -8
  106. package/vendor/sdk/lib/primitives.tsx +113 -4
  107. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
  108. package/vendor/sdk/lib/render.tsx +3 -3
  109. package/vendor/sdk/lib/voice.tsx +27 -13
  110. package/local/sample-identity/brand/brand.yaml +0 -88
  111. package/local/sample-identity/brand/manifest.yaml +0 -13
  112. package/local/sample-identity/organisation/manifest.yaml +0 -15
  113. package/local/sample-identity/organisation/organisation.yaml +0 -53
  114. package/local/sample-identity/purpose/manifest.yaml +0 -12
  115. package/local/sample-identity/purpose/purpose.yaml +0 -46
  116. package/local/sample-identity/story/manifest.yaml +0 -12
  117. package/local/sample-identity/story/story.yaml +0 -63
  118. package/vendor/base/src/definitions/registry/identitySeed.ts +0 -17
@@ -25,6 +25,8 @@
25
25
  */
26
26
 
27
27
  /** A pointer into the data plane — the `${chatId}:${nodeId}` key. */
28
+ import { stateOfUri } from "./apps";
29
+
28
30
  export type Pointer = string;
29
31
 
30
32
  /** Structural equality for wire values: identity first, JSON compare for the rest.
@@ -79,6 +81,7 @@ export type LifecycleState = "idle" | "thinking" | "streaming" | "complete" | "e
79
81
 
80
82
  /** Stable empty slice for missing keys — never mutate. */
81
83
  const EMPTY_SLICE: Record<string, unknown> = Object.freeze({});
84
+ const EMPTY_POINTERS: readonly Pointer[] = Object.freeze([]);
82
85
 
83
86
  export class ComponentStore {
84
87
  // --- data plane ---
@@ -122,6 +125,13 @@ export class ComponentStore {
122
125
  private appState: Record<string, unknown> = {};
123
126
  // --- history cap (UNOVERSE_STATE_MODEL §6a): hold at most N turns, oldest-out-first. ---
124
127
  private maxTurns: number;
128
+ // --- places (docs/unoverse/design/screen/UNOVERSE_SCREEN.md): what the server's screen document puts
129
+ // in each place, in order. An app that declares places draws from here and nothing else. ---
130
+ private places = new Map<string, Pointer[]>();
131
+ /** Interfaces that have been placed outside the conversation at some point (see setPlace). */
132
+ private everOff = new Set<Pointer>();
133
+ /** The state the screen placed each interface in: a place draws it in this face. */
134
+ private placed = new Map<Pointer, string>();
125
135
 
126
136
  /** @param opts.maxTurns history cap (default 100; §6a). Channels may raise/lower it. */
127
137
  constructor(opts?: { maxTurns?: number }) {
@@ -179,7 +189,11 @@ export class ComponentStore {
179
189
  * marked isApp). Templates are SERVED apps, never streamed components (protocol
180
190
  * §0.1); this records WHICH app the channel should load, never a timeline component.
181
191
  */
182
- setActiveApp(name: string, props: Record<string, unknown> = {}): void {
192
+ setActiveApp(address: string, given: Record<string, unknown> = {}): void {
193
+ // THE HASH NAMES THE STATE (stateOfUri): `unoverse://apps/acme/workspace#review` is the
194
+ // app, opened on review; the hash seeds `state` when the props carry none.
195
+ const { uri: name, state } = stateOfUri(address);
196
+ const props = state && given.state == null ? { state, ...given } : given;
183
197
  if (this.activeAppName === name && JSON.stringify(this.activeAppProps) === JSON.stringify(props)) return;
184
198
  // TEMPLATE SWAP = the hard refresh boundary: a NEW shell starts with every surface
185
199
  // retired — every instance (durable ones included) returns to inline; the new
@@ -294,13 +308,16 @@ export class ComponentStore {
294
308
  * declared initial, and a card whose initial IS the lower state would re-claim it on
295
309
  * the next walk and never settle.
296
310
  */
297
- cancelBelow(active: string, order: string[]): void {
311
+ cancelBelow(active: string, order: string[], aliasOf?: Map<string, string>): void {
298
312
  const below = new Set(order.slice(order.indexOf(active) + 1));
299
313
  if (order.indexOf(active) < 0 || !below.size) return;
300
314
  let changed = false;
301
315
  for (const [key, slice] of this.data) {
302
316
  if (slice.lifetime === "conversation") continue;
303
- if (!below.has((slice.state ?? slice.view ?? slice.defaultState) as string)) continue;
317
+ // A face is read THROUGH the mount claims (`mountAliases`), exactly as the ladder
318
+ // reads it: a card wearing `grid` under a `page` mount sits in `page`.
319
+ const face = (slice.state ?? slice.view ?? slice.defaultState) as string;
320
+ if (!below.has(aliasOf?.get(face) ?? face)) continue;
304
321
  this.data.set(key, { ...slice, [this.faceKey(slice)]: "inline", ...("defaultState" in slice ? { defaultState: "inline" } : {}) });
305
322
  this.touches.set(key, ++this.touchSeq);
306
323
  changed = true;
@@ -308,11 +325,24 @@ export class ComponentStore {
308
325
  if (changed) this.bump();
309
326
  }
310
327
 
328
+ /**
329
+ * GUEST CLOSE from template chrome: every surfaced slice retracts to `inline`, the
330
+ * universal fallback face — never to its declared initial (ruled 2026-09-07: a card's
331
+ * declared initial is a claimed state such as `grid`, so retracting there resurrected
332
+ * the rail the guest had just closed; rule 6 forbids it). Durable slices opt out as ever.
333
+ */
311
334
  closeSurfaces(): void {
312
335
  let changed = false;
313
336
  for (const [key, slice] of this.data) {
314
337
  if (slice.lifetime === "conversation") continue;
315
- if (this.retractSlice(key, slice)) changed = true;
338
+ const current = (slice.state ?? slice.view ?? slice.defaultState) as unknown;
339
+ if (typeof current !== "string" || current === "inline") continue;
340
+ const next: Record<string, unknown> = { ...slice };
341
+ next[this.faceKey(next)] = "inline";
342
+ if ("defaultState" in next) next.defaultState = "inline";
343
+ this.data.set(key, next);
344
+ this.touches.set(key, ++this.touchSeq);
345
+ changed = true;
316
346
  }
317
347
  if (changed) this.bump();
318
348
  }
@@ -327,6 +357,49 @@ export class ComponentStore {
327
357
  this.bump();
328
358
  }
329
359
 
360
+ /** Replace one place's contents, and the state each interface is placed in, as the server's
361
+ * SCREEN_PLACE says. */
362
+ setPlace(place: string, pointers: Pointer[], states: Record<Pointer, string> = {}): void {
363
+ const current = this.places.get(place) ?? [];
364
+ const sameList = current.length === pointers.length && current.every((p, i) => p === pointers[i]);
365
+ const sameStates = Object.entries(states).every(([p, s]) => this.placed.get(p) === s);
366
+ if (sameList && sameStates) return;
367
+ // STEPPING ASIDE IS AN ARRIVAL IN THE CONVERSATION (owner, 2026-09-16). An interface that
368
+ // opened in another place (focus, main, rail) was placed in its turn when it opened, off
369
+ // the conversation, after the reply bubble that had already been created. When the
370
+ // screen moves it into `chat` it appears in the conversation for the first time, so that
371
+ // is where its turn places it: at the front, above the reply. Placed once, here, and no
372
+ // later data moves it (the 2026-09-08 ruling below, "where it arrived is where it lives",
373
+ // holds: this is an arrival, not an update). A card shown straight into `chat` was never
374
+ // elsewhere and keeps its arrival order.
375
+ // Remembered, not read at the moment: the screen empties the old place and fills `chat`
376
+ // as two messages, in either order, so "is it elsewhere now" can already be false.
377
+ if (place !== "chat") for (const p of pointers) this.everOff.add(p);
378
+ if (place === "chat") {
379
+ const wasInChat = new Set(current);
380
+ for (const p of pointers) {
381
+ if (wasInChat.has(p) || !this.everOff.has(p)) continue;
382
+ // A TURN WITH COMPONENTS IS THE ASSISTANT'S: said in the predicate, because a check
383
+ // inside a callback does not narrow the union outside it, and only the assistant's turn
384
+ // has `components` at all.
385
+ const turn = this.timeline.find((t): t is AssistantTurn => t.role === "assistant" && t.components.includes(p));
386
+ if (!turn) continue;
387
+ turn.components = [p, ...turn.components.filter((c) => c !== p)];
388
+ }
389
+ }
390
+ this.places.set(place, pointers);
391
+ for (const [p, s] of Object.entries(states)) this.placed.set(p, s);
392
+ this.bump();
393
+ }
394
+ /** The state the screen placed an interface in, or undefined when the screen named none. */
395
+ placedState(pointer: Pointer): string | undefined {
396
+ return this.placed.get(pointer);
397
+ }
398
+ /** What one place holds, in the order the screen document gives. */
399
+ placeItems(place: string): readonly Pointer[] {
400
+ return this.places.get(place) ?? EMPTY_POINTERS;
401
+ }
402
+
330
403
  // ════════════════════════════════════════════════════════════════════════════
331
404
  // STATE HAS TWO HOMES, BY SCOPE (UNOVERSE_STATE_MODEL §2) — and the SDK hardcodes
332
405
  // NO UI concept (no panel / focus / faq / voice). The dev picks the key and the home:
@@ -404,17 +477,19 @@ export class ComponentStore {
404
477
  const seeded = event.component.props as Record<string, unknown> | undefined;
405
478
  this.supersede(event.chatId, seeded?.state ?? seeded?.view ?? seeded?.defaultState);
406
479
  } else {
407
- // NEW ACTIVITY reads newest-at-the-bottom: if this component is no longer the
408
- // LAST pointer in its turn (something was placed after it), fresh server data
409
- // moves it back to the end — e.g. answer text resuming AFTER an interactive
410
- // component the workflow placed mid-turn. At most ONE structure bump per
411
- // overtake; the streaming hot path (already last) stays data-only.
412
- const resp = this.responseFor(event.chatId);
413
- if (resp && resp.components.length > 1 && resp.components[resp.components.length - 1] !== key && resp.components.includes(key)) {
414
- resp.components.splice(resp.components.indexOf(key), 1);
415
- resp.components.push(key);
416
- this.bump();
417
- }
480
+ // WHERE IT ARRIVED IS WHERE IT LIVES (owner ruling 2026-09-08). A pointer's place in
481
+ // its turn is fixed by `placeInTimeline` when it first appears, and no later data
482
+ // moves it. Updates change what a component SAYS, never where it sits.
483
+ //
484
+ // This replaces "new activity reads newest-at-the-bottom", which moved a component to
485
+ // the end of its turn whenever fresh data arrived for it. That was written for one
486
+ // case — answer text resuming after an interactive component placed mid-turn — and it
487
+ // recomputed order on EVERY update, which is stable only while one thing is updating.
488
+ // A held Agent task has two: the task's face ticking through its steps, and the
489
+ // reply resuming once the result lands. They alternate, so the bottom slot alternated
490
+ // with them and the pair visibly swapped places on screen (SAB Account Agent,
491
+ // 2026-09-08). The case it was written for is served by arrival order too: text
492
+ // placed before a form stays above it.
418
493
  this.merge(key, this.shieldGuestFace(key, event.data));
419
494
  // Rule 5 — a server data-write that PLACES a component into a public state
420
495
  // (the host's arrival placement rides COMPONENT_DATA) refreshes that state.
@@ -72,6 +72,9 @@ export interface UnoverseNode {
72
72
  * EXISTS only while a component is in its state (§5b: derived, not stored) — e.g. a
73
73
  * focus overlay that appears because something is focused and vanishes when it isn't. */
74
74
  frame?: UnoverseNode;
75
+ /** Place: how the place holds what the screen shows into it. `one` shows the newest;
76
+ * `many` shows the set the last show named (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §2.2). */
77
+ holds?: "one" | "many";
75
78
  /** Skeleton: placeholder variant. */
76
79
  variant?: "text" | "card" | "image";
77
80
  /** Timeline: data sub-tree rendered for a user turn (binds `text`). ALL bubble UX lives here, not the SDK. */
@@ -154,6 +157,9 @@ export interface UnoverseNode {
154
157
  /** Markdown: render links in the surrounding text colour rather than the link colour,
155
158
  * for a block that is already inside a coloured surface. */
156
159
  inheritLinkColor?: boolean;
160
+ /** Markdown: the action a citation's Open button fires (a `cite:` link, drawn as a marker with
161
+ * a pop-up), with the citation in scope. Undeclared, the pop-up shows without the button. */
162
+ citeAction?: string;
157
163
  /** Input: cap the number of characters accepted. */
158
164
  maxLength?: number;
159
165
  /** Input: render a multi-line field rather than a single line. */
@@ -206,19 +212,14 @@ export interface UnoverseDefinition {
206
212
  * state entry that mounts a template (`template:`); the loader stamps the mounted
207
213
  * template's own state names as `claims` — the mount state's reaction claim. */
208
214
  templates?: Record<string, { template?: string; claims?: string[]; preview?: string[]; director?: Record<string, unknown> } & Record<string, unknown>>;
215
+ /** The places this app declares. An app with places is drawn by the screen document:
216
+ * each interface sits where the server placed it (UNOVERSE_SCREEN.md). */
217
+ places?: { name: string; holds: "one" | "many" }[];
209
218
  defaultLayout?: string;
210
219
  /** THE DECLARED STATE ORDER (STATE_MODEL v2 §5 rules 3+5) — the template's priority
211
220
  * ladder and arrival-scan order, served from the manifest. First entry outranks
212
221
  * all below it; no name is special. Absent → legacy v1 recency semantics. */
213
222
  stateOrder?: string[];
214
- /** THE LATCH, PER STATE (docs/MCP_COMPLETE_GUIDE.md §The Component Latch, moved off the
215
- * manifest 2026-08-31): the states whose wearer the conversation may be ADDRESSED AT,
216
- * keyed by state name, compiled from each state's own `latch:` block.
217
- *
218
- * A state absent from this map can never hold the latch, so the SAME instance retracted
219
- * from its page state to an inline one drops the pill by construction. `resolveLatch`
220
- * reads this; nothing about the latch travels on the wire with a render. */
221
- latches?: Record<string, { title: string; icon?: string }>;
222
223
  }
223
224
 
224
225
  // ---- The interaction vocabulary (UNOVERSE_SPEC.md §2e-3) ----
@@ -19,7 +19,7 @@
19
19
  * Text/Image (see FRAMEWORK.md), never a composite.
20
20
  * ════════════════════════════════════════════════════════════════════════════
21
21
  */
22
- import { createElement, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
22
+ import { createContext, createElement, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
23
23
  import Markdown from "markdown-to-jsx";
24
24
  import type { ResolvedTheme, UnoverseNode } from "./core";
25
25
  import { styleToCss, cssDecls } from "./style";
@@ -699,6 +699,58 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
699
699
  );
700
700
  }
701
701
 
702
+ /**
703
+ * WHAT A CITATION'S OPEN BUTTON FIRES (docs/unoverse/data/objects/citations-in-answers.md): the
704
+ * action the Markdown node declares in `citeAction`, with the render data. Carried by context,
705
+ * not baked into the overrides, so the overrides stay built once per theme and a marker's open
706
+ * pop-up survives the answer streaming on around it.
707
+ */
708
+ const CiteContext = createContext<{ action?: string; onAction?: ActionHandler; data: Record<string, unknown> }>({ data: {} });
709
+
710
+ /**
711
+ * A CITATION STILL ARRIVING is held back (citations-in-answers.md): while an answer streams, the
712
+ * tail from an open `[1](cite:` to its closing `)` would draw as a long line of raw link and then
713
+ * collapse into its marker, jolting the text once per citation. Only the tail is touched, and only
714
+ * a numbered label (a citation's), so finished text and web links draw exactly as written.
715
+ */
716
+ const OPEN_CITATION = /\[\d{0,3}(?:\](?:\((?:c(?:i(?:t(?:e(?::[^)\n]*)?)?)?)?)?)?)?$/;
717
+ export function holdOpenCitation(text: string): string {
718
+ return text.replace(OPEN_CITATION, "");
719
+ }
720
+
721
+ /**
722
+ * A CITATION'S QUOTE STAYS ONE CELL (citations-in-answers.md, 2026-09-15): a quote of a table row
723
+ * carried its pipes ("Total | 10") into the link's title, and inside an answer's own table the
724
+ * pipe split the row and left the marker raw. Before the markdown is parsed, a pipe inside a
725
+ * `cite:` link's title becomes a broken bar and a line break a space; the words still read.
726
+ */
727
+ const CITATION_TITLE = /(\]\(cite:[^)\s]*\s+")([^"]*)("\))/g;
728
+ export function keepCitationsWhole(text: string): string {
729
+ return text.replace(CITATION_TITLE, (_, open: string, quote: string, close: string) => `${open}${quote.replace(/\|/g, "\u00a6").replace(/\s*\n\s*/g, " ")}${close}`);
730
+ }
731
+
732
+ /**
733
+ * WHERE A POP-UP FITS (citations-in-answers.md): the horizontal shift that keeps a box of this
734
+ * width inside the window from an anchor at `left`, and whether it opens above the anchor
735
+ * because below there is no room and above there is. A margin keeps it off the very edge.
736
+ */
737
+ export function fitPopover(anchor: { left: number; top: number; bottom: number }, box: { width: number; height: number }, view: { width: number; height: number }, margin = 8) {
738
+ const over = anchor.left + box.width - (view.width - margin);
739
+ const shift = Math.max(over > 0 ? -over : 0, margin - anchor.left);
740
+ const above = anchor.bottom + box.height + margin > view.height && anchor.top - box.height - margin >= 0;
741
+ return { shift, above };
742
+ }
743
+
744
+ /** A `cite:` link's parts: `cite:<document>?part=&page=&doc=`, its title the quoted words. */
745
+ function citationOf(href: string, title?: string) {
746
+ try {
747
+ const u = new URL(href);
748
+ return { document: decodeURIComponent(u.pathname), part: u.searchParams.get("part") ?? "", page: u.searchParams.get("page") ?? "", doc: u.searchParams.get("doc") ?? "", quote: title ?? "" };
749
+ } catch {
750
+ return null;
751
+ }
752
+ }
753
+
702
754
  /**
703
755
  * markdown-to-jsx overrides for the `Markdown` primitive.
704
756
  *
@@ -734,6 +786,60 @@ function buildMarkdownOverrides(theme: ResolvedTheme, inheritLinkColor = false)
734
786
  {linkArrow}
735
787
  </a>
736
788
  );
789
+ // A CITATION (citations-in-answers.md): a `cite:` link is a marker after the fact it supports.
790
+ // Tapped, a pop-up shows the quoted words, the document and the page, and an Open button that
791
+ // fires the node's `citeAction` with the citation. Behaviour only: every look is a served
792
+ // prose key (citeAnchor, citeMark, citePopover, citeQuote, citeSource, citeOpen).
793
+ const CiteMark = ({ href, title, children }: { href: string; title?: string; children?: ReactNode }) => {
794
+ const [open, setOpen] = useState(false);
795
+ const box = useRef<HTMLSpanElement>(null);
796
+ const pop = useRef<HTMLSpanElement>(null);
797
+ const [place, setPlace] = useState({ shift: 0, above: false });
798
+ // ALWAYS ON SCREEN: measured as it opens, and again when the window changes size.
799
+ useLayoutEffect(() => {
800
+ if (!open) return;
801
+ const fit = () => {
802
+ if (!box.current || !pop.current) return;
803
+ const a = box.current.getBoundingClientRect();
804
+ setPlace(fitPopover(a, { width: pop.current.offsetWidth, height: pop.current.offsetHeight }, { width: window.innerWidth, height: window.innerHeight }));
805
+ };
806
+ fit();
807
+ window.addEventListener("resize", fit);
808
+ return () => window.removeEventListener("resize", fit);
809
+ }, [open]);
810
+ const { action, onAction, data } = useContext(CiteContext);
811
+ const c = citationOf(href, title);
812
+ useEffect(() => {
813
+ if (!open) return;
814
+ const away = (e: Event) => { if (box.current && !e.composedPath().includes(box.current)) setOpen(false); };
815
+ const esc = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
816
+ document.addEventListener("pointerdown", away);
817
+ document.addEventListener("keydown", esc);
818
+ return () => { document.removeEventListener("pointerdown", away); document.removeEventListener("keydown", esc); };
819
+ }, [open]);
820
+ if (!c) return <>{children}</>;
821
+ const source = [c.doc, c.page ? `p. ${c.page}` : ""].filter(Boolean).join(" · ");
822
+ return (
823
+ <span ref={box} style={s("citeAnchor")}>
824
+ <button type="button" aria-expanded={open} aria-label={source ? `Source: ${source}` : "Source"} style={s("citeMark")} onClick={() => setOpen((o) => !o)}>
825
+ {children}
826
+ </button>
827
+ {open && (
828
+ <span ref={pop} role="dialog" style={{ ...s("citePopover"), transform: `translateX(${place.shift}px)`, ...(place.above ? { top: "auto", bottom: "100%" } : {}) }}>
829
+ {c.quote && <span style={{ display: "block", ...s("citeQuote") }}>“{c.quote}”</span>}
830
+ {source && <span style={{ display: "block", ...s("citeSource") }}>{source}</span>}
831
+ {action && onAction && (
832
+ <button type="button" style={s("citeOpen")} onClick={() => { setOpen(false); onAction(action, { ...data, citation: c }); }}>
833
+ Open
834
+ </button>
835
+ )}
836
+ </span>
837
+ )}
838
+ </span>
839
+ );
840
+ };
841
+ const Link = (a: React.AnchorHTMLAttributes<HTMLAnchorElement>) =>
842
+ typeof a.href === "string" && a.href.startsWith("cite:") ? <CiteMark href={a.href} title={a.title}>{a.children}</CiteMark> : <ExternalLink {...a} />;
737
843
  // Wide tables scroll instead of crushing columns (legacy ScrollableTable).
738
844
  const ScrollableTable = (a: React.TableHTMLAttributes<HTMLTableElement>) => (
739
845
  <div style={s("tableWrap")}>
@@ -751,7 +857,7 @@ function buildMarkdownOverrides(theme: ResolvedTheme, inheritLinkColor = false)
751
857
 
752
858
  return {
753
859
  p: { props: { style: s("paragraph") } },
754
- a: { component: ExternalLink },
860
+ a: { component: Link },
755
861
  table: { component: ScrollableTable },
756
862
  img: { component: MarkdownImage },
757
863
  // INLINE MARKS and BLOCKS. Authored copy uses all of these (a bolded access code, a
@@ -785,16 +891,19 @@ function buildMarkdownOverrides(theme: ResolvedTheme, inheritLinkColor = false)
785
891
  * Container takes node.style (tokens); elements map to served theme tokens via
786
892
  * buildMarkdownOverrides. The SDK authors ZERO style values.
787
893
  */
788
- export function MarkdownView({ node, data, theme }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme }): ReactNode {
894
+ export function MarkdownView({ node, data, theme, onAction }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler }): ReactNode {
789
895
  const value = (node.bind?.value ? (data[node.bind.value] as string) : "") ?? "";
790
896
  // Explicit opt-in ONLY (a CTA pill whose label must match its declared color):
791
897
  // `inheritLinkColor: true` on the node. Declaring a body color alone never
792
898
  // hijacks anchors — chat prose keeps the served link recipe.
793
899
  const inheritLink = (node as { inheritLinkColor?: boolean }).inheritLinkColor === true;
794
900
  const overrides = useMemo(() => buildMarkdownOverrides(theme, inheritLink), [theme, inheritLink]);
901
+ const cite = (node as { citeAction?: string }).citeAction;
795
902
  return (
796
903
  <div style={styleToCss(node.style, theme, data)}>
797
- <Markdown options={{ overrides, forceBlock: true }}>{value}</Markdown>
904
+ <CiteContext.Provider value={{ action: cite, onAction, data }}>
905
+ <Markdown options={{ overrides, forceBlock: true }}>{holdOpenCitation(keepCitationsWhole(value))}</Markdown>
906
+ </CiteContext.Provider>
798
907
  </div>
799
908
  );
800
909
  }
@@ -10,6 +10,9 @@
10
10
 
11
11
  import { useRef, useCallback, useEffect, useState } from "react";
12
12
 
13
+ /** How far ahead the first frame is scheduled when nothing is queued. */
14
+ const START_LEAD_SECONDS = 0.2;
15
+
13
16
  /** Decode LPCM (Int16, 24 kHz, mono) → AudioBuffer. */
14
17
  function decodeLPCM(audioContext: AudioContext, data: ArrayBuffer): AudioBuffer {
15
18
  const sampleRate = 24000;
@@ -69,7 +72,11 @@ export function useAudioPlayback(): UseAudioPlaybackReturn {
69
72
  try {
70
73
  const audioBuffer = decodeLPCM(audioContext, item.audioData);
71
74
  const now = audioContext.currentTime;
72
- const startTime = Math.max(now, nextTimeRef.current);
75
+ // A CUSHION WHEN THE QUEUE IS EMPTY. A vendor that streams at speaking pace (GPT-Live)
76
+ // sends each frame just as the last one ends, so a frame that is a moment late leaves a
77
+ // gap, and a gap is heard as a click. Starting a little ahead absorbs that lateness; once
78
+ // playing, frames join end to end as before.
79
+ const startTime = nextTimeRef.current > now ? nextTimeRef.current : now + START_LEAD_SECONDS;
73
80
 
74
81
  const source = audioContext.createBufferSource();
75
82
  source.buffer = audioBuffer;
@@ -132,7 +132,7 @@ export function renderNode(
132
132
  // ComponentSlot AND Template keep their appWidth: both are slot-dispatched places
133
133
  // whose width applies to their FRAME (app.tsx), not to a box of their own —
134
134
  // consuming it here left the mount frame width-less (measured live 2026-08-28).
135
- if (panelW && node.type !== "ComponentSlot" && node.type !== "Template") {
135
+ if (panelW && node.type !== "ComponentSlot" && node.type !== "Template" && node.type !== "Place") {
136
136
  const w = cssWidth(theme.appSize?.[panelW] ?? panelW);
137
137
  if (w) node = { ...node, appWidth: undefined, style: { ...(node.style ?? {}), width: w, flex: "0 0 auto" } } as UnoverseNode;
138
138
  }
@@ -148,8 +148,8 @@ export function renderNode(
148
148
  // Orb: procedural thinking/working animation — state is a literal or bound (`bind.state`).
149
149
  if (node.type === "Orb") return <OrbView key={key} node={node} data={data} theme={theme} />;
150
150
  if (node.type === "Input") return <InputView key={key} node={node} data={data} theme={theme} onAction={onAction} />;
151
- if (node.type === "Markdown") return <MarkdownView key={key} node={node} data={data} theme={theme} />;
152
- if (node.type === "ComponentSlot" || node.type === "Timeline" || node.type === "Template") return slot ? slot(node, key) : null;
151
+ if (node.type === "Markdown") return <MarkdownView key={key} node={node} data={data} theme={theme} onAction={onAction} />;
152
+ if (node.type === "ComponentSlot" || node.type === "Timeline" || node.type === "Template" || node.type === "Place") return slot ? slot(node, key) : null;
153
153
 
154
154
  // `Ref` — REFERENCE, NOT COPY. The design system's shared pieces travel once and are
155
155
  // cached; a definition names one and specialises it here (core/refs.ts: `props` remap,
@@ -23,6 +23,7 @@ import { useAudioCapture } from "./realtime/useAudioCapture";
23
23
  import { useAudioPlayback } from "./realtime/useAudioPlayback";
24
24
  import type { AudioStateEvent } from "./realtime/types";
25
25
  import type { ComponentStore } from "./core";
26
+ import { sessionUserId } from "./connection";
26
27
 
27
28
  export type VoiceConnectionStatus = "idle" | "connecting" | "connected" | "ended" | "error";
28
29
 
@@ -93,8 +94,14 @@ function formatDuration(totalSeconds: number): string {
93
94
  }
94
95
 
95
96
  export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
96
- const { apiUrl, session, getAccessToken } = config;
97
+ const { apiUrl, getAccessToken } = config;
97
98
  const wsUrl = config.wsUrl ?? apiUrl.replace(/^http/, "ws");
99
+ // Same identity as the data plane (connection.tsx): without a token the audio socket is a
100
+ // guest socket, and the server refuses INIT_SESSION unless the id is guest-prefixed.
101
+ const session = useMemo(
102
+ () => ({ ...config.session, userId: sessionUserId(config.session.userId, getAccessToken) }),
103
+ [config.session, getAccessToken],
104
+ );
98
105
 
99
106
  const [connectionStatus, setConnectionStatus] = useState<VoiceConnectionStatus>("idle");
100
107
  const [isMuted, setIsMuted] = useState(false);
@@ -130,6 +137,8 @@ export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
130
137
  setConnectionStatus("ended");
131
138
  setAssistantHint(false);
132
139
  setUserSpeaking(false);
140
+ setIsLookingUp(false);
141
+ setLookupTool(null);
133
142
  playback.markAsLastChunk();
134
143
  } else if (state === "TOOL_USE") {
135
144
  setIsLookingUp(true);
@@ -204,6 +213,8 @@ export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
204
213
  } catch (err: unknown) {
205
214
  setError(err instanceof Error ? err.message : "Failed to start call");
206
215
  setConnectionStatus("error");
216
+ setIsLookingUp(false);
217
+ setLookupTool(null);
207
218
  }
208
219
  }, [ws, capture, apiUrl, session, getAccessToken]);
209
220
 
@@ -219,6 +230,8 @@ export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
219
230
  ws.disconnect();
220
231
  setConnectionStatus("ended");
221
232
  setIsMuted(false);
233
+ setIsLookingUp(false);
234
+ setLookupTool(null);
222
235
  }, [capture, playback, ws, session.workflowId]);
223
236
 
224
237
  const toggleMute = useCallback(() => {
@@ -259,14 +272,15 @@ export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
259
272
  () => ({
260
273
  connectionStatus,
261
274
  // The one phase the template branches on — derived from the neutral facts. Order matters:
262
- // agent speaking wins over user (barge-in), both win over a lookup, and all three win
263
- // over plain "active".
275
+ // a lookup wins, then agent speaking over user (barge-in), and all three win over plain
276
+ // "active".
264
277
  //
265
- // `thinking` sits BELOW both speaking checks because someone actually talking is the more
266
- // important fact: a lookup can still be running while the assistant narrates it ("let me
267
- // check that for you"), and the surface should show the speech.
278
+ // `thinking` sits ABOVE speaking because a lookup has a true start and a true end (the
279
+ // hand-off and its answer), while speaking may have no end at all: GPT-Live sends no
280
+ // end-of-speech event and streams silence, so speaking can read true for the whole call
281
+ // and would hide the lookup. Thinking clears on the answer, a hang-up or a dropped call.
268
282
  //
269
- // It sits ABOVE "active" because that is the bug it fixes. A lookup takes 8 to 9 seconds,
283
+ // It also sits ABOVE "active" because that is the bug it fixed. A lookup takes 8 to 9 seconds,
270
284
  // and for all of it callState read "active" — which every voice template draws as a calm
271
285
  // halo meaning "the floor is yours". The orb is the only progress indicator a call has,
272
286
  // and it was saying the opposite of what was happening. `isLookingUp` was already right
@@ -274,12 +288,12 @@ export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
274
288
  // field the templates branch on.
275
289
  callState: (!isCallActive
276
290
  ? "idle"
277
- : isAssistantSpeaking
278
- ? "speaking"
279
- : isUserSpeaking
280
- ? "listening"
281
- : isLookingUp
282
- ? "thinking"
291
+ : isLookingUp
292
+ ? "thinking"
293
+ : isAssistantSpeaking
294
+ ? "speaking"
295
+ : isUserSpeaking
296
+ ? "listening"
283
297
  : "active") as VoiceCallState,
284
298
  isIdle: !isCallActive,
285
299
  isCallActive,
@@ -1,88 +0,0 @@
1
- unoverse: "1.0"
2
- # THE BRAND, declared the way a component declares its props: each description is the brief
3
- # the writer follows; the defaults are this org's own words, as they stand.
4
- type: identity
5
- name: brand
6
- category: Identity
7
- props:
8
- name:
9
- type: string
10
- input: true
11
- description: >-
12
- The brand name as the enterprise presents it, which may differ from its trading name
13
- (a bank's card brand, a group's consumer brand). The site's logo text or title, not
14
- the legal entity.
15
- maxLength: 80
16
- default: ""
17
- standsFor:
18
- type: string
19
- input: true
20
- description: >-
21
- What the name stands for, in one sentence, in the enterprise's own words where it says
22
- so (a tagline, a strapline, the meaning of the name, a 'who we are' line). Empty when
23
- it does not say.
24
- maxLength: 300
25
- default: ""
26
- promise:
27
- type: string
28
- input: true
29
- description: >-
30
- The expectation the brand sets: what people can expect from it, in one or two
31
- sentences, drawn from the promises the site repeats on its home, about and brand
32
- pages. Written to the customer.
33
- maxLength: 400
34
- default: ""
35
- represents:
36
- type: string
37
- input: true
38
- description: >-
39
- How the brand carries the purpose (read purpose.yaml first): in one paragraph, how the
40
- name, the promise and the way it presents itself express why the enterprise exists. In
41
- the enterprise's own words where it connects the two; empty when it does not.
42
- maxLength: 500
43
- default: ""
44
- audience:
45
- type: string
46
- input: true
47
- description: >-
48
- Whom the brand speaks to and how it wants to be seen by them, in one or two sentences,
49
- from the way the site addresses its customers and describes its position. Never
50
- invented segments.
51
- maxLength: 400
52
- default: ""
53
- differentiators:
54
- type: array
55
- input: true
56
- description: >-
57
- What the enterprise claims sets its brand apart, three to six items, each in its own
58
- words (a first, a leadership claim, a partnership, a heritage). Only claims it makes
59
- itself.
60
- maxItems: 6
61
- default: []
62
- brands:
63
- type: array
64
- input: true
65
- description: >-
66
- The brand tree: the sub-brands and product brands the enterprise presents under this
67
- brand (a card family, an app, a loyalty programme, a subsidiary), one entry per brand
68
- with its name as written and one sentence on what it is for, nested in `brands` where
69
- the enterprise nests them. Empty when it presents none; never invented.
70
- maxItems: 20
71
- default: []
72
- rules:
73
- type: array
74
- input: true
75
- description: >-
76
- Three to six rules the site's copy visibly follows, each as 'Do this' or 'Never that':
77
- the person it addresses (you, we), its register (plain, formal, warm), sentence
78
- length, what it capitalises, what it never says.
79
- maxItems: 6
80
- default: []
81
- examples:
82
- type: array
83
- input: true
84
- description: >-
85
- Three to five sentences copied verbatim from the site that show the voice. Quotes,
86
- never paraphrase.
87
- maxItems: 5
88
- default: []
@@ -1,13 +0,0 @@
1
- # The Brand (docs/edgy/elements/brand.md): our name and what it stands for. The written half;
2
- # styles/ is the visual half. Published, it is one row on the org's map, found by meaning.
3
- title: Brand
4
- description: >-
5
- The name and what it stands for: the promise it makes, whom it speaks to, what sets it
6
- apart, the brands inside it, and how it sounds.
7
- whenToUse: >-
8
- Answer what the brand stands for, what to expect from it, whom it is for and what sets it
9
- apart; name the brands inside it; and give any writing its voice, the rules it follows and
10
- sentences in its own words. Not for what the organisation is or does, that is the
11
- Organisation, and not for why it exists, that is the Purpose.
12
- category: Identity
13
- version: 1.0.0
@@ -1,15 +0,0 @@
1
- # The Organisation (docs/edgy/elements/organisation.md): the one that owns this spatial. This
2
- # manifest says what the document is for; organisation.yaml is the document. It never sits
3
- # on the map: it is the scope on every row and the grounding every extraction and every
4
- # agent wakes with.
5
- title: Organisation
6
- description: >-
7
- Who this organisation is and what it does, in its own words, and the words it uses for its
8
- products, its customers and itself.
9
- whenToUse: >-
10
- Ground any extraction, any agent or any piece of writing in who the organisation is and
11
- how it speaks. Read whole at wake, never searched for. Not for what it offers, that is the
12
- products on the map, and not for why it exists or where it came from, those are Purpose
13
- and Story.
14
- category: Identity
15
- version: 1.0.0