@engenty/wizards-runtime 0.7.7 → 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 (51) hide show
  1. package/apps/runtime/dist/authoring/guide.js +8 -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/user/command-line.md +1 -1
  9. package/apps/runtime/dist/docs/user/install.md +1 -0
  10. package/apps/runtime/dist/engine/decide.js +3 -1
  11. package/apps/runtime/dist/film/project.js +221 -65
  12. package/apps/runtime/dist/film/step.js +16 -5
  13. package/apps/runtime/dist/models.js +43 -3
  14. package/apps/runtime/dist/tools/code.js +53 -13
  15. package/apps/web/dist/assets/{AboutDialog-BP8-Ugxf.js → AboutDialog-ByZyHXDj.js} +1 -1
  16. package/apps/web/dist/assets/{ChangelogDialog-CD6CQNW9.js → ChangelogDialog-QHYP0jAv.js} +1 -1
  17. package/apps/web/dist/assets/{ChatView-4T0QarEr.js → ChatView-DupTtI8K.js} +1 -1
  18. package/apps/web/dist/assets/EditorPage-k6qouLeS.js +4 -0
  19. package/apps/web/dist/assets/{FlowPreview-Dj2gAfuf.js → FlowPreview-Dx8AjXvE.js} +1 -1
  20. package/apps/web/dist/assets/{Harness-SLbsntQF.js → Harness-NvIb36TG.js} +1 -1
  21. package/apps/web/dist/assets/{NewWizardChannels-CsDMlji7.js → NewWizardChannels-B2YgtJlt.js} +1 -1
  22. package/apps/web/dist/assets/{NewWizardPage-_bFYjlgh.js → NewWizardPage-BgLY6SL6.js} +1 -1
  23. package/apps/web/dist/assets/{OnboardingPage-DObQeCDg.js → OnboardingPage-CxhwefJU.js} +1 -1
  24. package/apps/web/dist/assets/{ProjectPage-DX-oNLWy.js → ProjectPage-6ceSSS7W.js} +1 -1
  25. package/apps/web/dist/assets/{PublicRunner-Bn9rVNEb.js → PublicRunner-6lQvReDx.js} +1 -1
  26. package/apps/web/dist/assets/{ReadOnly-CK0piLUV.js → ReadOnly-CGNHlgUT.js} +1 -1
  27. package/apps/web/dist/assets/{RunDrawer-CVMTLse_.js → RunDrawer-TVGNnWyx.js} +2 -2
  28. package/apps/web/dist/assets/{RunnerView-Cjhuu9ua.js → RunnerView-DInSK0VZ.js} +1 -1
  29. package/apps/web/dist/assets/SettingsPage-D3k5pXN5.js +1 -0
  30. package/apps/web/dist/assets/{SetupPage-D9jlhgiP.js → SetupPage-Bj5frdzY.js} +1 -1
  31. package/apps/web/dist/assets/{SharePage-Cc3Opj2F.js → SharePage-hG7Z6SLy.js} +1 -1
  32. package/apps/web/dist/assets/changelog-CO9vKjV0.js +1 -0
  33. package/apps/web/dist/assets/{esm-CfnFE_jE.js → esm-DVk3RJij.js} +1 -1
  34. package/apps/web/dist/assets/index-DfaoPjNW.css +1 -0
  35. package/apps/web/dist/assets/index-DlDsr9Lc.js +14 -0
  36. package/apps/web/dist/assets/{phone-BWOB67tX.js → phone-CXXHXYnA.js} +1 -1
  37. package/apps/web/dist/assets/{settings-menu-CEQlVs1O.js → settings-menu-DXzZCIMx.js} +1 -1
  38. package/apps/web/dist/assets/{ui-BmR_4eL2.js → ui-DQ4L2npj.js} +2 -2
  39. package/apps/web/dist/assets/{upload-pBKn2EFp.js → upload-BnYHIfu5.js} +1 -1
  40. package/apps/web/dist/assets/{lock-DAvRJY4B.js → wrench-BrUJ7SV6.js} +1 -1
  41. package/apps/web/dist/index.html +3 -3
  42. package/apps/web/dist/install.sh +2 -2
  43. package/apps/web/dist/mcp/flow.html +4 -4
  44. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +3 -0
  45. package/node_modules/@engenty-wizards/shared/dist/definition.js +15 -0
  46. package/package.json +1 -1
  47. package/apps/web/dist/assets/EditorPage-CWdV7Xdn.js +0 -3
  48. package/apps/web/dist/assets/SettingsPage-DTzuiSAj.js +0 -1
  49. package/apps/web/dist/assets/changelog-CJNkd1_L.js +0 -1
  50. package/apps/web/dist/assets/index-AfUa8xKD.css +0 -1
  51. package/apps/web/dist/assets/index-B5F1_qA_.js +0 -14
