@unoverse-platform/studio 0.1.116 → 0.1.117
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/scaffold.d.mts +1 -0
- package/local/scaffold.mjs +12 -0
- package/package.json +1 -1
- package/screens/AgentChain.tsx +41 -25
- package/screens/AgentsView.tsx +41 -1
- package/screens/TemplatesLibraryView.tsx +32 -3
- package/screens/TemplatesView.tsx +2 -2
- package/screens/index.ts +1 -1
- package/src/host.ts +5 -0
- package/src/index.css +11 -0
- package/vendor/base/src/agent-mcp/discovery.ts +1 -1
- package/vendor/base/src/agent-mcp/index.ts +3 -1
- package/vendor/base/src/agent-mcp/invoke.ts +62 -18
- package/vendor/base/src/agent-mcp/progressive.ts +124 -0
- package/vendor/base/src/agent-mcp/tools.ts +1 -1
- package/vendor/base/src/definitions/load/expand/inherit.ts +1 -1
- package/vendor/base/src/definitions/load/expand/refs.ts +1 -1
- package/vendor/base/src/definitions/load/expand/walk.ts +1 -1
- package/vendor/base/src/definitions/registry/agentFolders.ts +2 -2
- package/vendor/base/src/definitions/registry/catalog.ts +1 -1
- package/vendor/base/src/definitions/registry/objects.ts +94 -6
- package/vendor/base/src/definitions/registry/pipelines.ts +13 -2
- package/vendor/base/src/http/uwsFetchAdapter.ts +39 -3
- package/vendor/base/src/items/collect.ts +10 -0
- package/vendor/base/src/items/publish.ts +10 -0
- package/vendor/base/src/lint/design/index.mjs +4 -1
- package/vendor/base/src/lint/design/vocabulary.mjs +1 -1
- package/vendor/base/src/lint/objects/index.mjs +1 -1
- package/vendor/base/src/lint/objects/rules.d.mts +3 -0
- package/vendor/base/src/lint/objects/rules.mjs +93 -5
- package/vendor/base/src/manifests/compose.ts +3 -0
- package/vendor/base/src/manifests/executor/callback.ts +3 -2
- package/vendor/base/src/manifests/executor/memory.ts +27 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +70 -11
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +43 -10
- package/vendor/base/src/manifests/runtime/events.ts +1 -1
- package/vendor/base/src/manifests/runtime/http/request.ts +43 -3
- package/vendor/base/src/manifests/runtime/loops/poll.ts +27 -5
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +1 -1
- package/vendor/base/src/platform/inbound.ts +67 -0
- package/vendor/base/src/platform/tokenCost.ts +27 -8
- package/vendor/sdk/lib/core/actions.ts +10 -0
- package/vendor/sdk/lib/core/apps.ts +2 -2
- package/vendor/sdk/lib/core/client.ts +11 -0
- package/vendor/sdk/lib/core/store.ts +1 -1
- package/vendor/sdk/lib/core/types.ts +2 -0
- package/vendor/sdk/lib/dom/app.ts +3 -2
- package/vendor/sdk/lib/dom/component.ts +38 -1
- package/vendor/sdk/lib/dom/element.ts +23 -0
- package/vendor/sdk/lib/dom/primitives.ts +97 -6
- package/vendor/sdk/lib/voiceService.ts +3 -1
- package/vendor/sdk/main.ts +1 -1
package/local/scaffold.d.mts
CHANGED
|
@@ -13,6 +13,7 @@ export function createProject(home: string, name: string): string;
|
|
|
13
13
|
export function createFirstProject(home: string, name: string): string;
|
|
14
14
|
|
|
15
15
|
/** Make `dir` one org's own repo (UNO-88): folders at the root, unoverse.yaml declaring the org, samples, editor globs, the check, Git. */
|
|
16
|
+
export const ORG_FOLDERS: string[];
|
|
16
17
|
export function createOrgRepo(dir: string, org: string): { state: "committed" | "no-identity" | "inside-repo" | "no-git"; message?: string };
|
|
17
18
|
|
|
18
19
|
/** Make `home` a Git repo on main with a first commit, unless it already is one; never an author it was not given. */
|
package/local/scaffold.mjs
CHANGED
|
@@ -77,6 +77,9 @@ export function createProject(home, name) {
|
|
|
77
77
|
return fillOrgFolder(root);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/** The folder of every authored kind, at an org repo's root (packages/docs/onboarding/studio.md). */
|
|
81
|
+
export const ORG_FOLDERS = ["components", "apps", "templates", "atoms", "skills", "blocks", "nodes", "agents", "objects", "pipelines"];
|
|
82
|
+
|
|
80
83
|
/**
|
|
81
84
|
* One org's folder, filled: the welcome component, the sample styles, identity, app and
|
|
82
85
|
* chat component. Shared by the monorepo shape (design/<org>/) and an org repo, whose root
|
|
@@ -93,6 +96,15 @@ function fillOrgFolder(root, { baselineOnly = false } = {}) {
|
|
|
93
96
|
const dirs = baselineOnly ? ["styles", "identity"] : ["components/welcome", "apps", "styles", "skills", "blocks", "identity"];
|
|
94
97
|
for (const d of dirs) mkdirSync(join(root, d), { recursive: true });
|
|
95
98
|
if (!baselineOnly) writeFileSync(join(root, "components/welcome/welcome.yaml"), WELCOME);
|
|
99
|
+
// EVERY KIND'S FOLDER, EMPTY (owner, 2026-10-25). The baseline ruling dropped the SAMPLES; the
|
|
100
|
+
// folders went with them only because they were made beside the samples. A new org shows
|
|
101
|
+
// where each kind lives from the first minute. `.gitkeep` because Git keeps no empty folder.
|
|
102
|
+
if (baselineOnly) {
|
|
103
|
+
for (const d of ORG_FOLDERS) {
|
|
104
|
+
mkdirSync(join(root, d), { recursive: true });
|
|
105
|
+
if (readdirSync(join(root, d)).length === 0) writeFileSync(join(root, d, ".gitkeep"), "");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
96
108
|
|
|
97
109
|
// Worked example styles (sample-styles: brand ingredients + the override rules), so
|
|
98
110
|
// styles/ is never an empty folder with no hint of the token cascade. Per PROJECT,
|
package/package.json
CHANGED
package/screens/AgentChain.tsx
CHANGED
|
@@ -21,11 +21,11 @@ export interface AgentFolderItem {
|
|
|
21
21
|
/** Its picture, an image URL. */
|
|
22
22
|
avatar?: string;
|
|
23
23
|
skills: string[];
|
|
24
|
-
model?: { use?: string; reasoning?: string; fallback?: string };
|
|
24
|
+
model?: { use?: string; reasoning?: string; fallback?: string; turns?: number };
|
|
25
25
|
budget?: { perDay?: string; perRun?: string };
|
|
26
26
|
tasks: string[];
|
|
27
27
|
access: string[];
|
|
28
|
-
triggers: { name: string; node?: string; wakes?: string }[];
|
|
28
|
+
triggers: { name: string; node?: string; wakes?: string; config?: Record<string, unknown> }[];
|
|
29
29
|
checkIn?: string;
|
|
30
30
|
memory?: { agent?: string };
|
|
31
31
|
askWhenUnsure?: string;
|
|
@@ -58,7 +58,12 @@ function taskLabel(uri: string, org: string): { name: string; from?: string } {
|
|
|
58
58
|
return m[1] === org ? { name: m[2] } : { name: m[2], from: m[1] };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
/**
|
|
62
|
+
* `mailbox`: its connected address in this environment (never in the file), drawn on the triggers
|
|
63
|
+
* its mailbox serves; `says`, by trigger, whether each works here today; `onConnect`, when the
|
|
64
|
+
* viewer may connect one, puts Connect there.
|
|
65
|
+
*/
|
|
66
|
+
export function AgentChain({ agent: given, mailbox, says, onConnect, connecting }: { agent: AgentFolderItem; mailbox?: string | null; says?: Record<string, string>; onConnect?: () => void; connecting?: boolean }) {
|
|
62
67
|
// Every list may be absent (a hand-written file, an older server): drawn as empty, never a crash.
|
|
63
68
|
const agent: AgentFolderItem = {
|
|
64
69
|
...given,
|
|
@@ -108,13 +113,19 @@ export function AgentChain({ agent: given }: { agent: AgentFolderItem }) {
|
|
|
108
113
|
{ways.length === 0 && <div className="text-[12px] text-gray-500">None yet</div>}
|
|
109
114
|
{ways.map((w) => {
|
|
110
115
|
const Icon = WAY_ICONS[w.name] ?? Radio;
|
|
116
|
+
const mail = w.node === "MailboxMCP";
|
|
111
117
|
return (
|
|
112
118
|
<div key={w.name} className={TILE}>
|
|
113
119
|
<span className={GLYPH}><Icon size={15} strokeWidth={1.8} /></span>
|
|
114
|
-
<div className="min-w-0">
|
|
120
|
+
<div className="min-w-0 flex-1">
|
|
115
121
|
<div className="truncate text-[13px] font-medium capitalize text-gray-900">{w.name}</div>
|
|
116
|
-
<div className="truncate text-[11px] text-gray-500">{w.name === "spatial" ? "found in Spatial" : [w.node, w.wakes ? WAKES[w.wakes] ?? w.wakes : null].filter(Boolean).join(", ")}</div>
|
|
122
|
+
<div className="truncate text-[11px] text-gray-500">{mail && mailbox !== undefined ? mailbox ?? "No mailbox connected" : says?.[w.name] ?? (w.name === "spatial" ? "found in Spatial" : [w.node, w.wakes ? WAKES[w.wakes] ?? w.wakes : null].filter(Boolean).join(", "))}</div>
|
|
117
123
|
</div>
|
|
124
|
+
{mail && onConnect && (
|
|
125
|
+
<button type="button" disabled={connecting} onClick={onConnect} className="shrink-0 rounded-md border border-gray-300 bg-white px-2 py-1 text-[11px] font-medium text-gray-800 hover:bg-gray-50 disabled:opacity-50">
|
|
126
|
+
{connecting ? "Connecting" : mailbox ? "Change" : "Connect"}
|
|
127
|
+
</button>
|
|
128
|
+
)}
|
|
118
129
|
</div>
|
|
119
130
|
);
|
|
120
131
|
})}
|
|
@@ -130,17 +141,21 @@ export function AgentChain({ agent: given }: { agent: AgentFolderItem }) {
|
|
|
130
141
|
{agent.role && <div className="text-[12px] text-gray-600">{agent.role}</div>}
|
|
131
142
|
</div>
|
|
132
143
|
</div>
|
|
133
|
-
<
|
|
134
|
-
<
|
|
135
|
-
<
|
|
136
|
-
<
|
|
137
|
-
<
|
|
144
|
+
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12px]">
|
|
145
|
+
<div className="text-gray-500">Model</div>
|
|
146
|
+
<div className="truncate font-mono text-gray-800">{agent.model?.use ?? "not set"}</div>
|
|
147
|
+
<div className="text-gray-500">Reasoning</div>
|
|
148
|
+
<div className="text-gray-800">{agent.model?.reasoning ?? "the model's default"}</div>
|
|
149
|
+
<div className="text-gray-500">Turns</div>
|
|
150
|
+
<div className="text-gray-800">{agent.model?.turns ? `${agent.model.turns} a run` : "the model's default"}</div>
|
|
151
|
+
<div className="text-gray-500">Budget</div>
|
|
152
|
+
<div className="text-gray-800">
|
|
138
153
|
{agent.budget?.perDay ? `${agent.budget.perDay} a day` : "not set"}
|
|
139
154
|
{agent.budget?.perRun ? `, ${agent.budget.perRun} a run` : ""}
|
|
140
|
-
</
|
|
141
|
-
<
|
|
142
|
-
<
|
|
143
|
-
</
|
|
155
|
+
</div>
|
|
156
|
+
<div className="text-gray-500">Memory</div>
|
|
157
|
+
<div className="text-gray-800">{agent.memory?.agent === "on" ? "its own" : "off"}</div>
|
|
158
|
+
</div>
|
|
144
159
|
</div>
|
|
145
160
|
|
|
146
161
|
<ChevronRight size={18} className="mt-14 text-gray-300" />
|
|
@@ -200,17 +215,18 @@ export function AgentChain({ agent: given }: { agent: AgentFolderItem }) {
|
|
|
200
215
|
</div>
|
|
201
216
|
|
|
202
217
|
<div className={LABEL}>Settings</div>
|
|
203
|
-
<
|
|
204
|
-
{agent.model?.reasoning && (<><
|
|
205
|
-
{agent.model?.
|
|
206
|
-
|
|
207
|
-
<
|
|
208
|
-
{agent.
|
|
209
|
-
|
|
210
|
-
<
|
|
211
|
-
<
|
|
212
|
-
<
|
|
213
|
-
|
|
218
|
+
<div className="mb-5 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-lg border border-gray-200 bg-gray-50 p-4 text-[12px] text-gray-800">
|
|
219
|
+
{agent.model?.reasoning && (<><div className="text-gray-500">Reasoning</div><div>{agent.model.reasoning}</div></>)}
|
|
220
|
+
{agent.model?.turns && (<><div className="text-gray-500">Turns</div><div>{agent.model.turns} a run</div></>)}
|
|
221
|
+
{agent.model?.fallback && (<><div className="text-gray-500">Fallback model</div><div className="font-mono">{agent.model.fallback}</div></>)}
|
|
222
|
+
<div className="text-gray-500">Check-in</div>
|
|
223
|
+
<div>{agent.checkIn ? `every ${agent.checkIn}` : "only when reached"}</div>
|
|
224
|
+
{agent.askWhenUnsure && (<><div className="text-gray-500">Asks when unsure</div><div>in {agent.askWhenUnsure}</div></>)}
|
|
225
|
+
<div className="text-gray-500">Email</div>
|
|
226
|
+
<div>its connected mailbox, set in Signal</div>
|
|
227
|
+
<div className="text-gray-500">Folder</div>
|
|
228
|
+
<div className="font-mono">{agent.path}</div>
|
|
229
|
+
</div>
|
|
214
230
|
</>
|
|
215
231
|
);
|
|
216
232
|
}
|
package/screens/AgentsView.tsx
CHANGED
|
@@ -39,6 +39,28 @@ interface AgentItem {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const ACCENT = "#7c3aed";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* COPY FOR CANVAS, AN AGENT WITH A VOICE (AGENTS.md § 5: its `voice` trigger names the node). The
|
|
45
|
+
* canvas's own clipboard payload, as Components writes it: only her face (the voice-chat component,
|
|
46
|
+
* her portrait and name). Clicking it starts her call; she works where she was woken, so her tasks
|
|
47
|
+
* show on that canvas.
|
|
48
|
+
*/
|
|
49
|
+
export function agentForCanvas(a: AgentFolderItem) {
|
|
50
|
+
if (!a.triggers.some((t) => t.name === "voice" && t.node)) return null;
|
|
51
|
+
const name = a.title ?? a.name;
|
|
52
|
+
// ONE NODE, ITS FACE. Its voice and its work are its own (engine agentKeys/talk.ts): `agent` is
|
|
53
|
+
// its address, and clicking the face starts its call on this canvas.
|
|
54
|
+
const face = {
|
|
55
|
+
id: `${a.name}-face1`,
|
|
56
|
+
type: "Component",
|
|
57
|
+
position: { x: 0, y: 0 },
|
|
58
|
+
data: { label: name, status: "ready", agent: `unoverse://agents/${a.org}/${a.name}`, config: { component: "voice-chat", portrait: a.avatar ?? "", orbSize: "360", assistantName: name, tagline: a.role ?? "" } },
|
|
59
|
+
style: { width: 520 },
|
|
60
|
+
draggable: true,
|
|
61
|
+
};
|
|
62
|
+
return { type: "gravity-workflow", version: 2, nodes: [face], edges: [] };
|
|
63
|
+
}
|
|
42
64
|
/** An Agent's mark: a bot's head, antenna up, two eyes. */
|
|
43
65
|
const AGENT_ICON = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${ACCENT}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="9" width="16" height="11" rx="3"/><path d="M12 9V5"/><circle cx="12" cy="4" r="1.2" fill="${ACCENT}"/><circle cx="9" cy="14.5" r="1.3" fill="${ACCENT}" stroke="none"/><circle cx="15" cy="14.5" r="1.3" fill="${ACCENT}" stroke="none"/><path d="M2 13v4M22 13v4"/></svg>`)}`;
|
|
44
66
|
|
|
@@ -49,6 +71,7 @@ export function AgentsView({ org = "all" }: { org?: string } = {}) {
|
|
|
49
71
|
const available = useRegistryToggles("agent");
|
|
50
72
|
const [selected, setSelected] = usePersistentState<string | null>("wb:agents:selected", null);
|
|
51
73
|
const [error, setError] = useState<string | null>(null);
|
|
74
|
+
const [copied, setCopied] = useState(false);
|
|
52
75
|
|
|
53
76
|
useEffect(() => {
|
|
54
77
|
fetchUnoverse(`/dev/agents${org !== "all" ? `?org=${encodeURIComponent(org)}` : ""}`)
|
|
@@ -137,7 +160,24 @@ export function AgentsView({ org = "all" }: { org?: string } = {}) {
|
|
|
137
160
|
{selected && folder ? (
|
|
138
161
|
<div className={DOC}>
|
|
139
162
|
<IdentityHeader
|
|
140
|
-
right={
|
|
163
|
+
right={
|
|
164
|
+
<div className="flex items-center gap-3">
|
|
165
|
+
{agentForCanvas(folder) && (
|
|
166
|
+
<button
|
|
167
|
+
onClick={async () => {
|
|
168
|
+
await navigator.clipboard.writeText(JSON.stringify(agentForCanvas(folder), null, 2));
|
|
169
|
+
setCopied(true);
|
|
170
|
+
setTimeout(() => setCopied(false), 1600);
|
|
171
|
+
}}
|
|
172
|
+
className="rounded border border-gray-300 px-2 py-0.5 text-[11px] font-medium text-gray-600 transition hover:bg-gray-50"
|
|
173
|
+
title="Copy this Agent, with its voice, for a canvas: paste (Cmd+V) on any workflow canvas"
|
|
174
|
+
>
|
|
175
|
+
{copied ? "✓ Copied, paste on canvas" : "⧉ Copy for Canvas"}
|
|
176
|
+
</button>
|
|
177
|
+
)}
|
|
178
|
+
<ToggleSwitch on={available.isEnabled(selected)} onClick={() => available.toggle(selected)} label="Available" />
|
|
179
|
+
</div>
|
|
180
|
+
}
|
|
141
181
|
logoUrl={folder.avatar ?? AGENT_ICON}
|
|
142
182
|
accent={ACCENT}
|
|
143
183
|
name={folder.title ? `${folder.title}${folder.role ? `, ${folder.role}` : ""}` : selected}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* NOT TemplatesView.tsx: that name is taken by the Apps screen (the legacy noun).
|
|
16
16
|
*/
|
|
17
|
-
import { useEffect, useMemo, useState, useSyncExternalStore, useCallback, type MouseEvent as ReactMouseEvent } from "react";
|
|
17
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore, useCallback, type MouseEvent as ReactMouseEvent } from "react";
|
|
18
18
|
import { ComponentStore, type ResolvedTheme } from "@unoverse/sdk";
|
|
19
19
|
import { UnoverseComponent } from "./react/unoverse";
|
|
20
20
|
import { client, authedFetch, usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
|
|
@@ -56,6 +56,9 @@ interface TemplateDef {
|
|
|
56
56
|
preview?: Record<string, string[]>;
|
|
57
57
|
/** The components it holds, one stand-in per item of its own preview data, per state. */
|
|
58
58
|
heldSeeds?: Record<string, { key: string; component: string; props: Record<string, unknown> }[]>;
|
|
59
|
+
/** The template's own props (the lists it holds, a picture a hook fills): their previews seed
|
|
60
|
+
* the instance, so a layout-level Each has items to walk and a held card a spot to draw in. */
|
|
61
|
+
props?: Record<string, { preview?: unknown; default?: unknown } | null>;
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
const PREVIEW_CHAT = "preview";
|
|
@@ -184,7 +187,14 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
184
187
|
type: "COMPONENT_INIT",
|
|
185
188
|
chatId: PREVIEW_CHAT,
|
|
186
189
|
nodeId: TEMPLATE_NODE,
|
|
187
|
-
|
|
190
|
+
// ITS OWN PROPS, seeded as a part's are (`preview ?? default`): a template whose layout walks
|
|
191
|
+
// a list it holds drew nothing here, because the instance arrived empty while the held
|
|
192
|
+
// stand-ins beneath it waited for an Each that never ran (observed 2026-10-23).
|
|
193
|
+
component: {
|
|
194
|
+
type: ref,
|
|
195
|
+
componentUrl: `unoverse://templates/${ref}`,
|
|
196
|
+
props: Object.fromEntries(Object.entries(current.props ?? {}).map(([k, v]) => [k, v?.preview ?? v?.default]).filter(([, v]) => v !== undefined)),
|
|
197
|
+
},
|
|
188
198
|
});
|
|
189
199
|
const base = current.defaultLayout ?? (current.layouts ? Object.keys(current.layouts)[0] : undefined);
|
|
190
200
|
if (base) store.mergeComponentState(PREVIEW_CHAT, TEMPLATE_NODE, { state: base });
|
|
@@ -246,6 +256,25 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
246
256
|
if ((own?.state ?? own?.view ?? own?.defaultState) !== activeState) store.mergeComponentState(PREVIEW_CHAT, TEMPLATE_NODE, { state: activeState });
|
|
247
257
|
}, [store, holdsItems, activeState]);
|
|
248
258
|
|
|
259
|
+
// A VIEW OPENING RUNS THE HOOK SCOPED TO IT (docs/design/lifecycle-hooks.md), as the app's stream
|
|
260
|
+
// would: the template's own fields so far as `props`, the state as the layout entered, and what
|
|
261
|
+
// comes back merged into the instance. Once per view per template; a host with no universe
|
|
262
|
+
// to run it (local Studio) fails quietly and the preview keeps its stand-ins.
|
|
263
|
+
const ranHooks = useRef(new Set<string>());
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (!uri || !activeState || !client.runTask) return;
|
|
266
|
+
const key = `${uri}#${activeState}`;
|
|
267
|
+
if (ranHooks.current.has(key)) return;
|
|
268
|
+
ranHooks.current.add(key);
|
|
269
|
+
const own = (store.get(PREVIEW_CHAT, TEMPLATE_NODE) ?? {}) as Record<string, unknown>;
|
|
270
|
+
client
|
|
271
|
+
.runTask(uri, "onEnterView", own, activeState)
|
|
272
|
+
.then((fields) => {
|
|
273
|
+
if (fields && Object.keys(fields).length) store.mergeComponentState(PREVIEW_CHAT, TEMPLATE_NODE, fields);
|
|
274
|
+
})
|
|
275
|
+
.catch((e: unknown) => console.warn(`[studio] ${uri} onEnterView(${activeState}) did not run:`, e));
|
|
276
|
+
}, [store, uri, activeState]);
|
|
277
|
+
|
|
249
278
|
// GROUPED BY CATEGORY, exactly as Components groups its rail — the shelf is no longer a
|
|
250
279
|
// flat list the moment small templates land on it (the `Blocks` shelf: pair, feature,
|
|
251
280
|
// single, rail, stack, statement) and a page has to be found among them. The category is
|
|
@@ -480,7 +509,7 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
|
|
|
480
509
|
);
|
|
481
510
|
})()}
|
|
482
511
|
|
|
483
|
-
<div className="flex-1 overflow-auto p-10">
|
|
512
|
+
<div data-print-stage className="flex-1 overflow-auto p-10">
|
|
484
513
|
<div className="mx-auto max-w-full transition-[width] duration-200" style={{ width: stageWidth ?? "100%" }}>
|
|
485
514
|
{uri && <UnoverseComponent key={uri} client={client} store={store} chatId={PREVIEW_CHAT} nodeId={TEMPLATE_NODE} theme={theme} />}
|
|
486
515
|
</div>
|
|
@@ -32,7 +32,7 @@ import { NAV, NAV_SECTION, SCREEN, navItem, navSubItem } from "./chrome";
|
|
|
32
32
|
|
|
33
33
|
// Per-APP test conversation: each app chats in its own conversation id, so testing
|
|
34
34
|
// one client's app never inherits another's agent history/memory (the "BPP answers
|
|
35
|
-
// about
|
|
35
|
+
// about a bank" trap — same workflow, polluted shared context).
|
|
36
36
|
// …and a fresh conversation per PAGE LOAD: the nonce is minted once per module load,
|
|
37
37
|
// so refreshing the page naturally starts a NEW conversation (fresh agent thread,
|
|
38
38
|
// fresh durable surfaces) — history never outlives the screen it happened on.
|
|
@@ -373,7 +373,7 @@ export function TemplatesView({
|
|
|
373
373
|
const base = store.getTimeline().length === 0 ? "isEmpty" : "hasMessages";
|
|
374
374
|
return states.find((s) => s.when === base)?.name ?? null;
|
|
375
375
|
}, [storeVersion, store, selectedDef]);
|
|
376
|
-
// The preview renders under the SELECTED APP'S ORG theme — a
|
|
376
|
+
// The preview renders under the SELECTED APP'S ORG theme — a a bank app is a bank-red, a
|
|
377
377
|
// BPP app cobalt, whatever the header org scope shows. The header toggle picks the
|
|
378
378
|
// light/dark variant; the app's org picks whose brand. Falls back to the app-level
|
|
379
379
|
// theme until the fetch resolves (or for org-less templates).
|
package/screens/index.ts
CHANGED
|
@@ -25,7 +25,7 @@ export { TemplatesLibraryView } from "./TemplatesLibraryView";
|
|
|
25
25
|
export { DesignSystemView } from "./DesignSystemView";
|
|
26
26
|
export { SkillsView } from "./SkillsView";
|
|
27
27
|
// Agents: the one file authored for the map (docs.unoverse.ai/design/tasks), one lane in both Studios.
|
|
28
|
-
export { AgentsView } from "./AgentsView";
|
|
28
|
+
export { AgentsView, agentForCanvas } from "./AgentsView";
|
|
29
29
|
// An Agent drawn off its agent.yaml: Studio's lane, and Signal's Agent page beside its runs.
|
|
30
30
|
export { AgentChain } from "./AgentChain";
|
|
31
31
|
export type { AgentFolderItem } from "./AgentChain";
|
package/src/host.ts
CHANGED
|
@@ -110,6 +110,11 @@ class LocalUnoverseClient {
|
|
|
110
110
|
);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/** A component's task runs in a universe. A folder runs none, so it fills nothing. */
|
|
114
|
+
async runTask(): Promise<Record<string, unknown>> {
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
|
|
113
118
|
/**
|
|
114
119
|
* Read a definition, and STAMP EVERY NODE WITH ITS PATH.
|
|
115
120
|
*
|
package/src/index.css
CHANGED
|
@@ -5,3 +5,14 @@ body,
|
|
|
5
5
|
#root {
|
|
6
6
|
height: 100%;
|
|
7
7
|
}
|
|
8
|
+
|
|
9
|
+
/* PRINT: only the template stage prints, one 1280x720 page per slide; Studio's chrome stays out.
|
|
10
|
+
The page is the stage's own size, so a stage drawn at Desktop width (1280) prints at scale 1. */
|
|
11
|
+
@media print {
|
|
12
|
+
@page { size: 1280px 720px; margin: 0; }
|
|
13
|
+
html, body, #root, #root * { height: auto !important; min-height: 0 !important; overflow: visible !important; }
|
|
14
|
+
body *:not([data-print-stage]):not([data-print-stage] *):not(:has([data-print-stage])) { display: none !important; }
|
|
15
|
+
:has(> [data-print-stage]), :has([data-print-stage]) { display: block !important; padding: 0 !important; margin: 0 !important; border: 0 !important; }
|
|
16
|
+
[data-print-stage] { padding: 0 !important; }
|
|
17
|
+
[data-print-stage] > div { width: 1280px !important; max-width: none !important; margin: 0 !important; }
|
|
18
|
+
}
|
|
@@ -72,7 +72,7 @@ export function parseDiscoveredMCPs(calls: AgentToolExchange[]): DiscoveredMCP[]
|
|
|
72
72
|
// A TASK IS A TOOL, DESCRIBED LIKE ONE (owner ruling 2026-09-08). This used to close
|
|
73
73
|
// with TASK_ETIQUETTE — "calling this runs an Agent and holds until it completes while
|
|
74
74
|
// the person watches its progress on screen; say in one short sentence what you are
|
|
75
|
-
// doing" — and the only thing it told the model to DO was speak. Measured on
|
|
75
|
+
// doing" — and the only thing it told the model to DO was speak. Measured on a bank
|
|
76
76
|
// (2026-09-08): across four runs of one question the model called once and, three
|
|
77
77
|
// times, emitted exactly one sentence of intent and ended the turn, with no
|
|
78
78
|
// function-call events in the stream at all.
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* lean projecting a result down to what the model needs
|
|
23
23
|
* tools minting a model tool from a discovered app
|
|
24
24
|
* invoke THE ONE WIRE CALL, where our code ends and native MCP begins
|
|
25
|
+
* progressive many tools held behind search_tools and call_tool (MCP Client Best Practices)
|
|
25
26
|
* memory* conversation ingest and user-memory hydration
|
|
26
27
|
*/
|
|
27
28
|
|
|
@@ -38,7 +39,8 @@ export { parseToolResult, isTurnEndingHandoff } from "./handoff.js";
|
|
|
38
39
|
export { rowComponentsFromResults, renderRowComponents, rankedRowIds, componentTypeFromAppUri, onScreenRead, onScreenPlaces, placesOn, placesLine, rememberRows, rowsById, cardIdOf, screenOf, openedOn, type ScreenItem, openTasksOn, onScreenLine, ABOUT_THE_PAGE, ABOUT_THE_OPENED } from "./rowComponents.js";
|
|
39
40
|
export { leanToolResultForModel } from "./lean.js";
|
|
40
41
|
export { toolDefFromDiscoveredMCP, resolveToolDefNative, isBindinglessComponentApp, componentAppInvoker, isWorkflowTask, workflowTaskInvoker, workflowAppInvoker, modelToolName, objectFunctionToolDef, objectFunctionInvoker } from "./tools.js";
|
|
41
|
-
export { invokeTaskNative, connectPlatformMcp, TRACE_META } from "./invoke.js";
|
|
42
|
+
export { invokeTaskNative, connectPlatformMcp, TRACE_META, readTask, answerTask } from "./invoke.js";
|
|
43
|
+
export { shouldDefer, metaTools, searchAnswer, searchTools, estimateTokens, DEFER_ABOVE_TOKENS, SEARCH_TOOLS, CALL_TOOL, type HeldTool } from "./progressive.js";
|
|
42
44
|
|
|
43
45
|
export { ingestConversationTurn, type ConversationTurn } from "./memoryIngest.js";
|
|
44
46
|
export { fetchUserMemoryContext, hydratePromptWithUserMemory } from "./memoryContext.js";
|
|
@@ -84,20 +84,11 @@ type CallResult = {
|
|
|
84
84
|
structuredContent?: Record<string, any>;
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
-
/**
|
|
88
|
-
*
|
|
89
|
-
|
|
90
|
-
* client that declared the tasks extension, so a Task answers at once with its task and the
|
|
91
|
-
* official client (`@modelcontextprotocol/ext-tasks/client`) follows it to its result; a call
|
|
92
|
-
* with nothing to wait for answers at once, through the same client. Several calls in one turn
|
|
93
|
-
* each follow their own task, side by side.
|
|
94
|
-
*
|
|
95
|
-
* The one piece of host code the library asks for is `rawDispatch`: it sends one JSON-RPC request
|
|
96
|
-
* on the 2026 framing and hands back the response. The host owns request ids, so it adds them.
|
|
97
|
-
*/
|
|
98
|
-
async function callFollowingTasks(toolId: string, args: Record<string, unknown>, ctx: AppInvocationContext): Promise<CallResult> {
|
|
87
|
+
/** A 2026-07-28 client on the platform's /mcp that declared the tasks extension, and the official
|
|
88
|
+
* task session over it: what every native task call below is made on. Close the client after. */
|
|
89
|
+
async function openTaskSession(ctx: Pick<AppInvocationContext, "baseUrl" | "accessToken">) {
|
|
99
90
|
const { Client, StreamableHTTPClientTransport } = await import("@modelcontextprotocol/client");
|
|
100
|
-
const { createTaskSessionFromClient
|
|
91
|
+
const { createTaskSessionFromClient } = await import("@modelcontextprotocol/ext-tasks/client");
|
|
101
92
|
const base = (ctx.baseUrl || process.env.UNOVERSE_SERVICE_URL || "http://localhost:4106").replace(/\/$/, "");
|
|
102
93
|
const auth: Record<string, string> = ctx.accessToken ? { Authorization: `Bearer ${ctx.accessToken}` } : {};
|
|
103
94
|
const clientInfo = { name: "unoverse-agent-mcp", version: "1.0.0" };
|
|
@@ -130,12 +121,29 @@ async function callFollowingTasks(toolId: string, args: Record<string, unknown>,
|
|
|
130
121
|
const answers = text.split("\n").filter((l) => l.startsWith("data: ")).map((l) => JSON.parse(l.slice(6)));
|
|
131
122
|
return answers.filter((m) => m.id === id).pop() ?? answers.pop();
|
|
132
123
|
};
|
|
124
|
+
const session = createTaskSessionFromClient(client as never, {
|
|
125
|
+
endpointId: `${base}/mcp`,
|
|
126
|
+
rawDispatch: rawDispatch as never,
|
|
127
|
+
v2RequestFraming: { protocolVersion: MODERN, clientInfo, clientCapabilities },
|
|
128
|
+
} as never);
|
|
129
|
+
return { client, session };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* ONE CALL, FOLLOWED AS AN MCP TASK WHEN IT IS ONE (docs/mcp/MCP_TASKS.md steps 6 and 7). Every
|
|
134
|
+
* Task is an MCP task: a workflow, a form, an app that asks. The harness calls as a 2026-07-28
|
|
135
|
+
* client that declared the tasks extension, so a Task answers at once with its task and the
|
|
136
|
+
* official client (`@modelcontextprotocol/ext-tasks/client`) follows it to its result; a call
|
|
137
|
+
* with nothing to wait for answers at once, through the same client. Several calls in one turn
|
|
138
|
+
* each follow their own task, side by side.
|
|
139
|
+
*
|
|
140
|
+
* The one piece of host code the library asks for is `rawDispatch`: it sends one JSON-RPC request
|
|
141
|
+
* on the 2026 framing and hands back the response. The host owns request ids, so it adds them.
|
|
142
|
+
*/
|
|
143
|
+
async function callFollowingTasks(toolId: string, args: Record<string, unknown>, ctx: AppInvocationContext): Promise<CallResult> {
|
|
144
|
+
const { resultFromTaskOutcome } = await import("@modelcontextprotocol/ext-tasks/client");
|
|
145
|
+
const { client, session } = await openTaskSession(ctx);
|
|
133
146
|
try {
|
|
134
|
-
const session = createTaskSessionFromClient(client as never, {
|
|
135
|
-
endpointId: `${base}/mcp`,
|
|
136
|
-
rawDispatch: rawDispatch as never,
|
|
137
|
-
v2RequestFraming: { protocolVersion: MODERN, clientInfo, clientCapabilities },
|
|
138
|
-
} as never);
|
|
139
147
|
const execution = await session.callTool(
|
|
140
148
|
// THE TOOL NAME FROM THE TASK'S ONE URI (docs/mcp/MCP_TASKS.md § 2a): a task URI or its short
|
|
141
149
|
// form `<org>/<name>` is called as `<org>.<name>`; anything else (a bare app id) as given.
|
|
@@ -257,6 +265,9 @@ export async function invokeTaskNative(
|
|
|
257
265
|
return {
|
|
258
266
|
status: "completed",
|
|
259
267
|
output: { result: result.structuredContent.result },
|
|
268
|
+
// THE TASK'S OWN ID (owner 2026-10-24: "save the task, show it in the timeline, click it
|
|
269
|
+
// to see it"): the run that did this, so the caller can record it where the work belongs.
|
|
270
|
+
...(result.structuredContent?.executionId ? { run: result.structuredContent.executionId } : {}),
|
|
260
271
|
conversationId: result.structuredContent?.conversationId,
|
|
261
272
|
message:
|
|
262
273
|
"The Agent completed. `output.result` is what it found, already shown to the person on screen. " +
|
|
@@ -303,3 +314,36 @@ export async function invokeTaskNative(
|
|
|
303
314
|
}
|
|
304
315
|
|
|
305
316
|
// Memory ingest — every agent family saves completed turns the same way.
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* A TASK THAT ALREADY EXISTS, by its id (docs/mcp/MCP_TASKS.md § 5c): the official client's
|
|
320
|
+
* `session.task(id)`. Read gives where it is and, once it has finished, what it produced; answer
|
|
321
|
+
* replies to what a waiting task asks (`tasks/update`). Nothing else: MCP offers nothing else.
|
|
322
|
+
*/
|
|
323
|
+
export async function readTask(taskId: string, ctx: Pick<AppInvocationContext, "baseUrl" | "accessToken">): Promise<{ taskId: string; status: string; statusMessage?: string; asks?: unknown; result?: string }> {
|
|
324
|
+
const { client, session } = await openTaskSession(ctx);
|
|
325
|
+
try {
|
|
326
|
+
const task = (session as any).task(taskId);
|
|
327
|
+
const view = await task.snapshot();
|
|
328
|
+
const raw = (view.raw ?? {}) as Record<string, any>;
|
|
329
|
+
const done = (raw.result?.content ?? []).find((c: any) => c?.type === "text")?.text;
|
|
330
|
+
return {
|
|
331
|
+
taskId: view.taskId,
|
|
332
|
+
status: view.status,
|
|
333
|
+
...(view.statusMessage ? { statusMessage: view.statusMessage } : {}),
|
|
334
|
+
...(raw.inputRequests && Object.keys(raw.inputRequests).length ? { asks: raw.inputRequests } : {}),
|
|
335
|
+
...(typeof done === "string" && done ? { result: done } : {}),
|
|
336
|
+
};
|
|
337
|
+
} finally {
|
|
338
|
+
await client.close().catch(() => {});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function answerTask(taskId: string, inputResponses: Record<string, unknown>, ctx: Pick<AppInvocationContext, "baseUrl" | "accessToken">): Promise<void> {
|
|
343
|
+
const { client, session } = await openTaskSession(ctx);
|
|
344
|
+
try {
|
|
345
|
+
await (session as any).task(taskId).updateJson(inputResponses);
|
|
346
|
+
} finally {
|
|
347
|
+
await client.close().catch(() => {});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PROGRESSIVE TOOL DISCOVERY (MCP 2026-07-28, Client Best Practices; docs/mcp/MCP_2026-07-28.md
|
|
3
|
+
* § 4, 3e; packages/docs/nodes/mcp-services.md, "Many tools").
|
|
4
|
+
*
|
|
5
|
+
* A host with many tools does not hand the model every definition. It keeps them, gives the
|
|
6
|
+
* model a `search_tools` meta-tool, and loads a definition only when the job needs it. Our
|
|
7
|
+
* harness is the host for every Agent, so this is decided here, once, for every model.
|
|
8
|
+
*
|
|
9
|
+
* THE TOOL LIST NEVER CHANGES DURING A RUN. A found tool is not added to the list: it is called
|
|
10
|
+
* through `call_tool`, MCP's "single stable call_tool({name, args}) meta-tool so the array never
|
|
11
|
+
* changes". Adding it would break the model's prompt cache at that turn.
|
|
12
|
+
*
|
|
13
|
+
* Pure: the tools come in, the offer and the answers go out. The bridge does the calling.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A tool as the bridge holds it: what the model would be offered, plus the server it is from. */
|
|
17
|
+
export interface HeldTool {
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
parameters: unknown;
|
|
21
|
+
server?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* THE THRESHOLD, in estimated tokens of definitions. MCP: switch "once the tool definitions take
|
|
26
|
+
* up a significant part of the available context window", for example 1% to 5%. 2,000 is 1% of a
|
|
27
|
+
* 200k window and under 2% of a 128k one. Below it, every tool is offered as before.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFER_ABOVE_TOKENS = 2000;
|
|
30
|
+
|
|
31
|
+
/** How many tools one search hands back: a few that fit, never the catalogue. */
|
|
32
|
+
const MATCHES = 5;
|
|
33
|
+
|
|
34
|
+
export const SEARCH_TOOLS = "search_tools";
|
|
35
|
+
export const CALL_TOOL = "call_tool";
|
|
36
|
+
|
|
37
|
+
/** About four characters a token: the usual estimate, and close enough to pick a side of 2,000. */
|
|
38
|
+
export function estimateTokens(tools: HeldTool[]): number {
|
|
39
|
+
return Math.ceil(JSON.stringify(tools.map(({ name, description, parameters }) => ({ name, description, parameters }))).length / 4);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Should these tools be held back behind search? */
|
|
43
|
+
export function shouldDefer(tools: HeldTool[]): boolean {
|
|
44
|
+
return estimateTokens(tools) > DEFER_ABOVE_TOKENS;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A tool in one line: its name and the first sentence of what it does, never its schema. */
|
|
48
|
+
function oneLine(t: HeldTool): string {
|
|
49
|
+
const first = String(t.description ?? "").split(/(?<=[.!?:])\s/)[0].trim();
|
|
50
|
+
return `- ${t.name}: ${first.length > 140 ? `${first.slice(0, 137)}...` : first}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* THE CATALOGUE: every held tool in one line, grouped by server (MCP: "names and one-line
|
|
55
|
+
* descriptions", "group tools by server"). The model sees what it has and loads a definition only
|
|
56
|
+
* when it needs it; without the lines an Agent never knew a tool existed, so it never searched and
|
|
57
|
+
* named tools it did not have (Bob, 2026-10-11). Stable for the run, so the prompt cache holds.
|
|
58
|
+
*/
|
|
59
|
+
export function catalogue(held: HeldTool[]): string {
|
|
60
|
+
const groups = new Map<string, HeldTool[]>();
|
|
61
|
+
for (const t of held) {
|
|
62
|
+
const key = t.server ?? "";
|
|
63
|
+
groups.set(key, [...(groups.get(key) ?? []), t]);
|
|
64
|
+
}
|
|
65
|
+
return [...groups.entries()].map(([server, tools]) => [server ? `${server}:` : "", ...tools.map(oneLine)].filter(Boolean).join("\n")).join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The two meta-tools, offered in place of the held ones. The search carries the catalogue. */
|
|
69
|
+
export function metaTools(held: HeldTool[]): HeldTool[] {
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
name: SEARCH_TOOLS,
|
|
73
|
+
description:
|
|
74
|
+
`Load the full definition (input schema) of the tools you need before you call them. Describe the job in plain words, or give a tool's name; the answer is the tools that fit, each with its server and full input schema. Call it before ${CALL_TOOL} for any tool whose schema you do not yet hold. The tools you have:\n${catalogue(held)}`,
|
|
75
|
+
parameters: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: { query: { type: "string", description: "What you need to do, e.g. 'save a person's email address'." } },
|
|
78
|
+
required: ["query"],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: CALL_TOOL,
|
|
83
|
+
description: `Call one tool that ${SEARCH_TOOLS} found, by its exact name, with arguments matching its input schema.`,
|
|
84
|
+
parameters: {
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {
|
|
87
|
+
name: { type: "string", description: `The tool's exact name, as ${SEARCH_TOOLS} gave it.` },
|
|
88
|
+
arguments: { type: "object", description: "The tool's arguments, matching its input schema." },
|
|
89
|
+
},
|
|
90
|
+
required: ["name"],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const words = (s: string): string[] => s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* KEYWORD SEARCH, the first of MCP's strategies ("simple and effective, particularly for descriptive
|
|
100
|
+
* tool names and descriptions"). A word of the query in the tool's name counts most, then in its
|
|
101
|
+
* server, then in its description. A name's own parts count: `send_message` is "send" and "message".
|
|
102
|
+
*/
|
|
103
|
+
export function searchTools(held: HeldTool[], query: string): Array<{ name: string; server?: string; description: string; inputSchema: unknown }> {
|
|
104
|
+
const asked = [...new Set(words(query))];
|
|
105
|
+
const scored = held
|
|
106
|
+
.map((t) => {
|
|
107
|
+
const name = new Set(words(t.name.replace(/([a-z0-9])([A-Z])/g, "$1 $2")));
|
|
108
|
+
const server = new Set(words((t.server ?? "").replace(/([a-z0-9])([A-Z])/g, "$1 $2")));
|
|
109
|
+
const text = new Set(words(t.description));
|
|
110
|
+
const score = asked.reduce((s, w) => s + (name.has(w) ? 3 : 0) + (server.has(w) ? 2 : 0) + (text.has(w) ? 1 : 0), 0);
|
|
111
|
+
return { t, score };
|
|
112
|
+
})
|
|
113
|
+
.filter((x) => x.score > 0)
|
|
114
|
+
.sort((a, b) => b.score - a.score);
|
|
115
|
+
return scored.slice(0, MATCHES).map(({ t }) => ({ name: t.name, ...(t.server ? { server: t.server } : {}), description: t.description, inputSchema: t.parameters }));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The answer to a search the model reads: the matches, or what to do when there are none. */
|
|
119
|
+
export function searchAnswer(held: HeldTool[], query: string): string {
|
|
120
|
+
const found = searchTools(held, String(query ?? ""));
|
|
121
|
+
if (found.length) return JSON.stringify({ tools: found });
|
|
122
|
+
const servers = [...new Set(held.map((t) => t.server).filter(Boolean))];
|
|
123
|
+
return JSON.stringify({ tools: [], note: `No tool matched "${query}". Describe the job in other words${servers.length ? `, or name a server: ${servers.join(", ")}` : ""}.` });
|
|
124
|
+
}
|
|
@@ -192,7 +192,7 @@ export function componentAppInvoker(
|
|
|
192
192
|
* `handoffTools` and nothing else, so `bridge.call` found no entry in `appInvokers` and fell
|
|
193
193
|
* through to `api.callService`, which addresses SERVICE methods: the platform answered
|
|
194
194
|
* "SpatialSearch has no service method \"sab-voice\"" and the call failed in 10ms while the
|
|
195
|
-
* run still reported success. Observed live on
|
|
195
|
+
* run still reported success. Observed live on a bank (2026-09-07): the app was found, minted
|
|
196
196
|
* and CALLED, and the person watched a blank panel. Every workflow-bound app shared this —
|
|
197
197
|
* the path-B sibling three lines above had an invoker from the day it was written.
|
|
198
198
|
*/
|
|
@@ -112,7 +112,7 @@ export function inheritProps(
|
|
|
112
112
|
// could send, which is exactly the venue-renaming this flag exists to prevent.
|
|
113
113
|
// `input` TRAVELS TOO (2026-09-15). An atom that marks a field `input: false` (the button's
|
|
114
114
|
// `icon` and `link`) says nobody outside fills it; without the word here the host inherited
|
|
115
|
-
// the field as fillable and the tool asked the model for it (
|
|
115
|
+
// the field as fillable and the tool asked the model for it (a bank's transfer form: four fields it
|
|
116
116
|
// could not honestly write). Same failure as `override` above, one word later.
|
|
117
117
|
const CONTRACT = ["type", "description", "maxLength", "minItems", "maxItems", "items", "hydrate", "optional", "override", "preview", "input"] as const;
|
|
118
118
|
// AN ATOM INSIDE AN `Each` DESCRIBES THE ITEM, not the page. The scope is the chain of
|