@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.
Files changed (72) hide show
  1. package/local/catalog.ts +3 -0
  2. package/local/plugin.ts +12 -9
  3. package/local/scaffold.d.mts +1 -0
  4. package/local/scaffold.mjs +12 -0
  5. package/package.json +1 -1
  6. package/screens/AgentChain.tsx +232 -0
  7. package/screens/AgentsView.tsx +85 -6
  8. package/screens/SkillsView.tsx +21 -3
  9. package/screens/TemplatesLibraryView.tsx +32 -3
  10. package/screens/TemplatesView.tsx +2 -2
  11. package/screens/index.ts +4 -1
  12. package/src/host.ts +5 -0
  13. package/src/index.css +11 -0
  14. package/vendor/base/src/agent-mcp/discovery.ts +5 -2
  15. package/vendor/base/src/agent-mcp/index.ts +3 -1
  16. package/vendor/base/src/agent-mcp/invoke.ts +199 -25
  17. package/vendor/base/src/agent-mcp/progressive.ts +124 -0
  18. package/vendor/base/src/agent-mcp/tools.ts +18 -11
  19. package/vendor/base/src/agent-mcp/types.ts +8 -0
  20. package/vendor/base/src/auth/agentSwitch.ts +52 -0
  21. package/vendor/base/src/auth/agentToken.ts +142 -0
  22. package/vendor/base/src/auth/auth.ts +7 -0
  23. package/vendor/base/src/definitions/load/expand/inherit.ts +1 -1
  24. package/vendor/base/src/definitions/load/expand/refs.ts +1 -1
  25. package/vendor/base/src/definitions/load/expand/walk.ts +1 -1
  26. package/vendor/base/src/definitions/load/load.ts +6 -1
  27. package/vendor/base/src/definitions/registry/agentFolders.ts +113 -0
  28. package/vendor/base/src/definitions/registry/catalog.ts +1 -1
  29. package/vendor/base/src/definitions/registry/objects.ts +109 -5
  30. package/vendor/base/src/definitions/registry/pipelines.ts +24 -5
  31. package/vendor/base/src/definitions/registry/taskStarts.ts +76 -0
  32. package/vendor/base/src/definitions/registry/taskUri.ts +67 -0
  33. package/vendor/base/src/http/uwsFetchAdapter.ts +39 -3
  34. package/vendor/base/src/items/collect.ts +10 -0
  35. package/vendor/base/src/items/publish.ts +10 -0
  36. package/vendor/base/src/lint/design/index.mjs +14 -3
  37. package/vendor/base/src/lint/design/vocabulary.mjs +1 -1
  38. package/vendor/base/src/lint/objects/index.d.mts +1 -0
  39. package/vendor/base/src/lint/objects/index.mjs +121 -3
  40. package/vendor/base/src/lint/objects/rules.d.mts +3 -0
  41. package/vendor/base/src/lint/objects/rules.mjs +106 -7
  42. package/vendor/base/src/manifests/compose.ts +3 -0
  43. package/vendor/base/src/manifests/executor/callback.ts +3 -2
  44. package/vendor/base/src/manifests/executor/memory.ts +27 -0
  45. package/vendor/base/src/manifests/executor/toolBridge.ts +79 -13
  46. package/vendor/base/src/manifests/marketplaceMcp.ts +20 -0
  47. package/vendor/base/src/manifests/runtime/auth/oauth.ts +43 -10
  48. package/vendor/base/src/manifests/runtime/events.ts +1 -1
  49. package/vendor/base/src/manifests/runtime/http/request.ts +47 -4
  50. package/vendor/base/src/manifests/runtime/loops/poll.ts +27 -5
  51. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +59 -24
  52. package/vendor/base/src/manifests/runtime/usage.ts +13 -5
  53. package/vendor/base/src/platform/agentBudget.ts +65 -0
  54. package/vendor/base/src/platform/agentCalendar.ts +87 -0
  55. package/vendor/base/src/platform/inbound.ts +67 -0
  56. package/vendor/base/src/platform/taskChanged.ts +25 -0
  57. package/vendor/base/src/platform/tokenCost.ts +414 -0
  58. package/vendor/base/src/security/permissions.ts +10 -2
  59. package/vendor/sdk/appEngine.ts +42 -1
  60. package/vendor/sdk/lib/core/actions.ts +10 -0
  61. package/vendor/sdk/lib/core/apps.ts +2 -2
  62. package/vendor/sdk/lib/core/client.ts +11 -0
  63. package/vendor/sdk/lib/core/store.ts +1 -1
  64. package/vendor/sdk/lib/core/types.ts +2 -0
  65. package/vendor/sdk/lib/dom/app.ts +3 -2
  66. package/vendor/sdk/lib/dom/component.ts +38 -1
  67. package/vendor/sdk/lib/dom/element.ts +23 -0
  68. package/vendor/sdk/lib/dom/markdown.ts +6 -2
  69. package/vendor/sdk/lib/dom/primitives.ts +97 -6
  70. package/vendor/sdk/lib/voiceService.ts +3 -1
  71. package/vendor/sdk/main.ts +1 -1
  72. package/vendor/sdk/pageInterfaces.ts +109 -8
