@engenty/wizards-runtime 0.7.6 → 0.7.8

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 (85) hide show
  1. package/apps/runtime/dist/authoring/guide.js +12 -3
  2. package/apps/runtime/dist/browser/fast/classifier.js +9 -4
  3. package/apps/runtime/dist/cli/main.js +1 -1
  4. package/apps/runtime/dist/cli/menu.js +5 -1
  5. package/apps/runtime/dist/cli/ollama.js +302 -0
  6. package/apps/runtime/dist/cli/setup.js +102 -2
  7. package/apps/runtime/dist/decision-model.js +33 -0
  8. package/apps/runtime/dist/docs/dev/index.md +6 -1
  9. package/apps/runtime/dist/docs/dev/integration/channels.md +11 -0
  10. package/apps/runtime/dist/docs/dev/integration/claude-code-plugin.md +10 -0
  11. package/apps/runtime/dist/docs/dev/integration/embed.md +10 -0
  12. package/apps/runtime/dist/docs/dev/integration/index.md +36 -0
  13. package/apps/runtime/dist/docs/dev/integration/link-and-share.md +10 -0
  14. package/apps/runtime/dist/docs/dev/integration/mcp-apps.md +10 -0
  15. package/apps/runtime/dist/docs/dev/integration/mcp.md +10 -0
  16. package/apps/runtime/dist/docs/dev/integration/meta.json +20 -0
  17. package/apps/runtime/dist/docs/dev/integration/openui.md +181 -0
  18. package/apps/runtime/dist/docs/dev/integration/own-ui.md +10 -0
  19. package/apps/runtime/dist/docs/dev/integration/results.md +11 -0
  20. package/apps/runtime/dist/docs/dev/integration/wordpress.md +10 -0
  21. package/apps/runtime/dist/docs/dev/meta.json +1 -1
  22. package/apps/runtime/dist/docs/user/command-line.md +1 -1
  23. package/apps/runtime/dist/docs/user/install.md +1 -0
  24. package/apps/runtime/dist/engine/decide.js +3 -1
  25. package/apps/runtime/dist/engine/steps.js +2 -1
  26. package/apps/runtime/dist/film/project.js +221 -65
  27. package/apps/runtime/dist/film/step.js +16 -5
  28. package/apps/runtime/dist/models.js +43 -3
  29. package/apps/runtime/dist/routes/runs.js +14 -1
  30. package/apps/runtime/dist/tools/code.js +173 -0
  31. package/apps/runtime/dist/tools/connector.js +6 -0
  32. package/apps/runtime/dist/tools/index.js +14 -3
  33. package/apps/web/dist/assets/{AboutDialog-DYgXPagX.js → AboutDialog-ByZyHXDj.js} +1 -1
  34. package/apps/web/dist/assets/{ChangelogDialog-BXqBW8j3.js → ChangelogDialog-QHYP0jAv.js} +1 -1
  35. package/apps/web/dist/assets/{ChatView-CK2--tsE.js → ChatView-DupTtI8K.js} +1 -1
  36. package/apps/web/dist/assets/EditorPage-k6qouLeS.js +4 -0
  37. package/apps/web/dist/assets/{FlowPreview-CusP7ci9.js → FlowPreview-Dx8AjXvE.js} +1 -1
  38. package/apps/web/dist/assets/{Harness-CV9uYzbv.js → Harness-NvIb36TG.js} +1 -1
  39. package/apps/web/dist/assets/{NewWizardChannels-BakW5q6s.js → NewWizardChannels-B2YgtJlt.js} +1 -1
  40. package/apps/web/dist/assets/{NewWizardPage-BoA-LJuW.js → NewWizardPage-BgLY6SL6.js} +1 -1
  41. package/apps/web/dist/assets/{OnboardingPage-DaD1XnOe.js → OnboardingPage-CxhwefJU.js} +1 -1
  42. package/apps/web/dist/assets/{ProjectPage-BFgk1DEv.js → ProjectPage-6ceSSS7W.js} +1 -1
  43. package/apps/web/dist/assets/{PublicRunner-C9MwEvxy.js → PublicRunner-6lQvReDx.js} +1 -1
  44. package/apps/web/dist/assets/{ReadOnly-DC2Kp7l8.js → ReadOnly-CGNHlgUT.js} +1 -1
  45. package/apps/web/dist/assets/{RunDrawer-CY6NZSrI.js → RunDrawer-TVGNnWyx.js} +2 -2
  46. package/apps/web/dist/assets/{RunnerView-BT26XgcY.js → RunnerView-DInSK0VZ.js} +1 -1
  47. package/apps/web/dist/assets/SettingsPage-D3k5pXN5.js +1 -0
  48. package/apps/web/dist/assets/{SetupPage-DtFYAbwJ.js → SetupPage-Bj5frdzY.js} +1 -1
  49. package/apps/web/dist/assets/{SharePage-t2xwNS61.js → SharePage-hG7Z6SLy.js} +1 -1
  50. package/apps/web/dist/assets/changelog-CO9vKjV0.js +1 -0
  51. package/apps/web/dist/assets/{esm-DXeMu8Lb.js → esm-DVk3RJij.js} +1 -1
  52. package/apps/web/dist/assets/index-DfaoPjNW.css +1 -0
  53. package/apps/web/dist/assets/index-DlDsr9Lc.js +14 -0
  54. package/apps/web/dist/assets/oss-credits-X2JPiMOR.js +1 -0
  55. package/apps/web/dist/assets/{phone-CgrIS4_-.js → phone-CXXHXYnA.js} +1 -1
  56. package/apps/web/dist/assets/{settings-menu-2IMOOlLl.js → settings-menu-DXzZCIMx.js} +1 -1
  57. package/apps/web/dist/assets/{ui-dzGvc3xk.js → ui-DQ4L2npj.js} +2 -2
  58. package/apps/web/dist/assets/{upload-BSuTilFF.js → upload-BnYHIfu5.js} +1 -1
  59. package/apps/web/dist/assets/{lock-R_CfU0Xo.js → wrench-BrUJ7SV6.js} +1 -1
  60. package/apps/web/dist/index.html +3 -3
  61. package/apps/web/dist/install.sh +2 -2
  62. package/apps/web/dist/mcp/flow.html +4 -4
  63. package/node_modules/@engenty/wizards/dist/openui/Wizard.d.ts +20 -0
  64. package/node_modules/@engenty/wizards/dist/openui/Wizard.js +62 -0
  65. package/node_modules/@engenty/wizards/dist/openui/index.d.ts +9 -0
  66. package/node_modules/@engenty/wizards/dist/openui/index.js +9 -0
  67. package/node_modules/@engenty/wizards/dist/openui/message.d.ts +11 -0
  68. package/node_modules/@engenty/wizards/dist/openui/message.js +69 -0
  69. package/node_modules/@engenty/wizards/dist/openui/run.d.ts +8 -0
  70. package/node_modules/@engenty/wizards/dist/openui/run.js +137 -0
  71. package/node_modules/@engenty/wizards/dist/openui/server.d.ts +37 -0
  72. package/node_modules/@engenty/wizards/dist/openui/server.js +77 -0
  73. package/node_modules/@engenty/wizards/package.json +4 -1
  74. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +7 -1
  75. package/node_modules/@engenty-wizards/shared/dist/definition.js +16 -0
  76. package/node_modules/@engenty-wizards/shared/dist/fit.js +7 -2
  77. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +14 -0
  78. package/node_modules/@engenty-wizards/shared/dist/run.js +2 -0
  79. package/package.json +4 -3
  80. package/apps/web/dist/assets/EditorPage-jvcwyXbU.js +0 -3
  81. package/apps/web/dist/assets/SettingsPage-AFt9IDLt.js +0 -1
  82. package/apps/web/dist/assets/changelog-CATQFxCQ.js +0 -1
  83. package/apps/web/dist/assets/index-AfUa8xKD.css +0 -1
  84. package/apps/web/dist/assets/index-Drw7FMTQ.js +0 -14
  85. package/apps/web/dist/assets/oss-credits-cZ9NUaLZ.js +0 -1
