wizards 0.2.7 → 0.2.9

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 (97) hide show
  1. package/apps/runtime/dist/agents/project-assistant.js +16 -2
  2. package/apps/runtime/dist/agents/website.js +48 -10
  3. package/apps/runtime/dist/app.js +2 -0
  4. package/apps/runtime/dist/db/control-schema.js +18 -1
  5. package/apps/runtime/dist/db/migrations/control/0007_common_the_hand.sql +15 -0
  6. package/apps/runtime/dist/db/migrations/control/meta/0007_snapshot.json +460 -0
  7. package/apps/runtime/dist/db/migrations/control/meta/_journal.json +7 -0
  8. package/apps/runtime/dist/db/migrations/tenant/0006_hard_war_machine.sql +30 -0
  9. package/apps/runtime/dist/db/migrations/tenant/meta/0006_snapshot.json +1696 -0
  10. package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
  11. package/apps/runtime/dist/db/schema.js +12 -6
  12. package/apps/runtime/dist/engine/steps.js +3 -0
  13. package/apps/runtime/dist/env.js +6 -0
  14. package/apps/runtime/dist/index.js +2 -0
  15. package/apps/runtime/dist/media/generate.js +2 -2
  16. package/apps/runtime/dist/media/transcribe.js +18 -6
  17. package/apps/runtime/dist/models.js +566 -218
  18. package/apps/runtime/dist/plugins/events.js +37 -14
  19. package/apps/runtime/dist/plugins/jobs.js +64 -0
  20. package/apps/runtime/dist/plugins/loader.js +67 -4
  21. package/apps/runtime/dist/plugins/public.js +31 -0
  22. package/apps/runtime/dist/plugins/registry.js +5 -0
  23. package/apps/runtime/dist/routes/plugin-public.js +59 -0
  24. package/apps/runtime/dist/routes/plugins.js +1 -1
  25. package/apps/runtime/dist/routes/studio.js +155 -10
  26. package/apps/runtime/dist/services/project-files.js +4 -0
  27. package/apps/runtime/dist/services/project-index.js +69 -9
  28. package/apps/runtime/dist/services/projects.js +7 -1
  29. package/apps/runtime/dist/services/spaces.js +2 -0
  30. package/apps/runtime/dist/tools/index.js +3 -2
  31. package/apps/runtime/dist/tools/plugin.js +89 -17
  32. package/apps/runtime/dist/tools/project.js +39 -31
  33. package/apps/web/dist/assets/Account-ZAqYVN9S.js +1 -0
  34. package/apps/web/dist/assets/{ChangelogDialog-DEfZRDK3.js → ChangelogDialog-CzdNuUMU.js} +1 -1
  35. package/apps/web/dist/assets/{ChatPanel-CNiFyu_w.js → ChatPanel-CDkHMh5g.js} +1 -1
  36. package/apps/web/dist/assets/{CreditsDialog-CUvc2iPM.js → CreditsDialog-DSjsgLoo.js} +1 -1
  37. package/apps/web/dist/assets/EditorPage-D4mertxP.js +2 -0
  38. package/apps/web/dist/assets/{FlowPreview-BC25OUH0.js → FlowPreview-DWYKllel.js} +1 -1
  39. package/apps/web/dist/assets/Harness-WJ0jpIl3.js +16 -0
  40. package/apps/web/dist/assets/HomePage-2Zn7et7Z.js +1 -0
  41. package/apps/web/dist/assets/ImportWizard-Dn7AiRgV.js +1 -0
  42. package/apps/web/dist/assets/LocalRuntime-BggRFDJt.js +1 -0
  43. package/apps/web/dist/assets/NewWizardPage-k-xPHAwe.js +2 -0
  44. package/apps/web/dist/assets/ProjectPage-CZgR1dQj.js +2 -0
  45. package/apps/web/dist/assets/{PublicRunner-BCrmXPkr.js → PublicRunner-BsvhBQMD.js} +1 -1
  46. package/apps/web/dist/assets/{ReadOnly-CvmN9XW4.js → ReadOnly-B0-ReU_0.js} +1 -1
  47. package/apps/web/dist/assets/RunnerView-0srHaE2T.js +2 -0
  48. package/apps/web/dist/assets/SettingsPage-CNsK0n6L.js +1 -0
  49. package/apps/web/dist/assets/SetupPage-BXQ6wfWP.js +1 -0
  50. package/apps/web/dist/assets/{SharePage-BqwqI3Ga.js → SharePage-CA8uuk9K.js} +1 -1
  51. package/apps/web/dist/assets/{api-Bf-l9Sd8.js → api-u3nydnnp.js} +1 -1
  52. package/apps/web/dist/assets/changelog-D7O5icPX.js +1 -0
  53. package/apps/web/dist/assets/circle-alert-B76z9TWZ.js +1 -0
  54. package/apps/web/dist/assets/definition-Co2Mg1qo.js +55 -0
  55. package/apps/web/dist/assets/esm-BbARZ7EE.js +7 -0
  56. package/apps/web/dist/assets/image-BShbErBm.js +1 -0
  57. package/apps/web/dist/assets/index-BQQihL4r.js +10 -0
  58. package/apps/web/dist/assets/index-CQQD8QNR.css +1 -0
  59. package/apps/web/dist/assets/meta-Dsb9LZTu.js +1 -0
  60. package/apps/web/dist/assets/oss-credits-CIxnNZor.js +1 -0
  61. package/apps/web/dist/assets/{outputs-CsESg-Eg.js → outputs-C806GWxt.js} +1 -1
  62. package/apps/web/dist/assets/{video-CjgmvRDk.js → pen-line-NK46C_nG.js} +1 -1
  63. package/apps/web/dist/assets/refresh-cw-Cmk7VCe0.js +1 -0
  64. package/apps/web/dist/assets/ui-gGAdXx-P.js +1 -0
  65. package/apps/web/dist/assets/video-DBmwF1Jm.js +1 -0
  66. package/apps/web/dist/assets/volume-2-Bp2tyaKE.js +1 -0
  67. package/apps/web/dist/index.html +4 -7
  68. package/apps/web/dist/mcp/flow.html +2 -2
  69. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +183 -0
  70. package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +48 -3
  71. package/node_modules/@engenty-wizards/shared/dist/projects.d.ts +7 -1
  72. package/node_modules/@engenty-wizards/shared/dist/providers.d.ts +75 -0
  73. package/node_modules/@engenty-wizards/shared/dist/providers.js +339 -0
  74. package/package.json +5 -1
  75. package/apps/web/dist/assets/Account-DLLeEwJL.js +0 -1
  76. package/apps/web/dist/assets/EditorPage-CZuCz7lB.js +0 -2
  77. package/apps/web/dist/assets/HomePage-DQKuhMFZ.js +0 -1
  78. package/apps/web/dist/assets/ImportWizard-BockXJXm.js +0 -1
  79. package/apps/web/dist/assets/LocalRuntime-C199bg_B.js +0 -16
  80. package/apps/web/dist/assets/NewWizardPage-C4cde8FO.js +0 -2
  81. package/apps/web/dist/assets/ProjectPage-DJC1K4w_.js +0 -2
  82. package/apps/web/dist/assets/RunnerView-CCe8-k9Q.js +0 -2
  83. package/apps/web/dist/assets/SettingsPage-BuocMkLt.js +0 -1
  84. package/apps/web/dist/assets/SetupPage-o6Jim2Pu.js +0 -1
  85. package/apps/web/dist/assets/changelog-CFeEzKqb.js +0 -1
  86. package/apps/web/dist/assets/cloud-off-D--dZLDw.js +0 -1
  87. package/apps/web/dist/assets/definition-DndaH5Ow.js +0 -55
  88. package/apps/web/dist/assets/esm-rJY59Ixx.js +0 -7
  89. package/apps/web/dist/assets/globe-ffVj8XwO.js +0 -1
  90. package/apps/web/dist/assets/index-B8n2AYO0.css +0 -1
  91. package/apps/web/dist/assets/index-BR5ELbeZ.js +0 -10
  92. package/apps/web/dist/assets/meta-CKE9QceT.js +0 -1
  93. package/apps/web/dist/assets/oss-credits-C-U2Tlpn.js +0 -1
  94. package/apps/web/dist/assets/react-dom-DenslgJm.js +0 -1
  95. package/apps/web/dist/assets/session-C7LDDcj7.js +0 -1
  96. package/apps/web/dist/assets/ui-BT0Y_Hci.js +0 -1
  97. /package/apps/web/dist/assets/{LocalRuntime-BrP-ENHg.css → Harness-BrP-ENHg.css} +0 -0
