@homie-rocks/studio 0.24.2 → 0.24.4
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/CHANGELOG.md +56 -1
- package/README.md +51 -5
- package/agents/GUIDES.md +13 -6
- package/bin/homie-studio.mjs +77 -15
- package/chat/CHAT.md +2 -0
- package/lib/agent-seat.mjs +60 -2
- package/lib/brain-probe.mjs +6 -5
- package/lib/build.mjs +3 -0
- package/lib/cloudflare.mjs +13 -3
- package/lib/doctor.mjs +64 -5
- package/lib/local-ai.mjs +66 -0
- package/lib/mcp-tools.mjs +10 -2
- package/lib/projects-env.mjs +68 -0
- package/lib/projects.mjs +311 -0
- package/lib/scaffold.mjs +20 -2
- package/lib/servers.mjs +31 -2
- package/lib/shop-catalog.mjs +163 -0
- package/lib/shop.mjs +151 -60
- package/lib/template-history.json +78 -0
- package/netplay/NETPLAY.md +86 -12
- package/netplay/netplay.ts +82 -0
- package/package.json +1 -1
- package/shop/SELLING.md +16 -0
- package/shop/SHOP.md +65 -16
- package/starters/ember-vale/game.json +1 -0
- package/starters/ember-vale/src/main.ts +80 -6
- package/worker/agents.mjs +93 -47
- package/worker/brain.mjs +356 -5
- package/worker/chat.mjs +13 -6
- package/worker/index.mjs +57 -2
- package/worker/office.mjs +65 -4
- package/worker/room.mjs +48 -3
- package/worker/shop-rules.mjs +17 -1
- package/worker/shop.mjs +32 -9
- package/worker/stripe.mjs +30 -0
- package/worker/version.mjs +1 -1
package/lib/cloudflare.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { MAX_ASSET_BYTES, R2_COST, mediaPlan, r2OverOf, recordMove, sha256File,
|
|
|
30
30
|
import { repoOf } from './repo.mjs';
|
|
31
31
|
import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
|
|
32
32
|
import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
33
|
+
import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
33
34
|
|
|
34
35
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
35
36
|
|
|
@@ -38,13 +39,17 @@ export function wranglerBin(root) {
|
|
|
38
39
|
return existsSync(local) ? local : null;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Wrangler from the studio's own node_modules, run in site/ with the given environment. A studio whose Cloudflare came
|
|
44
|
+
* through Stripe Projects (studio.json `cloudflare.auth: "stripe-projects"`, 0.24.3) runs it with the token and account
|
|
45
|
+
* Projects synced to its git-ignored output file; every other studio with Wrangler's own sign-in.
|
|
46
|
+
*/
|
|
42
47
|
export function runner(root, env) {
|
|
43
48
|
const bin = wranglerBin(root);
|
|
44
49
|
if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
|
|
45
50
|
return (args, { cwd = workerDir(root), input } = {}) => {
|
|
46
51
|
const res = spawnSync(bin, args, {
|
|
47
|
-
cwd, env: { ...process.env, ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
|
|
52
|
+
cwd, env: { ...process.env, ...projectsCloudflareEnv(root), ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
|
|
48
53
|
input, maxBuffer: 64 * 1024 * 1024, timeout: 10 * 60_000,
|
|
49
54
|
});
|
|
50
55
|
const out = `${res.stdout ?? ''}${res.stderr ?? ''}`.replace(ANSI, '');
|
|
@@ -128,6 +133,9 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
128
133
|
const created = new Set(cf.created ?? []);
|
|
129
134
|
const who = whoami(root);
|
|
130
135
|
if (!who) {
|
|
136
|
+
if (cf.auth === 'stripe-projects') {
|
|
137
|
+
return { ok: false, command: 'deploy', needs: 'cloudflare-login', why: 'Wrangler did not accept the Cloudflare token Stripe Projects keeps for this studio (studio.json "auth": "stripe-projects"). Run `stripe projects env --pull` in the studio folder (it rewrites the git-ignored .env from Projects\' vault), then `npm run deploy` again; or sign in the usual way (`npx wrangler login`) and remove "auth" from studio.json.' };
|
|
138
|
+
}
|
|
131
139
|
return { ok: false, command: 'deploy', needs: 'cloudflare-login', why: 'Wrangler is not signed in to Cloudflare. Run `npx wrangler login` in the studio folder: it opens Cloudflare in the browser and the person approves once (a free account works, no payment method). Then run `npm run deploy` again.' };
|
|
132
140
|
}
|
|
133
141
|
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
|
|
@@ -274,6 +282,8 @@ export function needsWorkersAi(root, w, db) {
|
|
|
274
282
|
// Room chat (0.23.0): a game whose players may type has its messages reviewed by the studio's own Workers AI
|
|
275
283
|
// (Cloudflare's Clef decision model, within a day's budget of the free allocation) unless its game.json says no.
|
|
276
284
|
if ((cat.games ?? []).some((g) => g.chat !== false && (g.chat?.mode ?? 'text') === 'text' && g.chat?.ai !== false)) return true;
|
|
285
|
+
// A game's own decisions (0.24.4): a game whose game.json says "decide": true asks the same decision model.
|
|
286
|
+
if ((cat.games ?? []).some((g) => g.decide === true)) return true;
|
|
277
287
|
} catch { /* not built */ }
|
|
278
288
|
const r = w(['d1', 'execute', db, '--remote', '--json', '--command', "SELECT COUNT(*) AS n FROM servers WHERE brain = 'workers-ai' AND state = 'open'"]);
|
|
279
289
|
if (r.code !== 0) return false;
|
|
@@ -490,7 +500,7 @@ export async function mediaMove(root, { paths = null, dryRun = false, verify = f
|
|
|
490
500
|
const bin = wranglerBin(root);
|
|
491
501
|
if (!bin) return { ok: false, ...base, why: 'Wrangler is not installed in this studio yet: run `npm install` in the studio folder first' };
|
|
492
502
|
const account = accountId || process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || null;
|
|
493
|
-
const opts = { cwd: workerDir(root), env: { ...process.env, ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}), WRANGLER_SEND_METRICS: 'false', CI: '1' } };
|
|
503
|
+
const opts = { cwd: workerDir(root), env: { ...process.env, ...projectsCloudflareEnv(root), ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}), WRANGLER_SEND_METRICS: 'false', CI: '1' } };
|
|
494
504
|
const moved = [];
|
|
495
505
|
const failed = [];
|
|
496
506
|
const verified = [];
|
package/lib/doctor.mjs
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* GitHub optional a private backup, publishing by pull request, building from the Claude app
|
|
17
17
|
* ElevenLabs optional songs and game scores
|
|
18
18
|
* fal optional painted art and generated video
|
|
19
|
+
* Stripe for selling only in a studio with shop.json (0.24.3): Stripe's own agent plugin (its MCP server
|
|
20
|
+
* and skills) for this AI, set up when the studio starts selling; the shop's key itself
|
|
21
|
+
* goes in through `homie-studio shop connect`, never here
|
|
22
|
+
* Clef locally optional Ollama with clef-flash: AI under dev for free (never downloaded without a yes)
|
|
19
23
|
* status line optional (Claude Code only) the build's progress under the prompt
|
|
20
24
|
*
|
|
21
25
|
* It is safe at any time, inside a studio or before one exists: it only reads (local files, `--version` of a few
|
|
@@ -28,10 +32,12 @@ import { spawn } from 'node:child_process';
|
|
|
28
32
|
import { existsSync, readFileSync } from 'node:fs';
|
|
29
33
|
import { join } from 'node:path';
|
|
30
34
|
import { workersAiRow } from './brain-probe.mjs';
|
|
35
|
+
import { detectLocalAi } from './local-ai.mjs';
|
|
31
36
|
import { findChrome } from './chrome.mjs';
|
|
32
37
|
import { whyFailed } from './net.mjs';
|
|
33
38
|
import { isOurs } from './statusline.mjs';
|
|
34
39
|
import { findStudio, readLocal, readStudio } from './studio.mjs';
|
|
40
|
+
import { projectsCloudflareEnv, projectsElevenLabs } from './projects-env.mjs';
|
|
35
41
|
import { STUDIO_VERSION } from './version.mjs';
|
|
36
42
|
|
|
37
43
|
/** A command's result, never throwing: code 127 when the program is not installed, 124 when it ran out of time. */
|
|
@@ -101,7 +107,12 @@ export async function setupStatus({
|
|
|
101
107
|
if (env.CLOUDFLARE_API_TOKEN) return { signedIn: true, how: 'an API token in the environment' };
|
|
102
108
|
const bin = root ? join(root, 'node_modules', '.bin', win ? 'wrangler.cmd' : 'wrangler') : null;
|
|
103
109
|
if (!bin || !existsSync(bin)) return { signedIn: null };
|
|
104
|
-
|
|
110
|
+
// Through Stripe Projects (studio.json "auth": "stripe-projects"): Wrangler with the token Projects synced.
|
|
111
|
+
const projects = projectsCloudflareEnv(root);
|
|
112
|
+
const r = await exec(bin, ['whoami', '--json'], { cwd: root, timeout: 25_000, env: { ...env, ...projects, WRANGLER_SEND_METRICS: 'false', CI: '1' } });
|
|
113
|
+
if (projects.CLOUDFLARE_API_TOKEN && r.code === 0) {
|
|
114
|
+
try { if (JSON.parse(r.stdout.slice(Math.max(0, r.stdout.indexOf('{')))).loggedIn) return { signedIn: true, how: 'through Stripe Projects (its token, in the studio\'s git-ignored .env; never shown)', accounts: 1, accountIds: [projects.CLOUDFLARE_ACCOUNT_ID].filter(Boolean) }; } catch { /* read below */ }
|
|
115
|
+
}
|
|
105
116
|
if (r.code === 124) return { signedIn: null, why: 'Wrangler did not answer in time' };
|
|
106
117
|
// The accounts' ids stay in this process (the Workers AI row calls the studio's own account); never printed.
|
|
107
118
|
try { const d = JSON.parse(r.stdout.slice(Math.max(0, r.stdout.indexOf('{')))); return { signedIn: Boolean(d.loggedIn), accounts: Array.isArray(d.accounts) ? d.accounts.length : null, accountIds: Array.isArray(d.accounts) ? d.accounts.map((a) => a?.id).filter(Boolean) : [] }; } catch { return { signedIn: false }; }
|
|
@@ -124,7 +135,7 @@ export async function setupStatus({
|
|
|
124
135
|
const s = await exec(cliBin, ['auth', 'status', '--format', 'json'], { timeout: 10_000 });
|
|
125
136
|
try { signedIn = Boolean(JSON.parse(s.stdout)?.schemes?.some((x) => x.logged_in)); } catch { signedIn = false; }
|
|
126
137
|
}
|
|
127
|
-
return { cli: v.code === 0, signedIn, key: Boolean(env.ELEVENLABS_API_KEY) };
|
|
138
|
+
return { cli: v.code === 0, signedIn, key: Boolean(env.ELEVENLABS_API_KEY), projects: root ? projectsElevenLabs(root) : false };
|
|
128
139
|
})(),
|
|
129
140
|
(async () => {
|
|
130
141
|
const key = String(env.FAL_KEY ?? '').trim();
|
|
@@ -219,6 +230,18 @@ export async function setupStatus({
|
|
|
219
230
|
});
|
|
220
231
|
}
|
|
221
232
|
|
|
233
|
+
// Clef on this computer (0.24.4, lib/local-ai.mjs): Ollama with clef-flash. Optional, and never downloaded here: the
|
|
234
|
+
// person says yes to the size first. In a studio only (it serves `dev`).
|
|
235
|
+
if (root) {
|
|
236
|
+
const found = await detectLocalAi({ env, fetchFn });
|
|
237
|
+
rows.push({
|
|
238
|
+
id: 'local-ai', label: 'Clef on this computer', need: 'optional', state: found.ok ? 'ok' : 'optional',
|
|
239
|
+
detail: found.ok ? `${found.model} on Ollama ${found.version}` : found.why === 'no-model' ? `Ollama ${found.version} is here, without clef-flash` : found.why === 'old-ollama' ? `Ollama ${found.version} is too old for Clef (0.35.1 or later)` : 'no Ollama running here',
|
|
240
|
+
unlocks: 'AI guides, chat review and a game\'s own decisions under `dev` with no Cloudflare and no cost, and a guide seat that thinks on this computer (agents sit --brain local)',
|
|
241
|
+
fix: found.ok ? null : { who: 'person', say: found.say, ...(found.why === 'no-ollama' ? { open: 'https://ollama.com/download' } : {}) },
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
222
245
|
// GitHub.
|
|
223
246
|
rows.push({
|
|
224
247
|
id: 'github', label: 'GitHub', need: 'optional', state: gh.signedIn || gh.onGitHub ? 'ok' : 'optional',
|
|
@@ -231,10 +254,10 @@ export async function setupStatus({
|
|
|
231
254
|
|
|
232
255
|
// ElevenLabs.
|
|
233
256
|
rows.push({
|
|
234
|
-
id: 'elevenlabs', label: 'ElevenLabs', need: 'optional', state: eleven.signedIn || eleven.key ? 'ok' : 'optional',
|
|
235
|
-
detail: eleven.signedIn ? 'the elevenlabs CLI is signed in' : eleven.key ? 'ELEVENLABS_API_KEY is set' : eleven.cli ? 'the elevenlabs CLI is here, not signed in' : 'not connected',
|
|
257
|
+
id: 'elevenlabs', label: 'ElevenLabs', need: 'optional', state: eleven.signedIn || eleven.key || eleven.projects ? 'ok' : 'optional',
|
|
258
|
+
detail: eleven.signedIn ? 'the elevenlabs CLI is signed in' : eleven.key ? 'ELEVENLABS_API_KEY is set' : eleven.projects ? 'through Stripe Projects (its key, in the studio\'s git-ignored .env; never shown)' : eleven.cli ? 'the elevenlabs CLI is here, not signed in' : 'not connected',
|
|
236
259
|
unlocks: 'songs and game scores (the music skill), billed to your own ElevenLabs plan; every render is quoted first',
|
|
237
|
-
fix: eleven.signedIn || eleven.key ? null : eleven.cli
|
|
260
|
+
fix: eleven.signedIn || eleven.key || eleven.projects ? null : eleven.cli
|
|
238
261
|
? { who: 'ai', run: 'elevenlabs auth login', say: 'ElevenLabs opens in your browser; sign in once. No key is pasted anywhere.' }
|
|
239
262
|
: { who: mac ? 'ai' : 'person', run: mac ? 'brew install elevenlabs/tap/elevenlabs && elevenlabs auth login' : undefined, open: 'https://elevenlabs.io/sign-up', say: 'Make an ElevenLabs account when you want songs; your AI installs their CLI and you sign in once in the browser.' },
|
|
240
263
|
});
|
|
@@ -247,6 +270,24 @@ export async function setupStatus({
|
|
|
247
270
|
fix: fal.key && fal.valid !== false ? null : { who: 'person', open: 'https://fal.ai/dashboard/keys', say: 'Make a key, put FAL_KEY in the environment your AI runs in (for example a line in your shell profile), and start a new session. Never paste it into the chat.' },
|
|
248
271
|
});
|
|
249
272
|
|
|
273
|
+
// Stripe, only in a studio that sells (shop.json): Stripe's own agent plugin, set up when selling starts (lazy, like
|
|
274
|
+
// every provider here). The shop's key never comes through here: `homie-studio shop connect` takes it on a page.
|
|
275
|
+
if (root && existsSync(join(root, 'shop.json'))) {
|
|
276
|
+
const v = await exec('stripe', ['--version']);
|
|
277
|
+
const version = v.code === 0 ? /(\d+\.\d+\.\d+)/.exec(v.stdout)?.[1] ?? null : null;
|
|
278
|
+
const mcp = stripeAgentConfigured({ root, env });
|
|
279
|
+
rows.push({
|
|
280
|
+
id: 'stripe', label: 'Stripe', need: 'for selling', state: mcp ? 'ok' : 'act',
|
|
281
|
+
detail: [mcp ? `Stripe's MCP is set up for ${mcp}` : 'Stripe\'s MCP is not set up for this AI', version ? `the Stripe CLI ${version}` : 'no Stripe CLI'].join('; '),
|
|
282
|
+
unlocks: 'the shop: your AI makes the catalog, checks tax and Managed Payments and answers "how are sales?" with Stripe\'s own tools, on your own Stripe account; you sign in on Stripe\'s page',
|
|
283
|
+
fix: mcp ? null : {
|
|
284
|
+
who: 'ai', run: 'npm install -g @stripe/cli@latest && stripe agent setup',
|
|
285
|
+
say: 'Stripe\'s own agent plugin for Claude Code and Codex (its MCP server and skills, kept up to date); you approve the install. Then sign in once on Stripe\'s page and give access to your Stripe sandbox first.',
|
|
286
|
+
open: 'https://dashboard.stripe.com/register',
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
250
291
|
// The Claude Code status line (only in Claude Code, only in a studio).
|
|
251
292
|
if (root && env.CLAUDECODE === '1') {
|
|
252
293
|
let on = false;
|
|
@@ -270,6 +311,7 @@ export async function setupStatus({
|
|
|
270
311
|
{ feature: 'Songs and game scores', state: ready(by.elevenlabs.state), needs: ['elevenlabs'] },
|
|
271
312
|
{ feature: 'Painted art and generated video', state: ready(by.fal.state), needs: ['fal'] },
|
|
272
313
|
...(by['workers-ai'] ? [{ feature: 'AI guides that think (Workers AI)', state: ready(by['workers-ai'].state), needs: ['workers-ai'] }] : []),
|
|
314
|
+
...(by.stripe ? [{ feature: 'Sell in the games (the shop, with Stripe\'s own tools)', state: ready(by.stripe.state), needs: ['stripe'] }] : []),
|
|
273
315
|
];
|
|
274
316
|
const now = rows.filter((r) => ['missing', 'act'].includes(r.state) && r.fix);
|
|
275
317
|
const meanwhile = rows.filter((r) => r.fix?.open && r.state !== 'ok' && (r.fix.who === 'person' || r.state === 'later'));
|
|
@@ -284,6 +326,23 @@ export async function setupStatus({
|
|
|
284
326
|
};
|
|
285
327
|
}
|
|
286
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Where Stripe's MCP server (https://mcp.stripe.com) is set up for this person's AI: Claude Code (the Stripe plugin,
|
|
331
|
+
* or an MCP entry in .claude.json in the home folder, or the studio's .mcp.json) or Codex (config.toml in the Codex
|
|
332
|
+
* home folder). It only looks for the address or the plugin's name in those files; it never reads a key and never
|
|
333
|
+
* prints what it found. Null when nowhere.
|
|
334
|
+
*/
|
|
335
|
+
export function stripeAgentConfigured({ root, env = process.env } = {}) {
|
|
336
|
+
const home = env.HOME || env.USERPROFILE || '';
|
|
337
|
+
const look = (file, re) => {
|
|
338
|
+
try { if (!file || !existsSync(file)) return false; const t = readFileSync(file, 'utf8'); return t.length < 64 * 1024 * 1024 && re.test(t); } catch { return false; }
|
|
339
|
+
};
|
|
340
|
+
const mcp = /mcp\.stripe\.com/;
|
|
341
|
+
if (look(home && join(home, '.claude', 'plugins', 'installed_plugins.json'), /"stripe@/) || look(home && join(home, '.claude.json'), mcp) || look(root && join(root, '.mcp.json'), mcp)) return 'Claude Code';
|
|
342
|
+
if (look(join(env.CODEX_HOME || (home && join(home, '.codex')) || '', 'config.toml'), /mcp\.stripe\.com|\[mcp_servers\.stripe\]|stripe@openai-curated/)) return 'Codex';
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
287
346
|
/** The checklist as a person reads it in a terminal. */
|
|
288
347
|
export function formatStatus(r) {
|
|
289
348
|
const lines = [`Setup status${r.studio?.name ? ` for ${r.studio.name}` : ' (before the studio exists)'}`, ''];
|
package/lib/local-ai.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PERSON'S OWN AI (@homie-rocks/studio 0.24.4): Cloudflare's Clef decision model on this computer, through Ollama.
|
|
3
|
+
* The same typed questions the studio's Workers AI answers, at /v1/systemone, free and offline:
|
|
4
|
+
*
|
|
5
|
+
* homie-studio dev finds it and hands it to the local Worker (HOMIE_LOCAL_AI), so AI guides, room chat's review
|
|
6
|
+
* and a game's own decisions (net.decide) need no Cloudflare while building
|
|
7
|
+
* agents sit --brain local / the MCP's agent_sit { brain: "local" }
|
|
8
|
+
* a guide's seat that thinks on this computer every few seconds, with no AI turn per decision
|
|
9
|
+
* homie-studio doctor says what is there, and what one sentence gets the rest
|
|
10
|
+
*
|
|
11
|
+
* It never downloads a model. `ollama pull clef-flash` is about 11 GB (clef, the 27B, about 18 GB): the person says yes
|
|
12
|
+
* first, every time (the studio skill asks and names the size). It needs Ollama 0.35.1 or later (the first with Clef).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const OLLAMA_BASE = 'http://127.0.0.1:11434';
|
|
16
|
+
export const OLLAMA_MIN = '0.35.1';
|
|
17
|
+
/** The models Homie uses, smallest first, and what each costs the disk (ollama.com/library, 2026-10-02). */
|
|
18
|
+
export const LOCAL_MODELS = Object.freeze([
|
|
19
|
+
Object.freeze({ name: 'clef-flash', size: 'about 11 GB', params: '9B' }),
|
|
20
|
+
Object.freeze({ name: 'clef', size: 'about 18 GB', params: '27B' }),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const newer = (a, b) => {
|
|
24
|
+
const pa = String(a).split(/[.-]/).map((x) => parseInt(x, 10) || 0);
|
|
25
|
+
const pb = String(b).split(/[.-]/).map((x) => parseInt(x, 10) || 0);
|
|
26
|
+
for (let i = 0; i < 3; i += 1) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
27
|
+
return true;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Only a loopback address: Homie never sends a game's state to another machine's Ollama. */
|
|
31
|
+
export const loopbackBase = (raw) => {
|
|
32
|
+
const s = String(raw ?? '').trim().replace(/\/+$/, '');
|
|
33
|
+
return /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d{2,5}$/.test(s) ? s : null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Is Clef on this computer? `{ ok, base, model, version }`, or `{ ok: false, why, say, version?, base }` with why one of
|
|
38
|
+
* no-ollama, old-ollama, no-model. `HOMIE_OLLAMA` names another loopback port. Two quick local requests, nothing else.
|
|
39
|
+
*/
|
|
40
|
+
export async function detectLocalAi({ env = process.env, fetchFn = globalThis.fetch, ms = 1500 } = {}) {
|
|
41
|
+
const base = loopbackBase(env.HOMIE_OLLAMA) ?? OLLAMA_BASE;
|
|
42
|
+
const get = async (path) => {
|
|
43
|
+
const res = await fetchFn(`${base}${path}`, { signal: AbortSignal.timeout(ms) });
|
|
44
|
+
if (!res.ok) throw new Error(`${path} answered ${res.status}`);
|
|
45
|
+
return res.json();
|
|
46
|
+
};
|
|
47
|
+
let version = null;
|
|
48
|
+
try { version = String((await get('/api/version'))?.version ?? ''); } catch {
|
|
49
|
+
return { ok: false, base, why: 'no-ollama', say: `Ollama is not running on this computer (${base}). With it and clef-flash, AI guides, chat review and game decisions run here for free while building; without it they play from the game's script under dev.` };
|
|
50
|
+
}
|
|
51
|
+
if (!newer(version, OLLAMA_MIN)) return { ok: false, base, version, why: 'old-ollama', say: `Ollama ${version} is here; Clef needs ${OLLAMA_MIN} or later. Updating Ollama is the person's to do (its own app updates itself, or ollama.com/download).` };
|
|
52
|
+
let names = [];
|
|
53
|
+
try { names = ((await get('/api/tags'))?.models ?? []).map((m) => String(m?.name ?? m?.model ?? '')).filter(Boolean); } catch { names = []; }
|
|
54
|
+
for (const want of LOCAL_MODELS) {
|
|
55
|
+
const hit = names.find((n) => n === want.name || n.startsWith(`${want.name}:`));
|
|
56
|
+
if (hit) return { ok: true, base, version, model: hit };
|
|
57
|
+
}
|
|
58
|
+
const flash = LOCAL_MODELS[0];
|
|
59
|
+
return {
|
|
60
|
+
ok: false, base, version, why: 'no-model', models: names.length,
|
|
61
|
+
say: `Ollama ${version} is here without Clef. Downloading it is the person's choice: ask first. \`ollama pull ${flash.name}\` downloads ${flash.size} (the ${flash.params} model; ${LOCAL_MODELS[1].name} is ${LOCAL_MODELS[1].size}). Then run dev again.`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The Worker variables that hand a found model to `wrangler dev` (`--var` pairs), or none. */
|
|
66
|
+
export const localAiVars = (found) => (found?.ok ? ['--var', `HOMIE_LOCAL_AI:${found.base}`, '--var', `HOMIE_LOCAL_AI_MODEL:${found.model}`] : []);
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { STUDIO_VERSION } from './version.mjs';
|
|
|
33
33
|
import { compareVersions, whatsNew, whatsNewLines } from './changelog.mjs';
|
|
34
34
|
import { pinnedVersion } from './upgrade.mjs';
|
|
35
35
|
import { AgentSeat } from './agent-seat.mjs';
|
|
36
|
+
import { detectLocalAi } from './local-ai.mjs';
|
|
36
37
|
import { LAB_PORT, runningLab } from './lab.mjs';
|
|
37
38
|
import { pictureFor } from './pictures.mjs';
|
|
38
39
|
import { ART_UI, artToolDefs } from './art-tools.mjs';
|
|
@@ -1050,17 +1051,24 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1050
1051
|
{
|
|
1051
1052
|
name: 'agent_sit', title: 'Sit in a game as an AI guide',
|
|
1052
1053
|
description: 'Take a guide\'s seat in a live room of one of the studio\'s games as an AI ("Claude · AI": always marked AI, never on a humans-only server, only in a room with people in it). The game\'s own bot code moves the body every frame; you choose its goal and, when the server lets its AI talk, one of the game\'s own lines (agents.json; never free text). Returns your seat, what you see (view), the asks players made of you and your choices. Then agent_look and agent_do every 20 to 40 seconds, and agent_stand when done. Uses the site running here (preview_run) or the live site; with no pass, makes a one-day guide pass with the owner\'s office key and revokes it when you stand.',
|
|
1053
|
-
inputSchema: { type: 'object', properties: { game: str('The game\'s id'), server: str('Optional: a server id (default: the pass\'s, else Quick play)'), pass: str('Optional: an agent pass (hap_…) the owner gave; never shown back'), label: str('Optional: the name you play under (default "Claude"; " · AI" is added)'), url: str('Optional: the site (default: this computer\'s preview, else the live site)'), ...STUDIO_ARG }, required: ['game'] },
|
|
1054
|
+
inputSchema: { type: 'object', properties: { game: str('The game\'s id'), server: str('Optional: a server id (default: the pass\'s, else Quick play)'), pass: str('Optional: an agent pass (hap_…) the owner gave; never shown back'), label: str('Optional: the name you play under (default "Claude"; " · AI" is added)'), url: str('Optional: the site (default: this computer\'s preview, else the live site)'), brain: { type: 'string', enum: ['local'], description: 'Optional: "local" lets Cloudflare\'s Clef decision model on this computer (Ollama with clef-flash) choose every few seconds instead of you, free; agent_look shows what it chose and agent_do still overrides it. Never downloads a model: if Ollama has no clef-flash this says what the download costs, and the person decides.' }, ...STUDIO_ARG }, required: ['game'] },
|
|
1054
1055
|
annotations: { title: 'Sit in a game as an AI guide', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1055
1056
|
run: async (a) => {
|
|
1056
1057
|
const root = ctx.root(a.studio);
|
|
1057
1058
|
if (ctx.seat && !ctx.seat.closed) await ctx.seat.stand();
|
|
1058
1059
|
const site = a.url ? String(a.url) : devOf(root)?.url ?? siteUrl(root);
|
|
1059
1060
|
if (!site) return fail('the studio is not running here and has no live site: preview_run first, or studio_deploy');
|
|
1060
|
-
|
|
1061
|
+
let local = null;
|
|
1062
|
+
if (a.brain === 'local') {
|
|
1063
|
+
const found = await detectLocalAi();
|
|
1064
|
+
if (!found.ok) return fail(`No local brain: ${found.say}`, { kind: 'agent', ok: false, error: found.why });
|
|
1065
|
+
local = found;
|
|
1066
|
+
}
|
|
1067
|
+
const seat = new AgentSeat({ site, game: String(a.game ?? ''), server: a.server ?? null, pass: a.pass ?? null, label: a.label ? String(a.label).slice(0, 16) : local ? 'Clef' : 'Claude', root, brain: local ? 'local' : null, local });
|
|
1061
1068
|
const r = await seat.sit();
|
|
1062
1069
|
if (!r.ok) return fail(`No seat: ${r.why}`, { kind: 'agent', ok: false, error: r.error ?? null });
|
|
1063
1070
|
ctx.seat = seat;
|
|
1071
|
+
if (local) return ok(`Seated as ${r.name} in ${r.room} (seat ${r.seat}), thinking with ${local.model} on this computer every few seconds (free; nothing sent to Cloudflare). agent_look shows its last decisions; agent_do overrides; agent_stand ends it.`, { kind: 'agent', ...r });
|
|
1064
1072
|
return ok(`Seated as ${r.name} in ${r.room} (seat ${r.seat}). ${r.talking ? 'This server lets its AI talk: lines from look.choices.lines, at most one every 8 s.' : 'This server\'s AI does not talk: choose goals only (say null).'} Look again with agent_look; act with agent_do { goal, args, say?, sayArgs? }.\n${JSON.stringify({ view: r.view, asks: r.asks, choices: r.choices }, null, 1).slice(0, 6000)}`, { kind: 'agent', ...r });
|
|
1065
1073
|
},
|
|
1066
1074
|
},
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The credentials Stripe Projects keeps for a studio (`homie-studio setup --via stripe-projects`, 0.24.3), read only
|
|
3
|
+
* where they are used and never printed, returned to a person, or written anywhere else.
|
|
4
|
+
*
|
|
5
|
+
* Stripe Projects keeps them encrypted in its vault and syncs them to the project's output file (`.env` unless the
|
|
6
|
+
* environment names another), which it creates readable by this user only and git-ignores. Wrangler then deploys with
|
|
7
|
+
* the Cloudflare token from that file instead of a `wrangler login`; the music skill reads the ElevenLabs key the same
|
|
8
|
+
* way. studio.json says which (never a value): `cloudflare.auth: "stripe-projects"`, `providers.elevenlabs.via`.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { isAbsolute, join, normalize, sep } from 'node:path';
|
|
12
|
+
|
|
13
|
+
/** The output file inside the studio (`.env` by default); null for anything outside it. */
|
|
14
|
+
export function envFileOf(root, name = '.env') {
|
|
15
|
+
const n = normalize(String(name || '.env'));
|
|
16
|
+
if (isAbsolute(n) || n === '..' || n.startsWith(`..${sep}`)) return null;
|
|
17
|
+
return join(root, n);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const unquote = (v) => {
|
|
21
|
+
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) { try { return JSON.parse(v); } catch { return v.slice(1, -1); } }
|
|
22
|
+
if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1);
|
|
23
|
+
return v;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** A dotenv file as { NAME: value } (KEY=value lines; `export ` and comments allowed). {} when absent. */
|
|
27
|
+
export function readDotenv(file) {
|
|
28
|
+
const out = {};
|
|
29
|
+
if (!file || !existsSync(file)) return out;
|
|
30
|
+
let text = '';
|
|
31
|
+
try { text = readFileSync(file, 'utf8'); } catch { return out; }
|
|
32
|
+
for (const line of text.split(/\r?\n/)) {
|
|
33
|
+
const t = line.trim().replace(/^export\s+/, '');
|
|
34
|
+
if (!t || t.startsWith('#')) continue;
|
|
35
|
+
const i = t.indexOf('=');
|
|
36
|
+
if (i < 1) continue;
|
|
37
|
+
const k = t.slice(0, i).trim();
|
|
38
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) continue;
|
|
39
|
+
out[k] = unquote(t.slice(i + 1).trim());
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function studioOf(root) {
|
|
45
|
+
try { return JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')); } catch { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The environment Wrangler gets for a studio whose Cloudflare came through Stripe Projects: CLOUDFLARE_API_TOKEN and
|
|
50
|
+
* CLOUDFLARE_ACCOUNT_ID from the Projects output file. {} for every other studio (the default: `wrangler login`).
|
|
51
|
+
*/
|
|
52
|
+
export function projectsCloudflareEnv(root, studio = studioOf(root)) {
|
|
53
|
+
const cf = studio?.cloudflare;
|
|
54
|
+
if (!root || cf?.auth !== 'stripe-projects') return {};
|
|
55
|
+
const vars = readDotenv(envFileOf(root, cf.envFile));
|
|
56
|
+
return {
|
|
57
|
+
...(vars.CLOUDFLARE_API_TOKEN ? { CLOUDFLARE_API_TOKEN: vars.CLOUDFLARE_API_TOKEN } : {}),
|
|
58
|
+
...(vars.CLOUDFLARE_ACCOUNT_ID ? { CLOUDFLARE_ACCOUNT_ID: vars.CLOUDFLARE_ACCOUNT_ID } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether Stripe Projects holds this studio's ElevenLabs key (studio.json `providers.elevenlabs`); never the key. */
|
|
63
|
+
export function projectsElevenLabs(root, studio = studioOf(root)) {
|
|
64
|
+
const p = studio?.providers?.elevenlabs;
|
|
65
|
+
if (!root || p?.via !== 'stripe-projects') return false;
|
|
66
|
+
const name = /^[A-Z][A-Z0-9_]{2,}$/.test(String(p.envKey ?? '')) ? p.envKey : 'ELEVENLABS_API_KEY';
|
|
67
|
+
return Boolean(readDotenv(envFileOf(root, p.envFile))[name]);
|
|
68
|
+
}
|