wizards 0.4.3 → 0.4.5

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 (129) hide show
  1. package/apps/runtime/dist/agents/architect.js +32 -13
  2. package/apps/runtime/dist/agents/subscription.js +12 -12
  3. package/apps/runtime/dist/app.js +9 -5
  4. package/apps/runtime/dist/assistant/knows.js +2 -2
  5. package/apps/runtime/dist/authoring/example.js +21 -20
  6. package/apps/runtime/dist/authoring/guide.js +4 -2
  7. package/apps/runtime/dist/connectors/imap.js +4 -4
  8. package/apps/runtime/dist/connectors/index.js +40 -15
  9. package/apps/runtime/dist/connectors/plugin-connections.js +3 -2
  10. package/apps/runtime/dist/connectors/via-account.js +8 -6
  11. package/apps/runtime/dist/docs/dev/plugins/connections.md +1 -1
  12. package/apps/runtime/dist/docs/dev/plugins/reference.md +5 -1
  13. package/apps/runtime/dist/docs/dev/plugins/routes.md +35 -1
  14. package/apps/runtime/dist/docs/dev/plugins/server.md +27 -1
  15. package/apps/runtime/dist/docs/dev/plugins/starters.md +1 -1
  16. package/apps/runtime/dist/documents/parse.js +5 -3
  17. package/apps/runtime/dist/engine/events.js +1 -1
  18. package/apps/runtime/dist/engine/input.js +28 -8
  19. package/apps/runtime/dist/engine/prepare.js +2 -2
  20. package/apps/runtime/dist/engine/requirements.js +12 -3
  21. package/apps/runtime/dist/engine/runner.js +34 -23
  22. package/apps/runtime/dist/engine/steps.js +6 -5
  23. package/apps/runtime/dist/engine/surface.js +2 -1
  24. package/apps/runtime/dist/engine/template.js +1 -1
  25. package/apps/runtime/dist/engine/types.js +2 -1
  26. package/apps/runtime/dist/film/project.js +1 -1
  27. package/apps/runtime/dist/film/skills.js +2 -2
  28. package/apps/runtime/dist/film/sounds.js +1 -1
  29. package/apps/runtime/dist/film/step.js +7 -4
  30. package/apps/runtime/dist/film/tools.js +1 -1
  31. package/apps/runtime/dist/geocode.js +1 -1
  32. package/apps/runtime/dist/harness/apple.js +0 -5
  33. package/apps/runtime/dist/harness/claude.js +2 -1
  34. package/apps/runtime/dist/harness/codex.js +2 -1
  35. package/apps/runtime/dist/harness/model.js +4 -3
  36. package/apps/runtime/dist/harness/terminal.js +4 -3
  37. package/apps/runtime/dist/manage.js +29 -0
  38. package/apps/runtime/dist/model-errors.js +8 -3
  39. package/apps/runtime/dist/models.js +58 -38
  40. package/apps/runtime/dist/plugins/channels.js +77 -0
  41. package/apps/runtime/dist/plugins/loader.js +9 -1
  42. package/apps/runtime/dist/plugins/public.js +16 -2
  43. package/apps/runtime/dist/plugins/registry.js +1 -0
  44. package/apps/runtime/dist/plugins/runs.js +2 -1
  45. package/apps/runtime/dist/routes/api.js +16 -5
  46. package/apps/runtime/dist/routes/lang.js +13 -0
  47. package/apps/runtime/dist/routes/plugin-public.js +65 -12
  48. package/apps/runtime/dist/routes/runs.js +12 -19
  49. package/apps/runtime/dist/routes/studio.js +40 -28
  50. package/apps/runtime/dist/services/access.js +4 -3
  51. package/apps/runtime/dist/services/architect.js +15 -4
  52. package/apps/runtime/dist/services/assistant-chats.js +2 -1
  53. package/apps/runtime/dist/services/brand.js +3 -2
  54. package/apps/runtime/dist/services/errors.js +7 -5
  55. package/apps/runtime/dist/services/knowledge-model.js +3 -2
  56. package/apps/runtime/dist/services/marketplace.js +6 -5
  57. package/apps/runtime/dist/services/package.js +10 -3
  58. package/apps/runtime/dist/services/profile.js +12 -0
  59. package/apps/runtime/dist/services/project-files.js +13 -12
  60. package/apps/runtime/dist/services/projects.js +11 -6
  61. package/apps/runtime/dist/services/runs.js +18 -11
  62. package/apps/runtime/dist/services/shares.js +2 -1
  63. package/apps/runtime/dist/services/site.js +21 -10
  64. package/apps/runtime/dist/services/space-categories.js +7 -6
  65. package/apps/runtime/dist/services/space-data.js +19 -16
  66. package/apps/runtime/dist/services/spaces.js +8 -7
  67. package/apps/runtime/dist/services/wizards.js +41 -20
  68. package/apps/runtime/dist/store/errors.js +2 -1
  69. package/apps/runtime/dist/store/index.js +21 -4
  70. package/apps/runtime/dist/store/shared-lists.js +2 -3
  71. package/apps/runtime/dist/tools/index.js +17 -2
  72. package/apps/runtime/dist/tools/plugin.js +3 -2
  73. package/apps/runtime/dist/widgets/render.js +1 -1
  74. package/apps/runtime/dist/widgets/step.js +2 -1
  75. package/apps/web/dist/assets/{AboutDialog-DWaBKvI6.js → AboutDialog-BCtnYRj2.js} +1 -1
  76. package/apps/web/dist/assets/{ChangelogDialog-BcM1gXPM.js → ChangelogDialog-DBjoIIHL.js} +1 -1
  77. package/apps/web/dist/assets/ChatView-BcyUYN0Q.js +10 -0
  78. package/apps/web/dist/assets/EditorPage-Ce1QD-DK.js +3 -0
  79. package/apps/web/dist/assets/{FlowPreview-S1s1yRaP.js → FlowPreview-BtpAdyt-.js} +1 -1
  80. package/apps/web/dist/assets/{Harness-DILx5Edb.js → Harness-DJJ_yMLY.js} +2 -2
  81. package/apps/web/dist/assets/NewWizardChannels-BrYLMV6F.js +1 -0
  82. package/apps/web/dist/assets/NewWizardPage-827zwhhW.js +7 -0
  83. package/apps/web/dist/assets/{OnboardingPage-xoGrKIJ-.js → OnboardingPage-jC6FidpF.js} +1 -1
  84. package/apps/web/dist/assets/ProjectPage-BYcM1-1H.js +1 -0
  85. package/apps/web/dist/assets/{PublicRunner-Cab2jUy6.js → PublicRunner-BofZl0MO.js} +1 -1
  86. package/apps/web/dist/assets/{ReadOnly-DQPHmvS0.js → ReadOnly-B4nIGkoj.js} +1 -1
  87. package/apps/web/dist/assets/RunDrawer-Dx-BRCkY.js +4 -0
  88. package/apps/web/dist/assets/RunnerView-CDCp61aq.js +1 -0
  89. package/apps/web/dist/assets/SettingsPage-B49nLHwv.js +1 -0
  90. package/apps/web/dist/assets/SetupPage-4tzzHUq0.js +1 -0
  91. package/apps/web/dist/assets/SharePage-Bs4s73h4.js +1 -0
  92. package/apps/web/dist/assets/changelog-D-12LwZB.js +1 -0
  93. package/apps/web/dist/assets/{circle-alert-DCBV7n5k.js → circle-alert-BueS7qO8.js} +1 -1
  94. package/apps/web/dist/assets/esm-CjGdsKU4.js +7 -0
  95. package/apps/web/dist/assets/index-BRDn-5v_.js +14 -0
  96. package/apps/web/dist/assets/index-mlnAPOdf.css +1 -0
  97. package/apps/web/dist/assets/{lock-DlzTfwxP.js → lock-CkXYnQux.js} +1 -1
  98. package/apps/web/dist/assets/{phone-BFD815nc.js → phone-CHkEf-Dz.js} +1 -1
  99. package/apps/web/dist/assets/settings-menu-CrVpArM_.js +1 -0
  100. package/apps/web/dist/assets/ui-DrdqFaQt.js +134 -0
  101. package/apps/web/dist/assets/{upload-BEf5-d6X.js → upload-CzkkZqAo.js} +1 -1
  102. package/apps/web/dist/index.html +4 -4
  103. package/apps/web/dist/mcp/flow.html +76 -76
  104. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +98 -2
  105. package/node_modules/@engenty-wizards/plugin-sdk/dist/thread.js +6 -1
  106. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +6 -2
  107. package/node_modules/@engenty-wizards/shared/dist/definition.js +13 -5
  108. package/node_modules/@engenty-wizards/shared/dist/knowledge.js +1 -1
  109. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +11 -1
  110. package/node_modules/@engenty-wizards/shared/dist/run.js +11 -1
  111. package/node_modules/@engenty-wizards/shared/dist/store.d.ts +8 -0
  112. package/node_modules/@engenty-wizards/shared/dist/store.js +8 -0
  113. package/package.json +1 -1
  114. package/apps/web/dist/assets/ChatView-D0RRIl15.js +0 -10
  115. package/apps/web/dist/assets/EditorPage-DrJ6e_2q.js +0 -3
  116. package/apps/web/dist/assets/NewWizardChannels-CEILDADp.js +0 -1
  117. package/apps/web/dist/assets/NewWizardPage-DxOtK2E-.js +0 -7
  118. package/apps/web/dist/assets/ProjectPage-6E5z4nZA.js +0 -1
  119. package/apps/web/dist/assets/RunDrawer-B3FNYpgZ.js +0 -4
  120. package/apps/web/dist/assets/RunnerView-D6sGVnRy.js +0 -1
  121. package/apps/web/dist/assets/SettingsPage-DHtIqmOJ.js +0 -1
  122. package/apps/web/dist/assets/SetupPage-Cn9iQbJx.js +0 -1
  123. package/apps/web/dist/assets/SharePage-CMr72PeU.js +0 -1
  124. package/apps/web/dist/assets/changelog-DfmWTlav.js +0 -1
  125. package/apps/web/dist/assets/esm-BPfTI3lL.js +0 -7
  126. package/apps/web/dist/assets/index-B-D8UvL9.js +0 -14
  127. package/apps/web/dist/assets/index-CmxVazOf.css +0 -1
  128. package/apps/web/dist/assets/settings-menu-BJm0CwOj.js +0 -1
  129. package/apps/web/dist/assets/ui-DTuixH4y.js +0 -134
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Routes
3
3
  description: HTTP routes below the plugin's own address — signed in, inside the person's tenant.
