@unoverse-platform/studio 0.1.90 → 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.
- package/local/plugin.ts +20 -12
- package/package.json +13 -3
- package/vendor/base/src/agent-mcp/discovery.ts +203 -0
- package/vendor/base/src/agent-mcp/handoff.ts +56 -0
- package/vendor/base/src/agent-mcp/index.ts +44 -0
- package/vendor/base/src/agent-mcp/invoke.ts +140 -0
- package/vendor/base/src/agent-mcp/lean.ts +111 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
- package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
- package/vendor/base/src/agent-mcp/rowComponents.ts +172 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +130 -0
- package/vendor/base/src/auth/auth.ts +278 -0
- package/vendor/base/src/auth/authConfig.ts +73 -0
- package/vendor/base/src/boot.ts +245 -0
- package/vendor/base/src/definitions/definitions.ts +18 -0
- package/vendor/base/src/definitions/dsPackage.ts +45 -0
- package/vendor/base/src/definitions/fsCache.ts +128 -0
- package/vendor/base/src/definitions/index.ts +19 -0
- package/vendor/base/src/definitions/inputs.ts +30 -0
- package/vendor/base/src/definitions/load/compile/component.ts +80 -0
- package/vendor/base/src/definitions/load/compile/index.ts +12 -0
- package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
- package/vendor/base/src/definitions/load/compile/template.ts +153 -0
- package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
- package/vendor/base/src/definitions/load/expand/index.ts +17 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
- package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
- package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
- package/vendor/base/src/definitions/load/load.ts +487 -0
- package/vendor/base/src/definitions/model.ts +238 -0
- package/vendor/base/src/definitions/registry/catalog.ts +643 -0
- package/vendor/base/src/definitions/registry/homes.ts +252 -0
- package/vendor/base/src/definitions/theme.ts +383 -0
- package/vendor/base/src/executor.ts +284 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +260 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +199 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +198 -0
- package/vendor/base/src/lint/context.mjs +56 -0
- package/vendor/base/src/lint/design/defs.mjs +46 -0
- package/vendor/base/src/lint/design/file.mjs +737 -0
- package/vendor/base/src/lint/design/index.d.mts +30 -0
- package/vendor/base/src/lint/design/index.mjs +996 -0
- package/vendor/base/src/lint/design/tokens.mjs +128 -0
- package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
- package/vendor/base/src/lint/design/walk.mjs +444 -0
- package/vendor/base/src/lint/index.mjs +28 -0
- package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
- package/vendor/base/src/lint/nodes/index.mjs +64 -0
- package/vendor/base/src/lint/nodes/load.mjs +126 -0
- package/vendor/base/src/lint/nodes/node.mjs +764 -0
- package/vendor/base/src/lint/nodes/package.mjs +112 -0
- package/vendor/base/src/lint/nodes/schema.mjs +75 -0
- package/vendor/base/src/logger.ts +33 -0
- package/vendor/base/src/manifests/compose.ts +539 -0
- package/vendor/base/src/manifests/executor/callback.ts +107 -0
- package/vendor/base/src/manifests/executor/context.ts +107 -0
- package/vendor/base/src/manifests/executor/index.ts +47 -0
- package/vendor/base/src/manifests/executor/lookup.ts +21 -0
- package/vendor/base/src/manifests/executor/memory.ts +62 -0
- package/vendor/base/src/manifests/executor/promise.ts +71 -0
- package/vendor/base/src/manifests/executor/refire.ts +34 -0
- package/vendor/base/src/manifests/executor/send.ts +65 -0
- package/vendor/base/src/manifests/executor/session.ts +46 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
- package/vendor/base/src/manifests/index.ts +280 -0
- package/vendor/base/src/manifests/integrity.ts +93 -0
- package/vendor/base/src/manifests/rows.ts +33 -0
- package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
- package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
- package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
- package/vendor/base/src/manifests/runtime/channels/service.ts +125 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +150 -0
- package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
- package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
- package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
- package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
- package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
- package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
- package/vendor/base/src/manifests/runtime/events.ts +223 -0
- package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
- package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
- package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
- package/vendor/base/src/manifests/runtime/index.ts +57 -0
- package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
- package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
- package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
- package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
- package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
- package/vendor/base/src/manifests/runtime/state.ts +171 -0
- package/vendor/base/src/manifests/runtime/templating.ts +110 -0
- package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +542 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +187 -0
- package/vendor/base/src/manifests/watch.ts +67 -0
- package/vendor/base/src/markdown/document.ts +141 -0
- package/vendor/base/src/paths.ts +180 -0
- package/vendor/base/src/platform/audioManager.ts +100 -0
- package/vendor/base/src/platform/clientTransport.ts +65 -0
- package/vendor/base/src/platform/executionContext.ts +154 -0
- package/vendor/base/src/platform/index.ts +10 -0
- package/vendor/base/src/platform/pluginAPI.ts +146 -0
- package/vendor/base/src/platform/redis.ts +54 -0
- package/vendor/base/src/platform/serviceCalls.ts +222 -0
- package/vendor/base/src/pluginBase.ts +248 -0
- package/vendor/base/src/plugins/README.md +57 -0
- package/vendor/base/src/plugins/discovery.ts +87 -0
- package/vendor/base/src/plugins/index.ts +16 -0
- package/vendor/base/src/plugins/loader.ts +179 -0
- package/vendor/base/src/plugins/redis.ts +36 -0
- package/vendor/base/src/plugins/state.ts +53 -0
- package/vendor/base/src/registry.ts +95 -0
- package/vendor/base/src/security/permissions.ts +93 -0
- package/vendor/base/src/security/rules.ts +73 -0
- package/vendor/base/src/template/SafeExpression.ts +556 -0
- package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
- package/vendor/base/src/template/index.ts +22 -0
- package/vendor/base/src/types.ts +50 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +438 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- 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
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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
|
-
*
|
|
197
|
-
*
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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.
|
|
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",
|
|
@@ -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.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PROJECTING A RESULT DOWN TO WHAT THE MODEL NEEDS.
|
|
3
|
+
*
|
|
4
|
+
* A search result carries far more than the model can use, and everything it does not use
|
|
5
|
+
* is context it pays for. The full rows are still used for minting tools; this is only what
|
|
6
|
+
* goes back into the conversation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Model-usable metadata fields — everything the calling LLM has ever actually used from
|
|
10
|
+
// a search row. The RAW rows (full bodyCopy markdown, openGraph, jsonLd, questions,
|
|
11
|
+
// crawl bookkeeping) are for the SERVER side — the card lane and app mint consume them
|
|
12
|
+
// BEFORE this projection is applied — and were costing ~100k tokens per search in the
|
|
13
|
+
// model's thread (observed live: context-window overflow on the first refinement turn).
|
|
14
|
+
const MODEL_METADATA_FIELDS = [
|
|
15
|
+
"title",
|
|
16
|
+
"tagline",
|
|
17
|
+
"shortDescription",
|
|
18
|
+
"introParagraph",
|
|
19
|
+
"callToAction",
|
|
20
|
+
// NO raw image URLs (primaryImage/images): with ref-hydration the model references a
|
|
21
|
+
// row by universal_id and the SERVER fills the bytes — `hasImage` is all it needs.
|
|
22
|
+
"app",
|
|
23
|
+
/**
|
|
24
|
+
* A NEED's one editorial field: the short verb its page leads with ("Advance Career",
|
|
25
|
+
* "Explore Jobs", "Check Requirements"). Needs are extracted rather than authored, so they
|
|
26
|
+
* carry none of the five fields above and reached the model with no metadata at all.
|
|
27
|
+
*
|
|
28
|
+
* Here AND in the node's own keep-list, because this projection runs second and drops
|
|
29
|
+
* whatever it does not name: either half alone is a no-op. `questions` was considered and
|
|
30
|
+
* left out — four sentences a row is the cost this projection exists to avoid.
|
|
31
|
+
*/
|
|
32
|
+
"action",
|
|
33
|
+
"name",
|
|
34
|
+
"kind",
|
|
35
|
+
"org",
|
|
36
|
+
"category",
|
|
37
|
+
"whenToUse",
|
|
38
|
+
"section",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* LEAN RESULT FOR THE MODEL: project a search-shaped tool result down to the fields the
|
|
43
|
+
* calling LLM actually uses (title/description/needs/URL/real image URLs/similarity —
|
|
44
|
+
* and for app rows the discovery meta; the app's input schema already rides the MINTED
|
|
45
|
+
* TOOL, never re-sent here). Non-search results pass through untouched. Applied AFTER
|
|
46
|
+
* card rendering and app discovery, which need the full rows.
|
|
47
|
+
*/
|
|
48
|
+
export function leanToolResultForModel(resultContent: string): string {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(resultContent);
|
|
51
|
+
if (!parsed || !Array.isArray(parsed.results)) return resultContent;
|
|
52
|
+
const lean = {
|
|
53
|
+
count: parsed.count,
|
|
54
|
+
results: parsed.results.map((r: Record<string, any>) => {
|
|
55
|
+
const m = (r.metadata ?? {}) as Record<string, unknown>;
|
|
56
|
+
const meta: Record<string, unknown> = {};
|
|
57
|
+
for (const k of MODEL_METADATA_FIELDS) if (m[k] !== undefined) meta[k] = m[k];
|
|
58
|
+
// hasImage: precomputed by the node's projection, else derived — the model's
|
|
59
|
+
// signal that this row can serve as an image REF (it never sees the URL).
|
|
60
|
+
const hasImage =
|
|
61
|
+
r.hasImage ??
|
|
62
|
+
Boolean(
|
|
63
|
+
(typeof m.primaryImage === "string" && m.primaryImage) ||
|
|
64
|
+
(Array.isArray(m.images) && m.images.length) ||
|
|
65
|
+
r.object_type === "image",
|
|
66
|
+
);
|
|
67
|
+
return {
|
|
68
|
+
// The row's HANDLE — hydration refs and follow-up lookups address by this.
|
|
69
|
+
universal_id: r.universal_id,
|
|
70
|
+
title: r.title,
|
|
71
|
+
description: r.description,
|
|
72
|
+
...(r.key_need ? { key_need: r.key_need } : {}),
|
|
73
|
+
object_type: r.object_type,
|
|
74
|
+
// One relevance number per mode: similarity (intent) or distance (discovery).
|
|
75
|
+
...(typeof r.similarity === "number" ? { similarity: r.similarity } : {}),
|
|
76
|
+
...(typeof r.distance === "number" ? { distance: r.distance } : {}),
|
|
77
|
+
source_url: r.source_url,
|
|
78
|
+
// The standard source identity (migration 021) — lets the model (and anyone
|
|
79
|
+
// reading the trace) scope follow-ups to ONE source ("only this doc's needs").
|
|
80
|
+
// 12 chars: cheaper than the URL it complements.
|
|
81
|
+
...(r.source_id ? { source_id: r.source_id } : {}),
|
|
82
|
+
hasImage,
|
|
83
|
+
...(Object.keys(meta).length ? { metadata: meta } : {}),
|
|
84
|
+
};
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
|
+
return JSON.stringify(lean);
|
|
88
|
+
} catch {
|
|
89
|
+
return resultContent;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* THE ONE DISCOVERY ABSORBER — what EVERY agent family does with a discovery tool's
|
|
95
|
+
* result, in one place (never re-implemented in an adapter):
|
|
96
|
+
* 1. UNLOCK: each discovered component app's tool joins `discoveredApps` — the set a
|
|
97
|
+
* native MCP attachment's `toolFilter` reads, so the app appears in the model's
|
|
98
|
+
* next `tools/list` (spatial selects; MCP serves).
|
|
99
|
+
* 2. SHELL-OPEN (fire-and-forget): one empty native call per newly discovered app —
|
|
100
|
+
* the server renders the page SKELETON immediately, so the guest sees the page
|
|
101
|
+
* exist seconds in and watches it hydrate, never a blank panel while the model
|
|
102
|
+
* composes.
|
|
103
|
+
* 3. LEAN: returns the model-facing projection of the rows (full cargo was already
|
|
104
|
+
* consumed by the card lane and this unlock pass; raw rows cost ~100k tokens).
|
|
105
|
+
* Non-discovery results pass through untouched.
|
|
106
|
+
*
|
|
107
|
+
* `onAppUnlocked` — fired ONCE per newly-unlocked path-B app, and AWAITED before the lean
|
|
108
|
+
* result returns. A family that mints its own SDK tool per app (rather than exposing them
|
|
109
|
+
* through a native MCP attachment's `toolFilter`) wires the mint here: parsing + dedup stay
|
|
110
|
+
* in this one absorber, and the tool is registered before the model's next turn reads it.
|
|
111
|
+
*/
|