wizards 0.2.25 → 0.2.27

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 (126) hide show
  1. package/apps/runtime/dist/agents/architect.js +5 -0
  2. package/apps/runtime/dist/agents/project-assistant.js +10 -1
  3. package/apps/runtime/dist/authoring/guide.js +61 -8
  4. package/apps/runtime/dist/connectors/builtin.js +17 -1
  5. package/apps/runtime/dist/connectors/index.js +43 -14
  6. package/apps/runtime/dist/connectors/plugin-connections.js +248 -0
  7. package/apps/runtime/dist/connectors/via-account.js +63 -0
  8. package/apps/runtime/dist/credits/estimate.js +12 -2
  9. package/apps/runtime/dist/db/migrations/tenant/0010_plugin_connection.sql +15 -0
  10. package/apps/runtime/dist/db/migrations/tenant/meta/0010_snapshot.json +2942 -0
  11. package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
  12. package/apps/runtime/dist/db/schema.js +21 -0
  13. package/apps/runtime/dist/engenty/connections-google/connectors/calendar.js +25 -3
  14. package/apps/runtime/dist/engenty/connections-microsoft/outlook.js +29 -1
  15. package/apps/runtime/dist/engine/decide.js +82 -0
  16. package/apps/runtime/dist/engine/input.js +21 -5
  17. package/apps/runtime/dist/engine/requirements.js +80 -15
  18. package/apps/runtime/dist/engine/runner.js +252 -6
  19. package/apps/runtime/dist/engine/steps.js +206 -18
  20. package/apps/runtime/dist/engine/surface.js +90 -0
  21. package/apps/runtime/dist/mcp/tools.js +3 -2
  22. package/apps/runtime/dist/plugins/loader.js +22 -1
  23. package/apps/runtime/dist/plugins/registry.js +10 -0
  24. package/apps/runtime/dist/render/downloads.js +7 -0
  25. package/apps/runtime/dist/routes/runs.js +28 -7
  26. package/apps/runtime/dist/routes/studio.js +3 -1
  27. package/apps/runtime/dist/services/marketplace.js +134 -3
  28. package/apps/runtime/dist/services/relevance.js +17 -49
  29. package/apps/runtime/dist/services/runs.js +43 -15
  30. package/apps/runtime/dist/services/wizards.js +4 -2
  31. package/apps/runtime/dist/tools/pages.js +31 -6
  32. package/apps/runtime/dist/tools/plugin.js +29 -1
  33. package/apps/web/dist/assets/Account-DW396ig5.js +1 -0
  34. package/apps/web/dist/assets/{ChangelogDialog-B7C0r9dF.js → ChangelogDialog-BdZ1SmEd.js} +1 -1
  35. package/apps/web/dist/assets/CreditsDialog-fOYdoev3.js +1 -0
  36. package/apps/web/dist/assets/EditorPage-CAARFsaI.js +3 -0
  37. package/apps/web/dist/assets/{FlowPreview-D0m5Qk6S.js → FlowPreview-U2AEnKlX.js} +1 -1
  38. package/apps/web/dist/assets/{Harness-bIL3pHB_.js → Harness-CtDlm_2b.js} +2 -2
  39. package/apps/web/dist/assets/HomePage-Bd-mkLxW.js +1 -0
  40. package/apps/web/dist/assets/ImportWizard-BMPGjd_t.js +1 -0
  41. package/apps/web/dist/assets/LocalRuntime-DjSNCRnV.js +1 -0
  42. package/apps/web/dist/assets/NewWizardPage-DZPcDR9i.js +2 -0
  43. package/apps/web/dist/assets/ProjectPage-_LAtG3tn.js +2 -0
  44. package/apps/web/dist/assets/PublicRunner-6Iqp0DmU.js +1 -0
  45. package/apps/web/dist/assets/RunDrawer-CtQoY3Ha.js +4 -0
  46. package/apps/web/dist/assets/RunnerView-BLOCNZaZ.js +2 -0
  47. package/apps/web/dist/assets/SettingsPage-Z6skBf6I.js +1 -0
  48. package/apps/web/dist/assets/SetupPage-4-Lic4pT.js +1 -0
  49. package/apps/web/dist/assets/SharePage-DfdhXxZm.js +1 -0
  50. package/apps/web/dist/assets/arrow-right-BOtqnsA1.js +1 -0
  51. package/apps/web/dist/assets/brand-CCAI9p0f.js +2 -0
  52. package/apps/web/dist/assets/changelog-DQM0oz9S.js +1 -0
  53. package/apps/web/dist/assets/circle-alert-BC6xmJ9r.js +1 -0
  54. package/apps/web/dist/assets/definition-D5V79jHA.js +1 -0
  55. package/apps/web/dist/assets/esm-FEWi61F_.js +7 -0
  56. package/apps/web/dist/assets/external-link-wKL1HE6G.js +1 -0
  57. package/apps/web/dist/assets/image-BMdSChOR.js +1 -0
  58. package/apps/web/dist/assets/index-BOp2fz-2.css +1 -0
  59. package/apps/web/dist/assets/index-YJMooz0J.js +132 -0
  60. package/apps/web/dist/assets/lock-BpqzLMuv.js +1 -0
  61. package/apps/web/dist/assets/meta-BqZ7SlZz.js +1 -0
  62. package/apps/web/dist/assets/pen-line-DM-u0dES.js +1 -0
  63. package/apps/web/dist/assets/refresh-cw-Bk7HkOIM.js +1 -0
  64. package/apps/web/dist/assets/settings-menu-BM_NfZfn.js +1 -0
  65. package/apps/web/dist/assets/star-JHzYYl5R.js +1 -0
  66. package/apps/web/dist/assets/trash-CdLg-Ela.js +1 -0
  67. package/apps/web/dist/assets/ui-8QmvuC9u.js +9 -0
  68. package/apps/web/dist/assets/upload-CL1R6shM.js +1 -0
  69. package/apps/web/dist/assets/video-BioLmXNO.js +1 -0
  70. package/apps/web/dist/assets/volume-2-_IOfPNCv.js +1 -0
  71. package/apps/web/dist/assets/wand-sparkles-DYtDQSJZ.js +1 -0
  72. package/apps/web/dist/index.html +4 -5
  73. package/apps/web/dist/mcp/flow.html +61 -61
  74. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +85 -0
  75. package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +23 -0
  76. package/node_modules/@engenty-wizards/shared/dist/decision.d.ts +76 -0
  77. package/node_modules/@engenty-wizards/shared/dist/decision.js +147 -0
  78. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +1076 -51
  79. package/node_modules/@engenty-wizards/shared/dist/definition.js +597 -44
  80. package/node_modules/@engenty-wizards/shared/dist/marketplace-entry.js +19 -2
  81. package/node_modules/@engenty-wizards/shared/dist/marketplace.d.ts +6 -1
  82. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +55 -0
  83. package/node_modules/@engenty-wizards/shared/dist/run.js +3 -0
  84. package/node_modules/@engenty-wizards/shared/dist/surface.d.ts +89 -0
  85. package/node_modules/@engenty-wizards/shared/dist/surface.js +198 -0
  86. package/package.json +1 -1
  87. package/apps/web/dist/assets/Account-CpxsROpe.js +0 -1
  88. package/apps/web/dist/assets/CreditsDialog-Com2Y3g8.js +0 -1
  89. package/apps/web/dist/assets/EditorPage-BCagWjrz.js +0 -2
  90. package/apps/web/dist/assets/HomePage-DPE_Z9HB.js +0 -1
  91. package/apps/web/dist/assets/ImportWizard-Dz2uKznp.js +0 -1
  92. package/apps/web/dist/assets/LocalRuntime-wMKUVZOu.js +0 -1
  93. package/apps/web/dist/assets/NewWizardPage-BOxU2jWq.js +0 -2
  94. package/apps/web/dist/assets/ProjectPage-BJp2wqIk.js +0 -5
  95. package/apps/web/dist/assets/PublicRunner-BsrqVK0J.js +0 -1
  96. package/apps/web/dist/assets/RunDrawer-CLfJ0W_V.js +0 -1
  97. package/apps/web/dist/assets/RunnerView-BXMELFC4.js +0 -70
  98. package/apps/web/dist/assets/SettingsPage-BkuUm-bf.js +0 -1
  99. package/apps/web/dist/assets/SetupPage-CoyXpcvS.js +0 -1
  100. package/apps/web/dist/assets/SharePage-BuR-he1L.js +0 -1
  101. package/apps/web/dist/assets/api-rJBQEu1G.js +0 -10
  102. package/apps/web/dist/assets/arrow-right-6FyN3e7D.js +0 -1
  103. package/apps/web/dist/assets/changelog-Bmw6dg-T.js +0 -1
  104. package/apps/web/dist/assets/check-CUpby_J9.js +0 -1
  105. package/apps/web/dist/assets/chevron-down-DymW-2BP.js +0 -1
  106. package/apps/web/dist/assets/circle-alert-D2SCwFT-.js +0 -1
  107. package/apps/web/dist/assets/createLucideIcon-q3MZMmnf.js +0 -1
  108. package/apps/web/dist/assets/definition-BznNdlhf.js +0 -55
  109. package/apps/web/dist/assets/esm-BrGYYg2h.js +0 -7
  110. package/apps/web/dist/assets/external-link-MAL7vJYj.js +0 -1
  111. package/apps/web/dist/assets/image-CC_2mJd6.js +0 -1
  112. package/apps/web/dist/assets/index-BZs1IxAk.css +0 -1
  113. package/apps/web/dist/assets/index-Co5QOA9_.js +0 -10
  114. package/apps/web/dist/assets/lock-DZL2N67v.js +0 -1
  115. package/apps/web/dist/assets/meta-B3jzXP27.js +0 -1
  116. package/apps/web/dist/assets/pen-line-NK46C_nG.js +0 -1
  117. package/apps/web/dist/assets/plus-Du-Jx8aI.js +0 -1
  118. package/apps/web/dist/assets/refresh-cw-BNtHis1x.js +0 -1
  119. package/apps/web/dist/assets/settings-menu-CCNUb0Fd.js +0 -1
  120. package/apps/web/dist/assets/table-2-BdDuBcF2.js +0 -1
  121. package/apps/web/dist/assets/trash-0IafyhZB.js +0 -1
  122. package/apps/web/dist/assets/ui-DJOZSSKo.js +0 -1
  123. package/apps/web/dist/assets/upload-DNRENWzQ.js +0 -1
  124. package/apps/web/dist/assets/video-9fDPRPCv.js +0 -1
  125. package/apps/web/dist/assets/volume-2-DE67_rue.js +0 -1
  126. package/apps/web/dist/assets/wand-sparkles-Clj3VRB5.js +0 -1
