@unoverse-platform/studio 0.1.89 → 0.1.91

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 (132) hide show
  1. package/local/plugin.ts +20 -12
  2. package/package.json +13 -3
  3. package/screens/TemplatesLibraryView.tsx +31 -6
  4. package/screens/TemplatesView.tsx +1 -1
  5. package/vendor/base/src/agent-mcp/discovery.ts +203 -0
  6. package/vendor/base/src/agent-mcp/handoff.ts +56 -0
  7. package/vendor/base/src/agent-mcp/index.ts +44 -0
  8. package/vendor/base/src/agent-mcp/invoke.ts +140 -0
  9. package/vendor/base/src/agent-mcp/lean.ts +111 -0
  10. package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
  11. package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
  12. package/vendor/base/src/agent-mcp/rowComponents.ts +172 -0
  13. package/vendor/base/src/agent-mcp/tools.ts +134 -0
  14. package/vendor/base/src/agent-mcp/types.ts +130 -0
  15. package/vendor/base/src/auth/auth.ts +278 -0
  16. package/vendor/base/src/auth/authConfig.ts +73 -0
  17. package/vendor/base/src/boot.ts +245 -0
  18. package/vendor/base/src/definitions/definitions.ts +18 -0
  19. package/vendor/base/src/definitions/dsPackage.ts +45 -0
  20. package/vendor/base/src/definitions/fsCache.ts +128 -0
  21. package/vendor/base/src/definitions/index.ts +19 -0
  22. package/vendor/base/src/definitions/inputs.ts +30 -0
  23. package/vendor/base/src/definitions/load/compile/component.ts +80 -0
  24. package/vendor/base/src/definitions/load/compile/index.ts +12 -0
  25. package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
  26. package/vendor/base/src/definitions/load/compile/template.ts +153 -0
  27. package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
  28. package/vendor/base/src/definitions/load/expand/index.ts +17 -0
  29. package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
  30. package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
  31. package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
  32. package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
  33. package/vendor/base/src/definitions/load/load.ts +487 -0
  34. package/vendor/base/src/definitions/model.ts +238 -0
  35. package/vendor/base/src/definitions/registry/catalog.ts +643 -0
  36. package/vendor/base/src/definitions/registry/homes.ts +252 -0
  37. package/vendor/base/src/definitions/theme.ts +383 -0
  38. package/vendor/base/src/executor.ts +284 -0
  39. package/vendor/base/src/http/uwsFetchAdapter.ts +260 -0
  40. package/vendor/base/src/items/baseVersion.ts +96 -0
  41. package/vendor/base/src/items/collect.ts +199 -0
  42. package/vendor/base/src/items/fingerprint.ts +34 -0
  43. package/vendor/base/src/items/publish.ts +198 -0
  44. package/vendor/base/src/lint/context.mjs +56 -0
  45. package/vendor/base/src/lint/design/defs.mjs +46 -0
  46. package/vendor/base/src/lint/design/file.mjs +737 -0
  47. package/vendor/base/src/lint/design/index.d.mts +30 -0
  48. package/vendor/base/src/lint/design/index.mjs +996 -0
  49. package/vendor/base/src/lint/design/tokens.mjs +128 -0
  50. package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
  51. package/vendor/base/src/lint/design/walk.mjs +444 -0
  52. package/vendor/base/src/lint/index.mjs +28 -0
  53. package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
  54. package/vendor/base/src/lint/nodes/index.mjs +64 -0
  55. package/vendor/base/src/lint/nodes/load.mjs +126 -0
  56. package/vendor/base/src/lint/nodes/node.mjs +764 -0
  57. package/vendor/base/src/lint/nodes/package.mjs +112 -0
  58. package/vendor/base/src/lint/nodes/schema.mjs +75 -0
  59. package/vendor/base/src/logger.ts +33 -0
  60. package/vendor/base/src/manifests/compose.ts +539 -0
  61. package/vendor/base/src/manifests/executor/callback.ts +107 -0
  62. package/vendor/base/src/manifests/executor/context.ts +107 -0
  63. package/vendor/base/src/manifests/executor/index.ts +47 -0
  64. package/vendor/base/src/manifests/executor/lookup.ts +21 -0
  65. package/vendor/base/src/manifests/executor/memory.ts +62 -0
  66. package/vendor/base/src/manifests/executor/promise.ts +71 -0
  67. package/vendor/base/src/manifests/executor/refire.ts +34 -0
  68. package/vendor/base/src/manifests/executor/send.ts +65 -0
  69. package/vendor/base/src/manifests/executor/session.ts +46 -0
  70. package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
  71. package/vendor/base/src/manifests/index.ts +280 -0
  72. package/vendor/base/src/manifests/integrity.ts +93 -0
  73. package/vendor/base/src/manifests/rows.ts +33 -0
  74. package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
  75. package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
  76. package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
  77. package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
  78. package/vendor/base/src/manifests/runtime/channels/service.ts +125 -0
  79. package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
  80. package/vendor/base/src/manifests/runtime/context.ts +150 -0
  81. package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
  82. package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
  83. package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
  84. package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
  85. package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
  86. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
  87. package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
  88. package/vendor/base/src/manifests/runtime/events.ts +223 -0
  89. package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
  90. package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
  91. package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
  92. package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
  93. package/vendor/base/src/manifests/runtime/index.ts +57 -0
  94. package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
  95. package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
  96. package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
  97. package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
  98. package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
  99. package/vendor/base/src/manifests/runtime/state.ts +171 -0
  100. package/vendor/base/src/manifests/runtime/templating.ts +110 -0
  101. package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
  102. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +542 -0
  103. package/vendor/base/src/manifests/runtime/usage.ts +87 -0
  104. package/vendor/base/src/manifests/source.ts +187 -0
  105. package/vendor/base/src/manifests/watch.ts +67 -0
  106. package/vendor/base/src/markdown/document.ts +141 -0
  107. package/vendor/base/src/paths.ts +180 -0
  108. package/vendor/base/src/platform/audioManager.ts +100 -0
  109. package/vendor/base/src/platform/clientTransport.ts +65 -0
  110. package/vendor/base/src/platform/executionContext.ts +154 -0
  111. package/vendor/base/src/platform/index.ts +10 -0
  112. package/vendor/base/src/platform/pluginAPI.ts +146 -0
  113. package/vendor/base/src/platform/redis.ts +54 -0
  114. package/vendor/base/src/platform/serviceCalls.ts +222 -0
  115. package/vendor/base/src/pluginBase.ts +248 -0
  116. package/vendor/base/src/plugins/README.md +57 -0
  117. package/vendor/base/src/plugins/discovery.ts +87 -0
  118. package/vendor/base/src/plugins/index.ts +16 -0
  119. package/vendor/base/src/plugins/loader.ts +179 -0
  120. package/vendor/base/src/plugins/redis.ts +36 -0
  121. package/vendor/base/src/plugins/state.ts +53 -0
  122. package/vendor/base/src/registry.ts +95 -0
  123. package/vendor/base/src/security/permissions.ts +93 -0
  124. package/vendor/base/src/security/rules.ts +73 -0
  125. package/vendor/base/src/template/SafeExpression.ts +556 -0
  126. package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
  127. package/vendor/base/src/template/index.ts +22 -0
  128. package/vendor/base/src/types.ts +50 -0
  129. package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
  130. package/vendor/base/src/workflow/PlatformMemoryCore.ts +438 -0
  131. package/vendor/base/src/workflow/routing.ts +122 -0
  132. package/vendor/sdk/lib/core/store.ts +26 -6
