@unoverse-platform/studio 0.1.96 → 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 +3 -3
  2. package/local/authoring.ts +1 -1
  3. package/local/catalog.ts +22 -2
  4. package/local/keys.ts +1 -1
  5. package/local/nodes.ts +29 -42
  6. package/local/plugin.ts +75 -13
  7. package/local/sample-identity/brand.yaml +24 -0
  8. package/local/sample-identity/organisation.yaml +14 -0
  9. package/local/sample-identity/purpose.yaml +14 -0
  10. package/local/sample-identity/story.yaml +18 -0
  11. package/local/scaffold.mjs +51 -22
  12. package/local/source.ts +4 -9
  13. package/package.json +1 -1
  14. package/screens/AgentsView.tsx +173 -0
  15. package/screens/ComponentsView.tsx +2 -2
  16. package/screens/EmptyLibrary.tsx +1 -1
  17. package/screens/IdentityHeader.tsx +1 -1
  18. package/screens/IdentityView.tsx +206 -0
  19. package/screens/PromptBlocksView.tsx +1 -1
  20. package/screens/SourceView.tsx +1 -1
  21. package/screens/TemplatesLibraryView.tsx +91 -6
  22. package/screens/TemplatesView.tsx +24 -9
  23. package/screens/index.ts +5 -2
  24. package/src/App.tsx +14 -4
  25. package/src/NodeKeys.tsx +1 -1
  26. package/src/host.ts +3 -16
  27. package/vendor/base/src/agent-mcp/discovery.ts +76 -18
  28. package/vendor/base/src/agent-mcp/handoff.ts +11 -31
  29. package/vendor/base/src/agent-mcp/index.ts +5 -5
  30. package/vendor/base/src/agent-mcp/invoke.ts +37 -19
  31. package/vendor/base/src/agent-mcp/lean.ts +19 -0
  32. package/vendor/base/src/agent-mcp/memoryContext.ts +15 -9
  33. package/vendor/base/src/agent-mcp/memoryIngest.ts +8 -3
  34. package/vendor/base/src/agent-mcp/rowComponents.ts +184 -72
  35. package/vendor/base/src/agent-mcp/tools.ts +136 -7
  36. package/vendor/base/src/agent-mcp/types.ts +47 -10
  37. package/vendor/base/src/definitions/load/compile/component.ts +59 -4
  38. package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
  39. package/vendor/base/src/definitions/load/compile/template.ts +19 -15
  40. package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
  41. package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
  42. package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
  43. package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
  44. package/vendor/base/src/definitions/load/load.ts +37 -28
  45. package/vendor/base/src/definitions/model.ts +8 -12
  46. package/vendor/base/src/definitions/registry/agents.ts +111 -0
  47. package/vendor/base/src/definitions/registry/catalog.ts +28 -12
  48. package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
  49. package/vendor/base/src/definitions/registry/homes.ts +69 -5
  50. package/vendor/base/src/definitions/registry/objects.ts +413 -0
  51. package/vendor/base/src/definitions/registry/orgDocuments.ts +107 -0
  52. package/vendor/base/src/definitions/registry/orgPack.ts +50 -0
  53. package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
  54. package/vendor/base/src/definitions/theme.ts +1 -2
  55. package/vendor/base/src/items/collect.ts +33 -25
  56. package/vendor/base/src/items/publish.ts +31 -7
  57. package/vendor/base/src/lint/context.mjs +17 -0
  58. package/vendor/base/src/lint/design/file.mjs +199 -2
  59. package/vendor/base/src/lint/design/index.mjs +66 -73
  60. package/vendor/base/src/lint/design/vocabulary.mjs +6 -3
  61. package/vendor/base/src/lint/design/walk.mjs +14 -4
  62. package/vendor/base/src/lint/index.mjs +15 -9
  63. package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
  64. package/vendor/base/src/lint/nodes/index.mjs +6 -1
  65. package/vendor/base/src/lint/nodes/node.mjs +157 -7
  66. package/vendor/base/src/lint/objects/index.d.mts +32 -0
  67. package/vendor/base/src/lint/objects/index.mjs +324 -0
  68. package/vendor/base/src/lint/objects/rules.d.mts +44 -0
  69. package/vendor/base/src/lint/objects/rules.mjs +446 -0
  70. package/vendor/base/src/lint/prompts/index.d.mts +34 -0
  71. package/vendor/base/src/lint/prompts/index.mjs +198 -0
  72. package/vendor/base/src/manifests/compose.ts +81 -10
  73. package/vendor/base/src/manifests/executor/callback.ts +15 -4
  74. package/vendor/base/src/manifests/executor/context.ts +12 -1
  75. package/vendor/base/src/manifests/executor/memory.ts +83 -3
  76. package/vendor/base/src/manifests/executor/session.ts +50 -0
  77. package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
  78. package/vendor/base/src/manifests/index.ts +1 -1
  79. package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
  80. package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
  81. package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
  82. package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
  83. package/vendor/base/src/manifests/runtime/context.ts +18 -6
  84. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
  85. package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
  86. package/vendor/base/src/manifests/runtime/events.ts +39 -3
  87. package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
  88. package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
  89. package/vendor/base/src/manifests/runtime/index.ts +3 -2
  90. package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
  91. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
  92. package/vendor/base/src/manifests/runtime/usage.ts +10 -0
  93. package/vendor/base/src/manifests/source.ts +1 -1
  94. package/vendor/base/src/paths.ts +16 -8
  95. package/vendor/base/src/platform/audioManager.ts +32 -0
  96. package/vendor/base/src/platform/executionContext.ts +14 -0
  97. package/vendor/base/src/platform/liveInput.ts +52 -0
  98. package/vendor/base/src/plugins/README.md +1 -1
  99. package/vendor/base/src/plugins/discovery.ts +1 -1
  100. package/vendor/base/src/template/SafeExpression.ts +6 -0
  101. package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
  102. package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
  103. package/vendor/sdk/appEngine.tsx +31 -12
  104. package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
  105. package/vendor/sdk/lib/app.tsx +70 -95
  106. package/vendor/sdk/lib/connection.tsx +47 -57
  107. package/vendor/sdk/lib/core/actions.ts +6 -0
  108. package/vendor/sdk/lib/core/apps.ts +66 -81
  109. package/vendor/sdk/lib/core/client.ts +5 -14
  110. package/vendor/sdk/lib/core/connection.ts +30 -0
  111. package/vendor/sdk/lib/core/index.ts +2 -2
  112. package/vendor/sdk/lib/core/store.ts +90 -15
  113. package/vendor/sdk/lib/core/types.ts +9 -8
  114. package/vendor/sdk/lib/primitives.tsx +113 -4
  115. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
  116. package/vendor/sdk/lib/render.tsx +3 -3
  117. package/vendor/sdk/lib/voice.tsx +27 -13
  118. package/vite.config.ts +1 -1
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The prompts linter: Agent skills and prompt blocks, as a LIBRARY. Rules in, findings out.
3
+ *
4
+ * A FOURTH SIBLING of ../design/, ../nodes/ and ../objects/ (2026-09-18). Until it existed
5
+ * nothing read a SKILL.md or a block before publish, and the run-time loaders drop a file
6
+ * they cannot parse with one console line and `null`. So a skill whose description held a
7
+ * bare colon (YAML reads `a: b: c` as a nested key) was published, installed, and absent
8
+ * from every list, and the first place anyone met the failure was an empty Studio screen.
9
+ * The author is the only person who can fix it, so the author is who this tells, at the
10
+ * line.
11
+ *
12
+ * What it holds a skill to is the same discoverability contract nodes are held to
13
+ * (https://docs.unoverse.ai/nodes/node-discoverability): a `whenToUse` that exists and
14
+ * carries selection signal, because a skill is found by ranking that text and nothing else.
15
+ *
16
+ * Callers: `unoverse lint` and `POST /publish` through items/publish lintForPublish; CI.
17
+ * Prints nothing, exits nothing, throws nothing for a lint failure.
18
+ *
19
+ * `options.overlay` maps an absolute path to text that STANDS IN for what is on disk, so an
20
+ * editor can lint what the developer has typed before it is saved.
21
+ */
22
+ import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
23
+ import { join, resolve, relative, basename } from "node:path";
24
+ import { createRequire } from "node:module";
25
+ import { problems, reset, report } from "../context.mjs";
26
+
27
+ const require_ = createRequire(import.meta.url);
28
+ let YAML;
29
+ try {
30
+ YAML = require_("yaml");
31
+ } catch {
32
+ throw new Error("the prompts linter needs yaml (npm install -D yaml)");
33
+ }
34
+
35
+ const DISCOVERABILITY = "https://docs.unoverse.ai/nodes/node-discoverability";
36
+ const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
37
+
38
+ const isDir = (p) => existsSync(p) && statSync(p).isDirectory();
39
+ const isRecord = (x) => !!x && typeof x === "object" && !Array.isArray(x);
40
+
41
+ /**
42
+ * Split a markdown file into frontmatter and body the way the run-time loaders do
43
+ * (gray-matter: a `---` line first, a `---` line closing). Returns
44
+ * `{ data, body, error, line }`: `data` is null when there is no block or it does not parse,
45
+ * and `line` is where in the FILE the parser stopped, so the finding lands on it.
46
+ */
47
+ export function splitFrontmatter(text) {
48
+ const lines = text.split(/\r?\n/);
49
+ if (lines[0]?.trim() !== "---") return { data: null, body: text, error: "no frontmatter block: the file must open with a --- line", line: 1 };
50
+ const close = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
51
+ if (close < 0) return { data: null, body: "", error: "the frontmatter never closes: no second --- line", line: 1 };
52
+ const yamlText = lines.slice(1, close).join("\n");
53
+ const body = lines.slice(close + 1).join("\n");
54
+ try {
55
+ const data = YAML.parse(yamlText) ?? {};
56
+ if (!isRecord(data)) return { data: null, body, error: "the frontmatter is not a mapping of key: value lines", line: 2 };
57
+ return { data, body, error: null, line: 0 };
58
+ } catch (e) {
59
+ const at = e?.linePos?.[0]?.line ?? e?.linePos?.line;
60
+ const line = typeof at === "number" ? at + 1 : 2; // +1: the opening --- is line 1
61
+ return { data: null, body, error: e.message.split("\n")[0], line };
62
+ }
63
+ }
64
+
65
+ /**
66
+ * A YAML error most authors will hit once: a plain value with `: ` inside it. The parser's
67
+ * own sentence is right and unhelpful ("nested mappings are not allowed"); this one says
68
+ * what to do.
69
+ */
70
+ function yamlHint(error, yamlLine) {
71
+ if (/nested mapping|implicit map keys|mapping pair|key node is missed/i.test(error) && /:\s/.test(yamlLine.replace(/^[^:]*:\s*/, "")))
72
+ return `${error}. A value holding a colon followed by a space must be quoted: description: "One thing: another"`;
73
+ return error;
74
+ }
75
+
76
+ function lintSkillFile(file, text, folderName, at) {
77
+ const { data, body, error, line } = splitFrontmatter(text);
78
+ if (!data) {
79
+ const src = text.split(/\r?\n/)[line - 1] ?? "";
80
+ report("error", at, `skill ${folderName}: ${yamlHint(error, src)}. The loaders skip a file they cannot parse, so this skill would not exist`, line);
81
+ return;
82
+ }
83
+ const name = typeof data.name === "string" ? data.name.trim() : "";
84
+ if (!name) report("error", at, `skill ${folderName}: name is missing; a SKILL.md without one does not load`);
85
+ else if (!KEBAB.test(name)) report("error", at, `skill ${folderName}: name "${name}" must be kebab-case: lowercase letters, digits and hyphens`);
86
+ else if (name !== folderName) report("error", at, `skill ${folderName}: name "${name}" differs from its folder; the folder is the lookup name and the two must agree`);
87
+
88
+ const description = typeof data.description === "string" ? data.description.trim() : "";
89
+ if (!description) report("error", at, `skill ${folderName}: description is missing; a SKILL.md without one does not load`);
90
+ else if (/\n/.test(description)) report("error", at, `skill ${folderName}: description must be one line; it is the listing subtitle`);
91
+ else if (description.length > 200) report("warn", at, `skill ${folderName}: description is ${description.length} characters; the listing subtitle reads best under 160`);
92
+
93
+ const w = typeof data.whenToUse === "string" ? data.whenToUse.trim() : "";
94
+ if (!w) report("error", at, `skill ${folderName}: whenToUse is missing. A skill is found by ranking this text; without it the skill cannot be discovered (${DISCOVERABILITY})`);
95
+ else {
96
+ if (description && w.toLowerCase() === description.toLowerCase()) report("error", at, `skill ${folderName}: whenToUse merely repeats description. It carries zero selection signal (${DISCOVERABILITY})`);
97
+ if (/^use this (skill|when)/i.test(w)) report("warn", at, `skill ${folderName}: whenToUse opens by restating itself. Lead with the OUTCOME in the user's own words (${DISCOVERABILITY})`);
98
+ if (/^(this is an?|a powerful|a comprehensive|an? (skill|guide) (for|that))/i.test(w)) report("warn", at, `skill ${folderName}: whenToUse opens with a label or marketing. Outcome first; it sinks the ranking (${DISCOVERABILITY})`);
99
+ }
100
+
101
+ if (data.triggers !== undefined && !(Array.isArray(data.triggers) && data.triggers.every((t) => typeof t === "string")))
102
+ report("error", at, `skill ${folderName}: triggers must be a list of phrases`);
103
+ if (data.title !== undefined && typeof data.title !== "string") report("error", at, `skill ${folderName}: title must be text`);
104
+ if (!data.title) report("hint", at, `skill ${folderName}: no title; the name "${name || folderName}" is what a person will see`);
105
+
106
+ if (!body.trim()) report("error", at, `skill ${folderName}: the body is empty; frontmatter alone is not a skill`);
107
+ }
108
+
109
+ function lintBlockFile(file, text, at) {
110
+ const id = basename(file, ".md");
111
+ const { data, body, error, line } = splitFrontmatter(text);
112
+ if (!data) {
113
+ const src = text.split(/\r?\n/)[line - 1] ?? "";
114
+ report("error", at, `block ${id}: ${yamlHint(error, src)}. The loaders skip a file they cannot parse, so this block would not exist`, line);
115
+ return;
116
+ }
117
+ if (!KEBAB.test(id)) report("error", at, `block ${id}: the filename must be kebab-case; it becomes the block's {{prompt.<camelCase>}} reference`);
118
+ if (typeof data.name !== "string" || !data.name.trim()) report("error", at, `block ${id}: name is missing`);
119
+ if (typeof data.description !== "string" || !data.description.trim()) report("error", at, `block ${id}: description is missing; it says what the block contributes to a prompt`);
120
+ if (data.tags !== undefined && !(Array.isArray(data.tags) && data.tags.every((t) => typeof t === "string"))) report("error", at, `block ${id}: tags must be a list of words`);
121
+ if (!body.trim()) report("error", at, `block ${id}: the body is empty`);
122
+ else if (/\{\{\s*prompt\./.test(body)) report("error", at, `block ${id}: references another block with {{prompt.…}}. A block stands alone; a node composes blocks, a block never does`);
123
+ }
124
+
125
+ /**
126
+ * Lint every skill and prompt block under `designRoot`: the marketplace tier and every org's.
127
+ * Returns `{ problems, skillCount, blockCount, designRoot }`.
128
+ */
129
+ export function lintPrompts(designRoot, options = {}) {
130
+ const ROOT = resolve(designRoot);
131
+ reset(ROOT, "");
132
+ const overlay = {};
133
+ for (const [k, v] of Object.entries(options.overlay ?? {})) overlay[resolve(k)] = v;
134
+ const readText = (f) => (overlay[resolve(f)] === undefined ? readFileSync(f, "utf8") : overlay[resolve(f)]);
135
+ const present = (f) => existsSync(f) || overlay[resolve(f)] !== undefined;
136
+ const rel = (f) => (relative(process.cwd(), f).startsWith("..") ? f : relative(process.cwd(), f));
137
+
138
+ if (!isDir(ROOT)) {
139
+ report("error", ROOT, "no design/ folder here");
140
+ return { problems: [...problems], skillCount: 0, blockCount: 0, designRoot: ROOT };
141
+ }
142
+
143
+ // ── the tiers: the marketplace, every flat org folder, and the legacy orgs/ container ──
144
+ const tiers = [];
145
+ const marketplace = join(ROOT, "marketplace");
146
+ if (isDir(marketplace)) tiers.push({ org: undefined, dir: marketplace });
147
+ const orgDirs = (base) => readdirSync(base).filter((e) => !e.startsWith(".") && !e.startsWith("_") && e !== "marketplace" && e !== "orgs" && isDir(join(base, e)));
148
+ for (const e of orgDirs(ROOT)) tiers.push({ org: e, dir: join(ROOT, e) });
149
+ if (isDir(join(ROOT, "orgs"))) for (const e of orgDirs(join(ROOT, "orgs"))) tiers.push({ org: e, dir: join(ROOT, "orgs", e) });
150
+
151
+ let skillCount = 0;
152
+ let blockCount = 0;
153
+ for (const tier of tiers) {
154
+ const skills = join(tier.dir, "skills");
155
+ if (isDir(skills)) {
156
+ for (const e of readdirSync(skills, { withFileTypes: true })) {
157
+ if (e.name.startsWith(".")) continue;
158
+ const p = join(skills, e.name);
159
+ if (!e.isDirectory()) {
160
+ if (e.name.endsWith(".md")) report("warn", rel(p), `a skill is a folder holding SKILL.md, not a loose file; ${e.name} is not read`);
161
+ continue;
162
+ }
163
+ const file = join(p, "SKILL.md");
164
+ if (!present(file)) {
165
+ report("error", rel(file), `skill ${e.name}: the folder has no SKILL.md, so it is not a skill`);
166
+ continue;
167
+ }
168
+ skillCount++;
169
+ lintSkillFile(file, readText(file), e.name, rel(file));
170
+ }
171
+ }
172
+ const blocks = join(tier.dir, "blocks");
173
+ if (isDir(blocks)) {
174
+ const walk = (dir) => {
175
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
176
+ if (e.name.startsWith(".")) continue;
177
+ const p = join(dir, e.name);
178
+ if (e.isDirectory()) walk(p);
179
+ else if (e.name.endsWith(".md")) {
180
+ if (dir === blocks) report("warn", rel(p), `a block lives in a category folder (blocks/<category>/<name>.md); ${e.name} sits at the root and has no category`);
181
+ blockCount++;
182
+ lintBlockFile(p, readText(p), rel(p));
183
+ }
184
+ }
185
+ };
186
+ walk(blocks);
187
+ }
188
+ }
189
+
190
+ const rank = { error: 0, warn: 1, hint: 2 };
191
+ const sorted = [...problems].sort((a, b) => rank[a.level] - rank[b.level] || a.file.localeCompare(b.file));
192
+ return { problems: sorted, skillCount, blockCount, designRoot: ROOT };
193
+ }
194
+
195
+ /** True when anything would fail a build. Warnings and hints inform, errors stop. */
196
+ export function hasErrors(result) {
197
+ return result.problems.some((p) => p.level === "error");
198
+ }
@@ -13,7 +13,7 @@
13
13
  * scripts/lib/lint-nodes.mjs, which must run without the server (it ships in the
14
14
  * starter kit). Keep the two in step; the linter is the one developers see first.
15
15
  *
16
- * See docs/architecture/DECLARATIVE_NODES.md §5.
16
+ * See docs/architecture/authoring/DECLARATIVE_NODES.md §5.
17
17
  */
18
18
  import * as YAML from "yaml";
19
19
  import type { RawNode, RawPackage } from "./source.js";
@@ -33,6 +33,34 @@ const SECTIONS = ["interface", "config", "api", "test"] as const;
33
33
  * runnable node does. The second path imports this constant rather than restating it: two
34
34
  * copies of a control that decides who may run something is how they drift.
35
35
  */
36
+ /**
37
+ * THE ONE HOLE IN THE WITHHOLDING RULE, and it is platform chrome so there is exactly one of it.
38
+ *
39
+ * NodeEffects.ts says there is no flag to override withholding, because an effect with no
40
+ * inverse is not something a test gets to do. That is right for a sent email or a charged card,
41
+ * and it makes such a node IMPOSSIBLE TO TRY: every way of running a workflow while building it
42
+ * is a test run, so the effect is traced and never performed, and the author cannot find out
43
+ * whether any of it works.
44
+ *
45
+ * So the hole exists, and these are its terms: OFF by default, injected only into nodes that
46
+ * actually emit, worded as what it does rather than as a mode, and the same field on every one
47
+ * of them — a per-vendor version of this would drift into five spellings of one idea.
48
+ *
49
+ * Injected beside the access chrome and for the same reason: it is a setting about the RUN, not
50
+ * about the job, so no author writes it.
51
+ */
52
+ export const EMIT_CHROME = {
53
+ emitOnTestRun: {
54
+ type: "boolean",
55
+ title: "Perform on test runs",
56
+ description:
57
+ "This step acts outside the platform and cannot be undone, so test runs trace it rather " +
58
+ "than perform it. Enable only while testing this step, then switch it off.",
59
+ default: false,
60
+ "ui:widget": "toggle",
61
+ },
62
+ } as const;
63
+
36
64
  export const RUN_AUTH_CHROME = {
37
65
  authRequired: {
38
66
  type: "boolean",
@@ -77,7 +105,13 @@ function deepMerge(base: any, over: any): any {
77
105
 
78
106
  export interface ComposedNode {
79
107
  type: string;
80
- kind: "PromiseNode" | "CallbackNode";
108
+ /**
109
+ * `Attachment` is the one kind that is not about how a node RUNS, because it does not run
110
+ * (DECLARATIVE_NODES.md §14). It clips onto another node and overrides that node's config,
111
+ * so it has no api, no inputs and no outputs, and nothing downstream ever executes it. It
112
+ * composes like any other node because the canvas still has to find it in the catalog.
113
+ */
114
+ kind: "PromiseNode" | "CallbackNode" | "Attachment";
81
115
  packageName: string;
82
116
  /**
83
117
  * Hosts this node may call, from its package's `allowedHosts`. Empty means NONE: a
@@ -399,8 +433,10 @@ export function composeNode(raw: RawNode, pkg: RawPackage): ComposedNode {
399
433
  );
400
434
 
401
435
  if (!node.type) throw new ManifestError(`${where}: node.yaml has no type`);
402
- if (node.kind !== "PromiseNode" && node.kind !== "CallbackNode")
403
- throw new ManifestError(`${where}: node.yaml kind must be PromiseNode or CallbackNode, got ${JSON.stringify(node.kind)}`);
436
+ if (node.kind !== "PromiseNode" && node.kind !== "CallbackNode" && node.kind !== "Attachment")
437
+ throw new ManifestError(
438
+ `${where}: node.yaml kind must be PromiseNode, CallbackNode or Attachment, got ${JSON.stringify(node.kind)}`,
439
+ );
404
440
 
405
441
  const iface = parts.interface ?? {};
406
442
  const config = parts.config ?? {};
@@ -462,15 +498,41 @@ export function composeNode(raw: RawNode, pkg: RawPackage): ComposedNode {
462
498
  const authoredProps = { ...(authoredSchema.properties ?? {}) };
463
499
  delete authoredProps.authRequired;
464
500
  delete authoredProps.authRole;
465
- const composedSchema = annotation
501
+ /**
502
+ * DOES THIS NODE REACH OUTSIDE? Its own capabilities say so, and for an ATTACHMENT what it
503
+ * contributes to its host says so: a phone line declares nothing about itself, it makes the
504
+ * voice node it clips onto emit (DECLARATIVE_NODES.md §14). Either way the switch belongs on
505
+ * the box a person opens, which is the box that declared it.
506
+ */
507
+ const emits = node.capabilities?.emitsExternally === true || iface.attaches?.capabilities?.emitsExternally === true;
508
+ const composedSchema = annotation && !emits
466
509
  ? authoredSchema
467
- : { type: "object", ...authoredSchema, properties: { ...authoredProps, ...RUN_AUTH_CHROME } };
510
+ : {
511
+ type: "object",
512
+ ...authoredSchema,
513
+ properties: {
514
+ ...authoredProps,
515
+ ...(emits ? EMIT_CHROME : {}),
516
+ // An attachment never runs, so WHO may run it is a question it does not have.
517
+ ...(annotation ? {} : RUN_AUTH_CHROME),
518
+ },
519
+ };
468
520
  const authoredOrder: string[] | null = config["ui:order"] ?? null;
469
521
  const composedOrder =
470
- annotation || !authoredOrder
522
+ !authoredOrder || (annotation && !emits)
471
523
  ? authoredOrder
472
- : [...authoredOrder.filter((f) => f !== "authRequired" && f !== "authRole"), "authRequired", "authRole"];
473
- const derived = streams || spawns || api?.toolExchange ? "CallbackNode" : "PromiseNode";
524
+ : [
525
+ ...authoredOrder.filter((f) => f !== "authRequired" && f !== "authRole" && f !== "emitOnTestRun"),
526
+ ...(emits ? ["emitOnTestRun"] : []),
527
+ ...(annotation ? [] : ["authRequired", "authRole"]),
528
+ ];
529
+ /**
530
+ * AN ATTACHMENT IS NOT DERIVED, because there is nothing to derive it from. Every signal the
531
+ * rule below reads — a streaming transport, a SPAWN port, a tool exchange — is a way of
532
+ * running, and an attachment declares none of them. Deriving would call it a PromiseNode and
533
+ * then refuse the manifest for saying Attachment, which is the declaration being right.
534
+ */
535
+ const derived = node.kind === "Attachment" ? "Attachment" : streams || spawns || api?.toolExchange ? "CallbackNode" : "PromiseNode";
474
536
  if (node.kind !== derived)
475
537
  throw new ManifestError(
476
538
  `${where}: declares kind "${node.kind}" but is a ${derived} (` +
@@ -492,6 +554,9 @@ export function composeNode(raw: RawNode, pkg: RawPackage): ComposedNode {
492
554
  definition: {
493
555
  type: node.type,
494
556
  name: node.name ?? node.type,
557
+ // What the CARD calls it, when the full name is too long for a box. Null falls back to
558
+ // `name` at the point of drawing, so an author who omits it changes nothing.
559
+ displayName: node.displayName ?? null,
495
560
  description: node.description ?? "",
496
561
  // The catalog ranker embeds this; losing it here would make the node invisible
497
562
  // to the building agent no matter how well it works.
@@ -513,6 +578,12 @@ export function composeNode(raw: RawNode, pkg: RawPackage): ComposedNode {
513
578
  logoUrl: node.logoUrl ?? pkgMeta.logoUrl ?? null,
514
579
  // How the canvas draws it. Absent means the ordinary node card.
515
580
  template: node.template ?? null,
581
+ // The card's menu entries (a person's faces on this node), carried verbatim to the canvas.
582
+ menu: Array.isArray(node.menu) ? node.menu : null,
583
+ // ATTACHMENTS (§14). What this one does to the node it clips onto: the host config
584
+ // fields it owns, and the credentials it contributes. Null for every ordinary node,
585
+ // which is also how the canvas tells the two apart without reading `kind`.
586
+ attaches: iface.attaches ?? null,
516
587
  visibility: node.visibility ?? "public",
517
588
  capabilities: node.capabilities ?? null,
518
589
  inputs: iface.inputs ?? [],
@@ -534,6 +605,6 @@ export function composeCredentials(pkg: RawPackage): Record<string, any>[] {
534
605
  return Object.entries(pkg.credentials).map(([file, body]) => {
535
606
  const doc = parse(body, `${pkg.name}/credentials/${file}`);
536
607
  if (!doc.name) throw new ManifestError(`${pkg.name}/credentials/${file}: no name`);
537
- return { name: doc.name, displayName: doc.displayName ?? doc.name, description: doc.description ?? "", properties: doc.properties ?? [] };
608
+ return { name: doc.name, displayName: doc.displayName ?? doc.name, description: doc.description ?? "", category: doc.category ?? null, visibility: doc.visibility ?? "public", properties: doc.properties ?? [] };
538
609
  });
539
610
  }
@@ -6,7 +6,7 @@ import { contextFor } from "./context.js";
6
6
  import { sessionFor, stateStoreFor, audioLaneFor } from "./session.js";
7
7
  import { refireAfterDocMutation } from "./refire.js";
8
8
  import { toolBridgeFor } from "./toolBridge.js";
9
- import { hydrateMemory, ingestMemoryTurn } from "./memory.js";
9
+ import { hydrateMemory, ingestMemoryTurn, sessionTurnRecorder } from "./memory.js";
10
10
 
11
11
  export class ManifestCallbackExecutor {
12
12
  logger: any = console;
@@ -66,8 +66,12 @@ export class ManifestCallbackExecutor {
66
66
  // The USER-MEMORY lane, before the run: when the node's config declares the toggle,
67
67
  // the person's context snapshot rides the prompt (memory.ts — the executor's job,
68
68
  // never a manifest's). No toggle, no prompt, headless → passes through untouched.
69
- const hydratedConfig = await hydrateMemory(event.config, executionContext ?? this.executionContext);
69
+ // A SESSION is a run that holds a socket for the length of a call: the block rides the
70
+ // replayed history instead of a prompt, and turns are saved as they complete (below).
71
+ const session = Array.isArray(node.api?.run) && node.api.run.some((r: any) => r?.transport === "ws");
72
+ const hydratedConfig = await hydrateMemory(event.config, executionContext ?? this.executionContext, { session });
70
73
  const ctx = contextFor(node, event.inputs, hydratedConfig, executionContext ?? this.executionContext);
74
+ const turns = session ? sessionTurnRecorder(node.type, event.config, executionContext ?? this.executionContext) : null;
71
75
 
72
76
  let emitted = 0;
73
77
  const { outputs } = await performApi(
@@ -79,6 +83,7 @@ export class ManifestCallbackExecutor {
79
83
  // executor boundary, which is the only layer holding the execution context.
80
84
  if (!e.emit) return;
81
85
  emitted++;
86
+ turns?.see(e.emit, e.value);
82
87
  emit({ __outputs: { [e.emit]: e.value } });
83
88
  },
84
89
  node.api?.toolExchange ? await toolBridgeFor(executionContext ?? this.executionContext) : undefined,
@@ -93,14 +98,20 @@ export class ManifestCallbackExecutor {
93
98
 
94
99
  // The settled result: whatever `finalize` produced, plus any connector the stream
95
100
  // never touched. Emitting the last streamed value again would double it.
96
- const streamed = new Set(node.api?.response?.events?.map((r: any) => r.emit) ?? []);
101
+ //
102
+ // The events table is `api.events`. This read `api.response.events`, a key no manifest has,
103
+ // so NOTHING counted as streamed and every connector was emitted a second time at the end:
104
+ // each downstream node fired twice on a final value, and a voice node's hand-off re-ran its
105
+ // backend at hang-up (observed live 2026-09-12).
106
+ const streamed = new Set((node.api?.events ?? []).map((r: any) => r.emit).filter(Boolean));
97
107
  const settled = Object.fromEntries(Object.entries(outputs).filter(([k]) => !streamed.has(k)));
98
108
  if (Object.keys(settled).length) emit({ __outputs: settled });
99
109
 
100
110
  // The turn complete — original input + final answer to user memory (fire-and-forget,
101
111
  // self-gating on the toggle; the ORIGINAL prompt, not the hydrated one, because the
102
112
  // memory block prepended above must not be re-ingested as something the user said).
103
- await ingestMemoryTurn(node.type, event.config, executionContext ?? this.executionContext, outputs);
113
+ if (turns) await turns.flush();
114
+ else await ingestMemoryTurn(node.type, event.config, executionContext ?? this.executionContext, outputs);
104
115
 
105
116
  return { ...state, emitted, isComplete: true };
106
117
  }
@@ -30,6 +30,11 @@
30
30
  import type { ComposedNode } from "../compose.js";
31
31
  import { applyResolvers, emptyContext, type RunContext } from "../runtime/index.js";
32
32
 
33
+ /** THE ENGINE'S ADDRESS, derived once from the port it listens on; the tool bridge reads the same one. */
34
+ export function engineUrl(): string {
35
+ return `http://127.0.0.1:${process.env.ENGINE_PORT ?? process.env.WORKFLOW_SERVICE_PORT ?? 4101}`;
36
+ }
37
+
33
38
  export function contextFor(node: ComposedNode, inputs: any, config: any, executionContext: any): RunContext {
34
39
  const user = executionContext?.auth?.user ?? {};
35
40
  return emptyContext({
@@ -59,9 +64,15 @@ export function contextFor(node: ComposedNode, inputs: any, config: any, executi
59
64
  scope: {
60
65
  userId: executionContext?.publishingContext?.userId ?? executionContext?.workflow?.variables?.userId,
61
66
  workflowId: executionContext?.workflowId ?? executionContext?.workflow?.id,
67
+ // THE WORLD THIS RUN WORKS IN (docs/unoverse/UNOVERSE_SPATIAL_ORG.md): named by the run,
68
+ // else the canvas's default. The spatial and ingest packages send it as the map's scope.
69
+ org: executionContext?.publishingContext?.org ?? executionContext?.workflow?.org,
62
70
  conversationId:
63
71
  executionContext?.publishingContext?.conversationId ?? executionContext?.workflow?.variables?.conversationId,
64
72
  chatId: executionContext?.publishingContext?.chatId ?? executionContext?.workflow?.variables?.chatId,
73
+ // A CANVAS TEST RUN: the engine's own debug mode for this run, sent with the node's context.
74
+ // A node keeps nothing between runs for one (docs/nodes/expressions.md, "The run context").
75
+ debug: executionContext?.debugMode === true,
65
76
  executionId: executionContext?.executionId,
66
77
  // Which INSTANCE on the canvas. The retired Code node fell back to the literal "code" when
67
78
  // this was missing, which meant two Code nodes minted the same universal id and the saved
@@ -104,7 +115,7 @@ export function contextFor(node: ComposedNode, inputs: any, config: any, executi
104
115
  * Same derivation as above, and the same reasoning: loopback because these listeners
105
116
  * are internal and never published, and derived so there is no dead variable to set.
106
117
  */
107
- engineUrl: `http://127.0.0.1:${process.env.ENGINE_PORT ?? process.env.WORKFLOW_SERVICE_PORT ?? 4101}`,
118
+ engineUrl: engineUrl(),
108
119
  },
109
120
  });
110
121
  }
@@ -17,11 +17,28 @@
17
17
  * and ingest saves original input + final answer, never tool chatter.
18
18
  */
19
19
 
20
- /** Prepend the user's memory context to config.prompt when the toggle is on. */
21
- export async function hydrateMemory(config: any, executionContext: any): Promise<any> {
22
- if (config?.enableUserMemory !== true || typeof config?.prompt !== "string") return config;
20
+ /**
21
+ * Prepend the user's memory context when the toggle is on.
22
+ *
23
+ * A REQUEST node carries it on `config.prompt`: the user turn of every request.
24
+ *
25
+ * A SESSION node (a duplex socket: a voice call) has no prompt. It has a handshake, sent
26
+ * once, and a `conversationHistory` it replays as user messages before the call starts.
27
+ * The block goes in there, first, as one replayed user message: the same convention (the
28
+ * input side, never the system prompt) reached through the field the node already has, so
29
+ * no manifest changes to receive it and every vendor's replay carries it the same way.
30
+ */
31
+ export async function hydrateMemory(config: any, executionContext: any, opts: { session?: boolean } = {}): Promise<any> {
32
+ if (config?.enableUserMemory !== true) return config;
23
33
  try {
24
34
  const harness: any = await import("../../agent-mcp/index.js");
35
+ if (opts.session) {
36
+ const block: string = await harness.fetchUserMemoryContext(config, executionContext, console);
37
+ if (!block) return config;
38
+ const history = Array.isArray(config.conversationHistory) ? config.conversationHistory : [];
39
+ return { ...config, conversationHistory: [{ role: "user", content: block }, ...history] };
40
+ }
41
+ if (typeof config?.prompt !== "string") return config;
25
42
  const prompt = await harness.hydratePromptWithUserMemory(config, executionContext, console, config.prompt);
26
43
  return prompt === config.prompt ? config : { ...config, prompt };
27
44
  } catch (e: any) {
@@ -60,3 +77,66 @@ export async function ingestMemoryTurn(
60
77
  console.log(`[manifests] memory ingest failed (non-blocking): ${e?.message ?? e}`);
61
78
  }
62
79
  }
80
+
81
+ /**
82
+ * A SESSION'S TURNS, saved as they complete. A call is one run holding many turns, and the
83
+ * end-of-run ingest above would keep only the last exchange, at hang-up. Every voice node
84
+ * emits the two halves of a turn on the same two connectors: `userTranscript`, once per
85
+ * completed utterance, and `assistantTranscript`, accumulated as the model speaks and reset
86
+ * when its response ends.
87
+ *
88
+ * THE ORDER ON THE WIRE IS NOT THE ORDER OF THE CONVERSATION. The vendor starts the reply the
89
+ * moment the person stops speaking, and the transcript of what they said arrives a beat later,
90
+ * so the first words of an answer can precede the question they answer (observed live
91
+ * 2026-09-18: "How are you?" was saved against "I", and the greeting against nothing). So a
92
+ * turn is paired by what is in flight, not by arrival: a response that has just begun and has
93
+ * no question yet takes the next transcript as its question; a new response (the accumulated
94
+ * text no longer extends the last) closes the turn before it; and a transcript arriving while
95
+ * an older answer is still the pending one closes that turn and opens a fresh one. The final
96
+ * turn is flushed at the end.
97
+ *
98
+ * `null` when the toggle is off, so the emit path pays nothing.
99
+ */
100
+ export function sessionTurnRecorder(
101
+ nodeType: string,
102
+ config: any,
103
+ executionContext: any,
104
+ now: () => number = Date.now,
105
+ ): { see(emit: string, value: unknown): void; flush(): Promise<void> } | null {
106
+ if (config?.enableUserMemory !== true) return null;
107
+ /** How long after a reply begins its question may still arrive. */
108
+ const LATE_QUESTION_MS = 4000;
109
+ let pending: { query: string; response: string; responseBegan: number } | null = null;
110
+ const save = async () => {
111
+ if (!pending || (!pending.query && !pending.response)) return;
112
+ const turn = pending;
113
+ pending = null;
114
+ try {
115
+ const harness: any = await import("../../agent-mcp/index.js");
116
+ harness.ingestConversationTurn(config, executionContext, { userMessage: turn.query, agentResponse: turn.response }, console, nodeType);
117
+ } catch (e: any) {
118
+ console.log(`[manifests] memory ingest failed (non-blocking): ${e?.message ?? e}`);
119
+ }
120
+ };
121
+ return {
122
+ see(emit, value) {
123
+ if (typeof value !== "string") return;
124
+ if (emit === "assistantTranscript") {
125
+ const extendsLast = !!pending && pending.response.length > 0 && value.startsWith(pending.response);
126
+ if (pending && pending.response && !extendsLast) {
127
+ // A new response began: the previous turn is complete.
128
+ void save();
129
+ }
130
+ if (!pending) pending = { query: "", response: "", responseBegan: now() };
131
+ if (!pending.response) pending.responseBegan = now();
132
+ pending.response = value;
133
+ } else if (emit === "userTranscript") {
134
+ const lateQuestion = !!pending && !pending.query && pending.response.length > 0 && now() - pending.responseBegan <= LATE_QUESTION_MS;
135
+ if (pending && !lateQuestion) void save();
136
+ if (lateQuestion) pending!.query = value;
137
+ else pending = { query: value, response: "", responseBegan: 0 };
138
+ }
139
+ },
140
+ flush: save,
141
+ };
142
+ }
@@ -37,6 +37,56 @@ export function sessionFor(executionContext: any) {
37
37
  * for a headless run.
38
38
  */
39
39
  export function audioLaneFor(executionContext: any) {
40
+ /**
41
+ * WHICH LANE. `config.lane` is an ordinary config field on the voice node, usually written by
42
+ * an attachment (DECLARATIVE_NODES.md §14): clip a phone line on and it reads `twilio`.
43
+ *
44
+ * Absent or `browser` is the ordinary case and the default, so every voice node that existed
45
+ * before there were two lanes keeps the one it had.
46
+ */
47
+ /**
48
+ * FROM CONFIG, not from a field on the context.
49
+ *
50
+ * `lane` IS config — an ordinary field on the voice node, usually written there by an
51
+ * attachment (DECLARATIVE_NODES.md §14) — so config is where to read it, and reading it
52
+ * anywhere else was the bug. A voice node RUNS REMOTELY (`POST /execute-stream`), and that
53
+ * boundary carries `inputs`, `config` and a whitelisted `context`. A field added to the
54
+ * execution context simply does not cross it: the node ran, spoke the right words, and played
55
+ * them into a browser lane with nobody on it (observed live 2026-09-20).
56
+ *
57
+ * Config crosses because it has to. So read the lane from the thing that travels.
58
+ */
59
+ const lane = executionContext?.config?.lane ?? executionContext?.lane;
60
+ if (lane && lane !== "browser") {
61
+ const built = executionContext?.api?.getAudioLane?.({
62
+ lane,
63
+ conversationId: executionContext?.publishingContext?.conversationId ?? executionContext?.executionId,
64
+ /**
65
+ * EVERY attachment's own settings, keyed by node type, not just the one this lane came
66
+ * from. A phone number lives in here; the host's config holds only the fields an
67
+ * attachment locked.
68
+ *
69
+ * Handed over whole, because naming `attachments.TwilioLine` here would put one vendor's
70
+ * node type in the generic executor. A lane knows which attachment it belongs to; the
71
+ * thing building lanes in general does not.
72
+ */
73
+ attachments: executionContext?.config?.attachments ?? {},
74
+ credentials: executionContext?.credentials ?? {},
75
+ /**
76
+ * `debugMode` is the run's own word for a canvas test run and it DOES cross the remote
77
+ * boundary; `isTestRun` is the local execution context's name for the same thing. Either
78
+ * being true is a test run, because a lane that guessed wrong here would ring a real
79
+ * telephone once per build stage.
80
+ */
81
+ isTestRun: executionContext?.isTestRun === true || executionContext?.debugMode === true,
82
+ });
83
+ /**
84
+ * A NAMED LANE THAT COULD NOT BE BUILT DOES NOT FALL BACK TO THE BROWSER. Someone asked for
85
+ * a phone call; playing the audio into a browser tab instead would be a different thing
86
+ * happening silently. No lane is the honest answer, and the transcripts still arrive.
87
+ */
88
+ return built ?? null;
89
+ }
40
90
  return executionContext?.api?.getAudioWebSocketManager?.() ?? null;
41
91
  }
42
92