@unoverse-platform/studio 0.1.91 → 0.1.93
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
|
@@ -22,7 +22,7 @@ export function lintNode(dir, pkg) {
|
|
|
22
22
|
const label = rel(dir);
|
|
23
23
|
|
|
24
24
|
if (!existsSync(nodePath))
|
|
25
|
-
return report("error", label, `has no node.yaml. It is the one REQUIRED file in a node folder (
|
|
25
|
+
return report("error", label, `has no node.yaml. It is the one REQUIRED file in a node folder (https://docs.unoverse.ai/nodes/manifest-nodes)`);
|
|
26
26
|
|
|
27
27
|
const node = readYaml(nodePath);
|
|
28
28
|
if (!node) return;
|
|
@@ -39,13 +39,13 @@ export function lintNode(dir, pkg) {
|
|
|
39
39
|
|
|
40
40
|
const ways = [hasFolder ? `${s}/` : null, onDisk ? `${s}.yaml` : null, inline ? "node.yaml" : null].filter(Boolean);
|
|
41
41
|
if (ways.length > 1) {
|
|
42
|
-
report("error", rel(hasFolder ? folder : file), `"${s}" is defined in ${ways.join(" and ")}. Pick one; this is never a merge (
|
|
42
|
+
report("error", rel(hasFolder ? folder : file), `"${s}" is defined in ${ways.join(" and ")}. Pick one; this is never a merge (https://docs.unoverse.ai/nodes/manifest-nodes)`);
|
|
43
43
|
continue;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// `api` is always the folder form, so every node reads the same way.
|
|
47
47
|
if (s === "api" && (onDisk || inline)) {
|
|
48
|
-
report("error", onDisk ? rel(file) : rel(nodePath), `api must be a FOLDER: api/run.yaml, api/events.yaml, and one file per further part (api/service.yaml, api/toolExchange.yaml, api/narrate.yaml). Every node has the same shape (
|
|
48
|
+
report("error", onDisk ? rel(file) : rel(nodePath), `api must be a FOLDER: api/run.yaml, api/events.yaml, and one file per further part (api/service.yaml, api/toolExchange.yaml, api/narrate.yaml). Every node has the same shape (https://docs.unoverse.ai/nodes/manifest-nodes)`);
|
|
49
49
|
continue;
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -54,7 +54,7 @@ export function lintNode(dir, pkg) {
|
|
|
54
54
|
// every rule below reads the same shape whichever layout the author chose.
|
|
55
55
|
const files = readdirSync(folder).filter((f) => /\.ya?ml$/.test(f)).sort();
|
|
56
56
|
if (!files.length) {
|
|
57
|
-
report("error", rel(folder), `is an empty ${s}/ folder. Delete it, or add one file per top-level key (
|
|
57
|
+
report("error", rel(folder), `is an empty ${s}/ folder. Delete it, or add one file per top-level key (https://docs.unoverse.ai/nodes/manifest-nodes)`);
|
|
58
58
|
continue;
|
|
59
59
|
}
|
|
60
60
|
const doc = {};
|
|
@@ -64,7 +64,7 @@ export function lintNode(dir, pkg) {
|
|
|
64
64
|
if (part === undefined || part === null) continue;
|
|
65
65
|
// The filename IS the key, so a file that repeats it nests the block twice.
|
|
66
66
|
if (typeof part === "object" && !Array.isArray(part) && Object.keys(part).length === 1 && key in part)
|
|
67
|
-
report("error", rel(join(folder, f)), `starts with "${key}:" again. The FILENAME is the key, so this file holds the CONTENTS of ${key} (
|
|
67
|
+
report("error", rel(join(folder, f)), `starts with "${key}:" again. The FILENAME is the key, so this file holds the CONTENTS of ${key} (https://docs.unoverse.ai/nodes/manifest-nodes)`);
|
|
68
68
|
doc[key] = part;
|
|
69
69
|
}
|
|
70
70
|
parts[s] = { doc, file: rel(folder) };
|
|
@@ -142,7 +142,7 @@ export function lintNode(dir, pkg) {
|
|
|
142
142
|
|
|
143
143
|
// The service channel must match what the node advertises. A method with no
|
|
144
144
|
// connector is undiscoverable; a connector method with no implementation is a lie
|
|
145
|
-
// that only surfaces when a consumer calls it (
|
|
145
|
+
// that only surfaces when a consumer calls it (https://docs.unoverse.ai/nodes/mcp-services).
|
|
146
146
|
if (api?.service || (iface.serviceConnectors ?? []).some((s) => s.isService === true)) {
|
|
147
147
|
const advertised = new Set(
|
|
148
148
|
(iface.serviceConnectors ?? []).filter((s) => s.isService === true).flatMap((s) => s.methods ?? []),
|
|
@@ -179,12 +179,12 @@ export function lintNode(dir, pkg) {
|
|
|
179
179
|
report(
|
|
180
180
|
"error",
|
|
181
181
|
F.node,
|
|
182
|
-
`offers service methods and declares no outputs, so it is a pure service node and needs "template: service". Without it the canvas draws connector dots that can never carry anything (
|
|
182
|
+
`offers service methods and declares no outputs, so it is a pure service node and needs "template: service". Without it the canvas draws connector dots that can never carry anything (https://docs.unoverse.ai/nodes/manifest-nodes)`,
|
|
183
183
|
);
|
|
184
184
|
|
|
185
185
|
// The mirror of it: advertising the discovery method as a tool.
|
|
186
186
|
if (advertised.has(DISCOVERY_METHOD))
|
|
187
|
-
report("error", F.iface ?? F.node, `a serviceConnector lists "${DISCOVERY_METHOD}" in methods. It is tool DISCOVERY, called by convention, and listing it advertises it to the model as a tool (
|
|
187
|
+
report("error", F.iface ?? F.node, `a serviceConnector lists "${DISCOVERY_METHOD}" in methods. It is tool DISCOVERY, called by convention, and listing it advertises it to the model as a tool (https://docs.unoverse.ai/nodes/mcp-services)`);
|
|
188
188
|
for (const method of advertised)
|
|
189
189
|
if (!api?.service?.[method])
|
|
190
190
|
report("error", F.iface ?? F.node, `advertises method "${method}" but api/service.yaml has no implementation. A consumer calling it gets an error`);
|
|
@@ -224,14 +224,14 @@ export function lintNode(dir, pkg) {
|
|
|
224
224
|
: api.toolExchange
|
|
225
225
|
? `it declares a toolExchange, which is a multi-turn loop`
|
|
226
226
|
: `an input declares a SPAWN signal`;
|
|
227
|
-
report("error", F.node, `kind is "${node.kind}" but this node is a ${derived}: ${why} (
|
|
227
|
+
report("error", F.node, `kind is "${node.kind}" but this node is a ${derived}: ${why} (https://docs.unoverse.ai/nodes/manifest-nodes)`);
|
|
228
228
|
}
|
|
229
229
|
derivedKinds.set(label, derived);
|
|
230
230
|
|
|
231
231
|
// The events table is compulsory for a node the graph can trigger: it is the only
|
|
232
232
|
// way anything leaves the node, so without it the node is a call into the void.
|
|
233
233
|
if (!(api.events ?? []).length)
|
|
234
|
-
report("error", F.api, `has run calls but no api/events.yaml. The events table is compulsory, it is the only way anything leaves a node (
|
|
234
|
+
report("error", F.api, `has run calls but no api/events.yaml. The events table is compulsory, it is the only way anything leaves a node (https://docs.unoverse.ai/nodes/manifest-nodes)`);
|
|
235
235
|
} else if (existsSync(join(dir, "src")) === false && !parts.api && !isAnnotation) {
|
|
236
236
|
// An ANNOTATION node is exempt, because "does nothing" is the whole specification. `Note`
|
|
237
237
|
// has no inputs, no outputs and no api: it is a markdown sticky on the canvas. Warning about
|
|
@@ -253,7 +253,7 @@ export function lintNode(dir, pkg) {
|
|
|
253
253
|
for (const [i, r] of rows.entries()) {
|
|
254
254
|
const from = r.from ?? "response";
|
|
255
255
|
if (!SOURCES.includes(from))
|
|
256
|
-
report("error", F.api, `events[${i}] has from: "${from}". Must be one of ${SOURCES.join(", ")} (
|
|
256
|
+
report("error", F.api, `events[${i}] has from: "${from}". Must be one of ${SOURCES.join(", ")} (https://docs.unoverse.ai/nodes/manifest-nodes)`);
|
|
257
257
|
/**
|
|
258
258
|
* A `send` ROW ADDRESSES A NODE, NOT A CONNECTOR, so the coverage and order rules below do
|
|
259
259
|
* not apply to it: there is no dot for it to cover and none to be out of order with. What it
|
|
@@ -310,7 +310,7 @@ export function lintNode(dir, pkg) {
|
|
|
310
310
|
report(
|
|
311
311
|
"error",
|
|
312
312
|
F.api,
|
|
313
|
-
`events rows are ordered ${rowOrder.join(", ")} but the output connectors are ${want.join(", ")}. Keep the table in connector order so the node reads top to bottom (
|
|
313
|
+
`events rows are ordered ${rowOrder.join(", ")} but the output connectors are ${want.join(", ")}. Keep the table in connector order so the node reads top to bottom (https://docs.unoverse.ai/nodes/manifest-nodes)`,
|
|
314
314
|
);
|
|
315
315
|
}
|
|
316
316
|
|
|
@@ -323,12 +323,12 @@ export function lintNode(dir, pkg) {
|
|
|
323
323
|
// Retired keys. Left in place they would silently do nothing, which is worse than a
|
|
324
324
|
// hard error, so each one names its replacement.
|
|
325
325
|
if (api?.response !== undefined)
|
|
326
|
-
report("error", F.api, `api.response is retired: transport/terminator/error moved INTO the call (request), and what leaves the node is the events table (
|
|
326
|
+
report("error", F.api, `api.response is retired: transport/terminator/error moved INTO the call (request), and what leaves the node is the events table (https://docs.unoverse.ai/nodes/manifest-nodes)`);
|
|
327
327
|
if (api?.narrate?.output !== undefined)
|
|
328
|
-
report("error", F.api, `narrate.output is retired. The narrator's line lands via an \`events\` row with from: narrator (
|
|
328
|
+
report("error", F.api, `narrate.output is retired. The narrator's line lands via an \`events\` row with from: narrator (https://docs.unoverse.ai/nodes/manifest-nodes)`);
|
|
329
329
|
for (const [name, m] of Object.entries(api?.service ?? {}))
|
|
330
330
|
if (m.response !== undefined)
|
|
331
|
-
report("error", F.api, `service.${name}.response is retired: transport/error moved into the call, and the value it hands back is service.${name}.returns (
|
|
331
|
+
report("error", F.api, `service.${name}.response is retired: transport/error moved into the call, and the value it hands back is service.${name}.returns (https://docs.unoverse.ai/nodes/manifest-nodes)`);
|
|
332
332
|
|
|
333
333
|
// ── chained calls ──
|
|
334
334
|
//
|
|
@@ -340,7 +340,7 @@ export function lintNode(dir, pkg) {
|
|
|
340
340
|
for (const [i, s] of list.entries()) {
|
|
341
341
|
const isLast = i === list.length - 1;
|
|
342
342
|
if (!s?.name) {
|
|
343
|
-
report("error", F.api, `${at}[${i}] has no name. A call is reached as calls.<name>, so an unnamed one cannot be read by a later call, an events row, or returns (
|
|
343
|
+
report("error", F.api, `${at}[${i}] has no name. A call is reached as calls.<name>, so an unnamed one cannot be read by a later call, an events row, or returns (https://docs.unoverse.ai/nodes/manifest-nodes)`);
|
|
344
344
|
} else {
|
|
345
345
|
if (names.includes(s.name))
|
|
346
346
|
report("error", F.api, `${at} declares two calls named "${s.name}". The second silently overwrites the first in calls.<name>`);
|
|
@@ -354,7 +354,7 @@ export function lintNode(dir, pkg) {
|
|
|
354
354
|
// left on the old spelling would send NO credential at all and fail as a vendor 401,
|
|
355
355
|
// which reads as a bad key rather than a manifest that was never migrated.
|
|
356
356
|
if (s?.auth !== undefined)
|
|
357
|
-
report("error", F.api, `${at}[${i}] (${s.name ?? "unnamed"}) uses "auth", which is retired on a call. Rename it to "credential" — that is how this node proves itself to the VENDOR. node.yaml's "auth" is the other direction: who may run this node (
|
|
357
|
+
report("error", F.api, `${at}[${i}] (${s.name ?? "unnamed"}) uses "auth", which is retired on a call. Rename it to "credential" — that is how this node proves itself to the VENDOR. node.yaml's "auth" is the other direction: who may run this node (https://docs.unoverse.ai/nodes/who-can-run-it)`);
|
|
358
358
|
|
|
359
359
|
// Only the LAST step may stream, and only on the workflow channel: its reply is the
|
|
360
360
|
// node's answer. An earlier step exists so a later one can READ it, and there is
|
|
@@ -418,8 +418,8 @@ export function lintNode(dir, pkg) {
|
|
|
418
418
|
"error",
|
|
419
419
|
F.api,
|
|
420
420
|
isLast
|
|
421
|
-
? `${at}[${i}] (${s.name ?? "unnamed"}) has transport "${s.transport}". A provided method hands back ONE value, so it has no connector to stream onto (
|
|
422
|
-
: `${at}[${i}] (${s.name ?? "unnamed"}) has transport "${s.transport}" but is not the last step. Only the last may stream (
|
|
421
|
+
? `${at}[${i}] (${s.name ?? "unnamed"}) has transport "${s.transport}". A provided method hands back ONE value, so it has no connector to stream onto (https://docs.unoverse.ai/nodes/manifest-nodes)`
|
|
422
|
+
: `${at}[${i}] (${s.name ?? "unnamed"}) has transport "${s.transport}" but is not the last step. Only the last may stream (https://docs.unoverse.ai/nodes/manifest-nodes)`,
|
|
423
423
|
);
|
|
424
424
|
// Only when it tests an earlier REPLY. A first call may perfectly well be gated on
|
|
425
425
|
// config or on the caller's params, and Salesforce's is. What is meaningless is
|
|
@@ -451,7 +451,7 @@ export function lintNode(dir, pkg) {
|
|
|
451
451
|
if (at === "narrate.request")
|
|
452
452
|
for (const k of ["name", "when"])
|
|
453
453
|
if (call[k] !== undefined)
|
|
454
|
-
report("error", F.api, `${at} sets "${k}", which belongs to a call in a list and is ignored here (
|
|
454
|
+
report("error", F.api, `${at} sets "${k}", which belongs to a call in a list and is ignored here (https://docs.unoverse.ai/nodes/manifest-nodes)`);
|
|
455
455
|
|
|
456
456
|
// RETIRED SHAPES. Each would silently do nothing rather than fail: the executor reads
|
|
457
457
|
// `run` and `service`, finds no calls at all, and the node dies on its first run with a
|
|
@@ -459,18 +459,18 @@ export function lintNode(dir, pkg) {
|
|
|
459
459
|
// because these are what every older example and every half-remembered habit produces.
|
|
460
460
|
for (const [old, now] of [["request", "api/run.yaml, a LIST"], ["steps", "api/run.yaml"], ["provides", "api/service.yaml"]])
|
|
461
461
|
if (api?.[old] !== undefined)
|
|
462
|
-
report("error", F.api, `api/${old}.yaml is retired. This is ${now}. A node's calls are ALWAYS a list, each entry starting "- name: <what it fetches>" (
|
|
462
|
+
report("error", F.api, `api/${old}.yaml is retired. This is ${now}. A node's calls are ALWAYS a list, each entry starting "- name: <what it fetches>" (https://docs.unoverse.ai/nodes/manifest-nodes)`);
|
|
463
463
|
|
|
464
464
|
for (const [name, m] of Object.entries(api?.service ?? {}))
|
|
465
465
|
for (const old of ["request", "steps"])
|
|
466
466
|
if (m[old] !== undefined)
|
|
467
|
-
report("error", F.api, `service.${name} declares "${old}". A method's calls are ALWAYS a list named "calls", even when there is one (
|
|
467
|
+
report("error", F.api, `service.${name} declares "${old}". A method's calls are ALWAYS a list named "calls", even when there is one (https://docs.unoverse.ai/nodes/manifest-nodes)`);
|
|
468
468
|
|
|
469
469
|
// Credentials must resolve to a declared type, or the run fails at execute time.
|
|
470
470
|
for (const c of iface.credentials ?? []) {
|
|
471
471
|
const name = c.name;
|
|
472
472
|
if (!pkg.credentials.has(name))
|
|
473
|
-
report("error", F.iface ?? F.node, `needs credential "${name}" but no credentials/${name}.yaml exists in this package. A package DECLARES the credentials it needs (
|
|
473
|
+
report("error", F.iface ?? F.node, `needs credential "${name}" but no credentials/${name}.yaml exists in this package. A package DECLARES the credentials it needs (https://docs.unoverse.ai/nodes/credentials)`);
|
|
474
474
|
}
|
|
475
475
|
|
|
476
476
|
// configSchema internal consistency.
|
|
@@ -495,7 +495,7 @@ export function lintNode(dir, pkg) {
|
|
|
495
495
|
report(
|
|
496
496
|
"error",
|
|
497
497
|
F.config,
|
|
498
|
-
`config.yaml declares "${field}", which is platform chrome: compose injects the run-authorization controls into every node's settings form, and a manifest must not carry its own copy (
|
|
498
|
+
`config.yaml declares "${field}", which is platform chrome: compose injects the run-authorization controls into every node's settings form, and a manifest must not carry its own copy (https://docs.unoverse.ai/nodes/who-can-run-it). Delete the field and its ui:order entry`,
|
|
499
499
|
);
|
|
500
500
|
}
|
|
501
501
|
for (const r of config.configSchema.required ?? [])
|
|
@@ -514,7 +514,7 @@ export function lintNode(dir, pkg) {
|
|
|
514
514
|
if (!names.includes(dep)) report("error", F.config, `${n}: ui:dependencies names "${dep}", which is not a sibling property`);
|
|
515
515
|
// A template field on an object/array takes a `return` expression, not handlebars.
|
|
516
516
|
if (f["ui:field"] === "template" && (f.type === "object" || f.type === "array") && typeof f.default === "string" && f.default.trim() && !f.default.startsWith("return "))
|
|
517
|
-
report("warn", F.config, `${n}: is an object template, so its value is a "return ..." expression, not handlebars (
|
|
517
|
+
report("warn", F.config, `${n}: is an object template, so its value is a "return ..." expression, not handlebars (https://docs.unoverse.ai/nodes/config-schema)`);
|
|
518
518
|
// A NUMBER OR A BOOLEAN IS NEVER A TEMPLATE FIELD, and this is an error rather than a
|
|
519
519
|
// warning because it takes the browser down. `ui:field: template` draws the CodeMirror
|
|
520
520
|
// editor, whose document is TEXT; handed the number 10 or the boolean true it builds a
|
|
@@ -529,7 +529,7 @@ export function lintNode(dir, pkg) {
|
|
|
529
529
|
report(
|
|
530
530
|
"error",
|
|
531
531
|
F.config,
|
|
532
|
-
`${n}: is a ${f.type}, so it cannot carry "ui:field: template" — that draws a text editor, which a non-string value crashes. Drop the marker; a route can still wire the field, since the engine templates any string config value (
|
|
532
|
+
`${n}: is a ${f.type}, so it cannot carry "ui:field: template" — that draws a text editor, which a non-string value crashes. Drop the marker; a route can still wire the field, since the engine templates any string config value (https://docs.unoverse.ai/nodes/config-schema)`,
|
|
533
533
|
);
|
|
534
534
|
}
|
|
535
535
|
|
|
@@ -578,7 +578,7 @@ export function lintNode(dir, pkg) {
|
|
|
578
578
|
if (!test?.testData && !api) {
|
|
579
579
|
// nothing to run, so nothing to fixture
|
|
580
580
|
} else if (!test?.testData) {
|
|
581
|
-
report("error", F.node, `has no testData. Every node needs sample data so it can be loaded and run (
|
|
581
|
+
report("error", F.node, `has no testData. Every node needs sample data so it can be loaded and run (https://docs.unoverse.ai/nodes/testing-nodes)`);
|
|
582
582
|
} else {
|
|
583
583
|
if (config?.configSchema) {
|
|
584
584
|
/**
|
|
@@ -619,7 +619,7 @@ export function lintNode(dir, pkg) {
|
|
|
619
619
|
report("error", F.test, `this node has only service methods, so testData needs a "call" block naming one. Otherwise nothing can run it`);
|
|
620
620
|
}
|
|
621
621
|
|
|
622
|
-
// WHO MAY RUN THIS NODE (
|
|
622
|
+
// WHO MAY RUN THIS NODE (https://docs.unoverse.ai/nodes/who-can-run-it).
|
|
623
623
|
//
|
|
624
624
|
// COMPULSORY, and that is the entire point of the block. Before 2026-07-28 authorization was
|
|
625
625
|
// an OPTIONAL `requires: { role }`, so a node that said nothing was indistinguishable from a
|
|
@@ -627,17 +627,17 @@ export function lintNode(dir, pkg) {
|
|
|
627
627
|
// answer to "who may run this", so each node now states its stance out loud and a reviewer
|
|
628
628
|
// reads a decision rather than an absence.
|
|
629
629
|
if (node.requires !== undefined)
|
|
630
|
-
report("error", F.node, `"requires" is retired on a node. Authorization is now "auth: { required, role }" — and unlike requires it is compulsory, so a node cannot stay silent about who may run it (
|
|
630
|
+
report("error", F.node, `"requires" is retired on a node. Authorization is now "auth: { required, role }" — and unlike requires it is compulsory, so a node cannot stay silent about who may run it (https://docs.unoverse.ai/nodes/who-can-run-it)`);
|
|
631
631
|
|
|
632
632
|
const auth = node.auth;
|
|
633
633
|
if (auth === undefined) {
|
|
634
|
-
report("error", F.node, `node.yaml has no "auth" block. Every node states who may run it: "auth: { required: false }" adds no requirement of its own (the trigger still owns the door), "required: true" demands a signed-in caller, and "role: noun:verb" demands a specific claim (
|
|
634
|
+
report("error", F.node, `node.yaml has no "auth" block. Every node states who may run it: "auth: { required: false }" adds no requirement of its own (the trigger still owns the door), "required: true" demands a signed-in caller, and "role: noun:verb" demands a specific claim (https://docs.unoverse.ai/nodes/who-can-run-it)`);
|
|
635
635
|
} else if (typeof auth.required !== "boolean") {
|
|
636
|
-
report("error", F.node, `auth.required must be true or false, written out. Leaving it off would put the node back to being silent about authorization, which is what this block exists to stop (
|
|
636
|
+
report("error", F.node, `auth.required must be true or false, written out. Leaving it off would put the node back to being silent about authorization, which is what this block exists to stop (https://docs.unoverse.ai/nodes/who-can-run-it)`);
|
|
637
637
|
} else if (auth.role !== undefined && auth.required === false) {
|
|
638
638
|
// A role lives on a token, so demanding one while waiving the token is unsatisfiable. It
|
|
639
639
|
// would read as "protected" on the acceptance screen and admit everyone at run time.
|
|
640
|
-
report("error", F.node, `auth declares role "${auth.role}" with required: false. A role lives on a token, so this can never be satisfied — a node asking for a role is asking for a signed-in caller (
|
|
640
|
+
report("error", F.node, `auth declares role "${auth.role}" with required: false. A role lives on a token, so this can never be satisfied — a node asking for a role is asking for a signed-in caller (https://docs.unoverse.ai/nodes/who-can-run-it)`);
|
|
641
641
|
}
|
|
642
642
|
|
|
643
643
|
// whenToUse decides whether the node SURFACES to the building agent at all.
|
|
@@ -646,15 +646,15 @@ export function lintNode(dir, pkg) {
|
|
|
646
646
|
// Absorbed from scripts/check-node-meta.mjs (2026-07-29, previously an orphan
|
|
647
647
|
// nothing invoked): a node without whenToUse is invisible to catalog ranking
|
|
648
648
|
// and marketplace search — a silent discovery failure, so it fails the lint.
|
|
649
|
-
report("error", F.node, `whenToUse is missing. The ranker embeds it for catalog/semantic search; without it this node cannot be discovered by capability (
|
|
649
|
+
report("error", F.node, `whenToUse is missing. The ranker embeds it for catalog/semantic search; without it this node cannot be discovered by capability (https://docs.unoverse.ai/nodes/node-discoverability)`);
|
|
650
650
|
}
|
|
651
651
|
if (w) {
|
|
652
652
|
if (/^use this (node|when)/i.test(w))
|
|
653
|
-
report("warn", F.node, `whenToUse opens by restating itself. Lead with the OUTCOME in task vocabulary (
|
|
653
|
+
report("warn", F.node, `whenToUse opens by restating itself. Lead with the OUTCOME in task vocabulary (https://docs.unoverse.ai/nodes/node-discoverability)`);
|
|
654
654
|
if (/^(hybrid|attach|this is an?|a powerful)/i.test(w))
|
|
655
|
-
report("warn", F.node, `whenToUse opens with plumbing or marketing. Outcome first, mechanism last; it sinks the ranking (
|
|
655
|
+
report("warn", F.node, `whenToUse opens with plumbing or marketing. Outcome first, mechanism last; it sinks the ranking (https://docs.unoverse.ai/nodes/node-discoverability)`);
|
|
656
656
|
if (node.description && w.toLowerCase() === node.description.toLowerCase())
|
|
657
|
-
report("error", F.node, `whenToUse merely repeats description. It carries zero selection signal (
|
|
657
|
+
report("error", F.node, `whenToUse merely repeats description. It carries zero selection signal (https://docs.unoverse.ai/nodes/node-discoverability)`);
|
|
658
658
|
}
|
|
659
659
|
|
|
660
660
|
// ALLOWED_HOSTS. A manifest cannot execute, but it can say "send this credential to
|
|
@@ -733,7 +733,7 @@ export function lintNode(dir, pkg) {
|
|
|
733
733
|
// Renamed with the call key it mirrors, so the package's promise and the call's spelling
|
|
734
734
|
// stay the same word. Left as `auth` it would list schemes for a key no call has.
|
|
735
735
|
if (pkg.requires?.auth !== undefined)
|
|
736
|
-
report("error", rel(pkg.file), `package.yaml uses requires.auth, which is retired. Rename it to requires.credential to match the call key (
|
|
736
|
+
report("error", rel(pkg.file), `package.yaml uses requires.auth, which is retired. Rename it to requires.credential to match the call key (https://docs.unoverse.ai/nodes/who-can-run-it)`);
|
|
737
737
|
|
|
738
738
|
if (pkg.requires && api)
|
|
739
739
|
for (const { at, call } of calls) {
|
|
@@ -46,7 +46,7 @@ export function lintPackage(dir) {
|
|
|
46
46
|
report(
|
|
47
47
|
"error",
|
|
48
48
|
rel(join(credDir, f)),
|
|
49
|
-
`credential "${doc.name}" is also declared in ${prior.file} with different fields (${prior.fields} vs ${fields}). They share one entry in context.credentials and must agree (
|
|
49
|
+
`credential "${doc.name}" is also declared in ${prior.file} with different fields (${prior.fields} vs ${fields}). They share one entry in context.credentials and must agree (https://docs.unoverse.ai/nodes/credentials)`,
|
|
50
50
|
);
|
|
51
51
|
else if (!prior) allCredentials.set(doc.name, { file: rel(join(credDir, f)), fields });
|
|
52
52
|
}
|
|
@@ -67,7 +67,7 @@ export function lintPackage(dir) {
|
|
|
67
67
|
report(
|
|
68
68
|
"error",
|
|
69
69
|
rel(entry),
|
|
70
|
-
`still calls api.registerCredential() while this package also declares credentials/ manifests. Registration is first-wins and silent, so one of them is dropped at random. Move the declaration, do not copy it (
|
|
70
|
+
`still calls api.registerCredential() while this package also declares credentials/ manifests. Registration is first-wins and silent, so one of them is dropped at random. Move the declaration, do not copy it (https://docs.unoverse.ai/nodes/credentials)`,
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -40,6 +40,9 @@ export function contextFor(node: ComposedNode, inputs: any, config: any, executi
|
|
|
40
40
|
credentials: executionContext?.credentials ?? {},
|
|
41
41
|
signal: inputs ?? {},
|
|
42
42
|
services: executionContext?.services ?? {},
|
|
43
|
+
// The caller's disconnect. A tool loop stops between turns when it fires, so a run
|
|
44
|
+
// the engine reaped cannot keep spending model calls (observed live 2026-09-05).
|
|
45
|
+
abort: executionContext?.abortSignal,
|
|
43
46
|
// Picked field by field, never spread: a future field on auth.user must be an explicit
|
|
44
47
|
// decision to expose, not something a manifest silently inherits.
|
|
45
48
|
user: { email: user.email, id: user.id ?? user.sub, name: user.name },
|
|
@@ -11,7 +11,7 @@ import type { RunContext } from "../context.js";
|
|
|
11
11
|
import { evaluate } from "../templating.js";
|
|
12
12
|
import { runCalls } from "../http/request.js";
|
|
13
13
|
import type { StateStore } from "../state.js";
|
|
14
|
-
import { rowComponentsFromResults, renderRowComponents } from "../../../agent-mcp/rowComponents.js";
|
|
14
|
+
import { rowComponentsFromResults, renderRowComponents, screenLine } from "../../../agent-mcp/rowComponents.js";
|
|
15
15
|
import { withHelpers } from "../helpers.js";
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -103,13 +103,26 @@ async function serviceMethod(
|
|
|
103
103
|
if (!session?.userId) {
|
|
104
104
|
console.log(`[manifests] ${node.type}: ${found.length} row component(s) not rendered — no live session on this run`);
|
|
105
105
|
}
|
|
106
|
-
renderRowComponents(found, sessionCtx, log);
|
|
106
|
+
const outcomes = renderRowComponents(found, sessionCtx, log);
|
|
107
|
+
/**
|
|
108
|
+
* THE SEARCH MIRRORS TOO (MCP_APP_PROTOCOL §0.1, ruled 2026-09-05). When the delivery
|
|
109
|
+
* holds a component that takes input (a shell), the model must answer the SCREEN,
|
|
110
|
+
* not the rows: it wait for the delivery's verdict — instant here, the ladder at the
|
|
111
|
+
* door leaves one candidate and the rules brain answers at once — and the result
|
|
112
|
+
* carries a `screen` line saying what is on screen and what was not shown.
|
|
113
|
+
* Card-only deliveries stay fire-and-forget, as fast as ever.
|
|
114
|
+
*/
|
|
115
|
+
if (found.some((i) => i.shell)) {
|
|
116
|
+
const screen = screenLine(await outcomes);
|
|
117
|
+
const result = await evaluate(spec.returns, { response: last, calls: results, params, config: ctx.config, user: ctx.user });
|
|
118
|
+
return screen && result && typeof result === "object" && !Array.isArray(result) ? { ...(result as Record<string, unknown>), screen } : result;
|
|
119
|
+
}
|
|
107
120
|
} else {
|
|
108
121
|
const n = Array.isArray(rows) ? rows.length : 0;
|
|
109
122
|
console.log(`[manifests] ${node.type}: no row components in ${n} row(s)`);
|
|
110
123
|
// The empty delivery is announced (§12e mechanical spent rule): this search
|
|
111
124
|
// ran and surfaced nothing, and the standing set's fate hangs on that fact.
|
|
112
|
-
renderRowComponents(found, sessionCtx, log);
|
|
125
|
+
void renderRowComponents(found, sessionCtx, log);
|
|
113
126
|
}
|
|
114
127
|
}
|
|
115
128
|
|
|
@@ -29,6 +29,12 @@ export interface RunContext {
|
|
|
29
29
|
* phrase for two different things.
|
|
30
30
|
*/
|
|
31
31
|
calls: Record<string, any>;
|
|
32
|
+
/**
|
|
33
|
+
* The CALLER'S DISCONNECT, when the node runs over a stream. Set from the request's
|
|
34
|
+
* own signal; a turn loop checks it between turns and stops. Absent for a node that
|
|
35
|
+
* runs in-process, which cannot be hung up on.
|
|
36
|
+
*/
|
|
37
|
+
abort?: AbortSignal;
|
|
32
38
|
/**
|
|
33
39
|
* The SIGNED-IN person, and nothing that authenticates as them.
|
|
34
40
|
*
|
|
@@ -223,6 +223,14 @@ export async function runToolLoop(
|
|
|
223
223
|
const usage = makeUsageCollector(node, ctx);
|
|
224
224
|
|
|
225
225
|
for (turn = 1; turn <= maxTurns; turn++) {
|
|
226
|
+
// THE CALLER HUNG UP. The engine reaps a run by aborting the node's request; a loop
|
|
227
|
+
// that did not look was observed spending a dozen model turns against a run that no
|
|
228
|
+
// longer existed (2026-09-05). Checked between turns: a turn in flight completes,
|
|
229
|
+
// the next never starts.
|
|
230
|
+
if (ctx.abort?.aborted) {
|
|
231
|
+
console.warn(`[manifests] ${node.type}: run aborted by the caller after ${turn - 1} turn(s) — stopping`);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
226
234
|
const scoped: RunContext = {
|
|
227
235
|
...ctx,
|
|
228
236
|
services: { ...ctx.services, [connector]: { tools: live } },
|
|
@@ -134,6 +134,17 @@ export function diskSource(home: string): ManifestSource {
|
|
|
134
134
|
return {
|
|
135
135
|
name: "disk",
|
|
136
136
|
async listPackages(): Promise<RawPackage[]> {
|
|
137
|
+
return readDiskPackages(home);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The same read, synchronous. Disk settles at once; the source above is async only
|
|
144
|
+
* because the row source beside it must be. The publish collector (items/collect.ts) is
|
|
145
|
+
* synchronous end to end, so it reads here rather than growing a promise it cannot await.
|
|
146
|
+
*/
|
|
147
|
+
export function readDiskPackages(home: string): RawPackage[] {
|
|
137
148
|
if (!fs.existsSync(home)) return [];
|
|
138
149
|
const packages: RawPackage[] = [];
|
|
139
150
|
|
|
@@ -182,6 +193,4 @@ export function diskSource(home: string): ManifestSource {
|
|
|
182
193
|
if (pkg.nodes.length || Object.keys(pkg.credentials).length) packages.push(pkg);
|
|
183
194
|
}
|
|
184
195
|
return packages;
|
|
185
|
-
},
|
|
186
|
-
};
|
|
187
196
|
}
|
|
@@ -44,6 +44,9 @@ export function buildExecutionContext(nodeType: string, inputs: any, config: any
|
|
|
44
44
|
},
|
|
45
45
|
publishingContext,
|
|
46
46
|
activeLoop: context.activeLoop,
|
|
47
|
+
// The caller's disconnect, when the node runs over a stream (routeExecuteStream). A
|
|
48
|
+
// turn loop checks it between turns so a reaped run stops spending model calls.
|
|
49
|
+
abortSignal: context.abortSignal,
|
|
47
50
|
logger: console,
|
|
48
51
|
auth: context.auth,
|
|
49
52
|
};
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* - data.config.enableAgentMemory → getGoalContext, writeNote,
|
|
15
15
|
* updateGoalState, searchHistory, resumeGoal, archiveGoal (goal-scoped working memory)
|
|
16
16
|
*
|
|
17
|
+
* recordAttempt is PLATFORM-ONLY (PLATFORM_MEMORY_METHODS): the judge writes the ledger
|
|
18
|
+
* through executePlatformMemoryMethod; it is never merged into an agent's schema.
|
|
19
|
+
*
|
|
17
20
|
* Per-agent (not canvas-wide) so memory never leaks into worker agents UNO
|
|
18
21
|
* builds inside the workflows it creates — those default off. Still a TOGGLE,
|
|
19
22
|
* not a connector: a bare agent with the toggle on still gets its schema with
|
|
@@ -57,7 +60,7 @@ export const USER_MEMORY_METHODS: Record<string, any> = {
|
|
|
57
60
|
export const AGENT_MEMORY_METHODS: Record<string, any> = {
|
|
58
61
|
getGoalContext: {
|
|
59
62
|
description:
|
|
60
|
-
"Your wake-up briefing: the active goal (description, acceptance criteria), your last saved goal-state (plan, current step
|
|
63
|
+
"Your wake-up briefing: the active goal (description, acceptance criteria), the LEDGER (attempts so far, whether the goal is met, the open criteria the judge failed last time with its reason, any standing directive), your last saved goal-state (plan, current step), recent journal notes, durable environment facts, and recent past-goal summaries. Call FIRST when starting or resuming work — it replaces re-reading history. Read-only, cheap.",
|
|
61
64
|
input: { type: "object", additionalProperties: false, properties: {} },
|
|
62
65
|
},
|
|
63
66
|
writeNote: {
|
|
@@ -79,7 +82,7 @@ export const AGENT_MEMORY_METHODS: Record<string, any> = {
|
|
|
79
82
|
},
|
|
80
83
|
updateGoalState: {
|
|
81
84
|
description:
|
|
82
|
-
"Overwrite your compact goal-state object — the FIRST thing future-you reads after a reset. Keep it current after each meaningful step: the goal description, the plan (short steps with status), the current step
|
|
85
|
+
"Overwrite your compact goal-state object — the FIRST thing future-you reads after a reset. Keep it current after each meaningful step: the goal description, the plan (short steps with status), and the current step. Supplied fields merge over the existing state; it is also snapshotted to durable storage. (The acceptance bar is NOT set here, and neither are blockers: the bar is locked independently and what is still open is derived from the judge's last verdict, so you cannot grade your own homework.)",
|
|
83
86
|
input: {
|
|
84
87
|
type: "object",
|
|
85
88
|
additionalProperties: false,
|
|
@@ -87,7 +90,6 @@ export const AGENT_MEMORY_METHODS: Record<string, any> = {
|
|
|
87
90
|
description: { type: "string", description: "The goal in one sentence (sets/updates the goal description)" },
|
|
88
91
|
plan: { type: "array", items: { type: "string" }, description: "Short step list, each prefixed with its status, e.g. '[done] fetch schema'" },
|
|
89
92
|
currentStep: { type: "string", description: "What is being worked on right now" },
|
|
90
|
-
blockers: { type: "array", items: { type: "string" }, description: "Open blockers; empty array clears them" },
|
|
91
93
|
},
|
|
92
94
|
},
|
|
93
95
|
},
|
|
@@ -134,14 +136,18 @@ export const USER_MEMORY_INSTRUCTIONS =
|
|
|
134
136
|
"User memory is available. A background summary of who this user is (identity + current state) was injected into your context at start — do not re-fetch it. queryMemory(topic) searches deeper when a specific topic needs prior context.";
|
|
135
137
|
|
|
136
138
|
export const AGENT_MEMORY_INSTRUCTIONS =
|
|
137
|
-
"You have persistent goal memory that survives restarts and context resets. On wake: getGoalContext before doing work — it shows the active goal AND your recent goals with their progress (done/total). If an earlier goal is unfinished or was closed too soon, resumeGoal reopens it so you continue from where you left off instead of starting over. While working: record decisions, failures, environment facts, and progress with writeNote, and keep updateGoalState accurate after each meaningful step (mark steps '[done] …' as you finish them — don't leave the plan frozen on step 1). On completion: REVIEW first — verify the output meets the locked acceptance criteria, mark the remaining plan steps done and set a closing currentStep via updateGoalState, then archiveGoal with a specific summary. The journal is for future-you — write what re-hydration needs, nothing else.";
|
|
139
|
+
"You have persistent goal memory that survives restarts and context resets. On wake: getGoalContext before doing work — it shows the active goal, its ledger (attempts, whether it is met, the criteria still open from the judge's last verdict), AND your recent goals with their progress (done/total). If an earlier goal is unfinished or was closed too soon, resumeGoal reopens it so you continue from where you left off instead of starting over. While working: record decisions, failures, environment facts, and progress with writeNote, and keep updateGoalState accurate after each meaningful step (mark steps '[done] …' as you finish them — don't leave the plan frozen on step 1). On completion: REVIEW first — verify the output meets the locked acceptance criteria, mark the remaining plan steps done and set a closing currentStep via updateGoalState, then archiveGoal with a specific summary. The journal is for future-you — write what re-hydration needs, nothing else.";
|
|
138
140
|
|
|
139
141
|
// A harness BUILDER runs under an external gate (Define→build→Verdict→Deliver). It must NOT
|
|
140
142
|
// close or complete the goal itself — the harness's Deliver node owns archiving, and only when
|
|
141
143
|
// an independent Verdict passed. So the builder gets the read/journal memory tools WITHOUT the
|
|
142
144
|
// goal-lifecycle tools (archiveGoal/resumeGoal), and an instruction that forbids self-completion.
|
|
143
145
|
export const AGENT_MEMORY_INSTRUCTIONS_BUILDER =
|
|
144
|
-
"You have persistent goal memory that survives restarts and context resets. On wake: getGoalContext BEFORE doing work — it shows the active goal AND
|
|
146
|
+
"You have persistent goal memory that survives restarts and context resets. On wake: getGoalContext BEFORE doing work — it shows the active goal AND its ledger: the criteria the independent judge failed on the last attempt (each with its reason and how many attempts in a row it has failed), plus any standing directive. If open criteria are present, your last build was REJECTED: address those exact gaps before rebuilding, and do not re-submit the same build. While working: record decisions, failures, environment facts, and progress with writeNote, and keep updateGoalState accurate (plan, currentStep). You do NOT complete, archive, or close the goal — an external harness independently judges the result and owns completion. Build the best workflow, produce its real output, then stop and report; never declare the goal done yourself.";
|
|
147
|
+
|
|
148
|
+
// PLATFORM-ONLY methods: executed by the harness through executePlatformMemoryMethod, never
|
|
149
|
+
// merged into an agent's schema. The judge records verdicts; an agent cannot.
|
|
150
|
+
export const PLATFORM_MEMORY_METHODS = new Set(["recordAttempt"]);
|
|
145
151
|
|
|
146
152
|
// UnoverseMCP authoring methods. An agent whose toolset contains any of these IS a harness
|
|
147
153
|
// builder — used to strip its goal-completion authority (see mergePlatformMemorySchema).
|
|
@@ -276,19 +282,16 @@ function resolveIdentity(params: any, context: any): MemoryCallIdentity | { erro
|
|
|
276
282
|
if (!userId) return { error: "No user identity in execution context — cannot access memory" };
|
|
277
283
|
if (!workflowId) return { error: "No workflow id in execution context — cannot access memory" };
|
|
278
284
|
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
// per
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
const agentId = params?.agentId || "
|
|
287
|
-
const agentNode =
|
|
288
|
-
|
|
289
|
-
? null
|
|
290
|
-
: (context?.workflow?.nodes || context?.cachedWorkflow?.nodes || []).find((n: any) => n?.id === agentId);
|
|
291
|
-
const agentName = agentNode?.data?.label || (agentId === "shared" ? "Canvas" : undefined);
|
|
285
|
+
// TWO DRAWERS (AGENT_MEMORY.md ruling 2026-09-05, part 2). The GOAL is a property of the
|
|
286
|
+
// canvas (HARNESS.md §12 ruling 1) — one active goal per workflowId, shared brief for
|
|
287
|
+
// everyone. SCRATCH is per agent: the calling node on a canvas, or "caller" for an external
|
|
288
|
+
// builder over the builder MCP (no node). The platform resolves the id from the execution
|
|
289
|
+
// context; agents never pass identity. A caller may still pass an explicit `agentId` to
|
|
290
|
+
// address a specific scratch (isolated builder scratch for a future tournament).
|
|
291
|
+
const nodes: any[] = context?.workflow?.nodes || context?.cachedWorkflow?.workflow?.nodes || context?.cachedWorkflow?.nodes || [];
|
|
292
|
+
const agentId: string = params?.agentId || context?.callingNodeId || context?.nodeId || "caller";
|
|
293
|
+
const agentNode = nodes.find((n: any) => n?.id === agentId);
|
|
294
|
+
const agentName: string | undefined = agentNode?.data?.label || (agentId === "caller" ? "External builder" : undefined);
|
|
292
295
|
|
|
293
296
|
return {
|
|
294
297
|
userId,
|
|
@@ -395,14 +398,22 @@ export async function executePlatformMemoryMethod(method: string, params: any, c
|
|
|
395
398
|
);
|
|
396
399
|
}
|
|
397
400
|
case "updateGoalState": {
|
|
398
|
-
// acceptanceCriteria
|
|
399
|
-
// judge reads
|
|
400
|
-
//
|
|
401
|
-
|
|
401
|
+
// description, acceptanceCriteria and directive are first-class columns on the goal
|
|
402
|
+
// (the judge reads criteria from goal.acceptanceCriteria; the directive is the one
|
|
403
|
+
// platform instruction slot), NOT part of the free-form state blob — pull them out so
|
|
404
|
+
// they route to their own fields. `directive` is only sent when the caller passed it,
|
|
405
|
+
// because null means CLEAR.
|
|
406
|
+
const { description, acceptanceCriteria, directive, ...stateFields } = params || {};
|
|
402
407
|
const state = Object.keys(stateFields).length > 0 ? stateFields : undefined;
|
|
408
|
+
const body: Record<string, any> = { userId, workflowId, agentId, agentName, description, acceptanceCriteria, state };
|
|
409
|
+
if (params && Object.prototype.hasOwnProperty.call(params, "directive")) body.directive = directive ?? null;
|
|
410
|
+
return await memoryServerPost("/agent/state", body, accessToken);
|
|
411
|
+
}
|
|
412
|
+
case "recordAttempt": {
|
|
413
|
+
// The judge's verdict → one ledger row. Platform-only (PLATFORM_MEMORY_METHODS).
|
|
403
414
|
return await memoryServerPost(
|
|
404
|
-
"/agent/
|
|
405
|
-
{ userId, workflowId, agentId,
|
|
415
|
+
"/agent/attempt",
|
|
416
|
+
{ userId, workflowId, agentId, results: params?.results, deliverables: params?.deliverables || [] },
|
|
406
417
|
accessToken,
|
|
407
418
|
);
|
|
408
419
|
}
|