@homie-rocks/studio 0.14.1 → 0.14.3
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/README.md +3 -3
- package/lib/files.mjs +7 -4
- package/lib/mcp-tools.mjs +99 -7
- package/lib/mcp.mjs +53 -2
- package/lib/pictures.mjs +67 -0
- package/lib/progress.mjs +14 -2
- package/lib/template-history.json +54 -0
- package/package.json +1 -1
- package/worker/version.mjs +1 -1
package/README.md
CHANGED
|
@@ -100,8 +100,8 @@ site. A game made with Homie's arcade controls that knocks for a Homie box (`/__
|
|
|
100
100
|
A studio pins one version, so nothing changes until it asks. To take a newer one:
|
|
101
101
|
|
|
102
102
|
```sh
|
|
103
|
-
npx -y @homie-rocks/studio@0.14.
|
|
104
|
-
npx -y @homie-rocks/studio@0.14.
|
|
103
|
+
npx -y @homie-rocks/studio@0.14.3 upgrade # the plan; changes nothing
|
|
104
|
+
npx -y @homie-rocks/studio@0.14.3 upgrade --apply # after the person agrees
|
|
105
105
|
npm install && npm run build
|
|
106
106
|
```
|
|
107
107
|
|
|
@@ -232,7 +232,7 @@ device; the owner's own passkey account (`homie-studio players owner`) counts as
|
|
|
232
232
|
## Setup status
|
|
233
233
|
|
|
234
234
|
```sh
|
|
235
|
-
npx -y @homie-rocks/studio@0.14.
|
|
235
|
+
npx -y @homie-rocks/studio@0.14.3 setup status --connector yes # before a studio exists
|
|
236
236
|
npx homie-studio setup status # in a studio (also: homie-studio doctor)
|
|
237
237
|
```
|
|
238
238
|
|
package/lib/files.mjs
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
10
|
+
import { PICTURE_MAX, PICTURE_TYPES, pictureFor } from './pictures.mjs';
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
const
|
|
12
|
+
// A picture is at most PICTURE_MAX raw in an answer (lib/pictures.mjs): a bigger one goes as a smaller JPEG copy.
|
|
13
|
+
export const FILE_LIMITS = Object.freeze({ write: 2 * 1024 * 1024, read: 400 * 1024, image: PICTURE_MAX, lines: 2000, list: 600, matches: 200 });
|
|
14
|
+
const IMAGES = PICTURE_TYPES;
|
|
13
15
|
const SKIP_DIRS = new Set(['node_modules', '.git', '.wrangler', '.studio', 'dist']);
|
|
14
16
|
|
|
15
17
|
const inside = (base, p) => p === base || p.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
|
|
@@ -79,8 +81,9 @@ export function readStudioFile(root, path, { offset = 1, limit = FILE_LIMITS.lin
|
|
|
79
81
|
if (st.isDirectory()) throw new Error(`${rel} is a folder (file_list lists it)`);
|
|
80
82
|
const mime = IMAGES[extname(abs).toLowerCase()];
|
|
81
83
|
if (mime) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
// Small enough for one answer (the Claude desktop app refuses one over 1 MB): a big picture goes as a smaller copy.
|
|
85
|
+
const pic = pictureFor(abs, { label: rel });
|
|
86
|
+
return { rel, image: { mimeType: pic.mimeType, data: pic.data }, bytes: pic.bytes, shrunk: pic.shrunk, from: pic.from ?? st.size };
|
|
84
87
|
}
|
|
85
88
|
if (st.size > FILE_LIMITS.read * 8) throw new Error(`${rel} is ${Math.round(st.size / 1024)} KB: too big to read here`);
|
|
86
89
|
const buf = readFileSync(abs);
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* file tools never leave it (lib/files.mjs).
|
|
14
14
|
*/
|
|
15
15
|
import { spawnSync } from 'node:child_process';
|
|
16
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
|
|
16
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
17
17
|
import { homedir } from 'node:os';
|
|
18
18
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
19
19
|
import { renderCodex, newCodex } from './codex.mjs';
|
|
@@ -399,6 +399,80 @@ async function startDev(ctx, root, port) {
|
|
|
399
399
|
return { url: `http://127.0.0.1:${at}`, job, slow: true };
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
+
/* ------------------------------------------------------------------ an earlier attempt, folded in */
|
|
403
|
+
|
|
404
|
+
/** Where a person keeps projects, looked at two levels deep for an earlier folder of a studio's name. */
|
|
405
|
+
const PROJECT_ROOTS = ['dev', 'Developer', 'code', 'Code', 'src', 'projects', 'Projects', 'repos', 'git', 'work', 'Documents', 'Desktop'];
|
|
406
|
+
const TEXT_NOTES = new Set(['.md', '.markdown', '.txt', '.json', '.yaml', '.yml', '.toml', '.csv', '.html', '.css', '.js', '.mjs', '.ts', '.tsx']);
|
|
407
|
+
const SKIP = new Set(['node_modules', '.git', 'Library', 'dist', '.studio', '.wrangler']);
|
|
408
|
+
|
|
409
|
+
/** Folders named like the studio that are not studios: [{ path, files }] (at most 3). */
|
|
410
|
+
export function earlierFolders(ctx, names, own, home = homedir()) {
|
|
411
|
+
const want = new Set(names.filter(Boolean).map((n) => String(n).toLowerCase()));
|
|
412
|
+
const roots = [ctx.studiosDir, home, ...PROJECT_ROOTS.map((r) => join(home, r))].filter((r) => r && existsSync(r));
|
|
413
|
+
const found = new Map();
|
|
414
|
+
const look = (dir, depth) => {
|
|
415
|
+
let entries = [];
|
|
416
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
417
|
+
for (const e of entries) {
|
|
418
|
+
if (found.size >= 3) return;
|
|
419
|
+
if (!e.isDirectory() || e.name.startsWith('.') || SKIP.has(e.name)) continue;
|
|
420
|
+
const p = join(dir, e.name);
|
|
421
|
+
if (want.has(e.name.toLowerCase()) && realOr(p) !== realOr(own) && !existsSync(join(p, 'studio.json'))) {
|
|
422
|
+
let files = [];
|
|
423
|
+
try { files = readdirSync(p).filter((f) => !f.startsWith('.')).slice(0, 12); } catch { files = []; }
|
|
424
|
+
found.set(realOr(p), { path: realOr(p), files });
|
|
425
|
+
} else if (depth > 1 && entries.length < 400) look(p, depth - 1);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
for (const r of [...new Set(roots.map(realOr))]) look(r, r === realOr(home) ? 1 : 2);
|
|
429
|
+
return [...found.values()];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** studio_fold: the earlier folder's notes into notes/earlier/, or (remove) the folder into the Trash. */
|
|
433
|
+
async function foldEarlier(ctx, root, a) {
|
|
434
|
+
const from = realOr(expandHome(a.from) ?? '');
|
|
435
|
+
const known = ctx.note(root).earlier ?? earlierFolders(ctx, [readStudio(root).name, readStudio(root).slug, basename(root)], root).map((e) => e.path);
|
|
436
|
+
if (!known.includes(from)) return fail(`${a.from} is not an earlier folder found for this studio (${known.join(', ') || 'none was found'}); nothing was touched`);
|
|
437
|
+
if (!existsSync(from)) return fail(`${from} is not there any more`);
|
|
438
|
+
if (a.remove === true) {
|
|
439
|
+
const trash = join(homedir(), '.Trash');
|
|
440
|
+
if (process.platform !== 'darwin' || !existsSync(trash)) return fail(`Moving a folder to the Trash works on a Mac only: the person can remove ${from} themselves.`);
|
|
441
|
+
const to = join(trash, `${basename(from)} ${new Date().toISOString().slice(0, 19).replace(/:/g, '.')}`);
|
|
442
|
+
try { renameSync(from, to); } catch (error) { return fail(`${from} could not be moved to the Trash (${error.code ?? error.message}): the person can remove it themselves`); }
|
|
443
|
+
ctx.note(root).earlier = known.filter((p) => p !== from);
|
|
444
|
+
return ok(`Moved ${from} to the Trash (as "${basename(to)}"); the person can put it back from there.`);
|
|
445
|
+
}
|
|
446
|
+
const dest = join(root, 'notes', 'earlier', basename(from));
|
|
447
|
+
const copied = [];
|
|
448
|
+
let total = 0;
|
|
449
|
+
const walk = (dir, rel) => {
|
|
450
|
+
let entries = [];
|
|
451
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
452
|
+
for (const e of entries) {
|
|
453
|
+
if (copied.length >= 60 || total > 2 * 1024 * 1024) return;
|
|
454
|
+
if (e.name.startsWith('.') || SKIP.has(e.name)) continue;
|
|
455
|
+
const p = join(dir, e.name);
|
|
456
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
457
|
+
if (e.isDirectory()) { walk(p, r); continue; }
|
|
458
|
+
if (!e.isFile() || !TEXT_NOTES.has(extname(e.name).toLowerCase())) continue;
|
|
459
|
+
const size = statSync(p).size;
|
|
460
|
+
if (size > 200 * 1024) continue;
|
|
461
|
+
mkdirSync(dirname(join(dest, r)), { recursive: true });
|
|
462
|
+
copyFileSync(p, join(dest, r));
|
|
463
|
+
copied.push(r); total += size;
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
walk(from, '');
|
|
467
|
+
let shown = '';
|
|
468
|
+
for (const r of copied.filter((f) => /\.(md|markdown|txt)$/i.test(f))) {
|
|
469
|
+
if (shown.length > 30_000) break;
|
|
470
|
+
shown += `\n--- ${r}\n${readFileSync(join(dest, r), 'utf8').split('\n').slice(0, 80).join('\n')}\n`;
|
|
471
|
+
}
|
|
472
|
+
ctx.note(root).changed = true;
|
|
473
|
+
return ok(`Copied ${copied.length} note file${copied.length === 1 ? '' : 's'} from ${from} into notes/earlier/${basename(from)}/ (the old folder is unchanged). Fold its premise into the plan with the person (the game's CODEX.md), then ask whether to remove the old folder (studio_fold with remove: true moves it to the Trash).${shown ? `\n${shown}` : ''}`, { kind: 'fold', from, to: `notes/earlier/${basename(from)}`, files: copied });
|
|
474
|
+
}
|
|
475
|
+
|
|
402
476
|
/* ------------------------------------------------------------------ a studio from GitHub */
|
|
403
477
|
|
|
404
478
|
/** owner/name of a checkout's origin remote, lowercased, or null. */
|
|
@@ -517,19 +591,34 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
517
591
|
// Compared on real paths (a temporary or linked folder may have two names).
|
|
518
592
|
const dir = join(realOr(dirname(asked)), basename(asked));
|
|
519
593
|
if (!inside(base, dir) || dir === base) return fail(`a studio goes in its own folder inside ${ctx.studiosDir}`);
|
|
594
|
+
// An earlier attempt for this name (a folder that is not a studio: notes, a plan, a charter), here or in the
|
|
595
|
+
// usual project folders: the person is offered to fold its premise in (studio_fold), never surprised by it.
|
|
596
|
+
const earlier = earlierFolders(ctx, [name, slugify(name), basename(dir)], dir);
|
|
597
|
+
let at = dir;
|
|
598
|
+
if (existsSync(at) && readdirSync(at).some((f) => !['.DS_Store', '.git'].includes(f)) && !existsSync(join(at, 'studio.json'))) at = `${dir}-studio`;
|
|
520
599
|
let made;
|
|
521
|
-
try { made = newStudio(
|
|
600
|
+
try { made = newStudio(at, { name, install: false, ...(ctx.directory ? { homie: ctx.directory } : {}) }); } catch (error) { return fail(error.message); }
|
|
522
601
|
ctx.current = made.dir;
|
|
602
|
+
ctx.note(made.dir).earlier = earlier.map((e) => e.path);
|
|
523
603
|
let install = null;
|
|
524
604
|
if (ctx.install) {
|
|
525
605
|
try { install = installJob(made.dir); } catch (error) { return fail(`${name} is made at ${made.dir}, but ${error.message}`); }
|
|
526
606
|
await waitJob(install, Math.min(ctx.waitMs, 35_000));
|
|
527
607
|
}
|
|
528
608
|
const r = await setupCard(ctx, made.dir, { install, made: { dir: made.dir, wrote: made.wrote } });
|
|
529
|
-
|
|
609
|
+
const fold = earlier.length ? `\n\nAn earlier folder for this name is on this computer, and it is not a studio: ${earlier.map((e) => `${e.path} (${e.files.join(', ') || 'empty'})`).join('; ')}. ASK the person: fold its premise into ${name}? With their yes, studio_fold { "from": "<that folder>" } copies its notes into the studio (notes/earlier/) and shows them to you, so the plan can use them. Then, only with another yes, studio_fold { "from": "<that folder>", "remove": true } moves the old folder to the Trash.` : '';
|
|
610
|
+
r.content[0].text = `Made ${name} at ${made.dir} (no game yet: its home page says "First game coming soon").\nWrote: ${made.wrote.join(', ')}\n${!install ? '' : install.endedAt ? (install.code === 0 ? 'Its toolkit is installed.' : `npm install failed: ${whyOf(install)}`) : `Its toolkit is installing (job ${install.id}).`}${fold}\n\n${r.content[0].text}`;
|
|
611
|
+
if (r.structuredContent) r.structuredContent.earlier = earlier;
|
|
530
612
|
return r;
|
|
531
613
|
},
|
|
532
614
|
},
|
|
615
|
+
{
|
|
616
|
+
name: 'studio_fold', title: 'Fold an earlier folder into the studio',
|
|
617
|
+
description: 'An earlier folder for this studio\'s name that is not a studio (one studio_scaffold found and named): copy its notes into the studio (notes/earlier/<folder>/, text files only) and read them, so its premise goes into the plan; or, with remove: true, move the old folder to the Trash. Only with the person\'s yes for each; it never touches any other folder.',
|
|
618
|
+
inputSchema: { type: 'object', properties: { from: str('The earlier folder studio_scaffold named'), remove: { type: 'boolean', description: 'Move the earlier folder to the Trash (only with the person\'s yes)' }, ...STUDIO_ARG }, required: ['from'] },
|
|
619
|
+
annotations: { title: 'Fold an earlier folder in', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
620
|
+
run: async (a) => foldEarlier(ctx, ctx.root(a.studio), a),
|
|
621
|
+
},
|
|
533
622
|
{
|
|
534
623
|
name: 'studio_install', title: 'Install the studio\'s dependencies',
|
|
535
624
|
description: 'npm install in the studio: its pinned toolkit and Wrangler, and anything its package.json gained (after an upgrade, a new pin, or a library a game needs). Runs in the background; studio_job follows it.',
|
|
@@ -569,14 +658,14 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
569
658
|
},
|
|
570
659
|
{
|
|
571
660
|
name: 'game_demo', title: 'See a working game',
|
|
572
|
-
description: 'Step 2: a live multiplayer game on Homie Arcade to play right now, made with this same toolkit, with NOTHING copied into the studio. Give the person the Play link:
|
|
661
|
+
description: 'Step 2: a live multiplayer game on Homie Arcade to play right now, made with this same toolkit, with NOTHING copied into the studio. Give the person the Play link: THEY open it (the link in your reply, or Play on a card); never open a browser or run a command to open it yourself. Two browser tabs (or a phone and a computer) are two players in the same room; bots fill the empty seats. Copy a starter into the studio (game_make) only if they ask.',
|
|
573
662
|
inputSchema: { type: 'object', properties: { ...STUDIO_ARG } },
|
|
574
663
|
annotations: { title: 'See a working game', ...RO, openWorldHint: true },
|
|
575
664
|
run: async (a) => {
|
|
576
665
|
const root = ctx.root(a.studio, { need: false });
|
|
577
666
|
const r = await demoGames();
|
|
578
667
|
if (root) ctx.note(root).demo = true;
|
|
579
|
-
return ok(formatDemo(r), { kind: 'demo', ...r });
|
|
668
|
+
return ok(`${formatDemo(r)}\n\nFor you, the AI: give the person this Play link in your reply; they open it themselves. Do not open a browser or run any command to open it.`, { kind: 'demo', ...r });
|
|
580
669
|
},
|
|
581
670
|
},
|
|
582
671
|
{
|
|
@@ -705,7 +794,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
705
794
|
return ok([
|
|
706
795
|
`${slow ? 'Starting' : 'Running'} here: ${url}/${games.length ? '' : ' (the home page: "First game coming soon")'}`,
|
|
707
796
|
...games.map((x) => ` ${x.name ?? x.id}: ${url}/${x.id}/play`),
|
|
708
|
-
g ? `
|
|
797
|
+
g ? `The person opens ${url}/${g.id}/play in two browser tabs: two players, one room. Give them the address; do not open a browser for them.` : 'Give the person the address; do not open a browser for them.',
|
|
709
798
|
].filter(Boolean).join('\n'), { kind: 'preview', url, games: games.map((x) => ({ id: x.id, play: `${url}/${x.id}/play` })) });
|
|
710
799
|
},
|
|
711
800
|
},
|
|
@@ -901,7 +990,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
901
990
|
annotations: { title: 'Read a file', ...RO },
|
|
902
991
|
run: async (a) => {
|
|
903
992
|
const r = readStudioFile(ctx.root(a.studio), a.path, { offset: a.offset, limit: a.limit });
|
|
904
|
-
if (r.image) return { content: [{ type: 'image', data: r.image.data, mimeType: r.image.mimeType }, { type: 'text', text: `${r.rel} (${Math.round(r.bytes / 1024)} KB)` }] };
|
|
993
|
+
if (r.image) return { content: [{ type: 'image', data: r.image.data, mimeType: r.image.mimeType }, { type: 'text', text: `${r.rel} (${r.shrunk ? `a smaller JPEG copy, ${Math.round(r.bytes / 1024)} KB, of a ${Math.round(r.from / 1024)} KB picture; the file is unchanged` : `${Math.round(r.bytes / 1024)} KB`})` }] };
|
|
905
994
|
return ok(`${r.rel} (lines ${r.from}-${r.to} of ${r.lines}${r.more ? '; more with offset' : ''})\n${r.text}`);
|
|
906
995
|
},
|
|
907
996
|
},
|
|
@@ -981,6 +1070,9 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
981
1070
|
},
|
|
982
1071
|
});
|
|
983
1072
|
}
|
|
1073
|
+
// Every tool says whose it is: a Claude app may also have the Homie house app's own MCP server (its room and TV
|
|
1074
|
+
// tools), a different server with different tools.
|
|
1075
|
+
for (const t of tools) if (!t.description.startsWith('Homie Studio')) t.description = `Homie Studio: ${t.description}`;
|
|
984
1076
|
return tools;
|
|
985
1077
|
}
|
|
986
1078
|
|
package/lib/mcp.mjs
CHANGED
|
@@ -22,12 +22,13 @@ import { join } from 'node:path';
|
|
|
22
22
|
import { PACKAGE_ROOT } from './studio.mjs';
|
|
23
23
|
import { StudioContext, UI, availability, toolDefs } from './mcp-tools.mjs';
|
|
24
24
|
import { stopAllJobs, toolPath } from './jobs.mjs';
|
|
25
|
+
import { shrinkPictureData } from './pictures.mjs';
|
|
25
26
|
import { STUDIO_VERSION } from './version.mjs';
|
|
26
27
|
|
|
27
28
|
export const PROTOCOLS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
28
29
|
export const MCP_APP_MIME = 'text/html;profile=mcp-app';
|
|
29
30
|
|
|
30
|
-
export const INSTRUCTIONS = `Homie Studio, on this computer: make game studios and their multiplayer web games, music and videos, run them here, and put them online on the studio's own Cloudflare. These tools do the work in this chat; the person never types a command and never needs a terminal. A studio is a folder in the studios folder; the file tools (file_list, file_read, file_edit, file_write, file_search) work inside it.
|
|
31
|
+
export const INSTRUCTIONS = `Homie Studio, on this computer: make game studios and their multiplayer web games, music and videos, run them here, and put them online on the studio's own Cloudflare. (It is not the Homie house app: that app's own server, with its room and TV tools, is a different one.) These tools do the work in this chat; the person never types a command and never needs a terminal. A studio is a folder in the studios folder; the file tools (file_list, file_read, file_edit, file_write, file_search) work inside it.
|
|
31
32
|
|
|
32
33
|
A new studio follows one checklist, in order and never ahead. Show it in your first reply and again, ticked, as each step ends:
|
|
33
34
|
0. Setup status (setup_status): what this computer and their accounts have; optional rows never block.
|
|
@@ -39,8 +40,12 @@ A new studio follows one checklist, in order and never ahead. Show it in your fi
|
|
|
39
40
|
6. Playtest it (playtest), then put it online: studio_deploy with plan: true first (say what it creates and costs, free), cloudflare_login when not signed in (they approve once in their browser), studio_deploy, then studio_publish.
|
|
40
41
|
If they ask for everything at once, show the list, make your own choices for steps 2 to 4 in one line each, and go on.
|
|
41
42
|
|
|
43
|
+
If studio_scaffold finds an earlier folder of the studio's name that is not a studio, ask the person whether to fold its premise in (studio_fold), and remove it only with a second yes.
|
|
44
|
+
|
|
42
45
|
A studio made elsewhere (on a phone, with Deploy to Cloudflare, so it lives on GitHub): studio_open with its repo clones it into the studios folder with this computer's own GitHub sign-in; github_login signs the computer in with GitHub's one-time code. Never ask for a token.
|
|
43
46
|
|
|
47
|
+
Links are the person's to open: give them in your reply (the cards have their own buttons). Never open a browser or run a command such as open to open one yourself.
|
|
48
|
+
|
|
44
49
|
Long work (npm install, check, playtest, deploy, renders) runs in the background: the tool answers at once with a card that follows it, and build_progress or studio_job reads where it is. studio_guide has Homie's full guide for each job (game, plan, port, playtest, publish, music, sound, art, video). Never put a key or password in a file or the chat. If Homie's homie.rocks connector is connected too, its tools of the same names say what to run; these run it.`;
|
|
45
50
|
|
|
46
51
|
const CARD_FILES = { [UI.setup]: 'setup.js', [UI.build]: 'build.js', [UI.studio]: 'studio.js', [UI.codex]: 'codex.js' };
|
|
@@ -64,6 +69,52 @@ export function cardCsp(uri) {
|
|
|
64
69
|
return { connectDomains: [], resourceDomains: [] };
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
/*
|
|
73
|
+
* ONE ANSWER, AT MOST RESULT_MAX. The Claude desktop app refuses a tool result over 1 MB. Every tools/call answer goes
|
|
74
|
+
* through fitResult on its way out: pictures first (a smaller copy, else left out), then the card's pictures, then the
|
|
75
|
+
* text (cut, and saying so), and last the card's data itself. What was left out is said in the answer's text.
|
|
76
|
+
*/
|
|
77
|
+
export const RESULT_MAX = 900_000;
|
|
78
|
+
const sizeOf = (r) => JSON.stringify(r).length;
|
|
79
|
+
|
|
80
|
+
/** Every data: URL in a card's data (a feed's preview, a codex's art, a studio's covers) taken out. */
|
|
81
|
+
function withoutDataUrls(v) {
|
|
82
|
+
if (typeof v === 'string') return v.startsWith('data:') && v.length > 1024 ? null : v;
|
|
83
|
+
if (Array.isArray(v)) return v.map(withoutDataUrls);
|
|
84
|
+
if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, withoutDataUrls(x)]));
|
|
85
|
+
return v;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function fitResult(result, { max = RESULT_MAX, shrink = shrinkPictureData } = {}) {
|
|
89
|
+
if (!result || typeof result !== 'object' || sizeOf(result) <= max) return result;
|
|
90
|
+
const r = JSON.parse(JSON.stringify(result));
|
|
91
|
+
const said = [];
|
|
92
|
+
const content = Array.isArray(r.content) ? r.content : (r.content = []);
|
|
93
|
+
for (let i = 0; i < content.length && sizeOf(r) > max; i++) {
|
|
94
|
+
const c = content[i];
|
|
95
|
+
if (c?.type !== 'image') continue;
|
|
96
|
+
const small = shrink(c.data, c.mimeType);
|
|
97
|
+
if (small && small.data.length < c.data.length) { content[i] = { ...c, ...small }; said.push('a picture went as a smaller copy'); }
|
|
98
|
+
else { content[i] = { type: 'text', text: '(A picture was left out: too big for one answer.)' }; said.push('a picture was left out'); }
|
|
99
|
+
}
|
|
100
|
+
if (sizeOf(r) > max && r.structuredContent) { r.structuredContent = withoutDataUrls(r.structuredContent); said.push('the card shows no pictures this time'); }
|
|
101
|
+
if (sizeOf(r) > max) {
|
|
102
|
+
// The longest text goes down to what fits, cut at a line's end.
|
|
103
|
+
const texts = content.filter((c) => c?.type === 'text' && typeof c.text === 'string').sort((a, b) => b.text.length - a.text.length);
|
|
104
|
+
for (const t of texts) {
|
|
105
|
+
const over = sizeOf(r) - max;
|
|
106
|
+
if (over <= 0) break;
|
|
107
|
+
const keep = Math.max(0, t.text.length - over - 400);
|
|
108
|
+
const cut = t.text.slice(0, keep);
|
|
109
|
+
t.text = `${cut.slice(0, Math.max(cut.lastIndexOf('\n'), Math.floor(keep * 0.9)))}\n…`;
|
|
110
|
+
said.push('the text was cut');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (sizeOf(r) > max && r.structuredContent) { delete r.structuredContent; said.push('the card had no room for its data'); }
|
|
114
|
+
if (said.length) content.push({ type: 'text', text: `(This answer was over ${Math.round(max / 1000)} KB, the most one answer may be here: ${[...new Set(said)].join('; ')}.)` });
|
|
115
|
+
return r;
|
|
116
|
+
}
|
|
117
|
+
|
|
67
118
|
export const PROMPTS = [
|
|
68
119
|
{ name: 'new-studio', title: 'Set up a game studio', description: 'Make a game studio on this computer and go through Homie\'s new-studio checklist.', arguments: [{ name: 'name', description: 'The studio\'s name', required: false }] },
|
|
69
120
|
{ name: 'make-multiplayer', title: 'Make my game multiplayer', description: 'Port an existing single-player web game on this computer into a studio.', arguments: [{ name: 'folder', description: 'The game\'s folder', required: true }] },
|
|
@@ -122,7 +173,7 @@ export async function serveMcp({ studios = null, skills = null, cwd = process.cw
|
|
|
122
173
|
let result;
|
|
123
174
|
try { result = await tool.run(params?.arguments ?? {}); } catch (e) { result = { content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }], isError: true }; }
|
|
124
175
|
log(`${name} ${result?.isError ? 'failed' : 'ok'} in ${Date.now() - t0} ms`);
|
|
125
|
-
reply(result);
|
|
176
|
+
reply(fitResult(result));
|
|
126
177
|
// A media provider set up meanwhile adds its tool.
|
|
127
178
|
const before = names;
|
|
128
179
|
avail = availability();
|
package/lib/pictures.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PICTURES SMALL ENOUGH FOR ONE ANSWER. The Claude desktop app refuses a tool result over 1 MB, and a picture travels
|
|
3
|
+
* as base64 (a third bigger). So a picture a tool hands back is at most PICTURE_MAX raw: a bigger one becomes a
|
|
4
|
+
* smaller JPEG copy (ffmpeg, else macOS's own sips), stepping down in width until it fits; with neither, it is
|
|
5
|
+
* refused, naming the file, so the person can open it themselves. The original file is never changed.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { extname, join } from 'node:path';
|
|
11
|
+
import { which } from './jobs.mjs';
|
|
12
|
+
|
|
13
|
+
export const PICTURE_MAX = 600 * 1024;
|
|
14
|
+
const SOURCE_MAX = 40 * 1024 * 1024;
|
|
15
|
+
export const PICTURE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
|
|
16
|
+
const WIDTHS = [1600, 1200, 900, 640, 480];
|
|
17
|
+
|
|
18
|
+
/** Which shrinker this computer has: { kind, bin } or null. */
|
|
19
|
+
export function shrinker() {
|
|
20
|
+
const ff = which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg');
|
|
21
|
+
if (ff) return { kind: 'ffmpeg', bin: ff };
|
|
22
|
+
if (process.platform === 'darwin' && existsSync('/usr/bin/sips')) return { kind: 'sips', bin: '/usr/bin/sips' };
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function shrinkTo(tool, src, out, width) {
|
|
27
|
+
const args = tool.kind === 'ffmpeg'
|
|
28
|
+
? ['-v', 'error', '-y', '-i', src, '-frames:v', '1', '-vf', `scale='min(${width},iw)':-2`, '-q:v', '6', out]
|
|
29
|
+
: ['-s', 'format', 'jpeg', '-s', 'formatOptions', '70', '-Z', String(width), src, '--out', out];
|
|
30
|
+
const r = spawnSync(tool.bin, args, { encoding: 'utf8', timeout: 30_000 });
|
|
31
|
+
return r.status === 0 && existsSync(out);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A picture file as MCP image content: { mimeType, data (base64), bytes, shrunk, width? }. Throws, naming `label`,
|
|
36
|
+
* when it cannot be made small enough here.
|
|
37
|
+
*/
|
|
38
|
+
export function pictureFor(path, { label = path, max = PICTURE_MAX, tool = shrinker() } = {}) {
|
|
39
|
+
const size = statSync(path).size;
|
|
40
|
+
const mime = PICTURE_TYPES[extname(path).toLowerCase()] ?? 'image/png';
|
|
41
|
+
if (size <= max) return { mimeType: mime, data: readFileSync(path).toString('base64'), bytes: size, shrunk: false };
|
|
42
|
+
if (size > SOURCE_MAX) throw new Error(`${label} is ${Math.round(size / 1024 / 1024)} MB: too big to look at here; the person can open it themselves`);
|
|
43
|
+
if (!tool) throw new Error(`${label} is ${Math.round(size / 1024)} KB, more than the ${Math.round(max / 1024)} KB a picture may be in one answer, and this computer has no ffmpeg (or sips) to make a smaller copy. The person can open it themselves: ${label}`);
|
|
44
|
+
const tmp = mkdtempSync(join(tmpdir(), 'homie-picture-'));
|
|
45
|
+
try {
|
|
46
|
+
for (const width of WIDTHS) {
|
|
47
|
+
const out = join(tmp, `w${width}.jpg`);
|
|
48
|
+
if (!shrinkTo(tool, path, out, width)) break;
|
|
49
|
+
const bytes = statSync(out).size;
|
|
50
|
+
if (bytes <= max) return { mimeType: 'image/jpeg', data: readFileSync(out).toString('base64'), bytes, shrunk: true, width, from: size };
|
|
51
|
+
}
|
|
52
|
+
} finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
53
|
+
throw new Error(`${label} could not be made smaller than ${Math.round(max / 1024)} KB here; the person can open it themselves: ${label}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A picture already in an answer (base64): a smaller copy that fits, or null when there is none to be had. */
|
|
57
|
+
export function shrinkPictureData(data, mimeType, { max = PICTURE_MAX, tool = shrinker() } = {}) {
|
|
58
|
+
if (!tool || typeof data !== 'string') return null;
|
|
59
|
+
const ext = Object.entries(PICTURE_TYPES).find(([, m]) => m === mimeType)?.[0] ?? '.png';
|
|
60
|
+
const tmp = mkdtempSync(join(tmpdir(), 'homie-picture-'));
|
|
61
|
+
try {
|
|
62
|
+
const src = join(tmp, `in${ext}`);
|
|
63
|
+
writeFileSync(src, Buffer.from(data, 'base64'));
|
|
64
|
+
const pic = pictureFor(src, { max, tool });
|
|
65
|
+
return { mimeType: pic.mimeType, data: pic.data };
|
|
66
|
+
} catch { return null; } finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
67
|
+
}
|
package/lib/progress.mjs
CHANGED
|
@@ -83,6 +83,17 @@ const IMAGE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image
|
|
|
83
83
|
|
|
84
84
|
const now = () => new Date().toISOString();
|
|
85
85
|
/** Plain text of at most `max` characters: no control characters, trimmed. */
|
|
86
|
+
/**
|
|
87
|
+
* Plain text of at most `max` characters, cut at the end of a word with "…" when it is longer: never mid-word, and
|
|
88
|
+
* never mid-number (a log line said "bots, up to)" once, its "6" cut off).
|
|
89
|
+
*/
|
|
90
|
+
export function clip(value, max) {
|
|
91
|
+
const t = plain(value, Number.MAX_SAFE_INTEGER);
|
|
92
|
+
if (t.length <= max) return t;
|
|
93
|
+
const cut = t.slice(0, max - 1);
|
|
94
|
+
const space = cut.lastIndexOf(' ');
|
|
95
|
+
return `${(space > max * 0.5 ? cut.slice(0, space) : cut).replace(/[\s,;:(\u2013\u2014-]+$/u, '')}…`;
|
|
96
|
+
}
|
|
86
97
|
export const plain = (value, max) => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
87
98
|
const dir = (root) => join(root, PROGRESS_DIR);
|
|
88
99
|
const docPath = (root, id) => join(dir(root), `${id}.json`);
|
|
@@ -219,7 +230,7 @@ export async function flushProgress(ms = 6000) {
|
|
|
219
230
|
/* ------------------------------------------------------------------ the document */
|
|
220
231
|
|
|
221
232
|
function pushLog(d, text) {
|
|
222
|
-
d.log = [...(d.log ?? []), { at: now(), text:
|
|
233
|
+
d.log = [...(d.log ?? []), { at: now(), text: clip(text, LIMITS.logLine) }].slice(-LIMITS.logLines);
|
|
223
234
|
}
|
|
224
235
|
|
|
225
236
|
function mutateDoc(root, id, fn) {
|
|
@@ -268,7 +279,8 @@ export class Feed {
|
|
|
268
279
|
if (note !== undefined) s.note = plain(note, LIMITS.note);
|
|
269
280
|
if (state === 'failed' && note) doc.error = plain(note, LIMITS.note);
|
|
270
281
|
if (state === 'running' || state === 'done') doc.error = null;
|
|
271
|
-
|
|
282
|
+
// The note is cut at a word's end, inside its own brackets, so the line keeps its closing ")".
|
|
283
|
+
pushLog(doc, `${s.label}: ${state}${note ? ` (${clip(note, LIMITS.logLine - s.label.length - state.length - 5)})` : ''}`);
|
|
272
284
|
}, { soon: true });
|
|
273
285
|
heartbeat(this.root, this.id, d?.stages?.some((s) => s.state === 'running') && d.state === 'running');
|
|
274
286
|
return d;
|
|
@@ -326,6 +326,60 @@
|
|
|
326
326
|
"## Beta": "9d02638207a26e73"
|
|
327
327
|
}
|
|
328
328
|
}
|
|
329
|
+
},
|
|
330
|
+
"0.14.2": {
|
|
331
|
+
"files": {
|
|
332
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
333
|
+
"README.md": "684b3cca8001673c",
|
|
334
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
335
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
336
|
+
"music/README.md": "b8a828de9311bd28",
|
|
337
|
+
"videos/README.md": "70614388cbb203c7",
|
|
338
|
+
"posts/README.md": "37841aae476bd540",
|
|
339
|
+
"site/README.md": "ca131cd0472464fb",
|
|
340
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
341
|
+
},
|
|
342
|
+
"sections": {
|
|
343
|
+
"AGENTS.md": {
|
|
344
|
+
"(intro)": "214aa46eb3a895d5",
|
|
345
|
+
"## Layout": "f14d641159480e6c",
|
|
346
|
+
"## Commands (all through the pinned CLI in node_modules)": "e579cd6ce3ce342a",
|
|
347
|
+
"## The site": "7c1618431bae6324",
|
|
348
|
+
"## Making games": "63069b31c87a2aa2",
|
|
349
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
350
|
+
"## Running live games (the back office)": "f0566f07cff04113",
|
|
351
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
352
|
+
"## Rules": "969318425c99f5a5",
|
|
353
|
+
"## Beta": "9d02638207a26e73"
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
"0.14.3": {
|
|
358
|
+
"files": {
|
|
359
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
360
|
+
"README.md": "684b3cca8001673c",
|
|
361
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
362
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
363
|
+
"music/README.md": "b8a828de9311bd28",
|
|
364
|
+
"videos/README.md": "70614388cbb203c7",
|
|
365
|
+
"posts/README.md": "37841aae476bd540",
|
|
366
|
+
"site/README.md": "ca131cd0472464fb",
|
|
367
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
368
|
+
},
|
|
369
|
+
"sections": {
|
|
370
|
+
"AGENTS.md": {
|
|
371
|
+
"(intro)": "214aa46eb3a895d5",
|
|
372
|
+
"## Layout": "f14d641159480e6c",
|
|
373
|
+
"## Commands (all through the pinned CLI in node_modules)": "e579cd6ce3ce342a",
|
|
374
|
+
"## The site": "7c1618431bae6324",
|
|
375
|
+
"## Making games": "63069b31c87a2aa2",
|
|
376
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
377
|
+
"## Running live games (the back office)": "f0566f07cff04113",
|
|
378
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
379
|
+
"## Rules": "969318425c99f5a5",
|
|
380
|
+
"## Beta": "9d02638207a26e73"
|
|
381
|
+
}
|
|
382
|
+
}
|
|
329
383
|
}
|
|
330
384
|
}
|
|
331
385
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.3",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.14.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.14.3';
|