@@ -0,0 +1,90 @@
1
+ import { assembleSurface, dataRef, listRef, } from "@engenty-wizards/shared/definition";
2
+ import { validateSurface } from "@engenty-wizards/shared/surface";
3
+ import { decide } from "./decide.js";
4
+ import { answersAsText, resolveRef } from "./template.js";
5
+ import { StepError } from "./types.js";
6
+ /**
7
+ * A surface step: its data read from the run (answers, outputs, stored lists; the pictures a
8
+ * step made as asset ids), its components written once — or, for a decided surface, the pieces
9
+ * one decision keeps. Checked against the catalog before it is shown.
10
+ */
11
+ export async function runSurfaceStep(step, ctx) {
12
+ const data = surfaceData(step.data, ctx);
13
+ const components = step.components ?? (await decidedComponents(step, ctx));
14
+ const issues = validateSurface(components, Object.keys(data));
15
+ if (issues.length) {
16
+ throw new StepError(`Die Ansicht ist fehlerhaft: ${issues.map((i) => i.message).join(" ")}`);
17
+ }
18
+ return { surface: { components, data }, at: new Date().toISOString() };
19
+ }
20
+ /** What a surface reads: key → a field, a step's output or one of its fields, a stored list. */
21
+ export function surfaceData(refs, ctx) {
22
+ const out = {};
23
+ for (const [key, raw] of Object.entries(refs)) {
24
+ const ref = dataRef(raw);
25
+ const list = listRef(ref);
26
+ if (list) {
27
+ out[key] = (ctx.scope.lists?.[list]?.rows ?? []).map((r) => r.cells);
28
+ continue;
29
+ }
30
+ const source = ctx.def.steps.find((s) => `steps.${s.id}` === ref);
31
+ if (source && (source.type === "generate" || source.type === "widget")) {
32
+ // Pictures and clips by asset id: the runner shows them from the run's files.
33
+ const ids = (ctx.state.outputs[source.id]?.assets ?? []).map((a) => a.id);
34
+ out[key] = source.type === "generate" && source.each ? ids : (ids[0] ?? null);
35
+ continue;
36
+ }
37
+ const output = source ? ctx.state.outputs[source.id] : undefined;
38
+ out[key] = output ? (output.json ?? output.text ?? null) : (resolveRef(ref, ctx.scope) ?? null);
39
+ }
40
+ return out;
41
+ }
42
+ /** The pieces of a decided surface one decision keeps: required ones always. */
43
+ async function decidedComponents(step, ctx) {
44
+ const candidates = step.candidates ?? [];
45
+ const questions = {};
46
+ for (const c of candidates) {
47
+ if (!c.required && !c.group) {
48
+ questions[`p_${c.id}`] = {
49
+ type: "noul",
50
+ instructions: `Should the view show this: ${c.description}`,
51
+ };
52
+ }
53
+ }
54
+ for (const g of step.groups ?? []) {
55
+ const members = candidates.filter((c) => c.group === g.id && !c.required);
56
+ if (members.length) {
57
+ questions[`g_${g.id}`] = {
58
+ type: "choice",
59
+ instructions: g.instructions,
60
+ criteria: {
61
+ ...Object.fromEntries(members.map((c) => [c.id, c.description])),
62
+ ...(g.optional ? { none: "None of these." } : {}),
63
+ },
64
+ };
65
+ }
66
+ }
67
+ const kept = new Set(candidates.filter((c) => c.required).map((c) => c.id));
68
+ if (Object.keys(questions).length) {
69
+ const results = Object.fromEntries(ctx.def.steps
70
+ .map((s) => [s.title, ctx.state.outputs[s.id]?.text ?? ""])
71
+ .filter(([, text]) => text)
72
+ .map(([title, text]) => [title, text.slice(0, 6000)]));
73
+ const decision = await decide({
74
+ state: { answers: answersAsText(ctx.scope), results },
75
+ questions,
76
+ call: ctx.call,
77
+ signal: ctx.signal,
78
+ charge: ctx.chargeUsd,
79
+ });
80
+ for (const [name, answer] of Object.entries(decision.answers)) {
81
+ if (answer.type === "noul" && answer.noul >= 0.5) {
82
+ kept.add(name.slice(2));
83
+ }
84
+ else if (answer.type === "choice" && answer.choice !== "none") {
85
+ kept.add(answer.choice);
86
+ }
87
+ }
88
+ }
89
+ return assembleSurface(candidates, kept);
90
+ }
@@ -143,7 +143,7 @@ export function registerTools(server, who) {
143
143
  });
