@engenty/wizards-runtime 0.7.7 → 0.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) 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/clients.js +118 -0
  8. package/apps/runtime/dist/db/client.js +37 -3
  9. package/apps/runtime/dist/db/migrations/tenant/0017_run_client.sql +1 -0
  10. package/apps/runtime/dist/db/migrations/tenant/meta/0017_snapshot.json +3333 -0
  11. package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
  12. package/apps/runtime/dist/db/schema.js +2 -0
  13. package/apps/runtime/dist/decision-model.js +33 -0
  14. package/apps/runtime/dist/docs/dev/api.md +39 -0
  15. package/apps/runtime/dist/docs/dev/index.md +1 -0
  16. package/apps/runtime/dist/docs/dev/integration/openui.md +5 -3
  17. package/apps/runtime/dist/docs/dev/meta.json +1 -1
  18. package/apps/runtime/dist/docs/user/ai-apps.md +14 -3
  19. package/apps/runtime/dist/docs/user/command-line.md +1 -1
  20. package/apps/runtime/dist/docs/user/install.md +1 -0
  21. package/apps/runtime/dist/engine/decide.js +3 -1
  22. package/apps/runtime/dist/engine/runner.js +1 -0
  23. package/apps/runtime/dist/film/project.js +221 -65
  24. package/apps/runtime/dist/film/step.js +16 -5
  25. package/apps/runtime/dist/index.js +13 -4
  26. package/apps/runtime/dist/models.js +43 -3
  27. package/apps/runtime/dist/plugin.js +24 -8
  28. package/apps/runtime/dist/routes/runs.js +9 -0
  29. package/apps/runtime/dist/tools/code.js +53 -13
  30. package/apps/web/dist/assets/AboutDialog-CShc0mRo.js +1 -0
  31. package/apps/web/dist/assets/ChangelogDialog-C4dVSwtN.js +1 -0
  32. package/apps/web/dist/assets/ChatView-KkOboyOo.js +10 -0
  33. package/apps/web/dist/assets/EditorPage-lQ3YgsFT.js +4 -0
  34. package/apps/web/dist/assets/{FlowPreview-Dj2gAfuf.js → FlowPreview-oX_Emm7u.js} +1 -1
  35. package/apps/web/dist/assets/{Harness-SLbsntQF.js → Harness-D6mgLWTb.js} +2 -2
  36. package/apps/web/dist/assets/{NewWizardChannels-CsDMlji7.js → NewWizardChannels-CPlh_37J.js} +1 -1
  37. package/apps/web/dist/assets/NewWizardPage-D0FVJtXK.js +7 -0
  38. package/apps/web/dist/assets/OnboardingPage-BXPOMrQR.js +1 -0
  39. package/apps/web/dist/assets/ProjectPage-DroTprqs.js +1 -0
  40. package/apps/web/dist/assets/PublicRunner-j3qoKjQQ.js +1 -0
  41. package/apps/web/dist/assets/{ReadOnly-CK0piLUV.js → ReadOnly-CKgSQ2z1.js} +1 -1
  42. package/apps/web/dist/assets/RunDrawer-CJZ_ISAd.js +4 -0
  43. package/apps/web/dist/assets/RunnerView-BW1h4x63.js +1 -0
  44. package/apps/web/dist/assets/SettingsPage-BHZ0Z-og.js +1 -0
  45. package/apps/web/dist/assets/SetupPage-CDkYkjJX.js +1 -0
  46. package/apps/web/dist/assets/{SharePage-Cc3Opj2F.js → SharePage-BlVjVjHG.js} +1 -1
  47. package/apps/web/dist/assets/changelog-Be0i5Jjw.js +1 -0
  48. package/apps/web/dist/assets/engenty-WpMi3xwq.js +1 -0
  49. package/apps/web/dist/assets/{esm-CfnFE_jE.js → esm-C-ZWeuhU.js} +1 -1
  50. package/apps/web/dist/assets/{fluffy-engenty-ClRou7yu.js → fluffy-engenty-DPNHytrz.js} +135 -16
  51. package/apps/web/dist/assets/goose-helmet-Cvc7KjUg.bin +0 -0
  52. package/apps/web/dist/assets/goose-helmet-livery-CZD9u0XS.webp +0 -0
  53. package/apps/web/dist/assets/index-C4aHMcg9.css +1 -0
  54. package/apps/web/dist/assets/index-C61HAltK.js +14 -0
  55. package/apps/web/dist/assets/oss-credits-qGhcbqiM.js +1 -0
  56. package/apps/web/dist/assets/{phone-BWOB67tX.js → phone-BjmhIKuT.js} +1 -1
  57. package/apps/web/dist/assets/{settings-menu-CEQlVs1O.js → settings-menu-Dpkp3f56.js} +1 -1
  58. package/apps/web/dist/assets/ui-DMmT-cSO.js +137 -0
  59. package/apps/web/dist/assets/{upload-pBKn2EFp.js → upload-JX9BcMTq.js} +1 -1
  60. package/apps/web/dist/assets/{lock-DAvRJY4B.js → wrench-BSxnPs2_.js} +1 -1
  61. package/apps/web/dist/index.html +4 -4
  62. package/apps/web/dist/install.sh +2 -2
  63. package/apps/web/dist/mcp/flow.html +142 -23
  64. package/node_modules/@engenty/wizards/package.json +1 -4
  65. package/node_modules/@engenty-wizards/shared/dist/claude-plugin.d.ts +7 -0
  66. package/node_modules/@engenty-wizards/shared/dist/claude-plugin.js +10 -0
  67. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +3 -0
  68. package/node_modules/@engenty-wizards/shared/dist/definition.js +15 -0
  69. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +2 -0
  70. package/node_modules/@engenty-wizards/shared/dist/run.js +4 -0
  71. package/package.json +1 -1
  72. package/plugin/.claude-plugin/plugin.json +28 -6
  73. package/plugin/README.md +58 -0
  74. package/plugin/hooks/engenty.ts +140 -0
  75. package/plugin/hooks/hooks.json +1 -0
  76. package/plugin/hooks/pane.test.tsx +177 -0
  77. package/plugin/hooks/register.tsx +396 -0
  78. package/plugin/types/index.d.ts +44 -0
  79. package/apps/web/dist/assets/AboutDialog-BP8-Ugxf.js +0 -1
  80. package/apps/web/dist/assets/ChangelogDialog-CD6CQNW9.js +0 -1
  81. package/apps/web/dist/assets/ChatView-4T0QarEr.js +0 -10
  82. package/apps/web/dist/assets/EditorPage-CWdV7Xdn.js +0 -3
  83. package/apps/web/dist/assets/NewWizardPage-_bFYjlgh.js +0 -7
  84. package/apps/web/dist/assets/OnboardingPage-DObQeCDg.js +0 -1
  85. package/apps/web/dist/assets/ProjectPage-DX-oNLWy.js +0 -1
  86. package/apps/web/dist/assets/PublicRunner-Bn9rVNEb.js +0 -1
  87. package/apps/web/dist/assets/RunDrawer-CVMTLse_.js +0 -4
  88. package/apps/web/dist/assets/RunnerView-Cjhuu9ua.js +0 -1
  89. package/apps/web/dist/assets/SettingsPage-DTzuiSAj.js +0 -1
  90. package/apps/web/dist/assets/SetupPage-D9jlhgiP.js +0 -1
  91. package/apps/web/dist/assets/changelog-CJNkd1_L.js +0 -1
  92. package/apps/web/dist/assets/engenty-eXZ-EAwd.js +0 -1
  93. package/apps/web/dist/assets/index-AfUa8xKD.css +0 -1
  94. package/apps/web/dist/assets/index-B5F1_qA_.js +0 -14
  95. package/apps/web/dist/assets/oss-credits-X2JPiMOR.js +0 -1
  96. package/apps/web/dist/assets/ui-BmR_4eL2.js +0 -137
  97. package/node_modules/@engenty/wizards/dist/openui/Wizard.d.ts +0 -20
  98. package/node_modules/@engenty/wizards/dist/openui/Wizard.js +0 -62
  99. package/node_modules/@engenty/wizards/dist/openui/index.d.ts +0 -9
  100. package/node_modules/@engenty/wizards/dist/openui/index.js +0 -9
  101. package/node_modules/@engenty/wizards/dist/openui/message.d.ts +0 -11
  102. package/node_modules/@engenty/wizards/dist/openui/message.js +0 -69
  103. package/node_modules/@engenty/wizards/dist/openui/run.d.ts +0 -8
  104. package/node_modules/@engenty/wizards/dist/openui/run.js +0 -137
  105. package/node_modules/@engenty/wizards/dist/openui/server.d.ts +0 -37
  106. package/node_modules/@engenty/wizards/dist/openui/server.js +0 -77
  107. package/plugin/.mcp.json +0 -8