4
- version: 0.3.2
4
+ version: 0.4.3
5
5
  ---
6
6
 
7
7
  A plugin's routes live below `/api/studio/plugins/<id>`, and for programs with a key below
@@ -130,6 +130,40 @@ const address = await server.publicUrl(`/signal/${source.token}`);
130
130
  - A body is taken up to 4 MB. Answers and errors work as for the plugin's other routes.
131
131
  - A tenant without the plugin, or a suspended one, answers `404`.
132
132
 
133
+ ## Shared routes
134
+
135
+ Some services post to one webhook for everyone: one account of the runtime's, one app. A shared
136
+ route has one address for all tenants, and the call itself says which tenant it is for, with
137
+ the ref the plugin handed the service before:
138
+
139
+ ```ts
140
+ // Handed to the service with the call it is about to make: names the tenant.
141
+ const ref = await server.publicRef();
142
+
143
+ server.registerSharedRoute({
144
+ method: "POST",
145
+ path: "/incoming",
146
+ handler: async ({ request, inTenant }) => {
147
+ const raw = await request.text();
148
+ if (!signedByTheService(raw, request.headers)) {
149
+ throw Object.assign(new Error("Bad signature."), { status: 401, code: "bad_signature" });
150
+ }
151
+ const event = JSON.parse(raw);
152
+ return inTenant(event.ref, () => pickUp(event));
153
+ },
154
+ });
155
+
156
+ // The address to enter at the service, once: the same for every tenant.
157
+ server.sharedUrl("/incoming");
158
+ // https://…/api/public/plugins/<id>/shared/incoming
159
+ ```
160
+
161
+ - The handler runs outside any tenant: check the service's signature, then `inTenant`.
162
+ - `inTenant(ref, work)` runs `work` inside the tenant of that ref, as a public route of the
163
+ tenant would run. A ref no tenant has, a suspended tenant and one without the plugin throw
164
+ `404` / `not_found`.
165
+ - `publicRef()` is the part of `publicUrl` that names the tenant; it is the same every time.
166
+
133
167
  ## From the studio half