144
144
  tool("list_starters", "wizards:read", {
145
145
  title: "List starters",
146
- description: "The marketplace's ready-made wizards (tweet with image, video ad, research briefing, dashboard, invoice, offer …), with what each makes and needs. Give a query to search them; without one you get the starters.",
146
+ description: "Ready-made wizards to start from: first those the space's plugins bring (marked with the plugin), then the marketplace's (tweet with image, video ad, research briefing, dashboard, invoice, offer …), with what each makes and needs. Give a query to search them; without one you get the starters.",
147
147
  input: z.object({ query: z.string().max(200).optional() }),
148
148
  readOnly: true,
149
149
  }, async ({ query }) => (await searchMarketplace({ q: query ?? "", lang: "de", limit: 30 })).entries
@@ -155,6 +155,7 @@ export function registerTools(server, who) {
155
155
  formats: s.formats,
156
156
  useCases: s.useCases,
157
157
  capabilities: s.capabilities,
158
+ ...(s.plugin ? { plugin: s.plugin.name } : {}),
158
159
  })));
159
160
  tool("get_starter", "wizards:read", {
160
161
  title: "Get starter",
@@ -401,7 +402,7 @@ While status is running, get_run with waitSeconds. Hosts with MCP Apps also show
401
402
  }, async ({ runId, waitSeconds }) => runReport(who.userId, runId, waitSeconds));
