@unoverse-platform/studio 0.1.115 → 0.1.117
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/local/catalog.ts +3 -0
- package/local/plugin.ts +12 -9
- package/local/scaffold.d.mts +1 -0
- package/local/scaffold.mjs +12 -0
- package/package.json +1 -1
- package/screens/AgentChain.tsx +232 -0
- package/screens/AgentsView.tsx +85 -6
- package/screens/SkillsView.tsx +21 -3
- package/screens/TemplatesLibraryView.tsx +32 -3
- package/screens/TemplatesView.tsx +2 -2
- package/screens/index.ts +4 -1
- package/src/host.ts +5 -0
- package/src/index.css +11 -0
- package/vendor/base/src/agent-mcp/discovery.ts +5 -2
- package/vendor/base/src/agent-mcp/index.ts +3 -1
- package/vendor/base/src/agent-mcp/invoke.ts +199 -25
- package/vendor/base/src/agent-mcp/progressive.ts +124 -0
- package/vendor/base/src/agent-mcp/tools.ts +18 -11
- package/vendor/base/src/agent-mcp/types.ts +8 -0
- package/vendor/base/src/auth/agentSwitch.ts +52 -0
- package/vendor/base/src/auth/agentToken.ts +142 -0
- package/vendor/base/src/auth/auth.ts +7 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +1 -1
- package/vendor/base/src/definitions/load/expand/refs.ts +1 -1
- package/vendor/base/src/definitions/load/expand/walk.ts +1 -1
- package/vendor/base/src/definitions/load/load.ts +6 -1
- package/vendor/base/src/definitions/registry/agentFolders.ts +113 -0
- package/vendor/base/src/definitions/registry/catalog.ts +1 -1
- package/vendor/base/src/definitions/registry/objects.ts +109 -5
- package/vendor/base/src/definitions/registry/pipelines.ts +24 -5
- package/vendor/base/src/definitions/registry/taskStarts.ts +76 -0
- package/vendor/base/src/definitions/registry/taskUri.ts +67 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +39 -3
- package/vendor/base/src/items/collect.ts +10 -0
- package/vendor/base/src/items/publish.ts +10 -0
- package/vendor/base/src/lint/design/index.mjs +14 -3
- package/vendor/base/src/lint/design/vocabulary.mjs +1 -1
- package/vendor/base/src/lint/objects/index.d.mts +1 -0
- package/vendor/base/src/lint/objects/index.mjs +121 -3
- package/vendor/base/src/lint/objects/rules.d.mts +3 -0
- package/vendor/base/src/lint/objects/rules.mjs +106 -7
- package/vendor/base/src/manifests/compose.ts +3 -0
- package/vendor/base/src/manifests/executor/callback.ts +3 -2
- package/vendor/base/src/manifests/executor/memory.ts +27 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +79 -13
- package/vendor/base/src/manifests/marketplaceMcp.ts +20 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +43 -10
- package/vendor/base/src/manifests/runtime/events.ts +1 -1
- package/vendor/base/src/manifests/runtime/http/request.ts +47 -4
- package/vendor/base/src/manifests/runtime/loops/poll.ts +27 -5
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +59 -24
- package/vendor/base/src/manifests/runtime/usage.ts +13 -5
- package/vendor/base/src/platform/agentBudget.ts +65 -0
- package/vendor/base/src/platform/agentCalendar.ts +87 -0
- package/vendor/base/src/platform/inbound.ts +67 -0
- package/vendor/base/src/platform/taskChanged.ts +25 -0
- package/vendor/base/src/platform/tokenCost.ts +414 -0
- package/vendor/base/src/security/permissions.ts +10 -2
- package/vendor/sdk/appEngine.ts +42 -1
- package/vendor/sdk/lib/core/actions.ts +10 -0
- package/vendor/sdk/lib/core/apps.ts +2 -2
- package/vendor/sdk/lib/core/client.ts +11 -0
- package/vendor/sdk/lib/core/store.ts +1 -1
- package/vendor/sdk/lib/core/types.ts +2 -0
- package/vendor/sdk/lib/dom/app.ts +3 -2
- package/vendor/sdk/lib/dom/component.ts +38 -1
- package/vendor/sdk/lib/dom/element.ts +23 -0
- package/vendor/sdk/lib/dom/markdown.ts +6 -2
- package/vendor/sdk/lib/dom/primitives.ts +97 -6
- package/vendor/sdk/lib/voiceService.ts +3 -1
- package/vendor/sdk/main.ts +1 -1
- package/vendor/sdk/pageInterfaces.ts +109 -8
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE URI FOR EVERY TASK (docs/mcp/MCP_TASKS.md § 2a, ruled 2026-10-10).
|
|
3
|
+
*
|
|
4
|
+
* Every task, of every kind (a form, a component, a template, an app, a workflow's Task Start, a
|
|
5
|
+
* pipeline), is `unoverse://tasks/<org>/<name>`. The URI is an address, never a file: it points at
|
|
6
|
+
* the asset that is the task. This module is the only place that reads or writes it; nothing else
|
|
7
|
+
* knows another spelling.
|
|
8
|
+
*
|
|
9
|
+
* It accepts exactly two forms: the URI, and the same identity written short, `<org>/<name>` (what
|
|
10
|
+
* live pages carry in `data-unoverse-interface`). Everything else is refused, so a caller holding an
|
|
11
|
+
* old spelling finds out here rather than saving or calling under a second name. A marketplace
|
|
12
|
+
* task with no org of its own is in the org `marketplace`.
|
|
13
|
+
*
|
|
14
|
+
* Pure: no files, no registry.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const TASK_SCHEME = "unoverse://tasks/";
|
|
18
|
+
|
|
19
|
+
/** The org of a marketplace task that belongs to no org. */
|
|
20
|
+
export const MARKETPLACE_ORG = "marketplace";
|
|
21
|
+
|
|
22
|
+
const ORG = /^[a-z0-9][a-z0-9-]*$/;
|
|
23
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
24
|
+
|
|
25
|
+
export interface TaskParts {
|
|
26
|
+
org: string;
|
|
27
|
+
name: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The task's parts, from its URI or its short form; null for anything else. */
|
|
31
|
+
export function taskParts(input: unknown): TaskParts | null {
|
|
32
|
+
if (typeof input !== "string") return null;
|
|
33
|
+
const raw = input.trim();
|
|
34
|
+
const body = raw.startsWith(TASK_SCHEME) ? raw.slice(TASK_SCHEME.length) : raw.includes("://") ? null : raw;
|
|
35
|
+
if (body === null) return null;
|
|
36
|
+
const parts = body.split("/");
|
|
37
|
+
if (parts.length !== 2) return null;
|
|
38
|
+
const [org, name] = parts;
|
|
39
|
+
if (!ORG.test(org) || !NAME.test(name)) return null;
|
|
40
|
+
return { org, name };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The task's URI, `unoverse://tasks/<org>/<name>`, from either form; null for anything else. */
|
|
44
|
+
export function taskUri(input: unknown): string | null {
|
|
45
|
+
const p = taskParts(input);
|
|
46
|
+
return p ? `${TASK_SCHEME}${p.org}/${p.name}` : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The URI of a task from its org (none: the marketplace) and its name. */
|
|
50
|
+
export function taskUriOf(org: string | null | undefined, name: string): string | null {
|
|
51
|
+
return taskUri(`${org || MARKETPLACE_ORG}/${name}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The short form, `<org>/<name>`: what definitions are looked up by (`loadDefinition`). */
|
|
55
|
+
export function taskShort(input: unknown): string | null {
|
|
56
|
+
const p = taskParts(input);
|
|
57
|
+
return p ? `${p.org}/${p.name}` : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The task's MCP tool name, `<org>.<name>`. MCP tool names cannot hold `://` or `/`, so the name on
|
|
62
|
+
* the wire is made from the URI by this one rule, never by hand.
|
|
63
|
+
*/
|
|
64
|
+
export function taskToolName(input: unknown): string | null {
|
|
65
|
+
const p = taskParts(input);
|
|
66
|
+
return p ? `${p.org}.${p.name}` : null;
|
|
67
|
+
}
|
|
@@ -31,6 +31,8 @@ const STATUS_TEXT: Record<number, string> = {
|
|
|
31
31
|
404: "Not Found",
|
|
32
32
|
405: "Method Not Allowed",
|
|
33
33
|
406: "Not Acceptable",
|
|
34
|
+
413: "Payload Too Large",
|
|
35
|
+
429: "Too Many Requests",
|
|
34
36
|
500: "Internal Server Error",
|
|
35
37
|
};
|
|
36
38
|
const statusLine = (code: number): string => `${code} ${STATUS_TEXT[code] ?? "OK"}`;
|
|
@@ -154,9 +156,23 @@ function writeCors(res: HttpResponse): void {
|
|
|
154
156
|
res.writeHeader("Access-Control-Max-Age", "86400");
|
|
155
157
|
}
|
|
156
158
|
|
|
157
|
-
/**
|
|
158
|
-
|
|
159
|
+
/**
|
|
160
|
+
* LIMITS FOR A PUBLIC DOOR (docs/auth-security/INBOUND.md § 3). A route anyone can call says how
|
|
161
|
+
* much it will read and whether to read at all, so a flood or an oversized body is refused before
|
|
162
|
+
* it costs anything. Optional: every route without them reads as it always did.
|
|
163
|
+
*/
|
|
164
|
+
export interface FetchLimits {
|
|
165
|
+
/** The most bytes this route reads. Over it: 413, and the rest is never buffered. */
|
|
166
|
+
maxBody?: number;
|
|
167
|
+
/** Asked before a single byte is read; false is 429. The route's rate limit. */
|
|
168
|
+
admit?: (path: string) => boolean;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Buffer the full request body; resolves on the last chunk (copies as it goes), or null once it passes `maxBody`. */
|
|
172
|
+
export function readBody(res: Pick<HttpResponse, "onData">, maxBody = Infinity): Promise<Buffer | null> {
|
|
159
173
|
return new Promise((resolve) => {
|
|
174
|
+
let size = 0;
|
|
175
|
+
let over = false;
|
|
160
176
|
// Collect copies and concat ONCE at the end. The old per-chunk
|
|
161
177
|
// `Buffer.concat([buf, chunk])` reallocated+copied the whole accumulator on every
|
|
162
178
|
// chunk — O(n²) in body size, which hurts large /execute payloads. Each chunk is
|
|
@@ -167,6 +183,13 @@ function readBody(res: HttpResponse): Promise<Buffer> {
|
|
|
167
183
|
// ArrayBuffer after this callback returns. `Buffer.from(arrayBuffer)` is only a
|
|
168
184
|
// VIEW (no copy), so wrap in a Uint8Array, which Buffer.from then copies; deferring
|
|
169
185
|
// a bare view to the final concat would read detached memory.
|
|
186
|
+
if (over) return;
|
|
187
|
+
size += chunk.byteLength;
|
|
188
|
+
if (size > maxBody) {
|
|
189
|
+
over = true;
|
|
190
|
+
chunks.length = 0;
|
|
191
|
+
return resolve(null);
|
|
192
|
+
}
|
|
170
193
|
chunks.push(Buffer.from(new Uint8Array(chunk)));
|
|
171
194
|
if (isLast) resolve(chunks.length === 1 ? chunks[0] : Buffer.concat(chunks));
|
|
172
195
|
});
|
|
@@ -181,6 +204,7 @@ export async function handleFetch(
|
|
|
181
204
|
res: HttpResponse,
|
|
182
205
|
req: HttpRequest,
|
|
183
206
|
handler: (request: Request) => Promise<Response>,
|
|
207
|
+
limits: FetchLimits = {},
|
|
184
208
|
): Promise<void> {
|
|
185
209
|
const state: PumpState = { aborted: false };
|
|
186
210
|
// THE HANDLER LEARNS OF THE DISCONNECT. `Request.signal` is how a Fetch handler is told
|
|
@@ -215,8 +239,20 @@ export async function handleFetch(
|
|
|
215
239
|
return;
|
|
216
240
|
}
|
|
217
241
|
|
|
218
|
-
const
|
|
242
|
+
const refuse = (code: 413 | 429): void => {
|
|
243
|
+
res.cork(() => {
|
|
244
|
+
res.writeStatus(statusLine(code));
|
|
245
|
+
res.writeHeader("Content-Type", "application/json");
|
|
246
|
+
writeCors(res);
|
|
247
|
+
res.end(JSON.stringify({ error: code === 413 ? "too large" : "too many requests" }));
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
if (limits.admit && !limits.admit(new URL(url).pathname)) return refuse(429);
|
|
251
|
+
if (limits.maxBody !== undefined && Number(headers["content-length"] ?? 0) > limits.maxBody) return refuse(413);
|
|
252
|
+
|
|
253
|
+
const body = method !== "GET" && method !== "HEAD" ? await readBody(res, limits.maxBody) : undefined;
|
|
219
254
|
if (state.aborted) return;
|
|
255
|
+
if (body === null) return refuse(413);
|
|
220
256
|
|
|
221
257
|
let response: Response;
|
|
222
258
|
try {
|
|
@@ -91,6 +91,16 @@ function orgDesignSystemDir(dir: string): string {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/** Collect an org repo into items: the same walk as a monorepo project, rooted at the repo. */
|
|
94
|
+
/**
|
|
95
|
+
* EVERY KIND THE WALK READS from an org's folder. A sync that reads a folder with this walk
|
|
96
|
+
* knows each of these completely: none of a kind in the folder means none in the org, so the
|
|
97
|
+
* sync may remove what is gone. A kind not listed (a recipe) is never read here, so a sync
|
|
98
|
+
* never removes it.
|
|
99
|
+
*/
|
|
100
|
+
export const COLLECTED_KINDS: ReadonlySet<string> = new Set([
|
|
101
|
+
"component", "app", "template", "atom", "identity", "agent", "object", "pipeline", "style", "skill", "prompt-block", "node",
|
|
102
|
+
]);
|
|
103
|
+
|
|
94
104
|
export function collectOrg(home: OrgHome): CollectedItem[] {
|
|
95
105
|
return collectFrom(home.dir, home.org, orgDesignSystemDir(home.dir));
|
|
96
106
|
}
|
|
@@ -118,6 +118,16 @@ export async function lintOrg(home: OrgHome): Promise<{ problems: Finding[]; err
|
|
|
118
118
|
return { problems: result.problems, errors: result.problems.filter((p: Finding) => p.level === "error") };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Lint an org repo's objects and pipelines with some files as they WOULD be (`overlay`: absolute path ->
|
|
123
|
+
* text): a change is checked before it is written (an approved table wording, pipelines/tables.md).
|
|
124
|
+
*/
|
|
125
|
+
export async function lintOrgObjects(home: OrgHome, overlay: Record<string, string> = {}): Promise<{ problems: Finding[]; errors: Finding[] }> {
|
|
126
|
+
const { lintObjects } = await import("../lint/objects/index.mjs");
|
|
127
|
+
const problems = lintObjects(home.dir, { org: { name: home.org, dir: home.dir }, overlay }).problems;
|
|
128
|
+
return { problems, errors: problems.filter((p: Finding) => p.level === "error") };
|
|
129
|
+
}
|
|
130
|
+
|
|
121
131
|
/**
|
|
122
132
|
* What publishing WOULD do, without doing it.
|
|
123
133
|
*
|
|
@@ -693,7 +693,10 @@ for (const orgDir of orgDirs) {
|
|
|
693
693
|
// rule is off, silently.
|
|
694
694
|
for (const sub of ["apps", "components", "templates", "agents"]) {
|
|
695
695
|
const d = join(orgDir, sub);
|
|
696
|
-
|
|
696
|
+
// AN AGENT FOLDER (agents/<name>/agent.yaml, docs/architecture/signal/AGENTS.md § 3) is not a
|
|
697
|
+
// definition: the objects linter checks it, as it does a pipeline folder. Only a flat file
|
|
698
|
+
// directly in agents/ is the older Agent file, still checked here until it is converted.
|
|
699
|
+
if (existsSync(d) && statSync(d).isDirectory()) homes.push(sub === "agents" ? { dir: d, flat: true } : { dir: d });
|
|
697
700
|
}
|
|
698
701
|
}
|
|
699
702
|
|
|
@@ -865,7 +868,10 @@ for (const orgDir of [DS, ...orgDirs]) {
|
|
|
865
868
|
// This was an org-blocking error until it stopped a yasisland deploy over a form
|
|
866
869
|
// nobody needed that day.
|
|
867
870
|
if (credentialDefs.size)
|
|
868
|
-
for (const
|
|
871
|
+
for (const entry of Array.isArray(readDef(mf).credentials) ? readDef(mf).credentials : [])
|
|
872
|
+
// `- googleApiCredential` or `- googleApiCredential: MapKey` (the saved name chooses
|
|
873
|
+
// between two of one type); the definition looked for is the TYPE either way.
|
|
874
|
+
for (const cname of typeof entry === "string" ? [entry] : entry && typeof entry === "object" ? Object.keys(entry) : [String(entry)])
|
|
869
875
|
if (!credentialDefs.has(String(cname)))
|
|
870
876
|
report(
|
|
871
877
|
"warn",
|
|
@@ -1039,7 +1045,12 @@ function placeNames() {
|
|
|
1039
1045
|
const walkNode = makeWalkNode(ctx);
|
|
1040
1046
|
const lintFile = makeLintFile({ ...ctx, walkNode });
|
|
1041
1047
|
|
|
1042
|
-
for (const home of homes)
|
|
1048
|
+
for (const home of homes) {
|
|
1049
|
+
const files = home.flat
|
|
1050
|
+
? readdirSync(home.dir).filter((f) => !f.startsWith(".") && isDefFile(f) && !statSync(join(home.dir, f)).isDirectory()).map((f) => join(home.dir, f))
|
|
1051
|
+
: jsonFiles(home.dir);
|
|
1052
|
+
for (const f of files) lintFile(f);
|
|
1053
|
+
}
|
|
1043
1054
|
return { problems, homes, designRoot: DESIGN_ROOT, designSystem: DS };
|
|
1044
1055
|
}
|
|
1045
1056
|
|
|
@@ -72,7 +72,7 @@ export const INTERFACE_FIELDS = new Set([
|
|
|
72
72
|
export const AUTHORED_CONTROLS = ["maxLength", "minItems", "maxItems", "enum"];
|
|
73
73
|
|
|
74
74
|
export const PRIMITIVE_PROPS = {
|
|
75
|
-
Box: ["children", "autoScroll", "revealOnStick", "selection", "appWidth"],
|
|
75
|
+
Box: ["children", "autoScroll", "revealOnStick", "selection", "appWidth", "pan"],
|
|
76
76
|
Stack: ["children", "autoScroll", "revealOnStick", "appWidth"],
|
|
77
77
|
Row: ["children", "autoScroll", "revealOnStick", "appWidth"],
|
|
78
78
|
Column: ["children", "autoScroll", "revealOnStick", "appWidth"],
|
|
@@ -22,7 +22,7 @@ import { problems, reset, report, state } from "../context.mjs";
|
|
|
22
22
|
import { validateAgainst, Validator } from "../nodes/schema.mjs";
|
|
23
23
|
import {
|
|
24
24
|
envelopeFindings, propsFindings, fromFindings, serviceFindings, makeFindings, rowKeyFindings, objectTestFindings,
|
|
25
|
-
pipelineFindings, pipelineFolderFindings,
|
|
25
|
+
pipelineFindings, pipelineFolderFindings, layoutFindings,
|
|
26
26
|
} from "./rules.mjs";
|
|
27
27
|
|
|
28
28
|
const require_ = createRequire(import.meta.url);
|
|
@@ -46,6 +46,7 @@ export const SCHEMA_ID = {
|
|
|
46
46
|
config: "https://docs.unoverse.ai/schemas/pipelines/config.schema.json",
|
|
47
47
|
interface: "https://docs.unoverse.ai/schemas/pipelines/interface.schema.json",
|
|
48
48
|
pipelineTest: "https://docs.unoverse.ai/schemas/pipelines/test.schema.json",
|
|
49
|
+
agent: "https://docs.unoverse.ai/schemas/agents/agent.schema.json",
|
|
49
50
|
};
|
|
50
51
|
|
|
51
52
|
/**
|
|
@@ -57,7 +58,7 @@ export const SCHEMA_ID = {
|
|
|
57
58
|
*/
|
|
58
59
|
function schemaDirs() {
|
|
59
60
|
const out = [];
|
|
60
|
-
for (const kind of ["objects", "pipelines", "nodes"]) {
|
|
61
|
+
for (const kind of ["objects", "pipelines", "agents", "nodes"]) {
|
|
61
62
|
for (const c of [join(HERE, "_schema", kind), resolve(HERE, `../../../../docs/schemas/${kind}`), join(HERE, "../nodes/_schema")]) {
|
|
62
63
|
if (existsSync(c)) {
|
|
63
64
|
out.push(c);
|
|
@@ -161,6 +162,28 @@ export function lintObjects(designRoot, options = {}) {
|
|
|
161
162
|
}
|
|
162
163
|
return out;
|
|
163
164
|
};
|
|
165
|
+
/**
|
|
166
|
+
* THE MARKETPLACE MCPS, by task name (MCP_TASKS.md § 2a): every marketplace node that offers MCP
|
|
167
|
+
* tools (`serviceType: mcp` in its interface), its type in kebab case. Null when the marketplace
|
|
168
|
+
* is not seen.
|
|
169
|
+
*/
|
|
170
|
+
const marketplaceMcps = () => {
|
|
171
|
+
if (!marketplaceSeen) return null;
|
|
172
|
+
const out = new Set();
|
|
173
|
+
const home = join(marketplace, "nodes");
|
|
174
|
+
if (!isDir(home)) return out;
|
|
175
|
+
for (const pkg of readdirSync(home)) {
|
|
176
|
+
const nodes = join(home, pkg, "nodes");
|
|
177
|
+
if (!isDir(nodes)) continue;
|
|
178
|
+
for (const n of readdirSync(nodes)) {
|
|
179
|
+
const iface = join(nodes, n, "interface.yaml");
|
|
180
|
+
if (!existsSync(iface) || !/serviceType:\s*mcp\b/.test(readFileSync(iface, "utf8"))) continue;
|
|
181
|
+
const m = /^type:\s*([A-Za-z0-9_]+)/m.exec(existsSync(join(nodes, n, "node.yaml")) ? readFileSync(join(nodes, n, "node.yaml"), "utf8") : "");
|
|
182
|
+
out.add((m ? m[1] : n).replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase());
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
};
|
|
164
187
|
const foldersIn = (tierDir, kind) => {
|
|
165
188
|
const home = join(tierDir, kind);
|
|
166
189
|
if (!isDir(home)) return [];
|
|
@@ -340,9 +363,104 @@ export function lintObjects(designRoot, options = {}) {
|
|
|
340
363
|
if (tier.org && marketplacePipelines.has(name)) report("error", at("pipeline.yaml"), `pipeline ${name}: an org's pipeline may not take a marketplace pipeline's name; the connector's name would be ambiguous`);
|
|
341
364
|
}
|
|
342
365
|
|
|
366
|
+
/**
|
|
367
|
+
* AGENTS (docs/architecture/signal/AGENTS.md § 3, § 3a): one folder each, `agents/<name>/`, its
|
|
368
|
+
* `agent.yaml` and its soul. The schema says the shape; these say what a schema cannot: the
|
|
369
|
+
* folder and the name agree, the skills and the soul exist, each task's URI names a task of the
|
|
370
|
+
* org, each trigger names a node there is, and the soul gives no orders. A flat file directly in
|
|
371
|
+
* agents/ is the older Agent file, checked by the design linter until it is converted.
|
|
372
|
+
*/
|
|
373
|
+
let agentCount = 0;
|
|
374
|
+
for (const tier of tiers) {
|
|
375
|
+
if (!tier.org) continue;
|
|
376
|
+
for (const name of foldersIn(tier.dir, "agents")) {
|
|
377
|
+
agentCount++;
|
|
378
|
+
const dir = join(tier.dir, "agents", name);
|
|
379
|
+
const file = join(dir, "agent.yaml");
|
|
380
|
+
const at = rel(file);
|
|
381
|
+
const doc = readYaml(file);
|
|
382
|
+
if (doc == null) {
|
|
383
|
+
if (!existsSync(file)) report("error", rel(dir), `agent ${name}: the folder holds no agent.yaml`);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
validateAgainst(SCHEMA_ID.agent, doc, at, `agent ${name}`);
|
|
387
|
+
if (doc.name !== name) report("error", at, `agent ${name}: name is "${doc.name}"; it must equal the folder`);
|
|
388
|
+
// NO BUDGET, NO AGENT, enforced where it runs: Signal refuses to switch it on (AGENTS.md § 9).
|
|
389
|
+
// Here it is a warning, so a draft Agent still syncs and shows in Studio as "not set", and
|
|
390
|
+
// never holds back the rest of its org's update (owner, 2026-10-10).
|
|
391
|
+
if (!isRecord(doc.model) || !doc.model.use) report("warn", at, `agent ${name}: no model set; it cannot be switched on until model.use is set`);
|
|
392
|
+
if (!isRecord(doc.budget) || !doc.budget.perDay) report("warn", at, `agent ${name}: no daily budget set; it cannot be switched on until budget.perDay is set`);
|
|
393
|
+
for (const skill of Array.isArray(doc.skills) ? doc.skills : []) {
|
|
394
|
+
if (!existsSync(join(tier.dir, "skills", skill, "SKILL.md")) && !existsSync(join(marketplace, "skills", skill, "SKILL.md"))) {
|
|
395
|
+
report("error", at, `agent ${name}: skill "${skill}" is not in skills/`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (typeof doc.soul === "string") {
|
|
399
|
+
const soulFile = join(dir, doc.soul);
|
|
400
|
+
if (!existsSync(soulFile)) report("error", at, `agent ${name}: its soul, ${doc.soul}, is not beside agent.yaml`);
|
|
401
|
+
else {
|
|
402
|
+
// CHARACTER ONLY (§ 3a): a soul that names a permission or a task changes nothing, and a
|
|
403
|
+
// reader may think it did. Said, not refused: the words may be innocent.
|
|
404
|
+
const soul = readText(soulFile);
|
|
405
|
+
if (/\b[a-z]+:(read|write|read-all|manage|author|publish)\b|unoverse:\/\/tasks\//.test(soul)) {
|
|
406
|
+
report("warn", rel(soulFile), `agent ${name}: the soul names a permission or a task. A soul is character only: put access and tasks in agent.yaml`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// Each task's URI names a task of the org: an app, template, component or pipeline folder.
|
|
411
|
+
// A workflow's Task Start is named on its canvas, not here, so one not found is a hint.
|
|
412
|
+
const own = new Set(["apps", "templates", "components", "pipelines"].flatMap((k) => foldersIn(tier.dir, k)));
|
|
413
|
+
const mcpNodes = marketplaceMcps();
|
|
414
|
+
for (const uri of Array.isArray(doc.tasks) ? doc.tasks : []) {
|
|
415
|
+
const m = /^unoverse:\/\/tasks\/([a-z0-9][a-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)$/.exec(String(uri));
|
|
416
|
+
if (!m) continue;
|
|
417
|
+
if (m[1] !== tier.org && m[1] !== "marketplace") report("error", at, `agent ${name}: task ${uri} belongs to another org; an Agent calls its own org's tasks and the marketplace's`);
|
|
418
|
+
// A MARKETPLACE MCP IS A TASK (MCP_TASKS.md § 2a): its name is a node type in kebab case.
|
|
419
|
+
else if (m[1] === "marketplace" && mcpNodes && !mcpNodes.has(m[2])) report("error", at, `agent ${name}: task ${uri} is not a marketplace MCP; one is a node that offers MCP tools, by its type in kebab case (signal-save, spatial-search)`);
|
|
420
|
+
else if (m[1] === tier.org && !own.has(m[2])) report("hint", at, `agent ${name}: task ${uri} is not an app, template, component or pipeline here; if it is a workflow's Task Start, check its Name on the canvas`);
|
|
421
|
+
}
|
|
422
|
+
const nodes = visibleNodes(tier);
|
|
423
|
+
for (const ch of Array.isArray(doc.triggers) ? doc.triggers : []) {
|
|
424
|
+
if (!isRecord(ch)) continue;
|
|
425
|
+
if (ch.name === "spatial") {
|
|
426
|
+
if (ch.node) report("error", at, `agent ${name}: the spatial trigger names no node; the Agent is found in its org's Spatial`);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (!ch.node) report("error", at, `agent ${name}: trigger ${ch.name} names no node`);
|
|
430
|
+
else if (nodes && !nodes.has(ch.node)) report("error", at, `agent ${name}: trigger ${ch.name} names node ${ch.node}, which is not a node here`);
|
|
431
|
+
}
|
|
432
|
+
if (!Array.isArray(doc.tasks) || doc.tasks.length === 0) report("error", at, `agent ${name}: it has no tasks, so it can do nothing`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* ONE NAME PER TASK IN AN ORG (docs/mcp/MCP_TASKS.md § 2a, U9). Every task is
|
|
438
|
+
* `unoverse://tasks/<org>/<name>`, whatever it is built from, so two assets of an org may not
|
|
439
|
+
* share a name. The one exception is the usual one: an app and the template or component it
|
|
440
|
+
* wraps share its name, and the app is the task. A workflow's Task Start is named on its canvas,
|
|
441
|
+
* not in these folders, so it is not seen here.
|
|
442
|
+
*/
|
|
443
|
+
for (const tier of tiers) {
|
|
444
|
+
if (!tier.org) continue;
|
|
445
|
+
const kinds = new Map();
|
|
446
|
+
for (const kind of ["apps", "templates", "components", "pipelines"]) {
|
|
447
|
+
for (const n of foldersIn(tier.dir, kind)) kinds.set(n, [...(kinds.get(n) ?? []), kind]);
|
|
448
|
+
}
|
|
449
|
+
for (const [n, list] of kinds) {
|
|
450
|
+
if (list.length < 2) continue;
|
|
451
|
+
const wrapped = list.includes("apps") && list.length === 2 && (list.includes("templates") || list.includes("components"));
|
|
452
|
+
if (wrapped) continue;
|
|
453
|
+
report(
|
|
454
|
+
"error",
|
|
455
|
+
rel(join(tier.dir, list[0], n)),
|
|
456
|
+
`task ${tier.org}/${n}: ${list.join(" and ")} share one name, and a task's name is unique in its org (unoverse://tasks/${tier.org}/${n}). Rename one.`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
343
461
|
const rank = { error: 0, warn: 1, hint: 2 };
|
|
344
462
|
const sorted = [...problems].sort((a, b) => rank[a.level] - rank[b.level] || a.file.localeCompare(b.file));
|
|
345
|
-
return { problems: sorted, objectCount, pipelineCount, designRoot: ROOT };
|
|
463
|
+
return { problems: sorted, objectCount, pipelineCount, agentCount, designRoot: ROOT };
|
|
346
464
|
}
|
|
347
465
|
|
|
348
466
|
/** True when anything would fail a build. Warnings and hints inform, errors stop. */
|
|
@@ -31,6 +31,9 @@ export function objectTestFindings(name: string, test: unknown, service: unknown
|
|
|
31
31
|
/** The words a `make` block says about its read; every other key is an object that read makes. */
|
|
32
32
|
export const PIPELINE_READ_KEYS: Set<string>;
|
|
33
33
|
export function pipelineFindings(name: string, file: unknown, fileName?: string): RuleFinding[];
|
|
34
|
+
/** A layout names no document (document-map.md, 2026-10-21): refuses a fund's, a firm's or a ticker's name, and lists of names. */
|
|
35
|
+
export function layoutFindings(name: string, file: unknown, fileName?: string): RuleFinding[];
|
|
36
|
+
export const LAYOUT_VOCABULARY: Set<string>;
|
|
34
37
|
export function collectRefs(value: unknown): Set<string>;
|
|
35
38
|
export function configRefs(value: unknown): Set<string>;
|
|
36
39
|
export function pipelineFolderFindings(
|
|
@@ -29,7 +29,9 @@ export const ELEMENTS = new Set(["purpose", "story", "content", "capability", "p
|
|
|
29
29
|
export const FACE_KEYS = ["title", "description", "whenToUse", "category", "version"];
|
|
30
30
|
|
|
31
31
|
/** The prop grammar of UNOVERSE_AUTHORING 3b plus the object's one word, `from` (README 4c). */
|
|
32
|
-
|
|
32
|
+
// `none` IS NO DEFAULT (2026-10-15): it is what a cell that STATES there is none ("None", "N/A") stands for, so a
|
|
33
|
+
// stated "no performance fee" is 0 and a fee nobody states stays empty.
|
|
34
|
+
export const PROP_KEYS = new Set(["type", "input", "description", "from", "hydrate", "enum", "oneOf", "minLength", "maxLength", "minItems", "maxItems", "items", "reuse", "answeredBy", "standard", "wordings", "wordingsIn", "onlyWith", "format", "pattern", "none"]);
|
|
33
35
|
export const LIMIT_KEYS = ["minLength", "maxLength", "minItems", "maxItems"];
|
|
34
36
|
|
|
35
37
|
/** Words that left the object grammar and must not come back (README 11.7, 12.3, 12.4). */
|
|
@@ -96,7 +98,9 @@ export function envelopeFindings(name, envelope, file = `${name}.yaml`) {
|
|
|
96
98
|
if (pairs.length !== 1 || pairs.some(([a, b]) => typeof a !== "string" || typeof b !== "string" || !a || !b)) refuse(`object ${name}: link ${ln}: match is one pair, this row's field naming that row's ({ manager: legalName })`);
|
|
97
99
|
if (l.between != null || l.order != null) refuse(`object ${name}: link ${ln}: a line to another object says to and match, never between or order`);
|
|
98
100
|
if (l.alike != null && (!Array.isArray(l.alike) || l.alike.some((f) => typeof f !== "string" || !pairs.some(([a]) => a === f)))) refuse(`object ${name}: link ${ln}: alike lists this row's match field whose value may name one thing several ways`);
|
|
99
|
-
|
|
101
|
+
// WHAT A LINE CARRIES (relationships-and-labels.md, 2026-10-14) is fields of this row, by name.
|
|
102
|
+
if (l.carries != null && (!Array.isArray(l.carries) || l.carries.some((f) => typeof f !== "string" || !f))) refuse(`object ${name}: link ${ln}: carries lists fields of this row each line keeps ([role])`);
|
|
103
|
+
for (const k of Object.keys(l)) if (!["type", "to", "match", "when", "alike", "carries"].includes(k)) say(`object ${name}: link ${ln}: "${k}" is not a word here`);
|
|
100
104
|
continue;
|
|
101
105
|
}
|
|
102
106
|
if (!Array.isArray(l.between) || !l.between.length || l.between.some((f) => typeof f !== "string")) refuse(`object ${name}: link ${ln}: between is a list of the fields whose equal values make one line of rows`);
|
|
@@ -116,8 +120,10 @@ export function envelopeFindings(name, envelope, file = `${name}.yaml`) {
|
|
|
116
120
|
for (const k of ["ids", "evidence", "ruleOut"]) if (m[k] != null && !fields(m[k])) refuse(`object ${name}: matching: ${k} is a list of field names`);
|
|
117
121
|
if (m.createFrom != null && !fields(m.createFrom)) refuse(`object ${name}: matching: createFrom is a list of line names (about)`);
|
|
118
122
|
if (m.remember != null && (typeof m.remember !== "string" || !(m.names ?? []).includes(m.remember))) refuse(`object ${name}: matching: remember names one of names, the list a confirmed name is added to`);
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
// A JUDGE IS A NODE BY ITS TYPE, OR A DECISION NODE WITH ITS CONFIG as `review` is ({ node: OpenAIDecision, config: { model } }).
|
|
124
|
+
const judgeNode = isRecord(m.judge) ? m.judge.node : m.judge;
|
|
125
|
+
if (m.judge != null && (typeof judgeNode !== "string" || !/^[A-Z][A-Za-z0-9]*$/.test(judgeNode) || (isRecord(m.judge) && m.judge.config != null && !isRecord(m.judge.config)))) refuse(`object ${name}: matching: judge names a node by its type (TypeSafeChoice), or a decision node with its config ({ node: OpenAIDecision, config: { model } })`);
|
|
126
|
+
for (const k of Object.keys(m)) if (!["ids", "names", "remember", "evidence", "ruleOut", "judge", "link", "review", "createFrom"].includes(k)) say(`object ${name}: matching: "${k}" is not a word here`);
|
|
121
127
|
}
|
|
122
128
|
}
|
|
123
129
|
return out;
|
|
@@ -158,6 +164,29 @@ export function propsFindings(name, ownProps, parentProps = null, base = null, f
|
|
|
158
164
|
if (raw.enum != null) say(`object ${name}: prop "${k}" says both enum and oneOf; one or the other`);
|
|
159
165
|
}
|
|
160
166
|
if (raw.type === "array" && !isRecord(raw.items)) say(`object ${name}: prop "${k}" is an array and carries no items: an array says the shape of one element, in the same grammar one level down (README 4c)`);
|
|
167
|
+
if (raw.answeredBy != null && (!Array.isArray(raw.answeredBy) || !raw.answeredBy.length || raw.answeredBy.some((d) => typeof d !== "string" || !d.trim()))) say(`object ${name}: prop "${k}": answeredBy is a list of the document kinds that may answer it, e.g. [offering-memorandum, side-letter]`);
|
|
168
|
+
if (raw.answeredBy != null && raw.input !== true) say(`object ${name}: prop "${k}" says answeredBy but not input: true; only a field read from documents is answered by one`);
|
|
169
|
+
// A FIELD A TABLE CAN FILL (pipelines/tables.md, 2026-10-12): `standard` is its id in the org's
|
|
170
|
+
// field list, `wordings` the words documents print for it, matched exactly after lower case.
|
|
171
|
+
if (raw.standard != null && (typeof raw.standard !== "string" || !raw.standard.trim())) say(`object ${name}: prop "${k}": standard is the field's id in the org's field list, one word (OFST452010)`);
|
|
172
|
+
if (raw.wordings != null && (!Array.isArray(raw.wordings) || !raw.wordings.length || raw.wordings.some((w) => typeof w !== "string" || !w.trim()))) say(`object ${name}: prop "${k}": wordings is a list of the words documents print for this field, e.g. [management fee, mgmt fee]`);
|
|
173
|
+
if (raw.pattern != null) { try { if (typeof raw.pattern !== "string" || !raw.pattern.trim()) throw 0; new RegExp(raw.pattern); } catch { say(`object ${name}: prop "${k}": pattern is the regular expression a value must match, e.g. ^[A-Z]{2}[A-Z0-9]{9}[0-9]$; a value that does not match is never saved`); } }
|
|
174
|
+
if (raw.format != null && raw.format !== "date" && raw.format !== "country") say(`object ${name}: prop "${k}": format is date, country, or nothing (a date is written yyyy-mm-dd; a country is held by its one name from ISO 3166, "UK" saved as United Kingdom)`);
|
|
175
|
+
// A WORDING SCOPED TO ONE KIND OF DOCUMENT (2026-10-26): `wordingsIn` lists, under a document type, the words that name
|
|
176
|
+
// this field in that type only, where one type prints for this field the words another prints for another field.
|
|
177
|
+
if (raw.wordingsIn != null && (!isRecord(raw.wordingsIn) || !Object.keys(raw.wordingsIn).length || Object.values(raw.wordingsIn).some((ws) => !Array.isArray(ws) || !ws.length || ws.some((w) => typeof w !== "string" || !w.trim())))) say(`object ${name}: prop "${k}": wordingsIn lists, under each document type, the words that name this field in that type only, as { <document type>: [<words>] }`);
|
|
178
|
+
if (raw.wordingsIn != null && raw.input !== true) say(`object ${name}: prop "${k}" says wordingsIn but not input: true; only a field read from documents is filled from a table`);
|
|
179
|
+
// A TERM OF ANOTHER FIELD (2026-10-26): `onlyWith` names the field this one is a term of; where that field is none,
|
|
180
|
+
// this one is not kept.
|
|
181
|
+
if (raw.onlyWith != null && (typeof raw.onlyWith !== "string" || raw.onlyWith === k || !(raw.onlyWith in ownProps || (parentProps && raw.onlyWith in parentProps)))) say(`object ${name}: prop "${k}": onlyWith names another field of this object that this one is a term of, and is kept only where that field is not none`);
|
|
182
|
+
if ((raw.standard != null || raw.wordings != null) && raw.input !== true) say(`object ${name}: prop "${k}" says standard or wordings but not input: true; only a field read from documents is filled from a table`);
|
|
183
|
+
}
|
|
184
|
+
// FILLED FROM DOCUMENTS, EVERY FIELD SAYS WHICH (proved-before-built.md, ruled 2026-10-07): an
|
|
185
|
+
// object one of whose fields names the documents that answer it is filled from documents, so
|
|
186
|
+
// every field it reads names them; a field without would never be asked.
|
|
187
|
+
const own = Object.entries(ownProps).filter(([k, raw]) => isRecord(raw) && !(parentProps && k in parentProps));
|
|
188
|
+
if (own.some(([, raw]) => raw.answeredBy != null)) {
|
|
189
|
+
for (const [k, raw] of own) if (raw.input === true && raw.answeredBy == null) say(`object ${name}: prop "${k}" is input: true and says no answeredBy; on an object filled from documents every field read names the documents that answer it, or nothing ever asks for it`);
|
|
161
190
|
}
|
|
162
191
|
return out;
|
|
163
192
|
}
|
|
@@ -304,7 +333,7 @@ export const PIPELINE_READ_KEYS = new Set(["each", "sourceUrl", "sourceId", "con
|
|
|
304
333
|
const DROPPED_MAKE_KEYS = ["partsOf", "by", "under", "when", "from", "any", "index"];
|
|
305
334
|
|
|
306
335
|
/** What an object may say: under `each`, how many and what it holds; made once per pack, how many and from which rows. */
|
|
307
|
-
const ITEM_OBJECT_KEYS = new Set(["cardinality", "any", "parts", "where", "per"]);
|
|
336
|
+
const ITEM_OBJECT_KEYS = new Set(["cardinality", "any", "parts", "where", "per", "read", "tables"]);
|
|
308
337
|
const FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
309
338
|
|
|
310
339
|
/**
|
|
@@ -381,8 +410,14 @@ export function pipelineFindings(name, file, fileName = "pipeline.yaml") {
|
|
|
381
410
|
}
|
|
382
411
|
if (made.includes(o)) refuse(`pipeline ${name}: ${at}.${o} is made above already; one entry makes an object`);
|
|
383
412
|
const allowed = ITEM_OBJECT_KEYS;
|
|
384
|
-
for (const k of Object.keys(spec)) if (!allowed.has(k)) refuse(`pipeline ${name}: ${at}.${o}: "${k}" is not a word here; an object says cardinality, any, parts, where and
|
|
385
|
-
if ("
|
|
413
|
+
for (const k of Object.keys(spec)) if (!allowed.has(k)) refuse(`pipeline ${name}: ${at}.${o}: "${k}" is not a word here; an object says cardinality, any, parts, where, per, read and tables (${see})`);
|
|
414
|
+
if ("tables" in spec && (!isRecord(spec.tables) || ("whose" in spec.tables && (!isRecord(spec.tables.whose) || typeof spec.tables.whose.node !== "string" || !spec.tables.whose.node)))) refuse(`pipeline ${name}: ${at}.${o}: tables is a record; its whose names the node that says which row a table is about: tables: { whose: { node: OpenAIDecision, config: { ... } } } (pipelines/tables.md)`);
|
|
415
|
+
if (isRecord(spec.tables) && "labels" in spec.tables && (!isRecord(spec.tables.labels) || typeof spec.tables.labels.node !== "string" || !spec.tables.labels.node)) refuse(`pipeline ${name}: ${at}.${o}: tables.labels names the node that proposes a field for a label no rule matched: tables: { labels: { node: OpenAIDecision, config: { ... } } } (pipelines/tables.md)`);
|
|
416
|
+
if (isRecord(spec.tables) && "allocations" in spec.tables && (typeof spec.tables.allocations !== "string" || !spec.tables.allocations)) refuse(`pipeline ${name}: ${at}.${o}: tables.allocations names the line an allocation table draws to each fund it holds: tables: { allocations: allocatesTo } (pipelines/tables.md 3c)`);
|
|
417
|
+
if (isRecord(spec.tables) && "boards" in spec.tables && (!Array.isArray(spec.tables.boards) || !spec.tables.boards.length || !spec.tables.boards.every((b) => typeof b === "string" && b))) refuse(`pipeline ${name}: ${at}.${o}: tables.boards names the lines a board list may draw, each to whose board it is: tables: { boards: [directorOf, worksAt] } (pipelines/tables.md 3d)`);
|
|
418
|
+
if ("read" in spec && (!isRecord(spec.read) || typeof spec.read.node !== "string" || !spec.read.node)) refuse(`pipeline ${name}: ${at}.${o}: read names the node that reads the pages: read: { node: BeamEndpoint, config: { ... } } (${see})`);
|
|
419
|
+
if ("read" in spec && spec.per !== "page") refuse(`pipeline ${name}: ${at}.${o}: read is how an object written per page is read; say per: page beside it (${see})`);
|
|
420
|
+
if ("per" in spec && spec.per !== "page") refuse(`pipeline ${name}: ${at}.${o}: per says page, the only unit an object is written per; there are no sections (page-store.md) (${see})`);
|
|
386
421
|
// HOW MANY IS SAID by every object the model writes (owner, 2026-09-13).
|
|
387
422
|
if (spec.cardinality !== "one" && spec.cardinality !== "many") say(`pipeline ${name}: ${at}.${o} says cardinality: one or many, how many of it one row makes (${see})`);
|
|
388
423
|
if ("any" in spec && spec.any !== true) refuse(`pipeline ${name}: ${at}.${o}: any: true is its only value, the item MAY be one`);
|
|
@@ -456,6 +491,70 @@ export function pipelineFolderFindings(name, { file, config, test }, { nodeTypes
|
|
|
456
491
|
}
|
|
457
492
|
|
|
458
493
|
/** The first error's message. */
|
|
494
|
+
// ── Layouts ─────────────────────────────────────────────────────────────────────────────────────────
|
|
495
|
+
/**
|
|
496
|
+
* A LAYOUT NAMES NO DOCUMENT (docs/unoverse/data/pipelines/document-map.md, owner 2026-10-21, seeing a
|
|
497
|
+
* draft carry "EAM B.V." and "TL-X"). A layout is the map of a document TYPE: it speaks in the type's
|
|
498
|
+
* structure and wording, never in a fund's, a firm's, an index's or a ticker's name. Names belong on
|
|
499
|
+
* records. Refused here, so a layout that fits one house's documents is never loaded.
|
|
500
|
+
*/
|
|
501
|
+
/** A legal form as one word of a name, in any jurisdiction's spelling: the mark of a firm's or a fund's name. */
|
|
502
|
+
const LEGAL_FORM_WORD = /^(?:Ltd|Limited|LLC|LLP|L\.?P\.?|LP|plc|PLC|Inc\.?|Corp\.?|Co\.?|GmbH|AG|S\.?A\.?|S\.?C\.?A\.?|S\.?p\.?A\.?|SpA|B\.?V\.?|BV|N\.?V\.?|NV|S\.?à\.?r\.?l\.?|SARL|Sàrl|Pte\.?|Pty\.?|KGaA|KG|ApS|AB|AS|Oy|SE|SAS|SL|SRL|Srl|V)\.?$/;
|
|
503
|
+
const LAYOUT_TEXT_KEYS = new Set(["pattern", "after", "heading", "text", "row", "table", "say", "describe", "trim", "split"]);
|
|
504
|
+
const LAYOUT_NAME_KEYS = new Set(["names", "sameAs", "neverMerge", "aliases"]);
|
|
505
|
+
/** Every capitalised run of two or more words in a string, regex syntax and escapes aside. */
|
|
506
|
+
function capitalisedRuns(str) {
|
|
507
|
+
// Regex syntax is not words: character classes, counts, escapes and group openers go before the words are read.
|
|
508
|
+
const plain = String(str).replace(/\(\?<?[!=][^)]*\)/g, " ").replace(/\[[^\]]*\]/g, " ").replace(/\{[^}]*\}/g, " ").replace(/\\[A-Za-z]/g, " ").replace(/\(\?[:!=<]?/g, " ").replace(/[\[\]()|^$?*+{}.\\]/g, " ").replace(/\s+/g, " ");
|
|
509
|
+
return [...plain.matchAll(/(?:\b[A-Z][\w'&.-]*(?: (?:of|de|di|du|des|and|&) )?\s?){2,}/g)].map((m) => m[0].trim()).filter((r) => r.split(/\s+/).length >= 2);
|
|
510
|
+
}
|
|
511
|
+
/** A ticker or an identifier spelled out: letters and digits run together ("LU0000000000", "ABCDEF LX", "X1AA"), or an ISIN shape. */
|
|
512
|
+
const LITERAL_CODE = /\b(?:[A-Z]{2}[A-Z0-9]{9}\d|[A-Z]{4,}\s[A-Z]{2}|[A-Z]+\d+[A-Z]+|TL-[A-Z]\d*)\b/;
|
|
513
|
+
/**
|
|
514
|
+
* The same inside a finder that reads any case (`case: any`, 2026-10-24): there capitals are the type's wording as printed
|
|
515
|
+
* ("A SUB-FUND OF THE"), so a word of four capitals then one of two is a ticker only when the two stand alone ("ABCDEF LX");
|
|
516
|
+
* a code with digits or an identifier's shape is refused as everywhere.
|
|
517
|
+
*/
|
|
518
|
+
const LITERAL_CODE_ANY_CASE = /\b(?:[A-Z]{2}[A-Z0-9]{9}\d|[A-Z]+\d+[A-Z]+|TL-[A-Z]\d*)\b|(?<![A-Za-z][\s-]?)\b[A-Z]{4,}\s[A-Z]{2}\b(?![\s-]?[A-Za-z])/;
|
|
519
|
+
export function layoutFindings(name, file, fileName = `${name}.yaml`) {
|
|
520
|
+
const out = [];
|
|
521
|
+
const refuse = (msg) => out.push({ file: fileName, msg, refuses: true });
|
|
522
|
+
if (!isRecord(file)) return [{ file: fileName, msg: `layout ${name}: ${fileName} must be a record`, refuses: true }];
|
|
523
|
+
const walk = (v, path, anyCase = false) => {
|
|
524
|
+
if (Array.isArray(v)) { v.forEach((x, i) => walk(x, `${path}[${i}]`, anyCase)); return; }
|
|
525
|
+
if (isRecord(v)) {
|
|
526
|
+
// A FINDER THAT READS ANY CASE (`case: any`): its own strings are wording, capitals and all; a record beneath it is its own.
|
|
527
|
+
const own = v.case === "any";
|
|
528
|
+
for (const [k, x] of Object.entries(v)) {
|
|
529
|
+
if (LAYOUT_NAME_KEYS.has(k)) refuse(`layout ${name}: "${k}" at ${path} lists names; a layout names no document, aliases live on records (document-map.md, 2026-10-21)`);
|
|
530
|
+
else if (k === "map" && isRecord(x)) { for (const [from, to] of Object.entries(x)) for (const t of [from, String(to)]) check(t, `${path}.map`); }
|
|
531
|
+
else walk(x, path ? `${path}.${k}` : k, own && typeof x === "string");
|
|
532
|
+
}
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (typeof v === "string") { const key = path.split(/[.[]/).pop()?.replace("]", ""); if (LAYOUT_TEXT_KEYS.has(key)) check(v, path, anyCase); }
|
|
536
|
+
};
|
|
537
|
+
const check = (str, path, anyCase = false) => {
|
|
538
|
+
// BY STRUCTURE, NEVER BY A WORD LIST (owner 2026-10-24: a list of a document type's words grew from one org's documents).
|
|
539
|
+
// A name shows itself two ways: a capitalised run that ends in a legal form and holds a word that is not one
|
|
540
|
+
// ("Example Asset Management B.V."; an alternation of legal forms alone is pattern syntax), and a `never` pattern that
|
|
541
|
+
// is plain words with no regular-expression structure, which can only be one name. Wording a pattern matches or maps
|
|
542
|
+
// ("No Distribution", "Swiss Paying Agent") is the type's own and carries no name.
|
|
543
|
+
const plain = String(str).replace(/\\[A-Za-z]/g, " ");
|
|
544
|
+
for (const run of capitalisedRuns(plain)) {
|
|
545
|
+
const words = run.split(/\s+/).filter(Boolean);
|
|
546
|
+
const last = words[words.length - 1] ?? "";
|
|
547
|
+
// A lone capital letter is a legal form's initial once its dots are gone ("S A", "B V"), never a word of a name.
|
|
548
|
+
const ownWords = words.filter((w) => !LEGAL_FORM_WORD.test(w) && !/^[A-Z]$/.test(w) && !/^(?:of|de|di|du|des|and|&)$/i.test(w));
|
|
549
|
+
if (LEGAL_FORM_WORD.test(last) && ownWords.length >= 1) refuse(`layout ${name}: "${run}" at ${path} reads like a name (a legal form ends it); a layout names no document (document-map.md, 2026-10-21)`);
|
|
550
|
+
}
|
|
551
|
+
if (/(^|\.)never\[\d+\]\.pattern$/.test(path) && !/[\\^$.*+?()[\]{}|]/.test(str) && /[A-Z]/.test(str)) refuse(`layout ${name}: "${str.slice(0, 60)}" at ${path} reads like a name (plain words with no structure); a never rule is structural (document-map.md, 2026-10-21)`);
|
|
552
|
+
if ((anyCase ? LITERAL_CODE_ANY_CASE : LITERAL_CODE).test(str.replace(/\[[^\]]*\]|\{[^}]*\}|\\[A-Za-z]/g, " "))) refuse(`layout ${name}: "${str.slice(0, 60)}" at ${path} spells out a code or ticker; a layout names no document (document-map.md, 2026-10-21)`);
|
|
553
|
+
};
|
|
554
|
+
walk(file, "");
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
557
|
+
|
|
459
558
|
export function firstError(findings) {
|
|
460
559
|
const f = findings.find((x) => !x.level || x.level === "error");
|
|
461
560
|
return f ? f.msg : null;
|
|
@@ -586,6 +586,9 @@ export function composeNode(raw: RawNode, pkg: RawPackage): ComposedNode {
|
|
|
586
586
|
attaches: iface.attaches ?? null,
|
|
587
587
|
visibility: node.visibility ?? "public",
|
|
588
588
|
capabilities: node.capabilities ?? null,
|
|
589
|
+
// HOW THE SERVICE PROVES A MESSAGE IS ITS OWN (docs/auth-security/INBOUND.md § 2 step 3): a
|
|
590
|
+
// scheme the platform verifies, and the credential field holding the secret. Null: it sends none.
|
|
591
|
+
inbound: node.inbound ?? null,
|
|
589
592
|
inputs: iface.inputs ?? [],
|
|
590
593
|
outputs: iface.outputs ?? [],
|
|
591
594
|
credentials: iface.credentials ?? [],
|
|
@@ -6,7 +6,7 @@ import { contextFor } from "./context.js";
|
|
|
6
6
|
import { sessionFor, stateStoreFor, audioLaneFor } from "./session.js";
|
|
7
7
|
import { refireAfterDocMutation } from "./refire.js";
|
|
8
8
|
import { toolBridgeFor } from "./toolBridge.js";
|
|
9
|
-
import { hydrateMemory, ingestMemoryTurn, sessionTurnRecorder } from "./memory.js";
|
|
9
|
+
import { hydrateMemory, hydrateOpenedTask, ingestMemoryTurn, sessionTurnRecorder } from "./memory.js";
|
|
10
10
|
|
|
11
11
|
export class ManifestCallbackExecutor {
|
|
12
12
|
logger: any = console;
|
|
@@ -69,7 +69,8 @@ export class ManifestCallbackExecutor {
|
|
|
69
69
|
// A SESSION is a run that holds a socket for the length of a call: the block rides the
|
|
70
70
|
// replayed history instead of a prompt, and turns are saved as they complete (below).
|
|
71
71
|
const session = Array.isArray(node.api?.run) && node.api.run.some((r: any) => r?.transport === "ws");
|
|
72
|
-
const
|
|
72
|
+
const memoryConfig = await hydrateMemory(event.config, executionContext ?? this.executionContext, { session });
|
|
73
|
+
const hydratedConfig = session ? await hydrateOpenedTask(memoryConfig, executionContext ?? this.executionContext) : memoryConfig;
|
|
73
74
|
const ctx = contextFor(node, event.inputs, hydratedConfig, executionContext ?? this.executionContext);
|
|
74
75
|
const turns = session ? sessionTurnRecorder(node.type, event.config, executionContext ?? this.executionContext) : null;
|
|
75
76
|
|