package/local/plugin.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  import { designHome } from "./design-home.mjs";
19
19
  import { createRequire } from "node:module";
20
20
  import { fileURLToPath } from "node:url";
21
- import { resolve } from "path";
21
+ import { resolve, dirname } from "path";
22
22
  import { readdirSync, existsSync, readFileSync, statSync } from "fs";
23
23
  import type { Plugin, ViteDevServer } from "vite";
24
24
  import { findProjectRoot, loadCatalog } from "./catalog.js";
@@ -187,19 +187,27 @@ export function localStudioHost(): Plugin {
187
187
  * The loaders live in `@unoverse-platform/base`. Studio reuses them rather than
188
188
  * growing a second implementation that would drift.
189
189
  *
190
- * RESOLVED THROUGH NODE, not by walking the tree. This used to be
191
- * `resolve(root, "../unoverse/server/src")`, which was two problems in one: it
192
- * reached into another app's source, so Studio could never be published, and it
193
- * silently broke the moment those files moved into `base` — the paths pointed at
194
- * nothing and no test covered the path.
190
+ * TWO PLACES, IN THIS ORDER. In the monorepo base is a workspace package and Node
191
+ * finds it, so edits to packages/base are live while developing. In a published
192
+ * Studio there is no such package to find: base left npm on 2026-09-03 (it ships
193
+ * inside the core image instead — `UNOVERSE_BASE_BOUNDARY.md` §1a), so its source is
194
+ * frozen into `vendor/base/src` at pack time by scripts/vendor-base.mjs.
195
195
  *
196
- * Asking Node where the package is means it works from a checkout OR from
197
- * node_modules, which is what makes standalone Studio possible.
196
+ * The earlier version of this resolved ONLY through Node, which was right while base
197
+ * was installable and became a crash the moment it was not. Before that it was
198
+ * `resolve(root, "../unoverse/server/src")`, reaching into another app's source, which
199
+ * is what kept Studio from being publishable at all.
198
200
  */
199
- baseSrc = resolve(
200
- createRequire(import.meta.url).resolve("@unoverse-platform/base/package.json"),
201
- "../src",
202
- );
201
+ const vendored = resolve(dirname(fileURLToPath(import.meta.url)), "../vendor/base/src");
202
+ try {
203
+ baseSrc = resolve(
204
+ createRequire(import.meta.url).resolve("@unoverse-platform/base/package.json"),
205
+ "../src",
206
+ );
207
+ } catch {
208
+ baseSrc = vendored;
209
+ }
210
+ if (!existsSync(baseSrc)) baseSrc = vendored;
203
211
 
204
212
  const ssr = (id: string) => server.ssrLoadModule(id) as Promise<Record<string, unknown>>;
205
213
  const catalog = loadCatalog(baseSrc, ssr, projectRoot);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.89",
3
+ "version": "0.1.91",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -8,9 +8,11 @@
8
8
  "build": "vite build",
9
9
  "preview": "vite preview",
10
10
  "typecheck": "tsc --noEmit",
11
- "prepack": "node scripts/vendor-sdk.mjs"
11
+ "prepack": "node scripts/vendor-sdk.mjs && node scripts/vendor-base.mjs"
12
12
  },