402
403
  tool("answer_page", "runs:test", {
403
404
  title: "Answer page",
404
- description: "Fill the page a run waits for: field id → value (items: an array of row objects; toggle: true/false; multiselect: an array). Returns the run, after waitSeconds.",
405
+ description: "Fill the page a run waits for: field id → value (items: an array of row objects; toggle: true/false; multiselect: an array; slot: one of the offered times, as given). Returns the run, after waitSeconds.",
405
406
  input: z.object({
406
407
  runId: z.string(),
407
408
  stepId: z.string().describe("waitingFor.page"),
@@ -1,11 +1,12 @@
1
1
  import { existsSync, mkdirSync, watch } from "node:fs";
2
2
  import { join, resolve, sep } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { PLUGIN_TOOL_NAME } from "@engenty-wizards/shared/definition";
4
+ import { PLUGIN_ID, PLUGIN_TOOL_NAME, wizardSchema } from "@engenty-wizards/shared/definition";
5
5
  import { generateText, Output } from "ai";
6
6
  import { createJiti } from "jiti";
7
7
  import { readPage } from "../agents/website.js";
8
8
  import { packageRoot } from "../cli/home.js";
9
+ import { pluginConnections } from "../connectors/plugin-connections.js";
9
10
  import { db, onTenantOpen } from "../db/client.js";
10
11
  import { parseDocument } from "../documents/parse.js";
11
12
  import { env } from "../env.js";
@@ -98,6 +99,9 @@ function apiFor(record) {
98
99
  }
99
100
  record.assistantTools.set(tool.name, tool);
100
101
  },
102
+ registerAssistantContext(context) {
103
+ record.assistantPages.push(context);
104
+ },
101
105
  registerPublicRoute(route) {
102
106
  record.publicRoutes.push(compileRoute(route));
103
107
  },
@@ -153,6 +157,21 @@ function apiFor(record) {
153
157
  object: (request.schema ? result.output : undefined),
154
158
  };
155
159
  },
160
+ connections: pluginConnections(source.id),
161
+ registerStarter(starter) {
162
+ if (!PLUGIN_ID.test(starter.id)) {
163
+ throw new Error(`Starter "${starter.id}": an id is lower case, digits and "-".`);
164
+ }
165
+ if (record.starters.has(starter.id)) {
166
+ throw new Error(`Starter "${starter.id}" is registered twice.`);
167
+ }
168
+ const parsed = wizardSchema.safeParse(starter.definition);
169
+ if (!parsed.success) {
170
+ const issue = parsed.error.issues[0];
171
+ throw new Error(`Starter "${starter.id}": ${issue?.path.join(".") || "definition"}: ${issue?.message}`);
172
+ }
173
+ record.starters.set(starter.id, { ...starter, definition: parsed.data });
174
+ },
156
175
  onUnload(dispose) {
157
176
  record.disposers.push(dispose);
158
177
  },
@@ -186,8 +205,10 @@ async function load(source) {
186
205
  record.tools.clear();
187
206
  record.contexts = [];
188
207
  record.assistantTools.clear();
208
+ record.assistantPages = [];
189
209
  record.publicRoutes = [];
190
210
  record.jobs.clear();
211
+ record.starters.clear();
191
212
  record.listeners = {};
192
213
  record.migrations = [];
193
214
  record.error = err?.message || String(err);
@@ -17,8 +17,10 @@ export function emptyRecord(source) {
17
17
  tools: new Map(),
18
18
  contexts: [],
19
19
  assistantTools: new Map(),
20
+ assistantPages: [],
20
21
  publicRoutes: [],
21
22
  jobs: new Map(),
23
+ starters: new Map(),
22
24
  listeners: {},
23
25
  migrations: [],
24
26
  disposers: [],
@@ -83,6 +85,14 @@ export async function pluginToolsOf(tenantId) {
83
85
  description: tool.description,
84
86
  })));
85
87
  }
88
+ /** The starters the tenant's plugins bring, in the order the plugins registered them. */
89
+ export async function pluginStartersOf(tenantId) {
90
+ return (await pluginsOf(tenantId)).flatMap((plugin) => [...plugin.starters.values()].map((starter) => ({
91
+ id: `${plugin.source.id}.${starter.id}`,
92
+ plugin,
93
+ starter,
94
+ })));
95
+ }
86
96
  /** The built studio half as it is on disk now; null when the plugin has none. */
