@homie-rocks/studio 0.26.1 → 0.28.0

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 CHANGED
@@ -17,9 +17,72 @@ 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.28.0] - 2026-10-03
21
+
22
+ **Plugin 0.29.0** · [#43](https://github.com/homie-rocks/homie/pull/43)
23
+
24
+ Tell Homie: when something confuses you or gets in your way, Claude can offer to send the people who make Homie a short note about it. You see the note word for word first, and nothing is sent without your yes.
25
+
26
+ ### Added
27
+
28
+ - `homie_feedback`, a new tool on both Homie MCP servers (the remote one at homie.rocks, and the local `homie-studio mcp` in Homie for Claude Desktop), with the same name and inputs on each:
29
+ - `draft` (the default) sends nothing. It answers with the note exactly as it would go: its kind (stuck, confusing, idea, praise or bug), the words, and what goes with them (the step or skill, the studio's and the plugin's versions, the app, and whether Claude offered it or you asked). Keys, tokens, private links, code, home folders, email addresses, network addresses and your Cloudflare account's name are taken out first, and the note says what was taken out.
30
+ - `send` sends only the note you were shown. It names the draft, and a note whose words or details changed since then is refused.
31
+ - `decline` records your no.
32
+ - A note goes only to homie.rocks: a studio.json can name Homie's own directory or this computer, nothing else.
33
+ - A Tell Homie card in Claude Desktop and Claude on the web or a phone: the note as it would go, with **Send**, **Edit** (your own words, your kind, an optional reply address) and **Don't send**.
34
+ - In Claude Code, the Homie mod:
35
+ - `/feedback <your words>` shows your note in a Tell Homie pane with Send and Don't send. `/feedback` alone opens the pane to write one, or puts an ask for Claude to draft one in your prompt box. The Studio pane has a **Tell Homie** button (`t`).
36
+ - Every send from Claude waits for **Send** in Claude Code's own question, with the note's exact words. Once you have sent or declined a note in a session, Claude cannot offer another.
37
+ - A note is drawn in the transcript as a framed note.
38
+ - In Codex, Homie's hooks hold every send until your own `proceed <code>`, with the note's exact words in the hold.
39
+ - Skills and the studio's `AGENTS.md` (a new "Telling Homie" section) tell Claude when to offer: after an error it could not fix, when you sound confused or frustrated, and at the end of your first studio setup or first publish. It offers at most once a session, never makes help wait on it, and takes no for an answer.
40
+
41
+ ### Changed
42
+
43
+ - The studio-setup and publish skills, the plugin's README and both MCP servers' instructions describe Tell Homie.
44
+
45
+ ### Upgrade notes
46
+
47
+ - Nothing to do. `homie-studio upgrade` adds the "Telling Homie" section to the studio's `AGENTS.md`. The tool is on the Homie MCP for every app; in Claude Code, update the plugin for `/feedback` and the send hold. Notes are private; homie.rocks/privacy says what is kept.
48
+
49
+ ## [0.27.0] - 2026-10-03
50
+
51
+ **Plugin 0.28.0** · [#42](https://github.com/homie-rocks/homie/pull/42) · [release-2026-10-03-studio-0.27.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-03-studio-0.27.0)
52
+
53
+ Search engines and AI agents read a studio's site correctly: schema.org data on every page, a full VideoGame on every landing, and a sitemap, robots.txt and llms.txt made from what is public.
54
+
55
+ ### Added
56
+
57
+ - Structured data (schema.org JSON-LD) on every generated page, from the studio's own files and nothing else:
58
+ - Home: the studio as an `Organization` (its name, address, logo, share picture and tagline) and its `WebSite`.
59
+ - Each game's landing: a `VideoGame` (co-typed `WebApplication`, as Google asks) with its description and pitch, pictures and screenshots, genre, players (fewest and most), play modes, platforms, the studio as author and publisher, the source's licence (linked to its SPDX page when it names one), what it is based on (a remix's original page, a port's original), its trailer as a `VideoObject`, when it came out and last changed, a Play action and a free-to-play offer.
60
+ - Music: `MusicRecording` (the studio as the artist, its length, its audio file, its cover, its key and date) and a `MusicAlbum` when the music manifest names one. Videos: `VideoObject` (poster, upload date, length, the file itself). Posts: `BlogPosting` and the studio's `Blog`. Games and Rooms: an `ItemList` of the public games. Every page but Home: a `BreadcrumbList`.
61
+ - Never a rating or a review, and no price for anything the shop does not sell. While your shop really sells, each item sold in a game is an add-on of that game's free offer, in real money. The Rooms page's data names the games, never who is playing this minute.
62
+ - `/robots.txt`, `/sitemap.xml`, `/llms.txt` and `/llms-full.txt`, made from your public catalogue:
63
+ - robots.txt lets crawlers read every public page, keeps them off your office, the APIs, accounts and each game's play, TV, watch and socket doors, and names the sitemap. A Preview asks not to be crawled.
64
+ - The sitemap lists Home, Games and each landing, Music, Videos, Rooms, Posts and your own pages, with dates only where a real date says when each changed.
65
+ - llms.txt tells an AI agent what your studio is: each game with its pitch, players, Play, Watch and big-screen links; which games are open to remix, with the licence, the source and the words to say to Claude Code or Codex with Homie; songs, videos, posts with their feeds, your own pages, and homie.rocks's llms.txt. llms-full.txt adds each game's whole description, how to play and credits, and every post's text.
66
+ - A private or invite-only game is in none of them, and a game is offered for remixing only when its source is shared, your Remixable switch is on and its licence allows it.
67
+ - game.json takes `"genre"` (a word, or up to three), `"released"` (the day it came out) and `"schema"` (schema.org properties of your own on the game). studio.json takes `"site": { "schema": { … } }` for the studio, such as `"sameAs"` links to its other pages. Homie's own properties always win, and ratings, reviews and offers are refused (the build says so).
68
+ - Screenshots: pictures in `games/<id>/screenshots/` (or game.json `landing.screenshots`), at most eight, get a band on the landing and go into its VideoGame. A game's genre shows with its room facts.
69
+ - A video's or song's `"date"` in its manifest; without one, when it was made (`made.at`). Search engines show no video without an upload date, so the build says when a video has neither.
70
+ - `<!-- homie:schema -->` in the `<head>` of a page of your own (`site/pages`) puts there the structured data the generated page would carry.
71
+ - `lib/schema-check.mjs` checks every JSON-LD block of a page against schema.org's own vocabulary (types, properties, which type each property is for, value kinds, enumerations) and against what Google documents as required for VideoGame, VideoObject, BreadcrumbList, ItemList, BlogPosting, Organization and MusicRecording.
72
+
73
+ ### Changed
74
+
75
+ - A landing's VideoGame was a short block with a price of 0 and no co-type; it is now the full record above, inside one `@graph` with the page's breadcrumbs.
76
+ - A private or invite-only game's landing, which only its owner and invitees see, answers `x-robots-tag: noindex`.
77
+
78
+ ### Upgrade notes
79
+
80
+ - Nothing to do: `homie-studio upgrade` and a deploy bring all of it. Give each game a `"genre"`, put a few pictures of real play in `games/<id>/screenshots/`, give each video a `"date"` if it has no `made.at`, and add your studio's other pages to `studio.json` `"site": { "schema": { "sameAs": [...] } }`.
81
+ - A `robots.txt`, `sitemap.xml`, `llms.txt` or `llms-full.txt` you already keep in `site/public/` still wins over the made one. A landing or Home page of your own in `site/pages/` is left exactly as it is; add `<!-- homie:schema -->` to its `<head>` to give it the structured data.
82
+
20
83
  ## [0.26.1] - 2026-10-03
21
84
 
22
- **Plugin 0.27.1** · [#41](https://github.com/homie-rocks/homie/pull/41)
85
+ **Plugin 0.27.1** · [#41](https://github.com/homie-rocks/homie/pull/41) · [release-2026-10-03-studio-0.26.1](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-03-studio-0.26.1)
23
86
 
24
87
  In Codex, Homie now holds what the Claude Code mod holds: protected files, deploys, Cloudflare changes, spending past the budget and model downloads wait for your own "proceed", and secrets come out of what Codex reads.
25
88
 
package/README.md CHANGED
@@ -49,6 +49,14 @@ feed; MCP Apps cards (setup, build progress, studio, codex) are served as `ui://
49
49
  answers in plain text. Homie for Claude Desktop (`desktop/` in this repository) is this server as a Desktop
50
50
  Extension: the plain Claude app gets it with one install, and no terminal.
51
51
 
52
+ **Tell Homie** (0.28.0): `homie_feedback` drafts a short note to the people who make Homie (stuck, confusing, idea,
53
+ praise or bug) from what happened, and sends it only after the person has seen it word for word and said yes: its
54
+ card (`ui://homie-studio/feedback`) has Send, Edit and Don't send, and a send must name the draft the person saw,
55
+ with the same words. Keys, home folders, email addresses, code and a workers.dev account name are taken out before
56
+ it is shown (`lib/feedback.mjs`, with the Homie mod's own redaction). Claude offers one at most once a session, and a
57
+ no is final for it. It goes to homie.rocks (`/api/feedback/tell`): a studio.json can name only Homie's own directory or
58
+ this computer, so a borrowed studio cannot send notes elsewhere.
59
+
52
60
  ## From a phone: a one-line hand-off
53
61
 
54
62
  The Claude app on a phone hands a build to a Claude Code cloud session with one line, `Continue building <Studio>:
@@ -107,6 +115,14 @@ in that palette, light or dark as its paper is (0.26.0). Every HTML answer is `n
107
115
  site. A game made with Homie's arcade controls that knocks for a Homie box (`/__homie/call`) is told
108
116
  `not-a-homie`, at the site's root as under the game.
109
117
 
118
+ Search engines and AI agents read a studio correctly (0.27.0): every generated page carries schema.org JSON-LD
119
+ (`worker/schema.mjs`: the studio's `Organization` and `WebSite` on Home, a full `VideoGame` on each landing with its
120
+ players, platforms, licence, remix original, trailer, dates and a free-to-play offer whose add-ons are what the shop
121
+ really sells, `MusicRecording`, `VideoObject`, `BlogPosting`, `ItemList` and `BreadcrumbList`; never a rating), and the
122
+ site makes `/robots.txt`, `/sitemap.xml`, `/llms.txt` and `/llms-full.txt` from its public catalogue
123
+ (`worker/discover.mjs`). `lib/schema-check.mjs` checks every block against schema.org's own vocabulary and what Google
124
+ documents as required. game.json `"schema"` and studio.json `site.schema` add the owner's own properties.
125
+
110
126
  Anyone can **watch** a live room from any player's view at `/<game>/watch?room=<room>` (a Watch button sits beside
111
127
  Join on every room): the game itself, drawn by the watcher's own browser as a watcher that never takes a seat,
112
128
  with a strip of the players to switch between (a tap, keys 1-9, Auto, the whole room). A game draws the followed
@@ -777,7 +793,8 @@ pinned a tarball at `https://homie.rocks/npm/homie-studio-<version>.tgz`, which
777
793
  ## Beta
778
794
 
779
795
  This is a beta. Bugs, port requests and questions go to
780
- <https://github.com/homie-rocks/homie/issues/new/choose>.
796
+ <https://github.com/homie-rocks/homie/issues/new/choose>, or, from inside Claude or Codex, to the people who make
797
+ Homie as a short note you see and send yourself (`homie_feedback`, above).
781
798
 
782
799
  ## License
783
800
 
package/lib/build.mjs CHANGED
@@ -40,7 +40,9 @@ import { join, relative } from 'node:path';
40
40
  import { buildCodexPages } from './codex.mjs';
41
41
  import { servedAssets } from './asset-manifest.mjs';
42
42
  import { buildMedia } from './media.mjs';
43
- import { buildSiteFiles, landingOf, readPosts, readTheme } from './site.mjs';
43
+ import { buildSiteFiles, isoDate, landingOf, readPosts, readTheme } from './site.mjs';
44
+ import { checkJsonLd } from './schema-check.mjs';
45
+ import { SCHEMA_REFUSED } from '../worker/schema.mjs';
44
46
  import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
45
47
  import { SEAT_MAX } from '../worker/seats.mjs';
46
48
  import { STUDIO_VERSION } from './version.mjs';
@@ -152,6 +154,57 @@ function readJson(path) {
152
154
  try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
153
155
  }
154
156
 
157
+ /**
158
+ * When a game came and last changed (its landing's datePublished and dateModified, and the sitemap's lastmod):
159
+ * game.json `"released"` (a day) when the owner says so, else the commit that added its game.json; and the last
160
+ * commit that touched its folder. Nothing from a shallow clone (its first commit would claim every file was made that
161
+ * day) or a folder git does not track.
162
+ */
163
+ export function gameDates(root, g, log = () => {}) {
164
+ const git = (args) => { try { const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 5000 }); return r.status === 0 ? r.stdout.trim() : null; } catch { return null; } };
165
+ const released = isoDate(g.released);
166
+ if (g.released !== undefined && !released) log(`warning: games/${g.id}/game.json "released" is a day (2026-09-30); left out`);
167
+ const shallow = git(['rev-parse', '--is-shallow-repository']);
168
+ if (shallow !== 'false') return released ? { published: released } : null;
169
+ const rel = relative(root, g.dir).split('\\').join('/');
170
+ const added = git(['log', '--diff-filter=A', '--format=%cI', '--', `${rel}/game.json`])?.split('\n').filter(Boolean).pop() ?? null;
171
+ const last = git(['log', '-1', '--format=%cI', '--', rel]) || null;
172
+ const iso = (v) => (v && Number.isFinite(Date.parse(v)) ? new Date(Date.parse(v)).toISOString() : null);
173
+ const out = { published: released ?? iso(added), modified: iso(last) };
174
+ if (out.published && out.modified && Date.parse(out.modified) < Date.parse(out.published)) out.modified = out.published;
175
+ return out.published || out.modified ? Object.fromEntries(Object.entries(out).filter(([, v]) => v)) : null;
176
+ }
177
+
178
+ /**
179
+ * The owner's own structured data (game.json "schema", studio.json "site": { "schema" }): a plain object of schema.org
180
+ * properties put on the game's VideoGame (the studio's Organization), under Homie's own. Ratings, reviews and offers
181
+ * are refused (Homie shows no ratings on the page; prices are shop.json's), and so is anything that is not JSON-LD a
182
+ * page can carry; a property schema.org does not have, or does not put on that type, is warned about.
183
+ */
184
+ export function schemaExtras(value, { where, types, log = () => {} }) {
185
+ if (value === undefined) return null;
186
+ if (!value || typeof value !== 'object' || Array.isArray(value)) { log(`warning: ${where} "schema" is an object of schema.org properties; left out`); return null; }
187
+ const out = {};
188
+ for (const [k, v] of Object.entries(value)) {
189
+ if (SCHEMA_REFUSED.includes(k)) { log(`warning: ${where} schema.${k} is left out: Homie publishes no ${k === 'offers' ? 'prices but the shop\'s (shop.json)' : 'ratings or reviews the page does not show'}`); continue; }
190
+ if (!/^[a-z][A-Za-z0-9]{0,63}$/.test(k)) { log(`warning: ${where} schema "${k.slice(0, 40)}" is not a property name (Homie sets @type and @id); left out`); continue; }
191
+ out[k] = v;
192
+ }
193
+ const json = JSON.stringify(out);
194
+ if (json.length > 8192) { log(`warning: ${where} "schema" is over 8 KB; left out`); return null; }
195
+ const { errors } = checkJsonLd({ '@context': 'https://schema.org', '@type': types, ...out });
196
+ for (const e of errors) log(`warning: ${where} schema: ${e.replace(/^\$: /, '')}`);
197
+ return Object.keys(out).length ? out : null;
198
+ }
199
+
200
+ /** game.json "genre": a word or up to three ("Arcade", ["Racing", "Party"]); its landing and VideoGame say it. */
201
+ export function genreOf(g, log = () => {}) {
202
+ if (g.genre === undefined) return null;
203
+ const list = (Array.isArray(g.genre) ? g.genre : [g.genre]).filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim().slice(0, 40)).slice(0, 3);
204
+ if (!list.length) log(`warning: games/${g.id}/game.json "genre" is a word or a list of up to three; left out`);
205
+ return list.length ? list : null;
206
+ }
207
+
155
208
  /**
156
209
  * A game's netplay manifest: game.json's `netplay` block, over a `netplay.json` beside game.json or in the game's
157
210
  * built output (a ported Vite game ships public/netplay.json), over nothing.
@@ -357,6 +410,11 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
357
410
  // The game's own palette (style.json, its art direction): the play page's buttons wear its paper and ink, so the
358
411
  // page's pills and the game's own HUD are one UI.
359
412
  const ui = uiOf(g);
413
+ // Search engines and agents (0.27.0, worker/schema.mjs): its genre, when it came and last changed, and the owner's
414
+ // own schema.org properties.
415
+ const genre = genreOf(g, log);
416
+ const dates = gameDates(root, g, log);
417
+ const extras = schemaExtras(g.schema, { where: `games/${g.id}/game.json`, types: ['VideoGame', 'WebApplication'], log });
360
418
  return {
361
419
  id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
362
420
  ...(ui ? { ui } : {}),
@@ -384,6 +442,9 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
384
442
  // Its source licence (worker/license.mjs), and, for a remix, what it is a remix of (shown on its landing).
385
443
  license: licenseOf(g.license),
386
444
  ...(remixRow(g.remixOf) ? { remixOf: remixRow(g.remixOf) } : {}),
445
+ ...(genre ? { genre } : {}),
446
+ ...(dates ? { dates } : {}),
447
+ ...(extras ? { schema: extras } : {}),
387
448
  landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
388
449
  };
389
450
  });
@@ -394,11 +455,14 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
394
455
  mkdirSync(join(dist, '_site'), { recursive: true });
395
456
  writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
396
457
  const s = studio.site && typeof studio.site === 'object' ? studio.site : {};
458
+ const studioExtras = schemaExtras(s.schema, { where: 'studio.json site', types: 'Organization', log });
397
459
  const catalogue = {
398
460
  studio: {
399
461
  name: studio.name, slug: studio.slug, version: STUDIO_VERSION,
400
462
  ...(typeof studio.tagline === 'string' && studio.tagline.trim() ? { tagline: studio.tagline.trim().slice(0, 140) } : {}),
401
463
  theme,
464
+ // studio.json "site": { "schema": { … } }: the owner's own schema.org properties on the studio (sameAs, …).
465
+ ...(studioExtras ? { schema: studioExtras } : {}),
402
466
  site: {
403
467
  ...(rows.some((g) => g.id === s.featured) ? { featured: s.featured } : {}),
404
468
  ...(Array.isArray(s.frameAncestors) ? { frameAncestors: s.frameAncestors.filter((o) => /^https:\/\/[a-z0-9.-]+(?::\d+)?$/i.test(String(o))).slice(0, 8) } : {}),
@@ -0,0 +1,181 @@
1
+ /**
2
+ * TELL HOMIE (0.28.0): a short note from a creator to the people who make Homie, drafted by Claude in plain words,
3
+ * shown to the person exactly as it would go, and sent ONLY after they say yes. One module for every road a note
4
+ * takes from this side: the local MCP's `homie_feedback` (lib/mcp-tools.mjs), its card (mcp/ui/feedback.js), and
5
+ * the rules the Homie mod and the remote Homie MCP keep the same (homie.rocks has its own copy of these rules and
6
+ * applies them again when a note arrives).
7
+ *
8
+ * What a note is, and nothing more: its kind (stuck, confusing, idea, praise, bug), the words, the step or skill it
9
+ * is about, the studio and plugin versions, which app it came from, whether Claude offered it or the person asked,
10
+ * and a reply address only when the person typed one. No file, no log, no code, no key, no studio name, no path.
11
+ *
12
+ * Before anything is shown, the words go through the mod's own redaction (lib/redact.mjs, the same rules the mod
13
+ * uses on tool output: keys, tokens, private keys, owner links) and then a note's own: fenced code, home folders
14
+ * (a user's home becomes ~), email addresses, a workers.dev address's account name, network addresses and long
15
+ * opaque strings. What was taken out is listed with the draft, so the person sees it too.
16
+ *
17
+ * THE DRAFT IS THE NOTE. A draft's id is a hash of everything that would be sent; a send carries the id and the same
18
+ * fields, and a send whose fields do not hash to its id is refused. So what goes out is exactly what was shown.
19
+ */
20
+ import { redactText } from './redact.mjs';
21
+
22
+ export const FEEDBACK_KINDS = Object.freeze(['stuck', 'confusing', 'idea', 'praise', 'bug']);
23
+ export const FEEDBACK_APPS = Object.freeze(['claude-code', 'codex', 'claude-desktop', 'claude-ai', 'other']);
24
+ export const FEEDBACK_LIMITS = Object.freeze({ text: 1500, raw: 6000, lines: 30, step: 80, email: 254, version: 32, perSession: 5, offers: 3 });
25
+ export const KIND_LABEL = Object.freeze({ stuck: 'Stuck', confusing: 'Confusing', idea: 'Idea', praise: 'Praise', bug: 'Bug' });
26
+ export const APP_LABEL = Object.freeze({ 'claude-code': 'Claude Code', codex: 'Codex', 'claude-desktop': 'the Claude desktop app', 'claude-ai': 'Claude on the web or a phone', other: 'another app' });
27
+
28
+ /** Where a note goes on a Homie directory (homie.rocks, or a local one for tests). */
29
+ export const feedbackUrl = (directory = 'https://homie.rocks') => `${String(directory || 'https://homie.rocks').replace(/\/+$/, '')}/api/feedback/tell`;
30
+
31
+ const EMAIL = /^[^\s@]{1,64}@[^\s@]{1,255}\.[^\s@]{2,63}$/;
32
+ const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,31}$/;
33
+ const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u2028\u2029\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g;
34
+ const HIDDEN = (what) => `[${what} hidden by Homie]`;
35
+
36
+ /*
37
+ * A note's own rules, after the mod's. Each: what it is (said to the person), the pattern, and its replacement.
38
+ * Order matters: code first (whatever is inside a fence goes whole), then links and addresses.
39
+ */
40
+ const NOTE_RULES = [
41
+ { kind: 'code', re: /```[\s\S]*?(?:```|$)/g, to: () => '[code left out by Homie]' },
42
+ // A workers.dev address names the Cloudflare account, often after its owner: the account part goes.
43
+ // (Labels are bounded, as DNS bounds them: an unbounded one makes this rule quadratic on a long run of hyphens.)
44
+ { kind: 'a workers.dev account name', re: /\b([a-z0-9-]{1,63}\.)?([a-z0-9-]{1,63})(\.workers\.dev)\b/gi, to: (m, sub, account, tail) => `${sub ?? ''}[account]${tail}` },
45
+ { kind: 'a home folder', re: /(?:\/Users\/|\/home\/)([^/\s"'`]+)/g, to: () => '~' },
46
+ { kind: 'a home folder', re: /\b[A-Za-z]:\\Users\\([^\\\s"'`]+)/g, to: () => '~' },
47
+ { kind: 'an email address', re: /\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,253}\.[A-Za-z]{2,63}\b/g, to: () => HIDDEN('an email address') },
48
+ // A network address that is not this computer's own.
49
+ { kind: 'a network address', re: /\b(?!127\.0\.0\.1\b)(?!0\.0\.0\.0\b)(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b/g, to: () => HIDDEN('a network address') },
50
+ // A signed token (three dotted parts, the first two JSON in base64): whole, before the rule below takes one part.
51
+ { kind: 'a token', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, to: () => HIDDEN('a token') },
52
+ // Anything long and opaque that a rule above did not name (a token of a kind nobody listed, a hash, a blob), after
53
+ // a dot too.
54
+ { kind: 'a long code', re: /(?<![A-Za-z0-9_\-+/=])(?=[A-Za-z0-9_\-+/=]*[0-9])(?=[A-Za-z0-9_\-+/=]*[A-Za-z])[A-Za-z0-9_\-+/=]{32,}(?![A-Za-z0-9_\-+/=])/g, to: () => HIDDEN('a long code') },
55
+ ];
56
+
57
+ /** The words of a note as they may go: redacted, plain, and the list of what was taken out. */
58
+ export function redactNote(value) {
59
+ let text = String(value ?? '').replace(/\r\n?/g, '\n').replace(CONTROL, '');
60
+ const taken = [];
61
+ for (const rule of NOTE_RULES.slice(0, 1)) text = applyRule(text, rule, taken);
62
+ const r = redactText(text);
63
+ if (r.hits.length) {
64
+ taken.push(...r.hits.map((h) => (h === 'owner link' ? 'a private link' : `a ${h}`.replace(/^a ([aeiou])/i, 'an $1'))));
65
+ // The mod's words for a link it showed in its pane are not true of a note: here the link is simply gone.
66
+ text = r.text.split('[one-time owner link: the Homie mod showed it to the person in its Studio pane; Claude never sees it]').join(HIDDEN('a private link'));
67
+ }
68
+ for (const rule of NOTE_RULES.slice(1)) text = applyRule(text, rule, taken);
69
+ text = text.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
70
+ return { text, taken: [...new Set(taken)] };
71
+ }
72
+
73
+ function applyRule(text, rule, taken) {
74
+ rule.re.lastIndex = 0;
75
+ return text.replace(rule.re, (...m) => {
76
+ const out = rule.to(...m);
77
+ if (out !== m[0]) taken.push(rule.kind);
78
+ return out;
79
+ });
80
+ }
81
+
82
+ const oneLine = (value, max) => String(value ?? '').replace(CONTROL, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
83
+
84
+ /**
85
+ * A note as it may be shown and sent, or why not: { ok: true, note, taken } | { ok: false, why }.
86
+ * `input`: { kind, text, step?, studioVersion?, pluginVersion?, app?, email?, offered? }.
87
+ */
88
+ export function cleanNote(input = {}) {
89
+ const kind = String(input.kind ?? '').trim().toLowerCase();
90
+ if (!FEEDBACK_KINDS.includes(kind)) return { ok: false, why: `kind is one of ${FEEDBACK_KINDS.join(', ')}` };
91
+ const raw = String(input.text ?? '');
92
+ // Before any rule reads it: a note is short, and a rule's cost grows with what it is given.
93
+ if (raw.length > FEEDBACK_LIMITS.raw) return { ok: false, why: `the note is ${raw.length.toLocaleString('en-US')} characters: keep it under ${FEEDBACK_LIMITS.text.toLocaleString('en-US')} (a short note, never a log or a file)` };
94
+ const { text, taken } = redactNote(raw);
95
+ if (!text) return { ok: false, why: 'the note is empty: write what happened in a sentence or two, in plain words' };
96
+ if (text.length > FEEDBACK_LIMITS.text) return { ok: false, why: `the note is ${text.length} characters: keep it under ${FEEDBACK_LIMITS.text.toLocaleString('en-US')} (a short note, never a log or a file)` };
97
+ if (text.split('\n').length > FEEDBACK_LIMITS.lines) return { ok: false, why: `the note has more than ${FEEDBACK_LIMITS.lines} lines: a short note, never a log or a file` };
98
+ const stepRaw = oneLine(input.step, 400);
99
+ const step = stepRaw ? oneLine(redactNote(stepRaw).text, FEEDBACK_LIMITS.step) || null : null;
100
+ const version = (v) => { const s = String(v ?? '').trim().replace(/^v/, ''); return VERSION.test(s) ? s : null; };
101
+ const app = FEEDBACK_APPS.includes(String(input.app ?? '')) ? String(input.app) : 'other';
102
+ const emailRaw = String(input.email ?? '').trim();
103
+ if (emailRaw && (emailRaw.length > FEEDBACK_LIMITS.email || !EMAIL.test(emailRaw))) return { ok: false, why: 'the reply address is not a whole email address: leave it out, or ask the person for theirs' };
104
+ const note = {
105
+ kind, text, step,
106
+ studioVersion: version(input.studioVersion), pluginVersion: version(input.pluginVersion),
107
+ app, email: emailRaw || null, offered: input.offered === true,
108
+ };
109
+ return { ok: true, note, taken };
110
+ }
111
+
112
+ /** A draft's id: what would be sent, hashed. The same note always has the same id; any change gives another. */
113
+ export async function draftId(note) {
114
+ const canonical = JSON.stringify([note.kind, note.text, note.step, note.studioVersion, note.pluginVersion, note.app, note.email, note.offered]);
115
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical));
116
+ return `fd_${[...new Uint8Array(digest)].slice(0, 12).map((b) => b.toString(16).padStart(2, '0')).join('')}`;
117
+ }
118
+
119
+ /** What goes with the words, in a line the person reads. */
120
+ export function withLine(note) {
121
+ const parts = [
122
+ note.step ? `the step (${note.step})` : null,
123
+ note.studioVersion ? `studio ${note.studioVersion}` : null,
124
+ note.pluginVersion ? `plugin ${note.pluginVersion}` : null,
125
+ APP_LABEL[note.app] ?? null,
126
+ note.offered ? 'that Claude offered it' : 'that you asked to send it',
127
+ ].filter(Boolean);
128
+ return `${parts.join(' · ')}. ${note.email ? `A reply address: ${note.email}.` : 'No reply address.'}`;
129
+ }
130
+
131
+ /** The note, exactly as it would go, as the text a chat shows (for an app with no card). */
132
+ export function noteBlock(note, taken = []) {
133
+ return [
134
+ ` Kind: ${KIND_LABEL[note.kind]}`,
135
+ ...note.text.split('\n').map((l, i, all) => ` ${i === 0 ? '"' : ' '}${l}${i === all.length - 1 ? '"' : ''}`),
136
+ ` Sent with it: ${withLine(note)}`,
137
+ ...(taken.length ? [` Homie took out: ${taken.join(', ')}.`] : []),
138
+ ].join('\n');
139
+ }
140
+
141
+ /** The body a Homie directory takes at /api/feedback/tell. `source`: 'plugin' (this toolkit) or 'mod'. */
142
+ export function noteBody(note, { source, consent }) {
143
+ const body = { kind: note.kind, text: note.text, source, consent, offered: note.offered, app: note.app };
144
+ for (const k of ['step', 'studioVersion', 'pluginVersion', 'email']) if (note[k]) body[k] = note[k];
145
+ return body;
146
+ }
147
+
148
+ /**
149
+ * Send a note the person said yes to: { ok, reference, repeat } | { ok: false, why, status }. Never retried here:
150
+ * a second press is the person's to make, and the directory answers a repeat with the same reference.
151
+ */
152
+ export async function sendNote(note, { directory, source = 'plugin', consent = 'chat', fetchImpl = fetch, timeoutMs = 15_000 } = {}) {
153
+ let res;
154
+ try {
155
+ res = await fetchImpl(feedbackUrl(directory), {
156
+ method: 'POST',
157
+ headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': `homie-studio-feedback (${source})` },
158
+ body: JSON.stringify(noteBody(note, { source, consent })),
159
+ redirect: 'manual',
160
+ signal: AbortSignal.timeout(timeoutMs),
161
+ });
162
+ } catch (error) {
163
+ return { ok: false, status: 0, why: `Homie could not be reached (${error?.name === 'TimeoutError' ? 'no answer in 15 s' : error?.cause?.code ?? error?.message ?? 'no connection'}). Nothing was sent; the person can try again later.` };
164
+ }
165
+ let body = null;
166
+ try { body = JSON.parse(await res.text()); } catch { body = null; }
167
+ if (res.ok && body?.ok === true && typeof body.reference === 'string') return { ok: true, reference: body.reference, repeat: body.repeat === true };
168
+ const said = typeof body?.message === 'string' ? oneLine(body.message, 300) : null;
169
+ if (res.status === 429) return { ok: false, status: 429, why: said ?? 'Homie has had enough notes from this network today. Nothing was sent; try again tomorrow.' };
170
+ if (res.status === 404) return { ok: false, status: 404, why: 'This Homie directory does not take notes yet. Nothing was sent.' };
171
+ return { ok: false, status: res.status, why: `${said ?? `Homie answered ${res.status}`}. Nothing was sent.` };
172
+ }
173
+
174
+ /** Which app a client is, from its MCP initialize clientInfo. */
175
+ export function appOf(clientInfo) {
176
+ const name = String(clientInfo?.name ?? '').toLowerCase();
177
+ if (/codex/.test(name)) return 'codex';
178
+ if (/claude[-_ ]?code/.test(name)) return 'claude-code';
179
+ if (/claude/.test(name)) return 'claude-desktop';
180
+ return 'other';
181
+ }
package/lib/mcp-tools.mjs CHANGED
@@ -37,6 +37,7 @@ import { detectLocalAi } from './local-ai.mjs';
37
37
  import { LAB_PORT, runningLab } from './lab.mjs';
38
38
  import { pictureFor } from './pictures.mjs';
39
39
  import { ART_UI, artToolDefs } from './art-tools.mjs';
40
+ import { FEEDBACK_APPS, FEEDBACK_KINDS, FEEDBACK_LIMITS, appOf, cleanNote, draftId, noteBlock, sendNote, withLine } from './feedback.mjs';
40
41
 
41
42
  export const UI = Object.freeze({
42
43
  setup: 'ui://homie-studio/setup',
@@ -44,6 +45,7 @@ export const UI = Object.freeze({
44
45
  studio: 'ui://homie-studio/studio',
45
46
  codex: 'ui://homie-studio/codex',
46
47
  lab: 'ui://homie-studio/lab',
48
+ feedback: 'ui://homie-studio/feedback',
47
49
  ...ART_UI,
48
50
  });
49
51
 
@@ -72,6 +74,10 @@ export class StudioContext {
72
74
  this.notes = new Map();
73
75
  this.runs = new Map();
74
76
  this.status = null;
77
+ // Tell Homie: this session's drafts (what the person was shown), and whether an offered note was answered.
78
+ this.feedback = { drafts: new Map(), sent: 0, offers: 0, offeredDone: false, declined: false };
79
+ // The MCP client (initialize's clientInfo), which a note names as an app and nothing more.
80
+ this.client = null;
75
81
  }
76
82
 
77
83
  note(root) { if (!this.notes.has(root)) this.notes.set(root, {}); return this.notes.get(root); }
@@ -580,6 +586,110 @@ async function githubLogin(ctx) {
580
586
  return ok(`GitHub's sign-in is waiting for the person: open https://github.com/login/device and enter the code ${code} (job ${job.id}; studio_job says when it is done). The code is GitHub's one-time device code, not a password.`, { kind: 'job', ...jobView(job), deviceCode: code });
581
587
  }
582
588
 
589
+ /* ------------------------------------------------------------------ Tell Homie (lib/feedback.mjs) */
590
+
591
+ /** The plugin's version, from the skills folder this server was given (the plugin's own manifest beside it). */
592
+ function pluginVersion(ctx) {
593
+ if (!ctx.skillsDir) return null;
594
+ for (const f of [join(ctx.skillsDir, '..', '.claude-plugin', 'plugin.json'), join(ctx.skillsDir, '..', 'plugin.json')]) {
595
+ try { const v = JSON.parse(readFileSync(f, 'utf8')).version; if (typeof v === 'string') return v; } catch { /* not here */ }
596
+ }
597
+ return null;
598
+ }
599
+
600
+ /** What this server knows that a note carries: the toolkit's version (the studio's own pin, in a studio), the plugin's, the app, and where notes go. */
601
+ function feedbackFacts(ctx, studioArg) {
602
+ let root = null;
603
+ try { root = ctx.root(studioArg, { need: false }); } catch { root = null; }
604
+ let studio = null;
605
+ try { studio = root ? readStudio(root) : null; } catch { studio = null; }
606
+ const pinned = (() => {
607
+ try { const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); return pinnedVersion(pkg?.devDependencies?.['@homie-rocks/studio'] ?? pkg?.dependencies?.['@homie-rocks/studio']); } catch { return null; }
608
+ })();
609
+ const fromStudio = String(studio?.homie?.directory ?? '');
610
+ // Where notes go: the directory this server was started with (--homie, the operator's own word), else the studio's,
611
+ // and a studio.json (which may be a clone of somebody else's) can only name Homie's own directory or this computer.
612
+ const loopback = (u) => u.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(u.hostname);
613
+ const started = (d) => { try { const u = new URL(d); return u.protocol === 'https:' || loopback(u); } catch { return false; } };
614
+ const homies = (d) => { try { const u = new URL(d); return (u.protocol === 'https:' && /(^|\.)homie\.rocks$/.test(u.hostname)) || loopback(u); } catch { return false; } };
615
+ const directory = (ctx.directory && started(ctx.directory) ? ctx.directory : null) ?? (fromStudio && homies(fromStudio) ? fromStudio : null) ?? 'https://homie.rocks';
616
+ return { studioVersion: pinned ?? STUDIO_VERSION, pluginVersion: pluginVersion(ctx), app: appOf(ctx.client), directory: directory.replace(/\/+$/, ''), studio: root ? basename(root) : null };
617
+ }
618
+
619
+ const hostOfUrl = (u) => { try { return new URL(u).host; } catch { return 'homie.rocks'; } };
620
+
621
+ /**
622
+ * homie_feedback: draft (shows the note exactly as it would go, sends nothing), send (only the note a draft showed,
623
+ * after the person's yes), decline (the person said no: nothing is sent, and no note is offered again this session).
624
+ *
625
+ * A send is tied to its draft by the draft's id alone (a hash of the whole note), never by this process's memory: an
626
+ * app may start a new server between the draft and the yes (a resumed session, a restart), and the remote Homie MCP
627
+ * keeps no session at all. What this process remembers is only a convenience (a send that names just its draft) and
628
+ * the once-a-session rule.
629
+ */
630
+ async function feedbackTool(ctx, a) {
631
+ const action = ['draft', 'send', 'decline'].includes(a.action) ? a.action : 'draft';
632
+ const session = ctx.feedback;
633
+ const facts = feedbackFacts(ctx, a.studio);
634
+ const to = hostOfUrl(facts.directory);
635
+ const view = (state, extra = {}) => ({ kind: 'feedback', state, to, ...extra });
636
+ if (action === 'decline') {
637
+ const kept = a.draft ? session.drafts.get(String(a.draft)) : null;
638
+ session.declined = true;
639
+ if (kept?.note.offered || !kept) session.offeredDone = true;
640
+ if (kept) session.drafts.delete(String(a.draft));
641
+ return ok('Not sent: nothing left this computer. Tell the person so in a few words, and do not offer to send a note again in this session.',
642
+ view('declined', { draft: a.draft ?? null, note: kept?.note ?? null, taken: kept?.taken ?? [], with: kept ? withLine(kept.note) : null, next: 'Not sent. Say so in a few words, and do not offer to send a note again in this session.' }));
643
+ }
644
+ // A send of a draft this process showed sends THAT note (its facts are the ones the person saw, whichever studio is
645
+ // in use now); words that come with it must be its own. A draft it does not know is rebuilt from the call's fields.
646
+ const known = a.draft ? session.drafts.get(String(a.draft)) : null;
647
+ const worded = !(a.text === undefined || a.text === null || a.text === '');
648
+ let c;
649
+ if (action === 'send' && known) {
650
+ const said = worded ? cleanNote({ ...known.note, kind: a.kind ?? known.note.kind, text: a.text }) : null;
651
+ if (said && (!said.ok || said.note.text !== known.note.text || said.note.kind !== known.note.kind)) {
652
+ return fail('Not sent: this is not the note the person was shown (its words or kind changed since the draft). Draft it again, show them the new note, and send that one after their yes.', view('changed', { draft: String(a.draft), note: known.note, taken: known.taken, with: withLine(known.note), studio: facts.studio, why: 'changed since the draft' }));
653
+ }
654
+ c = { ok: true, note: known.note, taken: known.taken };
655
+ } else {
656
+ c = cleanNote({ kind: a.kind, text: a.text, step: a.step, email: a.email, offered: a.offered === true, studioVersion: facts.studioVersion, pluginVersion: facts.pluginVersion, app: facts.app });
657
+ }
658
+ if (!c.ok) return fail(`Not sent: ${c.why}.`, view('invalid', { why: c.why }));
659
+ const id = await draftId(c.note);
660
+ const shown = { draft: id, note: c.note, taken: c.taken, with: withLine(c.note), studio: facts.studio };
661
+ if (action === 'draft') {
662
+ if (c.note.offered && (session.declined || session.offeredDone)) {
663
+ return fail(`Not drafted: ${session.declined ? 'the person said no to a note earlier in this session' : 'a note was already offered in this session'}, and Homie offers at most once a session. Do not offer again. If the person asks, in their own words, to tell Homie something, draft it with offered: false.`, view('invalid', { why: 'offered once already this session' }));
664
+ }
665
+ // An offer nobody answered may be reworded a few times (the person asked for a change); it is not asked over and over.
666
+ if (c.note.offered && !session.drafts.has(id)) {
667
+ if (session.offers >= FEEDBACK_LIMITS.offers) return fail('Not drafted: a note was offered several times in this session and the person has not said yes. Do not offer again. If they ask to tell Homie something, draft it with offered: false.', view('invalid', { why: 'offered several times already' }));
668
+ session.offers += 1;
669
+ }
670
+ session.drafts.set(id, { note: c.note, taken: c.taken, at: Date.now() });
671
+ const next = `Nothing has been sent. Show the person this note exactly as it is and ask whether to send it. Only after they say yes in their own words: homie_feedback { "action": "send", "draft": "${id}" } with the same kind and text. If they want it worded differently, draft it again in their words. If they say no: homie_feedback { "action": "decline", "draft": "${id}" }, say nothing was sent, and do not offer again in this session.`;
672
+ return ok([
673
+ 'Nothing has been sent. Show the person this note exactly as it is (where a card shows, it has Send, Edit and Don\'t send), and ask whether to send it to Homie:',
674
+ '',
675
+ noteBlock(c.note, c.taken),
676
+ '',
677
+ `Send it only after they say yes in their own words: homie_feedback { "action": "send", "draft": "${id}" } with the same kind and text. If they want it worded differently, draft it again in their words and show the new note. If they say no: homie_feedback { "action": "decline", "draft": "${id}" }, tell them nothing was sent, and do not offer again in this session. It goes privately to the people who make Homie (${to}).`,
678
+ ].join('\n'), view('draft', { ...shown, next }));
679
+ }
680
+ // send
681
+ if (!a.draft) return fail(`Not sent: a send names the draft the person saw (homie_feedback { "action": "draft", … } first, show it, and send its "draft" id after their yes).`, view('invalid', { why: 'no draft' }));
682
+ if (String(a.draft) !== id) return fail('Not sent: this is not the note the person was shown (its words or details changed since the draft). Draft it again, show them the new note, and send that one after their yes.', view('changed', { ...shown, why: 'changed since the draft' }));
683
+ if (session.sent >= FEEDBACK_LIMITS.perSession) return fail(`Not sent: ${FEEDBACK_LIMITS.perSession} notes is the most one session sends.`, view('invalid', { ...shown, why: 'too many this session' }));
684
+ const r = await sendNote(c.note, { directory: facts.directory, source: 'plugin', consent: ['card', 'dialog'].includes(a.by) ? a.by : 'chat' });
685
+ if (!r.ok) return fail(`Not sent: ${r.why}`, view('failed', { ...shown, why: r.why }));
686
+ session.sent += 1;
687
+ session.drafts.delete(id);
688
+ if (c.note.offered) session.offeredDone = true;
689
+ return ok(`Sent to Homie${r.repeat ? ' (it had this exact note already)' : ''}: reference ${r.reference.slice(0, 8)}. Thank the person in one line. The note is private: only the people who make Homie read it. Do not offer another note in this session.`,
690
+ view('sent', { ...shown, reference: r.reference, next: 'Sent. Thank the person in one line, and do not offer another note in this session.' }));
691
+ }
692
+
583
693
  /* ------------------------------------------------------------------ guides (the plugin's skills) */
584
694
 
585
695
  function guideTopics(ctx) {
@@ -1120,6 +1230,29 @@ export function toolDefs(ctx, avail = {}) {
1120
1230
  return ok(`${v.label}: ${v.state}${v.state === 'running' ? ` (${v.seconds} s so far)` : ` after ${v.seconds} s`}${v.state === 'failed' ? `: ${whyOf(job)}` : ''}${v.result ? `\n${JSON.stringify(v.result, null, 1).slice(0, 8000)}` : v.tail.length ? `\n${v.tail.join('\n')}` : ''}`, { kind: 'job', ...v });
1121
1231
  },
1122
1232
  },
1233
+ {
1234
+ name: 'homie_feedback', title: 'Tell Homie',
1235
+ description: 'A short private note to the people who make Homie, and ONLY with the person\'s yes. action "draft" (the default) sends nothing: it shows the note exactly as it would go (a card with Send, Edit and Don\'t send; the words, the step, the studio and plugin versions and the app, after keys, paths, emails and code are taken out), and you show the same text in the chat and ask. action "send" sends that draft (its "draft" id, with the same kind and text) only after the person said yes in their own words; "decline" records their no. OFFER it (offered: true) at most once a session, at a natural moment: after an error you could not fix, when the person sounds confused or frustrated, or at the end of their first studio setup or first publish. Never nag; a no is final for the session. When the person asks to tell Homie something, draft it with offered: false. Write it in plain words from what happened (what they tried, what they expected, what they saw), never a log, a file, code, a key or someone\'s name; a reply address only if they typed it themselves.',
1236
+ inputSchema: {
1237
+ type: 'object',
1238
+ properties: {
1239
+ action: str('draft (show it; sends nothing), send (after the person\'s yes) or decline (their no)', { enum: ['draft', 'send', 'decline'] }),
1240
+ kind: str('What it is', { enum: [...FEEDBACK_KINDS] }),
1241
+ text: str(`What happened, in plain words, at most ${FEEDBACK_LIMITS.text.toLocaleString('en-US')} characters: your draft, or the person\'s own words`),
1242
+ step: str('Optional: the step or skill it is about, e.g. "studio-setup: put it online" or "publish"'),
1243
+ email: str('Optional: a reply address, only if the person typed it themselves'),
1244
+ offered: { type: 'boolean', description: 'true when you offered it; false when the person asked to tell Homie' },
1245
+ draft: str('With send or decline: the draft id the draft returned (fd_…)'),
1246
+ by: str('How the person said yes: chat (in their own words), card (the card\'s Send) or dialog (Send in Claude Code\'s own question, which the Homie mod asks)', { enum: ['chat', 'card', 'dialog'] }),
1247
+ studioVersion: str('Filled in here from the studio (the remote Homie MCP takes it from you)'),
1248
+ pluginVersion: str('Filled in here from the plugin (the remote Homie MCP takes it from you)'),
1249
+ app: str('Filled in here from this app (the remote Homie MCP takes it from you)', { enum: [...FEEDBACK_APPS] }),
1250
+ ...STUDIO_ARG,
1251
+ },
1252
+ },
1253
+ annotations: { title: 'Tell Homie', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, _meta: ui(UI.feedback),
1254
+ run: async (a) => feedbackTool(ctx, a),
1255
+ },
1123
1256
  {
1124
1257
  name: 'file_list', title: 'List studio files',
1125
1258
  description: 'The files and folders in the studio (or one folder of it), two levels deep. Paths are relative to the studio.',
@@ -1238,4 +1371,4 @@ export function slimCodex(html, { max = 140_000, pictures = true } = {}) {
1238
1371
  return h.length <= max ? h : null;
1239
1372
  }
1240
1373
 
1241
- export const _test = { checklist, feedFor, slimCodex, expandHome };
1374
+ export const _test = { checklist, feedFor, slimCodex, expandHome, feedbackFacts };
package/lib/mcp.mjs CHANGED
@@ -50,10 +50,12 @@ To make one mechanic of a game feel better (a jump, a hit, a dash), game_lab ope
50
50
 
51
51
  A game's look is a set of decisions (render style, palette, light, camera, fonts, the cast and its budgets), automatic from the person's words until they steer or lock one (studio_guide { "topic": "style" }). style_explore draws three directions with the game engine for free; decision_set picks, steers and locks; assets_find searches the free CC0 starter library and asset_add copies a model in with its licence; asset_make makes a prop on the person's own fal account only after they agreed to its price; asset_check, asset_lineup and asset_rights keep it one game, phone-sized and properly licensed (studio_guide { "topic": "models" }).
52
52
 
53
+ Telling Homie (homie_feedback): when the person is stuck, confused or frustrated, after an error you could not fix, or at the end of their first studio setup or first publish, you may OFFER, once a session, to send the people who make Homie a short note about it. Draft it in plain words from what happened (it sends nothing), show it exactly as it would go (its card has Send, Edit and Don't send), and send only after they say yes. Never nag: a no is final for the session. When they ask to tell Homie something, draft it with offered: false.
54
+
53
55
  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.`;
54
56
 
55
- const CARD_FILES = { [UI.setup]: 'setup.js', [UI.build]: 'build.js', [UI.studio]: 'studio.js', [UI.codex]: 'codex.js', [UI.lab]: 'lab.js', [UI.style]: 'style.js', [UI.decision]: 'decision.js', [UI.cast]: 'cast.js', [UI.lineup]: 'lineup.js', [UI.rights]: 'rights.js', [UI.animation]: 'animation.js' };
56
- const CARD_TITLES = { [UI.setup]: 'Studio setup', [UI.build]: 'Build progress', [UI.studio]: 'Studio', [UI.codex]: 'Game Codex', [UI.lab]: 'Game Lab', [UI.style]: 'Style board', [UI.decision]: 'Look decision', [UI.cast]: 'Cast', [UI.lineup]: 'Lineup', [UI.rights]: 'Rights', [UI.animation]: 'Clips' };
57
+ const CARD_FILES = { [UI.setup]: 'setup.js', [UI.build]: 'build.js', [UI.studio]: 'studio.js', [UI.codex]: 'codex.js', [UI.lab]: 'lab.js', [UI.style]: 'style.js', [UI.decision]: 'decision.js', [UI.cast]: 'cast.js', [UI.lineup]: 'lineup.js', [UI.rights]: 'rights.js', [UI.animation]: 'animation.js', [UI.feedback]: 'feedback.js' };
58
+ const CARD_TITLES = { [UI.setup]: 'Studio setup', [UI.build]: 'Build progress', [UI.studio]: 'Studio', [UI.codex]: 'Game Codex', [UI.lab]: 'Game Lab', [UI.style]: 'Style board', [UI.decision]: 'Look decision', [UI.cast]: 'Cast', [UI.lineup]: 'Lineup', [UI.rights]: 'Rights', [UI.animation]: 'Clips', [UI.feedback]: 'Tell Homie' };
57
59
  const UI_DIR = join(PACKAGE_ROOT, 'mcp', 'ui');
58
60
 
59
61
  /** One card's whole document: the shared look and bridge, and the card's own script, as the files are. */
@@ -160,6 +162,8 @@ export async function serveMcp({ studios = null, skills = null, cwd = process.cw
160
162
  switch (method) {
161
163
  case 'initialize': {
162
164
  const asked = params?.protocolVersion;
165
+ // Which app this is (Claude Code, Codex, the Claude desktop app): a note to Homie says so, and nothing more.
166
+ ctx.client = params?.clientInfo ?? null;
163
167
  return reply({
164
168
  protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
165
169
  capabilities: { tools: { listChanged: true }, resources: { listChanged: false }, prompts: { listChanged: false } },