@@ -0,0 +1,142 @@
1
+ /**
2
+ * AN AGENT'S TOKEN: an Agent working with nobody signed in runs as itself.
3
+ *
4
+ * Design: docs/auth-security/IDENTITY.md, Agents; docs/architecture/signal/AGENTS.md § 7. The
5
+ * Agent is its folder, `agent:<org>/<name>`; its token is signed by THIS CELL, not by the login
6
+ * provider, so it works the same whatever provider signs people in. Its permissions are its
7
+ * folder's `access`, capped at what its owner holds (`agentSwitch.ts`); `act` names the owner.
8
+ *
9
+ * No database here. The engine keeps the key (`engine/src/agentKeys/`) and hands it in; a
10
+ * test can drive every branch with a key it made itself. The one network read is
11
+ * `engineAgentKeys`, the public half, fetched from the engine when a token is checked.
12
+ */
13
+
14
+ import { SignJWT, jwtVerify, decodeJwt, importJWK, generateKeyPair, exportJWK, createRemoteJWKSet, type JWK, type JWTPayload, type JWTVerifyGetKey } from "jose";
15
+
16
+ /** `iss` on every Agent token. A cell-local name: each cell signs with its own key, so a
17
+ * token from another cell carries the same name and still fails the signature. */
18
+ export const AGENT_ISSUER = "unoverse:agent";
19
+
20
+ /** Every Agent's user id starts with this, so a node can tell one from a person. */
21
+ export const AGENT_ID_PREFIX = "agent:";
22
+
23
+ export const AGENT_ALG = "ES256";
24
+
25
+ /** The longest a run is expected to take. A token is signed per run. */
26
+ export const AGENT_TOKEN_TTL_SECONDS = 3600;
27
+
28
+ export interface AgentClaims {
29
+ /** The Agent, `<org>/<name>`: its folder. */
30
+ agent: string;
31
+ /** Its title, for the run history. */
32
+ name: string;
33
+ /** The person who switched it on, and is accountable for it. */
34
+ ownerId: string;
35
+ /** Its `access`, already capped at the owner's own. */
36
+ permissions: string[];
37
+ }
38
+
39
+ export function isAgentId(id: unknown): id is string {
40
+ return typeof id === "string" && id.startsWith(AGENT_ID_PREFIX);
41
+ }
42
+
43
+ /** A cell's signing key as plain JSON: what the engine stores and hands in. */
44
+ export interface AgentKeyPair {
45
+ kid: string;
46
+ publicJwk: JWK;
47
+ privateJwk: JWK;
48
+ }
49
+
50
+ /** Make a new key pair. The engine calls this once per cell and stores the result. */
51
+ export async function newAgentKeyPair(kid: string): Promise<AgentKeyPair> {
52
+ const { publicKey, privateKey } = await generateKeyPair(AGENT_ALG, { extractable: true });
53
+ const publicJwk = { ...(await exportJWK(publicKey)), kid, alg: AGENT_ALG, use: "sig" };
54
+ const privateJwk = { ...(await exportJWK(privateKey)), kid, alg: AGENT_ALG };
55
+ return { kid, publicJwk, privateJwk };
56
+ }
57
+
58
+ /** Sign one run's token. `audience` is the cell's `AUTH_AUDIENCE`, the same as a person's. */
59
+ export async function signAgentToken(
60
+ claims: AgentClaims,
61
+ key: { kid: string; privateJwk: JWK },
62
+ audience: string | undefined,
63
+ now: Date = new Date(),
64
+ ): Promise<string> {
65
+ const iat = Math.floor(now.getTime() / 1000);
66
+ const jwt = new SignJWT({
67
+ name: claims.name,
68
+ permissions: claims.permissions,
69
+ act: { sub: claims.ownerId },
70
+ })
71
+ .setProtectedHeader({ alg: AGENT_ALG, kid: key.kid, typ: "at+jwt" })
72
+ .setIssuer(AGENT_ISSUER)
73
+ .setSubject(`${AGENT_ID_PREFIX}${claims.agent}`)
74
+ .setIssuedAt(iat)
75
+ .setExpirationTime(iat + AGENT_TOKEN_TTL_SECONDS);
76
+ if (audience) jwt.setAudience(audience);
77
+ return jwt.sign(await importJWK(key.privateJwk, AGENT_ALG));
78
+ }
79
+
80
+ /** Is this token one this platform signed for an Agent? Read, not verified: it only picks
81
+ * which check to run. */
82
+ export function isAgentToken(token: string): boolean {
83
+ try {
84
+ return decodeJwt(token).iss === AGENT_ISSUER;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ export interface VerifiedAgent {
91
+ id: string;
92
+ name?: string;
93
+ ownerId?: string;
94
+ permissions: string[];
95
+ raw: JWTPayload;
96
+ }
97
+
98
+ /**
99
+ * Check an Agent's token: signature against the cell's keys, issuer, expiry, audience.
100
+ * Null when any of them fails. FAIL CLOSED: a token without a `permissions` list, or whose
101
+ * subject is not an Agent id, is refused rather than read as "no permissions".
102
+ */
103
+ export async function verifyAgentToken(
104
+ token: string,
105
+ keys: JWTVerifyGetKey,
106
+ audience: string | undefined,
107
+ ): Promise<VerifiedAgent | null> {
108
+ try {
109
+ const { payload } = await jwtVerify(token, keys, {
110
+ issuer: AGENT_ISSUER,
111
+ algorithms: [AGENT_ALG],
112
+ ...(audience ? { audience } : {}),
113
+ });
114
+ if (!isAgentId(payload.sub) || !Array.isArray(payload.permissions)) return null;
115
+ const act = payload.act as { sub?: unknown } | undefined;
116
+ return {
117
+ id: payload.sub,
118
+ name: typeof payload.name === "string" ? payload.name : undefined,
119
+ ownerId: typeof act?.sub === "string" ? act.sub : undefined,
120
+ permissions: (payload.permissions as unknown[]).filter((p): p is string => typeof p === "string"),
121
+ raw: payload,
122
+ };
123
+ } catch (error) {
124
+ const code = (error as { code?: string }).code;
125
+ console.warn(`[auth] Agent token refused: ${code ?? (error as Error).message}`);
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * The cell's public Agent keys, from the engine's internal listener. The same address the
132
+ * server and the memory service already use to reach the engine (`WORKFLOW_SERVICE_URL`).
133
+ * jose caches the set and refetches it when a token names a key it has not seen.
134
+ */
135
+ let engineKeys: JWTVerifyGetKey | null = null;
136
+ export function engineAgentKeys(): JWTVerifyGetKey {
137
+ if (!engineKeys) {
138
+ const engine = (process.env.WORKFLOW_SERVICE_URL || "http://localhost:4101").replace(/\/$/, "");
139
+ engineKeys = createRemoteJWKSet(new URL(`${engine}/agent-keys/jwks.json`));
140
+ }
141
+ return engineKeys;
142
+ }
@@ -14,6 +14,7 @@
14
14
  import { jwtVerify, createRemoteJWKSet, JWTPayload } from "jose";
15
15
  import { isAuthEnabled, devIdentity } from "./authConfig.js";
16
16
  import { grantedTo } from "../security/permissions.js";
17
+ import { isAgentToken, verifyAgentToken, engineAgentKeys } from "./agentToken.js";
17
18
 
18
19
  // Read env lazily (not at module load) so it's correct regardless of when .env loads.
19
20
  //
@@ -145,6 +146,12 @@ export async function validateJWT(token: string): Promise<AuthUser | null> {
145
146
  }
146
147
 
147
148
  async function verifyJWT(token: string): Promise<AuthUser | null> {
149
+ // AN AGENT'S TOKEN is signed by this cell, not the login provider (IDENTITY.md, Agents):
150
+ // checked against the cell's own keys, never the provider's. No email, no roles.
151
+ if (isAgentToken(token)) {
152
+ const agent = await verifyAgentToken(token, engineAgentKeys(), getAudience());
153
+ return agent ? { id: agent.id, roles: [], permissions: agent.permissions, raw: agent.raw } : null;
154
+ }
148
155
  const jwksSet = await getJWKS();
149
156
  const issuers = getIssuerCandidates();
150
157
  if (!jwksSet || issuers.length === 0) {
@@ -112,7 +112,7 @@ export function inheritProps(
112
112
  // could send, which is exactly the venue-renaming this flag exists to prevent.
113
113
  // `input` TRAVELS TOO (2026-09-15). An atom that marks a field `input: false` (the button's
114
114
  // `icon` and `link`) says nobody outside fills it; without the word here the host inherited
115
- // the field as fillable and the tool asked the model for it (SAB transfer: four fields it
115
+ // the field as fillable and the tool asked the model for it (a bank's transfer form: four fields it
116
116
  // could not honestly write). Same failure as `override` above, one word later.
117
117
  const CONTRACT = ["type", "description", "maxLength", "minItems", "maxItems", "items", "hydrate", "optional", "override", "preview", "input"] as const;
118
118
  // AN ATOM INSIDE AN `Each` DESCRIBES THE ITEM, not the page. The scope is the chain of
@@ -83,7 +83,7 @@ function applyStyleLiterals(style: unknown, lits: Record<string, unknown>): void
83
83
 
84
84
  export function applyLiterals(node: AnyNode, lits: Record<string, unknown>): void {
85
85
  // A NESTED REF FORWARDS THE LITERAL. A piece whose kind is itself a Ref to an atom
86
- // (SAB's button: primary = the marketplace `button` atom with `props: {label: label}`)
86
+ // (a bank's button: primary = the marketplace `button` atom with `props: {label: label}`)
87
87
  // hands its host's literal on as that Ref's `with`, so the atom draws the words whether
88
88
  // the tree is expanded here or resolved by name on the client (referenced form).
89
89
  if (node.type === "Ref" && node.props && typeof node.props === "object") {
@@ -61,7 +61,7 @@ export function expandNode(node: AnyNode, contextOrg?: string, inherit?: Record<
61
61
  * silently resolved to the marketplace `button` atom and the org's own button component
62
62
  * was never reached (2026-09-07). A bare name keeps the old order: atom first.
63
63
  *
64
- * AN EMBEDDED PIECE'S PRIVATE AXIS IS CHOSEN WHERE IT IS COMPOSED (2026-09-07, the SAB
64
+ * AN EMBEDDED PIECE'S PRIVATE AXIS IS CHOSEN WHERE IT IS COMPOSED (2026-09-07, the a bank
65
65
  * button: kinds primary/secondary/outline/link on a private `kind` axis). Its root is the
66
66
  * compiled surface shell around the switches on that axis. Inlined, the piece has no slice
67
67
  * of its own, so the host's `with` names the kind, and a host that names none gets the
@@ -184,7 +184,12 @@ export function loadDefinition(
184
184
  if (m.preview && typeof m.preview === "object") def.preview = m.preview as Record<string, string[]>;
185
185
  // THE WORKFLOW THAT WORKS IT (UNOVERSE_HOST_PAGE.md rule 4): the manifest names it,
186
186
  // and the page reads it here to start a call on it.
187
- if (m.binding && typeof m.binding === "object") def.binding = m.binding as { workflow?: string; trigger?: string; pipeline?: string };
187
+ if (m.binding && typeof m.binding === "object") def.binding = m.binding as { workflow?: string; trigger?: string; pipeline?: string; autoTrigger?: boolean };
188
+ // THE TASK IT CARRIES (docs/design/lifecycle-hooks.md): which moments have a hook, so a
189
+ // page that draws it knows to ask for its task. Only the moments ride; the calls stay
190
+ // on the server, in the template's folder.
191
+ if (Array.isArray(m.lifecycle))
192
+ (def as unknown as Record<string, unknown>).lifecycle = (m.lifecycle as Array<Record<string, unknown>>).map((e) => ({ phase: e?.phase }));
188
193
  // SPATIAL-CAPABLE, exactly as a component is (§9c, ruled 2026-08-30): a
189
194
  // template is found by what a guest says, through the same projection, the
190
195
  // same registry switch and the same embedded text. The difference is only
@@ -0,0 +1,113 @@
1
+ /**
2
+ * AGENTS AS FOLDERS, READ (docs/architecture/signal/AGENTS.md § 3, § 3a).
3
+ *
4
+ * One shape for every Agent: `agents/<name>/agent.yaml` and its soul (`SOUL.md`). This reads them
5
+ * for whoever shows or runs an Agent (Studio's Agents lane, Signal, Spatial). A flat file directly
6
+ * in `agents/` is the older Agent file (agents.ts), read there until it is converted.
7
+ *
8
+ * Read as written: the lint (`lintObjects`) is what refuses a broken one; here a folder that cannot
9
+ * be read is left out with a warning, never taking the org's other Agents with it
10
+ * (UNOVERSE_ORGANISATION.md § 11).
11
+ */
12
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { parse } from "yaml";
15
+ import { listOrgs, projectDir } from "./homes.js";
16
+ import { isolated } from "../isolated.js";
17
+
18
+ /** One Agent trigger, as the file writes it: what wakes it, and the node behind it. */
19
+ export interface AgentTrigger {
20
+ name: string;
21
+ node?: string;
22
+ wakes?: string;
23
+ config?: Record<string, unknown>;
24
+ }
25
+
26
+ /** An Agent folder: its file as written, its soul's words, where it is. */
27
+ export interface AgentFolder {
28
+ /** `<org>/<name>`. */
29
+ ref: string;
30
+ org: string;
31
+ name: string;
32
+ title?: string;
33
+ role?: string;
34
+ description?: string;
35
+ whenToUse?: string;
36
+ category?: string;
37
+ version?: string;
38
+ avatar?: string;
39
+ skills: string[];
40
+ model?: { use?: string; reasoning?: string; fallback?: string; turns?: number };
41
+ budget?: { perDay?: string; perRun?: string };
42
+ tasks: string[];
43
+ access: string[];
44
+ triggers: AgentTrigger[];
45
+ checkIn?: string;
46
+ memory?: { agent?: string };
47
+ askWhenUnsure?: string;
48
+ /** The soul's own words (character only), empty when the file is missing. */
49
+ soul: string;
50
+ /** The folder, relative to the org repo. */
51
+ path: string;
52
+ }
53
+
54
+ const str = (v: unknown): string | undefined => (typeof v === "string" && v.trim() ? v.trim() : undefined);
55
+ const strs = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && !!x.trim()) : []);
56
+ const rec = (v: unknown): Record<string, unknown> | undefined => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined);
57
+
58
+ /** One Agent folder of an org, or null when it is not one. */
59
+ export function readAgentFolder(root: string, org: string, name: string): AgentFolder | null {
60
+ const dir = join(root, "agents", name);
61
+ const file = join(dir, "agent.yaml");
62
+ if (!existsSync(file)) return null;
63
+ const doc = (parse(readFileSync(file, "utf8")) as Record<string, unknown>) ?? {};
64
+ if (doc.type !== "agent") return null;
65
+ const soulName = str(doc.soul) ?? "SOUL.md";
66
+ const soulFile = join(dir, soulName);
67
+ const model = rec(doc.model);
68
+ const budget = rec(doc.budget);
69
+ const memory = rec(doc.memory);
70
+ return {
71
+ ref: `${org}/${name}`,
72
+ org,
73
+ name,
74
+ title: str(doc.title),
75
+ role: str(doc.role),
76
+ description: str(doc.description),
77
+ whenToUse: str(doc.whenToUse),
78
+ category: str(doc.category),
79
+ version: str(doc.version),
80
+ avatar: str(doc.avatar),
81
+ skills: strs(doc.skills),
82
+ ...(model ? { model: { use: str(model.use), reasoning: str(model.reasoning), fallback: str(model.fallback), ...(typeof model.turns === "number" && model.turns > 0 ? { turns: Math.floor(model.turns) } : {}) } } : {}),
83
+ ...(budget ? { budget: { perDay: str(budget.perDay), perRun: str(budget.perRun) } } : {}),
84
+ tasks: strs(doc.tasks),
85
+ access: strs(doc.access),
86
+ triggers: (Array.isArray(doc.triggers) ? doc.triggers : [])
87
+ .map(rec)
88
+ .filter((c): c is Record<string, unknown> => !!c && !!str(c.name))
89
+ .map((c) => ({ name: str(c.name)!, node: str(c.node), wakes: str(c.wakes), ...(rec(c.config) ? { config: rec(c.config) } : {}) })),
90
+ checkIn: str(doc.checkIn),
91
+ ...(memory ? { memory: { agent: str(memory.agent) } } : {}),
92
+ askWhenUnsure: str(doc.askWhenUnsure),
93
+ soul: existsSync(soulFile) ? readFileSync(soulFile, "utf8") : "",
94
+ path: `agents/${name}`,
95
+ };
96
+ }
97
+
98
+ /** Every Agent folder of one org, by name. */
99
+ export function listAgentFoldersIn(root: string, org: string): AgentFolder[] {
100
+ const dir = join(root, "agents");
101
+ if (!existsSync(dir)) return [];
102
+ return readdirSync(dir, { withFileTypes: true })
103
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".") && !e.name.startsWith("_"))
104
+ .map((e) => isolated(`agent ${org}/${e.name}`, () => readAgentFolder(root, org, e.name)) ?? null)
105
+ .filter((a): a is AgentFolder => a !== null)
106
+ .sort((a, b) => a.name.localeCompare(b.name));
107
+ }
108
+
109
+ /** Every Agent folder across the workspace, or one org's. */
110
+ export function listAgentFolders(org?: string): AgentFolder[] {
111
+ const orgs = org && org !== "all" ? [org] : listOrgs();
112
+ return orgs.flatMap((o) => listAgentFoldersIn(projectDir(o), o));
113
+ }
@@ -290,7 +290,7 @@ export function loadAppManifest(ref: string): AppManifest | null {
290
290
  // the template loader); the app loader never did, so `app.previewComponents?.[0]`
291
291
  // was undefined and the app tool's whole render block — gated on that ref — was
292
292
  // skipped. The call returned cleanly, nothing reached /stream, and the model told
293
- // the person to use a surface that had never appeared (SAB sab-voice, 2026-09-07).
293
+ // the person to use a surface that had never appeared (a bank's voice app, 2026-09-07).
294
294
  // `kind` rides along for the same reason it does on a template: the render path
295
295
  // resolves the definition in the tier `kind` names, and an app is not a component.
296
296
  return normalizeMode({
@@ -94,6 +94,8 @@ export interface ObjectLink {
94
94
  match?: Record<string, string>;
95
95
  /** Fields (of `between`, or the row's side of `match`) whose values may name one thing several ways: one question per set of names says which are the same. */
96
96
  alike?: string[];
97
+ /** Fields of the row this line carries, added never replaced: a provider's `serves` carries its role (relationships-and-labels.md). */
98
+ carries?: string[];
97
99
  }
98
100
 
99
101
  /**
@@ -112,10 +114,26 @@ export interface ObjectMatching {
112
114
  evidence?: string[];
113
115
  /** Filled on both and different, as words: never the same thing. */
114
116
  ruleOut?: string[];
115
- /** The node that decides a name the rules leave open (TypeSafeChoice). Absent, an open name goes to review. */
116
- judge?: string;
117
+ /** The node that decides a name the rules leave open: a node's name (TypeSafeChoice), or a decision node with its
118
+ * config ({ node: OpenAIDecision, config: { model } }), asked as the review is. Absent, an open name goes to review. */
119
+ judge?: string | { node: string; config?: Record<string, unknown> };
120
+ /**
121
+ * THE LINKER, BEFORE THE JUDGE (pipelines README, "Three sweeps"; owner 2026-10-10: "Splink to dedupe
122
+ * our entities"): a node that scores the name against each candidate's names. Sure the same, it links;
123
+ * sure different, the name is none of them; between, the judge decides.
124
+ */
125
+ link?: { node: string; config: Record<string, unknown> };
126
+ /**
127
+ * THE LAST LOOK (owner 2026-10-11: "use the LLM to investigate and solve these"): the node that
128
+ * looks again at every name a run left open, with what the documents say where it is used
129
+ * (OpenAIDecision). Sure, it decides as a person would, with Undo; not sure, it stays open.
130
+ * Absent, the judge looks.
131
+ */
132
+ review?: { node: string; config?: Record<string, unknown> };
117
133
  /** The lines (by name, e.g. a document's `about`) whose name, sure to be no record held, makes the record. A mention on any other line never does. */
118
134
  createFrom?: string[];
135
+ /** The object's `extract.never` words, set by the registry: left out when two names of one parent are compared ("A Shares" and "Class A"). */
136
+ never?: string[];
119
137
  }
120
138
 
121
139
  /**
@@ -130,6 +148,18 @@ export interface DatasetColumn {
130
148
  * its words here and the database refuses the rest: a misspelling cannot become a second
131
149
  * thing that never sums with the first. Absent on a column carrying a document's own words. */
132
150
  enum?: string[];
151
+ /** FOR A COLUMN WITH `enum`: the words a table's title or headings print for each value (net return:
152
+ * [performance, returns]), so a table found by its shape is known by what it holds (tables.md 3b). */
153
+ wordings?: Record<string, string[]>;
154
+ /** FOR A COLUMN WITH `enum`: the words a key-figures row's own label prints for each value (net asset
155
+ * value: [total net assets, fund size]), so "Total Net Assets | USD 126m" is one figure (tables.md 3b). */
156
+ rowWordings?: Record<string, string[]>;
157
+ /** FOR A COLUMN WITH `enum`: the words a table's COLUMN prints for each value (exposure: [market value, % of net
158
+ * assets]), so a number kept from any table is known by the column it sits under (tables.md 3f, 2026-10-16). */
159
+ columnWordings?: Record<string, string[]>;
160
+ /** FOR A COLUMN WITH `enum`: the values of another column each of its values gathers (dimension fair value level:
161
+ * [Level 1, Level 2, Level 3]), so a number whose row or column names one is known to be sliced that way (tables.md 3i). */
162
+ values?: Record<string, string[]>;
133
163
  /** string | number | integer | date | datetime | boolean. Nothing else: a type this does not
134
164
  * name is one the generated table cannot promise anything about. */
135
165
  type: string;
@@ -152,6 +182,35 @@ export interface DatasetDefinition {
152
182
  subject?: string;
153
183
  asOf?: string;
154
184
  description?: string;
185
+ /** WHOSE A ROW IS, AS RECORDS (tables.md 3i step 4, 2026-10-17: the code names no industry): the object a row's subject is,
186
+ * its parts, how one sits inside another, and the `level` values each is filed under. Absent: rows name no record. */
187
+ whose?: DatasetWhose;
188
+ }
189
+
190
+ export interface DatasetWhose {
191
+ /** The object a row's subject is (a fund), the column its name goes in, the `level` value for its own figures. */
192
+ object: string; column: string; level: string;
193
+ /** The subject's field of other names, the field naming what it is compared with (its index), and the `level` value for that. */
194
+ names?: string; compare?: string; compareLevel?: string;
195
+ /** How a subject sits inside another: the link from it to its parent, and the field naming the parent. */
196
+ within?: { link: string; field?: string };
197
+ /** Its parts (a fund's share classes): their object, their column, their `level` value, their name field, and the field naming their subject. */
198
+ part?: { object: string; column: string; level: string; name: string; of: string; measures?: string[]; perUnit?: Record<string, string> };
199
+ /** The subject's own measures: a figure of one is the subject's, over a part only its section named. */
200
+ measures?: string[];
201
+ /** The measures that are a record's standing terms (a fee rate): a figure of one is a field, never a row. */
202
+ terms?: string[];
203
+ /** The measures that are a subject's figure against its comparison (an excess return): a row of one that names no
204
+ * subject is the subject the table's own rows name, when they name exactly one. */
205
+ relative?: string[];
206
+ /** The words a name of the subject's kind carries (fund, portfolio): an allocation's row naming one, with words of its
207
+ * own, is a holding (tables.md 3c rule 3). Absent, the word fund. */
208
+ vehicles?: string[];
209
+ /** Who stands behind the subject (a fund's manager): its object, as a layout's section names it in `about`, and the
210
+ * `level` value for its figures (the firm's assets under management), reached from a row's subject by a join. */
211
+ owner?: { object: string; level: string };
212
+ /** The `level` value for a document about no subject (a market note). */
213
+ none?: string;
155
214
  }
156
215
 
157
216
  export interface ObjectFunction {
@@ -206,6 +265,8 @@ export interface ObjectDefinition {
206
265
  links?: Record<string, ObjectLink>;
207
266
  /** How a name is matched to its rows (entity-resolution.md); absent, a line to it compares one field with one field. */
208
267
  matching?: ObjectMatching;
268
+ /** How a page reader picks it out of a page (pipelines README, "Or read by a node"): instructions and worked examples. */
269
+ extract?: { instructions?: string; examples?: { text: string; answer: Record<string, unknown>[] }[]; never?: string[]; notAlso?: string[]; notAlsoWhen?: Record<string, Record<string, string[]>>; notWhen?: string[] };
209
270
  /** THE TABLE THIS OBJECT OWNS (docs/unoverse/data/objects/dataset.md, PROPOSED 2026-09-18),
210
271
  * from its folder's table.yaml: an object that holds many small facts read together rather
211
272
  * than one row read alone. Absent on every ordinary object, which stays a row. */
@@ -275,12 +336,54 @@ export function readDataset(name: string, file: Record<string, any>, dir: string
275
336
  // about the column.
276
337
  if (allowed && type !== "string") findings.push(`column "${col}": only a string column may say which values it allows`);
277
338
  if (allowed && new Set(allowed).size !== allowed.length) findings.push(`column "${col}": its allowed values repeat`);
278
- columns.push({ name: col, type, ...(allowed ? { enum: allowed } : {}), ...(c?.required === true ? { required: true } : {}), ...(c?.indexed === true ? { indexed: true } : {}), ...(c?.description ? { description: String(c.description) } : {}) });
339
+ // ITS WORDS (tables.md 3b): what a table prints for each allowed value; a key outside the list is no word of it.
340
+ const wordings = c?.wordings && typeof c.wordings === "object" ? Object.fromEntries(Object.entries(c.wordings as Record<string, unknown>).map(([k, v]) => [k, Array.isArray(v) ? v.map(String) : []])) : undefined;
341
+ if (wordings && !allowed) findings.push(`column "${col}": wordings name the words for allowed values, so the column says enum`);
342
+ for (const k of Object.keys(wordings ?? {})) if (allowed && !allowed.includes(k)) findings.push(`column "${col}": wordings for "${k}", which is not one of its allowed values`);
343
+ // AND A ROW'S OWN WORDS (key figures, tables.md 3b): kept like `wordings`, or the reader never sees them
344
+ // (measured 2026-10-14: dropped here, no key figure was read).
345
+ const rowWordings = c?.rowWordings && typeof c.rowWordings === "object" ? Object.fromEntries(Object.entries(c.rowWordings as Record<string, unknown>).map(([k, v]) => [k, Array.isArray(v) ? v.map(String) : []])) : undefined;
346
+ if (rowWordings && !allowed) findings.push(`column "${col}": rowWordings name the words for allowed values, so the column says enum`);
347
+ for (const k of Object.keys(rowWordings ?? {})) if (allowed && !allowed.includes(k)) findings.push(`column "${col}": rowWordings for "${k}", which is not one of its allowed values`);
348
+ // AND A COLUMN'S OWN WORDS (every number kept, tables.md 3f): kept like `wordings`.
349
+ const columnWordings = c?.columnWordings && typeof c.columnWordings === "object" ? Object.fromEntries(Object.entries(c.columnWordings as Record<string, unknown>).map(([k, v]) => [k, Array.isArray(v) ? v.map(String) : []])) : undefined;
350
+ if (columnWordings && !allowed) findings.push(`column "${col}": columnWordings name the words for allowed values, so the column says enum`);
351
+ for (const k of Object.keys(columnWordings ?? {})) if (allowed && !allowed.includes(k)) findings.push(`column "${col}": columnWordings for "${k}", which is not one of its allowed values`);
352
+ // AND THE VALUES EACH OF ITS VALUES GATHERS (a slicing's own values, tables.md 3i): kept like `wordings`.
353
+ const values = c?.values && typeof c.values === "object" ? Object.fromEntries(Object.entries(c.values as Record<string, unknown>).map(([k, v]) => [k, Array.isArray(v) ? v.map(String) : []])) : undefined;
354
+ if (values && !allowed) findings.push(`column "${col}": values name what allowed values gather, so the column says enum`);
355
+ for (const k of Object.keys(values ?? {})) if (allowed && !allowed.includes(k)) findings.push(`column "${col}": values for "${k}", which is not one of its allowed values`);
356
+ columns.push({ name: col, type, ...(allowed ? { enum: allowed } : {}), ...(wordings ? { wordings } : {}), ...(rowWordings ? { rowWordings } : {}), ...(columnWordings ? { columnWordings } : {}), ...(values ? { values } : {}), ...(c?.required === true ? { required: true } : {}), ...(c?.indexed === true ? { indexed: true } : {}), ...(c?.description ? { description: String(c.description) } : {}) });
279
357
  }
280
358
  const uniqueBy = Array.isArray(file.uniqueBy) ? file.uniqueBy.map((u: unknown) => String(u)) : undefined;
281
359
  for (const u of uniqueBy ?? []) if (!seen.has(u) && !RESERVED.has(u)) findings.push(`uniqueBy names "${u}", which is not a column`);
282
360
  if (findings.length) throw new Error(`object ${name}: ${findings.join("; ")} (${dir})`);
283
- return { columns, ...(uniqueBy ? { uniqueBy } : {}), ...(file.description ? { description: String(file.description) } : {}) };
361
+ // WHOSE A ROW IS, AS RECORDS: kept as declared, its columns checked.
362
+ const w = file.whose && typeof file.whose === "object" ? (file.whose as Record<string, any>) : null;
363
+ const whose: DatasetWhose | undefined = w && w.object && w.column && w.level ? {
364
+ object: String(w.object), column: String(w.column), level: String(w.level),
365
+ ...(w.names ? { names: String(w.names) } : {}), ...(w.compare ? { compare: String(w.compare) } : {}), ...(w.compareLevel ? { compareLevel: String(w.compareLevel) } : {}),
366
+ ...(w.within?.link ? { within: { link: String(w.within.link), ...(w.within.field ? { field: String(w.within.field) } : {}) } } : {}),
367
+ // A PART'S OWN MEASURES (2026-10-24): a figure of one of them is always a part's ("net asset value per share" is a
368
+ // share class's; a fund has no price per share of its own), whatever section it is printed in.
369
+ ...(w.part?.object ? { part: { object: String(w.part.object), column: String(w.part.column), level: String(w.part.level), name: String(w.part.name), of: String(w.part.of), ...(Array.isArray(w.part.measures) ? { measures: w.part.measures.map(String) } : {}), ...(w.part.perUnit && typeof w.part.perUnit === "object" ? { perUnit: Object.fromEntries(Object.entries(w.part.perUnit).map(([k, v]) => [k, String(v)])) } : {}) } } : {}),
370
+ ...(w.none ? { none: String(w.none) } : {}),
371
+ ...(Array.isArray(w.measures) ? { measures: w.measures.map(String) } : {}),
372
+ // THE MEASURES THAT ARE TERMS (2026-10-25): a figure of one is a field of its record, never a row ("static goes into
373
+ // the entities, changes over time goes into the metrics").
374
+ ...(Array.isArray(w.terms) ? { terms: w.terms.map(String) } : {}),
375
+ // THE MEASURES THAT ARE A FIGURE AGAINST A COMPARISON (2026-10-25): "Excess" printed under a class's and its index's
376
+ // rows is that class's return above the index, as the measure's own definition says.
377
+ ...(Array.isArray(w.relative) ? { relative: w.relative.map(String) } : {}),
378
+ // WHAT A HOLDING OF THE SUBJECT'S KIND IS CALLED IN ITS NAME (tables.md 3c rule 3, 2026-10-26): a fund, a segregated portfolio.
379
+ ...(Array.isArray(w.vehicles) ? { vehicles: w.vehicles.map(String) } : {}),
380
+ // WHO STANDS BEHIND THE SUBJECT (2026-10-25): a section about a fund's manager prints the firm's figures, not the fund's.
381
+ ...(w.owner?.object && w.owner?.level ? { owner: { object: String(w.owner.object), level: String(w.owner.level) } } : {}),
382
+ } : undefined;
383
+ if (w && !whose) findings.push("whose names its object, its column and its level");
384
+ for (const col of [whose?.column, whose?.part?.column].filter(Boolean) as string[]) if (!seen.has(col)) findings.push(`whose names column "${col}", which is not a column`);
385
+ if (findings.length) throw new Error(`object ${name}: ${findings.join("; ")} (${dir})`);
386
+ return { columns, ...(uniqueBy ? { uniqueBy } : {}), ...(file.description ? { description: String(file.description) } : {}), ...(whose ? { whose } : {}) };
284
387
  }
285
388
 
286
389
  function readFolder(dir: string, name: string, org: string | undefined, seen: string[]): ObjectDefinition {
@@ -367,7 +470,8 @@ function readFolder(dir: string, name: string, org: string | undefined, seen: st
367
470
  ...(typeof envelope.element === "string" && envelope.element ? { element: envelope.element } : parent?.element ? { element: parent.element } : {}),
368
471
  ...(envelope.meta ? { meta: envelope.meta } : parent?.meta ? { meta: parent.meta } : {}),
369
472
  ...(links && Object.keys(links).length ? { links } : {}),
370
- ...(envelope.matching ? { matching: envelope.matching as ObjectMatching } : parent?.matching ? { matching: parent.matching } : {}),
473
+ ...(envelope.matching ? { matching: { ...(envelope.matching as ObjectMatching), ...((envelope.extract as ObjectDefinition["extract"])?.never?.length ? { never: (envelope.extract as ObjectDefinition["extract"])!.never } : {}) } } : parent?.matching ? { matching: parent.matching } : {}),
474
+ ...(envelope.extract ? { extract: envelope.extract as ObjectDefinition["extract"] } : {}),
371
475
  ...(dataset ? { dataset } : {}),
372
476
  props,
373
477
  ownProps,
@@ -68,11 +68,28 @@ export interface MakeEntry {
68
68
  /** How many of this object one source makes (owner, 2026-09-13): one, or many. The read asks by it; a part's entry says none. */
69
69
  cardinality?: "one" | "many";
70
70
  /**
71
- * WRITTEN FROM EVERY SECTION (owner, 2026-10-05): `per: section` writes this object after the
72
- * document is cut, one read per stored piece of each named section, instead of in the
73
- * document's first read, which sees only its first two pages and its headings.
71
+ * WRITTEN FROM EVERY PAGE (owner, 2026-10-05; page-store.md, 2026-10-07: no sections): `per: page`
72
+ * writes this object once the document's pages are kept, one read per page, instead of in the
73
+ * document's first read, which sees only its first two pages.
74
74
  */
75
- per?: "section";
75
+ per?: "page";
76
+ /**
77
+ * READ BY A NODE, FROM THE PAGES THAT NAME IT (pipelines README, "Or read by a node", owner
78
+ * 2026-10-09): with `per: page`, the node handed the best chunks with a template made from the
79
+ * object's input props, in place of a model reading every page. Its config is resolved over the
80
+ * run's `config` when the rows are collected.
81
+ */
82
+ read?: { node: string; config: Record<string, unknown>; chunks?: number | "all"; cacheable?: true };
83
+ /**
84
+ * READ FROM THE DOCUMENT'S TABLES TOO (pipelines/tables.md, 2026-10-12): a table's labels are
85
+ * matched to the object's props by their `wordings`, and a matched cell's value is copied as
86
+ * printed. `whose` is the node that says which of the document's rows a table is about, when
87
+ * the document is linked to more than one. `labels` is the node that proposes a field for a label
88
+ * no rule matched (column matching step 4), once per org, for a person to approve in Review.
89
+ */
90
+ /** `allocations` names the object's line an allocation table draws, one per fund it holds, carrying the weight (tables.md 3c). */
91
+ /** `boards` names the object's lines a board list may draw, each to whose board it is, carrying the role (tables.md 3d). */
92
+ tables?: { whose?: { node: string; config: Record<string, unknown> }; subject?: boolean; labels?: { node: string; config: Record<string, unknown> }; allocations?: string; boards?: string[] };
76
93
  sourceUrl?: string;
77
94
  sourceId?: string;
78
95
  /** The item's content, each value a reference over `item`, `index`, `config`, `collect`. */
@@ -154,7 +171,9 @@ function readFolder(dir: string, name: string, org: string | undefined): Pipelin
154
171
  ...(spec.any === true ? { any: list } : { each: list }),
155
172
  ...(spec.cardinality === "one" || spec.cardinality === "many" ? { cardinality: spec.cardinality as "one" | "many" } : {}),
156
173
  ...(whenOf(spec) ? { when: whenOf(spec) } : {}),
157
- ...(spec.per === "section" ? { per: "section" as const } : {}),
174
+ ...(spec.per === "page" ? { per: "page" as const } : {}),
175
+ ...(isRecord(spec.read) && typeof spec.read.node === "string" ? { read: { node: spec.read.node, config: isRecord(spec.read.config) ? spec.read.config : {}, ...(spec.read.chunks === "all" || typeof spec.read.chunks === "number" ? { chunks: spec.read.chunks as number | "all" } : {}), ...(spec.read.cacheable === true ? { cacheable: true as const } : {}) } } : {}),
176
+ ...(isRecord(spec.tables) ? { tables: { ...(isRecord(spec.tables.whose) && typeof spec.tables.whose.node === "string" ? { whose: { node: spec.tables.whose.node, config: isRecord(spec.tables.whose.config) ? spec.tables.whose.config : {} } } : {}), ...(spec.tables.subject === true ? { subject: true } : {}), ...(isRecord(spec.tables.labels) && typeof spec.tables.labels.node === "string" ? { labels: { node: spec.tables.labels.node, config: isRecord(spec.tables.labels.config) ? spec.tables.labels.config : {} } } : {}), ...(typeof spec.tables.allocations === "string" && spec.tables.allocations ? { allocations: spec.tables.allocations } : {}), ...(Array.isArray(spec.tables.boards) && spec.tables.boards.length && spec.tables.boards.every((b: unknown) => typeof b === "string" && b) ? { boards: spec.tables.boards as string[] } : {}) } } : {}),
158
177
  sourceUrl: typeof raw.sourceUrl === "string" ? raw.sourceUrl : "{{ item.address }}",
159
178
  sourceId: typeof raw.sourceId === "string" ? raw.sourceId : "{{ item.universalId }}",
160
179
  content: isRecord(raw.content) ? raw.content : {},
@@ -0,0 +1,76 @@
1
+ /**
2
+ * A TASK START IS A TASK (docs/mcp/MCP_TASKS.md § 2): a titled, described Task Start has an MCP
3
+ * tool on our own door, so any Agent a line is drawn to on its canvas can call it, and is
4
+ * answered with an MCP task. One rule, read by the engine's Task Start (what it offers a wired
5
+ * Agent) and the server (the tool it registers), so the two always name the same tool.
6
+ */
7
+ import { AGENT_CARD, listAgents, type AgentFile } from "./agents.js";
8
+ import { listApps } from "./catalog.js";
9
+
10
+ /** A Task Start as a workflow saves it. */
11
+ export interface TaskStartNode {
12
+ id: string;
13
+ type?: string;
14
+ data?: { config?: Record<string, unknown> };
15
+ }
16
+
17
+ const kebab = (s: string) =>
18
+ s
19
+ .trim()
20
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
21
+ .replace(/[^a-zA-Z0-9]+/g, "-")
22
+ .replace(/^-+|-+$/g, "")
23
+ .toLowerCase();
24
+
25
+ const text = (v: unknown) => (typeof v === "string" ? v.trim() : "");
26
+
27
+ /**
28
+ * A TASK START'S NAME (docs/mcp/MCP_TASKS.md § 2a, step U2): its own `name` field, set once, so its
29
+ * URI (`unoverse://tasks/<org>/<name>`) never moves when someone renames the title. A Task Start
30
+ * saved before the field existed is named from its title, as it always was, and that name is
31
+ * written into the field on its next save (`withFixedTaskName`), so from then on it stays put.
32
+ */
33
+ export function taskStartName(node: TaskStartNode): string {
34
+ const cfg = node.data?.config ?? {};
35
+ return text(cfg.name) || kebab(text(cfg.title));
36
+ }
37
+
38
+ /** The node with its name written in, when it has a title and no name yet. Saving calls this. */
39
+ export function withFixedTaskName<T extends TaskStartNode>(node: T): T {
40
+ if (node.type !== "InputTrigger") return node;
41
+ const cfg = node.data?.config ?? {};
42
+ if (text(cfg.name) || !text(cfg.title)) return node;
43
+ return { ...node, data: { ...node.data, config: { ...cfg, name: kebab(text(cfg.title)) } } };
44
+ }
45
+
46
+ /**
47
+ * The Task a Task Start is, or undefined when it is not one yet (no title, or nothing saying what
48
+ * it does, or its workflow belongs to no org's app). An Agent file that already names this start
49
+ * IS its Task, so the start reuses that file's tool rather than minting a second one.
50
+ */
51
+ export function taskOfStart(workflowId: string, node: TaskStartNode): AgentFile | undefined {
52
+ if (node.type !== undefined && node.type !== "InputTrigger") return undefined;
53
+ const known = listAgents().find((a) => a.workflow === workflowId && a.trigger === node.id);
54
+ if (known) return known;
55
+ const cfg = node.data?.config ?? {};
56
+ const title = text(cfg.title);
57
+ const description = text(cfg.description);
58
+ const whenToUse = text(cfg.whenToUse);
59
+ const id = taskStartName(node);
60
+ if (!id || (!description && !whenToUse)) return undefined;
61
+ const org = listApps().find((a) => a.binding?.workflow === workflowId)?.org;
62
+ if (!org) return undefined;
63
+ return {
64
+ name: `${org}/${id}`,
65
+ org,
66
+ id,
67
+ title,
68
+ ...(description ? { description } : {}),
69
+ ...(whenToUse ? { whenToUse } : {}),
70
+ app: AGENT_CARD,
71
+ workflow: workflowId,
72
+ trigger: node.id,
73
+ ...(text(cfg.resultFrom) ? { output: text(cfg.resultFrom) } : {}),
74
+ path: "",
75
+ };
76
+ }