13
13
  "dependencies": {
14
+ "@aws-crypto/sha256-js": "^5.2.0",
15
+ "@aws-sdk/util-dynamodb": "^3.996.7",
14
16
  "@cfworker/json-schema": "^4.1.1",
15
17
  "@codemirror/autocomplete": "^6.18.6",
16
18
  "@codemirror/commands": "^6.8.1",
@@ -25,10 +27,17 @@
25
27
  "@codemirror/theme-one-dark": "^6.1.2",
26
28
  "@codemirror/view": "^6.37.1",
27
29
  "@modelcontextprotocol/sdk": "^1.25.1",
30
+ "@smithy/protocol-http": "^5.5.14",
31
+ "@smithy/signature-v4": "^5.6.10",
28
32
  "@tailwindcss/vite": "^4.0.0",
29
- "@unoverse-platform/base": "^0.3.37",
30
33
  "@unoverse-platform/marketplace": "*",
31
34
  "@vitejs/plugin-react": "^4.3.4",
35
+ "acorn": "^8.17.0",
36
+ "fast-memoize": "^2.5.2",
37
+ "fast-xml-parser": "^5.2.5",
38
+ "handlebars": "^4.7.8",
39
+ "ioredis": "^5.3.0",
40
+ "lucide": "^1.21.0",
32
41
  "lucide-react": "^0.546.0",
33
42
  "markdown-to-jsx": "^7.7.17",
34
43
  "oidc-client-ts": "^3.4.1",
@@ -37,6 +46,7 @@
37
46
  "react-oidc-context": "^3.1.0",
38
47
  "tailwindcss": "^4.0.0",
39
48
  "vite": "^6.0.0",
49
+ "ws": "^8.18.0",
40
50
  "yaml": "^2.4.0"
41
51
  },
42
52
  "license": "MIT",
@@ -23,7 +23,8 @@ import { NAV, SCREEN, navItem } from "./chrome";
23
23
 
