@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.
- package/apps/runtime/dist/authoring/guide.js +8 -3
- package/apps/runtime/dist/browser/fast/classifier.js +9 -4
- package/apps/runtime/dist/cli/main.js +1 -1
- package/apps/runtime/dist/cli/menu.js +5 -1
- package/apps/runtime/dist/cli/ollama.js +302 -0
- package/apps/runtime/dist/cli/setup.js +102 -2
- package/apps/runtime/dist/clients.js +118 -0
- package/apps/runtime/dist/db/client.js +37 -3
- package/apps/runtime/dist/db/migrations/tenant/0017_run_client.sql +1 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/0017_snapshot.json +3333 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
- package/apps/runtime/dist/db/schema.js +2 -0
- package/apps/runtime/dist/decision-model.js +33 -0
- package/apps/runtime/dist/docs/dev/api.md +39 -0
- package/apps/runtime/dist/docs/dev/index.md +1 -0
- package/apps/runtime/dist/docs/dev/integration/openui.md +5 -3
- package/apps/runtime/dist/docs/dev/meta.json +1 -1
- package/apps/runtime/dist/docs/user/ai-apps.md +14 -3
- package/apps/runtime/dist/docs/user/command-line.md +1 -1
- package/apps/runtime/dist/docs/user/install.md +1 -0
- package/apps/runtime/dist/engine/decide.js +3 -1
- package/apps/runtime/dist/engine/runner.js +1 -0
- package/apps/runtime/dist/film/project.js +221 -65
- package/apps/runtime/dist/film/step.js +16 -5
- package/apps/runtime/dist/index.js +13 -4
- package/apps/runtime/dist/models.js +43 -3
- package/apps/runtime/dist/plugin.js +24 -8
- package/apps/runtime/dist/routes/runs.js +9 -0
- package/apps/runtime/dist/tools/code.js +53 -13
- package/apps/web/dist/assets/AboutDialog-CShc0mRo.js +1 -0
- package/apps/web/dist/assets/ChangelogDialog-C4dVSwtN.js +1 -0
- package/apps/web/dist/assets/ChatView-KkOboyOo.js +10 -0
- package/apps/web/dist/assets/EditorPage-lQ3YgsFT.js +4 -0
- package/apps/web/dist/assets/{FlowPreview-Dj2gAfuf.js → FlowPreview-oX_Emm7u.js} +1 -1
- package/apps/web/dist/assets/{Harness-SLbsntQF.js → Harness-D6mgLWTb.js} +2 -2
- package/apps/web/dist/assets/{NewWizardChannels-CsDMlji7.js → NewWizardChannels-CPlh_37J.js} +1 -1
- package/apps/web/dist/assets/NewWizardPage-D0FVJtXK.js +7 -0
- package/apps/web/dist/assets/OnboardingPage-BXPOMrQR.js +1 -0
- package/apps/web/dist/assets/ProjectPage-DroTprqs.js +1 -0
- package/apps/web/dist/assets/PublicRunner-j3qoKjQQ.js +1 -0
- package/apps/web/dist/assets/{ReadOnly-CK0piLUV.js → ReadOnly-CKgSQ2z1.js} +1 -1
- package/apps/web/dist/assets/RunDrawer-CJZ_ISAd.js +4 -0
- package/apps/web/dist/assets/RunnerView-BW1h4x63.js +1 -0
- package/apps/web/dist/assets/SettingsPage-BHZ0Z-og.js +1 -0
- package/apps/web/dist/assets/SetupPage-CDkYkjJX.js +1 -0
- package/apps/web/dist/assets/{SharePage-Cc3Opj2F.js → SharePage-BlVjVjHG.js} +1 -1
- package/apps/web/dist/assets/changelog-Be0i5Jjw.js +1 -0
- package/apps/web/dist/assets/engenty-WpMi3xwq.js +1 -0
- package/apps/web/dist/assets/{esm-CfnFE_jE.js → esm-C-ZWeuhU.js} +1 -1
- package/apps/web/dist/assets/{fluffy-engenty-ClRou7yu.js → fluffy-engenty-DPNHytrz.js} +135 -16
- package/apps/web/dist/assets/goose-helmet-Cvc7KjUg.bin +0 -0
- package/apps/web/dist/assets/goose-helmet-livery-CZD9u0XS.webp +0 -0
- package/apps/web/dist/assets/index-C4aHMcg9.css +1 -0
- package/apps/web/dist/assets/index-C61HAltK.js +14 -0
- package/apps/web/dist/assets/oss-credits-qGhcbqiM.js +1 -0
- package/apps/web/dist/assets/{phone-BWOB67tX.js → phone-BjmhIKuT.js} +1 -1
- package/apps/web/dist/assets/{settings-menu-CEQlVs1O.js → settings-menu-Dpkp3f56.js} +1 -1
- package/apps/web/dist/assets/ui-DMmT-cSO.js +137 -0
- package/apps/web/dist/assets/{upload-pBKn2EFp.js → upload-JX9BcMTq.js} +1 -1
- package/apps/web/dist/assets/{lock-DAvRJY4B.js → wrench-BSxnPs2_.js} +1 -1
- package/apps/web/dist/index.html +4 -4
- package/apps/web/dist/install.sh +2 -2
- package/apps/web/dist/mcp/flow.html +142 -23
- package/node_modules/@engenty/wizards/package.json +1 -4
- package/node_modules/@engenty-wizards/shared/dist/claude-plugin.d.ts +7 -0
- package/node_modules/@engenty-wizards/shared/dist/claude-plugin.js +10 -0
- package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +3 -0
- package/node_modules/@engenty-wizards/shared/dist/definition.js +15 -0
- package/node_modules/@engenty-wizards/shared/dist/run.d.ts +2 -0
- package/node_modules/@engenty-wizards/shared/dist/run.js +4 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +28 -6
- package/plugin/README.md +58 -0
- package/plugin/hooks/engenty.ts +140 -0
- package/plugin/hooks/hooks.json +1 -0
- package/plugin/hooks/pane.test.tsx +177 -0
- package/plugin/hooks/register.tsx +396 -0
- package/plugin/types/index.d.ts +44 -0
- package/apps/web/dist/assets/AboutDialog-BP8-Ugxf.js +0 -1
- package/apps/web/dist/assets/ChangelogDialog-CD6CQNW9.js +0 -1
- package/apps/web/dist/assets/ChatView-4T0QarEr.js +0 -10
- package/apps/web/dist/assets/EditorPage-CWdV7Xdn.js +0 -3
- package/apps/web/dist/assets/NewWizardPage-_bFYjlgh.js +0 -7
- package/apps/web/dist/assets/OnboardingPage-DObQeCDg.js +0 -1
- package/apps/web/dist/assets/ProjectPage-DX-oNLWy.js +0 -1
- package/apps/web/dist/assets/PublicRunner-Bn9rVNEb.js +0 -1
- package/apps/web/dist/assets/RunDrawer-CVMTLse_.js +0 -4
- package/apps/web/dist/assets/RunnerView-Cjhuu9ua.js +0 -1
- package/apps/web/dist/assets/SettingsPage-DTzuiSAj.js +0 -1
- package/apps/web/dist/assets/SetupPage-D9jlhgiP.js +0 -1
- package/apps/web/dist/assets/changelog-CJNkd1_L.js +0 -1
- package/apps/web/dist/assets/engenty-eXZ-EAwd.js +0 -1
- package/apps/web/dist/assets/index-AfUa8xKD.css +0 -1
- package/apps/web/dist/assets/index-B5F1_qA_.js +0 -14
- package/apps/web/dist/assets/oss-credits-X2JPiMOR.js +0 -1
- package/apps/web/dist/assets/ui-BmR_4eL2.js +0 -137
- package/node_modules/@engenty/wizards/dist/openui/Wizard.d.ts +0 -20
- package/node_modules/@engenty/wizards/dist/openui/Wizard.js +0 -62
- package/node_modules/@engenty/wizards/dist/openui/index.d.ts +0 -9
- package/node_modules/@engenty/wizards/dist/openui/index.js +0 -9
- package/node_modules/@engenty/wizards/dist/openui/message.d.ts +0 -11
- package/node_modules/@engenty/wizards/dist/openui/message.js +0 -69
- package/node_modules/@engenty/wizards/dist/openui/run.d.ts +0 -8
- package/node_modules/@engenty/wizards/dist/openui/run.js +0 -137
- package/node_modules/@engenty/wizards/dist/openui/server.d.ts +0 -37
- package/node_modules/@engenty/wizards/dist/openui/server.js +0 -77
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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" }, {
|
|
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
|
+
}
|