134
168
 
135
169
  `studio.api` calls the plugin's own routes and gives the parsed JSON:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: The server half
3
3
  description: The function a plugin exports, what it is handed, and the rules it runs under.
4
- version: 0.3.7
4
+ version: 0.4.3
5
5
  ---
6
6
 
7
7
  The server half is a file whose default export is a function. The runtime calls it once when the
@@ -43,6 +43,8 @@ export default definePlugin((wizards) => {
43
43
  | `index.put(entry)`, `index.remove(space, key?)` | Texts in the space's search index | [The space](./space.md#the-search-index) |
44
44
  | `registerHttpRoute(route)` | A route below `/api/studio/plugins/<id>` | [Routes](./routes.md) |
45
45
  | `registerPublicRoute(route)`, `publicUrl(path)` | A route anyone may call, and its address | [Routes](./routes.md#public-routes) |
46
+ | `registerSharedRoute(route)`, `sharedUrl(path)`, `publicRef()` | One route for every tenant, and the ref that names one | [Routes](./routes.md#shared-routes) |
47
+ | `channels.get()`, `request`, `withdraw`, `book` | The numbers the Manage-App gives the tenant | below |
46
48
  | `registerRunner(runner)` | A door a wizard runs through, beside the page and the chat | [Runners](./runners.md) |
47
49
  | `runs.start(…)`, `report`, `answerPage`, … | Drives runs from the plugin's own door | [Runners](./runners.md#drive-a-run) |
48
50
  | `speech.conversation()` | The tenant's realtime speech model, for a call | [Runners](./runners.md#a-call) |
@@ -85,6 +87,30 @@ Either way a plugin never names a tenant:
85
87
  - State kept in a variable of the module is shared by all tenants. Keep per-tenant state in a
86
88
  table.
87
89
 
90
+ ## Numbers of the Manage-App
91
+
92
+ On engenty.ai a team gets its number for texts and calls from engenty: it asks for one, an
93
+ admin assigns it. `server.channels` reads it for the current tenant and books what it carries;
94
+ the prices are the Manage-App's ([manage contract](https://github.com/engenty/wizards/blob/main/docs/manage-contract.md#numbers-of-a-tenant)).
95
+
96
+ ```ts
97
+ const channels = await server.channels.get();
98
+ // { number: { number: "+43…", country: "AT", kinds: ["sms", "call"], providerRef: "PN…" } | null,
99
+ // request: { id, country, wish, createdAt } | null,
100
+ // shared: [{ number, country, kinds }] }
101
+
102
+ await server.channels.request({ userId: request.user.id, country: "AT", lang: "de" });
103
+ await server.channels.book({ kind: "sms", quantity: segments, key: messageSid, runId });
104
+ ```
105
+
106
+ - `get` is kept for a minute; `request` and `withdraw` forget it.
107
+ - `request` throws `403` / `plan` on a plan without a number and `409` / `has_number` when
108
+ the team has one. Asking again answers the open request.
109
+ - `book`: `sms` by segment, sent or received, `call` by started minute. The same `key` books
110
+ once.
111
+ - A runtime without a Manage-App has no numbers: `get` gives `null`, `request` and `withdraw`
112
+ throw `404` / `not_managed`, `book` books nothing.
113
+
88
114
  ## Asking a model
89
115
 
90
116
  `server.generate` asks one of the tenant's models, the same ones its wizards run on, and is paid
@@ -27,7 +27,7 @@ server.registerStarter({
27
27
  | `id` | Lower case, digits and `-`, starting with a letter, at most 40 characters. Unique in the plugin |
28
28
  | `title` | What the list of templates calls it |
29
29
  | `description` | What it makes, in one or two sentences. Shown under the title |
30
- | `language` | `"de"` or `"en"`, the language its texts are in. Default: `"de"` |
30
+ | `language` | `"de"` or `"en"`, the language its texts are in. Default: `"en"` |
31
31
  | `definition` | A complete wizard definition: the JSON the studio keeps and `get_authoring_guide` describes |
32
32
  | `files` | Files of the wizard's workspace by path: widgets, sample data. A string is written as UTF-8, a `Uint8Array` as it is |
33
33
 
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { existsSync, mkdirSync } from "node:fs";
3
3
  import { readFile, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
+ import { bilingual } from "@engenty-wizards/shared/run";
5
6
  import { generateText, Output } from "ai";
6
7
  import PostalMime from "postal-mime";
7
8
  import { z } from "zod";
@@ -11,9 +12,10 @@ import { splitMarkdownByPageBreaks } from "../engenty/doc-converter/page-break.j
11
12
  import { LocalProvider } from "../engenty/doc-converter/providers/local/index.js";
12
13
  import { invoiceSchema, receiptSchema } from "../engenty/document-scanner/schemas/index.js";
13
14
  import { env } from "../env.js";
15
+ import { NotedError } from "../model-errors.js";
14
16
  import { costOf, textModel } from "../models.js";
15
17
  import { htmlToMarkdown } from "../render/convert.js";
16
- export class UnreadableDocument extends Error {
18
+ export class UnreadableDocument extends NotedError {
17
19
  }
18
20
  const DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
19
21
  const XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
@@ -168,7 +170,7 @@ async function read(input, opts) {
168
170
  const vision = new VisionProvider(opts.charge, opts.signal, opts.call);
169
171
  if (mime.startsWith("image/")) {
170
172
  if (!vision.canConvert(mime)) {
171
- throw new UnreadableDocument(`Bilder vom Typ ${mime} kann ich nicht lesen (PNG, JPEG, WebP gehen).`);
173
+ throw new UnreadableDocument(bilingual(`Bilder vom Typ ${mime} kann ich nicht lesen (PNG, JPEG, WebP gehen).`, `I cannot read images of type ${mime} (PNG, JPEG and WebP work).`));
172
174
  }
173
175
  const result = await vision.convert(data, name, mime);
174
176
  return { markdown: result.markdown, pages: 1, via: "vision" };
@@ -212,7 +214,7 @@ async function read(input, opts) {
212
214
  if (mime === "application/json" || mime.startsWith("text/")) {
213
215
  return { markdown: text(), pages: null, via: "text" };
214
216
  }
215
- throw new UnreadableDocument(`„${name}“ (${mime}) kann ich nicht lesen.`);
217
+ throw new UnreadableDocument(bilingual(`„${name}“ (${mime}) kann ich nicht lesen.`, `I cannot read “${name}” (${mime}).`));
216
218
  }
217
219
  // --- structured extraction -----------------------------------------------------
218
220
  /** The scanner's schemas say nothing about the currency; SaaS invoices come in several. */
@@ -7,7 +7,7 @@ const bus = new EventEmitter();
7
7
  bus.setMaxListeners(0);
8
8
  export async function emitEvent(runId, stepId, type, said, asset) {
9
9
  const note = typeof said === "string" ? null : said;
10
- const message = note ? noteText(note, "de") : said;
10
+ const message = note ? noteText(note, "en") : said;
11
11
  const [row] = await db
12
12
  .insert(schema.runEvent)
13
13
  .values({
@@ -1,4 +1,9 @@
1
1
  import { isDecidedField, isLocationValue, MAX_FILES, offeredSlot, shownFields, } from "@engenty-wizards/shared/definition";
2
+ import { bilingual, saidText } from "@engenty-wizards/shared/run";
3
+ /** The field errors in a language, for whoever reads them. */
4
+ export function fieldErrorsIn(errors, lang) {
5
+ return errors.map((e) => ({ field: e.field, message: saidText(e.message, lang) }));
6
+ }
2
7
  function empty(v) {
3
8
  return v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
4
9
  }
@@ -137,36 +142,48 @@ export function readPageInput(step, input, context = {}) {
137
142
  continue;
138
143
  }
139
144
  if (field.required && empty(v) && field.kind !== "toggle") {
140
- errors.push({ field: field.id, message: "Pflichtfeld" });
145
+ errors.push({ field: field.id, message: bilingual("Pflichtfeld", "Required") });
141
146
  continue;
142
147
  }
143
148
  if (Array.isArray(v) && (field.kind === "image" || field.kind === "file")) {
144
149
  if (field.min && v.length < field.min) {
145
- errors.push({ field: field.id, message: `Bitte mindestens ${field.min} auswählen` });
150
+ errors.push({
151
+ field: field.id,
152
+ message: bilingual(`Bitte mindestens ${field.min} auswählen`, `Please choose at least ${field.min}`),
153
+ });
146
154
  continue;
147
155
  }
148
156
  if (field.max && v.length > field.max) {
149
157
  errors.push({
150
158
  field: field.id,
151
- message: `Höchstens ${field.max} – bitte einige entfernen`,
159
+ message: bilingual(`Höchstens ${field.max} – bitte einige entfernen`, `At most ${field.max} – please remove some`),
152
160
  });
153
161
  continue;
154
162
  }
155
163
  }
156
164
  if (field.kind === "number" && Number.isNaN(v)) {
157
- errors.push({ field: field.id, message: "Bitte eine Zahl eingeben" });
165
+ errors.push({
166
+ field: field.id,
167
+ message: bilingual("Bitte eine Zahl eingeben", "Please enter a number"),
168
+ });
158
169
  continue;
159
170
  }
160
171
  if (field.kind === "select" && !empty(v) && !field.options?.includes(String(v))) {
161
- errors.push({ field: field.id, message: "Ungültige Auswahl" });
172
+ errors.push({ field: field.id, message: bilingual("Ungültige Auswahl", "Invalid choice") });
162
173
  continue;
163
174
  }
164
175
  if (field.kind === "slot" && !empty(v) && !field.options?.includes(String(v))) {
165
- errors.push({ field: field.id, message: "Dieser Termin wird nicht angeboten" });
176
+ errors.push({
177
+ field: field.id,
178
+ message: bilingual("Dieser Termin wird nicht angeboten", "This time is not offered"),
179
+ });
166
180
  continue;
167
181
  }
168
182
  if (field.kind === "email" && !empty(v) && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v))) {
169
- errors.push({ field: field.id, message: "Keine gültige E-Mail-Adresse" });
183
+ errors.push({
184
+ field: field.id,
185
+ message: bilingual("Keine gültige E-Mail-Adresse", "Not a valid email address"),
186
+ });
170
187
  continue;
171
188
  }
172
189
  if (field.kind === "url" && !empty(v)) {
@@ -174,7 +191,10 @@ export function readPageInput(step, input, context = {}) {
174
191
  new URL(String(v));
175
192
  }
176
193
  catch {
177
- errors.push({ field: field.id, message: "Keine gültige URL" });
194
+ errors.push({
195
+ field: field.id,
196
+ message: bilingual("Keine gültige URL", "Not a valid URL"),
197
+ });
178
198
  continue;
179
199
  }
180
200
  }
@@ -68,7 +68,7 @@ async function listenTo(ctx, id, label, value) {
68
68
  if (!found || found.row.runId !== ctx.runId || !/^(audio|video)\//.test(found.row.mime)) {
69
69
  return;
70
70
  }
71
- await ctx.emit("info", `Hört „${label}“ an …`);
71
+ await ctx.emit("info", { code: "listen", params: { label } });
72
72
  try {
73
73
  const { pieces, costUsd } = await listenPieces({
74
74
  bytes: new Uint8Array(found.data),
@@ -85,6 +85,6 @@ async function listenTo(ctx, id, label, value) {
85
85
  }
86
86
  // The step goes on without the words; the next one tries again.
87
87
  console.error(`[run ${ctx.runId}] listen ${id}`, err);
88
- await ctx.emit("info", `„${label}“ konnte nicht verschriftlicht werden.`);
88
+ await ctx.emit("info", { code: "listenFailed", params: { label } });
89
89
  }
90
90
  }
@@ -1,5 +1,6 @@
1
1
  import { conditionMatches, conditionsOf, isDecidedField, isDecisionStep, isOtherwise, MODEL_CLASSES, } from "@engenty-wizards/shared/definition";
2
2
  import { closedChoicesFor, openValue, otherwiseIndex, unavoidableFrom, unavoidableSteps, } from "@engenty-wizards/shared/fit";
3
+ import { bilingual, saidText } from "@engenty-wizards/shared/run";
3
4
  import { classProblem, filmClient } from "../models.js";
4
5
  // The steps every run passes through, and the choices that lead around a missing model, are
5
6
  // pure and shared (packages/shared/src/fit.ts): the standalone check uses the same.
@@ -220,8 +221,12 @@ export async function stepsWithoutModel(def) {
220
221
  const missing = await missingClasses();
221
222
  return new Set(def.steps.filter((s) => stepClasses(s).some((cls) => missing.has(cls))).map((s) => s.id));
222
223
  }
224
+ /** The classes this wizard needs that this runtime cannot serve, in a language; empty = every step can run. */
225
+ export async function missingModels(def, lang = "en") {
226
+ return (await missingModelsSaid(def)).map((m) => ({ ...m, problem: saidText(m.problem, lang) }));
227
+ }
223
228
  /** The classes this wizard needs that this runtime cannot serve; empty = every step can run. */
224
- export async function missingModels(def) {
229
+ export async function missingModelsSaid(def) {
225
230
  const unavoidable = unavoidableSteps(def);
226
231
  const needs = new Map();
227
232
  for (const step of def.steps) {
@@ -268,9 +273,13 @@ export function blockingMessage(missing) {
268
273
  if (!blocking.length) {
269
274
  return null;
270
275
  }
271
- return blocking
272
- .map((m) => `${m.steps.map((s) => `„${s.title}“`).join(", ")}: ${m.problem}`)
276
+ const line = (lang) => blocking
277
+ .map((m) => {
278
+ const steps = m.steps.map((s) => (lang === "de" ? `„${s.title}“` : `“${s.title}”`));
279
+ return `${steps.join(", ")}: ${saidText(m.problem, lang)}`;
280
+ })
273
281
  .join(" ");
282
+ return bilingual(line("de"), line("en"));
274
283
  }
275
284
  /** The class a marketplace capability stands for; the rest need no model of their own. */
276
285
  const CAPABILITY_CLASS = {
@@ -1,6 +1,6 @@
1
1
  import { marginOf, } from "@engenty-wizards/shared/decision";
2
2
  import { branchValues, cardsFromData, conditionsOf, dataRef, followRules, formatsFor, isDecidedField, LIST_FORMATS, listRef, loopKey, MAX_SHOWN_FIELDS, optionsFromData, otherwiseOf, pageNeeds, whenHolds, wizardLang, } from "@engenty-wizards/shared/definition";
3
- import { noteText, } from "@engenty-wizards/shared/run";
3
+ import { bilingual, saidText, } from "@engenty-wizards/shared/run";
4
4
  import { CONVERSATION_RUNNERS, DEFAULT_RUNNER_SETTINGS } from "@engenty-wizards/shared/runners";
5
5
  import { eq } from "drizzle-orm";
6
6
  import { nanoid } from "nanoid";
@@ -12,7 +12,7 @@ import { env } from "../env.js";
12
12
  import { saveAsset } from "../files/storage.js";
13
13
  import { managed, tenantInfo } from "../manage.js";
14
14
  import { hasFfmpeg } from "../media/ffmpeg.js";
15
- import { NotedError, noteOf } from "../model-errors.js";
15
+ import { NotedError, noteOf, saidOf } from "../model-errors.js";
16
16
  import { conversationAvailable, freeTierRefusal, freeTierText, ModelUnavailableError, noteFreeTierRefusal, } from "../models.js";
17
17
  import { runEnded } from "../plugins/events.js";
18
18
  import { projectFiles } from "../services/project-files.js";
@@ -26,7 +26,7 @@ import { clearDeviceCalls } from "./device.js";
26
26
  import { emitEvent, recentEvents, signalChanged } from "./events.js";
27
27
  import { readPageInput } from "./input.js";
28
28
  import { pushRun } from "./push.js";
29
- import { blockingMessage, closedChoices, missingModels, openValue, stepsWithoutModel, } from "./requirements.js";
29
+ import { blockingMessage, closedChoices, missingModelsSaid, openValue, stepsWithoutModel, } from "./requirements.js";
30
30
  import { releaseResources, resourcesFor } from "./resources.js";
31
31
  import { runAutomaticStep } from "./steps.js";
32
32
  import { answersAsText, resolveRef } from "./template.js";
@@ -41,9 +41,9 @@ export class RunInputError extends NotedError {
41
41
  this.errors = errors;
42
42
  }
43
43
  }
44
- export class NoCreditsError extends Error {
44
+ export class NoCreditsError extends NotedError {
45
45
  constructor() {
46
- super("Dieser Wizard hat gerade kein Guthaben mehr. Bitte später erneut versuchen.");
46
+ super(bilingual("Dieser Wizard hat gerade kein Guthaben mehr. Bitte später erneut versuchen.", "This wizard has no credits left right now. Please try again later."));
47
47
  }
48
48
  }
49
49
  /** The runs this process drives right now, with the handle that stops each. */
@@ -99,7 +99,7 @@ function stepOf(def, id) {
99
99
  }
100
100
  function friendly(err) {
101
101
  if (err instanceof StepError || err instanceof ModelUnavailableError) {
102
- return err.message;
102
+ return saidOf(err);
103
103
  }
104
104
  // An own AI Gateway key on Vercel's free tier: the person can top it up or pick another way.
105
105
  const refused = freeTierRefusal(err);
@@ -109,32 +109,32 @@ function friendly(err) {
109
109
  const msg = err?.message ?? String(err);
110
110
  // The model-gateway answers 402 once the tenant's credits are used up.
111
111
  if (/insufficient_credits|\b402\b/i.test(msg)) {
112
- return new NoCreditsError().message;
112
+ return saidOf(new NoCreditsError());
113
113
  }
114
114
  // A runtime that runs alone pays its provider itself: its admin can top the account up.
115
115
  if (!managed && /minimum balance|insufficient_funds|top up your credits/i.test(msg)) {
116
- return "Das Guthaben beim Modell-Anbieter reicht dafür nicht aus (Videos brauchen dort ein Mindestguthaben). Bitte dort aufladen und noch einmal versuchen.";
116
+ return bilingual("Das Guthaben beim Modell-Anbieter reicht dafür nicht aus (Videos brauchen dort ein Mindestguthaben). Bitte dort aufladen und noch einmal versuchen.", "The credit with the model provider is not enough for this (videos need a minimum balance there). Please top up there and try again.");
117
117
  }
118
118
  if (/rate.?limit|429/i.test(msg)) {
119
- return "Der KI-Dienst ist gerade ausgelastet. Bitte gleich noch einmal versuchen.";
119
+ return bilingual("Der KI-Dienst ist gerade ausgelastet. Bitte gleich noch einmal versuchen.", "The AI service is busy right now. Please try again in a moment.");
120
120
  }
121
121
  if (/timeout|timed out/i.test(msg)) {
122
- return "Der Schritt hat zu lange gedauert. Bitte noch einmal versuchen.";
122
+ return bilingual("Der Schritt hat zu lange gedauert. Bitte noch einmal versuchen.", "The step took too long. Please try again.");
123
123
  }
124
124
  // The platform's own provider account (budget, quota, key): nothing the person can fix.
125
125
  if (/budget|quota|insufficient|billing|credit balance|api key|unauthori[sz]ed|401|403/i.test(msg)) {
126
- return "Der KI-Dienst ist gerade nicht erreichbar. Bitte später noch einmal versuchen.";
126
+ return bilingual("Der KI-Dienst ist gerade nicht erreichbar. Bitte später noch einmal versuchen.", "The AI service cannot be reached right now. Please try again later.");
127
127
  }
128
- return `Dieser Schritt ist fehlgeschlagen: ${msg.slice(0, 300)}`;
128
+ return bilingual(`Dieser Schritt ist fehlgeschlagen: ${msg.slice(0, 300)}`, `This step failed: ${msg.slice(0, 300)}`);
129
129
  }
130
130
  /**
131
131
  * A step that failed ends the run. A failure with a note (Apple Intelligence's) goes into the log
132
132
  * as that note, so a runner shows it in its language; the run's error is in the wizard's.
133
133
  */
134
134
  async function failRun(run, stepId, err) {
135
- const note = noteOf(err);
136
- const message = note ? noteText(note, wizardLang(run.definition)) : friendly(err);
137
- await emitEvent(run.id, stepId, "error", note ?? message);
135
+ const said = noteOf(err) ?? friendly(err);
136
+ const message = saidText(said, wizardLang(run.definition));
137
+ await emitEvent(run.id, stepId, "error", said);
138
138
  await updateRun(run.id, { status: "failed", error: message });
139
139
  }
140
140
  // --- creating & driving ------------------------------------------------------
@@ -143,7 +143,7 @@ export async function createRun(input) {
143
143
  const first = input.definition.steps[0];
144
144
  const state = { values: {}, outputs: {}, history: [], notes: {} };
145
145
  // A step every run passes through needs a model this runtime cannot serve: refuse before anything is held.
146
- const blocked = blockingMessage(await missingModels(input.definition));
146
+ const blocked = blockingMessage(await missingModelsSaid(input.definition));
147
147
  if (blocked) {
148
148
  throw new ModelUnavailableError(blocked);
149
149
  }
@@ -219,7 +219,11 @@ async function take(runId, tenant) {
219
219
  await withTenant(tenant, () => drive(runId, abort.signal))
220
220
  .catch(async (err) => {
221
221
  console.error(`[run ${runId}]`, err);
222
- await withTenant(tenant, () => updateRun(runId, { status: "failed", error: friendly(err) })).catch(() => undefined);
222
+ await withTenant(tenant, async () => {
223
+ const run = await loadRun(runId);
224
+ const lang = run ? wizardLang(run.definition) : "en";
225
+ await updateRun(runId, { status: "failed", error: saidText(friendly(err), lang) });
226
+ }).catch(() => undefined);
223
227
  })
224
228
  .finally(async () => {
225
229
  clearInterval(beat);
@@ -631,14 +635,17 @@ export async function submitPage(runId, stepId, input) {
631
635
  const answer = values[id];
632
636
  if (answer !== undefined &&
633
637
  (choice.values.length === 0 || choice.values.some((v) => String(v) === String(answer)))) {
634
- errors.push({ field: id, message: "Das ist hier gerade nicht verfügbar." });
638
+ errors.push({
639
+ field: id,
640
+ message: bilingual("Das ist hier gerade nicht verfügbar.", "That is not available here right now."),
641
+ });
635
642
  }
636
643
  }
637
644
  if (errors.length) {
638
645
  throw new RunInputError(errors);
639
646
  }
640
647
  const connections = step.fields.some((f) => f.kind === "connection")
641
- ? await connectionViews(run.definition.connections ?? [], scopeOf(run), await projectIdOf(run))
648
+ ? await connectionViews(run.definition.connections ?? [], scopeOf(run), await projectIdOf(run), wizardLang(run.definition))
642
649
  : [];
643
650
  for (const field of step.fields) {
644
651
  if (field.kind === "image" || field.kind === "file") {
@@ -646,7 +653,9 @@ export async function submitPage(runId, stepId, input) {
646
653
  for (const id of Array.isArray(value) ? value : value ? [value] : []) {
647
654
  const asset = await db.query.asset.findFirst({ where: eq(schema.asset.id, String(id)) });
648
655
  if (asset?.runId !== runId) {
649
- throw new RunInputError([{ field: field.id, message: "Datei nicht gefunden" }]);
656
+ throw new RunInputError([
657
+ { field: field.id, message: bilingual("Datei nicht gefunden", "File not found") },
658
+ ]);
650
659
  }
651
660
  }
652
661
  }
@@ -657,7 +666,9 @@ export async function submitPage(runId, stepId, input) {
657
666
  values[field.id] = account.label;
658
667
  }
659
668
  else if (field.required) {
660
- throw new RunInputError([{ field: field.id, message: "Bitte zuerst verbinden" }]);
669
+ throw new RunInputError([
670
+ { field: field.id, message: bilingual("Bitte zuerst verbinden", "Please connect first") },
671
+ ]);
661
672
  }
662
673
  }
663
674
  }
@@ -701,7 +712,7 @@ export async function reviewStep(runId, stepId, action) {
701
712
  if (!step.show.includes(action.target)) {
702
713
  throw new RunConflict("Unknown target");
703
714
  }
704
- state.notes[action.target] = action.note.slice(0, 2000) || "Bitte eine neue Variante.";
715
+ state.notes[action.target] = action.note.slice(0, 2000) || "Please make a new variant.";
705
716
  // A step with several results makes only the named ones again.
706
717
  if (action.items?.length) {
707
718
  state.redo = { ...state.redo, [action.target]: action.items };
@@ -884,7 +895,7 @@ export async function runView(run, brand) {
884
895
  ask: run.status === "running" ? run.ask : null,
885
896
  lists,
886
897
  connections: step?.type === "page" && step.fields.some((f) => f.kind === "connection")
887
- ? await connectionViews(def.connections ?? [], scopeOf(run), await projectIdOf(run))
898
+ ? await connectionViews(def.connections ?? [], scopeOf(run), await projectIdOf(run), wizardLang(def))
888
899
  : [],
889
900
  keeps: Boolean(def.lists?.length || def.connections?.length),
890
901
  shareUrl: run.shareToken ? `${env.publicUrl}/s/${run.shareToken}` : null,
@@ -1,6 +1,7 @@
1
1
  import { pOf } from "@engenty-wizards/shared/decision";
2
2
  import { allFields, dataRef, isDecisionStep, LOCALES, templateRefs, wizardLang, } from "@engenty-wizards/shared/definition";
3
3
  import { mainLogo, ruleLines } from "@engenty-wizards/shared/projects";
4
+ import { bilingual } from "@engenty-wizards/shared/run";
4
5
  import { SURFACE_GUIDE, validateSurface, } from "@engenty-wizards/shared/surface";
5
6
  import { Agent } from "@mastra/core/agent";
6
7
  import { generateText, Output } from "ai";
@@ -378,7 +379,7 @@ function agentEntries(step, ctx) {
378
379
  async function runAgentEach(step, ctx) {
379
380
  const entries = agentEntries(step, ctx);
380
381
  if (!entries.length) {
381
- throw new StepError("Es gibt keine Einträge, für die dieser Schritt arbeiten könnte.");
382
+ throw new StepError(bilingual("Es gibt keine Einträge, für die dieser Schritt arbeiten könnte.", "There are no entries for this step to work on."));
382
383
  }
383
384
  // The view, if any, is composed once over the table of all entries.
384
385
  const single = {
@@ -553,7 +554,7 @@ export async function runAgentStep(step, ctx) {
553
554
  const text = step.output.format === "json" ? everything : closing || everything;
554
555
  if (step.output.format !== "json") {
555
556
  if (!text && !assets.length) {
556
- throw new StepError("Der Schritt hat kein Ergebnis geliefert.");
557
+ throw new StepError(bilingual("Der Schritt hat kein Ergebnis geliefert.", "The step did not deliver a result."));
557
558
  }
558
559
  return { text, assets: assets.length ? assets : undefined, at: new Date().toISOString() };
559
560
  }
@@ -754,7 +755,7 @@ async function writeHtml(step, ctx, kind) {
754
755
  .replace(/```\s*$/, "")
755
756
  .trim();
756
757
  if (!/<html|<!doctype/i.test(html)) {
757
- throw new StepError("Das Dokument konnte nicht erstellt werden.");
758
+ throw new StepError(bilingual("Das Dokument konnte nicht erstellt werden.", "The document could not be made."));
758
759
  }
759
760
  // On screen (preview, PNG) a print document needs the margin its @page rule gives it on paper.
760
761
  return kind === "document"
@@ -805,7 +806,7 @@ async function inParallel(items, limit, work) {
805
806
  async function runMediaStep(step, ctx, asset) {
806
807
  const entries = entriesOf(step, ctx);
807
808
  if (!entries.length) {
808
- throw new StepError("Für diesen Schritt fehlen die Vorlagen (Bilder oder Szenen).");
809
+ throw new StepError(bilingual("Für diesen Schritt fehlen die Vorlagen (Bilder oder Szenen).", "This step is missing its sources (images or scenes)."));
809
810
  }
810
811
  const previous = ctx.state.outputs[step.id];
811
812
  const before = previous?.assets?.filter((a) => a.kind === asset) ?? [];
@@ -897,7 +898,7 @@ export async function runGenerateStep(step, ctx) {
897
898
  case "voice": {
898
899
  const text = renderTemplate(step.prompt, ctx.scope).trim().slice(0, 4000);
899
900
  if (!text) {
900
- throw new StepError("Es gibt keinen Text zum Vorlesen.");
901
+ throw new StepError(bilingual("Es gibt keinen Text zum Vorlesen.", "There is no text to read aloud."));
901
902
  }
902
903
  const note = ctx.state.notes[step.id];
903
904
  const media = await generateSpeechMedia({
@@ -1,4 +1,5 @@
1
1
  import { assembleSurface, dataRef, listRef, } from "@engenty-wizards/shared/definition";
2
+ import { bilingual } from "@engenty-wizards/shared/run";
2
3
  import { validateSurface } from "@engenty-wizards/shared/surface";
3
4
  import { decide } from "./decide.js";
4
5
  import { answersAsText, resolveRef } from "./template.js";
@@ -13,7 +14,7 @@ export async function runSurfaceStep(step, ctx) {
13
14
  const components = step.components ?? (await decidedComponents(step, ctx));
14
15
  const issues = validateSurface(components, Object.keys(data));
15
16
  if (issues.length) {
16
- throw new StepError(`Die Ansicht ist fehlerhaft: ${issues.map((i) => i.message).join(" ")}`);
17
+ throw new StepError(bilingual(`Die Ansicht ist fehlerhaft: ${issues.map((i) => i.message).join(" ")}`, `The view is faulty: ${issues.map((i) => i.message).join(" ")}`));
17
18
  }
18
19
  return { surface: { components, data }, at: new Date().toISOString() };
19
20
  }
@@ -11,7 +11,7 @@ function money(n, currency, locale) {
11
11
  }
12
12
  }
13
13
  /** Line items as a Markdown table with their totals — what a model needs to write a document. */
14
- export function itemsAsMarkdown(field, value, values, lang = "de") {
14
+ export function itemsAsMarkdown(field, value, values, lang = "en") {
15
15
  const locale = LOCALES[lang];
16
16
  const totals = itemsTotals(field, value, values);
17
17
  const cols = field.columns ?? [];
@@ -1,3 +1,4 @@
1
+ import { NotedError } from "../model-errors.js";
1
2
  /** A step failure the person can read. */
2
- export class StepError extends Error {
3
+ export class StepError extends NotedError {
3
4
  }
@@ -250,7 +250,7 @@ export async function prepareFilmFolder(step, ctx) {
250
250
  const preset = { "16:9": "landscape", "9:16": "portrait", "1:1": "square" }[step.format];
251
251
  const init = await runHyperframes(["init", "film", "--non-interactive", "--skip-transcribe", `--resolution=${preset}`], { cwd: root, signal: ctx.signal, timeoutMs: 5 * 60_000 });
252
252
  if (init.code !== 0 || !existsSync(join(root, "film"))) {
253
- throw new Error(`Das Filmprojekt konnte nicht angelegt werden: ${init.out.slice(-300)}`);
253
+ throw new Error(`The film project could not be set up: ${init.out.slice(-300)}`);
254
254
  }
255
255
  await put(root, ".engenty/inputs", stamp);
256
256
  return { root, brief };
@@ -21,7 +21,7 @@ const fetching = new Map();
21
21
  export function platformSkills(name) {
22
22
  const pkg = PLATFORM_SKILLS[name];
23
23
  if (!pkg) {
24
- return Promise.reject(new Error(`Das Skill-Paket „${name}“ gibt es nicht.`));
24
+ return Promise.reject(new Error(`There is no skill package “${name}”.`));
25
25
  }
26
26
  const dir = join(env.dataDir, "skills", `${pkg.name}@${pkg.version.slice(0, 12)}`);
27
27
  if (existsSync(join(dir, ".complete"))) {
@@ -37,7 +37,7 @@ export function platformSkills(name) {
37
37
  async function fetchPackage(pkg, dir) {
38
38
  const res = await fetch(pkg.tarball);
39
39
  if (!res.ok) {
40
- throw new Error(`Das Skill-Paket „${pkg.name}“ konnte nicht geladen werden (${res.status}).`);
40
+ throw new Error(`The skill package “${pkg.name}” could not be loaded (${res.status}).`);
41
41
  }
42
42
  const tmp = `${dir}.part`;
43
43
  await rm(tmp, { recursive: true, force: true });
@@ -108,7 +108,7 @@ async function build() {
108
108
  }
109
109
  }
110
110
  if (!sounds.length) {
111
- throw new Error("Die Geräusche für Filme konnten nicht geladen werden.");
111
+ throw new Error("The sounds for films could not be loaded.");
112
112
  }
113
113
  sounds.sort((a, b) => a.file.localeCompare(b.file));
114
114
  await writeFile(join(tmp, "catalog.json"), JSON.stringify({
@@ -2,10 +2,12 @@ import { spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { readFile, rm, stat } from "node:fs/promises";
4
4
  import { isAbsolute, join, normalize } from "node:path";
5
+ import { bilingual } from "@engenty-wizards/shared/run";
5
6
  import { StepError } from "../engine/types.js";
6
7
  import { CLAUDE_BIN, claudeEnv } from "../harness/claude.js";
7
8
  import { failureOf } from "../harness/model.js";
8
9
  import { openBridge } from "../mcp/bridge.js";
10
+ import { errorText } from "../model-errors.js";
9
11
  import { filmClient } from "../models.js";
10
12
  import { prepareFilmFolder, savedSession, saveSession } from "./project.js";
11
13
  import { runHyperframes } from "./tools.js";
@@ -227,12 +229,13 @@ export async function runFilmStep(step, ctx) {
227
229
  if (ctx.signal.aborted) {
228
230
  throw err;
229
231
  }
230
- throw new StepError(`${client.name} hat beim Film aufgehört: ${err.message}. Die Arbeit ist gespeichert; ein neuer Versuch macht dort weiter.`);
232
+ throw new StepError(bilingual(`${client.name} hat beim Film aufgehört: ${err.message}. Die Arbeit ist gespeichert; ein neuer Versuch macht dort weiter.`, `${client.name} stopped working on the film: ${err.message}. The work is saved; a new attempt carries on from there.`));
231
233
  });
232
234
  const result = last;
233
235
  if (!result || result.is_error) {
234
- const detail = String(result?.result ?? (stderr.trim() || "kein Ergebnis")).slice(0, 400);
235
- throw new StepError(`${failureOf(client, detail).message} Die Arbeit ist gespeichert; ein neuer Versuch macht dort weiter.`);
236
+ const detail = String(result?.result ?? (stderr.trim() || "no result")).slice(0, 400);
237
+ const failure = failureOf(client, detail);
238
+ throw new StepError(bilingual(`${errorText(failure, "de")} Die Arbeit ist gespeichert; ein neuer Versuch macht dort weiter.`, `${errorText(failure, "en")} The work is saved; a new attempt carries on from there.`));
236
239
  }
237
240
  return result;
238
241
  };
@@ -262,7 +265,7 @@ export async function runFilmStep(step, ctx) {
262
265
  }
263
266
  const problem = renderProblem(render.out);
264
267
  if (attempt >= RENDER_FIXES) {
265
- throw new StepError(`Der Film ließ sich nicht rendern: ${problem.slice(0, 400)}`);
268
+ throw new StepError(bilingual(`Der Film ließ sich nicht rendern: ${problem.slice(0, 400)}`, `The film could not be rendered: ${problem.slice(0, 400)}`));
266
269
  }
267
270
  await ctx.emit("tool", { code: "filmFix" });
268
271
  const fixed = await turn(`The final render of film/ failed:\n\n${problem}\n\nFind the cause in the composition's code, fix it, run \`check\` and a draft render through the hyperframes tool until both pass, and end with one line on what you fixed.`, await savedSession(root));
@@ -40,7 +40,7 @@ async function install() {
40
40
  `hyperframes@${HYPERFRAMES_VERSION}`,
41
41
  ], { cwd: home, timeoutMs: 10 * 60_000 });
42
42
  if (code !== 0 || !existsSync(entry)) {
43
- throw new Error(`HyperFrames konnte nicht installiert werden: ${out.slice(-400)}`);
43
+ throw new Error(`HyperFrames could not be installed: ${out.slice(-400)}`);
44
44
  }
45
45
  return entry;
46
46
  }