@homie-rocks/studio 0.24.3 → 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 +28 -1
- package/README.md +18 -3
- package/agents/GUIDES.md +13 -6
- package/bin/homie-studio.mjs +34 -10
- 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 +2 -0
- package/lib/doctor.mjs +14 -0
- package/lib/local-ai.mjs +66 -0
- package/lib/mcp-tools.mjs +10 -2
- package/lib/scaffold.mjs +7 -0
- package/lib/servers.mjs +31 -2
- package/lib/template-history.json +39 -0
- package/netplay/NETPLAY.md +86 -12
- package/netplay/netplay.ts +82 -0
- package/package.json +1 -1
- 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 +63 -3
- package/worker/room.mjs +48 -3
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,9 +17,36 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.24.4] - 2026-10-03
|
|
21
|
+
|
|
22
|
+
**Plugin 0.25.4** · [#37](https://github.com/homie-rocks/homie/pull/37)
|
|
23
|
+
|
|
24
|
+
Your AI guides think with Cloudflare's Clef decision model, which does what a player asks; Clef runs on your own computer while you build; and your games can ask it for their own decisions (tactics, a director's call, a turn) in about a quarter of a second.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **Clef thinks for your AI guides.** On a server whose guides use Workers AI, the default model is now `@cf/cloudflare/clef-flash`, Cloudflare's open-source decision model (Apache 2.0). It never writes text: each decision is asked as typed Choices of your `agents.json` (which goal, which quest or place or player, which line or none), and its answer is checked by the same rules as before. On 64 recorded Ember Vale moments it did what an open ask asked 36 times out of 40 by itself (the old Llama 3.1 8B: 0 of 40), and blind judges preferred its decisions in 51 of 64 (Llama's in none). In a live room it answered all 9 asks itself.
|
|
29
|
+
- `homie-studio agents try <game> --view <file.json> [--ask ask_help:quest=king-slime --from 0] [--model <id>]`: what your guides' brain would decide in one moment, with no seat taken: the decision, the brain's own pick before the fixed rules, how sure it was, how long it took and what it cost. Use it to test an `agents.json` or to compare a model before setting it.
|
|
30
|
+
- **Clef on your own computer.** With [Ollama](https://ollama.com) 0.35.1 or later and `clef-flash` on this computer, `homie-studio dev` runs the guides, chat review and your game's decisions on it: free, nothing sent to Cloudflare (`--no-local-ai` turns it off). `homie-studio agents sit <game> --brain local` (the MCP's `agent_sit { brain: "local" }`) seats a guide that thinks on your computer every few seconds. `setup status` has a "Clef on this computer" row. Nothing ever downloads the model by itself: `ollama pull clef-flash` is about 11 GB, and it is your call.
|
|
31
|
+
- **Your game's own decisions** (NETPLAY.md section 20): `net.decide(state, questions, { floor })` on the host, for a game whose `game.json` says `"decide": true`. Ask a Choice (2 to 26 options), a yes/no or a Score about your game's state; the room asks Clef and answers with option ids and numbers, within the same daily budget as the guides, at most one ask every 3 s per room. It always answers: with your own floor when there is no AI, no budget, or no answer in time. Measured: about a quarter of a second there and back, about 4 neurons for three questions. Ask per beat or per turn, never per frame.
|
|
32
|
+
- Ember Vale's slimes can think (opt-in: set `"decide": true` in its `game.json`): every 6 s the slimes' director picks how they hunt (rush, surround, gang up on the most hurt, regroup at the King), whether a wave comes and how hard to push, and every screen shows what the slimes are up to. On a kids server they never gang up on the most hurt and never push past steady.
|
|
33
|
+
- The office shows each room's game decisions (how many, who answered, the model's time, the neurons), and each guide decision's model, how sure it was and, when a rule overruled it, what the brain itself chose.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- A player's ask is now answered with the values they asked for: a brain that picks the asked goal with another quest or place is overruled, like one that picks another goal.
|
|
38
|
+
- House guides remember the lines they said and who is new to them: they greet a newcomer once and otherwise stay quiet unless asked or something changes.
|
|
39
|
+
- Clef costs more neurons a decision than Llama (about 9 against 4: it reads its questions and answers, and writes nothing), so the default 8,000 a day is about 900 guide decisions. Set `HOMIE_BRAIN_MODEL` to `@cf/meta/llama-3.1-8b-instruct-fp8-fast` in `wrangler.jsonc` to keep the old model.
|
|
40
|
+
- The doctor's Workers AI check asks Clef one yes/no question (it does not take a chat message).
|
|
41
|
+
|
|
42
|
+
### Upgrade notes
|
|
43
|
+
|
|
44
|
+
- `npm run deploy` after upgrading: guides on Workers AI switch to Clef at their next decision. Nothing else to do.
|
|
45
|
+
- To try local Clef, install Ollama (0.35.1 or later), say yes to the download, and run `ollama pull clef-flash`; then `npm run dev`.
|
|
46
|
+
|
|
20
47
|
## [0.24.3] - 2026-10-03
|
|
21
48
|
|
|
22
|
-
**Plugin 0.25.3** · [#36](https://github.com/homie-rocks/homie/pull/36)
|
|
49
|
+
**Plugin 0.25.3** · [#36](https://github.com/homie-rocks/homie/pull/36) · [release-2026-10-03-studio-0.24.3](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-03-studio-0.24.3)
|
|
23
50
|
|
|
24
51
|
Set up your shop with Stripe's own tools for AI: your AI makes the products, checks tax, makes the webhook and answers "how are sales?"; you make the account, approve Stripe's pages and paste one key.
|
|
25
52
|
|
package/README.md
CHANGED
|
@@ -284,8 +284,21 @@ npx homie-studio agents pass night-rush --label Claude
|
|
|
284
284
|
day); with none, the game's scripted floor. The local MCP's `agent_sit` puts the owner's own Claude in a guide's
|
|
285
285
|
seat. The AI never types: it picks ids; the room drops anything else.
|
|
286
286
|
[agents/GUIDES.md](agents/GUIDES.md) is the short path for an existing RPG.
|
|
287
|
+
- **Clef thinks for the guides** (0.24.4): Workers AI's default for them is Cloudflare's decision model
|
|
288
|
+
(`@cf/cloudflare/clef-flash`), which answers a decision as typed Choices (goal, values, line or none) and never
|
|
289
|
+
writes text. On 64 recorded Ember Vale moments it answered 36 of 40 open asks itself (Llama 3.1 8B: none) and blind
|
|
290
|
+
judges preferred it in 51 of 64, at about 9 neurons a decision (Llama: 4). `HOMIE_BRAIN_MODEL` still picks Llama or
|
|
291
|
+
the 27B Clef; `agents try <game> --view <file> [--ask …] [--model …]` shows what the brain would decide in a moment.
|
|
292
|
+
- **Clef on your own computer** (0.24.4): with Ollama and `clef-flash` here, `dev` runs the guides, chat review and
|
|
293
|
+
game decisions on it, free and with no Cloudflare, and `agents sit --brain local` (the MCP's `agent_sit { brain:
|
|
294
|
+
"local" }`) seats a guide that thinks here. Nothing downloads it: `ollama pull clef-flash` is about 11 GB, the
|
|
295
|
+
person's choice (doctor's "Clef on this computer" row says what is there).
|
|
296
|
+
- **A game's own decisions** (0.24.4, NETPLAY.md section 20): `net.decide(state, questions, { floor })` on the host,
|
|
297
|
+
for a game whose game.json says `"decide": true`: tactics for its opponents, an NPC's reaction, a director's call,
|
|
298
|
+
a turn's move, answered by Clef in the room in about a quarter of a second, per beat (never per frame), within the
|
|
299
|
+
AI brains' day; the floor answers whenever it cannot. Ember Vale's slimes pick their tactics this way (opt-in).
|
|
287
300
|
- Everything is in the studio's own Worker and D1 (migration `0006_studio_servers.sql`). The site's side is in
|
|
288
|
-
[site/SITE.md](site/SITE.md); the room's in [netplay/NETPLAY.md](netplay/NETPLAY.md) sections 17 and
|
|
301
|
+
[site/SITE.md](site/SITE.md); the room's in [netplay/NETPLAY.md](netplay/NETPLAY.md) sections 17, 18 and 20.
|
|
289
302
|
|
|
290
303
|
## Room chat
|
|
291
304
|
|
|
@@ -314,7 +327,8 @@ npx homie-studio chat words # the bu
|
|
|
314
327
|
- **Moderation, in the studio's own Worker, before anyone sees a typed line:** a built-in word list and patterns
|
|
315
328
|
(slurs, sexual words, self-harm, personal questions and contact details, links, swears, your own words), then
|
|
316
329
|
Cloudflare's **Clef** decision model on the studio's own Workers AI (`@cf/cloudflare/clef-flash`, about 2.3
|
|
317
|
-
neurons a message measured, 2,000 a day by default: inside the free allocation
|
|
330
|
+
neurons a message measured, 2,000 a day by default: inside the free allocation; under `dev`, Clef on this computer
|
|
331
|
+
when Ollama has it, free). Emoji and quick lines are never reviewed.
|
|
318
332
|
- **The owner:** every live room's last minutes in the office with Remove, Mute and Kick (from a line, with their
|
|
319
333
|
lines taken down), the same in the owner's own game, the reports players make, and Announce (a studio line).
|
|
320
334
|
- **Speech bubbles:** `net.on('say')` and the port kit's `createBubbles` / `paintBubbles`; Gem Rush, Ember Vale and
|
|
@@ -366,7 +380,8 @@ connector (the AI says whether its Homie tools are there; the directory is reach
|
|
|
366
380
|
with the studio's own Wrangler, and the account's email verified: a deploy that went through proves it, a
|
|
367
381
|
deploy refused for it is remembered), Chrome for the checks, ffmpeg, and the optional GitHub, ElevenLabs and
|
|
368
382
|
fal (and, in Claude Code, the status line); in a studio that sells (shop.json), a Stripe row: whether Stripe's own
|
|
369
|
-
agent plugin (its MCP server) is set up for this AI, with `stripe agent setup` as the fix (0.24.3)
|
|
383
|
+
agent plugin (its MCP server) is set up for this AI, with `stripe agent setup` as the fix (0.24.3); and in a studio, whether Clef is on this
|
|
384
|
+
computer (Ollama with `clef-flash`, optional, never downloaded without the person's yes) (0.24.4). Each row is green, missing or "do this now", says what it
|
|
370
385
|
unlocks, and gives the exact fix: a command the AI runs or a page the person taps. It only reads, answers in
|
|
371
386
|
seconds, and never prints a key, a token or an account's name or id. A studio the Claude app's setup card made
|
|
372
387
|
(`setup attach`) counts as connected to Cloudflare and GitHub there. `--json` gives the rows.
|
package/agents/GUIDES.md
CHANGED
|
@@ -10,8 +10,8 @@ Vale (`homie-studio game new <id> --from ember-vale`) is the worked example.
|
|
|
10
10
|
- A beginner server whose AI guide seats (always marked "· AI") follow new heroes, lead them to places, take on a
|
|
11
11
|
quest with them, and pull back when asked.
|
|
12
12
|
- **Hands** are your game's own bot code, every frame, at the party's skill dial. **The brain** picks a goal every
|
|
13
|
-
few seconds: the studio's own Workers AI, the owner's own key, or the owner's Claude in a seat. With none
|
|
14
|
-
(or over the day's budget) your scripted `decide` plays.
|
|
13
|
+
few seconds: the studio's own Workers AI (Clef), the owner's own key, or the owner's Claude in a seat. With none
|
|
14
|
+
of them (or over the day's budget) your scripted `decide` plays.
|
|
15
15
|
- The AI never types. It chooses ids from your `agents.json`; your game draws your own words.
|
|
16
16
|
|
|
17
17
|
## 1. Upgrade
|
|
@@ -86,10 +86,17 @@ const agents = useAgents(room.net, vocab as unknown as Vocabulary, {
|
|
|
86
86
|
|
|
87
87
|
1. `homie-studio servers new <id> "First Steps" --policy beginner --guides 2` (add `--kids` for children).
|
|
88
88
|
2. `homie-studio agents brain <id> first-steps workers-ai`: the first time is an ASK the owner taps; then
|
|
89
|
-
`npm run deploy` once (it binds Workers AI). The
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
`npm run deploy` once (it binds Workers AI). The guides think with Cloudflare's Clef decision model
|
|
90
|
+
(`@cf/cloudflare/clef-flash`, the default since 0.24.4): it answers each decision as typed Choices of your
|
|
91
|
+
goals, values and lines, so it does what an ask asks far more often than a chat model did. About 9 neurons a
|
|
92
|
+
decision: the default budget of 8,000 a day is about 900 decisions (the free allocation is 10,000). Or
|
|
93
|
+
`owner-key` with `agents brain key` on the owner's own computer.
|
|
94
|
+
3. Try it: `homie-studio agents try <id> --view view.json --ask ask_help:quest=<quest> --from 0` shows what the
|
|
95
|
+
brain would decide in that moment (with `--model` to compare one). Then two browsers on the server's page, walk
|
|
96
|
+
up to a guide, tap "Help me with ...". In a terminal or the local MCP, `agent_sit { game: "<id>", server:
|
|
97
|
+
"first-steps" }` puts your own Claude in a guide's seat; `brain: "local"` lets Clef on your computer decide.
|
|
98
|
+
Under `npm run dev` the guides think with Clef on your own computer when Ollama has `clef-flash` (free; it is
|
|
99
|
+
about 11 GB and only ever downloaded when you say so: `ollama pull clef-flash`).
|
|
93
100
|
|
|
94
101
|
## 5. Do not
|
|
95
102
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -18,8 +18,10 @@
|
|
|
18
18
|
* homie-studio games
|
|
19
19
|
* homie-studio build [<id>] [--maps] (--maps: also keep each bundle's source map and module sizes in .studio/maps/<id>/,
|
|
20
20
|
* never in site/dist: what `perf` reads a CPU profile through)
|
|
21
|
-
* homie-studio dev [--port 8787] [--remote-ai] (--stop: stop exactly this studio's dev server, nothing else;
|
|
22
|
-
* AI guides
|
|
21
|
+
* homie-studio dev [--port 8787] [--remote-ai] [--no-local-ai] (--stop: stop exactly this studio's dev server, nothing else;
|
|
22
|
+
* AI guides, chat review and game decisions think with Clef on this computer
|
|
23
|
+
* when Ollama has clef-flash (free; dev never downloads it), else scripted;
|
|
24
|
+
* --remote-ai: the real Workers AI, billed)
|
|
23
25
|
* homie-studio check <id> [--url <site>] [--shots <dir>]
|
|
24
26
|
* homie-studio perf <id> [--url <site>] [--device computer,phone] [--runs 1] [--seconds 15] [--warm 3] [--profile]
|
|
25
27
|
* [--cpu 4] [--out <dir>] [--max-load 0.8] [--pair <label>]
|
|
@@ -121,6 +123,11 @@
|
|
|
121
123
|
* time AI guides may talk are ASKED for, like office kick; --budget is
|
|
122
124
|
* Workers AI neurons a day, or dollars a day for the owner's key)
|
|
123
125
|
* homie-studio agents brain key [--remove] (the owner's AI key, typed into a page on this computer only)
|
|
126
|
+
* homie-studio agents sit <game> [--server <id>] [--brain local] (a guide's seat from this terminal; --brain local:
|
|
127
|
+
* Clef on this computer decides every few seconds, free; never downloads it)
|
|
128
|
+
* homie-studio agents try <game> --view <file.json> [--ask <ask>[:<arg>=<value>] --from <seat>] [--model <id>]
|
|
129
|
+
* (what the guides' brain would decide in that moment, with no seat taken;
|
|
130
|
+
* spent from the guides' day; --model compares a model before setting it)
|
|
124
131
|
* homie-studio shop is the studio's shop selling (its own Stripe), and if not, what is missing
|
|
125
132
|
* homie-studio shop init [--supporter] [--currency usd] [--price 500] [--managed] shop.json and SELLING.md
|
|
126
133
|
* homie-studio shop check shop.json against the kit's rules (real money, nothing random, the kids rules)
|
|
@@ -245,7 +252,8 @@ import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/
|
|
|
245
252
|
import { playersOwner, playersShow } from '../lib/players.mjs';
|
|
246
253
|
import { officeAnnounce, officeClose, officeInvite, officeKey, officeKick, officeLaunch, officeLines, officeLink, officeMute, officeRevoke, officeShow } from '../lib/office.mjs';
|
|
247
254
|
import { chatBudget, chatLines, chatRemove, chatRulesSet, chatShow, chatWords } from '../lib/chat-cli.mjs';
|
|
248
|
-
import {
|
|
255
|
+
import { detectLocalAi, localAiVars } from '../lib/local-ai.mjs';
|
|
256
|
+
import { agentsBrain, agentsBrainKey, agentsPass, agentsPasses, agentsRevoke, agentsTry, serversClose, serversLevel, serversLines, serversList, serversMember, serversNew, serversSet } from '../lib/servers.mjs';
|
|
249
257
|
import { AgentSeat } from '../lib/agent-seat.mjs';
|
|
250
258
|
import { shopCheck, shopConnect, shopDisconnect, shopInit, shopLines, shopOrders, shopRefund, shopStatements, shopStatus } from '../lib/shop.mjs';
|
|
251
259
|
import { catalogLines, catalogPlan } from '../lib/shop-catalog.mjs';
|
|
@@ -263,7 +271,7 @@ import { formatHandoff, handoff } from '../lib/handoff.mjs';
|
|
|
263
271
|
|
|
264
272
|
const argv = process.argv.slice(2);
|
|
265
273
|
const flags = new Map();
|
|
266
|
-
const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'supporter', 'managed', 'live', 'send', 'accept-tos'];
|
|
274
|
+
const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'supporter', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai'];
|
|
267
275
|
const positional = [];
|
|
268
276
|
for (let i = 0; i < argv.length; i++) {
|
|
269
277
|
const a = argv[i];
|
|
@@ -825,8 +833,9 @@ async function main() {
|
|
|
825
833
|
if (sub === 'revoke') return agentsRevoke(root, positional[2], { url });
|
|
826
834
|
if (sub === 'brain' && positional[2] === 'key') return agentsBrainKey(root, { remove: flags.has('remove'), log });
|
|
827
835
|
if (sub === 'brain') return agentsBrain(root, positional[2], positional[3], positional[4], { url, budget: flags.get('budget') });
|
|
828
|
-
if (sub === 'sit') return agentsSit(root, positional[2], { url, server: flags.get('server'), pass: flags.get('pass'), label: flags.get('label') });
|
|
829
|
-
return {
|
|
836
|
+
if (sub === 'sit') return agentsSit(root, positional[2], { url, server: flags.get('server'), pass: flags.get('pass'), label: flags.get('label'), brain: flags.get('brain') });
|
|
837
|
+
if (sub === 'try') return agentsTry(root, positional[2], { url, view: flags.get('view'), ask: flags.get('ask'), from: flags.get('from'), model: flags.get('model'), mode: flags.get('mode'), quiet: flags.has('quiet'), avoid: flags.get('avoid'), players: flags.get('players'), readFile: (f) => readFileSync(resolve(f), 'utf8') });
|
|
838
|
+
return { ok: false, command: 'agents', why: `unknown: agents ${sub ?? ''} (pass, passes, revoke, brain, brain key, sit, try)` };
|
|
830
839
|
}
|
|
831
840
|
if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
|
|
832
841
|
if (cmd === 'media' && sub === 'move') return mediaMove(root, { paths: positional.slice(2), dryRun: flags.has('dry-run') || flags.has('plan'), verify: flags.has('verify'), log });
|
|
@@ -1030,9 +1039,17 @@ async function dev(root) {
|
|
|
1030
1039
|
log(`Local site: http://127.0.0.1:${port}/ (each game: http://127.0.0.1:${port}/<id>/play — open it in two browsers)`);
|
|
1031
1040
|
const ai = devConfig(root, flags.has('remote-ai'));
|
|
1032
1041
|
if (ai.note) log(ai.note);
|
|
1042
|
+
// The person's own Clef (0.24.4, lib/local-ai.mjs): with Ollama and clef-flash on this computer, the local Worker's
|
|
1043
|
+
// guides, chat review and game decisions think here, free. Never a download: dev only says what one would cost.
|
|
1044
|
+
let localVars = [];
|
|
1045
|
+
if (!flags.has('remote-ai') && !flags.has('no-local-ai')) {
|
|
1046
|
+
const found = await detectLocalAi();
|
|
1047
|
+
localVars = localAiVars(found);
|
|
1048
|
+
log(found.ok ? `AI guides, chat review and game decisions think with ${found.model} on this computer (Ollama ${found.version}): free, nothing sent to Cloudflare.` : found.say);
|
|
1049
|
+
}
|
|
1033
1050
|
// --remote-ai: Wrangler's --local turns every remote binding off ("not supported"), so a dev with the real Workers AI
|
|
1034
1051
|
// (the guides' brains, room chat's review) runs without it; everything else stays local all the same.
|
|
1035
|
-
const child = spawn(bin, ['dev', ...(flags.has('remote-ai') ? [] : ['--local']), '--ip', '127.0.0.1', '--port', port, ...ai.args], { cwd: workerDir(root), env, stdio: 'inherit' });
|
|
1052
|
+
const child = spawn(bin, ['dev', ...(flags.has('remote-ai') ? [] : ['--local']), '--ip', '127.0.0.1', '--port', port, ...ai.args, ...localVars], { cwd: workerDir(root), env, stdio: 'inherit' });
|
|
1036
1053
|
mkdirSync(dirname(devFile(root)), { recursive: true });
|
|
1037
1054
|
writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
|
|
1038
1055
|
log(`Stop it with: npx --no-install homie-studio dev --stop (this studio's dev server only)`);
|
|
@@ -1063,13 +1080,20 @@ function devConfig(root, remoteAi) {
|
|
|
1063
1080
|
const copy = join(base, '.wrangler', 'homie-dev.wrangler.json');
|
|
1064
1081
|
mkdirSync(dirname(copy), { recursive: true });
|
|
1065
1082
|
writeFileSync(copy, `${JSON.stringify(json, null, 2)}\n`);
|
|
1066
|
-
return { args: ['--config', copy, '--persist-to', join(base, '.wrangler', 'state')], note: remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : '
|
|
1083
|
+
return { args: ['--config', copy, '--persist-to', join(base, '.wrangler', 'state')], note: remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : 'No Workers AI under dev (--remote-ai for the real one, billed): AI guides think with Clef on this computer when Ollama has it, else scripted.' };
|
|
1067
1084
|
}
|
|
1068
1085
|
|
|
1069
1086
|
/** `agents sit`: an AI guide's seat from this terminal, for a demo or a test of a vocabulary. Lines on stdin act. */
|
|
1070
|
-
async function agentsSit(root, game, { url, server, pass, label }) {
|
|
1087
|
+
async function agentsSit(root, game, { url, server, pass, label, brain }) {
|
|
1071
1088
|
const site = url ?? siteUrl(root);
|
|
1072
|
-
|
|
1089
|
+
// --brain local: Clef on this computer decides (lib/local-ai.mjs); never a download, only what one would cost.
|
|
1090
|
+
let local = null;
|
|
1091
|
+
if (brain === 'local') {
|
|
1092
|
+
local = await detectLocalAi();
|
|
1093
|
+
if (!local.ok) return { ok: false, command: 'agents sit', why: local.say };
|
|
1094
|
+
log(`This seat thinks with ${local.model} on this computer (Ollama ${local.version}) every few seconds. Type stand to leave.`);
|
|
1095
|
+
} else if (brain !== undefined) return { ok: false, command: 'agents sit', why: '--brain is local (Clef on this computer), or leave it out to choose yourself' };
|
|
1096
|
+
const seat = new AgentSeat({ site, game, server: server ?? null, pass: pass ?? null, label: label ?? (local ? 'Clef' : 'Claude'), root, brain: local ? 'local' : null, local });
|
|
1073
1097
|
const r = await seat.sit();
|
|
1074
1098
|
if (!r.ok) return { ok: false, command: 'agents sit', why: r.why };
|
|
1075
1099
|
log(`Seated as ${r.name} in ${r.room} (seat ${r.seat}). Type: look | do <goal> {"arg":…} | say <line> {"arg":…} with a goal | stand`);
|
package/chat/CHAT.md
CHANGED
|
@@ -90,6 +90,8 @@ accuracy numbers for it, and the message it reads is a player's, so a player can
|
|
|
90
90
|
(less of a risk than with a model that writes, since it only scores fixed answers). That is why
|
|
91
91
|
**the word list always runs first and decides alone** whenever the review cannot (no binding, the
|
|
92
92
|
day's budget used, an error, a slow answer). `HOMIE_CHAT_MODEL` switches to Clef or Llama Guard.
|
|
93
|
+
Under `homie-studio dev` with no Workers AI binding, the same question goes to Clef on the person's
|
|
94
|
+
own computer when Ollama has `clef-flash` (0.24.4): free, nothing sent to Cloudflare.
|
|
93
95
|
Firewall for AI (now "AI Security for Apps") was ruled out: it reads HTTP requests at the zone, not
|
|
94
96
|
messages inside a Durable Object, and its detections are an Enterprise add-on.
|
|
95
97
|
|
package/lib/agent-seat.mjs
CHANGED
|
@@ -8,8 +8,13 @@
|
|
|
8
8
|
*
|
|
9
9
|
* The pass: one the owner gave (`hap_…`), or, from a studio folder, a one-day guide pass minted with the owner's office
|
|
10
10
|
* key and revoked when the AI stands. The secret stays in this process; it is never printed or returned.
|
|
11
|
+
*
|
|
12
|
+
* BRAIN 'local' (0.24.4): instead of an AI's turns, Cloudflare's Clef decision model on this computer (Ollama with
|
|
13
|
+
* clef-flash, lib/local-ai.mjs) decides every few seconds, the way a house guide does (worker/agents.mjs think: the same
|
|
14
|
+
* questions, the same fixed rules), for free and with nothing sent to Cloudflare. agent_do still overrides it.
|
|
11
15
|
*/
|
|
12
|
-
import { parseDecision, renderLine, vocabularyOf } from '../worker/brain.mjs';
|
|
16
|
+
import { clefDecide, parseDecision, renderLine, vocabularyOf } from '../worker/brain.mjs';
|
|
17
|
+
import { think } from '../worker/agents.mjs';
|
|
13
18
|
import { withKey } from './office.mjs';
|
|
14
19
|
|
|
15
20
|
const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
@@ -26,7 +31,7 @@ export class AgentSeat {
|
|
|
26
31
|
* @param {string} [o.pass] an agent pass (hap_…); else one is minted from `root` with the office key
|
|
27
32
|
* @param {string} [o.root] the studio folder (for a pass of its own)
|
|
28
33
|
*/
|
|
29
|
-
constructor({ site, game, server = null, pass = null, label = 'Claude', root = null, WebSocketImpl = globalThis.WebSocket, fetchImpl = globalThis.fetch }) {
|
|
34
|
+
constructor({ site, game, server = null, pass = null, label = 'Claude', root = null, WebSocketImpl = globalThis.WebSocket, fetchImpl = globalThis.fetch, brain = null, local = null }) {
|
|
30
35
|
this.site = String(site ?? '').replace(/\/+$/, '');
|
|
31
36
|
this.game = game;
|
|
32
37
|
this.server = server;
|
|
@@ -52,6 +57,10 @@ export class AgentSeat {
|
|
|
52
57
|
this.errors = [];
|
|
53
58
|
this.vocab = null;
|
|
54
59
|
this.ping = null;
|
|
60
|
+
/** brain 'local': Clef on this computer ({ base, model }) decides; its last decisions, and who said no thanks. */
|
|
61
|
+
this.brain = brain === 'local' && local ? 'local' : null;
|
|
62
|
+
this.local = local;
|
|
63
|
+
this.auto = { timer: null, busy: false, at: 0, decisions: [], avoid: new Map(), said: [], goalState: null };
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
async sit() {
|
|
@@ -101,9 +110,56 @@ export class AgentSeat {
|
|
|
101
110
|
this.ping = setInterval(() => { try { ws.send(JSON.stringify({ t: 'ping', c: Date.now() })); } catch { /* closed */ } }, 2000);
|
|
102
111
|
// The host shows a guide the game every 2 s: wait for the first look (up to 6 s).
|
|
103
112
|
for (let i = 0; i < 30 && !this.view && !this.closed; i += 1) await wait(200);
|
|
113
|
+
if (this.brain === 'local') this.autopilot();
|
|
104
114
|
return { ok: true, seat: this.seat, name: this.name, room: this.room, server: this.server, ...this.look() };
|
|
105
115
|
}
|
|
106
116
|
|
|
117
|
+
/**
|
|
118
|
+
* BRAIN 'local': a decision on a new ask (at once), when the goal in force ends, else every 12 s while the room has a
|
|
119
|
+
* view; never more than one at a time, and do() keeps the 3 s pace and the 8 s quiet. No thanks holds that player off
|
|
120
|
+
* for 10 minutes.
|
|
121
|
+
*/
|
|
122
|
+
autopilot() {
|
|
123
|
+
const tick = async () => {
|
|
124
|
+
if (this.closed) { this.stopAuto(); return; }
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
const a = this.auto;
|
|
127
|
+
const asks = this.asks.filter((x) => now - x.at < 30_000);
|
|
128
|
+
const state = this.view?.goal && typeof this.view.goal === 'object' ? this.view.goal.state ?? null : null;
|
|
129
|
+
const ended = (state === 'done' || state === 'failed') && a.goalState !== state;
|
|
130
|
+
a.goalState = state;
|
|
131
|
+
if (a.busy || !this.view || !(asks.length || ended || now - a.at >= 12_000)) return;
|
|
132
|
+
a.busy = true;
|
|
133
|
+
a.at = now;
|
|
134
|
+
for (const [s, until] of a.avoid) if (until <= now) a.avoid.delete(s);
|
|
135
|
+
for (const x of asks) if (this.vocab?.asks?.[x.k]?.leave) a.avoid.set(x.from, now + 10 * 60_000);
|
|
136
|
+
try {
|
|
137
|
+
const party = Array.isArray(this.view.party) ? this.view.party.map((p) => p?.seat).filter(Number.isInteger) : [];
|
|
138
|
+
const ctx = {
|
|
139
|
+
view: this.view, me: { seat: this.seat }, goal: this.view.goal ?? null, asks, party: this.party.filter((p) => now - p.at < 60_000),
|
|
140
|
+
speech: this.policy?.speech ?? 'lines', kids: this.policy?.kids === true, quiet: !this.talking() || now - this.sayAt < 8000,
|
|
141
|
+
players: party, avoid: [...a.avoid.keys()], said: a.said.map((x) => ({ ...x, ago: now - x.at })),
|
|
142
|
+
};
|
|
143
|
+
const local = this.local;
|
|
144
|
+
const t = await think(this.vocab, { mode: 'local', brain: { engine: 'local', run: (args) => clefDecide({}, { vocab: args.vocab, ctx: args.ctx, model: local.model, ms: 8000, local, fetch: this.fetch }) }, ctx });
|
|
145
|
+
const d = t.decision;
|
|
146
|
+
let sent = null;
|
|
147
|
+
if (d?.goal && !this.closed) {
|
|
148
|
+
const say = d.say && this.talking() && Date.now() - this.sayAt >= 8000 ? d.say : null;
|
|
149
|
+
sent = await this.do({ goal: d.goal, args: d.args, say, sayArgs: say ? d.sayArgs : {} });
|
|
150
|
+
if (sent.ok && say) a.said = [...a.said, { line: say, args: d.sayArgs, at: Date.now() }].slice(-4);
|
|
151
|
+
}
|
|
152
|
+
a.decisions = [...a.decisions, { at: new Date(now).toISOString(), provider: t.provider, goal: d?.goal ?? null, args: d?.args ?? null, say: d?.say ?? null, ms: t.ms, ...(t.why ? { why: t.why } : {}), ...(sent && !sent.ok ? { refused: sent.why } : {}) }].slice(-20);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
a.decisions = [...a.decisions, { at: new Date(now).toISOString(), provider: 'none', why: String(error?.message ?? error).slice(0, 120) }].slice(-20);
|
|
155
|
+
} finally { a.busy = false; }
|
|
156
|
+
};
|
|
157
|
+
this.auto.timer = setInterval(() => { void tick(); }, 1000);
|
|
158
|
+
void tick();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
stopAuto() { if (this.auto.timer) clearInterval(this.auto.timer); this.auto.timer = null; }
|
|
162
|
+
|
|
107
163
|
onFrame(m) {
|
|
108
164
|
switch (m.t) {
|
|
109
165
|
case 'welcome': this.seat = m.seat; this.name = m.name; this.policy = m.policy ?? null; return;
|
|
@@ -135,6 +191,7 @@ export class AgentSeat {
|
|
|
135
191
|
asks: this.asks.filter((a) => now - a.at < 60_000).map((a) => ({ ask: a.k, args: a.args, seat: a.from, text: v ? renderLine(v, a.k, a.args, { kind: 'asks', nameOf: (s) => `seat ${s}` }) : null, secondsAgo: Math.round((now - a.at) / 1000) })),
|
|
136
192
|
party: this.party.filter((p) => now - p.at < 60_000).map((p) => ({ seat: p.seat, line: p.line, args: p.args })),
|
|
137
193
|
lastDo: this.lastDo,
|
|
194
|
+
...(this.brain === 'local' ? { brain: { local: this.local.model, decisions: this.auto.decisions.slice(-5) } } : {}),
|
|
138
195
|
choices: v ? { goals: describe(v.goals), lines: describe(v.lines) } : null,
|
|
139
196
|
errors: this.errors.slice(-3),
|
|
140
197
|
};
|
|
@@ -162,6 +219,7 @@ export class AgentSeat {
|
|
|
162
219
|
talking() { return Boolean(this.policy && ['workers-ai', 'owner-key'].includes(this.policy.brain) && this.policy.speech !== 'off'); }
|
|
163
220
|
|
|
164
221
|
close() {
|
|
222
|
+
this.stopAuto();
|
|
165
223
|
if (this.ping) clearInterval(this.ping);
|
|
166
224
|
this.ping = null;
|
|
167
225
|
if (this.ws) { try { this.ws.send(JSON.stringify({ t: 'bye' })); } catch { /* closed */ } try { this.ws.close(1000, 'bye'); } catch { /* closed */ } }
|
package/lib/brain-probe.mjs
CHANGED
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
* doctor asks once.
|
|
7
7
|
*
|
|
8
8
|
* The check is ONE tiny call to the model through Cloudflare's REST API (`POST /accounts/:id/ai/run/:model`, a
|
|
9
|
-
* one-word prompt, max_tokens 1:
|
|
9
|
+
* one-word prompt, max_tokens 1, or for a Clef decision model one yes/no question: a few of the 10,000 free neurons a day), with the studio's own Wrangler login
|
|
10
10
|
* (`wrangler auth token`, held in this process only) or CLOUDFLARE_API_TOKEN. It never prints the token, the account's
|
|
11
11
|
* id or its name. A call that cannot be made (not signed in, several accounts and none named) says so and spends
|
|
12
12
|
* nothing.
|
|
13
13
|
*/
|
|
14
14
|
import { existsSync, readFileSync } from 'node:fs';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
|
-
import { DEFAULT_MODEL } from '../worker/brain.mjs';
|
|
16
|
+
import { CLEF, DEFAULT_MODEL } from '../worker/brain.mjs';
|
|
17
17
|
import { configPath, workerDir } from './studio.mjs';
|
|
18
18
|
|
|
19
19
|
const MODELS = 'https://developers.cloudflare.com/workers-ai/models/';
|
|
@@ -96,7 +96,8 @@ export async function probeModel({ model, accountId, headers, fetchFn = globalTh
|
|
|
96
96
|
const res = await fetchFn(`${String(base).replace(/\/+$/, '')}/accounts/${encodeURIComponent(accountId)}/ai/run/${model}`, {
|
|
97
97
|
method: 'POST',
|
|
98
98
|
headers: { ...headers, 'content-type': 'application/json', accept: 'application/json', 'user-agent': 'homie-studio-doctor' },
|
|
99
|
-
|
|
99
|
+
// A decision model (Clef) takes a state and a question, not a chat message: one yes/no, a few hundred tokens.
|
|
100
|
+
body: JSON.stringify(CLEF.models[model] ? { model: CLEF.models[model], state: 'OK', questions: { ok: { type: 'noul', instructions: 'Does the state say OK?' } } } : { messages: [{ role: 'user', content: 'Say OK.' }], max_tokens: 1 }),
|
|
100
101
|
signal: AbortSignal.timeout(ms),
|
|
101
102
|
});
|
|
102
103
|
return { status: res.status, body: await res.json().catch(() => null) };
|
|
@@ -122,7 +123,7 @@ export async function workersAiRow({ root, studio, env = process.env, exec, fetc
|
|
|
122
123
|
const where = set ? 'HOMIE_BRAIN_MODEL in wrangler.jsonc' : 'the toolkit\'s default';
|
|
123
124
|
const pick = (lead) => ({
|
|
124
125
|
who: 'ai', open: MODELS,
|
|
125
|
-
say: `${lead}Pick a model the account can run: set HOMIE_BRAIN_MODEL in wrangler.jsonc "vars" to a
|
|
126
|
+
say: `${lead}Pick a model the account can run: set HOMIE_BRAIN_MODEL in wrangler.jsonc "vars" to a model from ${MODELS} that is not marked Workers Paid (Clef, @cf/cloudflare/clef-flash, or a text-generation model)${set && model !== DEFAULT_MODEL ? ` (or remove it for the default, ${DEFAULT_MODEL})` : ''}, run \`${cli} doctor\` again, then \`npm run deploy\`. Until then the guides answer from the game's script.`,
|
|
126
127
|
});
|
|
127
128
|
const money = ' Moving the account to Workers Paid ($5 a month) also works, but that is the person\'s money: ask them, never decide it.';
|
|
128
129
|
if (!MODEL_ID.test(model)) return { ...row, state: 'act', detail: `${where} is "${model.slice(0, 80)}", which is not a Workers AI model id (@cf/<maker>/<model>)`, fix: pick('') };
|
|
@@ -145,7 +146,7 @@ export async function workersAiRow({ root, studio, env = process.env, exec, fetc
|
|
|
145
146
|
case 'missing': return { ...row, state: 'act', detail: `Cloudflare has no model ${model} (renamed or retired)${said}`, fix: pick('') };
|
|
146
147
|
case 'not-allowed': return { ...row, state: 'act', detail: `this account may not use ${model} (a private model)${said}`, fix: pick('') };
|
|
147
148
|
case 'agreement': return { ...row, state: 'act', detail: `${model} asks for its maker's licence to be agreed once before it is used${said}`, fix: pick('Agreeing to a licence is the person\'s to do, never yours; or simply: ') };
|
|
148
|
-
case 'input': return { ...row, state: 'act', detail: `${model} did not take a chat message: the guides need a text-generation model${said}`, fix: pick('') };
|
|
149
|
+
case 'input': return { ...row, state: 'act', detail: `${model} did not take a chat message: the guides need Clef or a text-generation model${said}`, fix: pick('') };
|
|
149
150
|
case 'allowance': return { ...row, state: 'later', detail: `today's free Workers AI allowance (10,000 neurons for the account) is used up, so ${model} could not be checked; the guides answer from the script until 00:00 UTC`, fix: { who: 'person', say: `Nothing to do now: run doctor again after 00:00 UTC. The studio's daily budget (\`${cli} agents brain <game> <server> workers-ai --budget <neurons>\`) keeps the guides under the allowance.` } };
|
|
150
151
|
case 'busy': return { ...row, state: 'unknown', detail: `Workers AI was busy just now (status ${answer.status})${said}; run doctor again in a minute`, fix: null };
|
|
151
152
|
case 'auth': return { ...row, state: 'unknown', detail: `Cloudflare did not let this sign-in run Workers AI (status ${answer.status})${said}`, fix: { who: 'ai', run: 'npx wrangler login', say: 'Sign in to Cloudflare again (the login includes Workers AI), then run doctor again.' } };
|
package/lib/build.mjs
CHANGED
|
@@ -321,6 +321,8 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
321
321
|
// Room chat (NETPLAY.md section 19): game.json "chat", checked here (its quick lines pass the chat floor too).
|
|
322
322
|
const chatBad = chatProblems(g.chat);
|
|
323
323
|
if (chatBad.length) throw new Error(`games/${g.id}/game.json: ${chatBad.join('; ')}`);
|
|
324
|
+
// A game's own decisions (NETPLAY.md section 20): game.json "decide" is true or false (the Worker answers net.decide only then).
|
|
325
|
+
if (g.decide !== undefined && typeof g.decide !== 'boolean') throw new Error(`games/${g.id}/game.json: "decide" is true (the host may ask the studio's decision model, within the AI brains' day) or false`);
|
|
324
326
|
// The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
|
|
325
327
|
if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id, { studio: studio.name ?? null, game: g.name ?? g.id, license: g.license }))}\n`);
|
|
326
328
|
// Its assets' licences, and the address and SHA-256 of each one a remix may carry (`game remix` fetches them).
|
|
@@ -368,6 +370,7 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
368
370
|
// game.json `"chat"` (0.23.0, NETPLAY.md section 19): the room chat's defaults for this game (the owner's office
|
|
369
371
|
// overrides them), or false for none.
|
|
370
372
|
...(g.chat === false ? { chat: false } : g.chat && typeof g.chat === 'object' ? { chat: g.chat } : {}),
|
|
373
|
+
...(g.decide === true ? { decide: true } : {}),
|
|
371
374
|
// Servers (0.16.0): the seeds game.json ships, the netplay revision its build speaks (the office warns when it
|
|
372
375
|
// predates servers), and game.json "agents": { "vote": "game" | false } (the game draws its own vote card, or none).
|
|
373
376
|
...(seeds.length ? { servers: seeds } : {}),
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -282,6 +282,8 @@ export function needsWorkersAi(root, w, db) {
|
|
|
282
282
|
// Room chat (0.23.0): a game whose players may type has its messages reviewed by the studio's own Workers AI
|
|
283
283
|
// (Cloudflare's Clef decision model, within a day's budget of the free allocation) unless its game.json says no.
|
|
284
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;
|
|
285
287
|
} catch { /* not built */ }
|
|
286
288
|
const r = w(['d1', 'execute', db, '--remote', '--json', '--command', "SELECT COUNT(*) AS n FROM servers WHERE brain = 'workers-ai' AND state = 'open'"]);
|
|
287
289
|
if (r.code !== 0) return false;
|
package/lib/doctor.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* Stripe for selling only in a studio with shop.json (0.24.3): Stripe's own agent plugin (its MCP server
|
|
20
20
|
* and skills) for this AI, set up when the studio starts selling; the shop's key itself
|
|
21
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)
|
|
22
23
|
* status line optional (Claude Code only) the build's progress under the prompt
|
|
23
24
|
*
|
|
24
25
|
* It is safe at any time, inside a studio or before one exists: it only reads (local files, `--version` of a few
|
|
@@ -31,6 +32,7 @@ import { spawn } from 'node:child_process';
|
|
|
31
32
|
import { existsSync, readFileSync } from 'node:fs';
|
|
32
33
|
import { join } from 'node:path';
|
|
33
34
|
import { workersAiRow } from './brain-probe.mjs';
|
|
35
|
+
import { detectLocalAi } from './local-ai.mjs';
|
|
34
36
|
import { findChrome } from './chrome.mjs';
|
|
35
37
|
import { whyFailed } from './net.mjs';
|
|
36
38
|
import { isOurs } from './statusline.mjs';
|
|
@@ -228,6 +230,18 @@ export async function setupStatus({
|
|
|
228
230
|
});
|
|
229
231
|
}
|
|
230
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
|
+
|
|
231
245
|
// GitHub.
|
|
232
246
|
rows.push({
|
|
233
247
|
id: 'github', label: 'GitHub', need: 'optional', state: gh.signedIn || gh.onGitHub ? 'ok' : 'optional',
|
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
|
},
|
package/lib/scaffold.mjs
CHANGED
|
@@ -342,6 +342,13 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
342
342
|
it only ASKS. The AI never types: it picks a goal and a line id; the relay drops anything else. With no AI, over
|
|
343
343
|
budget, or between decisions, the game's \`decide\` plays. \`agent_sit\` (the local MCP) puts the owner's own
|
|
344
344
|
Claude in a guide's seat.
|
|
345
|
+
- **Clef (0.24.4):** Workers AI's guides think with Cloudflare's decision model \`@cf/cloudflare/clef-flash\` (it
|
|
346
|
+
picks among the vocabulary's goals, values and lines; it never writes text; about 9 neurons a decision).
|
|
347
|
+
\`agents try <id> --view <file> [--ask …] [--model …]\` shows what the brain would decide in one moment. Under
|
|
348
|
+
\`npm run dev\`, Clef on this computer (Ollama with \`clef-flash\`) runs the guides, chat review and game
|
|
349
|
+
decisions for free when it is here; **never download it without the person's yes** (\`ollama pull clef-flash\` is
|
|
350
|
+
about 11 GB). A game whose game.json says \`"decide": true\` may ask \`net.decide(state, questions, { floor })\`
|
|
351
|
+
on the host (\`NETPLAY.md\` section 20): per beat or per turn, never per frame.
|
|
345
352
|
|
|
346
353
|
## Selling things (the shop)
|
|
347
354
|
|