@@ -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,118 @@
1
+ import { env } from "./env.js";
2
+ import { allowed } from "./limits.js";
3
+ import { managed } from "./manage.js";
4
+ /**
5
+ * The clients of the public run API: who starts runs here (docs/content/dev/api.md,
6
+ * docs/plans/api-clients.md). A client names itself in the `x-wizards-client` header: `web` (the
7
+ * runner in a browser), `app` (the mobile app), `cli`, or the id of a client registered at the
8
+ * Manage-App. The id is no secret: it carries the terms, the limits and the revocation of a
9
+ * client, not its proof. The run keeps it.
10
+ *
11
+ * A managed runtime fetches the list of clients from its Manage-App at the start and once an
12
+ * hour, and keeps the last list when a fetch fails. Without a list every client may start runs:
13
+ * the Manage-App being down must not close the wizards. A runtime that runs alone fetches
14
+ * nothing and refuses nothing — its API is the person's own.
15
+ */
16
+ export const CLIENT_HEADER = "x-wizards-client";
17
+ /** The clients this repository ships: the runner, the mobile app, the command line. */
18
+ export const OWN_CLIENTS = ["web", "app", "cli"];
19
+ const ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
20
+ /** The client a request names, or null when it names none or a malformed one. */
21
+ export function clientOf(c) {
22
+ const id = c.req.header(CLIENT_HEADER)?.trim().toLowerCase() ?? "";
23
+ return ID.test(id) ? id : null;
24
+ }
25
+ let list = null;
26
+ /** The list in force; tests set it, a managed runtime fetches it. */
27
+ export function setClientList(next) {
28
+ list = next;
29
+ }
30
+ const HOUR = 3600_000;
31
+ /**
32
+ * Why `client` may not start a run now, or null when it may. Counts the try against the
33
+ * client's hourly cap, so call it once per start.
34
+ */
35
+ export function clientRefused(client) {
36
+ if (!list) {
37
+ return null;
38
+ }
39
+ const known = list.clients.find((c) => c.id === client);
40
+ const cap = known ? known.runsPerHour : list.unknown.runsPerHour;
41
+ if (known ? known.revoked : !list.unknown.allow) {
42
+ return {
43
+ status: 403,
44
+ code: "client_refused",
45
+ note: "clientRefused",
46
+ };
47
+ }
48
+ if (cap !== null && !allowed(`client:${client ?? "unknown"}`, cap, HOUR)) {
49
+ return {
50
+ status: 429,
51
+ code: "client_limit",
52
+ note: "clientLimit",
53
+ };
54
+ }
55
+ return null;
56
+ }
57
+ /** The list as the Manage-App sends it, checked; throws on a shape this runtime does not know. */
58
+ export function parseClientList(body) {
59
+ const raw = body;
60
+ if (!Array.isArray(raw?.clients)) {
61
+ throw new Error("no clients");
62
+ }
63
+ const clients = raw.clients.map((c) => {
64
+ if (typeof c?.id !== "string" || !ID.test(c.id)) {
65
+ throw new Error("bad client id");
66
+ }
67
+ return {
68
+ id: c.id,
69
+ name: typeof c.name === "string" ? c.name : c.id,
70
+ runsPerHour: typeof c.runsPerHour === "number" && c.runsPerHour >= 0 ? c.runsPerHour : null,
71
+ revoked: c.revoked === true,
72
+ };
73
+ });
74
+ const unknown = (raw.unknown ?? {});
75
+ return {
76
+ clients,
77
+ unknown: {
78
+ allow: unknown.allow !== false,
79
+ runsPerHour: typeof unknown.runsPerHour === "number" && unknown.runsPerHour >= 0
80
+ ? unknown.runsPerHour
81
+ : null,
82
+ },
83
+ };
84
+ }
85
+ /**
86
+ * Fetches the Manage-App's list. A Manage-App without the call (404) means no list: everything
87
+ * may start runs. Any other failure keeps the list from before.
88
+ */
89
+ export async function loadClientList() {
90
+ if (!managed) {
91
+ return;
92
+ }
93
+ try {
94
+ const res = await fetch(`${env.manage.url}/v1/clients`, {
95
+ headers: { authorization: `Bearer ${env.manage.serviceKey}` },
96
+ signal: AbortSignal.timeout(10_000),
97
+ });
98
+ if (res.status === 404) {
99
+ list = null;
100
+ return;
101
+ }
102
+ if (!res.ok) {
103
+ throw new Error(`HTTP ${res.status}`);
104
+ }
105
+ list = parseClientList(await res.json());
106
+ }
107
+ catch (err) {
108
+ console.warn(`[clients] the Manage-App's client list was not read: ${err.message}`);
109
+ }
110
+ }
111
+ /** A managed runtime: the list now, and again every hour. */
112
+ export function startClientChecks() {
113
+ if (!managed) {
114
+ return;
115
+ }
116
+ void loadClientList();
117
+ setInterval(() => void loadClientList(), HOUR).unref();
118
+ }