@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
@@ -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
+ */
@@ -0,0 +1,104 @@
1
+ /**
2
+ * User-memory context fetch — the READ twin of memoryIngest.ts.
3
+ *
4
+ * Harness-level (model-agnostic): when an agent node's User Memory toggle
5
+ * (config.enableUserMemory) is ON, the harness fetches the user's standing
6
+ * memory block (identity + current state, non-intrusion framing included)
7
+ * from the memory server's GET /context at LOOP START and inlines it into the
8
+ * agent's system context — memory arrives with the agent, never as a
9
+ * model-visible tool call (MEMORY_SPEC.md v3 § Serving: zero-call standing lane).
10
+ *
11
+ * Every agent family calls this once when building its instructions. Soft-fail
12
+ * by design: memory server down / cold / no identity → returns "" and the agent
13
+ * simply starts ungrounded — memory must never block or fail the conversation.
14
+ */
15
+
16
+ const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || "http://localhost:4114";
17
+ const CONTEXT_FETCH_TIMEOUT_MS = 3_000;
18
+
19
+ /**
20
+ * Fetch the user's standing memory block, ready to append to the agent's
21
+ * system context. Resolves identity from the node's execution context; returns
22
+ * "" when the toggle is off, identity is incomplete, or the fetch fails.
23
+ * Never throws.
24
+ */
25
+ export async function fetchUserMemoryContext(
26
+ config: { enableUserMemory?: boolean },
27
+ executionContext: any,
28
+ logger: any,
29
+ ): Promise<string> {
30
+ if (config?.enableUserMemory !== true) return "";
31
+
32
+ const pubCtx = executionContext?.publishingContext;
33
+ const userId = pubCtx?.userId;
34
+ const workflowId = executionContext?.workflow?.id;
35
+ if (!userId || !workflowId) {
36
+ logger?.debug?.("[MemoryContext] Skipped — incomplete identity", {
37
+ hasUserId: !!userId,
38
+ hasWorkflowId: !!workflowId,
39
+ });
40
+ return "";
41
+ }
42
+
43
+ return fetchUserMemoryBlock(userId, workflowId, executionContext?.auth?.accessToken, logger);
44
+ }
45
+
46
+ /**
47
+ * The bare standing-context read, for callers that hold identity directly (the
48
+ * director's verdict lane reads it this way — no node config, no execution
49
+ * context). Same endpoint, same soft-fail: any problem returns "".
50
+ */
51
+ export async function fetchUserMemoryBlock(userId: string, workflowId: string, accessToken?: string, logger?: any): Promise<string> {
52
+ if (!userId || !workflowId) return "";
53
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
54
+ if (accessToken) headers["Authorization"] = `Bearer ${accessToken}`;
55
+
56
+ try {
57
+ const params = new URLSearchParams({ userId, workflowId });
58
+ const res = await fetch(`${MEMORY_SERVICE_URL}/context?${params}`, {
59
+ headers,
60
+ signal: AbortSignal.timeout(CONTEXT_FETCH_TIMEOUT_MS),
61
+ });
62
+ if (!res.ok) {
63
+ logger?.warn?.(`[MemoryContext] Fetch rejected: ${res.status}`);
64
+ return "";
65
+ }
66
+ const data = (await res.json()) as { block?: string; count?: number; identity?: string | null };
67
+ const block = typeof data.block === "string" ? data.block : "";
68
+ if (block) {
69
+ logger?.info?.("[MemoryContext] User context injected", {
70
+ hasIdentity: !!data.identity,
71
+ stateLines: data.count ?? 0,
72
+ chars: block.length,
73
+ });
74
+ }
75
+ return block;
76
+ } catch (err: any) {
77
+ logger?.warn?.(`[MemoryContext] Fetch failed (non-blocking): ${err?.message ?? err}`);
78
+ return "";
79
+ }
80
+ }
81
+
82
+ /**
83
+ * THE standard user-memory hydration — the ONE call every agent family makes.
84
+ *
85
+ * When the User Memory toggle is on, the user's context snapshot (identity,
86
+ * verified key facts, open tasks, learned state) is prepended to the USER
87
+ * PROMPT of EVERY request — the input side, not the system prompt: facts that
88
+ * ride with the request get factored into the answer, refresh each turn, and
89
+ * survive previousResponseId resume + prompt caching.
90
+ *
91
+ * Families call `hydratePromptWithUserMemory(config, executionContext, logger,
92
+ * prompt)` and send the result as the user turn. Toggle off / fetch failure →
93
+ * the prompt passes through untouched. Do NOT hand-roll the concatenation in
94
+ * node code — the convention lives here so it cannot drift per family.
95
+ */
96
+ export async function hydratePromptWithUserMemory(
97
+ config: { enableUserMemory?: boolean },
98
+ executionContext: any,
99
+ logger: any,
100
+ prompt: string,
101
+ ): Promise<string> {
102
+ const block = await fetchUserMemoryContext(config, executionContext, logger);
103
+ return block ? `${block}\n\n---\n\n${prompt}` : prompt;
104
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Memory ingest — the agent saves its own conversation turns.
3
+ *
4
+ * Harness-level (model-agnostic): when an agent node's User Memory toggle
5
+ * (config.enableUserMemory) is ON, each completed turn (original user input +
6
+ * final agent answer — never intermediate iterations or tool chatter) is
7
+ * POSTed to the memory server's /memory/ingest, which feeds the L1→L4
8
+ * cascade (see docs/architecture/MEMORY_SPEC.md). Replaces the retired
9
+ * GravityMemoryStore canvas node.
10
+ *
11
+ * Every agent family calls this at turn completion with its own node type;
12
+ * fire-and-forget — memory must never block or fail the conversation.
13
+ */
14
+
15
+ const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || "http://localhost:4114";
16
+
17
+ export interface ConversationTurn {
18
+ userMessage: string;
19
+ agentResponse: string;
20
+ reasoning?: string;
21
+ }
22
+
23
+ /**
24
+ * Save one completed conversation turn to the memory server.
25
+ * Resolves identity from the node's execution context; silently no-ops when
26
+ * the toggle is off or identity is incomplete (e.g. canvas test runs with no
27
+ * conversation). Never throws.
28
+ */
29
+ export function ingestConversationTurn(
30
+ config: { enableUserMemory?: boolean },
31
+ executionContext: any,
32
+ turn: ConversationTurn,
33
+ logger: any,
34
+ sourceNodeType: string,
35
+ ): void {
36
+ if (config?.enableUserMemory !== true) return;
37
+ if (!turn.userMessage && !turn.agentResponse) return;
38
+
39
+ const pubCtx = executionContext?.publishingContext;
40
+ const userId = pubCtx?.userId;
41
+ const workflowId = executionContext?.workflow?.id;
42
+ const conversationId = pubCtx?.conversationId;
43
+ if (!userId || !workflowId || !conversationId) {
44
+ logger?.debug?.("[MemoryIngest] Skipped — incomplete identity", {
45
+ hasUserId: !!userId,
46
+ hasWorkflowId: !!workflowId,
47
+ hasConversationId: !!conversationId,
48
+ });
49
+ return;
50
+ }
51
+
52
+ const payload: Record<string, any> = {
53
+ userId,
54
+ workflowId,
55
+ conversationId,
56
+ executionId: executionContext?.executionId || "",
57
+ sourceNodeId: executionContext?.nodeId,
58
+ sourceNodeType,
59
+ timestamp: new Date().toISOString(),
60
+ };
61
+ // Only include fields with content — /memory/ingest rejects all-empty events
62
+ if (turn.userMessage) payload.userMessage = turn.userMessage;
63
+ if (turn.agentResponse) payload.agentResponse = turn.agentResponse;
64
+ if (turn.reasoning) payload.reasoning = turn.reasoning;
65
+
66
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
67
+ const accessToken = executionContext?.auth?.accessToken;
68
+ if (accessToken) headers["Authorization"] = `Bearer ${accessToken}`;
69
+
70
+ fetch(`${MEMORY_SERVICE_URL}/memory/ingest`, {
71
+ method: "POST",
72
+ headers,
73
+ body: JSON.stringify(payload),
74
+ })
75
+ .then((res) => {
76
+ if (!res.ok) {
77
+ logger?.warn?.(`[MemoryIngest] Ingest rejected: ${res.status}`);
78
+ } else {
79
+ logger?.info?.("[MemoryIngest] Turn saved to memory", {
80
+ userMsg: turn.userMessage.length,
81
+ agentResp: turn.agentResponse.length,
82
+ });
83
+ }
84
+ })
85
+ .catch((err) => {
86
+ logger?.warn?.(`[MemoryIngest] Ingest failed (non-blocking): ${err.message}`);
87
+ });
88
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * ROW COMPONENTS: a search result row may NAME a component, and surfacing the row renders
3
+ * that component rather than describing it.
4
+ *
5
+ * It is whatever component the row attached — a page, a chart, a document, a product panel.
6
+ * Nothing here is card-shaped; the lane was called "cards" for its first use and the name
7
+ * outlived the accuracy (renamed 2026-08-09).
8
+ */
9
+ import type { RowComponent, AppInvocationContext } from "./types.js";
10
+ import { invokeComponentAppNative } from "./invoke.js";
11
+ // Pure section helpers, shared with every renderer so the rules live in one place.
12
+ import { toDocument } from "../markdown/document.js";
13
+
14
+
15
+ /**
16
+ * Extract renderable COMPONENTS from search RESULT ROWS — content-tree rows
17
+ * (service/need) with a component ATTACHED (`metadata.app`, no workflow).
18
+ *
19
+ * The attachment is a component URI, so it is ANY component the row's author chose,
20
+ * not a card. (`unoverse://components/…` only: a row naming a template or carrying a
21
+ * `workflow` is skipped — the latter is a TOOL the model calls.)
22
+ *
23
+ * These NEVER reach a model's toolset: the component renders DATA-DRIVEN the moment
24
+ * discovery surfaces its row — the conversation flows normally and it simply appears
25
+ * (however many rows carry one). The model is neither asked nor able to decide; there
26
+ * is no prompt. The lane executes in the SPATIAL SEARCH NODE (the search that produced
27
+ * the rows renders them — every agent family gets the behavior by wiring the node), via
28
+ * `renderRowComponents` below.
29
+ */
30
+ export function rowComponentsFromResults(results: unknown): RowComponent[] {
31
+ const out: RowComponent[] = [];
32
+ const seen = new Set<string>();
33
+ if (!Array.isArray(results)) return out;
34
+ for (const item of results as any[]) {
35
+ if (item?.object_type === "mcp") continue; // registry apps are TOOLS (path A/B above)
36
+ const app = item?.metadata?.app;
37
+ if (typeof app !== "string" || !app.startsWith("unoverse://components/") || item.metadata.workflow) continue;
38
+ const component = componentTypeFromAppUri(app);
39
+ const id = String(item.id || item.universal_id || `${component}:${item.title ?? ""}`);
40
+ if (!component || seen.has(id)) continue;
41
+ seen.add(id);
42
+ out.push({
43
+ id,
44
+ component,
45
+ // The row's content IS the component's state. Row-level facts ride along:
46
+ // `link` is the row's page as a LABELLED markdown link — tagline (fallback
47
+ // title) linked to source_url, ready for a Markdown bind.
48
+ props: {
49
+ title: item.title,
50
+ description: item.description,
51
+ // EVERY row carries `sections`, so a renderer has ONE thing to read. A row promoted
52
+ // before the structuring pass existed has only `bodyCopy`, which is a valid document
53
+ // of one prose section — synthesised here rather than in each renderer, and never
54
+ // overwriting the sections the row already carries.
55
+ ...(!item.metadata.sections && item.metadata.bodyCopy
56
+ ? { sections: [{ kind: "prose", heading: "", body: item.metadata.bodyCopy }] }
57
+ : {}),
58
+ ...(item.metadata.sections ? toDocument(item.metadata.sections, item.metadata.layout as string) : {}),
59
+ ...(item.source_url
60
+ ? { link: `[${item.metadata.tagline || item.title || item.source_url}](${item.source_url})` }
61
+ : {}),
62
+ ...item.metadata,
63
+ },
64
+ });
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * Render the components discovered rows carry, on the caller's live session — the ONE
71
+ * place this lane executes (every agent family calls this; adapters never re-implement
72
+ * it). Renders are fire-and-forget (a display component declares no `outputs`, so the
73
+ * held call resolves immediately server-side) and a failed render never breaks the
74
+ * conversation.
75
+ */
76
+ /**
77
+ * THE EMPTY DELIVERY IS SAID, NOT INFERRED (UNOVERSE_TEMPLATE_MODEL §12e, the
78
+ * mechanical spent rule): a component-capable search that ran and surfaced
79
+ * nothing has REPLACED the previous answer with nothing, and the receiver needs
80
+ * to know. One listener (the server's director bookkeeping) registers here at
81
+ * boot; the lane announces, the receiver decides. In-process only by design —
82
+ * if the lane ever runs out of process the note goes missing and the rule fails
83
+ * toward KEEP, the safe side.
84
+ */
85
+ let emptyDeliveryListener: ((chatId: string) => void) | null = null;
86
+ export function onEmptyRowDelivery(fn: (chatId: string) => void): void {
87
+ emptyDeliveryListener = fn;
88
+ }
89
+
90
+ export function renderRowComponents(
91
+ items: RowComponent[] | undefined,
92
+ ctx: AppInvocationContext,
93
+ log?: (msg: string) => void,
94
+ ): void {
95
+ // No live session (builder/test/headless callers) → nothing to render; the
96
+ // search stays pure automatically — no flag juggling needed by the caller.
97
+ if (!ctx.userId) return;
98
+ if (!items?.length) {
99
+ // The search ran on a live session and surfaced nothing — say so (§12e).
100
+ if (ctx.chatId) emptyDeliveryListener?.(ctx.chatId);
101
+ return;
102
+ }
103
+ // ONE DELIVERY, announced as a batch (UNOVERSE_TEMPLATE_MODEL §12e): the server's
104
+ // director waits for all siblings, judges once, and only the held set renders —
105
+ // in verdict order, before anything reaches the screen.
106
+ const batchId = `${ctx.chatId ?? ctx.conversationId ?? "b"}:${Math.random().toString(36).slice(2, 8)}`;
107
+ let index = 0;
108
+ for (const item of items) {
109
+ // Per-row instance id: keys the render `<component>:<rowId>` server-side, so
110
+ // multiple rows sharing one component each get their OWN slice (and a repeat
111
+ // render of the same row re-merges the same slice — idempotent, no dupes).
112
+ log?.(
113
+ `🧩 dispatching ${item.component} (${item.id}) → conv=${ctx.conversationId ?? "MISSING"} ` +
114
+ `chat=${ctx.chatId ?? "MISSING"} user=${ctx.userId ?? "MISSING"} token=${ctx.accessToken ? "yes" : "NO"}`,
115
+ );
116
+ // fromRow: this content came from the DATABASE, not from a model composing parts.
117
+ // Without it the brief referee judges the row — a component that renders the Document
118
+ // inherits its prose/keyFacts briefs and gets rejected for parts nobody composes.
119
+ // Fire-and-forget, log on settle: a slow render must not hold up the answer.
120
+ invokeComponentAppNative(item.component, { ...ctx, message: "", props: item.props, instanceId: item.id, fromRow: true, batch: { id: batchId, size: items.length, index: index++ } })
121
+ // "RENDERED" must mean rendered: a director-dropped card resolves too
122
+ // (action: "held-back") and saying RENDERED for it is the exact
123
+ // proof-of-nothing this log line was once rewritten to stop telling.
124
+ .then((r) => log?.(r.action === "held-back" ? `🧩 HELD BACK ${item.component} (${item.id})` : `🧩 RENDERED ${item.component} (${item.id})`))
125
+ .catch((e: any) => log?.(`🧩 FAILED ${item.component} (${item.id}) — ${e?.message ?? e}`));
126
+ }
127
+ }
128
+
129
+ /** `unoverse://components/journey-finder` → `journey-finder`;
130
+ * `unoverse://components/bpp/journey-finder` → `bpp/journey-finder` (the render ref);
131
+ * `unoverse://templates/<org>/<page>` → `<org>/<page>`.
132
+ * The org survives: names are unique only within a tier, so stripping to the last
133
+ * segment would collapse two orgs' same-named components into one ambiguous type.
134
+ *
135
+ * BOTH HOMES, because an app URI names the tier it lives in (`componentUri`, meta.ts:
136
+ * "the address says WHAT it is"). This stripped `components/` alone, so a discovered
137
+ * TEMPLATE fell through with its whole URI intact — and the caller, which swaps `/` for
138
+ * `.` because MCP tool names carry no slash, asked the door for
139
+ * `unoverse:..templates.<org>.<page>`. Observed live 2026-08-31: an agent
140
+ * composed an entire page, every part filled, and the call bounced with -32602 at the
141
+ * door. Silent but for the agent's own trace, since nothing downstream logs a name it
142
+ * never resolved. */
143
+ export function componentTypeFromAppUri(app: string): string {
144
+ return String(app)
145
+ .replace(/^unoverse:\/\/(components|templates)\//, "")
146
+ .replace(/^\/+|\/+$/g, "");
147
+ }
148
+
149
+ /**
150
+ * THE UNLOCKING SEARCH'S ROWS, in rank order — the material a page can be DRAFTED from
151
+ * before the model has written a word (2026-09-01).
152
+ *
153
+ * Every row, not only the ones carrying a component: a draft fills parts that need no
154
+ * authoring (an image ref, a gallery of pure refs), and those reference CONTENT, which has
155
+ * nothing to do with whether a row has a card attached. Restricting this to card-carrying
156
+ * rows is what left a page drafting from three hotels while the attractions sat unusable.
157
+ */
158
+ export function rankedRowIds(resultContent: string, limit = 12): string[] {
159
+ try {
160
+ const parsed = JSON.parse(resultContent) as { results?: unknown };
161
+ if (!Array.isArray(parsed?.results)) return [];
162
+ const out: string[] = [];
163
+ for (const r of parsed.results as Record<string, unknown>[]) {
164
+ const id = r?.universal_id ?? r?.id;
165
+ if (typeof id === "string" && id.trim() && !out.includes(id)) out.push(id);
166
+ if (out.length >= limit) break;
167
+ }
168
+ return out;
169
+ } catch {
170
+ return []; // a search result we cannot read drafts nothing; the page still opens
171
+ }
172
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * MINTING A MODEL TOOL FROM A DISCOVERED APP.
3
+ *
4
+ * One discovered row becomes one tool definition. A path-B component app has no schema on
5
+ * the row at all, so its input schema is read LIVE from the app's own resource at mint time.
6
+ */
7
+ import type { AgentToolDef, DiscoveredMCP, AppInvocationContext } from "./types.js";
8
+ import { connectPlatformMcp, invokeComponentAppNative } from "./invoke.js";
9
+ import { APP_ETIQUETTE } from "./discovery.js";
10
+
11
+ /**
12
+ * AN IDENTITY IS NOT A TOOL NAME, and this is where the two meet.
13
+ *
14
+ * A discovered app's method name is its platform IDENTITY — for an org component that is the
15
+ * qualified ref `<org>/<name>` (UNOVERSE_COMPONENT_ORGS.md), because two orgs may ship the
16
+ * same component name. A slash is legal in an identity and legal in the `unoverse://` address
17
+ * built from it. It is NOT legal in a model tool name: every vendor with tool calling
18
+ * constrains the name, and OpenAI rejects the whole request with
19
+ * `Invalid 'tools[N].name': string does not match pattern '^[a-zA-Z0-9_-]+$'`.
20
+ *
21
+ * Observed live 2026-08-24, on two universes and two orgs: an agent discovered an org
22
+ * component app, minted it as its sixth tool, and every turn after the discovery 400'd. Turn
23
+ * one had passed, because the static service methods are manifest-authored and never carry
24
+ * an org — only a DISCOVERED app arrives org-qualified.
25
+ *
26
+ * REPLACE THE SEPARATOR, NEVER DROP THE ORG. Stripping the prefix would produce a legal name
27
+ * and reintroduce exactly the collision the qualified ref exists to prevent: two orgs shipping
28
+ * `journey-finder` into one conversation would mint one tool name twice, and `live`'s dedupe
29
+ * (toolloop) would silently keep whichever landed first. Substituting the illegal character
30
+ * keeps the name unique and keeps the org readable to the model.
31
+ *
32
+ * THE NAME IS ALSO THE ROUTE BACK. `toolBridge` keys `appInvokers` by the minted name and the
33
+ * invoker closes over the original `mcp`, so the sanitised name round-trips to the right app
34
+ * without a reverse lookup. Anything unminted falls through to `api.callService`, which is
35
+ * addressed by service method names that never carry an org.
36
+ */
37
+ export function modelToolName(identity: string): string {
38
+ return String(identity).replace(/[^a-zA-Z0-9_-]/g, "_");
39
+ }
40
+
41
+ /** Mint the model-facing tool definition for a discovered MCP. */
42
+ export function toolDefFromDiscoveredMCP(mcp: DiscoveredMCP): AgentToolDef {
43
+ const methodDef = mcp.schema?.methods?.[mcp.methodName];
44
+ return {
45
+ name: modelToolName(mcp.methodName),
46
+ description: methodDef?.description || mcp.description || mcp.title,
47
+ parameters: methodDef?.input || { type: "object", properties: { message: { type: "string" } } },
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Mint the model-facing tool definition, pulling a component app's schema LIVE.
53
+ *
54
+ * NATIVE MCP, no copies: a path-B app row carries only its `appUri` — the schema is a
55
+ * `resources/read` of `<appUri>/schema` on the platform server, compiled fresh from the
56
+ * definition at read time. Always current (no reconcile), always in document order (a
57
+ * DB-stored copy can't hold key order), and the same source the server's referee
58
+ * validates against — schema drift is structurally impossible. Non-app tools (and any
59
+ * pull failure) fall back to the synchronous mint.
60
+ */
61
+ export async function resolveToolDefNative(
62
+ mcp: DiscoveredMCP,
63
+ ctx: Pick<AppInvocationContext, "accessToken" | "baseUrl">,
64
+ ): Promise<AgentToolDef> {
65
+ if (!isBindinglessComponentApp(mcp) || !mcp.appUri) return toolDefFromDiscoveredMCP(mcp);
66
+ try {
67
+ const { client, close } = await connectPlatformMcp(ctx);
68
+ try {
69
+ const res = (await client.readResource({ uri: `${mcp.appUri}/schema` })) as {
70
+ contents?: Array<{ text?: string }>;
71
+ };
72
+ const contract = JSON.parse(res.contents?.[0]?.text ?? "{}") as {
73
+ description?: string;
74
+ inputSchema?: Record<string, unknown> | null;
75
+ };
76
+ if (!contract.inputSchema) return toolDefFromDiscoveredMCP(mcp); // not briefed — generic contract
77
+ // A BRIEFED schema carries the composer directive in its top-level description.
78
+ // Models read TOOL descriptions far more reliably than schema descriptions —
79
+ // surface it there too, so the briefed tool self-instructs at both levels.
80
+ const directive = typeof contract.inputSchema.description === "string" ? ` ${contract.inputSchema.description}` : "";
81
+ return {
82
+ name: modelToolName(mcp.methodName),
83
+ description: `${contract.description || mcp.title || mcp.methodName}${directive} ${APP_ETIQUETTE}`,
84
+ parameters: contract.inputSchema,
85
+ };
86
+ } finally {
87
+ await close();
88
+ }
89
+ } catch {
90
+ return toolDefFromDiscoveredMCP(mcp);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * A bindingless component app (path B): a `component` app-tool id and NO workflow. The
96
+ * adapter routes it to `invokeComponentAppNative` (held `/mcp` call) instead of firing.
97
+ */
98
+ export function isBindinglessComponentApp(mcp: DiscoveredMCP): boolean {
99
+ return !!mcp.component && !mcp.workflowId;
100
+ }
101
+
102
+ /**
103
+ * The ONE component-app adapter every agent family wires for a discovered bindingless
104
+ * component app (path B). MCP-NATIVE by design: the tool's schema is the instruction
105
+ * channel — for a BRIEFED component it was compiled from the definition's `brief` tags
106
+ * (server briefSchema → registry metadata.inputSchema → the minted tool), so the model
107
+ * hydrates real fields, not a prose protocol. This closure is the composer's APPLY half:
108
+ * every arg beyond `message` IS that hydrated content and forwards as `props`, which the
109
+ * platform MCP server seeds into the rendered component's state. Non-briefed apps carry
110
+ * only `message` — behavior unchanged.
111
+ *
112
+ * Agent families should wire exactly:
113
+ * `service[methodName] = componentAppInvoker(mcp, identity)`
114
+ * and never hand-roll the arg mapping again.
115
+ */
116
+ export function componentAppInvoker(
117
+ mcp: DiscoveredMCP,
118
+ identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken">,
119
+ ): (input: unknown) => ReturnType<typeof invokeComponentAppNative> {
120
+ const toolId = mcp.component as string;
121
+ return (input: unknown) => {
122
+ const { message, ...hydrated } = (input ?? {}) as Record<string, unknown>;
123
+ return invokeComponentAppNative(toolId, {
124
+ conversationId: identity.conversationId,
125
+ userId: identity.userId,
126
+ accessToken: identity.accessToken,
127
+ chatId: identity.chatId,
128
+ message: typeof message === "string" ? message : "",
129
+ ...(Object.keys(hydrated).length ? { props: hydrated } : {}),
130
+ });
131
+ };
132
+ }
133
+
134
+ /** Context a held app call needs to reach the human on the live session. */