@unoverse-platform/studio 0.1.113 → 0.1.114

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.113",
3
+ "version": "0.1.114",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -184,7 +184,7 @@ export function loadDefinition(
184
184
  if (m.preview && typeof m.preview === "object") def.preview = m.preview as Record<string, string[]>;
185
185
  // THE WORKFLOW THAT WORKS IT (UNOVERSE_HOST_PAGE.md rule 4): the manifest names it,
186
186
  // and the page reads it here to start a call on it.
187
- if (m.binding && typeof m.binding === "object") def.binding = m.binding as { workflow?: string; trigger?: string };
187
+ if (m.binding && typeof m.binding === "object") def.binding = m.binding as { workflow?: string; trigger?: string; pipeline?: string };
188
188
  // SPATIAL-CAPABLE, exactly as a component is (§9c, ruled 2026-08-30): a
189
189
  // template is found by what a guest says, through the same projection, the
190
190
  // same registry switch and the same embedded text. The difference is only
@@ -52,7 +52,7 @@ export interface UnoverseDefinition {
52
52
  /** TEMPLATES: the workflow that works this template as a task, from its manifest.yaml
53
53
  * (docs/unoverse/design/sdk/UNOVERSE_HOST_PAGE.md rule 4). A page starts a call on it from
54
54
  * the avatar; a template without one starts nothing. */
55
- binding?: { workflow?: string; trigger?: string };
55
+ binding?: { workflow?: string; trigger?: string; pipeline?: string };
56
56
  /** Source basename for FILE definitions (atoms) — the identity refs resolve by. */
57
57
  file?: string;
58
58
  /** The client org it belongs to (server-injected from its folder, never
@@ -114,6 +114,8 @@ export interface ObjectMatching {
114
114
  ruleOut?: string[];
115
115
  /** The node that decides a name the rules leave open (TypeSafeChoice). Absent, an open name goes to review. */
116
116
  judge?: string;
117
+ /** The lines (by name, e.g. a document's `about`) whose name, sure to be no record held, makes the record. A mention on any other line never does. */
118
+ createFrom?: string[];
117
119
  }
118
120
 
119
121
  /**
@@ -31,7 +31,7 @@ import { pipelineFindings, firstRefusal, PIPELINE_READ_KEYS } from "../../lint/o
31
31
  export interface CollectStep {
32
32
  /** The step's name; a later step reads its outputs as `{{ collect.<name>.<output> }}`. */
33
33
  name: string;
34
- /** The marketplace node it runs. */
34
+ /** The marketplace node it runs, or `InputTrigger` as the first step: the pipeline's door (README, `collect`). */
35
35
  node: string;
36
36
  /** The node's config, with `{{ config.* }}` and `{{ collect.* }}` references. */
37
37
  config: Record<string, unknown>;
@@ -461,8 +461,8 @@ function lintFile(file) {
461
461
  // TEMPLATE manifest = the envelope. Requires binding + a resolvable root.
462
462
  if (!json.name) report("warn", file, `app manifest missing "name": the display name (docs.unoverse.ai/design/apps)`);
463
463
  checkDiscoveryMeta(report, file, json, "app", "docs.unoverse.ai/design/apps");
464
- if (!(json.binding && json.binding.workflow))
465
- report("warn", file, `template manifest has no binding.workflow. The app owns its workflow binding (docs.unoverse.ai/design/apps)`);
464
+ if (!(json.binding && (json.binding.workflow || json.binding.pipeline)))
465
+ report("warn", file, `template manifest has no binding.workflow or binding.pipeline. The app owns its workflow binding (docs.unoverse.ai/design/apps)`);
466
466
  // Two valid roots (definitions.ts:229): the STANDARD manifest-only form (root =
467
467
  // layouts/<layout>), OR a `<name>` envelope OVERRIDE (its own root). Only the
468
468
  // manifest-only form must resolve a layout; an envelope-form template supplies its own.
@@ -23,7 +23,7 @@ export const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
23
23
  const REF_OR_EXPR = /[{}]|^\s*return\b/;
24
24
 
25
25
  /** The twelve elements of the vocabulary (docs/unoverse/data/objects/elements/): what an object IS. */
26
- export const ELEMENTS = new Set(["purpose", "story", "content", "capability", "process", "asset", "task", "journey", "channel", "organisation", "people", "product", "brand", "document"]);
26
+ export const ELEMENTS = new Set(["purpose", "story", "content", "capability", "process", "asset", "task", "journey", "channel", "organisation", "people", "product", "brand", "document", "observation"]);
27
27
 
28
28
  /** The face every object and pipeline carries, as a node's node.yaml does. */
29
29
  export const FACE_KEYS = ["title", "description", "whenToUse", "category", "version"];
@@ -110,13 +110,14 @@ export function envelopeFindings(name, envelope, file = `${name}.yaml`) {
110
110
  if (envelope.matching != null) {
111
111
  const m = envelope.matching;
112
112
  const fields = (v) => Array.isArray(v) && v.length > 0 && v.every((f) => typeof f === "string" && f);
113
- if (!isRecord(m)) refuse(`object ${name}: matching is a record: ids, names, remember, evidence, ruleOut, judge`);
113
+ if (!isRecord(m)) refuse(`object ${name}: matching is a record: ids, names, remember, evidence, ruleOut, judge, createFrom`);
114
114
  else {
115
115
  if (!fields(m.names)) refuse(`object ${name}: matching: names lists the fields that hold this object's names`);
116
116
  for (const k of ["ids", "evidence", "ruleOut"]) if (m[k] != null && !fields(m[k])) refuse(`object ${name}: matching: ${k} is a list of field names`);
117
+ if (m.createFrom != null && !fields(m.createFrom)) refuse(`object ${name}: matching: createFrom is a list of line names (about)`);
117
118
  if (m.remember != null && (typeof m.remember !== "string" || !(m.names ?? []).includes(m.remember))) refuse(`object ${name}: matching: remember names one of names, the list a confirmed name is added to`);
118
119
  if (m.judge != null && (typeof m.judge !== "string" || !/^[A-Z][A-Za-z0-9]*$/.test(m.judge))) refuse(`object ${name}: matching: judge names a node by its type (TypeSafeChoice)`);
119
- for (const k of Object.keys(m)) if (!["ids", "names", "remember", "evidence", "ruleOut", "judge"].includes(k)) say(`object ${name}: matching: "${k}" is not a word here`);
120
+ for (const k of Object.keys(m)) if (!["ids", "names", "remember", "evidence", "ruleOut", "judge", "createFrom"].includes(k)) say(`object ${name}: matching: "${k}" is not a word here`);
120
121
  }
121
122
  }
122
123
  return out;
@@ -333,6 +334,7 @@ export function pipelineFindings(name, file, fileName = "pipeline.yaml") {
333
334
  return;
334
335
  }
335
336
  if (stepNames.has(s.name)) refuse(`pipeline ${name}: two collect steps named "${s.name}"`);
337
+ if (s.node === "InputTrigger" && i > 0) refuse(`pipeline ${name}: collect step "${s.name}" is an Input Trigger, which only opens a pipeline: make it the first step (docs/unoverse/data/pipelines/README.md, collect)`);
336
338
  // A step reads only what came before it.
337
339
  for (const ref of collectRefs(s.config)) if (!stepNames.has(ref)) say(`pipeline ${name}: collect step "${s.name}" reads collect.${ref}, which is not an earlier step`);
338
340
  stepNames.add(s.name);
@@ -438,7 +440,8 @@ export function pipelineFolderFindings(name, { file, config, test }, { nodeTypes
438
440
  const out = [];
439
441
  if (!isRecord(file)) return out;
440
442
  const steps = Array.isArray(file.collect) ? file.collect.filter(isRecord) : [];
441
- if (nodeTypes) for (const s of steps) if (typeof s.node === "string" && !nodeTypes.has(s.node)) out.push({ file: "pipeline.yaml", msg: `pipeline ${name}: collect step "${s.name}" names node "${s.node}", which neither the marketplace nor this org holds. A step runs a node the org has` });
443
+ // The Input Trigger is the engine's own node, not a marketplace one: the pipeline's door (README, collect).
444
+ if (nodeTypes) for (const s of steps) if (typeof s.node === "string" && s.node !== "InputTrigger" && !nodeTypes.has(s.node)) out.push({ file: "pipeline.yaml", msg: `pipeline ${name}: collect step "${s.name}" names node "${s.node}", which neither the marketplace nor this org holds. A step runs a node the org has` });
442
445
  if (objectNames && isRecord(file.make)) for (const object of Object.keys(file.make)) if (!objectNames.has(object)) out.push({ file: "pipeline.yaml", msg: `pipeline ${name}: make.${object} names an object neither the marketplace nor this org holds. What a pipeline makes is an object folder: design/<tier>/objects/${object}/` });
443
446
  const props = isRecord(config?.configSchema?.properties) ? new Set(Object.keys(config.configSchema.properties)) : null;
444
447
  const refs = configRefs(file);
@@ -227,6 +227,7 @@ export function mountAppEngine(el: HTMLElement, first: AppConfig): AppEngine {
227
227
  // A PAGE TEMPLATE'S TASK, called at its own workflow's trigger with the user's token.
228
228
  // `metadata.task` names the task its Task Runner loads; the submit carries the answers.
229
229
  const startTask = async (b: Binding, task: string, submit?: Record<string, unknown>) => {
230
+ if (b.pipeline) return startPipeline(b.pipeline, submit ?? {});
230
231
  if (!b.workflow) return;
231
232
  inTask = true;
232
233
  syncConnection();
@@ -248,6 +249,27 @@ export function mountAppEngine(el: HTMLElement, first: AppConfig): AppEngine {
248
249
  }).catch((e) => console.error("[unoverse] task start failed", e));
249
250
  };
250
251
 
252
+ // A TEMPLATE BOUND TO A PIPELINE (docs/unoverse/data/pipelines/README.md, collect): the answers go
253
+ // to the pipeline's Input Trigger, called as a workflow is, `/api/pipelines/<org>/<name>/execute`,
254
+ // the org the app's own. The server shows the run live in this conversation, as it shows a
255
+ // workflow task (the agent-card, its steps from the run's traces), so nothing here follows it;
256
+ // the receipt is written into template state as `run`.
257
+ const startPipeline = async (name: string, answers: Record<string, unknown>) => {
258
+ const org = appId.includes("/") ? appId.split("/")[0] : "";
259
+ const token = cfg.getAccessToken ? await cfg.getAccessToken() : null;
260
+ try {
261
+ const res = await fetch(`${cfg.apiUrl ?? cfg.serverUrl}/api/pipelines/${encodeURIComponent(org)}/${encodeURIComponent(name)}/execute`, {
262
+ method: "POST",
263
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
264
+ body: JSON.stringify({ input: answers, conversationId: cfg.conversationId, chatId: cfg.conversationId }),
265
+ });
266
+ store.mergeAppState({ run: await res.json().catch(() => ({ status: res.ok ? "STARTED" : "FAILED" })) });
267
+ } catch (e) {
268
+ console.error("[unoverse] pipeline run failed", e);
269
+ store.mergeAppState({ run: { status: "FAILED", error: String((e as Error)?.message ?? e) } });
270
+ }
271
+ };
272
+
251
273
  const onAction: ActionHandler = (action, data, meta) => {
252
274
  const t = typeof action === "string" ? action : (action as { type?: string })?.type;
253
275
  const d = (data ?? {}) as Record<string, unknown>;
@@ -282,10 +282,15 @@ export function mountComponent(options: ComponentOptions): ComponentMount {
282
282
  tree.destroy();
283
283
  show([message(doc, text)]);
284
284
  };
285
- // Loading and error hints carry NO styling: the SDK owns zero styles.
285
+ // An error carries NO styling: the SDK owns zero styles. Loading shows nothing: a comment
286
+ // holds the component's place until the drawing arrives, so a slide never flashes a line.
287
+ const hold = () => {
288
+ tree.destroy();
289
+ show([doc.createComment(`loading ${defUri ?? "component"}`)]);
290
+ };
286
291
  if (error) return say(`Unoverse error: ${error}`);
287
- if (o.store && !o.store.getType(o.chatId ?? "", o.nodeId ?? "")) return say("waiting for COMPONENT_INIT…");
288
- if (!def || !theme) return say(`Loading ${defUri ?? "component"}…`);
292
+ if (o.store && !o.store.getType(o.chatId ?? "", o.nodeId ?? "")) return hold();
293
+ if (!def || !theme) return hold();
289
294
  // Defaults beneath; the app's state, then the definition's own state, then the
290
295
  // instance's values above; a value the component wrote itself on top (static source).
291
296
  const merged = o.store
@@ -534,8 +534,12 @@ export function inputView(ctx: RenderCtx): Prim {
534
534
  const max = maxLength();
535
535
  // Focus moves BEFORE the store round-trip, so a fast typist's next key lands next door.
536
536
  if (advanceOnFill() && max != null && t.length >= max) stepFocus(1);
537
- if (field() != null) ctx.onAction?.("input", { text: t, field: field() });
538
- else local = t;
537
+ if (field() != null) {
538
+ ctx.onAction?.("input", { text: t, field: field() });
539
+ // The store tells the page on the next frame; until then the field shows what was typed,
540
+ // not the old value, so a fast typist's next key lands after it, not in an emptied box.
541
+ data = { ...data, [field()!]: t };
542
+ } else local = t;
539
543
  show();
540
544
  };
541
545
  const submit = () => {
@@ -67,7 +67,7 @@ const avatars = new Set<HTMLElement>();
67
67
  const CALL_PHASES = ["listening", "speaking"];
68
68
 
69
69
  /** The workflow a template names in its manifest (UNOVERSE_HOST_PAGE.md rule 4). */
70
- export type Binding = { workflow?: string; trigger?: string };
70
+ export type Binding = { workflow?: string; trigger?: string; pipeline?: string };
71
71
 
72
72
  /** THE TASK IN VIEW: the marked template, not an avatar, spanning the middle of the screen. */
73
73
  function taskInView(doc: Document): HTMLElement | null {
@@ -152,6 +152,7 @@ function mountPageTemplate(o: PageTemplateOptions): { refresh(): void; destroy()
152
152
  // `props`, keyed by part, merge into those parts. THE SAME TEMPLATE, STREAMED, hydrates this
153
153
  // copy part by part; a copy still on its first step moves to the next as its values land.
154
154
  let lastProps = "";
155
+ let lastStep: string | undefined;
155
156
  let lastStreamed = "";
156
157
  const lastPart = new Map<string, string>();
157
158
  const fillParts = (byPart: Record<string, unknown>) => {
@@ -176,7 +177,12 @@ function mountPageTemplate(o: PageTemplateOptions): { refresh(): void; destroy()
176
177
  fillParts(task.props);
177
178
  }
178
179
  }
179
- moveTo(task?.step);
180
+ // A NEW STEP MOVES IT, once. A step the person took since (Enter) stands until the task
181
+ // publishes another: the latest move wins, wherever it was made.
182
+ if (task?.step !== lastStep) {
183
+ lastStep = task?.step;
184
+ moveTo(task?.step);
185
+ }
180
186
  for (const r of appStore.getResponses()) {
181
187
  for (const pointer of r.components) {
182
188
  const { chatId, nodeId } = appStore.split(pointer);
@@ -288,7 +294,7 @@ function mountPageTemplate(o: PageTemplateOptions): { refresh(): void; destroy()
288
294
  .then((raw) => {
289
295
  if (!alive || !raw) return;
290
296
  const d = raw as UnoverseDefinition & { defaultLayout?: string; layouts?: Record<string, unknown>; sections?: Record<string, Section[]>; binding?: Binding; stateTree?: StateTree };
291
- if (d.binding?.workflow) o.onBinding(uri, d.binding);
297
+ if (d.binding?.workflow || d.binding?.pipeline) o.onBinding(uri, d.binding);
292
298
  def = d;
293
299
  const base = d.defaultLayout ?? (d.layouts ? Object.keys(d.layouts)[0] : undefined);
294
300
  parts = [];
@@ -299,6 +305,8 @@ function mountPageTemplate(o: PageTemplateOptions): { refresh(): void; destroy()
299
305
  parts.push(nodeId);
300
306
  store.apply({ type: "COMPONENT_INIT", chatId: CHAT, nodeId, component: { type: ref, props: sec.seeds?.[ref] ?? {} } });
301
307
  if (sec.face) store.mergeComponentState(CHAT, nodeId, { state: sec.face });
308
+ // EVERY PART READ NOW, not when its face first shows: a step onto a slide draws at once.
309
+ client.readDefinition(`unoverse://components/${ref}`).catch(() => {});
302
310
  });
303
311
  }
304
312
  const ref = uri.replace(/^unoverse:\/\/templates\//, "");
@@ -366,7 +374,7 @@ export function mountPageInterfaces(o: PageInterfacesOptions): { destroy(): void
366
374
  if (action === "submit" || action === "cancel") {
367
375
  o.toServer(action, data, meta);
368
376
  const state = (meta?.state ?? data) as Record<string, unknown> | undefined;
369
- if (action === "submit" && bindings[uri]?.workflow && state && typeof state === "object") o.startTask(bindings[uri], uri, state);
377
+ if (action === "submit" && (bindings[uri]?.workflow || bindings[uri]?.pipeline) && state && typeof state === "object") o.startTask(bindings[uri], uri, state);
370
378
  } else if (action === "startCall") {
371
379
  const t = target();
372
380
  if (!t || started.has(t)) return;