87
97
  export function pluginAsset(record, kind) {
88
98
  const file = record.source[kind];
@@ -69,6 +69,13 @@ export async function renderDownload(step, output, format, baseName) {
69
69
  ? { data: new Uint8Array(found.data), mime: found.row.mime, filename: file(format) }
70
70
  : null;
71
71
  }
72
+ if (step.type === "surface") {
73
+ return {
74
+ data: JSON.stringify(output.surface ?? {}, null, 2),
75
+ mime: MIME.json,
76
+ filename: file("json"),
77
+ };
78
+ }
72
79
  if (step.type === "widget") {
73
80
  const source = output.assets?.find((a) => a.mime === "text/html");
74
81
  if (format === "json") {
@@ -9,6 +9,7 @@ import { nanoid } from "nanoid";
9
9
  import { z } from "zod";
10
10
  import { principalOf } from "../auth/index.js";
11
11
  import { ConnectError, connectionViews, connectWithCredentials, disconnect, finishOAuth, oauthStateRun, startOAuth, } from "../connectors/index.js";
12
+ import { finishPluginOAuth, pluginStateTenant } from "../connectors/plugin-connections.js";
12
13
  import { db, schema, withTenant } from "../db/client.js";
13
14
  import { answerAsk, pendingAsk } from "../engine/asks.js";
14
15
  import { signalChanged, subscribe } from "../engine/events.js";
@@ -558,8 +559,16 @@ export const runRoutes = new Hono()
558
559
  if (!run || !connection) {
559
560
  return c.json({ error: "not found" }, 404);
560
561
  }
561
- const url = await startOAuth(scopeOf(run), await projectIdOf(run), run.id, connection, c.req.param("connectorId"));
562
- return c.json({ url });
562
+ try {
563
+ const url = await startOAuth(scopeOf(run), await projectIdOf(run), run.id, connection, c.req.param("connectorId"));
564
+ return c.json({ url });
565
+ }
566
+ catch (err) {
567
+ if (err instanceof ConnectError) {
568
+ return c.json({ error: err.message }, 422);
569
+ }
570
+ throw err;
571
+ }
563
572
  })
564
573
  .post("/:id/connections/:connectionId/credentials/:connectorId", async (c) => {
565
574
  const run = await accessibleRun(c, c.req.param("id"));
@@ -780,14 +789,26 @@ function sendDownload(c, download) {
780
789
  */
781
790
  export const connectCallback = new Hono().get("/callback", async (c) => {
782
791
  const code = c.req.query("code");
792
+ // Connected through the account's Manage-App: a ticket instead of a code.
793
+ const ticket = c.req.query("ticket");
783
794
  const state = c.req.query("state");
784
795
  let ok = false;
785
- let message = "Verbinden wurde abgebrochen.";
786
- const tenant = state ? await tenantOfRun(oauthStateRun(state) ?? "") : null;
787
- if (code && state && tenant) {
796
+ let message = c.req.query("error") === "exchange_failed"
797
+ ? "Verbinden hat nicht geklappt. Bitte noch einmal versuchen."
798
+ : "Verbinden wurde abgebrochen.";
799
+ // A plugin's sign-in (server.connections) names its tenant; a wizard's names its run.
800
+ const pluginTenant = state ? pluginStateTenant(state) : null;
801
+ const tenant = state ? (pluginTenant ?? (await tenantOfRun(oauthStateRun(state) ?? ""))) : null;
802
+ const answer = ticket ? { ticket } : code ? { code } : null;
803
+ if (answer && state && tenant) {
788
804
  try {
789
- const done = await withTenant(tenant, () => finishOAuth(code, state));
790
- signalChanged(done.runId);
805
+ if (pluginTenant) {
806
+ await withTenant(tenant, () => finishPluginOAuth(answer, state));
807
+ }
808
+ else {
809
+ const done = await withTenant(tenant, () => finishOAuth(answer, state));
810
+ signalChanged(done.runId);
811
+ }
791
812
  ok = true;
792
813
  message = "Verbunden. Du kannst dieses Fenster schließen.";
793
814
  }
@@ -1,6 +1,6 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { MODEL_CLASSES, TEXT_CLASSES } from "@engenty-wizards/shared/definition";
3
+ import { MODEL_CLASSES, PLUGIN_ID, TEXT_CLASSES } from "@engenty-wizards/shared/definition";
4
4
  import { CATEGORY_TYPES, whereSchema } from "@engenty-wizards/shared/knowledge";
5
5
  import { PROJECT_FILE_KINDS } from "@engenty-wizards/shared/projects";
6
6
  import { isCreditsRef } from "@engenty-wizards/shared/providers";
@@ -405,6 +405,7 @@ export const studio = new Hono()
405
405
  .max(24)
406
406
  .default([]),
407
407
  part: z.enum(["info", "knowledge"]).default("info"),
408
+ plugin: z.string().regex(PLUGIN_ID).optional(),
408
409
  })
409
410
  .parse(await c.req.json());
410
411
  if (!(await canSpend())) {
@@ -420,6 +421,7 @@ export const studio = new Hono()
420
421
  message: body.message,
421
422
  history: body.history,
422
423
  part: body.part,
424
+ plugin: body.plugin,
423
425
  signal: abort.signal,
424
426
  onText: (delta) => {
425
427
  void stream.writeSSE({ event: "text", data: JSON.stringify(delta) });
@@ -1,5 +1,7 @@
1
+ import { createHash } from "node:crypto";
2
+ import { statSync } from "node:fs";
1
3
  import { DEFINITION_VERSION, readable, wizardSchema, } from "@engenty-wizards/shared/definition";
2
- import { MARKETPLACE_LANGS, } from "@engenty-wizards/shared/marketplace";
4
+ import { capabilitiesOf, costTierOf, effortOf, formatsOf, MARKETPLACE_LANGS, } from "@engenty-wizards/shared/marketplace";
3
5
  import { wizardOutline } from "@engenty-wizards/shared/marketplace-entry";
4
6
  import { searchEntries } from "@engenty-wizards/shared/marketplace-search";
5
7
  import { and, eq, inArray, notInArray } from "drizzle-orm";
@@ -9,14 +11,19 @@ import { missingCapabilities, optionalCapabilities, stepsWithoutModel, } from ".
9
11
  import { env } from "../env.js";
10
12
  import { managed } from "../manage.js";
11
13
  import { classCatalog, imageCostUsd, tokenCostUsd, videoCostUsd, WEB_SEARCH_COST_USD, } from "../models.js";
14
+ import { pluginStartersOf } from "../plugins/registry.js";
12
15
  import { readSetting, writeSetting } from "../settings.js";
13
- import { LOCAL_TENANT } from "../tenants/tenant.js";
16
+ import { currentTenant, LOCAL_TENANT } from "../tenants/tenant.js";
14
17
  import { ServiceError } from "./errors.js";
15
18
  /**
16
19
  * The marketplace is an app of its own; this runtime is one of its clients
17
20
  * (docs/marketplace-contract.md). It searches the marketplace live and starts wizards from its
18
21
  * entries. Its starters and what people starred are kept in the control database, so the list
19
22
  * and starting a wizard work without a connection too.
23
+ *
24
+ * Beside them stand the starters the tenant's plugins bring (docs/content/dev/plugins/starters.md),
25
+ * listed first and never fetched. They are known as `<plugin id>.<starter id>`; a marketplace
26
+ * entry's id has no dot, so the two never meet.
20
27
  */
21
28
  export const asLang = (raw) => MARKETPLACE_LANGS.includes(raw ?? "") ? raw : "de";
22
29
  /** A search waits this long for the marketplace; then it searches what is kept here. */
@@ -84,6 +91,69 @@ function keptItem(e, lang) {
84
91
  const text = textIn(e, lang);
85
92
  return text ? { ...keptSummary(e, lang), definition: text.definition, files: e.files } : null;
86
93
  }
94
+ // --- the plugins' starters ------------------------------------------------------------------
95
+ /** One of the starters the tenant's plugins bring, by its id; undefined for any other id. */
96
+ async function pluginStarter(id) {
97
+ if (!id.includes(".")) {
98
+ return undefined;
99
+ }
100
+ return (await pluginStartersOf(currentTenant())).find((s) => s.id === id);
101
+ }
102
+ /** A plugin's starter as the marketplace lists an entry, read from its definition. */
103
+ function pluginSummary({ id, plugin, starter }) {
104
+ const definition = starter.definition;
105
+ const { name, version } = plugin.source.manifest;
106
+ const capabilities = capabilitiesOf(definition);
107
+ const changed = statSync(plugin.source.entry ?? plugin.source.root, { throwIfNoEntry: false });
108
+ return {
109
+ id,
110
+ revision: 0,
111
+ hash: createHash("sha256")
112
+ .update(`${version}\n${JSON.stringify(definition)}`)
113
+ .digest("hex")
114
+ .slice(0, 16),
115
+ updatedAt: (changed?.mtime ?? new Date()).toISOString(),
116
+ starter: true,
117
+ language: starter.language ?? "de",
118
+ title: starter.title,
119
+ pitch: starter.description,
120
+ // Found by the plugin's name as well: "Termine" finds the appointment plugin's starters.
121
+ terms: [name],
122
+ avatar: definition.avatar,
123
+ formats: formatsOf(definition),
124
+ industries: ["any"],
125
+ useCases: [],
126
+ capabilities,
127
+ effort: effortOf(definition),
128
+ costTier: costTierOf(capabilities),
129
+ steps: definition.steps.length,
130
+ version: definition.version,
131
+ };
132
+ }
133
+ /** A plugin's starter as this app shows it: never starred, and marked with its plugin. */
134
+ function pluginEntry(found, unavailable) {
135
+ const { id, name } = found.plugin.source.manifest;
136
+ return {
137
+ ...asEntry(pluginSummary(found), new Set(), unavailable, optionalOf(found.starter.definition)),
138
+ plugin: { id, name },
139
+ };
140
+ }
141
+ /** Two searches' counts per option, added up. */
142
+ function addFacets(a, b) {
143
+ const add = (x = {}, y = {}) => {
144
+ const out = { ...x };
145
+ for (const [key, n] of Object.entries(y)) {
146
+ out[key] = (out[key] ?? 0) + n;
147
+ }
148
+ return out;
149
+ };
150
+ return {
151
+ useCase: add(a.useCase, b.useCase),
152
+ industry: add(a.industry, b.industry),
153
+ format: add(a.format, b.format),
154
+ capability: add(a.capability, b.capability),
155
+ };
156
+ }
87
157
  // --- searching -----------------------------------------------------------------------------
88
158
  /** A summary as this app shows it: whether it can read and run it, and whether it is starred. */
89
159
  function asEntry(summary, starred, unavailable, optional = new Set()) {
@@ -120,8 +190,43 @@ async function keptOptional(ids, lang, unavailable) {
120
190
  }
121
191
  return out;
122
192
  }
123
- /** Searches the marketplace; without an answer, what is kept here, scored the same way. */
193
+ /**
194
+ * Searches the tenant's plugins' starters and the marketplace, in that order: a page holds the
195
+ * plugins' starters the words and filters find, then the marketplace's entries.
196
+ */
124
197
  export async function searchMarketplace(search) {
198
+ const { q = "", starred, limit = 60, offset = 0, lang: _lang, ...filters } = search;
199
+ const own = starred ? [] : await pluginStartersOf(currentTenant());
200
+ if (!own.length) {
201
+ return searchListed(search);
202
+ }
203
+ const byId = new Map(own.map((s) => [s.id, s]));
204
+ const found = searchEntries(own.map(pluginSummary), q, filters, { limit: own.length });
205
+ const shown = found.entries.slice(offset, offset + limit);
206
+ // The marketplace fills what is left of the page, and counts from where its entries begin.
207
+ const room = limit - shown.length;
208
+ const listed = await searchListed({
209
+ ...search,
210
+ limit: Math.max(1, room),
211
+ offset: Math.max(0, offset - found.total),
212
+ });
213
+ const unavailable = new Set(listed.unavailable);
214
+ return {
215
+ ...listed,
216
+ entries: [
217
+ ...shown.flatMap((e) => {
218
+ const entry = byId.get(e.id);
219
+ return entry ? [pluginEntry(entry, unavailable)] : [];
220
+ }),
221
+ ...listed.entries.slice(0, room),
222
+ ],
223
+ total: found.total + listed.total,
224
+ all: own.length + listed.all,
225
+ facets: addFacets(listed.facets, found.facets),
226
+ };
227
+ }
228
+ /** Searches the marketplace; without an answer, what is kept here, scored the same way. */
229
+ async function searchListed(search) {
125
230
  const { q = "", lang, starred: onlyStarred, limit = 60, offset = 0, ...filters } = search;
126
231
  const stars = await starredIds();
127
232
  const starred = new Set(stars);
@@ -217,6 +322,15 @@ async function priced(definition) {
217
322
  }
218
323
  /** An entry in a language with its wizard read against this app; null where there is none. */
219
324
  export async function marketplaceDetail(id, lang) {
325
+ const own = await pluginStarter(id);
326
+ if (own) {
327
+ const definition = own.starter.definition;
328
+ return {
329
+ ...pluginEntry(own, await missingCapabilities()),
330
+ credits: await priced(definition),
331
+ ...wizardOutline(definition, Object.keys(own.starter.files ?? {}), lang),
332
+ };
333
+ }
220
334
  const found = await fetchItem(id, lang);
221
335
  if (!found) {
222
336
  return null;
@@ -242,6 +356,23 @@ export async function marketplaceDetail(id, lang) {
242
356
  }
243
357
  /** An entry's wizard and workspace in a language: what a wizard is made from. */
244
358
  export async function marketplaceWizard(id, lang) {
359
+ const own = await pluginStarter(id);
360
+ if (own) {
361
+ // A plugin's starter is in its one language, whatever is asked for.
362
+ const { starter, plugin } = own;
363
+ return {
364
+ id,
365
+ revision: null,
366
+ plugin: plugin.source.id,
367
+ title: starter.title,
368
+ language: starter.language ?? "de",
369
+ definition: structuredClone(starter.definition),
370
+ files: Object.fromEntries(Object.entries(starter.files ?? {}).map(([path, data]) => [
371
+ path,
372
+ typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data),
373
+ ])),
374
+ };
375
+ }
245
376
  const found = await fetchItem(id, lang);
246
377
  if (!found) {
247
378
  return null;
@@ -1,32 +1,27 @@
1
- import { systemOneAccess } from "../models.js";
1
+ import { decide } from "../engine/decide.js";
2
2
  /**
3
3
  * The last step of a search of Wissen: does each candidate the index found help answer the
4
- * question? TypeSafe's Jev answers one yes/no question per candidate, all in one call and each
5
- * on its own, with P(yes) and no text. What falls under MIN_RELEVANCE goes; the rest is ranked
6
- * by it. As engenty-pro's knowledge base checks its hits.
4
+ * question? One yes/no question per candidate, all in one decision: a decision model (Jev)
5
+ * answers each on its own with P(yes); without one the classifier class's language model
6
+ * answers yes or no. What falls under MIN_RELEVANCE goes; the rest is ranked by it. As
7
+ * engenty-pro's knowledge base checks its hits.
7
8
  */
8
9
  export const MIN_RELEVANCE = 0.5;
9
10
  /** A search waits for a person: a classifier that cannot answer in this time is left out. */
10
11
  const TIMEOUT_MS = 4000;
11
- const ATTEMPTS = 2;
12
12
  const TEXT_CHARS = 1200;
13
13
  const clip = (value, max) => {
14
14
  const s = value.replace(/\s+/g, " ").trim();
15
15
  return s.length > max ? `${s.slice(0, max - 1)}…` : s;
16
16
  };
17
17
  /**
18
- * P(relevant) for each candidate, in their order; null for one the answer left out. Null for
19
- * all of them when no classifier is set up or it did not answer in time: the search then keeps
20
- * the order the index gave.
18
+ * P(relevant) for each candidate, in their order. Null for all of them when no way decided in
19
+ * time: the search then keeps the order the index gave.
21
20
  */
22
21
  export async function judgeRelevance(query, candidates, signal) {
23
22
  if (!candidates.length) {
24
23
  return [];
25
24
  }
26
- const access = await systemOneAccess().catch(() => null);
27
- if (!access) {
28
- return null;
29
- }
30
25
  const questions = {};
31
26
  const state = {
32
27
  query: clip(query, 600),
@@ -49,42 +44,15 @@ export async function judgeRelevance(query, candidates, signal) {
49
44
  };
50
45
  }),
51
46
  };
52
- const body = JSON.stringify({
53
- ...(access.model ? { model: access.model } : {}),
54
- state,
55
- questions,
56
- });
57
- for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
58
- try {
59
- const res = await fetch(access.url, {
60
- method: "POST",
61
- headers: { ...access.headers, "content-type": "application/json" },
62
- body,
63
- signal: signal
64
- ? AbortSignal.any([signal, AbortSignal.timeout(TIMEOUT_MS)])
65
- : AbortSignal.timeout(TIMEOUT_MS),
66
- });
67
- if (!res.ok) {
68
- if ([429, 503, 529].includes(res.status) && attempt < ATTEMPTS) {
69
- continue;
70
- }
71
- console.warn(`[relevance] the classifier answered ${res.status}`);
72
- return null;
73
- }
74
- const answer = (await res.json());
75
- return candidates.map((_, index) => {
76
- const a = answer.answers?.[`c${index}`];
77
- return a?.type === "noul" && typeof a.noul === "number" && Number.isFinite(a.noul)
78
- ? Math.round(a.noul * 1000) / 1000
79
- : null;
80
- });
81
- }
82
- catch (err) {
83
- if (signal?.aborted || attempt >= ATTEMPTS) {
84
- console.warn("[relevance] the classifier did not answer:", err.message);
85
- return null;
86
- }
87
- }
47
+ try {
48
+ const decision = await decide({ state, questions, signal, deadlineMs: TIMEOUT_MS });
49
+ return candidates.map((_, index) => {
50
+ const a = decision.answers[`c${index}`];
51
+ return a?.type === "noul" ? Math.round(a.noul * 1000) / 1000 : null;
52
+ });
53
+ }
54
+ catch (err) {
55
+ console.warn("[relevance] no decision:", err.message);
56
+ return null;
88
57
  }
89
- return null;
90
58
  }
@@ -1,4 +1,4 @@
1
- import { formatsFor } from "@engenty-wizards/shared/definition";
1
+ import { formatsFor, optionsFromData, slotText, wizardLang, } from "@engenty-wizards/shared/definition";
2
2
  import { and, count, desc, eq, gte, inArray, max, or, sql } from "drizzle-orm";
3
3
  import { canSpend, MICROS_PER_CREDIT } from "../credits/credits.js";
4
4
  import { db, schema } from "../db/client.js";
@@ -159,7 +159,31 @@ const CHAT_KINDS = new Set([
159
159
  "color",
160
160
  "items",
161
161
  "location",
162
+ "slot",
162
163
  ]);
164
+ /** A field's choices in this run: what its `optionsFrom` finds in earlier steps, else the fixed ones. */
165
+ function choicesOf(run, field) {
166
+ const found = field.optionsFrom ? optionsFromData(field.optionsFrom, run.state.outputs) : [];
167
+ return found.length ? found : field.options;
168
+ }
169
+ /**
170
+ * What a client needs besides the plain field: a slot's times as the person reads them, and the
171
+ * one value of them to send back.
172
+ */
173
+ function slotHints(run, field, options) {
174
+ if (field.kind !== "slot") {
175
+ return {};
176
+ }
177
+ const lang = wizardLang(run.definition);
178
+ const value = run.state.values[field.id];
179
+ return {
180
+ times: (options ?? []).map((o) => ({ value: o, label: slotText(o, lang) })),
181
+ ...(typeof value === "string" ? { valueText: slotText(value, lang) } : {}),
182
+ hint: options?.length
183
+ ? "One appointment time: show the person the labels, answer with the value of the one they pick, exactly as given."
184
+ : "No times are free right now: tell the person.",
185
+ };
186
+ }
163
187
  /** Where the person can go on in a browser: the run page of a live run, the studio for a test. */
164
188
  /** The live run's page — the wizard itself, never the studio. A test run has none. */
165
189
  async function browserUrl(run) {
@@ -197,20 +221,24 @@ function waitingFor(run, current) {
197
221
  return {
198
222
  page: current.id,
199
223
  title: current.title,
200
- fields: current.fields.map((f) => ({
201
- id: f.id,
202
- label: f.label,
203
- kind: f.kind,
204
- required: Boolean(f.required),
205
- options: f.options,
206
- placeholder: f.placeholder,
207
- help: f.help,
208
- default: f.default,
209
- value: run.state.values[f.id],
210
- columns: f.columns,
211
- // A file, a recording or a signature comes from the person's device: the run page.
212
- inChat: CHAT_KINDS.has(f.kind),
213
- })),
224
+ fields: current.fields.map((f) => {
225
+ const options = choicesOf(run, f);
226
+ return {
227
+ id: f.id,
228
+ label: f.label,
229
+ kind: f.kind,
230
+ required: Boolean(f.required),
231
+ options,
232
+ placeholder: f.placeholder,
233
+ help: f.help,
234
+ default: f.default,
235
+ value: run.state.values[f.id],
236
+ columns: f.columns,
237
+ // A file, a recording or a signature comes from the person's device: the run page.
238
+ inChat: CHAT_KINDS.has(f.kind),
239
+ ...slotHints(run, f, options),
240
+ };
241
+ }),
214
242
  };
215
243
  }
216
244
  if (current.type === "review") {