@@ -159,7 +159,7 @@ type AgentStep = {
159
159
  // "code" brings: execute_typescript — the model writes one program that calls the step's other tools (not the browser) as
160
160
  // functions: loops, Promise.all, filtering, sums; only its answer reaches the model. For many calls of one kind (fetch 30
161
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 is never made from a program.
162
+ // must allow first, generate_image and scan_documents are never called from a program.
163
163
  // "browser" brings: browser_open/read/click/type/select/scroll/press/back/wait, browser_screenshot (the model looks at the page),
164
164
  // browser_run_fast (a decision model clicks through a page's mechanical part, where one is reached),
165
165
  // browser_request_credentials (the PERSON types a login into the page — the model never sees it; sign-ins can be remembered),
@@ -259,10 +259,14 @@ type FilmStep = {
259
259
  brief: string // a template: what the film is — story, look, length, what to use. The kit carries the craft.
260
260
  skills?: string[] // skill packages the client reads; default ["hyperframes"]. A workspace package: "skills/<name>" (has SKILL.md)
261
261
  kit?: string // workspace path of a STYLE KIT (.zip): a showreel made once with its rules (frame.md),
262
- // 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
263
265
  inputs?: { [name]: string } // name (lowercase, -) → fieldId | steps.stepId: voice lines, pictures, uploads become files in
264
266
  // input/<name>/, text in input/<name>.md, json in input/<name>.json
265
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
266
270
  seconds?: number // about how long
267
271
  model?: "standard" | "high" // default standard
268
272
  effort?: Effort
@@ -271,7 +275,8 @@ type FilmStep = {
271
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
272
276
  // skills, the kit, the space's brand (colours, logo, facts) and the inputs; it looks at its frames and fixes them; the
273
277
  // runtime renders the MP4. It runs only where Claude Code is set up as the AI subscription — never on credits or in the
274
- // 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.
275
280
 
276
281
  type ReviewStep = { type: "review", show: (stepId | "lists.<listId>")[], edit?: boolean, regenerate?: boolean }
277
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
+ }
@@ -11,7 +11,7 @@ version: 0.5.0
11
11
  | `wizards` | Shows what is running, and a menu. While it runs, you can open the studio, restart or stop it; while it doesn’t, “Start in the background” or “Start in this terminal”. Then “Update”, which restarts it into the new version, “Start at login”, “Connect AI apps”, “Account”, “Status”, “Setup”, “Uninstall” and “Help”. The first time, the setup runs first. It needs a terminal |
12
12
  | `wizards start` | Starts it and opens the studio. It runs in that terminal, and Ctrl-C stops it. The first time, the setup runs first |
13
13
  | `wizards studio` | Opens the studio in your browser. If it isn’t running, it starts in the background first |
14
- | `wizards setup` | Runs the guided setup again: your account, an AI client, the browser and ffmpeg, start at login, your AI apps. `--local` leaves the account out ([Local only](./local-only.md)) |
14
+ | `wizards setup` | Runs the guided setup again: your account, an AI client, the browser and ffmpeg, a decision model, start at login, your AI apps. `--local` leaves the account out ([Local only](./local-only.md)) |
15
15
  | `wizards account` | Shows the account this computer is signed in to: who, which team, the credits. `sign-in` shows a link and a code and opens your browser; `sign-out` disconnects this computer |
16
16
  | `wizards open` | Signs this browser in to the running studio. `--print` only prints the link; it works once, within a minute |
17
17
  | `wizards status` | Shows what is installed and running, with the browser it uses for PDF, PNG and websites |
@@ -32,6 +32,7 @@ have is left alone), installs engenty wizards into `~/.engenty/wizards` (about 6
32
32
  | Your engenty account | Asks “Sign in or create your account now?”. If you say yes, it shows a link and a code and opens your browser for the sign-in at account.engenty.ai; check that the page shows the same code (if you have no account yet, the page creates one with your invitation code, or puts you on the waitlist). While it waits, Enter opens the page again and Esc goes on without an account. Once you are signed in, what you publish on this computer runs in the cloud too. If you say no, `wizards account sign-in` does it any time. Setting it up again with `--local` signs an account out. `--local` leaves the account out altogether: see [Local only](./local-only.md) |
33
33
  | An AI client | Looks for Codex, Claude Code, Antigravity and Cursor Agent and offers to install the ones that are missing. It puts first the one whose app (ChatGPT, Claude, Antigravity, Cursor) is on your Mac, as you most likely have that subscription already. You can also use your account’s credits, an API key, [Ollama](https://ollama.com) or, on a Mac with Apple silicon and macOS 26, Apple Intelligence: the studio’s [first page](./first-start.md) offers them |
34
34
  | A browser and ffmpeg | Names the browser for PDF and PNG exports and steps on websites: the first of Chrome, Edge and Chromium it finds (on a Mac also Brave). engenty wizards uses that one; [`CHROME_PATH`](./command-line.md#settings) picks another. It also says whether ffmpeg is there for MP4 videos of animated widgets, and with Homebrew it offers to install it |
35
+ | A decision model (optional) | Offers to install [Ollama](https://ollama.com) if it isn’t there and to download Cloudflare’s Clef-flash into it (about 11 GB). The wizards then make their decisions, such as which branch to take, on your computer, without a key or credits. On a Mac the Ollama app goes into Applications and, when it first starts, asks once for your Mac’s password to set up its `ollama` command (cancel works too); on Linux Ollama’s own installer runs, installs `zstd` if it is missing, and asks for your password |
35
36
  | Start at login (optional) | engenty wizards starts in the background when you log in, so links and AI apps can always reach it. It is a LaunchAgent on a Mac and a systemd user service on Linux |
36
37
  | Your AI apps (optional) | Offers to add your wizards to the [AI apps](./ai-apps.md) it finds, such as Claude Desktop, Cursor or Codex |
37
38
  | The command | If `~/.local/bin` is not on your PATH, it offers to add it in your shell’s settings file |
@@ -32,7 +32,9 @@ async function askSystemOne(input, signal) {
32
32
  return readSystemOne(input.questions, await res.json());
33
33
  }
34
34
  if (!(RETRYABLE.has(res.status) && attempt < 2)) {
35
- console.warn(`[decide] the decision model answered ${res.status}`);
35
+ const why = (await res.text().catch(() => "")).trim().slice(0, 300);
36
+ access.refused?.(res.status, why);
37
+ console.warn(`[decide] the decision model answered ${res.status}${why ? `: ${why}` : ""}`);
36
38
  return {};
37
39
  }
38
40
  }