@@ -133,7 +133,7 @@ type AgentStep = {
133
133
  // (fields name the keys later steps read). Use it where a model would only fetch and copy
134
134
  // data (free appointment times) — it answers in a second instead of half a minute.
135
135
  // String inputs are templates; a filled-in whole number or true/false is passed as one.
136
- tools: ("web_search"|"web_fetch"|"browser"|"sandbox"|"image"|"http"|"pages")[]
136
+ tools: ("web_search"|"web_fetch"|"browser"|"sandbox"|"image"|"http"|"pages"|"code")[]
137
137
  mcp?: string[] // ids of the project's MCP servers this step may use
138
138
  connections?: string[] // ids of wizard connections this step may read (mail → mail_search, mail_read, mail_save)
139
139
  output: { format: "text"|"markdown"|"json", fields?: { id, kind: "text"|"number"|"list"|"table"|"yesno"|"choice"|"score", description, columns?: string[], options?: string[] }[], schema?: Schema, surface?: SurfaceComponent[] | true }
@@ -156,6 +156,10 @@ type AgentStep = {
156
156
  // project_list (by categories), page_read / table_read / project_document (an item whole, by its path).
157
157
  // "pages" brings: page_read / page_write — Markdown pages of the space the wizard keeps for every run (notes, a log
158
158
  // that grows with append), shown to the admin under Space → Data; page_read also reads Knowledge by path.
159
+ // "code" brings: execute_typescript — the model writes one program that calls the step's other tools (not the browser) as
160
+ // functions: loops, Promise.all, filtering, sums; only its answer reaches the model. For many calls of one kind (fetch 30
161
+ // pages, read every row of a list, search a mailbox month by month). It needs other tools to call; a change the person
162
+ // must allow first, generate_image and scan_documents are never called from a program.
159
163
  // "browser" brings: browser_open/read/click/type/select/scroll/press/back/wait, browser_screenshot (the model looks at the page),
160
164
  // browser_run_fast (a decision model clicks through a page's mechanical part, where one is reached),
161
165
  // browser_request_credentials (the PERSON types a login into the page — the model never sees it; sign-ins can be remembered),
@@ -255,10 +259,14 @@ type FilmStep = {
255
259
  brief: string // a template: what the film is — story, look, length, what to use. The kit carries the craft.
256
260
  skills?: string[] // skill packages the client reads; default ["hyperframes"]. A workspace package: "skills/<name>" (has SKILL.md)
257
261
  kit?: string // workspace path of a STYLE KIT (.zip): a showreel made once with its rules (frame.md),
258
- // techniques (SHOWCASE.md), helpers and stills — the bar every film of this wizard reaches
262
+ // techniques (SHOWCASE.md), helpers and stills — the bar every film of this wizard reaches.
263
+ // A kit with base/ (index.9x16.html, index.16x9.html, index.1x1.html, PARTS.md, BRAND.md,
264
+ // scene templates) is the project a film starts from: the client composes instead of building
259
265
  inputs?: { [name]: string } // name (lowercase, -) → fieldId | steps.stepId: voice lines, pictures, uploads become files in
260
266
  // input/<name>/, text in input/<name>.md, json in input/<name>.json
261
267
  format?: "16:9" | "9:16" | "1:1"
268
+ from?: string // "steps.<filmStepId>": start from that earlier film's code and lay it out for this format —
269
+ // the second format of a film in minutes; made from scratch when that step did not run
262
270
  seconds?: number // about how long
263
271
  model?: "standard" | "high" // default standard
264
272
  effort?: Effort
@@ -267,7 +275,8 @@ type FilmStep = {
267
275
  // the AI client installed on the person's computer (Claude Code) writes the film as code in a folder of its own, with the
268
276
  // skills, the kit, the space's brand (colours, logo, facts) and the inputs; it looks at its frames and fixes them; the
269
277
  // runtime renders the MP4. It runs only where Claude Code is set up as the AI subscription — never on credits or in the
270
- // cloud — and takes 10–40 minutes. Sounds come from a fixed catalog (CC0); voice comes from a generate voice step before it.
278
+ // cloud — and takes 5–40 minutes (less with a base kit; what a base film makes from the space's brand is kept for the
279
+ // next films there). Sounds come from a fixed catalog (CC0); voice comes from a generate voice step before it.
271
280
 
272
281
  type ReviewStep = { type: "review", show: (stepId | "lists.<listId>")[], edit?: boolean, regenerate?: boolean }
273
282
  // shows earlier outputs; the person accepts, edits text outputs (edit), or asks for a new version with a note (regenerate)
@@ -56,12 +56,16 @@ export function validateChoiceAnswer(answer, ids) {
56
56
  }
57
57
  const RETRYABLE = new Set([429, 503, 529]);
58
58
  /**
59
- * The run's way to a decision model (credits, an AI Gateway key or a TypeSafe key), or null
60
- * when there is none — then the fast loop is not offered.
59
+ * The run's way to a hosted decision model (credits, an AI Gateway key or a TypeSafe key), or
60
+ * null when there is none — then the fast loop is not offered. Clef in the local Ollama is not
61
+ * taken: a page's elements are more than its 26 options, and on a laptop one step took over a
62
+ * minute (base M4, Ollama 0.35 and 0.40, 20 to 160 s a call, also with the elements asked in
63
+ * groups of 26) and stopped at a search box Jev fills, where the agent's own browser tools take
64
+ * seconds.
61
65
  */
62
66
  export async function classifierClient(signal) {
63
67
  const access = await systemOneAccess().catch(() => null);
64
- if (!access) {
68
+ if (!access || access.local) {
65
69
  return null;
66
70
  }
67
71
  return {
@@ -83,7 +87,8 @@ export async function classifierClient(signal) {
83
87
  return (await res.json());
84
88
  }
85
89
  if (!(RETRYABLE.has(res.status) && attempt < 2)) {
86
- throw new Error(`The decision model answered ${res.status}.`);
90
+ const why = (await res.text().catch(() => "")).trim().slice(0, 300);
91
+ throw new Error(`The decision model answered ${res.status}${why ? `: ${why}` : ""}`);
87
92
  }
88
93
  }
89
94
  },
@@ -29,7 +29,7 @@ ${line(command(), "show the status and a menu (start, stop, update…); runs the
29
29
  ${more("(needs a terminal)")}
30
30
  ${line(`${command()} start`, "start it in this terminal and open the studio (Ctrl-C stops it)")}
31
31
  ${line(`${command()} studio`, "open the studio, starting it in the background if needed")}
32
- ${line(`${command()} setup`, "set up your engenty account, an AI client, ffmpeg and start at login")}
32
+ ${line(`${command()} setup`, "set up your engenty account, an AI client, ffmpeg, a decision model and start at login")}
33
33
  ${line(`${command()} account`, "show your engenty account; add sign-in or sign-out to change it")}
34
34
  ${line(`${command()} open`, "sign this browser in to the running studio (--print: only print the link)")}
35
35
  ${line(`${command()} status`, "show what’s installed and running")}
@@ -71,7 +71,11 @@ function choices({ running, atLogin, release, account }) {
71
71
  hint: account.kind === "linked"
72
72
  ? `signed in as ${describeAccount(account)}`
73
73
  : describeAccount(account),
74
- }, { value: "status", label: "Status", hint: "what’s installed, and any problems" }, { value: "setup", label: "Setup", hint: "account, AI client, ffmpeg, start at login" }, {
74
+ }, { value: "status", label: "Status", hint: "what’s installed, and any problems" }, {
75
+ value: "setup",
76
+ label: "Setup",
77
+ hint: "account, AI client, ffmpeg, decision model, start at login",
78
+ }, {
75
79
  value: "uninstall",
76
80
  label: "Uninstall",
77
81
  hint: "remove it from this computer; asks about your data first",
@@ -0,0 +1,302 @@
1
+ import { spawn } from "node:child_process";
2
+ import { accessSync, constants, existsSync, mkdirSync, mkdtempSync, openSync, realpathSync, rmSync, } from "node:fs";
3
+ import { statfs } from "node:fs/promises";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { setTimeout as sleep } from "node:timers/promises";
7
+ import { decisionModelIn, ollamaRoot } from "../decision-model.js";
8
+ import { runLogged } from "./exec.js";
9
+ import { findOnPath } from "./machine.js";
10
+ /** The first Ollama that answers decision questions at /v1/systemone. */
11
+ const DECISIONS_SINCE = [0, 35, 0];
12
+ /** What Clef-flash takes on disk, and about as much memory while it answers, in GB. */
13
+ export const DECISION_MODEL_GB = 11;
14
+ /** The Ollama app on a Mac: its server and its command are inside it. */
15
+ const MAC_APPS = ["/Applications/Ollama.app", join(homedir(), "Applications", "Ollama.app")];
16
+ const MAC_DOWNLOAD = "https://ollama.com/download/Ollama-darwin.zip";
17
+ /** Ollama's own installer on Linux: it puts the server in a systemd service, with sudo. */
18
+ const LINUX_SCRIPT = "https://ollama.com/install.sh";
19
+ /**
20
+ * What that installer needs and a fresh Debian lacks: zstd, to unpack Ollama. Installed first
21
+ * with the system's package manager, under the same password.
22
+ */
23
+ const LINUX_ZSTD = [
24
+ "if ! command -v zstd >/dev/null 2>&1; then",
25
+ " if command -v apt-get >/dev/null 2>&1; then sudo apt-get update -qq && sudo apt-get install -y -qq zstd",
26
+ " elif command -v dnf >/dev/null 2>&1; then sudo dnf install -y -q zstd",
27
+ " elif command -v pacman >/dev/null 2>&1; then sudo pacman -S --noconfirm --needed zstd",
28
+ " elif command -v zypper >/dev/null 2>&1; then sudo zypper --non-interactive install zstd",
29
+ " fi",
30
+ "fi",
31
+ ].join("\n");
32
+ /** Where Ollama answers: OLLAMA_URL as the runtime takes it (…/v1), without the /v1. */
33
+ export function ollamaAddress(env = process.env) {
34
+ return ollamaRoot(env.OLLAMA_URL || "http://127.0.0.1:11434/v1");
35
+ }
36
+ function executable(file) {
37
+ try {
38
+ accessSync(file, constants.X_OK);
39
+ return true;
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ /** The `ollama` command: on the PATH, or inside the app on a Mac that never linked it. */
46
+ export function findOllama(env = process.env) {
47
+ const onPath = findOnPath("ollama", env.PATH ?? "");
48
+ if (onPath || process.platform !== "darwin") {
49
+ return onPath;
50
+ }
51
+ return (MAC_APPS.map((app) => join(app, "Contents", "Resources", "ollama")).find(executable) ?? null);
52
+ }
53
+ async function getJson(url) {
54
+ try {
55
+ const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
56
+ return res.ok ? (await res.json()) : null;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ export async function ollamaState(env = process.env) {
63
+ const root = ollamaAddress(env);
64
+ const version = (await getJson(`${root}/api/version`))?.version ?? null;
65
+ const tags = version ? await getJson(`${root}/api/tags`) : null;
66
+ return {
67
+ path: findOllama(env),
68
+ version,
69
+ model: decisionModelIn((tags?.models ?? []).map((m) => m.name)),
70
+ };
71
+ }
72
+ /** Whether this Ollama is new enough to answer decision questions. */
73
+ export function servesDecisions(version) {
74
+ const parts = version.split(/[.-]/).map((n) => Number.parseInt(n, 10) || 0);
75
+ for (const [i, wanted] of DECISIONS_SINCE.entries()) {
76
+ const have = parts[i] ?? 0;
77
+ if (have !== wanted) {
78
+ return have > wanted;
79
+ }
80
+ }
81
+ return true;
82
+ }
83
+ /** Free space where Ollama keeps its models (the home folder), in GB. */
84
+ export async function freeGb(dir = homedir()) {
85
+ try {
86
+ const stats = await statfs(dir);
87
+ return Math.floor((stats.bavail * stats.bsize) / 2 ** 30);
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ /** The app an `ollama` command belongs to on a Mac; null for one from Homebrew or elsewhere. */
94
+ function macApp(path) {
95
+ if (process.platform !== "darwin") {
96
+ return null;
97
+ }
98
+ if (!path) {
99
+ return MAC_APPS.find((app) => existsSync(app)) ?? null;
100
+ }
101
+ try {
102
+ const real = realpathSync(path);
103
+ const at = real.indexOf(".app/");
104
+ return at > 0 ? real.slice(0, at + 4) : null;
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+ /**
111
+ * Installs Ollama. On a Mac its app goes into Applications (~/Applications where that is not
112
+ * writable) and asks for no password; on Linux Ollama's own script runs in this terminal and
113
+ * asks for one, as it installs a system service.
114
+ */
115
+ export async function installOllama(paths, onLine) {
116
+ const logFile = join(paths.logs, "install.log");
117
+ if (process.platform === "darwin") {
118
+ let target = "/Applications";
119
+ try {
120
+ accessSync(target, constants.W_OK);
121
+ }
122
+ catch {
123
+ target = join(homedir(), "Applications");
124
+ mkdirSync(target, { recursive: true });
125
+ }
126
+ const tmp = mkdtempSync(join(tmpdir(), "ollama-"));
127
+ try {
128
+ const zip = join(tmp, "Ollama.zip");
129
+ const download = await runLogged("curl", ["-fsSL", MAC_DOWNLOAD, "-o", zip], {
130
+ logFile,
131
+ onLine,
132
+ });
133
+ if (download.code !== 0) {
134
+ return { ok: false, message: `The download failed. ${lastLine(download.tail)}` };
135
+ }
136
+ const unpack = await runLogged("ditto", ["-x", "-k", zip, target], { logFile, onLine });
137
+ if (unpack.code !== 0) {
138
+ return { ok: false, message: `It could not be unpacked. ${lastLine(unpack.tail)}` };
139
+ }
140
+ return { ok: true, message: join(target, "Ollama.app") };
141
+ }
142
+ finally {
143
+ rmSync(tmp, { recursive: true, force: true });
144
+ }
145
+ }
146
+ if (process.platform === "linux") {
147
+ const code = await new Promise((resolve) => {
148
+ const child = spawn("sh", ["-c", `${LINUX_ZSTD}\ncurl -fsSL ${LINUX_SCRIPT} | sh`], {
149
+ stdio: "inherit",
150
+ });
151
+ child.on("error", () => resolve(127));
152
+ child.on("close", (status) => resolve(status ?? 1));
153
+ });
154
+ return code === 0
155
+ ? { ok: true, message: findOllama() ?? "ollama" }
156
+ : { ok: false, message: `Ollama’s installer stopped (exit ${code}).` };
157
+ }
158
+ return { ok: false, message: "Ollama is installed by this setup on macOS and Linux only." };
159
+ }
160
+ const lastLine = (text) => text.split("\n").pop() ?? "";
161
+ /**
162
+ * Whether the Ollama app asks for the Mac's password when it starts: the first time, it wants to
163
+ * link its `ollama` command into /usr/local/bin, and its server waits for the answer (cancel
164
+ * works too). Not once that link points into the app.
165
+ */
166
+ export function appAsksForPassword(path) {
167
+ const app = macApp(path);
168
+ if (!app) {
169
+ return false;
170
+ }
171
+ try {
172
+ return realpathSync("/usr/local/bin/ollama") !== join(app, "Contents", "Resources", "ollama");
173
+ }
174
+ catch {
175
+ return true;
176
+ }
177
+ }
178
+ /**
179
+ * Starts Ollama's server where it does not run: the app on a Mac (it then starts at login by
180
+ * itself), Homebrew's service, else `ollama serve` in the background. Its version once it
181
+ * answers; null where it does not in time — three minutes for the app, which may wait for a
182
+ * password, half a minute otherwise.
183
+ */
184
+ export async function startOllama(path, paths) {
185
+ const logFile = join(paths.logs, "install.log");
186
+ const app = macApp(path);
187
+ const brew = findOnPath("brew", process.env.PATH ?? "");
188
+ let real = path ?? "";
189
+ try {
190
+ real = path ? realpathSync(path) : "";
191
+ }
192
+ catch {
193
+ // a dangling link: started as it is
194
+ }
195
+ if (app) {
196
+ await runLogged("open", ["-g", app], { logFile });
197
+ }
198
+ else if (brew && real.includes("/Cellar/ollama/")) {
199
+ await runLogged(brew, ["services", "start", "ollama"], { logFile });
200
+ }
201
+ else if (path) {
202
+ mkdirSync(paths.logs, { recursive: true });
203
+ const out = openSync(join(paths.logs, "ollama.log"), "a");
204
+ spawn(path, ["serve"], { detached: true, stdio: ["ignore", out, out] }).unref();
205
+ }
206
+ else {
207
+ return null;
208
+ }
209
+ const deadline = Date.now() + (app ? 180_000 : 30_000);
210
+ while (Date.now() < deadline) {
211
+ const { version } = await ollamaState();
212
+ if (version) {
213
+ return version;
214
+ }
215
+ await sleep(500);
216
+ }
217
+ return null;
218
+ }
219
+ /**
220
+ * Whether Ollama runs the model: one question it has to answer. Null when it does; otherwise
221
+ * Ollama's reason, such as a newer Ollama that no longer runs a model in the format it was pulled
222
+ * in. The model is loaded for it, which takes some seconds the first time.
223
+ */
224
+ export async function decisionModelProblem(model, env = process.env) {
225
+ try {
226
+ const res = await fetch(`${ollamaAddress(env)}/v1/systemone`, {
227
+ method: "POST",
228
+ headers: { "content-type": "application/json" },
229
+ body: JSON.stringify({
230
+ model,
231
+ state: "The setup checks the decision model.",
232
+ questions: { check: { type: "noul", instructions: "Is this a check?" } },
233
+ }),
234
+ signal: AbortSignal.timeout(180_000),
235
+ });
236
+ if (res.ok) {
237
+ return null;
238
+ }
239
+ const text = (await res.text()).trim();
240
+ try {
241
+ return JSON.parse(text).error ?? text;
242
+ }
243
+ catch {
244
+ return text || `Ollama answered ${res.status}.`;
245
+ }
246
+ }
247
+ catch (error) {
248
+ return error.message;
249
+ }
250
+ }
251
+ /**
252
+ * Pulls a model into the running Ollama; `onProgress` hears the bytes so far and in all as they
253
+ * come. Rejects with Ollama's own reason.
254
+ */
255
+ export async function pullModel(model, onProgress, env = process.env) {
256
+ const res = await fetch(`${ollamaAddress(env)}/api/pull`, {
257
+ method: "POST",
258
+ headers: { "content-type": "application/json" },
259
+ body: JSON.stringify({ model, stream: true }),
260
+ });
261
+ if (!(res.ok && res.body)) {
262
+ throw new Error(`Ollama answered ${res.status}. ${await res.text().catch(() => "")}`.trim());
263
+ }
264
+ // Each part of the model reports on its own; the sum is the download.
265
+ const parts = new Map();
266
+ const reader = res.body.getReader();
267
+ const decoder = new TextDecoder();
268
+ let pending = "";
269
+ let finished = false;
270
+ for (;;) {
271
+ const { done, value } = await reader.read();
272
+ if (done) {
273
+ break;
274
+ }
275
+ pending += decoder.decode(value, { stream: true });
276
+ const lines = pending.split("\n");
277
+ pending = lines.pop() ?? "";
278
+ for (const line of lines) {
279
+ if (!line.trim()) {
280
+ continue;
281
+ }
282
+ const event = JSON.parse(line);
283
+ if (event.error) {
284
+ throw new Error(event.error);
285
+ }
286
+ if (event.digest && event.total) {
287
+ parts.set(event.digest, { total: event.total, completed: event.completed ?? 0 });
288
+ let sum = 0;
289
+ let all = 0;
290
+ for (const part of parts.values()) {
291
+ sum += part.completed;
292
+ all += part.total;
293
+ }
294
+ onProgress(sum, all);
295
+ }
296
+ finished ||= event.status === "success";
297
+ }
298
+ }
299
+ if (!finished) {
300
+ throw new Error("The download broke off.");
301
+ }
302
+ }
@@ -1,7 +1,8 @@
1
1
  import { appendFileSync, existsSync, readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
2
+ import { homedir, totalmem } from "node:os";
3
3
  import { delimiter, join } from "node:path";
4
4
  import * as p from "@clack/prompts";
5
+ import { LOCAL_DECISION_MODEL } from "../decision-model.js";
5
6
  import { accountStep } from "./account.js";
6
7
  import { autostartForStatus, setAutostart } from "./autostart.js";
7
8
  import { installClient, installCommand } from "./clients.js";
@@ -9,6 +10,7 @@ import { appStates, connectApps } from "./connect.js";
9
10
  import { runLogged } from "./exec.js";
10
11
  import { command, installedByScript, isCheckout, packageVersion, readInstallNote, writeInstallNote, } from "./home.js";
11
12
  import { CLIENTS, detectClients, findChrome, findFfmpeg, findOnPath, suggestedClient, vendorApp, } from "./machine.js";
13
+ import { appAsksForPassword, DECISION_MODEL_GB, decisionModelProblem, freeGb, installOllama, ollamaState, pullModel, servesDecisions, startOllama, } from "./ollama.js";
12
14
  import { runningRuntime, settings } from "./start.js";
13
15
  import { badge, busy, cmd, cyan, dim, tilde } from "./ui.js";
14
16
  /** The studio as an app of its own: Chrome (or Edge) installs it from the page. */
@@ -123,6 +125,101 @@ async function toolsStep(interactive) {
123
125
  }
124
126
  }
125
127
  }
128
+ const gb = (bytes) => (bytes / 2 ** 30).toFixed(1);
129
+ /**
130
+ * A decision model on this computer: Ollama with Cloudflare's Clef-flash. It answers a wizard's
131
+ * decisions (which branch, which option) without a key or credits. Asked, never assumed: it is a
132
+ * download of about 11 GB.
133
+ */
134
+ async function decisionModelStep(paths, interactive) {
135
+ const state = await ollamaState();
136
+ if (state.version && !servesDecisions(state.version)) {
137
+ p.log.warn(`Ollama ${state.version} is too old for a decision model (it needs 0.35 or newer).\n${dim(`Update Ollama, then run ${cmd("setup")} again.`)}`);
138
+ return;
139
+ }
140
+ // Pulled is not enough: a newer Ollama may no longer run a model pulled in an older format.
141
+ let broken = null;
142
+ if (state.model) {
143
+ const check = busy(`Checking ${state.model}…`);
144
+ broken = await decisionModelProblem(state.model);
145
+ if (!broken) {
146
+ check.stop(`Decision model: ${state.model} ${dim(`in Ollama ${state.version}`)}`);
147
+ return;
148
+ }
149
+ check.fail(`${state.model} doesn’t run in Ollama ${state.version}: ${broken}`);
150
+ }
151
+ const model = state.model ?? LOCAL_DECISION_MODEL;
152
+ const what = state.path ? `Clef-flash into Ollama` : "Ollama with Clef-flash";
153
+ if (!interactive) {
154
+ p.log.info(dim(broken
155
+ ? `To download it again: ${cyan(`ollama pull ${model}`)}`
156
+ : `Optional: a decision model on this computer (${what}, about ${DECISION_MODEL_GB} GB). To add it: ${cmd("setup")}`));
157
+ return;
158
+ }
159
+ const free = await freeGb();
160
+ if (free !== null && free < DECISION_MODEL_GB + 2) {
161
+ p.log.warn(`There isn’t room for a decision model: it needs about ${DECISION_MODEL_GB + 2} GB and ${free} GB are free.`);
162
+ return;
163
+ }
164
+ const memory = Math.round(totalmem() / 2 ** 30);
165
+ const answer = await p.confirm({
166
+ message: broken
167
+ ? `Download ${model} again, in the format this Ollama runs? About ${DECISION_MODEL_GB} GB.`
168
+ : `Install a decision model on this computer? ${what} makes the wizards’ decisions (which branch, which option) here, without a key or credits. About ${DECISION_MODEL_GB} GB${memory < 16 ? `; it needs 16 GB of memory and this computer has ${memory}` : ""}.`,
169
+ initialValue: Boolean(broken),
170
+ });
171
+ if (cancelled(answer)) {
172
+ return "cancelled";
173
+ }
174
+ if (!answer) {
175
+ p.log.info(dim(`To add it later: ${cmd("setup")}`));
176
+ return;
177
+ }
178
+ let { path: ollama, version } = state;
179
+ if (!ollama) {
180
+ // On Linux Ollama's installer writes to this terminal itself, and may ask for a password.
181
+ const task = process.platform === "linux" ? null : p.taskLog({ title: "Installing Ollama", limit: 6 });
182
+ if (!task) {
183
+ p.log.info("Ollama’s own installer runs now and asks for your password.");
184
+ }
185
+ const outcome = await installOllama(paths, (line) => task?.message(line));
186
+ if (!outcome.ok) {
187
+ const text = `Ollama didn’t install. ${outcome.message}`;
188
+ task ? task.error(text) : p.log.error(text);
189
+ return;
190
+ }
191
+ const installed = `Ollama installed ${dim(tilde(outcome.message))}`;
192
+ task ? task.success(installed) : p.log.success(installed);
193
+ // Linux's installer starts it as a service already.
194
+ ({ path: ollama, version } = await ollamaState());
195
+ }
196
+ if (!version) {
197
+ if (appAsksForPassword(ollama)) {
198
+ p.log.info("Ollama starts now and asks once for your Mac’s password, to put its `ollama` command in /usr/local/bin. Enter it, or cancel: Ollama runs either way.");
199
+ }
200
+ const spinner = p.spinner();
201
+ spinner.start("Waiting for Ollama");
202
+ version = await startOllama(ollama, paths);
203
+ if (!version) {
204
+ spinner.error(`Ollama isn’t answering. Start it, then run ${cmd("setup")} again.`);
205
+ return;
206
+ }
207
+ if (!servesDecisions(version)) {
208
+ spinner.error(`Ollama ${version} is too old for a decision model (it needs 0.35 or newer).`);
209
+ return;
210
+ }
211
+ spinner.stop(`Ollama ${version} is running`);
212
+ }
213
+ const spinner = p.spinner();
214
+ spinner.start(`Downloading ${model}`);
215
+ try {
216
+ await pullModel(model, (done, total) => spinner.message(`Downloading ${model} ${gb(done)} of ${gb(total)} GB`));
217
+ spinner.stop(`Decision model: ${model} ${dim(`in Ollama ${version}`)}`);
218
+ }
219
+ catch (error) {
220
+ spinner.error(`${model} didn’t download. ${error.message}\n${dim(`To try again: ${cyan(`ollama pull ${model}`)}`)}`);
221
+ }
222
+ }
126
223
  /**
127
224
  * Starting at login: on a Mac a LaunchAgent starts the runtime, on Linux a systemd user
128
225
  * service does. Asked, never assumed; `autostart off` undoes it.
@@ -287,7 +384,7 @@ async function howToUse(paths) {
287
384
  }
288
385
  /**
289
386
  * The guided setup: the account this computer joins, then what this machine has, what is
290
- * missing, and what to install — an AI client to think with, ffmpeg, starting at login. It
387
+ * missing, and what to install — an AI client to think with, ffmpeg, a decision model, starting at login. It
291
388
  * starts nothing; it ends with what runs and how to start, stop and check it. Returns false
292
389
  * when it was stopped.
293
390
  */
@@ -308,6 +405,9 @@ export async function setup(paths, options) {
308
405
  if ((await toolsStep(interactive)) === "cancelled") {
309
406
  return false;
310
407
  }
408
+ if ((await decisionModelStep(paths, interactive)) === "cancelled") {
409
+ return false;
410
+ }
311
411
  if ((await autostartStep(paths, interactive)) === "cancelled") {
312
412
  return false;
313
413
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A decision model on the person's own machine: Cloudflare's Clef-flash (open weights, Apache
3
+ * 2.0), which Ollama 0.35 and newer serves at /v1/systemone with the same questions and answers
4
+ * as TypeSafe's Jev. The setup offers to pull it; the runtime asks it where no hosted decision
5
+ * model is set up.
6
+ */
7
+ /** The model the setup pulls. Clef, its larger sibling, is taken as well where it is there. */
8
+ export const LOCAL_DECISION_MODEL = "clef-flash";
9
+ /** Ollama's own address from the one the runtime uses for its OpenAI-style API (…/v1). */
10
+ export function ollamaRoot(url) {
11
+ return url.trim().replace(/\/+$/, "").replace(/\/v1$/, "");
12
+ }
13
+ /** The decision model among the models Ollama lists: Clef-flash, else Clef. */
14
+ export function decisionModelIn(names) {
15
+ const named = (base) => names.find((n) => n === base || n.startsWith(`${base}:`));
16
+ return named(LOCAL_DECISION_MODEL) ?? named("clef") ?? null;
17
+ }
18
+ /** The decision model the Ollama at `url` holds; null where it does not run or has none. */
19
+ export async function ollamaDecisionModel(url, timeoutMs = 1500) {
20
+ try {
21
+ const res = await fetch(`${ollamaRoot(url)}/api/tags`, {
22
+ signal: AbortSignal.timeout(timeoutMs),
23
+ });
24
+ if (!res.ok) {
25
+ return null;
26
+ }
27
+ const { models } = (await res.json());
28
+ return decisionModelIn((models ?? []).map((m) => m.name));
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Developer
3
3
  description: Extend engenty wizards with plugins – tools for AI steps, routes with their own tables, pages in the studio.
4
- version: 0.5.0
4
+ version: 0.7.3
5
5
  ---
6
6
 
7
7
  You extend engenty wizards with **plugins**. A plugin adds to a runtime without changing its
@@ -21,6 +21,11 @@ code, and the runtime loads it from its files while it runs.
21
21
  A wizard is JSON, and it travels as a `.wizard` file. [The wizard format](./wizard-format.md)
22
22
  describes the format, the JSON Schema for your editor and `wizards validate`.
23
23
 
24
+ ## Wizards in other systems
25
+
26
+ [Integration](./integration/index.md) shows how a published wizard reaches other systems: people
27
+ over a link, a website or a channel, AI apps over MCP, and your own code.
28
+
24
29
  ## What you need
25
30
 
26
31
  - engenty wizards on your computer, or a checkout of
@@ -0,0 +1,11 @@
1
+ ---
2
+ title: Channels
3
+ description: Run a wizard as a chat, a conversation or a call, and over WhatsApp, SMS or the phone.
4
+ version: 0.7.4
5
+ ---
6
+
7
+ This page is not written yet.
8
+
9
+ Until then, [Channels](../../user/test-and-share.md#channels) in the user guide describes how a
10
+ wizard runs as a chat, a conversation or a call, and over WhatsApp, SMS or the phone. A plugin
11
+ adds a channel of its own with a [runner](../plugins/runners.md).
@@ -0,0 +1,10 @@
1
+ ---
2
+ title: Claude Code plugin
3
+ description: Build and use wizards from Claude Code with the /wizard command and its skills.
4
+ version: 0.7.4
5
+ ---
6
+
7
+ This page is not written yet.
8
+
9
+ Until then, [The Claude Code plugin](../../user/ai-apps.md#the-claude-code-plugin) in the user
10
+ guide shows how you install it.
@@ -0,0 +1,10 @@
1
+ ---
2
+ title: Embed
3
+ description: Put a published wizard on your website with one script tag.
4
+ version: 0.7.4
5
+ ---
6
+
7
+ This page is not written yet.
8
+
9
+ Until then, [Embed in a website](../../user/test-and-share.md#embed-in-a-website) in the user
10
+ guide shows the script tag and its options.