24
24
  interface TemplateSection {
25
25
  /** static = placed as designed; copywriter = written into through its briefs;
26
- * director = decided from what streams in (§9b, the three words). */
26
+ * director = the judge: a search place (with `component`) or a delivery band
27
+ * (without) — one word since 2026-09-02 (§9b + §9e). */
27
28
  kind: "static" | "copywriter" | "director";
28
29
  /** The state instances render in at this section — what the claim matches. */
29
30
  face: string;
@@ -36,9 +37,15 @@ interface TemplateSection {
36
37
  * previews as written and filled rather than as a page of ghosts. */
37
38
  seeds?: Record<string, Record<string, unknown>>;
38
39
  state?: string;
39
- limit?: number;
40
40
  rules?: string;
41
41
  preview?: string[];
42
+ /** Director places (§9e): the grammar, echoed for the dock table. `ask` appears
43
+ * only after derivation (rule 10d) — never authored. */
44
+ ask?: string;
45
+ type?: "discovery" | "intent";
46
+ include?: string[];
47
+ pick?: number;
48
+ items?: { min: number; max: number };
42
49
  }
43
50
  interface TemplateDef {
44
51
  name: string;
@@ -73,13 +80,26 @@ const refOf = (t: TemplateDef) => (t.org ? `${t.org}/${t.name}` : t.name);
73
80
 
74
81
  /** One section's description for the dock table — the three words (§9b). */
75
82
  function sectionLabel(sec: TemplateSection): { name: string; filler: string; rules: string } {
83
+ // ONE WORD, TWO MODES (§9e, 2026-09-02): with `component` = a search place; without =
84
+ // a delivery band. Both show as the director's — the judge is the same.
85
+ if (sec.kind === "director" && sec.component)
86
+ return {
87
+ name: sec.component,
88
+ filler: "director",
89
+ rules: [
90
+ sec.items ? `items ${sec.items.min}–${sec.items.max}` : null,
91
+ sec.type ?? null,
92
+ sec.include?.length ? sec.include.join("+") : null,
93
+ sec.pick != null ? `pick ${sec.pick}` : null,
94
+ ].filter(Boolean).join(" · "),
95
+ };
76
96
  if (sec.kind === "director")
77
97
  return {
78
98
  name: "director",
79
99
  filler: "director",
80
100
  rules: [
81
101
  sec.state ? `state ${sec.state}` : null,
82
- sec.limit != null ? `limit ${sec.limit}` : null,
102
+ sec.pick != null ? `pick ${sec.pick}` : null,
83
103
  sec.rules ?? null,
84
104
  ].filter(Boolean).join(" · "),
85
105
  };
@@ -151,11 +171,16 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
151
171
  const secs = (base && current?.sections?.[base]) || [];
152
172
  const out: { nodeId: string; ref: string; face?: string; framed: boolean; seed?: Record<string, unknown> }[] = [];
153
173
  for (const sec of secs) {
154
- if (sec.kind === "director") {
174
+ // ONE word, two modes (§9e rule 10b, 2026-09-02): a director WITH a component is a
175
+ // SEARCH place — its mock is its own component's preview, seeded exactly like a
176
+ // linked part. Only the BAND form (no ref) seeds stand-ins. This branch predates
177
+ // the merge and read every director as a band, so search places seeded nothing and
178
+ // the template previewed with those sections simply missing.
179
+ if (sec.ref) {
180
+ out.push({ nodeId: `${sec.face}~${sec.ref}`, ref: sec.ref, face: sec.face, framed: true, seed: sec.seeds?.[sec.ref] });
181
+ } else if (sec.kind === "director") {
155
182
  for (const [i, ref] of (sec.preview ?? []).entries())
156
183
  out.push({ nodeId: `${sec.face}~${ref}~${i}`, ref, face: sec.face, framed: false, seed: sec.seeds?.[ref] });
157
- } else if (sec.ref) {
158
- out.push({ nodeId: `${sec.face}~${sec.ref}`, ref: sec.ref, face: sec.face, framed: true, seed: sec.seeds?.[sec.ref] });
159
184
  }
160
185
  }
161
186
  return out;
@@ -590,7 +590,7 @@ export function TemplatesView({
590
590
  const base = d.defaultLayout ?? Object.keys(d.layouts ?? {})[0];
591
591
  const out: { ref: string; face: string; props: Record<string, unknown> }[] = [];
592
592
  for (const sec of (base && d.sections?.[base]) || []) {
593
- const refs = sec.kind === "director" ? (sec.preview ?? []) : sec.ref ? [sec.ref] : [];
593
+ const refs = sec.ref ? [sec.ref] : sec.kind === "director" ? (sec.preview ?? []) : [];
594
594
  for (const ref of refs) out.push({ ref, face: sec.face, props: sec.seeds?.[ref] ?? {} });
595
595
  }
596
596
  return out;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * WHAT SPATIAL FOUND, AND WHAT IT UNLOCKS.
3
+ *
4
+ * Reading `object_type: "mcp"` rows out of a discovery result and normalising three row
5
+ * kinds into one shape, so a model gets one tool per row regardless of which kind it was.
6
+ * Also the etiquette an interactive app carries, and the anchoring a follow-up search needs.
7
+ */
8
+ import type { AgentToolExchange, DiscoveredMCP, AppInvocationContext } from "./types.js";
9
+
10
+ /** The discovery tools whose results can carry `object_type: "mcp"` rows. */
11
+ export const DISCOVERY_TOOL_NAMES: readonly string[] = ["findIntent", "discoverRelated"];
12
+
13
+ import { parseToolResult } from "./handoff.js";
14
+ import { componentTypeFromAppUri } from "./rowComponents.js";
15
+ import { isBindinglessComponentApp } from "./tools.js";
16
+ import { leanToolResultForModel } from "./lean.js";
17
+
18
+ /**
19
+ * Extract discovered MCPs from discovery-tool results (`object_type: "mcp"` rows).
20
+ *
21
+ * Three row kinds, all minted as one model tool:
22
+ * - node-MCP rows: carry a registered `metadata.schema.methods`.
23
+ * - Path A — workflow-bound app rows (UNOVERSE_MCP_APP_PROTOCOL §4b): no schema —
24
+ * the app IS the tool, minted from the manifest call contract (name + inputSchema);
25
+ * calling it fires `workflow@trigger`.
26
+ * - Path B — bindingless component-app rows (§0.1): an `app` URI but NO workflow. The
27
+ * app IS the tool; calling it makes the held native `/mcp` call
28
+ * (`invokeComponentAppNative`) — the SERVER renders the component + elicits, nothing
29
+ * to fire. Carries `component` (the app-tool id) instead of workflow/trigger.
30
+ *
31
+ * Path B also covers CONTENT-TREE rows (object_type "service"/"need") with an app
32
+ * ATTACHED (`metadata.app: "unoverse://components/<id>"`): a promoted service reacts
33
+ * exactly like a discovered component app — the manifest's `defaultState` decides the
34
+ * render (inline card, focus, …). These rows carry no `metadata.name`, so the tool is
35
+ * minted under the component id and deduped across rows pointing at the same app.
36
+ *
37
+ * App descriptions gain the INTERACTIVE-APP etiquette so the model promotes the UI
38
+ * instead of answering alongside it (observed live: a full interview streamed next to
39
+ * the wizard asking the same questions).
40
+ */
41
+ export const APP_ETIQUETTE =
42
+ "INTERACTIVE APP: calling this renders a UI the user " +
43
+ "completes on screen. When you call it, reply with at most ONE short sentence inviting the user to it — " +
44
+ "never answer the request in text alongside it, never list options, never ask questions the app will " +
45
+ "collect. If the result includes `output`, those are the user's complete answers — continue the task " +
46
+ "with them (search if the request needs a lookup) and only then answer. " +
47
+ "If your instructions include background about this user, every text field you compose here is written " +
48
+ "FOR that person — use their name and what you know about them in headlines, intros, and copy; " +
49
+ "generic audience-neutral brochure copy is wrong when you know who is reading.";
50
+
51
+
52
+ export function parseDiscoveredMCPs(calls: AgentToolExchange[]): DiscoveredMCP[] {
53
+ const discovered: DiscoveredMCP[] = [];
54
+ for (const call of calls) {
55
+ if (!DISCOVERY_TOOL_NAMES.includes(call.name)) continue;
56
+ // The spatial search tools return `{ count, results, queryPoint }` — the items live
57
+ // under `.results`. (Some callers/older shapes hand back a bare array.) Accept both,
58
+ // else nothing is ever discovered (the object isn't an array → the loop is skipped).
59
+ const parsed = parseToolResult(call.resultContent);
60
+ const results = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.results) ? parsed.results : null;
61
+ if (!results) continue;
62
+ for (const item of results) {
63
+ if (item?.object_type !== "mcp") continue;
64
+ if (item.metadata?.schema?.methods) {
65
+ const methodName = Object.keys(item.metadata.schema.methods)[0];
66
+ if (!methodName) continue;
67
+ discovered.push({
68
+ id: item.id || item.universal_id,
69
+ title: item.title || "",
70
+ description: item.description || "",
71
+ workflowId: item.metadata?.workflowId || item.workflow_id,
72
+ nodeId: item.metadata?.nodeId,
73
+ methodName,
74
+ schema: item.metadata?.schema,
75
+ });
76
+ } else if (item.metadata?.workflow && item.metadata?.trigger) {
77
+ // PATH A — workflow-bound app: calling it fires workflow@trigger.
78
+ const methodName = item.metadata.name;
79
+ if (!methodName) continue;
80
+ const input = item.metadata.inputSchema || { type: "object", properties: { message: { type: "string" } } };
81
+ const appDescription = `${item.description || item.title || methodName} ${APP_ETIQUETTE}`;
82
+ discovered.push({
83
+ id: item.id || item.universal_id,
84
+ title: item.title || "",
85
+ description: appDescription,
86
+ workflowId: item.metadata.workflow || item.workflow_id,
87
+ nodeId: item.metadata.trigger,
88
+ methodName,
89
+ schema: { methods: { [methodName]: { description: appDescription, input } } },
90
+ });
91
+ } else if (item.metadata?.app && !item.metadata?.workflow) {
92
+ // PATH B — bindingless component app: no workflow. Minted like an app tool; the
93
+ // adapter passes `component` to `invokeComponentAppNative` (held native `/mcp`
94
+ // call → the SERVER renders + elicits) instead of firing. The row carries NO
95
+ // schema — the input schema is pulled LIVE from `<appUri>/schema` at mint time
96
+ // (`resolveToolDefNative`); this default is the un-briefed fallback only.
97
+ const methodName = item.metadata.name;
98
+ if (!methodName) continue;
99
+ const input = { type: "object", properties: {} };
100
+ const appDescription = `${item.description || item.title || methodName} ${APP_ETIQUETTE}`;
101
+ discovered.push({
102
+ id: item.id || item.universal_id,
103
+ title: item.title || "",
104
+ description: appDescription,
105
+ workflowId: "",
106
+ nodeId: "",
107
+ methodName,
108
+ component: componentTypeFromAppUri(item.metadata.app),
109
+ appUri: String(item.metadata.app),
110
+ schema: { methods: { [methodName]: { description: appDescription, input } } },
111
+ });
112
+ }
113
+ }
114
+ }
115
+ return discovered;
116
+ }
117
+
118
+ /** A content-tree row's attached card, ready to render — see parseRowComponents. */
119
+
120
+ /**
121
+ * THE ONE DISCOVERY ABSORBER — what EVERY agent family does with a discovery tool's
122
+ * result, in one place (never re-implemented in an adapter):
123
+ * 1. UNLOCK: each discovered component app's tool joins `discoveredApps` — the set a
124
+ * native MCP attachment's `toolFilter` reads, so the app appears in the model's
125
+ * next `tools/list` (spatial selects; MCP serves).
126
+ * 2. LEAN: returns the model-facing projection of the rows (full cargo was already
127
+ * consumed by the card lane and this unlock pass; raw rows cost ~100k tokens).
128
+ * Non-discovery results pass through untouched.
129
+ *
130
+ * DISCOVERY NEVER CALLS THE APP. Finding a component in search results is not someone
131
+ * asking for it; only the model's own tool call is. Two fire-and-forget calls lived here
132
+ * (a shell-open on unlock, a draft top-up when a later search carried images) to paint a
133
+ * composed page's frame early. Both were `invokeComponentAppNative`, so on an app whose
134
+ * component declares `outputs` each one rendered the component AND opened its own
135
+ * elicitation: observed live 2026-08-10, three renders and three concurrent forms in ONE
136
+ * turn, of which the channel can hold only one (connection.tsx keeps a single pending
137
+ * resolver). Native MCP is one call, one render, one elicitation, answers back to the
138
+ * model (MCP_TEMPLATE_PROTOCOL §3.3). An early frame, if wanted again, cannot be an app
139
+ * invocation.
140
+ *
141
+ * `onAppUnlocked` — fired ONCE per newly-unlocked path-B app, and AWAITED before the lean
142
+ * result returns. A family that mints its own SDK tool per app (rather than exposing them
143
+ * through a native MCP attachment's `toolFilter`) wires the mint here: parsing + dedup stay
144
+ * in this one absorber, and the tool is registered before the model's next turn reads it.
145
+ */
146
+ export async function handleDiscoveryResult(
147
+ toolName: string,
148
+ resultContent: string,
149
+ discoveredApps: Set<string>,
150
+ identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken">,
151
+ log?: (msg: string) => void,
152
+ onAppUnlocked?: (mcp: DiscoveredMCP) => Promise<void> | void,
153
+ ): Promise<string> {
154
+ if (!DISCOVERY_TOOL_NAMES.includes(toolName)) return resultContent;
155
+ for (const mcp of parseDiscoveredMCPs([{ name: toolName, resultContent }])) {
156
+ if (isBindinglessComponentApp(mcp) && mcp.component && !discoveredApps.has(mcp.component)) {
157
+ discoveredApps.add(mcp.component);
158
+ log?.(`🔓 App tool unlocked: ${mcp.component}`);
159
+ if (onAppUnlocked) await onAppUnlocked(mcp);
160
+ }
161
+ }
162
+ return leanToolResultForModel(resultContent);
163
+ }
164
+
165
+ /**
166
+ * THE SKILLS THE SEARCH SAID ARE IN FORCE where this query landed.
167
+ *
168
+ * The engine resolves them: a query anchors on the map, the point is inside a region or it
169
+ * is not, and that region's attached skills ride back on the search envelope. Nothing here
170
+ * decides anything — this only lifts them out before `leanToolResultForModel` drops the key,
171
+ * which it does because the model must never receive a skill as a TOOL RESULT. A tool result
172
+ * is an item in the conversation and items cannot be removed; the whole point of loading a
173
+ * skill this way is that it can be dropped again by simply not sending it.
174
+ *
175
+ * NULL AND EMPTY ARE DIFFERENT ANSWERS, and the difference is the whole contract. `null`
176
+ * means this reply said nothing about where the conversation is (not a placement search, or
177
+ * it failed) — leave whatever is in force alone. `[]` means it WAS placed and nothing
178
+ * applies there: unassigned ground, or a region nobody attached a skill to. That is an
179
+ * instruction to clear, not an absence of news.
180
+ */
181
+ export function parseRegionSkills(resultContent: string): LoadedRegionSkill[] | null {
182
+ try {
183
+ const parsed = JSON.parse(resultContent);
184
+ const skills = parsed?.regionSkills;
185
+ if (!Array.isArray(skills)) return null;
186
+ return skills.filter((s: any) => s?.name && s?.instructions);
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+
192
+ export interface LoadedRegionSkill {
193
+ name: string;
194
+ title?: string;
195
+ instructions: string;
196
+ regionId?: string;
197
+ regionName?: string | null;
198
+ depth?: number;
199
+ /** How long the engine took to resolve it, for the timeline bar. */
200
+ ms?: number;
201
+ }
202
+
203
+ /** A neutral function-tool definition; each family maps this to its wire format. */
@@ -0,0 +1,56 @@
1
+ /**
2
+ * DOES THIS TOOL CALL END THE TURN?
3
+ *
4
+ * Decided by PROVENANCE and RESULT SHAPE, never by a hardcoded list of tool names. A tool
5
+ * wired in up front keeps the conversation going; one spatial surfaced mid-loop is a handoff
6
+ * candidate, and whether it actually ends the turn depends on what came back.
7
+ */
8
+ import type { AgentToolExchange } from "./types.js";
9
+
10
+ /** Parse a tool result content string to JSON; non-JSON comes back verbatim. */
11
+ export function parseToolResult(content: string): any {
12
+ try {
13
+ return JSON.parse(content || "{}");
14
+ } catch {
15
+ return content;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Whether any tool call is a dynamically-discovered workflow handoff.
21
+ *
22
+ * Provenance, not a hardcoded name list: a tool keeps the conversation going if it
23
+ * was wired in up front (`coreToolNames`, the getSchema set — SpatialSearch, memory,
24
+ * connector MCPs). Only tools spatial surfaced mid-loop are handoff candidates.
25
+ * With no wired-tool info we can't identify a handoff, so we keep going.
26
+ */
27
+ export function hasDynamicHandoff(toolNames: string[], coreToolNames?: Set<string>): boolean {
28
+ if (!coreToolNames || coreToolNames.size === 0) return false;
29
+ return toolNames.some((name) => !coreToolNames.has(name));
30
+ }
31
+
32
+ /**
33
+ * Whether this batch of tool calls ENDS the agent's turn.
34
+ *
35
+ * Refined by RESULT shape: a dynamically-discovered tool whose result carries
36
+ * `output` (or an explicit completed/error status) is an interactive app's SLOW
37
+ * CALL — the user completed it and the answers ARE the result; the conversation
38
+ * CONTINUES with them. Only the fire-and-forget handoff signature
39
+ * ({ status: "started" } / unparseable) ends the turn.
40
+ */
41
+ export function isTurnEndingHandoff(calls: AgentToolExchange[], coreToolNames?: Set<string>): boolean {
42
+ if (
43
+ !hasDynamicHandoff(
44
+ calls.map((c) => c.name),
45
+ coreToolNames,
46
+ )
47
+ ) {
48
+ return false;
49
+ }
50
+ return calls.some((c) => {
51
+ if (coreToolNames?.has(c.name)) return false;
52
+ const res = parseToolResult(c.resultContent);
53
+ if (res && typeof res === "object" && (res.output || res.status === "completed" || res.status === "error")) return false;
54
+ return true;
55
+ });
56
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * AGENT MCP HARNESS — the model-agnostic half of a conversational agent's tool loop.
3
+ *
4
+ * Every agent family (OpenAI, GLM, Grok, …) shares the SAME platform behaviour: which
5
+ * spatial-search results become callable tools, how an interactive app presents itself to
6
+ * the model, and when a tool call ENDS the agent's turn. This folder is that behaviour,
7
+ * once. Agent families keep only their streaming adapters — wire shapes, chunk handling,
8
+ * token accounting — and map their tool-call objects into `AgentToolExchange` to consume it.
9
+ *
10
+ * Fixes land here and reach every family. The state this exists to prevent was real:
11
+ * OpenAI-only, GLM unable to discover apps at all, Grok forked. A conformance guard fails
12
+ * the build if a family re-implements any of it.
13
+ *
14
+ * THIS FILE IS THE PUBLIC SURFACE and nothing else. The harness was one 823-line module;
15
+ * it is now split by JOB, the same way `manifests/runtime/` is, so a reader looking for how
16
+ * a turn ends is not scrolling past row-component rendering to find it.
17
+ *
18
+ * types the shapes, no behaviour
19
+ * handoff does this call end the turn?
20
+ * discovery what spatial found, and what it unlocks
21
+ * rowComponents rows that name a component, rendered rather than described
22
+ * lean projecting a result down to what the model needs
23
+ * tools minting a model tool from a discovered app
24
+ * invoke THE ONE WIRE CALL, where our code ends and native MCP begins
25
+ * memory* conversation ingest and user-memory hydration
26
+ */
27
+
28
+ export type {
29
+ AgentToolExchange,
30
+ DiscoveredMCP,
31
+ RowComponent,
32
+ AgentToolDef,
33
+ AppInvocationContext,
34
+ } from "./types.js";
35
+
36
+ export { DISCOVERY_TOOL_NAMES, APP_ETIQUETTE, parseDiscoveredMCPs, handleDiscoveryResult, parseRegionSkills, type LoadedRegionSkill } from "./discovery.js";
37
+ export { parseToolResult, hasDynamicHandoff, isTurnEndingHandoff } from "./handoff.js";
38
+ export { rowComponentsFromResults, renderRowComponents, rankedRowIds, componentTypeFromAppUri, onEmptyRowDelivery } from "./rowComponents.js";
39
+ export { leanToolResultForModel } from "./lean.js";
40
+ export { toolDefFromDiscoveredMCP, resolveToolDefNative, isBindinglessComponentApp, componentAppInvoker, modelToolName } from "./tools.js";
41
+ export { invokeComponentAppNative, connectPlatformMcp } from "./invoke.js";
42
+
43
+ export { ingestConversationTurn, type ConversationTurn } from "./memoryIngest.js";
44
+ export { fetchUserMemoryContext, hydratePromptWithUserMemory } from "./memoryContext.js";
@@ -0,0 +1,140 @@
1
+ /**
2
+ * THE ONE WIRE CALL. Where our code ends and native MCP begins.
3
+ *
4
+ * Everything else in this folder is interpretation and routing that never touches the MCP
5
+ * wire. This is the exception: `invokeComponentAppNative` opens a client and makes a real
6
+ * `tools/call`. From there the server renders, elicits, and its answers resolve the call.
7
+ *
8
+ * Node-MCP rows and path-A workflow apps do NOT come through here — they route in-process
9
+ * via the caller's `api.callService`, because they have a node or a workflow to run. Only a
10
+ * path-B component app, with nothing local to call, rides the wire.
11
+ */
12
+ import type { AppInvocationContext, DiscoveredMCP } from "./types.js";
13
+
14
+ /**
15
+ * NATIVE MCP APP INVOCATION (UNOVERSE_MCP_APP_PROTOCOL §3.3 / §4b) — the held
16
+ * slow-call that renders a component app and returns the user's submitted answers to
17
+ * the CALLING model.
18
+ *
19
+ * A bindingless component app has no workflow to fire. Calling it is ONE held
20
+ * `tools/call` to the platform's internal MCP server (`/mcp`): the server registered the
21
+ * app as a tool (mcpServer `listApps()`), renders the component on the caller's live
22
+ * session and ELICITS its declared `outputs` block, then resolves the call with
23
+ * `{ output }`. No polling and no timeout by design — an abandoned form is a tool call
24
+ * that never returns (the SDK's 60s default is lifted to 24h). The answers come back as
25
+ * the tool result so the model continues in-turn.
26
+ *
27
+ * This is the modern replacement for the legacy engine's
28
+ * `MCPInvoker.awaitAppOutputNative`: same native slow-call, but driven from this shared
29
+ * harness (every agent family) using the app id it parsed at mint time — so it needs NO
30
+ * server-side (XState) schema cache.
31
+ */
32
+ /** Open an MCP client on the platform's internal `/mcp` — the ONE wire-connect shared by
33
+ * every native call this harness makes (app invocation, live schema read). */
34
+ export async function connectPlatformMcp(
35
+ ctx: Pick<AppInvocationContext, "accessToken" | "baseUrl">,
36
+ ): Promise<{ client: any; close: () => Promise<void> }> {
37
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
38
+ const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
39
+
40
+ const base = (ctx.baseUrl || process.env.UNOVERSE_SERVICE_URL || "http://localhost:4106").replace(/\/$/, "");
41
+ const accessToken = ctx.accessToken;
42
+ const authFetch = accessToken
43
+ ? async (url: string | URL, init?: RequestInit): Promise<Response> => {
44
+ const headers = new Headers(init?.headers);
45
+ headers.set("Authorization", `Bearer ${accessToken}`);
46
+ return fetch(url, { ...init, headers });
47
+ }
48
+ : undefined;
49
+
50
+ const client = new Client({ name: "unoverse-agent-mcp", version: "1.0.0" });
51
+ const transport = new StreamableHTTPClientTransport(
52
+ new URL(`${base}/mcp`),
53
+ authFetch ? { fetch: authFetch } : undefined,
54
+ );
55
+ await client.connect(transport);
56
+ return { client, close: () => client.close().catch(() => {}) };
57
+ }
58
+
59
+ export async function invokeComponentAppNative(
60
+ toolId: string,
61
+ ctx: AppInvocationContext,
62
+ ): Promise<{ status: "completed"; output?: Record<string, unknown>; conversationId?: string; action?: string; message?: string }> {
63
+ const { client, close } = await connectPlatformMcp(ctx);
64
+ try {
65
+ const result = (await client.callTool(
66
+ {
67
+ // Org-qualified refs are served as dotted aliases (no "/" in MCP tool names).
68
+ name: toolId.replace(/\//g, "."),
69
+ arguments: {
70
+ message: ctx.message ?? "",
71
+ ...(ctx.conversationId ? { conversationId: ctx.conversationId } : {}),
72
+ ...(ctx.userId ? { userId: ctx.userId } : {}),
73
+ ...(ctx.chatId ? { chatId: ctx.chatId } : {}),
74
+ ...(ctx.props ? { props: ctx.props } : {}),
75
+ ...(ctx.instanceId ? { instanceId: ctx.instanceId } : {}),
76
+ ...(ctx.fromRow ? { fromRow: true } : {}),
77
+ ...(ctx.batch ? { batch: ctx.batch } : {}),
78
+ },
79
+ },
80
+ undefined,
81
+ // A human is completing the wizard — lift the SDK's 60s default to 24h.
82
+ { timeout: 24 * 60 * 60 * 1000 },
83
+ )) as {
84
+ isError?: boolean;
85
+ content?: Array<{ type: string; text?: string }>;
86
+ structuredContent?: { output?: Record<string, unknown>; conversationId?: string; action?: string };
87
+ };
88
+ // A handler exception arrives as an isError RESULT, not a rejection — surface it, or a
89
+ // broken run masquerades as "completed with no output".
90
+ if (result.isError) {
91
+ throw new Error(result.content?.find((c) => c.type === "text")?.text || `${toolId} app tool call failed`);
92
+ }
93
+ const output = result.structuredContent?.output;
94
+ const resultText = result.content?.find((c) => c.type === "text")?.text;
95
+ // BRIEFED component result (output carries `rendered`): the TEXT is the instruction
96
+ // channel — the referee's "NOT RENDERED — <violations>" correction or the mirror's
97
+ // "Rendered. The guest now sees this page…" reflection. Pass it through VERBATIM so
98
+ // the standard tool-retry loop can self-correct / refine. The canned profile guidance
99
+ // below is for ELICITATION outputs only — substituting it here hid the referee from
100
+ // the model (observed live: composed args dropped, model told "profile collected").
101
+ if (output && typeof (output as { rendered?: unknown }).rendered === "boolean") {
102
+ return {
103
+ status: "completed",
104
+ output,
105
+ conversationId: result.structuredContent?.conversationId,
106
+ action: result.structuredContent?.action,
107
+ ...(resultText ? { message: resultText } : {}),
108
+ };
109
+ }
110
+ return {
111
+ // The held call RESOLVED — mark it completed so `isTurnEndingHandoff` keeps the
112
+ // conversation going. Without this, a display-only app (no `outputs` block, e.g. a
113
+ // showcase card) returns { app, conversationId } — the fire-and-forget handoff
114
+ // signature — and the loop SILENTLY ENDS THE TURN right after the render (observed
115
+ // live: card streamed in, then no follow-up text, twice).
116
+ status: "completed",
117
+ output,
118
+ conversationId: result.structuredContent?.conversationId,
119
+ action: result.structuredContent?.action,
120
+ // Tell the calling model what the output MEANS and that the task is NOT done yet.
121
+ // Without this the model summarises the answers and stops instead of using them —
122
+ // observed live: it returned a generic reply and never re-searched (iteration with
123
+ // toolCalls:0). The legacy MCPInvoker returned this same guidance; dropping it in the
124
+ // de-fork is why "the model does nothing with the answers". Restores the behaviour.
125
+ ...(output && Object.keys(output).length
126
+ ? {
127
+ message:
128
+ "`output` is the user's complete profile collected by the app — do NOT ask for any of these values again. The user's request is NOT yet fulfilled: any earlier search predates these answers. Search NOW with a query built from this profile and recommend the specific matches you find.",
129
+ }
130
+ : {
131
+ message:
132
+ "The component is now displayed on the user's screen. Continue your answer for the user now — the render is a visual aid, not your reply.",
133
+ }),
134
+ };
135
+ } finally {
136
+ await close();
137
+ }
138
+ }
139
+
140
+ // Memory ingest — every agent family saves completed turns the same way.