@unoverse-platform/studio 0.1.91 → 0.1.92
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 +1 -53
- package/package.json +1 -1
- package/vendor/base/src/agent-mcp/invoke.ts +14 -1
- package/vendor/base/src/agent-mcp/lean.ts +3 -0
- package/vendor/base/src/agent-mcp/rowComponents.ts +72 -10
- package/vendor/base/src/agent-mcp/types.ts +8 -0
- package/vendor/base/src/auth/auth.ts +29 -0
- package/vendor/base/src/definitions/fsCache.ts +27 -0
- package/vendor/base/src/definitions/registry/catalog.ts +19 -3
- package/vendor/base/src/http/uwsFetchAdapter.ts +196 -59
- package/vendor/base/src/items/collect.ts +49 -2
- package/vendor/base/src/items/publish.ts +14 -0
- package/vendor/base/src/lint/nodes/index.d.mts +28 -0
- package/vendor/base/src/lint/nodes/index.mjs +20 -2
- package/vendor/base/src/lint/nodes/node.mjs +37 -37
- package/vendor/base/src/lint/nodes/package.mjs +2 -2
- package/vendor/base/src/manifests/executor/context.ts +3 -0
- package/vendor/base/src/manifests/runtime/channels/service.ts +16 -3
- package/vendor/base/src/manifests/runtime/context.ts +6 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +8 -0
- package/vendor/base/src/manifests/source.ts +11 -2
- package/vendor/base/src/platform/executionContext.ts +3 -0
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +35 -24
- package/local/mcp.ts +0 -265
package/local/plugin.ts
CHANGED
|
@@ -26,7 +26,6 @@ import { loadLocalNodes, type LocalNodes } from "./nodes.js";
|
|
|
26
26
|
import { credentialsFromEnv, keyPresence, readEnv } from "./keys.js";
|
|
27
27
|
import { readSource, saveSource, sourceFiles, writableRoots } from "./source.js";
|
|
28
28
|
import { atomNames, vocabulary } from "./authoring.js";
|
|
29
|
-
import { buildTools, handleMcp, mintPairingCode } from "./mcp.js";
|
|
30
29
|
|
|
31
30
|
/**
|
|
32
31
|
* A definition URI's home on disk: a component's folder, or an app's.
|
|
@@ -169,14 +168,6 @@ export function localStudioHost(): Plugin {
|
|
|
169
168
|
let baseSrc: string;
|
|
170
169
|
/** The node the developer last picked in the preview, for an agent to read. */
|
|
171
170
|
let selection: Record<string, unknown> | null = null;
|
|
172
|
-
/** The agent tool surface, built on first use so the catalog is loaded by then. */
|
|
173
|
-
let mcpTools: ReturnType<typeof buildTools> | null = null;
|
|
174
|
-
/** This package's own root, for the vendored authoring skill. */
|
|
175
|
-
// Derived from this module's own URL, never RESOLVED: `createRequire().resolve("./plugin.js")`
|
|
176
|
-
// throws here, because Vite loads the TypeScript source and no sibling .js exists. Throwing
|
|
177
|
-
// at plugin construction kills the config load, which stops the dev server restarting at
|
|
178
|
-
// all, and the symptom is every new route 404ing with no error in the browser.
|
|
179
|
-
const appRoot = resolve(fileURLToPath(import.meta.url), "../..");
|
|
180
171
|
|
|
181
172
|
return {
|
|
182
173
|
name: "local-studio-host",
|
|
@@ -242,7 +233,7 @@ export function localStudioHost(): Plugin {
|
|
|
242
233
|
// 200. That has now cost three separate bugs (the /api allowlist, the dev proxy for
|
|
243
234
|
// /.well-known and /publish), each of which looked like a missing route rather than
|
|
244
235
|
// a filtered one.
|
|
245
|
-
if (!/^\/(dev|local|
|
|
236
|
+
if (!/^\/(dev|local|skills|prompt-blocks|registry|nodes|keys|execute)\b/.test(path)) return next();
|
|
246
237
|
|
|
247
238
|
/** Read a JSON request body. */
|
|
248
239
|
const body = async (): Promise<Record<string, unknown>> => {
|
|
@@ -515,49 +506,6 @@ export function localStudioHost(): Plugin {
|
|
|
515
506
|
case path === "/local/selection":
|
|
516
507
|
return json(res, selection ?? { empty: true });
|
|
517
508
|
|
|
518
|
-
/**
|
|
519
|
-
* CONNECT AN AGENT. Mints the code the developer pastes into Claude, the same
|
|
520
|
-
* flow Canvas uses. Ten minutes, single use, burned on redeem.
|
|
521
|
-
*/
|
|
522
|
-
case path === "/local/pair" && req.method === "POST":
|
|
523
|
-
return json(res, mintPairingCode());
|
|
524
|
-
|
|
525
|
-
/** The MCP endpoint itself. Same origin, same port, no separate install. */
|
|
526
|
-
case path === "/mcp" && req.method === "POST": {
|
|
527
|
-
mcpTools ??= buildTools(
|
|
528
|
-
{
|
|
529
|
-
projectRoot,
|
|
530
|
-
catalog: async () => (await catalog) as never,
|
|
531
|
-
sourceFiles: (uri) => {
|
|
532
|
-
const home = definitionHome(uri, c, projectRoot);
|
|
533
|
-
return home ? sourceFiles(home, writableRoots(projectRoot, c), projectRoot) : [];
|
|
534
|
-
},
|
|
535
|
-
readSource: (p) => readSource(p, writableRoots(projectRoot, c), projectRoot),
|
|
536
|
-
saveSource: async (p, text) => {
|
|
537
|
-
type LintModule = { lintDefinitions(designRoot: string): { problems: { level?: string; file?: string }[] } };
|
|
538
|
-
const lintMod = (await ssr(`${baseSrc}/lint/design/index.mjs`)) as unknown as LintModule;
|
|
539
|
-
recentSelfWrites.set(resolve(p), Date.now());
|
|
540
|
-
return saveSource(p, text, writableRoots(projectRoot, c), projectRoot, (designRoot) =>
|
|
541
|
-
lintMod.lintDefinitions(designRoot),
|
|
542
|
-
);
|
|
543
|
-
},
|
|
544
|
-
lintProject: async () => {
|
|
545
|
-
type LintModule = { lintDefinitions(designRoot: string): { problems: unknown[] } };
|
|
546
|
-
const lintMod = (await ssr(`${baseSrc}/lint/design/index.mjs`)) as unknown as LintModule;
|
|
547
|
-
return lintMod.lintDefinitions(designHome(projectRoot));
|
|
548
|
-
},
|
|
549
|
-
selection: () => selection,
|
|
550
|
-
vocabulary: async () => {
|
|
551
|
-
const vocabMod = (await ssr(`${baseSrc}/lint/design/vocabulary.mjs`)) as unknown as Parameters<typeof vocabulary>[0];
|
|
552
|
-
return vocabulary(vocabMod, c.resolveTheme("light"), atomNames(projectRoot), c.listDefinitions("component") as { name?: string }[]);
|
|
553
|
-
},
|
|
554
|
-
},
|
|
555
|
-
appRoot,
|
|
556
|
-
);
|
|
557
|
-
await handleMcp(req, res, await body(), mcpTools);
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
509
|
case path === "/skills":
|
|
562
510
|
return json(res, { skills: readMarkdownLibrary(resolve(projectRoot, "prompts/skills")) });
|
|
563
511
|
|
package/package.json
CHANGED
|
@@ -53,7 +53,19 @@ export async function connectPlatformMcp(
|
|
|
53
53
|
authFetch ? { fetch: authFetch } : undefined,
|
|
54
54
|
);
|
|
55
55
|
await client.connect(transport);
|
|
56
|
-
|
|
56
|
+
// TERMINATE, then close. `client.close()` only aborts the local fetch; the server keeps
|
|
57
|
+
// the session (its McpServer, ~70 compiled tool schemas) until an HTTP DELETE arrives,
|
|
58
|
+
// and the SDK sends that ONLY from `terminateSession()`. Every native call here opens
|
|
59
|
+
// a fresh session (one per card render, one per discovered app per turn), so without
|
|
60
|
+
// the DELETE the server's session map grew until restart. Best-effort: a failed
|
|
61
|
+
// DELETE falls back to the server's idle sweep (mcp/routes.ts).
|
|
62
|
+
return {
|
|
63
|
+
client,
|
|
64
|
+
close: async () => {
|
|
65
|
+
await transport.terminateSession().catch(() => {});
|
|
66
|
+
await client.close().catch(() => {});
|
|
67
|
+
},
|
|
68
|
+
};
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
export async function invokeComponentAppNative(
|
|
@@ -74,6 +86,7 @@ export async function invokeComponentAppNative(
|
|
|
74
86
|
...(ctx.props ? { props: ctx.props } : {}),
|
|
75
87
|
...(ctx.instanceId ? { instanceId: ctx.instanceId } : {}),
|
|
76
88
|
...(ctx.fromRow ? { fromRow: true } : {}),
|
|
89
|
+
...(ctx.shell ? { shell: true } : {}),
|
|
77
90
|
...(ctx.batch ? { batch: ctx.batch } : {}),
|
|
78
91
|
},
|
|
79
92
|
},
|
|
@@ -51,6 +51,9 @@ export function leanToolResultForModel(resultContent: string): string {
|
|
|
51
51
|
if (!parsed || !Array.isArray(parsed.results)) return resultContent;
|
|
52
52
|
const lean = {
|
|
53
53
|
count: parsed.count,
|
|
54
|
+
// THE SEARCH'S MIRROR (MCP_APP_PROTOCOL §0.1): what the delivery put on screen. The
|
|
55
|
+
// one key the model must read before the rows; kept verbatim.
|
|
56
|
+
...(typeof parsed.screen === "string" && parsed.screen ? { screen: parsed.screen } : {}),
|
|
54
57
|
results: parsed.results.map((r: Record<string, any>) => {
|
|
55
58
|
const m = (r.metadata ?? {}) as Record<string, unknown>;
|
|
56
59
|
const meta: Record<string, unknown> = {};
|
|
@@ -32,7 +32,27 @@ export function rowComponentsFromResults(results: unknown): RowComponent[] {
|
|
|
32
32
|
const seen = new Set<string>();
|
|
33
33
|
if (!Array.isArray(results)) return out;
|
|
34
34
|
for (const item of results as any[]) {
|
|
35
|
-
if (item?.object_type === "mcp")
|
|
35
|
+
if (item?.object_type === "mcp") {
|
|
36
|
+
/**
|
|
37
|
+
* A REGISTRY ROW THAT IS A COMPONENT ARRIVES AS ITS SHELL (TEMPLATE_MODEL §12e "One
|
|
38
|
+
* ladder, one delivery", ruled 2026-09-04). A bindingless component (a manifest, no
|
|
39
|
+
* workflow — the card finder) used to be only UNLOCKED here as a tool and rendered a
|
|
40
|
+
* turn later when the model called it; meanwhile the display cards from the same
|
|
41
|
+
* search had already landed, so a `page` card held the screen for the second before
|
|
42
|
+
* the `focus` component existed. Now it lands in the SAME delivery as the cards, in
|
|
43
|
+
* its declared state, and the ladder decides among them at once. Delivered, never
|
|
44
|
+
* invoked: no row props, no outputs collected — the model's own call (still minted
|
|
45
|
+
* by discovery) merges its arguments into this slice and collects the answers.
|
|
46
|
+
* Workflow-bound apps (path A) and native MCP tools stay tools.
|
|
47
|
+
*/
|
|
48
|
+
const uri = item?.metadata?.app;
|
|
49
|
+
if (typeof uri !== "string" || !uri.startsWith("unoverse://components/") || item.metadata?.workflow || item.metadata?.schema?.methods) continue;
|
|
50
|
+
const component = componentTypeFromAppUri(uri);
|
|
51
|
+
if (!component || seen.has(component)) continue;
|
|
52
|
+
seen.add(component);
|
|
53
|
+
out.push({ id: component, component, props: {}, shell: true });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
36
56
|
const app = item?.metadata?.app;
|
|
37
57
|
if (typeof app !== "string" || !app.startsWith("unoverse://components/") || item.metadata.workflow) continue;
|
|
38
58
|
const component = componentTypeFromAppUri(app);
|
|
@@ -87,24 +107,47 @@ export function onEmptyRowDelivery(fn: (chatId: string) => void): void {
|
|
|
87
107
|
emptyDeliveryListener = fn;
|
|
88
108
|
}
|
|
89
109
|
|
|
110
|
+
/** What the delivery did with one row: rendered, held back by the director or the ladder,
|
|
111
|
+
* delivered as a shell, or failed. The search's MIRROR is written from these. */
|
|
112
|
+
export interface RowOutcome {
|
|
113
|
+
component: string;
|
|
114
|
+
id: string;
|
|
115
|
+
action: "rendered" | "held-back" | "shell" | "failed";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* THE SEARCH'S MIRROR (MCP_APP_PROTOCOL §0.1, ruled 2026-09-05): one line saying what the
|
|
120
|
+
* delivery put on screen and what it did not show, so the model answers the screen and
|
|
121
|
+
* not the rows. Empty when nothing is on screen (card-only deliveries do not mirror).
|
|
122
|
+
*/
|
|
123
|
+
export function screenLine(outcomes: RowOutcome[]): string {
|
|
124
|
+
const shown = outcomes.filter((o) => o.action === "shell" || o.action === "rendered");
|
|
125
|
+
const hidden = outcomes.filter((o) => o.action === "held-back");
|
|
126
|
+
if (!shown.some((o) => o.action === "shell")) return "";
|
|
127
|
+
const on = shown.map((o) => (o.action === "shell" ? `${o.component} (on screen, in its opening state)` : o.component)).join(", ");
|
|
128
|
+
const off = hidden.length ? ` Not shown: ${hidden.length} result card(s), ranked below it on the app's ladder — do not describe them as visible.` : "";
|
|
129
|
+
return `On screen: ${on}. Answer by pointing the guest to what is on screen.${off}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
90
132
|
export function renderRowComponents(
|
|
91
133
|
items: RowComponent[] | undefined,
|
|
92
134
|
ctx: AppInvocationContext,
|
|
93
135
|
log?: (msg: string) => void,
|
|
94
|
-
):
|
|
136
|
+
): Promise<RowOutcome[]> {
|
|
95
137
|
// No live session (builder/test/headless callers) → nothing to render; the
|
|
96
138
|
// search stays pure automatically — no flag juggling needed by the caller.
|
|
97
|
-
if (!ctx.userId) return;
|
|
139
|
+
if (!ctx.userId) return Promise.resolve([]);
|
|
98
140
|
if (!items?.length) {
|
|
99
141
|
// The search ran on a live session and surfaced nothing — say so (§12e).
|
|
100
142
|
if (ctx.chatId) emptyDeliveryListener?.(ctx.chatId);
|
|
101
|
-
return;
|
|
143
|
+
return Promise.resolve([]);
|
|
102
144
|
}
|
|
103
145
|
// ONE DELIVERY, announced as a batch (UNOVERSE_TEMPLATE_MODEL §12e): the server's
|
|
104
146
|
// director waits for all siblings, judges once, and only the held set renders —
|
|
105
147
|
// in verdict order, before anything reaches the screen.
|
|
106
148
|
const batchId = `${ctx.chatId ?? ctx.conversationId ?? "b"}:${Math.random().toString(36).slice(2, 8)}`;
|
|
107
149
|
let index = 0;
|
|
150
|
+
const outcomes: Promise<RowOutcome>[] = [];
|
|
108
151
|
for (const item of items) {
|
|
109
152
|
// Per-row instance id: keys the render `<component>:<rowId>` server-side, so
|
|
110
153
|
// multiple rows sharing one component each get their OWN slice (and a repeat
|
|
@@ -117,13 +160,32 @@ export function renderRowComponents(
|
|
|
117
160
|
// Without it the brief referee judges the row — a component that renders the Document
|
|
118
161
|
// inherits its prose/keyFacts briefs and gets rejected for parts nobody composes.
|
|
119
162
|
// Fire-and-forget, log on settle: a slow render must not hold up the answer.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
163
|
+
// A shell keys the component's own slice (no per-row instance), so the model's later
|
|
164
|
+
// call for the same component merges into it rather than drawing a second one.
|
|
165
|
+
outcomes.push(
|
|
166
|
+
invokeComponentAppNative(item.component, {
|
|
167
|
+
...ctx,
|
|
168
|
+
message: "",
|
|
169
|
+
props: item.props,
|
|
170
|
+
...(item.shell ? { shell: true } : { instanceId: item.id }),
|
|
171
|
+
fromRow: true,
|
|
172
|
+
batch: { id: batchId, size: items.length, index: index++ },
|
|
173
|
+
})
|
|
174
|
+
// "RENDERED" must mean rendered: a director-dropped card resolves too
|
|
175
|
+
// (action: "held-back") and saying RENDERED for it is the exact
|
|
176
|
+
// proof-of-nothing this log line was once rewritten to stop telling.
|
|
177
|
+
.then((r): RowOutcome => {
|
|
178
|
+
const action: RowOutcome["action"] = r.action === "held-back" ? "held-back" : r.action === "shell" ? "shell" : "rendered";
|
|
179
|
+
log?.(action === "held-back" ? `🧩 HELD BACK ${item.component} (${item.id})` : `🧩 RENDERED ${item.component} (${item.id})`);
|
|
180
|
+
return { component: item.component, id: item.id, action };
|
|
181
|
+
})
|
|
182
|
+
.catch((e: any): RowOutcome => {
|
|
183
|
+
log?.(`🧩 FAILED ${item.component} (${item.id}) — ${e?.message ?? e}`);
|
|
184
|
+
return { component: item.component, id: item.id, action: "failed" };
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
126
187
|
}
|
|
188
|
+
return Promise.all(outcomes);
|
|
127
189
|
}
|
|
128
190
|
|
|
129
191
|
/** `unoverse://components/journey-finder` → `journey-finder`;
|
|
@@ -88,6 +88,9 @@ export interface AppInvocationContext {
|
|
|
88
88
|
instanceId?: string;
|
|
89
89
|
/** These props are ROW DATA, not a model's composition — skip the brief referee. */
|
|
90
90
|
fromRow?: boolean;
|
|
91
|
+
/** Deliver the component's SHELL only: render it in its declared state and return
|
|
92
|
+
* at once — never collect its outputs (§12e "Discovery delivers; it never invokes"). */
|
|
93
|
+
shell?: boolean;
|
|
91
94
|
/** The delivery batch this render belongs to (UNOVERSE_TEMPLATE_MODEL §12e): the
|
|
92
95
|
* server's director judges the WHOLE batch before anything renders. `size` is how
|
|
93
96
|
* many siblings to wait for; `index` is this render's arrival position. */
|
|
@@ -127,4 +130,9 @@ export interface RowComponent {
|
|
|
127
130
|
component: string;
|
|
128
131
|
/** The row's content AS-IS — the component's state (binds resolve by name). */
|
|
129
132
|
props: Record<string, unknown>;
|
|
133
|
+
/** A SHELL delivery (TEMPLATE_MODEL §12e "One ladder, one delivery"): the row is a
|
|
134
|
+
* component that takes input (a manifest, no workflow). It arrives in the same
|
|
135
|
+
* delivery as the cards, in its declared state, with its own defaults and NO row
|
|
136
|
+
* props; it is never invoked here — its form opens only on the model's call. */
|
|
137
|
+
shell?: boolean;
|
|
130
138
|
}
|
|
@@ -83,8 +83,37 @@ function parseStringClaim(payload: JWTPayload, claimName: string): string | unde
|
|
|
83
83
|
return undefined;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* VERIFIED TOKENS, REMEMBERED UNTIL THEY EXPIRE. A token is immutable: the same string
|
|
88
|
+
* verifies to the same user every time until `exp`, and nothing here checks revocation
|
|
89
|
+
* on the way (it never did). Yet every request on /mcp, /stream, /api/* and every
|
|
90
|
+
* per-click user_action paid a full RS256 verify, and the proxy and trigger paths paid
|
|
91
|
+
* it TWICE (gate, then enrichment). Bounded and insertion-evicted; an entry is trusted
|
|
92
|
+
* only while `now < exp`, so an expired token is re-verified and refused exactly as
|
|
93
|
+
* before. The JWKS fetch was already cached by jose; this caches the verify.
|
|
94
|
+
*/
|
|
95
|
+
const VERIFIED_MAX = 1000;
|
|
96
|
+
/** Cache life for a token that carries no `exp` (jose still verified it). */
|
|
97
|
+
const VERIFIED_NO_EXP_MS = 60_000;
|
|
98
|
+
const verified = new Map<string, { user: AuthUser; until: number }>();
|
|
99
|
+
|
|
86
100
|
/** Validate a bearer token and extract the user, or null if invalid/unconfigured. */
|
|
87
101
|
export async function validateJWT(token: string): Promise<AuthUser | null> {
|
|
102
|
+
const hit = verified.get(token);
|
|
103
|
+
if (hit) {
|
|
104
|
+
if (Date.now() < hit.until) return hit.user;
|
|
105
|
+
verified.delete(token);
|
|
106
|
+
}
|
|
107
|
+
const user = await verifyJWT(token);
|
|
108
|
+
if (user) {
|
|
109
|
+
const exp = typeof user.raw.exp === "number" ? user.raw.exp * 1000 : Date.now() + VERIFIED_NO_EXP_MS;
|
|
110
|
+
if (verified.size >= VERIFIED_MAX) verified.delete(verified.keys().next().value as string);
|
|
111
|
+
verified.set(token, { user, until: exp });
|
|
112
|
+
}
|
|
113
|
+
return user;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function verifyJWT(token: string): Promise<AuthUser | null> {
|
|
88
117
|
const jwksSet = getJWKS();
|
|
89
118
|
const issuers = getIssuerCandidates();
|
|
90
119
|
if (!jwksSet || issuers.length === 0) {
|
|
@@ -101,6 +101,33 @@ export function readTextCached(path: string): string {
|
|
|
101
101
|
* than a missing filter.
|
|
102
102
|
*/
|
|
103
103
|
export function dirSignature(dirs: string[]): string {
|
|
104
|
+
// MEMOIZED FOR A SHORT WINDOW. The sweep is cheap once, but `loadDefinition` computes
|
|
105
|
+
// it on EVERY call, and the callers are bursts: `listDefinitions` loads ~270
|
|
106
|
+
// components per request, tool registration loads every app per MCP initialize, and
|
|
107
|
+
// a streaming publish re-resolves its definition per chunk. Measured on the real tree
|
|
108
|
+
// that was ~1ms of blocked event loop per call, so a catalogue read was ~30k sync
|
|
109
|
+
// syscalls. Within the window the signature is reused; an edit still shows within
|
|
110
|
+
// SIGNATURE_TTL_MS, which is below the reload latency a developer can perceive.
|
|
111
|
+
const key = dirs.join("\0");
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const hit = signatures.get(key);
|
|
114
|
+
if (hit && now - hit.at < SIGNATURE_TTL_MS) return hit.sig;
|
|
115
|
+
const sig = computeDirSignature(dirs);
|
|
116
|
+
signatures.set(key, { at: now, sig });
|
|
117
|
+
return sig;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const SIGNATURE_TTL_MS = 500;
|
|
121
|
+
const signatures = new Map<string, { at: number; sig: string }>();
|
|
122
|
+
|
|
123
|
+
/** Drop every memoized signature, so the next `dirSignature` sweeps the disk again.
|
|
124
|
+
* For code that has JUST written a definition and must read it back within the window
|
|
125
|
+
* (tests, an installer). Steady-state callers never need this. */
|
|
126
|
+
export function forgetDirSignatures(): void {
|
|
127
|
+
signatures.clear();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function computeDirSignature(dirs: string[]): string {
|
|
104
131
|
const parts: string[] = [];
|
|
105
132
|
const walk = (dir: string) => {
|
|
106
133
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { readdirSync, existsSync, statSync } from "fs";
|
|
9
9
|
import { join } from "path";
|
|
10
|
-
import { readDefCached, defPath, isDefFile, defName } from "../fsCache.js";
|
|
10
|
+
import { readDefCached, defPath, isDefFile, defName, dirSignature, cachedBySignature } from "../fsCache.js";
|
|
11
11
|
import { SHARED_DIR, appDirs, templateDirs, componentDirs, resolveComponentDirs, parseRef, projectDir, listOrgs } from "./homes.js";
|
|
12
12
|
import type { UnoverseDefinition } from "../model.js";
|
|
13
13
|
import { normalizeDeclaredKind, defaultLayoutOf } from "../model.js";
|
|
@@ -362,8 +362,25 @@ export function loadTemplateApp(id: string, org?: string): AppManifest | null {
|
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
/** List MCP apps — every TEMPLATE app (one org's, or all orgs') PLUS every universal
|
|
365
|
-
* COMPONENT app (a component with a manifest.json). Both are native MCP app tools.
|
|
365
|
+
* COMPONENT app (a component with a manifest.json). Both are native MCP app tools.
|
|
366
|
+
*
|
|
367
|
+
* MEMOIZED ON THE TREE'S SIGNATURE, like every other catalogue read. This walk (every
|
|
368
|
+
* org's apps, components and templates, a manifest load per entry) ran on every MCP
|
|
369
|
+
* initialize, twice per anonymous MCP message, on every app open and every sitetool
|
|
370
|
+
* read, and nothing in it changes between requests except when a file is edited. The
|
|
371
|
+
* returned array is SHARED: callers read it (find/filter/map), never mutate it. */
|
|
366
372
|
export function listApps(org?: string): AppManifest[] {
|
|
373
|
+
const orgsToWalk = org != null ? [org] : listOrgs();
|
|
374
|
+
const dirs = [
|
|
375
|
+
...appDirs(org ?? null).map((d) => d.dir),
|
|
376
|
+
...(org == null ? [SHARED_DIR.component] : []),
|
|
377
|
+
...orgsToWalk.map((o) => join(projectDir(o), "components")),
|
|
378
|
+
...templateDirs(org ?? null).map((d) => d.dir),
|
|
379
|
+
];
|
|
380
|
+
return cachedBySignature(`apps:${org ?? "*"}`, dirSignature(dirs), () => walkApps(org, orgsToWalk));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function walkApps(org: string | undefined, orgsToWalk: string[]): AppManifest[] {
|
|
367
384
|
const out: AppManifest[] = [];
|
|
368
385
|
// PER-ENTRY ISOLATION (same law as listDefinitions): this list is built INSIDE MCP
|
|
369
386
|
// server construction, so one unloadable app taking the walk down means EVERY
|
|
@@ -402,7 +419,6 @@ export function listApps(org?: string): AppManifest[] {
|
|
|
402
419
|
// and a discovered org component fails with "Tool <id> not found" (observed live:
|
|
403
420
|
// PerfectDay after the two-tier move). Tool id stays the bare component id (names are
|
|
404
421
|
// unique across tiers, lint-enforced); the org rides on the manifest for addressing.
|
|
405
|
-
const orgsToWalk = org != null ? [org] : listOrgs();
|
|
406
422
|
for (const o of orgsToWalk) {
|
|
407
423
|
const dir = join(projectDir(o), "components");
|
|
408
424
|
if (!existsSync(dir)) continue;
|