@@ -3,30 +3,24 @@ import { db, schema } from "../db/client.js";
3
3
  import { currentTenant } from "../tenants/tenant.js";
4
4
  import { pluginsOf } from "./registry.js";
5
5
  /**
6
- * Tells the tenant's plugins that a run reached its end. A plugin cannot fail a run: what a
7
- * listener throws is logged and nothing more.
6
+ * Tells the current tenant's plugins what happened. A plugin cannot fail what the runtime does:
7
+ * what a listener throws is logged and nothing more.
8
8
  */
9
- export async function runEnded(runId, status) {
9
+ export async function emitPluginEvent(event, payload) {
10
10
  try {
11
- const event = `run.${status}`;
12
11
  const listening = (await pluginsOf(currentTenant())).filter((p) => p.listeners[event]?.length);
13
12
  if (!listening.length) {
14
13
  return;
15
14
  }
16
- const run = await db.query.run.findFirst({ where: eq(schema.run.id, runId) });
17
- const wizard = run && (await db.query.wizard.findFirst({ where: eq(schema.wizard.id, run.wizardId) }));
18
- if (!run || !wizard) {
15
+ // What is costly to gather is gathered only when someone listens.
16
+ const told = typeof payload === "function" ? await payload() : payload;
17
+ if (!told) {
19
18
  return;
20
19
  }
21
- const payload = {
22
- run: { id: run.id, wizardId: run.wizardId, mode: run.mode, status },
23
- wizard: { id: wizard.id, title: run.definition.title, projectId: wizard.projectId },
24
- values: run.state.values,
25
- };
26
20
  for (const plugin of listening) {
27
21
  for (const listener of plugin.listeners[event] ?? []) {
28
22
  try {
29
- await listener(payload);
23
+ await listener(told);
30
24
  }
31
25
  catch (err) {
32
26
  console.error(`[plugin ${plugin.source.id}] ${event}:`, err);
@@ -35,6 +29,35 @@ export async function runEnded(runId, status) {
35
29
  }
36
30
  }
37
31
  catch (err) {
38
- console.error("[plugins] run event:", err);
32
+ console.error(`[plugins] ${event}:`, err);
39
33
  }
40
34
  }
35
+ /** A run reached its end. */
36
+ export function runEnded(runId, status) {
37
+ return emitPluginEvent(`run.${status}`, async () => {
38
+ const run = await db.query.run.findFirst({ where: eq(schema.run.id, runId) });
39
+ const wizard = run && (await db.query.wizard.findFirst({ where: eq(schema.wizard.id, run.wizardId) }));
40
+ if (!run || !wizard) {
41
+ return null;
42
+ }
43
+ return {
44
+ run: { id: run.id, wizardId: run.wizardId, mode: run.mode, status },
45
+ wizard: { id: wizard.id, title: run.definition.title, projectId: wizard.projectId },
46
+ values: run.state.values,
47
+ };
48
+ });
49
+ }
50
+ /**
51
+ * A space was made, changed or deleted. Not waited for: the request that changed the space
52
+ * answers without its plugins.
53
+ */
54
+ export function spaceChanged(event, spaceId) {
55
+ void emitPluginEvent(event, { space: { id: spaceId } });
56
+ }
57
+ /** A file of a space was read and is ready, or was removed. Not waited for either. */
58
+ export function spaceFileChanged(event, file) {
59
+ void emitPluginEvent(event, {
60
+ space: { id: file.projectId },
61
+ file: { id: file.id, name: file.name, kind: file.kind, mime: file.mime },
62
+ });
63
+ }
@@ -0,0 +1,64 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import { control, controlDb, withTenant } from "../db/client.js";
3
+ import { env } from "../env.js";
4
+ import { loadedPlugins, pluginIdsOf } from "./registry.js";
5
+ /**
6
+ * Plugins' jobs (`server.every`). A tick looks once a minute which job is due for which tenant
7
+ * and runs it inside that tenant. When a job ran is kept in the control database, so a restart
8
+ * does not start the count again; a runtime that was off runs a due job once when it starts,
9
+ * not once per turn it missed.
10
+ */
11
+ /** Jobs that run right now, by tenant, plugin and name: one turn at a time each. */
12
+ const running = new Set();
13
+ /** Starts every job that is due, and is done when they are. */
14
+ export async function runDueJobs(now = new Date()) {
15
+ const jobs = loadedPlugins().flatMap((plugin) => [...plugin.jobs].map(([name, job]) => ({ plugin, name, job })));
16
+ if (!jobs.length) {
17
+ return;
18
+ }
19
+ const tenants = await controlDb.query.tenant.findMany({
20
+ where: eq(control.tenant.status, "active"),
21
+ });
22
+ const started = [];
23
+ for (const tenant of tenants) {
24
+ const has = await pluginIdsOf(tenant.id);
25
+ for (const { plugin, name, job } of jobs) {
26
+ const id = plugin.source.id;
27
+ const key = `${tenant.id}/${id}/${name}`;
28
+ if (!has.has(id) || running.has(key)) {
29
+ continue;
30
+ }
31
+ const where = and(eq(control.pluginJob.tenantId, tenant.id), eq(control.pluginJob.plugin, id), eq(control.pluginJob.name, name));
32
+ const last = (await controlDb.query.pluginJob.findFirst({ where }))?.lastRunAt ?? null;
33
+ if (last && now.getTime() - last.getTime() < job.everyMs) {
34
+ continue;
35
+ }
36
+ running.add(key);
37
+ // The start counts as the run: a turn that throws or hangs is not repeated every minute.
38
+ await controlDb
39
+ .insert(control.pluginJob)
40
+ .values({ tenantId: tenant.id, plugin: id, name, lastRunAt: now })
41
+ .onConflictDoUpdate({
42
+ target: [control.pluginJob.tenantId, control.pluginJob.plugin, control.pluginJob.name],
43
+ set: { lastRunAt: now },
44
+ });
45
+ started.push(withTenant(tenant.id, () => job.handler({ tenantId: tenant.id, lastRun: last, signal: plugin.stopped.signal }))
46
+ .catch((err) => console.error(`[plugin ${id}] job ${name}:`, err))
47
+ .finally(() => running.delete(key)));
48
+ }
49
+ }
50
+ await Promise.all(started);
51
+ }
52
+ const TICK_MS = 60_000;
53
+ let timer = null;
54
+ /** Looks for due jobs now and then once a minute, while the runtime runs. */
55
+ export function startJobs() {
56
+ if (timer || !env.plugins.jobs) {
57
+ return;
58
+ }
59
+ const tick = () => void runDueJobs().catch((err) => console.error("[plugins] jobs:", err));
60
+ // Soon after the start, not during it: tenant databases open first.
61
+ setTimeout(tick, 5_000).unref();
62
+ timer = setInterval(tick, TICK_MS);
63
+ timer.unref();
64
+ }
@@ -4,12 +4,17 @@ import { fileURLToPath } from "node:url";
4
4
  import { PLUGIN_TOOL_NAME } from "@engenty-wizards/shared/definition";
5
5
  import { generateText, Output } from "ai";
6
6
  import { createJiti } from "jiti";
7
+ import { readPage } from "../agents/website.js";
7
8
  import { packageRoot } from "../cli/home.js";
8
9
  import { db, onTenantOpen } from "../db/client.js";
10
+ import { parseDocument } from "../documents/parse.js";
9
11
  import { env } from "../env.js";
10
12
  import { ModelUnavailableError, textModel } from "../models.js";
13
+ import { putIndexEntry, removeIndexEntries } from "../services/project-index.js";
14
+ import { safeFetch } from "../tools/net-guard.js";
11
15
  import { discoverPlugins } from "./discovery.js";
12
16
  import { migrateOpenTenants, migratePlugins } from "./migrations.js";
17
+ import { publicUrlOf } from "./public.js";
13
18
  import { announceChange, compileRoute, dropPlugin, emptyRecord, loadedPlugin, loadedPlugins, setPlugin, } from "./registry.js";
14
19
  /**
15
20
  * Loading plugins (docs/content/dev/plugins). A plugin's server half is TypeScript or JavaScript read
@@ -40,9 +45,21 @@ async function importFactory(entry) {
40
45
  }
41
46
  return factory;
42
47
  }
48
+ /** A job runs at most this often. */
49
+ const MIN_EVERY_MS = 60_000;
43
50
  function apiFor(record) {
44
51
  const { source } = record;
45
52
  const tag = `[plugin ${source.id}]`;
53
+ /** The names a step's model sees: the plugin's step tools and its context tools share them. */
54
+ const stepToolNames = () => [
55
+ ...record.tools.keys(),
56
+ ...record.contexts.flatMap((c) => (c.tools ?? []).map((t) => t.name)),
57
+ ];
58
+ const named = (kind, name) => {
59
+ if (!PLUGIN_TOOL_NAME.test(name)) {
60
+ throw new Error(`${kind} "${name}": a name is lower case, digits and "_".`);
61
+ }
62
+ };
46
63
  return {
47
64
  plugin: source.manifest,
48
65
  resolvePath: (relative) => resolve(source.root, relative),
@@ -57,14 +74,54 @@ function apiFor(record) {
57
74
  record.routes.push(compileRoute(route));
58
75
  },
59
76
  registerTool(tool) {
60
- if (!PLUGIN_TOOL_NAME.test(tool.name)) {
61
- throw new Error(`Tool "${tool.name}": a name is lower case, digits and "_".`);
62
- }
63
- if (record.tools.has(tool.name)) {
77
+ named("Tool", tool.name);
78
+ if (stepToolNames().includes(tool.name)) {
64
79
  throw new Error(`Tool "${tool.name}" is registered twice.`);
65
80
  }
66
81
  record.tools.set(tool.name, tool);
67
82
  },
83
+ registerSpaceContext(context) {
84
+ const names = (context.tools ?? []).map((t) => t.name);
85
+ for (const name of names) {
86
+ named("Tool", name);
87
+ if (stepToolNames().includes(name) || names.indexOf(name) !== names.lastIndexOf(name)) {
88
+ throw new Error(`Tool "${name}" is registered twice.`);
89
+ }
90
+ }
91
+ record.contexts.push(context);
92
+ },
93
+ registerAssistantTool(tool) {
94
+ named("Assistant tool", tool.name);
95
+ if (record.assistantTools.has(tool.name)) {
96
+ throw new Error(`Assistant tool "${tool.name}" is registered twice.`);
97
+ }
98
+ record.assistantTools.set(tool.name, tool);
99
+ },
100
+ registerPublicRoute(route) {
101
+ record.publicRoutes.push(compileRoute(route));
102
+ },
103
+ publicUrl: (path) => publicUrlOf(source.id, path),
104
+ every(name, everyMs, handler) {
105
+ named("Job", name);
106
+ if (record.jobs.has(name)) {
107
+ throw new Error(`Job "${name}" is registered twice.`);
108
+ }
109
+ record.jobs.set(name, { everyMs: Math.max(MIN_EVERY_MS, everyMs), handler });
110
+ },
111
+ index: {
112
+ put: (entry) => putIndexEntry(source.id, entry),
113
+ remove: (space, key) => removeIndexEntries(source.id, space, key),
114
+ },
115
+ web: {
116
+ fetch: (url, init) => safeFetch(url, init),
117
+ read: (url, options) => readPage(url, options?.signal),
118
+ },
119
+ documents: {
120
+ async parse(file, options) {
121
+ const parsed = await parseDocument(file, { signal: options?.signal });
122
+ return { markdown: parsed.markdown, pages: parsed.pages };
123
+ },
124
+ },
68
125
  registerMigrations(folder) {
69
126
  record.migrations.push(resolve(source.root, folder));
70
127
  },
@@ -102,6 +159,8 @@ function apiFor(record) {
102
159
  }
103
160
  /** Runs what the plugin asked to have undone, the last first. */
104
161
  async function dispose(record) {
162
+ // Its jobs are told to stop; the runtime stops starting them with the record gone.
163
+ record.stopped.abort();
105
164
  for (const undo of record.disposers.splice(0).reverse()) {
106
165
  try {
107
166
  await undo();
@@ -123,6 +182,10 @@ async function load(source) {
123
182
  await dispose(record);
124
183
  record.routes = [];
125
184
  record.tools.clear();
185
+ record.contexts = [];
186
+ record.assistantTools.clear();
187
+ record.publicRoutes = [];
188
+ record.jobs.clear();
126
189
  record.listeners = {};
127
190
  record.migrations = [];
128
191
  record.error = err?.message || String(err);
@@ -0,0 +1,31 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import { nanoid } from "nanoid";
3
+ import { control, controlDb } from "../db/client.js";
4
+ import { env } from "../env.js";
5
+ import { currentTenant } from "../tenants/tenant.js";
6
+ /**
7
+ * Public addresses of plugins (`server.publicUrl`): `/api/public/plugins/<plugin>/<ref>/…`. The
8
+ * ref names the tenant without giving its id away; one per tenant and plugin, kept for good.
9
+ */
10
+ /** The full address of a plugin's public route for the current tenant. */
11
+ export async function publicUrlOf(plugin, path) {
12
+ const tenantId = currentTenant();
13
+ const where = and(eq(control.pluginAddress.tenantId, tenantId), eq(control.pluginAddress.plugin, plugin));
14
+ let row = await controlDb.query.pluginAddress.findFirst({ where });
15
+ if (!row) {
16
+ await controlDb
17
+ .insert(control.pluginAddress)
18
+ .values({ ref: nanoid(24), tenantId, plugin })
19
+ .onConflictDoNothing();
20
+ row = (await controlDb.query.pluginAddress.findFirst({ where }));
21
+ }
22
+ const rest = path.startsWith("/") ? path : `/${path}`;
23
+ return `${env.appUrl}/api/public/plugins/${plugin}/${row.ref}${rest === "/" ? "" : rest}`;
24
+ }
25
+ /** The tenant a plugin's public address belongs to. */
26
+ export async function tenantOfAddress(plugin, ref) {
27
+ const row = await controlDb.query.pluginAddress.findFirst({
28
+ where: eq(control.pluginAddress.ref, ref),
29
+ });
30
+ return row?.plugin === plugin ? row.tenantId : null;
31
+ }
@@ -15,9 +15,14 @@ export function emptyRecord(source) {
15
15
  error: null,
16
16
  routes: [],
17
17
  tools: new Map(),
18
+ contexts: [],
19
+ assistantTools: new Map(),
20
+ publicRoutes: [],
21
+ jobs: new Map(),
18
22
  listeners: {},
19
23
  migrations: [],
20
24
  disposers: [],
25
+ stopped: new AbortController(),
21
26
  };
22
27
  }
23
28
  export function setPlugin(record) {
@@ -0,0 +1,59 @@
1
+ import { Hono } from "hono";
2
+ import { withTenant } from "../db/client.js";
3
+ import { tenantOfAddress } from "../plugins/public.js";
4
+ import { loadedPlugin, pluginIdsOf } from "../plugins/registry.js";
5
+ import { tenantStatus } from "../tenants/control.js";
6
+ import { answerOf } from "./plugins.js";
7
+ /**
8
+ * Plugins' public routes (`server.registerPublicRoute`), below `/api/public/plugins/<id>/<ref>`.
9
+ * Nobody is signed in: the ref names the tenant, and the plugin checks a secret of its own.
10
+ */
11
+ const notFound = (c) => c.json({ error: "not found", code: "not_found" }, 404);
12
+ async function dispatch(c) {
13
+ const id = c.req.param("id") ?? "";
14
+ const ref = c.req.param("ref") ?? "";
15
+ const tenantId = await tenantOfAddress(id, ref);
16
+ if (!tenantId || (await tenantStatus(tenantId)) !== "active") {
17
+ return notFound(c);
18
+ }
19
+ const plugin = (await pluginIdsOf(tenantId)).has(id) ? loadedPlugin(id) : undefined;
20
+ if (!plugin) {
21
+ return notFound(c);
22
+ }
23
+ const url = new URL(c.req.url);
24
+ const marker = `/plugins/${id}/${ref}`;
25
+ const path = url.pathname.slice(url.pathname.indexOf(marker) + marker.length);
26
+ for (const { route, pattern, params } of plugin.publicRoutes) {
27
+ const match = route.method === c.req.method ? pattern.exec(path) : null;
28
+ if (!match) {
29
+ continue;
30
+ }
31
+ const request = {
32
+ request: c.req.raw,
33
+ path: path || "/",
34
+ params: Object.fromEntries(params.map((name, i) => [name, decodeURIComponent(match[i + 1])])),
35
+ query: url.searchParams,
36
+ tenantId,
37
+ json: async () => {
38
+ const text = await c.req.text();
39
+ return (text ? JSON.parse(text) : {});
40
+ },
41
+ };
42
+ try {
43
+ const result = await withTenant(tenantId, () => route.handler(request));
44
+ return result instanceof Response ? result : c.json(result ?? null);
45
+ }
46
+ catch (err) {
47
+ const answer = answerOf(err);
48
+ if (answer) {
49
+ return c.json(answer.body, answer.status);
50
+ }
51
+ console.error(`[plugin ${id}] public ${route.method} ${path}:`, err);
52
+ return c.json({ error: "The plugin failed.", code: "plugin_failed" }, 500);
53
+ }
54
+ }
55
+ return notFound(c);
56
+ }
57
+ export const publicPluginRoutes = new Hono()
58
+ .all("/:id/:ref", dispatch)
59
+ .all("/:id/:ref/*", dispatch);
@@ -29,7 +29,7 @@ function view(plugin) {
29
29
  };
30
30
  }
31
31
  /** A thrown error that says how to answer: `{ status, code }`, as the runtime's own errors do. */
32
- function answerOf(err) {
32
+ export function answerOf(err) {
33
33
  const e = err;
34
34
  return typeof e?.status === "number" &&
35
35
  e.status >= 400 &&
@@ -2,6 +2,7 @@ import { mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { MODEL_CLASSES, TEXT_CLASSES } from "@engenty-wizards/shared/definition";
4
4
  import { PROJECT_FILE_KINDS } from "@engenty-wizards/shared/projects";
5
+ import { isCreditsRef } from "@engenty-wizards/shared/providers";
5
6
  import { generateText } from "ai";
6
7
  import { Hono } from "hono";
7
8
  import { streamSSE } from "hono/streaming";
@@ -22,8 +23,9 @@ import { detectHarness, detectHarnesses, HARNESS_IDS, harness, installSpec, } fr
22
23
  import { closeTerminal, openTerminal, resizeTerminal, subscribeTerminal, writeTerminal, } from "../harness/terminal.js";
23
24
  import { managed } from "../manage.js";
24
25
  import { seenApps } from "../mcp/seen.js";
26
+ import { generateImageMedia, generateSpeechMedia, generateVideoMedia } from "../media/generate.js";
25
27
  import { transcribeAudio } from "../media/transcribe.js";
26
- import { embeddingModel, hasTextModel, LOCAL_KEYS, localModelSettings, ModelUnavailableError, saveLocalModels, textModel, } from "../models.js";
28
+ import { checkKey, classProblem, embeddingModel, hasTextModel, LOCAL_KEYS, localModelSettings, ModelUnavailableError, modelSettings, saveLocalModels, saveModels, storedKey, textModel, } from "../models.js";
27
29
  import { HTML_RESPONSE_CSP } from "../render/guard.js";
28
30
  import { mayBuild, projectWritable, requireWritable } from "../services/access.js";
29
31
  import { architectTurn } from "../services/architect.js";
@@ -60,6 +62,113 @@ async function setupDone() {
60
62
  }
61
63
  return hasTextModel();
62
64
  }
65
+ const modelsInput = z.object({
66
+ source: z.enum([...HARNESS_IDS, "account", "own"]).optional(),
67
+ bindings: z.partialRecord(z.enum(MODEL_CLASSES), z.string().max(160)).optional(),
68
+ ollamaUrl: z.string().max(200).optional(),
69
+ creditFallback: z.boolean().optional(),
70
+ keys: z.partialRecord(z.enum(LOCAL_KEYS), z.string().max(400)).optional(),
71
+ });
72
+ /** Alone the person changes everything; in a team its owner and admins. */
73
+ function mayChangeModels(user) {
74
+ return !managed || user.role === "owner" || user.role === "admin";
75
+ }
76
+ /** A binding the cloud can run: a provider's model or the credits, no client, no local model. */
77
+ function cloudBinding(ref) {
78
+ return (isCreditsRef(ref) || /^(openai|anthropic|google|fal|elevenlabs|replicate|gateway):/.test(ref));
79
+ }
80
+ const TEST_TEXT = {
81
+ de: {
82
+ image: "Ein kleiner, freundlicher Zauberhut aus orangem Filz auf einem Holztisch, weiches Licht, flache Illustration.",
83
+ speech: "Hallo! So klingt die Stimme deiner Wizards.",
84
+ video: "Ein orangefarbener Zauberhut dreht sich langsam auf einem Holztisch, weiches Licht.",
85
+ },
86
+ en: {
87
+ image: "A small, friendly wizard hat of orange felt on a wooden table, soft light, flat illustration.",
88
+ speech: "Hello! This is how your wizards sound.",
89
+ video: "An orange wizard hat slowly turns on a wooden table, soft light.",
90
+ },
91
+ };
92
+ const asDataUrl = (bytes, mime) => `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`;
93
+ /** One second of silence as WAV: what a voice note test listens to where nothing speaks. */
94
+ function silentWav() {
95
+ const rate = 16_000;
96
+ const samples = rate;
97
+ const buf = Buffer.alloc(44 + samples * 2);
98
+ buf.write("RIFF", 0);
99
+ buf.writeUInt32LE(36 + samples * 2, 4);
100
+ buf.write("WAVEfmt ", 8);
101
+ buf.writeUInt32LE(16, 16);
102
+ buf.writeUInt16LE(1, 20);
103
+ buf.writeUInt16LE(1, 22);
104
+ buf.writeUInt32LE(rate, 24);
105
+ buf.writeUInt32LE(rate * 2, 28);
106
+ buf.writeUInt16LE(2, 32);
107
+ buf.writeUInt16LE(16, 34);
108
+ buf.write("data", 36);
109
+ buf.writeUInt32LE(samples * 2, 40);
110
+ return new Uint8Array(buf);
111
+ }
112
+ async function testClass(cls, lang) {
113
+ const started = Date.now();
114
+ const signal = AbortSignal.timeout(cls === "video" ? 300_000 : 120_000);
115
+ const done = (result) => ({
116
+ ok: true,
117
+ ms: Date.now() - started,
118
+ ...result,
119
+ });
120
+ try {
121
+ if (TEXT_CLASSES.includes(cls)) {
122
+ const resolved = await textModel(cls);
123
+ const result = await generateText({
124
+ model: resolved.model,
125
+ prompt: "Reply with the single word: OK",
126
+ maxOutputTokens: 20,
127
+ abortSignal: signal,
128
+ });
129
+ return done({ ref: resolved.ref, reply: result.text.trim().slice(0, 200) });
130
+ }
131
+ if (cls === "image") {
132
+ const image = await generateImageMedia({
133
+ prompt: TEST_TEXT[lang].image,
134
+ aspectRatio: "1:1",
135
+ abortSignal: signal,
136
+ });
137
+ return done({ ref: image.system, media: asDataUrl(image.bytes, image.mime) });
138
+ }
139
+ if (cls === "speech") {
140
+ const voice = await generateSpeechMedia({
141
+ text: TEST_TEXT[lang].speech,
142
+ abortSignal: signal,
143
+ });
144
+ return done({ ref: voice.system, media: asDataUrl(voice.bytes, voice.mime) });
145
+ }
146
+ if (cls === "video") {
147
+ const clip = await generateVideoMedia({
148
+ prompt: TEST_TEXT[lang].video,
149
+ aspectRatio: "16:9",
150
+ duration: 4,
151
+ resolution: "480p",
152
+ abortSignal: signal,
153
+ });
154
+ return done({ ref: clip.system, media: asDataUrl(clip.bytes, clip.mime) });
155
+ }
156
+ // A voice note: the test voice where one speaks here, else a second of silence.
157
+ const voice = (await classProblem("speech"))
158
+ ? null
159
+ : await generateSpeechMedia({ text: TEST_TEXT[lang].speech, abortSignal: signal }).catch(() => null);
160
+ const heard = await transcribeAudio({
161
+ bytes: voice?.bytes ?? silentWav(),
162
+ mediaType: voice?.mime ?? "audio/wav",
163
+ abortSignal: signal,
164
+ });
165
+ return done({ reply: heard.text.slice(0, 300) });
166
+ }
167
+ catch (err) {
168
+ const message = err?.message ?? String(err);
169
+ return { ok: false, ms: Date.now() - started, error: message.slice(0, 500) };
170
+ }
171
+ }
63
172
  /** The size of an inline terminal, as the page lays it out. */
64
173
  const terminalSize = z.object({
65
174
  cols: z.number().int().min(10).max(500).optional(),
@@ -276,6 +385,9 @@ export const studio = new Hono()
276
385
  onChanged: () => {
277
386
  void stream.writeSSE({ event: "changed", data: "1" });
278
387
  },
388
+ onCard: (card) => {
389
+ void stream.writeSSE({ event: "card", data: JSON.stringify(card) });
390
+ },
279
391
  });
280
392
  await stream.writeSSE({
281
393
  event: "done",
@@ -617,16 +729,49 @@ export const studio = new Hono()
617
729
  if (managed) {
618
730
  return c.notFound();
619
731
  }
620
- const body = z
621
- .object({
622
- source: z.enum([...HARNESS_IDS, "account", "own"]).optional(),
623
- bindings: z.partialRecord(z.enum(MODEL_CLASSES), z.string().max(120)).optional(),
624
- ollamaUrl: z.string().max(200).optional(),
625
- keys: z.partialRecord(z.enum(LOCAL_KEYS), z.string().max(400)).optional(),
626
- })
627
- .parse(await c.req.json());
628
- await saveLocalModels(body);
732
+ await saveLocalModels(modelsInput.parse(await c.req.json()));
629
733
  return c.json(localModelSettings());
734
+ })
735
+ // --- models: where each class runs, own API keys, the credits behind them -----------------
736
+ // Alone for this machine; on a Manage-App's runtime for the team, whose admins change it.
737
+ .get("/models", async (c) => c.json(await modelSettings()))
738
+ .put("/models", async (c) => {
739
+ if (!mayChangeModels(c.get("user"))) {
740
+ return c.json({ error: "Only an admin of the team changes its models." }, 403);
741
+ }
742
+ const input = modelsInput.parse(await c.req.json());
743
+ // In the cloud nothing runs on a machine's client or local model.
744
+ if (managed &&
745
+ (input.source ||
746
+ input.ollamaUrl ||
747
+ Object.values(input.bindings ?? {}).some((ref) => ref && !cloudBinding(ref)))) {
748
+ return c.json({ error: "Not available in the cloud." }, 400);
749
+ }
750
+ await saveModels(input);
751
+ return c.json(await modelSettings());
752
+ })
753
+ // Whether a provider takes a key — the one typed, or the one stored. Nothing is generated.
754
+ .post("/models/keys/:provider/check", async (c) => {
755
+ const provider = z.enum(LOCAL_KEYS).parse(c.req.param("provider"));
756
+ const { key } = z
757
+ .object({ key: z.string().max(400).optional() })
758
+ .parse(await c.req.json().catch(() => ({})));
759
+ const value = key?.trim() || (await storedKey(provider));
760
+ if (!value) {
761
+ return c.json({ ok: false, message: "Kein API Key hinterlegt." });
762
+ }
763
+ return c.json(await checkKey(provider, value));
764
+ })
765
+ // One call on a class, as a run makes it: text answers a word, an image, a voice or a short
766
+ // clip comes back to look at, a voice note comes back as text. Paid like any call.
767
+ .post("/models/test", async (c) => {
768
+ if (!mayChangeModels(c.get("user"))) {
769
+ return c.json({ error: "Only an admin of the team tests its models." }, 403);
770
+ }
771
+ const { cls, lang } = z
772
+ .object({ cls: z.enum(MODEL_CLASSES), lang: z.enum(["de", "en"]).default("de") })
773
+ .parse(await c.req.json());
774
+ return c.json(await testClass(cls, lang));
630
775
  })
631
776
  // One short call on a class, as a run would make it: shows that the way to the model is open.
632
777
  .post("/local/models/test", async (c) => {
@@ -5,6 +5,7 @@ import { db, schema } from "../db/client.js";
5
5
  import { documentMime, UnreadableDocument } from "../documents/parse.js";
6
6
  import { loadAsset, removeAsset, saveAsset } from "../files/storage.js";
7
7
  import { textModel } from "../models.js";
8
+ import { spaceFileChanged } from "../plugins/events.js";
8
9
  import { dropLinks, putLink } from "../tenants/control.js";
9
10
  import { requireWritableProject } from "./access.js";
10
11
  import { notFound, ServiceError } from "./errors.js";
@@ -192,11 +193,13 @@ async function prepare(row) {
192
193
  if (row.kind !== "document") {
193
194
  const description = await describeImage(row, data).catch(() => "");
194
195
  await set({ status: "ready", ...(description ? { description } : {}) });
196
+ spaceFileChanged("space.file.ready", row);
195
197
  return;
196
198
  }
197
199
  const { text, ...indexed } = await indexDocument(row, data);
198
200
  const description = row.description || (text ? await describeDocument(row, text).catch(() => "") : "");
199
201
  await set({ ...indexed, status: "ready", error: null, description });
202
+ spaceFileChanged("space.file.ready", row);
200
203
  }
201
204
  catch (err) {
202
205
  console.error("[project-file]", row.name, err);
@@ -259,6 +262,7 @@ async function removeRow(row) {
259
262
  if (row.kind === "logo") {
260
263
  await dropLinks([row.id]);
261
264
  }
265
+ spaceFileChanged("space.file.removed", row);
262
266
  }
263
267
  export async function removeProjectFile(projectId, fileId) {
264
268
  await requireWritableProject(projectId);